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 mut report = empty_intersection_report(options);
985 if !(options.chord_tolerance > 0.0) || !options.chord_tolerance.is_finite() {
986 return Err("solid_self_intersections: chord tolerance must be positive".into());
987 }
988
989 let profile = scan_profile_enabled().then(Instant::now);
990 let stage_ms = |started: Option<Instant>| -> f64 {
991 started.map(|at| at.elapsed().as_secs_f64() * 1_000.0).unwrap_or(0.0)
992 };
993 // The face-stride entry point rather than `tessellate_brep_watertight`:
994 // the latter runs a coherent-orientation pass and a mesh validation that
995 // can fail on exactly the broken solids this detector exists to inspect,
996 // and winding is irrelevant to a crossing test.
997 let (mesh, face_keys) = crate::watertight_tessellation::tessellate_brep_watertight_face_stride_keyed(
998 solid, options.chord_tolerance, 1, 0,
999 )?;
1000 let tessellate_ms = stage_ms(profile);
1001 let triangle_count = mesh.indices.len() / 3;
1002 report.triangles = triangle_count;
1003 if triangle_count == 0 {
1004 return Ok(report);
1005 }
1006 if triangle_count > options.max_triangles {
1007 report.truncated = true;
1008 return Ok(report);
1009 }
1010
1011 // Faces in the tessellator's sequential order, so a triangle's `face_ids`
1012 // entry indexes straight into this.
1013 let faces: Vec<&crate::topology::FaceRecord> = solid
1014 .shells
1015 .iter()
1016 .flat_map(|shell| shell.faces.iter())
1017 .collect();
1018
1019 let mut census = collect_census.then(|| {
1020 let ids: Vec<_> = mesh.face_ids.iter().map(|&index| faces[index as usize].id).collect();
1021 census::ScanCensus::new(&ids)
1022 });
1023 let point_of = |index: u32| -> Vec3 {
1024 let base = index as usize * 3;
1025 Vec3::new(
1026 mesh.positions[base],
1027 mesh.positions[base + 1],
1028 mesh.positions[base + 2],
1029 )
1030 };
1031
1032 let weld_started = scan_profile_enabled().then(Instant::now);
1033 let welded = weld_positions(&mesh.positions, options.weld);
1034 let weld_ms = stage_ms(weld_started);
1035
1036 let bvh_started = scan_profile_enabled().then(Instant::now);
1037 let mut boxes = Vec::with_capacity(triangle_count);
1038 let mut corners: Vec<[Vec3; 3]> = Vec::with_capacity(triangle_count);
1039 for triangle in 0..triangle_count {
1040 let indices = [
1041 mesh.indices[triangle * 3],
1042 mesh.indices[triangle * 3 + 1],
1043 mesh.indices[triangle * 3 + 2],
1044 ];
1045 let points = [
1046 point_of(indices[0]),
1047 point_of(indices[1]),
1048 point_of(indices[2]),
1049 ];
1050 boxes.push(Aabb::from_points(points));
1051 corners.push(points);
1052 }
1053 let mut certificates = (certify_pairs && trim_samples_enabled() && node_membership_enabled()
1054 && faces.len() <= 256
1055 && options.straddle_band.is_finite() && options.straddle_band > 0.
1056 && options.min_crossing_length.is_finite() && options.min_crossing_length > 0.
1057 ).then(|| pair_certificate::Plan::new(solid, &faces, &corners, &mesh.face_ids));
1058 let bvh = Bvh::build(&boxes);
1059 let bvh_ms = stage_ms(bvh_started);
1060 // The face-pair memo (`pair_memo.rs`): a pair of fragments the sweep
1061 // read clean under these bands skips its triangle crossing tests; every
1062 // count the report carries is kept. Off while the census collects (its
1063 // tallies read every candidate) and when the mesh's face keys are not
1064 // the faces' (a stride other than 1 never reaches here).
1065 let pair_memo = pair_memo::enabled() && !collect_census && face_keys.len() == faces.len();
1066 let pair_options = pair_memo::options_key(options.chord_tolerance, options.weld, options.min_crossing_length);
1067 // Per unordered face pair: 0 untouched, 1 swept clean so far, 2 served
1068 // from the memo, 3 crossed in the mesh.
1069 let face_count = faces.len();
1070 let mut pair_state: Vec<u8> = if pair_memo { vec![0u8; face_count * face_count] } else { Vec::new() };
1071 let sweep_started = scan_profile_enabled().then(Instant::now);
1072 let mut overlaps: u64 = 0;
1073 let mut crossings: u64 = 0;
1074 let mut confirm_ms = 0.0f64;
1075
1076 // Deepest confirmed crossing per unordered face pair, and the
1077 // widest-separated confirmed fold per face.
1078 let mut best: HashMap<(u64, u64), FaceCrossing> = HashMap::default();
1079 let mut folded: HashMap<u64, FaceFold> = HashMap::default();
1080 // Same-face mesh crossings, per face index, as `(crossing length, point)`.
1081 let mut same_face: HashMap<usize, Vec<(f64, Vec3)>> = HashMap::default();
1082 // Candidates the mesh confirmation could not decide, asked of the carriers
1083 // after the sweep: `(face index, face index, cause, crossing segment)`.
1084 let mut pending: Vec<(usize, usize, CrossingUndecidedCause, Vec3, Vec3)> = Vec::new();
1085 let mut hits = Vec::new();
1086 for first in 0..triangle_count {
1087 hits.clear();
1088 bvh.overlapping(boxes[first], 0.0, &mut hits);
1089 for second in hits.iter().copied() {
1090 if second <= first {
1091 continue;
1092 }
1093 overlaps += 1;
1094 if let Some(census) = &mut census { census.pair(first, second, false); }
1095 let shares_vertex = (0..3).any(|a| {
1096 let wa = welded[mesh.indices[first * 3 + a] as usize];
1097 (0..3).any(|b| wa == welded[mesh.indices[second * 3 + b] as usize])
1098 });
1099 if shares_vertex {
1100 continue;
1101 }
1102 if let Some(plan) = &mut certificates {
1103 if plan.skips(mesh.face_ids[first], mesh.face_ids[second]) {
1104 plan.scope.skipped_candidate_pairs += 1;
1105 continue;
1106 }
1107 }
1108 report.candidate_pairs += 1;
1109 if let Some(census) = &mut census { census.pair(first, second, true); }
1110 if pair_memo {
1111 let (fa, fb) = (mesh.face_ids[first] as usize, mesh.face_ids[second] as usize);
1112 let slot = fa.min(fb) * face_count + fa.max(fb);
1113 match pair_state[slot] {
1114 2 => continue,
1115 0 => {
1116 let served = pair_memo::contains(pair_memo::pair_key(pair_options, face_keys[fa], face_keys[fb]));
1117 pair_state[slot] = if served { 2 } else { 1 };
1118 if served {
1119 continue;
1120 }
1121 }
1122 _ => {}
1123 }
1124 }
1125 let segment = triangle_crossing(&corners[first], &corners[second]);
1126 let Some((start, end)) = segment else {
1127 continue;
1128 };
1129 let length = end.sub(start).length();
1130 if length <= options.min_crossing_length {
1131 continue;
1132 }
1133 crossings += 1;
1134 let confirm_started = scan_profile_enabled().then(Instant::now);
1135 let face_first = mesh.face_ids[first] as usize;
1136 let face_second = mesh.face_ids[second] as usize;
1137 if pair_memo {
1138 pair_state[face_first.min(face_second) * face_count + face_first.max(face_second)] = 3;
1139 }
1140 if face_first == face_second {
1141 report.same_face_candidates += 1;
1142 // Confirmed in a SECOND pass, per face: the fold test needs a
1143 // sample grid over the face's own domain, and building that
1144 // once per face is the difference between a cheap check and a
1145 // tessellation-sized one.
1146 same_face
1147 .entry(face_first)
1148 .or_insert_with(Vec::new)
1149 .push((length, start.add(end).scale(0.5)));
1150 continue;
1151 }
1152 let (Some(face_a), Some(face_b)) = (faces.get(face_first), faces.get(face_second))
1153 else {
1154 pending.push((face_first, face_second, CrossingUndecidedCause::MissingFace, start, end));
1155 continue;
1156 };
1157 // The mesh crossing's midpoint must be a point BOTH faces own: a
1158 // crossing between two meshes whose witness lies outside either
1159 // trim is a crossing of the tessellation, not of the body
1160 // (2026-10-03, `anotherBooleanFail` ∩ sphere: four vendor
1161 // cone–cone junctions convicted at 1.395e-2 from interior mesh
1162 // nodes planted 0.26° past their own trims on a chart where the
1163 // junction pcurve bows 2.8e-3 in u). The witness is a chord point,
1164 // so only its carrier FOOT is asked of each trim (no reach test:
1165 // the oblique bore-mouth control's genuine crossing sits a chord
1166 // sagitta off the bore). A witness that cannot be classified
1167 // leaves the pair undecided, never clear.
1168 let witness = start.add(end).scale(0.5);
1169 match (footprint_material(face_a, witness), footprint_material(face_b, witness)) {
1170 (Some(true), Some(true)) => {}
1171 (in_a, in_b) => {
1172 pierce_trace!(
1173 "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",
1174 face_a.id, face_b.id, witness.x, witness.y, witness.z, face_a.id, face_b.id
1175 );
1176 pending.push((face_first, face_second, CrossingUndecidedCause::Witness, start, end));
1177 continue;
1178 }
1179 }
1180 // TWO confirmations, asked in order, and the first one is
1181 // unchanged in what it asks: a triangle's OWN CORNERS must straddle
1182 // the other carrier, in both directions — read over the corners
1183 // the face OWNS (a tessellation node outside its own trim is not
1184 // the face's material and its side counts for nothing).
1185 let forward = straddle_material(face_a, &face_b.surface, &corners[first], options.straddle_band);
1186 let backward = straddle_material(face_b, &face_a.surface, &corners[second], options.straddle_band);
1187 let corner_margin = match (forward, backward) {
1188 (Some(forward), Some(backward))
1189 if forward > options.straddle_band && backward > options.straddle_band =>
1190 {
1191 Some(forward.min(backward))
1192 }
1193 _ => None,
1194 };
1195 // The SECOND runs only when the first did not convict, and it is
1196 // the one a PLANAR face passing through a curved one needs: the
1197 // corner test reads a margin of zero there, because every corner
1198 // of the plane's few triangles is outside the cylinder its
1199 // INTERIOR passes through. See `pierce_straddle`.
1200 if corner_margin.is_none() {
1201 pierce_trace!(
1202 "pair faces {}|{} triangles {first}|{second}: corners forward {forward:?} backward {backward:?}; crossing ({:.6}, {:.6}, {:.6})-({:.6}, {:.6}, {:.6}); triangle A {:?} triangle B {:?}",
1203 face_a.id,
1204 face_b.id,
1205 start.x,
1206 start.y,
1207 start.z,
1208 end.x,
1209 end.y,
1210 end.z,
1211 corners[first].map(|corner| (corner.x, corner.y, corner.z)),
1212 corners[second].map(|corner| (corner.x, corner.y, corner.z)),
1213 );
1214 }
1215 let (margin, confirmation, penetration) = match corner_margin {
1216 Some(margin) => (margin, CrossingConfirmation::Corners, 0.0),
1217 None => match pierce_straddle(
1218 &corners[first],
1219 &corners[second],
1220 face_a,
1221 face_b,
1222 (start, end),
1223 options.straddle_band,
1224 ) {
1225 PierceVerdict::Confirmed(hit) => {
1226 pierce_trace!(
1227 " verdict: confirmed, bracket {:.6e} penetration {:.6e}",
1228 hit.bracket,
1229 hit.penetration
1230 );
1231 (hit.margin(), CrossingConfirmation::Pierce, hit.penetration)
1232 }
1233 verdict => {
1234 // A projection or a normal that could not be evaluated
1235 // is undecided in EITHER lane, never a rejection.
1236 if forward.is_none()
1237 || backward.is_none()
1238 || matches!(verdict, PierceVerdict::Undecided)
1239 {
1240 pierce_trace!(" verdict: undecided");
1241 pending.push((face_first, face_second, CrossingUndecidedCause::Pierce, start, end));
1242 } else {
1243 pierce_trace!(" verdict: rejected");
1244 report.rejected += 1;
1245 }
1246 continue;
1247 }
1248 },
1249 };
1250 let key = if face_a.id <= face_b.id {
1251 (face_a.id, face_b.id)
1252 } else {
1253 (face_b.id, face_a.id)
1254 };
1255 let crossing = FaceCrossing {
1256 face_a: key.0,
1257 face_b: key.1,
1258 name_a: if face_a.id <= face_b.id {
1259 face_a.name.clone()
1260 } else {
1261 face_b.name.clone()
1262 },
1263 name_b: if face_a.id <= face_b.id {
1264 face_b.name.clone()
1265 } else {
1266 face_a.name.clone()
1267 },
1268 point: start.add(end).scale(0.5),
1269 crossing_length: length,
1270 straddle: margin,
1271 confirmation,
1272 penetration,
1273 };
1274 let slot = best.entry(key).or_insert_with(|| crossing.clone());
1275 if crossing.straddle > slot.straddle {
1276 *slot = crossing;
1277 }
1278 confirm_ms += stage_ms(confirm_started);
1279 }
1280 }
1281 let sweep_ms = stage_ms(sweep_started);
1282 if pair_memo {
1283 // Every pair that was swept and never crossed is clean under these
1284 // inputs; pairs no triangle box ever brought together cost nothing to
1285 // sweep again and are left out.
1286 let clean = (0..face_count).flat_map(|a| (a..face_count).map(move |b| (a, b)))
1287 .filter(|&(a, b)| pair_state[a * face_count + b] == 1)
1288 .map(|(a, b)| pair_memo::pair_key(pair_options, face_keys[a], face_keys[b]));
1289 pair_memo::store(clean);
1290 if profile.is_some() {
1291 let (hits, stored, entries) = pair_memo::take_counts();
1292 eprintln!("soundness.pair_memo served={hits} stored={stored} entries={entries}");
1293 }
1294 }
1295 let resolve_started = scan_profile_enabled().then(Instant::now);
1296 resolve_pending(solid, &faces, pending, options, &mut best, &mut report);
1297 let resolve_ms = stage_ms(resolve_started);
1298 let fold_started = scan_profile_enabled().then(Instant::now);
1299 let fold_counts = fold_memo::counts();
1300
1301 // ---- the fold pass ---------------------------------------------------
1302 // One grid per face that produced a same-face crossing at all, and at most
1303 // MAX_FOLD_PROBES points per face — the longest crossing segments, which
1304 // are the clearest evidence. A fold region produces as many candidates as
1305 // the mesh happens to have there, and confirming every one of them buys
1306 // nothing beyond the first.
1307 let mut triangles_on_face: HashMap<usize, usize> = HashMap::default();
1308 for face in mesh.face_ids.iter() {
1309 *triangles_on_face.entry(*face as usize).or_insert(0) += 1;
1310 }
1311 let mut face_order: Vec<usize> = same_face.keys().copied().collect();
1312 face_order.sort_unstable();
1313 for face_index in face_order {
1314 let Some(face) = faces.get(face_index) else {
1315 report.fold_undecided += same_face[&face_index].len();
1316 continue;
1317 };
1318 let mut points = same_face.remove(&face_index).unwrap_or_default();
1319 points.sort_by(|a, b| b.0.total_cmp(&a.0));
1320 points.truncate(MAX_FOLD_PROBES);
1321 let triangles = triangles_on_face.get(&face_index).copied().unwrap_or(0);
1322 let verdicts = fold_memo::read(face, triangles, &points, options);
1323 for verdict in verdicts {
1324 match verdict {
1325 FoldVerdict::Confirmed(fold) => {
1326 let slot = folded.entry(face.id).or_insert_with(|| fold.clone());
1327 if fold.uv_separation > slot.uv_separation {
1328 *slot = fold;
1329 }
1330 }
1331 FoldVerdict::Rejected => report.fold_rejected += 1,
1332 FoldVerdict::Undecided => report.fold_undecided += 1,
1333 }
1334 }
1335 }
1336
1337 if profile.is_some() {
1338 let after = fold_memo::counts();
1339 eprintln!("soundness.fold_memo reused={} read={}", after.0 - fold_counts.0, after.1 - fold_counts.1);
1340 }
1341 report.confirmed = best.into_values().collect();
1342 report
1343 .confirmed
1344 .sort_by(|a, b| b.straddle.total_cmp(&a.straddle).then(a.face_a.cmp(&b.face_a)));
1345 report.folds = folded.into_values().collect();
1346 report
1347 .folds
1348 .sort_by(|a, b| b.uv_separation.total_cmp(&a.uv_separation).then(a.face.cmp(&b.face)));
1349 if profile.is_some() {
1350 eprintln!(
1351 "soundness.profile ms={:.2} faces={} tris={triangle_count} chord={:.3e} | \
1352 tessellate_ms={tessellate_ms:.2} weld_ms={weld_ms:.2} bvh_ms={bvh_ms:.2} \
1353 sweep_ms={sweep_ms:.2} confirm_ms={confirm_ms:.2} resolve_ms={resolve_ms:.2} fold_ms={:.2} | \
1354 overlaps={overlaps} candidate_pairs={} mesh_crossings={crossings} \
1355 confirmed={} rejected={} undecided={} same_face={} folds={}",
1356 stage_ms(profile),
1357 faces.len(),
1358 options.chord_tolerance,
1359 stage_ms(fold_started),
1360 report.candidate_pairs,
1361 report.confirmed.len(),
1362 report.rejected,
1363 report.undecided,
1364 report.same_face_candidates,
1365 report.folds.len(),
1366 );
1367 }
1368 if let Some(plan) = certificates {
1369 if !plan.proofs.is_empty() {
1370 if profile.is_some() {
1371 let mut rules = std::collections::BTreeMap::new();
1372 for proof in &plan.proofs { *rules.entry(proof.rule).or_insert(0usize) += 1; }
1373 eprintln!("soundness.certificates {}", serde_json::json!({"rules": rules, "skipped_by_certificate": plan.scope}));
1374 }
1375 report.certified_pairs = plan.proofs;
1376 report.skipped_by_certificate = Some(plan.scope);
1377 }
1378 }
1379 if let Some(census) = census { census.emit(&boxes); }
1380 Ok(report)
1381}
1382
1383/// Which of this solid's faces FOLD THROUGH THEMSELVES in 3D — a face whose
1384/// surface carries two distinct trimmed parameter feet to one point in space:
1385/// an offset taken past the source's curvature radius, a section swept through
1386/// a bend tighter than its own half-width, a carrier fitted through a
1387/// swallowtail.
1388///
1389/// The brief's named entry point, and one pass: it is
1390/// [`solid_self_intersections`] read for its fold half, because both verdicts
1391/// come off the SAME tessellation and paying for that twice would buy nothing.
1392/// A caller that wants both should call `solid_self_intersections` directly and
1393/// read `confirmed` and `folds` off the one report.
1394pub fn face_self_intersections(solid: &BrepSolid) -> Result<Vec<FaceFold>, String> {
1395 let options = SelfIntersectionOptions::for_solid(solid);
1396 Ok(solid_self_intersections(solid, options)?.folds)
1397}
1398
1399/// Verdict of the fold confirmation for ONE same-face triangle pair.
1400enum FoldVerdict {
1401 Confirmed(FaceFold),
1402 Rejected,
1403 Undecided,
1404}
1405
1406
1407/// A coarse `(u, v) -> 3D` sample of ONE face's surface over its own domain,
1408/// at the resolution the tessellation already resolved the face at.
1409///
1410/// This is the fold test's instrument, and it exists because
1411/// `project_point_to_surface` cannot be one. A global projection onto a
1412/// FOLDED general surface is exactly the case its Newton is weakest at:
1413/// measured on the ribbon fixture, a point lying EXACTLY on the surface came
1414/// back with a foot 2.766 away from itself. The grid replaces that search with
1415/// an enumeration — every branch that comes near the point has a node near it
1416/// — and `project_point_to_surface_seeded` then refines each one WITHOUT
1417/// re-seeding globally, which is the same instrument the pcurve tracer uses to
1418/// stay on one branch of a surface that folds back.
1419struct FoldGrid {
1420 samples: Vec<(f64, f64, Vec3)>,
1421 /// Parameter distance at which two feet are ONE foot: half a cell. Two
1422 /// seeds inside it refine to the same place, which is exactly what chord
1423 /// error on a tightly curved face produces.
1424 merge: f64,
1425 /// The largest 3D step between neighbouring nodes, measured rather than
1426 /// assumed. A branch's nearest node can sit this far from the point, so it
1427 /// is how wide the seed window has to be to miss nothing.
1428 reach: f64,
1429}
1430
1431/// At most this many crossing points are confirmed per face. A fold region
1432/// produces as many mesh candidates as the tessellation happens to have
1433/// there; the verdict is a property of the FACE, so the longest crossing
1434/// segments — the clearest evidence — are enough.
1435const MAX_FOLD_PROBES: usize = 64;
1436
1437/// At most this many seeds are refined for one crossing point. A fold needs
1438/// TWO distinct feet; more than a handful of branches through one point is a
1439/// different shape than this detector claims.
1440const MAX_FOLD_SEEDS: usize = 16;
1441
1442/// Build the sample grid for one face, at twice the tessellation's own linear
1443/// density (`triangles` is how many triangles the mesh gave this face), so the
1444/// grid resolves anything the mesh that produced the candidate could.
1445fn fold_grid(surface: &NurbsSurface, triangles: usize) -> Option<FoldGrid> {
1446 let [u0, u1] = surface.domain_u().ok()?;
1447 let [v0, v1] = surface.domain_v().ok()?;
1448 let steps = (((triangles.max(1) as f64).sqrt().ceil() as usize) * 2).clamp(24, 192);
1449 let node = |index: usize| -> (f64, f64) {
1450 let iu = index / (steps + 1);
1451 let iv = index % (steps + 1);
1452 (
1453 u0 + (u1 - u0) * iu as f64 / steps as f64,
1454 v0 + (v1 - v0) * iv as f64 / steps as f64,
1455 )
1456 };
1457 let mut samples = Vec::with_capacity((steps + 1) * (steps + 1));
1458 for index in 0..(steps + 1) * (steps + 1) {
1459 let (u, v) = node(index);
1460 samples.push((u, v, surface.evaluate(u, v).ok()?));
1461 }
1462 let mut reach = 0.0f64;
1463 for iu in 0..=steps {
1464 for iv in 0..=steps {
1465 let here = samples[iu * (steps + 1) + iv].2;
1466 if iu < steps {
1467 reach = reach.max(samples[(iu + 1) * (steps + 1) + iv].2.sub(here).length());
1468 }
1469 if iv < steps {
1470 reach = reach.max(samples[iu * (steps + 1) + iv + 1].2.sub(here).length());
1471 }
1472 }
1473 }
1474 let cell_u = (u1 - u0) / steps as f64;
1475 let cell_v = (v1 - v0) / steps as f64;
1476 Some(FoldGrid {
1477 samples,
1478 merge: 0.5 * (cell_u * cell_u + cell_v * cell_v).sqrt(),
1479 reach,
1480 })
1481}
1482
1483/// The parameter distance between two feet, measured the SHORT way round every
1484/// closed direction.
1485fn parameter_separation(a: Vec2, b: Vec2, period: [Option<f64>; 2]) -> f64 {
1486 b.add(period_shift(b, a, period)).sub(a).length()
1487}
1488
1489/// Are these two feet ONE locus reached from opposite ends of the domain — a
1490/// seam the surface carries but does not DECLARE?
1491///
1492/// `closed_directions()` is an exact test and an imported carrier can fail it
1493/// while still closing: measured on `abc_00000026.step`, a face whose two
1494/// u-boundary isocurves sit 2.5e-2 apart (on a model whose pcurves are known to
1495/// be ~3.5e-2 off their own edges) put one foot at u = 0 and the other at
1496/// u = 1, a full span apart, on what is one curve. The period reduction cannot
1497/// fold those together because the surface is not closed to the precision that
1498/// test demands.
1499///
1500/// So it is MEASURED: when the two feet sit within `window` of opposite ends of
1501/// a direction, the two boundary isocurves are sampled and compared, and they
1502/// are one locus when every sample agrees within `band`. `window` is the grid's
1503/// own resolution — a foot is "at the boundary" when the grid cannot place it
1504/// anywhere else — and `band` is the same coincidence band the feet themselves
1505/// had to clear.
1506fn boundary_identified(
1507 surface: &NurbsSurface,
1508 a: Vec2,
1509 b: Vec2,
1510 domain_u: [f64; 2],
1511 domain_v: [f64; 2],
1512 window: f64,
1513 band: f64,
1514) -> bool {
1515 const SAMPLES: usize = 8;
1516 let straddles = |first: f64, second: f64, low: f64, high: f64| {
1517 (first - low).abs() <= window && (second - high).abs() <= window
1518 || (first - high).abs() <= window && (second - low).abs() <= window
1519 };
1520 let isocurves_agree = |along_u: bool| {
1521 for index in 0..=SAMPLES {
1522 let fraction = index as f64 / SAMPLES as f64;
1523 let (first, second) = if along_u {
1524 let v = domain_v[0] + (domain_v[1] - domain_v[0]) * fraction;
1525 (
1526 surface.evaluate(domain_u[0], v),
1527 surface.evaluate(domain_u[1], v),
1528 )
1529 } else {
1530 let u = domain_u[0] + (domain_u[1] - domain_u[0]) * fraction;
1531 (
1532 surface.evaluate(u, domain_v[0]),
1533 surface.evaluate(u, domain_v[1]),
1534 )
1535 };
1536 let (Ok(first), Ok(second)) = (first, second) else {
1537 return false;
1538 };
1539 if first.sub(second).length() > band {
1540 return false;
1541 }
1542 }
1543 true
1544 };
1545 if straddles(a.x, b.x, domain_u[0], domain_u[1]) && isocurves_agree(true) {
1546 return true;
1547 }
1548 straddles(a.y, b.y, domain_v[0], domain_v[1]) && isocurves_agree(false)
1549}
1550
1551/// Is this same-face mesh crossing a genuine 3D FOLD?
1552///
1553/// The face-versus-face straddle test cannot be asked here: both triangles lie
1554/// on one carrier, so every corner of both is at distance zero from the
1555/// surface it would be measured against. The question asked instead is the
1556/// parameter one, and it is the definition of a fold — the surface carries two
1557/// DISTINCT trimmed parameters to ONE point in space:
1558///
1559/// 1. **Enumerate the branches.** Every grid node within one cell's reach of
1560/// the crossing point is a seed ([`FoldGrid`] says why this is an
1561/// enumeration and not a search).
1562/// 2. **Refine each**, through the Newton that does NOT re-seed globally, and
1563/// keep the feet that actually reach the point within the mesh's own chord
1564/// band. Two seeds on ONE branch refine to one foot and are merged, which
1565/// is what chord error on a tightly curved face produces — so a face that
1566/// does not fold ends this step with a single foot and is rejected.
1567/// 3. **Both feet inside the trim.** An untrimmed carrier may fold wherever it
1568/// likes; only a fold the FACE carries is its defect.
1569/// 4. **The feet are TRANSVERSAL.** Distinct parameters carried to one point
1570/// with ONE tangent plane is a degenerate parameterization — a sphere's
1571/// pole, a seam — and not a fold.
1572///
1573/// The widest-separated qualifying pair is reported, with every margin it
1574/// cleared.
1575fn confirm_fold(
1576 face: &crate::topology::FaceRecord,
1577 grid: &FoldGrid,
1578 point: Vec3,
1579 options: SelfIntersectionOptions,
1580) -> FoldVerdict {
1581 let surface = &face.surface;
1582 let (Ok((closed_u, closed_v)), Ok(domain_u), Ok(domain_v)) = (
1583 surface.closed_directions(),
1584 surface.domain_u(),
1585 surface.domain_v(),
1586 ) else {
1587 fold_trace!("fold face {}: domain/closedness unreadable", face.id);
1588 return FoldVerdict::Undecided;
1589 };
1590 let span_u = domain_u[1] - domain_u[0];
1591 let span_v = domain_v[1] - domain_v[0];
1592 let period = [closed_u.then_some(span_u), closed_v.then_some(span_v)];
1593 // A tessellation vertex lies exactly on its surface and the crossing point
1594 // is within the chord tolerance of one, so this is the band every 3D
1595 // residual here is held to: the mesh's own accuracy.
1596 let coincidence = 2.0 * options.chord_tolerance;
1597
1598 let window = grid.reach + coincidence;
1599 let mut seeds: Vec<(f64, usize)> = grid
1600 .samples
1601 .iter()
1602 .enumerate()
1603 .filter_map(|(index, (_, _, sample))| {
1604 let distance = sample.sub(point).length();
1605 (distance <= window).then_some((distance, index))
1606 })
1607 .collect();
1608 if seeds.is_empty() {
1609 fold_trace!(
1610 "fold face {}: no grid node within {window:.3e} of the crossing point",
1611 face.id
1612 );
1613 return FoldVerdict::Undecided;
1614 }
1615 seeds.sort_by(|a, b| a.0.total_cmp(&b.0));
1616
1617 let mut feet: Vec<crate::projection::SurfaceProjection> = Vec::new();
1618 let mut used: Vec<Vec2> = Vec::new();
1619 for (_, index) in seeds {
1620 if feet.len() >= MAX_FOLD_SEEDS {
1621 break;
1622 }
1623 let (u, v, _) = grid.samples[index];
1624 let seed = Vec2 { x: u, y: v };
1625 if used
1626 .iter()
1627 .any(|taken| parameter_separation(*taken, seed, period) <= grid.merge)
1628 {
1629 continue;
1630 }
1631 used.push(seed);
1632 let Ok(foot) = project_point_to_surface_seeded(surface, point, u, v) else {
1633 continue;
1634 };
1635 if !(foot.distance <= coincidence) {
1636 continue;
1637 }
1638 let landed = Vec2 {
1639 x: foot.u,
1640 y: foot.v,
1641 };
1642 if feet.iter().any(|other| {
1643 parameter_separation(Vec2 { x: other.u, y: other.v }, landed, period) <= grid.merge
1644 }) {
1645 continue;
1646 }
1647 feet.push(foot);
1648 }
1649 if feet.len() < 2 {
1650 fold_trace!(
1651 "fold face {}: {} distinct foot/feet at the crossing point (merge {:.3e}) — one foot is not a fold",
1652 face.id,
1653 feet.len(),
1654 grid.merge
1655 );
1656 return FoldVerdict::Rejected;
1657 }
1658
1659 // The uv coincidence band the trim is asked with, keyed to the face's own
1660 // parameter extent exactly as `loop_self_crossings` keys its weld: uv is
1661 // not a length, and a domain is as likely to be [0, 1] as [0, 2*PI]. A foot
1662 // ON the trim boundary counts as inside — a fold that reaches the boundary
1663 // is still a fold.
1664 let trim_band = UV_WELD_FRACTION * (span_u.abs() + span_v.abs()).max(1.0);
1665 // Each kept foot with whether it sits ON the trim boundary. Two feet
1666 // that both sit on the boundary are the TRIM's: a seam the face declares
1667 // by its loops, or the two arms of a slit a carved wall leaves along its
1668 // crease (the closed-edge lane's carve cuts the fold's lens out of the
1669 // trim and keeps one crease edge, used twice, as the hole's boundary —
1670 // the surface's two sheets meet there by construction). Such a pair
1671 // carries one point on two parameters exactly as a fold does, and is not
1672 // one: 2026-10-17, `fillet-mouth-over-wall-bore-5p5-r2p2`, convicted
1673 // under a coarser rim mesh whose triangles crossed the slit. A fold that
1674 // reaches the boundary from INSIDE is still a fold (one foot inside).
1675 let mut inside: Vec<(&crate::projection::SurfaceProjection, Vec3, bool)> = Vec::new();
1676 for foot in &feet {
1677 let uv = Vec2 {
1678 x: foot.u,
1679 y: foot.v,
1680 };
1681 let on_boundary = match parameter_point_in_face(face, uv, trim_band) {
1682 Ok(PolygonClass::Outside) => continue,
1683 Ok(PolygonClass::Boundary) => true,
1684 Ok(PolygonClass::Inside) => false,
1685 Err(message) => {
1686 fold_trace!("fold face {}: containment: {message}", face.id);
1687 return FoldVerdict::Undecided;
1688 }
1689 };
1690 let Ok(normal) = surface.normal(foot.u, foot.v).and_then(|normal| normal.normalized())
1691 else {
1692 fold_trace!("fold face {}: a normal could not be evaluated", face.id);
1693 return FoldVerdict::Undecided;
1694 };
1695 inside.push((foot, normal, on_boundary));
1696 }
1697 if inside.len() < 2 {
1698 fold_trace!(
1699 "fold face {}: {} of {} feet are inside the trim",
1700 face.id,
1701 inside.len(),
1702 feet.len()
1703 );
1704 return FoldVerdict::Rejected;
1705 }
1706
1707 let mut chosen: Option<(f64, usize, usize, f64)> = None;
1708 let mut worst_sine = 0.0f64;
1709 let mut identified = 0usize;
1710 let mut trim_pairs = 0usize;
1711 for first in 0..inside.len() {
1712 for second in (first + 1)..inside.len() {
1713 if inside[first].2 && inside[second].2 {
1714 trim_pairs += 1;
1715 continue;
1716 }
1717 // Two feet with the TRIM between them — the uv segment joining
1718 // them leaves the face — are the two sheets either side of a
1719 // slit or a hole, within the coincidence band of each other
1720 // because they meet along it; a fold's two feet are joined
1721 // through the material that folds. (The carved wall again: a
1722 // mesh triangle spanning the slit puts one foot a few 1e-5 of u
1723 // to each side of the crease, 1.1e-5 apart in 3D.)
1724 {
1725 let (a, b) = (inside[first].0, inside[second].0);
1726 let separated = (1..8).any(|k| {
1727 let s = k as f64 / 8.0;
1728 let uv = Vec2 { x: a.u + (b.u - a.u) * s, y: a.v + (b.v - a.v) * s };
1729 matches!(parameter_point_in_face(face, uv, trim_band), Ok(PolygonClass::Outside))
1730 });
1731 if separated {
1732 trim_pairs += 1;
1733 continue;
1734 }
1735 }
1736 let sine = inside[first].1.cross(inside[second].1).length();
1737 worst_sine = worst_sine.max(sine);
1738 if sine <= FOLD_TRANSVERSAL_SINE {
1739 continue;
1740 }
1741 // A SEAM the surface does not declare. `closed_directions()` is an
1742 // exact test, and an imported carrier whose two boundary isocurves
1743 // are 2.5e-2 apart fails it while still being one locus reached
1744 // from both ends of the domain — so the period reduction above did
1745 // not fold these two feet together and they read a full span
1746 // apart. Measured rather than assumed: the two isocurves are
1747 // sampled and compared.
1748 if boundary_identified(
1749 surface,
1750 Vec2 { x: inside[first].0.u, y: inside[first].0.v },
1751 Vec2 { x: inside[second].0.u, y: inside[second].0.v },
1752 domain_u,
1753 domain_v,
1754 6.0 * grid.merge,
1755 coincidence,
1756 ) {
1757 identified += 1;
1758 continue;
1759 }
1760 let separation = parameter_separation(
1761 Vec2 { x: inside[first].0.u, y: inside[first].0.v },
1762 Vec2 { x: inside[second].0.u, y: inside[second].0.v },
1763 period,
1764 );
1765 if chosen.is_none_or(|(best, _, _, _)| separation > best) {
1766 chosen = Some((separation, first, second, sine));
1767 }
1768 }
1769 }
1770 let Some((separation, first, second, sine)) = chosen else {
1771 fold_trace!(
1772 "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",
1773 face.id
1774 );
1775 return FoldVerdict::Rejected;
1776 };
1777 let (foot_a, normal_a, _) = inside[first];
1778 let (foot_b, normal_b, _) = inside[second];
1779 let angle = sine.atan2(normal_a.dot(normal_b));
1780 fold_trace!(
1781 "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",
1782 face.id,
1783 foot_a.u,
1784 foot_a.v,
1785 foot_b.u,
1786 foot_b.v,
1787 grid.merge,
1788 foot_a.point.sub(foot_b.point).length(),
1789 foot_a.distance,
1790 foot_b.distance,
1791 angle.to_degrees()
1792 );
1793
1794 FoldVerdict::Confirmed(FaceFold {
1795 face: face.id,
1796 face_name: face.name.clone(),
1797 uv_a: [foot_a.u, foot_a.v],
1798 uv_b: [foot_b.u, foot_b.v],
1799 point: foot_a.point.add(foot_b.point).scale(0.5),
1800 uv_separation: separation,
1801 uv_band: grid.merge,
1802 residual: foot_a.point.sub(foot_b.point).length(),
1803 normal_angle: angle,
1804 })
1805}
1806
1807/// Map every mesh vertex to a representative index, welding positions that
1808/// coincide within `weld`. Bucketed on a `weld`-sized lattice with the 27
1809/// neighbouring cells consulted, so a pair straddling a cell boundary still
1810/// welds.
1811fn weld_positions(positions: &[f64], weld: f64) -> Vec<u32> {
1812 let count = positions.len() / 3;
1813 let mut representative = vec![0u32; count];
1814 let cell = weld.max(f64::MIN_POSITIVE);
1815 let mut buckets: HashMap<(i64, i64, i64), Vec<u32>> = HashMap::default();
1816 for index in 0..count {
1817 let base = index * 3;
1818 let point = Vec3::new(positions[base], positions[base + 1], positions[base + 2]);
1819 let key = (
1820 (point.x / cell).floor() as i64,
1821 (point.y / cell).floor() as i64,
1822 (point.z / cell).floor() as i64,
1823 );
1824 let mut found = None;
1825 'search: for dx in -1..=1 {
1826 for dy in -1..=1 {
1827 for dz in -1..=1 {
1828 let Some(list) = buckets.get(&(key.0 + dx, key.1 + dy, key.2 + dz)) else {
1829 continue;
1830 };
1831 for candidate in list {
1832 let base = *candidate as usize * 3;
1833 let other = Vec3::new(
1834 positions[base],
1835 positions[base + 1],
1836 positions[base + 2],
1837 );
1838 if other.sub(point).length() <= weld {
1839 found = Some(representative[*candidate as usize]);
1840 break 'search;
1841 }
1842 }
1843 }
1844 }
1845 }
1846 let value = found.unwrap_or(index as u32);
1847 representative[index] = value;
1848 buckets.entry(key).or_default().push(index as u32);
1849 }
1850 representative
1851}
1852
1853/// Signed distances of a triangle's corners to `surface`, reduced to the
1854/// smaller of the two one-sided margins: positive means the corners genuinely
1855/// sit on both sides of the carrier by at least that much, zero means they do
1856/// not straddle it at all. `None` when a projection or a normal could not be
1857/// evaluated — never a verdict.
1858///
1859/// Three projections, and only for a triangle pair that already survived the
1860/// BVH, the welded-vertex skip and the mesh crossing test.
1861fn straddle(surface: &NurbsSurface, triangle: &[Vec3; 3], band: f64) -> Option<f64> {
1862 let mut above = 0.0f64;
1863 let mut below = 0.0f64;
1864 for corner in triangle {
1865 let projection = project_point_to_surface(surface, *corner).ok()?;
1866 if projection.distance <= band {
1867 continue;
1868 }
1869 let normal = surface.normal(projection.u, projection.v).ok()?;
1870 let normal = normal.normalized().ok()?;
1871 let signed = corner.sub(projection.point).dot(normal);
1872 if signed > 0.0 {
1873 above = above.max(signed);
1874 } else {
1875 below = below.max(-signed);
1876 }
1877 }
1878 Some(above.min(below))
1879}
1880
1881/// `BREP_SCAN_NODE_MEMBERSHIP=0` restores the pre-2026-10-03 verdict, where a
1882/// tessellation node's side of the other carrier counted whether or not the
1883/// node lay inside its own face's trim. Diagnostic A/B hatch, not a policy.
1884fn node_membership_enabled() -> bool {
1885 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1886 *ON.get_or_init(|| std::env::var("BREP_SCAN_NODE_MEMBERSHIP").as_deref() != Ok("0"))
1887}
1888
1889/// Is `point` a point `face` OWNS: on the face's carrier, inside (or on) its
1890/// trim? `Some(false)` when the carrier foot classifies OUTSIDE the trim;
1891/// `None` when the projection or the classification could not be evaluated
1892/// — never a verdict, exactly as [`across_crossing`] treats its samples. A
1893/// foot ON the trim counts as the face's material. The band is
1894/// [`TRIM_SAMPLE_TOLERANCE`], the same the across-samples are asked with.
1895///
1896/// Why this question exists: a watertight tessellation's interior node is an
1897/// exact point of the carrier but need not be a point of the FACE — on a
1898/// highly curved chart the trim boundary between two boundary samples bows
1899/// away from the UV chord, and nodes land past the trim. Their signed
1900/// distances to another carrier then read a straddle the body does not have.
1901fn node_material(face: &FaceRecord, point: Vec3, band: f64) -> Option<bool> {
1902 if !node_membership_enabled() {
1903 return Some(true);
1904 }
1905 let projection = project_point_to_surface(&face.surface, point).ok()?;
1906 // A finite carrier clamps a point past its domain onto its boundary, and
1907 // the trim then reads that boundary foot as the face's: a point the
1908 // carrier does not reach within `band` is not the face's material.
1909 if projection.distance > band {
1910 return Some(false);
1911 }
1912 match parameter_point_in_face(face, Vec2 { x: projection.u, y: projection.v }, TRIM_SAMPLE_TOLERANCE) {
1913 Ok(PolygonClass::Outside) => Some(false),
1914 Ok(_) => Some(true),
1915 Err(_) => None,
1916 }
1917}
1918
1919/// Does `face`'s trim OWN the spot under `point`: the carrier FOOT of the
1920/// point classifies inside or on the trim? No reach test — a mesh crossing's
1921/// midpoint is a point of two chords, off a curved carrier by the chord's
1922/// sagitta even at a genuine crossing (the oblique bore-mouth control read 0
1923/// confirmed with a reach test here, 2026-10-03), so the question for a
1924/// witness is only whether the faces' trims cover where it lands. `None` when
1925/// the projection or the classification cannot be evaluated.
1926fn footprint_material(face: &FaceRecord, point: Vec3) -> Option<bool> {
1927 if !node_membership_enabled() {
1928 return Some(true);
1929 }
1930 let projection = project_point_to_surface(&face.surface, point).ok()?;
1931 let (closed_u, closed_v) = face.surface.closed_directions().ok()?;
1932 let domains = [face.surface.domain_u().ok()?, face.surface.domain_v().ok()?];
1933 let parameters = [projection.u, projection.v];
1934 let closed = [closed_u, closed_v];
1935 // A closest point constrained to a finite chart boundary is not an
1936 // unconstrained carrier foot when the residual has a tangential component.
1937 // Keep that witness undecided. Pure normal displacement (chord sagitta)
1938 // does not disqualify a foot, including a legitimate foot on the boundary.
1939 // The only spatial floor here is the existing trim-sample tolerance.
1940 for axis in 0..2 {
1941 if closed[axis] {
1942 continue;
1943 }
1944 let [first, last] = domains[axis];
1945 let roundoff = 32.0 * f64::EPSILON * first.abs().max(last.abs()).max(last - first);
1946 if parameters[axis] > first + roundoff && parameters[axis] < last - roundoff {
1947 continue;
1948 }
1949 let derivatives = face.surface.derivatives(projection.u, projection.v, 1).ok()?;
1950 let tangent = if axis == 0 { derivatives[1][0] } else { derivatives[0][1] };
1951 let tangent = tangent.normalized().ok()?;
1952 let tangential_residual = point.sub(projection.point).dot(tangent);
1953 if !tangential_residual.is_finite() || tangential_residual.abs() > TRIM_SAMPLE_TOLERANCE {
1954 return None;
1955 }
1956 }
1957 match parameter_point_in_face(face, Vec2 { x: projection.u, y: projection.v }, TRIM_SAMPLE_TOLERANCE) {
1958 Ok(PolygonClass::Outside) => Some(false),
1959 Ok(_) => Some(true),
1960 Err(_) => None,
1961 }
1962}
1963
1964/// [`straddle`] over the corners `own_face` OWNS: a corner outside its own
1965/// trim ([`node_material`] `Some(false)`) contributes no side; a corner whose
1966/// membership cannot be evaluated makes the whole reading `None`. With every
1967/// corner a member this is exactly [`straddle`].
1968fn straddle_material(
1969 own_face: &FaceRecord,
1970 other: &NurbsSurface,
1971 triangle: &[Vec3; 3],
1972 band: f64,
1973) -> Option<f64> {
1974 let mut above = 0.0f64;
1975 let mut below = 0.0f64;
1976 for corner in triangle {
1977 match node_material(own_face, *corner, band) {
1978 Some(true) => {}
1979 Some(false) => {
1980 pierce_trace!(
1981 " corner ({:.6}, {:.6}, {:.6}) of face {} lies OUTSIDE its own trim; its side is not read",
1982 corner.x, corner.y, corner.z, own_face.id
1983 );
1984 continue;
1985 }
1986 None => return None,
1987 }
1988 let projection = project_point_to_surface(other, *corner).ok()?;
1989 if projection.distance <= band {
1990 continue;
1991 }
1992 let normal = other.normal(projection.u, projection.v).ok()?;
1993 let normal = normal.normalized().ok()?;
1994 let signed = corner.sub(projection.point).dot(normal);
1995 if signed > 0.0 {
1996 above = above.max(signed);
1997 } else {
1998 below = below.max(-signed);
1999 }
2000 }
2001 Some(above.min(below))
2002}
2003
2004/// Which confirmation convicted a [`FaceCrossing`].
2005///
2006/// Recorded so a sweep can attribute every hit to the question that caught it.
2007/// The two ask the same thing — does one face's surface sit on BOTH sides of
2008/// the other's carrier — of different sample points, and the sample points are
2009/// the whole difference between them.
2010#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
2011#[non_exhaustive]
2012pub enum CrossingConfirmation {
2013 /// [`straddle`]: the triangles' OWN CORNERS, in both directions.
2014 Corners,
2015 /// [`pierce_straddle`]: an edge of one triangle through the other's
2016 /// interior, with the samples taken at the pierce.
2017 Pierce,
2018 /// Neither of those decided it; the carriers did (`resolve.rs`): a point
2019 /// both faces own on their common curve, crossed transversally, straddled
2020 /// past the band both ways. Added 2026-10-16 (lane K2).
2021 Resolved,
2022}
2023
2024/// Signed distance from `point` to `surface`, positive on the side the
2025/// surface's normal points to. `None` when the projection or the normal could
2026/// not be evaluated — never a verdict.
2027fn signed_distance(surface: &NurbsSurface, point: Vec3) -> Option<f64> {
2028 let projection = project_point_to_surface(surface, point).ok()?;
2029 let normal = surface.normal(projection.u, projection.v).ok()?;
2030 let normal = normal.normalized().ok()?;
2031 Some(point.sub(projection.point).dot(normal))
2032}
2033
2034/// The SECOND confirmation, and the one a PLANAR face passing through a curved
2035/// one needs.
2036///
2037/// [`straddle`] reads a triangle's OWN CORNERS, and a planar face is
2038/// tessellated as the two triangles its rectangle needs. When such a face
2039/// passes through a cylinder every one of those corners is OUTSIDE the
2040/// cylinder, so the corner test reads a one-sided margin of zero and REJECTS a
2041/// body that demonstrably passes through itself: a 20-block whose `-X` wall is
2042/// pushed to `x = 6` through its own `r = 6` bore encloses 1669.026644711,
2043/// which is exactly the closed form that removes the bore's segment TWICE.
2044///
2045/// The same question is asked where the two triangles actually meet instead:
2046///
2047/// 1. An EDGE of one triangle passes through the INTERIOR of the other
2048/// (segment-triangle intersection, with a barycentric margin, so two
2049/// triangles meeting along a shared boundary are never a pierce).
2050/// 2. That edge's two ENDPOINTS straddle the pierced face's carrier — the same
2051/// signed-distance question [`straddle`] asks of a triangle's corners,
2052/// restricted to the two points that bracket the pierce.
2053/// 3. Two points ON the pierced triangle, either side of the two triangles'
2054/// crossing line and inside that triangle, straddle the PIERCING face's
2055/// carrier. This is the half the corner test cannot reach, and it is what
2056/// separates a face passing THROUGH another from one merely touching it: at
2057/// a tangency both samples sit on the SAME side, however deep the chords
2058/// interpenetrate.
2059///
2060/// Returns the smallest margin the confirmation cleared, or `None` when
2061/// neither direction confirmed.
2062fn pierce_straddle(
2063 first: &[Vec3; 3],
2064 second: &[Vec3; 3],
2065 face_first: &FaceRecord,
2066 face_second: &FaceRecord,
2067 crossing: (Vec3, Vec3),
2068 band: f64,
2069) -> PierceVerdict {
2070 pierce_trace!(" forward: an edge of the first triangle through the second");
2071 let forward = pierce_direction(first, second, face_first, face_second, crossing, band);
2072 pierce_trace!(" backward: an edge of the second triangle through the first");
2073 let backward = pierce_direction(second, first, face_second, face_first, crossing, band);
2074 match (forward, backward) {
2075 (PierceVerdict::Confirmed(a), PierceVerdict::Confirmed(b)) => {
2076 PierceVerdict::Confirmed(if a.margin() >= b.margin() { a } else { b })
2077 }
2078 (PierceVerdict::Confirmed(hit), _) | (_, PierceVerdict::Confirmed(hit)) => {
2079 PierceVerdict::Confirmed(hit)
2080 }
2081 // A projection or a normal that could not be evaluated is never a
2082 // verdict — the module's rule, and the same one the corner lane keeps.
2083 (PierceVerdict::Undecided, _) | (_, PierceVerdict::Undecided) => PierceVerdict::Undecided,
2084 _ => PierceVerdict::Rejected,
2085 }
2086}
2087
2088/// The pierce lane's two margins, both measured between EXACT surface points.
2089#[derive(Clone, Copy, Debug)]
2090struct PierceHit {
2091 /// How far the piercing mesh edge's two endpoints sit either side of the
2092 /// pierced face's carrier.
2093 bracket: f64,
2094 /// How far the pierced face's own SURFACE reaches either side of the
2095 /// piercing face's carrier, across the crossing line.
2096 penetration: f64,
2097}
2098
2099impl PierceHit {
2100 fn margin(&self) -> f64 {
2101 self.bracket.min(self.penetration)
2102 }
2103}
2104
2105enum PierceVerdict {
2106 Confirmed(PierceHit),
2107 Rejected,
2108 Undecided,
2109}
2110
2111/// One direction of [`pierce_straddle`]: an edge of `piercing` through
2112/// `pierced`'s interior. The widest margin over the three edges.
2113fn pierce_direction(
2114 piercing: &[Vec3; 3],
2115 pierced: &[Vec3; 3],
2116 face_piercing: &FaceRecord,
2117 face_pierced: &FaceRecord,
2118 crossing: (Vec3, Vec3),
2119 band: f64,
2120) -> PierceVerdict {
2121 let (surface_piercing, surface_pierced) = (&face_piercing.surface, &face_pierced.surface);
2122 let mut best: Option<PierceHit> = None;
2123 let mut undecided = false;
2124 for index in 0..3 {
2125 let p0 = piercing[index];
2126 let p1 = piercing[(index + 1) % 3];
2127 let Some(fraction) = segment_pierces_triangle(p0, p1, pierced) else {
2128 pierce_trace!(" edge {index}: does not pierce the other triangle's interior");
2129 continue;
2130 };
2131 let point = p0.add(p1.sub(p0).scale(fraction));
2132 pierce_trace!(
2133 " edge {index}: pierces at ({:.6}, {:.6}, {:.6}); bracket distances {:?} {:?}",
2134 point.x,
2135 point.y,
2136 point.z,
2137 signed_distance(surface_pierced, p0),
2138 signed_distance(surface_pierced, p1)
2139 );
2140 // The bracket is read from two tessellation nodes of the piercing
2141 // face; a node outside that face's own trim is not the face's
2142 // material, so its side says nothing about the body (2026-10-03). The
2143 // pair is then UNEVALUATED along this edge — never rejected, the same
2144 // rule the across-samples keep — and the trace records the readings.
2145 match (node_material(face_piercing, p0, band), node_material(face_piercing, p1, band)) {
2146 (Some(true), Some(true)) => {}
2147 (in_p0, in_p1) => {
2148 pierce_trace!(
2149 " edge {index}: bracket endpoints in their own face {}: {in_p0:?} {in_p1:?} — not read",
2150 face_piercing.id
2151 );
2152 undecided = true;
2153 continue;
2154 }
2155 }
2156 let bracket = match bracket_straddle(surface_pierced, p0, p1, band) {
2157 Margin::Cleared(value) => value,
2158 Margin::Failed => continue,
2159 Margin::Unevaluated => {
2160 undecided = true;
2161 continue;
2162 }
2163 };
2164 let penetration = match across_crossing(
2165 surface_piercing,
2166 face_pierced,
2167 pierced,
2168 point,
2169 crossing,
2170 band,
2171 ) {
2172 Margin::Cleared(value) => value,
2173 Margin::Failed => continue,
2174 Margin::Unevaluated => {
2175 undecided = true;
2176 continue;
2177 }
2178 };
2179 let hit = PierceHit {
2180 bracket,
2181 penetration,
2182 };
2183 if best.is_none_or(|value| hit.margin() > value.margin()) {
2184 best = Some(hit);
2185 }
2186 }
2187 match best {
2188 Some(hit) => PierceVerdict::Confirmed(hit),
2189 None if undecided => PierceVerdict::Undecided,
2190 None => PierceVerdict::Rejected,
2191 }
2192}
2193
2194/// One margin the pierce lane asked for: cleared with a value, genuinely not
2195/// cleared, or not evaluable at all. The third is never a verdict.
2196enum Margin {
2197 Cleared(f64),
2198 Failed,
2199 Unevaluated,
2200}
2201
2202/// Where the segment `p0 -> p1` passes through `triangle`'s INTERIOR, as the
2203/// fraction along the segment. `None` when it misses, when it is parallel to
2204/// the triangle's plane, or when it crosses within `INTERIOR_MARGIN` of the
2205/// triangle's boundary — a hit on the boundary is two triangles MEETING, which
2206/// every watertight tessellation does along every edge.
2207fn segment_pierces_triangle(p0: Vec3, p1: Vec3, triangle: &[Vec3; 3]) -> Option<f64> {
2208 /// Barycentric margin, as a fraction of the triangle.
2209 const INTERIOR_MARGIN: f64 = 1e-6;
2210 let edge_one = triangle[1].sub(triangle[0]);
2211 let edge_two = triangle[2].sub(triangle[0]);
2212 let direction = p1.sub(p0);
2213 let cross = direction.cross(edge_two);
2214 let determinant = edge_one.dot(cross);
2215 if determinant.abs() <= 1e-18 {
2216 return None; // parallel to the triangle's plane
2217 }
2218 let inverse = 1.0 / determinant;
2219 let to_corner = p0.sub(triangle[0]);
2220 let u = to_corner.dot(cross) * inverse;
2221 let second_cross = to_corner.cross(edge_one);
2222 let v = direction.dot(second_cross) * inverse;
2223 if u <= INTERIOR_MARGIN || v <= INTERIOR_MARGIN || u + v >= 1.0 - INTERIOR_MARGIN {
2224 return None;
2225 }
2226 let fraction = edge_two.dot(second_cross) * inverse;
2227 if fraction <= 0.0 || fraction >= 1.0 {
2228 return None;
2229 }
2230 Some(fraction)
2231}
2232
2233/// Do `p0` and `p1` sit on OPPOSITE sides of `surface`, each by more than
2234/// `band`? The smaller of the two margins, or `None`.
2235fn bracket_straddle(surface: &NurbsSurface, p0: Vec3, p1: Vec3, band: f64) -> Margin {
2236 let (Some(first), Some(second)) = (
2237 signed_distance(surface, p0),
2238 signed_distance(surface, p1),
2239 ) else {
2240 return Margin::Unevaluated;
2241 };
2242 if first.abs() <= band || second.abs() <= band || (first > 0.0) == (second > 0.0) {
2243 return Margin::Failed;
2244 }
2245 Margin::Cleared(first.abs().min(second.abs()))
2246}
2247
2248/// The boundary band [`parameter_point_in_face`] is asked with here, so that a
2249/// sample sitting ON the trim reads as the face's material rather than outside
2250/// it. The comparand is the same containment band the imprint's own window
2251/// guard uses (`restricted_carrier`'s wrap sample, `1e-9`) — the tightest in
2252/// the kernel, since the question is "is this spot the face's at all", not "how
2253/// far may it stray". The overshoot this test exists to catch is four orders
2254/// wider (2e-5 on the helmet's turning point).
2255const TRIM_SAMPLE_TOLERANCE: f64 = 1e-9;
2256
2257/// `BREP_PIERCE_TRIM_SAMPLES=0` restores the pre-2026-09-16 verdict, where an
2258/// across-sample was taken from the triangle without asking the face whether
2259/// it owns that spot.
2260fn trim_samples_enabled() -> bool {
2261 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
2262 *ON.get_or_init(|| std::env::var("BREP_PIERCE_TRIM_SAMPLES").as_deref() != Ok("0"))
2263}
2264
2265/// Does `triangle` sit on BOTH sides of `surface`, either side of the crossing
2266/// line through `point`?
2267///
2268/// The two samples are taken on the triangle, stepping perpendicular to the
2269/// crossing line, half way to the triangle's boundary in each direction, and
2270/// then put back onto the face's OWN carrier so the margin is a property of
2271/// the two surfaces rather than of the mesh. A face passing THROUGH the
2272/// carrier puts them on opposite sides; a face TOUCHING it puts both on the
2273/// same one, which is the rejection this test exists to make.
2274///
2275/// A TRIANGLE IS NOT THE FACE'S MATERIAL. Its corners lie on the trim, but the
2276/// boundary is approximated by chords, and where the boundary turns sharply
2277/// the chord bows to the outside: the triangle then covers a sliver the face
2278/// does not own. Measured on `20_helmet_merge_stage` b − a, at the turning
2279/// point of the helmet `Face_15` × cutter-plane section, a boundary chord of
2280/// 6.4e-4 stood 2e-5 outside its own trim, and a sample there read 1.72e-6
2281/// through the other carrier against a band of 2.2e-7: a confirmed crossing on
2282/// geometry no face carries. So each sample is asked of the pierced face's own
2283/// trim, and a sample outside it leaves the pair UNEVALUATED — never rejected,
2284/// because a genuine crossing whose sample happened to land outside would then
2285/// read clean. Escape hatch `BREP_PIERCE_TRIM_SAMPLES=0`.
2286fn across_crossing(
2287 surface: &NurbsSurface,
2288 own_face: &FaceRecord,
2289 triangle: &[Vec3; 3],
2290 point: Vec3,
2291 crossing: (Vec3, Vec3),
2292 band: f64,
2293) -> Margin {
2294 let own = &own_face.surface;
2295 let along = crossing.1.sub(crossing.0);
2296 let (Ok(along), Some((normal, _))) = (along.normalized(), triangle_plane(triangle)) else {
2297 return Margin::Unevaluated;
2298 };
2299 let Ok(across) = normal.cross(along).normalized() else {
2300 return Margin::Unevaluated;
2301 };
2302 // How far the samples may step before leaving the triangle: the three
2303 // inward half-planes of its own edges, clipped against the ray.
2304 let mut low = f64::NEG_INFINITY;
2305 let mut high = f64::INFINITY;
2306 for index in 0..3 {
2307 let a = triangle[index];
2308 let b = triangle[(index + 1) % 3];
2309 let opposite = triangle[(index + 2) % 3];
2310 let mut inward = normal.cross(b.sub(a));
2311 if inward.dot(opposite.sub(a)) < 0.0 {
2312 inward = inward.scale(-1.0);
2313 }
2314 let slope = inward.dot(across);
2315 let offset = inward.dot(point.sub(a));
2316 if slope.abs() <= 1e-18 {
2317 if offset < 0.0 {
2318 return Margin::Failed;
2319 }
2320 continue;
2321 }
2322 let bound = -offset / slope;
2323 if slope > 0.0 {
2324 low = low.max(bound);
2325 } else {
2326 high = high.min(bound);
2327 }
2328 }
2329 if !(low < 0.0) || !(high > 0.0) || !low.is_finite() || !high.is_finite() {
2330 pierce_trace!(" across: no room either side of the crossing (reach {low:.6}..{high:.6})");
2331 return Margin::Failed;
2332 }
2333 // Both samples sit on the pierced TRIANGLE, which is a chord of its own
2334 // face and up to the tessellation's chord tolerance off the surface it
2335 // approximates — on an imported model that tolerance is 1.5e-3 of the
2336 // model's extent, orders above the band this margin has to clear. Put each
2337 // sample back ON its own carrier before asking the other surface about it,
2338 // so what is measured is a property of the two SURFACES rather than of how
2339 // coarsely one of them was meshed. On a planar face this is the identity.
2340 let hold_to_trim = trim_samples_enabled();
2341 let samples = [low * 0.5, high * 0.5].map(|step| {
2342 let projection = project_point_to_surface(own, point.add(across.scale(step))).ok();
2343 let sample = projection.as_ref().map(|projection| projection.point);
2344 let material = match (&projection, hold_to_trim) {
2345 (Some(projection), true) => !matches!(
2346 parameter_point_in_face(
2347 own_face,
2348 Vec2 { x: projection.u, y: projection.v },
2349 TRIM_SAMPLE_TOLERANCE,
2350 ),
2351 Ok(PolygonClass::Outside)
2352 ),
2353 _ => true,
2354 };
2355 let reading = sample.and_then(|sample| signed_distance(surface, sample));
2356 if let Some(sample) = sample {
2357 pierce_trace!(
2358 " across step {step:.6} (reach {low:.6}..{high:.6}): sample ({:.6}, {:.6}, {:.6}) reads {reading:?}{}",
2359 sample.x,
2360 sample.y,
2361 sample.z,
2362 if material { "" } else { " OUTSIDE its own trim" }
2363 );
2364 }
2365 material.then_some(reading).flatten()
2366 });
2367 let [Some(below), Some(above)] = samples else {
2368 return Margin::Unevaluated;
2369 };
2370 if below.abs() <= band || above.abs() <= band || (below > 0.0) == (above > 0.0) {
2371 return Margin::Failed;
2372 }
2373 Margin::Cleared(below.abs().min(above.abs()))
2374}
2375
2376/// The segment along which two triangles cross, or `None` when they do not
2377/// cross transversally. Coplanar pairs return `None` — a coplanar overlap is a
2378/// different defect and this detector does not claim it.
2379fn triangle_crossing(first: &[Vec3; 3], second: &[Vec3; 3]) -> Option<(Vec3, Vec3)> {
2380 let plane_first = triangle_plane(first)?;
2381 let plane_second = triangle_plane(second)?;
2382 let direction = plane_first.0.cross(plane_second.0);
2383 let length = direction.length();
2384 if length <= 1e-12 {
2385 return None; // parallel or coplanar
2386 }
2387 let direction = direction.scale(1.0 / length);
2388
2389 let first_span = plane_span(first, plane_second, direction)?;
2390 let second_span = plane_span(second, plane_first, direction)?;
2391 let low = first_span.0.max(second_span.0);
2392 let high = first_span.1.min(second_span.1);
2393 if high < low {
2394 return None;
2395 }
2396 // Rebuild 3D points by interpolating the first triangle's own crossing
2397 // chord, so the returned segment is on the mesh rather than reconstructed
2398 // from a line equation.
2399 let span = first_span.1 - first_span.0;
2400 let lerp = |value: f64| {
2401 if span <= 0.0 {
2402 first_span.2
2403 } else {
2404 let fraction = ((value - first_span.0) / span).clamp(0.0, 1.0);
2405 first_span
2406 .2
2407 .add(first_span.3.sub(first_span.2).scale(fraction))
2408 }
2409 };
2410 Some((lerp(low), lerp(high)))
2411}
2412
2413/// Unit normal and plane offset of a triangle, `None` for a degenerate one.
2414fn triangle_plane(triangle: &[Vec3; 3]) -> Option<(Vec3, f64)> {
2415 let normal = triangle[1]
2416 .sub(triangle[0])
2417 .cross(triangle[2].sub(triangle[0]));
2418 let length = normal.length();
2419 if length <= 1e-18 {
2420 return None;
2421 }
2422 let normal = normal.scale(1.0 / length);
2423 Some((normal, -normal.dot(triangle[0])))
2424}
2425
2426/// Where `triangle` crosses `plane`, as `(low, high, low_point, high_point)`
2427/// parametrized by `point · direction`. `None` when the triangle does not
2428/// reach the plane.
2429fn plane_span(
2430 triangle: &[Vec3; 3],
2431 plane: (Vec3, f64),
2432 direction: Vec3,
2433) -> Option<(f64, f64, Vec3, Vec3)> {
2434 let distance = |point: Vec3| plane.0.dot(point) + plane.1;
2435 let distances = [
2436 distance(triangle[0]),
2437 distance(triangle[1]),
2438 distance(triangle[2]),
2439 ];
2440 if distances.iter().all(|value| *value > 0.0) || distances.iter().all(|value| *value < 0.0) {
2441 return None;
2442 }
2443 let mut points: Vec<Vec3> = Vec::with_capacity(3);
2444 for index in 0..3 {
2445 let next = (index + 1) % 3;
2446 if distances[index] == 0.0 {
2447 points.push(triangle[index]);
2448 }
2449 if (distances[index] < 0.0) != (distances[next] < 0.0)
2450 && distances[index] != 0.0
2451 && distances[next] != 0.0
2452 {
2453 let fraction = distances[index] / (distances[index] - distances[next]);
2454 points.push(
2455 triangle[index].add(triangle[next].sub(triangle[index]).scale(fraction)),
2456 );
2457 }
2458 }
2459 if points.len() < 2 {
2460 return None;
2461 }
2462 let mut low = (f64::INFINITY, Vec3::default());
2463 let mut high = (f64::NEG_INFINITY, Vec3::default());
2464 for point in points {
2465 let value = point.dot(direction);
2466 if value < low.0 {
2467 low = (value, point);
2468 }
2469 if value > high.0 {
2470 high = (value, point);
2471 }
2472 }
2473 Some((low.0, high.0, low.1, high.1))
2474}
2475
2476
2477// ---------------------------------------------------------------------------
2478// Euler parity
2479// ---------------------------------------------------------------------------
2480
2481/// Outcome of [`solid_euler`].
2482#[derive(Clone, Debug, Serialize)]
2483pub struct EulerReport {
2484 /// `V − E + F − H` over the reduced complex — the same count
2485 /// [`BrepSolid::validate`] makes, with degenerate (collapsed) cells left
2486 /// out of all four terms.
2487 pub characteristic: i64,
2488 pub shells: usize,
2489 /// `S − χ/2`, and `None` when `characteristic` is odd — there is no such
2490 /// surface, so there is no genus to report rather than a halved one.
2491 pub genus: Option<i64>,
2492 /// What the solid carries in its `genus` field.
2493 pub recorded_genus: i64,
2494 /// Non-degenerate edges used by FEWER than two coedges — a boundary edge,
2495 /// or an edge record no loop references at all. A closed surface has
2496 /// neither; a sheet or an in-progress shell legitimately has the first, and
2497 /// its characteristic is then unconstrained. Both are counted here because
2498 /// both mean the same thing for parity: these counts are not those of a
2499 /// closed surface, so decline to judge rather than convict.
2500 pub boundary_edges: usize,
2501 /// Non-degenerate edges used by three or more coedges — not a 2-manifold,
2502 /// so the characteristic means nothing either.
2503 pub non_manifold_edges: usize,
2504}
2505
2506impl EulerReport {
2507 /// Whether the counts describe a closed 2-manifold at all. Parity is only
2508 /// an invariant for one of those.
2509 pub fn is_closed_manifold(&self) -> bool {
2510 self.boundary_edges == 0 && self.non_manifold_edges == 0
2511 }
2512
2513 /// The HARD predicate: a CLOSED manifold whose Euler characteristic is odd.
2514 ///
2515 /// No closed orientable surface has one — χ = 2(S − G) is even by
2516 /// construction — so this is a statement about the record, not about a
2517 /// tolerance, and it cannot be tuned away. It is the one thing the Euler
2518 /// count can establish on its own: the genus itself has no second
2519 /// derivation from the cell complex to check against.
2520 pub fn is_unsound(&self) -> bool {
2521 self.is_closed_manifold() && self.characteristic % 2 != 0
2522 }
2523
2524 /// One line naming what is wrong, or `None`.
2525 pub fn summary(&self) -> Option<String> {
2526 if self.is_unsound() {
2527 return Some(format!(
2528 "Euler characteristic {} is ODD over {} shell(s); no closed orientable \
2529 surface has one, so these counts do not describe a solid \
2530 (recorded genus {})",
2531 self.characteristic, self.shells, self.recorded_genus
2532 ));
2533 }
2534 None
2535 }
2536}
2537
2538/// Closedness and Euler parity: `V − E + F − H` over the reduced complex, and
2539/// whether the counts can belong to a closed orientable surface at all.
2540///
2541/// Pure topology — ids and use counts, never a coordinate — so like
2542/// [`solid_connectivity`] it is tolerance-free and cannot be noisy.
2543///
2544/// This does NOT verify the genus. A genus has no second derivation from the
2545/// same cell complex: `genus = S − χ/2` IS the definition, so any recomputation
2546/// re-runs the same arithmetic. (The only independent route is geometric —
2547/// Gauss–Bonnet, `∮K dA` plus the edge and vertex terms — which is a different
2548/// project.) What parity establishes without a second source is that a body
2549/// whose characteristic is odd is not a closed orientable surface at all.
2550///
2551/// The characteristic is a TOTAL over shells, which is necessary but not
2552/// sufficient: two shells each odd sum to even and pass. Per-shell parity needs
2553/// edges attributed to shells, which `cross_shell_edges` makes ambiguous
2554/// exactly when it would matter.
2555pub fn solid_euler(solid: &BrepSolid) -> EulerReport {
2556 let degenerate: HashSet<u64> = solid
2557 .edges
2558 .iter()
2559 .filter(|edge| edge.degenerate)
2560 .map(|edge| edge.id)
2561 .collect();
2562 let mut uses: HashMap<u64, usize> = HashMap::default();
2563 for coedge in solid
2564 .shells
2565 .iter()
2566 .flat_map(|shell| &shell.faces)
2567 .flat_map(|face| &face.loops)
2568 .flat_map(|record| &record.coedges)
2569 {
2570 if !degenerate.contains(&coedge.edge_id) {
2571 *uses.entry(coedge.edge_id).or_insert(0) += 1;
2572 }
2573 }
2574 let mut boundary_edges = 0usize;
2575 let mut non_manifold_edges = 0usize;
2576 for edge in solid.edges.iter().filter(|edge| !edge.degenerate) {
2577 match uses.get(&edge.id).copied().unwrap_or(0) {
2578 2 => {}
2579 0 | 1 => boundary_edges += 1,
2580 _ => non_manifold_edges += 1,
2581 }
2582 }
2583
2584 let live: HashSet<u64> = solid
2585 .edges
2586 .iter()
2587 .filter(|edge| !edge.degenerate)
2588 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
2589 .collect();
2590 let vertices = solid
2591 .vertices
2592 .iter()
2593 .filter(|vertex| live.contains(&vertex.id))
2594 .count() as i64;
2595 let edges = solid.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
2596 let faces: i64 = solid.shells.iter().map(|shell| shell.faces.len() as i64).sum();
2597 let characteristic = vertices - edges + faces - solid.bounding_hole_count();
2598 let shells = solid.shells.len();
2599 EulerReport {
2600 characteristic,
2601 shells,
2602 genus: (characteristic % 2 == 0).then(|| shells as i64 - characteristic / 2),
2603 recorded_genus: solid.genus,
2604 boundary_edges,
2605 non_manifold_edges,
2606 }
2607}
2608
2609// ---------------------------------------------------------------------------
2610// Face-loop self-crossing
2611// ---------------------------------------------------------------------------
2612
2613/// Parametric exclusion at each chord's ends. A crossing this close to a
2614/// chord end is the shared vertex of two consecutive chords, not a crossing.
2615const CHORD_END_EXCLUSION: f64 = 1e-9;
2616
2617/// Sine of the angle below which two chords are treated as PARALLEL and no
2618/// crossing is computed. Scale-free: it is compared against the cross product
2619/// divided by both lengths, so a long chord pair and a short one are held to
2620/// the same angle. Collinear overlap — the two seam coedges of a periodic
2621/// face, traversed once each way — lands here and is not a crossing.
2622const CHORD_PARALLEL_SINE: f64 = 1e-12;
2623
2624/// Coincidence radius for chord ENDPOINTS, as a fraction of the face's own
2625/// parameter extent. Two chords whose ends meet share a vertex of the loop;
2626/// where that is a loop touching itself at a point it is a PINCH, which is
2627/// `ConnectivityReport::pinch_vertices`' question, not this one.
2628const UV_WELD_FRACTION: f64 = 1e-9;
2629
2630/// One place a face's trim loop crosses ITSELF in the face's parameter domain.
2631///
2632/// The two coedges are the loop's own — a bowtie between two different trims,
2633/// or one pcurve crossing itself, in which case they are the same coedge.
2634#[derive(Clone, Debug, Serialize)]
2635pub struct LoopCrossing {
2636 pub face: u64,
2637 pub face_name: Option<String>,
2638 pub loop_id: u64,
2639 /// The two crossing coedges, in loop order.
2640 pub coedge_a: u64,
2641 pub coedge_b: u64,
2642 /// The edges those coedges use — what a caller NAMES the defect by, since
2643 /// an edge survives the surgery that renumbers coedges.
2644 pub edge_a: u64,
2645 pub edge_b: u64,
2646 /// Where they cross, in the face's own domain (wrapped into it on a
2647 /// periodic direction, so it is a parameter the surface can be evaluated
2648 /// at rather than a covering-plane lift).
2649 pub uv: [f64; 2],
2650 /// The surface point there — the 3D place the face passes through itself.
2651 pub point: Vec3,
2652}
2653
2654/// Outcome of [`loop_self_crossings`].
2655#[derive(Clone, Debug, Serialize)]
2656pub struct LoopCrossingReport {
2657 /// Loops scanned.
2658 pub loops: usize,
2659 /// Chords tested — the sampled trim polygon's segments, summed over loops.
2660 pub chords: usize,
2661 /// One entry per crossing COEDGE PAIR per loop, first one found kept. The
2662 /// pair is the stable half: a denser sampling moves the crossing's `uv`
2663 /// without changing which two trims cross.
2664 ///
2665 /// Two kinds of crossing are deliberately NOT here, both because the two
2666 /// strands are the same curve twice rather than two parts of a boundary:
2667 ///
2668 /// * a crossing within `assembler_weld` of one of the loop's own VERTICES.
2669 /// Two trims arriving at one vertex are fitted independently, so each
2670 /// ends a fit-accuracy away from it and near the vertex they overlap.
2671 /// Measured over the case population on 2026-09-13, every such hit sat
2672 /// within 2.4e-7 of a vertex and the one true crossing sat 1.8e-1 from
2673 /// one — four orders of margin either side of the band, not a threshold
2674 /// decision. The cost is stated: a genuine bowtie whose crossing is
2675 /// inside that band of a vertex is not reported.
2676 /// * a crossing between two DIFFERENT coedges that use the SAME EDGE — a
2677 /// seam edge, which a periodic face's loop traverses once each way. The
2678 /// two are one 3D curve under two fits, so they cannot cross on the
2679 /// surface; where the fits wobble past each other they only look like
2680 /// it. A coedge against ITSELF is a different question and is asked.
2681 pub crossings: Vec<LoopCrossing>,
2682 /// Loops whose pcurves could not be evaluated, `face/loop: reason`. A scan
2683 /// that could not read a loop did not clear it, so a caller must not read
2684 /// an empty `crossings` beside a non-empty this as "no crossings".
2685 pub unreadable: Vec<String>,
2686}
2687
2688impl LoopCrossingReport {
2689 /// The predicate: some face's trim loop crosses itself.
2690 pub fn is_flagged(&self) -> bool {
2691 !self.crossings.is_empty()
2692 }
2693
2694 /// One line naming the first crossing, or `None` when nothing crossed.
2695 pub fn summary(&self) -> Option<String> {
2696 let first = self.crossings.first()?;
2697 Some(format!(
2698 "{} loop self-crossing(s); face {}{} loop {} crosses itself where edges {} and {} \
2699 meet at uv ({:.6}, {:.6}) = ({:.4}, {:.4}, {:.4})",
2700 self.crossings.len(),
2701 first.face,
2702 first
2703 .face_name
2704 .as_ref()
2705 .map(|name| format!(" '{name}'"))
2706 .unwrap_or_default(),
2707 first.loop_id,
2708 first.edge_a,
2709 first.edge_b,
2710 first.uv[0],
2711 first.uv[1],
2712 first.point.x,
2713 first.point.y,
2714 first.point.z
2715 ))
2716 }
2717}
2718
2719/// One sampled chord of a lifted trim loop.
2720#[derive(Clone, Copy)]
2721struct Chord {
2722 a: Vec2,
2723 b: Vec2,
2724}
2725
2726impl Chord {
2727 fn shifted(self, shift: Vec2) -> Self {
2728 Self {
2729 a: self.a.add(shift),
2730 b: self.b.add(shift),
2731 }
2732 }
2733
2734 fn direction(&self) -> Vec2 {
2735 self.b.sub(self.a)
2736 }
2737
2738 fn min_u(&self) -> f64 {
2739 self.a.x.min(self.b.x)
2740 }
2741
2742 fn max_u(&self) -> f64 {
2743 self.a.x.max(self.b.x)
2744 }
2745
2746 fn min_v(&self) -> f64 {
2747 self.a.y.min(self.b.y)
2748 }
2749
2750 fn max_v(&self) -> f64 {
2751 self.a.y.max(self.b.y)
2752 }
2753}
2754
2755/// One loop's sampled trim polygon, lifted into the covering plane.
2756struct Chain {
2757 /// `chords + 1` sample points. The last is the loop's closing vertex: the
2758 /// first again on a loop that closes in the domain, the first plus
2759 /// [`Chain::wind`] on one that winds the seam.
2760 points: Vec<Vec2>,
2761 /// The coedge each chord was sampled from.
2762 owner: Vec<usize>,
2763 /// End minus start — zero, or a whole number of periods.
2764 wind: Vec2,
2765}
2766
2767impl Chain {
2768 fn chords(&self) -> usize {
2769 self.owner.len()
2770 }
2771
2772 fn chord(&self, index: usize) -> Chord {
2773 Chord {
2774 a: self.points[index],
2775 b: self.points[index + 1],
2776 }
2777 }
2778
2779 /// Chain vertex `index`, continued past either end by one LAP of the
2780 /// loop's own wind — so the strand at the closing vertex still has the
2781 /// neighbour it has on the surface.
2782 fn vertex(&self, index: i64) -> Vec2 {
2783 let last = self.chords() as i64;
2784 if index < 0 {
2785 self.points[(index + last) as usize].sub(self.wind)
2786 } else if index > last {
2787 self.points[(index - last) as usize].add(self.wind)
2788 } else {
2789 self.points[index as usize]
2790 }
2791 }
2792}
2793
2794/// Which side of the directed chord `point` falls on: `1`, `-1`, or `0`
2795/// within `weld` of its line.
2796fn side_of(chord: &Chord, point: Vec2, weld: f64) -> i32 {
2797 let direction = chord.direction();
2798 let length = direction.length();
2799 if !(length > 0.0) {
2800 return 0;
2801 }
2802 let signed = (direction.x * (point.y - chord.a.y) - direction.y * (point.x - chord.a.x)) / length;
2803 if signed > weld {
2804 1
2805 } else if signed < -weld {
2806 -1
2807 } else {
2808 0
2809 }
2810}
2811
2812/// Where along `chord` the nearest point to `point` is, when `point` is within
2813/// `weld` of the chord at all.
2814fn along_chord(chord: &Chord, point: Vec2, weld: f64) -> Option<f64> {
2815 let direction = chord.direction();
2816 let length_squared = direction.dot(direction);
2817 if !(length_squared > 0.0) {
2818 return None;
2819 }
2820 let parameter = point.sub(chord.a).dot(direction) / length_squared;
2821 let foot = chord.a.add(direction.scale(parameter.clamp(0.0, 1.0)));
2822 (point.sub(foot).length() <= weld).then_some(parameter)
2823}
2824
2825/// Do two strands of the chain CROSS where they meet at one point, or does
2826/// the second merely touch the first and leave the way it came?
2827///
2828/// The four directions out of the shared vertex are compared by angle: the
2829/// strands cross exactly when they ALTERNATE around it — one of the second
2830/// strand's directions inside the sector the first sweeps and one outside.
2831///
2832/// A second-strand direction COLLINEAR with either of the first's — the same
2833/// way or the opposite way — declines instead of answering. That is not an
2834/// edge case: it is the standard periodic face. A cylinder's lateral loop
2835/// spans exactly one period, so the seam it runs UP at `u1` is the seam its
2836/// own period-shifted image runs DOWN, and the two strands lie along each
2837/// other at every seam vertex. Two curves that run along each other do not
2838/// cross there, and a rule that had to pick a side would pick one at every
2839/// seam in the corpus.
2840fn strands_cross(
2841 at: Vec2,
2842 first_before: Vec2,
2843 first_after: Vec2,
2844 second_before: Vec2,
2845 second_after: Vec2,
2846 weld: f64,
2847) -> bool {
2848 let angle = |point: Vec2| -> Option<f64> {
2849 let direction = point.sub(at);
2850 (direction.length() > weld).then(|| direction.y.atan2(direction.x))
2851 };
2852 let (Some(from), Some(to), Some(before), Some(after)) = (
2853 angle(first_before),
2854 angle(first_after),
2855 angle(second_before),
2856 angle(second_after),
2857 ) else {
2858 return false;
2859 };
2860 for second in [before, after] {
2861 for first in [from, to] {
2862 if (second - first).sin().abs() <= CHORD_PARALLEL_SINE {
2863 return false;
2864 }
2865 }
2866 }
2867 let sweep = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU);
2868 let span = sweep(from, to);
2869 let inside = |value: f64| {
2870 let offset = sweep(from, value);
2871 offset > 0.0 && offset < span
2872 };
2873 inside(before) != inside(after)
2874}
2875
2876/// Where chords `first` and `second` of one chain cross, with `second`
2877/// displaced by `shift` (zero for the loop against itself).
2878///
2879/// Three ways a chain crosses itself, and the last two are not exotic: a
2880/// symmetric bowtie puts the crossing exactly ON a sample of both strands,
2881/// because both pcurves are sampled at the same fractions of their own
2882/// domains. A detector that only tested for a crossing strictly inside both
2883/// chords would miss the very shape it is for.
2884fn chain_crossing(chain: &Chain, first: usize, second: usize, shift: Vec2, weld: f64) -> Option<Vec2> {
2885 let one = chain.chord(first);
2886 let other = chain.chord(second).shifted(shift);
2887
2888 // 1. Transversally, inside both.
2889 let a = one.direction();
2890 let b = other.direction();
2891 let scale = a.length() * b.length();
2892 let denominator = a.x * b.y - a.y * b.x;
2893 if scale > 0.0 && denominator.abs() > CHORD_PARALLEL_SINE * scale {
2894 let offset = other.a.sub(one.a);
2895 let t = (offset.x * b.y - offset.y * b.x) / denominator;
2896 let u = (offset.x * a.y - offset.y * a.x) / denominator;
2897 let interior = |value: f64| value > CHORD_END_EXCLUSION && value < 1.0 - CHORD_END_EXCLUSION;
2898 if interior(t) && interior(u) {
2899 return Some(one.a.add(a.scale(t)));
2900 }
2901 }
2902
2903 // 2. A chain VERTEX sitting inside the other chord: the strand crosses
2904 // there when the samples either side of it are on opposite sides.
2905 let through = |chord: &Chord, vertex: i64, displace: Vec2| -> Option<Vec2> {
2906 let point = chain.vertex(vertex).add(displace);
2907 let parameter = along_chord(chord, point, weld)?;
2908 if !(parameter > CHORD_END_EXCLUSION && parameter < 1.0 - CHORD_END_EXCLUSION) {
2909 return None;
2910 }
2911 let before = side_of(chord, chain.vertex(vertex - 1).add(displace), weld);
2912 let after = side_of(chord, chain.vertex(vertex + 1).add(displace), weld);
2913 (before != 0 && after != 0 && before != after).then_some(point)
2914 };
2915 if let Some(point) = through(&other, first as i64 + 1, Vec2 { x: 0.0, y: 0.0 }) {
2916 return Some(point);
2917 }
2918 if let Some(point) = through(&one, second as i64 + 1, shift) {
2919 return Some(point);
2920 }
2921
2922 // 3. The two strands meeting AT one shared vertex. Each chain vertex is
2923 // the END of exactly one chord, so testing end against end reports a
2924 // shared vertex once; end against START is the same point one chord
2925 // earlier and is reached as that pair's end-against-end.
2926 if one.b.sub(other.b).length() <= weld {
2927 let first_vertex = first as i64 + 1;
2928 let second_vertex = second as i64 + 1;
2929 if strands_cross(
2930 one.b,
2931 chain.vertex(first_vertex - 1),
2932 chain.vertex(first_vertex + 1),
2933 chain.vertex(second_vertex - 1).add(shift),
2934 chain.vertex(second_vertex + 1).add(shift),
2935 weld,
2936 ) {
2937 return Some(one.b);
2938 }
2939 }
2940 None
2941}
2942
2943/// Offer every chord pair whose boxes overlap — `first` against `second`, or
2944/// against itself when `second` is `None` — by a sweep over sorted
2945/// u-intervals, so a thousand-chord loop is not a million chord tests.
2946fn sweep_chords(
2947 first: &[Chord],
2948 second: Option<&[Chord]>,
2949 mut offer: impl FnMut(usize, usize),
2950) {
2951 let mut order: Vec<(f64, bool, usize)> = first
2952 .iter()
2953 .enumerate()
2954 .map(|(index, chord)| (chord.min_u(), false, index))
2955 .collect();
2956 if let Some(second) = second {
2957 order.extend(
2958 second
2959 .iter()
2960 .enumerate()
2961 .map(|(index, chord)| (chord.min_u(), true, index)),
2962 );
2963 }
2964 order.sort_by(|a, b| a.0.total_cmp(&b.0));
2965
2966 let mut active_first: Vec<usize> = Vec::new();
2967 let mut active_second: Vec<usize> = Vec::new();
2968 for (sweep_u, is_second, index) in order {
2969 let chord = if is_second {
2970 second.expect("second set indexed")[index]
2971 } else {
2972 first[index]
2973 };
2974 // Lazy deletion: a chord whose u-interval ended before this one starts
2975 // can never meet anything from here on.
2976 active_first.retain(|&other| first[other].max_u() >= sweep_u);
2977 if let Some(second) = second {
2978 active_second.retain(|&other| second[other].max_u() >= sweep_u);
2979 }
2980 // A chord is tested against the ACTIVE chords of the OTHER set, or
2981 // against its own when there is no other. Either way a pair is
2982 // offered exactly once.
2983 let (candidates, pool): (&[usize], &[Chord]) = match (second, is_second) {
2984 (None, _) => (&active_first, first),
2985 (Some(second), false) => (&active_second, second),
2986 (Some(_), true) => (&active_first, first),
2987 };
2988 for &other in candidates {
2989 let against = &pool[other];
2990 if chord.max_v() < against.min_v() || against.max_v() < chord.min_v() {
2991 continue;
2992 }
2993 if is_second {
2994 offer(other, index);
2995 } else {
2996 offer(index, other);
2997 }
2998 }
2999 if is_second {
3000 active_second.push(index);
3001 } else {
3002 active_first.push(index);
3003 }
3004 }
3005}
3006
3007/// The shift that carries `point` onto the period of `target` — zero in a
3008/// direction the surface is not closed in.
3009fn period_shift(point: Vec2, target: Vec2, period: [Option<f64>; 2]) -> Vec2 {
3010 let along = |value: f64, want: f64, period: Option<f64>| match period {
3011 Some(period) if period > 0.0 => ((want - value) / period).round() * period,
3012 _ => 0.0,
3013 };
3014 Vec2 {
3015 x: along(point.x, target.x, period[0]),
3016 y: along(point.y, target.y, period[1]),
3017 }
3018}
3019
3020/// One loop's trim polygon, sampled and lifted into the covering plane: each
3021/// coedge's whole sample run is shifted by the period that joins its start to
3022/// the previous coedge's end.
3023///
3024/// The shift is decided ONCE PER COEDGE, at the boundary where a jump can
3025/// actually occur — a pcurve is one continuous NURBS and does not jump inside
3026/// itself. Deciding it per SAMPLE (which is what `validate()`'s uv-wire
3027/// warning does) takes any genuine chord longer than half a period the short
3028/// way round instead.
3029fn lift_loop(loop_record: &LoopRecord, period: [Option<f64>; 2]) -> Result<Chain, String> {
3030 let mut points: Vec<Vec2> = Vec::new();
3031 let mut owner: Vec<usize> = Vec::new();
3032 let mut previous_end: Option<Vec2> = None;
3033 for (index, coedge) in loop_record.coedges.iter().enumerate() {
3034 let curve = &coedge.pcurve;
3035 let [start, end] = curve.domain()?;
3036 let at = |parameter: f64| -> Result<Vec2, String> {
3037 let value = curve.evaluate(parameter)?;
3038 Ok(Vec2 {
3039 x: value.x,
3040 y: value.y,
3041 })
3042 };
3043 let shift = match previous_end {
3044 Some(target) => period_shift(at(start)?, target, period),
3045 None => Vec2 { x: 0.0, y: 0.0 },
3046 };
3047 let samples = crate::classification::trim_sample_count(curve);
3048 for sample in 0..samples {
3049 let parameter = start + (end - start) * sample as f64 / samples as f64;
3050 points.push(at(parameter)?.add(shift));
3051 owner.push(index);
3052 }
3053 previous_end = Some(at(end)?.add(shift));
3054 }
3055 let wind = match (points.first(), previous_end) {
3056 (Some(first), Some(last)) => last.sub(*first),
3057 _ => Vec2 { x: 0.0, y: 0.0 },
3058 };
3059 if let Some(last) = previous_end {
3060 points.push(last);
3061 }
3062 Ok(Chain {
3063 points,
3064 owner,
3065 wind,
3066 })
3067}
3068
3069/// Does any face's trim loop cross ITSELF in its own parameter domain — a
3070/// bowtie?
3071///
3072/// This is the defect every other detector in this module is blind to by
3073/// construction (see the blind-spot section of the module docs): the crossing
3074/// is INSIDE one face, so there is no face pair to confirm; the incidence in
3075/// the record is perfect, so `validate()` is silent; the counts are unchanged,
3076/// so parity and connectivity are unchanged. The face's own AREA is the only
3077/// other tell, and nothing compares it to anything.
3078///
3079/// The loop is sampled at exactly the density
3080/// [`crate::parameter_point_in_face`]'s generic lane classifies against
3081/// (`trim_sample_count`), lifted into the covering plane a coedge at a time,
3082/// and swept for chord pairs that cross. Chords the CHAIN joins are skipped by
3083/// index, never by proximity: a loop that runs back over its own last vertex
3084/// is a finding, not an adjacency.
3085///
3086/// Reported once per crossing COEDGE PAIR per loop — the stable half, since a
3087/// denser sampling moves a crossing's `uv` without changing which two trims
3088/// cross. Nothing in the kernel's build path calls this; the blend acceptance
3089/// and the case gate are its callers.
3090pub fn loop_self_crossings(solid: &BrepSolid) -> LoopCrossingReport {
3091 let mut report = LoopCrossingReport {
3092 loops: 0,
3093 chords: 0,
3094 crossings: Vec::new(),
3095 unreadable: Vec::new(),
3096 };
3097 // "Are these two independently fitted points the SAME vertex?" — the
3098 // kernel's own named answer, and the band this detector holds a crossing
3099 // AT a loop vertex to. See the `crossings` field for why it is needed and
3100 // what it costs.
3101 let vertex_band = crate::tolerance::assembler_weld(
3102 crate::KernelTolerances::for_solid(solid, 1e-7).model,
3103 );
3104 for face in solid.shells.iter().flat_map(|shell| &shell.faces) {
3105 let (closed_u, closed_v) = match face.surface.closed_directions() {
3106 Ok(closed) => closed,
3107 Err(message) => {
3108 report.unreadable.push(format!("face {}: {message}", face.id));
3109 continue;
3110 }
3111 };
3112 let (domain_u, domain_v) = match (face.surface.domain_u(), face.surface.domain_v()) {
3113 (Ok(u), Ok(v)) => (u, v),
3114 (Err(message), _) | (_, Err(message)) => {
3115 report.unreadable.push(format!("face {}: {message}", face.id));
3116 continue;
3117 }
3118 };
3119 let span_u = domain_u[1] - domain_u[0];
3120 let span_v = domain_v[1] - domain_v[0];
3121 let period = [closed_u.then_some(span_u), closed_v.then_some(span_v)];
3122 // The coincidence band, and the band the side tests hold a sample to.
3123 // Keyed to the face's own parameter extent: uv is not a length, and a
3124 // domain is as likely to be [0, 1] as [0, 2π].
3125 let weld = UV_WELD_FRACTION * (span_u.abs() + span_v.abs()).max(1.0);
3126 for loop_record in &face.loops {
3127 let chain = match lift_loop(loop_record, period) {
3128 Ok(chain) => chain,
3129 Err(message) => {
3130 report.unreadable.push(format!(
3131 "face {} loop {}: {message}",
3132 face.id, loop_record.id
3133 ));
3134 continue;
3135 }
3136 };
3137 let count = chain.chords();
3138 if count < 4 {
3139 continue;
3140 }
3141 report.loops += 1;
3142 report.chords += count;
3143 let chords: Vec<Chord> = (0..count).map(|index| chain.chord(index)).collect();
3144
3145 let mut seen: HashSet<(usize, usize)> = HashSet::default();
3146 let mut hits: Vec<(usize, usize, Vec2)> = Vec::new();
3147 let zero = Vec2 { x: 0.0, y: 0.0 };
3148 // Two different coedges of one loop that use the SAME EDGE are two
3149 // parameter-space images of ONE 3D curve — a seam edge, which a
3150 // periodic face's loop traverses once each way. They are the same
3151 // locus on the surface and cannot cross it; where their two
3152 // independent fits wobble past each other at the fit tolerance,
3153 // that is the wobble and not a bowtie. (A coedge against ITSELF is
3154 // a different question and is still asked.)
3155 let same_edge = |first: usize, second: usize| {
3156 let (a, b) = (chain.owner[first], chain.owner[second]);
3157 a != b
3158 && loop_record.coedges[a].edge_id == loop_record.coedges[b].edge_id
3159 };
3160 let mut record = |first: usize, second: usize, point: Vec2| {
3161 let (a, b) = (chain.owner[first], chain.owner[second]);
3162 let pair = if a <= b { (a, b) } else { (b, a) };
3163 if seen.insert(pair) {
3164 hits.push((pair.0, pair.1, point));
3165 }
3166 };
3167
3168 // How many whole PERIODS the loop's end is from its start. This is
3169 // the structural question — does the loop close in the domain, or
3170 // wind the seam — and it must not be asked with a tolerance: the
3171 // two ends are one vertex reached by two independent pcurve fits,
3172 // and their uv gap is whatever those fits left (measured up to
3173 // 2.4e-5 on an offset shell, which is above `validate()`'s own
3174 // uv-wire closure band). Rounding to laps answers it exactly.
3175 let laps_between = |value: f64, period: Option<f64>| -> i64 {
3176 match period {
3177 Some(period) if period > 0.0 => (value / period).round() as i64,
3178 _ => 0,
3179 }
3180 };
3181 let wind_laps = (
3182 laps_between(chain.wind.x, period[0]),
3183 laps_between(chain.wind.y, period[1]),
3184 );
3185 let joined = wind_laps == (0, 0);
3186
3187 // The loop against itself. Chords the chain JOINS are skipped: the
3188 // consecutive pairs, and the closing pair when the loop closes in
3189 // the domain rather than winding the seam.
3190 sweep_chords(&chords, None, |first, second| {
3191 let (low, high) = if first <= second {
3192 (first, second)
3193 } else {
3194 (second, first)
3195 };
3196 if high == low + 1 || (joined && low == 0 && high == count - 1) {
3197 return;
3198 }
3199 if same_edge(low, high) {
3200 return;
3201 }
3202 if let Some(point) = chain_crossing(&chain, low, high, zero, weld) {
3203 record(low, high, point);
3204 }
3205 });
3206
3207 // A PERIODIC face's loop and its own period-shifted image are one
3208 // curve on the surface, so a loop that WINDS can cross itself
3209 // across the seam. The shift is taken in one direction only:
3210 // `(+P, i, j)` and `(-P, j, i)` are the same two points on the
3211 // surface, so testing both would find every seam crossing twice.
3212 let extent = |axis: fn(&Vec2) -> f64| -> f64 {
3213 let values = chain.points.iter().map(axis);
3214 let (lo, hi) = values.fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), value| {
3215 (lo.min(value), hi.max(value))
3216 });
3217 hi - lo
3218 };
3219 let laps = |period: Option<f64>, extent: f64| -> i64 {
3220 match period {
3221 Some(period) if period > 0.0 => (extent / period).ceil() as i64,
3222 _ => 0,
3223 }
3224 };
3225 let laps_u = laps(period[0], extent(|point| point.x));
3226 let laps_v = laps(period[1], extent(|point| point.y));
3227 for step_u in 0..=laps_u {
3228 for step_v in -laps_v..=laps_v {
3229 // (0, 0) is the scan above; the half-lattice `step_u > 0`,
3230 // or `step_u == 0` and `step_v > 0`, holds one of every
3231 // (shift, −shift) pair.
3232 if step_u == 0 && step_v <= 0 {
3233 continue;
3234 }
3235 let shift = Vec2 {
3236 x: step_u as f64 * period[0].unwrap_or(0.0),
3237 y: step_v as f64 * period[1].unwrap_or(0.0),
3238 };
3239 let shifted: Vec<Chord> = chords
3240 .iter()
3241 .map(|chord| chord.shifted(shift))
3242 .collect();
3243 // One lap on, the chain's own last chord is joined to its
3244 // first: that is the seam, not a crossing.
3245 let laps_forward = (step_u, step_v) == wind_laps;
3246 let laps_back = (step_u, step_v) == (-wind_laps.0, -wind_laps.1);
3247 sweep_chords(&chords, Some(&shifted), |first, second| {
3248 if laps_forward && first == count - 1 && second == 0 {
3249 return;
3250 }
3251 if laps_back && first == 0 && second == count - 1 {
3252 return;
3253 }
3254 if same_edge(first, second) {
3255 return;
3256 }
3257 if let Some(point) = chain_crossing(&chain, first, second, shift, weld) {
3258 record(first, second, point);
3259 }
3260 });
3261 }
3262 }
3263
3264 // Where this loop's own VERTICES are, in 3D: one per coedge, at
3265 // the start of its pcurve.
3266 let mut corners: Vec<Vec3> = Vec::with_capacity(loop_record.coedges.len());
3267 if !hits.is_empty() {
3268 for coedge in &loop_record.coedges {
3269 let Ok([start, _]) = coedge.pcurve.domain() else {
3270 continue;
3271 };
3272 let Ok(value) = coedge.pcurve.evaluate(start) else {
3273 continue;
3274 };
3275 if let Ok(point) = face.surface.evaluate(value.x, value.y) {
3276 corners.push(point);
3277 }
3278 }
3279 }
3280 for (first, second, point) in hits {
3281 let wrap = |value: f64, domain: [f64; 2], period: Option<f64>| match period {
3282 Some(period) if period > 0.0 => {
3283 domain[0] + (value - domain[0]).rem_euclid(period)
3284 }
3285 _ => value.clamp(domain[0], domain[1]),
3286 };
3287 let u = wrap(point.x, domain_u, period[0]);
3288 let v = wrap(point.y, domain_v, period[1]);
3289 let Ok(position) = face.surface.evaluate(u, v) else {
3290 report.unreadable.push(format!(
3291 "face {} loop {}: crossing at uv ({u:.6}, {v:.6}) is off the surface",
3292 face.id, loop_record.id
3293 ));
3294 continue;
3295 };
3296 // A crossing AT one of the loop's own vertices is the gap
3297 // between the two pcurves that meet there, not a bowtie. Two
3298 // trims arriving at one vertex are fitted independently, so
3299 // each ends a fit-accuracy away from it and near the vertex
3300 // they overlap; `assembler_weld` is the kernel's own band for
3301 // "the same vertex reached by two independent fits".
3302 if corners
3303 .iter()
3304 .any(|corner| corner.sub(position).length() <= vertex_band)
3305 {
3306 continue;
3307 }
3308 let coedge = |index: usize| &loop_record.coedges[index];
3309 report.crossings.push(LoopCrossing {
3310 face: face.id,
3311 face_name: face.name.clone(),
3312 loop_id: loop_record.id,
3313 coedge_a: coedge(first).id,
3314 coedge_b: coedge(second).id,
3315 edge_a: coedge(first).edge_id,
3316 edge_b: coedge(second).edge_id,
3317 uv: [u, v],
3318 point: position,
3319 });
3320 }
3321 }
3322 }
3323 report
3324}
3325
3326// ---------------------------------------------------------------------------
3327// Shell vector area — the closure a trim owes its own edge
3328// ---------------------------------------------------------------------------
3329
3330/// Gauss–Legendre accuracy target per span, RELATIVE to that span's own
3331/// magnitude `∫ |r − R| |dr|`. A span is bisected until the two halves agree
3332/// with the whole to this fraction of what the span can contribute at all.
3333///
3334/// Scale-free by construction, so a 3000 mm part and a 3 mm one are held to
3335/// the same number of digits. 1e-13 is about a hundred times the f64 noise of
3336/// an eight-station rule (each station is one rounding of a product of two
3337/// evaluated vectors), so the recursion terminates on geometry rather than on
3338/// arithmetic, and two orders below it is what the check needs: the whole
3339/// point is a residual read at 1e-9 out of face terms worth 1e+2.
3340const VECTOR_AREA_QUADRATURE_RELATIVE: f64 = 1e-13;
3341
3342/// `BREP_VECTOR_AREA_QUADRATURE` overrides that target, so the convergence of
3343/// a residual can be MEASURED rather than argued: read the same shell at
3344/// 1e-13 and at 1e-15 and the difference is what the quadrature is worth.
3345/// Read once per process.
3346fn vector_area_quadrature_relative() -> f64 {
3347 static TARGET: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
3348 *TARGET.get_or_init(|| {
3349 std::env::var("BREP_VECTOR_AREA_QUADRATURE")
3350 .ok()
3351 .and_then(|value| value.parse::<f64>().ok())
3352 .filter(|value| *value > 0.0 && value.is_finite())
3353 .unwrap_or(VECTOR_AREA_QUADRATURE_RELATIVE)
3354 })
3355}
3356
3357/// Bisection depth per span. A span that has not converged by here is
3358/// accepted with its last difference counted into
3359/// [`ShellVectorArea::quadrature_error`] and into
3360/// [`ShellVectorArea::spans_at_depth_limit`], never silently.
3361///
3362/// The limit is a COST bound, and it is sound because the error it leaves is
3363/// measured rather than assumed. A span whose integrand has a kink in it — a
3364/// trim read through the C¹ boundary extension past its surface's domain —
3365/// converges only linearly, so each further level halves its error for twice
3366/// the work, and the recursion is buying precision the bar never asked for.
3367/// `BREP_VECTOR_AREA_DEPTH` overrides it so that trade can be measured.
3368fn vector_area_quadrature_depth() -> usize {
3369 static DEPTH: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
3370 *DEPTH.get_or_init(|| {
3371 std::env::var("BREP_VECTOR_AREA_DEPTH")
3372 .ok()
3373 .and_then(|value| value.parse::<usize>().ok())
3374 .filter(|value| *value > 0 && *value <= 24)
3375 .unwrap_or(VECTOR_AREA_QUADRATURE_DEPTH)
3376 })
3377}
3378
3379/// Chosen by measurement, not by taste — and the cheapest setting that keeps
3380/// the reported error bar is NOT the right one.
3381///
3382/// Read on the notched cap's `F7` (curved trims) against the depth-12 value,
3383/// and on `20_helmet_merge_stage/a` (253 faces, half its spans hitting the
3384/// limit):
3385///
3386/// ```text
3387/// depth cap residual its bar off depth 12 cap ms helmet ms
3388/// 12 5.106706e-6 4.204e-10 -- 334.9 17347
3389/// 8 5.107557e-6 7.496e-9 8.5e-10 56.4 7356
3390/// 6 5.072736e-6 2.206e-9 3.4e-8 42.9 6385
3391/// 4 5.206351e-6 3.116e-7 1.0e-7 39.2 --
3392/// ```
3393///
3394/// Depth 6 is the trap. It reports a TIGHTER bar than depth 8 while sitting
3395/// 15x further from the converged value: the half-vs-whole difference
3396/// underestimates what is left when the recursion stops early on a
3397/// slowly-converging integrand, so the estimate stops being conservative
3398/// before the answer stops being right. On the helmet, whose residual is
3399/// invariant at every depth, that failure is invisible — cost alone would
3400/// have picked 6.
3401///
3402/// 8 is the shallowest depth whose reported bar still COVERS its own
3403/// deviation from converged, and it is 5.9x cheaper than 12 on the cap and
3404/// 2.4x on the helmet. The residual stays 681x its own error bar there, so it
3405/// is still a measurement by a wide margin.
3406const VECTOR_AREA_QUADRATURE_DEPTH: usize = 8;
3407
3408/// The most spans one SHELL is integrated over before the scan gives up on it.
3409///
3410/// This is a COST bound and it is the only kind that keeps the residual
3411/// honest: on reaching it the shell is reported in
3412/// [`VectorAreaReport::unreadable`] and NOT given a residual, so it falls into
3413/// the same "unjudged" the report uses for a solid with no closed shell. The
3414/// alternative — integrate the remaining spans at whatever accuracy is to
3415/// hand and publish the sum — would turn a measurement into a number with an
3416/// unknown error bar, which is exactly what [`ShellVectorArea::quadrature_error`]
3417/// exists to prevent. A scan that ran out of budget did not measure anything,
3418/// and it must not be able to look like a scan that read zero.
3419///
3420/// The value is read off the case corpus rather than chosen: see the record's
3421/// §6.3. `BREP_VECTOR_AREA_SPANS` overrides it.
3422pub(crate) const VECTOR_AREA_SPAN_BUDGET: usize = 400_000;
3423// ^ read off the case corpus, not chosen.
3424// Over its 317 shells the span count runs median 168, p90 8,278, p99 301,826,
3425// max 1,842,688 — and the distribution has a GAP exactly where a budget wants
3426// to sit. The four shells above 400,000 are the two `gear-hex-bore-push` rows
3427// (1.84M spans each, the only rows whose scan broke the case gate's time
3428// budget); the next largest shell in the whole corpus is 301,826. So this
3429// number separates the bodies this scan cannot afford from every body it can,
3430// with a 33% margin below it and a 4.6x jump above it, and it is a property of
3431// the corpus rather than of anyone's preference.
3432
3433fn vector_area_span_budget() -> usize {
3434 static BUDGET: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
3435 *BUDGET.get_or_init(|| {
3436 std::env::var("BREP_VECTOR_AREA_SPANS")
3437 .ok()
3438 .and_then(|value| value.parse::<usize>().ok())
3439 .filter(|value| *value > 0)
3440 .unwrap_or(VECTOR_AREA_SPAN_BUDGET)
3441 })
3442}
3443
3444/// The ABSOLUTE half of the acceptance, per unit of arc: a span is also
3445/// accepted once the halves agree to this times its own length.
3446///
3447/// It is what makes the cost bounded. The relative target alone is a target
3448/// on a SMOOTH integrand: a trim that runs outside its surface's domain is
3449/// read through the C¹ boundary extension, so its integrand has a kink, a
3450/// bisection gains only two orders per four levels, and the recursion bottoms
3451/// out at the depth limit on every span. Measured on the committed STEP
3452/// corpus that was not a slow scan but a stopped one — one imported solid
3453/// held the sweep for eight minutes.
3454///
3455/// 1e-11 is a ten-thousandth of [`PCURVE_REFINEMENT_TOLERANCE`], the same
3456/// per-unit-of-length currency the bar is quoted in, so the whole shell's
3457/// quadrature error is bounded by `bar / 5000` (a shell's boundary is twice
3458/// its edge length) whatever its geometry does — and the bound is on the
3459/// REPORTED estimate too, since every accepted difference is summed into it.
3460const VECTOR_AREA_QUADRATURE_FLOOR: f64 = 1e-11;
3461
3462/// `BREP_VECTOR_AREA_FLOOR` overrides [`VECTOR_AREA_QUADRATURE_FLOOR`], so the
3463/// cost the floor buys can be measured against the error it leaves rather
3464/// than argued.
3465fn vector_area_quadrature_floor() -> f64 {
3466 static FLOOR: std::sync::OnceLock<f64> = std::sync::OnceLock::new();
3467 *FLOOR.get_or_init(|| {
3468 std::env::var("BREP_VECTOR_AREA_FLOOR")
3469 .ok()
3470 .and_then(|value| value.parse::<f64>().ok())
3471 .filter(|value| *value > 0.0 && value.is_finite())
3472 .unwrap_or(VECTOR_AREA_QUADRATURE_FLOOR)
3473 })
3474}
3475
3476/// One face's vector area `∫ n dA`, as its TRIMS claim it and as its EDGES
3477/// claim it, and the difference between the two.
3478///
3479/// Both are the same boundary integral `(1/2) ∮ (r − R) × dr` about the
3480/// shell's own reference `R` (see [`ShellVectorArea::reference`]), over the
3481/// same loops in the same order; only the curve differs. The `trim` walks the
3482/// pcurve mapped through this face's surface — the boundary the mass
3483/// integrals actually integrate over — and the `edge` walks the 3D edge curve
3484/// the coedge names, which is the SAME curve the face across that edge walks.
3485#[derive(Clone, Debug, Serialize)]
3486pub struct FaceVectorArea {
3487 pub shell: u64,
3488 pub face: u64,
3489 pub face_name: Option<String>,
3490 /// `(1/2) ∮ (r − R) × dr` over the face's TRIMS, each coedge sampled in
3491 /// its own parameter direction (a pcurve is parameterized in its coedge's
3492 /// traversal direction, so the loop is walked by sampling each pcurve
3493 /// forwards, never by reversing a reverse coedge's sweep).
3494 pub trim: Vec3,
3495 /// The same integral over the 3D EDGE curves, each edge traversed in its
3496 /// coedge's direction — forwards over `[t0, t1]` for a forward coedge,
3497 /// backwards for a reverse one.
3498 pub edge: Vec3,
3499 /// `trim − edge`: what THIS face's trims add to the shell's residual.
3500 /// Every edge term cancels across the shell (each edge is walked once
3501 /// each way), so the shell's residual is exactly the sum of these.
3502 pub departure: Vec3,
3503 /// The face's own boundary arc length, taken on the edge curves.
3504 pub boundary_length: f64,
3505 /// `PCURVE_REFINEMENT_TOLERANCE * boundary_length` — what this face's
3506 /// departure can be while every one of its trims lies within the fit
3507 /// floor of the edge it claims. See [`ShellVectorArea::bar`] for the
3508 /// derivation; this is the same bound applied to one loop.
3509 ///
3510 /// **It does not cover a face whose `closure_gap` is over the floor.**
3511 /// The derivation takes one integration by parts over a CLOSED loop; a
3512 /// loop that does not close carries a boundary term the bound never
3513 /// modelled, and the sliver its chords span is real area that this face's
3514 /// trims genuinely enclose. Read `closure_gap` first.
3515 pub bar: f64,
3516 /// How far this face's own trims fail to MEET each other: the summed 3D
3517 /// distance from where one coedge's trim ends to where the next one
3518 /// starts, over this face's loops, including the chord home.
3519 ///
3520 /// A different defect from the departure, and the one that is easy to
3521 /// misread as it. Two trims arriving at one vertex are fitted
3522 /// independently, each refined to `PCURVE_REFINEMENT_TOLERANCE`, so they
3523 /// can miss each other by twice it — about 2e-7 — and anything at that
3524 /// scale is the fit floor rather than a defect. Past it the loop is not
3525 /// closed in the record, and then this face's `departure` is partly the
3526 /// area of the sliver its closing chords span rather than any trim lying
3527 /// off an edge.
3528 ///
3529 /// Reported, not verdicted: a bar on it would be a new tolerance, and the
3530 /// slice that owns a loop's closure is the one that opens it. On the
3531 /// notched cap (2026-09-16) the top cap `E5:S4:PROFILE:L2_END` carries a
3532 /// 4.095e-7 net step in uv while every other loop on that shell closes to
3533 /// 1e-12 or better, and it is the fillet that opens it — the same
3534 /// document's pre-fillet body closes exactly.
3535 pub closure_gap: f64,
3536}
3537
3538impl FaceVectorArea {
3539 /// `|departure|`.
3540 pub fn residual(&self) -> f64 {
3541 self.departure.length()
3542 }
3543
3544 /// Does this face's trim lie further from its own edges than a fit at the
3545 /// refinement floor can account for?
3546 pub fn is_over_bar(&self) -> bool {
3547 self.residual() > self.bar
3548 }
3549}
3550
3551/// One shell's vector-area closure: [`shell_vector_areas`]' per-shell verdict.
3552#[derive(Clone, Debug, Serialize)]
3553pub struct ShellVectorArea {
3554 pub shell: u64,
3555 pub faces: usize,
3556 /// Every non-degenerate edge this shell's coedges use is used EXACTLY
3557 /// TWICE by them. `∫ n dA = 0` is a statement about a closed shell only,
3558 /// so an open one is measured and reported but never flagged: its
3559 /// residual is its open boundary's own vector area, which is a fact about
3560 /// the sheet rather than a defect.
3561 pub closed: bool,
3562 /// The point every arm is taken from — the centre of the bounding box of
3563 /// the vertices this shell's edges use. The residual is independent of it
3564 /// to `|R| * closure_gap` (below), and a local reference is what keeps a
3565 /// far-translated part's face terms from dwarfing the number being read,
3566 /// the same reason `mass_properties::shell_volume_reference` exists.
3567 pub reference: Vec3,
3568 /// `Σ_faces (1/2) ∮ (r − R) × dr` over the TRIMS. **Exactly zero on a
3569 /// sound closed shell**, and otherwise the sum of every face's departure
3570 /// from the edges it shares.
3571 pub trim_residual: Vec3,
3572 /// The same sum over the EDGE curves. Every edge's integral is computed
3573 /// ONCE and added with each coedge's sign, so on a shell where every edge
3574 /// is used twice with opposite senses the curve terms cancel to rounding
3575 /// and what is left is the loops' closing chords alone. It is therefore a
3576 /// BOOKKEEPING check rather than an accuracy one: it moves off zero when
3577 /// the shell is open, when an edge is used twice the SAME way, or when
3578 /// the edge curves do not meet at the vertices they share.
3579 pub edge_residual: Vec3,
3580 /// Total arc length of the shell's non-degenerate edges, each counted
3581 /// ONCE.
3582 pub edge_length: f64,
3583 /// Total arc length walked by the faces' loops — `2 * edge_length` on a
3584 /// closed shell.
3585 pub boundary_length: f64,
3586 /// The comparand: `PCURVE_REFINEMENT_TOLERANCE * edge_length`. See the
3587 /// section docs for the derivation and for why the strict bound is twice
3588 /// this.
3589 pub bar: f64,
3590 /// The summed |half-vs-whole| difference at every accepted span — an
3591 /// OVER-estimate of this shell's quadrature error, and the error bar the
3592 /// residual is read against. A residual is a measurement only while it is
3593 /// far above this.
3594 pub quadrature_error: f64,
3595 /// Total length of the chords that close the TRIM loops — how far the
3596 /// sampled trims fail to meet each other at the vertices they share, over
3597 /// the whole shell. Two trims arriving at one vertex are fitted
3598 /// independently, so a few 1e-9 per vertex is ordinary; the chords are
3599 /// integrated (they are part of the region the mass integrator's own trim
3600 /// polygon bounds), so this is reported as a fact about the record rather
3601 /// than as an error term.
3602 pub trim_closure_gap: f64,
3603 /// The same for the EDGE loops: how far consecutive edge curves fail to
3604 /// meet at their shared vertex. `validate()` checks each curve against
3605 /// its vertex (`IssueKind::CurveVertexGap`); this is what the gaps are
3606 /// worth to the shell's closure.
3607 pub edge_closure_gap: f64,
3608 /// Spans integrated: the pcurve's own knot spans, cut at the surface's
3609 /// knot levels, bisected to convergence, plus the edge curves' own. The
3610 /// cost of the whole check is this times one eight-station rule.
3611 pub spans: usize,
3612 /// How many of those spans were accepted at the DEPTH LIMIT rather than
3613 /// because they converged. Their last half-vs-whole difference is in
3614 /// `quadrature_error` like any other, so the error bar stays honest, but
3615 /// a shell with many of these is paying the full recursion on an
3616 /// integrand the bisection cannot resolve — a kink rather than a curve —
3617 /// and the cost is the symptom.
3618 pub spans_at_depth_limit: usize,
3619 /// The scan ABANDONED this shell on [`VECTOR_AREA_SPAN_BUDGET`] with
3620 /// whole faces still unread.
3621 ///
3622 /// Its `trim_residual` is then a PARTIAL SUM over the faces that were
3623 /// reached, and a partial sum of a boundary integral is not a smaller
3624 /// version of the answer — it is the vector area of an arbitrary subset
3625 /// of the shell, which on a 1004-face gear reads 6.08e+3 where the whole
3626 /// shell reads 1.16e-5. [`Self::residual`] returns `NaN` for such a shell
3627 /// so that number cannot be read as either a residual or an open
3628 /// boundary's area.
3629 pub gave_up: bool,
3630 /// Per face, in record order.
3631 pub face_areas: Vec<FaceVectorArea>,
3632}
3633
3634/// What a shell's closure scan came back with — a measurement, or nothing.
3635///
3636/// The reason this is a type rather than a float: `NaN` is a value, and a value
3637/// gets folded into a comparison. [`ShellVectorArea::is_over_bar`] is
3638/// `residual() > bar`, and `NaN > bar` is FALSE, so a shell the scan gave up on
3639/// reads exactly like a shell that closed — the worst possible answer, since the
3640/// scan gave up on the bodies whose geometry is hardest. A caller that matches on
3641/// this cannot make that mistake without writing the arm.
3642#[derive(Clone, Copy, Debug, Serialize)]
3643pub enum ClosureReading {
3644 /// The trims were integrated to the end. `at_depth_limit` spans were
3645 /// accepted at the bisection's depth limit rather than because they
3646 /// converged; their last difference is inside `quadrature_error` either way.
3647 Measured {
3648 residual: f64,
3649 bar: f64,
3650 spans: usize,
3651 at_depth_limit: usize,
3652 },
3653 /// No residual exists for this shell: the scan stopped before it had read
3654 /// every face, and a partial sum of a boundary integral is not a smaller
3655 /// version of the answer.
3656 Unmeasured {
3657 reason: &'static str,
3658 spans: usize,
3659 faces_read: usize,
3660 faces: usize,
3661 },
3662 /// An OPEN shell is not judged by this check at all: `Σ ∫ n dA = 0` is a
3663 /// statement about a closed one, and an open shell's residual is its own
3664 /// boundary's vector area, which is a fact about the sheet.
3665 Open { residual: f64, faces: usize },
3666}
3667
3668impl ShellVectorArea {
3669 /// The typed outcome — what this shell's scan actually came back with.
3670 pub fn reading(&self) -> ClosureReading {
3671 if self.gave_up {
3672 return ClosureReading::Unmeasured {
3673 reason: "the scan reached its span budget with faces unread",
3674 spans: self.spans,
3675 faces_read: self.face_areas.len(),
3676 faces: self.faces,
3677 };
3678 }
3679 if !self.closed {
3680 return ClosureReading::Open {
3681 residual: self.trim_residual.length(),
3682 faces: self.faces,
3683 };
3684 }
3685 ClosureReading::Measured {
3686 residual: self.trim_residual.length(),
3687 bar: self.bar,
3688 spans: self.spans,
3689 at_depth_limit: self.spans_at_depth_limit,
3690 }
3691 }
3692
3693 /// `|trim_residual|`, and **`NaN` when the scan gave up on this shell**:
3694 /// see [`Self::gave_up`]. A partial sum is not a residual. Prefer
3695 /// [`Self::reading`], which cannot be compared by accident.
3696 pub fn residual(&self) -> f64 {
3697 if self.gave_up {
3698 return f64::NAN;
3699 }
3700 self.trim_residual.length()
3701 }
3702
3703 /// The predicate: a CLOSED shell whose trims enclose a vector area no fit
3704 /// at the refinement floor can account for. **False for a shell the scan
3705 /// did not measure** — ask [`Self::reading`] to tell those apart.
3706 pub fn is_over_bar(&self) -> bool {
3707 matches!(self.reading(), ClosureReading::Measured { residual, bar, .. } if residual > bar)
3708 }
3709
3710 /// The faces that own the residual, worst departure first.
3711 pub fn worst_faces(&self, count: usize) -> Vec<&FaceVectorArea> {
3712 let mut ordered: Vec<&FaceVectorArea> = self.face_areas.iter().collect();
3713 ordered.sort_by(|a, b| {
3714 b.residual()
3715 .partial_cmp(&a.residual())
3716 .unwrap_or(std::cmp::Ordering::Equal)
3717 .then_with(|| a.face.cmp(&b.face))
3718 });
3719 ordered.truncate(count);
3720 ordered
3721 }
3722
3723 /// The worst face's name, or its id when it has none.
3724 pub fn worst_face_name(&self) -> Option<String> {
3725 self.worst_faces(1).first().map(|face| {
3726 face.face_name
3727 .clone()
3728 .unwrap_or_else(|| format!("face {}", face.face))
3729 })
3730 }
3731}
3732
3733/// Outcome of [`shell_vector_areas`].
3734#[derive(Clone, Debug, Serialize)]
3735pub struct VectorAreaReport {
3736 pub shells: Vec<ShellVectorArea>,
3737 /// Shells or faces whose trims could not be read, `shell/face: reason`. A
3738 /// scan that could not read a face did not clear it, so an empty
3739 /// `shells` beside a non-empty this is not "every shell closes".
3740 pub unreadable: Vec<String>,
3741}
3742
3743impl VectorAreaReport {
3744 /// The predicate: some CLOSED shell is over its bar.
3745 pub fn is_flagged(&self) -> bool {
3746 self.shells.iter().any(ShellVectorArea::is_over_bar)
3747 }
3748
3749 /// The shells this scan did NOT measure, with what each reached. A report
3750 /// with entries here has not cleared those shells, whatever the rest read.
3751 pub fn unmeasured(&self) -> Vec<(u64, ClosureReading)> {
3752 self.shells
3753 .iter()
3754 .map(|shell| (shell.shell, shell.reading()))
3755 .filter(|(_, reading)| matches!(reading, ClosureReading::Unmeasured { .. }))
3756 .collect()
3757 }
3758
3759 /// The largest closed-shell residual, and the shell that carries it.
3760 pub fn worst_shell(&self) -> Option<&ShellVectorArea> {
3761 self.shells
3762 .iter()
3763 .filter(|shell| shell.closed)
3764 .max_by(|a, b| {
3765 a.residual()
3766 .partial_cmp(&b.residual())
3767 .unwrap_or(std::cmp::Ordering::Equal)
3768 })
3769 }
3770
3771 /// `|trim_residual|` of the worst CLOSED shell, and **`NaN` when this
3772 /// solid has no closed shell at all**.
3773 ///
3774 /// Not 0.0. A residual of zero is what a perfectly sound shell reads, so
3775 /// a solid whose shells are all open — one that has LOST faces, say —
3776 /// must not be able to read like one through this accessor. The whole
3777 /// point of the check is that a number nothing compares against is how
3778 /// the defects it hunts stayed hidden; handing back a sound-looking zero
3779 /// for "there was nothing here to judge" would rebuild that trap inside
3780 /// the detector. Use [`Self::has_closed_shell`] to tell the two apart
3781 /// before reading this, or read the per-shell reports, where an open
3782 /// shell's residual is its boundary's own vector area and is reported.
3783 pub fn residual(&self) -> f64 {
3784 self.worst_shell()
3785 .map(ShellVectorArea::residual)
3786 .unwrap_or(f64::NAN)
3787 }
3788
3789 /// That shell's bar, `NaN` when there is no closed shell — see
3790 /// [`Self::residual`] for why this is not 0.0.
3791 pub fn bar(&self) -> f64 {
3792 self.worst_shell().map(|shell| shell.bar).unwrap_or(f64::NAN)
3793 }
3794
3795 /// Is there a closed shell here for [`Self::residual`] to be about?
3796 ///
3797 /// `Σ ∫ n dA = 0` is a statement about a CLOSED shell, so a solid with
3798 /// none is not "sound" and not "unsound" — it is unjudged, and every
3799 /// reader of this report has to decide what to do about that rather than
3800 /// inherit a zero.
3801 pub fn has_closed_shell(&self) -> bool {
3802 self.shells.iter().any(|shell| shell.closed)
3803 }
3804
3805 /// The shells that are NOT closed, with the vector area their open
3806 /// boundary encloses — the reading that names a shell which has lost
3807 /// faces.
3808 ///
3809 /// A closed shell's every edge is used exactly twice. A shell that has
3810 /// dropped a face has that face's edges used once, so it is open here and
3811 /// its residual is what the missing boundary encloses. That is a
3812 /// different question from Euler parity, and a strictly stronger one for
3813 /// this purpose: parity notices an ODD number of faces going and is
3814 /// silent when an even number does, while this counts the uses of every
3815 /// edge and does not care how many faces left.
3816 pub fn open_shells(&self) -> impl Iterator<Item = &ShellVectorArea> {
3817 self.shells.iter().filter(|shell| !shell.closed)
3818 }
3819
3820 /// One line naming the worst shell and the faces that own its residual,
3821 /// or `None` when every closed shell is inside its bar.
3822 pub fn summary(&self) -> Option<String> {
3823 let shell = self
3824 .shells
3825 .iter()
3826 .filter(|shell| shell.is_over_bar())
3827 .max_by(|a, b| {
3828 a.residual()
3829 .partial_cmp(&b.residual())
3830 .unwrap_or(std::cmp::Ordering::Equal)
3831 })?;
3832 let named: Vec<String> = shell
3833 .worst_faces(3)
3834 .iter()
3835 .map(|face| {
3836 format!(
3837 "{} {:.3e}",
3838 face.face_name
3839 .clone()
3840 .unwrap_or_else(|| format!("face {}", face.face)),
3841 face.residual()
3842 )
3843 })
3844 .collect();
3845 Some(format!(
3846 "shell {} closes to {:.3e} of vector area against a bar of {:.3e} \
3847 ({} faces, {:.4} of edge, quadrature {:.1e}); worst: {}",
3848 shell.shell,
3849 shell.residual(),
3850 shell.bar,
3851 shell.faces,
3852 shell.edge_length,
3853 shell.quadrature_error,
3854 named.join(", ")
3855 ))
3856 }
3857}
3858
3859/// `(1/2) ∮ (r − reference) × dr` along the straight chord `from → to` — the
3860/// term that closes a loop across the gap between two independently fitted
3861/// curves that both claim to reach one vertex.
3862fn chord_area(from: Vec3, to: Vec3, reference: Vec3) -> Vec3 {
3863 from.sub(reference).cross(to.sub(from)).scale(0.5)
3864}
3865
3866/// One Gauss–Legendre pass over `[t0, t1]`: that span's half of
3867/// `(1/2) ∮ (r − reference) × dr`, the magnitude `∫ |r − R| |dr|` its relative
3868/// accuracy is judged against, and the arc `∫ |dr|` its absolute floor is.
3869fn vector_area_span<F>(
3870 at: &F,
3871 t0: f64,
3872 t1: f64,
3873 reference: Vec3,
3874) -> Result<(Vec3, f64, f64), String>
3875where
3876 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
3877{
3878 let half = (t1 - t0) * 0.5;
3879 let middle = (t1 + t0) * 0.5;
3880 let mut total = Vec3::new(0.0, 0.0, 0.0);
3881 let mut magnitude = 0.0;
3882 let mut arc = 0.0;
3883 for index in 0..GAUSS_X.len() {
3884 let (point, tangent) = at(middle + half * GAUSS_X[index])?;
3885 let arm = point.sub(reference);
3886 let weight = GAUSS_W[index] * half;
3887 total = total.add(arm.cross(tangent).scale(0.5 * weight));
3888 magnitude += weight.abs() * arm.length() * tangent.length();
3889 arc += weight.abs() * tangent.length();
3890 }
3891 Ok((total, magnitude, arc))
3892}
3893
3894/// [`vector_area_span`] bisected until the halves agree with the whole, or until
3895/// the disagreement is below `share` — the error this span is ALLOWED, which is
3896/// the trim's own budget spread over the leaves the depth limit permits.
3897///
3898/// Without it a span whose 3D length has collapsed cannot ever be accepted. Its
3899/// acceptance target is proportional to its own size (relative to `magnitude`,
3900/// absolute per unit of `arc`), while the disagreement between its halves and
3901/// its whole is set by the round-off in a tangent the parameterization no longer
3902/// resolves, which does not shrink with the span. Measured on
3903/// `fixture_25_cube_pierce_pole_gap_bridge`, a FOUR-face body: spans of arc
3904/// 2.0e-16 whose halves and whole differ by 1.4e-14 against a target of 2.8e-27,
3905/// bisecting to the depth limit every time — 313,900 of its 355,610 spans, 5.0 s
3906/// for a residual whose bar is 1.4e-4. A span contributing 1e-14 cannot move
3907/// that verdict, and `share` says so.
3908///
3909///
3910/// The composition `S(pcurve(t))` is not a polynomial of any degree — a
3911/// rational patch read along a rational curve — so no fixed rule integrates
3912/// it exactly and a fixed rule's error is unknown rather than small. Halving
3913/// is also what finds a surface KNOT crossing for free: the pcurve knows
3914/// nothing of the surface's knot lines, and a span that straddles one has a
3915/// kink in its integrand that the difference sees and the recursion resolves.
3916fn vector_area_adaptive<F>(
3917 at: &F,
3918 t0: f64,
3919 t1: f64,
3920 reference: Vec3,
3921 depth: usize,
3922 share: f64,
3923 error: &mut f64,
3924 spans: &mut usize,
3925 exhausted: &mut usize,
3926) -> Result<Vec3, String>
3927where
3928 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
3929{
3930 let (whole, magnitude, arc) = vector_area_span(at, t0, t1, reference)?;
3931 let middle = 0.5 * (t0 + t1);
3932 let (left, _, _) = vector_area_span(at, t0, middle, reference)?;
3933 let (right, _, _) = vector_area_span(at, middle, t1, reference)?;
3934 let halves = left.add(right);
3935 let difference = halves.sub(whole).length();
3936 let target = (vector_area_quadrature_relative() * magnitude)
3937 .max(vector_area_quadrature_floor() * arc)
3938 .max(share);
3939 if depth == 0 || difference <= target {
3940 *error += difference;
3941 *spans += 2;
3942 if depth == 0 && difference > target {
3943 *exhausted += 2;
3944 }
3945 return Ok(halves);
3946 }
3947 let first =
3948 vector_area_adaptive(at, t0, middle, reference, depth - 1, share, error, spans, exhausted)?;
3949 let second =
3950 vector_area_adaptive(at, middle, t1, reference, depth - 1, share, error, spans, exhausted)?;
3951 Ok(first.add(second))
3952}
3953
3954/// The spans one curve is integrated over: its own knot breaks, clipped to
3955/// `[t0, t1]`.
3956fn vector_area_breaks(curve: &NurbsCurve, t0: f64, t1: f64) -> Result<Vec<f64>, String> {
3957 let mut breaks = vec![t0];
3958 for knot in crate::curve::interior_knots(&curve.knots, curve.degree) {
3959 if knot > t0 && knot < t1 {
3960 breaks.push(knot);
3961 }
3962 }
3963 breaks.push(t1);
3964 Ok(breaks)
3965}
3966
3967/// `(1/2) ∮ (r − reference) × dr` over one curve span, plus the arc length
3968/// and the end-to-end displacement of what was walked.
3969fn vector_area_of<F>(
3970 at: &F,
3971 breaks: &[f64],
3972 reference: Vec3,
3973 share: f64,
3974 error: &mut f64,
3975 spans: &mut usize,
3976 exhausted: &mut usize,
3977) -> Result<Vec3, String>
3978where
3979 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
3980{
3981 let mut total = Vec3::new(0.0, 0.0, 0.0);
3982 let depth = vector_area_quadrature_depth();
3983 for pair in breaks.windows(2) {
3984 if pair[1] <= pair[0] {
3985 continue;
3986 }
3987 total = total.add(vector_area_adaptive(
3988 at,
3989 pair[0],
3990 pair[1],
3991 reference,
3992 depth,
3993 share,
3994 error,
3995 spans,
3996 exhausted,
3997 )?);
3998 }
3999 Ok(total)
4000}
4001
4002/// Arc length over the same spans, by the same rule — used for the bar, so it
4003/// needs the rule's accuracy and not the residual's.
4004fn vector_area_length<F>(at: &F, breaks: &[f64]) -> Result<f64, String>
4005where
4006 F: Fn(f64) -> Result<(Vec3, Vec3), String>,
4007{
4008 let mut length = 0.0;
4009 for pair in breaks.windows(2) {
4010 if pair[1] <= pair[0] {
4011 continue;
4012 }
4013 let half = (pair[1] - pair[0]) * 0.5;
4014 let middle = (pair[1] + pair[0]) * 0.5;
4015 for index in 0..GAUSS_X.len() {
4016 let (_, tangent) = at(middle + half * GAUSS_X[index])?;
4017 length += GAUSS_W[index] * half * tangent.length();
4018 }
4019 }
4020 Ok(length)
4021}
4022
4023/// Does a closed shell's boundary enclose the vector area it should — zero?
4024///
4025/// A patch's vector area is a BOUNDARY integral,
4026/// `∫_F n dA = (1/2) ∮_{∂F} r × dr`, so the whole shell's is a sum over the
4027/// faces' loops. Every edge of a closed shell is walked exactly twice, once
4028/// each way, so **`Σ_faces ∫ n dA` is exactly zero on a closed shell** — a
4029/// statement about the RECORD, with no tolerance in it, as long as the two
4030/// faces meeting at an edge walk the same curve. They do not: each walks its
4031/// OWN TRIM, the pcurve mapped through its own surface, and what is left over
4032/// is the sum of every trim's departure from the edge it claims.
4033///
4034/// That residual is the one whole-shell number that reads the trims — which
4035/// is what every mass property is actually an integral over
4036/// (`geometry/pcurve.rs`, `PCURVE_REFINEMENT_TOLERANCE`) — and nothing in the
4037/// kernel read it before 2026-09-16. `validate()` compares a pcurve to its
4038/// edge at `pcurve_consistency` (4e-3 on a small model, 2.5% of the diagonal
4039/// on a large one): three defects on the notched cap sat between 5e-7 and
4040/// 1.9e-5, four orders inside that bar, and the solid's volume matched its
4041/// closed form because two of them cancelled.
4042///
4043/// ## The bar, and where it comes from
4044///
4045/// Write a trim as its edge plus a departure, `r = e + d`. Over a CLOSED loop,
4046///
4047/// ```text
4048/// (1/2)∮ r×dr − (1/2)∮ e×de = (1/2)∮ (e×dd + d×de) + (1/2)∮ d×dd
4049/// = ∮ d×de + (1/2)∮ d×dd
4050/// ```
4051///
4052/// (the middle step is one integration by parts: `∮ e×dd = −∮ de×d` because
4053/// the loop closes). So a face whose every trim lies within `δ` of its edge
4054/// departs by at most `δ · L`, its own boundary length, and the second-order
4055/// term is `δ²`-small. Summing over a closed shell, whose faces walk every
4056/// edge twice, the strict bound is `2δ · Σ|e|`.
4057///
4058/// `δ` is [`PCURVE_REFINEMENT_TOLERANCE`], 1e-7 — the 3D distance
4059/// `fit_pcurve_on_surface` refines a trim to, and the floor under every mass
4060/// property. The bar this module applies is the TIGHTER
4061/// `δ · Σ|e|` — half the strict bound — because the worst case it drops is a
4062/// shell where every pair of trims sits at the floor on OPPOSITE sides of
4063/// every edge all the way round. A shell between the two is named, and the
4064/// per-face `bar` says which faces put it there.
4065///
4066/// Two things the bar does not cover, both reported rather than absorbed:
4067///
4068/// * a fit that exited at [`crate::PcurveFitExit::SampleCeiling`] never
4069/// reached the floor, so a shell carrying one can be over the bar and still
4070/// be the best this kernel builds. That is a finding about the fit, not a
4071/// reason to widen the bar.
4072/// * the quadrature's own error, reported per shell as
4073/// [`ShellVectorArea::quadrature_error`]. A residual inside its own error
4074/// bar is not a measurement.
4075///
4076/// ## What it does not ask
4077///
4078/// It does not ask whether a face's trim is in the right PLACE — a trim
4079/// displaced along its own edge, or two faces' trims displaced the same way,
4080/// cancel in this sum exactly as they cancel in the volume. It is a closure
4081/// test, not a fit test: `d` enters only through `∮ d×de`. And it says
4082/// nothing about an open shell, where the residual is the boundary's own
4083/// vector area and not a defect.
4084pub fn shell_vector_areas(solid: &BrepSolid) -> VectorAreaReport {
4085 shell_vector_areas_scan(solid, vector_area_span_budget(), "raise BREP_VECTOR_AREA_SPANS to read it")
4086}
4087
4088/// [`shell_vector_areas`] with an explicit span budget instead of the
4089/// default ([`VECTOR_AREA_SPAN_BUDGET`], or `BREP_VECTOR_AREA_SPANS`). Every
4090/// quadrature target, bar and reading is the same; only where the scan gives
4091/// up moves. For a caller that must judge a body the default cannot afford
4092/// and has a stated bound of its own (the inherited-trim refit's gate reads
4093/// at most two default budgets); the public entry and its default are
4094/// unchanged.
4095pub(crate) fn shell_vector_areas_with_budget(solid: &BrepSolid, span_budget: usize) -> VectorAreaReport {
4096 shell_vector_areas_scan(solid, span_budget, "raise the caller's span budget to read it")
4097}
4098
4099/// The scan behind both entries; `advice` ends an over-budget row and says
4100/// which budget the reader can raise (the environment's for the public
4101/// entry, the caller's for an explicit one).
4102fn shell_vector_areas_scan(solid: &BrepSolid, span_budget: usize, advice: &str) -> VectorAreaReport {
4103 let mut report = VectorAreaReport {
4104 shells: Vec::new(),
4105 unreadable: Vec::new(),
4106 };
4107 let edges: HashMap<u64, &crate::topology::EdgeRecord> =
4108 solid.edges.iter().map(|edge| (edge.id, edge)).collect();
4109 let vertices: HashMap<u64, Vec3> = solid
4110 .vertices
4111 .iter()
4112 .map(|vertex| (vertex.id, vertex.point))
4113 .collect();
4114 for shell in &solid.shells {
4115 // Which edges this shell uses, and how often — the closedness test,
4116 // and the edge set the bar's length is taken over.
4117 let mut uses: HashMap<u64, usize> = HashMap::default();
4118 for coedge in shell
4119 .faces
4120 .iter()
4121 .flat_map(|face| &face.loops)
4122 .flat_map(|record| &record.coedges)
4123 {
4124 *uses.entry(coedge.edge_id).or_insert(0) += 1;
4125 }
4126 let closed = uses.iter().all(|(id, count)| {
4127 *count == 2
4128 || edges
4129 .get(id)
4130 .map(|edge| edge.degenerate)
4131 .unwrap_or(false)
4132 });
4133
4134 // The reference: the centre of the bounding box of the vertices this
4135 // shell's edges use. Far-from-origin arms cost digits in a sum read
4136 // at 1e-9, and the kernel's volume integrator anchors locally for the
4137 // same reason.
4138 let mut low = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
4139 let mut high = Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
4140 let mut seen = false;
4141 for point in uses
4142 .keys()
4143 .filter_map(|id| edges.get(id))
4144 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
4145 .filter_map(|id| vertices.get(&id))
4146 {
4147 if !point.x.is_finite() || !point.y.is_finite() || !point.z.is_finite() {
4148 continue;
4149 }
4150 low = Vec3::new(low.x.min(point.x), low.y.min(point.y), low.z.min(point.z));
4151 high = Vec3::new(high.x.max(point.x), high.y.max(point.y), high.z.max(point.z));
4152 seen = true;
4153 }
4154 let reference = if seen {
4155 low.add(high).scale(0.5)
4156 } else {
4157 Vec3::new(0.0, 0.0, 0.0)
4158 };
4159
4160 // Each edge's own integral and length, computed ONCE and added with
4161 // the coedge's sign: the two uses of an edge then cancel to rounding
4162 // rather than to the quadrature, so `edge_residual` reads the
4163 // BOOKKEEPING (is every edge used twice, and once each way) and the
4164 // trims carry the geometry.
4165 // Per edge: its integral over its own ordered span, its arc length,
4166 // and the two end points — computed once and read by both coedges, so
4167 // the two uses cancel to rounding rather than to the quadrature.
4168 // What this shell's reading is ALLOWED to be wrong by, spread over every
4169 // leaf the depth limit permits — the same bound the per-arc floor above
4170 // publishes (`VECTOR_AREA_QUADRATURE_FLOOR` per unit of boundary), stated
4171 // once for the shell instead of per span.
4172 //
4173 // A span's own size cannot carry that bound. Where a trim's
4174 // parameterization has collapsed, the span's magnitude and arc vanish
4175 // while the disagreement between its halves and its whole does not: it is
4176 // round-off in a tangent that is no longer resolved. Then the target is
4177 // unreachable and the span bisects to the depth limit, every time.
4178 // Measured on `fixture_25_cube_pierce_pole_gap_bridge`, a FOUR-face body:
4179 // 313,900 of its 355,610 spans, 5.0 s, for a contribution of 1e-14 against
4180 // a bar of 1.4e-4. A reading may stop once it is inside what the verdict
4181 // allows, and this says where that is.
4182 let mut shell_edge_length = 0.0f64;
4183 let mut top_spans = 0usize;
4184 {
4185 let mut measured: HashSet<u64> = HashSet::default();
4186 for face in &shell.faces {
4187 for coedge in face.loops.iter().flat_map(|loop_record| &loop_record.coedges) {
4188 if let Ok(domain) = coedge.pcurve.domain() {
4189 if let Ok(breaks) = vector_area_breaks(&coedge.pcurve, domain[0], domain[1]) {
4190 top_spans += breaks.len().saturating_sub(1);
4191 }
4192 }
4193 let Some(record) = edges.get(&coedge.edge_id) else { continue };
4194 if !measured.insert(record.id) {
4195 continue;
4196 }
4197 let (lo, hi) = if record.t0 <= record.t1 { (record.t0, record.t1) } else { (record.t1, record.t0) };
4198 let on_edge = |parameter: f64| -> Result<(Vec3, Vec3), String> { record.curve.deriv1(parameter) };
4199 if let Ok(breaks) = vector_area_breaks(&record.curve, lo, hi) {
4200 top_spans += breaks.len().saturating_sub(1);
4201 if let Ok(length) = vector_area_length(&on_edge, &breaks) {
4202 shell_edge_length += length;
4203 }
4204 }
4205 }
4206 }
4207 }
4208 let share = if top_spans == 0 {
4209 0.0
4210 } else {
4211 vector_area_quadrature_floor() * 2.0 * shell_edge_length
4212 / (top_spans as f64 * (1u64 << vector_area_quadrature_depth()) as f64)
4213 };
4214 let mut edge_terms: HashMap<u64, (Vec3, f64, Vec3, Vec3)> = HashMap::default();
4215 let mut quadrature_error = 0.0;
4216 let mut spans = 0usize;
4217 let mut exhausted = 0usize;
4218 let mut unreadable_here = 0usize;
4219 let mut over_budget = false;
4220 let mut face_areas: Vec<FaceVectorArea> = Vec::with_capacity(shell.faces.len());
4221 let mut trim_residual = Vec3::new(0.0, 0.0, 0.0);
4222 let mut edge_residual = Vec3::new(0.0, 0.0, 0.0);
4223 let mut trim_gap = 0.0;
4224 let mut edge_gap = 0.0;
4225 let mut boundary_length = 0.0;
4226 for face in &shell.faces {
4227 // The surface's own knot levels, and its parameter periods: a
4228 // trim span that crosses one is cut there. The pcurve knows
4229 // nothing of the surface's knots, and a Gauss rule read straight
4230 // across one is the same 0.3% error `winding_loops_integral`
4231 // records — five orders above what this check reads. Bisection
4232 // alone cannot fix it either: a kink converges polynomially, so
4233 // the recursion bottoms out at its depth limit instead.
4234 let levels = surface_breaks(&face.surface)
4235 .ok()
4236 .zip(face.surface.domain_u().ok())
4237 .zip(face.surface.domain_v().ok())
4238 .map(|(((u_breaks, v_breaks), [u0, u1]), [v0, v1])| {
4239 (u_breaks, v_breaks, u1 - u0, v1 - v0)
4240 });
4241 let mut trim = Vec3::new(0.0, 0.0, 0.0);
4242 let mut edge = Vec3::new(0.0, 0.0, 0.0);
4243 let mut face_length = 0.0;
4244 let mut face_gap = 0.0;
4245 let mut failed = false;
4246 for loop_record in &face.loops {
4247 // A loop is walked as a CLOSED curve: each coedge's own
4248 // integral, plus the straight chord from where the previous
4249 // one ended to where this one starts, and the chord home at
4250 // the end. Those chords are not bookkeeping — two trims
4251 // arriving at one vertex are fitted independently and land a
4252 // fit-accuracy apart, so the sampled loop has small jumps in
4253 // it, and `(1/2) ∮ r × dr` over an OPEN curve is not a vector
4254 // area at all: it moves with the reference by
4255 // `(1/2) R × Σ jumps`. Measured on the notched cap the jumps
4256 // total 4.1e-7 and that reference dependence is 2e-6, half
4257 // the residual being read. Closed with its chords the loop
4258 // has `∮ dr = 0` exactly and the number is a property of the
4259 // face.
4260 let mut trim_previous: Option<Vec3> = None;
4261 let mut trim_first: Option<Vec3> = None;
4262 let mut edge_previous: Option<Vec3> = None;
4263 let mut edge_first: Option<Vec3> = None;
4264 for coedge in &loop_record.coedges {
4265 // The trim: the pcurve through this face's own surface.
4266 // `deriv1_extended` and not `deriv1`, because a sample a
4267 // rounding past a periodic domain must WRAP; the plain
4268 // evaluator clamps, which would flatten the curve onto
4269 // the domain boundary and manufacture a residual.
4270 let on_trim = |parameter: f64| -> Result<(Vec3, Vec3), String> {
4271 let (uv, duv) = coedge.pcurve.deriv1(parameter)?;
4272 let (point, su, sv) = face.surface.deriv1_extended(uv.x, uv.y)?;
4273 Ok((point, su.scale(duv.x).add(sv.scale(duv.y))))
4274 };
4275 let mut note = |message: String| {
4276 report
4277 .unreadable
4278 .push(format!("shell {} face {}: {message}", shell.id, face.id));
4279 };
4280 let trim_span = coedge.pcurve.domain().and_then(|domain| {
4281 let mut breaks =
4282 vector_area_breaks(&coedge.pcurve, domain[0], domain[1])?;
4283 if let Some((u_breaks, v_breaks, u_period, v_period)) = levels.as_ref() {
4284 breaks = split_at_surface_breaks(
4285 &coedge.pcurve,
4286 breaks,
4287 u_breaks,
4288 *u_period,
4289 true,
4290 false,
4291 )?;
4292 breaks = split_at_surface_breaks(
4293 &coedge.pcurve,
4294 breaks,
4295 v_breaks,
4296 *v_period,
4297 false,
4298 false,
4299 )?;
4300 }
4301 let value = vector_area_of(
4302 &on_trim,
4303 &breaks,
4304 reference,
4305 share,
4306 &mut quadrature_error,
4307 &mut spans,
4308 &mut exhausted,
4309 )?;
4310 Ok((value, on_trim(domain[0])?.0, on_trim(domain[1])?.0))
4311 });
4312 match trim_span {
4313 Ok((value, start, end)) => {
4314 trim = trim.add(value);
4315 if let Some(previous) = trim_previous {
4316 trim = trim.add(chord_area(previous, start, reference));
4317 trim_gap += start.sub(previous).length();
4318 face_gap += start.sub(previous).length();
4319 } else {
4320 trim_first = Some(start);
4321 }
4322 trim_previous = Some(end);
4323 }
4324 Err(message) => {
4325 note(format!("coedge {}: {message}", coedge.id));
4326 unreadable_here += 1;
4327 failed = true;
4328 }
4329 }
4330
4331 // The edge: the 3D curve, in the coedge's direction.
4332 let Some(record) = edges.get(&coedge.edge_id) else {
4333 note(format!(
4334 "coedge {} names edge {}, which is not in the record",
4335 coedge.id, coedge.edge_id
4336 ));
4337 unreadable_here += 1;
4338 failed = true;
4339 continue;
4340 };
4341 if !edge_terms.contains_key(&record.id) {
4342 let on_edge = |parameter: f64| -> Result<(Vec3, Vec3), String> {
4343 record.curve.deriv1(parameter)
4344 };
4345 let (lo, hi) = if record.t0 <= record.t1 {
4346 (record.t0, record.t1)
4347 } else {
4348 (record.t1, record.t0)
4349 };
4350 let term = vector_area_breaks(&record.curve, lo, hi).and_then(|breaks| {
4351 let value = vector_area_of(
4352 &on_edge,
4353 &breaks,
4354 reference,
4355 share,
4356 &mut quadrature_error,
4357 &mut spans,
4358 &mut exhausted,
4359 )?;
4360 let length = vector_area_length(&on_edge, &breaks)?;
4361 Ok((value, length, on_edge(lo)?.0, on_edge(hi)?.0))
4362 });
4363 match term {
4364 Ok(term) => {
4365 edge_terms.insert(record.id, term);
4366 }
4367 Err(message) => {
4368 note(format!("edge {}: {message}", record.id));
4369 unreadable_here += 1;
4370 failed = true;
4371 continue;
4372 }
4373 }
4374 }
4375 let Some((value, length, low, high)) = edge_terms.get(&record.id).copied()
4376 else {
4377 continue;
4378 };
4379 // `t0 > t1` on the record is the same span walked the
4380 // other way; the integral above was taken over the
4381 // ordered span, so the record's own order is one sign and
4382 // the coedge's `forward` the other.
4383 let ordered = if record.t0 <= record.t1 { 1.0 } else { -1.0 };
4384 let sense = if coedge.forward { 1.0 } else { -1.0 };
4385 let signed = ordered * sense;
4386 edge = edge.add(value.scale(signed));
4387 let (start, end) = if signed > 0.0 { (low, high) } else { (high, low) };
4388 if let Some(previous) = edge_previous {
4389 edge = edge.add(chord_area(previous, start, reference));
4390 edge_gap += start.sub(previous).length();
4391 } else {
4392 edge_first = Some(start);
4393 }
4394 edge_previous = Some(end);
4395 face_length += length;
4396 }
4397 if let (Some(previous), Some(first)) = (trim_previous, trim_first) {
4398 trim = trim.add(chord_area(previous, first, reference));
4399 trim_gap += first.sub(previous).length();
4400 face_gap += first.sub(previous).length();
4401 }
4402 if let (Some(previous), Some(first)) = (edge_previous, edge_first) {
4403 edge = edge.add(chord_area(previous, first, reference));
4404 edge_gap += first.sub(previous).length();
4405 }
4406 }
4407 if failed {
4408 continue;
4409 }
4410 // The budget, checked per face so a shell gives up on a face
4411 // boundary rather than part-way through a loop. `over_budget`
4412 // makes the shell unreadable below; it is never a residual.
4413 if spans > span_budget {
4414 over_budget = true;
4415 report.unreadable.push(format!(
4416 "shell {}: gave up after {} spans on {} of {} faces (budget {}); UNJUDGED rather than clean — {advice}",
4417 shell.id,
4418 spans,
4419 face_areas.len() + 1,
4420 shell.faces.len(),
4421 span_budget
4422 ));
4423 break;
4424 }
4425 boundary_length += face_length;
4426 trim_residual = trim_residual.add(trim);
4427 edge_residual = edge_residual.add(edge);
4428 face_areas.push(FaceVectorArea {
4429 shell: shell.id,
4430 face: face.id,
4431 face_name: face.name.clone(),
4432 trim,
4433 edge,
4434 departure: trim.sub(edge),
4435 boundary_length: face_length,
4436 bar: crate::pcurve::PCURVE_REFINEMENT_TOLERANCE * face_length,
4437 closure_gap: face_gap,
4438 });
4439 }
4440 let edge_length: f64 = edge_terms.values().map(|(_, length, _, _)| *length).sum();
4441 report.shells.push(ShellVectorArea {
4442 shell: shell.id,
4443 faces: shell.faces.len(),
4444 // A face that could not be read leaves its share of the residual
4445 // out of the sum, so the shell is not claimed to close — and a
4446 // shell abandoned on the span budget is in exactly that position,
4447 // with whole faces missing from the sum.
4448 closed: closed && unreadable_here == 0 && !over_budget,
4449 reference,
4450 trim_residual,
4451 edge_residual,
4452 edge_length,
4453 boundary_length,
4454 bar: crate::pcurve::PCURVE_REFINEMENT_TOLERANCE * edge_length,
4455 quadrature_error,
4456 spans,
4457 spans_at_depth_limit: exhausted,
4458 trim_closure_gap: trim_gap,
4459 edge_closure_gap: edge_gap,
4460 gave_up: over_budget,
4461 face_areas,
4462 });
4463 }
4464 report
4465}