axiolid_overlay/offset.rs
1//! Planar offset: polygon inset/outset and polyline stroke offset (#42).
2//!
3//! # Boundary
4//!
5//! The kernel owns the geometric offset and its numeric contract. It does not
6//! own *why* a distance was chosen. Clearance rules, code-mandated widths and
7//! expansion direction are caller policy, so distance is always a parameter and
8//! never a named constant here.
9//!
10//! # Sign convention
11//!
12//! A positive distance grows the region (outset), a negative distance shrinks
13//! it (inset), and zero is the identity. This is fixed once here so consumers
14//! do not each re-derive it from the backend's behaviour.
15//!
16//! # Collapse is a real answer
17//!
18//! Insetting further than a region's inradius removes it entirely. That returns
19//! an empty result, never a degenerate ring: emitting a zero-area or
20//! self-touching ring would hand the caller something that passes a ring-count
21//! check while representing no region at all. `OffsetEvidence::collapsed`
22//! reports it explicitly so a caller can distinguish "nothing left" from
23//! "nothing given".
24//!
25//! # Validation is deliberately asymmetric
26//!
27//! Polygon input reuses the same validation as `overlay`, because offsetting a
28//! self-intersecting or zero-area ring has no well-defined meaning. Polyline
29//! input does NOT reject self-intersection: a stroke over a crossing path is
30//! well defined — the crossing is resolved by the union of the swept region —
31//! and rejecting it would refuse a case the operation genuinely handles.
32
33use axiolid_core::{Point2, Tolerance};
34use i_overlay::mesh::outline::offset::OutlineOffset;
35use i_overlay::mesh::stroke::offset::StrokeOffset;
36use i_overlay::mesh::style::{LineCap, LineJoin, OutlineStyle, StrokeStyle};
37
38use crate::{canonical, validate_ring, OverlayError, Polygon, Ring};
39
40/// How the outline turns a corner.
41///
42/// Named in kernel vocabulary rather than re-exported from the backend so the
43/// choice stays a kernel contract if the backend is ever replaced.
44#[derive(Debug, Clone, Copy, PartialEq)]
45#[non_exhaustive]
46pub enum JoinStyle {
47 /// Cut the corner off with a straight segment. Bounded by construction.
48 Bevel,
49 /// Extend the offset edges until they meet, limited by `angle_limit`
50 /// radians: sharper corners than this fall back to a bevel.
51 ///
52 /// The limit is required rather than defaulted because an unlimited miter
53 /// on a near-degenerate corner produces an arbitrarily distant spike.
54 Miter { angle_limit: f64 },
55 /// Approximate a circular arc, with `max_segment_ratio` bounding segment
56 /// length over arc radius.
57 Round { max_segment_ratio: f64 },
58}
59
60/// How an open stroke terminates.
61#[derive(Debug, Clone, Copy, PartialEq)]
62#[non_exhaustive]
63pub enum CapStyle {
64 /// Stop flat at the endpoint.
65 Butt,
66 /// Extend flat by half the width past the endpoint.
67 Square,
68 /// Semicircular, with `max_segment_ratio` bounding the arc approximation.
69 Round { max_segment_ratio: f64 },
70}
71
72/// What an offset actually did.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74#[non_exhaustive]
75pub struct OffsetEvidence {
76 /// Rings supplied by the caller.
77 pub input_rings: usize,
78 /// Polygons in the result.
79 pub output_polygons: usize,
80 /// Inner boundary components across all result polygons.
81 pub output_holes: usize,
82 /// The input was non-empty but the result is empty.
83 ///
84 /// Distinguishes a region inset out of existence from an empty input, which
85 /// a bare empty vector cannot express.
86 pub collapsed: bool,
87}
88
89/// Result of an offset.
90#[derive(Debug, Clone, PartialEq)]
91#[non_exhaustive]
92pub struct OffsetResult {
93 /// Offset region, canonicalised exactly as `overlay` output is.
94 pub polygons: Vec<Polygon>,
95 /// What the operation did.
96 pub evidence: OffsetEvidence,
97}
98
99impl JoinStyle {
100 fn to_backend(self) -> Result<LineJoin<f64>, OverlayError> {
101 match self {
102 Self::Bevel => Ok(LineJoin::Bevel),
103 Self::Miter { angle_limit } => {
104 if !angle_limit.is_finite() || angle_limit <= 0.0 {
105 return Err(OverlayError::InvalidOffsetStyle);
106 }
107 Ok(LineJoin::Miter(angle_limit))
108 }
109 Self::Round { max_segment_ratio } => {
110 if !max_segment_ratio.is_finite() || max_segment_ratio <= 0.0 {
111 return Err(OverlayError::InvalidOffsetStyle);
112 }
113 Ok(LineJoin::Round(max_segment_ratio))
114 }
115 }
116 }
117}
118
119impl CapStyle {
120 fn to_backend(self) -> Result<LineCap<[f64; 2], f64>, OverlayError> {
121 match self {
122 Self::Butt => Ok(LineCap::Butt),
123 Self::Square => Ok(LineCap::Square),
124 Self::Round { max_segment_ratio } => {
125 if !max_segment_ratio.is_finite() || max_segment_ratio <= 0.0 {
126 return Err(OverlayError::InvalidOffsetStyle);
127 }
128 Ok(LineCap::Round(max_segment_ratio))
129 }
130 }
131 }
132}
133
134/// Convert backend shapes into canonical kernel polygons.
135///
136/// Shares the winding and ordering convention with `overlay` so an offset
137/// result and a boolean result are directly comparable.
138fn to_kernel(shapes: Vec<Vec<Vec<[f64; 2]>>>) -> Vec<Polygon> {
139 let mut polygons: Vec<Polygon> = shapes
140 .into_iter()
141 .filter_map(|shape| {
142 let mut rings = shape.into_iter();
143 let outer = rings.next()?;
144 let outer = canonical(
145 Ring {
146 points: outer.into_iter().map(|p| Point2::new(p[0], p[1])).collect(),
147 },
148 true,
149 );
150 // A backend ring with fewer than three points is not a region. It
151 // is dropped rather than emitted, because a two-point "ring" would
152 // satisfy a naive count check while bounding no area.
153 if outer.points.len() < 3 {
154 return None;
155 }
156 let holes = rings
157 .filter_map(|ring| {
158 let ring = canonical(
159 Ring {
160 points: ring.into_iter().map(|p| Point2::new(p[0], p[1])).collect(),
161 },
162 false,
163 );
164 (ring.points.len() >= 3).then_some(ring)
165 })
166 .collect();
167 Some(Polygon { outer, holes })
168 })
169 .collect();
170 polygons.sort_by(|a, b| {
171 a.outer.points[0]
172 .x
173 .total_cmp(&b.outer.points[0].x)
174 .then(a.outer.points[0].y.total_cmp(&b.outer.points[0].y))
175 });
176 polygons
177}
178
179fn backend_shape(polygons: &[Polygon]) -> Vec<Vec<Vec<[f64; 2]>>> {
180 polygons
181 .iter()
182 .map(|polygon| {
183 core::iter::once(&polygon.outer)
184 .chain(polygon.holes.iter())
185 .map(|ring| ring.points.iter().map(|p| [p.x, p.y]).collect())
186 .collect()
187 })
188 .collect()
189}
190
191/// Offset closed polygons by `distance`.
192///
193/// Positive grows, negative shrinks, zero is the identity. Holes are offset in
194/// the opposite direction to the outer boundary automatically, which is what
195/// makes an outset of a polygon-with-hole shrink the hole rather than grow it.
196///
197/// Returns [`OverlayError::ZeroArea`] and friends for malformed input via the
198/// same ring validation `overlay` applies, so the two operations cannot
199/// disagree about what a valid polygon is.
200pub fn offset_polygons(
201 polygons: &[Polygon],
202 distance: f64,
203 join: JoinStyle,
204 tolerance: Tolerance,
205) -> Result<OffsetResult, OverlayError> {
206 if !distance.is_finite() {
207 return Err(OverlayError::InvalidOffsetDistance);
208 }
209 for polygon in polygons {
210 validate_ring(&polygon.outer, tolerance)?;
211 for hole in &polygon.holes {
212 validate_ring(hole, tolerance)?;
213 }
214 }
215 let input_rings = polygons.iter().map(|p| 1 + p.holes.len()).sum();
216
217 // Zero is the identity, and is handled without touching the backend so it
218 // cannot pick up an incidental simplification pass.
219 let result = if distance == 0.0 {
220 to_kernel(backend_shape(polygons))
221 } else {
222 let style = OutlineStyle::new(distance).line_join(join.to_backend()?);
223 crate::settle::settle(
224 to_kernel(backend_shape(polygons).outline(&style)),
225 tolerance,
226 )
227 };
228
229 let evidence = OffsetEvidence {
230 input_rings,
231 output_polygons: result.len(),
232 output_holes: result.iter().map(|p| p.holes.len()).sum(),
233 collapsed: !polygons.is_empty() && result.is_empty(),
234 };
235 Ok(OffsetResult {
236 polygons: result,
237 evidence,
238 })
239}
240
241/// Sweep an open polyline into a closed region of the given width.
242///
243/// `width` is the full stroke width, not a half-width: the region extends
244/// `width / 2` either side of the path. Stating this explicitly matters because
245/// both conventions are common and silently halving a clearance band is exactly
246/// the kind of error this kernel refuses to make quietly.
247///
248/// Self-intersecting paths are accepted; the crossing is resolved by the union
249/// of the swept region rather than rejected.
250pub fn stroke_polyline(
251 points: &[Point2],
252 width: f64,
253 join: JoinStyle,
254 cap: CapStyle,
255 closed: bool,
256) -> Result<OffsetResult, OverlayError> {
257 if points.len() < 2 {
258 return Err(OverlayError::RingTooShort);
259 }
260 if !points.iter().all(|point| point.is_finite()) {
261 return Err(OverlayError::NonFinitePoint);
262 }
263 if !width.is_finite() || width <= 0.0 {
264 return Err(OverlayError::InvalidOffsetDistance);
265 }
266
267 let path: Vec<[f64; 2]> = points.iter().map(|p| [p.x, p.y]).collect();
268 let style = StrokeStyle::new(width)
269 .line_join(join.to_backend()?)
270 .start_cap(cap.to_backend()?)
271 .end_cap(cap.to_backend()?);
272
273 let result = to_kernel(path.stroke(style, closed));
274 let evidence = OffsetEvidence {
275 input_rings: 1,
276 output_polygons: result.len(),
277 output_holes: result.iter().map(|p| p.holes.len()).sum(),
278 collapsed: result.is_empty(),
279 };
280 Ok(OffsetResult {
281 polygons: result,
282 evidence,
283 })
284}
285
286/// Absolute area of a ring.
287///
288/// Exposed because area monotonicity is the property callers most often want to
289/// assert about an offset, and re-deriving the shoelace formula per consumer
290/// invites sign-convention mistakes.
291#[must_use]
292pub fn ring_area(ring: &Ring) -> f64 {
293 ring.points
294 .iter()
295 .zip(ring.points.iter().cycle().skip(1))
296 .take(ring.points.len())
297 .map(|(a, b)| a.x * b.y - b.x * a.y)
298 .sum::<f64>()
299 .abs()
300 * 0.5
301}
302
303/// Net area of a polygon: outer boundary minus its holes.
304#[must_use]
305pub fn polygon_area(polygon: &Polygon) -> f64 {
306 let holes: f64 = polygon.holes.iter().map(ring_area).sum();
307 (ring_area(&polygon.outer) - holes).max(0.0)
308}
309
310/// Total net area across a set of polygons.
311#[must_use]
312pub fn total_area(polygons: &[Polygon]) -> f64 {
313 polygons.iter().map(polygon_area).sum()
314}