From 659bb06cd67045c9692fc65058c9e361e5fcd521 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:06:16 +0300 Subject: [PATCH 1/9] IMPORT: read SAT, STEP, IGES and WMF files; ACISIN and WMFIN ACIS text files come in one object per body (3DSOLID, REGION or SURFACE). STEP B-rep models become a block per product with component inserts at their placements, scaled to the drawing's units; IGES manifold solids come in as 3DSOLIDs scaled to the drawing's units with their colours. Splines that are exactly analytic shapes are kept as those shapes. Windows metafiles become a WMF block of polylines and text fitted to half the view, placed at its top-left corner or by WMFIN's insertion prompts. The Import File window lists the four formats as supported. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 6 +- Cargo.toml | 7 + locales/ar-SA/opencadstudio.ftl | 4 - locales/bg-BG/opencadstudio.ftl | 4 - locales/cs-CZ/opencadstudio.ftl | 4 - locales/de-DE/opencadstudio.ftl | 4 - locales/el-GR/opencadstudio.ftl | 4 - locales/en-US/opencadstudio.ftl | 4 - locales/es-ES/opencadstudio.ftl | 4 - locales/fi-FI/opencadstudio.ftl | 4 - locales/fr-FR/opencadstudio.ftl | 4 - locales/hi-IN/opencadstudio.ftl | 4 - locales/hu-HU/opencadstudio.ftl | 4 - locales/it-IT/opencadstudio.ftl | 4 - locales/ja-JP/opencadstudio.ftl | 4 - locales/ko-KR/opencadstudio.ftl | 4 - locales/lo-LA/opencadstudio.ftl | 4 - locales/nl-NL/opencadstudio.ftl | 4 - locales/pl-PL/opencadstudio.ftl | 4 - locales/pt-BR/opencadstudio.ftl | 4 - locales/ru-RU/opencadstudio.ftl | 4 - locales/tr-TR/opencadstudio.ftl | 4 - locales/zh-CN/opencadstudio.ftl | 4 - locales/zh-TW/opencadstudio.ftl | 4 - src/app/commands/dgn_import.rs | 44 +- src/app/commands/mod.rs | 6 + src/app/commands/model_import.rs | 206 ++++++++ src/io/brep_build.rs | 205 ++++++++ src/io/iges_import.rs | 454 +++++++++++++++++ src/io/mod.rs | 5 + src/io/model_import.rs | 154 ++++++ src/io/step_import.rs | 829 +++++++++++++++++++++++++++++++ src/io/wmf_import.rs | 399 +++++++++++++++ src/locale_catalog.rs | 4 - src/scene/camera_ops.rs | 11 + src/scene/convert/acis_import.rs | 75 +++ src/scene/convert/mod.rs | 1 + src/ui/window/import_dialogs.rs | 19 +- 38 files changed, 2400 insertions(+), 113 deletions(-) create mode 100644 src/app/commands/model_import.rs create mode 100644 src/io/brep_build.rs create mode 100644 src/io/iges_import.rs create mode 100644 src/io/model_import.rs create mode 100644 src/io/step_import.rs create mode 100644 src/io/wmf_import.rs create mode 100644 src/scene/convert/acis_import.rs diff --git a/Cargo.lock b/Cargo.lock index 0244239b9..f6b095873 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=cb3ce6a#cb3ce6a93c76a5069f5c2391ee44ea38b4485d15" +source = "git+https://github.com/ramox81/cadcodec.git?rev=6c33e24#6c33e24dc3c72034afec8333f0554541f01cc508" dependencies = [ "anyhow", "bitflags 2.13.1", @@ -4415,7 +4415,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=40f59ca#40f59caf1e18df6c4bad3b53389fe59d0e740796" +source = "git+https://github.com/ramox81/cadkernel.git?rev=9490fb8#9490fb81fa81088d7890ce87af01353fd834101f" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4426,7 +4426,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=40f59ca#40f59caf1e18df6c4bad3b53389fe59d0e740796" +source = "git+https://github.com/ramox81/cadkernel.git?rev=9490fb8#9490fb81fa81088d7890ce87af01353fd834101f" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index e37088072..9b8c5d8f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -222,3 +222,10 @@ harness = false [[test]] name = "integration" path = "tests/integration.rs" + +[patch."https://github.com/HakanSeven12/opencadcodec.git"] +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6c33e24" } + +[patch."https://github.com/HakanSeven12/opencadkernel.git"] +opencadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "9490fb8" } +opencadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "9490fb8" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 6429a0f89..17fb92d2f 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6663,10 +6663,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c37642d03..b798f6561 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index abf843f86..ecce74d58 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6648,10 +6648,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index ba3802c7f..0c3562333 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6649,10 +6649,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 9ea02d2ca..613846df9 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 0b25e247b..17fad18ae 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6650,10 +6650,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a5efe2c3e..7a916b29a 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index d4e00edd1..33ea55802 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6648,10 +6648,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0e5473ea4..64c14a6a6 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e7f26c977..e3b6e630c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6639,10 +6639,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 15bee6825..c6258c905 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6635,10 +6635,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index df881cc37..07c39a13d 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6650,10 +6650,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 271c127f7..87ea4668b 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6639,10 +6639,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 63f5bfb85..5ea09af87 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6650,10 +6650,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index fc88b2625..de060bb70 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 60ff5d8e4..a372e1b9d 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6649,10 +6649,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 3801846f2..7474a495e 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6650,10 +6650,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 22723a1dc..0b1da574c 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6651,10 +6651,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index ae46aa3b6..0e5451071 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6652,10 +6652,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 80109e3fe..daa2854cf 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6572,10 +6572,6 @@ txt2mtxt = dgn-import = .supported = Destekleniyor .by-extension = Uzantıya göre - .acis-solver = ACIS çözücü var - .export-only = Yalnız dışa aktarma - .acis-reason = ACIS gövdeleri yalnız çizim içinde okunur; SAT dosyası içe aktarılamaz. - .step-reason = STEP dosyaları yalnız yazılır (STEPOUT), okunmaz. .no-importer = OpenCAD Studio'da bu biçim için içe aktarıcı yok. .recent = Son: .group-2d = 2B / Altlık diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a87e01d6e..a7b10f69c 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6634,10 +6634,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 0ff11fa53..63c0df350 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6646,10 +6646,6 @@ txt2mtxt = dgn-import = .supported = Supported .by-extension = By extension - .acis-solver = ACIS solver available - .export-only = Export only - .acis-reason = ACIS bodies are read only inside drawings; SAT files cannot be imported. - .step-reason = STEP files are only written (STEPOUT), not read. .no-importer = OpenCAD Studio has no importer for this format. .recent = Recent: .group-2d = 2D / Underlay diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 23efa5b81..1a220cd90 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::sync::Arc; use super::*; +use crate::io::model_import::ModelFormat; use crate::modules::insert::dgn_import::{self, DgnImportCommand, DgnImportRequest}; use crate::ui::window::import_dialogs::{ unsupported_reason, DgnImportState, ImportDialogMsg, ImportFileState, Support, FORMATS, @@ -18,7 +19,7 @@ const RECENT: usize = 5; /// Picks a file and registers its bytes (the web build reads them only /// here); `None` when the picker is cancelled. -async fn pick(title: String, filters: Vec<(String, Vec)>) -> Option { +pub(super) async fn pick(title: String, filters: Vec<(String, Vec)>) -> Option { let mut dialog = crate::sys::file_dialog().set_title(title.as_str()); for (name, extensions) in &filters { let extensions: Vec<&str> = extensions.iter().map(String::as_str).collect(); @@ -62,6 +63,8 @@ fn is_dgn(path: &str) -> bool { enum Route { Pdf, Dgn, + Model(ModelFormat), + Wmf, } /// The importer for `path` chosen as `format` (an index of [`FORMATS`]; `None` @@ -75,6 +78,21 @@ fn route(path: &str, format: Option) -> Result { } let ext = extension(path); let by_extension = f.is_none_or(|f| f.support == Support::ByExtension); + // The chosen format, or the one whose patterns name the extension. + let named = if by_extension { + FORMATS + .iter() + .find(|f| f.extensions.iter().any(|e| e.eq_ignore_ascii_case(&ext))) + } else { + f + }; + match named.map(|f| f.name) { + Some("ACIS") => return Ok(Route::Model(ModelFormat::Sat)), + Some("STEP") => return Ok(Route::Model(ModelFormat::Step)), + Some("IGES") => return Ok(Route::Model(ModelFormat::Iges)), + Some("Metafile") => return Ok(Route::Wmf), + _ => {} + } let pdf = if by_extension { ext == "pdf" } else { f.is_some_and(|f| f.name == "PDF Files") }; if pdf { return Ok(Route::Pdf); @@ -111,8 +129,8 @@ impl OpenCADStudio { Some(Task::none()) } ("IMPORT", true) => { - use crate::command::{CadCommand, ValuePromptCommand}; - let command = ValuePromptCommand::new("_IMPORTFILE", "Enter import file name:"); + use crate::command::{CadCommand, FreeTextValuePromptCommand}; + let command = FreeTextValuePromptCommand::new("_IMPORTFILE", "Enter import file name:"); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); Some(Task::none()) @@ -217,6 +235,16 @@ impl OpenCADStudio { } self.dispatch_command(&format!("-DGNIMPORT {path}")) } + Ok(Route::Model(format)) => { + self.remember_import(path); + let i = self.active_tab; + self.import_model(i, path, format) + } + Ok(Route::Wmf) => { + self.remember_import(path); + let i = self.active_tab; + self.import_wmf(i, path, false) + } Err(error) => { self.command_line.push_error(&error); Task::none() @@ -324,6 +352,16 @@ impl OpenCADStudio { self.command_line.push_error(&error); } } + Ok(Route::Model(kind)) => { + self.remember_import(&path); + self.close_active_modal(); + return self.import_model(i, &path, kind); + } + Ok(Route::Wmf) => { + self.remember_import(&path); + self.close_active_modal(); + return self.import_wmf(i, &path, false); + } } } // ── DGN Import ─────────────────────────────────────────────── diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a1af0cdcd..ac2bf7cc6 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -20,6 +20,7 @@ mod fileops; mod inquiry; mod layerprops; mod layers; +mod model_import; mod pc_colormap; mod pc_extract; mod pdf_dialogs; @@ -393,6 +394,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_import(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_model_import(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_bvisibility(cmd, i) { return Some(t); } @@ -994,6 +998,8 @@ inventory::submit!(crate::command::CommandRegistration { "QNEW", "OBJIMPORT", "IMPORT", + "ACISIN", + "WMFIN", "DGNIMPORT", "-DGNIMPORT", "OPEN", diff --git a/src/app/commands/model_import.rs b/src/app/commands/model_import.rs new file mode 100644 index 000000000..94b86d8f3 --- /dev/null +++ b/src/app/commands/model_import.rs @@ -0,0 +1,206 @@ +//! ACISIN and WMFIN, and the files IMPORT routes here: ACIS text (SAT), +//! STEP and IGES models and Windows metafiles, read into the current drawing +//! as one undo step. + +use super::*; +use crate::io::model_import::{self, ModelFormat}; + +impl OpenCADStudio { + /// ACISIN and WMFIN; `None` defers to the other families. + pub(super) fn dispatch_model_import(&mut self, cmd: &str, i: usize) -> Option> { + let (verb, rest) = match cmd.trim().split_once(char::is_whitespace) { + Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim().trim_matches('"')), + None => (cmd.trim().to_ascii_uppercase(), ""), + }; + match (verb.as_str(), rest.is_empty()) { + ("ACISIN", true) if crate::sys::file_dia() => { + let filters = vec![("ACIS (*.sat)".to_string(), vec!["sat".to_string()])]; + let title = crate::t!("Select ACIS File").into_owned(); + Some(Task::perform(super::dgn_import::pick(title, filters), |path| match path { + Some(path) => Message::Command(format!("_ACISINFILE {path}")), + None => Message::Noop, + })) + } + ("ACISIN", true) => { + use crate::command::FreeTextValuePromptCommand; + let command = FreeTextValuePromptCommand::new("_ACISINFILE", "Enter SAT file name:"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("ACISIN" | "_ACISINFILE", false) => { + if crate::scene::model::pdf_raster::source_bytes(rest).is_none() { + let name = std::path::Path::new(rest) + .file_name() + .map_or_else(|| rest.to_string(), |n| n.to_string_lossy().into_owned()); + let here = std::env::current_dir() + .map(|d| d.display().to_string()) + .unwrap_or_default(); + self.command_line.push_error(&format!( + "\"{name}\": Can't find file in search path:\n {here}\\ (current directory)" + )); + return Some(Task::none()); + } + Some(self.import_model(i, rest, ModelFormat::Sat)) + } + ("_ACISINFILE", true) => Some(Task::none()), + ("WMFIN", true) if crate::sys::file_dia() => { + let filters = vec![("Metafile (*.wmf)".to_string(), vec!["wmf".to_string()])]; + let title = crate::t!("Import WMF").into_owned(); + Some(Task::perform(super::dgn_import::pick(title, filters), |path| match path { + Some(path) => Message::Command(format!("_WMFINFILE {path}")), + None => Message::Noop, + })) + } + ("WMFIN", true) => { + use crate::command::FreeTextValuePromptCommand; + let command = FreeTextValuePromptCommand::new( + "_WMFINFILE", + "Enter name of WMF file to import (no extension):", + ); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + Some(Task::none()) + } + ("WMFIN" | "_WMFINFILE", false) => { + let path = if std::path::Path::new(rest).extension().is_some() { + rest.to_string() + } else { + format!("{rest}.wmf") + }; + Some(self.import_wmf(i, &path, true)) + } + ("_WMFINFILE", true) => Some(Task::none()), + _ => None, + } + } + + /// Reads a model file into drawing `i`. + pub(in crate::app) fn import_model(&mut self, i: usize, path: &str, format: ModelFormat) -> Task { + let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(path) else { + self.command_line + .push_error(&format!("File {path} does not exist.")); + return Task::none(); + }; + // The translated formats report the job as they run. + let translated = format != ModelFormat::Sat; + if translated { + self.command_line + .push_output("Import job in progress. Press Esc key to cancel."); + } + let drawing_mm = crate::app::properties::insunits_to_mm( + self.tabs[i].scene.document.header.insertion_units, + ); + let Some(imported) = model_import::read(format, &bytes, drawing_mm) else { + let shown = path.replace('/', "\\"); + match format { + ModelFormat::Sat => { + self.command_line.push_error("Modeling Operation Error:"); + self.command_line.push_error("Error Code Number is 58000"); + self.command_line + .push_error(&format!("Unable to read the ACIS file {shown}.")); + } + _ => self + .command_line + .push_error(&format!("Import failed: {shown} has no model the importer reads.")), + } + return Task::none(); + }; + self.push_undo_snapshot(i, "IMPORT"); + let scene = &mut self.tabs[i].scene; + // A block the drawing already has keeps its definition and is + // inserted as it is. + for (name, entities) in imported.blocks { + if scene.document.block_records.get(&name).is_none() { + let _ = scene.define_block_from_owned_entities(entities, &name, glam::DVec3::ZERO); + } + } + for entity in imported.entities { + scene.add_entity(entity); + } + self.tabs[i].dirty = true; + if translated { + self.command_line.push_output("Import Job Complete"); + } + self.refresh_properties(); + Task::none() + } + + /// A metafile as a new block `WMF`, fitted to half the current view: + /// placed at the view's top-left corner (IMPORT), or by the insertion + /// prompts (WMFIN). + pub(in crate::app) fn import_wmf(&mut self, i: usize, path: &str, prompt: bool) -> Task { + let Some(bytes) = crate::scene::model::pdf_raster::source_bytes(path) else { + self.command_line + .push_error(&format!("File {path} does not exist.")); + return Task::none(); + }; + let (centre, size, pixel) = self.tabs[i].scene.view_window(); + let Some(mut picture) = + crate::io::wmf_import::read(&bytes, [0.5 * size[0], 0.5 * size[1]]) + else { + self.command_line + .push_error(&format!("{} is not a valid Windows metafile.", path.replace('/', "\\"))); + return Task::none(); + }; + self.push_undo_snapshot(i, if prompt { "WMFIN" } else { "IMPORT" }); + let scene = &mut self.tabs[i].scene; + // A text style per font, named WMF- after the first free number. + let mut styles: Vec = Vec::new(); + for _ in &picture.fonts { + let mut n = 0; + let name = loop { + let name = format!("WMF-{n}"); + if !scene.document.text_styles.contains(&name) && !styles.contains(&name) { + break name; + } + n += 1; + }; + let mut style = codec::tables::TextStyle::new(name.as_str()); + style.handle = scene.document.allocate_handle(); + // No font of its own, as the metafile names the face only. + style.font_file = String::new(); + let _ = scene.document.text_styles.add(style); + styles.push(name); + } + for (at, font) in &picture.text_fonts { + if let Some(codec::EntityType::Text(text)) = picture.entities.get_mut(*at) { + text.style = styles[*font].clone(); + } + } + let mut n = 0; + let name = loop { + let name = format!("WMF{n}"); + if scene.document.block_records.get(&name).is_none() { + break name; + } + n += 1; + }; + if let Err(error) = + scene.define_block_from_owned_entities(picture.entities, &name, glam::DVec3::ZERO) + { + self.command_line.push_error(&error); + return Task::none(); + } + self.tabs[i].dirty = true; + if prompt { + use crate::command::CadCommand; + use crate::modules::insert::insert_block::InsertBlockCommand; + let preview = self.tabs[i].scene.block_preview_wires(&name); + let command = InsertBlockCommand::classic_for_block(name, preview, String::new()); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } else { + // The view's top-left corner, a pixel or two in. + let corner = codec::types::Vector3::new( + centre[0] - 0.5 * size[0] + pixel, + centre[1] + 0.5 * size[1] - 2.0 * pixel, + 0.0, + ); + let insert = codec::entities::Insert::new(&name, corner); + self.tabs[i].scene.add_entity(codec::EntityType::Insert(insert)); + self.refresh_properties(); + } + Task::none() + } +} diff --git a/src/io/brep_build.rs b/src/io/brep_build.rs new file mode 100644 index 000000000..c8dd1b254 --- /dev/null +++ b/src/io/brep_build.rs @@ -0,0 +1,205 @@ +//! Assembles a kernel B-rep from the topology an exchange file spells out: +//! vertices, edges along their curves, faces bounded by loops of those +//! edges, shells of faces. The STEP and IGES readers share it. +//! +//! Spline curves and surfaces that are exactly lines, circles, planes, +//! spheres, cylinders, cones or tori are kept as those shapes: many systems write +//! every one of them as a spline. + +use std::f64::consts::TAU; + +use kernel::brep::{ + Body, Coedge, Curve3, Edge, EdgeKey, Face, Loop, Lump, Provenance, Shell, Surface, Vertex, + VertexKey, +}; +use kernel::space::Vec3; + +/// A face as the file gives it: its surface, whether its outward normal +/// agrees with the surface's, and its loops — the outer one first — as +/// edges with the sense each is run in. +pub struct FaceSpec { + pub surface: Surface, + pub forward: bool, + pub loops: Vec>, +} + +pub struct Builder { + pub body: Body, +} + +/// Whether a curve repeats, and over how long a parameter run. +fn period(curve: &Curve3) -> Option { + match curve { + Curve3::Circle(_) | Curve3::Ellipse(_) => Some(TAU), + Curve3::Nurbs(nurbs) if nurbs.periodicity() => { + let (from, to) = nurbs.domain(); + Some(to - from) + } + _ => None, + } +} + +/// The parameter of a point on (or next to) a curve. +fn parameter(curve: &Curve3, point: [f64; 3]) -> Option { + Some(match curve { + Curve3::Line(line) => { + let direction = Vec3::from(line.direction); + (Vec3::from(point) - Vec3::from(line.origin)).dot(direction) + / direction.length_squared().max(f64::MIN_POSITIVE) + } + Curve3::Circle(circle) => { + let [x, y] = circle.plane.project(point)?; + y.atan2(x).rem_euclid(TAU) + } + Curve3::Ellipse(ellipse) => { + let [x, y] = ellipse.plane.project(point)?; + (y / ellipse.minor_radius) + .atan2(x / ellipse.major_radius) + .rem_euclid(TAU) + } + Curve3::Nurbs(nurbs) => nurbs.parameter_at(point), + Curve3::PlanarSpline { .. } => return None, + }) +} + +/// The same curve run the other way. +fn reversed(curve: Curve3) -> Option { + Some(match curve { + Curve3::Line(mut line) => { + line.direction = (-Vec3::from(line.direction)).to_array(); + Curve3::Line(line) + } + Curve3::Nurbs(nurbs) => Curve3::Nurbs(nurbs.reversed()?), + _ => return None, + }) +} + +impl Builder { + pub fn new() -> Self { + Self { body: Body::new() } + } + + pub fn vertex(&mut self, point: [f64; 3]) -> VertexKey { + self.body.vertices.insert(Vertex { + point, + provenance: Provenance::Synthesized, + }) + } + + fn point(&self, vertex: VertexKey) -> Option<[f64; 3]> { + self.body.vertices.get(vertex).map(|v| v.point) + } + + /// An edge along `curve` from `start` to `end`, which the curve runs + /// through in that order. A curve that turns out to run the other way + /// (or an open one handed its ends swapped) is reversed to match. + pub fn edge(&mut self, curve: Curve3, start: VertexKey, end: VertexKey) -> Option { + let (a, b) = (self.point(start)?, self.point(end)?); + let mut curve = match curve { + Curve3::Nurbs(nurbs) => kernel::brep::analytic_curve(&nurbs).unwrap_or(Curve3::Nurbs(nurbs)), + other => other, + }; + let mut t0 = parameter(&curve, a)?; + let mut t1 = parameter(&curve, b)?; + match period(&curve) { + Some(period) => { + if let Curve3::Nurbs(nurbs) = &curve { + let (from, _) = nurbs.domain(); + if start == end { + t0 = from; + } + } + if start == end { + t1 = t0 + period; + } else { + while t1 <= t0 + 1e-12 { + t1 += period; + } + } + } + None => { + if start == end { + // A clamped curve closing on itself: its whole domain. + if let Curve3::Nurbs(nurbs) = &curve { + (t0, t1) = nurbs.domain(); + } + } else if t1 < t0 { + curve = reversed(curve)?; + t0 = parameter(&curve, a)?; + t1 = parameter(&curve, b)?; + } + } + } + if !(t1 > t0) { + return None; + } + let curve = self.body.curves.insert(curve); + Some(self.body.edges.insert(Edge { + curve, + start_parameter: t0, + end_parameter: t1, + start, + end, + coedges: Vec::new(), + provenance: Provenance::Synthesized, + })) + } + + /// A lump of shells — the outer one first, then voids — each a list of + /// faces. `None` when a face has no surface or an edge has gone. + pub fn lump(&mut self, shells: Vec>) -> Option<()> { + let lump = self.body.lumps.insert(Lump { + shells: Vec::new(), + provenance: Provenance::Synthesized, + }); + for faces in shells { + let shell = self.body.shells.insert(Shell { + faces: Vec::new(), + owner: lump, + provenance: Provenance::Synthesized, + }); + for spec in faces { + let (surface, forward) = match spec.surface { + Surface::Nurbs(nurbs) => match kernel::brep::analytic_surface(&nurbs) { + Some((analytic, agrees)) => (analytic, spec.forward == agrees), + None => (Surface::Nurbs(nurbs), spec.forward), + }, + other => (other, spec.forward), + }; + let surface = self.body.surfaces.insert(surface); + let face = self.body.faces.insert(Face { + surface, + forward, + loops: Vec::new(), + owner: shell, + provenance: Provenance::Synthesized, + }); + for ring in spec.loops { + let key = self.body.loops.insert(Loop { + coedges: Vec::new(), + owner: face, + provenance: Provenance::Synthesized, + }); + let mut coedges = Vec::with_capacity(ring.len()); + for (edge, forward) in ring { + let coedge = self.body.coedges.insert(Coedge { + edge, + forward, + pcurve: None, + owner: key, + provenance: Provenance::Synthesized, + }); + self.body.edges.get_mut(edge)?.coedges.push(coedge); + coedges.push(coedge); + } + self.body.loops.get_mut(key)?.coedges = coedges; + self.body.faces.get_mut(face)?.loops.push(key); + } + self.body.shells.get_mut(shell)?.faces.push(face); + } + self.body.lumps.get_mut(lump)?.shells.push(shell); + } + self.body.roots.push(lump); + Some(()) + } +} diff --git a/src/io/iges_import.rs b/src/io/iges_import.rs new file mode 100644 index 000000000..64c0ee4d4 --- /dev/null +++ b/src/io/iges_import.rs @@ -0,0 +1,454 @@ +//! IGES reading: the manifold solids (type 186) of a file, their shells, +//! faces, loops and edges (514/510/508/504/502) over spline surfaces and +//! curves (128/126), lines and arcs (110/100), as kernel bodies. + +use std::collections::HashMap; + +use kernel::brep::{Circle3, Curve3, EdgeKey, Line3, Surface, VertexKey}; +use kernel::space::{NurbsCurve3, NurbsSurface3, Plane}; + +use super::brep_build::{Builder, FaceSpec}; + +/// One parameter: a number, or a Hollerith string. +#[derive(Debug, Clone)] +enum P { + Num(f64), + Str(String), +} + +impl P { + fn num(&self) -> f64 { + match self { + P::Num(v) => *v, + P::Str(_) => 0.0, + } + } +} + +/// Splits a section's text into parameters at `delimiter`, up to the first +/// `terminator`, reading `nH…` strings whole. +fn parameters(text: &str, delimiter: char, terminator: char) -> Vec

{ + let chars: Vec = text.chars().collect(); + let mut out = Vec::new(); + let mut i = 0; + loop { + while chars.get(i).is_some_and(|c| *c == ' ') { + i += 1; + } + let start = i; + while chars.get(i).is_some_and(|c| c.is_ascii_digit()) { + i += 1; + } + if i > start && matches!(chars.get(i), Some('H' | 'h')) { + let count: usize = chars[start..i].iter().collect::().parse().unwrap_or(0); + let body: String = chars.iter().skip(i + 1).take(count).collect(); + i += 1 + count; + out.push(P::Str(body)); + } else { + i = start; + while chars + .get(i) + .is_some_and(|c| *c != delimiter && *c != terminator) + { + i += 1; + } + let raw: String = chars[start..i].iter().collect::().replace(['D', 'd'], "E"); + out.push(P::Num(raw.trim().parse().unwrap_or(0.0))); + } + while chars.get(i).is_some_and(|c| *c == ' ') { + i += 1; + } + match chars.get(i) { + Some(c) if *c == delimiter => i += 1, + _ => return out, + } + } +} + +/// A directory entry and its parameters. +struct Entity { + kind: i32, + /// Pointer to a transformation matrix entity, or zero. + transform: usize, + /// Colour number, or a negated pointer to a colour definition. + color: i64, + params: Vec

, +} + +struct Doc { + /// Keyed by the entry's first directory line number. + entities: HashMap, + /// File units to drawing units. + scale: f64, +} + +/// A 3×4 matrix applied as `R·p + T`. +type Matrix = [[f64; 4]; 3]; + +impl Doc { + fn get(&self, pointer: f64) -> Option<&Entity> { + self.entities.get(&(pointer.abs() as usize)) + } + + /// An entry's colour: a numbered one, or a definition's RGB percentages + /// — as the nearest standard index colour when it is one exactly. + fn color(&self, number: i64) -> codec::types::Color { + use codec::types::Color; + let rgb = match number { + 1 => [0, 0, 0], + 2 => [255, 0, 0], + 3 => [0, 255, 0], + 4 => [0, 0, 255], + 5 => [255, 255, 0], + 6 => [255, 0, 255], + 7 => [0, 255, 255], + 8 => [255, 255, 255], + n if n < 0 => match self.entities.get(&(-n as usize)).filter(|e| e.kind == 314) { + Some(e) => [1, 2, 3].map(|k| { + (e.params.get(k).map_or(0.0, P::num) * 2.55).round().clamp(0.0, 255.0) as u8 + }), + None => return Color::ByLayer, + }, + _ => return Color::ByLayer, + }; + match rgb { + [255, 0, 0] => Color::Index(1), + [255, 255, 0] => Color::Index(2), + [0, 255, 0] => Color::Index(3), + [0, 255, 255] => Color::Index(4), + [0, 0, 255] => Color::Index(5), + [255, 0, 255] => Color::Index(6), + [255, 255, 255] => Color::Index(7), + [r, g, b] => Color::Rgb { r, g, b }, + } + } + + fn matrix(&self, pointer: usize) -> Option { + let entity = self.entities.get(&pointer).filter(|e| e.kind == 124)?; + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let mut m = [[v(1), v(2), v(3), v(4)], [v(5), v(6), v(7), v(8)], [v(9), v(10), v(11), v(12)]]; + // Matrices chain through their own transformation pointer. + if let Some(outer) = (entity.transform != 0).then(|| self.matrix(entity.transform)).flatten() { + let mut product = [[0.0; 4]; 3]; + for r in 0..3 { + for c in 0..4 { + product[r][c] = (0..3).map(|k| outer[r][k] * m[k][c]).sum::() + + if c == 3 { outer[r][3] } else { 0.0 }; + } + } + m = product; + } + Some(m) + } + + /// `entity`'s placement applied to a point it defines. + fn place(&self, entity: &Entity) -> impl Fn([f64; 3]) -> [f64; 3] { + let m = (entity.transform != 0).then(|| self.matrix(entity.transform)).flatten(); + let scale = self.scale; + move |p| { + let p = match m { + Some(m) => [0, 1, 2].map(|r| m[r][0] * p[0] + m[r][1] * p[1] + m[r][2] * p[2] + m[r][3]), + None => p, + }; + p.map(|c| c * scale) + } + } + + fn curve(&self, pointer: f64) -> Option { + let entity = self.get(pointer)?; + let p = &entity.params; + let v = |k: usize| p.get(k).map_or(0.0, P::num); + let place = self.place(entity); + Some(match entity.kind { + 110 => { + let a = place([v(1), v(2), v(3)]); + let b = place([v(4), v(5), v(6)]); + Curve3::Line(Line3 { + origin: a, + direction: [b[0] - a[0], b[1] - a[1], b[2] - a[2]], + }) + } + 100 => { + let centre = place([v(2), v(3), v(1)]); + let x = place([v(2) + 1.0, v(3), v(1)]); + let y = place([v(2), v(3) + 1.0, v(1)]); + let start = place([v(4), v(5), v(1)]); + let radius = (0..3).map(|k| (start[k] - centre[k]).powi(2)).sum::().sqrt(); + Curve3::Circle(Circle3 { + plane: Plane::orthonormal( + centre, + [x[0] - centre[0], x[1] - centre[1], x[2] - centre[2]], + { + let (a, b) = ( + [x[0] - centre[0], x[1] - centre[1], x[2] - centre[2]], + [y[0] - centre[0], y[1] - centre[1], y[2] - centre[2]], + ); + [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]] + }, + )?, + radius, + }) + } + 126 => { + let k = v(1) as usize; + let degree = v(2) as usize; + let knots = (0..k + degree + 2).map(|n| v(7 + n)).collect(); + let w0 = 7 + k + degree + 2; + let weights = (0..=k).map(|n| v(w0 + n)).collect(); + let c0 = w0 + k + 1; + let points = (0..=k) + .map(|n| place([v(c0 + 3 * n), v(c0 + 3 * n + 1), v(c0 + 3 * n + 2)])) + .collect(); + Curve3::Nurbs(NurbsCurve3::new(degree, points, knots, Some(weights))?) + } + _ => return None, + }) + } + + fn surface(&self, pointer: f64) -> Option { + let entity = self.get(pointer)?; + if entity.kind != 128 { + return None; + } + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let place = self.place(entity); + let (k1, k2, m1, m2) = (v(1) as usize, v(2) as usize, v(3) as usize, v(4) as usize); + let s0 = 10; + let t0 = s0 + k1 + m1 + 2; + let w0 = t0 + k2 + m2 + 2; + let c0 = w0 + (k1 + 1) * (k2 + 1); + let u_knots = (s0..t0).map(v).collect(); + let v_knots = (t0..w0).map(v).collect(); + // Points run with the first index fastest. + let at = |i: usize, j: usize| j * (k1 + 1) + i; + let weights = (0..=k1) + .map(|i| (0..=k2).map(|j| v(w0 + at(i, j))).collect()) + .collect(); + let points = (0..=k1) + .map(|i| { + (0..=k2) + .map(|j| { + let n = c0 + 3 * at(i, j); + place([v(n), v(n + 1), v(n + 2)]) + }) + .collect() + }) + .collect(); + NurbsSurface3::new(m1, m2, points, u_knots, v_knots, Some(weights)).map(Surface::Nurbs) + } +} + +struct Assembly<'a> { + doc: &'a Doc, + build: Builder, + vertices: HashMap<(usize, usize), VertexKey>, + edges: HashMap<(usize, usize), EdgeKey>, +} + +impl Assembly<'_> { + fn vertex(&mut self, list: f64, index: f64) -> Option { + let key = (list.abs() as usize, index as usize); + if let Some(&v) = self.vertices.get(&key) { + return Some(v); + } + let entity = self.doc.get(list).filter(|e| e.kind == 502)?; + let n = 2 + 3 * (key.1.checked_sub(1)?); + let p = |k: usize| entity.params.get(k).map(P::num); + let point = self.doc.place(entity)([p(n)?, p(n + 1)?, p(n + 2)?]); + let v = self.build.vertex(point); + self.vertices.insert(key, v); + Some(v) + } + + fn edge(&mut self, list: f64, index: f64) -> Option { + let key = (list.abs() as usize, index as usize); + if let Some(&e) = self.edges.get(&key) { + return Some(e); + } + let entity = self.doc.get(list).filter(|e| e.kind == 504)?; + let n = 2 + 5 * key.1.checked_sub(1)?; + let p = |k: usize| entity.params.get(k).map(P::num); + let curve = self.doc.curve(p(n)?)?; + let start = self.vertex(p(n + 1)?, p(n + 2)?)?; + let end = self.vertex(p(n + 3)?, p(n + 4)?)?; + let e = self.build.edge(curve, start, end)?; + self.edges.insert(key, e); + Some(e) + } + + fn ring(&mut self, pointer: f64) -> Option> { + let entity = self.doc.get(pointer).filter(|e| e.kind == 508)?; + let p = |k: usize| entity.params.get(k).map(P::num); + let mut ring = Vec::new(); + let mut at = 2; + for _ in 0..p(1)? as usize { + let (kind, list, index, agrees, pcurves) = (p(at)?, p(at + 1)?, p(at + 2)?, p(at + 3)?, p(at + 4)?); + if kind == 0.0 { + ring.push((self.edge(list, index)?, agrees != 0.0)); + } + at += 5 + 2 * pcurves as usize; + } + Some(ring) + } + + fn face(&mut self, pointer: f64, agrees: bool) -> Option { + let entity = self.doc.get(pointer).filter(|e| e.kind == 510)?; + let p = |k: usize| entity.params.get(k).map(P::num); + let surface = self.doc.surface(p(1)?)?; + let loops = (0..p(2)? as usize) + .map(|n| self.ring(p(4 + n)?)) + .collect::>>()?; + Some(FaceSpec { + surface, + forward: agrees, + loops, + }) + } + + fn shell(&mut self, pointer: f64, agrees: bool) -> Option> { + let entity = self.doc.get(pointer).filter(|e| e.kind == 514)?; + let p = |k: usize| entity.params.get(k).map(P::num); + (0..p(1)? as usize) + .map(|n| { + let face_agrees = p(3 + 2 * n)? != 0.0; + let mut face = self.face(p(2 + 2 * n)?, face_agrees == agrees)?; + if !agrees { + for ring in &mut face.loops { + ring.reverse(); + for (_, f) in ring.iter_mut() { + *f = !*f; + } + } + } + Some(face) + }) + .collect() + } +} + +/// What an IGES file holds. +pub struct IgesModel { + /// Each solid, scaled to the drawing, with its colour. + pub bodies: Vec<(kernel::brep::Body, codec::types::Color)>, +} + +/// `drawing_mm`: millimetres per drawing unit, `None` for a unitless drawing. +pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { + let text = String::from_utf8_lossy(bytes); + let mut sections: HashMap> = HashMap::new(); + for line in text.lines() { + let line = line.trim_end_matches('\r'); + if line.len() < 73 { + continue; + } + let Some(section) = line.get(72..73).and_then(|s| s.chars().next()) else { continue }; + sections.entry(section).or_default().push(line); + } + let directory = sections.get(&'D')?; + let global: String = sections + .get(&'G') + .map(|lines| lines.iter().filter_map(|l| l.get(..72)).collect()) + .unwrap_or_default(); + // The delimiters are themselves the first two parameters, when given. + let mut delimiter = ','; + let mut terminator = ';'; + if global.starts_with("1H") { + delimiter = global.chars().nth(2).unwrap_or(','); + let rest = global.get(4..).unwrap_or_default(); + if rest.starts_with("1H") { + terminator = rest.chars().nth(2).unwrap_or(';'); + } + } + let globals = parameters(&global, delimiter, terminator); + let unit_mm = match globals.get(13).map(P::num).unwrap_or(1.0) as i32 { + 1 => 25.4, + 4 => 304.8, + 5 => 1_609_344.0, + 6 => 1000.0, + 7 => 1_000_000.0, + 8 => 0.0254, + 9 => 0.001, + 10 => 10.0, + 11 => 0.000_025_4, + 3 => match globals.get(14) { + Some(P::Str(name)) if name.eq_ignore_ascii_case("IN") || name.eq_ignore_ascii_case("INCH") => 25.4, + Some(P::Str(name)) if name.eq_ignore_ascii_case("FT") => 304.8, + Some(P::Str(name)) if name.eq_ignore_ascii_case("M") => 1000.0, + Some(P::Str(name)) if name.eq_ignore_ascii_case("CM") => 10.0, + _ => 1.0, + }, + _ => 1.0, + } * globals.get(12).map(P::num).filter(|s| *s > 0.0).map_or(1.0, |s| 1.0 / s); + + let parameter_lines: HashMap = sections + .get(&'P') + .map(|lines| { + lines + .iter() + .filter_map(|l| Some((l.get(73..)?.trim().parse().ok()?, *l))) + .collect() + }) + .unwrap_or_default(); + let field = |line: &str, n: usize| -> i64 { + line.get(n * 8..n * 8 + 8) + .and_then(|f| f.trim().parse().ok()) + .unwrap_or(0) + }; + let mut entities = HashMap::new(); + for pair in directory.chunks(2) { + let [first, second] = pair else { continue }; + let Some(number) = first.get(73..).and_then(|n| n.trim().parse::().ok()) else { + continue; + }; + let start = field(first, 1) as usize; + let count = field(second, 3).max(1) as usize; + let data: String = (start..start + count) + .filter_map(|n| parameter_lines.get(&n)) + .filter_map(|l| l.get(..64)) + .collect(); + entities.insert( + number, + Entity { + kind: field(first, 0) as i32, + transform: field(first, 6).max(0) as usize, + color: field(second, 2), + params: parameters(&data, delimiter, terminator), + }, + ); + } + let scale = drawing_mm.filter(|mm| *mm > 0.0).map_or(1.0, |mm| unit_mm / mm); + let doc = Doc { entities, scale }; + + let mut solids: Vec = doc + .entities + .iter() + .filter(|(_, e)| e.kind == 186) + .map(|(n, _)| *n) + .collect(); + solids.sort_unstable(); + let mut bodies = Vec::new(); + for number in solids { + let entity = &doc.entities[&number]; + let v = |k: usize| entity.params.get(k).map(P::num); + let Some((shell, agrees)) = v(1).zip(v(2)) else { continue }; + let mut assembly = Assembly { + doc: &doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + }; + let mut shells = Vec::new(); + let outer = assembly.shell(shell, agrees != 0.0); + let voids = (0..v(3).unwrap_or(0.0) as usize) + .map(|n| assembly.shell(v(4 + 2 * n)?, v(5 + 2 * n)? != 0.0)) + .collect::>>(); + if let (Some(outer), Some(voids)) = (outer, voids) { + shells.push(outer); + shells.extend(voids); + if assembly.build.lump(shells).is_some() { + bodies.push((assembly.build.body, doc.color(entity.color))); + } + } + } + Some(IgesModel { bodies }) +} diff --git a/src/io/mod.rs b/src/io/mod.rs index a0e639614..f376df760 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -8,12 +8,15 @@ pub mod edit_lock; // Same split as web_session/web_autosave: the save-routing decision (and // its tests) is pure and compiles everywhere; the showSaveFilePicker half // inside is wasm-only. +pub mod brep_build; pub mod fbx; pub mod file_association; pub mod font_repo; pub(crate) mod fs_access; +pub mod iges_import; pub mod line_read; pub mod linetypes; +pub mod model_import; pub mod obj; pub mod ole_embed; pub mod paper_catalog; @@ -27,11 +30,13 @@ pub mod recovery; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; pub mod step; +pub mod step_import; pub mod stl; pub mod svg_export; pub mod thumbnail; pub mod update_check; pub mod visual_styles; +pub mod wmf_import; #[cfg(target_arch = "wasm32")] pub(crate) mod web_recent; // Registered on every target so the pure helpers (and their tests) compile on diff --git a/src/io/model_import.rs b/src/io/model_import.rs new file mode 100644 index 000000000..a0d347057 --- /dev/null +++ b/src/io/model_import.rs @@ -0,0 +1,154 @@ +//! Model files IMPORT reads: ACIS text (SAT), STEP and IGES, as the drawing +//! objects they become. + +use codec::entities::{Entity as _, Insert}; +use codec::types::{Color, Matrix4, Transform, Vector3}; +use codec::EntityType; + +use crate::scene::convert::acis_import::{body_entity, sat_entities}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModelFormat { + Sat, + Step, + Iges, +} + +/// The objects a file brings in. +pub struct Imported { + /// Block definitions, each before any block that inserts it. + pub blocks: Vec<(String, Vec)>, + /// The objects for the current space. + pub entities: Vec, +} + +/// File units over drawing units; one where the drawing is unitless. +fn ratio(file_mm: f64, drawing_mm: Option) -> f64 { + drawing_mm.filter(|mm| *mm > 0.0).map_or(1.0, |mm| file_mm / mm) +} + +/// A block name from a product's, with the characters names cannot hold +/// replaced. +fn block_name(name: &str) -> String { + let name: String = name + .trim() + .chars() + .map(|c| if "<>/\\\":;?*|,=`".contains(c) { '_' } else { c }) + .collect(); + if name.is_empty() { "Part".to_string() } else { name } +} + +/// An insert of `block` placed by `placement` (columns: axes, origin). +fn placed_insert(block: &str, placement: &[[f64; 4]; 3]) -> EntityType { + let mut insert = Insert::new(block, Vector3::ZERO); + let mut m = Matrix4::identity(); + for r in 0..3 { + m.m[r] = placement[r]; + } + insert.apply_transform(&Transform::from_matrix(m)); + EntityType::Insert(insert) +} + +/// STEP: a block per product — its solids, and inserts of its components — +/// with each top product inserted at the origin, scaled from the file's +/// units to the drawing's. The solids take their colour from the block. +fn step_blocks(model: crate::io::step_import::StepModel, scale: f64) -> Imported { + // Unique names, in product order. + let mut names: Vec = Vec::new(); + for product in &model.products { + let base = block_name(&product.name); + let mut name = base.clone(); + let mut n = 1; + while names.iter().any(|taken| taken.eq_ignore_ascii_case(&name)) { + name = format!("{base}_{n}"); + n += 1; + } + names.push(name); + } + // Children before parents. + let mut order = Vec::new(); + let mut state = vec![0u8; model.products.len()]; + fn visit(k: usize, model: &crate::io::step_import::StepModel, state: &mut [u8], order: &mut Vec) { + if state[k] != 0 { + return; + } + state[k] = 1; + for (child, _) in &model.products[k].children { + visit(*child, model, state, order); + } + state[k] = 2; + order.push(k); + } + for &root in &model.roots { + visit(root, &model, &mut state, &mut order); + } + let blocks = order + .iter() + .map(|&k| { + let product = &model.products[k]; + let mut entities: Vec = product + .bodies + .iter() + .filter_map(body_entity) + .map(|mut entity| { + entity.common_mut().color = Color::ByBlock; + entity + }) + .collect(); + entities.extend( + product + .children + .iter() + .map(|(child, placement)| { + let mut insert = placed_insert(&names[*child], placement); + insert.common_mut().color = Color::ByBlock; + insert + }), + ); + (names[k].clone(), entities) + }) + .collect(); + let identity = [[scale, 0.0, 0.0, 0.0], [0.0, scale, 0.0, 0.0], [0.0, 0.0, scale, 0.0]]; + Imported { + blocks, + entities: model + .roots + .iter() + .map(|&k| placed_insert(&names[k], &identity)) + .collect(), + } +} + +/// `None` when the file is not of `format` or holds nothing it can read. +pub fn read(format: ModelFormat, bytes: &[u8], drawing_mm: Option) -> Option { + let imported = match format { + ModelFormat::Sat => Imported { + blocks: Vec::new(), + entities: sat_entities(&String::from_utf8_lossy(bytes))?, + }, + ModelFormat::Step => { + let model = crate::io::step_import::read(bytes)?; + let scale = ratio(model.unit_mm, drawing_mm); + step_blocks(model, scale) + } + ModelFormat::Iges => Imported { + blocks: Vec::new(), + entities: crate::io::iges_import::read(bytes, drawing_mm)? + .bodies + .iter() + .filter_map(|(body, color)| { + let mut entity = body_entity(body)?; + entity.common_mut().color = *color; + Some(entity) + }) + .collect(), + }, + }; + let solids = imported + .blocks + .iter() + .flat_map(|(_, entities)| entities) + .chain(&imported.entities) + .any(|entity| !matches!(entity, EntityType::Insert(_))); + solids.then_some(imported) +} diff --git a/src/io/step_import.rs b/src/io/step_import.rs new file mode 100644 index 000000000..c4b951dda --- /dev/null +++ b/src/io/step_import.rs @@ -0,0 +1,829 @@ +//! STEP (ISO 10303-21) reading: the B-rep solids and shell models of an +//! AP203/AP214/AP242 file, as kernel bodies, with the product's name and the +//! file's length unit. + +use std::collections::HashMap; + +use kernel::brep::{ + Circle3, Cone, Curve3, Cylinder, EdgeKey, Ellipse3, Line3, Sphere, Surface, Torus, VertexKey, +}; +use kernel::space::{NurbsCurve3, NurbsSurface3, Plane, Vec3}; + +use super::brep_build::{Builder, FaceSpec}; + +/// One parameter of an entity instance. +#[derive(Debug, Clone)] +enum P { + Num(f64), + Str(String), + Enum(String), + Ref(u64), + List(Vec

), + Null, + /// A typed value, such as `LENGTH_MEASURE(1.)`. + Typed(Vec

), +} + +impl P { + fn num(&self) -> Option { + match self { + P::Num(v) => Some(*v), + P::Typed(inner) => inner.first()?.num(), + _ => None, + } + } + fn reference(&self) -> Option { + match self { + P::Ref(id) => Some(*id), + _ => None, + } + } + fn list(&self) -> &[P] { + match self { + P::List(items) => items, + _ => &[], + } + } + fn truth(&self) -> bool { + matches!(self, P::Enum(e) if e == "T") + } + fn text(&self) -> Option<&str> { + match self { + P::Str(s) => Some(s), + _ => None, + } + } +} + +/// An instance: one `(type, parameters)` part, or several for a complex one. +type Instance = Vec<(String, Vec

)>; + +struct Cursor<'a> { + s: &'a [u8], + i: usize, +} + +impl Cursor<'_> { + fn skip(&mut self) { + loop { + while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { + self.i += 1; + } + if self.s[self.i..].starts_with(b"/*") { + match self.s[self.i + 2..].windows(2).position(|w| w == b"*/") { + Some(end) => self.i += end + 4, + None => self.i = self.s.len(), + } + } else { + break; + } + } + } + fn peek(&mut self) -> Option { + self.skip(); + self.s.get(self.i).copied() + } + fn eat(&mut self, c: u8) -> Option<()> { + (self.peek()? == c).then(|| self.i += 1) + } + fn word(&mut self) -> String { + self.skip(); + let start = self.i; + while self + .s + .get(self.i) + .is_some_and(|c| c.is_ascii_alphanumeric() || *c == b'_' || *c == b'-') + { + self.i += 1; + } + String::from_utf8_lossy(&self.s[start..self.i]).to_ascii_uppercase() + } + fn param(&mut self) -> Option

