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