ifc_lite_geometry/csg/
group_cut.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Group subtraction (disjoint-cutter batching) and the outcome type it shares
6//! with the single-cutter `subtract_mesh`.
7//!
8//! [`GroupCut`] says whether the host was cut and, if not, why, so the router
9//! matches on it instead of comparing the returned mesh against the host (the
10//! triangle-count and 0.1 % volume decoder of #1788, deleted in #4692).
11
12use super::{record_csg_op, ClippingProcessor};
13use crate::diagnostics::{BoolFailureReason, BoolOp};
14use crate::kernel::mesh_bridge::{subtract_many_with_conformity, BatchSubtract};
15use crate::mesh::Mesh;
16
17/// Outcome of [`ClippingProcessor::subtract_mesh_many`] and of
18/// [`ClippingProcessor::subtract_mesh`], which is a group of one.
19#[must_use]
20#[derive(Debug, Clone)]
21pub enum GroupCut {
22    /// The kernel classified a cut, and every produced intermediate passed
23    /// validation and the accept gates. Single-cutter arrangements are lenient;
24    /// group cuts additionally require conformity or the kernel's volume oracle.
25    /// Empty when the cutters engulf the host.
26    Cut(Mesh),
27    /// The kernel classified the cutter as not reaching the host solid,
28    /// and this is the host re-tessellated along the
29    /// arrangement, consolidated, validated and gated like a `Cut`. Same solid
30    /// as the host, different triangles. Whether to keep it is the caller's
31    /// choice; the void router has kept it when it changed the triangle count,
32    /// and the watertightness census depends on that (#4692). A non-conforming
33    /// same-count miss is `Rejected(Nonconforming)` instead (#5362).
34    /// The public group API does not return this variant. The private void
35    /// group opt-in can retain a conforming, count-changing miss (#6516).
36    Retessellated(Mesh),
37    /// The host is untouched; uncertain results need a per-member fallback.
38    Rejected(GroupReject),
39}
40
41impl GroupCut {
42    /// Whether the kernel established that no cutter reaches the host solid,
43    /// as opposed to failing to cut one that does: there is nothing to
44    /// approximate, so a fallback cut must not run (#5362).
45    pub fn found_no_overlap(&self) -> bool {
46        matches!(
47            self,
48            Self::Retessellated(_)
49                | Self::Rejected(GroupReject::NoOverlap | GroupReject::EmptyHost | GroupReject::Unchanged)
50        )
51    }
52
53    /// The mesh the subtract produced, a cut or a re-tessellated miss; `None`
54    /// for a rejection.
55    pub fn into_mesh(self) -> Option<Mesh> {
56        match self {
57            Self::Cut(m) | Self::Retessellated(m) => Some(m),
58            Self::Rejected(_) => None,
59        }
60    }
61}
62
63/// Why a group was not cut. For a group, only `InvalidOutput` and
64/// `GateRejected` record a [`crate::diagnostics::BoolFailure`]: the others are
65/// the expected, handled outcome (see [`ClippingProcessor::subtract_mesh_many`]).
66/// The single cutter records more; see [`ClippingProcessor::subtract_mesh`].
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum GroupReject {
69    /// The host has no triangles.
70    EmptyHost,
71    /// No cutter is non-empty and AABB-overlapping the host.
72    NoOverlap,
73    /// The #1109 escalation budget tripped inside one chunk's arrangement.
74    BudgetTripped,
75    /// A chunk's arrangement left an unrecovered constraint and its lenient
76    /// batch failed the kernel's volume oracle or classified an uncertain miss.
77    /// The single cutter returns it
78    /// only for a non-conforming arrangement that changed nothing, which is
79    /// not proof the cutter misses the host (#5362).
80    Nonconforming,
81    /// Every chunk's arrangement conformed, but no cutter reaches the host
82    /// solid: the kernel kept every host face and no cutter face. Group only:
83    /// the single cutter returns [`GroupCut::Retessellated`] instead.
84    Unchanged,
85    /// A chunk's intermediate failed [`ClippingProcessor::validate_mesh`]
86    /// (recorded as `KernelOutputInvalid`).
87    InvalidOutput,
88    /// A chunk's intermediate was refused by the accept gates (recorded).
89    GateRejected,
90}
91
92/// Cap on the cutters packed into ONE conforming arrangement. Void cutters
93/// are order-free (set difference: host − {all} ≡ host − {chunk₁} − {chunk₂}
94/// − …), and the N-ary arrangement cost is SUPER-LINEAR in the cutters in a
95/// single arrangement. A Revit IfcBuildingElementPart with ~90 openings cost
96/// ~12 s in one arrangement vs ~0.4 s chunked at 16 (30×), and on wasm that
97/// single element alone blew the geometry-stream watchdog: an 86 MB model that
98/// loaded in ~15 s natively STALLED at 40 s in the browser. Chunking bounds
99/// the per-arrangement cost so no single element can stall the stream. It is
100/// solid-equivalent (the batch path's contract is volume parity +
101/// watertightness, not byte-identical tessellation); for
102/// `live.len() <= MAX_CUTTERS_PER_ARRANGEMENT` it IS the prior single
103/// arrangement.
104const MAX_CUTTERS_PER_ARRANGEMENT: usize = 16;
105
106impl ClippingProcessor {
107    /// Subtract a GROUP of pairwise-disjoint opening cutters from the host in
108    /// ONE conforming arrangement per chunk (disjoint-cutter batching).
109    ///
110    /// On any chunk's rejection the WHOLE group is rejected and the host is
111    /// left un-cut: the router's per-opening sequential loop (own budget,
112    /// #635 fallback machinery, own diagnostics) then takes over for the
113    /// members. Rejection is the expected, handled outcome, so only an invalid
114    /// kernel output or an accept-gate refusal records a failure; anything
115    /// more would be noise on elements whose voids end up perfectly cut (the
116    /// issue-582/583 zero-CSG-failure bar).
117    pub fn subtract_mesh_many(&self, host_mesh: &Mesh, cutters: &[&Mesh]) -> GroupCut {
118        self.subtract_mesh_many_inner(host_mesh, cutters, false)
119    }
120
121    /// Reuse a validated, consolidated conforming miss only for a void group
122    /// whose welded operands equal the sequential kernel operands (#6516).
123    /// Other callers retain the public batch outcome contract.
124    pub(crate) fn subtract_mesh_many_retaining_miss(&self, host_mesh: &Mesh, cutters: &[&Mesh]) -> GroupCut {
125        self.subtract_mesh_many_inner(host_mesh, cutters, true)
126    }
127
128    fn subtract_mesh_many_inner(&self, host_mesh: &Mesh, cutters: &[&Mesh], retain_miss: bool) -> GroupCut {
129        #[cfg(feature = "opening-perf-trace")]
130        crate::opening_perf_trace::record(|c| c.group_subtract_calls = c.group_subtract_calls.saturating_add(1));
131        if host_mesh.is_empty() {
132            #[cfg(feature = "opening-perf-trace")]
133            crate::opening_perf_trace::record(|c| c.group_rejections[0] = c.group_rejections[0].saturating_add(1));
134            return GroupCut::Rejected(GroupReject::EmptyHost);
135        }
136        let live: Vec<&Mesh> = cutters
137            .iter()
138            .copied()
139            .filter(|c| !c.is_empty() && Self::bounds_overlap(host_mesh, c))
140            .collect();
141        if live.is_empty() {
142            #[cfg(feature = "opening-perf-trace")]
143            crate::opening_perf_trace::record(|c| c.group_rejections[1] = c.group_rejections[1].saturating_add(1));
144            return GroupCut::Rejected(GroupReject::NoOverlap);
145        }
146        // `None` until a chunk cuts: the host is only copied by the kernel.
147        let mut cut: Option<Mesh> = None;
148        let mut retessellated: Option<Mesh> = None;
149        let mut misses_conform = true;
150        // Do not change the host seen by a later chunk merely to preserve a
151        // no-op. Multi-chunk groups keep their existing sequential fallback.
152        let retain_miss = retain_miss && live.len() <= MAX_CUTTERS_PER_ARRANGEMENT;
153        for chunk in live.chunks(MAX_CUTTERS_PER_ARRANGEMENT) {
154            let current = cut.as_ref().unwrap_or(host_mesh);
155            // Census: record THIS kernel invocation's real operand sizes (the
156            // current host + this chunk's cutters). Chunking runs the kernel once
157            // per chunk, so report K real ops, not one synthetic op carrying the
158            // whole group's cutter total. For live.len() <= cap this is one record
159            // identical to the prior single arrangement.
160            let chunk_tris: usize = chunk.iter().map(|c| c.triangle_count()).sum();
161            record_csg_op(0, current.triangle_count(), chunk_tris);
162            crate::kernel::budget::begin();
163            let (raw, conforming, miss) = subtract_many_with_conformity(current, chunk, retain_miss);
164            if crate::kernel::budget::tripped() {
165                // Escalation budget exceeded (#1109): the partial arrangement
166                // is discarded whatever the kernel made of it (deterministic).
167                #[cfg(feature = "opening-perf-trace")]
168                crate::opening_perf_trace::record(|c| c.group_rejections[2] = c.group_rejections[2].saturating_add(1));
169                return GroupCut::Rejected(GroupReject::BudgetTripped);
170            }
171            let (raw, changed) = match raw {
172                BatchSubtract::Cut(raw) => (raw, true),
173                BatchSubtract::Unchanged => {
174                    misses_conform &= conforming;
175                    let Some(miss) = miss else { continue; };
176                    (miss, false)
177                }
178                BatchSubtract::Nonconforming => {
179                    #[cfg(feature = "opening-perf-trace")]
180                    crate::opening_perf_trace::record(|c| c.group_rejections[3] = c.group_rejections[3].saturating_add(1));
181                    return GroupCut::Rejected(GroupReject::Nonconforming);
182                }
183            };
184            let next = self.consolidate(raw);
185            // Validate each intermediate BEFORE it becomes the next chunk's host:
186            // a non-watertight / invalid intermediate would silently corrupt every
187            // subsequent subtraction. Same guard as `subtract_mesh`, per chunk.
188            if !next.is_empty() && !self.validate_mesh(&next) {
189                self.record_failure(BoolOp::Difference, BoolFailureReason::KernelOutputInvalid);
190                #[cfg(feature = "opening-perf-trace")]
191                crate::opening_perf_trace::record(|c| c.group_rejections[5] = c.group_rejections[5].saturating_add(1));
192                return GroupCut::Rejected(GroupReject::InvalidOutput);
193            }
194            if self.accept_gates_reject(BoolOp::Difference, &next) {
195                #[cfg(feature = "opening-perf-trace")]
196                crate::opening_perf_trace::record(|c| c.group_rejections[6] = c.group_rejections[6].saturating_add(1));
197                return GroupCut::Rejected(GroupReject::GateRejected);
198            }
199            if changed { cut = Some(next); } else { retessellated = Some(next); }
200        }
201        match cut {
202            Some(result) => {
203                self.record_topology_tear(BoolOp::Difference, &result);
204                GroupCut::Cut(result)
205            }
206            None => {
207                if let Some(result) = retessellated {
208                    // Same-count single misses are discarded by mesh_to_keep.
209                    // Preserve that fallback/cleanup contract rather than
210                    // newly mutating the host from a same-count group miss.
211                    if result.triangle_count() != host_mesh.triangle_count() {
212                        self.record_topology_tear(BoolOp::Difference, &result);
213                        return GroupCut::Retessellated(result);
214                    }
215                }
216                // Only a conforming classification proves the entire group
217                // misses. Preserve the sequential fallback for uncertain misses,
218                // including when the lenient volume check accepted them (#6516).
219                let reason = if misses_conform || !retain_miss { GroupReject::Unchanged } else { GroupReject::Nonconforming };
220                #[cfg(feature = "opening-perf-trace")]
221                crate::opening_perf_trace::record(|c| {
222                    let index = if matches!(reason, GroupReject::Unchanged) { 4 } else { 3 };
223                    c.group_rejections[index] = c.group_rejections[index].saturating_add(1);
224                });
225                GroupCut::Rejected(reason)
226            },
227        }
228    }
229}