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;