ifc_lite_geometry/contour_bool2d.rs
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! General 2D boolean operations over **contour sets** (issue #1863).
6//!
7//! [`bool2d`](crate::bool2d) solves one fixed problem: subtract void footprints
8//! from a single [`Profile2D`](crate::profile::Profile2D) before extrusion. Its
9//! results are collapsed to the largest output shape, which is right there (a
10//! profile is one region) and wrong for anything else.
11//!
12//! This module is the general form: union / difference / intersection over
13//! arbitrary ring sets, keeping **every** disjoint output shape with its holes.
14//! It exists for analytic hidden-surface removal, whose core loop is
15//!
16//! ```text
17//! visible = outline(e) - occluders // may split into many islands
18//! occluders = occluders ∪ outline(e)
19//! ```
20//!
21//! where collapsing to the largest shape would silently delete visible
22//! geometry: a wall seen behind a column is two visible slivers, not one.
23//!
24//! ## Winding is the contract
25//!
26//! Every operation uses [`FillRule::NonZero`] and **respects the input ring
27//! winding**: counter-clockwise rings add coverage, clockwise rings remove it
28//! *from the region they overlap*. So a clockwise ring nested inside a
29//! counter-clockwise one is a hole, which is the convention
30//! [`mesh_outline_2d`](crate::projection_outline::mesh_outline_2d) emits — an
31//! outline's rings feed straight back in and its holes survive the round trip,
32//! and it is what SVG `fill-rule="nonzero"` renders. NonZero is literal here:
33//! winding matters relative to the rings around a point, not in the absolute.
34//! A *lone* clockwise ring still has non-zero winding inside it, so it fills
35//! (and comes back counter-clockwise) rather than staying "negative" — a bare
36//! hole with no outer to subtract from is not a meaningful input. A caller
37//! holding raw, arbitrarily-wound contours (projected triangles, say) that all
38//! mean "covered" must normalise them CCW first — see
39//! [`ensure_ccw`](crate::ensure_ccw). This layer will not guess, because
40//! guessing is precisely what destroys the holes of a well-formed set.
41
42use i_overlay::core::fill_rule::FillRule;
43use i_overlay::core::overlay_rule::OverlayRule;
44use i_overlay::float::single::SingleFloatOverlay;
45
46/// A closed ring: 2D points in order, WITHOUT a duplicated closing vertex.
47pub type Ring2D = Vec<[f64; 2]>;
48
49/// The result of a contour-set boolean: rings grouped into disjoint shapes.
50///
51/// Unlike [`Profile2D`](crate::profile::Profile2D) this holds any number of
52/// shapes, so a difference that splits its subject into islands loses nothing.
53#[derive(Clone, Debug, Default, PartialEq)]
54pub struct ContourSet {
55 /// Every boundary ring, laid out shape by shape.
56 pub rings: Vec<Ring2D>,
57 /// `shape_offsets[s]` is the index in [`rings`](Self::rings) of shape `s`'s
58 /// OUTER ring; the rings from there to the next offset (or the end) are
59 /// that shape's holes. Length == number of disjoint shapes.
60 pub shape_offsets: Vec<usize>,
61}
62
63impl ContourSet {
64 /// True when the set holds no boundary rings. Every `ContourSet` produced
65 /// by this module — a boolean result, or a soup that has been through
66 /// [`sanitize`] (which the WASM constructor applies) — has had its
67 /// non-contributing rings (under 3 vertices, non-finite, or exactly collinear)
68 /// dropped, so for those this also means it covers no area.
69 pub fn is_empty(&self) -> bool {
70 self.rings.is_empty()
71 }
72
73 /// Number of disjoint shapes.
74 pub fn shape_count(&self) -> usize {
75 self.shape_offsets.len()
76 }
77
78 /// Rings of shape `s` (outer boundary first, then its holes), or `None`
79 /// when `s` is out of range.
80 pub fn shape(&self, s: usize) -> Option<&[Ring2D]> {
81 let start = *self.shape_offsets.get(s)?;
82 let end = self
83 .shape_offsets
84 .get(s + 1)
85 .copied()
86 .unwrap_or(self.rings.len());
87 self.rings.get(start..end)
88 }
89
90 /// Axis-aligned bounds `[min_x, min_y, max_x, max_y]`, `None` when empty.
91 ///
92 /// Cheap enough to be the first test in an occlusion loop: an accumulated
93 /// occluder that does not overlap the next element's outline needs no
94 /// boolean at all.
95 pub fn bounds(&self) -> Option<[f64; 4]> {
96 let mut b = [
97 f64::INFINITY,
98 f64::INFINITY,
99 f64::NEG_INFINITY,
100 f64::NEG_INFINITY,
101 ];
102 for p in self.rings.iter().flatten() {
103 b[0] = b[0].min(p[0]);
104 b[1] = b[1].min(p[1]);
105 b[2] = b[2].max(p[0]);
106 b[3] = b[3].max(p[1]);
107 }
108 (b[0] <= b[2]).then_some(b)
109 }
110}
111
112/// Which boolean to apply in [`boolean_2d`].
113#[derive(Clone, Copy, PartialEq, Eq, Debug)]
114pub enum BooleanOp2D {
115 /// `subject ∪ clip`.
116 Union,
117 /// `subject - clip`.
118 Difference,
119 /// `subject ∩ clip`.
120 Intersection,
121}
122
123impl BooleanOp2D {
124 /// Decode the 0/1/2 = union/difference/intersection convention used across
125 /// the WASM boundary.
126 pub fn from_u8(v: u8) -> Option<Self> {
127 match v {
128 0 => Some(BooleanOp2D::Union),
129 1 => Some(BooleanOp2D::Difference),
130 2 => Some(BooleanOp2D::Intersection),
131 _ => None,
132 }
133 }
134}
135
136/// True when every vertex lies on one straight line — a ring with no interior
137/// at all. This is deliberately NOT a zero-*area* test: a self-intersecting
138/// bow-tie has zero signed (shoelace) area yet i_overlay fills both its lobes
139/// under NonZero, so dropping by area would silently discard real coverage.
140/// Collinearity is exact (`== 0.0` cross product), so it fires only on
141/// provably-degenerate input; a genuinely thin sliver is not exactly collinear
142/// and is left for i_overlay to judge, exactly as before this filter existed.
143fn is_collinear(path: &[[f64; 2]]) -> bool {
144 let p0 = path[0];
145 // Direction from p0 to the first vertex that differs from it.
146 let dir = path.iter().find_map(|p| {
147 let d = [p[0] - p0[0], p[1] - p0[1]];
148 (d != [0.0, 0.0]).then_some(d)
149 });
150 // All vertices identical → no interior.
151 let Some(dir) = dir else {
152 return true;
153 };
154 // Every vertex on the p0 + t·dir line: cross(dir, p - p0) == 0.
155 path.iter().all(|p| {
156 let d = [p[0] - p0[0], p[1] - p0[1]];
157 dir[0] * d[1] - dir[1] * d[0] == 0.0
158 })
159}
160
161/// Drop rings that cannot contribute, so no input can panic or hang the
162/// overlay: fewer than 3 vertices, ANY non-finite coordinate (a NaN makes
163/// i_overlay's segment ordering meaningless, so the whole ring goes rather than
164/// the offending point — dropping single points would silently deform the
165/// boundary instead), or all vertices collinear (a ring with no interior). A
166/// trailing vertex equal to the first is stripped, so a caller that closes its
167/// rings explicitly does not feed in a zero-length edge. Note this drops only
168/// provably-collinear rings, NOT by area: a zero-signed-area bow-tie still
169/// carries coverage under NonZero and is left for i_overlay.
170///
171/// Re-exported (as `sanitize_contours`) so the WASM `Contours2D` constructor
172/// can hold the same invariant its accessors document, rather than exposing a
173/// raw ring soup that a later boolean would silently disagree with: after this,
174/// a set's rings are exactly the ones a boolean would keep, so `is_empty`/
175/// `bounds` cannot report a collinear or degenerate ring that covers nothing.
176pub fn sanitize(rings: &[Ring2D]) -> Vec<Vec<[f64; 2]>> {
177 rings
178 .iter()
179 .filter_map(|ring| {
180 if ring.iter().any(|p| !p[0].is_finite() || !p[1].is_finite()) {
181 return None;
182 }
183 let mut path = ring.clone();
184 while path.len() >= 2 && path[path.len() - 1] == path[0] {
185 path.pop();
186 }
187 if path.len() < 3 || is_collinear(&path) {
188 return None;
189 }
190 Some(path)
191 })
192 .collect()
193}
194
195/// Flatten i_overlay's `shapes -> contours -> points` output into a
196/// [`ContourSet`], preserving the shape grouping (which
197/// `mesh_outline_2d` throws away).
198fn collect(shapes: Vec<Vec<Vec<[f64; 2]>>>) -> ContourSet {
199 let mut out = ContourSet::default();
200 for shape in shapes {
201 // i_overlay emits each shape's outer boundary first; a shape whose
202 // outer ring degenerated has nothing to contribute, holes included.
203 match shape.first() {
204 Some(outer) if outer.len() >= 3 => {}
205 _ => continue,
206 }
207 out.shape_offsets.push(out.rings.len());
208 for ring in shape {
209 if ring.len() >= 3 {
210 out.rings.push(ring);
211 }
212 }
213 }
214 out
215}
216
217/// Self-union: overlay against an empty clip so overlapping subject rings
218/// dissolve into disjoint shapes without changing the covered area.
219fn resolve(subject: &[Vec<[f64; 2]>]) -> ContourSet {
220 let empty: Vec<Vec<[f64; 2]>> = Vec::new();
221 collect(subject.overlay(&empty, OverlayRule::Union, FillRule::NonZero))
222}
223
224/// Apply a boolean operation to two contour sets.
225///
226/// Ring winding carries outer-vs-hole (see the module docs); the fill rule is
227/// always NonZero. Never panics: degenerate rings are dropped and every empty
228/// combination has a defined answer —
229///
230/// | subject | clip | union | difference | intersection |
231/// |---------|-------|-------|------------|--------------|
232/// | empty | any | clip | empty | empty |
233/// | any | empty | subj | subj | empty |
234///
235/// where "clip"/"subj" mean that operand resolved into disjoint shapes.
236pub fn boolean_2d(subject: &[Ring2D], clip: &[Ring2D], op: BooleanOp2D) -> ContourSet {
237 let subject = sanitize(subject);
238 let clip = sanitize(clip);
239 match op {
240 BooleanOp2D::Union => {
241 if subject.is_empty() {
242 return resolve(&clip);
243 }
244 if clip.is_empty() {
245 return resolve(&subject);
246 }
247 collect(subject.overlay(&clip, OverlayRule::Union, FillRule::NonZero))
248 }
249 BooleanOp2D::Difference => {
250 if subject.is_empty() {
251 return ContourSet::default();
252 }
253 // Subtracting nothing is the identity, but the subject may still
254 // self-overlap; resolve it so the result is always disjoint shapes.
255 if clip.is_empty() {
256 return resolve(&subject);
257 }
258 collect(subject.overlay(&clip, OverlayRule::Difference, FillRule::NonZero))
259 }
260 BooleanOp2D::Intersection => {
261 if subject.is_empty() || clip.is_empty() {
262 return ContourSet::default();
263 }
264 collect(subject.overlay(&clip, OverlayRule::Intersect, FillRule::NonZero))
265 }
266 }
267}
268
269/// Resolve a ring set into disjoint shapes without changing the area it covers
270/// (a self-union). Canonicalises a hand-built contour set — or the flattened
271/// rings of a [`MeshOutline`](crate::projection_outline::MeshOutline) — into
272/// grouped outer/hole shapes.
273pub fn resolve_2d(rings: &[Ring2D]) -> ContourSet {
274 boolean_2d(rings, &[], BooleanOp2D::Union)
275}
276
277#[cfg(test)]
278#[path = "contour_bool2d_tests.rs"]
279mod tests;