ifc_lite_geometry/
geom_closure.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 #1891 volume gate: what the mesher proved about an entity's surface
6//! topology, and the single narrow condition under which that licenses a
7//! divergence-theorem volume.
8//!
9//! Split out of the parent `geom_hash` module (whose child it is, so it reaches
10//! [`GeometryHasher`]'s private accumulators directly) because it is a separate
11//! subject with a long justification: the fingerprint answers "is this the same
12//! shape", this answers "may I state its volume out loud". Most of the file is
13//! the reasoning behind the second question's four-clause NO.
14
15use super::GeometryHasher;
16use crate::mesh_orient::OrientVerdict;
17
18/// What an ENTITY's produced geometry looked like topologically, folded over
19/// every segment the hasher was fed (#1891).
20///
21/// Four independent yes/no axes rather than one "is it good" bit, because a
22/// consumer that gets no volume deserves to know why: an open `SurfaceModel`
23/// sheet, a non-orientable shell, a two-piece body, and a many-item assembly
24/// are four different modelling situations with four different fixes, and
25/// collapsing them loses the diagnosis. They ride the wasm boundary as one
26/// packed [`Self::bits`] byte per entity.
27#[derive(Clone, Copy, Debug, PartialEq, Eq)]
28pub struct GeometryClosure {
29    /// Every segment's every component was closed (no boundary, no non-manifold
30    /// edge).
31    pub all_closed: bool,
32    /// Every segment's every component was orientable.
33    pub all_orientable: bool,
34    /// Every segment was a SINGLE connected component.
35    pub all_single_component: bool,
36    /// Segments (`add_mesh*` calls contributing at least one in-range triangle).
37    pub segments: u32,
38}
39
40impl GeometryClosure {
41    /// Nothing seen yet. The `all_*` conjunctions start true and are only
42    /// meaningful once `segments > 0`, which every consumer checks first.
43    pub const EMPTY: Self = Self {
44        all_closed: true,
45        all_orientable: true,
46        all_single_component: true,
47        segments: 0,
48    };
49
50    /// Fold one segment's [`OrientVerdict`] in. `pub(super)` — only the parent
51    /// accumulator may advance a verdict; a consumer reads, never writes.
52    pub(super) fn fold_segment(&mut self, v: &OrientVerdict) {
53        self.segments += 1;
54        self.all_closed &= v.all_closed;
55        self.all_orientable &= v.all_orientable;
56        self.all_single_component &= v.components == 1;
57    }
58
59    /// Withdraw all three topology claims, keeping the segment count (which is
60    /// a count of what was hashed and stays true). See
61    /// [`GeometryHasher::retract_closure_if_mesh_edited`].
62    fn retract(&mut self) {
63        self.all_closed = false;
64        self.all_orientable = false;
65        self.all_single_component = false;
66    }
67
68    /// Pack into one byte for the FFI boundary: bit 0 closed, bit 1 orientable,
69    /// bit 2 single-component, bit 3 exactly-one-segment. `0x0F` is the only
70    /// value that carries a volume, so a consumer can both read the volume's
71    /// presence and, when it is absent, name the reason.
72    ///
73    /// A CLEAR bit means NOT PROVED, never proved-false. Normally the two
74    /// coincide — the orienter decided each clause outright — but a retracted
75    /// verdict (above) clears bits 0-2 without having established anything
76    /// about them.
77    pub fn bits(&self) -> u8 {
78        (self.all_closed as u8)
79            | ((self.all_orientable as u8) << 1)
80            | ((self.all_single_component as u8) << 2)
81            | (((self.segments == 1) as u8) << 3)
82    }
83
84    /// Whether a divergence-theorem volume over this entity's geometry is
85    /// trustworthy. See [`GeometryHasher::volume`] for the reasoning behind
86    /// each clause — every one of them is load-bearing.
87    pub fn is_trustworthy_solid(&self) -> bool {
88        self.segments == 1 && self.all_closed && self.all_orientable && self.all_single_component
89    }
90}
91
92impl GeometryHasher {
93    /// The entity's folded per-segment topology. See [`GeometryClosure`].
94    pub fn closure(&self) -> GeometryClosure {
95        self.closure
96    }
97
98    /// Withdraw the topology verdict — and with it the volume — when the meshes
99    /// were EDITED after this hasher saw them. No-op for `0`.
100    ///
101    /// The producer takes each segment's verdict where the orienter runs, which
102    /// is necessarily BEFORE the per-`MeshData` funnel that finishes the mesh.
103    /// One step in that funnel removes triangles: the f32-collapse degenerate
104    /// backstop (`Mesh::drop_degenerate_triangles`). A removed triangle takes
105    /// its three welded edges with it, so each neighbour along them drops from
106    /// two incidences to one — a BOUNDARY edge. A shell certified closed can
107    /// therefore be handed back OPEN while still carrying `0x0F` and a finite
108    /// volume, which is exactly the "confidently wrong number" this gate exists
109    /// to refuse (Greptile review, PR #1993).
110    ///
111    /// So the producer passes its per-element drop tally here before reading
112    /// [`Self::closure`] / [`Self::volume`], and any drop at all retracts.
113    ///
114    /// Why retract rather than RE-DERIVE the verdict on the cleaned mesh (which
115    /// a throwaway re-run of the orienter would give, exactly, since closedness
116    /// is winding-independent): the verdict is only half of it. `volume6` was
117    /// accumulated over the PRE-cleanup triangle set, and a dropped needle's
118    /// tetrahedron is not zero — its contribution scales with the lever arm to
119    /// the reference corner, metre-scale on a metre-scale body. A re-derived
120    /// verdict would license a stale number. Re-accumulating both would mean
121    /// re-running the orienter and the whole hash pass on every affected
122    /// element; refusing is sound, costs nothing, and moves no vertex.
123    ///
124    /// The funnel's OTHER edit, the source vertex weld, needs no such treatment:
125    /// it merges only vertices with bit-identical `f32` positions, a strict
126    /// refinement of the orienter's 10 µm weld grid, so the welded edge graph the
127    /// verdict was read off is unchanged. Since #4103 it also mostly runs BEFORE
128    /// the verdict, in the object frame inside the router's placement appliers;
129    /// what `build_mesh_data` still welds post-verdict (`mesh_weld::weld`,
130    /// whose doc lists the two populations) is covered by the same argument, and
131    /// the reordering does not disturb the verdict either way: the orienter
132    /// derives its adjacency from a 10 µm quantized position grid, which an
133    /// exact-bit weld refines rather than changes.
134    pub fn retract_closure_if_mesh_edited(&mut self, triangles_dropped: u64) {
135        if triangles_dropped > 0 {
136            self.closure.retract();
137        }
138    }
139
140    /// The entity's enclosed volume in cubic metres — `Some` ONLY when the
141    /// geometry that produced it is provably a single closed orientable solid,
142    /// `None` otherwise. THE RULE IS DELIBERATELY NARROW. Each clause of
143    /// [`GeometryClosure::is_trustworthy_solid`] rejects a specific way the
144    /// number would otherwise be silently wrong.
145    ///
146    /// It is narrow, not useless: measured over the ara3d fixture corpus
147    /// (33,701 elements that produced geometry, across 68 files) the gate admits
148    /// 24,073 of them — 71.4% — and not one of those reports a volume exceeding
149    /// its own bounding box.
150    ///
151    /// ### `all_closed`
152    ///
153    /// Over an open surface the divergence sum is not approximate, it is
154    /// arbitrary: the boundary-loop flux scales with the distance to the
155    /// reference point, so the "volume" of a sheet is whatever you referenced
156    /// it to. Material-layer wall slices are open bands by construction since
157    /// #1311 (`router/layers.rs` refuses to cap them, because capping made every
158    /// shared interface a doubled coincident sheet), and `IfcTriangulatedFaceSet`
159    /// TINs / `SurfaceModel`s are open by definition. Measured over the ara3d
160    /// fixture corpus at exactly this granularity — 73,626 segments across
161    /// 33,701 elements — 16.2% of segments are not a single closed orientable
162    /// component, and 15.3% of elements have at least one such segment.
163    ///
164    /// ### `all_orientable`
165    ///
166    /// A non-orientable component has no consistent inside, so the sign of each
167    /// triangle's contribution is arbitrary. `orient_mesh_outward` already
168    /// refuses to re-wind these for the same reason.
169    ///
170    /// ### `all_single_component`
171    ///
172    /// `orient_mesh_outward` flips each CLOSED component so its own signed
173    /// volume is POSITIVE. For two disjoint solids that is right. For a solid
174    /// whose cavity is a second, inner shell it is catastrophic: the sum reports
175    /// `outer + cavity` where the truth is `outer − cavity`. Distinguishing the
176    /// two needs a containment test, and the orientation already applied has
177    /// destroyed the sign that encoded the difference.
178    ///
179    /// ### `segments == 1` — the multi-segment decision
180    ///
181    /// A segment is one sub-mesh, which is one representation ITEM (or one
182    /// material-layer band). IFC treats an item list as an implicit UNION, and
183    /// exporters routinely emit items that overlap: a window frame and its sash
184    /// meeting at the rebate, lapped steel plates, a `Clearance` representation
185    /// whose `RepresentationType` is `SweptSolid` (which passes the body filter
186    /// in `router/rep_filter.rs` — nothing filters on the representation
187    /// IDENTIFIER), or two `Body` representations in different subcontexts.
188    /// A sum over overlapping items double-counts the intersection, and each
189    /// item on its own is a perfectly ordinary closed solid, so nothing about
190    /// the individual verdicts reveals it.
191    ///
192    /// Measured, this is not a corner case. Of the 4,472 all-closed
193    /// multi-segment elements in the corpus, 2,971 (66%) have a pair of segments
194    /// whose world boxes overlap by more than 1% of the smaller box, and the
195    /// most common failure is total containment (overlap fraction 1.000 — one
196    /// item's box entirely inside another's), on doors, windows and furnishing
197    /// assemblies. Summing them produces a volume larger than the element's OWN
198    /// bounding box — a geometric impossibility — on 987 of them (22%), with a
199    /// p90 of 2.99× and a maximum of 3.00× the box. Restricting to a single
200    /// segment leaves 0 such elements, with a p50 fill of 0.96 and a maximum of
201    /// exactly 1.00.
202    ///
203    /// AABB-disjointness was considered as a weaker gate and rejected: it is
204    /// unsound in both directions (two interlocking L-members have overlapping
205    /// boxes and disjoint solids; two overlapping solids can share one box), and
206    /// a real disjointness test is a CSG intersection per pair.
207    ///
208    /// There is also a consistency argument that needs no measurement. When the
209    /// sub-mesh path fails, `produce_element_meshes` falls back to
210    /// `process_element`, which merges every item into ONE mesh. That merged
211    /// mesh has two components, so `all_single_component` already rejects it.
212    /// Accepting the sum on the sub-mesh path would mean the same element
213    /// reports a volume or not depending on which router entry point happened to
214    /// succeed. `segments == 1` makes the two paths agree.
215    ///
216    /// ### What this canNOT certify
217    ///
218    /// Closedness is a property of the SURFACE, not evidence that the surface is
219    /// the RIGHT one. When the #1109 CSG budget trips, `apply_void_context`
220    /// returns the UNCUT host (`router/voids/mod.rs`), which is still a flawless
221    /// closed solid — it just still contains its openings. That over-reports and
222    /// this verdict cannot see it. A consumer that cares must also read
223    /// `ProducedElementMeshes::csg_failures`, which is where that degradation is
224    /// reported.
225    pub fn volume(&self) -> Option<f64> {
226        if !self.closure.is_trustworthy_solid() {
227            return None;
228        }
229        // MAGNITUDE, not the raw signed sum. For a closed orientable surface the
230        // magnitude IS the enclosed volume; the sign only records which side the
231        // winding calls outside, and `orient_mesh_outward` normally normalizes it
232        // to positive. Normally: that pass decides the flip from a sum taken
233        // about the mesh's LOCAL FRAME ORIGIN, and when the local frame sits far
234        // from the geometry that is a cancellation of large terms whose SIGN can
235        // come out wrong — the same reference-point sensitivity documented on
236        // `kernel::signed_volume::signed_volume6`. One element in the 24,073 that
237        // pass this gate across the fixture corpus arrives inward-wound for that
238        // reason. This accumulator references a point ON the surface, so its
239        // magnitude is sound either way; taking it keeps that pre-existing
240        // orientation defect (a normals/lighting bug, out of scope here) from
241        // turning into a negative volume.
242        Some((self.volume6 / 6.0).abs())
243    }
244}