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}