brep: check_geometry, the geometric agreement of a record

validate() is structural only, and a kernel adapter that builds topology
from the records without its own checks (truck's new_unchecked) would take
an edge whose curve misses its vertices and fail later inside the kernel.
check_geometry spot-checks what it assumes, each against the record's own
tolerance: every edge's curve meets its vertices at its interval's ends;
every oriented edge's pcurve, lifted through its face's surface, lies on
the edge's curve at the interval's ends and middle and continues the use
before it on the same period; every vertex loop's vertex lies on its
surface. A fixed number of evaluations per record, so linear in the item
budget; a point that cannot be evaluated is an error (Mismatch, naming the
record), never a panic.

The schema fixtures now carry real planes and pcurves (from
pcurve::pcurves), so every form passes; one-defect fixtures and hostile
records cover the refusals.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QWhMwdqJYQKQq8RCLSL1LT
This commit is contained in:
jyviko
2026-10-21 00:36:24 -04:00
parent b85b51e81a
commit 4a6494b481
3 changed files with 310 additions and 6 deletions
+5 -2
View File
@@ -1,6 +1,7 @@
//! SolidCraft-owned portable B-rep data, independent of a modelling backend.
//! Millimetres, radians, right-handed Z-up. See docs/native-brep.md for semantics.
//! Validation checks structure and resource budgets, not geometric equivalence or trust.
//! Validation checks structure and resource budgets; `check_geometry` spot-checks that the
//! geometry agrees with the topology. Neither certifies geometric validity or trust.
//!
//! The records follow SMLib's flat form (`SmBrepData`): one record per logical use, the mated
//! halves a kernel keeps at run time left for it to rebuild. Names are STEP's B-rep names; each
@@ -11,6 +12,7 @@ use serde::{Deserialize, Serialize};
mod curve2;
pub mod envelope;
pub mod eval;
pub mod geometry;
pub mod history;
pub mod pcurve;
mod topology;
@@ -466,7 +468,8 @@ impl Brep {
/// Validate portable structure without calling a kernel or evaluating geometry: resource
/// budgets, geometry records, parameter ranges, references, loops, regions, every edge's
/// list of uses and the canonical order. Does not certify watertightness, that edges lie on
/// their surfaces or that pcurves lift onto their edges.
/// their surfaces or that pcurves lift onto their edges ([`Brep::check_geometry`] spot-checks
/// those).
pub fn validate(&self) -> Result<()> {
require(self.version == VERSION, "unsupported version")?;
tolerance(self.tolerance)?;
+161
View File
@@ -0,0 +1,161 @@
//! Geometric agreement of a record (docs/native-brep.md, "Validation and trust"): what a kernel
//! assumes when it builds topology from the records without checking them itself. Each edge's
//! curve meets its vertices at its interval's ends; each oriented edge's pcurve, lifted through
//! its face's surface, lies on the edge's curve at the interval's ends and middle, and continues
//! the use before it without jumping a period; each vertex loop's vertex lies on its surface.
//!
//! A spot check, not a certificate: a fixed number of evaluations per record (three points of
//! each edge, three of each use, one projection per vertex loop), so the work is linear in the
//! item budget [`Brep::validate`] caps. Every distance is held to the record's own tolerance:
//! an edge's for its pcurves, an edge's and its vertex's together where they meet, a vertex's on
//! its surface. A point that cannot be evaluated is an error, never a panic.
use super::eval::{CurveEval, SurfaceEval};
use super::{Brep, Loop};
use crate::Vec3;
/// Where a record disagrees with itself.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Record {
/// `edges[edge]`.
Edge(u32),
/// `faces[face]`.
Face(u32),
/// `faces[face].loops[loop].edges[position]`.
Use { face: u32, r#loop: u32, position: u32 },
/// `faces[face].loops[loop]`, a vertex loop.
VertexLoop { face: u32, r#loop: u32 },
}
impl std::fmt::Display for Record {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Record::Edge(e) => write!(f, "edge {e}"),
Record::Face(i) => write!(f, "face {i}"),
Record::Use { face, r#loop, position } => write!(f, "face {face} loop {loop} use {position}"),
Record::VertexLoop { face, r#loop } => write!(f, "face {face} loop {loop}"),
}
}
}
/// What disagrees.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum Problem {
/// Two points that should coincide are `gap` mm apart, more than `tolerance`.
#[error("{what} by {gap} mm (tolerance {tolerance} mm)")]
Off { what: &'static str, gap: f64, tolerance: f64 },
/// A pcurve starts `gap` in a periodic parameter from where the use before it ends: on
/// another period.
#[error("pcurve starts {gap} from the end of the use before it, on another period (period {period})")]
Period { gap: f64, period: f64 },
/// A point of the record cannot be evaluated (an unsupported kind, a non-finite value).
#[error("{0}")]
Unevaluable(String),
}
/// A record whose geometry disagrees with its topology, and where.
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
#[error("portable B-rep: {record}: {problem}")]
pub struct Mismatch {
pub record: Record,
pub problem: Problem,
}
type Result<T> = std::result::Result<T, Mismatch>;
fn index(i: usize) -> u32 {
u32::try_from(i).unwrap_or(u32::MAX)
}
fn v3(p: [f64; 3]) -> Vec3 {
Vec3::new(p[0], p[1], p[2])
}
fn unevaluable(record: Record, what: impl std::fmt::Display) -> Mismatch {
Mismatch { record, problem: Problem::Unevaluable(what.to_string()) }
}
/// `gap` within `tolerance` (a non-finite gap never is).
fn within(record: Record, what: &'static str, gap: f64, tolerance: f64) -> Result<()> {
if gap <= tolerance { Ok(()) } else { Err(Mismatch { record, problem: Problem::Off { what, gap, tolerance } }) }
}
const PCURVE_POINTS: [&str; 3] =
["pcurve start lifts off its edge's curve", "pcurve middle lifts off its edge's curve", "pcurve end lifts off its edge's curve"];
impl Brep {
/// Check that the record's geometry agrees with its topology, within its tolerances (module
/// docs). Call on a record [`Brep::validate`] accepted: this checks geometry, not structure,
/// though a malformed record is refused, never a panic.
pub fn check_geometry(&self) -> Result<()> {
// Each edge's curve at its interval's start, middle and end, shared by all its uses.
let mut points = Vec::with_capacity(self.edges.len());
for (i, e) in self.edges.iter().enumerate() {
let at = Record::Edge(index(i));
let curve = CurveEval::new(&e.curve).map_err(|err| unevaluable(at, err))?;
let [t0, t1] = e.interval;
let p = [t0, 0.5 * (t0 + t1), t1].map(|t| curve.point(t).ok_or(t));
let p = match p {
[Ok(a), Ok(m), Ok(z)] => [a, m, z],
[Err(t), ..] | [_, Err(t), _] | [.., Err(t)] => return Err(unevaluable(at, format!("the curve has no point at {t}"))),
};
for (end, what) in [(0, "curve start misses its start vertex"), (1, "curve end misses its end vertex")] {
let v = e.vertices.get(end).and_then(|&v| self.vertices.get(v as usize)).ok_or_else(|| unevaluable(at, "edge vertices"))?;
let q = if end == 0 { p[0] } else { p[2] };
within(at, what, q.dist(v3(v.point)), v.tolerance + e.tolerance)?;
}
points.push(p);
}
for (fi, f) in self.faces.iter().enumerate() {
let face = index(fi);
let surface = SurfaceEval::new(&f.surface).map_err(|err| unevaluable(Record::Face(face), err))?;
let period = surface.domain().period;
for (li, l) in f.loops.iter().enumerate() {
let r#loop = index(li);
match l {
Loop::Vertex { vertex } => {
let at = Record::VertexLoop { face, r#loop };
let v = self.vertices.get(*vertex as usize).ok_or_else(|| unevaluable(at, "vertex reference"))?;
let p = v3(v.point);
let on = surface.project(p, None, f64::INFINITY).and_then(|q| surface.point(q));
let gap = on.map_or(f64::INFINITY, |s| s.dist(p));
within(at, "vertex lies off its face's surface", gap, v.tolerance)?;
}
Loop::Edges { edges } => {
// Where each use starts and ends in (u, v), in the loop's direction.
let mut ends: Vec<[[f64; 2]; 2]> = Vec::with_capacity(edges.len());
for (position, u) in edges.iter().enumerate() {
let at = Record::Use { face, r#loop, position: index(position) };
let i = u.edge as usize;
let (e, p) = self.edges.get(i).zip(points.get(i)).ok_or_else(|| unevaluable(at, "edge reference"))?;
let [t0, t1] = e.interval;
let mut uv = [[0.0; 2]; 3];
for (k, t) in [t0, 0.5 * (t0 + t1), t1].into_iter().enumerate() {
let q = u.pcurve.eval(t).ok_or_else(|| unevaluable(at, format!("the pcurve has no point at {t}")))?;
let s = surface.point(q).ok_or_else(|| unevaluable(at, format!("the surface has no point at {q:?}")))?;
let (Some(c), Some(what), Some(slot)) = (p.get(k), PCURVE_POINTS.get(k), uv.get_mut(k)) else {
return Err(unevaluable(at, "pcurve sample"));
};
within(at, what, s.dist(*c), e.tolerance)?;
*slot = q;
}
ends.push(if u.forward { [uv[0], uv[2]] } else { [uv[2], uv[0]] });
}
// Each use continues the one before it (the last closes the loop), except
// along a direction that collapses there (a pole).
for (position, pair) in ends.iter().enumerate() {
let before = ends.get(position.checked_sub(1).unwrap_or(ends.len().saturating_sub(1)));
let (Some([_, end]), [start, _]) = (before, pair) else { continue };
let pole = surface.pole(*start);
for d in 0..2 {
let (Some(Some(p)), Some(false)) = (period.get(d), pole.get(d)) else { continue };
let gap = (start[d] - end[d]).abs();
if !(gap < 0.5 * p) {
let record = Record::Use { face, r#loop, position: index(position) };
return Err(Mismatch { record, problem: Problem::Period { gap, period: *p } });
}
}
}
}
}
}
}
Ok(())
}
}
+144 -4
View File
@@ -4,9 +4,6 @@ use std::f64::consts::{FRAC_PI_2, TAU};
fn segment(a: f64, b: f64) -> Curve2 {
Curve2::Spline { knots: vec![a, a, b, b], points: vec![[a, 0.], [b, 1.]] }
}
fn plane() -> Surface {
Surface::Plane { origin: [0.; 3], u_axis: [1., 0., 0.], v_axis: [0., 1., 0.] }
}
fn a_frame() -> Frame {
Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 1., 0.] }
}
@@ -28,6 +25,34 @@ fn list_uses(b: &mut Brep) {
}
}
/// Every oriented edge's pcurve from its face's surface and its edge (`pcurve::pcurves`).
fn fit_pcurves(b: &mut Brep) {
for f in &mut b.faces {
let loops: Vec<Vec<pcurve::EdgeUse<'_>>> = f
.loops
.iter()
.map(|l| match l {
Loop::Edges { edges } => edges
.iter()
.map(|u| {
let e = &b.edges[u.edge as usize];
pcurve::EdgeUse { edge: u.edge, curve: &e.curve, interval: e.interval, forward: u.forward }
})
.collect(),
Loop::Vertex { .. } => vec![],
})
.collect();
let fitted = pcurve::pcurves(&f.surface, &loops, 1e-7).unwrap();
for (l, pcs) in f.loops.iter_mut().zip(fitted) {
if let Loop::Edges { edges } = l {
for (u, pc) in edges.iter_mut().zip(pcs) {
u.pcurve = pc;
}
}
}
}
}
/// Planar faces, one per vertex cycle, grouped into shells; edges and vertices numbered in
/// canonical order as the cycles first meet them.
fn faceted(points: &[[f64; 3]], shells: &[&[&[usize]]], regions: Vec<Region>) -> Brep {
@@ -65,11 +90,16 @@ fn faceted(points: &[[f64; 3]], shells: &[&[&[usize]]], regions: Vec<Region>) ->
loop_.push(OrientedEdge { edge, forward, pcurve: segment(0., 1.) });
}
uses.push(OrientedFace { face: b.faces.len() as u32, forward: true });
b.faces.push(Face { surface: plane(), domain: [[-10., 10.], [-10., 10.]], loops: vec![Loop::Edges { edges: loop_ }] });
// The plane through the cycle's first three points: its natural normal is the cycle's.
let [o, p, q] = [0, 1, 2].map(|k| points[cycle[k]]);
let axis = |a: [f64; 3]| [a[0] - o[0], a[1] - o[1], a[2] - o[2]];
let surface = Surface::Plane { origin: o, u_axis: axis(p), v_axis: axis(q) };
b.faces.push(Face { surface, domain: [[-10., 10.], [-10., 10.]], loops: vec![Loop::Edges { edges: loop_ }] });
}
b.shells.push(Shell::Faces { faces: uses, wires: vec![] });
}
list_uses(&mut b);
fit_pcurves(&mut b);
b
}
@@ -127,6 +157,7 @@ fn tube() -> Brep {
vertices: vec![Vertex { point: [3., 2., 3.], tolerance: 1e-7 }, Vertex { point: [3., 2., 8.], tolerance: 1e-7 }],
};
list_uses(&mut b);
fit_pcurves(&mut b);
b
}
/// The sheet with a vertex loop inside it, a wire shell and a vertex shell beside it.
@@ -153,6 +184,7 @@ fn sheet_with_lower_forms() -> Brep {
fn every_form_validates_and_round_trips_without_a_kernel() {
for (name, body) in [("sheet", sheet()), ("closed", tetra()), ("void", hollow()), ("seam", tube()), ("lower forms", sheet_with_lower_forms())] {
body.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
body.check_geometry().unwrap_or_else(|e| panic!("{name}: {e}"));
let bytes = serde_json::to_vec(&body).unwrap();
let restored: Brep = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body, restored, "{name}");
@@ -383,6 +415,114 @@ fn one_defect_fixtures_are_refused() {
}
}
fn pcurve_of(b: &mut Brep, face: usize, position: usize) -> &mut Curve2 {
let Loop::Edges { edges } = &mut b.faces[face].loops[0] else { panic!("an edge loop") };
&mut edges[position].pcurve
}
fn shift(c: &mut Curve2, by: [f64; 2]) {
match c {
Curve2::Spline { points, .. } => points.iter_mut().for_each(|p| *p = [p[0] + by[0], p[1] + by[1]]),
Curve2::RationalSpline { homogeneous, .. } => homogeneous.iter_mut().for_each(|h| *h = [h[0] + by[0] * h[2], h[1] + by[1] * h[2], h[2]]),
}
}
/// One geometric defect each, structurally valid: every one is refused, naming the record.
#[test]
fn geometric_defects_are_refused_by_record() {
use geometry::{Problem, Record};
let off = |what| move |p: &Problem| matches!(p, Problem::Off { what: w, .. } if *w == what);
let cases: Vec<(&str, Record, Box<dyn Fn(&Problem) -> bool>, Brep)> = vec![
("vertex off its curve's start", Record::Edge(0), Box::new(off("curve start misses its start vertex")), {
let mut b = sheet();
b.vertices[0].point = [0., 0., 0.01];
b
}),
("vertex off its curve's end", Record::Edge(0), Box::new(off("curve end misses its end vertex")), {
let mut b = sheet();
b.edges[0].curve = Curve::Line { start: SQUARE[0], end: [1., 0., 0.01] };
b
}),
("pcurve shifted off its edge", Record::Use { face: 0, r#loop: 0, position: 1 }, Box::new(off("pcurve start lifts off its edge's curve")), {
let mut b = sheet();
shift(pcurve_of(&mut b, 0, 1), [0., 0.1]);
b
}),
("pcurve bowed off its edge", Record::Use { face: 0, r#loop: 0, position: 0 }, Box::new(off("pcurve middle lifts off its edge's curve")), {
let mut b = sheet();
let pc = pcurve_of(&mut b, 0, 0);
let (a, z) = (pc.eval(0.).unwrap(), pc.eval(1.).unwrap());
let middle = [0.5 * (a[0] + z[0]), 0.5 * (a[1] + z[1]) + 0.1];
*pc = Curve2::Spline { knots: vec![0., 0., 0., 1., 1., 1.], points: vec![a, middle, z] };
b
}),
(
"seam pcurve on the wrong period",
Record::Use { face: 0, r#loop: 0, position: 1 },
Box::new(|p: &Problem| matches!(p, Problem::Period { .. })),
{
let mut b = tube();
shift(pcurve_of(&mut b, 0, 1), [TAU, 0.]);
b
},
),
("vertex loop off its surface", Record::VertexLoop { face: 0, r#loop: 1 }, Box::new(off("vertex lies off its face's surface")), {
let mut b = sheet_with_lower_forms();
b.vertices[6].point[2] = 0.01;
b
}),
];
for (name, record, problem, body) in cases {
body.validate().unwrap_or_else(|e| panic!("{name}: structurally valid: {e}"));
let error = body.check_geometry().err().unwrap_or_else(|| panic!("{name} was accepted"));
assert_eq!(error.record, record, "{name}: {error}");
assert!(problem(&error.problem), "{name}: {error}");
}
}
/// Records `validate` would refuse, checked anyway: errors, never panics.
#[test]
fn hostile_records_never_panic_the_geometry_check() {
let mut cases: Vec<Brep> = Vec::new();
let mut b = sheet();
b.vertices[0].point = [f64::NAN; 3];
cases.push(b);
let mut b = sheet();
b.edges[0].vertices = [u32::MAX; 2];
cases.push(b);
let mut b = sheet();
if let Loop::Edges { edges } = &mut b.faces[0].loops[0] {
edges[2].edge = u32::MAX;
}
cases.push(b);
let mut b = sheet();
shift(pcurve_of(&mut b, 0, 0), [f64::NAN, 0.]);
cases.push(b);
let mut b = sheet();
*pcurve_of(&mut b, 0, 3) = Curve2::Spline { knots: vec![], points: vec![] };
cases.push(b);
let mut b = sheet();
b.edges[1].interval = [f64::NEG_INFINITY, f64::INFINITY];
cases.push(b);
let mut b = sheet_with_lower_forms();
b.faces[0].loops[1] = Loop::Vertex { vertex: u32::MAX };
cases.push(b);
let mut b = tube();
b.faces[0].surface = Surface::Cylinder { frame: a_frame(), radius: f64::NAN };
cases.push(b);
let mut b = tube();
b.edges[0].tolerance = f64::NAN;
cases.push(b);
let mut b = sheet();
let circle = Box::new(Curve::Circle { frame: a_frame(), radius: 1. });
let identity = [[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]];
b.faces[0].surface = Surface::PlacedRevolution { profile: circle, origin: [0.; 3], axis: [0., 0., 1.], matrix: identity, forward: true };
cases.push(b);
for (i, b) in cases.iter().enumerate() {
let checked = std::panic::catch_unwind(|| b.check_geometry());
assert!(matches!(checked, Ok(Err(_))), "case {i}: {checked:?}");
}
}
#[test]
fn a_reversed_face_is_walked_backwards() {
// The sheet seen from below: its first (only) side is reversed, so the canonical walk