Files
OneCAD-Draft/src/modules/insert/create_block.rs
T
Karim Jerbi 652f4d6bbc 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.
2026-09-22 17:05:15 +01:00

652 lines
22 KiB
Rust

use crate::t;
use acadrust::Handle;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult};
use crate::modules::{IconKind, ModuleEvent, ToolDef};
use crate::scene::model::wire_model::WireModel;
pub fn tool() -> ToolDef {
ToolDef {
id: "BLOCK",
label: "Create Block",
icon: IconKind::Svg(include_bytes!("../../../assets/icons/blocks/block.svg")),
event: ModuleEvent::Command("BLOCK".to_string()),
}
}
enum Step {
Name,
Base { name: String },
}
pub struct CreateBlockCommand {
handles: Vec<Handle>,
step: Step,
}
impl CreateBlockCommand {
pub fn new(handles: Vec<Handle>) -> Self {
Self {
handles,
step: Step::Name,
}
}
}
impl CadCommand for CreateBlockCommand {
fn name(&self) -> &'static str {
"BLOCK"
}
fn prompt(&self) -> String {
match &self.step {
Step::Name => t!(
"BLOCK Enter block name [%{count} objects selected]:",
count = self.handles.len()
)
.into_owned(),
Step::Base { name } => t!(
"BLOCK Specify base point for \"%{name}\" [%{count} objects]:",
name = name,
count = self.handles.len()
)
.into_owned(),
}
}
fn on_point(&mut self, pt: DVec3) -> CmdResult {
match &self.step {
Step::Name => CmdResult::NeedPoint,
Step::Base { name } => CmdResult::CreateBlock {
handles: self.handles.clone(),
name: name.clone(),
base: pt,
},
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
fn wants_text_input(&self) -> bool {
matches!(self.step, Step::Name)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if !matches!(self.step, Step::Name) {
return None;
}
let name = text.trim();
if name.is_empty() {
return None;
}
self.step = Step::Base {
name: name.to_string(),
};
Some(CmdResult::NeedPoint)
}
fn on_preview_wires(&mut self, _pt: DVec3) -> Vec<WireModel> {
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<Handle>) -> 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"
);
}
}