Skip to main content

arcane_engine/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 (msdf-atlas-gen format).
331///
332/// Expected JSON format:
333/// ```json
334/// {
335///   "atlas": { "width": 256, "height": 256, "distanceRange": 4, "size": 32 },
336///   "metrics": { "lineHeight": 1.2 },
337///   "glyphs": [
338///     {
339///       "unicode": 65,
340///       "advance": 0.6,
341///       "atlasBounds": { "left": 0, "bottom": 32, "right": 24, "top": 0 },
342///       "planeBounds": { "left": 0, "bottom": -0.1, "right": 0.6, "top": 0.9 }
343///     }
344///   ]
345/// }
346/// ```
347pub fn parse_msdf_metrics(json: &str, texture_id: u32) -> Result<MsdfFont, String> {
348    // Minimal JSON parsing without external dependencies.
349    // We only need a few specific fields.
350
351    let atlas_width = extract_number(json, "\"width\"")
352        .ok_or("Missing atlas width")? as u32;
353    let atlas_height = extract_number(json, "\"height\"")
354        .ok_or("Missing atlas height")? as u32;
355    let distance_range = extract_number(json, "\"distanceRange\"")
356        .unwrap_or(4.0);
357    let font_size = extract_number(json, "\"size\"")
358        .unwrap_or(32.0);
359    let line_height_factor = extract_number(json, "\"lineHeight\"")
360        .unwrap_or(1.2);
361
362    let line_height = font_size * line_height_factor as f32;
363
364    let mut glyphs = HashMap::new();
365
366    // Parse glyph array
367    if let Some(glyphs_start) = json.find("\"glyphs\"") {
368        let rest = &json[glyphs_start..];
369        if let Some(arr_start) = rest.find('[') {
370            let arr_rest = &rest[arr_start + 1..];
371            // Split by glyph objects
372            let mut depth = 0i32;
373            let mut obj_start = None;
374
375            for (i, ch) in arr_rest.char_indices() {
376                match ch {
377                    '{' => {
378                        if depth == 0 {
379                            obj_start = Some(i);
380                        }
381                        depth += 1;
382                    }
383                    '}' => {
384                        depth -= 1;
385                        if depth == 0 {
386                            if let Some(start) = obj_start {
387                                let obj = &arr_rest[start..=i];
388                                if let Some(glyph) = parse_glyph_object(
389                                    obj,
390                                    atlas_width as f32,
391                                    atlas_height as f32,
392                                    font_size,
393                                ) {
394                                    glyphs.insert(glyph.0, glyph.1);
395                                }
396                            }
397                        }
398                    }
399                    ']' if depth == 0 => break,
400                    _ => {}
401                }
402            }
403        }
404    }
405
406    Ok(MsdfFont {
407        texture_id,
408        atlas_width,
409        atlas_height,
410        font_size,
411        line_height,
412        distance_range,
413        glyphs,
414    })
415}
416
417/// Parse a single glyph object from JSON.
418fn parse_glyph_object(
419    obj: &str,
420    atlas_w: f32,
421    atlas_h: f32,
422    font_size: f32,
423) -> Option<(u32, MsdfGlyph)> {
424    let unicode = extract_number(obj, "\"unicode\"")? as u32;
425    let advance = extract_number(obj, "\"advance\"").unwrap_or(0.0);
426
427    // Atlas bounds (pixel coordinates in the atlas)
428    let ab_left = extract_nested_number(obj, "\"atlasBounds\"", "\"left\"").unwrap_or(0.0);
429    let ab_bottom = extract_nested_number(obj, "\"atlasBounds\"", "\"bottom\"").unwrap_or(0.0);
430    let ab_right = extract_nested_number(obj, "\"atlasBounds\"", "\"right\"").unwrap_or(0.0);
431    let ab_top = extract_nested_number(obj, "\"atlasBounds\"", "\"top\"").unwrap_or(0.0);
432
433    // Plane bounds (em-space coordinates)
434    let pb_left = extract_nested_number(obj, "\"planeBounds\"", "\"left\"").unwrap_or(0.0);
435    let pb_bottom = extract_nested_number(obj, "\"planeBounds\"", "\"bottom\"").unwrap_or(0.0);
436    let pb_right = extract_nested_number(obj, "\"planeBounds\"", "\"right\"").unwrap_or(0.0);
437    let pb_top = extract_nested_number(obj, "\"planeBounds\"", "\"top\"").unwrap_or(0.0);
438
439    let uv_x = ab_left / atlas_w;
440    let uv_y = ab_top / atlas_h;
441    let uv_w = (ab_right - ab_left) / atlas_w;
442    let uv_h = (ab_bottom - ab_top) / atlas_h;
443
444    let glyph_w = (pb_right - pb_left) * font_size;
445    let glyph_h = (pb_top - pb_bottom) * font_size;
446
447    Some((
448        unicode,
449        MsdfGlyph {
450            uv_x,
451            uv_y,
452            uv_w,
453            uv_h,
454            advance: advance * font_size,
455            width: glyph_w,
456            height: glyph_h,
457            offset_x: pb_left * font_size,
458            offset_y: pb_top * font_size,
459        },
460    ))
461}
462
463/// Extract a number value after a JSON key. Simple pattern matching.
464fn extract_number(json: &str, key: &str) -> Option<f32> {
465    let key_pos = json.find(key)?;
466    let after_key = &json[key_pos + key.len()..];
467    // Skip colon and whitespace
468    let value_start = after_key.find(|c: char| c.is_ascii_digit() || c == '-' || c == '.')?;
469    let value_str = &after_key[value_start..];
470    let value_end = value_str
471        .find(|c: char| !c.is_ascii_digit() && c != '.' && c != '-' && c != 'e' && c != 'E' && c != '+')
472        .unwrap_or(value_str.len());
473    value_str[..value_end].parse::<f32>().ok()
474}
475
476/// Extract a number from a nested JSON object.
477fn extract_nested_number(json: &str, outer_key: &str, inner_key: &str) -> Option<f32> {
478    let outer_pos = json.find(outer_key)?;
479    let rest = &json[outer_pos..];
480    let brace_pos = rest.find('{')?;
481    let end_pos = rest[brace_pos..].find('}')? + brace_pos;
482    let inner = &rest[brace_pos..=end_pos];
483    extract_number(inner, inner_key)
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489
490    #[test]
491    fn builtin_msdf_font_generates() {
492        let (pixels, w, h, font) = generate_builtin_msdf_font();
493        assert_eq!(w, ATLAS_COLS * DST_GLYPH_W);
494        assert_eq!(h, ATLAS_ROWS * DST_GLYPH_H);
495        assert_eq!(pixels.len(), (w * h * 4) as usize);
496        assert_eq!(font.glyphs.len(), 96);
497        assert!(font.distance_range > 0.0);
498    }
499
500    #[test]
501    fn builtin_msdf_has_expected_glyphs() {
502        let (_, _, _, font) = generate_builtin_msdf_font();
503        // Space (32), A (65), z (122)
504        assert!(font.get_glyph(' ').is_some());
505        assert!(font.get_glyph('A').is_some());
506        assert!(font.get_glyph('z').is_some());
507        // Outside range
508        assert!(font.get_glyph('\x01').is_none());
509    }
510
511    #[test]
512    fn builtin_msdf_glyph_uvs_valid() {
513        let (_, _, _, font) = generate_builtin_msdf_font();
514        for glyph in font.glyphs.values() {
515            assert!(glyph.uv_x >= 0.0 && glyph.uv_x <= 1.0, "uv_x out of range");
516            assert!(glyph.uv_y >= 0.0 && glyph.uv_y <= 1.0, "uv_y out of range");
517            assert!(glyph.uv_w > 0.0 && glyph.uv_w <= 1.0, "uv_w out of range");
518            assert!(glyph.uv_h > 0.0 && glyph.uv_h <= 1.0, "uv_h out of range");
519        }
520    }
521
522    #[test]
523    fn builtin_msdf_distance_field_correctness() {
524        let (pixels, w, _h, _font) = generate_builtin_msdf_font();
525        // Check that the 'A' glyph has varying distance values (not all zero or all 255).
526        // 'A' = glyph 33 (65 - 32), at col=1 row=2 in the atlas.
527        // The CP437 font has thin 1-2px strokes, so interior distances are small.
528        // With distance_range=4.0, the normalized range is 0.5 +/- dist/(2*4).
529        // A pixel 1.0 units inside the stroke gives value ~0.5 + 1.0/8.0 = ~0.625 = ~159.
530        let glyph_x = 1 * DST_GLYPH_W;
531        let glyph_y = 2 * DST_GLYPH_H;
532
533        let mut has_outside = false;  // < 110 (clearly outside)
534        let mut has_edge = false;     // 110..170 (near edge, ~128 = on edge)
535        let mut has_inside = false;   // > 140 (inside the stroke)
536
537        for py in 0..DST_GLYPH_H {
538            for px in 0..DST_GLYPH_W {
539                let offset = (((glyph_y + py) * w + (glyph_x + px)) * 4) as usize;
540                let val = pixels[offset]; // R channel
541                if val < 110 { has_outside = true; }
542                if val > 110 && val < 170 { has_edge = true; }
543                if val > 140 { has_inside = true; }
544            }
545        }
546
547        assert!(has_outside, "'A' glyph should have outside distance values");
548        assert!(has_edge, "'A' glyph should have edge distance values");
549        assert!(has_inside, "'A' glyph should have inside distance values");
550    }
551
552    #[test]
553    fn space_glyph_is_outside() {
554        let (pixels, w, _h, _font) = generate_builtin_msdf_font();
555        // Space is glyph 0 at col=0, row=0. All source pixels are empty,
556        // so the SDF should be all "outside" (values < 128) in the padded center region.
557        let glyph_x = 0;
558        let glyph_y = 0;
559
560        // Center of the glyph (away from edges of neighboring glyphs)
561        let cx = glyph_x + DST_GLYPH_W / 2;
562        let cy = glyph_y + DST_GLYPH_H / 2;
563        let offset = ((cy * w + cx) * 4) as usize;
564        let val = pixels[offset];
565        assert!(val < 128, "Space center should be outside (val={val}, expected < 128)");
566    }
567
568    #[test]
569    fn measure_width_works() {
570        let (_, _, _, font) = generate_builtin_msdf_font();
571        let width = font.measure_width("Hello", 8.0);
572        // Each glyph has advance = 8.0, scale = 8.0/8.0 = 1.0, so 5 * 8 = 40
573        assert!((width - 40.0).abs() < 0.01, "Expected ~40, got {width}");
574    }
575
576    #[test]
577    fn measure_width_with_scale() {
578        let (_, _, _, font) = generate_builtin_msdf_font();
579        let width = font.measure_width("AB", 16.0);
580        // scale = 16/8 = 2, advance = 8, so 2 * 8 * 2 = 32
581        assert!((width - 32.0).abs() < 0.01, "Expected ~32, got {width}");
582    }
583
584    #[test]
585    fn parse_metrics_basic() {
586        let json = r#"{
587            "atlas": { "width": 256, "height": 256, "distanceRange": 4, "size": 32 },
588            "metrics": { "lineHeight": 1.2 },
589            "glyphs": [
590                {
591                    "unicode": 65,
592                    "advance": 0.6,
593                    "atlasBounds": { "left": 0, "bottom": 32, "right": 24, "top": 0 },
594                    "planeBounds": { "left": 0, "bottom": -0.1, "right": 0.6, "top": 0.9 }
595                }
596            ]
597        }"#;
598
599        let font = parse_msdf_metrics(json, 42).unwrap();
600        assert_eq!(font.texture_id, 42);
601        assert_eq!(font.atlas_width, 256);
602        assert_eq!(font.atlas_height, 256);
603        assert!((font.distance_range - 4.0).abs() < 0.01);
604        assert_eq!(font.glyphs.len(), 1);
605
606        let glyph = font.get_glyph('A').unwrap();
607        assert!((glyph.advance - 19.2).abs() < 0.1); // 0.6 * 32
608    }
609
610    #[test]
611    fn font_store_register_and_get() {
612        let mut store = MsdfFontStore::new();
613        let font = MsdfFont {
614            texture_id: 1,
615            atlas_width: 128,
616            atlas_height: 128,
617            font_size: 16.0,
618            line_height: 20.0,
619            distance_range: 4.0,
620            glyphs: HashMap::new(),
621        };
622        let id = store.register(font);
623        assert!(store.get(id).is_some());
624        assert!(store.get(id + 1).is_none());
625    }
626
627    #[test]
628    fn point_to_segment_distance() {
629        // Point at origin, segment from (1,0) to (1,1) -> distance = 1
630        let d = point_to_segment_dist_sq(0.0, 0.5, 1.0, 0.0, 1.0, 1.0);
631        assert!((d - 1.0).abs() < 0.001);
632
633        // Point on segment -> distance = 0
634        let d = point_to_segment_dist_sq(0.5, 0.0, 0.0, 0.0, 1.0, 0.0);
635        assert!(d < 0.001);
636    }
637}