STEP import: a solid the kernel refuses, its faces too, is refused with the reason, never a mesh

A solid SMLib refused came in as a surface body of its faces; where SMLib could not stitch those
either, the importer meshed them with SolidCraft's planner and kept the triangles as a mesh body
(step_in/parallel.rs, mesh_part). A build without SMLib showed it: every STEP part of the import
rig came in as a mesh of its faces. That fallback is gone: the solid is not imported, and its
warning names it and gives the kernel's reason ("solid 'Cube': not imported: ..."); a file whose
every solid is refused fails with the same words.

The import report tells a part the kernel refuses (Refused) from one the reader cannot read
(NotImported), and its summary says "N parts couldn't be made into solids and were not
imported" in place of the mesh sentence (en, ar, de, he, ja, ko, zh). STL, 3MF, OBJ and glTF
still come in as mesh bodies: that is what those files are.

The two-tube sheet of #1374 had come in only through that fallback: SMLib's export lists its
edges in another order, which the next commit maps. Until then its test asserts the refusal and
meshes the faces as read at the body's coarse display tolerance, the regression it guards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-30 12:40:02 -04:00
committed by jyviko
parent 709765fcae
commit 393c83c2a3
6 changed files with 84 additions and 79 deletions
@@ -0,0 +1,30 @@
{
"en": {
"import_report.refused_one": "1 part couldn't be made into a solid and was not imported.",
"import_report.refused_other": "{count} parts couldn't be made into solids and were not imported."
},
"ar": {
"import_report.refused_one": "تعذّر تحويل جزء واحد إلى مجسم، فلم يُستورد.",
"import_report.refused_other": "تعذّر تحويل {count} أجزاء إلى مجسمات، فلم تُستورد."
},
"de": {
"import_report.refused_one": "1 Teil konnte nicht zu einem Volumenkörper werden und wurde nicht importiert.",
"import_report.refused_other": "{count} Teile konnten nicht zu Volumenkörpern werden und wurden nicht importiert."
},
"he": {
"import_report.refused_one": "לא ניתן היה להפוך חלק אחד לגוף מוצק והוא לא יובא.",
"import_report.refused_other": "לא ניתן היה להפוך {count} חלקים לגופים מוצקים והם לא יובאו."
},
"ja": {
"import_report.refused_one": "1 個のパーツをソリッドにできず、読み込みませんでした。",
"import_report.refused_other": "{count} 個のパーツをソリッドにできず、読み込みませんでした。"
},
"ko": {
"import_report.refused_one": "부품 1개를 솔리드로 만들지 못해 가져오지 않았습니다.",
"import_report.refused_other": "부품 {count}개를 솔리드로 만들지 못해 가져오지 않았습니다."
},
"zh": {
"import_report.refused_one": "1 个零件无法生成实体,未导入。",
"import_report.refused_other": "{count} 个零件无法生成实体,未导入。"
}
}
@@ -6,8 +6,6 @@
"import_report.hint": "Exporting these parts again from the program that made them may fix this. Copy Warning Message has the details.",
"import_report.left_out_one": "1 face couldn't be repaired and is missing.",
"import_report.left_out_other": "{count} faces couldn't be repaired and are missing.",
"import_report.mesh_one": "1 part couldn't be made into a solid and is shown as a mesh.",
"import_report.mesh_other": "{count} parts couldn't be made into solids and are shown as meshes.",
"import_report.not_imported_one": "1 part couldn't be imported.",
"import_report.not_imported_other": "{count} parts couldn't be imported.",
"import_report.not_shown_one": "1 face couldn't be drawn and is hidden.",
@@ -27,8 +25,6 @@
"import_report.hint": "قد يحل ذلك تصدير هذه الأجزاء مرة أخرى من البرنامج الذي أنشأها. يحتوي «نسخ رسالة التحذير» على التفاصيل.",
"import_report.left_out_one": "تعذّر إصلاح وجه واحد وهو مفقود.",
"import_report.left_out_other": "تعذّر إصلاح {count} أوجه وهي مفقودة.",
"import_report.mesh_one": "تعذّر تحويل جزء واحد إلى مجسم، فهو معروض كشبكة.",
"import_report.mesh_other": "تعذّر تحويل {count} أجزاء إلى مجسمات، فهي معروضة كشبكات.",
"import_report.not_imported_one": "تعذّر استيراد جزء واحد.",
"import_report.not_imported_other": "تعذّر استيراد {count} أجزاء.",
"import_report.not_shown_one": "تعذّر رسم وجه واحد وهو مخفي.",
@@ -48,8 +44,6 @@
"import_report.hint": "Die Teile erneut aus dem Programm zu exportieren, mit dem sie erstellt wurden, kann das beheben. „Warnmeldung kopieren“ enthält die Details.",
"import_report.left_out_one": "1 Fläche konnte nicht repariert werden und fehlt.",
"import_report.left_out_other": "{count} Flächen konnten nicht repariert werden und fehlen.",
"import_report.mesh_one": "1 Teil konnte nicht zu einem Volumenkörper werden und wird als Netz gezeigt.",
"import_report.mesh_other": "{count} Teile konnten nicht zu Volumenkörpern werden und werden als Netze gezeigt.",
"import_report.not_imported_one": "1 Teil konnte nicht importiert werden.",
"import_report.not_imported_other": "{count} Teile konnten nicht importiert werden.",
"import_report.not_shown_one": "1 Fläche konnte nicht gezeichnet werden und ist ausgeblendet.",
@@ -69,8 +63,6 @@
"import_report.hint": "ייצוא מחדש של החלקים האלה מהתוכנה שיצרה אותם עשוי לפתור זאת. ב„העתקת הודעת האזהרה” יש את הפרטים.",
"import_report.left_out_one": "לא ניתן היה לתקן פאה אחת והיא חסרה.",
"import_report.left_out_other": "לא ניתן היה לתקן {count} פאות והן חסרות.",
"import_report.mesh_one": "לא ניתן היה להפוך חלק אחד לגוף מוצק והוא מוצג כרשת.",
"import_report.mesh_other": "לא ניתן היה להפוך {count} חלקים לגופים מוצקים והם מוצגים כרשתות.",
"import_report.not_imported_one": "לא ניתן היה לייבא חלק אחד.",
"import_report.not_imported_other": "לא ניתן היה לייבא {count} חלקים.",
"import_report.not_shown_one": "לא ניתן היה לצייר פאה אחת והיא מוסתרת.",
@@ -90,8 +82,6 @@
"import_report.hint": "作成元のプログラムからこれらのパーツを書き出し直すと直ることがあります。詳細は「警告メッセージをコピー」で確認できます。",
"import_report.left_out_one": "1 個の面を修復できず、欠けています。",
"import_report.left_out_other": "{count} 個の面を修復できず、欠けています。",
"import_report.mesh_one": "1 個のパーツをソリッドにできず、メッシュで表示しています。",
"import_report.mesh_other": "{count} 個のパーツをソリッドにできず、メッシュで表示しています。",
"import_report.not_imported_one": "1 個のパーツを読み込めませんでした。",
"import_report.not_imported_other": "{count} 個のパーツを読み込めませんでした。",
"import_report.not_shown_one": "1 個の面を描画できず、非表示です。",
@@ -111,8 +101,6 @@
"import_report.hint": "이 부품을 만든 프로그램에서 다시 내보내면 해결될 수 있습니다. 세부 정보는 경고 메시지 복사에 있습니다.",
"import_report.left_out_one": "면 1개를 복구하지 못해 빠져 있습니다.",
"import_report.left_out_other": "면 {count}개를 복구하지 못해 빠져 있습니다.",
"import_report.mesh_one": "부품 1개를 솔리드로 만들지 못해 메시로 표시합니다.",
"import_report.mesh_other": "부품 {count}개를 솔리드로 만들지 못해 메시로 표시합니다.",
"import_report.not_imported_one": "부품 1개를 가져오지 못했습니다.",
"import_report.not_imported_other": "부품 {count}개를 가져오지 못했습니다.",
"import_report.not_shown_one": "면 1개를 그리지 못해 숨겨져 있습니다.",
@@ -132,8 +120,6 @@
"import_report.hint": "从创建这些零件的程序重新导出可能会解决此问题。“复制警告消息”中有详细信息。",
"import_report.left_out_one": "1 个面无法修复,已缺失。",
"import_report.left_out_other": "{count} 个面无法修复,已缺失。",
"import_report.mesh_one": "1 个零件无法生成实体,以网格显示。",
"import_report.mesh_other": "{count} 个零件无法生成实体,以网格显示。",
"import_report.not_imported_one": "1 个零件无法导入。",
"import_report.not_imported_other": "{count} 个零件无法导入。",
"import_report.not_shown_one": "1 个面无法绘制,已隐藏。",
+20 -13
View File
@@ -57,7 +57,7 @@ pub struct ImportedBody {
pub file_faces: usize,
/// 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.
/// but those.
pub refused: Option<String>,
}
@@ -99,10 +99,11 @@ pub struct StepImport {
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueKind {
/// No body could be made of the part: it is not in the design.
/// The part could not be read from the file: it is not in the design.
NotImported,
/// No solid could be made of the part: it is shown as a mesh of its faces.
ShownAsMesh,
/// The kernel refuses the part's B-rep, its faces' too: it is not in the design, and the
/// warning gives the kernel's reason.
Refused,
/// No solid could be made of the part: it came in as surfaces of its faces.
ImportedAsSurfaces,
/// A face of the part could not be read or made, and is left out.
@@ -365,6 +366,12 @@ impl Reader {
false
}
/// `item`'s part is not in the design: the kernel refuses its B-rep, for `why`.
fn refused(&mut self, item: u64, why: &str) {
let label = self.label(item);
self.note(IssueKind::Refused, Some(item), 0, format!("{label}: not imported: {why}"));
}
/// A warning about the file as a whole.
fn warn(&mut self, msg: impl Into<String>) {
self.note(IssueKind::File, None, 0, msg);
@@ -788,8 +795,7 @@ impl Reader {
}
if let Some(again) = self.unreadable.get(&item) {
if let Some(msg) = again.clone() {
let label = self.label(item);
self.note(IssueKind::NotImported, Some(item), 0, format!("{label}: not imported: {msg}"));
self.refused(item, &msg);
}
continue;
}
@@ -816,8 +822,7 @@ impl Reader {
None => {
if let Some(again) = self.unreadable.get(&item) {
if let Some(msg) = again.clone() {
let label = self.label(item);
self.warn(format!("{label}: not imported: {msg}"));
self.refused(item, &msg);
}
continue;
}
@@ -840,9 +845,7 @@ impl Reader {
if let Some(failed) = read {
let label = self.label(item);
if let Some(why) = &part.refused {
let kind = if part.body.is_mesh() { IssueKind::ShownAsMesh } else { IssueKind::ImportedAsSurfaces };
let words = if part.body.is_mesh() { "shown as a mesh" } else { "imported as surfaces" };
self.note(kind, Some(item), 0, format!("{label}: {words}: {why}"));
self.note(IssueKind::ImportedAsSurfaces, Some(item), 0, format!("{label}: imported as surfaces: {why}"));
}
for f in &failed {
let (kind, faces, f) = match f {
@@ -924,8 +927,12 @@ impl Reader {
});
}
Err(parallel::Failure { read, msg }) => {
let label = self.label(item);
self.note(IssueKind::NotImported, Some(item), 0, format!("{label}: not imported: {msg}"));
if read {
let label = self.label(item);
self.note(IssueKind::NotImported, Some(item), 0, format!("{label}: not imported: {msg}"));
} else {
self.refused(item, &msg);
}
// Later instances don't read the item again.
if !self.oriented.contains_key(&item) {
self.unreadable.insert(item, (!read).then_some(msg));
+9 -42
View File
@@ -27,8 +27,7 @@ pub(super) struct Part {
/// The file face of each kernel face.
pub face_origin: Vec<u64>,
/// 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`]).
/// the faces that read ([`surface_part`]).
pub refused: Option<String>,
}
@@ -204,9 +203,9 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
/// [`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.
/// Where SMLib cannot stitch them either, or there is no face to import, the solid stays refused
/// with the reason it was: a B-rep SMLib refuses is never kept as a mesh of its faces.
fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std::result::Result<(Part, Vec<Note>), Failure> {
let _span = solidcraft_profile::span("import", "step.refused_surfaces");
let trims = &solidcraft_smlib::pcurves::SmlibTrims;
@@ -256,13 +255,12 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
Err(e) => notes.push(Note::LeftOut(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)
}
}
// Where SMLib cannot stitch them either, the solid is refused with the reason it was.
let part = stitched_part(&pieces, faces.len(), &refused.msg).map_err(|e| {
log::debug!(target: "step", "item #{item}: its faces not stitched ({e})");
refused
})?;
Ok((part, notes))
}
/// The one-face surface bodies `pieces` (each with its file face) stitched by SMLib into one
@@ -290,37 +288,6 @@ fn stitched_part(pieces: &[(u64, Body)], file_faces: usize, refused: &str) -> cr
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<Note>, refused: Failure) -> std::result::Result<(Part, Vec<Note>), 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 = 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 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(Note::LeftOut(format!("left out: face #{face}: no triangles"))),
Err(e) => notes.push(Note::LeftOut(format!("left out: 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, 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.
+20 -5
View File
@@ -490,6 +490,17 @@ fn offset_surface_cube_import_is_closed_and_editable() {
assert!(rel(measure(&result).unwrap().volume, 1000.0 - 40.0 * std::f64::consts::PI) < 1e-3);
}
/// A solid whose B-rep SMLib refuses, its faces too (here every SMLib call: the build has no
/// SMLib), is refused with the kernel's reason, naming the solid. It once came in as a mesh of
/// its faces, which the planner could still triangulate.
#[cfg(not(feature = "smlib"))]
#[test]
fn a_solid_smlib_refuses_with_its_faces_is_refused_not_meshed() {
let text = format!("{HEAD}{}#60=SHAPE_REPRESENTATION('',(#101),$);\n{TAIL}", cube_entities());
let why = step_import(&text).map(|imp| imp.warnings).unwrap_err().to_string();
assert!(why.contains("solid 'Cube': not imported: ") && why.contains("SMLib"), "{why}");
}
/// A hand-written unit cube brep (`#100`…) in a representation with the given unit and items.
fn cube_entities() -> String {
let mut s = String::new();
@@ -1748,7 +1759,9 @@ fn periodic_knot_tube(first: u64, x: f64, r: f64, h: f64) -> (String, u64) {
/// Two such tubes far apart in one body: the body's size sets a chord tolerance coarse beside
/// the tubes, and meshing a face at it divided the surface's whole parameter box, whose
/// corners lay outside the domain. The division never converged there (#1374's recursion),
/// and the import grew by gigabytes without end.
/// and the import grew by gigabytes without end. SMLib does not give the sheet's faces back in
/// the file's order, so the import refuses it with that reason (it once came in as a mesh of
/// its faces); the faces as read are meshed at the body's display tolerance, the coarse one.
#[test]
fn periodic_knot_tube_faces_import_and_mesh() {
let t0 = std::time::Instant::now();
@@ -1758,11 +1771,13 @@ fn periodic_knot_tube_faces_import_and_mesh() {
let text = format!(
"{HEAD}{a}{b}#1=OPEN_SHELL('',(#{fa},#{fb}));\n#2=SHELL_BASED_SURFACE_MODEL('Tubes',(#1));\n#3=SHAPE_REPRESENTATION('',(#2),$);\n{TAIL}"
);
let imp = step_import(&text).unwrap();
assert_eq!(imp.bodies.len(), 1, "{:?}", imp.warnings);
let m = measure(&imp.bodies[0].body).unwrap();
let why = step_import(&text).map(|imp| imp.warnings).unwrap_err().to_string();
assert!(why.contains("solid 'Tubes': not imported: ") && why.contains("renumbered"), "{why}");
let ex = p21::parse(&text).unwrap();
let sheet = Body::from_record(solidcraft_step::topology::read_solid(&ex, 2, Units::default()).unwrap().brep).unwrap();
let mesh = sheet.mesh(&crate::MeshTolerance::display_of(sheet.size())).unwrap();
let area = 2.0 * std::f64::consts::TAU * r * h;
assert!(rel(m.area, area) < 0.02, "area {} (expected {area})", m.area);
assert!(rel(mesh.measure().area, area) < 0.02, "area {} (expected {area})", mesh.measure().area);
assert!(t0.elapsed().as_secs_f64() < 60.0, "took {:?}", t0.elapsed());
}
+5 -5
View File
@@ -398,7 +398,7 @@ pub(crate) fn import_summary(report: &ImportReport) -> String {
}
let sentences = [
("import_report.not_imported", parts_of(IssueKind::NotImported)),
("import_report.mesh", parts_of(IssueKind::ShownAsMesh)),
("import_report.refused", parts_of(IssueKind::Refused)),
("import_report.surfaces", parts_of(IssueKind::ImportedAsSurfaces)),
("import_report.left_out", faces_of(IssueKind::FaceLeftOut)),
("import_report.open", parts_of(IssueKind::OpenBody)),
@@ -408,7 +408,7 @@ pub(crate) fn import_summary(report: &ImportReport) -> String {
("import_report.file", file),
];
out.extend(sentences.iter().filter(|(_, n)| *n > 0).map(|(key, n)| counted(key, *n)));
let lost = [IssueKind::NotImported, IssueKind::ShownAsMesh, IssueKind::ImportedAsSurfaces, IssueKind::FaceLeftOut, IssueKind::OpenBody];
let lost = [IssueKind::NotImported, IssueKind::Refused, IssueKind::ImportedAsSurfaces, IssueKind::FaceLeftOut, IssueKind::OpenBody];
if lost.iter().any(|k| parts.contains_key(k)) {
out.push(crate::i18n::tr("import_report.hint").to_string());
}
@@ -853,10 +853,10 @@ mod tests {
solids: 40,
issues: vec![
issue(
IssueKind::ShownAsMesh,
IssueKind::Refused,
Some(7),
0,
"solid #7: shown as a mesh: pcurves failed on 1 of 122 faces; first: face #9: SMLib pcurves: an edge lies 13.73370504071075 mm off the face's surface",
"solid #7: not imported: pcurves failed on 1 of 122 faces; first: face #9: SMLib pcurves: an edge lies 13.73370504071075 mm off the face's surface",
),
issue(
IssueKind::FaceLeftOut,
@@ -876,7 +876,7 @@ mod tests {
let summary = super::import_summary(&report);
assert_eq!(
summary,
"Imported 40 of 43 parts as solids. 1 part couldn't be imported. 1 part couldn't be made into a solid and is shown as a mesh. \
"Imported 40 of 43 parts as solids. 1 part couldn't be imported. 1 part couldn't be made into a solid and was not imported. \
1 part couldn't be made into a solid and came in as surfaces. 3 faces couldn't be repaired and are missing. \
Small flaws were repaired in 2 parts. There was 1 other problem with the file. \
Exporting these parts again from the program that made them may fix this. Copy Warning Message has the details."