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    // A bounded continuation of the same full-content readings across
838    // operations. A bore chain changes two cap faces but carries earlier
839    // bore walls unchanged; closing the operation scope used to discard
840    // every one of those expensive curved-coedge readings.
841    static RETAINED_COEDGE_READINGS: std::cell::RefCell<(HashMap<Vec<u64>, f64>, usize)> =
842        std::cell::RefCell::new((HashMap::default(), 0));
843    /// Reads served from the memo and reads computed, in the open scope.
844    static COEDGE_MEMO_COUNTS: std::cell::Cell<(u64, u64)> = const { std::cell::Cell::new((0, 0)) };
845}
846
847const RETAINED_COEDGE_WORDS: usize = 1 << 20;
848const RETAINED_COEDGE_ENTRIES: usize = 4096;
849
850pub(crate) fn clear_coedge_readings() {
851    RETAINED_COEDGE_READINGS.with(|cache| *cache.borrow_mut() = (HashMap::default(), 0));
852}
853
854fn retain_coedge_reading(key: Vec<u64>, value: f64) {
855    if key.len() > RETAINED_COEDGE_WORDS { return; }
856    RETAINED_COEDGE_READINGS.with(|cache| {
857        let (map, words) = &mut *cache.borrow_mut();
858        if *words + key.len() > RETAINED_COEDGE_WORDS || map.len() >= RETAINED_COEDGE_ENTRIES {
859            map.clear();
860            *words = 0;
861        }
862        *words += key.len();
863        map.insert(key, value);
864    });
865}
866
867/// `BREP_VALIDATE_COEDGE_MEMO` (lane X): while a [`CoedgeMemoScope`] is open,
868/// `validate` reads each coedge's geometry check once per distinct input.
869/// [`coedge_error_floored`] is a pure function of the carrier, the pcurve, the
870/// edge curve, the edge's `[t0, t1]`, the sense and the band (validate builds
871/// all three NURBS fresh, so no recognition cache reaches it), and the key is
872/// every one of those inputs bit for bit, compared in full on a hit: a repeat
873/// returns exactly what re-reading would. A boolean validates its result three
874/// times (assembly, face merge, the closing check) and most coedges arrive at
875/// the later two unchanged. On by default; `0`/`off` disables it.
876pub(crate) fn coedge_memo_enabled() -> bool {
877    switch!(live "BREP_VALIDATE_COEDGE_MEMO", true)
878}
879
880
881/// The active memo's lifetime: one operation. Nested scopes share the outer
882/// one. Successful full-content readings also enter the bounded retention
883/// cache; errors remain local, and a closed/disabled scope consults neither.
884pub(crate) struct CoedgeMemoScope {
885    opened: bool,
886}
887
888impl CoedgeMemoScope {
889    pub(crate) fn open(enabled: bool) -> Self {
890        let opened = enabled
891            && COEDGE_MEMO.with(|memo| {
892                let mut memo = memo.borrow_mut();
893                if memo.is_some() {
894                    return false;
895                }
896                *memo = Some(HashMap::default());
897                COEDGE_MEMO_COUNTS.with(|counts| counts.set((0, 0)));
898                true
899            });
900        CoedgeMemoScope { opened }
901    }
902}
903
904impl Drop for CoedgeMemoScope {
905    fn drop(&mut self) {
906        if self.opened {
907            COEDGE_MEMO.with(|memo| *memo.borrow_mut() = None);
908            let (hits, misses) = COEDGE_MEMO_COUNTS.with(|counts| counts.get());
909            if crate::profile::profile_enabled() {
910                profile_line!("validate.coedge_memo hits={hits} misses={misses}");
911            }
912        }
913    }
914}
915
916fn coedge_memo_key(
917    surface: &NurbsSurface,
918    pcurve: &NurbsCurve,
919    curve: &NurbsCurve,
920    edge: &EdgeRecord,
921    forward: bool,
922    tolerance: f64,
923) -> Vec<u64> {
924    let mut key = Vec::with_capacity(
925        16 + surface.knots_u.len() + surface.knots_v.len() + 4 * surface.control_points.iter().map(Vec::len).sum::<usize>()
926            + pcurve.knots.len() + 4 * pcurve.control_points.len() + curve.knots.len() + 4 * curve.control_points.len(),
927    );
928    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()]);
929    key.extend([edge.t0.to_bits(), edge.t1.to_bits(), forward as u64, tolerance.to_bits()]);
930    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]);
931    key.extend(surface.knots_u.iter().chain(&surface.knots_v).map(|knot| knot.to_bits()));
932    for row in &surface.control_points {
933        key.push(row.len() as u64);
934        for point in row {
935            push_point(&mut key, point);
936        }
937    }
938    for nurbs in [pcurve, curve] {
939        key.extend([nurbs.degree as u64, nurbs.knots.len() as u64, nurbs.control_points.len() as u64]);
940        key.extend(nurbs.knots.iter().map(|knot| knot.to_bits()));
941        for point in &nurbs.control_points {
942            push_point(&mut key, point);
943        }
944    }
945    key
946}
947
948/// `BREP_VALIDATE_EXACT_LINES` (lane X): `validate` reads a line coedge on a
949/// plane exactly instead of sampling it. On by default; `0`/`off` disables it.
950/// Only `validate`'s own per-coedge read takes it: every other caller of
951/// [`coedge_error_floored`] / [`adaptive_coedge_error`] keeps the sampler.
952pub(crate) fn exact_lines_enabled() -> bool {
953    switch!("BREP_VALIDATE_EXACT_LINES", true)
954}
955
956
957/// The coedge error of a line edge on a plane, exactly, when the coedge's 3-D
958/// curve and its pcurve image are one line BY CONSTRUCTION; `None` sends the
959/// coedge to the sampler. The admitted class: an edge curve of degree 1 with
960/// two control points of equal weight (linear in `t`); a pcurve of degree 1
961/// with two control points of equal weight (linear in its parameter) whose
962/// ends lie in the carrier's domain; a carrier of degree 1×1 on a 2×2 net of
963/// equal weights whose parallelogram defect is exactly zero (an affine map,
964/// no bilinear term). The sampler compares `S(q(q0 + f·Δq))` with
965/// `c(t0 + f·Δt)` at fractions `f` of `[0, 1]`; for this class both are affine
966/// in `f`, so their difference is too and its largest norm on `[0, 1]` is at
967/// an end: the answer is the larger endpoint gap, read by the sampler's own
968/// `coedge_sample`. That is the true error; the sampler's reading adds only
969/// interior rounding (a box face's ~4e-15 becomes the endpoints' gap, 0 there).
970pub(crate) fn exact_line_on_plane_error(
971    surface: &NurbsSurface,
972    pcurve: &NurbsCurve,
973    curve: &NurbsCurve,
974    edge: &EdgeRecord,
975    forward: bool,
976) -> Option<f64> {
977    let linear = |nurbs: &NurbsCurve| {
978        nurbs.degree == 1
979            && nurbs.control_points.len() == 2
980            && nurbs.knots.len() == 4
981            && nurbs.control_points[0].w == nurbs.control_points[1].w
982            && nurbs.control_points[0].w > 0.0
983    };
984    if !linear(pcurve) || !linear(curve) {
985        return None;
986    }
987    if surface.degree_u != 1
988        || surface.degree_v != 1
989        || surface.knots_u.len() != 4
990        || surface.knots_v.len() != 4
991        || surface.control_points.len() != 2
992        || surface.control_points.iter().any(|row| row.len() != 2)
993    {
994        return None;
995    }
996    let weight = surface.control_points[0][0].w;
997    if !(weight > 0.0) || surface.control_points.iter().flatten().any(|control| control.w != weight) {
998        return None;
999    }
1000    let corner = |i: usize, j: usize| surface.control_points[i][j].point().ok();
1001    let (p00, p01, p10, p11) = (corner(0, 0)?, corner(0, 1)?, corner(1, 0)?, corner(1, 1)?);
1002    let defect = p11.sub(p10).sub(p01.sub(p00));
1003    if defect.x != 0.0 || defect.y != 0.0 || defect.z != 0.0 {
1004        return None;
1005    }
1006    let ([u0, u1], [v0, v1]) = (surface.domain_u().ok()?, surface.domain_v().ok()?);
1007    let [q0, q1] = pcurve.domain().ok()?;
1008    for q in [q0, q1] {
1009        let uv = pcurve.evaluate(q).ok()?;
1010        if !(uv.x >= u0 && uv.x <= u1 && uv.y >= v0 && uv.y <= v1) {
1011            return None;
1012        }
1013    }
1014    let [c0, c1] = curve.domain().ok()?;
1015    let (low, high) = (edge.t0.min(edge.t1), edge.t0.max(edge.t1));
1016    if !(low >= c0 && high <= c1) {
1017        return None;
1018    }
1019    let start = coedge_sample(surface, pcurve, curve, edge, forward, 0.0).ok()?.0;
1020    let end = coedge_sample(surface, pcurve, curve, edge, forward, 1.0).ok()?.0;
1021    Some(start.max(end))
1022}
1023
1024/// An upper bound on the coedge error when the pcurve is an ISO-LINE of the
1025/// carrier run at the edge's own parameter (lane O row 37); `None` sends the
1026/// coedge to the sampler. The admitted class: a pcurve of degree 1 with two
1027/// control points of equal weight, or one of any degree with equal weights
1028/// whose running controls are its knots' Greville abscissae (it reads `q` at
1029/// `q`, and its domain is the run), whose controls share one coordinate exactly
1030/// (`v = c`, or `u = c`), its other coordinate running from the edge's start
1031/// parameter to its end (`t0 → t1`, or `t1 → t0` for a reversed coedge) inside
1032/// the carrier's domain; an edge curve with the carrier's degree and knots in
1033/// that direction. Then the sampler compares, at each fraction, the carrier's
1034/// iso-curve `I(s) = S(s, c)` with `C(s)` at the SAME parameter `s`. `I` is a
1035/// rational curve on the same knots whose homogeneous controls are the net's
1036/// rows blended by the basis at `c`; when its weights are the edge curve's
1037/// (up to one common factor), `I − C = Σ R_i(s)(Q_i − P_i)` with `R_i` a
1038/// partition of unity, so `max |Q_i − P_i|` bounds the error everywhere. A
1039/// weight mismatch adds `4·δ·diameter` (δ the largest relative weight
1040/// difference, bounded at 1e-12); anything larger declines.
1041pub(crate) fn exact_iso_image_error(
1042    surface: &NurbsSurface,
1043    pcurve: &NurbsCurve,
1044    curve: &NurbsCurve,
1045    edge: &EdgeRecord,
1046    forward: bool,
1047) -> Option<f64> {
1048    let weight = pcurve.control_points.first()?.w;
1049    if pcurve.control_points.len() < 2 || !(weight > 0.0) || pcurve.control_points.iter().any(|control| control.w != weight) {
1050        return None;
1051    }
1052    let points = pcurve.control_points.iter().map(|control| control.point().ok()).collect::<Option<Vec<_>>>()?;
1053    let (a, b) = (points[0], points[points.len() - 1]);
1054    // `along_u`: the iso-line is `v = c` and the run is in u.
1055    let (along_u, constant, run) = if points.iter().all(|point| point.y == a.y) && a.x != b.x {
1056        (true, a.y, (a.x, b.x))
1057    } else if points.iter().all(|point| point.x == a.x) && a.y != b.y {
1058        (false, a.x, (a.y, b.y))
1059    } else {
1060        return None;
1061    };
1062    // The running coordinate is affine in the pcurve's parameter: a straight
1063    // two-point pcurve (any knots), or one of any degree whose running controls
1064    // are its own knots' Greville abscissae with equal weights, so that it reads
1065    // `q` at `q` (linear precision) and the run is the pcurve's own domain
1066    // (`imprint::support::iso_line_pcurve`'s form).
1067    let linear = pcurve.degree == 1 && points.len() == 2 && pcurve.knots.len() == 4;
1068    let run = if linear {
1069        run
1070    } else {
1071        let degree = pcurve.degree;
1072        if degree == 0 || pcurve.knots.len() != points.len() + degree + 1 {
1073            return None;
1074        }
1075        for (index, point) in points.iter().enumerate() {
1076            let greville = pcurve.knots[index + 1..=index + degree].iter().sum::<f64>() / degree as f64;
1077            if (if along_u { point.x } else { point.y }) != greville {
1078                return None;
1079            }
1080        }
1081        let [q0, q1] = pcurve.domain().ok()?;
1082        (q0, q1)
1083    };
1084    let (run_knots, run_degree, other_knots, other_degree) = if along_u {
1085        (&surface.knots_u, surface.degree_u, &surface.knots_v, surface.degree_v)
1086    } else {
1087        (&surface.knots_v, surface.degree_v, &surface.knots_u, surface.degree_u)
1088    };
1089    if curve.degree != run_degree || &curve.knots != run_knots {
1090        return None;
1091    }
1092    let expected = if forward { (edge.t0, edge.t1) } else { (edge.t1, edge.t0) };
1093    if run.0 != expected.0 || run.1 != expected.1 {
1094        return None;
1095    }
1096    let run_vector = crate::KnotVector::new(run_knots.clone(), run_degree).ok()?;
1097    let [r0, r1] = run_vector.domain();
1098    let (low, high) = (run.0.min(run.1), run.0.max(run.1));
1099    if !(low >= r0 && high <= r1) {
1100        return None;
1101    }
1102    let other = crate::KnotVector::new(other_knots.clone(), other_degree).ok()?;
1103    let [o0, o1] = other.domain();
1104    if !(constant >= o0 && constant <= o1) {
1105        return None;
1106    }
1107    let span = other.find_span(constant);
1108    let basis = other.basis_functions(span, constant);
1109    let count = curve.control_points.len();
1110    let rows = &surface.control_points;
1111    let net_count = if along_u { rows.len() } else { rows.first()?.len() };
1112    if net_count != count {
1113        return None;
1114    }
1115    let mut controls = Vec::with_capacity(count);
1116    for index in 0..count {
1117        let mut blended = crate::Vec4 { x: 0.0, y: 0.0, z: 0.0, w: 0.0 };
1118        for (offset, weight) in basis.iter().enumerate() {
1119            let other_index = span - other_degree + offset;
1120            let control = if along_u { *rows.get(index)?.get(other_index)? } else { *rows.get(other_index)?.get(index)? };
1121            blended = blended.add(control.scale(*weight));
1122        }
1123        if !(blended.w > 0.0) {
1124            return None;
1125        }
1126        controls.push(blended);
1127    }
1128    let factor = controls[0].w / curve.control_points[0].w;
1129    if !(factor > 0.0) {
1130        return None;
1131    }
1132    let mut weight_defect = 0.0f64;
1133    let mut gap = 0.0f64;
1134    let mut diameter = 0.0f64;
1135    let first = curve.control_points[0].point().ok()?;
1136    for (iso, own) in controls.iter().zip(&curve.control_points) {
1137        if !(own.w > 0.0) {
1138            return None;
1139        }
1140        weight_defect = weight_defect.max((iso.w / factor - own.w).abs() / own.w);
1141        let (iso_point, own_point) = (iso.point().ok()?, own.point().ok()?);
1142        gap = gap.max(iso_point.sub(own_point).length());
1143        diameter = diameter.max(own_point.sub(first).length());
1144    }
1145    if weight_defect > 1e-12 {
1146        return None;
1147    }
1148    Some(gap + 4.0 * weight_defect * diameter)
1149}
1150
1151/// `BREP_VALIDATE_EXACT_IMAGES` (lane N, 2026-10-28): `validate` bounds a
1152/// coedge on an affine plane in closed form when its pcurve is the plane's
1153/// chart image of its edge curve (`exact_plane_image_error`). On by default;
1154/// `0`/`off` sends it to the sampler.
1155pub(crate) fn exact_images_enabled() -> bool {
1156    switch!(live "BREP_VALIDATE_EXACT_IMAGES", true)
1157}
1158
1159
1160/// A bound on the coedge error when the pcurve is the affine plane's chart
1161/// image of the edge curve, BY CONSTRUCTION: the carrier of degree 1 × 1 on a
1162/// 2 × 2 net of equal weights with no parallelogram defect (an affine map
1163/// `S(u, v) = P00 + ũ·(P10 − P00) + ṽ·(P01 − P00)`, `ũ`, `ṽ` the parameters
1164/// normalised to the carrier's domain, the same map past it, which is what
1165/// the sampler's `evaluate_extended` reads on a plane); the pcurve of the edge
1166/// curve's degree and knots, bit for bit. The affine map commutes with the
1167/// rational combination, so `S∘q` is the rational curve on the pcurve's bases
1168/// whose homogeneous controls are the map's images of the pcurve's, and with
1169/// identical bases the homogeneous control differences bound `|S∘q − c|`
1170/// everywhere (positive weights: a partition of unity). The sampler compares
1171/// `S(q(q0 + f·Δq))` with `c(t0 + f·Δt)` (forward) or `c(t1 − f·Δt)`: the
1172/// parameters must correspond bit for bit — the pcurve's domain the edge's
1173/// range forward, or its mirror in the curve's domain reversed (the curve then
1174/// compared reversed). `None` for anything else, or a bound over
1175/// `1e-10·(1 + scale)`: the sampler reads it.
1176pub(crate) fn exact_plane_image_error(
1177    surface: &NurbsSurface,
1178    pcurve: &NurbsCurve,
1179    curve: &NurbsCurve,
1180    edge: &EdgeRecord,
1181    forward: bool,
1182) -> Option<f64> {
1183    if pcurve.degree != curve.degree || pcurve.knots != curve.knots || pcurve.control_points.len() != curve.control_points.len() {
1184        return None;
1185    }
1186    if surface.degree_u != 1
1187        || surface.degree_v != 1
1188        || surface.knots_u.len() != 4
1189        || surface.knots_v.len() != 4
1190        || surface.control_points.len() != 2
1191        || surface.control_points.iter().any(|row| row.len() != 2)
1192    {
1193        return None;
1194    }
1195    let weight = surface.control_points[0][0].w;
1196    if !(weight > 0.0) || surface.control_points.iter().flatten().any(|control| control.w != weight) {
1197        return None;
1198    }
1199    let corner = |i: usize, j: usize| surface.control_points[i][j].point().ok();
1200    let (p00, p01, p10, p11) = (corner(0, 0)?, corner(0, 1)?, corner(1, 0)?, corner(1, 1)?);
1201    let defect = p11.sub(p10).sub(p01.sub(p00));
1202    if defect.x != 0.0 || defect.y != 0.0 || defect.z != 0.0 {
1203        return None;
1204    }
1205    let ([u0, u1], [v0, v1]) = (surface.domain_u().ok()?, surface.domain_v().ok()?);
1206    if !(u1 > u0 && v1 > v0) {
1207        return None;
1208    }
1209    let [q0, q1] = pcurve.domain().ok()?;
1210    let [c0, c1] = curve.domain().ok()?;
1211    let (t0, t1) = (edge.t0, edge.t1);
1212    let reference = if forward {
1213        if q0.to_bits() != t0.to_bits() || q1.to_bits() != t1.to_bits() {
1214            return None;
1215        }
1216        curve.clone()
1217    } else {
1218        // c(t1 − f·Δt) is the reversed curve's c(c0 + c1 − s) at
1219        // s = c0 + c1 − t1 + f·Δt: the pcurve's domain must be that range.
1220        if q0.to_bits() != (c0 + c1 - t1).to_bits() || q1.to_bits() != (c0 + c1 - t0).to_bits() {
1221            return None;
1222        }
1223        let reversed = curve.reversed().ok()?;
1224        if reversed.knots != pcurve.knots {
1225            return None;
1226        }
1227        reversed
1228    };
1229    let (du, dv) = (p10.sub(p00), p01.sub(p00));
1230    let mut image = Vec::with_capacity(pcurve.control_points.len());
1231    for control in &pcurve.control_points {
1232        if !(control.w > 0.0) {
1233            return None;
1234        }
1235        let (u, v) = (control.x / control.w, control.y / control.w);
1236        let point = p00.add(du.scale((u - u0) / (u1 - u0))).add(dv.scale((v - v0) / (v1 - v0)));
1237        image.push(crate::Vec4 { x: point.x * control.w, y: point.y * control.w, z: point.z * control.w, w: control.w });
1238    }
1239    let bound = homogeneous_net_bound(&reference.control_points, &image)?;
1240    let scale = [p00, p01, p10, p11].iter().fold(0f64, |m, p| m.max(p.length()));
1241    (bound <= 1e-10 * (1.0 + scale)).then_some(bound)
1242}
1243
1244/// For two rational curves on identical bases with positive weights: a bound
1245/// on the distance between them everywhere, from the homogeneous control
1246/// differences and the weights' floor.
1247fn homogeneous_net_bound(a: &[crate::Vec4], b: &[crate::Vec4]) -> Option<f64> {
1248    if a.len() != b.len() || a.is_empty() {
1249        return None;
1250    }
1251    let origin = Vec3::new(a[0].x / a[0].w, a[0].y / a[0].w, a[0].z / a[0].w);
1252    let mut floor = f64::INFINITY;
1253    let (mut numerator, mut denominator, mut reach) = (0f64, 0f64, 0f64);
1254    for (p, q) in a.iter().zip(b) {
1255        if !(p.w > 0.0 && q.w > 0.0) {
1256            return None;
1257        }
1258        floor = floor.min(p.w);
1259        let ph = Vec3::new(p.x, p.y, p.z).sub(origin.scale(p.w));
1260        let qh = Vec3::new(q.x, q.y, q.z).sub(origin.scale(q.w));
1261        numerator = numerator.max(ph.sub(qh).length());
1262        denominator = denominator.max((p.w - q.w).abs());
1263        reach = reach.max(qh.length() / q.w);
1264    }
1265    let bound = (numerator + reach * denominator) / floor;
1266    bound.is_finite().then_some(bound)
1267}
1268
1269/// Lane R1 (granted 2026-10-18): the closed-form coedge reading of an
1270/// iso-parameter line. On by default (promoted 2026-10-18 on the 98 + 144
1271/// census); `BREP_VALIDATE_EXACT_ISO=0` (or `off`) sends it to the sampler.
1272pub(crate) fn exact_iso_enabled() -> bool {
1273    switch!("BREP_VALIDATE_EXACT_ISO", true)
1274}
1275
1276
1277/// The coedge error of an ISO-PARAMETER line, bounded in closed form; `None`
1278/// sends the coedge to the sampler. The admitted class: a pcurve of degree 1
1279/// with two control points of equal weight (linear in its parameter) along
1280/// one iso line of the carrier (`v = c` or `u = c`, the two ends' coordinate
1281/// equal bit for bit) inside the carrier's domain; an edge curve equal to the
1282/// carrier's own iso curve there (`iso_curve_v(c)` / `iso_curve_u(c)`
1283/// rebuilt: degree, knots and weights bit for bit, control points within
1284/// `1e-12` of the net's size) whose parameter range IS the pcurve's range of
1285/// the free coordinate, in the coedge's sense. Then the sampler's two points
1286/// at every fraction are the carrier and the edge curve at one parameter:
1287/// with the same basis and weights their difference is a convex combination
1288/// of the control points' differences, so the error is at most the largest
1289/// of those, and at least the ends' gaps — the answer is the larger of the
1290/// two (`coedge_sample` reads the ends). That bounds the true error; the
1291/// sampler's own reading adds only the interior rounding of evaluating one
1292/// point two ways (2e-15 on a rim fillet's rail), as for
1293/// [`exact_line_on_plane_error`].
1294pub(crate) fn exact_iso_line_error(
1295    surface: &NurbsSurface,
1296    pcurve: &NurbsCurve,
1297    curve: &NurbsCurve,
1298    edge: &EdgeRecord,
1299    forward: bool,
1300) -> Option<f64> {
1301    if pcurve.degree != 1
1302        || pcurve.control_points.len() != 2
1303        || pcurve.knots.len() != 4
1304        || pcurve.control_points[0].w != pcurve.control_points[1].w
1305        || !(pcurve.control_points[0].w > 0.0)
1306    {
1307        return None;
1308    }
1309    let [q0, q1] = pcurve.domain().ok()?;
1310    let (a, b) = (pcurve.evaluate(q0).ok()?, pcurve.evaluate(q1).ok()?);
1311    let ([u0, u1], [v0, v1]) = (surface.domain_u().ok()?, surface.domain_v().ok()?);
1312    for uv in [a, b] {
1313        if !(uv.x >= u0 && uv.x <= u1 && uv.y >= v0 && uv.y <= v1) {
1314            return None;
1315        }
1316    }
1317    // Which coordinate is fixed, and the free one's range along the pcurve.
1318    let (iso, from, to) = if a.y.to_bits() == b.y.to_bits() && a.x != b.x {
1319        (surface.iso_curve_v(a.y).ok()?, a.x, b.x)
1320    } else if a.x.to_bits() == b.x.to_bits() && a.y != b.y {
1321        (surface.iso_curve_u(a.x).ok()?, a.y, b.y)
1322    } else {
1323        return None;
1324    };
1325    // The edge's parameter at fraction f must be the free coordinate there.
1326    let (start, end) = if forward { (edge.t0, edge.t1) } else { (edge.t1, edge.t0) };
1327    if start.to_bits() != from.to_bits() || end.to_bits() != to.to_bits() {
1328        return None;
1329    }
1330    let matches = |iso: &NurbsCurve| {
1331        iso.degree == curve.degree
1332            && iso.knots.len() == curve.knots.len()
1333            && iso.knots.iter().zip(&curve.knots).all(|(x, y)| x.to_bits() == y.to_bits())
1334            && iso.control_points.len() == curve.control_points.len()
1335    };
1336    // Lane N (2026-10-28, with `exact_images_enabled`): an edge that is a
1337    // trimmed piece of the iso curve — a blend row cut to its crossing window
1338    // by two splits (`trim_row`: split at the low end, then at the high end) —
1339    // is compared with the iso curve cut the same way, which reproduces it.
1340    let iso = if !matches(&iso) && exact_images_enabled() {
1341        let (low, high) = (from.min(to), from.max(to));
1342        let [d0, d1] = iso.domain().ok()?;
1343        let tail = if low == d0 { iso } else { iso.split(low).ok()?.1 };
1344        let piece = if high == d1 { tail } else { tail.split(high).ok()?.0 };
1345        if !matches(&piece) {
1346            return None;
1347        }
1348        crate::blend::record_count("count.validate.exact_iso_piece", 1);
1349        piece
1350    } else {
1351        iso
1352    };
1353    if !matches(&iso) {
1354        return None;
1355    }
1356    let size = iso
1357        .control_points
1358        .iter()
1359        .filter_map(|c| c.point().ok())
1360        .fold(0f64, |m, q| m.max(q.x.abs()).max(q.y.abs()).max(q.z.abs()));
1361    let mut deviation = 0f64;
1362    for (p, q) in iso.control_points.iter().zip(&curve.control_points) {
1363        if p.w.to_bits() != q.w.to_bits() || !(p.w > 0.0) {
1364            return None;
1365        }
1366        deviation = deviation.max(p.point().ok()?.sub(q.point().ok()?).length());
1367    }
1368    if !(deviation <= 1e-12 * size.max(1.0)) {
1369        return None;
1370    }
1371    let start_gap = coedge_sample(surface, pcurve, curve, edge, forward, 0.0).ok()?.0;
1372    let end_gap = coedge_sample(surface, pcurve, curve, edge, forward, 1.0).ok()?.0;
1373    Some(start_gap.max(end_gap).max(deviation))
1374}
1375
1376/// [`coedge_error_floored`] through the operation's memo when one is open.
1377fn memoized_coedge_error(
1378    surface: &NurbsSurface,
1379    pcurve: &NurbsCurve,
1380    curve: &NurbsCurve,
1381    edge: &EdgeRecord,
1382    forward: bool,
1383    tolerance: f64,
1384) -> Result<f64, String> {
1385    if exact_lines_enabled() {
1386        if let Some(error) = exact_line_on_plane_error(surface, pcurve, curve, edge, forward) {
1387            return Ok(error);
1388        }
1389    }
1390    if exact_iso_enabled() {
1391        if let Some(error) = exact_iso_line_error(surface, pcurve, curve, edge, forward) {
1392            crate::blend::record_count("count.validate.exact_iso", 1);
1393            return Ok(error);
1394        }
1395    }
1396    if exact_images_enabled() {
1397        if let Some(error) = exact_plane_image_error(surface, pcurve, curve, edge, forward) {
1398            crate::blend::record_count("count.validate.exact_image", 1);
1399            return Ok(error);
1400        }
1401    }
1402    if COEDGE_MEMO.with(|memo| memo.borrow().is_none()) {
1403        return coedge_error_floored(surface, pcurve, curve, edge, forward, tolerance);
1404    }
1405    let key = coedge_memo_key(surface, pcurve, curve, edge, forward, tolerance);
1406    if let Some(hit) = COEDGE_MEMO.with(|memo| memo.borrow().as_ref().and_then(|map| map.get(&key).cloned())) {
1407        COEDGE_MEMO_COUNTS.with(|counts| counts.set((counts.get().0 + 1, counts.get().1)));
1408        return hit;
1409    }
1410    if let Some(value) = RETAINED_COEDGE_READINGS.with(|cache| cache.borrow().0.get(&key).copied()) {
1411        COEDGE_MEMO_COUNTS.with(|counts| counts.set((counts.get().0 + 1, counts.get().1)));
1412        COEDGE_MEMO.with(|memo| {
1413            if let Some(map) = memo.borrow_mut().as_mut() { map.insert(key, Ok(value)); }
1414        });
1415        return Ok(value);
1416    }
1417    COEDGE_MEMO_COUNTS.with(|counts| counts.set((counts.get().0, counts.get().1 + 1)));
1418    let read = coedge_error_floored(surface, pcurve, curve, edge, forward, tolerance);
1419    if let Ok(value) = read { retain_coedge_reading(key.clone(), value); }
1420    COEDGE_MEMO.with(|memo| {
1421        if let Some(map) = memo.borrow_mut().as_mut() {
1422            map.insert(key, read.clone());
1423        }
1424    });
1425    read
1426}
1427
1428/// [`adaptive_coedge_error`] with the band-free floor: the midpoint of every
1429/// knot span of the pcurve and of the edge curve, read whatever the band. This
1430/// is what `validate` and the offset family's measured tolerance read. The
1431/// boolean's repair decisions (`csg/boolean/assemble/finalize.rs`,
1432/// `refusal_welds.rs`) and the healing coalesce keep the band-steered reading
1433/// above: read through the floor, the zero-clearance ball-and-stick joint
1434/// gains an edge and a vertex at its crotch ((5, 9, 6) against (5, 8, 5),
1435/// `tests/suites/tangent_node_assembly.rs`, 2026-09-26) — a consumer that
1436/// depends on its instrument's coarseness, named for its own slice.
1437pub(crate) fn coedge_error_floored(
1438    surface: &NurbsSurface,
1439    pcurve: &NurbsCurve,
1440    curve: &NurbsCurve,
1441    edge: &EdgeRecord,
1442    forward: bool,
1443    tolerance: f64,
1444) -> Result<f64, String> {
1445    let steered = adaptive_coedge_error(surface, pcurve, curve, edge, forward, tolerance)?;
1446    Ok(steered.max(knot_span_midpoint_error(surface, pcurve, curve, edge, forward)?))
1447}
1448
1449/// `BREP_DEBUG_COEDGE_FLOOR`: trace every knot-span midpoint the floor reads
1450/// over 1e-3, once per process (the floor runs per span of every coedge of
1451/// every `validate()`, so the environment is not read per sample).
1452fn coedge_floor_tracing() -> bool {
1453    debug_flag!("BREP_DEBUG_COEDGE_FLOOR")
1454}
1455
1456/// `‖S(q(s)) − c(t(s))‖` at the midpoint of every knot span of `pcurve` and of
1457/// every knot span of `curve` inside the edge's `[t0, t1]`, both mapped to the
1458/// coedge fraction `adaptive_coedge_error` samples on — no tolerance steers
1459/// where it looks. The pcurve half is the one no other pass takes: a fitted
1460/// pcurve interpolates at its nodes and misses between them, and its nodes
1461/// are its own knots, not the edge curve's.
1462fn knot_span_midpoint_error(
1463    surface: &NurbsSurface,
1464    pcurve: &NurbsCurve,
1465    curve: &NurbsCurve,
1466    edge: &EdgeRecord,
1467    forward: bool,
1468) -> Result<f64, String> {
1469    let mut worst = 0.0_f64;
1470    let [q0, q1] = pcurve.domain()?;
1471    if q1 > q0 {
1472        let mut previous = q0;
1473        for &knot in pcurve.knots.iter().chain(std::iter::once(&q1)) {
1474            if knot > previous && knot <= q1 {
1475                let fraction = ((previous + knot) * 0.5 - q0) / (q1 - q0);
1476                let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
1477                if coedge_floor_tracing() && sample.0 > 1e-3 {
1478                    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
1479                    let ([u0, u1], [v0, v1]) = (surface.domain_u()?, surface.domain_v()?);
1480                    eprintln!(
1481                        "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 {}",
1482                        edge.id, sample.0, uv.x, uv.y, surface.closed_directions()?, surface.analytic().map(|_| "yes"), pcurve.degree, pcurve.knots.len(), pcurve.control_points.len()
1483                    );
1484                }
1485                worst = worst.max(sample.0);
1486                previous = knot;
1487            }
1488        }
1489    }
1490    let span = edge.t1 - edge.t0;
1491    if span.is_finite() && span != 0.0 {
1492        let low = edge.t0.min(edge.t1);
1493        let high = edge.t0.max(edge.t1);
1494        let mut previous = low;
1495        for &knot in curve.knots.iter().chain(std::iter::once(&high)) {
1496            if knot > previous && knot <= high {
1497                let midpoint = (previous + knot) * 0.5;
1498                let fraction = if forward {
1499                    (midpoint - edge.t0) / span
1500                } else {
1501                    (edge.t1 - midpoint) / span
1502                };
1503                let sample = coedge_sample(surface, pcurve, curve, edge, forward, fraction)?;
1504                if coedge_floor_tracing() && sample.0 > 1e-3 {
1505                    let uv = pcurve.evaluate(q0 + (q1 - q0) * fraction)?;
1506                    eprintln!(
1507                        "COEDGE-FLOOR edge {} edge-span midpoint fraction {fraction:.6}: miss {:.4e} at uv ({:.6}, {:.6})",
1508                        edge.id, sample.0, uv.x, uv.y
1509                    );
1510                }
1511                worst = worst.max(sample.0);
1512                previous = knot;
1513            }
1514        }
1515    }
1516    Ok(worst)
1517}
1518
1519fn segments_cross(a: Vec2, b: Vec2, c: Vec2, d: Vec2, tolerance: f64) -> bool {
1520    let cross = |first: Vec2, second: Vec2| first.x * second.y - first.y * second.x;
1521    let ab = b.sub(a);
1522    let cd = d.sub(c);
1523    let denominator = cross(ab, cd);
1524    if denominator.abs() <= tolerance {
1525        return false;
1526    }
1527    let ac = c.sub(a);
1528    let t = cross(ac, cd) / denominator;
1529    let u = cross(ac, ab) / denominator;
1530    t > tolerance && t < 1.0 - tolerance && u > tolerance && u < 1.0 - tolerance
1531}
1532
1533/// On a cylinder or a cone/frustum without a pole, the oriented boundary of
1534/// a finite face has zero total winding around the periodic direction. Checking
1535/// each loop separately cannot detect two full rims running the same way: both
1536/// close in 3D, and a constant-height rim has no signed area in the UV plane.
1537/// Sum pcurve displacements, not the jumps between their parameter branches;
1538/// those jumps are implicit seams and do not belong to the boundary traversal.
1539fn validate_periodic_boundary_winding(
1540    surface: &NurbsSurface,
1541    face: &FaceRecord,
1542    tolerances: &KernelTolerances,
1543) -> Option<String> {
1544    let Some(crate::AnalyticSurface::RuledRevolution { rho0, rho1, .. }) = surface.analytic() else {
1545        return None;
1546    };
1547    // A cap enclosing a pole can legitimately have a single winding rim.
1548    // Spheres, tori, and general surfaces are outside this nonsingular-wall check.
1549    if rho0 * rho1 <= 0.0 || rho0.abs().min(rho1.abs()) <= tolerances.model {
1550        return None;
1551    }
1552    let [u0, u1] = surface.domain_u().ok()?;
1553    let period = u1 - u0;
1554    if period <= 0.0 { return None; }
1555    let mut displacement = 0.0;
1556    for coedge in face.loops.iter().flat_map(|lp| &lp.coedges) {
1557        let [t0, t1] = coedge.pcurve.domain().ok()?;
1558        let start = coedge.pcurve.evaluate(t0).ok()?;
1559        let end = coedge.pcurve.evaluate(t1).ok()?;
1560        displacement += end.x - start.x;
1561    }
1562    let winding = displacement / period;
1563    // Valid endpoint matches make this an integer, up to projection error.
1564    // Half a turn separates zero from a genuine nonzero winding without
1565    // promoting tiny accumulated endpoint residuals into topology failures.
1566    (winding.abs() > 0.5).then(|| format!(
1567        "nonzero periodic boundary winding ({winding:.6} turns) on a nonsingular revolution wall"
1568    ))
1569}
1570
1571fn validate_uv_wire(
1572    surface: &NurbsSurface,
1573    loop_record: &LoopRecord,
1574    edges: &HashMap<u64, &EdgeRecord>,
1575    tolerances: &KernelTolerances,
1576    outer: bool,
1577    same_sense: bool,
1578) -> Option<String> {
1579    let u_domain = crate::KnotVector::new(surface.knots_u.clone(), surface.degree_u)
1580        .ok()?
1581        .domain();
1582    let v_domain = crate::KnotVector::new(surface.knots_v.clone(), surface.degree_v)
1583        .ok()?
1584        .domain();
1585    let u_span = u_domain[1] - u_domain[0];
1586    let v_span = v_domain[1] - v_domain[0];
1587    let mut points = Vec::<Vec2>::new();
1588    for coedge in &loop_record.coedges {
1589        let edge = edges.get(&coedge.edge_id)?;
1590        if edge.degenerate {
1591            continue;
1592        }
1593        let [q0, q1] = coedge.pcurve.domain().ok()?;
1594        for index in 0..=8 {
1595            if !points.is_empty() && index == 0 {
1596                continue;
1597            }
1598            let fraction = index as f64 / 8.0;
1599            let parameter = q0 + (q1 - q0) * fraction;
1600            let value = coedge.pcurve.evaluate(parameter).ok()?;
1601            let mut point = Vec2 {
1602                x: value.x,
1603                y: value.y,
1604            };
1605            if let Some(previous) = points.last() {
1606                while point.x - previous.x > u_span * 0.5 {
1607                    point.x -= u_span;
1608                }
1609                while point.x - previous.x < -u_span * 0.5 {
1610                    point.x += u_span;
1611                }
1612                while point.y - previous.y > v_span * 0.5 {
1613                    point.y -= v_span;
1614                }
1615                while point.y - previous.y < -v_span * 0.5 {
1616                    point.y += v_span;
1617                }
1618            }
1619            points.push(point);
1620        }
1621    }
1622    if points.len() < 4 {
1623        return None;
1624    }
1625    let first = points[0];
1626    let last = *points.last()?;
1627    let uv_tolerance = tolerances.model.max(1e-8);
1628    if last.sub(first).length() > uv_tolerance * 100.0 {
1629        // Parameter seams may differ by a complete period.  Compare modulo
1630        // both domains before reporting a genuine open wire.
1631        let du = (last.x - first.x) / u_span;
1632        let dv = (last.y - first.y) / v_span;
1633        if (du - du.round()).abs() * u_span > uv_tolerance * 100.0
1634            || (dv - dv.round()).abs() * v_span > uv_tolerance * 100.0
1635        {
1636            return Some(format!(
1637                "wire is open in parameter space (gap {:.3e})",
1638                last.sub(first).length()
1639            ));
1640        }
1641    }
1642    for first_index in 0..points.len() - 1 {
1643        for second_index in first_index + 2..points.len() - 1 {
1644            if first_index == 0 && second_index + 1 == points.len() - 1 {
1645                continue;
1646            }
1647            if segments_cross(
1648                points[first_index],
1649                points[first_index + 1],
1650                points[second_index],
1651                points[second_index + 1],
1652                1e-10,
1653            ) {
1654                return Some(format!(
1655                    "wire self-intersects near sampled segments {first_index} and {second_index}"
1656                ));
1657            }
1658        }
1659    }
1660    let area = points
1661        .windows(2)
1662        .map(|pair| pair[0].x * pair[1].y - pair[1].x * pair[0].y)
1663        .sum::<f64>()
1664        * 0.5;
1665    if area.abs() > uv_tolerance * uv_tolerance {
1666        let expected_positive = if outer { same_sense } else { !same_sense };
1667        if (area > 0.0) != expected_positive {
1668            return Some(format!(
1669                "{} wire winding disagrees with face sense (signed UV area {:.6})",
1670                if outer { "outer" } else { "inner" },
1671                area
1672            ));
1673        }
1674    }
1675    None
1676}
1677
1678/// Bound on the clean-verdict memo; the set is emptied when it would pass it.
1679const VALIDATED_CLEAN_CAP: usize = 4096;
1680
1681thread_local! {
1682    static VALIDATED_CLEAN: std::cell::RefCell<rustc_hash::FxHashSet<(u64, u64)>> =
1683        std::cell::RefCell::new(rustc_hash::FxHashSet::default());
1684}
1685
1686impl BrepSolid {
1687    /// Empty the clean-verdict memo of [`BrepSolid::validate_memoised`]:
1688    /// called at a fillet operation's entry, so each operation pays for the
1689    /// first read of every body it validates.
1690    pub(crate) fn clear_validate_memo() {
1691        VALIDATED_CLEAN.with(|clean| clean.borrow_mut().clear());
1692    }
1693
1694    /// The body's memo key: the 128-bit two-lane content hash
1695    /// (`mass_properties::TwoLane`, bit-exact on every float) over EVERY
1696    /// field the body serialises — ids, names, kind, genus, vertices, edges,
1697    /// shells, faces, loops, coedges, cells — so any mutation is a new key.
1698    /// (A JSON serialisation of the body read the same content at ~10× the
1699    /// cost: float formatting.)
1700    pub(crate) fn validate_memo_key(&self) -> Option<(u64, u64)> {
1701        use crate::mass_properties::TwoLane;
1702        fn curve(lane: &mut TwoLane, curve: &crate::NurbsCurve) {
1703            lane.usize(curve.degree);
1704            lane.floats(&curve.knots);
1705            lane.usize(curve.control_points.len());
1706            for point in &curve.control_points {
1707                lane.floats(&[point.x, point.y, point.z, point.w]);
1708            }
1709        }
1710        fn name(lane: &mut TwoLane, name: &Option<String>) {
1711            lane.bool(name.is_some());
1712            if let Some(name) = name {
1713                lane.usize(name.len());
1714                for byte in name.bytes() {
1715                    lane.u64(u64::from(byte));
1716                }
1717            }
1718        }
1719        let mut lane = TwoLane::new();
1720        lane.u64(self.id);
1721        lane.u64(self.genus as u64);
1722        lane.usize(self.kind as usize);
1723        lane.usize(self.vertices.len());
1724        for vertex in &self.vertices {
1725            lane.u64(vertex.id);
1726            lane.floats(&[vertex.point.x, vertex.point.y, vertex.point.z]);
1727        }
1728        lane.usize(self.edges.len());
1729        for edge in &self.edges {
1730            lane.u64(edge.id);
1731            curve(&mut lane, &edge.curve);
1732            lane.floats(&[edge.t0, edge.t1]);
1733            lane.u64(edge.start_vertex_id);
1734            lane.u64(edge.end_vertex_id);
1735            lane.bool(edge.degenerate);
1736            name(&mut lane, &edge.name);
1737        }
1738        lane.usize(self.shells.len());
1739        for shell in &self.shells {
1740            lane.u64(shell.id);
1741            lane.usize(shell.faces.len());
1742            for face in &shell.faces {
1743                lane.u64(face.id);
1744                crate::mass_properties::hash_surface(&mut lane, &face.surface);
1745                lane.bool(face.same_sense);
1746                name(&mut lane, &face.name);
1747                lane.usize(face.loops.len());
1748                for loop_record in &face.loops {
1749                    lane.u64(loop_record.id);
1750                    lane.usize(loop_record.coedges.len());
1751                    for coedge in &loop_record.coedges {
1752                        lane.u64(coedge.id);
1753                        lane.u64(coedge.edge_id);
1754                        lane.bool(coedge.forward);
1755                        curve(&mut lane, &coedge.pcurve);
1756                    }
1757                }
1758            }
1759        }
1760        lane.usize(self.cells.len());
1761        for cell in &self.cells {
1762            lane.u64(cell.id);
1763            lane.bool(cell.void);
1764            lane.usize(cell.faces.len());
1765            for (face, sense) in &cell.faces {
1766                lane.u64(*face);
1767                lane.bool(*sense);
1768            }
1769        }
1770        let [a, b] = lane.finish();
1771        Some((a, b))
1772    }
1773
1774}
1775
1776fn validate_memo_on() -> bool {
1777    switch!("BREP_VALIDATE_MEMO", true)
1778}
1779
1780
1781