{ + Some(match self.peek()? { + b'#' => { + self.i += 1; + P::Ref(self.word().parse().ok()?) + } + b'\'' => { + self.i += 1; + let mut text = Vec::new(); + loop { + let c = *self.s.get(self.i)?; + self.i += 1; + if c == b'\'' { + if self.s.get(self.i) == Some(&b'\'') { + self.i += 1; + } else { + break; + } + } + text.push(c); + } + P::Str(String::from_utf8_lossy(&text).into_owned()) + } + b'.' => { + self.i += 1; + let value = self.word(); + self.eat(b'.')?; + P::Enum(value) + } + b'$' | b'*' => { + self.i += 1; + P::Null + } + b'(' => P::List(self.params()?), + c if c.is_ascii_digit() || c == b'-' || c == b'+' || c == b'.' => { + let start = self.i; + while self + .s + .get(self.i) + .is_some_and(|c| c.is_ascii_digit() || b"+-.eE".contains(c)) + { + self.i += 1; + } + let raw = String::from_utf8_lossy(&self.s[start..self.i]).replace(".E", ".0E"); + P::Num(raw.replace(".e", ".0e").parse().ok()?) + } + _ => { + if self.word().is_empty() { + return None; + } + P::Typed(self.params()?) + } + }) + } + fn params(&mut self) -> Option> { + self.eat(b'(')?; + let mut items = Vec::new(); + if self.eat(b')').is_some() { + return Some(items); + } + loop { + items.push(self.param()?); + match self.peek()? { + b',' => self.i += 1, + b')' => { + self.i += 1; + return Some(items); + } + _ => return None, + } + } + } + /// `#id = TYPE(...)` or `#id = (A(...) B(...))`, up to its `;`. + fn instance(&mut self) -> Option<(u64, Instance)> { + self.eat(b'#')?; + let id = self.word().parse().ok()?; + self.eat(b'=')?; + let mut parts = Vec::new(); + if self.eat(b'(').is_some() { + while self.eat(b')').is_none() { + let name = self.word(); + if name.is_empty() { + return None; + } + parts.push((name, self.params()?)); + } + } else { + let name = self.word(); + parts.push((name, self.params()?)); + } + self.eat(b';')?; + Some((id, parts)) + } +} + +struct Doc { + instances: HashMap, + /// Plane-angle unit in radians. + angle: f64, +} + +impl Doc { + fn parts(&self, id: u64) -> Option<&Instance> { + self.instances.get(&id) + } + fn kind(&self, id: u64) -> &str { + self.parts(id) + .and_then(|parts| parts.first()) + .map_or("", |(name, _)| name.as_str()) + } + /// The parameters of the part named `name`. + fn part(&self, id: u64, name: &str) -> Option<&[P]> { + self.parts(id)? + .iter() + .find(|(n, _)| n == name) + .map(|(_, p)| p.as_slice()) + } + /// A simple instance's type and parameters. + fn simple(&self, id: u64) -> Option<(&str, &[P])> { + let parts = self.parts(id)?; + let (name, params) = parts.first()?; + Some((name.as_str(), params.as_slice())) + } + + fn point(&self, p: &P) -> Option<[f64; 3]> { + let (_, params) = self.simple(p.reference()?)?; + let c = params.get(1)?.list(); + Some([c.first()?.num()?, c.get(1)?.num()?, c.get(2).and_then(P::num).unwrap_or(0.0)]) + } + fn direction(&self, p: &P) -> Option<[f64; 3]> { + self.point(p) + } + fn placement(&self, p: &P) -> Option { + let (_, params) = self.simple(p.reference()?)?; + let origin = self.point(params.get(1)?)?; + let z = params.get(2).and_then(|d| self.direction(d)).unwrap_or([0.0, 0.0, 1.0]); + let x = params + .get(3) + .and_then(|d| self.direction(d)) + .filter(|x| Vec3::from(*x).cross(Vec3::from(z)).length() > 1e-9) + // Any axis off the normal; the plane squares it up. + .unwrap_or(if z[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] }); + Plane::orthonormal(origin, x, z) + } + + /// Knots expanded by their multiplicities. + fn knots(mults: &P, knots: &P) -> Option> { + let mut out = Vec::new(); + for (m, k) in mults.list().iter().zip(knots.list()) { + for _ in 0..m.num()? as usize { + out.push(k.num()?); + } + } + Some(out) + } + + fn curve(&self, p: &P) -> Option { + let id = p.reference()?; + let kind = self.kind(id); + if kind == "B_SPLINE_CURVE_WITH_KNOTS" || self.part(id, "B_SPLINE_CURVE").is_some() { + return self.spline_curve(id).map(Curve3::Nurbs); + } + let (_, params) = self.simple(id)?; + Some(match kind { + "LINE" => { + let origin = self.point(params.get(1)?)?; + let (_, vector) = self.simple(params.get(2)?.reference()?)?; + let direction = Vec3::from(self.direction(vector.get(1)?)?).normalize()? + * vector.get(2)?.num()?; + Curve3::Line(Line3 { + origin, + direction: direction.to_array(), + }) + } + "CIRCLE" => Curve3::Circle(Circle3 { + plane: self.placement(params.get(1)?)?, + radius: params.get(2)?.num()?, + }), + "ELLIPSE" => Curve3::Ellipse(Ellipse3 { + plane: self.placement(params.get(1)?)?, + major_radius: params.get(2)?.num()?, + minor_radius: params.get(3)?.num()?, + }), + "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE" => { + self.curve(params.get(1)?)? + } + "POLYLINE" => { + let points: Option> = + params.get(1)?.list().iter().map(|q| self.point(q)).collect(); + Curve3::Nurbs(NurbsCurve3::new(1, points?, Vec::new(), None)?) + } + _ => return None, + }) + } + + fn spline_curve(&self, id: u64) -> Option { + // The simple form carries everything after its name; the complex + // form splits it over parts that have no name. + let (degree, points, mults, knots, weights) = + if let Some(p) = self.part(id, "B_SPLINE_CURVE_WITH_KNOTS").filter(|p| p.len() >= 9) { + (&p[1], &p[2], &p[6], &p[7], None) + } else { + let base = self.part(id, "B_SPLINE_CURVE")?; + let with = self.part(id, "B_SPLINE_CURVE_WITH_KNOTS")?; + let weights = self.part(id, "RATIONAL_B_SPLINE_CURVE").and_then(|w| w.first()); + (&base[0], &base[1], &with[0], &with[1], weights) + }; + let points: Option> = points.list().iter().map(|q| self.point(q)).collect(); + let weights: Option> = weights.map(|w| w.list().iter().filter_map(P::num).collect()); + let degree = degree.num()? as usize; + let points = points?; + let knots = Self::knots(mults, knots)?; + match &weights { + Some(w) => NurbsCurve3::new_strict(degree, points.clone(), knots.clone(), w.clone()) + .or_else(|| NurbsCurve3::new(degree, points, knots, weights.clone())), + None => NurbsCurve3::new(degree, points, knots, None), + } + } + + fn surface(&self, p: &P) -> Option { + let id = p.reference()?; + let kind = self.kind(id); + if kind == "B_SPLINE_SURFACE_WITH_KNOTS" || self.part(id, "B_SPLINE_SURFACE").is_some() { + return self.spline_surface(id).map(Surface::Nurbs); + } + let (_, params) = self.simple(id)?; + let frame = self.placement(params.get(1)?)?; + let value = |k: usize| params.get(k).and_then(P::num); + Some(match kind { + "PLANE" => Surface::Plane(frame), + "CYLINDRICAL_SURFACE" => Surface::Cylinder(Cylinder { + base: frame, + radius: value(2)?, + }), + // A STEP cone widens along its axis by its semi-angle. + "CONICAL_SURFACE" => Surface::Cone(Cone { + base: frame, + radius: value(2)?, + half_angle: -value(3)? * self.angle, + }), + "SPHERICAL_SURFACE" => Surface::Sphere(Sphere { + frame, + radius: value(2)?, + }), + "TOROIDAL_SURFACE" => Surface::Torus(Torus { + frame, + major_radius: value(2)?, + minor_radius: value(3)?, + }), + _ => return None, + }) + } + + fn spline_surface(&self, id: u64) -> Option { + let (ud, vd, points, um, vm, uk, vk, weights) = if let Some(p) = self + .part(id, "B_SPLINE_SURFACE_WITH_KNOTS") + .filter(|p| p.len() >= 13) + { + (&p[1], &p[2], &p[3], &p[8], &p[9], &p[10], &p[11], None) + } else { + let base = self.part(id, "B_SPLINE_SURFACE")?; + let with = self.part(id, "B_SPLINE_SURFACE_WITH_KNOTS")?; + let weights = self.part(id, "RATIONAL_B_SPLINE_SURFACE").and_then(|w| w.first()); + (&base[0], &base[1], &base[2], &with[0], &with[1], &with[2], &with[3], weights) + }; + let rows: Option>> = points + .list() + .iter() + .map(|row| row.list().iter().map(|q| self.point(q)).collect()) + .collect(); + let weights: Option>> = weights.map(|w| { + w.list() + .iter() + .map(|row| row.list().iter().filter_map(P::num).collect()) + .collect() + }); + NurbsSurface3::new( + ud.num()? as usize, + vd.num()? as usize, + rows?, + Self::knots(um, uk)?, + Self::knots(vm, vk)?, + weights, + ) + } +} + +/// Topology of one body, keyed by the file's instance ids. +struct Assembly<'a> { + doc: &'a Doc, + build: Builder, + vertices: HashMap, + /// Each edge, and whether it runs from the file's edge end to its start. + edges: HashMap, +} + +impl Assembly<'_> { + fn vertex(&mut self, p: &P) -> Option { + let id = p.reference()?; + if let Some(&v) = self.vertices.get(&id) { + return Some(v); + } + let (_, params) = self.doc.simple(id)?; + let v = self.build.vertex(self.doc.point(params.get(1)?)?); + self.vertices.insert(id, v); + Some(v) + } + + fn edge(&mut self, p: &P) -> Option<(EdgeKey, bool)> { + let id = p.reference()?; + if let Some(&e) = self.edges.get(&id) { + return Some(e); + } + let (_, params) = self.doc.simple(id)?; + let (a, b) = (self.vertex(params.get(1)?)?, self.vertex(params.get(2)?)?); + let curve = self.doc.curve(params.get(3)?)?; + let same_sense = params.get(4).is_none_or(P::truth); + let key = if same_sense { + self.build.edge(curve, a, b)? + } else { + self.build.edge(curve, b, a)? + }; + self.edges.insert(id, (key, !same_sense)); + Some((key, !same_sense)) + } + + /// A bound's coedges, in the order the face runs them. + fn bound(&mut self, p: &P) -> Option> { + let (_, params) = self.doc.simple(p.reference()?)?; + let ring_id = params.get(1)?.reference()?; + let agrees = params.get(2).is_none_or(P::truth); + let mut ring = Vec::new(); + if self.doc.kind(ring_id) == "EDGE_LOOP" { + let (_, loop_params) = self.doc.simple(ring_id)?; + for oriented in loop_params.get(1)?.list() { + let (_, o) = self.doc.simple(oriented.reference()?)?; + let (edge, flipped) = self.edge(o.get(3)?)?; + let along = o.get(4).is_none_or(P::truth); + ring.push((edge, along != flipped)); + } + } + if !agrees { + ring.reverse(); + for (_, forward) in &mut ring { + *forward = !*forward; + } + } + Some(ring) + } + + fn face(&mut self, p: &P, flip: bool) -> Option { + let id = p.reference()?; + let (_, params) = self.doc.simple(id)?; + let surface = self.doc.surface(params.get(2)?)?; + let mut outer = Vec::new(); + let mut inner = Vec::new(); + for bound in params.get(1)?.list() { + let ring = self.bound(bound)?; + if self.doc.kind(bound.reference()?) == "FACE_OUTER_BOUND" { + outer.push(ring); + } else { + inner.push(ring); + } + } + outer.extend(inner); + let mut loops = outer; + let mut forward = params.get(3).is_none_or(P::truth); + if flip { + forward = !forward; + for ring in &mut loops { + ring.reverse(); + for (_, f) in ring.iter_mut() { + *f = !*f; + } + } + } + Some(FaceSpec { + surface, + forward, + loops, + }) + } + + /// A shell's faces; an oriented shell set against its faces flips them. + fn shell(&mut self, p: &P) -> Option> { + let mut id = p.reference()?; + let mut flip = false; + if self.doc.kind(id) == "ORIENTED_CLOSED_SHELL" || self.doc.kind(id) == "ORIENTED_OPEN_SHELL" { + let (_, params) = self.doc.simple(id)?; + flip = !params.get(3).is_none_or(P::truth); + id = params.get(2)?.reference()?; + } + let (_, params) = self.doc.simple(id)?; + params + .get(1)? + .list() + .iter() + .map(|face| self.face(face, flip)) + .collect() + } +} + +/// A 3×4 placement: the columns are the x, y and z axes and the origin. +pub type Placement = [[f64; 4]; 3]; + +const IDENTITY: Placement = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + +/// One product: its own bodies and where each of its components sits. +pub struct StepProduct { + pub name: String, + pub bodies: Vec, + /// Components: the product (an index into [`StepModel::products`]) and + /// its placement in this one. + pub children: Vec<(usize, Placement)>, +} + +/// What a STEP file holds. +pub struct StepModel { + /// Millimetres per file length unit. + pub unit_mm: f64, + pub products: Vec, + /// The products no other one is a component of. + pub roots: Vec, +} + +/// A named unit's size: millimetres for a length, radians for an angle. +fn unit_size(doc: &Doc, id: u64) -> Option { + if let Some(si) = doc.part(id, "SI_UNIT") { + let prefix = match si.first() { + Some(P::Enum(prefix)) => match prefix.as_str() { + "MILLI" => 1e-3, + "CENTI" => 1e-2, + "DECI" => 1e-1, + "KILO" => 1e3, + "MICRO" => 1e-6, + _ => 1.0, + }, + _ => 1.0, + }; + return Some(match si.get(1) { + Some(P::Enum(name)) if name == "METRE" => prefix * 1000.0, + _ => prefix, + }); + } + let conversion = doc.part(id, "CONVERSION_BASED_UNIT")?; + let (_, measure) = doc.simple(conversion.get(1)?.reference()?)?; + Some(measure.first()?.num()? * unit_size(doc, measure.get(1)?.reference()?)?) +} + +/// The bodies of one B-rep item: a solid, a solid with voids, or each shell +/// of a shell model. +fn item_bodies(doc: &Doc, id: u64) -> Vec { + let Some((kind, params)) = doc.simple(id) else { + return Vec::new(); + }; + let lumps: Vec> = match kind { + "MANIFOLD_SOLID_BREP" => vec![params.get(1).into_iter().collect()], + "BREP_WITH_VOIDS" => vec![params + .get(1) + .into_iter() + .chain(params.get(2).map(P::list).unwrap_or_default()) + .collect()], + "SHELL_BASED_SURFACE_MODEL" => params + .get(1) + .map(P::list) + .unwrap_or_default() + .iter() + .map(|shell| vec![shell]) + .collect(), + _ => return Vec::new(), + }; + let mut bodies = Vec::new(); + for lump in lumps { + let mut assembly = Assembly { + doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + }; + let faces: Option> = lump.iter().map(|shell| assembly.shell(shell)).collect(); + if let Some(faces) = faces { + if assembly.build.lump(faces).is_some() { + bodies.push(assembly.build.body); + } + } + } + bodies +} + +impl Doc { + /// An axis placement as a placement matrix. + fn frame(&self, id: u64) -> Option { + let plane = self.placement(&P::Ref(id))?; + let z = plane.normal()?; + let [x, y, o] = [plane.x_axis, plane.y_axis, plane.origin]; + Some([0, 1, 2].map(|r| [x[r], y[r], z[r], o[r]])) + } +} + +/// `a·b`. +fn compose(a: &Placement, b: &Placement) -> Placement { + let mut out = [[0.0; 4]; 3]; + for r in 0..3 { + for c in 0..4 { + out[r][c] = (0..3).map(|k| a[r][k] * b[k][c]).sum::() + if c == 3 { a[r][3] } else { 0.0 }; + } + } + out +} + +/// The inverse of a rigid placement. +fn invert(a: &Placement) -> Placement { + let mut out = [[0.0; 4]; 3]; + for r in 0..3 { + for c in 0..3 { + out[r][c] = a[c][r]; + } + out[r][3] = -(0..3).map(|k| a[k][r] * a[k][3]).sum::(); + } + out +} + +pub fn read(bytes: &[u8]) -> Option { + let text = String::from_utf8_lossy(bytes); + if !text.trim_start().starts_with("ISO-10303-21") { + return None; + } + let data = text.find("DATA;")? + 5; + let mut cursor = Cursor { + s: &text.as_bytes()[data..], + i: 0, + }; + let mut instances = HashMap::new(); + while cursor.peek() == Some(b'#') { + match cursor.instance() { + Some((id, parts)) => { + instances.insert(id, parts); + } + None => { + // Skip a malformed instance up to the next one. + while cursor.s.get(cursor.i).is_some_and(|c| *c != b';') { + cursor.i += 1; + } + cursor.i += 1; + } + } + } + let mut doc = Doc { + instances, + angle: 1.0, + }; + + let mut unit_mm = 1.0; + let mut angle = 1.0; + let mut ids: Vec = doc.instances.keys().copied().collect(); + ids.sort_unstable(); + for &id in &ids { + let Some(units) = doc.part(id, "GLOBAL_UNIT_ASSIGNED_CONTEXT") else { + continue; + }; + for unit in units.first().map(P::list).unwrap_or_default() { + let Some(unit) = unit.reference() else { continue }; + if doc.part(unit, "LENGTH_UNIT").is_some() { + unit_mm = unit_size(&doc, unit).unwrap_or(1.0); + } else if doc.part(unit, "PLANE_ANGLE_UNIT").is_some() { + angle = unit_size(&doc, unit).unwrap_or(1.0); + } + } + break; + } + doc.angle = angle; + let doc = doc; + + // What each instance id is, by kind. + let of_kind = |kind: &str| -> Vec { + ids.iter() + .copied() + .filter(|&id| doc.kind(id) == kind) + .collect() + }; + let param = |id: u64, k: usize| doc.simple(id).and_then(|(_, p)| p.get(k)).and_then(P::reference); + + // Product definitions and the name of the product each defines. + let definitions: Vec = ids + .iter() + .copied() + .filter(|&id| { + matches!(doc.kind(id), "PRODUCT_DEFINITION" | "PRODUCT_DEFINITION_WITH_ASSOCIATED_DOCUMENTS") + }) + .collect(); + let product_name = |definition: u64| -> String { + let product = param(definition, 2).and_then(|formation| param(formation, 2)); + product + .and_then(|p| doc.simple(p)) + .and_then(|(_, params)| { + params + .get(1) + .and_then(P::text) + .filter(|s| !s.trim().is_empty()) + .or_else(|| params.first().and_then(P::text)) + }) + .unwrap_or_default() + .to_string() + }; + // The shape of a definition (or an occurrence), and the representations + // attached to a shape. + let shape_of: HashMap = of_kind("PRODUCT_DEFINITION_SHAPE") + .into_iter() + .filter_map(|pds| Some((param(pds, 2)?, pds))) + .collect(); + let mut reps_of_shape: HashMap> = HashMap::new(); + for sdr in of_kind("SHAPE_DEFINITION_REPRESENTATION") { + if let (Some(pds), Some(rep)) = (param(sdr, 0), param(sdr, 1)) { + reps_of_shape.entry(pds).or_default().push(rep); + } + } + // Representations tied without a transformation share their items. + let mut linked: HashMap> = HashMap::new(); + for id in &ids { + let Some(("SHAPE_REPRESENTATION_RELATIONSHIP", params)) = doc.simple(*id) else { + continue; + }; + if let (Some(a), Some(b)) = (params.get(2).and_then(P::reference), params.get(3).and_then(P::reference)) { + linked.entry(a).or_default().push(b); + linked.entry(b).or_default().push(a); + } + } + let items_of = |rep: u64| -> Vec { + let mut seen = vec![rep]; + let mut at = 0; + while at < seen.len() { + for next in linked.get(&seen[at]).into_iter().flatten() { + if !seen.contains(next) { + seen.push(*next); + } + } + at += 1; + } + seen.iter() + .filter_map(|r| doc.simple(*r)) + .flat_map(|(_, params)| params.get(1).map(P::list).unwrap_or_default()) + .filter_map(P::reference) + .collect() + }; + + let mut index: HashMap = HashMap::new(); + let mut products: Vec = Vec::new(); + let mut used_items: Vec = Vec::new(); + for &definition in &definitions { + let mut bodies = Vec::new(); + for rep in shape_of.get(&definition).and_then(|pds| reps_of_shape.get(pds)).into_iter().flatten() { + for item in items_of(*rep) { + if !used_items.contains(&item) { + let found = item_bodies(&doc, item); + if !found.is_empty() { + used_items.push(item); + } + bodies.extend(found); + } + } + } + index.insert(definition, products.len()); + products.push(StepProduct { + name: product_name(definition), + bodies, + children: Vec::new(), + }); + } + + // Components, placed by the transformation of their occurrence. + let mut component = vec![false; products.len()]; + for occurrence in of_kind("NEXT_ASSEMBLY_USAGE_OCCURRENCE") { + let (Some(&parent), Some(&child)) = ( + param(occurrence, 3).and_then(|d| index.get(&d)), + param(occurrence, 4).and_then(|d| index.get(&d)), + ) else { + continue; + }; + let placement = shape_of + .get(&occurrence) + .and_then(|pds| { + ids.iter().copied().find(|&id| { + doc.kind(id) == "CONTEXT_DEPENDENT_SHAPE_REPRESENTATION" && param(id, 1) == Some(*pds) + }) + }) + .and_then(|cdsr| param(cdsr, 0)) + .and_then(|relation| doc.part(relation, "REPRESENTATION_RELATIONSHIP_WITH_TRANSFORMATION")) + .and_then(|p| p.first()?.reference()) + .and_then(|transformation| { + let (_, t) = doc.simple(transformation)?; + let from = doc.frame(t.get(2)?.reference()?)?; + let to = doc.frame(t.get(3)?.reference()?)?; + Some(compose(&to, &invert(&from))) + }) + .unwrap_or(IDENTITY); + products[parent].children.push((child, placement)); + component[child] = true; + } + + // A file whose solids no product claims: one product holding them all, + // named after the file's first product. + let loose: Vec = ids + .iter() + .copied() + .filter(|id| !used_items.contains(id)) + .collect(); + let mut loose_bodies = Vec::new(); + for id in loose { + loose_bodies.extend(item_bodies(&doc, id)); + } + if !loose_bodies.is_empty() { + let name = definitions.first().map(|d| product_name(*d)).unwrap_or_default(); + component.push(false); + products.push(StepProduct { + name, + bodies: loose_bodies, + children: Vec::new(), + }); + } + + let roots = (0..products.len()) + .filter(|&k| !component[k] && (!products[k].bodies.is_empty() || !products[k].children.is_empty())) + .collect(); + Some(StepModel { + unit_mm, + products, + roots, + }) +} diff --git a/src/io/wmf_import.rs b/src/io/wmf_import.rs new file mode 100644 index 000000000..352f79696 --- /dev/null +++ b/src/io/wmf_import.rs @@ -0,0 +1,399 @@ +//! Windows metafile (WMF) reading: the records a metafile plays, as the +//! drawing objects of a block — outlines as polylines in their pen's colour +//! and width, filled shapes as closed polylines in their brush's colour, +//! ellipses as 99-segment polylines, text as single-line text. +//! +//! The picture is scaled to fit half the view it is imported into, with its +//! top-left corner at the block's origin and y running down the screen as +//! a metafile's does. + +use codec::entities::{Polyline2D, Text, Vertex2D}; +use codec::types::{Color, Vector3}; +use codec::EntityType; + +/// A picture: its objects, in block coordinates, and the font of each +/// text (an index into `fonts`). +pub struct Picture { + pub entities: Vec, + /// The face name of each font the text uses; text styles are + /// `WMF-` for each, in this order. + pub fonts: Vec, + /// For each text in `entities` (by position), its font index. + pub text_fonts: Vec<(usize, usize)>, +} + +#[derive(Clone, Copy)] +struct Pen { + null: bool, + width: f64, + rgb: [u8; 3], +} + +#[derive(Clone, Copy)] +struct Brush { + null: bool, + rgb: [u8; 3], +} + +#[derive(Clone)] +struct Font { + height: f64, + escapement: f64, + face: String, +} + +#[derive(Clone)] +enum Object { + Pen(Pen), + Brush(Brush), + Font(Font), + Other, +} + +/// Segments of a whole ellipse. +const ELLIPSE_SEGMENTS: usize = 99; + +/// A colour reference as the nearest index colour; black and white both +/// draw as colour 7. +fn color(rgb: [u8; 3]) -> Color { + if rgb == [0, 0, 0] || rgb == [255, 255, 255] { + return Color::Index(7); + } + Color::Index(codec::types::nearest_aci(rgb[0], rgb[1], rgb[2])) +} + +fn colorref(lo: u16, hi: u16) -> [u8; 3] { + [(lo & 0xff) as u8, (lo >> 8) as u8, (hi & 0xff) as u8] +} + +struct Player { + origin: [f64; 2], + scale: [f64; 2], + entities: Vec, + fonts: Vec, + text_fonts: Vec<(usize, usize)>, +} + +impl Player { + fn at(&self, x: f64, y: f64) -> Vector3 { + Vector3::new((x - self.origin[0]) * self.scale[0], (y - self.origin[1]) * self.scale[1], 0.0) + } + + fn polyline(&mut self, points: &[[f64; 2]], closed: bool, width: f64, rgb: [u8; 3]) { + if points.len() < 2 { + return; + } + let width = width * self.scale[0].abs(); + let mut polyline = Polyline2D::new(); + polyline.start_width = width; + polyline.end_width = width; + for p in points { + let mut vertex = Vertex2D::new(self.at(p[0], p[1])); + vertex.start_width = width; + vertex.end_width = width; + polyline.add_vertex(vertex); + } + if closed { + polyline.close(); + } + polyline.common.color = color(rgb); + self.entities.push(EntityType::Polyline2D(polyline)); + } + + /// An outline in the pen, or a filled area as its brush's outline. + fn shape(&mut self, points: &[[f64; 2]], pen: &Pen, brush: &Brush) { + let width = if pen.null { 0.0 } else { pen.width }; + if !brush.null { + self.polyline(points, true, width, brush.rgb); + } else if !pen.null { + self.polyline(points, true, width, pen.rgb); + } + } +} + +/// Points round an ellipse inside `(left, top)–(right, bottom)`, from +/// `start` to `end` (radians, counter-clockwise in drawing coordinates). +fn ellipse_points(bounds: [f64; 4], start: f64, sweep: f64) -> Vec<[f64; 2]> { + let [left, top, right, bottom] = bounds; + let (cx, cy) = (0.5 * (left + right), 0.5 * (top + bottom)); + let (rx, ry) = (0.5 * (right - left).abs(), 0.5 * (bottom - top).abs()); + let step = std::f64::consts::TAU / ELLIPSE_SEGMENTS as f64; + let count = ((sweep.abs() / step).round() as usize).max(1); + (0..=count) + .map(|k| { + let a = start + sweep * k as f64 / count as f64; + // Metafile y runs down: counter-clockwise on the drawing is + // up the page. + [cx + rx * a.cos(), cy - ry * a.sin()] + }) + .collect() +} + +/// The angle of a radial point round an ellipse's centre, in drawing +/// orientation. +fn angle(bounds: [f64; 4], x: f64, y: f64) -> f64 { + let [left, top, right, bottom] = bounds; + let (cx, cy) = (0.5 * (left + right), 0.5 * (top + bottom)); + (-(y - cy)).atan2(x - cx) +} + +/// Reads a metafile, scaled so the picture fits within `fit` (width and +/// height, drawing units). `None` when it is not a metafile. +pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { + let word = |at: usize| -> Option { + Some(u16::from_le_bytes([*bytes.get(at)?, *bytes.get(at + 1)?])) + }; + let mut at = 0; + let mut bounds = None; + if word(0)? == 0xCDD7 && word(2)? == 0x9AC6 { + let s = |k: usize| word(6 + 2 * k).map(|w| w as i16 as f64); + bounds = Some([s(0)?, s(1)?, s(2)?, s(3)?]); + at = 22; + } + // The metafile header: type 1 or 2, nine words long. + if !matches!(word(at)?, 1 | 2) || word(at + 2)? != 9 { + return None; + } + at += 18; + let mut records = Vec::new(); + while at + 6 <= bytes.len() { + let size = (u32::from(word(at)?) | (u32::from(word(at + 2)?) << 16)) as usize; + let function = word(at + 4)?; + if function == 0 || size < 3 { + break; + } + let params: Vec = (0..size - 3).filter_map(|k| word(at + 6 + 2 * k)).collect(); + records.push((function, params)); + at += 2 * size; + } + + // The window first: where the picture is and how big. + let mut origin = [0.0, 0.0]; + let mut extent = None; + for (function, p) in &records { + let s = |k: usize| p.get(k).map(|w| *w as i16 as f64); + match function { + 0x020B => origin = [s(1)?, s(0)?], + 0x020C => extent = Some([s(1)?, s(0)?]), + _ => {} + } + } + let extent = extent + .or_else(|| bounds.map(|[l, t, r, b]| [r - l, b - t])) + .filter(|[w, h]| *w != 0.0 && *h != 0.0)?; + if let (None, Some([l, t, _, _])) = (records.iter().find(|(f, _)| *f == 0x020B), bounds) { + origin = [l, t]; + } + let scale = (fit[0] / extent[0].abs()).min(fit[1] / extent[1].abs()); + let mut player = Player { + origin, + scale: [scale * extent[0].signum(), -scale * extent[1].signum()], + entities: Vec::new(), + fonts: Vec::new(), + text_fonts: Vec::new(), + }; + + // A fresh device context: a one-unit black pen, a white brush. + let mut pen = Pen { + null: false, + width: 1.0, + rgb: [0, 0, 0], + }; + let mut brush = Brush { + null: false, + rgb: [255, 255, 255], + }; + let mut font = Font { + height: 12.0, + escapement: 0.0, + face: String::new(), + }; + let mut text_rgb = [0, 0, 0]; + let mut table: Vec> = Vec::new(); + let mut current = [0.0, 0.0]; + for (function, p) in &records { + let s = |k: usize| p.get(k).map(|w| *w as i16 as f64).unwrap_or(0.0); + let points = |from: usize, count: usize| -> Vec<[f64; 2]> { + (0..count).map(|k| [s(from + 2 * k), s(from + 2 * k + 1)]).collect() + }; + let create = |table: &mut Vec>, object: Object| { + match table.iter().position(Option::is_none) { + Some(slot) => table[slot] = Some(object), + None => table.push(Some(object)), + } + }; + match function { + // CREATEPENINDIRECT + 0x02FA => create( + &mut table, + Object::Pen(Pen { + null: p.first().is_some_and(|style| style & 0x0f == 5), + width: s(1), + rgb: colorref(p.get(3).copied().unwrap_or(0), p.get(4).copied().unwrap_or(0)), + }), + ), + // CREATEBRUSHINDIRECT + 0x02FC => create( + &mut table, + Object::Brush(Brush { + null: p.first() == Some(&1), + rgb: colorref(p.get(1).copied().unwrap_or(0), p.get(2).copied().unwrap_or(0)), + }), + ), + // CREATEFONTINDIRECT: height, width, escapement, ... face at + // byte 18. + 0x02FB => { + let mut face = Vec::new(); + for w in p.iter().skip(9) { + face.extend(w.to_le_bytes()); + } + let end = face.iter().position(|b| *b == 0).unwrap_or(face.len()); + create( + &mut table, + Object::Font(Font { + height: s(0).abs(), + escapement: s(2) / 10.0, + face: String::from_utf8_lossy(&face[..end]).into_owned(), + }), + ) + } + // Palettes and regions take a slot too. + 0x00F7 | 0x06FF | 0x01F9 | 0x0142 => create(&mut table, Object::Other), + // SELECTOBJECT + 0x012D => match table.get(p.first().copied().unwrap_or(0) as usize) { + Some(Some(Object::Pen(x))) => pen = *x, + Some(Some(Object::Brush(x))) => brush = *x, + Some(Some(Object::Font(x))) => font = x.clone(), + _ => {} + }, + // DELETEOBJECT + 0x01F0 => { + if let Some(slot) = table.get_mut(p.first().copied().unwrap_or(0) as usize) { + *slot = None; + } + } + // SETTEXTCOLOR + 0x0209 => text_rgb = colorref(p.first().copied().unwrap_or(0), p.get(1).copied().unwrap_or(0)), + // MOVETO, LINETO (y, x) + 0x0214 => current = [s(1), s(0)], + 0x0213 => { + let to = [s(1), s(0)]; + if !pen.null { + player.polyline(&[current, to], false, pen.width, pen.rgb); + } + current = to; + } + // POLYLINE + 0x0325 => { + if !pen.null { + let line = points(1, s(0) as usize); + player.polyline(&line, false, pen.width, pen.rgb); + } + } + // POLYGON + 0x0324 => { + let ring = points(1, s(0) as usize); + player.shape(&ring, &pen, &brush); + } + // POLYPOLYGON: counts, then every ring's points + 0x0538 => { + let rings = s(0) as usize; + let mut from = 1 + rings; + for k in 0..rings { + let count = s(1 + k) as usize; + let ring = points(from, count); + player.shape(&ring, &pen, &brush); + from += 2 * count; + } + } + // RECTANGLE, ROUNDRECT (bottom, right, top, left; corner + // ellipse first for the latter) + 0x041B | 0x061C => { + let o = if *function == 0x061C { 2 } else { 0 }; + let (b, r, t, l) = (s(o), s(o + 1), s(o + 2), s(o + 3)); + player.shape(&[[l, t], [r, t], [r, b], [l, b]], &pen, &brush); + } + // ELLIPSE: its outline in the pen + 0x0418 => { + if !pen.null { + let bounds = [s(3), s(2), s(1), s(0)]; + let ring = ellipse_points(bounds, 0.0, std::f64::consts::TAU); + player.polyline(&ring, false, pen.width, pen.rgb); + } + } + // ARC, CHORD, PIE: (yEnd, xEnd, yStart, xStart, bottom, + // right, top, left), drawn counter-clockwise + 0x0817 | 0x0830 | 0x081A => { + let bounds = [s(7), s(6), s(5), s(4)]; + let start = angle(bounds, s(3), s(2)); + let end = angle(bounds, s(1), s(0)); + let mut sweep = end - start; + if sweep <= 0.0 { + sweep += std::f64::consts::TAU; + } + let mut ring = ellipse_points(bounds, start, sweep); + match function { + 0x0817 => { + if !pen.null { + player.polyline(&ring, false, pen.width, pen.rgb); + } + } + _ => { + if *function == 0x081A { + ring.push([0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])]); + } + player.shape(&ring, &pen, &brush); + } + } + } + // TEXTOUT (count, string, y, x) and EXTTEXTOUT (y, x, count, + // options, [rectangle], string) + 0x0521 | 0x0A32 => { + let (count, text_at, (x, y)) = if *function == 0x0521 { + let count = s(0) as usize; + let words = count.div_ceil(2); + (count, 1, (s(2 + words), s(1 + words))) + } else { + let count = s(2) as usize; + let clipped = p.get(3).is_some_and(|o| o & 0x0006 != 0); + (count, if clipped { 8 } else { 4 }, (s(1), s(0))) + }; + let mut raw = Vec::new(); + for w in p.iter().skip(text_at) { + raw.extend(w.to_le_bytes()); + } + raw.truncate(count); + let value: String = raw.iter().map(|b| char::from(*b)).collect(); + if value.trim().is_empty() { + continue; + } + // The reference point is the cell's top; the baseline sits + // nine tenths of the font height below it. + let rotation = font.escapement.to_radians(); + let drop = 0.9 * font.height; + let base = player.at(x + drop * rotation.sin(), y + drop * rotation.cos()); + let index = match player.fonts.iter().position(|f| *f == font.face) { + Some(k) => k, + None => { + player.fonts.push(font.face.clone()); + player.fonts.len() - 1 + } + }; + let mut text = Text::new(); + text.value = value; + text.insertion_point = base; + text.height = 0.72 * font.height * scale; + text.rotation = rotation; + text.common.color = color(text_rgb); + player.text_fonts.push((player.entities.len(), index)); + player.entities.push(EntityType::Text(text)); + } + _ => {} + } + } + Some(Picture { + entities: player.entities, + fonts: player.fonts, + text_fonts: player.text_fonts, + }) +} diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 8868a5457..03982445a 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3477,10 +3477,6 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Unavailable" => Some(("plugins", "unavailable")), "Supported" => Some(("dgn-import", "supported")), "By extension" => Some(("dgn-import", "by-extension")), - "ACIS solver available" => Some(("dgn-import", "acis-solver")), - "Export only" => Some(("dgn-import", "export-only")), - "ACIS bodies are read only inside drawings; SAT files cannot be imported." => Some(("dgn-import", "acis-reason")), - "STEP files are only written (STEPOUT), not read." => Some(("dgn-import", "step-reason")), "OpenCAD Studio has no importer for this format." => Some(("dgn-import", "no-importer")), "Recent:" => Some(("dgn-import", "recent")), "2D / Underlay" => Some(("dgn-import", "group-2d")), diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 179e4261b..509d2de38 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -615,6 +615,17 @@ impl Scene { true } + /// The plan view's window: its centre and its width and height in world + /// units, and the world size of one screen pixel (zero before the first + /// frame). + pub fn view_window(&self) -> ([f64; 2], [f64; 2], f64) { + let aspect = f64::from(self.active_camera_aspect()); + let camera = self.camera.borrow(); + let height = 2.0 * f64::from(camera.ortho_size()); + let pixel = self.world_per_pixel().map_or(0.0, f64::from); + ([camera.target.x, camera.target.y], [height * aspect, height], pixel) + } + /// Aspect ratio of the active camera's actual render rectangle. Model /// panes are separate shader widgets, so the full-canvas render aspect is /// wrong whenever a tiled viewport is active. diff --git a/src/scene/convert/acis_import.rs b/src/scene/convert/acis_import.rs new file mode 100644 index 000000000..57da76578 --- /dev/null +++ b/src/scene/convert/acis_import.rs @@ -0,0 +1,75 @@ +//! ACIS text (SAT) files and kernel bodies as drawing objects. +//! +//! Each body of a file becomes one object: a closed solid a 3DSOLID, a sheet +//! whose faces all lie on planes a REGION, any other sheet a SURFACE — the +//! classes the ACIS import of the reference application produces. + +use codec::entities::acis::{SatDocument, SatFace, Sidedness}; +use codec::entities::{AcisData, Region, Solid3D, Surface, SurfaceKind}; +use codec::EntityType; + +/// What a body restores as. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Class { + Solid, + Region, + Surface, +} + +fn class(document: &SatDocument) -> Option { + let faces: Vec> = document + .records + .iter() + .filter_map(SatFace::from_record) + .collect(); + if faces.is_empty() { + return None; + } + if faces + .iter() + .all(|face| face.sidedness() != Sidedness::Double) + { + return Some(Class::Solid); + } + let planar = faces.iter().all(|face| { + face.surface() + .index() + .and_then(|at| document.record(at)) + .is_some_and(|surface| surface.entity_type == "plane-surface") + }); + Some(if planar { Class::Region } else { Class::Surface }) +} + +/// One body's document as its drawing object. +fn entity(document: &SatDocument) -> Option { + Some(match class(document)? { + Class::Solid => { + let mut solid = Solid3D::new(); + solid.set_sat_document(document); + EntityType::Solid3D(solid) + } + Class::Region => { + let mut region = Region::new(); + region.set_sat_document(document); + EntityType::Region(region) + } + Class::Surface => { + let mut surface = Surface::new(SurfaceKind::Generic); + surface.acis_data = AcisData::from_sat(&document.to_sat_string()); + EntityType::Surface(Box::new(surface)) + } + }) +} + +/// The objects of an ACIS text file, one per body with faces; `None` when +/// the text is no ACIS data. +pub fn sat_entities(text: &str) -> Option> { + let document = SatDocument::parse(text).ok()?; + Some(document.split_bodies().iter().filter_map(entity).collect()) +} + +/// A kernel body as its drawing object, written as exact ACIS. +pub fn body_entity(body: &kernel::brep::Body) -> Option { + let document = super::acis_export::solid_to_sat(body)?; + entity(&document) +} diff --git a/src/scene/convert/mod.rs b/src/scene/convert/mod.rs index 842532517..4ca327429 100644 --- a/src/scene/convert/mod.rs +++ b/src/scene/convert/mod.rs @@ -1,5 +1,6 @@ pub mod acad_to_render; pub mod acis_export; +pub mod acis_import; pub mod acis_kernel; pub mod dgn_linestyle; pub mod proxy_graphics; diff --git a/src/ui/window/import_dialogs.rs b/src/ui/window/import_dialogs.rs index 48b010e9e..d553411e1 100644 --- a/src/ui/window/import_dialogs.rs +++ b/src/ui/window/import_dialogs.rs @@ -50,10 +50,6 @@ pub enum Support { Yes, /// Any file: the importer follows the file's extension. ByExtension, - /// SAT: ACIS bodies are read only inside drawings. - AcisSolverOnly, - /// STEP: written by STEPOUT, not read. - ExportOnly, No, } @@ -87,9 +83,9 @@ pub const FORMATS: [ImportFormat; 21] = [ format("PDF Files", "*.pdf", &["pdf"], 0, Support::Yes), format("MicroStation DGN", "*.dgn", &["dgn"], 0, Support::Yes), format("All DGN Files", "*.*", &["*"], 0, Support::Yes), - format("Metafile", "*.wmf", &["wmf"], 0, Support::No), + format("Metafile", "*.wmf", &["wmf"], 0, Support::Yes), format("3D Studio", "*.3ds", &["3ds"], 1, Support::No), - format("ACIS", "*.sat", &["sat"], 1, Support::AcisSolverOnly), + format("ACIS", "*.sat", &["sat"], 1, Support::Yes), format( "CATIA V4", "*.model; *.session; *.exp; *.dlv3", @@ -104,7 +100,7 @@ pub const FORMATS: [ImportFormat; 21] = [ 1, Support::No, ), - format("IGES", "*.igs; *.iges", &["igs", "iges"], 1, Support::No), + format("IGES", "*.igs; *.iges", &["igs", "iges"], 1, Support::Yes), format("Inventor", "*.ipt; *.iam", &["ipt", "iam"], 1, Support::No), format("JT", "*.jt", &["jt"], 1, Support::No), format("NX", "*.prt", &["prt"], 1, Support::No), @@ -121,7 +117,7 @@ pub const FORMATS: [ImportFormat; 21] = [ 1, Support::No, ), - format("STEP", "*.ste; *.stp; *.step", &["ste", "stp", "step"], 1, Support::ExportOnly), + format("STEP", "*.ste; *.stp; *.step", &["ste", "stp", "step"], 1, Support::Yes), format("All Files", "*.*", &["*"], 2, Support::ByExtension), ]; @@ -178,13 +174,10 @@ fn badge<'a>(support: Support) -> Element<'a, Message> { let (label, tone) = match support { Support::Yes => (t!("Supported"), 0), Support::ByExtension => (t!("By extension"), 2), - Support::AcisSolverOnly => (t!("ACIS solver available"), 1), - Support::ExportOnly => (t!("Export only"), 1), Support::No => (t!("None"), 2), }; let color = move |theme: &Theme| match tone { 0 => theme.palette().success.base.color, - 1 => AMBER, _ => theme.palette().background.base.text.scale_alpha(0.55), }; container( @@ -210,10 +203,6 @@ fn badge<'a>(support: Support) -> Element<'a, Message> { pub fn unsupported_reason(support: Support) -> Option> { match support { Support::Yes | Support::ByExtension => None, - Support::AcisSolverOnly => Some(t!( - "ACIS bodies are read only inside drawings; SAT files cannot be imported." - )), - Support::ExportOnly => Some(t!("STEP files are only written (STEPOUT), not read.")), Support::No => Some(t!("OpenCAD Studio has no importer for this format.")), } } From 2019964591e5b70b5f93366130aa0d5c4619b953 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:13:29 +0300 Subject: [PATCH 2/9] Block definitions tessellate their solids A block defined from objects (imported STEP products, pasted blocks, recreated or redefined definitions) left its solids, surfaces and meshes without the instanced block meshes they draw through, so their inserts showed nothing until the drawing was reopened. Co-Authored-By: Claude Opus 5.5 --- src/scene/entity.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/scene/entity.rs b/src/scene/entity.rs index ac89c65c6..5b9af9763 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1349,6 +1349,9 @@ impl Scene { .map_err(|e| e.to_string())?; entity_handles.push(handle); } + // Solids, surfaces and meshes of a definition draw through their + // instanced block meshes, which only a tessellation pass creates. + self.refresh_meshes_for_handles(&entity_handles); // Block defns don't render on their own, but the geometry cache must // pick up the new definition so the interactive insert can preview it. self.bump_geometry(); @@ -1391,12 +1394,14 @@ impl Scene { block_end.common.owner_handle = br_handle; let _ = self.document.add_entity(EntityType::BlockEnd(block_end)); + let mut added = Vec::new(); for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; - let _ = self.document.add_entity(entity); + added.extend(self.document.add_entity(entity).ok()); } + self.refresh_meshes_for_handles(&added); self.bump_geometry(); } @@ -1446,18 +1451,21 @@ impl Scene { }) .map(|e| e.common().handle) .collect(); - for h in owned { - self.document.remove_entity(h); + for h in &owned { + self.document.remove_entity(*h); } if let Some(EntityType::Block(block)) = self.document.get_entity_mut(block_handle) { block.base_point = base_point; } + // The old contents' meshes go with them; the new ones are built. + let mut changed = owned; for mut entity in entities { Self::reset_clone_subhandles(&mut self.document, &mut entity); entity.common_mut().handle = Handle::NULL; entity.common_mut().owner_handle = br_handle; - let _ = self.document.add_entity(entity); + changed.extend(self.document.add_entity(entity).ok()); } + self.refresh_meshes_for_handles(&changed); self.bump_geometry(); Ok(()) } From 50b28513810bb05871c30a978420eac67d63ddfe Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:21:59 +0300 Subject: [PATCH 3/9] Drawing extents include solids placed through inserts Zoom Extents skips an insert's marker wire and counts solids through their meshes, but only model-space meshes were read: a drawing whose solids all sit in blocks had no extents and the view did not move. The instanced block meshes now count with their placed bounds. Co-Authored-By: Claude Opus 5.5 --- src/scene/camera_ops.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 509d2de38..4b2cee7ab 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1778,6 +1778,21 @@ impl Scene { fold!(Some((lo.min(hi), lo.max(hi)))); } } + // Solids inside blocks draw as one mesh instance per insert, placed + // where the insert puts them: that is the content the skipped insert + // marker stands for. + for set in self.instanced_block_meshes( + block, + None, + scale, + self.annotation_all_visible(), + None, + None, + ) { + if let Some([x0, y0, z0, x1, y1, z1]) = set.instance_aabb { + fold!(Some((glam::DVec3::new(x0, y0, z0), glam::DVec3::new(x1, y1, z1)))); + } + } // Raster images, OLE frames and underlays draw as GPU quads; with // their frames hidden they carry no wire outline at all. From 8e9bd7db6901a24d93563965af247d1c5ccb4661 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:12:46 +0300 Subject: [PATCH 4/9] IMPORT: STEP colours and IGES trimmed and bounded surfaces STEP presentation styles colour what they style: a solid in the standard index colour its RGB is close to, else in the true colour, and a face with a style of its own (with the edges round it) through the face and edge colour attributes. IGES trimmed (144) and bounded (143) surfaces come in as one SURFACE each in the entry's colour, after the solids, which now come in last first. Faces show a true colour attribute in that colour. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/io/iges_import.rs | 216 ++++++++++++++++++++++++++++++- src/io/model_import.rs | 84 +++++++++--- src/io/step_import.rs | 106 ++++++++++++++- src/scene/convert/acis_import.rs | 99 +++++++++++++- src/scene/convert/acis_kernel.rs | 13 +- 7 files changed, 490 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 94f7b77b5..047c97779 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4382,7 +4382,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/ramox81/cadcodec.git?rev=6c33e24#6c33e24dc3c72034afec8333f0554541f01cc508" +source = "git+https://github.com/ramox81/cadcodec.git?rev=58fac2c#58fac2c067cbd1346c08f371aaf92b0807a07f66" dependencies = [ "anyhow", "bitflags 2.13.1", diff --git a/Cargo.toml b/Cargo.toml index 03cabebf8..5223981c3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -224,7 +224,7 @@ name = "integration" path = "tests/integration.rs" [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "6c33e24" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "58fac2c" } [patch."https://github.com/HakanSeven12/opencadkernel.git"] opencadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "9490fb8" } diff --git a/src/io/iges_import.rs b/src/io/iges_import.rs index 64c0ee4d4..6317b4605 100644 --- a/src/io/iges_import.rs +++ b/src/io/iges_import.rs @@ -1,6 +1,8 @@ //! IGES reading: the manifold solids (type 186) of a file, their shells, -//! faces, loops and edges (514/510/508/504/502) over spline surfaces and -//! curves (128/126), lines and arcs (110/100), as kernel bodies. +//! faces, loops and edges (514/510/508/504/502), and its trimmed and bounded +//! surfaces (144/142 and 143/141), over spline surfaces and curves +//! (128/126), lines and arcs (110/100) and composite curves (102), as +//! kernel bodies. use std::collections::HashMap; @@ -80,6 +82,8 @@ struct Doc { entities: HashMap, /// File units to drawing units. scale: f64, + /// Points closer than this are one point, in drawing units. + resolution: f64, } /// A 3×4 matrix applied as `R·p + T`. @@ -205,6 +209,60 @@ impl Doc { }) } + /// A boundary curve as its pieces — each a curve and the points it runs + /// from and to — composite curves taken apart. + fn pieces(&self, pointer: f64) -> Option> { + let entity = self.get(pointer)?; + if entity.kind == 102 { + let count = entity.params.get(1).map_or(0.0, P::num) as usize; + let mut out = Vec::new(); + for k in 0..count { + out.extend(self.pieces(entity.params.get(2 + k)?.num())?); + } + return Some(out); + } + let curve = self.curve(pointer)?; + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let place = self.place(entity); + let (from, to) = match (&curve, entity.kind) { + (_, 110) => (place([v(1), v(2), v(3)]), place([v(4), v(5), v(6)])), + (_, 100) => (place([v(4), v(5), v(1)]), place([v(6), v(7), v(1)])), + (Curve3::Nurbs(nurbs), _) => { + let (t0, t1) = nurbs.domain(); + (nurbs.point_at_knot(t0), nurbs.point_at_knot(t1)) + } + _ => return None, + }; + Some(vec![(curve, from, to)]) + } + + /// The model-space pieces of a curve on a surface (142) or a boundary + /// (141), each of the latter's curves in the sense it is run. + fn boundary(&self, pointer: f64) -> Option> { + let entity = self.get(pointer)?; + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + match entity.kind { + 142 => self.pieces(v(4)), + 141 => { + let mut out = Vec::new(); + let mut at = 5; + for _ in 0..v(4) as usize { + let mut pieces = self.pieces(v(at))?; + if v(at + 1) == 2.0 { + pieces.reverse(); + for piece in &mut pieces { + std::mem::swap(&mut piece.1, &mut piece.2); + } + } + out.extend(pieces); + at += 3 + v(at + 2) as usize; + } + Some(out) + } + _ => self.pieces(pointer), + } + } + fn surface(&self, pointer: f64) -> Option { let entity = self.get(pointer)?; if entity.kind != 128 { @@ -243,6 +301,12 @@ struct Assembly<'a> { build: Builder, vertices: HashMap<(usize, usize), VertexKey>, edges: HashMap<(usize, usize), EdgeKey>, + /// Vertices made where boundary curves meet, by position. + corners: Vec<([f64; 3], VertexKey)>, +} + +fn distance(a: [f64; 3], b: [f64; 3]) -> f64 { + (0..3).map(|k| (a[k] - b[k]).powi(2)).sum::().sqrt() } impl Assembly<'_> { @@ -276,6 +340,119 @@ impl Assembly<'_> { Some(e) } + /// The vertex at a point, shared with one already there. + fn corner(&mut self, point: [f64; 3]) -> VertexKey { + let tolerance = self.doc.resolution; + if let Some((_, v)) = self.corners.iter().find(|(p, _)| distance(*p, point) <= tolerance) { + return *v; + } + let v = self.build.vertex(point); + self.corners.push((point, v)); + v + } + + /// A loop along curve pieces, in their order. + fn curve_ring(&mut self, pieces: Vec<(Curve3, [f64; 3], [f64; 3])>) -> Option> { + let mut ring = Vec::new(); + for (curve, from, to) in pieces { + if distance(from, to) <= self.doc.resolution && matches!(curve, Curve3::Line(_)) { + continue; + } + let (a, b) = (self.corner(from), self.corner(to)); + ring.push((self.build.edge(curve, a, b)?, true)); + } + (!ring.is_empty()).then_some(ring) + } + + /// A loop round the edge of a spline surface's parameter domain; sides + /// that close up to a point (a pole) are left out. + fn domain_ring(&mut self, surface: &NurbsSurface3) -> Option> { + let ((u0, u1), (v0, v1)) = surface.domain(); + let corners = [(u0, v0), (u1, v0), (u1, v1), (u0, v1)]; + let sides = [(1, v0), (0, u1), (1, v1), (0, u0)]; + let mut pieces = Vec::new(); + for (k, (axis, at)) in sides.into_iter().enumerate() { + let (a, b) = (corners[k], corners[(k + 1) % 4]); + let (from, to) = (surface.point_at_knot(a.0, a.1), surface.point_at_knot(b.0, b.1)); + let middle = surface.point_at_knot(0.5 * (a.0 + b.0), 0.5 * (a.1 + b.1)); + if distance(from, middle) <= self.doc.resolution && distance(to, middle) <= self.doc.resolution { + continue; + } + pieces.push((Curve3::Nurbs(surface.isocurve(axis, at)?), from, to)); + } + self.curve_ring(pieces) + } + + /// Turns a loop to run counter-clockwise round the surface's normal (an + /// outer one) or clockwise (a hole), judged by the area it encloses in + /// the surface's parameters. + fn orient(&self, surface: &Surface, ring: &mut [(EdgeKey, bool)], outer: bool) { + let mut uv = Vec::new(); + for (edge, forward) in ring.iter() { + let Some(edge) = self.build.body.edges.get(*edge) else { return }; + let Some(curve) = self.build.body.curves.get(edge.curve) else { return }; + let (t0, t1) = (edge.start_parameter, edge.end_parameter); + for k in 0..8 { + let s = k as f64 / 8.0; + let s = if *forward { s } else { 1.0 - s }; + if let Some(p) = surface.parameters_at(curve.point_at(t0 + (t1 - t0) * s)) { + uv.push(p); + } + } + } + let area: f64 = (0..uv.len()) + .map(|k| { + let (a, b) = (uv[k], uv[(k + 1) % uv.len()]); + a.0 * b.1 - b.0 * a.1 + }) + .sum(); + if (area > 0.0) != outer { + ring.reverse(); + for (_, forward) in ring.iter_mut() { + *forward = !*forward; + } + } + } + + /// A trimmed (144) or bounded (143) surface as a one-face sheet. + fn sheet(&mut self, entity: &Entity) -> Option<()> { + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + let (surface_at, outer, holes): (f64, Option, Vec) = match entity.kind { + 144 => ( + v(1), + (v(2) != 0.0).then(|| v(4)), + (0..v(3) as usize).map(|k| v(5 + k)).collect(), + ), + 143 => { + let count = v(3) as usize; + (v(2), (count > 0).then(|| v(4)), (1..count).map(|k| v(4 + k)).collect()) + } + _ => return None, + }; + let Surface::Nurbs(nurbs) = self.doc.surface(surface_at)? else { return None }; + let surface = Surface::Nurbs(nurbs.clone()); + let mut first = match outer { + Some(boundary) => { + let pieces = self.doc.boundary(boundary)?; + self.curve_ring(pieces)? + } + None => self.domain_ring(&nurbs)?, + }; + self.orient(&surface, &mut first, true); + let mut loops = vec![first]; + for hole in holes { + let pieces = self.doc.boundary(hole)?; + let mut ring = self.curve_ring(pieces)?; + self.orient(&surface, &mut ring, false); + loops.push(ring); + } + self.build.lump(vec![vec![FaceSpec { + surface, + forward: true, + loops, + }]]) + } + fn ring(&mut self, pointer: f64) -> Option> { let entity = self.doc.get(pointer).filter(|e| e.kind == 508)?; let p = |k: usize| entity.params.get(k).map(P::num); @@ -330,6 +507,8 @@ impl Assembly<'_> { pub struct IgesModel { /// Each solid, scaled to the drawing, with its colour. pub bodies: Vec<(kernel::brep::Body, codec::types::Color)>, + /// Each trimmed or bounded surface, a one-face sheet, with its colour. + pub sheets: Vec<(kernel::brep::Body, codec::types::Color)>, } /// `drawing_mm`: millimetres per drawing unit, `None` for a unitless drawing. @@ -417,7 +596,12 @@ pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { ); } let scale = drawing_mm.filter(|mm| *mm > 0.0).map_or(1.0, |mm| unit_mm / mm); - let doc = Doc { entities, scale }; + let resolution = globals.get(18).map(P::num).filter(|r| *r > 0.0).unwrap_or(1e-6) * scale; + let doc = Doc { + entities, + scale, + resolution, + }; let mut solids: Vec = doc .entities @@ -436,6 +620,7 @@ pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { build: Builder::new(), vertices: HashMap::new(), edges: HashMap::new(), + corners: Vec::new(), }; let mut shells = Vec::new(); let outer = assembly.shell(shell, agrees != 0.0); @@ -450,5 +635,28 @@ pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { } } } - Some(IgesModel { bodies }) + // The solids come in last first, as the reference reader places them. + bodies.reverse(); + let mut numbers: Vec = doc + .entities + .iter() + .filter(|(_, e)| matches!(e.kind, 143 | 144)) + .map(|(n, _)| *n) + .collect(); + numbers.sort_unstable(); + let mut sheets = Vec::new(); + for number in numbers { + let entity = &doc.entities[&number]; + let mut assembly = Assembly { + doc: &doc, + build: Builder::new(), + vertices: HashMap::new(), + edges: HashMap::new(), + corners: Vec::new(), + }; + if assembly.sheet(entity).is_some() { + sheets.push((assembly.build.body, doc.color(entity.color))); + } + } + Some(IgesModel { bodies, sheets }) } diff --git a/src/io/model_import.rs b/src/io/model_import.rs index a0d347057..f5515a0ae 100644 --- a/src/io/model_import.rs +++ b/src/io/model_import.rs @@ -5,7 +5,7 @@ use codec::entities::{Entity as _, Insert}; use codec::types::{Color, Matrix4, Transform, Vector3}; use codec::EntityType; -use crate::scene::convert::acis_import::{body_entity, sat_entities}; +use crate::scene::convert::acis_import::{painted_body_entity, sat_entities}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ModelFormat { @@ -38,6 +38,55 @@ fn block_name(name: &str) -> String { if name.is_empty() { "Part".to_string() } else { name } } +/// A STEP presentation colour as the drawing colour it imports as: the +/// standard index colour it is close to — red, yellow, green, cyan, blue, +/// magenta, white, or the dark grey 250 (which black also becomes) — and +/// otherwise the true colour. +// ponytail: the closeness bands are fitted to measured imports (saturation +// at least 0.75, value at least 0.825 and hue within 7.5 degrees for the six +// hues; greys from 0.16 to 0.39 for 250 and from 0.935 for white); refit +// them if a colour on a band edge comes out differently. +pub fn step_colour(rgb: crate::io::step_import::Rgb) -> Color { + let [r, g, b] = rgb.map(|c| c.clamp(0.0, 1.0)); + let true_colour = Color::Rgb { + r: (r * 255.0).round() as u8, + g: (g * 255.0).round() as u8, + b: (b * 255.0).round() as u8, + }; + let max = r.max(g).max(b); + let min = r.min(g).min(b); + if max == 0.0 { + return Color::Index(250); + } + let saturation = (max - min) / max; + if saturation <= 0.1 { + return if max >= 0.935 { + Color::Index(7) + } else if (0.16..0.395).contains(&max) { + Color::Index(250) + } else { + true_colour + }; + } + if saturation < 0.75 || max < 0.825 { + return true_colour; + } + let delta = max - min; + let hue = if max == r { + 60.0 * ((g - b) / delta).rem_euclid(6.0) + } else if max == g { + 60.0 * ((b - r) / delta + 2.0) + } else { + 60.0 * ((r - g) / delta + 4.0) + }; + let sector = (hue / 60.0).round(); + if (hue - 60.0 * sector).abs() > 7.5 { + return true_colour; + } + // Red, yellow, green, cyan, blue, magenta. + Color::Index([1, 2, 3, 4, 5, 6][sector as usize % 6]) +} + /// An insert of `block` placed by `placement` (columns: axes, origin). fn placed_insert(block: &str, placement: &[[f64; 4]; 3]) -> EntityType { let mut insert = Insert::new(block, Vector3::ZERO); @@ -89,10 +138,9 @@ fn step_blocks(model: crate::io::step_import::StepModel, scale: f64) -> Imported let mut entities: Vec = product .bodies .iter() - .filter_map(body_entity) - .map(|mut entity| { - entity.common_mut().color = Color::ByBlock; - entity + .filter_map(|body| { + let faces: Vec> = body.faces.iter().map(|c| c.map(step_colour)).collect(); + painted_body_entity(&body.body, body.colour.map(step_colour), &faces, false) }) .collect(); entities.extend( @@ -131,18 +179,20 @@ pub fn read(format: ModelFormat, bytes: &[u8], drawing_mm: Option) -> Optio let scale = ratio(model.unit_mm, drawing_mm); step_blocks(model, scale) } - ModelFormat::Iges => Imported { - blocks: Vec::new(), - entities: crate::io::iges_import::read(bytes, drawing_mm)? - .bodies - .iter() - .filter_map(|(body, color)| { - let mut entity = body_entity(body)?; - entity.common_mut().color = *color; - Some(entity) - }) - .collect(), - }, + ModelFormat::Iges => { + // Solids, then the surfaces as surfaces however flat; faces and + // edges carry the object's colour. + let model = crate::io::iges_import::read(bytes, drawing_mm)?; + let solids = model.bodies.iter().map(|body| (body, false)); + let sheets = model.sheets.iter().map(|body| (body, true)); + Imported { + blocks: Vec::new(), + entities: solids + .chain(sheets) + .filter_map(|((body, color), sheet)| painted_body_entity(body, Some(*color), &[], sheet)) + .collect(), + } + } }; let solids = imported .blocks diff --git a/src/io/step_import.rs b/src/io/step_import.rs index c4b951dda..4953d7052 100644 --- a/src/io/step_import.rs +++ b/src/io/step_import.rs @@ -197,9 +197,68 @@ struct Doc { instances: HashMap, /// Plane-angle unit in radians. angle: f64, + /// The colour each styled item (a solid, a shell or a face) is shown + /// in, as 0–1 RGB. + styles: HashMap, } +/// An RGB colour as 0–1 values. +pub type Rgb = [f64; 3]; + impl Doc { + /// A presentation colour: an RGB one or a predefined one. + fn colour(&self, p: &P) -> Option { + let (kind, params) = self.simple(p.reference()?)?; + match kind { + "COLOUR_RGB" => Some([params.get(1)?.num()?, params.get(2)?.num()?, params.get(3)?.num()?]), + "DRAUGHTING_PRE_DEFINED_COLOUR" => Some(match params.first()?.text()? { + "red" => [1.0, 0.0, 0.0], + "green" => [0.0, 1.0, 0.0], + "blue" => [0.0, 0.0, 1.0], + "yellow" => [1.0, 1.0, 0.0], + "magenta" => [1.0, 0.0, 1.0], + "cyan" => [0.0, 1.0, 1.0], + "black" => [0.0, 0.0, 0.0], + "white" => [1.0, 1.0, 1.0], + _ => return None, + }), + _ => None, + } + } + + /// The colour a styled item's presentation styles give it: its surface + /// fill (or rendering) colour, or failing that its curve colour. + fn style_colour(&self, styles: &P) -> Option { + let mut stack: Vec<&P> = vec![styles]; + let mut seen = Vec::new(); + let mut curve = None; + while let Some(p) = stack.pop() { + match p { + P::List(items) | P::Typed(items) => stack.extend(items.iter().rev()), + P::Ref(id) if !seen.contains(id) => { + seen.push(*id); + let Some((kind, params)) = self.simple(*id) else { continue }; + match kind { + "FILL_AREA_STYLE_COLOUR" => { + if let Some(c) = params.get(1).and_then(|c| self.colour(c)) { + return Some(c); + } + } + "SURFACE_STYLE_RENDERING" | "SURFACE_STYLE_RENDERING_WITH_PROPERTIES" => { + if let Some(c) = params.get(1).and_then(|c| self.colour(c)) { + return Some(c); + } + } + "CURVE_STYLE" => curve = curve.or_else(|| params.get(3).and_then(|c| self.colour(c))), + _ => stack.extend(params.iter().rev()), + } + } + _ => {} + } + } + curve + } + fn parts(&self, id: u64) -> Option<&Instance> { self.instances.get(&id) } @@ -392,6 +451,8 @@ struct Assembly<'a> { vertices: HashMap, /// Each edge, and whether it runs from the file's edge end to its start. edges: HashMap, + /// The file's face of each face built, in build order. + faces: Vec, } impl Assembly<'_> { @@ -450,6 +511,7 @@ impl Assembly<'_> { fn face(&mut self, p: &P, flip: bool) -> Option { let id = p.reference()?; + self.faces.push(id); let (_, params) = self.doc.simple(id)?; let surface = self.doc.surface(params.get(2)?)?; let mut outer = Vec::new(); @@ -505,10 +567,19 @@ pub type Placement = [[f64; 4]; 3]; const IDENTITY: Placement = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; +/// A body with the colours the file shows it in. +pub struct StepBody { + pub body: kernel::brep::Body, + /// The colour of the solid (or shell model) as a whole. + pub colour: Option, + /// Each face's own colour, in the body's face order. + pub faces: Vec>, +} + /// One product: its own bodies and where each of its components sits. pub struct StepProduct { pub name: String, - pub bodies: Vec, + pub bodies: Vec, /// Components: the product (an index into [`StepModel::products`]) and /// its placement in this one. pub children: Vec<(usize, Placement)>, @@ -549,7 +620,7 @@ fn unit_size(doc: &Doc, id: u64) -> Option { /// The bodies of one B-rep item: a solid, a solid with voids, or each shell /// of a shell model. -fn item_bodies(doc: &Doc, id: u64) -> Vec { +fn item_bodies(doc: &Doc, id: u64) -> Vec { let Some((kind, params)) = doc.simple(id) else { return Vec::new(); }; @@ -576,11 +647,22 @@ fn item_bodies(doc: &Doc, id: u64) -> Vec { build: Builder::new(), vertices: HashMap::new(), edges: HashMap::new(), + faces: Vec::new(), }; let faces: Option> = lump.iter().map(|shell| assembly.shell(shell)).collect(); if let Some(faces) = faces { if assembly.build.lump(faces).is_some() { - bodies.push(assembly.build.body); + // The item's own style, or its (first) shell's. + let colour = doc.styles.get(&id).copied().or_else(|| { + lump.iter() + .filter_map(|shell| shell.reference()) + .find_map(|shell| doc.styles.get(&shell).copied()) + }); + bodies.push(StepBody { + body: assembly.build.body, + colour, + faces: assembly.faces.iter().map(|face| doc.styles.get(face).copied()).collect(), + }); } } } @@ -648,6 +730,7 @@ pub fn read(bytes: &[u8]) -> Option { let mut doc = Doc { instances, angle: 1.0, + styles: HashMap::new(), }; let mut unit_mm = 1.0; @@ -669,6 +752,23 @@ pub fn read(bytes: &[u8]) -> Option { break; } doc.angle = angle; + // Styled items: plain ones first, so an overriding style wins. + let mut styled = Vec::new(); + for kind in ["STYLED_ITEM", "OVER_RIDING_STYLED_ITEM"] { + for &id in &ids { + if doc.kind(id) != kind { + continue; + } + let Some((_, params)) = doc.simple(id) else { continue }; + let (Some(styles), Some(item)) = (params.get(1), params.get(2).and_then(P::reference)) else { + continue; + }; + if let Some(colour) = doc.style_colour(styles) { + styled.push((item, colour)); + } + } + } + doc.styles.extend(styled); let doc = doc; // What each instance id is, by kind. diff --git a/src/scene/convert/acis_import.rs b/src/scene/convert/acis_import.rs index 57da76578..f7485b328 100644 --- a/src/scene/convert/acis_import.rs +++ b/src/scene/convert/acis_import.rs @@ -42,7 +42,11 @@ fn class(document: &SatDocument) -> Option { /// One body's document as its drawing object. fn entity(document: &SatDocument) -> Option { - Some(match class(document)? { + entity_as(document, class(document)?) +} + +fn entity_as(document: &SatDocument, class: Class) -> Option { + Some(match class { Class::Solid => { let mut solid = Solid3D::new(); solid.set_sat_document(document); @@ -68,8 +72,93 @@ pub fn sat_entities(text: &str) -> Option> { Some(document.split_bodies().iter().filter_map(entity).collect()) } -/// A kernel body as its drawing object, written as exact ACIS. -pub fn body_entity(body: &kernel::brep::Body) -> Option { - let document = super::acis_export::solid_to_sat(body)?; - entity(&document) +/// The value of an ACIS colour attribute: the index (0 by block), or the +/// index nearest a true colour. +fn attribute_index(color: codec::types::Color) -> i64 { + match color { + codec::types::Color::Index(index) => i64::from(index), + codec::types::Color::Rgb { r, g, b } => i64::from(codec::types::nearest_aci(r, g, b)), + _ => 0, + } +} + +/// Hangs colour attributes on a record: a true colour first, then its +/// index, as imported models carry them. +fn paint_record(document: &mut codec::entities::acis::SatDocument, owner: usize, color: codec::types::Color) { + use codec::entities::acis::{SatPointer, SatRecord, SatToken}; + let mut chain: Vec<(&str, i64)> = Vec::new(); + if let codec::types::Color::Rgb { r, g, b } = color { + let packed = (0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); + chain.push(("truecolor-adesk-attrib", i64::from(packed as i32))); + } + chain.push(("color-adesk-attrib", attribute_index(color))); + let Some(existing) = document.record(owner).map(|record| record.attribute) else { + return; + }; + let first = document.record_count() as i32; + let count = chain.len() as i32; + for (k, (kind, value)) in chain.into_iter().enumerate() { + let at = first + k as i32; + let next = if k as i32 + 1 < count { SatPointer::new(at + 1) } else { existing }; + let previous = if k == 0 { SatPointer::NULL } else { SatPointer::new(at - 1) }; + let mut record = SatRecord::new(-1, kind); + record.tokens = vec![ + SatToken::Pointer(next), + SatToken::Pointer(previous), + SatToken::Pointer(SatPointer::new(owner as i32)), + SatToken::Integer(value), + ]; + document.add_record(record); + } + if let Some(record) = document.record_mut(owner) { + record.attribute = SatPointer::new(first); + } +} + +/// A kernel body as its drawing object in `colour` (by block when `None`), +/// with the faces given a colour of their own carrying it — and the edges +/// bounding them too, as the reference modeler stores them. A `sheet` is a +/// SURFACE however flat it is. +pub fn painted_body_entity( + body: &kernel::brep::Body, + colour: Option, + faces: &[Option], + sheet: bool, +) -> Option { + let mut document = super::acis_export::solid_to_sat(body)?; + let base = colour.unwrap_or(codec::types::Color::ByBlock); + let own: std::collections::HashMap = body + .faces + .keys() + .zip(faces) + .filter_map(|(key, color)| Some((key, (*color)?))) + .collect(); + // The writer puts faces and edges in the body's own order. + let face_records: Vec = (0..document.record_count()) + .filter(|&at| document.record(at).is_some_and(|r| r.entity_type == "face")) + .collect(); + let edge_records: Vec = (0..document.record_count()) + .filter(|&at| document.record(at).is_some_and(|r| r.entity_type == "edge")) + .collect(); + for (key, at) in body.faces.keys().zip(face_records) { + if let Some(color) = own.get(&key) { + paint_record(&mut document, at, *color); + } + } + for ((_, edge), at) in body.edges.iter().zip(edge_records) { + let bounding = edge.coedges.iter().find_map(|coedge| { + let ring = body.coedges.get(*coedge)?.owner; + own.get(&body.loops.get(ring)?.owner).copied() + }); + if let Some(color) = bounding { + paint_record(&mut document, at, color); + } + } + let mut entity = if sheet { + entity_as(&document, Class::Surface)? + } else { + entity(&document)? + }; + entity.common_mut().color = base; + Some(entity) } diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index c37992c28..0ec93b26a 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -70,13 +70,24 @@ pub fn tessellate_sat( (owner >= 0 && handle > 0).then(|| (owner, codec::Handle::new(handle as u64))) }) .collect(); + // A true colour attribute outranks the index one beside it, which only + // holds its nearest index. let face_colors: std::collections::HashMap = document .records .iter() .filter(|record| record.entity_type == "color-adesk-attrib") + .chain( + document + .records + .iter() + .filter(|record| record.entity_type == "truecolor-adesk-attrib"), + ) .filter_map(|record| { let owner = record.token_pointer(2)?.0; - let value = record.token(3)?.as_integer()?; + let mut value = record.token(3)?.as_integer()?; + if record.entity_type == "truecolor-adesk-attrib" { + value = (value & 0x00FF_FFFF) | 0x0100_0000; + } let source = if (1..=255).contains(&value) { codec::Color::from_index(value as i16) } else if value > 257 { From 9ddc103bcce41e8dd44f12022e6e53105bdccf87 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 19:12:47 +0300 Subject: [PATCH 5/9] WMF: arcs on whole units, text by its font's ascent, no chords Arcs and pies are cut at whole multiples of 1/99 turn round the ellipse's whole-unit centre and radii, rounded down, from the start ray to the end ray. Text sits the font's ascent below its reference point and is four fifths of it tall; its style is WMF- on the installed file of that face. CHORD records are not read. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/model_import.rs | 29 ++-- src/io/wmf_import.rs | 220 ++++++++++++++++++++++++++----- src/scene/text/sysfont.rs | 16 +++ 3 files changed, 215 insertions(+), 50 deletions(-) diff --git a/src/app/commands/model_import.rs b/src/app/commands/model_import.rs index 94b86d8f3..f5a23b5c9 100644 --- a/src/app/commands/model_import.rs +++ b/src/app/commands/model_import.rs @@ -145,22 +145,21 @@ impl OpenCADStudio { }; self.push_undo_snapshot(i, if prompt { "WMFIN" } else { "IMPORT" }); let scene = &mut self.tabs[i].scene; - // A text style per font, named WMF- after the first free number. + // A text style per face, `WMF-` with `n` counting the + // face's bold and italic variants; the style's font is the + // installed file of that face, none when it is not installed. A + // style the drawing already has is used as it is. let mut styles: Vec = Vec::new(); - for _ in &picture.fonts { - let mut n = 0; - let name = loop { - let name = format!("WMF-{n}"); - if !scene.document.text_styles.contains(&name) && !styles.contains(&name) { - break name; - } - n += 1; - }; - let mut style = codec::tables::TextStyle::new(name.as_str()); - style.handle = scene.document.allocate_handle(); - // No font of its own, as the metafile names the face only. - style.font_file = String::new(); - let _ = scene.document.text_styles.add(style); + for (k, face) in picture.fonts.iter().enumerate() { + let n = picture.fonts[..k].iter().filter(|f| f.name == face.name).count(); + let name = format!("WMF-{}{n}", face.name); + if !scene.document.text_styles.contains(&name) { + let mut style = codec::tables::TextStyle::new(name.as_str()); + style.handle = scene.document.allocate_handle(); + style.font_file = + crate::scene::text::sysfont::face_file(&face.name, face.bold, face.italic).unwrap_or_default(); + let _ = scene.document.text_styles.add(style); + } styles.push(name); } for (at, font) in &picture.text_fonts { diff --git a/src/io/wmf_import.rs b/src/io/wmf_import.rs index 352f79696..2c0721e6e 100644 --- a/src/io/wmf_import.rs +++ b/src/io/wmf_import.rs @@ -1,7 +1,9 @@ //! Windows metafile (WMF) reading: the records a metafile plays, as the //! drawing objects of a block — outlines as polylines in their pen's colour //! and width, filled shapes as closed polylines in their brush's colour, -//! ellipses as 99-segment polylines, text as single-line text. +//! ellipses as 99-segment polylines, arcs and pies on whole units, text as +//! single-line text placed and sized by its font's own ascent. Chords are +//! not read. //! //! The picture is scaled to fit half the view it is imported into, with its //! top-left corner at the block's origin and y running down the screen as @@ -11,13 +13,21 @@ use codec::entities::{Polyline2D, Text, Vertex2D}; use codec::types::{Color, Vector3}; use codec::EntityType; +/// A font face as the text uses it: its name, and whether bold and italic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Face { + pub name: String, + pub bold: bool, + pub italic: bool, +} + /// A picture: its objects, in block coordinates, and the font of each /// text (an index into `fonts`). pub struct Picture { pub entities: Vec, - /// The face name of each font the text uses; text styles are - /// `WMF-` for each, in this order. - pub fonts: Vec, + /// Each face the text uses, in the order first used; each becomes a + /// text style. + pub fonts: Vec, /// For each text in `entities` (by position), its font index. pub text_fonts: Vec<(usize, usize)>, } @@ -37,11 +47,62 @@ struct Brush { #[derive(Clone)] struct Font { + /// The logical height: negative for the character height, positive for + /// the cell height. height: f64, escapement: f64, + weight: i32, + italic: bool, face: String, } +/// A font's ascent, in metafile units, as the system lays the font out at +/// its logical height: the baseline sits this far below a text's reference +/// point. Without the system's font engine it is nine tenths of the height. +#[cfg(target_os = "windows")] +fn ascent(font: &Font) -> f64 { + use windows_sys::Win32::Graphics::Gdi::{ + CreateCompatibleDC, CreateFontIndirectW, DeleteDC, DeleteObject, GetTextMetricsW, + SelectObject, LOGFONTW, TEXTMETRICW, + }; + // SAFETY: a private memory device context and font, released before + // returning; the structures are plain data the calls fill. + unsafe { + let mut logfont: LOGFONTW = std::mem::zeroed(); + logfont.lfHeight = font.height as i32; + logfont.lfWeight = font.weight; + logfont.lfItalic = u8::from(font.italic); + for (slot, unit) in logfont + .lfFaceName + .iter_mut() + .zip(font.face.encode_utf16().take(31)) + { + *slot = unit; + } + let dc = CreateCompatibleDC(std::ptr::null_mut()); + if dc.is_null() { + return 0.9 * font.height.abs(); + } + let handle = CreateFontIndirectW(&logfont); + let previous = SelectObject(dc, handle); + let mut metrics: TEXTMETRICW = std::mem::zeroed(); + let ok = GetTextMetricsW(dc, &mut metrics) != 0; + SelectObject(dc, previous); + DeleteObject(handle); + DeleteDC(dc); + if ok { + f64::from(metrics.tmAscent) + } else { + 0.9 * font.height.abs() + } + } +} + +#[cfg(not(target_os = "windows"))] +fn ascent(font: &Font) -> f64 { + 0.9 * font.height.abs() +} + #[derive(Clone)] enum Object { Pen(Pen), @@ -70,7 +131,7 @@ struct Player { origin: [f64; 2], scale: [f64; 2], entities: Vec, - fonts: Vec, + fonts: Vec, text_fonts: Vec<(usize, usize)>, } @@ -129,12 +190,94 @@ fn ellipse_points(bounds: [f64; 4], start: f64, sweep: f64) -> Vec<[f64; 2]> { .collect() } -/// The angle of a radial point round an ellipse's centre, in drawing -/// orientation. -fn angle(bounds: [f64; 4], x: f64, y: f64) -> f64 { +/// Turns an arc is cut into: its points lie on whole multiples of this +/// share of a turn. +const ARC_STEPS: i64 = 99; + +/// The angle of a radial point round an ellipse, in degrees in `(0, 360]` +/// counter-clockwise in drawing orientation — or `None` for a point inside +/// the ellipse, which marks no angle. +fn radial_angle(bounds: [f64; 4], x: f64, y: f64) -> Option { let [left, top, right, bottom] = bounds; let (cx, cy) = (0.5 * (left + right), 0.5 * (top + bottom)); - (-(y - cy)).atan2(x - cx) + let (rx, ry) = (0.5 * (right - left).abs(), 0.5 * (bottom - top).abs()); + let (u, v) = ((x - cx) / rx, -(y - cy) / ry); + if u.hypot(v) < 1.0 { + return None; + } + let degrees = v.atan2(u).to_degrees().rem_euclid(360.0); + Some(if degrees == 0.0 { 360.0 } else { degrees }) +} + +/// An arc as the reference reader plays it: points round the ellipse's +/// whole-unit centre and radii at whole multiples of 1/99 turn, rounded +/// down to whole units, from the start angle up to the end angle — past +/// 360 degrees back from zero when the end comes first. A start inside the +/// ellipse starts at zero; an end inside it runs on to a whole turn; with +/// both inside there is no arc. +fn arc_points(bounds: [f64; 4], start: [f64; 2], end: [f64; 2]) -> Vec<[f64; 2]> { + let [left, top, right, bottom] = bounds.map(|v| v as i64); + let (cx, cy) = ((left + right).div_euclid(2), (top + bottom).div_euclid(2)); + let (rx, ry) = ((right - left).abs() / 2, (bottom - top).abs() / 2); + let (from, to) = (radial_angle(bounds, start[0], start[1]), radial_angle(bounds, end[0], end[1])); + let (start_angle, end_angle) = match (from, to) { + (None, None) => return Vec::new(), + (None, to) => (0.0, to), + (Some(from), to) => (from, to.filter(|to| *to != from)), + }; + // The angles are stepped out from zero one step at a time in degrees, + // rounding and all, as the points come out. + let mut degrees = vec![0.0f64; ARC_STEPS as usize + 1]; + for k in 1..degrees.len() { + degrees[k] = degrees[k - 1] + 360.0 / ARC_STEPS as f64; + } + let point = |k: i64| { + let a = degrees[k as usize] * (std::f64::consts::PI / 180.0); + [ + (cx as f64 + rx as f64 * a.cos()).floor(), + (cy as f64 + ry as f64 * -a.sin()).floor(), + ] + }; + // `k` steps lie below an angle when k·360 < angle·99, kept exact. + let under = |k: &i64, angle: f64| ((k * 360) as f64) < angle * ARC_STEPS as f64; + let first = (start_angle * ARC_STEPS as f64 / 360.0).ceil() as i64; + let mut steps: Vec = match end_angle { + // Up to the end. + Some(to) if to > start_angle => (first..).take_while(|k| under(k, to)).collect(), + // Round to a whole turn, then from zero up to the end. + Some(to) => (first..=ARC_STEPS).chain((0..).take_while(|k| under(k, to))).collect(), + // Round to a whole turn. + None => (first..=ARC_STEPS).collect(), + }; + // The whole-unit points next to the start and end rays are judged by + // which side of the ray they fall: a point on the start ray is left + // out, one on the end ray kept. + let (fx, fy) = (0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])); + let side = |ray: [f64; 2], k: i64| { + let p = point(k); + let (ux, uy) = (ray[0] - fx, -(ray[1] - fy)); + let (vx, vy) = (p[0] - fx, -(p[1] - fy)); + ux * vy - uy * vx + }; + if from.is_some() { + if let Some(&k) = steps.first() { + if k < ARC_STEPS && side(start, k) <= 0.0 { + steps.remove(0); + } else if k > 0 && k < ARC_STEPS && side(start, k - 1) > 0.0 { + steps.insert(0, k - 1); + } + } + } + if end_angle.is_some() { + if let Some(&k) = steps.last() { + if k > 0 && side(end, k) > 0.0 { + steps.pop(); + } else if k + 1 < ARC_STEPS && side(end, k + 1) <= 0.0 { + steps.push(k + 1); + } + } + } + steps.into_iter().map(point).collect() } /// Reads a metafile, scaled so the picture fits within `fit` (width and @@ -206,6 +349,8 @@ pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { let mut font = Font { height: 12.0, escapement: 0.0, + weight: 400, + italic: false, face: String::new(), }; let mut text_rgb = [0, 0, 0]; @@ -251,8 +396,10 @@ pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { create( &mut table, Object::Font(Font { - height: s(0).abs(), + height: s(0), escapement: s(2) / 10.0, + weight: s(4) as i32, + italic: p.get(5).is_some_and(|w| w & 0xff != 0), face: String::from_utf8_lossy(&face[..end]).into_owned(), }), ) @@ -321,29 +468,26 @@ pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { player.polyline(&ring, false, pen.width, pen.rgb); } } - // ARC, CHORD, PIE: (yEnd, xEnd, yStart, xStart, bottom, - // right, top, left), drawn counter-clockwise - 0x0817 | 0x0830 | 0x081A => { + // ARC, PIE: (yEnd, xEnd, yStart, xStart, bottom, right, top, + // left), drawn counter-clockwise; a pie's outline starts at the + // centre. (CHORD records are not read.) + 0x0817 | 0x081A => { let bounds = [s(7), s(6), s(5), s(4)]; - let start = angle(bounds, s(3), s(2)); - let end = angle(bounds, s(1), s(0)); - let mut sweep = end - start; - if sweep <= 0.0 { - sweep += std::f64::consts::TAU; + let arc = arc_points(bounds, [s(3), s(2)], [s(1), s(0)]); + if arc.is_empty() { + continue; } - let mut ring = ellipse_points(bounds, start, sweep); - match function { - 0x0817 => { - if !pen.null { - player.polyline(&ring, false, pen.width, pen.rgb); - } - } - _ => { - if *function == 0x081A { - ring.push([0.5 * (bounds[0] + bounds[2]), 0.5 * (bounds[1] + bounds[3])]); - } - player.shape(&ring, &pen, &brush); + if *function == 0x0817 { + if !pen.null { + player.polyline(&arc, false, pen.width, pen.rgb); } + } else { + let centre = [ + ((bounds[0] + bounds[2]) as i64).div_euclid(2) as f64, + ((bounds[1] + bounds[3]) as i64).div_euclid(2) as f64, + ]; + let ring: Vec<[f64; 2]> = std::iter::once(centre).chain(arc).collect(); + player.shape(&ring, &pen, &brush); } } // TEXTOUT (count, string, y, x) and EXTTEXTOUT (y, x, count, @@ -368,21 +512,27 @@ pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { continue; } // The reference point is the cell's top; the baseline sits - // nine tenths of the font height below it. + // the font's ascent below it, and the text is four fifths + // of that ascent tall. let rotation = font.escapement.to_radians(); - let drop = 0.9 * font.height; + let drop = ascent(&font); let base = player.at(x + drop * rotation.sin(), y + drop * rotation.cos()); - let index = match player.fonts.iter().position(|f| *f == font.face) { + let face = Face { + name: font.face.clone(), + bold: font.weight >= 600, + italic: font.italic, + }; + let index = match player.fonts.iter().position(|f| *f == face) { Some(k) => k, None => { - player.fonts.push(font.face.clone()); + player.fonts.push(face); player.fonts.len() - 1 } }; let mut text = Text::new(); text.value = value; text.insertion_point = base; - text.height = 0.72 * font.height * scale; + text.height = 0.8 * drop * scale; text.rotation = rotation; text.common.color = color(text_rgb); player.text_fonts.push((player.entities.len(), index)); diff --git a/src/scene/text/sysfont.rs b/src/scene/text/sysfont.rs index a74de6122..ae0b3ed96 100644 --- a/src/scene/text/sysfont.rs +++ b/src/scene/text/sysfont.rs @@ -292,6 +292,22 @@ fn face_id_weighted(family: &str, weight: fontdb::Weight) -> Option fonts().db.lock().unwrap().query(&query) } +/// The file name (lower case) of the installed face of exactly `family` — +/// no stand-in family — in the bold and italic asked for, or `None`. +pub fn face_file(family: &str, bold: bool, italic: bool) -> Option { + let db = fonts().db.lock().unwrap(); + let id = db.query(&fontdb::Query { + families: &[fontdb::Family::Name(family)], + weight: if bold { fontdb::Weight::BOLD } else { fontdb::Weight::NORMAL }, + style: if italic { fontdb::Style::Italic } else { fontdb::Style::Normal }, + ..Default::default() + })?; + match &db.face(id)?.source { + fontdb::Source::File(path) => Some(path.file_name()?.to_string_lossy().to_ascii_lowercase()), + _ => None, + } +} + /// Borrow the raw face bytes for `family` and run `f` over them. The byte slice /// is only valid inside the closure, so callers extract everything they need /// (e.g. flattened glyph outlines) before returning. `index` is the face index From 78e429732ee8b410766c6ec6373aa2dc4fbfeeb0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:12:16 +0300 Subject: [PATCH 6/9] IMPORT: 3D Studio files A .3ds file opens the 3D Studio Import window: the file's objects with their kind and material count, filtered by kind or name, checked one by one or all at once, where to put them on layers and what a mesh with several materials becomes. Checked meshes come in as polyface meshes on the chosen layers with materials made from the file's colours, cameras as named views; a mesh with several materials is split, given its first material, given none, or asked about on the command line. Automation drives the window with tds_import_dialog. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 35 +++ locales/bg-BG/opencadstudio.ftl | 35 +++ locales/cs-CZ/opencadstudio.ftl | 35 +++ locales/de-DE/opencadstudio.ftl | 35 +++ locales/el-GR/opencadstudio.ftl | 35 +++ locales/en-US/opencadstudio.ftl | 35 +++ locales/es-ES/opencadstudio.ftl | 35 +++ locales/fi-FI/opencadstudio.ftl | 35 +++ locales/fr-FR/opencadstudio.ftl | 35 +++ locales/hi-IN/opencadstudio.ftl | 35 +++ locales/hu-HU/opencadstudio.ftl | 35 +++ locales/it-IT/opencadstudio.ftl | 35 +++ locales/ja-JP/opencadstudio.ftl | 35 +++ locales/ko-KR/opencadstudio.ftl | 35 +++ locales/lo-LA/opencadstudio.ftl | 35 +++ locales/nl-NL/opencadstudio.ftl | 35 +++ locales/pl-PL/opencadstudio.ftl | 35 +++ locales/pt-BR/opencadstudio.ftl | 35 +++ locales/ru-RU/opencadstudio.ftl | 35 +++ locales/tr-TR/opencadstudio.ftl | 35 +++ locales/zh-CN/opencadstudio.ftl | 35 +++ locales/zh-TW/opencadstudio.ftl | 35 +++ src/app/command_driver/mod.rs | 5 + src/app/commands/dgn_import.rs | 74 ++++++ src/app/commands/model_import.rs | 214 ++++++++++++++++ src/app/control/actions.rs | 40 +++ src/app/mod.rs | 2 + src/app/update/mod.rs | 1 + src/app/view/modal.rs | 7 + src/command.rs | 2 + src/io/mod.rs | 1 + src/io/tds_import.rs | 403 +++++++++++++++++++++++++++++++ src/modules/insert/mod.rs | 1 + src/modules/insert/tds_import.rs | 132 ++++++++++ src/ui/window/import_dialogs.rs | 245 ++++++++++++++++++- 35 files changed, 1893 insertions(+), 4 deletions(-) create mode 100644 src/io/tds_import.rs create mode 100644 src/modules/insert/tds_import.rs diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 6b4c40be8..3294a3489 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6687,6 +6687,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = استيراد DGN .recognize-shx = التعرف على نص SHX diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index e1ad8f608..fc1bd29f7 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Импортиране на DGN .recognize-shx = Разпознаване на SHX текст diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index ff53f069c..7a772cbe5 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6672,6 +6672,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Rozpoznat text SHX diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 16b715346..a02d69b58 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6673,6 +6673,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-Import .recognize-shx = SHX-Text erkennen diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 5d5f8c629..d628d3b69 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Εισαγωγή DGN .recognize-shx = Αναγνώριση κειμένου SHX diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 17f8de919..b58b35734 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6674,6 +6674,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN Import .recognize-shx = Recognize SHX Text diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index e69ec637c..1dbe7a9d2 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importar DGN .recognize-shx = Reconocer texto SHX diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 81fbf0d88..f9c4ac4ba 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6672,6 +6672,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-tuonti .recognize-shx = Tunnista SHX-teksti diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 94f1e1edf..af28e9df6 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Reconnaître le texte SHX diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e2a8dc08a..2e8616557 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6663,6 +6663,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN आयात .recognize-shx = SHX टेक्स्ट पहचानें diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 338e7e40b..54b3e44a7 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6659,6 +6659,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN-importálás .recognize-shx = SHX-szöveg felismerése diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index a155c96da..7d8f063bd 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6674,6 +6674,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importa DGN .recognize-shx = Riconosci testo SHX diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 3f4523e95..169061f1e 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6663,6 +6663,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 読み込み .recognize-shx = SHX 文字を認識 diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index e9d631f68..880d58f4d 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6674,6 +6674,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 가져오기 .recognize-shx = SHX 문자 인식 diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 92077d5ca..884e0e6d0 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = ນໍາເຂົ້າ DGN .recognize-shx = ຮັບຮູ້ຂໍ້ຄວາມ SHX diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 1e2a722b0..107f0ad66 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6673,6 +6673,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN importeren .recognize-shx = SHX-tekst herkennen diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 31eb9edcb..d0826c8d3 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6674,6 +6674,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Import DGN .recognize-shx = Rozpoznaj tekst SHX diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 437330dcc..ac6f48463 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6675,6 +6675,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Importar DGN .recognize-shx = Reconhecer texto SHX diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 9af234590..ba7834740 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6676,6 +6676,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = Импорт DGN .recognize-shx = Распознать текст SHX diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 8b391abf8..d38b23fcb 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6596,6 +6596,41 @@ dgn-import = .help-import = Seçilen biçimdeki dosyayı o biçimin içe aktarıcısıyla içe aktarır. .blank = Dosya adı boş olamaz .invalid-dgn = Geçersiz veya desteklenmeyen DGN dosyası. +import-3ds = + .title = 3D Studio İçe Aktar + .summary = { $objects } nesne, { $materials } malzeme + .change = Değiştir… + .mesh = Ağ + .light = Işık + .camera = Kamera + .general = Genel + .global = Genel Ayarlar + .all = Tümü + .name = Ad + .type = Tür + .materials = { $n } malzeme + .objects = Nesneler + .search = Ada göre ara... + .select-all = Tümünü seç + .clear = Temizle + .layers = Katmana yerleştir + .by-object = Nesneye göre + .by-material = Malzemeye göre + .by-color = Nesne rengine göre + .single = Tek katman + .note-object = Her nesne kendi adını taşıyan katmana yerleşir. + .note-material = Her nesne malzemesinin adını taşıyan katmana yerleşir. + .note-color = Her nesne renginin adını taşıyan katmana yerleşir. + .note-single = Tüm nesneler tek bir katmana yerleşir. + .multi = Birden çok malzemeli nesne + .ask = Her seferinde sor + .split = Malzemeye göre böl + .first = İlk malzemeyi ata + .none = Malzeme atama + .reason = En az bir nesne işaretleyin + .cancel = İptal + .import = İçe Aktar + .help = İşaretli nesneleri içe aktarır: ağlar malzemeleriyle çokyüzlü ağ, kameralar adlandırılmış görünüm olur. import-panel = .dgn-import = DGN İçe Aktar .recognize-shx = SHX Metnini Tanı diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 83c3d74e8..2461ce671 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6658,6 +6658,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 输入 .recognize-shx = 识别 SHX 文字 diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index e472e57cf..f38b013f7 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6670,6 +6670,41 @@ dgn-import = .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank .invalid-dgn = Invalid or unsupported DGN file. +import-3ds = + .title = 3D Studio Import + .summary = { $objects } objects, { $materials } materials + .change = Change... + .mesh = Mesh + .light = Light + .camera = Camera + .general = General + .global = Global Settings + .all = All + .name = Name + .type = Type + .materials = { $n } materials + .objects = Objects + .search = Search by name... + .select-all = Select all + .clear = Clear + .layers = Place on layers + .by-object = By object + .by-material = By material + .by-color = By object color + .single = Single layer + .note-object = Each object goes on a layer named after it. + .note-material = Each object goes on a layer named after its material. + .note-color = Each object goes on a layer named after its color. + .note-single = Every object goes on one layer. + .multi = Objects with several materials + .ask = Ask each time + .split = Split by material + .first = Assign the first material + .none = Don't assign a material + .reason = Check at least one object + .cancel = Cancel + .import = Import + .help = Imports the checked objects of a 3D Studio file: meshes as polyface meshes with their materials, cameras as named views. import-panel = .dgn-import = DGN 匯入 .recognize-shx = 辨識 SHX 文字 diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index a920cbee3..7d5133b36 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -654,6 +654,11 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; return self.run_dgn_import(i, request); } + CmdResult::TdsImport(request) => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.run_tds_import(i, request); + } CmdResult::PdfImportFile(import) => { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 1a220cd90..607b6899e 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -65,6 +65,7 @@ enum Route { Dgn, Model(ModelFormat), Wmf, + Tds, } /// The importer for `path` chosen as `format` (an index of [`FORMATS`]; `None` @@ -91,6 +92,7 @@ fn route(path: &str, format: Option) -> Result { Some("STEP") => return Ok(Route::Model(ModelFormat::Step)), Some("IGES") => return Ok(Route::Model(ModelFormat::Iges)), Some("Metafile") => return Ok(Route::Wmf), + Some("3D Studio") => return Ok(Route::Tds), _ => {} } let pdf = if by_extension { ext == "pdf" } else { f.is_some_and(|f| f.name == "PDF Files") }; @@ -245,6 +247,14 @@ impl OpenCADStudio { let i = self.active_tab; self.import_wmf(i, path, false) } + // The options window opens whatever FILEDIA is. + Ok(Route::Tds) => { + self.remember_import(path); + if let Err(error) = self.open_tds_import_dialog(path) { + self.command_line.push_error(&error); + } + Task::none() + } Err(error) => { self.command_line.push_error(&error); Task::none() @@ -260,6 +270,7 @@ impl OpenCADStudio { "dgn" => crate::t!( "Imports a DGN model as drawing objects: levels as layers, cells as blocks." ), + "3ds" => std::borrow::Cow::Owned(crate::tr!("import-3ds", "help")), _ => crate::t!( "Imports a file of the chosen format with the format's importer." ), @@ -362,8 +373,71 @@ impl OpenCADStudio { self.close_active_modal(); return self.import_wmf(i, &path, false); } + Ok(Route::Tds) => { + self.remember_import(&path); + self.close_active_modal(); + if let Err(error) = self.open_tds_import_dialog(&path) { + self.command_line.push_error(&error); + } + } } } + // ── 3D Studio Import ───────────────────────────────────────── + ImportDialogMsg::TdsChange => { + let title = crate::t!("Import File").into_owned(); + let filters = vec![("3D Studio (*.3ds)".to_string(), vec!["3ds".to_string()])]; + return Task::perform(pick(title, filters), |path| { + Message::ImportDialog(ImportDialogMsg::TdsPicked(path)) + }); + } + ImportDialogMsg::TdsPicked(Some(path)) => { + if let Err(error) = self.open_tds_import_dialog(&path) { + self.command_line.push_error(&error); + } + } + ImportDialogMsg::TdsPicked(None) => {} + ImportDialogMsg::TdsCheck(k, on) => { + if let Some(c) = self.import_dialogs.tds.as_mut().and_then(|s| s.checked.get_mut(k)) { + *c = on; + } + } + ImportDialogMsg::TdsAll | ImportDialogMsg::TdsClear => { + let on = matches!(message, ImportDialogMsg::TdsAll); + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.checked.iter_mut().for_each(|c| *c = on); + } + } + ImportDialogMsg::TdsFilter(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.filter = k.min(4); + } + } + ImportDialogMsg::TdsSearch(text) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.search = text; + } + } + ImportDialogMsg::TdsLayers(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.layers = k.min(3); + } + } + ImportDialogMsg::TdsMulti(k) => { + if let Some(s) = self.import_dialogs.tds.as_mut() { + s.multi = k.min(3); + } + } + ImportDialogMsg::TdsOk => { + let Some(state) = self.import_dialogs.tds.take() else { + return Task::none(); + }; + if !state.checked.iter().any(|c| *c) { + self.import_dialogs.tds = Some(state); + return Task::none(); + } + self.close_active_modal(); + return self.start_tds_import(i, state); + } // ── DGN Import ─────────────────────────────────────────────── ImportDialogMsg::DgnChange => return self.pick_dgn_file(), ImportDialogMsg::DgnPicked(Some(path)) => { diff --git a/src/app/commands/model_import.rs b/src/app/commands/model_import.rs index f5a23b5c9..d66565b7c 100644 --- a/src/app/commands/model_import.rs +++ b/src/app/commands/model_import.rs @@ -203,3 +203,217 @@ impl OpenCADStudio { Task::none() } } + +impl OpenCADStudio { + /// The 3D Studio Import window for a file; an error when it is no 3D + /// Studio file. + pub(in crate::app) fn open_tds_import_dialog(&mut self, path: &str) -> Result<(), String> { + let scene = crate::scene::model::pdf_raster::source_bytes(path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + .ok_or_else(|| format!("{} is not a valid 3D Studio file.", path.replace('/', "\\")))?; + let mut objects: Vec<(String, &'static str, usize)> = scene + .objects + .iter() + .map(|(name, object)| { + let materials = match object { + crate::io::tds_import::TdsObject::Mesh(mesh) => scene.mesh_materials(mesh).len(), + _ => 0, + }; + (name.clone(), object.kind(), materials) + }) + .collect(); + objects.sort_by_key(|o| o.0.to_lowercase()); + objects.insert(0, ("Global Settings".to_string(), "Global", 0)); + // The layer and material choices made last stay; nothing starts + // checked. + let previous = self.import_dialogs.tds.take(); + self.import_dialogs.tds = Some(crate::ui::window::import_dialogs::TdsImportState { + path: path.to_string(), + checked: vec![false; objects.len()], + objects, + materials: scene.materials.len(), + filter: 0, + search: String::new(), + layers: previous.as_ref().map_or(0, |s| s.layers), + multi: previous.as_ref().map_or(0, |s| s.multi), + }); + self.active_modal = Some(crate::app::ModalKind::TdsImport); + Ok(()) + } + + /// The window's choices as an import: asked mesh by mesh first when set + /// to ask and some checked mesh has several materials. + pub(in crate::app) fn start_tds_import( + &mut self, + i: usize, + state: crate::ui::window::import_dialogs::TdsImportState, + ) -> Task { + use crate::io::tds_import::{LayerMode, MultiMaterial, TdsObject}; + use crate::modules::insert::tds_import::{TdsImportRequest, TdsMaterialCommand}; + let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&state.path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + else { + return Task::none(); + }; + let selected: Vec = state + .objects + .iter() + .zip(&state.checked) + .filter(|(_, on)| **on) + .map(|((name, _, _), _)| name.clone()) + .collect(); + let several: Vec<(String, Vec)> = scene + .objects + .iter() + .filter(|(name, _)| selected.contains(name)) + .filter_map(|(name, object)| match object { + TdsObject::Mesh(mesh) => { + let materials = scene.mesh_materials(mesh); + (materials.len() > 1).then(|| (name.clone(), materials)) + } + _ => None, + }) + .collect(); + let choice = match state.multi { + 1 => Some(MultiMaterial::Split), + 2 => Some(MultiMaterial::First), + 3 => Some(MultiMaterial::Material(None)), + _ => None, + }; + let mut request = TdsImportRequest { + path: state.path.clone(), + selected, + layers: match state.layers { + 1 => LayerMode::ByMaterial, + 2 => LayerMode::ByObjectColor, + 3 => LayerMode::Single, + _ => LayerMode::ByObject, + }, + multi: Default::default(), + compact: matches!(state.multi, 0 | 1), + }; + match choice { + Some(choice) => { + for (name, _) in several { + request.multi.insert(name, choice.clone()); + } + } + None if !several.is_empty() => { + use crate::command::CadCommand; + let command = TdsMaterialCommand::new(request, several); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } + None => {} + } + self.run_tds_import(i, request); + Task::none() + } + + /// The import itself, one undo step: the meshes on their layers with + /// their materials, the cameras as named views. + pub(in crate::app) fn run_tds_import( + &mut self, + i: usize, + request: crate::modules::insert::tds_import::TdsImportRequest, + ) { + use codec::objects::ObjectType; + let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&request.path) + .and_then(|bytes| crate::io::tds_import::read(&bytes)) + else { + self.command_line.push_error(&format!( + "{} is not a valid 3D Studio file.", + request.path.replace('/', "\\") + )); + return; + }; + let import = crate::io::tds_import::convert( + &scene, + &crate::io::tds_import::TdsOptions { + selected: request.selected.clone(), + layers: request.layers, + multi: request.multi.clone(), + compact: request.compact, + }, + ); + self.push_undo_snapshot(i, "IMPORT"); + let document = &mut self.tabs[i].scene.document; + // Materials by name: the drawing's own when it has one, else one + // made from the file's colours. + let mut materials: std::collections::HashMap = Default::default(); + for name in &import.materials { + let existing = document.objects.iter().find_map(|(h, o)| match o { + ObjectType::Material(m) if m.name.eq_ignore_ascii_case(name) => Some(*h), + _ => None, + }); + let handle = match existing { + Some(h) => h, + None => { + let source = scene.materials.iter().find(|m| m.name == *name); + let mut material = codec::objects::Material::new(); + material.handle = document.allocate_handle(); + material.owner = document.header.acad_material_dict_handle; + material.name = name.clone(); + let color = |rgb: Option<[u8; 3]>| match rgb { + Some([r, g, b]) => codec::objects::MaterialColor { + flag: 1, + factor: 1.0, + rgb: Some( + ((0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) + as i32, + ), + }, + None => codec::objects::MaterialColor::default(), + }; + material.ambient_color = color(source.and_then(|m| m.ambient)); + material.diffuse_color = color(source.and_then(|m| m.diffuse)); + material.specular_color = color(source.and_then(|m| m.specular)); + let handle = material.handle; + let dictionary = document.header.acad_material_dict_handle; + if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) { + dictionary.add_entry(name.clone(), handle); + } + document.objects.insert(handle, ObjectType::Material(material)); + handle + } + }; + materials.insert(name.clone(), handle); + } + for part in &import.parts { + if !document.layers.contains(&part.layer) { + let mut layer = codec::tables::Layer::new(part.layer.as_str()); + layer.handle = document.allocate_handle(); + let _ = document.layers.add(layer); + } + } + for view in &import.views { + let mut named = codec::tables::View::new(view.name.as_str()); + named.target = codec::types::Vector3::new(view.target[0], view.target[1], view.target[2]); + named.direction = + codec::types::Vector3::new(view.direction[0], view.direction[1], view.direction[2]); + named.height = view.height; + named.width = view.height; + named.lens_length = view.lens; + named.handle = match document.views.get(&view.name) { + Some(old) => old.handle, + None => document.allocate_handle(), + }; + document.views.add_or_replace(named); + } + for part in import.parts { + let mut mesh = part.mesh; + mesh.common.layer = part.layer.clone(); + mesh.common.material_handle = part.material.as_ref().and_then(|m| materials.get(m).copied()); + if mesh.common.material_handle.is_some() { + mesh.common.material_flags = 0b11; + } + self.tabs[i].scene.add_entity(codec::EntityType::PolyfaceMesh(mesh)); + } + for line in &import.messages { + self.command_line.push_output(line); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } +} diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 6b4f0b49c..45af4f5b4 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -110,6 +110,7 @@ pub(super) const NAMES: &[&str] = &[ "image_adjust", "import_file_dialog", "dgn_import_dialog", + "tds_import_dialog", "refedit_dialog", "pdf_shx_settings", "pdf_layer_toggle", @@ -2198,6 +2199,45 @@ impl OpenCADStudio { _ => return Err(bad()), }) } + // The 3D Studio Import window, one field per call: "check=" + // and "uncheck=" (the object's name, or "Global Settings"), + // "all", "clear", "filter=all|mesh|light|camera|general", + // "search=", "layers=object|material|color|single", + // "multi=ask|split|first|none", "path=" (as Change… picks + // it), "ok" or "cancel". + "tds_import_dialog" => { + use crate::ui::window::import_dialogs::ImportDialogMsg as I; + if self.active_modal != Some(crate::app::ModalKind::TdsImport) { + return Err(failure("no_dialog", "The 3D Studio Import window is not open")); + } + let spec = string(req, "value")?; + let (key, value) = spec.split_once('=').unwrap_or((spec, "")); + let bad = || failure("invalid_value", spec); + if key == "cancel" { + return Ok(Task::done(Message::CloseModal)); + } + let pick = |options: &[&str]| options.iter().position(|o| *o == value).ok_or_else(bad); + Message::ImportDialog(match key { + "ok" => I::TdsOk, + "all" => I::TdsAll, + "clear" => I::TdsClear, + "check" | "uncheck" => { + let k = self + .import_dialogs + .tds + .as_ref() + .and_then(|s| s.objects.iter().position(|(name, _, _)| name.eq_ignore_ascii_case(value))) + .ok_or_else(bad)?; + I::TdsCheck(k, key == "check") + } + "filter" => I::TdsFilter(pick(&["all", "mesh", "light", "camera", "general"])?), + "search" => I::TdsSearch(value.to_string()), + "layers" => I::TdsLayers(pick(&["object", "material", "color", "single"])?), + "multi" => I::TdsMulti(pick(&["ask", "split", "first", "none"])?), + "path" => I::TdsPicked(Some(value.to_string())), + _ => return Err(bad()), + }) + } // The DGN Import window, one field per call: "path=" (as // Change… picks it), "model=", "target=new|current", // "conflict=prefix|ignore", "units=master|sub", "explode=on|off", diff --git a/src/app/mod.rs b/src/app/mod.rs index c58598e43..e23efa6d4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2335,6 +2335,8 @@ pub enum ModalKind { ImportFile, /// DGNIMPORT's DGN Import window. DgnImport, + /// IMPORT's 3D Studio Import window. + TdsImport, /// Web: a crash-recovery autosave snapshot newer than the drawing's last /// save awaits a restore/discard decision. Distinct from the corrupt-file /// `RecoveryPrompt` — nothing is wrong with the drawing itself. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 164e8ad17..d77fb772a 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -254,6 +254,7 @@ impl OpenCADStudio { Some(PdfImportFile) => self.pdf_import_file = None, Some(ImportFile) => self.import_dialogs.file = None, Some(DgnImport) => self.import_dialogs.dgn = None, + Some(TdsImport) => self.import_dialogs.tds = None, Some(XrefAttach) => self.xref_attach = None, Some(WriteBlock) => self.wblock = None, Some(Hyperlink) => { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6bb78ae38..6d1d2f362 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -51,6 +51,7 @@ impl OpenCADStudio { Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(), Some(K::ImportFile) => crate::t!("Import File").into_owned(), Some(K::DgnImport) => crate::t!("DGN Import").into_owned(), + Some(K::TdsImport) => crate::tr!("import-3ds", "title"), Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(), Some(K::WriteBlock) => crate::t!("Write Block").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), @@ -613,6 +614,12 @@ impl OpenCADStudio { crate::ui::window::import_dialogs::view_import_file(state, recent, flow) }) } + super::super::ModalKind::TdsImport => { + let state = self.import_dialogs.tds.as_ref()?; + sized_flow(ex, 860, 560, |flow| { + crate::ui::window::import_dialogs::view_tds_import(state, flow) + }) + } super::super::ModalKind::DgnImport => { let state = self.import_dialogs.dgn.as_ref()?; sized_flow(ex, 900, 680, |flow| { diff --git a/src/command.rs b/src/command.rs index 602870f9b..78778f14f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -2305,6 +2305,8 @@ pub enum CmdResult { ReturnPoint(DVec3), /// Import a DGN model (DGNIMPORT); ends the command. DgnImport(crate::modules::insert::dgn_import::DgnImportRequest), + /// Import a 3D Studio file with every question answered; ends the command. + TdsImport(crate::modules::insert::tds_import::TdsImportRequest), } /// What kind of value the active command is currently asking for. Drives diff --git a/src/io/mod.rs b/src/io/mod.rs index 64bfebb52..660ece8fb 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -33,6 +33,7 @@ pub mod step; pub mod step_import; pub mod stl; pub mod svg_export; +pub mod tds_import; pub mod thumbnail; pub mod update_check; pub mod visual_styles; diff --git a/src/io/tds_import.rs b/src/io/tds_import.rs new file mode 100644 index 000000000..46f4c2e27 --- /dev/null +++ b/src/io/tds_import.rs @@ -0,0 +1,403 @@ +//! 3D Studio (.3ds) reading: the materials, meshes, lights and cameras of a +//! file's editor section, and their conversion to drawing objects — each +//! mesh a polyface mesh on a layer chosen by object, material, object colour +//! or a single layer, with its material; each camera a named view. + +use std::collections::HashMap; + +use codec::entities::{PolyfaceFace, PolyfaceMesh, PolyfaceVertex}; +use codec::types::Vector3; + +pub struct TdsMaterial { + pub name: String, + pub ambient: Option<[u8; 3]>, + pub diffuse: Option<[u8; 3]>, + pub specular: Option<[u8; 3]>, +} + +pub struct TdsMesh { + pub vertices: Vec<[f64; 3]>, + pub faces: Vec<[usize; 3]>, + /// Each material the mesh uses and the faces it covers, in file order. + pub groups: Vec<(String, Vec)>, + /// The object colour, a palette index, when the file gives one. + pub color: Option, +} + +pub struct TdsCamera { + pub position: [f64; 3], + pub target: [f64; 3], + pub lens: f64, +} + +pub enum TdsObject { + Mesh(TdsMesh), + Light, + Camera(TdsCamera), +} + +impl TdsObject { + /// The kind as the import window lists it. + pub fn kind(&self) -> &'static str { + match self { + TdsObject::Mesh(_) => "Mesh", + TdsObject::Light => "Light", + TdsObject::Camera(_) => "Camera", + } + } +} + +pub struct TdsScene { + pub materials: Vec, + pub objects: Vec<(String, TdsObject)>, +} + +impl TdsScene { + /// The materials a mesh uses, in the order the file defines them. + pub fn mesh_materials(&self, mesh: &TdsMesh) -> Vec { + let mut used: Vec = Vec::new(); + for m in &self.materials { + if mesh.groups.iter().any(|(name, faces)| !faces.is_empty() && *name == m.name) { + used.push(m.name.clone()); + } + } + for (name, faces) in &mesh.groups { + if !faces.is_empty() && !used.contains(name) { + used.push(name.clone()); + } + } + used + } +} + +struct Reader<'a> { + b: &'a [u8], +} + +impl Reader<'_> { + fn u16(&self, at: usize) -> Option { + Some(u16::from_le_bytes([*self.b.get(at)?, *self.b.get(at + 1)?])) + } + fn u32(&self, at: usize) -> Option { + Some(u32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?)) + } + fn f32(&self, at: usize) -> Option { + Some(f64::from(f32::from_le_bytes(self.b.get(at..at + 4)?.try_into().ok()?))) + } + fn point(&self, at: usize) -> Option<[f64; 3]> { + Some([self.f32(at)?, self.f32(at + 4)?, self.f32(at + 8)?]) + } + /// A zero-terminated string and the offset after it. + fn cstr(&self, at: usize, end: usize) -> (String, usize) { + let stop = self.b[at.min(end)..end] + .iter() + .position(|c| *c == 0) + .map_or(end, |k| at + k); + ( + String::from_utf8_lossy(&self.b[at.min(stop)..stop]).into_owned(), + (stop + 1).min(end), + ) + } + /// The chunks between `from` and `to`: id, body start, body end. + fn chunks(&self, from: usize, to: usize) -> Vec<(u16, usize, usize)> { + let mut out = Vec::new(); + let mut at = from; + while at + 6 <= to { + let (Some(id), Some(len)) = (self.u16(at), self.u32(at + 2)) else { break }; + let len = len as usize; + if len < 6 || at + len > to { + break; + } + out.push((id, at + 6, at + len)); + at += len; + } + out + } + /// A colour chunk's 8-bit RGB: 24-bit or float, plain before linear. + fn color(&self, from: usize, to: usize) -> Option<[u8; 3]> { + let chunks = self.chunks(from, to); + for want in [0x0011, 0x0010, 0x0012, 0x0013] { + for &(id, start, _) in &chunks { + if id != want { + continue; + } + return Some(match id { + 0x0011 | 0x0012 => [*self.b.get(start)?, *self.b.get(start + 1)?, *self.b.get(start + 2)?], + _ => self.point(start)?.map(|c| (c.clamp(0.0, 1.0) * 255.0).round() as u8), + }); + } + } + None + } +} + +/// `None` when the bytes are no 3D Studio file. +pub fn read(bytes: &[u8]) -> Option { + let r = Reader { b: bytes }; + if r.u16(0)? != 0x4D4D { + return None; + } + let end = (r.u32(2)? as usize).min(bytes.len()); + let mut scene = TdsScene { + materials: Vec::new(), + objects: Vec::new(), + }; + for (id, start, stop) in r.chunks(6, end) { + if id != 0x3D3D { + continue; + } + for (id, start, stop) in r.chunks(start, stop) { + match id { + 0xAFFF => { + let mut material = TdsMaterial { + name: String::new(), + ambient: None, + diffuse: None, + specular: None, + }; + for (id, s, e) in r.chunks(start, stop) { + match id { + 0xA000 => material.name = r.cstr(s, e).0, + 0xA010 => material.ambient = r.color(s, e), + 0xA020 => material.diffuse = r.color(s, e), + 0xA030 => material.specular = r.color(s, e), + _ => {} + } + } + scene.materials.push(material); + } + 0x4000 => { + let (name, body) = r.cstr(start, stop); + for (id, s, e) in r.chunks(body, stop) { + let object = match id { + 0x4100 => TdsObject::Mesh(read_mesh(&r, s, e)), + 0x4600 => TdsObject::Light, + 0x4700 => TdsObject::Camera(TdsCamera { + position: r.point(s)?, + target: r.point(s + 12)?, + lens: r.f32(s + 28).filter(|l| *l > 0.0).unwrap_or(50.0), + }), + _ => continue, + }; + scene.objects.push((name.clone(), object)); + break; + } + } + _ => {} + } + } + } + Some(scene) +} + +fn read_mesh(r: &Reader<'_>, from: usize, to: usize) -> TdsMesh { + let mut mesh = TdsMesh { + vertices: Vec::new(), + faces: Vec::new(), + groups: Vec::new(), + color: None, + }; + for (id, s, e) in r.chunks(from, to) { + match id { + 0x4110 => { + let n = r.u16(s).unwrap_or(0) as usize; + mesh.vertices = (0..n).filter_map(|k| r.point(s + 2 + 12 * k)).collect(); + } + 0x4120 => { + let n = r.u16(s).unwrap_or(0) as usize; + mesh.faces = (0..n) + .filter_map(|k| { + let at = s + 2 + 8 * k; + Some([r.u16(at)? as usize, r.u16(at + 2)? as usize, r.u16(at + 4)? as usize]) + }) + .collect(); + for (id, gs, ge) in r.chunks(s + 2 + 8 * n, e) { + if id == 0x4130 { + let (name, at) = r.cstr(gs, ge); + let count = r.u16(at).unwrap_or(0) as usize; + let faces = (0..count).filter_map(|k| r.u16(at + 2 + 2 * k).map(usize::from)).collect(); + mesh.groups.push((name, faces)); + } + } + } + 0x4165 => mesh.color = r.b.get(s).copied(), + _ => {} + } + } + mesh +} + +/// Where imported objects go. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum LayerMode { + #[default] + ByObject, + ByMaterial, + ByObjectColor, + Single, +} + +/// What a mesh with several materials becomes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MultiMaterial { + /// One mesh per material. + Split, + /// One mesh with the first material the file defines. + First, + /// One mesh with this material, or none. + Material(Option), +} + +pub struct TdsOptions { + /// The objects to import, by name ("Global Settings" included). + pub selected: Vec, + pub layers: LayerMode, + /// The choice for each mesh with several materials, by name. + pub multi: HashMap, + /// Whether meshes keep only the vertices their faces use, in the order + /// they first use them — as they do when the import splits by + /// material or asks — rather than the file's vertex list. + pub compact: bool, +} + +/// One imported mesh. +pub struct TdsPart { + pub layer: String, + pub material: Option, + pub mesh: PolyfaceMesh, +} + +pub struct TdsCameraView { + pub name: String, + pub target: [f64; 3], + pub direction: [f64; 3], + /// The view height at the target, from the lens. + pub height: f64, + pub lens: f64, +} + +pub struct TdsImport { + pub parts: Vec, + /// The materials the parts use, in the order they were first used. + pub materials: Vec, + pub views: Vec, + pub messages: Vec, +} + +/// A polyface mesh of `faces` of `mesh`: its vertices in file order, or +/// (`compact`) only those the faces use, in the order they first use them. +fn polyface(mesh: &TdsMesh, faces: &[usize], compact: bool) -> PolyfaceMesh { + let mut out = PolyfaceMesh::new(); + let mut index: HashMap = HashMap::new(); + if !compact { + for (k, v) in mesh.vertices.iter().enumerate() { + index.insert(k, out.add_vertex(PolyfaceVertex::new(Vector3::new(v[0], v[1], v[2])))); + } + } + for &f in faces { + let Some(face) = mesh.faces.get(f) else { continue }; + if face.iter().any(|v| *v >= mesh.vertices.len()) { + continue; + } + let ids = face.map(|v| { + *index.entry(v).or_insert_with(|| { + let p = mesh.vertices[v]; + out.add_vertex(PolyfaceVertex::new(Vector3::new(p[0], p[1], p[2]))) + }) + }); + out.add_face(PolyfaceFace::triangle(ids[0], ids[1], ids[2])); + } + out +} + +/// The view height a camera's lens sees at the target distance. +// ponytail: one measured camera (50 mm at 86.46 units gives 30.457); the +// height is taken as proportional to distance over lens. +fn view_height(distance: f64, lens: f64) -> f64 { + distance * 17.6141 / lens +} + +pub fn convert(scene: &TdsScene, options: &TdsOptions) -> TdsImport { + let mut out = TdsImport { + parts: Vec::new(), + materials: Vec::new(), + views: Vec::new(), + messages: Vec::new(), + }; + let chosen = |name: &str| options.selected.iter().any(|s| s == name); + // The objects are taken in the order the window lists them, by name. + let mut objects: Vec<&(String, TdsObject)> = scene.objects.iter().collect(); + objects.sort_by_key(|(name, _)| name.to_lowercase()); + for (name, object) in objects { + if !chosen(name) { + continue; + } + match object { + TdsObject::Mesh(mesh) => { + out.messages.push(format!("Processing object {name}")); + let materials = scene.mesh_materials(mesh); + let all: Vec = (0..mesh.faces.len()).collect(); + let pieces: Vec<(Option, Vec)> = if materials.len() > 1 { + match options.multi.get(name).cloned().unwrap_or(MultiMaterial::Split) { + MultiMaterial::Split => materials + .iter() + .map(|m| { + let faces = mesh + .groups + .iter() + .filter(|(g, _)| g == m) + .flat_map(|(_, f)| f.iter().copied()) + .collect(); + (Some(m.clone()), faces) + }) + .collect(), + MultiMaterial::First => vec![(materials.first().cloned(), all)], + MultiMaterial::Material(m) => vec![(m, all)], + } + } else { + vec![(materials.first().cloned(), all)] + }; + let compact = options.compact || pieces.len() > 1; + for (material, faces) in pieces { + if let Some(m) = &material { + if !out.materials.contains(m) { + out.messages.push(format!("Converting material {m}")); + out.materials.push(m.clone()); + } + } + let layer = match options.layers { + LayerMode::ByObject => name.to_uppercase(), + LayerMode::ByMaterial => material.as_deref().unwrap_or("0").to_uppercase(), + LayerMode::ByObjectColor => match mesh.color { + Some(c) => format!("COLOR{c:02}"), + None => "COLORNONE".to_string(), + }, + LayerMode::Single => "AVLAYER".to_string(), + }; + out.parts.push(TdsPart { + layer, + material, + mesh: polyface(mesh, &faces, compact), + }); + } + } + TdsObject::Camera(camera) => { + let name = name.to_uppercase(); + out.messages.push(format!("Creating camera {name}")); + let direction = [0, 1, 2].map(|k| camera.position[k] - camera.target[k]); + let distance = direction.iter().map(|d| d * d).sum::().sqrt(); + out.views.push(TdsCameraView { + name, + target: camera.target, + direction, + height: view_height(distance, camera.lens), + lens: camera.lens, + }); + } + TdsObject::Light => {} + } + } + out.messages.push("3D Studio file import completed".to_string()); + out +} + diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 11385add7..8946a6a18 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -9,6 +9,7 @@ mod content_browser; pub(crate) mod create_block; mod design_center; pub(crate) mod dgn_import; +pub(crate) mod tds_import; pub(crate) mod edit_block; pub(crate) mod image_transparency; pub(crate) mod insert_block; diff --git a/src/modules/insert/tds_import.rs b/src/modules/insert/tds_import.rs new file mode 100644 index 000000000..a98e12880 --- /dev/null +++ b/src/modules/insert/tds_import.rs @@ -0,0 +1,132 @@ +// 3D Studio import: when the import is set to ask what a mesh with several +// materials becomes, this asks for each such mesh in turn. +// +// has multiple materials assigned. [Split/First/Material] : +// Select a material or [None] : + +use std::collections::HashMap; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::io::tds_import::{LayerMode, MultiMaterial}; + +/// What one 3D Studio import reads and how. +#[derive(Clone, Debug, PartialEq)] +pub struct TdsImportRequest { + pub path: String, + /// The objects to import, by name ("Global Settings" included). + pub selected: Vec, + pub layers: LayerMode, + /// The choice for each mesh with several materials, by name. + pub multi: HashMap, + /// Meshes keep only the vertices their faces use (the import splits or + /// asks), else the file's vertex list. + pub compact: bool, +} + +pub struct TdsMaterialCommand { + request: TdsImportRequest, + /// The meshes still to ask about, with their materials. + queue: Vec<(String, Vec)>, + choosing: bool, +} + +impl TdsMaterialCommand { + pub fn new(request: TdsImportRequest, queue: Vec<(String, Vec)>) -> Self { + Self { + request, + queue, + choosing: false, + } + } + + fn answer(&mut self, choice: MultiMaterial) -> CmdResult { + if self.queue.is_empty() { + return CmdResult::Cancel; + } + let (name, _) = self.queue.remove(0); + self.request.multi.insert(name, choice); + self.choosing = false; + if self.queue.is_empty() { + CmdResult::TdsImport(self.request.clone()) + } else { + CmdResult::NeedPoint + } + } +} + +impl CadCommand for TdsMaterialCommand { + fn name(&self) -> &'static str { + "IMPORT" + } + + fn prompt(&self) -> String { + match (self.choosing, self.queue.first()) { + (true, _) => "Select a material or [None] :".to_string(), + (false, Some((name, _))) => { + format!("{name} has multiple materials assigned. [Split/First/Material] :") + } + (false, None) => String::new(), + } + } + + fn options(&self) -> Vec { + if self.choosing { + let mut options = vec![CmdOption::new("None", "N")]; + if let Some((_, materials)) = self.queue.first() { + options.extend(materials.iter().map(|m| CmdOption::new(m, m))); + } + options + } else { + vec![ + CmdOption::new("Split", "S"), + CmdOption::new("First", "F"), + CmdOption::new("Material", "M"), + ] + } + } + + fn input_kind(&self) -> InputKind { + if self.choosing { + InputKind::FreeText + } else { + InputKind::SingleToken + } + } + + fn on_point(&mut self, _pt: glam::DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let text = text.trim(); + if self.choosing { + if text.is_empty() || text.eq_ignore_ascii_case("None") || text.eq_ignore_ascii_case("N") { + return Some(self.answer(MultiMaterial::Material(None))); + } + let found = self + .queue + .first() + .and_then(|(_, materials)| materials.iter().find(|m| m.eq_ignore_ascii_case(text))) + .cloned(); + return Some(match found { + Some(material) => self.answer(MultiMaterial::Material(Some(material))), + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }); + } + let t = text.to_ascii_uppercase(); + Some(match t.as_str() { + "" => self.answer(MultiMaterial::Split), + _ if "SPLIT".starts_with(&t) => self.answer(MultiMaterial::Split), + _ if "FIRST".starts_with(&t) => self.answer(MultiMaterial::First), + _ if "MATERIAL".starts_with(&t) => { + self.choosing = true; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }) + } + + fn on_enter(&mut self) -> CmdResult { + self.on_text_input("").unwrap_or(CmdResult::NeedPoint) + } +} diff --git a/src/ui/window/import_dialogs.rs b/src/ui/window/import_dialogs.rs index d553411e1..f98136c9c 100644 --- a/src/ui/window/import_dialogs.rs +++ b/src/ui/window/import_dialogs.rs @@ -1,6 +1,8 @@ //! Import File (IMPORT: the format first, then the file, routed to the -//! format's importer) and DGN Import (DGNIMPORT: model, target drawing, -//! conversion units and the mapping setup's effect on the file). +//! format's importer), DGN Import (DGNIMPORT: model, target drawing, +//! conversion units and the mapping setup's effect on the file) and 3D Studio +//! Import (the objects of a .3ds file to bring in, their layers and what a +//! mesh with several materials becomes). use iced::widget::{button, column, container, image, row, scrollable, text, text_input, Space}; use iced::{Background, Border, Element, Fill, Length, Theme}; @@ -34,6 +36,17 @@ pub enum ImportDialogMsg { DgnExplode(bool), DgnMappingTab(usize), DgnOk, + // 3D Studio Import + TdsChange, + TdsPicked(Option), + TdsCheck(usize, bool), + TdsAll, + TdsClear, + TdsFilter(usize), + TdsSearch(String), + TdsLayers(usize), + TdsMulti(usize), + TdsOk, Help(&'static str), } @@ -84,7 +97,7 @@ pub const FORMATS: [ImportFormat; 21] = [ format("MicroStation DGN", "*.dgn", &["dgn"], 0, Support::Yes), format("All DGN Files", "*.*", &["*"], 0, Support::Yes), format("Metafile", "*.wmf", &["wmf"], 0, Support::Yes), - format("3D Studio", "*.3ds", &["3ds"], 1, Support::No), + format("3D Studio", "*.3ds", &["3ds"], 1, Support::Yes), format("ACIS", "*.sat", &["sat"], 1, Support::Yes), format( "CATIA V4", @@ -146,11 +159,29 @@ pub struct DgnImportState { pub mapping: [Vec<(String, String)>; 4], } -/// Both windows' working copies and the files imported this session. +/// The objects of a 3D Studio file and the import's choices. +pub struct TdsImportState { + pub path: String, + /// "Global Settings" first, then the file's objects by name: each name, + /// its kind (Mesh, Light, Camera or Global) and the materials it uses. + pub objects: Vec<(String, &'static str, usize)>, + pub materials: usize, + pub checked: Vec, + /// 0 all, 1 meshes, 2 lights, 3 cameras, 4 global. + pub filter: usize, + pub search: String, + /// 0 by object, 1 by material, 2 by object colour, 3 single layer. + pub layers: usize, + /// 0 always ask, 1 split by material, 2 first material, 3 no material. + pub multi: usize, +} + +/// The windows' working copies and the files imported this session. #[derive(Default)] pub struct ImportDialogs { pub file: Option, pub dgn: Option, + pub tds: Option, pub recent: Vec, } @@ -414,6 +445,212 @@ pub fn view_import_file<'a>( .into() } +/// One choice out of a few, as buttons stacked one above the other. +fn stacked<'a>(options: Vec<(String, bool, Message)>) -> Element<'a, Message> { + let buttons = options.into_iter().map(|(label, on, message)| { + button(text(label).size(11).width(Fill)) + .on_press(message) + .style(button_style(on)) + .padding([5, 10]) + .width(Fill) + .into() + }); + container(iced::widget::Column::with_children(buttons.collect::>()).spacing(2)) + .padding(2) + .width(Fill) + .style(well_style) + .into() +} + +/// The kind of a 3D Studio object in the window's words. +fn tds_kind(kind: &str) -> String { + match kind { + "Mesh" => crate::tr!("import-3ds", "mesh"), + "Light" => crate::tr!("import-3ds", "light"), + "Camera" => crate::tr!("import-3ds", "camera"), + _ => crate::tr!("import-3ds", "general"), + } +} + +pub fn view_tds_import<'a>( + state: &'a TdsImportState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let top = container( + row![ + container(text(file_name(&state.path)).size(12)) + .padding([4, 10]) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 12.0.into(), + color: theme.palette().primary.base.color, + }, + ..Default::default() + }), + text(crate::tr!("import-3ds", "summary", objects = (state.objects.len().saturating_sub(1) as i64), materials = (state.materials as i64))) + .size(11) + .style(muted_style) + .width(Fill), + button(text(crate::tr!("import-3ds", "change")).size(11)) + .on_press(msg(ImportDialogMsg::TdsChange)) + .style(button::text) + .padding([4, 6]), + ] + .spacing(10) + .align_y(iced::Center), + ) + .padding([8, 12]) + .width(Fill) + .style(crate::ui::window::pdf_dialogs::card_style); + + let kinds = ["", "Mesh", "Light", "Camera", "Global"]; + let mut filters = row![].spacing(4).align_y(iced::Center); + for (k, label) in [crate::tr!("import-3ds", "all"), crate::tr!("import-3ds", "mesh"), crate::tr!("import-3ds", "light"), crate::tr!("import-3ds", "camera"), crate::tr!("import-3ds", "general")] + .into_iter() + .enumerate() + { + filters = filters.push(chip(label, state.filter == k, msg(ImportDialogMsg::TdsFilter(k)))); + } + let search = state.search.trim().to_lowercase(); + let mut rows = column![row![ + Space::new().width(Length::Fixed(22.0)), + text(crate::tr!("import-3ds", "name")).size(11).style(muted_style).width(Fill), + text(crate::tr!("import-3ds", "type")).size(11).style(muted_style).width(Length::Fixed(150.0)), + ]] + .spacing(2); + for (k, (name, kind, materials)) in state.objects.iter().enumerate() { + let global = *kind == "Global"; + if state.filter != 0 && kinds[state.filter] != *kind { + continue; + } + if !search.is_empty() && !name.to_lowercase().contains(&search) { + continue; + } + let shown_name = if global { crate::tr!("import-3ds", "global") } else { name.clone() }; + let shown_kind = if global { + String::new() + } else if *kind == "Mesh" { + format!("{} · {}", tds_kind(kind), crate::tr!("import-3ds", "materials", n = (*materials as i64))) + } else { + tds_kind(kind) + }; + let on = state.checked.get(k).copied().unwrap_or(false); + rows = rows.push( + row![ + iced::widget::checkbox(on) + .on_toggle(move |v| msg(ImportDialogMsg::TdsCheck(k, v))) + .size(14), + text(shown_name).size(12).width(Fill), + text(shown_kind).size(11).style(muted_style).width(Length::Fixed(150.0)), + ] + .spacing(8) + .align_y(iced::Center), + ); + } + let objects = card( + crate::tr!("import-3ds", "objects"), + column![ + row![ + filters, + Space::new().width(Fill), + text_input(crate::tr!("import-3ds", "search").as_ref(), &state.search) + .on_input(|s| msg(ImportDialogMsg::TdsSearch(s))) + .size(11) + .padding([4, 8]) + .width(Length::Fixed(170.0)) + .style(field_style), + ] + .spacing(8) + .align_y(iced::Center), + container(scrollable(rows).height(Length::Fixed(300.0))) + .padding(6) + .width(Fill) + .style(well_style), + row![ + button(text(crate::tr!("import-3ds", "select-all")).size(11)) + .on_press(msg(ImportDialogMsg::TdsAll)) + .style(button_style(false)) + .padding([4, 10]), + button(text(crate::tr!("import-3ds", "clear")).size(11)) + .on_press(msg(ImportDialogMsg::TdsClear)) + .style(button_style(false)) + .padding([4, 10]), + ] + .spacing(6), + ] + .spacing(8), + ); + let layer_notes = [ + crate::tr!("import-3ds", "note-object"), + crate::tr!("import-3ds", "note-material"), + crate::tr!("import-3ds", "note-color"), + crate::tr!("import-3ds", "note-single"), + ]; + let layers = card( + crate::tr!("import-3ds", "layers"), + column![ + stacked( + [crate::tr!("import-3ds", "by-object"), crate::tr!("import-3ds", "by-material"), crate::tr!("import-3ds", "by-color"), crate::tr!("import-3ds", "single")] + .into_iter() + .enumerate() + .map(|(k, label)| (label, state.layers == k, msg(ImportDialogMsg::TdsLayers(k)))) + .collect() + ), + text(layer_notes[state.layers.min(3)].clone()).size(11).style(muted_style), + ] + .spacing(8), + ); + let multi = card( + crate::tr!("import-3ds", "multi"), + stacked( + [ + crate::tr!("import-3ds", "ask"), + crate::tr!("import-3ds", "split"), + crate::tr!("import-3ds", "first"), + crate::tr!("import-3ds", "none"), + ] + .into_iter() + .enumerate() + .map(|(k, label)| (label, state.multi == k, msg(ImportDialogMsg::TdsMulti(k)))) + .collect(), + ), + ); + let any = state.checked.iter().any(|c| *c); + let mut actions = row![ + button(text("?").size(12)) + .on_press(msg(ImportDialogMsg::Help("3ds"))) + .style(button_style(false)) + .padding([5, 11]), + Space::new().width(Fill), + ] + .spacing(6) + .align_y(iced::Center); + if !any { + actions = actions.push(text(crate::tr!("import-3ds", "reason")).size(11).style(muted_style)); + } + actions = actions + .push(dialog_button(crate::tr!("import-3ds", "cancel"), Message::CloseModal, false)) + .push(dialog_button_styled_opt( + crate::tr!("import-3ds", "import"), + any.then(|| msg(ImportDialogMsg::TdsOk)), + button::primary, + )); + column![ + top, + row![ + container(objects).width(Length::FillPortion(3)), + column![layers, multi].spacing(10).width(Length::FillPortion(2)), + ] + .spacing(10), + actions, + ] + .spacing(10) + .padding([10, 12]) + .width(sizing.width) + .into() +} + pub fn view_dgn_import<'a>( state: &'a DgnImportState, sizing: crate::ui::modal::ModalSizing, From dbc428b237a2f3c8ecd0346754989f3be1301b25 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:20:03 +0300 Subject: [PATCH 7/9] Harden STEP, IGES and WMF readers against crafted files A truncated or hostile file could crash, hang or exhaust memory while being read. STEP: a last instance without its semicolon no longer runs the cursor past the end, the complex B-spline forms read their parts with bounds checks, a knot multiplicity is capped at degree + 1, parameter lists nest at most 64 deep, and wrapped curves and conversion-based units follow at most a fixed number of hops so self-references cannot loop. IGES: spline curve and surface sizes, hole and boundary counts and list indices are checked against the parameters the entity actually has, using checked arithmetic; transformation matrix chains and nested composite curves are cut after a few links. IGES colour 1 (black) now maps to colour 7 so it shows on a dark background. WMF: a record whose size claims more than the file has left is clamped to the remaining bytes. Doc comments describe the behaviour instead of naming an outside program. Co-Authored-By: Claude Opus 5.5 --- src/io/iges_import.rs | 110 ++++++++++++++++++++++++++---------------- src/io/step_import.rs | 100 ++++++++++++++++++++++++++------------ src/io/wmf_import.rs | 10 ++-- 3 files changed, 144 insertions(+), 76 deletions(-) diff --git a/src/io/iges_import.rs b/src/io/iges_import.rs index 6317b4605..ed7b33203 100644 --- a/src/io/iges_import.rs +++ b/src/io/iges_import.rs @@ -10,6 +10,7 @@ use kernel::brep::{Circle3, Curve3, EdgeKey, Line3, Surface, VertexKey}; use kernel::space::{NurbsCurve3, NurbsSurface3, Plane}; use super::brep_build::{Builder, FaceSpec}; +use super::step_import::compose; /// One parameter: a number, or a Hollerith string. #[derive(Debug, Clone)] @@ -44,7 +45,7 @@ fn parameters(text: &str, delimiter: char, terminator: char) -> Vec

