Skip to main content

neco_brep/boolean/
mod.rs

1//! 2D boolean operations module
2
3pub mod classify;
4pub mod combine;
5pub mod intersect;
6
7use crate::types::BooleanOp;
8use neco_nurbs::{dedup_piecewise_sample, NurbsCurve2D, NurbsRegion};
9
10#[derive(Clone, Debug, Default)]
11pub struct RegionSet {
12    pub regions: Vec<NurbsRegion>,
13}
14
15impl RegionSet {
16    pub fn new(regions: Vec<NurbsRegion>) -> Self {
17        Self { regions }
18    }
19
20    pub fn is_empty(&self) -> bool {
21        self.regions.is_empty()
22    }
23
24    pub fn len(&self) -> usize {
25        self.regions.len()
26    }
27
28    pub fn as_slice(&self) -> &[NurbsRegion] {
29        &self.regions
30    }
31
32    pub fn into_regions(self) -> Vec<NurbsRegion> {
33        self.regions
34    }
35}
36
37/// 2D point-to-point distance
38#[inline]
39fn dist2(a: [f64; 2], b: [f64; 2]) -> f64 {
40    ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2)).sqrt()
41}
42
43/// Intersection result between two NURBS curves.
44#[derive(Clone, Debug)]
45pub enum Intersection {
46    Point {
47        point: [f64; 2],
48        t_a: f64,
49        t_b: f64,
50    },
51    /// Collinear overlap interval
52    Overlap {
53        t_a: (f64, f64),
54        t_b: (f64, f64),
55    },
56}
57
58/// 2D boolean operation on two NurbsRegion.
59///
60/// Only single-region results are supported; multiple regions return an error.
61/// Collinear edges are handled via overlap detection + normal-based classification.
62pub fn boolean_2d(a: &NurbsRegion, b: &NurbsRegion, op: BooleanOp) -> Result<NurbsRegion, String> {
63    let result = boolean_2d_all(a, b, op)?;
64    match result.len() {
65        0 => Err("boolean result is empty; use boolean_2d_all".into()),
66        1 => match result.into_regions().into_iter().next() {
67            Some(region) => Ok(region),
68            None => Err("boolean result lost its single region unexpectedly".into()),
69        },
70        n => Err(format!(
71            "boolean result has {} regions; use boolean_2d_all",
72            n
73        )),
74    }
75}
76
77/// 2D boolean operation returning zero or more regions.
78pub fn boolean_2d_all(
79    a: &NurbsRegion,
80    b: &NurbsRegion,
81    op: BooleanOp,
82) -> Result<RegionSet, String> {
83    boolean_2d_inner(a, b, op)
84}
85
86/// Kind of a split segment.
87#[derive(Clone, Debug)]
88pub enum SegmentKind {
89    Normal,
90    /// Overlap segment classified by normal direction
91    Overlap {
92        other_curve_t_mid: f64,
93        other_curve_index: usize,
94    },
95}
96
97/// Piecewise overlap info: (segment_index, local_t_start, local_t_end) pairs.
98struct PiecewiseOverlap {
99    my: (usize, f64, f64),
100    other: (usize, f64, f64),
101}
102
103/// Classify each split segment as Normal or Overlap.
104fn classify_segment_kinds(
105    segs: &[NurbsCurve2D],
106    overlaps: &[PiecewiseOverlap],
107    seg_offsets: &[usize],
108    is_curve_a: bool,
109) -> Vec<SegmentKind> {
110    segs.iter()
111        .enumerate()
112        .map(|(i, seg)| {
113            let n = seg.control_points.len();
114            let t_min = seg.knots[seg.degree];
115            let t_max = seg.knots[n];
116            let t_mid = 0.5 * (t_min + t_max);
117
118            for ov in overlaps {
119                let (my, other) = if is_curve_a {
120                    (&ov.my, &ov.other)
121                } else {
122                    (&ov.other, &ov.my)
123                };
124                let (my_seg_idx, my_t0, my_t1) = *my;
125                let (other_seg_idx, other_t0, other_t1) = *other;
126
127                // Check if this segment belongs to the split range of the source segment
128                let split_start = seg_offsets[my_seg_idx];
129                let split_end = if my_seg_idx + 1 < seg_offsets.len() {
130                    seg_offsets[my_seg_idx + 1]
131                } else {
132                    segs.len()
133                };
134                if i < split_start || i >= split_end {
135                    continue;
136                }
137
138                // Normalize interval for reverse traversal
139                let (my_lo, my_hi) = if my_t0 <= my_t1 {
140                    (my_t0, my_t1)
141                } else {
142                    (my_t1, my_t0)
143                };
144                // Check if segment midpoint falls within the overlap interval
145                if t_mid >= my_lo - 1e-8 && t_mid <= my_hi + 1e-8 {
146                    let other_mid = 0.5 * (other_t0 + other_t1);
147                    return SegmentKind::Overlap {
148                        other_curve_t_mid: other_mid,
149                        other_curve_index: other_seg_idx,
150                    };
151                }
152            }
153            SegmentKind::Normal
154        })
155        .collect()
156}
157
158fn boolean_2d_inner(a: &NurbsRegion, b: &NurbsRegion, op: BooleanOp) -> Result<RegionSet, String> {
159    let outer_a = &a.outer;
160    let outer_b = &b.outer;
161
162    // Find intersections for all segment pairs, collecting local t per segment
163    let mut params_a: Vec<Vec<f64>> = vec![Vec::new(); outer_a.len()];
164    let mut params_b: Vec<Vec<f64>> = vec![Vec::new(); outer_b.len()];
165    let mut pw_overlaps: Vec<PiecewiseOverlap> = Vec::new();
166    let mut has_any_intersection = false;
167
168    for (ia, seg_a) in outer_a.iter().enumerate() {
169        for (ib, seg_b) in outer_b.iter().enumerate() {
170            let ixns = intersect::find_intersections(seg_a, seg_b);
171            for ix in &ixns {
172                has_any_intersection = true;
173                match ix {
174                    Intersection::Point { t_a, t_b, .. } => {
175                        params_a[ia].push(*t_a);
176                        params_b[ib].push(*t_b);
177                    }
178                    Intersection::Overlap { t_a, t_b } => {
179                        params_a[ia].push(t_a.0);
180                        params_a[ia].push(t_a.1);
181                        params_b[ib].push(t_b.0);
182                        params_b[ib].push(t_b.1);
183                        pw_overlaps.push(PiecewiseOverlap {
184                            my: (ia, t_a.0, t_a.1),
185                            other: (ib, t_b.0, t_b.1),
186                        });
187                    }
188                }
189            }
190        }
191    }
192
193    if !has_any_intersection {
194        return handle_no_intersection(a, b, op);
195    }
196
197    let eps = 1e-10;
198
199    // Sort, dedup, and exclude endpoints from local t values, then split
200    fn prepare_and_split(
201        outer: &[NurbsCurve2D],
202        params: &mut [Vec<f64>],
203        eps: f64,
204    ) -> (Vec<NurbsCurve2D>, Vec<usize>) {
205        let mut all_segs: Vec<NurbsCurve2D> = Vec::new();
206        let mut seg_offsets: Vec<usize> = Vec::new();
207
208        for (i, curve) in outer.iter().enumerate() {
209            seg_offsets.push(all_segs.len());
210
211            let n = curve.control_points.len();
212            let t_lo = curve.knots[curve.degree];
213            let t_hi = curve.knots[n];
214
215            let ts = &mut params[i];
216            ts.sort_by(|x, y| x.total_cmp(y));
217            ts.dedup_by(|a, b| (*a - *b).abs() < eps);
218            ts.retain(|&t| t > t_lo + eps && t < t_hi - eps);
219
220            let split = curve.split_at_params(ts);
221            all_segs.extend(split);
222        }
223        (all_segs, seg_offsets)
224    }
225
226    let (segs_a, seg_offsets_a) = prepare_and_split(outer_a, &mut params_a, eps);
227    let (segs_b, seg_offsets_b) = prepare_and_split(outer_b, &mut params_b, eps);
228
229    // If all split params were excluded as endpoints and no overlaps, treat as no intersection
230    let all_trivial_a = segs_a.len() == outer_a.len();
231    let all_trivial_b = segs_b.len() == outer_b.len();
232    if all_trivial_a && all_trivial_b && pw_overlaps.is_empty() {
233        return handle_no_intersection(a, b, op);
234    }
235
236    let kinds_a = classify_segment_kinds(&segs_a, &pw_overlaps, &seg_offsets_a, true);
237    let kinds_b = classify_segment_kinds(&segs_b, &pw_overlaps, &seg_offsets_b, false);
238
239    let locs_a = classify::classify_segments_with_kinds(&segs_a, &kinds_a, outer_b, outer_a);
240    let locs_b = classify::classify_segments_with_kinds(&segs_b, &kinds_b, outer_a, outer_b);
241
242    let selected = combine::select_segments(op, &segs_a, &locs_a, &segs_b, &locs_b);
243    let loops = if selected.is_empty() {
244        Vec::new()
245    } else {
246        combine::build_loops(selected, 1e-6)?
247    };
248    Ok(region_set_from_loops(loops))
249}
250
251/// Sample start point from the first segment's start parameter.
252fn sample_start_point(outer: &[NurbsCurve2D]) -> [f64; 2] {
253    let seg = &outer[0];
254    let t_start = seg.knots[seg.degree];
255    seg.evaluate(t_start)
256}
257
258/// Handle case with no intersections (containment or disjoint).
259fn handle_no_intersection(
260    a: &NurbsRegion,
261    b: &NurbsRegion,
262    op: BooleanOp,
263) -> Result<RegionSet, String> {
264    let a_sample = sample_start_point(&a.outer);
265    let b_sample = sample_start_point(&b.outer);
266    let a_in_b = classify::point_in_nurbs_region(&a_sample, &b.outer);
267    let b_in_a = classify::point_in_nurbs_region(&b_sample, &a.outer);
268
269    match op {
270        BooleanOp::Union => {
271            if a_in_b {
272                Ok(RegionSet::new(vec![b.clone()]))
273            } else if b_in_a {
274                Ok(RegionSet::new(vec![a.clone()]))
275            } else {
276                Ok(RegionSet::new(vec![a.clone(), b.clone()]))
277            }
278        }
279        BooleanOp::Subtract => {
280            if b_in_a {
281                // B is inside A: return B as a hole
282                Ok(RegionSet::new(vec![NurbsRegion {
283                    outer: a.outer.clone(),
284                    holes: vec![b.outer.clone()],
285                }]))
286            } else if a_in_b {
287                Ok(RegionSet::default())
288            } else {
289                // Disjoint: A unchanged
290                Ok(RegionSet::new(vec![a.clone()]))
291            }
292        }
293        BooleanOp::Intersect => {
294            if a_in_b {
295                Ok(RegionSet::new(vec![a.clone()]))
296            } else if b_in_a {
297                Ok(RegionSet::new(vec![b.clone()]))
298            } else {
299                Ok(RegionSet::default())
300            }
301        }
302    }
303}
304
305fn region_set_from_loops(loops: Vec<Vec<NurbsCurve2D>>) -> RegionSet {
306    if loops.is_empty() {
307        return RegionSet::default();
308    }
309
310    #[derive(Clone)]
311    struct LoopInfo {
312        curves: Vec<NurbsCurve2D>,
313        sample: [f64; 2],
314        area: f64,
315        parent: Option<usize>,
316        depth: usize,
317    }
318
319    let mut infos: Vec<LoopInfo> = loops
320        .into_iter()
321        .map(|curves| {
322            let sample = sample_start_point(&curves);
323            let area = sampled_loop_area(&curves).abs();
324            LoopInfo {
325                curves,
326                sample,
327                area,
328                parent: None,
329                depth: 0,
330            }
331        })
332        .collect();
333
334    for i in 0..infos.len() {
335        let mut parent = None;
336        let mut parent_area = f64::INFINITY;
337        for j in 0..infos.len() {
338            if i == j {
339                continue;
340            }
341            if classify::point_in_nurbs_region(&infos[i].sample, &infos[j].curves)
342                && infos[j].area < parent_area
343            {
344                parent = Some(j);
345                parent_area = infos[j].area;
346            }
347        }
348        infos[i].parent = parent;
349    }
350
351    for i in 0..infos.len() {
352        let mut depth = 0usize;
353        let mut cursor = infos[i].parent;
354        while let Some(parent) = cursor {
355            depth += 1;
356            cursor = infos[parent].parent;
357        }
358        infos[i].depth = depth;
359    }
360
361    let mut regions: Vec<NurbsRegion> = infos
362        .iter()
363        .filter(|info| info.depth % 2 == 0)
364        .map(|info| NurbsRegion {
365            outer: info.curves.clone(),
366            holes: vec![],
367        })
368        .collect();
369
370    for info in infos.iter().filter(|info| info.depth % 2 == 1) {
371        let mut cursor = info.parent;
372        while let Some(parent) = cursor {
373            if infos[parent].depth % 2 == 0 {
374                if let Some(region) = regions
375                    .iter_mut()
376                    .find(|region| same_loop(region.outer_curves(), &infos[parent].curves))
377                {
378                    region.holes.push(info.curves.clone());
379                }
380                break;
381            }
382            cursor = infos[parent].parent;
383        }
384    }
385
386    RegionSet::new(regions)
387}
388
389fn same_loop(a: &[NurbsCurve2D], b: &[NurbsCurve2D]) -> bool {
390    let pa = sample_start_point(a);
391    let pb = sample_start_point(b);
392    dist2(pa, pb) < 1e-8 && (sampled_loop_area(a) - sampled_loop_area(b)).abs() < 1e-6
393}
394
395fn sampled_loop_area(curves: &[NurbsCurve2D]) -> f64 {
396    let polygon = dedup_piecewise_sample(curves.iter(), 0.01);
397    signed_polygon_area(&polygon)
398}
399
400fn signed_polygon_area(points: &[[f64; 2]]) -> f64 {
401    if points.len() < 3 {
402        return 0.0;
403    }
404    let mut area = 0.0;
405    for i in 0..points.len() {
406        let j = (i + 1) % points.len();
407        area += points[i][0] * points[j][1] - points[j][0] * points[i][1];
408    }
409    area * 0.5
410}