From 67fe3637753fa883f7c937f95a4e4037a7c24df7 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Fri, 10 Jul 2026 10:39:44 +0300 Subject: [PATCH] fix(text): grip-drag no longer hides text (#316) Dragging text by a grip hid it until release. The dragged entity is hidden from the base wire set and shown as a preview overlay, but its SDF glyph quads (text_verts) were never uploaded for the preview, so the text vanished mid-drag. Add a per-frame preview_text GPU buffer (sibling to preview_wires). A rigid whole-entity move of pure text (TEXT/MTEXT, Square insertion grip) slides the drag-start glyphs by the drag delta instead of re-shaping the run every move; reshape grips (MTEXT width), dimensions and wire entities re-tessellate and feed the same buffer. Also fold preview_text into render_signature: a pure-text slide leaves preview_wires empty, so without it the scene-render cache froze the dragged text at its first frame (grip moved but text stayed, then jumped to place on release). Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 11 +++++++ src/app/update/command.rs | 2 ++ src/app/update/viewport.rs | 48 +++++++++++++++++++++++++++--- src/scene/entity.rs | 1 + src/scene/mod.rs | 5 ++++ src/scene/pipeline/mod.rs | 54 +++++++++++++++++++++++++++++++++- src/scene/pipeline/text_gpu.rs | 23 +++++++++++++++ src/scene/preview.rs | 7 +++++ src/scene/view/render.rs | 30 +++++++++++++++++++ 9 files changed, 176 insertions(+), 5 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 2acdb1729..8b76b545d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -377,6 +377,15 @@ pub(super) struct OpenCADStudio { /// the document live (so grips / properties track), so cancel reverts from /// this backup. Dropped (kept) on a normal commit. grip_original: Option, + /// Drag-start snapshot of the dragged entity's SDF glyph quads. A whole- + /// entity text move slides these each frame (translating the already-shaped + /// glyphs) instead of re-tessellating the run every cursor move (issue #316). + grip_text_verts: Vec, + /// True when the current grip drag is a rigid whole-entity move of pure text + /// (TEXT / MTEXT, no wire geometry, insertion grip) — the case where the + /// glyphs can just be slid. A reshape grip (e.g. an MTEXT width handle) or an + /// entity with wires re-tessellates instead so the preview stays exact. + grip_text_slide: bool, /// Open Quick Select panel state. `None` = panel closed. Filters are /// applied via `Message::QSelectApply`; the panel is dismissed on /// Apply / Cancel / Esc / outside-click. @@ -2101,6 +2110,8 @@ impl OpenCADStudio { grip_preview_handle: None, hover_dwell: None, grip_original: None, + grip_text_verts: Vec::new(), + grip_text_slide: false, qselect: None, show_ucs_icon: true, ucs_icon_at_origin: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 0e910346c..9da6c422f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -493,6 +493,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // once, and drop the preview. if let Some(h) = self.grip_preview_handle.take() { let i = self.active_tab; + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; if let Some(orig) = self.grip_original.take() { if let Some(e) = self.tabs[i].scene.document.get_entity_mut(h) { *e = orig; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index c13518413..138016be8 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -721,6 +721,25 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // block cache so the hide doesn't re-tessellate blocks. self.tabs[i].scene.bump_geometry_no_blocks(); self.grip_preview_handle = Some(grip.handle); + // Snapshot the entity's glyph quads once so each move can + // slide the already-shaped text rather than re-shaping it + // (issue #316). The fast slide path only runs for a rigid + // whole-entity move of pure text: no wire geometry (so a + // dimension re-tessellates) and a Square insertion grip (so + // an MTEXT width handle, a Triangle, still re-tessellates so + // the re-wrap is exact). + let snap = self.tabs[i].scene.wire_models_for(&[grip.handle]); + self.grip_text_verts = + snap.iter().flat_map(|w| w.text_verts.iter().copied()).collect(); + let square_grip = self.tabs[i] + .selected_grips + .iter() + .find(|g| g.id == grip.grip_id) + .map(|g| g.shape == crate::scene::model::object::GripShape::Square) + .unwrap_or(false); + self.grip_text_slide = !self.grip_text_verts.is_empty() + && snap.iter().all(|w| w.points.is_empty()) + && square_grip; } // The edited entity is hidden, so it's already absent from @@ -778,10 +797,29 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { .apply_grip(grip.handle, grip.grip_id, apply); self.tabs[i].dirty = true; self.tabs[i].active_grip.as_mut().unwrap().last_world = snapped; - // Overlay the moved entity (hidden from the base) — no base - // re-tessellation, just a one-entity preview tessellation. - let preview = self.tabs[i].scene.wire_models_for(&[grip.handle]); - self.tabs[i].scene.set_preview_wires(preview); + // Overlay the moved entity (hidden from the base). Pure text + // moved as a whole slides its drag-start glyphs — no re-shaping + // and no wire re-tess. Anything else (wire geometry, or a point + // grip that reshapes text) is re-tessellated for an exact + // preview; either way the glyphs ride the preview-text buffer + // so dragged text never vanishes mid-drag (issue #316). + if self.grip_text_slide { + let d = snapped - grip.origin_world; + let slid = crate::scene::pipeline::text_gpu::translate_verts( + &self.grip_text_verts, + [d.x, d.y, d.z], + ); + self.tabs[i].scene.set_preview_text(slid); + self.tabs[i].scene.set_preview_wires(Vec::new()); + } else { + 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(); self.refresh_properties(); return Task::none(); @@ -1729,6 +1767,8 @@ pub(super) fn on_tick(&mut self, t: Instant) -> Task { // re-tessellate the base once, dropping the overlay preview. if let Some(h) = self.grip_preview_handle.take() { self.grip_original = None; + self.grip_text_verts = Vec::new(); + self.grip_text_slide = false; self.tabs[i].scene.hidden.remove(&h); self.tabs[i].scene.clear_preview_wire(); // Only the dragged entity changed — re-tessellate just it. diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 2d8ec6b68..ef912aae6 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1649,6 +1649,7 @@ impl Scene { self.document = CadDocument::new(); self.selected = HashSet::default(); self.preview_wires = vec![]; + self.preview_text = vec![]; self.current_layout = "Model".to_string(); self.hatches = HashMap::default(); self.meshes = HashMap::default(); diff --git a/src/scene/mod.rs b/src/scene/mod.rs index df0ba47af..50a234625 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -752,6 +752,10 @@ pub struct Scene { pub selection_filter: HashSet, /// In-progress preview wires while a command is active (rubber-band + object ghosts). pub preview_wires: Vec, + /// In-progress preview SDF glyph quads (grip drag / command preview). Rides + /// a per-frame GPU buffer separate from the epoch-cached base text so the + /// dragged text stays visible while it's hidden from the base set (#316). + pub preview_text: Vec, /// Committed-segment wire drawn during multi-point commands (normal colour). pub interim_wire: Option, pub camera_generation: u64, @@ -995,6 +999,7 @@ impl Scene { transparency_display: true, selection_filter: HashSet::default(), preview_wires: vec![], + preview_text: vec![], interim_wire: None, camera_generation: 0, geometry_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index c7f49ae0e..f3ab2a67c 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -90,6 +90,14 @@ pub struct Pipeline { /// analogue of the selected-wire xray overlay). Rebuilt on selection change. text_highlight_vbuf: Option, text_highlight_vcount: u32, + /// Live grip-drag / command-preview SDF glyph quads. The base text buffer + /// only re-uploads on a geometry-epoch change, but a grip drag hides the + /// dragged entity from the base wire set and shows it as a per-frame + /// overlay — so its glyphs ride here, re-uploaded every frame like + /// `gpu_preview_wires`, or the dragged text vanishes until release re-tesses + /// it back into the base set (issue #316). + text_preview_vbuf: Option, + text_preview_vcount: u32, /// Last requested render size (the full viewport rect, in pixels). The /// geometry passes render at this size; the blit UV is scaled by /// `depth_texture_size / alloc_size` so it samples only the filled region. @@ -1161,6 +1169,8 @@ impl Pipeline { text_vcount: 0, text_highlight_vbuf: None, text_highlight_vcount: 0, + text_preview_vbuf: None, + text_preview_vcount: 0, mesh_pipeline, mesh_transparent_pipeline, mesh_highlight_pipeline, @@ -1383,6 +1393,39 @@ impl Pipeline { }; } + /// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded + /// every frame they're present, kept separate from the epoch-cached base + /// text buffer so a drag never re-uploads the (potentially huge) resident + /// text set — and so the dragged entity's glyphs (hidden from the base set + /// while the drag is live) still reach the GPU each frame (issue #316). + pub fn upload_preview_text( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + verts: &[text_gpu::TextVertex], + ) { + if verts.is_empty() { + self.text_preview_vbuf = None; + self.text_preview_vcount = 0; + return; + } + // Ensure the glyph atlas exists / is current even when the base text + // pass didn't run this frame (e.g. the dragged text is the only text). + if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() { + if self.text_atlas_gpu.is_none() || atlas.is_dirty() { + self.text_atlas_gpu = Some(text_gpu::TextAtlasGpu::upload( + device, + queue, + &atlas, + &self.text_atlas_bgl, + )); + atlas.clear_dirty(); + } + } + self.text_preview_vbuf = text_gpu::upload_vertices(device, verts); + self.text_preview_vcount = verts.len() as u32; + } + /// Recompute pixel scissor rects for viewport-clipped wires from the current view_proj. /// Called every frame from prepare() because scissor pixels shift with pan/zoom. pub fn compute_wire_scissors(&mut self, view_rot: glam::Mat4, eye: glam::DVec3, clip_w: u32, clip_h: u32) { @@ -2143,7 +2186,9 @@ impl Pipeline { if let Some(atlas) = &self.text_atlas_gpu { let have_base = self.text_vbuf.is_some() && self.text_vcount > 0; let have_hl = self.text_highlight_vbuf.is_some() && self.text_highlight_vcount > 0; - if have_base || have_hl { + let have_preview = + self.text_preview_vbuf.is_some() && self.text_preview_vcount > 0; + if have_base || have_hl || have_preview { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("text.render_pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { @@ -2182,6 +2227,13 @@ impl Pipeline { pass.draw(0..self.text_highlight_vcount, 0..1); } } + // Grip-drag / command-preview glyphs, drawn over the base text. + if let Some(pbuf) = &self.text_preview_vbuf { + if self.text_preview_vcount > 0 { + pass.set_vertex_buffer(0, pbuf.slice(..)); + pass.draw(0..self.text_preview_vcount, 0..1); + } + } } } diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 7cc996ac1..bab6b1e09 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -120,6 +120,29 @@ pub fn push_glyph_vertices( } } +/// Slide every glyph vertex by a world-space delta, re-splitting the +/// double-single position. Lets a grip drag move already-shaped text by +/// translating the drag-start glyphs each frame instead of re-tessellating +/// (re-shaping) the run on every cursor move (issue #316). +pub fn translate_verts(verts: &[TextVertex], delta: [f64; 3]) -> Vec { + verts + .iter() + .map(|v| { + let wx = v.pos[0] as f64 + v.pos_low[0] as f64 + delta[0]; + let wy = v.pos[1] as f64 + v.pos_low[1] as f64 + delta[1]; + let wz = v.pos[2] as f64 + v.pos_low[2] as f64 + delta[2]; + let (xh, xl) = split_ds(wx); + let (yh, yl) = split_ds(wy); + let (zh, zl) = split_ds(wz); + TextVertex { + pos: [xh, yh, zh], + pos_low: [xl, yl, zl], + ..*v + } + }) + .collect() +} + // ── Atlas texture ─────────────────────────────────────────────────────────── /// The shared glyph atlas on the GPU: a single-channel (R8) SDF texture plus diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 4c2200def..be2fb9f2c 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -14,11 +14,18 @@ impl Scene { self.preview_wires = wires; } + pub fn set_preview_text(&mut self, verts: Vec) { + // Overlay glyphs — same reasoning as `set_preview_wires`: no geometry + // bump. Uploaded to a dedicated per-frame text buffer in `prepare`. + self.preview_text = verts; + } + pub fn clear_preview_wire(&mut self) { // No geometry bump — see `set_preview_wires`. Dropping the overlay // flips the wire content id back to the base tessellation id, which // re-uploads the base wires (without the preview) on the next frame. self.preview_wires = vec![]; + self.preview_text = vec![]; self.interim_wire = None; } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 1db562c6f..2e95142c5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -41,6 +41,11 @@ pub struct ViewportData { pub(in crate::scene) face3d_wires: Arc>, /// SDF text-quad vertices (Phase 2b). Empty unless `OCS_TEXT_SDF` is set. pub(in crate::scene) text_verts: Arc>, + /// Live grip-drag / command-preview glyph quads. Kept out of the epoch-cached + /// `text_verts` and uploaded to a per-frame buffer, so text dragged by a grip + /// stays visible even though it's hidden from the base text set (issue #316). + pub(in crate::scene) preview_text_verts: + Arc>, /// Per-entity normalized draw-order depth (handle.value() → (0,1)), used /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no /// depth field (84 construction sites); the bias is looked up by handle @@ -380,6 +385,7 @@ impl shader::Primitive for Primitive { // refreshed every frame it's present, so a drag never re-uploads // the resident base wire buffer. inner.upload_preview_wires(device, &vp.preview_wires[..], &vp.draw_depths); + inner.upload_preview_text(device, queue, &vp.preview_text_verts[..]); // Cull / scissor / LOD project AABBs relative-to-eye (matching the // GPU's RTE path) so the math stays precise at UTM-scale coords. let view_rot = vp.uniforms.view_rot; @@ -524,6 +530,20 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { p[2].to_bits().hash(&mut h); } } + // Grip-drag / command-preview glyph quads (issue #316). A pure-text slide + // leaves `preview_wires` empty and moves ONLY these, so a signature that + // ignored them would let the scene-render cache freeze the dragged text at + // its first frame (re-blitting a stale texture) until release. Small — one + // dragged entity — so hashing every vertex is cheap. Hash the high AND low + // halves of the double-single position: a sub-unit slide at UTM scale shifts + // only the low residual, so hashing the high f32 alone would miss it. + vp.preview_text_verts.len().hash(&mut h); + for v in vp.preview_text_verts.iter() { + v.pos[0].to_bits().hash(&mut h); + v.pos[1].to_bits().hash(&mut h); + v.pos_low[0].to_bits().hash(&mut h); + v.pos_low[1].to_bits().hash(&mut h); + } h.finish() } @@ -1106,11 +1126,21 @@ 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()) + }; Some(ViewportData { wires: all_wires, preview_wires, face3d_wires, text_verts, + preview_text_verts, draw_depths: self.draw_depth_map(), hatches, wipeout_hatches,