Fix filled text zoom freezes with cached glyph meshes (#651)

Squash of PR #651 by @PedroGuille.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
PedroGuille 2026-10-11 21:42:29 -03:00 committed by GitHub
parent 90cd443000
commit 2404719c3b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 263 additions and 10 deletions

39
Cargo.lock generated
View File

@ -692,6 +692,7 @@ dependencies = [
"cadcraft-doc", "cadcraft-doc",
"cadcraft-fonts", "cadcraft-fonts",
"cadcraft-geom", "cadcraft-geom",
"lyon_tessellation",
"serde_json", "serde_json",
"tiny-skia", "tiny-skia",
] ]
@ -1399,6 +1400,12 @@ dependencies = [
"zlib-rs", "zlib-rs",
] ]
[[package]]
name = "float_next_after"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8"
[[package]] [[package]]
name = "foldhash" name = "foldhash"
version = "0.2.0" version = "0.2.0"
@ -2143,6 +2150,38 @@ version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "lyon_geom"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4336502e29e32af93cf2dad2214ed6003c17ceb5bd499df77b1de663b9042b92"
dependencies = [
"arrayvec",
"euclid",
"num-traits",
]
[[package]]
name = "lyon_path"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e"
dependencies = [
"lyon_geom",
"num-traits",
]
[[package]]
name = "lyon_tessellation"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b8dcf906637ecef61b3c0740c7a4e7f27caeb31257cfac0cc579ce15be6005"
dependencies = [
"float_next_after",
"lyon_path",
"num-traits",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"

View File

@ -34,6 +34,7 @@ rayon = "1"
# rendering / formats # rendering / formats
tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] } tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] }
lyon_tessellation = "1.0.22"
skrifa = "0.47" skrifa = "0.47"
# Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses) # Text shaping (OpenType GSUB/GPOS, HarfBuzz port on read-fonts; the version egui's epaint uses)
# and the Unicode Bidirectional Algorithm for right-to-left drawing text. # and the Unicode Bidirectional Algorithm for right-to-left drawing text.

View File

