Skip to main content

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;