ifc_lite_geometry/clash_solid.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//! The intersection SOLID of a clashing pair — the overlap volume itself, as a
6//! mesh the viewer can draw opaque while ghosting both parents (the BIMcollab
7//! Zoom / Solibri presentation). A contact point tells you two elements touch;
8//! this tells you how deep, in what shape, and in which direction.
9//!
10//! # On demand, never eager
11//!
12//! One model here yields 81 clashes. This entry point computes ONE pair and is
13//! meant to be called when a clash row is selected, exactly like the existing
14//! on-demand `@ifc-lite/clash/contact` path. Nothing in the detection sweep
15//! calls it.
16//!
17//! # Why this is gated, and what the gate is
18//!
19//! The exact CSG kernel snaps every input coordinate to
20//! [`SNAP_GRID`](crate::kernel::mesh_bridge) = `2^-16 m ≈ 15.26 µm` and treats
21//! faces within [`NearBand`]'s band of each other as coplanar. Inside that
22//! band a thin overlap is not a thin solid — it is a *coplanar contact*, and the
23//! arrangement returns a wedge rather than the slab. Measured on the analytic
24//! box oracle (`tests/clash_intersection_oracle.rs`), a slab overlap reports:
25//!
26//! | penetration depth | reported volume |
27//! |---|---|
28//! | ≤ 8 snap cells (≤ 122 µm) | exactly **2/3** of the truth (−33 %), at every world scale |
29//! | 10–24 cells (153–366 µm) | high by 5e-5 (at the origin) to 0.33 (at 1000 m) |
30//! | ≥ 4 × the near band | **exact**, to f64, at every tessellation and world scale |
31//!
32//! So a naive "call the kernel and show the answer" API would draw a solid that
33//! is a third too small for precisely the shallow clashes a coordinator cares
34//! most about — and would report a volume for a 15 µm graze as if it meant
35//! something. This module therefore refuses to return a solid it cannot stand
36//! behind, and says why. The viewer draws the existing contact marker instead.
37//!
38//! This is a real limit, not a conservatism knob: below the near band there is
39//! no exact solid to compute, only a coplanar contact, and the arrangement's
40//! own output cannot tell you otherwise. **No intersection solid exists for
41//! those pairs at this kernel's resolution**, and inventing one would be a
42//! sliver, not a finding.
43//!
44//! Note what the sub-micron distances this kernel reports are NOT: evidence of
45//! fine coordination issues. `TriMesh` ingests geometry as `f32` and queries it
46//! in `f64`, so those distances land on the `f32` ULP at the pair's coordinate
47//! magnitude — `2^-22 m ≈ 0.238 µm` for coordinates in `[2, 4)` — repeated
48//! bit-identically across unrelated pairs, which is the signature of a
49//! quantization floor rather than a physical graze. Do not state a real-world
50//! graze distance here without a reproducible per-pair measurement behind it.
51
52use crate::clash_contact_axes::gate_axes;
53use crate::kernel::mesh_bridge::intersection_tris;
54use crate::mesh::Mesh;
55
56#[path = "clash_solid_geom.rs"]
57mod clash_solid_geom;
58use clash_solid_geom::{operand_near_band, tri_volume, trust_gate_reason};
59
60/// Multiple of the kernel's near-coplanar band above which the intersection
61/// volume was measured to be exactly analytic.
62///
63/// Evidence (`clash_intersection_oracle::intersection_is_exact_at_and_above_the_
64/// trust_threshold` plus the recorded sweep in this module's docs): at world
65/// offsets 0, 10, 100 and 1000 m the reported volume is exact from `4 ×` the
66/// band upward, and carries a scale-dependent error below it. Lowering this
67/// constant re-admits that error; it is not a free tightening.
68const TRUST_BAND_MULTIPLE: f64 = 4.0;
69
70/// Why no solid is being returned.
71// Not `Eq`: `BelowKernelResolution` carries f64 measurements.
72#[derive(Debug, Clone, Copy, PartialEq)]
73pub enum DegenerateReason {
74 /// One or both operands had no triangles.
75 EmptyOperand,
76 /// The kernel's exact intersection is empty: the pair does not overlap, or
77 /// overlaps by less than the `2^-16 m` snap grid so both faces snapped flush.
78 /// A *touching* pair (coplanar faces, zero overlap) lands here.
79 NoOverlap,
80 /// The pair overlaps, but not deeply enough for the kernel's arrangement to
81 /// resolve the overlap as a solid rather than a coplanar contact. The solid
82 /// that would be returned here is systematically wrong (see the module
83 /// docs), so it is withheld. `thickness_m` is the intersection's smallest
84 /// extent over the candidate contact normals (see `gate_axes`), which for a
85 /// box-box pair is the true penetration depth whatever the pair's
86 /// orientation; `required_m` is the depth this pair would have needed.
87 ///
88 /// `thickness_m == 0.0` exactly is a real and distinct outcome, observed on
89 /// the bridge model (`IfcColumn` #761 × `IfcWall` #828): the kernel returned
90 /// triangles, but they are FLAT — a coplanar contact patch with no thickness
91 /// at all, rather than a slab too thin to resolve. It is reported here
92 /// instead of as [`NoOverlap`](Self::NoOverlap) because the arrangement did
93 /// produce geometry; the caller's action is the same either way (fall back
94 /// to the contact marker), and the two are told apart by this field.
95 BelowKernelResolution { thickness_m: f64, required_m: f64 },
96 /// The #1109 escalation budget tripped, so the arrangement is partial and
97 /// nothing about it can be trusted.
98 BudgetExhausted,
99}
100
101/// The overlap volume of one clashing pair.
102///
103/// `Solid` carries a closed, world-space triangle mesh in **f64** — not the f32
104/// of [`Mesh`] — because the caller reports its volume, and the f32 round-trip
105/// costs ~1e-7 relative on a quantity that is otherwise exact.
106#[derive(Debug, Clone, PartialEq)]
107pub enum IntersectionSolid {
108 Solid {
109 /// World-space vertex positions, `[x, y, z, …]`, f64.
110 positions: Vec<f64>,
111 /// Triangle indices into `positions / 3`.
112 indices: Vec<u32>,
113 /// Enclosed volume in m³. Exact to f64 on the analytic oracle.
114 volume_m3: f64,
115 },
116 /// No solid to draw. The viewer keeps the contact marker it already has.
117 Degenerate(DegenerateReason),
118}
119
120impl IntersectionSolid {
121 /// `Some(volume)` for a solid, `None` when degenerate. Deliberately not
122 /// `unwrap_or(0.0)`: "no measurable overlap" and "an overlap of zero" are
123 /// different claims, and the caller must not be able to conflate them by
124 /// accident.
125 pub fn volume_m3(&self) -> Option<f64> {
126 match self {
127 Self::Solid { volume_m3, .. } => Some(*volume_m3),
128 Self::Degenerate(_) => None,
129 }
130 }
131
132 /// Triangle count of the solid; `0` when degenerate.
133 pub fn triangle_count(&self) -> usize {
134 match self {
135 Self::Solid { indices, .. } => indices.len() / 3,
136 Self::Degenerate(_) => 0,
137 }
138 }
139}
140
141/// The intersection solid of two world-space meshes, or an honest reason there
142/// is none.
143///
144/// Both operands must already be in the **common world frame**: for a federated
145/// pair the models' placements must be baked into `positions` before the call.
146/// This function applies no transform and has no way to detect a missing one.
147///
148/// Costs one exact boolean of the two meshes; see the module docs for why the
149/// result is gated rather than returned raw.
150pub fn intersection_solid(a: &Mesh, b: &Mesh) -> IntersectionSolid {
151 if a.indices.len() < 3 || b.indices.len() < 3 {
152 return IntersectionSolid::Degenerate(DegenerateReason::EmptyOperand);
153 }
154
155 let tris = intersection_tris(a, b);
156 if tris.is_empty() {
157 // `intersection_tris` returns empty BOTH for a genuinely disjoint pair
158 // and for a budget trip. Distinguish them: the budget state is still the
159 // one this boolean left behind.
160 let reason = if crate::kernel::budget::tripped() {
161 DegenerateReason::BudgetExhausted
162 } else {
163 DegenerateReason::NoOverlap
164 };
165 return IntersectionSolid::Degenerate(reason);
166 }
167
168 // Gate on the solid's thinnest extent. It is a sound proxy for penetration
169 // depth here BECAUSE it is the one quantity the misclassification does not
170 // corrupt: the wedge the kernel returns for a sub-band overlap still spans
171 // the full slab, so its extent still reports the true (too small)
172 // thickness. Deriving the gate from the volume instead would be circular —
173 // the volume is the thing under suspicion.
174 //
175 // WHICH DIRECTION that thickness is measured along is the other half of the
176 // argument, and measuring it against the WORLD axes (as this did until the
177 // #2573 review) is only right when the contact normal happens to be
178 // parallel to one. Rotate the oracle's own 15–122 µm slab overlaps
179 // obliquely and the wedge's min world-axis extent jumps to ~0.6 m: the gate
180 // passed every one of them, and the volumes it returned ranged from 36 % to
181 // 103 % of the truth, drifting with tessellation
182 // (`rotated_near_band_overlap_is_withheld_exactly_as_the_axis_aligned_
183 // one_is`, in the oracle). `gate_axes` supplies the contact normal
184 // analytically instead, from the operands' own face planes, and keeps the
185 // world axes in the set so the measure can only get stricter. See its doc
186 // for what happens when an operand is not a box.
187 //
188 // Two earlier candidates were tried and rejected, both of which tried to
189 // recover the direction from the KERNEL'S OUTPUT rather than from the
190 // operands: (1) PCA of the wedge's vertex cloud is numerically unstable at
191 // the aspect ratios this gate deals with and regressed the already-correct
192 // axis-aligned case. (2) The normal of the wedge's largest-area triangle is
193 // wrong precisely in the regime this gate exists for: below the near band
194 // the kernel returns a genuine WEDGE (module docs above), not a flat slab,
195 // so its largest face is not reliably the cap — measured thickness came out
196 // over 30x too large on the very cases the oracle pins. Working from the
197 // operands' face planes sidesteps the wedge entirely.
198 //
199 // One more thing the extent must be measured PER, on top of axis: the
200 // arrangement can return more than one disjoint overlap component for a
201 // single operand pair (e.g. a non-convex operand overlapping the other in
202 // two separate places). Pooling `lo`/`hi` across every triangle the
203 // kernel returned, regardless of which component it belongs to, was
204 // itself a #2573 review finding: two below-band slivers at opposite
205 // ends of an operand can each be a genuine coplanar-contact wedge, yet
206 // their UNION bounding box spans the operand's full size along every
207 // axis and sails past the gate. `component_groups` below partitions
208 // `tris` by shared-vertex connectivity (the same bitwise key the welding
209 // step already uses) so each disjoint piece is measured against its OWN
210 // extent; the reported `thickness` is the worst (thinnest) extent found
211 // in ANY single component along ANY candidate axis, so one bad component
212 // still withholds the whole pair rather than being averaged away.
213 //
214 // The `required` band paired with that thickness must be measured along
215 // the SAME axis, not collapsed to one world-distance-derived scalar: a
216 // scalar sized from the max |coordinate| over every axis of both
217 // operands inflates whenever EITHER operand sits far from the origin on
218 // ANY axis, including one the measured thickness never touches (a pair
219 // 10 km out in X but overlapping along Z got a ~9.5 mm required band
220 // driven entirely by the irrelevant X offset — see
221 // `clash_solid_world_frame_tests.rs`). `NearBand::scaled_band2` instead
222 // projects the operands' PER-AXIS extents onto the candidate axis itself,
223 // so an offset on an axis orthogonal to the one being tested contributes
224 // nothing — exactly the fix `near_band.rs` already applies to the
225 // kernel's own near-coplanar reconciliation. `axis` is one of
226 // `gate_axes`'s unit vectors, so `nn = 1.0`.
227 // `trust_gate_reason` withholds the pair the moment ANY (component, axis)
228 // extent sits inside that axis's OWN band — not only the axis with the
229 // globally smallest extent. An earlier form tracked a single argmin
230 // `(thickness, required)` pair and checked only that one axis, which let
231 // an axis whose own extent was below its own band go unchecked whenever
232 // some OTHER axis happened to be thinner still (PR #2923 review). See
233 // `trust_gate_reason`'s doc for the concrete counter-example.
234 let axes = gate_axes(a, b);
235 let band = operand_near_band(a, b);
236 if let Some((thickness, required)) =
237 trust_gate_reason(&tris, &axes, &band, TRUST_BAND_MULTIPLE)
238 {
239 return IntersectionSolid::Degenerate(DegenerateReason::BelowKernelResolution {
240 thickness_m: thickness,
241 required_m: required,
242 });
243 }
244
245 // Weld to an indexed mesh on exact f64 bit patterns. The kernel's output
246 // already shares vertex coordinates exactly between adjacent triangles
247 // (it is an arrangement, not independently rounded facets), so a bitwise
248 // key welds the seam without a tolerance — and a tolerance here could weld
249 // two genuinely distinct vertices of a thin solid.
250 let mut positions: Vec<f64> = Vec::new();
251 let mut indices: Vec<u32> = Vec::with_capacity(tris.len() * 3);
252 let mut seen: std::collections::HashMap<[u64; 3], u32> = std::collections::HashMap::new();
253 for t in &tris {
254 for v in t {
255 let key = [v[0].to_bits(), v[1].to_bits(), v[2].to_bits()];
256 let idx = *seen.entry(key).or_insert_with(|| {
257 let i = (positions.len() / 3) as u32;
258 positions.extend_from_slice(v);
259 i
260 });
261 indices.push(idx);
262 }
263 }
264
265 IntersectionSolid::Solid {
266 positions,
267 indices,
268 volume_m3: tri_volume(&tris),
269 }
270}
271
272#[cfg(test)]
273#[path = "clash_solid_tests.rs"]
274mod clash_solid_tests;
275
276#[cfg(test)]
277#[path = "clash_solid_world_frame_tests.rs"]
278mod world_frame_tests;