From 652f4d6bbc040dc0b6950086099603dc28fb4185 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 22 Sep 2026 17:04:58 +0100 Subject: [PATCH] feat(blocks): implement Block Definition dialog (BLOCK/BMAKE) with on-screen sequencing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the complete Block Definition dialog window launched via BLOCK and BMAKE commands, featuring standard CAD layout, workflows, and entity creation pipelines. - Dialog UI & Layout: - 2-column compact CAD grid with synchronized vertical heights (580 × 390px). - Editable combobox with existing block names, invalid character validation, and inline redefinition badge. - Multi-line text editor widget for block descriptions (replacing single-line input). - Quick Select (QSELECT) button integration restoring dialog state upon completion. - Confirmation guard when attempting to redefine an existing block definition. - Centered radio buttons, clean coordinate inputs, and accessible keyboard shortcuts. - Base Point & On-Screen Workflow: - BlockOnScreenCommand strictly enforces prompt sequencing: Base point is prompted before object selection. - Dynamically recalculates world_to_block and block_to_world affine transforms when base point is specified on-screen, preventing stale (0,0,0) normalization. - Supports Retain, Convert to block (INSERT placement), and Delete modes. - Explode Enforcement: - Guarded EXPLODE command to prevent decomposing blocks flagged as non-explodable. - Dynamic Themed Icons: - Added pick_point.svg, select_objects.svg, quick_select.svg, and warning_triangle.svg. - Routed all icons through the semantic theming engine so line work (#B4B6B9) and accents (#6DB7ED) adapt automatically to the active theme. - Tests: - Added unit tests for retain/convert/delete modes, redefinition, explodable flag, on-screen base point transform recalculation, and command sequencing. --- assets/icons/blocks/pick_point.svg | 6 + assets/icons/blocks/quick_select.svg | 8 + assets/icons/blocks/select_objects.svg | 4 + assets/icons/ui/warning_triangle.svg | 5 + src/app/command_driver.rs | 30 ++ src/app/commands/blocks.rs | 704 +++++++++++++++++-------- src/app/commands/inquiry.rs | 18 +- src/app/helpers.rs | 78 ++- src/app/mod.rs | 28 + src/app/update/command.rs | 3 + src/app/update/mod.rs | 240 +++++++++ src/app/view/modal.rs | 7 + src/command.rs | 4 + src/modules/draw/modify/explode.rs | 31 +- src/modules/insert/create_block.rs | 559 +++++++++++++++++++- src/scene/entity.rs | 211 ++++++-- src/scene/mod.rs | 1 + src/ui/window/block_definition.rs | 696 ++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + 19 files changed, 2316 insertions(+), 318 deletions(-) create mode 100644 assets/icons/blocks/pick_point.svg create mode 100644 assets/icons/blocks/quick_select.svg create mode 100644 assets/icons/blocks/select_objects.svg create mode 100644 assets/icons/ui/warning_triangle.svg create mode 100644 src/ui/window/block_definition.rs diff --git a/assets/icons/blocks/pick_point.svg b/assets/icons/blocks/pick_point.svg new file mode 100644 index 000000000..46c0b195e --- /dev/null +++ b/assets/icons/blocks/pick_point.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/blocks/quick_select.svg b/assets/icons/blocks/quick_select.svg new file mode 100644 index 000000000..9fe9374e1 --- /dev/null +++ b/assets/icons/blocks/quick_select.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/blocks/select_objects.svg b/assets/icons/blocks/select_objects.svg new file mode 100644 index 000000000..190fa8e46 --- /dev/null +++ b/assets/icons/blocks/select_objects.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/ui/warning_triangle.svg b/assets/icons/ui/warning_triangle.svg new file mode 100644 index 000000000..b77a65f47 --- /dev/null +++ b/assets/icons/ui/warning_triangle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d4189ea22..94426080e 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3654,6 +3654,36 @@ impl OpenCADStudio { } } } + CmdResult::CreateBlockWithOptions { mut options } => { + options.handles.retain(|handle| !self.tabs[i].scene.is_layer_locked(*handle)); + if options.handles.is_empty() { + self.command_line + .push_info(crate::t!("No editable objects selected.").as_ref()); + return Task::none(); + } + self.push_undo_snapshot(i, "BLOCK"); + let name = options.name.clone(); + match self.tabs[i].scene.create_block_with_options(*options) { + Ok(insert_handle) => { + self.tabs[i].dirty = true; + self.tabs[i].scene.deselect_all(); + if !insert_handle.is_null() { + self.tabs[i].scene.select_entity(insert_handle, false); + } + 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()); + self.refresh_properties(); + self.refresh_block_palette(); + } + Err(err) => { + self.discard_last_undo_entry(i); + self.command_line.push_error(&err); + } + } + } CmdResult::CommitHatch(hatch) => { let label = self.history_label_from_active_cmd(i, "HATCH"); let pending = self.begin_undo(i, label, 1, true); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 52a1465dc..662360b29 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -10,28 +10,23 @@ impl OpenCADStudio { .push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref()); } crate::io::xref::XrefStatus::Recovered => { - self.command_line.push_error(crate::tf!( - "XREF Reloaded with repairs: \"{}\"", - info.name - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Reloaded with repairs: \"{}\"", info.name).as_ref(), + ); } crate::io::xref::XrefStatus::NotFound => { - self.command_line.push_error(crate::tf!( - "XREF Not found: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Not found: \"{}\" ({})", info.name, info.path).as_ref(), + ); } crate::io::xref::XrefStatus::Failed => { - self.command_line.push_error(crate::tf!( - "XREF Reload failed: \"{}\" ({})", - info.name, info.path - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF Reload failed: \"{}\" ({})", info.name, info.path).as_ref(), + ); } crate::io::xref::XrefStatus::Unloaded => { - self.command_line.push_info(crate::tf!( - "XREF Unloaded (skipped): \"{}\"", - info.name - ).as_ref()); + self.command_line + .push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref()); } } } @@ -58,6 +53,52 @@ impl OpenCADStudio { count } + pub(in crate::app) fn commit_block_definition(&mut self, redefine: bool) -> Task { + let Some(state) = self.block_definition.take() else { + self.active_modal = None; + return Task::none(); + }; + self.active_modal = None; + let i = self.active_tab; + let base_point = state.parse_base_point(); + 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: state.name.trim().to_string(), + handles: state.selected_handles, + base_point, + world_to_block, + block_to_world, + mode: state.object_mode, + annotative: state.annotative, + match_orientation: state.match_orientation, + scale_uniformly: state.scale_uniformly, + allow_exploding: state.allow_exploding, + unit: state.unit, + description: state.description.trim().to_string(), + hyperlink_url: state.hyperlink_url.trim().to_string(), + hyperlink_desc: state.hyperlink_desc.trim().to_string(), + redefine, + }; + + if state.base_point_specify_onscreen || state.objects_specify_onscreen { + let cmd = crate::modules::insert::create_block::BlockOnScreenCommand::new( + options, + ucs, + state.base_point_specify_onscreen, + state.objects_specify_onscreen, + ); + self.command_line.push_info(&cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + Task::none() + } else { + self.apply_cmd_result(crate::command::CmdResult::CreateBlockWithOptions { + options: Box::new(options), + }) + } + } + pub(super) fn dispatch_blocks(&mut self, cmd: &str, i: usize) -> Option> { match cmd { // ── BASE — drawing insertion base point ─────────────────────── @@ -101,12 +142,18 @@ impl OpenCADStudio { } else { "model space" }; - self.command_line.push_output(crate::tf!( - "Base point ({}, {}, {}) set for {space}.", - nums[0], nums[1], z - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Base point ({}, {}, {}) set for {space}.", + nums[0], + nums[1], + z + ) + .as_ref(), + ); } else { - self.command_line.push_error(crate::t!("Usage: BASE [z]").as_ref()); + self.command_line + .push_error(crate::t!("Usage: BASE [z]").as_ref()); } } } @@ -117,8 +164,9 @@ impl OpenCADStudio { if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { return match self.mtext_selected_text() { Some(text) => { - self.command_line - .push_info(crate::t!("Copied selected text to clipboard.").as_ref()); + self.command_line.push_info( + crate::t!("Copied selected text to clipboard.").as_ref(), + ); Some(iced::clipboard::write(text).discard()) } None => Some(Task::none()), @@ -141,10 +189,8 @@ impl OpenCADStudio { &handles, ); let count = self.copy_entities_to_clipboard(i, &handles, base); - self.command_line.push_info(crate::tf!( - "{} object(s) copied to clipboard.", - count - ).as_ref()); + self.command_line + .push_info(crate::tf!("{} object(s) copied to clipboard.", count).as_ref()); } } @@ -186,12 +232,15 @@ impl OpenCADStudio { if coords.len() == 3 && !handles.is_empty() { let base = glam::DVec3::new(coords[0], coords[1], coords[2]); let count = self.copy_entities_to_clipboard(i, &handles, base); - self.command_line.push_info(crate::tf!( - "{} object(s) copied to clipboard (base {:.3},{:.3}).", - count, - base.x, - base.y - ).as_ref()); + self.command_line.push_info( + crate::tf!( + "{} object(s) copied to clipboard (base {:.3},{:.3}).", + count, + base.x, + base.y + ) + .as_ref(), + ); } } @@ -252,10 +301,13 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.refresh_layer_panel(); self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "PASTEORIG: {} object(s) pasted at original coordinates.", - count - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "PASTEORIG: {} object(s) pasted at original coordinates.", + count + ) + .as_ref(), + ); } } @@ -327,12 +379,33 @@ impl OpenCADStudio { self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } - Err(e) => self.command_line.push_error(crate::tf!("PASTEBLOCK: {e}").as_ref()), + Err(e) => self + .command_line + .push_error(crate::tf!("PASTEBLOCK: {e}").as_ref()), } } } "BLOCK" | "BMAKE" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + let existing_names = self.tabs[i].scene.custom_block_names(); + let default_unit = self.tabs[i].scene.document.header.insertion_units; + self.block_definition = Some( + crate::ui::window::block_definition::BlockDefinitionState::new( + existing_names, + handles, + default_unit, + ), + ); + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } + + "-BLOCK" | "-BMAKE" => { let handles: Vec<_> = self.tabs[i] .scene .selected_entities() @@ -341,7 +414,7 @@ impl OpenCADStudio { .collect(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; - let cmd = SelectObjectsCommand::new("BLOCK"); + let cmd = SelectObjectsCommand::new("-BLOCK"); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { @@ -352,11 +425,47 @@ impl OpenCADStudio { } } + cmd if cmd.starts_with("BLOCK_POINT_PICKED ") => { + let parts: Vec = cmd["BLOCK_POINT_PICKED ".len()..] + .split_whitespace() + .filter_map(|s| s.parse::().ok()) + .collect(); + if parts.len() == 3 { + if let Some(state) = self.block_definition.as_mut() { + state.base_point_x = format!("{:.4}", parts[0]); + state.base_point_y = format!("{:.4}", parts[1]); + state.base_point_z = format!("{:.4}", parts[2]); + } + } + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } + + "BLOCK_POINT_CANCELLED" => { + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } + + "BLOCK_OBJECTS_GATHERED" => { + let handles: Vec<_> = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + if let Some(state) = self.block_definition.as_mut() { + state.selected_handles = handles; + } + self.tabs[i].active_cmd = None; + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } + "INSERT" => { let blocks = self.tabs[i].scene.custom_block_names(); if blocks.is_empty() { - self.command_line - .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); + self.command_line.push_error( + crate::t!("No user-defined blocks found in this drawing.").as_ref(), + ); } else { use crate::modules::insert::insert_block::InsertBlockCommand; let ranked = self.ranked_block_names(&blocks); @@ -376,8 +485,9 @@ impl OpenCADStudio { "MINSERT" => { let blocks = self.tabs[i].scene.custom_block_names(); if blocks.is_empty() { - self.command_line - .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); + self.command_line.push_error( + crate::t!("No user-defined blocks found in this drawing.").as_ref(), + ); } else { use crate::modules::insert::minsert::MinsertCommand; let ranked = self.ranked_block_names(&blocks); @@ -408,14 +518,17 @@ impl OpenCADStudio { 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()); + 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() @@ -449,9 +562,7 @@ impl OpenCADStudio { .filter_map(|entity| match entity { acadrust::EntityType::Insert(insert) if insert.block_name.eq_ignore_ascii_case(&block) - && !self.tabs[i] - .scene - .is_layer_locked(insert.common.handle) => + && !self.tabs[i].scene.is_layer_locked(insert.common.handle) => { Some(insert.common.handle) } @@ -459,9 +570,8 @@ impl OpenCADStudio { }) .collect(); if inserts.is_empty() { - self.command_line.push_error( - crate::t!("ATTSYNC: no editable block references.").as_ref(), - ); + self.command_line + .push_error(crate::t!("ATTSYNC: no editable block references.").as_ref()); return Some(Task::none()); } self.push_undo_snapshot(i, "ATTSYNC"); @@ -473,9 +583,8 @@ impl OpenCADStudio { else { continue; }; - ins.attributes.retain(|a| { - attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag)) - }); + ins.attributes + .retain(|a| attdefs.iter().any(|(t, _)| t.eq_ignore_ascii_case(&a.tag))); for (tag, default) in &attdefs { if !ins .attributes @@ -487,7 +596,7 @@ impl OpenCADStudio { tag.clone(), default.clone(), )); - } + } } synced += 1; changes.push((ins.common.handle, crate::scene::ChangeKind::Modified)); @@ -512,20 +621,21 @@ impl OpenCADStudio { .names() .map(|s| s.to_string()) .collect(); - self.command_line.push_output(crate::tf!( - "Blocks ({}): {}", - blocks.len(), - if blocks.is_empty() { - "(none)".to_string() - } else { - blocks.join(", ") - } - ).as_ref()); - self.command_line.push_output(crate::tf!( - "Layers ({}): {}", - layers.len(), - layers.join(", ") - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "Blocks ({}): {}", + blocks.len(), + if blocks.is_empty() { + "(none)".to_string() + } else { + blocks.join(", ") + } + ) + .as_ref(), + ); + self.command_line.push_output( + crate::tf!("Layers ({}): {}", layers.len(), layers.join(", ")).as_ref(), + ); } // BLOCKPALETTE / BLOCKSPALETTE — toggle the docked Insert Block panel. @@ -582,7 +692,11 @@ impl OpenCADStudio { if self.show_external_references { // Always open expanded so the panel is immediately usable; // dock it on the right if the user closed it from the layout. - if self.dock.location(crate::ui::dock::PanelId::ExternalReferences).is_none() { + if self + .dock + .location(crate::ui::dock::PanelId::ExternalReferences) + .is_none() + { self.dock.dock( crate::ui::dock::PanelId::ExternalReferences, crate::app::config::DockSide::Right, @@ -594,7 +708,12 @@ impl OpenCADStudio { } } - cmd if cmd.eq_ignore_ascii_case("XREF") || cmd.to_ascii_uppercase().starts_with("XREF ") || cmd.eq_ignore_ascii_case("-XREF") || cmd.to_ascii_uppercase().starts_with("-XREF ") => { // XREF sub-option dispatcher (Task 5). The verb is + cmd if cmd.eq_ignore_ascii_case("XREF") + || cmd.to_ascii_uppercase().starts_with("XREF ") + || cmd.eq_ignore_ascii_case("-XREF") + || cmd.to_ascii_uppercase().starts_with("-XREF ") => + { + // XREF sub-option dispatcher (Task 5). The verb is // case-insensitive; arguments split on whitespace. // `-XREF` is accepted as an alias for industry muscle memory // (modern CAD opens the palette on `XREF`); bare `XREF` keeps @@ -632,21 +751,22 @@ impl OpenCADStudio { &self.tabs[i].xref_stat_cache.0, ); if entries.is_empty() { - self.command_line - .push_output(crate::t!("XREF No external references in this drawing.").as_ref()); + self.command_line.push_output( + crate::t!("XREF No external references in this drawing.").as_ref(), + ); } else { - self.command_line.push_output(crate::t!("XREF External references:").as_ref()); + self.command_line + .push_output(crate::t!("XREF External references:").as_ref()); for entry in &entries { let path = if entry.saved_path.is_empty() { "(no path)".to_string() } else { entry.saved_path.clone() }; - let suffix = - match entry.ref_type { - crate::io::xref_model::RefType::Overlay => " [Overlay]", - crate::io::xref_model::RefType::Attach => " [Attach]", - }; + let suffix = match entry.ref_type { + crate::io::xref_model::RefType::Overlay => " [Overlay]", + crate::io::xref_model::RefType::Attach => " [Attach]", + }; self.command_line .push_output(&format!(" {} — {}{}", entry.name, path, suffix)); } @@ -677,10 +797,9 @@ impl OpenCADStudio { .iter() .any(|e| crate::io::xref_model::wildcard_match(&e.name, pattern)); if !any { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); return Some(self.finish_dispatch(cmd)); } } @@ -690,13 +809,12 @@ impl OpenCADStudio { // (so the listing agrees) with fresh stat // baselines on the next palette refresh. let reload_keys: Vec = { - let entries = - crate::io::xref::collect_entries_with_prev( - &self.tabs[i].scene.document, - base_dir, - self.tabs[i].xref_unloaded.as_set(), - &self.tabs[i].xref_stat_cache.0, - ); + let entries = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); // Drawing references only (mirrors the palette // Reload): image/PDF rows keep their flags // untouched and nested rows report per-entry @@ -706,9 +824,7 @@ impl OpenCADStudio { for e in &entries { let hit = pattern.is_empty() || pattern == "*" - || crate::io::xref_model::wildcard_match( - &e.name, pattern, - ); + || crate::io::xref_model::wildcard_match(&e.name, pattern); if !hit { continue; } @@ -718,10 +834,13 @@ impl OpenCADStudio { e.name ).as_ref()); } else if e.kind != crate::io::xref_model::RefKind::DwgXref { - self.command_line.push_error(crate::tf!( - "{}: reload applies to drawing references only.", - e.name - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "{}: reload applies to drawing references only.", + e.name + ) + .as_ref(), + ); } else { keys.push(e.key); } @@ -741,7 +860,8 @@ impl OpenCADStudio { self.tabs[i].xref_unloaded.remove(key); self.tabs[i].xref_stat_cache.remove(key); } - let handles: rustc_hash::FxHashSet = self.tabs[i] + let handles: rustc_hash::FxHashSet = self.tabs + [i] .scene .document .block_records @@ -756,13 +876,12 @@ impl OpenCADStudio { ); // Targeted reload updates only the selected rows. { - let fresh = - crate::io::xref::collect_entries_with_prev( - &self.tabs[i].scene.document, - base_dir, - self.tabs[i].xref_unloaded.as_set(), - &self.tabs[i].xref_stat_cache.0, - ); + let fresh = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); for e in &fresh { if !reload_keys.contains(&e.key) { continue; @@ -785,10 +904,9 @@ impl OpenCADStudio { .collect() }; if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { for info in matched { self.report_xref_status(info); @@ -797,8 +915,12 @@ impl OpenCADStudio { self.post_ref_op(i); } } else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!( + "XREF Save the drawing first to resolve relative XREF paths." + ) + .as_ref(), + ); } } else if op.eq_ignore_ascii_case("Unload") { // Unload (empty/* = all): drop merged content @@ -825,10 +947,9 @@ impl OpenCADStudio { }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-UNLOAD"); let mut done = 0usize; @@ -845,10 +966,9 @@ impl OpenCADStudio { e.key, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: unloaded \"{}\".", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: unloaded \"{}\".", name).as_ref(), + ); self.tabs[i].xref_unloaded.add(e.key); done += 1; } @@ -884,10 +1004,9 @@ impl OpenCADStudio { }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-DETACH"); let mut done = 0usize; @@ -904,10 +1023,9 @@ impl OpenCADStudio { e.key, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: detached \"{}\".", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: detached \"{}\".", name).as_ref(), + ); self.tabs[i].xref_unloaded.remove(&e.key); self.tabs[i].xref_stat_cache.remove(&e.key); done += 1; @@ -923,14 +1041,20 @@ impl OpenCADStudio { // Path Find → prefix rewrite across all; // Path → verbatim per-ref store. let after_op = rest.get(op.len()..).unwrap_or("").trim(); - if after_op.split_whitespace().next().is_some_and(|w| w.eq_ignore_ascii_case("Find")) { + if after_op + .split_whitespace() + .next() + .is_some_and(|w| w.eq_ignore_ascii_case("Find")) + { // Find & Replace: tokens are Find // (`new` keeps spaces via remainder slice). let toks: Vec<&str> = after_op.split_whitespace().collect(); let (old, new) = if toks.len() >= 3 { // Find the exact token index for old to avoid substring // contamination (e.g. "in" matching inside "Find"). - if let Some(idx) = toks.iter().position(|t| t.eq_ignore_ascii_case(&toks[1])) { + if let Some(idx) = + toks.iter().position(|t| t.eq_ignore_ascii_case(&toks[1])) + { let pos = after_op.find(toks[idx]).unwrap_or(0) + toks[idx].len(); (toks[idx].to_string(), after_op[pos..].trim().to_string()) } else { @@ -940,7 +1064,9 @@ impl OpenCADStudio { (String::new(), String::new()) }; if old.is_empty() || new.is_empty() { - self.command_line.push_error(crate::t!("Usage: XREF Path Find ").as_ref()); + self.command_line.push_error( + crate::t!("Usage: XREF Path Find ").as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-PATH"); let n = crate::io::xref::replace_path_prefix( @@ -948,10 +1074,9 @@ impl OpenCADStudio { &old, &new, ); - self.command_line.push_output(crate::tf!( - "XREF: updated {} reference(s).", - n - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: updated {} reference(s).", n).as_ref(), + ); if n > 0 { self.post_ref_op(i); } @@ -968,7 +1093,9 @@ impl OpenCADStudio { .unwrap_or_default() }; if pattern.is_empty() || new_raw.is_empty() { - self.command_line.push_error(crate::t!("Usage: XREF Path ").as_ref()); + self.command_line.push_error( + crate::t!("Usage: XREF Path ").as_ref(), + ); } else { let base_dir: std::path::PathBuf = self.tabs[i] .current_path @@ -983,13 +1110,14 @@ impl OpenCADStudio { ); let matched: Vec<_> = entries .iter() - .filter(|e| crate::io::xref_model::wildcard_match(&e.name, &pattern)) + .filter(|e| { + crate::io::xref_model::wildcard_match(&e.name, &pattern) + }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-PATH"); let mut done = 0usize; @@ -1000,10 +1128,13 @@ impl OpenCADStudio { &new_raw, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: Path set for \"{}\" — Reload to apply.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + name + ) + .as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1029,12 +1160,19 @@ impl OpenCADStudio { None }; let Some(pathtype) = pathtype else { - self.command_line.push_error(crate::t!("Usage: XREF Pathtype [pattern]").as_ref()); + self.command_line.push_error( + crate::t!("Usage: XREF Pathtype [pattern]") + .as_ref(), + ); return Some(self.finish_dispatch(cmd)); }; let Some(host) = self.tabs[i].current_path.clone() else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!( + "XREF Save the drawing first to resolve relative XREF paths." + ) + .as_ref(), + ); return Some(self.finish_dispatch(cmd)); }; let base_dir: std::path::PathBuf = host @@ -1056,10 +1194,13 @@ impl OpenCADStudio { }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - if pattern.is_empty() { "*" } else { &pattern } - ).as_ref()); + self.command_line.push_error( + crate::tf!( + "XREF: no references match '{}'.", + if pattern.is_empty() { "*" } else { &pattern } + ) + .as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-PATHTYPE"); let mut done = 0usize; @@ -1071,10 +1212,13 @@ impl OpenCADStudio { &host, ) { Ok(_) => { - self.command_line.push_output(crate::tf!( - "XREF: Path set for \"{}\" — Reload to apply.", - e.name - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "XREF: Path set for \"{}\" — Reload to apply.", + e.name + ) + .as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1110,14 +1254,17 @@ impl OpenCADStudio { }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { let Some(host) = self.tabs[i].current_path.clone() else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!( + "XREF Save the drawing first to resolve relative XREF paths." + ) + .as_ref(), + ); return Some(self.finish_dispatch(cmd)); }; let host_dir: std::path::PathBuf = host @@ -1142,10 +1289,10 @@ impl OpenCADStudio { ) { Ok(outcome) => { if outcome.unremapped == 0 { - self.command_line.push_output(crate::tf!( - "XREF: bound \"{}\".", - outcome.name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: bound \"{}\".", outcome.name) + .as_ref(), + ); } else { self.command_line.push_output(crate::tf!( "XREF: bound \"{}\" with {} unremapped style handles (see bind limitations).", @@ -1186,10 +1333,9 @@ impl OpenCADStudio { }) .collect(); if matched.is_empty() { - self.command_line.push_error(crate::tf!( - "XREF: no references match '{}'.", - pattern - ).as_ref()); + self.command_line.push_error( + crate::tf!("XREF: no references match '{}'.", pattern).as_ref(), + ); } else { self.push_undo_snapshot(i, "XREF-OVERLAY"); let mut done = 0usize; @@ -1200,10 +1346,9 @@ impl OpenCADStudio { crate::io::xref_model::RefType::Overlay, ) { Ok(name) => { - self.command_line.push_output(crate::tf!( - "XREF: \"{}\" set to Overlay.", - name - ).as_ref()); + self.command_line.push_output( + crate::tf!("XREF: \"{}\" set to Overlay.", name).as_ref(), + ); done += 1; } Err(msg) => self.command_line.push_error(msg.as_str()), @@ -1221,10 +1366,9 @@ impl OpenCADStudio { if after.is_empty() { return Some(Task::done(Message::XAttachPick)); } - let cmd = - crate::modules::insert::xattach::XAttachCommand::with_path( - after.to_string(), - ); + let cmd = crate::modules::insert::xattach::XAttachCommand::with_path( + after.to_string(), + ); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { @@ -1241,16 +1385,15 @@ impl OpenCADStudio { // again) with fresh stat baselines on the next refresh. if let Some(path) = &self.tabs[i].current_path.clone() { if let Some(base_dir) = path.parent() { - let reload_keys: Vec = - crate::io::xref::collect_entries_with_prev( - &self.tabs[i].scene.document, - base_dir, - self.tabs[i].xref_unloaded.as_set(), - &self.tabs[i].xref_stat_cache.0, - ) - .iter() - .map(|e| e.key) - .collect(); + let reload_keys: Vec = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ) + .iter() + .map(|e| e.key) + .collect(); self.push_undo_snapshot(i, "XREF-RELOAD"); for key in &reload_keys { self.tabs[i].xref_unloaded.remove(key); @@ -1264,13 +1407,12 @@ impl OpenCADStudio { // baselines for every currently-Loaded entry so no row // reports false Stale. { - let fresh = - crate::io::xref::collect_entries_with_prev( - &self.tabs[i].scene.document, - base_dir, - self.tabs[i].xref_unloaded.as_set(), - &self.tabs[i].xref_stat_cache.0, - ); + let fresh = crate::io::xref::collect_entries_with_prev( + &self.tabs[i].scene.document, + base_dir, + self.tabs[i].xref_unloaded.as_set(), + &self.tabs[i].xref_stat_cache.0, + ); for e in &fresh { if e.status == crate::io::xref_model::RefStatus::Loaded { if let Some(m) = e.modified { @@ -1285,8 +1427,10 @@ impl OpenCADStudio { self.post_ref_op(i); } } else { - self.command_line - .push_error(crate::t!("XREF Save the drawing first to resolve relative XREF paths.").as_ref()); + self.command_line.push_error( + crate::t!("XREF Save the drawing first to resolve relative XREF paths.") + .as_ref(), + ); } } @@ -1314,15 +1458,16 @@ impl OpenCADStudio { Some(p) => { return Some(Task::done(Message::OpenRecent(std::path::PathBuf::from(p)))) } - None => self - .command_line - .push_error(crate::t!("XOPEN: select an external reference (xref) to open.").as_ref()), + None => self.command_line.push_error( + crate::t!("XOPEN: select an external reference (xref) to open.").as_ref(), + ), } } "NCOPY" | "NCOPYALL" => { let command = crate::modules::draw::modify::ncopy::NcopyCommand::new( - &self.tabs[i].scene.document, self.ncopy_bind, + &self.tabs[i].scene.document, + self.ncopy_bind, ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); @@ -1521,7 +1666,13 @@ mod tests { assert!(out.contains("Path set"), "got: {out:?}"); let i = app.active_tab; assert_eq!( - app.tabs[i].scene.document.block_records.get("PLAN").unwrap().xref_path, + app.tabs[i] + .scene + .document + .block_records + .get("PLAN") + .unwrap() + .xref_path, "new\\raw path.dwg" ); } @@ -1544,7 +1695,11 @@ mod tests { let h = app.tabs[i].scene.document.allocate_handle(); let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); def.handle = h; - app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + app.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::ImageDefinition(def)); // Reference it so collect_entries lists it. let mut img = acadrust::entities::RasterImage::new( "img.png", @@ -1553,9 +1708,16 @@ mod tests { 8.0, ); img.definition_handle = Some(h); - app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i] + .scene + .document + .add_entity(acadrust::EntityType::RasterImage(img)) + .unwrap(); let out = run_capture(&mut app, "XREF Overlay img.png"); - assert!(out.contains("overlays apply to drawing references only"), "got: {out:?}"); + assert!( + out.contains("overlays apply to drawing references only"), + "got: {out:?}" + ); } #[test] @@ -1569,7 +1731,11 @@ mod tests { let h = app.tabs[i].scene.document.allocate_handle(); let mut def = ImageDefinition::with_dimensions("img.png", 8, 8); def.handle = h; - app.tabs[i].scene.document.objects.insert(h, ObjectType::ImageDefinition(def)); + app.tabs[i] + .scene + .document + .objects + .insert(h, ObjectType::ImageDefinition(def)); let mut img = acadrust::entities::RasterImage::new( "img.png", acadrust::types::Vector3::ZERO, @@ -1577,12 +1743,22 @@ mod tests { 8.0, ); img.definition_handle = Some(h); - app.tabs[i].scene.document.add_entity(acadrust::EntityType::RasterImage(img)).unwrap(); + app.tabs[i] + .scene + .document + .add_entity(acadrust::EntityType::RasterImage(img)) + .unwrap(); app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); app.tabs[i].xref_unloaded.add(h.value()); let out = run_capture(&mut app, "XREF Reload img.png"); - assert!(out.contains("reload applies to drawing references only"), "got: {out:?}"); - assert!(!out.contains("no references match"), "spurious no-match, got: {out:?}"); + assert!( + out.contains("reload applies to drawing references only"), + "got: {out:?}" + ); + assert!( + !out.contains("no references match"), + "spurious no-match, got: {out:?}" + ); assert!( app.tabs[i].xref_unloaded.is_unloaded(h.value()), "image flag must stay untouched" @@ -1623,7 +1799,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_nested_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let mut host_doc = acadrust::CadDocument::new(); @@ -1650,7 +1829,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_bind_cli_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let mut xref_doc = acadrust::CadDocument::new(); @@ -1685,7 +1867,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_bind_limited_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let mut xref_doc = acadrust::CadDocument::new(); @@ -1704,7 +1889,10 @@ mod tests { let i = app.active_tab; app.tabs[i].current_path = Some(dir.join("app.dwg")); let out = run_capture(&mut app, "XREF Bind PLAN"); - assert!(out.contains("with 1 unremapped style handles (see bind limitations)"), "got: {out:?}"); + assert!( + out.contains("with 1 unremapped style handles (see bind limitations)"), + "got: {out:?}" + ); std::fs::remove_dir_all(&dir).ok(); } @@ -1723,7 +1911,10 @@ mod tests { .insert(h, ObjectType::UnderlayDefinition(def)); app.tabs[i].current_path = Some(std::env::temp_dir().join("ocs_xref_bind_host.dwg")); let out = run_capture(&mut app, "XREF Bind doc.pdf"); - assert!(out.contains("PDF bind (vector import) is not available"), "got: {out:?}"); + assert!( + out.contains("PDF bind (vector import) is not available"), + "got: {out:?}" + ); } #[test] @@ -1732,7 +1923,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_bind_nested_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let mut host_doc = acadrust::CadDocument::new(); @@ -1769,13 +1963,23 @@ mod tests { Some(&base), ); assert!( - app.tabs[i].scene.document.block_records.get(&name).is_some(), + app.tabs[i] + .scene + .document + .block_records + .get(&name) + .is_some(), "prepare must create the definition" ); app.commit_undo_delta(i, pending); app.undo_active_tab(); assert!( - app.tabs[i].scene.document.block_records.get(&name).is_none(), + app.tabs[i] + .scene + .document + .block_records + .get(&name) + .is_none(), "undo after XATTACH must remove the definition" ); } @@ -1789,12 +1993,15 @@ mod tests { let mut app = fresh_app(); add_dwg_xref(&mut app, "PLAN", "old/plan.dwg"); let i = app.active_tab; - app.tabs[i].current_path = - Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); + app.tabs[i].current_path = Some(std::path::PathBuf::from("C:/Drawings/host.dwg")); let out = run_capture(&mut app, "XREF Reload PLAN"); assert!(!out.contains("no references match"), "got: {out:?}"); assert_eq!( - app.tabs[i].history.pending.as_ref().map(|p| p.label.as_str()), + app.tabs[i] + .history + .pending + .as_ref() + .map(|p| p.label.as_str()), Some("XREF-RELOAD"), "reload must push an undo snapshot, got: {out:?}" ); @@ -1845,7 +2052,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_reload_skew_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let a_path = dir.join("plan_a.dwg"); @@ -1857,8 +2067,22 @@ mod tests { add_dwg_xref(&mut app, "PLAN-B", &b_path.to_string_lossy()); let i = app.active_tab; app.tabs[i].current_path = Some(dir.join("app.dwg")); - let key_a = app.tabs[i].scene.document.block_records.get("PLAN-A").unwrap().handle.value(); - let key_b = app.tabs[i].scene.document.block_records.get("PLAN-B").unwrap().handle.value(); + let key_a = app.tabs[i] + .scene + .document + .block_records + .get("PLAN-A") + .unwrap() + .handle + .value(); + let key_b = app.tabs[i] + .scene + .document + .block_records + .get("PLAN-B") + .unwrap() + .handle + .value(); let out = run_capture(&mut app, "XREF Reload PLAN-A"); assert!(!out.contains("no references match"), "got: {out:?}"); assert!( @@ -1877,7 +2101,10 @@ mod tests { let dir = std::env::temp_dir().join(format!( "ocs_xref_bind_undo_{}_{}", std::process::id(), - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos() + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() )); std::fs::create_dir_all(&dir).unwrap(); let mut xref_doc = acadrust::CadDocument::new(); @@ -1902,17 +2129,30 @@ mod tests { // Resolve first so `PLAN|*` names exist pre-bind. let out = run_capture(&mut app, "XREF Reload PLAN"); assert!(out.contains("Reloaded"), "got: {out:?}"); - assert!(app.tabs[i].scene.document.layers.get("PLAN|WALLS").is_some()); + assert!(app.tabs[i] + .scene + .document + .layers + .get("PLAN|WALLS") + .is_some()); let out = run_capture(&mut app, "XREF Bind PLAN"); assert!(out.contains("bound \"PLAN\""), "got: {out:?}"); - assert!(app.tabs[i].scene.document.layers.get("PLAN$0$WALLS").is_some()); + assert!(app.tabs[i] + .scene + .document + .layers + .get("PLAN$0$WALLS") + .is_some()); app.undo_active_tab(); let doc = &app.tabs[i].scene.document; let br = doc.block_records.get("PLAN").unwrap(); assert!(br.flags.is_xref, "undo must restore the xref flag"); assert_eq!(br.xref_path, saved, "undo must restore the xref path"); - assert!(doc.layers.get("PLAN|WALLS").is_some(), "undo must restore `|` names"); + assert!( + doc.layers.get("PLAN|WALLS").is_some(), + "undo must restore `|` names" + ); assert!( !doc.layers.names().any(|n| n.contains("PLAN$0")), "undo must drop the bound names" diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index caab4c5ef..8eb5ec360 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -495,8 +495,8 @@ impl OpenCADStudio { return Some(Task::none()); } - let insert = match self.tabs[i].scene.document.get_entity(insert_handle) { - Some(acadrust::EntityType::Insert(ins)) => ins.clone(), + let block_name = match self.tabs[i].scene.document.get_entity(insert_handle) { + Some(acadrust::EntityType::Insert(ins)) => ins.block_name.clone(), _ => { self.command_line .push_error(crate::t!("BEDIT: selected object is not a block reference.").as_ref()); @@ -509,13 +509,13 @@ impl OpenCADStudio { .scene .document .block_records - .get(&insert.block_name) + .get(&block_name) { Some(br) => (br.handle, br.flags.is_xref), None => { self.command_line.push_error(crate::tf!( "BEDIT: block \"{}\" not found.", - insert.block_name + block_name ).as_ref()); return Some(Task::none()); } @@ -532,7 +532,7 @@ impl OpenCADStudio { { self.tabs[i].active_cmd = None; return Some(Task::done(Message::BlockEditSwitch( - insert.block_name.clone(), + block_name.clone(), ))); } @@ -543,7 +543,7 @@ impl OpenCADStudio { .scene .document .block_records - .get(&insert.block_name) + .get(&block_name) .unwrap(); br.entity_handles .iter() @@ -564,7 +564,7 @@ impl OpenCADStudio { .entities() .filter_map(|entity| match entity { acadrust::EntityType::Insert(reference) - if reference.block_name.eq_ignore_ascii_case(&insert.block_name) + if reference.block_name.eq_ignore_ascii_case(&block_name) && !reference.attributes.is_empty() => { Some(( @@ -607,7 +607,7 @@ impl OpenCADStudio { } self.tabs[i].scene.block_edit_block = Some(br_handle); self.tabs[i].block_edits.push(BlockEditSession { - block_name: insert.block_name.clone(), + block_name: block_name.clone(), br_handle, return_layout, return_block, @@ -651,7 +651,7 @@ impl OpenCADStudio { self.tabs[i].dirty = true; self.command_line.push_info(crate::tf!( "BEDIT: Editing block \"{}\". Use Save Block or Discard to finish.", - insert.block_name + block_name ).as_ref()); } diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 91e925bc0..55102e3cc 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -81,11 +81,26 @@ mod coordinate_parsing_tests { #[test] fn parses_all_coordinate_forms() { let close = |a: glam::DVec3, b: glam::DVec3| (a - b).length() < 1e-9; - assert!(close(parse_coord("1,2").unwrap().0, glam::dvec3(1.0, 2.0, 0.0))); - assert!(close(parse_coord("1,2,3").unwrap().0, glam::dvec3(1.0, 2.0, 3.0))); - assert!(close(parse_coord("10<90").unwrap().0, glam::dvec3(0.0, 10.0, 0.0))); - assert!(close(parse_coord("10<90,4").unwrap().0, glam::dvec3(0.0, 10.0, 4.0))); - assert!(close(parse_coord("10<0<30").unwrap().0, glam::dvec3(5.0 * 3.0_f64.sqrt(), 0.0, 5.0))); + assert!(close( + parse_coord("1,2").unwrap().0, + glam::dvec3(1.0, 2.0, 0.0) + )); + assert!(close( + parse_coord("1,2,3").unwrap().0, + glam::dvec3(1.0, 2.0, 3.0) + )); + assert!(close( + parse_coord("10<90").unwrap().0, + glam::dvec3(0.0, 10.0, 0.0) + )); + assert!(close( + parse_coord("10<90,4").unwrap().0, + glam::dvec3(0.0, 10.0, 4.0) + )); + assert!(close( + parse_coord("10<0<30").unwrap().0, + glam::dvec3(5.0 * 3.0_f64.sqrt(), 0.0, 5.0) + )); assert_eq!(parse_coord("@10<0").unwrap().1, CoordKind::Relative); assert_eq!(parse_coord("#1,2").unwrap().1, CoordKind::Absolute); } @@ -102,8 +117,8 @@ mod coordinate_parsing_tests { /// the UCS icon, snap/ortho, the ViewCube — goes through this one type instead /// of re-deriving the axis math. Axes are orthonormal, so the inverse rotation /// is just the transpose (the dot products in `to_ucs`); no matrix inversion. -#[derive(Clone, Copy)] -pub(super) struct UcsXform { +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct UcsXform { origin: glam::DVec3, x: glam::DVec3, y: glam::DVec3, @@ -112,7 +127,7 @@ pub(super) struct UcsXform { impl UcsXform { /// Plain WCS — no active UCS. - pub(super) fn identity() -> Self { + pub(crate) fn identity() -> Self { Self { origin: glam::DVec3::ZERO, x: glam::DVec3::X, @@ -132,7 +147,12 @@ impl UcsXform { }; let z = x.cross(raw_y).normalize_or(x.cross(fallback_z).normalize()); let y = z.cross(x).normalize(); - Self { origin: v(ucs.origin), x, y, z } + Self { + origin: v(ucs.origin), + x, + y, + z, + } } pub(super) fn from_active(ucs: Option<&Ucs>) -> Self { @@ -191,10 +211,7 @@ impl UcsXform { /// Full UCS-local → WCS transform, using `origin` as local zero while /// retaining this UCS's orthonormal axes. - pub(super) fn to_wcs_transform_at( - &self, - origin: glam::DVec3, - ) -> acadrust::types::Transform { + pub(crate) fn to_wcs_transform_at(&self, origin: glam::DVec3) -> acadrust::types::Transform { use acadrust::types::{Matrix4, Transform}; Transform::from_matrix(Matrix4 { m: [ @@ -207,10 +224,7 @@ impl UcsXform { } /// Full WCS → UCS-local transform, using `origin` as the local zero. - pub(super) fn to_ucs_transform_at( - &self, - origin: glam::DVec3, - ) -> acadrust::types::Transform { + pub(crate) fn to_ucs_transform_at(&self, origin: glam::DVec3) -> acadrust::types::Transform { use acadrust::types::{Matrix4, Transform}; Transform::from_matrix(Matrix4 { m: [ @@ -326,7 +340,11 @@ pub(super) fn drafting_constrain( let radians = degrees.to_radians(); glam::DVec2::new(radians.cos(), radians.sin()) }); - let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { a } else { c }; + let direction = if delta.dot(a).abs() >= delta.dot(c).abs() { + a + } else { + c + }; let projected = direction * delta.dot(direction); let c = glam::DVec3::new(b.x + projected.x, b.y + projected.y, p.z); xf.to_wcs(c) @@ -393,8 +411,7 @@ pub(super) fn polar_constrain_near( tol_px: f32, xf: &UcsXform, ) -> glam::DVec3 { - polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf) - .unwrap_or(pt) + polar_constrain_if_near(pt, base, step_deg, view_rot, eye, bounds, tol_px, xf).unwrap_or(pt) } /// Hard axis lock (#312): the locked ray's direction — the nearest polar @@ -427,8 +444,16 @@ pub(super) fn axis_lock_capture( let radians = degrees.to_radians(); glam::DVec2::new(radians.cos(), radians.sin()) }); - let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { a } else { b }; - let direction = if delta.dot(direction) < 0.0 { -direction } else { direction }; + let direction = if delta.dot(a).abs() >= delta.dot(b).abs() { + a + } else { + b + }; + let direction = if delta.dot(direction) < 0.0 { + -direction + } else { + direction + }; direction.y.atan2(direction.x) }; let dir_ucs = glam::DVec3::new(ang.cos(), ang.sin(), 0.0); @@ -439,11 +464,7 @@ pub(super) fn axis_lock_capture( /// Project `pt` onto the locked ray through `base` — the hard lock applies to /// EVERYTHING, including an osnap hit, so a snap far off-axis contributes only /// its along-axis component (#312). -pub(super) fn axis_lock_apply( - pt: glam::DVec3, - base: glam::DVec3, - dir: glam::DVec3, -) -> glam::DVec3 { +pub(super) fn axis_lock_apply(pt: glam::DVec3, base: glam::DVec3, dir: glam::DVec3) -> glam::DVec3 { base + dir * (pt - base).dot(dir) } @@ -481,8 +502,7 @@ pub(super) fn entities_lower_left_by_bbox( /// Generate the next available auto group name ("*A1", "*A2", …). pub(super) fn next_group_auto_name(scene: &crate::scene::Scene) -> String { - let existing: rustc_hash::FxHashSet = - scene.groups().map(|g| g.name.clone()).collect(); + let existing: rustc_hash::FxHashSet = scene.groups().map(|g| g.name.clone()).collect(); for n in 1..=9999 { let name = format!("*A{n}"); if !existing.contains(&name) { diff --git a/src/app/mod.rs b/src/app/mod.rs index c33dbcec5..794f4cb96 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -487,6 +487,8 @@ pub(super) struct OpenCADStudio { layer_translator: Option, /// Working copy of the Drawing Units dialog; `None` while it is closed. drawing_units: Option, + /// Working copy of the Block Definition dialog; `None` while it is closed. + block_definition: Option, /// Working copy of the structured feature-control-frame editor. geometric_tolerance: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = @@ -1857,6 +1859,7 @@ pub enum ModalKind { LayerStateManager, LayerTranslator, DrawingUnits, + BlockDefinition, GeometricTolerance, DraftingSettings, AutoConstrainSettings, @@ -2830,6 +2833,30 @@ pub enum Message { DrawingUnitsField(crate::ui::window::drawing_units::Field), /// Drawing Units OK — write the working copy into the drawing. DrawingUnitsApply, + /// Block Definition dialog field updates + BlockDefName(String), + BlockDefNameSelect(String), + BlockDefBaseOnScreen(bool), + BlockDefPickPoint, + BlockDefBaseX(String), + BlockDefBaseY(String), + BlockDefBaseZ(String), + BlockDefObjectsOnScreen(bool), + BlockDefSelectObjects, + BlockDefQuickSelect, + BlockDefObjectMode(crate::ui::window::block_definition::BlockObjectMode), + BlockDefAnnotative(bool), + BlockDefMatchOrientation(bool), + BlockDefScaleUniformly(bool), + BlockDefAllowExploding(bool), + BlockDefUnit(i16), + BlockDefDescription(String), + BlockDefDescriptionAction(iced::widget::text_editor::Action), + BlockDefHyperlink, + BlockDefApply, + BlockDefConfirmRedefine(bool), + BlockDefDismissError, + BlockDefHelp, /// One structured feature-control-frame field changed. ToleranceDialogField(crate::ui::window::geometric_tolerance::Field), /// One structured feature-control-frame option changed. @@ -3902,6 +3929,7 @@ impl OpenCADStudio { last_layer_translation: None, layer_translator: None, drawing_units: None, + block_definition: None, geometric_tolerance: None, pick_drag_rect: false, perf_hud: false, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 34898c111..09be3f45c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1117,6 +1117,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if let Some(state) = self.qselect.take() { self.qselect_settings = Some((&state).into()); self.reset_modal_geometry(); + if self.block_definition.is_some() { + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + } return Task::none(); } { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8edd3f181..8f9cb9eb0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1,4 +1,5 @@ use super::{ArrowKey, Message, OpenCADStudio, TextEntryMode}; +use crate::command::CadCommand; use crate::scene::VIEWCUBE_DRAW_PX; use crate::ui::PropertiesPanel; use iced::time::Instant; @@ -206,6 +207,7 @@ impl OpenCADStudio { self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute; } Some(GeometricTolerance) => self.geometric_tolerance = None, + Some(BlockDefinition) => self.block_definition = None, Some(Hyperlink) => { self.hyperlink_editor_handles.clear(); self.hyperlink_editor_url.clear(); @@ -313,8 +315,34 @@ impl OpenCADStudio { { return self.update(Message::AliasEditorDraftCancel); } + if self.active_modal == Some(super::ModalKind::BlockDefinition) { + if let Some(ref mut state) = self.block_definition { + if state.confirm_redefine.is_some() { + state.confirm_redefine = None; + return Task::none(); + } + if state.error_message.is_some() { + state.error_message = None; + return Task::none(); + } + } + } return self.update(Message::CloseModal); } + if self.active_modal == Some(super::ModalKind::BlockDefinition) { + if matches!(msg, Message::CommandFinalize) + || matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")) + { + if let Some(ref state) = self.block_definition { + if state.confirm_redefine.is_some() { + // Default to "No" so an accidental Enter doesn't nuke a definition. + return self.update(Message::BlockDefConfirmRedefine(false)); + } else { + return self.update(Message::BlockDefApply); + } + } + } + } if is_modal_blocked_key_msg(&msg) { return Task::none(); } @@ -4213,6 +4241,12 @@ impl OpenCADStudio { return Task::none(); } let description = self.hyperlink_editor_description.trim().to_owned(); + if let Some(block_def) = self.block_definition.as_mut() { + block_def.hyperlink_url = url; + block_def.hyperlink_desc = description; + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + return Task::none(); + } let values = if url.is_empty() { None } else { @@ -4236,6 +4270,12 @@ impl OpenCADStudio { Task::none() } Message::HyperlinkRemove => { + if let Some(block_def) = self.block_definition.as_mut() { + block_def.hyperlink_url.clear(); + block_def.hyperlink_desc.clear(); + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + return Task::none(); + } let i = self.active_tab; let handles = self.hyperlink_editor_handles.clone(); self.apply_property_op(i, "HYPERLINK", &handles, |app, handle| { @@ -4250,6 +4290,10 @@ impl OpenCADStudio { Task::none() } Message::HyperlinkCancel => { + if self.block_definition.is_some() { + self.active_modal = Some(crate::app::ModalKind::BlockDefinition); + return Task::none(); + } self.close_active_modal(); Task::none() } @@ -4606,6 +4650,189 @@ impl OpenCADStudio { ); Task::none() } + Message::BlockDefName(name) => { + if let Some(state) = self.block_definition.as_mut() { + state.name = name; + state.error_message = None; + state.confirm_redefine = None; + } + Task::none() + } + Message::BlockDefNameSelect(chosen) => { + if let Some(state) = self.block_definition.as_mut() { + state.name = chosen; + state.error_message = None; + state.confirm_redefine = None; + } + Task::none() + } + Message::BlockDefBaseOnScreen(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.base_point_specify_onscreen = val; + } + Task::none() + } + Message::BlockDefPickPoint => { + self.active_modal = None; + let cmd = crate::modules::insert::create_block::BlockPickBasePointCommand; + self.command_line.push_info(&cmd.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); + Task::none() + } + Message::BlockDefBaseX(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.base_point_x = val; + } + Task::none() + } + Message::BlockDefBaseY(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.base_point_y = val; + } + Task::none() + } + Message::BlockDefBaseZ(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.base_point_z = val; + } + Task::none() + } + Message::BlockDefObjectsOnScreen(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.objects_specify_onscreen = val; + } + Task::none() + } + Message::BlockDefSelectObjects => { + self.active_modal = None; + use crate::modules::draw::select::SelectObjectsCommand; + let cmd = SelectObjectsCommand::plain("BLOCK", "BLOCK_OBJECTS_GATHERED"); + self.command_line.push_info(&cmd.prompt()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd)); + Task::none() + } + Message::BlockDefQuickSelect => { + self.active_modal = None; + self.on_qselect_open() + } + Message::BlockDefObjectMode(mode) => { + if let Some(state) = self.block_definition.as_mut() { + state.object_mode = mode; + } + Task::none() + } + Message::BlockDefAnnotative(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.annotative = val; + if !val { + state.match_orientation = false; + } + } + Task::none() + } + Message::BlockDefMatchOrientation(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.match_orientation = val; + } + Task::none() + } + Message::BlockDefScaleUniformly(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.scale_uniformly = val; + } + Task::none() + } + Message::BlockDefAllowExploding(val) => { + if let Some(state) = self.block_definition.as_mut() { + state.allow_exploding = val; + } + Task::none() + } + Message::BlockDefUnit(unit) => { + if let Some(state) = self.block_definition.as_mut() { + state.unit = unit; + } + Task::none() + } + Message::BlockDefDescription(desc) => { + if let Some(state) = self.block_definition.as_mut() { + state.description = desc; + } + Task::none() + } + Message::BlockDefDescriptionAction(action) => { + if let Some(state) = self.block_definition.as_mut() { + state.description_content.perform(action); + state.description = state.description_content.text(); + } + Task::none() + } + Message::BlockDefHyperlink => { + if let Some(state) = self.block_definition.as_ref() { + self.hyperlink_editor_url = state.hyperlink_url.clone(); + self.hyperlink_editor_description = state.hyperlink_desc.clone(); + self.hyperlink_editor_mixed = false; + self.hyperlink_editor_dirty = false; + self.hyperlink_editor_handles.clear(); + self.active_modal = Some(crate::app::ModalKind::Hyperlink); + } + Task::none() + } + Message::BlockDefDismissError => { + if let Some(state) = self.block_definition.as_mut() { + state.error_message = None; + } + Task::none() + } + Message::BlockDefHelp => { + self.command_line.push_info( + crate::t!("BLOCK creates a block definition from objects you select.").as_ref(), + ); + Task::none() + } + Message::BlockDefConfirmRedefine(confirmed) => { + if !confirmed { + if let Some(state) = self.block_definition.as_mut() { + state.confirm_redefine = None; + } + return Task::none(); + } + self.commit_block_definition(true) + } + Message::BlockDefApply => { + let Some(state) = self.block_definition.as_mut() else { + return Task::none(); + }; + let name = state.name.trim().to_string(); + if name.is_empty() { + state.error_message = Some(crate::t!("Block name cannot be empty.").into_owned()); + return Task::none(); + } + if let Some(_ch) = state.invalid_name_char() { + state.error_message = Some( + crate::t!("Block name cannot contain: \\ / : * ? \" < > | = `").into_owned(), + ); + return Task::none(); + } + if name.starts_with('*') { + state.error_message = + Some(crate::t!("Block name cannot start with '*'.").into_owned()); + return Task::none(); + } + if !state.objects_specify_onscreen && state.selected_handles.is_empty() { + state.error_message = Some( + crate::t!("No objects selected. You must select objects to define a block.") + .into_owned(), + ); + return Task::none(); + } + let i = self.active_tab; + if self.tabs[i].scene.document.block_records.get(&name).is_some() { + state.confirm_redefine = Some(name); + return Task::none(); + } + self.commit_block_definition(false) + } Message::ToleranceDialogField(field) => { if let Some(state) = self.geometric_tolerance.as_mut() { state.apply_field(field); @@ -5558,6 +5785,9 @@ impl OpenCADStudio { self.qselect_settings = Some((&state).into()); } self.reset_modal_geometry(); + if self.block_definition.is_some() { + self.active_modal = Some(super::ModalKind::BlockDefinition); + } Task::none() } @@ -5754,6 +5984,16 @@ impl OpenCADStudio { self.command_line .push_output(crate::tf!("QSELECT: {} object(s) selected.", matched).as_ref()); self.refresh_properties(); + if let Some(ref mut block_def) = self.block_definition { + block_def.selected_handles = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .map(|(h, _)| h) + .collect(); + block_def.error_message = None; + self.active_modal = Some(super::ModalKind::BlockDefinition); + } Task::none() } diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0d2f85b65..b7a7c092e 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -23,6 +23,7 @@ impl OpenCADStudio { Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), + Some(K::BlockDefinition) => crate::t!("Block Definition").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), @@ -499,6 +500,12 @@ impl OpenCADStudio { crate::ui::window::drawing_units::view_window(state, flow) }) } + super::super::ModalKind::BlockDefinition => { + let state = self.block_definition.as_ref()?; + sized_flow(ex, 580, 390, |flow| { + crate::ui::window::block_definition::view_window(state, flow) + }) + } super::super::ModalKind::GeometricTolerance => { let state = self.geometric_tolerance.as_ref()?; sized_flow(ex, 670, 530, |flow| { diff --git a/src/command.rs b/src/command.rs index bc2cc4d61..01f095ac9 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1520,6 +1520,10 @@ pub enum CmdResult { name: String, base: DVec3, }, + /// Create a block definition with full dialog options (Retain/Convert/Delete, units, annotative, etc.). + CreateBlockWithOptions { + options: Box, + }, /// Apply a transform to selected entities and end the command. TransformSelected(Vec, EntityTransform), /// Copy selected entities with a transform; command stays active for more copies. diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 6ef505171..40b388976 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -98,11 +98,20 @@ pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec ins - .explode_from_document(document) - .into_iter() - .map(normalize_insert_entity) - .collect(), + EntityType::Insert(ins) => { + let explodable = document + .block_records + .get(&ins.block_name) + .map_or(true, |br| br.explodable); + if !explodable { + vec![] + } else { + ins.explode_from_document(document) + .into_iter() + .map(normalize_insert_entity) + .collect() + } + } EntityType::MLine(ml) => explode_mline(ml), EntityType::Dimension(dim) => explode_dimension(dim, document), _ => vec![], @@ -1500,6 +1509,18 @@ mod tests { assert!(explode_entity(&EntityType::Insert(insert), &doc).is_empty()); } + #[test] + fn unexplodable_block_is_not_exploded() { + let mut doc = CadDocument::new(); + let mut block = acadrust::tables::BlockRecord::new("NO_EXPLODE"); + block.handle = doc.allocate_handle(); + block.explodable = false; + doc.block_records.add(block).unwrap(); + + let insert = acadrust::entities::Insert::new("NO_EXPLODE", Vector3::new(0.0, 0.0, 0.0)); + assert!(explode_entity(&EntityType::Insert(insert), &doc).is_empty()); + } + /// A dimension created without a geometry block gets a real `*D` block on /// bake, its `block_name` resolves to that block, and a second bake is a /// no-op (already has a valid block). diff --git a/src/modules/insert/create_block.rs b/src/modules/insert/create_block.rs index f7fadd7fd..71d26bf7e 100644 --- a/src/modules/insert/create_block.rs +++ b/src/modules/insert/create_block.rs @@ -1,6 +1,6 @@ +use crate::t; use acadrust::Handle; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -92,3 +92,560 @@ impl CadCommand for CreateBlockCommand { vec![] } } + +/// One-shot point picker for the Block Definition dialog's "Pick point" button. +pub struct BlockPickBasePointCommand; + +impl CadCommand for BlockPickBasePointCommand { + fn name(&self) -> &'static str { + "BLOCK" + } + + fn prompt(&self) -> String { + crate::t!("BLOCK Specify insertion base point:").into_owned() + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + CmdResult::Dispatch(format!("BLOCK_POINT_PICKED {} {} {}", pt.x, pt.y, pt.z)) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Dispatch("BLOCK_POINT_CANCELLED".to_string()) + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Dispatch("BLOCK_POINT_CANCELLED".to_string()) + } +} + +/// Command handling the on-screen prompting sequence when "Specify On-screen" is checked in the dialog. +/// Enforces standard command ordering: Base point is ALWAYS prompted before object selection. +pub struct BlockOnScreenCommand { + options: crate::scene::CreateBlockOptions, + ucs: crate::app::helpers::UcsXform, + need_base: bool, + need_objects: bool, +} + +impl BlockOnScreenCommand { + pub fn new( + options: crate::scene::CreateBlockOptions, + ucs: crate::app::helpers::UcsXform, + need_base: bool, + need_objects: bool, + ) -> Self { + Self { + options, + ucs, + need_base, + need_objects, + } + } +} + +impl CadCommand for BlockOnScreenCommand { + fn name(&self) -> &'static str { + "BLOCK" + } + + fn prompt(&self) -> String { + if self.need_base { + crate::t!("BLOCK Specify insertion base point:").into_owned() + } else { + crate::t!("BLOCK Select objects:").into_owned() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.need_base { + self.options.base_point = pt; + self.options.world_to_block = self.ucs.to_ucs_transform_at(pt); + self.options.block_to_world = self.ucs.to_wcs_transform_at(pt); + self.need_base = false; + if self.need_objects { + CmdResult::NeedPoint + } else { + CmdResult::CreateBlockWithOptions { + options: Box::new(self.options.clone()), + } + } + } else { + CmdResult::NeedPoint + } + } + + fn is_selection_gathering(&self) -> bool { + !self.need_base && self.need_objects + } + + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.options.handles = handles; + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.need_base { + return CmdResult::Cancel; + } + if self.options.handles.is_empty() { + return CmdResult::Cancel; + } + CmdResult::CreateBlockWithOptions { + options: Box::new(self.options.clone()), + } + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scene::{CreateBlockOptions, Scene}; + use crate::ui::window::block_definition::BlockObjectMode; + use acadrust::entities::Line; + use acadrust::types::{Transform, Vector3}; + + fn make_test_scene() -> (Scene, Handle) { + let mut scene = Scene::new(); + let mut line = Line::new(); + line.start = Vector3::new(0.0, 0.0, 0.0); + line.end = Vector3::new(10.0, 10.0, 0.0); + let h = scene.add_entity(acadrust::EntityType::Line(line)); + (scene, h) + } + + #[test] + fn test_create_block_retain_mode() { + let (mut scene, line_h) = make_test_scene(); + let options = CreateBlockOptions { + name: "BLK_RETAIN".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "Retain test".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + let result = scene.create_block_with_options(options); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Handle::NULL); // No insert created in Retain mode + + // Original line must still exist in document + assert!(scene.document.get_entity(line_h).is_some()); + // Block definition must exist + assert!(scene.document.block_records.get("BLK_RETAIN").is_some()); + } + + #[test] + fn test_create_block_convert_mode() { + let (mut scene, line_h) = make_test_scene(); + let options = CreateBlockOptions { + name: "BLK_CONVERT".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: true, + allow_exploding: true, + unit: 4, // mm + description: "Convert test".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + let result = scene.create_block_with_options(options); + assert!(result.is_ok()); + let insert_h = result.unwrap(); + assert_ne!(insert_h, Handle::NULL); + + // Original line was erased from model space + assert!(scene.document.get_entity(line_h).is_none()); + // Insert entity exists in drawing + let insert_entity = scene.document.get_entity(insert_h); + assert!(insert_entity.is_some()); + if let Some(acadrust::EntityType::Insert(ins)) = insert_entity { + assert_eq!(ins.block_name, "BLK_CONVERT"); + } else { + panic!("Expected Insert entity"); + } + + // Check BlockRecord properties + let br = scene.document.block_records.get("BLK_CONVERT").unwrap(); + assert_eq!(br.units, 4); + assert!(br.scale_uniformly); + assert!(br.explodable); + assert_eq!(br.description, "Convert test"); + } + + #[test] + fn test_create_block_delete_mode() { + let (mut scene, line_h) = make_test_scene(); + let options = CreateBlockOptions { + name: "BLK_DELETE".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Delete, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + let result = scene.create_block_with_options(options); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), Handle::NULL); + + // Original line erased + assert!(scene.document.get_entity(line_h).is_none()); + // Block definition exists + assert!(scene.document.block_records.get("BLK_DELETE").is_some()); + } + + #[test] + fn test_create_block_redefinition() { + let (mut scene, line1_h) = make_test_scene(); + let options1 = CreateBlockOptions { + name: "REDEF_BLOCK".to_string(), + handles: vec![line1_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "Initial".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + assert!(scene.create_block_with_options(options1).is_ok()); + + // Attempting to create duplicate without redefine flag fails + let line2_h = { + let mut line2 = Line::new(); + line2.start = Vector3::new(5.0, 5.0, 0.0); + line2.end = Vector3::new(15.0, 15.0, 0.0); + scene.add_entity(acadrust::EntityType::Line(line2)) + }; + + let options2 = CreateBlockOptions { + name: "REDEF_BLOCK".to_string(), + handles: vec![line2_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 6, // meters + description: "Redefined".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + let err_res = scene.create_block_with_options(options2.clone()); + assert!(err_res.is_err()); + assert!(err_res.unwrap_err().contains("already exists")); + + // Redefine with redefine: true succeeds and updates fields + let mut options3 = options2; + options3.redefine = true; + let redef_res = scene.create_block_with_options(options3); + assert!(redef_res.is_ok()); + + let br = scene.document.block_records.get("REDEF_BLOCK").unwrap(); + assert_eq!(br.description, "Redefined"); + assert_eq!(br.units, 6); + } + + #[test] + fn test_create_block_validation() { + let (mut scene, line_h) = make_test_scene(); + + // Empty name + let opt_empty_name = CreateBlockOptions { + name: " ".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + assert!(scene.create_block_with_options(opt_empty_name).is_err()); + + // Anonymous '*' prefix blocked + let opt_star = CreateBlockOptions { + name: "*ANON".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + assert!(scene.create_block_with_options(opt_star).is_err()); + + // Empty handles + let opt_no_handles = CreateBlockOptions { + name: "EMPTY_HANDLES".to_string(), + handles: vec![], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Retain, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + assert!(scene.create_block_with_options(opt_no_handles).is_err()); + } + + #[test] + fn test_block_on_screen_command_ordering() { + let dummy_options = CreateBlockOptions { + name: "ON_SCREEN_TEST".to_string(), + handles: vec![], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + // When both base point and objects are to be specified on-screen: + let ucs = crate::app::helpers::UcsXform::identity(); + let mut cmd = BlockOnScreenCommand::new(dummy_options, ucs, true, true); + + // 1. Initial step must be insertion base point + assert!(cmd.prompt().contains("Specify insertion base point")); + + // 2. Pick base point -> prompt switches to object selection + let picked_pt = DVec3::new(10.0, 20.0, 0.0); + let res1 = cmd.on_point(picked_pt); + assert!(matches!(res1, CmdResult::NeedPoint)); + assert!(cmd.prompt().contains("Select objects")); + assert!(cmd.is_selection_gathering()); + + // 3. User attempts enter before selecting objects -> cancelled + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + + // 4. Complete selection with handles + let fake_handle = Handle::new(42); + cmd.on_selection_complete(vec![fake_handle]); + + // 5. User presses enter to finish selection + let finish = cmd.on_enter(); + match finish { + CmdResult::CreateBlockWithOptions { options } => { + assert_eq!(options.name, "ON_SCREEN_TEST"); + assert_eq!(options.base_point, picked_pt); + assert_eq!(options.handles, vec![fake_handle]); + assert_eq!(options.world_to_block, ucs.to_ucs_transform_at(picked_pt)); + assert_eq!(options.block_to_world, ucs.to_wcs_transform_at(picked_pt)); + } + _ => panic!("Expected CreateBlockWithOptions"), + } + } + + #[test] + fn test_block_on_screen_base_point_sets_correct_transforms() { + let (mut scene, line_h) = make_test_scene(); + let initial_options = CreateBlockOptions { + name: "ON_SCREEN_BASE_TEST".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + + // User picked objects upfront in dialog, but checked "Specify base point on screen" + let ucs = crate::app::helpers::UcsXform::identity(); + let mut cmd = BlockOnScreenCommand::new(initial_options, ucs, true, false); + + // Pick base point at (10, 10, 0) (end of the line in make_test_scene) + let pick_pt = DVec3::new(10.0, 10.0, 0.0); + let res = cmd.on_point(pick_pt); + + let created_options = match res { + CmdResult::CreateBlockWithOptions { options } => *options, + _ => { + panic!("Expected CreateBlockWithOptions immediately when objects already selected") + } + }; + + assert_eq!(created_options.base_point, pick_pt); + assert_eq!( + created_options.world_to_block, + ucs.to_ucs_transform_at(pick_pt) + ); + assert_eq!( + created_options.block_to_world, + ucs.to_wcs_transform_at(pick_pt) + ); + + // Create the block with these options and verify insert position and definition entities + let insert_h = scene.create_block_with_options(created_options).unwrap(); + assert_ne!(insert_h, Handle::NULL); + + // In Convert mode, the insert entity must be placed at the picked base point + let insert_entity = scene.document.get_entity(insert_h).unwrap(); + if let acadrust::EntityType::Insert(ins) = insert_entity { + assert_eq!(ins.block_name, "ON_SCREEN_BASE_TEST"); + assert!((ins.insert_point.x - 10.0).abs() < 1e-6); + assert!((ins.insert_point.y - 10.0).abs() < 1e-6); + assert!((ins.insert_point.z - 0.0).abs() < 1e-6); + } else { + panic!("Expected Insert entity"); + } + + // Inside the block table record, the line entity must have been transformed relative to (10, 10, 0) + // Original line was (0,0,0) to (10,10,0), so inside the block it must be (-10,-10,0) to (0,0,0) + let br = scene + .document + .block_records + .get("ON_SCREEN_BASE_TEST") + .unwrap(); + let block_line = scene + .document + .entities() + .find(|e| { + e.common().owner_handle == br.handle && matches!(e, acadrust::EntityType::Line(_)) + }) + .unwrap(); + if let acadrust::EntityType::Line(l) = block_line { + assert!((l.start.x - (-10.0)).abs() < 1e-6); + assert!((l.start.y - (-10.0)).abs() < 1e-6); + assert!((l.end.x - 0.0).abs() < 1e-6); + assert!((l.end.y - 0.0).abs() < 1e-6); + } else { + panic!("Expected Line entity inside block"); + } + } + + #[test] + fn test_explodable_flag_prevents_explode() { + let (mut scene, line_h) = make_test_scene(); + let options = CreateBlockOptions { + name: "BLK_NO_EXPLODE".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: false, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + let insert_h = scene.create_block_with_options(options).unwrap(); + let insert = scene.document.get_entity(insert_h).unwrap(); + let pieces = crate::modules::draw::modify::explode::explode_entity(insert, &scene.document); + assert!(pieces.is_empty(), "Unexplodable block must not explode"); + } + + #[test] + fn test_explodable_flag_allows_explode() { + let (mut scene, line_h) = make_test_scene(); + let options = CreateBlockOptions { + name: "BLK_CAN_EXPLODE".to_string(), + handles: vec![line_h], + base_point: DVec3::ZERO, + world_to_block: Transform::identity(), + block_to_world: Transform::identity(), + mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: "".to_string(), + hyperlink_url: "".to_string(), + hyperlink_desc: "".to_string(), + redefine: false, + }; + let insert_h = scene.create_block_with_options(options).unwrap(); + let insert = scene.document.get_entity(insert_h).unwrap(); + let pieces = crate::modules::draw::modify::explode::explode_entity(insert, &scene.document); + assert_eq!( + pieces.len(), + 1, + "Explodable block should explode into 1 line" + ); + } +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index cbfd84344..01c7eed3f 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1,6 +1,24 @@ -// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +#[derive(Debug, Clone)] +pub struct CreateBlockOptions { + pub name: String, + pub handles: Vec, + pub base_point: glam::DVec3, + pub world_to_block: acadrust::types::Transform, + pub block_to_world: acadrust::types::Transform, + pub mode: crate::ui::window::block_definition::BlockObjectMode, + pub annotative: bool, + pub match_orientation: bool, + pub scale_uniformly: bool, + pub allow_exploding: bool, + pub unit: i16, + pub description: String, + pub hyperlink_url: String, + pub hyperlink_desc: String, + pub redefine: bool, +} + /// Order sampled boundary edges into one tip-to-tail loop. pub(super) fn chain_path_edges(polys: Vec>) -> Vec<[f64; 2]> { chain_path_edges_with_directions(polys).0 @@ -845,18 +863,151 @@ impl Scene { world_to_block: &acadrust::types::Transform, block_to_world: &acadrust::types::Transform, ) -> Result { - let name = name.trim(); + self.create_block_with_options(CreateBlockOptions { + name: name.to_string(), + handles: handles.to_vec(), + base_point: glam::DVec3::ZERO, + world_to_block: *world_to_block, + block_to_world: *block_to_world, + mode: crate::ui::window::block_definition::BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: 0, + description: String::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + redefine: false, + }) + } + + pub fn create_block_with_options( + &mut self, + options: CreateBlockOptions, + ) -> Result { + let name = options.name.trim(); if name.is_empty() { return Err("Block name cannot be empty.".into()); } if name.starts_with('*') { return Err("Block name cannot start with '*'.".into()); } - if self.document.block_records.get(name).is_some() { - return Err(format!("Block \"{name}\" already exists.")); - } - let source_entities: Vec<_> = handles + let existing = self + .document + .block_records + .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 { + if !options.redefine { + return Err(format!("Block \"{name}\" already exists.")); + } + // Redefining: remove all existing entities owned by this block record (except Block/BlockEnd) + let owned: Vec = self + .document + .entities() + .filter(|e| { + e.common().owner_handle == existing_br_h + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|e| e.common().handle) + .collect(); + for h in owned { + self.document.remove_entity(h); + } + // Update existing BlockRecord fields + if let Some(br) = self.document.block_records.get_mut(name) { + br.units = options.unit; + br.description = options.description.clone(); + br.explodable = options.allow_exploding; + br.scale_uniformly = options.scale_uniformly; + } + // Update existing Block entity fields + if let Some(EntityType::Block(b)) = self.document.get_entity_mut(existing_blk_h) { + b.description = options.description.clone(); + } + if options.annotative { + let annotative_vals = vec![acadrust::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![acadrust::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(); + let br_handle = Handle::new(next); + let block_handle = Handle::new(next + 1); + let end_handle = Handle::new(next + 2); + + let mut block_record = acadrust::tables::BlockRecord::new(name); + block_record.handle = br_handle; + block_record.block_entity_handle = block_handle; + block_record.block_end_handle = end_handle; + block_record.units = options.unit; + block_record.description = options.description.clone(); + block_record.explodable = options.allow_exploding; + block_record.scale_uniformly = options.scale_uniformly; + self.document + .block_records + .add(block_record) + .map_err(|e| e.to_string())?; + + let mut block = Block::new(name, acadrust::types::Vector3::ZERO); + block.common.handle = block_handle; + block.common.owner_handle = br_handle; + block.description = options.description.clone(); + self.document + .add_entity(EntityType::Block(block)) + .map_err(|e| e.to_string())?; + + let mut block_end = BlockEnd::new(); + block_end.common.handle = end_handle; + block_end.common.owner_handle = br_handle; + self.document + .add_entity(EntityType::BlockEnd(block_end)) + .map_err(|e| e.to_string())?; + + if options.annotative { + let annotative_vals = vec![acadrust::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![acadrust::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) + }; + + let source_entities: Vec<_> = options + .handles .iter() .filter_map(|&h| self.document.get_entity(h).cloned().map(|e| (h, e))) .collect(); @@ -864,35 +1015,7 @@ impl Scene { return Err("No valid entities selected for block creation.".into()); } - let next = self.document.next_handle(); - let br_handle = Handle::new(next); - let block_handle = Handle::new(next + 1); - let end_handle = Handle::new(next + 2); - - let mut block_record = acadrust::tables::BlockRecord::new(name); - block_record.handle = br_handle; - block_record.block_entity_handle = block_handle; - block_record.block_end_handle = end_handle; - self.document - .block_records - .add(block_record) - .map_err(|e| e.to_string())?; - - let mut block = Block::new(name, acadrust::types::Vector3::ZERO); - block.common.handle = block_handle; - block.common.owner_handle = br_handle; - self.document - .add_entity(EntityType::Block(block)) - .map_err(|e| e.to_string())?; - - let mut block_end = BlockEnd::new(); - block_end.common.handle = end_handle; - block_end.common.owner_handle = br_handle; - self.document - .add_entity(EntityType::BlockEnd(block_end)) - .map_err(|e| e.to_string())?; - - let local = EntityTransform::Affine(*world_to_block); + let local = EntityTransform::Affine(options.world_to_block); for (old_handle, mut entity) in source_entities { view::dispatch::apply_transform(&mut entity, &local); entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity); @@ -901,15 +1024,19 @@ impl Scene { self.document .add_entity(entity) .map_err(|e| e.to_string())?; - self.erase_entities(&[old_handle]); + if options.mode != crate::ui::window::block_definition::BlockObjectMode::Retain { + self.erase_entities(&[old_handle]); + } } - let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO); - acadrust::Entity::apply_transform(&mut insert, block_to_world); - let insert_handle = self.add_entity(EntityType::Insert(insert)); - // A new block definition landed in the document; advance the block - // epoch so consumers (the block palette stale check) notice it even - // when the panel stays open. Mirrors `define_block_from_owned_entities`. + let mut insert_handle = Handle::NULL; + if options.mode == crate::ui::window::block_definition::BlockObjectMode::Convert { + let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO); + acadrust::Entity::apply_transform(&mut insert, &options.block_to_world); + insert_handle = self.add_entity(EntityType::Insert(insert)); + } + + // Advance the block epoch so all consumers and viewport renderers re-tessellate self.bump_geometry(); Ok(insert_handle) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e0282540f..bc8c98cec 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -36,6 +36,7 @@ pub use dimension_assoc::{ReferenceStatus, ResolvedReference}; pub use page_setup::{apply_default_page_setup, document_page_setups, rotated_margins}; mod dwg_native_constraints; mod entity; +pub use entity::CreateBlockOptions; #[cfg(test)] mod hatch_boundary; mod group_layer; diff --git a/src/ui/window/block_definition.rs b/src/ui/window/block_definition.rs new file mode 100644 index 000000000..5894eecfb --- /dev/null +++ b/src/ui/window/block_definition.rs @@ -0,0 +1,696 @@ +//! Block Definition dialog window — create or redefine a block definition. +//! Prompted when the user runs the `BLOCK` or `BMAKE` command. + +use std::fmt; + +use acadrust::Handle; +use iced::widget::{ + button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text, + text_editor, text_input, Space, +}; +use iced::{Background, Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::modules::draw::units; +use crate::t; +use crate::ui::style::common::muted_style; +use crate::ui::style::form::{button_style, field_style}; + +static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg"); +static ICON_SELECT_OBJECTS: &[u8] = + include_bytes!("../../../assets/icons/blocks/select_objects.svg"); +static ICON_QUICK_SELECT: &[u8] = include_bytes!("../../../assets/icons/blocks/quick_select.svg"); +static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg"); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum BlockObjectMode { + Retain, + Convert, + Delete, +} + +impl Default for BlockObjectMode { + fn default() -> Self { + BlockObjectMode::Convert + } +} + +/// Dropdown entry for the Block Unit picker. +#[derive(Clone, PartialEq, Eq)] +pub struct UnitChoice { + pub code: i16, + pub label: &'static str, +} + +impl fmt::Display for UnitChoice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = crate::i18n::translate(self.label); + f.write_str(name.as_ref()) + } +} + +/// The Block Definition dialog's working copy. +pub struct BlockDefinitionState { + pub name: String, + pub existing_names: Vec, + pub name_combo: combo_box::State, + pub base_point_specify_onscreen: bool, + pub base_point_x: String, + pub base_point_y: String, + pub base_point_z: String, + pub objects_specify_onscreen: bool, + pub selected_handles: Vec, + pub object_mode: BlockObjectMode, + pub annotative: bool, + pub match_orientation: bool, + pub scale_uniformly: bool, + pub allow_exploding: bool, + pub unit: i16, + pub description: String, + pub description_content: text_editor::Content, + pub hyperlink_url: String, + pub hyperlink_desc: String, + pub error_message: Option, + pub confirm_redefine: Option, +} + +impl Clone for BlockDefinitionState { + fn clone(&self) -> Self { + Self { + name: self.name.clone(), + existing_names: self.existing_names.clone(), + name_combo: self.name_combo.clone(), + base_point_specify_onscreen: self.base_point_specify_onscreen, + base_point_x: self.base_point_x.clone(), + base_point_y: self.base_point_y.clone(), + base_point_z: self.base_point_z.clone(), + objects_specify_onscreen: self.objects_specify_onscreen, + selected_handles: self.selected_handles.clone(), + object_mode: self.object_mode, + annotative: self.annotative, + match_orientation: self.match_orientation, + scale_uniformly: self.scale_uniformly, + allow_exploding: self.allow_exploding, + unit: self.unit, + description: self.description.clone(), + description_content: text_editor::Content::with_text(&self.description), + hyperlink_url: self.hyperlink_url.clone(), + hyperlink_desc: self.hyperlink_desc.clone(), + error_message: self.error_message.clone(), + confirm_redefine: self.confirm_redefine.clone(), + } + } +} + +impl BlockDefinitionState { + pub fn new(existing_names: Vec, pre_selected: Vec, default_unit: i16) -> Self { + 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, + selected_handles: pre_selected, + object_mode: BlockObjectMode::Convert, + annotative: false, + match_orientation: false, + scale_uniformly: false, + allow_exploding: true, + unit: default_unit, + description: String::new(), + description_content: text_editor::Content::new(), + hyperlink_url: String::new(), + hyperlink_desc: String::new(), + error_message: None, + confirm_redefine: None, + } + } + + pub fn parse_base_point(&self) -> glam::DVec3 { + let x = self.base_point_x.trim().parse::().unwrap_or(0.0); + let y = self.base_point_y.trim().parse::().unwrap_or(0.0); + let z = self.base_point_z.trim().parse::().unwrap_or(0.0); + glam::DVec3::new(x, y, z) + } + + pub fn is_existing_name(&self) -> bool { + let trimmed = self.name.trim(); + !trimmed.is_empty() + && self + .existing_names + .iter() + .any(|existing| existing.eq_ignore_ascii_case(trimmed)) + } + + pub fn invalid_name_char(&self) -> Option { + self.name.chars().find(|&c| { + matches!( + c, + '\\' | '/' | ':' | '*' | '?' | '"' | '<' | '>' | '|' | '=' | '`' + ) + }) + } +} + +fn group<'a>( + title: impl Into, + body: impl Into>, + height: impl Into, +) -> Element<'a, Message> { + container( + column![ + text(title.into()) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)), + }), + body.into(), + ] + .spacing(4), + ) + .padding([5, 8]) + .width(Fill) + .height(height) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }) + .into() +} + +fn labeled_checkbox<'a>( + label: impl Into, + is_checked: bool, + on_toggle: impl Fn(bool) -> Message + 'a, +) -> Element<'a, Message> { + row![ + checkbox(is_checked).on_toggle(on_toggle).size(14), + text(label.into()).size(11), + ] + .spacing(5) + .align_y(iced::Center) + .into() +} + +pub fn view_window<'a>( + state: &'a BlockDefinitionState, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + // ── Name Row ───────────────────────────────────────────────────────────── + let name_combo = combo_box( + &state.name_combo, + t!("Block name").as_ref(), + if state.name.is_empty() { + None + } else { + Some(&state.name) + }, + |chosen: String| Message::BlockDefNameSelect(chosen), + ) + .on_input(Message::BlockDefName) + .size(11) + .padding([3, 6]) + .width(Fill); + + let name_hint: Option> = if let Some(invalid_char) = + state.invalid_name_char() + { + Some( + 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), + }) + .into(), + ) + } else if state.is_existing_name() { + Some( + text(crate::tf!( + "Block \"{}\" already exists — will redefine upon OK", + state.name.trim() + )) + .size(10) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().primary.base.color), + }) + .into(), + ) + } else { + None + }; + + let mut name_column = column![text(t!("Name:")).size(11).style(muted_style), name_combo,] + .spacing(3) + .width(Fill); + + if let Some(hint) = name_hint { + name_column = name_column.push(hint); + } + + let name_section = name_column; + + // ── Group: Base Point ───────────────────────────────────────────────────── + let base_onscreen = labeled_checkbox( + t!("Specify On-screen"), + state.base_point_specify_onscreen, + Message::BlockDefBaseOnScreen, + ); + + let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 15.0); + + let mut pick_pt_btn = button( + row![pick_pt_icon, text(t!("Pick point")).size(11)] + .spacing(5) + .align_y(iced::Center), + ) + .padding([3, 8]); + + if !state.base_point_specify_onscreen { + pick_pt_btn = pick_pt_btn + .on_press(Message::BlockDefPickPoint) + .style(button_style(false)); + } + + let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| { + let is_enabled = !state.base_point_specify_onscreen; + let input: Element<'a, Message> = if is_enabled { + text_input("", val) + .on_input(on_input) + .on_submit(Message::BlockDefApply) + .style(field_style) + .size(11) + .padding([2, 5]) + .width(Fill) + .into() + } else { + crate::ui::read_only::field(val, 11.0, Fill) + }; + row![ + text(axis) + .size(11) + .style(muted_style) + .width(Length::Fixed(16.0)), + input, + ] + .spacing(4) + .align_y(iced::Center) + }; + + let base_point_group = group( + t!("Base point").into_owned(), + column![ + base_onscreen, + Space::new().height(1), + pick_pt_btn, + Space::new().height(2), + coord_row("X:", &state.base_point_x, Message::BlockDefBaseX), + coord_row("Y:", &state.base_point_y, Message::BlockDefBaseY), + coord_row("Z:", &state.base_point_z, Message::BlockDefBaseZ), + ] + .spacing(3), + Length::Fixed(152.0), + ); + + // ── Group: Objects ──────────────────────────────────────────────────────── + let objects_onscreen = labeled_checkbox( + t!("Specify On-screen"), + state.objects_specify_onscreen, + Message::BlockDefObjectsOnScreen, + ); + + let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 15.0); + + let mut sel_obj_btn = button( + row![sel_obj_icon, text(t!("Select objects")).size(11)] + .spacing(5) + .align_y(iced::Center), + ) + .padding([3, 8]); + + if !state.objects_specify_onscreen { + sel_obj_btn = sel_obj_btn + .on_press(Message::BlockDefSelectObjects) + .style(button_style(false)); + } + + let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 15.0); + let mut qsel_btn = button(qsel_icon).padding([3, 5]).style(button_style(false)); + + if !state.objects_specify_onscreen { + qsel_btn = qsel_btn.on_press(Message::BlockDefQuickSelect); + } + + let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center); + + let radios = column![ + radio( + t!("Retain"), + BlockObjectMode::Retain, + Some(state.object_mode), + Message::BlockDefObjectMode, + ) + .size(15) + .spacing(5) + .text_size(11), + radio( + t!("Convert to block"), + BlockObjectMode::Convert, + Some(state.object_mode), + Message::BlockDefObjectMode, + ) + .size(15) + .spacing(5) + .text_size(11), + radio( + t!("Delete"), + BlockObjectMode::Delete, + Some(state.object_mode), + Message::BlockDefObjectMode, + ) + .size(15) + .spacing(5) + .text_size(11), + ] + .spacing(2); + + let count = state.selected_handles.len(); + let status_row: Element<'a, Message> = if count == 0 { + row![ + crate::ui::icons::semantic(ICON_WARN, 13.0), + text(t!("No objects selected")) + .size(10) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text.scale_alpha(0.75)), + }), + ] + .spacing(5) + .align_y(iced::Center) + .into() + } else { + row![text(crate::tf!("{count} objects selected")) + .size(10) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().primary.base.color), + }),] + .spacing(5) + .align_y(iced::Center) + .into() + }; + + let objects_group = group( + t!("Objects").into_owned(), + column![ + objects_onscreen, + Space::new().height(1), + btns_row, + Space::new().height(2), + radios, + Space::new().height(2), + status_row, + ] + .spacing(2), + Length::Fixed(152.0), + ); + + // ── Group: Behavior ─────────────────────────────────────────────────────── + let annotative_cb = labeled_checkbox( + t!("Annotative"), + state.annotative, + Message::BlockDefAnnotative, + ); + + let match_orient_cb: Element<'a, Message> = if state.annotative { + row![ + Space::new().width(16), + checkbox(state.match_orientation) + .on_toggle(Message::BlockDefMatchOrientation) + .size(13), + column![ + text(t!("Match block orientation")).size(10), + text(t!("to layout")).size(10), + ] + .spacing(1), + ] + .spacing(5) + .align_y(iced::Center) + .into() + } else { + row![ + Space::new().width(16), + checkbox(false).size(13), + column![ + text(t!("Match block orientation")) + .size(10) + .style(muted_style), + text(t!("to layout")).size(10).style(muted_style), + ] + .spacing(1), + ] + .spacing(5) + .align_y(iced::Center) + .into() + }; + + let scale_uniform_cb = labeled_checkbox( + t!("Scale uniformly"), + state.scale_uniformly, + Message::BlockDefScaleUniformly, + ); + + let allow_explode_cb = labeled_checkbox( + t!("Allow exploding"), + state.allow_exploding, + Message::BlockDefAllowExploding, + ); + + let behavior_group = group( + t!("Behavior").into_owned(), + column![ + annotative_cb, + Space::new().height(2), + match_orient_cb, + Space::new().height(4), + scale_uniform_cb, + Space::new().height(4), + allow_explode_cb, + ] + .spacing(3), + Length::Fixed(152.0), + ); + + // ── Group: Settings (Column 1 Bottom) ───────────────────────────────────── + let unit_options: Vec = units::all() + .map(|(code, label)| UnitChoice { code, label }) + .collect(); + + let selected_unit = unit_options + .iter() + .find(|choice| choice.code == state.unit) + .cloned(); + + let unit_picker = pick_list(selected_unit, unit_options, |choice| choice.to_string()) + .on_select(|choice| Message::BlockDefUnit(choice.code)) + .text_size(11) + .padding([2, 5]) + .width(Fill); + + let hyperlink_btn = button( + text(if state.hyperlink_url.is_empty() { + t!("Hyperlink...") + } else { + t!("Hyperlink*") + }) + .size(11), + ) + .on_press(Message::BlockDefHyperlink) + .style(button_style(false)) + .padding([3, 14]); + + let settings_group = group( + t!("Settings").into_owned(), + column![ + text(t!("Block unit:")).size(10).style(muted_style), + unit_picker, + Space::new().height(6), + row![ + Space::new().width(Fill), + hyperlink_btn, + Space::new().width(Fill) + ], + ] + .spacing(3), + Length::Fixed(102.0), + ); + + // ── Group: Description (Column 2 Bottom) ────────────────────────────────── + let desc_editor = text_editor(&state.description_content) + .on_action(Message::BlockDefDescriptionAction) + .placeholder("Enter block description...") + .size(11) + .padding([4, 6]) + .height(Fill) + .style(|theme: &Theme, status| { + let palette = theme.palette(); + let border_color = match status { + text_editor::Status::Focused { .. } => palette.primary.base.color, + _ => palette.background.neutral.color, + }; + text_editor::Style { + background: Background::Color(palette.background.base.color), + border: Border { + color: border_color, + width: 1.0, + radius: 3.0.into(), + }, + placeholder: palette.background.base.text.scale_alpha(0.48), + value: palette.background.base.text, + selection: palette.primary.base.color.scale_alpha(0.5), + } + }); + + let description_group = group( + t!("Description").into_owned(), + desc_editor, + Length::Fixed(102.0), + ); + + // ── Layout Assembly: Left Column & Right Column ─────────────────────────── + let left_column = column![base_point_group, settings_group,] + .spacing(6) + .width(Length::FillPortion(1)); + + let top_right_row = row![objects_group, behavior_group,].spacing(6).width(Fill); + + let right_column = column![top_right_row, description_group,] + .spacing(6) + .width(Length::FillPortion(2)); + + let main_body = row![left_column, right_column].spacing(6).width(Fill); + + // ── Footer Bar ──────────────────────────────────────────────────────────── + let actions = row![ + Space::new().width(Fill), + button(text(t!("OK")).size(11)) + .on_press(Message::BlockDefApply) + .style(button_style(true)) + .padding([4, 16]), + button(text(t!("Cancel")).size(11)) + .on_press(Message::CloseModal) + .style(button_style(false)) + .padding([4, 12]), + button(text(t!("Help")).size(11)) + .on_press(Message::BlockDefHelp) + .style(button_style(false)) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + + // ── Error Banner ────────────────────────────────────────────────────────── + let mut main_column = column![name_section, main_body, actions,] + .spacing(6) + .padding([8, 10]) + .width(sizing.width); + + if let Some(err) = &state.error_message { + let err_banner = container( + row![ + crate::ui::icons::semantic(ICON_WARN, 16.0), + text(err.clone()) + .size(11) + .style(|theme: &Theme| text::Style { + color: Some(theme.palette().background.strongest.text), + }), + Space::new().width(Fill), + button(text("×").size(12)) + .on_press(Message::BlockDefDismissError) + .style(button_style(false)) + .padding([1, 6]), + ] + .spacing(8) + .align_y(iced::Center), + ) + .padding([6, 10]) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().danger.base.color.scale_alpha(0.18), + )), + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().danger.base.color, + }, + ..Default::default() + }); + + main_column = column![err_banner, main_column] + .spacing(8) + .width(sizing.width) + .padding(12); + } + + let main_content = main_column.into(); + + // ── 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 shield = mouse_area( + container(Space::new()) + .width(Length::Fill) + .height(Length::Fill) + .style(|theme: &Theme| container::Style { + background: Some(Background::Color( + theme.palette().background.strongest.color.scale_alpha(0.55), + )), + ..Default::default() + }), + ) + .on_press(Message::BlockDefConfirmRedefine(false)); + + let panel = container( + column![ + text(question) + .size(13) + .align_x(iced::alignment::Horizontal::Center), + Space::new().height(16), + row![ + button(text(t!("Yes")).size(12)) + .on_press(Message::BlockDefConfirmRedefine(true)) + .padding([6, 20]) + .style(button_style(false)), + // Default / safe focus on "No" + button(text(t!("No")).size(12)) + .on_press(Message::BlockDefConfirmRedefine(false)) + .padding([6, 22]) + .style(button_style(true)), + ] + .spacing(12) + .align_y(iced::Center), + ] + .spacing(0) + .align_x(iced::alignment::Horizontal::Center), + ) + .padding([20, 24]) + .style(container::rounded_box); + + return stack![ + main_content, + shield, + container(panel) + .width(Length::Fill) + .height(Length::Fill) + .center_x(Fill) + .center_y(Fill), + ] + .into(); + } + + main_content +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 85c39ac3c..71af8baf6 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -1,4 +1,5 @@ pub mod about; +pub mod block_definition; pub mod block_palette; pub mod browser; pub mod layout_manager;