fix(text): re-lay-out cached glyph quads after the SDF atlas grows

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) <noreply@anthropic.com>
This commit is contained in:
Chris YehandClaude Opus 5 committed 2026-09-21 21:01:05 +08:00
1 parent ff4981ffe6
commit 24f8e124d5
2 files changed
+47 -1

No files matched your search

+12 -1
View File
@@ -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();
}
}
}
+35
View File
@@ -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<u64>,
/// 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<u64>,
/// 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