From 677ff0e08c2b229e2b226be6d7fc41aa3d9d7147 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Fri, 16 Oct 2026 17:44:09 +0100 Subject: [PATCH] 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. --- src/app/block_authoring_edit.rs | 230 +++++++++++++ src/app/block_editor_tests.rs | 173 ++++++++++ src/app/command_driver/mod.rs | 3 + src/app/document.rs | 2 + src/app/mod.rs | 1 + src/app/properties.rs | 40 +++ src/app/update/command.rs | 1 + src/app/update/viewport.rs | 10 + src/command.rs | 7 + src/scene/block_authoring.rs | 1 + src/scene/block_authoring/edit.rs | 523 ++++++++++++++++++++++++++++++ 11 files changed, 991 insertions(+) create mode 100644 src/app/block_authoring_edit.rs create mode 100644 src/scene/block_authoring/edit.rs diff --git a/src/app/block_authoring_edit.rs b/src/app/block_authoring_edit.rs new file mode 100644 index 000000000..efc4c542c --- /dev/null +++ b/src/app/block_authoring_edit.rs @@ -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 { + 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::>(); + 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() + } +} diff --git a/src/app/block_editor_tests.rs b/src/app/block_editor_tests.rs index 54c733751..6bc41e9f9 100644 --- a/src/app/block_editor_tests.rs +++ b/src/app/block_editor_tests.rs @@ -59,6 +59,179 @@ fn feed(app: &mut OpenCADStudio, text: &str) { 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] fn block_actions_mouse_selection_waits_for_enter_for_move_and_scale() { use crate::scene::block_actions::{self as actions, Kind}; diff --git a/src/app/command_driver/mod.rs b/src/app/command_driver/mod.rs index aee127260..ae929a040 100644 --- a/src/app/command_driver/mod.rs +++ b/src/app/command_driver/mod.rs @@ -575,6 +575,9 @@ impl OpenCADStudio { return task; } } + result @ CmdResult::EditBlockAuthoring { .. } => { + return self.handle_authoring_edit(result); + } result @ (CmdResult::PlaceBlockAction { .. } | CmdResult::EvaluateBlockParameter { .. }) => { return self.handle_block_action_result(result); diff --git a/src/app/document.rs b/src/app/document.rs index 77871252f..dfe2434c2 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -182,6 +182,7 @@ pub(super) struct DocumentTab { /// the active grip edit instead of committing it. pub(super) grip_base_pending: bool, pub(super) selected_handle: Option, + pub(super) selected_authoring: Option, /// Dynamic-block visibility grip for the current single selection. pub(super) visibility_grip: Option, pub(super) wireframe: bool, @@ -657,6 +658,7 @@ impl DocumentTab { grip_copy: false, grip_base_pending: false, selected_handle: None, + selected_authoring: None, visibility_grip: None, wireframe: false, render_mode: codec::entities::ViewportRenderMode::Wireframe2D, diff --git a/src/app/mod.rs b/src/app/mod.rs index 2c1bd36cc..717e0ecb2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -15,6 +15,7 @@ mod block_save_as; mod block_visibility; mod block_lookup; mod block_actions; +mod block_authoring_edit; mod block_properties; #[cfg(test)] mod block_properties_tests; diff --git a/src/app/properties.rs b/src/app/properties.rs index 9882203f3..a243f06ce 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -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 // `PropSyncActive`; derived from `sections`, so rebuild it here. 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. self.refresh_visibility_grip(wo); self.refresh_test_action_grips(wo); + self.refresh_authoring_grips(wo); } pub(super) fn property_target_handles(&self, i: usize) -> Vec { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83e6fbc47..a85ae7f7c 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1410,6 +1410,7 @@ impl OpenCADStudio { // ESC while in MSPACE → exit back to paper space. return Task::done(Message::ExitViewport); } else { + self.tabs[i].selected_authoring = None; self.tabs[i].scene.deselect_all(); self.refresh_properties(); let mut sel = self.tabs[i].scene.selection.borrow_mut(); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index b3453639b..6a5ea31c6 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -832,6 +832,7 @@ impl OpenCADStudio { .filter(|(_, grip)| { grip.id != crate::app::visibility::VIS_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 { handle: owner, @@ -857,6 +858,7 @@ impl OpenCADStudio { .filter(|(_, grip)| { grip.id != crate::app::visibility::VIS_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) .map(|(owner, grip)| GripTarget { @@ -1000,6 +1002,7 @@ impl OpenCADStudio { if self.grip_popup.is_none() && grip_id != crate::app::visibility::VIS_GRIP_ID && !crate::app::block_actions::is_test_grip(grip_id) + && !crate::app::block_authoring_edit::is_editor_grip(grip_id) && self .grip_hover .as_ref() @@ -4003,6 +4006,9 @@ impl OpenCADStudio { if self.start_test_action_grip(handle, grip_id) { return Task::none(); } + if self.start_authoring_grip(handle, grip_id) { + return Task::none(); + } if self.shift_down { let key = (handle, grip_id); 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 // cursor — the one the Properties palette and grips then edit. if self.ctrl_down diff --git a/src/command.rs b/src/command.rs index f34a2cc87..f381dc489 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1514,6 +1514,13 @@ impl DimensionPreview { /// Returned by every `CadCommand` method to tell main.rs what to do. #[allow(dead_code)] pub enum CmdResult { + EditBlockAuthoring { + document: u64, + definition: Handle, + handle: Handle, + part: crate::scene::block_authoring::edit::Part, + position: DVec3, + }, PlaceBlockAction { document: u64, definition: Handle, diff --git a/src/scene/block_authoring.rs b/src/scene/block_authoring.rs index 6c0d9255a..6713d2ca0 100644 --- a/src/scene/block_authoring.rs +++ b/src/scene/block_authoring.rs @@ -1,5 +1,6 @@ //! Shared native graph construction for Block Editor authoring objects. pub(crate) mod graph; +pub(crate) mod edit; use codec::objects::{ BlockEvalExpression, BlockEvalValue, DynamicBlockData, DynamicBlockObject, ObjectType, }; diff --git a/src/scene/block_authoring/edit.rs b/src/scene/block_authoring/edit.rs new file mode 100644 index 000000000..47dfbb4ae --- /dev/null +++ b/src/scene/block_authoring/edit.rs @@ -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 { + 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 { + 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); + } + } +}