Skip to main content

oxitext_sdf/
msdf.rs

1//! Multi-channel signed distance field (MSDF) generation.
2//!
3//! Implements the Chlumsky MSDF algorithm: glyph outlines are extracted via
4//! `ttf-parser`, edges are assigned RGB channel colors, and per-pixel
5//! min-distances are computed per channel to produce a 3-channel SDF.
6
7/// Bitmask enum for edge color assignment.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
9pub struct EdgeColor(pub u8);
10
11impl EdgeColor {
12    /// Red channel only.
13    pub const RED: Self = Self(1);
14    /// Green channel only.
15    pub const GREEN: Self = Self(2);
16    /// Blue channel only.
17    pub const BLUE: Self = Self(4);
18    /// Red + Green (Yellow).
19    pub const YELLOW: Self = Self(3);
20    /// Green + Blue (Cyan).
21    pub const CYAN: Self = Self(6);
22    /// Red + Blue (Magenta).
23    pub const MAGENTA: Self = Self(5);
24    /// All channels (White).
25    pub const WHITE: Self = Self(7);
26
27    /// Returns `true` if the red channel is set.
28    pub fn has_red(self) -> bool {
29        self.0 & 1 != 0
30    }
31
32    /// Returns `true` if the green channel is set.
33    pub fn has_green(self) -> bool {
34        self.0 & 2 != 0
35    }
36
37    /// Returns `true` if the blue channel is set.
38    pub fn has_blue(self) -> bool {
39        self.0 & 4 != 0
40    }
41}
42
43/// A 2D point in shape (font) space.
44#[derive(Debug, Clone, Copy)]
45pub struct Point {
46    /// X coordinate.
47    pub x: f64,
48    /// Y coordinate.
49    pub y: f64,
50}
51
52/// A glyph outline segment.
53#[derive(Debug, Clone, Copy)]
54pub enum Segment {
55    /// Straight line from `p0` to `p1`.
56    Line(Point, Point),
57    /// Quadratic Bezier: `p0` (start), `ctrl` (control), `p1` (end).
58    Quad(Point, Point, Point),
59    /// Cubic Bezier: `p0` (start), `c1`, `c2` (controls), `p1` (end).
60    Cubic(Point, Point, Point, Point),
61}
62
63/// A segment with its assigned MSDF edge color.
64#[derive(Debug, Clone)]
65pub struct ColoredSegment {
66    /// The underlying geometry.
67    pub segment: Segment,
68    /// Assigned channel color for this edge.
69    pub color: EdgeColor,
70}
71
72/// A closed contour made up of colored segments.
73#[derive(Debug, Clone, Default)]
74pub struct Contour {
75    /// Segments forming this contour, in order.
76    pub segments: Vec<ColoredSegment>,
77}
78
79/// A full glyph shape composed of one or more contours.
80#[derive(Debug, Clone)]
81pub struct GlyphShape {
82    /// Closed contours forming the glyph outline.
83    pub contours: Vec<Contour>,
84    /// Font design units per em.
85    pub units_per_em: f32,
86}
87
88/// A rendered MSDF tile for a single glyph.
89#[derive(Debug, Clone)]
90pub struct MsdfTile {
91    /// Glyph ID within the font.
92    pub glyph_id: u16,
93    /// Tile width in pixels.
94    pub width: u32,
95    /// Tile height in pixels.
96    pub height: u32,
97    /// MSDF pixel data: `width * height * 3` bytes (RGB, 3 bytes per pixel).
98    pub data: Vec<u8>,
99    /// Left bearing in pixels.
100    pub bearing_x: f32,
101    /// Top bearing in pixels.
102    pub bearing_y: f32,
103    /// Horizontal advance in pixels.
104    pub advance_x: f32,
105}
106
107// ─── Outline extraction ───────────────────────────────────────────────────────
108
109/// Collects ttf-parser outline callbacks into a `GlyphShape`.
110pub struct OutlineCollector {
111    shape: GlyphShape,
112    current: Option<Contour>,
113    current_pos: Point,
114}
115
116impl OutlineCollector {
117    /// Creates a new collector for a font with the given design-units-per-em.
118    pub fn new(units_per_em: f32) -> Self {
119        Self {
120            shape: GlyphShape {
121                contours: Vec::new(),
122                units_per_em,
123            },
124            current: None,
125            current_pos: Point { x: 0.0, y: 0.0 },
126        }
127    }
128
129    /// Finalises the shape and returns it (flushes any open contour).
130    pub fn finish(mut self) -> GlyphShape {
131        if let Some(c) = self.current.take() {
132            if !c.segments.is_empty() {
133                self.shape.contours.push(c);
134            }
135        }
136        self.shape
137    }
138
139    /// Pushes a colored segment onto the current contour, creating it if needed.
140    fn push_segment(&mut self, segment: Segment) {
141        let color = EdgeColor::WHITE; // will be overwritten by color_edges
142        self.current
143            .get_or_insert_with(Contour::default)
144            .segments
145            .push(ColoredSegment { segment, color });
146    }
147}
148
149impl ttf_parser::OutlineBuilder for OutlineCollector {
150    fn move_to(&mut self, x: f32, y: f32) {
151        // Close any open contour before starting a new one.
152        if let Some(c) = self.current.take() {
153            if !c.segments.is_empty() {
154                self.shape.contours.push(c);
155            }
156        }
157        self.current = Some(Contour::default());
158        self.current_pos = Point {
159            x: x as f64,
160            y: y as f64,
161        };
162    }
163
164    fn line_to(&mut self, x: f32, y: f32) {
165        let p1 = Point {
166            x: x as f64,
167            y: y as f64,
168        };
169        let p0 = self.current_pos;
170        self.push_segment(Segment::Line(p0, p1));
171        self.current_pos = p1;
172    }
173
174    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
175        let ctrl = Point {
176            x: x1 as f64,
177            y: y1 as f64,
178        };
179        let p1 = Point {
180            x: x as f64,
181            y: y as f64,
182        };
183        let p0 = self.current_pos;
184        self.push_segment(Segment::Quad(p0, ctrl, p1));
185        self.current_pos = p1;
186    }
187
188    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
189        let c1 = Point {
190            x: x1 as f64,
191            y: y1 as f64,
192        };
193        let c2 = Point {
194            x: x2 as f64,
195            y: y2 as f64,
196        };
197        let p1 = Point {
198            x: x as f64,
199            y: y as f64,
200        };
201        let p0 = self.current_pos;
202        self.push_segment(Segment::Cubic(p0, c1, c2, p1));
203        self.current_pos = p1;
204    }
205
206    fn close(&mut self) {
207        if let Some(c) = self.current.take() {
208            if !c.segments.is_empty() {
209                self.shape.contours.push(c);
210            }
211        }
212    }
213}
214
215/// Extracts a `GlyphShape` from raw font bytes for the given glyph ID.
216///
217/// Returns `None` if the font cannot be parsed, the glyph has no outline, or
218/// the glyph is a whitespace/blank (no contours).
219pub fn extract_glyph_shape(face_data: &[u8], glyph_id: u16) -> Option<GlyphShape> {
220    let face = ttf_parser::Face::parse(face_data, 0).ok()?;
221    let gid = ttf_parser::GlyphId(glyph_id);
222    let units_per_em = face.units_per_em() as f32;
223    let mut collector = OutlineCollector::new(units_per_em);
224    face.outline_glyph(gid, &mut collector)?;
225    Some(collector.finish())
226}
227
228// ─── Edge coloring (Chlumsky algorithm) ──────────────────────────────────────
229
230/// Normalises a vector; returns `(1,0)` for near-zero input.
231fn normalize(p: Point) -> Point {
232    let len = (p.x * p.x + p.y * p.y).sqrt();
233    if len < 1e-10 {
234        Point { x: 1.0, y: 0.0 }
235    } else {
236        Point {
237            x: p.x / len,
238            y: p.y / len,
239        }
240    }
241}
242
243/// Returns the unit tangent at the *start* of `seg`.
244fn segment_start_tangent(seg: &Segment) -> Point {
245    match seg {
246        Segment::Line(p0, p1) => normalize(Point {
247            x: p1.x - p0.x,
248            y: p1.y - p0.y,
249        }),
250        Segment::Quad(p0, p1, _) => normalize(Point {
251            x: p1.x - p0.x,
252            y: p1.y - p0.y,
253        }),
254        Segment::Cubic(p0, p1, _, _) => normalize(Point {
255            x: p1.x - p0.x,
256            y: p1.y - p0.y,
257        }),
258    }
259}
260
261/// Returns the unit tangent at the *end* of `seg`.
262fn segment_end_tangent(seg: &Segment) -> Point {
263    match seg {
264        Segment::Line(p0, p1) => normalize(Point {
265            x: p1.x - p0.x,
266            y: p1.y - p0.y,
267        }),
268        Segment::Quad(_, p1, p2) => normalize(Point {
269            x: p2.x - p1.x,
270            y: p2.y - p1.y,
271        }),
272        Segment::Cubic(_, _, p2, p3) => normalize(Point {
273            x: p3.x - p2.x,
274            y: p3.y - p2.y,
275        }),
276    }
277}
278
279/// Assigns RGB channel colors to contour segments using the Chlumsky algorithm.
280///
281/// Corner detection uses a ~3° angle threshold between consecutive edge
282/// tangents. Smooth runs are split into thirds (R/G/B). Corners subdivide the
283/// contour into spans; single-segment spans receive a mixed color
284/// (Yellow/Cyan/Magenta).
285pub fn color_edges(shape: &mut GlyphShape) {
286    // cos(3°) ≈ 0.9986 — used as the dot-product threshold for corner detection.
287    let corner_dot_threshold: f64 = 3.0_f64.to_radians().cos();
288
289    for contour in &mut shape.contours {
290        if contour.segments.is_empty() {
291            continue;
292        }
293        let n = contour.segments.len();
294
295        // ── Corner detection ────────────────────────────────────────────────
296        let mut corners: Vec<usize> = Vec::new();
297        for i in 0..n {
298            let prev = &contour.segments[(i + n - 1) % n].segment;
299            let curr = &contour.segments[i].segment;
300            let t_prev = segment_end_tangent(prev);
301            let t_curr = segment_start_tangent(curr);
302            let dot = t_prev.x * t_curr.x + t_prev.y * t_curr.y;
303            let cross = t_prev.x * t_curr.y - t_prev.y * t_curr.x;
304            // Corner when the signed cross-product magnitude exceeds sin(threshold)
305            // OR when the edges point in opposite directions (dot < 0).
306            if cross.abs() > corner_dot_threshold.acos().sin() || dot < 0.0 {
307                corners.push(i);
308            }
309        }
310
311        if corners.is_empty() {
312            // Smooth contour: assign R/G/B in three roughly equal spans.
313            let seg_per_third = (n.max(3) / 3).max(1);
314            let colors = [EdgeColor::RED, EdgeColor::GREEN, EdgeColor::BLUE];
315            for (i, seg) in contour.segments.iter_mut().enumerate() {
316                seg.color = colors[(i / seg_per_third).min(2)];
317            }
318        } else {
319            // Cornered contour: cycle R/G/B between corners.
320            // Single-segment spans receive a mixed (transition) color.
321            let nc = corners.len();
322            let palette = [EdgeColor::RED, EdgeColor::GREEN, EdgeColor::BLUE];
323            let mut color_idx = 0usize;
324
325            for ci in 0..nc {
326                let start = corners[ci];
327                let end = corners[(ci + 1) % nc];
328                let span_len = if end > start {
329                    end - start
330                } else {
331                    n - start + end
332                };
333
334                let color = if span_len == 1 {
335                    // Mix the current and next palette colors.
336                    let c1 = palette[color_idx % 3];
337                    let c2 = palette[(color_idx + 1) % 3];
338                    color_idx += 1;
339                    EdgeColor(c1.0 | c2.0)
340                } else {
341                    let c = palette[color_idx % 3];
342                    color_idx += 1;
343                    c
344                };
345
346                for j in 0..span_len {
347                    contour.segments[(start + j) % n].color = color;
348                }
349            }
350        }
351    }
352}
353
354// ─── Signed pseudo-distance helpers ──────────────────────────────────────────
355
356/// Signed distance from point `(px, py)` to line segment `AB`.
357///
358/// Positive = left of A→B (outside for CCW contours).
359fn signed_pseudo_dist_line(px: f64, py: f64, ax: f64, ay: f64, bx: f64, by: f64) -> f64 {
360    let dx = bx - ax;
361    let dy = by - ay;
362    let len2 = dx * dx + dy * dy;
363    if len2 < 1e-12 {
364        return ((px - ax) * (px - ax) + (py - ay) * (py - ay)).sqrt();
365    }
366    let t = ((px - ax) * dx + (py - ay) * dy) / len2;
367    let t = t.clamp(0.0, 1.0);
368    let cx = ax + t * dx - px;
369    let cy = ay + t * dy - py;
370    let dist = (cx * cx + cy * cy).sqrt();
371    let cross = (bx - ax) * (py - ay) - (by - ay) * (px - ax);
372    if cross < 0.0 {
373        -dist
374    } else {
375        dist
376    }
377}
378
379/// Newton-Raphson refinement of the closest-point parameter on a quadratic Bezier.
380fn newton_raphson_quad(px: f64, py: f64, p0: Point, p1: Point, p2: Point, t_init: f64) -> f64 {
381    let mut t = t_init.clamp(0.0, 1.0);
382    for _ in 0..8 {
383        let qx = (1.0 - t) * (1.0 - t) * p0.x + 2.0 * t * (1.0 - t) * p1.x + t * t * p2.x;
384        let qy = (1.0 - t) * (1.0 - t) * p0.y + 2.0 * t * (1.0 - t) * p1.y + t * t * p2.y;
385        // first derivative: dP/dt
386        let dqx = 2.0 * (1.0 - t) * (p1.x - p0.x) + 2.0 * t * (p2.x - p1.x);
387        let dqy = 2.0 * (1.0 - t) * (p1.y - p0.y) + 2.0 * t * (p2.y - p1.y);
388        // second derivative: d²P/dt²
389        let ddqx = 2.0 * (p2.x - 2.0 * p1.x + p0.x);
390        let ddqy = 2.0 * (p2.y - 2.0 * p1.y + p0.y);
391        // f(t) = dP/dt · (P(t) - Q)
392        let fx = dqx * (qx - px) + dqy * (qy - py);
393        // f'(t) = |dP/dt|² + d²P/dt² · (P(t) - Q)
394        let dfx = dqx * dqx + dqy * dqy + ddqx * (qx - px) + ddqy * (qy - py);
395        if dfx.abs() < 1e-12 {
396            break;
397        }
398        let dt = fx / dfx;
399        t = (t - dt).clamp(0.0, 1.0);
400        if dt.abs() < 1e-9 {
401            break;
402        }
403    }
404    t
405}
406
407/// Signed distance from point `(px, py)` to quadratic Bezier `(p0, p1, p2)`.
408fn signed_pseudo_dist_quad(px: f64, py: f64, p0: Point, p1: Point, p2: Point) -> f64 {
409    // Initial candidate: sample at 9 uniformly-spaced t values.
410    let mut best_dist = f64::MAX;
411    let mut best_t = 0.0f64;
412    for i in 0..=8 {
413        let t = i as f64 / 8.0;
414        let qx = (1.0 - t) * (1.0 - t) * p0.x + 2.0 * t * (1.0 - t) * p1.x + t * t * p2.x;
415        let qy = (1.0 - t) * (1.0 - t) * p0.y + 2.0 * t * (1.0 - t) * p1.y + t * t * p2.y;
416        let d = ((qx - px) * (qx - px) + (qy - py) * (qy - py)).sqrt();
417        if d < best_dist {
418            best_dist = d;
419            best_t = t;
420        }
421    }
422
423    // Refine with Newton-Raphson.
424    let t = newton_raphson_quad(px, py, p0, p1, p2, best_t);
425    let qx = (1.0 - t) * (1.0 - t) * p0.x + 2.0 * t * (1.0 - t) * p1.x + t * t * p2.x;
426    let qy = (1.0 - t) * (1.0 - t) * p0.y + 2.0 * t * (1.0 - t) * p1.y + t * t * p2.y;
427    let dist = ((qx - px) * (qx - px) + (qy - py) * (qy - py)).sqrt();
428
429    // Determine sign via tangent cross product.
430    let dtx = 2.0 * (1.0 - t) * (p1.x - p0.x) + 2.0 * t * (p2.x - p1.x);
431    let dty = 2.0 * (1.0 - t) * (p1.y - p0.y) + 2.0 * t * (p2.y - p1.y);
432    let cross = dtx * (py - qy) - dty * (px - qx);
433    if cross < 0.0 {
434        -dist
435    } else {
436        dist
437    }
438}
439
440/// Newton-Raphson refinement of the closest-point parameter on a cubic Bezier.
441fn newton_raphson_cubic(
442    px: f64,
443    py: f64,
444    p0: Point,
445    p1: Point,
446    p2: Point,
447    p3: Point,
448    t_init: f64,
449) -> f64 {
450    let mut t = t_init.clamp(0.0, 1.0);
451    for _ in 0..8 {
452        let u = 1.0 - t;
453        let qx =
454            u * u * u * p0.x + 3.0 * u * u * t * p1.x + 3.0 * u * t * t * p2.x + t * t * t * p3.x;
455        let qy =
456            u * u * u * p0.y + 3.0 * u * u * t * p1.y + 3.0 * u * t * t * p2.y + t * t * t * p3.y;
457        // first derivative
458        let dqx =
459            3.0 * u * u * (p1.x - p0.x) + 6.0 * u * t * (p2.x - p1.x) + 3.0 * t * t * (p3.x - p2.x);
460        let dqy =
461            3.0 * u * u * (p1.y - p0.y) + 6.0 * u * t * (p2.y - p1.y) + 3.0 * t * t * (p3.y - p2.y);
462        // second derivative
463        let ddqx = 6.0 * u * (p2.x - 2.0 * p1.x + p0.x) + 6.0 * t * (p3.x - 2.0 * p2.x + p1.x);
464        let ddqy = 6.0 * u * (p2.y - 2.0 * p1.y + p0.y) + 6.0 * t * (p3.y - 2.0 * p2.y + p1.y);
465
466        let fx = dqx * (qx - px) + dqy * (qy - py);
467        let dfx = dqx * dqx + dqy * dqy + ddqx * (qx - px) + ddqy * (qy - py);
468        if dfx.abs() < 1e-12 {
469            break;
470        }
471        let dt = fx / dfx;
472        t = (t - dt).clamp(0.0, 1.0);
473        if dt.abs() < 1e-9 {
474            break;
475        }
476    }
477    t
478}
479
480/// Signed distance from point `(px, py)` to cubic Bezier `(p0, c1, c2, p1)`.
481fn signed_pseudo_dist_cubic(px: f64, py: f64, p0: Point, p1: Point, p2: Point, p3: Point) -> f64 {
482    let mut best_dist = f64::MAX;
483    let mut best_t = 0.0f64;
484    for i in 0..=8 {
485        let t = i as f64 / 8.0;
486        let u = 1.0 - t;
487        let qx =
488            u * u * u * p0.x + 3.0 * u * u * t * p1.x + 3.0 * u * t * t * p2.x + t * t * t * p3.x;
489        let qy =
490            u * u * u * p0.y + 3.0 * u * u * t * p1.y + 3.0 * u * t * t * p2.y + t * t * t * p3.y;
491        let d = ((qx - px) * (qx - px) + (qy - py) * (qy - py)).sqrt();
492        if d < best_dist {
493            best_dist = d;
494            best_t = t;
495        }
496    }
497
498    let t = newton_raphson_cubic(px, py, p0, p1, p2, p3, best_t);
499    let u = 1.0 - t;
500    let qx = u * u * u * p0.x + 3.0 * u * u * t * p1.x + 3.0 * u * t * t * p2.x + t * t * t * p3.x;
501    let qy = u * u * u * p0.y + 3.0 * u * u * t * p1.y + 3.0 * u * t * t * p2.y + t * t * t * p3.y;
502    let dist = ((qx - px) * (qx - px) + (qy - py) * (qy - py)).sqrt();
503
504    let dtx =
505        3.0 * u * u * (p1.x - p0.x) + 6.0 * u * t * (p2.x - p1.x) + 3.0 * t * t * (p3.x - p2.x);
506    let dty =
507        3.0 * u * u * (p1.y - p0.y) + 6.0 * u * t * (p2.y - p1.y) + 3.0 * t * t * (p3.y - p2.y);
508    let cross = dtx * (py - qy) - dty * (px - qx);
509    if cross < 0.0 {
510        -dist
511    } else {
512        dist
513    }
514}
515
516/// Returns the signed pseudo-distance from `(px, py)` to `seg`.
517pub(crate) fn segment_signed_dist(seg: &Segment, px: f64, py: f64) -> f64 {
518    match seg {
519        Segment::Line(a, b) => signed_pseudo_dist_line(px, py, a.x, a.y, b.x, b.y),
520        Segment::Quad(p0, p1, p2) => signed_pseudo_dist_quad(px, py, *p0, *p1, *p2),
521        Segment::Cubic(p0, p1, p2, p3) => signed_pseudo_dist_cubic(px, py, *p0, *p1, *p2, *p3),
522    }
523}
524
525// ─── Core MSDF computation ────────────────────────────────────────────────────
526
527/// Compute a multi-channel SDF from a colored glyph shape.
528///
529/// # Arguments
530/// - `shape` — colored glyph outline (call [`color_edges`] first).
531/// - `width`, `height` — output tile dimensions in pixels.
532/// - `spread` — maximum SDF distance in *shape* units mapped to channel
533///   saturation; a value in the range `[1, 64]` (font design units) is typical.
534/// - `scale` — shape → pixel transform: `px_size / units_per_em`.
535/// - `offset_x`, `offset_y` — additional translation (in shape units) applied
536///   before mapping; moves the shape origin to pixel `(padding, padding)`.
537///
538/// # Returns
539/// A `Vec<u8>` of length `width * height * 3` (RGB, 3 bytes per pixel).
540/// Value encoding: `0` = far outside, `128` ≈ on the outline, `255` = far inside.
541///
542/// # Errors
543/// Returns [`crate::edt::SdfError::ZeroSize`] when `width == 0 || height == 0`.
544pub fn compute_msdf(
545    shape: &GlyphShape,
546    width: u32,
547    height: u32,
548    spread: f32,
549    scale: f32,
550    offset_x: f32,
551    offset_y: f32,
552) -> Result<Vec<u8>, crate::edt::SdfError> {
553    if width == 0 || height == 0 {
554        return Err(crate::edt::SdfError::ZeroSize);
555    }
556    let mut output = vec![0u8; width as usize * height as usize * 3];
557    let spread_f64 = spread as f64;
558
559    for py in 0..height {
560        for px in 0..width {
561            // Map pixel centre → shape space (flip Y: font Y-up vs pixel Y-down).
562            let fx = (px as f32 + 0.5) / scale + offset_x;
563            // Y-axis: font uses Y-up, pixels use Y-down; invert so shapes aren't mirrored.
564            let fy = (py as f32 + 0.5) / scale + offset_y;
565
566            let mut r_dist = f64::MAX;
567            let mut g_dist = f64::MAX;
568            let mut b_dist = f64::MAX;
569
570            for contour in &shape.contours {
571                for colored_seg in &contour.segments {
572                    let d = segment_signed_dist(&colored_seg.segment, fx as f64, fy as f64);
573                    if colored_seg.color.has_red() && d.abs() < r_dist.abs() {
574                        r_dist = d;
575                    }
576                    if colored_seg.color.has_green() && d.abs() < g_dist.abs() {
577                        g_dist = d;
578                    }
579                    if colored_seg.color.has_blue() && d.abs() < b_dist.abs() {
580                        b_dist = d;
581                    }
582                }
583            }
584
585            // Normalize: dist ∈ [-spread, spread] → [0, 255].
586            let norm = |d: f64| -> u8 {
587                ((d / spread_f64 + 1.0) * 0.5 * 255.0)
588                    .clamp(0.0, 255.0)
589                    .round() as u8
590            };
591
592            let idx = (py as usize * width as usize + px as usize) * 3;
593            output[idx] = if r_dist == f64::MAX { 0 } else { norm(r_dist) };
594            output[idx + 1] = if g_dist == f64::MAX { 0 } else { norm(g_dist) };
595            output[idx + 2] = if b_dist == f64::MAX { 0 } else { norm(b_dist) };
596        }
597    }
598    Ok(output)
599}
600
601// ─── High-level tile builder ──────────────────────────────────────────────────
602
603/// Generate an [`MsdfTile`] for a single glyph from raw font bytes.
604///
605/// Returns `Ok(None)` for whitespace / blank glyphs (no outline contours).
606///
607/// # Errors
608/// - [`crate::edt::SdfError::InvalidFont`] if the font bytes cannot be parsed.
609/// - [`crate::edt::SdfError::ZeroSize`] if the tile dimensions are zero.
610pub fn glyph_to_msdf_tile(
611    face_data: &[u8],
612    glyph_id: u16,
613    px_size: f32,
614    tile_width: u32,
615    tile_height: u32,
616    spread: f32,
617    padding: u32,
618) -> Result<Option<MsdfTile>, crate::edt::SdfError> {
619    let Some(mut shape) = extract_glyph_shape(face_data, glyph_id) else {
620        return Ok(None);
621    };
622    if shape.contours.is_empty() {
623        return Ok(None);
624    }
625
626    color_edges(&mut shape);
627
628    let face =
629        ttf_parser::Face::parse(face_data, 0).map_err(|_| crate::edt::SdfError::InvalidFont)?;
630    let gid = ttf_parser::GlyphId(glyph_id);
631    let scale_full = px_size / shape.units_per_em;
632
633    let bearing_x = face.glyph_hor_side_bearing(gid).unwrap_or(0) as f32 * scale_full;
634    let bearing_y = face
635        .glyph_hor_advance(gid)
636        .map(|_| face.ascender() as f32 * scale_full)
637        .unwrap_or(px_size);
638    let advance_x = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * scale_full;
639
640    // Bounding box → compute fit scale and offset so the glyph fills the tile.
641    let bbox = face
642        .glyph_bounding_box(gid)
643        .ok_or(crate::edt::SdfError::ZeroSize)?;
644    let shape_w = (bbox.x_max - bbox.x_min) as f32;
645    let shape_h = (bbox.y_max - bbox.y_min) as f32;
646
647    let eff_w = tile_width.saturating_sub(2 * padding).max(1) as f32;
648    let eff_h = tile_height.saturating_sub(2 * padding).max(1) as f32;
649    let fit_scale = (eff_w / shape_w).min(eff_h / shape_h);
650    let offset_x = bbox.x_min as f32 - padding as f32 / fit_scale;
651    let offset_y = bbox.y_min as f32 - padding as f32 / fit_scale;
652
653    let data = compute_msdf(
654        &shape,
655        tile_width,
656        tile_height,
657        spread,
658        fit_scale,
659        offset_x,
660        offset_y,
661    )?;
662
663    Ok(Some(MsdfTile {
664        glyph_id,
665        width: tile_width,
666        height: tile_height,
667        data,
668        bearing_x,
669        bearing_y,
670        advance_x,
671    }))
672}
673
674// ─── MTSDF: multi-channel true SDF ───────────────────────────────────────────
675
676/// A multi-channel true SDF tile — RGB channels hold MSDF data, alpha holds true SDF.
677///
678/// The true SDF in the alpha channel provides smooth outlines at all scales, while
679/// the MSDF RGB channels enable sharp corner reproduction at higher resolutions.
680#[derive(Debug, Clone)]
681pub struct MtsdfTile {
682    /// Glyph ID within the font.
683    pub glyph_id: u16,
684    /// Tile width in pixels.
685    pub width: u32,
686    /// Tile height in pixels.
687    pub height: u32,
688    /// RGBA pixel data: `width * height * 4` bytes (RGBA, 4 bytes per pixel).
689    pub data: Vec<u8>,
690    /// Left bearing in pixels.
691    pub bearing_x: f32,
692    /// Top bearing in pixels.
693    pub bearing_y: f32,
694    /// Horizontal advance in pixels.
695    pub advance_x: f32,
696}
697
698/// Compute a 4-channel MTSDF: RGB channels from MSDF, alpha from a true single-channel SDF.
699///
700/// For each pixel the RGB channels encode the per-color-channel minimum distances
701/// (as in [`compute_msdf`]).  The alpha channel encodes the minimum distance across
702/// **all** segments regardless of color, which yields a smooth true SDF.
703///
704/// # Arguments
705/// - `shape` — colored glyph outline (call [`color_edges`] first).
706/// - `width`, `height` — output tile dimensions in pixels.
707/// - `spread` — maximum SDF distance in shape units; maps ±spread to \[0, 255\].
708/// - `scale` — shape → pixel transform: `px_size / units_per_em`.
709/// - `offset_x`, `offset_y` — translation applied before the shape→pixel mapping.
710///
711/// # Returns
712/// A `Vec<u8>` of length `width * height * 4` (RGBA, 4 bytes per pixel).
713///
714/// # Errors
715/// Returns [`crate::edt::SdfError::ZeroSize`] when `width == 0 || height == 0`.
716pub fn compute_mtsdf(
717    shape: &GlyphShape,
718    width: u32,
719    height: u32,
720    spread: f32,
721    scale: f32,
722    offset_x: f32,
723    offset_y: f32,
724) -> Result<Vec<u8>, crate::edt::SdfError> {
725    if width == 0 || height == 0 {
726        return Err(crate::edt::SdfError::ZeroSize);
727    }
728
729    // Compute RGB channels via standard MSDF.
730    let rgb = compute_msdf(shape, width, height, spread, scale, offset_x, offset_y)?;
731
732    let spread_f64 = spread as f64;
733    let n = width as usize * height as usize;
734    let mut alpha = vec![0u8; n];
735
736    for py in 0..height {
737        for px in 0..width {
738            let fx = (px as f32 + 0.5) / scale + offset_x;
739            let fy = (py as f32 + 0.5) / scale + offset_y;
740
741            // Find the minimum signed distance across ALL segments (regardless of color).
742            let mut min_dist = f64::MAX;
743            for contour in &shape.contours {
744                for seg in &contour.segments {
745                    let d = segment_signed_dist(&seg.segment, fx as f64, fy as f64);
746                    if d.abs() < min_dist.abs() {
747                        min_dist = d;
748                    }
749                }
750            }
751
752            let v = if min_dist == f64::MAX {
753                0u8
754            } else {
755                ((min_dist / spread_f64 + 1.0) * 0.5 * 255.0)
756                    .clamp(0.0, 255.0)
757                    .round() as u8
758            };
759            alpha[py as usize * width as usize + px as usize] = v;
760        }
761    }
762
763    // Interleave RGB + Alpha → RGBA.
764    let mut rgba = vec![0u8; n * 4];
765    for i in 0..n {
766        rgba[i * 4] = rgb[i * 3];
767        rgba[i * 4 + 1] = rgb[i * 3 + 1];
768        rgba[i * 4 + 2] = rgb[i * 3 + 2];
769        rgba[i * 4 + 3] = alpha[i];
770    }
771    Ok(rgba)
772}
773
774/// Generate an [`MtsdfTile`] for a single glyph from raw font bytes.
775///
776/// Returns `Ok(None)` for whitespace / blank glyphs (no outline contours).
777///
778/// # Errors
779/// - [`crate::edt::SdfError::InvalidFont`] if the font bytes cannot be parsed.
780/// - [`crate::edt::SdfError::ZeroSize`] if the tile dimensions are zero.
781pub fn glyph_to_mtsdf_tile(
782    face_data: &[u8],
783    glyph_id: u16,
784    px_size: f32,
785    tile_width: u32,
786    tile_height: u32,
787    spread: f32,
788    padding: u32,
789) -> Result<Option<MtsdfTile>, crate::edt::SdfError> {
790    let Some(mut shape) = extract_glyph_shape(face_data, glyph_id) else {
791        return Ok(None);
792    };
793    if shape.contours.is_empty() {
794        return Ok(None);
795    }
796
797    color_edges(&mut shape);
798
799    let face =
800        ttf_parser::Face::parse(face_data, 0).map_err(|_| crate::edt::SdfError::InvalidFont)?;
801    let gid = ttf_parser::GlyphId(glyph_id);
802    let scale_full = px_size / shape.units_per_em;
803
804    let bearing_x = face.glyph_hor_side_bearing(gid).unwrap_or(0) as f32 * scale_full;
805    let bearing_y = face
806        .glyph_hor_advance(gid)
807        .map(|_| face.ascender() as f32 * scale_full)
808        .unwrap_or(px_size);
809    let advance_x = face.glyph_hor_advance(gid).unwrap_or(0) as f32 * scale_full;
810
811    let bbox = face
812        .glyph_bounding_box(gid)
813        .ok_or(crate::edt::SdfError::ZeroSize)?;
814    let shape_w = (bbox.x_max - bbox.x_min) as f32;
815    let shape_h = (bbox.y_max - bbox.y_min) as f32;
816
817    let eff_w = tile_width.saturating_sub(2 * padding).max(1) as f32;
818    let eff_h = tile_height.saturating_sub(2 * padding).max(1) as f32;
819    let fit_scale = (eff_w / shape_w).min(eff_h / shape_h);
820    let offset_x = bbox.x_min as f32 - padding as f32 / fit_scale;
821    let offset_y = bbox.y_min as f32 - padding as f32 / fit_scale;
822
823    let data = compute_mtsdf(
824        &shape,
825        tile_width,
826        tile_height,
827        spread,
828        fit_scale,
829        offset_x,
830        offset_y,
831    )?;
832
833    Ok(Some(MtsdfTile {
834        glyph_id,
835        width: tile_width,
836        height: tile_height,
837        data,
838        bearing_x,
839        bearing_y,
840        advance_x,
841    }))
842}
843
844// ─── Tests ────────────────────────────────────────────────────────────────────
845
846#[cfg(test)]
847mod tests {
848    use super::*;
849
850    /// Build a simple square contour in shape space.
851    fn square_shape(size: f64) -> GlyphShape {
852        let segs = vec![
853            ColoredSegment {
854                segment: Segment::Line(Point { x: 0.0, y: 0.0 }, Point { x: size, y: 0.0 }),
855                color: EdgeColor::RED,
856            },
857            ColoredSegment {
858                segment: Segment::Line(Point { x: size, y: 0.0 }, Point { x: size, y: size }),
859                color: EdgeColor::GREEN,
860            },
861            ColoredSegment {
862                segment: Segment::Line(Point { x: size, y: size }, Point { x: 0.0, y: size }),
863                color: EdgeColor::BLUE,
864            },
865            ColoredSegment {
866                segment: Segment::Line(Point { x: 0.0, y: size }, Point { x: 0.0, y: 0.0 }),
867                color: EdgeColor::RED,
868            },
869        ];
870        GlyphShape {
871            contours: vec![Contour { segments: segs }],
872            units_per_em: 1000.0,
873        }
874    }
875
876    #[test]
877    fn color_bits() {
878        assert!(EdgeColor::RED.has_red());
879        assert!(!EdgeColor::RED.has_green());
880        assert!(EdgeColor::YELLOW.has_red() && EdgeColor::YELLOW.has_green());
881        assert!(
882            EdgeColor::WHITE.has_red()
883                && EdgeColor::WHITE.has_green()
884                && EdgeColor::WHITE.has_blue()
885        );
886    }
887
888    #[test]
889    fn msdf_output_has_three_channels() {
890        let shape = square_shape(500.0);
891        let result = compute_msdf(&shape, 16, 16, 4.0, 0.016, -50.0, -50.0);
892        assert!(result.is_ok());
893        let data = result.unwrap();
894        assert_eq!(data.len(), 16 * 16 * 3);
895    }
896
897    #[test]
898    fn sdf_midpoint_near_128() {
899        let shape = square_shape(1000.0);
900        let result = compute_msdf(&shape, 32, 32, 8.0, 0.032, -16.0, -16.0);
901        let data = result.unwrap();
902        let has_nonzero = data.iter().any(|&v| v > 10 && v < 245);
903        assert!(has_nonzero, "SDF should have values spanning the range");
904    }
905
906    #[test]
907    fn empty_glyph_returns_ok() {
908        let empty = GlyphShape {
909            contours: vec![],
910            units_per_em: 1000.0,
911        };
912        let result = compute_msdf(&empty, 8, 8, 4.0, 1.0, 0.0, 0.0);
913        // Empty shape: all distances are MAX → output all zeros (far outside).
914        assert!(result.is_ok());
915    }
916
917    #[test]
918    fn edge_color_hashable() {
919        let mut map = std::collections::HashMap::new();
920        map.insert(EdgeColor::RED, 1u32);
921        map.insert(EdgeColor::GREEN, 2u32);
922        assert_eq!(map.get(&EdgeColor::RED), Some(&1));
923    }
924
925    #[test]
926    fn color_edges_smooth_assigns_three_colors() {
927        let mut shape = square_shape(500.0);
928        color_edges(&mut shape);
929        let colors: std::collections::HashSet<u8> = shape.contours[0]
930            .segments
931            .iter()
932            .map(|s| s.color.0)
933            .collect();
934        // Should have at least 2 distinct channel assignments.
935        assert!(
936            colors.len() >= 2,
937            "expected multi-color assignment, got {colors:?}"
938        );
939    }
940
941    #[test]
942    fn zero_size_returns_error() {
943        let shape = square_shape(500.0);
944        assert!(compute_msdf(&shape, 0, 16, 4.0, 1.0, 0.0, 0.0).is_err());
945        assert!(compute_msdf(&shape, 16, 0, 4.0, 1.0, 0.0, 0.0).is_err());
946    }
947
948    #[test]
949    fn mtsdf_four_channel_output() {
950        let shape = GlyphShape {
951            contours: vec![Contour {
952                segments: vec![ColoredSegment {
953                    segment: Segment::Line(Point { x: 0.0, y: 0.0 }, Point { x: 100.0, y: 0.0 }),
954                    color: EdgeColor::WHITE,
955                }],
956            }],
957            units_per_em: 1000.0,
958        };
959        let result = compute_mtsdf(&shape, 8, 8, 4.0, 0.08, -50.0, -50.0);
960        assert!(result.is_ok(), "compute_mtsdf failed: {:?}", result);
961        let data = result.unwrap();
962        assert_eq!(
963            data.len(),
964            8 * 8 * 4,
965            "MTSDF output should be RGBA (4 bytes/pixel)"
966        );
967    }
968
969    #[test]
970    fn mtsdf_zero_size_errors() {
971        let shape = square_shape(500.0);
972        assert!(compute_mtsdf(&shape, 0, 8, 4.0, 1.0, 0.0, 0.0).is_err());
973        assert!(compute_mtsdf(&shape, 8, 0, 4.0, 1.0, 0.0, 0.0).is_err());
974    }
975
976    // ─── MSDF edge coloring quality test ──────────────────────────────────────
977    //
978    // Test 3: `glyph_to_msdf_tile` on a real printable ASCII glyph verifies that:
979    //   (a) the returned tile carries 3 bytes per pixel (RGB stride),
980    //   (b) the R, G, B channels are NOT all equal across all pixels, confirming
981    //       that edge coloring assigned distinct channel identities to different
982    //       contour segments (multi-channel differentiation).
983    //
984    // The threshold: at least 5 % of foreground pixels (those where at least one
985    // channel ≠ 0) must show channel disagreement (r≠g || g≠b || r≠b).
986    // This is meaningfully stronger than "at least one pixel differs" and
987    // demonstrates that the MSDF channels carry truly independent distance info.
988
989    const FONT: &[u8] = include_bytes!("../../../tests/fixtures/test-font.ttf");
990
991    /// Helper: find the glyph id for an ASCII character in the test font.
992    fn glyph_id_for_char(ch: char) -> u16 {
993        let face = ttf_parser::Face::parse(FONT, 0).expect("parse test font");
994        face.glyph_index(ch).expect("char not found in test font").0
995    }
996
997    /// Test 3: MSDF edge coloring assigns distinct R/G/B channel values.
998    #[test]
999    fn msdf_edge_coloring_assigns_distinct_channels() {
1000        // Use 'H' — a glyph with clear corners that forces the Chlumsky algorithm
1001        // to cycle through R/G/B on different contour spans.
1002        let gid = glyph_id_for_char('H');
1003
1004        let tile_w = 32u32;
1005        let tile_h = 32u32;
1006        let px_size = 24.0f32;
1007        let spread = 4.0f32;
1008        let padding = 2u32;
1009
1010        let tile = glyph_to_msdf_tile(FONT, gid, px_size, tile_w, tile_h, spread, padding)
1011            .expect("glyph_to_msdf_tile should not error")
1012            .expect("'H' should have a non-empty outline");
1013
1014        // (a) Stride check: 3 bytes per pixel.
1015        assert_eq!(
1016            tile.data.len(),
1017            (tile_w * tile_h * 3) as usize,
1018            "MSDF tile should have 3 bytes/pixel (RGB)"
1019        );
1020
1021        // (b) Channel distinctness: scan all pixels and count those where R≠G, G≠B, or R≠B.
1022        let n_pixels = (tile_w * tile_h) as usize;
1023        let mut foreground_pixels = 0usize;
1024        let mut distinct_pixels = 0usize;
1025
1026        for i in 0..n_pixels {
1027            let r = tile.data[i * 3];
1028            let g = tile.data[i * 3 + 1];
1029            let b = tile.data[i * 3 + 2];
1030            // Skip completely-black background pixels (all channels at min).
1031            if r > 5 || g > 5 || b > 5 {
1032                foreground_pixels += 1;
1033                if r != g || g != b || r != b {
1034                    distinct_pixels += 1;
1035                }
1036            }
1037        }
1038
1039        assert!(
1040            foreground_pixels > 0,
1041            "MSDF tile for 'H' should have at least some foreground pixels"
1042        );
1043
1044        // At least 5 % of foreground pixels must show channel disagreement.
1045        let ratio = distinct_pixels as f64 / foreground_pixels as f64;
1046        assert!(
1047            ratio >= 0.05,
1048            "Expected ≥5% of foreground pixels to have distinct R/G/B channels \
1049             (confirms edge coloring), got {:.1}% ({distinct_pixels}/{foreground_pixels})",
1050            ratio * 100.0
1051        );
1052    }
1053}