1use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
31use ogeom_geom::{Curve, Curve3d};
32use ogeom_math::{Point, integrate, solve};
33
34pub fn curve_length(curve: &Curve, range: (f64, f64), tol: Tolerances) -> OgeomResult<f64> {
48 let (lo, hi) = range;
49 if !lo.is_finite() || !hi.is_finite() {
50 ogeom_bail!(Domain, "cannot measure the length of [{lo}, {hi}]");
51 }
52 if (hi - lo).abs() <= tol.parametric() {
53 return Ok(0.0);
54 }
55 let speed = |u: f64| curve.d1_at(u, tol).map_or(0.0, |d| d.magnitude());
61 let length = integrate(speed, lo, hi, tol.confusion())?;
62 Ok(length.abs())
63}
64
65pub fn parameter_at_length(
79 curve: &Curve,
80 range: (f64, f64),
81 target: f64,
82 tol: Tolerances,
83) -> OgeomResult<f64> {
84 let total = curve_length(curve, range, tol)?;
85 if !target.is_finite() || target < -tol.confusion() {
86 ogeom_bail!(
87 Domain,
88 "arc length {target} is not a distance along a curve"
89 );
90 }
91 if target > total + tol.confusion() {
92 ogeom_bail!(
93 Domain,
94 "asked for the point {target} along a curve {total} long; clamping \
95 it would give an answer indistinguishable from a correct one at \
96 the end"
97 );
98 }
99 if target <= tol.confusion() {
100 return Ok(range.0);
101 }
102 if target >= total - tol.confusion() {
103 return Ok(range.1);
104 }
105
106 let residual = |u: f64| curve_length(curve, (range.0, u), tol).unwrap_or(0.0) - target;
110 let criteria = solve::Criteria {
111 residual: tol.confusion(),
115 step: tol.parametric(),
116 ..solve::Criteria::default()
117 };
118 Ok(solve::brent(residual, range.0, range.1, criteria)?.value)
119}
120
121pub fn points_by_count(
130 curve: &Curve,
131 range: (f64, f64),
132 count: usize,
133 tol: Tolerances,
134) -> OgeomResult<Vec<(f64, Point)>> {
135 if count < 2 {
136 ogeom_bail!(
137 Construction,
138 "a distribution along a curve needs at least its two ends, got \
139 {count}"
140 );
141 }
142 let total = curve_length(curve, range, tol)?;
143 #[allow(clippy::cast_precision_loss)]
144 let step = total / (count - 1) as f64;
145 let mut out = Vec::with_capacity(count);
146 for i in 0..count {
147 #[allow(clippy::cast_precision_loss)]
148 let at = parameter_at_length(curve, range, step * i as f64, tol)?;
149 out.push((at, curve.point_at(at, tol)?));
150 }
151 Ok(out)
152}
153
154pub fn points_by_spacing(
167 curve: &Curve,
168 range: (f64, f64),
169 spacing: f64,
170 tol: Tolerances,
171) -> OgeomResult<Vec<(f64, Point)>> {
172 if !spacing.is_finite() || spacing <= tol.confusion() {
173 ogeom_bail!(
174 Construction,
175 "spacing {spacing} must be finite and positive"
176 );
177 }
178 let total = curve_length(curve, range, tol)?;
179 let mut out = Vec::new();
180 let mut at_length = 0.0;
181 while at_length < total - tol.confusion() {
182 let at = parameter_at_length(curve, range, at_length, tol)?;
183 out.push((at, curve.point_at(at, tol)?));
184 at_length += spacing;
185 }
186 out.push((range.1, curve.point_at(range.1, tol)?));
187 Ok(out)
188}
189
190#[cfg(test)]
191#[allow(clippy::unwrap_used)]
192mod tests {
193 use super::*;
194 use approx::assert_relative_eq;
195 use core::f64::consts::{PI, TAU};
196 use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
197 use ogeom_math::{Circle, Frame, KnotVector};
198
199 const T: Tolerances = Tolerances::millimetres();
200
201 fn circle(radius: f64) -> Curve {
202 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
203 }
204
205 #[test]
206 fn a_lines_length_is_the_distance_between_its_ends() {
207 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
208 .unwrap()
209 .into();
210 assert_relative_eq!(
211 curve_length(&line, (0.0, 5.0), T).unwrap(),
212 5.0,
213 epsilon = 1e-12
214 );
215 assert_relative_eq!(
217 parameter_at_length(&line, (0.0, 5.0), 2.5, T).unwrap(),
218 2.5,
219 epsilon = 1e-9
220 );
221 }
222
223 #[test]
224 fn a_circles_length_is_its_circumference_and_an_arcs_is_the_fraction() {
225 let c = circle(2.0);
226 assert_relative_eq!(
227 curve_length(&c, (0.0, TAU), T).unwrap(),
228 TAU * 2.0,
229 epsilon = 1e-9
230 );
231 assert_relative_eq!(
232 curve_length(&c, (0.0, PI), T).unwrap(),
233 PI * 2.0,
234 epsilon = 1e-9
235 );
236 }
237
238 #[test]
239 fn length_is_measured_on_the_curve_not_on_a_polyline_through_it() {
240 let c = circle(1.0);
244 let exact = TAU;
245 let integrated = curve_length(&c, (0.0, TAU), T).unwrap();
246 assert!(
247 (integrated - exact).abs() < 1e-9,
248 "got {integrated} against {exact}"
249 );
250
251 let mesh =
252 ogeom_mesh::discretize(&c, (0.0, TAU), ogeom_mesh::Deflection::default(), T).unwrap();
253 assert!(
254 mesh.length() < exact - 1e-4,
255 "a coarse polyline should be visibly short, got {}",
256 mesh.length()
257 );
258 }
259
260 #[test]
261 fn points_by_count_are_evenly_spaced_along_the_curve() {
262 let c = circle(3.0);
265 let points = points_by_count(&c, (0.0, TAU), 9, T).unwrap();
266 assert_eq!(points.len(), 9);
267
268 let step = TAU / 8.0;
269 for (i, (at, _)) in points.iter().enumerate() {
270 #[allow(clippy::cast_precision_loss)]
271 let want = step * i as f64;
272 assert!((at - want).abs() < 1e-6, "point {i} at {at}, wanted {want}");
273 }
274 let first = points[0].1.distance(points[1].1);
276 for pair in points.windows(2) {
277 assert_relative_eq!(pair[0].1.distance(pair[1].1), first, max_relative = 1e-6);
278 }
279 }
280
281 #[test]
282 fn an_unevenly_parameterized_curve_is_still_evenly_divided() {
283 let knots = KnotVector::new(vec![0.0, 0.0, 0.0, 0.0, 0.2, 1.0, 1.0, 1.0, 1.0], 3).unwrap();
288 let control = vec![
289 Point::new(0.0, 0.0, 0.0),
290 Point::new(0.5, 4.0, 0.0),
291 Point::new(3.0, 4.0, 0.0),
292 Point::new(9.0, 0.5, 0.0),
293 Point::new(10.0, 0.0, 0.0),
294 ];
295 let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
296 let range = spline.domain();
297
298 let points = points_by_count(&spline, range, 12, T).unwrap();
299 let step = curve_length(&spline, (points[0].0, points[1].0), T).unwrap();
305 for pair in points.windows(2) {
306 let along = curve_length(&spline, (pair[0].0, pair[1].0), T).unwrap();
307 assert_relative_eq!(along, step, max_relative = 1e-6);
308 }
309
310 let steps: Vec<f64> = points.windows(2).map(|w| w[1].0 - w[0].0).collect();
312 let spread = steps.iter().fold(0.0_f64, |a, b| a.max(*b))
313 / steps.iter().fold(f64::MAX, |a, b| a.min(*b));
314 assert!(
315 spread > 1.5,
316 "this curve's parameter should be visibly uneven, spread {spread}"
317 );
318 }
319
320 #[test]
321 fn spacing_always_reaches_the_end_even_when_it_does_not_divide() {
322 let c = circle(1.0);
323 let total = TAU;
324 let points = points_by_spacing(&c, (0.0, total), 1.0, T).unwrap();
326 assert!(points.len() >= 7);
327 assert_relative_eq!(points[0].0, 0.0, epsilon = 1e-12);
328 assert_relative_eq!(points[points.len() - 1].0, total, epsilon = 1e-9);
329
330 for pair in points[..points.len() - 1].windows(2) {
332 let along = curve_length(&c, (pair[0].0, pair[1].0), T).unwrap();
333 assert_relative_eq!(along, 1.0, max_relative = 1e-6);
334 }
335 let tail = curve_length(
336 &c,
337 (points[points.len() - 2].0, points[points.len() - 1].0),
338 T,
339 )
340 .unwrap();
341 assert!(
342 tail <= 1.0 + 1e-9,
343 "the last gap should be short, got {tail}"
344 );
345 }
346
347 #[test]
348 fn asking_beyond_the_end_is_refused_rather_than_clamped() {
349 let c = circle(1.0);
352 assert!(parameter_at_length(&c, (0.0, PI), PI * 2.0, T).is_err());
353 assert!(parameter_at_length(&c, (0.0, PI), -1.0, T).is_err());
354 assert!(parameter_at_length(&c, (0.0, PI), PI, T).is_ok());
355 }
356
357 #[test]
358 fn distributions_that_describe_nothing_are_refused() {
359 let c = circle(1.0);
360 assert!(points_by_count(&c, (0.0, TAU), 1, T).is_err());
361 assert!(points_by_count(&c, (0.0, TAU), 0, T).is_err());
362 assert!(points_by_spacing(&c, (0.0, TAU), 0.0, T).is_err());
363 assert!(points_by_spacing(&c, (0.0, TAU), -1.0, T).is_err());
364 assert!(points_by_spacing(&c, (0.0, TAU), f64::NAN, T).is_err());
365 }
366}