Skip to main content

ling_graphics/
vfont.rs

1//! Vector fonts — extract real glyph outlines from a TTF/OTF, cache each glyph
2//! once as a compact `<font>/<codepoint>.ling` vector-path file, and hand back
3//! flattened polylines for crisp, resolution-independent rendering in 2D/3D/4D.
4//!
5//! Unlike the bitmap [`crate::font::GlyphFont`], nothing here is rasterized to a
6//! fixed pixel grid: curves are preserved *as curves* on disk and flattened
7//! adaptively at render time, so the same glyph stays sharp at any size or when
8//! projected through the 3D/4D camera.
9//!
10//! ## On-disk format (`cache/fonts/<Font>/<codepoint>.ling`)
11//! A tiny SVG-path-like dialect — fast to parse, diff-able, curve-preserving:
12//! ```text
13//! # ling glyph — font=Orbitron cp=65 char=A adv=0.6123
14//! M 0.1040 0.0000
15//! L 0.4510 0.7030
16//! Q 0.5000 0.7600 0.5490 0.7030     ; quadratic: cx cy  x y
17//! C 0.10 0.20 0.30 0.40 0.50 0.00   ; cubic:     c1 c1  c2 c2  x y
18//! Z
19//! ```
20//! Coordinates are normalized to the em (units / `units_per_em`), y-up, baseline
21//! at 0. `adv` is the normalized horizontal advance.
22
23use std::collections::HashMap;
24use std::path::PathBuf;
25use ttf_parser::OutlineBuilder;
26
27// ── Glyph geometry (normalized em space, y-up, baseline 0) ───────────────────
28
29#[derive(Clone, Debug)]
30enum Seg {
31    Line([f32; 2]),
32    Quad([f32; 2], [f32; 2]),            // control, end
33    Cubic([f32; 2], [f32; 2], [f32; 2]), // control1, control2, end
34}
35
36#[derive(Clone, Debug, Default)]
37struct Contour {
38    start: [f32; 2],
39    segs: Vec<Seg>,
40}
41
42#[derive(Clone, Debug, Default)]
43struct Glyph {
44    contours: Vec<Contour>,
45    advance: f32,
46}
47
48/// Flattened polylines for one glyph plus its advance — all in normalized em
49/// space (x→right, **y→up**, baseline at 0). Callers map this into 2D screen
50/// space or onto a 3D plane.
51#[derive(Clone)]
52pub struct GlyphOutline {
53    pub polylines: Vec<Vec<[f32; 2]>>,
54    pub advance: f32,
55}
56
57// ── Outline extraction from ttf-parser ───────────────────────────────────────
58
59#[derive(Default)]
60struct Collector {
61    contours: Vec<Contour>,
62    cur: Option<Contour>,
63    cp: [f32; 2],
64}
65
66impl Collector {
67    fn finish_cur(&mut self) {
68        if let Some(c) = self.cur.take() {
69            if !c.segs.is_empty() {
70                self.contours.push(c);
71            }
72        }
73    }
74}
75
76impl OutlineBuilder for Collector {
77    fn move_to(&mut self, x: f32, y: f32) {
78        self.finish_cur();
79        self.cp = [x, y];
80        self.cur = Some(Contour { start: [x, y], segs: Vec::new() });
81    }
82
83    fn line_to(&mut self, x: f32, y: f32) {
84        if let Some(c) = &mut self.cur {
85            c.segs.push(Seg::Line([x, y]));
86        }
87        self.cp = [x, y];
88    }
89
90    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
91        if let Some(c) = &mut self.cur {
92            c.segs.push(Seg::Quad([x1, y1], [x, y]));
93        }
94        self.cp = [x, y];
95    }
96
97    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
98        if let Some(c) = &mut self.cur {
99            c.segs.push(Seg::Cubic([x1, y1], [x2, y2], [x, y]));
100        }
101        self.cp = [x, y];
102    }
103
104    fn close(&mut self) {
105        self.finish_cur();
106    }
107}
108
109// ── Curve compression: merge near-collinear consecutive line segments ────────
110
111fn unit(d: [f32; 2]) -> Option<[f32; 2]> {
112    let l = (d[0] * d[0] + d[1] * d[1]).sqrt();
113    if l < 1e-9 {
114        None
115    } else {
116        Some([d[0] / l, d[1] / l])
117    }
118}
119
120/// Drop redundant interior points on straight runs (Line→Line where the two
121/// edges point the same way). Curves are left untouched.
122fn compress_contour(start: [f32; 2], segs: &[Seg]) -> Vec<Seg> {
123    let mut out: Vec<Seg> = Vec::with_capacity(segs.len());
124    let mut cp = start; // current on-curve point
125    let mut line_a: Option<[f32; 2]> = None; // start of the last Line in `out`
126    for seg in segs {
127        match seg {
128            Seg::Line(p) => {
129                if let (Some(a), Some(Seg::Line(_))) = (line_a, out.last()) {
130                    let d1 = unit([cp[0] - a[0], cp[1] - a[1]]);
131                    let d2 = unit([p[0] - cp[0], p[1] - cp[1]]);
132                    if let (Some(d1), Some(d2)) = (d1, d2) {
133                        let cross = (d1[0] * d2[1] - d1[1] * d2[0]).abs();
134                        let dot = d1[0] * d2[0] + d1[1] * d2[1];
135                        if cross < 2.0e-3 && dot > 0.0 {
136                            *out.last_mut().unwrap() = Seg::Line(*p); // extend a→p
137                            cp = *p;
138                            continue;
139                        }
140                    }
141                }
142                out.push(Seg::Line(*p));
143                line_a = Some(cp);
144                cp = *p;
145            },
146            Seg::Quad(c, p) => {
147                out.push(Seg::Quad(*c, *p));
148                cp = *p;
149                line_a = None;
150            },
151            Seg::Cubic(a, b, p) => {
152                out.push(Seg::Cubic(*a, *b, *p));
153                cp = *p;
154                line_a = None;
155            },
156        }
157    }
158    out
159}
160
161// ── Adaptive flattening (de Casteljau, screen-pixel tolerance in em units) ───
162
163fn flat_quad(p0: [f32; 2], c: [f32; 2], p1: [f32; 2], tol: f32, out: &mut Vec<[f32; 2]>) {
164    // distance from control point to the chord
165    let dx = p1[0] - p0[0];
166    let dy = p1[1] - p0[1];
167    let d = ((c[0] - p0[0]) * dy - (c[1] - p0[1]) * dx).abs();
168    let chord2 = dx * dx + dy * dy;
169    if d * d <= tol * tol * chord2 || chord2 < 1e-12 {
170        out.push(p1);
171        return;
172    }
173    let m = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
174    let p01 = m(p0, c);
175    let p12 = m(c, p1);
176    let mid = m(p01, p12);
177    flat_quad(p0, p01, mid, tol, out);
178    flat_quad(mid, p12, p1, tol, out);
179}
180
181fn flat_cubic(
182    p0: [f32; 2],
183    c1: [f32; 2],
184    c2: [f32; 2],
185    p1: [f32; 2],
186    tol: f32,
187    out: &mut Vec<[f32; 2]>,
188) {
189    let dx = p1[0] - p0[0];
190    let dy = p1[1] - p0[1];
191    let d1 = ((c1[0] - p0[0]) * dy - (c1[1] - p0[1]) * dx).abs();
192    let d2 = ((c2[0] - p0[0]) * dy - (c2[1] - p0[1]) * dx).abs();
193    let chord2 = dx * dx + dy * dy;
194    if (d1 + d2) * (d1 + d2) <= tol * tol * chord2 || chord2 < 1e-12 {
195        out.push(p1);
196        return;
197    }
198    let m = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
199    let p01 = m(p0, c1);
200    let p12 = m(c1, c2);
201    let p23 = m(c2, p1);
202    let p012 = m(p01, p12);
203    let p123 = m(p12, p23);
204    let mid = m(p012, p123);
205    flat_cubic(p0, p01, p012, mid, tol, out);
206    flat_cubic(mid, p123, p23, p1, tol, out);
207}
208
209// ── The font ─────────────────────────────────────────────────────────────────
210
211pub struct VectorFont {
212    bytes: Vec<u8>,
213    name: String,
214    upm: f32,
215    ascent: f32,  // normalized
216    descent: f32, // normalized (negative)
217    /// Desired weight on the variable-font `wght` axis (e.g. 600 for a bold,
218    /// solid UI look). `None` → use the font's default instance.
219    weight: Option<f32>,
220    cache_dir: PathBuf,
221    glyphs: HashMap<char, Glyph>,
222    /// Tessellated-outline cache keyed by (char, tolerance bucket). Flattening the
223    /// béziers is the per-call cost; caching it makes repeated per-frame draws of
224    /// the same glyphs (UI text, glyph rings, …) effectively free.
225    outline_cache: HashMap<(char, u32), GlyphOutline>,
226}
227
228impl VectorFont {
229    /// Load a font from a TTF/OTF file using its default weight.
230    pub fn from_path(path: &str) -> Result<Self, String> {
231        Self::from_path_weight(path, None)
232    }
233
234    /// Load a font, optionally pinning the variable-font weight axis (`wght`).
235    /// The glyph cache lives at `cache/fonts/<file-stem>[@<weight>]/`.
236    pub fn from_path_weight(path: &str, weight: Option<f32>) -> Result<Self, String> {
237        let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
238        let name = std::path::Path::new(path)
239            .file_stem()
240            .map(|s| s.to_string_lossy().into_owned())
241            .unwrap_or_else(|| "font".into());
242        Self::from_bytes(bytes, &name, weight)
243    }
244
245    pub fn from_bytes(bytes: Vec<u8>, name: &str, weight: Option<f32>) -> Result<Self, String> {
246        let face = ttf_parser::Face::parse(&bytes, 0).map_err(|e| format!("{e:?}"))?;
247        let upm = face.units_per_em() as f32;
248        let ascent = face.ascender() as f32 / upm;
249        let descent = face.descender() as f32 / upm;
250        let dir = match weight {
251            Some(w) => format!("{name}@{}", w as i32),
252            None => name.to_string(),
253        };
254        let cache_dir = PathBuf::from("cache").join("fonts").join(dir);
255        Ok(Self {
256            bytes,
257            name: name.to_string(),
258            upm,
259            ascent,
260            descent,
261            weight,
262            cache_dir,
263            glyphs: HashMap::new(),
264            outline_cache: HashMap::new(),
265        })
266    }
267
268    pub fn ascent(&self) -> f32 {
269        self.ascent
270    }
271
272    pub fn descent(&self) -> f32 {
273        self.descent
274    }
275
276    /// Ensure the glyph for `ch` is in memory: hot-cache → on-disk `.ling` →
277    /// extract-from-TTF (then write the `.ling`).
278    fn ensure(&mut self, ch: char) {
279        if self.glyphs.contains_key(&ch) {
280            return;
281        }
282        let file = self.cache_dir.join(format!("{}.ling", ch as u32));
283        if let Ok(text) = std::fs::read_to_string(&file) {
284            if let Some(g) = parse_glyph_ling(&text) {
285                self.glyphs.insert(ch, g);
286                return;
287            }
288        }
289        let g = self.extract(ch);
290        let _ = std::fs::create_dir_all(&self.cache_dir);
291        let _ = std::fs::write(&file, serialize_glyph_ling(&self.name, ch, &g));
292        self.glyphs.insert(ch, g);
293    }
294
295    /// Pull the outline straight from the TTF, normalize to em, compress.
296    fn extract(&self, ch: char) -> Glyph {
297        let mut face = match ttf_parser::Face::parse(&self.bytes, 0) {
298            Ok(f) => f,
299            Err(_) => return Glyph { contours: vec![], advance: 0.5 },
300        };
301        // Pin the weight axis on variable fonts for a bolder, solid look.
302        if let Some(w) = self.weight {
303            let _ = face.set_variation(ttf_parser::Tag::from_bytes(b"wght"), w);
304        }
305        let gid = match face.glyph_index(ch) {
306            Some(g) => g,
307            None => return Glyph { contours: vec![], advance: 0.5 },
308        };
309        let advance = face
310            .glyph_hor_advance(gid)
311            .map(|a| a as f32 / self.upm)
312            .unwrap_or(0.5);
313
314        let mut col = Collector::default();
315        face.outline_glyph(gid, &mut col);
316        col.finish_cur();
317
318        let upm = self.upm;
319        let n = |p: [f32; 2]| [p[0] / upm, p[1] / upm];
320        let contours = col
321            .contours
322            .into_iter()
323            .map(|c| {
324                let start = n(c.start);
325                let segs: Vec<Seg> = c
326                    .segs
327                    .iter()
328                    .map(|s| match s {
329                        Seg::Line(p) => Seg::Line(n(*p)),
330                        Seg::Quad(a, p) => Seg::Quad(n(*a), n(*p)),
331                        Seg::Cubic(a, b, p) => Seg::Cubic(n(*a), n(*b), n(*p)),
332                    })
333                    .collect();
334                let segs = compress_contour(start, &segs);
335                Contour { start, segs }
336            })
337            .collect();
338
339        Glyph { contours, advance }
340    }
341
342    /// Normalized advance width of `ch`.
343    pub fn advance(&mut self, ch: char) -> f32 {
344        self.ensure(ch);
345        self.glyphs[&ch].advance
346    }
347
348    /// Pixel width of `text` at size `px`.
349    pub fn measure(&mut self, text: &str, px: f32) -> f32 {
350        text.chars().map(|c| self.advance(c)).sum::<f32>() * px
351    }
352
353    /// Flattened outline of `ch`, with curves subdivided so the deviation stays
354    /// under `tol_em` (express your pixel tolerance as `tol_px / px`).
355    pub fn glyph_outline(&mut self, ch: char, tol_em: f32) -> GlyphOutline {
356        let tol = tol_em.max(1e-5);
357        // Cache flattened outlines: béziers are only subdivided once per (char,
358        // tolerance), so drawing the same glyphs every frame stops re-tessellating.
359        let key = (ch, (tol * 100_000.0) as u32);
360        if let Some(o) = self.outline_cache.get(&key) {
361            return o.clone();
362        }
363        self.ensure(ch);
364        let g = &self.glyphs[&ch];
365        let mut polylines = Vec::with_capacity(g.contours.len());
366        for c in &g.contours {
367            let mut pl = Vec::new();
368            let mut cur = c.start;
369            pl.push(cur);
370            for s in &c.segs {
371                match s {
372                    Seg::Line(p) => {
373                        pl.push(*p);
374                        cur = *p;
375                    },
376                    Seg::Quad(ctrl, p) => {
377                        flat_quad(cur, *ctrl, *p, tol, &mut pl);
378                        cur = *p;
379                    },
380                    Seg::Cubic(a, b, p) => {
381                        flat_cubic(cur, *a, *b, *p, tol, &mut pl);
382                        cur = *p;
383                    },
384                }
385            }
386            // close the contour back to its start
387            if pl.len() > 1 {
388                pl.push(c.start);
389            }
390            polylines.push(pl);
391        }
392        let out = GlyphOutline { polylines, advance: g.advance };
393        self.outline_cache.insert(key, out.clone());
394        out
395    }
396}
397
398// ── (De)serialization ────────────────────────────────────────────────────────
399
400fn serialize_glyph_ling(font: &str, ch: char, g: &Glyph) -> String {
401    let mut s = String::new();
402    s.push_str(&format!(
403        "# ling glyph — font={font} cp={} char={} adv={:.4}\n",
404        ch as u32, ch, g.advance
405    ));
406    for c in &g.contours {
407        s.push_str(&format!("M {:.4} {:.4}\n", c.start[0], c.start[1]));
408        for seg in &c.segs {
409            match seg {
410                Seg::Line(p) => s.push_str(&format!("L {:.4} {:.4}\n", p[0], p[1])),
411                Seg::Quad(a, p) => s.push_str(&format!(
412                    "Q {:.4} {:.4} {:.4} {:.4}\n",
413                    a[0], a[1], p[0], p[1]
414                )),
415                Seg::Cubic(a, b, p) => s.push_str(&format!(
416                    "C {:.4} {:.4} {:.4} {:.4} {:.4} {:.4}\n",
417                    a[0], a[1], b[0], b[1], p[0], p[1]
418                )),
419            }
420        }
421        s.push_str("Z\n");
422    }
423    s
424}
425
426fn parse_glyph_ling(text: &str) -> Option<Glyph> {
427    let mut advance = 0.5f32;
428    let mut contours: Vec<Contour> = Vec::new();
429    let mut cur: Option<Contour> = None;
430    for line in text.lines() {
431        let line = line.trim();
432        if line.is_empty() {
433            continue;
434        }
435        if let Some(rest) = line.strip_prefix('#') {
436            if let Some(i) = rest.find("adv=") {
437                if let Ok(v) = rest[i + 4..]
438                    .split_whitespace()
439                    .next()
440                    .unwrap_or("")
441                    .parse::<f32>()
442                {
443                    advance = v;
444                }
445            }
446            continue;
447        }
448        let mut it = line.split_whitespace();
449        let op = it.next()?;
450        let nums: Vec<f32> = it.filter_map(|t| t.parse::<f32>().ok()).collect();
451        match op {
452            "M" => {
453                if let Some(c) = cur.take() {
454                    contours.push(c);
455                }
456                cur = Some(Contour { start: [*nums.first()?, *nums.get(1)?], segs: Vec::new() });
457            },
458            "L" => {
459                if let Some(c) = &mut cur {
460                    c.segs.push(Seg::Line([*nums.first()?, *nums.get(1)?]));
461                }
462            },
463            "Q" => {
464                if let Some(c) = &mut cur {
465                    c.segs.push(Seg::Quad(
466                        [*nums.first()?, *nums.get(1)?],
467                        [*nums.get(2)?, *nums.get(3)?],
468                    ));
469                }
470            },
471            "C" => {
472                if let Some(c) = &mut cur {
473                    c.segs.push(Seg::Cubic(
474                        [*nums.first()?, *nums.get(1)?],
475                        [*nums.get(2)?, *nums.get(3)?],
476                        [*nums.get(4)?, *nums.get(5)?],
477                    ));
478                }
479            },
480            "Z" => {
481                if let Some(c) = cur.take() {
482                    contours.push(c);
483                }
484            },
485            _ => {},
486        }
487    }
488    if let Some(c) = cur.take() {
489        contours.push(c);
490    }
491    Some(Glyph { contours, advance })
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497
498    #[test]
499    fn collinear_lines_merge() {
500        let segs = vec![
501            Seg::Line([0.5, 0.0]),
502            Seg::Line([1.0, 0.0]),
503            Seg::Line([1.0, 1.0]),
504        ];
505        let out = compress_contour([0.0, 0.0], &segs);
506        // the two collinear horizontal lines collapse into one
507        assert_eq!(out.len(), 2);
508        match out[0] {
509            Seg::Line(p) => assert_eq!(p, [1.0, 0.0]),
510            _ => panic!(),
511        }
512    }
513
514    #[test]
515    fn glyph_ling_roundtrips() {
516        let g = Glyph {
517            advance: 0.6,
518            contours: vec![Contour {
519                start: [0.1, 0.0],
520                segs: vec![
521                    Seg::Line([0.4, 0.7]),
522                    Seg::Quad([0.5, 0.8], [0.6, 0.7]),
523                    Seg::Line([0.9, 0.0]),
524                ],
525            }],
526        };
527        let text = serialize_glyph_ling("Test", 'A', &g);
528        let back = parse_glyph_ling(&text).unwrap();
529        assert!((back.advance - 0.6).abs() < 1e-3);
530        assert_eq!(back.contours.len(), 1);
531        assert_eq!(back.contours[0].segs.len(), 3);
532        // curve preserved as a curve, not flattened
533        assert!(matches!(back.contours[0].segs[1], Seg::Quad(..)));
534    }
535}