feat(blocks): wire Stretch actions and independent reference evaluation

Author a native Stretch action from a Linear, Polar or XY parameter, a two-corner stretch frame, an object selection and a marker. Preview and retain its frame in BEDIT, with selectable and movable action markers.

Freeze line and planar lightweight-polyline vertex bindings, preserve curved-segment height, move wholly selected objects, and evaluate independent saved INSERTs from their definition. Retain native DWG/DXF data, copied-reference isolation and undo/redo.

Validate with synthetic frame, topology, parameter, history, Test Block, drawing-reference, COPY and roundtrip regressions: 340 block tests pass (6 existing opt-in ignored), plus 41 dock and 27 viewport tests. Other entity types, Polar Stretch, custom multipliers and chaining remain unsupported.
This commit is contained in:
Karim Jerbi committed 2026-10-17 10:03:59 +01:00
1 parent 94940ad77c
commit 042f26a4cc
14 files changed
+827 -27

No files matched your search

+11 -3
View File
@@ -38,7 +38,7 @@ impl OpenCADStudio {
};
if !args.is_empty() && kind.is_none() {
self.command_line.push_error(
crate::t!("Supported actions: Move, Scale, Rotate, Flip. Other action types are not wired yet.")
crate::t!("Supported actions: Move, Scale, Stretch, Rotate, Flip. Other action types are not wired yet.")
.as_ref(),
);
return Task::none();
@@ -76,6 +76,7 @@ impl OpenCADStudio {
parameter,
targets,
location,
frame,
} => {
if self.tabs[i].id != document
|| self.tabs[i]
@@ -90,8 +91,15 @@ impl OpenCADStudio {
return Task::none();
}
let mut staged = self.tabs[i].scene.document.clone();
match actions::create(&mut staged, definition, kind, parameter, &targets, location)
{
match actions::create_with_frame(
&mut staged,
definition,
kind,
parameter,
&targets,
location,
frame,
) {
Ok(_) => {
self.push_undo_snapshot(i, &format!("{} action", kind.name()));
self.tabs[i].scene.document = staged;
+133
View File
@@ -59,6 +59,138 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit);
}
#[test]
fn block_actions_stretch_frame_selection_history_test_and_saved_grip() {
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let p = actions::parameters(&app.tabs[i].scene.document, def, Kind::Stretch)[0].clone();
let before = app.tabs[i].scene.document.clone();
// A half-authored frame and Escape create neither objects nor history.
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.dispatch_command("BACTION Stretch");
feed(&mut app, &p.name);
feed(&mut app, "0.5,-1");
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.dispatch_command("BACTION Stretch");
feed(&mut app, &p.name);
assert!(!app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
feed(&mut app, "0.5,-1");
feed(&mut app, "0.5,1"); // zero-width error retains first corner
assert!(!app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
feed(&mut app, "2,1");
assert!(app.tabs[i]
.active_cmd
.as_ref()
.unwrap()
.is_selection_gathering());
let targets = members(&app);
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[i].active_cmd.is_none());
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
let action = actions::actions(&app.tabs[i].scene.document, def)[0].0;
assert_eq!(
actions::frame_outline(&app.tabs[i].scene.document, action).len(),
4
);
app.undo_steps(1);
assert!(actions::actions(&app.tabs[i].scene.document, def).is_empty());
app.redo_steps(1);
let editor = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
let ti = app.active_tab;
let test_insert = app.tabs[ti].block_test.as_ref().unwrap().insert;
app.tabs[ti].scene.selected.insert(test_insert);
app.refresh_properties();
let grip = app.tabs[ti]
.selected_grips
.iter()
.find(|g| super::block_actions::is_test_grip(g.id))
.unwrap()
.id;
assert!(app.start_test_action_grip(test_insert, grip));
feed(&mut app, "12,0");
let EntityType::Line(line) = app.tabs[ti].scene.document.get_entity(targets[0]).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
assert_eq!(line.end, Vector3::new(3., 0., 0.));
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.tabs[i].scene.document, editor);
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;
let depth = app.tabs[i].history.undo_stack.len();
assert!(app.start_test_action_grip(reference, grip));
feed(&mut app, "15,0");
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
let changed = app.tabs[i].scene.document.clone();
let EntityType::Insert(insert) = changed.get_entity(reference).unwrap() else {
panic!()
};
let member = changed
.block_records
.get(&insert.block_name)
.unwrap()
.entity_handles[0];
let EntityType::Line(line) = changed.get_entity(member).unwrap() else {
panic!()
};
assert_eq!(line.start, Vector3::ZERO);
assert_eq!(line.end, Vector3::new(6., 0., 0.));
assert_eq!(
changed.get_entity(targets[0]),
editor.get_entity(targets[0])
);
app.undo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(reference),
editor.get_entity(reference)
);
app.redo_steps(1);
assert_eq!(
app.tabs[i].scene.document.get_entity(member),
changed.get_entity(member)
);
let bytes = codec::DwgWriter::write_to_vec(&changed).unwrap();
app.tabs[i].scene.document = codec::DwgReader::from_stream(std::io::Cursor::new(bytes))
.read()
.unwrap();
begin(&mut app, reference);
assert_eq!(
app.tabs[i].active_block_edit_session().unwrap().br_handle,
def
);
assert_eq!(
actions::frame_outline(&app.tabs[i].scene.document, action).len(),
4
);
}
#[test]
fn block_parameters_rotation_only_save_close_reopen_then_flip() {
use crate::scene::block_actions::{self as actions, Kind};
@@ -903,6 +1035,7 @@ fn block_actions_rejected_outside_editor_preserve_running_command_and_stale_resu
parameter: p,
targets: members(&app),
location: glam::DVec3::ZERO,
frame: None,
});
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.dispatch_command("LINE");
+21
View File
@@ -1244,6 +1244,27 @@ fn block_actions_palette_controls_require_compatible_parameters_and_start_comman
});
}
#[test]
fn block_actions_stretch_palette_requires_distance_parameter() {
with_stack(|| {
let mut app = authoring_app();
let _ = app.dispatch_command("BAUTHORPALETTE");
app.authoring_palette_tab = crate::ui::window::authoring_palettes::Tab::Actions;
let point = authoring_control(&app, "authoring-tool-stretch-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab].active_cmd.is_none());
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 10,0 5,2");
let point = authoring_control(&app, "authoring-tool-stretch-action");
authoring_click(&mut app, point);
assert!(app.tabs[app.active_tab]
.active_cmd
.as_ref()
.unwrap()
.prompt()
.contains("Stretch"));
});
}
#[test]
fn block_actions_rotate_flip_palette_requires_matching_parameters() {
with_stack(|| {
+1
View File
@@ -415,6 +415,7 @@ impl OpenCADStudio {
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)),
self.action_error(Some(crate::scene::block_actions::Kind::Stretch)),
)
}
crate::ui::dock::PanelId::BlockPalette => {
+12 -1
View File
@@ -1203,8 +1203,19 @@ bg={bg_ms:.1}ms n={view_count}"
session.br_handle,
)
.into_iter()
.map(|(_, action, _, _)| {
.map(|(handle, action, _, _)| {
let mut g = crate::scene::block_distance_parameter::Graphic::default();
let frame = crate::scene::block_actions::frame_outline(
&tab.scene.document,
handle,
);
g.lines.extend(
frame
.iter()
.zip(frame.iter().cycle().skip(1))
.take(frame.len())
.map(|(a, b)| [*a, *b]),
);
g.grips.push((action.display_location, false));
g.labels
.push((action.display_location, action.element.name.clone()));