mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
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:
@@ -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)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user