diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index a7f8be194..347e2e08c 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -6678,7 +6678,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 85ef54df9..0fa552300 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -6666,7 +6666,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 2dde5f903..2a5a75145 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -6663,7 +6663,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 6a8467655..e30f41e53 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -6664,7 +6664,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 efa7121e4..a4c0c97c1 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -6666,7 +6666,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 46c6714df..5b39393a2 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -6665,7 +6665,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 70cac4e39..bc230fce0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -6666,7 +6666,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 f9f53355e..64b03cddb 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -6663,7 +6663,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 7f08c8a26..5775f6ab8 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -6666,7 +6666,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 c1f0a4f88..466b0d41c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -6654,7 +6654,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 073d13417..96e38f454 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -6650,7 +6650,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 3de98f192..2298c3fd9 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -6665,7 +6665,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 34dfb1382..c7bc19964 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -6654,7 +6654,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 6eb60ebdd..81e72afc5 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -6665,7 +6665,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 5b92c6b0f..eb0999f91 100644 --- a/locales/lo-LA/opencadstudio.ftl +++ b/locales/lo-LA/opencadstudio.ftl @@ -6666,7 +6666,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 0314ca291..eb5993d69 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -6664,7 +6664,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 c3aa4f488..054a74f35 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -6665,7 +6665,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 f8cb2f55b..9ba8973c8 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -6666,7 +6666,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 1d32dd666..9c70ed795 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/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/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 1f54074a9..4a3974589 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -6587,7 +6587,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 9257185b5..b72a5a190 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -6649,7 +6649,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 fc980762b..64f3df2a9 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -6661,7 +6661,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 607b6899e..a160f3d35 100644 --- a/src/app/commands/dgn_import.rs +++ b/src/app/commands/dgn_import.rs @@ -9,7 +9,9 @@ use std::sync::Arc; use super::*; use crate::io::model_import::ModelFormat; -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, }; @@ -54,7 +56,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()) } @@ -178,28 +180,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)), @@ -478,6 +490,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); @@ -498,7 +520,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); } @@ -506,7 +543,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) @@ -525,84 +563,34 @@ 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(); + let prefix = format!("{stem} - {}$0$", request.model); + Naming::Prefix(dgn_import::symbol_name(&prefix)) + } 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| 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 { + 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."); @@ -610,4 +598,249 @@ 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(); + // 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 joined = host_dir.join(&named); + let file = if joined.is_file() { + joined + } 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; + }; + // 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, + 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(); + // 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 { + 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 = 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}")); + 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(scale, scale, 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 9755dc41a..718d58609 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -860,6 +860,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 376f0ae63..0d7afbbc6 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 c7aab03bd..363e79ec7 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -2277,6 +2277,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; @@ -2314,6 +2315,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 2cd0935b8..cf30e5cb0 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 fa7f4b9e0..27308c3d4 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3494,7 +3494,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 4b15efb03..f1cd2f4dc 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, HashSet}; use codec::entities::{ - Arc, Circle, Ellipse, Insert, Line, LwPolyline, LwVertex, MText, Text, + Arc, AttachmentPoint, Circle, DrawingDirection, Ellipse, Insert, Line, LineSpacingStyle, + LwPolyline, LwVertex, MText, Point, Polyline3D, 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 { + Translate, + #[default] + 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 @@ -80,46 +109,124 @@ pub fn unit_display(short: &str) -> String { .to_string() } -fn layer_name(import: &DgnImport, level: u32) -> String { - import - .levels - .get(&level) - .map(|l| l.0.clone()) - .filter(|n| !n.is_empty()) - .unwrap_or_else(|| level.to_string()) -} - -/// 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 -} - -/// 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]> { - 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], - ] +/// 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() } -/// 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) +fn layer_name(import: &DgnImport, level: u32) -> String { + import + .levels + .get(&level) + .map(|l| symbol_name(&l.0)) + .filter(|n| !n.is_empty()) + .unwrap_or_else(|| level.to_string()) } -fn polyline(points: &[[f64; 2]], bulges: &[f64]) -> EntityType { +/// A cell's name as a block name: a leading `*` (an anonymous block +/// written out) is dropped. +pub fn block_name(cell: &str) -> String { + symbol_name(cell.trim_start_matches('*')) +} + +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: 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: 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; + std::array::from_fn(|c| center[c] + t.cos() * u[c] + t.sin() * v[c]) + }) + .collect() +} + +/// 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) + } +} + +/// 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) +} + +/// 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) { + 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; + pl.elevation = points.first().map_or(0.0, |p| p[2]); pl.vertices = points .iter() .enumerate() @@ -132,15 +239,51 @@ fn polyline(points: &[[f64; 2]], bulges: &[f64]) -> EntityType { EntityType::LwPolyline(pl) } +/// 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}; + 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() { + 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(); + boundary.flags.set_polyline(true); + boundary.edges.push(BoundaryEdge::Polyline(edge)); + hatch.add_path(boundary); + 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. -fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { - let mut points: Vec<[f64; 2]> = Vec::new(); +/// 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: [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); + .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; } @@ -162,16 +305,13 @@ fn chain(parts: &[DgnGeom]) -> (Vec<[f64; 2]>, Vec) { v, 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], - ] + } 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]) }; - 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,30 +328,109 @@ 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; - }; - 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)) +/// 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 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 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. + 0 if just == 6 => height / 2.0, + 0 => height, + 1 => height / 2.0, + 2 => 0.0, + _ => -0.3 * height, + }; + 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, + } +} + +/// 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 { + 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"; +/// 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") => { + let name = symbol_name(name); + if !styles.iter().any(|s| s.0 == name) { + styles.push((name.clone(), "txt.shx".to_string())); + } + name + } + _ => "Standard".to_string(), + }, DgnGeom::Text { font: Some(font), .. } => { @@ -225,223 +444,573 @@ 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: 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, style: &str) -> 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};{}}}", mtext_escape(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 = style.to_string(); + Some(EntityType::MText(Box::new(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 = 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 { + 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(Box::new(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). +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, + /// 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> { + /// `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 of its 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)))], + // A filled shape is its outline and a solid hatch inside it. + DgnGeom::Points(pts) if pts.len() >= 2 => { + let mut out = vec![polyline(pts, &[])]; + if element.filled { + out.extend(solid_hatch(pts, &[])); + } + out + } + 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 { + return Vec::new(); + } + 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(_), .. } => { + let style = text_style(geom, self.import, &mut self.out.styles); + text_entity(geom, &style).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() + .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, 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() + .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 { 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)); + 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(), + in_definition: false, + }; + 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()), + filled: false, + }; + c.layer(&element, false); } } - out.entities = convert_elements(import, &scale(&import.elements), explode_text_nodes, false, &mut out); - out + // Every text style of the file's table comes in, used or not. + for (_, name) in &import.text_styles { + 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; + for (name, elements) in &cells { + let entities = c.elements(elements, true, CellCopy::default(), 0); + 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; + // A cell left without a block (the point display cell, an empty or + // missing definition) places nothing: its instances are dropped. + 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, + }; + c.out.entities.retain(placed); + for (_, entities) in &mut c.out.blocks { + entities.retain(placed); + } + c.out } /// The Standard mapping of what a file uses: level → layer, line style → @@ -464,31 +1033,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 +1138,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 +1202,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..e39d0ef06 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, }; @@ -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, @@ -136,11 +172,18 @@ 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 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, depth: usize) -> 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<( @@ -203,31 +246,82 @@ 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; } + // 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 { 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, + filled: fill_index(e).is_some(), }; // 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 text node: its count of text components. + // 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 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()); + index += 1; + } + 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); + 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, or 74 in 3D). 7 => collect.open( uint16(e, 38).unwrap_or(0) as usize, - element(DgnGeom::TextNode(Vec::new())), + element(DgnGeom::TextNode { + lines: Vec::new(), + 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), + }), ), 12 | 14 => { let n = uint16(e, 38).unwrap_or(0) as usize; @@ -243,8 +337,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(a, 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, @@ -261,9 +356,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.clone()))); + collect.push(element(DgnGeom::Points(pts3))); push( &mut raw, &mut complex, @@ -271,6 +367,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 { (|| { @@ -312,9 +471,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(), })); @@ -344,16 +503,30 @@ 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; }; - 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 +538,36 @@ 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: origin3, 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), + style: None, })); 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) @@ -392,11 +575,17 @@ 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() { + 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 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 +596,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: poles3.clone(), + closed, + }, + ..header }); push( &mut raw, &mut complex, - polyline(&pts, false, Some((color, width)), None), + polyline(&pts, false, Some((color, -1.0)), None), ); } } @@ -430,21 +624,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], - z_scale: 1.0, + origin: [ox as f64, oy as f64, oz], + z_scale, })); raw.instances.push(Instance { name, @@ -647,14 +852,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), 0)); i = j; continue; } top.push(e); i += 1; } - let raw = read_run(&top, &colors); + 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, @@ -721,7 +926,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), 0).elements; cells.push((name, scaled_elements(&def, s, [0.0, 0.0]))); i = j; continue; @@ -729,7 +934,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), 0); let levels = (0..64u32) .map(|l| { let name = names.get(&(l as u8)).cloned().unwrap_or_else(|| l.to_string()); @@ -743,5 +948,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..32a66c759 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 ─────────────────────────────────────────────────────────── @@ -208,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() @@ -277,6 +284,34 @@ fn f64_at(e: &[u8], at: usize) -> Option { Some(f64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?)) } +/// 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 +} + /// 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 @@ -384,6 +419,99 @@ 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. +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,19 +682,36 @@ 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() { + // 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; } 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, + 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); @@ -578,6 +723,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 +735,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 +777,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 +795,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 +875,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, })); @@ -726,7 +899,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 { @@ -736,7 +910,18 @@ 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; + 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)? + }, )) })() } else { @@ -758,6 +943,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,11 +964,14 @@ 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, + style: text_style_id(e), })); raw.texts.push(Text { text, @@ -804,7 +1002,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 +1010,58 @@ 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 Some(&[lo, hi]) = e.get(108..110) else { + continue; + }; + let flags = u16::from_le_bytes([lo, hi]); + // 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; + 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 }); + } else { + collect.skip(); + } + } + } _ => {} } } @@ -925,6 +1175,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(); @@ -939,7 +1194,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 { @@ -983,12 +1238,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 { @@ -1153,16 +1410,114 @@ fn unit_name(metres: f64) -> String { .unwrap_or_default() } +/// 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 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<&str> = streams + .iter() + .filter_map(|(p, _)| p.strip_suffix("/Dgn^G/$1")) + .collect(); + dirs.sort(); + 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 +/// 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() +} + /// 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); - let (_, graphics, header) = all + 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 +1539,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 +1551,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 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]))); + } i = j; } else { i += 1; @@ -1210,13 +1570,28 @@ 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 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: [ + (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, + text_styles: text_style_table(&nm), }) } 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..ed63a9163 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -532,55 +532,112 @@ 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, + /// 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). +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, + /// 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. + 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 text node: its lines. - TextNode(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 +646,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,24 +664,53 @@ 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, + style, } => DgnGeom::Text { text: text.clone(), origin: p(*origin), height: height * s, width_factor: *width_factor, + length: length * s, rotation: *rotation, + just: *just, font: *font, + style: *style, }, DgnGeom::Cell { name, @@ -642,42 +728,69 @@ 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() { - if let DgnGeom::Chain(parts) | DgnGeom::TextNode(parts) = &mut header.geom { - parts.push(element.geom); + 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) + } + DgnGeom::CellGroup { parts, .. } => parts.push(element), + _ => {} } *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); } } } @@ -690,11 +803,31 @@ 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, + /// 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 +/// 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 f98136c9c..7bf444767 100644 --- a/src/ui/window/import_dialogs.rs +++ b/src/ui/window/import_dialogs.rs @@ -34,6 +34,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, // 3D Studio Import @@ -151,6 +153,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, @@ -790,21 +794,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![