axiolid_overlay/
region.rs

1//! A persistent planar region with set algebra, morphology and components.
2//!
3//! # Why a type rather than more free functions
4//!
5//! [`crate::overlay`] is stateless: it validates both operands on every
6//! call and returns a fresh result. That is the right primitive, but a
7//! consumer that builds free space, subtracts obstacles, erodes by a body
8//! radius and then counts what is left has to thread raw polygon vectors
9//! between calls and pay revalidation each time. Area and component count
10//! are not reachable at all.
11//!
12//! A [`Region`] is validated once at construction. Every operation on it
13//! consumes already-valid geometry and produces already-valid geometry, so
14//! the invariant is established at the boundary rather than re-proven at
15//! each step.
16//!
17//! # Emptiness is a reported fact, not an absence
18//!
19//! Every operation that can annihilate a region reports whether it did.
20//! An erosion that removes the last of a corridor and an intersection of
21//! two disjoint regions both yield no polygons, but they are different
22//! facts, and a bare empty vector cannot tell a caller which happened.
23//! [`RegionEvidence::emptied`] records that the operation consumed a
24//! non-empty input, so "nothing was there" stays distinguishable from
25//! "this operation destroyed it".
26//!
27//! # Boundary
28//!
29//! The kernel owns the region, the operations and the reported measures.
30//! It does not own what the region represents, which radius is correct, or
31//! whether a resulting area or component count is acceptable.
32
33use axiolid_core::{Point2, Tolerance, Vec2};
34
35use crate::minkowski::MorphologyBound;
36use crate::offset::{offset_polygons, total_area, JoinStyle};
37use crate::{canonical, validate_ring, OverlayError, Polygon, Ring};
38
39/// What an operation did to produce a region.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub struct RegionEvidence {
42    /// Number of disjoint polygons in the result.
43    pub polygons: usize,
44    /// Number of inner boundary components across all polygons.
45    pub holes: usize,
46    /// The operation consumed a non-empty input and produced nothing.
47    ///
48    /// Distinguishes "eroded out of existence" or "disjoint operands" from
49    /// "the input was already empty", which a bare empty result cannot.
50    pub emptied: bool,
51}
52
53/// A validated planar region: a normalised set of polygons with holes.
54///
55/// Construction validates; operations preserve validity. Ring ordering is
56/// canonical (outer counter-clockwise, holes clockwise, each rotated to its
57/// lexicographically smallest vertex) and polygons are sorted, so equal
58/// regions compare equal regardless of how they were built.
59#[derive(Debug, Clone, PartialEq, Default)]
60pub struct Region {
61    polygons: Vec<Polygon>,
62    evidence: RegionEvidence,
63    /// For a disc morphology with a stated side, that side and the
64    /// deviation from the exact result (#163).
65    bound: Option<MorphologyBound>,
66}
67
68/// Normalise a polygon set: canonical ring order, deterministic sorting.
69///
70/// Applied to every operation result so that two regions holding the same
71/// point set are structurally equal, which is what makes idempotence and
72/// round-trip assertions meaningful rather than incidental.
73fn normalise(mut polygons: Vec<Polygon>) -> Vec<Polygon> {
74    polygons = polygons
75        .into_iter()
76        .map(|polygon| Polygon {
77            outer: canonical(polygon.outer, true),
78            holes: polygon
79                .holes
80                .into_iter()
81                .map(|hole| canonical(hole, false))
82                .collect(),
83        })
84        .collect();
85    for polygon in &mut polygons {
86        polygon.holes.sort_by(|a, b| {
87            a.points[0]
88                .x
89                .total_cmp(&b.points[0].x)
90                .then(a.points[0].y.total_cmp(&b.points[0].y))
91        });
92    }
93    polygons.sort_by(|a, b| {
94        a.outer.points[0]
95            .x
96            .total_cmp(&b.outer.points[0].x)
97            .then(a.outer.points[0].y.total_cmp(&b.outer.points[0].y))
98    });
99    polygons
100}
101
102impl Region {
103    /// The empty region. Not a failure: the identity for union.
104    #[must_use]
105    pub fn empty() -> Self {
106        Self::default()
107    }
108
109    /// Build a region from polygons, validating every ring.
110    ///
111    /// Validation is the same used by [`crate::overlay`], so the two cannot
112    /// disagree about what a well-formed polygon is.
113    pub fn new(polygons: Vec<Polygon>, tolerance: Tolerance) -> Result<Self, OverlayError> {
114        for polygon in &polygons {
115            validate_ring(&polygon.outer, tolerance)?;
116            for hole in &polygon.holes {
117                validate_ring(hole, tolerance)?;
118            }
119        }
120        Ok(Self::from_valid(normalise(polygons), false))
121    }
122
123    /// Wrap already-valid geometry, recording whether the producing operation
124    /// annihilated a non-empty input.
125    fn from_valid(polygons: Vec<Polygon>, had_input: bool) -> Self {
126        let evidence = RegionEvidence {
127            polygons: polygons.len(),
128            holes: polygons.iter().map(|p| p.holes.len()).sum(),
129            emptied: had_input && polygons.is_empty(),
130        };
131        Self {
132            polygons: normalise(polygons),
133            evidence,
134            bound: None,
135        }
136    }
137
138    /// [`Self::from_valid`] for the other modules of the crate.
139    pub(crate) fn from_valid_polygons(polygons: Vec<Polygon>, had_input: bool) -> Self {
140        Self::from_valid(polygons, had_input)
141    }
142
143    /// Record the side and deviation of a disc morphology.
144    pub(crate) fn set_bound(&mut self, bound: MorphologyBound) {
145        self.bound = Some(bound);
146    }
147
148    /// For a result of [`Self::dilate_inner`], [`Self::dilate_outer`],
149    /// [`Self::erode_inner`] or [`Self::erode_outer`]: which side of the
150    /// exact disc morphology it lies on and how far from it it can be.
151    /// `None` for every other region, including [`Self::dilate`] and
152    /// [`Self::erode`], whose side is not stated.
153    #[must_use]
154    pub const fn bound(&self) -> Option<MorphologyBound> {
155        self.bound
156    }
157
158    /// The region's polygons, in canonical order.
159    #[must_use]
160    pub fn polygons(&self) -> &[Polygon] {
161        &self.polygons
162    }
163
164    /// What the producing operation did.
165    #[must_use]
166    pub const fn evidence(&self) -> RegionEvidence {
167        self.evidence
168    }
169
170    /// True when the region covers no area.
171    #[must_use]
172    pub fn is_empty(&self) -> bool {
173        self.polygons.is_empty()
174    }
175
176    /// Total covered area. Holes subtract.
177    #[must_use]
178    pub fn area(&self) -> f64 {
179        total_area(&self.polygons)
180    }
181
182    /// Number of disjoint connected components.
183    ///
184    /// One polygon is one component: the overlay backend already resolves
185    /// touching and overlapping input into disjoint output polygons, so
186    /// counting them is the component count rather than an approximation
187    /// of it.
188    #[must_use]
189    pub fn component_count(&self) -> usize {
190        self.polygons.len()
191    }
192
193    /// Every boundary ring, outer boundaries first then holes.
194    #[must_use]
195    pub fn boundary_rings(&self) -> Vec<Ring> {
196        let mut rings: Vec<Ring> = self.polygons.iter().map(|p| p.outer.clone()).collect();
197        for polygon in &self.polygons {
198            rings.extend(polygon.holes.iter().cloned());
199        }
200        rings
201    }
202
203    /// Set union.
204    pub fn union(&self, other: &Self, tolerance: Tolerance) -> Result<Self, OverlayError> {
205        self.combine(other, crate::OverlayOperation::Union, tolerance)
206    }
207
208    /// Set intersection.
209    pub fn intersection(&self, other: &Self, tolerance: Tolerance) -> Result<Self, OverlayError> {
210        self.combine(other, crate::OverlayOperation::Intersection, tolerance)
211    }
212
213    /// Set difference: this region minus `other`.
214    pub fn difference(&self, other: &Self, tolerance: Tolerance) -> Result<Self, OverlayError> {
215        self.combine(other, crate::OverlayOperation::Difference, tolerance)
216    }
217
218    /// Apply a boolean operation through the validated overlay primitive.
219    ///
220    /// Empty operands are handled here rather than pushed into the backend:
221    /// the identities are exact, and routing them through a general overlay
222    /// would risk an incidental simplification pass changing geometry that
223    /// set algebra says must be returned unchanged.
224    fn combine(
225        &self,
226        other: &Self,
227        operation: crate::OverlayOperation,
228        tolerance: Tolerance,
229    ) -> Result<Self, OverlayError> {
230        let had_input = !self.is_empty() || !other.is_empty();
231        match operation {
232            crate::OverlayOperation::Union if other.is_empty() => {
233                return Ok(Self::from_valid(self.polygons.clone(), had_input));
234            }
235            crate::OverlayOperation::Union if self.is_empty() => {
236                return Ok(Self::from_valid(other.polygons.clone(), had_input));
237            }
238            crate::OverlayOperation::Intersection if self.is_empty() || other.is_empty() => {
239                return Ok(Self::from_valid(Vec::new(), had_input));
240            }
241            crate::OverlayOperation::Difference if other.is_empty() => {
242                return Ok(Self::from_valid(self.polygons.clone(), had_input));
243            }
244            crate::OverlayOperation::Difference if self.is_empty() => {
245                return Ok(Self::from_valid(Vec::new(), had_input));
246            }
247            _ => {}
248        }
249        // The identity frame. A region carries no frame of its own: it is a
250        // point set in whatever frame the caller established, and overlay
251        // only requires both operands to agree.
252        let frame = axiolid_core::Frame2 {
253            origin: Point2::new(0.0, 0.0),
254            x: Vec2::new(1.0, 0.0),
255            y: Vec2::new(0.0, 1.0),
256        };
257        let subject = crate::OverlayInput {
258            frame,
259            polygons: self.polygons.clone(),
260        };
261        let clip = crate::OverlayInput {
262            frame,
263            polygons: other.polygons.clone(),
264        };
265        let result = crate::overlay(
266            &subject,
267            &clip,
268            operation,
269            crate::FillRule::NonZero,
270            tolerance,
271        )?;
272        Ok(Self::from_valid(result.polygons, had_input))
273    }
274
275    /// Dilate by `radius`: the Minkowski sum with a disc, approximated by
276    /// round joins on an unstated side of the exact result. Where a verdict
277    /// must be proven, use [`Self::dilate_inner`] or [`Self::dilate_outer`].
278    ///
279    /// This is the disc-expansion form used for clearance envelopes. A zero
280    /// radius is the identity.
281    pub fn dilate(&self, radius: f64, tolerance: Tolerance) -> Result<Self, OverlayError> {
282        self.morphology(radius, tolerance)
283    }
284
285    /// Erode by `radius`: the Minkowski erosion by a disc, approximated on
286    /// an unstated side of the exact result. Where a verdict must be
287    /// proven, use [`Self::erode_inner`] (a route found proves
288    /// reachability) or [`Self::erode_outer`] (no route proves
289    /// unreachability).
290    ///
291    /// A region thinner than `2 * radius` anywhere is cut there, which is
292    /// how a corridor narrower than a body radius becomes impassable. If
293    /// that removes everything the result is empty and
294    /// [`RegionEvidence::emptied`] is set.
295    pub fn erode(&self, radius: f64, tolerance: Tolerance) -> Result<Self, OverlayError> {
296        if radius < 0.0 {
297            return Err(OverlayError::InvalidOffsetDistance);
298        }
299        self.morphology(-radius, tolerance)
300    }
301
302    /// Shared offset path. Round joins approximate the disc, which is what
303    /// makes this morphology rather than a polygonal offset.
304    fn morphology(&self, distance: f64, tolerance: Tolerance) -> Result<Self, OverlayError> {
305        if !distance.is_finite() {
306            return Err(OverlayError::InvalidOffsetDistance);
307        }
308        if self.is_empty() || distance == 0.0 {
309            return Ok(Self::from_valid(self.polygons.clone(), false));
310        }
311        // A disc is approximated by round joins; the parameter is the maximum
312        // segment-length-to-radius ratio, so smaller is closer to a true disc.
313        let join = JoinStyle::Round {
314            max_segment_ratio: 0.1,
315        };
316        let result = offset_polygons(&self.polygons, distance, join, tolerance)?;
317        Ok(Self::from_valid(result.polygons, true))
318    }
319
320    /// Translate by `offset`. Rigid: area and component count are preserved.
321    pub fn translate(&self, offset: Vec2) -> Result<Self, OverlayError> {
322        if !offset.is_finite() {
323            return Err(OverlayError::NonFinitePoint);
324        }
325        let shift = |ring: &Ring| Ring {
326            points: ring
327                .points
328                .iter()
329                .map(|p| Point2::new(p.x + offset.x, p.y + offset.y))
330                .collect(),
331        };
332        let polygons = self
333            .polygons
334            .iter()
335            .map(|polygon| Polygon {
336                outer: shift(&polygon.outer),
337                holes: polygon.holes.iter().map(shift).collect(),
338            })
339            .collect();
340        Ok(Self::from_valid(polygons, !self.is_empty()))
341    }
342
343    /// Sweep along `direction`: the union of the region with every
344    /// translate of itself along the vector.
345    ///
346    /// Equivalent to the Minkowski sum with the segment `[0, direction]`.
347    /// Built as the union of the region and its translate plus the hull
348    /// swept between them, which for a polygon set is exactly the union of
349    /// the two end positions with the stroke of each boundary edge; using
350    /// the union of endpoints alone would miss the swept middle whenever
351    /// the translation exceeds the region's own extent.
352    pub fn sweep(&self, direction: Vec2, tolerance: Tolerance) -> Result<Self, OverlayError> {
353        if !direction.is_finite() {
354            return Err(OverlayError::NonFinitePoint);
355        }
356        if self.is_empty() || direction.length() == 0.0 {
357            return Ok(Self::from_valid(self.polygons.clone(), false));
358        }
359        let moved = self.translate(direction)?;
360        let mut swept = self.union(&moved, tolerance)?;
361        // Fill the corridor between the two positions: each boundary edge
362        // sweeps a parallelogram, and their union with the endpoints is the
363        // Minkowski sum. Built from quads so no offset approximation enters.
364        for ring in self.boundary_rings() {
365            let count = ring.points.len();
366            for index in 0..count {
367                let a = ring.points[index];
368                let b = ring.points[(index + 1) % count];
369                let quad = Polygon {
370                    outer: Ring {
371                        points: vec![
372                            a,
373                            b,
374                            Point2::new(b.x + direction.x, b.y + direction.y),
375                            Point2::new(a.x + direction.x, a.y + direction.y),
376                        ],
377                    },
378                    holes: Vec::new(),
379                };
380                // A degenerate quad (edge parallel to the sweep) contributes
381                // nothing and is skipped rather than rejected.
382                if let Ok(band) = Self::new(vec![quad], tolerance) {
383                    swept = swept.union(&band, tolerance)?;
384                }
385            }
386        }
387        Ok(swept)
388    }
389}