Flint Engine / Guide / API Reference

flint_core/
spline.rs

1//! Pure spline math — Catmull-Rom path sampling with twist (banking).
2//!
3//! Provides open and closed spline evaluation from control points,
4//! producing evenly-spaced samples with position, forward, right, up
5//! basis vectors and parametric t values.
6
7use crate::Vec3;
8
9/// A single control point on a Catmull-Rom spline.
10#[derive(Debug, Clone)]
11pub struct SplineControlPoint {
12    pub position: Vec3,
13    /// Twist (banking) in degrees around the forward axis.
14    pub twist: f32,
15    /// When true, road geometry and physics are omitted from this CP to the next,
16    /// creating a gap/jump. Consecutive `gap_after` CPs merge into longer gaps.
17    pub gap_after: bool,
18}
19
20/// A sampled point along a spline with computed basis vectors.
21#[derive(Debug, Clone)]
22pub struct SplineSample {
23    pub position: Vec3,
24    pub forward: Vec3,
25    pub right: Vec3,
26    pub up: Vec3,
27    /// Twist in degrees (before application to right/up).
28    pub twist: f32,
29    /// Parametric t in [0, 1) along the spline.
30    pub t: f32,
31}
32
33/// Catmull-Rom spline interpolation between four points.
34pub fn catmull_rom(p0: Vec3, p1: Vec3, p2: Vec3, p3: Vec3, t: f32) -> Vec3 {
35    let t2 = t * t;
36    let t3 = t2 * t;
37    Vec3::new(
38        0.5 * ((2.0 * p1.x)
39            + (-p0.x + p2.x) * t
40            + (2.0 * p0.x - 5.0 * p1.x + 4.0 * p2.x - p3.x) * t2
41            + (-p0.x + 3.0 * p1.x - 3.0 * p2.x + p3.x) * t3),
42        0.5 * ((2.0 * p1.y)
43            + (-p0.y + p2.y) * t
44            + (2.0 * p0.y - 5.0 * p1.y + 4.0 * p2.y - p3.y) * t2
45            + (-p0.y + 3.0 * p1.y - 3.0 * p2.y + p3.y) * t3),
46        0.5 * ((2.0 * p1.z)
47            + (-p0.z + p2.z) * t
48            + (2.0 * p0.z - 5.0 * p1.z + 4.0 * p2.z - p3.z) * t2
49            + (-p0.z + 3.0 * p1.z - 3.0 * p2.z + p3.z) * t3),
50    )
51}
52
53/// Catmull-Rom interpolation for a single scalar value.
54pub fn catmull_rom_scalar(p0: f32, p1: f32, p2: f32, p3: f32, t: f32) -> f32 {
55    let t2 = t * t;
56    let t3 = t2 * t;
57    0.5 * ((2.0 * p1)
58        + (-p0 + p2) * t
59        + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2
60        + (-p0 + 3.0 * p1 - 3.0 * p2 + p3) * t3)
61}
62
63/// Rotate a vector around an axis by an angle in radians (Rodrigues' formula).
64pub fn rotate_around_axis(v: Vec3, axis: Vec3, angle: f32) -> Vec3 {
65    let cos_a = angle.cos();
66    let sin_a = angle.sin();
67    let dot = v.dot(&axis);
68    let cross = axis.cross(&v);
69    Vec3::new(
70        v.x * cos_a + cross.x * sin_a + axis.x * dot * (1.0 - cos_a),
71        v.y * cos_a + cross.y * sin_a + axis.y * dot * (1.0 - cos_a),
72        v.z * cos_a + cross.z * sin_a + axis.z * dot * (1.0 - cos_a),
73    )
74}
75
76/// Sample a closed-loop Catmull-Rom spline at approximately `spacing` meters apart.
77pub fn sample_closed_spline(points: &[SplineControlPoint], spacing: f32) -> Vec<SplineSample> {
78    let n = points.len();
79    if n < 3 {
80        return Vec::new();
81    }
82
83    let world_up = Vec3::UP;
84
85    // Estimate total spline length
86    let segments_per_cp = 20;
87    let mut total_length = 0.0_f32;
88    let mut prev_pos = points[0].position;
89    for seg in 0..n {
90        let p0 = points[(seg + n - 1) % n].position;
91        let p1 = points[seg].position;
92        let p2 = points[(seg + 1) % n].position;
93        let p3 = points[(seg + 2) % n].position;
94        for j in 1..=segments_per_cp {
95            let t = j as f32 / segments_per_cp as f32;
96            let pos = catmull_rom(p0, p1, p2, p3, t);
97            total_length += (pos - prev_pos).length();
98            prev_pos = pos;
99        }
100    }
101
102    let num_samples = (total_length / spacing).ceil() as usize;
103    let num_samples = num_samples.max(20);
104    let mut samples = Vec::with_capacity(num_samples);
105
106    for i in 0..num_samples {
107        let global_t = i as f32 / num_samples as f32;
108        let scaled = global_t * n as f32;
109        let seg = scaled.floor() as usize % n;
110        let local_t = scaled - scaled.floor();
111
112        let p0 = points[(seg + n - 1) % n].position;
113        let p1 = points[seg].position;
114        let p2 = points[(seg + 1) % n].position;
115        let p3 = points[(seg + 2) % n].position;
116
117        let position = catmull_rom(p0, p1, p2, p3, local_t);
118
119        // Forward tangent via finite difference
120        let dt = 0.001;
121        let next_t = local_t + dt;
122        let pos_next = if next_t <= 1.0 {
123            catmull_rom(p0, p1, p2, p3, next_t)
124        } else {
125            let next_seg = (seg + 1) % n;
126            let np0 = points[(next_seg + n - 1) % n].position;
127            let np1 = points[next_seg].position;
128            let np2 = points[(next_seg + 1) % n].position;
129            let np3 = points[(next_seg + 2) % n].position;
130            catmull_rom(np0, np1, np2, np3, next_t - 1.0)
131        };
132
133        let forward = (pos_next - position).normalized();
134
135        // Interpolate twist using Catmull-Rom (C1 continuous, matches position)
136        let tw0 = points[(seg + n - 1) % n].twist;
137        let tw1 = points[seg].twist;
138        let tw2 = points[(seg + 1) % n].twist;
139        let tw3 = points[(seg + 2) % n].twist;
140        let twist = catmull_rom_scalar(tw0, tw1, tw2, tw3, local_t);
141        let twist_rad = twist.to_radians();
142
143        // Basis vectors with twist
144        let right_flat = forward.cross(&world_up).normalized();
145        let up_flat = right_flat.cross(&forward).normalized();
146        let right = rotate_around_axis(right_flat, forward, twist_rad);
147        let up = rotate_around_axis(up_flat, forward, twist_rad);
148
149        samples.push(SplineSample {
150            position,
151            forward,
152            right,
153            up,
154            twist,
155            t: global_t,
156        });
157    }
158
159    samples
160}
161
162/// Sample an open Catmull-Rom spline at approximately `spacing` meters apart.
163///
164/// For open splines, phantom control points are created by reflecting
165/// the first and last interior segments outward.
166pub fn sample_open_spline(points: &[SplineControlPoint], spacing: f32) -> Vec<SplineSample> {
167    let n = points.len();
168    if n < 2 {
169        return Vec::new();
170    }
171
172    // Build extended point list with phantom endpoints
173    let phantom_start = SplineControlPoint {
174        position: points[0].position * 2.0 - points[1].position,
175        twist: points[0].twist,
176        gap_after: false,
177    };
178    let phantom_end = SplineControlPoint {
179        position: points[n - 1].position * 2.0 - points[n - 2].position,
180        twist: points[n - 1].twist,
181        gap_after: false,
182    };
183
184    let mut extended = Vec::with_capacity(n + 2);
185    extended.push(phantom_start);
186    extended.extend_from_slice(points);
187    extended.push(phantom_end);
188
189    // Now we have extended.len() = n + 2, with n + 1 segments between real points
190    // but actually n - 1 segments between the original n points.
191    let num_segs = n - 1;
192    let world_up = Vec3::UP;
193
194    // Estimate total length
195    let segments_per_cp = 20;
196    let mut total_length = 0.0_f32;
197    let mut prev_pos = extended[1].position; // first real point
198    for seg in 0..num_segs {
199        let p0 = extended[seg].position;
200        let p1 = extended[seg + 1].position;
201        let p2 = extended[seg + 2].position;
202        let p3 = extended[seg + 3].position;
203        for j in 1..=segments_per_cp {
204            let t = j as f32 / segments_per_cp as f32;
205            let pos = catmull_rom(p0, p1, p2, p3, t);
206            total_length += (pos - prev_pos).length();
207            prev_pos = pos;
208        }
209    }
210
211    let num_samples = (total_length / spacing).ceil() as usize;
212    let num_samples = num_samples.max(10);
213    // +1 so we include both endpoints
214    let total_pts = num_samples + 1;
215    let mut samples = Vec::with_capacity(total_pts);
216
217    for i in 0..total_pts {
218        let global_t = i as f32 / num_samples as f32; // [0, 1]
219        let scaled = global_t * num_segs as f32;
220        let seg = (scaled.floor() as usize).min(num_segs - 1);
221        let local_t = scaled - seg as f32;
222
223        let p0 = extended[seg].position;
224        let p1 = extended[seg + 1].position;
225        let p2 = extended[seg + 2].position;
226        let p3 = extended[seg + 3].position;
227
228        let position = catmull_rom(p0, p1, p2, p3, local_t);
229
230        // Forward tangent
231        let dt = 0.001;
232        let next_t = local_t + dt;
233        let pos_next = if next_t <= 1.0 {
234            catmull_rom(p0, p1, p2, p3, next_t)
235        } else if seg + 1 < num_segs {
236            let np0 = extended[seg + 1].position;
237            let np1 = extended[seg + 2].position;
238            let np2 = extended[seg + 3].position;
239            let np3 = extended[seg + 4].position;
240            catmull_rom(np0, np1, np2, np3, next_t - 1.0)
241        } else {
242            catmull_rom(p0, p1, p2, p3, 1.0)
243        };
244
245        let forward = (pos_next - position).normalized();
246
247        // Interpolate twist using Catmull-Rom (C1 continuous, matches position)
248        let tw0 = extended[seg].twist;
249        let tw1 = extended[seg + 1].twist;
250        let tw2 = extended[seg + 2].twist;
251        let tw3 = extended[seg + 3].twist;
252        let twist = catmull_rom_scalar(tw0, tw1, tw2, tw3, local_t);
253        let twist_rad = twist.to_radians();
254
255        let right_flat = forward.cross(&world_up).normalized();
256        let up_flat = right_flat.cross(&forward).normalized();
257        let right = rotate_around_axis(right_flat, forward, twist_rad);
258        let up = rotate_around_axis(up_flat, forward, twist_rad);
259
260        samples.push(SplineSample {
261            position,
262            forward,
263            right,
264            up,
265            twist,
266            t: global_t,
267        });
268    }
269
270    samples
271}
272
273// ─── Gap helpers ─────────────────────────────────────────
274
275/// Compute parametric t-ranges where `gap_after` is true.
276///
277/// For a closed spline with N control points, CP k spans t = k/N to (k+1)/N.
278/// For an open spline with N control points, CP k spans t = k/(N-1) to (k+1)/(N-1).
279/// Consecutive `gap_after` CPs merge into a single range.
280/// Closed splines handle wrap-around (last CP → first CP).
281pub fn compute_gap_ranges(points: &[SplineControlPoint], closed: bool) -> Vec<(f32, f32)> {
282    let n = points.len();
283    if n < 2 {
284        return Vec::new();
285    }
286
287    let num_segs = if closed { n } else { n - 1 };
288    let mut ranges: Vec<(f32, f32)> = Vec::new();
289
290    let mut i = 0;
291    while i < n {
292        if points[i].gap_after {
293            let start_t = i as f32 / num_segs as f32;
294            // Merge consecutive gap_after CPs
295            let mut end_idx = i + 1;
296            while end_idx < n && points[end_idx].gap_after {
297                end_idx += 1;
298            }
299            // For closed splines, the gap extends to (end_idx)/num_segs
300            // For open splines, same but clamped to 1.0
301            let end_t = (end_idx as f32 / num_segs as f32).min(1.0);
302            ranges.push((start_t, end_t));
303            i = end_idx;
304        } else {
305            i += 1;
306        }
307    }
308
309    // Handle wrap-around for closed splines: if the last range ends at 1.0
310    // and the first range starts at 0.0, merge them
311    if closed && ranges.len() >= 2 {
312        let last = ranges.len() - 1;
313        if (ranges[last].1 - 1.0).abs() < 1e-6 && ranges[0].0.abs() < 1e-6 {
314            let merged_start = ranges[last].0;
315            let merged_end = ranges[0].1;
316            ranges[0] = (merged_start, merged_end); // wrap-around range: start > end
317            ranges.pop();
318        }
319    }
320
321    ranges
322}
323
324/// Check if parametric t falls within any gap range.
325///
326/// Start is inclusive, end is exclusive.
327pub fn is_in_gap(t: f32, gaps: &[(f32, f32)]) -> bool {
328    gap_at(t, gaps).is_some()
329}
330
331/// Return the gap range containing t, or None.
332///
333/// Handles wrap-around ranges where start > end (for closed splines).
334pub fn gap_at(t: f32, gaps: &[(f32, f32)]) -> Option<(f32, f32)> {
335    let t_wrapped = ((t % 1.0) + 1.0) % 1.0;
336    for &(start, end) in gaps {
337        if start <= end {
338            // Normal range
339            if t_wrapped >= start && t_wrapped < end {
340                return Some((start, end));
341            }
342        } else {
343            // Wrap-around range (e.g. 0.9..0.1)
344            if t_wrapped >= start || t_wrapped < end {
345                return Some((start, end));
346            }
347        }
348    }
349    None
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn make_square_loop() -> Vec<SplineControlPoint> {
357        vec![
358            SplineControlPoint {
359                position: Vec3::new(0.0, 0.0, 0.0),
360                twist: 0.0,
361                gap_after: false,
362            },
363            SplineControlPoint {
364                position: Vec3::new(10.0, 0.0, 0.0),
365                twist: 0.0,
366                gap_after: false,
367            },
368            SplineControlPoint {
369                position: Vec3::new(10.0, 0.0, 10.0),
370                twist: 0.0,
371                gap_after: false,
372            },
373            SplineControlPoint {
374                position: Vec3::new(0.0, 0.0, 10.0),
375                twist: 0.0,
376                gap_after: false,
377            },
378        ]
379    }
380
381    #[test]
382    fn closed_spline_produces_samples() {
383        let pts = make_square_loop();
384        let samples = sample_closed_spline(&pts, 1.0);
385        assert!(samples.len() > 10);
386        // t values should span [0, 1)
387        assert!(samples.first().unwrap().t < 0.01);
388        assert!(samples.last().unwrap().t < 1.0);
389    }
390
391    #[test]
392    fn open_spline_produces_samples() {
393        let pts = vec![
394            SplineControlPoint {
395                position: Vec3::new(0.0, 0.0, 0.0),
396                twist: 0.0,
397                gap_after: false,
398            },
399            SplineControlPoint {
400                position: Vec3::new(5.0, 0.0, 0.0),
401                twist: 0.0,
402                gap_after: false,
403            },
404            SplineControlPoint {
405                position: Vec3::new(10.0, 0.0, 0.0),
406                twist: 0.0,
407                gap_after: false,
408            },
409        ];
410        let samples = sample_open_spline(&pts, 1.0);
411        assert!(samples.len() >= 10);
412        // Endpoints should be near the original endpoints
413        let first = samples.first().unwrap();
414        let last = samples.last().unwrap();
415        assert!((first.position - pts[0].position).length() < 0.1);
416        assert!((last.position - pts[2].position).length() < 0.5);
417    }
418
419    #[test]
420    fn too_few_points_returns_empty() {
421        let pts = vec![
422            SplineControlPoint {
423                position: Vec3::new(0.0, 0.0, 0.0),
424                twist: 0.0,
425                gap_after: false,
426            },
427            SplineControlPoint {
428                position: Vec3::new(1.0, 0.0, 0.0),
429                twist: 0.0,
430                gap_after: false,
431            },
432        ];
433        assert!(sample_closed_spline(&pts, 1.0).is_empty());
434    }
435
436    #[test]
437    fn gap_ranges_single_gap_closed() {
438        let pts = vec![
439            SplineControlPoint {
440                position: Vec3::new(0.0, 0.0, 0.0),
441                twist: 0.0,
442                gap_after: false,
443            },
444            SplineControlPoint {
445                position: Vec3::new(10.0, 0.0, 0.0),
446                twist: 0.0,
447                gap_after: true,
448            },
449            SplineControlPoint {
450                position: Vec3::new(10.0, 0.0, 10.0),
451                twist: 0.0,
452                gap_after: false,
453            },
454            SplineControlPoint {
455                position: Vec3::new(0.0, 0.0, 10.0),
456                twist: 0.0,
457                gap_after: false,
458            },
459        ];
460        let ranges = compute_gap_ranges(&pts, true);
461        assert_eq!(ranges.len(), 1);
462        assert!((ranges[0].0 - 0.25).abs() < 0.01); // CP1 → t=1/4
463        assert!((ranges[0].1 - 0.50).abs() < 0.01); // CP2 → t=2/4
464    }
465
466    #[test]
467    fn gap_ranges_consecutive_merge() {
468        let pts = vec![
469            SplineControlPoint {
470                position: Vec3::new(0.0, 0.0, 0.0),
471                twist: 0.0,
472                gap_after: false,
473            },
474            SplineControlPoint {
475                position: Vec3::new(10.0, 0.0, 0.0),
476                twist: 0.0,
477                gap_after: true,
478            },
479            SplineControlPoint {
480                position: Vec3::new(10.0, 0.0, 10.0),
481                twist: 0.0,
482                gap_after: true,
483            },
484            SplineControlPoint {
485                position: Vec3::new(0.0, 0.0, 10.0),
486                twist: 0.0,
487                gap_after: false,
488            },
489        ];
490        let ranges = compute_gap_ranges(&pts, true);
491        assert_eq!(ranges.len(), 1);
492        assert!((ranges[0].0 - 0.25).abs() < 0.01); // CP1
493        assert!((ranges[0].1 - 0.75).abs() < 0.01); // CP3
494    }
495
496    #[test]
497    fn gap_ranges_open_spline() {
498        let pts = vec![
499            SplineControlPoint {
500                position: Vec3::new(0.0, 0.0, 0.0),
501                twist: 0.0,
502                gap_after: true,
503            },
504            SplineControlPoint {
505                position: Vec3::new(5.0, 0.0, 0.0),
506                twist: 0.0,
507                gap_after: false,
508            },
509            SplineControlPoint {
510                position: Vec3::new(10.0, 0.0, 0.0),
511                twist: 0.0,
512                gap_after: false,
513            },
514        ];
515        let ranges = compute_gap_ranges(&pts, false);
516        assert_eq!(ranges.len(), 1);
517        assert!(ranges[0].0.abs() < 0.01); // CP0 → t=0
518        assert!((ranges[0].1 - 0.50).abs() < 0.01); // CP1 → t=1/2
519    }
520
521    #[test]
522    fn is_in_gap_basic() {
523        let gaps = vec![(0.25, 0.50)];
524        assert!(!is_in_gap(0.1, &gaps));
525        assert!(is_in_gap(0.3, &gaps));
526        assert!(!is_in_gap(0.5, &gaps)); // end exclusive
527        assert!(!is_in_gap(0.7, &gaps));
528    }
529
530    #[test]
531    fn gap_at_returns_range() {
532        let gaps = vec![(0.25, 0.50)];
533        assert!(gap_at(0.1, &gaps).is_none());
534        let g = gap_at(0.3, &gaps).unwrap();
535        assert!((g.0 - 0.25).abs() < 0.01);
536        assert!((g.1 - 0.50).abs() < 0.01);
537    }
538
539    #[test]
540    fn gap_wraparound_closed() {
541        // Gaps at CP3 (last) that wraps to CP0
542        let pts = vec![
543            SplineControlPoint {
544                position: Vec3::new(0.0, 0.0, 0.0),
545                twist: 0.0,
546                gap_after: false,
547            },
548            SplineControlPoint {
549                position: Vec3::new(10.0, 0.0, 0.0),
550                twist: 0.0,
551                gap_after: false,
552            },
553            SplineControlPoint {
554                position: Vec3::new(10.0, 0.0, 10.0),
555                twist: 0.0,
556                gap_after: false,
557            },
558            SplineControlPoint {
559                position: Vec3::new(0.0, 0.0, 10.0),
560                twist: 0.0,
561                gap_after: true,
562            },
563        ];
564        let ranges = compute_gap_ranges(&pts, true);
565        assert_eq!(ranges.len(), 1);
566        // Last CP gap: t=0.75 to 1.0 — no merge since CP0 doesn't have gap_after
567        assert!((ranges[0].0 - 0.75).abs() < 0.01);
568        assert!((ranges[0].1 - 1.0).abs() < 0.01);
569        assert!(is_in_gap(0.8, &ranges));
570        assert!(!is_in_gap(0.1, &ranges));
571    }
572}