Wave 292/509: STEP import: a refused solid comes in as a surface body of its exact faces (and 3 more)

4 commits, squashed; the originals stay reachable from tag archive/merge-main-develop.

- fd5319a0e jyviko: docs: the kernel's move from truck to SMLib, as written up on #4012
- 4cb0d6ee9 jyviko: STEP import: a refused solid comes in as a surface body of its exact faces
- e19785620 jyviko: STEP import: a refused solid's surface body faces as the file has it
- 529fb7cf3 jyviko: Release: macOS builds on macos-15 (GitHub retires macos-14 on 2026-11-02)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M41G8Nua7HvuFPZkmJ
This commit is contained in:
jyviko
2026-10-28 12:48:43 -04:00
parent 53f4d607e2
commit 65b02ff7ab
6 changed files with 478 additions and 98 deletions
+2 -2
View File
@@ -316,7 +316,7 @@ jobs:
name: macOS build ${{ matrix.arch }}
needs: version
if: needs.version.outputs.build == 'true'
runs-on: macos-14
runs-on: macos-15
strategy:
fail-fast: false
matrix:
@@ -353,7 +353,7 @@ jobs:
name: macOS universal
needs: [version, macos-build]
if: needs.version.outputs.build == 'true'
runs-on: macos-14
runs-on: macos-15
env:
SOLIDCRAFT_VERSION: ${{ needs.version.outputs.version }}
SOLIDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
+7 -6
View File
@@ -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<String>,
/// 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<String>,
}
/// 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 }) => {
+200 -45
View File
@@ -26,8 +26,9 @@ pub(super) struct Part {
pub file_faces: usize,
/// The file face of each kernel face.
pub face_origin: Vec<u64>,
/// 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<String>,
}
@@ -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,67 +89,221 @@ fn placed(body: &Body, m: &M4) -> crate::Result<Body> {
/// 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<String>), 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 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)?;
// The file face of each body face: SMLib's face i is the record's `smlib_order` face and the
// body's `body_order` face (an empty order is the same one).
let (Some(smlib_order), Some(body_order)) = (smlib_order, body_order) else {
return Err(Failure { read: false, msg: "not supported yet: SMLib renumbered the solid's faces".into() });
};
let at = |order: &[usize], i: usize| if order.is_empty() { Some(i) } else { order.get(i).copied() };
let faces = brep.counts().map_err(|e| failed(KernelError::Failed(format!("SMLib: {e}"))))?[0];
let mut face_origin = vec![0u64; body.face_count()];
for i in 0..faces {
let file_face = at(&smlib_order.faces, i).and_then(|r| solid.face_origin.get(r)).copied();
match (at(&body_order.faces, i).and_then(|b| face_origin.get_mut(b)), file_face) {
(Some(slot), Some(f)) => *slot = f,
_ => 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 };
let part = solid_part(&solid)?;
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<String>), 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: "))),
/// The part SMLib makes of a solid (or sheet) record the reader made.
///
/// SMLib holds both sides of a sheet's faces and gives each back facing its surface's natural
/// normal, so a sheet whose file uses some faces the other way is given to SMLib with those faces
/// turned over (exactly, on the record) and they are turned back on SMLib's body: the body faces
/// as the file does, and each of its faces is known to be the record's face it was.
fn solid_part(solid: &solidcraft_step::topology::Solid) -> std::result::Result<Part, Failure> {
let failed = |e: KernelError| Failure { read: false, msg: e.to_string().trim_start_matches("the operation failed: ").to_string() };
let r = &solid.brep;
let mut reversed = vec![false; r.faces.len()];
if solid.sheet {
for s in &r.shells {
if let solidcraft_geom::brep::Shell::Faces { faces, .. } = s {
for f in faces.iter().filter(|f| !f.forward) {
if let Some(slot) = reversed.get_mut(f.face as usize) {
*slot = true;
}
}
}
}
}
let (body, of_record) = if reversed.iter().any(|r| *r) {
let turn = |body: &Body, by: &dyn Fn(usize) -> bool| -> crate::Result<(Body, Vec<usize>)> {
let picked: Vec<(usize, bool)> = (0..body.face_count()).map(|f| (f, by(f))).collect();
let turned = crate::surfaces::taken(body, body.record(), &picked)?;
let from = kept_faces(&turned).ok_or_else(|| KernelError::Failed("a face turned over from no one face".into()))?;
Ok((turned, from))
};
let as_read = Body::from_record(r.clone()).map_err(failed)?;
// `natural`'s face j is the record's `to_record[j]`; SMLib's body's face k is `natural`'s
// `of_natural[k]`; the body turned back's face i is SMLib's `back[i]`.
let (natural, to_record) = turn(&as_read, &|f| reversed.get(f).copied().unwrap_or(false)).map_err(failed)?;
let (smlib, of_natural) = smlib_body(natural.record())?;
let record_of = |k: usize| of_natural.get(k).and_then(|j| to_record.get(*j)).copied();
let (back, from) = turn(&smlib, &|k| record_of(k).and_then(|f| reversed.get(f)).copied().unwrap_or(false)).map_err(failed)?;
let of_record = from.iter().map(|k| record_of(*k)).collect::<Option<Vec<usize>>>().ok_or_else(renumbered)?;
(back.without_history(), of_record)
} else {
smlib_body(r)?
};
let face_origin = of_record.iter().map(|f| solid.face_origin.get(*f).copied()).collect::<Option<Vec<u64>>>().ok_or_else(renumbered)?;
Ok(Part { body, closed: !solid.sheet, file_faces: solid.face_origin.len(), face_origin, refused: None })
}
fn renumbered() -> Failure {
Failure { read: false, msg: "not supported yet: SMLib renumbered the solid's faces".into() }
}
/// The body SMLib makes of `record` as given, and the record's face each of its faces is.
fn smlib_body(record: &solidcraft_geom::brep::Brep) -> std::result::Result<(Body, Vec<usize>), Failure> {
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(record).map_err(failed)?;
let (body, body_order) = crate::native::body_from_smlib_ordered(&brep).map_err(failed)?;
// SMLib's face i is the record's `smlib_order` face and the body's `body_order` face (an
// empty order is the same one).
let (Some(smlib_order), Some(body_order)) = (smlib_order, body_order) else { return Err(renumbered()) };
let at = |order: &[usize], i: usize| if order.is_empty() { Some(i) } else { order.get(i).copied() };
let faces = brep.counts().map_err(|e| failed(KernelError::Failed(format!("SMLib: {e}"))))?[0];
let mut of_record = vec![usize::MAX; body.face_count()];
for i in 0..faces {
match (at(&body_order.faces, i).and_then(|b| of_record.get_mut(b)), at(&smlib_order.faces, i).filter(|r| *r < record.faces.len())) {
(Some(slot), Some(r)) => *slot = r,
_ => return Err(renumbered()),
}
}
if of_record.contains(&usize::MAX) {
return Err(renumbered());
}
Ok((body, of_record))
}
/// The face of its one operand each face of `body` is, from its history.
fn kept_faces(body: &Body) -> Option<Vec<usize>> {
let h = body.history()?;
let from: Vec<usize> = h
.faces
.iter()
.map(|f| match f.origins.as_slice() {
[crate::history::FaceOrigin::Face { operand: 0, face }] => Some(*face),
_ => None,
})
.collect::<Option<_>>()?;
(from.len() == body.face_count()).then_some(from)
}
/// 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<String>), 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<u64> = 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<u64> = 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<Part> {
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::<Option<Vec<u64>>>()
.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<String>,
refused: Failure,
) -> std::result::Result<(Part, Vec<String>), 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 +312,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
+75 -37
View File
@@ -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 (on a plane facing up, the face against it), 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.));
@@ -2186,38 +2182,80 @@ fn a_refused_solid_is_shown_as_a_mesh_of_the_faces_that_read() {
#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.);
#14=PLANE('',#5);
#15=ADVANCED_FACE('Bottom',(#13),#14,.F.);
#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::<f64>();
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 bottom faces down as the file has it, against its plane's natural normal (SMLib gives
// a sheet's faces back on their natural side; the import turns it back).
let r = can.body.record();
let bottom = can.face_origin.iter().position(|f| *f == 15).unwrap();
let uses: Vec<bool> = r
.shells
.iter()
.flat_map(|s| match s {
solidcraft_geom::brep::Shell::Faces { faces, .. } => faces.iter().filter(|f| f.face as usize == bottom).map(|f| f.forward).collect(),
_ => Vec::new(),
})
.collect();
assert_eq!(uses, [false], "the bottom's use");
// 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);
}
+52 -8
View File
@@ -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<Solid> {
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<Solid> {
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<u64>) -> R<Solid> {
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<u64> {
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<u64>,
) -> R<Solid> {
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<Vec<(u32, bool)>> {
/// The faces of shell `id` but those `left_out`, and whether any was left out.
fn shell(&mut self, id: u64, left_out: &HashSet<u64>) -> 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<u64> {
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.
@@ -942,6 +985,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 +995,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();
+142
View File
@@ -0,0 +1,142 @@
# Kernel: moving from truck to SMLib
How SolidCraft's kernel moved from truck to SMLib (usd-brep, Apache-2.0), as it was written up while the work was on the `kernel-smlib` branch (tracked in #4012, now closed: the branch landed on `develop`). It is a record of the steps and their pictures, not the current contract: that is [native-brep.md](native-brep.md), with the case ladder in [kernel-ladder.md](kernel-ladder.md) and [kernel-cases.md](kernel-cases.md). truck has since been removed.
The pictures live on the `pr-media-kernel-smlib` branch.
## Where it started (2026-10-21)
### Schema 4: a plain B-rep that mirrors SMLib's model
`solidcraft_geom::brep` now holds:
- regions and shells;
- oriented faces, and faces with parameter domains;
- edge and vertex loops;
- oriented edges, each carrying a **pcurve** (the edge in the face's (u, v));
- edges with intervals, and vertices;
- tolerances per body, per edge and per vertex.
Names follow STEP's B-rep vocabulary. Analytic curves and surfaces are unbounded; their bounds live in the edge interval and the face domain. A validator enforces one **canonical order** of faces, edges and vertices, which persistent names depend on. The history types moved beside it, to `solidcraft_geom::brep::history`. The contract is in docs/native-brep.md and tangency-labs/solidcore#342.
truck bodies round-trip through schema 4 exactly, and the model cache stores it. Every corpus design, sample and imported assembly reopens from the cache with bit-identical volumes, areas and face names:
| Filleted box | Cylinder cut by a tilted box |
|---|---|
| ![box-fillet](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/brep-schema-box-fillet.png) | ![cyl-cut](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/brep-schema-cyl-cut.png) |
| ![box-hole](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/brep-schema-box-hole.png) | ![corpus](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/brep-schema-corpus.png) |
### Pcurves for every edge
Pcurves are exact on planes. Elsewhere they're a cubic 2D B-spline fitted within tolerance, never a polyline. A seam edge gets two pcurves one period apart, and poles are handled. Each face's loops, drawn in its (u, v):
![cut](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pcurves-cut-side.png)
![cylinder](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pcurves-cylinder-side.png)
![sphere](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pcurves-sphere-side.png)
![torus](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pcurves-torus-side.png)
![seam](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pcurves-seam-cylinder.png)
### A naming guard for the move
`apps/solidcraft-cli/tests/naming_corpus.rs` rebuilds 154 designs and checks every face and edge name, and every stored reference, against `tests/naming/`. Every operation that switches to SMLib must keep them. Re-record with `NAMING_CORPUS_BLESS=1`.
![names](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/naming-naming-split-pieces-face-names.png)
![diff](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/naming-test-failure-diff.png)
### Next, as planned then
- SMLib linked statically behind C shims (`solidcraft-smlib`).
- SMLib bodies exported to schema 4 and shown in SolidCraft.
- Booleans, fillet, chamfer and shell through SMLib, compared side by side with truck.
## Updates
### SMLib B-reps in SolidCraft (2026-10-21)
Each body below was made by SMLib, converted to schema 4 (canonical order, our own pcurves, `validate()` and `check_geometry()` passing), decoded into a SolidCraft `Body`, and rendered next to truck's primitive of the same size. The faces, edges and vertices are SMLib's own: a cylinder is 3 faces, a whole sphere 1 face bounded by its seam, a torus 1 face.
| | |
|---|---|
| ![box](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-box.png) | ![cylinder](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-cylinder.png) |
| ![sphere](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-sphere.png) | ![frustum](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-frustum.png) |
| ![cone](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-cone.png) | ![torus](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-torus.png) |
The torus's and the cylinder's loops in (u, v), from our pcurve routine:
![torus-uv](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-torus-uv.png)
![cylinder-uv](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-cylinder-uv.png)
The SMLib volumes are SMLib's exact mass properties and match the closed form. "Decoded" is SolidCraft's mesh-based measure of the decoded body, within 2e-4 relative. These pictures come from a first JSON export; the same path now goes through the fork's flat converter (`capi-flat`) and is being rebuilt there.
Also on `kernel-smlib` since the last update:
- the link crate (`solidcraft-smlib`);
- the STEP geometry crate (`solidcraft-step`);
- history out of SMLib operations, and a side-by-side runner for truck and SMLib booleans;
- `Brep::check_geometry`, run before any record is decoded;
- cones that narrow along their axis.
### Landed on `kernel-smlib`: SMLib bodies into SolidCraft through schema 4, and back (2026-10-21)
(4d72f7e8, 1522e133). SMLib B-reps leave the C API as the fork's flat arrays, a port of SMLib's own converter without USD. They become schema 4 records: canonical order, our own pcurves, `validate()` and `check_geometry()` passing. With the kernel's `smlib` feature, `body_from_smlib` decodes them into a `Body`.
The reverse direction is in too. A round-trip test sends every primitive (box, cylinder, sphere, cone, two frustums, torus) from SMLib to schema 4 and back into SMLib, and each keeps its counts and its exact volume within 1e-7, and passes SMLib's own check at every level. The pictures above are re-rendered on this path. SMLib's whole sphere is now bounded correctly by its seam in (u, v):
![sphere-uv](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/pole-seam-sphere-seam-uv.png)
### SMLib operations in SolidCraft (2026-10-21)
(`kernel-smlib` 4067ab4c). Each result was built live by SMLib, exported to schema 4, decoded into a `Body` and rendered. Next to it is truck's result of the same operation on the same inputs. SMLib's own check passes and schema 4's `check_geometry` is ok in all nine.
| | |
|---|---|
| ![union](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-union.png) | ![slot](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-subtract_slot.png) |
| ![split](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-subtract_split.png) | ![intersect](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-intersect.png) |
| ![hole](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-subtract_hole.png) | ![fillet](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-fillet_all_edges.png) |
| ![chamfer](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-chamfer_one_edge.png) | ![shell](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-shell_inward.png) |
SMLib's volumes equal the analytic values in every case. Truck's are mesh-measured and close. The face counts match, except that SMLib keeps a through-hole as one cylindrical face with a seam where truck uses two halves. History is classified differently in two places. SMLib keeps an untouched face that truck calls Modified. A shell's inner walls are Split for SMLib and Generated (offsets) for truck. No face name moves because of it, since names are built from origins.
**A bug this found, being fixed now.** SMLib builds a hole-rim fillet as a NURBS torus that closes on itself in u. Our pcurve routine gives NURBS directions no period, so both uses of that face's seam got the same pcurve, the face enclosed nothing and wasn't drawn:
![rim](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/smlib-ops-render-fillet_hole_rim.png)
### Fixed: SMLib's hole-rim fillet (2026-10-21)
(`kernel-smlib` 4067ab4c..0eacff3e). A spline surface that closes on itself is now periodic over its knot interval for pcurves and tessellation, so the fillet torus's two seam uses sit one period apart. The decoded volume is 6989.0174 against SMLib's 6988.9956 (3e-6), where it was 6839.73. `check_geometry` now also refuses a loop that encloses no (u, v) area or turns the wrong way.
| before | after |
|---|---|
| ![before](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/rim-before.png) | ![after](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/rim-after.png) |
![uv](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/rim-uv.png)
### truck and SMLib booleans, side by side (2026-10-21)
(runner on `kernel-smlib` 8665746e: `cargo run --release -p solidcraft-kernel --features smlib --example smlib_booleans -- --seeds 128`). 411 operations: 128 random box/cylinder/sphere/torus pairs and 9 named cases, each as union, intersection and cut. Each kernel is timed on the boolean alone.
| | median | worst | total |
|---|---|---|---|
| truck boolean | 8.8 ms | 6.2 s | 44.0 s |
| SMLib boolean | 1.8 ms | 25 ms | 1.1 s |
| moving operands into SMLib | 2.6 ms | 91 ms | |
| moving the result back | 4.4 ms | 522 ms | |
![times](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/perf-times.png)
**The cut sequence:** a block, a joined boss, then three cuts, the last splitting the block. truck slows down with each cut and SMLib stays flat. SMLib matches the analytic volume exactly at every step, with the same face and edge counts as truck.
| step | truck | SMLib |
|---|---|---|
| join Ø20 boss | 9 ms | 5 ms |
| slot | 61 ms | 5 ms |
| Ø6 cross hole | 378 ms | 7 ms |
| splitting slot | 383 ms | 4 ms |
![sequence](https://raw.githubusercontent.com/tangency-labs/solidcraft/pr-media-kernel-smlib/perf-times-sequence.png)
**Where SMLib isn't at parity yet:**
- **11 of 411 operations fail on SMLib,** where a cylinder or torus is tangent to a planar face. truck completes all of them. Filed as tangency-labs/solidcore#383.
- **About 15 SMLib volumes are wrong although the body is right:** the same body measured after export matches. Filed as tangency-labs/solidcore#384.
**Also to fix on our side** (the conversion back to SolidCraft):
- results built from SMLib's own spheres and tori;
- an edge that runs along only part of its spline;
- a slow export of a one-face torus.
On the 27 named operations, SMLib's volumes are within 1e-6 of the analytic values, and truck's within 1e-3 (it measures from a tessellation).