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 // Volume witness for the AABB-only fallback: `inner ⊆ outer` implies
2254 // `vol(inner) ≤ vol(outer)`, so a decisively larger inner volume proves
2255 // non-containment. This catches what `center_outside` cannot: the AABB
2256 // expansion for partial cylinder/cone faces is a conservative full-circle
2257 // bound, so a thin angular wedge's box balloons to the whole cylinder
2258 // footprint and can "strictly contain" a much bigger solid's box, while
2259 // the bigger solid's own AABB center sits in its annular hole and
2260 // disables the center witness (gh #1499, the kumiko corner cutter).
2261 // Volumes are immune to that inflation. The 1.05 factor absorbs
2262 // deflection under-counting on curved faces so a true containment is
2263 // never rejected; errors fall through to "no refutation" (shortcut
2264 // soundness is then up to the remaining witnesses, as before).
2265 let volume_refutes = |topo: &Topology, inner: SolidId, outer: SolidId| -> bool {
2266 let defl = |bb: &Option<(Point3, Point3)>| {
2267 let Some((lo, hi)) = *bb else { return 1e-3 };
2268 let (dx, dy, dz) = (hi.x() - lo.x(), hi.y() - lo.y(), hi.z() - lo.z());
2269 (dx.mul_add(dx, dy.mul_add(dy, dz * dz)).sqrt() * 0.01).max(1e-6)
2270 };
2271 let d = defl(&aabb_a).min(defl(&aabb_b));
2272 let (Ok(vi), Ok(vo)) = (
2273 crate::measure::solid_volume(topo, inner, d),
2274 crate::measure::solid_volume(topo, outer, d),
2275 ) else {
2276 return false;
2277 };
2278 vi > vo * 1.05
2279 };
2280
2281 // Bidirectional vertex check via the analytic classifier — the
2282 // primary signal for identical/containment classification. A vertex
2283 // classifying as inside-or-on (None within tolerance band counts
2284 // as on) means it sits within the solid's region.
2285 let all_b_verts_in_a = ca.is_some_and(|c| all_vertices_inside_or_on(topo, b, c, tol));
2286 let all_a_verts_in_b = cb.is_some_and(|c| all_vertices_inside_or_on(topo, a, c, tol));
2287 let aabbs_match = aabb_a
2288 .zip(aabb_b)
2289 .map(|((a_min, a_max), (b_min, b_max))| {
2290 let eps = tol.linear;
2291 (a_min.x() - b_min.x()).abs() < eps
2292 && (a_min.y() - b_min.y()).abs() < eps
2293 && (a_min.z() - b_min.z()).abs() < eps
2294 && (a_max.x() - b_max.x()).abs() < eps
2295 && (a_max.y() - b_max.y()).abs() < eps
2296 && (a_max.z() - b_max.z()).abs() < eps
2297 })
2298 .unwrap_or(false);
2299
2300 // Containment: A contains B when all B vertices are inside-or-on A AND
2301 // A's AABB encloses B's. Falls back to a strict AABB-only check when the
2302 // containing solid has no classifier — the strict check requires ≥10%
2303 // larger in ALL three dims so that sparse multi-shell solids (e.g., a
2304 // fuse of two disjoint boxes) don't false-positive as "contains another
2305 // solid".
2306 // Both the analytic-classifier term and the AABB-only fallback can
2307 // false-positive when the container is non-convex: the analytic
2308 // classifier may mis-report notch points as inside-or-on, and an
2309 // AABB encloses a notch's empty volume. Guard the whole determination
2310 // with the `center_outside` witness — sound for every path because it
2311 // only fires on proven non-containment (see the lemma above).
2312 let b_in_a = ((all_b_verts_in_a && aabb_encloses(&aabb_b, &aabb_a))
2313 || (ca.is_none()
2314 && aabb_strictly_contains(&aabb_b, &aabb_a)
2315 && !volume_refutes(topo, b, a)))
2316 && !center_outside(topo, b, a, &aabb_b);
2317 let a_in_b = ((all_a_verts_in_b && aabb_encloses(&aabb_a, &aabb_b))
2318 || (cb.is_none()
2319 && aabb_strictly_contains(&aabb_a, &aabb_b)
2320 && !volume_refutes(topo, a, b)))
2321 && !center_outside(topo, a, b, &aabb_a);
2322
2323 TrivialRelation {
2324 identical: aabbs_match && all_b_verts_in_a && all_a_verts_in_b,
2325 a_in_b,
2326 b_in_a,
2327 }
2328}
2329
2330/// Check whether every boundary vertex of `solid` is classified as
2331/// `Inside` or `On` by `classifier`. Used by the identical-solid shortcut
2332/// to distinguish truly-identical solids from co-located but differently
2333/// shaped solids (e.g., a cone and a box that share an AABB).
2334fn all_vertices_inside_or_on(
2335 topo: &Topology,
2336 solid: SolidId,
2337 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2338 tol: brepkit_math::tolerance::Tolerance,
2339) -> bool {
2340 let Ok(s) = topo.solid(solid) else {
2341 return false;
2342 };
2343 let Ok(sh) = topo.shell(s.outer_shell()) else {
2344 return false;
2345 };
2346 for &fid in sh.faces() {
2347 let Ok(f) = topo.face(fid) else { return false };
2348 let Ok(w) = topo.wire(f.outer_wire()) else {
2349 return false;
2350 };
2351 for oe in w.edges() {
2352 let Ok(e) = topo.edge(oe.edge()) else {
2353 return false;
2354 };
2355 for vid in [e.start(), e.end()] {
2356 let Ok(v) = topo.vertex(vid) else {
2357 return false;
2358 };
2359 // The analytic classifier returns `None` for points within
2360 // tol.linear of the boundary — treat as "on" for this check.
2361 if classifier.classify(v.point(), tol) == Some(brepkit_algo::FaceClass::Outside) {
2362 return false;
2363 }
2364 }
2365 }
2366 }
2367 true
2368}
2369
2370/// True when every outer-shell vertex of `inner` classifies as *strictly*
2371/// `Inside` (not on the boundary) of `classifier`. A strictly-contained tool
2372/// has no surface contact with the blank, so `Cut(blank, tool)` is a clean
2373/// internal cavity rather than a notch through the boundary.
2374fn solid_strictly_inside(
2375 topo: &Topology,
2376 inner: SolidId,
2377 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2378 tol: brepkit_math::tolerance::Tolerance,
2379) -> bool {
2380 let Ok(s) = topo.solid(inner) else {
2381 return false;
2382 };
2383 let Ok(sh) = topo.shell(s.outer_shell()) else {
2384 return false;
2385 };
2386 let mut saw_vertex = false;
2387 for &fid in sh.faces() {
2388 let Ok(f) = topo.face(fid) else { return false };
2389 // Check the outer wire and any inner (hole) wires — a hole boundary on
2390 // a simple solid's face can also reach the blank's surface.
2391 let mut wires = vec![f.outer_wire()];
2392 wires.extend_from_slice(f.inner_wires());
2393 for wid in wires {
2394 let Ok(w) = topo.wire(wid) else {
2395 return false;
2396 };
2397 for oe in w.edges() {
2398 let Ok(e) = topo.edge(oe.edge()) else {
2399 return false;
2400 };
2401 for vid in [e.start(), e.end()] {
2402 let Ok(v) = topo.vertex(vid) else {
2403 return false;
2404 };
2405 if classifier.classify(v.point(), tol) != Some(brepkit_algo::FaceClass::Inside)
2406 {
2407 return false;
2408 }
2409 saw_vertex = true;
2410 }
2411 }
2412 }
2413 }
2414 saw_vertex
2415}
2416
2417/// Build `Cut(blank, tool)` for a tool strictly contained in the blank: the
2418/// result is the blank with a tool-shaped internal cavity. Deep-copies the
2419/// blank and the tool, reverses every copied tool face in place so the cavity
2420/// boundary faces into the void, and attaches the reversed tool shell to the
2421/// copied blank as an inner shell. Bypasses GFA, whose no-intersection assembly
2422/// drops fully-contained cone/torus tools.
2423fn build_contained_cut_hollow(
2424 topo: &mut Topology,
2425 blank: SolidId,
2426 tool: SolidId,
2427) -> Result<SolidId, crate::OperationsError> {
2428 let result = crate::copy::copy_solid(topo, blank)?;
2429
2430 // Deep-copy the tool as a whole solid so the cavity shell shares edges and
2431 // vertices between adjacent faces (a per-face copy would duplicate shared
2432 // boundary edges and leave the cavity non-manifold — wrong Euler, though
2433 // per-face volume is unaffected). Reverse each copied face in place and
2434 // reuse the copied outer shell directly as the cavity inner shell, so no
2435 // duplicate faces or extra result solid are created.
2436 let tool_copy = crate::copy::copy_solid(topo, tool)?;
2437 let cavity_shell = topo.solid(tool_copy)?.outer_shell();
2438 let cavity_faces = topo.shell(cavity_shell)?.faces().to_vec();
2439 for fid in cavity_faces {
2440 let face = topo.face_mut(fid)?;
2441 let flipped = !face.is_reversed();
2442 face.set_reversed(flipped);
2443 }
2444 topo.solid_mut(result)?.add_inner_shell(cavity_shell);
2445 Ok(result)
2446}
2447
2448/// Best-effort mesh boolean fallback for high face-count solids.
2449///
2450/// Tessellates both solids, runs mesh co-refinement, assembles the result,
2451/// and applies the same post-processing as the other boolean paths.
2452/// Returns `Err` on any failure so the caller can fall through to the
2453/// chord-based path.
2454fn mesh_boolean_fallback(
2455 topo: &mut Topology,
2456 op: BooleanOp,
2457 a: SolidId,
2458 b: SolidId,
2459 deflection: f64,
2460 tol: brepkit_math::tolerance::Tolerance,
2461 opts: &BooleanOptions,
2462) -> Result<SolidId, crate::OperationsError> {
2463 // Mesh density here is a boolean-robustness concern, independent of the
2464 // rendering tolerance: use the linear-only criterion (angular_tol 0.0) so
2465 // the face count is unaffected by the display deflection cap, AND keep the
2466 // circle curvature floor so co-refinement gets the denser circular sampling
2467 // it needs (display tessellation drops that floor for triangle count).
2468 let mesh_a = crate::tessellate::tessellate_solid_for_boolean(topo, a, deflection, 0.0)?;
2469 let mesh_b = crate::tessellate::tessellate_solid_for_boolean(topo, b, deflection, 0.0)?;
2470 log::debug!(
2471 "mesh fallback {op:?}: tessellated operands to {} + {} triangles at deflection {deflection}",
2472 mesh_a.indices.len() / 3,
2473 mesh_b.indices.len() / 3,
2474 );
2475
2476 let mb_result = crate::mesh_boolean::mesh_boolean(&mesh_a, &mesh_b, op, tol.linear)?;
2477 if mb_result.boundary_edge_count > 0 || mb_result.non_manifold_edge_count > 0 {
2478 log::warn!(
2479 "boolean {op:?}: mesh boolean fallback output is NOT a closed 2-manifold \
2480 ({} boundary edge(s), {} non-manifold edge(s) after position welding) — \
2481 downstream healing may not recover; exported geometry may be broken",
2482 mb_result.boundary_edge_count,
2483 mb_result.non_manifold_edge_count,
2484 );
2485 }
2486 let face_specs = mesh_result_to_face_specs(&mb_result);
2487 if face_specs.is_empty() {
2488 return Err(crate::OperationsError::EmptyResult {
2489 reason: "mesh boolean produced no output faces".into(),
2490 });
2491 }
2492 log::debug!(
2493 "mesh fallback {op:?}: {} face specs -> assemble_solid_mixed",
2494 face_specs.len()
2495 );
2496 let result = assemble_solid_mixed(topo, &face_specs, tol)?;
2497 let _ = crate::heal::remove_degenerate_edges(topo, result, tol.linear)?;
2498 if opts.unify_faces {
2499 let _ = crate::heal::unify_faces(topo, result)?;
2500 }
2501 // Cross-face symmetrization: tessellation diagonals that one face
2502 // dropped while its neighbour kept (#696) leave structurally
2503 // orphan collinear interior wire vertices. Collapse those so both
2504 // sides reference the same EdgeId for the shared 3D segment,
2505 // eliminating the residual non-manifold edges that `unify_faces`
2506 // can't symmetrize from per-face surface matching alone.
2507 let collapsed =
2508 brepkit_heal::upgrade::collapse_collinear_vertices::collapse_collinear_wire_vertices(
2509 topo, result, tol,
2510 )
2511 .unwrap_or_else(|e| {
2512 log::warn!("boolean {op:?}: collapse_collinear_wire_vertices failed: {e}");
2513 0
2514 });
2515 if collapsed > 0 {
2516 log::info!(
2517 "boolean {op:?}: collapsed {collapsed} collinear interior wire vertex/vertices post-mesh-assembly",
2518 );
2519 }
2520 // Mesh-fallback can glue two physically-separate holes into a
2521 // single figure-8 inner wire via diagonal "bridge" edges across
2522 // gap material (#696 cumulative pattern: a slab top with multiple
2523 // pocket cuts ends up with one self-intersecting inner wire that
2524 // visits each pocket region). Split such wires at every pinch
2525 // vertex so each physical hole is its own simple inner wire —
2526 // the resulting topology is well-formed for downstream
2527 // tessellation, validation, and STEP export, even when the
2528 // bridge edges themselves remain as boundary edges (those are a
2529 // separate cleanup).
2530 let wires_split =
2531 brepkit_heal::upgrade::split_self_intersecting_wires::split_self_intersecting_inner_wires(
2532 topo, result,
2533 )
2534 .unwrap_or_else(|e| {
2535 log::warn!("boolean {op:?}: split_self_intersecting_inner_wires failed: {e}");
2536 0
2537 });
2538 if wires_split > 0 {
2539 log::info!(
2540 "boolean {op:?}: split {wires_split} self-intersecting inner wire(s) post-mesh-assembly",
2541 );
2542 }
2543 if opts.heal_after_boolean {
2544 let _ = crate::heal::heal_solid(topo, result, tol.linear)?;
2545 }
2546 assembly::validate_boolean_result_lenient(topo, result)?;
2547 log::info!(
2548 "boolean {op:?}: mesh boolean path → solid {} ({} faces, surface types lost)",
2549 result.index(),
2550 face_specs.len()
2551 );
2552 Ok(result)
2553}
2554
2555/// Convert a mesh boolean result into `FaceSpec` entries for solid assembly.
2556fn mesh_result_to_face_specs(result: &crate::mesh_boolean::MeshBooleanResult) -> Vec<FaceSpec> {
2557 let mut specs = Vec::new();
2558 for tri in result.mesh.indices.chunks_exact(3) {
2559 let v0 = result.mesh.positions[tri[0] as usize];
2560 let v1 = result.mesh.positions[tri[1] as usize];
2561 let v2 = result.mesh.positions[tri[2] as usize];
2562
2563 let edge1 = v1 - v0;
2564 let edge2 = v2 - v0;
2565 let Ok(normal) = edge1.cross(edge2).normalize() else {
2566 continue;
2567 };
2568 let d = crate::dot_normal_point(normal, v0);
2569 specs.push(FaceSpec::Planar {
2570 vertices: vec![v0, v1, v2],
2571 normal,
2572 d,
2573 inner_wires: vec![],
2574 });
2575 }
2576 specs
2577}
2578
2579/// True when the outer-shell face components represent disjoint solid
2580/// pieces (e.g., a previous cut split one solid into N parts), false
2581/// when one component is concentric inside another (a hollow solid:
2582/// outer surface + cavity surface both live in the outer shell).
2583///
2584/// The check is AABB-based: if any component's bounding box is
2585/// strictly contained in another's, treat the whole solid as hollow
2586/// and skip the multi-region split path.
2587/// Check that every component's AABB centre classifies as outside the
2588/// supplied classifier. Used to reject multi-region GFA Cut results that
2589/// erroneously include the tool's interior as one of the pieces.
2590fn all_component_centers_outside(
2591 topo: &Topology,
2592 components: &[Vec<FaceId>],
2593 classifier: &brepkit_algo::classifier::AnalyticClassifier,
2594 tol: brepkit_math::tolerance::Tolerance,
2595) -> bool {
2596 use brepkit_algo::FaceClass;
2597 for comp in components {
2598 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2599 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2600 for &fid in comp {
2601 let Ok(face) = topo.face(fid) else { continue };
2602 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2603 {
2604 let Ok(wire) = topo.wire(wid) else { continue };
2605 for oe in wire.edges() {
2606 let Ok(edge) = topo.edge(oe.edge()) else {
2607 continue;
2608 };
2609 for vid in [edge.start(), edge.end()] {
2610 if let Ok(v) = topo.vertex(vid) {
2611 let p = v.point();
2612 min = Point3::new(
2613 min.x().min(p.x()),
2614 min.y().min(p.y()),
2615 min.z().min(p.z()),
2616 );
2617 max = Point3::new(
2618 max.x().max(p.x()),
2619 max.y().max(p.y()),
2620 max.z().max(p.z()),
2621 );
2622 }
2623 }
2624 }
2625 }
2626 }
2627 let centre = Point3::new(
2628 (min.x() + max.x()) * 0.5,
2629 (min.y() + max.y()) * 0.5,
2630 (min.z() + max.z()) * 0.5,
2631 );
2632 if matches!(classifier.classify(centre, tol), Some(FaceClass::Inside)) {
2633 return false;
2634 }
2635 }
2636 true
2637}
2638
2639/// Centre of a face component's vertex AABB, or `None` for an empty component.
2640fn component_aabb_centre(topo: &Topology, comp: &[FaceId]) -> Option<Point3> {
2641 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2642 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2643 for &fid in comp {
2644 let Ok(face) = topo.face(fid) else { continue };
2645 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
2646 let Ok(wire) = topo.wire(wid) else { continue };
2647 for oe in wire.edges() {
2648 let Ok(edge) = topo.edge(oe.edge()) else {
2649 continue;
2650 };
2651 for vid in [edge.start(), edge.end()] {
2652 if let Ok(v) = topo.vertex(vid) {
2653 let p = v.point();
2654 min =
2655 Point3::new(min.x().min(p.x()), min.y().min(p.y()), min.z().min(p.z()));
2656 max =
2657 Point3::new(max.x().max(p.x()), max.y().max(p.y()), max.z().max(p.z()));
2658 }
2659 }
2660 }
2661 }
2662 }
2663 if min.x() > max.x() {
2664 return None;
2665 }
2666 Some(Point3::new(
2667 (min.x() + max.x()) * 0.5,
2668 (min.y() + max.y()) * 0.5,
2669 (min.z() + max.z()) * 0.5,
2670 ))
2671}
2672
2673/// Does the closed surface made of `faces` enclose `p`?
2674///
2675/// Ray-parity against the component's own tessellation. Read-only by design:
2676/// building a temporary solid per component would add entities to an arena that
2677/// never reclaims, which is the growth cliff fixed in #1237.
2678///
2679/// `watertight_ray_triangle_intersect` reports exactly one hit on a shared edge,
2680/// so parity is meaningful across face boundaries. The direction is deliberately
2681/// irrational so the ray does not graze a face boundary or lie in a face plane —
2682/// the degeneracy that makes axis-aligned probes unreliable on the feature-plane
2683/// intersections these pieces are full of. Returns `None` when the component
2684/// cannot be tessellated, so callers can fall back rather than guess.
2685fn component_encloses_point(
2686 topo: &Topology,
2687 faces: &[FaceId],
2688 p: Point3,
2689 deflection: f64,
2690) -> Option<bool> {
2691 // A sqrt-prime direction: irrational in every component, so the ray cannot
2692 // lie in a face plane or run along an edge — the same generic-direction
2693 // escape the ray-cast classifier uses for degenerate probes.
2694 let dir = Vec3::new(2.0_f64.sqrt(), 3.0_f64.sqrt(), 5.0_f64.sqrt())
2695 .normalize()
2696 .ok()?;
2697 let mut crossings = 0usize;
2698 let mut any_triangle = false;
2699 for &fid in faces {
2700 let mesh = crate::tessellate::tessellate_with_uvs(topo, fid, deflection).ok()?;
2701 let pos = &mesh.mesh.positions;
2702 for tri in mesh.mesh.indices.chunks_exact(3) {
2703 let (a, b, c) = (
2704 pos[tri[0] as usize],
2705 pos[tri[1] as usize],
2706 pos[tri[2] as usize],
2707 );
2708 any_triangle = true;
2709 if let Some(hit) =
2710 brepkit_math::ray_triangle::watertight_ray_triangle_intersect(p, dir, a, b, c)
2711 && hit.t > 1e-9
2712 {
2713 crossings += 1;
2714 }
2715 }
2716 }
2717 any_triangle.then_some(crossings % 2 == 1)
2718}
2719
2720/// Any vertex position on `faces`, for use as a probe point.
2721fn any_vertex_of(topo: &Topology, faces: &[FaceId]) -> Option<Point3> {
2722 for &fid in faces {
2723 let face = topo.face(fid).ok()?;
2724 let wire = topo.wire(face.outer_wire()).ok()?;
2725 if let Some(oe) = wire.edges().first()
2726 && let Ok(edge) = topo.edge(oe.edge())
2727 && let Ok(v) = topo.vertex(edge.start())
2728 {
2729 return Some(v.point());
2730 }
2731 }
2732 None
2733}
2734
2735fn components_are_disjoint_pieces(topo: &Topology, components: &[Vec<FaceId>]) -> bool {
2736 let aabbs: Vec<(Point3, Point3)> = components
2737 .iter()
2738 .map(|comp| {
2739 let mut min = Point3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
2740 let mut max = Point3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
2741 for &fid in comp {
2742 let Ok(face) = topo.face(fid) else {
2743 continue;
2744 };
2745 for wid in
2746 std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
2747 {
2748 let Ok(wire) = topo.wire(wid) else {
2749 continue;
2750 };
2751 for oe in wire.edges() {
2752 let Ok(edge) = topo.edge(oe.edge()) else {
2753 continue;
2754 };
2755 for vid in [edge.start(), edge.end()] {
2756 if let Ok(v) = topo.vertex(vid) {
2757 let p = v.point();
2758 min = Point3::new(
2759 min.x().min(p.x()),
2760 min.y().min(p.y()),
2761 min.z().min(p.z()),
2762 );
2763 max = Point3::new(
2764 max.x().max(p.x()),
2765 max.y().max(p.y()),
2766 max.z().max(p.z()),
2767 );
2768 }
2769 }
2770 }
2771 }
2772 }
2773 (min, max)
2774 })
2775 .collect();
2776
2777 // Reject NESTING, not mere AABB overlap.
2778 //
2779 // Nesting is the hazard worth rejecting (a blob sitting inside another
2780 // piece's cavity is not a disjoint union); side-by-side pieces are exactly
2781 // what multi-region acceptance is for.
2782 //
2783 // Assume nothing about the components handed in. The acceptance gate calls
2784 // this on a GFA result that has cleared only `euler_balanced` — which is
2785 // genus-tolerant, and whose `closed_manifold` companion is a LATER conjunct
2786 // in the same `&&` chain, not a precondition. The input-splitting paths
2787 // call it on components of a raw operand no gate has examined at all. So
2788 // "every piece is a closed manifold, hence disjoint-or-nested" is not
2789 // available here; the ray-parity confirmation below earns the answer
2790 // instead of inferring it.
2791 //
2792 // Overlap is the wrong predicate for that, because an AABB is only tight on
2793 // axis-aligned geometry. Two ROTATED bars a clear distance apart each span
2794 // the whole diagonal envelope, so their boxes interpenetrate and an
2795 // overlap test calls them touching — which is why a kumiko lattice cut,
2796 // whose members are diagonal, could never be accepted and fell back to the
2797 // mesh path on every band (see
2798 // `tests::rotated_separate_pieces_are_recognised_as_disjoint`). Containment
2799 // is tight in the direction that matters: nesting implies it, and rotation
2800 // does not manufacture it.
2801 let eps = 1e-7;
2802 let contains = |(o_min, o_max): (Point3, Point3), (i_min, i_max): (Point3, Point3)| {
2803 o_min.x() - eps <= i_min.x()
2804 && o_min.y() - eps <= i_min.y()
2805 && o_min.z() - eps <= i_min.z()
2806 && o_max.x() + eps >= i_max.x()
2807 && o_max.y() + eps >= i_max.y()
2808 && o_max.z() + eps >= i_max.z()
2809 };
2810 // AABB containment is only the PRE-FILTER. It is necessary for nesting but
2811 // far from sufficient: a ring's box contains the box of a separate piece
2812 // sitting in its HOLE, and a lattice is full of rings. So a suspect pair
2813 // gets a real ray-parity test against the enclosing candidate's own surface,
2814 // and only genuine enclosure rejects. If the probe cannot be evaluated the
2815 // pair falls back to the conservative answer.
2816 for i in 0..aabbs.len() {
2817 for j in (i + 1)..aabbs.len() {
2818 let (outer, inner) = if contains(aabbs[i], aabbs[j]) {
2819 (i, j)
2820 } else if contains(aabbs[j], aabbs[i]) {
2821 (j, i)
2822 } else {
2823 continue;
2824 };
2825 let (o_min, o_max) = aabbs[outer];
2826 let diag = ((o_max.x() - o_min.x()).powi(2)
2827 + (o_max.y() - o_min.y()).powi(2)
2828 + (o_max.z() - o_min.z()).powi(2))
2829 .sqrt();
2830 let deflection = (diag / 200.0).max(1e-4);
2831 let Some(probe) = any_vertex_of(topo, &components[inner]) else {
2832 return false;
2833 };
2834 match component_encloses_point(topo, &components[outer], probe, deflection) {
2835 Some(true) => return false,
2836 Some(false) => {}
2837 None => return false,
2838 }
2839 }
2840 }
2841 true
2842}
2843
2844/// Fuse a multi-component TOOL by folding its disjoint pieces into the
2845/// target one at a time.
2846///
2847/// Each piece is copied into a fresh connected solid (the pavefiller
2848/// stumbles on shared vertex IDs across what it considers one "solid B")
2849/// and fused via the full `boolean` entry, so every per-piece fuse gets the
2850/// analytic path, gates, and fallbacks. Fuse distributes over a
2851/// disjoint-union tool, so the fold is exact. Recursion terminates: each
2852/// piece is single-component, so the recursive call never re-enters this
2853/// path.
2854fn fuse_multi_component_tool(
2855 topo: &mut Topology,
2856 a: SolidId,
2857 b_components: Vec<Vec<brepkit_topology::face::FaceId>>,
2858) -> Result<SolidId, crate::OperationsError> {
2859 let mut result = a;
2860 for comp_faces in b_components {
2861 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2862 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2863 result = boolean(topo, BooleanOp::Fuse, result, comp_solid)?;
2864 }
2865 Ok(result)
2866}
2867
2868/// Cut a multi-region input solid: split the components, cut each
2869/// against `b` independently, then combine the per-component results
2870/// back into a single multi-region solid.
2871///
2872/// This works around the GFA pavefiller's assumption of a single
2873/// connected input — feeding a 2-piece "solid" into GFA loses one piece
2874/// at a time as the cut proceeds (Category B `multiple cuts creating
2875/// three pieces` and gear bore are both downstream of this).
2876fn cut_multi_region_input(
2877 topo: &mut Topology,
2878 a: SolidId,
2879 b: SolidId,
2880 comp_count: usize,
2881) -> Result<SolidId, crate::OperationsError> {
2882 let components = crate::boolean::assembly::face_components(topo, a);
2883 debug_assert_eq!(components.len(), comp_count);
2884
2885 let mut per_component_results: Vec<SolidId> = Vec::with_capacity(components.len());
2886 for comp_faces in components {
2887 // Copy the component's faces into a fresh single-component solid
2888 // so the boolean engine sees a connected manifold.
2889 let comp_solid_raw = make_solid_from_face_subset(topo, &comp_faces)?;
2890 // Deep-copy the component into a fresh solid so its faces/edges/
2891 // vertices have fresh IDs disjoint from the original multi-region
2892 // input — GFA's pavefiller can stumble on shared vertex IDs across
2893 // what it considers a single "solid A".
2894 let comp_solid = crate::copy::copy_solid(topo, comp_solid_raw)?;
2895 match boolean(topo, BooleanOp::Cut, comp_solid, b) {
2896 Ok(r) => per_component_results.push(r),
2897 Err(
2898 crate::OperationsError::EmptyResult { .. }
2899 | crate::OperationsError::InvalidInput { .. },
2900 ) => {
2901 per_component_results.push(comp_solid);
2902 }
2903 Err(e) => return Err(e),
2904 }
2905 }
2906
2907 // Combine all per-component results into a single multi-region solid.
2908 // Collect every face from every result into one outer shell. The
2909 // results are pairwise disjoint by construction (each came from a
2910 // disjoint input component cut by the same tool), so a single shell
2911 // containing all their faces is a valid manifold representation.
2912 let mut all_faces: Vec<brepkit_topology::face::FaceId> = Vec::new();
2913 for &r in &per_component_results {
2914 let r_data = topo.solid(r)?;
2915 for &fid in topo.shell(r_data.outer_shell())?.faces() {
2916 all_faces.push(fid);
2917 }
2918 }
2919 make_solid_from_face_subset(topo, &all_faces)
2920}
2921
2922/// Build a new solid whose outer shell consists exactly of the given
2923/// faces. Faces are referenced as-is (no copying) — the caller is
2924/// expected to pass faces that already form a closed manifold.
2925///
2926/// `reversed=true` faces are NORMALIZED on the way in: a fresh face is
2927/// created with the surface normal negated, the wires reversed, and
2928/// `reversed=false`. Boolean operations downstream are sensitive to the
2929/// `reversed` flag (cut1's output carries reversed faces that GFA can't
2930/// re-process cleanly even via deep-copy), so handing GFA an
2931/// orientation-normalized solid recovers the fresh-primitive code path.
2932fn make_solid_from_face_subset(
2933 topo: &mut Topology,
2934 faces: &[brepkit_topology::face::FaceId],
2935) -> Result<SolidId, crate::OperationsError> {
2936 use brepkit_topology::face::{Face, FaceSurface};
2937 use brepkit_topology::wire::{OrientedEdge, Wire};
2938
2939 let mut normalized: Vec<brepkit_topology::face::FaceId> = Vec::with_capacity(faces.len());
2940 for &fid in faces {
2941 let face = topo.face(fid)?;
2942 if !face.is_reversed() {
2943 normalized.push(fid);
2944 continue;
2945 }
2946 // Only Plane has a trivial negate-the-normal flip. Non-planar
2947 // reversed faces (cylinder/cone/sphere/torus/nurbs) cannot have
2948 // their surface negated cheaply — they hit surface-specific GFA
2949 // paths that don't suffer from the same reversed-flag sensitivity.
2950 // Exhaustive match so a new FaceSurface variant fails to compile
2951 // rather than silently passing through un-normalized.
2952 let flipped_surface = match face.surface() {
2953 FaceSurface::Plane { normal, d } => FaceSurface::Plane {
2954 normal: -*normal,
2955 d: -*d,
2956 },
2957 FaceSurface::Nurbs(_)
2958 | FaceSurface::Cylinder(_)
2959 | FaceSurface::Cone(_)
2960 | FaceSurface::Sphere(_)
2961 | FaceSurface::Torus(_) => {
2962 normalized.push(fid);
2963 continue;
2964 }
2965 };
2966 let outer_wid = face.outer_wire();
2967 let inner_wids: Vec<_> = face.inner_wires().to_vec();
2968 let outer_wire = topo.wire(outer_wid)?;
2969 let outer_reversed: Vec<OrientedEdge> = outer_wire
2970 .edges()
2971 .iter()
2972 .rev()
2973 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2974 .collect();
2975 let new_outer_wire =
2976 Wire::new(outer_reversed, true).map_err(crate::OperationsError::Topology)?;
2977 let new_outer_wid = topo.add_wire(new_outer_wire);
2978 let mut new_inner_wids = Vec::with_capacity(inner_wids.len());
2979 for iw in &inner_wids {
2980 let w = topo.wire(*iw)?;
2981 let rev: Vec<OrientedEdge> = w
2982 .edges()
2983 .iter()
2984 .rev()
2985 .map(|oe| OrientedEdge::new(oe.edge(), !oe.is_forward()))
2986 .collect();
2987 let new_w = Wire::new(rev, true).map_err(crate::OperationsError::Topology)?;
2988 new_inner_wids.push(topo.add_wire(new_w));
2989 }
2990 let new_face = Face::new(new_outer_wid, new_inner_wids, flipped_surface);
2991 normalized.push(topo.add_face(new_face));
2992 }
2993
2994 let shell = brepkit_topology::shell::Shell::new(normalized)
2995 .map_err(crate::OperationsError::Topology)?;
2996 let shell_id = topo.add_shell(shell);
2997 let solid = brepkit_topology::solid::Solid::new(shell_id, Vec::new());
2998 Ok(topo.add_solid(solid))
2999}
3000
3001/// Count inner wire loops across all faces of a solid (outer + inner shells).
3002fn solid_inner_wire_count(topo: &Topology, solid: SolidId) -> Result<i64, crate::OperationsError> {
3003 let mut count: i64 = 0;
3004 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3005 let face = topo.face(fid)?;
3006 #[allow(clippy::cast_possible_wrap)]
3007 {
3008 count += face.inner_wires().len() as i64;
3009 }
3010 }
3011 Ok(count)
3012}
3013
3014/// Genus-aware Euler balance for `components` closed orientable surfaces with
3015/// holed faces.
3016///
3017/// Euler-Poincare over `C` closed components of total genus `G`:
3018/// `V - E + F - L = 2C - 2G`, so the inner-wire surplus `euler - L` is valid
3019/// when it is even and no greater than `2C` — `2C` for all-genus-0 pieces, less
3020/// by two per unit of genus (a thin wall pierced by N through-holes has genus
3021/// N). Odd or `> 2C` surpluses indicate a miscounted shell.
3022///
3023/// The `2C` bound matters as much as the parity: a multi-region result is not
3024/// obliged to be a bag of spheres. A kumiko lattice cut yields RINGS, and a
3025/// closed loop of material is genus 1 (`chi = 0`), so demanding `euler == 2C`
3026/// exactly rejected every lattice result and forced it onto the mesh path.
3027///
3028/// Callers must pair this with a closed-manifold check — the relation only holds
3029/// for closed surfaces.
3030const fn euler_balanced(euler: i64, inner_wires: i64, components: i64) -> bool {
3031 let surplus = euler - inner_wires;
3032 surplus <= components.saturating_mul(2) && surplus % 2 == 0
3033}
3034
3035/// Count edge uses across ALL shells of a solid (outer + inner cavity
3036/// shells). Hollow solids keep cavity faces in inner shells — an
3037/// outer-shell-only walk silently misses their edges, letting open or
3038/// non-manifold cavity shells pass the acceptance gates.
3039fn solid_edge_use_counts(
3040 topo: &Topology,
3041 solid: SolidId,
3042) -> Result<std::collections::HashMap<usize, usize>, crate::OperationsError> {
3043 let mut counts: std::collections::HashMap<usize, usize> = std::collections::HashMap::new();
3044 for fid in brepkit_topology::explorer::solid_faces(topo, solid)? {
3045 let face = topo.face(fid)?;
3046 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3047 let wire = topo.wire(wid)?;
3048 for oe in wire.edges() {
3049 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3050 }
3051 }
3052 }
3053 Ok(counts)
3054}
3055
3056/// Check whether every shell of a solid is a closed manifold: every edge
3057/// is shared by exactly 2 faces within its shell. Returns `false` for open
3058/// shells (boundary edges with count == 1) and non-manifold shells
3059/// (count > 2). Walks inner (cavity) shells as well as the outer shell —
3060/// each shell is an independent closed surface, so a single pooled count
3061/// per shell is correct.
3062///
3063/// Stricter than [`brepkit_topology::validation::validate_shell_manifold`],
3064/// which only rejects edges shared by *more* than two faces.
3065fn is_closed_manifold(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3066 let s = topo.solid(solid)?;
3067 let shell_ids: Vec<_> = std::iter::once(s.outer_shell())
3068 .chain(s.inner_shells().iter().copied())
3069 .collect();
3070 for shell_id in shell_ids {
3071 let shell = topo.shell(shell_id)?;
3072 if !shell_is_closed_manifold(topo, shell)? {
3073 return Ok(false);
3074 }
3075 }
3076 Ok(true)
3077}
3078
3079fn shell_is_closed_manifold(
3080 topo: &Topology,
3081 shell: &brepkit_topology::shell::Shell,
3082) -> Result<bool, crate::OperationsError> {
3083 use std::collections::HashMap;
3084
3085 let mut counts: HashMap<usize, usize> = HashMap::new();
3086 for &fid in shell.faces() {
3087 let face = topo.face(fid)?;
3088 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3089 let wire = topo.wire(wid)?;
3090 for oe in wire.edges() {
3091 *counts.entry(oe.edge().index()).or_insert(0) += 1;
3092 }
3093 }
3094 }
3095 if counts.is_empty() {
3096 return Ok(false);
3097 }
3098 Ok(counts.values().all(|&c| c == 2))
3099}
3100
3101/// Check whether a solid's boundary has free edges: edges used by only
3102/// one wire occurrence. A free edge means the shell is open (e.g. a phantom
3103/// membrane face left a circle edge unmatched), which is never a valid
3104/// boolean result even when Euler accidentally balances.
3105fn has_free_edges(topo: &Topology, solid: SolidId) -> Result<bool, crate::OperationsError> {
3106 let counts = solid_edge_use_counts(topo, solid)?;
3107 Ok(counts.values().any(|&c| c == 1))
3108}
3109
3110/// Cheap read-only test for whether [`flatten_planar_nurbs_faces`] would change
3111/// anything: does `solid` carry a planar NURBS face or a straight NURBS edge?
3112/// Used to gate the deep-copy-and-flatten pre-pass so analytic operands are
3113/// passed to the engine unchanged (a needless deep copy renumbers entity ids
3114/// and can perturb the engine's id-keyed ordering on volume-sensitive cuts).
3115///
3116/// `tol` must match the linear tolerance passed to [`flatten_planar_nurbs_faces`]
3117/// so the gate and the pass agree: a looser default here could report "nothing
3118/// to flatten" while the pass (run at the operation tolerance) would in fact
3119/// rewrite geometry, reintroducing the NURBS-vs-plane fragmentation.
3120fn solid_has_flattenable_nurbs(
3121 topo: &Topology,
3122 solid: SolidId,
3123 tol: f64,
3124) -> Result<bool, crate::OperationsError> {
3125 use brepkit_geometry::convert::{
3126 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3127 };
3128 use brepkit_topology::edge::EdgeCurve;
3129 use brepkit_topology::explorer::solid_faces;
3130
3131 let mut seen = std::collections::HashSet::new();
3132 for fid in solid_faces(topo, solid)? {
3133 let face = topo.face(fid)?;
3134 if let FaceSurface::Nurbs(nurbs) = face.surface()
3135 && matches!(
3136 recognize_surface(nurbs, tol),
3137 RecognizedSurface::Plane { .. }
3138 )
3139 {
3140 return Ok(true);
3141 }
3142 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3143 let wire = topo.wire(wid)?;
3144 for oe in wire.edges() {
3145 let eid = oe.edge();
3146 if !seen.insert(eid.index()) {
3147 continue;
3148 }
3149 if let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve()
3150 && matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. })
3151 {
3152 return Ok(true);
3153 }
3154 }
3155 }
3156 }
3157 Ok(false)
3158}
3159
3160/// Replace planar NURBS faces of `solid` with analytic `Plane` surfaces, and
3161/// straight NURBS boundary edges with the `Line` variant.
3162///
3163/// A NURBS surface whose every control point lies within `tol` of a single
3164/// plane is geometrically a plane; the tool's rounded-rect extrude emits the
3165/// straight cavity walls as planar B-splines, and the boolean engine's
3166/// face-face intersections only take the exact (same-domain) plane×plane path
3167/// when both operands are `FaceSurface::Plane`. Recognising the flat walls as
3168/// planes before the boolean lets coincident/abutting wall regions merge
3169/// analytically instead of fragmenting through the NURBS surface-intersection
3170/// path.
3171///
3172/// The same extrude also leaves the straight cavity-floor/wall boundary edges
3173/// as NURBS curves. A planar-arrangement splitter treats every non-`Line` edge
3174/// as an arc and bails when one is split mid-edge by a coplanar section, so a
3175/// straight NURBS floor edge crossed by the scoop footprint forces the floor
3176/// face to a self-crossing trace. Recognising those straight NURBS edges as
3177/// `Line` lets the arrangement split them exactly.
3178///
3179/// Genuinely curved NURBS surfaces/edges (and all other analytic geometry) are
3180/// left untouched. Returns the number of faces flattened.
3181fn flatten_planar_nurbs_faces(
3182 topo: &mut Topology,
3183 solid: SolidId,
3184 tol: f64,
3185) -> Result<usize, crate::OperationsError> {
3186 use brepkit_geometry::convert::{
3187 RecognizedCurve, RecognizedSurface, recognize_curve, recognize_surface,
3188 };
3189 use brepkit_topology::edge::{EdgeCurve, EdgeId};
3190 use brepkit_topology::explorer::solid_faces;
3191
3192 let face_ids = solid_faces(topo, solid)?;
3193 // Snapshot the surfaces first (immutable borrow), then mutate.
3194 let planar: Vec<(FaceId, Vec3, f64)> = face_ids
3195 .iter()
3196 .filter_map(|&fid| {
3197 let face = topo.face(fid).ok()?;
3198 let FaceSurface::Nurbs(nurbs) = face.surface() else {
3199 return None;
3200 };
3201 match recognize_surface(nurbs, tol) {
3202 RecognizedSurface::Plane { normal, d } => {
3203 // `recognize_surface` derives the plane normal from a
3204 // control-point cross product, whose sign can OPPOSE the
3205 // NURBS surface's own du×dv normal. A `FaceSurface::Plane`
3206 // is read with its normal flipped by `is_reversed`, so an
3207 // opposed sign silently inverts the face's effective
3208 // outward direction. Align to the surface du×dv normal at
3209 // the domain midpoint.
3210 let (u0, u1) = nurbs.domain_u();
3211 let (v0, v1) = nurbs.domain_v();
3212 let mid_n = nurbs.normal(0.5 * (u0 + u1), 0.5 * (v0 + v1)).ok();
3213 let (normal, d) = match mid_n {
3214 Some(n) if normal.dot(n) < 0.0 => (-normal, -d),
3215 _ => (normal, d),
3216 };
3217 Some((fid, normal, d))
3218 }
3219 _ => None,
3220 }
3221 })
3222 .collect();
3223 let count = planar.len();
3224 for (fid, normal, d) in planar {
3225 topo.face_mut(fid)?
3226 .set_surface(FaceSurface::Plane { normal, d });
3227 }
3228
3229 // Straighten NURBS edges that are geometrically lines.
3230 let mut straight_edges: Vec<EdgeId> = Vec::new();
3231 let mut seen = std::collections::HashSet::new();
3232 for &fid in &face_ids {
3233 let face = topo.face(fid)?;
3234 for &wid in std::iter::once(&face.outer_wire()).chain(face.inner_wires()) {
3235 let wire = topo.wire(wid)?;
3236 for oe in wire.edges() {
3237 let eid = oe.edge();
3238 if !seen.insert(eid.index()) {
3239 continue;
3240 }
3241 let EdgeCurve::NurbsCurve(nurbs) = topo.edge(eid)?.curve() else {
3242 continue;
3243 };
3244 if matches!(recognize_curve(nurbs, tol), RecognizedCurve::Line { .. }) {
3245 straight_edges.push(eid);
3246 }
3247 }
3248 }
3249 }
3250 for eid in straight_edges {
3251 topo.edge_mut(eid)?.set_curve(EdgeCurve::Line);
3252 }
3253
3254 Ok(count)
3255}
3256
3257/// Test-only access to [`flatten_planar_nurbs_faces`] so integration tests can
3258/// reproduce the exact operand preprocessing the boolean applies before handing
3259/// the operands to the GFA engine.
3260#[doc(hidden)]
3261pub fn flatten_planar_nurbs_faces_for_tests(
3262 topo: &mut Topology,
3263 solid: SolidId,
3264 tol: f64,
3265) -> Result<usize, crate::OperationsError> {
3266 flatten_planar_nurbs_faces(topo, solid, tol)
3267}
3268
3269/// For each vertex position (quantized at tolerance), picks one canonical
3270/// vertex. Rebuilds all edges and wires to use canonical vertices.
3271/// Creates new edges (doesn't mutate existing ones) to avoid corrupting
3272/// input solids that may share edge topology.
3273#[allow(clippy::items_after_statements, clippy::type_complexity)]
3274fn merge_result_vertices(
3275 topo: &mut Topology,
3276 solid: SolidId,
3277 tol: brepkit_math::tolerance::Tolerance,
3278) -> Result<(), crate::OperationsError> {
3279 use std::collections::{BTreeMap, HashMap};
3280
3281 let shell_id = topo.solid(solid)?.outer_shell();
3282 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3283
3284 let scale = 1.0 / tol.linear;
3285 let quantize = |p: brepkit_math::vec::Point3| -> (i64, i64, i64) {
3286 (
3287 (p.x() * scale).round() as i64,
3288 (p.y() * scale).round() as i64,
3289 (p.z() * scale).round() as i64,
3290 )
3291 };
3292
3293 // Build vertex canonical map: position → first VertexId seen
3294 let mut canonical: BTreeMap<(i64, i64, i64), brepkit_topology::vertex::VertexId> =
3295 BTreeMap::new();
3296 let mut replacements: HashMap<
3297 brepkit_topology::vertex::VertexId,
3298 brepkit_topology::vertex::VertexId,
3299 > = HashMap::new();
3300
3301 for &fid in &face_ids {
3302 let face = topo.face(fid)?;
3303 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3304 let wire = topo.wire(wid)?;
3305 for oe in wire.edges() {
3306 let edge = topo.edge(oe.edge())?;
3307 for vid in [edge.start(), edge.end()] {
3308 let pos = topo.vertex(vid)?.point();
3309 let key = quantize(pos);
3310 let canon = *canonical.entry(key).or_insert(vid);
3311 if canon != vid {
3312 replacements.insert(vid, canon);
3313 }
3314 }
3315 }
3316 }
3317 }
3318
3319 if replacements.is_empty() {
3320 return Ok(());
3321 }
3322
3323 // Rebuild faces with merged vertices
3324 // Cache: (old_edge, new_start, new_end) → new_edge to share edges
3325 let mut edge_cache: HashMap<
3326 (
3327 brepkit_topology::edge::EdgeId,
3328 brepkit_topology::vertex::VertexId,
3329 brepkit_topology::vertex::VertexId,
3330 ),
3331 brepkit_topology::edge::EdgeId,
3332 > = HashMap::new();
3333
3334 // Snapshot face data, then rebuild with merged vertices
3335 struct FaceSnap {
3336 surface: brepkit_topology::face::FaceSurface,
3337 reversed: bool,
3338 outer_oes: Vec<(
3339 brepkit_topology::edge::EdgeId,
3340 bool,
3341 brepkit_topology::edge::EdgeCurve,
3342 brepkit_topology::vertex::VertexId,
3343 brepkit_topology::vertex::VertexId,
3344 Option<f64>, // edge tolerance
3345 )>,
3346 outer_closed: bool,
3347 inner_wires: Vec<(
3348 Vec<(
3349 brepkit_topology::edge::EdgeId,
3350 bool,
3351 brepkit_topology::edge::EdgeCurve,
3352 brepkit_topology::vertex::VertexId,
3353 brepkit_topology::vertex::VertexId,
3354 Option<f64>,
3355 )>,
3356 bool, // wire closed flag
3357 )>,
3358 }
3359
3360 let mut snaps = Vec::with_capacity(face_ids.len());
3361 for &fid in &face_ids {
3362 let face = topo.face(fid)?;
3363 let surface = face.surface().clone();
3364 let reversed = face.is_reversed();
3365 let outer_wire = topo.wire(face.outer_wire())?;
3366 let outer_closed = outer_wire.is_closed();
3367 let outer_oes: Vec<_> = outer_wire
3368 .edges()
3369 .iter()
3370 .map(|oe| -> Result<_, crate::OperationsError> {
3371 let e = topo.edge(oe.edge())?;
3372 Ok((
3373 oe.edge(),
3374 oe.is_forward(),
3375 e.curve().clone(),
3376 e.start(),
3377 e.end(),
3378 e.tolerance(),
3379 ))
3380 })
3381 .collect::<Result<_, _>>()?;
3382 let inner_wids = face.inner_wires().to_vec();
3383 let mut inner_wires = Vec::new();
3384 for iw in inner_wids {
3385 let w = topo.wire(iw)?;
3386 let closed = w.is_closed();
3387 let oes: Vec<_> = w
3388 .edges()
3389 .iter()
3390 .map(|oe| -> Result<_, crate::OperationsError> {
3391 let e = topo.edge(oe.edge())?;
3392 Ok((
3393 oe.edge(),
3394 oe.is_forward(),
3395 e.curve().clone(),
3396 e.start(),
3397 e.end(),
3398 e.tolerance(),
3399 ))
3400 })
3401 .collect::<Result<_, _>>()?;
3402 inner_wires.push((oes, closed));
3403 }
3404 snaps.push(FaceSnap {
3405 surface,
3406 reversed,
3407 outer_oes,
3408 outer_closed,
3409 inner_wires,
3410 });
3411 }
3412
3413 #[allow(clippy::type_complexity)]
3414 let remap_oes = |oes: &[(
3415 brepkit_topology::edge::EdgeId,
3416 bool,
3417 brepkit_topology::edge::EdgeCurve,
3418 brepkit_topology::vertex::VertexId,
3419 brepkit_topology::vertex::VertexId,
3420 Option<f64>,
3421 )],
3422 replacements: &HashMap<
3423 brepkit_topology::vertex::VertexId,
3424 brepkit_topology::vertex::VertexId,
3425 >,
3426 edge_cache: &mut HashMap<
3427 (
3428 brepkit_topology::edge::EdgeId,
3429 brepkit_topology::vertex::VertexId,
3430 brepkit_topology::vertex::VertexId,
3431 ),
3432 brepkit_topology::edge::EdgeId,
3433 >,
3434 topo: &mut Topology|
3435 -> Vec<brepkit_topology::wire::OrientedEdge> {
3436 oes.iter()
3437 .map(|(eid, fwd, curve, start, end, edge_tol)| {
3438 let ns = replacements.get(start).copied().unwrap_or(*start);
3439 let ne = replacements.get(end).copied().unwrap_or(*end);
3440 if ns == *start && ne == *end {
3441 return brepkit_topology::wire::OrientedEdge::new(*eid, *fwd);
3442 }
3443 let key = (*eid, ns, ne);
3444 let new_eid = *edge_cache.entry(key).or_insert_with(|| {
3445 topo.add_edge(brepkit_topology::edge::Edge::with_tolerance(
3446 ns,
3447 ne,
3448 curve.clone(),
3449 *edge_tol,
3450 ))
3451 });
3452 brepkit_topology::wire::OrientedEdge::new(new_eid, *fwd)
3453 })
3454 .collect()
3455 };
3456
3457 let mut new_face_ids = Vec::with_capacity(snaps.len());
3458 for snap in &snaps {
3459 let outer_oes = remap_oes(&snap.outer_oes, &replacements, &mut edge_cache, topo);
3460 let Ok(outer_wire) = brepkit_topology::wire::Wire::new(outer_oes, snap.outer_closed) else {
3461 // Wire rebuild failed — keep the original face unchanged
3462 // rather than silently dropping it
3463 continue;
3464 };
3465 let outer_id = topo.add_wire(outer_wire);
3466
3467 let mut inner_ids = Vec::new();
3468 for (inner_oes_snap, inner_closed) in &snap.inner_wires {
3469 let oes = remap_oes(inner_oes_snap, &replacements, &mut edge_cache, topo);
3470 if let Ok(w) = brepkit_topology::wire::Wire::new(oes, *inner_closed) {
3471 inner_ids.push(topo.add_wire(w));
3472 }
3473 }
3474
3475 let mut new_face =
3476 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3477 if snap.reversed {
3478 new_face.set_reversed(true);
3479 }
3480 new_face_ids.push(topo.add_face(new_face));
3481 }
3482
3483 // Replace the shell's faces
3484 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3485 let new_shell_id = topo.add_shell(new_shell);
3486 let solid_mut = topo.solid_mut(solid)?;
3487 solid_mut.set_outer_shell(new_shell_id);
3488
3489 Ok(())
3490}
3491
3492/// Merge geometrically-coincident duplicate boundary edges on the outer shell.
3493///
3494/// A coincident-junction fuse (e.g. a box stacked on a tapered loft that share
3495/// a cap face) annihilates the shared cap but leaves each argument's faces
3496/// carrying their OWN copy of the junction-wire edges. Because the two copies
3497/// come from independently-built solids their endpoints differ by sub-micron
3498/// numerical noise (loft re-parameterization), so the tight-tolerance vertex
3499/// merge above leaves them as distinct edges — each used once → free edges that
3500/// open the shell.
3501///
3502/// This snaps vertices at `tol_merge` (looser than the default linear
3503/// tolerance, to absorb that noise), then rebuilds every wire against a global
3504/// canonical-edge map keyed by *unordered canonical endpoints + curve type +
3505/// geometric midpoint* — so a straight line and a bulged arc between the same
3506/// endpoints stay distinct, while true duplicates collapse to one shared edge.
3507/// Edges whose endpoints merge to a single vertex (degenerate) are dropped.
3508///
3509/// Returns `true` if anything changed. Run only on already-broken results
3510/// (free edges / non-manifold) so clean booleans keep their exact topology.
3511#[allow(
3512 clippy::too_many_lines,
3513 clippy::type_complexity,
3514 clippy::items_after_statements
3515)]
3516fn unify_coincident_boundary_edges(
3517 topo: &mut Topology,
3518 solid: SolidId,
3519 tol_merge: f64,
3520) -> Result<bool, crate::OperationsError> {
3521 use brepkit_topology::edge::{Edge, EdgeCurve, EdgeId};
3522 use brepkit_topology::vertex::VertexId;
3523 use brepkit_topology::wire::{OrientedEdge, Wire, WireId};
3524 use std::collections::HashMap;
3525
3526 let shell_id = topo.solid(solid)?.outer_shell();
3527 let face_ids: Vec<_> = topo.shell(shell_id)?.faces().to_vec();
3528
3529 let scale = 1.0 / tol_merge;
3530 let q = |p: Point3| -> (i64, i64, i64) {
3531 (
3532 (p.x() * scale).round() as i64,
3533 (p.y() * scale).round() as i64,
3534 (p.z() * scale).round() as i64,
3535 )
3536 };
3537
3538 // 1. Canonical vertex per quantized position (first VertexId seen wins).
3539 let mut vcanon: HashMap<(i64, i64, i64), VertexId> = HashMap::new();
3540 for &fid in &face_ids {
3541 let face = topo.face(fid)?;
3542 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied()) {
3543 let wire = topo.wire(wid)?;
3544 for oe in wire.edges() {
3545 let edge = topo.edge(oe.edge())?;
3546 for vid in [edge.start(), edge.end()] {
3547 let key = q(topo.vertex(vid)?.point());
3548 vcanon.entry(key).or_insert(vid);
3549 }
3550 }
3551 }
3552 }
3553
3554 // 2. Snapshot each face's wires (edge id, fwd, curve, endpoints, tol).
3555 type OeSnap = (EdgeId, bool, EdgeCurve, VertexId, VertexId, Option<f64>);
3556 struct FaceSnap {
3557 surface: FaceSurface,
3558 reversed: bool,
3559 outer: Vec<OeSnap>,
3560 outer_closed: bool,
3561 inners: Vec<(Vec<OeSnap>, bool)>,
3562 }
3563 let snap_wire =
3564 |topo: &Topology, wid: WireId| -> Result<(Vec<OeSnap>, bool), crate::OperationsError> {
3565 let w = topo.wire(wid)?;
3566 let closed = w.is_closed();
3567 let oes = w
3568 .edges()
3569 .iter()
3570 .map(|oe| -> Result<OeSnap, crate::OperationsError> {
3571 let e = topo.edge(oe.edge())?;
3572 Ok((
3573 oe.edge(),
3574 oe.is_forward(),
3575 e.curve().clone(),
3576 e.start(),
3577 e.end(),
3578 e.tolerance(),
3579 ))
3580 })
3581 .collect::<Result<_, _>>()?;
3582 Ok((oes, closed))
3583 };
3584 let mut snaps = Vec::with_capacity(face_ids.len());
3585 for &fid in &face_ids {
3586 let face = topo.face(fid)?;
3587 let surface = face.surface().clone();
3588 let reversed = face.is_reversed();
3589 let (outer, outer_closed) = snap_wire(topo, face.outer_wire())?;
3590 let mut inners = Vec::new();
3591 for iw in face.inner_wires() {
3592 inners.push(snap_wire(topo, *iw)?);
3593 }
3594 snaps.push(FaceSnap {
3595 surface,
3596 reversed,
3597 outer,
3598 outer_closed,
3599 inners,
3600 });
3601 }
3602
3603 // 3. Rebuild wires against a global canonical-edge map.
3604 // Key: (lo endpoint q, hi endpoint q, midpoint q, curve type tag).
3605 type EdgeKey = (
3606 (i64, i64, i64),
3607 (i64, i64, i64),
3608 (i64, i64, i64),
3609 &'static str,
3610 );
3611 let mut ecanon: HashMap<EdgeKey, (EdgeId, VertexId, VertexId)> = HashMap::new();
3612 let mut changed = false;
3613
3614 let canon_vid = |topo: &Topology, vid: VertexId| -> Result<VertexId, crate::OperationsError> {
3615 Ok(*vcanon.get(&q(topo.vertex(vid)?.point())).unwrap_or(&vid))
3616 };
3617
3618 let rebuild = |topo: &mut Topology,
3619 oes: &[OeSnap],
3620 ecanon: &mut HashMap<EdgeKey, (EdgeId, VertexId, VertexId)>,
3621 changed: &mut bool|
3622 -> Result<Vec<OrientedEdge>, crate::OperationsError> {
3623 let mut out = Vec::with_capacity(oes.len());
3624 for (eid, fwd, curve, start, end, etol) in oes {
3625 let cs = canon_vid(topo, *start)?;
3626 let ce = canon_vid(topo, *end)?;
3627 if cs == ce {
3628 // Endpoints collapsed to a single vertex → degenerate, drop it.
3629 *changed = true;
3630 continue;
3631 }
3632 let sp = topo.vertex(*start)?.point();
3633 let ep = topo.vertex(*end)?.point();
3634 let (t0, t1) = curve.domain_with_endpoints(sp, ep);
3635 let mid = curve.evaluate_with_endpoints((t0 + t1) * 0.5, sp, ep);
3636 let (cs_q, ce_q) = (q(topo.vertex(cs)?.point()), q(topo.vertex(ce)?.point()));
3637 let (lo, hi) = if cs_q <= ce_q {
3638 (cs_q, ce_q)
3639 } else {
3640 (ce_q, cs_q)
3641 };
3642 let key = (lo, hi, q(mid), curve.type_tag());
3643
3644 // Physical traversal start vertex (after canonicalization).
3645 let trav_start = if *fwd { cs } else { ce };
3646 if let Some(&(c_eid, c_start, _c_end)) = ecanon.get(&key) {
3647 // A duplicate of an already-seen edge → merge onto the keeper.
3648 *changed = true;
3649 out.push(OrientedEdge::new(c_eid, c_start == trav_start));
3650 } else {
3651 // First edge with this key. Reuse the original edge when its
3652 // endpoints didn't move; only allocate (and flag a change) when
3653 // a vertex was snapped — so an already-clean shell is a no-op.
3654 let (eid_use, e_start) = if cs == *start && ce == *end {
3655 (*eid, *start)
3656 } else {
3657 *changed = true;
3658 (
3659 topo.add_edge(Edge::with_tolerance(cs, ce, curve.clone(), *etol)),
3660 cs,
3661 )
3662 };
3663 ecanon.insert(key, (eid_use, e_start, ce));
3664 out.push(OrientedEdge::new(eid_use, e_start == trav_start));
3665 }
3666 }
3667 Ok(out)
3668 };
3669
3670 let mut new_face_ids = Vec::with_capacity(snaps.len());
3671 for snap in &snaps {
3672 let outer_oes = rebuild(topo, &snap.outer, &mut ecanon, &mut changed)?;
3673 let Ok(outer_wire) = Wire::new(outer_oes, snap.outer_closed) else {
3674 // Keep original face if the rebuilt wire is invalid.
3675 return Ok(false);
3676 };
3677 let outer_id = topo.add_wire(outer_wire);
3678 let mut inner_ids = Vec::new();
3679 for (inner_oes, inner_closed) in &snap.inners {
3680 let oes = rebuild(topo, inner_oes, &mut ecanon, &mut changed)?;
3681 let Ok(w) = Wire::new(oes, *inner_closed) else {
3682 // A dropped hole silently changes topology (and removes free
3683 // edges, so the downstream gate can't catch it). Bail like the
3684 // outer-wire case, leaving the original solid untouched.
3685 return Ok(false);
3686 };
3687 inner_ids.push(topo.add_wire(w));
3688 }
3689 let mut new_face =
3690 brepkit_topology::face::Face::new(outer_id, inner_ids, snap.surface.clone());
3691 if snap.reversed {
3692 new_face.set_reversed(true);
3693 }
3694 new_face_ids.push(topo.add_face(new_face));
3695 }
3696
3697 if !changed {
3698 return Ok(false);
3699 }
3700
3701 let new_shell = brepkit_topology::shell::Shell::new(new_face_ids)?;
3702 let new_shell_id = topo.add_shell(new_shell);
3703 topo.solid_mut(solid)?.set_outer_shell(new_shell_id);
3704 Ok(true)
3705}
3706
3707/// Post-process a solid to enforce manifold topology via greedy flood-fill.
3708///
3709/// Detects non-manifold edges (shared by 3+ faces) and uses greedy
3710/// shell building to split the non-manifold shell into manifold
3711/// sub-shells. The largest sub-shell becomes the outer shell; smaller ones
3712/// become inner shells (cavities).
3713///
3714/// If the solid is already manifold, returns it unchanged.
3715#[allow(clippy::too_many_lines)]
3716fn enforce_manifold_shell(
3717 topo: &mut Topology,
3718 solid: SolidId,
3719) -> Result<SolidId, crate::OperationsError> {
3720 use std::collections::{HashMap, HashSet, VecDeque};
3721
3722 let shell_id = topo.solid(solid)?.outer_shell();
3723 let face_ids = topo.shell(shell_id)?.faces().to_vec();
3724
3725 // Count edges per face.
3726 let mut edge_face_count: HashMap<usize, u32> = HashMap::new();
3727 for &fid in &face_ids {
3728 if let Ok(face) = topo.face(fid) {
3729 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3730 {
3731 if let Ok(wire) = topo.wire(wid) {
3732 for oe in wire.edges() {
3733 *edge_face_count.entry(oe.edge().index()).or_default() += 1;
3734 }
3735 }
3736 }
3737 }
3738 }
3739
3740 // Only apply for significant non-manifold (>3 edges). Minor non-manifold
3741 // (1-3 edges) from sphere/cone intersections is tolerable and splitting
3742 // the shell at those edges breaks downstream operations (section, volume).
3743 let nm_count = edge_face_count.values().filter(|&&c| c > 2).count();
3744 if nm_count <= 3 {
3745 return Ok(solid);
3746 }
3747
3748 log::debug!(
3749 "enforce_manifold_shell: {} non-manifold edges in {} faces",
3750 nm_count,
3751 face_ids.len()
3752 );
3753
3754 // Build vertex-pair → face adjacency for neighbor discovery.
3755 let mut vpair_faces: HashMap<(usize, usize), Vec<brepkit_topology::face::FaceId>> =
3756 HashMap::new();
3757 for &fid in &face_ids {
3758 if let Ok(face) = topo.face(fid) {
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 if let Ok(e) = topo.edge(oe.edge()) {
3764 let si = e.start().index();
3765 let ei = e.end().index();
3766 let key = if si <= ei { (si, ei) } else { (ei, si) };
3767 vpair_faces.entry(key).or_default().push(fid);
3768 }
3769 }
3770 }
3771 }
3772 }
3773 }
3774
3775 // Greedy flood-fill shell construction.
3776 let available: HashSet<brepkit_topology::face::FaceId> = face_ids.iter().copied().collect();
3777 let mut processed: HashSet<brepkit_topology::face::FaceId> = HashSet::new();
3778 let mut shells: Vec<Vec<brepkit_topology::face::FaceId>> = Vec::new();
3779
3780 for &start_face in &face_ids {
3781 if processed.contains(&start_face) {
3782 continue;
3783 }
3784
3785 let mut shell_faces = vec![start_face];
3786 processed.insert(start_face);
3787
3788 // Track edge-ID usage within this shell.
3789 let mut shell_edge_count: HashMap<usize, u32> = HashMap::new();
3790 if let Ok(face) = topo.face(start_face) {
3791 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3792 {
3793 if let Ok(wire) = topo.wire(wid) {
3794 for oe in wire.edges() {
3795 *shell_edge_count.entry(oe.edge().index()).or_default() += 1;
3796 }
3797 }
3798 }
3799 }
3800
3801 let mut queue = VecDeque::new();
3802 queue.push_back(start_face);
3803
3804 while let Some(current) = queue.pop_front() {
3805 let Ok(face) = topo.face(current) else {
3806 continue;
3807 };
3808 // Collect (vpair, edge_id) from all wires.
3809 let mut all_edges = Vec::new();
3810 for wid in std::iter::once(face.outer_wire()).chain(face.inner_wires().iter().copied())
3811 {
3812 if let Ok(wire) = topo.wire(wid) {
3813 for oe in wire.edges() {
3814 if let Ok(e) = topo.edge(oe.edge()) {
3815 let si = e.start().index();
3816 let ei = e.end().index();
3817 let key = if si <= ei { (si, ei) } else { (ei, si) };
3818 all_edges.push((key, oe.edge()));
3819 }
3820 }
3821 }
3822 }
3823
3824 for (vpair, edge_id) in all_edges {
3825 let eidx = edge_id.index();
3826
3827 // Skip edges already manifold in this shell.
3828 if shell_edge_count.get(&eidx).copied().unwrap_or(0) >= 2 {
3829 continue;
3830 }
3831
3832 // Find candidate neighbor faces via vertex-pair.
3833 let candidates: Vec<brepkit_topology::face::FaceId> = vpair_faces
3834 .get(&vpair)
3835 .map(|fs| {
3836 fs.iter()
3837 .copied()
3838 .filter(|&f| {
3839 f != current && available.contains(&f) && !processed.contains(&f)
3840 })
3841 .collect()
3842 })
3843 .unwrap_or_default();
3844
3845 if candidates.is_empty() {
3846 continue;
3847 }
3848
3849 // Pick first candidate (simple heuristic — dihedral selection
3850 // would be better but requires surface normal evaluation).
3851 let selected = candidates[0];
3852
3853 if processed.contains(&selected) {
3854 continue;
3855 }
3856
3857 processed.insert(selected);
3858 shell_faces.push(selected);
3859 queue.push_back(selected);
3860
3861 // Update edge count.
3862 if let Ok(sel_face) = topo.face(selected) {
3863 for wid in std::iter::once(sel_face.outer_wire())
3864 .chain(sel_face.inner_wires().iter().copied())
3865 {
3866 if let Ok(wire) = topo.wire(wid) {
3867 for sel_oe in wire.edges() {
3868 *shell_edge_count.entry(sel_oe.edge().index()).or_default() += 1;
3869 }
3870 }
3871 }
3872 }
3873 }
3874 }
3875
3876 shells.push(shell_faces);
3877 }
3878
3879 // Add any unprocessed faces to a final shell.
3880 let remaining: Vec<brepkit_topology::face::FaceId> = available
3881 .iter()
3882 .filter(|f| !processed.contains(f))
3883 .copied()
3884 .collect();
3885 if !remaining.is_empty() {
3886 shells.push(remaining);
3887 }
3888
3889 if shells.len() <= 1 {
3890 // Single shell — nothing to split.
3891 return Ok(solid);
3892 }
3893
3894 log::debug!(
3895 "enforce_manifold_shell: split into {} shells (sizes: {:?})",
3896 shells.len(),
3897 shells.iter().map(Vec::len).collect::<Vec<_>>(),
3898 );
3899
3900 // Build the solid: largest shell is outer, rest are inner.
3901 let mut best_idx = 0;
3902 let mut best_count = 0;
3903 for (i, faces) in shells.iter().enumerate() {
3904 if faces.len() > best_count {
3905 best_count = faces.len();
3906 best_idx = i;
3907 }
3908 }
3909
3910 let outer = brepkit_topology::shell::Shell::new(shells[best_idx].clone())
3911 .map_err(crate::OperationsError::Topology)?;
3912 let outer_id = topo.add_shell(outer);
3913 let mut inner_ids = Vec::new();
3914 for (i, faces) in shells.iter().enumerate() {
3915 if i != best_idx
3916 && !faces.is_empty()
3917 && let Ok(inner) = brepkit_topology::shell::Shell::new(faces.clone())
3918 {
3919 inner_ids.push(topo.add_shell(inner));
3920 }
3921 }
3922
3923 Ok(topo.add_solid(brepkit_topology::solid::Solid::new(outer_id, inner_ids)))
3924}
3925
3926/// Sample `n` evenly-spaced points along a closed edge curve.
3927///
3928/// For `Circle` and `Ellipse`, samples at `TAU * i / n`.
3929/// For closed `NurbsCurve`, samples across the domain avoiding endpoint
3930/// duplication. Returns an empty vec for `Line` (no sampling possible).
3931pub(crate) fn sample_edge_curve(curve: &EdgeCurve, n: usize) -> Vec<Point3> {
3932 match curve {
3933 EdgeCurve::Circle(c) => (0..n)
3934 .map(|i| {
3935 #[allow(clippy::cast_precision_loss)]
3936 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3937 c.evaluate(t)
3938 })
3939 .collect(),
3940 EdgeCurve::Ellipse(e) => (0..n)
3941 .map(|i| {
3942 #[allow(clippy::cast_precision_loss)]
3943 let t = std::f64::consts::TAU * (i as f64) / (n as f64);
3944 e.evaluate(t)
3945 })
3946 .collect(),
3947 EdgeCurve::NurbsCurve(nc) => {
3948 let (u0, u1) = nc.domain();
3949 // For closed curves (start ~ end), use n as divisor to avoid
3950 // duplicating the first point at t=u_max.
3951 let start_pt = nc.evaluate(u0);
3952 let end_pt = nc.evaluate(u1);
3953 // 1e-6 m: closure detection threshold — if start and end points are
3954 // within 1 micron, treat the NURBS curve as closed to avoid
3955 // duplicating the first point at t=u_max.
3956 let is_closed = (start_pt - end_pt).length() < 1e-6;
3957 let divisor = if is_closed { n } else { n - 1 };
3958 (0..n)
3959 .map(|i| {
3960 #[allow(clippy::cast_precision_loss)]
3961 let t = u0 + (u1 - u0) * (i as f64) / (divisor as f64);
3962 nc.evaluate(t)
3963 })
3964 .collect()
3965 }
3966 EdgeCurve::Line => vec![],
3967 }
3968}
3969
3970/// Get a polygon approximation of a face by sampling curved edges.
3971///
3972/// Samples circle/ellipse edges into 32 points so faces with a
3973/// single closed-curve edge (e.g. cylinder caps) get a proper polygon.
3974///
3975/// # Errors
3976///
3977/// Returns an error if the face or its wire cannot be resolved.
3978pub fn face_polygon(
3979 topo: &Topology,
3980 face_id: FaceId,
3981) -> Result<Vec<Point3>, crate::OperationsError> {
3982 let face = topo.face(face_id)?;
3983 let wire = topo.wire(face.outer_wire())?;
3984 let mut pts = Vec::new();
3985
3986 for oe in wire.edges() {
3987 let edge = topo.edge(oe.edge())?;
3988 let curve = edge.curve();
3989 // Sample closed parametric edges (start == end vertex).
3990 // Partial arcs fall through to the vertex-based path.
3991 let start_vid = edge.start();
3992 let end_vid = edge.end();
3993 let is_closed_edge = start_vid == end_vid
3994 && matches!(
3995 curve,
3996 EdgeCurve::Circle(_) | EdgeCurve::Ellipse(_) | EdgeCurve::NurbsCurve(_)
3997 );
3998 if is_closed_edge {
3999 // Must use CLOSED_CURVE_SAMPLES (not a larger value) — vertex count
4000 // must match create_band_fragments and inner-wire dedup for sharing.
4001 let mut sampled = sample_edge_curve(curve, types::CLOSED_CURVE_SAMPLES);
4002 if !oe.is_forward() {
4003 sampled.reverse();
4004 }
4005 pts.extend(sampled);
4006 } else {
4007 let vid = oe.oriented_start(edge);
4008 pts.push(topo.vertex(vid)?.point());
4009 }
4010 }
4011
4012 Ok(pts)
4013}
4014
4015/// Collect face signatures (index, normal, centroid) for evolution tracking.
4016///
4017/// For each face of the solid, computes a representative normal and centroid
4018/// from the face polygon. Used by [`boolean_with_evolution`] to match output
4019/// faces back to input faces.
4020///
4021/// # Errors
4022///
4023/// Returns an error if any face or wire cannot be resolved.
4024/// Snapshot each outer-shell face as `(index, face normal, centroid)` — the
4025/// signature [`crate::evolution::build_evolution_by_geometry`] matches on. The
4026/// normal is the stored plane normal (or a polygon-derived normal for
4027/// non-planar faces), not re-oriented by the face's `reversed` flag; matching
4028/// stays consistent because input and output faces use the same convention.
4029pub fn collect_face_signatures(
4030 topo: &Topology,
4031 solid_id: SolidId,
4032) -> Result<Vec<(usize, Vec3, Point3)>, crate::OperationsError> {
4033 let solid = topo.solid(solid_id)?;
4034 let shell = topo.shell(solid.outer_shell())?;
4035 let mut result = Vec::with_capacity(shell.faces().len());
4036
4037 for &fid in shell.faces() {
4038 let face = topo.face(fid)?;
4039 let verts = face_polygon(topo, fid)?;
4040 let normal = if let FaceSurface::Plane { normal, .. } = face.surface() {
4041 *normal
4042 } else if verts.len() >= 3 {
4043 let e1 = verts[1] - verts[0];
4044 let e2 = verts[2] - verts[0];
4045 e1.cross(e2).normalize().unwrap_or(Vec3::new(0.0, 0.0, 1.0))
4046 } else {
4047 Vec3::new(0.0, 0.0, 1.0)
4048 };
4049
4050 let centroid = classify::polygon_centroid(&verts);
4051 result.push((fid.index(), normal, centroid));
4052 }
4053
4054 Ok(result)
4055}
4056
4057#[cfg(test)]
4058mod tests;