From 5de7637a6917afe9dc3b98ace35abc6eba2aee3d Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:24:19 -0300 Subject: [PATCH 01/15] fix: measure live OTRACK distance from acquired snap origin --- src/app/view/mod.rs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index a91981cc3..0c0a8ac2b 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1036,6 +1036,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 +1199,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, From 86eb7a744c3074c471dae4aedfcce0f4795d2048 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:28:51 -0300 Subject: [PATCH 02/15] fix: capture viewport click coordinates in same mouse event --- src/app/view/mod.rs | 48 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 0c0a8ac2b..7a7dc658c 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; @@ -986,9 +1026,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) From 9954888d1da67d78415571b63cf8afe11efeff61 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:07 -0300 Subject: [PATCH 03/15] fix: carry click-time pointer position in viewport input message --- src/app/mod.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 076f2a07b..42248e3e1 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3181,6 +3181,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, From d4b7e4bfb8b2137128b7c084b93b0e925db2f396 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:31 -0300 Subject: [PATCH 04/15] fix: resolve precise snap at button-down before committing viewport pick --- src/app/update/mod.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 14443aa96..d96d14a66 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", @@ -4383,6 +4383,7 @@ impl OpenCADStudio { // A multi-finger gesture owns the pointer while it runs. Message::ViewportMove(_) + | Message::ViewportLeftPressAt(_) | Message::ViewportLeftPress | Message::ViewportLeftRelease | Message::PaneMove(..) @@ -4467,6 +4468,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(); From b6c4e0a81d7fd1102bd6c71a430ad403958b710f Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:37:42 -0300 Subject: [PATCH 05/15] ui: reuse precise point crosshair for selection without enabling snaps --- src/app/view/mod.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 7a7dc658c..5f3b940cc 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -916,10 +916,13 @@ 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()); + // Reuse the exact point-picking crosshair (cross + center dot) + // already used by LINE and RECTANG for ordinary selection and + // selection-gathering commands. This changes only cursor drawing, + // not object snapping or the selected world position. + let point_cursor = tab.active_cmd.as_ref().is_none_or(|cmd| { + !cmd.needs_entity_pick() || cmd.is_selection_gathering() + }); let constraint_cursor_badge = if is_paper { None } else { @@ -994,8 +997,7 @@ bg={bg_ms:.1}ms n={view_count}" // apart from both idle and point input (#1661). hide_arms: tab.active_cmd.as_ref().is_some_and(|c| { matches!(c.name(), "XLINE" | "RAY") - || c.needs_entity_pick() - || c.is_selection_gathering() + || (c.needs_entity_pick() && !c.is_selection_gathering()) }), snap_color: self.snap_marker_color, snap_size: self.model_space.snap_marker_size as f32, From 9632979d09899d50ab370ef36e448a4a1930be99 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Wed, 14 Oct 2026 14:44:58 +0100 Subject: [PATCH 06/15] feat(block-editor): add Test Block preview Preview the current unsaved block in a disposable drawing with independent history. Keep normal Draw tools available, append Close Test Block, and return to the original editor without saving. Guard persistence paths and handle refresh and parent-close lifecycle by stable document identity. --- src/app/automation.rs | 15 ++ src/app/block_editor.rs | 10 +- src/app/block_test.rs | 163 ++++++++++++ src/app/block_test_tests.rs | 346 ++++++++++++++++++++++++++ src/app/commands/fileops.rs | 2 +- src/app/commands/inquiry.rs | 2 + src/app/commands/mod.rs | 27 ++ src/app/control/actions.rs | 6 + src/app/control/mod.rs | 12 + src/app/control/sheets.rs | 5 +- src/app/document.rs | 2 + src/app/mod.rs | 3 + src/app/update/command.rs | 33 ++- src/app/update/dialog.rs | 3 + src/app/update/file.rs | 25 +- src/app/update/mod.rs | 18 +- src/modules/block_editor/mod.rs | 5 + src/modules/draw/mod.rs | 11 +- src/modules/draw/modify/block_edit.rs | 3 + src/ui/ribbon/keytips.rs | 20 +- src/ui/ribbon/locate.rs | 21 +- src/ui/ribbon/mod.rs | 134 +++++++--- 22 files changed, 801 insertions(+), 65 deletions(-) create mode 100644 src/app/block_test.rs create mode 100644 src/app/block_test_tests.rs 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 529a4c845..a74bbd65f 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -499,6 +499,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( @@ -506,6 +507,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; @@ -515,6 +517,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) } @@ -564,6 +567,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) } @@ -583,7 +587,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_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 d5fbd9d9f..43b944a28 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -614,6 +614,8 @@ impl OpenCADStudio { "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); } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 1ea774d8c..467319a6d 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -149,6 +149,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/mod.rs b/src/app/mod.rs index 076f2a07b..3aeeaa516 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; 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 94f571b71..5405968bc 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1759,6 +1759,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] @@ -2423,7 +2428,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 { @@ -3083,6 +3088,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 @@ -3751,6 +3759,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(), @@ -3807,6 +3818,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 @@ -4199,7 +4213,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); @@ -4234,7 +4251,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 @@ -4561,7 +4578,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 14443aa96..1817eb818 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -346,7 +346,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 } @@ -595,7 +605,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; } 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 3d7c0a0d1..0e1720262 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -119,3 +119,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/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 From d4879c5a512decf09d425184e6919f18efbb2f7b Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:45:01 -0300 Subject: [PATCH 07/15] fix: preserve acquired object snap over OTRACK at preview and click --- src/app/update/viewport.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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; From abad1b904577f4ea9d6d826c7143cad1eb5560bd Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Wed, 14 Oct 2026 12:47:36 +0100 Subject: [PATCH 08/15] feat(bedit): add Edit Block Definition dialog and new block authoring Open the Edit Block Definition dialog when BEDIT is invoked without an active selection, listing candidate block definitions alphabetically while filtering layout records, anonymous blocks, and external references. Allow typing a new valid block name to start an empty block editor session that only commits to the drawing block table upon explicit save. --- src/app/block_editor.rs | 120 ++++++++++++- src/app/block_editor_tests.rs | 235 +++++++++++++++++++++++++- src/app/commands/inquiry.rs | 194 +++++++++++++-------- src/app/mod.rs | 6 + src/app/update/mod.rs | 63 +++++++ src/app/view/modal.rs | 7 + src/modules/draw/modify/block_edit.rs | 4 + src/scene/block_service.rs | 53 +++++- src/ui/window/block_edit_picker.rs | 122 +++++++++++++ src/ui/window/mod.rs | 1 + 10 files changed, 719 insertions(+), 86 deletions(-) create mode 100644 src/ui/window/block_edit_picker.rs 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; From daf4ccdde866611a2855693092ed9b7539524eda Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:53:25 -0300 Subject: [PATCH 09/15] fix(ui): keep hollow pickbox idle; dot crosshair only during selection window --- src/app/view/mod.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 5f3b940cc..7ef9eafa3 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -916,13 +916,16 @@ bg={bg_ms:.1}ms n={view_count}" .hover_highlight .map(|h| tab.scene.is_layer_locked(h)) .unwrap_or(false); - // Reuse the exact point-picking crosshair (cross + center dot) - // already used by LINE and RECTANG for ordinary selection and - // selection-gathering commands. This changes only cursor drawing, - // not object snapping or the selected world position. - let point_cursor = tab.active_cmd.as_ref().is_none_or(|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 { @@ -997,7 +1000,8 @@ bg={bg_ms:.1}ms n={view_count}" // apart from both idle and point input (#1661). hide_arms: tab.active_cmd.as_ref().is_some_and(|c| { matches!(c.name(), "XLINE" | "RAY") - || (c.needs_entity_pick() && !c.is_selection_gathering()) + || c.needs_entity_pick() + || c.is_selection_gathering() }), snap_color: self.snap_marker_color, snap_size: self.model_space.snap_marker_size as f32, From 85959c770c25c859048add98d0b7d7ae939c5508 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:02 -0300 Subject: [PATCH 10/15] ui: expose editable OSNAP capture aperture in drafting settings --- src/ui/window/drafting_settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 97bb36c0a..64eae7e5b 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("15", &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, From 5108e741636160ae641ba05cf7053ad5dde82954 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:06 -0300 Subject: [PATCH 11/15] ui: add OSNAP aperture input message --- src/app/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 42248e3e1..4f8bcace4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3484,6 +3484,7 @@ pub enum Message { DraftingSettingsTogglePolar, DraftingSettingsToggleOrtho, DraftingSettingsToggleOsnap, + DraftingSettingsOsnapApertureChanged(String), DraftingSettingsToggleOtrack, DraftingSettingsToggleSnapMode(crate::snap::SnapType), DraftingSettingsToggleSnapMode3d(crate::snap::SnapType), From 8669631dd92bd4154c4a48729e8f117ff7573520 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:11 -0300 Subject: [PATCH 12/15] ui: route OSNAP capture aperture setting --- src/app/update/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index d96d14a66..a5c5e2e53 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6330,6 +6330,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; From 0b041e4c86ec08261a24f2ac1bc93bbcf9c96211 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:55:15 -0300 Subject: [PATCH 13/15] feat: apply validated configurable OSNAP capture radius --- src/app/drafting_settings.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) 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, From f47e4649c362d8d2a8fb104bfb617a082d73b70a Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:12:05 -0300 Subject: [PATCH 14/15] Set default OSNAP capture radius to 25 screen pixels --- src/snap.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ───────────────────────────────────────────────────────────── From aa6f88937d475825b83da99c11d85a92258b0566 Mon Sep 17 00:00:00 2001 From: gianlucafiore <50465150+gianlucafiore@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:12:08 -0300 Subject: [PATCH 15/15] Update OSNAP aperture placeholder to 25 px --- src/ui/window/drafting_settings.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/window/drafting_settings.rs b/src/ui/window/drafting_settings.rs index 64eae7e5b..e1a768e1b 100644 --- a/src/ui/window/drafting_settings.rs +++ b/src/ui/window/drafting_settings.rs @@ -512,7 +512,7 @@ pub fn view_window<'a>( 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("15", &state.osnap_aperture_input) + text_input("25", &state.osnap_aperture_input) .on_input(Message::DraftingSettingsOsnapApertureChanged) .width(70), ]