Skip to main content

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;