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 { start: [f32; 2], segs: Vec<Seg> }
38
39#[derive(Clone, Debug, Default)]
40struct Glyph { contours: Vec<Contour>, advance: f32 }
41
42/// Flattened polylines for one glyph plus its advance — all in normalized em
43/// space (x→right, **y→up**, baseline at 0). Callers map this into 2D screen
44/// space or onto a 3D plane.
45#[derive(Clone)]
46pub struct GlyphOutline {
47    pub polylines: Vec<Vec<[f32; 2]>>,
48    pub advance: f32,
49}
50
51// ── Outline extraction from ttf-parser ───────────────────────────────────────
52
53#[derive(Default)]
54struct Collector {
55    contours: Vec<Contour>,
56    cur: Option<Contour>,
57    cp: [f32; 2],
58}
59
60impl Collector {
61    fn finish_cur(&mut self) {
62        if let Some(c) = self.cur.take() {
63            if !c.segs.is_empty() { self.contours.push(c); }
64        }
65    }
66}
67
68impl OutlineBuilder for Collector {
69    fn move_to(&mut self, x: f32, y: f32) {
70        self.finish_cur();
71        self.cp = [x, y];
72        self.cur = Some(Contour { start: [x, y], segs: Vec::new() });
73    }
74    fn line_to(&mut self, x: f32, y: f32) {
75        if let Some(c) = &mut self.cur { c.segs.push(Seg::Line([x, y])); }
76        self.cp = [x, y];
77    }
78    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
79        if let Some(c) = &mut self.cur { c.segs.push(Seg::Quad([x1, y1], [x, y])); }
80        self.cp = [x, y];
81    }
82    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
83        if let Some(c) = &mut self.cur { c.segs.push(Seg::Cubic([x1, y1], [x2, y2], [x, y])); }
84        self.cp = [x, y];
85    }
86    fn close(&mut self) { self.finish_cur(); }
87}
88
89// ── Curve compression: merge near-collinear consecutive line segments ────────
90
91fn unit(d: [f32; 2]) -> Option<[f32; 2]> {
92    let l = (d[0] * d[0] + d[1] * d[1]).sqrt();
93    if l < 1e-9 { None } else { Some([d[0] / l, d[1] / l]) }
94}
95
96/// Drop redundant interior points on straight runs (Line→Line where the two
97/// edges point the same way). Curves are left untouched.
98fn compress_contour(start: [f32; 2], segs: &[Seg]) -> Vec<Seg> {
99    let mut out: Vec<Seg> = Vec::with_capacity(segs.len());
100    let mut cp = start;          // current on-curve point
101    let mut line_a: Option<[f32; 2]> = None; // start of the last Line in `out`
102    for seg in segs {
103        match seg {
104            Seg::Line(p) => {
105                if let (Some(a), Some(Seg::Line(_))) = (line_a, out.last()) {
106                    let d1 = unit([cp[0] - a[0], cp[1] - a[1]]);
107                    let d2 = unit([p[0] - cp[0], p[1] - cp[1]]);
108                    if let (Some(d1), Some(d2)) = (d1, d2) {
109                        let cross = (d1[0] * d2[1] - d1[1] * d2[0]).abs();
110                        let dot = d1[0] * d2[0] + d1[1] * d2[1];
111                        if cross < 2.0e-3 && dot > 0.0 {
112                            *out.last_mut().unwrap() = Seg::Line(*p); // extend a→p
113                            cp = *p;
114                            continue;
115                        }
116                    }
117                }
118                out.push(Seg::Line(*p));
119                line_a = Some(cp);
120                cp = *p;
121            }
122            Seg::Quad(c, p)      => { out.push(Seg::Quad(*c, *p));      cp = *p; line_a = None; }
123            Seg::Cubic(a, b, p)  => { out.push(Seg::Cubic(*a, *b, *p)); cp = *p; line_a = None; }
124        }
125    }
126    out
127}
128
129// ── Adaptive flattening (de Casteljau, screen-pixel tolerance in em units) ───
130
131fn flat_quad(p0: [f32; 2], c: [f32; 2], p1: [f32; 2], tol: f32, out: &mut Vec<[f32; 2]>) {
132    // distance from control point to the chord
133    let dx = p1[0] - p0[0]; let dy = p1[1] - p0[1];
134    let d = ((c[0] - p0[0]) * dy - (c[1] - p0[1]) * dx).abs();
135    let chord2 = dx * dx + dy * dy;
136    if d * d <= tol * tol * chord2 || chord2 < 1e-12 {
137        out.push(p1);
138        return;
139    }
140    let m = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
141    let p01 = m(p0, c); let p12 = m(c, p1); let mid = m(p01, p12);
142    flat_quad(p0, p01, mid, tol, out);
143    flat_quad(mid, p12, p1, tol, out);
144}
145
146fn flat_cubic(p0: [f32; 2], c1: [f32; 2], c2: [f32; 2], p1: [f32; 2], tol: f32, out: &mut Vec<[f32; 2]>) {
147    let dx = p1[0] - p0[0]; let dy = p1[1] - p0[1];
148    let d1 = ((c1[0] - p0[0]) * dy - (c1[1] - p0[1]) * dx).abs();
149    let d2 = ((c2[0] - p0[0]) * dy - (c2[1] - p0[1]) * dx).abs();
150    let chord2 = dx * dx + dy * dy;
151    if (d1 + d2) * (d1 + d2) <= tol * tol * chord2 || chord2 < 1e-12 {
152        out.push(p1);
153        return;
154    }
155    let m = |a: [f32; 2], b: [f32; 2]| [(a[0] + b[0]) * 0.5, (a[1] + b[1]) * 0.5];
156    let p01 = m(p0, c1); let p12 = m(c1, c2); let p23 = m(c2, p1);
157    let p012 = m(p01, p12); let p123 = m(p12, p23); let mid = m(p012, p123);
158    flat_cubic(p0, p01, p012, mid, tol, out);
159    flat_cubic(mid, p123, p23, p1, tol, out);
160}
161
162// ── The font ─────────────────────────────────────────────────────────────────
163
164pub struct VectorFont {
165    bytes: Vec<u8>,
166    name: String,
167    upm: f32,
168    ascent: f32,   // normalized
169    descent: f32,  // normalized (negative)
170    /// Desired weight on the variable-font `wght` axis (e.g. 600 for a bold,
171    /// solid UI look). `None` → use the font's default instance.
172    weight: Option<f32>,
173    cache_dir: PathBuf,
174    glyphs: HashMap<char, Glyph>,
175    /// Tessellated-outline cache keyed by (char, tolerance bucket). Flattening the
176    /// béziers is the per-call cost; caching it makes repeated per-frame draws of
177    /// the same glyphs (UI text, glyph rings, …) effectively free.
178    outline_cache: HashMap<(char, u32), GlyphOutline>,
179}
180
181impl VectorFont {
182    /// Load a font from a TTF/OTF file using its default weight.
183    pub fn from_path(path: &str) -> Result<Self, String> {
184        Self::from_path_weight(path, None)
185    }
186
187    /// Load a font, optionally pinning the variable-font weight axis (`wght`).
188    /// The glyph cache lives at `cache/fonts/<file-stem>[@<weight>]/`.
189    pub fn from_path_weight(path: &str, weight: Option<f32>) -> Result<Self, String> {
190        let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?;
191        let name = std::path::Path::new(path)
192            .file_stem().map(|s| s.to_string_lossy().into_owned())
193            .unwrap_or_else(|| "font".into());
194        Self::from_bytes(bytes, &name, weight)
195    }
196
197    pub fn from_bytes(bytes: Vec<u8>, name: &str, weight: Option<f32>) -> Result<Self, String> {
198        let face = ttf_parser::Face::parse(&bytes, 0).map_err(|e| format!("{e:?}"))?;
199        let upm = face.units_per_em() as f32;
200        let ascent = face.ascender() as f32 / upm;
201        let descent = face.descender() as f32 / upm;
202        let dir = match weight {
203            Some(w) => format!("{name}@{}", w as i32),
204            None => name.to_string(),
205        };
206        let cache_dir = PathBuf::from("cache").join("fonts").join(dir);
207        Ok(Self {
208            bytes,
209            name: name.to_string(),
210            upm, ascent, descent,
211            weight,
212            cache_dir,
213            glyphs: HashMap::new(),
214            outline_cache: HashMap::new(),
215        })
216    }
217
218    pub fn ascent(&self)  -> f32 { self.ascent }
219    pub fn descent(&self) -> f32 { self.descent }
220
221    /// Ensure the glyph for `ch` is in memory: hot-cache → on-disk `.ling` →
222    /// extract-from-TTF (then write the `.ling`).
223    fn ensure(&mut self, ch: char) {
224        if self.glyphs.contains_key(&ch) { return; }
225        let file = self.cache_dir.join(format!("{}.ling", ch as u32));
226        if let Ok(text) = std::fs::read_to_string(&file) {
227            if let Some(g) = parse_glyph_ling(&text) {
228                self.glyphs.insert(ch, g);
229                return;
230            }
231        }
232        let g = self.extract(ch);
233        let _ = std::fs::create_dir_all(&self.cache_dir);
234        let _ = std::fs::write(&file, serialize_glyph_ling(&self.name, ch, &g));
235        self.glyphs.insert(ch, g);
236    }
237
238    /// Pull the outline straight from the TTF, normalize to em, compress.
239    fn extract(&self, ch: char) -> Glyph {
240        let mut face = match ttf_parser::Face::parse(&self.bytes, 0) {
241            Ok(f) => f,
242            Err(_) => return Glyph { contours: vec![], advance: 0.5 },
243        };
244        // Pin the weight axis on variable fonts for a bolder, solid look.
245        if let Some(w) = self.weight {
246            let _ = face.set_variation(ttf_parser::Tag::from_bytes(b"wght"), w);
247        }
248        let gid = match face.glyph_index(ch) {
249            Some(g) => g,
250            None => return Glyph { contours: vec![], advance: 0.5 },
251        };
252        let advance = face.glyph_hor_advance(gid).map(|a| a as f32 / self.upm).unwrap_or(0.5);
253
254        let mut col = Collector::default();
255        face.outline_glyph(gid, &mut col);
256        col.finish_cur();
257
258        let upm = self.upm;
259        let n = |p: [f32; 2]| [p[0] / upm, p[1] / upm];
260        let contours = col.contours.into_iter().map(|c| {
261            let start = n(c.start);
262            let segs: Vec<Seg> = c.segs.iter().map(|s| match s {
263                Seg::Line(p)     => Seg::Line(n(*p)),
264                Seg::Quad(a, p)  => Seg::Quad(n(*a), n(*p)),
265                Seg::Cubic(a, b, p) => Seg::Cubic(n(*a), n(*b), n(*p)),
266            }).collect();
267            let segs = compress_contour(start, &segs);
268            Contour { start, segs }
269        }).collect();
270
271        Glyph { contours, advance }
272    }
273
274    /// Normalized advance width of `ch`.
275    pub fn advance(&mut self, ch: char) -> f32 {
276        self.ensure(ch);
277        self.glyphs[&ch].advance
278    }
279
280    /// Pixel width of `text` at size `px`.
281    pub fn measure(&mut self, text: &str, px: f32) -> f32 {
282        text.chars().map(|c| self.advance(c)).sum::<f32>() * px
283    }
284
285    /// Flattened outline of `ch`, with curves subdivided so the deviation stays
286    /// under `tol_em` (express your pixel tolerance as `tol_px / px`).
287    pub fn glyph_outline(&mut self, ch: char, tol_em: f32) -> GlyphOutline {
288        let tol = tol_em.max(1e-5);
289        // Cache flattened outlines: béziers are only subdivided once per (char,
290        // tolerance), so drawing the same glyphs every frame stops re-tessellating.
291        let key = (ch, (tol * 100_000.0) as u32);
292        if let Some(o) = self.outline_cache.get(&key) {
293            return o.clone();
294        }
295        self.ensure(ch);
296        let g = &self.glyphs[&ch];
297        let mut polylines = Vec::with_capacity(g.contours.len());
298        for c in &g.contours {
299            let mut pl = Vec::new();
300            let mut cur = c.start;
301            pl.push(cur);
302            for s in &c.segs {
303                match s {
304                    Seg::Line(p)        => { pl.push(*p); cur = *p; }
305                    Seg::Quad(ctrl, p)  => { flat_quad(cur, *ctrl, *p, tol, &mut pl); cur = *p; }
306                    Seg::Cubic(a, b, p) => { flat_cubic(cur, *a, *b, *p, tol, &mut pl); cur = *p; }
307                }
308            }
309            // close the contour back to its start
310            if pl.len() > 1 { pl.push(c.start); }
311            polylines.push(pl);
312        }
313        let out = GlyphOutline { polylines, advance: g.advance };
314        self.outline_cache.insert(key, out.clone());
315        out
316    }
317}
318
319// ── (De)serialization ────────────────────────────────────────────────────────
320
321fn serialize_glyph_ling(font: &str, ch: char, g: &Glyph) -> String {
322    let mut s = String::new();
323    s.push_str(&format!(
324        "# ling glyph — font={font} cp={} char={} adv={:.4}\n",
325        ch as u32, ch, g.advance
326    ));
327    for c in &g.contours {
328        s.push_str(&format!("M {:.4} {:.4}\n", c.start[0], c.start[1]));
329        for seg in &c.segs {
330            match seg {
331                Seg::Line(p) => s.push_str(&format!("L {:.4} {:.4}\n", p[0], p[1])),
332                Seg::Quad(a, p) =>
333                    s.push_str(&format!("Q {:.4} {:.4} {:.4} {:.4}\n", a[0], a[1], p[0], p[1])),
334                Seg::Cubic(a, b, p) =>
335                    s.push_str(&format!("C {:.4} {:.4} {:.4} {:.4} {:.4} {:.4}\n",
336                        a[0], a[1], b[0], b[1], p[0], p[1])),
337            }
338        }
339        s.push_str("Z\n");
340    }
341    s
342}
343
344fn parse_glyph_ling(text: &str) -> Option<Glyph> {
345    let mut advance = 0.5f32;
346    let mut contours: Vec<Contour> = Vec::new();
347    let mut cur: Option<Contour> = None;
348    for line in text.lines() {
349        let line = line.trim();
350        if line.is_empty() { continue; }
351        if let Some(rest) = line.strip_prefix('#') {
352            if let Some(i) = rest.find("adv=") {
353                if let Ok(v) = rest[i + 4..].split_whitespace().next().unwrap_or("").parse::<f32>() {
354                    advance = v;
355                }
356            }
357            continue;
358        }
359        let mut it = line.split_whitespace();
360        let op = it.next()?;
361        let nums: Vec<f32> = it.filter_map(|t| t.parse::<f32>().ok()).collect();
362        match op {
363            "M" => {
364                if let Some(c) = cur.take() { contours.push(c); }
365                cur = Some(Contour { start: [*nums.first()?, *nums.get(1)?], segs: Vec::new() });
366            }
367            "L" => { if let Some(c) = &mut cur { c.segs.push(Seg::Line([*nums.first()?, *nums.get(1)?])); } }
368            "Q" => { if let Some(c) = &mut cur {
369                c.segs.push(Seg::Quad([*nums.first()?, *nums.get(1)?], [*nums.get(2)?, *nums.get(3)?])); } }
370            "C" => { if let Some(c) = &mut cur {
371                c.segs.push(Seg::Cubic([*nums.first()?, *nums.get(1)?], [*nums.get(2)?, *nums.get(3)?], [*nums.get(4)?, *nums.get(5)?])); } }
372            "Z" => { if let Some(c) = cur.take() { contours.push(c); } }
373            _ => {}
374        }
375    }
376    if let Some(c) = cur.take() { contours.push(c); }
377    Some(Glyph { contours, advance })
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn collinear_lines_merge() {
386        let segs = vec![Seg::Line([0.5, 0.0]), Seg::Line([1.0, 0.0]), Seg::Line([1.0, 1.0])];
387        let out = compress_contour([0.0, 0.0], &segs);
388        // the two collinear horizontal lines collapse into one
389        assert_eq!(out.len(), 2);
390        match out[0] { Seg::Line(p) => assert_eq!(p, [1.0, 0.0]), _ => panic!() }
391    }
392
393    #[test]
394    fn glyph_ling_roundtrips() {
395        let g = Glyph {
396            advance: 0.6,
397            contours: vec![Contour {
398                start: [0.1, 0.0],
399                segs: vec![Seg::Line([0.4, 0.7]), Seg::Quad([0.5, 0.8], [0.6, 0.7]), Seg::Line([0.9, 0.0])],
400            }],
401        };
402        let text = serialize_glyph_ling("Test", 'A', &g);
403        let back = parse_glyph_ling(&text).unwrap();
404        assert!((back.advance - 0.6).abs() < 1e-3);
405        assert_eq!(back.contours.len(), 1);
406        assert_eq!(back.contours[0].segs.len(), 3);
407        // curve preserved as a curve, not flattened
408        assert!(matches!(back.contours[0].segs[1], Seg::Quad(..)));
409    }
410}