Skip to main content

ogeom_algo/
text.rs

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