feat(block-editor): wire Linear, Polar and XY parameter placement

Enable the three existing palette rows and BPARAMETER choices. Pick a base,
endpoint and label location to create the appropriate native parameter, one
end grip and its location expressions in the shared authoring graph.

Render editor dimensions and labels, preserve them when reframing the block,
and include them in editor extents. Reuse local undo/redo, save/discard and
native DWG/DXF persistence. Reject incomplete or invalid placement without
changing document data or history.

This milestone implements parameter placement. Action association/evaluation,
parameter property editing, alternate grip counts and parameter sets remain
separate work.

Validation: focused placement and palette interaction checks; Block Editor and
dock regressions; native append and DWG/DXF round trips in all 17 research
fixtures, preserving existing graph data and source files; app-view renders.
This commit is contained in:
Karim Jerbi committed 2026-10-15 16:00:15 +01:00
1 parent cdfb4ed632
commit 6dab5a7229
16 files changed
+1378 -26

No files matched your search

+144
View File
@@ -74,6 +74,150 @@ fn authored_points(app: &OpenCADStudio) -> Vec<codec::objects::BlockPointParamet
.collect()
}
fn distance_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_distance_parameter::parameters(&tab.scene.document, definition).len()
}
#[test]
fn distance_parameter_batch_three_picks_local_undo_redo_and_saved_parent_undo() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
for (index, kind) in ["LINEAR", "POLAR", "XY"].iter().enumerate() {
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
feed(&mut app, "2,3");
feed(&mut app, "6,7");
assert_eq!(
app.tabs[i].scene.document, before,
"base and endpoint are preview only"
);
feed(&mut app, "1,8");
assert_eq!(distance_parameter_count(&app), index + 1);
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!(distance_parameter_count(&app), index);
app.redo_steps(1);
assert_eq!(distance_parameter_count(&app), index + 1);
}
let _ = app.run_command_line("BPARAMETER POINT 0,0 1,1");
let _ = app.run_command_line("BPARAMETER VISIBILITY 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,
"parameters and their editor graphics add no drawing geometry"
);
assert_eq!(distance_parameter_count(&app), 3);
let _ = app.dispatch_command("BCLOSETESTBLOCK");
assert_eq!(app.active_tab, i);
assert_eq!(app.tabs[i].scene.document, editor);
let _ = app.run_command_line("UCS ORIGIN 1,2,0");
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
for p in
crate::scene::block_distance_parameter::parameters(&app.tabs[i].scene.document, definition)
{
assert_eq!(p.native.definition_base_point, Vector3::new(1.0, 1.0, 0.0));
assert_eq!(p.native.definition_end_point, Vector3::new(5.0, 5.0, 0.0));
}
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(distance_parameter_count(&app), 3);
app.undo_steps(1);
assert_eq!(distance_parameter_count(&app), 0);
app.redo_steps(1);
assert_eq!(distance_parameter_count(&app), 3);
}
#[test]
fn distance_parameter_cancel_chooser_invalid_endpoint_and_discard_are_safe() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for kind in ["Linear", "Polar", "XY"] {
for picks in 0..3 {
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, kind);
if picks > 0 {
feed(&mut app, "2,3");
}
if picks > 1 {
feed(&mut app, "6,7");
}
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(&format!("BPARAMETER {kind}"));
feed(&mut app, "2,3");
feed(&mut app, "2,3");
assert_eq!(app.tabs[i].scene.document, before);
assert!(
app.tabs[i].active_cmd.is_some(),
"invalid endpoint can be retried"
);
feed(&mut app, "6,7");
feed(&mut app, "1,8");
assert_eq!(distance_parameter_count(&app), 1);
app.undo_steps(1);
}
let _ = app.run_command_line("BPARAMETER LINEAR 0,0 1,1 2,2");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(distance_parameter_count(&app), 0);
let _ = app.dispatch_command("LINE");
for kind in ["LINEAR", "POLAR", "XY"] {
let _ = app.dispatch_command(&format!("BPARAMETER {kind}"));
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
}
}
#[test]
fn distance_parameter_stale_context_and_invalid_result_do_not_start_history() {
use crate::scene::block_distance_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::ONE),
(id, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_distance_parameter(
crate::command::CmdResult::PlaceBlockDistanceParameter {
document_id,
definition,
kind: Kind::Linear,
base: glam::DVec3::ZERO,
end,
label: glam::DVec3::ZERO,
},
);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
}
#[test]
fn point_parameter_placement_two_picks_one_undo_and_repeated_names() {
let (mut app, reference) = fixture();
+3
View File
@@ -575,6 +575,9 @@ impl OpenCADStudio {
return task;
}
}
result @ CmdResult::PlaceBlockDistanceParameter { .. } => {
return self.handle_place_block_distance_parameter(result);
}
result @ CmdResult::PlaceBlockPointParameter { .. } => {
return self.handle_place_block_point_parameter(result);
}
+76 -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_distance_parameter(
&mut self,
result: crate::command::CmdResult,
) -> Task<Message> {
let crate::command::CmdResult::PlaceBlockDistanceParameter {
document_id,
definition,
kind,
base,
end,
label,
} = 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_distance_parameter::points_error(
kind,
vector(base),
vector(end),
vector(label),
)
}) {
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_distance_parameter::create(
&mut self.tabs[i].scene.document,
definition,
kind,
vector(base),
vector(end),
vector(label),
)
.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 point_parameter_placement_error(&self) -> Option<&'static str> {
self.parameter_placement_error("POINT")
}
@@ -21,10 +81,16 @@ impl OpenCADStudio {
&tab.scene.document,
session.br_handle,
),
"LINEAR" | "L" | "POLAR" | "PO" | "XY" => {
crate::scene::block_distance_parameter::placement_error(
&tab.scene.document,
session.br_handle,
)
}
"VISIBILITY" | "V" => {
visibility::placement_error(&tab.scene.document, session.br_handle)
}
_ => Some("Only Point and Visibility parameter placement is currently supported."),
_ => Some("Supported parameter types: Point, Linear, Polar, XY, Visibility."),
}
}
@@ -267,6 +333,14 @@ impl OpenCADStudio {
return Some(Task::none());
};
let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim();
if let Some(kind) =
crate::scene::block_distance_parameter::Kind::parse(parameter_type)
{
let command = crate::modules::block_editor::distance_parameter::DistanceParameterCommand::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 parameter_type.eq_ignore_ascii_case("POINT")
|| parameter_type.eq_ignore_ascii_case("P")
{
@@ -285,7 +359,7 @@ impl OpenCADStudio {
{
self.command_line.push_error(
crate::t!(
"Only Point and Visibility parameter placement is currently supported."
"Supported parameter types: Point, Linear, Polar, XY, Visibility."
)
.as_ref(),
);
+36
View File
@@ -827,6 +827,42 @@ fn point_parameter_placement_starts_from_the_actual_palette_row() {
});
}
#[test]
fn distance_parameter_batch_starts_from_actual_palette_rows() {
with_stack(|| {
for (id, name) in [
("authoring-tool-linear", "linear"),
("authoring-tool-polar", "polar"),
("authoring-tool-xy", "xy"),
] {
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 ["0.25,-0.15", "0.65,0.15"] {
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("0.3,0.2".into()));
let _ = app.update(Message::CommandSubmit);
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let found = crate::scene::block_distance_parameter::parameters(
&app.tabs[i].scene.document,
definition,
);
assert_eq!(found.len(), 1);
assert!(found[0].kind.name().eq_ignore_ascii_case(name));
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
assert!(app.tabs[i].scene.preview_wires.is_empty());
let _ = app.run_command_line("ZOOM E");
authoring_capture(&app, &format!("{name}-parameter-placed"));
}
});
}
fn authoring_app() -> OpenCADStudio {
use codec::{
entities::{Insert, Line},
+59
View File
@@ -1075,6 +1075,64 @@ bg={bg_ms:.1}ms n={view_count}"
.collect()
})
.unwrap_or_default();
let authoring_dimensions = tab
.active_block_edit_session()
.map(|session| {
crate::scene::block_distance_parameter::parameters(
&tab.scene.document,
session.br_handle,
)
.iter()
.filter_map(|parameter| {
let project = |p: codec::types::Vector3| {
let p = tab
.scene
.camera
.borrow()
.project(glam::DVec3::new(p.x, p.y, p.z), vp_bounds)?;
(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()
.map(|[a, b]| Some([project(a)?, project(b)?]))
.collect::<Option<Vec<_>>>()?;
let labels = graphic
.labels
.into_iter()
.map(|(point, label)| Some((project(point)?, label)))
.collect::<Option<Vec<_>>>()?;
let grips = graphic
.grips
.into_iter()
.map(|(point, oriented)| {
Some((
project(point)?,
if oriented {
Some(project(parameter.native.definition_base_point)?)
} else {
None
},
))
})
.collect::<Option<Vec<_>>>()?;
Some(crate::ui::overlay::AuthoringDimension {
lines,
dimensions: graphic
.dimensions
.into_iter()
.map(|[a, b]| Some([project(a)?, project(b)?]))
.collect::<Option<Vec<_>>>()?,
labels,
grips,
clip: vp_bounds,
})
})
.collect()
})
.unwrap_or_default();
crate::ui::overlay::selection_overlay(
std::sync::Arc::clone(&tab.scene.selection),
snap_info,
@@ -1141,6 +1199,7 @@ bg={bg_ms:.1}ms n={view_count}"
constraint_cursor_badge,
authoring_visibility,
authoring_points,
authoring_dimensions,
)
};