Correct spline property value semantics

This commit is contained in:
ramox81 2026-08-19 16:50:49 +03:00
commit 1a6b61fb3a
8 changed files with 401 additions and 70 deletions

2
Cargo.lock generated
View file

@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
[[package]]
name = "acadrust"
version = "0.4.1"
source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=931c4ab#931c4ab0c590b755e280bed318a35f41c57b139f"
source = "git+https://github.com/ramox81/cadcodec.git?rev=969940a#969940a0e616507b603f3480d2d2780a6d565961"
dependencies = [
"ahash 0.8.12",
"anyhow",

View file

@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] }
rfd = "0.17"
clap = { version = "4", features = ["derive"] }
env_logger = "0.11"
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "931c4ab", features = ["serde"] }
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "969940a", features = ["serde"] }
cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ff950ce", features = ["acis", "offset"] }
dwg-thumbnailer = { path = "crates/dwg-thumbnailer" }
flate2 = "1"
@ -56,6 +56,9 @@ windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_Window
[target.'cfg(target_os = "linux")'.dependencies]
ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] }
[patch."https://github.com/HakanSeven12/cadcodec.git"]
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "969940a" }
[patch.crates-io]
iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }
iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" }

View file

@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] }
# Pulled in only by the `host` feature, which adds the `acadrust`-typed
# `HostApi` runtime surface. The default crate stays dependency-free so engine
# crates and external tooling can depend on the manifest/ribbon contract cheaply.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "931c4ab", optional = true, features = ["serde"] }
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "969940a", optional = true, features = ["serde"] }
# Runtime IPC and serialization (host feature only).
interprocess = { version = "2", optional = true }
@ -37,7 +37,7 @@ serde_json = "1"
serde = { version = "1", features = ["derive"] }
cargo-lock = "11"
# acadrust is scanned at build time to generate the embedded type registry.
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "931c4ab", features = ["serde"] }
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "969940a", features = ["serde"] }
[dev-dependencies]
serde_json = "1"

View file

@ -8,7 +8,7 @@ publish = false
crate-type = ["cdylib"]
[dependencies]
acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "931c4ab", features = ["serde"] }
acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "969940a", features = ["serde"] }
bincode = "1.3"
serde = { version = "1", features = ["derive"] }
console_error_panic_hook = "0.1"

View file

