From cfee4a72085dfe64032d74100e8dab37545201c6 Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 28 Oct 2026 07:35:18 -0400 Subject: [PATCH] STEP import: a solid no B-rep can be made of is shown as a mesh of the faces that read When the reader or SMLib refuses a solid, each of its faces is read on its own as a one-face surface body (solidcraft_step::topology::read_faces_with) and meshed by the planner; the triangles together are a mesh body (the existing kind STL/3MF imports make), marked ImportedBody::shown_as_mesh with the refusal. The report says 'shown as a mesh: ' and names each face left out. Modelling operations refuse it as any mesh body. Co-Authored-By: Claude Opus 5.5 --- crates/kernel/src/step_in/mod.rs | 10 ++- crates/kernel/src/step_in/parallel.rs | 53 ++++++++++- crates/kernel/src/step_in/tests.rs | 58 ++++++++++++ crates/step/src/topology.rs | 122 ++++++++++++++++++-------- 4 files changed, 202 insertions(+), 41 deletions(-) diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index b92d8a0f2f..290644b3c8 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -55,6 +55,10 @@ pub struct ImportedBody { pub closed: bool, /// Faces read from the file (each body face is one of them: `face_origin`). pub file_faces: usize, + /// No B-rep could be made of the solid: `body` is a mesh body of the faces that could be + /// read and meshed on their own, shown so the solid is not lost, and this is why the solid + /// was refused (the import's warnings say so too). + pub shown_as_mesh: Option, } /// One product occurrence in the file's assembly tree. @@ -715,10 +719,13 @@ impl Reader { // What the read of the item found, reported once. if let Some(failed) = read { let label = self.label(item); + if let Some(why) = &part.refused { + self.warn(format!("{label}: shown as a mesh: {why}")); + } for f in &failed { self.warn(format!("{label}: {f}")); } - if !part.closed { + if !part.closed && part.refused.is_none() { self.warn(format!("{label}: imported as an open body (the shell is not closed)")); } } @@ -773,6 +780,7 @@ impl Reader { file_faces, source_item: item, face_origin, + shown_as_mesh: part.refused.clone(), }); } Err(parallel::Failure { read, msg }) => { diff --git a/crates/kernel/src/step_in/parallel.rs b/crates/kernel/src/step_in/parallel.rs index f1e6257d4f..13293ddb4b 100644 --- a/crates/kernel/src/step_in/parallel.rs +++ b/crates/kernel/src/step_in/parallel.rs @@ -26,6 +26,9 @@ pub(super) struct Part { pub file_faces: usize, /// The file face of each kernel face. pub face_origin: Vec, + /// No B-rep could be made of the solid: the body is a mesh of the faces that could be read + /// on their own ([`mesh_part`]), and this is why the solid was refused. + pub refused: Option, } /// A prepared instance. @@ -63,7 +66,7 @@ fn prepare(ex: &Exchange, job: &Job) -> Prepared { let (part, read) = match &job.source { Source::Oriented(part) => (part.clone(), None), Source::Item { item, units } => { - let (part, read) = smlib_part(ex, *item, *units)?; + let (part, read) = smlib_part(ex, *item, *units).or_else(|refused| mesh_part(ex, *item, *units, refused))?; (Arc::new(part), Some(read)) } }; @@ -85,7 +88,7 @@ fn placed(body: &Body, m: &M4) -> crate::Result { /// A solid item read with the STEP reader into its schema 4 record, then into SMLib as given, /// and brought back as a body: the part SMLib made of the file. A solid the reader or SMLib -/// refuses is not imported, with the reason. +/// refuses fails with the reason ([`mesh_part`] then shows what it can of it). pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result::Result<(Part, Vec), Failure> { let solid = solidcraft_profile::scope("import", "step.read_solid", || { solidcraft_step::topology::read_solid_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims) @@ -109,10 +112,54 @@ pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result: _ => return Err(Failure { read: false, msg: "not supported yet: SMLib renumbered the solid's faces".into() }), } } - let part = Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin }; + let part = Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin, refused: None }; Ok((part, solid.notes)) } +/// A solid the reader or SMLib refuses, still shown: each of its faces read on its own +/// ([`solidcraft_step::topology::read_faces_with`]) and meshed by the planner as a one-face +/// surface body, the faces' triangles together a mesh body. It is no B-rep (modelling +/// operations refuse it); `refused` says why. Faces that cannot be read or meshed on their own +/// are left out, each named in the notes. With no face to show, the solid stays refused. +fn mesh_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::result::Result<(Part, Vec), Failure> { + let _span = solidcraft_profile::span("import", "step.refused_mesh"); + let faces = match solidcraft_step::topology::read_faces_with(ex, item, units, &solidcraft_smlib::pcurves::SmlibTrims) { + Ok(faces) => faces, + Err(_) => return Err(refused), + }; + let mut notes = Vec::new(); + let mut bodies = Vec::with_capacity(faces.len()); + for f in &faces { + match f.record.clone().map_err(KernelError::Failed).and_then(Body::from_record) { + Ok(b) => bodies.push((f.face, b)), + Err(e) => notes.push(format!("not shown: {}", e.to_string().trim_start_matches("the operation failed: "))), + } + } + // Every face at the display tolerance of the whole solid. + let bounds: solidcraft_geom::Aabb3 = bodies.iter().flat_map(|(_, b)| b.record().vertices.iter().map(|v| crate::body::v3(v.point))).collect(); + let size = if bounds.is_empty() { 0.0 } else { bounds.diagonal() }; + let tol = crate::MeshTolerance::display_of(if size.is_finite() { size } else { 0.0 }); + let (mut positions, mut triangles) = (Vec::new(), Vec::new()); + for (face, b) in &bodies { + match b.mesh(&tol) { + Ok(m) if !m.triangles.is_empty() => { + let Ok(base) = u32::try_from(positions.len()) else { break }; + positions.extend_from_slice(&m.positions); + triangles.extend(m.triangles.iter().map(|t| t.map(|i| i.saturating_add(base)))); + } + Ok(_) => notes.push(format!("not shown: face #{face}: no triangles")), + Err(e) => notes.push(format!("not shown: face #{face}: {}", e.to_string().trim_start_matches("the operation failed: "))), + } + } + if triangles.is_empty() { + return Err(Failure { msg: format!("{} (no face could be shown as a mesh either)", refused.msg), ..refused }); + } + let body = crate::meshbody::mesh_body(&positions, &triangles) + .map_err(|e| Failure { msg: format!("{} (its mesh: {e})", refused.msg), read: refused.read })?; + let closed = body.is_closed_mesh(); + Ok((Part { body, closed, file_faces: faces.len(), face_origin: Vec::new(), refused: Some(refused.msg) }, notes)) +} + /// Prepare `jobs`, the results in the jobs' order. Solids still to read and orient (the slow /// part) are shared out between up to `workers` threads, each taking the next job when it is /// done with one, so one large part does not hold up the rest. diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index a8023efe0d..d89f5934da 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -2163,3 +2163,61 @@ fn a_patch_side_shrunk_within_its_vertexs_tolerance_is_a_pole_of_the_loop() { let area = 0.5 * (30.0 + side) * 30.0; assert!(rel(m.area, area) < 1e-6, "{} vs {area}", m.area); } + +/// A solid no B-rep can be made of is still shown: a cup whose wall, a cylinder face, has one +/// bound (the rim it shares with its bottom) and no far one is refused, as Poppy Humanoid's +/// stock head is. Each face is read and meshed on its own instead: the bottom shows, as a mesh +/// body that says why the solid was refused; the wall, which cannot be read on its own either, +/// is left out and named. Modelling operations refuse the mesh body. +#[test] +fn a_refused_solid_is_shown_as_a_mesh_of_the_faces_that_read() { + let text = format!( + "{HEAD} +#1=CARTESIAN_POINT('',(0.,0.,0.)); +#2=DIRECTION('',(0.,0.,1.)); +#3=DIRECTION('',(1.,0.,0.)); +#4=DIRECTION('',(0.,0.,-1.)); +#5=AXIS2_PLACEMENT_3D('',#1,#2,#3); +#6=AXIS2_PLACEMENT_3D('',#1,#4,#3); +#7=CARTESIAN_POINT('',(5.,0.,0.)); +#8=VERTEX_POINT('',#7); +#9=CIRCLE('',#5,5.); +#10=EDGE_CURVE('',#8,#8,#9,.T.); +#11=ORIENTED_EDGE('',*,*,#10,.F.); +#12=EDGE_LOOP('',(#11)); +#13=FACE_OUTER_BOUND('',#12,.T.); +#14=PLANE('',#6); +#15=ADVANCED_FACE('Bottom',(#13),#14,.T.); +#16=ORIENTED_EDGE('',*,*,#10,.T.); +#17=EDGE_LOOP('',(#16)); +#18=FACE_OUTER_BOUND('',#17,.T.); +#19=CYLINDRICAL_SURFACE('',#5,5.); +#20=ADVANCED_FACE('Wall',(#18),#19,.T.); +#21=CLOSED_SHELL('',(#15,#20)); +#22=MANIFOLD_SOLID_BREP('Cup',#21); +#23=SHAPE_REPRESENTATION('',(#22),$); +{TAIL}" + ); + let imp = step_import(&text).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + let cup = &imp.bodies[0]; + assert!(cup.body.is_mesh(), "{:?}", imp.warnings); + let why = cup.shown_as_mesh.as_deref().unwrap_or_default(); + assert!(why.contains("face #20: a face around an open surface of revolution with one bound"), "{why}"); + assert_eq!(cup.file_faces, 2); + // The report keeps the refusal and names the face left out. + assert!(imp.warnings.iter().any(|w| w.contains("'Cup': shown as a mesh: ") && w.contains(why)), "{:?}", imp.warnings); + assert!(imp.warnings.iter().any(|w| w.contains("'Cup': not shown: face #20: ")), "{:?}", imp.warnings); + // The bottom, a 5 mm disk facing down. + let m = measure(&cup.body).unwrap(); + assert!(rel(m.area, std::f64::consts::PI * 25.0) < 1e-2, "area {}", m.area); + let (positions, triangles) = cup.body.triangle_mesh().unwrap(); + assert!(positions.iter().all(|p| p.z.abs() < 1e-9 && p.x.hypot(p.y) <= 5.0 + 1e-6), "the bottom only"); + let down = triangles + .iter() + .map(|t| (positions[t[1] as usize] - positions[t[0] as usize]).cross(positions[t[2] as usize] - positions[t[0] as usize]).z) + .sum::(); + assert!(down < 0.0, "the bottom faces out of the cup: {down}"); + let refused = fillet(&cup.body, &[Vec3::new(5.0, 0.0, 0.0)], 1.0).err().map(|e| e.to_string()).unwrap_or_default(); + assert!(refused.contains("needs a solid body"), "{refused}"); +} diff --git a/crates/step/src/topology.rs b/crates/step/src/topology.rs index bda59b5103..897499ecdc 100644 --- a/crates/step/src/topology.rs +++ b/crates/step/src/topology.rs @@ -310,39 +310,106 @@ fn read_with<'a>( trims: Option<&dyn Trims>, ) -> R { let e = entity(ex, item)?; - let p = e.params(); - let name = p.first().and_then(Param::as_str).unwrap_or("").to_string(); + let name = e.params().first().and_then(Param::as_str).unwrap_or("").to_string(); let mut b = Builder::new(ex, reader, tolerance(&context), budget, trims); // The file's faces, edges, vertices and their geometry read into the builder. let topology = solidcraft_profile::span("import", "step.topology"); - let (shells, kind) = match e.name() { - "MANIFOLD_SOLID_BREP" => (vec![b.shell(rid(p.get(1), "outer shell")?)?], Kind::Solid), + let (ids, kind) = item_shells(e)?; + let mut shells = Vec::with_capacity(ids.len()); + for s in ids { + shells.push(b.shell(s)?); + } + drop(topology); + b.finish(item, name, shells, kind) +} + +/// A face of a solid read on its own ([`read_faces_with`]). +#[derive(Clone, Debug)] +pub struct FaceRead { + /// The face's entity in the file (the ADVANCED_FACE or FACE_SURFACE, or the ORIENTED_FACE + /// the shell lists when it could not be read). + pub face: u64, + /// The face as a one-face surface body, facing as its shell uses it; else why it could not + /// be read (naming the face). + pub record: R, +} + +/// Each face of a solid item read on its own, as a one-face surface body: what can still be +/// shown of a solid that cannot be read whole, or whose body the kernel refuses. A face is read +/// as [`read_solid_with`] reads it, with its own edges and vertices (so its tolerances cover its +/// own gaps only); a face that cannot be read on its own says why and the others are still read. +pub fn read_faces_with(ex: &Exchange, item: u64, context: units::Context, trims: &dyn Trims) -> R> { + let (shells, _) = item_shells(entity(ex, item)?)?; + let mut reader = Reader::new(ex, context.units); + let mut budget = MAX_FILE_FACES; + let mut out = Vec::new(); + for s in shells { + let (faces, flip) = shell_faces(ex, s)?; + for f in faces { + if out.len() >= MAX_FACES { + return Err(format!("more than {MAX_FACES} faces")); + } + let mut b = Builder::new(ex, &mut reader, tolerance(&context), &mut budget, Some(trims)); + let read = b.face(f, 0).and_then(|(face, forward)| b.finish(item, String::new(), vec![vec![(face, forward != flip)]], Kind::Model)); + let named = |m: String| if m.starts_with(&format!("face #{f}")) { m } else { format!("face #{f}: {m}") }; + out.push(match read { + Ok(s) => FaceRead { face: s.face_origin.first().copied().unwrap_or(f), record: Ok(s.brep) }, + Err(m) => FaceRead { face: f, record: Err(named(m)) }, + }); + } + } + Ok(out) +} + +/// The shells of a solid item (the outer shell first, then its voids), and what kind it is. +fn item_shells(e: &Entity) -> R<(Vec, Kind)> { + let p = e.params(); + match e.name() { + "MANIFOLD_SOLID_BREP" => Ok((vec![rid(p.get(1), "outer shell")?], Kind::Solid)), "BREP_WITH_VOIDS" => { - let mut shells = vec![b.shell(rid(p.get(1), "outer shell")?)?]; + let mut shells = vec![rid(p.get(1), "outer shell")?]; let voids = refs(p.get(2), "voids")?; if voids.len() >= brep::MAX_SHELLS { return Err(format!("{} voids", voids.len())); } - for v in voids { - shells.push(b.shell(v)?); - } - (shells, Kind::Voids) + shells.extend(voids); + Ok((shells, Kind::Voids)) } "SHELL_BASED_SURFACE_MODEL" => { let ids = refs(p.get(1), "surface model shells")?; if ids.is_empty() || ids.len() > brep::MAX_SHELLS { return Err(format!("a surface model with {} shells", ids.len())); } - let mut shells = Vec::with_capacity(ids.len()); - for s in ids { - shells.push(b.shell(s)?); - } - (shells, Kind::Model) + Ok((ids, Kind::Model)) } - n => return Err(unsupported(n)), + n => Err(unsupported(n)), + } +} + +/// The faces a shell lists, and whether the shell turns them over (an oriented shell used +/// against its faces). +fn shell_faces(ex: &Exchange, id: u64) -> R<(Vec, bool)> { + let e = entity(ex, id)?; + let p = e.params(); + let (faces, flip) = match e.name() { + "CLOSED_SHELL" | "OPEN_SHELL" => (refs(p.get(1), "shell faces")?, false), + "ORIENTED_CLOSED_SHELL" | "ORIENTED_OPEN_SHELL" => { + let inner = rid(p.get(2), "oriented shell element")?; + let inner_e = entity(ex, inner)?; + if !matches!(inner_e.name(), "CLOSED_SHELL" | "OPEN_SHELL") { + return Err(format!("#{inner}: {}", unsupported(format!("shell {}", inner_e.name())))); + } + (refs(inner_e.params().get(1), "shell faces")?, !flag(p.get(3), "oriented shell orientation")?) + } + n => return Err(format!("#{id}: {}", unsupported(format!("shell {n}")))), }; - drop(topology); - b.finish(item, name, shells, kind) + if faces.is_empty() { + return Err(format!("shell #{id} has no faces")); + } + if faces.len() > MAX_FACES { + return Err(format!("shell #{id} with {} faces", faces.len())); + } + Ok((faces, flip)) } #[derive(Clone, Copy, PartialEq, Eq)] @@ -772,26 +839,7 @@ impl<'b, 'a> Builder<'b, 'a> { } fn shell(&mut self, id: u64) -> R> { - let e = entity(self.ex, id)?; - let p = e.params(); - let (faces, flip) = match e.name() { - "CLOSED_SHELL" | "OPEN_SHELL" => (refs(p.get(1), "shell faces")?, false), - "ORIENTED_CLOSED_SHELL" | "ORIENTED_OPEN_SHELL" => { - let inner = rid(p.get(2), "oriented shell element")?; - let inner_e = entity(self.ex, inner)?; - if !matches!(inner_e.name(), "CLOSED_SHELL" | "OPEN_SHELL") { - return Err(format!("#{inner}: {}", unsupported(format!("shell {}", inner_e.name())))); - } - (refs(inner_e.params().get(1), "shell faces")?, !flag(p.get(3), "oriented shell orientation")?) - } - n => return Err(format!("#{id}: {}", unsupported(format!("shell {n}")))), - }; - if faces.is_empty() { - return Err(format!("shell #{id} has no faces")); - } - if faces.len() > MAX_FACES { - return Err(format!("shell #{id} with {} faces", faces.len())); - } + let (faces, flip) = shell_faces(self.ex, id)?; let mut out = Vec::with_capacity(faces.len()); for f in faces { let (face, forward) = self.face(f, 0)?;