forked from 1b-agent/opencadstudio-upstream
Root cause: the glyph tessellator advanced the pen by advance*width_factor while the transform already scales the pen position by the width factor, so a backward (negative width factor) run kept its word order and grew on the same side of the anchor instead of mirroring across it. Advance in unscaled glyph units to match text_local_bounds; the transform applies the factor once. On top of that: - The renderer honours the entity's own text-generation flags (DXF 71: bit 2 backward / bit 4 upside-down) XOR the text style's flags, and signs the horizontal anchor by the width factor so Center/Right backward text mirrors about its anchor. - MIRRTEXT on: a MIRROR toggles both group-71 bits so the reflected rotation renders a true glyph mirror (backward-upright for a vertical axis, flipped for a horizontal one). - MIRRTEXT off: the text flips left<->right and lands mirror-symmetric to the source (right-reading) instead of hugging the axis. - The MIRROR preview re-derives text ghosts to match the commit; mirrored() now reflects the double-single residual too. Co-Authored-By: Claude Opus 4.8 <[email protected]>
405 lines
18 KiB
Rust
405 lines
18 KiB
Rust
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
|
use super::*;
|
|
|
|
/// Orientation fields of a text-like entity, captured so a mirror can keep the
|
|
/// glyphs right-reading when MIRRTEXT (`header.mirror_text`) is off.
|
|
struct TextOrient {
|
|
rotation: f64,
|
|
oblique: f64,
|
|
x_scale: f64,
|
|
}
|
|
|
|
/// Snapshot the orientation of a text / mtext / shape entity, or `None` for any
|
|
/// other kind.
|
|
fn capture_text_orient(e: &EntityType) -> Option<TextOrient> {
|
|
match e {
|
|
EntityType::Text(t) => Some(TextOrient {
|
|
rotation: t.rotation,
|
|
oblique: t.oblique_angle,
|
|
x_scale: 0.0,
|
|
}),
|
|
EntityType::MText(m) => Some(TextOrient {
|
|
rotation: m.rotation,
|
|
oblique: 0.0,
|
|
x_scale: 0.0,
|
|
}),
|
|
EntityType::Shape(s) => Some(TextOrient {
|
|
rotation: s.rotation,
|
|
oblique: s.oblique_angle,
|
|
x_scale: s.relative_x_scale,
|
|
}),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// MIRRTEXT off: after the mirror reflects a text's points and rotation, put
|
|
/// the glyphs back to right-reading. Restores the captured rotation / oblique /
|
|
/// x-scale, and for single-line TEXT also flips the horizontal justification
|
|
/// (left ↔ right) so the box lands mirror-symmetric to the source instead of
|
|
/// hugging the axis — AutoCAD's behaviour. Center/Middle/Aligned/Fit are already
|
|
/// symmetric about their (reflected) anchor, so they stay.
|
|
fn restore_text_orient(e: &mut EntityType, o: &TextOrient) {
|
|
match e {
|
|
EntityType::Text(t) => {
|
|
t.rotation = o.rotation;
|
|
t.oblique_angle = o.oblique;
|
|
use acadrust::entities::TextHorizontalAlignment as HA;
|
|
t.horizontal_alignment = match t.horizontal_alignment {
|
|
HA::Left => HA::Right,
|
|
HA::Right => HA::Left,
|
|
other => other,
|
|
};
|
|
crate::entities::text::sync_text_alignment_point(t);
|
|
}
|
|
EntityType::MText(m) => {
|
|
m.rotation = o.rotation;
|
|
}
|
|
EntityType::Shape(s) => {
|
|
s.rotation = o.rotation;
|
|
s.oblique_angle = o.oblique;
|
|
s.relative_x_scale = o.x_scale;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// MIRRTEXT on: a real MIRROR makes text a true glyph mirror. `apply_transform`
|
|
/// already reflects the baseline rotation; toggling *both* group-71 bits
|
|
/// (backward `0x2` + upside-down `0x4`) then renders the exact geometric
|
|
/// reflection (the renderer XORs these flags into the effective width-factor
|
|
/// sign / 180° flip). Toggling — not setting — keeps a double mirror an
|
|
/// involution. Only single-line TEXT carries group 71; MTEXT / Shape keep the
|
|
/// reflected rotation (readable-but-rotated), which is standard-conforming.
|
|
fn mirror_true_text_flags(e: &mut EntityType) {
|
|
if let EntityType::Text(t) = e {
|
|
t.generation_flags ^= 0x2 | 0x4;
|
|
}
|
|
}
|
|
|
|
impl Scene {
|
|
// ── Modify (transform / copy) ─────────────────────────────────────────
|
|
|
|
pub fn transform_entities(&mut self, handles: &[Handle], t: &EntityTransform) {
|
|
// Never transform objects on a locked layer (defense-in-depth: the pick
|
|
// path already excludes them, but programmatic callers may not).
|
|
let handles: Vec<Handle> =
|
|
handles.iter().copied().filter(|&h| !self.is_layer_locked(h)).collect();
|
|
let handles = &handles[..];
|
|
// MIRRTEXT (header.mirror_text): when false AutoCAD positions text /
|
|
// mtext / shape by the mirror but keeps the original rotation +
|
|
// oblique so the text stays right-reading. Capture before the
|
|
// transform and re-apply afterwards.
|
|
let preserve_text_orientation =
|
|
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
|
// MIRRTEXT on: toggle the group-71 flags after the reflect so text
|
|
// becomes a true glyph mirror (see `mirror_true_text_flags`).
|
|
let mirror_true =
|
|
matches!(t, EntityTransform::Mirror { .. }) && self.document.header.mirror_text;
|
|
let mut text_orient_backup: Vec<(Handle, TextOrient)> = Vec::new();
|
|
if preserve_text_orientation {
|
|
for &h in handles {
|
|
if let Some(entity) = self.document.get_entity(h) {
|
|
if let Some(o) = capture_text_orient(entity) {
|
|
text_orient_backup.push((h, o));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// A dimension's final geometry is baked into a per-instance `*D`
|
|
// block, and the render draws those sub-entities directly (not the
|
|
// definition points). Transform them with the dimension, or it would
|
|
// stay drawn in place while only its def points move.
|
|
let dim_block_subs: Vec<Handle> = handles
|
|
.iter()
|
|
.filter_map(|&h| match self.document.get_entity(h) {
|
|
Some(EntityType::Dimension(d)) => {
|
|
let bn = d.base().block_name.clone();
|
|
if bn.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(bn)
|
|
}
|
|
}
|
|
_ => None,
|
|
})
|
|
.filter_map(|bn| {
|
|
self.document
|
|
.block_records
|
|
.iter()
|
|
.find(|br| br.name.eq_ignore_ascii_case(&bn))
|
|
.map(|br| br.entity_handles.clone())
|
|
})
|
|
.flatten()
|
|
.collect();
|
|
for &h in handles {
|
|
if let Some(entity) = self.document.get_entity_mut(h) {
|
|
view::dispatch::apply_transform(entity, t);
|
|
if mirror_true {
|
|
mirror_true_text_flags(entity);
|
|
}
|
|
}
|
|
if self.hatches.contains_key(&h) {
|
|
let existing_color = self.hatches[&h].color;
|
|
let new_model = match self.document.get_entity(h) {
|
|
Some(EntityType::Hatch(dxf)) => {
|
|
Self::hatch_model_from_dxf(dxf, existing_color)
|
|
}
|
|
// A DXF SOLID renders as a solid-fill hatch; rebuild it from
|
|
// the moved corners so the fill follows the transform.
|
|
Some(EntityType::Solid(s)) => {
|
|
Some(Self::solid_hatch_model(s, existing_color))
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(model) = new_model {
|
|
self.hatches.insert(h, model);
|
|
}
|
|
}
|
|
}
|
|
if preserve_text_orientation {
|
|
for (h, o) in text_orient_backup {
|
|
if let Some(entity) = self.document.get_entity_mut(h) {
|
|
restore_text_orient(entity, &o);
|
|
}
|
|
}
|
|
}
|
|
// Move the baked dimension-block sub-entities too (collected above).
|
|
for h in &dim_block_subs {
|
|
if let Some(entity) = self.document.get_entity_mut(*h) {
|
|
view::dispatch::apply_transform(entity, t);
|
|
}
|
|
}
|
|
// Only the transformed entities changed (a top-level move/rotate/scale/
|
|
// mirror never edits a block definition) — re-tessellate just those and
|
|
// keep the block cache + every other entity's memoized wires.
|
|
for &h in handles {
|
|
self.mark_entity_dirty(h);
|
|
}
|
|
self.bump_geometry_no_blocks();
|
|
}
|
|
|
|
/// 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
|
|
/// a copy keeps its source's sub-handles — duplicate handles that corrupt
|
|
/// the saved DWG (file won't reopen in other CAD apps). Vertices that don't
|
|
/// store a handle (LwPolyline / heavy 2D polyline) get one from the writer,
|
|
/// so they need no fix-up here. (#129)
|
|
pub(super) fn reset_clone_subhandles(doc: &mut acadrust::CadDocument, entity: &mut EntityType) {
|
|
match entity {
|
|
EntityType::Insert(ins) => {
|
|
for att in ins.attributes.iter_mut() {
|
|
att.common.handle = doc.allocate_handle();
|
|
}
|
|
}
|
|
EntityType::Polyline3D(p) => {
|
|
for v in p.vertices.iter_mut() {
|
|
v.handle = doc.allocate_handle();
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Add a freshly-cloned entity, allocating a new handle for it *and* every
|
|
/// inline sub-entity so the copy never shares a handle with its source.
|
|
/// Use this (not `add_entity`) whenever inserting a duplicate. (#129)
|
|
pub fn add_entity_clone(&mut self, mut entity: EntityType) -> Handle {
|
|
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
|
entity.common_mut().handle = Handle::NULL;
|
|
self.add_entity(entity)
|
|
}
|
|
|
|
/// Duplicate the anonymous block `src_name`, transforming every sub-entity
|
|
/// by `t`, and return the new block's name. A dimension's drawn geometry
|
|
/// lives in such a baked `*D` block, so a copied dimension needs its own
|
|
/// transformed block — otherwise it still references the source block and
|
|
/// renders on top of the original instead of at the copy. Returns None when
|
|
/// the source block is missing or empty. (#161)
|
|
fn clone_transformed_block(&mut self, src_name: &str, t: &EntityTransform) -> Option<String> {
|
|
let sub_handles = self
|
|
.document
|
|
.block_records
|
|
.iter()
|
|
.find(|br| br.name.eq_ignore_ascii_case(src_name))
|
|
.map(|br| br.entity_handles.clone())?;
|
|
if sub_handles.is_empty() {
|
|
return None;
|
|
}
|
|
// Smallest free `*D<n>` anonymous name.
|
|
let mut n = 0u64;
|
|
let new_name = loop {
|
|
let cand = format!("*D{n}");
|
|
if self.document.block_records.get(&cand).is_none() {
|
|
break cand;
|
|
}
|
|
n += 1;
|
|
};
|
|
let next = self.document.next_handle();
|
|
let br_handle = Handle::new(next);
|
|
let block_handle = Handle::new(next + 1);
|
|
let end_handle = Handle::new(next + 2);
|
|
let mut br = acadrust::tables::BlockRecord::new(&new_name);
|
|
br.handle = br_handle;
|
|
br.block_entity_handle = block_handle;
|
|
br.block_end_handle = end_handle;
|
|
self.document.block_records.add(br).ok()?;
|
|
let mut block = Block::new(&new_name, acadrust::types::Vector3::ZERO);
|
|
block.common.handle = block_handle;
|
|
block.common.owner_handle = br_handle;
|
|
self.document.add_entity(EntityType::Block(block)).ok()?;
|
|
let mut block_end = BlockEnd::new();
|
|
block_end.common.handle = end_handle;
|
|
block_end.common.owner_handle = br_handle;
|
|
self.document.add_entity(EntityType::BlockEnd(block_end)).ok()?;
|
|
for sh in sub_handles {
|
|
if let Some(mut sub) = self.document.get_entity(sh).cloned() {
|
|
view::dispatch::apply_transform(&mut sub, t);
|
|
Self::reset_clone_subhandles(&mut self.document, &mut sub);
|
|
sub.common_mut().handle = Handle::NULL;
|
|
sub.common_mut().owner_handle = br_handle;
|
|
let _ = self.document.add_entity(sub);
|
|
}
|
|
}
|
|
Some(new_name)
|
|
}
|
|
|
|
pub fn copy_entities(&mut self, handles: &[Handle], t: &EntityTransform) -> Vec<Handle> {
|
|
// Objects on a locked layer can't be copied (they can't be selected).
|
|
let clones: Vec<EntityType> = handles
|
|
.iter()
|
|
.filter(|&&h| !self.is_layer_locked(h))
|
|
.filter_map(|&h| self.document.get_entity(h).cloned())
|
|
.collect();
|
|
// MIRRTEXT also governs the copy path (default MIRROR keeps the source
|
|
// and adds a mirrored copy): keep the copied text right-reading when the
|
|
// header flag is off.
|
|
let preserve_text_orientation =
|
|
matches!(t, EntityTransform::Mirror { .. }) && !self.document.header.mirror_text;
|
|
let mirror_true =
|
|
matches!(t, EntityTransform::Mirror { .. }) && self.document.header.mirror_text;
|
|
let mut new_handles = Vec::with_capacity(clones.len());
|
|
for mut entity in clones {
|
|
let text_orient = if preserve_text_orientation {
|
|
capture_text_orient(&entity)
|
|
} else {
|
|
None
|
|
};
|
|
view::dispatch::apply_transform(&mut entity, t);
|
|
if let Some(o) = &text_orient {
|
|
restore_text_orient(&mut entity, o);
|
|
}
|
|
if mirror_true {
|
|
mirror_true_text_flags(&mut entity);
|
|
}
|
|
// A dimension draws from its baked `*D` block; give the copy its own
|
|
// transformed block so it lands at the copy position rather than
|
|
// rendering on top of the source. (#161)
|
|
if let EntityType::Dimension(d) = &entity {
|
|
let bn = d.base().block_name.clone();
|
|
if !bn.trim().is_empty() {
|
|
if let Some(new_bn) = self.clone_transformed_block(&bn, t) {
|
|
if let EntityType::Dimension(d) = &mut entity {
|
|
d.base_mut().block_name = new_bn;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Self::reset_clone_subhandles(&mut self.document, &mut entity);
|
|
entity.common_mut().handle = Handle::NULL;
|
|
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
|
|
if !h.is_null() {
|
|
let new_model = match self.document.get_entity(h) {
|
|
Some(EntityType::Hatch(dxf)) => {
|
|
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
|
Self::hatch_model_from_dxf(dxf, color)
|
|
}
|
|
Some(EntityType::Solid(s)) => {
|
|
let color = convert::tess_util::aci_to_rgba(&s.common.color);
|
|
Some(Self::solid_hatch_model(s, color))
|
|
}
|
|
_ => None,
|
|
};
|
|
if let Some(model) = new_model {
|
|
self.hatches.insert(h, model);
|
|
}
|
|
}
|
|
new_handles.push(h);
|
|
}
|
|
// The copies are new handles (natural memo misses, tessellated fresh)
|
|
// and reference only already-cached blocks — no block defn changes.
|
|
self.bump_geometry_no_blocks();
|
|
new_handles
|
|
}
|
|
|
|
// ── Grip editing ──────────────────────────────────────────────────────
|
|
|
|
pub fn apply_grip(&mut self, handle: Handle, grip_id: usize, apply: GripApply) {
|
|
// Objects on a locked layer can't be grip-edited.
|
|
if self.is_layer_locked(handle) {
|
|
return;
|
|
}
|
|
// For Solid3D / Region / Body, record the old point_of_reference so we
|
|
// can translate the pre-tessellated MeshModel by the same delta after
|
|
// the grip is applied (the ACIS data itself is not modified).
|
|
let old_por: Option<[f64; 3]> = self
|
|
.document
|
|
.get_entity(handle)
|
|
.and_then(crate::entities::solid3d::point_of_reference)
|
|
.map(|p| [p.x, p.y, p.z]);
|
|
|
|
if let Some(entity) = self.document.get_entity_mut(handle) {
|
|
view::dispatch::apply_grip(entity, grip_id, apply);
|
|
}
|
|
|
|
// Translate MeshModel vertices by the same delta the grip applied.
|
|
if let Some(old) = old_por {
|
|
let new_por: Option<[f64; 3]> = self
|
|
.document
|
|
.get_entity(handle)
|
|
.and_then(crate::entities::solid3d::point_of_reference)
|
|
.map(|p| [p.x, p.y, p.z]);
|
|
if let Some(new) = new_por {
|
|
let dx = (new[0] - old[0]) as f32;
|
|
let dy = (new[1] - old[1]) as f32;
|
|
let dz = (new[2] - old[2]) as f32;
|
|
if let Some(set) = self.meshes.get_mut(&handle) {
|
|
for lod in &mut set.lods {
|
|
for v in &mut lod.verts {
|
|
v[0] += dx;
|
|
v[1] += dy;
|
|
v[2] += dz;
|
|
}
|
|
}
|
|
set.world_aabb[0] += dx;
|
|
set.world_aabb[1] += dy;
|
|
set.world_aabb[2] += dx;
|
|
set.world_aabb[3] += dy;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rebuild GPU hatch/solid model when a boundary vertex or corner moves.
|
|
match self.document.get_entity(handle) {
|
|
Some(EntityType::Hatch(dxf)) => {
|
|
let color = convert::tess_util::aci_to_rgba(&dxf.common.color);
|
|
if let Some(model) = Self::hatch_model_from_dxf(dxf, color) {
|
|
self.hatches.insert(handle, model);
|
|
} else {
|
|
self.hatches.remove(&handle);
|
|
}
|
|
}
|
|
Some(EntityType::Solid(solid)) => {
|
|
let color = convert::tess_util::aci_to_rgba(&solid.common.color);
|
|
self.hatches
|
|
.insert(handle, Self::solid_hatch_model(solid, color));
|
|
}
|
|
_ => {}
|
|
}
|
|
// NOTE: no `bump_geometry()` here. The grip-drag caller hides the
|
|
// edited entity and previews it as an overlay during the drag (so a
|
|
// move doesn't re-tessellate the whole model), then bumps once on
|
|
// commit. Any other caller must bump geometry itself.
|
|
}
|
|
}
|