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        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(&current.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}