diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index a74bbd65f..065f18555 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -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 { + fn block_edit_delta(&mut self, i: usize, for_save: bool) -> Option { 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 { + 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) diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 26bdd79a8..e3c955bb8 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -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" + ); +} diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index 43b944a28..3cc1305f7 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -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, + ) { + 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, diff --git a/src/app/mod.rs b/src/app/mod.rs index 3aeeaa516..70f792b02 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -574,6 +574,7 @@ pub(super) struct OpenCADStudio { drawing_units: Option, /// Working copy of the Block Definition dialog; `None` while it is closed. block_definition: Option, + block_edit_picker: Option, block_save_as: Option, /// PDF dialogs' working copies; `None` while closed. pdf_attach: Option, @@ -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, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 1817eb818..0c0527a50 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -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(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index b4a8d1501..f3c9505b7 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -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| { diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index 0e1720262..e8038eada 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -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, + /// 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, } // ── BEDIT pick command ───────────────────────────────────────────────────── diff --git a/src/scene/block_service.rs b/src/scene/block_service.rs index f85f71e90..5b20152d2 100644 --- a/src/scene/block_service.rs +++ b/src/scene/block_service.rs @@ -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 { + let mut names: Vec = 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"]); + } } diff --git a/src/ui/window/block_edit_picker.rs b/src/ui/window/block_edit_picker.rs new file mode 100644 index 000000000..72b7a89a1 --- /dev/null +++ b/src/ui/window/block_edit_picker.rs @@ -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, + pub preview: Vec, + pub description: String, + pub error: Option, +} + +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> = 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() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 22943e433..005c2c99f 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -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;