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}