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