ifc_lite_geometry/
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//! 2D Boolean Operations for Profile-Level Void Subtraction
6//!
7//! This module provides efficient 2D polygon boolean operations using the i_overlay crate.
8//! It is used to subtract void footprints from profiles before extrusion, which is much
9//! more efficient and reliable than performing 3D CSG operations on finished geometry.
10
11use crate::error::{Error, Result};
12use crate::profile::Profile2D;
13use i_overlay::core::fill_rule::FillRule;
14use i_overlay::core::overlay_rule::OverlayRule;
15use i_overlay::float::single::SingleFloatOverlay;
16use nalgebra::Point2;
17
18/// Epsilon for floating point comparisons in 2D operations
19#[cfg(test)]
20const EPSILON_2D: f64 = 1e-9;
21
22/// Minimum area threshold - polygons smaller than this are considered degenerate
23const MIN_AREA_THRESHOLD: f64 = 1e-10;
24
25/// Perform 2D boolean difference: profile - void_contour
26///
27/// Takes a profile and subtracts a void contour from it, returning a new profile
28/// with the void added as a hole (or modifying the outer boundary if the void
29/// cuts through it).
30///
31/// # Arguments
32/// * `profile` - The base profile to subtract from
33/// * `void_contour` - The void footprint to subtract (any winding; normalised CCW)
34///
35/// # Returns
36/// * `Ok(Profile2D)` - The resulting profile with the void subtracted
37/// * `Err` - If the operation fails
38pub fn subtract_2d(profile: &Profile2D, void_contour: &[Point2<f64>]) -> Result<Profile2D> {
39    if void_contour.len() < 3 {
40        return Err(Error::InvalidProfile(
41            "Void contour must have at least 3 vertices".to_string(),
42        ));
43    }
44
45    if profile.outer.len() < 3 {
46        return Err(Error::InvalidProfile(
47            "Profile must have at least 3 vertices".to_string(),
48        ));
49    }
50
51    // Convert profile to i_overlay format
52    // Subject is the profile (outer boundary + holes)
53    let subject = profile_to_paths(profile);
54
55    let clip = ccw_paths([void_contour]);
56
57    // Perform boolean difference using i_overlay
58    // Result is Vec<Vec<Vec<[f64; 2]>>> - Vec of shapes, each shape is Vec of contours
59    let result = subject.overlay(&clip, OverlayRule::Difference, FillRule::NonZero);
60
61    // Convert result back to Profile2D (take first shape if multiple)
62    shapes_to_profile(&result)
63}
64
65/// Perform 2D boolean difference with multiple voids at once
66///
67/// More efficient than calling subtract_2d multiple times as it performs
68/// a single boolean operation.
69///
70/// # Arguments
71/// * `profile` - The base profile to subtract from
72/// * `void_contours` - List of void footprints to subtract
73///
74/// # Returns
75/// * `Ok(Profile2D)` - The resulting profile with all voids subtracted
76pub fn subtract_multiple_2d(
77    profile: &Profile2D,
78    void_contours: &[Vec<Point2<f64>>],
79) -> Result<Profile2D> {
80    subtract_multiple_2d_counted(profile, void_contours).map(|(profile, _)| profile)
81}
82
83/// Like [`subtract_multiple_2d`], but also reports how many DISCONNECTED output
84/// shapes the difference produced (each with non-degenerate area). The returned
85/// `Profile2D` is the largest shape (as [`subtract_multiple_2d`]); a caller that
86/// must not silently drop geometry — the 2D opening-subtraction re-extrude —
87/// checks `shapes == 1` and otherwise defers to the exact kernel (a void that
88/// splits the profile into pieces can't be re-extruded from a single profile).
89pub fn subtract_multiple_2d_counted(
90    profile: &Profile2D,
91    void_contours: &[Vec<Point2<f64>>],
92) -> Result<(Profile2D, usize)> {
93    let valid_contours: Vec<_> = void_contours.iter().filter(|c| c.len() >= 3).collect();
94    if valid_contours.is_empty() {
95        return Ok((profile.clone(), 1));
96    }
97    let subject = profile_to_paths(profile);
98    let clip = ccw_paths(valid_contours.iter().map(|c| c.as_slice()));
99    let result = subject.overlay(&clip, OverlayRule::Difference, FillRule::NonZero);
100    // Count EVERY non-empty output shape (any outer contour with >= 3 vertices),
101    // NOT just those above MIN_AREA_THRESHOLD. This gate decides single-vs-multi
102    // shape for the caller: only `shapes == 1` lets the re-extrude proceed with the
103    // largest shape. Filtering by area would let a valid-but-tiny disconnected
104    // sliver report `shapes == 1`, and its geometry would be silently dropped; the
105    // conservative count forces the exact-kernel fallback whenever the difference
106    // splits the profile at all, even into a sub-threshold piece.
107    let shapes = result
108        .iter()
109        .filter(|s| s.first().is_some_and(|outer| outer.len() >= 3))
110        .count();
111    Ok((shapes_to_profile(&result)?, shapes))
112}
113
114/// Plan a mixed subtraction as a stable precursor and mandatory corrections.
115/// The precursor uses odd coverage to preserve the residual cutter's input
116/// tessellation. It is NOT a finished difference: its intersection with the
117/// NonZero footprint union is returned as additional material to remove AFTER
118/// residual cutting (#4617). Thus `(precursor - residual) - corrections` has
119/// exactly the union semantics of `host - (footprints union residual)`.
120/// The caller must reject disconnected precursors and apply every correction;
121/// failure at any stage must retry the complete opening set on the original host.
122pub(crate) fn subtract_staged_2d_counted(
123    profile: &Profile2D,
124    void_contours: &[Vec<Point2<f64>>],
125) -> Result<(Profile2D, usize, Vec<Profile2D>)> {
126    let clip: Vec<_> = void_contours
127        .iter()
128        .filter(|c| c.len() >= 3)
129        .map(|c| contour_to_path(c))
130        .collect();
131    if clip.is_empty() {
132        return Ok((profile.clone(), 1, Vec::new()));
133    }
134    let result = profile_to_paths(profile).overlay(&clip, OverlayRule::Difference, FillRule::EvenOdd);
135    let shapes = result
136        .iter()
137        .filter(|s| s.first().is_some_and(|outer| outer.len() >= 3))
138        .count();
139    let precursor = shapes_to_profile(&result)?;
140    let union = ccw_paths(void_contours.iter().map(Vec::as_slice));
141    let correction = profile_to_paths(&precursor)
142        .overlay(&union, OverlayRule::Intersect, FillRule::NonZero);
143    let correction = correction
144        .iter()
145        .map(|s| shapes_to_profile(std::slice::from_ref(s)))
146        .collect::<Result<Vec<_>>>()?;
147    Ok((precursor, shapes, correction))
148}
149
150/// Union many 2D contours into a set of DISJOINT shapes, each an outer boundary
151/// plus any holes, via ONE i_overlay Union pass (NonZero fill). Overlapping input
152/// contours merge exactly — no pairwise accumulation, so the coaxial
153/// footprint-union void path can't diverge from the true union. Every input
154/// contour is normalised CCW first so opposing windings (e.g. the two caps of a
155/// projected prism) accumulate coverage rather than cancelling. Degenerate
156/// (< 3-vertex or zero-area) contours are dropped; an empty input yields an empty
157/// result. Deterministic f64 → byte-identical native==wasm.
158pub fn union_contours_to_shapes(contours: &[Vec<Point2<f64>>]) -> Vec<Profile2D> {
159    let subject = ccw_paths(contours.iter().filter(|c| c.len() >= 3).map(Vec::as_slice));
160    if subject.is_empty() {
161        return Vec::new();
162    }
163    // Self-union: overlay the subject against an empty clip with the Union rule and
164    // NonZero fill so overlapping subject paths dissolve into merged shapes.
165    let empty: Vec<Vec<[f64; 2]>> = Vec::new();
166    let result = subject.overlay(&empty, OverlayRule::Union, FillRule::NonZero);
167    let mut out = Vec::new();
168    for shape in &result {
169        let Some(outer_raw) = shape.first() else {
170            continue;
171        };
172        let outer: Vec<Point2<f64>> = outer_raw.iter().map(|p| Point2::new(p[0], p[1])).collect();
173        if !is_valid_contour(&outer) {
174            continue;
175        }
176        let outer = ensure_ccw(&outer);
177        let mut holes = Vec::new();
178        for c in shape.iter().skip(1) {
179            let hole: Vec<Point2<f64>> = c.iter().map(|p| Point2::new(p[0], p[1])).collect();
180            if is_valid_contour(&hole) {
181                holes.push(ensure_cw(&hole));
182            }
183        }
184        out.push(Profile2D { outer, holes });
185    }
186    out
187}
188
189/// Check if a contour is valid (has area, not degenerate)
190pub fn is_valid_contour(contour: &[Point2<f64>]) -> bool {
191    if contour.len() < 3 {
192        return false;
193    }
194
195    let area = compute_signed_area(contour).abs();
196    area > MIN_AREA_THRESHOLD
197}
198
199/// Compute the signed area of a 2D contour
200/// Positive = counter-clockwise, Negative = clockwise
201pub fn compute_signed_area(contour: &[Point2<f64>]) -> f64 {
202    if contour.len() < 3 {
203        return 0.0;
204    }
205
206    let mut area = 0.0;
207    let n = contour.len();
208
209    for i in 0..n {
210        let j = (i + 1) % n;
211        area += contour[i].x * contour[j].y;
212        area -= contour[j].x * contour[i].y;
213    }
214
215    area * 0.5
216}
217
218/// Ensure contour has counter-clockwise winding (positive area)
219pub fn ensure_ccw(contour: &[Point2<f64>]) -> Vec<Point2<f64>> {
220    let area = compute_signed_area(contour);
221    if area < 0.0 {
222        // Clockwise - reverse to make counter-clockwise
223        contour.iter().rev().cloned().collect()
224    } else {
225        contour.to_vec()
226    }
227}
228
229/// Ensure contour has clockwise winding (for holes)
230pub fn ensure_cw(contour: &[Point2<f64>]) -> Vec<Point2<f64>> {
231    let area = compute_signed_area(contour);
232    if area > 0.0 {
233        // Counter-clockwise - reverse to make clockwise
234        contour.iter().rev().cloned().collect()
235    } else {
236        contour.to_vec()
237    }
238}
239
240/// Check if a point is inside a contour using ray casting
241pub fn point_in_contour(point: &Point2<f64>, contour: &[Point2<f64>]) -> bool {
242    if contour.len() < 3 {
243        return false;
244    }
245
246    let mut inside = false;
247    let n = contour.len();
248
249    let mut j = n - 1;
250    for i in 0..n {
251        let pi = &contour[i];
252        let pj = &contour[j];
253
254        if ((pi.y > point.y) != (pj.y > point.y))
255            && (point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y) + pi.x)
256        {
257            inside = !inside;
258        }
259        j = i;
260    }
261
262    inside
263}
264
265// `contour_inside_contour` and `contour_bounds` were deleted here: the C3.2
266// `pub(crate)` narrowing orphaned them (zero callers, production or test), so
267// per the anti-cruft rule they go rather than gain a dead-code allow.
268//
269// `simplify_contour`, `union_contours`, and `bounds_overlap` were deleted here
270// for the same reason (D15 dead-code sweep): each had zero production callers,
271// only their own now-deleted unit tests.
272
273// ============================================================================
274// Internal Helper Functions
275// ============================================================================
276
277/// Convert Profile2D to i_overlay path format
278fn profile_to_paths(profile: &Profile2D) -> Vec<Vec<[f64; 2]>> {
279    let mut paths = Vec::with_capacity(1 + profile.holes.len());
280
281    // Add outer boundary (must be counter-clockwise for i_overlay)
282    let outer = ensure_ccw(&profile.outer);
283    paths.push(contour_to_path(&outer));
284
285    // Add holes clockwise: the opposite winding is what subtracts them under the
286    // `NonZero` fill rule used for finished differences and footprint unions.
287    for hole in &profile.holes {
288        let hole_cw = ensure_cw(hole);
289        paths.push(contour_to_path(&hole_cw));
290    }
291
292    paths
293}
294
295/// Convert a Point2 contour to i_overlay path format
296fn contour_to_path(contour: &[Point2<f64>]) -> Vec<[f64; 2]> {
297    contour.iter().map(|p| [p.x, p.y]).collect()
298}
299
300/// Every contour handed to an overlay, normalised CCW, in i_overlay's path
301/// form. The ONE home for the winding contract on this module's inputs: under
302/// `NonZero` a CW contour's winding sums against a CCW one's and the overlap
303/// cancels, exactly the phantom-pillar shape `EvenOdd` produced (`1 & 2 == 0`
304/// on a doubly-covered patch), so the fill rule and the normalisation are one
305/// change, not two. Subject and clip alike go through here; `profile_to_paths`
306/// is the exception because a profile's holes are CW by contract.
307fn ccw_paths<'a>(contours: impl IntoIterator<Item = &'a [Point2<f64>]>) -> Vec<Vec<[f64; 2]>> {
308    contours
309        .into_iter()
310        .map(|c| contour_to_path(&ensure_ccw(c)))
311        .collect()
312}
313
314/// Convert i_overlay result shapes back to Profile2D
315///
316/// i_overlay returns Vec<Vec<Vec<[f64; 2]>>> where:
317/// - Outer Vec: list of shapes
318/// - Middle Vec: list of contours per shape (first is outer, rest are holes)
319/// - Inner Vec: list of points per contour
320fn shapes_to_profile(shapes: &[Vec<Vec<[f64; 2]>>]) -> Result<Profile2D> {
321    if shapes.is_empty() {
322        return Err(Error::InvalidProfile(
323            "Boolean operation resulted in empty geometry".to_string(),
324        ));
325    }
326
327    // Find the largest shape by outer boundary area
328    let mut best_shape_idx = 0;
329    let mut largest_area = 0.0f64;
330
331    for (idx, shape) in shapes.iter().enumerate() {
332        if shape.is_empty() {
333            continue;
334        }
335        // First contour in shape is the outer boundary
336        let outer_contour: Vec<Point2<f64>> =
337            shape[0].iter().map(|p| Point2::new(p[0], p[1])).collect();
338        let area = compute_signed_area(&outer_contour).abs();
339        if area > largest_area {
340            largest_area = area;
341            best_shape_idx = idx;
342        }
343    }
344
345    let best_shape = &shapes[best_shape_idx];
346    if best_shape.is_empty() {
347        return Err(Error::InvalidProfile(
348            "Selected shape has no contours".to_string(),
349        ));
350    }
351
352    // First contour is outer boundary
353    let outer: Vec<Point2<f64>> = best_shape[0]
354        .iter()
355        .map(|p| Point2::new(p[0], p[1]))
356        .collect();
357    let outer = ensure_ccw(&outer);
358
359    // Rest are holes
360    let mut holes = Vec::new();
361    for contour in best_shape.iter().skip(1) {
362        let hole: Vec<Point2<f64>> = contour.iter().map(|p| Point2::new(p[0], p[1])).collect();
363
364        if is_valid_contour(&hole) {
365            holes.push(ensure_cw(&hole));
366        }
367    }
368
369    Ok(Profile2D { outer, holes })
370}
371
372#[cfg(test)]
373#[path = "bool2d_tests.rs"]
374mod tests;