@ -580,7 +580,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
/// font's cap height. /// font's cap height.
#[derive(Default)] #[derive(Default)]
pub(crate) struct GlyphOutline { pub(crate) struct GlyphOutline {
pub(crate) contours: Vec<Vec<Vec2>>, pub(crate) contours: Arc<[Vec<Vec2>]>,
pub(crate) advance: f64, pub(crate) advance: f64,
pub(crate) cap: f64, pub(crate) cap: f64,
/// The font maps the character (false: the missing-glyph shape). /// The font maps the character (false: the missing-glyph shape).
@ -691,6 +691,12 @@ pub(crate) fn glyph_by_id(face: &Face, gid: u32) -> Arc<GlyphOutline> {
outline(face, GlyphKey::Id(gid)) outline(face, GlyphKey::Id(gid))
} }
/// Closed, flattened contours of a glyph at 1000 font units per em. Shared with the outline
/// cache so renderers can tessellate once before applying each occurrence's affine placement.
pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
glyph_by_id(face, u32::from(gid)).contours.clone()
}
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> { fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k); let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) { if let Some(g) = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner).get(&key) {
@ -709,7 +715,7 @@ fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
} }
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5)); let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32()); let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32());
let g = Arc::new(GlyphOutline { contours: pen.contours, advance, cap, mapped, gid, face: Some(face.clone()) }); let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner); let mut cache = glyph_cache().lock().unwrap_or_else(PoisonError::into_inner);
if cache.len() > MAX_CACHED_GLYPHS { if cache.len() > MAX_CACHED_GLYPHS {
cache.clear(); cache.clear();

View File

@ -14,6 +14,7 @@ cadcraft-color = { workspace = true }
cadcraft-doc = { workspace = true } cadcraft-doc = { workspace = true }
cadcraft-fonts = { workspace = true } cadcraft-fonts = { workspace = true }
tiny-skia = { workspace = true } tiny-skia = { workspace = true }
lyon_tessellation = { workspace = true }
[dev-dependencies] [dev-dependencies]
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@ -19,6 +19,7 @@ pub mod pen;
mod plotstyle; mod plotstyle;
mod point; mod point;
pub mod raster; pub mod raster;
mod text;
mod tolerance; mod tolerance;
pub mod units; pub mod units;
mod wide; mod wide;
@ -172,7 +173,8 @@ impl DisplayList {
pub struct Options { pub struct Options {
/// Chord tolerance in world units (about half a pixel at the current zoom). /// Chord tolerance in world units (about half a pixel at the current zoom).
pub tolerance: f64, pub tolerance: f64,
/// Dashes shorter than this (world units) are drawn as continuous lines. /// Two pixels in world units: shorter dashes are continuous and filled font text uses
/// screen-size detail. Zero keeps full detail (plots and image/vector exports).
pub min_dash: f64, pub min_dash: f64,
/// Draw text (off for very coarse previews). /// Draw text (off for very coarse previews).
pub text: bool, pub text: bool,
@ -289,6 +291,9 @@ impl Builder<'_> {
self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 }); self.list.prims.push(DPrim { handle: ctx.top, color: color.rgb, aci7: color.aci7, lw, kind: Kind::Polyline, start, len: pts.len() as u32 });
} }
fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) { fn tris(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2]) {
self.tris_at(ctx, color, tris, ctx.xf);
}
fn tris_at(&mut self, ctx: &Ctx, color: Ink, tris: &[Vec2], xf: Mat3) {
if tris.len() < 3 { if tris.len() < 3 {
return; return;
} }
@ -296,7 +301,7 @@ impl Builder<'_> {
let (color, _) = self.styled(color, 0.0); let (color, _) = self.styled(color, 0.0);
let start = self.list.tris.len() as u32; let start = self.list.tris.len() as u32;
for p in tris { for p in tris {
let q = ctx.xf.apply(*p); let q = xf.apply(*p);
self.list.bounds.add(q); self.list.bounds.add(q);
self.list.tris.push(q); self.list.tris.push(q);
} }
@ -312,16 +317,26 @@ impl Builder<'_> {
} }
let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0; let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0;
// Plots note which filled outlines are font glyphs (see `DisplayList::texts`). // Plots note which filled outlines are font glyphs (see `DisplayList::texts`).
let mut text = if self.plotting && fill { sh.text.iter().peekable() } else { [].iter().peekable() }; let mut text = sh.text.iter().peekable();
let pixel = if self.plotting { 0.0 } else { self.opts.min_dash * 0.5 };
for (i, g) in sh.glyphs.iter().enumerate() { for (i, g) in sh.glyphs.iter().enumerate() {
if fill { if fill {
let tris = fill::triangulate_evenodd(g);
let at = self.list.prims.len();
self.tris(ctx, color, &tris);
while text.next_if(|t| t.outline < i).is_some() {} while text.next_if(|t| t.outline < i).is_some() {}
if let Some(t) = text.next_if(|t| t.outline == i) let glyph = text.next_if(|t| t.outline == i);
let at = self.list.prims.len();
let len = if let Some(t) = glyph {
let xf = crate::text::placement(t, ctx.xf);
let mesh = crate::text::mesh(t, xf, pixel);
self.tris_at(ctx, color, &mesh.triangles, xf);
mesh.triangles.len()
} else {
let tris = fill::triangulate_evenodd(g);
self.tris(ctx, color, &tris);
tris.len()
};
if let Some(t) = glyph.filter(|_| self.plotting)
&& self.list.prims.len() > at && self.list.prims.len() > at
&& let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(tris.len())) && let (Ok(prim), Ok(verts)) = (u32::try_from(at), u32::try_from(len))
{ {
let xf = &ctx.xf; let xf = &ctx.xf;
let glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() }; let glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() };

191
crates/render/src/text.rs Normal file
View File

@ -0,0 +1,191 @@
//! Filled font glyphs: bounded font-unit meshes shared across placements and zoom rebuilds.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use cadcraft_fonts::{TextGlyph, ttf};
use cadcraft_geom::{Bounds2, Mat3, Vec2};
use lyon_tessellation::geometry_builder::{BuffersBuilder, VertexBuffers};
use lyon_tessellation::math::point;
use lyon_tessellation::path::Path;
use lyon_tessellation::{FillOptions, FillTessellator, FillVertex};
const MAX_POINTS: usize = 16_384;
const MAX_ENTRIES: usize = 8192;
const MAX_CACHE_BYTES: usize = 64 << 20;
const MAX_SIMPLIFY_STEPS: usize = 1_000_000;
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum Detail {
Full,
/// A power-of-two tolerance in font units, rounded toward finer detail.
Reduced(i16),
/// An em occupies at most two pixels in every direction: show the ink's extent.
Box,
}
type Key = (usize, usize, u32, u16, Detail);
pub(super) struct Mesh {
contours: Arc<[Vec<Vec2>]>,
pub triangles: Vec<Vec2>,
}
struct Entry {
// Keep the bytes alive: registering a replacement font must never reuse this key's address.
_face: ttf::Face,
mesh: Arc<Mesh>,
}
#[derive(Default)]
struct Cache {
entries: HashMap<Key, Entry>,
bytes: usize,
}
fn cache() -> &'static Mutex<Cache> {
static CACHE: OnceLock<Mutex<Cache>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(Cache::default()))
}
/// World-space placement of font units, including the block/viewport transform.
pub(super) fn placement(g: &TextGlyph, xf: Mat3) -> Mat3 {
xf.then_before(Mat3 { a: g.x.x / 1000.0, b: g.x.y / 1000.0, c: g.y.x / 1000.0, d: g.y.y / 1000.0, e: g.origin.x, f: g.origin.y })
}
fn detail(xf: Mat3, pixel: f64) -> Detail {
// The Frobenius norm bounds the largest stretch, including shear, mirroring and unequal
// block scales. A font-unit error below pixel / stretch is below a pixel in every direction.
let stretch = xf.a.hypot(xf.b).hypot(xf.c.hypot(xf.d));
if !(pixel.is_finite() && pixel > 0.0 && stretch.is_finite() && stretch > 0.0) {
return Detail::Full;
}
if stretch * 1000.0 <= pixel * 2.0 {
return Detail::Box;
}
let tolerance = pixel * 0.25 / stretch;
if !tolerance.is_finite() || tolerance < 0.0625 { Detail::Full } else { Detail::Reduced(tolerance.log2().floor().clamp(-4.0, 16.0) as i16) }
}
pub(super) fn mesh(g: &TextGlyph, xf: Mat3, pixel: f64) -> Arc<Mesh> {
let detail = detail(xf, pixel);
let key = (g.face.bytes.as_ptr() as usize, g.face.bytes.len(), g.face.index, g.gid, detail);
if let Some(entry) = cache().lock().unwrap_or_else(PoisonError::into_inner).entries.get(&key) {
return entry.mesh.clone();
}
let contours = ttf::glyph_contours(&g.face, g.gid);
let points = contours.iter().map(Vec::len).try_fold(0usize, usize::checked_add);
// Font data is untrusted; don't hand unbounded or invalid paths to the tessellator.
if !points.is_some_and(|n| n <= MAX_POINTS) || contours.iter().flatten().any(|p| !p.is_finite() || p.x.abs().max(p.y.abs()) > 1.0e6) {
return Arc::new(Mesh { contours: Arc::default(), triangles: Vec::new() });
}
let contours = match detail {
Detail::Full => contours,
Detail::Reduced(band) => contours.iter().map(|c| simplify(c, 2f64.powi(i32::from(band)))).collect::<Vec<_>>().into(),
Detail::Box => {
let bb = Bounds2::from_points(contours.iter().flatten().copied());
if bb.is_empty() {
Arc::default()
} else {
vec![vec![bb.min, Vec2::new(bb.max.x, bb.min.y), bb.max, Vec2::new(bb.min.x, bb.max.y), bb.min]].into()
}
}
};
let mesh = Arc::new(Mesh { triangles: triangulate(&contours), contours });
let points = mesh.contours.iter().map(Vec::capacity).sum::<usize>().saturating_add(mesh.triangles.capacity());
let bytes = points
.saturating_mul(std::mem::size_of::<Vec2>())
.saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::<Vec<Vec2>>()))
.saturating_add(std::mem::size_of::<Entry>() + std::mem::size_of::<Key>() + std::mem::size_of::<Mesh>());
if bytes <= MAX_CACHE_BYTES {
let mut c = cache().lock().unwrap_or_else(PoisonError::into_inner);
if c.entries.len() >= MAX_ENTRIES || c.bytes.saturating_add(bytes) > MAX_CACHE_BYTES {
c.entries.clear();
c.bytes = 0;
}
if !c.entries.contains_key(&key) {
c.bytes = c.bytes.saturating_add(bytes);
c.entries.insert(key, Entry { _face: g.face.clone(), mesh: mesh.clone() });
}
}
mesh
}
/// Iterative Douglas-Peucker on a closed contour. Never collapse a hole to a line or recurse
/// on font data; preserve the original if the work budget is exhausted.
fn simplify(contour: &[Vec2], tolerance: f64) -> Vec<Vec2> {
if contour.len() < 5 {
return contour.to_vec();
}
let mut points = contour.to_vec();
if let (Some(&first), Some(&last)) = (points.first(), points.last())
&& first != last
{
points.push(first);
}
let last = points.len().saturating_sub(1);
let mut keep = vec![false; points.len()];
if let Some(k) = keep.first_mut() {
*k = true;
}
if let Some(k) = keep.last_mut() {
*k = true;
}
let mut pending = vec![(0usize, last)];
let mut steps = 0usize;
while let Some((start, end)) = pending.pop() {
let (Some(&a), Some(&b)) = (points.get(start), points.get(end)) else { continue };
let ab = b - a;
let len2 = ab.len2();
let mut furthest = None;
let mut distance = tolerance;
for (i, &p) in points.iter().enumerate().take(end).skip(start + 1) {
steps += 1;
if steps > MAX_SIMPLIFY_STEPS {
return contour.to_vec();
}
let t = if len2 > 0.0 { ((p - a).dot(ab) / len2).clamp(0.0, 1.0) } else { 0.0 };
let d = p.dist(a + ab * t);
if d > distance {
distance = d;
furthest = Some(i);
}
}
if let Some(i) = furthest {
if let Some(k) = keep.get_mut(i) {
*k = true;
}
pending.extend([(start, i), (i, end)]);
}
}
let result: Vec<_> = points.into_iter().zip(keep).filter_map(|(p, keep)| keep.then_some(p)).collect();
if result.len() >= 4 { result } else { contour.to_vec() }
}
/// Monotone tessellation with even-odd holes and intersections. Work in font units so f32
/// precision is independent of drawing coordinates; the placement stays f64. The existing
/// slab filler remains the fallback when tessellation fails (including its u16 vertex limit).
fn triangulate(contours: &[Vec<Vec2>]) -> Vec<Vec2> {
let mut path = Path::builder();
for contour in contours {
let Some(&first) = contour.first().filter(|_| contour.len() >= 3) else { continue };
path.begin(point(first.x as f32, first.y as f32));
for p in contour.iter().skip(1) {
path.line_to(point(p.x as f32, p.y as f32));
}
path.end(true);
}
let mut buffers: VertexBuffers<Vec2, u16> = VertexBuffers::new();
let result = FillTessellator::new().tessellate_path(
&path.build(),
&FillOptions::even_odd(),
&mut BuffersBuilder::new(&mut buffers, |v: FillVertex<'_>| {
let p = v.position();
Vec2::new(f64::from(p.x), f64::from(p.y))
}),
);
if result.is_err() {
return crate::fill::triangulate_evenodd(contours);
}
buffers.indices.into_iter().filter_map(|i| buffers.vertices.get(usize::from(i)).copied()).collect()
}