Skip to main content

molgfx_math/curves/
spline.rs

1//! Deterministic Catmull-Rom evaluation and curvature-adaptive sampling.
2
3use crate::Vec3;
4
5#[cfg(test)]
6#[path = "spline_tests.rs"]
7mod tests;
8
9/// One sampled point and unit tangent along a spline.
10#[derive(Clone, Copy, PartialEq, Debug)]
11pub struct CurveSample {
12    /// Evaluated position.
13    pub position: Vec3,
14    /// Unit direction of increasing curve parameter.
15    pub tangent: Vec3,
16    /// Source control-point interval.
17    pub segment: u32,
18    /// Local parameter within the source interval.
19    pub parameter: f32,
20}
21
22/// Samples a centripetal-looking uniform Catmull-Rom trace with more samples
23/// where midpoint deviation and tangent change are high. Output storage is
24/// caller-owned and reused; work and output are bounded by `max_steps`.
25pub fn sample_catmull_rom(
26    points: &[Vec3],
27    tolerance: f32,
28    max_steps: u8,
29    out: &mut Vec<CurveSample>,
30) {
31    sample_catmull_rom_demanding(points, tolerance, max_steps, &[], out);
32}
33
34/// Adaptive sampling with one extra per-interval demand the curve cannot see.
35///
36/// Deviation and tangent turn describe the shape of the curve, and nothing
37/// else about a ribbon reaches this function. A ribbon that also twists along
38/// the curve needs samples the shape never asks for: a glycan runs almost
39/// straight between two sugars while the ring plane rotates through as much as
40/// a half turn, and it would otherwise be handed one sample per sugar and have
41/// to do that rotation inside a single quad, which folds. Passing the twist as
42/// a demand — in units of samples — buys density exactly where the ribbon turns
43/// instead of everywhere, which is the difference between a fixed dense trace
44/// and one that costs what it needs.
45///
46/// `demand[i]` applies to the interval from control point `i` to `i + 1`; a
47/// shorter or empty slice contributes nothing.
48pub fn sample_catmull_rom_demanding(
49    points: &[Vec3],
50    tolerance: f32,
51    max_steps: u8,
52    demand: &[f32],
53    out: &mut Vec<CurveSample>,
54) {
55    out.clear();
56    if points.len() < 2 {
57        if let Some(&position) = points.first() {
58            out.push(CurveSample {
59                position,
60                tangent: Vec3::Z,
61                segment: 0,
62                parameter: 0.0,
63            });
64        }
65        return;
66    }
67    let maximum = max_steps.max(1);
68    for segment in 0..points.len() - 1 {
69        let curve = segment_points(points, segment);
70        let extra = match demand.get(segment) {
71            Some(value) if value.is_finite() => value.max(0.0),
72            _ => 0.0,
73        };
74        let steps = adaptive_steps(curve, tolerance.max(1e-4), maximum, extra);
75        let start = u8::from(segment != 0);
76        for step in start..=steps {
77            let t = f32::from(step) / f32::from(steps);
78            let derivative = tangent(curve, t);
79            out.push(CurveSample {
80                position: position(curve, t),
81                tangent: normalized(derivative, Vec3::Z),
82                segment: u32::try_from(segment)
83                    .into_iter()
84                    .fold(u32::MAX, |_, value| value),
85                parameter: t,
86            });
87        }
88    }
89}
90
91/// Fixed-step Catmull-Rom sampling: the form a GPU kernel evaluates in
92/// parallel, one invocation per sample. The basis is identical to
93/// [`sample_catmull_rom`] — only the per-segment step count is a constant
94/// instead of curvature-adaptive — so this doubles as the CPU parity reference
95/// for the `spline` compute shader. Output storage is caller-owned and reused.
96pub fn sample_catmull_rom_fixed(
97    points: &[Vec3],
98    steps_per_segment: u8,
99    out: &mut Vec<CurveSample>,
100) {
101    out.clear();
102    if points.len() < 2 {
103        if let Some(&position) = points.first() {
104            out.push(CurveSample {
105                position,
106                tangent: Vec3::Z,
107                segment: 0,
108                parameter: 0.0,
109            });
110        }
111        return;
112    }
113    let steps = steps_per_segment.max(1);
114    for segment in 0..points.len() - 1 {
115        let curve = segment_points(points, segment);
116        let start = u8::from(segment != 0);
117        for step in start..=steps {
118            let t = f32::from(step) / f32::from(steps);
119            out.push(CurveSample {
120                position: position(curve, t),
121                tangent: normalized(tangent(curve, t), Vec3::Z),
122                segment: u32::try_from(segment)
123                    .into_iter()
124                    .fold(u32::MAX, |_, value| value),
125                parameter: t,
126            });
127        }
128    }
129}
130
131#[inline]
132fn segment_points(points: &[Vec3], segment: usize) -> [Vec3; 4] {
133    let last = points.len() - 1;
134    [
135        points[segment.saturating_sub(1)],
136        points[segment],
137        points[(segment + 1).min(last)],
138        points[(segment + 2).min(last)],
139    ]
140}
141
142#[inline]
143fn adaptive_steps(points: [Vec3; 4], tolerance: f32, maximum: u8, extra: f32) -> u8 {
144    let midpoint = position(points, 0.5);
145    let chord_midpoint = (points[1] + points[2]) * 0.5;
146    let deviation = midpoint.distance(chord_midpoint);
147    let start = normalized(tangent(points, 0.0), Vec3::Z);
148    let end = normalized(tangent(points, 1.0), start);
149    let turn = start.dot(end).clamp(-1.0, 1.0).acos();
150    let demand = (deviation / tolerance).sqrt() + turn * 2.0 + extra;
151    let mut steps = 1u8;
152    while steps < maximum && f32::from(steps) < demand {
153        steps = steps.saturating_mul(2).min(maximum);
154    }
155    steps
156}
157
158#[inline]
159fn normalized(value: Vec3, fallback: Vec3) -> Vec3 {
160    match value.try_normalize() {
161        Some(unit) => unit,
162        None => fallback,
163    }
164}
165
166#[inline]
167fn position(points: [Vec3; 4], t: f32) -> Vec3 {
168    let [p0, p1, p2, p3] = points;
169    let t2 = t * t;
170    let t3 = t2 * t;
171    (p1 * 2.0
172        + (p2 - p0) * t
173        + (p0 * 2.0 - p1 * 5.0 + p2 * 4.0 - p3) * t2
174        + (-p0 + p1 * 3.0 - p2 * 3.0 + p3) * t3)
175        * 0.5
176}
177
178#[inline]
179fn tangent(points: [Vec3; 4], t: f32) -> Vec3 {
180    let [p0, p1, p2, p3] = points;
181    let t2 = t * t;
182    ((p2 - p0)
183        + (p0 * 4.0 - p1 * 10.0 + p2 * 8.0 - p3 * 2.0) * t
184        + (-p0 * 3.0 + p1 * 9.0 - p2 * 9.0 + p3 * 3.0) * t2)
185        * 0.5
186}