Skip to main content

arcane_core/renderer/
msdf.rs

1/// MSDF (Multi-channel Signed Distance Field) font support.
2///
3/// MSDF fonts encode distance-to-edge information in RGB channels, enabling
4/// resolution-independent text rendering with crisp edges at any scale.
5/// Outlines and shadows are mathematical operations on the distance field.
6///
7/// Two font sources are supported:
8/// 1. Built-in: procedurally generated MSDF atlas from CP437 8x8 bitmap font data
9/// 2. External: loaded from a pre-generated MSDF atlas PNG + JSON glyph metrics
10///
11/// The MSDF shader computes median(R, G, B), then uses smoothstep for anti-aliased edges.
12
13use std::collections::HashMap;
14
15/// Glyph metrics for one character in an MSDF atlas.
16#[derive(Debug, Clone)]
17pub struct MsdfGlyph {
18    /// UV rectangle in the atlas (normalized 0-1).
19    pub uv_x: f32,
20    pub uv_y: f32,
21    pub uv_w: f32,
22    pub uv_h: f32,
23    /// Advance width in pixels (at size 1). How far to move the cursor after this glyph.
24    pub advance: f32,
25    /// Glyph width in pixels (at size 1).
26    pub width: f32,
27    /// Glyph height in pixels (at size 1).
28    pub height: f32,
29    /// Horizontal offset from cursor to glyph origin.
30    pub offset_x: f32,
31    /// Vertical offset from baseline to glyph top.
32    pub offset_y: f32,
33}
34
35/// MSDF font descriptor with atlas texture and glyph metrics.
36#[derive(Debug, Clone)]
37pub struct MsdfFont {
38    /// Texture ID of the MSDF atlas (assigned by the texture system).
39    pub texture_id: u32,
40    /// Atlas width in pixels.
41    pub atlas_width: u32,
42    /// Atlas height in pixels.
43    pub atlas_height: u32,
44    /// Font size the atlas was generated at (for scaling).
45    pub font_size: f32,
46    /// Line height in pixels (at size 1).
47    pub line_height: f32,
48    /// Distance field range in pixels (how many pixels the SDF extends).
49    pub distance_range: f32,
50    /// Glyph metrics indexed by Unicode codepoint.
51    pub glyphs: HashMap<u32, MsdfGlyph>,
52}
53
54impl MsdfFont {
55    /// Look up glyph metrics for a character.
56    pub fn get_glyph(&self, ch: char) -> Option<&MsdfGlyph> {
57        self.glyphs.get(&(ch as u32))
58    }
59
60    /// Measure the width of a text string at the given font size.
61    pub fn measure_width(&self, text: &str, font_size: f32) -> f32 {
62        let scale = font_size / self.font_size;
63        let mut width = 0.0f32;
64        for ch in text.chars() {
65            if let Some(glyph) = self.get_glyph(ch) {
66                width += glyph.advance * scale;
67            }
68        }
69        width
70    }
71}
72
73/// MSDF font storage, keyed by a font ID.
74#[derive(Clone)]
75pub struct MsdfFontStore {
76    fonts: HashMap<u32, MsdfFont>,
77    next_id: u32,
78}
79
80impl MsdfFontStore {
81    pub fn new() -> Self {
82        Self {
83            fonts: HashMap::new(),
84            next_id: 1,
85        }
86    }
87
88    /// Register a font and return its ID.
89    pub fn register(&mut self, font: MsdfFont) -> u32 {
90        let id = self.next_id;
91        self.next_id += 1;
92        self.fonts.insert(id, font);
93        id
94    }
95
96    /// Register a font with a specific ID (for bridge-assigned IDs).
97    pub fn register_with_id(&mut self, id: u32, font: MsdfFont) {
98        self.fonts.insert(id, font);
99        if id >= self.next_id {
100            self.next_id = id + 1;
101        }
102    }
103
104    /// Get a font by ID.
105    pub fn get(&self, id: u32) -> Option<&MsdfFont> {
106        self.fonts.get(&id)
107    }
108}
109
110/// The MSDF fragment shader source (used with ShaderStore::create).
111/// This is the fragment portion only; the standard vertex preamble and
112/// ShaderParams uniform are prepended by the ShaderStore automatically.
113pub const MSDF_FRAGMENT_SOURCE: &str = include_str!("shaders/msdf.wgsl");
114
115// ============================================================================
116// Built-in MSDF font generation from CP437 bitmap data
117// ============================================================================
118
119/// Distance field padding (pixels around each glyph in the atlas).
120const SDF_PAD: u32 = 4;
121/// Glyph cell size in the source bitmap.
122const SRC_GLYPH_W: u32 = 8;
123const SRC_GLYPH_H: u32 = 8;
124/// Output glyph cell size in the MSDF atlas (source + 2*padding).
125const DST_GLYPH_W: u32 = SRC_GLYPH_W + 2 * SDF_PAD;
126const DST_GLYPH_H: u32 = SRC_GLYPH_H + 2 * SDF_PAD;
127/// Atlas layout: 16 columns x 6 rows = 96 glyphs (ASCII 32-127).
128const ATLAS_COLS: u32 = 16;
129const ATLAS_ROWS: u32 = 6;
130/// Distance field range in pixels.
131const DIST_RANGE: f32 = 4.0;
132
133/// Generate a built-in MSDF font from the CP437 8x8 bitmap data.
134///
135/// Returns `(rgba_pixels, width, height, MsdfFont)` where:
136/// - `rgba_pixels` is the MSDF atlas texture (R, G, B encode distance field, A = 255)
137/// - `width`, `height` are the atlas dimensions
138/// - `MsdfFont` contains the glyph metrics (texture_id will be 0; caller must set it)
139pub fn generate_builtin_msdf_font() -> (Vec<u8>, u32, u32, MsdfFont) {
140    let atlas_w = ATLAS_COLS * DST_GLYPH_W;
141    let atlas_h = ATLAS_ROWS * DST_GLYPH_H;
142
143    // First, decode the bitmap font into a boolean grid
144    let font_data = super::font::generate_builtin_font();
145    let (bmp_pixels, bmp_w, _bmp_h) = font_data;
146
147    let mut atlas_pixels = vec![0u8; (atlas_w * atlas_h * 4) as usize];
148    let mut glyphs = HashMap::new();
149
150    for glyph_idx in 0..96u32 {
151        let src_col = glyph_idx % 16;
152        let src_row = glyph_idx / 16;
153        let src_base_x = src_col * SRC_GLYPH_W;
154        let src_base_y = src_row * SRC_GLYPH_H;
155
156        let dst_col = glyph_idx % ATLAS_COLS;
157        let dst_row = glyph_idx / ATLAS_COLS;
158        let dst_base_x = dst_col * DST_GLYPH_W;
159        let dst_base_y = dst_row * DST_GLYPH_H;
160
161        // Extract source bitmap as boolean array
162        let mut src_bits = [[false; SRC_GLYPH_W as usize]; SRC_GLYPH_H as usize];
163        for py in 0..SRC_GLYPH_H {
164            for px in 0..SRC_GLYPH_W {
165                let bmp_offset =
166                    (((src_base_y + py) * bmp_w + (src_base_x + px)) * 4 + 3) as usize;
167                src_bits[py as usize][px as usize] = bmp_pixels[bmp_offset] > 0;
168            }
169        }
170
171        // For each pixel in the destination cell, compute signed distance
172        for dy in 0..DST_GLYPH_H {
173            for dx in 0..DST_GLYPH_W {
174                // Position relative to source glyph (accounting for padding)
175                let sx = dx as f32 - SDF_PAD as f32 + 0.5;
176                let sy = dy as f32 - SDF_PAD as f32 + 0.5;
177
178                // Compute signed distance to nearest edge
179                let dist = compute_signed_distance(&src_bits, sx, sy);
180
181                // Normalize distance to 0-1 range (0.5 = on edge)
182                let normalized = 0.5 + dist / (2.0 * DIST_RANGE);
183                let clamped = normalized.clamp(0.0, 1.0);
184                let byte_val = (clamped * 255.0) as u8;
185
186                let out_x = dst_base_x + dx;
187                let out_y = dst_base_y + dy;
188                let offset = ((out_y * atlas_w + out_x) * 4) as usize;
189
190                // For a pseudo-MSDF, encode the same distance in all 3 channels.
191                // True MSDF would use different edge segments per channel, but for
192                // the CP437 bitmap font this gives excellent results with the
193                // median shader.
194                atlas_pixels[offset] = byte_val;     // R
195                atlas_pixels[offset + 1] = byte_val; // G
196                atlas_pixels[offset + 2] = byte_val; // B
197                atlas_pixels[offset + 3] = 255;      // A (always opaque)
198            }
199        }
200
201        // Build glyph metrics
202        let char_code = glyph_idx + 32; // ASCII 32-127
203        glyphs.insert(
204            char_code,
205            MsdfGlyph {
206                uv_x: dst_base_x as f32 / atlas_w as f32,
207                uv_y: dst_base_y as f32 / atlas_h as f32,
208                uv_w: DST_GLYPH_W as f32 / atlas_w as f32,
209                uv_h: DST_GLYPH_H as f32 / atlas_h as f32,
210                advance: SRC_GLYPH_W as f32,
211                width: SRC_GLYPH_W as f32,
212                height: SRC_GLYPH_H as f32,
213                offset_x: 0.0,
214                offset_y: 0.0,
215            },
216        );
217    }
218
219    let font = MsdfFont {
220        texture_id: 0, // caller sets this
221        atlas_width: atlas_w,
222        atlas_height: atlas_h,
223        font_size: SRC_GLYPH_H as f32,
224        line_height: SRC_GLYPH_H as f32,
225        distance_range: DIST_RANGE,
226        glyphs,
227    };
228
229    (atlas_pixels, atlas_w, atlas_h, font)
230}
231
232/// Compute the signed distance from point (sx, sy) to the nearest edge in the bitmap.
233/// Positive = inside the glyph, negative = outside.
234fn compute_signed_distance(bits: &[[bool; 8]; 8], sx: f32, sy: f32) -> f32 {
235    let w = SRC_GLYPH_W as i32;
236    let h = SRC_GLYPH_H as i32;
237
238    // Determine if the sample point is inside or outside
239    let ix = sx.floor() as i32;
240    let iy = sy.floor() as i32;
241    let inside = if ix >= 0 && ix < w && iy >= 0 && iy < h {
242        bits[iy as usize][ix as usize]
243    } else {
244        false
245    };
246
247    // Find minimum distance to any edge transition
248    let mut min_dist_sq = f32::MAX;
249
250    // Check distance to every pixel boundary that represents an edge
251    // An edge exists between adjacent pixels where one is filled and one is empty
252    for py in -1..=h {
253        for px in -1..=w {
254            let is_filled = if px >= 0 && px < w && py >= 0 && py < h {
255                bits[py as usize][px as usize]
256            } else {
257                false
258            };
259
260            // Check right neighbor
261            let right_filled = if (px + 1) >= 0 && (px + 1) < w && py >= 0 && py < h {
262                bits[py as usize][(px + 1) as usize]
263            } else {
264                false
265            };
266
267            if is_filled != right_filled {
268                // Vertical edge at x = px+1
269                let edge_x = (px + 1) as f32;
270                let edge_y_min = py as f32;
271                let edge_y_max = (py + 1) as f32;
272                let dist_sq = point_to_segment_dist_sq(
273                    sx, sy, edge_x, edge_y_min, edge_x, edge_y_max,
274                );
275                if dist_sq < min_dist_sq {
276                    min_dist_sq = dist_sq;
277                }
278            }
279
280            // Check bottom neighbor
281            let bottom_filled = if px >= 0 && px < w && (py + 1) >= 0 && (py + 1) < h {
282                bits[(py + 1) as usize][px as usize]
283            } else {
284                false
285            };
286
287            if is_filled != bottom_filled {
288                // Horizontal edge at y = py+1
289                let edge_y = (py + 1) as f32;
290                let edge_x_min = px as f32;
291                let edge_x_max = (px + 1) as f32;
292                let dist_sq = point_to_segment_dist_sq(
293                    sx, sy, edge_x_min, edge_y, edge_x_max, edge_y,
294                );
295                if dist_sq < min_dist_sq {
296                    min_dist_sq = dist_sq;
297                }
298            }
299        }
300    }
301
302    let dist = min_dist_sq.sqrt();
303    if inside { dist } else { -dist }
304}
305
306/// Squared distance from point (px, py) to line segment (x1, y1)-(x2, y2).
307fn point_to_segment_dist_sq(px: f32, py: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
308    let dx = x2 - x1;
309    let dy = y2 - y1;
310    let len_sq = dx * dx + dy * dy;
311
312    if len_sq < 1e-10 {
313        // Degenerate segment (point)
314        let ex = px - x1;
315        let ey = py - y1;
316        return ex * ex + ey * ey;
317    }
318
319    let t = ((px - x1) * dx + (py - y1) * dy) / len_sq;
320    let t = t.clamp(0.0, 1.0);
321
322    let closest_x = x1 + t * dx;
323    let closest_y = y1 + t * dy;
324
325    let ex = px - closest_x;
326    let ey = py - closest_y;
327    ex * ex + ey * ey
328}
329
330/// Parse MSDF font metrics from a JSON string.
331///
332/// Supports two formats:
333/// 1. msdf-bmfont format (has "chars" array)
334/// 2. msdf-atlas-gen format (has "glyphs" array)
335pub fn parse_msdf_metrics(json: &str, texture_id: u32) -> Result<MsdfFont, String> {
336    // Detect format: msdf-bmfont uses "chars", msdf-atlas-gen uses "glyphs"
337    if json.contains("\"chars\"") {
338        parse_msdf_bmfont_format(json, texture_id)
339    } else {
340        parse_msdf_atlas_gen_format(json, texture_id)
341    }
342}
343
344/// Parse msdf-bmfont format (from msdf-bmfont-xml npm package).
345fn parse_msdf_bmfont_format(json: &str, texture_id: u32) -> Result<MsdfFont, String> {
346    // common.scaleW, common.scaleH = atlas dimensions
347    let atlas_width = extract_nested_number(json, "\"common\"", "\"scaleW\"")
348        .ok_or("Missing common.scaleW")? as u32;
349    let atlas_height = extract_nested_number(json, "\"common\"", "\"scaleH\"")
350        .ok_or("Missing common.scaleH")? as u32;
351
352    // info.size = font size
353    let font_size = extract_nested_number(json, "\"info\"", "\"size\"")
354        .unwrap_or(32.0);
355
356    // distanceField.distanceRange
357    let distance_range = extract_nested_number(json, "\"distanceField\"", "\"distanceRange\"")
358        .unwrap_or(4.0);
359
360    // common.lineHeight (absolute value in pixels)
361    let line_height = extract_nested_number(json, "\"common\"", "\"lineHeight\"")
362        .unwrap_or(font_size * 1.2);
363
364    let mut glyphs = HashMap::new();
365
366    // Parse chars array
367    if let Some(chars_start) = json.find("\"chars\"") {
368        let rest = &json[chars_start..];
369        if let Some(arr_start) = rest.find('[') {
370            let arr_rest = &rest[arr_start + 1..];
371            let mut depth = 0i32;
372            let mut obj_start = None;
373
374            for (i, ch) in arr_rest.char_indices() {
375                match ch {
376                    '{' => {
377                        if depth == 0 {
378                            obj_start = Some(i);
379                        }
380                        depth += 1;
381                    }
382                    '}' => {
383                        depth -= 1;
384                        if depth == 0 {
385                            if let Some(start) = obj_start {
386                                let obj = &arr_rest[start..=i];
387                                if let Some(glyph) = parse_bmfont_char(
388                                    obj,
389                                    atlas_width as f32,
390                                    atlas_height as f32,
391                                ) {
392                                    glyphs.insert(glyph.0, glyph.1);
393                                }
394                            }
395                        }
396                    }
397                    ']' if depth == 0 => break,
398                    _ => {}
399                }
400            }
401        }
402    }
403
404    Ok(MsdfFont {
405        texture_id,
406        atlas_width,
407        atlas_height,
408        font_size,
409        line_height,
410        distance_range,
411        glyphs,
412    })
413}
414
415/// Parse a single char object from msdf-bmfont format.
416fn parse_bmfont_char(obj: &str, atlas_w: f32, atlas_h: f32) -> Option<(u32, MsdfGlyph)> {
417    let id = extract_number(obj, "\"id\"")? as u32;
418    let x = extract_number(obj, "\"x\"").unwrap_or(0.0);
419    let y = extract_number(obj, "\"y\"").unwrap_or(0.0);
420    let width = extract_number(obj, "\"width\"").unwrap_or(0.0);
421    let height = extract_number(obj, "\"height\"").unwrap_or(0.0);
422    let xoffset = extract_number(obj, "\"xoffset\"").unwrap_or(0.0);
423    let yoffset = extract_number(obj, "\"yoffset\"").unwrap_or(0.0);
424    let xadvance = extract_number(obj, "\"xadvance\"").unwrap_or(width);
425
426    Some((
427        id,
428        MsdfGlyph {
429            uv_x: x / atlas_w,
430            uv_y: y / atlas_h,
431            uv_w: width / atlas_w,
432            uv_h: height / atlas_h,
433            advance: xadvance,
434            width,
435            height,
436            offset_x: xoffset,
437            offset_y: yoffset,
438        },
439    ))
440}
441
442/// Parse msdf-atlas-gen format.
443fn parse_msdf_atlas_gen_format(json: &str, texture_id: u32) -> Result<MsdfFont, String> {
444    let atlas_width = extract_number(json, "\"width\"")
445        .ok_or("Missing atlas width")? as u32;
446    let atlas_height = extract_number(json, "\"height\"")
447        .ok_or("Missing atlas height")? as u32;
448    let distance_range = extract_number(json, "\"distanceRange\"")
449        .unwrap_or(4.0);
450    let font_size = extract_number(json, "\"size\"")
451        .unwrap_or(32.0);
452    let line_height_factor = extract_number(json, "\"lineHeight\"")
453        .unwrap_or(1.2);
454
455    let line_height = font_size * line_height_factor as f32;
456
457    let mut glyphs = HashMap::new();
458
459    // Parse glyph array
460    if let Some(glyphs_start) = json.find("\"glyphs\"") {
461        let rest = &json[glyphs_start..];
462        if let Some(arr_start) = rest.find('[') {
463            let arr_rest = &rest[arr_start + 1..];
464            let mut depth = 0i32;
465            let mut obj_start = None;
466
467            for (i, ch) in arr_rest.char_indices() {
468                match ch {
469                    '{' => {
470                        if depth == 0 {
471                            obj_start = Some(i);
472                        }
473                        depth += 1;
474                    }
475                    '}' => {
476                        depth -= 1;
477                        if depth == 0 {
478                            if let Some(start) = obj_start {
479                                let obj = &arr_rest[start..=i];
480                                if let Some(glyph) = parse_glyph_object(
481                                    obj,
482                                    atlas_width as f32,
483                                    atlas_height as f32,
484                                    font_size,
485                                ) {
486                                    glyphs.insert(glyph.0, glyph.1);
487                                }
488                            }
489                        }
490                    }
491                    ']' if depth == 0 => break,
492                    _ => {}
493                }
494            }
495        }
496    }
497
498    Ok(MsdfFont {
499        texture_id,
500        atlas_width,
501        atlas_height,
502        font_size,
503        line_height,
504        distance_range,
505        glyphs,
506    })
507}
508
509/// Parse a single glyph object from JSON.
510fn parse_glyph_object(
511    obj: &str,
512    atlas_w: f32,
513    atlas_h: f32,
514    font_size: f32,
515) -> Option<(u32, MsdfGlyph)> {
516    let unicode = extract_number(obj, "\"unicode\"")? as u32;
517    let advance = extract_number(obj, "\"advance\"").unwrap_or(0.0);
518
519    // Atlas bounds (pixel coordinates in the atlas)
520    let ab_left = extract_nested_number(obj, "\"atlasBounds\"", "\"left\"").unwrap_or(0.0);
521    let ab_bottom = extract_nested_number(obj, "\"atlasBounds\"", "\"bottom\"").unwrap_or(0.0);
522    let ab_right = extract_nested_number(obj, "\"atlasBounds\"", "\"right\"").unwrap_or(0.0);
523    let ab_top = extract_nested_number(obj, "\"atlasBounds\"", "\"top\"").unwrap_or(0.0);
524
525    // Plane bounds (em-space coordinates)
526    let pb_left = extract_nested_number(obj, "\"planeBounds\"", "\"left\"").unwrap_or(0.0);
527    let pb_bottom = extract_nested_number(obj, "\"planeBounds\"", "\"bottom\"").unwrap_or(0.0);
528    let pb_right = extract_nested_number(obj, "\"planeBounds\"", "\"right\"").unwrap_or(0.0);
529    let pb_top = extract_nested_number(obj, "\"planeBounds\"", "\"top\"").unwrap_or(0.0);
530
531    let uv_x = ab_left / atlas_w;
532    let uv_y = ab_top / atlas_h;
533    let uv_w = (ab_right - ab_left) / atlas_w;
534    let uv_h = (ab_bottom - ab_top) / atlas_h;
535
536    let glyph_w = (pb_right - pb_left) * font_size;
537    let glyph_h = (pb_top - pb_bottom) * font_size;
538
539    Some((
540        unicode,
541        MsdfGlyph {
542            uv_x,
543            uv_y,
544            uv_w,
545            uv_h,
546            advance: advance * font_size,
547            width: glyph_w,
548            height: glyph_h,
549            offset_x: pb_left * font_size,
550            offset_y: pb_top * font_size,
551        },
552    ))
553}
554
555/// Extract a number value after a JSON key. Simple pattern matching.
556fn extract_number(json: &str, key: &str) -> Option<f32> {
557    let key_pos = json.find(key)?;
558    let after_key = &json[key_pos + key.len()..];
559    // Skip colon and whitespace
560    let value_start = after_key.find(|c: char| c.is_ascii_digit() || c == '-' || c == '.')?;
561    let value_str = &after_key[value_start..];
562    let value_end = value_str
563        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+')
564        .unwrap_or(value_str.len());
565    value_str[..value_end].parse::<f32>().ok()
566}
567
568/// Extract a number from a nested JSON object.
569fn extract_nested_number(json: &str, outer_key: &str, inner_key: &str) -> Option<f32> {
570    let outer_pos = json.find(outer_key)?;
571    let rest = &json[outer_pos..];
572    let brace_pos = rest.find('{')?;
573    let end_pos = rest[brace_pos..].find('}')? + brace_pos;
574    let inner = &rest[brace_pos..=end_pos];
575    extract_number(inner, inner_key)
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn builtin_msdf_font_generates() {
584        let (pixels, w, h, font) = generate_builtin_msdf_font();
585        assert_eq!(w, ATLAS_COLS * DST_GLYPH_W);
586        assert_eq!(h, ATLAS_ROWS * DST_GLYPH_H);
587        assert_eq!(pixels.len(), (w * h * 4) as usize);
588        assert_eq!(font.glyphs.len(), 96);
589        assert!(font.distance_range > 0.0);
590    }
591
592    #[test]
593    fn builtin_msdf_has_expected_glyphs() {
594        let (_, _, _, font) = generate_builtin_msdf_font();
595        // Space (32), A (65), z (122)
596        assert!(font.get_glyph(' ').is_some());
597        assert!(font.get_glyph('A').is_some());
598        assert!(font.get_glyph('z').is_some());
599        // Outside range
600        assert!(font.get_glyph('\x01').is_none());
601    }
602
603    #[test]
604    fn builtin_msdf_glyph_uvs_valid() {
605        let (_, _, _, font) = generate_builtin_msdf_font();
606        for glyph in font.glyphs.values() {
607            assert!(glyph.uv_x >= 0.0 && glyph.uv_x <= 1.0, "uv_x out of range");
608            assert!(glyph.uv_y >= 0.0 && glyph.uv_y <= 1.0, "uv_y out of range");
609            assert!(glyph.uv_w > 0.0 && glyph.uv_w <= 1.0, "uv_w out of range");
610            assert!(glyph.uv_h > 0.0 && glyph.uv_h <= 1.0, "uv_h out of range");
611        }
612    }
613
614    #[test]
615    fn builtin_msdf_distance_field_correctness() {
616        let (pixels, w, _h, _font) = generate_builtin_msdf_font();
617        // Check that the 'A' glyph has varying distance values (not all zero or all 255).
618        // 'A' = glyph 33 (65 - 32), at col=1 row=2 in the atlas.
619        // The CP437 font has thin 1-2px strokes, so interior distances are small.
620        // With distance_range=4.0, the normalized range is 0.5 +/- dist/(2*4).
621        // A pixel 1.0 units inside the stroke gives value ~0.5 + 1.0/8.0 = ~0.625 = ~159.
622        let glyph_x = 1 * DST_GLYPH_W;
623        let glyph_y = 2 * DST_GLYPH_H;
624
625        let mut has_outside = false;  // < 110 (clearly outside)
626        let mut has_edge = false;     // 110..170 (near edge, ~128 = on edge)
627        let mut has_inside = false;   // > 140 (inside the stroke)
628
629        for py in 0..DST_GLYPH_H {
630            for px in 0..DST_GLYPH_W {
631                let offset = (((glyph_y + py) * w + (glyph_x + px)) * 4) as usize;
632                let val = pixels[offset]; // R channel
633                if val < 110 { has_outside = true; }
634                if val > 110 && val < 170 { has_edge = true; }
635                if val > 140 { has_inside = true; }
636            }
637        }
638
639        assert!(has_outside, "'A' glyph should have outside distance values");
640        assert!(has_edge, "'A' glyph should have edge distance values");
641        assert!(has_inside, "'A' glyph should have inside distance values");
642    }
643
644    #[test]
645    fn space_glyph_is_outside() {
646        let (pixels, w, _h, _font) = generate_builtin_msdf_font();
647        // Space is glyph 0 at col=0, row=0. All source pixels are empty,
648        // so the SDF should be all "outside" (values < 128) in the padded center region.
649        let glyph_x = 0;
650        let glyph_y = 0;
651
652        // Center of the glyph (away from edges of neighboring glyphs)
653        let cx = glyph_x + DST_GLYPH_W / 2;
654        let cy = glyph_y + DST_GLYPH_H / 2;
655        let offset = ((cy * w + cx) * 4) as usize;
656        let val = pixels[offset];
657        assert!(val < 128, "Space center should be outside (val={val}, expected < 128)");
658    }
659
660    #[test]
661    fn measure_width_works() {
662        let (_, _, _, font) = generate_builtin_msdf_font();
663        let width = font.measure_width("Hello", 8.0);
664        // Each glyph has advance = 8.0, scale = 8.0/8.0 = 1.0, so 5 * 8 = 40
665        assert!((width - 40.0).abs() < 0.01, "Expected ~40, got {width}");
666    }
667
668    #[test]
669    fn measure_width_with_scale() {
670        let (_, _, _, font) = generate_builtin_msdf_font();
671        let width = font.measure_width("AB", 16.0);
672        // scale = 16/8 = 2, advance = 8, so 2 * 8 * 2 = 32
673        assert!((width - 32.0).abs() < 0.01, "Expected ~32, got {width}");
674    }
675
676    #[test]
677    fn parse_metrics_basic() {
678        let json = r#"{
679            "atlas": { "width": 256, "height": 256, "distanceRange": 4, "size": 32 },
680            "metrics": { "lineHeight": 1.2 },
681            "glyphs": [
682                {
683                    "unicode": 65,
684                    "advance": 0.6,
685                    "atlasBounds": { "left": 0, "bottom": 32, "right": 24, "top": 0 },
686                    "planeBounds": { "left": 0, "bottom": -0.1, "right": 0.6, "top": 0.9 }
687                }
688            ]
689        }"#;
690
691        let font = parse_msdf_metrics(json, 42).unwrap();
692        assert_eq!(font.texture_id, 42);
693        assert_eq!(font.atlas_width, 256);
694        assert_eq!(font.atlas_height, 256);
695        assert!((font.distance_range - 4.0).abs() < 0.01);
696        assert_eq!(font.glyphs.len(), 1);
697
698        let glyph = font.get_glyph('A').unwrap();
699        assert!((glyph.advance - 19.2).abs() < 0.1); // 0.6 * 32
700    }
701
702    #[test]
703    fn font_store_register_and_get() {
704        let mut store = MsdfFontStore::new();
705        let font = MsdfFont {
706            texture_id: 1,
707            atlas_width: 128,
708            atlas_height: 128,
709            font_size: 16.0,
710            line_height: 20.0,
711            distance_range: 4.0,
712            glyphs: HashMap::new(),
713        };
714        let id = store.register(font);
715        assert!(store.get(id).is_some());
716        assert!(store.get(id + 1).is_none());
717    }
718
719    #[test]
720    fn point_to_segment_distance() {
721        // Point at origin, segment from (1,0) to (1,1) -> distance = 1
722        let d = point_to_segment_dist_sq(0.0, 0.5, 1.0, 0.0, 1.0, 1.0);
723        assert!((d - 1.0).abs() < 0.001);
724
725        // Point on segment -> distance = 0
726        let d = point_to_segment_dist_sq(0.5, 0.0, 0.0, 0.0, 1.0, 0.0);
727        assert!(d < 0.001);
728    }
729}