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