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