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(clusters) = cluster_tools_by_aabb(topo, tools)
944 && !clusters.is_empty()
945 {
946 // Tools that merely TOUCH (a plate's edge-tangent preview pockets)
947 // cluster together by AABB, and their union is genuinely non-manifold,
948 // so every pairwise fuse below "succeeds" through the mesh fallback —
949 // the batch then proceeds with a degraded all-planar tool and the cut
950 // grinds against it (11 s and lost cones on a 4x4 baseplate, #1488).
951 // A fallback-tainted merge is a FAILED merge for batching purposes:
952 // the sequential per-tool cuts are exact and never see the tangency.
953 // Taint propagates as Err from `fuse_cluster` and from the cross-
954 // cluster merge fuses below, so a discarded merge never touches the
955 // public fallback counter.
956 let merged = clusters.iter().try_fold(None::<SolidId>, |acc, cluster| {
957 let fused = fuse_cluster(topo, cluster)?;
958 match acc {
959 None => Ok(Some(fused)),
960 Some(prev) => {
961 let m = boolean_inner(topo, BooleanOp::Fuse, prev, fused)?;
962 if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
963 return Err(crate::OperationsError::InvalidInput {
964 reason: "cluster merge degraded to mesh fallback".to_string(),
965 });
966 }
967 Ok(Some(m))
968 }
969 }
970 });
971 if let Ok(Some(tool)) = merged
972 && let Ok(cut) = boolean(topo, BooleanOp::Cut, target, tool)
973 {
974 result = cut;
975 batched = true;
976 } else {
977 log::debug!("compound_cut: batched tool path failed, using sequential cuts");
978 }
979 }
980 if !batched {
981 for &tool in tools {
982 result = boolean(topo, BooleanOp::Cut, result, tool)?;
983 }
984 }
985 if opts.unify_faces {
986 let unify_opts = brepkit_heal::upgrade::unify_same_domain::UnifyOptions::default();
987 if let Err(e) =
988 brepkit_heal::upgrade::unify_same_domain::unify_same_domain(topo, result, &unify_opts)
989 {
990 log::debug!("compound_cut unify_faces failed: {e}");
991 }
992 }
993 Ok(result)
994}
995
996/// Fuse one AABB-overlap cluster into a single solid.
997///
998/// For a cluster of 3+ interpenetrating/touching tools, tries the single-pass
999/// N-way GFA fuse (`brepkit_algo::gfa::fuse_n`) — one arrangement over all tools
1000/// instead of the sequential pairwise fuse's O(n²) re-processing of a growing
1001/// accumulator. Falls back to the sequential fuse when the N-way path errors
1002/// (e.g. a non-planar coincident contact it does not yet handle) or yields an
1003/// invalid result. Clusters of 1–2 tools go straight to the sequential path,
1004/// where the N-way arrangement has nothing to save. The cluster must be
1005/// non-empty.
1006pub(crate) fn fuse_cluster(
1007 topo: &mut Topology,
1008 cluster: &[SolidId],
1009) -> Result<SolidId, crate::OperationsError> {
1010 let Some((&first, rest)) = cluster.split_first() else {
1011 return Err(crate::OperationsError::InvalidInput {
1012 reason: "fuse_cluster requires a non-empty cluster".into(),
1013 });
1014 };
1015 if cluster.len() >= 3
1016 && let Ok(fused) = brepkit_algo::gfa::fuse_n(topo, cluster)
1017 && validate_boolean_result(topo, fused).is_ok()
1018 {
1019 return Ok(fused);
1020 }
1021 // A pairwise fuse that degrades to the mesh fallback poisons the whole
1022 // batch; bail at the first one instead of paying the fallback for every
1023 // remaining pair. `boolean_inner` + the taint flag keeps the discarded
1024 // probe out of the public fallback counter entirely.
1025 rest.iter().try_fold(first, |a, &t| {
1026 let fused = boolean_inner(topo, BooleanOp::Fuse, a, t)?;
1027 if LAST_USED_MESH_FALLBACK.with(std::cell::Cell::take) {
1028 return Err(crate::OperationsError::InvalidInput {
1029 reason: "cluster fuse degraded to mesh fallback".to_string(),
1030 });
1031 }
1032 Ok(fused)
1033 })
1034}
1035
1036/// Group tools into AABB-overlap clusters (union-find over tolerance-
1037/// expanded boxes). Tools within a cluster may interpenetrate; distinct
1038/// clusters are pairwise disjoint. `None` when any AABB is unavailable.
1039fn cluster_tools_by_aabb(topo: &Topology, tools: &[SolidId]) -> Option<Vec<Vec<SolidId>>> {
1040 fn find(parent: &mut Vec<usize>, i: usize) -> usize {
1041 if parent[i] != i {
1042 let root = find(parent, parent[i]);
1043 parent[i] = root;
1044 }
1045 parent[i]
1046 }
1047 let tol = brepkit_math::tolerance::Tolerance::new().linear;
1048 let mut boxes = Vec::with_capacity(tools.len());
1049 for &t in tools {
1050 boxes.push(crate::measure::solid_bounding_box(topo, t).ok()?);
1051 }
1052 let mut parent: Vec<usize> = (0..tools.len()).collect();
1053 for i in 0..boxes.len() {
1054 for j in (i + 1)..boxes.len() {
1055 if boxes[i].expanded(tol).intersects(boxes[j]) {
1056 let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
1057 if ri != rj {
1058 parent[ri] = rj;
1059 }
1060 }
1061 }
1062 }
1063 let mut clusters: std::collections::BTreeMap<usize, Vec<SolidId>> =
1064 std::collections::BTreeMap::new();
1065 for i in 0..tools.len() {
1066 let root = find(&mut parent, i);
1067 clusters.entry(root).or_default().push(tools[i]);
1068 }
1069 Some(clusters.into_values().collect())
1070}
1071
1072/// Perform a boolean operation and return an [`crate::evolution::EvolutionMap`]
1073/// tracking face provenance.
1074///
1075/// Prefers **faithful** provenance from the GFA builder — each result face
1076/// records the input face it was split/derived from
1077/// (`brepkit_algo::gfa::boolean_with_face_origins`). Because that path runs the
1078/// GFA directly, it can take a different route than [`boolean`] (which
1079/// short-circuits some cases via AABB/containment fast paths), so its result is
1080/// validated; on a GFA error or an invalid result — and for identical or
1081/// fully-contained operand pairs (`detect_trivial_relation`) — it falls back to
1082/// [`boolean`] with the geometry heuristic (normal + centroid). Either way,
1083/// unmatched input faces are classified as "deleted"; synthesised result faces
1084/// with no input origin are left unattributed.
1085///
1086/// # Errors
1087///
1088/// Returns the same errors as [`boolean`].
1089pub fn boolean_with_evolution(
1090 topo: &mut Topology,
1091 op: BooleanOp,
1092 a: SolidId,
1093 b: SolidId,
1094) -> Result<(SolidId, crate::evolution::EvolutionMap), crate::OperationsError> {
1095 use brepkit_topology::explorer::solid_faces;
1096
1097 // Faithful path: the GFA reports each result face's true input source.
1098 // Identical/contained operand pairs must NOT take it: those are the
1099 // fully-coincident-boundary configurations `boolean` short-circuits
1100 // precisely because the raw GFA mis-splits them — coincident walls drop
1101 // into an open shell whose position-duplicate free edges pass the
1102 // by-edge-id validation gate (every edge id used ≤ 2×), so the broken
1103 // result would be returned as "valid". Route them through `boolean`'s
1104 // shortcuts below; the geometry heuristic attributes a copied result's
1105 // faces exactly (normal + centroid match 1:1). Detection only runs for
1106 // a != b (a == b skips the faithful path regardless) and its cost is
1107 // O(faces + vertices) per call; `boolean` re-runs it on the fallback,
1108 // which is accepted — deduplicating would mean threading the relation
1109 // through `boolean`'s public signature.
1110 let trivial = a != b && {
1111 use brepkit_algo::classifier::try_build_analytic_classifier;
1112 let tol = brepkit_math::tolerance::Tolerance::new();
1113 let ca = try_build_analytic_classifier(topo, a);
1114 let cb = try_build_analytic_classifier(topo, b);
1115 let rel = detect_trivial_relation(topo, a, b, ca.as_ref(), cb.as_ref(), tol);
1116 rel.identical || rel.a_in_b || rel.b_in_a
1117 };
1118 if a != b && !trivial {
1119 let input_indices: Vec<usize> = solid_faces(topo, a)?
1120 .into_iter()
1121 .chain(solid_faces(topo, b)?)
1122 .map(brepkit_topology::arena::Id::index)
1123 .collect();
1124 let algo_op = match op {
1125 BooleanOp::Fuse => brepkit_algo::bop::BooleanOp::Fuse,
1126 BooleanOp::Cut => brepkit_algo::bop::BooleanOp::Cut,
1127 BooleanOp::Intersect => brepkit_algo::bop::BooleanOp::Intersect,
1128 };
1129 if let Ok((result, origins)) =
1130 brepkit_algo::gfa::boolean_with_face_origins(topo, algo_op, a, b)
1131 {
1132 // Apply the face-id-preserving result heals so the evolution result
1133 // is as correct as the standard boolean (manifold, no #801 wire
1134 // spurs). These rewrite wires in place, so the provenance — keyed by
1135 // face ID — survives. `unify_faces` is intentionally NOT run here:
1136 // it merges coplanar faces into new entities, discarding the
1137 // per-face provenance this path exists to track.
1138 // A heal failure here is not fatal: fall through to boolean()'s
1139 // full pipeline rather than propagating (the result solid stays as
1140 // orphaned topology, which is harmless in the arena).
1141 let tol = brepkit_math::tolerance::Tolerance::default();
1142 let healed_ok = crate::heal::remove_degenerate_edges(topo, result, tol.linear).is_ok()
1143 && crate::heal::remove_wire_spurs(topo, result).is_ok();
1144
1145 // Trust the faithful path only if its result is valid; otherwise
1146 // fall through to boolean()'s full pipeline (fast paths + mesh
1147 // fallback + validation), matching boolean()'s contract.
1148 if healed_ok && validate_boolean_result(topo, result).is_ok() {
1149 let mut evo = crate::evolution::EvolutionMap::new();
1150 let mut sourced: std::collections::HashSet<usize> =
1151 std::collections::HashSet::new();
1152 for (out_idx, src) in origins {
1153 if let Some(in_idx) = src {
1154 evo.add_modified(in_idx, out_idx);
1155 sourced.insert(in_idx);
1156 }
1157 }
1158 for in_idx in input_indices {
1159 if !sourced.contains(&in_idx) {
1160 evo.add_deleted(in_idx);
1161 }
1162 }
1163 return Ok((result, evo));
1164 }
1165 }
1166 }
1167
1168 // Fallback: geometry heuristic over the standard boolean result. Reached
1169 // for identical operands, a GFA error, or a GFA result that failed
1170 // validation — the EvolutionMap is then approximate, not faithful.
1171 log::debug!("boolean_with_evolution: faithful GFA provenance unavailable, using heuristic");
1172 let input_faces_a = collect_face_signatures(topo, a)?;
1173 let input_faces_b = collect_face_signatures(topo, b)?;
1174
1175 let mut input_faces: Vec<(usize, Vec3, Point3)> =
1176 Vec::with_capacity(input_faces_a.len() + input_faces_b.len());
1177 input_faces.extend(input_faces_a);
1178 input_faces.extend(input_faces_b);
1179
1180 let result = boolean(topo, op, a, b)?;
1181
1182 let output_faces = collect_face_signatures(topo, result)?;
1183
1184 let evo = crate::evolution::build_evolution_by_geometry(&input_faces, &output_faces);
1185
1186 Ok((result, evo))
1187}
1188
1189/// Compute the boolean of two axis-aligned boxes via AABB algebra.
1190///
1191/// Returns `Ok(None)` when the result isn't a single box:
1192/// - Fuse: requires two of three dims to match exactly AND the boxes to
1193/// overlap or touch in the third dim. Otherwise the union is L-shaped.
1194/// - Intersect: any non-empty AABB intersection is a box.
1195/// - Cut: skipped — the general case is L-shaped, defer to GFA.
1196fn box_pair_shortcut(
1197 topo: &mut Topology,
1198 op: BooleanOp,
1199 a_min: Point3,
1200 a_max: Point3,
1201 b_min: Point3,
1202 b_max: Point3,
1203 tol: brepkit_math::tolerance::Tolerance,
1204) -> Result<Option<SolidId>, crate::OperationsError> {
1205 let eps = tol.linear;
1206 let (min, max) = match op {
1207 BooleanOp::Intersect => {
1208 let lo = Point3::new(
1209 a_min.x().max(b_min.x()),
1210 a_min.y().max(b_min.y()),
1211 a_min.z().max(b_min.z()),
1212 );
1213 let hi = Point3::new(
1214 a_max.x().min(b_max.x()),
1215 a_max.y().min(b_max.y()),
1216 a_max.z().min(b_max.z()),
1217 );
1218 // Empty intersection — let general path return an error.
1219 if hi.x() <= lo.x() + eps || hi.y() <= lo.y() + eps || hi.z() <= lo.z() + eps {
1220 return Ok(None);
1221 }
1222 (lo, hi)
1223 }
1224 BooleanOp::Fuse => {
1225 // The union of two axis-aligned boxes is itself a box only
1226 // when two of three dimensions match exactly AND the boxes
1227 // overlap or touch in the third dim.
1228 let x_match =
1229 (a_min.x() - b_min.x()).abs() < eps && (a_max.x() - b_max.x()).abs() < eps;
1230 let y_match =
1231 (a_min.y() - b_min.y()).abs() < eps && (a_max.y() - b_max.y()).abs() < eps;
1232 let z_match =
1233 (a_min.z() - b_min.z()).abs() < eps && (a_max.z() - b_max.z()).abs() < eps;
1234 let matched = u8::from(x_match) + u8::from(y_match) + u8::from(z_match);
1235 if matched < 2 {
1236 return Ok(None);
1237 }
1238 // Verify overlap/touch in all three dims (the unmatched dim
1239 // must overlap; matched dims trivially do).
1240 if a_max.x() < b_min.x() - eps
1241 || b_max.x() < a_min.x() - eps
1242 || a_max.y() < b_min.y() - eps
1243 || b_max.y() < a_min.y() - eps
1244 || a_max.z() < b_min.z() - eps
1245 || b_max.z() < a_min.z() - eps
1246 {
1247 return Ok(None);
1248 }
1249 (
1250 Point3::new(
1251 a_min.x().min(b_min.x()),
1252 a_min.y().min(b_min.y()),
1253 a_min.z().min(b_min.z()),
1254 ),
1255 Point3::new(
1256 a_max.x().max(b_max.x()),
1257 a_max.y().max(b_max.y()),
1258 a_max.z().max(b_max.z()),
1259 ),
1260 )
1261 }
1262 BooleanOp::Cut => {
1263 // Cut shortcut: when B spans A in 2 of 3 dims (≥ A's extent
1264 // on both sides) and overlaps in the third, the result is
1265 // up-to-2 axis-aligned boxes (the leftover slabs on either
1266 // side of B in the cutting dim). This avoids routing through
1267 // GFA's same-domain handling which currently mishandles the
1268 // 4-coincident-face case (target's lateral walls + tool's
1269 // matching walls).
1270 return box_pair_cut_shortcut(topo, a_min, a_max, b_min, b_max, eps);
1271 }
1272 };
1273 let dx = max.x() - min.x();
1274 let dy = max.y() - min.y();
1275 let dz = max.z() - min.z();
1276 if dx <= eps || dy <= eps || dz <= eps {
1277 return Ok(None);
1278 }
1279 let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1280 if min.x().abs() > eps || min.y().abs() > eps || min.z().abs() > eps {
1281 let xform = brepkit_math::mat::Mat4::translation(min.x(), min.y(), min.z());
1282 crate::transform::transform_solid(topo, bx, &xform)?;
1283 }
1284 Ok(Some(bx))
1285}
1286
1287/// Cut shortcut for two axis-aligned boxes: returns the leftover
1288/// portion(s) when B slices through A in one dimension while spanning
1289/// A in the other two dimensions. The result is 0, 1, or 2 axis-aligned
1290/// boxes packaged into a single multi-region Solid.
1291///
1292/// Returns `Ok(None)` when the shortcut doesn't fit — e.g., B doesn't
1293/// span A in any 2 dims, B touches only a corner, etc. The general path
1294/// (GFA) handles those cases.
1295fn box_pair_cut_shortcut(
1296 topo: &mut Topology,
1297 a_min: Point3,
1298 a_max: Point3,
1299 b_min: Point3,
1300 b_max: Point3,
1301 eps: f64,
1302) -> Result<Option<SolidId>, crate::OperationsError> {
1303 // B must span A in 2 of 3 dims (B_min ≤ A_min - eps AND B_max ≥ A_max + eps,
1304 // i.e., B's extent covers A's extent in that dim).
1305 let x_spans = b_min.x() <= a_min.x() + eps && b_max.x() >= a_max.x() - eps;
1306 let y_spans = b_min.y() <= a_min.y() + eps && b_max.y() >= a_max.y() - eps;
1307 let z_spans = b_min.z() <= a_min.z() + eps && b_max.z() >= a_max.z() - eps;
1308 let spans_count = u8::from(x_spans) + u8::from(y_spans) + u8::from(z_spans);
1309 if spans_count != 2 {
1310 return Ok(None);
1311 }
1312 // In the non-spanning dim, B must actually intersect A.
1313 let (a_lo, a_hi, b_lo, b_hi) = if !x_spans {
1314 (a_min.x(), a_max.x(), b_min.x(), b_max.x())
1315 } else if !y_spans {
1316 (a_min.y(), a_max.y(), b_min.y(), b_max.y())
1317 } else {
1318 (a_min.z(), a_max.z(), b_min.z(), b_max.z())
1319 };
1320 if b_hi <= a_lo + eps || b_lo >= a_hi - eps {
1321 return Ok(None);
1322 }
1323
1324 // Build the leftover slabs. There are 0, 1, or 2 pieces depending on
1325 // whether B extends past A on each side in the cutting dim.
1326 let cuts: Vec<(f64, f64)> = {
1327 let mut pieces = Vec::with_capacity(2);
1328 if b_lo > a_lo + eps {
1329 pieces.push((a_lo, b_lo)); // slab before B
1330 }
1331 if b_hi < a_hi - eps {
1332 pieces.push((b_hi, a_hi)); // slab after B
1333 }
1334 pieces
1335 };
1336 if cuts.is_empty() {
1337 // B fully covers A in the cutting dim → cut leaves nothing.
1338 // Let the general path handle this (it errors).
1339 return Ok(None);
1340 }
1341
1342 let piece_solids: Vec<SolidId> = cuts
1343 .iter()
1344 .map(|&(lo, hi)| -> Result<SolidId, crate::OperationsError> {
1345 let (dx, dy, dz, tx, ty, tz) = if !x_spans {
1346 (
1347 hi - lo,
1348 a_max.y() - a_min.y(),
1349 a_max.z() - a_min.z(),
1350 lo,
1351 a_min.y(),
1352 a_min.z(),
1353 )
1354 } else if !y_spans {
1355 (
1356 a_max.x() - a_min.x(),
1357 hi - lo,
1358 a_max.z() - a_min.z(),
1359 a_min.x(),
1360 lo,
1361 a_min.z(),
1362 )
1363 } else {
1364 (
1365 a_max.x() - a_min.x(),
1366 a_max.y() - a_min.y(),
1367 hi - lo,
1368 a_min.x(),
1369 a_min.y(),
1370 lo,
1371 )
1372 };
1373 let bx = crate::primitives::make_box(topo, dx, dy, dz)?;
1374 if tx.abs() > eps || ty.abs() > eps || tz.abs() > eps {
1375 let xform = brepkit_math::mat::Mat4::translation(tx, ty, tz);
1376 crate::transform::transform_solid(topo, bx, &xform)?;
1377 }
1378 Ok(bx)
1379 })
1380 .collect::<Result<_, _>>()?;
1381
1382 if piece_solids.len() == 1 {
1383 return Ok(Some(piece_solids[0]));
1384 }
1385
1386 // Combine pieces into a single multi-region solid.
1387 let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
1388 for &p in &piece_solids {
1389 let p_data = topo.solid(p)?;
1390 for &fid in topo.shell(p_data.outer_shell())?.faces() {
1391 all_faces.push(fid);
1392 }
1393 }
1394 Ok(Some(make_solid_from_face_subset(topo, &all_faces)?))
1395}
1396
1397/// Compute the coaxial-cylinder boolean for two cylinders sharing axis,
1398/// origin, and radius. Returns `Ok(None)` when the shortcut doesn't apply
1399/// (disjoint along axis for fuse/intersect; cut requires general handling).
1400#[allow(clippy::too_many_arguments)]
1401fn coaxial_cylinder_shortcut(
1402 topo: &mut Topology,
1403 op: BooleanOp,
1404 origin: Point3,
1405 axis: Vec3,
1406 radius: f64,
1407 a_range: (f64, f64),
1408 b_range: (f64, f64),
1409 tol: brepkit_math::tolerance::Tolerance,
1410) -> Result<Option<SolidId>, crate::OperationsError> {
1411 let (za_min, za_max) = a_range;
1412 let (zb_min, zb_max) = b_range;
1413 // For fuse: ranges must touch or overlap. Disjoint cylinders would
1414 // produce a compound, which the boolean API doesn't return.
1415 let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1416 let (z_min, z_max) = match op {
1417 BooleanOp::Fuse => {
1418 if !touches_or_overlaps {
1419 return Ok(None);
1420 }
1421 (za_min.min(zb_min), za_max.max(zb_max))
1422 }
1423 BooleanOp::Intersect => {
1424 // Strict overlap (not just touching) for non-degenerate result.
1425 let lo = za_min.max(zb_min);
1426 let hi = za_max.min(zb_max);
1427 if hi <= lo + tol.linear {
1428 return Ok(None);
1429 }
1430 (lo, hi)
1431 }
1432 BooleanOp::Cut => return Ok(None), // Defer to GFA / general path.
1433 };
1434 let height = z_max - z_min;
1435 if height <= tol.linear {
1436 return Ok(None);
1437 }
1438 // Build a fresh cylinder at axis-origin + axis*z_min, oriented along
1439 // axis. make_cylinder produces the canonical (0,0,0)→(0,0,h) cylinder;
1440 // then transform to the world axis frame.
1441 let cyl = crate::primitives::make_cylinder(topo, radius, height)?;
1442 let world_origin = Point3::new(
1443 origin.x() + axis.x() * z_min,
1444 origin.y() + axis.y() * z_min,
1445 origin.z() + axis.z() * z_min,
1446 );
1447 let xform = xform_from_canonical_z(world_origin, axis, tol);
1448 crate::transform::transform_solid(topo, cyl, &xform)?;
1449 Ok(Some(cyl))
1450}
1451
1452/// Compute the coaxial-cone boolean for two frustums on the same conical
1453/// surface (shared apex, axis, and half-angle). Returns `Ok(None)` when
1454/// the shortcut doesn't apply.
1455#[allow(clippy::too_many_arguments)]
1456fn coaxial_cone_shortcut(
1457 topo: &mut Topology,
1458 op: BooleanOp,
1459 apex: Point3,
1460 axis: Vec3,
1461 slope: f64,
1462 a_range: (f64, f64),
1463 b_range: (f64, f64),
1464 tol: brepkit_math::tolerance::Tolerance,
1465) -> Result<Option<SolidId>, crate::OperationsError> {
1466 let (za_min, za_max) = a_range;
1467 let (zb_min, zb_max) = b_range;
1468 let touches_or_overlaps = zb_min <= za_max + tol.linear && za_min <= zb_max + tol.linear;
1469 let (z_min, z_max) = match op {
1470 BooleanOp::Fuse => {
1471 if !touches_or_overlaps {
1472 return Ok(None);
1473 }
1474 (za_min.min(zb_min), za_max.max(zb_max))
1475 }
1476 BooleanOp::Intersect => {
1477 let lo = za_min.max(zb_min);
1478 let hi = za_max.min(zb_max);
1479 if hi <= lo + tol.linear {
1480 return Ok(None);
1481 }
1482 (lo, hi)
1483 }
1484 BooleanOp::Cut => return Ok(None),
1485 };
1486 let height = z_max - z_min;
1487 if height <= tol.linear {
1488 return Ok(None);
1489 }
1490 // r at axial position z (apex-relative) = slope * z. For frustums on
1491 // the +axis nappe, both z values are positive; if either becomes ≤ 0
1492 // (apex inclusion), bail out so we don't construct a degenerate cone.
1493 let r_at_z_min = slope * z_min;
1494 let r_at_z_max = slope * z_max;
1495 if r_at_z_min < -tol.linear || r_at_z_max < -tol.linear {
1496 return Ok(None);
1497 }
1498 let r_bot = r_at_z_min.abs();
1499 let r_top = r_at_z_max.abs();
1500 if r_bot <= tol.linear && r_top <= tol.linear {
1501 return Ok(None);
1502 }
1503 let cone = crate::primitives::make_cone(topo, r_bot, r_top, height)?;
1504 let world_origin = Point3::new(
1505 apex.x() + axis.x() * z_min,
1506 apex.y() + axis.y() * z_min,
1507 apex.z() + axis.z() * z_min,
1508 );
1509 // Cone shortcut keeps to axis-aligned cases for now (test corpus does
1510 // not yet cover off-axis cones). Detect parallel/antiparallel via the
1511 // dot product (the canonical-axis Z-component is the only term that
1512 // survives `canonical · axis` since canonical = ẑ).
1513 let dot = axis.z().clamp(-1.0, 1.0);
1514 if 1.0 - dot.abs() > tol.angular {
1515 return Ok(None);
1516 }
1517 let xform = xform_from_canonical_z(world_origin, axis, tol);
1518 crate::transform::transform_solid(topo, cone, &xform)?;
1519 Ok(Some(cone))
1520}
1521
1522/// Compute the concentric-sphere boolean for two spheres sharing a
1523/// Box-sphere `Intersect` shortcut. Handles two configurations exactly,
1524/// returning `Ok(None)` to fall through to GFA otherwise:
1525///
1526/// 1. **Sphere fully inside box** — every box face plane has the sphere
1527/// on the box-interior side with margin ≥ `R` (`s ≤ -R + eps`). The
1528/// result is a fresh sphere primitive at `sphere_center` with radius
1529/// `sphere_radius`.
1530/// 2. **Spherical "octant"** — exactly 3 of the 6 box face planes cut
1531/// the sphere (`|s| < R - eps`) and the other 3 leave the sphere on
1532/// the box-interior side. The 3 cutting planes are mutually orthogonal
1533/// (axis-aligned box invariant) and meet at a single box corner `O`.
1534/// The result is the sphere region in the box-interior octant of `O`,
1535/// bounded by 3 quarter-disc box sub-faces and 1 spherical patch.
1536///
1537/// `s` is the signed distance from `sphere_center` to a face plane along
1538/// the face's outward normal (positive = sphere on box-exterior side).
1539/// If any face has `s ≥ R - eps` the result is empty (sphere doesn't
1540/// reach into the box from that side) — we return `None` rather than an
1541/// empty solid so the caller can produce the canonical `EmptyResult`
1542/// error via the regular path.
1543#[allow(clippy::too_many_arguments)]
1544fn box_sphere_intersect_shortcut(
1545 topo: &mut Topology,
1546 box_min: Point3,
1547 box_max: Point3,
1548 sphere_center: Point3,
1549 sphere_radius: f64,
1550 sphere_segments: usize,
1551 tol: brepkit_math::tolerance::Tolerance,
1552) -> Result<Option<SolidId>, crate::OperationsError> {
1553 let r = sphere_radius;
1554 let eps = tol.linear;
1555 if r <= eps {
1556 return Ok(None);
1557 }
1558 // Sanity: degenerate or inverted box.
1559 if box_max.x() <= box_min.x() + eps
1560 || box_max.y() <= box_min.y() + eps
1561 || box_max.z() <= box_min.z() + eps
1562 {
1563 return Ok(None);
1564 }
1565
1566 // For each of 6 box face planes, compute `s` (signed distance from
1567 // sphere center along outward normal). Classify each plane.
1568 let faces: [(Vec3, f64); 6] = [
1569 (Vec3::new(-1.0, 0.0, 0.0), -box_min.x()),
1570 (Vec3::new(1.0, 0.0, 0.0), box_max.x()),
1571 (Vec3::new(0.0, -1.0, 0.0), -box_min.y()),
1572 (Vec3::new(0.0, 1.0, 0.0), box_max.y()),
1573 (Vec3::new(0.0, 0.0, -1.0), -box_min.z()),
1574 (Vec3::new(0.0, 0.0, 1.0), box_max.z()),
1575 ];
1576 let signed_dist = |n: Vec3, d: f64| -> f64 {
1577 n.x() * sphere_center.x() + n.y() * sphere_center.y() + n.z() * sphere_center.z() - d
1578 };
1579
1580 let mut cuts: Vec<usize> = Vec::new();
1581 for (i, &(n, d)) in faces.iter().enumerate() {
1582 let s = signed_dist(n, d);
1583 if s >= r - eps {
1584 // Sphere is fully on the exterior side of this plane → box ∩
1585 // sphere = empty. Defer to GFA which will surface an
1586 // EmptyResult error in its usual form.
1587 return Ok(None);
1588 }
1589 if s.abs() < r - eps {
1590 cuts.push(i);
1591 }
1592 // else: s ≤ -r + eps → sphere fully inside this plane, face
1593 // doesn't bound the result; nothing to do.
1594 }
1595
1596 // Case 1: sphere fully inside box (no cutting planes).
1597 if cuts.is_empty() {
1598 let sphere = crate::primitives::make_sphere(topo, r, sphere_segments)?;
1599 if sphere_center.x().abs() > eps
1600 || sphere_center.y().abs() > eps
1601 || sphere_center.z().abs() > eps
1602 {
1603 let xform = brepkit_math::mat::Mat4::translation(
1604 sphere_center.x(),
1605 sphere_center.y(),
1606 sphere_center.z(),
1607 );
1608 crate::transform::transform_solid(topo, sphere, &xform)?;
1609 }
1610 return Ok(Some(sphere));
1611 }
1612
1613 // Case 2: 3 cutting planes meeting at a box corner → spherical
1614 // octant. The 3 cut planes' outward normals are mutually orthogonal
1615 // (axis-aligned box invariant) so the in-box direction perpendicular
1616 // to each is the negated outward normal.
1617 if cuts.len() == 3 {
1618 return build_box_sphere_octant(topo, &faces, &cuts, sphere_center, r, tol);
1619 }
1620
1621 // 1, 2, 4, 5, 6 cutting planes — more complex geometries (caps,
1622 // lenses, etc.). Out of scope for this shortcut; fall through.
1623 Ok(None)
1624}
1625
1626/// Construct the result of `box ∩ sphere` when exactly 3 box face planes
1627/// cut the sphere and meet at a single corner `O`. The result topology
1628/// is 4 faces (3 quarter-discs + 1 spherical patch), 6 edges, 4 vertices.
1629fn build_box_sphere_octant(
1630 topo: &mut Topology,
1631 faces: &[(Vec3, f64); 6],
1632 cuts: &[usize],
1633 sphere_center: Point3,
1634 r: f64,
1635 tol: brepkit_math::tolerance::Tolerance,
1636) -> Result<Option<SolidId>, crate::OperationsError> {
1637 use brepkit_math::curves::Circle3D;
1638 use brepkit_math::surfaces::SphericalSurface;
1639 use brepkit_topology::edge::{Edge, EdgeCurve};
1640 use brepkit_topology::face::{Face, FaceSurface};
1641 use brepkit_topology::shell::Shell;
1642 use brepkit_topology::solid::Solid;
1643 use brepkit_topology::vertex::Vertex;
1644 use brepkit_topology::wire::{OrientedEdge, Wire};
1645
1646 // Cutting plane normals + their box-plane-d values.
1647 let cut_planes: Vec<(Vec3, f64)> = cuts.iter().map(|&i| faces[i]).collect();
1648 // The 3 outward normals must be mutually orthogonal (axis-aligned box).
1649 let n0 = cut_planes[0].0;
1650 let n1 = cut_planes[1].0;
1651 let n2 = cut_planes[2].0;
1652 if n0.dot(n1).abs() > tol.angular
1653 || n0.dot(n2).abs() > tol.angular
1654 || n1.dot(n2).abs() > tol.angular
1655 {
1656 // Not orthogonal — defer to GFA.
1657 return Ok(None);
1658 }
1659 // The corner O is at the intersection of the 3 cutting planes:
1660 // n_i · O = d_i for all 3 i.
1661 // Since the normals are axis-aligned (±x, ±y, ±z), we can pull each
1662 // coordinate of O directly off the matching plane's d.
1663 let coord_from_axis = |axis: Vec3, d: f64| -> f64 {
1664 if axis.x().abs() > 0.5 {
1665 d * axis.x().signum()
1666 } else if axis.y().abs() > 0.5 {
1667 d * axis.y().signum()
1668 } else {
1669 d * axis.z().signum()
1670 }
1671 };
1672 let mut o = [0.0_f64; 3];
1673 for &(n, d) in &cut_planes {
1674 if n.x().abs() > 0.5 {
1675 o[0] = coord_from_axis(n, d);
1676 } else if n.y().abs() > 0.5 {
1677 o[1] = coord_from_axis(n, d);
1678 } else {
1679 o[2] = coord_from_axis(n, d);
1680 }
1681 }
1682 let o = Point3::new(o[0], o[1], o[2]);
1683
1684 // In-box direction perpendicular to each cutting plane = -n_i.
1685 let in_dirs: Vec<Vec3> = cut_planes
1686 .iter()
1687 .map(|&(n, _)| Vec3::new(-n.x(), -n.y(), -n.z()))
1688 .collect();
1689
1690 // For each cutting plane i, the box edge from O in direction in_dirs[i]
1691 // is the intersection of the other two cutting planes. Find the sphere
1692 // intersection with this edge — the vertex on the sphere along the box
1693 // edge.
1694 //
1695 // Edge parameterised as O + t·d_i for t ≥ 0. Sphere: |P - C|² = R².
1696 // (O + t·d_i - C) · (O + t·d_i - C) = R²
1697 // Let v = O - C; expand:
1698 // t² + 2 t (v · d_i) + |v|² - R² = 0
1699 // So t = -v·d_i ± sqrt((v·d_i)² - |v|² + R²)
1700 let mut sphere_pts: [Point3; 3] = [Point3::new(0.0, 0.0, 0.0); 3];
1701 for (idx, &dir) in in_dirs.iter().enumerate() {
1702 let vx = o.x() - sphere_center.x();
1703 let vy = o.y() - sphere_center.y();
1704 let vz = o.z() - sphere_center.z();
1705 let v_dot_d = vx * dir.x() + vy * dir.y() + vz * dir.z();
1706 let v_sq = vx * vx + vy * vy + vz * vz;
1707 let disc = v_dot_d * v_dot_d - v_sq + r * r;
1708 if disc < -tol.linear * tol.linear {
1709 return Ok(None);
1710 }
1711 let t = -v_dot_d + disc.max(0.0).sqrt();
1712 if t <= tol.linear {
1713 return Ok(None);
1714 }
1715 sphere_pts[idx] = Point3::new(
1716 o.x() + t * dir.x(),
1717 o.y() + t * dir.y(),
1718 o.z() + t * dir.z(),
1719 );
1720 }
1721
1722 // Topology: 4 vertices, 6 edges, 4 faces.
1723 let v_o = topo.add_vertex(Vertex::new(o, tol.linear));
1724 let v_x = topo.add_vertex(Vertex::new(sphere_pts[0], tol.linear));
1725 let v_y = topo.add_vertex(Vertex::new(sphere_pts[1], tol.linear));
1726 let v_z = topo.add_vertex(Vertex::new(sphere_pts[2], tol.linear));
1727
1728 // 3 line edges from O along the box edges.
1729 let e_ox = topo.add_edge(Edge::new(v_o, v_x, EdgeCurve::Line));
1730 let e_oy = topo.add_edge(Edge::new(v_o, v_y, EdgeCurve::Line));
1731 let e_oz = topo.add_edge(Edge::new(v_o, v_z, EdgeCurve::Line));
1732
1733 // 3 arc edges on the sphere. Each arc lies on one of the cutting planes:
1734 // the arc opposite vertex `i` (i.e., between the other two vertices)
1735 // sits on cutting plane `i` (normal `n_i`), because those two vertices
1736 // lie on edges perpendicular to the remaining two normals — and both
1737 // of those edges lie within the plane perpendicular to `n_i`.
1738 let mut build_arc_edge =
1739 |n: Vec3,
1740 p_start: Point3,
1741 p_end: Point3,
1742 start_vid,
1743 end_vid|
1744 -> Result<brepkit_topology::edge::EdgeId, crate::OperationsError> {
1745 let dist = n.x() * (sphere_center.x() - p_start.x())
1746 + n.y() * (sphere_center.y() - p_start.y())
1747 + n.z() * (sphere_center.z() - p_start.z());
1748 let circle_center = Point3::new(
1749 sphere_center.x() - dist * n.x(),
1750 sphere_center.y() - dist * n.y(),
1751 sphere_center.z() - dist * n.z(),
1752 );
1753 let circle_r = (r * r - dist * dist).max(0.0).sqrt();
1754 if circle_r <= tol.linear {
1755 return Err(crate::OperationsError::InvalidInput {
1756 reason: "box-sphere octant: degenerate arc radius".into(),
1757 });
1758 }
1759 let dx = p_start.x() - circle_center.x();
1760 let dy = p_start.y() - circle_center.y();
1761 let dz = p_start.z() - circle_center.z();
1762 let len = (dx * dx + dy * dy + dz * dz).sqrt();
1763 if len <= tol.linear {
1764 return Err(crate::OperationsError::InvalidInput {
1765 reason: "box-sphere octant: degenerate arc reference".into(),
1766 });
1767 }
1768 let u_ref = Vec3::new(dx / len, dy / len, dz / len);
1769 // The circle's CCW direction must take start -> end the SHORT
1770 // way (the quarter arc bounding the octant). About the cutting
1771 // plane's OUTWARD normal that span is the 270-degree
1772 // complement (the wrong-region 1304.8 volume); the INWARD
1773 // normal makes it the intended quarter.
1774 let inward = Vec3::new(-n.x(), -n.y(), -n.z());
1775 let circle =
1776 Circle3D::new_with_ref(circle_center, inward, circle_r, u_ref).map_err(|e| {
1777 crate::OperationsError::InvalidInput {
1778 reason: format!("box-sphere octant: circle construction failed: {e}"),
1779 }
1780 })?;
1781 let _ = p_end; // p_end is used only via end_vid (already pre-placed at the correct sphere point)
1782 Ok(topo.add_edge(Edge::new(start_vid, end_vid, EdgeCurve::Circle(circle))))
1783 };
1784
1785 // Arc on cut plane 0 (between v_y and v_z, i.e., the edge "opposite" v_x).
1786 let arc_yz = build_arc_edge(n0, sphere_pts[1], sphere_pts[2], v_y, v_z)?;
1787 // Arc on cut plane 1 (between v_z and v_x).
1788 let arc_zx = build_arc_edge(n1, sphere_pts[2], sphere_pts[0], v_z, v_x)?;
1789 // Arc on cut plane 2 (between v_x and v_y).
1790 let arc_xy = build_arc_edge(n2, sphere_pts[0], sphere_pts[1], v_x, v_y)?;
1791
1792 // Quarter-disc face on cut plane 0 (perpendicular to n0): bounded by
1793 // box edges O-Y and O-Z + arc Y→Z.
1794 let qd0_wire = Wire::new(
1795 vec![
1796 OrientedEdge::new(e_oy, true), // O → Y
1797 OrientedEdge::new(arc_yz, true), // Y → Z (arc)
1798 OrientedEdge::new(e_oz, false), // Z → O (reversed)
1799 ],
1800 true,
1801 )
1802 .map_err(crate::OperationsError::Topology)?;
1803 let qd0_id = topo.add_wire(qd0_wire);
1804 let qd0_face = topo.add_face(Face::new(
1805 qd0_id,
1806 Vec::new(),
1807 FaceSurface::Plane {
1808 normal: n0,
1809 d: cut_planes[0].1,
1810 },
1811 ));
1812
1813 let qd1_wire = Wire::new(
1814 vec![
1815 OrientedEdge::new(e_oz, true), // O → Z
1816 OrientedEdge::new(arc_zx, true), // Z → X (arc)
1817 OrientedEdge::new(e_ox, false), // X → O (reversed)
1818 ],
1819 true,
1820 )
1821 .map_err(crate::OperationsError::Topology)?;
1822 let qd1_id = topo.add_wire(qd1_wire);
1823 let qd1_face = topo.add_face(Face::new(
1824 qd1_id,
1825 Vec::new(),
1826 FaceSurface::Plane {
1827 normal: n1,
1828 d: cut_planes[1].1,
1829 },
1830 ));
1831
1832 let qd2_wire = Wire::new(
1833 vec![
1834 OrientedEdge::new(e_ox, true), // O → X
1835 OrientedEdge::new(arc_xy, true), // X → Y (arc)
1836 OrientedEdge::new(e_oy, false), // Y → O (reversed)
1837 ],
1838 true,
1839 )
1840 .map_err(crate::OperationsError::Topology)?;
1841 let qd2_id = topo.add_wire(qd2_wire);
1842 let qd2_face = topo.add_face(Face::new(
1843 qd2_id,
1844 Vec::new(),
1845 FaceSurface::Plane {
1846 normal: n2,
1847 d: cut_planes[2].1,
1848 },
1849 ));
1850
1851 // Spherical patch: bounded by the 3 arcs.
1852 // Wind so the sphere's outward normal matches the resulting volume
1853 // (outside the octant). With arcs going X→Y→Z→X around the patch,
1854 // the right-hand rule gives an outward normal pointing AWAY from O.
1855 // Each arc is traversed forward by its quarter-disc, so the patch must
1856 // traverse all three reversed for consistent edge senses: X → Z → Y → X.
1857 let sph_wire = Wire::new(
1858 vec![
1859 OrientedEdge::new(arc_zx, false), // X → Z
1860 OrientedEdge::new(arc_yz, false), // Z → Y
1861 OrientedEdge::new(arc_xy, false), // Y → X
1862 ],
1863 true,
1864 )
1865 .map_err(crate::OperationsError::Topology)?;
1866 let sph_wire_id = topo.add_wire(sph_wire);
1867 let sphere_surface = SphericalSurface::new(sphere_center, r).map_err(|e| {
1868 crate::OperationsError::InvalidInput {
1869 reason: format!("box-sphere octant: sphere surface construction failed: {e}"),
1870 }
1871 })?;
1872 let sphere_face = topo.add_face(Face::new(
1873 sph_wire_id,
1874 Vec::new(),
1875 FaceSurface::Sphere(sphere_surface),
1876 ));
1877
1878 let shell = Shell::new(vec![qd0_face, qd1_face, qd2_face, sphere_face])
1879 .map_err(crate::OperationsError::Topology)?;
1880 let shell_id = topo.add_shell(shell);
1881 let solid = topo.add_solid(Solid::new(shell_id, Vec::new()));
1882 Ok(Some(solid))
1883}
1884
1885/// center. Returns `Ok(None)` when the shortcut doesn't apply (Cut, or
1886/// degenerate radii).
1887///
1888/// Sphere-sphere is simpler than the cylinder/cone analogues because
1889/// there's no axial range — the result radius is just `max(r_a, r_b)`
1890/// for Fuse and `min(r_a, r_b)` for Intersect.
1891///
1892/// The new sphere's tessellation density (segment count) is inherited from
1893/// whichever input has a higher equatorial vertex count, so a
1894/// 64-segment input never silently downgrades to a coarse default. This
1895/// relies on `make_sphere` allocating exactly `segments` equatorial
1896/// vertices and no pole vertices — see `crates/operations/src/primitives.rs`.
1897#[allow(clippy::too_many_arguments)]
1898fn concentric_sphere_shortcut(
1899 topo: &mut Topology,
1900 op: BooleanOp,
1901 a: SolidId,
1902 b: SolidId,
1903 center: Point3,
1904 r_a: f64,
1905 r_b: f64,
1906 tol: brepkit_math::tolerance::Tolerance,
1907) -> Result<Option<SolidId>, crate::OperationsError> {
1908 if r_a <= tol.linear || r_b <= tol.linear {
1909 return Ok(None);
1910 }
1911 let r_result = match op {
1912 BooleanOp::Fuse => r_a.max(r_b),
1913 BooleanOp::Intersect => {
1914 // Both r_a and r_b are guaranteed > tol.linear by the guard above,
1915 // so `min(r_a, r_b)` is always positive here.
1916 r_a.min(r_b)
1917 }
1918 // Cut(A, B) on concentric spheres yields a hollow ball when r_a > r_b;
1919 // empty when r_a ≤ r_b. The hollow-ball case needs an outer + inner
1920 // shell, which `make_sphere` doesn't produce — defer to GFA.
1921 BooleanOp::Cut => return Ok(None),
1922 };
1923
1924 // Inherit segment count from whichever input was tessellated more finely.
1925 // `make_sphere(r, n)` allocates exactly `n` equatorial vertices; because
1926 // sphere primitives are fully describe by (center, radius), all vertices
1927 // belong to that ring. Floor at 4 to satisfy `make_sphere`'s lower bound.
1928 let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1929 .map(|v| v.len())
1930 .unwrap_or(0);
1931 let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1932 .map(|v| v.len())
1933 .unwrap_or(0);
1934 let segments = segments_a.max(segments_b).max(4);
1935
1936 let sphere = crate::primitives::make_sphere(topo, r_result, segments)?;
1937 if center.x().abs() > tol.linear
1938 || center.y().abs() > tol.linear
1939 || center.z().abs() > tol.linear
1940 {
1941 let xform = brepkit_math::mat::Mat4::translation(center.x(), center.y(), center.z());
1942 crate::transform::transform_solid(topo, sphere, &xform)?;
1943 }
1944 Ok(Some(sphere))
1945}
1946
1947/// Compute the coaxial-torus boolean for two tori sharing center, axis,
1948/// and major radius. Returns `Ok(None)` when the shortcut doesn't apply
1949/// (Cut, or degenerate radii / overlap).
1950///
1951/// Like the concentric-sphere shortcut, the result tessellation density
1952/// is inherited from the higher-quality input so a 64-segment input
1953/// torus never silently downgrades.
1954#[allow(clippy::too_many_arguments)]
1955fn coaxial_torus_shortcut(
1956 topo: &mut Topology,
1957 op: BooleanOp,
1958 a: SolidId,
1959 b: SolidId,
1960 center: Point3,
1961 axis: Vec3,
1962 major_radius: f64,
1963 minor_a: f64,
1964 minor_b: f64,
1965 tol: brepkit_math::tolerance::Tolerance,
1966) -> Result<Option<SolidId>, crate::OperationsError> {
1967 if minor_a <= tol.linear || minor_b <= tol.linear || major_radius <= tol.linear {
1968 return Ok(None);
1969 }
1970 let minor_result = match op {
1971 BooleanOp::Fuse => minor_a.max(minor_b),
1972 BooleanOp::Intersect => {
1973 // Both minors are guaranteed > tol by the guard above.
1974 minor_a.min(minor_b)
1975 }
1976 // Cut on coaxial tori with shared major produces a hollow torus
1977 // (outer + inner small-circle shells) when minor_a > minor_b.
1978 // `make_torus` doesn't build that topology — defer to GFA.
1979 BooleanOp::Cut => return Ok(None),
1980 };
1981 if minor_result >= major_radius {
1982 // make_torus rejects self-intersecting tori (minor >= major).
1983 return Ok(None);
1984 }
1985
1986 // Inherit segment count from the higher-quality input. `make_torus`
1987 // accepts a `segments` param controlling u-direction discretization.
1988 // We'd ideally extract this from each input solid's vertex count, but
1989 // unlike make_sphere torus topology has internal seam vertices that
1990 // make the relationship less clean. Approximate by the larger vertex
1991 // count.
1992 let segments_a = brepkit_topology::explorer::solid_vertices(topo, a)
1993 .map(|v| v.len())
1994 .unwrap_or(0);
1995 let segments_b = brepkit_topology::explorer::solid_vertices(topo, b)
1996 .map(|v| v.len())
1997 .unwrap_or(0);
1998 let segments = segments_a.max(segments_b).max(8);
1999
2000 // Build a fresh torus at the origin then transform to the shared
2001 // center / axis. `make_torus` builds with axis = +z by default.
2002 let torus = crate::primitives::make_torus(topo, major_radius, minor_result, segments)?;
2003 let xform = xform_from_canonical_z(center, axis, tol);
2004 crate::transform::transform_solid(topo, torus, &xform)?;
2005 Ok(Some(torus))
2006}
2007
2008/// Build the world-frame transform that maps a primitive built in the
2009/// canonical Z-up local frame (origin at world origin, axis = +Z) to a
2010/// world frame at `world_origin` with up-axis `axis` (assumed
2011/// unit-length). Uses Rodrigues' rotation formula for the general case.
2012///
2013/// Comparisons use `1.0 - axis.dot(canonical) < tol.angular` rather than
2014/// vector-length deltas, because for unit vectors `|u−v| ≈ √2·θ`, so a
2015/// length comparison against `tol.angular` would correspond to
2016/// `θ ≈ 7×10⁻¹³` rad — effectively bit-identity.
2017fn xform_from_canonical_z(
2018 world_origin: Point3,
2019 axis: Vec3,
2020 tol: brepkit_math::tolerance::Tolerance,
2021) -> brepkit_math::mat::Mat4 {
2022 let translate =
2023 brepkit_math::mat::Mat4::translation(world_origin.x(), world_origin.y(), world_origin.z());
2024 let canonical = Vec3::new(0.0, 0.0, 1.0);
2025 let dot = canonical.dot(axis).clamp(-1.0, 1.0);
2026 // Parallel to +Z: pure translation.
2027 if 1.0 - dot < tol.angular {
2028 return translate;
2029 }
2030 // Antiparallel: rotate canonical (+z) by π around X to flip to −z.
2031 if 1.0 + dot < tol.angular {
2032 return translate * brepkit_math::mat::Mat4::rotation_x(std::f64::consts::PI);
2033 }
2034 // Rotate canonical (0,0,1) → axis via Rodrigues' formula:
2035 // R = I + sin(θ) K + (1 - cos(θ)) K², K = [k]× for k = ẑ × axis / sin(θ).
2036 // k.z = 0 by construction, so K's z-row/z-column have a known structure.
2037 let sin_t = (1.0 - dot * dot).sqrt();
2038 let kx = -axis.y() / sin_t;
2039 let ky = axis.x() / sin_t;
2040 let one_minus_cos = 1.0 - dot;
2041 let r00 = one_minus_cos.mul_add(kx * kx, dot);
2042 let r01 = one_minus_cos * kx * ky;
2043 let r02 = sin_t * ky;
2044 let r10 = one_minus_cos * kx * ky;
2045 let r11 = one_minus_cos.mul_add(ky * ky, dot);
2046 let r12 = -sin_t * kx;
2047 let r20 = -sin_t * ky;
2048 let r21 = sin_t * kx;
2049 let r22 = dot;
2050 let rot = brepkit_math::mat::Mat4([
2051 [r00, r01, r02, 0.0],
2052 [r10, r11, r12, 0.0],
2053 [r20, r21, r22, 0.0],
2054 [0.0, 0.0, 0.0, 1.0],
2055 ]);
2056 translate * rot
2057}
2058
2059/// Returns `true` when two axis-aligned boxes are separated on at least
2060/// one axis by more than `margin` — i.e. their (margin-expanded) extents
2061/// do not overlap and the solids they bound provably do not intersect.
2062///
2063/// The `margin` shrinks the overlap test so boxes that only touch (or
2064/// nearly touch) within `margin` are treated as separated: a shared
2065/// face/edge/corner has zero overlap volume.
2066fn aabbs_separated(
2067 a: &brepkit_math::aabb::Aabb3,
2068 b: &brepkit_math::aabb::Aabb3,
2069 margin: f64,
2070) -> bool {
2071 a.max.x() < b.min.x() + margin
2072 || b.max.x() < a.min.x() + margin
2073 || a.max.y() < b.min.y() + margin
2074 || b.max.y() < a.min.y() + margin
2075 || a.max.z() < b.min.z() + margin
2076 || b.max.z() < a.min.z() + margin
2077}
2078
2079/// Returns `true` when two axis-aligned boxes have a *clear gap* exceeding
2080/// `margin` on at least one axis — i.e. they are separated by a real positive
2081/// distance, not merely touching.
2082///
2083/// This is intentionally stricter than [`aabbs_separated`]: a shared
2084/// face/edge/corner (zero gap) returns `false` here. Touching solids must NOT
2085/// be treated as disjoint by the fuse fast path — their shared geometry has to
2086/// be welded by GFA.
2087fn aabbs_clear_gap(
2088 a: &brepkit_math::aabb::Aabb3,
2089 b: &brepkit_math::aabb::Aabb3,
2090 margin: f64,
2091) -> bool {
2092 b.min.x() - a.max.x() > margin
2093 || a.min.x() - b.max.x() > margin
2094 || b.min.y() - a.max.y() > margin
2095 || a.min.y() - b.max.y() > margin
2096 || b.min.z() - a.max.z() > margin
2097 || a.min.z() - b.max.z() > margin
2098}
2099
2100/// Returns `true` when solids `a` and `b` are provably spatially disjoint with
2101/// a clear gap: every connected face component of `a` is separated from every
2102/// connected face component of `b` by more than `margin` on some axis.
2103///
2104/// Soundness: component AABBs come from [`crate::measure::face_set_bounding_box`],
2105/// which is a conservative *outer* bound (vertices plus surface-curvature
2106/// expansion). If two components' true geometry overlapped or touched, their
2107/// boxes would touch or overlap and [`aabbs_clear_gap`] would (correctly)
2108/// return `false`. So a `true` result guarantees a real positive gap between
2109/// the two solids — never a false "disjoint" for touching/coincident inputs,
2110/// which must still go through GFA to weld shared geometry.
2111///
2112/// Component-level (rather than whole-solid) granularity is essential: a
2113/// multi-region solid (e.g. an accumulator of several already-merged disjoint
2114/// pieces) has a single outer shell whose overall box overlaps a nearby piece,
2115/// yet none of its pieces actually touch that piece. [`assembly::face_components`]
2116/// recovers the individual pieces from the merged shell.
2117///
2118/// Returns `false` on any topology error or empty operand (fall through to the
2119/// general path) rather than risking an unsound merge.
2120fn solids_provably_disjoint(topo: &Topology, a: SolidId, b: SolidId, margin: f64) -> bool {
2121 let comps_a = assembly::face_components(topo, a);
2122 let comps_b = assembly::face_components(topo, b);
2123 if comps_a.is_empty() || comps_b.is_empty() {
2124 return false;
2125 }
2126 let boxes = |comps: &[Vec<FaceId>]| -> Option<Vec<brepkit_math::aabb::Aabb3>> {
2127 comps
2128 .iter()
2129 .map(|faces| crate::measure::face_set_bounding_box(topo, faces).ok())
2130 .collect()
2131 };
2132 let (Some(boxes_a), Some(boxes_b)) = (boxes(&comps_a), boxes(&comps_b)) else {
2133 return false;
2134 };
2135 boxes_a
2136 .iter()
2137 .all(|ba| boxes_b.iter().all(|bb| aabbs_clear_gap(ba, bb, margin)))
2138}
2139
2140/// The trivial operand relationships that let [`boolean`] short-circuit
2141/// without running the GFA: identical solids and full containment.
2142struct TrivialRelation {
2143 /// Matching AABBs AND every boundary vertex of each solid classifies
2144 /// as inside-or-on the other's analytic classifier.
2145 identical: bool,
2146 /// A is fully contained in B.
2147 a_in_b: bool,
2148 /// B is fully contained in A.
2149 b_in_a: bool,
2150}
2151
2152/// Detect the trivial operand relationships (identical / contained).
2153///
2154/// [`boolean`] uses this to take copy/empty shortcuts. [`boolean_with_evolution`]
2155/// consults the same detection BEFORE its faithful raw-GFA provenance path:
2156/// these are exactly the fully-coincident-boundary configurations the raw GFA
2157/// mis-splits (coincident walls dropped into an open shell whose
2158/// position-duplicate free edges slip past the by-edge-id validation gate), so
2159/// the evolution path must route them through [`boolean`]'s shortcuts instead.
2160fn detect_trivial_relation(
2161 topo: &Topology,
2162 a: SolidId,
2163 b: SolidId,
2164 ca: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2165 cb: Option<&brepkit_algo::classifier::AnalyticClassifier>,
2166 tol: brepkit_math::tolerance::Tolerance,
2167) -> TrivialRelation {
2168 // Use measure::solid_bounding_box — it expands for surface curvature
2169 // (cylinder vertex projection, sphere/torus analytic). The naive
2170 // edge-vertex sampler missed cylinder lateral extents because cylinders
2171 // only have seam vertices, leaving the AABB center on the lateral
2172 // surface where the analytic classifier returns None.
2173 let sample_aabb = |topo: &Topology, solid: SolidId| -> Option<(Point3, Point3)> {
2174 let bb = crate::measure::solid_bounding_box(topo, solid).ok()?;
2175 Some((bb.min, bb.max))
2176 };
2177 let aabb_a = sample_aabb(topo, a);
2178 let aabb_b = sample_aabb(topo, b);
2179 // AABB-encloses check (lenient): does `inner` fit inside `outer`?
2180 let aabb_encloses =
2181 |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2182 let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2183 return false;
2184 };
2185 let margin = tol.linear;
2186 i_min.x() >= o_min.x() - margin
2187 && i_min.y() >= o_min.y() - margin
2188 && i_min.z() >= o_min.z() - margin
2189 && i_max.x() <= o_max.x() + margin
2190 && i_max.y() <= o_max.y() + margin
2191 && i_max.z() <= o_max.z() + margin
2192 };
2193 // AABB-strictly-contains (strict): outer must also be ≥10% larger in
2194 // ALL 3 dims. Used as the no-classifier fallback to detect true
2195 // nested containment (e.g., a ring fully inside a shell's cavity)
2196 // without false-positives on sparse multi-shell solids (e.g., a
2197 // fuse of disjoint boxes whose AABB technically encloses another
2198 // solid's AABB while mostly being empty space).
2199 let aabb_strictly_contains =
2200 |inner: &Option<(Point3, Point3)>, outer: &Option<(Point3, Point3)>| -> bool {
2201 if !aabb_encloses(inner, outer) {
2202 return false;
2203 }
2204 let Some(((i_min, i_max), (o_min, o_max))) = inner.zip(*outer) else {
2205 return false;
2206 };
2207 let dims = [
2208 (o_max.x() - o_min.x(), i_max.x() - i_min.x()),
2209 (o_max.y() - o_min.y(), i_max.y() - i_min.y()),
2210 (o_max.z() - o_min.z(), i_max.z() - i_min.z()),
2211 ];
2212 dims.iter()
2213 .all(|(outer_d, inner_d)| *outer_d > *inner_d * 1.1)
2214 };
2215
2216 // AABB enclosure is necessary but NOT sufficient for solid
2217 // containment: a non-convex container (notched or hollow) can
2218 // AABB-enclose a solid that actually lies in its empty region.
2219 // Issue #801: `(a − b) ∪ (a ∩ b)` dropped the `a ∩ b` operand
2220 // because the unit cube's bbox fits inside the notched `a − b`'s
2221 // bbox, yet the cube lives in the carved-out notch. Confirm the
2222 // AABB-only fallback with a real point-in-solid test: reject when
2223 // the inner solid's center is provably inside `inner` yet outside
2224 // `outer`. By the containment lemma (inner ⊆ outer ⇒ every point
2225 // of inner is in outer), that witness can only occur for genuine
2226 // non-containment, so it never rejects a true containment.
2227 let center_outside =
2228 |topo: &Topology, inner: SolidId, outer: SolidId, bb: &Option<(Point3, Point3)>| -> bool {
2229 let Some((lo, hi)) = *bb else { return false };
2230 let c = Point3::new(
2231 0.5 * (lo.x() + hi.x()),
2232 0.5 * (lo.y() + hi.y()),
2233 0.5 * (lo.z() + hi.z()),
2234 );
2235 let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2236 let defl = (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6);
2237 // Conservative by design: when the AABB center falls in `inner`'s own
2238 // concavity (a C/U-shaped solid), `inside_inner` is false and the
2239 // witness is disabled, so a false-positive containment could still
2240 // slip through. That only ever fails to *reject* — it never rejects a
2241 // true containment — so the shortcut stays sound, just not complete.
2242 let inside_inner = matches!(
2243 crate::classify::classify_point(topo, inner, c, defl, tol.linear),
2244 Ok(crate::classify::PointClassification::Inside)
2245 );
2246 let outside_outer = matches!(
2247 crate::classify::classify_point(topo, outer, c, defl, tol.linear),
2248 Ok(crate::classify::PointClassification::Outside)
2249 );
2250 inside_inner && outside_outer
2251 };
2252
2253 // Bidirectional vertex check via the analytic classifier — the
2254 // primary signal for identical/containment classification. A vertex
2255 // classifying as inside-or-on (None within tolerance band counts
2256 // as on) means it sits within the solid's region.
2257 let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2258 let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2259 let aabbs_match = aabb_a
2260 .zip(aabb_b)
2261 .map(|((a_min, a_max), (b_min, b_max))| {
2262 let eps = tol.linear;
2263 (a_min.x() - b_min.x()).abs() < eps
2264 && (a_min.y() - b_min.y()).abs() < eps
2265 && (a_min.z() - b_min.z()).abs() < eps
2266 && (a_max.x() - b_max.x()).abs() < eps
2267 && (a_max.y() - b_max.y()).abs() < eps
2268 && (a_max.z() - b_max.z()).abs() < eps
2269 })
2270 .unwrap_or(false);
2271
2272 // Containment: A contains B when all B vertices are inside-or-on A AND
2273 // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2274 // containing solid has no classifier — the strict check requires ≥10%
2275 // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2276 // fuse of two disjoint boxes) don't false-positive as "contains another
2277 // solid".
2278 // Both the analytic-classifier term and the AABB-only fallback can
2279 // false-positive when the container is non-convex: the analytic
2280 // classifier may mis-report notch points as inside-or-on, and an
2281 // AABB encloses a notch's empty volume. Guard the whole determination
2282 // with the `center_outside` witness — sound for every path because it
2283 // only fires on proven non-containment (see the lemma above).
2284 let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2285 || (ca.is_none() && aabb_strictly_contains(&aabb_b, &aabb_a)))
2286 && !center_outside(topo, b, a, &aabb_b);
2287 let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2288 || (cb.is_none() && aabb_strictly_contains(&aabb_a, &aabb_b)))
2289 && !center_outside(topo, a, b, &aabb_a);
2290
2291 TrivialRelation {
2292 identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2293 a_in_b,
2294 b_in_a,
2295 }
2296}
2297
2298/// Check whether every boundary vertex of `solid` is classified as
2299/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2300/// to distinguish truly-identical solids from co-located but differently
2301/// shaped solids (e.g., a cone and a box that share an AABB).
2302fn all_vertices_inside_or_on(
2303 topo: &Topology,
2304 solid: SolidId,
2305 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2306 tol: brepkit_math::tolerance::Tolerance,
2307) -> bool {
2308 let Ok(s) = topo.solid(solid) else {
2309 return false;
2310 };
2311 let Ok(sh) = topo.shell(s.outer_shell()) else {
2312 return false;
2313 };
2314 for &fid in sh.faces() {
2315 let Ok(f) = topo.face(fid) else { return false };
2316 let Ok(w) = topo.wire(f.outer_wire()) else {
2317 return false;
2318 };
2319 for oe in w.edges() {
2320 let Ok(e) = topo.edge(oe.edge()) else {
2321 return false;
2322 };
2323 for vid in [e.start(), e.end()] {
2324 let Ok(v) = topo.vertex(vid) else {
2325 return false;
2326 };
2327 // The analytic classifier returns `None` for points within
2328 // tol.linear of the boundary — treat as "on" for this check.
2329 if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2330 return false;
2331 }
2332 }
2333 }
2334 }
2335 true
2336}
2337
2338/// True when every outer-shell vertex of `inner` classifies as *strictly*
2339/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2340/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2341/// internal cavity rather than a notch through the boundary.
2342fn solid_strictly_inside(
2343 topo: &Topology,
2344 inner: SolidId,
2345 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2346 tol: brepkit_math::tolerance::Tolerance,
2347) -> bool {
2348 let Ok(s) = topo.solid(inner) else {
2349 return false;
2350 };
2351 let Ok(sh) = topo.shell(s.outer_shell()) else {
2352 return false;
2353 };
2354 let mut saw_vertex = false;
2355 for &fid in sh.faces() {
2356 let Ok(f) = topo.face(fid) else { return false };
2357 // Check the outer wire and any inner (hole) wires — a hole boundary on
2358 // a simple solid's face can also reach the blank's surface.
2359 let mut wires = vec![f.outer_wire()];
2360 wires.extend_from_slice(f.inner_wires());
2361 for wid in wires {
2362 let Ok(w) = topo.wire(wid) else {
2363 return false;
2364 };
2365 for oe in w.edges() {
2366 let Ok(e) = topo.edge(oe.edge()) else {
2367 return false;
2368 };
2369 for vid in [e.start(), e.end()] {
2370 let Ok(v) = topo.vertex(vid) else {
2371 return false;
2372 };
2373 if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2374 {
2375 return false;
2376 }
2377 saw_vertex = true;
2378 }
2379 }
2380 }
2381 }
2382 saw_vertex
2383}
2384
2385/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2386/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2387/// blank and the tool, reverses every copied tool face in place so the cavity
2388/// boundary faces into the void, and attaches the reversed tool shell to the
2389/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2390/// drops fully-contained cone/torus tools.
2391fn build_contained_cut_hollow(
2392 topo: &mut Topology,
2393 blank: SolidId,
2394 tool: SolidId,
2395) -> Result<SolidId, crate::OperationsError> {
2396 let result = crate::copy::copy_solid(topo, blank)?;
2397
2398 // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2399 // vertices between adjacent faces (a per-face copy would duplicate shared
2400 // boundary edges and leave the cavity non-manifold — wrong Euler, though
2401 // per-face volume is unaffected). Reverse each copied face in place and
2402 // reuse the copied outer shell directly as the cavity inner shell, so no
2403 // duplicate faces or extra result solid are created.
2404 let tool_copy = crate::copy::copy_solid(topo, tool)?;
2405 let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2406 let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2407 for fid in cavity_faces {
2408 let face = topo.face_mut(fid)?;
2409 let flipped = !face.is_reversed();
2410 face.set_reversed(flipped);
2411 }
2412 topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2413 Ok(result)
2414}
2415
2416/// Best-effort mesh boolean fallback for high face-count solids.
2417///
2418/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2419/// and applies the same post-processing as the other boolean paths.
2420/// Returns `Err` on any failure so the caller can fall through to the
2421/// chord-based path.
2422fn mesh_boolean_fallback(
2423 topo: &mut Topology,
2424 op: BooleanOp,
2425 a: SolidId,
2426 b: SolidId,
2427 deflection: f64,
2428 tol: brepkit_math::tolerance::Tolerance,
2429 opts: &BooleanOptions,
2430) -> Result<SolidId, crate::OperationsError> {
2431 // Mesh density here is a boolean-robustness concern, independent of the
2432 // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2433 // the face count is unaffected by the display deflection cap, AND keep the
2434 // circle curvature floor so co-refinement gets the denser circular sampling
2435 // it needs (display tessellation drops that floor for triangle count).
2436 let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2437 let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2438 log::debug!(
2439 "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2440 mesh_a.indices.len() / 3,
2441 mesh_b.indices.len() / 3,
2442 );
2443
2444 let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2445 if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2446 log::warn!(
2447 "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2448 ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2449 downstream healing may not recover; exported geometry may be broken",
2450 mb_result.boundary_edge_count,
2451 mb_result.non_manifold_edge_count,
2452 );
2453 }
2454 let face_specs = mesh_result_to_face_specs(&mb_result);
2455 if face_specs.is_empty() {
2456 return Err(crate::OperationsError::EmptyResult {
2457 reason: "mesh boolean produced no output faces".into(),
2458 });
2459 }
2460 log::debug!(
2461 "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2462 face_specs.len()
2463 );
2464 let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2465 let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2466 if opts.unify_faces {
2467 let _ = crate::heal::unify_faces(topo, result)?;
2468 }
2469 // Cross-face symmetrization: tessellation diagonals that one face
2470 // dropped while its neighbour kept (#696) leave structurally
2471 // orphan collinear interior wire vertices. Collapse those so both
2472 // sides reference the same EdgeId for the shared 3D segment,
2473 // eliminating the residual non-manifold edges that `unify_faces`
2474 // can't symmetrize from per-face surface matching alone.
2475 let collapsed =
2476 brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2477 topo, result, tol,
2478 )
2479 .unwrap_or_else(|e| {
2480 log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2481 0
2482 });
2483 if collapsed > 0 {
2484 log::info!(
2485 "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2486 );
2487 }
2488 // Mesh-fallback can glue two physically-separate holes into a
2489 // single figure-8 inner wire via diagonal "bridge" edges across
2490 // gap material (#696 cumulative pattern: a slab top with multiple
2491 // pocket cuts ends up with one self-intersecting inner wire that
2492 // visits each pocket region). Split such wires at every pinch
2493 // vertex so each physical hole is its own simple inner wire —
2494 // the resulting topology is well-formed for downstream
2495 // tessellation, validation, and STEP export, even when the
2496 // bridge edges themselves remain as boundary edges (those are a
2497 // separate cleanup).
2498 let wires_split =
2499 brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2500 topo, result,
2501 )
2502 .unwrap_or_else(|e| {
2503 log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2504 0
2505 });
2506 if wires_split > 0 {
2507 log::info!(
2508 "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2509 );
2510 }
2511 if opts.heal_after_boolean {
2512 let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2513 }
2514 assembly::validate_boolean_result_lenient(topo, result)?;
2515 log::info!(
2516 "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2517 result.index(),
2518 face_specs.len()
2519 );
2520 Ok(result)
2521}
2522
2523/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2524fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2525 let mut specs = Vec::new();
2526 for tri in result.mesh.indices.chunks_exact(3) {
2527 let v0 = result.mesh.positions[tri[0] as usize];
2528 let v1 = result.mesh.positions[tri[1] as usize];
2529 let v2 = result.mesh.positions[tri[2] as usize];
2530
2531 let edge1 = v1 - v0;
2532 let edge2 = v2 - v0;
2533 let Ok(normal) = edge1.cross(edge2).normalize() else {
2534 continue;
2535 };
2536 let d = crate::dot_normal_point(normal, v0);
2537 specs.push(FaceSpec::Planar {
2538 vertices: vec![v0, v1, v2],
2539 normal,
2540 d,
2541 inner_wires: vec![],
2542 });
2543 }
2544 specs
2545}
2546
2547/// True when the outer-shell face components represent disjoint solid
2548/// pieces (e.g., a previous cut split one solid into N parts), false
2549/// when one component is concentric inside another (a hollow solid:
2550/// outer surface + cavity surface both live in the outer shell).
2551///
2552/// The check is AABB-based: if any component's bounding box is
2553/// strictly contained in another's, treat the whole solid as hollow
2554/// and skip the multi-region split path.
2555/// Check that every component's AABB centre classifies as outside the
2556/// supplied classifier. Used to reject multi-region GFA Cut results that
2557/// erroneously include the tool's interior as one of the pieces.
2558fn all_component_centers_outside(
2559 topo: &Topology,
2560 components: &[Vec<FaceId>],
2561 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2562 tol: brepkit_math::tolerance::Tolerance,
2563) -> bool {
2564 use brepkit_algo::FaceClass;
2565 for comp in components {
2566 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2567 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2568 for &fid in comp {
2569 let Ok(face) = topo.face(fid) else { continue };
2570 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2571 {
2572 let Ok(wire) = topo.wire(wid) else { continue };
2573 for oe in wire.edges() {
2574 let Ok(edge) = topo.edge(oe.edge()) else {
2575 continue;
2576 };
2577 for vid in [edge.start(), edge.end()] {
2578 if let Ok(v) = topo.vertex(vid) {
2579 let p = v.point();
2580 min = Point3::new(
2581 min.x().min(p.x()),
2582 min.y().min(p.y()),
2583 min.z().min(p.z()),
2584 );
2585 max = Point3::new(
2586 max.x().max(p.x()),
2587 max.y().max(p.y()),
2588 max.z().max(p.z()),
2589 );
2590 }
2591 }
2592 }
2593 }
2594 }
2595 let centre = Point3::new(
2596 (min.x() + max.x()) * 0.5,
2597 (min.y() + max.y()) * 0.5,
2598 (min.z() + max.z()) * 0.5,
2599 );
2600 if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2601 return false;
2602 }
2603 }
2604 true
2605}
2606
2607/// Centre of a face component's vertex AABB, or `None` for an empty component.
2608fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2609 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2610 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2611 for &fid in comp {
2612 let Ok(face) = topo.face(fid) else { continue };
2613 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2614 let Ok(wire) = topo.wire(wid) else { continue };
2615 for oe in wire.edges() {
2616 let Ok(edge) = topo.edge(oe.edge()) else {
2617 continue;
2618 };
2619 for vid in [edge.start(), edge.end()] {
2620 if let Ok(v) = topo.vertex(vid) {
2621 let p = v.point();
2622 min =
2623 Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2624 max =
2625 Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2626 }
2627 }
2628 }
2629 }
2630 }
2631 if min.x() > max.x() {
2632 return None;
2633 }
2634 Some(Point3::new(
2635 (min.x() + max.x()) * 0.5,
2636 (min.y() + max.y()) * 0.5,
2637 (min.z() + max.z()) * 0.5,
2638 ))
2639}
2640
2641/// Does the closed surface made of `faces` enclose `p`?
2642///
2643/// Ray-parity against the component's own tessellation. Read-only by design:
2644/// building a temporary solid per component would add entities to an arena that
2645/// never reclaims, which is the growth cliff fixed in #1237.
2646///
2647/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2648/// so parity is meaningful across face boundaries. The direction is deliberately
2649/// irrational so the ray does not graze a face boundary or lie in a face plane —
2650/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2651/// intersections these pieces are full of. Returns `None` when the component
2652/// cannot be tessellated, so callers can fall back rather than guess.
2653fn component_encloses_point(
2654 topo: &Topology,
2655 faces: &[FaceId],
2656 p: Point3,
2657 deflection: f64,
2658) -> Option<bool> {
2659 // A sqrt-prime direction: irrational in every component, so the ray cannot
2660 // lie in a face plane or run along an edge — the same generic-direction
2661 // escape the ray-cast classifier uses for degenerate probes.
2662 let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2663 .normalize()
2664 .ok()?;
2665 let mut crossings = 0usize;
2666 let mut any_triangle = false;
2667 for &fid in faces {
2668 let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2669 let pos = &mesh.mesh.positions;
2670 for tri in mesh.mesh.indices.chunks_exact(3) {
2671 let (a, b, c) = (
2672 pos[tri[0] as usize],
2673 pos[tri[1] as usize],
2674 pos[tri[2] as usize],
2675 );
2676 any_triangle = true;
2677 if let Some(hit) =
2678 brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2679 && hit.t > 1e-9
2680 {
2681 crossings += 1;
2682 }
2683 }
2684 }
2685 any_triangle.then_some(crossings % 2 == 1)
2686}
2687
2688/// Any vertex position on `faces`, for use as a probe point.
2689fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2690 for &fid in faces {
2691 let face = topo.face(fid).ok()?;
2692 let wire = topo.wire(face.outer_wire()).ok()?;
2693 if let Some(oe) = wire.edges().first()
2694 && let Ok(edge) = topo.edge(oe.edge())
2695 && let Ok(v) = topo.vertex(edge.start())
2696 {
2697 return Some(v.point());
2698 }
2699 }
2700 None
2701}
2702
2703fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2704 let aabbs: Vec<(Point3, Point3)> = components
2705 .iter()
2706 .map(|comp| {
2707 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2708 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2709 for &fid in comp {
2710 let Ok(face) = topo.face(fid) else {
2711 continue;
2712 };
2713 for wid in
2714 std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2715 {
2716 let Ok(wire) = topo.wire(wid) else {
2717 continue;
2718 };
2719 for oe in wire.edges() {
2720 let Ok(edge) = topo.edge(oe.edge()) else {
2721 continue;
2722 };
2723 for vid in [edge.start(), edge.end()] {
2724 if let Ok(v) = topo.vertex(vid) {
2725 let p = v.point();
2726 min = Point3::new(
2727 min.x().min(p.x()),
2728 min.y().min(p.y()),
2729 min.z().min(p.z()),
2730 );
2731 max = Point3::new(
2732 max.x().max(p.x()),
2733 max.y().max(p.y()),
2734 max.z().max(p.z()),
2735 );
2736 }
2737 }
2738 }
2739 }
2740 }
2741 (min, max)
2742 })
2743 .collect();
2744
2745 // Reject NESTING, not mere AABB overlap.
2746 //
2747 // Nesting is the hazard worth rejecting (a blob sitting inside another
2748 // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2749 // what multi-region acceptance is for.
2750 //
2751 // Assume nothing about the components handed in. The acceptance gate calls
2752 // this on a GFA result that has cleared only `euler_balanced` — which is
2753 // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2754 // in the same `&&` chain, not a precondition. The input-splitting paths
2755 // call it on components of a raw operand no gate has examined at all. So
2756 // "every piece is a closed manifold, hence disjoint-or-nested" is not
2757 // available here; the ray-parity confirmation below earns the answer
2758 // instead of inferring it.
2759 //
2760 // Overlap is the wrong predicate for that, because an AABB is only tight on
2761 // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2762 // the whole diagonal envelope, so their boxes interpenetrate and an
2763 // overlap test calls them touching — which is why a kumiko lattice cut,
2764 // whose members are diagonal, could never be accepted and fell back to the
2765 // mesh path on every band (see
2766 // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2767 // is tight in the direction that matters: nesting implies it, and rotation
2768 // does not manufacture it.
2769 let eps = 1e-7;
2770 let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2771 o_min.x() - eps <= i_min.x()
2772 && o_min.y() - eps <= i_min.y()
2773 && o_min.z() - eps <= i_min.z()
2774 && o_max.x() + eps >= i_max.x()
2775 && o_max.y() + eps >= i_max.y()
2776 && o_max.z() + eps >= i_max.z()
2777 };
2778 // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2779 // far from sufficient: a ring's box contains the box of a separate piece
2780 // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2781 // gets a real ray-parity test against the enclosing candidate's own surface,
2782 // and only genuine enclosure rejects. If the probe cannot be evaluated the
2783 // pair falls back to the conservative answer.
2784 for i in 0..aabbs.len() {
2785 for j in (i + 1)..aabbs.len() {
2786 let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2787 (i, j)
2788 } else if contains(aabbs[j], aabbs[i]) {
2789 (j, i)
2790 } else {
2791 continue;
2792 };
2793 let (o_min, o_max) = aabbs[outer];
2794 let diag = ((o_max.x() - o_min.x()).powi(2)
2795 + (o_max.y() - o_min.y()).powi(2)
2796 + (o_max.z() - o_min.z()).powi(2))
2797 .sqrt();
2798 let deflection = (diag / 200.0).max(1e-4);
2799 let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2800 return false;
2801 };
2802 match component_encloses_point(topo, &components[outer], probe, deflection) {
2803 Some(true) => return false,
2804 Some(false) => {}
2805 None => return false,
2806 }
2807 }
2808 }
2809 true
2810}
2811
2812/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2813/// target one at a time.
2814///
2815/// Each piece is copied into a fresh connected solid (the pavefiller
2816/// stumbles on shared vertex IDs across what it considers one "solid B")
2817/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2818/// analytic path, gates, and fallbacks. Fuse distributes over a
2819/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2820/// piece is single-component, so the recursive call never re-enters this
2821/// path.
2822fn fuse_multi_component_tool(
2823 topo: &mut Topology,
2824 a: SolidId,
2825 b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2826) -> Result<SolidId, crate::OperationsError> {
2827 let mut result = a;
2828 for comp_faces in b_components {
2829 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2830 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2831 result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2832 }
2833 Ok(result)
2834}
2835
2836/// Cut a multi-region input solid: split the components, cut each
2837/// against `b` independently, then combine the per-component results
2838/// back into a single multi-region solid.
2839///
2840/// This works around the GFA pavefiller's assumption of a single
2841/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2842/// at a time as the cut proceeds (Category B `multiple cuts creating
2843/// three pieces` and gear bore are both downstream of this).
2844fn cut_multi_region_input(
2845 topo: &mut Topology,
2846 a: SolidId,
2847 b: SolidId,
2848 comp_count: usize,
2849) -> Result<SolidId, crate::OperationsError> {
2850 let components = crate::boolean::assembly::face_components(topo, a);
2851 debug_assert_eq!(components.len(), comp_count);
2852
2853 let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2854 for comp_faces in components {
2855 // Copy the component's faces into a fresh single-component solid
2856 // so the boolean engine sees a connected manifold.
2857 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2858 // Deep-copy the component into a fresh solid so its faces/edges/
2859 // vertices have fresh IDs disjoint from the original multi-region
2860 // input — GFA's pavefiller can stumble on shared vertex IDs across
2861 // what it considers a single "solid A".
2862 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2863 match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2864 Ok(r) => per_component_results.push(r),
2865 Err(
2866 crate::OperationsError::EmptyResult { .. }
2867 | crate::OperationsError::InvalidInput { .. },
2868 ) => {
2869 per_component_results.push(comp_solid);
2870 }
2871 Err(e) => return Err(e),
2872 }
2873 }
2874
2875 // Combine all per-component results into a single multi-region solid.
2876 // Collect every face from every result into one outer shell. The
2877 // results are pairwise disjoint by construction (each came from a
2878 // disjoint input component cut by the same tool), so a single shell
2879 // containing all their faces is a valid manifold representation.
2880 let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2881 for &r in &per_component_results {
2882 let r_data = topo.solid(r)?;
2883 for &fid in topo.shell(r_data.outer_shell())?.faces() {
2884 all_faces.push(fid);
2885 }
2886 }
2887 make_solid_from_face_subset(topo, &all_faces)
2888}
2889
2890/// Build a new solid whose outer shell consists exactly of the given
2891/// faces. Faces are referenced as-is (no copying) — the caller is
2892/// expected to pass faces that already form a closed manifold.
2893///
2894/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2895/// created with the surface normal negated, the wires reversed, and
2896/// `reversed=false`. Boolean operations downstream are sensitive to the
2897/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2898/// re-process cleanly even via deep-copy), so handing GFA an
2899/// orientation-normalized solid recovers the fresh-primitive code path.
2900fn make_solid_from_face_subset(
2901 topo: &mut Topology,
2902 faces: &[brepkit_topology::face::FaceId],
2903) -> Result<SolidId, crate::OperationsError> {
2904 use brepkit_topology::face::{Face, FaceSurface};
2905 use brepkit_topology::wire::{OrientedEdge, Wire};
2906
2907 let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2908 for &fid in faces {
2909 let face = topo.face(fid)?;
2910 if !face.is_reversed() {
2911 normalized.push(fid);
2912 continue;
2913 }
2914 // Only Plane has a trivial negate-the-normal flip. Non-planar
2915 // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
2916 // their surface negated cheaply — they hit surface-specific GFA
2917 // paths that don't suffer from the same reversed-flag sensitivity.
2918 // Exhaustive match so a new FaceSurface variant fails to compile
2919 // rather than silently passing through un-normalized.
2920 let flipped_surface = match face.surface() {
2921 FaceSurface::Plane { normal, d } => FaceSurface::Plane {
2922 normal: -*normal,
2923 d: -*d,
2924 },
2925 FaceSurface::Nurbs(_)
2926 | FaceSurface::Cylinder(_)
2927 | FaceSurface::Cone(_)
2928 | FaceSurface::Sphere(_)
2929 | FaceSurface::Torus(_) => {
2930 normalized.push(fid);
2931 continue;
2932 }
2933 };
2934 let outer_wid = face.outer_wire();
2935 let inner_wids: Vec<_> = face.inner_wires().to_vec();
2936 let outer_wire = topo.wire(outer_wid)?;
2937 let outer_reversed: Vec<OrientedEdge> = outer_wire
2938 .edges()
2939 .iter()
2940 .rev()
2941 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2942 .collect();
2943 let new_outer_wire =
2944 Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
2945 let new_outer_wid = topo.add_wire(new_outer_wire);
2946 let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
2947 for iw in &inner_wids {
2948 let w = topo.wire(*iw)?;
2949 let rev: Vec<OrientedEdge> = w
2950 .edges()
2951 .iter()
2952 .rev()
2953 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2954 .collect();
2955 let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
2956 new_inner_wids.push(topo.add_wire(new_w));
2957 }
2958 let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
2959 normalized.push(topo.add_face(new_face));
2960 }
2961
2962 let shell = brepkit_topology::shell::Shell::new(normalized)
2963 .map_err(crate::OperationsError::Topology)?;
2964 let shell_id = topo.add_shell(shell);
2965 let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
2966 Ok(topo.add_solid(solid))
2967}
2968
2969/// Count inner wire loops across all faces of a solid (outer + inner shells).
2970fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
2971 let mut count: i64 = 0;
2972 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
2973 let face = topo.face(fid)?;
2974 #[allow(clippy::cast_possible_wrap)]
2975 {
2976 count += face.inner_wires().len() as i64;
2977 }
2978 }
2979 Ok(count)
2980}
2981
2982/// Genus-aware Euler balance for `components` closed orientable surfaces with
2983/// holed faces.
2984///
2985/// Euler-Poincare over `C` closed components of total genus `G`:
2986/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
2987/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
2988/// by two per unit of genus (a thin wall pierced by N through-holes has genus
2989/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
2990///
2991/// The `2C` bound matters as much as the parity: a multi-region result is not
2992/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
2993/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
2994/// exactly rejected every lattice result and forced it onto the mesh path.
2995///
2996/// Callers must pair this with a closed-manifold check — the relation only holds
2997/// for closed surfaces.
2998const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
2999 let surplus = euler - inner_wires;
3000 surplus <= components.saturating_mul(2) && surplus % 2 == 0
3001}
3002
3003/// Count edge uses across ALL shells of a solid (outer + inner cavity
3004/// shells). Hollow solids keep cavity faces in inner shells — an
3005/// outer-shell-only walk silently misses their edges, letting open or
3006/// non-manifold cavity shells pass the acceptance gates.
3007fn solid_edge_use_counts(
3008 topo: &Topology,
3009 solid: SolidId,
3010) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3011 let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3012 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3013 let face = topo.face(fid)?;
3014 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3015 let wire = topo.wire(wid)?;
3016 for oe in wire.edges() {
3017 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3018 }
3019 }
3020 }
3021 Ok(counts)
3022}
3023
3024/// Check whether every shell of a solid is a closed manifold: every edge
3025/// is shared by exactly 2 faces within its shell. Returns `false` for open
3026/// shells (boundary edges with count == 1) and non-manifold shells
3027/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3028/// each shell is an independent closed surface, so a single pooled count
3029/// per shell is correct.
3030///
3031/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3032/// which only rejects edges shared by *more* than two faces.
3033fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3034 let s = topo.solid(solid)?;
3035 let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3036 .chain(s.inner_shells().iter().copied())
3037 .collect();
3038 for shell_id in shell_ids {
3039 let shell = topo.shell(shell_id)?;
3040 if !shell_is_closed_manifold(topo, shell)? {
3041 return Ok(false);
3042 }
3043 }
3044 Ok(true)
3045}
3046
3047fn shell_is_closed_manifold(
3048 topo: &Topology,
3049 shell: &brepkit_topology::shell::Shell,
3050) -> Result<bool, crate::OperationsError> {
3051 use std::collections::HashMap;
3052
3053 let mut counts: HashMap<usize, usize> = HashMap::new();
3054 for &fid in shell.faces() {
3055 let face = topo.face(fid)?;
3056 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3057 let wire = topo.wire(wid)?;
3058 for oe in wire.edges() {
3059 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3060 }
3061 }
3062 }
3063 if counts.is_empty() {
3064 return Ok(false);
3065 }
3066 Ok(counts.values().all(|&c| c == 2))
3067}
3068
3069/// Check whether a solid's boundary has free edges: edges used by only
3070/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3071/// membrane face left a circle edge unmatched), which is never a valid
3072/// boolean result even when Euler accidentally balances.
3073fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3074 let counts = solid_edge_use_counts(topo, solid)?;
3075 Ok(counts.values().any(|&c| c == 1))
3076}
3077
3078/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3079/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3080/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3081/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3082/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3083///
3084/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3085/// so the gate and the pass agree: a looser default here could report "nothing
3086/// to flatten" while the pass (run at the operation tolerance) would in fact
3087/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3088fn solid_has_flattenable_nurbs(
3089 topo: &Topology,
3090 solid: SolidId,
3091 tol: f64,
3092) -> Result<bool, crate::OperationsError> {
3093 use brepkit_geometry::convert::{
3094 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3095 };
3096 use brepkit_topology::edge::EdgeCurve;
3097 use brepkit_topology::explorer::solid_faces;
3098
3099 let mut seen = std::collections::HashSet::new();
3100 for fid in solid_faces(topo, solid)? {
3101 let face = topo.face(fid)?;
3102 if let FaceSurface::Nurbs(nurbs) = face.surface()
3103 && matches!(
3104 recognize_surface(nurbs, tol),
3105 RecognizedSurface::Plane { .. }
3106 )
3107 {
3108 return Ok(true);
3109 }
3110 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3111 let wire = topo.wire(wid)?;
3112 for oe in wire.edges() {
3113 let eid = oe.edge();
3114 if !seen.insert(eid.index()) {
3115 continue;
3116 }
3117 if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3118 && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3119 {
3120 return Ok(true);
3121 }
3122 }
3123 }
3124 }
3125 Ok(false)
3126}
3127
3128/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3129/// straight NURBS boundary edges with the `Line` variant.
3130///
3131/// A NURBS surface whose every control point lies within `tol` of a single
3132/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3133/// straight cavity walls as planar B-splines, and the boolean engine's
3134/// face-face intersections only take the exact (same-domain) plane×plane path
3135/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3136/// planes before the boolean lets coincident/abutting wall regions merge
3137/// analytically instead of fragmenting through the NURBS surface-intersection
3138/// path.
3139///
3140/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3141/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3142/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3143/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3144/// face to a self-crossing trace. Recognising those straight NURBS edges as
3145/// `Line` lets the arrangement split them exactly.
3146///
3147/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3148/// left untouched. Returns the number of faces flattened.
3149fn flatten_planar_nurbs_faces(
3150 topo: &mut Topology,
3151 solid: SolidId,
3152 tol: f64,
3153) -> Result<usize, crate::OperationsError> {
3154 use brepkit_geometry::convert::{
3155 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3156 };
3157 use brepkit_topology::edge::{EdgeCurve, EdgeId};
3158 use brepkit_topology::explorer::solid_faces;
3159
3160 let face_ids = solid_faces(topo, solid)?;
3161 // Snapshot the surfaces first (immutable borrow), then mutate.
3162 let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3163 .iter()
3164 .filter_map(|&fid| {
3165 let face = topo.face(fid).ok()?;
3166 let FaceSurface::Nurbs(nurbs) = face.surface() else {
3167 return None;
3168 };
3169 match recognize_surface(nurbs, tol) {
3170 RecognizedSurface::Plane { normal, d } => {
3171 // `recognize_surface` derives the plane normal from a
3172 // control-point cross product, whose sign can OPPOSE the
3173 // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3174 // is read with its normal flipped by `is_reversed`, so an
3175 // opposed sign silently inverts the face's effective
3176 // outward direction. Align to the surface du×dv normal at
3177 // the domain midpoint.
3178 let (u0, u1) = nurbs.domain_u();
3179 let (v0, v1) = nurbs.domain_v();
3180 let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3181 let (normal, d) = match mid_n {
3182 Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3183 _ => (normal, d),
3184 };
3185 Some((fid, normal, d))
3186 }
3187 _ => None,
3188 }
3189 })
3190 .collect();
3191 let count = planar.len();
3192 for (fid, normal, d) in planar {
3193 topo.face_mut(fid)?
3194 .set_surface(FaceSurface::Plane { normal, d });
3195 }
3196
3197 // Straighten NURBS edges that are geometrically lines.
3198 let mut straight_edges: Vec<EdgeId> = Vec::new();
3199 let mut seen = std::collections::HashSet::new();
3200 for &fid in &face_ids {
3201 let face = topo.face(fid)?;
3202 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3203 let wire = topo.wire(wid)?;
3204 for oe in wire.edges() {
3205 let eid = oe.edge();
3206 if !seen.insert(eid.index()) {
3207 continue;
3208 }
3209 let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3210 continue;
3211 };
3212 if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3213 straight_edges.push(eid);
3214 }
3215 }
3216 }
3217 }
3218 for eid in straight_edges {
3219 topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3220 }
3221
3222 Ok(count)
3223}
3224
3225/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3226/// reproduce the exact operand preprocessing the boolean applies before handing
3227/// the operands to the GFA engine.
3228#[doc(hidden)]
3229pub fn flatten_planar_nurbs_faces_for_tests(
3230 topo: &mut Topology,
3231 solid: SolidId,
3232 tol: f64,
3233) -> Result<usize, crate::OperationsError> {
3234 flatten_planar_nurbs_faces(topo, solid, tol)
3235}
3236
3237/// For each vertex position (quantized at tolerance), picks one canonical
3238/// vertex. Rebuilds all edges and wires to use canonical vertices.
3239/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3240/// input solids that may share edge topology.
3241#[allow(clippy::items_after_statements, clippy::type_complexity)]
3242fn merge_result_vertices(
3243 topo: &mut Topology,
3244 solid: SolidId,
3245 tol: brepkit_math::tolerance::Tolerance,
3246) -> Result<(), crate::OperationsError> {
3247 use std::collections::{BTreeMap, HashMap};
3248
3249 let shell_id = topo.solid(solid)?.outer_shell();
3250 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3251
3252 let scale = 1.0 / tol.linear;
3253 let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3254 (
3255 (p.x() * scale).round() as i64,
3256 (p.y() * scale).round() as i64,
3257 (p.z() * scale).round() as i64,
3258 )
3259 };
3260
3261 // Build vertex canonical map: position → first VertexId seen
3262 let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3263 BTreeMap::new();
3264 let mut replacements: HashMap<
3265 brepkit_topology::vertex::VertexId,
3266 brepkit_topology::vertex::VertexId,
3267 > = HashMap::new();
3268
3269 for &fid in &face_ids {
3270 let face = topo.face(fid)?;
3271 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3272 let wire = topo.wire(wid)?;
3273 for oe in wire.edges() {
3274 let edge = topo.edge(oe.edge())?;
3275 for vid in [edge.start(), edge.end()] {
3276 let pos = topo.vertex(vid)?.point();
3277 let key = quantize(pos);
3278 let canon = *canonical.entry(key).or_insert(vid);
3279 if canon != vid {
3280 replacements.insert(vid, canon);
3281 }
3282 }
3283 }
3284 }
3285 }
3286
3287 if replacements.is_empty() {
3288 return Ok(());
3289 }
3290
3291 // Rebuild faces with merged vertices
3292 // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3293 let mut edge_cache: HashMap<
3294 (
3295 brepkit_topology::edge::EdgeId,
3296 brepkit_topology::vertex::VertexId,
3297 brepkit_topology::vertex::VertexId,
3298 ),
3299 brepkit_topology::edge::EdgeId,
3300 > = HashMap::new();
3301
3302 // Snapshot face data, then rebuild with merged vertices
3303 struct FaceSnap {
3304 surface: brepkit_topology::face::FaceSurface,
3305 reversed: bool,
3306 outer_oes: Vec<(
3307 brepkit_topology::edge::EdgeId,
3308 bool,
3309 brepkit_topology::edge::EdgeCurve,
3310 brepkit_topology::vertex::VertexId,
3311 brepkit_topology::vertex::VertexId,
3312 Option<f64>, // edge tolerance
3313 )>,
3314 outer_closed: bool,
3315 inner_wires: Vec<(
3316 Vec<(
3317 brepkit_topology::edge::EdgeId,
3318 bool,
3319 brepkit_topology::edge::EdgeCurve,
3320 brepkit_topology::vertex::VertexId,
3321 brepkit_topology::vertex::VertexId,
3322 Option<f64>,
3323 )>,
3324 bool, // wire closed flag
3325 )>,
3326 }
3327
3328 let mut snaps = Vec::with_capacity(face_ids.len());
3329 for &fid in &face_ids {
3330 let face = topo.face(fid)?;
3331 let surface = face.surface().clone();
3332 let reversed = face.is_reversed();
3333 let outer_wire = topo.wire(face.outer_wire())?;
3334 let outer_closed = outer_wire.is_closed();
3335 let outer_oes: Vec<_> = outer_wire
3336 .edges()
3337 .iter()
3338 .map(|oe| -> Result<_, crate::OperationsError> {
3339 let e = topo.edge(oe.edge())?;
3340 Ok((
3341 oe.edge(),
3342 oe.is_forward(),
3343 e.curve().clone(),
3344 e.start(),
3345 e.end(),
3346 e.tolerance(),
3347 ))
3348 })
3349 .collect::<Result<_, _>>()?;
3350 let inner_wids = face.inner_wires().to_vec();
3351 let mut inner_wires = Vec::new();
3352 for iw in inner_wids {
3353 let w = topo.wire(iw)?;
3354 let closed = w.is_closed();
3355 let oes: Vec<_> = w
3356 .edges()
3357 .iter()
3358 .map(|oe| -> Result<_, crate::OperationsError> {
3359 let e = topo.edge(oe.edge())?;
3360 Ok((
3361 oe.edge(),
3362 oe.is_forward(),
3363 e.curve().clone(),
3364 e.start(),
3365 e.end(),
3366 e.tolerance(),
3367 ))
3368 })
3369 .collect::<Result<_, _>>()?;
3370 inner_wires.push((oes, closed));
3371 }
3372 snaps.push(FaceSnap {
3373 surface,
3374 reversed,
3375 outer_oes,
3376 outer_closed,
3377 inner_wires,
3378 });
3379 }
3380
3381 #[allow(clippy::type_complexity)]
3382 let remap_oes = |oes: &[(
3383 brepkit_topology::edge::EdgeId,
3384 bool,
3385 brepkit_topology::edge::EdgeCurve,
3386 brepkit_topology::vertex::VertexId,
3387 brepkit_topology::vertex::VertexId,
3388 Option<f64>,
3389 )],
3390 replacements: &HashMap<
3391 brepkit_topology::vertex::VertexId,
3392 brepkit_topology::vertex::VertexId,
3393 >,
3394 edge_cache: &mut HashMap<
3395 (
3396 brepkit_topology::edge::EdgeId,
3397 brepkit_topology::vertex::VertexId,
3398 brepkit_topology::vertex::VertexId,
3399 ),
3400 brepkit_topology::edge::EdgeId,
3401 >,
3402 topo: &mut Topology|
3403 -> Vec<brepkit_topology::wire::OrientedEdge> {
3404 oes.iter()
3405 .map(|(eid, fwd, curve, start, end, edge_tol)| {
3406 let ns = replacements.get(start).copied().unwrap_or(*start);
3407 let ne = replacements.get(end).copied().unwrap_or(*end);
3408 if ns == *start && ne == *end {
3409 return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3410 }
3411 let key = (*eid, ns, ne);
3412 let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3413 topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3414 ns,
3415 ne,
3416 curve.clone(),
3417 *edge_tol,
3418 ))
3419 });
3420 brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3421 })
3422 .collect()
3423 };
3424
3425 let mut new_face_ids = Vec::with_capacity(snaps.len());
3426 for snap in &snaps {
3427 let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3428 let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3429 // Wire rebuild failed — keep the original face unchanged
3430 // rather than silently dropping it
3431 continue;
3432 };
3433 let outer_id = topo.add_wire(outer_wire);
3434
3435 let mut inner_ids = Vec::new();
3436 for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3437 let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3438 if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3439 inner_ids.push(topo.add_wire(w));
3440 }
3441 }
3442
3443 let mut new_face =
3444 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3445 if snap.reversed {
3446 new_face.set_reversed(true);
3447 }
3448 new_face_ids.push(topo.add_face(new_face));
3449 }
3450
3451 // Replace the shell's faces
3452 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3453 let new_shell_id = topo.add_shell(new_shell);
3454 let solid_mut = topo.solid_mut(solid)?;
3455 solid_mut.set_outer_shell(new_shell_id);
3456
3457 Ok(())
3458}
3459
3460/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3461///
3462/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3463/// a cap face) annihilates the shared cap but leaves each argument's faces
3464/// carrying their OWN copy of the junction-wire edges. Because the two copies
3465/// come from independently-built solids their endpoints differ by sub-micron
3466/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3467/// merge above leaves them as distinct edges — each used once → free edges that
3468/// open the shell.
3469///
3470/// This snaps vertices at `tol_merge` (looser than the default linear
3471/// tolerance, to absorb that noise), then rebuilds every wire against a global
3472/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3473/// geometric midpoint* — so a straight line and a bulged arc between the same
3474/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3475/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3476///
3477/// Returns `true` if anything changed. Run only on already-broken results
3478/// (free edges / non-manifold) so clean booleans keep their exact topology.
3479#[allow(
3480 clippy::too_many_lines,
3481 clippy::type_complexity,
3482 clippy::items_after_statements
3483)]
3484fn unify_coincident_boundary_edges(
3485 topo: &mut Topology,
3486 solid: SolidId,
3487 tol_merge: f64,
3488) -> Result<bool, crate::OperationsError> {
3489 use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3490 use brepkit_topology::vertex::VertexId;
3491 use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3492 use std::collections::HashMap;
3493
3494 let shell_id = topo.solid(solid)?.outer_shell();
3495 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3496
3497 let scale = 1.0 / tol_merge;
3498 let q = |p: Point3| -> (i64, i64, i64) {
3499 (
3500 (p.x() * scale).round() as i64,
3501 (p.y() * scale).round() as i64,
3502 (p.z() * scale).round() as i64,
3503 )
3504 };
3505
3506 // 1. Canonical vertex per quantized position (first VertexId seen wins).
3507 let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3508 for &fid in &face_ids {
3509 let face = topo.face(fid)?;
3510 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3511 let wire = topo.wire(wid)?;
3512 for oe in wire.edges() {
3513 let edge = topo.edge(oe.edge())?;
3514 for vid in [edge.start(), edge.end()] {
3515 let key = q(topo.vertex(vid)?.point());
3516 vcanon.entry(key).or_insert(vid);
3517 }
3518 }
3519 }
3520 }
3521
3522 // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3523 type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3524 struct FaceSnap {
3525 surface: FaceSurface,
3526 reversed: bool,
3527 outer: Vec<OeSnap>,
3528 outer_closed: bool,
3529 inners: Vec<(Vec<OeSnap>, bool)>,
3530 }
3531 let snap_wire =
3532 |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3533 let w = topo.wire(wid)?;
3534 let closed = w.is_closed();
3535 let oes = w
3536 .edges()
3537 .iter()
3538 .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3539 let e = topo.edge(oe.edge())?;
3540 Ok((
3541 oe.edge(),
3542 oe.is_forward(),
3543 e.curve().clone(),
3544 e.start(),
3545 e.end(),
3546 e.tolerance(),
3547 ))
3548 })
3549 .collect::<Result<_, _>>()?;
3550 Ok((oes, closed))
3551 };
3552 let mut snaps = Vec::with_capacity(face_ids.len());
3553 for &fid in &face_ids {
3554 let face = topo.face(fid)?;
3555 let surface = face.surface().clone();
3556 let reversed = face.is_reversed();
3557 let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3558 let mut inners = Vec::new();
3559 for iw in face.inner_wires() {
3560 inners.push(snap_wire(topo, *iw)?);
3561 }
3562 snaps.push(FaceSnap {
3563 surface,
3564 reversed,
3565 outer,
3566 outer_closed,
3567 inners,
3568 });
3569 }
3570
3571 // 3. Rebuild wires against a global canonical-edge map.
3572 // Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3573 type EdgeKey = (
3574 (i64, i64, i64),
3575 (i64, i64, i64),
3576 (i64, i64, i64),
3577 &'static str,
3578 );
3579 let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3580 let mut changed = false;
3581
3582 let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3583 Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3584 };
3585
3586 let rebuild = |topo: &mut Topology,
3587 oes: &[OeSnap],
3588 ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3589 changed: &mut bool|
3590 -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3591 let mut out = Vec::with_capacity(oes.len());
3592 for (eid, fwd, curve, start, end, etol) in oes {
3593 let cs = canon_vid(topo, *start)?;
3594 let ce = canon_vid(topo, *end)?;
3595 if cs == ce {
3596 // Endpoints collapsed to a single vertex → degenerate, drop it.
3597 *changed = true;
3598 continue;
3599 }
3600 let sp = topo.vertex(*start)?.point();
3601 let ep = topo.vertex(*end)?.point();
3602 let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3603 let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3604 let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3605 let (lo, hi) = if cs_q <= ce_q {
3606 (cs_q, ce_q)
3607 } else {
3608 (ce_q, cs_q)
3609 };
3610 let key = (lo, hi, q(mid), curve.type_tag());
3611
3612 // Physical traversal start vertex (after canonicalization).
3613 let trav_start = if *fwd { cs } else { ce };
3614 if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3615 // A duplicate of an already-seen edge → merge onto the keeper.
3616 *changed = true;
3617 out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3618 } else {
3619 // First edge with this key. Reuse the original edge when its
3620 // endpoints didn't move; only allocate (and flag a change) when
3621 // a vertex was snapped — so an already-clean shell is a no-op.
3622 let (eid_use, e_start) = if cs == *start && ce == *end {
3623 (*eid, *start)
3624 } else {
3625 *changed = true;
3626 (
3627 topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3628 cs,
3629 )
3630 };
3631 ecanon.insert(key, (eid_use, e_start, ce));
3632 out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3633 }
3634 }
3635 Ok(out)
3636 };
3637
3638 let mut new_face_ids = Vec::with_capacity(snaps.len());
3639 for snap in &snaps {
3640 let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3641 let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3642 // Keep original face if the rebuilt wire is invalid.
3643 return Ok(false);
3644 };
3645 let outer_id = topo.add_wire(outer_wire);
3646 let mut inner_ids = Vec::new();
3647 for (inner_oes, inner_closed) in &snap.inners {
3648 let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3649 let Ok(w) = Wire::new(oes, *inner_closed) else {
3650 // A dropped hole silently changes topology (and removes free
3651 // edges, so the downstream gate can't catch it). Bail like the
3652 // outer-wire case, leaving the original solid untouched.
3653 return Ok(false);
3654 };
3655 inner_ids.push(topo.add_wire(w));
3656 }
3657 let mut new_face =
3658 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3659 if snap.reversed {
3660 new_face.set_reversed(true);
3661 }
3662 new_face_ids.push(topo.add_face(new_face));
3663 }
3664
3665 if !changed {
3666 return Ok(false);
3667 }
3668
3669 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3670 let new_shell_id = topo.add_shell(new_shell);
3671 topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3672 Ok(true)
3673}
3674
3675/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3676///
3677/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3678/// shell building to split the non-manifold shell into manifold
3679/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3680/// become inner shells (cavities).
3681///
3682/// If the solid is already manifold, returns it unchanged.
3683#[allow(clippy::too_many_lines)]
3684fn enforce_manifold_shell(
3685 topo: &mut Topology,
3686 solid: SolidId,
3687) -> Result<SolidId, crate::OperationsError> {
3688 use std::collections::{HashMap, HashSet, VecDeque};
3689
3690 let shell_id = topo.solid(solid)?.outer_shell();
3691 let face_ids = topo.shell(shell_id)?.faces().to_vec();
3692
3693 // Count edges per face.
3694 let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3695 for &fid in &face_ids {
3696 if let Ok(face) = topo.face(fid) {
3697 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3698 {
3699 if let Ok(wire) = topo.wire(wid) {
3700 for oe in wire.edges() {
3701 *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3702 }
3703 }
3704 }
3705 }
3706 }
3707
3708 // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3709 // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3710 // the shell at those edges breaks downstream operations (section, volume).
3711 let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3712 if nm_count <= 3 {
3713 return Ok(solid);
3714 }
3715
3716 log::debug!(
3717 "enforce_manifold_shell: {} non-manifold edges in {} faces",
3718 nm_count,
3719 face_ids.len()
3720 );
3721
3722 // Build vertex-pair → face adjacency for neighbor discovery.
3723 let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3724 HashMap::new();
3725 for &fid in &face_ids {
3726 if let Ok(face) = topo.face(fid) {
3727 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3728 {
3729 if let Ok(wire) = topo.wire(wid) {
3730 for oe in wire.edges() {
3731 if let Ok(e) = topo.edge(oe.edge()) {
3732 let si = e.start().index();
3733 let ei = e.end().index();
3734 let key = if si <= ei { (si, ei) } else { (ei, si) };
3735 vpair_faces.entry(key).or_default().push(fid);
3736 }
3737 }
3738 }
3739 }
3740 }
3741 }
3742
3743 // Greedy flood-fill shell construction.
3744 let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3745 let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3746 let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3747
3748 for &start_face in &face_ids {
3749 if processed.contains(&start_face) {
3750 continue;
3751 }
3752
3753 let mut shell_faces = vec![start_face];
3754 processed.insert(start_face);
3755
3756 // Track edge-ID usage within this shell.
3757 let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3758 if let Ok(face) = topo.face(start_face) {
3759 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3760 {
3761 if let Ok(wire) = topo.wire(wid) {
3762 for oe in wire.edges() {
3763 *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3764 }
3765 }
3766 }
3767 }
3768
3769 let mut queue = VecDeque::new();
3770 queue.push_back(start_face);
3771
3772 while let Some(current) = queue.pop_front() {
3773 let Ok(face) = topo.face(current) else {
3774 continue;
3775 };
3776 // Collect (vpair, edge_id) from all wires.
3777 let mut all_edges = Vec::new();
3778 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3779 {
3780 if let Ok(wire) = topo.wire(wid) {
3781 for oe in wire.edges() {
3782 if let Ok(e) = topo.edge(oe.edge()) {
3783 let si = e.start().index();
3784 let ei = e.end().index();
3785 let key = if si <= ei { (si, ei) } else { (ei, si) };
3786 all_edges.push((key, oe.edge()));
3787 }
3788 }
3789 }
3790 }
3791
3792 for (vpair, edge_id) in all_edges {
3793 let eidx = edge_id.index();
3794
3795 // Skip edges already manifold in this shell.
3796 if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3797 continue;
3798 }
3799
3800 // Find candidate neighbor faces via vertex-pair.
3801 let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3802 .get(&vpair)
3803 .map(|fs| {
3804 fs.iter()
3805 .copied()
3806 .filter(|&f| {
3807 f != current && available.contains(&f) && !processed.contains(&f)
3808 })
3809 .collect()
3810 })
3811 .unwrap_or_default();
3812
3813 if candidates.is_empty() {
3814 continue;
3815 }
3816
3817 // Pick first candidate (simple heuristic — dihedral selection
3818 // would be better but requires surface normal evaluation).
3819 let selected = candidates[0];
3820
3821 if processed.contains(&selected) {
3822 continue;
3823 }
3824
3825 processed.insert(selected);
3826 shell_faces.push(selected);
3827 queue.push_back(selected);
3828
3829 // Update edge count.
3830 if let Ok(sel_face) = topo.face(selected) {
3831 for wid in std::iter::once(sel_face.outer_wire())
3832 .chain(sel_face.inner_wires().iter().copied())
3833 {
3834 if let Ok(wire) = topo.wire(wid) {
3835 for sel_oe in wire.edges() {
3836 *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3837 }
3838 }
3839 }
3840 }
3841 }
3842 }
3843
3844 shells.push(shell_faces);
3845 }
3846
3847 // Add any unprocessed faces to a final shell.
3848 let remaining: Vec<brepkit_topology::face::FaceId> = available
3849 .iter()
3850 .filter(|f| !processed.contains(f))
3851 .copied()
3852 .collect();
3853 if !remaining.is_empty() {
3854 shells.push(remaining);
3855 }
3856
3857 if shells.len() <= 1 {
3858 // Single shell — nothing to split.
3859 return Ok(solid);
3860 }
3861
3862 log::debug!(
3863 "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3864 shells.len(),
3865 shells.iter().map(Vec::len).collect::<Vec<_>>(),
3866 );
3867
3868 // Build the solid: largest shell is outer, rest are inner.
3869 let mut best_idx = 0;
3870 let mut best_count = 0;
3871 for (i, faces) in shells.iter().enumerate() {
3872 if faces.len() > best_count {
3873 best_count = faces.len();
3874 best_idx = i;
3875 }
3876 }
3877
3878 let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3879 .map_err(crate::OperationsError::Topology)?;
3880 let outer_id = topo.add_shell(outer);
3881 let mut inner_ids = Vec::new();
3882 for (i, faces) in shells.iter().enumerate() {
3883 if i != best_idx
3884 && !faces.is_empty()
3885 && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3886 {
3887 inner_ids.push(topo.add_shell(inner));
3888 }
3889 }
3890
3891 Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3892}
3893
3894/// Sample `n` evenly-spaced points along a closed edge curve.
3895///
3896/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3897/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3898/// duplication. Returns an empty vec for `Line` (no sampling possible).
3899pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3900 match curve {
3901 EdgeCurve::Circle(c) => (0..n)
3902 .map(|i| {
3903 #[allow(clippy::cast_precision_loss)]
3904 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3905 c.evaluate(t)
3906 })
3907 .collect(),
3908 EdgeCurve::Ellipse(e) => (0..n)
3909 .map(|i| {
3910 #[allow(clippy::cast_precision_loss)]
3911 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3912 e.evaluate(t)
3913 })
3914 .collect(),
3915 EdgeCurve::NurbsCurve(nc) => {
3916 let (u0, u1) = nc.domain();
3917 // For closed curves (start ~ end), use n as divisor to avoid
3918 // duplicating the first point at t=u_max.
3919 let start_pt = nc.evaluate(u0);
3920 let end_pt = nc.evaluate(u1);
3921 // 1e-6 m: closure detection threshold — if start and end points are
3922 // within 1 micron, treat the NURBS curve as closed to avoid
3923 // duplicating the first point at t=u_max.
3924 let is_closed = (start_pt - end_pt).length() < 1e-6;
3925 let divisor = if is_closed { n } else { n - 1 };
3926 (0..n)
3927 .map(|i| {
3928 #[allow(clippy::cast_precision_loss)]
3929 let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
3930 nc.evaluate(t)
3931 })
3932 .collect()
3933 }
3934 EdgeCurve::Line => vec![],
3935 }
3936}
3937
3938/// Get a polygon approximation of a face by sampling curved edges.
3939///
3940/// Samples circle/ellipse edges into 32 points so faces with a
3941/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
3942///
3943/// # Errors
3944///
3945/// Returns an error if the face or its wire cannot be resolved.
3946pub fn face_polygon(
3947 topo: &Topology,
3948 face_id: FaceId,
3949) -> Result<Vec<Point3>, crate::OperationsError> {
3950 let face = topo.face(face_id)?;
3951 let wire = topo.wire(face.outer_wire())?;
3952 let mut pts = Vec::new();
3953
3954 for oe in wire.edges() {
3955 let edge = topo.edge(oe.edge())?;
3956 let curve = edge.curve();
3957 // Sample closed parametric edges (start == end vertex).
3958 // Partial arcs fall through to the vertex-based path.
3959 let start_vid = edge.start();
3960 let end_vid = edge.end();
3961 let is_closed_edge = start_vid == end_vid
3962 && matches!(
3963 curve,
3964 EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
3965 );
3966 if is_closed_edge {
3967 // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
3968 // must match create_band_fragments and inner-wire dedup for sharing.
3969 let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
3970 if !oe.is_forward() {
3971 sampled.reverse();
3972 }
3973 pts.extend(sampled);
3974 } else {
3975 let vid = oe.oriented_start(edge);
3976 pts.push(topo.vertex(vid)?.point());
3977 }
3978 }
3979
3980 Ok(pts)
3981}
3982
3983/// Collect face signatures (index, normal, centroid) for evolution tracking.
3984///
3985/// For each face of the solid, computes a representative normal and centroid
3986/// from the face polygon. Used by [`boolean_with_evolution`] to match output
3987/// faces back to input faces.
3988///
3989/// # Errors
3990///
3991/// Returns an error if any face or wire cannot be resolved.
3992/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
3993/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
3994/// normal is the stored plane normal (or a polygon-derived normal for
3995/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
3996/// stays consistent because input and output faces use the same convention.
3997pub fn collect_face_signatures(
3998 topo: &Topology,
3999 solid_id: SolidId,
4000) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4001 let solid = topo.solid(solid_id)?;
4002 let shell = topo.shell(solid.outer_shell())?;
4003 let mut result = Vec::with_capacity(shell.faces().len());
4004
4005 for &fid in shell.faces() {
4006 let face = topo.face(fid)?;
4007 let verts = face_polygon(topo, fid)?;
4008 let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4009 *normal
4010 } else if verts.len() >= 3 {
4011 let e1 = verts[1] - verts[0];
4012 let e2 = verts[2] - verts[0];
4013 e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4014 } else {
4015 Vec3::new(0.0, 0.0, 1.0)
4016 };
4017
4018 let centroid = classify::polygon_centroid(&verts);
4019 result.push((fid.index(), normal, centroid));
4020 }
4021
4022 Ok(result)
4023}
4024
4025#[cfg(test)]
4026mod tests;