diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 8bf570514..efddf7465 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -648,8 +648,10 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { use crate::modules::draw::modify::mirror::MirrorCommand; - let wires = self.tabs[i].scene.wire_models_for(&handles); - let new_cmd = MirrorCommand::new(handles, wires); + let (wires, text_ghosts) = + self.tabs[i].scene.mirror_preview_parts(&handles); + let mirror_text = self.tabs[i].scene.document.header.mirror_text; + let new_cmd = MirrorCommand::new(handles, wires, text_ghosts, mirror_text); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/entities/text.rs b/src/entities/text.rs index 65135e7eb..d1a950176 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -69,14 +69,15 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { } else { resolved_style.width_factor.max(0.01) }; - // is_backward mirrors text left-right via negative width factor. - let width_factor = if resolved_style.is_backward { - -base_wf - } else { - base_wf - }; - // is_upside_down rotates 180° around the insertion point. - let rotation = if resolved_style.is_upside_down { + // Backward mirrors text left-right (negative width factor); upside-down + // rotates 180° about the anchor. The effective state is the TextStyle flag + // XOR the entity's own generation flags (DXF group 71: bit 2 = backward, + // bit 4 = upside-down). A MIRROR toggles the entity bit for a true glyph + // mirror, and XOR keeps a double mirror an involution. + let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0); + let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0); + let width_factor = if eff_backward { -base_wf } else { base_wf }; + let rotation = if eff_upside { t.rotation as f32 + std::f32::consts::PI } else { t.rotation as f32 @@ -111,11 +112,16 @@ fn to_truck(t: &Text, document: &acadrust::CadDocument) -> TruckEntity { ); let (anchor_local_x, anchor_local_y) = if let Some(b) = bounds { // Horizontal anchor uses the pen advance box [0, advance] so leading / - // trailing spaces keep their width instead of snapping to the inked glyphs. + // trailing spaces keep their width instead of snapping to the inked + // glyphs. Signed by the width factor: backward text grows in −x, so its + // Center/Right offset flips sign too, otherwise the anchor pushes the + // box one way while the strokes run the other (the bounds advance is + // always positive — it uses |width_factor|). Left keeps its 0 reference. + let sign = width_factor.signum(); let ax = match t.horizontal_alignment { HA::Left => 0.0, - HA::Center | HA::Middle => b.advance * 0.5, - HA::Right | HA::Aligned | HA::Fit => b.advance, + HA::Center | HA::Middle => b.advance * 0.5 * sign, + HA::Right | HA::Aligned | HA::Fit => b.advance * sign, }; // Vertical anchor uses the inked extent (cap / baseline geometry). let ay = match t.vertical_alignment { diff --git a/src/modules/draw/modify/mirror.rs b/src/modules/draw/modify/mirror.rs index 2a4dbb7aa..4a0e64710 100644 --- a/src/modules/draw/modify/mirror.rs +++ b/src/modules/draw/modify/mirror.rs @@ -34,14 +34,26 @@ enum Step { pub struct MirrorCommand { handles: Vec, wire_models: Vec, + /// Text ghosts paired with their bounding-box centre — mirrored per MIRRTEXT + /// so the preview matches the commit (true glyph mirror on / symmetric + /// right-reading off). + text_ghosts: Vec<(WireModel, DVec3)>, + mirror_text: bool, step: Step, } impl MirrorCommand { - pub fn new(handles: Vec, wire_models: Vec) -> Self { + pub fn new( + handles: Vec, + wire_models: Vec, + text_ghosts: Vec<(WireModel, DVec3)>, + mirror_text: bool, + ) -> Self { Self { handles, wire_models, + text_ghosts, + mirror_text, step: Step::P1, } } @@ -121,12 +133,25 @@ impl CadCommand for MirrorCommand { Step::AskErase { p1, p2 } => (*p1, *p2), _ => return vec![], }; - // Mirrored ghosts of all selected objects. + // Mirrored ghosts of all non-text objects (full geometric reflection). let mut out: Vec = self .wire_models .iter() .map(|w| w.mirrored(p1.as_vec3(), p2.as_vec3())) .collect(); + // Text ghosts honour MIRRTEXT: on → true glyph mirror (full reflection); + // off → keep glyphs readable, relocate to the mirror-symmetric position + // by reflecting the box centre and translating. + for (w, center) in &self.text_ghosts { + if self.mirror_text { + out.push(w.mirrored(p1.as_vec3(), p2.as_vec3())); + } else { + let (mut cx, mut cy) = (center.x, center.y); + crate::scene::view::transform::reflect_xy_point(&mut cx, &mut cy, p1, p2); + let delta = glam::Vec3::new((cx - center.x) as f32, (cy - center.y) as f32, 0.0); + out.push(w.translated(delta)); + } + } // Mirror-axis line (rubber-band). out.push(WireModel::solid( "rubber_band".into(), diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index 37b8f0559..1a27ee89b 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -232,6 +232,14 @@ impl WireModel { p[0] = p1.x + 2.0 * t * ax - dx; p[1] = p1.y + 2.0 * t * ay - dy; } + // World position is the double-single sum `points + points_low` (text / + // UTM wires split it), so the residual must reflect too — as a direction + // (linear reflection about the axis, no `p1` offset). + for p in &mut out.points_low { + let t = (p[0] * ax + p[1] * ay) / len2; + p[0] = 2.0 * t * ax - p[0]; + p[1] = 2.0 * t * ay - p[1]; + } out } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index bc7314414..15c5f1285 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -32,13 +32,24 @@ fn capture_text_orient(e: &EntityType) -> Option { } } -/// Re-apply a captured orientation after a transform (the position stays -/// mirrored, only the rotation / oblique / x-scale are restored). +/// 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; @@ -52,6 +63,19 @@ fn restore_text_orient(e: &mut EntityType, o: &TextOrient) { } } +/// 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) ───────────────────────────────────────── @@ -67,6 +91,10 @@ impl Scene { // 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 { @@ -106,6 +134,9 @@ impl Scene { 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; @@ -245,6 +276,8 @@ impl Scene { // 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 { @@ -256,6 +289,9 @@ impl Scene { 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) diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 91fb4a2c1..32982f740 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -42,6 +42,55 @@ impl Scene { .collect() } + /// Split a MIRROR selection into plain ghost wires (reflected wholesale) and + /// text ghosts paired with their bounding-box centre. Lets the preview match + /// the commit for TEXT: MIRRTEXT on → true glyph mirror (full reflection, same + /// as plain geometry); MIRRTEXT off → right-reading at the mirror-symmetric + /// position (reflect the centre, translate) instead of hugging the axis. + pub fn mirror_preview_parts( + &self, + handles: &[Handle], + ) -> (Vec, Vec<(WireModel, glam::DVec3)>) { + let mut plain: Vec = Vec::new(); + let mut texts: Vec<(WireModel, glam::DVec3)> = Vec::new(); + for h in handles { + let Some(e) = self.document.entities().find(|e| e.common().handle == *h) else { + continue; + }; + if matches!(e, EntityType::Text(_)) { + let wires = self.tessellate_one(e); + let mut lo = [f64::INFINITY; 2]; + let mut hi = [f64::NEG_INFINITY; 2]; + for w in &wires { + for (i, p) in w.points.iter().enumerate() { + if !p[0].is_finite() || !p[1].is_finite() { + continue; + } + // Reconstruct f64 world from the double-single pair (the + // low residual may be absent on some wires). + let l = w.points_low.get(i).copied().unwrap_or([0.0; 3]); + let (x, y) = (p[0] as f64 + l[0] as f64, p[1] as f64 + l[1] as f64); + lo[0] = lo[0].min(x); + lo[1] = lo[1].min(y); + hi[0] = hi[0].max(x); + hi[1] = hi[1].max(y); + } + } + let center = if lo[0] <= hi[0] { + glam::DVec3::new((lo[0] + hi[0]) * 0.5, (lo[1] + hi[1]) * 0.5, 0.0) + } else { + glam::DVec3::ZERO + }; + for w in wires { + texts.push((w, center)); + } + } else { + plain.extend(self.tessellate_one(e)); + } + } + (plain, texts) + } + /// Boundary outline wire for a hatch, reconstructed from its cached /// `HatchModel` (offsets from `world_origin`). Used only for edit previews — /// the normal render shows the fill, not this outline. diff --git a/src/scene/text/lff.rs b/src/scene/text/lff.rs index f10c0a520..e4a31f152 100644 --- a/src/scene/text/lff.rs +++ b/src/scene/text/lff.rs @@ -420,18 +420,22 @@ pub fn tessellate_text_run( emit_glyph(out, &g.strokes, *cursor_x); emit_fill(fill_tris, &g.fill_tris, *cursor_x); } - *cursor_x += run.advance * wf; + // Advance the pen in unscaled glyph units — the `xform` scales the + // pen position by `wf`, so multiplying the advance here too would + // apply the width factor twice (and reverse a backward run's word + // order onto the wrong side). Matches `text_local_bounds`. + *cursor_x += run.advance; } else { for ch in seg.chars() { match face.glyph(ch) { Some(glyph) => { emit_glyph(out, &glyph.strokes, *cursor_x); emit_fill(fill_tris, &glyph.fill_tris, *cursor_x); - *cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf; + *cursor_x += glyph.advance + face.letter_spacing() * tracking; } None => { warn_missing_glyph(font_name, ch); - *cursor_x += (6.0 + face.letter_spacing() * tracking) * wf; + *cursor_x += 6.0 + face.letter_spacing() * tracking; } } } @@ -456,17 +460,17 @@ pub fn tessellate_text_run( Some(glyph) => { emit_glyph(&mut out, &glyph.strokes, cursor_x); emit_fill(&mut fill_tris, &glyph.fill_tris, cursor_x); - cursor_x += (glyph.advance + face.letter_spacing() * tracking) * wf; + cursor_x += glyph.advance + face.letter_spacing() * tracking; } None => { warn_missing_glyph(font_name, *c); - cursor_x += (6.0 + face.letter_spacing() * tracking) * wf; + cursor_x += 6.0 + face.letter_spacing() * tracking; } } } } Tok::Space => cursor_x += face.word_spacing(), - Tok::Missing => cursor_x += (6.0 + face.letter_spacing() * tracking) * wf, + Tok::Missing => cursor_x += 6.0 + face.letter_spacing() * tracking, Tok::Deco(deco, op) => { let (slot, y) = match deco { Deco::Under => (&mut underline, UNDER_Y),