brepkit_operations/boolean/mod.rs
1//! Boolean operations on solids: fuse, cut, and intersect.
2//!
3//! Uses the GFA pipeline (`brepkit_algo::gfa`) as the primary boolean engine,
4//! with mesh boolean (co-refinement) as a fallback when GFA fails or produces
5//! invalid results.
6
7pub mod assembly;
8mod classify;
9mod types;
10use assembly::validate_boolean_result;
11pub(crate) use assembly::{assemble_solid, assemble_solid_mixed};
12pub use types::{BooleanOp, BooleanOptions, FaceSpec};
13
14// WASM-compatible timer: `std::time::Instant` panics on wasm32 targets.
15#[cfg(not(target_arch = "wasm32"))]
16pub(super) fn timer_now() -> std::time::Instant {
17 std::time::Instant::now()
18}
19#[cfg(not(target_arch = "wasm32"))]
20pub(super) fn timer_elapsed_ms(t: std::time::Instant) -> f64 {
21 t.elapsed().as_secs_f64() * 1000.0
22}
23#[cfg(target_arch = "wasm32")]
24pub(super) fn timer_now() -> () {}
25#[cfg(target_arch = "wasm32")]
26pub(super) fn timer_elapsed_ms(_t: ()) -> f64 {
27 0.0
28}
29
30use brepkit_math::vec::{Point3, Vec3};
31use brepkit_topology::Topology;
32use brepkit_topology::edge::EdgeCurve;
33use brepkit_topology::face::{FaceId, FaceSurface};
34use brepkit_topology::solid::SolidId;
35
36static MESH_FALLBACK_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
37
38/// Number of boolean operations that have used the mesh (co-refinement)
39/// fallback since process start.
40///
41/// The fallback loses analytic surface types and does not guarantee a
42/// watertight result, so callers that require exact geometry (export
43/// pipelines in particular) can snapshot this counter around an operation
44/// chain and refuse the output when it grew.
45pub fn mesh_fallback_count() -> u64 {
46 MESH_FALLBACK_COUNT.load(std::sync::atomic::Ordering::Relaxed)
47}
48
49thread_local! {
50 /// Whether the innermost `boolean_inner` call routed through the mesh
51 /// fallback. Set at the fallback site, consumed by the callers that
52 /// decide whether the result is DELIVERED (counted) or DISCARDED (a
53 /// batching probe in `fuse_cluster`) — the public counter only records
54 /// fallbacks whose output actually reaches a caller, keeping its
55 /// monotonic snapshot-and-diff contract exact (#1445).
56 static LAST_USED_MESH_FALLBACK: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
57}
58
59/// Perform a boolean operation on two solids.
60///
61/// Uses the GFA pipeline as the primary engine, with mesh boolean
62/// (co-refinement) as a fallback when GFA fails or produces invalid results.
63///
64/// # Errors
65///
66/// Returns an error if either solid is invalid or the operation produces
67/// an empty or non-manifold result.
68pub fn boolean(
69 topo: &mut Topology,
70 op: BooleanOp,
71 a: SolidId,
72 b: SolidId,
73) -> Result<SolidId, crate::OperationsError> {
74 let result = boolean_inner(topo, op, a, b);
75 if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
76 MESH_FALLBACK_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
77 }
78 result
79}
80
81#[allow(clippy::too_many_lines)]
82fn boolean_inner(
83 topo: &mut Topology,
84 op: BooleanOp,
85 a: SolidId,
86 b: SolidId,
87) -> Result<SolidId, crate::OperationsError> {
88 LAST_USED_MESH_FALLBACK.with(|f| f.set(false));
89 let tol = brepkit_math::tolerance::Tolerance::new();
90
91 // Detect A⊂B or B⊂A (including A=B) and handle directly.
92 // Only applies when BOTH solids have simple analytic classifiers.
93 {
94 use brepkit_algo::classifier::try_build_analytic_classifier;
95 let ca = try_build_analytic_classifier(topo, a);
96 let cb = try_build_analytic_classifier(topo, b);
97 let TrivialRelation {
98 identical,
99 a_in_b,
100 b_in_a,
101 } = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
102
103 // Identical-solid shortcut: matching AABBs AND every boundary
104 // vertex of each solid classifies as inside-or-on the other's
105 // analytic classifier. Stronger than a center test (a cube
106 // inscribed in a sphere has matching AABBs but cube corners fall
107 // outside the sphere) and works for non-convex solids like tori.
108 if identical {
109 return match op {
110 BooleanOp::Fuse | BooleanOp::Intersect => Ok(crate::copy::copy_solid(topo, a)?),
111 BooleanOp::Cut => Err(crate::OperationsError::EmptyResult {
112 reason: "Cut of identical solids".into(),
113 }),
114 };
115 }
116 // Containment shortcuts:
117 // - Fuse/Intersect with either containment direction: copy the
118 // appropriate solid.
119 // - Cut with A ⊆ B: result is empty (A is fully removed). Return
120 // EmptyResult explicitly — without this short-circuit, GFA falls
121 // back to producing a degenerate vol=0 solid that callers
122 // mistake for a real result, breaking volume invariants like
123 // `vol((A-B) ∪ (A∩B)) = vol(A)`.
124 // - Cut with B ⊂ A: defer to GFA (produces hollow solid).
125 if op == BooleanOp::Cut && a_in_b && !b_in_a {
126 return Err(crate::OperationsError::EmptyResult {
127 reason: "Cut with target fully contained in tool".into(),
128 });
129 }
130 // Cut with the tool strictly inside the blank: build the hollow result
131 // (blank + a reversed copy of the tool as a cavity shell) directly.
132 // GFA's no-intersection assembly drops fully-contained cone/torus
133 // tools; the cavity is exactly the tool's reversed shell, so construct
134 // it here for any simple tool whose vertices are all strictly inside.
135 if op == BooleanOp::Cut
136 && b_in_a
137 && !a_in_b
138 && let Some(classifier) = ca.as_ref()
139 {
140 let tool_simple = topo
141 .solid(b)
142 .map(|s| s.inner_shells().is_empty())
143 .unwrap_or(false);
144 if tool_simple
145 && solid_strictly_inside(topo, b, classifier, tol)
146 && let Ok(result) = build_contained_cut_hollow(topo, a, b)
147 && validate_boolean_result(topo, result).is_ok()
148 {
149 return Ok(result);
150 }
151 }
152 if (b_in_a || a_in_b) && op != BooleanOp::Cut {
153 return match (op, b_in_a, a_in_b) {
154 (BooleanOp::Fuse, true, _) => Ok(crate::copy::copy_solid(topo, a)?),
155 (BooleanOp::Fuse, _, true) => Ok(crate::copy::copy_solid(topo, b)?),
156 (BooleanOp::Intersect, true, _) => Ok(crate::copy::copy_solid(topo, b)?),
157 (BooleanOp::Intersect, _, true) => Ok(crate::copy::copy_solid(topo, a)?),
158 _ => Err(crate::OperationsError::InvalidInput {
159 reason: "containment shortcut: unexpected state".into(),
160 }),
161 };
162 }
163
164 // Coaxial-cylinder merge shortcut: when both A and B are simple
165 // cylinder solids (cylinder + 2 planar caps) with the same axis,
166 // origin and radius, fuse/intersect collapse to a single cylinder
167 // spanning the combined / overlapping axial range. Bypasses GFA's
168 // cap-on-cap and lateral-SD coplanar handling, which currently
169 // falls through to a non-manifold mesh fallback.
170 if let (
171 Some(brepkit_algo::classifier::AnalyticClassifier::Cylinder {
172 origin: oa,
173 axis: aa,
174 radius: ra,
175 z_min: za_min,
176 z_max: za_max,
177 }),
178 Some(brepkit_algo::classifier::AnalyticClassifier::Cylinder {
179 origin: ob,
180 axis: ab,
181 radius: rb,
182 z_min: zb_min,
183 z_max: zb_max,
184 }),
185 ) = (ca.as_ref(), cb.as_ref())
186 {
187 // Axes coincide (same line) when directions are parallel AND
188 // the origin offset is parallel to the axis (no perpendicular
189 // component beyond linear tolerance).
190 let same_axis_dir = aa.dot(*ab) > 1.0 - tol.angular;
191 let origin_offset = *ob - *oa;
192 let along_axis = origin_offset.dot(*aa);
193 let perpendicular = origin_offset - *aa * along_axis;
194 let coaxial = same_axis_dir && perpendicular.length() < tol.linear;
195 let same_radius = (ra - rb).abs() < tol.linear;
196 if coaxial && same_radius {
197 // Translate B's z-range into A's axis frame.
198 let za = (*za_min, *za_max);
199 let zb = (*zb_min + along_axis, *zb_max + along_axis);
200 if let Some(result) =
201 coaxial_cylinder_shortcut(topo, op, *oa, *aa, *ra, za, zb, tol)?
202 {
203 return Ok(result);
204 }
205 }
206 }
207
208 // Coaxial-cone merge shortcut: two frustums on the same conical
209 // surface (shared apex, axis, and tan(half_angle) = r/z ratio)
210 // collapse to a single frustum spanning the combined axial range.
211 if let (
212 Some(brepkit_algo::classifier::AnalyticClassifier::Cone {
213 origin: oa,
214 axis: aa,
215 z_min: za_min,
216 z_max: za_max,
217 r_at_z_min: rmin_a,
218 r_at_z_max: rmax_a,
219 }),
220 Some(brepkit_algo::classifier::AnalyticClassifier::Cone {
221 origin: ob,
222 axis: ab,
223 z_min: zb_min,
224 z_max: zb_max,
225 r_at_z_min: rmin_b,
226 r_at_z_max: rmax_b,
227 }),
228 ) = (ca.as_ref(), cb.as_ref())
229 {
230 let same_axis_dir = aa.dot(*ab) > 1.0 - tol.angular;
231 let same_apex = (*oa - *ob).length() < tol.linear;
232 // Half-angle slope: dimensionless r/z. Use whichever endpoint has
233 // |z| above tol.linear (compared against tol.linear because slope
234 // is a length ratio, not an angle — `tol.angular` is a radian
235 // threshold, wrong unit). When both endpoints of a frustum are
236 // sub-tol (degenerate apex-pinned cone), skip the shortcut and
237 // let GFA handle it rather than dividing by near-zero.
238 let slope_a = if za_max.abs() > tol.linear {
239 Some(rmax_a / *za_max)
240 } else if za_min.abs() > tol.linear {
241 Some(rmin_a / *za_min)
242 } else {
243 None
244 };
245 let slope_b = if zb_max.abs() > tol.linear {
246 Some(rmax_b / *zb_max)
247 } else if zb_min.abs() > tol.linear {
248 Some(rmin_b / *zb_min)
249 } else {
250 None
251 };
252 let same_half_angle = match (slope_a, slope_b) {
253 (Some(sa), Some(sb)) => (sa - sb).abs() < tol.linear,
254 _ => false,
255 };
256 if let (true, Some(slope)) = (same_axis_dir && same_apex && same_half_angle, slope_a)
257 && let Some(result) = coaxial_cone_shortcut(
258 topo,
259 op,
260 *oa,
261 *aa,
262 slope,
263 (*za_min, *za_max),
264 (*zb_min, *zb_max),
265 tol,
266 )?
267 {
268 return Ok(result);
269 }
270 }
271
272 // Axis-aligned box-pair shortcut: when both A and B classify as
273 // Box (analytic classifier infers axis-aligned bounds), Fuse and
274 // Intersect can be computed exactly via AABB algebra. Bypasses
275 // GFA so chained operations get clean fresh-primitive topology
276 // rather than residual GFA splits that confuse subsequent steps.
277 if let (
278 Some(brepkit_algo::classifier::AnalyticClassifier::Box {
279 min: a_min,
280 max: a_max,
281 }),
282 Some(brepkit_algo::classifier::AnalyticClassifier::Box {
283 min: b_min,
284 max: b_max,
285 }),
286 ) = (ca.as_ref(), cb.as_ref())
287 && let Some(result) = box_pair_shortcut(topo, op, *a_min, *a_max, *b_min, *b_max, tol)?
288 {
289 return Ok(result);
290 }
291
292 // Box-sphere intersect shortcut: when one input classifies as an
293 // axis-aligned `Box` and the other as a `Sphere`, the Intersect
294 // result has a closed analytic form in two common cases:
295 // - sphere fully inside box → result is a copy of the sphere
296 // - exactly 3 of the 6 box planes cut the sphere (their meeting
297 // corner sits at or inside the sphere) → spherical "octant"
298 // bounded by 3 quarter-disc box sub-faces + 1 spherical patch
299 // Other configurations fall through to GFA.
300 //
301 // Cut/Fuse aren't covered here yet — they need outer/inner shell
302 // construction (Cut: box with spherical hole) or full periodic-
303 // sphere handling (Fuse: box with spherical bulge), both larger
304 // than this shortcut warrants.
305 if op == BooleanOp::Intersect {
306 let (box_args, sphere_args) = match (ca.as_ref(), cb.as_ref()) {
307 (
308 Some(brepkit_algo::classifier::AnalyticClassifier::Box {
309 min: bmin,
310 max: bmax,
311 }),
312 Some(brepkit_algo::classifier::AnalyticClassifier::Sphere { center, radius }),
313 ) => (Some((*bmin, *bmax)), Some((*center, *radius))),
314 (
315 Some(brepkit_algo::classifier::AnalyticClassifier::Sphere { center, radius }),
316 Some(brepkit_algo::classifier::AnalyticClassifier::Box {
317 min: bmin,
318 max: bmax,
319 }),
320 ) => (Some((*bmin, *bmax)), Some((*center, *radius))),
321 _ => (None, None),
322 };
323 if let (Some((bmin, bmax)), Some((sc, sr))) = (box_args, sphere_args) {
324 let segs = brepkit_topology::explorer::solid_vertices(topo, a)
325 .map(|v| v.len())
326 .unwrap_or(0)
327 .max(
328 brepkit_topology::explorer::solid_vertices(topo, b)
329 .map(|v| v.len())
330 .unwrap_or(0),
331 )
332 .max(16);
333 if let Some(result) =
334 box_sphere_intersect_shortcut(topo, bmin, bmax, sc, sr, segs, tol)?
335 {
336 return Ok(result);
337 }
338 }
339 }
340
341 // Concentric-sphere merge shortcut: when both A and B classify as
342 // Sphere with coincident centers, Fuse and Intersect collapse to a
343 // single sphere by radius algebra. Bypasses GFA's coplanar-pole
344 // handling (which currently routes spheres through the same SD
345 // pipeline that flakes on coaxial cylinders pre-#541).
346 //
347 // Cut intentionally falls through to GFA: subtracting an inner
348 // sphere from an outer one yields a hollow ball, whose topology
349 // (outer shell + inner shell) requires builder support beyond the
350 // single-sphere primitive used here.
351 if let (
352 Some(brepkit_algo::classifier::AnalyticClassifier::Sphere {
353 center: ca_center,
354 radius: ra,
355 }),
356 Some(brepkit_algo::classifier::AnalyticClassifier::Sphere {
357 center: cb_center,
358 radius: rb,
359 }),
360 ) = (ca.as_ref(), cb.as_ref())
361 {
362 let coincident = (*ca_center - *cb_center).length() < tol.linear;
363 if coincident
364 && let Some(result) =
365 concentric_sphere_shortcut(topo, op, a, b, *ca_center, *ra, *rb, tol)?
366 {
367 return Ok(result);
368 }
369 }
370
371 // Coaxial-torus merge shortcut: when both A and B classify as Torus
372 // with the same center, axis (parallel/antiparallel), and major
373 // radius, Fuse and Intersect collapse to a single torus by minor
374 // radius algebra. Same family as the concentric-sphere shortcut
375 // above; sidesteps GFA's torus same-domain handling for the
376 // common shared-major case.
377 if let (
378 Some(brepkit_algo::classifier::AnalyticClassifier::Torus {
379 center: ca_center,
380 axis: aa,
381 major_radius: maj_a,
382 minor_radius: min_a,
383 }),
384 Some(brepkit_algo::classifier::AnalyticClassifier::Torus {
385 center: cb_center,
386 axis: ab,
387 major_radius: maj_b,
388 minor_radius: min_b,
389 }),
390 ) = (ca.as_ref(), cb.as_ref())
391 {
392 let coincident = (*ca_center - *cb_center).length() < tol.linear;
393 // Allow either axis orientation — a torus with axis +z is the
394 // same surface as the same torus with axis -z (the small-circle
395 // sweep is symmetric about the central plane).
396 let coaxial = aa.dot(*ab).abs() > 1.0 - tol.angular;
397 let same_major = (maj_a - maj_b).abs() < tol.linear;
398 if coincident
399 && coaxial
400 && same_major
401 && let Some(result) = coaxial_torus_shortcut(
402 topo, op, a, b, *ca_center, *aa, *maj_a, *min_a, *min_b, tol,
403 )?
404 {
405 return Ok(result);
406 }
407 }
408 }
409
410 // If the curvature-aware AABBs of A and B are separated on any axis
411 // by more than linear tolerance, the solids provably do not overlap
412 // and their intersection is the empty set. Containment shortcuts have
413 // already run above (a contained solid has overlapping, not separated,
414 // AABBs), so reaching here with separated boxes is an exact witness.
415 // The boxes are conservative outer bounds, so box non-overlap implies
416 // solid non-overlap. Symmetric in A and B by construction.
417 if op == BooleanOp::Intersect {
418 let bb_a = crate::measure::solid_bounding_box(topo, a).ok();
419 let bb_b = crate::measure::solid_bounding_box(topo, b).ok();
420 if let Some((a_box, b_box)) = bb_a.zip(bb_b)
421 && aabbs_separated(&a_box, &b_box, tol.linear)
422 {
423 return Ok(topo.add_empty_solid());
424 }
425 }
426
427 // Disjoint-fuse fast path: when A and B are provably spatially disjoint,
428 // their union is a multi-region solid — the same result GFA produces for
429 // disjoint inputs, but built by a cheap shell merge instead of the full
430 // pavefiller/assembly pipeline. This is what makes a pairwise-accumulate
431 // loop over many disjoint pieces (e.g. one tapered foot per gridfinity
432 // cell) scale linearly: each fuse onto the growing accumulator short-
433 // circuits here.
434 //
435 // Disjointness is decided per connected component (not per whole-solid
436 // bbox): the accumulator spans many pieces, so its overall box overlaps
437 // the next piece's box even when no piece actually touches. Component
438 // boxes are conservative outer bounds, and the gap test uses a positive
439 // tolerance margin, so the path only fires on a clear gap — touching or
440 // overlapping operands fall through to GFA, which welds the shared
441 // geometry. The result is independent of the inputs (each operand is
442 // deep-copied before merging), preserving the boolean contract.
443 if op == BooleanOp::Fuse && solids_provably_disjoint(topo, a, b, tol.linear) {
444 let copy_a = crate::copy::copy_solid(topo, a)?;
445 let copy_b = crate::copy::copy_solid(topo, b)?;
446 let merged = crate::compound_ops::merge_disjoint_solids(topo, &[copy_a, copy_b])?;
447 log::debug!("Fuse short-circuited via disjoint shell merge");
448 return Ok(merged);
449 }
450
451 // Disjoint-cut fast path: a tool with a clear gap from every component of
452 // the target removes nothing, so A − B is exactly A. Same disjointness
453 // witness as the fuse path above (per-component conservative boxes, strict
454 // positive gap), so a touching or overlapping tool still routes to GFA.
455 // A tool floating inside the target can never reach here: its boxes nest
456 // inside the target's, which is overlap, not separation. The copy keeps
457 // the result independent of the inputs, preserving the boolean contract.
458 if op == BooleanOp::Cut && solids_provably_disjoint(topo, a, b, tol.linear) {
459 let copy_a = crate::copy::copy_solid(topo, a)?;
460 log::debug!("Cut short-circuited: disjoint tool removes nothing");
461 return Ok(copy_a);
462 }
463
464 let algo_op = match op {
465 BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
466 BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
467 BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
468 };
469 // Recognise flat NURBS walls/edges as analytic planes/lines so the engine's
470 // face-face intersections take the exact plane×plane path (the tool's
471 // rounded-rect extrude emits straight cavity walls as planar B-splines).
472 // Only an operand that actually carries flattenable NURBS is deep-copied
473 // and rewritten; operands without any (the common case — primitives and
474 // already-analytic solids) are passed through unchanged. This matters for
475 // correctness, not just speed: the engine's downstream ordering is keyed on
476 // entity ids, so needlessly deep-copying an operand (which renumbers its
477 // ids) can perturb volume-sensitive cut/fuse results.
478 let gfa_a = if solid_has_flattenable_nurbs(topo, a, tol.linear)? {
479 let copy_a = crate::copy::copy_solid(topo, a)?;
480 let _ = flatten_planar_nurbs_faces(topo, copy_a, tol.linear)?;
481 copy_a
482 } else {
483 a
484 };
485 let gfa_b = if solid_has_flattenable_nurbs(topo, b, tol.linear)? {
486 let copy_b = crate::copy::copy_solid(topo, b)?;
487 let _ = flatten_planar_nurbs_faces(topo, copy_b, tol.linear)?;
488 copy_b
489 } else {
490 b
491 };
492 let gfa_start = timer_now();
493 match brepkit_algo::gfa::boolean(topo, algo_op, gfa_a, gfa_b) {
494 Ok(result) => {
495 let result_faces = brepkit_topology::explorer::solid_faces(topo, result)
496 .map(|f| f.len())
497 .unwrap_or(0);
498 // Narrow-phase empty intersect: overlapping AABBs but the engine
499 // selected no faces for the common region (e.g. boxes whose boxes
500 // overlap by tolerance but whose interiors do not). This is the
501 // authoritative witness of an empty intersection.
502 if op == BooleanOp::Intersect && result_faces == 0 {
503 log::info!(
504 "GFA intersect empty in {:.1}ms (no common faces)",
505 timer_elapsed_ms(gfa_start)
506 );
507 return Ok(topo.add_empty_solid());
508 }
509 if result_faces > 0 {
510 let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
511 // Strip out-and-back wire spurs left by the GFA wire builder on
512 // U-shaped (single-opening-notch) faces — they over-connect the
513 // opening edge and inflate volume (issue #801 slot fuse).
514 let _ = crate::heal::remove_wire_spurs(topo, result)?;
515 // A coincident-junction fuse can leave duplicate junction-wire
516 // edges (one per argument) that differ by sub-micron loft noise
517 // → free edges. Merge those coincident duplicates. Gated on the
518 // shell actually being open so clean results keep exact topology.
519 if has_free_edges(topo, result).unwrap_or(false) {
520 // Best-effort: an error here shouldn't abort the boolean,
521 // but it's useful signal on an already-broken shell.
522 if let Err(e) =
523 unify_coincident_boundary_edges(topo, result, (tol.linear * 10.0).max(1e-6))
524 {
525 log::debug!("unify_coincident_boundary_edges failed: {e}");
526 }
527 }
528 // Check Euler before unify_faces — if already valid, skip
529 // unify to avoid its face-merging bugs (non-manifold edges).
530 let (f_pre, e_pre, v_pre) =
531 brepkit_topology::explorer::solid_entity_counts(topo, result)?;
532 #[allow(clippy::cast_possible_wrap)]
533 let euler_pre = (v_pre as i64) - (e_pre as i64) + (f_pre as i64);
534
535 // If Euler>2, try merging duplicate vertices before unify.
536 // This fixes the flush-face case where duplicate vertices at
537 // cross-rank positions inflate V.
538 let merged_vertices = euler_pre > 2;
539 if merged_vertices {
540 // Best-effort: don't abort on merge failure
541 let _ = merge_result_vertices(topo, result, tol);
542 }
543
544 // Re-count only when the merge above ran; otherwise the counts
545 // are unchanged from the pre-merge measurement (the merge is the
546 // only mutation in between).
547 let (f2, e2, v2) = if merged_vertices {
548 brepkit_topology::explorer::solid_entity_counts(topo, result)?
549 } else {
550 (f_pre, e_pre, v_pre)
551 };
552 #[allow(clippy::cast_possible_wrap)]
553 let euler_pre2 = (v2 as i64) - (e2 as i64) + (f2 as i64);
554
555 // Hollow results (a Cut whose tool sits strictly inside the
556 // target) arrive from GFA with the cavity assembled as inner
557 // shells. Each closed genus-0 cavity shell adds 2 to V-E+F,
558 // so the Euler acceptance below must compare against
559 // 2 + 2*K instead of 2. Entity counts above already include
560 // inner-shell entities via `solid_entity_counts`.
561 #[allow(clippy::cast_possible_wrap)]
562 let inner_shell_surplus = 2 * (topo.solid(result)?.inner_shells().len() as i64);
563
564 // Hole-aware Euler: a face with L inner wire loops raises V-E+F
565 // by L (Euler-Poincare: V-E+F-L = 2(1-g)), so a valid genus-0
566 // result with holed faces (e.g. a fuse leaving circular holes in
567 // box faces) has euler = 2 + L. Compute the inner-wire surplus
568 // once here so both the unify decision and the acceptance gate
569 // use the same hole-aware balance — otherwise a result that
570 // deviates from euler==2 solely because of inner wires would
571 // still trigger an unnecessary unify_faces pass.
572 let inner_wire_count_pre = solid_inner_wire_count(topo, result)?;
573 // Deliberately single-component: this only decides whether to
574 // run `unify_faces`, and that pass can mangle a legitimate
575 // N-piece result, so widening the bound here would change which
576 // multi-region results get unified — a separate question from
577 // acceptance, and one the calibrated foils cover.
578 let euler_balanced_pre = euler_pre2 - inner_shell_surplus == 2
579 || euler_balanced(euler_pre2 - inner_shell_surplus, inner_wire_count_pre, 1);
580
581 // Run unify_faces if the (hole-aware) Euler is off OR if the
582 // topology has 3+-face junctions, which can occur with a
583 // balanced Euler when overlapping coplanar faces cancel in
584 // V-E+F counting. The same-domain detection in the assembler
585 // only pairs faces across opposing ranks with identical edge
586 // sets, so within-rank or different-boundary overlaps slip
587 // through; unify_faces is the safety net for those (issue #696).
588 // `is_closed_manifold` is a whole-solid walk. It is needed both
589 // here (to decide unify) and again after unify (the acceptance
590 // gate). Compute the pre-unify value at most once, and reuse it
591 // for the gate when unify changes nothing. It is only evaluated
592 // when `euler_balanced_pre` holds (otherwise `||` short-circuits
593 // and `needs_unify` is already true).
594 let manifold_pre = if euler_balanced_pre {
595 Some(is_closed_manifold(topo, result)?)
596 } else {
597 None
598 };
599 // Multi-component operands (e.g. the lite base's 16 disjoint
600 // feet before their web joins them) balance at 2*N, which the
601 // single-component check above can never see — without this,
602 // `unify_faces` runs on a perfectly clean N-piece result and
603 // its edits break the manifold it was meant to repair.
604 let (multi_balanced_pre, manifold_pre) = if euler_balanced_pre {
605 (false, manifold_pre)
606 } else {
607 let comps = crate::boolean::assembly::face_components(topo, result);
608 #[allow(clippy::cast_possible_wrap)]
609 let expected = (comps.len() as i64) * 2;
610 if comps.len() >= 2
611 && euler_pre2 - inner_shell_surplus - inner_wire_count_pre == expected
612 && components_are_disjoint_pieces(topo, &comps)
613 {
614 let m = is_closed_manifold(topo, result)?;
615 (m, Some(m))
616 } else {
617 (false, None)
618 }
619 };
620 let needs_unify =
621 !(euler_balanced_pre || multi_balanced_pre) || manifold_pre == Some(false);
622 let mut unified = false;
623 if needs_unify {
624 for _ in 0..3 {
625 if crate::heal::unify_faces(topo, result)? == 0 {
626 break;
627 }
628 unified = true;
629 }
630 }
631 // Re-count only when unify actually merged faces; otherwise the
632 // counts are unchanged from the (post-merge) measurement above.
633 let (f, e, v) = if unified {
634 brepkit_topology::explorer::solid_entity_counts(topo, result)?
635 } else {
636 (f2, e2, v2)
637 };
638 #[allow(clippy::cast_possible_wrap)]
639 let euler = (v as i64) - (e as i64) + (f as i64);
640 // Free edges in an Intersect result mean faces were dropped
641 // (e.g. a tolerance-thin sliver kept only some of its
642 // bounding faces) — reject even when Euler accidentally
643 // balances. Cut and Fuse keep the legacy lenient gate: some
644 // coplanar cut/fuse results carry boundary edges yet are
645 // still the best available output (the mesh fallback loses
646 // more volume than the open GFA shell does).
647 let open_shell_ok = op != BooleanOp::Intersect || !has_free_edges(topo, result)?;
648 // Hole-aware Euler acceptance: re-measure the inner-wire surplus
649 // after unify (which can merge faces and change wire counts) and
650 // accept euler - L == 2 - 2g for genus g >= 0. The holed/genus
651 // acceptance additionally requires a closed manifold so that
652 // accidental cancellations (open shells whose missing faces
653 // offset the inner-wire surplus) still fail safe to the mesh
654 // fallback. Reuse the pre-unify count when unify made no change.
655 let inner_wire_count = if unified {
656 solid_inner_wire_count(topo, result)?
657 } else {
658 inner_wire_count_pre
659 };
660 // `is_closed_manifold` walks every face/edge of the result; the
661 // hollow gate, the genus-acceptance gate, and the multi-region
662 // gate below all need it on the same (post-unify) topology, so
663 // compute it once. Reuse the pre-unify value when it was already
664 // computed AND unify changed nothing — the only intervening
665 // mutation. Propagating a topology-query error with `?` here is
666 // equivalent to the old multi-region `unwrap_or(false)`: that
667 // call ran on this same solid, so an error would have surfaced
668 // at the hollow gate (reached first) regardless.
669 let closed_manifold = match manifold_pre {
670 Some(m) if !unified => m,
671 _ => is_closed_manifold(topo, result)?,
672 };
673 // A hollow result must additionally have every shell closed:
674 // a missing cavity face could otherwise cancel against the
675 // inner-shell surplus and balance Euler by accident.
676 let hollow_ok = inner_shell_surplus == 0 || closed_manifold;
677 let euler_eff = euler - inner_shell_surplus;
678 let euler_ok = hollow_ok
679 && (euler_eff == 2
680 || (euler_balanced(euler_eff, inner_wire_count, 1) && closed_manifold));
681 if euler_ok && open_shell_ok && validate_boolean_result(topo, result).is_ok() {
682 log::info!(
683 "GFA boolean succeeded in {:.1}ms ({result_faces} faces)",
684 timer_elapsed_ms(gfa_start)
685 );
686 return Ok(result);
687 }
688 // Multi-region manifold result (e.g., a Cut that splits a
689 // solid into N spatially-disjoint pieces). N independently
690 // closed manifolds have combined Euler = 2*N. Falling back
691 // to mesh boolean would collapse the disjoint pieces into
692 // a single region's volume (the `cut with simplify`
693 // returning vol 166 instead of 1000 symptom).
694 //
695 // Gate: every edge must be shared by exactly 2 faces
696 // (closed-manifold) AND the components must be pairwise
697 // spatially disjoint (AABBs do not overlap). The latter
698 // distinguishes a "cut into N pieces" from a hollow solid
699 // (outer surface + cavity surface — same number of
700 // components, same Euler relation, but AABBs overlap).
701 // N closed manifolds satisfy `V - E + F - inner_wires =
702 // 2 * (N - genus)`, which this gate pins at the genus-0 case
703 // `... = 2 * N` (as it always has — a handled piece is left to
704 // the mesh fallback). The hole term, however, is NOT optional:
705 // a piece carrying a blind pocket (a face with an inner wire)
706 // shifts raw Euler away from 2*N even at genus 0, so comparing
707 // raw Euler here rejected every pocketed piece. This mirrors the
708 // `euler_balanced` correction the single-component gate above
709 // applies — which is why the bound below is `2 * components`
710 // rather than an equality against it.
711 let components_vec = crate::boolean::assembly::face_components(topo, result);
712 let components = components_vec.len();
713 // For Cut, also verify no component is a "B-interior piece" —
714 // GFA can produce N closed manifolds where one of them is the
715 // tool's interior (sphere - cylinder example: 3 pieces =
716 // top cap + bottom cap + cylinder interior). Sample a point
717 // inside each component's AABB and classify against B; if any
718 // sits inside B, the GFA result included the cut-out piece
719 // and should be rejected. Fuse/Intersect don't have this
720 // failure mode.
721 let cut_safe = op != BooleanOp::Cut
722 || brepkit_algo::classifier::try_build_analytic_classifier(topo, b)
723 .as_ref()
724 .is_none_or(|cls_b| {
725 all_component_centers_outside(topo, &components_vec, cls_b, tol)
726 });
727 // Intersect's mirror hazard: GFA could emit a piece that is not
728 // part of A∩B at all. Reject when any component's AABB-centre
729 // sample classifies OUTSIDE either operand — an intersection
730 // piece must lie inside both. The winding-number classifier
731 // (unlike the analytic one) handles multi-piece operands, the
732 // very case this acceptance exists for; a classification error
733 // rejects (this acceptance is purely an optimization, so
734 // unclassifiable geometry keeps the old fallback behaviour).
735 // `OnBoundary` passes — thin clip pieces legitimately touch
736 // the operand boundaries. The centre need not be interior to a
737 // concave piece, but that failure direction only REJECTS a
738 // valid result into the mesh fallback (the status quo), the
739 // same posture `cut_safe` already accepts.
740 let intersect_safe = op != BooleanOp::Intersect
741 || components_vec.iter().all(|comp| {
742 let Some(centre) = component_aabb_centre(topo, comp) else {
743 return true;
744 };
745 [a, b].iter().all(|&operand| {
746 !matches!(
747 crate::classify::classify_point_robust(
748 topo, operand, centre, 0.1, tol.linear,
749 ),
750 Ok(crate::classify::PointClassification::Outside) | Err(_)
751 )
752 })
753 });
754 // Fuse shares this gate: fusing a tool into ONE piece of a
755 // multi-component operand (the lite base's 16 disjoint feet
756 // before their web joins them) legitimately leaves N disjoint
757 // closed manifolds, which the single-component Euler gate above
758 // can never accept. The same conditions apply; `cut_safe`'s
759 // B-interior probe is Cut-specific and passes vacuously here.
760 // Intersect joins for the same reason: clipping a multi-piece
761 // operand (the lite void against a divider-column prism)
762 // legitimately yields N disjoint chunks.
763 if matches!(op, BooleanOp::Cut | BooleanOp::Fuse | BooleanOp::Intersect)
764 && components >= 2
765 && euler_balanced(euler, inner_wire_count, i64::try_from(components).unwrap_or(i64::MAX))
766 && components_are_disjoint_pieces(topo, &components_vec)
767 && cut_safe
768 && intersect_safe
769 // Reuse the `closed_manifold` computed above: nothing between
770 // it and here mutates the result (only read-only component
771 // and classifier queries run in between).
772 && closed_manifold
773 && validate_boolean_result(topo, result).is_ok()
774 {
775 log::info!(
776 "GFA multi-region succeeded in {:.1}ms ({result_faces} faces, {components} pieces)",
777 timer_elapsed_ms(gfa_start)
778 );
779 return Ok(result);
780 }
781 // Which gate refused? Both acceptance paths are conjunctions,
782 // so the bare rejection below says nothing about the cause —
783 // and when `validate` is None the result is topologically fine
784 // and something else declined it.
785 log::debug!(
786 "GFA reject detail {op:?}: euler={euler} euler_eff={euler_eff} \
787 inner_wires={inner_wire_count} inner_shell_surplus={inner_shell_surplus} \
788 euler_ok={euler_ok} open_shell_ok={open_shell_ok} \
789 closed_manifold={closed_manifold} components={components} \
790 cut_safe={cut_safe} intersect_safe={intersect_safe} \
791 euler_multi_ok={} surplus={} bound={} disjoint={}",
792 euler_balanced(
793 euler,
794 inner_wire_count,
795 i64::try_from(components).unwrap_or(i64::MAX)
796 ),
797 euler - inner_wire_count,
798 i64::try_from(components)
799 .unwrap_or(i64::MAX)
800 .saturating_mul(2),
801 components_are_disjoint_pieces(topo, &components_vec)
802 );
803 }
804 log::warn!(
805 "GFA result not accepted in {:.1}ms (faces={result_faces}, \
806 validate={:?}), falling back",
807 timer_elapsed_ms(gfa_start),
808 validate_boolean_result(topo, result).err()
809 );
810 }
811 Err(e) => {
812 log::warn!(
813 "GFA boolean failed in {:.1}ms ({e}), falling back",
814 timer_elapsed_ms(gfa_start)
815 );
816 }
817 }
818
819 // When the input solid carries multiple disjoint pieces (a previous
820 // cut split a solid into N parts), GFA's pavefiller can't process
821 // them together — feeding the whole thing in loses regions. Splitting
822 // into per-component cuts and recombining preserves the missing
823 // pieces. Cut distributes over disjoint union; Fuse/Intersect have
824 // more complex interaction semantics so we leave those to mesh.
825 if op == BooleanOp::Cut {
826 let components = crate::boolean::assembly::face_components(topo, a);
827 if components.len() >= 2
828 && components_are_disjoint_pieces(topo, &components)
829 && let Ok(result) = cut_multi_region_input(topo, a, b, components.len())
830 {
831 return Ok(result);
832 }
833 }
834
835 // A Fuse whose TOOL carries many disjoint pieces (the lite base's 64
836 // magnet pads arrive as one 64-component union) also defeats the
837 // pavefiller when fed whole. Fuse distributes over a disjoint-union
838 // tool, so fold the pieces in one at a time — each per-piece fuse is
839 // the configuration the engine handles analytically.
840 // Gated to tools WITHOUT inner (cavity) shells: `face_components` walks
841 // the outer shell only, so a hollow piece would silently lose its cavity.
842 if op == BooleanOp::Fuse && topo.solid(b).is_ok_and(|s| s.inner_shells().is_empty()) {
843 let tool_components = crate::boolean::assembly::face_components(topo, b);
844 if tool_components.len() >= 2
845 && components_are_disjoint_pieces(topo, &tool_components)
846 && let Ok(result) = fuse_multi_component_tool(topo, a, tool_components)
847 {
848 return Ok(result);
849 }
850 }
851
852 // Mesh boolean fallback (no recursion).
853 log::debug!(
854 target: "brepkit_approx",
855 "boolean {op:?}: GFA unusable — using mesh (co-refinement) fallback; analytic surface types will be lost"
856 );
857 LAST_USED_MESH_FALLBACK.with(|f| f.set(true));
858 let opts = BooleanOptions::default();
859 let raw = match mesh_boolean_fallback(topo, op, a, b, opts.deflection, tol, &opts) {
860 Ok(raw) => raw,
861 // An empty mesh-boolean output for an intersect means the common
862 // region is empty — return the empty-result sentinel rather than
863 // surfacing the empty set as an error.
864 Err(crate::OperationsError::EmptyResult { .. }) if op == BooleanOp::Intersect => {
865 return Ok(topo.add_empty_solid());
866 }
867 Err(e) => return Err(e),
868 };
869 let result = crate::copy::copy_solid(topo, raw)?;
870 let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
871 for _ in 0..3 {
872 if crate::heal::unify_faces(topo, result)? == 0 {
873 break;
874 }
875 }
876 Ok(enforce_manifold_shell(topo, result).unwrap_or(result))
877}
878
879/// Perform a boolean operation with custom options.
880///
881/// Runs the standard GFA boolean pipeline, then applies post-processing
882/// options. Currently supported: `unify_faces` (merges co-surface face
883/// fragments via `brepkit_heal::unify_same_domain`).
884///
885/// # Errors
886///
887/// Returns the same errors as [`boolean`].
888pub fn boolean_with_options(
889 topo: &mut Topology,
890 op: BooleanOp,
891 a: SolidId,
892 b: SolidId,
893 opts: BooleanOptions,
894) -> Result<SolidId, crate::OperationsError> {
895 let result = boolean(topo, op, a, b)?;
896 if opts.unify_faces {
897 let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
898 if let Err(e) =
899 brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
900 {
901 log::debug!("boolean unify_faces post-processing failed: {e}");
902 }
903 }
904 Ok(result)
905}
906
907/// Sequential compound cut via GFA.
908///
909/// Cuts the `target` solid by each tool in order using sequential
910/// `boolean(Cut)` calls.
911///
912/// # Errors
913///
914/// Returns an error if any individual cut fails.
915pub fn compound_cut(
916 topo: &mut Topology,
917 target: SolidId,
918 tools: &[SolidId],
919 opts: BooleanOptions,
920) -> Result<SolidId, crate::OperationsError> {
921 // Batched fast path: merge the tools into one multi-piece solid and cut
922 // ONCE. Sequential cutting re-runs the full boolean pipeline against the
923 // whole target per tool — O(target × tools); the lite magnet-drill pass
924 // was 8.4s sequential vs 0.75s batched for the exact same result volume.
925 // A ∖ (T₁ ∪ T₂ ∪ …) ≡ (A ∖ T₁) ∖ T₂ ∖ …, so the batch is semantically
926 // identical. Tools are first grouped into AABB-overlap clusters
927 // (union-find): tools in one cluster get a real fuse (the coaxial
928 // magnet+screw drill pair), while the pairwise-disjoint cluster
929 // representatives merge via the free disjoint-shell shortcut.
930 //
931 // A SINGLE cluster batches too. That case used to fall through to the
932 // sequential loop on the assumption that fusing one overlapping blob costs
933 // more than it saves, but a connected lattice of many small tools refutes
934 // it — fusing scales with the tools, while the sequential loop re-cuts the
935 // whole target once per tool, and the target only grows more fragmented.
936 // Measured on the kumiko wall lattice (180 strut prisms, one cluster):
937 // batching is comfortably faster for an identical result. Replay it with
938 // the captured operands under `kumiko-goma` in the parity-capture cache.
939 // Any failure falls back to the sequential loop.
940 let mut result = target;
941 let mut batched = false;
942 if tools.len() >= 2
943 && let Some(boxes) = tool_bounding_boxes(topo, tools)
944 && let clusters = cluster_tools_by_aabb(&boxes, tools)
945 && !clusters.is_empty()
946 {
947 // Cheapest rung first: when no tool pair shares volume (disjoint or
948 // merely TOUCHING — a baseplate's pocket grid meets rim-to-rim), the
949 // union's boundary is just the concatenation of the tool shells, so
950 // combine them verbatim and cut once. This is the same
951 // position-duplicate-edge tool the fuse ladder below builds one GFA
952 // run per tool (a 6x4 plate's 24-pocket stage spent ~24 wasted
953 // arrangements on it), minus all the fuses. Interpenetrating pairs
954 // (the coaxial magnet+screw drill) disqualify the whole set: a
955 // multi-component tool with overlapping components breaks parity
956 // classification, so those take the fuse ladder.
957 if tools_at_most_touch(&boxes) {
958 let shortcut = crate::compound_ops::merge_disjoint_solids(topo, tools)
959 .and_then(|tool| boolean_inner(topo, BooleanOp::Cut, target, tool));
960 match shortcut {
961 Ok(cut) if !LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) => {
962 result = cut;
963 batched = true;
964 }
965 _ => log::debug!("compound_cut: contact-thin shortcut declined"),
966 }
967 }
968 // Tools that merely TOUCH (a plate's edge-tangent preview pockets)
969 // cluster together by AABB, and their union is genuinely non-manifold,
970 // so every pairwise fuse below "succeeds" through the mesh fallback —
971 // the batch then proceeds with a degraded all-planar tool and the cut
972 // grinds against it (11 s and lost cones on a 4x4 baseplate, #1488).
973 // A fallback-tainted merge is a FAILED merge for batching purposes:
974 // the sequential per-tool cuts are exact and never see the tangency.
975 // Taint propagates as Err from `fuse_cluster` and from the cross-
976 // cluster merge fuses below, so a discarded merge never touches the
977 // public fallback counter.
978 if !batched {
979 let merged = clusters.iter().try_fold(None::<SolidId>, |acc, cluster| {
980 let fused = fuse_cluster(topo, cluster)?;
981 match acc {
982 None => Ok(Some(fused)),
983 Some(prev) => {
984 let m = boolean_inner(topo, BooleanOp::Fuse, prev, fused)?;
985 if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
986 return Err(crate::OperationsError::InvalidInput {
987 reason: "cluster merge degraded to mesh fallback".to_string(),
988 });
989 }
990 Ok(Some(m))
991 }
992 }
993 });
994 if let Ok(Some(tool)) = merged
995 && let Ok(cut) = boolean(topo, BooleanOp::Cut, target, tool)
996 {
997 result = cut;
998 batched = true;
999 } else {
1000 log::debug!("compound_cut: batched tool path failed, using sequential cuts");
1001 }
1002 }
1003 }
1004 if !batched {
1005 for &tool in tools {
1006 result = boolean(topo, BooleanOp::Cut, result, tool)?;
1007 }
1008 }
1009 if opts.unify_faces {
1010 let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
1011 if let Err(e) =
1012 brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
1013 {
1014 log::debug!("compound_cut unify_faces failed: {e}");
1015 }
1016 }
1017 Ok(result)
1018}
1019
1020/// Fuse one AABB-overlap cluster into a single solid.
1021///
1022/// For a cluster of 3+ interpenetrating/touching tools, tries the single-pass
1023/// N-way GFA fuse (`brepkit_algo::gfa::fuse_n`) — one arrangement over all tools
1024/// instead of the sequential pairwise fuse's O(n²) re-processing of a growing
1025/// accumulator. Falls back to the sequential fuse when the N-way path errors
1026/// (e.g. a non-planar coincident contact it does not yet handle) or yields an
1027/// invalid result. Clusters of 1–2 tools go straight to the sequential path,
1028/// where the N-way arrangement has nothing to save. The cluster must be
1029/// non-empty.
1030pub(crate) fn fuse_cluster(
1031 topo: &mut Topology,
1032 cluster: &[SolidId],
1033) -> Result<SolidId, crate::OperationsError> {
1034 let Some((&first, rest)) = cluster.split_first() else {
1035 return Err(crate::OperationsError::InvalidInput {
1036 reason: "fuse_cluster requires a non-empty cluster".into(),
1037 });
1038 };
1039 if cluster.len() >= 3
1040 && let Ok(fused) = brepkit_algo::gfa::fuse_n(topo, cluster)
1041 && validate_boolean_result(topo, fused).is_ok()
1042 {
1043 return Ok(fused);
1044 }
1045 // A pairwise fuse that degrades to the mesh fallback poisons the whole
1046 // batch; bail at the first one instead of paying the fallback for every
1047 // remaining pair. `boolean_inner` + the taint flag keeps the discarded
1048 // probe out of the public fallback counter entirely.
1049 rest.iter().try_fold(first, |a, &t| {
1050 let fused = boolean_inner(topo, BooleanOp::Fuse, a, t)?;
1051 if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
1052 return Err(crate::OperationsError::InvalidInput {
1053 reason: "cluster fuse degraded to mesh fallback".to_string(),
1054 });
1055 }
1056 Ok(fused)
1057 })
1058}
1059
1060/// True when no pair of tools shares volume beyond a weld-scale sliver:
1061/// every pairwise AABB intersection is at most `100·tol` thick in some
1062/// axis, so tools can touch face-to-face or rim-to-rim but never nest or
1063/// interpenetrate. The discriminant for the contact-thin compound-cut
1064/// shortcut — a coaxial magnet+screw drill pair (screw box nested inside
1065/// the magnet box) fails it, a pitch-aligned pocket grid passes.
1066fn tools_at_most_touch(boxes: &[brepkit_math::aabb::Aabb3]) -> bool {
1067 let band = brepkit_math::tolerance::Tolerance::new().linear * 100.0;
1068 for i in 0..boxes.len() {
1069 for j in (i + 1)..boxes.len() {
1070 let ix =
1071 boxes[i].max.x().min(boxes[j].max.x()) - boxes[i].min.x().max(boxes[j].min.x());
1072 let iy =
1073 boxes[i].max.y().min(boxes[j].max.y()) - boxes[i].min.y().max(boxes[j].min.y());
1074 let iz =
1075 boxes[i].max.z().min(boxes[j].max.z()) - boxes[i].min.z().max(boxes[j].min.z());
1076 if ix > band && iy > band && iz > band {
1077 return false;
1078 }
1079 }
1080 }
1081 true
1082}
1083
1084/// Per-tool AABBs, computed once and shared by the clustering pass and the
1085/// contact-thin guard. `None` when any AABB is unavailable.
1086fn tool_bounding_boxes(
1087 topo: &Topology,
1088 tools: &[SolidId],
1089) -> Option<Vec<brepkit_math::aabb::Aabb3>> {
1090 tools
1091 .iter()
1092 .map(|&t| crate::measure::solid_bounding_box(topo, t).ok())
1093 .collect()
1094}
1095
1096/// Group tools into AABB-overlap clusters (union-find over tolerance-
1097/// expanded boxes). Tools within a cluster may interpenetrate; distinct
1098/// clusters are pairwise disjoint.
1099fn cluster_tools_by_aabb(
1100 boxes: &[brepkit_math::aabb::Aabb3],
1101 tools: &[SolidId],
1102) -> Vec<Vec<SolidId>> {
1103 fn find(parent: &mut Vec<usize>, i: usize) -> usize {
1104 if parent[i] != i {
1105 let root = find(parent, parent[i]);
1106 parent[i] = root;
1107 }
1108 parent[i]
1109 }
1110 let tol = brepkit_math::tolerance::Tolerance::new().linear;
1111 let mut parent: Vec<usize> = (0..tools.len()).collect();
1112 for i in 0..boxes.len() {
1113 for j in (i + 1)..boxes.len() {
1114 if boxes[i].expanded(tol).intersects(boxes[j]) {
1115 let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
1116 if ri != rj {
1117 parent[ri] = rj;
1118 }
1119 }
1120 }
1121 }
1122 let mut clusters: std::collections::BTreeMap<usize, Vec<SolidId>> =
1123 std::collections::BTreeMap::new();
1124 for i in 0..tools.len() {
1125 let root = find(&mut parent, i);
1126 clusters.entry(root).or_default().push(tools[i]);
1127 }
1128 clusters.into_values().collect()
1129}
1130
1131/// Perform a boolean operation and return an [`crate::evolution::EvolutionMap`]
1132/// tracking face provenance.
1133///
1134/// Prefers **faithful** provenance from the GFA builder — each result face
1135/// records the input face it was split/derived from
1136/// (`brepkit_algo::gfa::boolean_with_face_origins`). Because that path runs the
1137/// GFA directly, it can take a different route than [`boolean`] (which
1138/// short-circuits some cases via AABB/containment fast paths), so its result is
1139/// validated; on a GFA error or an invalid result — and for identical or
1140/// fully-contained operand pairs (`detect_trivial_relation`) — it falls back to
1141/// [`boolean`] with the geometry heuristic (normal + centroid). Either way,
1142/// unmatched input faces are classified as "deleted"; synthesised result faces
1143/// with no input origin are left unattributed.
1144///
1145/// # Errors
1146///
1147/// Returns the same errors as [`boolean`].
1148pub fn boolean_with_evolution(
1149 topo: &mut Topology,
1150 op: BooleanOp,
1151 a: SolidId,
1152 b: SolidId,
1153) -> Result<(SolidId, crate::evolution::EvolutionMap), crate::OperationsError> {
1154 use brepkit_topology::explorer::solid_faces;
1155
1156 // Faithful path: the GFA reports each result face's true input source.
1157 // Identical/contained operand pairs must NOT take it: those are the
1158 // fully-coincident-boundary configurations `boolean` short-circuits
1159 // precisely because the raw GFA mis-splits them — coincident walls drop
1160 // into an open shell whose position-duplicate free edges pass the
1161 // by-edge-id validation gate (every edge id used ≤ 2×), so the broken
1162 // result would be returned as "valid". Route them through `boolean`'s
1163 // shortcuts below; the geometry heuristic attributes a copied result's
1164 // faces exactly (normal + centroid match 1:1). Detection only runs for
1165 // a != b (a == b skips the faithful path regardless) and its cost is
1166 // O(faces + vertices) per call; `boolean` re-runs it on the fallback,
1167 // which is accepted — deduplicating would mean threading the relation
1168 // through `boolean`'s public signature.
1169 let trivial = a != b && {
1170 use brepkit_algo::classifier::try_build_analytic_classifier;
1171 let tol = brepkit_math::tolerance::Tolerance::new();
1172 let ca = try_build_analytic_classifier(topo, a);
1173 let cb = try_build_analytic_classifier(topo, b);
1174 let rel = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
1175 rel.identical || rel.a_in_b || rel.b_in_a
1176 };
1177 if a != b && !trivial {
1178 let input_indices: Vec<usize> = solid_faces(topo, a)?
1179 .into_iter()
1180 .chain(solid_faces(topo, b)?)
1181 .map(brepkit_topology::arena::Id::index)
1182 .collect();
1183 let algo_op = match op {
1184 BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
1185 BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
1186 BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
1187 };
1188 if let Ok((result, origins)) =
1189 brepkit_algo::gfa::boolean_with_face_origins(topo, algo_op, a, b)
1190 {
1191 // Apply the face-id-preserving result heals so the evolution result
1192 // is as correct as the standard boolean (manifold, no #801 wire
1193 // spurs). These rewrite wires in place, so the provenance — keyed by
1194 // face ID — survives. `unify_faces` is intentionally NOT run here:
1195 // it merges coplanar faces into new entities, discarding the
1196 // per-face provenance this path exists to track.
1197 // A heal failure here is not fatal: fall through to boolean()'s
1198 // full pipeline rather than propagating (the result solid stays as
1199 // orphaned topology, which is harmless in the arena).
1200 let tol = brepkit_math::tolerance::Tolerance::default();
1201 let healed_ok = crate::heal::remove_degenerate_edges(topo, result, tol.linear).is_ok()
1202 && crate::heal::remove_wire_spurs(topo, result).is_ok();
1203
1204 // Trust the faithful path only if its result is valid; otherwise
1205 // fall through to boolean()'s full pipeline (fast paths + mesh
1206 // fallback + validation), matching boolean()'s contract.
1207 if healed_ok && validate_boolean_result(topo, result).is_ok() {
1208 let mut evo = crate::evolution::EvolutionMap::new();
1209 let mut sourced: std::collections::HashSet<usize> =
1210 std::collections::HashSet::new();
1211 for (out_idx, src) in origins {
1212 if let Some(in_idx) = src {
1213 evo.add_modified(in_idx, out_idx);
1214 sourced.insert(in_idx);
1215 }
1216 }
1217 for in_idx in input_indices {
1218 if !sourced.contains(&in_idx) {
1219 evo.add_deleted(in_idx);
1220 }
1221 }
1222 return Ok((result, evo));
1223 }
1224 }
1225 }
1226
1227 // Fallback: geometry heuristic over the standard boolean result. Reached
1228 // for identical operands, a GFA error, or a GFA result that failed
1229 // validation — the EvolutionMap is then approximate, not faithful.
1230 log::debug!("boolean_with_evolution: faithful GFA provenance unavailable, using heuristic");
1231 let input_faces_a = collect_face_signatures(topo, a)?;
1232 let input_faces_b = collect_face_signatures(topo, b)?;
1233
1234 let mut input_faces: Vec<(usize, Vec3, Point3)> =
1235 Vec::with_capacity(input_faces_a.len() + input_faces_b.len());
1236 input_faces.extend(input_faces_a);
1237 input_faces.extend(input_faces_b);
1238
1239 let result = boolean(topo, op, a, b)?;
1240
1241 let output_faces = collect_face_signatures(topo, result)?;
1242
1243 let evo = crate::evolution::build_evolution_by_geometry(&input_faces, &output_faces);
1244
1245 Ok((result, evo))
1246}
1247
1248/// Compute the boolean of two axis-aligned boxes via AABB algebra.
1249///
1250/// Returns `Ok(None)` when the result isn't a single box:
1251/// - Fuse: requires two of three dims to match exactly AND the boxes to
1252/// overlap or touch in the third dim. Otherwise the union is L-shaped.
1253/// - Intersect: any non-empty AABB intersection is a box.
1254/// - Cut: skipped — the general case is L-shaped, defer to GFA.
1255fn box_pair_shortcut(
1256 topo: &mut Topology,
1257 op: BooleanOp,
1258 a_min: Point3,
1259 a_max: Point3,
1260 b_min: Point3,
1261 b_max: Point3,
1262 tol: brepkit_math::tolerance::Tolerance,
1263) -> Result<Option<SolidId>, crate::OperationsError> {
1264 let eps = tol.linear;
1265 let (min, max) = match op {
1266 BooleanOp::Intersect => {
1267 let lo = Point3::new(
1268 a_min.x().max(b_min.x()),
1269 a_min.y().max(b_min.y()),
1270 a_min.z().max(b_min.z()),
1271 );
1272 let hi = Point3::new(
1273 a_max.x().min(b_max.x()),
1274 a_max.y().min(b_max.y()),
1275 a_max.z().min(b_max.z()),
1276 );
1277 // Empty intersection — let general path return an error.
1278 if hi.x() <= lo.x() + eps || hi.y() <= lo.y() + eps || hi.z() <= lo.z() + eps {
1279 return Ok(None);
1280 }
1281 (lo, hi)
1282 }
1283 BooleanOp::Fuse => {
1284 // The union of two axis-aligned boxes is itself a box only
1285 // when two of three dimensions match exactly AND the boxes
1286 // overlap or touch in the third dim.
1287 let x_match =
1288 (a_min.x() - b_min.x()).abs() < eps && (a_max.x() - b_max.x()).abs() < eps;
1289 let y_match =
1290 (a_min.y() - b_min.y()).abs() < eps && (a_max.y() - b_max.y()).abs() < eps;
1291 let z_match =
1292 (a_min.z() - b_min.z()).abs() < eps && (a_max.z() - b_max.z()).abs() < eps;
1293 let matched = u8::from(x_match) + u8::from(y_match) + u8::from(z_match);
1294 if matched < 2 {
1295 return Ok(None);
1296 }
1297 // Verify overlap/touch in all three dims (the unmatched dim
1298 // must overlap; matched dims trivially do).
1299 if a_max.x() < b_min.x() - eps
1300 || b_max.x() < a_min.x() - eps
1301 || a_max.y() < b_min.y() - eps
1302 || b_max.y() < a_min.y() - eps
1303 || a_max.z() < b_min.z() - eps
1304 || b_max.z() < a_min.z() - eps
1305 {
1306 return Ok(None);
1307 }
1308 (
1309 Point3::new(
1310 a_min.x().min(b_min.x()),
1311 a_min.y().min(b_min.y()),
1312 a_min.z().min(b_min.z()),
1313 ),
1314 Point3::new(
1315 a_max.x().max(b_max.x()),
1316 a_max.y().max(b_max.y()),
1317 a_max.z().max(b_max.z()),
1318 ),
1319 )
1320 }
1321 BooleanOp::Cut => {
1322 // Cut shortcut: when B spans A in 2 of 3 dims (≥ A's extent
1323 // on both sides) and overlaps in the third, the result is
1324 // up-to-2 axis-aligned boxes (the leftover slabs on either
1325 // side of B in the cutting dim). This avoids routing through
1326 // GFA's same-domain handling which currently mishandles the
1327 // 4-coincident-face case (target's lateral walls + tool's
1328 // matching walls).
1329 return box_pair_cut_shortcut(topo, a_min, a_max, b_min, b_max, eps);
1330 }
1331 };
1332 let dx = max.x() - min.x();
1333 let dy = max.y() - min.y();
1334 let dz = max.z() - min.z();
1335 if dx <= eps || dy <= eps || dz <= eps {
1336 return Ok(None);
1337 }
1338 let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1339 if min.x().abs() > eps || min.y().abs() > eps || min.z().abs() > eps {
1340 let xform = brepkit_math::mat::Mat4::translation(min.x(), min.y(), min.z());
1341 crate::transform::transform_solid(topo, bx, &xform)?;
1342 }
1343 Ok(Some(bx))
1344}
1345
1346/// Cut shortcut for two axis-aligned boxes: returns the leftover
1347/// portion(s) when B slices through A in one dimension while spanning
1348/// A in the other two dimensions. The result is 0, 1, or 2 axis-aligned
1349/// boxes packaged into a single multi-region Solid.
1350///
1351/// Returns `Ok(None)` when the shortcut doesn't fit — e.g., B doesn't
1352/// span A in any 2 dims, B touches only a corner, etc. The general path
1353/// (GFA) handles those cases.
1354fn box_pair_cut_shortcut(
1355 topo: &mut Topology,
1356 a_min: Point3,
1357 a_max: Point3,
1358 b_min: Point3,
1359 b_max: Point3,
1360 eps: f64,
1361) -> Result<Option<SolidId>, crate::OperationsError> {
1362 // B must span A in 2 of 3 dims (B_min ≤ A_min - eps AND B_max ≥ A_max + eps,
1363 // i.e., B's extent covers A's extent in that dim).
1364 let x_spans = b_min.x() <= a_min.x() + eps && b_max.x() >= a_max.x() - eps;
1365 let y_spans = b_min.y() <= a_min.y() + eps && b_max.y() >= a_max.y() - eps;
1366 let z_spans = b_min.z() <= a_min.z() + eps && b_max.z() >= a_max.z() - eps;
1367 let spans_count = u8::from(x_spans) + u8::from(y_spans) + u8::from(z_spans);
1368 if spans_count != 2 {
1369 return Ok(None);
1370 }
1371 // In the non-spanning dim, B must actually intersect A.
1372 let (a_lo, a_hi, b_lo, b_hi) = if !x_spans {
1373 (a_min.x(), a_max.x(), b_min.x(), b_max.x())
1374 } else if !y_spans {
1375 (a_min.y(), a_max.y(), b_min.y(), b_max.y())
1376 } else {
1377 (a_min.z(), a_max.z(), b_min.z(), b_max.z())
1378 };
1379 if b_hi <= a_lo + eps || b_lo >= a_hi - eps {
1380 return Ok(None);
1381 }
1382
1383 // Build the leftover slabs. There are 0, 1, or 2 pieces depending on
1384 // whether B extends past A on each side in the cutting dim.
1385 let cuts: Vec<(f64, f64)> = {
1386 let mut pieces = Vec::with_capacity(2);
1387 if b_lo > a_lo + eps {
1388 pieces.push((a_lo, b_lo)); // slab before B
1389 }
1390 if b_hi < a_hi - eps {
1391 pieces.push((b_hi, a_hi)); // slab after B
1392 }
1393 pieces
1394 };
1395 if cuts.is_empty() {
1396 // B fully covers A in the cutting dim → cut leaves nothing.
1397 // Let the general path handle this (it errors).
1398 return Ok(None);
1399 }
1400
1401 let piece_solids: Vec<SolidId> = cuts
1402 .iter()
1403 .map(|&(lo, hi)| -> Result<SolidId, crate::OperationsError> {
1404 let (dx, dy, dz, tx, ty, tz) = if !x_spans {
1405 (
1406 hi - lo,
1407 a_max.y() - a_min.y(),
1408 a_max.z() - a_min.z(),
1409 lo,
1410 a_min.y(),
1411 a_min.z(),
1412 )
1413 } else if !y_spans {
1414 (
1415 a_max.x() - a_min.x(),
1416 hi - lo,
1417 a_max.z() - a_min.z(),
1418 a_min.x(),
1419 lo,
1420 a_min.z(),
1421 )
1422 } else {
1423 (
1424 a_max.x() - a_min.x(),
1425 a_max.y() - a_min.y(),
1426 hi - lo,
1427 a_min.x(),
1428 a_min.y(),
1429 lo,
1430 )
1431 };
1432 let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1433 if tx.abs() > eps || ty.abs() > eps || tz.abs() > eps {
1434 let xform = brepkit_math::mat::Mat4::translation(tx, ty, tz);
1435 crate::transform::transform_solid(topo, bx, &xform)?;
1436 }
1437 Ok(bx)
1438 })
1439 .collect::<Result<_, _>>()?;
1440
1441 if piece_solids.len() == 1 {
1442 return Ok(Some(piece_solids[0]));
1443 }
1444
1445 // Combine pieces into a single multi-region solid.
1446 let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
1447 for &p in &piece_solids {
1448 let p_data = topo.solid(p)?;
1449 for &fid in topo.shell(p_data.outer_shell())?.faces() {
1450 all_faces.push(fid);
1451 }
1452 }
1453 Ok(Some(make_solid_from_face_subset(topo, &all_faces)?))
1454}
1455
1456/// Compute the coaxial-cylinder boolean for two cylinders sharing axis,
1457/// origin, and radius. Returns `Ok(None)` when the shortcut doesn't apply
1458/// (disjoint along axis for fuse/intersect; cut requires general handling).
1459#[allow(clippy::too_many_arguments)]
1460fn coaxial_cylinder_shortcut(
1461 topo: &mut Topology,
1462 op: BooleanOp,
1463 origin: Point3,
1464 axis: Vec3,
1465 radius: f64,
1466 a_range: (f64, f64),
1467 b_range: (f64, f64),
1468 tol: brepkit_math::tolerance::Tolerance,
1469) -> Result<Option<SolidId>, crate::OperationsError> {
1470 let (za_min, za_max) = a_range;
1471 let (zb_min, zb_max) = b_range;
1472 // For fuse: ranges must touch or overlap. Disjoint cylinders would
1473 // produce a compound, which the boolean API doesn't return.
1474 let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1475 let (z_min, z_max) = match op {
1476 BooleanOp::Fuse => {
1477 if !touches_or_overlaps {
1478 return Ok(None);
1479 }
1480 (za_min.min(zb_min), za_max.max(zb_max))
1481 }
1482 BooleanOp::Intersect => {
1483 // Strict overlap (not just touching) for non-degenerate result.
1484 let lo = za_min.max(zb_min);
1485 let hi = za_max.min(zb_max);
1486 if hi <= lo + tol.linear {
1487 return Ok(None);
1488 }
1489 (lo, hi)
1490 }
1491 BooleanOp::Cut => return Ok(None), // Defer to GFA / general path.
1492 };
1493 let height = z_max - z_min;
1494 if height <= tol.linear {
1495 return Ok(None);
1496 }
1497 // Build a fresh cylinder at axis-origin + axis*z_min, oriented along
1498 // axis. make_cylinder produces the canonical (0,0,0)→(0,0,h) cylinder;
1499 // then transform to the world axis frame.
1500 let cyl = crate::primitives::make_cylinder(topo, radius, height)?;
1501 let world_origin = Point3::new(
1502 origin.x() + axis.x() * z_min,
1503 origin.y() + axis.y() * z_min,
1504 origin.z() + axis.z() * z_min,
1505 );
1506 let xform = xform_from_canonical_z(world_origin, axis, tol);
1507 crate::transform::transform_solid(topo, cyl, &xform)?;
1508 Ok(Some(cyl))
1509}
1510
1511/// Compute the coaxial-cone boolean for two frustums on the same conical
1512/// surface (shared apex, axis, and half-angle). Returns `Ok(None)` when
1513/// the shortcut doesn't apply.
1514#[allow(clippy::too_many_arguments)]
1515fn coaxial_cone_shortcut(
1516 topo: &mut Topology,
1517 op: BooleanOp,
1518 apex: Point3,
1519 axis: Vec3,
1520 slope: f64,
1521 a_range: (f64, f64),
1522 b_range: (f64, f64),
1523 tol: brepkit_math::tolerance::Tolerance,
1524) -> Result<Option<SolidId>, crate::OperationsError> {
1525 let (za_min, za_max) = a_range;
1526 let (zb_min, zb_max) = b_range;
1527 let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1528 let (z_min, z_max) = match op {
1529 BooleanOp::Fuse => {
1530 if !touches_or_overlaps {
1531 return Ok(None);
1532 }
1533 (za_min.min(zb_min), za_max.max(zb_max))
1534 }
1535 BooleanOp::Intersect => {
1536 let lo = za_min.max(zb_min);
1537 let hi = za_max.min(zb_max);
1538 if hi <= lo + tol.linear {
1539 return Ok(None);
1540 }
1541 (lo, hi)
1542 }
1543 BooleanOp::Cut => return Ok(None),
1544 };
1545 let height = z_max - z_min;
1546 if height <= tol.linear {
1547 return Ok(None);
1548 }
1549 // r at axial position z (apex-relative) = slope * z. For frustums on
1550 // the +axis nappe, both z values are positive; if either becomes ≤ 0
1551 // (apex inclusion), bail out so we don't construct a degenerate cone.
1552 let r_at_z_min = slope * z_min;
1553 let r_at_z_max = slope * z_max;
1554 if r_at_z_min < -tol.linear || r_at_z_max < -tol.linear {
1555 return Ok(None);
1556 }
1557 let r_bot = r_at_z_min.abs();
1558 let r_top = r_at_z_max.abs();
1559 if r_bot <= tol.linear && r_top <= tol.linear {
1560 return Ok(None);
1561 }
1562 let cone = crate::primitives::make_cone(topo, r_bot, r_top, height)?;
1563 let world_origin = Point3::new(
1564 apex.x() + axis.x() * z_min,
1565 apex.y() + axis.y() * z_min,
1566 apex.z() + axis.z() * z_min,
1567 );
1568 // Cone shortcut keeps to axis-aligned cases for now (test corpus does
1569 // not yet cover off-axis cones). Detect parallel/antiparallel via the
1570 // dot product (the canonical-axis Z-component is the only term that
1571 // survives `canonical · axis` since canonical = ẑ).
1572 let dot = axis.z().clamp(-1.0, 1.0);
1573 if 1.0 - dot.abs() > tol.angular {
1574 return Ok(None);
1575 }
1576 let xform = xform_from_canonical_z(world_origin, axis, tol);
1577 crate::transform::transform_solid(topo, cone, &xform)?;
1578 Ok(Some(cone))
1579}
1580
1581/// Compute the concentric-sphere boolean for two spheres sharing a
1582/// Box-sphere `Intersect` shortcut. Handles two configurations exactly,
1583/// returning `Ok(None)` to fall through to GFA otherwise:
1584///
1585/// 1. **Sphere fully inside box** — every box face plane has the sphere
1586/// on the box-interior side with margin ≥ `R` (`s ≤ -R + eps`). The
1587/// result is a fresh sphere primitive at `sphere_center` with radius
1588/// `sphere_radius`.
1589/// 2. **Spherical "octant"** — exactly 3 of the 6 box face planes cut
1590/// the sphere (`|s| < R - eps`) and the other 3 leave the sphere on
1591/// the box-interior side. The 3 cutting planes are mutually orthogonal
1592/// (axis-aligned box invariant) and meet at a single box corner `O`.
1593/// The result is the sphere region in the box-interior octant of `O`,
1594/// bounded by 3 quarter-disc box sub-faces and 1 spherical patch.
1595///
1596/// `s` is the signed distance from `sphere_center` to a face plane along
1597/// the face's outward normal (positive = sphere on box-exterior side).
1598/// If any face has `s ≥ R - eps` the result is empty (sphere doesn't
1599/// reach into the box from that side) — we return `None` rather than an
1600/// empty solid so the caller can produce the canonical `EmptyResult`
1601/// error via the regular path.
1602#[allow(clippy::too_many_arguments)]
1603fn box_sphere_intersect_shortcut(
1604 topo: &mut Topology,
1605 box_min: Point3,
1606 box_max: Point3,
1607 sphere_center: Point3,
1608 sphere_radius: f64,
1609 sphere_segments: usize,
1610 tol: brepkit_math::tolerance::Tolerance,
1611) -> Result<Option<SolidId>, crate::OperationsError> {
1612 let r = sphere_radius;
1613 let eps = tol.linear;
1614 if r <= eps {
1615 return Ok(None);
1616 }
1617 // Sanity: degenerate or inverted box.
1618 if box_max.x() <= box_min.x() + eps
1619 || box_max.y() <= box_min.y() + eps
1620 || box_max.z() <= box_min.z() + eps
1621 {
1622 return Ok(None);
1623 }
1624
1625 // For each of 6 box face planes, compute `s` (signed distance from
1626 // sphere center along outward normal). Classify each plane.
1627 let faces: [(Vec3, f64); 6] = [
1628 (Vec3::new(-1.0, 0.0, 0.0), -box_min.x()),
1629 (Vec3::new(1.0, 0.0, 0.0), box_max.x()),
1630 (Vec3::new(0.0, -1.0, 0.0), -box_min.y()),
1631 (Vec3::new(0.0, 1.0, 0.0), box_max.y()),
1632 (Vec3::new(0.0, 0.0, -1.0), -box_min.z()),
1633 (Vec3::new(0.0, 0.0, 1.0), box_max.z()),
1634 ];
1635 let signed_dist = |n: Vec3, d: f64| -> f64 {
1636 n.x() * sphere_center.x() + n.y() * sphere_center.y() + n.z() * sphere_center.z() - d
1637 };
1638
1639 let mut cuts: Vec<usize> = Vec::new();
1640 for (i, &(n, d)) in faces.iter().enumerate() {
1641 let s = signed_dist(n, d);
1642 if s >= r - eps {
1643 // Sphere is fully on the exterior side of this plane → box ∩
1644 // sphere = empty. Defer to GFA which will surface an
1645 // EmptyResult error in its usual form.
1646 return Ok(None);
1647 }
1648 if s.abs() < r - eps {
1649 cuts.push(i);
1650 }
1651 // else: s ≤ -r + eps → sphere fully inside this plane, face
1652 // doesn't bound the result; nothing to do.
1653 }
1654
1655 // Case 1: sphere fully inside box (no cutting planes).
1656 if cuts.is_empty() {
1657 let sphere = crate::primitives::make_sphere(topo, r, sphere_segments)?;
1658 if sphere_center.x().abs() > eps
1659 || sphere_center.y().abs() > eps
1660 || sphere_center.z().abs() > eps
1661 {
1662 let xform = brepkit_math::mat::Mat4::translation(
1663 sphere_center.x(),
1664 sphere_center.y(),
1665 sphere_center.z(),
1666 );
1667 crate::transform::transform_solid(topo, sphere, &xform)?;
1668 }
1669 return Ok(Some(sphere));
1670 }
1671
1672 // Case 2: 3 cutting planes meeting at a box corner → spherical
1673 // octant. The 3 cut planes' outward normals are mutually orthogonal
1674 // (axis-aligned box invariant) so the in-box direction perpendicular
1675 // to each is the negated outward normal.
1676 if cuts.len() == 3 {
1677 return build_box_sphere_octant(topo, &faces, &cuts, sphere_center, r, tol);
1678 }
1679
1680 // 1, 2, 4, 5, 6 cutting planes — more complex geometries (caps,
1681 // lenses, etc.). Out of scope for this shortcut; fall through.
1682 Ok(None)
1683}
1684
1685/// Construct the result of `box ∩ sphere` when exactly 3 box face planes
1686/// cut the sphere and meet at a single corner `O`. The result topology
1687/// is 4 faces (3 quarter-discs + 1 spherical patch), 6 edges, 4 vertices.
1688fn build_box_sphere_octant(
1689 topo: &mut Topology,
1690 faces: &[(Vec3, f64); 6],
1691 cuts: &[usize],
1692 sphere_center: Point3,
1693 r: f64,
1694 tol: brepkit_math::tolerance::Tolerance,
1695) -> Result<Option<SolidId>, crate::OperationsError> {
1696 use brepkit_math::curves::Circle3D;
1697 use brepkit_math::surfaces::SphericalSurface;
1698 use brepkit_topology::edge::{Edge, EdgeCurve};
1699 use brepkit_topology::face::{Face, FaceSurface};
1700 use brepkit_topology::shell::Shell;
1701 use brepkit_topology::solid::Solid;
1702 use brepkit_topology::vertex::Vertex;
1703 use brepkit_topology::wire::{OrientedEdge, Wire};
1704
1705 // Cutting plane normals + their box-plane-d values.
1706 let cut_planes: Vec<(Vec3, f64)> = cuts.iter().map(|&i| faces[i]).collect();
1707 // The 3 outward normals must be mutually orthogonal (axis-aligned box).
1708 let n0 = cut_planes[0].0;
1709 let n1 = cut_planes[1].0;
1710 let n2 = cut_planes[2].0;
1711 if n0.dot(n1).abs() > tol.angular
1712 || n0.dot(n2).abs() > tol.angular
1713 || n1.dot(n2).abs() > tol.angular
1714 {
1715 // Not orthogonal — defer to GFA.
1716 return Ok(None);
1717 }
1718 // The corner O is at the intersection of the 3 cutting planes:
1719 // n_i · O = d_i for all 3 i.
1720 // Since the normals are axis-aligned (±x, ±y, ±z), we can pull each
1721 // coordinate of O directly off the matching plane's d.
1722 let coord_from_axis = |axis: Vec3, d: f64| -> f64 {
1723 if axis.x().abs() > 0.5 {
1724 d * axis.x().signum()
1725 } else if axis.y().abs() > 0.5 {
1726 d * axis.y().signum()
1727 } else {
1728 d * axis.z().signum()
1729 }
1730 };
1731 let mut o = [0.0_f64; 3];
1732 for &(n, d) in &cut_planes {
1733 if n.x().abs() > 0.5 {
1734 o[0] = coord_from_axis(n, d);
1735 } else if n.y().abs() > 0.5 {
1736 o[1] = coord_from_axis(n, d);
1737 } else {
1738 o[2] = coord_from_axis(n, d);
1739 }
1740 }
1741 let o = Point3::new(o[0], o[1], o[2]);
1742
1743 // In-box direction perpendicular to each cutting plane = -n_i.
1744 let in_dirs: Vec<Vec3> = cut_planes
1745 .iter()
1746 .map(|&(n, _)| Vec3::new(-n.x(), -n.y(), -n.z()))
1747 .collect();
1748
1749 // For each cutting plane i, the box edge from O in direction in_dirs[i]
1750 // is the intersection of the other two cutting planes. Find the sphere
1751 // intersection with this edge — the vertex on the sphere along the box
1752 // edge.
1753 //
1754 // Edge parameterised as O + t·d_i for t ≥ 0. Sphere: |P - C|² = R².
1755 // (O + t·d_i - C) · (O + t·d_i - C) = R²
1756 // Let v = O - C; expand:
1757 // t² + 2 t (v · d_i) + |v|² - R² = 0
1758 // So t = -v·d_i ± sqrt((v·d_i)² - |v|² + R²)
1759 let mut sphere_pts: [Point3; 3] = [Point3::new(0.0, 0.0, 0.0); 3];
1760 for (idx, &dir) in in_dirs.iter().enumerate() {
1761 let vx = o.x() - sphere_center.x();
1762 let vy = o.y() - sphere_center.y();
1763 let vz = o.z() - sphere_center.z();
1764 let v_dot_d = vx * dir.x() + vy * dir.y() + vz * dir.z();
1765 let v_sq = vx * vx + vy * vy + vz * vz;
1766 let disc = v_dot_d * v_dot_d - v_sq + r * r;
1767 if disc < -tol.linear * tol.linear {
1768 return Ok(None);
1769 }
1770 let t = -v_dot_d + disc.max(0.0).sqrt();
1771 if t <= tol.linear {
1772 return Ok(None);
1773 }
1774 sphere_pts[idx] = Point3::new(
1775 o.x() + t * dir.x(),
1776 o.y() + t * dir.y(),
1777 o.z() + t * dir.z(),
1778 );
1779 }
1780
1781 // Topology: 4 vertices, 6 edges, 4 faces.
1782 let v_o = topo.add_vertex(Vertex::new(o, tol.linear));
1783 let v_x = topo.add_vertex(Vertex::new(sphere_pts[0], tol.linear));
1784 let v_y = topo.add_vertex(Vertex::new(sphere_pts[1], tol.linear));
1785 let v_z = topo.add_vertex(Vertex::new(sphere_pts[2], tol.linear));
1786
1787 // 3 line edges from O along the box edges.
1788 let e_ox = topo.add_edge(Edge::new(v_o, v_x, EdgeCurve::Line));
1789 let e_oy = topo.add_edge(Edge::new(v_o, v_y, EdgeCurve::Line));
1790 let e_oz = topo.add_edge(Edge::new(v_o, v_z, EdgeCurve::Line));
1791
1792 // 3 arc edges on the sphere. Each arc lies on one of the cutting planes:
1793 // the arc opposite vertex `i` (i.e., between the other two vertices)
1794 // sits on cutting plane `i` (normal `n_i`), because those two vertices
1795 // lie on edges perpendicular to the remaining two normals — and both
1796 // of those edges lie within the plane perpendicular to `n_i`.
1797 let mut build_arc_edge =
1798 |n: Vec3,
1799 p_start: Point3,
1800 p_end: Point3,
1801 start_vid,
1802 end_vid|
1803 -> Result<brepkit_topology::edge::EdgeId, crate::OperationsError> {
1804 let dist = n.x() * (sphere_center.x() - p_start.x())
1805 + n.y() * (sphere_center.y() - p_start.y())
1806 + n.z() * (sphere_center.z() - p_start.z());
1807 let circle_center = Point3::new(
1808 sphere_center.x() - dist * n.x(),
1809 sphere_center.y() - dist * n.y(),
1810 sphere_center.z() - dist * n.z(),
1811 );
1812 let circle_r = (r * r - dist * dist).max(0.0).sqrt();
1813 if circle_r <= tol.linear {
1814 return Err(crate::OperationsError::InvalidInput {
1815 reason: "box-sphere octant: degenerate arc radius".into(),
1816 });
1817 }
1818 let dx = p_start.x() - circle_center.x();
1819 let dy = p_start.y() - circle_center.y();
1820 let dz = p_start.z() - circle_center.z();
1821 let len = (dx * dx + dy * dy + dz * dz).sqrt();
1822 if len <= tol.linear {
1823 return Err(crate::OperationsError::InvalidInput {
1824 reason: "box-sphere octant: degenerate arc reference".into(),
1825 });
1826 }
1827 let u_ref = Vec3::new(dx / len, dy / len, dz / len);
1828 // The circle's CCW direction must take start -> end the SHORT
1829 // way (the quarter arc bounding the octant). About the cutting
1830 // plane's OUTWARD normal that span is the 270-degree
1831 // complement (the wrong-region 1304.8 volume); the INWARD
1832 // normal makes it the intended quarter.
1833 let inward = Vec3::new(-n.x(), -n.y(), -n.z());
1834 let circle =
1835 Circle3D::new_with_ref(circle_center, inward, circle_r, u_ref).map_err(|e| {
1836 crate::OperationsError::InvalidInput {
1837 reason: format!("box-sphere octant: circle construction failed: {e}"),
1838 }
1839 })?;
1840 let _ = p_end; // p_end is used only via end_vid (already pre-placed at the correct sphere point)
1841 Ok(topo.add_edge(Edge::new(start_vid, end_vid, EdgeCurve::Circle(circle))))
1842 };
1843
1844 // Arc on cut plane 0 (between v_y and v_z, i.e., the edge "opposite" v_x).
1845 let arc_yz = build_arc_edge(n0, sphere_pts[1], sphere_pts[2], v_y, v_z)?;
1846 // Arc on cut plane 1 (between v_z and v_x).
1847 let arc_zx = build_arc_edge(n1, sphere_pts[2], sphere_pts[0], v_z, v_x)?;
1848 // Arc on cut plane 2 (between v_x and v_y).
1849 let arc_xy = build_arc_edge(n2, sphere_pts[0], sphere_pts[1], v_x, v_y)?;
1850
1851 // Quarter-disc face on cut plane 0 (perpendicular to n0): bounded by
1852 // box edges O-Y and O-Z + arc Y→Z.
1853 let qd0_wire = Wire::new(
1854 vec![
1855 OrientedEdge::new(e_oy, true), // O → Y
1856 OrientedEdge::new(arc_yz, true), // Y → Z (arc)
1857 OrientedEdge::new(e_oz, false), // Z → O (reversed)
1858 ],
1859 true,
1860 )
1861 .map_err(crate::OperationsError::Topology)?;
1862 let qd0_id = topo.add_wire(qd0_wire);
1863 let qd0_face = topo.add_face(Face::new(
1864 qd0_id,
1865 Vec::new(),
1866 FaceSurface::Plane {
1867 normal: n0,
1868 d: cut_planes[0].1,
1869 },
1870 ));
1871
1872 let qd1_wire = Wire::new(
1873 vec![
1874 OrientedEdge::new(e_oz, true), // O → Z
1875 OrientedEdge::new(arc_zx, true), // Z → X (arc)
1876 OrientedEdge::new(e_ox, false), // X → O (reversed)
1877 ],
1878 true,
1879 )
1880 .map_err(crate::OperationsError::Topology)?;
1881 let qd1_id = topo.add_wire(qd1_wire);
1882 let qd1_face = topo.add_face(Face::new(
1883 qd1_id,
1884 Vec::new(),
1885 FaceSurface::Plane {
1886 normal: n1,
1887 d: cut_planes[1].1,
1888 },
1889 ));
1890
1891 let qd2_wire = Wire::new(
1892 vec![
1893 OrientedEdge::new(e_ox, true), // O → X
1894 OrientedEdge::new(arc_xy, true), // X → Y (arc)
1895 OrientedEdge::new(e_oy, false), // Y → O (reversed)
1896 ],
1897 true,
1898 )
1899 .map_err(crate::OperationsError::Topology)?;
1900 let qd2_id = topo.add_wire(qd2_wire);
1901 let qd2_face = topo.add_face(Face::new(
1902 qd2_id,
1903 Vec::new(),
1904 FaceSurface::Plane {
1905 normal: n2,
1906 d: cut_planes[2].1,
1907 },
1908 ));
1909
1910 // Spherical patch: bounded by the 3 arcs.
1911 // Wind so the sphere's outward normal matches the resulting volume
1912 // (outside the octant). With arcs going X→Y→Z→X around the patch,
1913 // the right-hand rule gives an outward normal pointing AWAY from O.
1914 // Each arc is traversed forward by its quarter-disc, so the patch must
1915 // traverse all three reversed for consistent edge senses: X → Z → Y → X.
1916 let sph_wire = Wire::new(
1917 vec![
1918 OrientedEdge::new(arc_zx, false), // X → Z
1919 OrientedEdge::new(arc_yz, false), // Z → Y
1920 OrientedEdge::new(arc_xy, false), // Y → X
1921 ],
1922 true,
1923 )
1924 .map_err(crate::OperationsError::Topology)?;
1925 let sph_wire_id = topo.add_wire(sph_wire);
1926 let sphere_surface = SphericalSurface::new(sphere_center, r).map_err(|e| {
1927 crate::OperationsError::InvalidInput {
1928 reason: format!("box-sphere octant: sphere surface construction failed: {e}"),
1929 }
1930 })?;
1931 let sphere_face = topo.add_face(Face::new(
1932 sph_wire_id,
1933 Vec::new(),
1934 FaceSurface::Sphere(sphere_surface),
1935 ));
1936
1937 let shell = Shell::new(vec![qd0_face, qd1_face, qd2_face, sphere_face])
1938 .map_err(crate::OperationsError::Topology)?;
1939 let shell_id = topo.add_shell(shell);
1940 let solid = topo.add_solid(Solid::new(shell_id, Vec::new()));
1941 Ok(Some(solid))
1942}
1943
1944/// center. Returns `Ok(None)` when the shortcut doesn't apply (Cut, or
1945/// degenerate radii).
1946///
1947/// Sphere-sphere is simpler than the cylinder/cone analogues because
1948/// there's no axial range — the result radius is just `max(r_a, r_b)`
1949/// for Fuse and `min(r_a, r_b)` for Intersect.
1950///
1951/// The new sphere's tessellation density (segment count) is inherited from
1952/// whichever input has a higher equatorial vertex count, so a
1953/// 64-segment input never silently downgrades to a coarse default. This
1954/// relies on `make_sphere` allocating exactly `segments` equatorial
1955/// vertices and no pole vertices — see `crates/operations/src/primitives.rs`.
1956#[allow(clippy::too_many_arguments)]
1957fn concentric_sphere_shortcut(
1958 topo: &mut Topology,
1959 op: BooleanOp,
1960 a: SolidId,
1961 b: SolidId,
1962 center: Point3,
1963 r_a: f64,
1964 r_b: f64,
1965 tol: brepkit_math::tolerance::Tolerance,
1966) -> Result<Option<SolidId>, crate::OperationsError> {
1967 if r_a <= tol.linear || r_b <= tol.linear {
1968 return Ok(None);
1969 }
1970 let r_result = match op {
1971 BooleanOp::Fuse => r_a.max(r_b),
1972 BooleanOp::Intersect => {
1973 // Both r_a and r_b are guaranteed > tol.linear by the guard above,
1974 // so `min(r_a, r_b)` is always positive here.
1975 r_a.min(r_b)
1976 }
1977 // Cut(A, B) on concentric spheres yields a hollow ball when r_a > r_b;
1978 // empty when r_a ≤ r_b. The hollow-ball case needs an outer + inner
1979 // shell, which `make_sphere` doesn't produce — defer to GFA.
1980 BooleanOp::Cut => return Ok(None),
1981 };
1982
1983 // Inherit segment count from whichever input was tessellated more finely.
1984 // `make_sphere(r, n)` allocates exactly `n` equatorial vertices; because
1985 // sphere primitives are fully describe by (center, radius), all vertices
1986 // belong to that ring. Floor at 4 to satisfy `make_sphere`'s lower bound.
1987 let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1988 .map(|v| v.len())
1989 .unwrap_or(0);
1990 let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1991 .map(|v| v.len())
1992 .unwrap_or(0);
1993 let segments = segments_a.max(segments_b).max(4);
1994
1995 let sphere = crate::primitives::make_sphere(topo, r_result, segments)?;
1996 if center.x().abs() > tol.linear
1997 || center.y().abs() > tol.linear
1998 || center.z().abs() > tol.linear
1999 {
2000 let xform = brepkit_math::mat::Mat4::translation(center.x(), center.y(), center.z());
2001 crate::transform::transform_solid(topo, sphere, &xform)?;
2002 }
2003 Ok(Some(sphere))
2004}
2005
2006/// Compute the coaxial-torus boolean for two tori sharing center, axis,
2007/// and major radius. Returns `Ok(None)` when the shortcut doesn't apply
2008/// (Cut, or degenerate radii / overlap).
2009///
2010/// Like the concentric-sphere shortcut, the result tessellation density
2011/// is inherited from the higher-quality input so a 64-segment input
2012/// torus never silently downgrades.
2013#[allow(clippy::too_many_arguments)]
2014fn coaxial_torus_shortcut(
2015 topo: &mut Topology,
2016 op: BooleanOp,
2017 a: SolidId,
2018 b: SolidId,
2019 center: Point3,
2020 axis: Vec3,
2021 major_radius: f64,
2022 minor_a: f64,
2023 minor_b: f64,
2024 tol: brepkit_math::tolerance::Tolerance,
2025) -> Result<Option<SolidId>, crate::OperationsError> {
2026 if minor_a <= tol.linear || minor_b <= tol.linear || major_radius <= tol.linear {
2027 return Ok(None);
2028 }
2029 let minor_result = match op {
2030 BooleanOp::Fuse => minor_a.max(minor_b),
2031 BooleanOp::Intersect => {
2032 // Both minors are guaranteed > tol by the guard above.
2033 minor_a.min(minor_b)
2034 }
2035 // Cut on coaxial tori with shared major produces a hollow torus
2036 // (outer + inner small-circle shells) when minor_a > minor_b.
2037 // `make_torus` doesn't build that topology — defer to GFA.
2038 BooleanOp::Cut => return Ok(None),
2039 };
2040 if minor_result >= major_radius {
2041 // make_torus rejects self-intersecting tori (minor >= major).
2042 return Ok(None);
2043 }
2044
2045 // Inherit segment count from the higher-quality input. `make_torus`
2046 // accepts a `segments` param controlling u-direction discretization.
2047 // We'd ideally extract this from each input solid's vertex count, but
2048 // unlike make_sphere torus topology has internal seam vertices that
2049 // make the relationship less clean. Approximate by the larger vertex
2050 // count.
2051 let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
2052 .map(|v| v.len())
2053 .unwrap_or(0);
2054 let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
2055 .map(|v| v.len())
2056 .unwrap_or(0);
2057 let segments = segments_a.max(segments_b).max(8);
2058
2059 // Build a fresh torus at the origin then transform to the shared
2060 // center / axis. `make_torus` builds with axis = +z by default.
2061 let torus = crate::primitives::make_torus(topo, major_radius, minor_result, segments)?;
2062 let xform = xform_from_canonical_z(center, axis, tol);
2063 crate::transform::transform_solid(topo, torus, &xform)?;
2064 Ok(Some(torus))
2065}
2066
2067/// Build the world-frame transform that maps a primitive built in the
2068/// canonical Z-up local frame (origin at world origin, axis = +Z) to a
2069/// world frame at `world_origin` with up-axis `axis` (assumed
2070/// unit-length). Uses Rodrigues' rotation formula for the general case.
2071///
2072/// Comparisons use `1.0 - axis.dot(canonical) < tol.angular` rather than
2073/// vector-length deltas, because for unit vectors `|u−v| ≈ √2·θ`, so a
2074/// length comparison against `tol.angular` would correspond to
2075/// `θ ≈ 7×10⁻¹³` rad — effectively bit-identity.
2076fn xform_from_canonical_z(
2077 world_origin: Point3,
2078 axis: Vec3,
2079 tol: brepkit_math::tolerance::Tolerance,
2080) -> brepkit_math::mat::Mat4 {
2081 let translate =
2082 brepkit_math::mat::Mat4::translation(world_origin.x(), world_origin.y(), world_origin.z());
2083 let canonical = Vec3::new(0.0, 0.0, 1.0);
2084 let dot = canonical.dot(axis).clamp(-1.0, 1.0);
2085 // Parallel to +Z: pure translation.
2086 if 1.0 - dot < tol.angular {
2087 return translate;
2088 }
2089 // Antiparallel: rotate canonical (+z) by π around X to flip to −z.
2090 if 1.0 + dot < tol.angular {
2091 return translate * brepkit_math::mat::Mat4::rotation_x(std::f64::consts::PI);
2092 }
2093 // Rotate canonical (0,0,1) → axis via Rodrigues' formula:
2094 // R = I + sin(θ) K + (1 - cos(θ)) K², K = [k]× for k = ẑ × axis / sin(θ).
2095 // k.z = 0 by construction, so K's z-row/z-column have a known structure.
2096 let sin_t = (1.0 - dot * dot).sqrt();
2097 let kx = -axis.y() / sin_t;
2098 let ky = axis.x() / sin_t;
2099 let one_minus_cos = 1.0 - dot;
2100 let r00 = one_minus_cos.mul_add(kx * kx, dot);
2101 let r01 = one_minus_cos * kx * ky;
2102 let r02 = sin_t * ky;
2103 let r10 = one_minus_cos * kx * ky;
2104 let r11 = one_minus_cos.mul_add(ky * ky, dot);
2105 let r12 = -sin_t * kx;
2106 let r20 = -sin_t * ky;
2107 let r21 = sin_t * kx;
2108 let r22 = dot;
2109 let rot = brepkit_math::mat::Mat4([
2110 [r00, r01, r02, 0.0],
2111 [r10, r11, r12, 0.0],
2112 [r20, r21, r22, 0.0],
2113 [0.0, 0.0, 0.0, 1.0],
2114 ]);
2115 translate * rot
2116}
2117
2118/// Returns `true` when two axis-aligned boxes are separated on at least
2119/// one axis by more than `margin` — i.e. their (margin-expanded) extents
2120/// do not overlap and the solids they bound provably do not intersect.
2121///
2122/// The `margin` shrinks the overlap test so boxes that only touch (or
2123/// nearly touch) within `margin` are treated as separated: a shared
2124/// face/edge/corner has zero overlap volume.
2125fn aabbs_separated(
2126 a: &brepkit_math::aabb::Aabb3,
2127 b: &brepkit_math::aabb::Aabb3,
2128 margin: f64,
2129) -> bool {
2130 a.max.x() < b.min.x() + margin
2131 || b.max.x() < a.min.x() + margin
2132 || a.max.y() < b.min.y() + margin
2133 || b.max.y() < a.min.y() + margin
2134 || a.max.z() < b.min.z() + margin
2135 || b.max.z() < a.min.z() + margin
2136}
2137
2138/// Returns `true` when two axis-aligned boxes have a *clear gap* exceeding
2139/// `margin` on at least one axis — i.e. they are separated by a real positive
2140/// distance, not merely touching.
2141///
2142/// This is intentionally stricter than [`aabbs_separated`]: a shared
2143/// face/edge/corner (zero gap) returns `false` here. Touching solids must NOT
2144/// be treated as disjoint by the fuse fast path — their shared geometry has to
2145/// be welded by GFA.
2146fn aabbs_clear_gap(
2147 a: &brepkit_math::aabb::Aabb3,
2148 b: &brepkit_math::aabb::Aabb3,
2149 margin: f64,
2150) -> bool {
2151 b.min.x() - a.max.x() > margin
2152 || a.min.x() - b.max.x() > margin
2153 || b.min.y() - a.max.y() > margin
2154 || a.min.y() - b.max.y() > margin
2155 || b.min.z() - a.max.z() > margin
2156 || a.min.z() - b.max.z() > margin
2157}
2158
2159/// Returns `true` when solids `a` and `b` are provably spatially disjoint with
2160/// a clear gap: every connected face component of `a` is separated from every
2161/// connected face component of `b` by more than `margin` on some axis.
2162///
2163/// Soundness: component AABBs come from [`crate::measure::face_set_bounding_box`],
2164/// which is a conservative *outer* bound (vertices plus surface-curvature
2165/// expansion). If two components' true geometry overlapped or touched, their
2166/// boxes would touch or overlap and [`aabbs_clear_gap`] would (correctly)
2167/// return `false`. So a `true` result guarantees a real positive gap between
2168/// the two solids — never a false "disjoint" for touching/coincident inputs,
2169/// which must still go through GFA to weld shared geometry.
2170///
2171/// Component-level (rather than whole-solid) granularity is essential: a
2172/// multi-region solid (e.g. an accumulator of several already-merged disjoint
2173/// pieces) has a single outer shell whose overall box overlaps a nearby piece,
2174/// yet none of its pieces actually touch that piece. [`assembly::face_components`]
2175/// recovers the individual pieces from the merged shell.
2176///
2177/// Returns `false` on any topology error or empty operand (fall through to the
2178/// general path) rather than risking an unsound merge.
2179fn solids_provably_disjoint(topo: &Topology, a: SolidId, b: SolidId, margin: f64) -> bool {
2180 let comps_a = assembly::face_components(topo, a);
2181 let comps_b = assembly::face_components(topo, b);
2182 if comps_a.is_empty() || comps_b.is_empty() {
2183 return false;
2184 }
2185 let boxes = |comps: &[Vec<FaceId>]| -> Option<Vec<brepkit_math::aabb::Aabb3>> {
2186 comps
2187 .iter()
2188 .map(|faces| crate::measure::face_set_bounding_box(topo, faces).ok())
2189 .collect()
2190 };
2191 let (Some(boxes_a), Some(boxes_b)) = (boxes(&comps_a), boxes(&comps_b)) else {
2192 return false;
2193 };
2194 boxes_a
2195 .iter()
2196 .all(|ba| boxes_b.iter().all(|bb| aabbs_clear_gap(ba, bb, margin)))
2197}
2198
2199/// The trivial operand relationships that let [`boolean`] short-circuit
2200/// without running the GFA: identical solids and full containment.
2201struct TrivialRelation {
2202 /// Matching AABBs AND every boundary vertex of each solid classifies
2203 /// as inside-or-on the other's analytic classifier.
2204 identical: bool,
2205 /// A is fully contained in B.
2206 a_in_b: bool,
2207 /// B is fully contained in A.
2208 b_in_a: bool,
2209}
2210
2211/// Detect the trivial operand relationships (identical / contained).
2212///
2213/// [`boolean`] uses this to take copy/empty shortcuts. [`boolean_with_evolution`]
2214/// consults the same detection BEFORE its faithful raw-GFA provenance path:
2215/// these are exactly the fully-coincident-boundary configurations the raw GFA
2216/// mis-splits (coincident walls dropped into an open shell whose
2217/// position-duplicate free edges slip past the by-edge-id validation gate), so
2218/// the evolution path must route them through [`boolean`]'s shortcuts instead.
2219fn detect_trivial_relation(
2220 topo: &Topology,
2221 a: SolidId,
2222 b: SolidId,
2223 ca: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2224 cb: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2225 tol: brepkit_math::tolerance::Tolerance,
2226) -> TrivialRelation {
2227 // Use measure::solid_bounding_box — it expands for surface curvature
2228 // (cylinder vertex projection, sphere/torus analytic). The naive
2229 // edge-vertex sampler missed cylinder lateral extents because cylinders
2230 // only have seam vertices, leaving the AABB center on the lateral
2231 // surface where the analytic classifier returns None.
2232 let sample_aabb = |topo: &Topology, solid: SolidId| -> Option<(Point3, Point3)> {
2233 let bb = crate::measure::solid_bounding_box(topo, solid).ok()?;
2234 Some((bb.min, bb.max))
2235 };
2236 let aabb_a = sample_aabb(topo, a);
2237 let aabb_b = sample_aabb(topo, b);
2238 // AABB-encloses check (lenient): does `inner` fit inside `outer`?
2239 let aabb_encloses =
2240 |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2241 let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2242 return false;
2243 };
2244 let margin = tol.linear;
2245 i_min.x() >= o_min.x() - margin
2246 && i_min.y() >= o_min.y() - margin
2247 && i_min.z() >= o_min.z() - margin
2248 && i_max.x() <= o_max.x() + margin
2249 && i_max.y() <= o_max.y() + margin
2250 && i_max.z() <= o_max.z() + margin
2251 };
2252 // AABB-strictly-contains (strict): outer must also be ≥10% larger in
2253 // ALL 3 dims. Used as the no-classifier fallback to detect true
2254 // nested containment (e.g., a ring fully inside a shell's cavity)
2255 // without false-positives on sparse multi-shell solids (e.g., a
2256 // fuse of disjoint boxes whose AABB technically encloses another
2257 // solid's AABB while mostly being empty space).
2258 let aabb_strictly_contains =
2259 |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2260 if !aabb_encloses(inner, outer) {
2261 return false;
2262 }
2263 let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2264 return false;
2265 };
2266 let dims = [
2267 (o_max.x() - o_min.x(), i_max.x() - i_min.x()),
2268 (o_max.y() - o_min.y(), i_max.y() - i_min.y()),
2269 (o_max.z() - o_min.z(), i_max.z() - i_min.z()),
2270 ];
2271 dims.iter()
2272 .all(|(outer_d, inner_d)| *outer_d > *inner_d * 1.1)
2273 };
2274
2275 // AABB enclosure is necessary but NOT sufficient for solid
2276 // containment: a non-convex container (notched or hollow) can
2277 // AABB-enclose a solid that actually lies in its empty region.
2278 // Issue #801: `(a − b) ∪ (a ∩ b)` dropped the `a ∩ b` operand
2279 // because the unit cube's bbox fits inside the notched `a − b`'s
2280 // bbox, yet the cube lives in the carved-out notch. Confirm the
2281 // AABB-only fallback with a real point-in-solid test: reject when
2282 // the inner solid's center is provably inside `inner` yet outside
2283 // `outer`. By the containment lemma (inner ⊆ outer ⇒ every point
2284 // of inner is in outer), that witness can only occur for genuine
2285 // non-containment, so it never rejects a true containment.
2286 let center_outside =
2287 |topo: &Topology, inner: SolidId, outer: SolidId, bb: &Option<(Point3, Point3)>| -> bool {
2288 let Some((lo, hi)) = *bb else { return false };
2289 let c = Point3::new(
2290 0.5 * (lo.x() + hi.x()),
2291 0.5 * (lo.y() + hi.y()),
2292 0.5 * (lo.z() + hi.z()),
2293 );
2294 let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2295 let defl = (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6);
2296 // Conservative by design: when the AABB center falls in `inner`'s own
2297 // concavity (a C/U-shaped solid), `inside_inner` is false and the
2298 // witness is disabled, so a false-positive containment could still
2299 // slip through. That only ever fails to *reject* — it never rejects a
2300 // true containment — so the shortcut stays sound, just not complete.
2301 let inside_inner = matches!(
2302 crate::classify::classify_point(topo, inner, c, defl, tol.linear),
2303 Ok(crate::classify::PointClassification::Inside)
2304 );
2305 let outside_outer = matches!(
2306 crate::classify::classify_point(topo, outer, c, defl, tol.linear),
2307 Ok(crate::classify::PointClassification::Outside)
2308 );
2309 inside_inner && outside_outer
2310 };
2311
2312 // Volume witness for the AABB-only fallback: `inner ⊆ outer` implies
2313 // `vol(inner) ≤ vol(outer)`, so a decisively larger inner volume proves
2314 // non-containment. This catches what `center_outside` cannot: the AABB
2315 // expansion for partial cylinder/cone faces is a conservative full-circle
2316 // bound, so a thin angular wedge's box balloons to the whole cylinder
2317 // footprint and can "strictly contain" a much bigger solid's box, while
2318 // the bigger solid's own AABB center sits in its annular hole and
2319 // disables the center witness (gh #1499, the kumiko corner cutter).
2320 // Volumes are immune to that inflation. The 1.05 factor absorbs
2321 // deflection under-counting on curved faces so a true containment is
2322 // never rejected; errors fall through to "no refutation" (shortcut
2323 // soundness is then up to the remaining witnesses, as before).
2324 let volume_refutes = |topo: &Topology, inner: SolidId, outer: SolidId| -> bool {
2325 let defl = |bb: &Option<(Point3, Point3)>| {
2326 let Some((lo, hi)) = *bb else { return 1e-3 };
2327 let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2328 (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6)
2329 };
2330 let d = defl(&aabb_a).min(defl(&aabb_b));
2331 let (Ok(vi), Ok(vo)) = (
2332 crate::measure::solid_volume(topo, inner, d),
2333 crate::measure::solid_volume(topo, outer, d),
2334 ) else {
2335 return false;
2336 };
2337 vi > vo * 1.05
2338 };
2339
2340 // Bidirectional vertex check via the analytic classifier — the
2341 // primary signal for identical/containment classification. A vertex
2342 // classifying as inside-or-on (None within tolerance band counts
2343 // as on) means it sits within the solid's region.
2344 let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2345 let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2346 let aabbs_match = aabb_a
2347 .zip(aabb_b)
2348 .map(|((a_min, a_max), (b_min, b_max))| {
2349 let eps = tol.linear;
2350 (a_min.x() - b_min.x()).abs() < eps
2351 && (a_min.y() - b_min.y()).abs() < eps
2352 && (a_min.z() - b_min.z()).abs() < eps
2353 && (a_max.x() - b_max.x()).abs() < eps
2354 && (a_max.y() - b_max.y()).abs() < eps
2355 && (a_max.z() - b_max.z()).abs() < eps
2356 })
2357 .unwrap_or(false);
2358
2359 // Containment: A contains B when all B vertices are inside-or-on A AND
2360 // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2361 // containing solid has no classifier — the strict check requires ≥10%
2362 // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2363 // fuse of two disjoint boxes) don't false-positive as "contains another
2364 // solid".
2365 // Both the analytic-classifier term and the AABB-only fallback can
2366 // false-positive when the container is non-convex: the analytic
2367 // classifier may mis-report notch points as inside-or-on, and an
2368 // AABB encloses a notch's empty volume. Guard the whole determination
2369 // with the `center_outside` witness — sound for every path because it
2370 // only fires on proven non-containment (see the lemma above).
2371 let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2372 || (ca.is_none()
2373 && aabb_strictly_contains(&aabb_b, &aabb_a)
2374 && !volume_refutes(topo, b, a)))
2375 && !center_outside(topo, b, a, &aabb_b);
2376 let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2377 || (cb.is_none()
2378 && aabb_strictly_contains(&aabb_a, &aabb_b)
2379 && !volume_refutes(topo, a, b)))
2380 && !center_outside(topo, a, b, &aabb_a);
2381
2382 TrivialRelation {
2383 identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2384 a_in_b,
2385 b_in_a,
2386 }
2387}
2388
2389/// Check whether every boundary vertex of `solid` is classified as
2390/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2391/// to distinguish truly-identical solids from co-located but differently
2392/// shaped solids (e.g., a cone and a box that share an AABB).
2393fn all_vertices_inside_or_on(
2394 topo: &Topology,
2395 solid: SolidId,
2396 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2397 tol: brepkit_math::tolerance::Tolerance,
2398) -> bool {
2399 let Ok(s) = topo.solid(solid) else {
2400 return false;
2401 };
2402 let Ok(sh) = topo.shell(s.outer_shell()) else {
2403 return false;
2404 };
2405 for &fid in sh.faces() {
2406 let Ok(f) = topo.face(fid) else { return false };
2407 let Ok(w) = topo.wire(f.outer_wire()) else {
2408 return false;
2409 };
2410 for oe in w.edges() {
2411 let Ok(e) = topo.edge(oe.edge()) else {
2412 return false;
2413 };
2414 for vid in [e.start(), e.end()] {
2415 let Ok(v) = topo.vertex(vid) else {
2416 return false;
2417 };
2418 // The analytic classifier returns `None` for points within
2419 // tol.linear of the boundary — treat as "on" for this check.
2420 if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2421 return false;
2422 }
2423 }
2424 }
2425 }
2426 true
2427}
2428
2429/// True when every outer-shell vertex of `inner` classifies as *strictly*
2430/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2431/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2432/// internal cavity rather than a notch through the boundary.
2433fn solid_strictly_inside(
2434 topo: &Topology,
2435 inner: SolidId,
2436 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2437 tol: brepkit_math::tolerance::Tolerance,
2438) -> bool {
2439 let Ok(s) = topo.solid(inner) else {
2440 return false;
2441 };
2442 let Ok(sh) = topo.shell(s.outer_shell()) else {
2443 return false;
2444 };
2445 let mut saw_vertex = false;
2446 for &fid in sh.faces() {
2447 let Ok(f) = topo.face(fid) else { return false };
2448 // Check the outer wire and any inner (hole) wires — a hole boundary on
2449 // a simple solid's face can also reach the blank's surface.
2450 let mut wires = vec![f.outer_wire()];
2451 wires.extend_from_slice(f.inner_wires());
2452 for wid in wires {
2453 let Ok(w) = topo.wire(wid) else {
2454 return false;
2455 };
2456 for oe in w.edges() {
2457 let Ok(e) = topo.edge(oe.edge()) else {
2458 return false;
2459 };
2460 for vid in [e.start(), e.end()] {
2461 let Ok(v) = topo.vertex(vid) else {
2462 return false;
2463 };
2464 if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2465 {
2466 return false;
2467 }
2468 saw_vertex = true;
2469 }
2470 }
2471 }
2472 }
2473 saw_vertex
2474}
2475
2476/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2477/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2478/// blank and the tool, reverses every copied tool face in place so the cavity
2479/// boundary faces into the void, and attaches the reversed tool shell to the
2480/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2481/// drops fully-contained cone/torus tools.
2482fn build_contained_cut_hollow(
2483 topo: &mut Topology,
2484 blank: SolidId,
2485 tool: SolidId,
2486) -> Result<SolidId, crate::OperationsError> {
2487 let result = crate::copy::copy_solid(topo, blank)?;
2488
2489 // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2490 // vertices between adjacent faces (a per-face copy would duplicate shared
2491 // boundary edges and leave the cavity non-manifold — wrong Euler, though
2492 // per-face volume is unaffected). Reverse each copied face in place and
2493 // reuse the copied outer shell directly as the cavity inner shell, so no
2494 // duplicate faces or extra result solid are created.
2495 let tool_copy = crate::copy::copy_solid(topo, tool)?;
2496 let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2497 let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2498 for fid in cavity_faces {
2499 let face = topo.face_mut(fid)?;
2500 let flipped = !face.is_reversed();
2501 face.set_reversed(flipped);
2502 }
2503 topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2504 Ok(result)
2505}
2506
2507/// Best-effort mesh boolean fallback for high face-count solids.
2508///
2509/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2510/// and applies the same post-processing as the other boolean paths.
2511/// Returns `Err` on any failure so the caller can fall through to the
2512/// chord-based path.
2513fn mesh_boolean_fallback(
2514 topo: &mut Topology,
2515 op: BooleanOp,
2516 a: SolidId,
2517 b: SolidId,
2518 deflection: f64,
2519 tol: brepkit_math::tolerance::Tolerance,
2520 opts: &BooleanOptions,
2521) -> Result<SolidId, crate::OperationsError> {
2522 // Mesh density here is a boolean-robustness concern, independent of the
2523 // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2524 // the face count is unaffected by the display deflection cap, AND keep the
2525 // circle curvature floor so co-refinement gets the denser circular sampling
2526 // it needs (display tessellation drops that floor for triangle count).
2527 let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2528 let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2529 log::debug!(
2530 "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2531 mesh_a.indices.len() / 3,
2532 mesh_b.indices.len() / 3,
2533 );
2534
2535 let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2536 if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2537 log::warn!(
2538 "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2539 ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2540 downstream healing may not recover; exported geometry may be broken",
2541 mb_result.boundary_edge_count,
2542 mb_result.non_manifold_edge_count,
2543 );
2544 }
2545 let face_specs = mesh_result_to_face_specs(&mb_result);
2546 if face_specs.is_empty() {
2547 return Err(crate::OperationsError::EmptyResult {
2548 reason: "mesh boolean produced no output faces".into(),
2549 });
2550 }
2551 log::debug!(
2552 "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2553 face_specs.len()
2554 );
2555 let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2556 let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2557 if opts.unify_faces {
2558 let _ = crate::heal::unify_faces(topo, result)?;
2559 }
2560 // Cross-face symmetrization: tessellation diagonals that one face
2561 // dropped while its neighbour kept (#696) leave structurally
2562 // orphan collinear interior wire vertices. Collapse those so both
2563 // sides reference the same EdgeId for the shared 3D segment,
2564 // eliminating the residual non-manifold edges that `unify_faces`
2565 // can't symmetrize from per-face surface matching alone.
2566 let collapsed =
2567 brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2568 topo, result, tol,
2569 )
2570 .unwrap_or_else(|e| {
2571 log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2572 0
2573 });
2574 if collapsed > 0 {
2575 log::info!(
2576 "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2577 );
2578 }
2579 // Mesh-fallback can glue two physically-separate holes into a
2580 // single figure-8 inner wire via diagonal "bridge" edges across
2581 // gap material (#696 cumulative pattern: a slab top with multiple
2582 // pocket cuts ends up with one self-intersecting inner wire that
2583 // visits each pocket region). Split such wires at every pinch
2584 // vertex so each physical hole is its own simple inner wire —
2585 // the resulting topology is well-formed for downstream
2586 // tessellation, validation, and STEP export, even when the
2587 // bridge edges themselves remain as boundary edges (those are a
2588 // separate cleanup).
2589 let wires_split =
2590 brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2591 topo, result,
2592 )
2593 .unwrap_or_else(|e| {
2594 log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2595 0
2596 });
2597 if wires_split > 0 {
2598 log::info!(
2599 "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2600 );
2601 }
2602 if opts.heal_after_boolean {
2603 let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2604 }
2605 assembly::validate_boolean_result_lenient(topo, result)?;
2606 log::info!(
2607 "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2608 result.index(),
2609 face_specs.len()
2610 );
2611 Ok(result)
2612}
2613
2614/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2615fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2616 let mut specs = Vec::new();
2617 for tri in result.mesh.indices.chunks_exact(3) {
2618 let v0 = result.mesh.positions[tri[0] as usize];
2619 let v1 = result.mesh.positions[tri[1] as usize];
2620 let v2 = result.mesh.positions[tri[2] as usize];
2621
2622 let edge1 = v1 - v0;
2623 let edge2 = v2 - v0;
2624 let Ok(normal) = edge1.cross(edge2).normalize() else {
2625 continue;
2626 };
2627 let d = crate::dot_normal_point(normal, v0);
2628 specs.push(FaceSpec::Planar {
2629 vertices: vec![v0, v1, v2],
2630 normal,
2631 d,
2632 inner_wires: vec![],
2633 });
2634 }
2635 specs
2636}
2637
2638/// True when the outer-shell face components represent disjoint solid
2639/// pieces (e.g., a previous cut split one solid into N parts), false
2640/// when one component is concentric inside another (a hollow solid:
2641/// outer surface + cavity surface both live in the outer shell).
2642///
2643/// The check is AABB-based: if any component's bounding box is
2644/// strictly contained in another's, treat the whole solid as hollow
2645/// and skip the multi-region split path.
2646/// Check that every component's AABB centre classifies as outside the
2647/// supplied classifier. Used to reject multi-region GFA Cut results that
2648/// erroneously include the tool's interior as one of the pieces.
2649fn all_component_centers_outside(
2650 topo: &Topology,
2651 components: &[Vec<FaceId>],
2652 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2653 tol: brepkit_math::tolerance::Tolerance,
2654) -> bool {
2655 use brepkit_algo::FaceClass;
2656 for comp in components {
2657 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2658 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2659 for &fid in comp {
2660 let Ok(face) = topo.face(fid) else { continue };
2661 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2662 {
2663 let Ok(wire) = topo.wire(wid) else { continue };
2664 for oe in wire.edges() {
2665 let Ok(edge) = topo.edge(oe.edge()) else {
2666 continue;
2667 };
2668 for vid in [edge.start(), edge.end()] {
2669 if let Ok(v) = topo.vertex(vid) {
2670 let p = v.point();
2671 min = Point3::new(
2672 min.x().min(p.x()),
2673 min.y().min(p.y()),
2674 min.z().min(p.z()),
2675 );
2676 max = Point3::new(
2677 max.x().max(p.x()),
2678 max.y().max(p.y()),
2679 max.z().max(p.z()),
2680 );
2681 }
2682 }
2683 }
2684 }
2685 }
2686 let centre = Point3::new(
2687 (min.x() + max.x()) * 0.5,
2688 (min.y() + max.y()) * 0.5,
2689 (min.z() + max.z()) * 0.5,
2690 );
2691 if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2692 return false;
2693 }
2694 }
2695 true
2696}
2697
2698/// Centre of a face component's vertex AABB, or `None` for an empty component.
2699fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2700 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2701 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2702 for &fid in comp {
2703 let Ok(face) = topo.face(fid) else { continue };
2704 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2705 let Ok(wire) = topo.wire(wid) else { continue };
2706 for oe in wire.edges() {
2707 let Ok(edge) = topo.edge(oe.edge()) else {
2708 continue;
2709 };
2710 for vid in [edge.start(), edge.end()] {
2711 if let Ok(v) = topo.vertex(vid) {
2712 let p = v.point();
2713 min =
2714 Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2715 max =
2716 Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2717 }
2718 }
2719 }
2720 }
2721 }
2722 if min.x() > max.x() {
2723 return None;
2724 }
2725 Some(Point3::new(
2726 (min.x() + max.x()) * 0.5,
2727 (min.y() + max.y()) * 0.5,
2728 (min.z() + max.z()) * 0.5,
2729 ))
2730}
2731
2732/// Does the closed surface made of `faces` enclose `p`?
2733///
2734/// Ray-parity against the component's own tessellation. Read-only by design:
2735/// building a temporary solid per component would add entities to an arena that
2736/// never reclaims, which is the growth cliff fixed in #1237.
2737///
2738/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2739/// so parity is meaningful across face boundaries. The direction is deliberately
2740/// irrational so the ray does not graze a face boundary or lie in a face plane —
2741/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2742/// intersections these pieces are full of. Returns `None` when the component
2743/// cannot be tessellated, so callers can fall back rather than guess.
2744fn component_encloses_point(
2745 topo: &Topology,
2746 faces: &[FaceId],
2747 p: Point3,
2748 deflection: f64,
2749) -> Option<bool> {
2750 // A sqrt-prime direction: irrational in every component, so the ray cannot
2751 // lie in a face plane or run along an edge — the same generic-direction
2752 // escape the ray-cast classifier uses for degenerate probes.
2753 let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2754 .normalize()
2755 .ok()?;
2756 let mut crossings = 0usize;
2757 let mut any_triangle = false;
2758 for &fid in faces {
2759 let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2760 let pos = &mesh.mesh.positions;
2761 for tri in mesh.mesh.indices.chunks_exact(3) {
2762 let (a, b, c) = (
2763 pos[tri[0] as usize],
2764 pos[tri[1] as usize],
2765 pos[tri[2] as usize],
2766 );
2767 any_triangle = true;
2768 if let Some(hit) =
2769 brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2770 && hit.t > 1e-9
2771 {
2772 crossings += 1;
2773 }
2774 }
2775 }
2776 any_triangle.then_some(crossings % 2 == 1)
2777}
2778
2779/// Any vertex position on `faces`, for use as a probe point.
2780fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2781 for &fid in faces {
2782 let face = topo.face(fid).ok()?;
2783 let wire = topo.wire(face.outer_wire()).ok()?;
2784 if let Some(oe) = wire.edges().first()
2785 && let Ok(edge) = topo.edge(oe.edge())
2786 && let Ok(v) = topo.vertex(edge.start())
2787 {
2788 return Some(v.point());
2789 }
2790 }
2791 None
2792}
2793
2794fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2795 let aabbs: Vec<(Point3, Point3)> = components
2796 .iter()
2797 .map(|comp| {
2798 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2799 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2800 for &fid in comp {
2801 let Ok(face) = topo.face(fid) else {
2802 continue;
2803 };
2804 for wid in
2805 std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2806 {
2807 let Ok(wire) = topo.wire(wid) else {
2808 continue;
2809 };
2810 for oe in wire.edges() {
2811 let Ok(edge) = topo.edge(oe.edge()) else {
2812 continue;
2813 };
2814 for vid in [edge.start(), edge.end()] {
2815 if let Ok(v) = topo.vertex(vid) {
2816 let p = v.point();
2817 min = Point3::new(
2818 min.x().min(p.x()),
2819 min.y().min(p.y()),
2820 min.z().min(p.z()),
2821 );
2822 max = Point3::new(
2823 max.x().max(p.x()),
2824 max.y().max(p.y()),
2825 max.z().max(p.z()),
2826 );
2827 }
2828 }
2829 }
2830 }
2831 }
2832 (min, max)
2833 })
2834 .collect();
2835
2836 // Reject NESTING, not mere AABB overlap.
2837 //
2838 // Nesting is the hazard worth rejecting (a blob sitting inside another
2839 // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2840 // what multi-region acceptance is for.
2841 //
2842 // Assume nothing about the components handed in. The acceptance gate calls
2843 // this on a GFA result that has cleared only `euler_balanced` — which is
2844 // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2845 // in the same `&&` chain, not a precondition. The input-splitting paths
2846 // call it on components of a raw operand no gate has examined at all. So
2847 // "every piece is a closed manifold, hence disjoint-or-nested" is not
2848 // available here; the ray-parity confirmation below earns the answer
2849 // instead of inferring it.
2850 //
2851 // Overlap is the wrong predicate for that, because an AABB is only tight on
2852 // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2853 // the whole diagonal envelope, so their boxes interpenetrate and an
2854 // overlap test calls them touching — which is why a kumiko lattice cut,
2855 // whose members are diagonal, could never be accepted and fell back to the
2856 // mesh path on every band (see
2857 // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2858 // is tight in the direction that matters: nesting implies it, and rotation
2859 // does not manufacture it.
2860 let eps = 1e-7;
2861 let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2862 o_min.x() - eps <= i_min.x()
2863 && o_min.y() - eps <= i_min.y()
2864 && o_min.z() - eps <= i_min.z()
2865 && o_max.x() + eps >= i_max.x()
2866 && o_max.y() + eps >= i_max.y()
2867 && o_max.z() + eps >= i_max.z()
2868 };
2869 // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2870 // far from sufficient: a ring's box contains the box of a separate piece
2871 // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2872 // gets a real ray-parity test against the enclosing candidate's own surface,
2873 // and only genuine enclosure rejects. If the probe cannot be evaluated the
2874 // pair falls back to the conservative answer.
2875 for i in 0..aabbs.len() {
2876 for j in (i + 1)..aabbs.len() {
2877 let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2878 (i, j)
2879 } else if contains(aabbs[j], aabbs[i]) {
2880 (j, i)
2881 } else {
2882 continue;
2883 };
2884 let (o_min, o_max) = aabbs[outer];
2885 let diag = ((o_max.x() - o_min.x()).powi(2)
2886 + (o_max.y() - o_min.y()).powi(2)
2887 + (o_max.z() - o_min.z()).powi(2))
2888 .sqrt();
2889 let deflection = (diag / 200.0).max(1e-4);
2890 let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2891 return false;
2892 };
2893 match component_encloses_point(topo, &components[outer], probe, deflection) {
2894 Some(true) => return false,
2895 Some(false) => {}
2896 None => return false,
2897 }
2898 }
2899 }
2900 true
2901}
2902
2903/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2904/// target one at a time.
2905///
2906/// Each piece is copied into a fresh connected solid (the pavefiller
2907/// stumbles on shared vertex IDs across what it considers one "solid B")
2908/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2909/// analytic path, gates, and fallbacks. Fuse distributes over a
2910/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2911/// piece is single-component, so the recursive call never re-enters this
2912/// path.
2913fn fuse_multi_component_tool(
2914 topo: &mut Topology,
2915 a: SolidId,
2916 b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2917) -> Result<SolidId, crate::OperationsError> {
2918 let mut result = a;
2919 for comp_faces in b_components {
2920 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2921 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2922 result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2923 }
2924 Ok(result)
2925}
2926
2927/// Cut a multi-region input solid: split the components, cut each
2928/// against `b` independently, then combine the per-component results
2929/// back into a single multi-region solid.
2930///
2931/// This works around the GFA pavefiller's assumption of a single
2932/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2933/// at a time as the cut proceeds (Category B `multiple cuts creating
2934/// three pieces` and gear bore are both downstream of this).
2935fn cut_multi_region_input(
2936 topo: &mut Topology,
2937 a: SolidId,
2938 b: SolidId,
2939 comp_count: usize,
2940) -> Result<SolidId, crate::OperationsError> {
2941 let components = crate::boolean::assembly::face_components(topo, a);
2942 debug_assert_eq!(components.len(), comp_count);
2943
2944 let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2945 for comp_faces in components {
2946 // Copy the component's faces into a fresh single-component solid
2947 // so the boolean engine sees a connected manifold.
2948 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2949 // Deep-copy the component into a fresh solid so its faces/edges/
2950 // vertices have fresh IDs disjoint from the original multi-region
2951 // input — GFA's pavefiller can stumble on shared vertex IDs across
2952 // what it considers a single "solid A".
2953 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2954 match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2955 Ok(r) => per_component_results.push(r),
2956 Err(
2957 crate::OperationsError::EmptyResult { .. }
2958 | crate::OperationsError::InvalidInput { .. },
2959 ) => {
2960 per_component_results.push(comp_solid);
2961 }
2962 Err(e) => return Err(e),
2963 }
2964 }
2965
2966 // Combine all per-component results into a single multi-region solid.
2967 // Collect every face from every result into one outer shell. The
2968 // results are pairwise disjoint by construction (each came from a
2969 // disjoint input component cut by the same tool), so a single shell
2970 // containing all their faces is a valid manifold representation.
2971 let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2972 for &r in &per_component_results {
2973 let r_data = topo.solid(r)?;
2974 for &fid in topo.shell(r_data.outer_shell())?.faces() {
2975 all_faces.push(fid);
2976 }
2977 }
2978 make_solid_from_face_subset(topo, &all_faces)
2979}
2980
2981/// Build a new solid whose outer shell consists exactly of the given
2982/// faces. Faces are referenced as-is (no copying) — the caller is
2983/// expected to pass faces that already form a closed manifold.
2984///
2985/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2986/// created with the surface normal negated, the wires reversed, and
2987/// `reversed=false`. Boolean operations downstream are sensitive to the
2988/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2989/// re-process cleanly even via deep-copy), so handing GFA an
2990/// orientation-normalized solid recovers the fresh-primitive code path.
2991fn make_solid_from_face_subset(
2992 topo: &mut Topology,
2993 faces: &[brepkit_topology::face::FaceId],
2994) -> Result<SolidId, crate::OperationsError> {
2995 use brepkit_topology::face::{Face, FaceSurface};
2996 use brepkit_topology::wire::{OrientedEdge, Wire};
2997
2998 let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2999 for &fid in faces {
3000 let face = topo.face(fid)?;
3001 if !face.is_reversed() {
3002 normalized.push(fid);
3003 continue;
3004 }
3005 // Only Plane has a trivial negate-the-normal flip. Non-planar
3006 // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
3007 // their surface negated cheaply — they hit surface-specific GFA
3008 // paths that don't suffer from the same reversed-flag sensitivity.
3009 // Exhaustive match so a new FaceSurface variant fails to compile
3010 // rather than silently passing through un-normalized.
3011 let flipped_surface = match face.surface() {
3012 FaceSurface::Plane { normal, d } => FaceSurface::Plane {
3013 normal: -*normal,
3014 d: -*d,
3015 },
3016 FaceSurface::Nurbs(_)
3017 | FaceSurface::Cylinder(_)
3018 | FaceSurface::Cone(_)
3019 | FaceSurface::Sphere(_)
3020 | FaceSurface::Torus(_) => {
3021 normalized.push(fid);
3022 continue;
3023 }
3024 };
3025 let outer_wid = face.outer_wire();
3026 let inner_wids: Vec<_> = face.inner_wires().to_vec();
3027 let outer_wire = topo.wire(outer_wid)?;
3028 let outer_reversed: Vec<OrientedEdge> = outer_wire
3029 .edges()
3030 .iter()
3031 .rev()
3032 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
3033 .collect();
3034 let new_outer_wire =
3035 Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
3036 let new_outer_wid = topo.add_wire(new_outer_wire);
3037 let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
3038 for iw in &inner_wids {
3039 let w = topo.wire(*iw)?;
3040 let rev: Vec<OrientedEdge> = w
3041 .edges()
3042 .iter()
3043 .rev()
3044 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
3045 .collect();
3046 let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
3047 new_inner_wids.push(topo.add_wire(new_w));
3048 }
3049 let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
3050 normalized.push(topo.add_face(new_face));
3051 }
3052
3053 let shell = brepkit_topology::shell::Shell::new(normalized)
3054 .map_err(crate::OperationsError::Topology)?;
3055 let shell_id = topo.add_shell(shell);
3056 let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
3057 Ok(topo.add_solid(solid))
3058}
3059
3060/// Count inner wire loops across all faces of a solid (outer + inner shells).
3061fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
3062 let mut count: i64 = 0;
3063 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3064 let face = topo.face(fid)?;
3065 #[allow(clippy::cast_possible_wrap)]
3066 {
3067 count += face.inner_wires().len() as i64;
3068 }
3069 }
3070 Ok(count)
3071}
3072
3073/// Genus-aware Euler balance for `components` closed orientable surfaces with
3074/// holed faces.
3075///
3076/// Euler-Poincare over `C` closed components of total genus `G`:
3077/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
3078/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
3079/// by two per unit of genus (a thin wall pierced by N through-holes has genus
3080/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
3081///
3082/// The `2C` bound matters as much as the parity: a multi-region result is not
3083/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
3084/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
3085/// exactly rejected every lattice result and forced it onto the mesh path.
3086///
3087/// Callers must pair this with a closed-manifold check — the relation only holds
3088/// for closed surfaces.
3089const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
3090 let surplus = euler - inner_wires;
3091 surplus <= components.saturating_mul(2) && surplus % 2 == 0
3092}
3093
3094/// Count edge uses across ALL shells of a solid (outer + inner cavity
3095/// shells). Hollow solids keep cavity faces in inner shells — an
3096/// outer-shell-only walk silently misses their edges, letting open or
3097/// non-manifold cavity shells pass the acceptance gates.
3098fn solid_edge_use_counts(
3099 topo: &Topology,
3100 solid: SolidId,
3101) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3102 let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3103 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3104 let face = topo.face(fid)?;
3105 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3106 let wire = topo.wire(wid)?;
3107 for oe in wire.edges() {
3108 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3109 }
3110 }
3111 }
3112 Ok(counts)
3113}
3114
3115/// Check whether every shell of a solid is a closed manifold: every edge
3116/// is shared by exactly 2 faces within its shell. Returns `false` for open
3117/// shells (boundary edges with count == 1) and non-manifold shells
3118/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3119/// each shell is an independent closed surface, so a single pooled count
3120/// per shell is correct.
3121///
3122/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3123/// which only rejects edges shared by *more* than two faces.
3124fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3125 let s = topo.solid(solid)?;
3126 let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3127 .chain(s.inner_shells().iter().copied())
3128 .collect();
3129 for shell_id in shell_ids {
3130 let shell = topo.shell(shell_id)?;
3131 if !shell_is_closed_manifold(topo, shell)? {
3132 return Ok(false);
3133 }
3134 }
3135 Ok(true)
3136}
3137
3138fn shell_is_closed_manifold(
3139 topo: &Topology,
3140 shell: &brepkit_topology::shell::Shell,
3141) -> Result<bool, crate::OperationsError> {
3142 use std::collections::HashMap;
3143
3144 let mut counts: HashMap<usize, usize> = HashMap::new();
3145 for &fid in shell.faces() {
3146 let face = topo.face(fid)?;
3147 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3148 let wire = topo.wire(wid)?;
3149 for oe in wire.edges() {
3150 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3151 }
3152 }
3153 }
3154 if counts.is_empty() {
3155 return Ok(false);
3156 }
3157 Ok(counts.values().all(|&c| c == 2))
3158}
3159
3160/// Check whether a solid's boundary has free edges: edges used by only
3161/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3162/// membrane face left a circle edge unmatched), which is never a valid
3163/// boolean result even when Euler accidentally balances.
3164fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3165 let counts = solid_edge_use_counts(topo, solid)?;
3166 Ok(counts.values().any(|&c| c == 1))
3167}
3168
3169/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3170/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3171/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3172/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3173/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3174///
3175/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3176/// so the gate and the pass agree: a looser default here could report "nothing
3177/// to flatten" while the pass (run at the operation tolerance) would in fact
3178/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3179fn solid_has_flattenable_nurbs(
3180 topo: &Topology,
3181 solid: SolidId,
3182 tol: f64,
3183) -> Result<bool, crate::OperationsError> {
3184 use brepkit_geometry::convert::{
3185 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3186 };
3187 use brepkit_topology::edge::EdgeCurve;
3188 use brepkit_topology::explorer::solid_faces;
3189
3190 let mut seen = std::collections::HashSet::new();
3191 for fid in solid_faces(topo, solid)? {
3192 let face = topo.face(fid)?;
3193 if let FaceSurface::Nurbs(nurbs) = face.surface()
3194 && matches!(
3195 recognize_surface(nurbs, tol),
3196 RecognizedSurface::Plane { .. }
3197 )
3198 {
3199 return Ok(true);
3200 }
3201 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3202 let wire = topo.wire(wid)?;
3203 for oe in wire.edges() {
3204 let eid = oe.edge();
3205 if !seen.insert(eid.index()) {
3206 continue;
3207 }
3208 if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3209 && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3210 {
3211 return Ok(true);
3212 }
3213 }
3214 }
3215 }
3216 Ok(false)
3217}
3218
3219/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3220/// straight NURBS boundary edges with the `Line` variant.
3221///
3222/// A NURBS surface whose every control point lies within `tol` of a single
3223/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3224/// straight cavity walls as planar B-splines, and the boolean engine's
3225/// face-face intersections only take the exact (same-domain) plane×plane path
3226/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3227/// planes before the boolean lets coincident/abutting wall regions merge
3228/// analytically instead of fragmenting through the NURBS surface-intersection
3229/// path.
3230///
3231/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3232/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3233/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3234/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3235/// face to a self-crossing trace. Recognising those straight NURBS edges as
3236/// `Line` lets the arrangement split them exactly.
3237///
3238/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3239/// left untouched. Returns the number of faces flattened.
3240fn flatten_planar_nurbs_faces(
3241 topo: &mut Topology,
3242 solid: SolidId,
3243 tol: f64,
3244) -> Result<usize, crate::OperationsError> {
3245 use brepkit_geometry::convert::{
3246 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3247 };
3248 use brepkit_topology::edge::{EdgeCurve, EdgeId};
3249 use brepkit_topology::explorer::solid_faces;
3250
3251 let face_ids = solid_faces(topo, solid)?;
3252 // Snapshot the surfaces first (immutable borrow), then mutate.
3253 let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3254 .iter()
3255 .filter_map(|&fid| {
3256 let face = topo.face(fid).ok()?;
3257 let FaceSurface::Nurbs(nurbs) = face.surface() else {
3258 return None;
3259 };
3260 match recognize_surface(nurbs, tol) {
3261 RecognizedSurface::Plane { normal, d } => {
3262 // `recognize_surface` derives the plane normal from a
3263 // control-point cross product, whose sign can OPPOSE the
3264 // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3265 // is read with its normal flipped by `is_reversed`, so an
3266 // opposed sign silently inverts the face's effective
3267 // outward direction. Align to the surface du×dv normal at
3268 // the domain midpoint.
3269 let (u0, u1) = nurbs.domain_u();
3270 let (v0, v1) = nurbs.domain_v();
3271 let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3272 let (normal, d) = match mid_n {
3273 Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3274 _ => (normal, d),
3275 };
3276 Some((fid, normal, d))
3277 }
3278 _ => None,
3279 }
3280 })
3281 .collect();
3282 let count = planar.len();
3283 for (fid, normal, d) in planar {
3284 topo.face_mut(fid)?
3285 .set_surface(FaceSurface::Plane { normal, d });
3286 }
3287
3288 // Straighten NURBS edges that are geometrically lines.
3289 let mut straight_edges: Vec<EdgeId> = Vec::new();
3290 let mut seen = std::collections::HashSet::new();
3291 for &fid in &face_ids {
3292 let face = topo.face(fid)?;
3293 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3294 let wire = topo.wire(wid)?;
3295 for oe in wire.edges() {
3296 let eid = oe.edge();
3297 if !seen.insert(eid.index()) {
3298 continue;
3299 }
3300 let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3301 continue;
3302 };
3303 if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3304 straight_edges.push(eid);
3305 }
3306 }
3307 }
3308 }
3309 for eid in straight_edges {
3310 topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3311 }
3312
3313 Ok(count)
3314}
3315
3316/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3317/// reproduce the exact operand preprocessing the boolean applies before handing
3318/// the operands to the GFA engine.
3319#[doc(hidden)]
3320pub fn flatten_planar_nurbs_faces_for_tests(
3321 topo: &mut Topology,
3322 solid: SolidId,
3323 tol: f64,
3324) -> Result<usize, crate::OperationsError> {
3325 flatten_planar_nurbs_faces(topo, solid, tol)
3326}
3327
3328/// For each vertex position (quantized at tolerance), picks one canonical
3329/// vertex. Rebuilds all edges and wires to use canonical vertices.
3330/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3331/// input solids that may share edge topology.
3332#[allow(clippy::items_after_statements, clippy::type_complexity)]
3333fn merge_result_vertices(
3334 topo: &mut Topology,
3335 solid: SolidId,
3336 tol: brepkit_math::tolerance::Tolerance,
3337) -> Result<(), crate::OperationsError> {
3338 use std::collections::{BTreeMap, HashMap};
3339
3340 let shell_id = topo.solid(solid)?.outer_shell();
3341 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3342
3343 let scale = 1.0 / tol.linear;
3344 let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3345 (
3346 (p.x() * scale).round() as i64,
3347 (p.y() * scale).round() as i64,
3348 (p.z() * scale).round() as i64,
3349 )
3350 };
3351
3352 // Build vertex canonical map: position → first VertexId seen
3353 let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3354 BTreeMap::new();
3355 let mut replacements: HashMap<
3356 brepkit_topology::vertex::VertexId,
3357 brepkit_topology::vertex::VertexId,
3358 > = HashMap::new();
3359
3360 for &fid in &face_ids {
3361 let face = topo.face(fid)?;
3362 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3363 let wire = topo.wire(wid)?;
3364 for oe in wire.edges() {
3365 let edge = topo.edge(oe.edge())?;
3366 for vid in [edge.start(), edge.end()] {
3367 let pos = topo.vertex(vid)?.point();
3368 let key = quantize(pos);
3369 let canon = *canonical.entry(key).or_insert(vid);
3370 if canon != vid {
3371 replacements.insert(vid, canon);
3372 }
3373 }
3374 }
3375 }
3376 }
3377
3378 if replacements.is_empty() {
3379 return Ok(());
3380 }
3381
3382 // Rebuild faces with merged vertices
3383 // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3384 let mut edge_cache: HashMap<
3385 (
3386 brepkit_topology::edge::EdgeId,
3387 brepkit_topology::vertex::VertexId,
3388 brepkit_topology::vertex::VertexId,
3389 ),
3390 brepkit_topology::edge::EdgeId,
3391 > = HashMap::new();
3392
3393 // Snapshot face data, then rebuild with merged vertices
3394 struct FaceSnap {
3395 surface: brepkit_topology::face::FaceSurface,
3396 reversed: bool,
3397 outer_oes: Vec<(
3398 brepkit_topology::edge::EdgeId,
3399 bool,
3400 brepkit_topology::edge::EdgeCurve,
3401 brepkit_topology::vertex::VertexId,
3402 brepkit_topology::vertex::VertexId,
3403 Option<f64>, // edge tolerance
3404 )>,
3405 outer_closed: bool,
3406 inner_wires: Vec<(
3407 Vec<(
3408 brepkit_topology::edge::EdgeId,
3409 bool,
3410 brepkit_topology::edge::EdgeCurve,
3411 brepkit_topology::vertex::VertexId,
3412 brepkit_topology::vertex::VertexId,
3413 Option<f64>,
3414 )>,
3415 bool, // wire closed flag
3416 )>,
3417 }
3418
3419 let mut snaps = Vec::with_capacity(face_ids.len());
3420 for &fid in &face_ids {
3421 let face = topo.face(fid)?;
3422 let surface = face.surface().clone();
3423 let reversed = face.is_reversed();
3424 let outer_wire = topo.wire(face.outer_wire())?;
3425 let outer_closed = outer_wire.is_closed();
3426 let outer_oes: Vec<_> = outer_wire
3427 .edges()
3428 .iter()
3429 .map(|oe| -> Result<_, crate::OperationsError> {
3430 let e = topo.edge(oe.edge())?;
3431 Ok((
3432 oe.edge(),
3433 oe.is_forward(),
3434 e.curve().clone(),
3435 e.start(),
3436 e.end(),
3437 e.tolerance(),
3438 ))
3439 })
3440 .collect::<Result<_, _>>()?;
3441 let inner_wids = face.inner_wires().to_vec();
3442 let mut inner_wires = Vec::new();
3443 for iw in inner_wids {
3444 let w = topo.wire(iw)?;
3445 let closed = w.is_closed();
3446 let oes: Vec<_> = w
3447 .edges()
3448 .iter()
3449 .map(|oe| -> Result<_, crate::OperationsError> {
3450 let e = topo.edge(oe.edge())?;
3451 Ok((
3452 oe.edge(),
3453 oe.is_forward(),
3454 e.curve().clone(),
3455 e.start(),
3456 e.end(),
3457 e.tolerance(),
3458 ))
3459 })
3460 .collect::<Result<_, _>>()?;
3461 inner_wires.push((oes, closed));
3462 }
3463 snaps.push(FaceSnap {
3464 surface,
3465 reversed,
3466 outer_oes,
3467 outer_closed,
3468 inner_wires,
3469 });
3470 }
3471
3472 #[allow(clippy::type_complexity)]
3473 let remap_oes = |oes: &[(
3474 brepkit_topology::edge::EdgeId,
3475 bool,
3476 brepkit_topology::edge::EdgeCurve,
3477 brepkit_topology::vertex::VertexId,
3478 brepkit_topology::vertex::VertexId,
3479 Option<f64>,
3480 )],
3481 replacements: &HashMap<
3482 brepkit_topology::vertex::VertexId,
3483 brepkit_topology::vertex::VertexId,
3484 >,
3485 edge_cache: &mut HashMap<
3486 (
3487 brepkit_topology::edge::EdgeId,
3488 brepkit_topology::vertex::VertexId,
3489 brepkit_topology::vertex::VertexId,
3490 ),
3491 brepkit_topology::edge::EdgeId,
3492 >,
3493 topo: &mut Topology|
3494 -> Vec<brepkit_topology::wire::OrientedEdge> {
3495 oes.iter()
3496 .map(|(eid, fwd, curve, start, end, edge_tol)| {
3497 let ns = replacements.get(start).copied().unwrap_or(*start);
3498 let ne = replacements.get(end).copied().unwrap_or(*end);
3499 if ns == *start && ne == *end {
3500 return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3501 }
3502 let key = (*eid, ns, ne);
3503 let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3504 topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3505 ns,
3506 ne,
3507 curve.clone(),
3508 *edge_tol,
3509 ))
3510 });
3511 brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3512 })
3513 .collect()
3514 };
3515
3516 let mut new_face_ids = Vec::with_capacity(snaps.len());
3517 for snap in &snaps {
3518 let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3519 let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3520 // Wire rebuild failed — keep the original face unchanged
3521 // rather than silently dropping it
3522 continue;
3523 };
3524 let outer_id = topo.add_wire(outer_wire);
3525
3526 let mut inner_ids = Vec::new();
3527 for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3528 let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3529 if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3530 inner_ids.push(topo.add_wire(w));
3531 }
3532 }
3533
3534 let mut new_face =
3535 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3536 if snap.reversed {
3537 new_face.set_reversed(true);
3538 }
3539 new_face_ids.push(topo.add_face(new_face));
3540 }
3541
3542 // Replace the shell's faces
3543 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3544 let new_shell_id = topo.add_shell(new_shell);
3545 let solid_mut = topo.solid_mut(solid)?;
3546 solid_mut.set_outer_shell(new_shell_id);
3547
3548 Ok(())
3549}
3550
3551/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3552///
3553/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3554/// a cap face) annihilates the shared cap but leaves each argument's faces
3555/// carrying their OWN copy of the junction-wire edges. Because the two copies
3556/// come from independently-built solids their endpoints differ by sub-micron
3557/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3558/// merge above leaves them as distinct edges — each used once → free edges that
3559/// open the shell.
3560///
3561/// This snaps vertices at `tol_merge` (looser than the default linear
3562/// tolerance, to absorb that noise), then rebuilds every wire against a global
3563/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3564/// geometric midpoint* — so a straight line and a bulged arc between the same
3565/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3566/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3567///
3568/// Returns `true` if anything changed. Run only on already-broken results
3569/// (free edges / non-manifold) so clean booleans keep their exact topology.
3570#[allow(
3571 clippy::too_many_lines,
3572 clippy::type_complexity,
3573 clippy::items_after_statements
3574)]
3575fn unify_coincident_boundary_edges(
3576 topo: &mut Topology,
3577 solid: SolidId,
3578 tol_merge: f64,
3579) -> Result<bool, crate::OperationsError> {
3580 use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3581 use brepkit_topology::vertex::VertexId;
3582 use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3583 use std::collections::HashMap;
3584
3585 let shell_id = topo.solid(solid)?.outer_shell();
3586 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3587
3588 let scale = 1.0 / tol_merge;
3589 let q = |p: Point3| -> (i64, i64, i64) {
3590 (
3591 (p.x() * scale).round() as i64,
3592 (p.y() * scale).round() as i64,
3593 (p.z() * scale).round() as i64,
3594 )
3595 };
3596
3597 // 1. Canonical vertex per quantized position (first VertexId seen wins).
3598 let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3599 for &fid in &face_ids {
3600 let face = topo.face(fid)?;
3601 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3602 let wire = topo.wire(wid)?;
3603 for oe in wire.edges() {
3604 let edge = topo.edge(oe.edge())?;
3605 for vid in [edge.start(), edge.end()] {
3606 let key = q(topo.vertex(vid)?.point());
3607 vcanon.entry(key).or_insert(vid);
3608 }
3609 }
3610 }
3611 }
3612
3613 // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3614 type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3615 struct FaceSnap {
3616 surface: FaceSurface,
3617 reversed: bool,
3618 outer: Vec<OeSnap>,
3619 outer_closed: bool,
3620 inners: Vec<(Vec<OeSnap>, bool)>,
3621 }
3622 let snap_wire =
3623 |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3624 let w = topo.wire(wid)?;
3625 let closed = w.is_closed();
3626 let oes = w
3627 .edges()
3628 .iter()
3629 .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3630 let e = topo.edge(oe.edge())?;
3631 Ok((
3632 oe.edge(),
3633 oe.is_forward(),
3634 e.curve().clone(),
3635 e.start(),
3636 e.end(),
3637 e.tolerance(),
3638 ))
3639 })
3640 .collect::<Result<_, _>>()?;
3641 Ok((oes, closed))
3642 };
3643 let mut snaps = Vec::with_capacity(face_ids.len());
3644 for &fid in &face_ids {
3645 let face = topo.face(fid)?;
3646 let surface = face.surface().clone();
3647 let reversed = face.is_reversed();
3648 let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3649 let mut inners = Vec::new();
3650 for iw in face.inner_wires() {
3651 inners.push(snap_wire(topo, *iw)?);
3652 }
3653 snaps.push(FaceSnap {
3654 surface,
3655 reversed,
3656 outer,
3657 outer_closed,
3658 inners,
3659 });
3660 }
3661
3662 // 3. Rebuild wires against a global canonical-edge map.
3663 // Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3664 type EdgeKey = (
3665 (i64, i64, i64),
3666 (i64, i64, i64),
3667 (i64, i64, i64),
3668 &'static str,
3669 );
3670 let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3671 let mut changed = false;
3672
3673 let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3674 Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3675 };
3676
3677 let rebuild = |topo: &mut Topology,
3678 oes: &[OeSnap],
3679 ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3680 changed: &mut bool|
3681 -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3682 let mut out = Vec::with_capacity(oes.len());
3683 for (eid, fwd, curve, start, end, etol) in oes {
3684 let cs = canon_vid(topo, *start)?;
3685 let ce = canon_vid(topo, *end)?;
3686 if cs == ce {
3687 // Endpoints collapsed to a single vertex → degenerate, drop it.
3688 *changed = true;
3689 continue;
3690 }
3691 let sp = topo.vertex(*start)?.point();
3692 let ep = topo.vertex(*end)?.point();
3693 let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3694 let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3695 let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3696 let (lo, hi) = if cs_q <= ce_q {
3697 (cs_q, ce_q)
3698 } else {
3699 (ce_q, cs_q)
3700 };
3701 let key = (lo, hi, q(mid), curve.type_tag());
3702
3703 // Physical traversal start vertex (after canonicalization).
3704 let trav_start = if *fwd { cs } else { ce };
3705 if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3706 // A duplicate of an already-seen edge → merge onto the keeper.
3707 *changed = true;
3708 out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3709 } else {
3710 // First edge with this key. Reuse the original edge when its
3711 // endpoints didn't move; only allocate (and flag a change) when
3712 // a vertex was snapped — so an already-clean shell is a no-op.
3713 let (eid_use, e_start) = if cs == *start && ce == *end {
3714 (*eid, *start)
3715 } else {
3716 *changed = true;
3717 (
3718 topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3719 cs,
3720 )
3721 };
3722 ecanon.insert(key, (eid_use, e_start, ce));
3723 out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3724 }
3725 }
3726 Ok(out)
3727 };
3728
3729 let mut new_face_ids = Vec::with_capacity(snaps.len());
3730 for snap in &snaps {
3731 let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3732 let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3733 // Keep original face if the rebuilt wire is invalid.
3734 return Ok(false);
3735 };
3736 let outer_id = topo.add_wire(outer_wire);
3737 let mut inner_ids = Vec::new();
3738 for (inner_oes, inner_closed) in &snap.inners {
3739 let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3740 let Ok(w) = Wire::new(oes, *inner_closed) else {
3741 // A dropped hole silently changes topology (and removes free
3742 // edges, so the downstream gate can't catch it). Bail like the
3743 // outer-wire case, leaving the original solid untouched.
3744 return Ok(false);
3745 };
3746 inner_ids.push(topo.add_wire(w));
3747 }
3748 let mut new_face =
3749 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3750 if snap.reversed {
3751 new_face.set_reversed(true);
3752 }
3753 new_face_ids.push(topo.add_face(new_face));
3754 }
3755
3756 if !changed {
3757 return Ok(false);
3758 }
3759
3760 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3761 let new_shell_id = topo.add_shell(new_shell);
3762 topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3763 Ok(true)
3764}
3765
3766/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3767///
3768/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3769/// shell building to split the non-manifold shell into manifold
3770/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3771/// become inner shells (cavities).
3772///
3773/// If the solid is already manifold, returns it unchanged.
3774#[allow(clippy::too_many_lines)]
3775fn enforce_manifold_shell(
3776 topo: &mut Topology,
3777 solid: SolidId,
3778) -> Result<SolidId, crate::OperationsError> {
3779 use std::collections::{HashMap, HashSet, VecDeque};
3780
3781 let shell_id = topo.solid(solid)?.outer_shell();
3782 let face_ids = topo.shell(shell_id)?.faces().to_vec();
3783
3784 // Count edges per face.
3785 let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3786 for &fid in &face_ids {
3787 if let Ok(face) = topo.face(fid) {
3788 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3789 {
3790 if let Ok(wire) = topo.wire(wid) {
3791 for oe in wire.edges() {
3792 *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3793 }
3794 }
3795 }
3796 }
3797 }
3798
3799 // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3800 // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3801 // the shell at those edges breaks downstream operations (section, volume).
3802 let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3803 if nm_count <= 3 {
3804 return Ok(solid);
3805 }
3806
3807 log::debug!(
3808 "enforce_manifold_shell: {} non-manifold edges in {} faces",
3809 nm_count,
3810 face_ids.len()
3811 );
3812
3813 // Build vertex-pair → face adjacency for neighbor discovery.
3814 let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3815 HashMap::new();
3816 for &fid in &face_ids {
3817 if let Ok(face) = topo.face(fid) {
3818 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3819 {
3820 if let Ok(wire) = topo.wire(wid) {
3821 for oe in wire.edges() {
3822 if let Ok(e) = topo.edge(oe.edge()) {
3823 let si = e.start().index();
3824 let ei = e.end().index();
3825 let key = if si <= ei { (si, ei) } else { (ei, si) };
3826 vpair_faces.entry(key).or_default().push(fid);
3827 }
3828 }
3829 }
3830 }
3831 }
3832 }
3833
3834 // Greedy flood-fill shell construction.
3835 let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3836 let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3837 let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3838
3839 for &start_face in &face_ids {
3840 if processed.contains(&start_face) {
3841 continue;
3842 }
3843
3844 let mut shell_faces = vec![start_face];
3845 processed.insert(start_face);
3846
3847 // Track edge-ID usage within this shell.
3848 let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3849 if let Ok(face) = topo.face(start_face) {
3850 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3851 {
3852 if let Ok(wire) = topo.wire(wid) {
3853 for oe in wire.edges() {
3854 *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3855 }
3856 }
3857 }
3858 }
3859
3860 let mut queue = VecDeque::new();
3861 queue.push_back(start_face);
3862
3863 while let Some(current) = queue.pop_front() {
3864 let Ok(face) = topo.face(current) else {
3865 continue;
3866 };
3867 // Collect (vpair, edge_id) from all wires.
3868 let mut all_edges = Vec::new();
3869 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3870 {
3871 if let Ok(wire) = topo.wire(wid) {
3872 for oe in wire.edges() {
3873 if let Ok(e) = topo.edge(oe.edge()) {
3874 let si = e.start().index();
3875 let ei = e.end().index();
3876 let key = if si <= ei { (si, ei) } else { (ei, si) };
3877 all_edges.push((key, oe.edge()));
3878 }
3879 }
3880 }
3881 }
3882
3883 for (vpair, edge_id) in all_edges {
3884 let eidx = edge_id.index();
3885
3886 // Skip edges already manifold in this shell.
3887 if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3888 continue;
3889 }
3890
3891 // Find candidate neighbor faces via vertex-pair.
3892 let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3893 .get(&vpair)
3894 .map(|fs| {
3895 fs.iter()
3896 .copied()
3897 .filter(|&f| {
3898 f != current && available.contains(&f) && !processed.contains(&f)
3899 })
3900 .collect()
3901 })
3902 .unwrap_or_default();
3903
3904 if candidates.is_empty() {
3905 continue;
3906 }
3907
3908 // Pick first candidate (simple heuristic — dihedral selection
3909 // would be better but requires surface normal evaluation).
3910 let selected = candidates[0];
3911
3912 if processed.contains(&selected) {
3913 continue;
3914 }
3915
3916 processed.insert(selected);
3917 shell_faces.push(selected);
3918 queue.push_back(selected);
3919
3920 // Update edge count.
3921 if let Ok(sel_face) = topo.face(selected) {
3922 for wid in std::iter::once(sel_face.outer_wire())
3923 .chain(sel_face.inner_wires().iter().copied())
3924 {
3925 if let Ok(wire) = topo.wire(wid) {
3926 for sel_oe in wire.edges() {
3927 *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3928 }
3929 }
3930 }
3931 }
3932 }
3933 }
3934
3935 shells.push(shell_faces);
3936 }
3937
3938 // Add any unprocessed faces to a final shell.
3939 let remaining: Vec<brepkit_topology::face::FaceId> = available
3940 .iter()
3941 .filter(|f| !processed.contains(f))
3942 .copied()
3943 .collect();
3944 if !remaining.is_empty() {
3945 shells.push(remaining);
3946 }
3947
3948 if shells.len() <= 1 {
3949 // Single shell — nothing to split.
3950 return Ok(solid);
3951 }
3952
3953 log::debug!(
3954 "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3955 shells.len(),
3956 shells.iter().map(Vec::len).collect::<Vec<_>>(),
3957 );
3958
3959 // Build the solid: largest shell is outer, rest are inner.
3960 let mut best_idx = 0;
3961 let mut best_count = 0;
3962 for (i, faces) in shells.iter().enumerate() {
3963 if faces.len() > best_count {
3964 best_count = faces.len();
3965 best_idx = i;
3966 }
3967 }
3968
3969 let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3970 .map_err(crate::OperationsError::Topology)?;
3971 let outer_id = topo.add_shell(outer);
3972 let mut inner_ids = Vec::new();
3973 for (i, faces) in shells.iter().enumerate() {
3974 if i != best_idx
3975 && !faces.is_empty()
3976 && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3977 {
3978 inner_ids.push(topo.add_shell(inner));
3979 }
3980 }
3981
3982 Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3983}
3984
3985/// Sample `n` evenly-spaced points along a closed edge curve.
3986///
3987/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3988/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3989/// duplication. Returns an empty vec for `Line` (no sampling possible).
3990pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3991 match curve {
3992 EdgeCurve::Circle(c) => (0..n)
3993 .map(|i| {
3994 #[allow(clippy::cast_precision_loss)]
3995 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3996 c.evaluate(t)
3997 })
3998 .collect(),
3999 EdgeCurve::Ellipse(e) => (0..n)
4000 .map(|i| {
4001 #[allow(clippy::cast_precision_loss)]
4002 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
4003 e.evaluate(t)
4004 })
4005 .collect(),
4006 EdgeCurve::NurbsCurve(nc) => {
4007 let (u0, u1) = nc.domain();
4008 // For closed curves (start ~ end), use n as divisor to avoid
4009 // duplicating the first point at t=u_max.
4010 let start_pt = nc.evaluate(u0);
4011 let end_pt = nc.evaluate(u1);
4012 // 1e-6 m: closure detection threshold — if start and end points are
4013 // within 1 micron, treat the NURBS curve as closed to avoid
4014 // duplicating the first point at t=u_max.
4015 let is_closed = (start_pt - end_pt).length() < 1e-6;
4016 let divisor = if is_closed { n } else { n - 1 };
4017 (0..n)
4018 .map(|i| {
4019 #[allow(clippy::cast_precision_loss)]
4020 let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
4021 nc.evaluate(t)
4022 })
4023 .collect()
4024 }
4025 EdgeCurve::Line => vec![],
4026 }
4027}
4028
4029/// Get a polygon approximation of a face by sampling curved edges.
4030///
4031/// Samples circle/ellipse edges into 32 points so faces with a
4032/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
4033///
4034/// # Errors
4035///
4036/// Returns an error if the face or its wire cannot be resolved.
4037pub fn face_polygon(
4038 topo: &Topology,
4039 face_id: FaceId,
4040) -> Result<Vec<Point3>, crate::OperationsError> {
4041 let face = topo.face(face_id)?;
4042 let wire = topo.wire(face.outer_wire())?;
4043 let mut pts = Vec::new();
4044
4045 for oe in wire.edges() {
4046 let edge = topo.edge(oe.edge())?;
4047 let curve = edge.curve();
4048 // Sample closed parametric edges (start == end vertex).
4049 // Partial arcs fall through to the vertex-based path.
4050 let start_vid = edge.start();
4051 let end_vid = edge.end();
4052 let is_closed_edge = start_vid == end_vid
4053 && matches!(
4054 curve,
4055 EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
4056 );
4057 if is_closed_edge {
4058 // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
4059 // must match create_band_fragments and inner-wire dedup for sharing.
4060 let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
4061 if !oe.is_forward() {
4062 sampled.reverse();
4063 }
4064 pts.extend(sampled);
4065 } else {
4066 let vid = oe.oriented_start(edge);
4067 pts.push(topo.vertex(vid)?.point());
4068 }
4069 }
4070
4071 Ok(pts)
4072}
4073
4074/// Collect face signatures (index, normal, centroid) for evolution tracking.
4075///
4076/// For each face of the solid, computes a representative normal and centroid
4077/// from the face polygon. Used by [`boolean_with_evolution`] to match output
4078/// faces back to input faces.
4079///
4080/// # Errors
4081///
4082/// Returns an error if any face or wire cannot be resolved.
4083/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
4084/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
4085/// normal is the stored plane normal (or a polygon-derived normal for
4086/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
4087/// stays consistent because input and output faces use the same convention.
4088pub fn collect_face_signatures(
4089 topo: &Topology,
4090 solid_id: SolidId,
4091) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4092 let solid = topo.solid(solid_id)?;
4093 let shell = topo.shell(solid.outer_shell())?;
4094 let mut result = Vec::with_capacity(shell.faces().len());
4095
4096 for &fid in shell.faces() {
4097 let face = topo.face(fid)?;
4098 let verts = face_polygon(topo, fid)?;
4099 let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4100 *normal
4101 } else if verts.len() >= 3 {
4102 let e1 = verts[1] - verts[0];
4103 let e2 = verts[2] - verts[0];
4104 e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4105 } else {
4106 Vec3::new(0.0, 0.0, 1.0)
4107 };
4108
4109 let centroid = classify::polygon_centroid(&verts);
4110 result.push((fid.index(), normal, centroid));
4111 }
4112
4113 Ok(result)
4114}
4115
4116#[cfg(test)]
4117mod tests;