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); + } + } +}