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}