{ if i > start && matches!(chars.get(i), Some('H' | 'h')) { let count: usize = chars[start..i].iter().collect::().parse().unwrap_or(0); let body: String = chars.iter().skip(i + 1).take(count).collect(); - i += 1 + count; + i = i.saturating_add(1).saturating_add(count); out.push(P::Str(body)); } else { i = start; @@ -87,7 +88,13 @@ struct Doc { } /// A 3×4 matrix applied as `R·p + T`. -type Matrix = [[f64; 4]; 3]; +type Matrix = super::step_import::Placement; + +/// A transformation matrix entity's (124) matrix. +fn matrix_of(entity: &Entity) -> Matrix { + let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + [[v(1), v(2), v(3), v(4)], [v(5), v(6), v(7), v(8)], [v(9), v(10), v(11), v(12)]] +} impl Doc { fn get(&self, pointer: f64) -> Option<&Entity> { @@ -99,7 +106,8 @@ impl Doc { fn color(&self, number: i64) -> codec::types::Color { use codec::types::Color; let rgb = match number { - 1 => [0, 0, 0], + // Black draws as colour 7, so it shows on a dark background too. + 1 => return Color::Index(7), 2 => [255, 0, 0], 3 => [0, 255, 0], 4 => [0, 0, 255], @@ -127,20 +135,22 @@ impl Doc { } } + /// A transformation matrix, with the matrices it chains through its own + /// transformation pointer applied after it. The chain is cut after a + /// few links, so matrices that point at each other cannot loop. fn matrix(&self, pointer: usize) -> Option { - let entity = self.entities.get(&pointer).filter(|e| e.kind == 124)?; - let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); - let mut m = [[v(1), v(2), v(3), v(4)], [v(5), v(6), v(7), v(8)], [v(9), v(10), v(11), v(12)]]; - // Matrices chain through their own transformation pointer. - if let Some(outer) = (entity.transform != 0).then(|| self.matrix(entity.transform)).flatten() { - let mut product = [[0.0; 4]; 3]; - for r in 0..3 { - for c in 0..4 { - product[r][c] = (0..3).map(|k| outer[r][k] * m[k][c]).sum::() - + if c == 3 { outer[r][3] } else { 0.0 }; - } - } - m = product; + let mut entity = self.entities.get(&pointer).filter(|e| e.kind == 124)?; + let mut m = matrix_of(entity); + for _ in 0..16 { + let Some(outer) = (entity.transform != 0) + .then(|| self.entities.get(&entity.transform)) + .flatten() + .filter(|e| e.kind == 124) + else { + break; + }; + m = compose(&matrix_of(outer), &m); + entity = outer; } Some(m) } @@ -194,12 +204,17 @@ impl Doc { }) } 126 => { + // The sizes come from the file: refuse ones its parameters + // cannot hold. let k = v(1) as usize; let degree = v(2) as usize; - let knots = (0..k + degree + 2).map(|n| v(7 + n)).collect(); - let w0 = 7 + k + degree + 2; + let w0 = k.checked_add(degree)?.checked_add(9)?; + let c0 = w0.checked_add(k)?.checked_add(1)?; + if c0.checked_add(k.checked_add(1)?.checked_mul(3)?)? > p.len() { + return None; + } + let knots = (7..w0).map(v).collect(); let weights = (0..=k).map(|n| v(w0 + n)).collect(); - let c0 = w0 + k + 1; let points = (0..=k) .map(|n| place([v(c0 + 3 * n), v(c0 + 3 * n + 1), v(c0 + 3 * n + 2)])) .collect(); @@ -211,13 +226,22 @@ impl Doc { /// A boundary curve as its pieces — each a curve and the points it runs /// from and to — composite curves taken apart. + /// Composite curves nest only a few deep, so a deeper (or circular) one + /// is refused. fn pieces(&self, pointer: f64) -> Option> { + self.pieces_at(pointer, 0) + } + + fn pieces_at(&self, pointer: f64, depth: usize) -> Option> { let entity = self.get(pointer)?; if entity.kind == 102 { + if depth >= 8 { + return None; + } let count = entity.params.get(1).map_or(0.0, P::num) as usize; let mut out = Vec::new(); for k in 0..count { - out.extend(self.pieces(entity.params.get(2 + k)?.num())?); + out.extend(self.pieces_at(entity.params.get(2 + k)?.num(), depth + 1)?); } return Some(out); } @@ -245,8 +269,8 @@ impl Doc { 142 => self.pieces(v(4)), 141 => { let mut out = Vec::new(); - let mut at = 5; - for _ in 0..v(4) as usize { + let mut at: usize = 5; + for _ in 0..(v(4) as usize).min(entity.params.len()) { let mut pieces = self.pieces(v(at))?; if v(at + 1) == 2.0 { pieces.reverse(); @@ -255,7 +279,7 @@ impl Doc { } } out.extend(pieces); - at += 3 + v(at + 2) as usize; + at = at.saturating_add(3).saturating_add(v(at + 2) as usize); } Some(out) } @@ -271,10 +295,16 @@ impl Doc { let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); let place = self.place(entity); let (k1, k2, m1, m2) = (v(1) as usize, v(2) as usize, v(3) as usize, v(4) as usize); + // The sizes come from the file: refuse ones its parameters cannot + // hold. let s0 = 10; - let t0 = s0 + k1 + m1 + 2; - let w0 = t0 + k2 + m2 + 2; - let c0 = w0 + (k1 + 1) * (k2 + 1); + let t0 = k1.checked_add(m1)?.checked_add(s0 + 2)?; + let w0 = t0.checked_add(k2)?.checked_add(m2)?.checked_add(2)?; + let count = k1.checked_add(1)?.checked_mul(k2.checked_add(1)?)?; + let c0 = w0.checked_add(count)?; + if c0.checked_add(count.checked_mul(3)?)? > entity.params.len() { + return None; + } let u_knots = (s0..t0).map(v).collect(); let v_knots = (t0..w0).map(v).collect(); // Points run with the first index fastest. @@ -316,7 +346,7 @@ impl Assembly<'_> { return Some(v); } let entity = self.doc.get(list).filter(|e| e.kind == 502)?; - let n = 2 + 3 * (key.1.checked_sub(1)?); + let n = key.1.checked_sub(1)?.checked_mul(3)?.checked_add(2)?.min(entity.params.len()); let p = |k: usize| entity.params.get(k).map(P::num); let point = self.doc.place(entity)([p(n)?, p(n + 1)?, p(n + 2)?]); let v = self.build.vertex(point); @@ -330,7 +360,7 @@ impl Assembly<'_> { return Some(e); } let entity = self.doc.get(list).filter(|e| e.kind == 504)?; - let n = 2 + 5 * key.1.checked_sub(1)?; + let n = key.1.checked_sub(1)?.checked_mul(5)?.checked_add(2)?.min(entity.params.len()); let p = |k: usize| entity.params.get(k).map(P::num); let curve = self.doc.curve(p(n)?)?; let start = self.vertex(p(n + 1)?, p(n + 2)?)?; @@ -417,16 +447,11 @@ impl Assembly<'_> { /// A trimmed (144) or bounded (143) surface as a one-face sheet. fn sheet(&mut self, entity: &Entity) -> Option<()> { let v = |k: usize| entity.params.get(k).map_or(0.0, P::num); + // A count can be no more than the parameters there are. + let count = (v(3) as usize).min(entity.params.len()); let (surface_at, outer, holes): (f64, Option, Vec) = match entity.kind { - 144 => ( - v(1), - (v(2) != 0.0).then(|| v(4)), - (0..v(3) as usize).map(|k| v(5 + k)).collect(), - ), - 143 => { - let count = v(3) as usize; - (v(2), (count > 0).then(|| v(4)), (1..count).map(|k| v(4 + k)).collect()) - } + 144 => (v(1), (v(2) != 0.0).then(|| v(4)), (0..count).map(|k| v(5 + k)).collect()), + 143 => (v(2), (count > 0).then(|| v(4)), (1..count).map(|k| v(4 + k)).collect()), _ => return None, }; let Surface::Nurbs(nurbs) = self.doc.surface(surface_at)? else { return None }; @@ -463,7 +488,7 @@ impl Assembly<'_> { if kind == 0.0 { ring.push((self.edge(list, index)?, agrees != 0.0)); } - at += 5 + 2 * pcurves as usize; + at = at.saturating_add(5).saturating_add((pcurves as usize).saturating_mul(2)); } Some(ring) } @@ -579,9 +604,10 @@ pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { let Some(number) = first.get(73..).and_then(|n| n.trim().parse::().ok()) else { continue; }; - let start = field(first, 1) as usize; - let count = field(second, 3).max(1) as usize; - let data: String = (start..start + count) + // An entry spans at most every parameter line there is. + let start = field(first, 1).max(0) as usize; + let count = (field(second, 3).max(1) as usize).min(parameter_lines.len()); + let data: String = (start..start.saturating_add(count)) .filter_map(|n| parameter_lines.get(&n)) .filter_map(|l| l.get(..64)) .collect(); @@ -635,7 +661,7 @@ pub fn read(bytes: &[u8], drawing_mm: Option) -> Option { } } } - // The solids come in last first, as the reference reader places them. + // The solids are listed last first. bodies.reverse(); let mut numbers: Vec = doc .entities diff --git a/src/io/step_import.rs b/src/io/step_import.rs index 4953d7052..24ee86435 100644 --- a/src/io/step_import.rs +++ b/src/io/step_import.rs @@ -61,6 +61,8 @@ type Instance = Vec<(String, Vec

)>; struct Cursor<'a> { s: &'a [u8], i: usize, + /// How many parameter lists deep the cursor is. + depth: usize, } impl Cursor<'_> { @@ -69,7 +71,7 @@ impl Cursor<'_> { while self.s.get(self.i).is_some_and(|c| c.is_ascii_whitespace()) { self.i += 1; } - if self.s[self.i..].starts_with(b"/*") { + if self.s.get(self.i..).is_some_and(|rest| rest.starts_with(b"/*")) { match self.s[self.i + 2..].windows(2).position(|w| w == b"*/") { Some(end) => self.i += end + 4, None => self.i = self.s.len(), @@ -152,7 +154,18 @@ impl Cursor<'_> { } }) } + /// A parameter list; lists nested past any real file's depth are + /// refused rather than read on until the stack runs out. fn params(&mut self) -> Option> { + if self.depth >= 64 { + return None; + } + self.depth += 1; + let items = self.list(); + self.depth -= 1; + items + } + fn list(&mut self) -> Option> { self.eat(b'(')?; let mut items = Vec::new(); if self.eat(b')').is_some() { @@ -302,11 +315,12 @@ impl Doc { Plane::orthonormal(origin, x, z) } - /// Knots expanded by their multiplicities. - fn knots(mults: &P, knots: &P) -> Option> { + /// Knots expanded by their multiplicities. No knot repeats more than + /// `degree + 1` times, so a bogus multiplicity cannot blow up the list. + fn knots(mults: &P, knots: &P, degree: usize) -> Option> { let mut out = Vec::new(); for (m, k) in mults.list().iter().zip(knots.list()) { - for _ in 0..m.num()? as usize { + for _ in 0..(m.num()? as usize).min(degree.saturating_add(1)) { out.push(k.num()?); } } @@ -314,7 +328,20 @@ impl Doc { } fn curve(&self, p: &P) -> Option { - let id = p.reference()?; + let mut id = p.reference()?; + // Curves that wrap a basis curve are followed to it; the hop limit + // stops curves that wrap each other in a loop. + let mut hops = 0; + while matches!( + self.kind(id), + "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE" + ) { + hops += 1; + if hops > 32 { + return None; + } + id = self.simple(id)?.1.get(1)?.reference()?; + } let kind = self.kind(id); if kind == "B_SPLINE_CURVE_WITH_KNOTS" || self.part(id, "B_SPLINE_CURVE").is_some() { return self.spline_curve(id).map(Curve3::Nurbs); @@ -340,9 +367,6 @@ impl Doc { major_radius: params.get(2)?.num()?, minor_radius: params.get(3)?.num()?, }), - "SURFACE_CURVE" | "SEAM_CURVE" | "INTERSECTION_CURVE" | "TRIMMED_CURVE" => { - self.curve(params.get(1)?)? - } "POLYLINE" => { let points: Option> = params.get(1)?.list().iter().map(|q| self.point(q)).collect(); @@ -362,13 +386,13 @@ impl Doc { let base = self.part(id, "B_SPLINE_CURVE")?; let with = self.part(id, "B_SPLINE_CURVE_WITH_KNOTS")?; let weights = self.part(id, "RATIONAL_B_SPLINE_CURVE").and_then(|w| w.first()); - (&base[0], &base[1], &with[0], &with[1], weights) + (base.first()?, base.get(1)?, with.first()?, with.get(1)?, weights) }; let points: Option> = points.list().iter().map(|q| self.point(q)).collect(); let weights: Option> = weights.map(|w| w.list().iter().filter_map(P::num).collect()); let degree = degree.num()? as usize; let points = points?; - let knots = Self::knots(mults, knots)?; + let knots = Self::knots(mults, knots, degree)?; match &weights { Some(w) => NurbsCurve3::new_strict(degree, points.clone(), knots.clone(), w.clone()) .or_else(|| NurbsCurve3::new(degree, points, knots, weights.clone())), @@ -420,7 +444,16 @@ impl Doc { let base = self.part(id, "B_SPLINE_SURFACE")?; let with = self.part(id, "B_SPLINE_SURFACE_WITH_KNOTS")?; let weights = self.part(id, "RATIONAL_B_SPLINE_SURFACE").and_then(|w| w.first()); - (&base[0], &base[1], &base[2], &with[0], &with[1], &with[2], &with[3], weights) + ( + base.first()?, + base.get(1)?, + base.get(2)?, + with.first()?, + with.get(1)?, + with.get(2)?, + with.get(3)?, + weights, + ) }; let rows: Option>> = points .list() @@ -433,14 +466,8 @@ impl Doc { .map(|row| row.list().iter().filter_map(P::num).collect()) .collect() }); - NurbsSurface3::new( - ud.num()? as usize, - vd.num()? as usize, - rows?, - Self::knots(um, uk)?, - Self::knots(vm, vk)?, - weights, - ) + let (ud, vd) = (ud.num()? as usize, vd.num()? as usize); + NurbsSurface3::new(ud, vd, rows?, Self::knots(um, uk, ud)?, Self::knots(vm, vk, vd)?, weights) } } @@ -595,8 +622,18 @@ pub struct StepModel { } /// A named unit's size: millimetres for a length, radians for an angle. -fn unit_size(doc: &Doc, id: u64) -> Option { - if let Some(si) = doc.part(id, "SI_UNIT") { +/// A conversion-based unit is a multiple of another unit; the hop limit +/// stops units defined in terms of each other. +fn unit_size(doc: &Doc, mut id: u64) -> Option { + let mut factor = 1.0; + for _ in 0..16 { + let Some(si) = doc.part(id, "SI_UNIT") else { + let conversion = doc.part(id, "CONVERSION_BASED_UNIT")?; + let (_, measure) = doc.simple(conversion.get(1)?.reference()?)?; + factor *= measure.first()?.num()?; + id = measure.get(1)?.reference()?; + continue; + }; let prefix = match si.first() { Some(P::Enum(prefix)) => match prefix.as_str() { "MILLI" => 1e-3, @@ -608,14 +645,15 @@ fn unit_size(doc: &Doc, id: u64) -> Option { }, _ => 1.0, }; - return Some(match si.get(1) { - Some(P::Enum(name)) if name == "METRE" => prefix * 1000.0, - _ => prefix, - }); + return Some( + factor + * match si.get(1) { + Some(P::Enum(name)) if name == "METRE" => prefix * 1000.0, + _ => prefix, + }, + ); } - let conversion = doc.part(id, "CONVERSION_BASED_UNIT")?; - let (_, measure) = doc.simple(conversion.get(1)?.reference()?)?; - Some(measure.first()?.num()? * unit_size(doc, measure.get(1)?.reference()?)?) + None } /// The bodies of one B-rep item: a solid, a solid with voids, or each shell @@ -680,7 +718,7 @@ impl Doc { } /// `a·b`. -fn compose(a: &Placement, b: &Placement) -> Placement { +pub(super) fn compose(a: &Placement, b: &Placement) -> Placement { let mut out = [[0.0; 4]; 3]; for r in 0..3 { for c in 0..4 { @@ -711,6 +749,7 @@ pub fn read(bytes: &[u8]) -> Option { let mut cursor = Cursor { s: &text.as_bytes()[data..], i: 0, + depth: 0, }; let mut instances = HashMap::new(); while cursor.peek() == Some(b'#') { @@ -723,7 +762,8 @@ pub fn read(bytes: &[u8]) -> Option { while cursor.s.get(cursor.i).is_some_and(|c| *c != b';') { cursor.i += 1; } - cursor.i += 1; + // Past the `;`, or at the end of a truncated file. + cursor.i = (cursor.i + 1).min(cursor.s.len()); } } } diff --git a/src/io/wmf_import.rs b/src/io/wmf_import.rs index 2c0721e6e..7d6686a3a 100644 --- a/src/io/wmf_import.rs +++ b/src/io/wmf_import.rs @@ -209,8 +209,8 @@ fn radial_angle(bounds: [f64; 4], x: f64, y: f64) -> Option { Some(if degrees == 0.0 { 360.0 } else { degrees }) } -/// An arc as the reference reader plays it: points round the ellipse's -/// whole-unit centre and radii at whole multiples of 1/99 turn, rounded +/// An arc's points: round the ellipse's whole-unit centre and radii at +/// whole multiples of 1/99 turn, rounded /// down to whole units, from the start angle up to the end angle — past /// 360 degrees back from zero when the end comes first. A start inside the /// ellipse starts at zero; an end inside it runs on to a whole turn; with @@ -305,9 +305,11 @@ pub fn read(bytes: &[u8], fit: [f64; 2]) -> Option { if function == 0 || size < 3 { break; } - let params: Vec = (0..size - 3).filter_map(|k| word(at + 6 + 2 * k)).collect(); + // A record claims no more words than the file has left. + let words = (size - 3).min((bytes.len() - (at + 6)) / 2); + let params: Vec = (0..words).filter_map(|k| word(at + 6 + 2 * k)).collect(); records.push((function, params)); - at += 2 * size; + at = at.saturating_add(size.saturating_mul(2)); } // The window first: where the picture is and how big. From a0ed20df2895579ce9d3bcd5a3058027a09b32e1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:20:03 +0300 Subject: [PATCH 8/9] Fix IMPORT naming, messages and 3D Studio limits STEP assemblies: a link that closes a loop in the assembly is dropped, so no block inserts itself; the walk is iterative. Each imported block gets a name the drawing does not use yet instead of silently reusing an existing definition, inserts are pointed at the names the blocks got, and an insert of a block that could not be defined is left out with the error reported. Names that come from files (STEP products, 3D Studio objects, materials and cameras, WMF font faces) go through a new shared scene::sanitized_table_name, which applies the block-name rules (reserved and control characters, 255 characters). 3D Studio: meshes with more vertices than a polyface mesh can index are split over several meshes. Single-layer mode uses the current layer. Materials are only created when the drawing has a material dictionary. Camera views get the current view's proportions and the perspective flag. Messages show paths as given, the file-not-found and read-failure messages are plain text of our own, and the "Press Esc" line is gone since the import cannot be cancelled. The true-colour packing is shared. "Select ACIS File" and "Import WMF" get catalog entries and keys in all 22 locales. Co-Authored-By: Claude Opus 5.5 --- locales/ar-SA/opencadstudio.ftl | 2 + locales/bg-BG/opencadstudio.ftl | 2 + locales/cs-CZ/opencadstudio.ftl | 2 + locales/de-DE/opencadstudio.ftl | 2 + locales/el-GR/opencadstudio.ftl | 2 + locales/en-US/opencadstudio.ftl | 2 + locales/es-ES/opencadstudio.ftl | 2 + locales/fi-FI/opencadstudio.ftl | 2 + locales/fr-FR/opencadstudio.ftl | 2 + locales/hi-IN/opencadstudio.ftl | 2 + locales/hu-HU/opencadstudio.ftl | 2 + locales/it-IT/opencadstudio.ftl | 2 + locales/ja-JP/opencadstudio.ftl | 2 + locales/ko-KR/opencadstudio.ftl | 2 + locales/lo-LA/opencadstudio.ftl | 2 + locales/nl-NL/opencadstudio.ftl | 2 + locales/pl-PL/opencadstudio.ftl | 2 + locales/pt-BR/opencadstudio.ftl | 2 + locales/ru-RU/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + locales/zh-CN/opencadstudio.ftl | 2 + locales/zh-TW/opencadstudio.ftl | 2 + src/app/commands/model_import.rs | 92 ++++++++++++++++++-------------- src/io/model_import.rs | 60 +++++++++++++-------- src/io/tds_import.rs | 63 ++++++++++++++-------- src/locale_catalog.rs | 2 + src/scene/convert/acis_import.rs | 18 ++++--- src/scene/mod.rs | 14 +++++ 28 files changed, 205 insertions(+), 88 deletions(-) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 3294a3489..b4ef65ab0 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1250,6 +1250,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = اختيار ملف ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6683,6 +6684,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = استيراد WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fc1bd29f7..3098e3c76 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Избор на ACIS файл .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Импортиране на WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7a772cbe5..6ab862da4 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Vybrat soubor ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6668,6 +6669,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importovat WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index a02d69b58..4ed55bd97 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS-Datei auswählen .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6669,6 +6670,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importieren .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index d628d3b69..352bb4ade 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1239,6 +1239,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Επιλογή αρχείου ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Εισαγωγή WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index b58b35734..59a064489 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1236,6 +1236,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Select ACIS File .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = Select External Reference File .cad-files = CAD Files @@ -6670,6 +6671,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Import WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 1dbe7a9d2..4f286a5ee 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Seleccionar archivo ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importar WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f9c4ac4ba..8406bf191 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Valitse ACIS-tiedosto .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6668,6 +6669,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Tuo WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index af28e9df6..7b7617457 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Sélectionner un fichier ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importer un WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 2e8616557..5085c23e4 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1226,6 +1226,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS फ़ाइल चुनें .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6659,6 +6660,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF आयात करें .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 54b3e44a7..5c7437bff 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1242,6 +1242,7 @@ common = .dgn-files = DGN-fájlok .select-dwf-file = DWF-fájl kiválasztása .select-dgn-file = DGN-fájl kiválasztása + .select-acis-file = ACIS-fájl kiválasztása .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6655,6 +6656,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importálása .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 7d8f063bd..2a62e6357 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Seleziona file ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6670,6 +6671,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importa WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 169061f1e..7ec514c93 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1228,6 +1228,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS ファイルを選択 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6659,6 +6660,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF を読み込み .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 880d58f4d..e88b54ec4 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS 파일 선택 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6670,6 +6671,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF 가져오기 .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index 884e0e6d0..de3ebe1ba 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -1243,6 +1243,7 @@ common = .dgn-files = ໄຟລ໌ DGN .select-dwf-file = ເລືອກໄຟລ໌ DWF .select-dgn-file = ເລືອກໄຟລ໌ DGN + .select-acis-file = ເລືອກໄຟລ໌ ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = ເລືອກໄຟລ໌ອ້າງອີງພາຍນອກ .cad-files = ໄຟລ໌ CAD @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = ນຳເຂົ້າ WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 107f0ad66..5f59b0c44 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1236,6 +1236,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = ACIS-bestand selecteren .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6669,6 +6670,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = WMF importeren .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index d0826c8d3..3a5ac1cd6 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Wybierz plik ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6670,6 +6671,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importuj WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index ac6f48463..6aeaf1e70 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1238,6 +1238,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Selecionar arquivo ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6671,6 +6672,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Importar WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index ba7834740..b8ae4319a 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1241,6 +1241,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = Выбор файла ACIS .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6672,6 +6673,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = Импорт WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index d38b23fcb..8ccbb75ba 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1224,6 +1224,7 @@ common = .dgn-files = DGN Dosyaları .select-dwf-file = DWF Dosyası Seç .select-dgn-file = DGN Dosyası Seç + .select-acis-file = ACIS Dosyası Seç .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6592,6 +6593,7 @@ dgn-import = .import-file = Dosya İçe Aktar .dgn-import = DGN İçe Aktar .import-dgn-file = DGN Dosyası İçe Aktar + .import-wmf = WMF İçe Aktar .help-dgn = DGN modelini çizim nesneleri olarak içe aktarır: seviyeler katman, hücreler blok olur. .help-import = Seçilen biçimdeki dosyayı o biçimin içe aktarıcısıyla içe aktarır. .blank = Dosya adı boş olamaz diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 2461ce671..8d2e6e14b 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1225,6 +1225,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = 选择 ACIS 文件 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6654,6 +6655,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = 导入 WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index f38b013f7..ea0d94fdb 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1237,6 +1237,7 @@ common = .dgn-files = DGN Files .select-dwf-file = Select DWF File .select-dgn-file = Select DGN File + .select-acis-file = 選擇 ACIS 檔案 .pdfattach = PDFATTACH: __ocs_fmt_0__ .select-external-reference-file = { draw.select } · { properties.external-reference } · { common.files } .cad-files = { common.files } (CAD) @@ -6666,6 +6667,7 @@ dgn-import = .import-file = Import File .dgn-import = DGN Import .import-dgn-file = Import DGN File + .import-wmf = 匯入 WMF .help-dgn = Imports a DGN model as drawing objects: levels as layers, cells as blocks. .help-import = Imports a file of the chosen format with the format's importer. .blank = Filename cannot be blank diff --git a/src/app/commands/model_import.rs b/src/app/commands/model_import.rs index d66565b7c..66d2bc032 100644 --- a/src/app/commands/model_import.rs +++ b/src/app/commands/model_import.rs @@ -3,6 +3,8 @@ //! as one undo step. use super::*; +use std::collections::HashMap; + use crate::io::model_import::{self, ModelFormat}; impl OpenCADStudio { @@ -36,9 +38,8 @@ impl OpenCADStudio { let here = std::env::current_dir() .map(|d| d.display().to_string()) .unwrap_or_default(); - self.command_line.push_error(&format!( - "\"{name}\": Can't find file in search path:\n {here}\\ (current directory)" - )); + self.command_line + .push_error(&format!("\"{name}\": file not found in the current directory, {here}")); return Some(Task::none()); } Some(self.import_model(i, rest, ModelFormat::Sat)) @@ -82,40 +83,48 @@ impl OpenCADStudio { .push_error(&format!("File {path} does not exist.")); return Task::none(); }; - // The translated formats report the job as they run. let translated = format != ModelFormat::Sat; - if translated { - self.command_line - .push_output("Import job in progress. Press Esc key to cancel."); - } let drawing_mm = crate::app::properties::insunits_to_mm( self.tabs[i].scene.document.header.insertion_units, ); let Some(imported) = model_import::read(format, &bytes, drawing_mm) else { - let shown = path.replace('/', "\\"); - match format { - ModelFormat::Sat => { - self.command_line.push_error("Modeling Operation Error:"); - self.command_line.push_error("Error Code Number is 58000"); - self.command_line - .push_error(&format!("Unable to read the ACIS file {shown}.")); - } - _ => self - .command_line - .push_error(&format!("Import failed: {shown} has no model the importer reads.")), - } + self.command_line + .push_error(&format!("Import failed: {path} has no model the importer reads.")); return Task::none(); }; self.push_undo_snapshot(i, "IMPORT"); let scene = &mut self.tabs[i].scene; - // A block the drawing already has keeps its definition and is - // inserted as it is. + // Each block goes under a name the drawing does not use yet (a + // product named like a block already there gets a new name, not the + // old definition), and the inserts are pointed at the names the + // blocks got. An insert of a block that could not be made is left + // out. + fn retarget(entities: Vec, defined: &HashMap) -> Vec { + entities + .into_iter() + .filter_map(|entity| match entity { + codec::EntityType::Insert(mut insert) => { + insert.block_name = defined.get(&insert.block_name)?.clone(); + Some(codec::EntityType::Insert(insert)) + } + other => Some(other), + }) + .collect() + } + let mut taken: Vec = scene.document.block_records.iter().map(|b| b.name.clone()).collect(); + let mut defined: HashMap = HashMap::new(); for (name, entities) in imported.blocks { - if scene.document.block_records.get(&name).is_none() { - let _ = scene.define_block_from_owned_entities(entities, &name, glam::DVec3::ZERO); + let unique = crate::modules::insert::xattach::unique_block_name(&name, &taken); + let entities = retarget(entities, &defined); + match scene.define_block_from_owned_entities(entities, &unique, glam::DVec3::ZERO) { + Ok(_) => { + taken.push(unique.clone()); + defined.insert(name, unique); + } + Err(error) => self.command_line.push_error(&format!("{name}: {error}")), } } - for entity in imported.entities { + for entity in retarget(imported.entities, &defined) { scene.add_entity(entity); } self.tabs[i].dirty = true; @@ -140,7 +149,7 @@ impl OpenCADStudio { crate::io::wmf_import::read(&bytes, [0.5 * size[0], 0.5 * size[1]]) else { self.command_line - .push_error(&format!("{} is not a valid Windows metafile.", path.replace('/', "\\"))); + .push_error(&format!("{path} is not a valid Windows metafile.")); return Task::none(); }; self.push_undo_snapshot(i, if prompt { "WMFIN" } else { "IMPORT" }); @@ -152,7 +161,8 @@ impl OpenCADStudio { let mut styles: Vec = Vec::new(); for (k, face) in picture.fonts.iter().enumerate() { let n = picture.fonts[..k].iter().filter(|f| f.name == face.name).count(); - let name = format!("WMF-{}{n}", face.name); + // The face name comes from the file. + let name = crate::scene::sanitized_table_name(&format!("WMF-{}{n}", face.name), "WMF"); if !scene.document.text_styles.contains(&name) { let mut style = codec::tables::TextStyle::new(name.as_str()); style.handle = scene.document.allocate_handle(); @@ -210,7 +220,7 @@ impl OpenCADStudio { pub(in crate::app) fn open_tds_import_dialog(&mut self, path: &str) -> Result<(), String> { let scene = crate::scene::model::pdf_raster::source_bytes(path) .and_then(|bytes| crate::io::tds_import::read(&bytes)) - .ok_or_else(|| format!("{} is not a valid 3D Studio file.", path.replace('/', "\\")))?; + .ok_or_else(|| format!("{path} is not a valid 3D Studio file."))?; let mut objects: Vec<(String, &'static str, usize)> = scene .objects .iter() @@ -322,10 +332,8 @@ impl OpenCADStudio { let Some(scene) = crate::scene::model::pdf_raster::source_bytes(&request.path) .and_then(|bytes| crate::io::tds_import::read(&bytes)) else { - self.command_line.push_error(&format!( - "{} is not a valid 3D Studio file.", - request.path.replace('/', "\\") - )); + self.command_line + .push_error(&format!("{} is not a valid 3D Studio file.", request.path)); return; }; let import = crate::io::tds_import::convert( @@ -335,10 +343,18 @@ impl OpenCADStudio { layers: request.layers, multi: request.multi.clone(), compact: request.compact, + single_layer: self.tabs[i].scene.document.header.current_layer_name.clone(), }, ); + // Named views take the current view's proportions. + let (_, size, _) = self.tabs[i].scene.view_window(); + let aspect = if size[0] > 0.0 && size[1] > 0.0 { size[0] / size[1] } else { 1.0 }; self.push_undo_snapshot(i, "IMPORT"); let document = &mut self.tabs[i].scene.document; + // Materials live in the material dictionary; a drawing without one + // imports the meshes without materials. + let dictionary = document.header.acad_material_dict_handle; + let has_dictionary = matches!(document.objects.get(&dictionary), Some(ObjectType::Dictionary(_))); // Materials by name: the drawing's own when it has one, else one // made from the file's colours. let mut materials: std::collections::HashMap = Default::default(); @@ -349,20 +365,18 @@ impl OpenCADStudio { }); let handle = match existing { Some(h) => h, + None if !has_dictionary => continue, None => { let source = scene.materials.iter().find(|m| m.name == *name); let mut material = codec::objects::Material::new(); material.handle = document.allocate_handle(); - material.owner = document.header.acad_material_dict_handle; + material.owner = dictionary; material.name = name.clone(); let color = |rgb: Option<[u8; 3]>| match rgb { Some([r, g, b]) => codec::objects::MaterialColor { flag: 1, factor: 1.0, - rgb: Some( - ((0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) - as i32, - ), + rgb: Some(crate::scene::convert::acis_import::packed_true_colour(r, g, b)), }, None => codec::objects::MaterialColor::default(), }; @@ -370,7 +384,6 @@ impl OpenCADStudio { material.diffuse_color = color(source.and_then(|m| m.diffuse)); material.specular_color = color(source.and_then(|m| m.specular)); let handle = material.handle; - let dictionary = document.header.acad_material_dict_handle; if let Some(ObjectType::Dictionary(dictionary)) = document.objects.get_mut(&dictionary) { dictionary.add_entry(name.clone(), handle); } @@ -393,8 +406,9 @@ impl OpenCADStudio { named.direction = codec::types::Vector3::new(view.direction[0], view.direction[1], view.direction[2]); named.height = view.height; - named.width = view.height; + named.width = view.height * aspect; named.lens_length = view.lens; + named.perspective = true; named.handle = match document.views.get(&view.name) { Some(old) => old.handle, None => document.allocate_handle(), diff --git a/src/io/model_import.rs b/src/io/model_import.rs index f5515a0ae..4b72358e6 100644 --- a/src/io/model_import.rs +++ b/src/io/model_import.rs @@ -30,19 +30,14 @@ fn ratio(file_mm: f64, drawing_mm: Option) -> f64 { /// A block name from a product's, with the characters names cannot hold /// replaced. fn block_name(name: &str) -> String { - let name: String = name - .trim() - .chars() - .map(|c| if "<>/\\\":;?*|,=`".contains(c) { '_' } else { c }) - .collect(); - if name.is_empty() { "Part".to_string() } else { name } + crate::scene::sanitized_table_name(name, "Part") } /// A STEP presentation colour as the drawing colour it imports as: the /// standard index colour it is close to — red, yellow, green, cyan, blue, /// magenta, white, or the dark grey 250 (which black also becomes) — and /// otherwise the true colour. -// ponytail: the closeness bands are fitted to measured imports (saturation +// ponytail: the closeness bands are fitted to sample files (saturation // at least 0.75, value at least 0.825 and hue within 7.5 degrees for the six // hues; greys from 0.16 to 0.39 for 250 and from 0.935 for white); refit // them if a colour on a band edge comes out differently. @@ -114,22 +109,43 @@ fn step_blocks(model: crate::io::step_import::StepModel, scale: f64) -> Imported } names.push(name); } - // Children before parents. + // Children before parents. A link that closes a loop in the file's + // assembly (a product that would contain itself) is dropped, so no + // block ends up inserting itself. let mut order = Vec::new(); + let mut dropped: std::collections::HashSet<(usize, usize)> = Default::default(); let mut state = vec![0u8; model.products.len()]; - fn visit(k: usize, model: &crate::io::step_import::StepModel, state: &mut [u8], order: &mut Vec) { - if state[k] != 0 { - return; - } - state[k] = 1; - for (child, _) in &model.products[k].children { - visit(*child, model, state, order); - } - state[k] = 2; - order.push(k); - } for &root in &model.roots { - visit(root, &model, &mut state, &mut order); + if state[root] != 0 { + continue; + } + state[root] = 1; + // Each product being walked and the next of its children to visit. + let mut stack = vec![(root, 0usize)]; + while let Some((k, next)) = stack.last_mut() { + let k = *k; + match model.products[k].children.get(*next) { + Some(&(child, _)) => { + let link = (k, *next); + *next += 1; + match state[child] { + 0 => { + state[child] = 1; + stack.push((child, 0)); + } + 1 => { + dropped.insert(link); + } + _ => {} + } + } + None => { + state[k] = 2; + order.push(k); + stack.pop(); + } + } + } } let blocks = order .iter() @@ -147,7 +163,9 @@ fn step_blocks(model: crate::io::step_import::StepModel, scale: f64) -> Imported product .children .iter() - .map(|(child, placement)| { + .enumerate() + .filter(|(n, _)| !dropped.contains(&(k, *n))) + .map(|(_, (child, placement))| { let mut insert = placed_insert(&names[*child], placement); insert.common_mut().color = Color::ByBlock; insert diff --git a/src/io/tds_import.rs b/src/io/tds_import.rs index 46f4c2e27..56490406c 100644 --- a/src/io/tds_import.rs +++ b/src/io/tds_import.rs @@ -8,6 +8,8 @@ use std::collections::HashMap; use codec::entities::{PolyfaceFace, PolyfaceMesh, PolyfaceVertex}; use codec::types::Vector3; +use crate::scene::sanitized_table_name; + pub struct TdsMaterial { pub name: String, pub ambient: Option<[u8; 3]>, @@ -258,6 +260,8 @@ pub struct TdsOptions { /// they first use them — as they do when the import splits by /// material or asks — rather than the file's vertex list. pub compact: bool, + /// The layer every mesh goes on with [`LayerMode::Single`]. + pub single_layer: String, } /// One imported mesh. @@ -284,14 +288,24 @@ pub struct TdsImport { pub messages: Vec, } -/// A polyface mesh of `faces` of `mesh`: its vertices in file order, or +/// Polyface meshes of `faces` of `mesh`: its vertices in file order, or /// (`compact`) only those the faces use, in the order they first use them. -fn polyface(mesh: &TdsMesh, faces: &[usize], compact: bool) -> PolyfaceMesh { - let mut out = PolyfaceMesh::new(); +/// A polyface mesh numbers its vertices in 16 bits, so a mesh with more +/// vertices than that is always compact, and the faces that need further +/// vertices go on further meshes. +fn polyfaces(mesh: &TdsMesh, faces: &[usize], compact: bool) -> Vec { + const MAX_VERTICES: usize = i16::MAX as usize; + let compact = compact || mesh.vertices.len() > MAX_VERTICES; + let vertex = |v: usize| { + let p = mesh.vertices[v]; + PolyfaceVertex::new(Vector3::new(p[0], p[1], p[2])) + }; + let mut out = Vec::new(); + let mut current = PolyfaceMesh::new(); let mut index: HashMap = HashMap::new(); if !compact { - for (k, v) in mesh.vertices.iter().enumerate() { - index.insert(k, out.add_vertex(PolyfaceVertex::new(Vector3::new(v[0], v[1], v[2])))); + for k in 0..mesh.vertices.len() { + index.insert(k, current.add_vertex(vertex(k))); } } for &f in faces { @@ -299,14 +313,15 @@ fn polyface(mesh: &TdsMesh, faces: &[usize], compact: bool) -> PolyfaceMesh { if face.iter().any(|v| *v >= mesh.vertices.len()) { continue; } - let ids = face.map(|v| { - *index.entry(v).or_insert_with(|| { - let p = mesh.vertices[v]; - out.add_vertex(PolyfaceVertex::new(Vector3::new(p[0], p[1], p[2]))) - }) - }); - out.add_face(PolyfaceFace::triangle(ids[0], ids[1], ids[2])); + let new = face.iter().filter(|v| !index.contains_key(v)).count(); + if current.vertices.len() + new > MAX_VERTICES { + out.push(std::mem::replace(&mut current, PolyfaceMesh::new())); + index.clear(); + } + let ids = face.map(|v| *index.entry(v).or_insert_with(|| current.add_vertex(vertex(v)))); + current.add_face(PolyfaceFace::triangle(ids[0], ids[1], ids[2])); } + out.push(current); out } @@ -365,24 +380,30 @@ pub fn convert(scene: &TdsScene, options: &TdsOptions) -> TdsImport { out.materials.push(m.clone()); } } + // Object and material names come from the file, so they + // are made fit to name a layer. let layer = match options.layers { - LayerMode::ByObject => name.to_uppercase(), - LayerMode::ByMaterial => material.as_deref().unwrap_or("0").to_uppercase(), + LayerMode::ByObject => sanitized_table_name(&name.to_uppercase(), "0"), + LayerMode::ByMaterial => { + sanitized_table_name(&material.as_deref().unwrap_or("0").to_uppercase(), "0") + } LayerMode::ByObjectColor => match mesh.color { Some(c) => format!("COLOR{c:02}"), None => "COLORNONE".to_string(), }, - LayerMode::Single => "AVLAYER".to_string(), + LayerMode::Single => options.single_layer.clone(), }; - out.parts.push(TdsPart { - layer, - material, - mesh: polyface(mesh, &faces, compact), - }); + for polyface in polyfaces(mesh, &faces, compact) { + out.parts.push(TdsPart { + layer: layer.clone(), + material: material.clone(), + mesh: polyface, + }); + } } } TdsObject::Camera(camera) => { - let name = name.to_uppercase(); + let name = sanitized_table_name(&name.to_uppercase(), "CAMERA"); out.messages.push(format!("Creating camera {name}")); let direction = [0, 1, 2].map(|k| camera.position[k] - camera.target[k]); let distance = direction.iter().map(|d| d * d).sum::().sqrt(); diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 4c5d5fbc3..fa7f4b9e0 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3501,6 +3501,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Import File" => Some(("dgn-import", "import-file")), "DGN Import" => Some(("dgn-import", "dgn-import")), "Import DGN File" => Some(("dgn-import", "import-dgn-file")), + "Import WMF" => Some(("dgn-import", "import-wmf")), "Imports a DGN model as drawing objects: levels as layers, cells as blocks." => Some(("dgn-import", "help-dgn")), "Imports a file of the chosen format with the format's importer." => Some(("dgn-import", "help-import")), "Filename cannot be blank" => Some(("dgn-import", "blank")), @@ -4425,6 +4426,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DGN Files" => Some(("common", "dgn-files")), "Select DWF File" => Some(("common", "select-dwf-file")), "Select DGN File" => Some(("common", "select-dgn-file")), + "Select ACIS File" => Some(("common", "select-acis-file")), "PDFATTACH: {e}" => Some(("common", "pdfattach")), "Select External Reference File" => Some(("common", "select-external-reference-file")), "CAD Files" => Some(("common", "cad-files")), diff --git a/src/scene/convert/acis_import.rs b/src/scene/convert/acis_import.rs index f7485b328..e88746a2f 100644 --- a/src/scene/convert/acis_import.rs +++ b/src/scene/convert/acis_import.rs @@ -1,8 +1,7 @@ //! ACIS text (SAT) files and kernel bodies as drawing objects. //! //! Each body of a file becomes one object: a closed solid a 3DSOLID, a sheet -//! whose faces all lie on planes a REGION, any other sheet a SURFACE — the -//! classes the ACIS import of the reference application produces. +//! whose faces all lie on planes a REGION, any other sheet a SURFACE. use codec::entities::acis::{SatDocument, SatFace, Sidedness}; use codec::entities::{AcisData, Region, Solid3D, Surface, SurfaceKind}; @@ -82,14 +81,19 @@ fn attribute_index(color: codec::types::Color) -> i64 { } } +/// A true colour packed as a 32-bit colour value: the true-colour flag +/// byte, then red, green and blue. +pub(crate) fn packed_true_colour(r: u8, g: u8, b: u8) -> i32 { + ((0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b)) as i32 +} + /// Hangs colour attributes on a record: a true colour first, then its /// index, as imported models carry them. fn paint_record(document: &mut codec::entities::acis::SatDocument, owner: usize, color: codec::types::Color) { use codec::entities::acis::{SatPointer, SatRecord, SatToken}; let mut chain: Vec<(&str, i64)> = Vec::new(); if let codec::types::Color::Rgb { r, g, b } = color { - let packed = (0xC2u32 << 24) | (u32::from(r) << 16) | (u32::from(g) << 8) | u32::from(b); - chain.push(("truecolor-adesk-attrib", i64::from(packed as i32))); + chain.push(("truecolor-adesk-attrib", i64::from(packed_true_colour(r, g, b)))); } chain.push(("color-adesk-attrib", attribute_index(color))); let Some(existing) = document.record(owner).map(|record| record.attribute) else { @@ -116,9 +120,9 @@ fn paint_record(document: &mut codec::entities::acis::SatDocument, owner: usize, } /// A kernel body as its drawing object in `colour` (by block when `None`), -/// with the faces given a colour of their own carrying it — and the edges -/// bounding them too, as the reference modeler stores them. A `sheet` is a -/// SURFACE however flat it is. +/// with the faces given a colour of their own carrying it, and the edges +/// bounding those faces carrying it too. A `sheet` is a SURFACE however +/// flat it is. pub fn painted_body_entity( body: &kernel::brep::Body, colour: Option, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 0b2958a82..3297b5433 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1208,6 +1208,20 @@ pub(crate) fn valid_block_name(name: &str) -> bool { }) } +/// `raw` made fit to name a table entry (a block, layer, style or view): +/// characters [`valid_block_name`] refuses become `_`, the name is cut to +/// 255 characters, and `fallback` stands in when nothing is left. For names +/// that come from outside files. +pub(crate) fn sanitized_table_name(raw: &str, fallback: &str) -> String { + let name: String = raw + .trim() + .chars() + .map(|c| if valid_block_name(c.encode_utf8(&mut [0; 4])) { c } else { '_' }) + .take(255) + .collect(); + block_service::validate_block_name(&name).unwrap_or_else(|_| fallback.to_string()) +} + /// Pre-built entity caches returned by [`build_derived_caches`]. /// Produced in the file-load background task so the UI thread only assigns. #[derive(Debug, Clone)] From 2ac8fd999586410da85c77dde57cd88b98fc4b03 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:20:03 +0300 Subject: [PATCH 9/9] Pin MCP tool schema digest for the 3D Studio dialog action The merged import adds the tds_import_dialog control action, which changes the MCP tool surface; update the pinned digest to match. Co-Authored-By: Claude Opus 5.5 --- src/mcp.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp.rs b/src/mcp.rs index 31e64499c..5f4b64017 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -32,7 +32,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; /// /// `block_delete` gained its optional `force` parameter. #[cfg(test)] -const TOOL_SCHEMA_DIGEST: &str = "baab80e34177ea4c20ae859fbf4f66aa82e612fe69062ab6389779a3c5bf7d93"; +const TOOL_SCHEMA_DIGEST: &str = "d15005e14fc00154501db5d261cb9febae1bcc61f6d9b850e91b7fd1879c3b8e"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000;