From db896ec632011aca321fcaf46c01d67d4a752ded Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sat, 18 Jul 2026 12:46:09 +0300 Subject: [PATCH] fix(spline): interpolate fit-point splines with end tangents, grip via fit points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fit-point SPLINE (no control vertices) was drawn with Catmull-Rom through its fit points, which ignores the stored begin/end tangents and uses local slopes. An open curve then peeled away from the true shape at its ends — budweiser's handle 2735 lost the tangent-driven overhang at its top end. A fit spline is the C² cubic that passes through every fit point clamped to the stored end tangents. Replace Catmull-Rom (kept only for closed splines, whose wrap the clamped solve doesn't model) with `fit_spline_polyline`: chord-length parameterisation (honouring knot_parameterization), a tridiagonal Thomas solve for the knot slopes with clamped-tangent or natural end rows, and Hermite evaluation. Verified on 2735: the curve passes through all five fit points and its ends follow begin_tangent/end_tangent (dir·tan = 0.997). Also fixes grip-selection: grips()/apply_grip() only ever looked at control_points, so a fit spline (0 CV) showed no grips and couldn't be grip-selected or edited. Both now fall back to fit_points when there are no control points — dragging a grip moves the fit point and the curve re-fits on the next tessellation, keeping the end tangents. The mirror path already transformed fit_points; move/rotate/scale run through acadrust. Co-Authored-By: Claude Opus 4.8 --- src/entities/spline.rs | 198 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 174 insertions(+), 24 deletions(-) diff --git a/src/entities/spline.rs b/src/entities/spline.rs index 46474cb60..6329bcca6 100644 --- a/src/entities/spline.rs +++ b/src/entities/spline.rs @@ -19,7 +19,16 @@ fn to_truck(spl: &Spline) -> TruckEntity { // polyline so it still draws. if spl.fit_points.len() >= 2 { let closed = spl.flags.closed || spl.flags.periodic; - let pts = catmull_rom_polyline(&spl.fit_points, closed); + // A fit spline is a C² cubic through its fit points honouring the + // stored end tangents. Catmull-Rom ignores those tangents (its ends + // use local slopes), so an open curve peels away from the true shape + // at the ends; interpolate properly instead. Closed splines wrap, + // which the clamped solve doesn't model, so they keep Catmull-Rom. + let pts = if closed { + catmull_rom_polyline(&spl.fit_points, true) + } else { + fit_spline_polyline(spl) + }; let key_vertices: Vec<[f64; 3]> = spl.fit_points.iter().map(|p| [p.x, p.y, p.z]).collect(); return TruckEntity { @@ -142,18 +151,30 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[ 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]] + [ + 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]] + [ + 2.0 * p2[0] - p1[0], + 2.0 * p2[1] - p1[1], + 2.0 * p2[2] - p1[2], + ] } else { get(seg as isize + 2) }; // Emit t in [0, 1); the final segment also emits t = 1 so the curve // closes onto the last point (no duplicate shared vertices otherwise). - let last = if seg == seg_count - 1 { STEPS } else { STEPS - 1 }; + let last = if seg == seg_count - 1 { + STEPS + } else { + STEPS - 1 + }; for s in 0..=last { let t = s as f64 / STEPS as f64; let (t2, t3) = (t * t, t * t * t); @@ -171,9 +192,139 @@ fn catmull_rom_polyline(pts: &[acadrust::types::Vector3], closed: bool) -> Vec<[ 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). This is what a fit spline *is* — the same +/// interpolation AutoCAD-family tools draw — 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 < 3 { + // A single segment can't be C²-fit; a straight chord is the honest draw. + return p; + } + + // 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; + } + + // Evaluate each segment as a cubic Hermite (dP/du = slope · h_i). + const STEPS: usize = 32; + let mut out = Vec::with_capacity((n - 1) * STEPS + 1); + for i in 0..n - 1 { + let last = if i == n - 2 { STEPS } else { STEPS - 1 }; + for s in 0..=last { + let u = s as f64 / STEPS as 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; + } + out.push(q); + } + } + out +} + +/// 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]; + } +} + fn grips(spline: &Spline) -> Vec { - spline - .control_points + // A fit-point spline carries no control points, so its editable points — and + // therefore its grips — are the fit points it interpolates. A CV spline + // grips its control vertices. + let source = if spline.control_points.is_empty() { + &spline.fit_points + } else { + &spline.control_points + }; + source .iter() .enumerate() .map(|(i, p)| square_grip(i, glam::DVec3::new(p.x, p.y, p.z))) @@ -266,21 +417,9 @@ fn properties(spline: &Spline) -> Vec { spline.fit_points.len().to_string(), ), ro("Fit Point", "fit_pt_index", "0"), - edit( - "Fit Point X", - "fit_pt_x", - fp0.map(|p| p.x).unwrap_or(0.0), - ), - edit( - "Fit Point Y", - "fit_pt_y", - fp0.map(|p| p.y).unwrap_or(0.0), - ), - edit( - "Fit Point Z", - "fit_pt_z", - fp0.map(|p| p.z).unwrap_or(0.0), - ), + edit("Fit Point X", "fit_pt_x", fp0.map(|p| p.x).unwrap_or(0.0)), + edit("Fit Point Y", "fit_pt_y", fp0.map(|p| p.y).unwrap_or(0.0)), + edit("Fit Point Z", "fit_pt_z", fp0.map(|p| p.z).unwrap_or(0.0)), edit("Fit Tolerance", "fit_tolerance", spline.fit_tolerance), edit("Start Tangent X", "start_tan_x", spline.begin_tangent.x), edit("Start Tangent Y", "start_tan_y", spline.begin_tangent.y), @@ -352,7 +491,15 @@ fn apply_geom_prop(spline: &mut Spline, field: &str, value: &str) { } fn apply_grip(spline: &mut Spline, grip_id: usize, apply: GripApply) { - if let Some(cp) = spline.control_points.get_mut(grip_id) { + // Match the grip source: dragging a fit-point spline's grip moves the fit + // point (the curve re-fits through it on the next tessellation, keeping the + // stored end tangents); a CV spline moves the control vertex. + let target = if spline.control_points.is_empty() { + 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; @@ -413,7 +560,11 @@ impl crate::entities::traits::Grippable for Spline { }, ] } - fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { + fn apply_grip_menu( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) { use crate::scene::model::object::GripMenuAction as A; let n = self.control_points.len(); let min_cv = (self.degree as usize).saturating_add(1).max(2); @@ -498,4 +649,3 @@ impl crate::entities::traits::Transformable for Spline { apply_transform(self, t); } } -