@ -524,6 +524,28 @@ pub fn edit_prop(label: &str, field: &'static str, value: f64) -> Property {
}
}
/// Editable dimensionless number. Unlike coordinates and distances, vector
/// components and NURBS weights must stay plain decimal values when the
/// drawing uses engineering, architectural, or fractional length units.
pub fn edit_scalar_prop(label: &str, field: &'static str, value: f64) -> Property {
let precision = unit_context().luprec.max(0) as usize;
let formatted = format!("{:.*}", precision, value);
let trimmed = if formatted.contains('.') {
formatted.trim_end_matches('0').trim_end_matches('.')
} else {
formatted.as_str()
};
Property {
label: label.into(),
field,
value: PropValue::EditText(if trimmed.is_empty() || trimmed == "-0" {
"0".to_string()
} else {
trimmed.to_string()
}),
}
}
pub fn ro_prop(label: &str, field: &'static str, value: impl Into<String>) -> Property {
Property {
label: label.into(),
@ -563,8 +585,13 @@ pub fn stepper_prop(
pub fn parse_f64(value: &str) -> Option<f64> {
let t = value.trim();
// Angle rows display via AUNITS (#297) — accept those formats back.
t.parse::<f64>().ok().or_else(|| parse_angle_deg(t))
// Length rows display via LUNITS and angle rows via AUNITS. Accept both
// representations back so a value shown by Properties can always be
// committed unchanged.
t.parse::<f64>()
.ok()
.or_else(|| parse_length(t))
.or_else(|| parse_angle_deg(t))
}
/// Parse an angle string the panel displayed via AUNITS back to DEGREES:

View file

@ -294,20 +294,101 @@ pub fn spline_curve(spline: &SplineEnt) -> Option<PlanarCurve> {
.copied()
.collect();
let first = points.first()?;
if !spline_is_planar(spline) {
return None;
}
let normal = normalized(spline.normal);
let elevation = Vec3::from(xyz(*first)).dot(Vec3::from(xyz(normal)));
let plane = ocs_plane(normal, elevation);
let tolerance = PLANARITY_TOLERANCE * scale_of(&points);
let tolerance = spline_point_tolerance(&points);
if !points.iter().all(|p| plane.contains(xyz(*p), tolerance)) {
return None;
}
if !spline.fit_points.is_empty() {
let plane_normal = Vec3::from(plane.normal()?);
for tangent in [spline.begin_tangent, spline.end_tangent] {
let tangent = Vec3::from(xyz(tangent));
if tangent.length_squared() > 1e-18
&& tangent.dot(plane_normal).abs()
> PLANARITY_TOLERANCE * tangent.length().max(1.0)
{
return None;
}
}
}
Some(PlanarCurve::new(
plane,
Curve::Nurbs(spline_to_nurbs_on(spline, &plane)?),
))
}
/// Whether the actual spline definition fits some plane. Fit-point tangents
/// participate because coplanar points can still define a spatial curve when
/// an endpoint derivative leaves their plane.
pub fn spline_is_planar(spline: &SplineEnt) -> bool {
let points = if spline.fit_points.is_empty() {
&spline.control_points
} else {
&spline.fit_points
};
let Some(origin) = points.first().copied() else {
return true;
};
let origin = Vec3::from(xyz(origin));
let mut directions: Vec<Vec3> = points
.iter()
.skip(1)
.map(|point| Vec3::from(xyz(*point)) - origin)
.collect();
if !spline.fit_points.is_empty() {
directions.extend(
[spline.begin_tangent, spline.end_tangent]
.into_iter()
.map(|tangent| Vec3::from(xyz(tangent))),
);
}
let extent = directions
.iter()
.map(|direction| direction.length())
.fold(1.0, f64::max);
let tolerance = PLANARITY_TOLERANCE * extent
+ f64::EPSILON * scale_of(points) * 64.0;
let Some(axis) = directions
.iter()
.copied()
.find(|direction| direction.length() > tolerance)
else {
return true;
};
let Some(normal) = directions.iter().find_map(|direction| {
let cross = axis.cross(*direction);
if cross.length() > tolerance * axis.length().max(1.0) {
cross.normalize()
} else {
None
}
}) else {
return true;
};
directions
.iter()
.all(|direction| direction.dot(normal).abs() <= tolerance)
}
fn spline_point_tolerance(points: &[Vector3]) -> f64 {
let Some(origin) = points.first().copied() else {
return PLANARITY_TOLERANCE;
};
let origin = Vec3::from(xyz(origin));
let extent = points
.iter()
.map(|point| (Vec3::from(xyz(*point)) - origin).length())
.fold(1.0, f64::max);
PLANARITY_TOLERANCE * extent + f64::EPSILON * scale_of(points) * 64.0
}
/// The entity's curve in world XY coordinates.
///
/// The editing commands — TRIM, EXTEND, FILLET, OFFSET — work in plan view,

View file

@ -5,7 +5,8 @@ use cadkernel::space::NurbsCurve3;
use crate::command::EntityTransform;
use crate::entities::common::{
dropdown_grip, edit_prop as edit, parse_f64, ro_prop as ro, round_grip, square_grip,
dropdown_grip, edit_prop as edit, edit_scalar_prop as edit_scalar, parse_f64,
ro_prop as ro, round_grip, square_grip,
};
use crate::entities::traits::RenderConvertible;
use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject};
@ -31,7 +32,8 @@ fn to_render(spl: &Spline) -> RenderEntity {
// A fit spline through points in space is not a planar curve,
// so the kernel has nothing to say about it and the solve
// here remains the only description of its shape.
None if spl.flags.closed || spl.flags.periodic => {
None if spl.flags.periodic => periodic_fit_spline_polyline(spl),
None if spl.flags.closed => {
catmull_rom_polyline(&spl.fit_points, true)
}
None => fit_spline_polyline(spl),
@ -149,7 +151,9 @@ pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> {
if spl.fit_points.len() < 2 {
return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect();
}
return if spl.flags.closed || spl.flags.periodic {
return if spl.flags.periodic {
periodic_fit_spline_polyline(spl)
} else if spl.flags.closed {
catmull_rom_polyline(&spl.fit_points, true)
} else {
fit_spline_polyline(spl)
@ -311,6 +315,151 @@ fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
out
}
/// Spatial counterpart of the planar periodic interpolator. The cyclic
/// derivative system gives the seam the same C² continuity as every interior
/// fit point instead of only drawing a final chord back to the start.
fn periodic_fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> {
let mut points: Vec<[f64; 3]> = spl
.fit_points
.iter()
.map(|point| [point.x, point.y, point.z])
.collect();
if points.len() > 1 {
let first = points[0];
let last = points[points.len() - 1];
let distance2: f64 = (0..3).map(|axis| (last[axis] - first[axis]).powi(2)).sum();
if distance2 <= 1e-18 {
points.pop();
}
}
let count = points.len();
if count < 3 {
return catmull_rom_polyline(&spl.fit_points, true);
}
let step = |from: [f64; 3], to: [f64; 3]| {
let chord = (0..3)
.map(|axis| (to[axis] - from[axis]).powi(2))
.sum::<f64>()
.sqrt()
.max(1e-9);
match spl.knot_parameterization {
2 => 1.0,
1 => chord.sqrt(),
_ => chord,
}
};
let spans: Vec<f64> = (0..count)
.map(|index| step(points[index], points[(index + 1) % count]))
.collect();
let mut matrix = vec![vec![0.0; count]; count];
let mut right = vec![[0.0; 3]; count];
for index in 0..count {
let previous = (index + count - 1) % count;
let next = (index + 1) % count;
let before = spans[previous];
let after = spans[index];
matrix[index][previous] += after;
matrix[index][index] += 2.0 * (before + after);
matrix[index][next] += before;
for axis in 0..3 {
let previous_slope = (points[index][axis] - points[previous][axis]) / before;
let next_slope = (points[next][axis] - points[index][axis]) / after;
right[index][axis] =
3.0 * (after * previous_slope + before * next_slope);
}
}
let Some(slopes) = solve_spatial_system(matrix, right) else {
return catmull_rom_polyline(&spl.fit_points, true);
};
let mut out = Vec::new();
for index in 0..count {
let next = (index + 1) % count;
let span = spans[index];
let point_at = |u: f64| {
let (u2, u3) = (u * u, u * u * u);
let basis = [
2.0 * u3 - 3.0 * u2 + 1.0,
u3 - 2.0 * u2 + u,
-2.0 * u3 + 3.0 * u2,
u3 - u2,
];
let mut point = [0.0; 3];
for axis in 0..3 {
point[axis] = basis[0] * points[index][axis]
+ basis[1] * slopes[index][axis] * span
+ basis[2] * points[next][axis]
+ basis[3] * slopes[next][axis] * span;
}
point
};
let tangent_at = |u: f64| {
let u2 = u * u;
let basis = [
6.0 * u2 - 6.0 * u,
3.0 * u2 - 4.0 * u + 1.0,
-6.0 * u2 + 6.0 * u,
3.0 * u2 - 2.0 * u,
];
let mut tangent = [0.0; 3];
for axis in 0..3 {
tangent[axis] = basis[0] * points[index][axis]
+ basis[1] * slopes[index][axis] * span
+ basis[2] * points[next][axis]
+ basis[3] * slopes[next][axis] * span;
}
tangent
};
let sampled = cadkernel::tessellation::sample_curve3_angle(
point_at,
tangent_at,
cadkernel::tessellation::DEFAULT_ANGLE,
);
out.extend(sampled.into_iter().skip(usize::from(index > 0)));
}
out
}
fn solve_spatial_system(
mut matrix: Vec<Vec<f64>>,
mut right: Vec<[f64; 3]>,
) -> Option<Vec<[f64; 3]>> {
let count = right.len();
for column in 0..count {
let pivot = (column..count).max_by(|&left, &right_index| {
matrix[left][column]
.abs()
.total_cmp(&matrix[right_index][column].abs())
})?;
if matrix[pivot][column].abs() < 1e-14 {
return None;
}
matrix.swap(column, pivot);
right.swap(column, pivot);
for row in column + 1..count {
let factor = matrix[row][column] / matrix[column][column];
for entry in column..count {
matrix[row][entry] -= factor * matrix[column][entry];
}
for axis in 0..3 {
right[row][axis] -= factor * right[column][axis];
}
}
}
let mut result = vec![[0.0; 3]; count];
for row in (0..count).rev() {
for axis in 0..3 {
let known: f64 = (row + 1..count)
.map(|column| matrix[row][column] * result[column][axis])
.sum();
result[row][axis] = (right[row][axis] - known) / matrix[row][row];
}
}
Some(result)
}
/// Slopes used by the spatial fit-point interpolator. Keeping this solve
/// shared lets Properties report the same effective end tangents that the
/// renderer uses when the stored tangent fields mean "automatic".
@ -535,49 +684,6 @@ fn convert_to_fit_method(spline: &mut Spline) -> bool {
true
}
fn is_planar(spline: &Spline) -> bool {
let points = if spline.fit_points.is_empty() {
&spline.control_points
} else {
&spline.fit_points
};
if points.len() <= 3 {
return true;
}
let origin = points[0];
let Some(axis) = points.iter().skip(1).find_map(|point| {
let vector = [point.x - origin.x, point.y - origin.y, point.z - origin.z];
let length2 = vector.iter().map(|value| value * value).sum::<f64>();
(length2 > 1e-18).then_some(vector)
}) else {
return true;
};
let Some(normal) = points.iter().skip(1).find_map(|point| {
let vector = [point.x - origin.x, point.y - origin.y, point.z - origin.z];
let cross = [
axis[1] * vector[2] - axis[2] * vector[1],
axis[2] * vector[0] - axis[0] * vector[2],
axis[0] * vector[1] - axis[1] * vector[0],
];
let length2 = cross.iter().map(|value| value * value).sum::<f64>();
(length2 > 1e-18).then_some(cross)
}) else {
return true;
};
let normal_length = normal.iter().map(|value| value * value).sum::<f64>().sqrt();
points.iter().all(|point| {
let offset = [point.x - origin.x, point.y - origin.y, point.z - origin.z];
let distance = normal
.iter()
.zip(offset)
.map(|(component, value)| component * value)
.sum::<f64>()
.abs()
/ normal_length;
distance <= 1e-8
})
}
fn tangent_is_set(tangent: &acadrust::types::Vector3) -> bool {
tangent.x * tangent.x + tangent.y * tangent.y + tangent.z * tangent.z > 1e-18
}
@ -753,7 +859,7 @@ fn properties(spline: &Spline) -> Vec<PropSection> {
"ctrl_pt_z",
point.map(|value| value.z).unwrap_or(0.0),
),
edit(t!("Weight").as_ref(), "weight", weight),
edit_scalar(t!("Weight").as_ref(), "weight", weight),
]
};
data_points.push(choice_prop(
@ -785,37 +891,37 @@ fn properties(spline: &Spline) -> Vec<PropSection> {
ro(
t!("Planar").as_ref(),
"planar",
yes_no(is_planar(spline)),
yes_no(crate::entities::curve::spline_is_planar(spline)),
),
];
if fit_method {
misc.extend([
edit(
edit_scalar(
t!("Start tangent vector X").as_ref(),
"start_tan_x",
effective_begin_tangent.x,
),
edit(
edit_scalar(
t!("Start tangent vector Y").as_ref(),
"start_tan_y",
effective_begin_tangent.y,
),
edit(
edit_scalar(
t!("Start tangent vector Z").as_ref(),
"start_tan_z",
effective_begin_tangent.z,
),
edit(
edit_scalar(
t!("End tangent vector X").as_ref(),
"end_tan_x",
effective_end_tangent.x,
),
edit(
edit_scalar(
t!("End tangent vector Y").as_ref(),
"end_tan_y",
effective_end_tangent.y,
),
edit(
edit_scalar(
t!("End tangent vector Z").as_ref(),
"end_tan_z",
effective_end_tangent.z,
@ -855,7 +961,17 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
"Chord" => 0,
"Square Root" => 1,
"Uniform" => 2,
"Custom" => 15,
"Custom" => {
// Custom parameterization is an explicit knot/control
// representation, not a fourth automatic spacing rule.
// Materialize the current fit curve before marking it
// custom so rendering and saving cannot silently fall
// back to chord spacing.
if !spline.fit_points.is_empty() && !convert_to_control_method(spline) {
return;
}
15
}
_ => return,
};
return;
@ -947,7 +1063,7 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) {
}
_ => {}
}
spline.flags.planar = is_planar(spline);
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
}
fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) {
@ -985,6 +1101,21 @@ fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) {
}
fn apply_transform(spline: &mut Spline, t: &EntityTransform) {
if let EntityTransform::Mirror {
p1,
p2,
working_normal,
} = t
{
let transform = crate::scene::view::transform::reflection_about_working_line(
*p1,
*p2,
*working_normal,
);
acadrust::Entity::apply_transform(spline, &transform);
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
return;
}
crate::scene::view::transform::apply_standard_entity_transform(spline, t, |entity, p1, p2| {
for cp in &mut entity.control_points {
crate::scene::view::transform::reflect_xy_point(&mut cp.x, &mut cp.y, p1, p2);
@ -993,6 +1124,7 @@ fn apply_transform(spline: &mut Spline, t: &EntityTransform) {
crate::scene::view::transform::reflect_xy_point(&mut fp.x, &mut fp.y, p1, p2);
}
});
spline.flags.planar = crate::entities::curve::spline_is_planar(spline);
}
impl RenderConvertible for Spline {

View file

@ -227,6 +227,91 @@ fn solve_control_points(
Some(result)
}
/// A closed C² cubic through every fit point. The cyclic slope solve makes
/// the first and last derivatives and accelerations agree at the seam; merely
/// repeating the first point in the open interpolator only closes the shape
/// and does not make it periodic.
fn interpolate_periodic(
points: &[[f64; 2]],
parameterization: Parameterization,
) -> Option<NurbsCurve> {
let mut points = points.to_vec();
if points.len() > 1 {
let first = points[0];
let last = points[points.len() - 1];
let dx = last[0] - first[0];
let dy = last[1] - first[1];
if dx * dx + dy * dy <= 1e-18 {
points.pop();
}
}
let count = points.len();
if count < 3 {
return None;
}
let step = |from: [f64; 2], to: [f64; 2]| {
let dx = to[0] - from[0];
let dy = to[1] - from[1];
let chord = (dx * dx + dy * dy).sqrt().max(1e-9);
match parameterization {
Parameterization::Uniform => 1.0,
Parameterization::Centripetal => chord.sqrt(),
Parameterization::Chord => chord,
}
};
let spans: Vec<f64> = (0..count)
.map(|index| step(points[index], points[(index + 1) % count]))
.collect();
let mut matrix = vec![vec![0.0; count]; count];
let mut right = vec![[0.0; 2]; count];
for index in 0..count {
let previous = (index + count - 1) % count;
let next = (index + 1) % count;
let before = spans[previous];
let after = spans[index];
matrix[index][previous] += after;
matrix[index][index] += 2.0 * (before + after);
matrix[index][next] += before;
for axis in 0..2 {
let previous_slope = (points[index][axis] - points[previous][axis]) / before;
let next_slope = (points[next][axis] - points[index][axis]) / after;
right[index][axis] =
3.0 * (after * previous_slope + before * next_slope);
}
}
let slopes = solve_control_points(matrix, right)?;
let mut controls = Vec::with_capacity(3 * count + 1);
let mut boundaries = Vec::with_capacity(count + 1);
controls.push(points[0]);
boundaries.push(0.0);
let mut parameter = 0.0;
for index in 0..count {
let next = (index + 1) % count;
let span = spans[index];
controls.push([
points[index][0] + slopes[index][0] * span / 3.0,
points[index][1] + slopes[index][1] * span / 3.0,
]);
controls.push([
points[next][0] - slopes[next][0] * span / 3.0,
points[next][1] - slopes[next][1] * span / 3.0,
]);
controls.push(points[next]);
parameter += span;
boundaries.push(parameter);
}
let mut knots = vec![0.0; 4];
for boundary in boundaries.iter().take(count).skip(1) {
knots.extend([*boundary; 3]);
}
knots.extend([parameter; 4]);
NurbsCurve::new(3, controls, knots, None)
}
/// [`spline_to_nurbs`] with the points expressed in `plane`'s coordinates.
///
/// The two differ only for a spline whose extrusion normal is not +Z. Where
@ -259,7 +344,15 @@ fn spline_to_nurbs_with(
// No usable control polygon, so this is a fit-point spline.
let mut fit: Vec<[f64; 2]> = spl.fit_points.iter().map(&point).collect();
if spl.flags.closed || spl.flags.periodic {
let parameterization = match spl.knot_parameterization {
2 => Parameterization::Uniform,
1 => Parameterization::Centripetal,
_ => Parameterization::Chord,
};
if spl.flags.periodic {
return interpolate_periodic(&fit, parameterization);
}
if spl.flags.closed {
// The interpolation is a clamped solve and does not model a wrap, so
// a closed spline came back as an open curve that never returned to
// its start — and a TRIM against it then cut nothing along the seam.
@ -278,11 +371,6 @@ fn spline_to_nurbs_with(
};
let start_tangent = tangent(&spl.begin_tangent);
let end_tangent = tangent(&spl.end_tangent);
let parameterization = match spl.knot_parameterization {
2 => Parameterization::Uniform,
1 => Parameterization::Centripetal,
_ => Parameterization::Chord,
};
if !spl.flags.closed && !spl.flags.periodic && spl.fit_tolerance > 0.0 {
fit = fit_within_tolerance(
fit,