solidcraft-step: refuse loops across a closed spline surface's seam; conformance parts and truck volumes

A face winding around a closed (non-periodic) spline surface without a seam edge has loops
that jump across the surface's domain; it is refused for now instead of making a record whose
loops do not close. The step-conformance parts are read in a test, and the kernel's ignored
corpus comparison builds each record back into a truck solid to compare volumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brennen Herbruck
2026-10-21 01:50:04 -04:00
committed by jyviko
parent 3d7e465b16
commit 91e482fc8a
4 changed files with 87 additions and 6 deletions
+7
View File
@@ -102,6 +102,13 @@ pub fn smlib_body(body: &Body) -> Result<solidcraft_smlib::Brep> {
solidcraft_smlib::Brep::from_record(&record, true).map_err(|e| KernelError::Failed(format!("SMLib: {e}")))
}
/// The truck solid of a schema 4 record, as a saved body's is built: tests compare records
/// made elsewhere (the STEP reader's) with truck's own import.
#[cfg(test)]
pub(crate) fn solid_of_record(record: solidcraft_geom::brep::Brep) -> Result<truck_modeling::Solid> {
guard("decode a B-rep record", || adapter::decode(record))
}
/// Decode bounded untrusted data. Validate indices before topology extraction.
/// Only a caller that has checked who produced the data may treat it as evaluated geometry.
pub fn decode_native_body(bytes: &[u8]) -> Result<Body> {
+30 -6
View File
@@ -1821,16 +1821,19 @@ fn step_conformance_parts_read_back_their_exact_shape() {
}
}
/// Each STEP file in `SOLIDCRAFT_STEP_CORPUS`: bodies, faces and edges as truck imports it and
/// as `solidcraft_step::topology` reads it into schema 4 records. Counts may differ where truck
/// splits faces (bands, balls) or edges; this reports, it does not judge. Run with
/// `--ignored --nocapture`.
/// Each STEP file in `SOLIDCRAFT_STEP_CORPUS`: bodies, faces, edges and volume as truck
/// imports it and as `solidcraft_step::topology` reads it into schema 4 records (built back into
/// truck solids for their volume, signed: negative means faces turned into the material).
/// Counts may differ where truck splits faces (bands, balls) or edges and where schema 4 adds
/// seams; volumes are coarse meshes' (1% of the size). This reports, it does not judge. Run
/// with `--ignored --nocapture`.
#[test]
#[ignore = "needs SOLIDCRAFT_STEP_CORPUS"]
fn corpus_truck_and_schema_4_counts() {
let Ok(dir) = std::env::var("SOLIDCRAFT_STEP_CORPUS") else { return };
let mut paths: Vec<_> = std::fs::read_dir(&dir).into_iter().flatten().flatten().map(|e| e.path()).collect();
paths.sort();
let volume = |s: &mt::Solid| s.boundaries().iter().map(crate::body::shell_volume).sum::<Option<f64>>();
for path in paths.iter().filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("step") || e.eq_ignore_ascii_case("stp"))) {
let text = std::fs::read_to_string(path).unwrap_or_default();
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
@@ -1840,7 +1843,8 @@ fn corpus_truck_and_schema_4_counts() {
let mut seen = HashSet::new();
let once: Vec<&ImportedBody> = imp.bodies.iter().filter(|b| seen.insert(b.source_item)).collect();
let (f, e) = once.iter().fold((0, 0), |(f, e), b| (f + b.body.face_count(), e + b.body.edge_count()));
format!("{} bodies, {f} faces, {e} edges", once.len())
let v: f64 = once.iter().filter_map(|b| volume(&b.body.solid)).sum();
format!("{} bodies, {f} faces, {e} edges, volume {v:.4}", once.len())
}
Err(e) => format!("failed: {e}"),
};
@@ -1853,6 +1857,26 @@ fn corpus_truck_and_schema_4_counts() {
};
let r = solidcraft_step::topology::read_solids(&ex);
let (f, e) = r.solids.iter().fold((0, 0), |(f, e), s| (f + s.brep.faces.len(), e + s.brep.edges.len()));
println!("{name}: truck {truck}; schema 4 {} solids ({} refused), {f} faces, {e} edges", r.solids.len(), r.failures.len());
let (mut v, mut unbuilt) = (0.0, Vec::new());
for s in &r.solids {
// Truck holds a plane by three points: the same plane, within a rounding.
let mut record = s.brep.clone();
for f in &mut record.faces {
if let solidcraft_geom::brep::Surface::Plane { origin, u_axis, v_axis } = f.surface {
let at = |d: [f64; 3]| [origin[0] + d[0], origin[1] + d[1], origin[2] + d[2]];
f.surface = solidcraft_geom::brep::Surface::PlaneThrough { origin, u_point: at(u_axis), v_point: at(v_axis) };
}
}
match crate::native::solid_of_record(record) {
Ok(solid) => v += volume(&solid).unwrap_or(f64::NAN),
Err(err) => unbuilt.push(err.to_string()),
}
}
println!(
"{name}: truck {truck}; schema 4 {} solids ({} refused), {f} faces, {e} edges, volume {v:.4}{}",
r.solids.len(),
r.failures.len(),
if unbuilt.is_empty() { String::new() } else { format!(" ({} not built by truck: {})", unbuilt.len(), unbuilt[0]) }
);
}
}
+25
View File
@@ -913,6 +913,31 @@ impl<'b, 'a> Builder<'b, 'a> {
}
// Signed areas of the loops that close in (u, v).
let periods = natural_periods(&surface);
// Each use starts near where the one before it ends, along a bounded direction that
// does not wrap: a loop around a closed spline surface without a seam edge jumps across
// its domain.
let natural = surface.natural();
for (l, pc) in edge_loops.iter().zip(&pcs) {
let mut ends = Vec::with_capacity(l.len());
for (&(_, forward), c) in l.iter().zip(pc) {
let [a, b] = c.interval().ok_or("pcurve interval")?;
let (s, e) = (c.eval(a).ok_or("pcurve start")?, c.eval(b).ok_or("pcurve end")?);
ends.push(if forward { (s, e) } else { (e, s) });
}
for k in 0..ends.len() {
let (Some((start, _)), Some((_, end))) = (ends.get(k), ends.get(if k == 0 { ends.len() - 1 } else { k - 1 })) else { continue };
for d in 0..2 {
// Only a bounded direction has a seam to jump; half its width is no gap.
let Some(Natural::Range(a, b)) = natural.get(d) else { continue };
if !(b - a).is_finite() {
continue;
}
if !((start[d] - end[d]).abs() <= 0.5 * (b - a)) {
return Err(unsupported("a loop across a closed spline surface's seam without a seam edge"));
}
}
}
}
let shapes: Vec<Option<f64>> = edge_loops.iter().zip(&pcs).map(|(l, pc)| loop_area(l, pc, periods)).collect();
let face = self.faces.get_mut(fi).ok_or("face reference")?;
let marked = face.outer.and_then(|o| positions.iter().position(|p| *p == o));
+25
View File
@@ -792,6 +792,31 @@ fn hostile_topology_is_an_error_never_a_panic() {
}
}
/// The step-conformance parts (tests/step-conformance, CC0): every one reads, its faces facing
/// out of its material, except the NURBS cylinder, dome and torus whose faces wind around a
/// closed spline surface without a seam edge (refused for now).
#[test]
fn step_conformance_parts() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/step-conformance");
let mut paths: Vec<_> =
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "step")).collect();
paths.sort();
assert_eq!(paths.len(), 17);
for path in paths {
let name = path.file_stem().unwrap().to_string_lossy().into_owned();
let r = read_solids(&p21::parse(&std::fs::read_to_string(&path).unwrap()).unwrap());
if name.ends_with("_nurbs") {
assert!(r.solids.is_empty() && r.failures[0].message.contains("closed spline surface's seam"), "{name}: {:?}", r.failures);
continue;
}
assert!(r.failures.is_empty() && r.solids.len() == 1, "{name}: {:?}", r.failures);
let b = &r.solids[0].brep;
b.validate().unwrap();
b.check_geometry().unwrap();
assert_eq!(b.regions.len(), 2, "{name}");
}
}
/// Every solid of every STEP file in `SOLIDCRAFT_STEP_CORPUS` (the tangency-labs/models files)
/// read into schema 4: solids read and refused (by reason), faces, edges, time. Run with
/// `--ignored --nocapture`.