Skip to main content

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