feat(bedit): add Edit Block Definition dialog and new block authoring

Open the Edit Block Definition dialog when BEDIT is invoked without an active selection, listing candidate block definitions alphabetically while filtering layout records, anonymous blocks, and external references. Allow typing a new valid block name to start an empty block editor session that only commits to the drawing block table upon explicit save.
This commit is contained in:
Karim Jerbi committed 2026-10-14 14:46:48 +01:00
1 parent 9632979d09
commit abad1b9045
10 files changed
+719 -86

No files matched your search

+111 -9
View File
@@ -27,6 +27,7 @@ pub(super) struct BlockEditHistory {
pub(super) enum BlockEditContinuation {
Close,
Switch(String),
Create(String),
Layout(String),
Tab(u64),
Quit,
@@ -98,7 +99,7 @@ impl OpenCADStudio {
});
}
fn block_edit_delta(&mut self, i: usize) -> Option<DeltaSnapshot> {
fn block_edit_delta(&mut self, i: usize, for_save: bool) -> Option<DeltaSnapshot> {
self.reconcile_block_edit_membership(i);
self.finish_pending_history(i);
let current_structure = self.tabs[i].scene.document.snapshot_structure();
@@ -106,12 +107,19 @@ impl OpenCADStudio {
let history = tab.block_edit_history.as_ref()?;
let checkpoint = &history.checkpoint;
let session = tab.active_block_edit_session()?;
let before: FxHashMap<_, _> = checkpoint
.document
let before_doc = if for_save {
session
.unpersisted_pre_document
.as_ref()
.unwrap_or(&checkpoint.document)
} else {
&checkpoint.document
};
let before: FxHashMap<_, _> = before_doc
.entities()
.map(|e| {
let h = e.common().handle;
(h, checkpoint.document.get_entity_arc(h).unwrap())
(h, before_doc.get_entity_arc(h).unwrap())
})
.collect();
let after: FxHashMap<_, _> = tab
@@ -134,7 +142,7 @@ impl OpenCADStudio {
})
.collect();
entities.sort_by_key(|(handle, _, _)| handle.value());
let mut before_structure = checkpoint.document.clone();
let mut before_structure = before_doc.clone();
before_structure = before_structure.snapshot_structure();
let mut after_structure = current_structure;
// The editor temporarily uses Model rendering. Drawing history must
@@ -221,7 +229,7 @@ impl OpenCADStudio {
}
pub(super) fn block_edit_dirty(&mut self, i: usize) -> bool {
self.block_edit_delta(i).is_some()
self.block_edit_delta(i, false).is_some()
}
pub(super) fn validate_block_save_as_name(
@@ -245,6 +253,88 @@ impl OpenCADStudio {
Ok(name)
}
pub(super) fn open_bedit_dialog(&mut self, i: usize) {
if self.tabs[i].refedit_session.is_some() {
self.command_line.push_error(
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
);
return;
}
let existing_names =
crate::scene::block_service::bedit_candidate_block_names(&self.tabs[i].scene.document);
let initial_name = if let Some(session) = self.tabs[i].active_block_edit_session() {
session.block_name.clone()
} else if let Some(first) = existing_names.first() {
first.clone()
} else {
String::new()
};
let preview = if !initial_name.is_empty() {
self.tabs[i].scene.block_preview_wires(&initial_name)
} else {
Vec::new()
};
let description = if !initial_name.is_empty() {
self.tabs[i]
.scene
.document
.block_records
.get(&initial_name)
.map(|b| b.description.clone())
.unwrap_or_default()
} else {
String::new()
};
let error = if initial_name.is_empty() {
None
} else {
crate::scene::block_service::validate_block_name(&initial_name)
.err()
.map(|e| e.to_string())
};
self.block_edit_picker = Some(crate::ui::window::block_edit_picker::State {
document_id: self.tabs[i].id,
name: initial_name,
existing_names,
preview,
description,
error,
});
self.active_modal = Some(ModalKind::BlockEditPicker);
self.reset_modal_geometry();
}
pub(super) fn apply_bedit_dialog(&mut self) -> Task<Message> {
let Some(state) = self.block_edit_picker.as_ref() else {
return Task::none();
};
let id = state.document_id;
let name = state.name.trim().to_string();
if name.is_empty() || state.error.is_some() {
return Task::none();
}
let Some(i) = self.tabs.iter().position(|tab| tab.id == id) else {
return Task::none();
};
self.active_tab = i;
self.block_edit_picker = None;
self.active_modal = None;
self.reset_modal_geometry();
if let Some(record) = self.tabs[i].scene.document.block_records.get(&name) {
let real_name = record.name.clone();
self.dispatch_command(&format!("BEDIT_OPEN:{real_name}"))
} else {
self.dispatch_command(&format!("BEDIT_NEW:{name}"))
}
}
pub(super) fn open_block_save_as_dialog(&mut self, i: usize) {
let Some(session) = self.tabs[i].active_block_edit_session() else {
self.command_line
@@ -385,7 +475,7 @@ impl OpenCADStudio {
self.tabs[i].scene.named_parameters = parameters;
self.tabs[i].active_layer = active_layer;
let mut delta = self
.block_edit_delta(i)
.block_edit_delta(i, true)
.expect("a new definition changes the document");
delta.label = format!("Save block as {name}");
let mut history = self.tabs[i].block_edit_history.take().unwrap();
@@ -398,6 +488,7 @@ impl OpenCADStudio {
debug_assert_eq!(session.br_handle, source_handle);
session.br_handle = copied.record;
session.block_name = name.clone();
session.unpersisted_pre_document = None;
self.tabs[i].scene.block_edit_block = Some(copied.record);
self.tabs[i].scene.deselect_all();
self.tabs[i].active_grip = None;
@@ -460,7 +551,7 @@ impl OpenCADStudio {
definition,
);
self.tabs[i].scene.visibility_states.remove(&definition);
if let Some(delta) = self.block_edit_delta(i) {
if let Some(delta) = self.block_edit_delta(i, true) {
let mut history = self.tabs[i].block_edit_history.take().unwrap();
std::mem::swap(&mut self.tabs[i].history, &mut history.main);
self.push_undo_entry(i, HistorySnapshot::Delta(Box::new(delta)));
@@ -470,6 +561,9 @@ impl OpenCADStudio {
self.tabs[i].block_edit_history = Some(history);
self.tabs[i].scene.bump_geometry();
}
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.unpersisted_pre_document = None;
}
self.command_line
.push_output(crate::tf!("Block {} saved.", name).as_ref());
true
@@ -482,7 +576,11 @@ impl OpenCADStudio {
};
if let Some(history) = self.tabs[i].block_edit_history.take() {
if discard {
self.tabs[i].scene.document = history.checkpoint.document;
if let Some(pre_doc) = session.unpersisted_pre_document {
self.tabs[i].scene.document = pre_doc;
} else {
self.tabs[i].scene.document = history.checkpoint.document;
}
self.tabs[i].scene.parametric_constraints = history.checkpoint.constraints;
self.tabs[i].scene.named_parameters = history.checkpoint.parameters;
self.tabs[i].active_layer = history.checkpoint.active_layer;
@@ -532,6 +630,10 @@ impl OpenCADStudio {
self.active_tab = i;
self.dispatch_command(&format!("BEDIT_OPEN:{name}"))
}
BlockEditContinuation::Create(name) => {
self.active_tab = i;
self.dispatch_command(&format!("BEDIT_NEW:{name}"))
}
BlockEditContinuation::Layout(name) => {
self.active_tab = i;
self.on_layout_switch(name)
+234 -1
View File
@@ -56,7 +56,9 @@ fn main_depth(app: &OpenCADStudio) -> usize {
#[test]
fn local_undo_preserves_imported_native_networks_when_constraints_are_unchanged() {
use codec::objects::{AssocNetwork, AssociativeData, AssociativeObject, Dictionary, ObjectType};
use codec::objects::{
AssocNetwork, AssociativeData, AssociativeObject, Dictionary, ObjectType,
};
let (mut app, reference) = fixture();
let i = app.active_tab;
let doc = &mut app.tabs[i].scene.document;
@@ -919,3 +921,234 @@ fn close_all_resumes_after_discarding_a_dirty_block() {
.any(|tab| tab.id == first || tab.id == second));
assert!(app.pending_tab_closes.is_empty());
}
#[test]
fn locked_bedit_no_selection_opens_edit_block_definition_dialog() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.define_block_raw(
"DOOR",
Vector3::ZERO,
vec![EntityType::Line(Line::from_points(
Vector3::ZERO,
Vector3::UNIT_X,
))],
);
app.tabs[tab]
.scene
.document
.block_records
.get_mut("DOOR")
.unwrap()
.description = "Wooden door".to_string();
app.tabs[tab]
.scene
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
let picker = app.block_edit_picker.as_ref().expect("picker state set");
assert_eq!(picker.existing_names, vec!["DOOR", "WINDOW"]);
assert_eq!(picker.name, "DOOR");
assert_eq!(picker.description, "Wooden door");
assert!(picker.error.is_none());
}
#[test]
fn locked_bedit_dialog_select_existing_block() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab]
.scene
.define_block_raw("DOOR", Vector3::ZERO, vec![]);
app.tabs[tab]
.scene
.define_block_raw("WINDOW", Vector3::ZERO, vec![]);
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerSelect("WINDOW".to_string()));
let picker = app.block_edit_picker.as_ref().unwrap();
assert_eq!(picker.name, "WINDOW");
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"WINDOW"
);
}
#[test]
fn locked_bedit_dialog_name_validation() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("INVALID/NAME".to_string()));
assert!(app.block_edit_picker.as_ref().unwrap().error.is_some());
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
assert!(app.tabs[tab].active_block_edit.is_none());
let _ = app.update(Message::BlockEditPickerName("".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, Some(ModalKind::BlockEditPicker));
assert!(app.tabs[tab].active_block_edit.is_none());
let _ = app.update(Message::CloseModal);
assert_eq!(app.active_modal, None);
}
#[test]
fn locked_bedit_dialog_create_new_block_closes_without_saving() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW".to_string()));
assert!(app.block_edit_picker.as_ref().unwrap().error.is_none());
let _ = app.update(Message::BlockEditPickerApply);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"BRAND_NEW"
);
assert!(!app.block_edit_dirty(tab));
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("BRAND_NEW")
.is_none(),
"unpersisted new block closed without saving must not remain in drawing"
);
}
#[test]
fn locked_bedit_dialog_create_new_block_discard_dirty_leaves_no_block() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("BRAND_NEW2".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert!(app.tabs[tab].active_block_edit.is_some());
circle(&mut app, 1.0);
assert!(app.block_edit_dirty(tab));
let _ = app.dispatch_command("BCLOSE");
assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges));
let _ = app.update(Message::BlockEditChangesDiscard);
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("BRAND_NEW2")
.is_none(),
"discarded new block must not remain in drawing"
);
}
#[test]
fn locked_bedit_dialog_create_new_block_save_persists_and_undoes() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
let _ = app.dispatch_command("BEDIT");
let _ = app.update(Message::BlockEditPickerName("SAVED_BLOCK".to_string()));
let _ = app.update(Message::BlockEditPickerApply);
assert!(app.tabs[tab].active_block_edit.is_some());
circle(&mut app, 1.0);
let _ = app.dispatch_command("BSAVE");
assert!(app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some());
let _ = app.dispatch_command("BCLOSE");
assert!(app.tabs[tab].active_block_edit.is_none());
assert!(app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some());
app.undo_steps(1);
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_none(),
"undo must revert block creation"
);
app.redo_steps(1);
assert!(
app.tabs[tab]
.scene
.document
.block_records
.get("SAVED_BLOCK")
.is_some(),
"redo must restore block creation"
);
}
#[test]
fn locked_bedit_with_preselected_insert_opens_directly() {
let (mut app, reference) = fixture();
let tab = app.active_tab;
app.tabs[tab].scene.deselect_all();
app.tabs[tab].scene.select_entity(reference, true);
let _ = app.dispatch_command("BEDIT");
assert_eq!(app.active_modal, None);
assert!(app.tabs[tab].active_block_edit.is_some());
assert_eq!(
app.tabs[tab]
.active_block_edit_session()
.unwrap()
.block_name,
"EDIT_ME"
);
}
+119 -75
View File
@@ -458,7 +458,6 @@ impl OpenCADStudio {
}
"BEDIT" => {
use crate::modules::draw::modify::block_edit::BlockEditPickCommand;
if self.tabs[i].refedit_session.is_some() {
self.command_line.push_error(
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
@@ -476,12 +475,46 @@ impl OpenCADStudio {
return Some(Task::none());
}
}
let cmd_obj = BlockEditPickCommand::new();
self.command_line.push_info(&cmd_obj.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd_obj));
self.open_bedit_dialog(i);
}
}
cmd if cmd.starts_with("BEDIT_NEW:") => {
if self.tabs[i].refedit_session.is_some() {
self.command_line.push_error(
crate::t!("BEDIT: finish the active REFEDIT (REFCLOSE) first.").as_ref(),
);
return Some(Task::none());
}
let raw_name = &cmd["BEDIT_NEW:".len()..];
let block_name = match crate::scene::block_service::validate_block_name(raw_name) {
Ok(n) => n,
Err(e) => {
self.command_line.push_error(&e.to_string());
return Some(Task::none());
}
};
if let Some(br) = self.tabs[i].scene.document.block_records.get(&block_name) {
let real_name = br.name.clone();
return Some(self.dispatch_command(&format!("BEDIT_OPEN:{real_name}")));
}
if self.tabs[i].active_block_edit_session().is_some() {
return Some(self.request_block_edit_close(
i,
crate::app::block_editor::BlockEditContinuation::Create(block_name),
));
}
let pre_doc = self.tabs[i].scene.document.clone();
self.tabs[i]
.scene
.define_block_raw(&block_name, codec::types::Vector3::ZERO, vec![]);
let Some(record) = self.tabs[i].scene.document.block_records.get(&block_name) else {
return Some(Task::none());
};
let br_handle = record.handle;
self.start_block_edit_session(i, block_name, br_handle, Some(pre_doc));
}
cmd if cmd.starts_with("BEDIT_BEGIN:") || cmd.starts_with("BEDIT_OPEN:") => {
if self.tabs[i].refedit_session.is_some() {
self.command_line.push_error(
@@ -489,7 +522,6 @@ impl OpenCADStudio {
);
return Some(Task::none());
}
use crate::modules::draw::modify::block_edit::BlockEditSession;
use codec::Handle;
let block_name = if let Some(name) = cmd.strip_prefix("BEDIT_OPEN:") {
@@ -539,76 +571,7 @@ impl OpenCADStudio {
));
}
// Capture the camera before the editor reframes it, so leaving
// the block editor returns the view exactly where it was (#425).
let current_camera = self.tabs[i].scene.camera.borrow().clone();
let current_ucs = self.tabs[i].active_ucs.clone();
let (return_layout, return_block, return_camera) =
if let Some(parent_index) = self.tabs[i].active_block_edit {
let parent = &mut self.tabs[i].block_edits[parent_index];
parent.editor_camera = current_camera.clone();
parent.editor_ucs = current_ucs;
(
parent.return_layout.clone(),
Some(parent.block_name.clone()),
parent.return_camera.clone(),
)
} else {
self.tabs[i].scene.sync_camera_to_document();
(
self.tabs[i].scene.current_layout.clone(),
None,
current_camera.clone(),
)
};
// A block editor renders in model style; switch to Model first so
// the paper-space code paths stay off, then scope to the block.
if self.tabs[i].scene.current_layout != "Model" {
self.tabs[i].scene.set_current_layout("Model".to_string());
}
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].block_edits.push(BlockEditSession {
block_name: block_name.clone(),
br_handle,
return_layout,
return_block,
return_camera,
editor_camera: current_camera,
editor_ucs: None,
});
self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1);
// Every BEDIT tab owns an isolated local UCS. Never inherit or
// overwrite the drawing's model-space UCS.
self.tabs[i].refresh_active_ucs();
self.tabs[i].scene.deselect_all();
// The first click of a double-click selects the model-space
// INSERT and populates the cached grip overlay. Clearing only
// Scene::selected leaves that INSERT's grips active inside the
// block editor, where dragging one moves the outer reference
// instead of block-local geometry (#517).
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
// Entering BEDIT changes which block is assembled, not the
// cached geometry of that block's entities.
self.tabs[i].scene.bump_geometry_no_blocks();
// Frame the camera on the block's own geometry (block-local, near
// origin) — fit_all() goes through current_layout_block_handle so
// it already scopes to the edited block. Without this the view
// stays wherever model/paper space was. (#261)
self.tabs[i].scene.fit_all();
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.editor_camera = editor_camera;
}
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.refresh_properties();
self.tabs[i].active_cmd = None;
self.begin_block_edit_history(i);
self.command_line
.push_info(crate::tf!("Block Editor: editing {}", block_name).as_ref());
self.start_block_edit_session(i, block_name, br_handle, None);
}
"BSAVE" => {
@@ -1027,6 +990,87 @@ impl OpenCADStudio {
Some(self.finish_dispatch(cmd))
}
pub(in crate::app) fn start_block_edit_session(
&mut self,
i: usize,
block_name: String,
br_handle: codec::Handle,
unpersisted_pre_document: Option<codec::CadDocument>,
) {
use crate::modules::draw::modify::block_edit::BlockEditSession;
// Capture the camera before the editor reframes it, so leaving
// the block editor returns the view exactly where it was (#425).
let current_camera = self.tabs[i].scene.camera.borrow().clone();
let current_ucs = self.tabs[i].active_ucs.clone();
let (return_layout, return_block, return_camera) =
if let Some(parent_index) = self.tabs[i].active_block_edit {
let parent = &mut self.tabs[i].block_edits[parent_index];
parent.editor_camera = current_camera.clone();
parent.editor_ucs = current_ucs;
(
parent.return_layout.clone(),
Some(parent.block_name.clone()),
parent.return_camera.clone(),
)
} else {
self.tabs[i].scene.sync_camera_to_document();
(
self.tabs[i].scene.current_layout.clone(),
None,
current_camera.clone(),
)
};
// A block editor renders in model style; switch to Model first so
// the paper-space code paths stay off, then scope to the block.
if self.tabs[i].scene.current_layout != "Model" {
self.tabs[i].scene.set_current_layout("Model".to_string());
}
self.tabs[i].scene.block_edit_block = Some(br_handle);
self.tabs[i].block_edits.push(BlockEditSession {
block_name: block_name.clone(),
br_handle,
return_layout,
return_block,
return_camera,
editor_camera: current_camera,
editor_ucs: None,
unpersisted_pre_document,
});
self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1);
// Every BEDIT tab owns an isolated local UCS. Never inherit or
// overwrite the drawing's model-space UCS.
self.tabs[i].refresh_active_ucs();
self.tabs[i].scene.deselect_all();
// The first click of a double-click selects the model-space
// INSERT and populates the cached grip overlay. Clearing only
// Scene::selected leaves that INSERT's grips active inside the
// block editor, where dragging one moves the outer reference
// instead of block-local geometry (#517).
self.tabs[i].active_grip = None;
self.grip_hover = None;
self.grip_popup = None;
self.visibility_popup = None;
// Entering BEDIT changes which block is assembled, not the
// cached geometry of that block's entities.
self.tabs[i].scene.bump_geometry_no_blocks();
// Frame the camera on the block's own geometry (block-local, near
// origin) — fit_all() goes through current_layout_block_handle so
// it already scopes to the edited block. Without this the view
// stays wherever model/paper space was. (#261)
self.tabs[i].scene.fit_all();
let editor_camera = self.tabs[i].scene.camera.borrow().clone();
if let Some(session) = self.tabs[i].active_block_edit_session_mut() {
session.editor_camera = editor_camera;
}
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
self.refresh_properties();
self.tabs[i].active_cmd = None;
self.begin_block_edit_history(i);
self.command_line
.push_info(crate::tf!("Block Editor: editing {}", block_name).as_ref());
}
pub(in crate::app) fn restore_after_block_edit_close(
&mut self,
i: usize,
+6
View File
@@ -574,6 +574,7 @@ pub(super) struct OpenCADStudio {
drawing_units: Option<crate::ui::window::drawing_units::State>,
/// Working copy of the Block Definition dialog; `None` while it is closed.
block_definition: Option<crate::ui::window::block_definition::BlockDefinitionState>,
block_edit_picker: Option<crate::ui::window::block_edit_picker::State>,
block_save_as: Option<crate::ui::window::block_save_as::State>,
/// PDF dialogs' working copies; `None` while closed.
pdf_attach: Option<crate::ui::window::pdf_dialogs::PdfAttachState>,
@@ -2190,6 +2191,7 @@ pub enum ModalKind {
DimStyle,
Unsaved,
BlockEditChanges,
BlockEditPicker,
BlockSaveAs,
SaveDialog,
Recovery,
@@ -2827,6 +2829,9 @@ pub enum Message {
BlockEditChangesCancel,
BlockSaveAsName(String),
BlockSaveAsApply,
BlockEditPickerName(String),
BlockEditPickerSelect(String),
BlockEditPickerApply,
// ── AEC / unsupported-object drop warning (lossy Save-As) ──────────────
/// Save as the source DWG version so the unsupported objects survive.
AecDropSameVersion,
@@ -4577,6 +4582,7 @@ impl OpenCADStudio {
layer_translator: None,
drawing_units: None,
block_definition: None,
block_edit_picker: None,
block_save_as: None,
pdf_attach: None,
point_cloud_attach: None,
+63
View File
@@ -230,6 +230,7 @@ impl OpenCADStudio {
}
Some(GeometricTolerance) => self.geometric_tolerance = None,
Some(BlockDefinition) => self.block_definition = None,
Some(BlockEditPicker) => self.block_edit_picker = None,
Some(BlockSaveAs) => self.block_save_as = None,
Some(PdfAttach) => self.pdf_attach = None,
Some(UnderlayLayers) => self.underlay_layers = None,
@@ -455,6 +456,12 @@ impl OpenCADStudio {
}
}
}
if self.active_modal == Some(super::ModalKind::BlockEditPicker)
&& (matches!(msg, Message::CommandFinalize)
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")))
{
return self.apply_bedit_dialog();
}
if self.active_modal == Some(super::ModalKind::BlockSaveAs)
&& (matches!(msg, Message::CommandFinalize)
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN")))
@@ -10522,6 +10529,62 @@ impl OpenCADStudio {
Task::none()
}
Message::BlockSaveAsApply => self.apply_block_save_as_dialog(),
Message::BlockEditPickerName(name) => {
if let Some(state) = self.block_edit_picker.as_mut() {
state.name = name.clone();
let trimmed = name.trim();
state.error = if trimmed.is_empty() {
None
} else {
crate::scene::block_service::validate_block_name(trimmed)
.err()
.map(|e| e.to_string())
};
if let Some(tab_idx) =
self.tabs.iter().position(|t| t.id == state.document_id)
{
if let Some(matched) = state
.existing_names
.iter()
.find(|n| n.eq_ignore_ascii_case(trimmed))
{
state.preview =
self.tabs[tab_idx].scene.block_preview_wires(matched);
state.description = self.tabs[tab_idx]
.scene
.document
.block_records
.get(matched)
.map(|b| b.description.clone())
.unwrap_or_default();
} else {
state.preview.clear();
state.description.clear();
}
}
}
Task::none()
}
Message::BlockEditPickerSelect(name) => {
if let Some(state) = self.block_edit_picker.as_mut() {
state.name = name.clone();
state.error = None;
if let Some(tab_idx) =
self.tabs.iter().position(|t| t.id == state.document_id)
{
state.preview = self.tabs[tab_idx].scene.block_preview_wires(&name);
state.description = self.tabs[tab_idx]
.scene
.document
.block_records
.get(&name)
.map(|b| b.description.clone())
.unwrap_or_default();
}
}
Task::none()
}
Message::BlockEditPickerApply => self.apply_bedit_dialog(),
Message::BlockEditChangesDiscard => self.resolve_block_edit_close(false),
Message::BlockEditChangesCancel => {
self.cancel_block_edit_close();
+7
View File
@@ -79,6 +79,7 @@ impl OpenCADStudio {
Some(K::LayerDeleteWarning) => crate::tr!("modal", "delete-layer"),
Some(K::Unsaved) => crate::tr!("modal", "unsaved-changes"),
Some(K::BlockEditChanges) => crate::t!("Block Editor").into_owned(),
Some(K::BlockEditPicker) => crate::t!("Edit Block Definition").into_owned(),
Some(K::BlockSaveAs) => crate::t!("Save Block As").into_owned(),
Some(K::PointStyle) => crate::tr!("modal", "point-style"),
Some(K::AttributeEditor) => crate::tr!("modal", "attribute-editor"),
@@ -501,6 +502,12 @@ impl OpenCADStudio {
crate::ui::window::block_definition::view_window(state, flow)
})
}
super::super::ModalKind::BlockEditPicker => {
let state = self.block_edit_picker.as_ref()?;
sized_flow(ex, 540, 380, |flow| {
crate::ui::window::block_edit_picker::view(state, flow)
})
}
super::super::ModalKind::BlockSaveAs => {
let state = self.block_save_as.as_ref()?;
sized_flow(ex, 430, 370, |flow| {
+4
View File
@@ -70,6 +70,10 @@ pub struct BlockEditSession {
/// Transient UCS being manipulated in this block tab. On commit its full
/// frame is baked into the definition and this returns to `None`.
pub editor_ucs: Option<codec::tables::Ucs>,
/// Snapshot of the document prior to creating an empty new block for editing.
/// If the session is closed without saving, this snapshot is restored so
/// no block record remains in the block table.
pub unpersisted_pre_document: Option<codec::CadDocument>,
}
// ── BEDIT pick command ─────────────────────────────────────────────────────
+52 -1
View File
@@ -16,7 +16,7 @@
use codec::objects::ObjectType;
use codec::tables::BlockRecord;
use codec::types::Handle;
use codec::{CadDocument, EntityType};
use codec::{CadDocument, EntityType, TableEntry};
use std::collections::HashSet;
use std::fmt;
@@ -285,6 +285,29 @@ fn is_layout_record(document: &CadDocument, record: &BlockRecord) -> bool {
)
}
/// Candidate block names for the Edit Block Definition (BEDIT) dialog:
/// excludes standard layouts (*MODEL_SPACE, *PAPER_SPACE), layout records,
/// anonymous blocks (*U, *D, *T, etc.), and externally referenced blocks.
/// Sorted alphabetically, case-insensitive.
pub fn bedit_candidate_block_names(document: &CadDocument) -> Vec<String> {
let mut names: Vec<String> = document
.block_records
.iter()
.filter(|record| {
!record.is_standard()
&& !is_layout_record(document, record)
&& !record.is_anonymous()
&& !record.name.starts_with('*')
&& !record.flags.is_xref
&& !record.flags.is_xref_overlay
&& !record.name.contains('|')
})
.map(|record| record.name.clone())
.collect();
names.sort_by_key(|n| n.to_ascii_lowercase());
names
}
/// The extension-dictionary key on a dynamic block's record.
const ENHANCED_BLOCK_KEY: &str = "ACAD_ENHANCEDBLOCK";
@@ -1238,4 +1261,32 @@ mod tests {
assert!(would_create_cycle(&scene.document, "blockb", "BLOCKA"));
assert!(!would_create_cycle(&scene.document, "blocka", "BLOCKB"));
}
#[test]
fn locked_bedit_candidate_block_names_filters_and_sorts() {
let mut scene = scene();
define(&mut scene, "zebra");
define(&mut scene, "Alpha");
define(&mut scene, "Beta");
// Anonymous block
let mut anon = BlockRecord::new("*U1");
anon.handle = Handle::new(9901);
anon.flags.anonymous = true;
scene.document.block_records.add(anon).unwrap();
// Xref block
let mut xref = BlockRecord::new("XrefBlock");
xref.handle = Handle::new(9902);
xref.flags.is_xref = true;
scene.document.block_records.add(xref).unwrap();
// Xref dependent symbol
let mut xref_dep = BlockRecord::new("HOST|CHILD");
xref_dep.handle = Handle::new(9903);
scene.document.block_records.add(xref_dep).unwrap();
let candidates = bedit_candidate_block_names(&scene.document);
assert_eq!(candidates, vec!["Alpha", "Beta", "zebra"]);
}
}
+122
View File
@@ -0,0 +1,122 @@
use crate::app::Message;
use crate::scene::WireModel;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{hdivider, vsep};
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Element, Fill, Length, Theme};
pub(crate) struct State {
pub document_id: u64,
pub name: String,
pub existing_names: Vec<String>,
pub preview: Vec<WireModel>,
pub description: String,
pub error: Option<String>,
}
fn list_item_style(selected: bool) -> impl Fn(&Theme, button::Status) -> button::Style {
move |theme: &Theme, status| {
if selected {
button::primary(theme, status)
} else {
button::subtle(theme, status)
}
}
}
pub(crate) fn view(state: &State, sizing: crate::ui::modal::ModalSizing) -> Element<'_, Message> {
let can_apply = state.error.is_none() && !state.name.trim().is_empty();
// ── Left: Block name field and list of existing blocks ─────────────────────
let name_input = text_input(crate::t!("Block name").as_ref(), &state.name)
.on_input(Message::BlockEditPickerName)
.on_submit_maybe(can_apply.then_some(Message::BlockEditPickerApply))
.style(crate::ui::style::form::field_style);
let list_items: Vec<Element<'_, Message>> = state
.existing_names
.iter()
.map(|name| {
let is_sel = name.eq_ignore_ascii_case(state.name.trim());
button(text(name.clone()).size(12))
.on_press(Message::BlockEditPickerSelect(name.clone()))
.style(list_item_style(is_sel))
.padding([4, 8])
.width(Fill)
.into()
})
.collect();
let list_scrollable = container(scrollable(column(list_items).spacing(2)).height(Fill))
.style(container::bordered_box)
.padding(2)
.height(Fill);
let left_col = column![
text(crate::t!("Block to create or edit").into_owned()).size(12),
name_input,
list_scrollable,
]
.spacing(8)
.width(Length::Fixed(240.0))
.height(Fill);
// ── Right: Preview and Description ─────────────────────────────────────────
let preview_box = column![
text(crate::t!("Preview").into_owned()).size(12),
container(super::block_palette::preview_canvas(&state.preview, 150.0))
.style(container::bordered_box)
.width(Fill)
.height(Length::Fixed(150.0)),
]
.spacing(4);
let desc_text = if state.description.is_empty() {
crate::t!("(No description)").into_owned()
} else {
state.description.clone()
};
let desc_box = column![
text(crate::t!("Description").into_owned()).size(12),
container(scrollable(text(desc_text).size(11).style(muted_style)).height(Fill))
.style(container::bordered_box)
.padding(6)
.width(Fill)
.height(Fill),
]
.spacing(4)
.height(Fill);
let right_col = column![preview_box, desc_box]
.spacing(8)
.width(Fill)
.height(Fill);
// ── Body ───────────────────────────────────────────────────────────────────
let body = row![left_col, vsep(sizing.height), right_col]
.spacing(12)
.height(Fill);
// ── Bottom: Error message and OK / Cancel buttons ───────────────────────────
let error_text = text(state.error.clone().unwrap_or_default())
.size(11)
.style(text::danger);
let buttons = row![
error_text,
Space::new().width(Fill),
button(text(crate::t!("OK").into_owned()))
.on_press_maybe(can_apply.then_some(Message::BlockEditPickerApply))
.style(button::primary),
button(text(crate::t!("Cancel").into_owned())).on_press(Message::CloseModal),
]
.spacing(8)
.align_y(iced::Center);
container(column![body, hdivider(sizing.width), buttons].spacing(10))
.padding(16)
.width(sizing.width)
.height(sizing.height)
.into()
}
+1
View File
@@ -5,6 +5,7 @@ pub mod attdef_dialog;
pub mod attribute_editor;
pub mod auto_constrain_settings;
pub mod block_definition;
pub mod block_edit_picker;
pub mod block_save_as;
pub mod block_palette;
pub mod browser;