brep_kernel/brep/topology/validate.rs
1use super::*;
2
3impl BrepSolid {
4 pub fn validate(&self) -> Vec<ValidationIssue> {
5 self.validate_with_tolerances(&KernelTolerances::for_solid(self, 1e-7))
6 }
7
8 pub fn validate_with_tolerances(&self, tolerances: &KernelTolerances) -> Vec<ValidationIssue> {
9 self.validate_detailed(tolerances).issues
10 }
11
12 pub fn validate_detailed(&self, tolerances: &KernelTolerances) -> ValidationReport {
13 self.validate_detailed_scoped(tolerances, None)
14 }
15
16 /// [`validate`](Self::validate) reading the per-coedge GEOMETRY (carrier,
17 /// pcurve against edge, uv wire) of the faces in `geometry_faces` only.
18 /// Every structural check — ids, loop closure, edge use counts, curve
19 /// endpoints against vertices, the Euler count — still reads the whole
20 /// solid. A boolean uses this for faces it carried through unchanged
21 /// from an operand (`BooleanOptions::untouched_passthrough`): their
22 /// geometry is the operand's, byte for byte, and re-reading it is the
23 /// cost the record named.
24 pub fn validate_scoped(&self, geometry_faces: &HashSet<u64>) -> Vec<ValidationIssue> {
25 self.validate_detailed_scoped(&KernelTolerances::for_solid(self, 1e-7), Some(geometry_faces))
26 .issues
27 }
28
29 /// [`validate_detailed`](Self::validate_detailed) with the geometry checks
30 /// restricted as [`validate_scoped`](Self::validate_scoped) describes;
31 /// `None` reads every face.
32 pub fn validate_detailed_scoped(
33 &self,
34 tolerances: &KernelTolerances,
35 geometry_faces: Option<&HashSet<u64>>,
36 ) -> ValidationReport {
37 let mut issues = Vec::new();
38 let mut wire_warnings = Vec::new();
39 let mut max_pcurve_error = 0.0f64;
40 // Model bounding-box diagonal: the LOCAL extent the edge/pcurve
41 // coincidence band size-couples to (see `pcurve_acceptance`). Raw, not
42 // `solid_scale` — that floors at 1.0, which would hand a genuinely tiny
43 // part a coincidence band 30x its own size; the tight absolute
44 // `pcurve_consistency` floor already guards the small end.
45 let mut bbox_lo = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
46 let mut bbox_hi = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
47 for vertex in &self.vertices {
48 bbox_lo.x = bbox_lo.x.min(vertex.point.x);
49 bbox_lo.y = bbox_lo.y.min(vertex.point.y);
50 bbox_lo.z = bbox_lo.z.min(vertex.point.z);
51 bbox_hi.x = bbox_hi.x.max(vertex.point.x);
52 bbox_hi.y = bbox_hi.y.max(vertex.point.y);
53 bbox_hi.z = bbox_hi.z.max(vertex.point.z);
54 }
55 let model_diagonal = if self.vertices.is_empty() {
56 0.0
57 } else {
58 bbox_hi.sub(bbox_lo).length()
59 };
60 let pcurve_limit = tolerances.pcurve_acceptance(model_diagonal);
61 // Vendor STEP files commit a vertex point and its incident edges' 3D-curve
62 // endpoints as INDEPENDENT approximations that disagree by a small, roughly
63 // ABSOLUTE amount (ABC 00000084/109/218 fail ONLY here: every gap clusters
64 // tightly at ~1.0-2.2e-5 regardless of the sub-unit part size; ABC 00010746
65 // has the same repeated pattern at ~3.2-3.9e-5 on 24 matching endpoints —
66 // a fixed export-precision offset, not a size-relative one). Like OCC/Parasolid
67 // absorbing the gap in a widened per-vertex tolerance, accept it: floor the
68 // identity band at a vendor-precision absolute and size-couple for large
69 // parts via `heal_band`. The old fixed `max(model*100, 1e-5)` band (model
70 // is pinned at 1e-7, so a flat 1e-5) rejected these shared vertices; a
71 // genuinely disconnected edge sits orders of magnitude above this floor, so
72 // real breakage is still refused. Clean parts already match far tighter, so
73 // this only admits the vendor near-miss.
74 // k = 2e-5: at metre numerics the .max(4e-5) ABSOLUTE floor was the
75 // effective acceptance (~1.4e-4 of a 0.28-extent part) and silently
76 // absorbed vendor edge-vs-vertex gaps; in mm the floor is inert and
77 // k=1e-5 narrowly rejected an authored 1.25e-5-of-diagonal gap
78 // (ABC 8575). 2e-5 keeps the check meaningful while accepting vendor
79 // imprecision the old floor accepted. Validation acceptance only —
80 // no merge radius derives from this.
81 // Floor = vendor EXPORT PRECISION in mm. The old 4e-5 floor encoded
82 // metre-era numerics (~4e-5 of a metre); the same physical export
83 // imprecision lands x1000 bigger now that imports convert to mm
84 // (ABC 8575: a 3.5e-6 m authored vertex/curve offset = 3.5e-3 mm).
85 // 5e-3 mm (= 5 um) accepts vendor precision for metre- and
86 // mm-authored files alike. Validation acceptance only — no merge
87 // radius derives from this.
88 let vertex_match = tolerances
89 .heal_band(model_diagonal, 2e-5)
90 .max(crate::VERTEX_MATCH_FLOOR);
91 let vertices: HashMap<u64, &VertexRecord> = self
92 .vertices
93 .iter()
94 .map(|vertex| (vertex.id, vertex))
95 .collect();
96 let edges: HashMap<u64, &EdgeRecord> =
97 self.edges.iter().map(|edge| (edge.id, edge)).collect();
98 if vertices.len() != self.vertices.len() {
99 issues.push(ValidationIssue::error("duplicate vertex id"));
100 }
101 if edges.len() != self.edges.len() {
102 issues.push(ValidationIssue::error("duplicate edge id"));
103 }
104
105 let mut edge_uses: HashMap<u64, Vec<bool>> = HashMap::default();
106 let mut face_ids = HashSet::default();
107 for shell in &self.shells {
108 for face in &shell.faces {
109 if !face_ids.insert(face.id) {
110 issues.push(ValidationIssue::error(format!(
111 "duplicate face id {}",
112 face.id
113 )));
114 }
115 let read_geometry =
116 geometry_faces.is_none_or(|scope| scope.contains(&face.id));
117 let surface = if read_geometry {
118 match NurbsSurface::new(
119 face.surface.degree_u,
120 face.surface.degree_v,
121 face.surface.knots_u.clone(),
122 face.surface.knots_v.clone(),
123 face.surface.control_points.clone(),
124 ) {
125 Ok(surface) => Some(surface),
126 Err(error) => {
127 issues.push(ValidationIssue::error(format!(
128 "face {} has invalid surface: {}",
129 face.id, error
130 )));
131 continue;
132 }
133 }
134 } else {
135 None
136 };
137 for (loop_index, loop_record) in face.loops.iter().enumerate() {
138 if loop_record.coedges.is_empty() {
139 issues.push(ValidationIssue::error(format!(
140 "loop {} of face {} is empty",
141 loop_record.id, face.id
142 )));
143 continue;
144 }
145 for coedge in &loop_record.coedges {
146 edge_uses
147 .entry(coedge.edge_id)
148 .or_default()
149 .push(coedge.forward);
150 }
151 for index in 0..loop_record.coedges.len() {
152 let current = &loop_record.coedges[index];
153 let next = &loop_record.coedges[(index + 1) % loop_record.coedges.len()];
154 let Some(current_edge) = edges.get(¤t.edge_id) else {
155 issues.push(ValidationIssue::error(format!(
156 "coedge {} references missing edge {}",
157 current.id, current.edge_id
158 )));
159 continue;
160 };
161 let Some(next_edge) = edges.get(&next.edge_id) else {
162 issues.push(ValidationIssue::error(format!(
163 "coedge {} references missing edge {}",
164 next.id, next.edge_id
165 )));
166 continue;
167 };
168 let current_end = if current.forward {
169 current_edge.end_vertex_id
170 } else {
171 current_edge.start_vertex_id
172 };
173 let next_start = if next.forward {
174 next_edge.start_vertex_id
175 } else {
176 next_edge.end_vertex_id
177 };
178 if current_end != next_start {
179 issues.push(ValidationIssue::error(format!(
180 "loop {} of face {} is open between coedges {} and {}",
181 loop_record.id, face.id, current.id, next.id
182 )));
183 }
184 let Some(surface) = &surface else {
185 continue;
186 };
187
188 let pcurve = match NurbsCurve::new(
189 current.pcurve.degree,
190 current.pcurve.knots.clone(),
191 current.pcurve.control_points.clone(),
192 ) {
193 Ok(curve) => curve,
194 Err(error) => {
195 issues.push(ValidationIssue::error(format!(
196 "coedge {} has invalid pcurve: {}",
197 current.id, error
198 )));
199 continue;
200 }
201 };
202 let curve = match NurbsCurve::new(
203 current_edge.curve.degree,
204 current_edge.curve.knots.clone(),
205 current_edge.curve.control_points.clone(),
206 ) {
207 Ok(curve) => curve,
208 Err(error) => {
209 issues.push(ValidationIssue::error(format!(
210 "edge {} has invalid curve: {}",
211 current_edge.id, error
212 )));
213 continue;
214 }
215 };
216 match coedge_error_floored(
217 surface,
218 &pcurve,
219 &curve,
220 current_edge,
221 current.forward,
222 pcurve_limit,
223 ) {
224 Ok(error) => {
225 max_pcurve_error = max_pcurve_error.max(error);
226 if error > pcurve_limit {
227 issues.push(ValidationIssue::error(format!(
228 "coedge {} of face {} pcurve is inconsistent with edge {} \
229 (max deviation {:.6}, limit {:.6})",
230 current.id, face.id, current_edge.id, error, pcurve_limit,
231 )));
232 }
233 }
234 Err(error) => issues.push(ValidationIssue::error(format!(
235 "coedge {} of face {} cannot be evaluated: {}",
236 current.id, face.id, error
237 ))),
238 }
239 }
240
241 let Some(surface) = &surface else {
242 continue;
243 };
244 if let Some(warning) = validate_uv_wire(
245 surface,
246 loop_record,
247 &edges,
248 tolerances,
249 loop_index == 0,
250 face.same_sense,
251 ) {
252 wire_warnings.push(ValidationIssue::warning(format!(
253 "face {} loop {}: {}",
254 face.id, loop_record.id, warning
255 )));
256 }
257 }
258 }
259 }
260
261 for edge in &self.edges {
262 if !(edge.t0.is_finite() && edge.t1.is_finite() && edge.t0 < edge.t1) {
263 issues.push(ValidationIssue::error(format!(
264 "edge {} has invalid parameter range",
265 edge.id
266 )));
267 }
268 let Some(start) = vertices.get(&edge.start_vertex_id) else {
269 issues.push(ValidationIssue::error(format!(
270 "edge {} references missing start vertex {}",
271 edge.id, edge.start_vertex_id
272 )));
273 continue;
274 };
275 let Some(end) = vertices.get(&edge.end_vertex_id) else {
276 issues.push(ValidationIssue::error(format!(
277 "edge {} references missing end vertex {}",
278 edge.id, edge.end_vertex_id
279 )));
280 continue;
281 };
282 if let Ok(curve) = NurbsCurve::new(
283 edge.curve.degree,
284 edge.curve.knots.clone(),
285 edge.curve.control_points.clone(),
286 ) {
287 if let Ok(point) = curve.evaluate(edge.t0) {
288 let gap = point.sub(start.point).length();
289 if gap > vertex_match {
290 issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: true }, format!(
291 "edge {} curve start does not match vertex {} \
292 (gap={gap:.9}, curve={point:?}, vertex={:?})",
293 edge.id, start.id, start.point
294 )));
295 }
296 }
297 if let Ok(point) = curve.evaluate(edge.t1) {
298 let gap = point.sub(end.point).length();
299 if gap > vertex_match {
300 issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: false }, format!(
301 "edge {} curve end does not match vertex {} \
302 (gap={gap:.9}, curve={point:?}, vertex={:?})",
303 edge.id, end.id, end.point
304 )));
305 }
306 }
307 }
308 let uses = edge_uses.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
309 if edge.degenerate {
310 // A synthesized VERTEX_LOOP/pole boundary is single-use, but
311 // vendor STEP may share one explicit collapsed EDGE_CURVE
312 // between the two faces meeting at that pole. The latter is a
313 // valid manifold incidence exactly when the coedge senses are
314 // opposite, just like an ordinary shared edge.
315 let valid = uses.len() == 1 || (uses.len() == 2 && uses[0] != uses[1]);
316 if !valid {
317 issues.push(ValidationIssue::error(format!(
318 "degenerate edge {} has invalid incidence {:?} \
319 (expected one use or an opposite-sense pair)",
320 edge.id, uses,
321 )));
322 }
323 } else if uses.len() != 2 {
324 let kind = if uses.len() < 2 { IssueKind::OpenEdge } else { IssueKind::OverUsedEdge };
325 issues.push(ValidationIssue::error_kind(kind, format!(
326 "edge {} used {} times (expected 2)",
327 edge.id,
328 uses.len()
329 )));
330 } else if uses[0] == uses[1] {
331 issues.push(ValidationIssue::error(format!(
332 "edge {} has coedges with the same sense",
333 edge.id
334 )));
335 }
336 }
337
338 for edge_id in edge_uses.keys() {
339 if !edges.contains_key(edge_id) {
340 issues.push(ValidationIssue::error(format!(
341 "topology references missing edge {}",
342 edge_id
343 )));
344 }
345 }
346
347 // A pole edge is a collapsed parameter-space boundary. Its endpoint
348 // is not an independent 0-cell unless a non-degenerate edge also
349 // uses it, so use the reduced complex for Euler accounting.
350 let non_degenerate_vertex_ids = self
351 .edges
352 .iter()
353 .filter(|edge| !edge.degenerate)
354 .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
355 .collect::<HashSet<_>>();
356 let vertex_count = vertices
357 .keys()
358 .filter(|id| non_degenerate_vertex_ids.contains(id))
359 .count() as i64;
360 let edge_count = self.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
361 let face_count = face_ids.len() as i64;
362 let hole_count = self.bounding_hole_count();
363 let shell_count = self.shells.len() as i64;
364 let actual = vertex_count - edge_count + face_count - hole_count;
365 let expected = 2 * (shell_count - self.genus);
366 // What this comparison actually tests, and it is not the genus.
367 //
368 // On a freshly derived solid the producer set `genus = S - chi/2` in
369 // INTEGER division, so `expected = 2*(S - genus) = 2*trunc(chi/2)` and
370 // `actual == expected` holds EXACTLY when the characteristic is even.
371 // Measured over 1042 imported bodies, the rows that mismatch and the
372 // rows with an odd characteristic are the same rows, both ways. After a
373 // mutation that adjusts `genus` by hand -- `closed_heal`'s `genus -= 1`,
374 // `delete_faces`' `genus += shift / 2` -- it becomes a drift check on
375 // that adjustment. It is never an independent measurement of the genus:
376 // `genus = S - chi/2` IS the definition, so any recomputation from the
377 // same complex re-runs this arithmetic.
378 //
379 // The PRECONDITION, which this check did not used to state.
380 //
381 // `chi = 2(S - G)` is a fact about CLOSED orientable surfaces. On an
382 // open shell -- one still being assembled, or a sheet -- the
383 // characteristic is unconstrained (a disk is 1) and comparing it to
384 // anything is meaningless. Until 2026-09-11 the comparison ran on open
385 // shells too and was merely hidden, on some of them, by a skip for
386 // degenerate edges: three offset-shell cases turn out to reach it with
387 // an open shell and an EVEN characteristic, so the skip was covering
388 // for a missing precondition rather than for a pole-collapse problem.
389 //
390 // The degenerate-edge skip that used to stand here is GONE, and what it
391 // was hiding was not what its comment claimed.
392 //
393 // It read "the reduced V-E+F formula does not model parameter-space
394 // pole collapses reliably". Removing it reddened three offset-shell
395 // tests with an EVEN characteristic and a recorded genus that
396 // contradicted it -- so not a parity problem, and reproducible with the
397 // pre-2026-09-11 hole rule, so not a consequence of that work either.
398 // The cause was `finalize.rs` forcing `genus = 0` whenever a body's only
399 // single-use edges are degenerate, which is true of any body carrying an
400 // ordinary pole edge. A shelled solid with a spherical cavity wall was
401 // therefore recorded genus 0 while its own count said 3, and an
402 // independent watertight MESH count of the same shell confirmed the
403 // three handles (chi = -4 at three chord tolerances, closed, no boundary
404 // or non-manifold edges). The handles are real -- shelling on an ANNULAR
405 // opening joins outer and inner skins along two rim curves -- so only the
406 // recorded number was wrong. With `finalize` recording what it counts,
407 // this skip has nothing left to hide and the check runs on every solid.
408 //
409 // `closed` is the precondition the formula genuinely needs: chi =
410 // 2(S - G) is a fact about CLOSED orientable surfaces, and an open
411 // shell's characteristic is unconstrained (a disk is 1). It is stated so
412 // this check and `soundness::solid_euler` agree about the domain they
413 // apply to -- the disagreement between them, one having the precondition
414 // and the other not, is what made a green case gate look like coverage
415 // it was not.
416 let closed = self
417 .edges
418 .iter()
419 .filter(|edge| !edge.degenerate)
420 .all(|edge| edge_uses.get(&edge.id).map(Vec::len) == Some(2));
421 if closed && actual != expected {
422 // OCC-style models carry COINCIDENT PARALLEL edges: two
423 // ref-distinct edges riding one geometric locus between the same
424 // vertices (a wall split exactly where an adjoining ring meets
425 // it). The complex is manifold — the four incident faces pair
426 // off along the shared locus — but the formula counts the locus
427 // twice. Re-check with each coincident pair credited once; the
428 // scan runs only on this failure path, so ordinary solids pay
429 // nothing for it.
430 let pairs = self.coincident_parallel_edge_pairs() as i64;
431 if actual + pairs != expected {
432 issues.push(ValidationIssue::error_kind(IssueKind::GenusMismatch, format!(
433 "Euler formula: V-E+F-H = {}, expected {} ({} coincident parallel edge pair(s) credited)",
434 actual + pairs,
435 expected,
436 pairs
437 )));
438 }
439 }
440 ValidationReport {
441 issues,
442 wire_warnings,
443 max_pcurve_error,
444 }
445 }
446
447 /// `H` in the reduced-complex Euler count `V - E + F - H`: per face, the
448 /// loops that actually bound area, less its outer one.
449 ///
450 /// A boundary collapsed to a single point — STEP's `VERTEX_LOOP`, which
451 /// this kernel models as a single-use degenerate edge carrying a pcurve —
452 /// encloses no area, so it is not a hole. The reduced complex already
453 /// leaves that edge out of `E` and its vertex out of `V`; leaving its loop
454 /// out of `H` is the same rule applied to the same collapsed cell.
455 ///
456 /// Counting it as a hole subtracts a spurious 1 per puncture. An ODD number
457 /// of punctures then makes the characteristic odd, which no closed
458 /// orientable surface has; an even number keeps it even and quietly inflates
459 /// the derived genus by half of them.
460 pub(crate) fn bounding_hole_count(&self) -> i64 {
461 let degenerate: HashSet<u64> = self
462 .edges
463 .iter()
464 .filter(|edge| edge.degenerate)
465 .map(|edge| edge.id)
466 .collect();
467 self.shells
468 .iter()
469 .flat_map(|shell| &shell.faces)
470 .map(|face| {
471 face.loops
472 .iter()
473 .filter(|record| {
474 !record.coedges.is_empty()
475 && !record
476 .coedges
477 .iter()
478 .all(|coedge| degenerate.contains(&coedge.edge_id))
479 })
480 .count()
481 .saturating_sub(1) as i64
482 })
483 .sum()
484 }
485
486 /// Count disjoint pairs of non-degenerate edges that ride ONE geometric
487 /// locus between the same endpoint vertices (OCC wall-split rims). Each
488 /// such pair contributes a single locus to the cell complex, so Euler
489 /// accounting credits it once.
490 pub(crate) fn coincident_parallel_edge_pairs(&self) -> usize {
491 let candidates: Vec<&EdgeRecord> =
492 self.edges.iter().filter(|edge| !edge.degenerate).collect();
493 let mut consumed = vec![false; candidates.len()];
494 let mut pairs = 0usize;
495 for first_index in 0..candidates.len() {
496 if consumed[first_index] {
497 continue;
498 }
499 let first = candidates[first_index];
500 for second_index in first_index + 1..candidates.len() {
501 if consumed[second_index] {
502 continue;
503 }
504 let second = candidates[second_index];
505 let endpoints_match = (first.start_vertex_id == second.start_vertex_id
506 && first.end_vertex_id == second.end_vertex_id)
507 || (first.start_vertex_id == second.end_vertex_id
508 && first.end_vertex_id == second.start_vertex_id);
509 if !endpoints_match {
510 continue;
511 }
512 // Locus agreement at interior samples, direction-agnostic.
513 let coincident = (1..4).all(|sample| {
514 let fraction = sample as f64 / 4.0;
515 let Ok(on_first) = first
516 .curve
517 .evaluate(first.t0 + (first.t1 - first.t0) * fraction)
518 else {
519 return false;
520 };
521 let forward = second.t0 + (second.t1 - second.t0) * fraction;
522 let backward = second.t1 - (second.t1 - second.t0) * fraction;
523 let tolerance = 1e-6 * (1.0 + on_first.length());
524 let matches_forward = second
525 .curve
526 .evaluate(forward)
527 .map(|point| point.sub(on_first).length() <= tolerance)
528 .unwrap_or(false);
529 let matches_backward = second
530 .curve
531 .evaluate(backward)
532 .map(|point| point.sub(on_first).length() <= tolerance)
533 .unwrap_or(false);
534 matches_forward || matches_backward
535 });
536 if coincident {
537 consumed[first_index] = true;
538 consumed[second_index] = true;
539 pairs += 1;
540 break;
541 }
542 }
543 }
544 pairs
545 }
546}
547
548fn coedge_sample(
549 surface: &NurbsSurface,
550 pcurve: &NurbsCurve,
551 curve: &NurbsCurve,
552 edge: &EdgeRecord,
553 forward: bool,
554 fraction: f64,
555) -> Result<(f64, Vec3, Vec3), String> {
556 let [q0, q1] = pcurve.domain()?;
557 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
558 // A pcurve that straddles a periodic seam carries parameters just past the
559 // domain; the surface WRAPS there (evaluate_extended), so a coedge riding
560 // the seam still reproduces its edge exactly instead of reading as a gross
561 // deviation against the clamped boundary.
562 let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
563 let t = if forward {
564 edge.t0 + (edge.t1 - edge.t0) * fraction
565 } else {
566 edge.t1 - (edge.t1 - edge.t0) * fraction
567 };
568 let on_curve = curve.evaluate(t)?;
569 Ok((on_surface.sub(on_curve).length(), on_surface, on_curve))
570}
571
572/// Start with 32 intervals, then subdivide where either represented curve
573/// bends appreciably or the deviation function is non-linear. This catches
574/// interior NURBS drift that four fixed samples cannot see without imposing
575/// the cost of a uniformly extreme sampling count on every coedge.
576pub(crate) fn adaptive_coedge_error(
577 surface: &NurbsSurface,
578 pcurve: &NurbsCurve,
579 curve: &NurbsCurve,
580 edge: &EdgeRecord,
581 forward: bool,
582 tolerance: f64,
583) -> Result<f64, String> {
584 fn interval(
585 surface: &NurbsSurface,
586 pcurve: &NurbsCurve,
587 curve: &NurbsCurve,
588 edge: &EdgeRecord,
589 forward: bool,
590 a: f64,
591 b: f64,
592 sample_a: (f64, Vec3, Vec3),
593 sample_b: (f64, Vec3, Vec3),
594 tolerance: f64,
595 depth: usize,
596 ) -> Result<f64, String> {
597 let mid = (a + b) * 0.5;
598 let sample_mid = coedge_sample(surface, pcurve, curve, edge, forward, mid)?;
599 let error_nonlinearity = (sample_mid.0 - (sample_a.0 + sample_b.0) * 0.5).abs();
600 let surface_bend = sample_mid
601 .1
602 .sub(sample_a.1.add(sample_b.1).scale(0.5))
603 .length();
604 let curve_bend = sample_mid
605 .2
606 .sub(sample_a.2.add(sample_b.2).scale(0.5))
607 .length();
608 let local_max = sample_a.0.max(sample_mid.0).max(sample_b.0);
609 if depth >= 3
610 || (error_nonlinearity <= tolerance * 0.05
611 && surface_bend.max(curve_bend) <= tolerance * 0.25)
612 {
613 return Ok(local_max);
614 }
615 Ok(interval(
616 surface,
617 pcurve,
618 curve,
619 edge,
620 forward,
621 a,
622 mid,
623 sample_a,
624 sample_mid,
625 tolerance,
626 depth + 1,
627 )?
628 .max(interval(
629 surface,
630 pcurve,
631 curve,
632 edge,
633 forward,
634 mid,
635 b,
636 sample_mid,
637 sample_b,
638 tolerance,
639 depth + 1,
640 )?))
641 }
642
643 let mut maximum = 0.0f64;
644 let mut previous = coedge_sample(surface, pcurve, curve, edge, forward, 0.0)?;
645 maximum = maximum.max(previous.0);
646 for index in 1..=32 {
647 let a = (index - 1) as f64 / 32.0;
648 let b = index as f64 / 32.0;
649 let next = coedge_sample(surface, pcurve, curve, edge, forward, b)?;
650 maximum = maximum.max(interval(
651 surface, pcurve, curve, edge, forward, a, b, previous, next, tolerance, 0,
652 )?);
653 previous = next;
654 }
655 // This pass refines only while the band it is given says to, so on a part
656 // whose band is slack it stops at its 33 uniform stations and a pcurve
657 // exact at its own nodes and wrong between them reads as exact (measured
658 // 2026-09-18 over 54556 imported pcurves: more than 2x low on 2204). The
659 // band-free floor is [`coedge_error_floored`]; `validate` reads through
660 // it, the boolean's repair decisions do not yet.
661 Ok(maximum)
662}
663
664/// [`adaptive_coedge_error`] with the band-free floor: the midpoint of every
665/// knot span of the pcurve and of the edge curve, read whatever the band. This
666/// is what `validate` and the offset family's measured tolerance read. The
667/// boolean's repair decisions (`csg/boolean/assemble/finalize.rs`,
668/// `refusal_welds.rs`) and the healing coalesce keep the band-steered reading
669/// above: read through the floor, the zero-clearance ball-and-stick joint
670/// gains an edge and a vertex at its crotch ((5, 9, 6) against (5, 8, 5),
671/// `tests/suites/tangent_node_assembly.rs`, 2026-09-26) — a consumer that
672/// depends on its instrument's coarseness, named for its own slice.
673pub(crate) fn coedge_error_floored(
674 surface: &NurbsSurface,
675 pcurve: &NurbsCurve,
676 curve: &NurbsCurve,
677 edge: &EdgeRecord,
678 forward: bool,
679 tolerance: f64,
680) -> Result<f64, String> {
681 let steered = adaptive_coedge_error(surface, pcurve, curve, edge, forward, tolerance)?;
682 Ok(steered.max(knot_span_midpoint_error(surface, pcurve, curve, edge, forward)?))
683}
684
685/// `BREP_DEBUG_COEDGE_FLOOR`: trace every knot-span midpoint the floor reads
686/// over 1e-3, once per process (the floor runs per span of every coedge of
687/// every `validate()`, so the environment is not read per sample).
688fn coedge_floor_tracing() -> bool {
689 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
690 *ON.get_or_init(|| std::env::var_os("BREP_DEBUG_COEDGE_FLOOR").is_some())
691}
692
693/// `‖S(q(s)) − c(t(s))‖` at the midpoint of every knot span of `pcurve` and of
694/// every knot span of `curve` inside the edge's `[t0, t1]`, both mapped to the
695/// coedge fraction `adaptive_coedge_error` samples on — no tolerance steers
696/// where it looks. The pcurve half is the one no other pass takes: a fitted
697/// pcurve interpolates at its nodes and misses between them, and its nodes
698/// are its own knots, not the edge curve's.
699fn knot_span_midpoint_error(
700 surface: &NurbsSurface,
701 pcurve: &NurbsCurve,
702 curve: &NurbsCurve,
703 edge: &EdgeRecord,
704 forward: bool,
705) -> Result<f64, String> {
706 let mut worst = 0.0_f64;
707 let [q0, q1] = pcurve.domain()?;
708 if q1 > q0 {
709 let mut previous = q0;
710 for &knot in pcurve.knots.iter().chain(std::iter::once(&q1)) {
711 if knot > previous && knot <= q1 {
712 let fraction = ((previous + knot) * 0.5 - q0) / (q1 - q0);
713 let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
714 if coedge_floor_tracing() && sample.0 > 1e-3 {
715 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
716 let ([u0, u1], [v0, v1]) = (surface.domain_u()?, surface.domain_v()?);
717 eprintln!(
718 "COEDGE-FLOOR edge {} pcurve-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6}) domain u[{u0},{u1}] v[{v0},{v1}] closed {:?} analytic {:?} degree {} knots {} cps {}",
719 edge.id, sample.0, uv.x, uv.y, surface.closed_directions()?, surface.analytic().map(|_| "yes"), pcurve.degree, pcurve.knots.len(), pcurve.control_points.len()
720 );
721 }
722 worst = worst.max(sample.0);
723 previous = knot;
724 }
725 }
726 }
727 let span = edge.t1 - edge.t0;
728 if span.is_finite() && span != 0.0 {
729 let low = edge.t0.min(edge.t1);
730 let high = edge.t0.max(edge.t1);
731 let mut previous = low;
732 for &knot in curve.knots.iter().chain(std::iter::once(&high)) {
733 if knot > previous && knot <= high {
734 let midpoint = (previous + knot) * 0.5;
735 let fraction = if forward {
736 (midpoint - edge.t0) / span
737 } else {
738 (edge.t1 - midpoint) / span
739 };
740 let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
741 if coedge_floor_tracing() && sample.0 > 1e-3 {
742 let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
743 eprintln!(
744 "COEDGE-FLOOR edge {} edge-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6})",
745 edge.id, sample.0, uv.x, uv.y
746 );
747 }
748 worst = worst.max(sample.0);
749 previous = knot;
750 }
751 }
752 }
753 Ok(worst)
754}
755
756fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
757 let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
758 let ab = b.sub(a);
759 let cd = d.sub(c);
760 let denominator = cross(ab, cd);
761 if denominator.abs() <= tolerance {
762 return false;
763 }
764 let ac = c.sub(a);
765 let t = cross(ac, cd) / denominator;
766 let u = cross(ac, ab) / denominator;
767 t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
768}
769
770fn validate_uv_wire(
771 surface: &NurbsSurface,
772 loop_record: &LoopRecord,
773 edges: &HashMap<u64, &EdgeRecord>,
774 tolerances: &KernelTolerances,
775 outer: bool,
776 same_sense: bool,
777) -> Option<String> {
778 let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
779 .ok()?
780 .domain();
781 let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
782 .ok()?
783 .domain();
784 let u_span = u_domain[1] - u_domain[0];
785 let v_span = v_domain[1] - v_domain[0];
786 let mut points = Vec::<Vec2>::new();
787 for coedge in &loop_record.coedges {
788 let edge = edges.get(&coedge.edge_id)?;
789 if edge.degenerate {
790 continue;
791 }
792 let [q0, q1] = coedge.pcurve.domain().ok()?;
793 for index in 0..=8 {
794 if !points.is_empty() && index == 0 {
795 continue;
796 }
797 let fraction = index as f64 / 8.0;
798 let parameter = q0 + (q1 - q0) * fraction;
799 let value = coedge.pcurve.evaluate(parameter).ok()?;
800 let mut point = Vec2 {
801 x: value.x,
802 y: value.y,
803 };
804 if let Some(previous) = points.last() {
805 while point.x - previous.x > u_span * 0.5 {
806 point.x -= u_span;
807 }
808 while point.x - previous.x < -u_span * 0.5 {
809 point.x += u_span;
810 }
811 while point.y - previous.y > v_span * 0.5 {
812 point.y -= v_span;
813 }
814 while point.y - previous.y < -v_span * 0.5 {
815 point.y += v_span;
816 }
817 }
818 points.push(point);
819 }
820 }
821 if points.len() < 4 {
822 return None;
823 }
824 let first = points[0];
825 let last = *points.last()?;
826 let uv_tolerance = tolerances.model.max(1e-8);
827 if last.sub(first).length() > uv_tolerance * 100.0 {
828 // Parameter seams may differ by a complete period. Compare modulo
829 // both domains before reporting a genuine open wire.
830 let du = (last.x - first.x) / u_span;
831 let dv = (last.y - first.y) / v_span;
832 if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
833 || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
834 {
835 return Some(format!(
836 "wire is open in parameter space (gap {:.3e})",
837 last.sub(first).length()
838 ));
839 }
840 }
841 for first_index in 0..points.len() - 1 {
842 for second_index in first_index + 2..points.len() - 1 {
843 if first_index == 0 && second_index + 1 == points.len() - 1 {
844 continue;
845 }
846 if segments_cross(
847 points[first_index],
848 points[first_index + 1],
849 points[second_index],
850 points[second_index + 1],
851 1e-10,
852 ) {
853 return Some(format!(
854 "wire self-intersects near sampled segments {first_index} and {second_index}"
855 ));
856 }
857 }
858 }
859 let area = points
860 .windows(2)
861 .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
862 .sum::<f64>()
863 * 0.5;
864 if area.abs() > uv_tolerance * uv_tolerance {
865 let expected_positive = if outer { same_sense } else { !same_sense };
866 if (area > 0.0) != expected_positive {
867 return Some(format!(
868 "{} wire winding disagrees with face sense (signed UV area {:.6})",
869 if outer { "outer" } else { "inner" },
870 area
871 ));
872 }
873 }
874 None
875}