From f5870b011d5cf9573c8b715ce134fb27d94bed07 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 10:49:13 +0300 Subject: [PATCH] fix(text): show text in command previews (move/copy/etc) (#316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Command previews (MOVE, COPY, ROTATE, SCALE, STRETCH, MIRROR, ARRAY, PASTE, INSERT) build ghost WireModels via WireModel::translated/rotated/ scaled/stretched/mirrored, but those helpers moved only `points`, never `text_verts`, and the ghosts' glyphs were never uploaded — so text vanished (or froze at its source) during the preview, same root cause as the grip-drag case. Transform text_verts alongside points in all five helpers (shared map_text_verts, reconstructing + re-splitting the double-single world position), and gather preview_wires' glyphs into the per-frame preview-text buffer in the render path. This covers every command preview automatically. The grip-drag else branch no longer sets preview_text explicitly (the render gather handles it); the grip-slide fast path keeps its own scene.preview_text channel (empty preview_wires, so no double-draw). MIRROR honours MIRRTEXT: on -> true glyph mirror via mirrored(); off -> readable relocation via translated(). Co-Authored-By: Claude Opus 4.8 --- src/app/update/viewport.rs | 9 ++--- src/scene/model/wire_model.rs | 74 ++++++++++++++++++++++++++++++++++ src/scene/pipeline/text_gpu.rs | 2 +- src/scene/view/render.rs | 24 +++++++---- 4 files changed, 95 insertions(+), 14 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 138016be8..e285d0457 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -812,12 +812,11 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { self.tabs[i].scene.set_preview_text(slid); self.tabs[i].scene.set_preview_wires(Vec::new()); } else { + // Wire geometry, or a reshape grip: re-tessellate. The + // preview WireModels carry the entity's glyphs (gathered + // for the preview-text buffer in the render path), so a + // dimension / MTEXT-width drag keeps its text visible too. let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); - let ptext = preview - .iter() - .flat_map(|w| w.text_verts.iter().copied()) - .collect(); - self.tabs[i].scene.set_preview_text(ptext); self.tabs[i].scene.set_preview_wires(preview); } self.refresh_selected_grips(); diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index c8ae9ddcb..e0b42de93 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -168,6 +168,11 @@ impl WireModel { p[1] += delta.y; p[2] += delta.z; } + if !out.text_verts.is_empty() { + let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64); + out.text_verts = + map_text_verts(&self.text_verts, |x, y, z| (x + dx, y + dy, z + dz)); + } out } @@ -184,6 +189,14 @@ impl WireModel { p[0] = center.x + dx * c - dy * s; p[1] = center.y + dx * s + dy * c; } + if !out.text_verts.is_empty() { + let (cx, cy) = (center.x as f64, center.y as f64); + let (s, c) = (s as f64, c as f64); + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + let (dx, dy) = (x - cx, y - cy); + (cx + dx * c - dy * s, cy + dx * s + dy * c, z) + }); + } out } @@ -198,6 +211,13 @@ impl WireModel { p[1] = center.y + (p[1] - center.y) * factor; p[2] = center.z + (p[2] - center.z) * factor; } + if !out.text_verts.is_empty() { + let (cx, cy, cz) = (center.x as f64, center.y as f64, center.z as f64); + let f = factor as f64; + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + (cx + (x - cx) * f, cy + (y - cy) * f, cz + (z - cz) * f) + }); + } out } @@ -218,6 +238,18 @@ impl WireModel { p[2] += delta.z; } } + if !out.text_verts.is_empty() { + let (mnx, mny) = (win_min.x as f64, win_min.y as f64); + let (mxx, mxy) = (win_max.x as f64, win_max.y as f64); + let (dx, dy, dz) = (delta.x as f64, delta.y as f64, delta.z as f64); + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + if x >= mnx && x <= mxx && y >= mny && y <= mxy { + (x + dx, y + dy, z + dz) + } else { + (x, y, z) + } + }); + } out } @@ -248,6 +280,18 @@ impl WireModel { p[0] = 2.0 * t * ax - p[0]; p[1] = 2.0 * t * ay - p[1]; } + // Glyph quads reflect wholesale (true mirror) — the caller only routes + // text through here for MIRRTEXT-on; MIRRTEXT-off relocates via + // `translated` so glyphs stay readable. + if !out.text_verts.is_empty() { + let (ax, ay, len2) = (ax as f64, ay as f64, len2 as f64); + let (p1x, p1y) = (p1.x as f64, p1.y as f64); + out.text_verts = map_text_verts(&self.text_verts, |x, y, z| { + let (dx, dy) = (x - p1x, y - p1y); + let t = (dx * ax + dy * ay) / len2; + (p1x + 2.0 * t * ax - dx, p1y + 2.0 * t * ay - dy, z) + }); + } out } @@ -266,6 +310,36 @@ impl WireModel { } } +/// Map every glyph vertex's double-single world position through `f`, re- +/// splitting the result. The preview transforms above move `points`, but SDF +/// glyph quads live in `text_verts` (absolute-world double-single) — so a text +/// ghost (MOVE / COPY / ROTATE / SCALE / STRETCH / MIRROR preview) must carry +/// these along or the dragged text renders frozen at its source (issue #316). +fn map_text_verts( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + f: impl Fn(f64, f64, f64) -> (f64, f64, f64), +) -> Vec { + use crate::scene::pipeline::text_gpu::split_ds; + verts + .iter() + .map(|v| { + let (nx, ny, nz) = f( + v.pos[0] as f64 + v.pos_low[0] as f64, + v.pos[1] as f64 + v.pos_low[1] as f64, + v.pos[2] as f64 + v.pos_low[2] as f64, + ); + let (xh, xl) = split_ds(nx); + let (yh, yl) = split_ds(ny); + let (zh, zl) = split_ds(nz); + crate::scene::pipeline::text_gpu::TextVertex { + pos: [xh, yh, zh], + pos_low: [xl, yl, zl], + ..*v + } + }) + .collect() +} + impl Default for WireModel { fn default() -> Self { Self { diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index bab6b1e09..c18510eea 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -70,7 +70,7 @@ impl TextVertex { /// Split an f64 into the double-single (high f32, low residual f32) pair the /// shaders reconstruct relative-to-eye. -fn split_ds(v: f64) -> (f32, f32) { +pub(crate) fn split_ds(v: f64) -> (f32, f32) { let high = v as f32; (high, (v - high as f64) as f32) } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 2e95142c5..5c51d4618 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -1126,14 +1126,22 @@ impl Scene { // draws only the text that belongs to it. Cached on the wire content // id so an unchanged wire set is not re-walked every frame. let text_verts = self.gather_text_verts(&all_wires, wire_content_id); - // Grip-drag / command-preview glyphs live on `self.preview_text` (set by - // the edit handlers), excluded from the epoch-cached base gather above. - // Clone the (tiny — one dragged entity) set per frame so dragged text - // stays on screen without re-uploading the resident base buffer (#316). - let preview_text_verts = if self.preview_text.is_empty() { - Arc::new(Vec::new()) - } else { - Arc::new(self.preview_text.clone()) + // Grip-drag / command-preview glyphs, excluded from the epoch-cached base + // gather above. Two sources, both tiny (one operation's worth) and walked + // per frame: the overlay wires' own glyphs (MOVE / COPY / ROTATE / SCALE / + // STRETCH / MIRROR ghosts carry text_verts) and `self.preview_text` (the + // grip-slide fast path, which emits bare glyphs with empty preview_wires). + // The two never overlap — a slide leaves preview_wires empty — so a plain + // concat is correct, no double-draw (issue #316). + let preview_text_verts = { + let mut pv: Vec = Vec::new(); + for w in preview_wires.iter() { + if !w.text_verts.is_empty() { + pv.extend_from_slice(&w.text_verts); + } + } + pv.extend_from_slice(&self.preview_text); + Arc::new(pv) }; Some(ViewportData { wires: all_wires,