From d583d9d421c3616103eb6ddc6c4a508954397a7b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:30:21 +0300 Subject: [PATCH] Block Definition command-line forms, block record data and dialog defaults -BLOCK, -WBLOCK, -BEDIT, ATTSYNC, BASE and INSBASE follow the reference prompts and messages; empty blocks; WBLOCK keeps object coordinates and stores INSBASE; annotative and hyperlink data on the block record; block preview images; remembered dialog choices; block aliases. Co-Authored-By: Claude Opus 5.5 --- assets/ocad.pgp | 7 + locales/en-US/opencadstudio.ftl | 8 +- locales/tr-TR/opencadstudio.ftl | 8 +- src/app/alias.rs | 14 +- src/app/block_editor.rs | 3 + src/app/command_driver/entity_commit.rs | 14 +- src/app/commands/block_cmdline.rs | 223 +++++ src/app/commands/blocks.rs | 186 +--- src/app/commands/inquiry.rs | 31 + src/app/commands/mod.rs | 6 + src/app/settings.rs | 20 + src/app/update/blocks_palette.rs | 9 +- src/app/update/file.rs | 116 +++ src/app/update/mod.rs | 137 +-- src/locale_catalog.rs | 6 +- src/modules/draw/modify/block_edit.rs | 3 + src/modules/insert/base_point.rs | 49 +- src/modules/insert/block_cmdline.rs | 1034 +++++++++++++++++++++++ src/modules/insert/create_block.rs | 89 +- src/modules/insert/mod.rs | 1 + src/modules/insert/wblock.rs | 19 +- src/scene/annotative.rs | 33 + src/scene/entity.rs | 207 ++++- src/sys.rs | 30 + src/ui/window/block_definition.rs | 41 +- src/ui/window/wblock.rs | 19 +- 26 files changed, 1878 insertions(+), 435 deletions(-) create mode 100644 src/app/commands/block_cmdline.rs create mode 100644 src/modules/insert/block_cmdline.rs diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 6c6a6b92c..30e1a8e59 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -13,6 +13,7 @@ PL, *PLINE A, *ARC C, *CIRCLE B, *BLOCK +-B, *-BLOCK I, *INSERT DO, *DONUT EL, *ELLIPSE @@ -58,6 +59,8 @@ AR, *ARRAY MA, *MATCHPROP VV, *QLAYER ATE, *ATTEDIT +-ATE, *-ATTEDIT +ATTE, *-ATTEDIT ; ── Clipboard ───────────────────────────────────────────────────────────── CC, *COPYCLIP @@ -68,6 +71,9 @@ PC, *PASTECLIP ATT, *ATTDEF -ATT, *-ATTDEF BE, *BEDIT +BC, *BCLOSE +BS, *BSAVE +BVS, *BVSTATE IAT, *IMAGEATTACH XA, *XATTACH XR, *XREF @@ -77,6 +83,7 @@ ICL, *IMAGECLIP IAD, *IMAGEADJUST ADC, *ADCENTER W, *WBLOCK +-W, *-WBLOCK ; ── Layers / groups ─────────────────────────────────────────────────────── LA, *LAYERS diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 0f4734280..557153766 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2397,7 +2397,7 @@ common = .hectometers = Hectometers .hyperlink-ellipsis = Hyperlink... .hyperlink-star = Hyperlink* - .invalid-character-in-block-name = Invalid character '__ocs_fmt_0__' in block name (\ / : * ? " < > | = ` are not allowed) + .invalid-character-in-block-name = Invalid character '__ocs_fmt_0__' in block name (\ < > / ? " : ; * | , = ` are not allowed) .light-years = Light Years .match-block-orientation = Match block orientation .microinches = Microinches @@ -6540,3 +6540,9 @@ keytips = .hint-tabs = Type a tab letter · Esc closes .hint-tab = { $tab } tab · Esc goes back one level .hint-items = Type an item letter · Esc goes back one level + +block-def = + .name-too-short = The name must be at least one character long. + .name-unsupported-chars = The name contains unsupported characters. The following characters are not supported: \ < > / ? " : ; * | , = ` + .definition-changed-redefine = The block definition has changed. Do you want to redefine it ? + .create-drawing-file = Create Drawing File diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index da6152f3a..bca6d678d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2381,7 +2381,7 @@ common = .hectometers = Hektometre .hyperlink-ellipsis = Köprü... .hyperlink-star = Köprü* - .invalid-character-in-block-name = Blok adında geçersiz karakter '__ocs_fmt_0__' (\ / : * ? " < > | = ` kullanılamaz) + .invalid-character-in-block-name = Blok adında geçersiz karakter '__ocs_fmt_0__' (\ < > / ? " : ; * | , = ` kullanılamaz) .light-years = Işık Yılı .match-block-orientation = Blok yönünü eşle .microinches = Mikroinç @@ -6462,3 +6462,9 @@ keytips = .hint-tabs = Sekme harfini yazın · Esc kapatır .hint-tab = { $tab } sekmesi · Esc bir düzey geri .hint-items = Öğe harfini yazın · Esc bir düzey geri + +block-def = + .name-too-short = Ad en az bir karakter uzunluğunda olmalıdır. + .name-unsupported-chars = Ad desteklenmeyen karakterler içeriyor. Şu karakterler desteklenmez: \ < > / ? " : ; * | , = ` + .definition-changed-redefine = Blok tanımı değişti. Yeniden tanımlamak istiyor musunuz? + .create-drawing-file = Çizim Dosyası Oluştur diff --git a/src/app/alias.rs b/src/app/alias.rs index acc50ee5e..19551287f 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -79,8 +79,9 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped /// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 -/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES). -const DEFAULT_ALIASES_VERSION: u32 = 9; +/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES); +/// version 10 the block command-line forms and block editor shortcuts. +const DEFAULT_ALIASES_VERSION: u32 = 10; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -104,6 +105,15 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { 7 => &[("DV", "DVIEW")], 8 => &[("-XR", "-XREF"), ("XC", "XCLIP"), ("ICL", "IMAGECLIP")], 9 => &[("IAD", "IMAGEADJUST")], + 10 => &[ + ("-B", "-BLOCK"), + ("-W", "-WBLOCK"), + ("BC", "BCLOSE"), + ("BS", "BSAVE"), + ("BVS", "BVSTATE"), + ("-ATE", "-ATTEDIT"), + ("ATTE", "-ATTEDIT"), + ], _ => &[], } } diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index 529a4c845..957c568c3 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -491,6 +491,9 @@ impl OpenCADStudio { self.tabs[i].history = history.main; self.tabs[i].scene.object_isolation = history.return_isolation; } + if discard && session.created_for_edit { + crate::scene::block_service::erase(&mut self.tabs[i].scene, &session.block_name); + } self.tabs[i].active_block_edit = None; self.restore_after_block_edit_close( i, diff --git a/src/app/command_driver/entity_commit.rs b/src/app/command_driver/entity_commit.rs index 35300a2f3..30e8de088 100644 --- a/src/app/command_driver/entity_commit.rs +++ b/src/app/command_driver/entity_commit.rs @@ -764,16 +764,21 @@ impl OpenCADStudio { mut options: Box, ) -> Option> { let i = self.active_tab; + // No objects at all defines an empty block; objects that were all on + // locked layers define nothing. + let picked = options.handles.len(); options .handles .retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); - if options.handles.is_empty() { + if picked > 0 && options.handles.is_empty() { self.command_line .push_info(crate::t!("No editable objects selected.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "BLOCK"); let name = options.name.clone(); + let redefined = options.redefine + && self.tabs[i].scene.document.block_records.get(&name).is_some(); match self.tabs[i].scene.create_block_with_options(*options) { Ok(insert_handle) => { self.tabs[i].dirty = true; @@ -784,8 +789,11 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.command_line - .push_output(crate::tf!("Block \"{name}\" created.").as_ref()); + // A new definition is not reported; a redefinition is. + if redefined { + self.command_line + .push_output(&format!("Block \"{name}\" redefined")); + } self.refresh_properties(); self.note_recent_block(&name); self.refresh_block_palette(); diff --git a/src/app/commands/block_cmdline.rs b/src/app/commands/block_cmdline.rs new file mode 100644 index 000000000..2da217b88 --- /dev/null +++ b/src/app/commands/block_cmdline.rs @@ -0,0 +1,223 @@ +//! Command-line Block Definition commands: -BLOCK, -WBLOCK, -BEDIT, +//! ATTSYNC, INSBASE, and the BATTMAN check for attributed blocks. The +//! interactive steps live in `modules::insert::block_cmdline`. + +use crate::app::{Message, OpenCADStudio}; +use crate::modules::insert::block_cmdline::{ + AttsyncCommand, BlockTable, DashBeditCommand, DashBlockCommand, DashWblockCommand, + WblockSource, +}; +use iced::Task; + +impl OpenCADStudio { + pub(super) fn dispatch_block_cmdline(&mut self, cmd: &str, i: usize) -> Option> { + use crate::command::CadCommand; + match cmd { + "-BLOCK" | "-BMAKE" => { + let preselected: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let table = BlockTable::of(&self.tabs[i].scene.document); + let units = self.tabs[i].scene.document.header.insertion_units; + let command = + DashBlockCommand::new(table, self.tabs[i].ucs_xform(), preselected, units); + self.command_line.push_output(&command.settings_line()); + self.start_block_cmdline(i, Box::new(command)); + } + "-WBLOCK" | "-WB" => { + let preselected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + let table = BlockTable::of(&self.tabs[i].scene.document); + let command = DashWblockCommand::new(table, preselected); + self.command_line.push_output(&command.settings_line()); + if crate::sys::file_dia() { + // FILEDIA 1: the output file comes from the file dialog, + // the rest from the command line. + return Some(Task::done(Message::WblockSave("-".to_string()))); + } + self.start_block_cmdline(i, Box::new(command)); + } + cmd if cmd.starts_with("_-WBLOCKFILE ") => { + let path = cmd["_-WBLOCKFILE ".len()..].trim().to_string(); + let preselected: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect(); + let table = BlockTable::of(&self.tabs[i].scene.document); + let mut command = DashWblockCommand::new(table, preselected); + let _ = command.on_text_input(&path); + self.start_block_cmdline(i, Box::new(command)); + } + cmd if cmd.starts_with("_WBLOCKWRITE ") => { + self.tabs[i].active_cmd = None; + let Ok(request) = + serde_json::from_str::(&cmd["_WBLOCKWRITE ".len()..]) + else { + return Some(Task::none()); + }; + let path = std::path::PathBuf::from(request["path"].as_str().unwrap_or("")); + let source = &request["source"]; + let source = if let Some(name) = source["block"].as_str() { + WblockSource::Block(name.to_string()) + } else if source["drawing"].as_bool() == Some(true) { + WblockSource::Drawing + } else { + let handles = source["handles"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_u64()) + .map(codec::Handle::new) + .collect() + }) + .unwrap_or_default(); + let b = &source["base"]; + let base = glam::DVec3::new( + b[0].as_f64().unwrap_or(0.0), + b[1].as_f64().unwrap_or(0.0), + b[2].as_f64().unwrap_or(0.0), + ); + let mode = crate::modules::insert::block_cmdline::mode_from_code( + source["mode"].as_u64().unwrap_or(0) as u8, + ); + WblockSource::Objects { + handles, + base, + mode, + } + }; + let unit = self.tabs[i].scene.document.header.insertion_units; + return Some(self.wblock_write(i, source, path, unit)); + } + "-BEDIT" => { + if self.tabs[i].refedit_session.is_some() { + self.command_line.push_error( + crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(), + ); + return Some(Task::none()); + } + let table = BlockTable::of(&self.tabs[i].scene.document); + self.start_block_cmdline(i, Box::new(DashBeditCommand::new(table))); + } + "ATTSYNC" => { + let doc = &self.tabs[i].scene.document; + let table = BlockTable::of(doc); + let references = doc + .entities() + .filter_map(|entity| match entity { + codec::EntityType::Insert(insert) => { + Some((insert.common.handle, insert.block_name.clone())) + } + _ => None, + }) + .collect(); + self.start_block_cmdline(i, Box::new(AttsyncCommand::new(table, references))); + } + cmd if cmd.starts_with("_ATTSYNCBLOCK ") => { + self.tabs[i].active_cmd = None; + let block = cmd["_ATTSYNCBLOCK ".len()..].trim().to_string(); + self.attsync_block(i, &block); + self.command_line.push_output("ATTSYNC complete."); + } + "INSBASE" => { + let base = self.current_insertion_base(i); + let command = crate::modules::insert::base_point::BaseCommand::insbase(base); + self.start_block_cmdline(i, Box::new(command)); + } + cmd if cmd == "ATTMAN" + || cmd == "BATTMAN" + || cmd.starts_with("ATTMAN ") + || cmd.starts_with("BATTMAN ") => + { + if BlockTable::of(&self.tabs[i].scene.document) + .attributed + .is_empty() + { + self.command_line + .push_output("This drawing contains no attributed blocks."); + return Some(Task::none()); + } + self.open_attedit_dialog(); + } + _ => return None, + } + Some(self.finish_dispatch(cmd)) + } + + fn start_block_cmdline(&mut self, i: usize, command: Box) { + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[i].active_cmd = Some(command); + } + + /// The insertion base of the active space (INSBASE / BASE). + pub(in crate::app) fn current_insertion_base(&self, i: usize) -> glam::DVec3 { + let header = &self.tabs[i].scene.document.header; + let base = if self.tabs[i].scene.current_layout != "Model" { + header.paper_space_insertion_base + } else { + header.model_space_insertion_base + }; + glam::DVec3::new(base.x, base.y, base.z) + } + + /// Reconcile every editable reference of `block` with the block's + /// attribute definitions: attributes whose tag is gone are dropped, new + /// ones are added with their default value, and the values of the rest + /// are kept. + pub(in crate::app) fn attsync_block(&mut self, i: usize, block: &str) { + let doc = &self.tabs[i].scene.document; + let attdefs: Vec<(String, String)> = doc + .block_records + .get(block) + .map(|br| { + br.entity_handles + .iter() + .filter_map(|h| doc.get_entity(*h)) + .filter_map(|e| match e { + codec::EntityType::AttributeDefinition(a) => { + Some((a.tag.clone(), a.default_value.clone())) + } + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + let inserts: Vec<_> = doc + .entities() + .filter_map(|entity| match entity { + codec::EntityType::Insert(insert) + if insert.block_name.eq_ignore_ascii_case(block) + && !self.tabs[i].scene.is_layer_locked(insert.common.handle) => + { + Some(insert.common.handle) + } + _ => None, + }) + .collect(); + if inserts.is_empty() { + return; + } + self.push_undo_snapshot(i, "ATTSYNC"); + let mut changes = Vec::new(); + for handle in inserts { + let Some(codec::EntityType::Insert(ins)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + ins.attributes + .retain(|a| attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))); + for (tag, default) in &attdefs { + if !ins.attributes.iter().any(|a| a.tag.eq_ignore_ascii_case(tag)) { + ins.attributes.push(codec::entities::AttributeEntity::new( + tag.clone(), + default.clone(), + )); + } + } + changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); + } + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].dirty = true; + } +} diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index b6f985351..dbf4a8f3c 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -55,6 +55,14 @@ impl OpenCADStudio { return Task::none(); }; self.active_modal = None; + { + let mut remembered = crate::modules::insert::block_cmdline::defaults(); + remembered.base_on_screen = state.base_point_specify_onscreen; + remembered.objects_on_screen = state.objects_specify_onscreen; + remembered.mode = state.object_mode; + remembered.allow_exploding = state.allow_exploding; + } + self.persist_settings_if_changed(); let i = self.active_tab; let base_point = state.parse_base_point(); let ucs = self.tabs[i].ucs_xform(); @@ -121,7 +129,7 @@ impl OpenCADStudio { let rest = cmd.strip_prefix("BASE").unwrap_or("").trim(); if rest.is_empty() { use crate::modules::insert::base_point::BaseCommand; - let c = BaseCommand::new(); + let c = BaseCommand::new(self.current_insertion_base(i)); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } else { @@ -150,20 +158,10 @@ impl OpenCADStudio { .model_space_insertion_base = pt; } self.tabs[i].dirty = true; - let space = if is_paper { - "paper space" - } else { - "model space" - }; - self.command_line.push_output( - crate::tf!( - "Base point ({}, {}, {}) set for {space}.", - nums[0], - nums[1], - z - ) - .as_ref(), - ); + if z != 0.0 { + self.command_line + .push_output("Beware, Z insertion base is not zero."); + } } else { self.command_line .push_error(crate::t!("Usage: BASE [z]").as_ref()); @@ -470,26 +468,6 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::BlockDefinition); } - "-BLOCK" | "-BMAKE" => { - let handles: Vec<_> = self.tabs[i] - .scene - .selected_entities() - .into_iter() - .map(|(h, _)| h) - .collect(); - if handles.is_empty() { - use crate::modules::draw::select::SelectObjectsCommand; - let cmd = SelectObjectsCommand::new("-BLOCK"); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } else { - use crate::modules::insert::create_block::CreateBlockCommand; - let cmd = CreateBlockCommand::new(handles); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); - } - } - cmd if cmd.starts_with("BLOCK_POINT_PICKED ") => { let parts: Vec = cmd["BLOCK_POINT_PICKED ".len()..] .split_whitespace() @@ -631,108 +609,21 @@ impl OpenCADStudio { } } - // ATTSYNC — reconcile every insert of against the - // block's current attribute definitions: drop attributes whose tag - // no longer exists and add any newly-defined ones (keeping the values - // of attributes that remain). - "ATTSYNC" => { - use crate::command::ValuePromptCommand; - let c = ValuePromptCommand::new("ATTSYNC", "ATTSYNC block name to sync:"); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); - } + // ATTSYNC : the named block, without prompts. cmd if cmd.starts_with("ATTSYNC ") => { - let arg = cmd.trim_start_matches("ATTSYNC").trim(); - let blocks = self.tabs[i].scene.custom_block_names(); - if arg.is_empty() { - self.command_line.push_info( - crate::tf!( - "Usage: ATTSYNC . Blocks: {}", - if blocks.is_empty() { - "(none)".to_string() - } else { - blocks.join(", ") - } - ) - .as_ref(), - ); - return Some(Task::none()); - } - let Some(block) = blocks.iter().find(|b| b.eq_ignore_ascii_case(arg)).cloned() - else { - self.command_line - .push_error(crate::tf!("ATTSYNC: no block named \"{arg}\".").as_ref()); - return Some(Task::none()); - }; - // Gather the block's attribute definitions (tag, default value). - let doc = &self.tabs[i].scene.document; - let attdefs: Vec<(String, String)> = doc - .block_records - .get(&block) - .map(|br| { - br.entity_handles - .iter() - .filter_map(|h| doc.get_entity(*h)) - .filter_map(|e| match e { - codec::EntityType::AttributeDefinition(a) => { - Some((a.tag.clone(), a.default_value.clone())) - } - _ => None, - }) - .collect() - }) - .unwrap_or_default(); - let inserts: Vec<_> = self.tabs[i] - .scene - .document - .entities() - .filter_map(|entity| match entity { - codec::EntityType::Insert(insert) - if insert.block_name.eq_ignore_ascii_case(&block) - && !self.tabs[i].scene.is_layer_locked(insert.common.handle) => - { - Some(insert.common.handle) - } - _ => None, - }) - .collect(); - if inserts.is_empty() { - self.command_line - .push_error(crate::t!("ATTSYNC: no editable block references.").as_ref()); - return Some(Task::none()); - } - self.push_undo_snapshot(i, "ATTSYNC"); - let mut synced = 0usize; - let mut changes = Vec::new(); - for handle in inserts { - let Some(codec::EntityType::Insert(ins)) = - self.tabs[i].scene.document.get_entity_mut(handle) - else { - continue; - }; - ins.attributes - .retain(|a| attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))); - for (tag, default) in &attdefs { - if !ins - .attributes - .iter() - .any(|a| a.tag.eq_ignore_ascii_case(tag)) - { - ins.attributes.push(codec::entities::AttributeEntity::new( - tag.clone(), - default.clone(), - )); - } + let arg = cmd.trim_start_matches("ATTSYNC").trim().to_string(); + let table = + crate::modules::insert::block_cmdline::BlockTable::of(&self.tabs[i].scene.document); + match table.attributed.iter().find(|b| b.eq_ignore_ascii_case(&arg)) { + Some(block) => { + let block = block.clone(); + self.attsync_block(i, &block); + self.command_line.push_output("ATTSYNC complete."); } - synced += 1; - changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); + None => self.command_line.push_output(&format!( + "Block table contains no attributed block named {arg}." + )), } - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "ATTSYNC: synchronised {synced} insert(s) of \"{block}\" against {} attribute definition(s).", - attdefs.len() - ).as_ref()); } // ADCENTER / CONTENTBROWSER — report the drawing's named content @@ -792,17 +683,6 @@ impl OpenCADStudio { self.open_blocks_palette(Some(tab)); } - // ATTMAN / BATTMAN — the Block Attribute Manager. Rather than a - // command-line listing, both route to the attribute editor - // (ATTEDIT): edit the selected block's attributes, or pick a block. - cmd if cmd == "ATTMAN" - || cmd == "BATTMAN" - || cmd.starts_with("ATTMAN ") - || cmd.starts_with("BATTMAN ") => - { - self.open_attedit_dialog(); - } - "PDFIMPORT" => { use crate::command::CadCommand; let command = crate::modules::insert::pdf_import::PdfImportCommand::new(); @@ -937,16 +817,16 @@ impl OpenCADStudio { .collect(); let existing_blocks = self.tabs[i].scene.custom_block_names(); let default_unit = self.tabs[i].scene.document.header.insertion_units; - let default_folder = self.tabs[i] + // "new block" in the drawing's folder, or in Documents for an + // unsaved drawing; the file type is added when it is written. + let folder = self.tabs[i] .current_path .as_ref() - .and_then(|p| p.parent()) - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - let default_path = if default_folder.is_empty() { - "new_block.dwg".to_string() - } else { - format!("{}/new_block.dwg", default_folder.replace('\\', "/")) + .and_then(|p| p.parent().map(|p| p.to_path_buf())) + .or_else(crate::sys::documents_dir); + let default_path = match folder { + Some(folder) => folder.join("new block").to_string_lossy().to_string(), + None => "new block".to_string(), }; self.wblock = Some(crate::ui::window::wblock::WblockState::new( existing_blocks, diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index d5fbd9d9f..f5f5192fb 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -512,6 +512,36 @@ impl OpenCADStudio { } }; + // A name that is not a block yet opens an empty definition, + // dropped again when the editor closes without saving. + let mut created_for_edit = false; + if self.tabs[i].scene.document.block_records.get(&block_name).is_none() + && cmd.starts_with("BEDIT_OPEN:") + && crate::ui::window::block_definition::invalid_block_name_char(&block_name) + .is_none() + && !block_name.trim().is_empty() + && !block_name.starts_with('*') + { + let options = crate::scene::CreateBlockOptions { + name: block_name.clone(), + handles: Vec::new(), + base_point: glam::DVec3::ZERO, + world_to_block: codec::types::Transform::identity(), + block_to_world: codec::types::Transform::identity(), + mode: crate::ui::window::block_definition::BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: self.tabs[i].scene.document.header.insertion_units, + description: String::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + redefine: false, + }; + created_for_edit = + self.tabs[i].scene.create_block_with_options(options).is_ok(); + } // Resolve the block record; reject external references (xrefs). let (br_handle, is_xref) = match self.tabs[i].scene.document.block_records.get(&block_name) { @@ -575,6 +605,7 @@ impl OpenCADStudio { return_camera, editor_camera: current_camera, editor_ucs: None, + created_for_edit, }); self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1); // Every BEDIT tab owns an isolated local UCS. Never inherit or diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1ea774d8c..a5e23c182 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -5,6 +5,7 @@ use iced::Task; use std::path::PathBuf; mod attdef; +mod block_cmdline; mod blocks; mod blockvars; mod bvisibility; @@ -306,6 +307,9 @@ impl OpenCADStudio { if let Some(t) = self.dispatch_bvisibility(cmd, i) { return Some(t); } + if let Some(t) = self.dispatch_block_cmdline(cmd, i) { + return Some(t); + } if let Some(t) = self.dispatch_blocks(cmd, i) { return Some(t); } @@ -552,6 +556,8 @@ inventory::submit!(crate::command::CommandRegistration { "INSNAME", "ATTMAN", "BATTMAN", + "-BEDIT", + "INSBASE", // Drawing-content overview. "ADCENTER", "CONTENTBROWSER", diff --git a/src/app/settings.rs b/src/app/settings.rs index a63453fb1..d60a06421 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -421,6 +421,20 @@ pub struct UserSettings { pub image_frame: i16, /// The Attribute Definition dialog's "Specify on-screen" choice. pub attdef_on_screen: bool, + /// Block Definition dialog: base point "Specify On-screen". + pub block_base_on_screen: bool, + /// Block Definition dialog: objects "Specify On-screen". + pub block_objects_on_screen: bool, + /// Block Definition dialog / -BLOCK object conversion (0 Retain, + /// 1 Convert, 2 Delete). + pub block_object_mode: u8, + /// Block Definition dialog: "Allow exploding". + pub block_allow_exploding: bool, + /// Block Definition dialog: open the new block in the block editor. + pub block_open_in_editor: bool, + /// Write Block dialog / -WBLOCK object conversion (0 Retain, 1 Convert, + /// 2 Delete). + pub wblock_object_mode: u8, /// FIELDDISPLAY: fields show on a gray background. pub field_display: bool, /// XDWGFADECTL: how much referenced drawings fade (-90..=90; negative is off). @@ -892,6 +906,12 @@ impl Default for UserSettings { grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, image_frame: 1, attdef_on_screen: true, + block_base_on_screen: false, + block_objects_on_screen: false, + block_object_mode: 1, + block_allow_exploding: true, + block_open_in_editor: false, + wblock_object_mode: 0, field_display: true, xref_fade: 50, file_dia: true, diff --git a/src/app/update/blocks_palette.rs b/src/app/update/blocks_palette.rs index bbea5ab38..88189d53a 100644 --- a/src/app/update/blocks_palette.rs +++ b/src/app/update/blocks_palette.rs @@ -629,14 +629,7 @@ impl OpenCADStudio { self.block_palette.source_tab_id = Some(self.tabs[i].id); self.block_palette.source_block_epoch = self.tabs[i].scene.block_epoch; let doc = &self.tabs[i].scene.document; - let annotative = |name: &str| { - doc.block_records.get(name).is_some_and(|br| { - matches!( - doc.get_entity(br.block_entity_handle), - Some(codec::EntityType::Block(b)) if b.common.extended_data.records().iter().any(|r| r.application_name == "AcadAnnotative") - ) - }) - }; + let annotative = |name: &str| crate::scene::annotative::block_is_annotative(doc, name); let entries: Vec = names .into_iter() .map(|name| BlockEntry { diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 94f571b71..39c277217 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -768,6 +768,8 @@ impl OpenCADStudio { /// Snapshot the persisted UI preferences from live state. pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { + // One copy: the guard must not be held across the literal below. + let block_defaults = *crate::modules::insert::block_cmdline::defaults(); crate::app::settings::UserSettings { spacemouse: self.spacemouse_preferences, dyn_input: self.dyn_input, @@ -795,6 +797,14 @@ impl OpenCADStudio { grip_object_limit: self.grip_object_limit, image_frame: crate::scene::frame::profile_image_mode(), attdef_on_screen: crate::modules::draw::draw::attdef::session().on_screen, + block_base_on_screen: block_defaults.base_on_screen, + block_objects_on_screen: block_defaults.objects_on_screen, + block_object_mode: crate::modules::insert::block_cmdline::mode_code(block_defaults.mode), + block_allow_exploding: block_defaults.allow_exploding, + block_open_in_editor: block_defaults.open_in_editor, + wblock_object_mode: crate::modules::insert::block_cmdline::mode_code( + block_defaults.wblock_mode, + ), field_display: crate::entities::field::display(), xref_fade: crate::scene::cache::block_cache::xref_fade_ctl(), file_dia: crate::sys::file_dia(), @@ -920,6 +930,16 @@ impl OpenCADStudio { self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); crate::scene::frame::set_profile_image_mode(s.image_frame); crate::modules::draw::draw::attdef::session().on_screen = s.attdef_on_screen; + { + use crate::modules::insert::block_cmdline as bc; + let mut remembered = bc::defaults(); + remembered.base_on_screen = s.block_base_on_screen; + remembered.objects_on_screen = s.block_objects_on_screen; + remembered.mode = bc::mode_from_code(s.block_object_mode); + remembered.allow_exploding = s.block_allow_exploding; + remembered.open_in_editor = s.block_open_in_editor; + remembered.wblock_mode = bc::mode_from_code(s.wblock_object_mode); + } crate::entities::field::set_display(s.field_display); crate::scene::cache::block_cache::set_xref_fade_ctl(s.xref_fade); self.ribbon.xref_fade = crate::scene::cache::block_cache::xref_fade_ctl(); @@ -2327,6 +2347,102 @@ impl OpenCADStudio { ]) } + /// Write `source` to `path` (WBLOCK and -WBLOCK): an existing block, the + /// whole drawing, or objects, which then are kept, converted to a block + /// named after the file, or deleted. + pub(in crate::app) fn wblock_write( + &mut self, + i: usize, + source: crate::modules::insert::block_cmdline::WblockSource, + path: std::path::PathBuf, + unit: i16, + ) -> Task { + let mut path = path; + if path.extension().is_none() { + path.set_extension("dwg"); + } + let document = self.tabs[i].scene.document_for_save(); + if let crate::modules::insert::block_cmdline::WblockSource::Objects { + handles, + base, + mode, + } = &source + { + match mode { + crate::ui::window::block_definition::BlockObjectMode::Retain => {} + crate::ui::window::block_definition::BlockObjectMode::Delete => { + self.push_undo_snapshot(i, "WBLOCK"); + self.tabs[i].scene.erase_entities(handles); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + } + crate::ui::window::block_definition::BlockObjectMode::Convert => { + let name = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("WBLOCK") + .to_string(); + self.push_undo_snapshot(i, "WBLOCK"); + let ucs = self.tabs[i].ucs_xform(); + let options = crate::scene::CreateBlockOptions { + name, + handles: handles.clone(), + base_point: *base, + world_to_block: ucs.to_ucs_transform_at(*base), + block_to_world: ucs.to_wcs_transform_at(*base), + mode: crate::ui::window::block_definition::BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit, + description: String::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + redefine: true, + }; + let _ = self.tabs[i].scene.create_block_with_options(options); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_geometry(); + self.refresh_properties(); + } + } + } + let display = match &source { + crate::modules::insert::block_cmdline::WblockSource::Block(name) => name.clone(), + _ => "*".to_string(), + }; + let worker_path = path.clone(); + background_task( + crate::t!("WBLOCK save"), + move || { + let out_doc = match source { + crate::modules::insert::block_cmdline::WblockSource::Block(name) => { + let mut d = + crate::modules::insert::wblock::extract_block_to_doc(&document, &name) + .map_err(|e| e.to_string())?; + d.header.insertion_units = unit; + d + } + crate::modules::insert::block_cmdline::WblockSource::Drawing => { + let mut d = document.clone(); + d.header.insertion_units = unit; + d + } + crate::modules::insert::block_cmdline::WblockSource::Objects { handles, base, .. } => { + crate::modules::insert::wblock::extract_entities_to_doc_with_base( + &document, &handles, base, unit, + ) + .map_err(|e| e.to_string())? + } + }; + crate::io::save(&out_doc, &worker_path).map_err(|e| e.to_string()) + }, + move |result| Message::WblockWriteFinished(display, path, result), + ) + } + pub(super) fn on_wblock_save_result_some( &mut self, block_name: String, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14443aa96..af78597c2 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1774,10 +1774,15 @@ impl OpenCADStudio { Message::WblockSave(block_name) => { let name = block_name.clone(); + let title = if block_name == "-" { + crate::t!("Create Drawing File").into_owned() + } else { + crate::t!("Save Block As").into_owned() + }; Task::perform( async move { let path = crate::sys::file_dialog() - .set_title(crate::t!("Save Block As").as_ref()) + .set_title(&title) .set_file_name("block.dwg") .add_filter(crate::t!("DWG Files").as_ref(), &["dwg"]) .save_file() @@ -1789,6 +1794,10 @@ impl OpenCADStudio { ) } + // -WBLOCK with FILEDIA 1: the file came from the dialog, the + // command line asks for the rest. + Message::WblockSaveResult(block_name, Some(path)) if block_name == "-" => self + .dispatch_command(&format!("_-WBLOCKFILE {}", path.display())), Message::WblockSaveResult(block_name, Some(path)) => { self.on_wblock_save_result_some(block_name, path) } @@ -1796,14 +1805,12 @@ impl OpenCADStudio { Message::WblockSaveResult(_, None) => Task::none(), Message::WblockWriteFinished(block_name, path, result) => { - match result { - Ok(()) => self.command_line.push_output( - crate::tf!("WBLOCK Saved \"{block_name}\" → \"{}\"", path.display()) - .as_ref(), - ), - Err(error) => self - .command_line - .push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()), + // A written file is not reported (as in the reference); a + // failed write is. + let _ = (&block_name, &path); + if let Err(error) = result { + self.command_line + .push_error(crate::tf!("WBLOCK save failed: {error}").as_ref()); } Task::none() } @@ -5745,13 +5752,14 @@ impl OpenCADStudio { }; let name = state.name.trim().to_string(); if name.is_empty() { - state.error_message = - Some(crate::t!("Block name cannot be empty.").into_owned()); + state.error_message = Some( + crate::t!("The name must be at least one character long.").into_owned(), + ); return Task::none(); } if let Some(_ch) = state.invalid_name_char() { state.error_message = Some( - crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `") + crate::t!("The name contains unsupported characters. The following characters are not supported: \\ < > / ? \" : ; * | , = `") .into_owned(), ); return Task::none(); @@ -5989,96 +5997,25 @@ impl OpenCADStudio { let state = self.wblock.take().unwrap(); self.active_modal = None; - let document = self.tabs[i].scene.document_for_save(); - let source_mode = state.source_mode; - let block_name = state.block_name.trim().to_string(); - let handles = state.selected_handles.clone(); - let base_point = state.parse_base_point(); - let unit = state.unit; - let object_mode = state.object_mode; - - if source_mode == WblockSourceMode::Objects { - match object_mode { - WblockObjectMode::Retain => {} - WblockObjectMode::Delete => { - self.push_undo_snapshot(i, "WBLOCK"); - self.tabs[i].scene.erase_entities(&handles); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); - self.refresh_properties(); - } - WblockObjectMode::Convert => { - let block_name_for_conv = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("WBLOCK") - .to_string(); - self.push_undo_snapshot(i, "WBLOCK"); - let ucs = self.tabs[i].ucs_xform(); - let world_to_block = ucs.to_ucs_transform_at(base_point); - let block_to_world = ucs.to_wcs_transform_at(base_point); - let options = crate::scene::CreateBlockOptions { - name: block_name_for_conv, - handles: handles.clone(), - base_point, - world_to_block, - block_to_world, - mode: crate::ui::window::block_definition::BlockObjectMode::Convert, - annotative: false, - match_orientation: false, - scale_uniformly: false, - allow_exploding: true, - unit, - description: String::new(), - hyperlink_url: String::new(), - hyperlink_desc: String::new(), - redefine: true, - }; - let _ = self.tabs[i].scene.create_block_with_options(options); - self.tabs[i].dirty = true; - self.tabs[i].scene.bump_geometry(); - self.refresh_properties(); - } - } - } - - let worker_path = path.clone(); - let display_name = match source_mode { - WblockSourceMode::Block => block_name.clone(), - WblockSourceMode::EntireDrawing => "*".to_string(), - WblockSourceMode::Objects => "*".to_string(), + use crate::modules::insert::block_cmdline::WblockSource; + use crate::ui::window::block_definition::BlockObjectMode; + let mode = match state.object_mode { + WblockObjectMode::Retain => BlockObjectMode::Retain, + WblockObjectMode::Convert => BlockObjectMode::Convert, + WblockObjectMode::Delete => BlockObjectMode::Delete, }; - let worker_display = display_name.clone(); - - file::background_task( - crate::t!("WBLOCK save"), - move || { - let out_doc = match source_mode { - WblockSourceMode::Block => { - let mut d = crate::modules::insert::wblock::extract_block_to_doc( - &document, - &block_name, - ) - .map_err(|e| e.to_string())?; - d.header.insertion_units = unit; - d - } - WblockSourceMode::EntireDrawing => { - let mut d = document.clone(); - d.header.insertion_units = unit; - d - } - WblockSourceMode::Objects => { - crate::modules::insert::wblock::extract_entities_to_doc_with_base( - &document, &handles, base_point, unit, - ) - .map_err(|e| e.to_string())? - } - }; - crate::io::save(&out_doc, &worker_path).map_err(|e| e.to_string()) + crate::modules::insert::block_cmdline::defaults().wblock_mode = mode; + self.persist_settings_if_changed(); + let source = match state.source_mode { + WblockSourceMode::Block => WblockSource::Block(state.block_name.trim().to_string()), + WblockSourceMode::EntireDrawing => WblockSource::Drawing, + WblockSourceMode::Objects => WblockSource::Objects { + handles: state.selected_handles.clone(), + base: state.parse_base_point(), + mode, }, - move |result| Message::WblockWriteFinished(worker_display, path, result), - ) + }; + self.wblock_write(i, source, path, state.unit) } Message::ToleranceDialogField(field) => { if let Some(state) = self.geometric_tolerance.as_mut() { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 3d578321d..3926ea42e 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,10 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "The name must be at least one character long." => Some(("block-def", "name-too-short")), + "The name contains unsupported characters. The following characters are not supported: \\ < > / ? \" : ; * | , = `" => Some(("block-def", "name-unsupported-chars")), + "The block definition has changed. Do you want to redefine it ?" => Some(("block-def", "definition-changed-redefine")), + "Create Drawing File" => Some(("block-def", "create-drawing-file")), "Add to Working Set" => Some(("ref-edit", "add-to-working-set")), "Remove from Working Set" => Some(("ref-edit", "remove-from-working-set")), "Select reference:" => Some(("ref-edit", "select-reference")), @@ -5943,7 +5947,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Hectometers" => Some(("common", "hectometers")), "Hyperlink*" => Some(("common", "hyperlink-star")), "Hyperlink..." => Some(("common", "hyperlink-ellipsis")), - "Invalid character '{invalid_char}' in block name (\\ / : * ? \" < > | = ` are not allowed)" => Some(("common", "invalid-character-in-block-name")), + "Invalid character '{invalid_char}' in block name (\\ < > / ? \" : ; * | , = ` are not allowed)" => Some(("common", "invalid-character-in-block-name")), "Light Years" => Some(("common", "light-years")), "Match block orientation" => Some(("common", "match-block-orientation")), "Microinches" => Some(("common", "microinches")), diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index 3d7c0a0d1..4321db56e 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -70,6 +70,9 @@ pub struct BlockEditSession { /// Transient UCS being manipulated in this block tab. On commit its full /// frame is baked into the definition and this returns to `None`. pub editor_ucs: Option, + /// The definition was created empty to be edited (a new name at BEDIT / + /// -BEDIT); closing without saving removes it again. + pub created_for_edit: bool, } // ── BEDIT pick command ───────────────────────────────────────────────────── diff --git a/src/modules/insert/base_point.rs b/src/modules/insert/base_point.rs index f0984affa..c41765aa6 100644 --- a/src/modules/insert/base_point.rs +++ b/src/modules/insert/base_point.rs @@ -1,27 +1,56 @@ -use crate::t; - use crate::command::{CadCommand, CmdResult}; use glam::DVec3; // ── Command implementation ──────────────────────────────────────────────── -/// Picks one point and delegates to the inline `BASE ` handler, -/// which writes the active space's insertion base point onto the document. -pub struct BaseCommand; +/// BASE (`Enter base point :`) and INSBASE +/// (`Enter new value for INSBASE :`): a point sets the active space's +/// insertion base through the inline `BASE ` handler; Enter keeps +/// the current one. +pub struct BaseCommand { + current: DVec3, + variable: bool, +} impl BaseCommand { - pub fn new() -> Self { - Self + pub fn new(current: DVec3) -> Self { + Self { + current, + variable: false, + } } + + /// The INSBASE system variable's prompt. + pub fn insbase(current: DVec3) -> Self { + Self { + current, + variable: true, + } + } +} + +fn point_text(p: DVec3) -> String { + format!("{:.4},{:.4},{:.4}", p.x, p.y, p.z) } impl CadCommand for BaseCommand { fn name(&self) -> &'static str { - "BASE" + if self.variable { + "INSBASE" + } else { + "BASE" + } } fn prompt(&self) -> String { - t!("BASE Specify base point:").into_owned() + if self.variable { + format!( + "Enter new value for INSBASE <{}>:", + point_text(self.current) + ) + } else { + format!("Enter base point <{}>:", point_text(self.current)) + } } fn on_point(&mut self, pt: DVec3) -> CmdResult { @@ -29,7 +58,7 @@ impl CadCommand for BaseCommand { } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + CmdResult::Measurement(String::new()) } } diff --git a/src/modules/insert/block_cmdline.rs b/src/modules/insert/block_cmdline.rs new file mode 100644 index 000000000..90671562c --- /dev/null +++ b/src/modules/insert/block_cmdline.rs @@ -0,0 +1,1034 @@ +//! Command-line forms of the Block Definition tools: -BLOCK, -WBLOCK, +//! -BEDIT and ATTSYNC, with the reference's prompts and messages, and the +//! choices the Block Definition / Write Block dialogs remember. + +use codec::{CadDocument, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind}; +use crate::ui::window::block_definition::{invalid_block_name_char, BlockObjectMode}; + +// ── Remembered dialog choices ───────────────────────────────────────────── + +/// What the Block Definition and Write Block dialogs remember between +/// uses (and the profile keeps): the on-screen switches, the object +/// conversion, Allow exploding and Open in block editor. -BLOCK and -WBLOCK +/// start from the same conversion. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct BlockDefaults { + pub base_on_screen: bool, + pub objects_on_screen: bool, + pub mode: BlockObjectMode, + pub allow_exploding: bool, + pub open_in_editor: bool, + pub wblock_mode: BlockObjectMode, +} + +static DEFAULTS: std::sync::Mutex = std::sync::Mutex::new(BlockDefaults { + base_on_screen: false, + objects_on_screen: false, + mode: BlockObjectMode::Convert, + allow_exploding: true, + open_in_editor: false, + wblock_mode: BlockObjectMode::Retain, +}); + +pub fn defaults() -> std::sync::MutexGuard<'static, BlockDefaults> { + DEFAULTS.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The settings code of a conversion (0 Retain, 1 Convert, 2 Delete). +pub fn mode_code(mode: BlockObjectMode) -> u8 { + match mode { + BlockObjectMode::Retain => 0, + BlockObjectMode::Convert => 1, + BlockObjectMode::Delete => 2, + } +} + +pub fn mode_from_code(code: u8) -> BlockObjectMode { + match code { + 0 => BlockObjectMode::Retain, + 2 => BlockObjectMode::Delete, + _ => BlockObjectMode::Convert, + } +} + +fn mode_name(mode: BlockObjectMode) -> &'static str { + match mode { + BlockObjectMode::Retain => "Retain", + BlockObjectMode::Convert => "Convert", + BlockObjectMode::Delete => "Delete", + } +} + +/// `Current settings: Object conversion=…`, printed as -BLOCK and -WBLOCK start. +pub fn current_settings_line(mode: BlockObjectMode) -> String { + format!("Current settings: Object conversion={}", mode_name(mode)) +} + +// ── Block table listing (`?`) ───────────────────────────────────────────── + +/// The block names of a drawing as the `?` listing sorts and counts them. +#[derive(Clone, Debug, Default)] +pub struct BlockTable { + /// (name, kind) for every block but the model and paper spaces. + pub entries: Vec<(String, BlockKind)>, + /// Names of the blocks that hold attribute definitions. + pub attributed: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BlockKind { + User, + External, + Dependent, + Unnamed, +} + +impl BlockTable { + pub fn of(doc: &CadDocument) -> Self { + let mut entries = Vec::new(); + let mut attributed = Vec::new(); + for br in doc.block_records.iter() { + let upper = br.name.to_ascii_uppercase(); + if upper == "*MODEL_SPACE" || upper.starts_with("*PAPER_SPACE") { + continue; + } + let kind = if br.flags.is_xref || br.flags.is_xref_overlay { + BlockKind::External + } else if br.name.contains('|') { + BlockKind::Dependent + } else if br.name.starts_with('*') || br.flags.anonymous { + BlockKind::Unnamed + } else { + BlockKind::User + }; + if kind == BlockKind::User + && br.entity_handles.iter().any(|h| { + matches!( + doc.get_entity(*h), + Some(codec::EntityType::AttributeDefinition(_)) + ) + }) + { + attributed.push(br.name.clone()); + } + entries.push((br.name.clone(), kind)); + } + entries.sort_by(|a, b| sort_key(&a.0).cmp(&sort_key(&b.0))); + attributed.sort_by(|a, b| sort_key(a).cmp(&sort_key(b))); + Self { + entries, + attributed, + } + } + + pub fn user_names(&self) -> impl Iterator { + self.entries + .iter() + .filter(|(_, kind)| *kind == BlockKind::User) + .map(|(name, _)| name.as_str()) + } + + /// The user block named `name`, in its stored case. + pub fn find_user(&self, name: &str) -> Option { + self.user_names() + .find(|n| n.eq_ignore_ascii_case(name)) + .map(str::to_string) + } + + /// `Defined blocks.`, the matching names, and the four counts of the + /// blocks the pattern matched. + pub fn listing(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { + "*" + } else { + pattern.trim() + }; + let matches = |name: &str| { + pattern + .split(',') + .any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) + }; + let mut out = String::from("Defined blocks."); + let mut counts = [0usize; 4]; + for (name, kind) in &self.entries { + if !matches(name) { + continue; + } + match kind { + BlockKind::User => { + out.push_str(&format!("\n \"{name}\"")); + counts[0] += 1; + } + BlockKind::External => counts[1] += 1, + BlockKind::Dependent => counts[2] += 1, + BlockKind::Unnamed => counts[3] += 1, + } + } + out.push_str(&format!( + "\nUser External Dependent Unnamed\nBlocks References Blocks Blocks\n{:>5}{:>10}{:>12}{:>12}", + counts[0], counts[1], counts[2], counts[3] + )); + out + } +} + +/// Punctuation sorts before digits and letters; letters ignore case. +fn sort_key(name: &str) -> Vec<(u8, char)> { + name.chars() + .map(|c| { + let lower = c.to_lowercase().next().unwrap_or(c); + let rank = if c.is_alphanumeric() { 1 } else { 0 }; + (rank, lower) + }) + .collect() +} + +fn yes_no(text: &str, default: bool) -> Option { + match text.trim().to_ascii_uppercase().as_str() { + "" => Some(default), + "Y" | "YES" => Some(true), + "N" | "NO" => Some(false), + _ => None, + } +} + +/// A block name -BLOCK accepts. +fn valid_block_name(name: &str) -> bool { + !name.is_empty() + && !name.starts_with('*') + && invalid_block_name_char(name).is_none() + && name.chars().count() <= 255 +} + +// ── -BLOCK ──────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq)] +enum BlockStep { + Name, + ListPattern, + Redefine, + Base, + Annotative, + Orient, + Mode, + Objects, +} + +/// -BLOCK: `Enter block name or [?]:` → (redefine?) → +/// `Specify insertion base point or [Annotative/mOde]:` → `Select objects:`. +/// A preselection is taken as the objects; an empty selection defines an +/// empty block. +pub struct DashBlockCommand { + step: BlockStep, + table: BlockTable, + ucs: crate::app::helpers::UcsXform, + name: String, + redefine: bool, + mode: BlockObjectMode, + annotative: bool, + match_orientation: bool, + base: DVec3, + preselected: Vec, + handles: Vec, + /// The drawing's insertion units, given to the definition. + units: i16, +} + +impl DashBlockCommand { + pub fn new( + table: BlockTable, + ucs: crate::app::helpers::UcsXform, + preselected: Vec, + units: i16, + ) -> Self { + Self { + units, + step: BlockStep::Name, + table, + ucs, + name: String::new(), + redefine: false, + mode: defaults().mode, + annotative: false, + match_orientation: false, + base: DVec3::ZERO, + preselected, + handles: Vec::new(), + } + } + + pub fn settings_line(&self) -> String { + current_settings_line(self.mode) + } + + fn create(&self, handles: Vec) -> CmdResult { + CmdResult::CreateBlockWithOptions { + options: Box::new(crate::scene::CreateBlockOptions { + name: self.name.clone(), + handles, + base_point: self.base, + world_to_block: self.ucs.to_ucs_transform_at(self.base), + block_to_world: self.ucs.to_wcs_transform_at(self.base), + mode: self.mode, + annotative: self.annotative, + match_orientation: self.match_orientation, + scale_uniformly: self.annotative, + allow_exploding: true, + unit: self.units, + description: String::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + redefine: self.redefine, + }), + } + } + + fn after_base(&mut self) -> CmdResult { + if self.preselected.is_empty() { + self.step = BlockStep::Objects; + CmdResult::NeedPoint + } else { + let handles = self.preselected.clone(); + self.create(handles) + } + } +} + +impl CadCommand for DashBlockCommand { + fn name(&self) -> &'static str { + "-BLOCK" + } + + fn prompt(&self) -> String { + match self.step { + BlockStep::Name => "Enter block name or [?]:".to_string(), + BlockStep::ListPattern => "Enter block(s) to list <*>:".to_string(), + BlockStep::Redefine => format!( + "Block \"{}\" already exists. Redefine it? [Yes/No] :", + self.name + ), + BlockStep::Base => "Specify insertion base point or [Annotative/mOde]:".to_string(), + BlockStep::Annotative => "Create annotative block [Yes/No] :".to_string(), + BlockStep::Orient => { + "Orient relative to sheet in paper space viewports [Yes/No] :".to_string() + } + BlockStep::Mode => format!( + "Choose mode for selected object conversion [Convert to block/Retain/Delete] <{}>:", + mode_name(self.mode) + ), + BlockStep::Objects => "Select objects:".to_string(), + } + } + + fn options(&self) -> Vec { + let opts: &[(&str, &str)] = match self.step { + BlockStep::Name => &[("?", "?")], + BlockStep::Redefine | BlockStep::Annotative | BlockStep::Orient => { + &[("Yes", "Y"), ("No", "N")] + } + BlockStep::Base => &[("Annotative", "A"), ("mOde", "O")], + BlockStep::Mode => &[("Convert to block", "C"), ("Retain", "R"), ("Delete", "D")], + _ => &[], + }; + opts.iter().map(|(l, k)| CmdOption::new(l, k)).collect() + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, BlockStep::Objects) + } + + fn input_kind(&self) -> InputKind { + match self.step { + BlockStep::Name | BlockStep::ListPattern => InputKind::FreeText, + BlockStep::Base => InputKind::Point, + BlockStep::Objects => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, BlockStep::Base) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { + BlockStep::Name => { + if t == "?" { + self.step = BlockStep::ListPattern; + return Some(CmdResult::NeedPoint); + } + if !valid_block_name(t) { + return Some(CmdResult::ReportError("Invalid block name.".to_string())); + } + match self.table.find_user(t) { + Some(existing) => { + self.name = existing; + self.step = BlockStep::Redefine; + } + None => { + self.name = t.to_string(); + self.step = BlockStep::Base; + } + } + CmdResult::NeedPoint + } + BlockStep::ListPattern => CmdResult::Measurement(self.table.listing(t)), + BlockStep::Redefine => match yes_no(t, false) { + Some(true) => { + self.redefine = true; + self.step = BlockStep::Base; + CmdResult::NeedPoint + } + Some(false) => CmdResult::Measurement(String::new()), + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + BlockStep::Base => match up.as_str() { + "A" | "ANNOTATIVE" => { + self.step = BlockStep::Annotative; + CmdResult::NeedPoint + } + "O" | "MODE" => { + self.step = BlockStep::Mode; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Point or option keyword required.".to_string()), + }, + BlockStep::Annotative => match yes_no(t, true) { + Some(true) => { + self.annotative = true; + self.step = BlockStep::Orient; + CmdResult::NeedPoint + } + Some(false) => { + self.annotative = false; + self.match_orientation = false; + self.step = BlockStep::Base; + CmdResult::NeedPoint + } + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + BlockStep::Orient => match yes_no(t, false) { + Some(orient) => { + self.match_orientation = orient; + self.step = BlockStep::Base; + CmdResult::NeedPoint + } + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + BlockStep::Mode => { + let mode = match up.as_str() { + "" => Some(self.mode), + "C" | "CONVERT" => Some(BlockObjectMode::Convert), + "R" | "RETAIN" => Some(BlockObjectMode::Retain), + "D" | "DELETE" => Some(BlockObjectMode::Delete), + _ => None, + }; + match mode { + Some(mode) => { + self.mode = mode; + self.step = BlockStep::Base; + CmdResult::NeedPoint + } + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + BlockStep::Objects => return None, + }) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + match self.step { + BlockStep::Base => { + self.base = pt; + self.after_base() + } + _ => CmdResult::NeedPoint, + } + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, BlockStep::Objects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles; + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + BlockStep::Name => CmdResult::ReportError("Invalid block name.".to_string()), + BlockStep::ListPattern => CmdResult::Measurement(self.table.listing("*")), + BlockStep::Redefine => CmdResult::Measurement(String::new()), + BlockStep::Base => { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + BlockStep::Annotative | BlockStep::Orient | BlockStep::Mode => self + .on_text_input("") + .unwrap_or(CmdResult::NeedPoint), + BlockStep::Objects => { + let handles = std::mem::take(&mut self.handles); + self.create(handles) + } + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── -WBLOCK ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq)] +enum WblockStep { + File, + Replace, + Source, + Base, + Mode, + Objects, +} + +/// What -WBLOCK writes. +#[derive(Clone, Debug, PartialEq)] +pub enum WblockSource { + /// An existing block definition. + Block(String), + /// The whole drawing. + Drawing, + /// Objects with a base point (the conversion applied after writing). + Objects { + handles: Vec, + base: DVec3, + mode: BlockObjectMode, + }, +} + +/// -WBLOCK: `Enter name of output file:` → (replace?) → +/// `Enter name of existing block or [= (block=output file)/* (whole drawing)] :` +/// → `Specify insertion base point or [mOde]:` → `Select objects:`. +pub struct DashWblockCommand { + step: WblockStep, + table: BlockTable, + path: String, + mode: BlockObjectMode, + base: DVec3, + preselected: Vec, + handles: Vec, +} + +impl DashWblockCommand { + pub fn new(table: BlockTable, preselected: Vec) -> Self { + Self { + step: WblockStep::File, + table, + path: String::new(), + mode: defaults().wblock_mode, + base: DVec3::ZERO, + preselected, + handles: Vec::new(), + } + } + + pub fn settings_line(&self) -> String { + current_settings_line(self.mode) + } + + fn finish(&self, source: WblockSource) -> CmdResult { + CmdResult::Dispatch(format!( + "_WBLOCKWRITE {}", + serde_json::json!({ + "path": self.path, + "source": match &source { + WblockSource::Block(name) => serde_json::json!({"block": name}), + WblockSource::Drawing => serde_json::json!({"drawing": true}), + WblockSource::Objects { handles, base, mode } => serde_json::json!({ + "handles": handles.iter().map(|h| h.value()).collect::>(), + "base": [base.x, base.y, base.z], + "mode": mode_code(*mode), + }), + } + }) + )) + } + + fn after_base(&mut self) -> CmdResult { + if self.preselected.is_empty() { + self.step = WblockStep::Objects; + CmdResult::NeedPoint + } else { + self.finish(WblockSource::Objects { + handles: self.preselected.clone(), + base: self.base, + mode: self.mode, + }) + } + } + + fn file_name_block(&self) -> String { + std::path::Path::new(&self.path) + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_default() + } +} + +/// The output path -WBLOCK writes: `.dwg` is added when no extension is given. +pub fn wblock_output_path(text: &str) -> String { + let path = text.trim().trim_matches('"').to_string(); + let lower = path.to_ascii_lowercase(); + if lower.ends_with(".dwg") || lower.ends_with(".dxf") { + path + } else { + format!("{path}.dwg") + } +} + +impl CadCommand for DashWblockCommand { + fn name(&self) -> &'static str { + "-WBLOCK" + } + + fn prompt(&self) -> String { + match self.step { + WblockStep::File => "Enter name of output file:".to_string(), + WblockStep::Replace => format!( + "{} already exists, do you want to replace it? [Yes/No] :", + self.path.replace('/', "\\") + ), + WblockStep::Source => "Enter name of existing block or\n[= (block=output file)/* (whole drawing)] :".to_string(), + WblockStep::Base => "Specify insertion base point or [mOde]:".to_string(), + WblockStep::Mode => format!( + "Choose mode for selected object conversion [Convert to block/Retain/Delete] <{}>:", + mode_name(self.mode) + ), + WblockStep::Objects => "Select objects:".to_string(), + } + } + + fn options(&self) -> Vec { + let opts: &[(&str, &str)] = match self.step { + WblockStep::Replace => &[("Yes", "Y"), ("No", "N")], + WblockStep::Source => &[("=", "="), ("*", "*")], + WblockStep::Base => &[("mOde", "O")], + WblockStep::Mode => &[("Convert to block", "C"), ("Retain", "R"), ("Delete", "D")], + _ => &[], + }; + opts.iter().map(|(l, k)| CmdOption::new(l, k)).collect() + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, WblockStep::Objects) + } + + fn input_kind(&self) -> InputKind { + match self.step { + WblockStep::File | WblockStep::Source => InputKind::FreeText, + WblockStep::Base | WblockStep::Objects => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, WblockStep::Base) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { + WblockStep::File => { + if t.is_empty() { + return Some(CmdResult::Cancel); + } + self.path = wblock_output_path(t); + self.step = if std::path::Path::new(&self.path).exists() { + WblockStep::Replace + } else { + WblockStep::Source + }; + CmdResult::NeedPoint + } + WblockStep::Replace => match yes_no(t, false) { + Some(true) => { + self.step = WblockStep::Source; + CmdResult::NeedPoint + } + Some(false) => CmdResult::Measurement(String::new()), + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + WblockStep::Source => match t { + "" => { + self.step = WblockStep::Base; + CmdResult::NeedPoint + } + "*" => self.finish(WblockSource::Drawing), + "=" => { + let name = self.file_name_block(); + match self.table.find_user(&name) { + Some(block) => self.finish(WblockSource::Block(block)), + None => CmdResult::ReportError(format!("Block \"{name}\" not found.")), + } + } + name => match self.table.find_user(name) { + Some(block) => self.finish(WblockSource::Block(block)), + None => CmdResult::ReportError(format!("Block \"{name}\" not found.")), + }, + }, + WblockStep::Base => match up.as_str() { + "O" | "MODE" => { + self.step = WblockStep::Mode; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Point or option keyword required.".to_string()), + }, + WblockStep::Mode => { + let mode = match up.as_str() { + "" => Some(self.mode), + "C" | "CONVERT" => Some(BlockObjectMode::Convert), + "R" | "RETAIN" => Some(BlockObjectMode::Retain), + "D" | "DELETE" => Some(BlockObjectMode::Delete), + _ => None, + }; + match mode { + Some(mode) => { + self.mode = mode; + self.step = WblockStep::Base; + CmdResult::NeedPoint + } + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + } + } + WblockStep::Objects => return None, + }) + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.step == WblockStep::Base { + self.base = pt; + return self.after_base(); + } + CmdResult::NeedPoint + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, WblockStep::Objects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.handles = handles; + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + WblockStep::File => CmdResult::Cancel, + WblockStep::Replace | WblockStep::Source | WblockStep::Mode => { + self.on_text_input("").unwrap_or(CmdResult::NeedPoint) + } + WblockStep::Base => { + CmdResult::ReportError("Point or option keyword required.".to_string()) + } + WblockStep::Objects => { + let handles = std::mem::take(&mut self.handles); + if handles.is_empty() { + return CmdResult::Cancel; + } + self.finish(WblockSource::Objects { + handles, + base: self.base, + mode: self.mode, + }) + } + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +// ── -BEDIT ──────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq)] +enum BeditStep { + Name, + ListPattern, +} + +/// -BEDIT: `Enter block name or [?]:` opens the block editor on that +/// definition, creating an empty one for a new name. +pub struct DashBeditCommand { + step: BeditStep, + table: BlockTable, +} + +impl DashBeditCommand { + pub fn new(table: BlockTable) -> Self { + Self { + step: BeditStep::Name, + table, + } + } +} + +impl DashBeditCommand { + /// The user blocks matching `pattern`, one per line, or + /// `No blocks found.` + fn names(&self, pattern: &str) -> String { + let pattern = if pattern.trim().is_empty() { + "*" + } else { + pattern.trim() + }; + let lines: Vec = self + .table + .user_names() + .filter(|name| { + pattern + .split(',') + .any(|p| crate::io::xref_model::wildcard_match(name, p.trim())) + }) + .map(|name| format!(" {name}")) + .collect(); + if lines.is_empty() { + "No blocks found.".to_string() + } else { + lines.join(" +") + } + } +} + +impl CadCommand for DashBeditCommand { + fn name(&self) -> &'static str { + "-BEDIT" + } + + fn prompt(&self) -> String { + match self.step { + BeditStep::Name => "Enter block name or [?]:".to_string(), + BeditStep::ListPattern => "Enter block(s) to list <*>:".to_string(), + } + } + + fn options(&self) -> Vec { + match self.step { + BeditStep::Name => vec![CmdOption::new("?", "?")], + BeditStep::ListPattern => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + true + } + + fn input_kind(&self) -> InputKind { + InputKind::FreeText + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + Some(match self.step { + BeditStep::Name => { + if t == "?" { + self.step = BeditStep::ListPattern; + return Some(CmdResult::NeedPoint); + } + if !valid_block_name(t) { + return Some(CmdResult::ReportError("Invalid block name.".to_string())); + } + let name = self.table.find_user(t).unwrap_or_else(|| t.to_string()); + CmdResult::Dispatch(format!("BEDIT_OPEN:{name}")) + } + BeditStep::ListPattern => { + self.step = BeditStep::Name; + CmdResult::ReportMeasurement(self.names(t)) + } + }) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + BeditStep::Name => CmdResult::Cancel, + BeditStep::ListPattern => { + self.step = BeditStep::Name; + CmdResult::ReportMeasurement(self.names("*")) + } + } + } +} + +// ── ATTSYNC ─────────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq)] +enum AttsyncStep { + Option, + Name, + Pick, + Confirm, +} + +/// ATTSYNC: `Enter an option [?/Name/Select] :".to_string(), + AttsyncStep::Name => "Enter name of block to sync or [?]:".to_string(), + AttsyncStep::Pick => "Select a block:".to_string(), + AttsyncStep::Confirm => format!("ATTSYNC block {}? [Yes/No] :", self.picked), + } + } + + fn options(&self) -> Vec { + let opts: &[(&str, &str)] = match self.step { + AttsyncStep::Option => &[("?", "?"), ("Name", "N"), ("Select", "S")], + AttsyncStep::Name => &[("?", "?")], + AttsyncStep::Confirm => &[("Yes", "Y"), ("No", "N")], + AttsyncStep::Pick => &[], + }; + opts.iter().map(|(l, k)| CmdOption::new(l, k)).collect() + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, AttsyncStep::Pick) + } + + fn input_kind(&self) -> InputKind { + match self.step { + AttsyncStep::Name => InputKind::FreeText, + AttsyncStep::Pick => InputKind::Point, + _ => InputKind::SingleToken, + } + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, AttsyncStep::Pick) + } + + fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + match self.reference_blocks.get(&handle) { + Some(name) => { + if self + .table + .attributed + .iter() + .any(|n| n.eq_ignore_ascii_case(name)) + { + self.picked = name.clone(); + self.step = AttsyncStep::Confirm; + CmdResult::NeedPoint + } else { + CmdResult::Measurement(format!( + "Block table contains no attributed block named {name}." + )) + } + } + None => CmdResult::ReportError("Error selecting entity.".to_string()), + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + let up = t.to_ascii_uppercase(); + Some(match self.step { + AttsyncStep::Option => match up.as_str() { + "?" => CmdResult::ReportMeasurement(self.attributed_line()), + "N" | "NAME" => { + self.step = AttsyncStep::Name; + CmdResult::NeedPoint + } + "" | "S" | "SELECT" => { + self.step = AttsyncStep::Pick; + CmdResult::NeedPoint + } + _ => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + AttsyncStep::Name => { + if t == "?" { + return Some(CmdResult::ReportMeasurement(self.attributed_line())); + } + if t.is_empty() { + return Some(CmdResult::Cancel); + } + self.sync(t) + } + AttsyncStep::Confirm => match yes_no(t, true) { + Some(true) => { + let name = self.picked.clone(); + self.sync(&name) + } + Some(false) => CmdResult::Cancel, + None => CmdResult::ReportError("Invalid option keyword.".to_string()), + }, + AttsyncStep::Pick => return None, + }) + } + + fn on_point(&mut self, _pt: DVec3) -> CmdResult { + if self.step == AttsyncStep::Pick { + return CmdResult::ReportError("Error selecting entity.".to_string()); + } + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + AttsyncStep::Pick => CmdResult::Cancel, + _ => self.on_text_input("").unwrap_or(CmdResult::Cancel), + } + } +} diff --git a/src/modules/insert/create_block.rs b/src/modules/insert/create_block.rs index d9f45ac72..7f8e545fa 100644 --- a/src/modules/insert/create_block.rs +++ b/src/modules/insert/create_block.rs @@ -1,10 +1,8 @@ -use crate::t; use codec::Handle; use glam::DVec3; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; pub fn tool() -> ToolDef { ToolDef { @@ -15,84 +13,6 @@ pub fn tool() -> ToolDef { } } -enum Step { - Name, - Base { name: String }, -} - -pub struct CreateBlockCommand { - handles: Vec, - step: Step, -} - -impl CreateBlockCommand { - pub fn new(handles: Vec) -> Self { - Self { - handles, - step: Step::Name, - } - } -} - -impl CadCommand for CreateBlockCommand { - fn name(&self) -> &'static str { - "BLOCK" - } - - fn prompt(&self) -> String { - match &self.step { - Step::Name => t!( - "BLOCK Enter block name [%{count} objects selected]:", - count = self.handles.len() - ) - .into_owned(), - Step::Base { name } => t!( - "BLOCK Specify base point for \"%{name}\" [%{count} objects]:", - name = name, - count = self.handles.len() - ) - .into_owned(), - } - } - - fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.step { - Step::Name => CmdResult::NeedPoint, - Step::Base { name } => CmdResult::CreateBlock { - handles: self.handles.clone(), - name: name.clone(), - base: pt, - }, - } - } - - fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel - } - - fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Name) - } - - fn on_text_input(&mut self, text: &str) -> Option { - if !matches!(self.step, Step::Name) { - return None; - } - let name = text.trim(); - if name.is_empty() { - return None; - } - self.step = Step::Base { - name: name.to_string(), - }; - Some(CmdResult::NeedPoint) - } - - fn on_preview_wires(&mut self, _pt: DVec3) -> Vec { - vec![] - } -} - /// One-shot point picker for the Block Definition dialog's "Pick point" button. pub struct BlockPickBasePointCommand; @@ -102,7 +22,7 @@ impl CadCommand for BlockPickBasePointCommand { } fn prompt(&self) -> String { - crate::t!("BLOCK Specify insertion base point:").into_owned() + "Specify insertion base point:".to_string() } fn on_point(&mut self, pt: DVec3) -> CmdResult { @@ -150,9 +70,9 @@ impl CadCommand for BlockOnScreenCommand { fn prompt(&self) -> String { if self.need_base { - crate::t!("BLOCK Specify insertion base point:").into_owned() + "Specify insertion base point:".to_string() } else { - crate::t!("BLOCK Select objects:").into_owned() + "Select objects:".to_string() } } @@ -450,7 +370,8 @@ mod tests { hyperlink_desc: "".to_string(), redefine: false, }; - assert!(scene.create_block_with_options(opt_no_handles).is_err()); + // No objects define an empty block. + assert!(scene.create_block_with_options(opt_no_handles).is_ok()); } #[test] diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 685317b9e..a8677fe3c 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -4,6 +4,7 @@ mod attdef; pub(crate) mod attedit; mod attman; pub mod base_point; +pub(crate) mod block_cmdline; mod content_browser; pub(crate) mod create_block; mod design_center; diff --git a/src/modules/insert/wblock.rs b/src/modules/insert/wblock.rs index 3564e6f52..943589a57 100644 --- a/src/modules/insert/wblock.rs +++ b/src/modules/insert/wblock.rs @@ -186,8 +186,11 @@ pub fn extract_entities_to_doc_with_base( if handles.is_empty() { return Err(t!("No entities selected for WBLOCK.").into_owned()); } - let shift = crate::command::EntityTransform::Translate(-base_point); - copy_with_dependencies(src, handles, &mut out, Some(&shift)); + // The objects keep their coordinates; the base point becomes the new + // drawing's insertion base (INSBASE). + copy_with_dependencies(src, handles, &mut out, None); + out.header.model_space_insertion_base = + codec::types::Vector3::new(base_point.x, base_point.y, base_point.z); if out.entities().count() == 0 { return Err(t!("None of the selected entities could be exported.").into_owned()); } @@ -218,12 +221,16 @@ mod tests { assert_eq!(out.header.insertion_units, 4); assert_eq!(out.entities().count(), 1); + assert_eq!( + out.header.model_space_insertion_base, + Vector3::new(10.0, 20.0, 0.0) + ); let e = out.entities().next().unwrap(); if let EntityType::Line(l) = e { - assert!((l.start.x - 0.0).abs() < 1e-6); - assert!((l.start.y - 0.0).abs() < 1e-6); - assert!((l.end.x - 20.0).abs() < 1e-6); - assert!((l.end.y - 20.0).abs() < 1e-6); + assert!((l.start.x - 10.0).abs() < 1e-6); + assert!((l.start.y - 20.0).abs() < 1e-6); + assert!((l.end.x - 30.0).abs() < 1e-6); + assert!((l.end.y - 40.0).abs() < 1e-6); } else { panic!("Expected Line entity"); } diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 9315295f4..c791a51b3 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -1975,6 +1975,39 @@ pub fn update_entity_from_annotation_style( true } +/// Whether the block definition `name` is annotative: its block record +/// carries `AcadAnnotative` (`AnnotativeData { 1 1 }`), or, in drawings an +/// earlier build wrote, its BLOCK entity does. +pub fn block_is_annotative(doc: &CadDocument, name: &str) -> bool { + let Some(br) = doc.block_records.get(name) else { + return false; + }; + let flag = |record: &codec::xdata::ExtendedDataRecord| { + record + .values + .iter() + .filter_map(|value| match value { + codec::xdata::XDataValue::Integer16(value) => Some(*value), + _ => None, + }) + .last() + .is_some_and(|value| value != 0) + }; + if let Some(record) = doc + .object_xdata(br.handle) + .and_then(|xdata| xdata.get_record("AcadAnnotative")) + { + return flag(record); + } + doc.get_entity(br.block_entity_handle).is_some_and(|block| { + block + .common() + .extended_data + .get_record("AcadAnnotative") + .is_some_and(flag) + }) +} + /// Whether an entity participates in annotation scaling. pub fn is_annotative(doc: &CadDocument, entity: &EntityType) -> bool { // Per-object annotation context (works regardless of style). diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 741400e9d..4e3e436d2 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -969,7 +969,7 @@ impl Scene { .get(name) .map(|br| (br.handle, br.block_entity_handle)); - let (br_handle, _block_handle) = if let Some((existing_br_h, existing_blk_h)) = existing { + let (br_handle, block_handle) = if let Some((existing_br_h, existing_blk_h)) = existing { if !options.redefine { return Err(format!("Block \"{name}\" already exists.")); } @@ -991,7 +991,7 @@ impl Scene { br.units = options.unit; br.description = options.description.clone(); br.explodable = options.allow_exploding; - br.scale_uniformly = options.scale_uniformly; + br.scale_uniformly = options.scale_uniformly || options.annotative; } // Update existing Block entity fields if let Some(EntityType::Block(b)) = self.document.get_entity_mut(existing_blk_h) { @@ -1002,26 +1002,6 @@ impl Scene { // base point here would subtract the picked base twice. b.base_point = codec::types::Vector3::ZERO; } - if options.annotative { - let annotative_vals = vec![codec::xdata::XDataValue::Integer16(1)]; - view::dispatch::set_entity_xdata( - &mut self.document, - existing_blk_h, - "AcadAnnotative", - Some(annotative_vals), - ); - } - if !options.hyperlink_url.is_empty() { - let link_vals = vec![codec::xdata::XDataValue::String( - options.hyperlink_url.clone(), - )]; - view::dispatch::set_entity_xdata( - &mut self.document, - existing_blk_h, - "PE_URL", - Some(link_vals), - ); - } (existing_br_h, existing_blk_h) } else { let next = self.document.next_handle(); @@ -1036,7 +1016,7 @@ impl Scene { block_record.units = options.unit; block_record.description = options.description.clone(); block_record.explodable = options.allow_exploding; - block_record.scale_uniformly = options.scale_uniformly; + block_record.scale_uniformly = options.scale_uniformly || options.annotative; self.document .block_records .add(block_record) @@ -1056,36 +1036,19 @@ impl Scene { self.document .add_entity(EntityType::BlockEnd(block_end)) .map_err(|e| e.to_string())?; - - if options.annotative { - let annotative_vals = vec![codec::xdata::XDataValue::Integer16(1)]; - view::dispatch::set_entity_xdata( - &mut self.document, - block_handle, - "AcadAnnotative", - Some(annotative_vals), - ); - } - if !options.hyperlink_url.is_empty() { - let link_vals = vec![codec::xdata::XDataValue::String( - options.hyperlink_url.clone(), - )]; - view::dispatch::set_entity_xdata( - &mut self.document, - block_handle, - "PE_URL", - Some(link_vals), - ); - } (br_handle, block_handle) }; + self.write_block_definition_xdata(br_handle, block_handle, &options); let source_entities: Vec<_> = options .handles .iter() .filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e))) .collect(); - if source_entities.is_empty() { + // No objects at all defines an empty block (the reference's + // "Continue" / an empty selection at -BLOCK); objects that all vanished + // are an error. + if source_entities.is_empty() && !options.handles.is_empty() { return Err("No valid entities selected for block creation.".into()); } @@ -1104,17 +1067,169 @@ impl Scene { } let mut insert_handle = Handle::NULL; - if options.mode == crate::ui::window::block_definition::BlockObjectMode::Convert { + // An empty definition has nothing to convert into a reference. + if options.mode == crate::ui::window::block_definition::BlockObjectMode::Convert + && !options.handles.is_empty() + { let mut insert = DxfInsert::new(name, codec::types::Vector3::ZERO); codec::Entity::apply_transform(&mut insert, &options.block_to_world); insert_handle = self.add_entity(EntityType::Insert(insert)); } + // The definition's thumbnail (BLOCK_RECORD 310), as the format keeps it. + let thumbnail = self.block_thumbnail(name); + if let Some(br) = self.document.block_records.get_mut(name) { + br.preview_data = thumbnail; + } + // Advance the block epoch so all consumers and viewport renderers re-tessellate self.bump_geometry(); Ok(insert_handle) } + /// A 40 × 40, 32-bit bottom-up DIB of the block's geometry: white strokes + /// on the dark preview background, fitted with a one-pixel margin. Empty + /// for a block without drawable geometry. + pub fn block_thumbnail(&self, name: &str) -> Vec { + const SIZE: usize = 40; + let wires = self.block_preview_wires(name); + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + for wire in &wires { + for p in &wire.points { + if p[0].is_finite() && p[1].is_finite() { + lo = [lo[0].min(p[0] as f64), lo[1].min(p[1] as f64)]; + hi = [hi[0].max(p[0] as f64), hi[1].max(p[1] as f64)]; + } + } + } + if !lo[0].is_finite() { + return Vec::new(); + } + let span = (hi[0] - lo[0]).max(hi[1] - lo[1]).max(1e-9); + let scale = (SIZE as f64 - 3.0) / span; + let offset = [ + (SIZE as f64 - (hi[0] - lo[0]) * scale) / 2.0, + (SIZE as f64 - (hi[1] - lo[1]) * scale) / 2.0, + ]; + let to_px = |p: &[f32; 3]| { + ( + ((p[0] as f64 - lo[0]) * scale + offset[0]).floor() as i64, + ((p[1] as f64 - lo[1]) * scale + offset[1]).floor() as i64, + ) + }; + // Bottom-up rows of BGRA pixels. + let mut pixels = vec![[0x30u8, 0x28, 0x21, 0xFF]; SIZE * SIZE]; + let mut plot = |x: i64, y: i64| { + if (0..SIZE as i64).contains(&x) && (0..SIZE as i64).contains(&y) { + pixels[y as usize * SIZE + x as usize] = [0xFF; 4]; + } + }; + for wire in &wires { + let mut prev: Option<(i64, i64)> = None; + for p in &wire.points { + if !(p[0].is_finite() && p[1].is_finite()) { + prev = None; + continue; + } + let (x1, y1) = to_px(p); + let (mut x0, mut y0) = prev.unwrap_or((x1, y1)); + let (dx, dy) = ((x1 - x0).abs(), -(y1 - y0).abs()); + let (sx, sy) = (if x0 < x1 { 1 } else { -1 }, if y0 < y1 { 1 } else { -1 }); + let mut err = dx + dy; + loop { + plot(x0, y0); + if x0 == x1 && y0 == y1 { + break; + } + let e2 = 2 * err; + if e2 >= dy { + err += dy; + x0 += sx; + } + if e2 <= dx { + err += dx; + y0 += sy; + } + } + prev = Some((x1, y1)); + } + } + let mut dib = Vec::with_capacity(40 + SIZE * SIZE * 4); + for value in [40u32, SIZE as u32, SIZE as u32] { + dib.extend_from_slice(&value.to_le_bytes()); + } + dib.extend_from_slice(&1u16.to_le_bytes()); + dib.extend_from_slice(&32u16.to_le_bytes()); + for value in [0u32, (SIZE * SIZE * 4) as u32, 1, 1, 0, 0] { + dib.extend_from_slice(&value.to_le_bytes()); + } + for pixel in pixels { + dib.extend_from_slice(&pixel); + } + dib + } + + /// The definition-level xdata of a block, kept on its block record the + /// way the format stores it: `AcadAnnotative` (`AnnotativeData { 1 1 }`) + /// for an annotative block, `AcadAnnoPO` when its orientation matches the + /// layout, and the `PE_URL` hyperlink. Markers an older definition kept + /// on its BLOCK entity are dropped. + pub fn write_block_definition_xdata( + &mut self, + br_handle: Handle, + block_handle: Handle, + options: &CreateBlockOptions, + ) { + use codec::xdata::{ExtendedDataRecord, XDataValue as V}; + for app in ["AcadAnnotative", "AcadAnnoPO", "PE_URL"] { + view::dispatch::set_entity_xdata(&mut self.document, block_handle, app, None); + self.document.remove_object_xdata_record(br_handle, app); + } + let mut records = Vec::new(); + if options.annotative { + let mut anno = ExtendedDataRecord::new("AcadAnnotative"); + for value in [ + V::String("AnnotativeData".into()), + V::ControlString("{".into()), + V::Integer16(1), + V::Integer16(1), + V::ControlString("}".into()), + ] { + anno.add_value(value); + } + records.push(anno); + if options.match_orientation { + let mut po = ExtendedDataRecord::new("AcadAnnoPO"); + po.add_value(V::Integer16(1)); + records.push(po); + } + } + let url = options.hyperlink_url.trim(); + if !url.is_empty() { + let mut link = ExtendedDataRecord::new("PE_URL"); + link.add_value(V::String(url.to_string())); + link.add_value(V::ControlString("{".into())); + let description = options.hyperlink_desc.trim(); + if !description.is_empty() { + link.add_value(V::String(description.to_string())); + } + link.add_value(V::ControlString("{".into())); + link.add_value(V::Integer32(0)); + link.add_value(V::ControlString("}".into())); + link.add_value(V::ControlString("}".into())); + records.push(link); + } + for record in records { + if !self.document.app_ids.contains(&record.application_name) { + let mut app = codec::tables::AppId::new(&record.application_name); + app.handle = self.document.allocate_handle(); + let _ = self.document.app_ids.add(app); + } + self.document.set_object_xdata_record(br_handle, record); + } + } + /// Define a new block named `name` from `entities` (owned, not yet in the /// document), with `base` as its insertion origin. Unlike /// [`create_block_from_entities`] this does NOT place an insert — the diff --git a/src/sys.rs b/src/sys.rs index 0867bc8e0..85d5b778b 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -1370,3 +1370,33 @@ pub mod web_diag { } } } + +/// The user's Documents folder (the known folder, wherever it is +/// redirected), else the home folder. +#[cfg(target_os = "windows")] +pub fn documents_dir() -> Option { + use windows_sys::Win32::System::Com::CoTaskMemFree; + use windows_sys::Win32::UI::Shell::{FOLDERID_Documents, SHGetKnownFolderPath}; + let mut raw: windows_sys::core::PWSTR = std::ptr::null_mut(); + // SAFETY: SHGetKnownFolderPath writes a CoTaskMemAlloc'd, NUL-terminated + // wide string to `raw`, freed below. + let hr = unsafe { SHGetKnownFolderPath(&FOLDERID_Documents, 0, std::ptr::null_mut(), &mut raw) }; + let path = if hr == 0 && !raw.is_null() { + // SAFETY: `raw` is a valid NUL-terminated wide string from the call above. + let len = unsafe { (0..).take_while(|&k| *raw.add(k) != 0).count() }; + let wide = unsafe { std::slice::from_raw_parts(raw, len) }; + Some(std::path::PathBuf::from(String::from_utf16_lossy(wide))) + } else { + None + }; + // SAFETY: freeing the buffer SHGetKnownFolderPath allocated (null is a no-op). + unsafe { CoTaskMemFree(raw as *const core::ffi::c_void) }; + path.or_else(|| std::env::var_os("USERPROFILE").map(std::path::PathBuf::from)) +} + +#[cfg(not(target_os = "windows"))] +pub fn documents_dir() -> Option { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from)?; + let documents = home.join("Documents"); + Some(if documents.is_dir() { documents } else { home }) +} diff --git a/src/ui/window/block_definition.rs b/src/ui/window/block_definition.rs index e8905a685..1b2213bf0 100644 --- a/src/ui/window/block_definition.rs +++ b/src/ui/window/block_definition.rs @@ -106,21 +106,24 @@ impl Clone for BlockDefinitionState { impl BlockDefinitionState { pub fn new(existing_names: Vec, pre_selected: Vec, default_unit: i16) -> Self { + // The on-screen switches, the conversion and Allow exploding start + // where the dialog was last confirmed. + let remembered = *crate::modules::insert::block_cmdline::defaults(); Self { name: String::new(), name_combo: combo_box::State::new(existing_names.clone()), existing_names, - base_point_specify_onscreen: false, - base_point_x: "0.0000".to_string(), - base_point_y: "0.0000".to_string(), - base_point_z: "0.0000".to_string(), - objects_specify_onscreen: false, + base_point_specify_onscreen: remembered.base_on_screen, + base_point_x: "0".to_string(), + base_point_y: "0".to_string(), + base_point_z: "0".to_string(), + objects_specify_onscreen: remembered.objects_on_screen, selected_handles: pre_selected, - object_mode: BlockObjectMode::Convert, + object_mode: remembered.mode, annotative: false, match_orientation: false, scale_uniformly: false, - allow_exploding: true, + allow_exploding: remembered.allow_exploding, unit: default_unit, description: String::new(), description_content: text_editor::Content::new(), @@ -158,15 +161,20 @@ impl BlockDefinitionState { } pub fn invalid_name_char(&self) -> Option { - self.name.chars().find(|&c| { - matches!( - c, - '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '=' | '`' - ) - }) + invalid_block_name_char(&self.name) } } +/// The first character a block name may not hold: \ < > / ? " : ; * | , = ` +pub fn invalid_block_name_char(name: &str) -> Option { + name.chars().find(|&c| { + matches!( + c, + '\\' | '<' | '>' | '/' | '?' | '"' | ':' | ';' | '*' | '|' | ',' | '=' | '`' + ) + }) +} + pub(crate) fn group<'a>( title: impl Into, body: impl Into>, @@ -235,7 +243,7 @@ pub fn view_window<'a>( state.invalid_name_char() { Some( - text(crate::tf!("Invalid character '{invalid_char}' in block name (\\ / : * ? \" < > | = ` are not allowed)")) + text(crate::tf!("Invalid character '{invalid_char}' in block name (\\ < > / ? \" : ; * | , = ` are not allowed)")) .size(10) .style(|theme: &Theme| text::Style { color: Some(theme.palette().danger.base.color), @@ -634,7 +642,8 @@ pub fn view_window<'a>( // ── Confirm Redefinition Guard ──────────────────────────────────────────── if let Some(name) = &state.confirm_redefine { - let question = crate::tf!("Block \"{name}\" already exists.\nDo you want to redefine it?"); + let _ = name; + let question = t!("The block definition has changed. Do you want to redefine it ?"); let shield = mouse_area( container(Space::new()) .width(Length::Fill) @@ -655,7 +664,7 @@ pub fn view_window<'a>( .align_x(iced::alignment::Horizontal::Center), Space::new().height(16), row![ - dialog_button(t!("Yes"), Message::BlockDefConfirmRedefine(true), false), + dialog_button(t!("Redefine"), Message::BlockDefConfirmRedefine(true), false), // Default / safe focus on "No" dialog_button(t!("No"), Message::BlockDefConfirmRedefine(false), true), ] diff --git a/src/ui/window/wblock.rs b/src/ui/window/wblock.rs index 2d9660aa1..c4367df1c 100644 --- a/src/ui/window/wblock.rs +++ b/src/ui/window/wblock.rs @@ -104,16 +104,27 @@ impl WblockState { default_path: String, ) -> Self { let default_block = existing_blocks.first().cloned().unwrap_or_default(); + let remembered_mode = crate::modules::insert::block_cmdline::defaults().wblock_mode; Self { source_mode: WblockSourceMode::Objects, block_name: default_block, block_combo: combo_box::State::new(existing_blocks.clone()), existing_blocks, - base_point_x: "0.00".to_string(), - base_point_y: "0.00".to_string(), - base_point_z: "0.00".to_string(), + base_point_x: "0".to_string(), + base_point_y: "0".to_string(), + base_point_z: "0".to_string(), selected_handles: pre_selected, - object_mode: WblockObjectMode::Retain, + object_mode: match remembered_mode { + crate::ui::window::block_definition::BlockObjectMode::Retain => { + WblockObjectMode::Retain + } + crate::ui::window::block_definition::BlockObjectMode::Convert => { + WblockObjectMode::Convert + } + crate::ui::window::block_definition::BlockObjectMode::Delete => { + WblockObjectMode::Delete + } + }, file_path: default_path, unit: default_unit, error_message: None,