diff --git a/src/app/automation.rs b/src/app/automation.rs index dfd666579..a6e10ddba 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -700,6 +700,9 @@ impl OpenCADStudio { }; match req["op"].as_str().unwrap_or("") { "new" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Close Test Block before replacing the drawing."); + } if self.tabs[self.active_tab].block_edit_history.is_some() { return err("Close the block editor before replacing the drawing."); } @@ -726,6 +729,9 @@ impl OpenCADStudio { } #[cfg(not(target_arch = "wasm32"))] "open" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Close Test Block before replacing the drawing."); + } if self.tabs[self.active_tab].block_edit_history.is_some() { return err("Close the block editor before replacing the drawing."); } @@ -792,6 +798,7 @@ impl OpenCADStudio { if self.command_line.error_revision != error_revision { return err(self.command_line.last_error.clone().unwrap_or_default()); } + let i = self.active_tab; let after = self.tabs[i].scene.document.entities().count(); // A command can finish with an interactive surface still open: // the in-place text editor (the `TEXT` content step), the MTEXT @@ -996,6 +1003,9 @@ impl OpenCADStudio { json!({ "ok": true, "selected": self.tabs[i].scene.selected_entities().len() }) } "save" => { + if self.tabs[self.active_tab].block_test.is_some() { + return err("Test Block is temporary and cannot be saved."); + } let i = self.active_tab; let path = req["path"] .as_str() @@ -1533,6 +1543,11 @@ impl OpenCADStudio { #[cfg(not(target_arch = "wasm32"))] pub(super) fn save_verified_request(&mut self, req: &Value) -> Result { let i = self.active_tab; + if self.tabs[i].block_test.is_some() { + return Err( + json!({"ok":false,"code":"temporary_document","error":"Test Block is temporary and cannot be saved."}), + ); + } let Some(raw_path) = req["path"].as_str() else { return Err( json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}), diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index 957c568c3..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; @@ -491,9 +589,6 @@ impl OpenCADStudio { self.tabs[i].history = history.main; self.tabs[i].scene.object_isolation = history.return_isolation; } - if discard && session.created_for_edit { - crate::scene::block_service::erase(&mut self.tabs[i].scene, &session.block_name); - } self.tabs[i].active_block_edit = None; self.restore_after_block_edit_close( i, @@ -502,6 +597,7 @@ impl OpenCADStudio { &session.return_camera, ); self.cancel_attr_editor(); + self.discard_block_tests(self.tabs[i].id); } pub(super) fn request_block_edit_close( @@ -509,6 +605,7 @@ impl OpenCADStudio { i: usize, continuation: BlockEditContinuation, ) -> Task { + let id = self.tabs[i].id; if self.tabs[i].active_block_edit_session().is_some() { if self.block_edit_dirty(i) { self.active_tab = i; @@ -518,6 +615,7 @@ impl OpenCADStudio { } self.close_block_edit(i, true); } + let i = self.tabs.iter().position(|tab| tab.id == id).unwrap(); self.continue_after_block_edit(continuation, i) } @@ -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) @@ -567,6 +669,7 @@ impl OpenCADStudio { self.pending_block_close = None; self.active_modal = None; self.close_block_edit(i, !save); + let i = self.tabs.iter().position(|tab| tab.id == id).unwrap(); self.continue_after_block_edit(continuation, i) } @@ -586,7 +689,11 @@ impl OpenCADStudio { { return self.request_block_edit_close(i, BlockEditContinuation::Quit); } - if self.tabs.iter().any(|tab| tab.dirty) { + if self + .tabs + .iter() + .any(|tab| tab.dirty && tab.block_test.is_none()) + { self.pending_close = Some(super::PendingClose::Quit); self.open_unsaved_dialog_window() } else { 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/block_test.rs b/src/app/block_test.rs new file mode 100644 index 000000000..729c4d829 --- /dev/null +++ b/src/app/block_test.rs @@ -0,0 +1,163 @@ +use super::{document::DocumentTab, Message, OpenCADStudio}; +use codec::entities::{AttributeEntity, Entity, Insert}; +use codec::EntityType; +use iced::Task; + +pub(super) struct BlockTestSession { + pub source_id: u64, +} + +impl OpenCADStudio { + pub(super) fn refuse_block_test_save(&mut self, i: usize) -> bool { + if self.tabs[i].block_test.is_none() { + return false; + } + self.command_line.push_error( + crate::t!("Test Block is temporary. Close it and save from the Block Editor.").as_ref(), + ); + true + } + + pub(super) fn open_block_test(&mut self, i: usize) -> Task { + let source_id = self.tabs[i] + .block_test + .as_ref() + .map_or(self.tabs[i].id, |test| test.source_id); + let Some(i) = self.tabs.iter().position(|tab| tab.id == source_id) else { + return Task::none(); + }; + let Some(session) = self.tabs[i].active_block_edit_session() else { + self.command_line + .push_error(crate::t!("No block editor is open.").as_ref()); + return Task::none(); + }; + let name = session.block_name.clone(); + let definition = session.br_handle; + self.finish_pending_history(i); + let mut document = self.tabs[i].scene.document.clone(); + // Keep definitions and their native relationships in the clone. Only + // drawing-space contents are replaced by the temporary reference. + let spaces: Vec<_> = document + .block_records + .iter() + .filter(|record| record.is_model_space() || record.is_paper_space()) + .map(|record| record.handle) + .collect(); + let removed: Vec<_> = document + .entities() + .filter(|entity| { + spaces.contains(&entity.common().owner_handle) + && !matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .map(|entity| entity.common().handle) + .collect(); + for handle in removed { + document.remove_entity(handle); + } + for record in document + .block_records + .iter_mut() + .filter(|record| spaces.contains(&record.handle)) + { + record.entity_handles.retain(|handle| { + *handle == record.block_entity_handle || *handle == record.block_end_handle + }); + record.insert_handles.clear(); + record.insert_count_bytes.clear(); + } + document.header.show_model_space = true; + crate::scene::block_visibility::finish_editing(&mut document, definition); + let record = document.block_records.get(&name).unwrap(); + let base = record.base_point; + let mut insert = Insert::new(&name, codec::types::Vector3::ZERO); + insert.common.layer = self.tabs[i].active_layer.clone(); + insert.common.handle = document.allocate_handle(); + insert.common.owner_handle = document.header.model_space_block_handle; + let transform = crate::scene::render_graph::insert_transform_with_base(&insert, base); + for entity in document.entities_in_block(&name) { + if let EntityType::AttributeDefinition(definition) = entity { + if !definition.flags.constant { + let mut attribute = AttributeEntity::from_definition(definition, None); + attribute.attdef_handle = definition.common.handle; + attribute.apply_transform(&transform); + insert.attributes.push(attribute); + } + } + } + for attribute in &mut insert.attributes { + attribute.common.handle = document.allocate_handle(); + attribute.common.owner_handle = insert.common.handle; + if let Some(text) = &mut attribute.embedded_mtext { + text.common.handle = document.allocate_handle(); + text.common.owner_handle = attribute.common.handle; + text.common.raw_record = None; + text.common.xdictionary_handle = None; + text.common.reactors.clear(); + } + } + if !insert.attributes.is_empty() { + insert.seqend_handle = Some(document.allocate_handle()); + } + if let Err(error) = document.add_entity(EntityType::Insert(insert)) { + self.command_line.push_error(&error.to_string()); + return Task::none(); + } + let mut tab = DocumentTab::new_drawing(0); + tab.tab_title = crate::tf!("Test Block Window - {}", name).into_owned(); + tab.block_test = Some(BlockTestSession { source_id }); + tab.active_layer = self.tabs[i].active_layer.clone(); + tab.scene.document = document; + tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone(); + tab.scene.parametric_constraints = self.tabs[i] + .scene + .parametric_constraints + .iter() + .filter(|set| { + matches!( + set.scope, + crate::scene::parametric_constraints::ParametricScope::Block(_) + ) + }) + .cloned() + .collect(); + tab.scene.material_base_dir = self.tabs[i].scene.material_base_dir.clone(); + tab.scene.sync_annotation_scale_from_header(); + tab.scene.rebuild_derived_caches(); + tab.scene.set_current_layout("Model".into()); + self.discard_block_tests(source_id); + self.visibility_popup = None; + self.grip_popup = None; + self.grip_hover = None; + self.tabs.push(tab); + self.active_tab = self.tabs.len() - 1; + self.sync_ribbon_layers(); + self.sync_ribbon_styles(); + self.sync_ribbon_from_selection(); + self.refresh_block_palette(); + self.refresh_properties(); + self.ribbon.set_block_editing(false); + self.ribbon.set_block_testing(true); + self.dispatch_command("ZOOM EXTENTS") + } + + pub(super) fn close_block_test(&mut self, i: usize) -> Task { + if self.tabs[i].block_test.is_none() { + self.command_line + .push_error(crate::t!("No Test Block is open.").as_ref()); + return Task::none(); + } + self.on_tab_close(i) + } + + pub(super) fn discard_block_tests(&mut self, source_id: u64) { + for index in (0..self.tabs.len()).rev() { + if self.tabs[index] + .block_test + .as_ref() + .is_some_and(|test| test.source_id == source_id) + { + let _ = self.on_tab_close(index); + } + } + } +} diff --git a/src/app/block_test_tests.rs b/src/app/block_test_tests.rs new file mode 100644 index 000000000..8fa9a2647 --- /dev/null +++ b/src/app/block_test_tests.rs @@ -0,0 +1,346 @@ +use super::{Message, ModalKind, OpenCADStudio}; +use codec::entities::{Insert, Line}; +use codec::types::Vector3; +use codec::{EntityType, Handle}; + +fn fixture() -> (OpenCADStudio, usize, Handle) { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + app.tabs[i].scene.define_block_raw( + "SAMPLE", + Vector3::new(10.0, 20.0, 0.0), + vec![EntityType::Line(Line::from_points( + Vector3::new(10.0, 20.0, 0.0), + Vector3::new(15.0, 20.0, 0.0), + ))], + ); + app.tabs[i] + .scene + .document + .block_records + .get_mut("SAMPLE") + .unwrap() + .base_point = Vector3::new(10.0, 20.0, 0.0); + let reference = app.tabs[i] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "SAMPLE", + Vector3::new(100.0, 200.0, 0.0), + ))) + .unwrap(); + let _ = app.dispatch_command(&format!("BEDIT_BEGIN:{}", reference.value())); + (app, i, reference) +} + +#[test] +fn block_test_uses_unsaved_definition_and_preserves_editor_history_and_view() { + let (mut app, source, _) = fixture(); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#)["ok"], + true + ); + app.finish_pending_history(source); + let document = app.tabs[source].scene.document.clone(); + let depth = app.tabs[source].history.undo_stack.len(); + let camera = app.tabs[source].scene.camera.borrow().clone(); + let source_id = app.tabs[source].id; + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"BTESTBLOCK"}"#)["ok"], + true + ); + let test = app.active_tab; + assert_ne!(app.tabs[test].id, source_id); + assert!(app.tabs[test].active_block_edit.is_none()); + assert_eq!( + app.tabs[test].scene.document.block_records.get("SAMPLE"), + document.block_records.get("SAMPLE") + ); + let model = app.tabs[test] + .scene + .document + .header + .model_space_block_handle; + let top: Vec<_> = app.tabs[test] + .scene + .document + .entities() + .filter(|e| { + e.common().owner_handle == model + && !matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) + }) + .collect(); + assert_eq!(top.len(), 1); + let EntityType::Insert(reference) = top[0] else { + panic!("missing test reference") + }; + assert_eq!(reference.insert_point, Vector3::ZERO); + assert_eq!(reference.block_name, "SAMPLE"); + assert!(app.tabs[test].history.undo_stack.is_empty()); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 1000,1000 8"}"#)["ok"], + true + ); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"BCLOSETESTBLOCK"}"#)["ok"], + true + ); + assert_eq!(app.active_tab, source); + assert_eq!(app.tabs[source].scene.document, document); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth); + assert_eq!(app.tabs[source].scene.camera.borrow().target, camera.target); + assert!(app.active_modal.is_none()); + app.undo_steps(1); + assert_eq!( + app.tabs[source] + .scene + .document + .entities_in_block("SAMPLE") + .count(), + 1 + ); +} + +#[test] +fn block_test_keytips_follow_context_and_offer_close() { + let (mut app, source, _) = fixture(); + let tab_tip = |app: &OpenCADStudio, target: &str| { + app.ribbon.keytips_json(false)["tips"] + .as_array() + .unwrap() + .iter() + .any(|tip| tip["target"] == target) + }; + app.ribbon.show_keytips(false); + assert!(!tab_tip(&app, "block_test")); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(tab_tip(&app, "LINE")); + assert!(!tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.hide_keytips(); + let _ = app.open_block_test(source); + app.ribbon.show_keytips(false); + assert_eq!(app.ribbon.keytips_json(false)["tab"], "draw"); + assert!(!tab_tip(&app, "block_test")); + assert!(!tab_tip(&app, "block_editor")); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(tab_tip(&app, "LINE")); + assert!(tab_tip(&app, "Close")); + assert!(tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.keytip_type(Some("C")); + let crate::ui::ribbon::KeyTipStep::Run(close) = app.ribbon.keytip_type(Some("T")) else { + panic!("close key tip did not dispatch a ribbon action"); + }; + assert!(matches!( + &close, + Message::RibbonToolClick { event: crate::modules::ModuleEvent::Command(command), .. } + if command == "BCLOSETESTBLOCK" + )); + let _ = app.ribbon.hide_keytips(); + let _ = app.update(close); + app.ribbon.show_keytips(false); + assert!(!tab_tip(&app, "block_test")); + assert!(tab_tip(&app, "block_editor")); + assert_eq!(app.ribbon.keytips_json(false)["tab"], "block_editor"); + let _ = app.ribbon.keytip_type(Some("H")); + assert!(!tab_tip(&app, "Close")); + assert!(!tab_tip(&app, "BCLOSETESTBLOCK")); + let _ = app.ribbon.hide_keytips(); +} + +#[test] +fn block_test_close_x_discards_dirty_edits_and_repeated_test_refreshes() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + let first = app.tabs[app.active_tab].id; + let _ = app.open_block_test(app.active_tab); + assert!(!app.tabs.iter().any(|tab| tab.id == first)); + assert_eq!( + app.tabs + .iter() + .filter(|tab| tab.block_test.is_some()) + .count(), + 1 + ); + let i = app.active_tab; + app.tabs[i].dirty = true; + let _ = app.on_tab_close(i); + assert_eq!(app.active_tab, source); + assert!(app.pending_close.is_none()); + assert!(app.active_modal.is_none()); +} + +#[test] +fn block_test_refuses_persistence_and_replacing_the_scratch_drawing() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + let test = app.active_tab; + for command in [ + "SAVE", + "QSAVE", + "SAVEAS", + "SAVEALL", + "BSAVE", + "BSAVEAS:OTHER", + "BEDIT_OPEN:SAMPLE", + "REFEDIT", + ] { + let request = serde_json::json!({"op":"run", "cmd":command}); + assert_eq!( + app.automation_op(&request.to_string())["ok"], + false, + "{command}" + ); + } + assert_eq!( + app.automation_op(r#"{"op":"save","path":"blocked-test.dwg"}"#)["ok"], + false + ); + assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], false); + let _ = app.update(Message::SaveFile); + let _ = app.update(Message::SaveAs); + assert!(app.active_modal.is_none()); + assert_eq!(app.active_tab, test); + assert!(app.tabs[test].current_path.is_none()); + assert!(app.active_save_jobs.is_empty()); + app.tabs[test].dirty = true; + let _ = app.update(Message::AutoSave); + assert!(app.active_save_jobs.is_empty()); +} + +#[test] +fn block_test_parent_close_cancel_keeps_test_and_discard_removes_it() { + let (mut app, source, _) = fixture(); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 12,20 2"}"#); + let _ = app.open_block_test(source); + let test_id = app.tabs[app.active_tab].id; + let _ = app.on_tab_close(source); + assert_eq!(app.active_modal, Some(ModalKind::BlockEditChanges)); + let _ = app.update(Message::BlockEditChangesCancel); + assert!(app.tabs.iter().any(|tab| tab.id == test_id)); + let _ = app.on_tab_close(source); + let _ = app.update(Message::BlockEditChangesDiscard); + assert!(!app.tabs.iter().any(|tab| tab.id == test_id)); + assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some())); +} + +#[test] +fn block_test_no_editor_is_refused() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let count = app.tabs.len(); + let _ = app.open_block_test(app.active_tab); + assert_eq!(app.tabs.len(), count); + assert!(app.command_line.last_error.is_some()); +} + +#[test] +fn block_test_reordering_before_its_source_keeps_close_targets_stable() { + let (mut app, source, _) = fixture(); + let source_id = app.tabs[source].id; + let _ = app.open_block_test(source); + let test = app.active_tab; + app.tabs.swap(source, test); + app.active_tab = test; + let _ = app.dispatch_command("BEDIT_DISCARD"); + assert!(!app.tabs.iter().any(|tab| tab.block_test.is_some())); + assert_eq!(app.tabs[app.active_tab].id, source_id); + assert!(app.tabs[app.active_tab].active_block_edit.is_none()); + assert_eq!(app.tabs.len(), 1); +} + +#[test] +fn block_test_unsaved_visibility_states_work_without_changing_the_editor() { + let (mut app, source, _) = fixture(); + let doc = &mut app.tabs[source].scene.document; + let owner = doc.block_records.get("SAMPLE").unwrap().handle; + let first = doc.block_records.get("SAMPLE").unwrap().entity_handles[0]; + let mut line = Line::from_points(Vector3::new(10.0, 23.0, 0.0), Vector3::new(15.0, 23.0, 0.0)); + line.common.owner_handle = owner; + let second = doc.add_entity(EntityType::Line(line)).unwrap(); + let parameter = crate::scene::block_visibility::create(doc, owner, Vector3::ZERO).unwrap(); + crate::scene::block_visibility::add_state(doc, parameter, "Alternative", 0).unwrap(); + crate::scene::block_visibility::set_visible(doc, parameter, 0, &[second], false); + crate::scene::block_visibility::set_visible(doc, parameter, 1, &[first], false); + let original = app.tabs[source].scene.document.clone(); + let _ = app.open_block_test(source); + let test = app.active_tab; + let doc = &app.tabs[test].scene.document; + let model = doc.header.model_space_block_handle; + let reference = doc + .entities() + .find(|e| e.common().owner_handle == model && matches!(e, EntityType::Insert(_))) + .unwrap() + .common() + .handle; + assert!(doc.dynamic_visibility_for_insert(reference).is_some()); + app.apply_visibility_state(reference, 1); + app.finish_pending_history(test); + let doc = &app.tabs[test].scene.document; + let EntityType::Insert(insert) = doc.get_entity(reference).unwrap() else { + unreachable!() + }; + assert_ne!(insert.block_name, "SAMPLE"); + let visible: Vec<_> = doc + .entities_in_block(&insert.block_name) + .filter(|e| !e.common().invisible) + .collect(); + assert_eq!(visible.len(), 1); + let EntityType::Line(line) = visible[0] else { + panic!("expected the alternative line") + }; + assert_eq!(line.start.y, 23.0); + assert_eq!(app.tabs[source].scene.document, original); + app.visibility_popup = Some(super::visibility::VisibilityPopup { + insert_handle: reference, + anchor: iced::Point::ORIGIN, + items: vec!["Alternative".into()], + current: Some(1), + }); + let _ = app.close_block_test(test); + assert!(app.visibility_popup.is_none()); + assert_eq!(app.tabs[source].scene.document, original); +} + +#[test] +fn block_test_attributes_use_defaults_and_the_stored_base_point() { + use codec::entities::AttributeDefinition; + let (mut app, source, _) = fixture(); + let doc = &mut app.tabs[source].scene.document; + let owner = doc.block_records.get("SAMPLE").unwrap().handle; + let mut attribute = AttributeDefinition::new("TAG".into(), "Value".into(), "default".into()); + attribute.insertion_point = Vector3::new(12.0, 23.0, 0.0); + attribute.common.owner_handle = owner; + let definition = doc + .add_entity(EntityType::AttributeDefinition(attribute)) + .unwrap(); + let _ = app.open_block_test(source); + let doc = &app.tabs[app.active_tab].scene.document; + let model = doc.header.model_space_block_handle; + let insert = doc + .entities() + .find_map(|entity| match entity { + EntityType::Insert(insert) if insert.common.owner_handle == model => Some(insert), + _ => None, + }) + .unwrap(); + assert_eq!(insert.attributes.len(), 1); + let attribute = &insert.attributes[0]; + assert_eq!(attribute.value, "default"); + assert_eq!(attribute.insertion_point, Vector3::new(2.0, 3.0, 0.0)); + assert_eq!(attribute.attdef_handle, definition); + assert!(!attribute.common.handle.is_null()); + assert_eq!(attribute.common.owner_handle, insert.common.handle); +} + +#[test] +fn block_test_close_others_discards_the_test_without_prompting() { + let (mut app, source, _) = fixture(); + let _ = app.open_block_test(source); + app.tabs[app.active_tab].dirty = true; + let test = app.tabs[app.active_tab].id; + let _ = app.update(Message::DocTabCloseOthers(source)); + assert!(!app.tabs.iter().any(|tab| tab.id == test)); + assert!(app.active_modal.is_none()); + assert!(app.tabs[source].active_block_edit.is_some()); +} diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index b9e6d75a9..825763781 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -38,7 +38,7 @@ impl OpenCADStudio { let mut saved = 0usize; let mut skipped = 0usize; for t in 0..self.tabs.len() { - if self.tabs[t].is_start { + if self.tabs[t].is_start || self.tabs[t].block_test.is_some() { continue; } if let Some(path) = self.tabs[t].current_path.clone() { diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index f5f5192fb..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:") { @@ -512,36 +544,6 @@ impl OpenCADStudio { } }; - // A name that is not a block yet opens an empty definition, - // dropped again when the editor closes without saving. - let mut created_for_edit = false; - if self.tabs[i].scene.document.block_records.get(&block_name).is_none() - && cmd.starts_with("BEDIT_OPEN:") - && crate::ui::window::block_definition::invalid_block_name_char(&block_name) - .is_none() - && !block_name.trim().is_empty() - && !block_name.starts_with('*') - { - let options = crate::scene::CreateBlockOptions { - name: block_name.clone(), - handles: Vec::new(), - base_point: glam::DVec3::ZERO, - world_to_block: codec::types::Transform::identity(), - block_to_world: codec::types::Transform::identity(), - mode: crate::ui::window::block_definition::BlockObjectMode::Retain, - annotative: false, - match_orientation: false, - scale_uniformly: false, - allow_exploding: true, - unit: self.tabs[i].scene.document.header.insertion_units, - description: String::new(), - hyperlink_url: String::new(), - hyperlink_desc: String::new(), - redefine: false, - }; - created_for_edit = - self.tabs[i].scene.create_block_with_options(options).is_ok(); - } // Resolve the block record; reject external references (xrefs). let (br_handle, is_xref) = match self.tabs[i].scene.document.block_records.get(&block_name) { @@ -569,82 +571,14 @@ 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, - created_for_edit, - }); - 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" => { self.save_block_edit(i); } + "BTESTBLOCK" => return Some(self.open_block_test(i)), + "BCLOSETESTBLOCK" => return Some(self.close_block_test(i)), "BSAVEAS" => { self.open_block_save_as_dialog(i); } @@ -1056,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/commands/mod.rs b/src/app/commands/mod.rs index a5e23c182..d3ff4c6b6 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -150,6 +150,33 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if self.tabs[i].block_test.is_some() { + let name = cmd.split([' ', ':']).next().unwrap_or(cmd); + if matches!( + name, + "BEDIT" + | "BEDIT_BEGIN" + | "BEDIT_OPEN" + | "REFEDIT" + | "REFEDIT_BEGIN" + | "BSAVE" + | "BSAVEAS" + | "SAVE" + | "QSAVE" + | "SAVEAS" + | "SAVEALL" + | "WBLOCK" + | "EXPORT" + | "ETRANSMIT" + | "ARCHIVE" + ) { + self.command_line.push_error( + crate::t!("Close Test Block before saving or opening another block editor.") + .as_ref(), + ); + return Task::none(); + } + } if is_spacemouse_command(cmd) { return self.run_action(cmd); } diff --git a/src/app/control/actions.rs b/src/app/control/actions.rs index b87927eca..923f1d775 100644 --- a/src/app/control/actions.rs +++ b/src/app/control/actions.rs @@ -410,6 +410,12 @@ impl OpenCADStudio { /// this needs no undo snapshot. Format follows the path extension. #[cfg(not(target_arch = "wasm32"))] pub(super) fn control_wblock(&mut self, req: &Value) -> Result, Value> { + if self.tabs[self.active_tab].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } let path = string(req, "path")?; let document = &self.tabs[self.active_tab].scene.document; // A template base keeps its tables/styles in the exported file diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 1ee429364..caaff5e31 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1148,6 +1148,12 @@ impl OpenCADStudio { "view_focus" => self.control_view_focus(req)?, #[cfg(not(target_arch = "wasm32"))] "save" => { + if self.tabs[i].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } let path = req["path"] .as_str() .map(std::path::PathBuf::from) @@ -1198,6 +1204,12 @@ impl OpenCADStudio { // Web: no filesystem path; the drawing comes back in the reply. #[cfg(target_arch = "wasm32")] "save" => { + if self.tabs[i].block_test.is_some() { + return Err(failure( + "temporary_document", + "Test Block is temporary and cannot be saved", + )); + } use base64::Engine as _; let default_is_dxf = crate::io::source_is_dxf(None, &self.tabs[i].scene.document); let (version, is_dxf) = super::automation::requested_save_target( diff --git a/src/app/control/sheets.rs b/src/app/control/sheets.rs index 42cdb2f2c..aafd39cd8 100644 --- a/src/app/control/sheets.rs +++ b/src/app/control/sheets.rs @@ -20,7 +20,10 @@ impl OpenCADStudio { self.set_control_result(json!({ "closed": false, "reason": "start_page" })); return Ok(Task::none()); } - if self.tabs[index].dirty && !req["discard"].as_bool().unwrap_or(false) { + if self.tabs[index].dirty + && self.tabs[index].block_test.is_none() + && !req["discard"].as_bool().unwrap_or(false) + { return Err(failure( "document_dirty", "Save the document or pass discard:true", diff --git a/src/app/document.rs b/src/app/document.rs index 2fcceaaeb..bc753b254 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -207,6 +207,7 @@ pub(super) struct DocumentTab { pub(super) history: HistoryState, /// Drawing history is suspended while `history` belongs to BEDIT. pub(super) block_edit_history: Option, + pub(super) block_test: Option, pub(super) active_layer: String, /// Block references whose properties panel shows per-axis Scale X/Y/Z even /// though the three factors are currently equal — the user unchecked the @@ -660,6 +661,7 @@ impl DocumentTab { dyn_active: 0, history: HistoryState::default(), block_edit_history: None, + block_test: None, active_layer: "0".to_string(), props_asym_scale: std::collections::HashSet::new(), graph: Default::default(), diff --git a/src/app/drafting_settings.rs b/src/app/drafting_settings.rs index 058fff8ab..b95cd229f 100644 --- a/src/app/drafting_settings.rs +++ b/src/app/drafting_settings.rs @@ -32,6 +32,7 @@ impl DraftingSettingsState { ortho_on: app.ortho_mode, polar_increment_deg: app.polar_increment_deg, osnap_on: app.snapper.snap_enabled, + osnap_aperture_input: format!("{}", app.snapper.osnap_radius_px), otrack_on: app.snapper.otrack_enabled, snap_modes: app.snapper.enabled.clone(), osnap3d_on: app.snapper.snap3d_enabled, @@ -107,6 +108,13 @@ impl OpenCADStudio { self.polar_mode = state.polar_on; self.ortho_mode = state.ortho_on; self.polar_increment_deg = state.polar_increment_deg; + let aperture = state.osnap_aperture_input.trim().parse::().ok() + .filter(|v| v.is_finite() && (4.0..=40.0).contains(v)); + let Some(aperture) = aperture else { + self.command_line.push_error("OSNAP capture radius must be between 4 and 40 pixels."); + return false; + }; + self.snapper.osnap_radius_px = aperture; self.snapper.snap_enabled = state.osnap_on; self.snapper.otrack_enabled = state.otrack_on; self.snapper.enabled = state.snap_modes.clone(); @@ -145,6 +153,7 @@ mod tests { ortho_on: false, polar_increment_deg: 15.0, osnap_on: true, + osnap_aperture_input: "15".to_string(), otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, @@ -186,6 +195,7 @@ mod tests { ortho_on: false, polar_increment_deg: 15.0, osnap_on: true, + osnap_aperture_input: "15".to_string(), otrack_on: false, snap_modes: rustc_hash::FxHashSet::default(), osnap3d_on: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 076f2a07b..ee66dddd5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,6 +9,9 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod annotation_data; mod block_editor; +mod block_test; +#[cfg(test)] +mod block_test_tests; mod block_save_as; #[cfg(test)] mod block_editor_tests; @@ -571,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, @@ -2187,6 +2191,7 @@ pub enum ModalKind { DimStyle, Unsaved, BlockEditChanges, + BlockEditPicker, BlockSaveAs, SaveDialog, Recovery, @@ -2824,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, @@ -3181,6 +3189,8 @@ pub enum Message { /// never fall through and rotate a different camera. ViewportClick(Option), ViewportMove(Point), + /// Position recorded by the pointer event itself, not the last hover. + ViewportLeftPressAt(Point), ViewportLeftPress, ViewportLeftRelease, ViewportRightPress, @@ -3482,6 +3492,7 @@ pub enum Message { DraftingSettingsTogglePolar, DraftingSettingsToggleOrtho, DraftingSettingsToggleOsnap, + DraftingSettingsOsnapApertureChanged(String), DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), @@ -4574,6 +4585,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/command.rs b/src/app/update/command.rs index 8c476f78d..7ca474523 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -160,7 +160,7 @@ impl OpenCADStudio { if self.tabs[idx].is_start { continue; } - if self.tabs[idx].active_block_edit.is_some() { + if self.tabs[idx].active_block_edit.is_some() || self.tabs[idx].block_test.is_some() { tasks.push(self.on_tab_close(idx)); if self.pending_block_close.is_some() || self.pending_close.is_some() { break; @@ -178,6 +178,27 @@ impl OpenCADStudio { } pub(in crate::app) fn on_tab_close(&mut self, idx: usize) -> Task { + let test_return_id = self + .tabs + .get(idx) + .and_then(|tab| tab.block_test.as_ref()) + .map(|test| { + if idx == self.active_tab { + test.source_id + } else { + self.tabs[self.active_tab].id + } + }); + if test_return_id.is_some() { + if idx == self.active_tab { + self.cancel_active_grip_edit(); + self.grip_pending = None; + self.visibility_popup = None; + self.grip_popup = None; + self.grip_hover = None; + } + self.tabs[idx].dirty = false; + } // Start tab is fixed — close requests on it are no-ops. if self.tabs.get(idx).map_or(false, |t| t.is_start) { return Task::none(); @@ -249,6 +270,16 @@ impl OpenCADStudio { // The active tab is now either a brand-new blank or a // different existing tab; in both cases the ribbon needs // to track that doc's defaults / selection. #21. + if let Some(index) = + test_return_id.and_then(|id| self.tabs.iter().position(|tab| tab.id == id)) + { + self.active_tab = index; + self.adopt_header_sysvars(index); + } + self.ribbon + .set_block_testing(self.tabs[self.active_tab].block_test.is_some()); + self.ribbon + .set_block_editing(self.tabs[self.active_tab].active_block_edit.is_some()); self.sync_ribbon_layers(); self.sync_ribbon_styles(); self.sync_ribbon_from_selection(); diff --git a/src/app/update/dialog.rs b/src/app/update/dialog.rs index 1a33b1db2..7dea3f90b 100644 --- a/src/app/update/dialog.rs +++ b/src/app/update/dialog.rs @@ -22,6 +22,9 @@ use iced::{mouse, Point, Task}; impl OpenCADStudio { pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task { + if self.refuse_block_test_save(tab_idx) { + return Task::none(); + } // Every entry here is Save-As semantics (the SAVEAS command, the // recovery save-as, the native failure redirects): the user is // choosing a destination, so on the web the FS Access picker must diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 39c277217..5118acafa 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1779,6 +1779,11 @@ impl OpenCADStudio { version: codec::DxfVersion, set_current_path: bool, ) -> Result<(), crate::io::SaveFailure> { + if self.tabs[i].block_test.is_some() { + return Err(crate::io::SaveFailure::other( + "Test Block is temporary and cannot be saved", + )); + } let previous_autosave = self.autosave_target(i); if self.tabs[i].recovery_save_as_required && self.tabs[i] @@ -2539,7 +2544,7 @@ impl OpenCADStudio { let mut skipped = 0usize; let mut pending: Vec<(crate::app::WebSaveallEntry, codec::CadDocument)> = Vec::new(); for t in 0..self.tabs.len() { - if self.tabs[t].is_start { + if self.tabs[t].is_start || self.tabs[t].block_test.is_some() { continue; } let Some(path) = self.tabs[t].current_path.clone() else { @@ -3199,6 +3204,9 @@ impl OpenCADStudio { set_current_path: bool, check_external_change: bool, ) -> Task { + if self.refuse_block_test_save(i) { + return Task::none(); + } let tab_id = self.tabs[i].id; if self .pending_native_thumbnail_save @@ -3867,6 +3875,9 @@ impl OpenCADStudio { } pub(super) fn on_save_file(&mut self) -> Task { + if self.refuse_block_test_save(self.active_tab) { + return Task::none(); + } if self.read_only { self.command_line.push_error( crate::t!("Read-only session (--read-only): saving is disabled.").as_ref(), @@ -3923,6 +3934,9 @@ impl OpenCADStudio { /// Used by plain Save (QSAVE) on an as-yet-unsaved drawing and by the /// save-before-close flow — the version picker is reserved for Save As. pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task { + if self.refuse_block_test_save(tab_idx) { + return Task::none(); + } self.active_tab = tab_idx; // Every caller of this entry (Save/QSAVE, the save-before-close // flow) is plain-save semantics: on the web, the tab's retained @@ -4315,7 +4329,10 @@ impl OpenCADStudio { pub(super) fn on_autosave(&mut self) -> Task { let mut tasks = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty || self.active_save_jobs.contains_key(&self.tabs[i].id) { + if self.tabs[i].block_test.is_some() + || !self.tabs[i].dirty + || self.active_save_jobs.contains_key(&self.tabs[i].id) + { continue; } self.prepare_snapshot_save(i); @@ -4350,7 +4367,7 @@ impl OpenCADStudio { } let mut pending = Vec::new(); for i in 0..self.tabs.len() { - if !self.tabs[i].dirty { + if self.tabs[i].block_test.is_some() || !self.tabs[i].dirty { continue; } // Same pre-serialize state sync the web Save flow performs @@ -4677,7 +4694,7 @@ impl OpenCADStudio { let mut names: Vec = Vec::new(); let mut active = 0; for (index, tab) in self.tabs.iter().enumerate() { - if tab.is_start { + if tab.is_start || tab.block_test.is_some() { continue; } if index == self.active_tab { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index af78597c2..2c4ad94b1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -50,7 +50,7 @@ fn is_modal_blocked_key_msg(msg: &Message) -> bool { fn perf_message_label(msg: &Message) -> &'static str { match msg { - Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down", + Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::PanePress(_) => "pointer-down", Message::ViewportLeftRelease | Message::PaneRelease(_) => "pointer-up", Message::ViewportMove(_) | Message::PaneMove(_, _) => "pointer-move", Message::CommandFinalize => "command-finalize", @@ -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, @@ -346,7 +347,17 @@ impl OpenCADStudio { .tabs .get(self.active_tab) .is_some_and(|tab| tab.active_block_edit.is_some()); - self.ribbon.set_block_editing(block_editing); + let testing = self + .tabs + .get(self.active_tab) + .is_some_and(|tab| tab.block_test.is_some()); + if testing { + self.ribbon.set_block_editing(false); + self.ribbon.set_block_testing(true); + } else { + self.ribbon.set_block_testing(false); + self.ribbon.set_block_editing(block_editing); + } task } @@ -445,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"))) @@ -595,7 +612,11 @@ impl OpenCADStudio { } } #[cfg(target_arch = "wasm32")] - crate::sys::set_unsaved_changes_warning(self.tabs.iter().any(|tab| tab.dirty)); + crate::sys::set_unsaved_changes_warning( + self.tabs + .iter() + .any(|tab| tab.dirty && tab.block_test.is_none()), + ); if self.tabs[i].active_cmd.is_none() { self.block_palette.placing = None; } @@ -4390,6 +4411,7 @@ impl OpenCADStudio { // A multi-finger gesture owns the pointer while it runs. Message::ViewportMove(_) + | Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::ViewportLeftRelease | Message::PaneMove(..) @@ -4474,6 +4496,17 @@ impl OpenCADStudio { self.update(Message::ViewportScroll(d)) } + Message::ViewportLeftPressAt(position) => { + // Use the pointer location captured by the actual button + // event. The last hover can lag behind fast cursor motion, + // particularly while expensive OSNAP/OTRACK previews run. + // Refresh the snap and world point before accepting the pick. + let moved = self.on_viewport_move(position); + self.graphic_attributes.open_menu = None; + let sweep = self.sync_active_field_if_any(); + Task::batch(vec![moved, sweep, self.on_viewport_left_press()]) + } + Message::ViewportLeftPress => { self.graphic_attributes.open_menu = None; let sweep = self.sync_active_field_if_any(); @@ -6255,6 +6288,12 @@ impl OpenCADStudio { } Task::none() } + Message::DraftingSettingsOsnapApertureChanged(value) => { + if let Some(state) = &mut self.drafting_settings_state { + state.osnap_aperture_input = value; + } + Task::none() + } Message::DraftingSettingsToggleOsnap => { if let Some(state) = &mut self.drafting_settings_state { state.osnap_on = !state.osnap_on; @@ -10445,6 +10484,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/update/viewport.rs b/src/app/update/viewport.rs index 397be1f48..dc9c99f78 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2663,7 +2663,15 @@ impl OpenCADStudio { .map(|snap| snap.world) .unwrap_or(cursor_world); axis_lock_apply(point, base, dir) - } else if let Some(h) = otrack_hit { + } else if let Some(h) = otrack_hit.filter(|_| { + // A real object snap must win over tracking alignment. + // Otherwise the endpoint glyph can appear at the + // corner while the preview/accepted point follows the + // tracking line instead. + !self.tabs[i].snap_result.is_some_and(|s| { + s.snap_type != crate::snap::SnapType::Grid + }) + }) { h.aligned } else { // Snap runs in model space (viewport camera or the @@ -4498,7 +4506,11 @@ impl OpenCADStudio { } else { None }; - if let Some(h) = otrack { + if let Some(h) = otrack.filter(|_| { + // Match the hover path: never override a genuine OSNAP + // endpoint/intersection with a tracking projection. + !snap_hit.is_some_and(|s| s.snap_type != crate::snap::SnapType::Grid) + }) { pt = h.aligned; if clamp_world_xy { pt.z = 0.0; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a91981cc3..7ef9eafa3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -15,6 +15,46 @@ use iced::widget::{ use iced::window; use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme}; use iced_aw::ContextMenu; + +/// Invisible event surface for accurate point picking. MouseArea emits a +/// positionless press message; capturing the cursor in the actual button +/// event avoids using the previous hover location when clicking quickly. +#[derive(Debug, Clone, Copy)] +struct ViewportPickSurface; + +impl iced::widget::canvas::Program for ViewportPickSurface { + type State = (); + + fn update( + &self, + _state: &mut Self::State, + event: &iced::Event, + bounds: iced::Rectangle, + cursor: iced::mouse::Cursor, + ) -> Option> { + if let iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left)) = + event + { + return cursor.position_in(bounds).map(|position| { + iced::widget::canvas::Action::publish(Message::ViewportLeftPressAt(position)) + .and_capture() + }); + } + None + } + + fn draw( + &self, + _state: &Self::State, + _renderer: &iced::Renderer, + _theme: &iced::Theme, + _bounds: iced::Rectangle, + _cursor: iced::mouse::Cursor, + ) -> Vec { + Vec::new() + } +} + use std::sync::Arc; mod controls; @@ -876,10 +916,16 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - let point_cursor = tab - .active_cmd - .as_ref() - .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); + // Keep the hollow pickbox at rest. Use the standard LINE/RECTANG + // dot crosshair only after the first window/crossing corner. + // No OSNAP or selection behavior changes. + let selecting_window = tab.active_cmd.is_none() + && tab.scene.selection.borrow().gesture.box_anchor.is_some(); + let point_cursor = selecting_window + || tab + .active_cmd + .as_ref() + .is_some_and(|cmd| !cmd.needs_entity_pick() && !cmd.is_selection_gathering()); let constraint_cursor_badge = if is_paper { None } else { @@ -986,9 +1032,15 @@ bg={bg_ms:.1}ms n={view_count}" mark("viewport_mouse"); let viewport_mouse = mouse_area(container( - iced::widget::Space::new().width(Fill).height(Fill), + iced::widget::canvas(ViewportPickSurface) + .width(Fill) + .height(Fill), )) .on_move(Message::ViewportMove) + // Left mouse presses are captured by the canvas with their exact + // event-time position. MouseArea::on_press has no coordinate, so + // queued cursor moves could otherwise make quick clicks use a stale + // snap position. Touch presses still use this fallback. .on_press(Message::ViewportLeftPress) .on_release(Message::ViewportLeftRelease) .on_right_press(Message::ViewportRightPress) @@ -1036,6 +1088,20 @@ bg={bg_ms:.1}ms n={view_count}" { let w = tab.last_cursor_world; let base = tab.dyn_anchor.or(self.last_point); + // A single distance field on an acquired tracking ray is measured + // from the acquired OSNAP/OTRACK point, not from the command's + // previous point (or from WCS zero before the first pick). + // Keep typed fields and ordinary polar distance inputs unchanged. + let tracking_distance = if tab.dyn_fields.len() == 1 + && tab.dyn_fields[0].component == DynComponent::Distance + { + self.active_distance_ray(self.active_tab).and_then(|(origin, direction)| { + let axis = direction.try_normalize()?; + Some((w - origin).dot(axis).abs()) + }) + } else { + None + }; let label_screen = tab .active_cmd .as_ref() @@ -1185,6 +1251,16 @@ bg={bg_ms:.1}ms n={view_count}" format!("{lv:.4}") } } + // The displayed live distance must use the same + // acquired tracking origin as the guide and typed + // distance placement, not the last picked point. + (None, _) if f.component == DynComponent::Distance + && tracking_distance.is_some() => + { + crate::entities::common::format_length( + tracking_distance.unwrap() * f.role.value_scale() as f64, + ) + } _ => dyn_component_value( f, w, 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/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 26f0f4379..09b61c71e 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -63,6 +63,11 @@ impl CadModule for BlockEditorModule { "Close Block Editor", include_bytes!("../../../assets/icons/mt_cancel.svg"), ), + command( + "BTESTBLOCK", + "Test Block", + include_bytes!("../../../assets/icons/edit_block.svg"), + ), ], }, RibbonGroup { diff --git a/src/modules/draw/mod.rs b/src/modules/draw/mod.rs index 4acb13587..7fb2cc5a7 100644 --- a/src/modules/draw/mod.rs +++ b/src/modules/draw/mod.rs @@ -15,7 +15,7 @@ mod report; pub mod select; pub mod units; -use crate::modules::{CadModule, RibbonGroup, RibbonItem}; +use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; pub struct DrawModule; @@ -249,6 +249,15 @@ impl CadModule for DrawModule { default: "DIST", }], }, + RibbonGroup { + title: "Close", + tools: vec![RibbonItem::LargeTool(ToolDef { + id: "BCLOSETESTBLOCK", + label: "Close Test Block", + icon: IconKind::Svg(include_bytes!("../../../assets/icons/mt_ok.svg")), + event: ModuleEvent::Command("BCLOSETESTBLOCK".into()), + })], + }, // Support group lives on the Start tab now (see view.rs: // start_page_view). Removed from the Draw ribbon to declutter. ] diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index 4321db56e..e8038eada 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -70,9 +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, - /// The definition was created empty to be edited (a new name at BEDIT / - /// -BEDIT); closing without saving removes it again. - pub created_for_edit: bool, + /// 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 ───────────────────────────────────────────────────── @@ -122,3 +123,6 @@ inventory::submit!(crate::command::CommandRegistration { inventory::submit!(crate::command::CommandRegistration { names: &["BSAVE", "BSAVEAS", "BCLOSE"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["BTESTBLOCK", "BCLOSETESTBLOCK"] +}); diff --git a/src/modules/insert/block_cmdline.rs b/src/modules/insert/block_cmdline.rs index 90671562c..0ff3810fe 100644 --- a/src/modules/insert/block_cmdline.rs +++ b/src/modules/insert/block_cmdline.rs @@ -763,7 +763,7 @@ enum BeditStep { } /// -BEDIT: `Enter block name or [?]:` opens the block editor on that -/// definition, creating an empty one for a new name. +/// definition, or on a new empty one for a new name. pub struct DashBeditCommand { step: BeditStep, table: BlockTable, @@ -845,7 +845,7 @@ impl CadCommand for DashBeditCommand { return Some(CmdResult::ReportError("Invalid block name.".to_string())); } let name = self.table.find_user(t).unwrap_or_else(|| t.to_string()); - CmdResult::Dispatch(format!("BEDIT_OPEN:{name}")) + CmdResult::Dispatch(format!("BEDIT_NEW:{name}")) } BeditStep::ListPattern => { self.step = BeditStep::Name; 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/snap.rs b/src/snap.rs index 8e5260fad..bc8cd04a4 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -14,7 +14,7 @@ use kernel::geom2d::Curve; use crate::command::{DimensionAssociationSource, TangentObject}; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use crate::scene::pick::interaction_index::WireSource; -const DEFAULT_OSNAP_RADIUS_PX: f32 = 15.0; +const DEFAULT_OSNAP_RADIUS_PX: f32 = 25.0; // ── Snap type ───────────────────────────────────────────────────────────── diff --git a/src/ui/ribbon/keytips.rs b/src/ui/ribbon/keytips.rs index 906646c18..a7a626eaf 100644 --- a/src/ui/ribbon/keytips.rs +++ b/src/ui/ribbon/keytips.rs @@ -434,6 +434,9 @@ const CODES: &[(&str, &str, &str)] = &[ ("block_editor", "Open and Save", "BO"), ("block_editor", "BEDIT", "BE"), ("block_editor", "BSAVE", "BS"), + ("block_editor", "BTESTBLOCK", "TB"), + ("draw", "Close", "TC"), + ("draw", "BCLOSETESTBLOCK", "CT"), ("block_editor", "ATTDEF", "AD"), ("block_editor", "AUTOCONSTRAIN", "AC"), ("block_editor", "CCONSTRAINT", "CI"), @@ -631,7 +634,7 @@ impl Ribbon { }; let scope = module.id(); let mut tips = Vec::new(); - for group in module.ribbon_groups() { + for group in self.groups_for(index) { for item in &group.tools { match item { RibbonItem::Tool(tool) @@ -709,11 +712,16 @@ impl Ribbon { fn item_tips(&self, index: usize, open: &str) -> Vec { let mut items = draw_panel::keytip_items(open); if items.is_empty() { - let groups = self.modules.get(index).map(|m| m.ribbon_groups()).unwrap_or(&[]); - for item in groups.iter().flat_map(|group| &group.tools) { - if let RibbonItem::Dropdown { id, items: list, .. } - | RibbonItem::LabeledDropdown { id, items: list, .. } - | RibbonItem::LargeDropdown { id, items: list, .. } = item + for item in self.groups_for(index).flat_map(|group| &group.tools) { + if let RibbonItem::Dropdown { + id, items: list, .. + } + | RibbonItem::LabeledDropdown { + id, items: list, .. + } + | RibbonItem::LargeDropdown { + id, items: list, .. + } = item { if *id == open { items = list diff --git a/src/ui/ribbon/locate.rs b/src/ui/ribbon/locate.rs index e4dd32c35..18c3c3d9a 100644 --- a/src/ui/ribbon/locate.rs +++ b/src/ui/ribbon/locate.rs @@ -68,15 +68,11 @@ impl Ribbon { fn entries(&self) -> impl Iterator)> { let order = std::iter::once(self.active) .chain((0..self.modules.len()).filter(move |i| *i != self.active)); - order - .filter_map(|i| self.modules.get(i).map(|m| (i, m))) - .flat_map(|(i, module)| { - module - .ribbon_groups() - .iter() - .flat_map(group_entries) - .map(move |entry| (i, entry)) - }) + order.flat_map(|i| { + self.groups_for(i) + .flat_map(group_entries) + .map(move |entry| (i, entry)) + }) } /// The button id that runs the command line `cmd`: an exact match first, @@ -111,12 +107,7 @@ impl Ribbon { /// module has no tab (its tools are on the side toolbar). pub(super) fn tab_holding_active_tool(&self) -> Option { let id = self.active_tool.as_deref()?; - let holds = |i: usize| { - self.modules[i] - .ribbon_groups() - .iter() - .any(|group| group_holds(group, id)) - }; + let holds = |i: usize| self.groups_for(i).any(|group| group_holds(group, id)); if self .modules .get(self.active) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 86680973b..bae9d8b1a 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -126,6 +126,7 @@ pub struct Ribbon { /// A block editor session is open: the tab that was active before the /// Block Editor tab took over. block_editing: Option, + block_testing: Option, /// XDWGFADECTL as the Reference slide-out shows it (negative = off). pub xref_fade: i32, /// Open key tips (Alt). @@ -231,6 +232,7 @@ impl Ribbon { xref_ctx: false, point_cloud_ctx: None, block_editing: None, + block_testing: None, xref_fade: 50, keytips: None, } @@ -328,6 +330,40 @@ impl Ribbon { } } + pub fn set_block_testing(&mut self, testing: bool) { + if testing != self.block_testing.is_some() { + self.close_dropdown(); + let _ = self.hide_keytips(); + } + let Some(index) = self.modules.iter().position(|module| module.id() == "draw") else { + return; + }; + match (testing, self.block_testing) { + (true, None) => { + self.block_testing = Some(self.active); + self.active = index; + } + (false, Some(previous)) => { + self.block_testing = None; + if self.active == index { + self.active = previous.min(self.modules.len().saturating_sub(1)); + } + } + _ => {} + } + } + + /// Contextual panels share their normal tab with the drawing tools. + fn groups_for(&self, index: usize) -> impl Iterator { + let testing = self.block_testing.is_some(); + self.modules.get(index).into_iter().flat_map(move |module| { + module + .ribbon_groups() + .iter() + .filter(move |group| module.id() != "draw" || group.title != "Close" || testing) + }) + } + pub fn select(&mut self, index: usize) { if index < self.modules.len() { self.active = index; @@ -733,38 +769,36 @@ impl Ribbon { } else { self.active }; - let tool_area: Element<'_, Message> = - if let Some(module) = self.modules.get(effective_active) { - let groups = module.ribbon_groups(); - let style_ctx = StyleContext { - text_style_names: &self.text_style_names, - active_text_style: &self.active_text_style, - dim_style_names: &self.dim_style_names, - active_dim_style: &self.active_dim_style, - mleader_style_names: &self.mleader_style_names, - active_mleader_style: &self.active_mleader_style, - table_style_names: &self.table_style_names, - active_table_style: &self.active_table_style, - }; - - // Adaptive tool area: panels sit on one row; when they don't all - // fit they degrade from the right — a panel's large buttons first - // shrink to compact icon columns, then it collapses to a ▾ flyout - // button. See `CollapsePanels`. - let ts = self.toggle_state(show_block_palette); - let panels: Vec> = groups - .iter() - .map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new())) - .collect(); - CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) - .report_height(self.tool_bar_h.clone()) - .report_tight(self.collapse_tight.clone()) - .mode(self.collapse_mode) - .into() - } else { - text("").into() + let tool_area: Element<'_, Message> = if self.modules.get(effective_active).is_some() { + let groups = self.groups_for(effective_active); + let style_ctx = StyleContext { + text_style_names: &self.text_style_names, + active_text_style: &self.active_text_style, + dim_style_names: &self.dim_style_names, + active_dim_style: &self.active_dim_style, + mleader_style_names: &self.mleader_style_names, + active_mleader_style: &self.active_mleader_style, + table_style_names: &self.table_style_names, + active_table_style: &self.active_table_style, }; + // Adaptive tool area: panels sit on one row; when they don't all + // fit they degrade from the right — a panel's large buttons first + // shrink to compact icon columns, then it collapses to a ▾ flyout + // button. See `CollapsePanels`. + let ts = self.toggle_state(show_block_palette); + let panels: Vec> = groups + .map(|g| self.panel(g, ts, &style_ctx, &|_| Vec::new())) + .collect(); + CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H) + .report_height(self.tool_bar_h.clone()) + .report_tight(self.collapse_tight.clone()) + .mode(self.collapse_mode) + .into() + } else { + text("").into() + }; + let tool_bar: Element<'_, Message> = container(tool_area) .style(|theme: &Theme| container::Style { background: Some(Background::Color(theme.palette().background.weakest.color)), @@ -931,8 +965,7 @@ impl Ribbon { return Some(ov); } - let module = self.modules.get(self.active)?; - let groups = module.ribbon_groups(); + let groups = self.groups_for(self.active); let mut items_list: Option> = None; let mut dd_default = ""; let mut dd_id: &'static str = ""; @@ -1161,7 +1194,7 @@ impl Ribbon { /// Draw-tab dropdowns — so the list grows to fit its entries instead of /// being clipped to the ribbon-row height. (#153) fn style_combo_overlay(&self, open_id: &str, win: (f32, f32)) -> Option> { - let groups = self.modules.get(self.active)?.ribbon_groups(); + let groups = self.groups_for(self.active); // Locate the open style combo; capture its style key + manager command. let mut found: Option<(crate::modules::StyleKey, Option<&'static str>)> = None; @@ -1677,6 +1710,41 @@ mod tests { use super::widgets::is_active_tool; use super::Ribbon; + #[test] + fn block_test_close_panel_extends_draw_only_while_testing() { + let mut ribbon = Ribbon::default(); + let draw = ribbon + .modules + .iter() + .position(|module| module.id() == "draw") + .unwrap(); + assert!(!ribbon + .modules + .iter() + .any(|module| module.id() == "block_test")); + let ordinary: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect(); + assert!(!ordinary.contains(&"Close")); + ribbon.set_block_editing(true); + ribbon.set_block_editing(false); + ribbon.set_block_testing(true); + assert_eq!(ribbon.active, draw); + let testing: Vec<_> = ribbon.groups_for(draw).map(|group| group.title).collect(); + assert_eq!(&testing[..ordinary.len()], ordinary.as_slice()); + assert_eq!(testing.last(), Some(&"Close")); + ribbon.toggle_collapsed_panel("Close"); + ribbon.set_block_testing(false); + assert!(ribbon.collapsed_open.is_none()); + assert_eq!( + ribbon + .groups_for(draw) + .map(|group| group.title) + .collect::>(), + ordinary + ); + ribbon.set_block_editing(true); + assert_eq!(ribbon.modules[ribbon.active].id(), "block_editor"); + } + #[test] fn block_palette_highlight_follows_the_app_state_passed_to_the_view() { // The Block Palette button highlight is derived from the app's 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/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 97bb36c0a..e1a768e1b 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -44,6 +44,7 @@ pub struct DraftingSettingsState { pub polar_increment_deg: f32, // Object Snap pub osnap_on: bool, + pub osnap_aperture_input: String, pub otrack_on: bool, pub snap_modes: rustc_hash::FxHashSet, // 3D Object Snap @@ -78,6 +79,7 @@ impl DraftingSettingsState { || self.ortho_on != saved.ortho_on || (self.polar_increment_deg - saved.polar_increment_deg).abs() > 0.001 || self.osnap_on != saved.osnap_on + || self.osnap_aperture_input != saved.osnap_aperture_input || self.otrack_on != saved.otrack_on || self.snap_modes != saved.snap_modes || self.osnap3d_on != saved.osnap3d_on @@ -508,6 +510,14 @@ pub fn view_window<'a>( column![ row![osnap_on_toggle, Space::new().width(24), otrack_on_toggle].align_y(iced::Center), + row![ + text("OSNAP capture radius (pixels)").size(11), + text_input("25", &state.osnap_aperture_input) + .on_input(Message::DraftingSettingsOsnapApertureChanged) + .width(70), + ] + .spacing(10) + .align_y(iced::Center), action_buttons, Space::new().height(4), snap_modes_group, 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;