//! CPU-side signed-distance-field (SDF) glyph atlas. //! //! Phase 1 of the text-shader initiative: bake each glyph into a single-channel //! SDF tile and pack the tiles into one growable atlas texture. Later phases //! draw text as per-glyph quads that sample this atlas in an SDF fragment //! shader, instead of tessellating glyph outlines into wire segments. //! //! Both glyph kinds feed one unified field so a single shader renders both: //! * Filled (TrueType): the glyph carries closed contours (`strokes`) plus a //! fill triangulation (`fill_tris`). The field is the **signed** distance to //! the outline — positive inside the fill, negative outside. //! * Stroke (LFF pen fonts): the glyph carries open polylines and no fill. The //! field is a **band** around the strokes: `pen_half - dist_to_polyline`, //! positive inside the nominal pen width. //! //! In both cases the boundary sits at field value 0, mapped to 0.5 in the u8 //! texel so the shader can threshold with `smoothstep(0.5 ± aa, value)`. //! //! Uses only crates already in the tree (no image/SDF crate): the distance //! field is computed by brute-force point-to-segment distance over the glyph's //! own polylines. Tiles are small and baked once per (font, char), then cached. // Phase 1 lands the CPU atlas builder; it is exercised by tests but not yet // wired into the render path — that is Phase 2 (the text quad pipeline). Drop // this allow once `GlyphAtlas` is consumed by the renderer. #![allow(dead_code)] use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; use crate::scene::text::font_face::Face; use crate::scene::text::lff::Glyph; /// Process-wide glyph atlas, shared by the text collector (which bakes glyphs /// while building draw data) and the GPU upload (which reads the texels). /// Mirrors the existing global font-glyph caches (see `ttf_glyph`). pub fn text_atlas() -> &'static Mutex { static ATLAS: OnceLock> = OnceLock::new(); // 2048² (~780 glyphs) covers real text-heavy drawings (many fonts + CJK / // Vietnamese diacritics) without ever growing; `pack()` still grows as a // fallback beyond that. See issue #347. ATLAS.get_or_init(|| Mutex::new(GlyphAtlas::new(2048, 2048))) } /// TEXTFILL system variable — `true` fills TrueType glyphs (default), `false` /// draws them hollow (outline only). Global, like the AutoCAD system variable. static TEXTFILL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); pub fn textfill() -> bool { TEXTFILL.load(std::sync::atomic::Ordering::Relaxed) } /// Set TEXTFILL and drop the cached glyphs so they re-bake filled / hollow. /// Callers must re-tessellate text (`bump_geometry`) so quads pick up the /// re-baked tiles. pub fn set_textfill(on: bool) { TEXTFILL.store(on, std::sync::atomic::Ordering::Relaxed); if let Ok(mut a) = text_atlas().lock() { a.reset(); } } /// Debug: when env `OCS_TEXT_BOX` is set, each SDF text run also emits a /// rectangle outline around its glyph bounds so the text pick box is visible. /// Read once. pub fn text_box_debug() -> bool { static ON: OnceLock = OnceLock::new(); *ON.get_or_init(|| std::env::var_os("OCS_TEXT_BOX").is_some()) } // ── Bake configuration ────────────────────────────────────────────────────── /// Texels per glyph unit. Glyphs live in a 9-unit cap-height space, so a cap /// letter is ~9 * PX_PER_UNIT texels tall. SDF is resolution-independent when /// sampled, so this only sets the baked field's fidelity, not on-screen size. const PX_PER_UNIT: f32 = 6.0; /// Half-range of the distance field, in glyph units. Field values are clamped /// to ±SPREAD_UNITS and linearly mapped to [0, 1] (0 -> 0.5). Also the tile /// margin: the tile extends this far past the ink so the field/AA is not /// clipped at the glyph edge. const SPREAD_UNITS: f32 = 1.5; /// LFF stroke half-width, in glyph units. Stroke fonts have no intrinsic width /// (plotted at the current lineweight); this is the nominal pen the SDF band is /// built around. Scales with text height like the strokes themselves. const PEN_HALF_UNITS: f32 = 0.35; /// Upper bound on a single glyph tile's dimension (texels), a guard against a /// pathologically wide/tall glyph blowing up the atlas. const MAX_TILE_PX: u32 = 512; /// Largest atlas dimension the packer will grow the texture to, kept within the /// common wgpu `max_texture_dimension_2d` limit (8192). The atlas starts at /// 1024² and doubles its height on demand up to this bound. const MAX_ATLAS_PX: u32 = 8192; // ── Public types ───────────────────────────────────────────────────────────── /// Placement of one baked glyph within the atlas. #[derive(Clone, Copy, Debug)] pub struct AtlasEntry { /// Atlas UV of the tile's top-left corner (normalized 0..1). pub uv_min: [f32; 2], /// Atlas UV of the tile's bottom-right corner (normalized 0..1). pub uv_max: [f32; 2], /// Glyph-space (9-unit) rectangle the tile covers — the ink bounding box /// expanded by the SDF spread. The render quad must span exactly this rect /// (in glyph-local coords) so texels line up with world positions. pub plane_min: [f32; 2], pub plane_max: [f32; 2], /// Glyph advance width (9-unit space). pub advance: f32, } /// A single-channel SDF glyph atlas plus a shelf packer. pub struct GlyphAtlas { width: u32, height: u32, /// R8 SDF texels, row-major, row 0 = top. data: Vec, /// Cache keyed by (font family, char, bold). `None` = a whitespace/empty /// glyph with no ink (nothing to draw), cached so we do not re-resolve it. /// Bold bakes the same glyph with a wider pen (a separate atlas entry). entries: HashMap<(String, char, bool), Option>, // Shelf packer cursor. cursor_x: u32, cursor_y: u32, shelf_h: u32, /// UV of a fully-inside (value 255) texel, reserved at the atlas corner. /// A quad whose corners all map here samples a constant "inside" field, so /// the SDF shader fills it solid — used to draw decoration lines /// (underline / overline / strikethrough) through the glyph pipeline. solid_uv: [f32; 2], /// Set when `data` changed since the last upload; the GPU side clears it. dirty: bool, } /// A freshly baked, not-yet-packed glyph tile. struct BakedTile { w: u32, h: u32, data: Vec, plane_min: [f32; 2], plane_max: [f32; 2], advance: f32, } /// Side of the reserved solid (all-255) block at the atlas corner. const SOLID_PX: u32 = 4; impl GlyphAtlas { pub fn new(width: u32, height: u32) -> Self { let mut data = vec![0u8; (width * height) as usize]; // Reserve a small solid block at (0,0) for decoration lines; the shelf // packer starts past it so glyphs never overwrite it. let solid = SOLID_PX.min(width).min(height); for y in 0..solid { for x in 0..solid { data[(y * width + x) as usize] = 255; } } Self { width, height, data, entries: HashMap::new(), cursor_x: solid, cursor_y: 0, shelf_h: solid, // Centre of the solid block, safely inside it under bilinear filtering. solid_uv: [ (solid as f32 * 0.5) / width as f32, (solid as f32 * 0.5) / height as f32, ], dirty: true, } } /// UV of a fully-inside texel; a quad with all corners at this UV renders /// solid. Used for decoration lines. pub fn solid_uv(&self) -> [f32; 2] { self.solid_uv } pub fn width(&self) -> u32 { self.width } pub fn height(&self) -> u32 { self.height } /// Raw R8 texel data (row-major, row 0 = top) for GPU upload. pub fn data(&self) -> &[u8] { &self.data } pub fn is_dirty(&self) -> bool { self.dirty } pub fn clear_dirty(&mut self) { self.dirty = false; } /// Drop every cached glyph and re-init the texture, so glyphs re-bake on /// next use (e.g. after TEXTFILL toggles between fill and outline). pub fn reset(&mut self) { *self = GlyphAtlas::new(self.width, self.height); } /// Look up a glyph's atlas placement, baking + packing it on first use. /// Returns `None` for whitespace/empty glyphs (nothing to draw) and when the /// atlas is full (Phase 1 is single-page; callers should treat `None` as /// "skip / fall back"). pub fn get_or_insert(&mut self, family: &str, ch: char, bold: bool) -> Option { let key = (family.to_string(), ch, bold); if let Some(cached) = self.entries.get(&key) { return *cached; } // Bold stroke glyphs bake with a wider pen band (same shapes, thicker). let pen_half = if bold { PEN_HALF_UNITS * 1.7 } else { PEN_HALF_UNITS }; let face = Face::resolve(family); let entry = face .glyph(ch) .and_then(|g| bake_glyph(&g, pen_half)) .and_then(|tile| self.pack(tile)); self.entries.insert(key, entry); entry } /// Grow the atlas taller (keeping the width, so packed X positions and their /// normalized U stay valid) and rescale every cached entry's V for the new /// height. Returns false once the height hits `MAX_ATLAS_PX` — text-heavy /// drawings would otherwise silently drop glyphs once the single page filled /// (issue #347). Width is unchanged, so the row-major data just grows with /// zeroed rows appended at the bottom. fn grow_height(&mut self) -> bool { if self.height >= MAX_ATLAS_PX { return false; } let old_h = self.height; let new_h = (self.height * 2).min(MAX_ATLAS_PX); self.data.resize((self.width * new_h) as usize, 0); let sy = old_h as f32 / new_h as f32; for entry in self.entries.values_mut().flatten() { entry.uv_min[1] *= sy; entry.uv_max[1] *= sy; } self.solid_uv[1] *= sy; self.height = new_h; self.dirty = true; true } /// Blit a baked tile into the atlas via a simple shelf packer. Grows the /// atlas when a tile doesn't fit; returns `None` only once it can grow no /// further (or the tile is wider than the atlas, which the tile cap prevents). fn pack(&mut self, tile: BakedTile) -> Option { // Wrap to a new shelf if the tile overflows the current row. if self.cursor_x + tile.w > self.width { self.cursor_y += self.shelf_h; self.cursor_x = 0; self.shelf_h = 0; } if self.cursor_x + tile.w > self.width { return None; // tile wider than the atlas (MAX_TILE_PX guards this) } while self.cursor_y + tile.h > self.height { if !self.grow_height() { return None; // atlas full and cannot grow further } } let (ox, oy) = (self.cursor_x, self.cursor_y); for row in 0..tile.h { let dst = ((oy + row) * self.width + ox) as usize; let src = (row * tile.w) as usize; self.data[dst..dst + tile.w as usize] .copy_from_slice(&tile.data[src..src + tile.w as usize]); } self.cursor_x += tile.w; self.shelf_h = self.shelf_h.max(tile.h); self.dirty = true; let (fw, fh) = (self.width as f32, self.height as f32); Some(AtlasEntry { uv_min: [ox as f32 / fw, oy as f32 / fh], uv_max: [(ox + tile.w) as f32 / fw, (oy + tile.h) as f32 / fh], plane_min: tile.plane_min, plane_max: tile.plane_max, advance: tile.advance, }) } } // ── Baking ───────────────────────────────────────────────────────────────── /// Rasterize a glyph into an SDF tile. `None` if the glyph has no ink. fn bake_glyph(g: &Glyph, pen_half: f32) -> Option { // TEXTFILL 0 renders TrueType glyphs hollow: bake them as a stroke field // (outline) instead of a signed fill field. Stroke fonts (no fill tris) are // unaffected. let filled = textfill() && !g.fill_tris.is_empty(); // Ink bounding box over all strokes (and fill triangles, for safety). let (mut min_x, mut min_y) = (f32::INFINITY, f32::INFINITY); let (mut max_x, mut max_y) = (f32::NEG_INFINITY, f32::NEG_INFINITY); let mut acc = |x: f32, y: f32| { min_x = min_x.min(x); min_y = min_y.min(y); max_x = max_x.max(x); max_y = max_y.max(y); }; for s in &g.strokes { for &[x, y] in s { acc(x, y); } } for &[x, y] in &g.fill_tris { acc(x, y); } if !min_x.is_finite() || max_x < min_x || max_y < min_y { return None; // no geometry (whitespace) } // Expand by the spread so the field is not clipped at the ink edge. let plane_min = [min_x - SPREAD_UNITS, min_y - SPREAD_UNITS]; let plane_max = [max_x + SPREAD_UNITS, max_y + SPREAD_UNITS]; let w = (((plane_max[0] - plane_min[0]) * PX_PER_UNIT).ceil() as u32) .clamp(1, MAX_TILE_PX); let h = (((plane_max[1] - plane_min[1]) * PX_PER_UNIT).ceil() as u32) .clamp(1, MAX_TILE_PX); let mut data = vec![0u8; (w * h) as usize]; let inv = 1.0 / PX_PER_UNIT; for py in 0..h { // Row 0 = top of the tile; glyph Y is up, so flip. let gy = plane_max[1] - (py as f32 + 0.5) * inv; for px in 0..w { let gx = plane_min[0] + (px as f32 + 0.5) * inv; let d = min_dist_to_strokes(gx, gy, &g.strokes); let sd = if filled { // Signed: positive inside the fill. if point_in_fill(gx, gy, &g.fill_tris) { d } else { -d } } else { // Band around the pen path (wider for bold). pen_half - d }; let t = (0.5 + sd / (2.0 * SPREAD_UNITS)).clamp(0.0, 1.0); data[(py * w + px) as usize] = (t * 255.0).round() as u8; } } Some(BakedTile { w, h, data, plane_min, plane_max, advance: g.advance, }) } /// Minimum distance from `(x, y)` to any segment of any polyline. Treats a /// single-point polyline as a point. Returns `+inf` if there is no geometry. fn min_dist_to_strokes(x: f32, y: f32, strokes: &[Vec<[f32; 2]>]) -> f32 { let mut best = f32::INFINITY; for s in strokes { if s.len() == 1 { best = best.min(dist(x, y, s[0][0], s[0][1])); continue; } for w in s.windows(2) { best = best.min(dist_point_seg(x, y, w[0], w[1])); } } best } /// True if `(x, y)` lies in any triangle of a flat triangle-vertex list /// (3 vertices per triangle). fn point_in_fill(x: f32, y: f32, tris: &[[f32; 2]]) -> bool { for t in tris.chunks_exact(3) { if point_in_tri(x, y, t[0], t[1], t[2]) { return true; } } false } fn dist(px: f32, py: f32, ax: f32, ay: f32) -> f32 { ((px - ax) * (px - ax) + (py - ay) * (py - ay)).sqrt() } fn dist_point_seg(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 { let (abx, aby) = (b[0] - a[0], b[1] - a[1]); let (apx, apy) = (px - a[0], py - a[1]); let ab2 = abx * abx + aby * aby; let t = if ab2 <= 1e-12 { 0.0 } else { ((apx * abx + apy * aby) / ab2).clamp(0.0, 1.0) }; let (cx, cy) = (a[0] + abx * t, a[1] + aby * t); dist(px, py, cx, cy) } fn point_in_tri(px: f32, py: f32, a: [f32; 2], b: [f32; 2], c: [f32; 2]) -> bool { // Sign of the cross products; inside if all the same sign (allowing edges). let d1 = edge_sign(px, py, a, b); let d2 = edge_sign(px, py, b, c); let d3 = edge_sign(px, py, c, a); let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; !(has_neg && has_pos) } fn edge_sign(px: f32, py: f32, a: [f32; 2], b: [f32; 2]) -> f32 { (px - b[0]) * (a[1] - b[1]) - (a[0] - b[0]) * (py - b[1]) } // ── Tests ───────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; /// Sample the SDF value at a glyph-space point of a baked tile (0..255). fn sample(tile: &BakedTile, gx: f32, gy: f32) -> u8 { let px = (((gx - tile.plane_min[0]) * PX_PER_UNIT) as i32) .clamp(0, tile.w as i32 - 1) as u32; let py = (((tile.plane_max[1] - gy) * PX_PER_UNIT) as i32) .clamp(0, tile.h as i32 - 1) as u32; tile.data[(py * tile.w + px) as usize] } /// A filled 4x4 square (fill_tris + closed contour). fn filled_square() -> Glyph { let sq = [[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0], [0.0, 0.0]]; Glyph { strokes: vec![sq.to_vec()], advance: 4.0, fill_tris: vec![ [0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 0.0], [4.0, 4.0], [0.0, 4.0], ], } } /// A single diagonal stroke, no fill (LFF-style). fn diagonal_stroke() -> Glyph { Glyph { strokes: vec![vec![[0.0, 0.0], [4.0, 4.0]]], advance: 4.0, fill_tris: vec![], } } #[test] fn filled_glyph_inside_is_high_outside_is_low() { let tile = bake_glyph(&filled_square(), PEN_HALF_UNITS).expect("square bakes"); // Deep inside the fill -> well above the 0.5 (128) edge. assert!(sample(&tile, 2.0, 2.0) > 200, "center should read as inside"); // Well outside the fill -> below the edge. assert!( sample(&tile, -1.0, -1.0) < 60, "far corner should read as outside" ); } #[test] fn stroke_glyph_on_line_is_high_off_line_is_low() { let tile = bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).expect("stroke bakes"); // On the diagonal -> inside the pen band. assert!(sample(&tile, 2.0, 2.0) > 128, "on-stroke should be inside band"); // Off the diagonal by more than the pen half-width -> outside. assert!( sample(&tile, 3.0, 0.5) < 128, "off-stroke should be outside band" ); } #[test] fn empty_glyph_has_no_tile() { let space = Glyph { strokes: vec![], advance: 4.5, fill_tris: vec![], }; assert!(bake_glyph(&space, PEN_HALF_UNITS).is_none()); } #[test] fn packing_places_tiles_without_overlap() { let mut atlas = GlyphAtlas::new(256, 256); let a = atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).unwrap(); let b = atlas.pack(bake_glyph(&diagonal_stroke(), PEN_HALF_UNITS).unwrap()).unwrap(); assert!(atlas.is_dirty()); // UVs are within the atlas. for e in [&a, &b] { assert!(e.uv_min[0] >= 0.0 && e.uv_max[0] <= 1.0); assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0); } // Two tiles on the same shelf must not overlap in U. let overlap_u = a.uv_min[0] < b.uv_max[0] && b.uv_min[0] < a.uv_max[0]; let overlap_v = a.uv_min[1] < b.uv_max[1] && b.uv_min[1] < a.uv_max[1]; assert!(!(overlap_u && overlap_v), "tiles overlap in the atlas"); } #[test] fn full_atlas_returns_none() { // A tiny atlas cannot fit even one padded tile (wider than the atlas — // the width the packer does not grow). let mut atlas = GlyphAtlas::new(4, 4); assert!(atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()).is_none()); } #[test] fn atlas_grows_instead_of_dropping_glyphs() { // Text-heavy drawings need more unique glyphs than one 1024² page holds; // the packer must grow taller rather than start returning None, which // silently dropped text and dimensions (issue #347). let mut atlas = GlyphAtlas::new(1024, 1024); let start_h = atlas.height(); let mut packed = 0; for _ in 0..1000 { match atlas.pack(bake_glyph(&filled_square(), PEN_HALF_UNITS).unwrap()) { Some(e) => { // UVs stay normalized after any growth-driven rescale. assert!(e.uv_min[1] >= 0.0 && e.uv_max[1] <= 1.0); packed += 1; } None => break, } } assert_eq!(packed, 1000, "every tile must fit once the atlas grows"); assert!(atlas.height() > start_h, "atlas should have grown taller"); assert!(atlas.height() <= MAX_ATLAS_PX); } // Full path over a real, embedded stroke font (no system fonts needed): // Face::resolve -> glyph -> bake -> pack, plus the (font, char) cache. #[test] fn real_lff_glyph_bakes_and_packs() { let mut atlas = GlyphAtlas::new(512, 512); let e = atlas.get_or_insert("txt", 'A', false).expect("LFF 'A' bakes"); assert!(atlas.is_dirty()); assert!( e.plane_max[0] > e.plane_min[0] && e.plane_max[1] > e.plane_min[1], "tile covers a positive-area glyph rect" ); assert!( e.uv_max[0] > e.uv_min[0] && e.uv_max[1] > e.uv_min[1] && e.uv_max[0] <= 1.0 && e.uv_max[1] <= 1.0, "uv rect non-degenerate and within the atlas" ); assert!(e.advance > 0.0); // Second lookup is cached: identical placement, no re-pack. let e2 = atlas.get_or_insert("txt", 'A', false).expect("cached"); assert_eq!(e.uv_min, e2.uv_min); assert_eq!(e.uv_max, e2.uv_max); } }