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.
This commit is contained in:
Hakan Seven 2026-07-30 19:50:33 +03:00
commit 2c64888048
22 changed files with 693 additions and 41 deletions

View file

@ -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<Handle, String> {
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)))
}

View file

@ -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<Handle> = 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;

View file

@ -9,6 +9,44 @@ struct TextOrient {
x_scale: f64,
}
fn inverse_affine(
transform: &acadrust::types::Transform,
) -> Option<acadrust::types::Transform> {
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<TextOrient> {
@ -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<Handle> {
let Some(record) = self
.document
.block_records
.iter()
.find(|record| record.handle == block_record)
else {
return Vec::new();
};
let root_handles: HashSet<Handle> = 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<Handle> = 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<Handle> = 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

View file

@ -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

View file

@ -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),
}
}