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;