brepkit_geometry/sampling/
arc_length.rs1use brepkit_math::traits::ParametricCurve;
4use brepkit_math::vec::Point3;
5
6const CHORD_SEGMENTS: usize = 256;
8
9#[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 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 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 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 let t_approx = if (s1 - s0).abs() < f64::EPSILON {
86 t0
87 } else {
88 t0 + (target - s0) / (s1 - s0) * (t1 - t0)
89 };
90
91 let t_refined = bisect_arc_length(curve, t0, t1, s0, target, 32);
93
94 let t_final = if i == 0 {
96 t_start
97 } else if i == n - 1 {
98 t_end
99 } else {
100 let _ = t_approx; t_refined
103 };
104
105 result.push((t_final, curve.evaluate(t_final)));
106 }
107
108 result
109}
110
111fn 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 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 let c = unit_circle();
197 let pts = sample_arc_length(&c, 0.0, TAU, 16);
198 assert_eq!(pts.len(), 16);
199
200 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 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}