STEP import: the feature's warning says in words what became of the parts

The kernel's import warnings get a structure (ImportReport: the file's parts, how many came
in as solids, and each warning's kind, part, faces and count; the reads' notes are typed as
repairs or faces left out). The Import feature's result carries it (and the model cache keeps
it), and the timeline tooltip and the status line after an import word it from the catalogs:
"Imported 40 of 43 parts as solids. 1 part couldn't be made into a solid and is shown as a
mesh. 3 faces couldn't be repaired and are missing." with what may help. Entity numbers and
the kernel's wording stay in Copy Warning Message, which now lists every warning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
jyviko
2026-10-28 08:57:39 -04:00
committed by Irakli Kourtis
parent b7d813bd6c
commit 920acc5279
10 changed files with 490 additions and 42 deletions
@@ -0,0 +1,149 @@
{
"en": {
"import_report.details": "Details:",
"import_report.file_one": "There was 1 other problem with the file.",
"import_report.file_other": "There were {count} other problems with the file.",
"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.",
"import_report.not_shown_other": "{count} faces couldn't be drawn and are hidden.",
"import_report.open_one": "1 part has gaps between its faces and came in as an open body.",
"import_report.open_other": "{count} parts have gaps between their faces and came in as open bodies.",
"import_report.repaired_one": "Small flaws were repaired in 1 part.",
"import_report.repaired_other": "Small flaws were repaired in {count} parts.",
"import_report.solids": "Imported {solids} of {parts} parts as solids.",
"import_report.surfaces_one": "1 part couldn't be made into a solid and came in as surfaces.",
"import_report.surfaces_other": "{count} parts couldn't be made into solids and came in as surfaces."
},
"ar": {
"import_report.details": "التفاصيل:",
"import_report.file_one": "كانت هناك مشكلة أخرى واحدة في الملف.",
"import_report.file_other": "كانت هناك {count} مشكلات أخرى في الملف.",
"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": "تعذّر رسم وجه واحد وهو مخفي.",
"import_report.not_shown_other": "تعذّر رسم {count} أوجه وهي مخفية.",
"import_report.open_one": "في جزء واحد فجوات بين أوجهه، فاستُورد كجسم مفتوح.",
"import_report.open_other": "في {count} أجزاء فجوات بين أوجهها، فاستُوردت كأجسام مفتوحة.",
"import_report.repaired_one": "أُصلحت عيوب صغيرة في جزء واحد.",
"import_report.repaired_other": "أُصلحت عيوب صغيرة في {count} أجزاء.",
"import_report.solids": "استُورد {solids} من {parts} أجزاء كمجسمات.",
"import_report.surfaces_one": "تعذّر تحويل جزء واحد إلى مجسم، فاستُورد كأسطح.",
"import_report.surfaces_other": "تعذّر تحويل {count} أجزاء إلى مجسمات، فاستُوردت كأسطح."
},
"de": {
"import_report.details": "Details:",
"import_report.file_one": "Die Datei hatte 1 weiteres Problem.",
"import_report.file_other": "Die Datei hatte {count} weitere Probleme.",
"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.",
"import_report.not_shown_other": "{count} Flächen konnten nicht gezeichnet werden und sind ausgeblendet.",
"import_report.open_one": "1 Teil hat Lücken zwischen seinen Flächen und kam als offener Körper herein.",
"import_report.open_other": "{count} Teile haben Lücken zwischen ihren Flächen und kamen als offene Körper herein.",
"import_report.repaired_one": "Kleine Fehler wurden an 1 Teil repariert.",
"import_report.repaired_other": "Kleine Fehler wurden an {count} Teilen repariert.",
"import_report.solids": "{solids} von {parts} Teilen als Volumenkörper importiert.",
"import_report.surfaces_one": "1 Teil konnte nicht zu einem Volumenkörper werden und kam als Flächen herein.",
"import_report.surfaces_other": "{count} Teile konnten nicht zu Volumenkörpern werden und kamen als Flächen herein."
},
"he": {
"import_report.details": "פרטים:",
"import_report.file_one": "הייתה בעיה נוספת אחת בקובץ.",
"import_report.file_other": "היו {count} בעיות נוספות בקובץ.",
"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": "לא ניתן היה לצייר פאה אחת והיא מוסתרת.",
"import_report.not_shown_other": "לא ניתן היה לצייר {count} פאות והן מוסתרות.",
"import_report.open_one": "בחלק אחד יש רווחים בין הפאות והוא יובא כגוף פתוח.",
"import_report.open_other": "ב-{count} חלקים יש רווחים בין הפאות והם יובאו כגופים פתוחים.",
"import_report.repaired_one": "תוקנו פגמים קטנים בחלק אחד.",
"import_report.repaired_other": "תוקנו פגמים קטנים ב-{count} חלקים.",
"import_report.solids": "{solids} מתוך {parts} חלקים יובאו כגופים מוצקים.",
"import_report.surfaces_one": "לא ניתן היה להפוך חלק אחד לגוף מוצק והוא יובא כמשטחים.",
"import_report.surfaces_other": "לא ניתן היה להפוך {count} חלקים לגופים מוצקים והם יובאו כמשטחים."
},
"ja": {
"import_report.details": "詳細:",
"import_report.file_one": "ファイルにほかに 1 件の問題がありました。",
"import_report.file_other": "ファイルにほかに {count} 件の問題がありました。",
"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 個の面を描画できず、非表示です。",
"import_report.not_shown_other": "{count} 個の面を描画できず、非表示です。",
"import_report.open_one": "1 個のパーツは面の間にすき間があり、開いたボディとして読み込まれました。",
"import_report.open_other": "{count} 個のパーツは面の間にすき間があり、開いたボディとして読み込まれました。",
"import_report.repaired_one": "1 個のパーツの小さな不具合を修復しました。",
"import_report.repaired_other": "{count} 個のパーツの小さな不具合を修復しました。",
"import_report.solids": "{parts} 個のパーツのうち {solids} 個をソリッドとして読み込みました。",
"import_report.surfaces_one": "1 個のパーツをソリッドにできず、サーフェスとして読み込みました。",
"import_report.surfaces_other": "{count} 個のパーツをソリッドにできず、サーフェスとして読み込みました。"
},
"ko": {
"import_report.details": "세부 정보:",
"import_report.file_one": "파일에 다른 문제가 1개 있었습니다.",
"import_report.file_other": "파일에 다른 문제가 {count}개 있었습니다.",
"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개를 그리지 못해 숨겨져 있습니다.",
"import_report.not_shown_other": "면 {count}개를 그리지 못해 숨겨져 있습니다.",
"import_report.open_one": "부품 1개는 면 사이에 틈이 있어 열린 바디로 들어왔습니다.",
"import_report.open_other": "부품 {count}개는 면 사이에 틈이 있어 열린 바디로 들어왔습니다.",
"import_report.repaired_one": "부품 1개의 작은 결함을 복구했습니다.",
"import_report.repaired_other": "부품 {count}개의 작은 결함을 복구했습니다.",
"import_report.solids": "부품 {parts}개 중 {solids}개를 솔리드로 가져왔습니다.",
"import_report.surfaces_one": "부품 1개를 솔리드로 만들지 못해 서피스로 들어왔습니다.",
"import_report.surfaces_other": "부품 {count}개를 솔리드로 만들지 못해 서피스로 들어왔습니다."
},
"zh": {
"import_report.details": "详细信息:",
"import_report.file_one": "文件还有 1 个其他问题。",
"import_report.file_other": "文件还有 {count} 个其他问题。",
"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 个面无法绘制,已隐藏。",
"import_report.not_shown_other": "{count} 个面无法绘制,已隐藏。",
"import_report.open_one": "1 个零件的面之间有缝隙,作为开放实体导入。",
"import_report.open_other": "{count} 个零件的面之间有缝隙,作为开放实体导入。",
"import_report.repaired_one": "已修复 1 个零件中的小缺陷。",
"import_report.repaired_other": "已修复 {count} 个零件中的小缺陷。",
"import_report.solids": "{parts} 个零件中有 {solids} 个作为实体导入。",
"import_report.surfaces_one": "1 个零件无法生成实体,作为曲面导入。",
"import_report.surfaces_other": "{count} 个零件无法生成实体,作为曲面导入。"
}
}
+27 -3
View File
@@ -733,6 +733,7 @@ pub struct EvalCache {
struct CachedEval {
error: Option<String>,
warning: Option<String>,
import_report: Option<Arc<kernel::ImportReport>>,
ms: f64,
state: Arc<ModelState>,
/// What the state costs, worked out once when it was stored (see
@@ -938,6 +939,10 @@ pub struct FeatureResult {
pub name: String,
pub error: Option<String>,
pub warning: Option<String>,
/// An Import feature's warnings by kind and part, with what came in of the file's parts
/// (`warning` is the first of them as the kernel words them).
#[serde(skip_serializing_if = "Option::is_none")]
pub import_report: Option<Arc<kernel::ImportReport>>,
pub ms: f64,
pub skipped: bool,
/// Not evaluated on the document's current inputs: a feature at or after a sketch shown live
@@ -1868,6 +1873,7 @@ impl Model {
name: f.name.clone(),
error: None,
warning: None,
import_report: None,
ms: 0.0,
skipped: true,
stale: false,
@@ -1903,6 +1909,7 @@ impl Model {
name: f.name.clone(),
error: c.error,
warning: c.warning,
import_report: c.import_report,
ms: c.ms,
skipped: false,
stale: false,
@@ -1936,6 +1943,7 @@ impl Model {
b.made = crate::lineage::Made::Before;
}
let mut warning = None;
let mut import_report = None;
// Face references by name: move each face point onto its named face (on the body the
// feature names, when it names one and the face is there).
let renamed;
@@ -1959,7 +1967,7 @@ impl Model {
let r = match frame_error.or(bad_param) {
Some(e) => Err(kernel::KernelError::Failed(e)),
None => kernel::guard(&f.name, || {
eval_feature(doc, &vals, f, &mut next, &mut warning).map_err(|e| kernel::KernelError::Failed(e.to_string()))
eval_feature(doc, &vals, f, &mut next, &mut warning, &mut import_report).map_err(|e| kernel::KernelError::Failed(e.to_string()))
}),
};
if r.is_ok() {
@@ -2011,7 +2019,14 @@ impl Model {
Err(e) => Some(e.to_string().trim_start_matches("the operation failed: ").to_string()),
};
if !self.no_cache {
let e = CachedEval { error: error.clone(), warning: warning.clone(), ms, bytes: state.added_bytes(&before), state: state.clone() };
let e = CachedEval {
error: error.clone(),
warning: warning.clone(),
import_report: import_report.clone(),
ms,
bytes: state.added_bytes(&before),
state: state.clone(),
};
if let Some(k) = &self.kept.0 {
k.put(key, &e);
}
@@ -2025,6 +2040,7 @@ impl Model {
name: f.name.clone(),
error,
warning,
import_report,
ms,
skipped: false,
stale: false,
@@ -6214,7 +6230,14 @@ fn chamfer_with(
})
}
fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
fn eval_feature(
doc: &Document,
vals: &BTreeMap<String, Value>,
f: &Feature,
st: &mut ModelState,
warning: &mut Option<String>,
import_report: &mut Option<Arc<kernel::ImportReport>>,
) -> Result<()> {
// Its tools meet bodies in other placements where those are shown (#3383).
let _frames = ToolFrames::install(doc, f, st);
match &f.kind {
@@ -7018,6 +7041,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
let shown: Vec<&str> = imp.warnings.iter().take(3).map(String::as_str).collect();
let more = if n > 3 { format!(" (+{} more)", n - 3) } else { String::new() };
*warning = Some(format!("{}{more}", shown.join("; ")));
*import_report = Some(Arc::new(imp.report.clone()));
}
Ok(())
}
+15 -1
View File
@@ -228,6 +228,9 @@ impl Model {
if let Some(w) = &r.warning {
f["warning"] = json!(w);
}
if let Some(report) = &r.import_report {
f["import_report"] = serde_json::to_value(report.as_ref()).map_err(|e| kernel::KernelError::Invalid(e.to_string()))?;
}
features.push(f);
}
let records = json!({
@@ -589,6 +592,10 @@ impl Model {
name: f.name.clone(),
error: None,
warning: r.get("warning").map(|w| text(Some(w))).transpose()?,
import_report: r
.get("import_report")
.map(|v| serde_json::from_value::<kernel::ImportReport>(v.clone()).map(Arc::new).map_err(|_| bad("saved model: import report")))
.transpose()?,
ms,
skipped: r.get("skipped").and_then(Json::as_bool).ok_or_else(|| bad("saved model: skipped"))?,
stale: false,
@@ -623,7 +630,14 @@ impl Model {
let local = fingerprint(doc, 0, f, &vals, false, &model.step_digests) ^ placement_digest(doc, f, Some(&before));
cache.put(
cache_key(f, r.fingerprint, local, ctx, &before),
CachedEval { error: None, warning: r.warning.clone(), ms: r.ms, bytes: r.state.retained_bytes(), state: r.state.clone() },
CachedEval {
error: None,
warning: r.warning.clone(),
import_report: r.import_report.clone(),
ms: r.ms,
bytes: r.state.retained_bytes(),
state: r.state.clone(),
},
);
before = r.state.clone();
}
+1 -1
View File
@@ -208,7 +208,7 @@ pub use sew::{EdgeSpec, FaceSpec, SurfSpec, sew, sew_touching};
pub use splitface::{SplitTool, split_body, split_faces, split_faces_with_map};
pub use step::step_export;
pub use step_in::{
ImportedBody, StepImport, StepNode, StepReading, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate,
ImportIssue, ImportReport, ImportedBody, IssueKind, StepImport, StepNode, StepReading, step_cache_alive, step_import, step_import_shared, step_orientation_errors, step_validate,
};
pub use step_out::{ExportBody, ExportProduct, StepHeader, step_export_bodies, step_export_products};
pub use surface_extend::{ExtendLimit, ExtendMode, ExtendOptions, extend_surface};
+119 -12
View File
@@ -82,12 +82,75 @@ pub struct StepImport {
pub tree: Vec<StepNode>,
/// What could not be read (unsupported entities, faces left out…).
pub warnings: Vec<String>,
/// The same, by kind and part, with what came in of the file's parts: for a summary in words.
pub report: ImportReport,
/// FILE_SCHEMA (e.g. `AUTOMOTIVE_DESIGN`, `CONFIG_CONTROL_DESIGN`, `AP242_…`).
pub schema: String,
/// FILE_NAME originating system.
pub system: String,
}
/// What an import warning is about, so a window can word a summary of them (the warning's
/// text is the detail).
#[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.
NotImported,
/// No solid could be made of the part: it is shown as a mesh of its faces.
ShownAsMesh,
/// 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.
FaceLeftOut,
/// The part's shell is not closed: it came in as an open body.
OpenBody,
/// Faces of the part could not be meshed and are not shown.
FacesNotShown,
/// Something was repaired on the way (edges split at a pole, faces turned, gaps covered).
Repaired,
/// The file as a whole: its assembly structure, its limits, its entity ids.
File,
}
/// One import warning: what it is about and the part it concerns.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ImportIssue {
pub kind: IssueKind,
/// The STEP item of the part (`None`: the file as a whole).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub item: Option<u64>,
/// The part's name in the file (empty: it has none).
#[serde(default, skip_serializing_if = "String::is_empty")]
pub part: String,
/// Faces it concerns: one for a face left out, the count for faces not shown, else 0.
#[serde(default)]
pub faces: usize,
/// How many times it came (a part not imported says so at each of its instances).
pub times: usize,
/// The warning as [`StepImport::warnings`] words it.
pub detail: String,
}
/// What an import made of the file's parts, and its warnings by kind and part.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ImportReport {
/// Parts (distinct solid items) the file places.
pub parts: usize,
/// Parts that came in as closed solids.
pub solids: usize,
/// In the order of [`StepImport::warnings`].
pub issues: Vec<ImportIssue>,
}
/// A warning kept by [`Reader::note`]: what it is about and how often it came.
struct Noted {
kind: IssueKind,
item: Option<u64>,
faces: usize,
times: usize,
}
/// A solid the walk found, converted later.
struct Planned {
item: u64,
@@ -189,7 +252,7 @@ fn frame_matrix(p: &solidcraft_step::geom::Placement) -> M4 {
struct Reader {
ex: Arc<Exchange>,
units: HashMap<u64, Units>,
warnings: BTreeMap<String, usize>,
warnings: BTreeMap<String, Noted>,
/// Representation → its items and context.
reps: HashMap<u64, (Vec<u64>, Option<u64>)>,
/// Plain representation relationships (same product, no placement).
@@ -293,8 +356,20 @@ impl Reader {
false
}
/// A warning about the file as a whole.
fn warn(&mut self, msg: impl Into<String>) {
*self.warnings.entry(msg.into()).or_default() += 1;
self.note(IssueKind::File, None, 0, msg);
}
/// A warning of `kind` about `item`'s part (`faces`: the faces it concerns), counted when it
/// comes again.
fn note(&mut self, kind: IssueKind, item: Option<u64>, faces: usize, msg: impl Into<String>) {
self.warnings.entry(msg.into()).or_insert(Noted { kind, item, faces, times: 0 }).times += 1;
}
/// The part's own name in the file (empty: it has none).
fn part_name(&self, item: u64) -> String {
self.ex.get(item).and_then(|e| e.params().first()).and_then(Param::as_str).unwrap_or("").trim().to_string()
}
fn index(&mut self) {
@@ -574,7 +649,7 @@ impl Reader {
}
fn label(&self, item: u64) -> String {
let name = self.ex.get(item).and_then(|e| e.params().first()).and_then(Param::as_str).unwrap_or("").trim().to_string();
let name = self.part_name(item);
if name.is_empty() { format!("solid #{item}") } else { format!("solid '{name}'") }
}
@@ -708,7 +783,7 @@ impl Reader {
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.note(IssueKind::NotImported, Some(item), 0, format!("{label}: not imported: {msg}"));
}
continue;
}
@@ -732,14 +807,19 @@ 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() { "shown as a mesh" } else { "imported as surfaces" };
self.warn(format!("{label}: {kind}: {why}"));
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}"));
}
for f in &failed {
self.warn(format!("{label}: {f}"));
let (kind, faces, f) = match f {
parallel::Note::LeftOut(f) => (IssueKind::FaceLeftOut, 1, f),
parallel::Note::Repaired(f) => (IssueKind::Repaired, 0, f),
};
self.note(kind, Some(item), faces, format!("{label}: {f}"));
}
if !part.closed && part.refused.is_none() {
self.warn(format!("{label}: imported as an open body (the shell is not closed)"));
self.note(IssueKind::OpenBody, Some(item), 0, format!("{label}: imported as an open body (the shell is not closed)"));
}
}
if !self.once.contains(&item) {
@@ -750,7 +830,12 @@ impl Reader {
let name = if own.is_empty() { product.clone() } else { own };
// Faces the mesher gives up on (or that the budget cut short) are reported.
if self.unmeshed_checked.insert(item) && missing > 0 {
self.warn(format!("solid '{name}': {missing} face(s) could not be meshed and are not shown"));
self.note(
IssueKind::FacesNotShown,
Some(item),
missing,
format!("solid '{name}': {missing} face(s) could not be meshed and are not shown"),
);
}
if let Some(slot) = self.made.get_mut(i) {
*slot = Some(self.bodies.len());
@@ -798,7 +883,7 @@ impl Reader {
}
Err(parallel::Failure { read, msg }) => {
let label = self.label(item);
self.warn(format!("{label}: not imported: {msg}"));
self.note(IssueKind::NotImported, Some(item), 0, format!("{label}: not imported: {msg}"));
// Later instances don't read the item again.
if !self.oriented.contains_key(&item) {
self.unreadable.insert(item, (!read).then_some(msg));
@@ -809,6 +894,27 @@ impl Reader {
true
}
/// The warnings (worded as `warnings`, in their order) by kind and part, and how many of the
/// file's parts came in as closed solids.
fn report(&self, warnings: &[String]) -> ImportReport {
let parts = self.plan.iter().map(|p| p.item).collect::<HashSet<_>>().len();
let solids = self.bodies.iter().filter(|b| b.closed && b.refused.is_none()).map(|b| b.source_item).collect::<HashSet<_>>().len();
let issues = self
.warnings
.values()
.zip(warnings)
.map(|(n, detail)| ImportIssue {
kind: n.kind,
item: n.item,
part: n.item.map(|i| self.part_name(i)).unwrap_or_default(),
faces: n.faces,
times: n.times,
detail: detail.clone(),
})
.collect();
ImportReport { parts, solids, issues }
}
/// Planned-solid indices (a node's `bodies` after the walk) as body indices, leaving out
/// the ones that failed.
fn remap(&self, planned: &[usize]) -> Vec<usize> {
@@ -1179,13 +1285,14 @@ impl StepReading {
if let Some(first) = ex.duplicates.first() {
rd.warn(format!("{} entity ids are defined twice (first #{first}); the first definitions were used", ex.duplicates.len()));
}
let warnings: Vec<String> = rd.warnings.iter().map(|(m, n)| if *n > 1 { format!("{m} (×{n})") } else { m.clone() }).collect();
let warnings: Vec<String> = rd.warnings.iter().map(|(m, n)| if n.times > 1 { format!("{m} (×{})", n.times) } else { m.clone() }).collect();
let report = rd.report(&warnings);
if rd.bodies.is_empty() {
let why = warnings.first().cloned().unwrap_or_else(|| "the file has no solids".into());
return Err(KernelError::Failed(format!("STEP import: no bodies ({why})")));
}
let bodies = std::mem::take(&mut rd.bodies);
Ok(StepImport { bodies, tree, warnings, schema: ex.schemas.join(", "), system: ex.originating_system.clone() })
Ok(StepImport { bodies, tree, warnings, report, schema: ex.schemas.join(", "), system: ex.originating_system.clone() })
}
/// [`Self::finish`], and the import is cached for the next read of the same text.
+22 -16
View File
@@ -39,8 +39,16 @@ pub(super) struct Made {
/// Faces the mesher missed (counted when the job asked).
pub missing: usize,
pub part: Arc<Part>,
/// This job read the item: the faces the read left out, with the reason.
pub read: Option<Vec<String>>,
/// This job read the item: what the read repaired and the faces it left out.
pub read: Option<Vec<Note>>,
}
/// What reading an item noted.
pub(super) enum Note {
/// Repaired on the way (the reader's notes).
Repaired(String),
/// A face left out, with the reason.
LeftOut(String),
}
/// Why a job made no body: the item could not be read (`read`), or its body not made.
@@ -90,13 +98,13 @@ 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 ([`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> {
pub(super) fn smlib_part(ex: &Exchange, item: u64, units: Units) -> std::result::Result<(Part, Vec<Note>), 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))
Ok((part, solid.notes.into_iter().map(Note::Repaired).collect()))
}
/// The part SMLib makes of a solid (or sheet) record the reader made.
@@ -199,7 +207,7 @@ const MAX_LEFT_OUT_ROUNDS: usize = 8;
/// 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> {
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;
let Ok(faces) = solidcraft_step::topology::read_faces_with(ex, item, units, trims) else { return Err(refused) };
@@ -223,11 +231,14 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
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))) {
match read
.map_err(|msg| Failure { read: true, msg })
.and_then(|solid| Ok((solid_part(&solid)?, solid.notes.into_iter().map(Note::Repaired).collect::<Vec<_>>())))
{
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}")));
notes.extend(left_out.iter().map(|(_, m)| Note::LeftOut(format!("left out: {m}"))));
return Ok((part, notes));
}
Err(e) => {
@@ -242,7 +253,7 @@ fn surface_part(ex: &Exchange, item: u64, units: Units, refused: Failure) -> std
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: "))),
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) {
@@ -283,12 +294,7 @@ fn stitched_part(pieces: &[(u64, Body)], file_faces: usize, refused: &str) -> cr
/// 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> {
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();
@@ -302,8 +308,8 @@ fn mesh_part(
positions.extend_from_slice(&m.positions);
triangles.extend(m.triangles.iter().map(|t| t.map(|i| i.saturating_add(base))));
}
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: "))),
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() {
+2
View File
@@ -927,6 +927,8 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
app.set_status(
if made.result.get("read_only").and_then(Value::as_bool) == Some(true) {
crate::i18n::tr("document.opened_read_only").to_string()
} else if let Some(summary) = crate::timeline::import_status(app, &made.result) {
summary
} else if let Some(w) = warnings.filter(|w| !w.is_empty()) {
format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or(""))
} else {
+3 -1
View File
@@ -1730,7 +1730,9 @@ impl SolveApp {
if let Ok(r) = self.run(cmd, json!({ "path": path })) {
self.fit_view();
stand_up::offer(self, path, &before, Some(&r));
if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
if let Some(summary) = timeline::import_status(self, &r) {
self.set_status(summary, false);
} else if let Some(w) = r["warnings"].as_array().filter(|w| !w.is_empty()) {
self.set_status(format!("imported with {} warning(s): {}", w.len(), w.first().and_then(Value::as_str).unwrap_or("")), false);
}
}
+148 -5
View File
@@ -4,8 +4,9 @@ use crate::ui_kit::{Container, Edge, Surface};
use egui::{Rect, Sense, UiBuilder, pos2, vec2};
use serde_json::{Value, json};
use solidcraft_engine::doc::{
FeatureKind, PlaneRef, SketchId,
FeatureKind, FeatureResult, PlaneRef, SketchId,
construct::{AxisDef, PointDef},
kernel::{ImportReport, IssueKind},
};
use crate::SolveApp;
@@ -293,6 +294,21 @@ pub(crate) fn clipboard_buttons(app: &mut SolveApp, ui: &mut egui::Ui, id: u64)
}
}
/// A feature tooltip's (and its copied message's) name, type, error and warning: an import's
/// summary as it is (the mark already says it is a warning), any other warning labelled.
pub(crate) fn feature_status(name: &str, ty: &str, result: Option<&FeatureResult>) -> String {
let error = result.and_then(|r| r.error.as_deref());
match result.and_then(|r| r.import_report.as_deref()).filter(|r| !r.issues.is_empty()) {
Some(report) => {
let mut text = status_text(name, ty, error, None);
text.push('\n');
text.push_str(&import_summary(report));
text
}
None => status_text(name, ty, error, result.and_then(|r| r.warning.as_deref())),
}
}
/// The common prefix of a feature tooltip and its copied status message.
pub(crate) fn status_text(name: &str, ty: &str, error: Option<&str>, warning: Option<&str>) -> String {
let mut text = format!("{name} ({ty})");
@@ -308,14 +324,94 @@ pub(crate) fn status_text(name: &str, ty: &str, error: Option<&str>, warning: Op
}
/// What a feature's error or warning mark says, as its tooltip shows it (name and type, then
/// the error and the warning), for the clipboard. `None` when it has neither.
/// the error and the warning), for the clipboard, and an import's every warning as the kernel
/// words them. `None` when it has neither.
pub(crate) fn feature_message(app: &SolveApp, id: u64) -> Option<String> {
let result = app.session.model.result(id)?;
if result.error.is_none() && result.warning.is_none() {
return None;
}
let feature = app.session.doc.features.iter().find(|f| f.id == id)?;
Some(status_text(&feature.name, feature.kind.type_name(), result.error.as_deref(), result.warning.as_deref()))
let mut text = feature_status(&feature.name, feature.kind.type_name(), Some(result));
if let Some(report) = result.import_report.as_ref().filter(|r| !r.issues.is_empty()) {
text.push_str("\n\n");
text.push_str(crate::i18n::tr("import_report.details"));
for issue in &report.issues {
text.push('\n');
text.push_str(&issue.detail);
}
}
Some(text)
}
/// What a feature's warning mark says: an import's warnings summed up in words, else the
/// feature's warning.
pub(crate) fn warning_text(result: &FeatureResult) -> Option<String> {
match result.import_report.as_deref().filter(|r| !r.issues.is_empty()) {
Some(report) => Some(import_summary(report)),
None => result.warning.clone(),
}
}
/// The status line after an open or an import with warnings: their summary. The import made
/// the reply's `feature`; a STEP file opened as a design is the design's one import. `None`
/// without an import's warnings.
pub(crate) fn import_status(app: &SolveApp, reply: &Value) -> Option<String> {
let reported = |r: &&FeatureResult| r.import_report.as_deref().is_some_and(|r| !r.issues.is_empty());
let result = match reply.get("feature").and_then(Value::as_u64) {
Some(id) => app.session.model.result(id).filter(reported)?,
// Only when the open says it has warnings: a design opened with an import in it does not.
None if reply.get("warnings").and_then(Value::as_array).is_some_and(|w| !w.is_empty()) => {
let mut imports = app.session.model.results.iter().filter(reported);
match (imports.next(), imports.next()) {
(Some(only), None) => only,
_ => return None,
}
}
None => return None,
};
result.import_report.as_deref().map(import_summary)
}
/// An import's warnings for a person who uses CAD: how many of the file's parts came in as
/// solids, then what happened to the others, by kind, counted by part (faces for the faces left
/// out or not drawn), and what may help. Entity numbers and the kernel's wording stay in the
/// details ([`feature_message`]).
pub(crate) fn import_summary(report: &ImportReport) -> String {
let mut parts: std::collections::BTreeMap<IssueKind, std::collections::BTreeSet<Option<u64>>> = Default::default();
let mut faces: std::collections::BTreeMap<IssueKind, usize> = Default::default();
let mut file = 0usize;
for issue in &report.issues {
if issue.kind == IssueKind::File {
file += 1;
continue;
}
parts.entry(issue.kind).or_default().insert(issue.item);
*faces.entry(issue.kind).or_default() += issue.faces;
}
let parts_of = |kind| parts.get(&kind).map_or(0, std::collections::BTreeSet::len);
let faces_of = |kind| faces.get(&kind).copied().unwrap_or(0);
let counted = |key: &str, n: usize| crate::i18n::format(&format!("{key}_{}", if n == 1 { "one" } else { "other" }), &[("count", &n.to_string())]);
let mut out = Vec::new();
if report.parts > 1 && report.solids < report.parts {
out.push(crate::i18n::format("import_report.solids", &[("solids", &report.solids.to_string()), ("parts", &report.parts.to_string())]));
}
let sentences = [
("import_report.not_imported", parts_of(IssueKind::NotImported)),
("import_report.mesh", parts_of(IssueKind::ShownAsMesh)),
("import_report.surfaces", parts_of(IssueKind::ImportedAsSurfaces)),
("import_report.left_out", faces_of(IssueKind::FaceLeftOut)),
("import_report.open", parts_of(IssueKind::OpenBody)),
("import_report.not_shown", faces_of(IssueKind::FacesNotShown)),
("import_report.repaired", parts_of(IssueKind::Repaired)),
("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];
if lost.iter().any(|k| parts.contains_key(k)) {
out.push(crate::i18n::tr("import_report.hint").to_string());
}
out.join(" ")
}
/// Copy Error Message (or Copy Warning Message) in the menu of a feature that has one.
@@ -526,7 +622,7 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
let resp = ui.interact(br, ui.id().with(("feat", *id)), Sense::click_and_drag());
let res = app.session.model.result(*id);
let err = res.and_then(|r| r.error.clone());
let warn = res.and_then(|r| r.warning.clone());
let warn = res.and_then(warning_text);
let rolled = i >= marker;
let is_it = |s: &solidcraft_engine::Sel| matches!(s, solidcraft_engine::Sel::Feature { id: f } if f == id);
let sel = app.session.selection.iter().any(is_it) || app.dialog.as_ref().is_some_and(|d| d.items().iter().any(is_it));
@@ -551,7 +647,7 @@ fn timeline_contents(app: &mut SolveApp, ui: &mut egui::Ui, floating: bool) {
if floating && marker > 0 && i == marker - 1 {
p.circle_filled(pos2(br.center().x, br.bottom() + t.space_3), t.space_2, accent.color);
}
let mut tip = status_text(name, ty, err.as_deref(), warn.as_deref());
let mut tip = feature_status(name, ty, res);
if let Some(r) = res {
tip += &format!("\n{:.1} ms", r.ms);
}
@@ -743,6 +839,53 @@ mod tests {
assert_eq!(super::status_text("Box", "BoxFeature", Some("error"), Some("warning")), "Box (BoxFeature)\nError: error\nWarning: warning");
}
/// An import's warnings read as a summary for a CAD user: parts counted by what became of
/// them, faces counted, no entity numbers or kernel wording (those stay in the details).
#[test]
fn an_import_summary_counts_parts_and_faces_in_plain_words() {
use solidcraft_engine::doc::kernel::{ImportIssue, ImportReport, IssueKind};
let _english = crate::i18n::scope("en");
let issue =
|kind, item: Option<u64>, faces, detail: &str| ImportIssue { kind, item, part: String::new(), faces, times: 1, detail: detail.into() };
let report = ImportReport {
parts: 43,
solids: 40,
issues: vec![
issue(
IssueKind::ShownAsMesh,
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",
),
issue(
IssueKind::FaceLeftOut,
Some(7),
1,
"solid #7: not shown: face #9: SMLib pcurves: SMLib rejected the input (SM_ERR_INVALID_INPUT)",
),
issue(IssueKind::ImportedAsSurfaces, Some(8), 0, "solid #8: imported as surfaces: …"),
issue(IssueKind::FaceLeftOut, Some(8), 1, "solid #8: left out: face #11: …"),
issue(IssueKind::FaceLeftOut, Some(8), 1, "solid #8: left out: face #12: …"),
issue(IssueKind::NotImported, Some(5), 0, "solid #5: not imported: …"),
issue(IssueKind::Repaired, Some(3), 0, "solid #3: 2 seam edges added to faces that wind around their surface"),
issue(IssueKind::Repaired, Some(4), 0, "solid #4: 1 faces turned over: their loops disagree with their same_sense"),
issue(IssueKind::File, None, 0, "component placement #77: … (placed at the origin)"),
],
};
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. \
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."
);
assert!(!summary.contains('#') && !summary.contains("SMLib"), "{summary}");
// Only repairs: no advice, and a part alone has no "of" count.
let repaired = ImportReport { parts: 1, solids: 1, issues: vec![issue(IssueKind::Repaired, Some(3), 0, "solid #3: …")] };
assert_eq!(super::import_summary(&repaired), "Small flaws were repaired in 1 part.");
}
#[test]
fn history_marker_tooltip_uses_the_language_in_both_layouts() {
for left in [false, true] {
+4 -3
View File
@@ -1257,7 +1257,8 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us
let result = app.session.model.result(id);
let elapsed = result.map(|r| r.ms);
let error = result.and_then(|result| result.error.clone());
let warning = result.and_then(|result| result.warning.clone());
let warning = result.and_then(crate::timeline::warning_text);
let status = (error.is_some() || warning.is_some()).then(|| crate::timeline::feature_status(name, ty, result));
let rolled = match row_kind {
RowKind::Reference { parent } => parent >= marker,
RowKind::Own | RowKind::Nested => index >= marker,
@@ -1314,8 +1315,8 @@ fn feature_row(app: &mut SolveApp, ui: &mut egui::Ui, rows: &mut Rows, index: us
.unwrap_or_else(|| elapsed.map_or_else(String::new, |ms| format!("{ms:.1} ms")));
let kind = crate::i18n::tr_label(ty.strip_suffix("Feature").unwrap_or(ty));
let mut tip = format!("{name} · {kind} · {summary}");
if error.is_some() || warning.is_some() {
tip.push_str(&format!("\n{}", crate::timeline::status_text(name, ty, error.as_deref(), warning.as_deref())));
if let Some(status) = &status {
tip.push_str(&format!("\n{status}"));
}
crate::ui_kit::text(ui, &tip, crate::ui_kit::Text::Value("history", false));
// A value row is one line: where the sketch is listed gets a row of its own.