Skip to main content

trx_rs/fit/
catmull_rom.rs

1//! Core Catmull-Rom fitting + sampling, free of any tractogram concerns so
2//! the algorithm can be reused on raw polylines.
3
4/// Endpoints are always kept, so the minimum output size is 2 — even when
5/// the input has fewer points the function returns the input unchanged.
6pub const MIN_KEPT_POINTS: usize = 2;
7
8/// Number of evenly-spaced samples per segment used to tessellate the
9/// candidate curve when measuring fit error. 32 keeps the polyline within
10/// ~`segment_length / 32` of the true curve, which is well below typical
11/// `epsilon_mm` values of 0.5–2 mm for brain tractography.
12const SAMPLES_PER_SEGMENT: usize = 32;
13
14/// Floor on chord length (mm) used when computing centripetal knots. Avoids
15/// a divide-by-zero when two consecutive control points coincide.
16const MIN_CHORD: f32 = 1e-4;
17
18/// Fit a Catmull-Rom curve through a sparse subset of `points` such that the
19/// curve stays within `epsilon_mm` of every input vertex. Returns the
20/// retained input indices in ascending order.
21///
22/// `points` are 3D coordinates in any consistent unit; `epsilon_mm` must be
23/// in the same unit. Inputs of fewer than 3 points or non-positive epsilons
24/// fall through with every index retained.
25///
26/// Both endpoints are always retained.
27pub fn fit_catmull_rom_indices(points: &[[f32; 3]], epsilon_mm: f32) -> Vec<usize> {
28    let n = points.len();
29    if n <= 2 || epsilon_mm <= 0.0 {
30        return (0..n).collect();
31    }
32    let eps2 = epsilon_mm * epsilon_mm;
33
34    let mut kept: Vec<usize> = Vec::with_capacity(16);
35    kept.push(0);
36    kept.push(n - 1);
37
38    let mut tess_buf: Vec<[f32; 3]> = Vec::with_capacity(SAMPLES_PER_SEGMENT + 1);
39    let mut additions: Vec<usize> = Vec::with_capacity(16);
40
41    loop {
42        additions.clear();
43
44        for seg in 0..kept.len() - 1 {
45            let start = kept[seg];
46            let end = kept[seg + 1];
47            if end <= start + 1 {
48                continue; // no interior input vertices in this segment
49            }
50
51            tessellate_segment(points, &kept, seg, &mut tess_buf);
52
53            let mut worst_idx = start + 1;
54            let mut worst_d2 = 0.0_f32;
55            for (input_idx, &point) in points.iter().enumerate().take(end).skip(start + 1) {
56                let d2 = point_to_polyline_dist2(point, &tess_buf);
57                if d2 > worst_d2 {
58                    worst_d2 = d2;
59                    worst_idx = input_idx;
60                }
61            }
62
63            if worst_d2 > eps2 {
64                additions.push(worst_idx);
65            }
66        }
67
68        if additions.is_empty() {
69            break;
70        }
71
72        // `additions` come from disjoint segments, so they're already in
73        // ascending order. Insert into `kept` while maintaining sortedness.
74        merge_sorted_into(&mut kept, &additions);
75    }
76
77    kept
78}
79
80/// Convenience: return the simplified streamline as owned positions.
81pub fn simplify_streamline(points: &[[f32; 3]], epsilon_mm: f32) -> Vec<[f32; 3]> {
82    let indices = fit_catmull_rom_indices(points, epsilon_mm);
83    indices.iter().map(|&i| points[i]).collect()
84}
85
86/// Sample a Catmull-Rom curve through `cps` with `samples_per_segment`
87/// evenly-spaced parameter steps per segment. The first CP appears once at
88/// the start; each subsequent segment contributes `samples_per_segment`
89/// points (so the final CP appears once at the end). Returns owned points.
90pub fn sample_catmull_rom(cps: &[[f32; 3]], samples_per_segment: usize) -> Vec<[f32; 3]> {
91    let mut out = Vec::new();
92    sample_catmull_rom_into(cps, samples_per_segment, &mut out);
93    out
94}
95
96/// Sample a Catmull-Rom curve into a caller-provided buffer. The buffer is
97/// cleared first; capacity is reused. Useful in tight loops to avoid
98/// per-call allocations.
99pub fn sample_catmull_rom_into(
100    cps: &[[f32; 3]],
101    samples_per_segment: usize,
102    out: &mut Vec<[f32; 3]>,
103) {
104    out.clear();
105    let n = cps.len();
106    if n == 0 {
107        return;
108    }
109    if n < 2 || samples_per_segment == 0 {
110        out.extend_from_slice(cps);
111        return;
112    }
113    out.reserve((n - 1) * samples_per_segment + 1);
114    out.push(cps[0]);
115    let inv = 1.0 / samples_per_segment as f32;
116    for seg in 0..n - 1 {
117        let cps4 = segment_control_points_from_dense(cps, seg);
118        let knots = centripetal_knots(cps4);
119        for k in 1..=samples_per_segment {
120            let u = k as f32 * inv;
121            out.push(catmull_rom_segment_with_knots(cps4, &knots, u));
122        }
123    }
124}
125
126// ─── internal helpers ────────────────────────────────────────────────────────
127
128fn tessellate_segment(points: &[[f32; 3]], kept: &[usize], seg: usize, out: &mut Vec<[f32; 3]>) {
129    out.clear();
130    let cps = segment_control_points(points, kept, seg);
131    let knots = centripetal_knots(cps);
132    let inv = 1.0 / SAMPLES_PER_SEGMENT as f32;
133    for k in 0..=SAMPLES_PER_SEGMENT {
134        let u = k as f32 * inv;
135        out.push(catmull_rom_segment_with_knots(cps, &knots, u));
136    }
137}
138
139/// Pull out the four Catmull-Rom control points for the segment between
140/// `kept[seg]` and `kept[seg + 1]`, using endpoint-reflection for `p0` /
141/// `p3` at the curve's boundaries.
142fn segment_control_points(points: &[[f32; 3]], kept: &[usize], seg: usize) -> [[f32; 3]; 4] {
143    let n = kept.len();
144    let p1 = points[kept[seg]];
145    let p2 = points[kept[seg + 1]];
146    let p0 = if seg == 0 {
147        reflect(p1, p2)
148    } else {
149        points[kept[seg - 1]]
150    };
151    let p3 = if seg + 2 == n {
152        reflect(p2, p1)
153    } else {
154        points[kept[seg + 2]]
155    };
156    [p0, p1, p2, p3]
157}
158
159/// Same as `segment_control_points` but indexes directly into `cps` (i.e.
160/// every consecutive pair is a segment, no `kept` indirection).
161fn segment_control_points_from_dense(cps: &[[f32; 3]], seg: usize) -> [[f32; 3]; 4] {
162    let n = cps.len();
163    let p1 = cps[seg];
164    let p2 = cps[seg + 1];
165    let p0 = if seg == 0 {
166        reflect(p1, p2)
167    } else {
168        cps[seg - 1]
169    };
170    let p3 = if seg + 2 == n {
171        reflect(p2, p1)
172    } else {
173        cps[seg + 2]
174    };
175    [p0, p1, p2, p3]
176}
177
178/// Reflect `b` across `a`: returns `a + (a - b) = 2a - b`.
179#[inline]
180fn reflect(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
181    [2.0 * a[0] - b[0], 2.0 * a[1] - b[1], 2.0 * a[2] - b[2]]
182}
183
184/// Centripetal Catmull-Rom (α = 0.5).
185///
186/// Knot intervals are the chord lengths raised to the α = 0.5 power. This
187/// is the parameterisation that's mathematically guaranteed not to form
188/// loops or cusps near closely-spaced control points — see Yuksel,
189/// Schaefer, Keyser, "Parameterization and Applications of Catmull-Rom
190/// Curves" (CAD 2011). The plain uniform Catmull-Rom (α = 0) overshoots
191/// when adjacent CPs are unevenly spaced, which is precisely the regime
192/// the iterative fitter produces (it densifies CPs in high-curvature
193/// regions).
194///
195/// Evaluated via Aitken-Neville so the formula stays compact and
196/// numerically clean. The four knot values are precomputed once per
197/// segment by `centripetal_knots` and threaded through here so the
198/// per-sample work is just five lerps + a few divisions.
199#[derive(Clone, Copy)]
200struct CentripetalKnots {
201    t0: f32,
202    t1: f32,
203    t2: f32,
204    t3: f32,
205}
206
207#[inline]
208fn centripetal_knots(cps: [[f32; 3]; 4]) -> CentripetalKnots {
209    let [p0, p1, p2, p3] = cps;
210    let t0 = 0.0;
211    let t1 = t0 + chord_centripetal(p0, p1);
212    let t2 = t1 + chord_centripetal(p1, p2);
213    let t3 = t2 + chord_centripetal(p2, p3);
214    CentripetalKnots { t0, t1, t2, t3 }
215}
216
217#[inline]
218fn chord_centripetal(a: [f32; 3], b: [f32; 3]) -> f32 {
219    // ||b - a||^0.5  =  (||b - a||²)^0.25  =  sqrt(sqrt(d²)).
220    let dx = b[0] - a[0];
221    let dy = b[1] - a[1];
222    let dz = b[2] - a[2];
223    let d2 = dx * dx + dy * dy + dz * dz;
224    d2.sqrt().sqrt().max(MIN_CHORD)
225}
226
227#[inline]
228fn catmull_rom_segment_with_knots(
229    cps: [[f32; 3]; 4],
230    knots: &CentripetalKnots,
231    u: f32,
232) -> [f32; 3] {
233    let [p0, p1, p2, p3] = cps;
234    let CentripetalKnots { t0, t1, t2, t3 } = *knots;
235    // Map u ∈ [0, 1] to t ∈ [t1, t2] — the parameter interval that
236    // produces the segment between p1 and p2.
237    let t = t1 + u * (t2 - t1);
238
239    // First-level Aitken interpolations.
240    let a1 = lerp3(p0, p1, (t - t0) / (t1 - t0));
241    let a2 = lerp3(p1, p2, (t - t1) / (t2 - t1));
242    let a3 = lerp3(p2, p3, (t - t2) / (t3 - t2));
243
244    // Second level.
245    let b1 = lerp3(a1, a2, (t - t0) / (t2 - t0));
246    let b2 = lerp3(a2, a3, (t - t1) / (t3 - t1));
247
248    // Final blend.
249    lerp3(b1, b2, (t - t1) / (t2 - t1))
250}
251
252#[inline]
253fn lerp3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
254    [
255        a[0] + t * (b[0] - a[0]),
256        a[1] + t * (b[1] - a[1]),
257        a[2] + t * (b[2] - a[2]),
258    ]
259}
260
261/// Minimum squared distance from `p` to the polyline formed by consecutive
262/// pairs of `polyline`. `polyline.len()` must be ≥ 2.
263#[inline]
264fn point_to_polyline_dist2(p: [f32; 3], polyline: &[[f32; 3]]) -> f32 {
265    let mut min_d2 = f32::INFINITY;
266    for window in polyline.windows(2) {
267        let d2 = point_to_segment_dist2(p, window[0], window[1]);
268        if d2 < min_d2 {
269            min_d2 = d2;
270        }
271    }
272    min_d2
273}
274
275#[inline]
276fn point_to_segment_dist2(p: [f32; 3], a: [f32; 3], b: [f32; 3]) -> f32 {
277    let abx = b[0] - a[0];
278    let aby = b[1] - a[1];
279    let abz = b[2] - a[2];
280    let denom = abx * abx + aby * aby + abz * abz;
281    if denom < f32::EPSILON {
282        let dx = p[0] - a[0];
283        let dy = p[1] - a[1];
284        let dz = p[2] - a[2];
285        return dx * dx + dy * dy + dz * dz;
286    }
287    let apx = p[0] - a[0];
288    let apy = p[1] - a[1];
289    let apz = p[2] - a[2];
290    let t = ((apx * abx + apy * aby + apz * abz) / denom).clamp(0.0, 1.0);
291    let dx = apx - abx * t;
292    let dy = apy - aby * t;
293    let dz = apz - abz * t;
294    dx * dx + dy * dy + dz * dz
295}
296
297/// Merge a *sorted* slice of new indices into a *sorted* `Vec`, preserving
298/// sortedness and deduplicating. Single allocation, single pass.
299fn merge_sorted_into(kept: &mut Vec<usize>, additions: &[usize]) {
300    if additions.is_empty() {
301        return;
302    }
303    let mut merged = Vec::with_capacity(kept.len() + additions.len());
304    let (mut i, mut j) = (0, 0);
305    while i < kept.len() && j < additions.len() {
306        let a = kept[i];
307        let b = additions[j];
308        if a < b {
309            merged.push(a);
310            i += 1;
311        } else if a > b {
312            merged.push(b);
313            j += 1;
314        } else {
315            merged.push(a);
316            i += 1;
317            j += 1;
318        }
319    }
320    merged.extend_from_slice(&kept[i..]);
321    merged.extend_from_slice(&additions[j..]);
322    *kept = merged;
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn cubic_curve(steps: usize) -> Vec<[f32; 3]> {
330        (0..=steps)
331            .map(|i| {
332                let t = i as f32 / steps as f32;
333                let x = -25.0 + 50.0 * t;
334                let y = 10.0 * (t * std::f32::consts::PI).sin();
335                let z = 5.0 * t;
336                [x, y, z]
337            })
338            .collect()
339    }
340
341    #[test]
342    fn endpoints_always_retained() {
343        let pts = cubic_curve(50);
344        let kept = fit_catmull_rom_indices(&pts, 1.0);
345        assert_eq!(kept.first(), Some(&0));
346        assert_eq!(kept.last(), Some(&50));
347    }
348
349    #[test]
350    fn straight_line_collapses_to_endpoints() {
351        let pts: Vec<[f32; 3]> = (0..20).map(|i| [i as f32, 0.0, 0.0]).collect();
352        let kept = fit_catmull_rom_indices(&pts, 0.01);
353        assert_eq!(kept, vec![0, 19]);
354    }
355
356    #[test]
357    fn fewer_than_three_points_pass_through() {
358        let pts = vec![[0.0, 0.0, 0.0], [1.0, 1.0, 0.0]];
359        assert_eq!(fit_catmull_rom_indices(&pts, 1.0), vec![0, 1]);
360        let pts = vec![[0.0, 0.0, 0.0]];
361        assert_eq!(fit_catmull_rom_indices(&pts, 1.0), vec![0]);
362        assert_eq!(fit_catmull_rom_indices(&[], 1.0), Vec::<usize>::new());
363    }
364
365    #[test]
366    fn fit_within_tolerance_for_smooth_curve() {
367        let pts = cubic_curve(200);
368        let epsilon = 0.5;
369        let kept = fit_catmull_rom_indices(&pts, epsilon);
370        // Re-densify the kept set via Catmull-Rom and verify every input
371        // vertex is within ε of the resulting polyline.
372        let kept_pts: Vec<[f32; 3]> = kept.iter().map(|&i| pts[i]).collect();
373        let dense = sample_catmull_rom(&kept_pts, 32);
374        let max_d = pts
375            .iter()
376            .map(|&p| point_to_polyline_dist2(p, &dense).sqrt())
377            .fold(0.0_f32, f32::max);
378        assert!(
379            max_d <= epsilon * 1.05,
380            "max error {max_d} exceeded tolerance {epsilon}"
381        );
382        // Sanity: should be much smaller than the input.
383        assert!(
384            kept.len() < pts.len() / 4,
385            "expected sparse output (got {} from {})",
386            kept.len(),
387            pts.len()
388        );
389    }
390
391    #[test]
392    fn smaller_epsilon_keeps_more_points() {
393        let pts = cubic_curve(200);
394        let coarse = fit_catmull_rom_indices(&pts, 2.0).len();
395        let fine = fit_catmull_rom_indices(&pts, 0.1).len();
396        assert!(fine > coarse, "fine {fine} should exceed coarse {coarse}");
397    }
398
399    #[test]
400    fn sampler_passes_through_control_points() {
401        let cps = vec![
402            [0.0, 0.0, 0.0],
403            [1.0, 2.0, 0.0],
404            [3.0, 1.0, 0.0],
405            [4.0, 3.0, 0.0],
406        ];
407        let dense = sample_catmull_rom(&cps, 10);
408        assert_eq!(dense.first(), Some(&cps[0]));
409        assert_eq!(dense.last(), Some(&cps[3]));
410        for (i, cp) in cps.iter().enumerate() {
411            let idx = i * 10;
412            let d2 = (dense[idx][0] - cp[0]).powi(2)
413                + (dense[idx][1] - cp[1]).powi(2)
414                + (dense[idx][2] - cp[2]).powi(2);
415            assert!(d2 < 1e-8, "CP {i} not hit at sample {idx}");
416        }
417    }
418
419    /// Centripetal CR's defining property: the curve through any 4 CPs is
420    /// guaranteed to stay within the convex hull of those CPs locally,
421    /// even when CPs are clustered. We construct a curve with two CPs
422    /// very close together and verify the densified samples between them
423    /// don't bulge sideways (the failure mode of uniform CR).
424    #[test]
425    fn no_overshoot_with_clustered_cps() {
426        // Polyline with a slight kink at the middle; close spacing between
427        // CPs 1 and 2 is exactly the shape that makes uniform CR overshoot.
428        let cps = vec![
429            [0.0, 0.0, 0.0],
430            [10.0, 0.0, 0.0],
431            [10.5, 0.0, 0.0],
432            [20.0, 0.0, 0.0],
433        ];
434        let dense = sample_catmull_rom(&cps, 64);
435        // The cluster is colinear in y/z, so any non-zero excursion in y or z
436        // is overshoot. Centripetal must keep that bounded by the chord.
437        let max_y = dense.iter().map(|p| p[1].abs()).fold(0.0_f32, f32::max);
438        let max_z = dense.iter().map(|p| p[2].abs()).fold(0.0_f32, f32::max);
439        assert!(max_y < 1e-3, "centripetal CR overshot in y: {max_y}");
440        assert!(max_z < 1e-3, "centripetal CR overshot in z: {max_z}");
441    }
442
443    #[test]
444    fn merge_sorted_dedup_works() {
445        let mut kept = vec![0, 5, 10];
446        merge_sorted_into(&mut kept, &[3, 7]);
447        assert_eq!(kept, vec![0, 3, 5, 7, 10]);
448
449        let mut kept = vec![0, 10];
450        merge_sorted_into(&mut kept, &[]);
451        assert_eq!(kept, vec![0, 10]);
452
453        let mut kept = vec![0, 5];
454        merge_sorted_into(&mut kept, &[5, 7]); // dedup 5
455        assert_eq!(kept, vec![0, 5, 7]);
456    }
457}