brep_kernel/brep/soundness.rs
1//! # Structural soundness — the 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//! * **Does a single face's own BOUNDARY pass through itself?** That is what
17//! the uv-wire warning above is about — and `validate()` does not return it.
18//! It goes to [`ValidationReport::wire_warnings`](crate::ValidationReport),
19//! and `validate()` hands back `issues`, so every caller in the kernel that
20//! reads `validate().is_empty()` has never seen that verdict. It was also
21//! measured blind to the symmetric case (see [`loop_self_crossings`]).
22//!
23//! Both live here as SEPARATE detectors, following the precedent set by
24//! `csg/oracle.rs`: an independent wrongness detector that a caller asks for,
25//! never a silent widening of an existing verdict. Nothing in the kernel's own
26//! build path calls either of them; what a CALLER does with the verdict — gate
27//! or record — is the table below, and it is the case gate that acts on it.
28//!
29//! ## Gate or diagnostic — the standing decision
30//!
31//! | check | cost | verdict |
32//! |---|---|---|
33//! | [`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. |
34//! | `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`. |
35//! | [`loop_self_crossings`] — one face loop crossing ITSELF | samples each trim at the density the containment scan uses and sweeps the chords; no tessellation, no surface evaluation until a hit | **hard gate.** [`LoopCrossingReport::is_flagged`] is the predicate. A bowtie is not a tolerance question: a face's boundary either passes through itself in its own domain or it does not, and no valid face has one. The whole case population reads zero, the blend acceptance refuses a result that does not, and the case gate diffs the count per solid. |
36//! | [`face_self_intersections`] — ONE face folded through itself in 3D | the SAME tessellation and BVH sweep as the row below — it is that scan's same-face half, confirmed rather than counted | **hard gate.** A face whose surface carries two distinct trimmed parameter feet to one 3D point is wrong however its volume reads, and no valid face has one. Added 2026-09-13; the case gate diffs the count per solid, and the offset-shell, thicken, face-offset, sweep, loft and boolean acceptances resolve or refuse a result that has one. |
37//! | [`shell_vector_areas`] — a CLOSED shell's TRIMS enclosing the zero vector area they owe | one pass over the trims and the edge curves, at the quadrature the mass integrals use; no tessellation, no projection, no surface-surface work | **gate for a NEW crossing of the bar,** and a measurement otherwise. `Σ_faces ∫ n dA = 0` is exact on a closed shell — every edge is walked twice, once each way — so the residual is the sum of the trims' departures from the edges they claim, which is the accuracy floor under every mass property and which nothing in the kernel read before 2026-09-16. The bar is derived, not chosen: `PCURVE_REFINEMENT_TOLERANCE` times the shell's edge length. The case gate records the residual per solid and fails a solid that goes over its bar when its baseline was inside it. |
38//! | [`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. |
39//!
40//! ## How the self-intersection test avoids being noisy
41//!
42//! A mesh-only test flags every tangent neighbour: two faces meeting G1 along
43//! a shared edge chord-cross each other one triangle row in, and a fillet
44//! corpus is nothing but tangent neighbours. Two rules keep this one quiet:
45//!
46//! 1. **Welded-vertex skip.** The watertight tessellator pins shared-edge
47//! samples to bit-identical positions, so triangles that meet along a
48//! shared edge share a welded vertex and are never compared.
49//! 2. **Exact-surface confirmation.** A mesh crossing is only a *candidate*.
50//! It is confirmed by projecting each triangle's corners onto the OTHER
51//! face's carrier surface and requiring the signed distances to straddle
52//! zero by more than [`SelfIntersectionOptions::straddle_band`]. Every
53//! tessellation vertex lies exactly on its own surface, so chord error
54//! cannot manufacture a straddle: a tangent neighbour's corners all sit on
55//! one side of the other carrier, while a genuine piercing has corners on
56//! both. The confirmation runs only on BVH+tri-tri survivors, so it is
57//! paid a handful of times per solid, not per triangle.
58//!
59//! ## The same-face case, and why it is a different question
60//!
61//! Only DISTINCT face pairs can be confirmed that way. When both triangles lie
62//! on ONE face the straddle test is degenerate by construction — every corner
63//! of both triangles is on that carrier at distance zero — so for a long time
64//! a single face folding through itself was only COUNTED, in
65//! [`SelfIntersectionReport::same_face_candidates`].
66//!
67//! [`face_self_intersections`] closes that, by asking the question the fold
68//! actually is, in parameter space: the surface carries two DISTINCT trimmed
69//! feet to ONE point in space. Each triangle is seeded from its own three
70//! corners, and then every branch of the surface that comes near that point is
71//! ENUMERATED from a sample grid over the face's own domain and refined
72//! through the branch-preserving Newton `project_point_to_surface_seeded` —
73//! the same instrument the pcurve tracer uses to stay on one branch of a
74//! surface that folds back. A global projection cannot do this job and is not
75//! asked to: measured on the ribbon fixture, `project_point_to_surface` put a
76//! point lying EXACTLY on the surface 2.766 away from its own foot. A crossing
77//! is confirmed only when two DISTINCT feet reach the point, both inside the
78//! trim, with non-parallel normals. The last two rules are what keep it quiet:
79//! chord error on a tight curve leaves ONE foot however many seeds are
80//! refined, and a degenerate parameterization (a sphere's pole, a seam)
81//! carries distinct uv to one point with one tangent plane. Two triangles of
82//! one face that merely TOUCH — a
83//! carved offset image pinching to the apex of a cone, a torus sheet meeting
84//! its own axis point — never reach the confirmation at all: they either share
85//! a welded vertex or their crossing segment has zero length.
86//!
87//! What it does NOT ask: a face that lies ON itself over an area (a coplanar
88//! double cover) rather than crossing, since a coplanar triangle pair returns
89//! no crossing segment; and a fold whose whole crossing region is finer than
90//! the tessellation's chord tolerance, which this — like every check keyed to
91//! a mesh — resolves nothing below.
92//!
93//! ## The one that is not a shape question at all
94//!
95//! Every check above asks about a PLACE — a face, a pair of faces, a loop.
96//! [`shell_vector_areas`] asks about a sum: a closed shell's faces enclose
97//! zero vector area, whatever shape they are, so any residual is error and
98//! the only question is whose. It is the check that notices a trim lying off
99//! the edge it claims while every other verdict here is silent — the
100//! incidence is perfect, the topology is untouched, no face crosses anything,
101//! and the volume is an integral over exactly those trims, so it moves
102//! without anything to compare it against. Three such defects on one user
103//! solid (the notched cap, 2026-09-16) sat between 5e-7 and 1.9e-5 while
104//! `validate()`'s own pcurve-versus-edge bar was 4e-3, and the solid's volume
105//! matched its closed form because two of them cancelled.
106//!
107//! [`loop_self_crossings`] is that blind spot covered, in parameter space
108//! where the question is exact. It was added on 2026-09-13 after the
109//! rib-spine partition composed a solid whose two rib side faces each had a
110//! loop crossing itself twice and EVERY check above passed it: `validate()`
111//! silent, one connected component, no pinch, χ = 2, and the crossing scan 0
112//! confirmed / 0 rejected / 0 undecided / **0 same-face candidates** over 3900
113//! triangles — because both inverted lobes lie in the face's own plane, so
114//! there is no surface crossing to find at all. The face's AREA was the only
115//! other tell.
116//!
117//! What that detector in turn does not ask: whether two DIFFERENT loops of one
118//! face cross each other (a hole loop escaping its outer loop), and whether a
119//! loop runs back along itself without crossing (collinear overlap — which is
120//! what a periodic face's seam looks like, traversed once each way, so it
121//! cannot be convicted here).
122
123use crate::arrangement::Vec2;
124use crate::classification::{parameter_point_in_face, PolygonClass};
125use crate::projection::{project_point_to_surface, project_point_to_surface_seeded};
126use crate::spatial::{Aabb, Bvh};
127use crate::mass_properties::{split_at_surface_breaks, surface_breaks, GAUSS_W, GAUSS_X};
128use crate::topology::{BrepSolid, FaceRecord, LoopRecord};
129use crate::{NurbsCurve, NurbsSurface, Vec3};
130use rustc_hash::{FxHashMap as HashMap, FxHashSet as HashSet};
131use serde::Serialize;
132use web_time::Instant;
133
134#[path = "soundness/sphere_certificate.rs"]
135mod sphere_certificate;
136
137#[path = "soundness/nurbs_certificate.rs"]
138mod nurbs_certificate;
139
140#[path = "soundness/census.rs"]
141pub(crate) mod census;
142#[path = "soundness/pair_certificate.rs"]
143mod pair_certificate;
144#[path = "soundness/pair_memo.rs"]
145pub(crate) mod pair_memo;
146#[path = "soundness/fold_memo.rs"]
147pub(crate) mod fold_memo;
148#[path = "soundness/analytic_body.rs"]
149mod analytic_body;
150#[path = "soundness/tube_grid.rs"]
151mod tube_grid;
152#[path = "soundness/resolve.rs"]
153pub(crate) mod resolve;
154pub use resolve::{CrossingUndecidedCause, UndecidedCrossing};
155#[path = "soundness/blend_patch.rs"]
156mod blend_patch;
157#[path = "soundness/blend_curved.rs"]
158mod blend_curved;
159
160// ---------------------------------------------------------------------------
161// Disjoint set — shared by the shell and the vertex-fan scans
162// ---------------------------------------------------------------------------
163
164struct DisjointSet {
165 parent: Vec<usize>,
166}
167
168impl DisjointSet {
169 fn new(count: usize) -> Self {
170 Self {
171 parent: (0..count).collect(),
172 }
173 }
174
175 fn find(&mut self, mut index: usize) -> usize {
176 while self.parent[index] != index {
177 self.parent[index] = self.parent[self.parent[index]];
178 index = self.parent[index];
179 }
180 index
181 }
182
183 fn union(&mut self, first: usize, second: usize) {
184 let (a, b) = (self.find(first), self.find(second));
185 if a != b {
186 self.parent[b] = a;
187 }
188 }
189}
190
191// ---------------------------------------------------------------------------
192// Connectivity
193// ---------------------------------------------------------------------------
194
195/// One shell's connectivity: how many edge-connected pieces its faces form.
196#[derive(Clone, Debug, Serialize)]
197pub struct ShellConnectivity {
198 pub shell_id: u64,
199 pub faces: usize,
200 /// Edge-connected components of this shell's faces. Anything but 1 means
201 /// the record holds several surfaces under one shell.
202 pub components: usize,
203 /// Face ids of every component EXCEPT the largest, smallest first — the
204 /// pieces that would each have had to be their own shell. Empty when
205 /// `components == 1`.
206 pub detached: Vec<Vec<u64>>,
207}
208
209/// Outcome of [`solid_connectivity`].
210#[derive(Clone, Debug, Serialize)]
211pub struct ConnectivityReport {
212 pub shells: Vec<ShellConnectivity>,
213 /// Edges used by faces belonging to two DIFFERENT shells. A shell is a
214 /// closed surface in its own right; sharing an edge across shells means
215 /// the shell split is fiction.
216 pub cross_shell_edges: Vec<u64>,
217 /// Vertices where the incident face corners form more than one fan — two
218 /// pieces of surface touching at a point and nowhere else. Edge
219 /// connectivity cannot see this: both pieces are edge-connected, just not
220 /// through this vertex. Diagnostic, not part of [`Self::is_disconnected`].
221 pub pinch_vertices: Vec<u64>,
222 /// Vertices the fan scan skipped because a degenerate (pole) edge meets
223 /// there — a collapsed parameter boundary is not an ordinary fan and the
224 /// scan would read it as a pinch.
225 pub pinch_skipped: usize,
226}
227
228impl ConnectivityReport {
229 /// The HARD-GATE predicate: the record claims to be one closed surface per
230 /// shell and it is not. Pinch vertices are deliberately excluded — see the
231 /// module table.
232 pub fn is_disconnected(&self) -> bool {
233 self.shells.iter().any(|shell| shell.components > 1)
234 || !self.cross_shell_edges.is_empty()
235 }
236
237 /// One line naming what is wrong, or `None` when the gate predicate is
238 /// clear. Pinch vertices are appended when present so a caller printing
239 /// the summary still sees them.
240 pub fn summary(&self) -> Option<String> {
241 let mut parts = Vec::new();
242 for shell in &self.shells {
243 if shell.components > 1 {
244 let sizes: Vec<usize> = shell.detached.iter().map(Vec::len).collect();
245 parts.push(format!(
246 "shell {} splits into {} edge-connected components ({} face(s), detached sizes {sizes:?}, first detached faces {:?})",
247 shell.shell_id,
248 shell.components,
249 shell.faces,
250 shell.detached.first().map(Vec::as_slice).unwrap_or(&[]),
251 ));
252 }
253 }
254 if !self.cross_shell_edges.is_empty() {
255 parts.push(format!(
256 "{} edge(s) shared across shells ({:?})",
257 self.cross_shell_edges.len(),
258 &self.cross_shell_edges[..self.cross_shell_edges.len().min(8)]
259 ));
260 }
261 if !self.pinch_vertices.is_empty() {
262 parts.push(format!(
263 "{} pinch vertex/vertices ({:?})",
264 self.pinch_vertices.len(),
265 &self.pinch_vertices[..self.pinch_vertices.len().min(8)]
266 ));
267 }
268 if parts.is_empty() {
269 None
270 } else {
271 Some(parts.join("; "))
272 }
273 }
274}
275
276/// Connectivity of a solid's topology: are each shell's faces reachable from
277/// one another across shared edges, do any two shells share an edge, and does
278/// any vertex pinch two otherwise-separate fans together.
279///
280/// Pure topology — ids and use counts, never a coordinate — so it is
281/// tolerance-free and cannot be noisy. Degenerate (pole) edges DO count as
282/// connections: a collapsed boundary shared by two faces genuinely joins them.
283pub fn solid_connectivity(solid: &BrepSolid) -> ConnectivityReport {
284 // Global face indexing over (shell, face) in record order.
285 let mut face_shell: Vec<usize> = Vec::new();
286 let mut face_ids: Vec<u64> = Vec::new();
287 for (shell_index, shell) in solid.shells.iter().enumerate() {
288 for face in &shell.faces {
289 face_shell.push(shell_index);
290 face_ids.push(face.id);
291 }
292 }
293
294 // edge id -> the global face indices that use it.
295 let mut edge_faces: HashMap<u64, Vec<usize>> = HashMap::default();
296 let mut global_index = 0usize;
297 for shell in &solid.shells {
298 for face in &shell.faces {
299 let _ = face;
300 for loop_record in &face.loops {
301 for coedge in &loop_record.coedges {
302 let users = edge_faces.entry(coedge.edge_id).or_default();
303 if users.last() != Some(&global_index) {
304 users.push(global_index);
305 }
306 }
307 }
308 global_index += 1;
309 }
310 }
311
312 let mut sets = DisjointSet::new(face_ids.len());
313 let mut cross_shell_edges = Vec::new();
314 for (edge_id, users) in &edge_faces {
315 for pair in users.windows(2) {
316 sets.union(pair[0], pair[1]);
317 }
318 let shells_touched: HashSet<usize> =
319 users.iter().map(|index| face_shell[*index]).collect();
320 if shells_touched.len() > 1 {
321 cross_shell_edges.push(*edge_id);
322 }
323 }
324 cross_shell_edges.sort_unstable();
325
326 let mut shells = Vec::with_capacity(solid.shells.len());
327 let mut base = 0usize;
328 for shell in &solid.shells {
329 let count = shell.faces.len();
330 let mut buckets: HashMap<usize, Vec<u64>> = HashMap::default();
331 for offset in 0..count {
332 let root = sets.find(base + offset);
333 buckets.entry(root).or_default().push(face_ids[base + offset]);
334 }
335 let mut groups: Vec<Vec<u64>> = buckets.into_values().collect();
336 // Largest first so `detached` is "everything but the main piece", and
337 // deterministic: ties break on the smallest face id.
338 groups.sort_by(|a, b| b.len().cmp(&a.len()).then(a.first().cmp(&b.first())));
339 let components = groups.len();
340 let mut detached: Vec<Vec<u64>> = groups.into_iter().skip(1).collect();
341 detached.sort_by_key(Vec::len);
342 for group in &mut detached {
343 group.sort_unstable();
344 }
345 shells.push(ShellConnectivity {
346 shell_id: shell.id,
347 faces: count,
348 components,
349 detached,
350 });
351 base += count;
352 }
353
354 let (pinch_vertices, pinch_skipped) = pinch_vertices(solid);
355
356 ConnectivityReport {
357 shells,
358 cross_shell_edges,
359 pinch_vertices,
360 pinch_skipped,
361 }
362}
363
364/// Vertices whose incident face corners form more than one fan.
365///
366/// A *corner* is one consecutive coedge pair of a loop meeting at the vertex.
367/// Each non-degenerate edge at the vertex is used by exactly two coedges, each
368/// belonging to exactly one corner there, so the edge links those two corners.
369/// One fan = one component. Two cones touching at their apex are edge-
370/// connected everywhere else and split into two fans exactly here.
371///
372/// Any vertex with an incident DEGENERATE edge is skipped: a collapsed pole
373/// boundary is not an ordinary fan and would read as a pinch.
374fn pinch_vertices(solid: &BrepSolid) -> (Vec<u64>, usize) {
375 let mut degenerate_at: HashSet<u64> = HashSet::default();
376 for edge in &solid.edges {
377 if edge.degenerate {
378 degenerate_at.insert(edge.start_vertex_id);
379 degenerate_at.insert(edge.end_vertex_id);
380 }
381 }
382 let edges: HashMap<u64, &crate::topology::EdgeRecord> =
383 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
384
385 // vertex id -> corners; a corner is the pair of edge ids meeting there.
386 let mut corners: HashMap<u64, Vec<[u64; 2]>> = HashMap::default();
387 for shell in &solid.shells {
388 for face in &shell.faces {
389 for loop_record in &face.loops {
390 let count = loop_record.coedges.len();
391 if count == 0 {
392 continue;
393 }
394 for index in 0..count {
395 let current = &loop_record.coedges[index];
396 let next = &loop_record.coedges[(index + 1) % count];
397 let Some(current_edge) = edges.get(¤t.edge_id) else {
398 continue;
399 };
400 let vertex = if current.forward {
401 current_edge.end_vertex_id
402 } else {
403 current_edge.start_vertex_id
404 };
405 corners
406 .entry(vertex)
407 .or_default()
408 .push([current.edge_id, next.edge_id]);
409 }
410 }
411 }
412 }
413
414 let mut pinched = Vec::new();
415 let mut skipped = 0usize;
416 for (vertex, list) in &corners {
417 if degenerate_at.contains(vertex) {
418 skipped += 1;
419 continue;
420 }
421 if list.len() < 2 {
422 continue;
423 }
424 let mut sets = DisjointSet::new(list.len());
425 let mut by_edge: HashMap<u64, usize> = HashMap::default();
426 for (index, corner) in list.iter().enumerate() {
427 for edge_id in corner {
428 match by_edge.entry(*edge_id) {
429 std::collections::hash_map::Entry::Occupied(slot) => {
430 sets.union(*slot.get(), index);
431 }
432 std::collections::hash_map::Entry::Vacant(slot) => {
433 slot.insert(index);
434 }
435 }
436 }
437 }
438 let fans: HashSet<usize> = (0..list.len()).map(|index| sets.find(index)).collect();
439 if fans.len() > 1 {
440 pinched.push(*vertex);
441 }
442 }
443 pinched.sort_unstable();
444 (pinched, skipped)
445}
446
447// ---------------------------------------------------------------------------
448// Face-versus-face self-intersection
449// ---------------------------------------------------------------------------
450
451/// Fraction of the solid's bounding diagonal a confirmed crossing must
452/// straddle the other carrier by. Tessellation vertices are exact on their own
453/// surface, so this band only has to clear projection/Newton residue, not
454/// chord error; it is deliberately far below any real penetration.
455const STRADDLE_FRACTION: f64 = 1e-6;
456
457/// Fraction of the diagonal a mesh crossing segment must be longer than. A
458/// crossing shorter than this is a corner graze, not a penetration.
459const CROSSING_FRACTION: f64 = 1e-7;
460
461/// Fraction of the diagonal used to weld coincident tessellation vertices.
462/// Shared-edge samples are pinned to identical values by the watertight
463/// tessellator, so this only has to survive being read back out of the mesh
464/// buffers.
465const WELD_FRACTION: f64 = 1e-9;
466
467/// How far from PARALLEL two surface normals must be for a same-face crossing
468/// to be a fold. A degenerate parameterization — a sphere's pole, a seam the
469/// period reduction did not fold away — carries distinct uv to one point with
470/// one tangent plane, and this is what separates that from a surface genuinely
471/// passing through itself. The two mesh triangles already crossed
472/// TRANSVERSALLY (a coplanar pair returns no crossing), so a fold clears this
473/// by orders of magnitude and the constant is a floor, not a threshold.
474const FOLD_TRANSVERSAL_SINE: f64 = 1e-6;
475
476/// `BREP_FOLD_TRACE=1` prints why each same-face candidate was confirmed,
477/// rejected or left undecided, with the numbers behind the verdict. The
478/// attribution a population sweep needs — "which rule turned this hit away,
479/// and by how much" — cannot be reconstructed from the counts.
480fn fold_trace_enabled() -> bool {
481 std::env::var("BREP_FOLD_TRACE").is_ok_and(|value| !value.is_empty() && value != "0")
482}
483
484macro_rules! fold_trace {
485 ($($arg:tt)*) => {
486 if fold_trace_enabled() {
487 eprintln!($($arg)*);
488 }
489 };
490}
491
492/// `BREP_PIERCE_TRACE=1` prints every face-PAIR candidate the corner lane
493/// declined and each step of the pierce lane's answer for it: which edge
494/// pierced which triangle, the two bracket distances, and where the two
495/// samples across the crossing landed and what they read. The fold trace's
496/// counterpart for the other half of the scan. It is asked several times for
497/// every pair the corner lane declines, so it is one `OnceLock` read rather
498/// than an environment lookup each time.
499fn pierce_trace_enabled() -> bool {
500 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
501 *ON.get_or_init(|| {
502 std::env::var("BREP_PIERCE_TRACE").is_ok_and(|value| !value.is_empty() && value != "0")
503 })
504}
505
506macro_rules! pierce_trace {
507 ($($arg:tt)*) => {
508 if pierce_trace_enabled() {
509 eprintln!($($arg)*);
510 }
511 };
512}
513
514/// `BREP_PROFILE` (the switch the boolean's `boolean.*_ms` and the mass
515/// integrator's `mass.profile` read) prints one `soundness.profile` line per
516/// scan with its stage split — tessellation, weld, BVH build, the pair sweep,
517/// the fold pass — and the counts behind them, so a scan's cost can be named
518/// per stage rather than as one `timing.soundness_ms` ("Rules every slice
519/// follows", measure before changing).
520fn scan_profile_enabled() -> bool {
521 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
522 *ON.get_or_init(|| std::env::var("BREP_PROFILE").is_ok())
523}
524
525/// Knobs for [`solid_self_intersections`]. [`SelfIntersectionOptions::for_solid`]
526/// derives every one from the solid's own size.
527#[derive(Clone, Copy, Debug, Serialize)]
528pub struct SelfIntersectionOptions {
529 /// Tessellation density. The detector resolves nothing finer than this.
530 pub chord_tolerance: f64,
531 /// Signed-distance margin a confirmed crossing must straddle the other
532 /// carrier surface by, on BOTH sides.
533 pub straddle_band: f64,
534 /// Shortest mesh crossing segment that counts as a candidate.
535 pub min_crossing_length: f64,
536 /// Coincidence radius for welding tessellation vertices.
537 pub weld: f64,
538 /// Refuse to scan a mesh larger than this (the report comes back
539 /// `truncated`), so a pathological solid cannot hang a gate.
540 pub max_triangles: usize,
541}
542
543impl SelfIntersectionOptions {
544 /// Every band derived from the solid's own extent: the display chord
545 /// tolerance the app meshes at, and diagonal-relative bands.
546 pub fn for_solid(solid: &BrepSolid) -> Self {
547 let diagonal = solid_diagonal(solid).max(1e-9);
548 Self {
549 chord_tolerance: crate::display_chord_tolerance(solid, 1.0),
550 straddle_band: diagonal * STRADDLE_FRACTION,
551 min_crossing_length: diagonal * CROSSING_FRACTION,
552 weld: diagonal * WELD_FRACTION,
553 max_triangles: 400_000,
554 }
555 }
556}
557
558/// One confirmed face-versus-face crossing.
559#[derive(Clone, Debug, Serialize)]
560pub struct FaceCrossing {
561 pub face_a: u64,
562 pub face_b: u64,
563 pub name_a: Option<String>,
564 pub name_b: Option<String>,
565 /// Midpoint of the mesh crossing segment.
566 pub point: Vec3,
567 /// Length of the mesh crossing segment.
568 pub crossing_length: f64,
569 /// How far the confirmation straddled the other carrier: the smaller of
570 /// the two one-sided margins, over both directions of the pair.
571 pub straddle: f64,
572 /// WHICH confirmation convicted this pair. Recorded so a sweep can
573 /// attribute every hit to the question that caught it — the two ask the
574 /// same thing of different sample points, and a hit only the pierce lane
575 /// sees is a hit the corner lane structurally could not.
576 pub confirmation: CrossingConfirmation,
577 /// How deep one face's own SURFACE reaches past the other's carrier, at
578 /// the crossing — the pierce lane's second margin, and the one that says
579 /// whether the crossing is the geometry's or the mesh's.
580 ///
581 /// The lane's first margin brackets the pierce along a mesh EDGE, so on a
582 /// regularly tessellated carrier it reads the same number for every pair
583 /// in a ring and says more about the tessellation's stride than about the
584 /// shapes. This one is measured between exact surface points and is the
585 /// discriminator. `straddle` is the smaller of the two; for a `Corners`
586 /// confirmation this is 0.0 and means nothing.
587 pub penetration: f64,
588}
589
590/// One confirmed place a face FOLDS THROUGH ITSELF in 3D: its own surface
591/// carries two DISTINCT trimmed parameter feet to ONE point in space.
592///
593/// This is the defect a [`FaceCrossing`] structurally cannot hold. A crossing
594/// is between two faces and is confirmed by a signed straddle of the OTHER
595/// carrier; with both triangles on ONE carrier every corner sits at distance
596/// zero from it, so that test is degenerate by construction and the pair was
597/// only ever counted. A fold is confirmed by a different question, asked in
598/// parameter space where it is exact: are the two feet DISTINCT, are both
599/// inside the face's own trim, and do they land on the same 3D point?
600#[derive(Clone, Debug, Serialize)]
601pub struct FaceFold {
602 pub face: u64,
603 pub face_name: Option<String>,
604 /// The two parameter feet, each inside the face's own trim.
605 pub uv_a: [f64; 2],
606 pub uv_b: [f64; 2],
607 /// Where they meet — the midpoint of the two surface images.
608 pub point: Vec3,
609 /// Parameter distance between the feet, measured the SHORT way round every
610 /// closed direction, so a seam is never mistaken for a separation.
611 pub uv_separation: f64,
612 /// The parameter distance at which two feet count as ONE: half a cell of
613 /// the face's own sample grid, which is built at the tessellation's own
614 /// resolution. Two seeds closer than this refine to the same foot — which
615 /// is what a chord-error crossing on a sharply curved face produces — and
616 /// a face that ends with one foot is rejected.
617 pub uv_band: f64,
618 /// `|S(uv_a) - S(uv_b)|`: how coincident in 3D the two feet actually are.
619 /// Bounded by the mesh's own chord tolerance, and recorded so a reader can
620 /// see the margin rather than trust the verdict.
621 pub residual: f64,
622 /// Angle between the two surface normals, in radians. A genuine fold
623 /// crosses TRANSVERSALLY. A degenerate parameterization — a pole, a seam
624 /// the period reduction did not fold away — also carries distinct uv to
625 /// one point, but with ONE tangent plane; that is rejected here rather
626 /// than reported as a fold.
627 pub normal_angle: f64,
628}
629
630/// Separating support of one omitted distinct-face pair. Bounds are signed
631/// coordinates on the named world axis; no adjacency inference is involved.
632#[derive(Clone, Debug, Serialize)]
633pub struct CertifiedFacePair {
634 pub faces: [u64; 2],
635 pub rule: &'static str,
636 pub axis: usize,
637 pub bounds: [[f64; 2]; 2],
638}
639
640/// Explicit scope of an opt-in analytic pair scan. Original counters describe
641/// only examined pairs. Same-face fold work is retained in this first row.
642#[derive(Clone, Debug, Serialize)]
643pub struct CertificateScope {
644 pub examined_face_pairs: usize,
645 pub certified_face_pairs: usize,
646 pub omitted_faces: usize,
647 pub skipped_candidate_pairs: usize,
648 pub retained_fold_faces: usize,
649 pub faces_in_uncertified_pairs: usize,
650 pub triangles_in_uncertified_pairs: usize,
651 pub retained_triangles: usize,
652}
653
654/// Outcome of [`solid_self_intersections`].
655#[derive(Clone, Debug, Serialize)]
656pub struct SelfIntersectionReport {
657 #[serde(skip_serializing_if = "Vec::is_empty")]
658 pub certified_pairs: Vec<CertifiedFacePair>,
659 #[serde(skip_serializing_if = "Option::is_none")]
660 pub skipped_by_certificate: Option<CertificateScope>,
661 /// The whole-body proof that stood in for the scan, when one did: the
662 /// counters below then read zero because no mesh was built, and this names
663 /// why, so a caller can still tell a proved-clean body from a scan that
664 /// never ran.
665 #[serde(skip_serializing_if = "Option::is_none")]
666 pub certificate: Option<&'static str>,
667 pub triangles: usize,
668 /// Triangle pairs whose boxes overlapped and that survived the welded-
669 /// vertex skip — the work the exact confirmation was offered.
670 pub candidate_pairs: usize,
671 /// Mesh crossings between DISTINCT faces that the exact-surface straddle
672 /// test confirmed, one entry per face pair (deepest kept).
673 pub confirmed: Vec<FaceCrossing>,
674 /// Mesh crossings between distinct faces the straddle test REJECTED —
675 /// tangent neighbours and chord error. A large number here beside zero
676 /// confirmations is the detector working, not failing.
677 pub rejected: usize,
678 /// Mesh crossings whose confirmation could not be evaluated (the witness
679 /// is not a point both faces own, or a projection or a normal failed) and
680 /// that the carriers did not settle either when asked again on their exact
681 /// geometry (`resolve.rs`). Never counted as either verdict; a caller that
682 /// accepts a body must not read zero `confirmed` beside a nonzero count
683 /// here as clean.
684 pub undecided: usize,
685 /// The first [`MAX_LISTED_UNDECIDED`] of those, with their faces and cause.
686 #[serde(skip_serializing_if = "Vec::is_empty")]
687 pub undecided_pairs: Vec<UndecidedCrossing>,
688 /// Candidates the mesh confirmation left undecided that the carriers then
689 /// CLEARED: every point of the carriers' common curve near the crossing
690 /// lies on an edge both faces share, or outside a trim.
691 #[serde(skip_serializing_if = "is_zero")]
692 pub resolved_clear: usize,
693 /// Mesh crossings between two triangles of ONE face: the candidates the
694 /// fold confirmation was offered. A candidate is not a defect — a sharply
695 /// curved face's chords cross each other without the face folding.
696 pub same_face_candidates: usize,
697 /// Faces confirmed to FOLD THROUGH THEMSELVES in 3D, one entry per FACE
698 /// (the widest-separated hit kept). The face is the stable unit: a fold
699 /// region produces as many triangle pairs as the mesh happens to have
700 /// there, and a denser mesh would rewrite that number without the face
701 /// being any more or less folded.
702 pub folds: Vec<FaceFold>,
703 /// Same-face candidates the fold confirmation REJECTED — the two feet came
704 /// back within one tessellation cell of each other (chord error on a tight
705 /// curve), or their normals were parallel (a pole). A large number here
706 /// beside zero folds is the confirmation working, not failing.
707 pub fold_rejected: usize,
708 /// Same-face candidates whose confirmation could not be evaluated: a
709 /// projection, a normal or a containment query failed, or a corner did not
710 /// come back onto its own carrier. Never counted as either verdict, and a
711 /// caller must not read an empty `folds` beside a large one of these as
712 /// "no folds".
713 pub fold_undecided: usize,
714 /// The mesh exceeded `max_triangles` and was not scanned.
715 pub truncated: bool,
716 pub options: SelfIntersectionOptions,
717}
718
719impl SelfIntersectionReport {
720 /// The predicate for a caller that has opted into this check: the solid
721 /// passes through itself — two faces crossing, or ONE face folded through
722 /// itself. Both are the same defect to a caller deciding whether to accept
723 /// a result, so both are in the predicate.
724 pub fn is_flagged(&self) -> bool {
725 !self.confirmed.is_empty() || !self.folds.is_empty()
726 }
727
728 /// One line naming the worst crossing or fold, or `None` when nothing was
729 /// confirmed.
730 pub fn summary(&self) -> Option<String> {
731 if self.confirmed.is_empty() {
732 return self.fold_summary();
733 }
734 let worst = self
735 .confirmed
736 .iter()
737 .max_by(|a, b| a.straddle.total_cmp(&b.straddle))?;
738 Some(format!(
739 "{} face pair(s) self-intersect; worst faces {} and {} near ({:.4}, {:.4}, {:.4}) (crossing {:.3e}, straddle {:.3e})",
740 self.confirmed.len(),
741 worst.face_a,
742 worst.face_b,
743 worst.point.x,
744 worst.point.y,
745 worst.point.z,
746 worst.crossing_length,
747 worst.straddle
748 ))
749 }
750
751 /// One line naming the worst FOLD, or `None` when no face folded.
752 pub fn fold_summary(&self) -> Option<String> {
753 let worst = self
754 .folds
755 .iter()
756 .max_by(|a, b| a.uv_separation.total_cmp(&b.uv_separation))?;
757 Some(format!(
758 "{} face(s) fold through themselves; worst face {}{} carries uv ({:.6}, {:.6}) and \
759 ({:.6}, {:.6}) to ({:.4}, {:.4}, {:.4}) (separation {:.3e} vs band {:.3e}, \
760 residual {:.3e}, normals {:.1}°)",
761 self.folds.len(),
762 worst.face,
763 worst
764 .face_name
765 .as_ref()
766 .map(|name| format!(" '{name}'"))
767 .unwrap_or_default(),
768 worst.uv_a[0],
769 worst.uv_a[1],
770 worst.uv_b[0],
771 worst.uv_b[1],
772 worst.point.x,
773 worst.point.y,
774 worst.point.z,
775 worst.uv_separation,
776 worst.uv_band,
777 worst.residual,
778 worst.normal_angle.to_degrees()
779 ))
780 }
781}
782
783fn solid_diagonal(solid: &BrepSolid) -> f64 {
784 let mut bounds = Aabb::empty();
785 for vertex in &solid.vertices {
786 bounds.include_point(vertex.point);
787 }
788 let diagonal = bounds.diagonal();
789 if diagonal.is_finite() && diagonal > 0.0 {
790 return diagonal;
791 }
792 // Vertex-free solids (a full sphere, a full torus) fall back to the
793 // control hull, exactly as the display tolerance does.
794 let mut hull = Aabb::empty();
795 for shell in &solid.shells {
796 for face in &shell.faces {
797 if let Ok(box_of) = Aabb::from_surface_controls(&face.surface) {
798 hull.include(box_of);
799 }
800 }
801 }
802 let diagonal = hull.diagonal();
803 if diagonal.is_finite() {
804 diagonal
805 } else {
806 0.0
807 }
808}
809
810/// Does this solid pass through itself? Tessellates every face once, walks a
811/// triangle BVH for mesh crossings, and confirms each candidate against the
812/// two carrier SURFACES so tangent neighbours and chord error cannot
813/// manufacture a finding. See the module docs for the design and its one
814/// documented blind spot.
815///
816/// Nothing in the kernel's build path calls this — a caller asks for it and
817/// reads [`SelfIntersectionReport::is_flagged`]. The case gate is that caller
818/// and treats a flag as a failure unless its baseline already holds the same
819/// face pair; see the module table.
820pub fn solid_self_intersections(
821 solid: &BrepSolid,
822 options: SelfIntersectionOptions,
823) -> Result<SelfIntersectionReport, String> {
824 let started = scan_profile_enabled().then(Instant::now);
825 // First, because it declines in microseconds where the NURBS certificate
826 // validates the whole body. Every certificate returns this same empty
827 // report, so the order changes only what a scan costs.
828 if analytic_body::enabled() && analytic_body::certifies(solid, options) {
829 if let Some(started) = started {
830 eprintln!("soundness.profile ms={:.2} faces={} certificate=analytic_plane_cylinder triangles=0 confirmed=0 folds=0", started.elapsed().as_secs_f64()*1000.0, solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>());
831 }
832 return Ok(SelfIntersectionReport {
833 certificate: Some("analytic_plane_cylinder"),
834 ..empty_intersection_report(options)
835 });
836 }
837 if tube_grid::enabled() && tube_grid::certifies(solid, options) {
838 if let Some(started) = started {
839 eprintln!("soundness.profile ms={:.2} faces={} certificate=tube_grid triangles=0 confirmed=0 folds=0", started.elapsed().as_secs_f64()*1000.0, solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>());
840 }
841 return Ok(SelfIntersectionReport {
842 certificate: Some("tube_grid"),
843 ..empty_intersection_report(options)
844 });
845 }
846 if blend_patch::enabled() && blend_patch::certifies(solid, options) {
847 if let Some(started) = started {
848 eprintln!("soundness.profile ms={:.2} faces={} certificate=analytic_blend triangles=0 confirmed=0 folds=0", started.elapsed().as_secs_f64()*1000.0, solid.shells.iter().map(|shell| shell.faces.len()).sum::<usize>());
849 }
850 return Ok(SelfIntersectionReport {
851 certificate: Some("analytic_blend"),
852 ..empty_intersection_report(options)
853 });
854 }
855 if sphere_certificate::enabled() && sphere_certificate::certifies(solid, options) {
856 if let Some(started) = started {
857 eprintln!("soundness.profile ms={:.2} faces={} certificate=native_trimmed_spheres triangles=0 confirmed=0 folds=0", started.elapsed().as_secs_f64()*1000.0, solid.shells[0].faces.len());
858 }
859 return Ok(empty_intersection_report(options));
860 }
861 if nurbs_certificate::enabled() && nurbs_certificate::certifies(solid, options) {
862 if let Some(started) = started {
863 eprintln!("soundness.profile ms={:.2} faces={} certificate=native_nurbs_horizontal_sections triangles=0 confirmed=0 folds=0", started.elapsed().as_secs_f64()*1000.0, solid.shells[0].faces.len());
864 }
865 return Ok(empty_intersection_report(options));
866 }
867 solid_self_intersections_full(solid, options)
868}
869
870fn is_zero(value: &usize) -> bool {
871 *value == 0
872}
873
874/// How many undecided candidates a report lists by name.
875pub const MAX_LISTED_UNDECIDED: usize = 16;
876
877fn empty_intersection_report(options: SelfIntersectionOptions) -> SelfIntersectionReport {
878 SelfIntersectionReport {
879 certified_pairs: Vec::new(),
880 skipped_by_certificate: None,
881 certificate: None,
882 triangles: 0,
883 candidate_pairs: 0,
884 confirmed: Vec::new(),
885 rejected: 0,
886 undecided: 0,
887 undecided_pairs: Vec::new(),
888 resolved_clear: 0,
889 same_face_candidates: 0,
890 folds: Vec::new(),
891 fold_rejected: 0,
892 fold_undecided: 0,
893 truncated: false,
894 options,
895 }
896}
897
898/// Ask the carriers about every candidate the mesh confirmation left
899/// undecided (`resolve.rs`), within its budget. Skipped when a crossing is
900/// already confirmed (the body is flagged either way) or the trim switches are
901/// off; what stays undecided is counted and the first few are named.
902fn resolve_pending(
903 solid: &BrepSolid,
904 faces: &[&crate::topology::FaceRecord],
905 pending: Vec<(usize, usize, CrossingUndecidedCause, Vec3, Vec3)>,
906 options: SelfIntersectionOptions,
907 best: &mut HashMap<(u64, u64), FaceCrossing>,
908 report: &mut SelfIntersectionReport,
909) {
910 let mut attempt = resolve::enabled() && best.is_empty() && trim_samples_enabled() && node_membership_enabled();
911 let curves: HashMap<u64, &NurbsCurve> = solid.edges.iter().map(|edge| (edge.id, &edge.curve)).collect();
912 let ends: HashMap<u64, [u64; 2]> = solid.edges.iter().map(|edge| (edge.id, [edge.start_vertex_id, edge.end_vertex_id])).collect();
913 let points: HashMap<u64, Vec3> = solid.vertices.iter().map(|vertex| (vertex.id, vertex.point)).collect();
914 let edges_of = |face: &crate::topology::FaceRecord| -> HashSet<u64> {
915 face.loops.iter().flat_map(|l| &l.coedges).map(|coedge| coedge.edge_id).collect()
916 };
917 for (index, (first, second, cause, start, end)) in pending.into_iter().enumerate() {
918 let (face_a, face_b) = (faces.get(first), faces.get(second));
919 let id = |face: Option<&&crate::topology::FaceRecord>| face.map_or(u64::MAX, |f| f.id);
920 let resolution = match (face_a, face_b) {
921 (Some(a), Some(b)) if attempt && index < resolve::MAX_RESOLVED => {
922 let shared_ids: Vec<u64> = edges_of(a).intersection(&edges_of(b)).copied().collect();
923 let shared: Vec<&NurbsCurve> = shared_ids.iter().filter_map(|edge| curves.get(edge).copied()).collect();
924 let vertices_of = |edges: &HashSet<u64>| -> HashSet<u64> { edges.iter().filter_map(|e| ends.get(e)).flatten().copied().collect() };
925 let corners: Vec<Vec3> = vertices_of(&edges_of(a)).intersection(&vertices_of(&edges_of(b))).filter_map(|v| points.get(v).copied()).collect();
926 resolve::resolve(a, b, &shared, &corners, (start, end), options)
927 }
928 _ => resolve::Resolution::Undecided,
929 };
930 match resolution {
931 resolve::Resolution::Crossing { point, straddle } => {
932 let (a, b) = (face_a.expect("resolved faces"), face_b.expect("resolved faces"));
933 let (lo, hi) = if a.id <= b.id { (a, b) } else { (b, a) };
934 let crossing = FaceCrossing {
935 face_a: lo.id,
936 face_b: hi.id,
937 name_a: lo.name.clone(),
938 name_b: hi.name.clone(),
939 point,
940 crossing_length: end.sub(start).length(),
941 straddle,
942 confirmation: CrossingConfirmation::Resolved,
943 penetration: 0.0,
944 };
945 let slot = best.entry((lo.id, hi.id)).or_insert_with(|| crossing.clone());
946 if crossing.straddle > slot.straddle {
947 *slot = crossing;
948 }
949 // Flagged now: the rest need not be asked.
950 attempt = false;
951 }
952 resolve::Resolution::Clear => report.resolved_clear += 1,
953 resolve::Resolution::Undecided => {
954 report.undecided += 1;
955 if report.undecided_pairs.len() < MAX_LISTED_UNDECIDED {
956 report.undecided_pairs.push(UndecidedCrossing { face_a: id(face_a), face_b: id(face_b), cause, point: start.add(end).scale(0.5) });
957 }
958 }
959 }
960 }
961}
962
963fn solid_self_intersections_full(
964 solid: &BrepSolid,
965 options: SelfIntersectionOptions,
966) -> Result<SelfIntersectionReport, String> {
967 solid_self_intersections_with_census(solid, options, census::enabled())
968}
969
970fn solid_self_intersections_with_census(
971 solid: &BrepSolid,
972 options: SelfIntersectionOptions,
973 collect_census: bool,
974) -> Result<SelfIntersectionReport, String> {
975 solid_self_intersections_with_pair_certificates(solid, options, collect_census, pair_certificate::enabled())
976}
977
978fn solid_self_intersections_with_pair_certificates(
979 solid: &BrepSolid,
980 options: SelfIntersectionOptions,
981 collect_census: bool,
982 certify_pairs: bool,
983) -> Result<SelfIntersectionReport, String> {
984 let key = if collect_census || fold_trace_enabled() || pierce_trace_enabled() {
985 None
986 } else {
987 network_acceptance_memo::scan_key(solid, options, certify_pairs)
988 };
989 if let Some(report) = key.and_then(network_acceptance_memo::lookup) {
990 return Ok(report);
991 }
992 if key.is_some() { network_acceptance_memo::note_read(); }
993 let report = solid_self_intersections_uncached(solid, options, collect_census, certify_pairs)?;
994 if let Some(key) = key {
995 if !report.is_flagged() && !report.truncated && report.undecided == 0 && report.fold_undecided == 0 {
996 network_acceptance_memo::store(key, report.clone());
997 }
998 }
999 Ok(report)
1000}
1001
1002fn solid_self_intersections_uncached(
1003 solid: &BrepSolid,
1004 options: SelfIntersectionOptions,
1005 collect_census: bool,
1006 certify_pairs: bool,
1007) -> Result<SelfIntersectionReport, String> {
1008 let mut report = empty_intersection_report(options);
1009 if !(options.chord_tolerance > 0.0) || !options.chord_tolerance.is_finite() {
1010 return Err("solid_self_intersections: chord tolerance must be positive".into());
1011 }
1012
1013 let profile = scan_profile_enabled().then(Instant::now);
1014 let stage_ms = |started: Option<Instant>| -> f64 {
1015 started.map(|at| at.elapsed().as_secs_f64() * 1_000.0).unwrap_or(0.0)
1016 };
1017 // The face-stride entry point rather than `tessellate_brep_watertight`:
1018 // the latter runs a coherent-orientation pass and a mesh validation that
1019 // can fail on exactly the broken solids this detector exists to inspect,
1020 // and winding is irrelevant to a crossing test.
1021 let (mesh, face_keys) = crate::watertight_tessellation::tessellate_brep_watertight_face_stride_keyed(
1022 solid, options.chord_tolerance, 1, 0,
1023 )?;
1024 let tessellate_ms = stage_ms(profile);
1025 let triangle_count = mesh.indices.len() / 3;
1026 report.triangles = triangle_count;
1027 if triangle_count == 0 {
1028 return Ok(report);
1029 }
1030 if triangle_count > options.max_triangles {
1031 report.truncated = true;
1032 return Ok(report);
1033 }
1034
1035 // Faces in the tessellator's sequential order, so a triangle's `face_ids`
1036 // entry indexes straight into this.
1037 let faces: Vec<&crate::topology::FaceRecord> = solid
1038 .shells
1039 .iter()
1040 .flat_map(|shell| shell.faces.iter())
1041 .collect();
1042
1043 let mut census = collect_census.then(|| {
1044 let ids: Vec<_> = mesh.face_ids.iter().map(|&index| faces[index as usize].id).collect();
1045 census::ScanCensus::new(&ids)
1046 });
1047 let point_of = |index: u32| -> Vec3 {
1048 let base = index as usize * 3;
1049 Vec3::new(
1050 mesh.positions[base],
1051 mesh.positions[base + 1],
1052 mesh.positions[base + 2],
1053 )
1054 };
1055
1056 let weld_started = scan_profile_enabled().then(Instant::now);
1057 let welded = weld_positions(&mesh.positions, options.weld);
1058 let weld_ms = stage_ms(weld_started);
1059
1060 let bvh_started = scan_profile_enabled().then(Instant::now);
1061 let mut boxes = Vec::with_capacity(triangle_count);
1062 let mut corners: Vec<[Vec3; 3]> = Vec::with_capacity(triangle_count);
1063 for triangle in 0..triangle_count {
1064 let indices = [
1065 mesh.indices[triangle * 3],
1066 mesh.indices[triangle * 3 + 1],
1067 mesh.indices[triangle * 3 + 2],
1068 ];
1069 let points = [
1070 point_of(indices[0]),
1071 point_of(indices[1]),
1072 point_of(indices[2]),
1073 ];
1074 boxes.push(Aabb::from_points(points));
1075 corners.push(points);
1076 }
1077 let mut certificates = (certify_pairs && trim_samples_enabled() && node_membership_enabled()
1078 && faces.len() <= 256
1079 && options.straddle_band.is_finite() && options.straddle_band > 0.
1080 && options.min_crossing_length.is_finite() && options.min_crossing_length > 0.
1081 ).then(|| pair_certificate::Plan::new(solid, &faces, &corners, &mesh.face_ids));
1082 let bvh = Bvh::build(&boxes);
1083 let bvh_ms = stage_ms(bvh_started);
1084 // The face-pair memo (`pair_memo.rs`): a pair of fragments the sweep
1085 // read clean under these bands skips its triangle crossing tests; every
1086 // count the report carries is kept. Off while the census collects (its
1087 // tallies read every candidate) and when the mesh's face keys are not
1088 // the faces' (a stride other than 1 never reaches here).
1089 let pair_memo = pair_memo::enabled() && !collect_census && face_keys.len() == faces.len();
1090 let pair_options = pair_memo::options_key(options.chord_tolerance, options.weld, options.min_crossing_length);
1091 // Per unordered face pair: 0 untouched, 1 swept clean so far, 2 served
1092 // from the memo, 3 crossed in the mesh.
1093 let face_count = faces.len();
1094 let mut pair_state: Vec<u8> = if pair_memo { vec![0u8; face_count * face_count] } else { Vec::new() };
1095 let sweep_started = scan_profile_enabled().then(Instant::now);
1096 let mut overlaps: u64 = 0;
1097 let mut crossings: u64 = 0;
1098 let mut confirm_ms = 0.0f64;
1099
1100 // Deepest confirmed crossing per unordered face pair, and the
1101 // widest-separated confirmed fold per face.
1102 let mut best: HashMap<(u64, u64), FaceCrossing> = HashMap::default();
1103 let mut folded: HashMap<u64, FaceFold> = HashMap::default();
1104 // Same-face mesh crossings, per face index, as `(crossing length, point)`.
1105 let mut same_face: HashMap<usize, Vec<(f64, Vec3)>> = HashMap::default();
1106 // Candidates the mesh confirmation could not decide, asked of the carriers
1107 // after the sweep: `(face index, face index, cause, crossing segment)`.
1108 let mut pending: Vec<(usize, usize, CrossingUndecidedCause, Vec3, Vec3)> = Vec::new();
1109 let mut hits = Vec::new();
1110 for first in 0..triangle_count {
1111 hits.clear();
1112 bvh.overlapping(boxes[first], 0.0, &mut hits);
1113 for second in hits.iter().copied() {
1114 if second <= first {
1115 continue;
1116 }
1117 overlaps += 1;
1118 if let Some(census) = &mut census { census.pair(first, second, false); }
1119 let shares_vertex = (0..3).any(|a| {
1120 let wa = welded[mesh.indices[first * 3 + a] as usize];
1121 (0..3).any(|b| wa == welded[mesh.indices[second * 3 + b] as usize])
1122 });
1123 if shares_vertex {
1124 continue;
1125 }
1126 if let Some(plan) = &mut certificates {
1127 if plan.skips(mesh.face_ids[first], mesh.face_ids[second]) {
1128 plan.scope.skipped_candidate_pairs += 1;
1129 continue;
1130 }
1131 }
1132 report.candidate_pairs += 1;
1133 if let Some(census) = &mut census { census.pair(first, second, true); }
1134 if pair_memo {
1135 let (fa, fb) = (mesh.face_ids[first] as usize, mesh.face_ids[second] as usize);
1136 let slot = fa.min(fb) * face_count + fa.max(fb);
1137 match pair_state[slot] {
1138 2 => continue,
1139 0 => {
1140 let served = pair_memo::contains(pair_memo::pair_key(pair_options, face_keys[fa], face_keys[fb]));
1141 pair_state[slot] = if served { 2 } else { 1 };
1142 if served {
1143 continue;
1144 }
1145 }
1146 _ => {}
1147 }
1148 }
1149 let segment = triangle_crossing(&corners[first], &corners[second]);
1150 let Some((start, end)) = segment else {
1151 continue;
1152 };
1153 let length = end.sub(start).length();
1154 if length <= options.min_crossing_length {
1155 continue;
1156 }
1157 crossings += 1;
1158 let confirm_started = scan_profile_enabled().then(Instant::now);
1159 let face_first = mesh.face_ids[first] as usize;
1160 let face_second = mesh.face_ids[second] as usize;
1161 if pair_memo {
1162 pair_state[face_first.min(face_second) * face_count + face_first.max(face_second)] = 3;
1163 }
1164 if face_first == face_second {
1165 report.same_face_candidates += 1;
1166 // Confirmed in a SECOND pass, per face: the fold test needs a
1167 // sample grid over the face's own domain, and building that
1168 // once per face is the difference between a cheap check and a
1169 // tessellation-sized one.
1170 same_face
1171 .entry(face_first)
1172 .or_insert_with(Vec::new)
1173 .push((length, start.add(end).scale(0.5)));
1174 continue;
1175 }
1176 let (Some(face_a), Some(face_b)) = (faces.get(face_first), faces.get(face_second))
1177 else {
1178 pending.push((face_first, face_second, CrossingUndecidedCause::MissingFace, start, end));
1179 continue;
1180 };
1181 // The mesh crossing's midpoint must be a point BOTH faces own: a
1182 // crossing between two meshes whose witness lies outside either
1183 // trim is a crossing of the tessellation, not of the body
1184 // (2026-10-03, `anotherBooleanFail` ∩ sphere: four vendor
1185 // cone–cone junctions convicted at 1.395e-2 from interior mesh
1186 // nodes planted 0.26° past their own trims on a chart where the
1187 // junction pcurve bows 2.8e-3 in u). The witness is a chord point,
1188 // so only its carrier FOOT is asked of each trim (no reach test:
1189 // the oblique bore-mouth control's genuine crossing sits a chord
1190 // sagitta off the bore). A witness that cannot be classified
1191 // leaves the pair undecided, never clear.
1192 let witness = start.add(end).scale(0.5);
1193 match (footprint_material(face_a, witness), footprint_material(face_b, witness)) {
1194 (Some(true), Some(true)) => {}
1195 (in_a, in_b) => {
1196 pierce_trace!(
1197 "pair faces {}|{} triangles {first}|{second}: crossing witness ({:.6}, {:.6}, {:.6}) in face {}: {in_a:?}, in face {}: {in_b:?} — not a point both faces own; undecided",
1198 face_a.id, face_b.id, witness.x, witness.y, witness.z, face_a.id, face_b.id
1199 );
1200 pending.push((face_first, face_second, CrossingUndecidedCause::Witness, start, end));
1201 continue;
1202 }
1203 }
1204 // TWO confirmations, asked in order, and the first one is
1205 // unchanged in what it asks: a triangle's OWN CORNERS must straddle
1206 // the other carrier, in both directions — read over the corners
1207 // the face OWNS (a tessellation node outside its own trim is not
1208 // the face's material and its side counts for nothing).
1209 let forward = straddle_material(face_a, &face_b.surface, &corners[first], options.straddle_band);
1210 let backward = straddle_material(face_b, &face_a.surface, &corners[second], options.straddle_band);
1211 let corner_margin = match (forward, backward) {
1212 (Some(forward), Some(backward))
1213 if forward > options.straddle_band && backward > options.straddle_band =>
1214 {
1215 Some(forward.min(backward))
1216 }
1217 _ => None,
1218 };
1219 // The SECOND runs only when the first did not convict, and it is
1220 // the one a PLANAR face passing through a curved one needs: the
1221 // corner test reads a margin of zero there, because every corner
1222 // of the plane's few triangles is outside the cylinder its
1223 // INTERIOR passes through. See `pierce_straddle`.
1224 if corner_margin.is_none() {
1225 pierce_trace!(
1226 "pair faces {}|{} triangles {first}|{second}: corners forward {forward:?} backward {backward:?}; crossing ({:.6}, {:.6}, {:.6})-({:.6}, {:.6}, {:.6}); triangle A {:?} triangle B {:?}",
1227 face_a.id,
1228 face_b.id,
1229 start.x,
1230 start.y,
1231 start.z,
1232 end.x,
1233 end.y,
1234 end.z,
1235 corners[first].map(|corner| (corner.x, corner.y, corner.z)),
1236 corners[second].map(|corner| (corner.x, corner.y, corner.z)),
1237 );
1238 }
1239 let (margin, confirmation, penetration) = match corner_margin {
1240 Some(margin) => (margin, CrossingConfirmation::Corners, 0.0),
1241 None => match pierce_straddle(
1242 &corners[first],
1243 &corners[second],
1244 face_a,
1245 face_b,
1246 (start, end),
1247 options.straddle_band,
1248 ) {
1249 PierceVerdict::Confirmed(hit) => {
1250 pierce_trace!(
1251 " verdict: confirmed, bracket {:.6e} penetration {:.6e}",
1252 hit.bracket,
1253 hit.penetration
1254 );
1255 (hit.margin(), CrossingConfirmation::Pierce, hit.penetration)
1256 }
1257 verdict => {
1258 // A projection or a normal that could not be evaluated
1259 // is undecided in EITHER lane, never a rejection.
1260 if forward.is_none()
1261 || backward.is_none()
1262 || matches!(verdict, PierceVerdict::Undecided)
1263 {
1264 pierce_trace!(" verdict: undecided");
1265 pending.push((face_first, face_second, CrossingUndecidedCause::Pierce, start, end));
1266 } else {
1267 pierce_trace!(" verdict: rejected");
1268 report.rejected += 1;
1269 }
1270 continue;
1271 }
1272 },
1273 };
1274 let key = if face_a.id <= face_b.id {
1275 (face_a.id, face_b.id)
1276 } else {
1277 (face_b.id, face_a.id)
1278 };
1279 let crossing = FaceCrossing {
1280 face_a: key.0,
1281 face_b: key.1,
1282 name_a: if face_a.id <= face_b.id {
1283 face_a.name.clone()
1284 } else {
1285 face_b.name.clone()
1286 },
1287 name_b: if face_a.id <= face_b.id {
1288 face_b.name.clone()
1289 } else {
1290 face_a.name.clone()
1291 },
1292 point: start.add(end).scale(0.5),
1293 crossing_length: length,
1294 straddle: margin,
1295 confirmation,
1296 penetration,
1297 };
1298 let slot = best.entry(key).or_insert_with(|| crossing.clone());
1299 if crossing.straddle > slot.straddle {
1300 *slot = crossing;
1301 }
1302 confirm_ms += stage_ms(confirm_started);
1303 }
1304 }
1305 let sweep_ms = stage_ms(sweep_started);
1306 if pair_memo {
1307 // Every pair that was swept and never crossed is clean under these
1308 // inputs; pairs no triangle box ever brought together cost nothing to
1309 // sweep again and are left out.
1310 let clean = (0..face_count).flat_map(|a| (a..face_count).map(move |b| (a, b)))
1311 .filter(|&(a, b)| pair_state[a * face_count + b] == 1)
1312 .map(|(a, b)| pair_memo::pair_key(pair_options, face_keys[a], face_keys[b]));
1313 pair_memo::store(clean);
1314 if profile.is_some() {
1315 let (hits, stored, entries) = pair_memo::take_counts();
1316 eprintln!("soundness.pair_memo served={hits} stored={stored} entries={entries}");
1317 }
1318 }
1319 let resolve_started = scan_profile_enabled().then(Instant::now);
1320 resolve_pending(solid, &faces, pending, options, &mut best, &mut report);
1321 let resolve_ms = stage_ms(resolve_started);
1322 let fold_started = scan_profile_enabled().then(Instant::now);
1323 let fold_counts = fold_memo::counts();
1324
1325 // ---- the fold pass ---------------------------------------------------
1326 // One grid per face that produced a same-face crossing at all, and at most
1327 // MAX_FOLD_PROBES points per face — the longest crossing segments, which
1328 // are the clearest evidence. A fold region produces as many candidates as
1329 // the mesh happens to have there, and confirming every one of them buys
1330 // nothing beyond the first.
1331 let mut triangles_on_face: HashMap<usize, usize> = HashMap::default();
1332 for face in mesh.face_ids.iter() {
1333 *triangles_on_face.entry(*face as usize).or_insert(0) += 1;
1334 }
1335 let mut face_order: Vec<usize> = same_face.keys().copied().collect();
1336 face_order.sort_unstable();
1337 for face_index in face_order {
1338 let Some(face) = faces.get(face_index) else {
1339 report.fold_undecided += same_face[&face_index].len();
1340 continue;
1341 };
1342 let mut points = same_face.remove(&face_index).unwrap_or_default();
1343 points.sort_by(|a, b| b.0.total_cmp(&a.0));
1344 points.truncate(MAX_FOLD_PROBES);
1345 let triangles = triangles_on_face.get(&face_index).copied().unwrap_or(0);
1346 let verdicts = fold_memo::read(face, triangles, &points, options);
1347 for verdict in verdicts {
1348 match verdict {
1349 FoldVerdict::Confirmed(fold) => {
1350 let slot = folded.entry(face.id).or_insert_with(|| fold.clone());
1351 if fold.uv_separation > slot.uv_separation {
1352 *slot = fold;
1353 }
1354 }
1355 FoldVerdict::Rejected => report.fold_rejected += 1,
1356 FoldVerdict::Undecided => report.fold_undecided += 1,
1357 }
1358 }
1359 }
1360
1361 if profile.is_some() {
1362 let after = fold_memo::counts();
1363 eprintln!("soundness.fold_memo reused={} read={}", after.0 - fold_counts.0, after.1 - fold_counts.1);
1364 }
1365 report.confirmed = best.into_values().collect();
1366 report
1367 .confirmed
1368 .sort_by(|a, b| b.straddle.total_cmp(&a.straddle).then(a.face_a.cmp(&b.face_a)));
1369 report.folds = folded.into_values().collect();
1370 report
1371 .folds
1372 .sort_by(|a, b| b.uv_separation.total_cmp(&a.uv_separation).then(a.face.cmp(&b.face)));
1373 if profile.is_some() {
1374 eprintln!(
1375 "soundness.profile ms={:.2} faces={} tris={triangle_count} chord={:.3e} | \
1376 tessellate_ms={tessellate_ms:.2} weld_ms={weld_ms:.2} bvh_ms={bvh_ms:.2} \
1377 sweep_ms={sweep_ms:.2} confirm_ms={confirm_ms:.2} resolve_ms={resolve_ms:.2} fold_ms={:.2} | \
1378 overlaps={overlaps} candidate_pairs={} mesh_crossings={crossings} \
1379 confirmed={} rejected={} undecided={} same_face={} folds={}",
1380 stage_ms(profile),
1381 faces.len(),
1382 options.chord_tolerance,
1383 stage_ms(fold_started),
1384 report.candidate_pairs,
1385 report.confirmed.len(),
1386 report.rejected,
1387 report.undecided,
1388 report.same_face_candidates,
1389 report.folds.len(),
1390 );
1391 }
1392 if let Some(plan) = certificates {
1393 if !plan.proofs.is_empty() {
1394 if profile.is_some() {
1395 let mut rules = std::collections::BTreeMap::new();
1396 for proof in &plan.proofs { *rules.entry(proof.rule).or_insert(0usize) += 1; }
1397 eprintln!("soundness.certificates {}", serde_json::json!({"rules": rules, "skipped_by_certificate": plan.scope}));
1398 }
1399 report.certified_pairs = plan.proofs;
1400 report.skipped_by_certificate = Some(plan.scope);
1401 }
1402 }
1403 if let Some(census) = census { census.emit(&boxes); }
1404 Ok(report)
1405}
1406
1407/// Which of this solid's faces FOLD THROUGH THEMSELVES in 3D — a face whose
1408/// surface carries two distinct trimmed parameter feet to one point in space:
1409/// an offset taken past the source's curvature radius, a section swept through
1410/// a bend tighter than its own half-width, a carrier fitted through a
1411/// swallowtail.
1412///
1413/// The brief's named entry point, and one pass: it is
1414/// [`solid_self_intersections`] read for its fold half, because both verdicts
1415/// come off the SAME tessellation and paying for that twice would buy nothing.
1416/// A caller that wants both should call `solid_self_intersections` directly and
1417/// read `confirmed` and `folds` off the one report.
1418pub fn face_self_intersections(solid: &BrepSolid) -> Result<Vec<FaceFold>, String> {
1419 let options = SelfIntersectionOptions::for_solid(solid);
1420 Ok(solid_self_intersections(solid, options)?.folds)
1421}
1422
1423/// Verdict of the fold confirmation for ONE same-face triangle pair.
1424enum FoldVerdict {
1425 Confirmed(FaceFold),
1426 Rejected,
1427 Undecided,
1428}
1429
1430
1431/// A coarse `(u, v) -> 3D` sample of ONE face's surface over its own domain,
1432/// at the resolution the tessellation already resolved the face at.
1433///
1434/// This is the fold test's instrument, and it exists because
1435/// `project_point_to_surface` cannot be one. A global projection onto a
1436/// FOLDED general surface is exactly the case its Newton is weakest at:
1437/// measured on the ribbon fixture, a point lying EXACTLY on the surface came
1438/// back with a foot 2.766 away from itself. The grid replaces that search with
1439/// an enumeration — every branch that comes near the point has a node near it
1440/// — and `project_point_to_surface_seeded` then refines each one WITHOUT
1441/// re-seeding globally, which is the same instrument the pcurve tracer uses to
1442/// stay on one branch of a surface that folds back.
1443struct FoldGrid {
1444 samples: Vec<(f64, f64, Vec3)>,
1445 /// Parameter distance at which two feet are ONE foot: half a cell. Two
1446 /// seeds inside it refine to the same place, which is exactly what chord
1447 /// error on a tightly curved face produces.
1448 merge: f64,
1449 /// The largest 3D step between neighbouring nodes, measured rather than
1450 /// assumed. A branch's nearest node can sit this far from the point, so it
1451 /// is how wide the seed window has to be to miss nothing.
1452 reach: f64,
1453}
1454
1455/// At most this many crossing points are confirmed per face. A fold region
1456/// produces as many mesh candidates as the tessellation happens to have
1457/// there; the verdict is a property of the FACE, so the longest crossing
1458/// segments — the clearest evidence — are enough.
1459const MAX_FOLD_PROBES: usize = 64;
1460
1461/// At most this many seeds are refined for one crossing point. A fold needs
1462/// TWO distinct feet; more than a handful of branches through one point is a
1463/// different shape than this detector claims.
1464const MAX_FOLD_SEEDS: usize = 16;
1465
1466/// Build the sample grid for one face, at twice the tessellation's own linear
1467/// density (`triangles` is how many triangles the mesh gave this face), so the
1468/// grid resolves anything the mesh that produced the candidate could.
1469fn fold_grid(surface: &NurbsSurface, triangles: usize) -> Option<FoldGrid> {
1470 let [u0, u1] = surface.domain_u().ok()?;
1471 let [v0, v1] = surface.domain_v().ok()?;
1472 let steps = (((triangles.max(1) as f64).sqrt().ceil() as usize) * 2).clamp(24, 192);
1473 let node = |index: usize| -> (f64, f64) {
1474 let iu = index / (steps + 1);
1475 let iv = index % (steps + 1);
1476 (
1477 u0 + (u1 - u0) * iu as f64 / steps as f64,
1478 v0 + (v1 - v0) * iv as f64 / steps as f64,
1479 )
1480 };
1481 let mut samples = Vec::with_capacity((steps + 1) * (steps + 1));
1482 for index in 0..(steps + 1) * (steps + 1) {
1483 let (u, v) = node(index);
1484 samples.push((u, v, surface.evaluate(u, v).ok()?));
1485 }
1486 let mut reach = 0.0f64;
1487 for iu in 0..=steps {
1488 for iv in 0..=steps {
1489 let here = samples[iu * (steps + 1) + iv].2;
1490 if iu < steps {
1491 reach = reach.max(samples[(iu + 1) * (steps + 1) + iv].2.sub(here).length());
1492 }
1493 if iv < steps {
1494 reach = reach.max(samples[iu * (steps + 1) + iv + 1].2.sub(here).length());
1495 }
1496 }
1497 }
1498 let cell_u = (u1 - u0) / steps as f64;
1499 let cell_v = (v1 - v0) / steps as f64;
1500 Some(FoldGrid {
1501 samples,
1502 merge: 0.5 * (cell_u * cell_u + cell_v * cell_v).sqrt(),
1503 reach,
1504 })
1505}
1506
1507/// The parameter distance between two feet, measured the SHORT way round every
1508/// closed direction.
1509fn parameter_separation(a: Vec2, b: Vec2, period: [Option<f64>; 2]) -> f64 {
1510 b.add(period_shift(b, a, period)).sub(a).length()
1511}
1512
1513/// Are these two feet ONE locus reached from opposite ends of the domain — a
1514/// seam the surface carries but does not DECLARE?
1515///
1516/// `closed_directions()` is an exact test and an imported carrier can fail it
1517/// while still closing: measured on `abc_00000026.step`, a face whose two
1518/// u-boundary isocurves sit 2.5e-2 apart (on a model whose pcurves are known to
1519/// be ~3.5e-2 off their own edges) put one foot at u = 0 and the other at
1520/// u = 1, a full span apart, on what is one curve. The period reduction cannot
1521/// fold those together because the surface is not closed to the precision that
1522/// test demands.
1523///
1524/// So it is MEASURED: when the two feet sit within `window` of opposite ends of
1525/// a direction, the two boundary isocurves are sampled and compared, and they
1526/// are one locus when every sample agrees within `band`. `window` is the grid's
1527/// own resolution — a foot is "at the boundary" when the grid cannot place it
1528/// anywhere else — and `band` is the same coincidence band the feet themselves
1529/// had to clear.
1530fn boundary_identified(
1531 surface: &NurbsSurface,
1532 a: Vec2,
1533 b: Vec2,
1534 domain_u: [f64; 2],
1535 domain_v: [f64; 2],
1536 window: f64,
1537 band: f64,
1538) -> bool {
1539 const SAMPLES: usize = 8;
1540 let straddles = |first: f64, second: f64, low: f64, high: f64| {
1541 (first - low).abs() <= window && (second - high).abs() <= window
1542 || (first - high).abs() <= window && (second - low).abs() <= window
1543 };
1544 let isocurves_agree = |along_u: bool| {
1545 for index in 0..=SAMPLES {
1546 let fraction = index as f64 / SAMPLES as f64;
1547 let (first, second) = if along_u {
1548 let v = domain_v[0] + (domain_v[1] - domain_v[0]) * fraction;
1549 (
1550 surface.evaluate(domain_u[0], v),
1551 surface.evaluate(domain_u[1], v),
1552 )
1553 } else {
1554 let u = domain_u[0] + (domain_u[1] - domain_u[0]) * fraction;
1555 (
1556 surface.evaluate(u, domain_v[0]),
1557 surface.evaluate(u, domain_v[1]),
1558 )
1559 };
1560 let (Ok(first), Ok(second)) = (first, second) else {
1561 return false;
1562 };
1563 if first.sub(second).length() > band {
1564 return false;
1565 }
1566 }
1567 true
1568 };
1569 if straddles(a.x, b.x, domain_u[0], domain_u[1]) && isocurves_agree(true) {
1570 return true;
1571 }
1572 straddles(a.y, b.y, domain_v[0], domain_v[1]) && isocurves_agree(false)
1573}
1574
1575/// Is this same-face mesh crossing a genuine 3D FOLD?
1576///
1577/// The face-versus-face straddle test cannot be asked here: both triangles lie
1578/// on one carrier, so every corner of both is at distance zero from the
1579/// surface it would be measured against. The question asked instead is the
1580/// parameter one, and it is the definition of a fold — the surface carries two
1581/// DISTINCT trimmed parameters to ONE point in space:
1582///
1583/// 1. **Enumerate the branches.** Every grid node within one cell's reach of
1584/// the crossing point is a seed ([`FoldGrid`] says why this is an
1585/// enumeration and not a search).
1586/// 2. **Refine each**, through the Newton that does NOT re-seed globally, and
1587/// keep the feet that actually reach the point within the mesh's own chord
1588/// band. Two seeds on ONE branch refine to one foot and are merged, which
1589/// is what chord error on a tightly curved face produces — so a face that
1590/// does not fold ends this step with a single foot and is rejected.
1591/// 3. **Both feet inside the trim.** An untrimmed carrier may fold wherever it
1592/// likes; only a fold the FACE carries is its defect.
1593/// 4. **The feet are TRANSVERSAL.** Distinct parameters carried to one point
1594/// with ONE tangent plane is a degenerate parameterization — a sphere's
1595/// pole, a seam — and not a fold.
1596///
1597/// The widest-separated qualifying pair is reported, with every margin it
1598/// cleared.
1599fn confirm_fold(
1600 face: &crate::topology::FaceRecord,
1601 grid: &FoldGrid,
1602 point: Vec3,
1603 options: SelfIntersectionOptions,
1604) -> FoldVerdict {
1605 let surface = &face.surface;
1606 let (Ok((closed_u, closed_v)), Ok(domain_u), Ok(domain_v)) = (
1607 surface.closed_directions(),
1608 surface.domain_u(),
1609 surface.domain_v(),
1610 ) else {
1611 fold_trace!("fold face {}: domain/closedness unreadable", face.id);
1612 return FoldVerdict::Undecided;
1613 };
1614 let span_u = domain_u[1] - domain_u[0];
1615 let span_v = domain_v[1] - domain_v[0];
1616 let period = [closed_u.then_some(span_u), closed_v.then_some(span_v)];
1617 // A tessellation vertex lies exactly on its surface and the crossing point
1618 // is within the chord tolerance of one, so this is the band every 3D
1619 // residual here is held to: the mesh's own accuracy.
1620 let coincidence = 2.0 * options.chord_tolerance;
1621
1622 let window = grid.reach + coincidence;
1623 let mut seeds: Vec<(f64, usize)> = grid
1624 .samples
1625 .iter()
1626 .enumerate()
1627 .filter_map(|(index, (_, _, sample))| {
1628 let distance = sample.sub(point).length();
1629 (distance <= window).then_some((distance, index))
1630 })
1631 .collect();
1632 if seeds.is_empty() {
1633 fold_trace!(
1634 "fold face {}: no grid node within {window:.3e} of the crossing point",
1635 face.id
1636 );
1637 return FoldVerdict::Undecided;
1638 }
1639 seeds.sort_by(|a, b| a.0.total_cmp(&b.0));
1640
1641 let mut feet: Vec<crate::projection::SurfaceProjection> = Vec::new();
1642 let mut used: Vec<Vec2> = Vec::new();
1643 for (_, index) in seeds {
1644 if feet.len() >= MAX_FOLD_SEEDS {
1645 break;
1646 }
1647 let (u, v, _) = grid.samples[index];
1648 let seed = Vec2 { x: u, y: v };
1649 if used
1650 .iter()
1651 .any(|taken| parameter_separation(*taken, seed, period) <= grid.merge)
1652 {
1653 continue;
1654 }
1655 used.push(seed);
1656 let Ok(foot) = project_point_to_surface_seeded(surface, point, u, v) else {
1657 continue;
1658 };
1659 if !(foot.distance <= coincidence) {
1660 continue;
1661 }
1662 let landed = Vec2 {
1663 x: foot.u,
1664 y: foot.v,
1665 };
1666 if feet.iter().any(|other| {
1667 parameter_separation(Vec2 { x: other.u, y: other.v }, landed, period) <= grid.merge
1668 }) {
1669 continue;
1670 }
1671 feet.push(foot);
1672 }
1673 if feet.len() < 2 {
1674 fold_trace!(
1675 "fold face {}: {} distinct foot/feet at the crossing point (merge {:.3e}) — one foot is not a fold",
1676 face.id,
1677 feet.len(),
1678 grid.merge
1679 );
1680 return FoldVerdict::Rejected;
1681 }
1682
1683 // The uv coincidence band the trim is asked with, keyed to the face's own
1684 // parameter extent exactly as `loop_self_crossings` keys its weld: uv is
1685 // not a length, and a domain is as likely to be [0, 1] as [0, 2*PI]. A foot
1686 // ON the trim boundary counts as inside — a fold that reaches the boundary
1687 // is still a fold.
1688 let trim_band = UV_WELD_FRACTION * (span_u.abs() + span_v.abs()).max(1.0);
1689 // Each kept foot with whether it sits ON the trim boundary. Two feet
1690 // that both sit on the boundary are the TRIM's: a seam the face declares
1691 // by its loops, or the two arms of a slit a carved wall leaves along its
1692 // crease (the closed-edge lane's carve cuts the fold's lens out of the
1693 // trim and keeps one crease edge, used twice, as the hole's boundary —
1694 // the surface's two sheets meet there by construction). Such a pair
1695 // carries one point on two parameters exactly as a fold does, and is not
1696 // one: 2026-10-17, `fillet-mouth-over-wall-bore-5p5-r2p2`, convicted
1697 // under a coarser rim mesh whose triangles crossed the slit. A fold that
1698 // reaches the boundary from INSIDE is still a fold (one foot inside).
1699 let mut inside: Vec<(&crate::projection::SurfaceProjection, Vec3, bool)> = Vec::new();
1700 for foot in &feet {
1701 let uv = Vec2 {
1702 x: foot.u,
1703 y: foot.v,
1704 };
1705 let on_boundary = match parameter_point_in_face(face, uv, trim_band) {
1706 Ok(PolygonClass::Outside) => continue,
1707 Ok(PolygonClass::Boundary) => true,
1708 Ok(PolygonClass::Inside) => false,
1709 Err(message) => {
1710 fold_trace!("fold face {}: containment: {message}", face.id);
1711 return FoldVerdict::Undecided;
1712 }
1713 };
1714 let Ok(normal) = surface.normal(foot.u, foot.v).and_then(|normal| normal.normalized())
1715 else {
1716 fold_trace!("fold face {}: a normal could not be evaluated", face.id);
1717 return FoldVerdict::Undecided;
1718 };
1719 inside.push((foot, normal, on_boundary));
1720 }
1721 if inside.len() < 2 {
1722 fold_trace!(
1723 "fold face {}: {} of {} feet are inside the trim",
1724 face.id,
1725 inside.len(),
1726 feet.len()
1727 );
1728 return FoldVerdict::Rejected;
1729 }
1730
1731 let mut chosen: Option<(f64, usize, usize, f64)> = None;
1732 let mut worst_sine = 0.0f64;
1733 let mut identified = 0usize;
1734 let mut trim_pairs = 0usize;
1735 for first in 0..inside.len() {
1736 for second in (first + 1)..inside.len() {
1737 if inside[first].2 && inside[second].2 {
1738 trim_pairs += 1;
1739 continue;
1740 }
1741 // Two feet with the TRIM between them — the uv segment joining
1742 // them leaves the face — are the two sheets either side of a
1743 // slit or a hole, within the coincidence band of each other
1744 // because they meet along it; a fold's two feet are joined
1745 // through the material that folds. (The carved wall again: a
1746 // mesh triangle spanning the slit puts one foot a few 1e-5 of u
1747 // to each side of the crease, 1.1e-5 apart in 3D.)
1748 {
1749 let (a, b) = (inside[first].0, inside[second].0);
1750 let separated = (1..8).any(|k| {
1751 let s = k as f64 / 8.0;
1752 let uv = Vec2 { x: a.u + (b.u - a.u) * s, y: a.v + (b.v - a.v) * s };
1753 matches!(parameter_point_in_face(face, uv, trim_band), Ok(PolygonClass::Outside))
1754 });
1755 if separated {
1756 trim_pairs += 1;
1757 continue;
1758 }
1759 }
1760 let sine = inside[first].1.cross(inside[second].1).length();
1761 worst_sine = worst_sine.max(sine);
1762 if sine <= FOLD_TRANSVERSAL_SINE {
1763 continue;
1764 }
1765 // A SEAM the surface does not declare. `closed_directions()` is an
1766 // exact test, and an imported carrier whose two boundary isocurves
1767 // are 2.5e-2 apart fails it while still being one locus reached
1768 // from both ends of the domain — so the period reduction above did
1769 // not fold these two feet together and they read a full span
1770 // apart. Measured rather than assumed: the two isocurves are
1771 // sampled and compared.
1772 if boundary_identified(
1773 surface,
1774 Vec2 { x: inside[first].0.u, y: inside[first].0.v },
1775 Vec2 { x: inside[second].0.u, y: inside[second].0.v },
1776 domain_u,
1777 domain_v,
1778 6.0 * grid.merge,
1779 coincidence,
1780 ) {
1781 identified += 1;
1782 continue;
1783 }
1784 let separation = parameter_separation(
1785 Vec2 { x: inside[first].0.u, y: inside[first].0.v },
1786 Vec2 { x: inside[second].0.u, y: inside[second].0.v },
1787 period,
1788 );
1789 if chosen.is_none_or(|(best, _, _, _)| separation > best) {
1790 chosen = Some((separation, first, second, sine));
1791 }
1792 }
1793 }
1794 let Some((separation, first, second, sine)) = chosen else {
1795 fold_trace!(
1796 "fold face {}: no pair of feet is a fold (widest sine {worst_sine:.3e}, {identified} pair(s) are one locus across an undeclared seam, {trim_pairs} pair(s) both on the trim's own boundary) — a degenerate parameterization or the trim's own seam, not a fold",
1797 face.id
1798 );
1799 return FoldVerdict::Rejected;
1800 };
1801 let (foot_a, normal_a, _) = inside[first];
1802 let (foot_b, normal_b, _) = inside[second];
1803 let angle = sine.atan2(normal_a.dot(normal_b));
1804 fold_trace!(
1805 "fold face {}: CONFIRMED feet ({:.6}, {:.6}) and ({:.6}, {:.6}) separation {separation:.3e} vs merge {:.3e} residual {:.3e} (feet {:.3e}/{:.3e} from the point, band {coincidence:.3e}) normals {:.2}deg",
1806 face.id,
1807 foot_a.u,
1808 foot_a.v,
1809 foot_b.u,
1810 foot_b.v,
1811 grid.merge,
1812 foot_a.point.sub(foot_b.point).length(),
1813 foot_a.distance,
1814 foot_b.distance,
1815 angle.to_degrees()
1816 );
1817
1818 FoldVerdict::Confirmed(FaceFold {
1819 face: face.id,
1820 face_name: face.name.clone(),
1821 uv_a: [foot_a.u, foot_a.v],
1822 uv_b: [foot_b.u, foot_b.v],
1823 point: foot_a.point.add(foot_b.point).scale(0.5),
1824 uv_separation: separation,
1825 uv_band: grid.merge,
1826 residual: foot_a.point.sub(foot_b.point).length(),
1827 normal_angle: angle,
1828 })
1829}
1830
1831/// Map every mesh vertex to a representative index, welding positions that
1832/// coincide within `weld`. Bucketed on a `weld`-sized lattice with the 27
1833/// neighbouring cells consulted, so a pair straddling a cell boundary still
1834/// welds.
1835fn weld_positions(positions: &[f64], weld: f64) -> Vec<u32> {
1836 let count = positions.len() / 3;
1837 let mut representative = vec![0u32; count];
1838 let cell = weld.max(f64::MIN_POSITIVE);
1839 let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::default();
1840 for index in 0..count {
1841 let base = index * 3;
1842 let point = Vec3::new(positions[base], positions[base + 1], positions[base + 2]);
1843 let key = (
1844 (point.x / cell).floor() as i64,
1845 (point.y / cell).floor() as i64,
1846 (point.z / cell).floor() as i64,
1847 );
1848 let mut found = None;
1849 'search: for dx in -1..=1 {
1850 for dy in -1..=1 {
1851 for dz in -1..=1 {
1852 let Some(list) = buckets.get(&(key.0 + dx, key.1 + dy, key.2 + dz)) else {
1853 continue;
1854 };
1855 for candidate in list {
1856 let base = *candidate as usize * 3;
1857 let other = Vec3::new(
1858 positions[base],
1859 positions[base + 1],
1860 positions[base + 2],
1861 );
1862 if other.sub(point).length() <= weld {
1863 found = Some(representative[*candidate as usize]);
1864 break 'search;
1865 }
1866 }
1867 }
1868 }
1869 }
1870 let value = found.unwrap_or(index as u32);
1871 representative[index] = value;
1872 buckets.entry(key).or_default().push(index as u32);
1873 }
1874 representative
1875}
1876
1877/// Signed distances of a triangle's corners to `surface`, reduced to the
1878/// smaller of the two one-sided margins: positive means the corners genuinely
1879/// sit on both sides of the carrier by at least that much, zero means they do
1880/// not straddle it at all. `None` when a projection or a normal could not be
1881/// evaluated — never a verdict.
1882///
1883/// Three projections, and only for a triangle pair that already survived the
1884/// BVH, the welded-vertex skip and the mesh crossing test.
1885fn straddle(surface: &NurbsSurface, triangle: &[Vec3; 3], band: f64) -> Option<f64> {
1886 let mut above = 0.0f64;
1887 let mut below = 0.0f64;
1888 for corner in triangle {
1889 let projection = project_point_to_surface(surface, *corner).ok()?;
1890 if projection.distance <= band {
1891 continue;
1892 }
1893 let normal = surface.normal(projection.u, projection.v).ok()?;
1894 let normal = normal.normalized().ok()?;
1895 let signed = corner.sub(projection.point).dot(normal);
1896 if signed > 0.0 {
1897 above = above.max(signed);
1898 } else {
1899 below = below.max(-signed);
1900 }
1901 }
1902 Some(above.min(below))
1903}
1904
1905/// `BREP_SCAN_NODE_MEMBERSHIP=0` restores the pre-2026-10-03 verdict, where a
1906/// tessellation node's side of the other carrier counted whether or not the
1907/// node lay inside its own face's trim. Diagnostic A/B hatch, not a policy.
1908fn node_membership_enabled() -> bool {
1909 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1910 *ON.get_or_init(|| std::env::var("BREP_SCAN_NODE_MEMBERSHIP").as_deref() != Ok("0"))
1911}
1912
1913/// Is `point` a point `face` OWNS: on the face's carrier, inside (or on) its
1914/// trim? `Some(false)` when the carrier foot classifies OUTSIDE the trim;
1915/// `None` when the projection or the classification could not be evaluated
1916/// — never a verdict, exactly as [`across_crossing`] treats its samples. A
1917/// foot ON the trim counts as the face's material. The band is
1918/// [`TRIM_SAMPLE_TOLERANCE`], the same the across-samples are asked with.
1919///
1920/// Why this question exists: a watertight tessellation's interior node is an
1921/// exact point of the carrier but need not be a point of the FACE — on a
1922/// highly curved chart the trim boundary between two boundary samples bows
1923/// away from the UV chord, and nodes land past the trim. Their signed
1924/// distances to another carrier then read a straddle the body does not have.
1925fn node_material(face: &FaceRecord, point: Vec3, band: f64) -> Option<bool> {
1926 if !node_membership_enabled() {
1927 return Some(true);
1928 }
1929 let projection = project_point_to_surface(&face.surface, point).ok()?;
1930 // A finite carrier clamps a point past its domain onto its boundary, and
1931 // the trim then reads that boundary foot as the face's: a point the
1932 // carrier does not reach within `band` is not the face's material.
1933 if projection.distance > band {
1934 return Some(false);
1935 }
1936 match parameter_point_in_face(face, Vec2 { x: projection.u, y: projection.v }, TRIM_SAMPLE_TOLERANCE) {
1937 Ok(PolygonClass::Outside) => Some(false),
1938 Ok(_) => Some(true),
1939 Err(_) => None,
1940 }
1941}
1942
1943/// Does `face`'s trim OWN the spot under `point`: the carrier FOOT of the
1944/// point classifies inside or on the trim? No reach test — a mesh crossing's
1945/// midpoint is a point of two chords, off a curved carrier by the chord's
1946/// sagitta even at a genuine crossing (the oblique bore-mouth control read 0
1947/// confirmed with a reach test here, 2026-10-03), so the question for a
1948/// witness is only whether the faces' trims cover where it lands. `None` when
1949/// the projection or the classification cannot be evaluated.
1950fn footprint_material(face: &FaceRecord, point: Vec3) -> Option<bool> {
1951 if !node_membership_enabled() {
1952 return Some(true);
1953 }
1954 let projection = project_point_to_surface(&face.surface, point).ok()?;
1955 let (closed_u, closed_v) = face.surface.closed_directions().ok()?;
1956 let domains = [face.surface.domain_u().ok()?, face.surface.domain_v().ok()?];
1957 let parameters = [projection.u, projection.v];
1958 let closed = [closed_u, closed_v];
1959 // A closest point constrained to a finite chart boundary is not an
1960 // unconstrained carrier foot when the residual has a tangential component.
1961 // Keep that witness undecided. Pure normal displacement (chord sagitta)
1962 // does not disqualify a foot, including a legitimate foot on the boundary.
1963 // The only spatial floor here is the existing trim-sample tolerance.
1964 for axis in 0..2 {
1965 if closed[axis] {
1966 continue;
1967 }
1968 let [first, last] = domains[axis];
1969 let roundoff = 32.0 * f64::EPSILON * first.abs().max(last.abs()).max(last - first);
1970 if parameters[axis] > first + roundoff && parameters[axis] < last - roundoff {
1971 continue;
1972 }
1973 let derivatives = face.surface.derivatives(projection.u, projection.v, 1).ok()?;
1974 let tangent = if axis == 0 { derivatives[1][0] } else { derivatives[0][1] };
1975 let tangent = tangent.normalized().ok()?;
1976 let tangential_residual = point.sub(projection.point).dot(tangent);
1977 if !tangential_residual.is_finite() || tangential_residual.abs() > TRIM_SAMPLE_TOLERANCE {
1978 return None;
1979 }
1980 }
1981 match parameter_point_in_face(face, Vec2 { x: projection.u, y: projection.v }, TRIM_SAMPLE_TOLERANCE) {
1982 Ok(PolygonClass::Outside) => Some(false),
1983 Ok(_) => Some(true),
1984 Err(_) => None,
1985 }
1986}
1987
1988/// [`straddle`] over the corners `own_face` OWNS: a corner outside its own
1989/// trim ([`node_material`] `Some(false)`) contributes no side; a corner whose
1990/// membership cannot be evaluated makes the whole reading `None`. With every
1991/// corner a member this is exactly [`straddle`].
1992fn straddle_material(
1993 own_face: &FaceRecord,
1994 other: &NurbsSurface,
1995 triangle: &[Vec3; 3],
1996 band: f64,
1997) -> Option<f64> {
1998 let mut above = 0.0f64;
1999 let mut below = 0.0f64;
2000 for corner in triangle {
2001 match node_material(own_face, *corner, band) {
2002 Some(true) => {}
2003 Some(false) => {
2004 pierce_trace!(
2005 " corner ({:.6}, {:.6}, {:.6}) of face {} lies OUTSIDE its own trim; its side is not read",
2006 corner.x, corner.y, corner.z, own_face.id
2007 );
2008 continue;
2009 }
2010 None => return None,
2011 }
2012 let projection = project_point_to_surface(other, *corner).ok()?;
2013 if projection.distance <= band {
2014 continue;
2015 }
2016 let normal = other.normal(projection.u, projection.v).ok()?;
2017 let normal = normal.normalized().ok()?;
2018 let signed = corner.sub(projection.point).dot(normal);
2019 if signed > 0.0 {
2020 above = above.max(signed);
2021 } else {
2022 below = below.max(-signed);
2023 }
2024 }
2025 Some(above.min(below))
2026}
2027
2028/// Which confirmation convicted a [`FaceCrossing`].
2029///
2030/// Recorded so a sweep can attribute every hit to the question that caught it.
2031/// The two ask the same thing — does one face's surface sit on BOTH sides of
2032/// the other's carrier — of different sample points, and the sample points are
2033/// the whole difference between them.
2034#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
2035#[non_exhaustive]
2036pub enum CrossingConfirmation {
2037 /// `straddle`: the triangles' OWN CORNERS, in both directions.
2038 Corners,
2039 /// `pierce_straddle`: an edge of one triangle through the other's
2040 /// interior, with the samples taken at the pierce.
2041 Pierce,
2042 /// Neither of those decided it; the carriers did (`resolve.rs`): a point
2043 /// both faces own on their common curve, crossed transversally, straddled
2044 /// past the band both ways. Added 2026-10-16 (lane K2).
2045 Resolved,
2046}
2047
2048/// Signed distance from `point` to `surface`, positive on the side the
2049/// surface's normal points to. `None` when the projection or the normal could
2050/// not be evaluated — never a verdict.
2051fn signed_distance(surface: &NurbsSurface, point: Vec3) -> Option<f64> {
2052 let projection = project_point_to_surface(surface, point).ok()?;
2053 let normal = surface.normal(projection.u, projection.v).ok()?;
2054 let normal = normal.normalized().ok()?;
2055 Some(point.sub(projection.point).dot(normal))
2056}
2057
2058/// The SECOND confirmation, and the one a PLANAR face passing through a curved
2059/// one needs.
2060///
2061/// [`straddle`] reads a triangle's OWN CORNERS, and a planar face is
2062/// tessellated as the two triangles its rectangle needs. When such a face
2063/// passes through a cylinder every one of those corners is OUTSIDE the
2064/// cylinder, so the corner test reads a one-sided margin of zero and REJECTS a
2065/// body that demonstrably passes through itself: a 20-block whose `-X` wall is
2066/// pushed to `x = 6` through its own `r = 6` bore encloses 1669.026644711,
2067/// which is exactly the closed form that removes the bore's segment TWICE.
2068///
2069/// The same question is asked where the two triangles actually meet instead:
2070///
2071/// 1. An EDGE of one triangle passes through the INTERIOR of the other
2072/// (segment-triangle intersection, with a barycentric margin, so two
2073/// triangles meeting along a shared boundary are never a pierce).
2074/// 2. That edge's two ENDPOINTS straddle the pierced face's carrier — the same
2075/// signed-distance question [`straddle`] asks of a triangle's corners,
2076/// restricted to the two points that bracket the pierce.
2077/// 3. Two points ON the pierced triangle, either side of the two triangles'
2078/// crossing line and inside that triangle, straddle the PIERCING face's
2079/// carrier. This is the half the corner test cannot reach, and it is what
2080/// separates a face passing THROUGH another from one merely touching it: at
2081/// a tangency both samples sit on the SAME side, however deep the chords
2082/// interpenetrate.
2083///
2084/// Returns the smallest margin the confirmation cleared, or `None` when
2085/// neither direction confirmed.
2086fn pierce_straddle(
2087 first: &[Vec3; 3],
2088 second: &[Vec3; 3],
2089 face_first: &FaceRecord,
2090 face_second: &FaceRecord,
2091 crossing: (Vec3, Vec3),
2092 band: f64,
2093) -> PierceVerdict {
2094 pierce_trace!(" forward: an edge of the first triangle through the second");
2095 let forward = pierce_direction(first, second, face_first, face_second, crossing, band);
2096 pierce_trace!(" backward: an edge of the second triangle through the first");
2097 let backward = pierce_direction(second, first, face_second, face_first, crossing, band);
2098 match (forward, backward) {
2099 (PierceVerdict::Confirmed(a), PierceVerdict::Confirmed(b)) => {
2100 PierceVerdict::Confirmed(if a.margin() >= b.margin() { a } else { b })
2101 }
2102 (PierceVerdict::Confirmed(hit), _) | (_, PierceVerdict::Confirmed(hit)) => {
2103 PierceVerdict::Confirmed(hit)
2104 }
2105 // A projection or a normal that could not be evaluated is never a
2106 // verdict — the module's rule, and the same one the corner lane keeps.
2107 (PierceVerdict::Undecided, _) | (_, PierceVerdict::Undecided) => PierceVerdict::Undecided,
2108 _ => PierceVerdict::Rejected,
2109 }
2110}
2111
2112/// The pierce lane's two margins, both measured between EXACT surface points.
2113#[derive(Clone, Copy, Debug)]
2114struct PierceHit {
2115 /// How far the piercing mesh edge's two endpoints sit either side of the
2116 /// pierced face's carrier.
2117 bracket: f64,
2118 /// How far the pierced face's own SURFACE reaches either side of the
2119 /// piercing face's carrier, across the crossing line.
2120 penetration: f64,
2121}
2122
2123impl PierceHit {
2124 fn margin(&self) -> f64 {
2125 self.bracket.min(self.penetration)
2126 }
2127}
2128
2129enum PierceVerdict {
2130 Confirmed(PierceHit),
2131 Rejected,
2132 Undecided,
2133}
2134
2135/// One direction of [`pierce_straddle`]: an edge of `piercing` through
2136/// `pierced`'s interior. The widest margin over the three edges.
2137fn pierce_direction(
2138 piercing: &[Vec3; 3],
2139 pierced: &[Vec3; 3],
2140 face_piercing: &FaceRecord,
2141 face_pierced: &FaceRecord,
2142 crossing: (Vec3, Vec3),
2143 band: f64,
2144) -> PierceVerdict {
2145 let (surface_piercing, surface_pierced) = (&face_piercing.surface, &face_pierced.surface);
2146 let mut best: Option<PierceHit> = None;
2147 let mut undecided = false;
2148 for index in 0..3 {
2149 let p0 = piercing[index];
2150 let p1 = piercing[(index + 1) % 3];
2151 let Some(fraction) = segment_pierces_triangle(p0, p1, pierced) else {
2152 pierce_trace!(" edge {index}: does not pierce the other triangle's interior");
2153 continue;
2154 };
2155 let point = p0.add(p1.sub(p0).scale(fraction));
2156 pierce_trace!(
2157 " edge {index}: pierces at ({:.6}, {:.6}, {:.6}); bracket distances {:?} {:?}",
2158 point.x,
2159 point.y,
2160 point.z,
2161 signed_distance(surface_pierced, p0),
2162 signed_distance(surface_pierced, p1)
2163 );
2164 // The bracket is read from two tessellation nodes of the piercing
2165 // face; a node outside that face's own trim is not the face's
2166 // material, so its side says nothing about the body (2026-10-03). The
2167 // pair is then UNEVALUATED along this edge — never rejected, the same
2168 // rule the across-samples keep — and the trace records the readings.
2169 match (node_material(face_piercing, p0, band), node_material(face_piercing, p1, band)) {
2170 (Some(true), Some(true)) => {}
2171 (in_p0, in_p1) => {
2172 pierce_trace!(
2173 " edge {index}: bracket endpoints in their own face {}: {in_p0:?} {in_p1:?} — not read",
2174 face_piercing.id
2175 );
2176 undecided = true;
2177 continue;
2178 }
2179 }
2180 let bracket = match bracket_straddle(surface_pierced, p0, p1, band) {
2181 Margin::Cleared(value) => value,
2182 Margin::Failed => continue,
2183 Margin::Unevaluated => {
2184 undecided = true;
2185 continue;
2186 }
2187 };
2188 let penetration = match across_crossing(
2189 surface_piercing,
2190 face_pierced,
2191 pierced,
2192 point,
2193 crossing,
2194 band,
2195 ) {
2196 Margin::Cleared(value) => value,
2197 Margin::Failed => continue,
2198 Margin::Unevaluated => {
2199 undecided = true;
2200 continue;
2201 }
2202 };
2203 let hit = PierceHit {
2204 bracket,
2205 penetration,
2206 };
2207 if best.is_none_or(|value| hit.margin() > value.margin()) {
2208 best = Some(hit);
2209 }
2210 }
2211 match best {
2212 Some(hit) => PierceVerdict::Confirmed(hit),
2213 None if undecided => PierceVerdict::Undecided,
2214 None => PierceVerdict::Rejected,
2215 }
2216}
2217
2218/// One margin the pierce lane asked for: cleared with a value, genuinely not
2219/// cleared, or not evaluable at all. The third is never a verdict.
2220enum Margin {
2221 Cleared(f64),
2222 Failed,
2223 Unevaluated,
2224}
2225
2226/// Where the segment `p0 -> p1` passes through `triangle`'s INTERIOR, as the
2227/// fraction along the segment. `None` when it misses, when it is parallel to
2228/// the triangle's plane, or when it crosses within `INTERIOR_MARGIN` of the
2229/// triangle's boundary — a hit on the boundary is two triangles MEETING, which
2230/// every watertight tessellation does along every edge.
2231fn segment_pierces_triangle(p0: Vec3, p1: Vec3, triangle: &[Vec3; 3]) -> Option<f64> {
2232 /// Barycentric margin, as a fraction of the triangle.
2233 const INTERIOR_MARGIN: f64 = 1e-6;
2234 let edge_one = triangle[1].sub(triangle[0]);
2235 let edge_two = triangle[2].sub(triangle[0]);
2236 let direction = p1.sub(p0);
2237 let cross = direction.cross(edge_two);
2238 let determinant = edge_one.dot(cross);
2239 if determinant.abs() <= 1e-18 {
2240 return None; // parallel to the triangle's plane
2241 }
2242 let inverse = 1.0 / determinant;
2243 let to_corner = p0.sub(triangle[0]);
2244 let u = to_corner.dot(cross) * inverse;
2245 let second_cross = to_corner.cross(edge_one);
2246 let v = direction.dot(second_cross) * inverse;
2247 if u <= INTERIOR_MARGIN || v <= INTERIOR_MARGIN || u + v >= 1.0 - INTERIOR_MARGIN {
2248 return None;
2249 }
2250 let fraction = edge_two.dot(second_cross) * inverse;
2251 if fraction <= 0.0 || fraction >= 1.0 {
2252 return None;
2253 }
2254 Some(fraction)
2255}
2256
2257/// Do `p0` and `p1` sit on OPPOSITE sides of `surface`, each by more than
2258/// `band`? The smaller of the two margins, or `None`.
2259fn bracket_straddle(surface: &NurbsSurface, p0: Vec3, p1: Vec3, band: f64) -> Margin {
2260 let (Some(first), Some(second)) = (
2261 signed_distance(surface, p0),
2262 signed_distance(surface, p1),
2263 ) else {
2264 return Margin::Unevaluated;
2265 };
2266 if first.abs() <= band || second.abs() <= band || (first > 0.0) == (second > 0.0) {
2267 return Margin::Failed;
2268 }
2269 Margin::Cleared(first.abs().min(second.abs()))
2270}
2271
2272/// The boundary band [`parameter_point_in_face`] is asked with here, so that a
2273/// sample sitting ON the trim reads as the face's material rather than outside
2274/// it. The comparand is the same containment band the imprint's own window
2275/// guard uses (`restricted_carrier`'s wrap sample, `1e-9`) — the tightest in
2276/// the kernel, since the question is "is this spot the face's at all", not "how
2277/// far may it stray". The overshoot this test exists to catch is four orders
2278/// wider (2e-5 on the helmet's turning point).
2279const TRIM_SAMPLE_TOLERANCE: f64 = 1e-9;
2280
2281/// `BREP_PIERCE_TRIM_SAMPLES=0` restores the pre-2026-09-16 verdict, where an
2282/// across-sample was taken from the triangle without asking the face whether
2283/// it owns that spot.
2284fn trim_samples_enabled() -> bool {
2285 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2286 *ON.get_or_init(|| std::env::var("BREP_PIERCE_TRIM_SAMPLES").as_deref() != Ok("0"))
2287}
2288
2289/// Does `triangle` sit on BOTH sides of `surface`, either side of the crossing
2290/// line through `point`?
2291///
2292/// The two samples are taken on the triangle, stepping perpendicular to the
2293/// crossing line, half way to the triangle's boundary in each direction, and
2294/// then put back onto the face's OWN carrier so the margin is a property of
2295/// the two surfaces rather than of the mesh. A face passing THROUGH the
2296/// carrier puts them on opposite sides; a face TOUCHING it puts both on the
2297/// same one, which is the rejection this test exists to make.
2298///
2299/// A TRIANGLE IS NOT THE FACE'S MATERIAL. Its corners lie on the trim, but the
2300/// boundary is approximated by chords, and where the boundary turns sharply
2301/// the chord bows to the outside: the triangle then covers a sliver the face
2302/// does not own. Measured on `20_helmet_merge_stage` b − a, at the turning
2303/// point of the helmet `Face_15` × cutter-plane section, a boundary chord of
2304/// 6.4e-4 stood 2e-5 outside its own trim, and a sample there read 1.72e-6
2305/// through the other carrier against a band of 2.2e-7: a confirmed crossing on
2306/// geometry no face carries. So each sample is asked of the pierced face's own
2307/// trim, and a sample outside it leaves the pair UNEVALUATED — never rejected,
2308/// because a genuine crossing whose sample happened to land outside would then
2309/// read clean. Escape hatch `BREP_PIERCE_TRIM_SAMPLES=0`.
2310fn across_crossing(
2311 surface: &NurbsSurface,
2312 own_face: &FaceRecord,
2313 triangle: &[Vec3; 3],
2314 point: Vec3,
2315 crossing: (Vec3, Vec3),
2316 band: f64,
2317) -> Margin {
2318 let own = &own_face.surface;
2319 let along = crossing.1.sub(crossing.0);
2320 let (Ok(along), Some((normal, _))) = (along.normalized(), triangle_plane(triangle)) else {
2321 return Margin::Unevaluated;
2322 };
2323 let Ok(across) = normal.cross(along).normalized() else {
2324 return Margin::Unevaluated;
2325 };
2326 // How far the samples may step before leaving the triangle: the three
2327 // inward half-planes of its own edges, clipped against the ray.
2328 let mut low = f64::NEG_INFINITY;
2329 let mut high = f64::INFINITY;
2330 for index in 0..3 {
2331 let a = triangle[index];
2332 let b = triangle[(index + 1) % 3];
2333 let opposite = triangle[(index + 2) % 3];
2334 let mut inward = normal.cross(b.sub(a));
2335 if inward.dot(opposite.sub(a)) < 0.0 {
2336 inward = inward.scale(-1.0);
2337 }
2338 let slope = inward.dot(across);
2339 let offset = inward.dot(point.sub(a));
2340 if slope.abs() <= 1e-18 {
2341 if offset < 0.0 {
2342 return Margin::Failed;
2343 }
2344 continue;
2345 }
2346 let bound = -offset / slope;
2347 if slope > 0.0 {
2348 low = low.max(bound);
2349 } else {
2350 high = high.min(bound);
2351 }
2352 }
2353 if !(low < 0.0) || !(high > 0.0) || !low.is_finite() || !high.is_finite() {
2354 pierce_trace!(" across: no room either side of the crossing (reach {low:.6}..{high:.6})");
2355 return Margin::Failed;
2356 }
2357 // Both samples sit on the pierced TRIANGLE, which is a chord of its own
2358 // face and up to the tessellation's chord tolerance off the surface it
2359 // approximates — on an imported model that tolerance is 1.5e-3 of the
2360 // model's extent, orders above the band this margin has to clear. Put each
2361 // sample back ON its own carrier before asking the other surface about it,
2362 // so what is measured is a property of the two SURFACES rather than of how
2363 // coarsely one of them was meshed. On a planar face this is the identity.
2364 let hold_to_trim = trim_samples_enabled();
2365 let samples = [low * 0.5, high * 0.5].map(|step| {
2366 let projection = project_point_to_surface(own, point.add(across.scale(step))).ok();
2367 let sample = projection.as_ref().map(|projection| projection.point);
2368 let material = match (&projection, hold_to_trim) {
2369 (Some(projection), true) => !matches!(
2370 parameter_point_in_face(
2371 own_face,
2372 Vec2 { x: projection.u, y: projection.v },
2373 TRIM_SAMPLE_TOLERANCE,
2374 ),
2375 Ok(PolygonClass::Outside)
2376 ),
2377 _ => true,
2378 };
2379 let reading = sample.and_then(|sample| signed_distance(surface, sample));
2380 if let Some(sample) = sample {
2381 pierce_trace!(
2382 " across step {step:.6} (reach {low:.6}..{high:.6}): sample ({:.6}, {:.6}, {:.6}) reads {reading:?}{}",
2383 sample.x,
2384 sample.y,
2385 sample.z,
2386 if material { "" } else { " OUTSIDE its own trim" }
2387 );
2388 }
2389 material.then_some(reading).flatten()
2390 });
2391 let [Some(below), Some(above)] = samples else {
2392 return Margin::Unevaluated;
2393 };
2394 if below.abs() <= band || above.abs() <= band || (below > 0.0) == (above > 0.0) {
2395 return Margin::Failed;
2396 }
2397 Margin::Cleared(below.abs().min(above.abs()))
2398}
2399
2400/// The segment along which two triangles cross, or `None` when they do not
2401/// cross transversally. Coplanar pairs return `None` — a coplanar overlap is a
2402/// different defect and this detector does not claim it.
2403fn triangle_crossing(first: &[Vec3; 3], second: &[Vec3; 3]) -> Option<(Vec3, Vec3)> {
2404 let plane_first = triangle_plane(first)?;
2405 let plane_second = triangle_plane(second)?;
2406 let direction = plane_first.0.cross(plane_second.0);
2407 let length = direction.length();
2408 if length <= 1e-12 {
2409 return None; // parallel or coplanar
2410 }
2411 let direction = direction.scale(1.0 / length);
2412
2413 let first_span = plane_span(first, plane_second, direction)?;
2414 let second_span = plane_span(second, plane_first, direction)?;
2415 let low = first_span.0.max(second_span.0);
2416 let high = first_span.1.min(second_span.1);
2417 if high < low {
2418 return None;
2419 }
2420 // Rebuild 3D points by interpolating the first triangle's own crossing
2421 // chord, so the returned segment is on the mesh rather than reconstructed
2422 // from a line equation.
2423 let span = first_span.1 - first_span.0;
2424 let lerp = |value: f64| {
2425 if span <= 0.0 {
2426 first_span.2
2427 } else {
2428 let fraction = ((value - first_span.0) / span).clamp(0.0, 1.0);
2429 first_span
2430 .2
2431 .add(first_span.3.sub(first_span.2).scale(fraction))
2432 }
2433 };
2434 Some((lerp(low), lerp(high)))
2435}
2436
2437/// Unit normal and plane offset of a triangle, `None` for a degenerate one.
2438fn triangle_plane(triangle: &[Vec3; 3]) -> Option<(Vec3, f64)> {
2439 let normal = triangle[1]
2440 .sub(triangle[0])
2441 .cross(triangle[2].sub(triangle[0]));
2442 let length = normal.length();
2443 if length <= 1e-18 {
2444 return None;
2445 }
2446 let normal = normal.scale(1.0 / length);
2447 Some((normal, -normal.dot(triangle[0])))
2448}
2449
2450/// Where `triangle` crosses `plane`, as `(low, high, low_point, high_point)`
2451/// parametrized by `point · direction`. `None` when the triangle does not
2452/// reach the plane.
2453fn plane_span(
2454 triangle: &[Vec3; 3],
2455 plane: (Vec3, f64),
2456 direction: Vec3,
2457) -> Option<(f64, f64, Vec3, Vec3)> {
2458 let distance = |point: Vec3| plane.0.dot(point) + plane.1;
2459 let distances = [
2460 distance(triangle[0]),
2461 distance(triangle[1]),
2462 distance(triangle[2]),
2463 ];
2464 if distances.iter().all(|value| *value > 0.0) || distances.iter().all(|value| *value < 0.0) {
2465 return None;
2466 }
2467 let mut points: Vec<Vec3> = Vec::with_capacity(3);
2468 for index in 0..3 {
2469 let next = (index + 1) % 3;
2470 if distances[index] == 0.0 {
2471 points.push(triangle[index]);
2472 }
2473 if (distances[index] < 0.0) != (distances[next] < 0.0)
2474 && distances[index] != 0.0
2475 && distances[next] != 0.0
2476 {
2477 let fraction = distances[index] / (distances[index] - distances[next]);
2478 points.push(
2479 triangle[index].add(triangle[next].sub(triangle[index]).scale(fraction)),
2480 );
2481 }
2482 }
2483 if points.len() < 2 {
2484 return None;
2485 }
2486 let mut low = (f64::INFINITY, Vec3::default());
2487 let mut high = (f64::NEG_INFINITY, Vec3::default());
2488 for point in points {
2489 let value = point.dot(direction);
2490 if value < low.0 {
2491 low = (value, point);
2492 }
2493 if value > high.0 {
2494 high = (value, point);
2495 }
2496 }
2497 Some((low.0, high.0, low.1, high.1))
2498}
2499
2500
2501// ---------------------------------------------------------------------------
2502// Euler parity
2503// ---------------------------------------------------------------------------
2504
2505/// Outcome of [`solid_euler`].
2506#[derive(Clone, Debug, Serialize)]
2507pub struct EulerReport {
2508 /// `V − E + F − H` over the reduced complex — the same count
2509 /// [`BrepSolid::validate`] makes, with degenerate (collapsed) cells left
2510 /// out of all four terms.
2511 pub characteristic: i64,
2512 pub shells: usize,
2513 /// `S − χ/2`, and `None` when `characteristic` is odd — there is no such
2514 /// surface, so there is no genus to report rather than a halved one.
2515 pub genus: Option<i64>,
2516 /// What the solid carries in its `genus` field.
2517 pub recorded_genus: i64,
2518 /// Non-degenerate edges used by FEWER than two coedges — a boundary edge,
2519 /// or an edge record no loop references at all. A closed surface has
2520 /// neither; a sheet or an in-progress shell legitimately has the first, and
2521 /// its characteristic is then unconstrained. Both are counted here because
2522 /// both mean the same thing for parity: these counts are not those of a
2523 /// closed surface, so decline to judge rather than convict.
2524 pub boundary_edges: usize,
2525 /// Non-degenerate edges used by three or more coedges — not a 2-manifold,
2526 /// so the characteristic means nothing either.
2527 pub non_manifold_edges: usize,
2528}
2529
2530impl EulerReport {
2531 /// Whether the counts describe a closed 2-manifold at all. Parity is only
2532 /// an invariant for one of those.
2533 pub fn is_closed_manifold(&self) -> bool {
2534 self.boundary_edges == 0 && self.non_manifold_edges == 0
2535 }
2536
2537 /// The HARD predicate: a CLOSED manifold whose Euler characteristic is odd.
2538 ///
2539 /// No closed orientable surface has one — χ = 2(S − G) is even by
2540 /// construction — so this is a statement about the record, not about a
2541 /// tolerance, and it cannot be tuned away. It is the one thing the Euler
2542 /// count can establish on its own: the genus itself has no second
2543 /// derivation from the cell complex to check against.
2544 pub fn is_unsound(&self) -> bool {
2545 self.is_closed_manifold() && self.characteristic % 2 != 0
2546 }
2547
2548 /// One line naming what is wrong, or `None`.
2549 pub fn summary(&self) -> Option<String> {
2550 if self.is_unsound() {
2551 return Some(format!(
2552 "Euler characteristic {} is ODD over {} shell(s); no closed orientable \
2553 surface has one, so these counts do not describe a solid \
2554 (recorded genus {})",
2555 self.characteristic, self.shells, self.recorded_genus
2556 ));
2557 }
2558 None
2559 }
2560}
2561
2562/// Closedness and Euler parity: `V − E + F − H` over the reduced complex, and
2563/// whether the counts can belong to a closed orientable surface at all.
2564///
2565/// Pure topology — ids and use counts, never a coordinate — so like
2566/// [`solid_connectivity`] it is tolerance-free and cannot be noisy.
2567///
2568/// This does NOT verify the genus. A genus has no second derivation from the
2569/// same cell complex: `genus = S − χ/2` IS the definition, so any recomputation
2570/// re-runs the same arithmetic. (The only independent route is geometric —
2571/// Gauss–Bonnet, `∮K dA` plus the edge and vertex terms — which is a different
2572/// project.) What parity establishes without a second source is that a body
2573/// whose characteristic is odd is not a closed orientable surface at all.
2574///
2575/// The characteristic is a TOTAL over shells, which is necessary but not
2576/// sufficient: two shells each odd sum to even and pass. Per-shell parity needs
2577/// edges attributed to shells, which `cross_shell_edges` makes ambiguous
2578/// exactly when it would matter.
2579pub fn solid_euler(solid: &BrepSolid) -> EulerReport {
2580 let degenerate: HashSet<u64> = solid
2581 .edges
2582 .iter()
2583 .filter(|edge| edge.degenerate)
2584 .map(|edge| edge.id)
2585 .collect();
2586 let mut uses: HashMap<u64, usize> = HashMap::default();
2587 for coedge in solid
2588 .shells
2589 .iter()
2590 .flat_map(|shell| &shell.faces)
2591 .flat_map(|face| &face.loops)
2592 .flat_map(|record| &record.coedges)
2593 {
2594 if !degenerate.contains(&coedge.edge_id) {
2595 *uses.entry(coedge.edge_id).or_insert(0) += 1;
2596 }
2597 }
2598 let mut boundary_edges = 0usize;
2599 let mut non_manifold_edges = 0usize;
2600 for edge in solid.edges.iter().filter(|edge| !edge.degenerate) {
2601 match uses.get(&edge.id).copied().unwrap_or(0) {
2602 2 => {}
2603 0 | 1 => boundary_edges += 1,
2604 _ => non_manifold_edges += 1,
2605 }
2606 }
2607
2608 let live: HashSet<u64> = solid
2609 .edges
2610 .iter()
2611 .filter(|edge| !edge.degenerate)
2612 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
2613 .collect();
2614 let vertices = solid
2615 .vertices
2616 .iter()
2617 .filter(|vertex| live.contains(&vertex.id))
2618 .count() as i64;
2619 let edges = solid.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
2620 let faces: i64 = solid.shells.iter().map(|shell| shell.faces.len() as i64).sum();
2621 let characteristic = vertices - edges + faces - solid.bounding_hole_count();
2622 let shells = solid.shells.len();
2623 EulerReport {
2624 characteristic,
2625 shells,
2626 genus: (characteristic % 2 == 0).then(|| shells as i64 - characteristic / 2),
2627 recorded_genus: solid.genus,
2628 boundary_edges,
2629 non_manifold_edges,
2630 }
2631}
2632
2633// ---------------------------------------------------------------------------
2634// Face-loop self-crossing
2635// ---------------------------------------------------------------------------
2636
2637/// Parametric exclusion at each chord's ends. A crossing this close to a
2638/// chord end is the shared vertex of two consecutive chords, not a crossing.
2639const CHORD_END_EXCLUSION: f64 = 1e-9;
2640
2641/// Sine of the angle below which two chords are treated as PARALLEL and no
2642/// crossing is computed. Scale-free: it is compared against the cross product
2643/// divided by both lengths, so a long chord pair and a short one are held to
2644/// the same angle. Collinear overlap — the two seam coedges of a periodic
2645/// face, traversed once each way — lands here and is not a crossing.
2646const CHORD_PARALLEL_SINE: f64 = 1e-12;
2647
2648/// Coincidence radius for chord ENDPOINTS, as a fraction of the face's own
2649/// parameter extent. Two chords whose ends meet share a vertex of the loop;
2650/// where that is a loop touching itself at a point it is a PINCH, which is
2651/// `ConnectivityReport::pinch_vertices`' question, not this one.
2652const UV_WELD_FRACTION: f64 = 1e-9;
2653
2654/// One place a face's trim loop crosses ITSELF in the face's parameter domain.
2655///
2656/// The two coedges are the loop's own — a bowtie between two different trims,
2657/// or one pcurve crossing itself, in which case they are the same coedge.
2658#[derive(Clone, Debug, Serialize)]
2659pub struct LoopCrossing {
2660 pub face: u64,
2661 pub face_name: Option<String>,
2662 pub loop_id: u64,
2663 /// The two crossing coedges, in loop order.
2664 pub coedge_a: u64,
2665 pub coedge_b: u64,
2666 /// The edges those coedges use — what a caller NAMES the defect by, since
2667 /// an edge survives the surgery that renumbers coedges.
2668 pub edge_a: u64,
2669 pub edge_b: u64,
2670 /// Where they cross, in the face's own domain (wrapped into it on a
2671 /// periodic direction, so it is a parameter the surface can be evaluated
2672 /// at rather than a covering-plane lift).
2673 pub uv: [f64; 2],
2674 /// The surface point there — the 3D place the face passes through itself.
2675 pub point: Vec3,
2676}
2677
2678/// Outcome of [`loop_self_crossings`].
2679#[derive(Clone, Debug, Serialize)]
2680pub struct LoopCrossingReport {
2681 /// Loops scanned.
2682 pub loops: usize,
2683 /// Chords tested — the sampled trim polygon's segments, summed over loops.
2684 pub chords: usize,
2685 /// One entry per crossing COEDGE PAIR per loop, first one found kept. The
2686 /// pair is the stable half: a denser sampling moves the crossing's `uv`
2687 /// without changing which two trims cross.
2688 ///
2689 /// Two kinds of crossing are deliberately NOT here, both because the two
2690 /// strands are the same curve twice rather than two parts of a boundary:
2691 ///
2692 /// * a crossing within `assembler_weld` of one of the loop's own VERTICES.
2693 /// Two trims arriving at one vertex are fitted independently, so each
2694 /// ends a fit-accuracy away from it and near the vertex they overlap.
2695 /// Measured over the case population on 2026-09-13, every such hit sat
2696 /// within 2.4e-7 of a vertex and the one true crossing sat 1.8e-1 from
2697 /// one — four orders of margin either side of the band, not a threshold
2698 /// decision. The cost is stated: a genuine bowtie whose crossing is
2699 /// inside that band of a vertex is not reported.
2700 /// * a crossing between two DIFFERENT coedges that use the SAME EDGE — a
2701 /// seam edge, which a periodic face's loop traverses once each way. The
2702 /// two are one 3D curve under two fits, so they cannot cross on the
2703 /// surface; where the fits wobble past each other they only look like
2704 /// it. A coedge against ITSELF is a different question and is asked.
2705 pub crossings: Vec<LoopCrossing>,
2706 /// Loops whose pcurves could not be evaluated, `face/loop: reason`. A scan
2707 /// that could not read a loop did not clear it, so a caller must not read
2708 /// an empty `crossings` beside a non-empty this as "no crossings".
2709 pub unreadable: Vec<String>,
2710}
2711
2712impl LoopCrossingReport {
2713 /// The predicate: some face's trim loop crosses itself.
2714 pub fn is_flagged(&self) -> bool {
2715 !self.crossings.is_empty()
2716 }
2717
2718 /// One line naming the first crossing, or `None` when nothing crossed.
2719 pub fn summary(&self) -> Option<String> {
2720 let first = self.crossings.first()?;
2721 Some(format!(
2722 "{} loop self-crossing(s); face {}{} loop {} crosses itself where edges {} and {} \
2723 meet at uv ({:.6}, {:.6}) = ({:.4}, {:.4}, {:.4})",
2724 self.crossings.len(),
2725 first.face,
2726 first
2727 .face_name
2728 .as_ref()
2729 .map(|name| format!(" '{name}'"))
2730 .unwrap_or_default(),
2731 first.loop_id,
2732 first.edge_a,
2733 first.edge_b,
2734 first.uv[0],
2735 first.uv[1],
2736 first.point.x,
2737 first.point.y,
2738 first.point.z
2739 ))
2740 }
2741}
2742
2743/// One sampled chord of a lifted trim loop.
2744#[derive(Clone, Copy)]
2745struct Chord {
2746 a: Vec2,
2747 b: Vec2,
2748}
2749
2750impl Chord {
2751 fn shifted(self, shift: Vec2) -> Self {
2752 Self {
2753 a: self.a.add(shift),
2754 b: self.b.add(shift),
2755 }
2756 }
2757
2758 fn direction(&self) -> Vec2 {
2759 self.b.sub(self.a)
2760 }
2761
2762 fn min_u(&self) -> f64 {
2763 self.a.x.min(self.b.x)
2764 }
2765
2766 fn max_u(&self) -> f64 {
2767 self.a.x.max(self.b.x)
2768 }
2769
2770 fn min_v(&self) -> f64 {
2771 self.a.y.min(self.b.y)
2772 }
2773
2774 fn max_v(&self) -> f64 {
2775 self.a.y.max(self.b.y)
2776 }
2777}
2778
2779/// One loop's sampled trim polygon, lifted into the covering plane.
2780struct Chain {
2781 /// `chords + 1` sample points. The last is the loop's closing vertex: the
2782 /// first again on a loop that closes in the domain, the first plus
2783 /// [`Chain::wind`] on one that winds the seam.
2784 points: Vec<Vec2>,
2785 /// The coedge each chord was sampled from.
2786 owner: Vec<usize>,
2787 /// End minus start — zero, or a whole number of periods.
2788 wind: Vec2,
2789}
2790
2791impl Chain {
2792 fn chords(&self) -> usize {
2793 self.owner.len()
2794 }
2795
2796 fn chord(&self, index: usize) -> Chord {
2797 Chord {
2798 a: self.points[index],
2799 b: self.points[index + 1],
2800 }
2801 }
2802
2803 /// Chain vertex `index`, continued past either end by one LAP of the
2804 /// loop's own wind — so the strand at the closing vertex still has the
2805 /// neighbour it has on the surface.
2806 fn vertex(&self, index: i64) -> Vec2 {
2807 let last = self.chords() as i64;
2808 if index < 0 {
2809 self.points[(index + last) as usize].sub(self.wind)
2810 } else if index > last {
2811 self.points[(index - last) as usize].add(self.wind)
2812 } else {
2813 self.points[index as usize]
2814 }
2815 }
2816}
2817
2818/// Which side of the directed chord `point` falls on: `1`, `-1`, or `0`
2819/// within `weld` of its line.
2820fn side_of(chord: &Chord, point: Vec2, weld: f64) -> i32 {
2821 let direction = chord.direction();
2822 let length = direction.length();
2823 if !(length > 0.0) {
2824 return 0;
2825 }
2826 let signed = (direction.x * (point.y - chord.a.y) - direction.y * (point.x - chord.a.x)) / length;
2827 if signed > weld {
2828 1
2829 } else if signed < -weld {
2830 -1
2831 } else {
2832 0
2833 }
2834}
2835
2836/// Where along `chord` the nearest point to `point` is, when `point` is within
2837/// `weld` of the chord at all.
2838fn along_chord(chord: &Chord, point: Vec2, weld: f64) -> Option<f64> {
2839 let direction = chord.direction();
2840 let length_squared = direction.dot(direction);
2841 if !(length_squared > 0.0) {
2842 return None;
2843 }
2844 let parameter = point.sub(chord.a).dot(direction) / length_squared;
2845 let foot = chord.a.add(direction.scale(parameter.clamp(0.0, 1.0)));
2846 (point.sub(foot).length() <= weld).then_some(parameter)
2847}
2848
2849/// Do two strands of the chain CROSS where they meet at one point, or does
2850/// the second merely touch the first and leave the way it came?
2851///
2852/// The four directions out of the shared vertex are compared by angle: the
2853/// strands cross exactly when they ALTERNATE around it — one of the second
2854/// strand's directions inside the sector the first sweeps and one outside.
2855///
2856/// A second-strand direction COLLINEAR with either of the first's — the same
2857/// way or the opposite way — declines instead of answering. That is not an
2858/// edge case: it is the standard periodic face. A cylinder's lateral loop
2859/// spans exactly one period, so the seam it runs UP at `u1` is the seam its
2860/// own period-shifted image runs DOWN, and the two strands lie along each
2861/// other at every seam vertex. Two curves that run along each other do not
2862/// cross there, and a rule that had to pick a side would pick one at every
2863/// seam in the corpus.
2864fn strands_cross(
2865 at: Vec2,
2866 first_before: Vec2,
2867 first_after: Vec2,
2868 second_before: Vec2,
2869 second_after: Vec2,
2870 weld: f64,
2871) -> bool {
2872 let angle = |point: Vec2| -> Option<f64> {
2873 let direction = point.sub(at);
2874 (direction.length() > weld).then(|| direction.y.atan2(direction.x))
2875 };
2876 let (Some(from), Some(to), Some(before), Some(after)) = (
2877 angle(first_before),
2878 angle(first_after),
2879 angle(second_before),
2880 angle(second_after),
2881 ) else {
2882 return false;
2883 };
2884 for second in [before, after] {
2885 for first in [from, to] {
2886 if (second - first).sin().abs() <= CHORD_PARALLEL_SINE {
2887 return false;
2888 }
2889 }
2890 }
2891 let sweep = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU);
2892 let span = sweep(from, to);
2893 let inside = |value: f64| {
2894 let offset = sweep(from, value);
2895 offset > 0.0 && offset < span
2896 };
2897 inside(before) != inside(after)
2898}
2899
2900/// Where chords `first` and `second` of one chain cross, with `second`
2901/// displaced by `shift` (zero for the loop against itself).
2902///
2903/// Three ways a chain crosses itself, and the last two are not exotic: a
2904/// symmetric bowtie puts the crossing exactly ON a sample of both strands,
2905/// because both pcurves are sampled at the same fractions of their own
2906/// domains. A detector that only tested for a crossing strictly inside both
2907/// chords would miss the very shape it is for.
2908fn chain_crossing(chain: &Chain, first: usize, second: usize, shift: Vec2, weld: f64) -> Option<Vec2> {
2909 let one = chain.chord(first);
2910 let other = chain.chord(second).shifted(shift);
2911
2912 // 1. Transversally, inside both.
2913 let a = one.direction();
2914 let b = other.direction();
2915 let scale = a.length() * b.length();
2916 let denominator = a.x * b.y - a.y * b.x;
2917 if scale > 0.0 && denominator.abs() > CHORD_PARALLEL_SINE * scale {
2918 let offset = other.a.sub(one.a);
2919 let t = (offset.x * b.y - offset.y * b.x) / denominator;
2920 let u = (offset.x * a.y - offset.y * a.x) / denominator;
2921 let interior = |value: f64| value > CHORD_END_EXCLUSION && value < 1.0 - CHORD_END_EXCLUSION;
2922 if interior(t) && interior(u) {
2923 return Some(one.a.add(a.scale(t)));
2924 }
2925 }
2926
2927 // 2. A chain VERTEX sitting inside the other chord: the strand crosses
2928 // there when the samples either side of it are on opposite sides.
2929 let through = |chord: &Chord, vertex: i64, displace: Vec2| -> Option<Vec2> {
2930 let point = chain.vertex(vertex).add(displace);
2931 let parameter = along_chord(chord, point, weld)?;
2932 if !(parameter > CHORD_END_EXCLUSION && parameter < 1.0 - CHORD_END_EXCLUSION) {
2933 return None;
2934 }
2935 let before = side_of(chord, chain.vertex(vertex - 1).add(displace), weld);
2936 let after = side_of(chord, chain.vertex(vertex + 1).add(displace), weld);
2937 (before != 0 && after != 0 && before != after).then_some(point)
2938 };
2939 if let Some(point) = through(&other, first as i64 + 1, Vec2 { x: 0.0, y: 0.0 }) {
2940 return Some(point);
2941 }
2942 if let Some(point) = through(&one, second as i64 + 1, shift) {
2943 return Some(point);
2944 }
2945
2946 // 3. The two strands meeting AT one shared vertex. Each chain vertex is
2947 // the END of exactly one chord, so testing end against end reports a
2948 // shared vertex once; end against START is the same point one chord
2949 // earlier and is reached as that pair's end-against-end.
2950 if one.b.sub(other.b).length() <= weld {
2951 let first_vertex = first as i64 + 1;
2952 let second_vertex = second as i64 + 1;
2953 if strands_cross(
2954 one.b,
2955 chain.vertex(first_vertex - 1),
2956 chain.vertex(first_vertex + 1),
2957 chain.vertex(second_vertex - 1).add(shift),
2958 chain.vertex(second_vertex + 1).add(shift),
2959 weld,
2960 ) {
2961 return Some(one.b);
2962 }
2963 }
2964 None
2965}
2966
2967/// Offer every chord pair whose boxes overlap — `first` against `second`, or
2968/// against itself when `second` is `None` — by a sweep over sorted
2969/// u-intervals, so a thousand-chord loop is not a million chord tests.
2970fn sweep_chords(
2971 first: &[Chord],
2972 second: Option<&[Chord]>,
2973 mut offer: impl FnMut(usize, usize),
2974) {
2975 let mut order: Vec<(f64, bool, usize)> = first
2976 .iter()
2977 .enumerate()
2978 .map(|(index, chord)| (chord.min_u(), false, index))
2979 .collect();
2980 if let Some(second) = second {
2981 order.extend(
2982 second
2983 .iter()
2984 .enumerate()
2985 .map(|(index, chord)| (chord.min_u(), true, index)),
2986 );
2987 }
2988 order.sort_by(|a, b| a.0.total_cmp(&b.0));
2989
2990 let mut active_first: Vec<usize> = Vec::new();
2991 let mut active_second: Vec<usize> = Vec::new();
2992 for (sweep_u, is_second, index) in order {
2993 let chord = if is_second {
2994 second.expect("second set indexed")[index]
2995 } else {
2996 first[index]
2997 };
2998 // Lazy deletion: a chord whose u-interval ended before this one starts
2999 // can never meet anything from here on.
3000 active_first.retain(|&other| first[other].max_u() >= sweep_u);
3001 if let Some(second) = second {
3002 active_second.retain(|&other| second[other].max_u() >= sweep_u);
3003 }
3004 // A chord is tested against the ACTIVE chords of the OTHER set, or
3005 // against its own when there is no other. Either way a pair is
3006 // offered exactly once.
3007 let (candidates, pool): (&[usize], &[Chord]) = match (second, is_second) {
3008 (None, _) => (&active_first, first),
3009 (Some(second), false) => (&active_second, second),
3010 (Some(_), true) => (&active_first, first),
3011 };
3012 for &other in candidates {
3013 let against = &pool[other];
3014 if chord.max_v() < against.min_v() || against.max_v() < chord.min_v() {
3015 continue;
3016 }
3017 if is_second {
3018 offer(other, index);
3019 } else {
3020 offer(index, other);
3021 }
3022 }
3023 if is_second {
3024 active_second.push(index);
3025 } else {
3026 active_first.push(index);
3027 }
3028 }
3029}
3030
3031/// The shift that carries `point` onto the period of `target` — zero in a
3032/// direction the surface is not closed in.
3033fn period_shift(point: Vec2, target: Vec2, period: [Option<f64>; 2]) -> Vec2 {
3034 let along = |value: f64, want: f64, period: Option<f64>| match period {
3035 Some(period) if period > 0.0 => ((want - value) / period).round() * period,
3036 _ => 0.0,
3037 };
3038 Vec2 {
3039 x: along(point.x, target.x, period[0]),
3040 y: along(point.y, target.y, period[1]),
3041 }
3042}
3043
3044/// One loop's trim polygon, sampled and lifted into the covering plane: each
3045/// coedge's whole sample run is shifted by the period that joins its start to
3046/// the previous coedge's end.
3047///
3048/// The shift is decided ONCE PER COEDGE, at the boundary where a jump can
3049/// actually occur — a pcurve is one continuous NURBS and does not jump inside
3050/// itself. Deciding it per SAMPLE (which is what `validate()`'s uv-wire
3051/// warning does) takes any genuine chord longer than half a period the short
3052/// way round instead.
3053fn lift_loop(loop_record: &LoopRecord, period: [Option<f64>; 2]) -> Result<Chain, String> {
3054 let mut points: Vec<Vec2> = Vec::new();
3055 let mut owner: Vec<usize> = Vec::new();
3056 let mut previous_end: Option<Vec2> = None;
3057 for (index, coedge) in loop_record.coedges.iter().enumerate() {
3058 let curve = &coedge.pcurve;
3059 let [start, end] = curve.domain()?;
3060 let at = |parameter: f64| -> Result<Vec2, String> {
3061 let value = curve.evaluate(parameter)?;
3062 Ok(Vec2 {
3063 x: value.x,
3064 y: value.y,
3065 })
3066 };
3067 let shift = match previous_end {
3068 Some(target) => period_shift(at(start)?, target, period),
3069 None => Vec2 { x: 0.0, y: 0.0 },
3070 };
3071 let samples = crate::classification::trim_sample_count(curve);
3072 for sample in 0..samples {
3073 let parameter = start + (end - start) * sample as f64 / samples as f64;
3074 points.push(at(parameter)?.add(shift));
3075 owner.push(index);
3076 }
3077 previous_end = Some(at(end)?.add(shift));
3078 }
3079 let wind = match (points.first(), previous_end) {
3080 (Some(first), Some(last)) => last.sub(*first),
3081 _ => Vec2 { x: 0.0, y: 0.0 },
3082 };
3083 if let Some(last) = previous_end {
3084 points.push(last);
3085 }
3086 Ok(Chain {
3087 points,
3088 owner,
3089 wind,
3090 })
3091}
3092
3093/// Does any face's trim loop cross ITSELF in its own parameter domain — a
3094/// bowtie?
3095///
3096/// This is the defect every other detector in this module is blind to by
3097/// construction (see the blind-spot section of the module docs): the crossing
3098/// is INSIDE one face, so there is no face pair to confirm; the incidence in
3099/// the record is perfect, so `validate()` is silent; the counts are unchanged,
3100/// so parity and connectivity are unchanged. The face's own AREA is the only
3101/// other tell, and nothing compares it to anything.
3102///
3103/// The loop is sampled at exactly the density
3104/// [`crate::parameter_point_in_face`]'s generic lane classifies against
3105/// (`trim_sample_count`), lifted into the covering plane a coedge at a time,
3106/// and swept for chord pairs that cross. Chords the CHAIN joins are skipped by
3107/// index, never by proximity: a loop that runs back over its own last vertex
3108/// is a finding, not an adjacency.
3109///
3110/// Reported once per crossing COEDGE PAIR per loop — the stable half, since a
3111/// denser sampling moves a crossing's `uv` without changing which two trims
3112/// cross. Nothing in the kernel's build path calls this; the blend acceptance
3113/// and the case gate are its callers.
3114pub fn loop_self_crossings(solid: &BrepSolid) -> LoopCrossingReport {
3115 let mut report = LoopCrossingReport {
3116 loops: 0,
3117 chords: 0,
3118 crossings: Vec::new(),
3119 unreadable: Vec::new(),
3120 };
3121 // "Are these two independently fitted points the SAME vertex?" — the
3122 // kernel's own named answer, and the band this detector holds a crossing
3123 // AT a loop vertex to. See the `crossings` field for why it is needed and
3124 // what it costs.
3125 let vertex_band = crate::tolerance::assembler_weld(
3126 crate::KernelTolerances::for_solid(solid, 1e-7).model,
3127 );
3128 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
3129 let (closed_u, closed_v) = match face.surface.closed_directions() {
3130 Ok(closed) => closed,
3131 Err(message) => {
3132 report.unreadable.push(format!("face {}: {message}", face.id));
3133 continue;
3134 }
3135 };
3136 let (domain_u, domain_v) = match (face.surface.domain_u(), face.surface.domain_v()) {
3137 (Ok(u), Ok(v)) => (u, v),
3138 (Err(message), _) | (_, Err(message)) => {
3139 report.unreadable.push(format!("face {}: {message}", face.id));
3140 continue;
3141 }
3142 };
3143 let span_u = domain_u[1] - domain_u[0];
3144 let span_v = domain_v[1] - domain_v[0];
3145 let period = [closed_u.then_some(span_u), closed_v.then_some(span_v)];
3146 // The coincidence band, and the band the side tests hold a sample to.
3147 // Keyed to the face's own parameter extent: uv is not a length, and a
3148 // domain is as likely to be [0, 1] as [0, 2π].
3149 let weld = UV_WELD_FRACTION * (span_u.abs() + span_v.abs()).max(1.0);
3150 for loop_record in &face.loops {
3151 let chain = match lift_loop(loop_record, period) {
3152 Ok(chain) => chain,
3153 Err(message) => {
3154 report.unreadable.push(format!(
3155 "face {} loop {}: {message}",
3156 face.id, loop_record.id
3157 ));
3158 continue;
3159 }
3160 };
3161 let count = chain.chords();
3162 if count < 4 {
3163 continue;
3164 }
3165 report.loops += 1;
3166 report.chords += count;
3167 let chords: Vec<Chord> = (0..count).map(|index| chain.chord(index)).collect();
3168
3169 let mut seen: HashSet<(usize, usize)> = HashSet::default();
3170 let mut hits: Vec<(usize, usize, Vec2)> = Vec::new();
3171 let zero = Vec2 { x: 0.0, y: 0.0 };
3172 // Two different coedges of one loop that use the SAME EDGE are two
3173 // parameter-space images of ONE 3D curve — a seam edge, which a
3174 // periodic face's loop traverses once each way. They are the same
3175 // locus on the surface and cannot cross it; where their two
3176 // independent fits wobble past each other at the fit tolerance,
3177 // that is the wobble and not a bowtie. (A coedge against ITSELF is
3178 // a different question and is still asked.)
3179 let same_edge = |first: usize, second: usize| {
3180 let (a, b) = (chain.owner[first], chain.owner[second]);
3181 a != b
3182 && loop_record.coedges[a].edge_id == loop_record.coedges[b].edge_id
3183 };
3184 let mut record = |first: usize, second: usize, point: Vec2| {
3185 let (a, b) = (chain.owner[first], chain.owner[second]);
3186 let pair = if a <= b { (a, b) } else { (b, a) };
3187 if seen.insert(pair) {
3188 hits.push((pair.0, pair.1, point));
3189 }
3190 };
3191
3192 // How many whole PERIODS the loop's end is from its start. This is
3193 // the structural question — does the loop close in the domain, or
3194 // wind the seam — and it must not be asked with a tolerance: the
3195 // two ends are one vertex reached by two independent pcurve fits,
3196 // and their uv gap is whatever those fits left (measured up to
3197 // 2.4e-5 on an offset shell, which is above `validate()`'s own
3198 // uv-wire closure band). Rounding to laps answers it exactly.
3199 let laps_between = |value: f64, period: Option<f64>| -> i64 {
3200 match period {
3201 Some(period) if period > 0.0 => (value / period).round() as i64,
3202 _ => 0,
3203 }
3204 };
3205 let wind_laps = (
3206 laps_between(chain.wind.x, period[0]),
3207 laps_between(chain.wind.y, period[1]),
3208 );
3209 let joined = wind_laps == (0, 0);
3210
3211 // The loop against itself. Chords the chain JOINS are skipped: the
3212 // consecutive pairs, and the closing pair when the loop closes in
3213 // the domain rather than winding the seam.
3214 sweep_chords(&chords, None, |first, second| {
3215 let (low, high) = if first <= second {
3216 (first, second)
3217 } else {
3218 (second, first)
3219 };
3220 if high == low + 1 || (joined && low == 0 && high == count - 1) {
3221 return;
3222 }
3223 if same_edge(low, high) {
3224 return;
3225 }
3226 if let Some(point) = chain_crossing(&chain, low, high, zero, weld) {
3227 record(low, high, point);
3228 }
3229 });
3230
3231 // A PERIODIC face's loop and its own period-shifted image are one
3232 // curve on the surface, so a loop that WINDS can cross itself
3233 // across the seam. The shift is taken in one direction only:
3234 // `(+P, i, j)` and `(-P, j, i)` are the same two points on the
3235 // surface, so testing both would find every seam crossing twice.
3236 let extent = |axis: fn(&Vec2) -> f64| -> f64 {
3237 let values = chain.points.iter().map(axis);
3238 let (lo, hi) = values.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), value| {
3239 (lo.min(value), hi.max(value))
3240 });
3241 hi - lo
3242 };
3243 let laps = |period: Option<f64>, extent: f64| -> i64 {
3244 match period {
3245 Some(period) if period > 0.0 => (extent / period).ceil() as i64,
3246 _ => 0,
3247 }
3248 };
3249 let laps_u = laps(period[0], extent(|point| point.x));
3250 let laps_v = laps(period[1], extent(|point| point.y));
3251 for step_u in 0..=laps_u {
3252 for step_v in -laps_v..=laps_v {
3253 // (0, 0) is the scan above; the half-lattice `step_u > 0`,
3254 // or `step_u == 0` and `step_v > 0`, holds one of every
3255 // (shift, −shift) pair.
3256 if step_u == 0 && step_v <= 0 {
3257 continue;
3258 }
3259 let shift = Vec2 {
3260 x: step_u as f64 * period[0].unwrap_or(0.0),
3261 y: step_v as f64 * period[1].unwrap_or(0.0),
3262 };
3263 let shifted: Vec<Chord> = chords
3264 .iter()
3265 .map(|chord| chord.shifted(shift))
3266 .collect();
3267 // One lap on, the chain's own last chord is joined to its
3268 // first: that is the seam, not a crossing.
3269 let laps_forward = (step_u, step_v) == wind_laps;
3270 let laps_back = (step_u, step_v) == (-wind_laps.0, -wind_laps.1);
3271 sweep_chords(&chords, Some(&shifted), |first, second| {
3272 if laps_forward && first == count - 1 && second == 0 {
3273 return;
3274 }
3275 if laps_back && first == 0 && second == count - 1 {
3276 return;
3277 }
3278 if same_edge(first, second) {
3279 return;
3280 }
3281 if let Some(point) = chain_crossing(&chain, first, second, shift, weld) {
3282 record(first, second, point);
3283 }
3284 });
3285 }
3286 }
3287
3288 // Where this loop's own VERTICES are, in 3D: one per coedge, at
3289 // the start of its pcurve.
3290 let mut corners: Vec<Vec3> = Vec::with_capacity(loop_record.coedges.len());
3291 if !hits.is_empty() {
3292 for coedge in &loop_record.coedges {
3293 let Ok([start, _]) = coedge.pcurve.domain() else {
3294 continue;
3295 };
3296 let Ok(value) = coedge.pcurve.evaluate(start) else {
3297 continue;
3298 };
3299 if let Ok(point) = face.surface.evaluate(value.x, value.y) {
3300 corners.push(point);
3301 }
3302 }
3303 }
3304 for (first, second, point) in hits {
3305 let wrap = |value: f64, domain: [f64; 2], period: Option<f64>| match period {
3306 Some(period) if period > 0.0 => {
3307 domain[0] + (value - domain[0]).rem_euclid(period)
3308 }
3309 _ => value.clamp(domain[0], domain[1]),
3310 };
3311 let u = wrap(point.x, domain_u, period[0]);
3312 let v = wrap(point.y, domain_v, period[1]);
3313 let Ok(position) = face.surface.evaluate(u, v) else {
3314 report.unreadable.push(format!(
3315 "face {} loop {}: crossing at uv ({u:.6}, {v:.6}) is off the surface",
3316 face.id, loop_record.id
3317 ));
3318 continue;
3319 };
3320 // A crossing AT one of the loop's own vertices is the gap
3321 // between the two pcurves that meet there, not a bowtie. Two
3322 // trims arriving at one vertex are fitted independently, so
3323 // each ends a fit-accuracy away from it and near the vertex
3324 // they overlap; `assembler_weld` is the kernel's own band for
3325 // "the same vertex reached by two independent fits".
3326 if corners
3327 .iter()
3328 .any(|corner| corner.sub(position).length() <= vertex_band)
3329 {
3330 continue;
3331 }
3332 let coedge = |index: usize| &loop_record.coedges[index];
3333 report.crossings.push(LoopCrossing {
3334 face: face.id,
3335 face_name: face.name.clone(),
3336 loop_id: loop_record.id,
3337 coedge_a: coedge(first).id,
3338 coedge_b: coedge(second).id,
3339 edge_a: coedge(first).edge_id,
3340 edge_b: coedge(second).edge_id,
3341 uv: [u, v],
3342 point: position,
3343 });
3344 }
3345 }
3346 }
3347 report
3348}
3349
3350// ---------------------------------------------------------------------------
3351// Shell vector area — the closure a trim owes its own edge
3352// ---------------------------------------------------------------------------
3353
3354/// Gauss–Legendre accuracy target per span, RELATIVE to that span's own
3355/// magnitude `∫ |r − R| |dr|`. A span is bisected until the two halves agree
3356/// with the whole to this fraction of what the span can contribute at all.
3357///
3358/// Scale-free by construction, so a 3000 mm part and a 3 mm one are held to
3359/// the same number of digits. 1e-13 is about a hundred times the f64 noise of
3360/// an eight-station rule (each station is one rounding of a product of two
3361/// evaluated vectors), so the recursion terminates on geometry rather than on
3362/// arithmetic, and two orders below it is what the check needs: the whole
3363/// point is a residual read at 1e-9 out of face terms worth 1e+2.
3364const VECTOR_AREA_QUADRATURE_RELATIVE: f64 = 1e-13;
3365
3366/// `BREP_VECTOR_AREA_QUADRATURE` overrides that target, so the convergence of
3367/// a residual can be MEASURED rather than argued: read the same shell at
3368/// 1e-13 and at 1e-15 and the difference is what the quadrature is worth.
3369/// Read once per process.
3370fn vector_area_quadrature_relative() -> f64 {
3371 static TARGET: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
3372 *TARGET.get_or_init(|| {
3373 std::env::var("BREP_VECTOR_AREA_QUADRATURE")
3374 .ok()
3375 .and_then(|value| value.parse::<f64>().ok())
3376 .filter(|value| *value > 0.0 && value.is_finite())
3377 .unwrap_or(VECTOR_AREA_QUADRATURE_RELATIVE)
3378 })
3379}
3380
3381/// Bisection depth per span. A span that has not converged by here is
3382/// accepted with its last difference counted into
3383/// [`ShellVectorArea::quadrature_error`] and into
3384/// [`ShellVectorArea::spans_at_depth_limit`], never silently.
3385///
3386/// The limit is a COST bound, and it is sound because the error it leaves is
3387/// measured rather than assumed. A span whose integrand has a kink in it — a
3388/// trim read through the C¹ boundary extension past its surface's domain —
3389/// converges only linearly, so each further level halves its error for twice
3390/// the work, and the recursion is buying precision the bar never asked for.
3391/// `BREP_VECTOR_AREA_DEPTH` overrides it so that trade can be measured.
3392fn vector_area_quadrature_depth() -> usize {
3393 static DEPTH: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
3394 *DEPTH.get_or_init(|| {
3395 std::env::var("BREP_VECTOR_AREA_DEPTH")
3396 .ok()
3397 .and_then(|value| value.parse::<usize>().ok())
3398 .filter(|value| *value > 0 && *value <= 24)
3399 .unwrap_or(VECTOR_AREA_QUADRATURE_DEPTH)
3400 })
3401}
3402
3403/// Chosen by measurement, not by taste — and the cheapest setting that keeps
3404/// the reported error bar is NOT the right one.
3405///
3406/// Read on the notched cap's `F7` (curved trims) against the depth-12 value,
3407/// and on `20_helmet_merge_stage/a` (253 faces, half its spans hitting the
3408/// limit):
3409///
3410/// ```text
3411/// depth cap residual its bar off depth 12 cap ms helmet ms
3412/// 12 5.106706e-6 4.204e-10 -- 334.9 17347
3413/// 8 5.107557e-6 7.496e-9 8.5e-10 56.4 7356
3414/// 6 5.072736e-6 2.206e-9 3.4e-8 42.9 6385
3415/// 4 5.206351e-6 3.116e-7 1.0e-7 39.2 --
3416/// ```
3417///
3418/// Depth 6 is the trap. It reports a TIGHTER bar than depth 8 while sitting
3419/// 15x further from the converged value: the half-vs-whole difference
3420/// underestimates what is left when the recursion stops early on a
3421/// slowly-converging integrand, so the estimate stops being conservative
3422/// before the answer stops being right. On the helmet, whose residual is
3423/// invariant at every depth, that failure is invisible — cost alone would
3424/// have picked 6.
3425///
3426/// 8 is the shallowest depth whose reported bar still COVERS its own
3427/// deviation from converged, and it is 5.9x cheaper than 12 on the cap and
3428/// 2.4x on the helmet. The residual stays 681x its own error bar there, so it
3429/// is still a measurement by a wide margin.
3430const VECTOR_AREA_QUADRATURE_DEPTH: usize = 8;
3431
3432/// The most spans one SHELL is integrated over before the scan gives up on it.
3433///
3434/// This is a COST bound and it is the only kind that keeps the residual
3435/// honest: on reaching it the shell is reported in
3436/// [`VectorAreaReport::unreadable`] and NOT given a residual, so it falls into
3437/// the same "unjudged" the report uses for a solid with no closed shell. The
3438/// alternative — integrate the remaining spans at whatever accuracy is to
3439/// hand and publish the sum — would turn a measurement into a number with an
3440/// unknown error bar, which is exactly what [`ShellVectorArea::quadrature_error`]
3441/// exists to prevent. A scan that ran out of budget did not measure anything,
3442/// and it must not be able to look like a scan that read zero.
3443///
3444/// The value is read off the case corpus rather than chosen: see the record's
3445/// §6.3. `BREP_VECTOR_AREA_SPANS` overrides it.
3446pub(crate) const VECTOR_AREA_SPAN_BUDGET: usize = 400_000;
3447// ^ read off the case corpus, not chosen.
3448// Over its 317 shells the span count runs median 168, p90 8,278, p99 301,826,
3449// max 1,842,688 — and the distribution has a GAP exactly where a budget wants
3450// to sit. The four shells above 400,000 are the two `gear-hex-bore-push` rows
3451// (1.84M spans each, the only rows whose scan broke the case gate's time
3452// budget); the next largest shell in the whole corpus is 301,826. So this
3453// number separates the bodies this scan cannot afford from every body it can,
3454// with a 33% margin below it and a 4.6x jump above it, and it is a property of
3455// the corpus rather than of anyone's preference.
3456
3457fn vector_area_span_budget() -> usize {
3458 static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
3459 *BUDGET.get_or_init(|| {
3460 std::env::var("BREP_VECTOR_AREA_SPANS")
3461 .ok()
3462 .and_then(|value| value.parse::<usize>().ok())
3463 .filter(|value| *value > 0)
3464 .unwrap_or(VECTOR_AREA_SPAN_BUDGET)
3465 })
3466}
3467
3468/// The ABSOLUTE half of the acceptance, per unit of arc: a span is also
3469/// accepted once the halves agree to this times its own length.
3470///
3471/// It is what makes the cost bounded. The relative target alone is a target
3472/// on a SMOOTH integrand: a trim that runs outside its surface's domain is
3473/// read through the C¹ boundary extension, so its integrand has a kink, a
3474/// bisection gains only two orders per four levels, and the recursion bottoms
3475/// out at the depth limit on every span. Measured on the committed STEP
3476/// corpus that was not a slow scan but a stopped one — one imported solid
3477/// held the sweep for eight minutes.
3478///
3479/// 1e-11 is a ten-thousandth of [`crate::PCURVE_REFINEMENT_TOLERANCE`], the same
3480/// per-unit-of-length currency the bar is quoted in, so the whole shell's
3481/// quadrature error is bounded by `bar / 5000` (a shell's boundary is twice
3482/// its edge length) whatever its geometry does — and the bound is on the
3483/// REPORTED estimate too, since every accepted difference is summed into it.
3484const VECTOR_AREA_QUADRATURE_FLOOR: f64 = 1e-11;
3485
3486/// `BREP_VECTOR_AREA_FLOOR` overrides [`VECTOR_AREA_QUADRATURE_FLOOR`], so the
3487/// cost the floor buys can be measured against the error it leaves rather
3488/// than argued.
3489fn vector_area_quadrature_floor() -> f64 {
3490 static FLOOR: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
3491 *FLOOR.get_or_init(|| {
3492 std::env::var("BREP_VECTOR_AREA_FLOOR")
3493 .ok()
3494 .and_then(|value| value.parse::<f64>().ok())
3495 .filter(|value| *value > 0.0 && value.is_finite())
3496 .unwrap_or(VECTOR_AREA_QUADRATURE_FLOOR)
3497 })
3498}
3499
3500/// One face's vector area `∫ n dA`, as its TRIMS claim it and as its EDGES
3501/// claim it, and the difference between the two.
3502///
3503/// Both are the same boundary integral `(1/2) ∮ (r − R) × dr` about the
3504/// shell's own reference `R` (see [`ShellVectorArea::reference`]), over the
3505/// same loops in the same order; only the curve differs. The `trim` walks the
3506/// pcurve mapped through this face's surface — the boundary the mass
3507/// integrals actually integrate over — and the `edge` walks the 3D edge curve
3508/// the coedge names, which is the SAME curve the face across that edge walks.
3509#[derive(Clone, Debug, Serialize)]
3510pub struct FaceVectorArea {
3511 pub shell: u64,
3512 pub face: u64,
3513 pub face_name: Option<String>,
3514 /// `(1/2) ∮ (r − R) × dr` over the face's TRIMS, each coedge sampled in
3515 /// its own parameter direction (a pcurve is parameterized in its coedge's
3516 /// traversal direction, so the loop is walked by sampling each pcurve
3517 /// forwards, never by reversing a reverse coedge's sweep).
3518 pub trim: Vec3,
3519 /// The same integral over the 3D EDGE curves, each edge traversed in its
3520 /// coedge's direction — forwards over `[t0, t1]` for a forward coedge,
3521 /// backwards for a reverse one.
3522 pub edge: Vec3,
3523 /// `trim − edge`: what THIS face's trims add to the shell's residual.
3524 /// Every edge term cancels across the shell (each edge is walked once
3525 /// each way), so the shell's residual is exactly the sum of these.
3526 pub departure: Vec3,
3527 /// The face's own boundary arc length, taken on the edge curves.
3528 pub boundary_length: f64,
3529 /// `PCURVE_REFINEMENT_TOLERANCE * boundary_length` — what this face's
3530 /// departure can be while every one of its trims lies within the fit
3531 /// floor of the edge it claims. See [`ShellVectorArea::bar`] for the
3532 /// derivation; this is the same bound applied to one loop.
3533 ///
3534 /// **It does not cover a face whose `closure_gap` is over the floor.**
3535 /// The derivation takes one integration by parts over a CLOSED loop; a
3536 /// loop that does not close carries a boundary term the bound never
3537 /// modelled, and the sliver its chords span is real area that this face's
3538 /// trims genuinely enclose. Read `closure_gap` first.
3539 pub bar: f64,
3540 /// How far this face's own trims fail to MEET each other: the summed 3D
3541 /// distance from where one coedge's trim ends to where the next one
3542 /// starts, over this face's loops, including the chord home.
3543 ///
3544 /// A different defect from the departure, and the one that is easy to
3545 /// misread as it. Two trims arriving at one vertex are fitted
3546 /// independently, each refined to `PCURVE_REFINEMENT_TOLERANCE`, so they
3547 /// can miss each other by twice it — about 2e-7 — and anything at that
3548 /// scale is the fit floor rather than a defect. Past it the loop is not
3549 /// closed in the record, and then this face's `departure` is partly the
3550 /// area of the sliver its closing chords span rather than any trim lying
3551 /// off an edge.
3552 ///
3553 /// Reported, not verdicted: a bar on it would be a new tolerance, and the
3554 /// slice that owns a loop's closure is the one that opens it. On the
3555 /// notched cap (2026-09-16) the top cap `E5:S4:PROFILE:L2_END` carries a
3556 /// 4.095e-7 net step in uv while every other loop on that shell closes to
3557 /// 1e-12 or better, and it is the fillet that opens it — the same
3558 /// document's pre-fillet body closes exactly.
3559 pub closure_gap: f64,
3560}
3561
3562impl FaceVectorArea {
3563 /// `|departure|`.
3564 pub fn residual(&self) -> f64 {
3565 self.departure.length()
3566 }
3567
3568 /// Does this face's trim lie further from its own edges than a fit at the
3569 /// refinement floor can account for?
3570 pub fn is_over_bar(&self) -> bool {
3571 self.residual() > self.bar
3572 }
3573}
3574
3575/// One shell's vector-area closure: [`shell_vector_areas`]' per-shell verdict.
3576#[derive(Clone, Debug, Serialize)]
3577pub struct ShellVectorArea {
3578 pub shell: u64,
3579 pub faces: usize,
3580 /// Every non-degenerate edge this shell's coedges use is used EXACTLY
3581 /// TWICE by them. `∫ n dA = 0` is a statement about a closed shell only,
3582 /// so an open one is measured and reported but never flagged: its
3583 /// residual is its open boundary's own vector area, which is a fact about
3584 /// the sheet rather than a defect.
3585 pub closed: bool,
3586 /// The point every arm is taken from — the centre of the bounding box of
3587 /// the vertices this shell's edges use. The residual is independent of it
3588 /// to `|R| * closure_gap` (below), and a local reference is what keeps a
3589 /// far-translated part's face terms from dwarfing the number being read,
3590 /// the same reason `mass_properties::shell_volume_reference` exists.
3591 pub reference: Vec3,
3592 /// `Σ_faces (1/2) ∮ (r − R) × dr` over the TRIMS. **Exactly zero on a
3593 /// sound closed shell**, and otherwise the sum of every face's departure
3594 /// from the edges it shares.
3595 pub trim_residual: Vec3,
3596 /// The same sum over the EDGE curves. Every edge's integral is computed
3597 /// ONCE and added with each coedge's sign, so on a shell where every edge
3598 /// is used twice with opposite senses the curve terms cancel to rounding
3599 /// and what is left is the loops' closing chords alone. It is therefore a
3600 /// BOOKKEEPING check rather than an accuracy one: it moves off zero when
3601 /// the shell is open, when an edge is used twice the SAME way, or when
3602 /// the edge curves do not meet at the vertices they share.
3603 pub edge_residual: Vec3,
3604 /// Total arc length of the shell's non-degenerate edges, each counted
3605 /// ONCE.
3606 pub edge_length: f64,
3607 /// Total arc length walked by the faces' loops — `2 * edge_length` on a
3608 /// closed shell.
3609 pub boundary_length: f64,
3610 /// The comparand: `PCURVE_REFINEMENT_TOLERANCE * edge_length`. See the
3611 /// section docs for the derivation and for why the strict bound is twice
3612 /// this.
3613 pub bar: f64,
3614 /// The summed |half-vs-whole| difference at every accepted span — an
3615 /// OVER-estimate of this shell's quadrature error, and the error bar the
3616 /// residual is read against. A residual is a measurement only while it is
3617 /// far above this.
3618 pub quadrature_error: f64,
3619 /// Total length of the chords that close the TRIM loops — how far the
3620 /// sampled trims fail to meet each other at the vertices they share, over
3621 /// the whole shell. Two trims arriving at one vertex are fitted
3622 /// independently, so a few 1e-9 per vertex is ordinary; the chords are
3623 /// integrated (they are part of the region the mass integrator's own trim
3624 /// polygon bounds), so this is reported as a fact about the record rather
3625 /// than as an error term.
3626 pub trim_closure_gap: f64,
3627 /// The same for the EDGE loops: how far consecutive edge curves fail to
3628 /// meet at their shared vertex. `validate()` checks each curve against
3629 /// its vertex (`IssueKind::CurveVertexGap`); this is what the gaps are
3630 /// worth to the shell's closure.
3631 pub edge_closure_gap: f64,
3632 /// Spans integrated: the pcurve's own knot spans, cut at the surface's
3633 /// knot levels, bisected to convergence, plus the edge curves' own. The
3634 /// cost of the whole check is this times one eight-station rule.
3635 pub spans: usize,
3636 /// How many of those spans were accepted at the DEPTH LIMIT rather than
3637 /// because they converged. Their last half-vs-whole difference is in
3638 /// `quadrature_error` like any other, so the error bar stays honest, but
3639 /// a shell with many of these is paying the full recursion on an
3640 /// integrand the bisection cannot resolve — a kink rather than a curve —
3641 /// and the cost is the symptom.
3642 pub spans_at_depth_limit: usize,
3643 /// The scan ABANDONED this shell on `VECTOR_AREA_SPAN_BUDGET` with
3644 /// whole faces still unread.
3645 ///
3646 /// Its `trim_residual` is then a PARTIAL SUM over the faces that were
3647 /// reached, and a partial sum of a boundary integral is not a smaller
3648 /// version of the answer — it is the vector area of an arbitrary subset
3649 /// of the shell, which on a 1004-face gear reads 6.08e+3 where the whole
3650 /// shell reads 1.16e-5. [`Self::residual`] returns `NaN` for such a shell
3651 /// so that number cannot be read as either a residual or an open
3652 /// boundary's area.
3653 pub gave_up: bool,
3654 /// Per face, in record order.
3655 pub face_areas: Vec<FaceVectorArea>,
3656}
3657
3658/// What a shell's closure scan came back with — a measurement, or nothing.
3659///
3660/// The reason this is a type rather than a float: `NaN` is a value, and a value
3661/// gets folded into a comparison. [`ShellVectorArea::is_over_bar`] is
3662/// `residual() > bar`, and `NaN > bar` is FALSE, so a shell the scan gave up on
3663/// reads exactly like a shell that closed — the worst possible answer, since the
3664/// scan gave up on the bodies whose geometry is hardest. A caller that matches on
3665/// this cannot make that mistake without writing the arm.
3666#[derive(Clone, Copy, Debug, Serialize)]
3667pub enum ClosureReading {
3668 /// The trims were integrated to the end. `at_depth_limit` spans were
3669 /// accepted at the bisection's depth limit rather than because they
3670 /// converged; their last difference is inside `quadrature_error` either way.
3671 Measured {
3672 residual: f64,
3673 bar: f64,
3674 spans: usize,
3675 at_depth_limit: usize,
3676 },
3677 /// No residual exists for this shell: the scan stopped before it had read
3678 /// every face, and a partial sum of a boundary integral is not a smaller
3679 /// version of the answer.
3680 Unmeasured {
3681 reason: &'static str,
3682 spans: usize,
3683 faces_read: usize,
3684 faces: usize,
3685 },
3686 /// An OPEN shell is not judged by this check at all: `Σ ∫ n dA = 0` is a
3687 /// statement about a closed one, and an open shell's residual is its own
3688 /// boundary's vector area, which is a fact about the sheet.
3689 Open { residual: f64, faces: usize },
3690}
3691
3692impl ShellVectorArea {
3693 /// The typed outcome — what this shell's scan actually came back with.
3694 pub fn reading(&self) -> ClosureReading {
3695 if self.gave_up {
3696 return ClosureReading::Unmeasured {
3697 reason: "the scan reached its span budget with faces unread",
3698 spans: self.spans,
3699 faces_read: self.face_areas.len(),
3700 faces: self.faces,
3701 };
3702 }
3703 if !self.closed {
3704 return ClosureReading::Open {
3705 residual: self.trim_residual.length(),
3706 faces: self.faces,
3707 };
3708 }
3709 ClosureReading::Measured {
3710 residual: self.trim_residual.length(),
3711 bar: self.bar,
3712 spans: self.spans,
3713 at_depth_limit: self.spans_at_depth_limit,
3714 }
3715 }
3716
3717 /// `|trim_residual|`, and **`NaN` when the scan gave up on this shell**:
3718 /// see [`Self::gave_up`]. A partial sum is not a residual. Prefer
3719 /// [`Self::reading`], which cannot be compared by accident.
3720 pub fn residual(&self) -> f64 {
3721 if self.gave_up {
3722 return f64::NAN;
3723 }
3724 self.trim_residual.length()
3725 }
3726
3727 /// The predicate: a CLOSED shell whose trims enclose a vector area no fit
3728 /// at the refinement floor can account for. **False for a shell the scan
3729 /// did not measure** — ask [`Self::reading`] to tell those apart.
3730 pub fn is_over_bar(&self) -> bool {
3731 matches!(self.reading(), ClosureReading::Measured { residual, bar, .. } if residual > bar)
3732 }
3733
3734 /// The faces that own the residual, worst departure first.
3735 pub fn worst_faces(&self, count: usize) -> Vec<&FaceVectorArea> {
3736 let mut ordered: Vec<&FaceVectorArea> = self.face_areas.iter().collect();
3737 ordered.sort_by(|a, b| {
3738 b.residual()
3739 .partial_cmp(&a.residual())
3740 .unwrap_or(std::cmp::Ordering::Equal)
3741 .then_with(|| a.face.cmp(&b.face))
3742 });
3743 ordered.truncate(count);
3744 ordered
3745 }
3746
3747 /// The worst face's name, or its id when it has none.
3748 pub fn worst_face_name(&self) -> Option<String> {
3749 self.worst_faces(1).first().map(|face| {
3750 face.face_name
3751 .clone()
3752 .unwrap_or_else(|| format!("face {}", face.face))
3753 })
3754 }
3755}
3756
3757/// Outcome of [`shell_vector_areas`].
3758#[derive(Clone, Debug, Serialize)]
3759pub struct VectorAreaReport {
3760 pub shells: Vec<ShellVectorArea>,
3761 /// Shells or faces whose trims could not be read, `shell/face: reason`. A
3762 /// scan that could not read a face did not clear it, so an empty
3763 /// `shells` beside a non-empty this is not "every shell closes".
3764 pub unreadable: Vec<String>,
3765}
3766
3767impl VectorAreaReport {
3768 /// The predicate: some CLOSED shell is over its bar.
3769 pub fn is_flagged(&self) -> bool {
3770 self.shells.iter().any(ShellVectorArea::is_over_bar)
3771 }
3772
3773 /// The shells this scan did NOT measure, with what each reached. A report
3774 /// with entries here has not cleared those shells, whatever the rest read.
3775 pub fn unmeasured(&self) -> Vec<(u64, ClosureReading)> {
3776 self.shells
3777 .iter()
3778 .map(|shell| (shell.shell, shell.reading()))
3779 .filter(|(_, reading)| matches!(reading, ClosureReading::Unmeasured { .. }))
3780 .collect()
3781 }
3782
3783 /// The largest closed-shell residual, and the shell that carries it.
3784 pub fn worst_shell(&self) -> Option<&ShellVectorArea> {
3785 self.shells
3786 .iter()
3787 .filter(|shell| shell.closed)
3788 .max_by(|a, b| {
3789 a.residual()
3790 .partial_cmp(&b.residual())
3791 .unwrap_or(std::cmp::Ordering::Equal)
3792 })
3793 }
3794
3795 /// `|trim_residual|` of the worst CLOSED shell, and **`NaN` when this
3796 /// solid has no closed shell at all**.
3797 ///
3798 /// Not 0.0. A residual of zero is what a perfectly sound shell reads, so
3799 /// a solid whose shells are all open — one that has LOST faces, say —
3800 /// must not be able to read like one through this accessor. The whole
3801 /// point of the check is that a number nothing compares against is how
3802 /// the defects it hunts stayed hidden; handing back a sound-looking zero
3803 /// for "there was nothing here to judge" would rebuild that trap inside
3804 /// the detector. Use [`Self::has_closed_shell`] to tell the two apart
3805 /// before reading this, or read the per-shell reports, where an open
3806 /// shell's residual is its boundary's own vector area and is reported.
3807 pub fn residual(&self) -> f64 {
3808 self.worst_shell()
3809 .map(ShellVectorArea::residual)
3810 .unwrap_or(f64::NAN)
3811 }
3812
3813 /// That shell's bar, `NaN` when there is no closed shell — see
3814 /// [`Self::residual`] for why this is not 0.0.
3815 pub fn bar(&self) -> f64 {
3816 self.worst_shell().map(|shell| shell.bar).unwrap_or(f64::NAN)
3817 }
3818
3819 /// Is there a closed shell here for [`Self::residual`] to be about?
3820 ///
3821 /// `Σ ∫ n dA = 0` is a statement about a CLOSED shell, so a solid with
3822 /// none is not "sound" and not "unsound" — it is unjudged, and every
3823 /// reader of this report has to decide what to do about that rather than
3824 /// inherit a zero.
3825 pub fn has_closed_shell(&self) -> bool {
3826 self.shells.iter().any(|shell| shell.closed)
3827 }
3828
3829 /// The shells that are NOT closed, with the vector area their open
3830 /// boundary encloses — the reading that names a shell which has lost
3831 /// faces.
3832 ///
3833 /// A closed shell's every edge is used exactly twice. A shell that has
3834 /// dropped a face has that face's edges used once, so it is open here and
3835 /// its residual is what the missing boundary encloses. That is a
3836 /// different question from Euler parity, and a strictly stronger one for
3837 /// this purpose: parity notices an ODD number of faces going and is
3838 /// silent when an even number does, while this counts the uses of every
3839 /// edge and does not care how many faces left.
3840 pub fn open_shells(&self) -> impl Iterator<Item = &ShellVectorArea> {
3841 self.shells.iter().filter(|shell| !shell.closed)
3842 }
3843
3844 /// One line naming the worst shell and the faces that own its residual,
3845 /// or `None` when every closed shell is inside its bar.
3846 pub fn summary(&self) -> Option<String> {
3847 let shell = self
3848 .shells
3849 .iter()
3850 .filter(|shell| shell.is_over_bar())
3851 .max_by(|a, b| {
3852 a.residual()
3853 .partial_cmp(&b.residual())
3854 .unwrap_or(std::cmp::Ordering::Equal)
3855 })?;
3856 let named: Vec<String> = shell
3857 .worst_faces(3)
3858 .iter()
3859 .map(|face| {
3860 format!(
3861 "{} {:.3e}",
3862 face.face_name
3863 .clone()
3864 .unwrap_or_else(|| format!("face {}", face.face)),
3865 face.residual()
3866 )
3867 })
3868 .collect();
3869 Some(format!(
3870 "shell {} closes to {:.3e} of vector area against a bar of {:.3e} \
3871 ({} faces, {:.4} of edge, quadrature {:.1e}); worst: {}",
3872 shell.shell,
3873 shell.residual(),
3874 shell.bar,
3875 shell.faces,
3876 shell.edge_length,
3877 shell.quadrature_error,
3878 named.join(", ")
3879 ))
3880 }
3881}
3882
3883/// `(1/2) ∮ (r − reference) × dr` along the straight chord `from → to` — the
3884/// term that closes a loop across the gap between two independently fitted
3885/// curves that both claim to reach one vertex.
3886fn chord_area(from: Vec3, to: Vec3, reference: Vec3) -> Vec3 {
3887 from.sub(reference).cross(to.sub(from)).scale(0.5)
3888}
3889
3890/// One Gauss–Legendre pass over `[t0, t1]`: that span's half of
3891/// `(1/2) ∮ (r − reference) × dr`, the magnitude `∫ |r − R| |dr|` its relative
3892/// accuracy is judged against, and the arc `∫ |dr|` its absolute floor is.
3893fn vector_area_span<F>(
3894 at: &F,
3895 t0: f64,
3896 t1: f64,
3897 reference: Vec3,
3898) -> Result<(Vec3, f64, f64), String>
3899where
3900 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
3901{
3902 let half = (t1 - t0) * 0.5;
3903 let middle = (t1 + t0) * 0.5;
3904 let mut total = Vec3::new(0.0, 0.0, 0.0);
3905 let mut magnitude = 0.0;
3906 let mut arc = 0.0;
3907 for index in 0..GAUSS_X.len() {
3908 let (point, tangent) = at(middle + half * GAUSS_X[index])?;
3909 let arm = point.sub(reference);
3910 let weight = GAUSS_W[index] * half;
3911 total = total.add(arm.cross(tangent).scale(0.5 * weight));
3912 magnitude += weight.abs() * arm.length() * tangent.length();
3913 arc += weight.abs() * tangent.length();
3914 }
3915 Ok((total, magnitude, arc))
3916}
3917
3918/// [`vector_area_span`] bisected until the halves agree with the whole, or until
3919/// the disagreement is below `share` — the error this span is ALLOWED, which is
3920/// the trim's own budget spread over the leaves the depth limit permits.
3921///
3922/// Without it a span whose 3D length has collapsed cannot ever be accepted. Its
3923/// acceptance target is proportional to its own size (relative to `magnitude`,
3924/// absolute per unit of `arc`), while the disagreement between its halves and
3925/// its whole is set by the round-off in a tangent the parameterization no longer
3926/// resolves, which does not shrink with the span. Measured on
3927/// `fixture_25_cube_pierce_pole_gap_bridge`, a FOUR-face body: spans of arc
3928/// 2.0e-16 whose halves and whole differ by 1.4e-14 against a target of 2.8e-27,
3929/// bisecting to the depth limit every time — 313,900 of its 355,610 spans, 5.0 s
3930/// for a residual whose bar is 1.4e-4. A span contributing 1e-14 cannot move
3931/// that verdict, and `share` says so.
3932///
3933///
3934/// The composition `S(pcurve(t))` is not a polynomial of any degree — a
3935/// rational patch read along a rational curve — so no fixed rule integrates
3936/// it exactly and a fixed rule's error is unknown rather than small. Halving
3937/// is also what finds a surface KNOT crossing for free: the pcurve knows
3938/// nothing of the surface's knot lines, and a span that straddles one has a
3939/// kink in its integrand that the difference sees and the recursion resolves.
3940fn vector_area_adaptive<F>(
3941 at: &F,
3942 t0: f64,
3943 t1: f64,
3944 reference: Vec3,
3945 depth: usize,
3946 share: f64,
3947 error: &mut f64,
3948 spans: &mut usize,
3949 exhausted: &mut usize,
3950) -> Result<Vec3, String>
3951where
3952 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
3953{
3954 let (whole, magnitude, arc) = vector_area_span(at, t0, t1, reference)?;
3955 let middle = 0.5 * (t0 + t1);
3956 let (left, _, _) = vector_area_span(at, t0, middle, reference)?;
3957 let (right, _, _) = vector_area_span(at, middle, t1, reference)?;
3958 let halves = left.add(right);
3959 let difference = halves.sub(whole).length();
3960 let target = (vector_area_quadrature_relative() * magnitude)
3961 .max(vector_area_quadrature_floor() * arc)
3962 .max(share);
3963 if depth == 0 || difference <= target {
3964 *error += difference;
3965 *spans += 2;
3966 if depth == 0 && difference > target {
3967 *exhausted += 2;
3968 }
3969 return Ok(halves);
3970 }
3971 let first =
3972 vector_area_adaptive(at, t0, middle, reference, depth - 1, share, error, spans, exhausted)?;
3973 let second =
3974 vector_area_adaptive(at, middle, t1, reference, depth - 1, share, error, spans, exhausted)?;
3975 Ok(first.add(second))
3976}
3977
3978/// The spans one curve is integrated over: its own knot breaks, clipped to
3979/// `[t0, t1]`.
3980fn vector_area_breaks(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<Vec<f64>, String> {
3981 let mut breaks = vec![t0];
3982 for knot in crate::curve::interior_knots(&curve.knots, curve.degree) {
3983 if knot > t0 && knot < t1 {
3984 breaks.push(knot);
3985 }
3986 }
3987 breaks.push(t1);
3988 Ok(breaks)
3989}
3990
3991/// `(1/2) ∮ (r − reference) × dr` over one curve span, plus the arc length
3992/// and the end-to-end displacement of what was walked.
3993fn vector_area_of<F>(
3994 at: &F,
3995 breaks: &[f64],
3996 reference: Vec3,
3997 share: f64,
3998 error: &mut f64,
3999 spans: &mut usize,
4000 exhausted: &mut usize,
4001) -> Result<Vec3, String>
4002where
4003 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
4004{
4005 let mut total = Vec3::new(0.0, 0.0, 0.0);
4006 let depth = vector_area_quadrature_depth();
4007 for pair in breaks.windows(2) {
4008 if pair[1] <= pair[0] {
4009 continue;
4010 }
4011 total = total.add(vector_area_adaptive(
4012 at,
4013 pair[0],
4014 pair[1],
4015 reference,
4016 depth,
4017 share,
4018 error,
4019 spans,
4020 exhausted,
4021 )?);
4022 }
4023 Ok(total)
4024}
4025
4026/// Arc length over the same spans, by the same rule — used for the bar, so it
4027/// needs the rule's accuracy and not the residual's.
4028fn vector_area_length<F>(at: &F, breaks: &[f64]) -> Result<f64, String>
4029where
4030 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
4031{
4032 let mut length = 0.0;
4033 for pair in breaks.windows(2) {
4034 if pair[1] <= pair[0] {
4035 continue;
4036 }
4037 let half = (pair[1] - pair[0]) * 0.5;
4038 let middle = (pair[1] + pair[0]) * 0.5;
4039 for index in 0..GAUSS_X.len() {
4040 let (_, tangent) = at(middle + half * GAUSS_X[index])?;
4041 length += GAUSS_W[index] * half * tangent.length();
4042 }
4043 }
4044 Ok(length)
4045}
4046
4047/// Does a closed shell's boundary enclose the vector area it should — zero?
4048///
4049/// A patch's vector area is a BOUNDARY integral,
4050/// `∫_F n dA = (1/2) ∮_{∂F} r × dr`, so the whole shell's is a sum over the
4051/// faces' loops. Every edge of a closed shell is walked exactly twice, once
4052/// each way, so **`Σ_faces ∫ n dA` is exactly zero on a closed shell** — a
4053/// statement about the RECORD, with no tolerance in it, as long as the two
4054/// faces meeting at an edge walk the same curve. They do not: each walks its
4055/// OWN TRIM, the pcurve mapped through its own surface, and what is left over
4056/// is the sum of every trim's departure from the edge it claims.
4057///
4058/// That residual is the one whole-shell number that reads the trims — which
4059/// is what every mass property is actually an integral over
4060/// (`geometry/pcurve.rs`, `PCURVE_REFINEMENT_TOLERANCE`) — and nothing in the
4061/// kernel read it before 2026-09-16. `validate()` compares a pcurve to its
4062/// edge at `pcurve_consistency` (4e-3 on a small model, 2.5% of the diagonal
4063/// on a large one): three defects on the notched cap sat between 5e-7 and
4064/// 1.9e-5, four orders inside that bar, and the solid's volume matched its
4065/// closed form because two of them cancelled.
4066///
4067/// ## The bar, and where it comes from
4068///
4069/// Write a trim as its edge plus a departure, `r = e + d`. Over a CLOSED loop,
4070///
4071/// ```text
4072/// (1/2)∮ r×dr − (1/2)∮ e×de = (1/2)∮ (e×dd + d×de) + (1/2)∮ d×dd
4073/// = ∮ d×de + (1/2)∮ d×dd
4074/// ```
4075///
4076/// (the middle step is one integration by parts: `∮ e×dd = −∮ de×d` because
4077/// the loop closes). So a face whose every trim lies within `δ` of its edge
4078/// departs by at most `δ · L`, its own boundary length, and the second-order
4079/// term is `δ²`-small. Summing over a closed shell, whose faces walk every
4080/// edge twice, the strict bound is `2δ · Σ|e|`.
4081///
4082/// `δ` is [`crate::PCURVE_REFINEMENT_TOLERANCE`], 1e-7 — the 3D distance
4083/// `fit_pcurve_on_surface` refines a trim to, and the floor under every mass
4084/// property. The bar this module applies is the TIGHTER
4085/// `δ · Σ|e|` — half the strict bound — because the worst case it drops is a
4086/// shell where every pair of trims sits at the floor on OPPOSITE sides of
4087/// every edge all the way round. A shell between the two is named, and the
4088/// per-face `bar` says which faces put it there.
4089///
4090/// Two things the bar does not cover, both reported rather than absorbed:
4091///
4092/// * a fit that exited at [`crate::PcurveFitExit::SampleCeiling`] never
4093/// reached the floor, so a shell carrying one can be over the bar and still
4094/// be the best this kernel builds. That is a finding about the fit, not a
4095/// reason to widen the bar.
4096/// * the quadrature's own error, reported per shell as
4097/// [`ShellVectorArea::quadrature_error`]. A residual inside its own error
4098/// bar is not a measurement.
4099///
4100/// ## What it does not ask
4101///
4102/// It does not ask whether a face's trim is in the right PLACE — a trim
4103/// displaced along its own edge, or two faces' trims displaced the same way,
4104/// cancel in this sum exactly as they cancel in the volume. It is a closure
4105/// test, not a fit test: `d` enters only through `∮ d×de`. And it says
4106/// nothing about an open shell, where the residual is the boundary's own
4107/// vector area and not a defect.
4108pub fn shell_vector_areas(solid: &BrepSolid) -> VectorAreaReport {
4109 shell_vector_areas_scan(solid, vector_area_span_budget(), "raise BREP_VECTOR_AREA_SPANS to read it")
4110}
4111
4112/// [`shell_vector_areas`] with an explicit span budget instead of the
4113/// default ([`VECTOR_AREA_SPAN_BUDGET`], or `BREP_VECTOR_AREA_SPANS`). Every
4114/// quadrature target, bar and reading is the same; only where the scan gives
4115/// up moves. For a caller that must judge a body the default cannot afford
4116/// and has a stated bound of its own (the inherited-trim refit's gate reads
4117/// at most two default budgets); the public entry and its default are
4118/// unchanged.
4119pub(crate) fn shell_vector_areas_with_budget(solid: &BrepSolid, span_budget: usize) -> VectorAreaReport {
4120 shell_vector_areas_scan(solid, span_budget, "raise the caller's span budget to read it")
4121}
4122
4123/// The scan behind both entries; `advice` ends an over-budget row and says
4124/// which budget the reader can raise (the environment's for the public
4125/// entry, the caller's for an explicit one).
4126fn shell_vector_areas_scan(solid: &BrepSolid, span_budget: usize, advice: &str) -> VectorAreaReport {
4127 let mut report = VectorAreaReport {
4128 shells: Vec::new(),
4129 unreadable: Vec::new(),
4130 };
4131 let edges: HashMap<u64, &crate::topology::EdgeRecord> =
4132 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
4133 let vertices: HashMap<u64, Vec3> = solid
4134 .vertices
4135 .iter()
4136 .map(|vertex| (vertex.id, vertex.point))
4137 .collect();
4138 for shell in &solid.shells {
4139 // Which edges this shell uses, and how often — the closedness test,
4140 // and the edge set the bar's length is taken over.
4141 let mut uses: HashMap<u64, usize> = HashMap::default();
4142 for coedge in shell
4143 .faces
4144 .iter()
4145 .flat_map(|face| &face.loops)
4146 .flat_map(|record| &record.coedges)
4147 {
4148 *uses.entry(coedge.edge_id).or_insert(0) += 1;
4149 }
4150 let closed = uses.iter().all(|(id, count)| {
4151 *count == 2
4152 || edges
4153 .get(id)
4154 .map(|edge| edge.degenerate)
4155 .unwrap_or(false)
4156 });
4157
4158 // The reference: the centre of the bounding box of the vertices this
4159 // shell's edges use. Far-from-origin arms cost digits in a sum read
4160 // at 1e-9, and the kernel's volume integrator anchors locally for the
4161 // same reason.
4162 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
4163 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
4164 let mut seen = false;
4165 for point in uses
4166 .keys()
4167 .filter_map(|id| edges.get(id))
4168 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
4169 .filter_map(|id| vertices.get(&id))
4170 {
4171 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite() {
4172 continue;
4173 }
4174 low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
4175 high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
4176 seen = true;
4177 }
4178 let reference = if seen {
4179 low.add(high).scale(0.5)
4180 } else {
4181 Vec3::new(0.0, 0.0, 0.0)
4182 };
4183
4184 // Each edge's own integral and length, computed ONCE and added with
4185 // the coedge's sign: the two uses of an edge then cancel to rounding
4186 // rather than to the quadrature, so `edge_residual` reads the
4187 // BOOKKEEPING (is every edge used twice, and once each way) and the
4188 // trims carry the geometry.
4189 // Per edge: its integral over its own ordered span, its arc length,
4190 // and the two end points — computed once and read by both coedges, so
4191 // the two uses cancel to rounding rather than to the quadrature.
4192 // What this shell's reading is ALLOWED to be wrong by, spread over every
4193 // leaf the depth limit permits — the same bound the per-arc floor above
4194 // publishes (`VECTOR_AREA_QUADRATURE_FLOOR` per unit of boundary), stated
4195 // once for the shell instead of per span.
4196 //
4197 // A span's own size cannot carry that bound. Where a trim's
4198 // parameterization has collapsed, the span's magnitude and arc vanish
4199 // while the disagreement between its halves and its whole does not: it is
4200 // round-off in a tangent that is no longer resolved. Then the target is
4201 // unreachable and the span bisects to the depth limit, every time.
4202 // Measured on `fixture_25_cube_pierce_pole_gap_bridge`, a FOUR-face body:
4203 // 313,900 of its 355,610 spans, 5.0 s, for a contribution of 1e-14 against
4204 // a bar of 1.4e-4. A reading may stop once it is inside what the verdict
4205 // allows, and this says where that is.
4206 let mut shell_edge_length = 0.0f64;
4207 let mut top_spans = 0usize;
4208 {
4209 let mut measured: HashSet<u64> = HashSet::default();
4210 for face in &shell.faces {
4211 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
4212 if let Ok(domain) = coedge.pcurve.domain() {
4213 if let Ok(breaks) = vector_area_breaks(&coedge.pcurve, domain[0], domain[1]) {
4214 top_spans += breaks.len().saturating_sub(1);
4215 }
4216 }
4217 let Some(record) = edges.get(&coedge.edge_id) else { continue };
4218 if !measured.insert(record.id) {
4219 continue;
4220 }
4221 let (lo, hi) = if record.t0 <= record.t1 { (record.t0, record.t1) } else { (record.t1, record.t0) };
4222 let on_edge = |parameter: f64| -> Result<(Vec3, Vec3), String> { record.curve.deriv1(parameter) };
4223 if let Ok(breaks) = vector_area_breaks(&record.curve, lo, hi) {
4224 top_spans += breaks.len().saturating_sub(1);
4225 if let Ok(length) = vector_area_length(&on_edge, &breaks) {
4226 shell_edge_length += length;
4227 }
4228 }
4229 }
4230 }
4231 }
4232 let share = if top_spans == 0 {
4233 0.0
4234 } else {
4235 vector_area_quadrature_floor() * 2.0 * shell_edge_length
4236 / (top_spans as f64 * (1u64 << vector_area_quadrature_depth()) as f64)
4237 };
4238 let mut edge_terms: HashMap<u64, (Vec3, f64, Vec3, Vec3)> = HashMap::default();
4239 let mut quadrature_error = 0.0;
4240 let mut spans = 0usize;
4241 let mut exhausted = 0usize;
4242 let mut unreadable_here = 0usize;
4243 let mut over_budget = false;
4244 let mut face_areas: Vec<FaceVectorArea> = Vec::with_capacity(shell.faces.len());
4245 let mut trim_residual = Vec3::new(0.0, 0.0, 0.0);
4246 let mut edge_residual = Vec3::new(0.0, 0.0, 0.0);
4247 let mut trim_gap = 0.0;
4248 let mut edge_gap = 0.0;
4249 let mut boundary_length = 0.0;
4250 for face in &shell.faces {
4251 // The surface's own knot levels, and its parameter periods: a
4252 // trim span that crosses one is cut there. The pcurve knows
4253 // nothing of the surface's knots, and a Gauss rule read straight
4254 // across one is the same 0.3% error `winding_loops_integral`
4255 // records — five orders above what this check reads. Bisection
4256 // alone cannot fix it either: a kink converges polynomially, so
4257 // the recursion bottoms out at its depth limit instead.
4258 let levels = surface_breaks(&face.surface)
4259 .ok()
4260 .zip(face.surface.domain_u().ok())
4261 .zip(face.surface.domain_v().ok())
4262 .map(|(((u_breaks, v_breaks), [u0, u1]), [v0, v1])| {
4263 (u_breaks, v_breaks, u1 - u0, v1 - v0)
4264 });
4265 let mut trim = Vec3::new(0.0, 0.0, 0.0);
4266 let mut edge = Vec3::new(0.0, 0.0, 0.0);
4267 let mut face_length = 0.0;
4268 let mut face_gap = 0.0;
4269 let mut failed = false;
4270 for loop_record in &face.loops {
4271 // A loop is walked as a CLOSED curve: each coedge's own
4272 // integral, plus the straight chord from where the previous
4273 // one ended to where this one starts, and the chord home at
4274 // the end. Those chords are not bookkeeping — two trims
4275 // arriving at one vertex are fitted independently and land a
4276 // fit-accuracy apart, so the sampled loop has small jumps in
4277 // it, and `(1/2) ∮ r × dr` over an OPEN curve is not a vector
4278 // area at all: it moves with the reference by
4279 // `(1/2) R × Σ jumps`. Measured on the notched cap the jumps
4280 // total 4.1e-7 and that reference dependence is 2e-6, half
4281 // the residual being read. Closed with its chords the loop
4282 // has `∮ dr = 0` exactly and the number is a property of the
4283 // face.
4284 let mut trim_previous: Option<Vec3> = None;
4285 let mut trim_first: Option<Vec3> = None;
4286 let mut edge_previous: Option<Vec3> = None;
4287 let mut edge_first: Option<Vec3> = None;
4288 for coedge in &loop_record.coedges {
4289 // The trim: the pcurve through this face's own surface.
4290 // `deriv1_extended` and not `deriv1`, because a sample a
4291 // rounding past a periodic domain must WRAP; the plain
4292 // evaluator clamps, which would flatten the curve onto
4293 // the domain boundary and manufacture a residual.
4294 let on_trim = |parameter: f64| -> Result<(Vec3, Vec3), String> {
4295 let (uv, duv) = coedge.pcurve.deriv1(parameter)?;
4296 let (point, su, sv) = face.surface.deriv1_extended(uv.x, uv.y)?;
4297 Ok((point, su.scale(duv.x).add(sv.scale(duv.y))))
4298 };
4299 let mut note = |message: String| {
4300 report
4301 .unreadable
4302 .push(format!("shell {} face {}: {message}", shell.id, face.id));
4303 };
4304 let trim_span = coedge.pcurve.domain().and_then(|domain| {
4305 let mut breaks =
4306 vector_area_breaks(&coedge.pcurve, domain[0], domain[1])?;
4307 if let Some((u_breaks, v_breaks, u_period, v_period)) = levels.as_ref() {
4308 breaks = split_at_surface_breaks(
4309 &coedge.pcurve,
4310 breaks,
4311 u_breaks,
4312 *u_period,
4313 true,
4314 false,
4315 )?;
4316 breaks = split_at_surface_breaks(
4317 &coedge.pcurve,
4318 breaks,
4319 v_breaks,
4320 *v_period,
4321 false,
4322 false,
4323 )?;
4324 }
4325 let value = vector_area_of(
4326 &on_trim,
4327 &breaks,
4328 reference,
4329 share,
4330 &mut quadrature_error,
4331 &mut spans,
4332 &mut exhausted,
4333 )?;
4334 Ok((value, on_trim(domain[0])?.0, on_trim(domain[1])?.0))
4335 });
4336 match trim_span {
4337 Ok((value, start, end)) => {
4338 trim = trim.add(value);
4339 if let Some(previous) = trim_previous {
4340 trim = trim.add(chord_area(previous, start, reference));
4341 trim_gap += start.sub(previous).length();
4342 face_gap += start.sub(previous).length();
4343 } else {
4344 trim_first = Some(start);
4345 }
4346 trim_previous = Some(end);
4347 }
4348 Err(message) => {
4349 note(format!("coedge {}: {message}", coedge.id));
4350 unreadable_here += 1;
4351 failed = true;
4352 }
4353 }
4354
4355 // The edge: the 3D curve, in the coedge's direction.
4356 let Some(record) = edges.get(&coedge.edge_id) else {
4357 note(format!(
4358 "coedge {} names edge {}, which is not in the record",
4359 coedge.id, coedge.edge_id
4360 ));
4361 unreadable_here += 1;
4362 failed = true;
4363 continue;
4364 };
4365 if !edge_terms.contains_key(&record.id) {
4366 let on_edge = |parameter: f64| -> Result<(Vec3, Vec3), String> {
4367 record.curve.deriv1(parameter)
4368 };
4369 let (lo, hi) = if record.t0 <= record.t1 {
4370 (record.t0, record.t1)
4371 } else {
4372 (record.t1, record.t0)
4373 };
4374 let term = vector_area_breaks(&record.curve, lo, hi).and_then(|breaks| {
4375 let value = vector_area_of(
4376 &on_edge,
4377 &breaks,
4378 reference,
4379 share,
4380 &mut quadrature_error,
4381 &mut spans,
4382 &mut exhausted,
4383 )?;
4384 let length = vector_area_length(&on_edge, &breaks)?;
4385 Ok((value, length, on_edge(lo)?.0, on_edge(hi)?.0))
4386 });
4387 match term {
4388 Ok(term) => {
4389 edge_terms.insert(record.id, term);
4390 }
4391 Err(message) => {
4392 note(format!("edge {}: {message}", record.id));
4393 unreadable_here += 1;
4394 failed = true;
4395 continue;
4396 }
4397 }
4398 }
4399 let Some((value, length, low, high)) = edge_terms.get(&record.id).copied()
4400 else {
4401 continue;
4402 };
4403 // `t0 > t1` on the record is the same span walked the
4404 // other way; the integral above was taken over the
4405 // ordered span, so the record's own order is one sign and
4406 // the coedge's `forward` the other.
4407 let ordered = if record.t0 <= record.t1 { 1.0 } else { -1.0 };
4408 let sense = if coedge.forward { 1.0 } else { -1.0 };
4409 let signed = ordered * sense;
4410 edge = edge.add(value.scale(signed));
4411 let (start, end) = if signed > 0.0 { (low, high) } else { (high, low) };
4412 if let Some(previous) = edge_previous {
4413 edge = edge.add(chord_area(previous, start, reference));
4414 edge_gap += start.sub(previous).length();
4415 } else {
4416 edge_first = Some(start);
4417 }
4418 edge_previous = Some(end);
4419 face_length += length;
4420 }
4421 if let (Some(previous), Some(first)) = (trim_previous, trim_first) {
4422 trim = trim.add(chord_area(previous, first, reference));
4423 trim_gap += first.sub(previous).length();
4424 face_gap += first.sub(previous).length();
4425 }
4426 if let (Some(previous), Some(first)) = (edge_previous, edge_first) {
4427 edge = edge.add(chord_area(previous, first, reference));
4428 edge_gap += first.sub(previous).length();
4429 }
4430 }
4431 if failed {
4432 continue;
4433 }
4434 // The budget, checked per face so a shell gives up on a face
4435 // boundary rather than part-way through a loop. `over_budget`
4436 // makes the shell unreadable below; it is never a residual.
4437 if spans > span_budget {
4438 over_budget = true;
4439 report.unreadable.push(format!(
4440 "shell {}: gave up after {} spans on {} of {} faces (budget {}); UNJUDGED rather than clean — {advice}",
4441 shell.id,
4442 spans,
4443 face_areas.len() + 1,
4444 shell.faces.len(),
4445 span_budget
4446 ));
4447 break;
4448 }
4449 boundary_length += face_length;
4450 trim_residual = trim_residual.add(trim);
4451 edge_residual = edge_residual.add(edge);
4452 face_areas.push(FaceVectorArea {
4453 shell: shell.id,
4454 face: face.id,
4455 face_name: face.name.clone(),
4456 trim,
4457 edge,
4458 departure: trim.sub(edge),
4459 boundary_length: face_length,
4460 bar: crate::pcurve::PCURVE_REFINEMENT_TOLERANCE * face_length,
4461 closure_gap: face_gap,
4462 });
4463 }
4464 let edge_length: f64 = edge_terms.values().map(|(_, length, _, _)| *length).sum();
4465 report.shells.push(ShellVectorArea {
4466 shell: shell.id,
4467 faces: shell.faces.len(),
4468 // A face that could not be read leaves its share of the residual
4469 // out of the sum, so the shell is not claimed to close — and a
4470 // shell abandoned on the span budget is in exactly that position,
4471 // with whole faces missing from the sum.
4472 closed: closed && unreadable_here == 0 && !over_budget,
4473 reference,
4474 trim_residual,
4475 edge_residual,
4476 edge_length,
4477 boundary_length,
4478 bar: crate::pcurve::PCURVE_REFINEMENT_TOLERANCE * edge_length,
4479 quadrature_error,
4480 spans,
4481 spans_at_depth_limit: exhausted,
4482 trim_closure_gap: trim_gap,
4483 edge_closure_gap: edge_gap,
4484 gave_up: over_budget,
4485 face_areas,
4486 });
4487 }
4488 report
4489}
4490
4491/// Completed acceptance readings, keyed by every serialized body field and
4492/// policy input. Cold misses run the original reader. Entries contain no body
4493/// references; the bounded per-thread store can safely outlive a document.
4494/// Test builds opt in per thread so unrelated fault-injection tests cannot
4495/// accidentally consume an earlier clean reading instead of their injection.
4496pub(crate) mod network_acceptance_memo {
4497 use super::{BrepSolid, SelfIntersectionOptions, SelfIntersectionReport};
4498 use crate::mass_properties::TwoLane;
4499 use std::cell::{Cell, RefCell};
4500 use rustc_hash::FxHashMap;
4501
4502 const DEFAULT: bool = true;
4503 const CAPACITY: usize = 32;
4504 thread_local! {
4505 static REPORTS: RefCell<FxHashMap<[u64; 2], SelfIntersectionReport>> = RefCell::new(FxHashMap::default());
4506 static HITS: Cell<u64> = const { Cell::new(0) };
4507 static READS: Cell<u64> = const { Cell::new(0) };
4508 }
4509
4510 fn enabled() -> bool {
4511 if cfg!(target_arch = "wasm32") { return DEFAULT; }
4512 match std::env::var("BREP_NETWORK_ACCEPTANCE_MEMO").as_deref() {
4513 Ok("1") | Ok("on") => true,
4514 Ok("0") | Ok("off") => false,
4515 _ => DEFAULT,
4516 }
4517 }
4518
4519 /// Includes all BREP environment inputs, even diagnostic policy overrides,
4520 /// plus the classifier's thread-local policy switches. Byte encoding also
4521 /// distinguishes non-UTF8 values. Option/body floats are hashed as bits.
4522 pub(crate) fn policy() -> Option<[u64; 2]> {
4523 if !enabled() { return None; }
4524 let mut hash = TwoLane::new();
4525 hash.f64(crate::classification::lane_switches());
4526 #[cfg(not(target_arch = "wasm32"))]
4527 {
4528 let mut variables: Vec<_> = std::env::vars_os().filter(|(name, _)| name.as_encoded_bytes().starts_with(b"BREP_")).collect();
4529 variables.sort();
4530 hash.usize(variables.len());
4531 for (name, value) in variables {
4532 for bytes in [name.as_encoded_bytes(), value.as_encoded_bytes()] {
4533 hash.usize(bytes.len());
4534 for &byte in bytes { hash.u64(u64::from(byte)); }
4535 }
4536 }
4537 }
4538 Some(hash.finish())
4539 }
4540
4541 pub(super) fn scan_key(body: &BrepSolid, options: SelfIntersectionOptions, certify_pairs: bool) -> Option<[u64; 2]> {
4542 let policy = policy()?;
4543 let (a, b) = body.validate_memo_key()?;
4544 let mut hash = TwoLane::new();
4545 for word in [a, b, policy[0], policy[1]] { hash.u64(word); }
4546 hash.f64(options.chord_tolerance);
4547 hash.f64(options.straddle_band);
4548 hash.f64(options.min_crossing_length);
4549 hash.f64(options.weld);
4550 hash.usize(options.max_triangles);
4551 hash.bool(certify_pairs);
4552 Some(hash.finish())
4553 }
4554
4555 pub(super) fn lookup(key: [u64; 2]) -> Option<SelfIntersectionReport> {
4556 let report = REPORTS.with(|reports| reports.borrow().get(&key).cloned());
4557 if report.is_some() {
4558 HITS.with(|hits| hits.set(hits.get() + 1));
4559 crate::blend::record_count("count.soundness.report_memo_hits", 1);
4560 }
4561 report
4562 }
4563 pub(super) fn note_read() {
4564 READS.with(|reads| reads.set(reads.get() + 1));
4565 crate::blend::record_count("count.soundness.report_memo_reads", 1);
4566 }
4567 pub(super) fn store(key: [u64; 2], report: SelfIntersectionReport) {
4568 REPORTS.with(|reports| {
4569 let mut reports = reports.borrow_mut();
4570 if reports.len() >= CAPACITY { reports.clear(); }
4571 reports.insert(key, report);
4572 });
4573 }
4574
4575}
4576