feat(block-editor): add native Point parameter placement

Enable Point in Authoring Palettes and BPARAMETER. Pick parameter and label
positions, then create the native Point, XY grip and position expressions in
one undo transaction. Draw editor-only markers and clear placement previews.

Share graph append construction with Visibility so both types coexist in
either order. Allocate node, expression and edge IDs independently; preserve
existing objects, nodes and relationships and reject malformed/opaque graphs.

Reframe parameter, label and grip with the edited definition. Include authoring
positions in editor framing while keeping reference geometry extents unchanged.
Retain save/discard and parent saved-edit undo. Action association/evaluation
and parameter property editing remain separate milestones.

Validation: 10 targeted, 286 block and 60 dock checks passed (overlapping
filters). Appending a Point to an existing native graph passed DWG/DXF
roundtrips in all 17 local fixtures; old objects and original checksums stayed
unchanged. Real-view palette interaction and rendered marker/label verified.
This commit is contained in:
Karim Jerbi committed 2026-10-15 15:33:27 +01:00
1 parent daa26007f9
commit cdfb4ed632
19 files changed
+1457 -303

No files matched your search

+142 -3
View File
@@ -59,6 +59,145 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit);
}
fn authored_points(app: &OpenCADStudio) -> Vec<codec::objects::BlockPointParameter> {
let tab = &app.tabs[app.active_tab];
let definition = tab
.scene
.document
.block_records
.get("EDIT_ME")
.unwrap()
.handle;
crate::scene::block_point_parameter::parameters(&tab.scene.document, definition)
.into_iter()
.map(|(_, parameter)| parameter.clone())
.collect()
}
#[test]
fn point_parameter_placement_two_picks_one_undo_and_repeated_names() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("BPARAMETER POINT");
feed(&mut app, "3,4,0");
assert_eq!(
app.tabs[i].scene.document, before,
"first pick is preview only"
);
feed(&mut app, "6,7,0");
assert_eq!(authored_points(&app)[0].position_name, "Position1");
assert_eq!(
authored_points(&app)[0].definition_label_point,
Vector3::new(6.0, 7.0, 0.0)
);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
assert!(app.block_edit_dirty(i));
app.undo_steps(1);
assert!(authored_points(&app).is_empty());
app.redo_steps(1);
assert_eq!(authored_points(&app).len(), 1);
let _ = app.run_command_line("BPARAMETER POINT 8,9 10,11");
assert_eq!(authored_points(&app)[1].position_name, "Position2");
assert_eq!(app.tabs[i].history.undo_stack.len(), 2);
}
#[test]
fn point_parameter_placement_cancel_at_both_prompts_is_inert() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
for picked in [false, true] {
let _ = app.dispatch_command("BPARAMETER POINT");
if picked {
feed(&mut app, "3,4");
}
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
assert!(!app.block_edit_dirty(i));
}
let _ = app.dispatch_command("BPARAMETER POINT");
feed(&mut app, "3,4");
feed(&mut app, "");
assert_eq!(app.tabs[i].scene.document, before);
assert!(app.tabs[i].history.undo_stack.is_empty());
}
#[test]
fn point_parameter_placement_chooser_works_with_existing_visibility_and_reframes() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER VISIBILITY 0,0");
let _ = app.dispatch_command("BPARAMETER");
feed(&mut app, "Point");
feed(&mut app, "3,4,5");
feed(&mut app, "6,7,5");
assert_eq!(authored_points(&app).len(), 1);
assert!(app.block_visibility_context().is_some());
let _ = app.run_command_line("UCS ORIGIN 1,2,3");
assert_eq!(
authored_points(&app)[0].parameter.definition_point,
Vector3::new(2.0, 2.0, 2.0)
);
assert_eq!(
authored_points(&app)[0].definition_label_point,
Vector3::new(5.0, 5.0, 2.0)
);
app.undo_steps(1);
assert_eq!(
authored_points(&app)[0].parameter.definition_point,
Vector3::new(3.0, 4.0, 5.0)
);
let _ = app.dispatch_command("BSAVE");
assert!(!app.block_edit_dirty(i));
let _ = app.dispatch_command("BEDIT_DISCARD");
assert_eq!(authored_points(&app).len(), 1);
app.undo_steps(1);
assert!(
authored_points(&app).is_empty(),
"saved block undo restores the parent drawing"
);
}
#[test]
fn point_parameter_placement_discard_and_stale_context_leave_no_objects() {
let (mut app, reference) = fixture();
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
begin(&mut app, reference);
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let start = app.tabs[i].scene.document.clone();
let id = app.tabs[i].id;
for (document, location) in [
(id + 1, glam::DVec3::ZERO),
(id, glam::DVec3::splat(f64::NAN)),
] {
let _ = app.handle_place_block_point_parameter(
crate::command::CmdResult::PlaceBlockPointParameter {
document_id: document,
definition,
location,
label: glam::DVec3::ZERO,
},
);
assert_eq!(app.tabs[i].scene.document, start);
}
let _ = app.run_command_line("BPARAMETER POINT 3,4 6,7");
let _ = app.dispatch_command("BEDIT_DISCARD");
let mut after = app.tabs[i].scene.document.clone();
after.vports = before.vports.clone();
assert_eq!(after, before);
let closed = app.tabs[i].scene.document.clone();
let _ = app.dispatch_command("LINE");
let _ = app.dispatch_command("BPARAMETER POINT");
assert_eq!(app.tabs[i].active_cmd.as_ref().unwrap().name(), "LINE");
assert_eq!(app.tabs[i].scene.document, closed);
}
fn visibility_editor() -> (OpenCADStudio, crate::ui::window::block_visibility::Target) {
let (mut app, reference) = fixture();
begin(&mut app, reference);
@@ -1721,8 +1860,8 @@ fn visibility_parameter_placement_rejects_duplicates_stale_context_and_nonfinite
}
#[test]
fn visibility_parameter_placement_preserves_an_existing_authoring_graph() {
use codec::objects::{DynamicBlockObject, DynamicBlockData, BlockEvaluationGraph, ObjectType};
fn visibility_parameter_placement_preserves_an_opaque_authoring_graph() {
use codec::objects::{DynamicBlockObject, DynamicBlockData, ObjectType};
let (mut app, reference) = fixture();
let i = app.active_tab;
let doc = &mut app.tabs[i].scene.document;
@@ -1732,7 +1871,7 @@ fn visibility_parameter_placement_preserves_an_existing_authoring_graph() {
let mut graph = DynamicBlockObject::new("ACAD_EVALUATION_GRAPH", "AcDbEvalGraph");
graph.handle = graph_handle;
graph.owner = dictionary;
graph.data = DynamicBlockData::EvaluationGraph(BlockEvaluationGraph::default());
graph.data = DynamicBlockData::Unknown;
doc.objects
.insert(graph_handle, ObjectType::DynamicBlock(graph));
if let Some(ObjectType::Dictionary(dictionary)) = doc.objects.get_mut(&dictionary) {
+3
View File
@@ -575,6 +575,9 @@ impl OpenCADStudio {
return task;
}
}
result @ CmdResult::PlaceBlockPointParameter { .. } => {
return self.handle_place_block_point_parameter(result);
}
CmdResult::PlaceBlockVisibilityParameter {
document_id,
definition,
+90 -2
View File
@@ -7,6 +7,80 @@ use codec::{CadDocument, EntityType, Handle};
use iced::Task;
impl OpenCADStudio {
pub(in crate::app) fn point_parameter_placement_error(&self) -> Option<&'static str> {
self.parameter_placement_error("POINT")
}
pub(in crate::app) fn parameter_placement_error(&self, kind: &str) -> Option<&'static str> {
let tab = &self.tabs[self.active_tab];
let Some(session) = tab.active_block_edit_session() else {
return Some("Open a block in the Block Editor before placing a parameter.");
};
match kind.to_ascii_uppercase().as_str() {
"POINT" | "P" | "" => crate::scene::block_point_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."),
}
}
pub(in crate::app) fn handle_place_block_point_parameter(
&mut self,
result: crate::command::CmdResult,
) -> Task<Message> {
let crate::command::CmdResult::PlaceBlockPointParameter {
document_id,
definition,
location,
label,
} = result
else {
unreachable!()
};
let i = self.active_tab;
if self.tabs[i].id != document_id
|| self.tabs[i]
.active_block_edit_session()
.map(|session| session.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();
}
if let Some(error) = self.point_parameter_placement_error() {
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
if !location.is_finite() || !label.is_finite() {
self.command_line
.push_error(crate::t!("Specify finite parameter and label locations.").as_ref());
return Task::none();
}
self.push_undo_snapshot(i, "Point parameter");
crate::scene::block_point_parameter::create(
&mut self.tabs[i].scene.document,
definition,
codec::types::Vector3::new(location.x, location.y, location.z),
codec::types::Vector3::new(label.x, label.y, label.z),
)
.expect("validated Point 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::t!("Point parameter placed.").as_ref());
Task::none()
}
pub(in crate::app) fn visibility_placement_error(&self) -> Option<&'static str> {
let tab = &self.tabs[self.active_tab];
let Some(session) = tab.active_block_edit_session() else {
@@ -193,13 +267,27 @@ impl OpenCADStudio {
return Some(Task::none());
};
let parameter_type = cmd.strip_prefix("BPARAMETER").unwrap().trim();
if parameter_type.eq_ignore_ascii_case("POINT")
|| parameter_type.eq_ignore_ascii_case("P")
{
let command =
crate::modules::block_editor::point_parameter::PointParameterCommand::new(
self.tabs[i].id,
block,
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
return Some(Task::none());
}
if !parameter_type.is_empty()
&& !parameter_type.eq_ignore_ascii_case("VISIBILITY")
&& !parameter_type.eq_ignore_ascii_case("V")
{
self.command_line.push_error(
crate::t!("Only Visibility parameter placement is currently supported.")
.as_ref(),
crate::t!(
"Only Point and Visibility parameter placement is currently supported."
)
.as_ref(),
);
return Some(Task::none());
}
+3 -1
View File
@@ -174,7 +174,9 @@ impl OpenCADStudio {
let resolved = self.resolve_alias(cmd);
let cmd = resolved.as_deref().unwrap_or(cmd);
if cmd == "BPARAMETER" || cmd.starts_with("BPARAMETER ") {
if let Some(error) = self.visibility_placement_error() {
if let Some(error) =
self.parameter_placement_error(cmd.strip_prefix("BPARAMETER").unwrap().trim())
{
self.command_line.push_error(crate::t!(error).as_ref());
return Task::none();
}
+44
View File
@@ -783,6 +783,50 @@ fn visibility_ribbon_real_controls_disable_enable_and_open_manager() {
});
}
#[test]
fn point_parameter_placement_starts_from_the_actual_palette_row() {
with_stack(|| {
let mut app = authoring_app();
let _ = app.dispatch_command("BAUTHORPALETTE");
let i = app.active_tab;
let before = app.tabs[i].scene.document.clone();
let point = authoring_control(&app, "authoring-tool-point");
authoring_click(&mut app, point);
assert_eq!(
app.tabs[i].active_cmd.as_ref().unwrap().name(),
"BPARAMETER"
);
let _ = app.update(Message::CommandInput("0.25,0.25".into()));
let _ = app.update(Message::CommandSubmit);
assert_eq!(app.tabs[i].scene.document, before);
let _ = app.update(Message::CommandInput("0.65,0.15".into()));
let _ = app.update(Message::CommandSubmit);
assert!(
app.tabs[i].scene.preview_wires.is_empty(),
"placement clears its rubber-band preview"
);
let definition = app.tabs[i].active_block_edit_session().unwrap().br_handle;
assert_eq!(
crate::scene::block_point_parameter::parameters(
&app.tabs[i].scene.document,
definition
)
.len(),
1
);
assert!(
app.visibility_placement_error().is_none(),
"Visibility can join the Point graph"
);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
authoring_capture(&app, "point-parameter-placed");
let point = authoring_control(&app, "authoring-tool-point");
authoring_click(&mut app, point);
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].history.undo_stack.len(), 1);
});
}
fn authoring_app() -> OpenCADStudio {
use codec::{
entities::{Insert, Line},
+1
View File
@@ -407,6 +407,7 @@ impl OpenCADStudio {
width,
chrome,
self.visibility_placement_error(),
self.point_parameter_placement_error(),
)
}
crate::ui::dock::PanelId::BlockPalette => {
+29
View File
@@ -1047,6 +1047,34 @@ bg={bg_ms:.1}ms n={view_count}"
(point.x.is_finite() && point.y.is_finite())
.then(|| (point, parameter.name.clone(), vp_bounds))
});
let authoring_points =
tab.active_block_edit_session()
.map(|session| {
crate::scene::block_point_parameter::parameters(
&tab.scene.document,
session.br_handle,
)
.into_iter()
.filter_map(|(_, parameter)| {
let project = |point: codec::types::Vector3| {
let point = tab.scene.camera.borrow().project(
glam::DVec3::new(point.x, point.y, point.z),
vp_bounds,
)?;
(point.x.is_finite() && point.y.is_finite()).then_some(
iced::Point::new(vp_bounds.x + point.x, vp_bounds.y + point.y),
)
};
Some((
project(parameter.parameter.definition_point)?,
project(parameter.definition_label_point)?,
parameter.position_name.clone(),
vp_bounds,
))
})
.collect()
})
.unwrap_or_default();
crate::ui::overlay::selection_overlay(
std::sync::Arc::clone(&tab.scene.selection),
snap_info,
@@ -1112,6 +1140,7 @@ bg={bg_ms:.1}ms n={view_count}"
.map(|kind| crate::t!(kind.label()).into_owned()),
constraint_cursor_badge,
authoring_visibility,
authoring_points,
)
};