Skip to main content

brep_kernel/brep/
soundness.rs

1//! # Structural soundness — the two checks `validate()` deliberately does NOT make
2//!
3//! [`BrepSolid::validate`](crate::BrepSolid::validate) is an
4//! **incidence** test: ids resolve, loops close, an edge is used twice with
5//! opposite senses, a pcurve tracks its edge, Euler closes. A great deal of
6//! code reads "validate passed" as exactly that claim, so this module does not
7//! widen it. What `validate()` never asks:
8//!
9//! * **Is the thing connected?** Nothing in the incidence test notices that a
10//!   shell's faces fall into two mutually unreachable pieces. Every local
11//!   incidence can be perfect while the "solid" is two solids in one record.
12//! * **Does it pass through itself?** `validate()`'s one string carrying the
13//!   words "self-intersects" is a *uv-wire* warning — one loop's sampled
14//!   pcurve polygon crossing itself in parameter space. It is not, and never
15//!   was, a face-versus-face test.
16//!
17//! Both live here as SEPARATE detectors, following the precedent set by
18//! `csg/oracle.rs`: an independent wrongness detector that a caller asks for,
19//! never a silent widening of an existing verdict. Nothing in the kernel's own
20//! build path calls either of them; what a CALLER does with the verdict — gate
21//! or record — is the table below, and it is the case gate that acts on it.
22//!
23//! ## Gate or diagnostic — the standing decision
24//!
25//! | check | cost | verdict |
26//! |---|---|---|
27//! | [`solid_connectivity`] — per-shell edge-connected components, cross-shell edges | pure topology, one pass over the coedges, no geometry | **hard gate.** A split shell is never acceptable and the test cannot be noisy: it reads ids, not numbers. [`ConnectivityReport::is_disconnected`] is the gate predicate. |
28//! | `ConnectivityReport::pinch_vertices` — vertices whose incident faces form more than one fan | same pass | **diagnostic.** A pinch is a real non-manifold defect, but pole/seam vertices are false-positive bait, so it is reported beside the gate rather than inside it. Vertices with an incident degenerate edge are skipped outright and counted in `pinch_skipped`. |
29//! | [`solid_self_intersections`] — face-versus-face crossing | tessellates the solid and walks a triangle BVH | **gate for NEW occurrences.** [`SelfIntersectionReport::is_flagged`] is the predicate. It shipped opt-in because it costs a full tessellation plus a BVH sweep; the decision was reversed once the first corpus run found a case labelled `correct` whose faces cross four orders of magnitude past the band, because a gate that fires only when someone remembers a flag is a diagnostic with extra steps. The case gate now runs it by default, reports a crossing its baseline already holds as a note, and fails a crossing on any other face pair. |
30//!
31//! ## How the self-intersection test avoids being noisy
32//!
33//! A mesh-only test flags every tangent neighbour: two faces meeting G1 along
34//! a shared edge chord-cross each other one triangle row in, and a fillet
35//! corpus is nothing but tangent neighbours. Two rules keep this one quiet:
36//!
37//! 1. **Welded-vertex skip.** The watertight tessellator pins shared-edge
38//!    samples to bit-identical positions, so triangles that meet along a
39//!    shared edge share a welded vertex and are never compared.
40//! 2. **Exact-surface confirmation.** A mesh crossing is only a *candidate*.
41//!    It is confirmed by projecting each triangle's corners onto the OTHER
42//!    face's carrier surface and requiring the signed distances to straddle
43//!    zero by more than [`SelfIntersectionOptions::straddle_band`]. Every
44//!    tessellation vertex lies exactly on its own surface, so chord error
45//!    cannot manufacture a straddle: a tangent neighbour's corners all sit on
46//!    one side of the other carrier, while a genuine piercing has corners on
47//!    both. The confirmation runs only on BVH+tri-tri survivors, so it is
48//!    paid a handful of times per solid, not per triangle.
49//!
50//! ## Known blind spot
51//!
52//! Only DISTINCT face pairs are confirmed. When both triangles lie on one
53//! face, the straddle test is degenerate by construction — every corner of
54//! both triangles is on that carrier at distance zero — so a single face
55//! folding through itself is counted in
56//! [`SelfIntersectionReport::same_face_candidates`] and reported, never
57//! confirmed. The parameter-space side of that defect is what `validate()`'s
58//! uv-wire warning already looks at.
59
60use crate::projection::project_point_to_surface;
61use crate::spatial::{Aabb, Bvh};
62use crate::topology::BrepSolid;
63use crate::{NurbsSurface, Vec3};
64use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
65use serde::Serialize;
66
67// ---------------------------------------------------------------------------
68// Disjoint set — shared by the shell and the vertex-fan scans
69// ---------------------------------------------------------------------------
70
71struct DisjointSet {
72    parent: Vec<usize>,
73}
74
75impl DisjointSet {
76    fn new(count: usize) -> Self {
77        Self {
78            parent: (0..count).collect(),
79        }
80    }
81
82    fn find(&mut self, mut index: usize) -> usize {
83        while self.parent[index] != index {
84            self.parent[index] = self.parent[self.parent[index]];
85            index = self.parent[index];
86        }
87        index
88    }
89
90    fn union(&mut self, first: usize, second: usize) {
91        let (a, b) = (self.find(first), self.find(second));
92        if a != b {
93            self.parent[b] = a;
94        }
95    }
96}
97
98// ---------------------------------------------------------------------------
99// Connectivity
100// ---------------------------------------------------------------------------
101
102/// One shell's connectivity: how many edge-connected pieces its faces form.
103#[derive(Clone, Debug, Serialize)]
104pub struct ShellConnectivity {
105    pub shell_id: u64,
106    pub faces: usize,
107    /// Edge-connected components of this shell's faces. Anything but 1 means
108    /// the record holds several surfaces under one shell.
109    pub components: usize,
110    /// Face ids of every component EXCEPT the largest, smallest first — the
111    /// pieces that would each have had to be their own shell. Empty when
112    /// `components == 1`.
113    pub detached: Vec<Vec<u64>>,
114}
115
116/// Outcome of [`solid_connectivity`].
117#[derive(Clone, Debug, Serialize)]
118pub struct ConnectivityReport {
119    pub shells: Vec<ShellConnectivity>,
120    /// Edges used by faces belonging to two DIFFERENT shells. A shell is a
121    /// closed surface in its own right; sharing an edge across shells means
122    /// the shell split is fiction.
123    pub cross_shell_edges: Vec<u64>,
124    /// Vertices where the incident face corners form more than one fan — two
125    /// pieces of surface touching at a point and nowhere else. Edge
126    /// connectivity cannot see this: both pieces are edge-connected, just not
127    /// through this vertex. Diagnostic, not part of [`Self::is_disconnected`].
128    pub pinch_vertices: Vec<u64>,
129    /// Vertices the fan scan skipped because a degenerate (pole) edge meets
130    /// there — a collapsed parameter boundary is not an ordinary fan and the
131    /// scan would read it as a pinch.
132    pub pinch_skipped: usize,
133}
134
135impl ConnectivityReport {
136    /// The HARD-GATE predicate: the record claims to be one closed surface per
137    /// shell and it is not. Pinch vertices are deliberately excluded — see the
138    /// module table.
139    pub fn is_disconnected(&self) -> bool {
140        self.shells.iter().any(|shell| shell.components > 1)
141            || !self.cross_shell_edges.is_empty()
142    }
143
144    /// One line naming what is wrong, or `None` when the gate predicate is
145    /// clear. Pinch vertices are appended when present so a caller printing
146    /// the summary still sees them.
147    pub fn summary(&self) -> Option<String> {
148        let mut parts = Vec::new();
149        for shell in &self.shells {
150            if shell.components > 1 {
151                let sizes: Vec<usize> = shell.detached.iter().map(Vec::len).collect();
152                parts.push(format!(
153                    "shell {} splits into {} edge-connected components ({} face(s), detached sizes {sizes:?}, first detached faces {:?})",
154                    shell.shell_id,
155                    shell.components,
156                    shell.faces,
157                    shell.detached.first().map(Vec::as_slice).unwrap_or(&[]),
158                ));
159            }
160        }
161        if !self.cross_shell_edges.is_empty() {
162            parts.push(format!(
163                "{} edge(s) shared across shells ({:?})",
164                self.cross_shell_edges.len(),
165                &self.cross_shell_edges[..self.cross_shell_edges.len().min(8)]
166            ));
167        }
168        if !self.pinch_vertices.is_empty() {
169            parts.push(format!(
170                "{} pinch vertex/vertices ({:?})",
171                self.pinch_vertices.len(),
172                &self.pinch_vertices[..self.pinch_vertices.len().min(8)]
173            ));
174        }
175        if parts.is_empty() {
176            None
177        } else {
178            Some(parts.join("; "))
179        }
180    }
181}
182
183/// Connectivity of a solid's topology: are each shell's faces reachable from
184/// one another across shared edges, do any two shells share an edge, and does
185/// any vertex pinch two otherwise-separate fans together.
186///
187/// Pure topology — ids and use counts, never a coordinate — so it is
188/// tolerance-free and cannot be noisy. Degenerate (pole) edges DO count as
189/// connections: a collapsed boundary shared by two faces genuinely joins them.
190pub fn solid_connectivity(solid: &BrepSolid) -> ConnectivityReport {
191    // Global face indexing over (shell, face) in record order.
192    let mut face_shell: Vec<usize> = Vec::new();
193    let mut face_ids: Vec<u64> = Vec::new();
194    for (shell_index, shell) in solid.shells.iter().enumerate() {
195        for face in &shell.faces {
196            face_shell.push(shell_index);
197            face_ids.push(face.id);
198        }
199    }
200
201    // edge id -> the global face indices that use it.
202    let mut edge_faces: HashMap<u64, Vec<usize>> = HashMap::default();
203    let mut global_index = 0usize;
204    for shell in &solid.shells {
205        for face in &shell.faces {
206            let _ = face;
207            for loop_record in &face.loops {
208                for coedge in &loop_record.coedges {
209                    let users = edge_faces.entry(coedge.edge_id).or_default();
210                    if users.last() != Some(&global_index) {
211                        users.push(global_index);
212                    }
213                }
214            }
215            global_index += 1;
216        }
217    }
218
219    let mut sets = DisjointSet::new(face_ids.len());
220    let mut cross_shell_edges = Vec::new();
221    for (edge_id, users) in &edge_faces {
222        for pair in users.windows(2) {
223            sets.union(pair[0], pair[1]);
224        }
225        let shells_touched: HashSet<usize> =
226            users.iter().map(|index| face_shell[*index]).collect();
227        if shells_touched.len() > 1 {
228            cross_shell_edges.push(*edge_id);
229        }
230    }
231    cross_shell_edges.sort_unstable();
232
233    let mut shells = Vec::with_capacity(solid.shells.len());
234    let mut base = 0usize;
235    for shell in &solid.shells {
236        let count = shell.faces.len();
237        let mut buckets: HashMap<usize, Vec<u64>> = HashMap::default();
238        for offset in 0..count {
239            let root = sets.find(base + offset);
240            buckets.entry(root).or_default().push(face_ids[base + offset]);
241        }
242        let mut groups: Vec<Vec<u64>> = buckets.into_values().collect();
243        // Largest first so `detached` is "everything but the main piece", and
244        // deterministic: ties break on the smallest face id.
245        groups.sort_by(|a, b| b.len().cmp(&a.len()).then(a.first().cmp(&b.first())));
246        let components = groups.len();
247        let mut detached: Vec<Vec<u64>> = groups.into_iter().skip(1).collect();
248        detached.sort_by_key(Vec::len);
249        for group in &mut detached {
250            group.sort_unstable();
251        }
252        shells.push(ShellConnectivity {
253            shell_id: shell.id,
254            faces: count,
255            components,
256            detached,
257        });
258        base += count;
259    }
260
261    let (pinch_vertices, pinch_skipped) = pinch_vertices(solid);
262
263    ConnectivityReport {
264        shells,
265        cross_shell_edges,
266        pinch_vertices,
267        pinch_skipped,
268    }
269}
270
271/// Vertices whose incident face corners form more than one fan.
272///
273/// A *corner* is one consecutive coedge pair of a loop meeting at the vertex.
274/// Each non-degenerate edge at the vertex is used by exactly two coedges, each
275/// belonging to exactly one corner there, so the edge links those two corners.
276/// One fan = one component. Two cones touching at their apex are edge-
277/// connected everywhere else and split into two fans exactly here.
278///
279/// Any vertex with an incident DEGENERATE edge is skipped: a collapsed pole
280/// boundary is not an ordinary fan and would read as a pinch.
281fn pinch_vertices(solid: &BrepSolid) -> (Vec<u64>, usize) {
282    let mut degenerate_at: HashSet<u64> = HashSet::default();
283    for edge in &solid.edges {
284        if edge.degenerate {
285            degenerate_at.insert(edge.start_vertex_id);
286            degenerate_at.insert(edge.end_vertex_id);
287        }
288    }
289    let edges: HashMap<u64, &crate::topology::EdgeRecord> =
290        solid.edges.iter().map(|edge| (edge.id, edge)).collect();
291
292    // vertex id -> corners; a corner is the pair of edge ids meeting there.
293    let mut corners: HashMap<u64, Vec<[u64; 2]>> = HashMap::default();
294    for shell in &solid.shells {
295        for face in &shell.faces {
296            for loop_record in &face.loops {
297                let count = loop_record.coedges.len();
298                if count == 0 {
299                    continue;
300                }
301                for index in 0..count {
302                    let current = &loop_record.coedges[index];
303                    let next = &loop_record.coedges[(index + 1) % count];
304                    let Some(current_edge) = edges.get(&current.edge_id) else {
305                        continue;
306                    };
307                    let vertex = if current.forward {
308                        current_edge.end_vertex_id
309                    } else {
310                        current_edge.start_vertex_id
311                    };
312                    corners
313                        .entry(vertex)
314                        .or_default()
315                        .push([current.edge_id, next.edge_id]);
316                }
317            }
318        }
319    }
320
321    let mut pinched = Vec::new();
322    let mut skipped = 0usize;
323    for (vertex, list) in &corners {
324        if degenerate_at.contains(vertex) {
325            skipped += 1;
326            continue;
327        }
328        if list.len() < 2 {
329            continue;
330        }
331        let mut sets = DisjointSet::new(list.len());
332        let mut by_edge: HashMap<u64, usize> = HashMap::default();
333        for (index, corner) in list.iter().enumerate() {
334            for edge_id in corner {
335                match by_edge.entry(*edge_id) {
336                    std::collections::hash_map::Entry::Occupied(slot) => {
337                        sets.union(*slot.get(), index);
338                    }
339                    std::collections::hash_map::Entry::Vacant(slot) => {
340                        slot.insert(index);
341                    }
342                }
343            }
344        }
345        let fans: HashSet<usize> = (0..list.len()).map(|index| sets.find(index)).collect();
346        if fans.len() > 1 {
347            pinched.push(*vertex);
348        }
349    }
350    pinched.sort_unstable();
351    (pinched, skipped)
352}
353
354// ---------------------------------------------------------------------------
355// Face-versus-face self-intersection
356// ---------------------------------------------------------------------------
357
358/// Fraction of the solid's bounding diagonal a confirmed crossing must
359/// straddle the other carrier by. Tessellation vertices are exact on their own
360/// surface, so this band only has to clear projection/Newton residue, not
361/// chord error; it is deliberately far below any real penetration.
362const STRADDLE_FRACTION: f64 = 1e-6;
363
364/// Fraction of the diagonal a mesh crossing segment must be longer than. A
365/// crossing shorter than this is a corner graze, not a penetration.
366const CROSSING_FRACTION: f64 = 1e-7;
367
368/// Fraction of the diagonal used to weld coincident tessellation vertices.
369/// Shared-edge samples are pinned to identical values by the watertight
370/// tessellator, so this only has to survive being read back out of the mesh
371/// buffers.
372const WELD_FRACTION: f64 = 1e-9;
373
374/// Knobs for [`solid_self_intersections`]. [`SelfIntersectionOptions::for_solid`]
375/// derives every one from the solid's own size.
376#[derive(Clone, Copy, Debug, Serialize)]
377pub struct SelfIntersectionOptions {
378    /// Tessellation density. The detector resolves nothing finer than this.
379    pub chord_tolerance: f64,
380    /// Signed-distance margin a confirmed crossing must straddle the other
381    /// carrier surface by, on BOTH sides.
382    pub straddle_band: f64,
383    /// Shortest mesh crossing segment that counts as a candidate.
384    pub min_crossing_length: f64,
385    /// Coincidence radius for welding tessellation vertices.
386    pub weld: f64,
387    /// Refuse to scan a mesh larger than this (the report comes back
388    /// `truncated`), so a pathological solid cannot hang a gate.
389    pub max_triangles: usize,
390}
391
392impl SelfIntersectionOptions {
393    /// Every band derived from the solid's own extent: the display chord
394    /// tolerance the app meshes at, and diagonal-relative bands.
395    pub fn for_solid(solid: &BrepSolid) -> Self {
396        let diagonal = solid_diagonal(solid).max(1e-9);
397        Self {
398            chord_tolerance: crate::display_chord_tolerance(solid, 1.0),
399            straddle_band: diagonal * STRADDLE_FRACTION,
400            min_crossing_length: diagonal * CROSSING_FRACTION,
401            weld: diagonal * WELD_FRACTION,
402            max_triangles: 400_000,
403        }
404    }
405}
406
407/// One confirmed face-versus-face crossing.
408#[derive(Clone, Debug, Serialize)]
409pub struct FaceCrossing {
410    pub face_a: u64,
411    pub face_b: u64,
412    pub name_a: Option<String>,
413    pub name_b: Option<String>,
414    /// Midpoint of the mesh crossing segment.
415    pub point: Vec3,
416    /// Length of the mesh crossing segment.
417    pub crossing_length: f64,
418    /// How far the confirmation straddled the other carrier: the smaller of
419    /// the two one-sided margins, over both directions of the pair.
420    pub straddle: f64,
421}
422
423/// Outcome of [`solid_self_intersections`].
424#[derive(Clone, Debug, Serialize)]
425pub struct SelfIntersectionReport {
426    pub triangles: usize,
427    /// Triangle pairs whose boxes overlapped and that survived the welded-
428    /// vertex skip — the work the exact confirmation was offered.
429    pub candidate_pairs: usize,
430    /// Mesh crossings between DISTINCT faces that the exact-surface straddle
431    /// test confirmed, one entry per face pair (deepest kept).
432    pub confirmed: Vec<FaceCrossing>,
433    /// Mesh crossings between distinct faces the straddle test REJECTED —
434    /// tangent neighbours and chord error. A large number here beside zero
435    /// confirmations is the detector working, not failing.
436    pub rejected: usize,
437    /// Mesh crossings whose confirmation could not be evaluated (projection or
438    /// normal failed). Never counted as either verdict.
439    pub undecided: usize,
440    /// Mesh crossings between two triangles of ONE face — the documented blind
441    /// spot; reported, never confirmed.
442    pub same_face_candidates: usize,
443    /// The mesh exceeded `max_triangles` and was not scanned.
444    pub truncated: bool,
445    pub options: SelfIntersectionOptions,
446}
447
448impl SelfIntersectionReport {
449    /// The predicate for a caller that has opted into this check: the solid
450    /// passes through itself.
451    pub fn is_flagged(&self) -> bool {
452        !self.confirmed.is_empty()
453    }
454
455    /// One line naming the worst crossing, or `None` when nothing was
456    /// confirmed.
457    pub fn summary(&self) -> Option<String> {
458        let worst = self
459            .confirmed
460            .iter()
461            .max_by(|a, b| a.straddle.total_cmp(&b.straddle))?;
462        Some(format!(
463            "{} face pair(s) self-intersect; worst faces {} and {} near ({:.4}, {:.4}, {:.4}) (crossing {:.3e}, straddle {:.3e})",
464            self.confirmed.len(),
465            worst.face_a,
466            worst.face_b,
467            worst.point.x,
468            worst.point.y,
469            worst.point.z,
470            worst.crossing_length,
471            worst.straddle
472        ))
473    }
474}
475
476fn solid_diagonal(solid: &BrepSolid) -> f64 {
477    let mut bounds = Aabb::empty();
478    for vertex in &solid.vertices {
479        bounds.include_point(vertex.point);
480    }
481    let diagonal = bounds.diagonal();
482    if diagonal.is_finite() && diagonal > 0.0 {
483        return diagonal;
484    }
485    // Vertex-free solids (a full sphere, a full torus) fall back to the
486    // control hull, exactly as the display tolerance does.
487    let mut hull = Aabb::empty();
488    for shell in &solid.shells {
489        for face in &shell.faces {
490            if let Ok(box_of) = Aabb::from_surface_controls(&face.surface) {
491                hull.include(box_of);
492            }
493        }
494    }
495    let diagonal = hull.diagonal();
496    if diagonal.is_finite() {
497        diagonal
498    } else {
499        0.0
500    }
501}
502
503/// Does this solid pass through itself? Tessellates every face once, walks a
504/// triangle BVH for mesh crossings, and confirms each candidate against the
505/// two carrier SURFACES so tangent neighbours and chord error cannot
506/// manufacture a finding. See the module docs for the design and its one
507/// documented blind spot.
508///
509/// Nothing in the kernel's build path calls this — a caller asks for it and
510/// reads [`SelfIntersectionReport::is_flagged`]. The case gate is that caller
511/// and treats a flag as a failure unless its baseline already holds the same
512/// face pair; see the module table.
513pub fn solid_self_intersections(
514    solid: &BrepSolid,
515    options: SelfIntersectionOptions,
516) -> Result<SelfIntersectionReport, String> {
517    let mut report = SelfIntersectionReport {
518        triangles: 0,
519        candidate_pairs: 0,
520        confirmed: Vec::new(),
521        rejected: 0,
522        undecided: 0,
523        same_face_candidates: 0,
524        truncated: false,
525        options,
526    };
527    if !(options.chord_tolerance > 0.0) || !options.chord_tolerance.is_finite() {
528        return Err("solid_self_intersections: chord tolerance must be positive".into());
529    }
530
531    // The face-stride entry point rather than `tessellate_brep_watertight`:
532    // the latter runs a coherent-orientation pass and a mesh validation that
533    // can fail on exactly the broken solids this detector exists to inspect,
534    // and winding is irrelevant to a crossing test.
535    let mesh = crate::watertight_tessellation::tessellate_brep_watertight_face_stride(
536        solid,
537        options.chord_tolerance,
538        1,
539        0,
540    )?;
541    let triangle_count = mesh.indices.len() / 3;
542    report.triangles = triangle_count;
543    if triangle_count == 0 {
544        return Ok(report);
545    }
546    if triangle_count > options.max_triangles {
547        report.truncated = true;
548        return Ok(report);
549    }
550
551    // Faces in the tessellator's sequential order, so a triangle's `face_ids`
552    // entry indexes straight into this.
553    let faces: Vec<&crate::topology::FaceRecord> = solid
554        .shells
555        .iter()
556        .flat_map(|shell| shell.faces.iter())
557        .collect();
558
559    let point_of = |index: u32| -> Vec3 {
560        let base = index as usize * 3;
561        Vec3::new(
562            mesh.positions[base],
563            mesh.positions[base + 1],
564            mesh.positions[base + 2],
565        )
566    };
567
568    let welded = weld_positions(&mesh.positions, options.weld);
569
570    let mut boxes = Vec::with_capacity(triangle_count);
571    let mut corners: Vec<[Vec3; 3]> = Vec::with_capacity(triangle_count);
572    for triangle in 0..triangle_count {
573        let indices = [
574            mesh.indices[triangle * 3],
575            mesh.indices[triangle * 3 + 1],
576            mesh.indices[triangle * 3 + 2],
577        ];
578        let points = [
579            point_of(indices[0]),
580            point_of(indices[1]),
581            point_of(indices[2]),
582        ];
583        boxes.push(Aabb::from_points(points));
584        corners.push(points);
585    }
586    let bvh = Bvh::build(&boxes);
587
588    // Deepest confirmed crossing per unordered face pair.
589    let mut best: HashMap<(u64, u64), FaceCrossing> = HashMap::default();
590    let mut hits = Vec::new();
591    for first in 0..triangle_count {
592        hits.clear();
593        bvh.overlapping(boxes[first], 0.0, &mut hits);
594        for second in hits.iter().copied() {
595            if second <= first {
596                continue;
597            }
598            let shares_vertex = (0..3).any(|a| {
599                let wa = welded[mesh.indices[first * 3 + a] as usize];
600                (0..3).any(|b| wa == welded[mesh.indices[second * 3 + b] as usize])
601            });
602            if shares_vertex {
603                continue;
604            }
605            report.candidate_pairs += 1;
606            let Some((start, end)) = triangle_crossing(&corners[first], &corners[second]) else {
607                continue;
608            };
609            let length = end.sub(start).length();
610            if length <= options.min_crossing_length {
611                continue;
612            }
613            let face_first = mesh.face_ids[first] as usize;
614            let face_second = mesh.face_ids[second] as usize;
615            if face_first == face_second {
616                report.same_face_candidates += 1;
617                continue;
618            }
619            let (Some(face_a), Some(face_b)) = (faces.get(face_first), faces.get(face_second))
620            else {
621                report.undecided += 1;
622                continue;
623            };
624            let forward = straddle(&face_b.surface, &corners[first], options.straddle_band);
625            let backward = straddle(&face_a.surface, &corners[second], options.straddle_band);
626            let (Some(forward), Some(backward)) = (forward, backward) else {
627                report.undecided += 1;
628                continue;
629            };
630            if forward <= options.straddle_band || backward <= options.straddle_band {
631                report.rejected += 1;
632                continue;
633            }
634            let key = if face_a.id <= face_b.id {
635                (face_a.id, face_b.id)
636            } else {
637                (face_b.id, face_a.id)
638            };
639            let crossing = FaceCrossing {
640                face_a: key.0,
641                face_b: key.1,
642                name_a: if face_a.id <= face_b.id {
643                    face_a.name.clone()
644                } else {
645                    face_b.name.clone()
646                },
647                name_b: if face_a.id <= face_b.id {
648                    face_b.name.clone()
649                } else {
650                    face_a.name.clone()
651                },
652                point: start.add(end).scale(0.5),
653                crossing_length: length,
654                straddle: forward.min(backward),
655            };
656            let slot = best.entry(key).or_insert_with(|| crossing.clone());
657            if crossing.straddle > slot.straddle {
658                *slot = crossing;
659            }
660        }
661    }
662
663    report.confirmed = best.into_values().collect();
664    report
665        .confirmed
666        .sort_by(|a, b| b.straddle.total_cmp(&a.straddle).then(a.face_a.cmp(&b.face_a)));
667    Ok(report)
668}
669
670/// Map every mesh vertex to a representative index, welding positions that
671/// coincide within `weld`. Bucketed on a `weld`-sized lattice with the 27
672/// neighbouring cells consulted, so a pair straddling a cell boundary still
673/// welds.
674fn weld_positions(positions: &[f64], weld: f64) -> Vec<u32> {
675    let count = positions.len() / 3;
676    let mut representative = vec![0u32; count];
677    let cell = weld.max(f64::MIN_POSITIVE);
678    let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::default();
679    for index in 0..count {
680        let base = index * 3;
681        let point = Vec3::new(positions[base], positions[base + 1], positions[base + 2]);
682        let key = (
683            (point.x / cell).floor() as i64,
684            (point.y / cell).floor() as i64,
685            (point.z / cell).floor() as i64,
686        );
687        let mut found = None;
688        'search: for dx in -1..=1 {
689            for dy in -1..=1 {
690                for dz in -1..=1 {
691                    let Some(list) = buckets.get(&(key.0 + dx, key.1 + dy, key.2 + dz)) else {
692                        continue;
693                    };
694                    for candidate in list {
695                        let base = *candidate as usize * 3;
696                        let other = Vec3::new(
697                            positions[base],
698                            positions[base + 1],
699                            positions[base + 2],
700                        );
701                        if other.sub(point).length() <= weld {
702                            found = Some(representative[*candidate as usize]);
703                            break 'search;
704                        }
705                    }
706                }
707            }
708        }
709        let value = found.unwrap_or(index as u32);
710        representative[index] = value;
711        buckets.entry(key).or_default().push(index as u32);
712    }
713    representative
714}
715
716/// Signed distances of a triangle's corners to `surface`, reduced to the
717/// smaller of the two one-sided margins: positive means the corners genuinely
718/// sit on both sides of the carrier by at least that much, zero means they do
719/// not straddle it at all. `None` when a projection or a normal could not be
720/// evaluated — never a verdict.
721///
722/// Three projections, and only for a triangle pair that already survived the
723/// BVH, the welded-vertex skip and the mesh crossing test.
724fn straddle(surface: &NurbsSurface, triangle: &[Vec3; 3], band: f64) -> Option<f64> {
725    let mut above = 0.0f64;
726    let mut below = 0.0f64;
727    for corner in triangle {
728        let projection = project_point_to_surface(surface, *corner).ok()?;
729        if projection.distance <= band {
730            continue;
731        }
732        let normal = surface.normal(projection.u, projection.v).ok()?;
733        let normal = normal.normalized().ok()?;
734        let signed = corner.sub(projection.point).dot(normal);
735        if signed > 0.0 {
736            above = above.max(signed);
737        } else {
738            below = below.max(-signed);
739        }
740    }
741    Some(above.min(below))
742}
743
744/// The segment along which two triangles cross, or `None` when they do not
745/// cross transversally. Coplanar pairs return `None` — a coplanar overlap is a
746/// different defect and this detector does not claim it.
747fn triangle_crossing(first: &[Vec3; 3], second: &[Vec3; 3]) -> Option<(Vec3, Vec3)> {
748    let plane_first = triangle_plane(first)?;
749    let plane_second = triangle_plane(second)?;
750    let direction = plane_first.0.cross(plane_second.0);
751    let length = direction.length();
752    if length <= 1e-12 {
753        return None; // parallel or coplanar
754    }
755    let direction = direction.scale(1.0 / length);
756
757    let first_span = plane_span(first, plane_second, direction)?;
758    let second_span = plane_span(second, plane_first, direction)?;
759    let low = first_span.0.max(second_span.0);
760    let high = first_span.1.min(second_span.1);
761    if high < low {
762        return None;
763    }
764    // Rebuild 3D points by interpolating the first triangle's own crossing
765    // chord, so the returned segment is on the mesh rather than reconstructed
766    // from a line equation.
767    let span = first_span.1 - first_span.0;
768    let lerp = |value: f64| {
769        if span <= 0.0 {
770            first_span.2
771        } else {
772            let fraction = ((value - first_span.0) / span).clamp(0.0, 1.0);
773            first_span
774                .2
775                .add(first_span.3.sub(first_span.2).scale(fraction))
776        }
777    };
778    Some((lerp(low), lerp(high)))
779}
780
781/// Unit normal and plane offset of a triangle, `None` for a degenerate one.
782fn triangle_plane(triangle: &[Vec3; 3]) -> Option<(Vec3, f64)> {
783    let normal = triangle[1]
784        .sub(triangle[0])
785        .cross(triangle[2].sub(triangle[0]));
786    let length = normal.length();
787    if length <= 1e-18 {
788        return None;
789    }
790    let normal = normal.scale(1.0 / length);
791    Some((normal, -normal.dot(triangle[0])))
792}
793
794/// Where `triangle` crosses `plane`, as `(low, high, low_point, high_point)`
795/// parametrized by `point · direction`. `None` when the triangle does not
796/// reach the plane.
797fn plane_span(
798    triangle: &[Vec3; 3],
799    plane: (Vec3, f64),
800    direction: Vec3,
801) -> Option<(f64, f64, Vec3, Vec3)> {
802    let distance = |point: Vec3| plane.0.dot(point) + plane.1;
803    let distances = [
804        distance(triangle[0]),
805        distance(triangle[1]),
806        distance(triangle[2]),
807    ];
808    if distances.iter().all(|value| *value > 0.0) || distances.iter().all(|value| *value < 0.0) {
809        return None;
810    }
811    let mut points: Vec<Vec3> = Vec::with_capacity(3);
812    for index in 0..3 {
813        let next = (index + 1) % 3;
814        if distances[index] == 0.0 {
815            points.push(triangle[index]);
816        }
817        if (distances[index] < 0.0) != (distances[next] < 0.0)
818            && distances[index] != 0.0
819            && distances[next] != 0.0
820        {
821            let fraction = distances[index] / (distances[index] - distances[next]);
822            points.push(
823                triangle[index].add(triangle[next].sub(triangle[index]).scale(fraction)),
824            );
825        }
826    }
827    if points.len() < 2 {
828        return None;
829    }
830    let mut low = (f64::INFINITY, Vec3::default());
831    let mut high = (f64::NEG_INFINITY, Vec3::default());
832    for point in points {
833        let value = point.dot(direction);
834        if value < low.0 {
835            low = (value, point);
836        }
837        if value > high.0 {
838            high = (value, point);
839        }
840    }
841    Some((low.0, high.0, low.1, high.1))
842}
843
844// BREP private tests: ba1a0a116d86088e