Skip to main content

brep_kernel/meshing/watertight_tessellation/
orient.rs

1use super::*;
2
3/// Watertight tessellation of a closed solid: coincident triangulation along
4/// every shared edge, density driven by `chord_tolerance`.
5pub fn tessellate_brep_watertight(solid: &BrepSolid, chord_tolerance: f64) -> Result<Mesh, String> {
6    // A disconnected shell is not necessarily another exterior component: in
7    // a BREP_WITH_VOIDS its authored winding is negative so its normal points
8    // away from material and into the cavity. Preserve that sign through the
9    // mesh coherence pass. The overwhelmingly common one-shell path remains
10    // the historical positive/outward convention without an extra integral.
11    let mut face_shell_signs = Vec::new();
12    if solid.shells.len() == 1 {
13        face_shell_signs.resize(solid.shells[0].faces.len(), 1_i8);
14    } else {
15        for shell in &solid.shells {
16            let volume = crate::mass_properties::shell_signed_volume(shell)?;
17            let sign = if volume < 0.0 { -1_i8 } else { 1_i8 };
18            face_shell_signs.resize(face_shell_signs.len() + shell.faces.len(), sign);
19        }
20    }
21    // The whole solid is stride 1 (every face). Validation (closed-shell,
22    // no odd-use edges) only makes sense on the complete mesh.
23    let mut mesh = tessellate_brep_watertight_face_stride(solid, chord_tolerance, 1, 0)?;
24    // Final orientation pass over the COMPLETE mesh: make every shared edge
25    // coherent and match each component to its source shell's material-side
26    // orientation. Faces are emitted
27    // with the `same_sense` convention, which is per-face consistent but leaves
28    // some faces (CW-in-uv seam bands / folded patches) or whole shells wound
29    // inward; this pass repairs both at the mesh level. Positions are never
30    // touched, so watertightness is preserved (validated below).
31    orient_mesh_coherently(&mut mesh, chord_tolerance, &face_shell_signs)?;
32    mesh.validate()?;
33    Ok(mesh)
34}
35
36/// Geometric normal of triangle `t` in its CURRENT winding (`(b-a)×(c-a)`),
37/// magnitude 2·area. Read-only over the mesh arrays.
38pub(super) fn tri_geometric_normal(positions: &[f64], indices: &[u32], t: usize) -> [f64; 3] {
39    let p = |i: usize| {
40        let i = i * 3;
41        [positions[i], positions[i + 1], positions[i + 2]]
42    };
43    let a = p(indices[3 * t] as usize);
44    let b = p(indices[3 * t + 1] as usize);
45    let c = p(indices[3 * t + 2] as usize);
46    let e1 = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
47    let e2 = [c[0] - a[0], c[1] - a[1], c[2] - a[2]];
48    [
49        e1[1] * e2[2] - e1[2] * e2[1],
50        e1[2] * e2[0] - e1[0] * e2[2],
51        e1[0] * e2[1] - e1[1] * e2[0],
52    ]
53}
54
55/// Signed volume of the tetrahedron (origin, a, b, c) for triangle `t` in its
56/// CURRENT winding: `a · (b × c) / 6`. Summed over a closed shell it is positive
57/// iff the winding faces outward (translation-invariant for a closed surface).
58pub(super) fn tetra_signed_volume(positions: &[f64], indices: &[u32], t: usize) -> f64 {
59    let p = |i: usize| {
60        let i = i * 3;
61        [positions[i], positions[i + 1], positions[i + 2]]
62    };
63    let a = p(indices[3 * t] as usize);
64    let b = p(indices[3 * t + 1] as usize);
65    let c = p(indices[3 * t + 2] as usize);
66    (a[0] * (b[1] * c[2] - b[2] * c[1]) - a[1] * (b[0] * c[2] - b[2] * c[0])
67        + a[2] * (b[0] * c[1] - b[1] * c[0]))
68        / 6.0
69}
70
71/// Final mesh-level orientation pass: make every 2-manifold shared edge
72/// COHERENT (traversed once in each direction) and wind each connected
73/// component to its source shell's material orientation. This repairs both
74/// within-face incoherence (a folded or
75/// trimmed face whose per-vertex analytic normal flips, so the `same_sense`
76/// emission winds half of it inside-out) and whole faces / shells wound inward.
77///
78/// Positions are never modified — only a triangle's 2nd/3rd index may be swapped
79/// and a stored normal's sign flipped — so the mesh stays watertight. Runs in
80/// O(V + T): hashed welding, a triangle flood-fill over shared edges, then each
81/// component oriented by the sign of its centroid-referenced signed volume
82/// (fold-immune; the stored analytic normals are NOT trusted for orientation
83/// because they are exactly what flips), matched to the source shell sign.
84pub(crate) fn orient_mesh_coherently(
85    mesh: &mut Mesh,
86    chord: f64,
87    face_shell_signs: &[i8],
88) -> Result<(), String> {
89    let tri_count = mesh.indices.len() / 3;
90    if tri_count == 0 {
91        return Ok(());
92    }
93    let vcount = mesh.positions.len() / 3;
94    if vcount == 0 {
95        return Ok(());
96    }
97    let _timer = std::env::var("BREP_TIME_ORIENT")
98        .ok()
99        .map(|_| web_time::Instant::now());
100
101    // (a) Weld raw vertices by quantized position, so triangles from adjacent
102    // faces that meet along a shared edge share welded endpoints.
103    let mut lo = [f64::INFINITY; 3];
104    let mut hi = [f64::NEG_INFINITY; 3];
105    for i in 0..vcount {
106        for k in 0..3 {
107            let x = mesh.positions[3 * i + k];
108            if x < lo[k] {
109                lo[k] = x;
110            }
111            if x > hi[k] {
112                hi[k] = x;
113            }
114        }
115    }
116    let diag = {
117        let d = [hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]];
118        (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
119    };
120    if !(diag > 0.0) {
121        return Ok(());
122    }
123    // Shared boundary samples are identical by construction. Preserve their
124    // exact edge adjacency before considering approximate seam matches: a
125    // chord-sized weld grid must not collapse a real, short boundary edge.
126    let mut exact: FxHashMap<[u64; 3], u32> = FxHashMap::default();
127    let mut welded = Vec::with_capacity(vcount);
128    for point in mesh.positions.chunks_exact(3) {
129        if point.iter().any(|x| !x.is_finite()) {
130            return Err("orient_mesh_coherently: nonfinite vertex".into());
131        }
132        let key = std::array::from_fn(|i| {
133            if point[i] == 0. {
134                0
135            } else {
136                point[i].to_bits()
137            }
138        });
139        let next = exact.len() as u32;
140        welded.push(*exact.entry(key).or_insert(next));
141    }
142    let mut edge_tris: FxHashMap<(u32, u32), Vec<(u32, bool)>> = FxHashMap::default();
143    edge_tris.reserve(tri_count * 3);
144    for (t, triangle) in mesh.indices.chunks_exact(3).enumerate() {
145        for i in 0..3 {
146            let a = welded[triangle[i] as usize];
147            let b = welded[triangle[(i + 1) % 3] as usize];
148            if a == b {
149                continue;
150            }
151            let (key, forward) = if a < b {
152                ((a, b), true)
153            } else {
154                ((b, a), false)
155            };
156            edge_tris.entry(key).or_default().push((t as u32, forward));
157        }
158    }
159    // Only one-use exact edges need a geometric seam match. Approximate
160    // groups use a disjoint ID range and cannot change an exact shared edge.
161    let mut unmatched: Vec<_> = edge_tris
162        .iter()
163        .filter(|(_, uses)| uses.len() == 1)
164        .map(|(&key, _)| key)
165        .collect();
166    unmatched.sort_unstable();
167    if !unmatched.is_empty() {
168        let quant = (chord * 1e-2).max(1e-12);
169        let mut coarse: FxHashMap<[i64; 3], u32> = FxHashMap::default();
170        let mut coarse_id = vec![None; exact.len()];
171        for (raw, point) in mesh.positions.chunks_exact(3).enumerate() {
172            let coordinates: [f64; 3] =
173                std::array::from_fn(|i| ((point[i] - lo[i]) / quant).round());
174            // Unrepresentable grid coordinates decline only the approximate match;
175            // exact adjacency remains available, without saturating integer keys.
176            if coordinates
177                .iter()
178                .any(|x| !x.is_finite() || x.abs() >= i64::MAX as f64)
179            {
180                continue;
181            }
182            let key = coordinates.map(|x| x as i64);
183            let next = u32::try_from(exact.len() + coarse.len())
184                .map_err(|_| "orient_mesh_coherently: too many welded vertices")?;
185            coarse_id[welded[raw] as usize] = Some(*coarse.entry(key).or_insert(next));
186        }
187        let mut approximate: FxHashMap<(u32, u32), Vec<(u32, bool)>> = FxHashMap::default();
188        for (a, b) in unmatched {
189            let (Some(ca), Some(cb)) = (coarse_id[a as usize], coarse_id[b as usize]) else {
190                continue;
191            };
192            if ca == cb {
193                continue;
194            } // never erase a finite edge for a seam match
195            let uses = edge_tris.remove(&(a, b)).unwrap();
196            let key = if ca < cb { (ca, cb) } else { (cb, ca) };
197            approximate.entry(key).or_default().extend(
198                uses.into_iter()
199                    .map(|(t, forward)| (t, if ca < cb { forward } else { !forward })),
200            );
201        }
202        edge_tris.extend(approximate);
203    }
204
205    // (c) Triangle adjacency across 2-manifold edges. `same_dir` means both
206    // triangles currently traverse the shared edge the SAME way (incoherent), so
207    // their flip bits must DIFFER to become coherent. Non-manifold seam / pole
208    // edges (welded to > 2 triangles) are NOT propagated across — pairing their
209    // triangles is geometrically ambiguous and forcing a pairing injects
210    // contradictory constraints. Leaving them unpropagated only splits the mesh
211    // into more components; the outward pass below orients each independently.
212    let mut adj: Vec<Vec<(u32, bool)>> = vec![Vec::new(); tri_count];
213    for (key, uses) in &edge_tris {
214        // Establish exact components first. Approximate seam matches may
215        // orient whole components, never contradict an exact shared edge.
216        if key.0 >= exact.len() as u32 || uses.len() != 2 {
217            continue;
218        }
219        let (t0, d0) = uses[0];
220        let (t1, d1) = uses[1];
221        let same_dir = d0 == d1;
222        adj[t0 as usize].push((t1, same_dir));
223        adj[t1 as usize].push((t0, same_dir));
224    }
225    // Canonical neighbour order so the flood-fill (and thus every flip) is
226    // deterministic regardless of hash-map iteration order.
227    for a in adj.iter_mut() {
228        a.sort_unstable();
229    }
230
231    // Flood-fill: propagate a coherent winding across each connected component
232    // (fragments split at non-manifold seams) and record each triangle's
233    // component index.
234    let mut flip = vec![false; tri_count];
235    let mut comp_of = vec![u32::MAX; tri_count];
236    let mut stack: Vec<u32> = Vec::new();
237    let mut ncomp = 0u32;
238    for seed in 0..tri_count {
239        if comp_of[seed] != u32::MAX {
240            continue;
241        }
242        comp_of[seed] = ncomp;
243        stack.push(seed as u32);
244        while let Some(t) = stack.pop() {
245            let tf = flip[t as usize];
246            for &(nb, same_dir) in &adj[t as usize] {
247                if comp_of[nb as usize] != u32::MAX {
248                    continue;
249                }
250                flip[nb as usize] = tf ^ same_dir;
251                comp_of[nb as usize] = ncomp;
252                stack.push(nb);
253            }
254        }
255        ncomp += 1;
256    }
257    let ncomp = ncomp as usize;
258
259    // (d) Fragments split at non-manifold seams are re-joined into whole SHELLS.
260    // A component is internally coherent but its GLOBAL sign is an independent
261    // gauge; two fragments meeting at a seam edge must traverse it oppositely, so
262    // their relative gauge is fixed by that edge. A parity union-find over the
263    // seam edges resolves every fragment's sign relative to its shell. (Open
264    // fragments have no reliable inside/outside on their own — this is why a
265    // per-fragment vote fails — but a fully assembled shell is closed, so its
266    // signed volume gives a robust outward test.)
267    let mut parent: Vec<u32> = (0..ncomp as u32).collect();
268    let mut prel = vec![false; ncomp]; // parity from node to its parent
269    fn find(parent: &mut [u32], prel: &mut [bool], mut c: u32) -> (u32, bool) {
270        let mut par = false;
271        // Walk to root accumulating parity.
272        let mut path = Vec::new();
273        while parent[c as usize] != c {
274            path.push(c);
275            par ^= prel[c as usize];
276            c = parent[c as usize];
277        }
278        // Path-compress: point every visited node straight at the root with its
279        // total parity to the root.
280        let mut acc = par;
281        for &node in path.iter() {
282            let this = prel[node as usize];
283            parent[node as usize] = c;
284            prel[node as usize] = acc;
285            acc ^= this;
286        }
287        (c, par)
288    }
289    let mut fwd: Vec<u32> = Vec::new();
290    let mut rev: Vec<u32> = Vec::new();
291    // Process seam edges in a CANONICAL (sorted) order so the union-find gauge is
292    // deterministic regardless of hash-map iteration order.
293    let mut seam_keys: Vec<(u32, u32)> = edge_tris.keys().copied().collect();
294    seam_keys.sort_unstable();
295    for key in &seam_keys {
296        let uses = &edge_tris[key];
297        // A two-use seam has an unambiguous triangle pair regardless of its
298        // initial winding. For non-manifold seams retain the legacy pairing
299        // of opposite raw directions. A coherent continuation
300        // traverses the edge once each way, so the required relative gauge between
301        // the two fragments is 1 exactly when they currently traverse it the SAME
302        // effective way (effective dir = raw dir XOR flip).
303        fwd.clear();
304        rev.clear();
305        for &(t, d) in uses {
306            if d {
307                fwd.push(t);
308            } else {
309                rev.push(t);
310            }
311        }
312        let pairs = if uses.len() == 2 {
313            1
314        } else {
315            fwd.len().min(rev.len())
316        };
317        for i in 0..pairs {
318            let ((ta, da), (tb, db)) = if uses.len() == 2 {
319                (uses[0], uses[1])
320            } else {
321                ((fwd[i], true), (rev[i], false))
322            };
323            let (ca, cb) = (comp_of[ta as usize], comp_of[tb as usize]);
324            if ca == cb {
325                continue;
326            }
327            let eff_a = da ^ flip[ta as usize];
328            let eff_b = db ^ flip[tb as usize];
329            let rel = eff_a == eff_b;
330            let (ra, pa) = find(&mut parent, &mut prel, ca);
331            let (rb, pb) = find(&mut parent, &mut prel, cb);
332            if ra != rb {
333                parent[ra as usize] = rb;
334                prel[ra as usize] = pa ^ pb ^ rel;
335            }
336        }
337    }
338    // crel[c] = gauge of fragment c relative to its shell root.
339    let mut crel = vec![false; ncomp];
340    let mut root_of = vec![0u32; ncomp];
341    for c in 0..ncomp as u32 {
342        let (r, p) = find(&mut parent, &mut prel, c);
343        crel[c as usize] = p;
344        root_of[c as usize] = r;
345    }
346    // Bake the shell-relative gauge into flip, so each shell is now consistently
347    // oriented (up to one global sign per shell).
348    for t in 0..tri_count {
349        if crel[comp_of[t] as usize] {
350            flip[t] = !flip[t];
351        }
352    }
353
354    // (e-orient) Match each coherent component to its SOURCE shell's material
355    // orientation. Exterior/disconnected material shells target positive
356    // volume; cavity shells target negative volume. A welded component may not
357    // mix the two roles.
358    let mut shell_vol: HashMap<u32, f64> = HashMap::default();
359    let mut shell_target: HashMap<u32, i8> = HashMap::default();
360    for t in 0..tri_count {
361        let v = tetra_signed_volume(&mesh.positions, &mesh.indices, t);
362        let v = if flip[t] { -v } else { v };
363        let root = root_of[comp_of[t] as usize];
364        *shell_vol.entry(root).or_insert(0.0) += v;
365        let face_id = mesh.face_ids.get(t).copied().ok_or_else(|| {
366            "orient_mesh_coherently: triangle is missing its source face id".to_string()
367        })? as usize;
368        let target = *face_shell_signs.get(face_id).ok_or_else(|| {
369            format!("orient_mesh_coherently: source face id {face_id} is out of range")
370        })?;
371        if let Some(previous) = shell_target.insert(root, target) {
372            if previous != target {
373                return Err(
374                    "orient_mesh_coherently: one welded component spans oppositely oriented shells"
375                        .into(),
376                );
377            }
378        }
379    }
380    if std::env::var("BREP_DEBUG_ORIENT_SHELLS").is_ok() {
381        let mut roots: std::collections::BTreeMap<u32, usize> = Default::default();
382        for t in 0..tri_count {
383            *roots.entry(root_of[comp_of[t] as usize]).or_insert(0) += 1;
384        }
385        let mut edge_use: std::collections::BTreeMap<usize, usize> = Default::default();
386        for uses in edge_tris.values() {
387            *edge_use.entry(uses.len()).or_insert(0) += 1;
388        }
389        eprintln!(
390            "[orient-shells] {ncomp} components -> {} shells | edge-use histogram {edge_use:?}",
391            roots.len()
392        );
393        for (r, n) in roots.iter().filter(|(_, n)| **n > 1) {
394            let v = shell_vol.get(r).copied().unwrap_or(0.0);
395            let tgt = shell_target.get(r).copied().unwrap_or(1);
396            eprintln!("  shell {r}: {n} tris, vol={v:.3e}, target={tgt}");
397        }
398    }
399    for t in 0..tri_count {
400        let r = root_of[comp_of[t] as usize];
401        let volume_sign = if shell_vol.get(&r).copied().unwrap_or(0.0) < 0.0 {
402            -1_i8
403        } else {
404            1_i8
405        };
406        if volume_sign != shell_target.get(&r).copied().unwrap_or(1) {
407            flip[t] = !flip[t];
408        }
409    }
410    // (f) Make stored normals continuous and material-outward, matching the
411    // FINAL winding (into the cavity for a void shell).
412    // Reference per raw vertex = sum of the post-flip geometric normals of the
413    // triangles touching it; flip any stored normal that opposes it. This turns
414    // the folded region's flipped analytic normals continuous (and outward).
415    let mut vnref = vec![[0.0f64; 3]; vcount];
416    for t in 0..tri_count {
417        let mut g = tri_geometric_normal(&mesh.positions, &mesh.indices, t);
418        if flip[t] {
419            g = [-g[0], -g[1], -g[2]];
420        }
421        for k in 0..3 {
422            let v = mesh.indices[3 * t + k] as usize;
423            vnref[v][0] += g[0];
424            vnref[v][1] += g[1];
425            vnref[v][2] += g[2];
426        }
427    }
428    for v in 0..vcount {
429        let r = vnref[v];
430        let dot = mesh.normals[3 * v] * r[0]
431            + mesh.normals[3 * v + 1] * r[1]
432            + mesh.normals[3 * v + 2] * r[2];
433        if dot < 0.0 {
434            mesh.normals[3 * v] = -mesh.normals[3 * v];
435            mesh.normals[3 * v + 1] = -mesh.normals[3 * v + 1];
436            mesh.normals[3 * v + 2] = -mesh.normals[3 * v + 2];
437        }
438    }
439
440    // (e) Apply the winding flips (swap 2nd & 3rd index of each flipped triangle).
441    for t in 0..tri_count {
442        if flip[t] {
443            mesh.indices.swap(3 * t + 1, 3 * t + 2);
444        }
445    }
446
447    if let Some(start) = _timer {
448        let nflip = flip.iter().filter(|&&f| f).count();
449        eprintln!(
450            "orient_mesh_coherently: {tri_count} tris, {vcount} verts, {ncomp} components, {nflip} flipped in {:?}",
451            start.elapsed()
452        );
453    }
454    Ok(())
455}
456
457// BREP private tests: aa6f62d2648159b4