feat(block-editor): select and edit native parameter and action grips

Parameters and Move/Scale action markers were drawn as overlays with no selectable authoring objects. Pick their points, labels or graphics in BEDIT to display editable definition, endpoint, reference and label grips using the existing selection overlay and point-input commands. Show the selected object's coordinates in the Properties panel.

Write edits to the native parameter/grip/action records while preserving graph identities and target bindings. Maintain Rotation radii and oriented grip directions, synchronize visibility lookup data, and retain Basepoint attribute relocation with recorded entity history. Escape cancels edits or clears selection; editor undo/redo and save/discard preserve the native changes.

Validate with self-contained viewport-input and native DWG/DXF persistence regressions, all ten parameter types, action bindings, cancellation and Basepoint attribute undo. Checks: 3 focused, 323 block (6 existing opt-in ignored), 39 dock and 23 viewport tests passed; counts overlap.

Ordinary drawing-reference grips and per-instance saved action evaluation follow in the next milestone.
This commit is contained in:
Karim Jerbi committed 2026-10-16 17:44:09 +01:00
1 parent f69aefc3df
commit 677ff0e08c
11 files changed
+991

No files matched your search

+230
View File
@@ -0,0 +1,230 @@
use super::{Message, OpenCADStudio};
use crate::command::{CadCommand, CmdResult};
use crate::scene::block_authoring::edit::{self, Part};
use codec::Handle;
use glam::DVec3;
use iced::{Point, Rectangle, Task};
const EDIT_GRIP_START: usize = usize::MAX / 4;
pub(super) fn is_editor_grip(id: usize) -> bool {
(EDIT_GRIP_START..EDIT_GRIP_START + 16).contains(&id)
}
struct EditCommand {
document: u64,
definition: Handle,
handle: Handle,
part: Part,
}
impl CadCommand for EditCommand {
fn name(&self) -> &'static str {
"BEDITGRIP"
}
fn prompt(&self) -> String {
crate::t!("Block Editor Specify the new authoring grip location (Escape cancels):")
.into_owned()
}
fn on_point(&mut self, position: DVec3) -> CmdResult {
CmdResult::EditBlockAuthoring {
document: self.document,
definition: self.definition,
handle: self.handle,
part: self.part,
position,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
impl OpenCADStudio {
pub(super) fn pick_authoring_object(&mut self, p: Point, bounds: Rectangle) -> bool {
let i = self.active_tab;
let tab = &self.tabs[i];
if tab.active_cmd.is_some() || tab.active_grip.is_some() {
return false;
}
let Some(session) = tab.active_block_edit_session() else {
return false;
};
let items = edit::items(&tab.scene.document, session.br_handle);
let camera = tab.scene.camera.borrow();
let project = |v: codec::types::Vector3| {
camera
.project(DVec3::new(v.x, v.y, v.z), bounds)
.map(|v| Point::new(v.x, v.y))
};
let segment = |a: Point, b: Point| {
let ab = glam::Vec2::new(b.x - a.x, b.y - a.y);
let ap = glam::Vec2::new(p.x - a.x, p.y - a.y);
let t = (ap.dot(ab) / ab.length_squared().max(1e-12)).clamp(0., 1.);
(ap - ab * t).length()
};
let hit = items
.iter()
.filter_map(|item| {
let mut d = f32::INFINITY;
for (_, v) in &item.grips {
if let Some(v) = project(*v) {
d = d.min(segment(v, v));
}
}
for [a, b] in &item.graphic.lines {
if let (Some(a), Some(b)) = (project(*a), project(*b)) {
d = d.min(segment(a, b));
}
}
for (v, label) in &item.graphic.labels {
if let Some(v) = project(*v) {
// Labels are drawn starting beside their anchor, in screen pixels.
let width = (label.chars().count() as f32 * 7.).max(20.);
if p.x >= v.x - 8. && p.x <= v.x + width + 12. && (p.y - v.y).abs() < 16. {
d = d.min(0.);
}
}
}
(d <= 10.).then_some((d, item.handle, item.name.clone()))
})
.min_by(|a, b| a.0.total_cmp(&b.0));
drop(camera);
if let Some((_, handle, name)) = hit {
self.tabs[i].scene.deselect_all();
self.tabs[i].selected_authoring = (!self.shift_down).then_some(handle);
self.tabs[i]
.scene
.selection
.borrow_mut()
.clear_left_selection_gesture();
self.refresh_properties();
self.command_line.push_info(&format!(
"{} — {}",
name,
crate::t!("Select a grip to edit its location.")
));
true
} else {
if self.tabs[i].selected_authoring.take().is_some() {
self.refresh_properties();
}
false
}
}
pub(super) fn refresh_authoring_grips(&mut self, wo: [f64; 3]) {
let i = self.active_tab;
let Some(handle) = self.tabs[i].selected_authoring else {
return;
};
let item = self.tabs[i].active_block_edit_session().and_then(|s| {
edit::items(&self.tabs[i].scene.document, s.br_handle)
.into_iter()
.find(|p| p.handle == handle)
});
let Some(item) = item.filter(|_| self.tabs[i].scene.selected.is_empty()) else {
self.tabs[i].selected_authoring = None;
return;
};
for (index, (_, point)) in item.grips.into_iter().enumerate() {
self.tabs[i]
.selected_grips
.push(crate::scene::model::object::GripDef {
id: EDIT_GRIP_START + index,
world: DVec3::new(point.x - wo[0], point.y - wo[1], point.z - wo[2]),
is_midpoint: false,
shape: crate::scene::model::object::GripShape::Square,
dir: None,
axis: None,
});
self.tabs[i].selected_grip_handles.push(handle);
}
}
pub(super) fn start_authoring_grip(&mut self, handle: Handle, id: usize) -> bool {
if !is_editor_grip(id) {
return false;
}
let i = self.active_tab;
let Some(session) = self.tabs[i].active_block_edit_session() else {
return false;
};
let Some(part) = edit::items(&self.tabs[i].scene.document, session.br_handle)
.into_iter()
.find(|p| p.handle == handle)
.and_then(|p| p.grips.get(id - EDIT_GRIP_START).map(|(part, _)| *part))
else {
return false;
};
let command = EditCommand {
document: self.tabs[i].id,
definition: session.br_handle,
handle,
part,
};
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
self.grip_hover = None;
self.grip_popup = None;
true
}
pub(super) fn handle_authoring_edit(&mut self, result: CmdResult) -> Task<Message> {
let CmdResult::EditBlockAuthoring {
document,
definition,
handle,
part,
position,
} = result
else {
unreachable!()
};
let i = self.active_tab;
if self.tabs[i].id != document
|| self.tabs[i]
.active_block_edit_session()
.map(|s| s.br_handle)
!= Some(definition)
{
self.command_line
.push_error(crate::t!("The Block Editor session is no longer active.").as_ref());
return Task::none();
}
let mut staged = self.tabs[i].scene.document.clone();
match edit::move_part(
&mut staged,
definition,
handle,
part,
codec::types::Vector3::new(position.x, position.y, position.z),
) {
Ok(changed) => {
if changed {
self.push_undo_snapshot(i, "Block authoring grip");
// Keep the live entity-change recorder attached. Basepoint edits
// can also relocate INSERT attributes outside the edited block.
let doc = &mut self.tabs[i].scene.document;
let changed_entities = staged
.entities()
.filter(|e| doc.get_entity(e.common().handle) != Some(*e))
.cloned()
.collect::<Vec<_>>();
for entity in changed_entities {
let handle = entity.common().handle;
*doc.get_entity_mut(handle)
.expect("authoring edits retain entity identities") = entity;
}
for (h, o) in staged.objects.iter() {
if doc.objects.get(h) != Some(o) {
doc.objects.insert(*h, o.clone());
}
}
doc.block_visibility_params = staged.block_visibility_params.clone();
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();
}
Err(e) => self.command_line.push_error(crate::t!(e).as_ref()),
}
Task::none()
}
}
+173
View File
@@ -59,6 +59,179 @@ fn feed(app: &mut OpenCADStudio, text: &str) {
let _ = app.update(Message::CommandSubmit); let _ = app.update(Message::CommandSubmit);
} }
#[test]
fn authoring_edit_basepoint_attribute_relocation_undo_and_cancel() {
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER BASEPOINT 2,3");
let def = app.tabs[i].active_block_edit_session().unwrap().br_handle;
let handle = crate::scene::block_basepoint::parameter(&app.tabs[i].scene.document, def)
.unwrap()
.0;
let mut attribute = codec::entities::AttributeEntity::new("TAG".into(), "value".into());
attribute.insertion_point = Vector3::new(7., 8., 0.);
let EntityType::Insert(insert) = app.tabs[i]
.scene
.document
.get_entity_mut(reference)
.unwrap()
else {
panic!()
};
insert.attributes.push(attribute);
let before = app.tabs[i].scene.document.clone();
let result = crate::command::CmdResult::EditBlockAuthoring {
document: app.tabs[i].id,
definition: def,
handle,
part: crate::scene::block_authoring::edit::Part::Point,
position: glam::DVec3::new(4., 6., 0.),
};
let _ = app.handle_authoring_edit(result);
assert_ne!(
app.tabs[i].scene.document.get_entity(reference),
before.get_entity(reference)
);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
app.redo_steps(1);
assert_ne!(
app.tabs[i].scene.document.get_entity(reference),
before.get_entity(reference)
);
app.tabs[i].selected_authoring = Some(handle);
app.refresh_properties();
let id = app.tabs[i].selected_grips[0].id;
assert!(app.start_authoring_grip(handle, id));
let after = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].scene.document, after);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth);
let _ = app.update(Message::CommandEscape);
assert_eq!(app.tabs[i].selected_authoring, None);
}
#[test]
fn authoring_edit_mouse_selection_endpoint_label_marker_history_and_reopen() {
use crate::scene::block_authoring::edit::{self, Part};
use crate::scene::block_actions::{self as actions, Kind};
let (mut app, reference) = fixture();
begin(&mut app, reference);
let i = app.active_tab;
let _ = app.run_command_line("BPARAMETER LINEAR 2,2 6,2 4,3");
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 a = actions::create(
&mut app.tabs[i].scene.document,
def,
Kind::Move,
p.handle,
&targets,
glam::DVec3::new(5., 5., 0.),
)
.unwrap();
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600., 900.);
app.tabs[i].scene.camera.borrow_mut().fit_to_bounds_f64(
glam::DVec3::new(-1., -1., 0.),
glam::DVec3::new(10., 7., 0.),
1600. / 900.,
);
let click = |app: &mut OpenCADStudio, point: Vector3| {
let scene = &app.tabs[app.active_tab].scene;
let (w, h) = scene.selection.borrow().view.vp_size;
let b = scene.active_model_tile_bounds(w, h);
let s = scene
.camera
.borrow()
.project(
glam::DVec3::new(point.x, point.y, point.z),
iced::Rectangle {
x: 0.,
y: 0.,
width: b.width,
height: b.height,
},
)
.unwrap();
let _ = app.update(Message::ViewportLeftPressAt(iced::Point::new(
s.x + b.x,
s.y + b.y,
)));
let _ = app.update(Message::ViewportLeftRelease);
};
click(&mut app, Vector3::new(4., 3., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(p.handle));
assert_eq!(app.tabs[i].selected_grips.len(), 3);
let before = app.tabs[i].scene.document.clone();
let depth = app.tabs[i].history.undo_stack.len();
click(&mut app, Vector3::new(6., 2., 0.));
assert_eq!(
app.tabs[i].active_cmd.as_ref().map(|c| c.name()),
Some("BEDITGRIP")
);
feed(&mut app, "8,2");
assert!(app.tabs[i].active_cmd.is_none());
assert_eq!(
actions::parameters(&app.tabs[i].scene.document, def, Kind::Move)[0].end,
glam::DVec3::new(8., 2., 0.)
);
assert_eq!(app.tabs[i].history.undo_stack.len(), depth + 1);
app.undo_steps(1);
assert_eq!(app.tabs[i].scene.document, before);
app.redo_steps(1);
let label = edit::items(&app.tabs[i].scene.document, def)
.into_iter()
.find(|i| i.handle == p.handle)
.unwrap()
.grips
.into_iter()
.find(|(p, _)| *p == Part::Label(0))
.unwrap()
.1;
click(&mut app, label);
feed(&mut app, "5,4");
let graphic = edit::items(&app.tabs[i].scene.document, def)
.into_iter()
.find(|i| i.handle == p.handle)
.unwrap();
assert_eq!(graphic.graphic.labels[0].0, Vector3::new(5., 4., 0.));
let _ = app.update(Message::CommandEscape);
click(&mut app, Vector3::new(5., 5., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(a));
click(&mut app, Vector3::new(5., 5., 0.));
feed(&mut app, "7,5");
let action = actions::actions(&app.tabs[i].scene.document, def);
assert_eq!(action[0].1.display_location, Vector3::new(7., 5., 0.));
assert_eq!(action[0].1.dependencies, targets);
let _ = app.dispatch_command("BSAVE");
let _ = app.dispatch_command("BEDIT_DISCARD");
assert!(app.tabs[i]
.selected_grips
.iter()
.all(|g| !super::block_authoring_edit::is_editor_grip(g.id)));
// Persisted native coordinates, rather than session-only display positions.
use std::io::Cursor;
let bytes = codec::DwgWriter::write_to_vec(&app.tabs[i].scene.document).unwrap();
let loaded = codec::DwgReader::from_stream(Cursor::new(bytes))
.read()
.unwrap();
assert_eq!(
actions::parameters(&loaded, def, Kind::Move)[0].end,
glam::DVec3::new(8., 2., 0.)
);
assert_eq!(
actions::actions(&loaded, def)[0].1.display_location,
Vector3::new(7., 5., 0.)
);
app.tabs[i].scene.document = loaded;
begin(&mut app, reference);
click(&mut app, Vector3::new(8., 2., 0.));
assert_eq!(app.tabs[i].selected_authoring, Some(p.handle));
}
#[test] #[test]
fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() {
use crate::scene::block_actions::{self as actions, Kind}; use crate::scene::block_actions::{self as actions, Kind};
+3
View File
@@ -575,6 +575,9 @@ impl OpenCADStudio {
return task; return task;
} }
} }
result @ CmdResult::EditBlockAuthoring { .. } => {
return self.handle_authoring_edit(result);
}
result @ (CmdResult::PlaceBlockAction { .. } result @ (CmdResult::PlaceBlockAction { .. }
| CmdResult::EvaluateBlockParameter { .. }) => { | CmdResult::EvaluateBlockParameter { .. }) => {
return self.handle_block_action_result(result); return self.handle_block_action_result(result);
+2
View File
@@ -182,6 +182,7 @@ pub(super) struct DocumentTab {
/// the active grip edit instead of committing it. /// the active grip edit instead of committing it.
pub(super) grip_base_pending: bool, pub(super) grip_base_pending: bool,
pub(super) selected_handle: Option<Handle>, pub(super) selected_handle: Option<Handle>,
pub(super) selected_authoring: Option<Handle>,
/// Dynamic-block visibility grip for the current single selection. /// Dynamic-block visibility grip for the current single selection.
pub(super) visibility_grip: Option<super::visibility::VisibilityGrip>, pub(super) visibility_grip: Option<super::visibility::VisibilityGrip>,
pub(super) wireframe: bool, pub(super) wireframe: bool,
@@ -657,6 +658,7 @@ impl DocumentTab {
grip_copy: false, grip_copy: false,
grip_base_pending: false, grip_base_pending: false,
selected_handle: None, selected_handle: None,
selected_authoring: None,
visibility_grip: None, visibility_grip: None,
wireframe: false, wireframe: false,
render_mode: codec::entities::ViewportRenderMode::Wireframe2D, render_mode: codec::entities::ViewportRenderMode::Wireframe2D,
+1
View File
@@ -15,6 +15,7 @@ mod block_save_as;
mod block_visibility; mod block_visibility;
mod block_lookup; mod block_lookup;
mod block_actions; mod block_actions;
mod block_authoring_edit;
mod block_properties; mod block_properties;
#[cfg(test)] #[cfg(test)]
mod block_properties_tests; mod block_properties_tests;
+40
View File
@@ -2475,6 +2475,45 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}",
} }
} }
}; };
if selected.is_empty() {
if let Some(item) = self.tabs[i].selected_authoring.and_then(|handle| {
let session = self.tabs[i].active_block_edit_session()?;
crate::scene::block_authoring::edit::items(
&self.tabs[i].scene.document,
session.br_handle,
)
.into_iter()
.find(|item| item.handle == handle)
}) {
use crate::scene::model::object::{PropSection, PropValue, Property};
panel.title = item.name;
panel.sections = vec![PropSection {
title: crate::t!("Geometry").into_owned(),
props: item
.grips
.into_iter()
.map(|(part, point)| Property {
label: crate::t!(match part {
crate::scene::block_authoring::edit::Part::Point => "Position",
crate::scene::block_authoring::edit::Part::Base => "Base point",
crate::scene::block_authoring::edit::Part::End => "End point",
crate::scene::block_authoring::edit::Part::Reference =>
"Reference point",
crate::scene::block_authoring::edit::Part::Label(0) =>
"Label position",
_ => "Second label position",
})
.into_owned(),
field: "authoring_grip_position",
value: PropValue::ReadOnly(format!(
"{:.4}, {:.4}, {:.4}",
point.x, point.y, point.z
)),
})
.collect(),
}];
}
}
// Precompute the focused-id → field-key map for O(1) lookups on // Precompute the focused-id → field-key map for O(1) lookups on
// `PropSyncActive`; derived from `sections`, so rebuild it here. // `PropSyncActive`; derived from `sections`, so rebuild it here.
panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections); panel.field_key_by_id = crate::ui::properties::build_field_key_map(&panel.sections);
@@ -2887,6 +2926,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
// selection is a visibility-parametric block reference. // selection is a visibility-parametric block reference.
self.refresh_visibility_grip(wo); self.refresh_visibility_grip(wo);
self.refresh_test_action_grips(wo); self.refresh_test_action_grips(wo);
self.refresh_authoring_grips(wo);
} }
pub(super) fn property_target_handles(&self, i: usize) -> Vec<Handle> { pub(super) fn property_target_handles(&self, i: usize) -> Vec<Handle> {
+1
View File
@@ -1410,6 +1410,7 @@ impl OpenCADStudio {
// ESC while in MSPACE → exit back to paper space. // ESC while in MSPACE → exit back to paper space.
return Task::done(Message::ExitViewport); return Task::done(Message::ExitViewport);
} else { } else {
self.tabs[i].selected_authoring = None;
self.tabs[i].scene.deselect_all(); self.tabs[i].scene.deselect_all();
self.refresh_properties(); self.refresh_properties();
let mut sel = self.tabs[i].scene.selection.borrow_mut(); let mut sel = self.tabs[i].scene.selection.borrow_mut();
+10
View File
@@ -832,6 +832,7 @@ impl OpenCADStudio {
.filter(|(_, grip)| { .filter(|(_, grip)| {
grip.id != crate::app::visibility::VIS_GRIP_ID grip.id != crate::app::visibility::VIS_GRIP_ID
&& !crate::app::block_actions::is_test_grip(grip.id) && !crate::app::block_actions::is_test_grip(grip.id)
&& !crate::app::block_authoring_edit::is_editor_grip(grip.id)
}) })
.map(|(owner, grip)| GripTarget { .map(|(owner, grip)| GripTarget {
handle: owner, handle: owner,
@@ -857,6 +858,7 @@ impl OpenCADStudio {
.filter(|(_, grip)| { .filter(|(_, grip)| {
grip.id != crate::app::visibility::VIS_GRIP_ID grip.id != crate::app::visibility::VIS_GRIP_ID
&& !crate::app::block_actions::is_test_grip(grip.id) && !crate::app::block_actions::is_test_grip(grip.id)
&& !crate::app::block_authoring_edit::is_editor_grip(grip.id)
}) })
.filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq) .filter(|(_, grip)| (grip.world - world).length_squared() <= epsilon_sq)
.map(|(owner, grip)| GripTarget { .map(|(owner, grip)| GripTarget {
@@ -1000,6 +1002,7 @@ impl OpenCADStudio {
if self.grip_popup.is_none() if self.grip_popup.is_none()
&& grip_id != crate::app::visibility::VIS_GRIP_ID && grip_id != crate::app::visibility::VIS_GRIP_ID
&& !crate::app::block_actions::is_test_grip(grip_id) && !crate::app::block_actions::is_test_grip(grip_id)
&& !crate::app::block_authoring_edit::is_editor_grip(grip_id)
&& self && self
.grip_hover .grip_hover
.as_ref() .as_ref()
@@ -4003,6 +4006,9 @@ impl OpenCADStudio {
if self.start_test_action_grip(handle, grip_id) { if self.start_test_action_grip(handle, grip_id) {
return Task::none(); return Task::none();
} }
if self.start_authoring_grip(handle, grip_id) {
return Task::none();
}
if self.shift_down { if self.shift_down {
let key = (handle, grip_id); let key = (handle, grip_id);
if !self.tabs[i].hot_grips.remove(&key) { if !self.tabs[i].hot_grips.remove(&key) {
@@ -4121,6 +4127,10 @@ impl OpenCADStudio {
} }
} }
if edit_cam.is_none() && self.pick_authoring_object(p, bounds) {
return Task::none();
}
// Ctrl+click on a composite solid picks the solid of it under the // Ctrl+click on a composite solid picks the solid of it under the
// cursor — the one the Properties palette and grips then edit. // cursor — the one the Properties palette and grips then edit.
if self.ctrl_down if self.ctrl_down
+7
View File
@@ -1514,6 +1514,13 @@ impl DimensionPreview {
/// Returned by every `CadCommand` method to tell main.rs what to do. /// Returned by every `CadCommand` method to tell main.rs what to do.
#[allow(dead_code)] #[allow(dead_code)]
pub enum CmdResult { pub enum CmdResult {
EditBlockAuthoring {
document: u64,
definition: Handle,
handle: Handle,
part: crate::scene::block_authoring::edit::Part,
position: DVec3,
},
PlaceBlockAction { PlaceBlockAction {
document: u64, document: u64,
definition: Handle, definition: Handle,
+1
View File
@@ -1,5 +1,6 @@
//! Shared native graph construction for Block Editor authoring objects. //! Shared native graph construction for Block Editor authoring objects.
pub(crate) mod graph; pub(crate) mod graph;
pub(crate) mod edit;
use codec::objects::{ use codec::objects::{
BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType,
}; };
+523
View File
@@ -0,0 +1,523 @@
//! Editor picking and native definition-point edits. No proxy drawing entities.
use super::*;
use crate::scene::block_distance_parameter::{self as distance, Graphic};
use codec::objects::{BlockGrip, BlockTwoPointParameter};
use codec::types::Vector3;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Part {
Point,
Base,
End,
Label(usize),
Reference,
}
pub struct Item {
pub handle: Handle,
pub name: String,
pub graphic: Graphic,
pub grips: Vec<(Part, Vector3)>,
}
pub fn items(doc: &CadDocument, definition: Handle) -> Vec<Item> {
let mut result = vec![];
for (handle, p) in crate::scene::block_point_parameter::parameters(doc, definition) {
let mut graphic = Graphic::default();
graphic
.lines
.push([p.parameter.definition_point, p.definition_label_point]);
graphic
.labels
.push((p.definition_label_point, p.position_name.clone()));
result.push(Item {
handle,
name: p.position_name.clone(),
graphic,
grips: vec![
(Part::Point, p.parameter.definition_point),
(Part::Label(0), p.definition_label_point),
],
});
}
for p in distance::parameters(doc, definition) {
let graphic = distance::graphic(&p);
let mut grips = vec![
(Part::Base, p.native.definition_base_point),
(Part::End, p.native.definition_end_point),
];
// Polar's second label is derived from its angle arc, not an editable offset.
grips.extend(
graphic
.labels
.iter()
.take(if p.kind == distance::Kind::XY { 2 } else { 1 })
.enumerate()
.map(|(i, (p, _))| (Part::Label(i), *p)),
);
result.push(Item {
handle: p.handle,
name: p.labels.join(" / "),
graphic,
grips,
});
}
for p in crate::scene::block_orientation_parameter::parameters(doc, definition) {
use crate::scene::block_orientation_parameter::Kind;
let graphic = crate::scene::block_orientation_parameter::graphic(&p);
let mut grips = vec![
(Part::Base, p.native.definition_base_point),
(Part::End, p.native.definition_end_point),
];
if p.kind == Kind::Rotation {
grips.push((Part::Reference, p.reference));
}
if p.kind != Kind::Alignment {
grips.extend(
graphic
.labels
.iter()
.take(1)
.map(|(p, _)| (Part::Label(0), *p)),
);
}
result.push(Item {
handle: p.handle,
name: p.label.clone(),
graphic,
grips,
});
}
for (handle, p) in crate::scene::block_lookup::parameters(doc, definition) {
result.push(single(
handle,
p.lookup_name.clone(),
p.parameter.definition_point,
));
}
if let Some((handle, p)) = crate::scene::block_basepoint::parameter(doc, definition) {
result.push(single(
handle,
"Basepoint".into(),
p.parameter.definition_point,
));
}
if let Some(p) = crate::scene::block_visibility::parameter(doc, definition) {
result.push(single(p.handle, p.name.clone(), p.def_point));
}
for (handle, p, _, _) in crate::scene::block_actions::actions(doc, definition) {
result.push(single(handle, p.element.name.clone(), p.display_location));
}
result
}
fn single(handle: Handle, name: String, point: Vector3) -> Item {
let mut graphic = Graphic::default();
graphic.labels.push((point, name.clone()));
Item {
handle,
name,
graphic,
grips: vec![(Part::Point, point)],
}
}
fn two(data: &mut DynamicBlockData) -> Option<&mut BlockTwoPointParameter> {
Some(match data {
DynamicBlockData::LinearParameter(p) => &mut p.parameter,
DynamicBlockData::PolarParameter(p) => &mut p.parameter,
DynamicBlockData::XYParameter(p) => &mut p.parameter,
DynamicBlockData::RotationParameter(p) => &mut p.parameter,
DynamicBlockData::AlignmentParameter(p) => &mut p.parameter,
DynamicBlockData::FlipParameter(p) => &mut p.parameter,
_ => return None,
})
}
fn grip(data: &mut DynamicBlockData) -> Option<&mut BlockGrip> {
Some(match data {
DynamicBlockData::LinearGrip(p) | DynamicBlockData::AlignmentGrip(p) => &mut p.grip,
DynamicBlockData::XYGrip(p)
| DynamicBlockData::PolarGrip(p)
| DynamicBlockData::RotationGrip(p)
| DynamicBlockData::LookupGrip(p)
| DynamicBlockData::VisibilityGrip(p) => p,
DynamicBlockData::FlipGrip(p) => &mut p.grip,
_ => return None,
})
}
pub fn move_part(
doc: &mut CadDocument,
definition: Handle,
handle: Handle,
part: Part,
at: Vector3,
) -> Result<bool, &'static str> {
if ![at.x, at.y, at.z].iter().all(|v| v.is_finite()) {
return Err("Specify a finite authoring location.");
}
let item = items(doc, definition)
.into_iter()
.find(|i| i.handle == handle)
.ok_or("The authoring object is no longer available.")?;
let old = item
.grips
.iter()
.find(|(p, _)| *p == part)
.map(|(_, p)| *p)
.ok_or("This authoring grip is unavailable.")?;
if (at.z - old.z).abs() > 1e-8 {
return Err("Keep the authoring grip in its block XY plane.");
}
if (at - old).length() < 1e-10 {
return Ok(false);
}
let graph = graph(doc, definition)?.ok_or("The authoring graph is unavailable.")?;
let mut staged = doc.clone();
if crate::scene::block_basepoint::parameter(doc, definition).is_some_and(|(h, _)| h == handle) {
crate::scene::block_basepoint::create(&mut staged, definition, at)?;
*doc = staged;
return Ok(true);
}
let mut moved_grips = vec![];
match staged
.objects
.get_mut(&handle)
.ok_or("The authoring object is unavailable.")?
{
ObjectType::BlockVisibilityParameter(p) => {
p.def_point = at;
moved_grips.extend(
p.property_info
.iter()
.flat_map(|p| p.connections.iter().map(|c| (c.code, at))),
);
}
ObjectType::DynamicBlock(object) => {
if let Some(p) = two(&mut object.data) {
match part {
Part::Base => {
p.definition_base_point = at;
p.base_point = p.base_point.map(|_| at);
p.updated_base_point = p.updated_base_point.map(|_| at);
moved_grips.push((p.property_states[0], at));
}
Part::End => {
p.definition_end_point = at;
p.end_point = p.end_point.map(|_| at);
p.updated_end_point = p.updated_end_point.map(|_| at);
moved_grips.push((p.property_states[1], at));
}
_ => {}
}
let length = (p.definition_end_point - p.definition_base_point).length();
if !length.is_finite() || length < 1e-10 {
return Err("Parameter endpoints must remain distinct and finite.");
}
}
match &mut object.data {
DynamicBlockData::PointParameter(p) => match part {
Part::Point => {
p.parameter.definition_point = at;
moved_grips.push((p.parameter.property_count, at))
}
Part::Label(_) => p.definition_label_point = at,
_ => {}
},
DynamicBlockData::LookupParameter(p) => {
p.parameter.definition_point = at;
moved_grips.push((p.parameter.property_count, at))
}
DynamicBlockData::BasePointParameter(p) => {
p.parameter.definition_point = at;
p.point = at;
p.base_point = at;
}
DynamicBlockData::LinearParameter(p) => {
if let Part::Label(_) = part {
p.distance = label_offset(&p.parameter, at)
}
}
DynamicBlockData::PolarParameter(p) => {
if let Part::Label(_) = part {
p.offset = label_offset(&p.parameter, at)
}
}
DynamicBlockData::XYParameter(p) => {
if let Part::Label(index) = part {
if index == 0 {
p.x_value = at.y - p.parameter.definition_base_point.y
} else {
p.y_value = at.x - p.parameter.definition_base_point.x
}
}
if (p.parameter.definition_end_point.x - p.parameter.definition_base_point.x)
.abs()
< 1e-10
|| (p.parameter.definition_end_point.y
- p.parameter.definition_base_point.y)
.abs()
< 1e-10
{
return Err("XY parameter endpoints need both X and Y distances.");
}
}
DynamicBlockData::RotationParameter(p) => match part {
Part::Reference => {
if (at - p.parameter.definition_base_point).length() < 1e-10 {
return Err("The rotation reference must differ from its base.");
}
p.definition_base_angle_point = at
}
Part::Label(_) => {
let b = p.parameter.definition_base_point;
p.angle =
(at - b).length() - (p.parameter.definition_end_point - b).length()
}
_ => {}
},
DynamicBlockData::FlipParameter(p) => {
if let Part::Label(_) = part {
p.definition_label_point = at
}
}
DynamicBlockData::MoveAction(p) => p.action.display_location = at,
DynamicBlockData::ScaleAction(p) => p.action.action.display_location = at,
DynamicBlockData::AlignmentParameter(_) => {}
_ => return Err("This authoring object cannot be edited yet."),
}
if let DynamicBlockData::RotationParameter(p) = &mut object.data {
let base = p.parameter.definition_base_point;
if part == Part::Reference {
let radius = (p.definition_base_angle_point - base).length();
let delta = p.parameter.definition_end_point - base;
let end = base + delta * (radius / delta.length());
p.parameter.definition_end_point = end;
p.parameter.end_point = p.parameter.end_point.map(|_| end);
p.parameter.updated_end_point = p.parameter.updated_end_point.map(|_| end);
moved_grips.push((p.parameter.property_states[1], end));
} else if matches!(part, Part::Base | Part::End) {
let old_base = item.grips.iter().find(|(p, _)| *p == Part::Base).unwrap().1;
let direction = p.definition_base_angle_point - old_base;
let radius = (p.parameter.definition_end_point - base).length();
p.definition_base_angle_point =
base + direction * (radius / direction.length());
}
if let Some(e) = crate::scene::block_orientation_parameter::points_error(
crate::scene::block_orientation_parameter::Kind::Rotation,
base,
p.parameter.definition_end_point,
p.definition_base_angle_point,
) {
return Err(e);
}
}
}
_ => return Err("This is not an authoring object."),
}
for object in staged.objects.values_mut() {
let ObjectType::DynamicBlock(object) = object else {
continue;
};
if object.owner != graph {
continue;
}
let Some(g) = grip(&mut object.data) else {
continue;
};
if let Some((_, location)) = moved_grips
.iter()
.find(|(id, _)| *id > 0 && *id == g.element.eval.node_id)
{
g.location = *location;
}
}
// Keep the Linear grip's native direction consistent after endpoint edits.
if matches!(part, Part::Base | Part::End) {
if let Some(p) = distance::parameters(&staged, definition)
.into_iter()
.find(|p| p.handle == handle)
{
let delta = p.native.definition_end_point - p.native.definition_base_point;
let orientation = delta * (1.0 / delta.length());
let ids = p.native.property_states;
for o in staged.objects.values_mut() {
if let ObjectType::DynamicBlock(o) = o {
if o.owner == graph {
if let DynamicBlockData::LinearGrip(g) = &mut o.data {
if ids.contains(&g.grip.element.eval.node_id) {
g.orientation = orientation;
}
}
}
}
}
}
if let Some(p) = crate::scene::block_orientation_parameter::parameters(&staged, definition)
.into_iter()
.find(|p| p.handle == handle)
{
let orientation = p.native.definition_end_point - p.native.definition_base_point;
let ids = p.native.property_states;
for o in staged.objects.values_mut() {
if let ObjectType::DynamicBlock(o) = o {
if o.owner == graph {
match &mut o.data {
DynamicBlockData::AlignmentGrip(g)
if ids.contains(&g.grip.element.eval.node_id) =>
{
g.orientation = orientation
}
DynamicBlockData::FlipGrip(g)
if ids.contains(&g.grip.element.eval.node_id) =>
{
g.orientation = orientation
}
_ => {}
}
}
}
}
}
}
if let Some(ObjectType::BlockVisibilityParameter(p)) = staged.objects.get(&handle) {
staged.block_visibility_params.insert(handle, p.clone());
}
if let Some(item) = items(&staged, definition)
.into_iter()
.find(|i| i.handle == handle)
{
let finite = |p: Vector3| [p.x, p.y, p.z].iter().all(|v| v.is_finite());
if !item.grips.iter().all(|(_, p)| finite(*p))
|| !item.graphic.lines.iter().flatten().all(|p| finite(*p))
{
return Err("The authoring location produces non-finite geometry.");
}
}
*doc = staged;
Ok(true)
}
fn label_offset(p: &BlockTwoPointParameter, at: Vector3) -> f64 {
let d = p.definition_end_point - p.definition_base_point;
let n = Vector3::new(-d.y, d.x, 0.) * (1.0 / d.x.hypot(d.y));
let delta = at - (p.definition_base_point + p.definition_end_point) * 0.5;
delta.x * n.x + delta.y * n.y
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scene::{
block_orientation_parameter as orientation, block_point_parameter as point, Scene,
};
#[test]
fn authoring_edit_all_parameter_kinds_keep_native_grips_and_graph_identity() {
let mut scene = Scene::new();
scene.define_block_raw("EDIT", Vector3::ZERO, vec![]);
let doc = &mut scene.document;
let def = doc.block_records.get("EDIT").unwrap().handle;
let b = Vector3::new(2., 3., 0.);
let e = Vector3::new(6., 7., 0.);
let l = Vector3::new(1., 8., 0.);
point::create(doc, def, b, l).unwrap();
for kind in [
distance::Kind::Linear,
distance::Kind::Polar,
distance::Kind::XY,
] {
distance::create(doc, def, kind, b, e, l).unwrap();
}
for kind in [
orientation::Kind::Rotation,
orientation::Kind::Alignment,
orientation::Kind::Flip,
] {
orientation::create(
doc,
def,
kind,
b,
e,
Vector3::new(b.x + (e - b).length(), b.y, 0.),
)
.unwrap();
}
crate::scene::block_lookup::create(doc, def, b).unwrap();
crate::scene::block_basepoint::create(doc, def, b).unwrap();
let visibility = crate::scene::block_visibility::create(doc, def, b).unwrap();
let graph_handle = graph(doc, def).unwrap().unwrap();
let graph_before = doc.objects[&graph_handle].clone();
let inventory = items(doc, def);
assert_eq!(inventory.len(), 10);
for item in inventory {
for (part, _) in item.grips {
let old = items(doc, def)
.into_iter()
.find(|i| i.handle == item.handle)
.unwrap()
.grips
.into_iter()
.find(|(p, _)| *p == part)
.unwrap()
.1;
let next = old + Vector3::new(0.3, 0.5, 0.);
assert!(
move_part(doc, def, item.handle, part, next).unwrap(),
"{} {part:?}",
item.name
);
let after = items(doc, def)
.into_iter()
.find(|i| i.handle == item.handle)
.unwrap();
let actual = after.grips.iter().find(|(p, _)| *p == part).unwrap().1;
// Dimension labels project onto their normal/radius; definition points are exact.
if !matches!(part, Part::Label(_)) {
assert!((actual - next).length() < 1e-10);
}
let snapshot = doc.clone();
assert!(!move_part(doc, def, item.handle, part, actual).unwrap());
assert!(
move_part(doc, def, item.handle, part, Vector3::new(f64::NAN, 0., 0.)).is_err()
);
assert_eq!(*doc, snapshot);
}
}
assert_eq!(
doc.objects[&graph_handle], graph_before,
"editing positions must preserve graph lists and node identities"
);
assert_eq!(
crate::scene::block_visibility::parameter(doc, def)
.unwrap()
.def_point,
items(doc, def)
.into_iter()
.find(|i| i.handle == visibility)
.unwrap()
.grips[0]
.1
);
for p in distance::parameters(doc, def) {
let ids = p.native.property_states;
let expected = p.native.definition_end_point;
for o in doc.objects.values() {
if let ObjectType::DynamicBlock(o) = o {
let mut data = o.data.clone();
if let Some(g) = grip(&mut data) {
if g.element.eval.node_id == ids[1] {
assert_eq!(g.location, expected);
}
}
}
}
}
let bytes = codec::DxfWriter::new(doc).write_to_vec().unwrap();
let saved = codec::DxfReader::from_reader(std::io::Cursor::new(bytes))
.unwrap()
.read()
.unwrap();
let a = items(doc, def);
let b = items(&saved, def);
assert_eq!(a.len(), b.len());
for (a, b) in a.iter().zip(b) {
assert_eq!(a.handle, b.handle);
assert_eq!(a.grips, b.grips);
}
}
}