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