Skip to main content

brep_kernel/csg/boolean/
mod.rs

1use crate::arrangement::Vec2;
2use crate::classification::{classify_point, parameter_point_in_face, PolygonClass};
3use crate::fragment::{FaceFragmentRecord, FragmentEdgeSource};
4use crate::imprint::{
5    EdgeSplitRecord, FaceImprints, FaceKey, ImprintOptions, ImprintPieceRecord,
6    ImprintResultRecord, ImprintVertex,
7};
8use crate::tolerance::{assembler_weld, commit_weld};
9use crate::topology::{
10    adaptive_coedge_error, BrepSolid, CoedgeRecord, EdgeRecord, FaceRecord, LoopRecord,
11    ShellRecord, VertexRecord,
12};
13use crate::{
14    apply_edge_splits, apply_edge_splits_with_map, build_imprints, build_pcurve_on_surface,
15    build_pcurve_on_surface_range, classify_surface_pair,
16    fragment_solid, interpolate_curve, merge_curve_continuation_edges,
17    merge_same_surface_faces_excluding,
18    project_point_to_curve, project_point_to_surface, solid_signed_volume, AffineTransform,
19    DiagnosticSeverity, KernelDiagnostics, KernelOutcome, KernelStage, KernelTolerances, NurbsCurve,
20    PointClass, SolidClassifier, SurfacePairRelation, Vec3,
21};
22use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
23use serde::{Deserialize, Serialize};
24use web_time::Instant;
25
26thread_local! {
27    /// Running count (per thread) of one-use edges the EDGE-CONFORMANCE
28    /// repair lanes merged or bridged.  The lanes only fire on an assembly
29    /// that would otherwise refuse, so a zero delta across an operation is
30    /// an honest witness that the boolean assembled cleanly without the
31    /// repair.  Test-observable; not part of any result.
32    pub(crate) static CONFORMANCE_REPAIRS: std::cell::Cell<u64> =
33        const { std::cell::Cell::new(0) };
34}
35
36#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
37#[serde(rename_all = "lowercase")]
38pub enum BooleanOperation {
39    Union,
40    Intersect,
41    Subtract,
42}
43
44#[derive(Clone, Debug, Deserialize)]
45pub struct BooleanOptions {
46    #[serde(default = "default_tolerance")]
47    pub tolerance: f64,
48    /// Optional complete accuracy/search policy.  `tolerance` remains the
49    /// backwards-compatible model-identity shorthand when this is absent.
50    #[serde(default)]
51    pub tolerances: Option<KernelTolerances>,
52    #[serde(default)]
53    pub imprint: ImprintOptions,
54    /// Coalesce adjacent result fragments after assembly. This defaults to
55    /// true to preserve the kernel's normal clean-boundary behavior.
56    #[serde(default = "default_true")]
57    pub merge_coplanar_faces: bool,
58    /// Face-name substrings that PIN a result face out of the coplanar/cosurface
59    /// merge (only consulted when [`merge_coplanar_faces`] is on). A face whose
60    /// `name` CONTAINS any of these is emitted unchanged instead of being
61    /// coalesced with a mergeable neighbour; faces matching none merge exactly as
62    /// before. Default empty — no caller sees a behavior change unless it opts
63    /// in. Sheet metal uses this to keep every thickness/side wall face per
64    /// outline segment in the folded solid (so a flange can still attach to a
65    /// specific segment: fusing two collinear thickness faces would erase the
66    /// segment boundary).
67    #[serde(default)]
68    pub keep_unmerged_name_substrs: Vec<String>,
69}
70
71fn default_tolerance() -> f64 {
72    1e-7
73}
74
75fn default_true() -> bool {
76    true
77}
78
79impl Default for BooleanOptions {
80    fn default() -> Self {
81        Self {
82            tolerance: default_tolerance(),
83            tolerances: None,
84            imprint: ImprintOptions::default(),
85            merge_coplanar_faces: true,
86            keep_unmerged_name_substrs: Vec::new(),
87        }
88    }
89}
90
91mod select;
92mod assemble;
93mod rim;
94#[cfg(test)]
95mod tests;
96
97use rim::*;
98use select::*;
99// The non-API `assemble` helpers (Assembler, SourceEdge, conform/repair/polish
100// internals) are consumed only by this module's own `#[cfg(test)] mod tests`.
101#[cfg(test)]
102use assemble::*;
103pub(crate) use assemble::{
104    assemble_fragments, assemble_open_fragments, commit_nearby_edge_endpoints,
105    edge_interior_lies_on, finalize_assembled_solid,
106};
107// Preserve the pre-split `crate::boolean::apply_assembly_heal_chain` path.
108// Currently referenced only inside `assemble`, so the re-export is unused.
109#[allow(unused_imports)]
110pub(crate) use assemble::apply_assembly_heal_chain;
111
112pub fn boolean_operation(
113    first: &BrepSolid,
114    second: &BrepSolid,
115    operation: BooleanOperation,
116    options: &BooleanOptions,
117) -> Result<BrepSolid, String> {
118    match boolean_operation_with_diagnostics(first, second, operation, options) {
119        Ok(outcome) => Ok(outcome.value),
120        Err(error) => {
121            // Perturbation-fallback lane (Simulation of Simplicity). The exact
122            // arrangement only errs on a DEGENERACY here (coincident /
123            // near-tangent carrier faces make it structurally inconsistent) —
124            // e.g. a partial torus whose equatorial circles lie exactly in a box
125            // face. Retry once on a rigidly perturbed second operand; accept only
126            // an oracle-clean, volume-consistent result. See
127            // `perturbation_retry`.
128            if std::env::var("BREP_NO_PERTURB").as_deref() == Ok("1")
129                || !is_degeneracy_error(&error)
130            {
131                return Err(error);
132            }
133            // INTERNAL-TANGENCY PINCH GATE. Perturbation is only legitimate
134            // where the exact arrangement is UNSTABLE but its ANSWER is not:
135            // the nearby transversal case must carry the same topology the
136            // degenerate one does. An internal tangency in a DIFFERENCE breaks
137            // exactly that premise — the exact `A − B` pinches to zero
138            // thickness along the contact, so an epsilon either tears the wall
139            // open (a through-slot where the exact answer has intact material)
140            // or leaves a sub-micron web. Both are within epsilon in Hausdorff
141            // distance and in volume, so neither the oracle (boundary skip
142            // band) nor the 1% CSG volume bound can see them, and the lane
143            // would ship the tolerant-kernel sliver this kernel refuses. Only
144            // consulted on the failure path, so nothing that succeeds today can
145            // change; the honest exact-path error is returned instead.
146            // Escape hatch for tamper-verification: BREP_PINCH_GATE=0.
147            if matches!(operation, BooleanOperation::Subtract)
148                && std::env::var("BREP_PINCH_GATE").as_deref() != Ok("0")
149                && subtract_pinches_at_internal_tangency(first, second, options.tolerance)
150                    .unwrap_or(false)
151            {
152                return Err(format!(
153                    "{error}; internal tangency: the operands touch tangentially with \
154                     co-directed normals, so the exact difference pinches to zero thickness \
155                     (non-manifold, unrepresentable in a boundary model) — refusing rather \
156                     than returning a perturbed sliver"
157                ));
158            }
159            match perturbation_retry(first, second, operation, options) {
160                Some(solid) => Ok(solid),
161                None => Err(error),
162            }
163        }
164    }
165}
166
167/// Whether a boolean error looks like an arrangement DEGENERACY worth a
168/// perturbation retry (as opposed to a legitimate conservative refusal —
169/// empty/disjoint operands — which perturbation cannot and must not "fix").
170fn is_degeneracy_error(message: &str) -> bool {
171    message.contains("invalid topology")
172        || message.contains("non-integral genus")
173        || message.contains("non-positive volume")
174        || message.contains("open edges")
175        // Tangent-node / singular surface intersections (e.g. two equal-radius
176        // cylinders whose axes intersect, a uniform-radius tube corner): the SoS
177        // perturbation breaks the exact tangency into a clean transversal crossing.
178        || message.contains("singular/tangent-node")
179}
180
181/// Grid resolution (per parameter direction, endpoints included) used to probe a
182/// face pair for a tangential contact. Endpoints and the midpoint are both
183/// sampled, so a contact sitting on a periodic seam (a cylinder's `u = 0`
184/// ruling) and one sitting at a face's parametric centre are both hit exactly.
185const PINCH_PROBE_STEPS: usize = 8;
186
187/// `|n_a × n_b|` bound below which two unit normals count as parallel — the same
188/// bound `csg::imprint`'s pair classifier uses (`PAIR_ANGULAR_TOLERANCE`).
189const PINCH_ANGULAR_TOLERANCE: f64 = 1e-4;
190
191/// Parameter-space step (fraction of the domain span) taken around a contact
192/// sample to prove the contact is LOWER-DIMENSIONAL — a tangency curve/point
193/// rather than a cosurface patch.
194const PINCH_SEPARATION_STEP: f64 = 1e-2;
195
196/// Would the exact `first − second` PINCH to zero thickness at a tangential
197/// contact?
198///
199/// True when some face of `first` and some face of `second` touch tangentially
200/// (surfaces within the contact band, normals parallel) with CO-DIRECTED outward
201/// normals, at a contact that is lower-dimensional (the surfaces separate as you
202/// step away from it).
203///
204/// Co-directed outward normals at a tangency mean one solid lies locally INSIDE
205/// the other: subtracting leaves material on both sides of the contact that
206/// meets there at zero thickness — a non-manifold pinch no boundary model can
207/// represent. Anti-directed normals are the harmless EXTERNAL tangency (a
208/// cylinder resting against a wall): the difference simply keeps `first` intact.
209/// The lower-dimensionality requirement excludes a CO-SURFACE contact (a pocket
210/// wall flush with an outer wall — co-directed normals, but the surfaces stay
211/// coincident in every direction), where the difference is perfectly well
212/// behaved.
213///
214/// Conservative by construction: it only reports a pinch it can actually witness
215/// on the probe grid, and a miss just leaves the perturbation lane to its
216/// existing gates.
217fn subtract_pinches_at_internal_tangency(
218    first: &BrepSolid,
219    second: &BrepSolid,
220    tolerance: f64,
221) -> Result<bool, String> {
222    // Same contact band the imprint pair classifier uses for its sampled
223    // "these carriers touch" verdict.
224    let contact = (tolerance * 100.0).max(1e-12);
225    let probes_a = first
226        .shells
227        .iter()
228        .flat_map(|shell| &shell.faces)
229        .map(PinchProbe::of)
230        .collect::<Result<Vec<_>, _>>()?;
231    let probes_b = second
232        .shells
233        .iter()
234        .flat_map(|shell| &shell.faces)
235        .map(PinchProbe::of)
236        .collect::<Result<Vec<_>, _>>()?;
237    for probe_a in &probes_a {
238        for probe_b in &probes_b {
239            // Sampled-hull cull, so the (failure-path-only) cost stays linear in
240            // the faces that actually touch rather than quadratic in every face.
241            // The hull is built from the probe grid, so it can under-cover a
242            // curved carrier between samples: pad it by a percent of the pair's
243            // size before culling. Over-keeping a pair only costs a probe that
244            // finds nothing.
245            let pad = 1e-2 * probe_a.extent().max(probe_b.extent());
246            if probe_a.separation(probe_b) > contact + pad {
247                continue;
248            }
249            // CO-SURFACE contacts are 2-dimensional, not tangencies: two flush
250            // walls (a pocket's side coincident with an outer wall, a cylinder
251            // cap lying in a box face) have co-directed normals wherever the
252            // solids nest, yet the difference there is perfectly well behaved.
253            // Only a LOWER-dimensional contact pinches, so drop the pair the
254            // pair classifier calls cosurface before probing it.
255            if classify_surface_pair(
256                &probe_a.face.surface,
257                &probe_b.face.surface,
258                tolerance,
259                PINCH_ANGULAR_TOLERANCE,
260            )?
261            .relation
262                == SurfacePairRelation::Cosurface
263            {
264                continue;
265            }
266            if faces_touch_with_codirected_normals(probe_a, probe_b, contact)? {
267                return Ok(true);
268            }
269        }
270    }
271    Ok(false)
272}
273
274/// A face's probe grid plus the grid's bounding box, built once per face so the
275/// pair loop only pays for a hull comparison.
276struct PinchProbe<'a> {
277    face: &'a FaceRecord,
278    /// `(u, v, point)` on the face's carrier surface.
279    samples: Vec<(f64, f64, Vec3)>,
280    minimum: [f64; 3],
281    maximum: [f64; 3],
282}
283
284impl<'a> PinchProbe<'a> {
285    fn of(face: &'a FaceRecord) -> Result<Self, String> {
286        let [u0, u1] = face.surface.domain_u()?;
287        let [v0, v1] = face.surface.domain_v()?;
288        let steps = PINCH_PROBE_STEPS as f64;
289        let mut samples = Vec::with_capacity((PINCH_PROBE_STEPS + 1).pow(2));
290        let mut minimum = [f64::INFINITY; 3];
291        let mut maximum = [f64::NEG_INFINITY; 3];
292        for i in 0..=PINCH_PROBE_STEPS {
293            let u = u0 + (u1 - u0) * i as f64 / steps;
294            for j in 0..=PINCH_PROBE_STEPS {
295                let v = v0 + (v1 - v0) * j as f64 / steps;
296                let Ok(point) = face.surface.evaluate(u, v) else {
297                    continue;
298                };
299                for (axis, value) in [point.x, point.y, point.z].into_iter().enumerate() {
300                    minimum[axis] = minimum[axis].min(value);
301                    maximum[axis] = maximum[axis].max(value);
302                }
303                samples.push((u, v, point));
304            }
305        }
306        Ok(Self {
307            face,
308            samples,
309            minimum,
310            maximum,
311        })
312    }
313
314    /// Largest side of the sampled hull (0 when nothing sampled).
315    fn extent(&self) -> f64 {
316        (0..3)
317            .map(|axis| self.maximum[axis] - self.minimum[axis])
318            .fold(0.0f64, f64::max)
319    }
320
321    /// Axis-aligned gap between the two sampled hulls (0 when they overlap).
322    fn separation(&self, other: &Self) -> f64 {
323        let mut gap: f64 = 0.0;
324        for axis in 0..3 {
325            gap = gap.max(self.minimum[axis] - other.maximum[axis]);
326            gap = gap.max(other.minimum[axis] - self.maximum[axis]);
327        }
328        gap
329    }
330}
331
332/// One face pair of [`subtract_pinches_at_internal_tangency`]: probe both
333/// surfaces on a grid, keep samples that land on the other surface inside BOTH
334/// trims with parallel co-directed outward normals, and accept only where the
335/// contact provably separates nearby.
336fn faces_touch_with_codirected_normals(
337    probe_a: &PinchProbe<'_>,
338    probe_b: &PinchProbe<'_>,
339    contact: f64,
340) -> Result<bool, String> {
341    for (probe, other) in [(probe_a, probe_b), (probe_b, probe_a)] {
342        let source = probe.face;
343        let target = other.face;
344        for &(u, v, point) in &probe.samples {
345            let projection = project_point_to_surface(&target.surface, point)?;
346            if projection.distance > contact {
347                continue;
348            }
349            // A pole (collapsed du × dv) has no reliable orientation here;
350            // skipping it keeps the predicate conservative.
351            let (Ok(source_normal), Ok(target_normal)) = (
352                source.surface.normal(u, v),
353                target.surface.normal(projection.u, projection.v),
354            ) else {
355                continue;
356            };
357            let source_outward = outward_normal(source_normal, source.same_sense);
358            let target_outward = outward_normal(target_normal, target.same_sense);
359            if source_outward.cross(target_outward).length() > PINCH_ANGULAR_TOLERANCE
360                || source_outward.dot(target_outward) <= 0.0
361            {
362                continue;
363            }
364            if parameter_point_in_face(source, Vec2 { x: u, y: v }, 1e-6)?
365                == PolygonClass::Outside
366                || parameter_point_in_face(
367                    target,
368                    Vec2 {
369                        x: projection.u,
370                        y: projection.v,
371                    },
372                    1e-6,
373                )? == PolygonClass::Outside
374            {
375                continue;
376            }
377            if contact_separates_locally(source, target, u, v, contact)? {
378                return Ok(true);
379            }
380        }
381    }
382    Ok(false)
383}
384
385fn outward_normal(normal: Vec3, same_sense: bool) -> Vec3 {
386    if same_sense {
387        normal
388    } else {
389        normal.scale(-1.0)
390    }
391}
392
393/// Does the contact at `source(u, v)` LEAVE the contact band when you step away
394/// from it in parameter space? A tangency curve or point does (stepping across
395/// the tangency separates the surfaces quadratically); a cosurface patch does
396/// not (it stays coincident in every direction). Steps are clamped into the
397/// domain, so a sample on a seam or a domain edge probes only inward.
398fn contact_separates_locally(
399    source: &FaceRecord,
400    target: &FaceRecord,
401    u: f64,
402    v: f64,
403    contact: f64,
404) -> Result<bool, String> {
405    let [u0, u1] = source.surface.domain_u()?;
406    let [v0, v1] = source.surface.domain_v()?;
407    let step_u = (u1 - u0) * PINCH_SEPARATION_STEP;
408    let step_v = (v1 - v0) * PINCH_SEPARATION_STEP;
409    for (probe_u, probe_v) in [
410        ((u + step_u).min(u1), v),
411        ((u - step_u).max(u0), v),
412        (u, (v + step_v).min(v1)),
413        (u, (v - step_v).max(v0)),
414    ] {
415        if (probe_u - u).abs() < f64::EPSILON && (probe_v - v).abs() < f64::EPSILON {
416            continue;
417        }
418        let Ok(point) = source.surface.evaluate(probe_u, probe_v) else {
419            continue;
420        };
421        // A generous multiple of the band: the step must clear it decisively,
422        // never on projection noise.
423        if project_point_to_surface(&target.surface, point)?.distance > contact * 10.0 {
424            return Ok(true);
425        }
426    }
427    Ok(false)
428}
429
430/// Deterministic perturbation-fallback (Simulation of Simplicity), the textbook
431/// CAD answer to an exact/near-tangent arrangement degeneracy (see
432/// `BOOLEAN-NEXT-STEPS.md`). Rigidly TRANSLATE the second operand by a tiny
433/// epsilon so the degenerate contact becomes a clean transversal crossing, redo
434/// the boolean, and accept the result ONLY if it is (a) topologically valid —
435/// guaranteed by `assemble`'s own gate returning `Ok` — (b) semantically equal to
436/// the exact CSG of the ORIGINAL operands (the point-classification oracle), and
437/// (c) volume-consistent with the CSG inequality bounds. A wrong perturbed result
438/// is rejected on (b)/(c), so this lane preserves the engine's fail-safe property:
439/// it can only turn a degeneracy-error into a CORRECT solid, never a wrong one.
440///
441/// INVARIANT — no recursion: this calls `boolean_operation_with_diagnostics`
442/// (the EXACT path), never `boolean_operation`, so the fallback can never
443/// re-enter itself. A future refactor must preserve that.
444fn perturbation_retry(
445    first: &BrepSolid,
446    second: &BrepSolid,
447    operation: BooleanOperation,
448    options: &BooleanOptions,
449) -> Option<BrepSolid> {
450    // Generic translation directions: each has a substantial component on ALL
451    // three axes, so whatever axis the coincidence normal lies along (an
452    // equatorial plane flush with a box face, a cap plane flush with a side
453    // face, …) at least one direction has a component that lifts the contact off
454    // exact tangency. Axis-aligned directions are deliberately excluded — a nudge
455    // parallel to the coincident plane leaves the degeneracy in place (verified:
456    // pure ±X / ±Z never rescue the flush-torus case). Fixed (not hash-seeded):
457    // fixed vectors already satisfy the plan doc's determinism intent and four
458    // diverse directions cover any axis-aligned normal.
459    const DIRECTIONS: [[f64; 3]; 4] = [
460        [0.4034, 0.7973, 0.4491],
461        [0.7973, 0.4491, 0.4034],
462        [0.4491, 0.4034, 0.7973],
463        [0.5774, -0.5774, 0.5774],
464    ];
465    // Fractions of operand scale, ordered SMALLEST-FIRST so the accepted solid
466    // carries the least geometric offset. Sized above the near-tangent sliver
467    // dead zone and below local feature size; the ladder + accept-gate make the
468    // exact magnitude non-critical (a rung that lands in a dead zone simply fails
469    // the gate and the next rung is tried).
470    const FRACTIONS: [f64; 5] = [3e-5, 1e-4, 3e-4, 1e-3, 3e-3];
471
472    let debug = std::env::var("BREP_DEBUG_BOOL").is_ok();
473    let scale = crate::tolerance::solid_scale(first)
474        .max(crate::tolerance::solid_scale(second))
475        .max(1.0);
476
477    // Original operand volumes for the CSG inequality backstop (the oracle skips
478    // points near boundaries, so a wrong thin shell hugging the flush face is
479    // invisible to it but shows up here).
480    let va = solid_signed_volume(first).ok().map(f64::abs);
481    let vb = solid_signed_volume(second).ok().map(f64::abs);
482
483    for &fraction in &FRACTIONS {
484        let magnitude = scale * fraction;
485        for dir in &DIRECTIONS {
486            let offset = [dir[0] * magnitude, dir[1] * magnitude, dir[2] * magnitude];
487            let translate = match AffineTransform::new([
488                1.0, 0.0, 0.0, offset[0], //
489                0.0, 1.0, 0.0, offset[1], //
490                0.0, 0.0, 1.0, offset[2], //
491                0.0, 0.0, 0.0, 1.0,
492            ]) {
493                Ok(transform) => transform,
494                Err(_) => continue,
495            };
496            let moved = match crate::transform_brep(second, translate, false) {
497                Ok(solid) => solid,
498                Err(_) => continue,
499            };
500            let candidate =
501                match boolean_operation_with_diagnostics(first, &moved, operation, options) {
502                    Ok(outcome) => outcome.value,
503                    Err(_) => continue,
504                };
505            // A perturbation must not "rescue" a case into emptiness.
506            if candidate.shells.is_empty() {
507                continue;
508            }
509            // (c) Volume must satisfy the CSG inequality for the ORIGINAL
510            // operands (generous 1% relative slack: catches gross wrongness —
511            // doubled/halved bodies, missing lobes — without rejecting the
512            // legitimate sub-micron offset).
513            if let (Some(va), Some(vb)) = (va, vb) {
514                if let Ok(vr) = solid_signed_volume(&candidate) {
515                    if !volume_within_csg_bounds(operation, va, vb, vr.abs()) {
516                        continue;
517                    }
518                }
519            }
520            // (b) Semantic agreement with the exact CSG of the ORIGINAL
521            // operands. Reject a vacuous verdict (skip-band swallowed every
522            // sample so nothing was actually checked).
523            match crate::oracle::boolean_semantic_disagreement(
524                first, second, operation, &candidate, 400,
525            ) {
526                Ok(report) if report.considered >= 30 && !report.is_flagged() => {
527                    if debug {
528                        eprintln!(
529                            "[perturb] rescued {operation:?}: dir={dir:?} fraction={fraction:.1e} \
530                             magnitude={magnitude:.3e} offset={offset:?} \
531                             (oracle considered={} rate={:.4})",
532                            report.considered, report.disagreement_rate
533                        );
534                    }
535                    return Some(candidate);
536                }
537                _ => continue,
538            }
539        }
540    }
541    if debug {
542        eprintln!("[perturb] ladder exhausted for {operation:?}; no rung produced a clean result");
543    }
544    None
545}
546
547/// CSG volume inequality bounds (no extra boolean needed) with 1% relative slack.
548/// `va`, `vb`, `vr` are absolute volumes of operand A, operand B, and the result.
549fn volume_within_csg_bounds(operation: BooleanOperation, va: f64, vb: f64, vr: f64) -> bool {
550    let slack = 1e-2 * (va + vb).max(1.0);
551    match operation {
552        // max(A,B) ≤ A∪B ≤ A+B
553        BooleanOperation::Union => vr >= va.max(vb) - slack && vr <= va + vb + slack,
554        // A−B ≤ A (and ≥ 0)
555        BooleanOperation::Subtract => vr <= va + slack && vr >= -slack,
556        // A∩B ≤ min(A,B)
557        BooleanOperation::Intersect => vr <= va.min(vb) + slack && vr >= -slack,
558    }
559}
560
561// ---------------------------------------------------------------------------
562// N-ary booleans (Golovanov T4.4)
563//
564// Union / intersect / subtract of N solids in ONE imprint+fragment pass.  This
565// is a NEW ADDITIVE entry point: the binary `boolean_operation` above is left
566// byte-identical.  The pipeline mirrors the binary one but generalizes the two
567// operand-specific stages to N operands:
568//
569//   1. Imprint EVERY unordered pair (i, j) so every mutual intersection edge is
570//      present on both operands' faces, and accumulate all pairs into ONE global
571//      imprint (operand indices remapped to 0..N, piece/vertex ids made globally
572//      unique, per-face piece lists and per-edge split parameters merged).
573//   2. Split + fragment each operand by that single global imprint, so each
574//      operand's faces are cut by ALL of its intersections at once.
575//   3. Select fragments in one pass: the per-fragment keep decision generalizes
576//      from "vs one other solid" to "vs the SET of other solids" (see
577//      `nary_keep`).  Because every pair is imprinted, a fragment's interior lies
578//      wholly inside or wholly outside each other operand, so a single interior
579//      test point per (fragment, other-solid) is an exact membership verdict.
580//   4. Assemble with the same machinery (`assemble_fragments`), then merge and
581//      validate exactly as the binary path does.
582// ---------------------------------------------------------------------------
583
584/// Reverse a fragment's orientation (surface sense + every coedge), the same
585/// transform the binary Subtract path applies to the second operand's kept
586/// fragments.  Extracted so the n-ary selector can reuse it verbatim.
587fn reverse_fragment(fragment: &mut FaceFragmentRecord) -> Result<(), String> {
588    fragment.same_sense = !fragment.same_sense;
589    for loop_record in &mut fragment.loops {
590        loop_record.coedges.reverse();
591        for coedge in &mut loop_record.coedges {
592            coedge.forward = !coedge.forward;
593            coedge.pcurve = coedge.pcurve.reversed()?;
594        }
595    }
596    Ok(())
597}
598
599/// Imprint every unordered pair of operands and fold the pairwise
600/// `ImprintResultRecord`s into ONE global imprint whose operand indices are the
601/// operands' global positions (0..N).  Piece and vertex ids are re-based to be
602/// globally unique; `by_face` piece lists and `edge_splits` parameter lists are
603/// MERGED per key so `fragment_face` (which takes the first `by_face` match) and
604/// `apply_edge_splits` (which collects one entry per edge) see every cut.
605fn build_nary_imprint(
606    operands: &[BrepSolid],
607    options: &ImprintOptions,
608) -> Result<ImprintResultRecord, String> {
609    let mut section_evidence = false;
610    let mut vertices: Vec<ImprintVertex> = Vec::new();
611    let mut pieces: Vec<ImprintPieceRecord> = Vec::new();
612    let mut by_face: HashMap<(u8, u64), Vec<u64>> = HashMap::default();
613    let mut edge_splits: HashMap<(u8, u64), Vec<f64>> = HashMap::default();
614    let mut next_vertex_id: u64 = 1;
615    let mut next_piece_id: u64 = 1;
616
617    for i in 0..operands.len() {
618        for j in (i + 1)..operands.len() {
619            let pair = build_imprints(&operands[i], &operands[j], options)?;
620            let remap = |operand: u8| -> u8 {
621                if operand == 0 {
622                    i as u8
623                } else {
624                    j as u8
625                }
626            };
627
628            section_evidence = section_evidence || pair.section_evidence;
629            let mut vertex_map: HashMap<u64, u64> = HashMap::default();
630            for vertex in &pair.vertices {
631                let global = next_vertex_id;
632                next_vertex_id += 1;
633                vertex_map.insert(vertex.id, global);
634                vertices.push(ImprintVertex {
635                    id: global,
636                    point: vertex.point,
637                });
638            }
639
640            let mut piece_map: HashMap<u64, u64> = HashMap::default();
641            for piece in &pair.pieces {
642                let global = next_piece_id;
643                next_piece_id += 1;
644                piece_map.insert(piece.id, global);
645                let mut mapped = piece.clone();
646                mapped.id = global;
647                mapped.start_vertex_id = *vertex_map
648                    .get(&piece.start_vertex_id)
649                    .ok_or_else(|| "n-ary imprint: piece references unknown vertex".to_string())?;
650                mapped.end_vertex_id = *vertex_map
651                    .get(&piece.end_vertex_id)
652                    .ok_or_else(|| "n-ary imprint: piece references unknown vertex".to_string())?;
653                for pcurve in &mut mapped.pcurves {
654                    pcurve.operand = remap(pcurve.operand);
655                }
656                mapped.support_faces = [
657                    FaceKey {
658                        operand: remap(piece.support_faces[0].operand),
659                        face_id: piece.support_faces[0].face_id,
660                    },
661                    FaceKey {
662                        operand: remap(piece.support_faces[1].operand),
663                        face_id: piece.support_faces[1].face_id,
664                    },
665                ];
666                pieces.push(mapped);
667            }
668
669            for entry in &pair.by_face {
670                let list = by_face
671                    .entry((remap(entry.operand), entry.face_id))
672                    .or_default();
673                for piece_id in &entry.piece_ids {
674                    let global = *piece_map.get(piece_id).ok_or_else(|| {
675                        "n-ary imprint: by_face references unknown piece".to_string()
676                    })?;
677                    list.push(global);
678                }
679            }
680
681            for split in &pair.edge_splits {
682                edge_splits
683                    .entry((remap(split.operand), split.edge_id))
684                    .or_default()
685                    .extend(split.parameters.iter().copied());
686            }
687        }
688    }
689
690    Ok(ImprintResultRecord {
691        vertices,
692        pieces,
693        barrier_edges: Vec::new(),
694        section_evidence,
695        by_face: by_face
696            .into_iter()
697            .map(|((operand, face_id), piece_ids)| FaceImprints {
698                operand,
699                face_id,
700                piece_ids,
701            })
702            .collect(),
703        edge_splits: edge_splits
704            .into_iter()
705            .map(|((operand, edge_id), parameters)| EdgeSplitRecord {
706                operand,
707                edge_id,
708                parameters,
709            })
710            .collect(),
711    })
712}
713
714/// N-ary union / intersect / subtract of `operands` in ONE imprint+fragment
715/// pass (Golovanov T4.4).  Additive: does not touch the binary path.
716///
717/// - `Union`   : the boundary of `∪ operands`.
718/// - `Intersect`: the boundary of `∩ operands`.
719/// - `Subtract`: `operands[0]` minus the union of `operands[1..]`.
720///
721/// `N = 1` returns the sole operand; `N = 2` runs the same general pipeline and
722/// reproduces the binary result (same volume, valid).  Returns a clear `Err`
723/// when the operands cannot assemble into a closed, valid solid.
724pub fn boolean_operation_nary(
725    operands: &[BrepSolid],
726    operation: BooleanOperation,
727) -> Result<BrepSolid, String> {
728    if operands.is_empty() {
729        return Err("boolean_operation_nary: no operands provided".into());
730    }
731    if operands.len() == 1 {
732        return Ok(operands[0].clone());
733    }
734    if operands.len() > 255 {
735        return Err("boolean_operation_nary: at most 255 operands supported".into());
736    }
737    let debug = std::env::var("BREP_DEBUG_BOOL").is_ok();
738    let options = BooleanOptions::default();
739
740    // One tolerance policy for the whole set: the largest-scale operand's policy
741    // dominates (every derived band is monotonic in scale), matching how the
742    // binary path takes `for_pair` = max of the two scales.
743    let mut policy = KernelTolerances::for_solid(&operands[0], options.tolerance);
744    for operand in &operands[1..] {
745        let candidate = KernelTolerances::for_solid(operand, options.tolerance);
746        if candidate.sew_search > policy.sew_search {
747            policy = candidate;
748        }
749    }
750    policy.check()?;
751    let tolerance = policy.model;
752
753    // Per-operand fuse-first healing, exactly as the binary path (a no-op on
754    // clean input, so clean operands are untouched).
755    let mut healed = operands.to_vec();
756    for operand in &mut healed {
757        crate::heal::heal_operands(operand, &policy)?;
758        // Same operand normalization as the binary path: seam edges for
759        // seamless full-period band faces.
760        normalize_operand_band_seams(operand)?;
761    }
762
763    // 1. Imprint every pair into one global imprint.
764    let mut imprint_options = options.imprint.clone();
765    imprint_options.tolerance = tolerance;
766    let imprint = build_nary_imprint(&healed, &imprint_options)?;
767
768    // 2. Split every operand's edges, then fragment its faces, by that imprint.
769    let mut split = Vec::with_capacity(healed.len());
770    for (index, operand) in healed.iter().enumerate() {
771        split.push(apply_edge_splits(operand, index as u8, &imprint)?);
772    }
773    let mut fragments = Vec::with_capacity(split.len());
774    for (index, solid) in split.iter().enumerate() {
775        fragments.push(fragment_solid(solid, index as u8, &imprint)?);
776    }
777
778    // 3. Select fragments in one pass against the set of other operands.
779    let selected = select_fragments_nary(&fragments, &healed, operation, tolerance, debug)?;
780
781    // 4. Assemble + normalize + validate, mirroring the binary tail.
782    let solids: HashMap<u8, &BrepSolid> = split
783        .iter()
784        .enumerate()
785        .map(|(index, solid)| (index as u8, solid))
786        .collect();
787    let solid = assemble_fragments(selected, &solids, &imprint, tolerance)?;
788    let solid = if options.merge_coplanar_faces
789        && std::env::var("BREP_COPLANAR_MERGE").as_deref() != Ok("0")
790    {
791        let merged = merge_same_surface_faces_excluding(
792            &solid,
793            tolerance,
794            &options.keep_unmerged_name_substrs,
795        )?;
796        merge_curve_continuation_edges(&merged, tolerance)?
797    } else {
798        solid
799    };
800    let validation = solid.validate_detailed(&policy);
801    if !validation.issues.is_empty() {
802        return Err(format!(
803            "boolean_operation_nary: invalid result: {:?}",
804            validation.issues
805        ));
806    }
807    if debug {
808        if let Ok(report) =
809            crate::oracle::boolean_semantic_disagreement_nary(&healed, operation, &solid, 300)
810        {
811            if report.is_flagged() {
812                eprintln!(
813                    "[oracle] n-ary semantic disagreement rate {:.4} ({} of {} decidable points); sample {:?}",
814                    report.disagreement_rate,
815                    report.disagreements.len(),
816                    report.considered,
817                    report.sample_disagreement()
818                );
819            }
820        }
821    }
822    Ok(solid)
823}
824
825pub fn boolean_operation_with_diagnostics(
826    first: &BrepSolid,
827    second: &BrepSolid,
828    operation: BooleanOperation,
829    options: &BooleanOptions,
830) -> Result<KernelOutcome<BrepSolid>, String> {
831    let policy = options
832        .tolerances
833        .unwrap_or_else(|| KernelTolerances::for_pair(first, second, options.tolerance));
834    policy.check()?;
835    let tolerance = policy.model;
836
837    // Fuse-first operand healing (Lever A): before any intersection touches the
838    // operands, snap each one's near-coincident / off-plane vertices to exact
839    // and re-anchor the incident edge curves onto them, so noisy
840    // near-degenerate input (points that should coincide but drifted, vertices
841    // a few microns off a planar face) cannot tip a fixed downstream band over
842    // the edge.  Healing is per-operand, validate-gated, and — because a clean
843    // operand has no vertices within `heal_tol` and none off its planes — a
844    // no-op that leaves the boolean output byte-identical on clean inputs.
845    let mut first_owned = first.clone();
846    let mut second_owned = second.clone();
847    crate::heal::heal_operands(&mut first_owned, &policy)?;
848    crate::heal::heal_operands(&mut second_owned, &policy)?;
849    // Seamless full-period band faces (STEP import) break the seam-aware
850    // imprint/arrangement machinery; normalize them to the seam-carrying
851    // topology native periodic faces use. No-op on operands without such
852    // faces. See `insert_periodic_band_seam_edges`.
853    normalize_operand_band_seams(&mut first_owned)?;
854    normalize_operand_band_seams(&mut second_owned)?;
855    let first = &first_owned;
856    let second = &second_owned;
857
858    let mut diagnostics = KernelDiagnostics::default();
859    let operation_started = Instant::now();
860    diagnostics.count_n(
861        "collect.faces",
862        first
863            .shells
864            .iter()
865            .chain(&second.shells)
866            .map(|shell| shell.faces.len() as u64)
867            .sum(),
868    );
869    diagnostics.measure_max("tolerance.model", policy.model);
870    diagnostics.measure_max("tolerance.sew_search", policy.sew_search);
871
872    let mut imprint_options = options.imprint.clone();
873    imprint_options.tolerance = tolerance;
874    let stage_started = Instant::now();
875    let imprint = build_imprints(first, second, &imprint_options)?;
876    if std::env::var("BREP_DEBUG_BOOL").is_ok() {
877        for piece in &imprint.pieces {
878            let start = piece.curve.evaluate(piece.t0);
879            let end = piece.curve.evaluate(piece.t1);
880            eprintln!(
881                "piece {} supports={:?} t=[{:.6},{:.6}] start={:?} end={:?}",
882                piece.id, piece.support_faces, piece.t0, piece.t1, start, end
883            );
884        }
885        for entry in &imprint.by_face {
886            eprintln!(
887                "by_face operand={} face={} pieces={:?}",
888                entry.operand, entry.face_id, entry.piece_ids
889            );
890        }
891        for split in &imprint.edge_splits {
892            eprintln!(
893                "edge_split operand={} edge={} params={:?}",
894                split.operand, split.edge_id, split.parameters
895            );
896        }
897    }
898    diagnostics.measure_max(
899        "timing.imprint_ms",
900        stage_started.elapsed().as_secs_f64() * 1_000.0,
901    );
902    diagnostics.count_n("intersect.pieces", imprint.pieces.len() as u64);
903    diagnostics.count_n("intersect.vertices", imprint.vertices.len() as u64);
904    diagnostics.count_n("intersect.edge_splits", imprint.edge_splits.len() as u64);
905    let stage_started = Instant::now();
906    let (split_first, split_map_first) = apply_edge_splits_with_map(first, 0, &imprint)?;
907    let (split_second, split_map_second) = apply_edge_splits_with_map(second, 1, &imprint)?;
908    diagnostics.measure_max(
909        "timing.edge_split_ms",
910        stage_started.elapsed().as_secs_f64() * 1_000.0,
911    );
912    let stage_started = Instant::now();
913    let fragments_a = fragment_solid(&split_first, 0, &imprint)?;
914    let fragments_b = fragment_solid(&split_second, 1, &imprint)?;
915    diagnostics.measure_max(
916        "timing.fragment_ms",
917        stage_started.elapsed().as_secs_f64() * 1_000.0,
918    );
919    diagnostics.count_n(
920        "fragment.candidates",
921        (fragments_a.len() + fragments_b.len()) as u64,
922    );
923    // Operand surface samples for the LEGITIMATE-EMPTY adjudication below:
924    // every fragment test point is a 3D point ON its operand's boundary and
925    // inside its face trim, so they witness where the operands' materials sit
926    // relative to each other without extra geometry work.
927    let a_surface_samples: Vec<Vec3> = fragments_a
928        .iter()
929        .flat_map(|fragment| {
930            std::iter::once(fragment.test_point).chain(fragment.extra_test_points.iter().copied())
931        })
932        .collect();
933    let b_surface_samples: Vec<Vec3> = fragments_b
934        .iter()
935        .flat_map(|fragment| {
936            std::iter::once(fragment.test_point).chain(fragment.extra_test_points.iter().copied())
937        })
938        .collect();
939    let stage_started = Instant::now();
940    let mut barrier_edges: HashSet<(u8, u64)> = imprint
941        .pieces
942        .iter()
943        .filter_map(|piece| piece.shared_edge.map(|(operand, edge_id, _)| (operand, edge_id)))
944        .collect();
945    barrier_edges.extend(imprint.barrier_edges.iter().copied());
946    // The barrier set is keyed on ORIGINAL edge ids, but a barrier edge the
947    // imprint also SPLIT (rotated equator-tangency: the overlapped cap ring
948    // gains a junction at the sphere-seam crossing) reaches fragment
949    // selection as its minted sub-edge ids. Remap the barrier through the
950    // split ledger — keeping the originals too for unsplit references.
951    for (operand, split_map) in [(0u8, &split_map_first), (1u8, &split_map_second)] {
952        let minted: Vec<(u8, u64)> = barrier_edges
953            .iter()
954            .filter(|(barrier_operand, _)| *barrier_operand == operand)
955            .filter_map(|(_, edge_id)| split_map.get(edge_id))
956            .flat_map(|sub_ids| sub_ids.iter().map(|sub_id| (operand, *sub_id)))
957            .collect();
958        barrier_edges.extend(minted);
959    }
960    let selected = select_fragments(
961        fragments_a,
962        fragments_b,
963        first,
964        second,
965        operation,
966        tolerance,
967        &barrier_edges,
968    )?;
969    diagnostics.measure_max(
970        "timing.select_ms",
971        stage_started.elapsed().as_secs_f64() * 1_000.0,
972    );
973    diagnostics.count_n("select.fragments", selected.len() as u64);
974    // LEGITIMATE-EMPTY RESULTS: an intersect of DISJOINT operands (or a
975    // subtract whose left operand is entirely CONSUMED by the right) selects
976    // zero fragments, and assembly used to refuse with "operation produced no
977    // boundary faces" — 384 of the 474 post-wrapped-band pool errors were
978    // this, not bugs. Empty is only blessed on TWO independent proofs:
979    //   (a) the imprint recorded NO section evidence (no accepted pierce
980    //       seed, no traced SSI branch, no minted piece) — a silently-lost
981    //       section (the trial-489 class) always leaves upstream evidence
982    //       even when every downstream sub-segment is clipped away, while
983    //       pure material disjointness leaves none (verified: 489's
984    //       recreated silent-miss state keeps erroring; per-face surface
985    //       samples ALONE missed its 235 mm³ pocket, which is why (a) is
986    //       required and sampling alone was rejected);
987    //   (b) the operands' surface samples agree (every fragment test point
988    //       classified against the other solid):
989    //       intersect — no sample of either operand strictly inside the
990    //       other; subtract — no left-operand sample strictly outside the
991    //       right.
992    // Contact/graze pairs (evidence exists, material still disjoint) stay
993    // errors — conservative by design. Escape hatch: BREP_EMPTY_BOOLEAN=0
994    // restores the unconditional error.
995    if selected.is_empty()
996        && !imprint.section_evidence
997        && std::env::var("BREP_EMPTY_BOOLEAN").as_deref() != Ok("0")
998    {
999        let strictly_inside = |samples: &[Vec3], other: &BrepSolid| -> Result<bool, String> {
1000            for &sample in samples {
1001                if classify_point(sample, other, tolerance)?.class == PointClass::In {
1002                    return Ok(true);
1003                }
1004            }
1005            Ok(false)
1006        };
1007        let outside_witness = |samples: &[Vec3], other: &BrepSolid| -> Result<bool, String> {
1008            for &sample in samples {
1009                if classify_point(sample, other, tolerance)?.class == PointClass::Out {
1010                    return Ok(true);
1011                }
1012            }
1013            Ok(false)
1014        };
1015        let legitimate = match operation {
1016            BooleanOperation::Intersect => {
1017                !strictly_inside(&a_surface_samples, second)?
1018                    && !strictly_inside(&b_surface_samples, first)?
1019            }
1020            BooleanOperation::Subtract => !outside_witness(&a_surface_samples, second)?,
1021            BooleanOperation::Union => false,
1022        };
1023        if legitimate {
1024            diagnostics.event(
1025                DiagnosticSeverity::Info,
1026                KernelStage::Select,
1027                "boolean.empty_result",
1028                format!("{operation:?} of witnessed-non-overlapping operands is empty"),
1029            );
1030            diagnostics.measure_max(
1031                "timing.total_ms",
1032                operation_started.elapsed().as_secs_f64() * 1_000.0,
1033            );
1034            return Ok(KernelOutcome {
1035                value: BrepSolid {
1036                    id: 0,
1037                    vertices: Vec::new(),
1038                    edges: Vec::new(),
1039                    shells: Vec::new(),
1040                    genus: 0,
1041                },
1042                diagnostics,
1043            });
1044        }
1045    }
1046    let solids = [(0, &split_first), (1, &split_second)]
1047        .into_iter()
1048        .collect::<HashMap<_, _>>();
1049    let stage_started = Instant::now();
1050    let solid = assemble_fragments(selected, &solids, &imprint, tolerance)?;
1051    diagnostics.measure_max(
1052        "timing.assemble_ms",
1053        stage_started.elapsed().as_secs_f64() * 1_000.0,
1054    );
1055    let stage_started = Instant::now();
1056    if std::env::var("BREP_DEBUG_PREMERGE_VALIDATE").is_ok() {
1057        let issues = solid.validate();
1058        eprintln!(
1059            "pre-merge validation: {} issue(s){}",
1060            issues.len(),
1061            issues
1062                .first()
1063                .map(|issue| format!(" — first: {}", issue.message))
1064                .unwrap_or_default()
1065        );
1066    }
1067    let solid = if options.merge_coplanar_faces
1068        && std::env::var("BREP_COPLANAR_MERGE").as_deref() != Ok("0")
1069    {
1070        let merged = merge_same_surface_faces_excluding(
1071            &solid,
1072            tolerance,
1073            &options.keep_unmerged_name_substrs,
1074        )?;
1075        // Face merging can make previously separate collinear boundary
1076        // segments incident to the same pair of faces. Run continuation
1077        // cleanup again, matching the post-merge normalization performed by
1078        // the former assembly pipeline.
1079        merge_curve_continuation_edges(&merged, tolerance)?
1080    } else {
1081        solid
1082    };
1083    diagnostics.measure_max(
1084        "timing.face_merge_ms",
1085        stage_started.elapsed().as_secs_f64() * 1_000.0,
1086    );
1087    let validation = solid.validate_detailed(&policy);
1088    diagnostics.count_n("validate.issues", validation.issues.len() as u64);
1089    diagnostics.count_n(
1090        "validate.wire_warnings",
1091        validation.wire_warnings.len() as u64,
1092    );
1093    diagnostics.measure_max("validate.max_pcurve_error", validation.max_pcurve_error);
1094    for warning in &validation.wire_warnings {
1095        diagnostics.event(
1096            DiagnosticSeverity::Warning,
1097            KernelStage::Validate,
1098            "validate.uv_wire",
1099            warning.message.clone(),
1100        );
1101    }
1102    for issue in &validation.issues {
1103        diagnostics.event(
1104            DiagnosticSeverity::Error,
1105            KernelStage::Validate,
1106            "validate.brep",
1107            issue.message.clone(),
1108        );
1109    }
1110    diagnostics.measure_max(
1111        "timing.total_ms",
1112        operation_started.elapsed().as_secs_f64() * 1_000.0,
1113    );
1114    if !validation.issues.is_empty() {
1115        return Err(format!(
1116            "boolean_operation: invalid result: {:?}",
1117            validation.issues
1118        ));
1119    }
1120    // Optional semantic oracle (off by default, no perf hit): under
1121    // BREP_DEBUG_BOOL, cross-check the well-formed result against the CSG
1122    // point-membership expectation and scan for residual coincident-but-unwelded
1123    // geometry. Purely diagnostic and NON-rejecting — a statistical check must
1124    // never fail a valid op (see oracle.rs) — so it only ever warns.
1125    if std::env::var("BREP_DEBUG_BOOL").is_ok() {
1126        if let Ok(report) =
1127            crate::oracle::boolean_semantic_disagreement(first, second, operation, &solid, 200)
1128        {
1129            if report.is_flagged() {
1130                eprintln!(
1131                    "[oracle] semantic disagreement rate {:.4} ({} of {} decidable points); sample {:?}",
1132                    report.disagreement_rate,
1133                    report.disagreements.len(),
1134                    report.considered,
1135                    report.sample_disagreement()
1136                );
1137            }
1138        }
1139        let fusable_band = crate::tolerance::assembler_weld(policy.model);
1140        let fusables = crate::oracle::boolean_residual_fusables(&solid, fusable_band);
1141        if !fusables.is_empty() {
1142            eprintln!(
1143                "[oracle] {} residual fusable(s) after weld; sample {:?}",
1144                fusables.len(),
1145                fusables.first()
1146            );
1147        }
1148    }
1149    Ok(KernelOutcome {
1150        value: solid,
1151        diagnostics,
1152    })
1153}
1154