forked from 1b-agent/opencadstudio-upstream
fix(scene): correct MIRROR of TEXT under MIRRTEXT (commit + preview)
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]>
This commit is contained in:
1 parent
a24232842c
commit
79a5ac6fed
7 files changed
+153
-23
No files matched your search
@@ -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));
|
||||
}
|
||||
|
||||
+17
-11
@@ -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 {
|
||||
|
||||
@@ -34,14 +34,26 @@ enum Step {
|
||||
pub struct MirrorCommand {
|
||||
handles: Vec<Handle>,
|
||||
wire_models: Vec<WireModel>,
|
||||
/// 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<Handle>, wire_models: Vec<WireModel>) -> Self {
|
||||
pub fn new(
|
||||
handles: Vec<Handle>,
|
||||
wire_models: Vec<WireModel>,
|
||||
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<WireModel> = 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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+38
-2
@@ -32,13 +32,24 @@ fn capture_text_orient(e: &EntityType) -> Option<TextOrient> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -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<WireModel>, Vec<(WireModel, glam::DVec3)>) {
|
||||
let mut plain: Vec<WireModel> = 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.
|
||||
|
||||
+10
-6
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user