Skip to main content

ogeom_algo/
text.rs

1//! Text to wires: a built-in single-stroke engraving font.
2//!
3//! The glyphs are the kernel's own, defined here as polyline strokes on a
4//! six-by-nine grid: the shapes a CNC engraver or a drawing title block
5//! wants, dependency-free and deterministic. Outline fonts are a file
6//! format problem: when a TTF reader arrives with the exchange work, its
7//! glyph outlines feed the same wire builder. Until then, what this speaks
8//! it speaks exactly, and any character it does not carry is refused by
9//! name rather than dropped.
10
11use ogeom_core::{OgeomResult, Tolerances, ogeom_bail};
12use ogeom_geom::LineCurve;
13use ogeom_math::{Frame, Point};
14use ogeom_topo::{Model, Shape};
15
16use crate::build::{make_compound, make_edge_between, make_vertex, make_wire};
17use crate::history::{Built, History};
18
19/// One glyph: strokes on a 6-wide grid with cap height 9, baseline 0.
20type Glyph = &'static [&'static [(f64, f64)]];
21
22/// The advance from one character origin to the next, in grid units.
23const ADVANCE: f64 = 8.0;
24/// The cap height the requested text height maps onto.
25const CAP: f64 = 9.0;
26
27fn glyph_of(c: char) -> Option<Glyph> {
28    let glyph: Glyph = match c.to_ascii_uppercase() {
29        'A' => &[
30            &[(0.0, 0.0), (3.0, 9.0), (6.0, 0.0)],
31            &[(1.0, 3.0), (5.0, 3.0)],
32        ],
33        'B' => &[
34            &[
35                (0.0, 0.0),
36                (0.0, 9.0),
37                (4.0, 9.0),
38                (5.0, 8.0),
39                (5.0, 6.0),
40                (4.0, 5.0),
41                (0.0, 5.0),
42            ],
43            &[(4.0, 5.0), (5.0, 4.0), (5.0, 1.0), (4.0, 0.0), (0.0, 0.0)],
44        ],
45        'C' => &[&[
46            (6.0, 7.0),
47            (4.0, 9.0),
48            (2.0, 9.0),
49            (0.0, 7.0),
50            (0.0, 2.0),
51            (2.0, 0.0),
52            (4.0, 0.0),
53            (6.0, 2.0),
54        ]],
55        'D' => &[&[
56            (0.0, 0.0),
57            (0.0, 9.0),
58            (3.0, 9.0),
59            (6.0, 7.0),
60            (6.0, 2.0),
61            (3.0, 0.0),
62            (0.0, 0.0),
63        ]],
64        'E' => &[
65            &[(6.0, 0.0), (0.0, 0.0), (0.0, 9.0), (6.0, 9.0)],
66            &[(0.0, 5.0), (4.0, 5.0)],
67        ],
68        'F' => &[
69            &[(0.0, 0.0), (0.0, 9.0), (6.0, 9.0)],
70            &[(0.0, 5.0), (4.0, 5.0)],
71        ],
72        'G' => &[&[
73            (6.0, 7.0),
74            (4.0, 9.0),
75            (2.0, 9.0),
76            (0.0, 7.0),
77            (0.0, 2.0),
78            (2.0, 0.0),
79            (4.0, 0.0),
80            (6.0, 2.0),
81            (6.0, 4.0),
82            (3.0, 4.0),
83        ]],
84        'H' => &[
85            &[(0.0, 0.0), (0.0, 9.0)],
86            &[(6.0, 0.0), (6.0, 9.0)],
87            &[(0.0, 5.0), (6.0, 5.0)],
88        ],
89        'I' => &[
90            &[(3.0, 0.0), (3.0, 9.0)],
91            &[(1.0, 9.0), (5.0, 9.0)],
92            &[(1.0, 0.0), (5.0, 0.0)],
93        ],
94        'J' => &[&[(5.0, 9.0), (5.0, 2.0), (3.0, 0.0), (1.0, 0.0), (0.0, 2.0)]],
95        'K' => &[
96            &[(0.0, 0.0), (0.0, 9.0)],
97            &[(6.0, 9.0), (0.0, 4.0)],
98            &[(2.0, 5.5), (6.0, 0.0)],
99        ],
100        'L' => &[&[(0.0, 9.0), (0.0, 0.0), (6.0, 0.0)]],
101        'M' => &[&[(0.0, 0.0), (0.0, 9.0), (3.0, 4.0), (6.0, 9.0), (6.0, 0.0)]],
102        'N' => &[&[(0.0, 0.0), (0.0, 9.0), (6.0, 0.0), (6.0, 9.0)]],
103        'O' => &[&[
104            (2.0, 0.0),
105            (0.0, 2.0),
106            (0.0, 7.0),
107            (2.0, 9.0),
108            (4.0, 9.0),
109            (6.0, 7.0),
110            (6.0, 2.0),
111            (4.0, 0.0),
112            (2.0, 0.0),
113        ]],
114        'P' => &[&[
115            (0.0, 0.0),
116            (0.0, 9.0),
117            (5.0, 9.0),
118            (6.0, 8.0),
119            (6.0, 6.0),
120            (5.0, 5.0),
121            (0.0, 5.0),
122        ]],
123        'Q' => &[
124            &[
125                (2.0, 0.0),
126                (0.0, 2.0),
127                (0.0, 7.0),
128                (2.0, 9.0),
129                (4.0, 9.0),
130                (6.0, 7.0),
131                (6.0, 2.0),
132                (4.0, 0.0),
133                (2.0, 0.0),
134            ],
135            &[(4.0, 2.0), (6.0, 0.0)],
136        ],
137        'R' => &[
138            &[
139                (0.0, 0.0),
140                (0.0, 9.0),
141                (5.0, 9.0),
142                (6.0, 8.0),
143                (6.0, 6.0),
144                (5.0, 5.0),
145                (0.0, 5.0),
146            ],
147            &[(2.0, 5.0), (6.0, 0.0)],
148        ],
149        'S' => &[&[
150            (6.0, 7.0),
151            (4.0, 9.0),
152            (2.0, 9.0),
153            (0.0, 7.5),
154            (0.0, 6.0),
155            (2.0, 5.0),
156            (4.0, 5.0),
157            (6.0, 3.5),
158            (6.0, 2.0),
159            (4.0, 0.0),
160            (2.0, 0.0),
161            (0.0, 2.0),
162        ]],
163        'T' => &[&[(3.0, 0.0), (3.0, 9.0)], &[(0.0, 9.0), (6.0, 9.0)]],
164        'U' => &[&[
165            (0.0, 9.0),
166            (0.0, 2.0),
167            (2.0, 0.0),
168            (4.0, 0.0),
169            (6.0, 2.0),
170            (6.0, 9.0),
171        ]],
172        'V' => &[&[(0.0, 9.0), (3.0, 0.0), (6.0, 9.0)]],
173        'W' => &[&[(0.0, 9.0), (1.5, 0.0), (3.0, 6.0), (4.5, 0.0), (6.0, 9.0)]],
174        'X' => &[&[(0.0, 0.0), (6.0, 9.0)], &[(0.0, 9.0), (6.0, 0.0)]],
175        'Y' => &[
176            &[(0.0, 9.0), (3.0, 4.0), (6.0, 9.0)],
177            &[(3.0, 4.0), (3.0, 0.0)],
178        ],
179        'Z' => &[&[(0.0, 9.0), (6.0, 9.0), (0.0, 0.0), (6.0, 0.0)]],
180        '0' => &[
181            &[
182                (2.0, 0.0),
183                (0.0, 2.0),
184                (0.0, 7.0),
185                (2.0, 9.0),
186                (4.0, 9.0),
187                (6.0, 7.0),
188                (6.0, 2.0),
189                (4.0, 0.0),
190                (2.0, 0.0),
191            ],
192            &[(1.0, 1.0), (5.0, 8.0)],
193        ],
194        '1' => &[
195            &[(1.0, 7.0), (3.0, 9.0), (3.0, 0.0)],
196            &[(1.0, 0.0), (5.0, 0.0)],
197        ],
198        '2' => &[&[
199            (0.0, 7.0),
200            (2.0, 9.0),
201            (4.0, 9.0),
202            (6.0, 7.0),
203            (6.0, 5.0),
204            (0.0, 0.0),
205            (6.0, 0.0),
206        ]],
207        '3' => &[&[
208            (0.0, 8.0),
209            (2.0, 9.0),
210            (4.0, 9.0),
211            (6.0, 7.0),
212            (6.0, 6.0),
213            (4.0, 5.0),
214            (6.0, 4.0),
215            (6.0, 2.0),
216            (4.0, 0.0),
217            (2.0, 0.0),
218            (0.0, 1.0),
219        ]],
220        '4' => &[&[(4.0, 0.0), (4.0, 9.0), (0.0, 3.0), (6.0, 3.0)]],
221        '5' => &[&[
222            (6.0, 9.0),
223            (0.0, 9.0),
224            (0.0, 5.0),
225            (4.0, 5.0),
226            (6.0, 3.0),
227            (6.0, 2.0),
228            (4.0, 0.0),
229            (1.0, 0.0),
230            (0.0, 1.0),
231        ]],
232        '6' => &[&[
233            (5.0, 9.0),
234            (2.0, 9.0),
235            (0.0, 6.0),
236            (0.0, 2.0),
237            (2.0, 0.0),
238            (4.0, 0.0),
239            (6.0, 2.0),
240            (6.0, 3.0),
241            (4.0, 5.0),
242            (0.0, 5.0),
243        ]],
244        '7' => &[&[(0.0, 9.0), (6.0, 9.0), (2.0, 0.0)]],
245        '8' => &[&[
246            (2.0, 5.0),
247            (0.0, 6.5),
248            (0.0, 7.5),
249            (2.0, 9.0),
250            (4.0, 9.0),
251            (6.0, 7.5),
252            (6.0, 6.5),
253            (4.0, 5.0),
254            (2.0, 5.0),
255            (0.0, 3.5),
256            (0.0, 1.5),
257            (2.0, 0.0),
258            (4.0, 0.0),
259            (6.0, 1.5),
260            (6.0, 3.5),
261            (4.0, 5.0),
262        ]],
263        '9' => &[&[
264            (1.0, 0.0),
265            (4.0, 0.0),
266            (6.0, 3.0),
267            (6.0, 7.0),
268            (4.0, 9.0),
269            (2.0, 9.0),
270            (0.0, 7.0),
271            (0.0, 6.0),
272            (2.0, 4.0),
273            (6.0, 4.0),
274        ]],
275        '-' => &[&[(1.0, 4.0), (5.0, 4.0)]],
276        '.' => &[&[(2.6, 0.0), (3.4, 0.0), (3.4, 0.8), (2.6, 0.8), (2.6, 0.0)]],
277        _ => return None,
278    };
279    Some(glyph)
280}
281
282/// Build `text` as engraved wires in the `xy` plane of `frame`, `height`
283/// tall at the capitals, reading along `x`.
284///
285/// The result is a compound of open wires, one per stroke, with history
286/// generating each from nothing. A space advances; any other character the
287/// font does not carry refuses by name.
288///
289/// # Errors
290///
291/// [`OgeomError::Construction`](ogeom_core::OgeomError::Construction) if the
292/// height is not finite and positive, or a character is not in the font.
293pub fn make_text(
294    model: &mut Model,
295    text: &str,
296    frame: &Frame,
297    height: f64,
298    tol: Tolerances,
299) -> OgeomResult<Built> {
300    if !height.is_finite() || height <= tol.confusion() {
301        ogeom_bail!(Construction, "text {height} tall is not text");
302    }
303    let scale = height / CAP;
304    let place = |pen: f64, x: f64, y: f64| -> Point {
305        frame.origin() + frame.x().vector() * ((pen + x) * scale) + frame.y().vector() * (y * scale)
306    };
307
308    let mut wires: Vec<Shape> = Vec::new();
309    let mut pen = 0.0;
310    for c in text.chars() {
311        if c == ' ' {
312            pen += ADVANCE;
313            continue;
314        }
315        let Some(glyph) = glyph_of(c) else {
316            ogeom_bail!(
317                Construction,
318                "the built-in font does not carry {c:?}; the supported set is A-Z, 0-9, \
319                 space, dash and dot"
320            );
321        };
322        for stroke in glyph {
323            if stroke.len() < 2 {
324                continue;
325            }
326            let vertices: Vec<Shape> = stroke
327                .iter()
328                .map(|(x, y)| make_vertex(model, place(pen, *x, *y)).shape)
329                .collect();
330            let mut edges = Vec::with_capacity(stroke.len() - 1);
331            for i in 0..stroke.len() - 1 {
332                let a = place(pen, stroke[i].0, stroke[i].1);
333                let b = place(pen, stroke[i + 1].0, stroke[i + 1].1);
334                edges.push(
335                    make_edge_between(
336                        model,
337                        LineCurve::segment(a, b, tol)?.into(),
338                        (0.0, a.distance(b)),
339                        &vertices[i],
340                        &vertices[i + 1],
341                        tol,
342                    )?
343                    .shape,
344                );
345            }
346            wires.push(make_wire(model, &edges, tol)?.shape);
347        }
348        pen += ADVANCE;
349    }
350
351    let compound = make_compound(model, &wires)?;
352    let mut history = History::new();
353    for wire in &wires {
354        history.generate(wire, compound.shape.clone());
355    }
356    Ok(Built::new(compound.shape, history))
357}
358
359#[cfg(test)]
360#[allow(clippy::unwrap_used)]
361mod tests {
362    use super::*;
363    use ogeom_topo::{ShapeType, explore_unique};
364
365    const T: Tolerances = Tolerances::millimetres();
366
367    #[test]
368    fn text_engraves_at_its_height_and_refuses_what_it_lacks() {
369        let mut model = Model::new();
370        let built = make_text(&mut model, "OG-42.0", &Frame::WORLD, 12.0, T).unwrap();
371        let wires = explore_unique(&model, &built.shape, ShapeType::Wire).unwrap();
372        // O(1) G(1) -(1) 4(1) 2(1) .(1) 0(2) strokes.
373        assert_eq!(wires.len(), 8, "one wire per stroke");
374
375        // The capitals reach exactly the requested height, and nothing
376        // rises above it.
377        let mut top = f64::NEG_INFINITY;
378        for v in explore_unique(&model, &built.shape, ShapeType::Vertex).unwrap() {
379            let Some(data) = model.node(&v).and_then(|n| n.data().as_vertex()) else {
380                continue;
381            };
382            top = top.max(data.point.z.mul_add(0.0, data.point.y));
383        }
384        assert!((top - 12.0).abs() < 1e-9, "cap height {top}");
385
386        let unsupported = make_text(&mut model, "π", &Frame::WORLD, 12.0, T);
387        assert!(unsupported.is_err(), "an honest refusal, not a blank");
388    }
389}