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