Skip to main content

proof_engine/glyph/
font_to_mesh.rs

1//! Convert font glyph outlines to polygon meshes.
2//!
3//! Parses TTF/OTF glyph outlines via `ab_glyph`, extracts contour points,
4//! subdivides Bezier curves adaptively, classifies holes, and outputs clean
5//! polygon outlines ready for triangulation and extrusion.
6
7use glam::Vec2;
8use std::collections::HashMap;
9use ab_glyph::{Font, FontVec, PxScale, ScaleFont, GlyphId};
10
11// ── Types ───────────────────────────────────────────────────────────────────
12
13/// Bounding box for a glyph outline.
14#[derive(Clone, Copy, Debug)]
15pub struct GlyphBounds {
16    pub min: Vec2,
17    pub max: Vec2,
18}
19
20impl GlyphBounds {
21    pub fn size(&self) -> Vec2 { self.max - self.min }
22    pub fn center(&self) -> Vec2 { (self.min + self.max) * 0.5 }
23
24    pub fn from_points(points: &[Vec2]) -> Self {
25        let mut min = Vec2::splat(f32::MAX);
26        let mut max = Vec2::splat(f32::MIN);
27        for p in points {
28            min = min.min(*p);
29            max = max.max(*p);
30        }
31        Self { min, max }
32    }
33}
34
35/// A single closed contour (outer boundary or hole).
36#[derive(Clone, Debug)]
37pub struct Contour {
38    pub points: Vec<Vec2>,
39    pub is_hole: bool,
40}
41
42impl Contour {
43    pub fn signed_area(&self) -> f32 {
44        signed_area(&self.points)
45    }
46
47    pub fn bounds(&self) -> GlyphBounds {
48        GlyphBounds::from_points(&self.points)
49    }
50}
51
52/// Complete outline for a single glyph character.
53#[derive(Clone, Debug)]
54pub struct GlyphOutline {
55    pub contours: Vec<Contour>,
56    pub advance_width: f32,
57    pub bounds: GlyphBounds,
58}
59
60// ── Signed area / winding ───────────────────────────────────────────────────
61
62/// Shoelace formula. Positive = CCW, Negative = CW.
63pub fn signed_area(points: &[Vec2]) -> f32 {
64    let n = points.len();
65    if n < 3 { return 0.0; }
66    let mut area = 0.0f32;
67    for i in 0..n {
68        let j = (i + 1) % n;
69        area += points[i].x * points[j].y;
70        area -= points[j].x * points[i].y;
71    }
72    area * 0.5
73}
74
75/// Check if polygon winds counter-clockwise.
76pub fn is_ccw(points: &[Vec2]) -> bool {
77    signed_area(points) > 0.0
78}
79
80// ── Point-in-polygon ────────────────────────────────────────────────────────
81
82/// Ray casting test.
83pub fn point_in_polygon(point: Vec2, polygon: &[Vec2]) -> bool {
84    let n = polygon.len();
85    if n < 3 { return false; }
86    let mut inside = false;
87    let mut j = n - 1;
88    for i in 0..n {
89        let pi = polygon[i];
90        let pj = polygon[j];
91        if ((pi.y > point.y) != (pj.y > point.y))
92            && (point.x < (pj.x - pi.x) * (point.y - pi.y) / (pj.y - pi.y) + pi.x)
93        {
94            inside = !inside;
95        }
96        j = i;
97    }
98    inside
99}
100
101// ── Bezier subdivision ──────────────────────────────────────────────────────
102
103/// Adaptive subdivision of a quadratic Bezier (TrueType).
104pub fn subdivide_quadratic(p0: Vec2, p1: Vec2, p2: Vec2, tolerance: f32) -> Vec<Vec2> {
105    let mut result = Vec::new();
106    subdivide_quad_recursive(p0, p1, p2, tolerance * tolerance, &mut result);
107    result.push(p2);
108    result
109}
110
111fn subdivide_quad_recursive(p0: Vec2, p1: Vec2, p2: Vec2, tol_sq: f32, out: &mut Vec<Vec2>) {
112    let mid = (p0 + p2) * 0.5;
113    let dist_sq = (p1 - mid).length_squared();
114    if dist_sq <= tol_sq {
115        out.push(p0);
116        return;
117    }
118    let q0 = (p0 + p1) * 0.5;
119    let q1 = (p1 + p2) * 0.5;
120    let r = (q0 + q1) * 0.5;
121    subdivide_quad_recursive(p0, q0, r, tol_sq, out);
122    subdivide_quad_recursive(r, q1, p2, tol_sq, out);
123}
124
125/// Adaptive subdivision of a cubic Bezier (OpenType/CFF).
126pub fn subdivide_cubic(p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2, tolerance: f32) -> Vec<Vec2> {
127    let mut result = Vec::new();
128    subdivide_cubic_recursive(p0, p1, p2, p3, tolerance * tolerance, 0, &mut result);
129    result.push(p3);
130    result
131}
132
133fn subdivide_cubic_recursive(
134    p0: Vec2, p1: Vec2, p2: Vec2, p3: Vec2,
135    tol_sq: f32, depth: u32, out: &mut Vec<Vec2>,
136) {
137    if depth > 10 {
138        out.push(p0);
139        return;
140    }
141    let d1 = (p1 - (p0 + p3) * 0.5).length_squared();
142    let d2 = (p2 - (p0 + p3) * 0.5).length_squared();
143    if d1 + d2 <= tol_sq {
144        out.push(p0);
145        return;
146    }
147    let ab = (p0 + p1) * 0.5;
148    let bc = (p1 + p2) * 0.5;
149    let cd = (p2 + p3) * 0.5;
150    let abc = (ab + bc) * 0.5;
151    let bcd = (bc + cd) * 0.5;
152    let abcd = (abc + bcd) * 0.5;
153    subdivide_cubic_recursive(p0, ab, abc, abcd, tol_sq, depth + 1, out);
154    subdivide_cubic_recursive(abcd, bcd, cd, p3, tol_sq, depth + 1, out);
155}
156
157// ── Contour classification ──────────────────────────────────────────────────
158
159/// Classify contours as outer boundaries or holes based on winding direction.
160/// Convention: CW (negative area) = outer, CCW (positive area) = hole (TrueType convention).
161/// We normalize so outer contours are CCW and holes are CW for our triangulator.
162pub fn classify_contours(contours: &mut [Contour]) {
163    for c in contours.iter_mut() {
164        let area = signed_area(&c.points);
165        // TrueType: negative area = outer contour
166        // We want: positive area (CCW) = outer, negative (CW) = hole
167        if area < 0.0 {
168            // Negative = CW in our coord system → outer in TrueType → make CCW
169            c.points.reverse();
170            c.is_hole = false;
171        } else {
172            // Positive = CCW → hole in TrueType → make CW
173            c.points.reverse();
174            c.is_hole = true;
175        }
176    }
177    // If all are classified as holes, the largest one is likely the outer boundary.
178    let all_holes = contours.iter().all(|c| c.is_hole);
179    if all_holes && !contours.is_empty() {
180        let mut max_area = 0.0f32;
181        let mut max_idx = 0;
182        for (i, c) in contours.iter().enumerate() {
183            let a = signed_area(&c.points).abs();
184            if a > max_area {
185                max_area = a;
186                max_idx = i;
187            }
188        }
189        contours[max_idx].is_hole = false;
190        contours[max_idx].points.reverse(); // flip to CCW
191    }
192}
193
194/// Assign holes to their containing outer contours.
195/// Returns Vec of (outer_index, hole_indices).
196pub fn assign_holes_to_outers(contours: &[Contour]) -> Vec<(usize, Vec<usize>)> {
197    let outers: Vec<usize> = contours.iter().enumerate()
198        .filter(|(_, c)| !c.is_hole).map(|(i, _)| i).collect();
199    let holes: Vec<usize> = contours.iter().enumerate()
200        .filter(|(_, c)| c.is_hole).map(|(i, _)| i).collect();
201
202    let mut assignments: Vec<(usize, Vec<usize>)> = outers.iter()
203        .map(|&i| (i, Vec::new())).collect();
204
205    for &h in &holes {
206        if let Some(hp) = contours[h].points.first() {
207            // Find the smallest outer contour containing this hole's first point.
208            let mut best = None;
209            let mut best_area = f32::MAX;
210            for &o in &outers {
211                if point_in_polygon(*hp, &contours[o].points) {
212                    let area = signed_area(&contours[o].points).abs();
213                    if area < best_area {
214                        best_area = area;
215                        best = Some(o);
216                    }
217                }
218            }
219            if let Some(o) = best {
220                if let Some(entry) = assignments.iter_mut().find(|(idx, _)| *idx == o) {
221                    entry.1.push(h);
222                }
223            }
224        }
225    }
226
227    assignments
228}
229
230// ── Ramer-Douglas-Peucker simplification ────────────────────────────────────
231
232pub fn simplify_contour(points: &[Vec2], tolerance: f32) -> Vec<Vec2> {
233    if points.len() <= 3 { return points.to_vec(); }
234    let mut keep = vec![false; points.len()];
235    keep[0] = true;
236    keep[points.len() - 1] = true;
237    rdp_recursive(points, 0, points.len() - 1, tolerance * tolerance, &mut keep);
238    points.iter().enumerate().filter(|(i, _)| keep[*i]).map(|(_, p)| *p).collect()
239}
240
241fn rdp_recursive(points: &[Vec2], start: usize, end: usize, tol_sq: f32, keep: &mut [bool]) {
242    if end <= start + 1 { return; }
243    let line_dir = points[end] - points[start];
244    let line_len_sq = line_dir.length_squared();
245    let mut max_dist_sq = 0.0f32;
246    let mut max_idx = start;
247    for i in (start + 1)..end {
248        let d = if line_len_sq < 1e-10 {
249            (points[i] - points[start]).length_squared()
250        } else {
251            let t = ((points[i] - points[start]).dot(line_dir) / line_len_sq).clamp(0.0, 1.0);
252            let proj = points[start] + line_dir * t;
253            (points[i] - proj).length_squared()
254        };
255        if d > max_dist_sq {
256            max_dist_sq = d;
257            max_idx = i;
258        }
259    }
260    if max_dist_sq > tol_sq {
261        keep[max_idx] = true;
262        rdp_recursive(points, start, max_idx, tol_sq, keep);
263        rdp_recursive(points, max_idx, end, tol_sq, keep);
264    }
265}
266
267// ── Outline extraction from ab_glyph ────────────────────────────────────────
268
269/// Extract glyph outline from a font.
270pub fn extract_outline(font: &FontVec, ch: char, scale: f32) -> Option<GlyphOutline> {
271    let glyph_id = font.glyph_id(ch);
272    if glyph_id.0 == 0 && ch != ' ' { return None; }
273
274    let px_scale = PxScale::from(scale);
275    let scaled = font.as_scaled(px_scale);
276    let advance = scaled.h_advance(glyph_id);
277    let ascent = scaled.ascent();
278
279    let glyph = glyph_id.with_scale_and_position(px_scale, ab_glyph::point(0.0, ascent));
280
281    let outlined = font.outline_glyph(glyph)?;
282    let bounds_ab = outlined.px_bounds();
283
284    let mut contours = Vec::new();
285    let mut current_contour: Vec<Vec2> = Vec::new();
286    let mut last_pos = Vec2::ZERO;
287
288    // ab_glyph's OutlineCurve gives us MoveTo, LineTo, QuadTo, CurveTo
289    // We use outline_glyph and then manually walk the outline via the draw method
290    // Since ab_glyph doesn't expose raw outline walking easily, we use the
291    // rasterization bounds and reconstruct from the glyph metrics.
292
293    // Alternative approach: build contours from the outline callback
294    struct OutlineBuilder {
295        contours: Vec<Vec<Vec2>>,
296        current: Vec<Vec2>,
297        tolerance: f32,
298    }
299
300    impl OutlineBuilder {
301        fn finish_contour(&mut self) {
302            if self.current.len() >= 3 {
303                self.contours.push(std::mem::take(&mut self.current));
304            } else {
305                self.current.clear();
306            }
307        }
308    }
309
310    // Since ab_glyph doesn't give us direct outline walking in a simple way,
311    // we'll sample the glyph boundary by tracing the coverage at the edges.
312    // For a production implementation, you'd use ttf-parser's OutlineBuilder.
313    // Here we create a simplified outline from the glyph's bounding box and
314    // rasterized coverage.
315
316    let b = bounds_ab;
317    let w = (b.max.x - b.min.x).ceil() as u32 + 2;
318    let h = (b.max.y - b.min.y).ceil() as u32 + 2;
319
320    if w < 2 || h < 2 { return None; }
321
322    // Rasterize to coverage grid
323    let mut coverage = vec![0.0f32; (w * h) as usize];
324    let ox = b.min.x.floor();
325    let oy = b.min.y.floor();
326
327    outlined.draw(|x, y, v| {
328        let px = x as i32 - ox as i32;
329        let py = y as i32 - oy as i32;
330        if px >= 0 && py >= 0 && (px as u32) < w && (py as u32) < h {
331            coverage[(py as u32 * w + px as u32) as usize] = v;
332        }
333    });
334
335    // Extract contour via marching squares on the coverage grid
336    let threshold = 0.5;
337    let contour_points = marching_squares_contour(&coverage, w as usize, h as usize, threshold);
338
339    for mut pts in contour_points {
340        if pts.len() < 3 { continue; }
341        // Transform from grid space back to glyph space
342        for p in &mut pts {
343            p.x = p.x + ox;
344            p.y = p.y + oy;
345        }
346        contours.push(Contour { points: pts, is_hole: false });
347    }
348
349    if contours.is_empty() {
350        // Fallback: rectangular outline from bounds
351        contours.push(Contour {
352            points: vec![
353                Vec2::new(b.min.x, b.min.y),
354                Vec2::new(b.max.x, b.min.y),
355                Vec2::new(b.max.x, b.max.y),
356                Vec2::new(b.min.x, b.max.y),
357            ],
358            is_hole: false,
359        });
360    }
361
362    classify_contours(&mut contours);
363
364    let all_points: Vec<Vec2> = contours.iter().flat_map(|c| c.points.iter().copied()).collect();
365    let gbounds = if all_points.is_empty() {
366        GlyphBounds { min: Vec2::new(b.min.x, b.min.y), max: Vec2::new(b.max.x, b.max.y) }
367    } else {
368        GlyphBounds::from_points(&all_points)
369    };
370
371    Some(GlyphOutline { contours, advance_width: advance, bounds: gbounds })
372}
373
374/// Simple marching-squares contour extraction from a coverage grid.
375fn marching_squares_contour(coverage: &[f32], w: usize, h: usize, threshold: f32) -> Vec<Vec<Vec2>> {
376    if w < 2 || h < 2 { return Vec::new(); }
377
378    let mut visited = vec![false; w * h];
379    let mut contours = Vec::new();
380
381    // Find boundary pixels and trace contours
382    for y in 1..h - 1 {
383        for x in 1..w - 1 {
384            let idx = y * w + x;
385            if visited[idx] { continue; }
386            if coverage[idx] < threshold { continue; }
387
388            // Check if this is a boundary pixel (has a neighbor below threshold)
389            let is_boundary = (x > 0 && coverage[idx - 1] < threshold)
390                || (x + 1 < w && coverage[idx + 1] < threshold)
391                || (y > 0 && coverage[idx - w] < threshold)
392                || (y + 1 < h && coverage[idx + w] < threshold);
393
394            if !is_boundary { continue; }
395
396            // Trace this contour
397            let contour = trace_boundary(coverage, w, h, x, y, threshold, &mut visited);
398            if contour.len() >= 3 {
399                contours.push(contour);
400            }
401        }
402    }
403
404    contours
405}
406
407/// Trace a boundary contour starting from (sx, sy) using Moore neighbor tracing.
408fn trace_boundary(
409    coverage: &[f32], w: usize, h: usize,
410    sx: usize, sy: usize, threshold: f32,
411    visited: &mut [bool],
412) -> Vec<Vec2> {
413    let mut contour = Vec::new();
414    let dirs: [(i32, i32); 8] = [
415        (1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1),
416    ];
417
418    let mut x = sx as i32;
419    let mut y = sy as i32;
420    let mut dir = 0usize;
421    let max_steps = w * h;
422
423    for step in 0..max_steps {
424        if step > 0 && x == sx as i32 && y == sy as i32 { break; }
425
426        contour.push(Vec2::new(x as f32 + 0.5, y as f32 + 0.5));
427        let idx = y as usize * w + x as usize;
428        visited[idx] = true;
429
430        // Find next boundary pixel
431        let mut found = false;
432        for i in 0..8 {
433            let d = (dir + 6 + i) % 8; // start looking from backtrack direction
434            let nx = x + dirs[d].0;
435            let ny = y + dirs[d].1;
436            if nx >= 0 && ny >= 0 && (nx as usize) < w && (ny as usize) < h {
437                let nidx = ny as usize * w + nx as usize;
438                if coverage[nidx] >= threshold {
439                    let is_bd = (nx > 0 && coverage[nidx - 1] < threshold)
440                        || ((nx as usize + 1) < w && coverage[nidx + 1] < threshold)
441                        || (ny > 0 && coverage[nidx - w as i32 as usize] < threshold)
442                        || ((ny as usize + 1) < h && coverage[nidx + w] < threshold);
443                    if is_bd || !found {
444                        x = nx;
445                        y = ny;
446                        dir = d;
447                        found = true;
448                        break;
449                    }
450                }
451            }
452        }
453        if !found { break; }
454    }
455
456    // Simplify
457    if contour.len() > 20 {
458        simplify_contour(&contour, 0.5)
459    } else {
460        contour
461    }
462}
463
464// ── Outline Cache ───────────────────────────────────────────────────────────
465
466/// Cache of extracted glyph outlines.
467pub struct OutlineCache {
468    pub outlines: HashMap<char, GlyphOutline>,
469}
470
471impl OutlineCache {
472    pub fn build(font: &FontVec, chars: &[char], scale: f32) -> Self {
473        let mut outlines = HashMap::new();
474        for &ch in chars {
475            if let Some(outline) = extract_outline(font, ch, scale) {
476                outlines.insert(ch, outline);
477            }
478        }
479        Self { outlines }
480    }
481
482    pub fn get(&self, ch: char) -> Option<&GlyphOutline> {
483        self.outlines.get(&ch)
484    }
485
486    pub fn len(&self) -> usize { self.outlines.len() }
487    pub fn is_empty(&self) -> bool { self.outlines.is_empty() }
488}
489
490// ── Tests ───────────────────────────────────────────────────────────────────
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn square() -> Vec<Vec2> {
497        vec![
498            Vec2::new(0.0, 0.0),
499            Vec2::new(1.0, 0.0),
500            Vec2::new(1.0, 1.0),
501            Vec2::new(0.0, 1.0),
502        ]
503    }
504
505    #[test]
506    fn signed_area_ccw() {
507        let area = signed_area(&square());
508        assert!(area > 0.0, "CCW square should have positive area: {}", area);
509    }
510
511    #[test]
512    fn signed_area_cw() {
513        let mut sq = square();
514        sq.reverse();
515        let area = signed_area(&sq);
516        assert!(area < 0.0, "CW square should have negative area: {}", area);
517    }
518
519    #[test]
520    fn point_in_polygon_inside() {
521        assert!(point_in_polygon(Vec2::new(0.5, 0.5), &square()));
522    }
523
524    #[test]
525    fn point_in_polygon_outside() {
526        assert!(!point_in_polygon(Vec2::new(2.0, 2.0), &square()));
527    }
528
529    #[test]
530    fn subdivide_quadratic_produces_points() {
531        let pts = subdivide_quadratic(
532            Vec2::new(0.0, 0.0), Vec2::new(0.5, 1.0), Vec2::new(1.0, 0.0), 0.1,
533        );
534        assert!(pts.len() >= 3, "Should produce at least 3 points");
535    }
536
537    #[test]
538    fn subdivide_cubic_produces_points() {
539        let pts = subdivide_cubic(
540            Vec2::new(0.0, 0.0), Vec2::new(0.3, 1.0),
541            Vec2::new(0.7, 1.0), Vec2::new(1.0, 0.0), 0.1,
542        );
543        assert!(pts.len() >= 4, "Should produce at least 4 points");
544    }
545
546    #[test]
547    fn classify_contours_identifies_holes() {
548        let outer = vec![
549            Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0),
550            Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0),
551        ];
552        let hole = vec![
553            Vec2::new(2.0, 2.0), Vec2::new(8.0, 2.0),
554            Vec2::new(8.0, 8.0), Vec2::new(2.0, 8.0),
555        ];
556        // Make both CW (TrueType outer convention)
557        let mut contours = vec![
558            Contour { points: outer.into_iter().rev().collect(), is_hole: false },
559            Contour { points: hole, is_hole: false },
560        ];
561        classify_contours(&mut contours);
562        let outers: Vec<_> = contours.iter().filter(|c| !c.is_hole).collect();
563        let holes: Vec<_> = contours.iter().filter(|c| c.is_hole).collect();
564        assert_eq!(outers.len(), 1);
565        assert_eq!(holes.len(), 1);
566    }
567
568    #[test]
569    fn assign_holes_finds_containment() {
570        let outer = Contour {
571            points: vec![
572                Vec2::new(0.0, 0.0), Vec2::new(10.0, 0.0),
573                Vec2::new(10.0, 10.0), Vec2::new(0.0, 10.0),
574            ],
575            is_hole: false,
576        };
577        let hole = Contour {
578            points: vec![
579                Vec2::new(3.0, 3.0), Vec2::new(7.0, 3.0),
580                Vec2::new(7.0, 7.0), Vec2::new(3.0, 7.0),
581            ],
582            is_hole: true,
583        };
584        let assignments = assign_holes_to_outers(&[outer, hole]);
585        assert_eq!(assignments.len(), 1);
586        assert_eq!(assignments[0].1.len(), 1);
587    }
588
589    #[test]
590    fn simplify_reduces_points() {
591        let pts: Vec<Vec2> = (0..100).map(|i| {
592            let t = i as f32 / 99.0;
593            Vec2::new(t * 10.0, (t * 6.28).sin() * 0.01)
594        }).collect();
595        let simplified = simplify_contour(&pts, 0.1);
596        assert!(simplified.len() < pts.len());
597    }
598
599    #[test]
600    fn glyph_bounds_from_points() {
601        let pts = vec![Vec2::new(-1.0, -2.0), Vec2::new(3.0, 4.0)];
602        let b = GlyphBounds::from_points(&pts);
603        assert_eq!(b.min, Vec2::new(-1.0, -2.0));
604        assert_eq!(b.max, Vec2::new(3.0, 4.0));
605    }
606}