From 24f8e124d52c62ca8a658c6e06e1259dcab310e8 Mon Sep 17 00:00:00 2001 From: Chris Yeh Date: Mon, 21 Sep 2026 21:01:05 +0800 Subject: [PATCH] fix(text): re-lay-out cached glyph quads after the SDF atlas grows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Glyph quads bake the atlas UV of their tile. `grow_height` rescales every cached entry's V and bumps the atlas generation, and the tessellation memo folds that generation in — but the caches that hold finished wires do not: the resident wire set is keyed by geometry epoch, block definitions by block epoch, and a full build can grow the atlas part-way through (a CJK sheet set bakes thousands of tiles on first sight). Text tessellated before the growth then samples the wrong tiles for as long as those caches live, which on a big drawing is forever: whole tables of scrambled glyphs, and the block contents stayed scrambled even after a REGEN. - `resident_wires_for` rebuilds (dropping the block definition cache) until the atlas generation is stable across the pass. - `Scene::update` compares the live generation with the one the scene's caches were built against and bumps geometry once when it moved, so a later growth (opening a layout, editing text) re-lays-out everything. Co-Authored-By: Claude Opus 5 (1M context) --- src/scene/camera_ops.rs | 13 ++++++++++++- src/scene/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index a1db36b48..3ee2e7ce1 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1566,5 +1566,16 @@ impl Scene { self.camera_generation += 1; } - pub fn update(&mut self, _dt: Duration) {} + pub fn update(&mut self, _dt: Duration) { + // The glyph atlas grew or re-baked since this scene last built its + // caches (a CJK sheet set bakes thousands of tiles on first sight): + // every quad laid out before that samples the wrong tile — scrambled + // text in the affected blocks / viewports. One structural bump + // re-lays-out all text against the settled atlas. + let atlas_gen = crate::scene::text::sdf_atlas::generation(); + if self.last_atlas_generation.get() != atlas_gen { + self.last_atlas_generation.set(atlas_gen); + self.bump_geometry(); + } + } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e2b373127..4fc66e527 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2348,6 +2348,11 @@ pub struct Scene { /// (re)built, otherwise the held value. `build_primitive` reads it right /// after the call to gate GPU wire re-upload. 0 = none yet. pub(crate) last_model_wire_gen: std::cell::Cell, + /// SDF glyph-atlas generation this scene's caches were built against. + /// When the atlas grows or re-bakes, every cached glyph quad (resident + /// sets, block caches, projected viewport copies) addresses the wrong + /// tile; `update` compares against the live generation and rebuilds. + pub(crate) last_atlas_generation: std::cell::Cell, /// Interaction-LOD state: `camera_generation` seen on the previous frame and /// the wall time it last changed. Used to detect "the view is actively being /// panned / zoomed / orbited" so the expensive per-pixel hatch pass can be @@ -2638,6 +2643,7 @@ impl Scene { last_tess_ms: std::cell::Cell::new(0.0), last_tess_wires: std::cell::Cell::new(0), last_model_wire_gen: std::cell::Cell::new(0), + last_atlas_generation: std::cell::Cell::new(crate::scene::text::sdf_atlas::generation()), nav_last_gen: std::cell::Cell::new(0), nav_changed_at: std::cell::Cell::new(None), nav_perf_pending: std::cell::Cell::new(None), @@ -6105,6 +6111,7 @@ impl Scene { // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); + let mut atlas_gen = crate::scene::text::sdf_atlas::generation(); let mut wires = self.wires_for_block_culled( block, None, @@ -6115,6 +6122,34 @@ impl Scene { all_visible, style_viewport, ); + // Baking a drawing's glyphs for the first time can grow the SDF atlas + // part-way through this pass (a CJK sheet set bakes thousands of + // tiles). Text tessellated before the growth carries UVs that now + // address the wrong tiles — visible as scrambled glyphs — and this + // set is cached by geometry epoch, so nothing would ever re-lay it + // out. The memo guard already folds the generation in, so rebuilding + // once the atlas has settled re-tessellates only the stale text. + for _ in 0..3 { + let now = crate::scene::text::sdf_atlas::generation(); + if now == atlas_gen { + break; + } + atlas_gen = now; + // Block definitions tessellated earlier in this pass hold the + // stale quads too, and their cache is keyed by block epoch (which + // has not moved), so drop them or the rebuild would reuse them. + self.block_defn_cache.borrow_mut().clear(); + wires = self.wires_for_block_culled( + block, + None, + None, + frozen_layers, + anno_scale_override, + annotation_scale_handle, + all_visible, + style_viewport, + ); + } let perf = crate::perf::enabled(); let t_post = perf.then(iced::time::Instant::now); // Synthesized nonprint markers (geo-location daisy) live in model space