diff --git a/Cargo.lock b/Cargo.lock index 3a7a4fc..480d016 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -692,6 +692,7 @@ dependencies = [ "cadcraft-doc", "cadcraft-fonts", "cadcraft-geom", + "lyon_tessellation", "serde_json", "tiny-skia", ] @@ -1399,6 +1400,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + [[package]] name = "foldhash" version = "0.2.0" @@ -2143,6 +2150,38 @@ version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "memchr" version = "2.8.3" diff --git a/Cargo.toml b/Cargo.toml index fe72ec0..0daa173 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ rayon = "1" # rendering / formats tiny-skia = { version = "0.11", default-features = false, features = ["std", "simd", "png-format"] } +lyon_tessellation = "1.0.22" skrifa = "0.47" # 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. diff --git a/crates/fonts/src/ttf.rs b/crates/fonts/src/ttf.rs index de322d9..1858084 100644 --- a/crates/fonts/src/ttf.rs +++ b/crates/fonts/src/ttf.rs @@ -580,7 +580,7 @@ pub fn fallback_for(c: char) -> Option { /// font's cap height. #[derive(Default)] pub(crate) struct GlyphOutline { - pub(crate) contours: Vec>, + pub(crate) contours: Arc<[Vec]>, pub(crate) advance: f64, pub(crate) cap: f64, /// 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 { 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]> { + glyph_by_id(face, u32::from(gid)).contours.clone() +} + fn outline(face: &Face, k: GlyphKey) -> Arc { 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) { @@ -709,7 +715,7 @@ fn outline(face: &Face, k: GlyphKey) -> Arc { } 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 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); if cache.len() > MAX_CACHED_GLYPHS { cache.clear(); diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index e7c5c14..01e3633 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -14,6 +14,7 @@ cadcraft-color = { workspace = true } cadcraft-doc = { workspace = true } cadcraft-fonts = { workspace = true } tiny-skia = { workspace = true } +lyon_tessellation = { workspace = true } [dev-dependencies] serde_json = { workspace = true } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 691c1c9..65a964b 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -19,6 +19,7 @@ pub mod pen; mod plotstyle; mod point; pub mod raster; +mod text; mod tolerance; pub mod units; mod wide; @@ -172,7 +173,8 @@ impl DisplayList { pub struct Options { /// Chord tolerance in world units (about half a pixel at the current zoom). 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, /// Draw text (off for very coarse previews). 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 }); } 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 { return; } @@ -296,7 +301,7 @@ impl Builder<'_> { let (color, _) = self.styled(color, 0.0); let start = self.list.tris.len() as u32; for p in tris { - let q = ctx.xf.apply(*p); + let q = xf.apply(*p); self.list.bounds.add(q); self.list.tris.push(q); } @@ -312,16 +317,26 @@ impl Builder<'_> { } let fill = self.opts.fill && ctx.d.header.i64("TEXTFILL", 1) != 0; // 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() { 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() {} - 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 - && 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 glyph = cadcraft_fonts::TextGlyph { origin: xf.apply(t.origin), x: xf.apply_vec(t.x), y: xf.apply_vec(t.y), ..t.clone() }; diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs new file mode 100644 index 0000000..4ddf091 --- /dev/null +++ b/crates/render/src/text.rs @@ -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]>, + pub triangles: Vec, +} + +struct Entry { + // Keep the bytes alive: registering a replacement font must never reuse this key's address. + _face: ttf::Face, + mesh: Arc, +} + +#[derive(Default)] +struct Cache { + entries: HashMap, + bytes: usize, +} + +fn cache() -> &'static Mutex { + static CACHE: OnceLock> = 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 { + 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::>().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::().saturating_add(mesh.triangles.capacity()); + let bytes = points + .saturating_mul(std::mem::size_of::()) + .saturating_add(mesh.contours.len().saturating_mul(std::mem::size_of::>())) + .saturating_add(std::mem::size_of::() + std::mem::size_of::() + std::mem::size_of::()); + 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 { + 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]) -> Vec { + 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 = 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() +}