feat(blocks): implement Block Definition dialog (BLOCK/BMAKE) with on-screen sequencing

Implement the complete Block Definition dialog window launched via BLOCK
and BMAKE commands, featuring standard CAD layout, workflows, and entity
creation pipelines.

- Dialog UI & Layout:
  - 2-column compact CAD grid with synchronized vertical heights (580 × 390px).
  - Editable combobox with existing block names, invalid character validation,
    and inline redefinition badge.
  - Multi-line text editor widget for block descriptions (replacing single-line input).
  - Quick Select (QSELECT) button integration restoring dialog state upon completion.
  - Confirmation guard when attempting to redefine an existing block definition.
  - Centered radio buttons, clean coordinate inputs, and accessible keyboard shortcuts.

- Base Point & On-Screen Workflow:
  - BlockOnScreenCommand strictly enforces prompt sequencing: Base point is
    prompted before object selection.
  - Dynamically recalculates world_to_block and block_to_world affine transforms
    when base point is specified on-screen, preventing stale (0,0,0) normalization.
  - Supports Retain, Convert to block (INSERT placement), and Delete modes.

- Explode Enforcement:
  - Guarded EXPLODE command to prevent decomposing blocks flagged as non-explodable.

- Dynamic Themed Icons:
  - Added pick_point.svg, select_objects.svg, quick_select.svg, and warning_triangle.svg.
  - Routed all icons through the semantic theming engine so line work (#B4B6B9)
    and accents (#6DB7ED) adapt automatically to the active theme.

- Tests:
  - Added unit tests for retain/convert/delete modes, redefinition, explodable flag,
    on-screen base point transform recalculation, and command sequencing.
This commit is contained in:
Karim Jerbi committed 2026-09-22 17:05:15 +01:00
1 parent 889a19c362
commit 652f4d6bbc
19 files changed
+2316 -318

No files matched your search

+169 -42
View File
@@ -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<Handle>,
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]>>) -> 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<Handle, String> {
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<Handle, String> {
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<Handle> = 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)
}
+1
View File
@@ -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;