Skip to main content

proof_engine/glyph/
atlas.rs

1//! Font texture atlas.
2//!
3//! Uses ab_glyph to rasterize a system TTF into a single R8 texture.
4//! Falls back to a placeholder if no font is found.
5
6use std::collections::HashMap;
7use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
8
9/// UV rectangle for one glyph in the atlas.
10#[derive(Copy, Clone, Debug)]
11pub struct GlyphUv {
12    pub u0: f32, pub v0: f32,
13    pub u1: f32, pub v1: f32,
14}
15impl GlyphUv {
16    pub fn offset(&self) -> [f32; 2] { [self.u0, self.v0] }
17    pub fn size(&self)   -> [f32; 2] { [self.u1 - self.u0, self.v1 - self.v0] }
18}
19
20/// The characters the engine can render.
21///
22/// Covers: ASCII, box drawing (single + double), block elements, arrows,
23/// math operators, Greek alphabet, symbols, card suits, musical notes,
24/// geometric shapes, dingbats/stars, bullets, currency, braille-bar
25/// characters, and common game/UI glyphs.
26pub const ATLAS_CHARS: &str = concat!(
27    // ASCII printable (32-126)
28    " !\"#$%&'()*+,-./0123456789:;<=>?",
29    "@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_",
30    "`abcdefghijklmnopqrstuvwxyz{|}~",
31
32    // Block elements
33    "░▒▓█▄▀▌▐■□▪▫▬▮▯▰▱",
34
35    // Box drawing — double lines
36    "╔╗╚╝║═╠╣╦╩╬╟╢╤╧╪╫",
37    // Box drawing — single lines
38    "┌┐└┘│─├┤┬┴┼┃━┏┓┗┛┣┫┳┻╋",
39    // Box drawing — mixed
40    "╭╮╯╰╱╲╳",
41
42    // Arrows
43    "←→↑↓↔↕↖↗↘↙⇐⇒⇑⇓⇔▲▼◀▶▷▸▹►◁◂◃",
44
45    // Math operators and symbols
46    "∞∑∫∂∇∆∏√±∓×÷≈≠≡≤≥«»∈∉⊂⊃∪∩∧∨¬∀∃∅∝∠",
47    "⌈⌉⌊⌋",
48
49    // Greek alphabet (upper + lower)
50    "ΑΒΓΔΕΖΗΘΙΚΛΜΝΞΟΠΡΣΤΥΦΧΨΩαβγδεζηθικλμνξοπρστυφχψω",
51
52    // Geometric shapes
53    "●○◉◎◌◍◐◑◒◓◔◕",
54    "◆◇◈□■▢▣▤▥▦▧▨▩",
55    "△▽▲▼◁▷◀▶",
56    "◯⬠⬡⬢⬣",
57
58    // Stars, dingbats, decorative
59    "★☆✦✧✩✪✫✬✭✮✯✰✱✲✳✴✵✶✷✸✹",
60    "⁂❖❘❙❚",
61    "⊕⊗⊙⊛⊜⊝",
62
63    // Bullets and dots
64    "·•‣⁃∙◦°※†‡§¶",
65
66    // Card suits
67    "♠♣♥♦♤♧♡♢",
68
69    // Musical
70    "♩♪♫♬♭♮♯",
71
72    // Misc symbols
73    "☠☢☣☯☮☸✓✗✘✔✕❌⚙⚡⚔⚒⚑⚐⚠⚰⚱⛏",
74    "☀☁☂☃☄☾☽❄❆❇",
75    "☹☺☻♻♲♳⚕⚖⚗⚛⚜",
76
77    // Currency
78    "¢£¥€₹₽₿",
79
80    // Bar chart characters (for spectrum visualizer, HP bars, etc.)
81    "▁▂▃▄▅▆▇█",
82
83    // Braces, brackets, misc punctuation
84    "‹›「」『』【】〈〉《》",
85    "…—–\u{2018}\u{2019}\u{201C}\u{201D}\u{201E}\u{201A}",
86);
87
88/// Complete font atlas ready to upload to the GPU.
89/// Uses Signed Distance Field (SDF) encoding for resolution-independent rendering.
90/// Each pixel stores the distance to the nearest edge:
91///   128 = on the edge
92///   >128 = inside the glyph
93///   <128 = outside the glyph
94pub struct FontAtlas {
95    pub width:  u32,
96    pub height: u32,
97    /// R8 pixel data -- SDF encoded (128 = edge, >128 = inside, <128 = outside).
98    pub pixels: Vec<u8>,
99    pub uvs:    HashMap<char, GlyphUv>,
100    pub cell_w: u32,
101    pub cell_h: u32,
102    /// Whether this atlas uses SDF encoding (vs raw coverage).
103    pub is_sdf: bool,
104}
105
106/// Convert a coverage bitmap to a signed distance field.
107/// `spread` is the maximum distance in pixels that the SDF encodes.
108fn bitmap_to_sdf(bitmap: &[u8], w: u32, h: u32, spread: f32) -> Vec<u8> {
109    let mut sdf = vec![0u8; (w * h) as usize];
110    let threshold = 128u8; // coverage > this = inside
111
112    for y in 0..h as i32 {
113        for x in 0..w as i32 {
114            let idx = (y as u32 * w + x as u32) as usize;
115            let is_inside = bitmap[idx] > threshold;
116
117            // Find minimum distance to an edge (brute force within spread radius)
118            let search = spread.ceil() as i32 + 1;
119            let mut min_dist_sq = (spread * spread) as f32 + 1.0;
120
121            'search: for dy in -search..=search {
122                for dx in -search..=search {
123                    let nx = x + dx;
124                    let ny = y + dy;
125                    if nx < 0 || ny < 0 || nx >= w as i32 || ny >= h as i32 { continue; }
126                    let ni = (ny as u32 * w + nx as u32) as usize;
127                    let neighbor_inside = bitmap[ni] > threshold;
128                    if neighbor_inside != is_inside {
129                        let d = (dx * dx + dy * dy) as f32;
130                        if d < min_dist_sq { min_dist_sq = d; }
131                    }
132                }
133            }
134
135            let dist = min_dist_sq.sqrt();
136            let signed_dist = if is_inside { dist } else { -dist };
137            // Map to 0-255: 128 = edge, 255 = deep inside, 0 = far outside
138            let normalized = (signed_dist / spread * 127.0 + 128.0).clamp(0.0, 255.0);
139            sdf[idx] = normalized as u8;
140        }
141    }
142    sdf
143}
144
145fn load_system_font() -> Option<FontVec> {
146    let paths: &[&str] = &[
147        r"C:\Windows\Fonts\consola.ttf",
148        r"C:\Windows\Fonts\cour.ttf",
149        r"C:\Windows\Fonts\lucon.ttf",
150        "/System/Library/Fonts/Menlo.ttc",
151        "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
152        "/usr/share/fonts/TTF/DejaVuSansMono.ttf",
153        "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
154    ];
155    for p in paths {
156        if let Ok(data) = std::fs::read(p) {
157            if let Ok(f) = FontVec::try_from_vec(data) {
158                log::info!("FontAtlas: loaded '{}'", p);
159                return Some(f);
160            }
161        }
162    }
163    None
164}
165
166/// Pixels of distance-field spread around each glyph.
167const SDF_SPREAD: f32 = 6.0;
168
169impl FontAtlas {
170    pub fn build(px_size: f32) -> Self {
171        let chars: Vec<char> = ATLAS_CHARS.chars().collect();
172        if let Some(font) = load_system_font() {
173            return Self::from_ttf(&font, px_size, &chars);
174        }
175        log::warn!("FontAtlas: no system font found, using fallback");
176        Self::fallback(&chars, px_size as u32)
177    }
178
179    fn from_ttf(font: &FontVec, px_size: f32, chars: &[char]) -> Self {
180        let scale   = PxScale::from(px_size);
181        let scaled  = font.as_scaled(scale);
182        let ascent  = scaled.ascent();
183        let descent = scaled.descent();
184        let cell_h  = (ascent - descent).ceil() as u32 + 4;
185        let cell_w  = chars.iter().filter_map(|ch| {
186            let id = font.glyph_id(*ch);
187            if id.0 == 0 { return None; }
188            Some(scaled.h_advance(id).ceil() as u32)
189        }).max().unwrap_or(px_size as u32) + 4;
190
191        // Gutter between cells, in pixels.
192        //
193        // The atlas is converted to a signed distance field across the whole
194        // image, so every glyph's field spreads SDF_SPREAD pixels in all
195        // directions. Packed edge to edge, that field leaks into the
196        // neighbouring cell and shows up in game as faint marks around every
197        // character and as solid rules breaking into dashes. The gutter has to
198        // be wider than the spread for the leak to die out before it reaches
199        // the next glyph.
200        const GUTTER: u32 = 8;
201        debug_assert!(
202            GUTTER as f32 > SDF_SPREAD,
203            "the gutter must outlast the distance field spread"
204        );
205
206        let cols = 32u32;
207        let rows = (chars.len() as u32 + cols - 1) / cols;
208        let stride_w = cell_w + GUTTER;
209        let stride_h = cell_h + GUTTER;
210        let w = cols * stride_w;
211        let h = rows * stride_h;
212        let mut pixels = vec![0u8; (w * h) as usize];
213        let mut uvs    = HashMap::new();
214        let baseline_offset = ascent.ceil() as i32 + 1;
215
216        for (i, ch) in chars.iter().enumerate() {
217            let col = (i as u32 % cols) as i32;
218            let row = (i as u32 / cols) as i32;
219            let cx  = col * stride_w as i32;
220            let cy  = row * stride_h as i32;
221
222            let glyph = font.glyph_id(*ch).with_scale_and_position(
223                scale,
224                ab_glyph::point(cx as f32 + 1.0, cy as f32 + baseline_offset as f32),
225            );
226            if let Some(outlined) = font.outline_glyph(glyph) {
227                let bounds = outlined.px_bounds();
228                outlined.draw(|x, y, v| {
229                    let px = bounds.min.x as i32 + x as i32;
230                    let py = bounds.min.y as i32 + y as i32;
231                    if px >= 0 && py >= 0 {
232                        let idx = (py as u32 * w + px as u32) as usize;
233                        if idx < pixels.len() {
234                            pixels[idx] = (v * 255.0).min(255.0) as u8;
235                        }
236                    }
237                });
238            }
239            // Span the glyph box only. The gutter exists to absorb the
240            // distance field, so sampling into it would defeat the point.
241            // The extra half-texel inset keeps LINEAR filtering off the seam.
242            let (hx, hy) = (0.5 / w as f32, 0.5 / h as f32);
243            uvs.insert(*ch, GlyphUv {
244                u0: cx as f32 / w as f32 + hx,
245                v0: cy as f32 / h as f32 + hy,
246                u1: (cx + cell_w as i32) as f32 / w as f32 - hx,
247                v1: (cy + cell_h as i32) as f32 / h as f32 - hy,
248            });
249        }
250        // Convert to SDF for resolution-independent rendering
251        let sdf_spread = SDF_SPREAD;
252        log::info!("FontAtlas: computing SDF ({}x{}, spread={})...", w, h, sdf_spread);
253        let sdf_pixels = bitmap_to_sdf(&pixels, w, h, sdf_spread);
254        log::info!("FontAtlas: SDF complete");
255
256        Self { width: w, height: h, pixels: sdf_pixels, uvs, cell_w, cell_h, is_sdf: true }
257    }
258
259    fn fallback(chars: &[char], px: u32) -> Self {
260        let cw = (px / 2).max(8);
261        let ch = px.max(12);
262        let cols = 32u32;
263        let rows = (chars.len() as u32 + cols - 1) / cols;
264        let w = cols * cw;
265        let h = rows * ch;
266        let mut pixels = vec![0u8; (w * h) as usize];
267        let mut uvs = HashMap::new();
268        for (i, c) in chars.iter().enumerate() {
269            let col = (i as u32 % cols) as i32;
270            let row = (i as u32 / cols) as i32;
271            let cx = col * cw as i32;
272            let cy = row * ch as i32;
273            for py in 1..ch as i32 - 1 {
274                for px in 1..cw as i32 - 1 {
275                    let idx = ((cy + py) as u32 * w + (cx + px) as u32) as usize;
276                    if idx < pixels.len() { pixels[idx] = 180; }
277                }
278            }
279            let (hx, hy) = (0.5 / w as f32, 0.5 / h as f32);
280            uvs.insert(*c, GlyphUv {
281                u0: cx as f32 / w as f32 + hx,
282                v0: cy as f32 / h as f32 + hy,
283                u1: (cx + cw as i32) as f32 / w as f32 - hx,
284                v1: (cy + ch as i32) as f32 / h as f32 - hy,
285            });
286        }
287        Self { width: w, height: h, pixels, uvs, cell_w: cw, cell_h: ch, is_sdf: false }
288    }
289
290    pub fn uv_for(&self, ch: char) -> GlyphUv {
291        self.uvs.get(&ch)
292            .or_else(|| self.uvs.get(&'?'))
293            .copied()
294            .unwrap_or(GlyphUv { u0: 0.0, v0: 0.0, u1: 0.01, v1: 0.01 })
295    }
296}