fix: write each lump of a body as its own STEP solid (#455)

A body of several pieces (a plate and the islands a cut leaves inside its
holes, such as letter counters) was written as one BREP_WITH_VOIDS whose
other shells were voids. Export now writes one solid per lump, each with
its face colours; a real void stays a void of its lump.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
NOWN-DEVandClaude Opus 5.5 authored and GitHub committed 2026-10-12 20:27:09 -04:00
1 parent 6fde5ff55b
commit 455ca86a6c
2 files changed
+85 -20

No files matched your search

+3 -1
View File
@@ -115,7 +115,9 @@ fn curved_internal_void_and_disconnected_material_keep_analytic_volume() {
}
let imported = step_import(&step_export(&[&body], "curved nested cavities").unwrap()).unwrap();
assert!(imported.warnings.is_empty(), "{:?}", imported.warnings);
let imported: Vec<_> = imported.bodies.into_iter().flat_map(|b| b.body.lumps().unwrap()).collect();
// The two pieces are written as two solids (each with its own void), so they come back as
// two bodies.
let imported: Vec<_> = imported.bodies.into_iter().map(|b| b.body).collect();
assert_eq!(imported.len(), 2);
let imported_meshes: Vec<_> = imported.iter().map(|b| b.tessellate_with(0.01, true).unwrap()).collect();
for (p, want) in [(Vec3::ZERO, 0), (Vec3::X * 3.0, 1), (Vec3::X * 7.0, 0), (Vec3::X * 15.0, 1)] {
+82 -19
View File
@@ -1214,6 +1214,39 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap<u64, V
Ok((ex, merged, paint))
}
/// The separate pieces of a body for export, each keeping its face colours: a body of several
/// lumps (a plate and the islands a cut left inside its holes, such as letter counters) is
/// several solids, never one solid whose other pieces are written as voids. A body in one
/// piece, or one whose pieces cannot be told apart, is itself.
fn export_lumps(b: &Body) -> Vec<Body> {
if b.mesh.is_some() || b.solid.boundaries().len() < 2 {
return vec![b.clone()];
}
let lumps = match b.lumps() {
Ok(l) if l.len() > 1 => l,
_ => return vec![b.clone()],
};
let index: HashMap<_, usize> = b.solid.face_iter().enumerate().map(|(i, f)| (f.id(), i)).collect();
lumps
.into_iter()
.map(|lump| {
let paint = b.paint().map(|p| crate::body::Paint {
opacity: p.opacity,
faces: lump
.solid
.face_iter()
.enumerate()
.filter_map(|(k, f)| {
let i = index.get(&f.id())?;
p.faces.iter().find(|fp| fp.face == *i).map(|fp| crate::body::FacePaint { face: k, ..fp.clone() })
})
.collect(),
});
lump.with_color(b.color()).with_split_lines(b.split_lines().cloned()).with_paint(paint)
})
.collect()
}
/// Faces in the exchange's shells (fewer after merging).
fn shell_face_count(ex: &p21::Exchange) -> usize {
ex.entities
@@ -1366,26 +1399,28 @@ fn products_text(products: &[ExportProduct], root: usize, header: &StepHeader, m
if !p.bodies.is_empty() {
let mut solids = Vec::new();
for b in &p.bodies {
let (ex, merged, face_paint) = brep_exchange(b.body, merge)?;
o.merged = merged;
o.faces_out.clear();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
roots.sort();
for r in roots {
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
solids.push(id);
let opacity = b.body.paint().map(|p| p.opacity).unwrap_or(1.0);
if let Some(c) = b.color {
styled.push(style(&mut o, id, c, opacity));
for lump in export_lumps(b.body) {
let (ex, merged, face_paint) = brep_exchange(&lump, merge)?;
o.merged = merged;
o.faces_out.clear();
let mut roots: Vec<u64> =
ex.entities.iter().filter(|(_, e)| matches!(e.name(), "MANIFOLD_SOLID_BREP" | "BREP_WITH_VOIDS")).map(|(i, _)| *i).collect();
roots.sort();
for r in roots {
let id = o.copy_solid(&ex, r, &b.name).map_err(|e| KernelError::Failed(format!("STEP export: {e}")))?;
solids.push(id);
let opacity = lump.paint().map(|p| p.opacity).unwrap_or(1.0);
if let Some(c) = b.color {
styled.push(style(&mut o, id, c, opacity));
}
}
// Faces with colours of their own.
let mut painted: Vec<(u64, ([f32; 3], f32))> =
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
painted.sort_by_key(|(f, _)| *f);
for (f, (c, op)) in painted {
styled.push(style(&mut o, f, c, op));
}
}
// Faces with colours of their own.
let mut painted: Vec<(u64, ([f32; 3], f32))> =
face_paint.into_iter().filter_map(|(f, look)| Some((*o.faces_out.get(&f)?, look))).collect();
painted.sort_by_key(|(f, _)| *f);
for (f, (c, op)) in painted {
styled.push(style(&mut o, f, c, op));
}
}
let list = solids.iter().chain(std::iter::once(&origin)).map(|i| format!("#{i}")).collect::<Vec<_>>().join(",");
@@ -1484,6 +1519,34 @@ pub fn step_export_bodies(bodies: &[ExportBody], header: &StepHeader) -> Result<
mod tests {
use super::*;
#[test]
fn islands_are_solids_of_their_own_not_voids() {
use solidcraft_geom::{Loop2, Plane, Region2, Vec2};
let square = |a: f64, b: f64| Loop2::polygon(&[Vec2::new(a, a), Vec2::new(b, a), Vec2::new(b, b), Vec2::new(a, b)]);
// A plate with a square hole, and an island inside the hole (a letter's counter).
let plate = Region2 { outer: square(0.0, 30.0), holes: vec![square(10.0, 20.0).reversed()] };
let island = Region2 { outer: square(12.0, 18.0), holes: vec![] };
let body = crate::extrude_planar_regions(&Plane::XY, &[plate, island], 0.0, 2.0).unwrap();
assert_eq!(body.shell_count(), 2);
let text = crate::step_export(&[&body], "test").unwrap();
assert_eq!(text.matches("MANIFOLD_SOLID_BREP(").count(), 2, "{text}");
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 0);
let imp = crate::step_import(&text).unwrap();
let v: f64 = imp.bodies.iter().map(|b| crate::measure(&b.body).unwrap().volume).sum();
assert!((v - (800.0 + 36.0) * 2.0).abs() < 1e-6, "{v}");
// A real void stays a void.
let hollow = crate::boolean(
&crate::box_solid(Vec3::ZERO, Vec3::new(10.0, 10.0, 10.0)).unwrap(),
&crate::box_solid(Vec3::new(3.0, 3.0, 3.0), Vec3::new(7.0, 7.0, 7.0)).unwrap(),
crate::BoolOp::Cut,
)
.unwrap()
.unwrap();
assert_eq!(hollow.shell_count(), 2);
let text = crate::step_export(&[&hollow], "test").unwrap();
assert_eq!(text.matches("BREP_WITH_VOIDS").count(), 1, "{text}");
}
#[test]
fn numbers_and_strings() {
assert_eq!(real(1.0), "1.0");