From 7c91f5835e6634fa8b441950a07fba0af4843647 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sat, 17 Oct 2026 10:22:14 +0100 Subject: [PATCH] feat(blocks): edit action selections and refresh evaluated references Add BACTIONSET and the Block Editor Action Selection ribbon entry. Pick or preselect an action, replace/add/remove its drawing targets, or redefine a Stretch frame while preserving the native action and graph identity. Retain existing Stretch vertex bindings during target edits and bind new targets against the stored frame. Allow removing invalid bindings or rebuilding them through a replacement frame. Rebuild evaluated references at their independent cached values so removed targets lose old action effects, with local undo, discard and one drawing save delta. Validate synthetic native DWG/DXF roundtrips, shared COPY representations, stale-result guards, reference geometry refresh, frame/cancel/history and the real ribbon flyout. 347 block tests pass (6 existing opt-in ignored), plus 42 dock and 27 viewport tests. --- src/app/block_actions.rs | 115 ++++++++++-- src/app/block_editor.rs | 16 ++ src/app/block_editor_tests.rs | 207 +++++++++++++++++++++ src/app/command_driver/mod.rs | 1 + src/app/commands/bvisibility.rs | 1 + src/app/commands/mod.rs | 8 +- src/app/dock/tests.rs | 69 +++++++ src/command.rs | 8 + src/modules/block_editor/action.rs | 36 ++-- src/modules/block_editor/action_set.rs | 243 +++++++++++++++++++++++++ src/modules/block_editor/mod.rs | 6 + src/scene/block_actions.rs | 119 +++++++++++- src/scene/block_actions/instance.rs | 55 ++++-- src/scene/block_actions/stretch.rs | 80 ++++++-- src/scene/block_actions/tests.rs | 188 +++++++++++++++++++ 15 files changed, 1083 insertions(+), 69 deletions(-) create mode 100644 src/modules/block_editor/action_set.rs diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs index 16c288bbe..f96bd0fe0 100644 --- a/src/app/block_actions.rs +++ b/src/app/block_actions.rs @@ -30,6 +30,55 @@ impl OpenCADStudio { } None } + pub(in crate::app) fn action_set_error(&self) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(session) = tab.active_block_edit_session() else { + return Some("BACTIONSET is only available in the Block Editor."); + }; + if let Some(e) = crate::scene::block_point_parameter::placement_error( + &tab.scene.document, + session.br_handle, + ) { + return Some(e); + } + actions::actions(&tab.scene.document, session.br_handle) + .is_empty() + .then_some("This block has no supported actions to edit.") + } + pub(in crate::app) fn start_block_action_set(&mut self) -> Task { + if let Some(e) = self.action_set_error() { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let command = crate::modules::block_editor::action_set::ActionSetCommand::new( + tab.id, + tab.active_block_edit_session().unwrap().br_handle, + &tab.scene.document, + tab.selected_authoring, + ); + self.command_line.push_info(&command.prompt()); + self.command_line.set_step_options(command.options()); + self.tabs[self.active_tab].active_cmd = Some(Box::new(command)); + Task::none() + } + fn apply_action_document(&mut self, i: usize, mut staged: codec::CadDocument) { + let doc = &mut self.tabs[i].scene.document; + let changes = staged + .entities() + .filter(|e| doc.get_entity(e.common().handle) != Some(*e)) + .cloned() + .collect::>(); + doc.swap_structure(staged.snapshot_structure()); + for entity in changes { + let handle = entity.common().handle; + if let Some(current) = doc.get_entity_mut(handle) { + *current = entity; + } else { + doc.restore_entity_arc(std::sync::Arc::new(entity)); + } + } + } pub(in crate::app) fn start_block_action(&mut self, args: &str) -> Task { let kind = if args.is_empty() { None @@ -69,6 +118,53 @@ impl OpenCADStudio { ) -> Task { let i = self.active_tab; match result { + crate::command::CmdResult::EditBlockActionSet { + document, + definition, + action, + expected, + targets, + frame, + } => { + let tab = &self.tabs[i]; + if tab.id != document + || tab.active_block_edit_session().map(|s| s.br_handle) != Some(definition) + || tab.scene.document.objects.get(&action) != Some(expected.as_ref()) + { + self.command_line.push_error( + crate::t!( + "The action or its Block Editor session changed; restart BACTIONSET." + ) + .as_ref(), + ); + return Task::none(); + } + let mut staged = tab.scene.document.clone(); + let result = actions::retarget(&mut staged, definition, action, &targets, frame) + .and_then(|changed| { + if changed { + staged = actions::instance::refresh(&staged, definition)?; + } + Ok(changed) + }); + match result { + Ok(changed) => { + if changed { + self.push_undo_snapshot(i, "Action selection set"); + self.apply_action_document(i, staged); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.finish_pending_history(i); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + self.command_line + .push_output(crate::t!("Action selection updated.").as_ref()); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } crate::command::CmdResult::PlaceBlockAction { document, definition, @@ -427,24 +523,9 @@ impl OpenCADStudio { }; match actions::instance::evaluate_value(&tab.scene.document, handle, parameter, local) { Ok(staged) => { - if let Some(mut staged) = staged { + if let Some(staged) = staged { self.push_undo_snapshot(i, "Block reference parameter"); - let doc = &mut self.tabs[i].scene.document; - let changes = staged - .entities() - .filter(|e| doc.get_entity(e.common().handle) != Some(*e)) - .cloned() - .collect::>(); - // Structure swapping retains the live store and its transaction recorder. - doc.swap_structure(staged.snapshot_structure()); - for entity in changes { - let h = entity.common().handle; - if let Some(current) = doc.get_entity_mut(h) { - *current = entity - } else { - doc.restore_entity_arc(std::sync::Arc::new(entity)); - } - } + self.apply_action_document(i, staged); self.tabs[i].scene.bump_geometry(); self.tabs[i].dirty = true; self.finish_pending_history(i); diff --git a/src/app/block_editor.rs b/src/app/block_editor.rs index f34480746..aa4f66f03 100644 --- a/src/app/block_editor.rs +++ b/src/app/block_editor.rs @@ -150,6 +150,22 @@ impl OpenCADStudio { candidate_handles.insert(record.block_end_handle); } } + // Action-set edits can rebuild anonymous references outside the editor + // definition. Include their entities in the single drawing save delta. + for document in [before_doc, &tab.scene.document] { + for entity in document.entities() { + if let EntityType::Insert(insert) = entity { + if document.dynamic_definition_for_insert(insert.common.handle) + == Some(session.br_handle) + { + candidate_handles.insert(insert.common.handle); + if let Some(record) = document.block_records.get(&insert.block_name) { + candidate_handles.extend(record.entity_handles.iter().copied()); + } + } + } + } + } let mut sub_handles = Vec::new(); for &h in &candidate_handles { let entity_opt = before_doc diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index d26afb245..c095af535 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,213 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +fn edit_action_set(app: &mut OpenCADStudio, action: Handle, mode: &str, targets: Vec) { + app.tabs[app.active_tab].selected_authoring = Some(action); + let _ = app.dispatch_command("BACTIONSET"); + feed(app, mode); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); +} + +#[test] +fn block_actions_set_edit_refresh_local_history_discard_save_and_drawing_history() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let i = app.active_tab; + circle(&mut app, 3.); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone(); + let targets = members(&app); + let action = actions::create( + &mut app.tabs[i].scene.document, + def, + Kind::Move, + p.handle, + &targets[..1], + glam::DVec3::new(3., 4., 0.), + ) + .unwrap(); + edit_action_set(&mut app, action, "Add", targets[1..].to_vec()); + assert_eq!( + actions::actions(&app.tabs[i].scene.document, def)[0] + .1 + .dependencies, + targets + ); + app.undo_steps(1); + assert_eq!( + actions::actions(&app.tabs[i].scene.document, def)[0] + .1 + .dependencies, + targets[..1] + ); + app.redo_steps(1); + edit_action_set(&mut app, action, "Remove", targets[..1].to_vec()); + assert_eq!( + actions::actions(&app.tabs[i].scene.document, def)[0] + .1 + .dependencies, + targets[1..] + ); + edit_action_set(&mut app, action, "New", targets[..1].to_vec()); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BCLOSE"); + app.tabs[i].scene.selected.insert(reference); + app.refresh_properties(); + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| super::block_actions::is_test_grip(g.id)) + .unwrap() + .id; + assert!(app.start_test_action_grip(reference, grip)); + feed(&mut app, "12,0"); + let check = |app: &OpenCADStudio, start: f64, center: f64| { + let doc = &app.tabs[app.active_tab].scene.document; + let EntityType::Insert(ins) = doc.get_entity(reference).unwrap() else { + panic!() + }; + let members = &doc + .block_records + .get(&ins.block_name) + .unwrap() + .entity_handles; + let EntityType::Line(line) = doc.get_entity(members[0]).unwrap() else { + panic!() + }; + let EntityType::Circle(circle) = doc.get_entity(members[1]).unwrap() else { + panic!() + }; + assert_eq!(line.start.x, start); + assert_eq!(circle.center.x, center); + }; + check(&app, 2., 3.); + begin(&mut app, reference); + let depth = app.tabs[i].history.undo_stack.len(); + edit_action_set(&mut app, action, "New", targets[1..].to_vec()); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1); + check(&app, 0., 5.); + app.undo_steps(1); + check(&app, 2., 3.); + app.redo_steps(1); + check(&app, 0., 5.); + let _ = app.dispatch_command("BEDIT_DISCARD"); + check(&app, 2., 3.); + begin(&mut app, reference); + edit_action_set(&mut app, action, "New", targets[1..].to_vec()); + let _ = app.dispatch_command("BSAVE"); + let _ = app.dispatch_command("BCLOSE"); + check(&app, 0., 5.); + app.undo_steps(1); + check(&app, 2., 3.); + assert_eq!( + actions::actions(&app.tabs[i].scene.document, def)[0] + .1 + .dependencies, + targets[..1] + ); + app.redo_steps(1); + check(&app, 0., 5.); + let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap(); + app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes)) + .read() + .unwrap(); + check(&app, 0., 5.); + assert_eq!( + actions::actions(&app.tabs[i].scene.document, def)[0] + .1 + .dependencies, + targets[1..] + ); +} + +#[test] +fn block_actions_set_frame_cancel_stale_and_context_guards() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BACTIONSET"); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "LINE" + ); + let _ = app.update(Message::CommandEscape); + begin(&mut app, reference); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Stretch)[0].handle; + let targets = members(&app); + let action = actions::create_with_frame( + &mut app.tabs[i].scene.document, + def, + Kind::Stretch, + p, + &targets, + glam::DVec3::new(3., 4., 0.), + Some([glam::DVec3::new(0.5, -1., 0.), glam::DVec3::new(2., 1., 0.)]), + ) + .unwrap(); + let original = app.tabs[i].scene.document.clone(); + let depth = app.tabs[i].history.undo_stack.len(); + let _ = app.dispatch_command("BACTIONSET"); + let name = actions::actions(&original, def)[0].1.element.name.clone(); + feed(&mut app, &name); + feed(&mut app, "Frame"); + feed(&mut app, "-1,-1"); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, original); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + app.tabs[i].selected_authoring = Some(action); + let _ = app.dispatch_command("BACTIONSET"); + feed(&mut app, "Frame"); + feed(&mut app, "-1,-1"); + feed(&mut app, "-1,1"); + assert_eq!(app.tabs[i].scene.document, original); + feed(&mut app, "0.5,1"); + assert!(app.tabs[i].active_cmd.is_none()); + assert_eq!( + actions::frame_outline(&app.tabs[i].scene.document, action)[0], + Vector3::new(-1., -1., 0.) + ); + app.undo_steps(1); + assert_eq!( + app.tabs[i].scene.document.objects[&action], + original.objects[&action] + ); + let expected = Box::new(original.objects[&action].clone()); + let _ = app.handle_block_action_result(crate::command::CmdResult::EditBlockActionSet { + document: app.tabs[i].id + 1, + definition: def, + action, + expected: expected.clone(), + targets: targets.clone(), + frame: None, + }); + assert_eq!(app.tabs[i].scene.document, original); + crate::scene::block_authoring::edit::move_part( + &mut app.tabs[i].scene.document, + def, + action, + crate::scene::block_authoring::edit::Part::Point, + Vector3::new(8., 8., 0.), + ) + .unwrap(); + let stale = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::EditBlockActionSet { + document: app.tabs[i].id, + definition: def, + action, + expected, + targets, + frame: None, + }); + assert_eq!(app.tabs[i].scene.document, stale); +} + #[test] fn block_actions_stretch_frame_selection_history_test_and_saved_grip() { use crate::scene::block_actions::{self as actions, Kind}; diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index ad4428912..abdafff86 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -579,6 +579,7 @@ impl OpenCADStudio { return self.handle_authoring_edit(result); } result @ (CmdResult::PlaceBlockAction { .. } + | CmdResult::EditBlockActionSet { .. } | CmdResult::EvaluateBlockParameter { .. }) => { return self.handle_block_action_result(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index a33024d00..a572b6bc7 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -391,6 +391,7 @@ impl OpenCADStudio { return Some(Task::none()); } match cmd { + "BACTIONSET" => Some(self.start_block_action_set()), cmd if cmd == "BACTION" || cmd.starts_with("BACTION ") || cmd == "BACTIONTOOL" diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 718d58609..3685bfb40 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -175,12 +175,18 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + if cmd == "BACTIONSET" { + if let Some(error) = self.action_set_error() { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL")) { let args = cmd.split_once(' ').map_or("", |(_, a)| a.trim()); if !args.is_empty() && crate::scene::block_actions::Kind::parse(args).is_none() { self.command_line.push_error( crate::t!( - "Supported actions: Move, Scale. Other action types are not wired yet." + "Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet." ) .as_ref(), ); diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index f145df36d..0a652c7ff 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1244,6 +1244,75 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman }); } +#[test] +fn block_actions_set_ribbon_flyout_starts_action_selection_and_uses_preselected_marker() { + with_stack(|| { + use crate::scene::block_actions::{self as actions, Kind}; + let mut app = authoring_app(); + let i = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].handle; + let targets = app.tabs[i] + .scene + .document + .block_records + .get("AUTHOR") + .unwrap() + .entity_handles + .clone(); + let a = actions::create( + &mut app.tabs[i].scene.document, + def, + Kind::Move, + p, + &targets, + glam::DVec3::new(3., 4., 0.), + ) + .unwrap(); + app.tabs[i].selected_authoring = Some(a); + app.ribbon + .set_collapse_mode(crate::ui::ribbon::CollapseMode::Collapsed); + // The top-level Manage tab has the same label as this panel opener. + // Open the existing panel, then click the new command in its real flyout. + let _ = app.update(Message::ToggleRibbonPanel("Manage".into())); + assert_eq!(app.ribbon.collapsed_open.as_deref(), Some("Manage")); + let messages = { + let mut simulator = iced_test::Simulator::with_size( + iced::Settings::default(), + VIEW_SIZE, + app.view_main(), + ); + simulator + .click("Action Selection") + .expect("action selection in Manage flyout"); + simulator.into_messages().collect::>() + }; + for message in messages { + let _ = app.update(message); + } + assert_eq!( + app.tabs[i].active_cmd.as_ref().unwrap().name(), + "BACTIONSET" + ); + assert!(app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .prompt() + .contains("New/Add/Remove")); + let _ = app.feed_command(crate::command::StepInput::Enter); + assert!(app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let before = app.tabs[i].scene.document.clone(); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[i].scene.document, before); + }); +} + #[test] fn block_actions_stretch_palette_requires_distance_parameter() { with_stack(|| { diff --git a/src/command.rs b/src/command.rs index f76a0ac9b..97143695b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,14 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + EditBlockActionSet { + document: u64, + definition: Handle, + action: Handle, + expected: Box, + targets: Vec, + frame: Option<[DVec3; 2]>, + }, EditBlockAuthoring { document: u64, definition: Handle, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs index d80dcc7ce..65bfde5f3 100644 --- a/src/modules/block_editor/action.rs +++ b/src/modules/block_editor/action.rs @@ -207,22 +207,9 @@ impl CadCommand for ActionCommand { } } fn on_preview_wires(&mut self, point: DVec3) -> Vec { - let Some(a) = self.frame.first().filter(|_| point.is_finite()) else { - return vec![]; - }; - let b = self.frame.get(1).copied().unwrap_or(point); - vec![crate::scene::WireModel::solid_f64( - "preview_stretch_frame".into(), - vec![ - a.to_array(), - [b.x, a.y, a.z], - [b.x, b.y, a.z], - [a.x, b.y, a.z], - ], - crate::scene::WireModel::CYAN, - true, - )] + frame_preview(&self.frame, point) } + fn is_selection_gathering(&self) -> bool { self.selecting } @@ -276,3 +263,22 @@ impl CadCommand for TestParameterCommand { CmdResult::Cancel } } + +/// Shared rectangular frame preview for authoring and reauthoring. +pub(super) fn frame_preview(frame: &[DVec3], point: DVec3) -> Vec { + let Some(a) = frame.first().filter(|_| point.is_finite()) else { + return vec![]; + }; + let b = frame.get(1).copied().unwrap_or(point); + vec![crate::scene::WireModel::solid_f64( + "preview_stretch_frame".into(), + vec![ + a.to_array(), + [b.x, a.y, a.z], + [b.x, b.y, a.z], + [a.x, b.y, a.z], + ], + crate::scene::WireModel::CYAN, + true, + )] +} diff --git a/src/modules/block_editor/action_set.rs b/src/modules/block_editor/action_set.rs new file mode 100644 index 000000000..6e9affcc5 --- /dev/null +++ b/src/modules/block_editor/action_set.rs @@ -0,0 +1,243 @@ +//! Edit native action selections without replacing their graph nodes. +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, PointPickContext}; +use crate::scene::block_actions::{self as actions, Kind}; +use codec::objects::ObjectType; +use codec::{CadDocument, Handle}; +use glam::DVec3; + +inventory::submit!(crate::command::CommandRegistration { + names: &["BACTIONSET"] +}); + +struct Candidate { + handle: Handle, + name: String, + at: DVec3, + targets: Vec, + stretch: bool, + expected: ObjectType, +} +#[derive(Clone, Copy)] +enum Mode { + New, + Add, + Remove, + Frame, +} +pub(crate) struct ActionSetCommand { + document: u64, + definition: Handle, + candidates: Vec, + chosen: Option, + mode: Option, + picked: Vec, + frame: Vec, + context: Option, +} +impl ActionSetCommand { + pub(crate) fn new( + document: u64, + definition: Handle, + doc: &CadDocument, + selected: Option, + ) -> Self { + let candidates = actions::actions(doc, definition) + .into_iter() + .map(|(handle, a, kind, _)| Candidate { + handle, + name: a.element.name.clone(), + at: actions::v(a.display_location), + targets: a.dependencies.clone(), + stretch: kind == Kind::Stretch, + expected: doc.objects[&handle].clone(), + }) + .collect::>(); + let chosen = candidates.iter().position(|c| Some(c.handle) == selected); + Self { + document, + definition, + candidates, + chosen, + mode: None, + picked: vec![], + frame: vec![], + context: None, + } + } + fn finish(&self) -> CmdResult { + let c = &self.candidates[self.chosen.unwrap()]; + let targets = match self.mode.unwrap() { + Mode::New => self.picked.clone(), + Mode::Add => c.targets.iter().chain(&self.picked).copied().collect(), + Mode::Remove => c + .targets + .iter() + .copied() + .filter(|h| !self.picked.contains(h)) + .collect(), + Mode::Frame => c.targets.clone(), + }; + CmdResult::EditBlockActionSet { + document: self.document, + definition: self.definition, + action: c.handle, + expected: Box::new(c.expected.clone()), + targets, + frame: (self.frame.len() == 2).then(|| [self.frame[0], self.frame[1]]), + } + } +} +impl CadCommand for ActionSetCommand { + fn name(&self) -> &'static str { + "BACTIONSET" + } + fn input_kind(&self) -> InputKind { + if self.chosen.is_none() { + InputKind::FreeText + } else if self.mode.is_none() { + InputKind::SingleToken + } else { + InputKind::Point + } + } + fn prompt(&self) -> String { + crate::t!(if self.chosen.is_none() { + "BACTIONSET Pick an action marker or enter its name:" + } else if self.mode.is_none() && self.candidates[self.chosen.unwrap()].stretch { + "BACTIONSET [New/Add/Remove/Frame] :" + } else if self.mode.is_none() { + "BACTIONSET [New/Add/Remove] :" + } else if matches!(self.mode, Some(Mode::Frame)) && self.frame.is_empty() { + "BACTIONSET Specify first corner of replacement stretch frame:" + } else if matches!(self.mode, Some(Mode::Frame)) { + "BACTIONSET Specify opposite corner of replacement stretch frame:" + } else { + "BACTIONSET Select objects, then press Enter:" + }) + .into_owned() + } + fn options(&self) -> Vec { + if self.chosen.is_some() && self.mode.is_none() { + let mut options = vec![ + CmdOption::new("New", "New"), + CmdOption::new("Add", "Add"), + CmdOption::new("Remove", "Remove"), + ]; + if self.candidates[self.chosen.unwrap()].stretch { + options.push(CmdOption::new("Frame", "Frame")); + } + options + } else { + vec![] + } + } + fn on_text_input(&mut self, text: &str) -> Option { + if self.chosen.is_none() { + let matches = self + .candidates + .iter() + .enumerate() + .filter(|(_, c)| c.name.eq_ignore_ascii_case(text)) + .map(|(i, _)| i) + .collect::>(); + return Some(if matches.len() == 1 { + self.chosen = Some(matches[0]); + CmdResult::NeedPoint + } else { + CmdResult::ReportError( + crate::t!("Enter a unique action name or pick its marker.").into_owned(), + ) + }); + } + if self.mode.is_none() { + let mode = match text.to_ascii_uppercase().as_str() { + "NEW" | "N" => Mode::New, + "ADD" | "A" => Mode::Add, + "REMOVE" | "R" => Mode::Remove, + "FRAME" | "F" if self.candidates[self.chosen.unwrap()].stretch => Mode::Frame, + _ => { + return Some(CmdResult::ReportError( + crate::t!("Choose an available selection-editing option.").into_owned(), + )) + } + }; + self.mode = Some(mode); + return Some(CmdResult::DeselectAndContinue); + } + None + } + fn wants_point_pick_context(&self) -> bool { + self.chosen.is_none() + } + fn set_point_pick_context(&mut self, context: Option) { + self.context = context; + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.chosen.is_none() { + let Some(c) = self.context else { + return CmdResult::ReportError(crate::t!("Enter the action name.").into_owned()); + }; + let project = + |p| crate::scene::pick::hit_test::world_to_screen(p, c.view, c.eye, c.bounds); + let at = project(point); + if let Some((i, _)) = self + .candidates + .iter() + .enumerate() + .map(|(i, a)| (i, project(a.at).distance(at))) + .filter(|(_, d)| d.is_finite() && *d <= c.aperture_px.max(10.)) + .min_by(|(_, a), (_, b)| a.total_cmp(b)) + { + self.chosen = Some(i); + return CmdResult::NeedPoint; + } + return CmdResult::ReportError(crate::t!("Pick an action marker.").into_owned()); + } + if matches!(self.mode, Some(Mode::Frame)) { + let plane = self.candidates[self.chosen.unwrap()].at.z; + if !point.is_finite() || (point.z - plane).abs() > 1e-8 { + return CmdResult::ReportError( + crate::t!("Specify a finite corner in the action's XY plane.").into_owned(), + ); + } + if self + .frame + .first() + .is_some_and(|a| (a.x - point.x).abs() < 1e-9 || (a.y - point.y).abs() < 1e-9) + { + return CmdResult::ReportError( + crate::t!("The stretch frame needs a nonzero width and height.").into_owned(), + ); + } + self.frame.truncate(1); + self.frame.push(point); + if self.frame.len() == 2 { + return self.finish(); + } + } + CmdResult::NeedPoint + } + fn is_selection_gathering(&self) -> bool { + matches!(self.mode, Some(Mode::New | Mode::Add | Mode::Remove)) + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + self.picked = handles; + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if self.chosen.is_none() { + return CmdResult::Cancel; + } + if self.mode.is_none() { + return self.on_text_input("New").unwrap(); + } + if self.is_selection_gathering() { + self.finish() + } else { + CmdResult::Cancel + } + } + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + super::action::frame_preview(&self.frame, point) + } +} diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 06fa45da9..53a849166 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -7,6 +7,7 @@ use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, pub mod visibility_parameter; pub mod action; +pub mod action_set; pub mod point_parameter; pub mod single_parameter; pub mod distance_parameter; @@ -90,6 +91,11 @@ impl CadModule for BlockEditorModule { // parameters as the Parametric tab. groups.extend(ParametricModule.ribbon_groups().iter().cloned()); if let Some(manage) = groups.iter_mut().find(|group| group.title == "Manage") { + manage.tools.push(command( + "BACTIONSET", + "Action Selection", + include_bytes!("../../../assets/icons/blocks/select_objects.svg"), + )); manage.tools.insert( 0, command( diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs index 5756df495..e1d762b01 100644 --- a/src/scene/block_actions.rs +++ b/src/scene/block_actions.rs @@ -510,6 +510,89 @@ pub(crate) fn create_with_frame( Ok(handle) } +/// Retarget an existing native action, retaining identity, graph and options. +pub(crate) fn retarget( + doc: &mut CadDocument, + definition: Handle, + handle: Handle, + targets: &[Handle], + frame: Option<[DVec3; 2]>, +) -> Result { + let (_, action, kind, id) = actions(doc, definition) + .into_iter() + .find(|(h, _, _, _)| *h == handle) + .ok_or("Select an action belonging to this block.")?; + let mut targets = targets.to_vec(); + targets.sort(); + targets.dedup(); + if targets.is_empty() { + return Err("An action needs at least one object."); + } + let members = &doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The definition is unavailable.")? + .entity_handles; + if targets.iter().any(|h| { + !members.contains(h) + || doc + .get_entity(*h) + .is_none_or(|e| e.common().owner_handle != definition || !supported(e)) + }) { + return Err("Select supported drawing objects belonging to this block."); + } + let old_targets = action.dependencies.clone(); + let mut staged = doc.clone(); + let ObjectType::DynamicBlock(o) = staged.objects.get_mut(&handle).unwrap() else { + unreachable!() + }; + match &mut o.data { + DynamicBlockData::StretchAction(a) => { + let plane = parameters(doc, definition, Kind::Stretch) + .into_iter() + .find(|p| p.id == id) + .ok_or("The Stretch parameter is unavailable.")? + .end + .z; + stretch::validate_options(a)?; + if let Some(corners) = frame { + let (points, handles) = stretch::bind(doc, &targets, corners, plane)?; + a.points = points; + a.handles = handles; + } else { + a.handles.retain(|h| targets.contains(&h.handle)); + let mut retained = a.clone(); + retained.action.dependencies.retain(|h| targets.contains(h)); + stretch::validate(doc, &retained)?; + let added = targets + .iter() + .copied() + .filter(|h| !old_targets.contains(h)) + .collect::>(); + a.handles + .extend(stretch::bind_existing(doc, &added, &a.points, plane)?); + a.handles.sort_by_key(|h| h.handle); + } + a.action.dependencies = targets; + } + DynamicBlockData::MoveAction(a) => a.action.dependencies = targets, + DynamicBlockData::FlipAction(a) => a.action.dependencies = targets, + DynamicBlockData::ScaleAction(a) | DynamicBlockData::RotateAction(a) => { + a.action.action.dependencies = targets + } + _ => return Err("This action's selection cannot be edited yet."), + } + if frame.is_some() && kind != Kind::Stretch { + return Err("Only Stretch actions have a stretch frame."); + } + if staged == *doc { + return Ok(false); + } + *doc = staged; + Ok(true) +} + pub(crate) fn actions( doc: &CadDocument, definition: Handle, @@ -681,6 +764,30 @@ pub(crate) fn evaluate_value( definition: Handle, parameter: Handle, input: Value, +) -> Result { + evaluate_impl(doc, baseline, definition, parameter, input, false) +} + +pub(crate) fn rebuild( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, +) -> Result<(), &'static str> { + let Some(p) = all_parameters(baseline, definition).into_iter().next() else { + return Ok(()); + }; + let input = value(doc, &p)?; + evaluate_impl(doc, baseline, definition, p.handle, input, true)?; + Ok(()) +} + +fn evaluate_impl( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, + parameter: Handle, + input: Value, + force: bool, ) -> Result { let graph = authoring::graph(baseline, definition)? .ok_or("The block authoring graph is unavailable.")?; @@ -722,11 +829,13 @@ pub(crate) fn evaluate_value( value => value, }; let previous = value(doc, &p)?; - if match (previous, input) { - (Value::Flip(a), Value::Flip(b)) => a == b, - (Value::Point(a), Value::Point(b)) => a.distance(b) < 1e-10, - _ => false, - } { + if !force + && match (previous, input) { + (Value::Flip(a), Value::Flip(b)) => a == b, + (Value::Point(a), Value::Point(b)) => a.distance(b) < 1e-10, + _ => false, + } + { return Ok(false); } let mut staged = doc.clone(); diff --git a/src/scene/block_actions/instance.rs b/src/scene/block_actions/instance.rs index 60fa7f9bb..cd3800bbb 100644 --- a/src/scene/block_actions/instance.rs +++ b/src/scene/block_actions/instance.rs @@ -301,6 +301,47 @@ pub(crate) fn evaluate_value( return Ok(None); } let value = super::value(&evaluated, selected)?; + let mut result = materialize(doc, insert, def, &evaluated)?; + save(&mut result, insert, selected, value)?; + Ok(Some(result)) +} + +/// Refresh existing evaluated references after an authoring change. +pub(crate) fn refresh(doc: &CadDocument, def: Handle) -> Result { + let mut staged = doc.clone(); + let inserts = doc + .entities() + .filter_map(|e| match e { + EntityType::Insert(i) + if doc.dynamic_definition_for_insert(i.common.handle) == Some(def) + && doc + .block_records + .get(&i.block_name) + .is_some_and(|b| b.handle != def) => + { + Some(i.common.handle) + } + _ => None, + }) + .collect::>(); + for insert in inserts { + let mut evaluated = staged.clone(); + for p in all_parameters(&staged, def) { + let v = value(&staged, insert, &p)?; + super::set_value(&mut evaluated, &p, v)?; + } + super::rebuild(&mut evaluated, &staged, def)?; + staged = materialize(&staged, insert, def, &evaluated)?; + } + Ok(staged) +} + +fn materialize( + doc: &CadDocument, + insert: Handle, + def: Handle, + evaluated: &CadDocument, +) -> Result { let mut scene = crate::scene::Scene::new(); scene.document = doc.clone(); scene.dynamic_representation(insert); @@ -316,8 +357,8 @@ pub(crate) fn evaluate_value( { return Err("Could not isolate the dynamic block reference."); } - // Evaluate only temporary definition members, then copy the affected geometry - // into this reference's representation, preserving identity and visibility. + // Rebuild all members so removed targets lose their old action effects. + // Preserve each representation member's identity and visibility. let source = doc .block_records .iter() @@ -327,10 +368,6 @@ pub(crate) fn evaluate_value( if source.entity_handles.len() != target.entity_handles.len() { return Err("The reference topology no longer matches its definition."); } - let affected = actions(doc, def) - .iter() - .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) - .collect::>(); let pairs = source .entity_handles .iter() @@ -338,9 +375,6 @@ pub(crate) fn evaluate_value( .zip(target.entity_handles.iter().copied()) .collect::>(); for (from, to) in pairs { - if !affected.contains(&from) { - continue; - } let mut e = evaluated .get_entity(from) .cloned() @@ -355,6 +389,5 @@ pub(crate) fn evaluate_value( *e.common_mut() = target.common().clone(); *target = e; } - save(&mut scene.document, insert, selected, value)?; - Ok(Some(scene.document)) + Ok(scene.document) } diff --git a/src/scene/block_actions/stretch.rs b/src/scene/block_actions/stretch.rs index ca500bc65..f5818a7c6 100644 --- a/src/scene/block_actions/stretch.rs +++ b/src/scene/block_actions/stretch.rs @@ -39,40 +39,68 @@ pub(crate) fn bind( if high.x - low.x < 1e-9 || high.y - low.y < 1e-9 { return Err("The stretch frame needs a nonzero width and height."); } + let points = vec![Vector2::new(low.x, low.y), Vector2::new(high.x, high.y)]; + let bindings = bind_existing(doc, targets, &points, plane)?; + Ok((points, bindings)) +} + +pub(crate) fn bind_existing( + doc: &CadDocument, + targets: &[Handle], + frame: &[Vector2], + plane: f64, +) -> Result, &'static str> { + let polygon = outline(frame, plane); + if polygon.len() < 3 || polygon.iter().any(|p| !p.x.is_finite() || !p.y.is_finite()) { + return Err("The Stretch frame is invalid."); + } + let inside = |p: DVec3| { + let mut crossing = false; + for (a, b) in polygon + .iter() + .zip(polygon.iter().cycle().skip(1)) + .take(polygon.len()) + { + let (dx, dy) = (b.x - a.x, b.y - a.y); + let cross = (p.x - a.x) * dy - (p.y - a.y) * dx; + if cross.abs() < 1e-8 + && p.x >= a.x.min(b.x) - 1e-8 + && p.x <= a.x.max(b.x) + 1e-8 + && p.y >= a.y.min(b.y) - 1e-8 + && p.y <= a.y.max(b.y) + 1e-8 + { + return true; + } + if (a.y > p.y) != (b.y > p.y) && p.x < (b.x - a.x) * (p.y - a.y) / (b.y - a.y) + a.x { + crossing = !crossing; + } + } + crossing + }; let mut bindings = vec![]; - let mut unique = targets.to_vec(); - unique.sort(); - unique.dedup(); - for handle in unique { - let entity = doc - .get_entity(handle) - .ok_or("A Stretch target is unavailable.")?; - let points = control_points(entity)?; + for &handle in targets { + let points = control_points( + doc.get_entity(handle) + .ok_or("A Stretch target is unavailable.")?, + )?; if points.iter().any(|p| (p.z - plane).abs() > 1e-8) { return Err("Stretch targets must lie in the parameter's XY plane."); } let indexes = points - .iter() + .into_iter() .enumerate() - .filter(|(_, p)| p.x >= low.x && p.x <= high.x && p.y >= low.y && p.y <= high.y) + .filter(|(_, p)| inside(*p)) .map(|(i, _)| i as i32) .collect::>(); if !indexes.is_empty() { bindings.push(BlockStretchHandle { handle, indexes }); } } - Ok(( - vec![Vector2::new(low.x, low.y), Vector2::new(high.x, high.y)], - bindings, - )) + Ok(bindings) } -pub(crate) fn validate(doc: &CadDocument, a: &BlockStretchAction) -> Result<(), &'static str> { - if a.points.len() < 2 - || a.points - .iter() - .any(|p| !p.x.is_finite() || !p.y.is_finite()) - || a.offsets.distance_multiplier != 1. +pub(crate) fn validate_options(a: &BlockStretchAction) -> Result<(), &'static str> { + if a.offsets.distance_multiplier != 1. || a.offsets.angle_offset != 0. || a.offsets.flags != 0 || !a.codes.is_empty() @@ -83,6 +111,18 @@ pub(crate) fn validate(doc: &CadDocument, a: &BlockStretchAction) -> Result<(), { return Err("This Stretch action uses options or parameter bindings not supported yet."); } + Ok(()) +} + +pub(crate) fn validate(doc: &CadDocument, a: &BlockStretchAction) -> Result<(), &'static str> { + validate_options(a)?; + if a.points.len() < 2 + || a.points + .iter() + .any(|p| !p.x.is_finite() || !p.y.is_finite()) + { + return Err("The Stretch frame is invalid."); + } let mut seen = std::collections::BTreeSet::new(); for binding in &a.handles { if !a.action.dependencies.contains(&binding.handle) || !seen.insert(binding.handle) { diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs index 129ff0f75..b41ccf1dc 100644 --- a/src/scene/block_actions/tests.rs +++ b/src/scene/block_actions/tests.rs @@ -866,3 +866,191 @@ fn block_actions_stretch_polar_xy_and_reframed_marker_keep_native_bindings() { assert_eq!(a.handles[0].indexes, vec![1]); } } + +#[test] +fn block_actions_retarget_preserves_identity_and_native_roundtrip() { + for kind in [Kind::Move, Kind::Scale, Kind::Rotate, Kind::Flip] { + let (mut doc, def, targets) = fixture(); + let p = match kind { + Kind::Rotate => orientation( + &mut doc, + def, + super::super::block_orientation_parameter::Kind::Rotation, + ), + Kind::Flip => orientation( + &mut doc, + def, + super::super::block_orientation_parameter::Kind::Flip, + ), + _ => linear(&mut doc, def), + }; + let a = create( + &mut doc, + def, + kind, + p, + &targets[..1], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + let graph = authoring::graph(&doc, def).unwrap().unwrap(); + let graph_before = doc.objects[&graph].clone(); + assert!(retarget(&mut doc, def, a, &targets[1..], None).unwrap()); + assert_eq!(doc.objects[&graph], graph_before); + let (_, action, k, _) = actions(&doc, def) + .into_iter() + .find(|(h, _, _, _)| *h == a) + .unwrap(); + assert_eq!(k, kind); + assert_eq!(action.dependencies, targets[1..]); + assert_eq!(action.display_location, Vector3::new(3., 4., 0.)); + assert!(!retarget(&mut doc, def, a, &targets[1..], None).unwrap()); + let before = doc.clone(); + assert!(retarget(&mut doc, def, a, &[], None).is_err()); + assert!(retarget(&mut doc, def, a, &[Handle::new(999999)], None).is_err()); + assert_eq!(doc, before); + for saved in [ + DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(), + DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(), + ] { + assert_eq!(saved.objects[&a], doc.objects[&a]); + } + } +} + +#[test] +fn block_actions_retarget_stretch_retains_vertices_and_replaces_frame() { + let (mut doc, def, targets, p, a) = stretch_fixture(); + // Removing members must retain the old vertex bindings of retained members. + retarget(&mut doc, def, a, &targets[..1], None).unwrap(); + let EntityType::Line(line) = doc.get_entity_mut(targets[0]).unwrap() else { + panic!() + }; + line.end.x = 20.; // Now outside the old frame; retained binding must not change. + retarget(&mut doc, def, a, &targets[..2], None).unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(12., 0., 0.)).unwrap(); + let EntityType::Line(line) = doc.get_entity(targets[0]).unwrap() else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + assert_eq!(line.end.x, 22.); + let mut doc = baseline; + retarget( + &mut doc, + def, + a, + &targets[..2], + Some([DVec3::new(-1., -1., 0.), DVec3::new(1., 4., 0.)]), + ) + .unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(12., 0., 0.)).unwrap(); + let EntityType::Line(line) = doc.get_entity(targets[0]).unwrap() else { + panic!() + }; + assert_eq!(line.start.x, 2.); + assert_eq!(line.end.x, 20.); + let saved = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&baseline).unwrap())) + .read() + .unwrap(); + assert_eq!(saved.objects[&a], baseline.objects[&a]); + let mut polygon = baseline.clone(); + reframe( + &mut polygon, + def, + &Transform::from_translation(Vector3::ZERO), + ); + retarget(&mut polygon, def, a, &targets, None).unwrap(); + assert_eq!(frame_outline(&polygon, a).len(), 4); +} + +#[test] +fn block_actions_retarget_refresh_resets_old_targets_and_keeps_each_reference_value() { + let (mut doc, def, targets) = fixture(); + let p = linear(&mut doc, def); + let a = create(&mut doc, def, Kind::Move, p, &targets[..1], DVec3::ZERO).unwrap(); + let first = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::ZERO, + ))) + .unwrap(); + let second = doc + .add_entity(EntityType::Insert(codec::entities::Insert::new( + "ACTIONS", + Vector3::new(40., 0., 0.), + ))) + .unwrap(); + doc = instance::evaluate(&doc, first, p, DVec3::new(18., 0., 0.)) + .unwrap() + .unwrap(); + doc = instance::evaluate(&doc, second, p, DVec3::new(22., 0., 0.)) + .unwrap() + .unwrap(); + let mut scene = Scene::new(); + scene.document = doc; + let copied = scene.copy_entities( + &[first], + &crate::command::EntityTransform::Translate(DVec3::new(80., 0., 0.)), + )[0]; + let mut doc = scene.document; + let original_line = doc.get_entity(targets[0]).unwrap().clone(); + retarget(&mut doc, def, a, &targets[1..], None).unwrap(); + let doc = instance::refresh(&doc, def).unwrap(); + for (reference, delta) in [(first, 8.), (second, 12.), (copied, 8.)] { + let EntityType::Insert(i) = doc.get_entity(reference).unwrap() else { + panic!() + }; + let members = &doc.block_records.get(&i.block_name).unwrap().entity_handles; + assert_eq!(start(&doc, members[0]), Vector3::UNIT_X); + let EntityType::Circle(circle) = doc.get_entity(members[1]).unwrap() else { + panic!() + }; + assert_eq!(circle.center, Vector3::new(4. + delta, 3., 0.)); + assert_eq!( + instance::current(&doc, reference, ¶meters(&doc, def, Kind::Move)[0]).unwrap(), + DVec3::new(10. + delta, 0., 0.) + ); + } + assert_eq!(doc.get_entity(targets[0]).unwrap(), &original_line); + // Re-evaluating a reference at a different value also resets removed targets. + let changed = instance::evaluate(&doc, first, p, DVec3::new(14., 0., 0.)) + .unwrap() + .unwrap(); + assert_eq!(instance_line(&changed, first), Vector3::UNIT_X); +} + +#[test] +fn block_actions_retarget_stretch_can_remove_stale_binding_or_replace_it() { + let (mut doc, def, targets, _, a) = stretch_fixture(); + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(&a).unwrap() else { + panic!() + }; + let DynamicBlockData::StretchAction(action) = &mut o.data else { + panic!() + }; + action.handles[0].indexes = vec![99]; + let mut replace = doc.clone(); + retarget(&mut doc, def, a, &targets[1..], None).unwrap(); + retarget( + &mut replace, + def, + a, + &targets, + Some([DVec3::new(9., -1., 0.), DVec3::new(11., 4., 0.)]), + ) + .unwrap(); + let ObjectType::DynamicBlock(o) = &replace.objects[&a] else { + panic!() + }; + let DynamicBlockData::StretchAction(action) = &o.data else { + panic!() + }; + assert_eq!(action.handles[0].indexes, vec![1]); +}