Skip to main content

brepkit_geometry/sampling/
arc_length.rs

1//! Arc-length parameterized curve sampling.
2
3use brepkit_math::traits::ParametricCurve;
4use brepkit_math::vec::Point3;
5
6/// Number of segments used for the coarse chord-length table.
7const CHORD_SEGMENTS: usize = 256;
8
9/// Sample `n` points at approximately equal arc-length spacing over `[t_start, t_end]`.
10///
11/// Uses a fine-resolution chord-length approximation (256 segments) to build a
12/// cumulative arc-length table, then bisects to find the parameter at each
13/// target arc-length fraction.
14///
15/// - `n == 0` returns an empty `Vec`.
16/// - `n == 1` returns a single point at `t_start`.
17/// - `n >= 2` returns points including both endpoints.
18#[must_use]
19pub fn sample_arc_length<C: ParametricCurve>(
20    curve: &C,
21    t_start: f64,
22    t_end: f64,
23    n: usize,
24) -> Vec<(f64, Point3)> {
25    if n == 0 {
26        return Vec::new();
27    }
28    if n == 1 {
29        return vec![(t_start, curve.evaluate(t_start))];
30    }
31
32    // Build a cumulative chord-length table at fine resolution.
33    let segs = CHORD_SEGMENTS;
34    let mut t_table = Vec::with_capacity(segs + 1);
35    let mut arc_table = Vec::with_capacity(segs + 1);
36
37    let mut prev_p = curve.evaluate(t_start);
38    t_table.push(t_start);
39    arc_table.push(0.0_f64);
40
41    for i in 1..=segs {
42        let t = if i == segs {
43            t_end
44        } else {
45            t_start + i as f64 * (t_end - t_start) / segs as f64
46        };
47        let p = curve.evaluate(t);
48        let chord = {
49            let dx = p.x() - prev_p.x();
50            let dy = p.y() - prev_p.y();
51            let dz = p.z() - prev_p.z();
52            (dx * dx + dy * dy + dz * dz).sqrt()
53        };
54        t_table.push(t);
55        arc_table.push(arc_table[i - 1] + chord);
56        prev_p = p;
57    }
58
59    let total_len = *arc_table.last().unwrap_or(&0.0);
60
61    // For each target fraction k/(n-1), bisect into the arc-length table to
62    // find the parameter that achieves that arc-length.
63    let mut result = Vec::with_capacity(n);
64
65    for i in 0..n {
66        let target = if i == n - 1 {
67            total_len
68        } else {
69            total_len * i as f64 / (n - 1) as f64
70        };
71
72        // Binary search in arc_table for the segment containing `target`.
73        let seg_idx = arc_table
74            .partition_point(|&s| s < target)
75            .saturating_sub(1)
76            .min(segs - 1);
77
78        let s0 = arc_table[seg_idx];
79        let s1 = arc_table[seg_idx + 1];
80        let t0 = t_table[seg_idx];
81        let t1 = t_table[seg_idx + 1];
82
83        // Linearly interpolate within the fine segment, then bisect on the
84        // actual curve for higher accuracy.
85        let t_approx = if (s1 - s0).abs() < f64::EPSILON {
86            t0
87        } else {
88            t0 + (target - s0) / (s1 - s0) * (t1 - t0)
89        };
90
91        // Bisect on the curve arc-length within [t0, t1] to refine.
92        let t_refined = bisect_arc_length(curve, t0, t1, s0, target, 32);
93
94        // Use whichever is closer to the target; for the endpoints, snap exactly.
95        let t_final = if i == 0 {
96            t_start
97        } else if i == n - 1 {
98            t_end
99        } else {
100            // Prefer the bisected value but fall back to linear if bisect is off.
101            let _ = t_approx; // linear approx available but bisect is better
102            t_refined
103        };
104
105        result.push((t_final, curve.evaluate(t_final)));
106    }
107
108    result
109}
110
111/// Bisect to find the parameter in `[t_lo, t_hi]` at which the arc-length
112/// from `t_lo` (where arc-length offset from curve start is `arc_lo`) reaches
113/// `target_arc`.
114///
115/// Uses chord-length approximation with `max_iter` steps.
116fn bisect_arc_length<C: ParametricCurve>(
117    curve: &C,
118    t_lo: f64,
119    t_hi: f64,
120    arc_lo: f64,
121    target_arc: f64,
122    max_iter: u32,
123) -> f64 {
124    let mut lo = t_lo;
125    let mut hi = t_hi;
126    let mut arc_at_lo = arc_lo;
127
128    for _ in 0..max_iter {
129        let mid = 0.5 * (lo + hi);
130        // Approximate arc-length from lo to mid by chord.
131        let p_lo = curve.evaluate(lo);
132        let p_mid = curve.evaluate(mid);
133        let dx = p_mid.x() - p_lo.x();
134        let dy = p_mid.y() - p_lo.y();
135        let dz = p_mid.z() - p_lo.z();
136        let chord = (dx * dx + dy * dy + dz * dz).sqrt();
137        let arc_at_mid = arc_at_lo + chord;
138
139        if arc_at_mid < target_arc {
140            lo = mid;
141            arc_at_lo = arc_at_mid;
142        } else {
143            hi = mid;
144        }
145    }
146
147    0.5 * (lo + hi)
148}
149
150#[cfg(test)]
151mod tests {
152    #![allow(clippy::unwrap_used, clippy::expect_used)]
153
154    use super::*;
155    use brepkit_math::curves::Circle3D;
156    use brepkit_math::vec::{Point3, Vec3};
157    use std::f64::consts::TAU;
158
159    fn unit_circle() -> Circle3D {
160        Circle3D::new(Point3::new(0.0, 0.0, 0.0), Vec3::new(0.0, 0.0, 1.0), 1.0).unwrap()
161    }
162
163    fn dist(a: Point3, b: Point3) -> f64 {
164        let dx = a.x() - b.x();
165        let dy = a.y() - b.y();
166        let dz = a.z() - b.z();
167        (dx * dx + dy * dy + dz * dz).sqrt()
168    }
169
170    #[test]
171    fn zero_samples_returns_empty() {
172        let c = unit_circle();
173        assert!(sample_arc_length(&c, 0.0, TAU, 0).is_empty());
174    }
175
176    #[test]
177    fn one_sample_returns_start() {
178        let c = unit_circle();
179        let pts = sample_arc_length(&c, 0.0, TAU, 1);
180        assert_eq!(pts.len(), 1);
181        assert!((pts[0].0 - 0.0).abs() < 1e-12);
182    }
183
184    #[test]
185    fn endpoints_included() {
186        let c = unit_circle();
187        let pts = sample_arc_length(&c, 0.0, TAU, 8);
188        assert_eq!(pts.len(), 8);
189        assert!((pts[0].0 - 0.0).abs() < 1e-12);
190        assert!((pts[7].0 - TAU).abs() < 1e-12);
191    }
192
193    #[test]
194    fn spacing_approximately_uniform_on_circle() {
195        // A circle has uniform curvature so arc-length spacing = chord spacing.
196        let c = unit_circle();
197        let pts = sample_arc_length(&c, 0.0, TAU, 16);
198        assert_eq!(pts.len(), 16);
199
200        // Compute consecutive chord distances (skip the wrap-around gap).
201        let dists: Vec<f64> = pts.windows(2).map(|w| dist(w[0].1, w[1].1)).collect();
202
203        let max_d = dists.iter().copied().fold(f64::NEG_INFINITY, f64::max);
204        let min_d = dists.iter().copied().fold(f64::INFINITY, f64::min);
205
206        // Max/min ratio must be ≤ 1.5 — a generous bound for uniformity.
207        assert!(
208            max_d / min_d <= 1.5,
209            "spacing not uniform: max={max_d:.4}, min={min_d:.4}, ratio={:.4}",
210            max_d / min_d
211        );
212    }
213
214    #[test]
215    fn all_points_on_circle() {
216        let c = unit_circle();
217        let pts = sample_arc_length(&c, 0.0, TAU, 12);
218        for (_, p) in &pts {
219            let r = (p.x() * p.x() + p.y() * p.y() + p.z() * p.z()).sqrt();
220            assert!((r - 1.0).abs() < 1e-10, "point not on unit circle: r={r}");
221        }
222    }
223}