1use 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
16type Glyph = &'static [&'static [(f64, f64)]];
18
19const ADVANCE: f64 = 8.0;
21const 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
279pub 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 assert_eq!(wires.len(), 8, "one wire per stroke");
371
372 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}