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}