feat(block-editor): wire Rotation, Alignment and Flip parameter placement

Enable the existing palette rows and BPARAMETER choices. Rotation accepts a
center, picked or numeric radius, and picked or numeric default angle.
Alignment places a tangent direction from its base point. Flip places a
reflection line and state label, with native grip and state expressions.

Extend shared graph append/capacity checks for Flip's additional state node
while preserving existing subgraphs. Keep Polar/Rotation angle labels unique
in either placement order. Add editor graphics, extents and definition
reframing through the existing overlay/history/save paths.

This milestone implements native parameter placement and persistence.
Rotate/Flip action association, reference alignment/grip editing, parameter
property editing and parameter sets remain separate work.

Validation covers command and palette placement, cancellation, local/parent
undo, save/discard, native DWG/DXF objects, existing graph preservation and
the 17 research fixtures. Broader block/dock regressions and editor renders
are also checked.
This commit is contained in:
Karim Jerbi committed 2026-10-15 16:50:47 +01:00
1 parent 6dab5a7229
commit 72c762e944
19 files changed
+1483 -25

No files matched your search

+149
View File
@@ -86,6 +86,155 @@ fn distance_parameter_count(app: &OpenCADStudio) -> usize {
crate::scene::block_distance_parameter::parameters(&tab.scene.document, definition).len()
}
fn orientation_parameter_count(app: &OpenCADStudio) -> usize {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_orientation_parameter::parameters(&tab.scene.document, definition).len()
}
#[test]
fn orientation_parameter_batch_numeric_rotation_local_history_save_and_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
for (index, (kind, inputs)) in [
("ROTATION", vec!["0,0", "2", "90"]),
("ALIGNMENT", vec!["0,0", "0,2"]),
("FLIP", vec!["0,0", "0,2", "3,3"]),
]
.into_iter()
.enumerate()
{
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
for text in &inputs[..inputs.len() - 1] {
feed(&mut app, text);
assert_eq!(app.tabs[i].scene.document, before, "incomplete placement");
}
feed(&mut app, inputs.last().unwrap());
assert_eq!(orientation_parameter_count(&app), index + 1, "{kind}");
assert_eq!(app.tabs[i].history.undo_stack.len(), index + 1);
assert!(app.tabs[i].scene.preview_wires.is_empty());
app.undo_steps(1);
assert_eq!(orientation_parameter_count(&app), index);
app.redo_steps(1);
assert_eq!(orientation_parameter_count(&app), index + 1);
}
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let p = &crate::scene::block_orientation_parameter::parameters(
&app.tabs[i].scene.document,
definition,
)[0];
assert!((p.native.definition_end_point - Vector3::new(0.0, 2.0, 0.0)).length() < 1e-10);
assert_eq!(p.reference, Vector3::new(2.0, 0.0, 0.0));
let editor = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BTESTBLOCK");
assert!(app.tabs[app.active_tab]
.active_block_edit_session()
.is_none());
assert_eq!(
app.tabs[app.active_tab]
.scene
.document
.entities_in_block("EDIT_ME")
.count(),
1
);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.tabs[i].scene.document, editor);
let _ = app.run_command_line("UCS ORIGIN 1,2,0");
for p in crate::scene::block_orientation_parameter::parameters(
&app.tabs[i].scene.document,
definition,
) {
assert_eq!(
p.native.definition_base_point,
Vector3::new(-1.0, -2.0, 0.0)
);
}
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(orientation_parameter_count(&app), 3);
app.undo_steps(1);
assert_eq!(orientation_parameter_count(&app), 0);
app.redo_steps(1);
assert_eq!(orientation_parameter_count(&app), 3);
}
#[test]
fn orientation_parameter_chooser_cancels_invalid_picks_and_rotation_default_angle() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for (kind, maximum) in [("R", 2), ("A", 1), ("F", 2)] {
for picks in 0..=maximum {
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, kind);
if picks > 0 {
feed(&mut app, "0,0");
feed(&mut app, "0,0");
assert_eq!(app.tabs[i].scene.document, before);
}
if picks > 1 {
feed(&mut app, "0,2");
}
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
let _ = app.dispatch_command("BPARAMETER ROTATION");
feed(&mut app, "0,0");
feed(&mut app, "-2");
assert_eq!(app.tabs[i].scene.document, before);
feed(&mut app, "2");
feed(&mut app, "");
assert_eq!(orientation_parameter_count(&app), 1);
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(orientation_parameter_count(&app), 0);
let _ = app.dispatch_command("LINE");
for kind in ["ROTATION", "ALIGNMENT", "FLIP"] {
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
}
#[test]
fn orientation_parameter_stale_context_and_invalid_result_do_not_start_history() {
use crate::scene::block_orientation_parameter::Kind;
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let id = app.tabs[i].id;
let before = app.tabs[i].scene.document.clone();
for (document_id, end) in [
(id + 1, glam::DVec3::Y),
(id, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_orientation_parameter(
crate::command::CmdResult::PlaceBlockOrientationParameter {
document_id,
definition,
kind: Kind::Flip,
base: glam::DVec3::ZERO,
end,
reference: glam::DVec3::X,
},
);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
#[test]
fn distance_parameter_batch_three_picks_local_undo_redo_and_saved_parent_undo() {
let (mut app, reference) = fixture();
+3
View File
@@ -575,6 +575,9 @@ impl OpenCADStudio {
return task;
}
}
result @ CmdResult::PlaceBlockOrientationParameter { .. } => {
return self.handle_place_block_orientation_parameter(result);
}
result @ CmdResult::PlaceBlockDistanceParameter { .. } => {
return self.handle_place_block_distance_parameter(result);
}
+71 -2
View File
@@ -7,6 +7,66 @@ use codec::{CadDocument, EntityType, Handle};
use iced::Task;
impl OpenCADStudio {
pub(in crate::app) fn handle_place_block_orientation_parameter(
&mut self,
result: crate::command::CmdResult,
) -> Task<Message> {
let crate::command::CmdResult::PlaceBlockOrientationParameter {
document_id,
definition,
kind,
base,
end,
reference,
} = result
else {
unreachable!()
};
let i = self.active_tab;
if self.tabs[i].id != document_id
|| self.tabs[i]
.active_block_edit_session()
.map(|s| s.br_handle)
!= Some(definition)
{
self.command_line.push_error(
crate::t!("The parameter's Block Editor session is no longer active.").as_ref(),
);
return Task::none();
}
let vector = |p: glam::DVec3| codec::types::Vector3::new(p.x, p.y, p.z);
if let Some(error) = self.parameter_placement_error(kind.name()).or_else(|| {
crate::scene::block_orientation_parameter::points_error(
kind,
vector(base),
vector(end),
vector(reference),
)
}) {
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
self.push_undo_snapshot(i, &format!("{} parameter", kind.name()));
crate::scene::block_orientation_parameter::create(
&mut self.tabs[i].scene.document,
definition,
kind,
vector(base),
vector(end),
vector(reference),
)
.expect("validated parameter placement");
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!("{} parameter placed.", crate::t!(kind.name())).as_ref());
Task::none()
}
pub(in crate::app) fn handle_place_block_distance_parameter(
&mut self,
result: crate::command::CmdResult,
@@ -87,10 +147,11 @@ impl OpenCADStudio {
session.br_handle,
)
}
"ROTATION" | "R" | "ALIGNMENT" | "A" | "FLIP" | "F" => crate::scene::block_orientation_parameter::placement_error(&tab.scene.document, session.br_handle, crate::scene::block_orientation_parameter::Kind::parse(kind).unwrap()),
"VISIBILITY" | "V" => {
visibility::placement_error(&tab.scene.document, session.br_handle)
}
_ => Some("Supported parameter types: Point, Linear, Polar, XY, Visibility."),
_ => Some("Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility."),
}
}
@@ -333,6 +394,14 @@ impl OpenCADStudio {
return Some(Task::none());
};
let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim();
if let Some(kind) =
crate::scene::block_orientation_parameter::Kind::parse(parameter_type)
{
let command = crate::modules::block_editor::orientation_parameter::OrientationParameterCommand::new(self.tabs[i].id,block,kind);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Some(Task::none());
}
if let Some(kind) =
crate::scene::block_distance_parameter::Kind::parse(parameter_type)
{
@@ -359,7 +428,7 @@ impl OpenCADStudio {
{
self.command_line.push_error(
crate::t!(
"Supported parameter types: Point, Linear, Polar, XY, Visibility."
"Supported parameter types: Point, Linear, Polar, XY, Rotation, Alignment, Flip, Visibility."
)
.as_ref(),
);
+48
View File
@@ -863,6 +863,54 @@ fn distance_parameter_batch_starts_from_actual_palette_rows() {
});
}
#[test]
fn orientation_parameter_batch_starts_from_actual_palette_rows() {
with_stack(|| {
for (id, name, inputs) in [
(
"authoring-tool-rotation",
"rotation",
vec!["0.35,-0.12", "0.65,-0.12", "0.35,0.2"],
),
(
"authoring-tool-alignment",
"alignment",
vec!["0.25,-0.1", "0.6,0.15"],
),
(
"authoring-tool-flip",
"flip",
vec!["0.35,-0.15", "0.35,0.15", "0.6,0.15"],
),
] {
let mut app = authoring_app();
let _ = app.dispatch_command("BAUTHORPALETTE");
let point = authoring_control(&app, id);
authoring_click(&mut app, point);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for text in &inputs[..inputs.len() - 1] {
let _ = app.update(Message::CommandInput((*text).into()));
let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[i].scene.document, before);
}
let _ = app.update(Message::CommandInput(inputs.last().unwrap().to_string()));
let _ = app.update(Message::CommandSubmit);
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let found = crate::scene::block_orientation_parameter::parameters(
&app.tabs[i].scene.document,
definition,
);
assert_eq!(found.len(), 1);
assert!(found[0].kind.name().eq_ignore_ascii_case(name));
assert!(app.tabs[i].scene.preview_wires.is_empty());
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
let _ = app.run_command_line("ZOOM E");
authoring_capture(&app, &format!("{name}-parameter-placed"));
}
});
}
fn authoring_app() -> OpenCADStudio {
use codec::{
entities::{Insert, Line},
+1
View File
@@ -408,6 +408,7 @@ impl OpenCADStudio {
chrome,
self.visibility_placement_error(),
self.point_parameter_placement_error(),
self.parameter_placement_error("FLIP"),
)
}
crate::ui::dock::PanelId::BlockPalette => {
+26 -3
View File
@@ -1083,7 +1083,26 @@ bg={bg_ms:.1}ms n={view_count}"
session.br_handle,
)
.iter()
.filter_map(|parameter| {
.map(|p| {
(
crate::scene::block_distance_parameter::graphic(p),
p.native.definition_base_point,
)
})
.chain(
crate::scene::block_orientation_parameter::parameters(
&tab.scene.document,
session.br_handle,
)
.iter()
.map(|p| {
(
crate::scene::block_orientation_parameter::graphic(p),
crate::scene::block_orientation_parameter::marker_tail(p),
)
}),
)
.filter_map(|(graphic, marker_tail)| {
let project = |p: codec::types::Vector3| {
let p = tab
.scene
@@ -1093,7 +1112,6 @@ bg={bg_ms:.1}ms n={view_count}"
(p.x.is_finite() && p.y.is_finite())
.then_some(iced::Point::new(vp_bounds.x + p.x, vp_bounds.y + p.y))
};
let graphic = crate::scene::block_distance_parameter::graphic(parameter);
let lines = graphic
.lines
.into_iter()
@@ -1111,7 +1129,7 @@ bg={bg_ms:.1}ms n={view_count}"
Some((
project(point)?,
if oriented {
Some(project(parameter.native.definition_base_point)?)
Some(project(marker_tail)?)
} else {
None
},
@@ -1120,6 +1138,11 @@ bg={bg_ms:.1}ms n={view_count}"
.collect::<Option<Vec<_>>>()?;
Some(crate::ui::overlay::AuthoringDimension {
lines,
round_grips: graphic
.round_grips
.into_iter()
.map(project)
.collect::<Option<Vec<_>>>()?,
dimensions: graphic
.dimensions
.into_iter()