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