use axiolid_contracts::{GeomError, GeomResult};
use axiolid_core::{Point2, Scalar, Tolerance};
use axiolid_profile::CenterLineProfile;
use crate::profile::Rings;
pub fn center_line_rings(
profile: &CenterLineProfile,
chord_error: Scalar,
tolerance: Tolerance,
flatten: impl Fn(&axiolid_profile::Contour, Scalar, Tolerance) -> GeomResult<Vec<Point2>>,
) -> GeomResult<Rings> {
if profile.half_width <= 0.0 || profile.half_width.is_nan() {
return Err(GeomError::Degenerate(format!(
"centre line half-width must be positive, got {}",
profile.half_width
)));
}
let path = flatten(&profile.path, chord_error, tolerance)?;
if path.len() < 2 {
return Err(GeomError::Degenerate(format!(
"centre line path flattened to {} points, need at least 2",
path.len()
)));
}
let left = offset_polyline(&path, profile.half_width, tolerance)?;
let right = offset_polyline(&path, -profile.half_width, tolerance)?;
let mut outer = left;
outer.extend(right.into_iter().rev());
Ok(Rings {
outer,
holes: Vec::new(),
})
}
fn offset_polyline(
path: &[Point2],
distance: Scalar,
tolerance: Tolerance,
) -> GeomResult<Vec<Point2>> {
let eps = tolerance.linear();
let normal_of = |a: Point2, b: Point2| -> GeomResult<Point2> {
let dx = b.x - a.x;
let dy = b.y - a.y;
let len = (dx * dx + dy * dy).sqrt();
if len <= eps {
return Err(GeomError::Degenerate(
"centre line has a zero-length segment".to_string(),
));
}
Ok(Point2::new(-dy / len, dx / len))
};
let mut out = Vec::with_capacity(path.len());
for index in 0..path.len() {
if index == 0 {
let n = normal_of(path[0], path[1])?;
out.push(Point2::new(
path[0].x + n.x * distance,
path[0].y + n.y * distance,
));
} else if index == path.len() - 1 {
let n = normal_of(path[index - 1], path[index])?;
out.push(Point2::new(
path[index].x + n.x * distance,
path[index].y + n.y * distance,
));
} else {
let n0 = normal_of(path[index - 1], path[index])?;
let n1 = normal_of(path[index], path[index + 1])?;
let mx = n0.x + n1.x;
let my = n0.y + n1.y;
let denom = 1.0 + (n0.x * n1.x + n0.y * n1.y);
if denom <= eps {
return Err(GeomError::Degenerate(
"centre line reverses on itself; the miter is unbounded".to_string(),
));
}
out.push(Point2::new(
path[index].x + (mx / denom) * distance,
path[index].y + (my / denom) * distance,
));
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use axiolid_core::Interval;
use axiolid_curve::linear::Polyline2;
use axiolid_curve::Curve2;
use axiolid_profile::{Contour, ProfileSegment};
fn polyline_path(points: &[(f64, f64)]) -> Contour {
let pts: Vec<Point2> = points.iter().map(|(x, y)| Point2::new(*x, *y)).collect();
Contour::new(vec![ProfileSegment {
curve: Curve2::Polyline(Polyline2 {
points: pts,
closed: false,
}),
domain: Interval::new(0.0, (points.len() - 1) as f64),
same_sense: true,
}])
}
fn flatten(contour: &Contour, _chord: Scalar, _tol: Tolerance) -> GeomResult<Vec<Point2>> {
let mut out = Vec::new();
for segment in &contour.segments {
if let Curve2::Polyline(p) = &segment.curve {
out.extend(p.points.iter().copied());
}
}
Ok(out)
}
fn tol() -> Tolerance {
Tolerance::new(1e-9, 1e-9).expect("valid tolerance")
}
fn area(ring: &[Point2]) -> f64 {
let mut sum = 0.0;
for i in 0..ring.len() {
let a = ring[i];
let b = ring[(i + 1) % ring.len()];
sum += a.x * b.y - b.x * a.y;
}
sum.abs() / 2.0
}
#[test]
fn straight_center_line_has_length_times_width_area() {
let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (2.0, 0.0)]), 0.05);
let rings = center_line_rings(
&profile,
1e-4,
Tolerance::new(1e-9, 1e-9).expect("valid tolerance"),
flatten,
)
.expect("straight centre line resolves");
assert!(rings.holes.is_empty(), "a centre line encloses no holes");
let got = area(&rings.outer);
assert!(
(got - 0.1).abs() < 1e-12,
"2.0 long by 0.05 wide is 0.1, got {got}"
);
}
#[test]
fn a_mitered_corner_keeps_constant_width() {
let profile = CenterLineProfile::from_width(
polyline_path(&[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0)]),
0.1,
);
let rings = center_line_rings(&profile, 1e-4, tol(), flatten).expect("corner resolves");
let got = area(&rings.outer);
assert!(
(got - 0.2).abs() < 1e-12,
"a mitered strip is length times width: 2.0 * 0.1 = 0.2, got {got}"
);
}
#[test]
fn width_is_symmetric_about_the_path() {
let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (1.0, 0.0)]), 0.2);
let rings = center_line_rings(&profile, 1e-4, tol(), flatten).expect("resolves");
let ys: Vec<f64> = rings.outer.iter().map(|p| p.y).collect();
let top = ys.iter().cloned().fold(f64::MIN, f64::max);
let bottom = ys.iter().cloned().fold(f64::MAX, f64::min);
assert!(
(top - 0.1).abs() < 1e-12,
"top offset is half the width, got {top}"
);
assert!(
(bottom + 0.1).abs() < 1e-12,
"bottom offset is half the width, got {bottom}"
);
}
#[test]
fn a_reversing_path_is_refused_not_spiked() {
let profile = CenterLineProfile::from_width(
polyline_path(&[(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)]),
0.1,
);
let error = center_line_rings(&profile, 1e-4, tol(), flatten)
.expect_err("a reversal has no finite miter");
assert!(
matches!(error, GeomError::Degenerate(_)),
"expected a typed degeneracy, got {error:?}"
);
}
#[test]
fn a_zero_width_center_line_is_refused() {
let profile = CenterLineProfile::from_width(polyline_path(&[(0.0, 0.0), (1.0, 0.0)]), 0.0);
let error = center_line_rings(&profile, 1e-4, tol(), flatten)
.expect_err("zero width encloses no area");
assert!(matches!(error, GeomError::Degenerate(_)));
}
#[test]
fn a_nan_width_center_line_is_refused() {
let profile = CenterLineProfile {
path: polyline_path(&[(0.0, 0.0), (1.0, 0.0)]),
half_width: f64::NAN,
};
let error =
center_line_rings(&profile, 1e-4, tol(), flatten).expect_err("NaN is not a width");
assert!(matches!(error, GeomError::Degenerate(_)));
}
}