forked from 1b-agent/opencadstudio-upstream
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).
This commit is contained in:
1 parent
10c5a9d382
commit
f69aefc3df
25 files changed
+1914
-39
No files matched your search
@@ -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<Kind>) -> 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<Message> {
|
||||
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<Message> {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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<codec::objects::BlockPointParameter> {
|
||||
let tab = &app.tabs[app.active_tab];
|
||||
let definition = tab
|
||||
|
||||
+10
-1
@@ -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<codec::CadDocument>,
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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(|| {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Handle> {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user