diff --git a/crates/kernel/src/native.rs b/crates/kernel/src/native.rs index 7c06d3d915..0baffb2c91 100644 --- a/crates/kernel/src/native.rs +++ b/crates/kernel/src/native.rs @@ -102,6 +102,13 @@ pub fn smlib_body(body: &Body) -> Result { 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 { + 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 { diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index e1de28e87c..f3fa0960dd 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -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::>(); 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]) } + ); } } diff --git a/crates/step/src/topology.rs b/crates/step/src/topology.rs index 1c9d03dda9..d6ac9dd71c 100644 --- a/crates/step/src/topology.rs +++ b/crates/step/src/topology.rs @@ -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> = 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)); diff --git a/crates/step/src/topology_tests.rs b/crates/step/src/topology_tests.rs index de09305148..702815d17d 100644 --- a/crates/step/src/topology_tests.rs +++ b/crates/step/src/topology_tests.rs @@ -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`.