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;