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    /// [`validate`](Self::validate) with its CLEAN verdicts memoised by the
9    /// body's content (lane R1 row 3, lane B's B3). A rim built by the
10    /// closed-rim lane is validated by the surgery's repaired-branch check, by
11    /// the group lane and by the pipeline's exit acceptance; `validate` is a
12    /// pure function of the body, so a body whose serialised record read clean
13    /// once reads clean again. The key is two independent 64-bit hashes of the
14    /// serialisation, so ANY mutation (a moved control point, a re-trimmed
15    /// coedge, a renamed face) is validated afresh — the key is the content,
16    /// never an address. A body with issues is validated every time. The memo
17    /// is thread-local, bounded, and emptied by [`BrepSolid::clear_validate_memo`] at
18    /// each fillet operation's entry. On by default (promoted 2026-10-18 on
19    /// the 98 + 144 census; `BREP_VALIDATE_MEMO=0` validates every time); counted as
20    /// `count.validate.memo_hit` / `count.validate.memo_miss`.
21    pub(crate) fn validate_memoised(&self) -> Vec<ValidationIssue> {
22        if !validate_memo_on() {
23            return self.validate();
24        }
25        let key_timing = crate::blend::BlendTimer::start("timing.validate.memo_key_ms");
26        let Some(key) = self.validate_memo_key() else {
27            return self.validate();
28        };
29        key_timing.stop();
30        if VALIDATED_CLEAN.with(|clean| clean.borrow().contains(&key)) {
31            crate::blend::record_count("count.validate.memo_hit", 1);
32            return Vec::new();
33        }
34        crate::blend::record_count("count.validate.memo_miss", 1);
35        let issues = self.validate();
36        if issues.is_empty() {
37            VALIDATED_CLEAN.with(|clean| {
38                let mut clean = clean.borrow_mut();
39                if clean.len() >= VALIDATED_CLEAN_CAP {
40                    clean.clear();
41                }
42                clean.insert(key);
43            });
44        }
45        issues
46    }
47
48    pub fn validate_with_tolerances(&self, tolerances: &KernelTolerances) -> Vec<ValidationIssue> {
49        self.validate_detailed(tolerances).issues
50    }
51
52    pub fn validate_detailed(&self, tolerances: &KernelTolerances) -> ValidationReport {
53        self.validate_detailed_scoped(tolerances, None)
54    }
55
56    /// [`validate`](Self::validate) reading the per-coedge GEOMETRY (carrier,
57    /// pcurve against edge, uv wire) of the faces in `geometry_faces` only.
58    /// Every structural check — ids, loop closure, edge use counts, curve
59    /// endpoints against vertices, the Euler count — still reads the whole
60    /// solid. A boolean uses this for faces it carried through unchanged
61    /// from an operand (`BooleanOptions::untouched_passthrough`): their
62    /// geometry is the operand's, byte for byte, and re-reading it is the
63    /// cost the record named.
64    pub fn validate_scoped(&self, geometry_faces: &HashSet<u64>) -> Vec<ValidationIssue> {
65        self.validate_detailed_scoped(&KernelTolerances::for_solid(self, 1e-7), Some(geometry_faces))
66            .issues
67    }
68
69    /// [`validate_detailed`](Self::validate_detailed) with the geometry checks
70    /// restricted as [`validate_scoped`](Self::validate_scoped) describes;
71    /// `None` reads every face.
72    pub fn validate_detailed_scoped(
73        &self,
74        tolerances: &KernelTolerances,
75        geometry_faces: Option<&HashSet<u64>>,
76    ) -> ValidationReport {
77        let mut issues = Vec::new();
78        let mut wire_warnings = Vec::new();
79        let mut max_pcurve_error = 0.0f64;
80        // Model bounding-box diagonal: the LOCAL extent the edge/pcurve
81        // coincidence band size-couples to (see `pcurve_acceptance`). Raw, not
82        // `solid_scale` — that floors at 1.0, which would hand a genuinely tiny
83        // part a coincidence band 30x its own size; the tight absolute
84        // `pcurve_consistency` floor already guards the small end.
85        let mut bbox_lo = crate::Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
86        let mut bbox_hi = crate::Vec3::new(f64::NEG_INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY);
87        for vertex in &self.vertices {
88            bbox_lo.x = bbox_lo.x.min(vertex.point.x);
89            bbox_lo.y = bbox_lo.y.min(vertex.point.y);
90            bbox_lo.z = bbox_lo.z.min(vertex.point.z);
91            bbox_hi.x = bbox_hi.x.max(vertex.point.x);
92            bbox_hi.y = bbox_hi.y.max(vertex.point.y);
93            bbox_hi.z = bbox_hi.z.max(vertex.point.z);
94        }
95        let model_diagonal = if self.vertices.is_empty() {
96            0.0
97        } else {
98            bbox_hi.sub(bbox_lo).length()
99        };
100        let pcurve_limit = tolerances.pcurve_acceptance(model_diagonal);
101        // Vendor STEP files commit a vertex point and its incident edges' 3D-curve
102        // endpoints as INDEPENDENT approximations that disagree by a small, roughly
103        // ABSOLUTE amount (ABC 00000084/109/218 fail ONLY here: every gap clusters
104        // tightly at ~1.0-2.2e-5 regardless of the sub-unit part size; ABC 00010746
105        // has the same repeated pattern at ~3.2-3.9e-5 on 24 matching endpoints —
106        // a fixed export-precision offset, not a size-relative one). Like OCC/Parasolid
107        // absorbing the gap in a widened per-vertex tolerance, accept it: floor the
108        // identity band at a vendor-precision absolute and size-couple for large
109        // parts via `heal_band`. The old fixed `max(model*100, 1e-5)` band (model
110        // is pinned at 1e-7, so a flat 1e-5) rejected these shared vertices; a
111        // genuinely disconnected edge sits orders of magnitude above this floor, so
112        // real breakage is still refused. Clean parts already match far tighter, so
113        // this only admits the vendor near-miss.
114        // k = 2e-5: at metre numerics the .max(4e-5) ABSOLUTE floor was the
115        // effective acceptance (~1.4e-4 of a 0.28-extent part) and silently
116        // absorbed vendor edge-vs-vertex gaps; in mm the floor is inert and
117        // k=1e-5 narrowly rejected an authored 1.25e-5-of-diagonal gap
118        // (ABC 8575). 2e-5 keeps the check meaningful while accepting vendor
119        // imprecision the old floor accepted. Validation acceptance only —
120        // no merge radius derives from this.
121        // Floor = vendor EXPORT PRECISION in mm. The old 4e-5 floor encoded
122        // metre-era numerics (~4e-5 of a metre); the same physical export
123        // imprecision lands x1000 bigger now that imports convert to mm
124        // (ABC 8575: a 3.5e-6 m authored vertex/curve offset = 3.5e-3 mm).
125        // 5e-3 mm (= 5 um) accepts vendor precision for metre- and
126        // mm-authored files alike. Validation acceptance only — no merge
127        // radius derives from this.
128        let vertex_match = tolerances
129            .heal_band(model_diagonal, 2e-5)
130            .max(crate::VERTEX_MATCH_FLOOR);
131        let vertices: HashMap<u64, &VertexRecord> = self
132            .vertices
133            .iter()
134            .map(|vertex| (vertex.id, vertex))
135            .collect();
136        let edges: HashMap<u64, &EdgeRecord> =
137            self.edges.iter().map(|edge| (edge.id, edge)).collect();
138        if vertices.len() != self.vertices.len() {
139            issues.push(ValidationIssue::error("duplicate vertex id"));
140        }
141        if edges.len() != self.edges.len() {
142            issues.push(ValidationIssue::error("duplicate edge id"));
143        }
144
145        let mut edge_uses: HashMap<u64, Vec<bool>> = HashMap::default();
146        let mut face_ids = HashSet::default();
147        for shell in &self.shells {
148            for face in &shell.faces {
149                if !face_ids.insert(face.id) {
150                    issues.push(ValidationIssue::error(format!(
151                        "duplicate face id {}",
152                        face.id
153                    )));
154                }
155                let read_geometry =
156                    geometry_faces.is_none_or(|scope| scope.contains(&face.id));
157                let surface = if read_geometry {
158                    match NurbsSurface::new(
159                        face.surface.degree_u,
160                        face.surface.degree_v,
161                        face.surface.knots_u.clone(),
162                        face.surface.knots_v.clone(),
163                        face.surface.control_points.clone(),
164                    ) {
165                        Ok(surface) => Some(surface),
166                        Err(error) => {
167                            issues.push(ValidationIssue::error(format!(
168                                "face {} has invalid surface: {}",
169                                face.id, error
170                            )));
171                            continue;
172                        }
173                    }
174                } else {
175                    None
176                };
177                for (loop_index, loop_record) in face.loops.iter().enumerate() {
178                    if loop_record.coedges.is_empty() {
179                        issues.push(ValidationIssue::error(format!(
180                            "loop {} of face {} is empty",
181                            loop_record.id, face.id
182                        )));
183                        continue;
184                    }
185                    for coedge in &loop_record.coedges {
186                        edge_uses
187                            .entry(coedge.edge_id)
188                            .or_default()
189                            .push(coedge.forward);
190                    }
191                    for index in 0..loop_record.coedges.len() {
192                        let current = &loop_record.coedges[index];
193                        let next = &loop_record.coedges[(index + 1) % loop_record.coedges.len()];
194                        let Some(current_edge) = edges.get(&current.edge_id) else {
195                            issues.push(ValidationIssue::error(format!(
196                                "coedge {} references missing edge {}",
197                                current.id, current.edge_id
198                            )));
199                            continue;
200                        };
201                        let Some(next_edge) = edges.get(&next.edge_id) else {
202                            issues.push(ValidationIssue::error(format!(
203                                "coedge {} references missing edge {}",
204                                next.id, next.edge_id
205                            )));
206                            continue;
207                        };
208                        let current_end = if current.forward {
209                            current_edge.end_vertex_id
210                        } else {
211                            current_edge.start_vertex_id
212                        };
213                        let next_start = if next.forward {
214                            next_edge.start_vertex_id
215                        } else {
216                            next_edge.end_vertex_id
217                        };
218                        if current_end != next_start {
219                            issues.push(ValidationIssue::error(format!(
220                                "loop {} of face {} is open between coedges {} and {}",
221                                loop_record.id, face.id, current.id, next.id
222                            )));
223                        }
224                        let Some(surface) = &surface else {
225                            continue;
226                        };
227
228                        let pcurve = match NurbsCurve::new(
229                            current.pcurve.degree,
230                            current.pcurve.knots.clone(),
231                            current.pcurve.control_points.clone(),
232                        ) {
233                            Ok(curve) => curve,
234                            Err(error) => {
235                                issues.push(ValidationIssue::error(format!(
236                                    "coedge {} has invalid pcurve: {}",
237                                    current.id, error
238                                )));
239                                continue;
240                            }
241                        };
242                        let curve = match NurbsCurve::new(
243                            current_edge.curve.degree,
244                            current_edge.curve.knots.clone(),
245                            current_edge.curve.control_points.clone(),
246                        ) {
247                            Ok(curve) => curve,
248                            Err(error) => {
249                                issues.push(ValidationIssue::error(format!(
250                                    "edge {} has invalid curve: {}",
251                                    current_edge.id, error
252                                )));
253                                continue;
254                            }
255                        };
256                        match memoized_coedge_error(
257                            surface,
258                            &pcurve,
259                            &curve,
260                            current_edge,
261                            current.forward,
262                            pcurve_limit,
263                        ) {
264                            Ok(error) => {
265                                max_pcurve_error = max_pcurve_error.max(error);
266                                if error > pcurve_limit {
267                                    issues.push(ValidationIssue::error(format!(
268                                        "coedge {} of face {} pcurve is inconsistent with edge {} \
269                                         (max deviation {:.6}, limit {:.6})",
270                                        current.id, face.id, current_edge.id, error, pcurve_limit,
271                                    )));
272                                }
273                            }
274                            Err(error) => issues.push(ValidationIssue::error(format!(
275                                "coedge {} of face {} cannot be evaluated: {}",
276                                current.id, face.id, error
277                            ))),
278                        }
279                    }
280
281                    let Some(surface) = &surface else {
282                        continue;
283                    };
284                    if let Some(warning) = validate_uv_wire(
285                        surface,
286                        loop_record,
287                        &edges,
288                        tolerances,
289                        loop_index == 0,
290                        face.same_sense,
291                    ) {
292                        wire_warnings.push(ValidationIssue::warning(format!(
293                            "face {} loop {}: {}",
294                            face.id, loop_record.id, warning
295                        )));
296                    }
297                }
298            }
299        }
300
301        for edge in &self.edges {
302            if !(edge.t0.is_finite() && edge.t1.is_finite() && edge.t0 < edge.t1) {
303                issues.push(ValidationIssue::error(format!(
304                    "edge {} has invalid parameter range",
305                    edge.id
306                )));
307            }
308            let Some(start) = vertices.get(&edge.start_vertex_id) else {
309                issues.push(ValidationIssue::error(format!(
310                    "edge {} references missing start vertex {}",
311                    edge.id, edge.start_vertex_id
312                )));
313                continue;
314            };
315            let Some(end) = vertices.get(&edge.end_vertex_id) else {
316                issues.push(ValidationIssue::error(format!(
317                    "edge {} references missing end vertex {}",
318                    edge.id, edge.end_vertex_id
319                )));
320                continue;
321            };
322            if let Ok(curve) = NurbsCurve::new(
323                edge.curve.degree,
324                edge.curve.knots.clone(),
325                edge.curve.control_points.clone(),
326            ) {
327                if let Ok(point) = curve.evaluate(edge.t0) {
328                    let gap = point.sub(start.point).length();
329                    if gap > vertex_match {
330                        issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: true }, format!(
331                            "edge {} curve start does not match vertex {} \
332                             (gap={gap:.9}, curve={point:?}, vertex={:?})",
333                            edge.id, start.id, start.point
334                        )));
335                    }
336                }
337                if let Ok(point) = curve.evaluate(edge.t1) {
338                    let gap = point.sub(end.point).length();
339                    if gap > vertex_match {
340                        issues.push(ValidationIssue::error_kind(IssueKind::CurveVertexGap { at_start: false }, format!(
341                            "edge {} curve end does not match vertex {} \
342                             (gap={gap:.9}, curve={point:?}, vertex={:?})",
343                            edge.id, end.id, end.point
344                        )));
345                    }
346                }
347            }
348            let uses = edge_uses.get(&edge.id).map(Vec::as_slice).unwrap_or(&[]);
349            if edge.degenerate {
350                // A synthesized VERTEX_LOOP/pole boundary is single-use, but
351                // vendor STEP may share one explicit collapsed EDGE_CURVE
352                // between the two faces meeting at that pole. The latter is a
353                // valid manifold incidence exactly when the coedge senses are
354                // opposite, just like an ordinary shared edge.
355                let valid = uses.len() == 1 || (uses.len() == 2 && uses[0] != uses[1]);
356                if !valid {
357                    issues.push(ValidationIssue::error(format!(
358                        "degenerate edge {} has invalid incidence {:?} \
359                         (expected one use or an opposite-sense pair)",
360                        edge.id, uses,
361                    )));
362                }
363            // Non-manifold plan 1.2: only a DECLARED Sheet admits a lamina
364            // edge. Unreferenced edges and three-or-more uses still fail;
365            // shared edges keep the existing opposite-sense rule below.
366            } else if self.kind == BodyKind::Sheet && uses.len() == 1 {
367            } else if self.kind == BodyKind::Cellular {
368                // Region closure and radial senses are checked per declared
369                // cell below. A shared wall can give three global face uses.
370                if uses.len() < 2 {
371                    issues.push(ValidationIssue::error_kind(IssueKind::OpenEdge,
372                        format!("Cellular edge {} used {} times (expected at least 2)", edge.id, uses.len())));
373                }
374            } else if uses.len() != 2 {
375                let kind = if uses.len() < 2 { IssueKind::OpenEdge } else { IssueKind::OverUsedEdge };
376                issues.push(ValidationIssue::error_kind(kind, format!(
377                    "edge {} used {} times (expected 2)",
378                    edge.id,
379                    uses.len()
380                )));
381            } else if uses[0] == uses[1] {
382                issues.push(ValidationIssue::error(format!(
383                    "edge {} has coedges with the same sense",
384                    edge.id
385                )));
386            }
387        }
388
389        for edge_id in edge_uses.keys() {
390            if !edges.contains_key(edge_id) {
391                issues.push(ValidationIssue::error(format!(
392                    "topology references missing edge {}",
393                    edge_id
394                )));
395            }
396        }
397
398        // A pole edge is a collapsed parameter-space boundary. Its endpoint
399        // is not an independent 0-cell unless a non-degenerate edge also
400        // uses it, so use the reduced complex for Euler accounting.
401        let non_degenerate_vertex_ids = self
402            .edges
403            .iter()
404            .filter(|edge| !edge.degenerate)
405            .flat_map(|edge| [edge.start_vertex_id, edge.end_vertex_id])
406            .collect::<HashSet<_>>();
407        let vertex_count = vertices
408            .keys()
409            .filter(|id| non_degenerate_vertex_ids.contains(id))
410            .count() as i64;
411        let edge_count = self.edges.iter().filter(|edge| !edge.degenerate).count() as i64;
412        let face_count = face_ids.len() as i64;
413        let hole_count = self.bounding_hole_count();
414        let shell_count = self.shells.len() as i64;
415        let actual = vertex_count - edge_count + face_count - hole_count;
416        let expected = 2 * (shell_count - self.genus);
417        // What this comparison actually tests, and it is not the genus.
418        //
419        // On a freshly derived solid the producer set `genus = S - chi/2` in
420        // INTEGER division, so `expected = 2*(S - genus) = 2*trunc(chi/2)` and
421        // `actual == expected` holds EXACTLY when the characteristic is even.
422        // Measured over 1042 imported bodies, the rows that mismatch and the
423        // rows with an odd characteristic are the same rows, both ways. After a
424        // mutation that adjusts `genus` by hand -- `closed_heal`'s `genus -= 1`,
425        // `delete_faces`' `genus += shift / 2` -- it becomes a drift check on
426        // that adjustment. It is never an independent measurement of the genus:
427        // `genus = S - chi/2` IS the definition, so any recomputation from the
428        // same complex re-runs this arithmetic.
429        //
430        // The PRECONDITION, which this check did not used to state.
431        //
432        // `chi = 2(S - G)` is a fact about CLOSED orientable surfaces. On an
433        // open shell -- one still being assembled, or a sheet -- the
434        // characteristic is unconstrained (a disk is 1) and comparing it to
435        // anything is meaningless. Until 2026-09-11 the comparison ran on open
436        // shells too and was merely hidden, on some of them, by a skip for
437        // degenerate edges: three offset-shell cases turn out to reach it with
438        // an open shell and an EVEN characteristic, so the skip was covering
439        // for a missing precondition rather than for a pole-collapse problem.
440        //
441        // The degenerate-edge skip that used to stand here is GONE, and what it
442        // was hiding was not what its comment claimed.
443        //
444        // It read "the reduced V-E+F formula does not model parameter-space
445        // pole collapses reliably". Removing it reddened three offset-shell
446        // tests with an EVEN characteristic and a recorded genus that
447        // contradicted it -- so not a parity problem, and reproducible with the
448        // pre-2026-09-11 hole rule, so not a consequence of that work either.
449        // The cause was `finalize.rs` forcing `genus = 0` whenever a body's only
450        // single-use edges are degenerate, which is true of any body carrying an
451        // ordinary pole edge. A shelled solid with a spherical cavity wall was
452        // therefore recorded genus 0 while its own count said 3, and an
453        // independent watertight MESH count of the same shell confirmed the
454        // three handles (chi = -4 at three chord tolerances, closed, no boundary
455        // or non-manifold edges). The handles are real -- shelling on an ANNULAR
456        // opening joins outer and inner skins along two rim curves -- so only the
457        // recorded number was wrong. With `finalize` recording what it counts,
458        // this skip has nothing left to hide and the check runs on every solid.
459        //
460        // `closed` is the precondition the formula genuinely needs: chi =
461        // 2(S - G) is a fact about CLOSED orientable surfaces, and an open
462        // shell's characteristic is unconstrained (a disk is 1). It is stated so
463        // this check and `soundness::solid_euler` agree about the domain they
464        // apply to -- the disagreement between them, one having the precondition
465        // and the other not, is what made a green case gate look like coverage
466        // it was not.
467        let closed = self
468            .edges
469            .iter()
470            .filter(|edge| !edge.degenerate)
471            .all(|edge| edge_uses.get(&edge.id).map(Vec::len) == Some(2));
472        // Non-manifold plan 1.2: a Sheet does not declare a solid genus,
473        // including when its particular set of faces happens to be closed.
474        if !matches!(self.kind, BodyKind::Sheet | BodyKind::Cellular) && closed && actual != expected {
475            // OCC-style models carry COINCIDENT PARALLEL edges: two
476            // ref-distinct edges riding one geometric locus between the same
477            // vertices (a wall split exactly where an adjoining ring meets
478            // it). The complex is manifold — the four incident faces pair
479            // off along the shared locus — but the formula counts the locus
480            // twice. Re-check with each coincident pair credited once; the
481            // scan runs only on this failure path, so ordinary solids pay
482            // nothing for it.
483            let pairs = self.coincident_parallel_edge_pairs() as i64;
484            if actual + pairs != expected {
485                issues.push(ValidationIssue::error_kind(IssueKind::GenusMismatch, format!(
486                    "Euler formula: V-E+F-H = {}, expected {} ({} coincident parallel edge pair(s) credited)",
487                    actual + pairs,
488                    expected,
489                    pairs
490                )));
491            }
492        }
493        if self.kind == BodyKind::Cellular {
494            self.validate_cells(&edges, &mut issues);
495        }
496        ValidationReport {
497            issues,
498            wire_warnings,
499            max_pcurve_error,
500        }
501    }
502
503    // Plan 2.1: this is reached only for a DECLARED Cellular body. All
504    // geometry/identity checks above still run; no Solid/Sheet rule changes.
505    fn validate_cells(&self, edges: &HashMap<u64, &EdgeRecord>, issues: &mut Vec<ValidationIssue>) {
506        if self.cells.is_empty() {
507            issues.push(ValidationIssue::error("Cellular body has no declared cells"));
508            return;
509        }
510        let faces: HashMap<_, _> = self.shells.iter().flat_map(|s| &s.faces).map(|f| (f.id, f)).collect();
511        let mut cell_ids = HashSet::default();
512        let mut owners: HashMap<u64, Vec<bool>> = HashMap::default();
513        // A wall contributes one directed use for EACH incident region.
514        let mut fans: HashMap<u64, Vec<(&FaceRecord, bool, &CoedgeRecord)>> = HashMap::default();
515        for cell in &self.cells {
516            if !cell_ids.insert(cell.id) {
517                issues.push(ValidationIssue::error(format!("duplicate cell id {}", cell.id)));
518            }
519            if cell.faces.is_empty() {
520                issues.push(ValidationIssue::error(format!("cell {} has no faces", cell.id)));
521            }
522            let mut seen = HashSet::default();
523            let mut uses: HashMap<u64, Vec<bool>> = HashMap::default();
524            let mut holes = 0i64;
525            for &(id, forward) in &cell.faces {
526                if !seen.insert(id) {
527                    issues.push(ValidationIssue::error(format!("cell {} repeats face {id}", cell.id)));
528                    continue;
529                }
530                let Some(&face) = faces.get(&id) else {
531                    issues.push(ValidationIssue::error(format!("cell {} references missing face {id}", cell.id)));
532                    continue;
533                };
534                owners.entry(id).or_default().push(forward);
535                holes += face.loops.iter().filter(|record| !record.coedges.is_empty()
536                    && record.coedges.iter().any(|c| edges.get(&c.edge_id).is_some_and(|e| !e.degenerate)))
537                    .count().saturating_sub(1) as i64;
538                for coedge in face.loops.iter().flat_map(|l| &l.coedges) {
539                    uses.entry(coedge.edge_id).or_default().push(coedge.forward == forward);
540                    fans.entry(coedge.edge_id).or_default().push((face, forward, coedge));
541                }
542            }
543            let mut vertices = HashSet::default();
544            let mut edge_count = 0i64;
545            for (id, senses) in &uses {
546                let Some(&edge) = edges.get(id) else { continue; };
547                let opposite = senses.len() == 2 && senses[0] != senses[1];
548                if !(opposite || (edge.degenerate && senses.len() == 1)) {
549                    issues.push(ValidationIssue::error(format!(
550                        "cell {} edge {id} is not closed with opposite senses: {senses:?}", cell.id)));
551                }
552                if !edge.degenerate {
553                    edge_count += 1;
554                    vertices.extend([edge.start_vertex_id, edge.end_vertex_id]);
555                }
556            }
557            let chi = vertices.len() as i64 - edge_count + seen.len() as i64 - holes;
558            if chi % 2 != 0 {
559                issues.push(ValidationIssue::error_kind(IssueKind::GenusMismatch,
560                    format!("cell {} has odd Euler characteristic {chi}", cell.id)));
561            }
562        }
563        for id in faces.keys() {
564            let senses = owners.get(id).map(Vec::as_slice).unwrap_or(&[]);
565            if senses.is_empty() || senses.len() > 2 || (senses.len() == 2 && senses[0] == senses[1]) {
566                issues.push(ValidationIssue::error(format!(
567                    "face {id} has invalid cell ownership senses {senses:?}")));
568            }
569        }
570        for (id, uses) in fans {
571            let Some(&edge) = edges.get(&id) else { continue; };
572            if edge.degenerate || uses.len() <= 2 { continue; }
573            for fraction in [0.25, 0.5, 0.75] {
574                let reading = (|| -> Result<Vec<(f64, bool)>, String> {
575                    let t = edge.t0 + (edge.t1-edge.t0)*fraction;
576                    let derivatives = edge.curve.derivatives(t, 1)?;
577                    let tangent = derivatives[1].scale(if edge.t1 >= edge.t0 { 1.0 } else { -1.0 }).normalized()?;
578                    let axis = tangent.perpendicular()?;
579                    let other = tangent.cross(axis);
580                    let mut radial = Vec::new();
581                    for &(face, forward, coedge) in &uses {
582                        let uv = crate::project_point_to_surface(&face.surface, derivatives[0])?;
583                        let normal = face.surface.normal(uv.u, uv.v)?
584                            .scale(if face.same_sense { 1.0 } else { -1.0 });
585                        // Order face HALF-planes about the edge: coplanar
586                        // faces on opposite sides have equal normals but
587                        // opposite inward rays. A shared wall contributes
588                        // the same ray twice, with opposite cell senses.
589                        let ray = normal.cross(tangent.scale(if coedge.forward { 1.0 } else { -1.0 }));
590                        let (x,y) = (ray.dot(axis), ray.dot(other));
591                        if !x.is_finite() || !y.is_finite() || x*x+y*y < 1e-20 {
592                            return Err("undefined radial face tangent".into());
593                        }
594                        radial.push((y.atan2(x), coedge.forward == forward));
595                    }
596                    // At a shared-wall ray, close the preceding region
597                    // before opening the following one (true then false).
598                    radial.sort_by(|a,b| a.0.total_cmp(&b.0).then_with(|| b.1.cmp(&a.1)));
599                    Ok(radial)
600                })();
601                match reading {
602                    Ok(radial) if radial.iter().enumerate().all(|(i, a)| a.1 != radial[(i+1)%radial.len()].1) => {},
603                    Ok(_) => { issues.push(ValidationIssue::error(format!(
604                        "Cellular edge {id} radial cell senses do not alternate at {fraction}"))); break; },
605                    Err(error) => { issues.push(ValidationIssue::error(format!(
606                        "Cellular edge {id} radial cell fan cannot be read: {error}"))); break; },
607                }
608            }
609        }
610    }
611
612    /// `H` in the reduced-complex Euler count `V - E + F - H`: per face, the
613    /// loops that actually bound area, less its outer one.
614    ///
615    /// A boundary collapsed to a single point — STEP's `VERTEX_LOOP`, which
616    /// this kernel models as a single-use degenerate edge carrying a pcurve —
617    /// encloses no area, so it is not a hole. The reduced complex already
618    /// leaves that edge out of `E` and its vertex out of `V`; leaving its loop
619    /// out of `H` is the same rule applied to the same collapsed cell.
620    ///
621    /// Counting it as a hole subtracts a spurious 1 per puncture. An ODD number
622    /// of punctures then makes the characteristic odd, which no closed
623    /// orientable surface has; an even number keeps it even and quietly inflates
624    /// the derived genus by half of them.
625    pub(crate) fn bounding_hole_count(&self) -> i64 {
626        let degenerate: HashSet<u64> = self
627            .edges
628            .iter()
629            .filter(|edge| edge.degenerate)
630            .map(|edge| edge.id)
631            .collect();
632        self.shells
633            .iter()
634            .flat_map(|shell| &shell.faces)
635            .map(|face| {
636                face.loops
637                    .iter()
638                    .filter(|record| {
639                        !record.coedges.is_empty()
640                            && !record
641                                .coedges
642                                .iter()
643                                .all(|coedge| degenerate.contains(&coedge.edge_id))
644                    })
645                    .count()
646                    .saturating_sub(1) as i64
647            })
648            .sum()
649    }
650
651    /// Count disjoint pairs of non-degenerate edges that ride ONE geometric
652    /// locus between the same endpoint vertices (OCC wall-split rims). Each
653    /// such pair contributes a single locus to the cell complex, so Euler
654    /// accounting credits it once.
655    pub(crate) fn coincident_parallel_edge_pairs(&self) -> usize {
656        let candidates: Vec<&EdgeRecord> =
657            self.edges.iter().filter(|edge| !edge.degenerate).collect();
658        let mut consumed = vec![false; candidates.len()];
659        let mut pairs = 0usize;
660        for first_index in 0..candidates.len() {
661            if consumed[first_index] {
662                continue;
663            }
664            let first = candidates[first_index];
665            for second_index in first_index + 1..candidates.len() {
666                if consumed[second_index] {
667                    continue;
668                }
669                let second = candidates[second_index];
670                let endpoints_match = (first.start_vertex_id == second.start_vertex_id
671                    && first.end_vertex_id == second.end_vertex_id)
672                    || (first.start_vertex_id == second.end_vertex_id
673                        && first.end_vertex_id == second.start_vertex_id);
674                if !endpoints_match {
675                    continue;
676                }
677                // Locus agreement at interior samples, direction-agnostic.
678                let coincident = (1..4).all(|sample| {
679                    let fraction = sample as f64 / 4.0;
680                    let Ok(on_first) = first
681                        .curve
682                        .evaluate(first.t0 + (first.t1 - first.t0) * fraction)
683                    else {
684                        return false;
685                    };
686                    let forward = second.t0 + (second.t1 - second.t0) * fraction;
687                    let backward = second.t1 - (second.t1 - second.t0) * fraction;
688                    let tolerance = 1e-6 * (1.0 + on_first.length());
689                    let matches_forward = second
690                        .curve
691                        .evaluate(forward)
692                        .map(|point| point.sub(on_first).length() <= tolerance)
693                        .unwrap_or(false);
694                    let matches_backward = second
695                        .curve
696                        .evaluate(backward)
697                        .map(|point| point.sub(on_first).length() <= tolerance)
698                        .unwrap_or(false);
699                    matches_forward || matches_backward
700                });
701                if coincident {
702                    consumed[first_index] = true;
703                    consumed[second_index] = true;
704                    pairs += 1;
705                    break;
706                }
707            }
708        }
709        pairs
710    }
711}
712
713fn coedge_sample(
714    surface: &NurbsSurface,
715    pcurve: &NurbsCurve,
716    curve: &NurbsCurve,
717    edge: &EdgeRecord,
718    forward: bool,
719    fraction: f64,
720) -> Result<(f64, Vec3, Vec3), String> {
721    let [q0, q1] = pcurve.domain()?;
722    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
723    // A pcurve that straddles a periodic seam carries parameters just past the
724    // domain; the surface WRAPS there (evaluate_extended), so a coedge riding
725    // the seam still reproduces its edge exactly instead of reading as a gross
726    // deviation against the clamped boundary.
727    let on_surface = surface.evaluate_extended(uv.x, uv.y)?;
728    let t = if forward {
729        edge.t0 + (edge.t1 - edge.t0) * fraction
730    } else {
731        edge.t1 - (edge.t1 - edge.t0) * fraction
732    };
733    let on_curve = curve.evaluate(t)?;
734    Ok((on_surface.sub(on_curve).length(), on_surface, on_curve))
735}
736
737/// Start with 32 intervals, then subdivide where either represented curve
738/// bends appreciably or the deviation function is non-linear.  This catches
739/// interior NURBS drift that four fixed samples cannot see without imposing
740/// the cost of a uniformly extreme sampling count on every coedge.
741pub(crate) fn adaptive_coedge_error(
742    surface: &NurbsSurface,
743    pcurve: &NurbsCurve,
744    curve: &NurbsCurve,
745    edge: &EdgeRecord,
746    forward: bool,
747    tolerance: f64,
748) -> Result<f64, String> {
749    fn interval(
750        surface: &NurbsSurface,
751        pcurve: &NurbsCurve,
752        curve: &NurbsCurve,
753        edge: &EdgeRecord,
754        forward: bool,
755        a: f64,
756        b: f64,
757        sample_a: (f64, Vec3, Vec3),
758        sample_b: (f64, Vec3, Vec3),
759        tolerance: f64,
760        depth: usize,
761    ) -> Result<f64, String> {
762        let mid = (a + b) * 0.5;
763        let sample_mid = coedge_sample(surface, pcurve, curve, edge, forward, mid)?;
764        let error_nonlinearity = (sample_mid.0 - (sample_a.0 + sample_b.0) * 0.5).abs();
765        let surface_bend = sample_mid
766            .1
767            .sub(sample_a.1.add(sample_b.1).scale(0.5))
768            .length();
769        let curve_bend = sample_mid
770            .2
771            .sub(sample_a.2.add(sample_b.2).scale(0.5))
772            .length();
773        let local_max = sample_a.0.max(sample_mid.0).max(sample_b.0);
774        if depth >= 3
775            || (error_nonlinearity <= tolerance * 0.05
776                && surface_bend.max(curve_bend) <= tolerance * 0.25)
777        {
778            return Ok(local_max);
779        }
780        Ok(interval(
781            surface,
782            pcurve,
783            curve,
784            edge,
785            forward,
786            a,
787            mid,
788            sample_a,
789            sample_mid,
790            tolerance,
791            depth + 1,
792        )?
793        .max(interval(
794            surface,
795            pcurve,
796            curve,
797            edge,
798            forward,
799            mid,
800            b,
801            sample_mid,
802            sample_b,
803            tolerance,
804            depth + 1,
805        )?))
806    }
807
808    let mut maximum = 0.0f64;
809    let mut previous = coedge_sample(surface, pcurve, curve, edge, forward, 0.0)?;
810    maximum = maximum.max(previous.0);
811    for index in 1..=32 {
812        let a = (index - 1) as f64 / 32.0;
813        let b = index as f64 / 32.0;
814        let next = coedge_sample(surface, pcurve, curve, edge, forward, b)?;
815        maximum = maximum.max(interval(
816            surface, pcurve, curve, edge, forward, a, b, previous, next, tolerance, 0,
817        )?);
818        previous = next;
819    }
820    // This pass refines only while the band it is given says to, so on a part
821    // whose band is slack it stops at its 33 uniform stations and a pcurve
822    // exact at its own nodes and wrong between them reads as exact (measured
823    // 2026-09-18 over 54556 imported pcurves: more than 2x low on 2204). The
824    // band-free floor is [`coedge_error_floored`]; `validate` reads through
825    // it, the boolean's repair decisions do not yet.
826    Ok(maximum)
827}
828
829/// [`adaptive_coedge_error`] with the band-free floor: the midpoint of every
830/// knot span of the pcurve and of the edge curve, read whatever the band. This
831/// is what `validate` and the offset family's measured tolerance read. The
832/// boolean's repair decisions (`csg/boolean/assemble/finalize.rs`,
833/// `refusal_welds.rs`) and the healing coalesce keep the band-steered reading
834/// above: read through the floor, the zero-clearance ball-and-stick joint
835/// gains an edge and a vertex at its crotch ((5, 9, 6) against (5, 8, 5),
836/// `tests/suites/tangent_node_assembly.rs`, 2026-09-26) — a consumer that
837/// depends on its instrument's coarseness, named for its own slice.
838thread_local! {
839    static COEDGE_MEMO: std::cell::RefCell<Option<HashMap<Vec<u64>, Result<f64, String>>>> =
840        const { std::cell::RefCell::new(None) };
841    /// Reads served from the memo and reads computed, in the open scope.
842    static COEDGE_MEMO_COUNTS: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) };
843}
844
845/// `BREP_VALIDATE_COEDGE_MEMO` (lane X): while a [`CoedgeMemoScope`] is open,
846/// `validate` reads each coedge's geometry check once per distinct input.
847/// [`coedge_error_floored`] is a pure function of the carrier, the pcurve, the
848/// edge curve, the edge's `[t0, t1]`, the sense and the band (validate builds
849/// all three NURBS fresh, so no recognition cache reaches it), and the key is
850/// every one of those inputs bit for bit, compared in full on a hit: a repeat
851/// returns exactly what re-reading would. A boolean validates its result three
852/// times (assembly, face merge, the closing check) and most coedges arrive at
853/// the later two unchanged. On by default; `0`/`off` disables it.
854pub(crate) fn coedge_memo_enabled() -> bool {
855    let enabled = !matches!(std::env::var("BREP_VALIDATE_COEDGE_MEMO").as_deref(), Ok("0") | Ok("off"));
856    enabled
857}
858
859
860/// The memo's lifetime: one operation. Nested scopes share the outer one.
861pub(crate) struct CoedgeMemoScope {
862    opened: bool,
863}
864
865impl CoedgeMemoScope {
866    pub(crate) fn open(enabled: bool) -> Self {
867        let opened = enabled
868            && COEDGE_MEMO.with(|memo| {
869                let mut memo = memo.borrow_mut();
870                if memo.is_some() {
871                    return false;
872                }
873                *memo = Some(HashMap::default());
874                COEDGE_MEMO_COUNTS.with(|counts| counts.set((0, 0)));
875                true
876            });
877        CoedgeMemoScope { opened }
878    }
879}
880
881impl Drop for CoedgeMemoScope {
882    fn drop(&mut self) {
883        if self.opened {
884            COEDGE_MEMO.with(|memo| *memo.borrow_mut() = None);
885            let (hits, misses) = COEDGE_MEMO_COUNTS.with(|counts| counts.get());
886            if std::env::var_os("BREP_PROFILE").is_some() {
887                eprintln!("validate.coedge_memo hits={hits} misses={misses}");
888            }
889        }
890    }
891}
892
893fn coedge_memo_key(
894    surface: &NurbsSurface,
895    pcurve: &NurbsCurve,
896    curve: &NurbsCurve,
897    edge: &EdgeRecord,
898    forward: bool,
899    tolerance: f64,
900) -> Vec<u64> {
901    let mut key = Vec::with_capacity(
902        16 + surface.knots_u.len() + surface.knots_v.len() + 4 * surface.control_points.iter().map(Vec::len).sum::<usize>()
903            + pcurve.knots.len() + 4 * pcurve.control_points.len() + curve.knots.len() + 4 * curve.control_points.len(),
904    );
905    let push_point = |key: &mut Vec<u64>, point: &crate::Vec4| key.extend([point.x.to_bits(), point.y.to_bits(), point.z.to_bits(), point.w.to_bits()]);
906    key.extend([edge.t0.to_bits(), edge.t1.to_bits(), forward as u64, tolerance.to_bits()]);
907    key.extend([surface.degree_u as u64, surface.degree_v as u64, surface.knots_u.len() as u64, surface.knots_v.len() as u64, surface.control_points.len() as u64]);
908    key.extend(surface.knots_u.iter().chain(&surface.knots_v).map(|knot| knot.to_bits()));
909    for row in &surface.control_points {
910        key.push(row.len() as u64);
911        for point in row {
912            push_point(&mut key, point);
913        }
914    }
915    for nurbs in [pcurve, curve] {
916        key.extend([nurbs.degree as u64, nurbs.knots.len() as u64, nurbs.control_points.len() as u64]);
917        key.extend(nurbs.knots.iter().map(|knot| knot.to_bits()));
918        for point in &nurbs.control_points {
919            push_point(&mut key, point);
920        }
921    }
922    key
923}
924
925/// `BREP_VALIDATE_EXACT_LINES` (lane X): `validate` reads a line coedge on a
926/// plane exactly instead of sampling it. On by default; `0`/`off` disables it.
927/// Only `validate`'s own per-coedge read takes it: every other caller of
928/// [`coedge_error_floored`] / [`adaptive_coedge_error`] keeps the sampler.
929pub(crate) fn exact_lines_enabled() -> bool {
930    let enabled = !matches!(std::env::var("BREP_VALIDATE_EXACT_LINES").as_deref(), Ok("0") | Ok("off"));
931    enabled
932}
933
934
935/// The coedge error of a line edge on a plane, exactly, when the coedge's 3-D
936/// curve and its pcurve image are one line BY CONSTRUCTION; `None` sends the
937/// coedge to the sampler. The admitted class: an edge curve of degree 1 with
938/// two control points of equal weight (linear in `t`); a pcurve of degree 1
939/// with two control points of equal weight (linear in its parameter) whose
940/// ends lie in the carrier's domain; a carrier of degree 1×1 on a 2×2 net of
941/// equal weights whose parallelogram defect is exactly zero (an affine map,
942/// no bilinear term). The sampler compares `S(q(q0 + f·Δq))` with
943/// `c(t0 + f·Δt)` at fractions `f` of `[0, 1]`; for this class both are affine
944/// in `f`, so their difference is too and its largest norm on `[0, 1]` is at
945/// an end: the answer is the larger endpoint gap, read by the sampler's own
946/// `coedge_sample`. That is the true error; the sampler's reading adds only
947/// interior rounding (a box face's ~4e-15 becomes the endpoints' gap, 0 there).
948pub(crate) fn exact_line_on_plane_error(
949    surface: &NurbsSurface,
950    pcurve: &NurbsCurve,
951    curve: &NurbsCurve,
952    edge: &EdgeRecord,
953    forward: bool,
954) -> Option<f64> {
955    let linear = |nurbs: &NurbsCurve| {
956        nurbs.degree == 1
957            && nurbs.control_points.len() == 2
958            && nurbs.knots.len() == 4
959            && nurbs.control_points[0].w == nurbs.control_points[1].w
960            && nurbs.control_points[0].w > 0.0
961    };
962    if !linear(pcurve) || !linear(curve) {
963        return None;
964    }
965    if surface.degree_u != 1
966        || surface.degree_v != 1
967        || surface.knots_u.len() != 4
968        || surface.knots_v.len() != 4
969        || surface.control_points.len() != 2
970        || surface.control_points.iter().any(|row| row.len() != 2)
971    {
972        return None;
973    }
974    let weight = surface.control_points[0][0].w;
975    if !(weight > 0.0) || surface.control_points.iter().flatten().any(|control| control.w != weight) {
976        return None;
977    }
978    let corner = |i: usize, j: usize| surface.control_points[i][j].point().ok();
979    let (p00, p01, p10, p11) = (corner(0, 0)?, corner(0, 1)?, corner(1, 0)?, corner(1, 1)?);
980    let defect = p11.sub(p10).sub(p01.sub(p00));
981    if defect.x != 0.0 || defect.y != 0.0 || defect.z != 0.0 {
982        return None;
983    }
984    let ([u0, u1], [v0, v1]) = (surface.domain_u().ok()?, surface.domain_v().ok()?);
985    let [q0, q1] = pcurve.domain().ok()?;
986    for q in [q0, q1] {
987        let uv = pcurve.evaluate(q).ok()?;
988        if !(uv.x >= u0 && uv.x <= u1 && uv.y >= v0 && uv.y <= v1) {
989            return None;
990        }
991    }
992    let [c0, c1] = curve.domain().ok()?;
993    let (low, high) = (edge.t0.min(edge.t1), edge.t0.max(edge.t1));
994    if !(low >= c0 && high <= c1) {
995        return None;
996    }
997    let start = coedge_sample(surface, pcurve, curve, edge, forward, 0.0).ok()?.0;
998    let end = coedge_sample(surface, pcurve, curve, edge, forward, 1.0).ok()?.0;
999    Some(start.max(end))
1000}
1001
1002/// Lane R1 (granted 2026-10-18): the closed-form coedge reading of an
1003/// iso-parameter line. On by default (promoted 2026-10-18 on the 98 + 144
1004/// census); `BREP_VALIDATE_EXACT_ISO=0` (or `off`) sends it to the sampler.
1005pub(crate) fn exact_iso_enabled() -> bool {
1006    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1007    *ON.get_or_init(|| !matches!(std::env::var("BREP_VALIDATE_EXACT_ISO").as_deref(), Ok("0") | Ok("off")))
1008}
1009
1010
1011/// The coedge error of an ISO-PARAMETER line, bounded in closed form; `None`
1012/// sends the coedge to the sampler. The admitted class: a pcurve of degree 1
1013/// with two control points of equal weight (linear in its parameter) along
1014/// one iso line of the carrier (`v = c` or `u = c`, the two ends' coordinate
1015/// equal bit for bit) inside the carrier's domain; an edge curve equal to the
1016/// carrier's own iso curve there (`iso_curve_v(c)` / `iso_curve_u(c)`
1017/// rebuilt: degree, knots and weights bit for bit, control points within
1018/// `1e-12` of the net's size) whose parameter range IS the pcurve's range of
1019/// the free coordinate, in the coedge's sense. Then the sampler's two points
1020/// at every fraction are the carrier and the edge curve at one parameter:
1021/// with the same basis and weights their difference is a convex combination
1022/// of the control points' differences, so the error is at most the largest
1023/// of those, and at least the ends' gaps — the answer is the larger of the
1024/// two (`coedge_sample` reads the ends). That bounds the true error; the
1025/// sampler's own reading adds only the interior rounding of evaluating one
1026/// point two ways (2e-15 on a rim fillet's rail), as for
1027/// [`exact_line_on_plane_error`].
1028pub(crate) fn exact_iso_line_error(
1029    surface: &NurbsSurface,
1030    pcurve: &NurbsCurve,
1031    curve: &NurbsCurve,
1032    edge: &EdgeRecord,
1033    forward: bool,
1034) -> Option<f64> {
1035    if pcurve.degree != 1
1036        || pcurve.control_points.len() != 2
1037        || pcurve.knots.len() != 4
1038        || pcurve.control_points[0].w != pcurve.control_points[1].w
1039        || !(pcurve.control_points[0].w > 0.0)
1040    {
1041        return None;
1042    }
1043    let [q0, q1] = pcurve.domain().ok()?;
1044    let (a, b) = (pcurve.evaluate(q0).ok()?, pcurve.evaluate(q1).ok()?);
1045    let ([u0, u1], [v0, v1]) = (surface.domain_u().ok()?, surface.domain_v().ok()?);
1046    for uv in [a, b] {
1047        if !(uv.x >= u0 && uv.x <= u1 && uv.y >= v0 && uv.y <= v1) {
1048            return None;
1049        }
1050    }
1051    // Which coordinate is fixed, and the free one's range along the pcurve.
1052    let (iso, from, to) = if a.y.to_bits() == b.y.to_bits() && a.x != b.x {
1053        (surface.iso_curve_v(a.y).ok()?, a.x, b.x)
1054    } else if a.x.to_bits() == b.x.to_bits() && a.y != b.y {
1055        (surface.iso_curve_u(a.x).ok()?, a.y, b.y)
1056    } else {
1057        return None;
1058    };
1059    // The edge's parameter at fraction f must be the free coordinate there.
1060    let (start, end) = if forward { (edge.t0, edge.t1) } else { (edge.t1, edge.t0) };
1061    if start.to_bits() != from.to_bits() || end.to_bits() != to.to_bits() {
1062        return None;
1063    }
1064    if iso.degree != curve.degree
1065        || iso.knots.len() != curve.knots.len()
1066        || iso.knots.iter().zip(&curve.knots).any(|(x, y)| x.to_bits() != y.to_bits())
1067        || iso.control_points.len() != curve.control_points.len()
1068    {
1069        return None;
1070    }
1071    let size = iso
1072        .control_points
1073        .iter()
1074        .filter_map(|c| c.point().ok())
1075        .fold(0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()).max(q.z.abs()));
1076    let mut deviation = 0f64;
1077    for (p, q) in iso.control_points.iter().zip(&curve.control_points) {
1078        if p.w.to_bits() != q.w.to_bits() || !(p.w > 0.0) {
1079            return None;
1080        }
1081        deviation = deviation.max(p.point().ok()?.sub(q.point().ok()?).length());
1082    }
1083    if !(deviation <= 1e-12 * size.max(1.0)) {
1084        return None;
1085    }
1086    let start_gap = coedge_sample(surface, pcurve, curve, edge, forward, 0.0).ok()?.0;
1087    let end_gap = coedge_sample(surface, pcurve, curve, edge, forward, 1.0).ok()?.0;
1088    Some(start_gap.max(end_gap).max(deviation))
1089}
1090
1091/// [`coedge_error_floored`] through the operation's memo when one is open.
1092fn memoized_coedge_error(
1093    surface: &NurbsSurface,
1094    pcurve: &NurbsCurve,
1095    curve: &NurbsCurve,
1096    edge: &EdgeRecord,
1097    forward: bool,
1098    tolerance: f64,
1099) -> Result<f64, String> {
1100    if exact_lines_enabled() {
1101        if let Some(error) = exact_line_on_plane_error(surface, pcurve, curve, edge, forward) {
1102            return Ok(error);
1103        }
1104    }
1105    if exact_iso_enabled() {
1106        if let Some(error) = exact_iso_line_error(surface, pcurve, curve, edge, forward) {
1107            crate::blend::record_count("count.validate.exact_iso", 1);
1108            return Ok(error);
1109        }
1110    }
1111    if COEDGE_MEMO.with(|memo| memo.borrow().is_none()) {
1112        return coedge_error_floored(surface, pcurve, curve, edge, forward, tolerance);
1113    }
1114    let key = coedge_memo_key(surface, pcurve, curve, edge, forward, tolerance);
1115    if let Some(hit) = COEDGE_MEMO.with(|memo| memo.borrow().as_ref().and_then(|map| map.get(&key).cloned())) {
1116        COEDGE_MEMO_COUNTS.with(|counts| counts.set((counts.get().0 + 1, counts.get().1)));
1117        return hit;
1118    }
1119    COEDGE_MEMO_COUNTS.with(|counts| counts.set((counts.get().0, counts.get().1 + 1)));
1120    let read = coedge_error_floored(surface, pcurve, curve, edge, forward, tolerance);
1121    COEDGE_MEMO.with(|memo| {
1122        if let Some(map) = memo.borrow_mut().as_mut() {
1123            map.insert(key, read.clone());
1124        }
1125    });
1126    read
1127}
1128
1129pub(crate) fn coedge_error_floored(
1130    surface: &NurbsSurface,
1131    pcurve: &NurbsCurve,
1132    curve: &NurbsCurve,
1133    edge: &EdgeRecord,
1134    forward: bool,
1135    tolerance: f64,
1136) -> Result<f64, String> {
1137    let steered = adaptive_coedge_error(surface, pcurve, curve, edge, forward, tolerance)?;
1138    Ok(steered.max(knot_span_midpoint_error(surface, pcurve, curve, edge, forward)?))
1139}
1140
1141/// `BREP_DEBUG_COEDGE_FLOOR`: trace every knot-span midpoint the floor reads
1142/// over 1e-3, once per process (the floor runs per span of every coedge of
1143/// every `validate()`, so the environment is not read per sample).
1144fn coedge_floor_tracing() -> bool {
1145    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1146    *ON.get_or_init(|| std::env::var_os("BREP_DEBUG_COEDGE_FLOOR").is_some())
1147}
1148
1149/// `‖S(q(s)) − c(t(s))‖` at the midpoint of every knot span of `pcurve` and of
1150/// every knot span of `curve` inside the edge's `[t0, t1]`, both mapped to the
1151/// coedge fraction `adaptive_coedge_error` samples on — no tolerance steers
1152/// where it looks. The pcurve half is the one no other pass takes: a fitted
1153/// pcurve interpolates at its nodes and misses between them, and its nodes
1154/// are its own knots, not the edge curve's.
1155fn knot_span_midpoint_error(
1156    surface: &NurbsSurface,
1157    pcurve: &NurbsCurve,
1158    curve: &NurbsCurve,
1159    edge: &EdgeRecord,
1160    forward: bool,
1161) -> Result<f64, String> {
1162    let mut worst = 0.0_f64;
1163    let [q0, q1] = pcurve.domain()?;
1164    if q1 > q0 {
1165        let mut previous = q0;
1166        for &knot in pcurve.knots.iter().chain(std::iter::once(&q1)) {
1167            if knot > previous && knot <= q1 {
1168                let fraction = ((previous + knot) * 0.5 - q0) / (q1 - q0);
1169                let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
1170                if coedge_floor_tracing() && sample.0 > 1e-3 {
1171                    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
1172                    let ([u0, u1], [v0, v1]) = (surface.domain_u()?, surface.domain_v()?);
1173                    eprintln!(
1174                        "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 {}",
1175                        edge.id, sample.0, uv.x, uv.y, surface.closed_directions()?, surface.analytic().map(|_| "yes"), pcurve.degree, pcurve.knots.len(), pcurve.control_points.len()
1176                    );
1177                }
1178                worst = worst.max(sample.0);
1179                previous = knot;
1180            }
1181        }
1182    }
1183    let span = edge.t1 - edge.t0;
1184    if span.is_finite() && span != 0.0 {
1185        let low = edge.t0.min(edge.t1);
1186        let high = edge.t0.max(edge.t1);
1187        let mut previous = low;
1188        for &knot in curve.knots.iter().chain(std::iter::once(&high)) {
1189            if knot > previous && knot <= high {
1190                let midpoint = (previous + knot) * 0.5;
1191                let fraction = if forward {
1192                    (midpoint - edge.t0) / span
1193                } else {
1194                    (edge.t1 - midpoint) / span
1195                };
1196                let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
1197                if coedge_floor_tracing() && sample.0 > 1e-3 {
1198                    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
1199                    eprintln!(
1200                        "COEDGE-FLOOR edge {} edge-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6})",
1201                        edge.id, sample.0, uv.x, uv.y
1202                    );
1203                }
1204                worst = worst.max(sample.0);
1205                previous = knot;
1206            }
1207        }
1208    }
1209    Ok(worst)
1210}
1211
1212fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
1213    let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
1214    let ab = b.sub(a);
1215    let cd = d.sub(c);
1216    let denominator = cross(ab, cd);
1217    if denominator.abs() <= tolerance {
1218        return false;
1219    }
1220    let ac = c.sub(a);
1221    let t = cross(ac, cd) / denominator;
1222    let u = cross(ac, ab) / denominator;
1223    t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
1224}
1225
1226fn validate_uv_wire(
1227    surface: &NurbsSurface,
1228    loop_record: &LoopRecord,
1229    edges: &HashMap<u64, &EdgeRecord>,
1230    tolerances: &KernelTolerances,
1231    outer: bool,
1232    same_sense: bool,
1233) -> Option<String> {
1234    let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
1235        .ok()?
1236        .domain();
1237    let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
1238        .ok()?
1239        .domain();
1240    let u_span = u_domain[1] - u_domain[0];
1241    let v_span = v_domain[1] - v_domain[0];
1242    let mut points = Vec::<Vec2>::new();
1243    for coedge in &loop_record.coedges {
1244        let edge = edges.get(&coedge.edge_id)?;
1245        if edge.degenerate {
1246            continue;
1247        }
1248        let [q0, q1] = coedge.pcurve.domain().ok()?;
1249        for index in 0..=8 {
1250            if !points.is_empty() && index == 0 {
1251                continue;
1252            }
1253            let fraction = index as f64 / 8.0;
1254            let parameter = q0 + (q1 - q0) * fraction;
1255            let value = coedge.pcurve.evaluate(parameter).ok()?;
1256            let mut point = Vec2 {
1257                x: value.x,
1258                y: value.y,
1259            };
1260            if let Some(previous) = points.last() {
1261                while point.x - previous.x > u_span * 0.5 {
1262                    point.x -= u_span;
1263                }
1264                while point.x - previous.x < -u_span * 0.5 {
1265                    point.x += u_span;
1266                }
1267                while point.y - previous.y > v_span * 0.5 {
1268                    point.y -= v_span;
1269                }
1270                while point.y - previous.y < -v_span * 0.5 {
1271                    point.y += v_span;
1272                }
1273            }
1274            points.push(point);
1275        }
1276    }
1277    if points.len() < 4 {
1278        return None;
1279    }
1280    let first = points[0];
1281    let last = *points.last()?;
1282    let uv_tolerance = tolerances.model.max(1e-8);
1283    if last.sub(first).length() > uv_tolerance * 100.0 {
1284        // Parameter seams may differ by a complete period.  Compare modulo
1285        // both domains before reporting a genuine open wire.
1286        let du = (last.x - first.x) / u_span;
1287        let dv = (last.y - first.y) / v_span;
1288        if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
1289            || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
1290        {
1291            return Some(format!(
1292                "wire is open in parameter space (gap {:.3e})",
1293                last.sub(first).length()
1294            ));
1295        }
1296    }
1297    for first_index in 0..points.len() - 1 {
1298        for second_index in first_index + 2..points.len() - 1 {
1299            if first_index == 0 && second_index + 1 == points.len() - 1 {
1300                continue;
1301            }
1302            if segments_cross(
1303                points[first_index],
1304                points[first_index + 1],
1305                points[second_index],
1306                points[second_index + 1],
1307                1e-10,
1308            ) {
1309                return Some(format!(
1310                    "wire self-intersects near sampled segments {first_index} and {second_index}"
1311                ));
1312            }
1313        }
1314    }
1315    let area = points
1316        .windows(2)
1317        .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
1318        .sum::<f64>()
1319        * 0.5;
1320    if area.abs() > uv_tolerance * uv_tolerance {
1321        let expected_positive = if outer { same_sense } else { !same_sense };
1322        if (area > 0.0) != expected_positive {
1323            return Some(format!(
1324                "{} wire winding disagrees with face sense (signed UV area {:.6})",
1325                if outer { "outer" } else { "inner" },
1326                area
1327            ));
1328        }
1329    }
1330    None
1331}
1332
1333/// Bound on the clean-verdict memo; the set is emptied when it would pass it.
1334const VALIDATED_CLEAN_CAP: usize = 4096;
1335
1336thread_local! {
1337    static VALIDATED_CLEAN: std::cell::RefCell<rustc_hash::FxHashSet<(u64, u64)>> =
1338        std::cell::RefCell::new(rustc_hash::FxHashSet::default());
1339}
1340
1341impl BrepSolid {
1342    /// Empty the clean-verdict memo of [`BrepSolid::validate_memoised`]:
1343    /// called at a fillet operation's entry, so each operation pays for the
1344    /// first read of every body it validates.
1345    pub(crate) fn clear_validate_memo() {
1346        VALIDATED_CLEAN.with(|clean| clean.borrow_mut().clear());
1347    }
1348
1349    /// The body's memo key: the 128-bit two-lane content hash
1350    /// (`mass_properties::TwoLane`, bit-exact on every float) over EVERY
1351    /// field the body serialises — ids, names, kind, genus, vertices, edges,
1352    /// shells, faces, loops, coedges, cells — so any mutation is a new key.
1353    /// (A JSON serialisation of the body read the same content at ~10× the
1354    /// cost: float formatting.)
1355    pub(crate) fn validate_memo_key(&self) -> Option<(u64, u64)> {
1356        use crate::mass_properties::TwoLane;
1357        fn curve(lane: &mut TwoLane, curve: &crate::NurbsCurve) {
1358            lane.usize(curve.degree);
1359            lane.floats(&curve.knots);
1360            lane.usize(curve.control_points.len());
1361            for point in &curve.control_points {
1362                lane.floats(&[point.x, point.y, point.z, point.w]);
1363            }
1364        }
1365        fn name(lane: &mut TwoLane, name: &Option<String>) {
1366            lane.bool(name.is_some());
1367            if let Some(name) = name {
1368                lane.usize(name.len());
1369                for byte in name.bytes() {
1370                    lane.u64(u64::from(byte));
1371                }
1372            }
1373        }
1374        let mut lane = TwoLane::new();
1375        lane.u64(self.id);
1376        lane.u64(self.genus as u64);
1377        lane.usize(self.kind as usize);
1378        lane.usize(self.vertices.len());
1379        for vertex in &self.vertices {
1380            lane.u64(vertex.id);
1381            lane.floats(&[vertex.point.x, vertex.point.y, vertex.point.z]);
1382        }
1383        lane.usize(self.edges.len());
1384        for edge in &self.edges {
1385            lane.u64(edge.id);
1386            curve(&mut lane, &edge.curve);
1387            lane.floats(&[edge.t0, edge.t1]);
1388            lane.u64(edge.start_vertex_id);
1389            lane.u64(edge.end_vertex_id);
1390            lane.bool(edge.degenerate);
1391            name(&mut lane, &edge.name);
1392        }
1393        lane.usize(self.shells.len());
1394        for shell in &self.shells {
1395            lane.u64(shell.id);
1396            lane.usize(shell.faces.len());
1397            for face in &shell.faces {
1398                lane.u64(face.id);
1399                crate::mass_properties::hash_surface(&mut lane, &face.surface);
1400                lane.bool(face.same_sense);
1401                name(&mut lane, &face.name);
1402                lane.usize(face.loops.len());
1403                for loop_record in &face.loops {
1404                    lane.u64(loop_record.id);
1405                    lane.usize(loop_record.coedges.len());
1406                    for coedge in &loop_record.coedges {
1407                        lane.u64(coedge.id);
1408                        lane.u64(coedge.edge_id);
1409                        lane.bool(coedge.forward);
1410                        curve(&mut lane, &coedge.pcurve);
1411                    }
1412                }
1413            }
1414        }
1415        lane.usize(self.cells.len());
1416        for cell in &self.cells {
1417            lane.u64(cell.id);
1418            lane.bool(cell.void);
1419            lane.usize(cell.faces.len());
1420            for (face, sense) in &cell.faces {
1421                lane.u64(*face);
1422                lane.bool(*sense);
1423            }
1424        }
1425        let [a, b] = lane.finish();
1426        Some((a, b))
1427    }
1428
1429}
1430
1431fn validate_memo_on() -> bool {
1432    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
1433    *ON.get_or_init(|| !matches!(std::env::var("BREP_VALIDATE_MEMO").as_deref(), Ok("0") | Ok("off")))
1434}
1435
1436