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