fix(spline): interpolate fit-point splines with end tangents, grip via fit points

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 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-18 12:46:09 +03:00
1 parent 340a358c26
commit db896ec632
1 file changed
+174 -24
+174 -24
View File
@@ -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<f64> = (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<GripDef> {
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<PropSection> {
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);
}
}