use acadrust::entities::Spline; use cadkernel::geom2d::Curve as KernelCurve; use cadkernel::space::{NurbsCurve3, Parameterization}; use crate::command::EntityTransform; use crate::entities::common::{ 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}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::t; pub(crate) fn uses_fit_method(spline: &Spline) -> bool { let degree = spline.degree.max(1) as usize; !spline.fit_points.is_empty() && spline.control_points.len() <= degree } pub(crate) fn shows_fit_points(spline: &Spline) -> bool { !spline.fit_points.is_empty() && (!spline.cv_frame_visible || spline.flags.periodic) } pub(crate) fn shows_control_vertices(spline: &Spline) -> bool { !shows_fit_points(spline) && !(uses_fit_method(spline) && spline.flags.periodic) && control_vertices(spline).len() >= 2 } pub(crate) fn fit_nurbs3(spline: &Spline) -> Option { if !uses_fit_method(spline) { return None; } let mut points: Vec<_> = spline.fit_points.iter() .map(|point| [point.x, point.y, point.z]).collect(); let parameterization = match spline.knot_parameterization { 1 => Parameterization::Centripetal, 2 => Parameterization::Uniform, _ => Parameterization::Chord, }; if spline.flags.periodic { return NurbsCurve3::interpolate_periodic(&points, parameterization); } if spline.flags.closed && points.first() != points.last() { if let Some(first) = points.first().copied() { points.push(first); } } let tangent = |value: acadrust::types::Vector3| { let value = [value.x, value.y, value.z]; (value.iter().map(|component| component * component).sum::() > 1e-18) .then_some(value) }; NurbsCurve3::interpolate_fit(&points, tangent(spline.begin_tangent), tangent(spline.end_tangent), parameterization) } pub(crate) fn nurbs3(spline: &Spline) -> Option { let curve = if uses_fit_method(spline) { fit_nurbs3(spline)? } else { let degree = usize::try_from(spline.degree).ok()?; let weights = if spline.weights.is_empty() { vec![1.0; spline.control_points.len()] } else { spline.weights.clone() }; NurbsCurve3::new_strict( degree, spline.control_points.iter().map(|point| [point.x, point.y, point.z]).collect(), spline.knots.clone(), weights, )? }; Some(curve.with_periodicity(spline.flags.closed || spline.flags.periodic)) } /// NURBS knot locations on a control-point spline, evaluated on its own /// curve: distinct interior knot values (the clamped ends already snap as /// endpoints). Fit-method splines invent their knots at the fit nodes, which /// already snap as endpoints, so they contribute nothing — re-offering them /// would double every marker. pub(crate) fn spline_knot_points(spline: &Spline) -> Vec { const MAX_KNOT_SNAPS: usize = 1024; if uses_fit_method(spline) { return Vec::new(); } let Some(curve) = nurbs3(spline) else { return Vec::new(); }; let (from, to) = curve.domain(); if !(from.is_finite() && to.is_finite()) || !(to > from) { return Vec::new(); } let span = to - from; let mut knots: Vec = curve .knots() .iter() .copied() .filter(|k| k.is_finite() && *k > from + span * 1e-9 && *k < to - span * 1e-9) .collect(); knots.sort_by(f64::total_cmp); knots.dedup(); if knots.len() > MAX_KNOT_SNAPS { let step = knots.len().div_ceil(MAX_KNOT_SNAPS); knots = knots.into_iter().step_by(step).collect(); } knots .into_iter() .map(|k| glam::DVec3::from_array(curve.point_at_knot(k))) .filter(|p| p.is_finite()) .collect() } pub(crate) fn replace_with_nurbs(spline: &mut Spline, curve: &NurbsCurve3) { let mut fit_points = spline.fit_points.clone(); spline.degree = curve.degree() as i32; spline.knots = curve.knots().to_vec(); spline.control_points = curve.control_points().iter() .map(|point| acadrust::types::Vector3::new(point[0], point[1], point[2])).collect(); spline.weights = curve.weights().to_vec(); if !fit_points.is_empty() { if let (Some(first), Some(point)) = (fit_points.first_mut(), spline.control_points.first()) { *first = *point; } if let (Some(last), Some(point)) = (fit_points.last_mut(), spline.control_points.last()) { *last = *point; } } spline.fit_points = fit_points; spline.begin_tangent = acadrust::types::Vector3::ZERO; spline.end_tangent = acadrust::types::Vector3::ZERO; spline.flags.rational = spline.weights.windows(2) .any(|pair| (pair[0] - pair[1]).abs() > 1e-12); spline.flags.closed = false; spline.flags.periodic = false; spline.dwg_flags1 &= !1; spline.dxf_flags &= !(32 | 1024); } /// Conservative world bounds of a fit-method spline. Fit points are interpolation /// constraints, not control-hull vertices, so their box can exclude the curve. /// The shared kernel reconstructs the same spatial interpolation used by the /// curve consumers, then its control hull bounds every point of that curve. pub(crate) fn fit_geometry_bounds(spline: &Spline) -> Option { let curve = fit_nurbs3(spline)?; cadkernel::brep::Aabb::around(curve.control_points().iter().copied()) } fn to_render(spl: &Spline) -> RenderEntity { let n = spl.control_points.len(); if n < 2 { // A fit-point spline (DWG scenario 2 / R2013+) stores only the points // the curve passes through — no control points or knots. Interpolate a // smooth Catmull-Rom curve through them and hand it back as a dense // polyline so it still draws. if spl.fit_points.len() >= 2 { // Drawn from the same interpolation TRIM and snap use, so that // what is on screen is the curve those commands cut. The two had // parted: this drew a C² cubic solved here (or a Catmull-Rom for a // closed spline), while the kernel interpolated its own — near // enough to look right and far enough apart that a trim landed // beside the line it was aimed at. let planar = crate::entities::curve::spline_curve(spl); let pts = match &planar { Some(curve) => crate::entities::curve::curve_points(curve), // 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.periodic => periodic_fit_spline_polyline(spl), None if spl.flags.closed => { catmull_rom_polyline(&spl.fit_points, true) } None => fit_spline_polyline(spl), }; let snap = planar .as_ref() .map(crate::entities::curve::snap_from) .unwrap_or_default(); // Fit points are on the curve, so they stay on offer as ends in // their own right — but never through `key_vertices`, whose // consecutive entries the snap engine joins and offers the middle // of. The chord between two fit points of a curvy spline does not // run along it. let mut snap_pts = snap.snap_pts; snap_pts.extend(spl.fit_points.iter().map(|p| { ( glam::DVec3::new(p.x, p.y, p.z), crate::scene::model::wire_model::SnapHint::Endpoint, ) })); snap_pts.extend( spline_knot_points(spl) .into_iter() .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), ); let key_vertices = if planar.is_some() { Vec::new() } else { spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect() }; return RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(pts), snap_pts, tangent_geoms: vec![], key_vertices, fill_tris: vec![], }; } return RenderEntity { pick_tris: Vec::new(), object: RenderObject::Lines(Vec::new()), snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], fill_tris: vec![], }; } // Snap sources from the spline's own curve. A spline is not a chain of // straight segments, so nothing goes in `key_vertices`: consecutive // entries there are joined and their midpoints offered, and the chord // between two fit points of a curvy spline does not run along it. // // Fit points are on the curve and stay on offer, as ends in their own // right. Control points are not on the curve and are dropped — snapping // to one put the cursor in empty space beside the geometry. let curve_snap = crate::entities::curve::spline_curve(spl) .map(|curve| crate::entities::curve::snap_from(&curve)); let (snap_pts, key_vertices) = match curve_snap { Some(snap) => { let mut points = snap.snap_pts; if uses_fit_method(spl) { points.extend(spl.fit_points.iter().map(|p| { ( glam::DVec3::new(p.x, p.y, p.z), crate::scene::model::wire_model::SnapHint::Endpoint, ) })); } points.extend( spline_knot_points(spl) .into_iter() .map(|p| (p, crate::scene::model::wire_model::SnapHint::Knot)), ); (points, Vec::new()) } // A spline through points in space is not a planar curve, so the // kernel has nothing to say about it. Its stored points remain the // only thing to offer. None => { let source = if uses_fit_method(spl) { &spl.fit_points } else { &spl.control_points }; ( Vec::new(), source.iter().map(|p| [p.x, p.y, p.z]).collect::>(), ) } }; // Sampled through the spline's own curve — the same definition EXTRUDE // and REVOLVE read — and closed back onto its start when the flags say // it is periodic and the two ends do not already meet. let is_closed = spl.flags.closed || spl.flags.periodic; let mut points = crate::entities::curve::spline_curve(spl) .map(|planar| crate::entities::curve::curve_points(&planar)) .unwrap_or_else(|| measurement_polyline(spl)); if is_closed { if let (Some(first), Some(last)) = (points.first().copied(), points.last().copied()) { let gap = ((last[0] - first[0]).powi(2) + (last[1] - first[1]).powi(2) + (last[2] - first[2]).powi(2)) .sqrt(); if gap > 1e-6 { points.push(first); } } } let object = RenderObject::Lines(points); RenderEntity { pick_tris: Vec::new(), object, snap_pts, tangent_geoms: vec![], key_vertices, fill_tris: vec![], } } pub(crate) fn measurement_polyline(spl: &Spline) -> Vec<[f64; 3]> { if uses_fit_method(spl) { if spl.fit_points.len() < 2 { return spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect(); } 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) }; } let count = spl.control_points.len(); if count < 2 { return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); } let degree = spl.degree.max(0) as usize; if degree == 0 || degree >= count { return spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(); } // The kernel's space curve holds rational and polynomial curves alike. let controls: Vec<[f64; 3]> = spl .control_points .iter() .map(|point| [point.x, point.y, point.z]) .collect(); let weights = (spl.weights.len() == count).then(|| { spl.weights .iter() .map(|weight| if weight.abs() < 1e-12 { 1.0 } else { *weight }) .collect() }); match NurbsCurve3::new(degree, controls, spl.knots.clone(), weights) { Some(curve) => { curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) } None => spl.control_points.iter().map(|p| [p.x, p.y, p.z]).collect(), } } /// Sample a Catmull-Rom spline through `pts` into a dense polyline. The curve /// passes through every input point; open ends use reflected phantom points so /// they don't kink, closed curves wrap around. fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[f64; 3]> { let n = pts.len(); if n < 2 { return pts.iter().map(|p| [p.x, p.y, p.z]).collect(); } let get = |i: isize| -> [f64; 3] { let j = if closed { let m = n as isize; (((i % m) + m) % m) as usize } else { i.clamp(0, n as isize - 1) as usize }; [pts[j].x, pts[j].y, pts[j].z] }; let seg_count = if closed { n } else { n - 1 }; let mut out = Vec::new(); for seg in 0..seg_count { let p1 = get(seg as isize); let p2 = get(seg as isize + 1); // Reflect at open ends so the tangent isn't pulled toward a clamped dup. let p0 = if !closed && seg == 0 { [ 2.0 * p1[0] - p2[0], 2.0 * p1[1] - p2[1], 2.0 * p1[2] - p2[2], ] } else { get(seg as isize - 1) }; let p3 = if !closed && seg == seg_count - 1 { [ 2.0 * p2[0] - p1[0], 2.0 * p2[1] - p1[1], 2.0 * p2[2] - p1[2], ] } else { get(seg as isize + 2) }; let point_at = |t: f64| { let (t2, t3) = (t * t, t * t * t); let mut q = [0.0f64; 3]; for k in 0..3 { q[k] = 0.5 * (2.0 * p1[k] + (-p0[k] + p2[k]) * t + (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 + (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3); } q }; let tangent_at = |t: f64| { let mut q = [0.0; 3]; for k in 0..3 { let a = -p0[k] + p2[k]; let b = 2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]; let c = -p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]; q[k] = 0.5 * (a + 2.0 * b * t + 3.0 * c * t * t); } q }; let sampled = cadkernel::tessellation::sample_curve3_angle( point_at, tangent_at, cadkernel::tessellation::DEFAULT_ANGLE, ); out.extend(sampled.into_iter().skip(usize::from(seg > 0))); } out } /// Interpolate an open fit-point spline into a dense polyline: the C² cubic that /// passes through every fit point, clamped to the stored start/end tangents when /// present (natural end otherwise), so its ends follow the specified tangents /// instead of the local slopes Catmull-Rom would use. fn fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> { let p: Vec<[f64; 3]> = spl.fit_points.iter().map(|q| [q.x, q.y, q.z]).collect(); let n = p.len(); if n < 2 { return p; } let Some((h, slopes)) = fit_spline_slopes(spl, &p) else { return p; }; // Evaluate each segment as a cubic Hermite (dP/du = slope · h_i). let mut out = Vec::new(); for i in 0..n - 1 { let point_at = |u: f64| { let (u2, u3) = (u * u, u * u * u); let h00 = 2.0 * u3 - 3.0 * u2 + 1.0; let h10 = u3 - 2.0 * u2 + u; let h01 = -2.0 * u3 + 3.0 * u2; let h11 = u3 - u2; let mut q = [0.0f64; 3]; for k in 0..3 { let m0 = slopes[k][i] * h[i]; let m1 = slopes[k][i + 1] * h[i]; q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; } q }; let tangent_at = |u: f64| { let u2 = u * u; let (h00, h10, h01, h11) = ( 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 q = [0.0; 3]; for k in 0..3 { let m0 = slopes[k][i] * h[i]; let m1 = slopes[k][i + 1] * h[i]; q[k] = h00 * p[i][k] + h10 * m0 + h01 * p[i + 1][k] + h11 * m1; } q }; let sampled = cadkernel::tessellation::sample_curve3_angle( point_at, tangent_at, cadkernel::tessellation::DEFAULT_ANGLE, ); out.extend(sampled.into_iter().skip(usize::from(i > 0))); } out } fn periodic_fit_spline_polyline(spl: &Spline) -> Vec<[f64; 3]> { let points: Vec<[f64; 3]> = spl .fit_points .iter() .map(|point| [point.x, point.y, point.z]) .collect(); let parameterization = match spl.knot_parameterization { 2 => Parameterization::Uniform, 1 => Parameterization::Centripetal, _ => Parameterization::Chord, }; NurbsCurve3::interpolate_periodic(&points, parameterization) .map(|curve| curve.tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE)) .unwrap_or_else(|| catmull_rom_polyline(&spl.fit_points, true)) } /// Slopes used by spatial fit interpolation. fn fit_spline_slopes(spl: &Spline, p: &[[f64; 3]]) -> Option<(Vec, [Vec; 3])> { let n = p.len(); if n < 2 { return None; } // Parameterise the fit points (unnormalised, so a unit end tangent — how the // tangents are stored — is a consistent dP/dt). Match the spline's knot // parameterisation: 2 = uniform, 1 = centripetal (√chord), else chord. let dist = |a: [f64; 3], b: [f64; 3]| { let (dx, dy, dz) = (b[0] - a[0], b[1] - a[1], b[2] - a[2]); (dx * dx + dy * dy + dz * dz).sqrt() }; let mut t = vec![0.0f64; n]; for i in 1..n { let d = dist(p[i - 1], p[i]).max(1e-9); let step = match spl.knot_parameterization { 2 => 1.0, 1 => d.sqrt(), _ => d, }; t[i] = t[i - 1] + step; } let h: Vec = (0..n - 1).map(|i| (t[i + 1] - t[i]).max(1e-9)).collect(); let nonzero = |v: &acadrust::types::Vector3| v.x * v.x + v.y * v.y + v.z * v.z > 1e-18; let begin = nonzero(&spl.begin_tangent).then(|| { [ spl.begin_tangent.x, spl.begin_tangent.y, spl.begin_tangent.z, ] }); let end = nonzero(&spl.end_tangent) .then(|| [spl.end_tangent.x, spl.end_tangent.y, spl.end_tangent.z]); // Solve for the knot slopes m_i = dP/dt per coordinate. The tridiagonal is // the C² continuity system; the end rows are the clamped tangent (m fixed) // or the natural condition (S'' = 0) when no tangent is stored. let mut slopes = [vec![0.0f64; n], vec![0.0f64; n], vec![0.0f64; n]]; for k in 0..3 { let seg_slope = |i: usize| (p[i + 1][k] - p[i][k]) / h[i]; let (mut a, mut b, mut c, mut d) = (vec![0.0; n], vec![0.0; n], vec![0.0; n], vec![0.0; n]); match begin { Some(bt) => { b[0] = 1.0; d[0] = bt[k]; } None => { b[0] = 2.0; c[0] = 1.0; d[0] = 3.0 * seg_slope(0); } } for i in 1..n - 1 { a[i] = h[i]; b[i] = 2.0 * (h[i - 1] + h[i]); c[i] = h[i - 1]; d[i] = 3.0 * (h[i] * seg_slope(i - 1) + h[i - 1] * seg_slope(i)); } match end { Some(et) => { b[n - 1] = 1.0; d[n - 1] = et[k]; } None => { a[n - 1] = 1.0; b[n - 1] = 2.0; d[n - 1] = 3.0 * seg_slope(n - 2); } } thomas_solve(&a, &b, &c, &mut d); slopes[k] = d; } Some((h, slopes)) } /// In-place Thomas solve for a tridiagonal system (`a` sub-, `b` main-, `c` /// super-diagonal; `d` right-hand side, overwritten with the solution). fn thomas_solve(a: &[f64], b: &[f64], c: &[f64], d: &mut [f64]) { let n = d.len(); let mut cp = vec![0.0f64; n]; cp[0] = c[0] / b[0]; d[0] /= b[0]; for i in 1..n { let m = b[i] - a[i] * cp[i - 1]; cp[i] = c[i] / m; d[i] = (d[i] - a[i] * d[i - 1]) / m; } for i in (0..n - 1).rev() { d[i] -= cp[i] * d[i + 1]; } } pub(crate) const SPLINE_MODE_GRIP_ID: usize = usize::MAX - 1; fn control_vertices(spline: &Spline) -> Vec { if !uses_fit_method(spline) { return spline.control_points.clone(); } let Some(planar) = crate::entities::curve::spline_curve(spline) else { return Vec::new(); }; let KernelCurve::Nurbs(curve) = &planar.curve else { return Vec::new(); }; curve .control_points() .iter() .map(|point| { let point = planar.plane.point_at(*point); acadrust::types::Vector3::new(point[0], point[1], point[2]) }) .collect() } pub(crate) fn control_vertex_count(spline: &Spline) -> usize { control_vertices(spline).len() } fn choice_prop( label: &str, field: &'static str, selected: &str, options: &[&str], ) -> Property { Property { label: label.into(), field, value: PropValue::Choice { selected: selected.to_string(), options: options.iter().map(|option| option.to_string()).collect(), }, } } fn index_prop(label: &str, field: &'static str, index: usize, count: usize) -> Property { Property { label: label.into(), field, value: PropValue::EditText(if count == 0 { "0".to_string() } else { (index + 1).to_string() }), } } fn convert_to_control_method(spline: &mut Spline) -> bool { if !uses_fit_method(spline) { let valid = spline.control_points.len() >= 2; if valid { spline.fit_points.clear(); spline.begin_tangent = acadrust::types::Vector3::ZERO; spline.end_tangent = acadrust::types::Vector3::ZERO; spline.dwg_flags1 &= !1; spline.dxf_flags &= !(32 | 1024); } return valid; } let Some(planar) = crate::entities::curve::spline_curve(spline) else { return false; }; let KernelCurve::Nurbs(curve) = &planar.curve else { return false; }; spline.degree = curve.degree() as i32; spline.knots = curve.knots().to_vec(); spline.control_points = curve .control_points() .iter() .map(|point| { let point = planar.plane.point_at(*point); acadrust::types::Vector3::new(point[0], point[1], point[2]) }) .collect(); spline.weights = if curve.is_rational() { curve.weights().to_vec() } else { Vec::new() }; spline.flags.rational = curve.is_rational(); spline.flags.planar = true; spline.fit_points.clear(); spline.begin_tangent = acadrust::types::Vector3::ZERO; spline.end_tangent = acadrust::types::Vector3::ZERO; true } fn convert_to_fit_method(spline: &mut Spline) -> bool { if uses_fit_method(spline) { return true; } if spline.fit_points.len() < 2 { return false; } spline.control_points.clear(); spline.knots.clear(); spline.weights.clear(); spline.flags.rational = false; spline.dwg_flags1 |= 1; spline.dxf_flags |= 32 | 1024; true } fn derived_fit_points(spline: &Spline) -> Vec { if spline.flags.closed || spline.flags.periodic { return Vec::new(); } let Some(curve) = nurbs3(spline) else { return Vec::new(); }; let (start, end) = curve.domain(); if !(start.is_finite() && end.is_finite() && end > start) { return Vec::new(); } let tolerance = (end - start).abs().max(1.0) * 1.0e-12; let mut parameters = vec![start]; for knot in curve.knots().iter().copied() { if knot > start + tolerance && knot < end - tolerance && parameters .last() .is_none_or(|previous| (knot - *previous).abs() > tolerance) { parameters.push(knot); } } parameters.push(end); parameters .into_iter() .map(|parameter| { let point = curve.point_at_knot(parameter); acadrust::types::Vector3::new(point[0], point[1], point[2]) }) .collect() } fn can_show_fit_points(spline: &Spline) -> bool { spline.fit_points.len() >= 2 || derived_fit_points(spline).len() >= 2 } fn prepare_fit_point_view(spline: &mut Spline) -> bool { if spline.fit_points.len() < 2 { spline.fit_points = derived_fit_points(spline); } spline.fit_points.len() >= 2 } fn grips(spline: &Spline) -> Vec { let derived_control = control_vertices(spline); let show_fit = shows_fit_points(spline); let show_control_vertices = !show_fit && !(uses_fit_method(spline) && spline.flags.periodic) && derived_control.len() >= 2; let source = if show_control_vertices { derived_control } else if show_fit { spline.fit_points.clone() } else { spline.control_points.clone() }; let mut grips: Vec<_> = source .iter() .enumerate() .map(|(i, p)| { let world = glam::DVec3::new(p.x, p.y, p.z); if show_control_vertices { round_grip(i, world) } else { square_grip(i, world) } }) .collect(); if let Some(first) = source.first() { grips.push(dropdown_grip( SPLINE_MODE_GRIP_ID, glam::DVec3::new(first.x, first.y, first.z), )); } grips } fn properties(spline: &Spline) -> Vec { let fit_method = uses_fit_method(spline); let show_fit = shows_fit_points(spline); let method = if show_fit { "Fit" } else { "Control Vertices" }; let closed = spline.flags.closed || spline.flags.periodic; let can_show_control = control_vertices(spline).len() >= 2 && !(fit_method && spline.flags.periodic); let yes_no = |b: bool| if b { "Yes" } else { "No" }; let knot_param = match spline.knot_parameterization { 0 => "Chord", 1 => "Square Root", 2 => "Uniform", 15 => "Custom", _ => "Custom", }; let current = crate::scene::view::dispatch::prop_current_vertex(); let mut data_points = if show_fit { let count = spline.fit_points.len(); let index = current.min(count.saturating_sub(1)); let point = spline.fit_points.get(index); vec![ ro( t!("Number of fit points").as_ref(), "fit_pt_count", count.to_string(), ), index_prop( t!("Current fit point").as_ref(), "current_fit_point", index, count, ), edit( t!("Fit point X").as_ref(), "fit_pt_x", point.map(|value| value.x).unwrap_or(0.0), ), edit( t!("Fit point Y").as_ref(), "fit_pt_y", point.map(|value| value.y).unwrap_or(0.0), ), edit( t!("Fit point Z").as_ref(), "fit_pt_z", point.map(|value| value.z).unwrap_or(0.0), ), if matches!(spline.knot_parameterization, 0..=2) { choice_prop( t!("Knot parameterization").as_ref(), "knot_param", knot_param, &["Chord", "Square Root", "Uniform"], ) } else { ro( t!("Knot parameterization").as_ref(), "knot_param", crate::i18n::translate(knot_param).into_owned(), ) }, ] } else { let controls = control_vertices(spline); let count = controls.len(); let index = current.min(count.saturating_sub(1)); let point = controls.get(index); let weight = spline.weights.get(index).copied().unwrap_or(1.0); vec![ ro( t!("Number of control points").as_ref(), "ctrl_pt_count", count.to_string(), ), index_prop( t!("Current Control point").as_ref(), "current_control_point", index, count, ), edit( t!("Control point X").as_ref(), "ctrl_pt_x", point.map(|value| value.x).unwrap_or(0.0), ), edit( t!("Control point Y").as_ref(), "ctrl_pt_y", point.map(|value| value.y).unwrap_or(0.0), ), edit( t!("Control point Z").as_ref(), "ctrl_pt_z", point.map(|value| value.z).unwrap_or(0.0), ), edit_scalar(t!("Weight").as_ref(), "weight", weight), ] }; data_points.push(if can_show_control { choice_prop( t!("CV frame").as_ref(), "cv_frame", if spline.cv_frame_visible { "Show" } else { "Hide" }, &["Hide", "Show"], ) } else { ro(t!("CV frame").as_ref(), "cv_frame", t!("Hide").into_owned()) }); let method_prop = if fit_method && can_show_control { choice_prop( t!("Method").as_ref(), "spline_method", method, &["Fit", "Control Vertices"], ) } else { ro( t!("Method").as_ref(), "spline_method", crate::i18n::translate(method).into_owned(), ) }; let mut misc = vec![ method_prop, ro(t!("Degree").as_ref(), "degree", spline.degree.to_string()), ro( t!("Closed").as_ref(), "closed", crate::i18n::translate(yes_no(closed)).into_owned(), ), ro( t!("Periodic").as_ref(), "periodic", crate::i18n::translate(yes_no(spline.flags.periodic)).into_owned(), ), ro( t!("Planar").as_ref(), "planar", crate::i18n::translate(yes_no(crate::entities::curve::spline_is_planar(spline))) .into_owned(), ), ]; if show_fit && !spline.flags.periodic { misc.extend([ edit_scalar( t!("Start tangent vector X").as_ref(), "start_tan_x", spline.begin_tangent.x, ), edit_scalar( t!("Start tangent vector Y").as_ref(), "start_tan_y", spline.begin_tangent.y, ), edit_scalar( t!("Start tangent vector Z").as_ref(), "start_tan_z", spline.begin_tangent.z, ), edit_scalar( t!("End tangent vector X").as_ref(), "end_tan_x", spline.end_tangent.x, ), edit_scalar( t!("End tangent vector Y").as_ref(), "end_tan_y", spline.end_tangent.y, ), edit_scalar( t!("End tangent vector Z").as_ref(), "end_tan_z", spline.end_tangent.z, ), ]); } if show_fit { misc.push(edit( t!("Fit tolerance").as_ref(), "fit_tolerance", spline.fit_tolerance, )); } vec![ PropSection { title: t!("Data Points").into_owned(), props: data_points, }, PropSection { title: t!("Misc").into_owned(), props: misc, }, ] } fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { match field { "spline_method" => { if value == "Fit" { if prepare_fit_point_view(spline) { spline.cv_frame_visible = false; } } else if value == "Control Vertices" { if !spline.flags.periodic && control_vertices(spline).len() >= 2 { spline.cv_frame_visible = true; } } return; } "knot_param" => { if shows_fit_points(spline) && !uses_fit_method(spline) && !convert_to_fit_method(spline) { return; } spline.knot_parameterization = match value { "Chord" => 0, "Square Root" => 1, "Uniform" => 2, _ => return, }; return; } "cv_frame" => { spline.cv_frame_visible = value == "Show" && !(uses_fit_method(spline) && spline.flags.periodic) && control_vertices(spline).len() >= 2; return; } _ => {} } if uses_fit_method(spline) && spline.flags.periodic && matches!( field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight" | "start_tan_x" | "start_tan_y" | "start_tan_z" | "end_tan_x" | "end_tan_y" | "end_tan_z" ) { return; } if matches!(field, "ctrl_pt_x" | "ctrl_pt_y" | "ctrl_pt_z" | "weight") { if !shows_fit_points(spline) && !spline.fit_points.is_empty() { if !convert_to_control_method(spline) { return; } } else if uses_fit_method(spline) && !convert_to_control_method(spline) { return; } } if shows_fit_points(spline) && !uses_fit_method(spline) && matches!( field, "fit_pt_x" | "fit_pt_y" | "fit_pt_z" | "start_tan_x" | "start_tan_y" | "start_tan_z" | "end_tan_x" | "end_tan_y" | "end_tan_z" ) && !convert_to_fit_method(spline) { return; } let Some(v) = parse_f64(value) else { return }; let control_index = crate::scene::view::dispatch::prop_current_vertex() .min(spline.control_points.len().saturating_sub(1)); let fit_index = crate::scene::view::dispatch::prop_current_vertex() .min(spline.fit_points.len().saturating_sub(1)); match field { "ctrl_pt_x" => { if let Some(cp) = spline.control_points.get_mut(control_index) { cp.x = v; } } "ctrl_pt_y" => { if let Some(cp) = spline.control_points.get_mut(control_index) { cp.y = v; } } "ctrl_pt_z" => { if let Some(cp) = spline.control_points.get_mut(control_index) { cp.z = v; } } "weight" => { if v > 0.0 && !spline.control_points.is_empty() { if spline.weights.len() != spline.control_points.len() { spline.weights = vec![1.0; spline.control_points.len()]; } spline.weights[control_index] = v; spline.flags.rational = spline .weights .iter() .any(|weight| (*weight - spline.weights[0]).abs() > 1e-12); } } "fit_pt_x" => { if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.x = v; } } "fit_pt_y" => { if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.y = v; } } "fit_pt_z" => { if let Some(fp) = spline.fit_points.get_mut(fit_index) { fp.z = v; } } "fit_tolerance" => { if v.is_finite() && v >= 0.0 { spline.fit_tolerance = v; } } "start_tan_x" | "start_tan_y" | "start_tan_z" => { match field { "start_tan_x" => spline.begin_tangent.x = v, "start_tan_y" => spline.begin_tangent.y = v, "start_tan_z" => spline.begin_tangent.z = v, _ => {} } } "end_tan_x" | "end_tan_y" | "end_tan_z" => { match field { "end_tan_x" => spline.end_tangent.x = v, "end_tan_y" => spline.end_tangent.y = v, "end_tan_z" => spline.end_tangent.z = v, _ => {} } } _ => {} } spline.flags.planar = crate::entities::curve::spline_is_planar(spline); } fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { if grip_id == SPLINE_MODE_GRIP_ID { return; } // First CV edit makes the displayed kernel controls authoritative. let editing_fit = shows_fit_points(spline); if editing_fit && !uses_fit_method(spline) { if !convert_to_fit_method(spline) { return; } } else if !editing_fit && (uses_fit_method(spline) || !spline.fit_points.is_empty()) && !spline.flags.periodic { if !convert_to_control_method(spline) { return; } } let target = if editing_fit { spline.fit_points.get_mut(grip_id) } else { spline.control_points.get_mut(grip_id) }; if let Some(cp) = target { match apply { GripApply::Absolute(p) => { cp.x = p.x as f64; cp.y = p.y as f64; cp.z = p.z as f64; } GripApply::Translate(d) => { cp.x += d.x as f64; cp.y += d.y as f64; cp.z += d.z as f64; } } } } 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); } for fp in &mut entity.fit_points { 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 { fn to_render(&self, _document: &acadrust::CadDocument) -> Option { Some(to_render(self)) } } impl crate::entities::traits::Grippable for Spline { fn grips(&self) -> Vec { grips(self) } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; if grip_id == SPLINE_MODE_GRIP_ID { let fit_method = uses_fit_method(self); let can_show_control = control_vertices(self).len() >= 2 && !(fit_method && self.flags.periodic); let can_show_fit = can_show_fit_points(self); return if !shows_fit_points(self) { vec![ can_show_fit.then_some(GripMenuItem { label: "Fit", action: GripMenuAction::ShowFit, }), can_show_control.then_some(GripMenuItem { label: "✓ Control Vertices", action: GripMenuAction::ShowControlVertices, }), ] .into_iter() .flatten() .collect() } else { vec![ Some(GripMenuItem { label: "✓ Fit", action: GripMenuAction::ShowFit, }), can_show_control.then_some(GripMenuItem { label: "Control Vertices", action: GripMenuAction::ShowControlVertices, }), ] .into_iter() .flatten() .collect() }; } vec![GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, }] } fn apply_grip_menu( &mut self, _grip_id: usize, action: crate::scene::model::object::GripMenuAction, ) { use crate::scene::model::object::GripMenuAction as A; match action { A::ShowFit => { if prepare_fit_point_view(self) { self.cv_frame_visible = false; return; } } A::ShowControlVertices if !(uses_fit_method(self) && self.flags.periodic) && control_vertices(self).len() >= 2 => { self.cv_frame_visible = true; return; } _ => {} } } } impl crate::entities::traits::PropertyEditable for Spline { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { properties(self) } fn apply_geom_prop(&mut self, field: &str, value: &str) { apply_geom_prop(self, field, value); } } impl crate::entities::traits::Transformable for Spline { fn apply_transform(&mut self, t: &EntityTransform) { apply_transform(self, t); } } #[cfg(test)] mod tests { use super::*; #[test] fn knot_points_evaluate_distinct_interior_knots() { let mut spline = Spline::default(); spline.degree = 3; spline.control_points = vec![ acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::types::Vector3::new(1.0, 2.0, 0.0), acadrust::types::Vector3::new(3.0, 3.0, 1.0), acadrust::types::Vector3::new(5.0, 2.0, 0.0), acadrust::types::Vector3::new(6.0, 0.0, 1.0), ]; spline.knots = vec![0.0, 0.0, 0.0, 0.0, 0.5, 1.0, 1.0, 1.0, 1.0]; let pts = spline_knot_points(&spline); assert_eq!(pts.len(), 1, "one interior knot value: {pts:?}"); let curve = nurbs3(&spline).expect("control-point spline builds"); let expected = curve.point_at_knot(0.5); let got = [pts[0].x, pts[0].y, pts[0].z]; let err = ((got[0] - expected[0]).powi(2) + (got[1] - expected[1]).powi(2) + (got[2] - expected[2]).powi(2)) .sqrt(); assert!(err < 1e-9, "knot point off the curve: {err}"); } #[test] fn fit_method_splines_emit_no_knot_snaps() { // Interpolated curves invent their own knots at the fit nodes, which // already snap as endpoints — re-offering them as knots would double // every marker. let mut spline = Spline::default(); spline.degree = 3; spline.fit_points = vec![ acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::types::Vector3::new(4.0, 0.0, 1.0), acadrust::types::Vector3::new(4.0, 3.0, 2.0), ]; assert!(spline_knot_points(&spline).is_empty()); } #[test] fn fit_bounds_accept_closed_curves_with_unset_tangents() { let mut spline = Spline::default(); spline.degree = 3; spline.flags.closed = true; spline.control_points.push(acadrust::types::Vector3::ZERO); spline.fit_points = vec![ acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::types::Vector3::new(4.0, 0.0, 1.0), acadrust::types::Vector3::new(4.0, 3.0, 2.0), acadrust::types::Vector3::new(0.0, 3.0, 1.0), ]; let bounds = fit_geometry_bounds(&spline).unwrap(); for point in &spline.fit_points { for (value, min, max) in [ (point.x, bounds.min[0], bounds.max[0]), (point.y, bounds.min[1], bounds.max[1]), (point.z, bounds.min[2], bounds.max[2]), ] { assert!(value >= min - 1e-9 && value <= max + 1e-9); } } } #[test] fn fit_tolerance_is_editable_and_rejects_invalid_values() { let mut spline = Spline::default(); spline.fit_points = vec![ acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 0.0, 0.0), ]; spline.fit_tolerance = 0.25; let tolerance = properties(&spline) .into_iter() .flat_map(|section| section.props) .find(|property| property.field == "fit_tolerance") .unwrap(); assert!(matches!(tolerance.value, PropValue::EditText(_))); apply_geom_prop(&mut spline, "fit_tolerance", "1.5"); assert_eq!(spline.fit_tolerance, 1.5); for invalid in ["-1", "NaN", "inf"] { apply_geom_prop(&mut spline, "fit_tolerance", invalid); assert_eq!(spline.fit_tolerance, 1.5); } } #[test] fn tangent_properties_edit_only_the_stored_component() { let mut spline = Spline::default(); spline.fit_points = vec![ acadrust::types::Vector3::ZERO, acadrust::types::Vector3::new(1.0, 1.0, 0.0), ]; let start_y = properties(&spline) .into_iter() .flat_map(|section| section.props) .find(|property| property.field == "start_tan_y") .unwrap(); assert_eq!(start_y.value, PropValue::EditText("0".into())); apply_geom_prop(&mut spline, "start_tan_y", "2.5"); assert_eq!( spline.begin_tangent, acadrust::types::Vector3::new(0.0, 2.5, 0.0) ); } #[test] fn showing_derived_fit_points_does_not_change_the_curve() { let mut spline = Spline::default(); spline.degree = 3; spline.control_points = vec![ acadrust::types::Vector3::new(0.0, 0.0, 0.0), acadrust::types::Vector3::new(1.0, 3.0, 0.0), acadrust::types::Vector3::new(4.0, 3.0, 0.0), acadrust::types::Vector3::new(5.0, 0.0, 0.0), ]; spline.knots = cadkernel::space::clamped_uniform_knots(3, 4); let before = nurbs3(&spline).unwrap().point_at(0.37); assert!(prepare_fit_point_view(&mut spline)); spline.cv_frame_visible = false; assert!(shows_fit_points(&spline)); assert!(!uses_fit_method(&spline)); let after = nurbs3(&spline).unwrap().point_at(0.37); assert!( cadkernel::space::Vec3::from(before) .distance(cadkernel::space::Vec3::from(after)) < 1.0e-12 ); } }