1use axiolid_contracts::{GeomError, GeomResult};
15use axiolid_core::{Point2, Scalar, Tolerance};
16use axiolid_profile::CenterLineProfile;
17
18use crate::profile::Rings;
19
20pub 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 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 let mut outer = left;
52 outer.extend(right.into_iter().rev());
53 Ok(Rings {
54 outer,
55 holes: Vec::new(),
56 })
57}
58
59fn 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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}