ifc_lite_geometry/
mesh_orient.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//! Make a mesh's per-triangle winding consistent and OUTWARD, per connected
6//! component.
7//!
8//! IFC faceted breps (whose face loops are not reliably outward) and merged
9//! multi-item bodies (an extrusion unioned with a boolean cut) can arrive with
10//! MIXED per-triangle winding: some faces wound outward, some inward. That
11//! corrupts signed volume (quantities) and the smooth normals
12//! [`crate::csg::calculate_normals`] accumulates from the winding (lighting). The
13//! kernel's whole-operand `orient_outward` only flips an ENTIRE mesh by its
14//! global signed volume, so it cannot repair a mesh that is internally
15//! inconsistent.
16//!
17//! [`orient_mesh_outward`] recovers face adjacency (the meshes are flat-shaded,
18//! so positions are welded by a fine grid), propagates a consistent orientation
19//! across shared edges per connected component, then flips each CLOSED component
20//! so its signed volume is positive (outward). An OPEN component (a TIN /
21//! `SurfaceModel` sheet with boundary edges — no meaningful enclosed volume) or a
22//! non-manifold / non-orientable one is left untouched: re-orienting it is
23//! ambiguous and would reverse authored normals. The winding-invariant geometry
24//! hash and the summary snapshots are unaffected; only normals/quantities change,
25//! which is the point.
26//!
27//! Deciding all of that requires knowing, per connected component, whether it is
28//! closed and whether it is orientable — and this pass is the ONLY place in the
29//! pipeline that knows. [`orient_mesh_outward_verdict`] hands that verdict back
30//! (it used to be computed and dropped on the floor); see [`OrientVerdict`].
31
32use crate::kernel::signed_volume::signed_volume6_of;
33use crate::Mesh;
34use rustc_hash::FxHashMap;
35use std::cell::RefCell;
36
37#[path = "mesh_orient_adjacency.rs"]
38mod adjacency;
39use adjacency::EdgeAdjacency;
40#[cfg(test)]
41use adjacency::EdgeInc;
42
43/// Vertex weld grid scale (reciprocal of a 10 µm grid, i.e. positions are
44/// quantized to `round(v * WELD_SCALE)`): fine enough not to merge distinct
45/// mm-scale features, coarse enough to weld the (usually bit-equal) coincident
46/// flat-shaded duplicates so shared edges are found. Under-welding only splits
47/// a body into more components (each still oriented); over-welding would fuse
48/// distinct vertices and is the dangerous direction, so the grid stays fine.
49const WELD_SCALE: f64 = 1.0e5;
50
51/// Per-worker reusable scratch for [`orient_mesh_outward`], cleared (never freed)
52/// between meshes. The pass runs once per assembled submesh (~109k times on a
53/// mesh-heavy model), so each fresh call's two `FxHashMap`s + the traversal `Vec`s were
54/// ~4-6% of busy CPU on pure-brep/steel models; pooling makes it allocate-once,
55/// clear-many. BYTE-IDENTICAL: neither map is ever iterated — both are only
56/// `.entry()`-inserted (order fixed by the deterministic scan) and keyed-looked-up,
57/// so bucket count / residual capacity can't reach the output; every `Vec` is
58/// fully overwritten before it is read. A cleared, reused buffer replays the
59/// identical fill sequence, so the flip decisions and `indices.swap`s are unchanged.
60#[derive(Default)]
61struct OrientScratch {
62    vid_of: FxHashMap<(i64, i64, i64), u32>,
63    vpos: Vec<[f64; 3]>,
64    corner: Vec<u32>,
65    edge_tris: EdgeAdjacency,
66    flip: Vec<bool>,
67    visited: Vec<bool>,
68    comp: Vec<usize>,
69    stack: Vec<usize>,
70}
71
72thread_local! {
73    /// One scratch per rayon worker (and the main / wasm single thread). A
74    /// `thread_local!`, not the `Vec<Mutex<_>>` worker-slot pattern: this LEAF
75    /// buffer never crosses a crate boundary, needs no lock (so no deadlock risk,
76    /// unlike the CartesianPoint cache #1572), and works when
77    /// `rayon::current_thread_index()` is `None` (direct calls, tests, wasm). Each
78    /// worker owns its instance — no cross-thread sharing, fully deterministic.
79    /// Re-entrancy is TAKE / put-back (below): the `RefCell` borrow is never held
80    /// across the computation, so a nested-`par_iter` re-entrant call on the same
81    /// thread mints a fresh scratch instead of panicking on a double borrow.
82    static ORIENT_SCRATCH: RefCell<Option<OrientScratch>> = const { RefCell::new(None) };
83}
84
85/// What [`orient_mesh_outward_verdict`] concluded about a mesh's SURFACE
86/// TOPOLOGY, on top of whether it re-wound anything.
87///
88/// The orienter has to decide, per connected component, whether that component
89/// is CLOSED (every welded edge shared by exactly two triangles) and ORIENTABLE
90/// (no winding contradiction closes a cycle), because only such a component has
91/// a meaningful "outward" to flip toward. It then discarded the answer. Nothing
92/// downstream can recover it: by the time a mesh reaches the hasher or the FFI
93/// boundary the adjacency has not been rebuilt, and rebuilding it is this pass's
94/// whole cost.
95///
96/// The consumer that needs it is a divergence-theorem volume. Over an OPEN
97/// surface that sum is not approximate, it is ARBITRARY — the boundary-loop flux
98/// grows with the distance to the reference point, so the "volume" of a sheet is
99/// whatever you referenced it to. There is no way to spot that from the number
100/// itself; it looks like an ordinary positive volume. This verdict is what lets
101/// a consumer refuse instead of guessing.
102#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
103pub struct OrientVerdict {
104    /// At least one triangle's winding was flipped, so any baked normals are
105    /// stale. Exactly the `bool` [`orient_mesh_outward`] returns.
106    pub flipped: bool,
107    /// EVERY connected component is closed (no boundary edge, no non-manifold
108    /// edge). False if there are no components at all — see [`Self::INDETERMINATE`].
109    pub all_closed: bool,
110    /// EVERY connected component is orientable (consistent winding propagated
111    /// without contradiction). Independent of `all_closed`: a Klein-bottle-like
112    /// or self-touching component can be edge-manifold yet non-orientable.
113    pub all_orientable: bool,
114    /// Connected components found (BFS over welded-edge adjacency). A single
115    /// closed body is 1; a faceted brep holding two disjoint solids, or a solid
116    /// plus the shell of its own cavity, is 2.
117    pub components: u32,
118}
119
120impl OrientVerdict {
121    /// The verdict for a mesh the orienter refused to analyse: fewer than two
122    /// triangles, or a malformed position/index buffer. Nothing is known, so
123    /// nothing is claimed — `all_closed` is FALSE rather than vacuously true, so
124    /// a consumer gating on it emits nothing. That is also the correct geometric
125    /// answer: a mesh of under two triangles cannot enclose anything.
126    pub const INDETERMINATE: Self = Self {
127        flipped: false,
128        all_closed: false,
129        all_orientable: false,
130        components: 0,
131    };
132
133    /// The one shape for which a divergence-theorem volume over this mesh is
134    /// trustworthy: EXACTLY ONE closed, orientable component.
135    ///
136    /// Why not the weaker "all components closed". This pass flips each closed
137    /// component so its OWN signed volume is positive. For two disjoint solids
138    /// that is right and the sum is their combined volume. But for a solid whose
139    /// cavity is modelled as a second, inner shell it is wrong in the worst way:
140    /// the cavity shell is also made positive, so the sum reports
141    /// `outer + cavity` where the true volume is `outer − cavity`. Telling those
142    /// two arrangements apart needs a containment test this pass does not do —
143    /// and the orientation it already applied has destroyed the sign that would
144    /// have distinguished them. One component has no such ambiguity.
145    #[inline]
146    pub fn is_single_closed_solid(&self) -> bool {
147        self.components == 1 && self.all_closed && self.all_orientable
148    }
149}
150
151/// Orient every connected component of `mesh` consistently and outward, in place.
152/// Returns `true` iff any triangle's winding was flipped (the caller must then
153/// recompute normals — the existing ones were baked with the old winding).
154///
155/// Thin wrapper over [`orient_mesh_outward_verdict`], kept because most callers
156/// only ever needed the "did you touch my normals" bit.
157pub fn orient_mesh_outward(mesh: &mut Mesh) -> bool {
158    orient_mesh_outward_verdict(mesh).flipped
159}
160
161/// [`orient_mesh_outward`], also reporting the per-component topology it had to
162/// work out along the way. See [`OrientVerdict`].
163///
164/// Identical mutation: this IS the orienting pass, and no flip decision reads
165/// the verdict fields.
166pub fn orient_mesh_outward_verdict(mesh: &mut Mesh) -> OrientVerdict {
167    let ntri = mesh.indices.len() / 3;
168    if ntri < 2 {
169        return OrientVerdict::INDETERMINATE;
170    }
171    // Bail cleanly on malformed buffers instead of panicking on an out-of-range
172    // index below. (Before any scratch is taken — the bail path allocates nothing.)
173    let vertex_count = mesh.positions.len() / 3;
174    if !mesh.positions.len().is_multiple_of(3)
175        || mesh.indices.iter().any(|&idx| idx as usize >= vertex_count)
176    {
177        return OrientVerdict::INDETERMINATE;
178    }
179
180    // Take this worker's warm scratch (or mint one on the first call / a re-entrant
181    // borrow). The momentary `.with` borrow is never held across the pass, so a
182    // nested-`par_iter` re-entrant call on this thread can't trip a double borrow;
183    // the scratch is handed back on the single return path (byte-for-byte the
184    // pre-pool algorithm). The disjoint `&mut` field bindings (the borrow checker
185    // splits struct fields) let a read-only closure borrow one buffer while another
186    // is mutated.
187    let mut scratch = ORIENT_SCRATCH.with(|c| c.borrow_mut().take()).unwrap_or_default();
188    let OrientScratch { vid_of, vpos, corner, edge_tris, flip, visited, comp, stack } =
189        &mut scratch;
190    // Clear (retain capacity) before use so no stale data leaks in; the reserves
191    // restore the original `with_capacity` sizing.
192    vid_of.clear();
193    vid_of.reserve(vertex_count);
194    vpos.clear();
195    corner.clear();
196    corner.reserve(mesh.indices.len());
197
198    // #3988: indexed corners repeatedly use the SAME source vertex. Reuse its
199    // welded id, keeping the original first-corner insertion order. Sparse meshes
200    // use corner slots instead, so scratch never exceeds the old corner budget.
201    let indexed = vertex_count < mesh.indices.len();
202    if indexed { corner.resize(vertex_count, u32::MAX); }
203    let q = |v: f32| (v as f64 * WELD_SCALE).round() as i64;
204    for &idx in &mesh.indices {
205        if indexed && corner[idx as usize] != u32::MAX { continue; }
206        let b = idx as usize * 3;
207        let key = (
208            q(mesh.positions[b]),
209            q(mesh.positions[b + 1]),
210            q(mesh.positions[b + 2]),
211        );
212        let vid = *vid_of.entry(key).or_insert_with(|| {
213            let id = vpos.len() as u32;
214            vpos.push([
215                key.0 as f64 / WELD_SCALE,
216                key.1 as f64 / WELD_SCALE,
217                key.2 as f64 / WELD_SCALE,
218            ]);
219            id
220        });
221        if indexed { corner[idx as usize] = vid; } else { corner.push(vid); }
222    }
223    let tv = |t: usize| std::array::from_fn::<_, 3, _>(|k| {
224        corner[if indexed { mesh.indices[3 * t + k] as usize } else { 3 * t + k }]
225    });
226
227    // Undirected welded edge -> incident triangles. >2 incident ⇒ non-manifold.
228    // A closed manifold has ~1.5 edges per triangle; reserve to skip rehashing.
229    edge_tris.reset(ntri);
230    for t in 0..ntri {
231        let v = tv(t);
232        for (slot, &(a, b)) in [(v[0], v[1]), (v[1], v[2]), (v[2], v[0])].iter().enumerate() {
233            if a == b {
234                continue; // welded-degenerate edge
235            }
236            let key = if a < b { (a, b) } else { (b, a) };
237            edge_tris.push(key, t * 3 + slot);
238        }
239    }
240
241    // `clear()` + `resize(ntri, false)` reproduces the original `vec![false; ntri]`.
242    flip.clear();
243    flip.resize(ntri, false);
244    visited.clear();
245    visited.resize(ntri, false);
246    let mut any_flip = false;
247    // The verdict, folded across components as they are discovered. `all_*`
248    // start TRUE because they are conjunctions over the components; a mesh with
249    // zero components never reaches here (`ntri < 2` bailed above), so they can
250    // never be reported vacuously true.
251    let mut verdict = OrientVerdict {
252        flipped: false,
253        all_closed: true,
254        all_orientable: true,
255        components: 0,
256    };
257
258    for seed in 0..ntri {
259        if visited[seed] {
260            continue;
261        }
262        verdict.components += 1;
263        // BFS the component, propagating a consistent orientation. `comp`/`stack`
264        // are cleared per component (pre-pool freshly allocated them) — identical order.
265        comp.clear();
266        stack.clear();
267        stack.push(seed);
268        visited[seed] = true;
269        let mut orientable = true;
270        // Only a CLOSED manifold (every welded edge shared by exactly two tris) has
271        // a meaningful "outward". An OPEN component — an `IfcTriangulatedFaceSet`
272        // TIN (`Closed=.F.`), a `SurfaceModel` sheet — would be flipped by its
273        // (meaningless) signed volume, reversing the authored normals. Track any
274        // boundary/non-manifold edge and leave such a component untouched.
275        let mut closed = true;
276
277        while let Some(t) = stack.pop() {
278            comp.push(t);
279            let v = tv(t);
280            // Effective directed edges of t given its current flip.
281            let dirs = if flip[t] {
282                [(v[0], v[2]), (v[2], v[1]), (v[1], v[0])]
283            } else {
284                [(v[0], v[1]), (v[1], v[2]), (v[2], v[0])]
285            };
286            for (slot, &(a, b)) in dirs.iter().enumerate() {
287                if a == b {
288                    continue;
289                }
290                let key = if a < b { (a, b) } else { (b, a) };
291                let original_slot = if flip[t] { 2 - slot } else { slot };
292                let (closed_edge, neighbors) = edge_tris.neighbors(key, t * 3 + original_slot);
293                if !closed_edge { closed = false; }
294                for nb in neighbors {
295                    if nb == usize::MAX || nb == t { continue; }
296                    // A consistent neighbour must traverse this edge as (b, a). Its
297                    // UNFLIPPED winding has (a, b) iff it must flip to do so.
298                    let nv = tv(nb);
299                    let need_flip =
300                        [(nv[0], nv[1]), (nv[1], nv[2]), (nv[2], nv[0])].contains(&(a, b));
301                    if !visited[nb] {
302                        visited[nb] = true;
303                        flip[nb] = need_flip;
304                        stack.push(nb);
305                    } else if flip[nb] != need_flip {
306                        orientable = false; // contradiction (non-orientable)
307                    }
308                }
309            }
310        }
311
312        verdict.all_closed &= closed;
313        verdict.all_orientable &= orientable;
314
315        if !orientable || !closed {
316            for &t in comp.iter() {
317                flip[t] = false; // open / non-orientable — leave winding as authored
318            }
319            continue;
320        }
321
322        // Flip the whole CLOSED component outward (positive signed volume).
323        // The sum is referenced to the component's own AABB centre, not the
324        // world origin: `kernel::signed_volume` explains why (each term is
325        // O(|v|^3), so far from the origin the SIGN is rounding noise), and
326        // `a_small_solid_far_from_the_origin_is_oriented_by_its_shape_not_its_position`
327        // measures it. One home for that rule; this is a caller.
328        let vol6 = signed_volume6_of(comp.iter().map(|&t| {
329            let v = tv(t);
330            let (i0, i1, i2) = if flip[t] { (v[0], v[2], v[1]) } else { (v[0], v[1], v[2]) };
331            [vpos[i0 as usize], vpos[i1 as usize], vpos[i2 as usize]]
332        }));
333        if vol6 < 0.0 {
334            for &t in comp.iter() {
335                flip[t] = !flip[t];
336            }
337        }
338    }
339
340    for t in 0..ntri {
341        if flip[t] {
342            mesh.indices.swap(3 * t + 1, 3 * t + 2);
343            any_flip = true;
344        }
345    }
346    verdict.flipped = any_flip;
347    // Field borrows have ended (NLL); hand the now-warm scratch back to this worker.
348    ORIENT_SCRATCH.with(|c| *c.borrow_mut() = Some(scratch));
349    verdict
350}
351
352#[cfg(test)]
353#[path = "mesh_orient_tests.rs"]
354mod tests;