From 2c64888048fecf114b62b41fc7bedc01f5fa5c38 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 30 Jul 2026 19:50:33 +0300 Subject: [PATCH] fix(blocks): preserve UCS transformations Bake BEDIT frames into block-local geometry while keeping model and viewport UCS isolated. Normalize legacy nonzero block origins during load. --- src/app/command_driver.rs | 10 +- src/app/commands/inquiry.rs | 62 ++++++-- src/app/commands/layerprops.rs | 15 +- src/app/document.rs | 28 +++- src/app/helpers.rs | 48 ++++++- src/app/history.rs | 14 +- src/app/update/command.rs | 8 +- src/app/update/mod.rs | 10 +- src/app/update/viewport.rs | 82 ++++++++++- src/command.rs | 3 + src/entities/dimension.rs | 68 +++++++++ src/entities/extended.rs | 1 + src/entities/line.rs | 3 + src/entities/ole2frame.rs | 3 + src/entities/underlay.rs | 3 + src/io/mod.rs | 58 ++++++++ src/modules/draw/modify/block_edit.rs | 16 ++- src/scene/entity.rs | 11 +- src/scene/mod.rs | 45 ++++++ src/scene/modify.rs | 196 ++++++++++++++++++++++++++ src/scene/view/camera.rs | 49 +++++++ src/scene/view/transform.rs | 1 + 22 files changed, 693 insertions(+), 41 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 2166ab9c6..094c51ab5 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -932,9 +932,17 @@ impl OpenCADStudio { base, } => { self.push_undo_snapshot(i, "BLOCK"); + let ucs = self.tabs[i].ucs_xform(); + let world_to_block = ucs.to_ucs_transform_at(base); + let block_to_world = ucs.to_wcs_transform_at(base); match self.tabs[i] .scene - .create_block_from_entities(&handles, &name, base) + .create_block_from_entities( + &handles, + &name, + &world_to_block, + &block_to_world, + ) { Ok(insert_handle) => { self.tabs[i].dirty = true; diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index ad259d4e6..6e4e76394 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -406,8 +406,8 @@ impl OpenCADStudio { ))); } - // Snapshot the block's block-local entities so Discard can restore - // them (skip structural Block/BlockEnd/AttDef, mirroring REFEDIT). + // Snapshot the complete block definition so Discard can restore + // structural markers, ATTDEFs and geometry with exact handles. let snapshot: Vec<_> = { let br = self.tabs[i] .scene @@ -418,26 +418,45 @@ impl OpenCADStudio { br.entity_handles .iter() .filter_map(|h| self.tabs[i].scene.document.get_entity(*h).cloned()) - .filter(|e| { - !matches!( - e, - acadrust::EntityType::Block(_) - | acadrust::EntityType::BlockEnd(_) - | acadrust::EntityType::AttributeDefinition(_) - ) - }) .collect() }; + let dependent_snapshot: Vec<_> = self.tabs[i] + .scene + .block_definition_dependent_handles(br_handle) + .into_iter() + .filter_map(|handle| { + self.tabs[i].scene.document.get_entity_arc(handle) + }) + .collect(); + let reference_attributes: Vec<_> = self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| match entity { + acadrust::EntityType::Insert(reference) + if reference.block_name.eq_ignore_ascii_case(&insert.block_name) + && !reference.attributes.is_empty() => + { + Some(( + reference.common.handle, + reference.attributes.clone(), + )) + } + _ => None, + }) + .collect(); self.push_undo_snapshot(i, "BEDIT"); // Capture the camera before the editor reframes it, so leaving // the block editor returns the view exactly where it was (#425). let current_camera = self.tabs[i].scene.camera.borrow().clone(); + let current_ucs = self.tabs[i].active_ucs.clone(); let (return_layout, return_block, return_camera) = if let Some(parent_index) = self.tabs[i].active_block_edit { let parent = &mut self.tabs[i].block_edits[parent_index]; parent.editor_camera = current_camera.clone(); + parent.editor_ucs = current_ucs; ( parent.return_layout.clone(), Some(parent.block_name.clone()), @@ -463,10 +482,16 @@ impl OpenCADStudio { return_layout, return_block, snapshot, + dependent_snapshot, + reference_attributes, return_camera, editor_camera: current_camera, + editor_ucs: None, }); self.tabs[i].active_block_edit = Some(self.tabs[i].block_edits.len() - 1); + // Every BEDIT tab owns an isolated local UCS. Never inherit or + // overwrite the drawing's model-space UCS. + self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.deselect_all(); // The first click of a double-click selects the model-space @@ -574,10 +599,25 @@ impl OpenCADStudio { let return_layout = session.return_layout.clone(); let return_camera = session.return_camera.clone(); for mut entity in session.snapshot { - entity.common_mut().handle = acadrust::Handle::NULL; + // Preserve the original handle so ATTDEF references and + // other handle-based relationships survive Discard. entity.common_mut().owner_handle = session.br_handle; let _ = self.tabs[i].scene.document.add_entity(entity); } + for (handle, attributes) in session.reference_attributes { + if let Some(acadrust::EntityType::Insert(reference)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + reference.attributes = attributes; + } + } + for entity in session.dependent_snapshot { + let handle = entity.common().handle; + let _ = self.tabs[i] + .scene + .document + .replace_entity_arc(handle, entity); + } self.restore_after_block_edit_close( i, return_block.as_deref(), diff --git a/src/app/commands/layerprops.rs b/src/app/commands/layerprops.rs index 7ce640f44..fb1a16dd0 100644 --- a/src/app/commands/layerprops.rs +++ b/src/app/commands/layerprops.rs @@ -186,6 +186,7 @@ impl OpenCADStudio { use acadrust::types::Vector3; let parts: Vec<&str> = cmd.splitn(4, ' ').collect(); let sub = parts.get(1).map(|s| s.to_uppercase()).unwrap_or_default(); + let mut active_changed = false; match sub.as_str() { "" | "LIST" | "?" => { let active_name = self.tabs[i] @@ -248,6 +249,7 @@ impl OpenCADStudio { } "W" | "WORLD" => { self.tabs[i].active_ucs = None; + active_changed = true; self.command_line .push_output("UCS reset to World Coordinate System."); } @@ -271,6 +273,7 @@ impl OpenCADStudio { wcs_origin.y as f64, wcs_origin.z as f64, ); + active_changed = true; self.command_line.push_output(&format!( "UCS origin set to ({:.4}, {:.4}, {:.4}).", wcs_origin.x, wcs_origin.y, wcs_origin.z @@ -313,6 +316,7 @@ impl OpenCADStudio { Vector3::new(ny.x as f64, ny.y as f64, ny.z as f64); } self.tabs[i].active_ucs = Some(new_ucs); + active_changed = true; self.command_line .push_output(&format!("UCS rotated {:.2}° around Z.", angle_deg)); } else { @@ -340,6 +344,7 @@ impl OpenCADStudio { let rot = glam::Quat::from_axis_angle(x_ax, rad); let ny = rot * old_y; ucs.y_axis = Vector3::new(ny.x as f64, ny.y as f64, ny.z as f64); + active_changed = true; self.command_line .push_output(&format!("UCS rotated {:.2}° around X.", angle_deg)); } else { @@ -367,6 +372,7 @@ impl OpenCADStudio { let rot = glam::Quat::from_axis_angle(y_ax, rad); let nx = rot * old_x; ucs.x_axis = Vector3::new(nx.x as f64, nx.y as f64, nx.z as f64); + active_changed = true; self.command_line .push_output(&format!("UCS rotated {:.2}° around Y.", angle_deg)); } else { @@ -378,6 +384,7 @@ impl OpenCADStudio { let name = sub.clone(); if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { self.tabs[i].active_ucs = Some(named); + active_changed = true; self.command_line .push_output(&format!("UCS '{}' activated.", name)); } else { @@ -388,11 +395,9 @@ impl OpenCADStudio { } } } - // Keep the scene's ViewCube UCS in lock-step with active_ucs and - // persist it to the active pane (per-viewport UCS inside a - // viewport, header model UCS in the Model tab) so it round-trips. - self.tabs[i].sync_ucs_to_scene(); - self.tabs[i].persist_active_ucs(); + if active_changed { + self.commit_active_ucs_change(i, "UCS"); + } } // ── Named Views (VIEW command) ──────────────────────────────── diff --git a/src/app/document.rs b/src/app/document.rs index 49268c3ef..48756983e 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -241,7 +241,7 @@ impl DocumentTab { /// The document's saved model-space UCS (header), as a `Ucs`. `None` when it /// is identity (plain WCS). - fn model_ucs_from_header(&self) -> Option { + pub(super) fn model_ucs_from_header(&self) -> Option { let h = &self.scene.document.header; let mut u = Ucs::new(h.model_space_ucs_name.clone()); u.origin = h.model_space_ucs_origin; @@ -281,7 +281,9 @@ impl DocumentTab { /// change (enter/exit viewport, layout / tab switch, load) so one field /// drives all UCS-aware systems regardless of where editing happens. pub(super) fn refresh_active_ucs(&mut self) { - self.active_ucs = if let Some(h) = self.scene.active_viewport { + self.active_ucs = if let Some(session) = self.active_block_edit_session() { + session.editor_ucs.clone() + } else if let Some(h) = self.scene.active_viewport { self.ucs_from_viewport(h) } else if self.scene.current_layout == "Model" { self.model_ucs_from_header() @@ -297,16 +299,28 @@ impl DocumentTab { /// any UCS change. pub(super) fn persist_active_ucs(&mut self) { use acadrust::types::Vector3; - if let Some(h) = self.scene.active_viewport { - let (o, x, y, per) = match &self.active_ucs { - Some(u) => (u.origin, u.x_axis, u.y_axis, true), - None => (Vector3::ZERO, Vector3::UNIT_X, Vector3::UNIT_Y, false), + if let Some(index) = self.active_block_edit { + let active_ucs = self.active_ucs.clone(); + if let Some(session) = self.block_edits.get_mut(index) { + session.editor_ucs = active_ucs; + } + } else if let Some(h) = self.scene.active_viewport { + let (o, x, y, handle, per) = match &self.active_ucs { + Some(u) => (u.origin, u.x_axis, u.y_axis, u.handle, true), + None => ( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + Handle::NULL, + false, + ), }; if let Some(acadrust::EntityType::Viewport(vp)) = self.scene.document.get_entity_mut(h) { vp.ucs_origin = o; vp.ucs_x_axis = x; vp.ucs_y_axis = y; + vp.ucs_handle = handle; vp.ucs_per_viewport = per; } } else if self.scene.current_layout == "Model" { @@ -316,11 +330,13 @@ impl DocumentTab { h.model_space_ucs_origin = u.origin; h.model_space_ucs_x_axis = u.x_axis; h.model_space_ucs_y_axis = u.y_axis; + h.model_space_ucs_name = u.name.clone(); } None => { h.model_space_ucs_origin = Vector3::ZERO; h.model_space_ucs_x_axis = Vector3::UNIT_X; h.model_space_ucs_y_axis = Vector3::UNIT_Y; + h.model_space_ucs_name.clear(); } } } diff --git a/src/app/helpers.rs b/src/app/helpers.rs index 24d601c0c..e8a7f71d1 100644 --- a/src/app/helpers.rs +++ b/src/app/helpers.rs @@ -73,8 +73,14 @@ impl UcsXform { pub(super) fn from_ucs(ucs: &Ucs) -> Self { let v = |a: acadrust::types::Vector3| glam::DVec3::new(a.x, a.y, a.z); let x = v(ucs.x_axis).normalize_or(glam::DVec3::X); - let y = v(ucs.y_axis).normalize_or(glam::DVec3::Y); - let z = x.cross(y).normalize_or(glam::DVec3::Z); + let raw_y = v(ucs.y_axis).normalize_or(glam::DVec3::Y); + let fallback_z = if x.dot(glam::DVec3::Z).abs() < 0.999 { + glam::DVec3::Z + } else { + glam::DVec3::Y + }; + let z = x.cross(raw_y).normalize_or(x.cross(fallback_z).normalize()); + let y = z.cross(x).normalize(); Self { origin: v(ucs.origin), x, y, z } } @@ -127,6 +133,44 @@ impl UcsXform { glam::Vec4::W, ) } + + /// Full UCS-local → WCS transform, using `origin` as local zero while + /// retaining this UCS's orthonormal axes. + pub(super) fn to_wcs_transform_at( + &self, + origin: glam::DVec3, + ) -> acadrust::types::Transform { + use acadrust::types::{Matrix4, Transform}; + Transform::from_matrix(Matrix4 { + m: [ + [self.x.x, self.y.x, self.z.x, origin.x], + [self.x.y, self.y.y, self.z.y, origin.y], + [self.x.z, self.y.z, self.z.z, origin.z], + [0.0, 0.0, 0.0, 1.0], + ], + }) + } + + /// Full WCS → UCS-local transform, using `origin` as the local zero. + pub(super) fn to_ucs_transform_at( + &self, + origin: glam::DVec3, + ) -> acadrust::types::Transform { + use acadrust::types::{Matrix4, Transform}; + Transform::from_matrix(Matrix4 { + m: [ + [self.x.x, self.x.y, self.x.z, -origin.dot(self.x)], + [self.y.x, self.y.y, self.y.z, -origin.dot(self.y)], + [self.z.x, self.z.y, self.z.z, -origin.dot(self.z)], + [0.0, 0.0, 0.0, 1.0], + ], + }) + } + + /// Convert from the represented UCS into its canonical local frame. + pub(super) fn to_ucs_transform(&self) -> acadrust::types::Transform { + self.to_ucs_transform_at(self.origin) + } } // ── UCS ↔ WCS transforms (thin wrappers over `UcsXform`) ──────────────────── diff --git a/src/app/history.rs b/src/app/history.rs index c71bb589a..751dec81f 100644 --- a/src/app/history.rs +++ b/src/app/history.rs @@ -969,11 +969,23 @@ impl OpenCADStudio { scene.reseed_derived_caches(handle); } if !final_changes.is_empty() { - scene.bump_entities(&final_changes); + if scene.changes_touch_block_definition(&final_changes) { + // A block-local delta changes the assembled definition + // consumed by every INSERT (including nested blocks). + // Replaying only the child handle leaves those cached + // references visually stale after BEDIT closes. + scene.bump_geometry(); + } else { + scene.bump_entities(&final_changes); + } } } scene.clear_preview_wire(); } + // UCS state is cached separately from its persisted header/viewport + // fields. History may just have restored those fields, so adopt them + // before drawing the icon/grid or accepting the next coordinate. + self.tabs[i].refresh_active_ucs(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].active_grip = None; diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 9101ca6b2..431377225 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -493,7 +493,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { && (self.ucs_grip_drag.is_some() || self.ucs_icon_selected) { let i = self.active_tab; - self.ucs_grip_drag = None; + // A UCS grip drag is live-only until mouse release. Reload + // the persisted pane UCS so Escape really cancels it. + if self.ucs_grip_drag.take().is_some() { + self.tabs[i].refresh_active_ucs(); + self.tabs[i].scene.camera_generation += 1; + } self.ucs_icon_selected = false; self.ucs_icon_hover = false; self.tabs[i].snap_result = None; @@ -1319,6 +1324,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { vp.ucs_origin = ucs.origin.clone(); vp.ucs_x_axis = ucs.x_axis.clone(); vp.ucs_y_axis = ucs.y_axis.clone(); + vp.ucs_per_viewport = true; } } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a9b1d3364..7c7ab09ef 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2344,16 +2344,20 @@ impl OpenCADStudio { Message::SetViewcubeUcs(name) => { let i = self.active_tab; + let mut changed = false; if name.is_empty() || name == "WCS" { self.tabs[i].active_ucs = None; self.command_line.push_output("UCS: World"); + changed = true; } else if let Some(named) = self.tabs[i].scene.document.ucss.get(&name).cloned() { self.tabs[i].active_ucs = Some(named); self.command_line.push_output(&format!("UCS: {}", name)); + changed = true; + } + if changed { + self.commit_active_ucs_change(i, "UCS"); + self.tabs[i].scene.camera_generation += 1; } - self.tabs[i].sync_ucs_to_scene(); - self.tabs[i].scene.camera_generation += 1; - self.tabs[i].dirty = true; Task::none() } diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 232aecdc8..65ba44ed5 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1752,10 +1752,81 @@ impl OpenCADStudio { } } self.tabs[i].sync_ucs_to_scene(); - self.tabs[i].dirty = true; self.tabs[i].scene.camera_generation += 1; } + /// Commit the live UCS to its owning space. Model/viewport panes persist + /// their normal UCS fields. A BEDIT pane has no persistent UCS of its own: + /// its transient origin and axes are baked into the block definition, then + /// the editor returns to canonical local coordinates. + pub(in crate::app) fn commit_active_ucs_change(&mut self, i: usize, label: &'static str) { + if let Some((session_index, block_record)) = self.tabs[i] + .active_block_edit + .and_then(|index| { + self.tabs[i] + .block_edits + .get(index) + .map(|session| (index, session.br_handle)) + }) + { + let frame = self.tabs[i].ucs_xform(); + if !frame.is_identity() { + self.push_undo_snapshot(i, label); + let local_from_old = frame.to_ucs_transform(); + let changed = self.tabs[i] + .scene + .reframe_block_definition(block_record, &local_from_old); + if changed > 0 { + // Carry the editor camera through the same rigid transform + // so changing the block coordinate frame does not make its + // contents jump on screen. + self.tabs[i] + .scene + .camera + .borrow_mut() + .apply_rigid_transform(&local_from_old); + self.tabs[i].scene.camera_generation += 1; + self.tabs[i].dirty = true; + } + } + + // Blocks do not own a saved UCS. The chosen frame has now become + // their identity coordinates. + self.tabs[i].active_ucs = None; + let editor_camera = self.tabs[i].scene.camera.borrow().clone(); + if let Some(session) = self.tabs[i].block_edits.get_mut(session_index) { + session.editor_ucs = None; + session.editor_camera = editor_camera; + } + self.tabs[i].sync_ucs_to_scene(); + return; + } + + let persisted = if let Some(handle) = self.tabs[i].scene.active_viewport { + self.tabs[i].ucs_from_viewport(handle) + } else if self.tabs[i].scene.current_layout == "Model" { + self.tabs[i].model_ucs_from_header() + } else { + None + }; + let same_basis = |a: Option<&acadrust::tables::Ucs>, + b: Option<&acadrust::tables::Ucs>| { + match (a, b) { + (None, None) => true, + (Some(a), Some(b)) => { + a.origin == b.origin && a.x_axis == b.x_axis && a.y_axis == b.y_axis + } + _ => false, + } + }; + if !same_basis(self.tabs[i].active_ucs.as_ref(), persisted.as_ref()) { + self.push_undo_snapshot(i, label); + self.tabs[i].persist_active_ucs(); + self.tabs[i].dirty = true; + } + self.tabs[i].sync_ucs_to_scene(); + } + // ── Per-pane Model viewport (pane_grid) ─────────────────────────────── /// Focus Model pane `idx` (cursor entered it): swap in its camera, sync the @@ -2033,9 +2104,10 @@ impl OpenCADStudio { } // Commit a UCS icon grip drag: persist the new UCS so it - // round-trips, and clear the lingering press state. + // round-trips, and clear the lingering press state. In BEDIT this + // rebases only the block; elsewhere it writes the model/viewport UCS. if self.ucs_grip_drag.take().is_some() { - self.tabs[i].persist_active_ucs(); + self.commit_active_ucs_change(i, "UCS"); self.tabs[i].snap_result = None; self.snapper.from_point = None; let mut sel = self.tabs[i].scene.selection.borrow_mut(); @@ -3819,9 +3891,11 @@ impl OpenCADStudio { let cancel_task = self.cancel_active_command_for_space_change(); let current_camera = self.tabs[i].scene.camera.borrow().clone(); + let current_ucs = self.tabs[i].active_ucs.clone(); if let Some(active_index) = self.tabs[i].active_block_edit { if let Some(session) = self.tabs[i].block_edits.get_mut(active_index) { session.editor_camera = current_camera; + session.editor_ucs = current_ucs; } } else { self.tabs[i].scene.sync_camera_to_document(); @@ -3902,9 +3976,11 @@ impl OpenCADStudio { }; if let Some(active_index) = self.tabs[i].active_block_edit.take() { let camera = self.tabs[i].scene.camera.borrow().clone(); + let editor_ucs = self.tabs[i].active_ucs.clone(); let (return_layout, return_camera) = { let session = &mut self.tabs[i].block_edits[active_index]; session.editor_camera = camera; + session.editor_ucs = editor_ucs; (session.return_layout.clone(), session.return_camera.clone()) }; let return_layout = if self.tabs[i].scene.layout_names().contains(&return_layout) { diff --git a/src/command.rs b/src/command.rs index 7f1e8ee8d..af7bd19ab 100644 --- a/src/command.rs +++ b/src/command.rs @@ -33,6 +33,9 @@ pub enum EntityTransform { Scale { center: DVec3, factor: f64 }, /// Mirror across the line through `p1`→`p2` in the world XY plane. Mirror { p1: DVec3, p2: DVec3 }, + /// General affine transform. Used when a complete UCS basis must be baked + /// into block-local geometry instead of stored as drawing UCS state. + Affine(acadrust::types::Transform), } // ── Tangent object ───────────────────────────────────────────────────────── diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 2bb42aaca..fda8027a0 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -636,6 +636,47 @@ fn apply_transform(dim: &mut Dimension, t: &EntityTransform) { EntityTransform::Mirror { p1, p2 } => { transform_dimension_points(dim, |pt| mirror_point(pt, *p1, *p2)) } + EntityTransform::Affine(transform) => { + let old_normal = dim.base().normal; + let text_rotation = + transformed_dimension_angle(old_normal, dim.base().text_rotation, transform); + let horizontal_direction = transformed_dimension_angle( + old_normal, + dim.base().horizontal_direction, + transform, + ); + let insertion_rotation = transformed_dimension_angle( + old_normal, + dim.base().insertion_rotation, + transform, + ); + let linear_angles = match dim { + Dimension::Linear(value) => Some(( + transformed_dimension_angle(old_normal, value.rotation, transform), + transformed_dimension_angle(old_normal, value.ext_line_rotation, transform), + )), + _ => None, + }; + transform_dimension_points(dim, |point| { + *point = transform.apply(*point); + }); + let transformed_normal = transform.apply_rotation(old_normal); + let base = dim.base_mut(); + base.normal = if transformed_normal.length() > 1e-12 { + transformed_normal.normalize() + } else { + old_normal + }; + base.text_rotation = text_rotation; + base.horizontal_direction = horizontal_direction; + base.insertion_rotation = insertion_rotation; + if let Some((rotation, ext_line_rotation)) = linear_angles { + if let Dimension::Linear(value) = dim { + value.rotation = rotation; + value.ext_line_rotation = ext_line_rotation; + } + } + } } dim.base_mut().actual_measurement = dim.measurement(); } @@ -644,6 +685,7 @@ fn transform_dimension_points(dim: &mut Dimension, mut f: F) where F: FnMut(&mut acadrust::types::Vector3), { + f(&mut dim.base_mut().definition_point); f(&mut dim.base_mut().text_middle_point); f(&mut dim.base_mut().insertion_point); match dim { @@ -702,6 +744,32 @@ where } } +fn transformed_dimension_angle( + normal: acadrust::types::Vector3, + angle: f64, + transform: &acadrust::types::Transform, +) -> f64 { + let ((xx, xy, xz), (yx, yy, yz)) = + crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + let direction = acadrust::types::Vector3::new( + xx * angle.cos() + yx * angle.sin(), + xy * angle.cos() + yy * angle.sin(), + xz * angle.cos() + yz * angle.sin(), + ); + let transformed_normal = transform.apply_rotation(normal).normalize(); + let transformed_direction = transform.apply_rotation(direction).normalize(); + let ((nxx, nxy, nxz), (nyx, nyy, nyz)) = crate::scene::view::transform::ocs_axes(( + transformed_normal.x, + transformed_normal.y, + transformed_normal.z, + )); + let new_x = acadrust::types::Vector3::new(nxx, nxy, nxz); + let new_y = acadrust::types::Vector3::new(nyx, nyy, nyz); + transformed_direction + .dot(&new_y) + .atan2(transformed_direction.dot(&new_x)) +} + fn rotate_point(p: &mut acadrust::types::Vector3, center: DVec3, angle_rad: f64) { let dx = p.x - center.x; let dy = p.y - center.y; diff --git a/src/entities/extended.rs b/src/entities/extended.rs index dee7dfa4c..2dca84f95 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1383,6 +1383,7 @@ fn entity_transform(transform: &EntityTransform) -> Transform { EntityTransform::Mirror { p1, p2 } => { crate::scene::view::transform::reflection_about_xy_line(*p1, *p2) } + EntityTransform::Affine(transform) => *transform, } } diff --git a/src/entities/line.rs b/src/entities/line.rs index f2d7ba866..1913c7693 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -153,6 +153,9 @@ fn apply_transform(line: &mut Line, t: &EntityTransform) { EntityTransform::Mirror { p1, p2 } => { crate::scene::view::transform::mirror_xy_line(line, *p1, *p2); } + EntityTransform::Affine(transform) => { + acadrust::Entity::apply_transform(line, transform); + } } } diff --git a/src/entities/ole2frame.rs b/src/entities/ole2frame.rs index 761aad554..133651f1c 100644 --- a/src/entities/ole2frame.rs +++ b/src/entities/ole2frame.rs @@ -161,6 +161,9 @@ fn apply_transform(ole: &mut Ole2Frame, t: &EntityTransform) { ole.lower_right_corner.x = scale(ole.lower_right_corner.x, center.x as f64); ole.lower_right_corner.y = scale(ole.lower_right_corner.y, center.y as f64); } + EntityTransform::Affine(transform) => { + acadrust::Entity::apply_transform(ole, transform); + } _ => {} } } diff --git a/src/entities/underlay.rs b/src/entities/underlay.rs index 247b24453..770bb47f0 100644 --- a/src/entities/underlay.rs +++ b/src/entities/underlay.rs @@ -419,6 +419,9 @@ impl Transformable for Underlay { self.insertion_point.y = by + dx * sin_a + dy * cos_a; self.rotation += a; } + EntityTransform::Affine(transform) => { + acadrust::Entity::apply_transform(self, transform); + } } } } diff --git a/src/io/mod.rs b/src/io/mod.rs index 1bf7865d1..6cb8005e2 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -371,6 +371,7 @@ pub(crate) fn load_file_with_progress( .map_err(|e| e.to_string())? .read() .map_err(|e| e.to_string())?; + normalize_block_origins(&mut doc); fix_viewport_status_flags(&mut doc); fix_current_style_names(&mut doc); resolve_raster_image_paths(&mut doc, path.parent()); @@ -382,6 +383,7 @@ pub(crate) fn load_file_with_progress( .map_err(|e| e.to_string())? .read() .map_err(|e| e.to_string())?; + normalize_block_origins(&mut doc); fix_dxf_dimension_rotations(&mut doc); fix_dxf_layout_plot_settings(&mut doc); fix_viewport_status_flags(&mut doc); @@ -394,6 +396,62 @@ pub(crate) fn load_file_with_progress( } } +/// Canonicalise legacy block-table origins to the post-R10 representation: +/// block contents are local around zero and INSERT carries placement. +/// +/// DWG exposes the legacy point on `BlockRecord::base_point`; DXF exposes it +/// on the structural BLOCK entity. Rendering, picking, exploding and nested +/// insertion can then share the ordinary zero-origin transform without each +/// path having to reinterpret this compatibility field. +fn normalize_block_origins(doc: &mut CadDocument) { + use acadrust::types::Vector3; + use acadrust::EntityType; + + let blocks: Vec<_> = doc + .block_records + .iter() + .filter_map(|record| { + let marker_point = match doc.get_entity(record.block_entity_handle) { + Some(EntityType::Block(block)) => block.base_point, + _ => Vector3::ZERO, + }; + let base_point = if marker_point.length() > 1e-12 { + marker_point + } else { + record.base_point + }; + (base_point.length() > 1e-12).then(|| { + ( + record.name.clone(), + record.block_entity_handle, + record.entity_handles.clone(), + base_point, + ) + }) + }) + .collect(); + + for (name, marker_handle, handles, base_point) in blocks { + let offset = base_point * -1.0; + for handle in handles { + let Some(entity) = doc.get_entity_mut(handle) else { + continue; + }; + match entity { + EntityType::Block(block) => block.base_point = Vector3::ZERO, + EntityType::BlockEnd(_) => {} + _ => entity.translate(offset), + } + } + if let Some(EntityType::Block(block)) = doc.get_entity_mut(marker_handle) { + block.base_point = Vector3::ZERO; + } + if let Some(record) = doc.block_records.get_mut(&name) { + record.base_point = Vector3::ZERO; + } + } +} + /// A RasterImage entity stores its file path on the linked ImageDefinition, /// often as the original author's absolute path (e.g. a Windows / VMware share) /// that doesn't exist on this machine. Resolve each image to a usable path: diff --git a/src/modules/draw/modify/block_edit.rs b/src/modules/draw/modify/block_edit.rs index 71666173f..ac7b2098e 100644 --- a/src/modules/draw/modify/block_edit.rs +++ b/src/modules/draw/modify/block_edit.rs @@ -55,15 +55,27 @@ pub struct BlockEditSession { /// Parent block tab to return to for a nested BEDIT session. `None` returns /// to `return_layout`. pub return_block: Option, - /// The block's block-local entities captured on entry, so Discard can - /// restore the definition (structural Block/BlockEnd/AttDef excluded). + /// The complete block definition captured on entry, so Discard restores + /// structural markers and content with their original handles. pub snapshot: Vec, + /// Baked anonymous dimension-block entities transformed alongside + /// dimensions in this definition. They remain in place, so Discard can + /// restore their shared before-images directly. + pub dependent_snapshot: Vec>, + /// Inline ATTRIB values/positions on every existing INSERT of this block. + /// Rebasing the origin moves them with the definition; Discard restores + /// their exact on-entry state. + pub reference_attributes: + Vec<(Handle, Vec)>, /// Camera state of the space that was active when BEDIT began, restored /// on Save/Discard so the view returns exactly where it was (#425). pub return_camera: crate::scene::view::camera::Camera, /// Camera owned by this block tab. Updated whenever another space tab is /// activated, then restored when the user returns to this block. pub editor_camera: crate::scene::view::camera::Camera, + /// Transient UCS being manipulated in this block tab. On commit its full + /// frame is baked into the definition and this returns to `None`. + pub editor_ucs: Option, } // ── BEDIT pick command ───────────────────────────────────────────────────── diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 7d5042472..3a4163ffd 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -607,7 +607,8 @@ impl Scene { &mut self, handles: &[Handle], name: &str, - base: glam::DVec3, + world_to_block: &acadrust::types::Transform, + block_to_world: &acadrust::types::Transform, ) -> Result { let name = name.trim(); if name.is_empty() { @@ -656,7 +657,7 @@ impl Scene { .add_entity(EntityType::BlockEnd(block_end)) .map_err(|e| e.to_string())?; - let local = EntityTransform::Translate(-base); + let local = EntityTransform::Affine(*world_to_block); for (old_handle, mut entity) in source_entities { view::dispatch::apply_transform(&mut entity, &local); entity = crate::modules::draw::modify::explode::normalize_entity_for_block(entity); @@ -668,10 +669,8 @@ impl Scene { self.erase_entities(&[old_handle]); } - let insert = DxfInsert::new( - name, - acadrust::types::Vector3::new(base.x, base.y, base.z), - ); + let mut insert = DxfInsert::new(name, acadrust::types::Vector3::ZERO); + acadrust::Entity::apply_transform(&mut insert, block_to_world); Ok(self.add_entity(EntityType::Insert(insert))) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 10a072844..5f851ccb2 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2466,6 +2466,51 @@ impl Scene { self.push_geometry_delta(epoch, changes.to_vec(), false); } + /// True when any changed handle belongs to an ordinary block definition + /// rather than model/paper space. History replay needs this distinction: + /// restoring a block-local entity must invalidate the assembled definition + /// and every INSERT that consumes it, not only that entity's direct wire. + /// + /// Removed entities may no longer have a live slot, so fall back to the + /// BlockRecord membership list, which deliberately retains their handles + /// for symmetric undo/redo restoration. + pub(crate) fn changes_touch_block_definition( + &self, + changes: &[(Handle, ChangeKind)], + ) -> bool { + let layout_blocks: HashSet = self + .document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) if !layout.block_record.is_null() => { + Some(layout.block_record) + } + _ => None, + }) + .collect(); + let is_definition = |record: &acadrust::tables::BlockRecord| { + let name = record.name.to_ascii_uppercase(); + !layout_blocks.contains(&record.handle) + && !name.starts_with("*MODEL_SPACE") + && !name.starts_with("*PAPER_SPACE") + }; + + changes.iter().any(|(handle, _)| { + let owner = self + .document + .get_entity(*handle) + .map(|entity| entity.common().owner_handle); + self.document.block_records.iter().any(|record| { + is_definition(record) + && (owner == Some(record.handle) + || record.block_entity_handle == *handle + || record.block_end_handle == *handle + || record.entity_handles.contains(handle)) + }) + }) + } + pub fn bump_geometry(&mut self) { let epoch = GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed); self.geometry_epoch = epoch; diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 5fa778e91..2521300b5 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -9,6 +9,44 @@ struct TextOrient { x_scale: f64, } +fn inverse_affine( + transform: &acadrust::types::Transform, +) -> Option { + use acadrust::types::{Matrix3, Matrix4, Transform, Vector3}; + let matrix = &transform.matrix.m; + let linear = Matrix3::from_rows( + [matrix[0][0], matrix[0][1], matrix[0][2]], + [matrix[1][0], matrix[1][1], matrix[1][2]], + [matrix[2][0], matrix[2][1], matrix[2][2]], + ); + let inverse = linear.inverse()?; + let translation = Vector3::new(matrix[0][3], matrix[1][3], matrix[2][3]); + let inverse_translation = inverse * (translation * -1.0); + Some(Transform::from_matrix(Matrix4 { + m: [ + [ + inverse.m[0][0], + inverse.m[0][1], + inverse.m[0][2], + inverse_translation.x, + ], + [ + inverse.m[1][0], + inverse.m[1][1], + inverse.m[1][2], + inverse_translation.y, + ], + [ + inverse.m[2][0], + inverse.m[2][1], + inverse.m[2][2], + inverse_translation.z, + ], + [0.0, 0.0, 0.0, 1.0], + ], + })) +} + /// Snapshot the orientation of a text / mtext / shape entity, or `None` for any /// other kind. fn capture_text_orient(e: &EntityType) -> Option { @@ -229,6 +267,164 @@ impl Scene { self.bump_entities(&changes); } + /// Entities in anonymous dimension blocks that visually belong to + /// dimensions inside `block_record`. They must follow a block-coordinate + /// reframe and be included in BEDIT's discard snapshot. + pub(crate) fn block_definition_dependent_handles( + &self, + block_record: Handle, + ) -> Vec { + let Some(record) = self + .document + .block_records + .iter() + .find(|record| record.handle == block_record) + else { + return Vec::new(); + }; + let root_handles: HashSet = record.entity_handles.iter().copied().collect(); + let mut result = Vec::new(); + let mut seen = HashSet::default(); + for handle in &record.entity_handles { + let Some(EntityType::Dimension(dimension)) = self.document.get_entity(*handle) else { + continue; + }; + let name = dimension.base().block_name.trim(); + if name.is_empty() { + continue; + } + let Some(dependent) = self + .document + .block_records + .iter() + .find(|candidate| candidate.name.eq_ignore_ascii_case(name)) + else { + continue; + }; + for dependent_handle in &dependent.entity_handles { + if !root_handles.contains(dependent_handle) && seen.insert(*dependent_handle) { + result.push(*dependent_handle); + } + } + } + result + } + + /// Bake a transient BEDIT UCS into one block definition. + /// + /// `local_from_old` maps the currently stored block coordinates into the + /// newly selected UCS frame. No UCS record is attached to the block: the + /// content is transformed and remains canonical around identity/zero. + pub fn reframe_block_definition( + &mut self, + block_record: Handle, + local_from_old: &acadrust::types::Transform, + ) -> usize { + let Some(record) = self + .document + .block_records + .iter() + .find(|record| record.handle == block_record) + else { + return 0; + }; + let block_name = record.name.clone(); + let root_handles: HashSet = record.entity_handles.iter().copied().collect(); + let dependent_handles = self.block_definition_dependent_handles(block_record); + let mut handles = record.entity_handles.clone(); + handles.extend(dependent_handles.iter().copied()); + let owned: HashSet = handles.iter().copied().collect(); + let transform = EntityTransform::Affine(*local_from_old); + let matrix = &local_from_old.matrix.m; + let dependent_transform = + EntityTransform::Affine(acadrust::types::Transform::from_matrix( + acadrust::types::Matrix4 { + m: [ + [matrix[0][0], matrix[0][1], matrix[0][2], 0.0], + [matrix[1][0], matrix[1][1], matrix[1][2], 0.0], + [matrix[2][0], matrix[2][1], matrix[2][2], 0.0], + [0.0, 0.0, 0.0, 1.0], + ], + }, + )); + let mut changed = Vec::new(); + + for handle in handles { + let is_structural = self.document.get_entity(handle).is_some_and(|entity| { + matches!(entity, EntityType::Block(_) | EntityType::BlockEnd(_)) + }); + if is_structural { + continue; + } + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(entity) = self.document.get_entity_mut(handle) { + // A nested dimension's anonymous `*D` picture is placed by + // adding the dimension insertion point. That point already + // receives the affine translation with the root dimension, so + // its picture receives only the new basis here; applying the + // translation twice would shift dimensions on origin changes. + let entity_transform = if root_handles.contains(&handle) { + &transform + } else { + &dependent_transform + }; + view::dispatch::apply_transform(entity, entity_transform); + changed.push(handle); + } + } + + // ATTRIBs live inline on each INSERT in that reference's owner space. + // Conjugate the block-local reframe through the INSERT transform: + // owner' = M · local_from_old · M⁻¹ · owner. + let references: Vec<(Handle, acadrust::types::Transform)> = self + .document + .entities() + .filter_map(|entity| match entity { + EntityType::Insert(reference) + if reference.block_name.eq_ignore_ascii_case(&block_name) + && !reference.attributes.is_empty() + && !owned.contains(&reference.common.handle) => + { + let insertion = reference.get_transform(); + let inverse = inverse_affine(&insertion)?; + Some(( + reference.common.handle, + acadrust::types::Transform::from_matrix( + insertion.matrix * local_from_old.matrix * inverse.matrix, + ), + )) + } + _ => None, + }) + .collect(); + for (handle, attribute_transform) in references { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some(EntityType::Insert(reference)) = self.document.get_entity_mut(handle) { + for attribute in &mut reference.attributes { + acadrust::Entity::apply_transform(attribute, &attribute_transform); + } + changed.push(handle); + } + } + + for handle in changed.iter().copied() { + let _ = crate::scene::annotative::sync_active_context_from_entity( + &mut self.document, + handle, + ); + } + if !changed.is_empty() { + self.rebuild_derived_caches(); + } + changed.len() + } + /// Give a freshly-cloned entity brand-new handles for every *inline* /// sub-entity that stores one (INSERT attributes, 3D-polyline vertices). /// `document.add_entity` only assigns the top-level handle, so without this diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index e6425af9b..c17dd5486 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -273,6 +273,55 @@ impl Camera { Mat4::from_quat(self.rotation.conjugate()) } + /// Carry the camera through a rigid model-space transform. BEDIT uses this + /// when a transient UCS is baked into block-local geometry: the contents + /// retain their on-screen framing while their canonical coordinates change. + pub fn apply_rigid_transform(&mut self, transform: &acadrust::types::Transform) { + let point = acadrust::types::Vector3::new( + self.target.x, + self.target.y, + self.target.z, + ); + let point = transform.apply(point); + self.target = DVec3::new(point.x, point.y, point.z); + + let matrix = &transform.matrix.m; + let rotation = glam::Mat3::from_cols( + Vec3::new(matrix[0][0] as f32, matrix[1][0] as f32, matrix[2][0] as f32), + Vec3::new(matrix[0][1] as f32, matrix[1][1] as f32, matrix[2][1] as f32), + Vec3::new(matrix[0][2] as f32, matrix[1][2] as f32, matrix[2][2] as f32), + ); + self.rotation = (Quat::from_mat3(&rotation) * self.rotation).normalize(); + self.sync_yaw_pitch(); + + if let Some((min, max)) = self.model_bounds { + let corners = [ + Vec3::new(min.x, min.y, min.z), + Vec3::new(max.x, min.y, min.z), + Vec3::new(min.x, max.y, min.z), + Vec3::new(max.x, max.y, min.z), + Vec3::new(min.x, min.y, max.z), + Vec3::new(max.x, min.y, max.z), + Vec3::new(min.x, max.y, max.z), + Vec3::new(max.x, max.y, max.z), + ]; + let mut new_min = Vec3::splat(f32::INFINITY); + let mut new_max = Vec3::splat(f32::NEG_INFINITY); + for corner in corners { + let transformed = transform.apply(acadrust::types::Vector3::new( + corner.x as f64, + corner.y as f64, + corner.z as f64, + )); + let transformed = + Vec3::new(transformed.x as f32, transformed.y as f32, transformed.z as f32); + new_min = new_min.min(transformed); + new_max = new_max.max(transformed); + } + self.model_bounds = Some((new_min, new_max)); + } + } + /// The camera's roll — rotation about the view axis, in radians — the /// inverse of the `roll` argument to [`yaw_pitch_to_quat`]. Recovered by /// removing the yaw/pitch frame from the live rotation, so a saved view can diff --git a/src/scene/view/transform.rs b/src/scene/view/transform.rs index a94b75e66..72a68f662 100644 --- a/src/scene/view/transform.rs +++ b/src/scene/view/transform.rs @@ -40,6 +40,7 @@ where } EntityTransform::Scale { center, factor } => apply_standard_scale(entity, *center, *factor), EntityTransform::Mirror { p1, p2 } => mirror(entity, *p1, *p2), + EntityTransform::Affine(transform) => entity.apply_transform(transform), } }