diff --git a/crates/kernel/src/step_in/mod.rs b/crates/kernel/src/step_in/mod.rs index 290644b3c8..3485b6cab0 100644 --- a/crates/kernel/src/step_in/mod.rs +++ b/crates/kernel/src/step_in/mod.rs @@ -55,10 +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, + /// No B-rep could be made of the whole solid, and this is why (the import's warnings say so + /// too, naming each face left out): `body` is an open surface body of the solid's exact faces + /// but those, or a mesh body of them where no surface body could be made of them. + pub refused: Option, } /// One product occurrence in the file's assembly tree. @@ -720,7 +720,8 @@ impl Reader { 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}")); + let kind = if part.body.is_mesh() { "shown as a mesh" } else { "imported as surfaces" }; + self.warn(format!("{label}: {kind}: {why}")); } for f in &failed { self.warn(format!("{label}: {f}")); @@ -780,7 +781,7 @@ impl Reader { file_faces, source_item: item, face_origin, - shown_as_mesh: part.refused.clone(), + refused: 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 13293ddb4b..b29408ade8 100644 --- a/crates/kernel/src/step_in/parallel.rs +++ b/crates/kernel/src/step_in/parallel.rs @@ -26,8 +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. + /// No B-rep could be made of the whole solid, and this is why: the body is a surface body of + /// the faces that read ([`surface_part`]), or, where none could be made of them, a mesh of + /// them ([`mesh_part`]). pub refused: Option, } @@ -66,7 +67,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).or_else(|refused| mesh_part(ex, *item, *units, refused))?; + let (part, read) = smlib_part(ex, *item, *units).or_else(|refused| surface_part(ex, *item, *units, refused))?; (Arc::new(part), Some(read)) } }; @@ -88,12 +89,18 @@ 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 fails with the reason ([`mesh_part`] then shows what it can of it). +/// refuses fails with the reason ([`surface_part`] then imports 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) }) .map_err(|msg| Failure { read: true, msg })?; + let part = solid_part(&solid)?; + Ok((part, solid.notes)) +} + +/// The part SMLib makes of a solid (or sheet) record the reader made. +fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result { let failed = |e: KernelError| Failure { read: false, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() }; let (brep, smlib_order) = crate::native::smlib_record_ordered(&solid.brep).map_err(failed)?; let (body, body_order) = crate::native::body_from_smlib_ordered(&brep).map_err(failed)?; @@ -112,43 +119,129 @@ 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, refused: None }; - Ok((part, solid.notes)) + Ok(Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin, refused: None }) } -/// 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: "))), +/// Most reads of a refused solid without faces, each leaving out the face the refusal before names. +const MAX_LEFT_OUT_ROUNDS: usize = 8; + +/// A solid the reader or SMLib refuses, imported as an open surface body of its exact faces, the +/// faces that make it refused left out and named in the notes; `refused` says why the solid was. +/// Each face is read on its own first ([`solidcraft_step::topology::read_faces_with`]); then: +/// 1. the solid is read again without the faces that cannot be read alone and the face the +/// refusal names, as one sheet whose faces share their edges as the file has them, and made +/// by SMLib; a refusal of that naming another face of the solid leaves it out too (up to +/// [`MAX_LEFT_OUT_ROUNDS`] reads). A face is only ever left out where a refusal names it. +/// 2. Where a refusal names no face of the solid: the faces that read alone, stitched by SMLib +/// into one surface body (their edges sewn at ten times their tolerance). +/// 3. Where SMLib cannot stitch them either: a mesh of them ([`mesh_part`]). +/// +/// With no face to import, the solid stays refused. +fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::result::Result<(Part, Vec), Failure> { + let _span = solidcraft_profile::span("import", "step.refused_surfaces"); + let trims = &solidcraft_smlib::pcurves::SmlibTrims; + let Ok(faces) = solidcraft_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) }; + let of_solid: std::collections::HashSet = faces.iter().map(|f| f.face).collect(); + // The faces left out, each with the refusal that names it, in the order they were found. + let mut left_out: Vec<(u64, String)> = faces.iter().filter_map(|f| f.record.as_ref().err().map(|m| (f.face, m.clone()))).collect(); + let mut why = refused.msg.clone(); + let mut rounds = 0; + loop { + // The face the last refusal names, if it is one of the solid's still in, is left out too. + let named = solidcraft_step::topology::named_face(&why).filter(|f| of_solid.contains(f) && !left_out.iter().any(|(g, _)| g == f)); + match named { + Some(f) => left_out.push((f, why.clone())), + None if rounds > 0 => break, + None => {} + } + if left_out.is_empty() || left_out.len() >= faces.len() || rounds >= MAX_LEFT_OUT_ROUNDS { + break; + } + rounds += 1; + let skip: std::collections::HashSet = left_out.iter().map(|(f, _)| *f).collect(); + let read = + solidcraft_profile::scope("import", "step.read_without", || solidcraft_step::topology::read_solid_without(ex, item, units, trims, &skip)); + match read.map_err(|msg| Failure { read: true, msg }).and_then(|solid| Ok((solid_part(&solid)?, solid.notes))) { + Ok((mut part, mut notes)) => { + part.file_faces = faces.len(); + part.refused = Some(refused.msg); + notes.extend(left_out.iter().map(|(_, m)| format!("left out: {m}"))); + return Ok((part, notes)); + } + Err(e) => { + log::debug!(target: "step", "item #{item}: read without {} faces, refused: {}", skip.len(), e.msg); + why = e.msg; + } } } + // The faces that read alone, each a one-face surface body. + let mut notes = Vec::new(); + let mut pieces = Vec::with_capacity(faces.len()); + for f in &faces { + match f.record.clone().map_err(KernelError::Failed).and_then(Body::from_record) { + Ok(b) => pieces.push((f.face, b)), + Err(e) => notes.push(format!("left out: {}", e.to_string().trim_start_matches("the operation failed: "))), + } + } + match stitched_part(&pieces, faces.len(), &refused.msg) { + Ok(part) => Ok((part, notes)), + Err(e) => { + log::debug!(target: "step", "item #{item}: its faces not stitched ({e}); meshed instead"); + mesh_part(&pieces, faces.len(), notes, refused) + } + } +} + +/// The one-face surface bodies `pieces` (each with its file face) stitched by SMLib into one +/// surface body, where their edges coincide within ten times the pieces' tolerance. +fn stitched_part(pieces: &[(u64, Body)], file_faces: usize, refused: &str) -> crate::Result { + if pieces.is_empty() { + return Err(KernelError::Failed("no face read".into())); + } + let tol = 10.0 * pieces.iter().map(|(_, b)| b.record().tolerance).fold(0.0, f64::max); + let bodies: Vec<&Body> = pieces.iter().map(|(_, b)| b).collect(); + let body = crate::surfaces::stitch(&bodies, tol)?; + // Each stitched face is the one face of the piece it was sewn from. + let history = body.history().ok_or_else(|| KernelError::Failed("the stitch kept no history".into()))?; + let face_origin = history + .faces + .iter() + .map(|f| match f.origins.as_slice() { + [crate::history::FaceOrigin::Face { operand, .. }] => pieces.get(*operand).map(|(face, _)| *face), + _ => None, + }) + .collect::>>() + .filter(|o| o.len() == body.face_count()) + .ok_or_else(|| KernelError::Failed("a stitched face from no one face".into()))?; + let closed = !body.is_surface(); + Ok(Part { body, closed, file_faces, face_origin, refused: Some(refused.to_string()) }) +} + +/// The one-face surface bodies `pieces` meshed by the planner, their triangles together a mesh +/// body: no B-rep (modelling operations refuse it), for a solid whose faces SMLib could not +/// stitch either. Faces that cannot be meshed are left out, each named in the notes; with no +/// face to show, the solid stays refused. +fn mesh_part( + pieces: &[(u64, Body)], + file_faces: usize, + mut notes: Vec, + refused: Failure, +) -> std::result::Result<(Part, Vec), Failure> { + let _span = solidcraft_profile::span("import", "step.refused_mesh"); // 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 bounds: solidcraft_geom::Aabb3 = pieces.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 { + for (face, b) in pieces { 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: "))), + Ok(_) => notes.push(format!("left out: face #{face}: no triangles")), + Err(e) => notes.push(format!("left out: face #{face}: {}", e.to_string().trim_start_matches("the operation failed: "))), } } if triangles.is_empty() { @@ -157,7 +250,7 @@ fn mesh_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::r 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)) + Ok((Part { body, closed, file_faces, 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 diff --git a/crates/kernel/src/step_in/tests.rs b/crates/kernel/src/step_in/tests.rs index d89f5934da..d3c4688a53 100644 --- a/crates/kernel/src/step_in/tests.rs +++ b/crates/kernel/src/step_in/tests.rs @@ -2164,14 +2164,10 @@ fn a_patch_side_shrunk_within_its_vertexs_tolerance_is_a_pole_of_the_loop() { 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!( +/// A can of radius 5 and height 10: its bottom, its wall, and its lid on the plane `lid_z` +/// (10 where the lid's rim lies on it). +fn can_with_its_lid_at(lid_z: f64) -> String { + format!( "{HEAD} #1=CARTESIAN_POINT('',(0.,0.,0.)); #2=DIRECTION('',(0.,0.,1.)); @@ -2190,34 +2186,63 @@ fn a_refused_solid_is_shown_as_a_mesh_of_the_faces_that_read() { #15=ADVANCED_FACE('Bottom',(#13),#14,.T.); #16=ORIENTED_EDGE('',*,*,#10,.T.); #17=EDGE_LOOP('',(#16)); -#18=FACE_OUTER_BOUND('',#17,.T.); +#18=FACE_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); +#20=ADVANCED_FACE('Wall',(#18,#37),#19,.T.); +#30=CARTESIAN_POINT('',(0.,0.,10.)); +#31=AXIS2_PLACEMENT_3D('',#30,#2,#3); +#32=CARTESIAN_POINT('',(5.,0.,10.)); +#33=VERTEX_POINT('',#32); +#34=CIRCLE('',#31,5.); +#35=EDGE_CURVE('',#33,#33,#34,.T.); +#36=ORIENTED_EDGE('',*,*,#35,.F.); +#37=FACE_BOUND('',#38,.T.); +#38=EDGE_LOOP('',(#36)); +#39=ORIENTED_EDGE('',*,*,#35,.T.); +#40=EDGE_LOOP('',(#39)); +#41=FACE_OUTER_BOUND('',#40,.T.); +#42=CARTESIAN_POINT('',(0.,0.,{lid_z:?})); +#43=AXIS2_PLACEMENT_3D('',#42,#2,#3); +#44=PLANE('',#43); +#45=ADVANCED_FACE('Lid',(#41),#44,.T.); +#21=CLOSED_SHELL('',(#15,#20,#45)); +#22=MANIFOLD_SOLID_BREP('Can',#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}"); + ) +} + +/// A solid no B-rep can be made of comes in as an open surface body of its exact faces, the +/// faces that make it refused left out and named, as Poppy Humanoid's stock head (a face around +/// an open surface of revolution with one bound) and Congé19 (an edge further off its face than +/// any tolerance SMLib takes) do. A can whose lid lies 1.5 mm off its rim: the lid is left out, +/// the bottom and the wall come in sharing their rim, and the existing surface tools close the +/// can again (the rim patched, the patch stitched on): a solid with the can's volume. +#[test] +fn a_refused_solid_comes_in_as_a_surface_body_of_the_faces_that_read() { + let pi = std::f64::consts::PI; + let whole = step_import(&can_with_its_lid_at(10.0)).unwrap(); + assert_eq!(whole.bodies.len(), 1, "{:?}", whole.warnings); + assert!(whole.bodies[0].closed && whole.bodies[0].refused.is_none(), "{:?}", whole.warnings); + assert!(rel(measure(&whole.bodies[0].body).unwrap().volume, 250.0 * pi) < 1e-6); + + let imp = step_import(&can_with_its_lid_at(11.5)).unwrap(); + assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings); + let can = &imp.bodies[0]; + assert!(!can.body.is_mesh() && can.body.is_surface() && !can.closed, "{:?}", imp.warnings); + let why = can.refused.as_deref().unwrap_or_default(); + assert!(why.contains("face #45") && why.contains("off the face's surface"), "{why}"); + assert_eq!((can.file_faces, can.body.face_count()), (3, 2)); + assert_eq!(can.face_origin, [15, 20]); + // The report keeps the refusal and names the face left out; no "open body" warning on top. + assert!(imp.warnings.iter().any(|w| w.contains("'Can': imported as surfaces: ") && w.contains(why)), "{:?}", imp.warnings); + assert!(imp.warnings.iter().any(|w| w.contains("'Can': left out: ") && w.contains("face #45")), "{:?}", imp.warnings); + assert!(!imp.warnings.iter().any(|w| w.contains("open body")), "{:?}", imp.warnings); + // Exact: the bottom's disk and the wall's 10 mm of cylinder. + assert!(rel(measure(&can.body).unwrap().area, 125.0 * pi) < 1e-6, "{}", measure(&can.body).unwrap().area); + // Closed again with the existing tools. + let lid = crate::patch_edges(&can.body, &[Vec3::new(0.0, 5.0, 10.0)]).unwrap(); + let back = crate::stitch(&[&can.body, &lid], 0.0).unwrap(); + assert!(!back.is_surface(), "closed"); + assert!(rel(measure(&back).unwrap().volume, 250.0 * pi) < 1e-6, "{}", measure(&back).unwrap().volume); } diff --git a/crates/step/src/topology.rs b/crates/step/src/topology.rs index 897499ecdc..4ca1751e88 100644 --- a/crates/step/src/topology.rs +++ b/crates/step/src/topology.rs @@ -266,7 +266,7 @@ fn read_solids_in(ex: &Exchange, trims: Option<&dyn Trims>) -> Solids { (Reader::new(ex, c.units), c) }); let context = *context; - match read_with(ex, reader, item, context, &mut budget, trims) { + match read_with(ex, reader, item, context, &mut budget, trims, &HashSet::new()) { Ok(mut s) => { s.product = index.product.get(&item).cloned(); if s.name.is_empty() @@ -286,14 +286,33 @@ fn read_solids_in(ex: &Exchange, trims: Option<&dyn Trims>) -> Solids { pub fn read_solid(ex: &Exchange, item: u64, context: units::Context) -> R { let mut reader = Reader::new(ex, context.units); let mut budget = MAX_FILE_FACES; - read_with(ex, &mut reader, item, context, &mut budget, None) + read_with(ex, &mut reader, item, context, &mut budget, None, &HashSet::new()) } /// [`read_solid`], with `trims` making the pcurves of uses that have no closed form. pub fn read_solid_with(ex: &Exchange, item: u64, context: units::Context, trims: &dyn Trims) -> R { let mut reader = Reader::new(ex, context.units); let mut budget = MAX_FILE_FACES; - read_with(ex, &mut reader, item, context, &mut budget, Some(trims)) + read_with(ex, &mut reader, item, context, &mut budget, Some(trims), &HashSet::new()) +} + +/// [`read_solid_with`] with the faces `left_out` (file face entities: the ADVANCED_FACE or +/// FACE_SURFACE, or the ORIENTED_FACE a shell lists) left out of their shells: what is left of a +/// solid that cannot be read or made whole, its faces still sharing their edges. With any face +/// left out it is a surface model of open shells (`sheet`), none of them bounding a region. +pub fn read_solid_without(ex: &Exchange, item: u64, context: units::Context, trims: &dyn Trims, left_out: &HashSet) -> R { + let mut reader = Reader::new(ex, context.units); + let mut budget = MAX_FILE_FACES; + read_with(ex, &mut reader, item, context, &mut budget, Some(trims), left_out) +} + +/// The file face a refusal of the reader names first (its "face #id"), if it names one. +pub fn named_face(message: &str) -> Option { + message.match_indices("face #").find_map(|(at, m)| { + let rest = message.get(at + m.len()..)?; + let digits = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len()); + rest.get(..digits)?.parse().ok() + }) } /// The base tolerance of a context. @@ -308,6 +327,7 @@ fn read_with<'a>( context: units::Context, budget: &mut usize, trims: Option<&dyn Trims>, + left_out: &HashSet, ) -> R { let e = entity(ex, item)?; let name = e.params().first().and_then(Param::as_str).unwrap_or("").to_string(); @@ -316,11 +336,14 @@ fn read_with<'a>( let topology = solidcraft_profile::span("import", "step.topology"); let (ids, kind) = item_shells(e)?; let mut shells = Vec::with_capacity(ids.len()); + let mut skipped = false; for s in ids { - shells.push(b.shell(s)?); + let (faces, any) = b.shell(s, left_out)?; + skipped |= any; + shells.push(faces); } drop(topology); - b.finish(item, name, shells, kind) + b.finish(item, name, shells, if skipped { Kind::Open } else { kind }) } /// A face of a solid read on its own ([`read_faces_with`]). @@ -417,6 +440,8 @@ enum Kind { Solid, Voids, Model, + /// A solid with faces left out ([`read_solid_without`]): its shells are sheets only. + Open, } /// A curve the edge can be read on, and how its parameter relates to its vertices. @@ -838,14 +863,32 @@ impl<'b, 'a> Builder<'b, 'a> { Ok(ext) } - fn shell(&mut self, id: u64) -> R> { + /// The faces of shell `id` but those `left_out`, and whether any was left out. + fn shell(&mut self, id: u64, left_out: &HashSet) -> R<(Vec<(u32, bool)>, bool)> { let (faces, flip) = shell_faces(self.ex, id)?; let mut out = Vec::with_capacity(faces.len()); + let mut skipped = false; for f in faces { + if !left_out.is_empty() && (left_out.contains(&f) || left_out.contains(&self.face_entity(f, 0)?)) { + skipped = true; + continue; + } let (face, forward) = self.face(f, 0)?; out.push((face, forward != flip)); } - Ok(out) + Ok((out, skipped)) + } + + /// The ADVANCED_FACE or FACE_SURFACE a shell's face `id` is (through its ORIENTED_FACEs). + fn face_entity(&self, id: u64, depth: usize) -> R { + if depth > MAX_NESTING { + return Err("oriented faces nested too deeply".into()); + } + let e = entity(self.ex, id)?; + if e.name() == "ORIENTED_FACE" { + return self.face_entity(rid(e.params().get(2), "oriented face element")?, depth + 1); + } + Ok(id) } /// Pcurves, outer loops, domains and the canonical order: the finished record. @@ -923,9 +966,13 @@ impl<'b, 'a> Builder<'b, 'a> { if flipped > 0 { notes.push(format!("{flipped} faces turned over: their loops disagree with their same_sense")); } + // What is left of a solid with faces left out is a sheet whose faces face their surfaces' + // natural normals: SMLib holds both sides of a sheet's faces and gives each back on that + // side, so the record is the one SMLib's body of it numbers alike. + let open = kind == Kind::Open; let shells: Vec> = shells .into_iter() - .map(|s| s.into_iter().map(|(f, fwd)| (f, if turned.get(f as usize).copied().unwrap_or(false) { !fwd } else { fwd })).collect()) + .map(|s| s.into_iter().map(|(f, fwd)| (f, open || if turned.get(f as usize).copied().unwrap_or(false) { !fwd } else { fwd })).collect()) .collect(); // Regions: which shells are closed (every edge used at least twice among its faces). @@ -942,6 +989,7 @@ impl<'b, 'a> Builder<'b, 'a> { regions.push(Region { shells: vec![index(v)?], void: true }); } } + Kind::Open => regions[0].shells = (0..index(shells.len())?).collect(), Kind::Model => { regions[0].shells = (0..index(shells.len())?).collect(); for (k, c) in closed.iter().enumerate() { @@ -951,7 +999,7 @@ impl<'b, 'a> Builder<'b, 'a> { } } } - let sheet = !closed.iter().any(|c| *c); + let sheet = kind == Kind::Open || !closed.iter().any(|c| *c); // Canonical order: faces walking regions → shells → oriented faces. let nf = self.faces.len();