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.")), } }