Skip to main content

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