brep: check_geometry refuses loops that enclose no area or turn the wrong way

check_geometry compared pcurve ends with edge points only, so a loop whose
pcurves enclose nothing passed whenever its uses lift onto the same 3D points:
SMLib's hole-rim fillet with both seam uses on u = 0, or a whole sphere whose
pole-to-pole seam has both uses on one line (the continuity check skips the
pole). Such a face has no surface and meshes to nothing.

Each edge loop's signed (u, v) area is now taken over 16 points of each use's
pcurve: a loop within 1e-9 of the square of its extent of zero is refused
(Problem::Empty), and each face must have exactly one counter-clockwise loop
about the natural normal, the rest clockwise (Problem::Turn). Outer and inner
are told apart by area rather than position, because the truck adapter writes a
face's wires in truck's order, which puts a hole first on some planar faces of
the sample designs (the model cache test caught it).

Fixtures: the pole-to-pole seam on one line, and a sheet whose plane axes are
swapped so its loop runs clockwise; a seamed sphere passes.

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 02:55:17 -04:00
parent 27e996c44d
commit 204b650d9f
3 changed files with 118 additions and 6 deletions
+66 -4
View File
@@ -2,11 +2,13 @@
//! 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.
//! the use before it without jumping a period; each edge loop's pcurves enclose (u, v) area,
//! one loop of each face counter-clockwise about the natural normal (the outer) and the rest
//! clockwise; 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:
//! each edge, [`AREA_SAMPLES`] 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};
@@ -24,6 +26,8 @@ pub enum Record {
Use { face: u32, r#loop: u32, position: u32 },
/// `faces[face].loops[loop]`, a vertex loop.
VertexLoop { face: u32, r#loop: u32 },
/// `faces[face].loops[loop]`, an edge loop.
Loop { face: u32, r#loop: u32 },
}
impl std::fmt::Display for Record {
@@ -32,7 +36,7 @@ impl std::fmt::Display for Record {
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}"),
Record::VertexLoop { face, r#loop } | Record::Loop { face, r#loop } => write!(f, "face {face} loop {loop}"),
}
}
}
@@ -47,6 +51,15 @@ pub enum Problem {
/// another period.
#[error("pcurve starts {gap} from the end of the use before it, on another period (period {period})")]
Period { gap: f64, period: f64 },
/// An edge loop's pcurves enclose no (u, v) area, within rounding of the loop's size: its
/// face has no surface to it (a seam's two uses on one parameter line).
#[error("loop encloses no (u, v) area (signed area {area})")]
Empty { area: f64 },
/// An edge loop runs the wrong way round in (u, v), seen from the surface's natural normal:
/// no loop of the face runs counter-clockwise (`outer`: the largest runs clockwise), or a
/// second one does (an inner loop, the smaller of them).
#[error("{} loop runs {} in (u, v) (signed area {area})", if *outer { "outer" } else { "inner" }, if *outer { "clockwise" } else { "counter-clockwise" })]
Turn { outer: bool, area: f64 },
/// A point of the record cannot be evaluated (an unsupported kind, a non-finite value).
#[error("{0}")]
Unevaluable(String),
@@ -76,6 +89,11 @@ fn within(record: Record, what: &'static str, gap: f64, tolerance: f64) -> Resul
if gap <= tolerance { Ok(()) } else { Err(Mismatch { record, problem: Problem::Off { what, gap, tolerance } }) }
}
/// Points of each use's pcurve that its loop's (u, v) area is taken over.
pub const AREA_SAMPLES: usize = 16;
/// Signed area this small against the square of the loop's (u, v) extent is none.
const EMPTY_AREA: f64 = 1e-9;
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"];
@@ -106,6 +124,8 @@ impl Brep {
let face = index(fi);
let surface = SurfaceEval::new(&f.surface).map_err(|err| unevaluable(Record::Face(face), err))?;
let period = surface.domain().period;
// Each edge loop's signed (u, v) area.
let mut areas: Vec<(u32, f64)> = Vec::with_capacity(f.loops.len());
for (li, l) in f.loops.iter().enumerate() {
let r#loop = index(li);
match l {
@@ -152,10 +172,52 @@ impl Brep {
}
}
}
// The area the loop encloses, its sign by its place (outer first). Where
// uses meet at a pole the ring runs straight along the pole's line.
let at = Record::Loop { face, r#loop };
let mut ring: Vec<[f64; 2]> = Vec::with_capacity(edges.len().saturating_mul(AREA_SAMPLES));
for u in edges {
let [t0, t1] = self.edges.get(u.edge as usize).ok_or_else(|| unevaluable(at, "edge reference"))?.interval;
for k in 0..AREA_SAMPLES {
let f = k as f64 / AREA_SAMPLES as f64;
let t = if u.forward { t0 + (t1 - t0) * f } else { t1 - (t1 - t0) * f };
ring.push(u.pcurve.eval(t).ok_or_else(|| unevaluable(at, format!("the pcurve has no point at {t}")))?);
}
}
let area = signed_area(&ring);
let extent = (0..2)
.map(|d| {
let (lo, hi) = ring.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), q| (a.min(q[d]), b.max(q[d])));
hi - lo
})
.fold(0.0, f64::max);
if !(area.abs() > EMPTY_AREA * extent * extent) {
return Err(Mismatch { record: at, problem: Problem::Empty { area } });
}
areas.push((r#loop, area));
}
}
}
// One loop runs counter-clockwise, the outer; every other one clockwise. Which is
// which comes from the areas, not the loops' order: the truck adapter writes a face's
// wires in truck's order, which need not put the outer one first.
let by_size = |a: &(u32, f64), b: &(u32, f64)| a.1.abs().total_cmp(&b.1.abs());
let ccw: Vec<(u32, f64)> = areas.iter().copied().filter(|a| a.1 > 0.0).collect();
let wrong = match ccw.len() {
0 => areas.into_iter().max_by(by_size).map(|(l, area)| (l, true, area)),
1 => None,
_ => ccw.into_iter().min_by(by_size).map(|(l, area)| (l, false, area)),
};
if let Some((r#loop, outer, area)) = wrong {
return Err(Mismatch { record: Record::Loop { face, r#loop }, problem: Problem::Turn { outer, area } });
}
}
Ok(())
}
}
/// Signed area of a closed polygon in (u, v): positive counter-clockwise.
fn signed_area(ring: &[[f64; 2]]) -> f64 {
let n = ring.len();
0.5 * (0..n).filter_map(|i| Some((ring.get(i)?, ring.get((i + 1) % n)?))).map(|(p, q)| p[0] * q[1] - q[0] * p[1]).sum::<f64>()
}
+47
View File
@@ -201,6 +201,19 @@ fn closed_band() -> Brep {
&[(0, true), (1, true), (2, false), (1, false)],
)
}
/// A whole sphere bounded by one meridian from pole to pole, used twice: a closed shell.
fn ball() -> Brep {
let meridian = Curve::Circle { frame: Frame { origin: [1., 2., 3.], x_axis: [1., 0., 0.], y_axis: [0., 0., 1.] }, radius: 2. };
let mut b = one_face(
Surface::Sphere { frame: a_frame(), radius: 2. },
[[0., TAU], [-FRAC_PI_2, FRAC_PI_2]],
vec![(meridian, [-FRAC_PI_2, FRAC_PI_2], [0, 1])],
&[[1., 2., 1.], [1., 2., 5.]],
&[(0, true), (0, false)],
);
b.regions.push(Region { shells: vec![0], void: false });
b
}
/// The sheet with a vertex loop inside it, a wire shell and a vertex shell beside it.
fn sheet_with_lower_forms() -> Brep {
let mut b = sheet();
@@ -229,6 +242,7 @@ fn every_form_validates_and_round_trips_without_a_kernel() {
("void", hollow()),
("seam", tube()),
("seam on a closed spline", closed_band()),
("seam from pole to pole", ball()),
("lower forms", sheet_with_lower_forms()),
] {
body.validate().unwrap_or_else(|e| panic!("{name}: {e}"));
@@ -528,6 +542,39 @@ fn geometric_defects_are_refused_by_record() {
b
},
),
(
"seam from pole to pole on one line",
Record::Loop { face: 0, r#loop: 0 },
Box::new(|p: &Problem| {
matches!(p, Problem::Empty { area } if area.abs() < 1e-9) && p.to_string().starts_with("loop encloses no (u, v) area (signed area ")
}),
{
let mut b = ball();
let mid = |b: &mut Brep, k: usize| pcurve_of(b, 0, k).eval(0.).unwrap()[0];
let by = mid(&mut b, 0) - mid(&mut b, 1);
assert!((by.abs() - TAU).abs() < 1e-9, "a period apart: {by}");
shift(pcurve_of(&mut b, 0, 1), [by, 0.]);
b
},
),
(
"outer loop clockwise",
Record::Loop { face: 0, r#loop: 0 },
Box::new(|p: &Problem| {
matches!(p, Problem::Turn { outer: true, area } if (*area + 1.).abs() < 1e-12)
&& p.to_string() == "outer loop runs clockwise in (u, v) (signed area -1)"
}),
{
// The plane's axes swapped turn its natural normal over: the loop, unchanged in
// space, runs clockwise about it.
let mut b = sheet();
if let Surface::Plane { u_axis, v_axis, .. } = &mut b.faces[0].surface {
std::mem::swap(u_axis, v_axis);
}
fit_pcurves(&mut b);
b
},
),
];
for (name, record, problem, body) in cases {
body.validate().unwrap_or_else(|e| panic!("{name}: structurally valid: {e}"));
+5 -2
View File
@@ -201,8 +201,11 @@ ends (within the edge's and the vertex's tolerances together); every oriented
edge's pcurve, lifted through its face's surface, lies on the edge's curve at the
interval's ends and middle (within the edge's) and starts on the same period as
the use before it ends, except along a direction that collapses at a pole; every
vertex loop's vertex lies on its face's surface (within the vertex's). It makes a
fixed number of evaluations per record, so its work is linear in the item
edge loop's pcurves enclose (u, v) area (more than 10⁻⁹ of the square of the
loop's extent), and exactly one loop of each face, the outer, runs
counter-clockwise about the natural normal, the rest clockwise (told apart by
their areas, not their order); every vertex loop's vertex lies on its face's
surface (within the vertex's). It makes a fixed number of evaluations per record, so its work is linear in the item
budget, and a point it cannot evaluate is an error naming the record. It is not
a certificate either: between the points it checks, an edge may still leave its
surface. The truck adapter runs it before building topology, which truck does