From f69aefc3dfb04bab8e6c32315ebd129578fdf210 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 16:41:22 +0100 Subject: [PATCH] feat(block-editor): author Move and Scale actions with Test Block evaluation Add BACTION/BACTIONTOOL workflows for compatible parameter endpoints, selected geometry and native action markers. Persist Move/Scale records, target handles and evaluation graph bindings; reuse shared adjacency editing with Lookup and the existing palette, tooltips and selection UI. Evaluate endpoint changes in the disposable Test Block document from immutable starting geometry, with geometry/grip undo, source isolation and transformed-reference support. Preserve visibility representations and their native Basepoint anchor. Refuse unsupported options, value constraints and shared test references. This milestone supports default Move and uniform Scale on basic drawing entities. Other actions, custom options, live preview and ordinary drawing-reference evaluation remain pending. Validation: self-contained synthetic DWG/DXF roundtrips, graph adjacency, command cancellation/history, Test Block isolation/undo, transformed references, visibility/Basepoint and real-view palette/viewport input checks; broader block and dock suites. Keep action object selection active across mouse picks/removals and advance only on Enter. Cover empty selection, multiple targets, Shift removal, marker placement and Move/Scale Test Block evaluation using viewport mouse events. Verified 10 focused, 320 block and 39 dock tests (6 existing opt-in block checks ignored). --- src/app/block_actions.rs | 265 ++++++++ src/app/block_editor_tests.rs | 386 ++++++++++++ src/app/block_test.rs | 11 +- src/app/command_driver/mod.rs | 4 + src/app/commands/bvisibility.rs | 11 + src/app/commands/mod.rs | 16 + src/app/dock/tests.rs | 138 +++++ src/app/dock/view.rs | 2 + src/app/mod.rs | 1 + src/app/properties.rs | 1 + src/app/update/viewport.rs | 14 +- src/app/view/mod.rs | 14 + src/command.rs | 14 + src/modules/block_editor/action.rs | 203 +++++++ src/modules/block_editor/mod.rs | 1 + src/scene/block_actions.rs | 568 ++++++++++++++++++ src/scene/block_actions/tests.rs | 214 +++++++ src/scene/block_authoring.rs | 1 + .../graph.rs | 32 +- src/scene/block_lookup.rs | 10 +- src/scene/block_visibility.rs | 13 +- src/scene/camera_ops.rs | 5 + src/scene/mod.rs | 2 + src/scene/modify.rs | 7 +- src/ui/window/authoring_palettes.rs | 20 +- 25 files changed, 1914 insertions(+), 39 deletions(-) create mode 100644 src/app/block_actions.rs create mode 100644 src/modules/block_editor/action.rs create mode 100644 src/scene/block_actions.rs create mode 100644 src/scene/block_actions/tests.rs rename src/scene/{block_lookup => block_authoring}/graph.rs (86%) diff --git a/src/app/block_actions.rs b/src/app/block_actions.rs new file mode 100644 index 000000000..b838796a4 --- /dev/null +++ b/src/app/block_actions.rs @@ -0,0 +1,265 @@ +use super::{Message, OpenCADStudio}; +use crate::command::CadCommand; +use crate::scene::block_actions::{self as actions, Kind}; +use codec::{EntityType, Handle}; +use iced::Task; + +// These grips start an ordinary point-input command, keeping snapping, typed +// coordinates and Escape on the command driver's existing path. +const TEST_GRIP_START: usize = usize::MAX / 2; +pub(super) fn is_test_grip(id: usize) -> bool { + (TEST_GRIP_START..usize::MAX).contains(&id) +} +impl OpenCADStudio { + pub(in crate::app) fn action_error(&self, kind: Option) -> Option<&'static str> { + let tab = &self.tabs[self.active_tab]; + let Some(s) = tab.active_block_edit_session() else { + return Some("Open a block in the Block Editor before adding actions."); + }; + if let Some(e) = + crate::scene::block_point_parameter::placement_error(&tab.scene.document, s.br_handle) + { + return Some(e); + } + if actions::parameters(&tab.scene.document, s.br_handle, kind.unwrap_or(Kind::Move)) + .is_empty() + { + return Some("Place a compatible parameter before adding this action."); + } + None + } + pub(in crate::app) fn start_block_action(&mut self, args: &str) -> Task { + let kind = if args.is_empty() { + None + } else { + Kind::parse(args) + }; + if !args.is_empty() && kind.is_none() { + self.command_line.push_error( + crate::t!("Supported actions: Move, Scale. Other action types are not wired yet.") + .as_ref(), + ); + return Task::none(); + } + if let Some(e) = self.action_error(kind) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + let tab = &self.tabs[self.active_tab]; + let definition = tab.active_block_edit_session().unwrap().br_handle; + let command = crate::modules::block_editor::action::ActionCommand::new( + tab.id, + definition, + kind, + actions::parameters(&tab.scene.document, definition, kind.unwrap_or(Kind::Move)), + ); + 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() + } + pub(in crate::app) fn handle_block_action_result( + &mut self, + result: crate::command::CmdResult, + ) -> Task { + let i = self.active_tab; + match result { + crate::command::CmdResult::PlaceBlockAction { + document, + definition, + kind, + parameter, + targets, + location, + } => { + if self.tabs[i].id != document + || self.tabs[i] + .active_block_edit_session() + .map(|s| s.br_handle) + != Some(definition) + { + self.command_line.push_error( + crate::t!("The action's Block Editor session is no longer active.") + .as_ref(), + ); + return Task::none(); + } + let mut staged = self.tabs[i].scene.document.clone(); + match actions::create(&mut staged, definition, kind, parameter, &targets, location) + { + Ok(_) => { + self.push_undo_snapshot(i, &format!("{} action", kind.name())); + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.finish_pending_history(i); + self.refresh_properties(); + self.command_line.push_output(crate::tf!("{} action attached. Open Test Block and click the parameter grip to test it.",crate::t!(kind.name())).as_ref()); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + crate::command::CmdResult::EvaluateBlockParameter { + document, + definition, + parameter, + position, + } => { + if self.tabs[i].id != document { + return Task::none(); + } + let Some(test) = self.tabs[i].block_test.as_ref() else { + return Task::none(); + }; + if test.definition != definition { + return Task::none(); + } + let Some(EntityType::Insert(insert)) = + self.tabs[i].scene.document.get_entity(test.insert) + else { + return Task::none(); + }; + let doc = &self.tabs[i].scene.document; + if doc.entities().any(|e|matches!(e,EntityType::Insert(other) if other.common.handle!=test.insert && (other.block_name==insert.block_name||doc.dynamic_definition_for_insert(other.common.handle)==Some(definition)))) { + self.command_line.push_error(crate::t!("Test actions with one reference in Test Block. Remove the extra references or reopen Test Block.").as_ref());return Task::none(); + } + let Some(inverse) = + crate::scene::inverse_affine(&crate::scene::render_graph::insert_transform( + &self.tabs[i].scene.document, + insert, + )) + else { + self.command_line.push_error( + crate::t!("The test reference has a singular transform.").as_ref(), + ); + return Task::none(); + }; + let local = actions::v(inverse.apply(actions::native(position))); + let representation = insert.block_name.clone(); + let mut staged = self.tabs[i].scene.document.clone(); + match actions::evaluate(&mut staged, &test.baseline, definition, parameter, local) { + Ok(changed) => { + if changed { + if let Err(e) = actions::sync_representation( + &mut staged, + definition, + &representation, + ) { + self.command_line.push_error(crate::t!(e).as_ref()); + return Task::none(); + } + self.finish_pending_history(i); + let before = &self.tabs[i].scene.document; + let entities = before + .entities() + .filter_map(|e| { + let h = e.common().handle; + (staged.get_entity(h) != Some(e)) + .then(|| (h, before.get_entity_arc(h).unwrap())) + }) + .collect(); + let objects = before + .objects + .iter() + .filter(|(h, o)| staged.objects.get(h) != Some(*o)) + .map(|(h, o)| (*h, o.clone())) + .collect(); + let dirty_before = self.tabs[i].dirty; + self.tabs[i].scene.document = staged; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.push_entity_group_history( + i, + "Test block parameter", + entities, + objects, + dirty_before, + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } + Err(e) => self.command_line.push_error(crate::t!(e).as_ref()), + } + } + _ => unreachable!(), + } + Task::none() + } + pub(super) fn refresh_test_action_grips(&mut self, wo: [f64; 3]) { + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return; + }; + if tab.selected_handle != Some(test.insert) { + return; + } + let Some(EntityType::Insert(insert)) = tab.scene.document.get_entity(test.insert) else { + return; + }; + let transform = crate::scene::render_graph::insert_transform(&tab.scene.document, insert); + let mut grips = vec![]; + let all = actions::actions(&test.baseline, test.definition); + for (index, p) in actions::parameters(&test.baseline, test.definition, Kind::Move) + .into_iter() + .enumerate() + { + if !all.iter().any(|(_, _, _, id)| *id == p.id) { + continue; + } + let world = actions::v( + transform.apply(actions::native(actions::current(&tab.scene.document, &p))), + ) - glam::DVec3::from_array(wo); + grips.push(crate::scene::model::object::GripDef { + id: TEST_GRIP_START + index, + world, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: None, + axis: None, + }); + } + let handle = test.insert; + let tab = &mut self.tabs[i]; + for grip in grips { + tab.selected_grips.push(grip); + tab.selected_grip_handles.push(handle); + } + } + pub(super) fn start_test_action_grip(&mut self, handle: Handle, id: usize) -> bool { + if !is_test_grip(id) { + return false; + } + let i = self.active_tab; + let tab = &self.tabs[i]; + let Some(test) = tab.block_test.as_ref() else { + return false; + }; + if handle != test.insert { + return false; + } + if tab.scene.is_layer_locked(handle) { + return true; + } + let Some(p) = actions::parameters(&test.baseline, test.definition, Kind::Move) + .get(id - TEST_GRIP_START) + .cloned() + else { + return false; + }; + let command = crate::modules::block_editor::action::TestParameterCommand { + document: tab.id, + definition: test.definition, + parameter: p.handle, + }; + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.grip_hover = None; + self.grip_popup = None; + true + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 4a6255703..54c733751 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,392 @@ fn feed(app: &mut OpenCADStudio, text: &str) { let _ = app.update(Message::CommandSubmit); } +#[test] +fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { + use crate::scene::block_actions::{self as actions, Kind}; + for (kind, command) in [(Kind::Move, "BACTION MOVE"), (Kind::Scale, "BACTION SCALE")] { + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("CIRCLE 0.5,0.5 0.2"); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 2,0 1,-0.2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + app.tabs[source].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[source] + .scene + .camera + .borrow_mut() + .fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(2.5, 1.5, 0.), + 1600. / 900., + ); + let _ = app.dispatch_command(command); + let click = |app: &mut OpenCADStudio, point: [f64; 3], modifiers: &str| { + let scene = &app.tabs[app.active_tab].scene; + let (vw, vh) = scene.selection.borrow().view.vp_size; + let tile = scene.active_model_tile_bounds(vw, vh); + let screen = scene + .camera + .borrow() + .project( + glam::DVec3::from_array(point), + iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }, + ) + .unwrap(); + let cursor = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + let previous = (app.ctrl_down, app.shift_down); + app.ctrl_down = modifiers.contains("ctrl"); + app.shift_down = modifiers.contains("shift"); + let _ = app.update(Message::ViewportLeftPressAt(cursor)); + let _ = app.update(Message::ViewportLeftRelease); + (app.ctrl_down, app.shift_down) = previous; + }; + click(&mut app, [2., 0., 0.], ""); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + feed(&mut app, ""); // Empty selection does not cancel or advance. + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + for (point, modifiers, count) in [ + ([0.5, 0., 0.], "", 1), + ([0.5, 0.7, 0.], "", 2), + ([0.5, 0., 0.], "shift", 1), + ([0.5, 0., 0.], "", 2), + ] { + click(&mut app, point, modifiers); + assert!( + app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "every mouse pick/removal stays in object selection" + ); + assert_eq!(app.tabs[source].scene.selected.len(), count); + assert!(actions::actions(&app.tabs[source].scene.document, definition).is_empty()); + } + feed(&mut app, ""); + assert!(!app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + click(&mut app, [1.5, 1., 0.], ""); + assert!(app.tabs[source].active_cmd.is_none()); + let authored = actions::actions(&app.tabs[source].scene.document, definition); + assert_eq!(authored.len(), 1); + assert_eq!(authored[0].2, kind); + assert_eq!(authored[0].1.dependencies.len(), 2); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.5, -0.5, 0.), + glam::DVec3::new(4.5, 1.5, 0.), + 1600. / 900., + ); + click(&mut app, [0.5, 0., 0.], ""); + click(&mut app, [2., 0., 0.], ""); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c| c.name()), + Some("BTESTBLOCK") + ); + click(&mut app, [4., 0., 0.], ""); + assert!(app.tabs[i].active_cmd.is_none()); + let circle = app.tabs[i] + .scene + .document + .entities_in_block("EDIT_ME") + .find_map(|e| { + if let EntityType::Circle(c) = e { + Some(c) + } else { + None + } + }) + .unwrap(); + let (center, radius) = if kind == Kind::Move { + (Vector3::new(2.5, 0.5, 0.), 0.2) + } else { + (Vector3::new(1., 1., 0.), 0.4) + }; + // Mouse projection uses f32 screen coordinates; allow sub-pixel roundoff. + assert!( + (circle.center - center).length() < 1e-5, + "{kind:?}: actual {:?}, expected {center:?}", + circle.center + ); + assert!((circle.radius - radius).abs() < 1e-5); + assert_eq!(app.tabs[source].scene.document, original); + } +} + +#[test] +fn block_actions_command_selection_cancel_undo_and_test_block_isolation() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let before = app.tabs[source].scene.document.clone(); + let depth = app.tabs[source].history.undo_stack.len(); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + let _ = app.update(Message::CommandEscape); + assert_eq!(app.tabs[source].scene.document, before); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth); + for kind in ["Move", "Scale"] { + let _ = app.dispatch_command(&format!("BACTION {kind}")); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete( + targets.clone(), + )); + assert!(app.tabs[source] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering()); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "3,4"); + assert!(app.tabs[source].active_cmd.is_none()); + } + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 2); + app.undo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 1 + ); + app.redo_steps(1); + assert_eq!( + actions::actions(&app.tabs[source].scene.document, definition).len(), + 2 + ); + let original = app.tabs[source].scene.document.clone(); + let _ = app.dispatch_command("BTESTBLOCK"); + let test = app.active_tab; + let insert = app.tabs[test].block_test.as_ref().unwrap().insert; + app.tabs[test].selected_handle = Some(insert); + app.refresh_test_action_grips([0.; 3]); + let grip = app.tabs[test].selected_grips.last().unwrap().id; + assert!(app.start_test_action_grip(insert, grip)); + feed(&mut app, "20,5"); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + assert_eq!(line.end, Vector3::new(22., 0., 0.)); + assert_eq!(app.tabs[source].scene.document, original); + app.undo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::ZERO); + app.redo_steps(1); + let EntityType::Line(line) = app.tabs[test] + .scene + .document + .get_entity(targets[0]) + .unwrap() + else { + panic!() + }; + assert_eq!(line.start, Vector3::new(20., 0., 0.)); + app.tabs[test] + .scene + .document + .add_entity(EntityType::Insert(Insert::new( + "EDIT_ME", + Vector3::new(50., 0., 0.), + ))) + .unwrap(); + let shared_before = app.tabs[test].scene.document.clone(); + let id = app.tabs[test].id; + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: glam::DVec3::new(30., 0., 0.), + }); + assert_eq!( + app.tabs[test].scene.document, shared_before, + "shared references are refused rather than silently modified together" + ); + let _ = app.dispatch_command("BCLOSETESTBLOCK"); + assert_eq!(app.active_tab, source); + assert_eq!(app.tabs[source].scene.document, original); +} + +#[test] +fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_results_are_inert() { + use crate::scene::block_actions::Kind; + let (mut app, reference) = fixture(); + let i = app.active_tab; + let _ = app.dispatch_command("LINE"); + let before = app.tabs[i].scene.document.clone(); + let _ = app.dispatch_command("BACTION MOVE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.update(Message::CommandEscape); + begin(&mut app, reference); + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let def = app.tabs[i].active_block_edit_session().unwrap().br_handle; + let p = crate::scene::block_actions::parameters(&app.tabs[i].scene.document, def, Kind::Move) + [0] + .handle; + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::PlaceBlockAction { + document: app.tabs[i].id + 1, + definition: def, + kind: Kind::Move, + parameter: p, + targets: members(&app), + location: glam::DVec3::ZERO, + }); + assert_eq!(app.tabs[i].scene.document, before); + let _ = app.dispatch_command("LINE"); + let _ = app.dispatch_command("BACTION SCALE"); + assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE"); +} + +#[test] +fn block_actions_test_reference_transform_visibility_and_noop_history() { + use crate::scene::block_actions::{self as actions, Kind}; + let (mut app, reference) = fixture(); + begin(&mut app, reference); + let source = app.active_tab; + let _ = app.run_command_line("BPARAMETER POINT 2,3 4,5"); + let _ = app.run_command_line("BPARAMETER VISIBILITY 8,8"); + let _ = app.run_command_line("BPARAMETER BASEPOINT 1,2"); + let definition = app.tabs[source] + .active_block_edit_session() + .unwrap() + .br_handle; + let p = + actions::parameters(&app.tabs[source].scene.document, definition, Kind::Move)[0].clone(); + let targets = members(&app); + let _ = app.dispatch_command("BACTION MOVE"); + feed(&mut app, &p.name); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + feed(&mut app, "4,6"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + let depth = app.tabs[i].history.undo_stack.len(); + let id = app.tabs[i].id; + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let current_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: current_world, + }); + assert_eq!(app.tabs[i].history.undo_stack.len(), depth); + // Existing visibility UI creates a per-reference anonymous definition. + app.apply_visibility_state(insert, 0); + app.finish_pending_history(i); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let after_world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(actions::native(p.end)), + ); + assert_eq!( + after_world, current_world, + "visibility representation retains the native Basepoint anchor" + ); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity_mut(insert).unwrap() else { + panic!() + }; + ins.insert_point = Vector3::new(100., 200., 0.); + ins.rotation = std::f64::consts::FRAC_PI_2; + ins.set_x_scale(2.); + ins.set_y_scale(3.); + let EntityType::Insert(ins) = app.tabs[i].scene.document.get_entity(insert).unwrap() else { + panic!() + }; + let name = ins.block_name.clone(); + assert_ne!(name, "EDIT_ME"); + let world = actions::v( + crate::scene::render_graph::insert_transform(&app.tabs[i].scene.document, ins) + .apply(Vector3::new(12., 13., 0.)), + ); + let before = app.tabs[i].scene.document.clone(); + let _ = app.handle_block_action_result(crate::command::CmdResult::EvaluateBlockParameter { + document: id, + definition, + parameter: p.handle, + position: world, + }); + let anon = app.tabs[i] + .scene + .document + .entities_in_block(&name) + .find_map(|e| { + if let EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert!((anon.start.x - 10.).abs() < 1e-9 && (anon.start.y - 10.).abs() < 1e-9); + app.undo_steps(1); + assert_eq!(app.tabs[i].scene.document, before); +} + fn authored_points(app: &OpenCADStudio) -> Vec { let tab = &app.tabs[app.active_tab]; let definition = tab diff --git a/src/app/block_test.rs b/src/app/block_test.rs index b32cf6b67..2ac5f2456 100644 --- a/src/app/block_test.rs +++ b/src/app/block_test.rs @@ -5,6 +5,9 @@ use iced::Task; pub(super) struct BlockTestSession { pub source_id: u64, + pub definition: codec::Handle, + pub insert: codec::Handle, + pub baseline: std::sync::Arc, } impl OpenCADStudio { @@ -97,13 +100,19 @@ impl OpenCADStudio { if !insert.attributes.is_empty() { insert.seqend_handle = Some(document.allocate_handle()); } + let insert_handle = insert.common.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.block_test = Some(BlockTestSession { + source_id, + definition, + insert: insert_handle, + baseline: std::sync::Arc::new(document.clone()), + }); tab.active_layer = self.tabs[i].active_layer.clone(); tab.scene.document = document; tab.scene.named_parameters = self.tabs[i].scene.named_parameters.clone(); diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index fb882bc3e..aee127260 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,10 @@ impl OpenCADStudio { return task; } } + result @ (CmdResult::PlaceBlockAction { .. } + | CmdResult::EvaluateBlockParameter { .. }) => { + return self.handle_block_action_result(result); + } result @ CmdResult::PlaceBlockSingleParameter { .. } => { return self.handle_place_block_single_parameter(result); } diff --git a/src/app/commands/bvisibility.rs b/src/app/commands/bvisibility.rs index 0fefd6f83..a33024d00 100644 --- a/src/app/commands/bvisibility.rs +++ b/src/app/commands/bvisibility.rs @@ -391,6 +391,17 @@ impl OpenCADStudio { return Some(Task::none()); } match cmd { + cmd if cmd == "BACTION" + || cmd.starts_with("BACTION ") + || cmd == "BACTIONTOOL" + || cmd.starts_with("BACTIONTOOL ") => + { + Some( + self.start_block_action( + cmd.split_once(' ').map_or("", |(_, args)| args.trim()), + ), + ) + } cmd if cmd == "BLOOKUPTABLE" || cmd.starts_with("BLOOKUPTABLE ") => { Some(self.open_lookup_table(cmd.strip_prefix("BLOOKUPTABLE").unwrap().trim())) } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 33194169e..8db2361f9 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -174,6 +174,22 @@ impl OpenCADStudio { }; let resolved = self.resolve_alias(cmd); let cmd = resolved.as_deref().unwrap_or(cmd); + 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." + ) + .as_ref(), + ); + return Task::none(); + } + if let Some(error) = self.action_error(crate::scene::block_actions::Kind::parse(args)) { + self.command_line.push_error(crate::t!(error).as_ref()); + return Task::none(); + } + } if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") { if let Some(error) = self.parameter_placement_error(cmd.strip_prefix("BPARAMETER").unwrap().trim()) diff --git a/src/app/dock/tests.rs b/src/app/dock/tests.rs index cb02124dc..982427aa0 100644 --- a/src/app/dock/tests.rs +++ b/src/app/dock/tests.rs @@ -1106,6 +1106,144 @@ fn authoring_app() -> OpenCADStudio { app } +#[test] +fn block_actions_palette_controls_require_compatible_parameters_and_start_commands() { + with_stack(|| { + use crate::ui::window::authoring_palettes::Tab; + let mut app = authoring_app(); + let _ = app.dispatch_command("BAUTHORPALETTE"); + let point = authoring_control(&app, Tab::Actions.widget_id()); + authoring_click(&mut app, point); + for id in ["authoring-tool-move-action", "authoring-tool-scale-action"] { + let point = authoring_control(&app, id); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + } + let _ = app.run_command_line("BPARAMETER POINT 0.5,0.5 0.7,0.7"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert!(app.tabs[app.active_tab].active_cmd.is_none()); + let point = authoring_control(&app, "authoring-tool-move-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,0 0.5,0.3"); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + assert_eq!( + app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(), + "BACTION" + ); + let _ = app.update(Message::CommandEscape); + authoring_capture(&app, "move-scale-actions-enabled"); + let i = app.active_tab; + let targets = app.tabs[i] + .scene + .document + .block_records + .get("AUTHOR") + .unwrap() + .entity_handles + .clone(); + let point = authoring_control(&app, "authoring-tool-scale-action"); + authoring_click(&mut app, point); + let _ = app.feed_command(crate::command::StepInput::Point(glam::DVec3::X)); + assert!( + app.tabs[i] + .active_cmd + .as_ref() + .unwrap() + .is_selection_gathering(), + "picking the parameter endpoint starts object selection" + ); + let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets)); + let _ = app.feed_command(crate::command::StepInput::Enter); + let _ = app.update(Message::CommandInput("0.8,0.5".into())); + let _ = app.update(Message::CommandSubmit); + assert!(app.tabs[i].active_cmd.is_none()); + let _ = app.run_command_line("ZOOM E"); + authoring_capture(&app, "scale-action-placed"); + let _ = app.dispatch_command("BTESTBLOCK"); + let i = app.active_tab; + app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.); + app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64( + glam::DVec3::new(-0.2, -0.4, 0.), + glam::DVec3::new(1.2, 0.6, 0.), + 1600. / 900., + ); + let insert = app.tabs[i].block_test.as_ref().unwrap().insert; + app.tabs[i].scene.select_entity(insert, true); + app.refresh_properties(); + assert!(app.tabs[i] + .selected_grips + .iter() + .any(|g| crate::app::block_actions::is_test_grip(g.id))); + authoring_capture(&app, "scale-action-test-grip"); + // Drawing a software snapshot updates the canvas size. Use the same + // current tile as the viewport input path, including when captures run. + let (vw, vh) = app.tabs[i].scene.selection.borrow().view.vp_size; + let tile = app.tabs[i].scene.active_model_tile_bounds(vw, vh); + let bounds = iced::Rectangle { + x: 0., + y: 0., + width: tile.width, + height: tile.height, + }; + let grip = app.tabs[i] + .selected_grips + .iter() + .find(|g| crate::app::block_actions::is_test_grip(g.id)) + .unwrap(); + let screen = app.tabs[i] + .scene + .camera + .borrow() + .project(grip.world, bounds) + .unwrap(); + app.cursor_pos = iced::Point::new(screen.x + tile.x, screen.y + tile.y); + app.tabs[i].scene.selection.borrow_mut().input.last_move_pos = Some(app.cursor_pos); + assert!( + app.tabs[i].active_cmd.is_none(), + "a running command consumes the test grip" + ); + let hit = crate::scene::pick::grip::find_hit_grip( + iced::Point::new(screen.x, screen.y), + &app.tabs[i].selected_grips, + &app.tabs[i].scene.camera.borrow(), + bounds, + ); + assert!( + hit.is_some_and(|(_, id, _, _)| crate::app::block_actions::is_test_grip(id)), + "test grip hit failed: {hit:?}; bounds={bounds:?}, cursor={:?}", + app.cursor_pos + ); + let _ = app.update(Message::ViewportLeftPressAt(app.cursor_pos)); + assert_eq!( + app.tabs[i].active_cmd.as_ref().map(|c|c.name()), + Some("BTESTBLOCK"), + "viewport click routes the parameter grip to the test command: modal={:?}, active grip={:?}, selected={:?}, navigation={:?}, ucs={}",app.active_modal,app.tabs[i].active_grip,app.tabs[i].selected_handle,(app.tabs[i].orbit_mode,app.tabs[i].pan_mode,app.tabs[i].zoom_dynamic_mode),app.ucs_icon_selected + ); + let _ = app.update(Message::CommandInput("2,0".into())); + let _ = app.update(Message::CommandSubmit); + let line = app.tabs[i] + .scene + .document + .entities_in_block("AUTHOR") + .find_map(|e| { + if let codec::EntityType::Line(l) = e { + Some(l) + } else { + None + } + }) + .unwrap(); + assert_eq!(line.end, codec::types::Vector3::new(2., 0., 0.)); + }); +} + #[test] fn lookup_basepoint_palette_and_table_dialog_real_controls() { with_stack(|| { diff --git a/src/app/dock/view.rs b/src/app/dock/view.rs index 170d69b88..7ac3e0f88 100644 --- a/src/app/dock/view.rs +++ b/src/app/dock/view.rs @@ -411,6 +411,8 @@ impl OpenCADStudio { self.point_parameter_placement_error(), self.parameter_placement_error("FLIP"), self.lookup_table_error(), + self.action_error(Some(crate::scene::block_actions::Kind::Move)), + self.action_error(Some(crate::scene::block_actions::Kind::Scale)), ) } crate::ui::dock::PanelId::BlockPalette => { diff --git a/src/app/mod.rs b/src/app/mod.rs index a7707fe2b..2c1bd36cc 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -14,6 +14,7 @@ mod block_editor_tests; mod block_save_as; mod block_visibility; mod block_lookup; +mod block_actions; mod block_properties; #[cfg(test)] mod block_properties_tests; diff --git a/src/app/properties.rs b/src/app/properties.rs index 1b88ad023..9882203f3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2886,6 +2886,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} // Append the dynamic-block visibility (lookup) grip, if the lone // selection is a visibility-parametric block reference. self.refresh_visibility_grip(wo); + self.refresh_test_action_grips(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 1b3b5008f..b3453639b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -829,7 +829,10 @@ impl OpenCADStudio { .copied() .zip(self.tabs[i].selected_grips.iter()) .filter(|(owner, grip)| self.tabs[i].hot_grips.contains(&(*owner, grip.id))) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + }) .map(|(owner, grip)| GripTarget { handle: owner, grip_id: grip.id, @@ -851,7 +854,10 @@ impl OpenCADStudio { .iter() .copied() .zip(self.tabs[i].selected_grips.iter()) - .filter(|(_, grip)| grip.id != crate::app::visibility::VIS_GRIP_ID) + .filter(|(_, grip)| { + grip.id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip.id) + }) .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .map(|(owner, grip)| GripTarget { handle: owner, @@ -993,6 +999,7 @@ impl OpenCADStudio { // hover grip-menu. if self.grip_popup.is_none() && grip_id != crate::app::visibility::VIS_GRIP_ID + && !crate::app::block_actions::is_test_grip(grip_id) && self .grip_hover .as_ref() @@ -3993,6 +4000,9 @@ impl OpenCADStudio { self.grip_popup = None; return Task::none(); } + if self.start_test_action_grip(handle, grip_id) { + return Task::none(); + } if self.shift_down { let key = (handle, grip_id); if !self.tabs[i].hot_grips.remove(&key) { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 01e7952cb..580ab4ab0 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1144,6 +1144,20 @@ bg={bg_ms:.1}ms n={view_count}" (g, p.parameter.definition_point) }), ) + .chain( + crate::scene::block_actions::actions( + &tab.scene.document, + session.br_handle, + ) + .into_iter() + .map(|(_, action, _, _)| { + let mut g = crate::scene::block_distance_parameter::Graphic::default(); + g.grips.push((action.display_location, false)); + g.labels + .push((action.display_location, action.element.name.clone())); + (g, action.display_location) + }), + ) .filter_map(|(graphic, marker_tail)| { let project = |p: codec::types::Vector3| { let p = tab diff --git a/src/command.rs b/src/command.rs index 71fdbf73a..f34a2cc87 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,20 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + PlaceBlockAction { + document: u64, + definition: Handle, + kind: crate::scene::block_actions::Kind, + parameter: Handle, + targets: Vec, + location: DVec3, + }, + EvaluateBlockParameter { + document: u64, + definition: Handle, + parameter: Handle, + position: DVec3, + }, /// Place or relocate a Basepoint, or place a Lookup parameter. PlaceBlockSingleParameter { document_id: u64, diff --git a/src/modules/block_editor/action.rs b/src/modules/block_editor/action.rs new file mode 100644 index 000000000..03aced724 --- /dev/null +++ b/src/modules/block_editor/action.rs @@ -0,0 +1,203 @@ +//! Reuse the command host's point picking and object-set selection. +use crate::command::{CadCommand, CmdOption, CmdResult, PointPickContext}; +use crate::scene::block_actions::{Kind, Parameter}; +use codec::Handle; +use glam::DVec3; + +inventory::submit!(crate::command::CommandRegistration { + names: &["BACTION", "BACTIONTOOL"] +}); + +pub(crate) struct ActionCommand { + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + parameter: Option, + targets: Vec, + selecting: bool, + context: Option, +} +impl ActionCommand { + pub(crate) fn new( + document: u64, + definition: Handle, + kind: Option, + candidates: Vec, + ) -> Self { + Self { + document, + definition, + kind, + candidates, + parameter: None, + targets: vec![], + selecting: false, + context: None, + } + } + fn choose(&mut self, p: Parameter) -> CmdResult { + if self.kind == Some(Kind::Scale) && (p.point || p.xy) { + return CmdResult::ReportError( + crate::t!("Scale requires a Linear or Polar parameter.").into_owned(), + ); + } + self.parameter = Some(p.handle); + self.selecting = true; + CmdResult::DeselectAndContinue + } +} +impl CadCommand for ActionCommand { + fn name(&self) -> &'static str { + "BACTION" + } + fn wants_text_input(&self) -> bool { + self.parameter.is_none() + } + fn prompt(&self) -> String { + if self.kind.is_none() { + return crate::t!("BACTION Enter action type [Move/Scale]:").into_owned(); + } + if self.parameter.is_none() { + return crate::tf!( + "BACTION {} Select a parameter endpoint or enter its name:", + crate::t!(self.kind.unwrap().name()) + ) + .into_owned(); + } + crate::t!(if self.selecting { + "BACTION Select objects for the action, then press Enter:" + } else { + "BACTION Specify action marker location:" + }) + .into_owned() + } + fn options(&self) -> Vec { + if self.kind.is_none() { + vec![ + CmdOption::new("Move", "Move"), + CmdOption::new("Scale", "Scale"), + ] + } else { + vec![] + } + } + fn on_text_input(&mut self, s: &str) -> Option { + if self.kind.is_none() { + return Some(if let Some(k) = Kind::parse(s) { + self.kind = Some(k); + CmdResult::NeedPoint + } else { + CmdResult::ReportError(crate::t!("Choose Move or Scale.").into_owned()) + }); + } + if self.parameter.is_none() { + let matches = self + .candidates + .iter() + .filter(|p| p.name.eq_ignore_ascii_case(s)) + .cloned() + .collect::>(); + return Some(if matches.len() == 1 { + self.choose(matches[0].clone()) + } else { + CmdResult::ReportError( + crate::t!("Enter a unique compatible parameter name or pick its endpoint.") + .into_owned(), + ) + }); + } + None + } + fn wants_point_pick_context(&self) -> bool { + self.kind.is_some() && self.parameter.is_none() + } + fn set_point_pick_context(&mut self, c: Option) { + self.context = c; + } + fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.parameter.is_none() { + let Some(c) = self.context else { + return CmdResult::ReportError(crate::t!("Enter the parameter name.").into_owned()); + }; + let screen = + |p| crate::scene::pick::hit_test::world_to_screen(p, c.view, c.eye, c.bounds); + let click = screen(point); + let candidate = self + .candidates + .iter() + .filter(|p| self.kind != Some(Kind::Scale) || (!p.point && !p.xy)) + .map(|p| (screen(p.end).distance(click), p)) + .filter(|(d, _)| d.is_finite() && *d <= c.aperture_px.max(10.)) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, p)| p.clone()); + return candidate.map(|p| self.choose(p)).unwrap_or_else(|| { + CmdResult::ReportError( + crate::t!("Pick a compatible parameter endpoint.").into_owned(), + ) + }); + } + if self.selecting { + return CmdResult::NeedPoint; + } + if !point.is_finite() { + return CmdResult::ReportError( + crate::t!("Specify a finite action location.").into_owned(), + ); + } + CmdResult::PlaceBlockAction { + document: self.document, + definition: self.definition, + kind: self.kind.unwrap(), + parameter: self.parameter.unwrap(), + targets: self.targets.clone(), + location: point, + } + } + fn is_selection_gathering(&self) -> bool { + self.selecting + } + fn on_selection_complete(&mut self, handles: Vec) -> CmdResult { + // The host calls this after every pick/window/removal, not just Enter. + // Retain the current set while the user gathers additional objects. + self.targets = handles; + CmdResult::NeedPoint + } + fn on_enter(&mut self) -> CmdResult { + if self.selecting { + if self.targets.is_empty() { + return CmdResult::ReportError( + crate::t!("Select at least one object for the action.").into_owned(), + ); + } + self.selecting = false; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } +} + +pub(crate) struct TestParameterCommand { + pub document: u64, + pub definition: Handle, + pub parameter: Handle, +} +impl CadCommand for TestParameterCommand { + fn name(&self) -> &'static str { + "BTESTBLOCK" + } + fn prompt(&self) -> String { + crate::t!("Test Block Specify the new parameter endpoint (Escape cancels):").into_owned() + } + fn on_point(&mut self, position: DVec3) -> CmdResult { + CmdResult::EvaluateBlockParameter { + document: self.document, + definition: self.definition, + parameter: self.parameter, + position, + } + } + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} diff --git a/src/modules/block_editor/mod.rs b/src/modules/block_editor/mod.rs index 9e2b5df08..06fa45da9 100644 --- a/src/modules/block_editor/mod.rs +++ b/src/modules/block_editor/mod.rs @@ -6,6 +6,7 @@ use crate::modules::parametric::ParametricModule; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; pub mod visibility_parameter; +pub mod action; pub mod point_parameter; pub mod single_parameter; pub mod distance_parameter; diff --git a/src/scene/block_actions.rs b/src/scene/block_actions.rs new file mode 100644 index 000000000..17de6ee9f --- /dev/null +++ b/src/scene/block_actions.rs @@ -0,0 +1,568 @@ +//! Native Move/Scale authoring and deterministic evaluation in Test Block. +use super::block_authoring::{self as authoring, *}; +use codec::objects::{ + BlockAction, BlockActionOffsets, BlockActionWithBasePoint, BlockBasePointAction, + BlockConnection, BlockElement, BlockEvalValue, BlockMoveAction, DynamicBlockData, ObjectType, +}; +use codec::types::{Transform, Vector3}; +use codec::{CadDocument, EntityType, Handle}; +use glam::DVec3; +#[cfg(test)] +mod tests; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Move, + Scale, +} +impl Kind { + pub(crate) fn parse(s: &str) -> Option { + match s.to_ascii_uppercase().as_str() { + "MOVE" | "M" => Some(Self::Move), + "SCALE" | "S" => Some(Self::Scale), + _ => None, + } + } + pub(crate) fn name(self) -> &'static str { + match self { + Self::Move => "Move", + Self::Scale => "Scale", + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct Parameter { + pub handle: Handle, + pub id: i32, + pub grip: Handle, + pub name: String, + pub base: DVec3, + pub end: DVec3, + pub linear: bool, + pub point: bool, + pub xy: bool, +} +pub(crate) fn v(p: Vector3) -> DVec3 { + DVec3::new(p.x, p.y, p.z) +} +pub(crate) fn native(p: DVec3) -> Vector3 { + Vector3::new(p.x, p.y, p.z) +} + +pub(crate) fn parameters(doc: &CadDocument, definition: Handle, kind: Kind) -> Vec { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let mut result = vec![]; + for (handle, p) in super::block_point_parameter::parameters(doc, definition) { + if kind == Kind::Move { + result.push(Parameter { + handle, + id: p.parameter.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.position_name.clone(), + base: v(p.parameter.definition_point), + end: v(p.parameter.definition_point), + linear: false, + point: true, + xy: false, + }); + } + } + for p in super::block_distance_parameter::parameters(doc, definition) { + let xy = p.kind == super::block_distance_parameter::Kind::XY; + if kind == Kind::Scale && xy { + continue; + } + result.push(Parameter { + handle: p.handle, + id: p.native.parameter.element.eval.node_id, + grip: Handle::NULL, + name: p.labels.first().cloned().unwrap_or_default(), + base: v(p.native.definition_base_point), + end: v(p.native.definition_end_point), + linear: p.kind == super::block_distance_parameter::Kind::Linear, + point: false, + xy, + }); + } + // Resolve the actual linked grip, rather than assuming expression IDs are contiguous. + result.retain_mut(|p| { + let grip_id = if p.point { + super::block_point_parameter::parameters(doc, definition) + .into_iter() + .find(|(h, _)| *h == p.handle) + .unwrap() + .1 + .parameter + .property_count + } else { + super::block_distance_parameter::parameters(doc, definition) + .into_iter() + .find(|q| q.handle == p.handle) + .unwrap() + .native + .properties[2] + .connections + .first() + .map(|c| c.code) + .unwrap_or(-1) + }; + let grips = doc + .objects + .iter() + .filter_map(|(h, o)| { + let ObjectType::DynamicBlock(o) = o else { + return None; + }; + if o.owner != graph { + return None; + } + let grip = match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => g, + DynamicBlockData::LinearGrip(g) => &g.grip, + _ => return None, + }; + (grip.element.eval.node_id == grip_id).then_some(*h) + }) + .collect::>(); + if grips.len() != 1 + || !p.base.is_finite() + || !p.end.is_finite() + || (!p.point && (p.end.distance(p.base) < 1e-9 || !p.end.distance(p.base).is_finite())) + { + return false; + } + p.grip = grips[0]; + true + }); + result.sort_by_key(|p| p.id); + result +} + +pub(crate) fn supported(e: &EntityType) -> bool { + matches!( + e, + EntityType::Line(_) + | EntityType::Circle(_) + | EntityType::Arc(_) + | EntityType::LwPolyline(_) + | EntityType::Ellipse(_) + | EntityType::Point(_) + | EntityType::Text(_) + | EntityType::MText(_) + ) +} +pub(crate) fn create( + doc: &mut CadDocument, + definition: Handle, + kind: Kind, + parameter: Handle, + targets: &[Handle], + location: DVec3, +) -> Result { + if !location.is_finite() { + return Err("Specify a finite action location."); + } + let p = parameters(doc, definition, kind) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("Select a compatible parameter belonging to this block.")?; + if targets.is_empty() { + return Err("Select at least one object for the action."); + } + let members = &doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The block definition is unavailable.")? + .entity_handles; + if targets.iter().any(|h| { + !members.contains(h) + || doc + .get_entity(*h) + .is_none_or(|e| !supported(e) || e.common().owner_handle != definition) + }) { + return Err("This milestone supports lines, polylines, arcs, circles, ellipses, points and text belonging to this block."); + } + let mut staged = doc.clone(); + let (graph, slots) = authoring::prepare(&mut staged, definition)?; + let (dxf, cpp) = match kind { + Kind::Move => ("BLOCKMOVEACTION", "AcDbBlockMoveAction"), + Kind::Scale => ("BLOCKSCALEACTION", "AcDbBlockScaleAction"), + }; + register_classes(&mut staged, &[(dxf, cpp, 55)]); + let handle = staged.allocate_handle(); + let mut dependencies = targets.to_vec(); + dependencies.sort(); + dependencies.dedup(); + let action = BlockAction { + element: BlockElement { + eval: expression(slots.eval, NO_VALUE, BlockEvalValue::None), + name: format!("{}{}", kind.name(), slots.eval), + major: MAJOR, + minor: MINOR, + eed_1071: 0, + }, + display_location: native(location), + dependencies, + action_ids: vec![], + }; + let connection = |name: &str| BlockConnection { + code: p.id, + name: name.into(), + }; + let data = match kind { + Kind::Move => DynamicBlockData::MoveAction(BlockMoveAction { + action, + connections: if p.point { + [connection("XDelta"), connection("YDelta")] + } else { + [connection("EndXDelta"), connection("EndYDelta")] + }, + offsets: BlockActionOffsets { + distance_multiplier: 1., + angle_offset: 0., + flags: 0, + }, + }), + Kind::Scale => DynamicBlockData::ScaleAction(BlockBasePointAction { + action: BlockActionWithBasePoint { + action, + offset: Vector3::ZERO, + connections: vec![connection("UpdatedBaseX"), connection("UpdatedBaseY")], + dependent: true, + base_point: Vector3::ZERO, + }, + connections: vec![ + connection("Scale"), + connection("XScale"), + connection("YScale"), + ], + dwg_tail_byte: 0, + }), + }; + staged.objects.insert( + handle, + ObjectType::DynamicBlock(dynamic(handle, graph, dxf, cpp, data)), + ); + authoring::append_single(&mut staged, graph, slots, handle); + authoring::graph::bind_ids(&mut staged, graph, slots.eval, &[p.id])?; + *doc = staged; + Ok(handle) +} + +pub(crate) fn actions( + doc: &CadDocument, + definition: Handle, +) -> Vec<(Handle, &BlockAction, Kind, i32)> { + let Ok(Some(graph)) = authoring::graph(doc, definition) else { + return vec![]; + }; + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + return vec![]; + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + return vec![]; + }; + g.nodes + .iter() + .filter_map(|n| { + let ObjectType::DynamicBlock(o) = doc.objects.get(&n.expression)? else { + return None; + }; + if o.owner != graph { + return None; + } + match &o.data { + DynamicBlockData::MoveAction(a) => { + Some((o.handle, &a.action, Kind::Move, a.connections[0].code)) + } + DynamicBlockData::ScaleAction(a) => a + .connections + .first() + .map(|c| (o.handle, &a.action.action, Kind::Scale, c.code)), + _ => None, + } + }) + .collect() +} +pub(crate) fn reframe(doc: &mut CadDocument, definition: Handle, t: &Transform) -> usize { + let handles = actions(doc, definition) + .iter() + .map(|(h, _, _, _)| *h) + .collect::>(); + for h in &handles { + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(h).unwrap() else { + unreachable!() + }; + match &mut o.data { + DynamicBlockData::MoveAction(a) => { + a.action.display_location = t.apply(a.action.display_location) + } + DynamicBlockData::ScaleAction(a) => { + a.action.action.display_location = t.apply(a.action.action.display_location); + a.action.base_point = t.apply(a.action.base_point); + a.action.offset = t.apply_rotation(a.action.offset); + } + _ => {} + } + } + handles.len() +} + +pub(crate) fn current(doc: &CadDocument, p: &Parameter) -> DVec3 { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&p.grip) else { + return p.end; + }; + match &o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => v(g.location), + DynamicBlockData::LinearGrip(g) => v(g.grip.location), + _ => p.end, + } +} + +/// Visibility can give the test INSERT an anonymous representation. Keep its +/// parallel members in sync, retaining their own handles and visibility flags. +pub(crate) fn sync_representation( + doc: &mut CadDocument, + definition: Handle, + name: &str, +) -> Result<(), &'static str> { + let original = doc + .block_records + .iter() + .find(|b| b.handle == definition) + .ok_or("The test definition is unavailable.")?; + if original.name == name { + return Ok(()); + } + let source = original.entity_handles.clone(); + let target = doc + .block_records + .get(name) + .ok_or("The test representation is unavailable.")? + .entity_handles + .clone(); + if source.len() != target.len() { + return Err("The test representation no longer matches its definition."); + } + let affected = actions(doc, definition) + .iter() + .flat_map(|(_, a, _, _)| a.dependencies.iter().copied()) + .collect::>(); + for (from, to) in source.into_iter().zip(target) { + if !affected.contains(&from) { + continue; + } + let mut entity = doc + .get_entity(from) + .cloned() + .ok_or("An action target is unavailable.")?; + let current = doc + .get_entity_mut(to) + .ok_or("An action representation target is unavailable.")?; + *entity.common_mut() = current.common().clone(); + *current = entity; + } + Ok(()) +} + +/// Re-evaluate from the scratch document's immutable starting geometry. This +/// avoids incremental drift and makes repeated changes/order independent. +pub(crate) fn evaluate( + doc: &mut CadDocument, + baseline: &CadDocument, + definition: Handle, + parameter: Handle, + position: DVec3, +) -> Result { + let graph = authoring::graph(baseline, definition)? + .ok_or("The test authoring graph is unavailable.")?; + if baseline.objects.values().any(|o|matches!(o,ObjectType::DynamicBlock(o) if o.owner==graph && matches!(&o.data, + DynamicBlockData::StretchAction(_)|DynamicBlockData::PolarStretchAction(_)|DynamicBlockData::RotateAction(_)|DynamicBlockData::FlipAction(_)|DynamicBlockData::ArrayAction(_)))) { + return Err("This block contains action types not supported in Test Block yet."); + } + let p = parameters(baseline, definition, Kind::Move) + .into_iter() + .find(|p| p.handle == parameter) + .ok_or("The test parameter is unavailable.")?; + if !position.is_finite() { + return Err("Specify a finite parameter position."); + } + let mut position = position; + position.z = p.end.z; + if p.linear { + let axis = (p.end - p.base).normalize(); + let length = (position - p.base).dot(axis); + if length < 1e-9 { + return Err("Specify a positive parameter length."); + } + position = p.base + axis * length; + } + if current(doc, &p).distance(position) < 1e-10 { + return Ok(false); + } + let mut staged = doc.clone(); + let ObjectType::DynamicBlock(o) = staged + .objects + .get_mut(&p.grip) + .ok_or("The test grip is unavailable.")? + else { + return Err("The test grip is unavailable."); + }; + match &mut o.data { + DynamicBlockData::XYGrip(g) | DynamicBlockData::PolarGrip(g) => { + g.location = native(position) + } + DynamicBlockData::LinearGrip(g) => g.grip.location = native(position), + _ => return Err("The test grip is unavailable."), + } + let all = actions(baseline, definition); + let mut affected = std::collections::BTreeSet::new(); + for (_, a, _, _) in &all { + affected.extend(a.dependencies.iter().copied()); + } + for h in affected { + let mut entity = baseline + .get_entity(h) + .cloned() + .ok_or("An action target is unavailable.")?; + if !supported(&entity) { + return Err("This action contains an unsupported object type."); + } + // State flags may have changed through the existing visibility grip. + if let Some(e) = staged.get_entity(h) { + entity.common_mut().invisible = e.common().invisible; + } + for (action_handle, a, kind, id) in &all { + if !a.dependencies.contains(&h) { + continue; + } + let q = parameters(baseline, definition, *kind) + .into_iter() + .find(|p| p.id == *id) + .ok_or("An action has an unsupported parameter binding.")?; + if !test_parameter_supported(baseline, q.handle) { + return Err("This parameter uses value constraints or chaining not supported in Test Block yet."); + } + let end = current(&staged, &q); + let transform = match kind { + Kind::Move => crate::command::EntityTransform::Translate(end - q.end), + Kind::Scale => { + let factor = end.distance(q.base) / q.end.distance(q.base); + if !factor.is_finite() || factor < 1e-9 { + return Err("Specify a positive scale distance."); + } + crate::command::EntityTransform::Scale { + center: q.base, + factor, + } + } + }; + // Imported actions with custom multipliers/offsets/chaining must + // not silently behave like the simple actions authored here. + if !a.action_ids.is_empty() { + return Err("Chained actions are not supported in Test Block yet."); + } + let ObjectType::DynamicBlock(o) = &baseline.objects[action_handle] else { + unreachable!() + }; + match &o.data { + DynamicBlockData::MoveAction(m) + if m.offsets.distance_multiplier != 1. + || m.offsets.angle_offset != 0. + || m.offsets.flags != 0 + || m.connections + != if q.point { + [ + BlockConnection { + code: q.id, + name: "XDelta".into(), + }, + BlockConnection { + code: q.id, + name: "YDelta".into(), + }, + ] + } else { + [ + BlockConnection { + code: q.id, + name: "EndXDelta".into(), + }, + BlockConnection { + code: q.id, + name: "EndYDelta".into(), + }, + ] + } => + { + return Err("This Move action uses options not supported in Test Block yet.") + } + DynamicBlockData::ScaleAction(s) + if !s.action.dependent + || s.action.offset != Vector3::ZERO + || s.connections + != ["Scale", "XScale", "YScale"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) + || s.action.connections + != ["UpdatedBaseX", "UpdatedBaseY"].map(|name| BlockConnection { + code: q.id, + name: name.into(), + }) => + { + return Err("This Scale action uses options not supported in Test Block yet.") + } + _ => {} + } + super::view::dispatch::apply_transform(&mut entity, &transform); + let bounds = entity.as_entity().bounding_box(); + if !v(bounds.min).is_finite() || !v(bounds.max).is_finite() { + return Err("The parameter value produces non-finite geometry."); + } + } + *staged + .get_entity_mut(h) + .ok_or("An action target was removed.")? = entity; + } + *doc = staged; + Ok(true) +} + +fn test_parameter_supported(doc: &CadDocument, handle: Handle) -> bool { + let Some(ObjectType::DynamicBlock(o)) = doc.objects.get(&handle) else { + return false; + }; + let distance = |s: &codec::objects::BlockParameterValueSet| { + s.flags == 1 + && s.minimum == 0. + && s.maximum == 0. + && s.increment == 0. + && s.values.is_empty() + }; + match &o.data { + DynamicBlockData::PointParameter(p) => !p.parameter.parameter.chain_actions, + DynamicBlockData::LinearParameter(p) => { + !p.parameter.parameter.chain_actions && distance(&p.value_set) + } + DynamicBlockData::XYParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.x_value_set) + && distance(&p.y_value_set) + } + DynamicBlockData::PolarParameter(p) => { + !p.parameter.parameter.chain_actions + && distance(&p.distance_value_set) + && p.angle_value_set.flags == 3 + && p.angle_value_set.minimum == 0. + && p.angle_value_set.maximum == std::f64::consts::TAU + && p.angle_value_set.increment == 0. + && p.angle_value_set.values.is_empty() + } + _ => false, + } +} diff --git a/src/scene/block_actions/tests.rs b/src/scene/block_actions/tests.rs new file mode 100644 index 000000000..18b14d891 --- /dev/null +++ b/src/scene/block_actions/tests.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::scene::{block_distance_parameter as distance, block_point_parameter as point, Scene}; +use codec::{DwgReader, DwgWriter, DxfReader, DxfWriter}; +use std::io::Cursor; + +fn fixture() -> (CadDocument, Handle, Vec) { + let mut scene = Scene::new(); + scene.define_block_raw( + "ACTIONS", + Vector3::ZERO, + vec![ + EntityType::Line(codec::entities::Line::from_points( + Vector3::UNIT_X, + Vector3::new(2., 0., 0.), + )), + EntityType::Circle(codec::entities::Circle::from_center_radius( + Vector3::new(4., 3., 0.), + 2., + )), + ], + ); + let block = scene.document.block_records.get("ACTIONS").unwrap(); + let (definition, handles) = (block.handle, block.entity_handles.clone()); + (scene.document, definition, handles) +} +fn linear(doc: &mut CadDocument, def: Handle) -> Handle { + distance::create( + doc, + def, + distance::Kind::Linear, + Vector3::ZERO, + Vector3::new(10., 0., 0.), + Vector3::new(5., 2., 0.), + ) + .unwrap() +} +fn start(doc: &CadDocument, h: Handle) -> Vector3 { + let EntityType::Line(l) = doc.get_entity(h).unwrap() else { + panic!() + }; + l.start +} +#[test] +fn block_actions_native_bindings_roundtrip_and_append_preserves_neighbors() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + for kind in [Kind::Move, Kind::Scale] { + create( + &mut doc, + def, + kind, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + } + let graph = authoring::graph(&doc, def).unwrap().unwrap(); + let ObjectType::DynamicBlock(o) = &doc.objects[&graph] else { + panic!() + }; + let DynamicBlockData::EvaluationGraph(g) = &o.data else { + panic!() + }; + assert!(g + .nodes + .iter() + .all(|n| authoring::graph::valid_lists(g, n.id))); + let dwg = DwgReader::from_stream(Cursor::new(DwgWriter::write_to_vec(&doc).unwrap())) + .read() + .unwrap(); + let dxf = DxfReader::from_reader(Cursor::new(DxfWriter::new(&doc).write_to_vec().unwrap())) + .unwrap() + .read() + .unwrap(); + for saved in [dwg, dxf] { + assert_eq!(actions(&saved, def).len(), 2); + for (h, _, _, _) in actions(&doc, def) { + assert_eq!(saved.objects[&h], doc.objects[&h]); + } + let mut test = saved.clone(); + assert!(evaluate(&mut test, &saved, def, p, DVec3::new(20., 5., 0.)).unwrap()); + assert_eq!(start(&test, handles[0]), Vector3::new(22., 0., 0.)); + assert_eq!(test.get_entity(handles[1]), saved.get_entity(handles[1])); + } +} +#[test] +fn block_actions_point_move_is_absolute_reversible_and_leaves_unselected_objects() { + let (mut doc, def, handles) = fixture(); + let p = point::create( + &mut doc, + def, + Vector3::new(2., 3., 0.), + Vector3::new(5., 6., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + for _ in 0..8 { + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), Vector3::new(11., -10., 0.)); + assert!(!evaluate(&mut doc, &baseline, def, p, DVec3::new(12., -7., 0.)).unwrap()); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(2., 3., 0.)).unwrap()); + assert_eq!(start(&doc, handles[0]), start(&baseline, handles[0])); + } + assert_eq!(doc.get_entity(handles[1]), baseline.get_entity(handles[1])); +} +#[test] +fn block_actions_scale_uses_distance_ratio_and_parameter_base() { + let (mut doc, def, handles) = fixture(); + let p = distance::create( + &mut doc, + def, + distance::Kind::Polar, + Vector3::new(4., 3., 0.), + Vector3::new(14., 3., 0.), + Vector3::new(9., 5., 0.), + ) + .unwrap(); + create(&mut doc, def, Kind::Scale, p, &[handles[1]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 23., 0.)).unwrap(); + let EntityType::Circle(c) = doc.get_entity(handles[1]).unwrap() else { + panic!() + }; + assert_eq!(c.center, Vector3::new(4., 3., 0.)); + assert_eq!(c.radius, 4.); + let before = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(4., 3., 0.)).is_err()); + assert_eq!(doc, before); +} +#[test] +fn block_actions_reject_invalid_selection_binding_and_nonfinite_without_mutation() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let before = doc.clone(); + for (parameter, targets, location) in [ + (Handle::NULL, vec![handles[0]], DVec3::ZERO), + (p, vec![], DVec3::ZERO), + (p, vec![Handle::new(0xff_ff_ff)], DVec3::ZERO), + (p, vec![handles[0]], DVec3::splat(f64::NAN)), + ] { + assert!(create(&mut doc, def, Kind::Move, parameter, &targets, location).is_err()); + assert_eq!(doc, before); + } + let action = create(&mut doc, def, Kind::Move, p, &[handles[0]], DVec3::ZERO).unwrap(); + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::splat(f64::NAN)).is_err()); + assert_eq!(doc, baseline); + let mut restricted = doc.clone(); + let ObjectType::DynamicBlock(object) = restricted.objects.get_mut(&p).unwrap() else { + panic!() + }; + let DynamicBlockData::LinearParameter(parameter) = &mut object.data else { + panic!() + }; + parameter.value_set.maximum = 15.; + parameter.value_set.flags |= 2; + let restricted_baseline = restricted.clone(); + assert!(evaluate( + &mut restricted, + &restricted_baseline, + def, + p, + DVec3::new(20., 0., 0.) + ) + .is_err()); + assert_eq!( + restricted, restricted_baseline, + "unsupported value constraints are not silently ignored" + ); + let ObjectType::DynamicBlock(o) = doc.objects.get_mut(&action).unwrap() else { + panic!() + }; + let DynamicBlockData::MoveAction(m) = &mut o.data else { + panic!() + }; + m.offsets.distance_multiplier = 0.5; + let baseline = doc.clone(); + assert!(evaluate(&mut doc, &baseline, def, p, DVec3::new(15., 0., 0.)).is_err()); + assert_eq!(doc, baseline); +} +#[test] +fn block_actions_reframe_moves_markers_and_retains_targets_and_connections() { + let (mut doc, def, handles) = fixture(); + let p = linear(&mut doc, def); + let h = create( + &mut doc, + def, + Kind::Scale, + p, + &[handles[0]], + DVec3::new(3., 4., 0.), + ) + .unwrap(); + let before = doc.objects[&h].clone(); + let t = Transform::from_translation(Vector3::new(-2., -3., 0.)); + assert_eq!(reframe(&mut doc, def, &t), 1); + let ObjectType::DynamicBlock(o) = &doc.objects[&h] else { + panic!() + }; + let DynamicBlockData::ScaleAction(s) = &o.data else { + panic!() + }; + assert_eq!(s.action.action.display_location, Vector3::new(1., 1., 0.)); + let ObjectType::DynamicBlock(o) = before else { + panic!() + }; + let DynamicBlockData::ScaleAction(b) = o.data else { + panic!() + }; + assert_eq!(s.action.action.dependencies, b.action.action.dependencies); + assert_eq!(s.connections, b.connections); +} diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index aa7657c81..6c0d9255a 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -1,4 +1,5 @@ //! Shared native graph construction for Block Editor authoring objects. +pub(crate) mod graph; use codec::objects::{ BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, }; diff --git a/src/scene/block_lookup/graph.rs b/src/scene/block_authoring/graph.rs similarity index 86% rename from src/scene/block_lookup/graph.rs rename to src/scene/block_authoring/graph.rs index 7c66c8586..1b8356c56 100644 --- a/src/scene/block_lookup/graph.rs +++ b/src/scene/block_authoring/graph.rs @@ -1,8 +1,8 @@ -//! Maintain native adjacency lists while adding/replacing lookup dependencies. -use super::*; -use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph}; +//! Maintain native adjacency lists while adding/replacing action dependencies. +use codec::objects::{BlockEvaluationEdge, BlockEvaluationGraph, DynamicBlockData, ObjectType}; +use codec::{CadDocument, Handle}; -pub(super) fn valid_lists(g: &BlockEvaluationGraph, node: i32) -> bool { +pub(crate) fn valid_lists(g: &BlockEvaluationGraph, node: i32) -> bool { let Some(n) = g.nodes.iter().find(|n| n.id == node) else { return false; }; @@ -115,11 +115,11 @@ fn add(g: &mut BlockEvaluationGraph, id: i32, source: i32, destination: i32, inv outgoing_edges: [incoming, -1, outgoing, -1, inverse], }); } -pub(super) fn bind( +pub(crate) fn bind_ids( document: &mut CadDocument, graph: Handle, action: i32, - columns: &[BlockLookupColumn], + parameters: &[i32], ) -> Result<(), &'static str> { let Some(ObjectType::DynamicBlock(object)) = document.objects.get(&graph) else { return Err("The authoring graph is unavailable."); @@ -129,27 +129,27 @@ pub(super) fn bind( }; let mut g = original.clone(); if std::iter::once(action) - .chain(columns.iter().map(|c| c.node_id)) + .chain(parameters.iter().copied()) .any(|id| g.nodes.iter().filter(|n| n.next_id == id).count() != 1) { - return Err("A Lookup binding has an invalid or ambiguous expression ID."); + return Err("An action binding has an invalid or ambiguous expression ID."); } let action = g .nodes .iter() .find(|n| n.next_id == action) - .ok_or("The Lookup action node is unavailable.")? + .ok_or("The action node is unavailable.")? .id; let mut bindings = vec![]; - for column in columns { + for parameter in parameters { let node = g .nodes .iter() - .find(|n| n.next_id == column.node_id) - .ok_or("A table property no longer belongs to this block.")? + .find(|n| n.next_id == *parameter) + .ok_or("A bound parameter no longer belongs to this block.")? .id; if node == action { - return Err("A Lookup table cannot use its action as a property."); + return Err("An action cannot bind to itself."); } if !bindings.contains(&node) { bindings.push(node); @@ -165,10 +165,10 @@ pub(super) fn bind( .chain(old.iter().flat_map(|e| [e.source_node, e.destination_node])) .any(|n| !valid_lists(&g, n)) { - return Err("The Lookup graph has invalid adjacency links."); + return Err("The authoring graph has invalid adjacency links."); } if old.iter().any(|e| e.next_id != 4) { - return Err("This Lookup action has unsupported graph dependencies."); + return Err("This action has unsupported graph dependencies."); } if old.iter().any(|e| { !old.iter().any(|inverse| { @@ -178,7 +178,7 @@ pub(super) fn bind( && inverse.destination_node == e.source_node }) }) { - return Err("The Lookup graph has invalid inverse dependencies."); + return Err("The authoring graph has invalid inverse dependencies."); } let expected = bindings .iter() diff --git a/src/scene/block_lookup.rs b/src/scene/block_lookup.rs index 0b968920d..fa8c8fa1f 100644 --- a/src/scene/block_lookup.rs +++ b/src/scene/block_lookup.rs @@ -9,7 +9,7 @@ use codec::{ types::{Transform, Vector3}, CadDocument, Handle, }; -mod graph; +use super::block_authoring::graph; #[cfg(test)] mod tests; @@ -349,7 +349,13 @@ fn apply_table_inner( h }; let node_id = edited.action.element.eval.node_id; - graph::bind(document, graph, node_id, &edited.columns).map_err(str::to_string)?; + graph::bind_ids( + document, + graph, + node_id, + &edited.columns.iter().map(|c| c.node_id).collect::>(), + ) + .map_err(str::to_string)?; if let Some(ObjectType::DynamicBlock(o)) = document.objects.get_mut(&handle) { o.data = DynamicBlockData::LookupAction(edited); } diff --git a/src/scene/block_visibility.rs b/src/scene/block_visibility.rs index 6c88a883c..282562974 100644 --- a/src/scene/block_visibility.rs +++ b/src/scene/block_visibility.rs @@ -499,18 +499,7 @@ impl Scene { .map_or(1, |n| n + 1); format!("*U{next}") }; - let base = self - .document - .block_records - .iter() - .find(|record| record.handle == source) - .and_then( - |record| match self.document.get_entity(record.block_entity_handle) { - Some(EntityType::Block(block)) => Some(block.base_point), - _ => None, - }, - ) - .unwrap_or(Vector3::ZERO); + let base = super::render_graph::block_base_point(&self.document, &reference.block_name); let originals = members(&self.document, source); let copies: Vec = originals .iter() diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 5b7a6182d..c286840b3 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1710,6 +1710,11 @@ impl Scene { super::block_lookup::parameters(&self.document, block) .into_iter() .map(|(_, p)| p.parameter.definition_point), + ) + .chain( + super::block_actions::actions(&self.document, block) + .into_iter() + .map(|(_, a, _, _)| a.display_location), ); for point in locations { let point = glam::DVec3::new(point.x, point.y, point.z); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 4dbd72a32..0b2958a82 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -13,6 +13,7 @@ pub mod block_visibility; pub(crate) mod block_authoring; pub(crate) mod block_basepoint; pub(crate) mod block_lookup; +pub(crate) mod block_actions; pub(crate) mod block_point_parameter; pub(crate) mod block_distance_parameter; pub(crate) mod block_orientation_parameter; @@ -64,6 +65,7 @@ mod layout; mod limits; mod modify; pub(crate) use modify::remap_copied_reactors; +pub(crate) use modify::inverse_affine; mod mspace; pub mod named_parameters; mod page_setup; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 7a95f3fea..f7d078dd7 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,7 +9,9 @@ struct TextOrient { x_scale: f64, } -fn inverse_affine(transform: &codec::types::Transform) -> Option { +pub(crate) fn inverse_affine( + transform: &codec::types::Transform, +) -> Option { use codec::types::{Matrix3, Matrix4, Transform, Vector3}; let matrix = &transform.matrix.m; let linear = Matrix3::from_rows( @@ -580,7 +582,8 @@ impl Scene { ); let parameter_changes = parameter_changes + basepoint_changes - + super::block_lookup::reframe(&mut self.document, block_record, local_from_old); + + super::block_lookup::reframe(&mut self.document, block_record, local_from_old) + + super::block_actions::reframe(&mut self.document, block_record, local_from_old); if !changed.is_empty() || parameter_changes > 0 { self.rebuild_derived_caches(); } diff --git a/src/ui/window/authoring_palettes.rs b/src/ui/window/authoring_palettes.rs index 2d37e9d54..348c80a76 100644 --- a/src/ui/window/authoring_palettes.rs +++ b/src/ui/window/authoring_palettes.rs @@ -194,6 +194,8 @@ fn catalog( point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let (entries, unavailable) = match tab { Tab::Parameters => ( @@ -215,7 +217,9 @@ fn catalog( let orientation_error = if *label=="Flip" {flip_error} else {point_error}; let single=tab==Tab::Parameters&&matches!(*label,"Lookup"|"Basepoint"); let lookup_action=tab==Tab::Actions&&*label=="Lookup"; - let enabled = (single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); + let geometry_action=tab==Tab::Actions&&matches!(*label,"Move"|"Scale"); + let action_error=if *label=="Scale" {scale_error}else{move_error}; + let enabled = (geometry_action&&action_error.is_none())||(single&&point_error.is_none())||(lookup_action&&lookup_error.is_none())||(orientation && orientation_error.is_none()) || (visibility && visibility_error.is_none()) || ((point || distance) && point_error.is_none()); let base = if enabled { icons::semantic(icon, 28.0) } else { @@ -239,8 +243,8 @@ fn catalog( base }; let message = enabled.then(|| Message::RibbonToolClick { - tool_id: if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, - event: crate::modules::ModuleEvent::Command(if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), + tool_id: if geometry_action {format!("BACTION_{}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER_{}", label.to_ascii_uppercase())}, + event: crate::modules::ModuleEvent::Command(if geometry_action {format!("BACTION {}",label.to_ascii_uppercase())}else if lookup_action {"BLOOKUPTABLE".into()}else{format!("BPARAMETER {}", label.to_ascii_uppercase())}), }); let description = if visibility { crate::t!(visibility_error @@ -259,6 +263,8 @@ fn catalog( })).into_owned() } else if single { crate::t!(point_error.unwrap_or(if *label=="Basepoint" {"Place or relocate the dynamic block insertion anchor."}else{"Place a Lookup parameter, then use the Lookup action to edit its property table."})).into_owned() + } else if geometry_action { + crate::t!(action_error.unwrap_or("Select a parameter endpoint, select the objects it controls, then place the action marker. Test it using the parameter grip in Test Block.")).into_owned() } else if lookup_action { crate::t!(lookup_error.unwrap_or("Create or edit property Lookup tables for this block.")).into_owned() } else { @@ -275,6 +281,8 @@ fn catalog( container(entry).id(match *label {"Rotation"=>"authoring-tool-rotation","Alignment"=>"authoring-tool-alignment",_=>"authoring-tool-flip"}).into() } else if single { container(entry).id(if *label=="Lookup" {"authoring-tool-lookup"}else{"authoring-tool-basepoint"}).into() + } else if geometry_action { + container(entry).id(if *label=="Move" {"authoring-tool-move-action"}else{"authoring-tool-scale-action"}).into() } else if lookup_action { container(entry).id("authoring-tool-lookup-table").into() } else { @@ -391,6 +399,8 @@ pub fn view( point_error: Option<&'static str>, flip_error: Option<&'static str>, lookup_error: Option<&'static str>, + move_error: Option<&'static str>, + scale_error: Option<&'static str>, ) -> Element<'static, Message> { let tabs = column(Tab::ALL.into_iter().map(|category| { let label = category.label(); @@ -445,7 +455,9 @@ pub fn view( visibility_error, point_error, flip_error, - lookup_error + lookup_error, + move_error, + scale_error )) .width(Fill) .height(Fill),