Skip to main content

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 post-verdict edit, `mesh_weld::weld_indexed`, needs no
125    /// such treatment: it merges only vertices with bit-identical `f32`
126    /// positions, a strict refinement of the orienter's 10 µm weld grid, so the
127    /// welded edge graph the verdict was read off is unchanged.
128    pub fn retract_closure_if_mesh_edited(&mut self, triangles_dropped: u64) {
129        if triangles_dropped > 0 {
130            self.closure.retract();
131        }
132    }
133
134    /// The entity's enclosed volume in cubic metres — `Some` ONLY when the
135    /// geometry that produced it is provably a single closed orientable solid,
136    /// `None` otherwise. THE RULE IS DELIBERATELY NARROW. Each clause of
137    /// [`GeometryClosure::is_trustworthy_solid`] rejects a specific way the
138    /// number would otherwise be silently wrong.
139    ///
140    /// It is narrow, not useless: measured over the ara3d fixture corpus
141    /// (33,701 elements that produced geometry, across 68 files) the gate admits
142    /// 24,073 of them — 71.4% — and not one of those reports a volume exceeding
143    /// its own bounding box.
144    ///
145    /// ### `all_closed`
146    ///
147    /// Over an open surface the divergence sum is not approximate, it is
148    /// arbitrary: the boundary-loop flux scales with the distance to the
149    /// reference point, so the "volume" of a sheet is whatever you referenced
150    /// it to. Material-layer wall slices are open bands by construction since
151    /// #1311 (`router/layers.rs` refuses to cap them, because capping made every
152    /// shared interface a doubled coincident sheet), and `IfcTriangulatedFaceSet`
153    /// TINs / `SurfaceModel`s are open by definition. Measured over the ara3d
154    /// fixture corpus at exactly this granularity — 73,626 segments across
155    /// 33,701 elements — 16.2% of segments are not a single closed orientable
156    /// component, and 15.3% of elements have at least one such segment.
157    ///
158    /// ### `all_orientable`
159    ///
160    /// A non-orientable component has no consistent inside, so the sign of each
161    /// triangle's contribution is arbitrary. `orient_mesh_outward` already
162    /// refuses to re-wind these for the same reason.
163    ///
164    /// ### `all_single_component`
165    ///
166    /// `orient_mesh_outward` flips each CLOSED component so its own signed
167    /// volume is POSITIVE. For two disjoint solids that is right. For a solid
168    /// whose cavity is a second, inner shell it is catastrophic: the sum reports
169    /// `outer + cavity` where the truth is `outer − cavity`. Distinguishing the
170    /// two needs a containment test, and the orientation already applied has
171    /// destroyed the sign that encoded the difference.
172    ///
173    /// ### `segments == 1` — the multi-segment decision
174    ///
175    /// A segment is one sub-mesh, which is one representation ITEM (or one
176    /// material-layer band). IFC treats an item list as an implicit UNION, and
177    /// exporters routinely emit items that overlap: a window frame and its sash
178    /// meeting at the rebate, lapped steel plates, a `Clearance` representation
179    /// whose `RepresentationType` is `SweptSolid` (which passes the body filter
180    /// in `router/rep_filter.rs` — nothing filters on the representation
181    /// IDENTIFIER), or two `Body` representations in different subcontexts.
182    /// A sum over overlapping items double-counts the intersection, and each
183    /// item on its own is a perfectly ordinary closed solid, so nothing about
184    /// the individual verdicts reveals it.
185    ///
186    /// Measured, this is not a corner case. Of the 4,472 all-closed
187    /// multi-segment elements in the corpus, 2,971 (66%) have a pair of segments
188    /// whose world boxes overlap by more than 1% of the smaller box, and the
189    /// most common failure is total containment (overlap fraction 1.000 — one
190    /// item's box entirely inside another's), on doors, windows and furnishing
191    /// assemblies. Summing them produces a volume larger than the element's OWN
192    /// bounding box — a geometric impossibility — on 987 of them (22%), with a
193    /// p90 of 2.99× and a maximum of 3.00× the box. Restricting to a single
194    /// segment leaves 0 such elements, with a p50 fill of 0.96 and a maximum of
195    /// exactly 1.00.
196    ///
197    /// AABB-disjointness was considered as a weaker gate and rejected: it is
198    /// unsound in both directions (two interlocking L-members have overlapping
199    /// boxes and disjoint solids; two overlapping solids can share one box), and
200    /// a real disjointness test is a CSG intersection per pair.
201    ///
202    /// There is also a consistency argument that needs no measurement. When the
203    /// sub-mesh path fails, `produce_element_meshes` falls back to
204    /// `process_element`, which merges every item into ONE mesh. That merged
205    /// mesh has two components, so `all_single_component` already rejects it.
206    /// Accepting the sum on the sub-mesh path would mean the same element
207    /// reports a volume or not depending on which router entry point happened to
208    /// succeed. `segments == 1` makes the two paths agree.
209    ///
210    /// ### What this canNOT certify
211    ///
212    /// Closedness is a property of the SURFACE, not evidence that the surface is
213    /// the RIGHT one. When the #1109 CSG budget trips, `apply_void_context`
214    /// returns the UNCUT host (`router/voids/mod.rs`), which is still a flawless
215    /// closed solid — it just still contains its openings. That over-reports and
216    /// this verdict cannot see it. A consumer that cares must also read
217    /// `ProducedElementMeshes::csg_failures`, which is where that degradation is
218    /// reported.
219    pub fn volume(&self) -> Option<f64> {
220        if !self.closure.is_trustworthy_solid() {
221            return None;
222        }
223        // MAGNITUDE, not the raw signed sum. For a closed orientable surface the
224        // magnitude IS the enclosed volume; the sign only records which side the
225        // winding calls outside, and `orient_mesh_outward` normally normalizes it
226        // to positive. Normally: that pass decides the flip from a sum taken
227        // about the mesh's LOCAL FRAME ORIGIN, and when the local frame sits far
228        // from the geometry that is a cancellation of large terms whose SIGN can
229        // come out wrong — the same reference-point sensitivity documented on
230        // `kernel::signed_volume::signed_volume6`. One element in the 24,073 that
231        // pass this gate across the fixture corpus arrives inward-wound for that
232        // reason. This accumulator references a point ON the surface, so its
233        // magnitude is sound either way; taking it keeps that pre-existing
234        // orientation defect (a normals/lighting bug, out of scope here) from
235        // turning into a negative volume.
236        Some((self.volume6 / 6.0).abs())
237    }
238}