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),