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]); +}