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
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
// 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/.
//! Which DIRECTIONS the [`clash_solid`](crate::clash_solid) trust gate measures
//! the intersection's thickness along.
//!
//! Split out of `clash_solid.rs` to keep that module inside the repository's
//! 400-line ceiling; it is one cohesive question — "what could the contact
//! normal of this pair be?" — and the gate itself is the only caller.
//!
//! The short version, in full in [`gate_axes`]: the world axes are always in
//! the set, and every distinct face-normal direction of EACH operand joins
//! them too — box-shaped or not — along with every pairwise cross product
//! between an `a`-face axis and a `b`-face axis. For two boxes that reduces
//! to the classical 15 OBB separating-axis candidates. Because the gate
//! takes the MINIMUM extent over the set, adding an axis can only tighten
//! it.
use crateMesh;
/// Tolerance for "these three families are mutually perpendicular".
///
/// [`Mesh::positions`] is **f32**, so a face normal reconstructed from a
/// tessellated box's edge vectors carries ~1e-6 rad of round-off, which a
/// 1e-6 dot-product test would reject — and a rejection here silently drops
/// back to the world axes, i.e. back to the very bug this machinery exists to
/// fix. 1e-4 (0.006 rad) admits that round-off while still rejecting a
/// genuinely non-orthogonal frame; the axes are only ever used as *directions
/// to measure an extent along*, where a 0.006 rad error is worth ~2e-5 of the
/// extent.
const ORTHO_EPS: f64 = 1.0e-4;
/// Flip `n` so its largest-magnitude component is positive, collapsing a face
/// and its antipodal opposite onto one canonical direction. Ties break x, y, z.
/// Same rule as the sibling OBB kernel's `canonical`.
pub
pub
/// Reject only a truly degenerate (zero-length or non-finite) vector — not a
/// scale-dependent magnitude threshold. `v` is a cross product of edge
/// vectors reconstructed from f32 mesh positions, so its length scales with
/// the *square* of the operand's own dimensions: a fixed absolute epsilon
/// here would silently drop every face-normal candidate for any operand
/// small enough that its triangle areas fall under that epsilon, collapsing
/// `orthogonal_face_axes` back to `None` (i.e. the world-axes-only fallback)
/// for small but perfectly valid box operands.
///
/// This function alone cannot distinguish "small but valid" from "sliver
/// noise" — both produce a small `len`, and length is exactly what scales
/// with the operand's own dimensions. That distinction needs the *relative*
/// test in [`face_normal`], which is why every caller that reconstructs a
/// face normal from triangle vertices goes through `face_normal`, not this
/// function directly. `normalize3` stays a plain building block, used
/// directly only where the input is already scale-free (e.g. crossing two
/// unit face-normal candidates in [`gate_axes`]).
/// Minimum `sin(θ)` between a triangle's two edges at the vertex the normal
/// is computed from, where θ is that triangle's interior angle there — a
/// **dimensionless** ratio (`|edge1 × edge2| / (|edge1| · |edge2|)`), not the
/// raw cross-product magnitude. A 1 mm box corner and a 100 m wall corner are
/// both right angles, θ = 90°, sin θ = 1, so this judges them identically —
/// which a length-based cutoff on the cross product cannot do, since that
/// magnitude scales with the *square* of the operand's own dimensions.
///
/// `Mesh::positions` is f32 (~1.2e-7 relative precision), so vertices
/// reconstructed from it carry noise of roughly that order. A genuinely
/// degenerate or near-collinear triangle (three points nearly on a line —
/// e.g. a stray sliver a tessellator emits alongside real box faces)
/// produces sin θ at or below that noise floor; its direction is unrelated
/// to any real geometric feature, verified by construction to swing across
/// dot products of −0.95 to 0.55 against the true face normal for sin θ in
/// the 1e-8 range. `1e-3` (θ ≈ 0.057°) sits three orders of magnitude above
/// that ~1e-7 noise floor while a genuine box-corner triangle sits at
/// sin θ = 1, eight orders of magnitude above the cutoff — so the cutoff
/// rejects noise with a wide margin on both sides regardless of the
/// operand's absolute size.
///
/// Rejected alternatives:
/// - An absolute length threshold on the cross product (the original bug):
/// fails exactly because it is not scale-free.
/// - No threshold at all (this file's prior state): admits ULP-noise
/// directions as described above.
/// - A threshold on the angle itself (`asin`/`acos`) instead of on sin θ:
/// equivalent near this range but costs an extra transcendental call for
/// no precision benefit this close to zero, where sin θ ≈ θ.
const MIN_SIN_THETA: f64 = 1.0e-3;
/// The unit normal of the triangle `(a, b, c)`, or `None` when the triangle
/// is degenerate (zero-length edge, non-finite input) or a sliver relative to
/// its own edge lengths (see [`MIN_SIN_THETA`]).
/// Cap on distinct face-normal direction families collected per operand.
/// `gate_axes` crosses every family of `a` against every family of `b`
/// (`O(|families_a| · |families_b|)`), but this module runs on ONE selected
/// clash pair at a time, never the detection sweep (`clash_solid` module
/// docs, "On demand, never eager"), so a few thousand candidates cost
/// nothing a user would notice. The cap only bounds the pathological case of
/// a mesh with hundreds of mutually non-parallel triangle normals; an
/// ordinary `n`-gon extrusion contributes at most `n + 2` families, and 64
/// covers a 62-gon — finer than any real IFC profile tessellation.
///
/// The direction of error when the cap DOES bite, stated plainly because the
/// rest of this module is careful about it: truncating drops candidate axes,
/// and a dropped axis can only make the gate find LESS separation, so a
/// contact it would otherwise withhold can be admitted. That is the unsafe
/// direction — the opposite of the "gate-tightening only" property the
/// generalisation otherwise has.
///
/// It is nonetheless a strict improvement on every input, because the
/// alternative is not an uncapped scan: before this, `gate_axes` fell back to
/// the three WORLD axes for any operand that was not a perfect box (its own
/// doc called that "conservative, not correct"). A tessellated cylinder went
/// from 3 candidate axes to at least 64 per operand plus their crosses. So
/// the cap leaves the generalisation incomplete for meshes with more than 64
/// distinct normal families — a tessellated dome or a swept curved BREP, not
/// a profile extrusion — rather than making anything worse than it was.
const MAX_FACE_FAMILIES: usize = 64;
/// The distinct face-normal direction families of `m`: one canonical unit
/// direction per group of mutually-parallel (within `ORTHO_EPS`) triangle
/// face normals, in first-seen order, capped at `MAX_FACE_FAMILIES`.
///
/// Unlike a box-frame detector, this makes NO claim the families are
/// mutually perpendicular or that there are exactly three — it works for ANY
/// polyhedral mesh, box-shaped or not. A flat face is a legitimate candidate
/// contact-normal direction whether or not the rest of the mesh forms a
/// rectangular box: a chamfered beam end, a mitred pipe joint or an
/// arbitrary extruded profile still has flat faces, and the true contact
/// normal of a planar touch between two polyhedra is parallel to one of
/// those faces (see `gate_axes`).
/// The three mutually perpendicular face-normal directions of a box-shaped
/// operand, or `None` when the mesh's faces do not fall into exactly three
/// such families. A special case of [`face_axis_families`] (exactly 3
/// families, mutually orthogonal); kept `cfg(test)` for the box-specific
/// unit tests below, its only remaining caller — `gate_axes` calls the more
/// general function directly.
/// Unit directions the thickness is measured along.
///
/// Always the three world axes — the historical behaviour, and the floor,
/// never the ceiling: thickness is the MINIMUM extent over this set, so
/// every extra axis can only *lower* it and therefore only tighten the gate.
/// An added axis can withhold a solid that used to be returned; it can never
/// admit one that used to be withheld.
///
/// The set also carries EVERY distinct face-normal direction
/// ([`face_axis_families`]) of each operand, box-shaped or not, plus every
/// pairwise cross product between an `a`-family axis and a `b`-family axis.
/// For two boxes this reduces to the classical 15 OBB separating-axis
/// candidates the #2573 review's box-box fix introduced. For a non-box
/// operand — a chamfered beam end, a mitred pipe joint, an arbitrary profile
/// — it is the direct generalisation: that operand's own flat faces are
/// still legitimate candidate contact-normal directions, since the true
/// contact normal of a planar touch between two polyhedra is parallel to a
/// face of at least one of them. That closes the review's finding: a box vs.
/// a corner-chamfering tetrahedron now includes the tetrahedron's own
/// cut-face normal, not just the world axes.
///
/// Not a completeness guarantee for every contact — a genuinely curved
/// touch, or a true normal that is a cross product of edge directions no
/// face here exposes, is not derivable this way — so the blind spot is
/// narrowed, not eliminated. But an extra axis can only tighten the gate,
/// never certify a shallow contact as thick; where the true normal is not
/// among these candidates the world-axis floor still applies the module's
/// trust threshold, just without certainty the measured extent is the true
/// minimum. There is no cheaper analytic candidate left to add without
/// inventing a depth.
pub