feat(blocks): wire Rotate and Flip actions and saved reference grips

Author Rotate actions against Rotation parameters and Flip actions against Flip
parameters through the existing palette, BACTION selection flow and native
evaluation graph. Keep markers selectable and editable in BEDIT.

Rotation grips accept a new direction at the original radius. Flip grips toggle
immediately. Evaluate both from immutable definition geometry in Test Block and
independent drawing references, preserving per-INSERT angle/state caches across
DWG/DXF saves and COPY. Undo/redo includes the native state and representation.
Accept parameter names containing spaces at the action parameter prompt.

332 block regressions pass (6 existing opt-in checks ignored), including native
binding/marker roundtrips, composed rotation/flip, repeated reversible toggles,
COPY isolation, viewport mouse interactions, history and saved-reference reopen.
Custom base modes, constrained/chained evaluation and parameter-target actions
remain unsupported; Rotate currently binds Rotation parameters.
This commit is contained in:
Karim Jerbi committed 2026-10-17 09:15:37 +01:00
1 parent f67f2cf4f5
commit de7ed432fb
11 files changed
+922 -98

No files matched your search

+98 -25
View File
@@ -21,9 +21,11 @@ impl OpenCADStudio {
{
return Some(e);
}
if actions::parameters(&tab.scene.document, s.br_handle, kind.unwrap_or(Kind::Move))
.is_empty()
{
let candidates = kind.map_or_else(
|| actions::all_parameters(&tab.scene.document, s.br_handle),
|kind| actions::parameters(&tab.scene.document, s.br_handle, kind),
);
if candidates.is_empty() {
return Some("Place a compatible parameter before adding this action.");
}
None
@@ -36,7 +38,7 @@ impl OpenCADStudio {
};
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.")
crate::t!("Supported actions: Move, Scale, Rotate, Flip. Other action types are not wired yet.")
.as_ref(),
);
return Task::none();
@@ -51,7 +53,10 @@ impl OpenCADStudio {
tab.id,
definition,
kind,
actions::parameters(&tab.scene.document, definition, kind.unwrap_or(Kind::Move)),
kind.map_or_else(
|| actions::all_parameters(&tab.scene.document, definition),
|kind| actions::parameters(&tab.scene.document, definition, kind),
),
);
self.command_line.push_info(&command.prompt());
self.command_line.set_step_options(command.options());
@@ -96,7 +101,7 @@ impl OpenCADStudio {
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());
self.command_line.push_output(crate::tf!("{} action attached. Select the reference and use its parameter grip, or open Test Block.",crate::t!(kind.name())).as_ref());
}
Err(e) => self.command_line.push_error(crate::t!(e).as_ref()),
}
@@ -106,14 +111,13 @@ impl OpenCADStudio {
document,
definition,
parameter,
position,
value,
} => {
if self.tabs[i].id != document {
return Task::none();
}
if let Some(insert) = insert {
return self
.evaluate_reference_parameter(insert, definition, parameter, position);
return self.evaluate_reference_parameter(insert, definition, parameter, value);
}
let Some(test) = self.tabs[i].block_test.as_ref() else {
return Task::none();
@@ -141,10 +145,21 @@ impl OpenCADStudio {
);
return Task::none();
};
let local = actions::v(inverse.apply(actions::native(position)));
let local = match value {
actions::Value::Point(at) => {
actions::Value::Point(actions::v(inverse.apply(actions::native(at))))
}
state => state,
};
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) {
match actions::evaluate_value(
&mut staged,
&test.baseline,
definition,
parameter,
local,
) {
Ok(changed) => {
if changed {
if let Err(e) = actions::sync_representation(
@@ -210,21 +225,28 @@ impl OpenCADStudio {
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)
for (index, p) in actions::all_parameters(&test.baseline, test.definition)
.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);
let Ok(value) = actions::value(&tab.scene.document, &p) else {
continue;
};
let world =
actions::v(transform.apply(actions::native(actions::grip_position(&p, value))))
- 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,
shape: if p.flip {
crate::scene::model::object::GripShape::Square
} else {
crate::scene::model::object::GripShape::Triangle
},
dir: None,
axis: None,
});
@@ -251,12 +273,18 @@ impl OpenCADStudio {
if tab.scene.is_layer_locked(handle) {
return true;
}
let Some(p) = actions::parameters(&test.baseline, test.definition, Kind::Move)
let Some(p) = actions::all_parameters(&test.baseline, test.definition)
.get(id - TEST_GRIP_START)
.cloned()
else {
return false;
};
if p.flip {
let state = actions::value(&tab.scene.document, &p);
let document = tab.id;
let definition = test.definition;
return self.toggle_action_grip(document, definition, p.handle, None, state);
}
let command = crate::modules::block_editor::action::TestParameterCommand {
insert: None,
document: tab.id,
@@ -291,23 +319,28 @@ impl OpenCADStudio {
crate::scene::render_graph::insert_transform(&tab.scene.document, reference);
let all = actions::actions(&tab.scene.document, def);
let mut grips = vec![];
for (index, p) in actions::parameters(&tab.scene.document, def, Kind::Move)
for (index, p) in actions::all_parameters(&tab.scene.document, def)
.into_iter()
.enumerate()
{
if !all.iter().any(|(_, _, _, id)| *id == p.id) {
continue;
}
let Ok(local) = actions::instance::current(&tab.scene.document, handle, &p) else {
let Ok(value) = actions::instance::value(&tab.scene.document, handle, &p) else {
continue;
};
let world =
actions::v(transform.apply(actions::native(local))) - glam::DVec3::from_array(wo);
actions::v(transform.apply(actions::native(actions::grip_position(&p, value))))
- 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,
shape: if p.flip {
crate::scene::model::object::GripShape::Square
} else {
crate::scene::model::object::GripShape::Triangle
},
dir: None,
axis: None,
});
@@ -329,12 +362,17 @@ impl OpenCADStudio {
if tab.scene.is_layer_locked(handle) {
return true;
}
let Some(p) = actions::parameters(&tab.scene.document, def, Kind::Move)
let Some(p) = actions::all_parameters(&tab.scene.document, def)
.get(id - TEST_GRIP_START)
.cloned()
else {
return false;
};
if p.flip {
let state = actions::instance::value(&tab.scene.document, handle, &p);
let document = tab.id;
return self.toggle_action_grip(document, def, p.handle, Some(handle), state);
}
let command = crate::modules::block_editor::action::TestParameterCommand {
insert: Some(handle),
document: tab.id,
@@ -352,7 +390,7 @@ impl OpenCADStudio {
handle: Handle,
definition: Handle,
parameter: Handle,
position: glam::DVec3,
value: actions::Value,
) -> Task<Message> {
let i = self.active_tab;
let tab = &self.tabs[i];
@@ -373,8 +411,13 @@ impl OpenCADStudio {
) else {
return Task::none();
};
let local = actions::v(inverse.apply(actions::native(position)));
match actions::instance::evaluate(&tab.scene.document, handle, parameter, local) {
let local = match value {
actions::Value::Point(at) => {
actions::Value::Point(actions::v(inverse.apply(actions::native(at))))
}
state => state,
};
match actions::instance::evaluate_value(&tab.scene.document, handle, parameter, local) {
Ok(staged) => {
if let Some(mut staged) = staged {
self.push_undo_snapshot(i, "Block reference parameter");
@@ -407,3 +450,33 @@ impl OpenCADStudio {
Task::none()
}
}
impl OpenCADStudio {
fn toggle_action_grip(
&mut self,
document: u64,
definition: Handle,
parameter: Handle,
insert: Option<Handle>,
state: Result<actions::Value, &'static str>,
) -> bool {
match state {
Ok(actions::Value::Flip(state)) => {
let _ = self.handle_block_action_result(
crate::command::CmdResult::EvaluateBlockParameter {
document,
definition,
parameter,
insert,
value: actions::Value::Flip(!state),
},
);
}
Err(e) => self.command_line.push_error(crate::t!(e).as_ref()),
_ => return false,
}
self.grip_hover = None;
self.grip_popup = None;
true
}
}
+136 -3
View File
@@ -59,6 +59,139 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit);
}
#[test]
fn block_actions_rotate_flip_mouse_grips_authoring_history_and_reopen() {
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 ROTATION 0,0 10 0");
let _ = app.run_command_line("BPARAMETER FLIP 0,0 10,0 -2,2");
let def = app.tabs[source]
.active_block_edit_session()
.unwrap()
.br_handle;
let rotate =
actions::parameters(&app.tabs[source].scene.document, def, Kind::Rotate)[0].clone();
let flip = actions::parameters(&app.tabs[source].scene.document, def, Kind::Flip)[0].clone();
let targets = members(&app);
for (kind, p) in [("Rotate", &rotate), ("Flip", &flip)] {
let depth = app.tabs[source].history.undo_stack.len();
let _ = app.dispatch_command(&format!("BACTION {kind}"));
feed(&mut app, &p.name);
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(
targets.clone(),
));
let _ = app.feed_command(crate::command::StepInput::Enter);
feed(&mut app, "3,4");
assert!(app.tabs[source].active_cmd.is_none());
assert_eq!(app.tabs[source].history.undo_stack.len(), depth + 1);
}
let baseline = app.tabs[source].scene.document.clone();
let click = |app: &mut OpenCADStudio, world: glam::DVec3| {
let scene = &app.tabs[app.active_tab].scene;
let (w, h) = scene.selection.borrow().view.vp_size;
let tile = scene.active_model_tile_bounds(w, h);
let at = scene
.camera
.borrow()
.project(
world,
iced::Rectangle {
x: 0.,
y: 0.,
width: tile.width,
height: tile.height,
},
)
.unwrap();
let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new(
at.x + tile.x,
at.y + tile.y,
)));
let _ = app.update(Message::ViewportLeftRelease);
};
let check = |app: &OpenCADStudio, insert: Handle, expected: Vector3| {
let doc = &app.tabs[app.active_tab].scene.document;
let EntityType::Insert(ins) = doc.get_entity(insert).unwrap() else {
panic!()
};
let h = doc
.block_records
.get(&ins.block_name)
.unwrap()
.entity_handles[0];
let EntityType::Line(line) = doc.get_entity(h).unwrap() else {
panic!()
};
assert!(
line.end.distance(&expected) < 1e-5,
"{:?} != {expected:?}",
line.end
);
};
// Exercise the exact viewport grip press/release path in both contexts.
for test in [true, false] {
let insert = if test {
let _ = app.dispatch_command("BTESTBLOCK");
app.tabs[app.active_tab].block_test.as_ref().unwrap().insert
} else {
app.active_tab = source;
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
reference
};
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(-4., -3., 0.),
glam::DVec3::new(13., 13., 0.),
1600. / 900.,
);
app.tabs[i].scene.selected.insert(insert);
app.refresh_properties();
click(&mut app, rotate.end);
assert!(app.tabs[i].active_cmd.is_some());
let depth = app.tabs[i].history.undo_stack.len();
click(&mut app, glam::DVec3::new(0., 10., 0.));
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
check(&app, insert, Vector3::UNIT_Y);
app.tabs[i].scene.selected.insert(insert);
app.refresh_properties();
let depth = app.tabs[i].history.undo_stack.len();
click(&mut app, flip.reference);
assert!(
app.tabs[i].active_cmd.is_none(),
"Flip toggles immediately without point input"
);
check(&app, insert, -Vector3::UNIT_Y);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
app.undo_steps(1);
check(&app, insert, Vector3::UNIT_Y);
app.redo_steps(1);
check(&app, insert, -Vector3::UNIT_Y);
if test {
assert_eq!(app.tabs[source].scene.document, baseline);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
} else {
let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap();
app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes))
.read()
.unwrap();
app.tabs[i].scene.selected.insert(insert);
app.refresh_properties();
click(&mut app, flip.reference);
check(&app, insert, Vector3::UNIT_Y);
let EntityType::Line(original) =
app.tabs[i].scene.document.get_entity(targets[0]).unwrap()
else {
panic!()
};
assert_eq!(original.end, Vector3::UNIT_X);
}
}
}
#[test]
fn block_actions_drawing_grips_history_cancel_transform_and_reopen() {
use crate::scene::block_actions::{self as actions, Kind};
@@ -601,7 +734,7 @@ fn block_actions_command_selection_cancel_undo_and_test_block_isolation() {
document: id,
definition,
parameter: p.handle,
position: glam::DVec3::new(30., 0., 0.),
value: crate::scene::block_actions::Value::Point(glam::DVec3::new(30., 0., 0.)),
});
assert_eq!(
app.tabs[test].scene.document, shared_before,
@@ -682,7 +815,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() {
document: id,
definition,
parameter: p.handle,
position: current_world,
value: crate::scene::block_actions::Value::Point(current_world),
});
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
// Existing visibility UI creates a per-reference anonymous definition.
@@ -721,7 +854,7 @@ fn block_actions_test_reference_transform_visibility_and_noop_history() {
document: id,
definition,
parameter: p.handle,
position: world,
value: crate::scene::block_actions::Value::Point(world),
});
let anon = app.tabs[i]
.scene
+38
View File
@@ -1244,6 +1244,44 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman
});
}
#[test]
fn block_actions_rotate_flip_palette_requires_matching_parameters() {
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-rotate-action", "authoring-tool-flip-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 ROTATION 0,0 2 0");
let point = authoring_control(&app, "authoring-tool-flip-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab].active_cmd.is_none());
let point = authoring_control(&app, "authoring-tool-rotate-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab]
.active_cmd
.as_ref()
.unwrap()
.prompt()
.contains("Rotate"));
let _ = app.update(Message::CommandEscape);
let _ = app.run_command_line("BPARAMETER FLIP 0,0 2,0 -1,1");
let point = authoring_control(&app, "authoring-tool-flip-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab]
.active_cmd
.as_ref()
.unwrap()
.prompt()
.contains("Flip"));
});
}
#[test]
fn lookup_basepoint_palette_and_table_dialog_real_controls() {
with_stack(|| {
+2
View File
@@ -413,6 +413,8 @@ impl OpenCADStudio {
self.lookup_table_error(),
self.action_error(Some(crate::scene::block_actions::Kind::Move)),
self.action_error(Some(crate::scene::block_actions::Kind::Scale)),
self.action_error(Some(crate::scene::block_actions::Kind::Rotate)),
self.action_error(Some(crate::scene::block_actions::Kind::Flip)),
)
}
crate::ui::dock::PanelId::BlockPalette => {