axiolid_construct/
center_line.rs

1//! Centre-line profiles: offset an open path into a closed boundary.
2//!
3//! The area a centre line denotes is the set of points within a half-width of
4//! its path. This module resolves that into an explicit ring by walking the
5//! flattened path out one side and back the other.
6//!
7//! Offsetting is done on the FLATTENED path rather than on the exact curves.
8//! A true exact offset of a spline is a different curve type, not the same
9//! curve moved sideways, so producing one here would either be wrong or would
10//! need a curve representation the kernel does not have. Flattening first
11//! keeps the approximation in one place and under the caller's chord budget,
12//! which is the same contract every other curved profile already accepts.
13
14use axiolid_contracts::{GeomError, GeomResult};
15use axiolid_core::{Point2, Scalar, Tolerance};
16use axiolid_profile::CenterLineProfile;
17
18use crate::profile::Rings;
19
20/// Resolve a centre-line profile into a closed ring.
21pub fn center_line_rings(
22    profile: &CenterLineProfile,
23    chord_error: Scalar,
24    tolerance: Tolerance,
25    flatten: impl Fn(&axiolid_profile::Contour, Scalar, Tolerance) -> GeomResult<Vec<Point2>>,
26) -> GeomResult<Rings> {
27    // Written as an explicit non-positive test rather than a negated `>`:
28    // a NaN half-width must be refused too, and `!(x > 0.0)` states that by
29    // accident rather than on purpose.
30    if profile.half_width <= 0.0 || profile.half_width.is_nan() {
31        return Err(GeomError::Degenerate(format!(
32            "centre line half-width must be positive, got {}",
33            profile.half_width
34        )));
35    }
36    let path = flatten(&profile.path, chord_error, tolerance)?;
37    if path.len() < 2 {
38        return Err(GeomError::Degenerate(format!(
39            "centre line path flattened to {} points, need at least 2",
40            path.len()
41        )));
42    }
43
44    let left = offset_polyline(&path, profile.half_width, tolerance)?;
45    let right = offset_polyline(&path, -profile.half_width, tolerance)?;
46
47    // Walk out along one side and back along the other: the two offsets plus
48    // the flat end caps form one closed ring. Butt caps are used because the
49    // source states a width and an extent, not an end treatment; inventing a
50    // round or square cap would add material the author did not specify.
51    let mut outer = left;
52    outer.extend(right.into_iter().rev());
53    Ok(Rings {
54        outer,
55        holes: Vec::new(),
56    })
57}
58
59/// Offset a polyline sideways by a signed distance.
60///
61/// Interior vertices use a MITER join: the offset point sits on the
62/// intersection of the two offset edges, which is the only join that keeps a
63/// constant width through a corner. Bevelling or rounding a corner would make
64/// the section narrower there than the author declared.
65///
66/// The miter length grows without bound as a corner closes on itself, so a
67/// reversal is rejected rather than emitting a spike that would self-intersect
68/// the ring and produce a solid whose volume depends on the tessellation.
69fn offset_polyline(
70    path: &[Point2],
71    distance: Scalar,
72    tolerance: Tolerance,
73) -> GeomResult<Vec<Point2>> {
74    let eps = tolerance.linear();
75    let normal_of = |a: Point2, b: Point2| -> GeomResult<Point2> {
76        let dx = b.x - a.x;
77        let dy = b.y - a.y;
78        let len = (dx * dx + dy * dy).sqrt();
79        if len <= eps {
80            return Err(GeomError::Degenerate(
81                "centre line has a zero-length segment".to_string(),
82            ));
83        }
84        // Left normal of the direction vector.
85        Ok(Point2::new(-dy / len, dx / len))
86    };
87
88    let mut out = Vec::with_capacity(path.len());
89    for index in 0..path.len() {
90        if index == 0 {
91            let n = normal_of(path[0], path[1])?;
92            out.push(Point2::new(
93                path[0].x + n.x * distance,
94                path[0].y + n.y * distance,
95            ));
96        } else if index == path.len() - 1 {
97            let n = normal_of(path[index - 1], path[index])?;
98            out.push(Point2::new(
99                path[index].x + n.x * distance,
100                path[index].y + n.y * distance,
101            ));
102        } else {
103            let n0 = normal_of(path[index - 1], path[index])?;
104            let n1 = normal_of(path[index], path[index + 1])?;
105            // The miter direction bisects the two edge normals; scaling it by
106            // 1/cos(half-angle) puts it on both offset lines at once.
107            let mx = n0.x + n1.x;
108            let my = n0.y + n1.y;
109            let denom = 1.0 + (n0.x * n1.x + n0.y * n1.y);
110            if denom <= eps {
111                return Err(GeomError::Degenerate(
112                    "centre line reverses on itself; the miter is unbounded".to_string(),
113                ));
114            }
115            out.push(Point2::new(
116                path[index].x + (mx / denom) * distance,
117                path[index].y + (my / denom) * distance,
118            ));
119        }
120    }
121    Ok(out)
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use axiolid_core::Interval;
128    use axiolid_curve::linear::Polyline2;
129    use axiolid_curve::Curve2;
130    use axiolid_profile::{Contour, ProfileSegment};
131
132    fn polyline_path(points: &[(f64, f64)]) -> Contour {
133        let pts: Vec<Point2> = points.iter().map(|(x, y)| Point2::new(*x, *y)).collect();
134        Contour::new(vec![ProfileSegment {
135            curve: Curve2::Polyline(Polyline2 {
136                points: pts,
137                closed: false,
138            }),
139            domain: Interval::new(0.0, (points.len() - 1) as f64),
140            same_sense: true,
141        }])
142    }
143
144    fn flatten(contour: &Contour, _chord: Scalar, _tol: Tolerance) -> GeomResult<Vec<Point2>> {
145        // The real flattener closes rings by dropping a repeated last point.
146        // A centre line is open, so the test feeds points through unchanged.
147        let mut out = Vec::new();
148        for segment in &contour.segments {
149            if let Curve2::Polyline(p) = &segment.curve {
150                out.extend(p.points.iter().copied());
151            }
152        }
153        Ok(out)
154    }
155
156    fn tol() -> Tolerance {
157        Tolerance::new(1e-9, 1e-9).expect("valid tolerance")
158    }
159
160    fn area(ring: &[Point2]) -> f64 {
161        let mut sum = 0.0;
162        for i in 0..ring.len() {
163            let a = ring[i];
164            let b = ring[(i + 1) % ring.len()];
165            sum += a.x * b.y - b.x * a.y;
166        }
167        sum.abs() / 2.0
168    }
169
170    /// A straight centre line encloses length times width, exactly.
171    ///
172    /// This is the assertion that catches a half-width read as a full width:
173    /// the ring still closes and still looks like a plausible bar, but every
174    /// quantity taken from it is out by a factor of two.
175    #[test]
176    fn straight_center_line_has_length_times_width_area() {
177        let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (2.0, 0.0)]), 0.05);
178        let rings = center_line_rings(
179            &profile,
180            1e-4,
181            Tolerance::new(1e-9, 1e-9).expect("valid tolerance"),
182            flatten,
183        )
184        .expect("straight centre line resolves");
185        assert!(rings.holes.is_empty(), "a centre line encloses no holes");
186        let got = area(&rings.outer);
187        assert!(
188            (got - 0.1).abs() < 1e-12,
189            "2.0 long by 0.05 wide is 0.1, got {got}"
190        );
191    }
192
193    /// A right-angle corner keeps full width through the bend.
194    ///
195    /// A mitered strip has area equal to centre-line length times width,
196    /// because the outer corner gains exactly the wedge the inner corner
197    /// loses. That identity is what makes this test worth having: it holds for
198    /// ANY corner angle, so it catches a join that bevels (too little area) or
199    /// one that overshoots the miter (too much), neither of which a
200    /// closes-and-looks-plausible check would notice.
201    ///
202    /// It is deliberately NOT the union of two bars minus their overlap
203    /// (0.19): that describes a strip whose corner is clipped square, which is
204    /// a different and narrower section through the bend.
205    #[test]
206    fn a_mitered_corner_keeps_constant_width() {
207        let profile = CenterLineProfile::from_width(
208            polyline_path(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]),
209            0.1,
210        );
211        let rings = center_line_rings(&profile, 1e-4, tol(), flatten).expect("corner resolves");
212        let got = area(&rings.outer);
213        assert!(
214            (got - 0.2).abs() < 1e-12,
215            "a mitered strip is length times width: 2.0 * 0.1 = 0.2, got {got}"
216        );
217    }
218
219    /// Half the width is measured each side, not the whole width.
220    #[test]
221    fn width_is_symmetric_about_the_path() {
222        let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (1.0, 0.0)]), 0.2);
223        let rings = center_line_rings(&profile, 1e-4, tol(), flatten).expect("resolves");
224        let ys: Vec<f64> = rings.outer.iter().map(|p| p.y).collect();
225        let top = ys.iter().cloned().fold(f64::MIN, f64::max);
226        let bottom = ys.iter().cloned().fold(f64::MAX, f64::min);
227        assert!(
228            (top - 0.1).abs() < 1e-12,
229            "top offset is half the width, got {top}"
230        );
231        assert!(
232            (bottom + 0.1).abs() < 1e-12,
233            "bottom offset is half the width, got {bottom}"
234        );
235    }
236
237    /// A path that doubles back has an unbounded miter and is refused.
238    #[test]
239    fn a_reversing_path_is_refused_not_spiked() {
240        let profile = CenterLineProfile::from_width(
241            polyline_path(&[(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)]),
242            0.1,
243        );
244        let error = center_line_rings(&profile, 1e-4, tol(), flatten)
245            .expect_err("a reversal has no finite miter");
246        assert!(
247            matches!(error, GeomError::Degenerate(_)),
248            "expected a typed degeneracy, got {error:?}"
249        );
250    }
251
252    /// A non-positive width encloses nothing and is refused.
253    #[test]
254    fn a_zero_width_center_line_is_refused() {
255        let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (1.0, 0.0)]), 0.0);
256        let error = center_line_rings(&profile, 1e-4, tol(), flatten)
257            .expect_err("zero width encloses no area");
258        assert!(matches!(error, GeomError::Degenerate(_)));
259    }
260
261    /// A NaN width is refused rather than propagating into every coordinate.
262    ///
263    /// NaN fails every comparison, so a naive positive-width guard lets it
264    /// through and it silently poisons the whole ring.
265    #[test]
266    fn a_nan_width_center_line_is_refused() {
267        let profile = CenterLineProfile {
268            path: polyline_path(&[(0.0, 0.0), (1.0, 0.0)]),
269            half_width: f64::NAN,
270        };
271        let error =
272            center_line_rings(&profile, 1e-4, tol(), flatten).expect_err("NaN is not a width");
273        assert!(matches!(error, GeomError::Degenerate(_)));
274    }
275}