Skip to main content

geop_core_sketch/
text.rs

1//! Text in a sketch: the outlines of a string's glyphs, closed contours
2//! of exact NURBS (see [`crate::CurveKind::Text`]).
3//!
4//! The font is Noto Sans Regular, by the Noto Project Authors, under the
5//! SIL Open Font License 1.1 (`fonts/OFL.txt`; it reserves no font name).
6//! It is embedded as a subset (`fonts/NotoSans-Regular-subset.ttf`, made
7//! with `fontTools.subset`): Basic Latin, Latin-1, dashes, quotes, the
8//! bullet, the ellipsis, the minus, the euro and the trade mark sign;
9//! hinting and layout tables dropped, overlapping contours united
10//! (`fontTools.ttLib.removeOverlaps`), so that no two contours of a glyph
11//! cross and each is a loop of the sketch's profile. A character the font
12//! has no glyph of is drawn as its missing glyph, a box.
13//!
14//! A TrueType outline is lines and quadratic Béziers between points of
15//! whole font units, so it is a NURBS exactly. A run of quadratics each
16//! starting halfway between the control points either side of it — a
17//! TrueType "implied" point — is one quadratic B-spline on uniform knots,
18//! the off-curve points its control points: so it is one piece, which
19//! the extrusion makes one face, not a face per Bézier.
20
21use geop_core_geometry::nurb_curve::NurbCurve2D;
22use geop_core_math::{
23    geop_error::{GeopError, GeopResult},
24    scalars::Scalar,
25    vector::Vector2,
26};
27use ttf_parser::{Face, GlyphId, OutlineBuilder};
28
29use crate::profile::spline_knots;
30
31/// The font every sketch text is set in.
32static FONT: &[u8] = include_bytes!("../fonts/NotoSans-Regular-subset.ttf");
33
34/// A point of an outline, in font units.
35pub type FontPoint = [f64; 2];
36
37/// One piece of a contour: a polynomial B-spline of `degree` through
38/// `points`, its control points, on clamped uniform knots — a line, a
39/// quadratic or a cubic Bézier, or a run of quadratics joined smoothly.
40#[derive(Clone, Debug, PartialEq)]
41pub struct OutlinePiece {
42    pub degree: usize,
43    pub points: Vec<FontPoint>,
44}
45
46/// A closed contour of a glyph: pieces each starting where the one before
47/// ends, the last ending where the first starts — at least two of them,
48/// so every joint between them is one of the contour's own.
49#[derive(Clone, Debug, PartialEq)]
50pub struct Contour {
51    pub pieces: Vec<OutlinePiece>,
52}
53
54fn font() -> GeopResult<Face<'static>> {
55    Face::parse(FONT, 0).map_err(|e| GeopError::new(format!("the sketch font: {e}")))
56}
57
58/// The contours of `text` set in the sketch font, in font units: its first
59/// baseline along the `x` axis from the origin, each line below the one
60/// before by the font's line spacing.
61pub fn outlines(text: &str) -> GeopResult<Vec<Contour>> {
62    let face = font()?;
63    let line =
64        f64::from(face.ascender()) - f64::from(face.descender()) + f64::from(face.line_gap());
65    let mut contours = Vec::new();
66    let mut pen = [0.0, 0.0];
67    for c in text.chars() {
68        if c == '\n' {
69            pen = [0.0, pen[1] - line];
70            continue;
71        }
72        let glyph = face.glyph_index(c).unwrap_or(GlyphId(0));
73        let mut outline = Outline {
74            at: pen,
75            contours: Vec::new(),
76            current: Vec::new(),
77            start: [0.0, 0.0],
78            last: [0.0, 0.0],
79        };
80        face.outline_glyph(glyph, &mut outline);
81        outline.close();
82        contours.extend(outline.contours);
83        pen[0] += f64::from(face.glyph_hor_advance(glyph).unwrap_or(0));
84    }
85    Ok(contours)
86}
87
88/// How tall the font's capitals are, in font units: what a text's height
89/// measures.
90fn capital_height() -> GeopResult<f64> {
91    let face = font()?;
92    let height = face
93        .capital_height()
94        .ok_or_else(|| GeopError::new("the sketch font gives no capital height"))?;
95    Ok(f64::from(height))
96}
97
98/// `contours` placed in the sketch: their origin at `anchor`, scaled so a
99/// capital is `height` tall, turned counter-clockwise by `angle` radians —
100/// each a loop of NURBS pieces on `[0, 1]`.
101pub fn place(
102    contours: &[Contour],
103    anchor: Vector2,
104    height: Scalar,
105    angle: Scalar,
106) -> GeopResult<Vec<Vec<NurbCurve2D>>> {
107    let scale = height.div(Scalar::from_f64(capital_height()?))?;
108    let (cos, sin) = (angle.cos(), angle.sin());
109    let at = |p: &FontPoint| {
110        let local = Vector2::from_array([
111            Scalar::from_f64(p[0]).mul(scale),
112            Scalar::from_f64(p[1]).mul(scale),
113        ]);
114        anchor.add(&local.rotate(cos, sin))
115    };
116    (contours.iter())
117        .map(|contour| {
118            (contour.pieces.iter())
119                .map(|piece| {
120                    let n = piece.points.len();
121                    NurbCurve2D::try_new(
122                        piece.degree,
123                        piece
124                            .points
125                            .iter()
126                            .map(|p| at(p).homogeneous(Scalar::ONE))
127                            .collect(),
128                        spline_knots(n, piece.degree)?,
129                    )
130                })
131                .collect()
132        })
133        .collect()
134}
135
136/// A glyph's outline as `ttf_parser` traces it, gathered into contours
137/// (see [`Contour`]), moved by `at`.
138struct Outline {
139    at: FontPoint,
140    contours: Vec<Contour>,
141    /// The pieces of the contour being traced.
142    current: Vec<OutlinePiece>,
143    /// Where it started.
144    start: FontPoint,
145    /// Where it is.
146    last: FontPoint,
147}
148
149impl Outline {
150    fn point(&self, x: f32, y: f32) -> FontPoint {
151        [self.at[0] + f64::from(x), self.at[1] + f64::from(y)]
152    }
153
154    /// Adds the piece of `degree` from where the outline is through
155    /// `points`: a quadratic continuing a quadratic smoothly — starting
156    /// halfway between the control points either side — joined to it, one
157    /// whose control point is an end of it as the line it is, and nothing
158    /// for a piece of no length.
159    fn push(&mut self, degree: usize, points: &[FontPoint]) {
160        let from = self.last;
161        let to = *points.last().expect("a segment ends somewhere");
162        if points.iter().all(|&p| p == from) {
163            return;
164        }
165        if degree == 2 && (points[0] == from || points[0] == to) {
166            return self.push(1, &[to]);
167        }
168        self.last = to;
169        if degree == 2
170            && let Some(previous) = self.current.last_mut()
171            && previous.degree == 2
172        {
173            let control = previous.points[previous.points.len() - 2];
174            let halfway = [
175                (control[0] + points[0][0]) / 2.0,
176                (control[1] + points[0][1]) / 2.0,
177            ];
178            if halfway == from {
179                previous.points.pop();
180                previous.points.extend_from_slice(points);
181                return;
182            }
183        }
184        let mut all = vec![from];
185        all.extend_from_slice(points);
186        self.current.push(OutlinePiece {
187            degree,
188            points: all,
189        });
190    }
191
192    /// Ends the contour being traced: closed by a line back to its start,
193    /// if it does not end there, and kept if it is a loop of at least two
194    /// pieces — one closed piece split in two.
195    fn close(&mut self) {
196        if self.last != self.start {
197            self.push(1, &[self.start]);
198        }
199        let mut pieces = std::mem::take(&mut self.current);
200        if let [only] = &pieces[..] {
201            pieces = halves(only);
202        }
203        if pieces.len() >= 2 {
204            self.contours.push(Contour { pieces });
205        }
206    }
207}
208
209/// A piece cut in two, exactly: a run of quadratics at the joint in its
210/// middle, a single Bézier at its middle by de Casteljau — in halves and
211/// quarters of whole font units, which the format holds exactly.
212fn halves(piece: &OutlinePiece) -> Vec<OutlinePiece> {
213    let p = &piece.points;
214    let mid = |a: FontPoint, b: FontPoint| [(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0];
215    let segments = p.len() - piece.degree;
216    let (first, second) = if piece.degree == 2 && segments >= 2 {
217        let m = segments / 2;
218        let joint = mid(p[m], p[m + 1]);
219        let mut first = p[..=m].to_vec();
220        first.push(joint);
221        let mut second = vec![joint];
222        second.extend_from_slice(&p[m + 1..]);
223        (first, second)
224    } else {
225        // De Casteljau at one half, for a single Bézier of any degree.
226        let mut rows = vec![p.clone()];
227        while rows.last().expect("a first row").len() > 1 {
228            let row = rows.last().expect("a row");
229            let next = row.windows(2).map(|w| mid(w[0], w[1])).collect();
230            rows.push(next);
231        }
232        let first = rows.iter().map(|row| row[0]).collect();
233        let second = rows
234            .iter()
235            .rev()
236            .map(|row| *row.last().expect("a point"))
237            .collect();
238        (first, second)
239    };
240    [first, second]
241        .into_iter()
242        .map(|points| OutlinePiece {
243            degree: piece.degree,
244            points,
245        })
246        .collect()
247}
248
249impl OutlineBuilder for Outline {
250    fn move_to(&mut self, x: f32, y: f32) {
251        self.close();
252        self.start = self.point(x, y);
253        self.last = self.start;
254    }
255
256    fn line_to(&mut self, x: f32, y: f32) {
257        let p = self.point(x, y);
258        self.push(1, &[p]);
259    }
260
261    fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) {
262        let points = [self.point(x1, y1), self.point(x, y)];
263        self.push(2, &points);
264    }
265
266    fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) {
267        let points = [self.point(x1, y1), self.point(x2, y2), self.point(x, y)];
268        self.push(3, &points);
269    }
270
271    fn close(&mut self) {
272        Outline::close(self);
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use geop_core_geometry::nurb_curve::loop_signed_area;
280
281    fn closes(contour: &Contour) -> bool {
282        let n = contour.pieces.len();
283        let p = &contour.pieces;
284        (0..n).all(|i| p[i].points.last() == p[(i + 1) % n].points.first())
285    }
286
287    /// An `o` is an outer contour and a hole, `B` one and two, an `i` two
288    /// apart, a space nothing: each closed, of whole pieces.
289    #[test]
290    fn letters_have_their_contours() {
291        for (text, count) in [("o", 2), ("B", 3), ("i", 2), (" ", 0), ("8", 3), ("A", 2)] {
292            let contours = outlines(text).unwrap();
293            assert_eq!(contours.len(), count, "{text:?}");
294            assert!(contours.iter().all(closes), "{text:?}");
295        }
296    }
297
298    /// The joined runs of quadratics make few pieces: an `o` is not a
299    /// piece per Bézier.
300    #[test]
301    fn smooth_runs_are_one_piece() {
302        let contours = outlines("o").unwrap();
303        let pieces: usize = contours.iter().map(|c| c.pieces.len()).sum();
304        assert!(pieces <= 8, "{contours:?}");
305    }
306
307    /// Placed, an outer contour and its hole wind opposite ways, by the
308    /// exact area of the curves.
309    #[test]
310    fn a_hole_winds_against_its_outline() {
311        let contours = outlines("o").unwrap();
312        let origin = Vector2::from_array([Scalar::from_f64(10.0), Scalar::from_f64(-3.0)]);
313        let placed = place(
314            &contours,
315            origin,
316            Scalar::from_f64(5.0),
317            Scalar::from_f64(0.3),
318        )
319        .unwrap();
320        let areas: Vec<f64> = placed
321            .iter()
322            .map(|c| loop_signed_area(c).unwrap().to_f64())
323            .collect();
324        assert!(areas[0] * areas[1] < 0.0, "{areas:?}");
325    }
326
327    /// A second line starts below the first.
328    #[test]
329    fn a_new_line_goes_down() {
330        let one = outlines("I").unwrap();
331        let two = outlines("I\nI").unwrap();
332        assert_eq!(two.len(), 2 * one.len());
333        let lowest = |c: &[Contour]| {
334            c.iter()
335                .flat_map(|c| &c.pieces)
336                .flat_map(|p| &p.points)
337                .map(|p| p[1])
338                .fold(f64::INFINITY, f64::min)
339        };
340        assert!(lowest(&two) < lowest(&one) - 500.0);
341    }
342}