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
12pub const BLANK_DISTANCE: f32 = 1.0e9;
14
15pub 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#[derive(Clone, Debug, PartialEq)]
44pub struct GlyphParams {
45 pub em_size: f32,
46 pub depth: f32,
48 pub slab: (f32, f32),
50 pub resolution: u32,
52 pub collider_resolution: u32,
54 pub flatten_tolerance_em: f32,
56 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
98pub 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 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"), ¶ms()).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"), ¶ms()).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", ¶ms()).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", ¶ms()).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", ¶ms()).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", ¶ms()).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", ¶ms()).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", ¶ms()).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}