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(¤t.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