From 455ca86a6c30fa56daf37637c6737059c80b56dd Mon Sep 17 00:00:00 2001 From: NOWN-DEV <257771410+NOWN-DEV@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:27:09 +0200 Subject: [PATCH] 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 --- crates/kernel/src/body_lump_tests.rs | 4 +- crates/kernel/src/step_out.rs | 101 ++++++++++++++++++++++----- 2 files changed, 85 insertions(+), 20 deletions(-) diff --git a/crates/kernel/src/body_lump_tests.rs b/crates/kernel/src/body_lump_tests.rs index 1480aea7..83a355b6 100644 --- a/crates/kernel/src/body_lump_tests.rs +++ b/crates/kernel/src/body_lump_tests.rs @@ -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)] { diff --git a/crates/kernel/src/step_out.rs b/crates/kernel/src/step_out.rs index 64870acc..b773caa9 100644 --- a/crates/kernel/src/step_out.rs +++ b/crates/kernel/src/step_out.rs @@ -1214,6 +1214,39 @@ fn brep_exchange(b: &Body, merge: bool) -> Result<(p21::Exchange, HashMap Vec { + 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 = - 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 = + 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::>().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");