Skip to main content

loam_text/glyph/
mod.rs

1mod field;
2mod hull;
3mod outline;
4mod solid;
5
6pub use field::DistanceField2D;
7pub use solid::{append_field_prism, GlyphSolid};
8
9use ab_glyph::{Font, FontRef, GlyphId};
10use glam::Vec2;
11
12/// Finite so callers can still combine it with `min`/`max` without producing NaN.
13pub const BLANK_DISTANCE: f32 = 1.0e9;
14
15/// Below this many cells per em the render bake cannot resolve a stem.
16pub const MIN_RESOLUTION: u32 = 4;
17
18#[derive(Debug, Clone, PartialEq, thiserror::Error)]
19pub enum GlyphError {
20    #[error("font has no glyph for {ch:?}")]
21    NoGlyph { ch: char },
22
23    #[error("{ch:?} is a control character; the glyph pipeline takes printable text only")]
24    ControlCharacter { ch: char },
25
26    #[error("outline for {ch:?} encloses no area")]
27    DegenerateOutline { ch: char },
28
29    #[error("font declares no units_per_em")]
30    NoUnitsPerEm,
31
32    #[error("GlyphParams::{field} must be finite and positive, got {value}")]
33    NonPositive { field: &'static str, value: f32 },
34
35    #[error("GlyphParams::{field} must be at least {MIN_RESOLUTION}, got {resolution}")]
36    Resolution {
37        field: &'static str,
38        resolution: u32,
39    },
40}
41
42/// All lengths are world units except [`Self::flatten_tolerance_em`].
43#[derive(Clone, Debug, PartialEq)]
44pub struct GlyphParams {
45    pub em_size: f32,
46    /// Extent along `z`, centred on `z = 0`.
47    pub depth: f32,
48    /// `(min, max)` `w` extent of the slab the letter occupies.
49    pub slab: (f32, f32),
50    /// Grid cells per em for the bake and the render decomposition.
51    pub resolution: u32,
52    /// Grid cells per em for the collider cover.
53    pub collider_resolution: u32,
54    /// Requested chord deviation in em, subject to a 64-subdivision budget per Bezier segment.
55    pub flatten_tolerance_em: f32,
56    /// RGBA linear.
57    pub color: [f32; 4],
58}
59
60impl Default for GlyphParams {
61    fn default() -> Self {
62        Self {
63            em_size: 1.0,
64            depth: 0.15,
65            slab: (-0.075, 0.075),
66            resolution: 48,
67            collider_resolution: 48,
68            flatten_tolerance_em: 0.002,
69            color: [1.0, 1.0, 1.0, 1.0],
70        }
71    }
72}
73
74impl GlyphParams {
75    fn validate(&self) -> Result<(), GlyphError> {
76        for (field, value) in [
77            ("em_size", self.em_size),
78            ("depth", self.depth),
79            ("slab", self.slab.1 - self.slab.0),
80            ("flatten_tolerance_em", self.flatten_tolerance_em),
81        ] {
82            if value <= 0.0 || !value.is_finite() {
83                return Err(GlyphError::NonPositive { field, value });
84            }
85        }
86        for (field, resolution) in [
87            ("resolution", self.resolution),
88            ("collider_resolution", self.collider_resolution),
89        ] {
90            if resolution < MIN_RESOLUTION {
91                return Err(GlyphError::Resolution { field, resolution });
92            }
93        }
94        Ok(())
95    }
96}
97
98/// Places glyphs in a shared word frame with baseline `y = 0` and first pen origin `x = 0`.
99pub fn layout_word(
100    font: &FontRef<'_>,
101    text: &str,
102    params: &GlyphParams,
103) -> Result<Vec<GlyphSolid>, GlyphError> {
104    params.validate()?;
105    let units_per_em = font.units_per_em().ok_or(GlyphError::NoUnitsPerEm)?;
106    let units_to_world = params.em_size / units_per_em;
107    let tolerance_world = params.flatten_tolerance_em * params.em_size;
108    let cell = params.em_size / params.resolution as f32;
109
110    let mut solids = Vec::with_capacity(text.chars().count());
111    let mut pen_x = 0.0_f32;
112    let mut previous: Option<GlyphId> = None;
113
114    for ch in text.chars() {
115        if ch.is_control() {
116            return Err(GlyphError::ControlCharacter { ch });
117        }
118        let id = font.glyph_id(ch);
119        // Glyph 0 is `.notdef` by the OpenType specification.
120        if id.0 == 0 {
121            return Err(GlyphError::NoGlyph { ch });
122        }
123
124        if let Some(previous) = previous {
125            pen_x += font.kern_unscaled(previous, id) * units_to_world;
126        }
127        let advance = font.h_advance_unscaled(id) * units_to_world;
128        let pen_origin = Vec2::new(pen_x, 0.0);
129
130        let field = match font.outline(id) {
131            None => None,
132            Some(raw) => {
133                let mut contours =
134                    outline::contours_from_curves(&raw.curves, units_to_world, tolerance_world);
135                for contour in &mut contours {
136                    for point in &mut contour.points {
137                        *point += pen_origin;
138                    }
139                }
140                Some(
141                    field::DistanceField2D::bake(&contours, cell)
142                        .ok_or(GlyphError::DegenerateOutline { ch })?,
143                )
144            }
145        };
146
147        solids.push(GlyphSolid::new(ch, pen_origin, advance, params, field));
148        pen_x += advance;
149        previous = Some(id);
150    }
151
152    Ok(solids)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use loam_shape::{Shape, Visualizable};
159
160    fn params() -> GlyphParams {
161        GlyphParams {
162            resolution: 32,
163            ..GlyphParams::default()
164        }
165    }
166
167    #[test]
168    fn nonpositive_and_undersized_params_are_rejected() {
169        let bad_depth = GlyphParams {
170            depth: 0.0,
171            ..GlyphParams::default()
172        };
173        assert_eq!(
174            bad_depth.validate().unwrap_err(),
175            GlyphError::NonPositive {
176                field: "depth",
177                value: 0.0
178            }
179        );
180
181        let inverted_slab = GlyphParams {
182            slab: (1.0, -1.0),
183            ..GlyphParams::default()
184        };
185        assert!(matches!(
186            inverted_slab.validate().unwrap_err(),
187            GlyphError::NonPositive { field: "slab", .. }
188        ));
189
190        let coarse_render = GlyphParams {
191            resolution: MIN_RESOLUTION - 1,
192            ..GlyphParams::default()
193        };
194        assert_eq!(
195            coarse_render.validate().unwrap_err(),
196            GlyphError::Resolution {
197                field: "resolution",
198                resolution: MIN_RESOLUTION - 1
199            }
200        );
201
202        let coarse_collider = GlyphParams {
203            collider_resolution: MIN_RESOLUTION - 1,
204            ..GlyphParams::default()
205        };
206        assert_eq!(
207            coarse_collider.validate().unwrap_err(),
208            GlyphError::Resolution {
209                field: "collider_resolution",
210                resolution: MIN_RESOLUTION - 1
211            }
212        );
213
214        assert!(GlyphParams::default().validate().is_ok());
215    }
216
217    #[test]
218    fn nonpositive_or_nan_flatten_tolerance_is_rejected() {
219        for value in [0.0, -0.002, f32::NAN, f32::INFINITY] {
220            let params = GlyphParams {
221                flatten_tolerance_em: value,
222                ..GlyphParams::default()
223            };
224            let error = params.validate().unwrap_err();
225            assert!(
226                matches!(
227                    error,
228                    GlyphError::NonPositive {
229                        field: "flatten_tolerance_em",
230                        ..
231                    }
232                ),
233                "tolerance {value} gave {error}"
234            );
235        }
236    }
237
238    #[test]
239    fn characters_the_font_lacks_are_rejected() {
240        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
241        let font = FontRef::try_from_slice(bytes).expect("parse font");
242        let missing = '\u{10FFFF}';
243        assert_eq!(font.glyph_id(missing), GlyphId(0));
244        let error = layout_word(&font, &format!("LO{missing}AM"), &params()).unwrap_err();
245        assert_eq!(
246            error,
247            GlyphError::NoGlyph { ch: missing },
248            "expected a loud failure, got {error}"
249        );
250    }
251
252    #[test]
253    fn control_characters_are_rejected() {
254        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
255        let font = FontRef::try_from_slice(bytes).expect("parse font");
256        for ch in ['\t', '\n', '\u{7F}'] {
257            assert_eq!(
258                layout_word(&font, &format!("A{ch}B"), &params()).unwrap_err(),
259                GlyphError::ControlCharacter { ch }
260            );
261        }
262    }
263
264    #[test]
265    fn letters_are_placed_at_their_own_pen_positions() {
266        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
267        let font = FontRef::try_from_slice(bytes).expect("parse font");
268        let letters = layout_word(&font, "LOAM", &params()).expect("layout");
269
270        let mut previous_centroid_x = f32::NEG_INFINITY;
271        for letter in &letters {
272            let mesh = Visualizable::<3>::to_triangles(letter).expect("mesh");
273            assert!(!mesh.vertices.is_empty());
274            let centroid_x: f32 =
275                mesh.vertices.iter().map(|v| v[0]).sum::<f32>() / mesh.vertices.len() as f32;
276            assert!(
277                centroid_x > previous_centroid_x,
278                "{:?} centroid {centroid_x} did not advance past {previous_centroid_x}",
279                letter.ch()
280            );
281            previous_centroid_x = centroid_x;
282
283            let cell = letter.field().expect("field").cell_size();
284            let left = letter.pen_origin().x - 0.2 * letter.advance() - 2.0 * cell;
285            let right = letter.pen_origin().x + 1.2 * letter.advance() + 2.0 * cell;
286            for v in &mesh.vertices {
287                assert!(
288                    v[0] > left && v[0] < right,
289                    "{:?} strays to {}",
290                    letter.ch(),
291                    v[0]
292                );
293            }
294        }
295    }
296
297    #[test]
298    fn counters_stay_open() {
299        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
300        let font = FontRef::try_from_slice(bytes).expect("parse font");
301        let o = &layout_word(&font, "O", &params()).expect("layout")[0];
302
303        let mesh = Visualizable::<3>::to_triangles(o).expect("mesh");
304        let min_y = mesh.vertices.iter().fold(f32::INFINITY, |m, v| m.min(v[1]));
305        let max_y = mesh
306            .vertices
307            .iter()
308            .fold(f32::NEG_INFINITY, |m, v| m.max(v[1]));
309        let mid_y = 0.5 * (min_y + max_y);
310
311        const PROBES: u32 = 512;
312        let mut crossings = 0;
313        let mut previous = None;
314        for k in 0..=PROBES {
315            let x = o.pen_origin().x + (k as f32 / PROBES as f32 - 0.05) * o.advance() * 1.1;
316            let inside = o.distance_2d(Vec2::new(x, mid_y)) < 0.0;
317            if previous.is_some_and(|was| was != inside) {
318                crossings += 1;
319            }
320            previous = Some(inside);
321        }
322        assert_eq!(crossings, 4, "'O' crossed {crossings} times, not 4");
323    }
324
325    #[test]
326    fn letters_serve_as_render_geometry_and_colliders() {
327        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
328        let font = FontRef::try_from_slice(bytes).expect("parse font");
329        let letters = layout_word(&font, "LOAM", &params()).expect("layout");
330
331        for letter in &letters {
332            let mesh = Visualizable::<3>::to_triangles(letter).expect("mesh");
333            assert!(mesh.indices.len() >= 2);
334            assert_eq!(mesh.colors.len(), mesh.vertices.len());
335
336            let colliders = letter.colliders_4d();
337            assert_eq!(colliders.len(), letter.collider_count());
338            assert!(!colliders.is_empty());
339            let margin = letter.collider_margin();
340            for (centre, collider) in &colliders {
341                let Shape::ConvexPolytope4D { vertices } = collider else {
342                    panic!("collider is not 4D convex: {:?}", collider.kind());
343                };
344                assert_eq!(vertices.len(), 16);
345                for v in vertices {
346                    let world = *centre + *v;
347                    assert!(
348                        letter.distance_4d(world) <= margin,
349                        "{:?} collider vertex {world} lies {} outside the letter",
350                        letter.ch(),
351                        letter.distance_4d(world)
352                    );
353                }
354            }
355        }
356    }
357
358    #[test]
359    fn the_cover_encloses_every_letter_without_filling_its_counters() {
360        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
361        let font = FontRef::try_from_slice(bytes).expect("parse font");
362        let letters = layout_word(&font, "LOAM", &params()).expect("layout");
363
364        for letter in &letters {
365            let cover = letter.collider_cover().expect("cover");
366            assert!(!cover.clipped(), "{:?} ran off its domain", letter.ch());
367            let margin = letter.collider_margin();
368
369            const PROBES: usize = 161;
370            let x0 = letter.pen_origin().x - 0.25 * letter.advance();
371            let width = 1.5 * letter.advance();
372            let (mut ink, mut clear) = (0, 0);
373            for j in 0..PROBES {
374                for i in 0..PROBES {
375                    let p = Vec2::new(
376                        x0 + width * (i as f32 + 0.317) / PROBES as f32,
377                        -0.25 + 1.25 * (j as f32 + 0.211) / PROBES as f32,
378                    );
379                    let d = letter.distance_2d(p);
380                    if d <= 0.0 {
381                        ink += 1;
382                        assert!(
383                            cover.contains(p.to_array()),
384                            "{:?} leaves ink at {p} uncovered",
385                            letter.ch()
386                        );
387                    } else if d > margin {
388                        clear += 1;
389                        assert!(
390                            !cover.contains(p.to_array()),
391                            "{:?} covers {p}, which is {d} clear of the ink",
392                            letter.ch()
393                        );
394                    }
395                }
396            }
397            assert!(ink > 1_000, "{:?}: only {ink} ink probes", letter.ch());
398            assert!(
399                clear > 1_000,
400                "{:?}: only {clear} clear probes",
401                letter.ch()
402            );
403        }
404    }
405
406    #[test]
407    fn the_collider_pitch_moves_the_box_count_and_not_the_render_mesh() {
408        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
409        let font = FontRef::try_from_slice(bytes).expect("parse font");
410        let fine = layout_word(&font, "LOAM", &params()).expect("layout");
411        let coarse_params = GlyphParams {
412            collider_resolution: 12,
413            ..params()
414        };
415        let coarse = layout_word(&font, "LOAM", &coarse_params).expect("layout");
416
417        let count = |ls: &[GlyphSolid]| ls.iter().map(GlyphSolid::collider_count).sum::<usize>();
418        assert!(
419            count(&coarse) < count(&fine),
420            "coarse {} is not below fine {}",
421            count(&coarse),
422            count(&fine)
423        );
424        for (a, b) in fine.iter().zip(&coarse) {
425            let (ma, mb) = (
426                Visualizable::<3>::to_triangles(a).expect("mesh"),
427                Visualizable::<3>::to_triangles(b).expect("mesh"),
428            );
429            assert_eq!(ma.vertices, mb.vertices);
430            assert_eq!(ma.indices, mb.indices);
431        }
432    }
433
434    #[test]
435    fn spaces_advance_without_geometry() {
436        let bytes = include_bytes!("../../../examples/fonts/lmroman10-bold.otf");
437        let font = FontRef::try_from_slice(bytes).expect("parse font");
438        let letters = layout_word(&font, "A B", &params()).expect("layout");
439
440        assert_eq!(letters.len(), 3);
441        assert!(letters[1].is_blank());
442        assert!(letters[1].advance() > 0.0);
443        assert!(letters[1].colliders_4d().is_empty());
444        assert!(letters[1].collider_cover().is_none());
445        assert!(!letters[0].is_blank() && !letters[2].is_blank());
446        assert!(letters[2].pen_origin().x > letters[0].pen_origin().x + letters[0].advance());
447    }
448}