feat(blocks): edit action selections and refresh evaluated references

Add BACTIONSET and the Block Editor Action Selection ribbon entry. Pick or preselect an action, replace/add/remove its drawing targets, or redefine a Stretch frame while preserving the native action and graph identity.

Retain existing Stretch vertex bindings during target edits and bind new targets against the stored frame. Allow removing invalid bindings or rebuilding them through a replacement frame. Rebuild evaluated references at their independent cached values so removed targets lose old action effects, with local undo, discard and one drawing save delta.

Validate synthetic native DWG/DXF roundtrips, shared COPY representations, stale-result guards, reference geometry refresh, frame/cancel/history and the real ribbon flyout. 347 block tests pass (6 existing opt-in ignored), plus 42 dock and 27 viewport tests.
This commit is contained in:
Karim Jerbi committed 2026-10-17 10:22:14 +01:00
1 parent 042f26a4cc
commit 7c91f5835e
15 files changed
+1083 -69

No files matched your search

+98 -17
View File
@@ -30,6 +30,55 @@ impl OpenCADStudio {
}
None
}
pub(in crate::app) fn action_set_error(&self) -> Option<&'static str> {
let tab = &self.tabs[self.active_tab];
let Some(session) = tab.active_block_edit_session() else {
return Some("BACTIONSET is only available in the Block Editor.");
};
if let Some(e) = crate::scene::block_point_parameter::placement_error(
&tab.scene.document,
session.br_handle,
) {
return Some(e);
}
actions::actions(&tab.scene.document, session.br_handle)
.is_empty()
.then_some("This block has no supported actions to edit.")
}
pub(in crate::app) fn start_block_action_set(&mut self) -> Task<Message> {
if let Some(e) = self.action_set_error() {
self.command_line.push_error(crate::t!(e).as_ref());
return Task::none();
}
let tab = &self.tabs[self.active_tab];
let command = crate::modules::block_editor::action_set::ActionSetCommand::new(
tab.id,
tab.active_block_edit_session().unwrap().br_handle,
&tab.scene.document,
tab.selected_authoring,
);
self.command_line.push_info(&command.prompt());
self.command_line.set_step_options(command.options());
self.tabs[self.active_tab].active_cmd = Some(Box::new(command));
Task::none()
}
fn apply_action_document(&mut self, i: usize, mut staged: codec::CadDocument) {
let doc = &mut self.tabs[i].scene.document;
let changes = staged
.entities()
.filter(|e| doc.get_entity(e.common().handle) != Some(*e))
.cloned()
.collect::<Vec<_>>();
doc.swap_structure(staged.snapshot_structure());
for entity in changes {
let handle = entity.common().handle;
if let Some(current) = doc.get_entity_mut(handle) {
*current = entity;
} else {
doc.restore_entity_arc(std::sync::Arc::new(entity));
}
}
}
pub(in crate::app) fn start_block_action(&mut self, args: &str) -> Task<Message> {
let kind = if args.is_empty() {
None
@@ -69,6 +118,53 @@ impl OpenCADStudio {
) -> Task<Message> {
let i = self.active_tab;
match result {
crate::command::CmdResult::EditBlockActionSet {
document,
definition,
action,
expected,
targets,
frame,
} => {
let tab = &self.tabs[i];
if tab.id != document
|| tab.active_block_edit_session().map(|s| s.br_handle) != Some(definition)
|| tab.scene.document.objects.get(&action) != Some(expected.as_ref())
{
self.command_line.push_error(
crate::t!(
"The action or its Block Editor session changed; restart BACTIONSET."
)
.as_ref(),
);
return Task::none();
}
let mut staged = tab.scene.document.clone();
let result = actions::retarget(&mut staged, definition, action, &targets, frame)
.and_then(|changed| {
if changed {
staged = actions::instance::refresh(&staged, definition)?;
}
Ok(changed)
});
match result {
Ok(changed) => {
if changed {
self.push_undo_snapshot(i, "Action selection set");
self.apply_action_document(i, staged);
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.finish_pending_history(i);
}
self.tabs[i].active_cmd = None;
self.tabs[i].scene.clear_preview_wire();
self.refresh_properties();
self.command_line
.push_output(crate::t!("Action selection updated.").as_ref());
}
Err(e) => self.command_line.push_error(crate::t!(e).as_ref()),
}
}
crate::command::CmdResult::PlaceBlockAction {
document,
definition,
@@ -427,24 +523,9 @@ impl OpenCADStudio {
};
match actions::instance::evaluate_value(&tab.scene.document, handle, parameter, local) {
Ok(staged) => {
if let Some(mut staged) = staged {
if let Some(staged) = staged {
self.push_undo_snapshot(i, "Block reference parameter");
let doc = &mut self.tabs[i].scene.document;
let changes = staged
.entities()
.filter(|e| doc.get_entity(e.common().handle) != Some(*e))
.cloned()
.collect::<Vec<_>>();
// Structure swapping retains the live store and its transaction recorder.
doc.swap_structure(staged.snapshot_structure());
for entity in changes {
let h = entity.common().handle;
if let Some(current) = doc.get_entity_mut(h) {
*current = entity
} else {
doc.restore_entity_arc(std::sync::Arc::new(entity));
}
}
self.apply_action_document(i, staged);
self.tabs[i].scene.bump_geometry();
self.tabs[i].dirty = true;
self.finish_pending_history(i);
+16
View File
@@ -150,6 +150,22 @@ impl OpenCADStudio {
candidate_handles.insert(record.block_end_handle);
}
}
// Action-set edits can rebuild anonymous references outside the editor
// definition. Include their entities in the single drawing save delta.
for document in [before_doc, &tab.scene.document] {
for entity in document.entities() {
if let EntityType::Insert(insert) = entity {
if document.dynamic_definition_for_insert(insert.common.handle)
== Some(session.br_handle)
{
candidate_handles.insert(insert.common.handle);
if let Some(record) = document.block_records.get(&insert.block_name) {
candidate_handles.extend(record.entity_handles.iter().copied());
}
}
}
}
}
let mut sub_handles = Vec::new();
for &h in &candidate_handles {
let entity_opt = before_doc
+207
View File
@@ -59,6 +59,213 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit);
}
fn edit_action_set(app: &mut OpenCADStudio, action: Handle, mode: &str, targets: Vec<Handle>) {
app.tabs[app.active_tab].selected_authoring = Some(action);
let _ = app.dispatch_command("BACTIONSET");
feed(app, mode);
let _ = app.feed_command(crate::command::StepInput::SelectionComplete(targets));
let _ = app.feed_command(crate::command::StepInput::Enter);
assert!(app.tabs[app.active_tab].active_cmd.is_none());
}
#[test]
fn block_actions_set_edit_refresh_local_history_discard_save_and_drawing_history() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
circle(&mut app, 3.);
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].clone();
let targets = members(&app);
let action = actions::create(
&mut app.tabs[i].scene.document,
def,
Kind::Move,
p.handle,
&targets[..1],
glam::DVec3::new(3., 4., 0.),
)
.unwrap();
edit_action_set(&mut app, action, "Add", targets[1..].to_vec());
assert_eq!(
actions::actions(&app.tabs[i].scene.document, def)[0]
.1
.dependencies,
targets
);
app.undo_steps(1);
assert_eq!(
actions::actions(&app.tabs[i].scene.document, def)[0]
.1
.dependencies,
targets[..1]
);
app.redo_steps(1);
edit_action_set(&mut app, action, "Remove", targets[..1].to_vec());
assert_eq!(
actions::actions(&app.tabs[i].scene.document, def)[0]
.1
.dependencies,
targets[1..]
);
edit_action_set(&mut app, action, "New", targets[..1].to_vec());
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BCLOSE");
app.tabs[i].scene.selected.insert(reference);
app.refresh_properties();
let grip = app.tabs[i]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap()
.id;
assert!(app.start_test_action_grip(reference, grip));
feed(&mut app, "12,0");
let check = |app: &OpenCADStudio, start: f64, center: f64| {
let doc = &app.tabs[app.active_tab].scene.document;
let EntityType::Insert(ins) = doc.get_entity(reference).unwrap() else {
panic!()
};
let members = &doc
.block_records
.get(&ins.block_name)
.unwrap()
.entity_handles;
let EntityType::Line(line) = doc.get_entity(members[0]).unwrap() else {
panic!()
};
let EntityType::Circle(circle) = doc.get_entity(members[1]).unwrap() else {
panic!()
};
assert_eq!(line.start.x, start);
assert_eq!(circle.center.x, center);
};
check(&app, 2., 3.);
begin(&mut app, reference);
let depth = app.tabs[i].history.undo_stack.len();
edit_action_set(&mut app, action, "New", targets[1..].to_vec());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
check(&app, 0., 5.);
app.undo_steps(1);
check(&app, 2., 3.);
app.redo_steps(1);
check(&app, 0., 5.);
let _ = app.dispatch_command("BEDIT_DISCARD");
check(&app, 2., 3.);
begin(&mut app, reference);
edit_action_set(&mut app, action, "New", targets[1..].to_vec());
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BCLOSE");
check(&app, 0., 5.);
app.undo_steps(1);
check(&app, 2., 3.);
assert_eq!(
actions::actions(&app.tabs[i].scene.document, def)[0]
.1
.dependencies,
targets[..1]
);
app.redo_steps(1);
check(&app, 0., 5.);
let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap();
app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes))
.read()
.unwrap();
check(&app, 0., 5.);
assert_eq!(
actions::actions(&app.tabs[i].scene.document, def)[0]
.1
.dependencies,
targets[1..]
);
}
#[test]
fn block_actions_set_frame_cancel_stale_and_context_guards() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BACTIONSET");
assert_eq!(
app.tabs[app.active_tab].active_cmd.as_ref().unwrap().name(),
"LINE"
);
let _ = app.update(Message::CommandEscape);
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Stretch)[0].handle;
let targets = members(&app);
let action = actions::create_with_frame(
&mut app.tabs[i].scene.document,
def,
Kind::Stretch,
p,
&targets,
glam::DVec3::new(3., 4., 0.),
Some([glam::DVec3::new(0.5, -1., 0.), glam::DVec3::new(2., 1., 0.)]),
)
.unwrap();
let original = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("BACTIONSET");
let name = actions::actions(&original, def)[0].1.element.name.clone();
feed(&mut app, &name);
feed(&mut app, "Frame");
feed(&mut app, "-1,-1");
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, original);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
app.tabs[i].selected_authoring = Some(action);
let _ = app.dispatch_command("BACTIONSET");
feed(&mut app, "Frame");
feed(&mut app, "-1,-1");
feed(&mut app, "-1,1");
assert_eq!(app.tabs[i].scene.document, original);
feed(&mut app, "0.5,1");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(
actions::frame_outline(&app.tabs[i].scene.document, action)[0],
Vector3::new(-1., -1., 0.)
);
app.undo_steps(1);
assert_eq!(
app.tabs[i].scene.document.objects[&action],
original.objects[&action]
);
let expected = Box::new(original.objects[&action].clone());
let _ = app.handle_block_action_result(crate::command::CmdResult::EditBlockActionSet {
document: app.tabs[i].id + 1,
definition: def,
action,
expected: expected.clone(),
targets: targets.clone(),
frame: None,
});
assert_eq!(app.tabs[i].scene.document, original);
crate::scene::block_authoring::edit::move_part(
&mut app.tabs[i].scene.document,
def,
action,
crate::scene::block_authoring::edit::Part::Point,
Vector3::new(8., 8., 0.),
)
.unwrap();
let stale = app.tabs[i].scene.document.clone();
let _ = app.handle_block_action_result(crate::command::CmdResult::EditBlockActionSet {
document: app.tabs[i].id,
definition: def,
action,
expected,
targets,
frame: None,
});
assert_eq!(app.tabs[i].scene.document, stale);
}
#[test]
fn block_actions_stretch_frame_selection_history_test_and_saved_grip() {
use crate::scene::block_actions::{self as actions, Kind};
+1
View File
@@ -579,6 +579,7 @@ impl OpenCADStudio {
return self.handle_authoring_edit(result);
}
result @ (CmdResult::PlaceBlockAction { .. }
| CmdResult::EditBlockActionSet { .. }
| CmdResult::EvaluateBlockParameter { .. }) => {
return self.handle_block_action_result(result);
}
+1
View File
@@ -391,6 +391,7 @@ impl OpenCADStudio {
return Some(Task::none());
}
match cmd {
"BACTIONSET" => Some(self.start_block_action_set()),
cmd if cmd == "BACTION"
|| cmd.starts_with("BACTION ")
|| cmd == "BACTIONTOOL"
+7 -1
View File
@@ -175,12 +175,18 @@ impl OpenCADStudio {
};
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if cmd == "BACTIONSET" {
if let Some(error) = self.action_set_error() {
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
}
if matches!(cmd.split(' ').next(), Some("BACTION" | "BACTIONTOOL")) {
let args = cmd.split_once(' ').map_or("", |(_, a)| a.trim());
if !args.is_empty() && crate::scene::block_actions::Kind::parse(args).is_none() {
self.command_line.push_error(
crate::t!(
"Supported actions: Move, Scale. Other action types are not wired yet."
"Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet."
)
.as_ref(),
);
+69
View File
@@ -1244,6 +1244,75 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman
});
}
#[test]
fn block_actions_set_ribbon_flyout_starts_action_selection_and_uses_preselected_marker() {
with_stack(|| {
use crate::scene::block_actions::{self as actions, Kind};
let mut app = authoring_app();
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].handle;
let targets = app.tabs[i]
.scene
.document
.block_records
.get("AUTHOR")
.unwrap()
.entity_handles
.clone();
let a = actions::create(
&mut app.tabs[i].scene.document,
def,
Kind::Move,
p,
&targets,
glam::DVec3::new(3., 4., 0.),
)
.unwrap();
app.tabs[i].selected_authoring = Some(a);
app.ribbon
.set_collapse_mode(crate::ui::ribbon::CollapseMode::Collapsed);
// The top-level Manage tab has the same label as this panel opener.
// Open the existing panel, then click the new command in its real flyout.
let _ = app.update(Message::ToggleRibbonPanel("Manage".into()));
assert_eq!(app.ribbon.collapsed_open.as_deref(), Some("Manage"));
let messages = {
let mut simulator = iced_test::Simulator::with_size(
iced::Settings::default(),
VIEW_SIZE,
app.view_main(),
);
simulator
.click("Action Selection")
.expect("action selection in Manage flyout");
simulator.into_messages().collect::<Vec<_>>()
};
for message in messages {
let _ = app.update(message);
}
assert_eq!(
app.tabs[i].active_cmd.as_ref().unwrap().name(),
"BACTIONSET"
);
assert!(app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.prompt()
.contains("New/Add/Remove"));
let _ = app.feed_command(crate::command::StepInput::Enter);
assert!(app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
let before = app.tabs[i].scene.document.clone();
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
});
}
#[test]
fn block_actions_stretch_palette_requires_distance_parameter() {
with_stack(|| {