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 (from, to) = (lo.min(hi), lo.max(hi));
65 let mut stations = vec![from];
66 stations.extend(breaks(curve).into_iter().filter(|&b| b > from && b < to));
67 stations.push(to);
68 let mut length = 0.0;
69 for pair in stations.windows(2) {
70 if pair[1] - pair[0] > tol.parametric() {
71 length += integrate(speed, pair[0], pair[1], tol.confusion())?.abs();
72 }
73 }
74 Ok(length)
75}
76
77fn breaks(curve: &Curve) -> Vec<f64> {
80 match curve {
81 Curve::BSpline(spline) => spline
82 .knots()
83 .distinct()
84 .into_iter()
85 .map(|(value, _)| value)
86 .collect(),
87 Curve::Trimmed(trim) => {
88 let (lo, hi) = trim.domain();
89 let inner = breaks(trim.basis());
90 if trim.is_reversed() {
91 inner.into_iter().map(|b| lo + hi - b).collect()
92 } else {
93 inner
94 }
95 }
96 _ => Vec::new(),
97 }
98}
99
100pub fn parameter_at_length(
114 curve: &Curve,
115 range: (f64, f64),
116 target: f64,
117 tol: Tolerances,
118) -> OgeomResult<f64> {
119 let total = curve_length(curve, range, tol)?;
120 if !target.is_finite() || target < -tol.confusion() {
121 ogeom_bail!(
122 Domain,
123 "arc length {target} is not a distance along a curve"
124 );
125 }
126 if target > total + tol.confusion() {
127 ogeom_bail!(
128 Domain,
129 "asked for the point {target} along a curve {total} long; clamping \
130 it would give an answer indistinguishable from a correct one at \
131 the end"
132 );
133 }
134 if target <= tol.confusion() {
135 return Ok(range.0);
136 }
137 if target >= total - tol.confusion() {
138 return Ok(range.1);
139 }
140
141 let failed: core::cell::Cell<Option<ogeom_core::OgeomError>> = core::cell::Cell::new(None);
148 let residual = |u: f64| match curve_length(curve, (range.0, u), tol) {
149 Ok(length) => length - target,
150 Err(e) => {
151 failed.set(Some(e));
152 0.0
153 }
154 };
155 let criteria = solve::Criteria {
156 residual: tol.confusion(),
160 step: tol.parametric(),
161 ..solve::Criteria::default()
162 };
163 let found = solve::brent(residual, range.0, range.1, criteria);
164 if let Some(e) = failed.take() {
165 return Err(e);
166 }
167 Ok(found?.value)
168}
169
170pub fn points_by_count(
179 curve: &Curve,
180 range: (f64, f64),
181 count: usize,
182 tol: Tolerances,
183) -> OgeomResult<Vec<(f64, Point)>> {
184 if count < 2 {
185 ogeom_bail!(
186 Construction,
187 "a distribution along a curve needs at least its two ends, got \
188 {count}"
189 );
190 }
191 let total = curve_length(curve, range, tol)?;
192 #[allow(clippy::cast_precision_loss)]
193 let step = total / (count - 1) as f64;
194 let mut out = Vec::with_capacity(count);
195 for i in 0..count {
196 #[allow(clippy::cast_precision_loss)]
197 let at = parameter_at_length(curve, range, step * i as f64, tol)?;
198 out.push((at, curve.point_at(at, tol)?));
199 }
200 Ok(out)
201}
202
203pub fn points_by_spacing(
216 curve: &Curve,
217 range: (f64, f64),
218 spacing: f64,
219 tol: Tolerances,
220) -> OgeomResult<Vec<(f64, Point)>> {
221 if !spacing.is_finite() || spacing <= tol.confusion() {
222 ogeom_bail!(
223 Construction,
224 "spacing {spacing} must be finite and positive"
225 );
226 }
227 let total = curve_length(curve, range, tol)?;
228 let mut out = Vec::new();
229 let mut at_length = 0.0;
230 while at_length < total - tol.confusion() {
231 let at = parameter_at_length(curve, range, at_length, tol)?;
232 out.push((at, curve.point_at(at, tol)?));
233 at_length += spacing;
234 }
235 out.push((range.1, curve.point_at(range.1, tol)?));
236 Ok(out)
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used)]
241mod tests {
242 use super::*;
243 use approx::assert_relative_eq;
244 use core::f64::consts::{PI, TAU};
245 use ogeom_geom::{BSplineCurve, CircleCurve, LineCurve};
246 use ogeom_math::{Circle, Frame, KnotVector, Point};
247
248 const T: Tolerances = Tolerances::millimetres();
249
250 fn circle(radius: f64) -> Curve {
251 CircleCurve::new(Circle::new(Frame::WORLD, radius, T).unwrap()).into()
252 }
253
254 #[test]
255 fn a_lines_length_is_the_distance_between_its_ends() {
256 let line: Curve = LineCurve::segment(Point::ORIGIN, Point::new(3.0, 4.0, 0.0), T)
257 .unwrap()
258 .into();
259 assert_relative_eq!(
260 curve_length(&line, (0.0, 5.0), T).unwrap(),
261 5.0,
262 epsilon = 1e-12
263 );
264 assert_relative_eq!(
266 parameter_at_length(&line, (0.0, 5.0), 2.5, T).unwrap(),
267 2.5,
268 epsilon = 1e-9
269 );
270 }
271
272 #[test]
273 fn a_circles_length_is_its_circumference_and_an_arcs_is_the_fraction() {
274 let c = circle(2.0);
275 assert_relative_eq!(
276 curve_length(&c, (0.0, TAU), T).unwrap(),
277 TAU * 2.0,
278 epsilon = 1e-9
279 );
280 assert_relative_eq!(
281 curve_length(&c, (0.0, PI), T).unwrap(),
282 PI * 2.0,
283 epsilon = 1e-9
284 );
285 }
286
287 #[test]
288 fn length_is_measured_on_the_curve_not_on_a_polyline_through_it() {
289 let c = circle(1.0);
293 let exact = TAU;
294 let integrated = curve_length(&c, (0.0, TAU), T).unwrap();
295 assert!(
296 (integrated - exact).abs() < 1e-9,
297 "got {integrated} against {exact}"
298 );
299
300 let mesh =
301 ogeom_mesh::discretize(&c, (0.0, TAU), ogeom_mesh::Deflection::default(), T).unwrap();
302 assert!(
303 mesh.length() < exact - 1e-4,
304 "a coarse polyline should be visibly short, got {}",
305 mesh.length()
306 );
307 }
308
309 #[test]
310 fn points_by_count_are_evenly_spaced_along_the_curve() {
311 let c = circle(3.0);
314 let points = points_by_count(&c, (0.0, TAU), 9, T).unwrap();
315 assert_eq!(points.len(), 9);
316
317 let step = TAU / 8.0;
318 for (i, (at, _)) in points.iter().enumerate() {
319 #[allow(clippy::cast_precision_loss)]
320 let want = step * i as f64;
321 assert!((at - want).abs() < 1e-6, "point {i} at {at}, wanted {want}");
322 }
323 let first = points[0].1.distance(points[1].1);
325 for pair in points.windows(2) {
326 assert_relative_eq!(pair[0].1.distance(pair[1].1), first, max_relative = 1e-6);
327 }
328 }
329
330 #[test]
331 fn an_unevenly_parameterized_curve_is_still_evenly_divided() {
332 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();
337 let control = vec![
338 Point::new(0.0, 0.0, 0.0),
339 Point::new(0.5, 4.0, 0.0),
340 Point::new(3.0, 4.0, 0.0),
341 Point::new(9.0, 0.5, 0.0),
342 Point::new(10.0, 0.0, 0.0),
343 ];
344 let spline: Curve = BSplineCurve::new(knots, control, T).unwrap().into();
345 let range = spline.domain();
346
347 let points = points_by_count(&spline, range, 12, T).unwrap();
348 let step = curve_length(&spline, (points[0].0, points[1].0), T).unwrap();
354 for pair in points.windows(2) {
355 let along = curve_length(&spline, (pair[0].0, pair[1].0), T).unwrap();
356 assert_relative_eq!(along, step, max_relative = 1e-6);
357 }
358
359 let steps: Vec<f64> = points.windows(2).map(|w| w[1].0 - w[0].0).collect();
361 let spread = steps.iter().fold(0.0_f64, |a, b| a.max(*b))
362 / steps.iter().fold(f64::MAX, |a, b| a.min(*b));
363 assert!(
364 spread > 1.5,
365 "this curve's parameter should be visibly uneven, spread {spread}"
366 );
367 }
368
369 #[test]
370 fn spacing_always_reaches_the_end_even_when_it_does_not_divide() {
371 let c = circle(1.0);
372 let total = TAU;
373 let points = points_by_spacing(&c, (0.0, total), 1.0, T).unwrap();
375 assert!(points.len() >= 7);
376 assert_relative_eq!(points[0].0, 0.0, epsilon = 1e-12);
377 assert_relative_eq!(points[points.len() - 1].0, total, epsilon = 1e-9);
378
379 for pair in points[..points.len() - 1].windows(2) {
381 let along = curve_length(&c, (pair[0].0, pair[1].0), T).unwrap();
382 assert_relative_eq!(along, 1.0, max_relative = 1e-6);
383 }
384 let tail = curve_length(
385 &c,
386 (points[points.len() - 2].0, points[points.len() - 1].0),
387 T,
388 )
389 .unwrap();
390 assert!(
391 tail <= 1.0 + 1e-9,
392 "the last gap should be short, got {tail}"
393 );
394 }
395
396 #[test]
397 fn asking_beyond_the_end_is_refused_rather_than_clamped() {
398 let c = circle(1.0);
401 assert!(parameter_at_length(&c, (0.0, PI), PI * 2.0, T).is_err());
402 assert!(parameter_at_length(&c, (0.0, PI), -1.0, T).is_err());
403 assert!(parameter_at_length(&c, (0.0, PI), PI, T).is_ok());
404 }
405
406 #[test]
407 fn distributions_that_describe_nothing_are_refused() {
408 let c = circle(1.0);
409 assert!(points_by_count(&c, (0.0, TAU), 1, T).is_err());
410 assert!(points_by_count(&c, (0.0, TAU), 0, T).is_err());
411 assert!(points_by_spacing(&c, (0.0, TAU), 0.0, T).is_err());
412 assert!(points_by_spacing(&c, (0.0, TAU), -1.0, T).is_err());
413 assert!(points_by_spacing(&c, (0.0, TAU), f64::NAN, T).is_err());
414 }
415
416 #[test]
420 fn lengths_and_points_across_a_kink() {
421 let knots = KnotVector::new(vec![0.0, 0.0, 0.37, 1.0, 1.0], 1).unwrap();
422 let corner: Curve = BSplineCurve::new(
423 knots,
424 vec![
425 Point::new(0.0, 0.0, 0.0),
426 Point::new(30.0, 0.0, 0.0),
427 Point::new(30.0, 70.0, 0.0),
428 ],
429 T,
430 )
431 .unwrap()
432 .into();
433 assert_relative_eq!(
434 curve_length(&corner, (0.0, 1.0), T).unwrap(),
435 100.0,
436 epsilon = 1e-9
437 );
438 let placed = points_by_count(&corner, (0.0, 1.0), 5, T).unwrap();
439 let want = [
440 (0.0, 0.0),
441 (25.0, 0.0),
442 (30.0, 20.0),
443 (30.0, 45.0),
444 (30.0, 70.0),
445 ];
446 for ((_, p), (x, y)) in placed.iter().zip(want) {
447 assert!(
448 p.distance(Point::new(x, y, 0.0)) < 1e-6,
449 "{p:?} against ({x}, {y})"
450 );
451 }
452 }
453}