axiolid_evaluate/polyline_length.rs
1//! Exact distance-to-parameter conversion for polylines.
2//!
3//! A polyline is the one refused family whose arc length is a finite
4//! sum rather than an integral: locating a distance is a running sum
5//! until the interval is found, then one linear interpolation. The
6//! only transcendental is the `sqrt` in each segment length, which
7//! `Line` already relies on and reports as `ArcLength3d`, so refusing
8//! the sequence while accepting each element was not defensible on
9//! exactness grounds (kernel#107).
10//!
11//! Three edges have a plausible-looking wrong answer, so each is
12//! pinned deliberately rather than left to fall out of the code:
13//!
14//! - A SEAM is two-valued. At an interior vertex the tangent jumps,
15//! so this returns the parameter of the OUTGOING segment: a
16//! distance that lands exactly on a vertex reads the heading the
17//! curve is about to take, not the one it arrived with.
18//! - A ZERO-LENGTH segment (repeated identical points) has no
19//! direction. Skipping it would silently change the
20//! parameterisation, so it is refused instead.
21//! - A CLOSED polyline's wrap segment is real length and is
22//! included, but distance still may not exceed the total: it is
23//! clamped by refusal, never wrapped around.
24
25use axiolid_contracts::{GeomError, GeomResult};
26use axiolid_core::{Point3, Scalar};
27use axiolid_curve::Polyline3;
28
29/// Index pairs of the segments of `points`, honouring `closed`.
30fn spans(count: usize, closed: bool) -> Vec<(usize, usize)> {
31 if closed {
32 (0..count).map(|i| (i, (i + 1) % count)).collect()
33 } else {
34 (0..count.saturating_sub(1)).map(|i| (i, i + 1)).collect()
35 }
36}
37
38/// Total 3D length of `polyline`, or an error if any segment is
39/// degenerate or non-finite.
40pub fn polyline_length(polyline: &Polyline3) -> GeomResult<Scalar> {
41 let mut total = 0.0;
42 for (i, j) in spans(polyline.points.len(), polyline.closed) {
43 total += segment_length(polyline.points[i], polyline.points[j])?;
44 }
45 Ok(total)
46}
47
48/// Length of one segment, refusing the degenerate cases by name.
49fn segment_length(a: Point3, b: Point3) -> GeomResult<Scalar> {
50 let length = (b - a).length();
51 if !length.is_finite() {
52 return Err(GeomError::Degenerate(
53 "polyline segment has a non-finite length".to_string(),
54 ));
55 }
56 if length <= 0.0 {
57 return Err(GeomError::Degenerate(
58 "polyline has a zero-length segment, so distance along it is ambiguous".to_string(),
59 ));
60 }
61 Ok(length)
62}
63
64/// Convert a distance along `polyline` to its native parameter.
65///
66/// The parameter runs `[0, segment_count]` with the integer part
67/// selecting the segment, matching `curve::evaluate3`.
68pub fn polyline_parameter(polyline: &Polyline3, distance: Scalar) -> GeomResult<Scalar> {
69 let spans = spans(polyline.points.len(), polyline.closed);
70 if spans.is_empty() {
71 return Err(GeomError::Degenerate(format!(
72 "polyline with {} points has no evaluable segment",
73 polyline.points.len()
74 )));
75 }
76
77 // Every segment is measured up front so a degenerate one is
78 // refused even when the requested distance stops short of it:
79 // the curve is ill-defined as a whole, not just past that point.
80 let mut lengths = Vec::with_capacity(spans.len());
81 for (i, j) in &spans {
82 lengths.push(segment_length(polyline.points[*i], polyline.points[*j])?);
83 }
84 let total: Scalar = lengths.iter().sum();
85
86 if !distance.is_finite() || distance < 0.0 || distance > total {
87 return Err(GeomError::InvalidInput(format!(
88 "distance {distance} is outside the polyline's length [0, {total}]"
89 )));
90 }
91
92 // Running sum until the interval is found. Both `<` and `<=` yield the
93 // same parameter at a vertex (`t` lands exactly on the integer either
94 // way); what actually pins the seam is `polyline_span`'s `floor`, which
95 // sends an integral `t` to the segment STARTING there. `<=` is kept so
96 // the loop resolves a vertex distance without relying on the trailing
97 // fallback below.
98 let mut run = 0.0;
99 for (index, length) in lengths.iter().enumerate() {
100 if distance <= run + length {
101 let local = (distance - run) / length;
102 return Ok(index as Scalar + local);
103 }
104 run += length;
105 }
106
107 // Only reachable when rounding leaves `distance` a hair above
108 // the accumulated sum; the domain end is the exact answer.
109 Ok(spans.len() as Scalar)
110}