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}