From 6fdff6c515189eb709197301f69c04c81a185638 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:33:07 +0300 Subject: [PATCH 01/10] DGNIMPORT: reference naming, cells, text placement, curves, references Name clashes, DGNIMPORTMODE and prefixing as the reference names them; V7 cell copies, unshared cells and mirrored placements as blocks; justified text and text nodes placed at their justification points with the reference's MTEXT contents; B-spline curves as splines and 3D arcs and ellipses in their own planes; every V8 level as a layer; design models only; reference attachments translated, ignored or attached as underlays. The colour index palette comes from the codec fix it depends on. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 2 +- Cargo.toml | 3 + locales/ar-SA/opencadstudio.ftl | 5 +- locales/bg-BG/opencadstudio.ftl | 5 +- locales/cs-CZ/opencadstudio.ftl | 5 +- locales/de-DE/opencadstudio.ftl | 5 +- locales/el-GR/opencadstudio.ftl | 5 +- locales/en-US/opencadstudio.ftl | 5 +- locales/es-ES/opencadstudio.ftl | 5 +- locales/fi-FI/opencadstudio.ftl | 5 +- locales/fr-FR/opencadstudio.ftl | 5 +- locales/hi-IN/opencadstudio.ftl | 5 +- locales/hu-HU/opencadstudio.ftl | 5 +- locales/it-IT/opencadstudio.ftl | 5 +- locales/ja-JP/opencadstudio.ftl | 5 +- locales/ko-KR/opencadstudio.ftl | 5 +- locales/lo-LA/opencadstudio.ftl | 5 +- locales/nl-NL/opencadstudio.ftl | 5 +- locales/pl-PL/opencadstudio.ftl | 5 +- locales/pt-BR/opencadstudio.ftl | 5 +- locales/ru-RU/opencadstudio.ftl | 5 +- locales/tr-TR/opencadstudio.ftl | 5 +- locales/zh-CN/opencadstudio.ftl | 5 +- locales/zh-TW/opencadstudio.ftl | 5 +- src/app/commands/dgn_import.rs | 392 +++++++-- src/app/commands/mod.rs | 1 + src/app/commands/styleprops.rs | 36 + src/app/control/actions.rs | 13 + src/app/settings.rs | 3 + src/app/update/file.rs | 2 + src/locale_catalog.rs | 5 +- src/modules/insert/dgn_import.rs | 967 +++++++++++++++++------ src/modules/insert/xattach.rs | 12 +- src/scene/model/underlay_vector/dgn7.rs | 93 ++- src/scene/model/underlay_vector/dgn8.rs | 358 ++++++++- src/scene/model/underlay_vector/mod.rs | 16 +- src/scene/model/underlay_vector/model.rs | 150 +++- src/ui/window/import_dialogs.rs | 48 +- 38 files changed, 1774 insertions(+), 437 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bf8d59d12..fc76e9b93 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4373,7 +4373,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.6.0" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=102f04c#102f04c8ecda1cd409f7ecc4b6eab2dc895c9095" +source = "git+https://github.com/ramox81/cadcodec.git?rev=6434df7#6434df7049b4b81e66b8884180587efda343e070" dependencies = [ "anyhow", "bitflags 2.13.1", diff --git a/Cargo.toml b/Cargo.toml index a3cbad80c..f05bd0dd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -222,3 +222,6 @@ 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 = "6434df7" } diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 6429a0f89..7e3d4df3a 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6684,7 +6684,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index c37642d03..b7df2ac73 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index abf843f86..1aeb662fd 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6669,7 +6669,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index ba3802c7f..a05910b62 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6670,7 +6670,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 9ea02d2ca..207c2e265 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 0b25e247b..7245cf065 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6671,7 +6671,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index a5efe2c3e..99454f537 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index d4e00edd1..e1bb9c20b 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6669,7 +6669,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0e5473ea4..f579c49a0 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index e7f26c977..62bc1707e 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6660,7 +6660,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 15bee6825..5aa461c1c 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6656,7 +6656,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index df881cc37..0e0954c76 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6671,7 +6671,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 271c127f7..dbb1852c1 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6660,7 +6660,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 63f5bfb85..b2407e787 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6671,7 +6671,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/lo-LA/opencadstudio.ftl b/locales/lo-LA/opencadstudio.ftl index fc88b2625..044bcf559 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 60ff5d8e4..62d129e43 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6670,7 +6670,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 3801846f2..6592dcea3 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6671,7 +6671,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 22723a1dc..c4104606b 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6672,7 +6672,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index ae46aa3b6..654ee4628 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -6673,7 +6673,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 80109e3fe..c693f75cb 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6593,7 +6593,10 @@ dgn-import = .references = Dış DGN referansları .bind = Blok / xref yap .attach = DGN altlığı bağla - .references-note = DGN dosyalarının referans ekleri okunmaz; dışarıda bırakılır. + .existing = Çevrilmiş çizim zaten varsa + .prompt-overwrite = Sor + .overwrite = Sormadan üzerine yaz + .keep-existing = Üzerine yazma .mapping = Özellik eşleme .mapping-standard = Standart eşleme kurulumu. .mapping-note = Önizleme seçili kurulumun bu dosyaya etkisini gösterir. diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index a87e01d6e..d6ea27555 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6655,7 +6655,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 0ff11fa53..d985bea3c 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6667,7 +6667,10 @@ dgn-import = .references = External DGN references .bind = Bind as block / xref .attach = Attach as DGN underlay - .references-note = Reference attachments of DGN files are not read; they are left out. + .existing = When the translated drawing exists + .prompt-overwrite = Prompt to overwrite + .overwrite = Overwrite without prompting + .keep-existing = Do not overwrite .mapping = Property mapping .mapping-standard = Standard mapping setup. .mapping-note = The preview shows the selected setup's effect on this file. diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 23efa5b81..0fe00693b 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -8,7 +8,9 @@ use std::collections::HashMap; use std::sync::Arc; use super::*; -use crate::modules::insert::dgn_import::{self, DgnImportCommand, DgnImportRequest}; +use crate::modules::insert::dgn_import::{ + self, DgnImportCommand, DgnImportRequest, ExistingReference, ReferenceMode, +}; use crate::ui::window::import_dialogs::{ unsupported_reason, DgnImportState, ImportDialogMsg, ImportFileState, Support, FORMATS, }; @@ -53,7 +55,7 @@ fn exists(path: &str) -> bool { /// Whether the file is a DGN model DGNIMPORT reads. fn is_dgn(path: &str) -> bool { - crate::scene::model::underlay_vector::item_names(codec::entities::UnderlayType::Dgn, path) + crate::scene::model::underlay_vector::dgn_import_models(path) .and_then(|models| models.into_iter().next()) .is_some_and(|model| crate::scene::model::underlay_vector::dgn_import(path, &model).is_some()) } @@ -158,28 +160,38 @@ impl OpenCADStudio { /// The DGN Import window for a file; an error when it is no DGN model. pub(in crate::app) fn open_dgn_import_dialog(&mut self, path: &str) -> Result<(), String> { - use crate::scene::model::underlay_vector::{dgn_import, item_names}; - let models = item_names(codec::entities::UnderlayType::Dgn, path).unwrap_or_default(); + use crate::scene::model::underlay_vector::{dgn_import, dgn_import_models}; + let models = dgn_import_models(path).unwrap_or_default(); let Some(import) = models.first().and_then(|m| dgn_import(path, m)) else { return Err(crate::t!("Invalid or unsupported DGN file.").into_owned()); }; - // The options chosen last stay when the file is changed. + // The options chosen last (DGNIMPORTMODE and the kept import + // settings when the window opens) stay when the file is changed. let previous = self.import_dialogs.dgn.take(); + let saved = dgn_import::settings(); let keep = |f: fn(&DgnImportState) -> bool, default: bool| { previous.as_ref().map_or(default, f) }; self.import_dialogs.dgn = Some(DgnImportState { path: path.to_string(), version: if import.v7 { "V7" } else { "V8" }, + // Sheet models are not imported. models: crate::ui::window::pdf_dialogs::item_thumbs( codec::entities::UnderlayType::Dgn, path, - ), + ) + .into_iter() + .filter(|m| models.contains(&m.label)) + .collect(), model: 0, - current_drawing: keep(|s| s.current_drawing, false), - prefix_duplicates: keep(|s| s.prefix_duplicates, false), + current_drawing: keep(|s| s.current_drawing, saved.mode != 0), + prefix_duplicates: keep(|s| s.prefix_duplicates, saved.mode == 2), sub_units: keep(|s| s.sub_units, false), - explode_text_nodes: keep(|s| s.explode_text_nodes, false), + explode_text_nodes: keep(|s| s.explode_text_nodes, saved.explode_text_nodes), + references: previous.as_ref().map_or(saved.references, |s| s.references), + existing_reference: previous + .as_ref() + .map_or(saved.existing_reference, |s| s.existing_reference), units: import.unit_names.clone(), mapping_tab: previous.as_ref().map_or(0, |s| s.mapping_tab), mapping: [0, 1, 2, 3].map(|tab| dgn_import::mapping_rows(&import, tab)), @@ -366,6 +378,16 @@ impl OpenCADStudio { s.explode_text_nodes = on; } } + ImportDialogMsg::DgnReferences(mode) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.references = mode; + } + } + ImportDialogMsg::DgnExisting(choice) => { + if let Some(s) = self.import_dialogs.dgn.as_mut() { + s.existing_reference = choice; + } + } ImportDialogMsg::DgnMappingTab(tab) => { if let Some(s) = self.import_dialogs.dgn.as_mut() { s.mapping_tab = tab.min(3); @@ -386,7 +408,22 @@ impl OpenCADStudio { explode_text_nodes: s.explode_text_nodes, current_drawing: s.current_drawing, prefix_duplicates: s.prefix_duplicates, + references: s.references, + existing_reference: s.existing_reference, }; + // The window's choices become DGNIMPORTMODE and the kept + // settings the command line imports with. + dgn_import::set_settings(dgn_import::DgnImportSettings { + mode: match (s.current_drawing, s.prefix_duplicates) { + (false, _) => 0, + (true, false) => 1, + (true, true) => 2, + }, + explode_text_nodes: s.explode_text_nodes, + references: s.references, + existing_reference: s.existing_reference, + }); + self.save_config(); self.close_active_modal(); return self.run_dgn_import(i, request); } @@ -394,7 +431,8 @@ impl OpenCADStudio { Task::none() } - /// The import: into a new drawing (or the current one), one undo step. + /// The import: into a new drawing (or the current one), one undo step; + /// then the model's reference attachments as the request says. pub(in crate::app) fn run_dgn_import(&mut self, i: usize, request: DgnImportRequest) -> Task { let Some(import) = crate::scene::model::underlay_vector::dgn_import(&request.path, &request.model) @@ -413,84 +451,33 @@ impl OpenCADStudio { self.active_tab }; self.push_undo_snapshot(i, "DGNIMPORT"); - let stem = std::path::Path::new(&request.path.replace('\\', "/")) - .file_stem() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(); - let scene = &mut self.tabs[i].scene; - // In the current drawing, names it already has keep the existing - // definitions, or are imported under the file's name as prefix. - let prefix = (request.current_drawing && request.prefix_duplicates) - .then(|| format!("{stem}$0$")); - let rename = |taken: bool, name: &str| match &prefix { - Some(p) if taken && name != "0" => format!("{p}{name}"), - _ => name.to_string(), + let naming = if !request.current_drawing { + Naming::Number + } else if request.prefix_duplicates { + let stem = std::path::Path::new(&request.path.replace('\\', "/")) + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + Naming::Prefix(format!("{stem} - {}$0$", request.model)) + } else { + Naming::Keep }; - let mut layers: HashMap = HashMap::new(); - for (name, color) in &converted.layers { - let target = rename(scene.document.layers.contains(name), name); - if !scene.document.layers.contains(&target) { - let mut layer = codec::tables::Layer::new(target.as_str()); - layer.handle = scene.document.allocate_handle(); - layer.color = *color; - let _ = scene.document.layers.add(layer); + let layers = place_converted(&mut self.tabs[i].scene, converted, &naming); + if request.references != ReferenceMode::Ignore { + let k = if request.sub_units { import.sub_per_master } else { 1.0 }; + // A reference is placed on the model's default level. + let layer = import + .levels + .get(&64) + .map(|l| l.0.clone()) + .unwrap_or_else(|| "0".to_string()); + let layer = layers.get(&layer).cloned().unwrap_or(layer); + for reference in &import.references { + self.attach_dgn_reference(i, &request, reference, k, &layer); } - layers.insert(name.clone(), target); - } - let mut styles: HashMap = HashMap::new(); - for (name, font) in &converted.styles { - let target = rename(scene.document.text_styles.contains(name), name); - if !scene.document.text_styles.contains(&target) { - let mut style = codec::tables::TextStyle::new(target.as_str()); - style.handle = scene.document.allocate_handle(); - style.font_file = font.clone(); - let _ = scene.document.text_styles.add(style); - } - styles.insert(name.clone(), target); - } - let mut blocks: HashMap = HashMap::new(); - for (name, _) in &converted.blocks { - let taken = scene.document.block_records.get(name).is_some(); - blocks.insert(name.clone(), rename(taken, name)); - } - let fix = |mut entity: codec::EntityType| { - let common = entity.common_mut(); - if let Some(layer) = layers.get(&common.layer) { - common.layer = layer.clone(); - } - match &mut entity { - codec::EntityType::Text(t) => { - if let Some(s) = styles.get(&t.style) { - t.style = s.clone(); - } - } - codec::EntityType::MText(t) => { - if let Some(s) = styles.get(&t.style) { - t.style = s.clone(); - } - } - codec::EntityType::Insert(insert) => { - if let Some(b) = blocks.get(&insert.block_name) { - insert.block_name = b.clone(); - } - } - _ => {} - } - entity - }; - for (name, entities) in converted.blocks { - let target = &blocks[&name]; - if scene.document.block_records.get(target).is_some() { - continue; - } - let entities = entities.into_iter().map(&fix).collect(); - let _ = scene.define_block_from_owned_entities(entities, target, glam::DVec3::ZERO); - } - for entity in converted.entities { - scene.add_entity(fix(entity)); } if !request.current_drawing { - scene.fit_all(); + self.tabs[i].scene.fit_all(); } self.tabs[i].dirty = true; self.command_line.push_output("Regenerating model."); @@ -498,4 +485,241 @@ impl OpenCADStudio { self.refresh_properties(); task } + + /// One reference attachment: its file translated to "-.dwg" + /// beside it and attached as the external reference " - ", + /// or the file attached as a DGN underlay, at the attachment's place. + fn attach_dgn_reference( + &mut self, + i: usize, + request: &DgnImportRequest, + reference: &crate::scene::model::underlay_vector::DgnReference, + units: f64, + layer: &str, + ) { + use crate::scene::model::underlay_vector::{dgn_import, dgn_import_models}; + let host_dir = std::path::Path::new(&request.path) + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_default(); + let named = std::path::Path::new(&reference.file.replace('\\', "/")).to_path_buf(); + let file = if named.is_file() { + named + } else { + host_dir.join(named.file_name().unwrap_or_default()) + }; + let path = file.to_string_lossy().into_owned(); + if crate::scene::model::pdf_raster::source_bytes(&path).is_none() { + return; + } + let Some(model) = (if reference.model.is_empty() { + dgn_import_models(&path).and_then(|m| m.into_iter().next()) + } else { + Some(reference.model.clone()) + }) else { + return; + }; + let origin = glam::DVec3::from_array(reference.origin) * units; + if request.references == ReferenceMode::Underlay { + let mut placed = crate::modules::insert::pdf_attach::underlays_for_pages( + codec::entities::UnderlayType::Dgn, + std::slice::from_ref(&model), + origin, + reference.scale, + reference.rotation, + ); + let scene = &mut self.tabs[i].scene; + for (page, mut entity) in placed.drain(..) { + let definition = crate::modules::insert::pdf_attach::ensure_underlay_definition( + &mut scene.document, + codec::entities::UnderlayType::Dgn, + &path, + &page, + ); + if let codec::EntityType::Underlay(underlay) = &mut entity { + underlay.definition_handle = definition; + } + entity.common_mut().layer = layer.to_string(); + scene.add_entity(entity); + } + return; + } + let stem = file + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + let target = file.with_file_name(format!("{stem}-{model}.dwg")); + // ponytail: "Prompt to overwrite" keeps the existing drawing, as the + // reference does when it cannot ask; ask here if a window is wanted. + let write = !target.is_file() || request.existing_reference == ExistingReference::Overwrite; + if write { + let Some(source) = dgn_import(&path, &model) else { + return; + }; + let mut scene = crate::scene::Scene::new(); + let converted = dgn_import::convert(&source, false, request.explode_text_nodes); + place_converted(&mut scene, converted, &Naming::Number); + if let Err(error) = crate::io::save(&scene.document, &target) { + self.command_line.push_error(&error); + return; + } + } + let xref = crate::modules::insert::xattach::XrefAttachRequest { + path: target.to_string_lossy().into_owned(), + overlay: false, + path_type: crate::io::xref_model::Pathtype::Relative, + }; + let name = format!("{stem} - {model}"); + let full = crate::modules::insert::xattach::resolve_xref_store_path(&xref.path, None); + self.command_line + .push_output(&format!("Attach Xref \"{name}\": {full}")); + let host = self.tabs[i].current_path.clone(); + let block = crate::modules::insert::xattach::prepare_named_xref_definition( + &mut self.tabs[i].scene, + &xref, + host.as_deref(), + &name, + ); + if host.is_none() { + self.tabs[i].xref_relative_on_save.insert(block.clone()); + } + self.command_line + .push_output(&format!("\"{name}\" loaded: {full}")); + let mut insert = codec::entities::Insert::new( + block, + codec::types::Vector3::new(origin.x, origin.y, origin.z), + ) + .with_scale(reference.scale, reference.scale, reference.scale) + .with_rotation(reference.rotation); + insert.common.layer = layer.to_string(); + insert.common.linetype = "Continuous".to_string(); + self.tabs[i] + .scene + .add_entity(codec::EntityType::Insert(insert)); + self.refresh_xref_manager(); + } +} + +/// How imported names meet the names a drawing already has: a new drawing +/// numbers a clashing layer "_1" and a clashing block or text style +/// "1" (layer 0 and the Standard style are shared); the current +/// drawing either keeps its own definitions or prefixes every imported name +/// (layers 0 and Defpoints excepted). +enum Naming { + Number, + Keep, + Prefix(String), +} + +impl Naming { + fn name(&self, kind: char, name: &str, taken: &dyn Fn(&str) -> bool) -> String { + match self { + Naming::Keep => name.to_string(), + Naming::Prefix(p) => { + let shared = kind == 'l' + && (name == "0" || name.eq_ignore_ascii_case("Defpoints")); + if shared { + name.to_string() + } else { + format!("{p}{name}") + } + } + Naming::Number => { + let shared = (kind == 'l' && name == "0") + || (kind == 's' && name.eq_ignore_ascii_case("Standard")); + if shared || !taken(name) { + return name.to_string(); + } + let sep = if kind == 'l' { "_" } else { "" }; + (1..) + .map(|n| format!("{name}{sep}{n}")) + .find(|n| !taken(n)) + .unwrap_or_default() + } + } + } +} + +/// Adds converted objects to a drawing under the naming rule; returns the +/// layer names as placed. +fn place_converted( + scene: &mut crate::scene::Scene, + converted: dgn_import::Converted, + naming: &Naming, +) -> HashMap { + let mut layers: HashMap = HashMap::new(); + for (name, color) in &converted.layers { + let doc = &scene.document; + let taken = |n: &str| doc.layers.iter().any(|l| l.name.eq_ignore_ascii_case(n)); + let target = naming.name('l', name, &taken); + if !scene.document.layers.iter().any(|l| l.name.eq_ignore_ascii_case(&target)) { + let mut layer = codec::tables::Layer::new(target.as_str()); + layer.handle = scene.document.allocate_handle(); + layer.color = *color; + let _ = scene.document.layers.add(layer); + } + layers.insert(name.clone(), target); + } + let mut styles: HashMap = HashMap::new(); + for (name, font) in &converted.styles { + let doc = &scene.document; + let taken = |n: &str| doc.text_styles.iter().any(|s| s.name.eq_ignore_ascii_case(n)); + let target = naming.name('s', name, &taken); + if !scene.document.text_styles.iter().any(|s| s.name.eq_ignore_ascii_case(&target)) { + let mut style = codec::tables::TextStyle::new(target.as_str()); + style.handle = scene.document.allocate_handle(); + style.font_file = font.clone(); + let _ = scene.document.text_styles.add(style); + } + styles.insert(name.clone(), target); + } + let mut blocks: HashMap = HashMap::new(); + let mut claimed: Vec = Vec::new(); + for (name, _) in &converted.blocks { + let doc = &scene.document; + let taken = |n: &str| { + doc.block_records.iter().any(|b| b.name.eq_ignore_ascii_case(n)) + || claimed.iter().any(|c| c.eq_ignore_ascii_case(n)) + }; + let target = naming.name('b', name, &taken); + claimed.push(target.clone()); + blocks.insert(name.clone(), target); + } + let fix = |mut entity: codec::EntityType| { + let common = entity.common_mut(); + if let Some(layer) = layers.get(&common.layer) { + common.layer = layer.clone(); + } + match &mut entity { + codec::EntityType::Text(t) => { + if let Some(s) = styles.get(&t.style) { + t.style = s.clone(); + } + } + codec::EntityType::MText(t) => { + if let Some(s) = styles.get(&t.style) { + t.style = s.clone(); + } + } + codec::EntityType::Insert(insert) => { + if let Some(b) = blocks.get(&insert.block_name) { + insert.block_name = b.clone(); + } + } + _ => {} + } + entity + }; + for (name, entities) in converted.blocks { + let target = &blocks[&name]; + if scene.document.block_records.get(target).is_some() { + continue; + } + let entities = entities.into_iter().map(&fix).collect(); + let _ = scene.define_block_from_owned_entities(entities, target, glam::DVec3::ZERO); + } + for entity in converted.entities { + scene.add_entity(fix(entity)); + } + layers } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index a1af0cdcd..0a42048f9 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -834,6 +834,7 @@ inventory::submit!(crate::command::CommandRegistration { "SELECTIONCYCLING", "UCSDETECT", "SURFACEASSOCIATIVITY", + "DGNIMPORTMODE", "LAYOUTCREATEVIEWPORT", "TXT2MTXTCOMBINEMTEXT", "TXT2MTXTTEXTORDER", diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index d1147aa5f..40de85256 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1223,6 +1223,7 @@ impl OpenCADStudio { | "SELECTIONCYCLING" | "UCSDETECT" | "SURFACEASSOCIATIVITY" + | "DGNIMPORTMODE" | "UCSORTHO" | "UCSVIEW" | "LAYOUTCREATEVIEWPORT" @@ -1622,6 +1623,41 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if name == "DGNIMPORTMODE" { + // Where DGNIMPORT puts the model: 0 a new drawing, 1 the + // current one keeping its definitions, 2 the current one + // with prefixed names. + use crate::modules::insert::dgn_import::{set_settings, settings}; + let mut s = settings(); + let current = s.mode; + match value + .as_deref() + .map(|v| v.trim().parse::().ok().filter(|v| *v <= 2)) + { + Some(Some(mode)) => { + if current != mode { + s.mode = mode; + set_settings(s); + self.save_config(); + } + } + Some(None) => { + self.command_line + .push_error("Requires an integer between 0 and 2."); + self.command_line.push_output(&format!( + "Enter new value for DGNIMPORTMODE <{current}>:" + )); + self.pending_setvar = Some(name.clone()); + } + None => { + self.command_line.push_output(&format!( + "Enter new value for DGNIMPORTMODE <{current}>:" + )); + self.pending_setvar = Some(name.clone()); + } + } + return Some(self.finish_dispatch(cmd)); + } if name == "SURFACEASSOCIATIVITY" { use crate::scene::model::sweep_model; let current = i16::from(sweep_model::surface_associativity()); diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index 6b4f0b49c..e40e148eb 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -2201,6 +2201,7 @@ impl OpenCADStudio { // The DGN Import window, one field per call: "path=" (as // Change… picks it), "model=", "target=new|current", // "conflict=prefix|ignore", "units=master|sub", "explode=on|off", + // "references=translate|ignore|underlay", "existing=prompt|overwrite|keep", // "mapping=layer|linetype|lineweight|color", "ok" or "cancel". "dgn_import_dialog" => { use crate::ui::window::import_dialogs::ImportDialogMsg as I; @@ -2238,6 +2239,18 @@ impl OpenCADStudio { _ => return Err(bad()), }), "explode" => I::DgnExplode(on(value)?), + "references" => I::DgnReferences(match value { + "translate" => crate::modules::insert::dgn_import::ReferenceMode::Translate, + "ignore" => crate::modules::insert::dgn_import::ReferenceMode::Ignore, + "underlay" => crate::modules::insert::dgn_import::ReferenceMode::Underlay, + _ => return Err(bad()), + }), + "existing" => I::DgnExisting(match value { + "prompt" => crate::modules::insert::dgn_import::ExistingReference::Prompt, + "overwrite" => crate::modules::insert::dgn_import::ExistingReference::Overwrite, + "keep" => crate::modules::insert::dgn_import::ExistingReference::Keep, + _ => return Err(bad()), + }), "mapping" => I::DgnMappingTab(match value { "layer" => 0, "linetype" => 1, diff --git a/src/app/settings.rs b/src/app/settings.rs index 42f4d2f86..d845e47fe 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -519,6 +519,8 @@ pub struct UserSettings { /// PDFSHXTEXT's recognition settings (fonts, threshold, PDFSHXLAYER, /// PDFSHXBESTFONT, PDFSHXTEXTSETTINGS). pub pdf_shx_text: crate::modules::insert::pdf_shx_text::ShxTextSettings, + /// DGNIMPORTMODE and the DGN Import window's last choices. + pub dgn_import: crate::modules::insert::dgn_import::DgnImportSettings, /// Nested-copy symbol handling: false inserts, true binds. pub ncopy_bind: bool, /// Last Options page; unknown saved names fall back without rejecting the config. @@ -999,6 +1001,7 @@ impl Default for UserSettings { ucs_view: true, layout_create_viewport: true, pdf_shx_text: Default::default(), + dgn_import: Default::default(), ncopy_bind: false, cursor_type: CursorType::Crosshair, crosshair_color: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 16bd379cb..3e6338a30 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -864,6 +864,7 @@ impl OpenCADStudio { ucs_view: crate::modules::view::view_cmd::ucs_view(), layout_create_viewport: crate::scene::layout_create_viewport(), pdf_shx_text: crate::modules::insert::pdf_shx_text::settings(), + dgn_import: crate::modules::insert::dgn_import::settings(), ncopy_bind: self.ncopy_bind, cursor_type: self.cursor_type, crosshair_color: self.crosshair_color, @@ -1013,6 +1014,7 @@ impl OpenCADStudio { crate::modules::view::view_cmd::set_ucs_view(s.ucs_view); crate::scene::set_layout_create_viewport(s.layout_create_viewport); crate::modules::insert::pdf_shx_text::set_settings(s.pdf_shx_text.clone()); + crate::modules::insert::dgn_import::set_settings(s.dgn_import); self.ncopy_bind = s.ncopy_bind; self.cursor_type = s.cursor_type; self.crosshair_color = s.crosshair_color; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 8868a5457..863ec5bfb 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3498,7 +3498,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "External DGN references" => Some(("dgn-import", "references")), "Bind as block / xref" => Some(("dgn-import", "bind")), "Attach as DGN underlay" => Some(("dgn-import", "attach")), - "Reference attachments of DGN files are not read; they are left out." => Some(("dgn-import", "references-note")), + "When the translated drawing exists" => Some(("dgn-import", "existing")), + "Prompt to overwrite" => Some(("dgn-import", "prompt-overwrite")), + "Overwrite without prompting" => Some(("dgn-import", "overwrite")), + "Do not overwrite" => Some(("dgn-import", "keep-existing")), "Property mapping" => Some(("dgn-import", "mapping")), "Standard mapping setup." => Some(("dgn-import", "mapping-standard")), "The preview shows the selected setup's effect on this file." => Some(("dgn-import", "mapping-note")), diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index 41b22f575..43a984a01 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -9,17 +9,21 @@ // number when unnamed), colours become the colour index of the same RGB // (else a true colour), weights the lineweights below, V7 line styles // Continuous. Lines, line strings and shapes, arcs, circles and ellipses, -// complex chains and shapes (polylines with arcs), text, text nodes and -// shared cells (blocks) are converted. +// B-spline curves, complex chains and shapes (polylines with arcs), text, +// text nodes and shared cells (blocks) are converted. + +use std::collections::HashMap; use codec::entities::{ - Arc, Circle, Ellipse, Insert, Line, LwPolyline, LwVertex, MText, Text, + Arc, AttachmentPoint, Circle, DrawingDirection, Ellipse, Insert, Line, LineSpacingStyle, + LwPolyline, LwVertex, MText, Point, Spline, Text, TextHorizontalAlignment, + TextVerticalAlignment, }; use codec::types::{Color, LineWeight, Vector2, Vector3}; use codec::EntityType; use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; -use crate::scene::model::underlay_vector::{DgnElement, DgnGeom, DgnImport}; +use crate::scene::model::underlay_vector::{By, DgnElement, DgnGeom, DgnImport, P3}; /// What one import reads and where it goes. #[derive(Clone, Debug, PartialEq)] @@ -32,9 +36,34 @@ pub struct DgnImportRequest { pub explode_text_nodes: bool, /// Into the current drawing (else a new one). pub current_drawing: bool, - /// In the current drawing: layer, block and style names that already - /// exist get the file name as prefix (else the existing ones are used). + /// In the current drawing: every imported layer, block and text style + /// name gets " - $0$" as prefix (else names the drawing + /// already has keep the existing definitions). pub prefix_duplicates: bool, + /// What becomes of the model's reference attachments. + pub references: ReferenceMode, + /// What an earlier translation of a reference already on disk gets. + pub existing_reference: ExistingReference, +} + +/// The External DGN references choice: translated into drawings attached +/// as external references, left out, or attached as DGN underlays. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ReferenceMode { + #[default] + Translate, + Ignore, + Underlay, +} + +/// A translated reference drawing that already exists: asked about, +/// overwritten, or kept. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum ExistingReference { + #[default] + Prompt, + Overwrite, + Keep, } /// The objects of an import: layers (name, colour), text styles (name, font @@ -89,37 +118,89 @@ fn layer_name(import: &DgnImport, level: u32) -> String { .unwrap_or_else(|| level.to_string()) } +/// A cell's name as a block name: a leading `*` (an anonymous block +/// written out) is dropped. +pub fn block_name(cell: &str) -> String { + cell.trim_start_matches('*').to_string() +} + +fn v3(p: P3) -> Vector3 { + Vector3::new(p[0], p[1], p[2]) +} + +fn sub(a: P3, b: P3) -> P3 { + [a[0] - b[0], a[1] - b[1], a[2] - b[2]] +} + +fn dot(a: P3, b: P3) -> f64 { + a[0] * b[0] + a[1] * b[1] + a[2] * b[2] +} + +fn cross(a: P3, b: P3) -> P3 { + [ + a[1] * b[2] - a[2] * b[1], + a[2] * b[0] - a[0] * b[2], + a[0] * b[1] - a[1] * b[0], + ] +} + +fn norm(a: P3) -> f64 { + dot(a, a).sqrt() +} + /// True when the axes `u` and `v` are of one length and at right angles. -fn circular(u: [f64; 2], v: [f64; 2]) -> bool { - let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); - (lu - lv).abs() <= 1e-9 * lu.max(1.0) && (u[0] * v[0] + u[1] * v[1]).abs() <= 1e-9 * lu * lv +fn circular(u: P3, v: P3) -> bool { + let (lu, lv) = (norm(u), norm(v)); + (lu - lv).abs() <= 1e-9 * lu.max(1.0) && dot(u, v).abs() <= 1e-9 * lu * lv } /// The arc's points at `n` steps. -fn arc_points(center: [f64; 2], u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> Vec<[f64; 2]> { +fn arc_points(center: P3, u: P3, v: P3, start: f64, sweep: f64) -> Vec { let n = ((sweep.abs() / (std::f64::consts::PI / 32.0)).ceil() as usize).clamp(2, 4096); (0..=n) .map(|k| { let t = start + sweep * k as f64 / n as f64; - [ - center[0] + t.cos() * u[0] + t.sin() * v[0], - center[1] + t.cos() * u[1] + t.sin() * v[1], - ] + std::array::from_fn(|c| center[c] + t.cos() * u[c] + t.sin() * v[c]) }) .collect() } -/// A circular arc as centre, radius, start angle and signed sweep in the -/// drawing's angles. -fn circle_angles(u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> (f64, f64, f64) { - let side = (u[0] * v[1] - u[1] * v[0]).signum(); - let a0 = u[1].atan2(u[0]); - (u[0].hypot(u[1]), a0 + side * start, side * sweep) +/// The plane of an arc: its normal (+z for an arc in the drawing plane, +/// whichever way it turns) and whether its parameter turns clockwise about +/// that normal. +fn arc_plane(u: P3, v: P3) -> (P3, bool) { + let n = cross(u, v); + let len = norm(n); + if len < 1e-12 { + return ([0.0, 0.0, 1.0], false); + } + let n = n.map(|c| c / len); + if n[2] < -1.0 + 1e-9 { + ([0.0, 0.0, 1.0], true) + } else { + (n, false) + } } -fn polyline(points: &[[f64; 2]], bulges: &[f64]) -> EntityType { +/// The angle of `d` in the entity coordinate system of `normal`. +fn ocs_angle(normal: P3, d: P3) -> f64 { + let m = codec::types::Matrix3::arbitrary_axis(v3(normal)); + let (x, y) = ( + m * Vector3::new(1.0, 0.0, 0.0), + m * Vector3::new(0.0, 1.0, 0.0), + ); + (d[0] * y.x + d[1] * y.y + d[2] * y.z).atan2(d[0] * x.x + d[1] * x.y + d[2] * x.z) +} + +/// A point in the entity coordinate system of `normal`. +fn to_ocs(normal: P3, p: P3) -> Vector3 { + codec::types::Matrix3::arbitrary_axis(v3(normal)).transpose() * v3(p) +} + +fn polyline(points: &[P3], bulges: &[f64]) -> EntityType { let mut pl = LwPolyline::new(); pl.plinegen = true; + pl.elevation = points.first().map_or(0.0, |p| p[2]); pl.vertices = points .iter() .enumerate() @@ -134,10 +215,10 @@ fn polyline(points: &[[f64; 2]], bulges: &[f64]) -> EntityType { /// A complex chain's vertices and bulges: lines and line strings add their /// points, circular arcs a bulge, other arcs their points. -fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { - let mut points: Vec<[f64; 2]> = Vec::new(); +fn chain(parts: &[DgnGeom]) -> (Vec, Vec) { + let mut points: Vec = Vec::new(); let mut bulges: Vec = Vec::new(); - let mut add = |p: [f64; 2], bulge_before: Option| { + let mut add = |p: P3, bulge_before: Option| { let same = points .last() .is_some_and(|q| (q[0] - p[0]).abs() < 1e-9 && (q[1] - p[1]).abs() < 1e-9); @@ -163,15 +244,12 @@ fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { start, sweep, } if circular(*u, *v) => { - let at = |t: f64| { - [ - center[0] + t.cos() * u[0] + t.sin() * v[0], - center[1] + t.cos() * u[1] + t.sin() * v[1], - ] + let at = |t: f64| -> P3 { + std::array::from_fn(|c| center[c] + t.cos() * u[c] + t.sin() * v[c]) }; - let (_, _, signed) = circle_angles(*u, *v, *start, *sweep); + let side = if arc_plane(*u, *v).1 { -1.0 } else { 1.0 }; add(at(*start), None); - add(at(start + sweep), Some((signed / 4.0).tan())); + add(at(start + sweep), Some((side * sweep / 4.0).tan())); } DgnGeom::Arc { center, @@ -188,24 +266,84 @@ fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { (points, bulges) } -fn text_entity(geom: &DgnGeom, style: &str) -> Option { - let DgnGeom::Text { - text, - origin, - height, - width_factor, - rotation, - .. - } = geom - else { - return None; +/// Horizontal (0 left, 1 centre, 2 right) and vertical (0 top, 1 middle, +/// 2 baseline, 3 descender) parts of a DGN justification code. +fn just_parts(just: u8) -> (u8, u8) { + match just { + 0..=2 => (0, just), + 3..=5 => (0, just - 3), + 6..=8 => (1, just - 6), + 9..=11 => (2, just - 9), + 12..=14 => (2, just - 12), + 16 => (0, 3), + 20 => (1, 3), + 24 => (2, 3), + _ => (0, 2), + } +} + +/// The justification point of a text: its lower left moved along the +/// baseline by the part of its length, up by the part of its height (a +/// descender justification 0.3 heights below the baseline). +fn just_point(origin: P3, rotation: f64, length: f64, height: f64, just: u8) -> P3 { + let (h, v) = just_parts(just); + let along = length * h as f64 / 2.0; + let up = match v { + // Centre top is placed half a height up, as the reference places it. + 0 if just == 6 => height / 2.0, + 0 => height, + 1 => height / 2.0, + 2 => 0.0, + _ => -0.3 * height, }; - let mut t = Text::with_value(text.clone(), Vector3::new(origin[0], origin[1], 0.0)) - .with_height(*height) - .with_rotation(*rotation); - t.width_factor = if *width_factor > 0.0 { *width_factor } else { 1.0 }; - t.style = style.to_string(); - Some(EntityType::Text(t)) + let (c, s) = (rotation.cos(), rotation.sin()); + [ + origin[0] + along * c - up * s, + origin[1] + along * s + up * c, + origin[2], + ] +} + +fn attachment(just: u8) -> AttachmentPoint { + use AttachmentPoint as A; + match just_parts(just) { + (0, 0) => A::TopLeft, + (1, 0) => A::TopCenter, + (2, 0) => A::TopRight, + (0, 1) => A::MiddleLeft, + (1, 1) => A::MiddleCenter, + (2, 1) => A::MiddleRight, + (1, _) => A::BottomCenter, + (2, _) => A::BottomRight, + _ => A::BottomLeft, + } +} + +/// A number with six significant digits, trailing zeros dropped. +fn sig6(x: f64) -> String { + let digits = if x == 0.0 { + 0 + } else { + (5 - x.abs().log10().floor() as i32).max(0) as usize + }; + let s = format!("{x:.digits$}"); + if s.contains('.') { + s.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + s + } +} + +/// The colour code a V8 text carries in its contents: 256 for the level's +/// colour, 0 for the cell's, else the colour index nearest its table entry. +fn color_code(color: By) -> u16 { + match color { + By::Level => 256, + By::Cell => 0, + By::Own(c) => { + codec::types::aci_table::nearest_aci(c.table[0], c.table[1], c.table[2]) as u16 + } + } } /// The text style of a line of text (V7: one per font number, "dgnNNN"), @@ -225,223 +363,530 @@ fn text_style(geom: &DgnGeom, styles: &mut Vec<(String, String)>) -> String { } } -/// Lines of text as one MTEXT, a paragraph a line, as the reference -/// converts them: a text node attached at its top left (a height above the -/// first baseline), a V8 text at its bottom left a third of the height -/// below its baseline. -fn mtext(lines: &[DgnGeom], style: String, node: bool) -> Option { - let Some(DgnGeom::Text { +/// A V7 text as TEXT, justified at its justification point. +fn text_entity(geom: &DgnGeom, style: &str) -> Option { + let DgnGeom::Text { + text, origin, height, + width_factor, + length, rotation, + just, .. - }) = lines.first() + } = geom + else { + return None; + }; + let mut t = Text::with_value(text.clone(), v3(*origin)) + .with_height(*height) + .with_rotation(rotation.rem_euclid(std::f64::consts::TAU)); + t.width_factor = if *width_factor > 0.0 { *width_factor } else { 1.0 }; + t.style = style.to_string(); + let (h, v) = just_parts(*just); + if (h, v) != (0, 2) { + t.horizontal_alignment = match h { + 1 => TextHorizontalAlignment::Center, + 2 => TextHorizontalAlignment::Right, + _ => TextHorizontalAlignment::Left, + }; + t.vertical_alignment = match v { + 0 => TextVerticalAlignment::Top, + 1 => TextVerticalAlignment::Middle, + 3 => TextVerticalAlignment::Bottom, + _ => TextVerticalAlignment::Baseline, + }; + t.alignment_point = Some(v3(just_point(*origin, *rotation, *length, *height, *just))); + } + Some(EntityType::Text(t)) +} + +fn text_line(g: &DgnGeom) -> Option<(&str, P3, f64, f64, f64)> { + match g { + DgnGeom::Text { + text, + origin, + height, + rotation, + width_factor, + .. + } => Some((text.as_str(), *origin, *height, *rotation, *width_factor)), + _ => None, + } +} + +/// A V8 text as MTEXT, as the reference converts it: a bottom left text +/// attached at its bottom left a third of the height below the baseline +/// (exact line spacing 0.8), any other at its justification point; its +/// contents carry its colour (and width factor). +fn mtext_of_text(geom: &DgnGeom, color: u16) -> Option { + let DgnGeom::Text { + text, + origin, + height, + width_factor, + length, + rotation, + just, + .. + } = geom else { return None; }; let mut m = MText::new(); - m.value = lines - .iter() - .filter_map(|g| match g { - DgnGeom::Text { text, .. } => Some(text.clone()), - _ => None, - }) - .collect::>() - .join("\\P"); - let up = if node { *height } else { -0.33 * height }; - m.insertion_point = Vector3::new( - origin[0] - rotation.sin() * up, - origin[1] + rotation.cos() * up, - 0.0, - ); - if !node { - m.attachment_point = codec::entities::AttachmentPoint::BottomLeft; + let width = if (width_factor - 1.0).abs() > 1e-6 { + format!("\\W{};", sig6(*width_factor)) + } else { + String::new() + }; + m.value = format!("{{{width}\\C{color};{text}}}"); + if *just == 2 { + let up = -0.33 * height; + m.insertion_point = Vector3::new( + origin[0] - rotation.sin() * up, + origin[1] + rotation.cos() * up, + origin[2], + ); + m.attachment_point = AttachmentPoint::BottomLeft; + m.line_spacing_style = LineSpacingStyle::Exactly; m.line_spacing_factor = 0.8; + } else { + m.insertion_point = v3(just_point(*origin, *rotation, *length, *height, *just)); + m.attachment_point = attachment(*just); + m.line_spacing_style = LineSpacingStyle::AtLeast; + m.line_spacing_factor = 1.0; } m.height = *height; - m.rotation = *rotation; + m.rectangle_height = Some(*height); + m.rotation = rotation.rem_euclid(std::f64::consts::TAU); m.rectangle_width = 0.0; + m.drawing_direction = DrawingDirection::ByStyle; + m.style = "Standard".to_string(); + Some(EntityType::MText(m)) +} + +/// A text node as one MTEXT, a paragraph a line, attached at the node's +/// origin by its justification. V8: its contents carry its colour, its +/// defined height spans its lines; V7: exact spacing of the node's line +/// spacing. +fn mtext_of_node( + lines: &[DgnGeom], + origin: Option, + just: u8, + line_spacing: Option, + style: String, + color: Option, +) -> Option { + let (_, first, height, rotation, _) = lines.iter().find_map(text_line)?; + let texts: Vec<&str> = lines.iter().filter_map(text_line).map(|l| l.0).collect(); + let mut m = MText::new(); + let joined = texts.join("\\P"); + m.value = match color { + Some(c) => format!("{{\\C{c};{joined}}}"), + None => joined, + }; + let up = [-rotation.sin(), rotation.cos(), 0.0]; + let (just, insertion) = match origin { + Some(o) => (just, o), + None => (0, std::array::from_fn(|c| first[c] + up[c] * height)), + }; + m.insertion_point = v3(insertion); + m.attachment_point = attachment(just); + match line_spacing { + // V7: the gap between lines over the default pitch (5/3 heights). + Some(gap) => { + m.line_spacing_style = LineSpacingStyle::Exactly; + m.line_spacing_factor = if height > 0.0 { + (height + gap) / (height * 5.0 / 3.0) + } else { + 1.0 + }; + m.rectangle_height = None; + } + None => { + let last = lines.iter().filter_map(text_line).last().map_or(first, |l| l.1); + m.line_spacing_style = LineSpacingStyle::AtLeast; + m.line_spacing_factor = 1.0; + m.rectangle_height = Some(height + dot(sub(first, last), up).max(0.0)); + } + } + m.height = height; + m.rotation = rotation.rem_euclid(std::f64::consts::TAU); + m.rectangle_width = 0.0; + m.drawing_direction = DrawingDirection::ByStyle; m.style = style; Some(EntityType::MText(m)) } -/// The object(s) one element converts to. -fn convert_geom( - geom: &DgnGeom, - explode_text_nodes: bool, - styles: &mut Vec<(String, String)>, -) -> Vec { - match geom { - DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points( - Vector3::new(a[0], a[1], 0.0), - Vector3::new(b[0], b[1], 0.0), - ))], - DgnGeom::Points(pts) if pts.len() >= 2 => vec![polyline(pts, &[])], - DgnGeom::Points(_) => Vec::new(), - DgnGeom::Arc { - center, - u, - v, - start, - sweep, - } => { - let c = Vector3::new(center[0], center[1], 0.0); - let full = sweep.abs() >= std::f64::consts::TAU - 1e-9; - if circular(*u, *v) { - let (radius, a0, signed) = circle_angles(*u, *v, *start, *sweep); - if full { - return vec![EntityType::Circle(Circle::from_center_radius(c, radius))]; - } - let (from, to) = if signed >= 0.0 { - (a0, a0 + signed) - } else { - (a0 + signed, a0) - }; - return vec![EntityType::Arc(Arc::from_center_radius_angles( - c, - radius, - from.rem_euclid(std::f64::consts::TAU), - to.rem_euclid(std::f64::consts::TAU), - ))]; - } - let (lu, lv) = (u[0].hypot(u[1]), v[0].hypot(v[1])); - if (u[0] * v[0] + u[1] * v[1]).abs() > 1e-9 * lu * lv || lu < 1e-12 || lv < 1e-12 { - // ponytail: a skewed plan projection (a 3D ellipse seen - // obliquely) becomes points; find its principal axes if - // such files matter. - return vec![polyline(&arc_points(*center, *u, *v, *start, *sweep), &[])]; - } - // P(t) = c + cos t·u + sin t·v as an ellipse on its major axis - // with the minor axis turned counter-clockwise from it. - let ccw = u[0] * v[1] - u[1] * v[0] > 0.0; - let (major, ratio, map): (_, f64, Box f64>) = if lu >= lv { - if ccw { - (*u, lv / lu, Box::new(|t| t)) - } else { - (*u, lv / lu, Box::new(|t: f64| -t)) - } - } else if ccw { - (*v, lu / lv, Box::new(|t: f64| t - std::f64::consts::FRAC_PI_2)) - } else { - (*v, lu / lv, Box::new(|t: f64| std::f64::consts::FRAC_PI_2 - t)) - }; - let mut e = Ellipse::from_center_axes(c, Vector3::new(major[0], major[1], 0.0), ratio); - if !full { - let (p0, p1) = (map(*start), map(start + sweep)); - let (from, to) = if p1 >= p0 { (p0, p1) } else { (p1, p0) }; - e.start_parameter = from.rem_euclid(std::f64::consts::TAU); - e.end_parameter = e.start_parameter + (to - from); - } - vec![EntityType::Ellipse(e)] - } - DgnGeom::Chain(parts) => { - let (points, bulges) = chain(parts); - if points.len() >= 2 { - vec![polyline(&points, &bulges)] - } else { - Vec::new() - } - } - // V7 text is TEXT, V8 text an MTEXT. - DgnGeom::Text { font: Some(_), .. } => { - let style = text_style(geom, styles); - text_entity(geom, &style).into_iter().collect() - } - DgnGeom::Text { .. } => { - let style = text_style(geom, styles); - mtext(std::slice::from_ref(geom), style, false).into_iter().collect() - } - // Exploded, each line converts as a text element of its own. - DgnGeom::TextNode(lines) if explode_text_nodes => lines - .iter() - .flat_map(|g| convert_geom(g, false, styles)) - .collect(), - DgnGeom::TextNode(lines) => { - let Some(first) = lines.first() else { - return Vec::new(); - }; - let style = text_style(first, styles); - mtext(lines, style, true).into_iter().collect() - } - DgnGeom::Cell { - name, - m, - origin, - z_scale, - } => { - let sx = m[0].hypot(m[2]); - let det = m[0] * m[3] - m[1] * m[2]; - let sy = if sx > 0.0 { det / sx } else { 1.0 }; - let insert = Insert::new(name.clone(), Vector3::new(origin[0], origin[1], 0.0)) - .with_scale(sx, sy, *z_scale) - .with_rotation(m[2].atan2(m[0])); - vec![EntityType::Insert(insert)] +/// An arc, circle or ellipse in its own plane. +fn arc_entity(center: P3, u: P3, v: P3, start: f64, sweep: f64) -> Vec { + let full = sweep.abs() >= std::f64::consts::TAU - 1e-9; + let (normal, flipped) = arc_plane(u, v); + let at = |t: f64| -> P3 { std::array::from_fn(|c| t.cos() * u[c] + t.sin() * v[c]) }; + if circular(u, v) { + let radius = norm(u); + let c = to_ocs(normal, center); + if full { + let mut circle = Circle::from_center_radius(c, radius); + circle.normal = v3(normal); + return vec![EntityType::Circle(circle)]; } + let (a0, a1) = (ocs_angle(normal, at(start)), ocs_angle(normal, at(start + sweep))); + // The arc runs counter-clockwise about its normal. + let ccw = (sweep > 0.0) != flipped; + let (from, to) = if ccw { (a0, a1) } else { (a1, a0) }; + let mut arc = Arc::from_center_radius_angles( + c, + radius, + from.rem_euclid(std::f64::consts::TAU), + to.rem_euclid(std::f64::consts::TAU), + ); + arc.normal = v3(normal); + return vec![EntityType::Arc(arc)]; } + let (lu, lv) = (norm(u), norm(v)); + if dot(u, v).abs() > 1e-9 * lu * lv || lu < 1e-12 || lv < 1e-12 { + // ponytail: axes not at right angles cannot come out of one plane's + // ellipse; kept as points. + return vec![polyline(&arc_points(center, u, v, start, sweep), &[])]; + } + // P(t) = c + cos t·u + sin t·v as an ellipse on its major axis with the + // minor axis turned counter-clockwise from it about the normal. + let ccw = !flipped; + let (major, ratio, map): (P3, f64, Box f64>) = if lu >= lv { + if ccw { + (u, lv / lu, Box::new(|t| t)) + } else { + (u, lv / lu, Box::new(|t: f64| -t)) + } + } else if ccw { + (v, lu / lv, Box::new(|t: f64| t - std::f64::consts::FRAC_PI_2)) + } else { + (v, lu / lv, Box::new(|t: f64| std::f64::consts::FRAC_PI_2 - t)) + }; + let mut e = Ellipse::from_center_axes(v3(center), v3(major), ratio); + e.normal = v3(normal); + if !full { + let (p0, p1) = (map(start), map(start + sweep)); + let (from, to) = if p1 >= p0 { (p0, p1) } else { (p1, p0) }; + e.start_parameter = from.rem_euclid(std::f64::consts::TAU); + e.end_parameter = e.start_parameter + (to - from); + } + vec![EntityType::Ellipse(e)] } -fn convert_elements( - import: &DgnImport, - elements: &[DgnElement], +/// A shared cell instance as an INSERT: a mirrored placement looks along +/// -z (x negated in its entity coordinates, z scale negated), as the +/// reference places it. +fn insert_entity(block: String, m: [f64; 4], origin: P3, z_scale: f64) -> EntityType { + let det = m[0] * m[3] - m[1] * m[2]; + let mirrored = det < 0.0; + let m = if mirrored { [-m[0], -m[1], m[2], m[3]] } else { m }; + let sx = m[0].hypot(m[2]); + let sy = if sx > 0.0 { (m[0] * m[3] - m[1] * m[2]) / sx } else { 1.0 }; + let (point, z) = if mirrored { + (Vector3::new(-origin[0], origin[1], -origin[2]), -z_scale) + } else { + (v3(origin), z_scale) + }; + let mut insert = Insert::new(block, point) + .with_scale(sx, sy, z) + .with_rotation(m[2].atan2(m[0])); + if mirrored { + insert.normal = Vector3::new(0.0, 0.0, -1.0); + } + EntityType::Insert(insert) +} + +/// How a run of elements converts: a V7 cell copy turns every weight (and +/// a cell of one colour its colour) over to the inserting block. +#[derive(Clone, Copy, Default)] +struct CellCopy { + active: bool, + color_by_block: bool, +} + +/// Conversion state: the output, the V7 cell copies made so far and the +/// definitions they copy. +struct Converter<'a> { + import: &'a DgnImport, explode_text_nodes: bool, - in_cell: bool, - out: &mut Converted, -) -> Vec { - let mut entities = Vec::new(); - for element in elements { - // A cell's objects are on layer 0 (V7: by block), as the reference - // defines the block. + out: Converted, + defs: HashMap, + /// (cell, made inside a copy) → copy block name. + copies: HashMap<(String, bool), String>, + names: Vec, +} + +impl<'a> Converter<'a> { + /// `base`, or `base` and the first number that makes it a new name. + fn unique(&mut self, base: &str) -> String { + let taken = |n: &str| self.names.iter().any(|t| t.eq_ignore_ascii_case(n)); + let name = if taken(base) { + (1..) + .map(|k| format!("{base}{k}")) + .find(|n| !taken(n)) + .unwrap_or_default() + } else { + base.to_string() + }; + self.names.push(name.clone()); + name + } + + /// Every colour index a cell draws with, through the cells it places. + fn cell_colors(&self, name: &str, depth: usize, out: &mut Vec) { + let Some(def) = self.defs.get(&name.to_ascii_uppercase()) else { + return; + }; + for e in def.iter() { + if let By::Own(c) = e.color { + out.push(c.index); + } + if let (DgnGeom::Cell { name, .. }, true) = (&e.geom, depth < 8) { + self.cell_colors(name, depth + 1, out); + } + } + } + + /// The block a V7 instance of `cell` inserts: a copy of the definition + /// whose weights (and, when the whole cell is one colour, colour) come + /// from the INSERT, one per definition placed at the top or inside + /// definitions and another for those inside copies. + fn v7_copy(&mut self, cell: &str, in_copy: bool, depth: usize) -> String { + let key = (cell.to_ascii_uppercase(), in_copy); + if let Some(name) = self.copies.get(&key) { + return name.clone(); + } + let name = self.unique(&block_name(cell)); + self.copies.insert(key, name.clone()); + let Some(def) = self.defs.get(&cell.to_ascii_uppercase()).copied() else { + return name; + }; + let mut colors = Vec::new(); + self.cell_colors(cell, 0, &mut colors); + let copy = CellCopy { + active: true, + color_by_block: colors.windows(2).all(|w| w[0] == w[1]), + }; + let entities = self.elements(def, true, copy, depth + 1); + self.out.blocks.push((name.clone(), entities)); + name + } + + fn layer(&mut self, element: &DgnElement, in_cell: bool) -> String { + // A cell's objects are on layer 0, as the reference defines the + // block. let layer = if in_cell { "0".to_string() } else { - layer_name(import, element.level) + layer_name(self.import, element.level) }; - if !out.layers.iter().any(|l| l.0 == layer) { - let color = import + if !self.out.layers.iter().any(|l| l.0 == layer) { + let color = self + .import .levels .get(&element.level) .and_then(|l| l.1) + .filter(|_| !in_cell) .map(color_of) .unwrap_or(Color::Index(7)); - out.layers.push((layer.clone(), color)); + self.out.layers.push((layer.clone(), color)); } - for mut entity in convert_geom(&element.geom, explode_text_nodes, &mut out.styles) { - let common = entity.common_mut(); - common.layer = layer.clone(); - common.color = element.color.map(color_of).unwrap_or(Color::ByLayer); - common.line_weight = element - .weight - .map(|w| LineWeight::Value(lineweight_of(w))) - .unwrap_or(LineWeight::ByLayer); - // The Standard mapping draws every line style Continuous. - if element.style.is_some() { - common.linetype = "Continuous".to_string(); + layer + } + + /// The object(s) one element converts to. + fn geom( + &mut self, + element: &DgnElement, + geom: &DgnGeom, + copy: CellCopy, + depth: usize, + ) -> Vec { + match geom { + DgnGeom::Line(a, b) if dot(sub(*a, *b), sub(*a, *b)) < 1e-24 => { + vec![EntityType::Point(Point::from_coords(a[0], a[1], a[2]))] } - if import.v7 && in_cell { - common.color = Color::ByBlock; - common.line_weight = LineWeight::ByBlock; + DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points(v3(*a), v3(*b)))], + DgnGeom::Points(pts) if pts.len() >= 2 => vec![polyline(pts, &[])], + DgnGeom::Points(_) => Vec::new(), + DgnGeom::Arc { + center, + u, + v, + start, + sweep, + } => arc_entity(*center, *u, *v, *start, *sweep), + DgnGeom::Spline { + degree, + knots, + poles, + closed, + } => { + let mut s = Spline::new(); + s.degree = *degree as i32; + s.knots = knots.clone(); + s.control_points = poles.iter().map(|p| v3(*p)).collect(); + s.flags.closed = *closed; + // A V8 closed curve is periodic (its knots do not start at + // 0); a V7 one is clamped. + s.flags.periodic = *closed && knots.first().is_some_and(|k| *k > 0.0); + s.flags.planar = true; + vec![EntityType::Spline(s)] + } + DgnGeom::Chain(parts) => { + let (points, bulges) = chain(parts); + if points.len() >= 2 { + vec![polyline(&points, &bulges)] + } else { + Vec::new() + } + } + // V7 text is TEXT, V8 text an MTEXT. + DgnGeom::Text { font: Some(_), .. } => { + let style = text_style(geom, &mut self.out.styles); + text_entity(geom, &style).into_iter().collect() + } + DgnGeom::Text { .. } => mtext_of_text(geom, color_code(element.color)) + .into_iter() + .collect(), + // Exploded, each line converts as a text element of its own. + DgnGeom::TextNode { lines, .. } if self.explode_text_nodes => lines + .iter() + .flat_map(|g| self.geom(element, g, copy, depth)) + .collect(), + DgnGeom::TextNode { + lines, + origin, + just, + line_spacing, + } => { + let Some(first) = lines.first() else { + return Vec::new(); + }; + let style = text_style(first, &mut self.out.styles); + let color = (!self.import.v7).then(|| color_code(element.color)); + mtext_of_node(lines, *origin, *just, *line_spacing, style, color) + .into_iter() + .collect() + } + // A cell that is not shared becomes a block of its own ("the + // cell's name, else AnonymousCell" and a number) placed at the + // origin; its objects keep their layers. + DgnGeom::CellGroup { name, parts } => { + let base = if name.is_empty() { "AnonymousCell" } else { name.as_str() }; + let block = self.unique(&block_name(base)); + let entities = self.elements(parts, false, copy, depth + 1); + self.out.blocks.push((block.clone(), entities)); + vec![insert_entity(block, [1.0, 0.0, 0.0, 1.0], [0.0; 3], 1.0)] + } + DgnGeom::Cell { + name, + m, + origin, + z_scale, + } => { + let block = if self.import.v7 && depth < 8 { + self.v7_copy(name, copy.active, depth) + } else { + block_name(name) + }; + vec![insert_entity(block, *m, *origin, *z_scale)] } - entities.push(entity); } } - entities + + fn elements( + &mut self, + elements: &[DgnElement], + in_cell: bool, + copy: CellCopy, + depth: usize, + ) -> Vec { + let mut entities = Vec::new(); + for element in elements { + let layer = self.layer(element, in_cell); + for mut entity in self.geom(element, &element.geom, copy, depth) { + let common = entity.common_mut(); + common.layer = layer.clone(); + common.color = match element.color { + By::Level => Color::ByLayer, + By::Cell => Color::ByBlock, + By::Own(c) => color_of(c.rgb), + }; + common.line_weight = match element.weight { + By::Level => LineWeight::ByLayer, + By::Cell => LineWeight::ByBlock, + By::Own(w) => LineWeight::Value(lineweight_of(w)), + }; + // The Standard mapping draws every line style Continuous. + match element.style { + By::Own(_) => common.linetype = "Continuous".to_string(), + By::Cell => common.linetype = "ByBlock".to_string(), + By::Level => {} + } + if copy.active { + common.line_weight = LineWeight::ByBlock; + if copy.color_by_block { + common.color = Color::ByBlock; + } + } + entities.push(entity); + } + } + entities + } } -/// The import's objects, in sub units when `sub_units`. +/// The import's objects, in sub units when `sub_units`. V7 cell +/// definitions become blocks as drawn; each is placed through a copy (the +/// definition's name and a number), made before the blocks that place it. V8 cells are blocks placed as they +/// are. A V8 file's levels all become layers, in its level table's order. pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> Converted { let k = if sub_units { import.sub_per_master } else { 1.0 }; let scale = |elements: &[DgnElement]| { crate::scene::model::underlay_vector::scaled_elements(elements, k, [0.0, 0.0]) }; - let mut out = Converted::default(); - for (name, elements) in &import.cells { - let entities = convert_elements(import, &scale(elements), explode_text_nodes, true, &mut out); - if !name.is_empty() && !entities.is_empty() { - out.blocks.push((name.clone(), entities)); + let cells: Vec<(String, Vec)> = import + .cells + .iter() + .map(|(name, elements)| (name.clone(), scale(elements))) + .collect(); + let top = scale(&import.elements); + let mut c = Converter { + import, + explode_text_nodes, + out: Converted::default(), + defs: cells + .iter() + .map(|(name, elements)| (name.to_ascii_uppercase(), elements.as_slice())) + .collect(), + copies: HashMap::new(), + names: cells.iter().map(|(name, _)| block_name(name)).collect(), + }; + if !import.v7 { + for level in &import.level_order { + let element = DgnElement { + level: *level, + color: By::Level, + weight: By::Level, + style: By::Level, + geom: DgnGeom::Points(Vec::new()), + }; + c.layer(&element, false); } } - out.entities = convert_elements(import, &scale(&import.elements), explode_text_nodes, false, &mut out); - out + for (name, elements) in &cells { + let entities = c.elements(elements, true, CellCopy::default(), 0); + // Every named cell becomes a block, even one with nothing it draws. + if !name.is_empty() { + c.out.blocks.push((block_name(name), entities)); + } + } + let entities = c.elements(&top, false, CellCopy::default(), 0); + c.out.entities = entities; + c.out } /// The Standard mapping of what a file uses: level → layer, line style → @@ -464,31 +909,69 @@ pub fn mapping_rows(import: &DgnImport, tab: usize) -> Vec<(String, String)> { add(name.clone(), name); } 1 => match e.style { - Some(s) => add(s.to_string(), "Continuous".to_string()), - None => add("ByLevel".to_string(), "ByLayer".to_string()), + By::Own(s) => add(s.to_string(), "Continuous".to_string()), + By::Cell => add("ByCell".to_string(), "ByBlock".to_string()), + By::Level => add("ByLevel".to_string(), "ByLayer".to_string()), }, 2 => match e.weight { - Some(w) => add( + By::Own(w) => add( w.to_string(), format!("{:.2} mm", lineweight_of(w) as f64 / 100.0), ), - None => add("ByLevel".to_string(), "ByLayer".to_string()), + By::Cell => add("ByCell".to_string(), "ByBlock".to_string()), + By::Level => add("ByLevel".to_string(), "ByLayer".to_string()), }, _ => match e.color { - Some(rgb) => add( - format!("RGB {},{},{}", rgb[0], rgb[1], rgb[2]), - match color_of(rgb) { - Color::Index(i) => i.to_string(), - _ => format!("{},{},{}", rgb[0], rgb[1], rgb[2]), - }, - ), - None => add("ByLevel".to_string(), "ByLayer".to_string()), + By::Own(c) => { + let rgb = c.rgb; + add( + format!("RGB {},{},{}", rgb[0], rgb[1], rgb[2]), + match color_of(rgb) { + Color::Index(i) => i.to_string(), + _ => format!("{},{},{}", rgb[0], rgb[1], rgb[2]), + }, + ) + } + By::Cell => add("ByCell".to_string(), "ByBlock".to_string()), + By::Level => add("ByLevel".to_string(), "ByLayer".to_string()), }, } } rows } +// ── Settings ─────────────────────────────────────────────────────────────── + +/// The import settings kept in the profile: DGNIMPORTMODE (0 a new +/// drawing, 1 the current drawing keeping its own definitions, 2 the +/// current drawing with prefixed names) and the DGN Import window's last +/// choices. +#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(default)] +pub struct DgnImportSettings { + pub mode: u8, + pub explode_text_nodes: bool, + pub references: ReferenceMode, + pub existing_reference: ExistingReference, +} + +static SETTINGS: std::sync::Mutex> = std::sync::Mutex::new(None); + +pub fn settings() -> DgnImportSettings { + SETTINGS + .lock() + .ok() + .and_then(|s| *s) + .unwrap_or_default() +} + +pub fn set_settings(mut settings: DgnImportSettings) { + settings.mode = settings.mode.min(2); + if let Ok(mut s) = SETTINGS.lock() { + *s = Some(settings); + } +} + // ── Command line ─────────────────────────────────────────────────────────── #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -531,11 +1014,8 @@ impl DgnImportCommand { if path.is_empty() { return CmdResult::NeedPoint; } - let models = crate::scene::model::underlay_vector::item_names( - codec::entities::UnderlayType::Dgn, - &path, - ) - .filter(|m| !m.is_empty() && crate::scene::model::underlay_vector::dgn_import(&path, &m[0]).is_some()); + let models = crate::scene::model::underlay_vector::dgn_import_models(&path) + .filter(|m| !m.is_empty() && crate::scene::model::underlay_vector::dgn_import(&path, &m[0]).is_some()); let Some(models) = models else { return CmdResult::ReportError("Invalid or unsupported DGN file.".to_string()); }; @@ -598,13 +1078,18 @@ impl DgnImportCommand { if !text.is_empty() && !text.eq_ignore_ascii_case("Standard") { return CmdResult::ReportError(format!("{text} not found.")); } + // The rest comes from DGNIMPORTMODE and the import settings the + // window last kept. + let s = settings(); CmdResult::DgnImport(DgnImportRequest { path: self.path.clone(), model: self.model.clone(), sub_units: self.sub_units, - explode_text_nodes: false, - current_drawing: false, - prefix_duplicates: false, + explode_text_nodes: s.explode_text_nodes, + current_drawing: s.mode != 0, + prefix_duplicates: s.mode == 2, + references: s.references, + existing_reference: s.existing_reference, }) } } diff --git a/src/modules/insert/xattach.rs b/src/modules/insert/xattach.rs index c98005b87..2a2106982 100644 --- a/src/modules/insert/xattach.rs +++ b/src/modules/insert/xattach.rs @@ -651,7 +651,17 @@ pub fn prepare_xref_definition( request: &XrefAttachRequest, host_file: Option<&std::path::Path>, ) -> String { - let stem = path_to_block_name(&request.path); + prepare_named_xref_definition(scene, request, host_file, &path_to_block_name(&request.path)) +} + +/// [`prepare_xref_definition`] under a chosen name instead of the file's. +pub fn prepare_named_xref_definition( + scene: &mut Scene, + request: &XrefAttachRequest, + host_file: Option<&std::path::Path>, + stem: &str, +) -> String { + let stem = stem.to_string(); if let Some(existing) = existing_reference(scene, &stem) { return existing; } diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index e2205e473..dcbb0345b 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -6,8 +6,8 @@ use std::collections::HashMap; use super::model::{ - arc_cubics, bspline_points, dgn_color_table, paths_bounds, scaled_elements, DgnCollector, - DgnElement, DgnGeom, DgnImport, Path, PathBuilder, Segment, Sheet, SubPath, Text, + arc_cubics, bspline_points, dgn_color_table, paths_bounds, scaled_elements, By, DgnCollector, + DgnColor, DgnElement, DgnGeom, DgnImport, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS, }; @@ -140,7 +140,9 @@ struct Instance { /// chains and shapes (joined), B-splines, text and shared cell instances. fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let mut raw = Raw::default(); - let mut spline: Option<(usize, usize, [u8; 3], f64)> = None; + // An open B-spline curve: order, pole count, colour, closed, and the + // header as the element it imports as. + let mut spline: Option<(usize, usize, [u8; 3], bool, DgnElement)> = None; let mut knots: Vec = Vec::new(); // A complex shape or chain collects its components into one path. let mut complex: Option<( @@ -212,11 +214,16 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let color = colors[(symbology >> 8) as usize]; let element = |geom: DgnGeom| DgnElement { level: level(e) as u32, - color: Some(color), - weight: Some(((symbology >> 3) & 31) as u32), - style: Some((symbology & 7) as u32), + color: By::Own(DgnColor { + index: (symbology >> 8) as u32, + table: color, + rgb: color, + }), + weight: By::Own(((symbology >> 3) & 31) as u32), + style: By::Own((symbology & 7) as u32), geom, }; + let flat = |p: [f64; 2]| [p[0], p[1], 0.0]; // One pixel wide, as V8 models draw: the element weight is a // lineweight, not shown while lineweights are off. let stroke = Some((color, -1.0)); @@ -224,10 +231,16 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let int_pt = |i: usize| -> Option<[f64; 2]> { Some([int32(e, i)? as f64, int32(e, i + 4)? as f64]) }; match kind { - // A text node: its count of text components. + // A text node: its count of text components, justification + // (45), line spacing (46) and origin (62). 7 => collect.open( uint16(e, 38).unwrap_or(0) as usize, - element(DgnGeom::TextNode(Vec::new())), + element(DgnGeom::TextNode { + lines: Vec::new(), + origin: int_pt(62).map(flat), + just: e.get(45).copied().unwrap_or(0), + line_spacing: int32(e, 46).map(|v| v as f64), + }), ), 12 | 14 => { let n = uint16(e, 38).unwrap_or(0) as usize; @@ -244,7 +257,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } 3 => { if let (Some(a), Some(b)) = (int_pt(36), int_pt(44)) { - collect.push(element(DgnGeom::Line(a, b))); + collect.push(element(DgnGeom::Line(flat(a), flat(b)))); push( &mut raw, &mut complex, @@ -263,7 +276,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let n = uint16(e, 36).unwrap_or(0) as usize; let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); if pts.len() >= 2 { - collect.push(element(DgnGeom::Points(pts.clone()))); + collect.push(element(DgnGeom::Points(pts.iter().map(|p| flat(*p)).collect()))); push( &mut raw, &mut complex, @@ -312,9 +325,9 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let turn = (r as f64 / 360000.0).to_radians(); let (c, s) = (turn.cos(), turn.sin()); collect.push(element(DgnGeom::Arc { - center: [ox, oy], - u: [a * c, a * s], - v: [-b * s, b * c], + center: [ox, oy, 0.0], + u: [a * c, a * s, 0.0], + v: [-b * s, b * c, 0.0], start: start.to_radians(), sweep: sweep.to_radians(), })); @@ -353,7 +366,11 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { let Some(chars) = e.get(60..60 + n) else { continue; }; - let (height, width) = (height as f64 * 6.0 / 1000.0, width as f64 * 6.0 / 1000.0); + // Sizes in whole UORs (the multipliers are 1000/6 of one). + let (height, width) = ( + (height as f64 * 6.0 / 1000.0).round(), + (width as f64 * 6.0 / 1000.0).round(), + ); let text = Text { text: String::from_utf8_lossy(chars) .trim_end_matches('\0') @@ -365,26 +382,35 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { color, font: "txt".to_string(), }; + // A V7 font is drawn monospaced: the text is as long as its + // characters times their width. collect.push(element(DgnGeom::Text { text: text.text.clone(), - origin, + origin: flat(origin), height, width_factor: text.width_factor, + length: text.text.chars().count() as f64 * width, rotation: text.rotation, + just: e[37], font: Some(e[36] as u32), })); raw.texts.push(text); } 27 => { - // B-spline curve header: order, pole count. - let order = ((e.get(40).copied().unwrap_or(0) & 0x0f) + 2) as usize; + // B-spline curve header: order and closure (40), pole count + // (42), knot count (44); the curve imports with the header's + // level and symbology. + let flags = e.get(40).copied().unwrap_or(0); + let order = ((flags & 0x0f) + 2) as usize; let poles = uint16(e, 42).unwrap_or(0) as usize; - spline = Some((order, poles, color, -1.0)); - knots.clear(); + let header = element(DgnGeom::Chain(Vec::new())); + spline = Some((order, poles, color, flags & 0x80 != 0, header)); + knots = vec![f64::NAN; uint16(e, 44).unwrap_or(0) as usize]; } 26 => { - // Interior knots as fractions of the parameter range. - let n = spline.map(|s| s.1.saturating_sub(s.0)).unwrap_or(0); + // Knots as fractions of the parameter range; a closed + // curve's list also holds its ends. + let n = knots.len(); knots = (0..n) .filter_map(|k| int32(e, 36 + k * 4)) .map(|v| v as u32 as f64 / 2_147_483_648.0) @@ -393,10 +419,15 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { 21 => { let n = uint16(e, 36).unwrap_or(0) as usize; let poles: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); - if let Some((order, count, color, width)) = spline.take() { + if let Some((order, count, color, closed, header)) = spline.take() { if poles.len() == count && count >= order { - let interior = if knots.len() == count - order { - knots.clone() + let stored: Vec = knots + .iter() + .copied() + .filter(|k| *k > 1e-9 && *k < 1.0 - 1e-6) + .collect(); + let interior = if stored.len() == count - order { + stored } else { (1..=count - order) .map(|k| k as f64 / (count - order + 1) as f64) @@ -407,13 +438,18 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { full.extend(vec![1.0; order]); let pts = bspline_points(order, &poles, &full, 24 * count); collect.push(DgnElement { - color: Some(color), - ..element(DgnGeom::Points(pts.clone())) + geom: DgnGeom::Spline { + degree: order - 1, + knots: full, + poles: poles.iter().map(|p| flat(*p)).collect(), + closed, + }, + ..header }); push( &mut raw, &mut complex, - polyline(&pts, false, Some((color, width)), None), + polyline(&pts, false, Some((color, -1.0)), None), ); } } @@ -443,7 +479,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { collect.push(element(DgnGeom::Cell { name: name.clone(), m: [m0, m1, m2, m3], - origin: [ox as f64, oy as f64], + origin: [ox as f64, oy as f64, 0.0], z_scale: 1.0, })); raw.instances.push(Instance { @@ -743,5 +779,6 @@ pub fn import(bytes: &[u8]) -> Option { sub_per_master: u.sub_per_master, unit_names: (unit_name(bytes, 1120), unit_name(bytes, 1122)), v7: true, + ..Default::default() }) } diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index b442b6a30..413edb60b 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -8,8 +8,9 @@ use std::collections::HashMap; use std::io::Read; use super::model::{ - arc_cubics, dgn_color_table, paths_bounds, scaled_elements, DgnCollector, DgnElement, DgnGeom, - DgnImport, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS, + arc_cubics, bspline_points, dgn_color_table, paths_bounds, scaled_elements, By, DgnCollector, + DgnColor, DgnElement, DgnGeom, DgnImport, DgnReference, Path, PathBuilder, Segment, Sheet, + SubPath, Text, DGN_DEFAULT_COLORS, }; // ── Compound file ─────────────────────────────────────────────────────────── @@ -384,6 +385,100 @@ impl Styles { .copied() .unwrap_or([255, 255, 255]) } + + /// The colour word as an element's colour: the level's (0xFFFFFFFF), + /// the enclosing cell's (0xFFFFFFFE), or a table index (low byte) with + /// the table entry and the colour drawn. + fn color_word(&self, e: &[u8]) -> By { + match u32_at(e, 52) { + None | Some(u32::MAX) => By::Level, + Some(0xFFFF_FFFE) => By::Cell, + Some(word) => { + let index = word & 0xff; + By::Own(DgnColor { + index, + table: self + .colors + .get(index as usize) + .copied() + .unwrap_or([255, 255, 255]), + rgb: self.color(e), + }) + } + } + } +} + +/// A B-spline curve from its order and poles, with uniform knots. A closed +/// curve (its last pole repeating the first) is periodic: its distinct +/// poles wrap around with knots (i + 1) / (count + 1), and the curve is +/// clamped at both ends of its domain by knot insertion, as the reference +/// converts it. +fn bspline_geom(order: usize, poles: Vec<[f64; 3]>, closed: bool) -> DgnGeom { + let degree = order - 1; + let distinct = poles.len() - usize::from(closed && poles.first() == poles.last()); + if !closed || distinct < order { + let interior = poles.len() - order; + let mut knots = vec![0.0; order]; + knots.extend((1..=interior).map(|k| k as f64 / (interior + 1) as f64)); + knots.extend(vec![1.0; order]); + return DgnGeom::Spline { + degree, + knots, + poles, + closed, + }; + } + let mut ctrl: Vec<[f64; 3]> = (0..distinct + degree).map(|i| poles[i % distinct]).collect(); + let count = ctrl.len() + order; + let mut knots: Vec = (0..count).map(|i| (i + 1) as f64 / (count + 1) as f64).collect(); + let (lo, hi) = (knots[degree], knots[ctrl.len()]); + let same = |a: f64, b: f64| (a - b).abs() < 1e-12; + // Knot insertion (Boehm) of t until it is a degree-fold knot. + let mut insert = |t: f64| loop { + let mult = knots.iter().filter(|k| same(**k, t)).count(); + if mult >= degree { + return; + } + let Some(k) = knots.iter().rposition(|x| *x <= t + 1e-12) else { + return; + }; + let mut next = Vec::with_capacity(ctrl.len() + 1); + for i in 0..=ctrl.len() { + if i + degree <= k { + next.push(ctrl[i]); + } else if i + mult > k { + next.push(ctrl[i - 1]); + } else { + let a = (t - knots[i]) / (knots[i + degree] - knots[i]); + next.push(std::array::from_fn(|c| (1.0 - a) * ctrl[i - 1][c] + a * ctrl[i][c])); + } + } + ctrl = next; + knots.insert(k + 1, t); + }; + insert(lo); + insert(hi); + // The domain clamped: an order-fold knot at each end, the poles that + // span it. + let first = knots.iter().position(|k| same(*k, lo)).unwrap_or(1).max(1); + let last = knots.iter().rposition(|k| same(*k, hi)).unwrap_or(knots.len() - 1); + let mut clamped = vec![lo]; + clamped.extend_from_slice(&knots[first..=last]); + clamped.push(hi); + let poles: Vec<[f64; 3]> = ctrl + .iter() + .skip(first - 1) + .take(clamped.len() - order) + .copied() + .collect(); + let knots = clamped; + DgnGeom::Spline { + degree, + knots, + poles, + closed: true, + } } /// Plan geometry in UORs, and the shared cells it places. @@ -554,18 +649,29 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { [q[0], q[1]] }; let mut collect = DgnCollector::default(); + // An open B-spline curve: order, pole count, closure and its header. + let mut spline: Option<(usize, usize, bool, DgnElement)> = None; for (index, e) in run.iter().enumerate() { if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } let color = styles.color(e); - // Colour and weight words: 0xFFFFFFFF takes the level's; line style - // 0x7FFFFFFF (or 0xFFFFFFFF). + // Colour and weight words: 0xFFFFFFFF takes the level's, 0xFFFFFFFE + // the enclosing cell's; line style 0x7FFFFFFF (or 0xFFFFFFFF) the + // level's, 0x7FFFFFFE the cell's. let element = |geom: DgnGeom| DgnElement { level: u32_at(e, 12).unwrap_or(0), - color: (u32_at(e, 52) != Some(u32::MAX)).then_some(color), - weight: u32_at(e, 48).filter(|w| *w != u32::MAX).map(|w| w & 31), - style: u32_at(e, 44).filter(|s| *s != u32::MAX && *s != 0x7FFF_FFFF), + color: styles.color_word(e), + weight: match u32_at(e, 48) { + None | Some(u32::MAX) => By::Level, + Some(0xFFFF_FFFE) => By::Cell, + Some(w) => By::Own(w & 31), + }, + style: match u32_at(e, 44) { + None | Some(u32::MAX) | Some(0x7FFF_FFFF) => By::Level, + Some(0xFFFF_FFFE) | Some(0x7FFF_FFFE) => By::Cell, + Some(s) => By::Own(s), + }, geom, }; let stroke = Some((color, -1.0)); @@ -578,6 +684,9 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { if d3 { f64_at(e, at + 16)? } else { 0.0 }, ]) }; + // A point or vector placed in the model (3D kept for the import). + let at3 = |p: [f64; 3]| xf_point(xf, p); + let dir3 = |v: [f64; 3]| xf_vector(xf, v); match kind(e) { // A text node: grouped when its count is followed by that many // text components. @@ -587,7 +696,34 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { parts.iter().all(|p| p.len() >= 104 && kind(p) == 17 && is_component(p)) }); if texts { - collect.open(n, element(DgnGeom::TextNode(Vec::new()))); + // 2D: justification 118, origin 152 (the node's + // justification point). + let origin = (!d3).then(|| p3(152)).flatten().map(at3); + collect.open( + n, + element(DgnGeom::TextNode { + lines: Vec::new(), + origin, + just: if d3 { 0 } else { e.get(118).copied().unwrap_or(0) }, + line_spacing: None, + }), + ); + } + } + // A cell (not shared): its components follow, flagged as such. + 2 => { + let n = u32_at(e, 104).unwrap_or(0) as usize; + let parts = run + .get(index + 1..index + 1 + n) + .is_some_and(|parts| parts.iter().all(|p| is_component(p))); + if parts && n > 0 { + collect.open( + n, + element(DgnGeom::CellGroup { + name: marked_string(e, 104).unwrap_or_default(), + parts: Vec::new(), + }), + ); } } 12 | 14 => { @@ -602,7 +738,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { } 3 => { if let (Some(a), Some(b)) = (p3(104), p3(if d3 { 128 } else { 120 })) { - collect.push(element(DgnGeom::Line(plan(a), plan(b)))); + collect.push(element(DgnGeom::Line(at3(a), at3(b)))); push( &mut raw, &mut complex, @@ -620,12 +756,10 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { 4 | 6 | 11 => { let n = u32_at(e, 104).unwrap_or(0) as usize; let stride = if d3 { 24 } else { 16 }; - let pts: Vec<[f64; 2]> = (0..n) - .filter_map(|k| p3(112 + k * stride)) - .map(plan) - .collect(); + let pts3: Vec<[f64; 3]> = (0..n).filter_map(|k| p3(112 + k * stride)).collect(); + let pts: Vec<[f64; 2]> = pts3.iter().map(|p| plan(*p)).collect(); if pts.len() >= 2 { - collect.push(element(DgnGeom::Points(pts.clone()))); + collect.push(element(DgnGeom::Points(pts3.iter().map(|p| at3(*p)).collect()))); let mut pb = PathBuilder::default(); pb.move_to(pts[0]); for p in &pts[1..] { @@ -702,11 +836,11 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { }; let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9; { - let (pu, pv) = (axis(u), axis(v)); + let (du, dv) = (dir3(u), dir3(v)); collect.push(element(DgnGeom::Arc { - center: plan(c), - u: [a * pu[0], a * pu[1]], - v: [b * pv[0], b * pv[1]], + center: at3(c), + u: du.map(|x| a * x), + v: dv.map(|x| b * x), start, sweep, })); @@ -758,6 +892,16 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let Some((width, height, (u, v), origin, text)) = placed else { continue; }; + // 2D: the justification (108) and the length of the text box + // along the baseline (128). + let (just, length) = if d3 { + (2, 0.0) + } else { + ( + e.get(108).copied().unwrap_or(2), + f64_at(e, 128).filter(|l| l.is_finite()).unwrap_or(0.0), + ) + }; // The text's axes as the plan shows them: its baseline sets the // angle, and each axis's plan length scales its size. let (pu, pv) = (axis(u), axis(v)); @@ -769,10 +913,12 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let width_factor = if height > 0.0 { width / height } else { 1.0 }; collect.push(element(DgnGeom::Text { text: text.clone(), - origin: plan(origin), + origin: at3(origin), height, width_factor, + length: length * su, rotation: pu[1].atan2(pu[0]), + just, font: None, })); raw.texts.push(Text { @@ -804,7 +950,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { collect.push(element(DgnGeom::Cell { name: name.clone(), m: [m[0], m[1], m[3], m[4]], - origin: [o[0], o[1]], + origin: o, z_scale: m[8], })); raw.instances.push(Instance { @@ -812,6 +958,48 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { xf: xf_compose(xf, &local), }); } + 27 => { + // B-spline curve header: order and closure in the word at + // 108, pole count 112; its poles follow (type 21). + let flags = u16::from_le_bytes([e[108], e[109]]); + spline = Some(( + ((flags & 0x0f) + 2) as usize, + u32_at(e, 112).unwrap_or(0) as usize, + flags & 0x0180 != 0, + element(DgnGeom::Chain(Vec::new())), + )); + } + 21 => { + let n = u32_at(e, 104).unwrap_or(0) as usize; + let stride = if d3 { 24 } else { 16 }; + let poles: Vec<[f64; 3]> = (0..n).filter_map(|k| p3(112 + k * stride)).collect(); + if let Some((order, count, closed, header)) = spline.take() { + if poles.len() == count && count >= order && order >= 2 { + let poles: Vec<[f64; 3]> = poles.iter().map(|p| at3(*p)).collect(); + let geom = bspline_geom(order, poles, closed); + if let DgnGeom::Spline { degree, knots, poles, .. } = &geom { + let plan_poles: Vec<[f64; 2]> = + poles.iter().map(|p| [p[0], p[1]]).collect(); + let pts = bspline_points(degree + 1, &plan_poles, knots, 24 * count); + let mut pb = PathBuilder::default(); + pb.move_to(pts[0]); + for p in &pts[1..] { + pb.line_to(*p); + } + push( + &mut raw, + &mut complex, + Path { + subpaths: pb.finish(), + stroke, + fill: None, + }, + ); + } + collect.push(DgnElement { geom, ..header }); + } + } + } _ => {} } } @@ -983,12 +1171,14 @@ fn level_table(nm: &[&[u8]]) -> Vec<(u32, String, u32)> { out } -/// The level table with the file's own names. +/// The level table with the file's own names: the first one (the file's; +/// a later level table holds a reference attachment's levels). fn level_table_raw(nm: &[&[u8]]) -> Vec<(u32, String, u32)> { let mut out = Vec::new(); let mut in_levels = false; for e in nm { match kind(e) { + 96 if u32_at(e, 12) == Some(1) && !out.is_empty() => break, 96 => in_levels = u32_at(e, 12) == Some(1), 95 if in_levels && e.len() >= 80 => { let (Some(id), Some(color)) = (u32_at(e, 32), u32_at(e, 72)) else { @@ -1156,13 +1346,108 @@ fn unit_name(metres: f64) -> String { /// The named model element by element (DGNIMPORT): in master units with /// the global origin at 0,0, shared cell definitions in master units, and /// every level by id with its own name and colour. +/// A design model (not a sheet): the model type byte of its header (4105) +/// is 2 for a sheet. +fn is_design(header: &[u8]) -> bool { + header.get(4105) != Some(&2) +} + +/// The models DGNIMPORT offers: the design models (sheets are not +/// imported). +pub fn design_model_names(bytes: &[u8]) -> Option> { + let cfb = Cfb::open(bytes)?; + let names: Vec = models(&cfb) + .into_iter() + .filter(|m| is_design(&m.2)) + .map(|m| m.0) + .collect(); + (!names.is_empty()).then_some(names) +} + +/// The control stream (Dgn^C) of each model, in the order `models` lists +/// them. +fn control_streams(cfb: &Cfb) -> Vec> { + let streams = cfb.streams(); + let mut dirs: Vec = streams + .iter() + .filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1").map(|d| d.to_string())) + .collect(); + dirs.sort(); + dirs.iter() + .map(|dir| { + streams + .iter() + .find(|(p, _)| *p == format!("{dir}/Dgn^C/$1")) + .map(|(_, e)| inflate(&cfb.stream(e))) + .unwrap_or_default() + }) + .collect() +} + +/// Every string of an element stored after the "ff fe 01 00" marker, with +/// the subtype of the string linkage before it (2 file name, 3 full path, +/// 4 logical name). +fn linked_strings(e: &[u8]) -> Vec<(u32, String)> { + let mut out = Vec::new(); + let mut from = 0; + while let Some(i) = e + .get(from..) + .and_then(|rest| rest.windows(4).position(|w| w == [0xff, 0xfe, 0x01, 0x00])) + { + let at = from + i; + let end = e[at + 4..] + .iter() + .position(|&c| c == 0 || c < 0x20) + .map(|n| at + 4 + n) + .unwrap_or(e.len()); + let subtype = at.checked_sub(8).and_then(|p| u32_at(e, p)).unwrap_or(0); + out.push((subtype, String::from_utf8_lossy(&e[at + 4..end]).to_string())); + from = end.max(at + 4); + } + out +} + +/// The reference attachments (type 100) of a model's control stream: the +/// file (full path, else file name), origin 192, rotation rows from 216, +/// scale 288, in UORs of the model. +fn references(control: &[u8]) -> Vec { + elements(control) + .into_iter() + .filter(|e| kind(e) == 100 && e.len() >= 296) + .filter_map(|e| { + let strings = linked_strings(e); + let file = strings + .iter() + .find(|s| s.0 == 3) + .or_else(|| strings.iter().find(|s| s.0 == 2))? + .1 + .clone(); + Some(DgnReference { + file, + model: strings + .iter() + .find(|s| s.0 == 1) + .map(|s| s.1.clone()) + .unwrap_or_default(), + origin: [f64_at(e, 192)?, f64_at(e, 200)?, f64_at(e, 208)?], + scale: f64_at(e, 288).filter(|s| *s > 0.0).unwrap_or(1.0), + rotation: f64_at(e, 240)?.atan2(f64_at(e, 216)?), + }) + }) + .collect() +} + pub fn import(bytes: &[u8], name: &str) -> Option { let cfb = Cfb::open(bytes)?; let all = models(&cfb); - let (_, graphics, header) = all + let controls = control_streams(&cfb); + let designs: Vec = (0..all.len()).filter(|k| is_design(&all[*k].2)).collect(); + let k = designs .iter() - .find(|m| m.0.eq_ignore_ascii_case(name)) - .or_else(|| all.first())?; + .copied() + .find(|k| all[*k].0.eq_ignore_ascii_case(name)) + .or_else(|| designs.first().copied())?; + let (_, graphics, header) = &all[k]; let non_model = non_model(&cfb); let nm = elements(&non_model); let colors = nm @@ -1184,6 +1469,7 @@ pub fn import(bytes: &[u8], name: &str) -> Option { (*id, (name.clone(), rgb)) }) .collect(); + let level_order = table.iter().map(|l| l.0).collect(); let (per_unit, sub_uor) = units(header); let s = 1.0 / per_unit; let mut cells = Vec::new(); @@ -1195,8 +1481,12 @@ pub fn import(bytes: &[u8], name: &str) -> Option { while j < nm.len() && is_component(nm[j]) { j += 1; } - let def = read_run(&nm[i + 1..j], &styles, &IDENTITY).elements; - cells.push((cell, scaled_elements(&def, s, [0.0, 0.0]))); + // The cell that draws points is the writer's own; the reference + // leaves it out of the drawing. + if !cell.eq_ignore_ascii_case("POINT_DISPLAY") { + let def = read_run(&nm[i + 1..j], &styles, &IDENTITY).elements; + cells.push((cell, scaled_elements(&def, s, [0.0, 0.0]))); + } i = j; } else { i += 1; @@ -1210,13 +1500,29 @@ pub fn import(bytes: &[u8], name: &str) -> Option { (Some(n), Some(d)) if n > 0.0 && d > 0.0 => unit_name(d / n), _ => String::new(), }; + let references = controls + .get(k) + .map(|c| references(c)) + .unwrap_or_default() + .into_iter() + .map(|r| DgnReference { + origin: [ + (r.origin[0] - origin[0]) * s, + (r.origin[1] - origin[1]) * s, + r.origin[2] * s, + ], + ..r + }) + .collect(); Some(DgnImport { elements: scaled_elements(&raw.elements, s, [-origin[0] * s, -origin[1] * s]), cells, levels, + level_order, sub_per_master: per_unit / sub_uor, unit_names: (metres(4180), metres(4196)), v7: false, + references, }) } diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index 7452be7a5..d8a938097 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -20,7 +20,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use codec::entities::UnderlayType; -pub use model::{scaled_elements, DgnElement, DgnGeom, DgnImport, Path, Segment, Sheet, SubPath}; +pub use model::{ + scaled_elements, By, DgnColor, DgnElement, DgnGeom, DgnImport, DgnReference, Path, Segment, + Sheet, SubPath, P3, +}; use super::pdf_raster::{source_bytes, PdfPage}; @@ -35,6 +38,17 @@ pub fn dgn_import(path: &str, item: &str) -> Option { } } +/// The models DGNIMPORT offers: a V7 file's one model, a V8 file's design +/// models (sheet models are not imported). +pub fn dgn_import_models(path: &str) -> Option> { + let bytes = source_bytes(path)?; + if dgn7::is_v7(&bytes) { + dgn7::model_names(&bytes) + } else { + dgn8::design_model_names(&bytes) + } +} + /// Sheet names (DWF) or model names (DGN) of a file. pub fn item_names(kind: UnderlayType, path: &str) -> Option> { let bytes = source_bytes(path)?; diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 7c455094d..355d2759d 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -532,55 +532,108 @@ pub fn dgn_color_table(e: &[u8], first: usize) -> Option> { Some(out) } +/// A symbology attribute of a DGN element: the level's, the enclosing +/// cell's (V8 "by cell"), or its own. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum By { + Level, + Cell, + Own(T), +} + +impl By { + pub fn own(self) -> Option { + match self { + By::Own(v) => Some(v), + _ => None, + } + } +} + +/// An element's own colour: its index in the file's colour table, that +/// table entry's RGB and the RGB it is drawn in (a true colour can differ +/// from the table entry it names). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DgnColor { + pub index: u32, + pub table: [u8; 3], + pub rgb: [u8; 3], +} + /// A DGN element as drawing geometry (what DGNIMPORT converts): its level, /// symbology and shape, in the units of the run that read it. #[derive(Clone, Debug, PartialEq)] pub struct DgnElement { /// Level number (V7) or id (V8). pub level: u32, - /// Colour; `None` takes the level's. - pub color: Option<[u8; 3]>, - /// Weight 0-31; `None` takes the level's. - pub weight: Option, - /// Line style; `None` takes the level's. - pub style: Option, + pub color: By, + /// Weight 0-31. + pub weight: By, + /// Line style. + pub style: By, pub geom: DgnGeom, } +/// A point of a DGN element (z 0 in a 2D file). +pub type P3 = [f64; 3]; + #[derive(Clone, Debug, PartialEq)] pub enum DgnGeom { - Line([f64; 2], [f64; 2]), - /// A line string, curve or B-spline (sampled), or a shape (its points - /// end where they start). - Points(Vec<[f64; 2]>), + Line(P3, P3), + /// A line string, curve or shape (its points end where they start). + Points(Vec), /// An elliptical arc: the point at parameter t is /// `center + cos t · u + sin t · v`, from `start` over `sweep` (radians). Arc { - center: [f64; 2], - u: [f64; 2], - v: [f64; 2], + center: P3, + u: P3, + v: P3, start: f64, sweep: f64, }, + /// A B-spline curve: degree, full knot vector and poles. + Spline { + degree: usize, + knots: Vec, + poles: Vec, + closed: bool, + }, /// A complex chain or shape: its components in order. Chain(Vec), Text { text: String, - origin: [f64; 2], + /// The text's lower left (baseline start). + origin: P3, height: f64, width_factor: f64, + /// Length of the text along its baseline (its justification box). + length: f64, rotation: f64, + /// DGN justification code (2 left bottom). + just: u8, /// Font number (V7); `None` when the file's font is not read. font: Option, }, - /// A text node: its lines. - TextNode(Vec), + /// A text node: its lines, its origin (the justification point, when + /// read), its justification and (V7) the space between its lines. + TextNode { + lines: Vec, + origin: Option, + just: u8, + line_spacing: Option, + }, + /// A cell that is not shared: its name (empty when anonymous) and its + /// components, each with its own level and symbology. + CellGroup { + name: String, + parts: Vec, + }, /// A shared cell instance: x' = m[0]·x + m[1]·y + origin.x, /// y' = m[2]·x + m[3]·y + origin.y. Cell { name: String, m: [f64; 4], - origin: [f64; 2], + origin: P3, /// Scale along z (1 in a 2D file). z_scale: f64, }, @@ -589,8 +642,8 @@ pub enum DgnGeom { impl DgnGeom { /// The geometry through x' = s·x + t (lengths scaled by `s`). pub fn scaled(&self, s: f64, t: [f64; 2]) -> DgnGeom { - let p = |q: [f64; 2]| [q[0] * s + t[0], q[1] * s + t[1]]; - let v = |q: [f64; 2]| [q[0] * s, q[1] * s]; + let p = |q: P3| [q[0] * s + t[0], q[1] * s + t[1], q[2] * s]; + let v = |q: P3| [q[0] * s, q[1] * s, q[2] * s]; match self { DgnGeom::Line(a, b) => DgnGeom::Line(p(*a), p(*b)), DgnGeom::Points(pts) => DgnGeom::Points(pts.iter().map(|q| p(*q)).collect()), @@ -607,23 +660,50 @@ impl DgnGeom { start: *start, sweep: *sweep, }, + DgnGeom::Spline { + degree, + knots, + poles, + closed, + } => DgnGeom::Spline { + degree: *degree, + knots: knots.clone(), + poles: poles.iter().map(|q| p(*q)).collect(), + closed: *closed, + }, DgnGeom::Chain(parts) => DgnGeom::Chain(parts.iter().map(|g| g.scaled(s, t)).collect()), - DgnGeom::TextNode(parts) => { - DgnGeom::TextNode(parts.iter().map(|g| g.scaled(s, t)).collect()) - } + DgnGeom::CellGroup { name, parts } => DgnGeom::CellGroup { + name: name.clone(), + parts: scaled_elements(parts, s, t), + }, + DgnGeom::TextNode { + lines, + origin, + just, + line_spacing, + } => DgnGeom::TextNode { + lines: lines.iter().map(|g| g.scaled(s, t)).collect(), + origin: origin.map(p), + just: *just, + line_spacing: line_spacing.map(|l| l * s), + }, DgnGeom::Text { text, origin, height, width_factor, + length, rotation, + just, font, } => DgnGeom::Text { text: text.clone(), origin: p(*origin), height: height * s, width_factor: *width_factor, + length: length * s, rotation: *rotation, + just: *just, font: *font, }, DgnGeom::Cell { @@ -662,8 +742,12 @@ impl DgnCollector { pub fn push(&mut self, element: DgnElement) { if let Some((left, header)) = self.open.as_mut() { - if let DgnGeom::Chain(parts) | DgnGeom::TextNode(parts) = &mut header.geom { - parts.push(element.geom); + match &mut header.geom { + DgnGeom::Chain(parts) | DgnGeom::TextNode { lines: parts, .. } => { + parts.push(element.geom) + } + DgnGeom::CellGroup { parts, .. } => parts.push(element), + _ => {} } *left -= 1; if *left == 0 { @@ -690,11 +774,29 @@ pub struct DgnImport { pub elements: Vec, pub cells: Vec<(String, Vec)>, pub levels: std::collections::HashMap)>, + /// Level ids in the file's level table order; a V8 file's levels all + /// become layers, used or not. + pub level_order: Vec, pub sub_per_master: f64, /// Master and sub unit names, when the file has them. pub unit_names: (String, String), /// A V7 file (its elements carry their own line style and weight). pub v7: bool, + /// The model's reference attachments. + pub references: Vec, +} + +/// A reference attachment of a DGN model: the file and model it shows and +/// how it is placed (in master units of the attaching model). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct DgnReference { + /// The file as the attachment names it. + pub file: String, + pub model: String, + pub origin: P3, + /// Scale of the reference's master units into the model's. + pub scale: f64, + pub rotation: f64, } /// The elements through x' = s·x + t. diff --git a/src/ui/window/import_dialogs.rs b/src/ui/window/import_dialogs.rs index 48b010e9e..5fb203803 100644 --- a/src/ui/window/import_dialogs.rs +++ b/src/ui/window/import_dialogs.rs @@ -32,6 +32,8 @@ pub enum ImportDialogMsg { DgnPrefix(bool), DgnSub(bool), DgnExplode(bool), + DgnReferences(crate::modules::insert::dgn_import::ReferenceMode), + DgnExisting(crate::modules::insert::dgn_import::ExistingReference), DgnMappingTab(usize), DgnOk, Help(&'static str), @@ -142,6 +144,8 @@ pub struct DgnImportState { pub prefix_duplicates: bool, pub sub_units: bool, pub explode_text_nodes: bool, + pub references: crate::modules::insert::dgn_import::ReferenceMode, + pub existing_reference: crate::modules::insert::dgn_import::ExistingReference, /// Master and sub unit names. pub units: (String, String), pub mapping_tab: usize, @@ -564,21 +568,49 @@ pub fn view_dgn_import<'a>( ] .spacing(8), ); - // The DGN readers do not read reference attachments, so their handling - // is shown but cannot be chosen. + use crate::modules::insert::dgn_import::{ExistingReference as E, ReferenceMode as R}; + let translate = state.references == R::Translate; let references = card( t!("External DGN references").into_owned(), column![ segmented(vec![ - (t!("Bind as block / xref").into_owned(), false, None), - (t!("Ignore").into_owned(), true, None), - (t!("Attach as DGN underlay").into_owned(), false, None), + ( + t!("Bind as block / xref").into_owned(), + translate, + Some(msg(ImportDialogMsg::DgnReferences(R::Translate))) + ), + ( + t!("Ignore").into_owned(), + state.references == R::Ignore, + Some(msg(ImportDialogMsg::DgnReferences(R::Ignore))) + ), + ( + t!("Attach as DGN underlay").into_owned(), + state.references == R::Underlay, + Some(msg(ImportDialogMsg::DgnReferences(R::Underlay))) + ), ]), - text(t!("Reference attachments of DGN files are not read; they are left out.")) - .size(11) + text(t!("When the translated drawing exists")) + .size(10) .style(muted_style), + segmented( + [ + (t!("Prompt to overwrite"), E::Prompt), + (t!("Overwrite without prompting"), E::Overwrite), + (t!("Do not overwrite"), E::Keep), + ] + .into_iter() + .map(|(label, value)| { + ( + label.into_owned(), + translate && state.existing_reference == value, + translate.then(|| msg(ImportDialogMsg::DgnExisting(value))), + ) + }) + .collect() + ), ] - .spacing(8), + .spacing(6), ); let tabs = [t!("Layer"), t!("Linetype"), t!("Lineweight"), t!("Color")]; let mut table = column![row![ From fc04472472fb6b3c17313d4fcb4ccff866b0720b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:47:56 +0300 Subject: [PATCH 02/10] DGNIMPORT: a cell that draws nothing still becomes a block Co-Authored-By: Claude Opus 5.5 --- src/app/commands/dgn_import.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 0fe00693b..4344e4d59 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -715,7 +715,15 @@ fn place_converted( if scene.document.block_records.get(target).is_some() { continue; } - let entities = entities.into_iter().map(&fix).collect(); + let entities: Vec = entities.into_iter().map(&fix).collect(); + if entities.is_empty() { + // A cell that draws nothing still names a block. + let mut record = codec::tables::block_record::BlockRecord::new(target); + record.handle = scene.document.allocate_handle(); + let _ = scene.document.block_records.add(record); + crate::io::xref::ensure_block_entities(&mut scene.document, target); + continue; + } let _ = scene.define_block_from_owned_entities(entities, target, glam::DVec3::ZERO); } for entity in converted.entities { From dca6c9d116a2254392b1f49f0c71f190f66201fe Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:58:28 +0300 Subject: [PATCH 03/10] DGNIMPORT: cells inside shared cell definitions are part of the definition; empty cells stay out Co-Authored-By: Claude Opus 5.5 --- src/app/commands/dgn_import.rs | 10 +--------- src/modules/insert/dgn_import.rs | 12 ++++++++++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 4344e4d59..0fe00693b 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -715,15 +715,7 @@ fn place_converted( if scene.document.block_records.get(target).is_some() { continue; } - let entities: Vec = entities.into_iter().map(&fix).collect(); - if entities.is_empty() { - // A cell that draws nothing still names a block. - let mut record = codec::tables::block_record::BlockRecord::new(target); - record.handle = scene.document.allocate_handle(); - let _ = scene.document.block_records.add(record); - crate::io::xref::ensure_block_entities(&mut scene.document, target); - continue; - } + let entities = entities.into_iter().map(&fix).collect(); let _ = scene.define_block_from_owned_entities(entities, target, glam::DVec3::ZERO); } for entity in converted.entities { diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index 43a984a01..118ed7d8b 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -616,6 +616,9 @@ struct Converter<'a> { /// (cell, made inside a copy) → copy block name. copies: HashMap<(String, bool), String>, names: Vec, + /// Converting a shared cell definition: the cells inside it are not + /// blocks of their own, their objects are part of the definition. + in_definition: bool, } impl<'a> Converter<'a> { @@ -774,6 +777,9 @@ impl<'a> Converter<'a> { // A cell that is not shared becomes a block of its own ("the // cell's name, else AnonymousCell" and a number) placed at the // origin; its objects keep their layers. + DgnGeom::CellGroup { parts, .. } if self.in_definition => { + self.elements(parts, true, copy, depth + 1) + } DgnGeom::CellGroup { name, parts } => { let base = if name.is_empty() { "AnonymousCell" } else { name.as_str() }; let block = self.unique(&block_name(base)); @@ -864,6 +870,7 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> .collect(), copies: HashMap::new(), names: cells.iter().map(|(name, _)| block_name(name)).collect(), + in_definition: false, }; if !import.v7 { for level in &import.level_order { @@ -877,13 +884,14 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> c.layer(&element, false); } } + c.in_definition = true; for (name, elements) in &cells { let entities = c.elements(elements, true, CellCopy::default(), 0); - // Every named cell becomes a block, even one with nothing it draws. - if !name.is_empty() { + if !name.is_empty() && !entities.is_empty() { c.out.blocks.push((block_name(name), entities)); } } + c.in_definition = false; let entities = c.elements(&top, false, CellCopy::default(), 0); c.out.entities = entities; c.out From 952cc0326db33cff250d37181f2cb84903fac747 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:20:47 +0300 Subject: [PATCH 04/10] DGNIMPORT: V7 3D files, V7 cells, filled shapes as solid hatches A 3D V7 design file's points keep their z, ellipses, arcs and text take their plane from the element's quaternion, and shared cell instances their 3x3 transformation and z. A V7 cell (type 2) brings its components in as a block of its own placed at the origin, as a V8 one does. A filled shape (V7 or V8) comes in as its outline followed by a solid hatch. Co-Authored-By: Claude Opus 5.5 --- src/modules/insert/dgn_import.rs | 21 +++ src/scene/model/underlay_vector/dgn7.rs | 206 ++++++++++++++++++++--- src/scene/model/underlay_vector/dgn8.rs | 1 + src/scene/model/underlay_vector/model.rs | 2 + 4 files changed, 203 insertions(+), 27 deletions(-) diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index f93da2733..2135d82dd 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -213,6 +213,22 @@ fn polyline(points: &[P3], bulges: &[f64]) -> EntityType { EntityType::LwPolyline(pl) } +/// A solid hatch filling a closed outline, in its plane. +fn solid_hatch(points: &[P3]) -> EntityType { + use codec::entities::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge}; + let mut ring: Vec = points.iter().map(|p| Vector2::new(p[0], p[1])).collect(); + if ring.len() > 1 && ring.first() == ring.last() { + ring.pop(); + } + let mut hatch = Hatch::solid(); + let mut boundary = BoundaryPath::external(); + boundary.flags.set_polyline(true); + boundary.edges.push(BoundaryEdge::Polyline(PolylineEdge::new(ring, true))); + hatch.add_path(boundary); + hatch.elevation = points.first().map_or(0.0, |p| p[2]); + EntityType::Hatch(Box::new(hatch)) +} + /// A complex chain's vertices and bulges: lines and line strings add their /// points, circular arcs a bulge, other arcs their points. fn chain(parts: &[DgnGeom]) -> (Vec, Vec) { @@ -712,6 +728,10 @@ impl<'a> Converter<'a> { vec![EntityType::Point(Point::from_coords(a[0], a[1], a[2]))] } DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points(v3(*a), v3(*b)))], + // A filled shape is its outline and a solid hatch inside it. + DgnGeom::Points(pts) if pts.len() >= 3 && element.filled => { + vec![polyline(pts, &[]), solid_hatch(pts)] + } DgnGeom::Points(pts) if pts.len() >= 2 => vec![polyline(pts, &[])], DgnGeom::Points(_) => Vec::new(), DgnGeom::Arc { @@ -880,6 +900,7 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> weight: By::Level, style: By::Level, geom: DgnGeom::Points(Vec::new()), + filled: false, }; c.layer(&element, false); } diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index dcbb0345b..a72343bf9 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -49,6 +49,42 @@ pub fn is_v7(bytes: &[u8]) -> bool { bytes.len() > 1256 && (bytes[1] & 0x7f) == 9 && (bytes[0] & 0x3f) == 8 } +/// A 3D design file: the header element's 3D flag. Its points have a z, +/// and planar elements carry their plane as a quaternion. +fn is_3d(bytes: &[u8]) -> bool { + bytes[0] & 0x40 != 0 +} + +/// The rows of the rotation a 3D element's quaternion (w, x, y, z as +/// signed 1.31 fractions) stands for: the element's own x, y and z axes. +fn quaternion_axes(e: &[u8], at: usize) -> Option<[[f64; 3]; 3]> { + let q = |k: usize| int32(e, at + 4 * k).map(|v| v as f64 / 2_147_483_648.0); + let (w, x, y, z) = (q(0)?, q(1)?, q(2)?, q(3)?); + // The rotation matrix's columns are the axes. + let m = [ + [x * x - y * y - z * z + w * w, 2.0 * (z * w + x * y), 2.0 * (x * z - y * w)], + [2.0 * (x * y - z * w), -x * x + y * y - z * z + w * w, 2.0 * (x * w + y * z)], + [2.0 * (x * z + y * w), 2.0 * (y * z - x * w), -x * x - y * y + z * z + w * w], + ]; + Some([0, 1, 2].map(|c| [m[0][c], m[1][c], m[2][c]])) +} + +/// A cell name in Radix-50: two words of three characters each. +fn radix50(e: &[u8], at: usize) -> String { + const SET: &[u8] = b" ABCDEFGHIJKLMNOPQRSTUVWXYZ$.?0123456789"; + let mut name = String::new(); + for k in 0..2 { + let Some(mut word) = uint16(e, at + 2 * k).map(usize::from) else { break }; + let mut chars = [0u8; 3]; + for c in chars.iter_mut().rev() { + *c = SET.get(word % 40).copied().unwrap_or(b' '); + word /= 40; + } + name.extend(chars.iter().map(|c| char::from(*c))); + } + name.trim().to_string() +} + struct Units { uor_per_sub: f64, sub_per_master: f64, @@ -137,8 +173,10 @@ struct Instance { } /// Reads a run of elements into raw geometry: plain elements, complex -/// chains and shapes (joined), B-splines, text and shared cell instances. -fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { +/// chains and shapes (joined), B-splines, text, cells and shared cell +/// instances. In a 3D file (`d3`) points have a z and planar elements their +/// plane; the drawn geometry is their plan. +fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw { let mut raw = Raw::default(); // An open B-spline curve: order, pole count, colour, closed, and the // header as the element it imports as. @@ -205,7 +243,10 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } }; let mut collect = DgnCollector::default(); - for e in run { + let mut index = 0; + while index < run.len() { + let e = run[index]; + index += 1; let kind = e[1] & 0x7f; if e[1] & 0x80 != 0 || e.len() < 36 { continue; @@ -222,22 +263,46 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { weight: By::Own(((symbology >> 3) & 31) as u32), style: By::Own((symbology & 7) as u32), geom, + filled: fill_index(e).is_some(), }; - let flat = |p: [f64; 2]| [p[0], p[1], 0.0]; // One pixel wide, as V8 models draw: the element weight is a // lineweight, not shown while lineweights are off. let stroke = Some((color, -1.0)); let fill = fill_index(e).map(|i| colors[i as usize]); - let int_pt = - |i: usize| -> Option<[f64; 2]> { Some([int32(e, i)? as f64, int32(e, i + 4)? as f64]) }; + // A point as the file holds it: two whole UORs, or three in 3D. + let pt3 = |i: usize| -> Option<[f64; 3]> { + Some([ + int32(e, i)? as f64, + int32(e, i + 4)? as f64, + if d3 { int32(e, i + 8)? as f64 } else { 0.0 }, + ]) + }; + let plan = |p: [f64; 3]| [p[0], p[1]]; + let step = if d3 { 12 } else { 8 }; match kind { + // A cell: its components follow as complex elements; they are + // already placed, so the cell keeps them as they are. + 2 => { + let start = index; + while index < run.len() && run[index][0] & 0x80 != 0 { + index += 1; + } + let inner = read_run(&run[start..index], colors, d3); + raw.paths.extend(inner.paths); + raw.texts.extend(inner.texts); + raw.instances.extend(inner.instances); + collect.push(element(DgnGeom::CellGroup { + name: radix50(e, 38), + parts: inner.elements, + })); + } // A text node: its count of text components, justification - // (45), line spacing (46) and origin (62). + // (45), line spacing (46) and origin (62, or 74 in 3D). 7 => collect.open( uint16(e, 38).unwrap_or(0) as usize, element(DgnGeom::TextNode { lines: Vec::new(), - origin: int_pt(62).map(flat), + origin: pt3(if d3 { 74 } else { 62 }), just: e.get(45).copied().unwrap_or(0), line_spacing: int32(e, 46).map(|v| v as f64), }), @@ -256,8 +321,9 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } } 3 => { - if let (Some(a), Some(b)) = (int_pt(36), int_pt(44)) { - collect.push(element(DgnGeom::Line(flat(a), flat(b)))); + if let (Some(a3), Some(b3)) = (pt3(36), pt3(36 + step)) { + let (a, b) = (plan(a3), plan(b3)); + collect.push(element(DgnGeom::Line(a3, b3))); push( &mut raw, &mut complex, @@ -274,9 +340,10 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } 4 | 6 | 11 => { let n = uint16(e, 36).unwrap_or(0) as usize; - let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); + let pts3: Vec<[f64; 3]> = (0..n).filter_map(|k| pt3(38 + k * step)).collect(); + let pts: Vec<[f64; 2]> = pts3.iter().map(|p| plan(*p)).collect(); if pts.len() >= 2 { - collect.push(element(DgnGeom::Points(pts.iter().map(|p| flat(*p)).collect()))); + collect.push(element(DgnGeom::Points(pts3))); push( &mut raw, &mut complex, @@ -284,6 +351,69 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { ); } } + // 3D ellipses and arcs: the axes from the quaternion (52 or + // 60), the centre as three doubles after it. + 15 | 16 if d3 => { + let at = if kind == 15 { 36 } else { 44 }; + let q = at + 16; + let (Some(a), Some(b), Some(axes), Some(cx), Some(cy), Some(cz)) = ( + vax_double(e, at), + vax_double(e, at + 8), + quaternion_axes(e, q), + vax_double(e, q + 16), + vax_double(e, q + 24), + vax_double(e, q + 32), + ) else { + continue; + }; + let (start, sweep) = if kind == 15 { + (0.0, 360.0) + } else { + let (Some(st), Some(sw)) = (int32(e, 36), int32(e, 40)) else { continue }; + let sw = sw as u32; + let mut sweep = if sw & 0x8000_0000 != 0 { + -((sw & 0x7fff_ffff) as f64) + } else { + sw as f64 + } / 360000.0; + if sweep == 0.0 { + sweep = 360.0; + } + (st as f64 / 360000.0, sweep) + }; + let [x, y, _] = axes; + collect.push(element(DgnGeom::Arc { + center: [cx, cy, cz], + u: x.map(|c| c * a), + v: y.map(|c| c * b), + start: start.to_radians(), + sweep: sweep.to_radians(), + })); + let pieces = arc_cubics( + [cx, cy], + a, + b, + x[1].atan2(x[0]), + start.to_radians(), + sweep.to_radians(), + ); + let closed = sweep.abs() >= 360.0; + push( + &mut raw, + &mut complex, + Path { + subpaths: vec![SubPath { + segments: pieces + .into_iter() + .map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)) + .collect(), + closed, + }], + stroke, + fill: if closed { fill } else { None }, + }, + ); + } 15 | 16 => { let arc = if kind == 15 { (|| { @@ -357,13 +487,23 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { ); } 17 => { - let (Some(width), Some(height), Some(rotation), Some(origin)) = - (int32(e, 38), int32(e, 42), int32(e, 46), int_pt(50)) + // 2D: rotation (46), origin (50), count (58), characters + // (60); 3D: quaternion (46), origin (62), count (74), + // characters (76). + let rotation = if d3 { + quaternion_axes(e, 46).map(|[x, _, _]| (x[1].atan2(x[0]).to_degrees() * 360000.0).round() as i32) + } else { + int32(e, 46) + }; + let (origin_at, count_at) = if d3 { (62, 74) } else { (50, 58) }; + let (Some(width), Some(height), Some(rotation), Some(origin3)) = + (int32(e, 38), int32(e, 42), rotation, pt3(origin_at)) else { continue; }; - let n = e.get(58).copied().unwrap_or(0) as usize; - let Some(chars) = e.get(60..60 + n) else { + let origin = plan(origin3); + let n = e.get(count_at).copied().unwrap_or(0) as usize; + let Some(chars) = e.get(count_at + 2..count_at + 2 + n) else { continue; }; // Sizes in whole UORs (the multipliers are 1000/6 of one). @@ -386,7 +526,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { // characters times their width. collect.push(element(DgnGeom::Text { text: text.text.clone(), - origin: flat(origin), + origin: origin3, height, width_factor: text.width_factor, length: text.text.chars().count() as f64 * width, @@ -418,7 +558,8 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { } 21 => { let n = uint16(e, 36).unwrap_or(0) as usize; - let poles: Vec<[f64; 2]> = (0..n).filter_map(|k| int_pt(38 + k * 8)).collect(); + let poles3: Vec<[f64; 3]> = (0..n).filter_map(|k| pt3(38 + k * step)).collect(); + let poles: Vec<[f64; 2]> = poles3.iter().map(|p| plan(*p)).collect(); if let Some((order, count, color, closed, header)) = spline.take() { if poles.len() == count && count >= order { let stored: Vec = knots @@ -441,7 +582,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { geom: DgnGeom::Spline { degree: order - 1, knots: full, - poles: poles.iter().map(|p| flat(*p)).collect(), + poles: poles3.clone(), closed, }, ..header @@ -466,21 +607,32 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]]) -> Raw { .to_string() }) .unwrap_or_default(); + // A 3D instance holds a 3×3 matrix (its plan part and the z + // scale are used) and a z. + let (r1, zz) = if d3 { (100, 140) } else { (92, 0) }; let (Some(m0), Some(m1), Some(m2), Some(m3), Some(ox), Some(oy)) = ( vax_double(e, 76), vax_double(e, 84), - vax_double(e, 92), - vax_double(e, 100), + vax_double(e, r1), + vax_double(e, r1 + 8), le_i32(e, 148), le_i32(e, 152), ) else { continue; }; + let (oz, z_scale) = if d3 { + ( + le_i32(e, 156).unwrap_or(0) as f64, + vax_double(e, zz).filter(|z| *z != 0.0).unwrap_or(1.0), + ) + } else { + (0.0, 1.0) + }; collect.push(element(DgnGeom::Cell { name: name.clone(), m: [m0, m1, m2, m3], - origin: [ox as f64, oy as f64, 0.0], - z_scale: 1.0, + origin: [ox as f64, oy as f64, oz], + z_scale, })); raw.instances.push(Instance { name, @@ -683,14 +835,14 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { while j < all.len() && all[j][0] & 0x80 != 0 { j += 1; } - defs.insert(name, read_run(&all[i + 1..j], &colors)); + defs.insert(name, read_run(&all[i + 1..j], &colors, is_3d(bytes))); i = j; continue; } top.push(e); i += 1; } - let raw = read_run(&top, &colors); + let raw = read_run(&top, &colors, is_3d(bytes)); let per_unit = u.uor_per_sub * u.sub_per_master; let m: Affine = [ 1.0 / per_unit, @@ -757,7 +909,7 @@ pub fn import(bytes: &[u8]) -> Option { while j < all.len() && all[j][0] & 0x80 != 0 { j += 1; } - let def = read_run(&all[i + 1..j], &colors).elements; + let def = read_run(&all[i + 1..j], &colors, is_3d(bytes)).elements; cells.push((name, scaled_elements(&def, s, [0.0, 0.0]))); i = j; continue; @@ -765,7 +917,7 @@ pub fn import(bytes: &[u8]) -> Option { top.push(e); i += 1; } - let raw = read_run(&top, &colors); + let raw = read_run(&top, &colors, is_3d(bytes)); let levels = (0..64u32) .map(|l| { let name = names.get(&(l as u8)).cloned().unwrap_or_else(|| l.to_string()); diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index 413edb60b..730fe07ba 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -673,6 +673,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { Some(s) => By::Own(s), }, geom, + filled: matches!(kind(e), 6 | 14) && e.windows(4).any(|w| w == [0x07, 0x10, 0x41, 0x00]), }; let stroke = Some((color, -1.0)); let d3 = is_3d(e); diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 355d2759d..c7b526179 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -572,6 +572,8 @@ pub struct DgnElement { /// Line style. pub style: By, pub geom: DgnGeom, + /// The element is a filled shape (it has a fill linkage). + pub filled: bool, } /// A point of a DGN element (z 0 in a 2D file). From b686d5cbe8db059395e05ccfa863505e10805c7c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:52:33 +0300 Subject: [PATCH 05/10] DGNIMPORT: V8 text styles; 3D text ends at its byte count A V8 file's text style table comes in as text styles (a style the drawing has keeps its own; Standard stays Standard), and each text or text node takes the style its text style linkage names. A 3D text's characters end at the byte count the element gives, so a byte after them is no longer read as a character. Co-Authored-By: Claude Opus 5.5 --- src/modules/insert/dgn_import.rs | 39 +++++++++++++++++------ src/scene/model/underlay_vector/dgn7.rs | 1 + src/scene/model/underlay_vector/dgn8.rs | 40 ++++++++++++++++++++++-- src/scene/model/underlay_vector/model.rs | 6 ++++ 4 files changed, 74 insertions(+), 12 deletions(-) diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index 2135d82dd..e437d5c4a 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -362,10 +362,20 @@ fn color_code(color: By) -> u16 } } -/// The text style of a line of text (V7: one per font number, "dgnNNN"), -/// added to `styles` with its font file the first time. -fn text_style(geom: &DgnGeom, styles: &mut Vec<(String, String)>) -> String { +/// The text style of a line of text (V7: one per font number, "dgnNNN"; +/// V8: the style the text names), added to `styles` with its font file +/// the first time. +fn text_style(geom: &DgnGeom, import: &DgnImport, styles: &mut Vec<(String, String)>) -> String { match geom { + DgnGeom::Text { style: Some(id), .. } => match import.text_styles.iter().find(|(i, _)| i == id) { + Some((_, name)) if !name.eq_ignore_ascii_case("Standard") => { + if !styles.iter().any(|s| s.0 == *name) { + styles.push((name.clone(), "txt.shx".to_string())); + } + name.clone() + } + _ => "Standard".to_string(), + }, DgnGeom::Text { font: Some(font), .. } => { @@ -435,7 +445,7 @@ fn text_line(g: &DgnGeom) -> Option<(&str, P3, f64, f64, f64)> { /// attached at its bottom left a third of the height below the baseline /// (exact line spacing 0.8), any other at its justification point; its /// contents carry its colour (and width factor). -fn mtext_of_text(geom: &DgnGeom, color: u16) -> Option { +fn mtext_of_text(geom: &DgnGeom, color: u16, style: &str) -> Option { let DgnGeom::Text { text, origin, @@ -477,7 +487,7 @@ fn mtext_of_text(geom: &DgnGeom, color: u16) -> Option { m.rotation = rotation.rem_euclid(std::f64::consts::TAU); m.rectangle_width = 0.0; m.drawing_direction = DrawingDirection::ByStyle; - m.style = "Standard".to_string(); + m.style = style.to_string(); Some(EntityType::MText(Box::new(m))) } @@ -768,12 +778,15 @@ impl<'a> Converter<'a> { } // V7 text is TEXT, V8 text an MTEXT. DgnGeom::Text { font: Some(_), .. } => { - let style = text_style(geom, &mut self.out.styles); + let style = text_style(geom, self.import, &mut self.out.styles); text_entity(geom, &style).into_iter().collect() } - DgnGeom::Text { .. } => mtext_of_text(geom, color_code(element.color)) - .into_iter() - .collect(), + DgnGeom::Text { .. } => { + let style = text_style(geom, self.import, &mut self.out.styles); + mtext_of_text(geom, color_code(element.color), &style) + .into_iter() + .collect() + } // Exploded, each line converts as a text element of its own. DgnGeom::TextNode { lines, .. } if self.explode_text_nodes => lines .iter() @@ -788,7 +801,7 @@ impl<'a> Converter<'a> { let Some(first) = lines.first() else { return Vec::new(); }; - let style = text_style(first, &mut self.out.styles); + let style = text_style(first, self.import, &mut self.out.styles); let color = (!self.import.v7).then(|| color_code(element.color)); mtext_of_node(lines, *origin, *just, *line_spacing, style, color) .into_iter() @@ -905,6 +918,12 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> c.layer(&element, false); } } + // Every text style of the file's table comes in, used or not. + for (_, name) in &import.text_styles { + if !name.eq_ignore_ascii_case("Standard") && !c.out.styles.iter().any(|s| s.0 == *name) { + c.out.styles.push((name.clone(), "txt.shx".to_string())); + } + } c.in_definition = true; for (name, elements) in &cells { let entities = c.elements(elements, true, CellCopy::default(), 0); diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index a72343bf9..1a626eec7 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -533,6 +533,7 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw { rotation: text.rotation, just: e[37], font: Some(e[36] as u32), + style: None, })); raw.texts.push(text); } diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index 730fe07ba..29ecd4de8 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -279,6 +279,34 @@ fn f64_at(e: &[u8], at: usize) -> Option { } /// A string stored after the "ff fe 01 00" marker, when the element has one. +/// The text style a text element uses: the id in its text style linkage +/// (the four bytes after `04 20 e6 04 00 00`). +fn text_style_id(e: &[u8]) -> Option { + let at = e.windows(6).position(|w| w == [0x04, 0x20, 0xe6, 0x04, 0x00, 0x00])? + 6; + u32_at(e, at) +} + +/// The text style table (the table entries after a header of table 3): +/// each style's id and name. +fn text_style_table(nm: &[&[u8]]) -> Vec<(u32, String)> { + let mut out = Vec::new(); + let mut table = 0; + for e in nm { + match kind(e) { + 96 => table = u32_at(e, 12).unwrap_or(0), + 95 if table == 3 => { + if let (Some(id), Some(name)) = (u32_at(e, 32), marked_string(e, 32)) { + if !out.iter().any(|(i, _)| *i == id) { + out.push((id, name)); + } + } + } + _ => {} + } + } + out +} + fn marked_string(e: &[u8], from: usize) -> Option { let i = e .get(from..)? @@ -861,7 +889,8 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { } 17 => { // 3D: width 112, height 120, quaternion 144, origin 176, the - // characters after the string marker. 2D: angle 144, origin + // characters after the string marker, as many as the byte + // count at 110 holds. 2D: angle 144, origin // 152, a byte count (with its 4-byte marker) at 110 and the // characters from 174. let placed = if d3 { @@ -871,7 +900,12 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { f64_at(e, 120)?, quat_axes(e, 144)?, p3(176)?, - marked_string(e, 200)?, + // The byte count at 110 (with the 4-byte marker) + // ends the characters. + { + let count = u16::from_le_bytes(e.get(110..112)?.try_into().ok()?) as usize; + marked_string(e, 200)?.chars().take(count.saturating_sub(4)).collect() + }, )) })() } else { @@ -921,6 +955,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { rotation: pu[1].atan2(pu[0]), just, font: None, + style: text_style_id(e), })); raw.texts.push(Text { text, @@ -1524,6 +1559,7 @@ pub fn import(bytes: &[u8], name: &str) -> Option { unit_names: (metres(4180), metres(4196)), v7: false, references, + text_styles: text_style_table(&nm), }) } diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index c7b526179..ea98c7d81 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -615,6 +615,8 @@ pub enum DgnGeom { just: u8, /// Font number (V7); `None` when the file's font is not read. font: Option, + /// Text style id (V8), an entry of [`DgnImport::text_styles`]. + style: Option, }, /// A text node: its lines, its origin (the justification point, when /// read), its justification and (V7) the space between its lines. @@ -698,6 +700,7 @@ impl DgnGeom { rotation, just, font, + style, } => DgnGeom::Text { text: text.clone(), origin: p(*origin), @@ -707,6 +710,7 @@ impl DgnGeom { rotation: *rotation, just: *just, font: *font, + style: *style, }, DgnGeom::Cell { name, @@ -786,6 +790,8 @@ pub struct DgnImport { pub v7: bool, /// The model's reference attachments. pub references: Vec, + /// The text style table (V8): each style's id and name. + pub text_styles: Vec<(u32, String)>, } /// A reference attachment of a DGN model: the file and model it shows and From 6cbe62921bd7a6719aefae098acea7a2059ca9b6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:10:27 +0300 Subject: [PATCH 06/10] Bound DGN element reads that a crafted file could abuse A V8 B-spline header shorter than 110 bytes no longer panics: the order and closure word is read only when the element holds it. A V7 cell takes only the components its total length covers, and cells nested deeper than 16 levels are left out, so a long run of complex elements no longer recurses once per element until the stack overflows. A nested cell also stops swallowing the sibling elements after it. Only the chosen model's control stream is inflated for its reference attachments, within what is left of the budget the models were read under, instead of every model's stream without a total limit. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/underlay_vector/dgn7.rs | 28 +++++++---- src/scene/model/underlay_vector/dgn8.rs | 65 +++++++++++++++---------- 2 files changed, 59 insertions(+), 34 deletions(-) diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index 1a626eec7..f9be987e6 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -172,11 +172,14 @@ struct Instance { origin: [f64; 2], } +/// How deep cells inside cells are read. +const MAX_CELL_DEPTH: usize = 16; + /// Reads a run of elements into raw geometry: plain elements, complex /// chains and shapes (joined), B-splines, text, cells and shared cell /// instances. In a 3D file (`d3`) points have a z and planar elements their /// plane; the drawn geometry is their plan. -fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw { +fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool, depth: usize) -> Raw { let mut raw = Raw::default(); // An open B-spline curve: order, pole count, colour, closed, and the // header as the element it imports as. @@ -280,14 +283,21 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool) -> Raw { let plan = |p: [f64; 3]| [p[0], p[1]]; let step = if d3 { 12 } else { 8 }; match kind { - // A cell: its components follow as complex elements; they are - // already placed, so the cell keeps them as they are. + // A cell: its components follow as complex elements, as many as + // its total length (36, the words after its first 38 bytes) + // covers; they are already placed, so the cell keeps them as + // they are. Nesting past `MAX_CELL_DEPTH` is left out. 2 => { + let mut left = (38 + uint16(e, 36).unwrap_or(0) as usize * 2).saturating_sub(e.len()); let start = index; - while index < run.len() && run[index][0] & 0x80 != 0 { + while left > 0 && index < run.len() && run[index][0] & 0x80 != 0 { + left = left.saturating_sub(run[index].len()); index += 1; } - let inner = read_run(&run[start..index], colors, d3); + if depth >= MAX_CELL_DEPTH { + continue; + } + let inner = read_run(&run[start..index], colors, d3, depth + 1); raw.paths.extend(inner.paths); raw.texts.extend(inner.texts); raw.instances.extend(inner.instances); @@ -836,14 +846,14 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { while j < all.len() && all[j][0] & 0x80 != 0 { j += 1; } - defs.insert(name, read_run(&all[i + 1..j], &colors, is_3d(bytes))); + defs.insert(name, read_run(&all[i + 1..j], &colors, is_3d(bytes), 0)); i = j; continue; } top.push(e); i += 1; } - let raw = read_run(&top, &colors, is_3d(bytes)); + let raw = read_run(&top, &colors, is_3d(bytes), 0); let per_unit = u.uor_per_sub * u.sub_per_master; let m: Affine = [ 1.0 / per_unit, @@ -910,7 +920,7 @@ pub fn import(bytes: &[u8]) -> Option { while j < all.len() && all[j][0] & 0x80 != 0 { j += 1; } - let def = read_run(&all[i + 1..j], &colors, is_3d(bytes)).elements; + let def = read_run(&all[i + 1..j], &colors, is_3d(bytes), 0).elements; cells.push((name, scaled_elements(&def, s, [0.0, 0.0]))); i = j; continue; @@ -918,7 +928,7 @@ pub fn import(bytes: &[u8]) -> Option { top.push(e); i += 1; } - let raw = read_run(&top, &colors, is_3d(bytes)); + let raw = read_run(&top, &colors, is_3d(bytes), 0); let levels = (0..64u32) .map(|l| { let name = names.get(&(l as u8)).cloned().unwrap_or_else(|| l.to_string()); diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index 29ecd4de8..ca2f5f63b 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -209,16 +209,22 @@ impl<'a> Cfb<'a> { } } +/// Far above any real element stream; stops a crafted one from inflating +/// until memory runs out. +const STREAM_LIMIT: u64 = 512 << 20; + /// A DGN stream: raw, or zlib data after a 16-byte header. fn inflate(data: &[u8]) -> Vec { - // Far above any real element stream; stops a crafted one from - // inflating until memory runs out. - const STREAM_LIMIT: u64 = 512 << 20; + inflate_at_most(data, STREAM_LIMIT) +} + +/// [`inflate`] stopping after `limit` bytes. +fn inflate_at_most(data: &[u8], limit: u64) -> Vec { for skip in [16usize, 0] { if let Some(z) = data.get(skip..) { let mut out = Vec::new(); if flate2::read::ZlibDecoder::new(z) - .take(STREAM_LIMIT) + .take(limit) .read_to_end(&mut out) .is_ok() && !out.is_empty() @@ -997,7 +1003,10 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { 27 => { // B-spline curve header: order and closure in the word at // 108, pole count 112; its poles follow (type 21). - let flags = u16::from_le_bytes([e[108], e[109]]); + let Some(&[lo, hi]) = e.get(108..110) else { + continue; + }; + let flags = u16::from_le_bytes([lo, hi]); spline = Some(( ((flags & 0x0f) + 2) as usize, u32_at(e, 112).unwrap_or(0) as usize, @@ -1149,6 +1158,11 @@ fn units(header: &[u8]) -> (f64, f64) { (master, sub) } +/// How many bytes the streams of one file may inflate to in all. +fn inflate_budget(cfb: &Cfb) -> usize { + cfb.b.len().saturating_mul(64).max(256 << 20) +} + /// Models of the file: (name, graphic stream, header stream). fn models(cfb: &Cfb) -> Vec<(String, Vec, Vec)> { let streams = cfb.streams(); @@ -1163,7 +1177,7 @@ fn models(cfb: &Cfb) -> Vec<(String, Vec, Vec)> { // scales with the file (×64 covers any real compression ratio, so // legitimate files never trip it) with a 256 MiB floor for small ones; // models past the budget are dropped, the earlier ones still load. - let total_limit = cfb.b.len().saturating_mul(64).max(256 << 20); + let total_limit = inflate_budget(cfb); let mut total = 0usize; let mut out = Vec::new(); for dir in dirs { @@ -1400,24 +1414,28 @@ pub fn design_model_names(bytes: &[u8]) -> Option> { (!names.is_empty()).then_some(names) } -/// The control stream (Dgn^C) of each model, in the order `models` lists -/// them. -fn control_streams(cfb: &Cfb) -> Vec> { +/// The control stream (Dgn^C) of model `k` in the order `models` lists +/// them, inflated within what is left of the file's budget (`used` bytes +/// are taken by the models already read). +fn control_stream(cfb: &Cfb, k: usize, used: usize) -> Vec { let streams = cfb.streams(); - let mut dirs: Vec = streams + let mut dirs: Vec<&str> = streams .iter() - .filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1").map(|d| d.to_string())) + .filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1")) .collect(); dirs.sort(); - dirs.iter() - .map(|dir| { - streams - .iter() - .find(|(p, _)| *p == format!("{dir}/Dgn^C/$1")) - .map(|(_, e)| inflate(&cfb.stream(e))) - .unwrap_or_default() - }) - .collect() + let Some(dir) = dirs.get(k) else { + return Vec::new(); + }; + let left = inflate_budget(cfb).saturating_sub(used) as u64; + if left == 0 { + return Vec::new(); + } + streams + .iter() + .find(|(p, _)| *p == format!("{dir}/Dgn^C/$1")) + .map(|(_, e)| inflate_at_most(&cfb.stream(e), left.min(STREAM_LIMIT))) + .unwrap_or_default() } /// Every string of an element stored after the "ff fe 01 00" marker, with @@ -1476,7 +1494,6 @@ fn references(control: &[u8]) -> Vec { pub fn import(bytes: &[u8], name: &str) -> Option { let cfb = Cfb::open(bytes)?; let all = models(&cfb); - let controls = control_streams(&cfb); let designs: Vec = (0..all.len()).filter(|k| is_design(&all[*k].2)).collect(); let k = designs .iter() @@ -1536,10 +1553,8 @@ pub fn import(bytes: &[u8], name: &str) -> Option { (Some(n), Some(d)) if n > 0.0 && d > 0.0 => unit_name(d / n), _ => String::new(), }; - let references = controls - .get(k) - .map(|c| references(c)) - .unwrap_or_default() + let used = all.iter().map(|m| m.1.len() + m.2.len()).sum(); + let references = references(&control_stream(&cfb, k, used)) .into_iter() .map(|r| DgnReference { origin: [ From ed4dc916701e466d669339b7be188878d80f2e6b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:11:18 +0300 Subject: [PATCH 07/10] Keep DGN reference attachments and cell instances well-formed Plain -DGNIMPORT no longer writes drawings next to referenced files on its own: references are ignored unless the window (or the kept import settings) asks to translate them or attach them as underlays. A referenced file named relative to the host is looked up in the host's folder rather than the working directory. Its translated drawing takes a file name made from the model name with path separators and other reserved characters replaced, so it is always written in the reference's own folder. A reference placed in sub units is scaled by the same factor as its origin. Layer, block, text style and reference names read from the file are made valid symbol names (reserved characters become underscores). An instance of a cell that has no block (the point display cell, an empty or missing definition) is dropped instead of inserting a block that does not exist. "Prompt to overwrite" still keeps an existing translated drawing; the code now says so instead of implying a prompt. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/dgn_import.rs | 28 +++++++++++------ src/modules/insert/dgn_import.rs | 52 ++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 23 deletions(-) diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index 0fe00693b..bac56cb5f 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -458,7 +458,7 @@ impl OpenCADStudio { .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); - Naming::Prefix(format!("{stem} - {}$0$", request.model)) + Naming::Prefix(dgn_import::symbol_name(&format!("{stem} - {}$0$", request.model))) } else { Naming::Keep }; @@ -469,7 +469,7 @@ impl OpenCADStudio { let layer = import .levels .get(&64) - .map(|l| l.0.clone()) + .map(|l| dgn_import::symbol_name(&l.0)) .unwrap_or_else(|| "0".to_string()); let layer = layers.get(&layer).cloned().unwrap_or(layer); for reference in &import.references { @@ -502,9 +502,12 @@ impl OpenCADStudio { .parent() .map(|p| p.to_path_buf()) .unwrap_or_default(); + // A relative name is beside the host file; failing that, the file + // of that name in the host's folder. let named = std::path::Path::new(&reference.file.replace('\\', "/")).to_path_buf(); - let file = if named.is_file() { - named + let joined = host_dir.join(&named); + let file = if joined.is_file() { + joined } else { host_dir.join(named.file_name().unwrap_or_default()) }; @@ -519,13 +522,15 @@ impl OpenCADStudio { }) else { return; }; + // Sub units scale the placement and the size alike. let origin = glam::DVec3::from_array(reference.origin) * units; + let scale = reference.scale * units; if request.references == ReferenceMode::Underlay { let mut placed = crate::modules::insert::pdf_attach::underlays_for_pages( codec::entities::UnderlayType::Dgn, std::slice::from_ref(&model), origin, - reference.scale, + scale, reference.rotation, ); let scene = &mut self.tabs[i].scene; @@ -548,9 +553,12 @@ impl OpenCADStudio { .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); - let target = file.with_file_name(format!("{stem}-{model}.dwg")); - // ponytail: "Prompt to overwrite" keeps the existing drawing, as the - // reference does when it cannot ask; ask here if a window is wanted. + // The model name comes from the file: made a plain file name so the + // drawing is written in the reference's folder. + let model_name = dgn_import::symbol_name(&model); + let target = file.with_file_name(format!("{stem}-{model_name}.dwg")); + // ponytail: "Prompt to overwrite" asks nothing and keeps the existing + // drawing, like "Do not overwrite"; add a confirmation if wanted. let write = !target.is_file() || request.existing_reference == ExistingReference::Overwrite; if write { let Some(source) = dgn_import(&path, &model) else { @@ -569,7 +577,7 @@ impl OpenCADStudio { overlay: false, path_type: crate::io::xref_model::Pathtype::Relative, }; - let name = format!("{stem} - {model}"); + let name = dgn_import::symbol_name(&format!("{stem} - {model}")); let full = crate::modules::insert::xattach::resolve_xref_store_path(&xref.path, None); self.command_line .push_output(&format!("Attach Xref \"{name}\": {full}")); @@ -589,7 +597,7 @@ impl OpenCADStudio { block, codec::types::Vector3::new(origin.x, origin.y, origin.z), ) - .with_scale(reference.scale, reference.scale, reference.scale) + .with_scale(scale, scale, scale) .with_rotation(reference.rotation); insert.common.layer = layer.to_string(); insert.common.linetype = "Continuous".to_string(); diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index e437d5c4a..a8cf8712a 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -50,8 +50,8 @@ pub struct DgnImportRequest { /// as external references, left out, or attached as DGN underlays. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ReferenceMode { - #[default] Translate, + #[default] Ignore, Underlay, } @@ -109,11 +109,25 @@ pub fn unit_display(short: &str) -> String { .to_string() } +/// A name read from a DGN file as a symbol (layer, block, text style) or +/// file name: the characters those cannot hold become `_`. +pub fn symbol_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_control() || "<>/\\\":;?*|,=`".contains(c) { + '_' + } else { + c + } + }) + .collect() +} + fn layer_name(import: &DgnImport, level: u32) -> String { import .levels .get(&level) - .map(|l| l.0.clone()) + .map(|l| symbol_name(&l.0)) .filter(|n| !n.is_empty()) .unwrap_or_else(|| level.to_string()) } @@ -121,7 +135,7 @@ fn layer_name(import: &DgnImport, level: u32) -> String { /// A cell's name as a block name: a leading `*` (an anonymous block /// written out) is dropped. pub fn block_name(cell: &str) -> String { - cell.trim_start_matches('*').to_string() + symbol_name(cell.trim_start_matches('*')) } fn v3(p: P3) -> Vector3 { @@ -305,7 +319,7 @@ fn just_point(origin: P3, rotation: f64, length: f64, height: f64, just: u8) -> let (h, v) = just_parts(just); let along = length * h as f64 / 2.0; let up = match v { - // Centre top is placed half a height up, as the reference places it. + // Centre top is placed half a height up. 0 if just == 6 => height / 2.0, 0 => height, 1 => height / 2.0, @@ -369,10 +383,11 @@ fn text_style(geom: &DgnGeom, import: &DgnImport, styles: &mut Vec<(String, Stri match geom { DgnGeom::Text { style: Some(id), .. } => match import.text_styles.iter().find(|(i, _)| i == id) { Some((_, name)) if !name.eq_ignore_ascii_case("Standard") => { - if !styles.iter().any(|s| s.0 == *name) { + let name = symbol_name(name); + if !styles.iter().any(|s| s.0 == name) { styles.push((name.clone(), "txt.shx".to_string())); } - name.clone() + name } _ => "Standard".to_string(), }, @@ -441,7 +456,7 @@ fn text_line(g: &DgnGeom) -> Option<(&str, P3, f64, f64, f64)> { } } -/// A V8 text as MTEXT, as the reference converts it: a bottom left text +/// A V8 text as MTEXT: a bottom left text /// attached at its bottom left a third of the height below the baseline /// (exact line spacing 0.8), any other at its justification point; its /// contents carry its colour (and width factor). @@ -602,8 +617,7 @@ fn arc_entity(center: P3, u: P3, v: P3, start: f64, sweep: f64) -> Vec EntityType { let det = m[0] * m[3] - m[1] * m[2]; let mirrored = det < 0.0; @@ -704,8 +718,7 @@ impl<'a> Converter<'a> { } fn layer(&mut self, element: &DgnElement, in_cell: bool) -> String { - // A cell's objects are on layer 0, as the reference defines the - // block. + // A cell's objects are on layer 0 of its block. let layer = if in_cell { "0".to_string() } else { @@ -920,8 +933,9 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> } // Every text style of the file's table comes in, used or not. for (_, name) in &import.text_styles { - if !name.eq_ignore_ascii_case("Standard") && !c.out.styles.iter().any(|s| s.0 == *name) { - c.out.styles.push((name.clone(), "txt.shx".to_string())); + let name = symbol_name(name); + if !name.eq_ignore_ascii_case("Standard") && !c.out.styles.iter().any(|s| s.0 == name) { + c.out.styles.push((name, "txt.shx".to_string())); } } c.in_definition = true; @@ -934,6 +948,18 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> c.in_definition = false; let entities = c.elements(&top, false, CellCopy::default(), 0); c.out.entities = entities; + // A cell left without a block (the point display cell, an empty or + // missing definition) places nothing: its instances are dropped. + let defined: std::collections::HashSet = + c.out.blocks.iter().map(|b| b.0.to_ascii_uppercase()).collect(); + let placed = |e: &EntityType| match e { + EntityType::Insert(i) => defined.contains(&i.block_name.to_ascii_uppercase()), + _ => true, + }; + c.out.entities.retain(placed); + for (_, entities) in &mut c.out.blocks { + entities.retain(placed); + } c.out } From 11ef1efc43ca959a36dcd82db99c26b4225ca31e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:11:49 +0300 Subject: [PATCH 08/10] Keep DGN 3D line strings in 3D and fill filled complex shapes A line string, shape or complex chain whose points are not all at one z becomes a 3D polyline instead of a 2D polyline flattened to the first point's elevation; its arcs then come in as points. Chain vertices are de-duplicated on all three coordinates, and only arcs in the drawing plane become bulges. A filled complex shape gets its solid hatch like a filled simple shape, following the outline's arcs. The hatch keeps the outline's elevation and is left out when the outline is not at one z. Co-Authored-By: Claude Opus 5.5 --- src/modules/insert/dgn_import.rs | 72 ++++++++++++++++++++++---------- 1 file changed, 51 insertions(+), 21 deletions(-) diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index a8cf8712a..23aa84971 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -16,7 +16,7 @@ use std::collections::HashMap; use codec::entities::{ Arc, AttachmentPoint, Circle, DrawingDirection, Ellipse, Insert, Line, LineSpacingStyle, - LwPolyline, LwVertex, MText, Point, Spline, Text, TextHorizontalAlignment, + LwPolyline, LwVertex, MText, Point, Polyline3D, Spline, Text, TextHorizontalAlignment, TextVerticalAlignment, }; use codec::types::{Color, LineWeight, Vector2, Vector3}; @@ -211,7 +211,20 @@ fn to_ocs(normal: P3, p: P3) -> Vector3 { codec::types::Matrix3::arbitrary_axis(v3(normal)).transpose() * v3(p) } +/// True when every point is at the first one's z. +fn flat(points: &[P3]) -> bool { + let z = points.first().map_or(0.0, |p| p[2]); + points.iter().all(|p| (p[2] - z).abs() <= 1e-9 * z.abs().max(1.0)) +} + +/// The points as a polyline: at one z a 2D polyline at that elevation +/// (with the bulges), else a 3D polyline (no bulges). fn polyline(points: &[P3], bulges: &[f64]) -> EntityType { + if !flat(points) { + return EntityType::Polyline3D(Polyline3D::from_points( + points.iter().map(|p| v3(*p)).collect(), + )); + } let mut pl = LwPolyline::new(); pl.plinegen = true; pl.elevation = points.first().map_or(0.0, |p| p[2]); @@ -227,31 +240,48 @@ fn polyline(points: &[P3], bulges: &[f64]) -> EntityType { EntityType::LwPolyline(pl) } -/// A solid hatch filling a closed outline, in its plane. -fn solid_hatch(points: &[P3]) -> EntityType { +/// A solid hatch filling a closed outline (with its bulges) at the +/// outline's elevation; `None` when the outline is not at one z. +fn solid_hatch(points: &[P3], bulges: &[f64]) -> Option { use codec::entities::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge}; - let mut ring: Vec = points.iter().map(|p| Vector2::new(p[0], p[1])).collect(); - if ring.len() > 1 && ring.first() == ring.last() { - ring.pop(); + if points.len() < 3 || !flat(points) { + return None; + } + let mut n = points.len(); + if points[0][0] == points[n - 1][0] && points[0][1] == points[n - 1][1] { + n -= 1; + } + let mut edge = PolylineEdge::new(Vec::new(), true); + for (k, p) in points[..n].iter().enumerate() { + edge.add_vertex(Vector2::new(p[0], p[1]), bulges.get(k).copied().unwrap_or(0.0)); } let mut hatch = Hatch::solid(); let mut boundary = BoundaryPath::external(); boundary.flags.set_polyline(true); - boundary.edges.push(BoundaryEdge::Polyline(PolylineEdge::new(ring, true))); + boundary.edges.push(BoundaryEdge::Polyline(edge)); hatch.add_path(boundary); - hatch.elevation = points.first().map_or(0.0, |p| p[2]); - EntityType::Hatch(Box::new(hatch)) + hatch.elevation = points[0][2]; + Some(EntityType::Hatch(Box::new(hatch))) } /// A complex chain's vertices and bulges: lines and line strings add their -/// points, circular arcs a bulge, other arcs their points. +/// points, circular arcs in the drawing plane a bulge, other arcs their +/// points. A chain whose vertices are not at one z comes out as points +/// only (a 3D polyline has no bulges). fn chain(parts: &[DgnGeom]) -> (Vec, Vec) { + let (points, bulges) = chain_points(parts, true); + if flat(&points) { + (points, bulges) + } else { + chain_points(parts, false) + } +} + +fn chain_points(parts: &[DgnGeom], use_bulges: bool) -> (Vec, Vec) { let mut points: Vec = Vec::new(); let mut bulges: Vec = Vec::new(); let mut add = |p: P3, bulge_before: Option| { - let same = points - .last() - .is_some_and(|q| (q[0] - p[0]).abs() < 1e-9 && (q[1] - p[1]).abs() < 1e-9); + let same = points.last().is_some_and(|q| (0..3).all(|c| (q[c] - p[c]).abs() < 1e-9)); if let (Some(b), Some(last)) = (bulge_before, bulges.last_mut()) { *last = b; } @@ -273,7 +303,7 @@ fn chain(parts: &[DgnGeom]) -> (Vec, Vec) { v, start, sweep, - } if circular(*u, *v) => { + } if use_bulges && circular(*u, *v) && arc_plane(*u, *v).0[2] > 1.0 - 1e-9 => { let at = |t: f64| -> P3 { std::array::from_fn(|c| center[c] + t.cos() * u[c] + t.sin() * v[c]) }; @@ -752,10 +782,10 @@ impl<'a> Converter<'a> { } DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points(v3(*a), v3(*b)))], // A filled shape is its outline and a solid hatch inside it. - DgnGeom::Points(pts) if pts.len() >= 3 && element.filled => { - vec![polyline(pts, &[]), solid_hatch(pts)] + DgnGeom::Points(pts) if pts.len() >= 2 => { + let fill = element.filled.then(|| solid_hatch(pts, &[])).flatten(); + std::iter::once(polyline(pts, &[])).chain(fill).collect() } - DgnGeom::Points(pts) if pts.len() >= 2 => vec![polyline(pts, &[])], DgnGeom::Points(_) => Vec::new(), DgnGeom::Arc { center, @@ -783,11 +813,11 @@ impl<'a> Converter<'a> { } DgnGeom::Chain(parts) => { let (points, bulges) = chain(parts); - if points.len() >= 2 { - vec![polyline(&points, &bulges)] - } else { - Vec::new() + if points.len() < 2 { + return Vec::new(); } + let fill = element.filled.then(|| solid_hatch(&points, &bulges)).flatten(); + std::iter::once(polyline(&points, &bulges)).chain(fill).collect() } // V7 text is TEXT, V8 text an MTEXT. DgnGeom::Text { font: Some(_), .. } => { From a5f965ead4cb116c3aad0f1c1115afa8c767cd9f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:12:45 +0300 Subject: [PATCH 09/10] Group nested DGN complex elements under their own headers The element collector keeps a stack of open complex elements instead of one. A chain, text node or cell inside a cell is gathered on its own and, once complete, counts as one component of the cell, rather than closing the cell early and leaving its remaining components at the top level. A B-spline curve whose poles cannot be read still takes its place in the open cell, so the cell does not absorb the element after it. An element that is not a component ends every complex element still open, so a header whose count overstates its components cannot swallow the rest of the model. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/underlay_vector/dgn7.rs | 5 +++ src/scene/model/underlay_vector/dgn8.rs | 14 ++++++- src/scene/model/underlay_vector/model.rs | 53 +++++++++++++++++------- 3 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index f9be987e6..d5b8612d3 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -254,6 +254,11 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool, depth: usize) -> Raw { if e[1] & 0x80 != 0 || e.len() < 36 { continue; } + // An element that is no component ends the complex elements still + // open. + if e[0] & 0x80 == 0 { + collect.close(); + } let symbology = uint16(e, 34).unwrap_or(0); let color = colors[(symbology >> 8) as usize]; let element = |geom: DgnGeom| DgnElement { diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index ca2f5f63b..fd03152f4 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -686,6 +686,11 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { // An open B-spline curve: order, pole count, closure and its header. let mut spline: Option<(usize, usize, bool, DgnElement)> = None; for (index, e) in run.iter().enumerate() { + // An element that is no component ends the complex elements still + // open. + if !is_component(e) { + collect.close(); + } if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } @@ -1007,12 +1012,17 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { continue; }; let flags = u16::from_le_bytes([lo, hi]); - spline = Some(( + // A curve before it that never got its poles still takes + // its place in an open cell. + let unread = spline.replace(( ((flags & 0x0f) + 2) as usize, u32_at(e, 112).unwrap_or(0) as usize, flags & 0x0180 != 0, element(DgnGeom::Chain(Vec::new())), )); + if unread.is_some() { + collect.skip(); + } } 21 => { let n = u32_at(e, 104).unwrap_or(0) as usize; @@ -1042,6 +1052,8 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { ); } collect.push(DgnElement { geom, ..header }); + } else { + collect.skip(); } } } diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index ea98c7d81..ed63a9163 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -728,26 +728,33 @@ impl DgnGeom { } /// Collects elements in file order, gathering the components of a complex -/// chain, shape or text node into their header's element. +/// chain, shape, text node or cell into their header's element. #[derive(Default)] pub struct DgnCollector { pub elements: Vec, - /// The open complex element and how many components it still takes. - open: Option<(usize, DgnElement)>, + /// The open complex elements, innermost last, each with how many + /// components it still takes. A complex element inside another is one + /// component of it once it is complete. + open: Vec<(usize, DgnElement)>, } impl DgnCollector { - /// A complex chain or shape (or a text node) header with `count` + /// A complex chain or shape, a text node or a cell header with `count` /// components. pub fn open(&mut self, count: usize, header: DgnElement) { - self.close(); if count > 0 { - self.open = Some((count, header)); + self.open.push((count, header)); + } else { + self.skip(); } } - pub fn push(&mut self, element: DgnElement) { - if let Some((left, header)) = self.open.as_mut() { + pub fn push(&mut self, mut element: DgnElement) { + loop { + let Some((left, header)) = self.open.last_mut() else { + self.elements.push(element); + return; + }; match &mut header.geom { DgnGeom::Chain(parts) | DgnGeom::TextNode { lines: parts, .. } => { parts.push(element.geom) @@ -756,18 +763,34 @@ impl DgnCollector { _ => {} } *left -= 1; - if *left == 0 { - self.close(); + if *left > 0 { + return; } - return; + let Some((_, done)) = self.open.pop() else { + return; + }; + element = done; } - self.elements.push(element); } - /// Ends a complex element cut short with the components it has. + /// A component that converts to nothing (a curve that could not be + /// read) still takes its place in the open complex element. + pub fn skip(&mut self) { + if let Some((left, _)) = self.open.last_mut() { + *left -= 1; + if *left == 0 { + if let Some((_, done)) = self.open.pop() { + self.push(done); + } + } + } + } + + /// Ends the complex elements cut short with the components they have + /// (an element that is no component ends them all). pub fn close(&mut self) { - if let Some((_, header)) = self.open.take() { - self.elements.push(header); + while let Some((_, header)) = self.open.pop() { + self.push(header); } } } From 56861aa0bc963e3e84d814a4c092d2e0a04f046a Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 16 Oct 2026 23:25:27 +0300 Subject: [PATCH 10/10] Escape DGN text in MTEXT and read 3D text by its byte count Backslashes and braces in a DGN text stay characters in the MTEXT it becomes instead of turning into formatting codes. A 3D V8 text is cut at its stored byte count before it is decoded, so a multi-byte character no longer shifts where the text ends. Doc comments that had slipped onto the wrong functions are back on their own, comments describe the behaviour instead of pointing at another program, and a few lines added in the previous commits are laid out the way the rest of the code is. Co-Authored-By: Claude Opus 5.5 --- src/app/commands/dgn_import.rs | 3 +- src/modules/insert/dgn_import.rs | 50 +++++++++++++++++-------- src/scene/model/underlay_vector/dgn7.rs | 3 +- src/scene/model/underlay_vector/dgn8.rs | 25 ++++++++----- 4 files changed, 54 insertions(+), 27 deletions(-) diff --git a/src/app/commands/dgn_import.rs b/src/app/commands/dgn_import.rs index bac56cb5f..0645105ad 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -458,7 +458,8 @@ impl OpenCADStudio { .file_stem() .map(|s| s.to_string_lossy().into_owned()) .unwrap_or_default(); - Naming::Prefix(dgn_import::symbol_name(&format!("{stem} - {}$0$", request.model))) + let prefix = format!("{stem} - {}$0$", request.model); + Naming::Prefix(dgn_import::symbol_name(&prefix)) } else { Naming::Keep }; diff --git a/src/modules/insert/dgn_import.rs b/src/modules/insert/dgn_import.rs index 23aa84971..f1cd2f4dc 100644 --- a/src/modules/insert/dgn_import.rs +++ b/src/modules/insert/dgn_import.rs @@ -12,7 +12,7 @@ // B-spline curves, complex chains and shapes (polylines with arcs), text, // text nodes and shared cells (blocks) are converted. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use codec::entities::{ Arc, AttachmentPoint, Circle, DrawingDirection, Ellipse, Insert, Line, LineSpacingStyle, @@ -114,7 +114,7 @@ pub fn unit_display(short: &str) -> String { pub fn symbol_name(name: &str) -> String { name.chars() .map(|c| { - if c.is_control() || "<>/\\\":;?*|,=`".contains(c) { + if c.is_control() || "<>/\\\":;?*|=`".contains(c) { '_' } else { c @@ -221,9 +221,8 @@ fn flat(points: &[P3]) -> bool { /// (with the bulges), else a 3D polyline (no bulges). fn polyline(points: &[P3], bulges: &[f64]) -> EntityType { if !flat(points) { - return EntityType::Polyline3D(Polyline3D::from_points( - points.iter().map(|p| v3(*p)).collect(), - )); + let points: Vec = points.iter().map(|p| v3(*p)).collect(); + return EntityType::Polyline3D(Polyline3D::from_points(points)); } let mut pl = LwPolyline::new(); pl.plinegen = true; @@ -253,7 +252,8 @@ fn solid_hatch(points: &[P3], bulges: &[f64]) -> Option { } let mut edge = PolylineEdge::new(Vec::new(), true); for (k, p) in points[..n].iter().enumerate() { - edge.add_vertex(Vector2::new(p[0], p[1]), bulges.get(k).copied().unwrap_or(0.0)); + let bulge = bulges.get(k).copied().unwrap_or(0.0); + edge.add_vertex(Vector2::new(p[0], p[1]), bulge); } let mut hatch = Hatch::solid(); let mut boundary = BoundaryPath::external(); @@ -281,7 +281,9 @@ fn chain_points(parts: &[DgnGeom], use_bulges: bool) -> (Vec, Vec) { let mut points: Vec = Vec::new(); let mut bulges: Vec = Vec::new(); let mut add = |p: P3, bulge_before: Option| { - let same = points.last().is_some_and(|q| (0..3).all(|c| (q[c] - p[c]).abs() < 1e-9)); + let same = points + .last() + .is_some_and(|q| (0..3).all(|c| (q[c] - p[c]).abs() < 1e-9)); if let (Some(b), Some(last)) = (bulge_before, bulges.last_mut()) { *last = b; } @@ -379,6 +381,14 @@ fn attachment(just: u8) -> AttachmentPoint { } } +/// Text as MTEXT contents: backslashes and braces stay characters instead +/// of becoming formatting codes. +fn mtext_escape(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('{', "\\{") + .replace('}', "\\}") +} + /// A number with six significant digits, trailing zeros dropped. fn sig6(x: f64) -> String { let digits = if x == 0.0 { @@ -510,7 +520,7 @@ fn mtext_of_text(geom: &DgnGeom, color: u16, style: &str) -> Option } else { String::new() }; - m.value = format!("{{{width}\\C{color};{text}}}"); + m.value = format!("{{{width}\\C{color};{}}}", mtext_escape(text)); if *just == 2 { let up = -0.33 * height; m.insertion_point = Vector3::new( @@ -549,7 +559,11 @@ fn mtext_of_node( color: Option, ) -> Option { let (_, first, height, rotation, _) = lines.iter().find_map(text_line)?; - let texts: Vec<&str> = lines.iter().filter_map(text_line).map(|l| l.0).collect(); + let texts: Vec = lines + .iter() + .filter_map(text_line) + .map(|l| mtext_escape(l.0)) + .collect(); let mut m = MText::new(); let joined = texts.join("\\P"); m.value = match color { @@ -783,8 +797,11 @@ impl<'a> Converter<'a> { DgnGeom::Line(a, b) => vec![EntityType::Line(Line::from_points(v3(*a), v3(*b)))], // A filled shape is its outline and a solid hatch inside it. DgnGeom::Points(pts) if pts.len() >= 2 => { - let fill = element.filled.then(|| solid_hatch(pts, &[])).flatten(); - std::iter::once(polyline(pts, &[])).chain(fill).collect() + let mut out = vec![polyline(pts, &[])]; + if element.filled { + out.extend(solid_hatch(pts, &[])); + } + out } DgnGeom::Points(_) => Vec::new(), DgnGeom::Arc { @@ -816,8 +833,11 @@ impl<'a> Converter<'a> { if points.len() < 2 { return Vec::new(); } - let fill = element.filled.then(|| solid_hatch(&points, &bulges)).flatten(); - std::iter::once(polyline(&points, &bulges)).chain(fill).collect() + let mut out = vec![polyline(&points, &bulges)]; + if element.filled { + out.extend(solid_hatch(&points, &bulges)); + } + out } // V7 text is TEXT, V8 text an MTEXT. DgnGeom::Text { font: Some(_), .. } => { @@ -980,8 +1000,8 @@ pub fn convert(import: &DgnImport, sub_units: bool, explode_text_nodes: bool) -> c.out.entities = entities; // A cell left without a block (the point display cell, an empty or // missing definition) places nothing: its instances are dropped. - let defined: std::collections::HashSet = - c.out.blocks.iter().map(|b| b.0.to_ascii_uppercase()).collect(); + let blocks = &c.out.blocks; + let defined: HashSet = blocks.iter().map(|b| b.0.to_ascii_uppercase()).collect(); let placed = |e: &EntityType| match e { EntityType::Insert(i) => defined.contains(&i.block_name.to_ascii_uppercase()), _ => true, diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index d5b8612d3..e39d0ef06 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -293,7 +293,8 @@ fn read_run(run: &[&[u8]], colors: &[[u8; 3]], d3: bool, depth: usize) -> Raw { // covers; they are already placed, so the cell keeps them as // they are. Nesting past `MAX_CELL_DEPTH` is left out. 2 => { - let mut left = (38 + uint16(e, 36).unwrap_or(0) as usize * 2).saturating_sub(e.len()); + let total = 38 + uint16(e, 36).unwrap_or(0) as usize * 2; + let mut left = total.saturating_sub(e.len()); let start = index; while left > 0 && index < run.len() && run[index][0] & 0x80 != 0 { left = left.saturating_sub(run[index].len()); diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index fd03152f4..32a66c759 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -284,7 +284,6 @@ fn f64_at(e: &[u8], at: usize) -> Option { Some(f64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?)) } -/// A string stored after the "ff fe 01 00" marker, when the element has one. /// The text style a text element uses: the id in its text style linkage /// (the four bytes after `04 20 e6 04 00 00`). fn text_style_id(e: &[u8]) -> Option { @@ -313,6 +312,7 @@ fn text_style_table(nm: &[&[u8]]) -> Vec<(u32, String)> { out } +/// A string stored after the "ff fe 01 00" marker, when the element has one. fn marked_string(e: &[u8], from: usize) -> Option { let i = e .get(from..)? @@ -446,8 +446,7 @@ impl Styles { /// A B-spline curve from its order and poles, with uniform knots. A closed /// curve (its last pole repeating the first) is periodic: its distinct /// poles wrap around with knots (i + 1) / (count + 1), and the curve is -/// clamped at both ends of its domain by knot insertion, as the reference -/// converts it. +/// clamped at both ends of its domain by knot insertion. fn bspline_geom(order: usize, poles: Vec<[f64; 3]>, closed: bool) -> DgnGeom { let degree = order - 1; let distinct = poles.len() - usize::from(closed && poles.first() == poles.last()); @@ -914,8 +913,14 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { // The byte count at 110 (with the 4-byte marker) // ends the characters. { - let count = u16::from_le_bytes(e.get(110..112)?.try_into().ok()?) as usize; - marked_string(e, 200)?.chars().take(count.saturating_sub(4)).collect() + let count = + u16::from_le_bytes(e.get(110..112)?.try_into().ok()?) as usize; + let mark = e + .get(200..)? + .windows(4) + .position(|w| w == [0xff, 0xfe, 0x01, 0x00])? + + 200; + marked_string(&e[..(mark + count.max(4)).min(e.len())], mark)? }, )) })() @@ -1405,9 +1410,6 @@ fn unit_name(metres: f64) -> String { .unwrap_or_default() } -/// The named model element by element (DGNIMPORT): in master units with -/// the global origin at 0,0, shared cell definitions in master units, and -/// every level by id with its own name and colour. /// A design model (not a sheet): the model type byte of its header (4105) /// is 2 for a sheet. fn is_design(header: &[u8]) -> bool { @@ -1503,6 +1505,9 @@ fn references(control: &[u8]) -> Vec { .collect() } +/// The named model element by element (DGNIMPORT): in master units with +/// the global origin at 0,0, shared cell definitions in master units, and +/// every level by id with its own name and colour. pub fn import(bytes: &[u8], name: &str) -> Option { let cfb = Cfb::open(bytes)?; let all = models(&cfb); @@ -1546,8 +1551,8 @@ pub fn import(bytes: &[u8], name: &str) -> Option { while j < nm.len() && is_component(nm[j]) { j += 1; } - // The cell that draws points is the writer's own; the reference - // leaves it out of the drawing. + // The cell that draws points belongs to the writing program, + // not to the drawing: it is left out. if !cell.eq_ignore_ascii_case("POINT_DISPLAY") { let def = read_run(&nm[i + 1..j], &styles, &IDENTITY).elements; cells.push((cell, scaled_elements(&def, s, [0.0, 0.0])));