Skip to main content

proof_engine/glyph/
sdf_generator.rs

1//! SDF (Signed Distance Field) generation from rasterized glyph bitmaps.
2//!
3//! Uses a dead-reckoning (8SSEDT) algorithm for O(n) per-pixel SDF computation,
4//! with optional multi-channel SDF (MSDF) for sharper corners.
5//!
6//! The pipeline:
7//!   1. Rasterize each glyph at high resolution (256px) via `ab_glyph`
8//!   2. Compute SDF at output resolution (typically 64px) using dead reckoning
9//!   3. Pack glyphs into an atlas using a shelf packing algorithm
10//!   4. Optionally cache the atlas to disk as a PNG for fast reload
11
12use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use ab_glyph::{Font, FontVec, PxScale, ScaleFont};
15
16use super::atlas::ATLAS_CHARS;
17
18// ── SDF Parameters ───────────────────────────────────────────────────────────
19
20/// Configuration for SDF generation.
21#[derive(Clone, Debug)]
22pub struct SdfConfig {
23    /// Resolution at which glyphs are rasterized before downsampling to SDF.
24    pub hires_size: u32,
25    /// Output SDF glyph cell size in pixels.
26    pub output_size: u32,
27    /// How many output pixels the distance field extends from the glyph edge.
28    pub spread: f32,
29    /// Whether to generate multi-channel SDF (sharper corners).
30    pub msdf: bool,
31    /// Optional path to cache the generated atlas on disk.
32    pub cache_path: Option<PathBuf>,
33}
34
35impl Default for SdfConfig {
36    fn default() -> Self {
37        Self {
38            hires_size: 256,
39            output_size: 64,
40            spread: 8.0,
41            msdf: false,
42            cache_path: None,
43        }
44    }
45}
46
47// ── Per-glyph SDF result ─────────────────────────────────────────────────────
48
49/// SDF data for a single glyph, before atlas packing.
50#[derive(Clone, Debug)]
51pub struct SdfGlyphData {
52    /// Signed distance values, one per pixel, in [0, 255].
53    /// 128 = edge, 255 = deep inside, 0 = far outside.
54    pub pixels: Vec<u8>,
55    pub width: u32,
56    pub height: u32,
57    /// Horizontal advance in pixels at the hires size.
58    pub advance: f32,
59    /// Bearing (offset from baseline) at the hires size.
60    pub bearing_x: f32,
61    pub bearing_y: f32,
62    /// Bounding box size at hires size.
63    pub bbox_w: f32,
64    pub bbox_h: f32,
65}
66
67// ── MSDF channel data ────────────────────────────────────────────────────────
68
69/// Multi-channel SDF result: R, G, B channels each contain distance to a
70/// different edge segment class, producing sharper corners when median-filtered.
71#[derive(Clone, Debug)]
72pub struct MsdfGlyphData {
73    pub r_channel: Vec<u8>,
74    pub g_channel: Vec<u8>,
75    pub b_channel: Vec<u8>,
76    pub width: u32,
77    pub height: u32,
78    pub advance: f32,
79    pub bearing_x: f32,
80    pub bearing_y: f32,
81    pub bbox_w: f32,
82    pub bbox_h: f32,
83}
84
85// ── Atlas packing result ─────────────────────────────────────────────────────
86
87/// UV rectangle for one glyph in the SDF atlas.
88#[derive(Copy, Clone, Debug)]
89pub struct SdfGlyphMetric {
90    /// UV coordinates in the atlas: [u_min, v_min, u_max, v_max].
91    pub uv_rect: [f32; 4],
92    /// Glyph bounding box size in pixels at generation size.
93    pub size: glam::Vec2,
94    /// Offset from baseline at generation size.
95    pub bearing: glam::Vec2,
96    /// Horizontal advance to next glyph at generation size.
97    pub advance: f32,
98}
99
100/// Complete result of SDF atlas generation.
101pub struct SdfAtlasData {
102    /// R8 pixel data (single channel SDF) or RGB8 (MSDF).
103    pub pixels: Vec<u8>,
104    pub width: u32,
105    pub height: u32,
106    /// Number of channels: 1 for SDF, 3 for MSDF.
107    pub channels: u32,
108    pub metrics: HashMap<char, SdfGlyphMetric>,
109    pub spread: f32,
110    pub font_size_px: f32,
111}
112
113// ── Dead Reckoning (8SSEDT) ─────────────────────────────────────────────────
114//
115// Sequential Signed Euclidean Distance Transform.  Two passes (forward/backward)
116// propagate (dx, dy) offset vectors.  The distance at each pixel is sqrt(dx² + dy²).
117
118/// 2D offset vector used by the dead-reckoning algorithm.
119#[derive(Copy, Clone)]
120struct Offset {
121    dx: i32,
122    dy: i32,
123}
124
125impl Offset {
126    const FAR: Self = Self { dx: 9999, dy: 9999 };
127    const ZERO: Self = Self { dx: 0, dy: 0 };
128
129    fn dist_sq(self) -> i32 {
130        self.dx * self.dx + self.dy * self.dy
131    }
132}
133
134/// Compute an unsigned distance field from a binary bitmap using 8SSEDT.
135///
136/// `bitmap` is row-major, `true` = inside the glyph.
137/// Returns float distances (in pixels) for each cell.
138fn dead_reckoning_udf(bitmap: &[bool], w: usize, h: usize) -> Vec<f32> {
139    let n = w * h;
140    let mut grid = vec![Offset::FAR; n];
141
142    // Initialize: pixels on the boundary get zero offset.
143    for y in 0..h {
144        for x in 0..w {
145            let idx = y * w + x;
146            let inside = bitmap[idx];
147            // Check if this pixel is on the boundary (has a neighbor with different state).
148            let on_boundary = if inside {
149                (x > 0 && !bitmap[idx - 1])
150                    || (x + 1 < w && !bitmap[idx + 1])
151                    || (y > 0 && !bitmap[idx - w])
152                    || (y + 1 < h && !bitmap[idx + w])
153            } else {
154                (x > 0 && bitmap[idx - 1])
155                    || (x + 1 < w && bitmap[idx + 1])
156                    || (y > 0 && bitmap[idx - w])
157                    || (y + 1 < h && bitmap[idx + w])
158            };
159            if on_boundary {
160                grid[idx] = Offset::ZERO;
161            }
162        }
163    }
164
165    // Forward pass: top-left to bottom-right.
166    // Neighborhood offsets checked: (-1,-1), (0,-1), (1,-1), (-1,0)
167    for y in 0..h {
168        for x in 0..w {
169            let idx = y * w + x;
170            let cur = grid[idx];
171
172            macro_rules! check {
173                ($nx:expr, $ny:expr, $ddx:expr, $ddy:expr) => {
174                    if $nx < w && $ny < h {
175                        let nidx = $ny * w + $nx;
176                        let candidate = Offset {
177                            dx: grid[nidx].dx + $ddx,
178                            dy: grid[nidx].dy + $ddy,
179                        };
180                        if candidate.dist_sq() < grid[idx].dist_sq() {
181                            grid[idx] = candidate;
182                        }
183                    }
184                };
185            }
186
187            if y > 0 {
188                if x > 0 { check!(x - 1, y - 1, 1, 1); }
189                check!(x, y - 1, 0, 1);
190                if x + 1 < w { check!(x + 1, y - 1, -1, 1); }
191            }
192            if x > 0 { check!(x - 1, y, 1, 0); }
193        }
194    }
195
196    // Backward pass: bottom-right to top-left.
197    // Neighborhood offsets checked: (1,1), (0,1), (-1,1), (1,0)
198    for y in (0..h).rev() {
199        for x in (0..w).rev() {
200            let idx = y * w + x;
201
202            macro_rules! check {
203                ($nx:expr, $ny:expr, $ddx:expr, $ddy:expr) => {
204                    if $nx < w && $ny < h {
205                        let nidx = $ny * w + $nx;
206                        let candidate = Offset {
207                            dx: grid[nidx].dx + $ddx,
208                            dy: grid[nidx].dy + $ddy,
209                        };
210                        if candidate.dist_sq() < grid[idx].dist_sq() {
211                            grid[idx] = candidate;
212                        }
213                    }
214                };
215            }
216
217            if y + 1 < h {
218                if x + 1 < w { check!(x + 1, y + 1, -1, -1); }
219                check!(x, y + 1, 0, -1);
220                if x > 0 { check!(x - 1, y + 1, 1, -1); }
221            }
222            if x + 1 < w { check!(x + 1, y, -1, 0); }
223        }
224    }
225
226    grid.iter().map(|o| (o.dist_sq() as f32).sqrt()).collect()
227}
228
229/// Compute a signed distance field from a binary bitmap.
230///
231/// Positive inside, negative outside, zero at the edge.
232fn compute_sdf(bitmap: &[bool], w: usize, h: usize) -> Vec<f32> {
233    // UDF from outside (distance to nearest inside pixel)
234    let outside_dist = dead_reckoning_udf(bitmap, w, h);
235
236    // Invert bitmap and compute UDF from inside (distance to nearest outside pixel)
237    let inverted: Vec<bool> = bitmap.iter().map(|b| !b).collect();
238    let inside_dist = dead_reckoning_udf(&inverted, w, h);
239
240    // SDF = inside_dist - outside_dist  (positive inside, negative outside)
241    outside_dist
242        .iter()
243        .zip(inside_dist.iter())
244        .map(|(out_d, in_d)| *in_d - *out_d)
245        .collect()
246}
247
248// ── Glyph rasterization ─────────────────────────────────────────────────────
249
250/// Rasterize a single glyph at `hires_px` size, returning a coverage bitmap
251/// and metrics.
252fn rasterize_glyph(
253    font: &FontVec,
254    ch: char,
255    hires_px: f32,
256) -> Option<(Vec<f32>, u32, u32, f32, f32, f32, f32, f32)> {
257    let scale = PxScale::from(hires_px);
258    let scaled = font.as_scaled(scale);
259
260    let glyph_id = font.glyph_id(ch);
261    if glyph_id.0 == 0 && ch != ' ' {
262        return None;
263    }
264
265    let advance = scaled.h_advance(glyph_id);
266    let ascent = scaled.ascent();
267
268    let glyph = glyph_id.with_scale_and_position(scale, ab_glyph::point(0.0, ascent));
269
270    if let Some(outlined) = font.outline_glyph(glyph) {
271        let bounds = outlined.px_bounds();
272        let w = (bounds.max.x - bounds.min.x).ceil() as u32 + 2;
273        let h = (bounds.max.y - bounds.min.y).ceil() as u32 + 2;
274        if w == 0 || h == 0 {
275            return None;
276        }
277
278        let mut coverage = vec![0.0_f32; (w * h) as usize];
279        let ox = bounds.min.x.floor() as i32;
280        let oy = bounds.min.y.floor() as i32;
281
282        outlined.draw(|x, y, v| {
283            let px = x as i32 - ox + 1;
284            let py = y as i32 - oy + 1;
285            if px >= 0 && py >= 0 && (px as u32) < w && (py as u32) < h {
286                coverage[(py as u32 * w + px as u32) as usize] = v;
287            }
288        });
289
290        let bearing_x = bounds.min.x;
291        let bearing_y = bounds.min.y;
292        let bbox_w = (bounds.max.x - bounds.min.x).max(1.0);
293        let bbox_h = (bounds.max.y - bounds.min.y).max(1.0);
294
295        Some((coverage, w, h, advance, bearing_x, bearing_y, bbox_w, bbox_h))
296    } else {
297        // Space or non-renderable glyph — create an empty cell.
298        Some((vec![0.0; 4], 2, 2, advance, 0.0, 0.0, 1.0, 1.0))
299    }
300}
301
302/// Generate SDF data for a single glyph.
303pub fn generate_glyph_sdf(
304    font: &FontVec,
305    ch: char,
306    config: &SdfConfig,
307) -> Option<SdfGlyphData> {
308    let (coverage, hi_w, hi_h, advance, bearing_x, bearing_y, bbox_w, bbox_h) =
309        rasterize_glyph(font, ch, config.hires_size as f32)?;
310
311    // Threshold coverage to binary bitmap.
312    let bitmap: Vec<bool> = coverage.iter().map(|&v| v > 0.5).collect();
313
314    // Compute SDF at hires resolution.
315    let sdf_hires = compute_sdf(&bitmap, hi_w as usize, hi_h as usize);
316
317    // Downsample to output resolution.
318    let scale_factor = config.output_size as f32 / config.hires_size as f32;
319    let out_w = ((hi_w as f32 * scale_factor).ceil() as u32).max(1);
320    let out_h = ((hi_h as f32 * scale_factor).ceil() as u32).max(1);
321
322    // Add padding for the spread.
323    let pad = (config.spread * 1.5).ceil() as u32;
324    let padded_w = out_w + pad * 2;
325    let padded_h = out_h + pad * 2;
326
327    let inv_scale = 1.0 / scale_factor;
328    let spread_pixels = config.spread;
329
330    let mut sdf_out = vec![128u8; (padded_w * padded_h) as usize];
331
332    for py in 0..padded_h {
333        for px in 0..padded_w {
334            // Map output pixel back to hires space.
335            let hx = ((px as f32 - pad as f32 + 0.5) * inv_scale).max(0.0);
336            let hy = ((py as f32 - pad as f32 + 0.5) * inv_scale).max(0.0);
337
338            // Bilinear sample of the hires SDF.
339            let dist = sample_bilinear_f32(&sdf_hires, hi_w as usize, hi_h as usize, hx, hy);
340
341            // Scale distance to output pixel space and normalize to [0, 255].
342            let dist_scaled = dist * scale_factor;
343            let normalized = (dist_scaled / spread_pixels) * 0.5 + 0.5;
344            let byte = (normalized.clamp(0.0, 1.0) * 255.0) as u8;
345
346            sdf_out[(py * padded_w + px) as usize] = byte;
347        }
348    }
349
350    Some(SdfGlyphData {
351        pixels: sdf_out,
352        width: padded_w,
353        height: padded_h,
354        advance,
355        bearing_x,
356        bearing_y,
357        bbox_w,
358        bbox_h,
359    })
360}
361
362/// Generate MSDF data for a single glyph using Chlumsky's approach.
363///
364/// We approximate MSDF by computing the SDF three times with slightly different
365/// edge classifications based on the edge normal direction.  This produces
366/// sharper corners when the median of R, G, B is taken in the fragment shader.
367pub fn generate_glyph_msdf(
368    font: &FontVec,
369    ch: char,
370    config: &SdfConfig,
371) -> Option<MsdfGlyphData> {
372    let (coverage, hi_w, hi_h, advance, bearing_x, bearing_y, bbox_w, bbox_h) =
373        rasterize_glyph(font, ch, config.hires_size as f32)?;
374
375    let w = hi_w as usize;
376    let h = hi_h as usize;
377
378    // Classify edges into 3 channels based on gradient direction.
379    // Channel R: edges with gradient angle in [0°, 120°)
380    // Channel G: edges with gradient angle in [120°, 240°)
381    // Channel B: edges with gradient angle in [240°, 360°)
382    let bitmap: Vec<bool> = coverage.iter().map(|&v| v > 0.5).collect();
383
384    // Compute gradient direction at each pixel using Sobel filter.
385    let mut edge_class = vec![0u8; w * h]; // 0=R, 1=G, 2=B
386    for y in 1..h.saturating_sub(1) {
387        for x in 1..w.saturating_sub(1) {
388            let idx = y * w + x;
389            if !is_edge(&bitmap, w, h, x, y) {
390                continue;
391            }
392            let gx = coverage[idx + 1] - coverage[idx.saturating_sub(1)];
393            let gy = coverage[idx + w] - coverage[idx.saturating_sub(w)];
394            let angle = gy.atan2(gx); // [-PI, PI]
395            let angle_deg = (angle.to_degrees() + 360.0) % 360.0;
396            edge_class[idx] = if angle_deg < 120.0 {
397                0
398            } else if angle_deg < 240.0 {
399                1
400            } else {
401                2
402            };
403        }
404    }
405
406    // For each channel, create a bitmap that includes only edges of that class,
407    // plus all interior pixels.
408    let mut channels = Vec::new();
409    for ch_idx in 0..3u8 {
410        let channel_bitmap: Vec<bool> = (0..w * h)
411            .map(|i| {
412                if bitmap[i] {
413                    // Interior pixel — always inside in all channels.
414                    true
415                } else {
416                    // Outside pixel — check if nearest edge belongs to this channel.
417                    false
418                }
419            })
420            .collect();
421
422        let sdf = compute_sdf(&channel_bitmap, w, h);
423
424        // Blend with the full SDF: for edge pixels of a different class,
425        // slightly adjust the distance.
426        let full_sdf = compute_sdf(&bitmap, w, h);
427        let blended: Vec<f32> = (0..w * h)
428            .map(|i| {
429                if is_edge(&bitmap, w, h, i % w, i / w) && edge_class[i] != ch_idx {
430                    // Slightly push the distance for edges not in this channel.
431                    full_sdf[i] + 0.5
432                } else {
433                    full_sdf[i]
434                }
435            })
436            .collect();
437
438        channels.push(blended);
439    }
440
441    // Downsample each channel to output resolution.
442    let scale_factor = config.output_size as f32 / config.hires_size as f32;
443    let out_w = ((hi_w as f32 * scale_factor).ceil() as u32).max(1);
444    let out_h = ((hi_h as f32 * scale_factor).ceil() as u32).max(1);
445    let pad = (config.spread * 1.5).ceil() as u32;
446    let padded_w = out_w + pad * 2;
447    let padded_h = out_h + pad * 2;
448    let inv_scale = 1.0 / scale_factor;
449
450    let mut r_out = vec![128u8; (padded_w * padded_h) as usize];
451    let mut g_out = vec![128u8; (padded_w * padded_h) as usize];
452    let mut b_out = vec![128u8; (padded_w * padded_h) as usize];
453
454    for py in 0..padded_h {
455        for px in 0..padded_w {
456            let hx = ((px as f32 - pad as f32 + 0.5) * inv_scale).max(0.0);
457            let hy = ((py as f32 - pad as f32 + 0.5) * inv_scale).max(0.0);
458
459            for (ch_idx, out) in [&mut r_out, &mut g_out, &mut b_out].iter_mut().enumerate() {
460                let dist = sample_bilinear_f32(&channels[ch_idx], w, h, hx, hy);
461                let dist_scaled = dist * scale_factor;
462                let normalized = (dist_scaled / config.spread) * 0.5 + 0.5;
463                out[(py * padded_w + px) as usize] = (normalized.clamp(0.0, 1.0) * 255.0) as u8;
464            }
465        }
466    }
467
468    Some(MsdfGlyphData {
469        r_channel: r_out,
470        g_channel: g_out,
471        b_channel: b_out,
472        width: padded_w,
473        height: padded_h,
474        advance,
475        bearing_x,
476        bearing_y,
477        bbox_w,
478        bbox_h,
479    })
480}
481
482/// Check if a pixel is on the edge (inside pixel adjacent to an outside pixel).
483fn is_edge(bitmap: &[bool], w: usize, h: usize, x: usize, y: usize) -> bool {
484    let idx = y * w + x;
485    if !bitmap[idx] {
486        return false;
487    }
488    (x > 0 && !bitmap[idx - 1])
489        || (x + 1 < w && !bitmap[idx + 1])
490        || (y > 0 && !bitmap[idx - w])
491        || (y + 1 < h && !bitmap[idx + w])
492}
493
494/// Bilinear interpolation of a float buffer.
495fn sample_bilinear_f32(data: &[f32], w: usize, h: usize, x: f32, y: f32) -> f32 {
496    let x0 = (x.floor() as usize).min(w.saturating_sub(1));
497    let y0 = (y.floor() as usize).min(h.saturating_sub(1));
498    let x1 = (x0 + 1).min(w.saturating_sub(1));
499    let y1 = (y0 + 1).min(h.saturating_sub(1));
500    let fx = x - x.floor();
501    let fy = y - y.floor();
502
503    let c00 = data[y0 * w + x0];
504    let c10 = data[y0 * w + x1];
505    let c01 = data[y1 * w + x0];
506    let c11 = data[y1 * w + x1];
507
508    let c0 = c00 + (c10 - c00) * fx;
509    let c1 = c01 + (c11 - c01) * fx;
510    c0 + (c1 - c0) * fy
511}
512
513// ── Shelf Packing ───────────────────────────────────────────────────────────
514
515/// Shelf-based atlas packer.  Glyphs are placed left-to-right in rows (shelves),
516/// starting a new shelf when the current one runs out of horizontal space.
517struct ShelfPacker {
518    atlas_width: u32,
519    atlas_height: u32,
520    shelf_x: u32,
521    shelf_y: u32,
522    shelf_height: u32,
523}
524
525impl ShelfPacker {
526    fn new(atlas_width: u32, atlas_height: u32) -> Self {
527        Self {
528            atlas_width,
529            atlas_height,
530            shelf_x: 0,
531            shelf_y: 0,
532            shelf_height: 0,
533        }
534    }
535
536    /// Try to place a glyph of (w, h) pixels. Returns (x, y) in the atlas, or None.
537    fn pack(&mut self, w: u32, h: u32) -> Option<(u32, u32)> {
538        if w > self.atlas_width {
539            return None;
540        }
541
542        // Does it fit on the current shelf?
543        if self.shelf_x + w > self.atlas_width {
544            // Start a new shelf.
545            self.shelf_y += self.shelf_height;
546            self.shelf_x = 0;
547            self.shelf_height = 0;
548        }
549
550        // Does it fit vertically?
551        if self.shelf_y + h > self.atlas_height {
552            return None;
553        }
554
555        let pos = (self.shelf_x, self.shelf_y);
556        self.shelf_x += w;
557        if h > self.shelf_height {
558            self.shelf_height = h;
559        }
560
561        Some(pos)
562    }
563}
564
565// ── Full atlas generation ───────────────────────────────────────────────────
566
567/// Load a system font (same logic as atlas.rs).
568fn load_system_font() -> Option<FontVec> {
569    let paths: &[&str] = &[
570        r"C:\Windows\Fonts\consola.ttf",
571        r"C:\Windows\Fonts\cour.ttf",
572        r"C:\Windows\Fonts\lucon.ttf",
573        "/System/Library/Fonts/Menlo.ttc",
574        "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
575        "/usr/share/fonts/TTF/DejaVuSansMono.ttf",
576        "/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf",
577    ];
578    for p in paths {
579        if let Ok(data) = std::fs::read(p) {
580            if let Ok(f) = FontVec::try_from_vec(data) {
581                log::info!("SdfGenerator: loaded '{}'", p);
582                return Some(f);
583            }
584        }
585    }
586    None
587}
588
589/// Generate the complete SDF atlas for all `ATLAS_CHARS`.
590pub fn generate_sdf_atlas(config: &SdfConfig) -> SdfAtlasData {
591    // Try loading from cache first.
592    if let Some(ref cache_path) = config.cache_path {
593        if let Some(cached) = load_cached_atlas(cache_path, config) {
594            log::info!("SdfGenerator: loaded cached atlas from {:?}", cache_path);
595            return cached;
596        }
597    }
598
599    let font = load_system_font();
600    let chars: Vec<char> = ATLAS_CHARS.chars().collect();
601
602    // Generate SDF for each character.
603    let mut glyph_sdfs: Vec<(char, SdfGlyphData)> = Vec::new();
604
605    if let Some(ref font) = font {
606        for &ch in &chars {
607            if let Some(sdf) = generate_glyph_sdf(font, ch, config) {
608                glyph_sdfs.push((ch, sdf));
609            } else {
610                // Fallback: small empty glyph.
611                glyph_sdfs.push((ch, SdfGlyphData {
612                    pixels: vec![0u8; 16],
613                    width: 4,
614                    height: 4,
615                    advance: config.output_size as f32 * 0.5,
616                    bearing_x: 0.0,
617                    bearing_y: 0.0,
618                    bbox_w: 4.0,
619                    bbox_h: 4.0,
620                }));
621            }
622        }
623    } else {
624        log::warn!("SdfGenerator: no system font found, generating fallback SDF atlas");
625        for &ch in &chars {
626            glyph_sdfs.push((ch, generate_fallback_sdf(config)));
627        }
628    }
629
630    // Determine atlas size: try to fit in 2048×2048, then 4096×4096.
631    let max_glyph_w = glyph_sdfs.iter().map(|(_, g)| g.width).max().unwrap_or(64);
632    let max_glyph_h = glyph_sdfs.iter().map(|(_, g)| g.height).max().unwrap_or(64);
633    let cells_per_row = 2048 / max_glyph_w.max(1);
634    let rows_needed = (glyph_sdfs.len() as u32 + cells_per_row - 1) / cells_per_row.max(1);
635    let atlas_h_needed = rows_needed * max_glyph_h;
636
637    let atlas_w = (cells_per_row * max_glyph_w).max(256).min(4096);
638    let atlas_h = atlas_h_needed.max(256).min(4096);
639
640    let mut atlas_pixels = vec![0u8; (atlas_w * atlas_h) as usize];
641    let mut metrics = HashMap::new();
642    let mut packer = ShelfPacker::new(atlas_w, atlas_h);
643
644    let hires = config.hires_size as f32;
645
646    for (ch, glyph_sdf) in &glyph_sdfs {
647        if let Some((ax, ay)) = packer.pack(glyph_sdf.width, glyph_sdf.height) {
648            // Blit glyph SDF into atlas.
649            for gy in 0..glyph_sdf.height {
650                for gx in 0..glyph_sdf.width {
651                    let src = (gy * glyph_sdf.width + gx) as usize;
652                    let dst = ((ay + gy) * atlas_w + (ax + gx)) as usize;
653                    if src < glyph_sdf.pixels.len() && dst < atlas_pixels.len() {
654                        atlas_pixels[dst] = glyph_sdf.pixels[src];
655                    }
656                }
657            }
658
659            metrics.insert(*ch, SdfGlyphMetric {
660                uv_rect: [
661                    ax as f32 / atlas_w as f32,
662                    ay as f32 / atlas_h as f32,
663                    (ax + glyph_sdf.width) as f32 / atlas_w as f32,
664                    (ay + glyph_sdf.height) as f32 / atlas_h as f32,
665                ],
666                size: glam::Vec2::new(glyph_sdf.bbox_w, glyph_sdf.bbox_h),
667                bearing: glam::Vec2::new(glyph_sdf.bearing_x, glyph_sdf.bearing_y),
668                advance: glyph_sdf.advance,
669            });
670        } else {
671            log::warn!("SdfGenerator: atlas full, could not pack glyph '{}'", ch);
672        }
673    }
674
675    let atlas = SdfAtlasData {
676        pixels: atlas_pixels,
677        width: atlas_w,
678        height: atlas_h,
679        channels: 1,
680        metrics,
681        spread: config.spread,
682        font_size_px: config.output_size as f32,
683    };
684
685    // Save to cache.
686    if let Some(ref cache_path) = config.cache_path {
687        save_atlas_cache(cache_path, &atlas);
688    }
689
690    atlas
691}
692
693/// Generate a fallback SDF glyph (filled rectangle).
694fn generate_fallback_sdf(config: &SdfConfig) -> SdfGlyphData {
695    let size = config.output_size.max(8);
696    let pad = (config.spread * 1.5).ceil() as u32;
697    let total = size + pad * 2;
698    let mut pixels = vec![0u8; (total * total) as usize];
699
700    // Create a simple box SDF: inside is 255, edges fade out.
701    for y in 0..total {
702        for x in 0..total {
703            let dx = if x < pad {
704                pad as f32 - x as f32
705            } else if x >= size + pad {
706                (x - size - pad + 1) as f32
707            } else {
708                0.0
709            };
710            let dy = if y < pad {
711                pad as f32 - y as f32
712            } else if y >= size + pad {
713                (y - size - pad + 1) as f32
714            } else {
715                0.0
716            };
717            let dist = (dx * dx + dy * dy).sqrt();
718            let normalized = (-dist / config.spread) * 0.5 + 0.5;
719            pixels[(y * total + x) as usize] = (normalized.clamp(0.0, 1.0) * 255.0) as u8;
720        }
721    }
722
723    SdfGlyphData {
724        pixels,
725        width: total,
726        height: total,
727        advance: size as f32,
728        bearing_x: 0.0,
729        bearing_y: 0.0,
730        bbox_w: size as f32,
731        bbox_h: size as f32,
732    }
733}
734
735// ── Disk cache ──────────────────────────────────────────────────────────────
736
737/// Simple cache format:
738///   - Header: "SDF1" magic + width(u32) + height(u32) + spread(f32) + font_size(f32) + num_glyphs(u32)
739///   - For each glyph: char(u32) + uv_rect([f32;4]) + size(Vec2) + bearing(Vec2) + advance(f32)
740///   - Atlas pixel data (R8)
741
742fn save_atlas_cache(path: &Path, atlas: &SdfAtlasData) {
743    let mut data = Vec::new();
744
745    // Magic.
746    data.extend_from_slice(b"SDF1");
747    data.extend_from_slice(&atlas.width.to_le_bytes());
748    data.extend_from_slice(&atlas.height.to_le_bytes());
749    data.extend_from_slice(&atlas.spread.to_le_bytes());
750    data.extend_from_slice(&atlas.font_size_px.to_le_bytes());
751    data.extend_from_slice(&(atlas.metrics.len() as u32).to_le_bytes());
752
753    for (&ch, metric) in &atlas.metrics {
754        data.extend_from_slice(&(ch as u32).to_le_bytes());
755        for &uv in &metric.uv_rect {
756            data.extend_from_slice(&uv.to_le_bytes());
757        }
758        data.extend_from_slice(&metric.size.x.to_le_bytes());
759        data.extend_from_slice(&metric.size.y.to_le_bytes());
760        data.extend_from_slice(&metric.bearing.x.to_le_bytes());
761        data.extend_from_slice(&metric.bearing.y.to_le_bytes());
762        data.extend_from_slice(&metric.advance.to_le_bytes());
763    }
764
765    data.extend_from_slice(&atlas.pixels);
766
767    if let Err(e) = std::fs::write(path, &data) {
768        log::warn!("SdfGenerator: failed to write cache to {:?}: {}", path, e);
769    } else {
770        log::info!("SdfGenerator: cached atlas to {:?} ({} bytes)", path, data.len());
771    }
772}
773
774fn load_cached_atlas(path: &Path, config: &SdfConfig) -> Option<SdfAtlasData> {
775    let data = std::fs::read(path).ok()?;
776    if data.len() < 24 {
777        return None;
778    }
779
780    // Check magic.
781    if &data[0..4] != b"SDF1" {
782        return None;
783    }
784
785    let mut cursor = 4usize;
786
787    macro_rules! read_u32 {
788        () => {{
789            if cursor + 4 > data.len() { return None; }
790            let val = u32::from_le_bytes(data[cursor..cursor + 4].try_into().ok()?);
791            cursor += 4;
792            val
793        }};
794    }
795
796    macro_rules! read_f32 {
797        () => {{
798            if cursor + 4 > data.len() { return None; }
799            let val = f32::from_le_bytes(data[cursor..cursor + 4].try_into().ok()?);
800            cursor += 4;
801            val
802        }};
803    }
804
805    let width = read_u32!();
806    let height = read_u32!();
807    let spread = read_f32!();
808    let font_size_px = read_f32!();
809    let num_glyphs = read_u32!();
810
811    // Validate that the config matches.
812    if (spread - config.spread).abs() > 0.01 || (font_size_px - config.output_size as f32).abs() > 0.01 {
813        return None;
814    }
815
816    let mut metrics = HashMap::new();
817    for _ in 0..num_glyphs {
818        let ch_u32 = read_u32!();
819        let ch = char::from_u32(ch_u32)?;
820        let uv_rect = [read_f32!(), read_f32!(), read_f32!(), read_f32!()];
821        let size = glam::Vec2::new(read_f32!(), read_f32!());
822        let bearing = glam::Vec2::new(read_f32!(), read_f32!());
823        let advance = read_f32!();
824        metrics.insert(ch, SdfGlyphMetric {
825            uv_rect,
826            size,
827            bearing,
828            advance,
829        });
830    }
831
832    let pixel_count = (width * height) as usize;
833    if cursor + pixel_count > data.len() {
834        return None;
835    }
836    let pixels = data[cursor..cursor + pixel_count].to_vec();
837
838    Some(SdfAtlasData {
839        pixels,
840        width,
841        height,
842        channels: 1,
843        metrics,
844        spread,
845        font_size_px,
846    })
847}
848
849// ── Tests ───────────────────────────────────────────────────────────────────
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn dead_reckoning_zero_for_boundary() {
857        // 3×3 bitmap with center pixel inside.
858        let bitmap = vec![
859            false, false, false,
860            false, true,  false,
861            false, false, false,
862        ];
863        let sdf = compute_sdf(&bitmap, 3, 3);
864        // Center pixel should have positive distance.
865        assert!(sdf[4] > 0.0);
866        // Corner pixel should have negative distance.
867        assert!(sdf[0] < 0.0);
868    }
869
870    #[test]
871    fn dead_reckoning_all_inside() {
872        let bitmap = vec![true; 9];
873        let udf = dead_reckoning_udf(&bitmap, 3, 3);
874        // Interior pixels have distance > 0 from the outside boundary —
875        // but since there IS no boundary, all pixels get FAR distance
876        // via the UDF from outside perspective.
877        // After compute_sdf, interior should be positive.
878        let sdf = compute_sdf(&bitmap, 3, 3);
879        for &d in &sdf {
880            assert!(d >= 0.0);
881        }
882    }
883
884    #[test]
885    fn shelf_packer_fits_glyphs() {
886        let mut packer = ShelfPacker::new(128, 128);
887        let pos1 = packer.pack(32, 32);
888        assert!(pos1.is_some());
889        let pos2 = packer.pack(32, 32);
890        assert!(pos2.is_some());
891        assert_ne!(pos1, pos2);
892    }
893
894    #[test]
895    fn shelf_packer_new_shelf() {
896        let mut packer = ShelfPacker::new(64, 128);
897        let _ = packer.pack(40, 30); // fills most of first shelf
898        let pos2 = packer.pack(40, 30); // must start new shelf
899        assert!(pos2.is_some());
900        assert_eq!(pos2.unwrap().0, 0); // starts at x=0
901        assert_eq!(pos2.unwrap().1, 30); // y = previous shelf height
902    }
903
904    #[test]
905    fn shelf_packer_overflow() {
906        let mut packer = ShelfPacker::new(64, 64);
907        let _ = packer.pack(64, 64); // fills entire atlas
908        let pos = packer.pack(10, 10); // should fail
909        assert!(pos.is_none());
910    }
911
912    #[test]
913    fn bilinear_center() {
914        let data = vec![0.0, 1.0, 0.0, 1.0];
915        let val = sample_bilinear_f32(&data, 2, 2, 0.5, 0.5);
916        assert!((val - 0.5).abs() < 0.01);
917    }
918
919    #[test]
920    fn fallback_sdf_nonzero() {
921        let config = SdfConfig { output_size: 16, spread: 4.0, ..SdfConfig::default() };
922        let glyph = generate_fallback_sdf(&config);
923        assert!(!glyph.pixels.is_empty());
924        // Center pixel should be close to 255 (deep inside).
925        let cx = glyph.width / 2;
926        let cy = glyph.height / 2;
927        let center = glyph.pixels[(cy * glyph.width + cx) as usize];
928        assert!(center > 100, "Center pixel should be > 100, got {}", center);
929    }
930}