Skip to main content

bevy_gizmos/
stroke_text.rs

1//! This module draws text gizmos using a stroke font.
2
3use crate::simplex_stroke_font::*;
4use crate::{gizmos::GizmoBuffer, prelude::GizmoConfigGroup};
5use bevy_color::Color;
6use bevy_math::{vec2, Isometry2d, Isometry3d, Vec2};
7use core::ops::Range;
8
9/// A stroke font containing glyphs for the 95 printable ASCII codes.
10pub struct StrokeFont<'a> {
11    /// Baseline-to-baseline line height ratio.
12    pub line_height: f32,
13    /// Full glyph height (cap + descender) in font units.
14    pub height: f32,
15    /// Cap height in font units.
16    pub cap_height: f32,
17    /// Advance used for unsupported glyphs.
18    pub advance: i8,
19    /// Raw glyph point positions.
20    pub positions: &'a [[i8; 2]],
21    /// Stroke ranges into `positions`.
22    pub strokes: &'a [Range<usize>],
23    /// Glyph advances and stroke ranges.
24    pub glyphs: &'a [(i8, Range<usize>); 95],
25}
26
27impl<'a> StrokeFont<'a> {
28    /// Builds a `StrokeTextLayout` for `sections` at the requested `font_size`.
29    pub fn layout(
30        &'a self,
31        sections: &'a [(&'a str, Color)],
32        font_size: f32,
33    ) -> StrokeTextLayout<'a> {
34        let scale = font_size / SIMPLEX_CAP_HEIGHT;
35        let glyph_height = SIMPLEX_HEIGHT * scale;
36        let line_height = LINE_HEIGHT * glyph_height;
37        let margin_top = line_height - glyph_height;
38        let space_advance = SIMPLEX_GLYPHS[0].0 as f32 * scale;
39        StrokeTextLayout {
40            font: self,
41            sections,
42            scale,
43            line_height,
44            margin_top,
45            space_advance,
46        }
47    }
48
49    fn get_glyph_index(&self, c: char) -> Option<usize> {
50        let code = c as u32;
51        if (0x20..=0x7E).contains(&code) {
52            Some(code as usize - 0x20)
53        } else {
54            None
55        }
56    }
57
58    /// Get the advance and stroke point ranges for a glyph.
59    pub fn get_glyph(&self, c: char) -> Option<(i8, Range<usize>)> {
60        Some(self.glyphs[self.get_glyph_index(c)?].clone())
61    }
62
63    /// Get the advance for a glyph.
64    pub fn get_glyph_advance(&self, c: char) -> Option<i8> {
65        Some(self.glyphs[self.get_glyph_index(c)?].0)
66    }
67}
68
69/// Stroke text layout
70pub struct StrokeTextLayout<'a> {
71    /// The unscaled font
72    font: &'a StrokeFont<'a>,
73    /// The text sections with per-section colors.
74    sections: &'a [(&'a str, Color)],
75    /// Scale applied to the raw glyph positions.
76    scale: f32,
77    /// Height of each line of text.
78    line_height: f32,
79    /// Space between top of line and cap height.
80    margin_top: f32,
81    /// Width of a space.
82    space_advance: f32,
83}
84
85impl<'a> StrokeTextLayout<'a> {
86    /// Computes the width and height of the text layout.
87    ///
88    /// Returns the layout size in pixels.
89    pub fn measure(&self) -> Vec2 {
90        let mut layout_size = vec2(0., self.line_height);
91
92        let mut line_width = 0.;
93        for (c, _) in colored_chars(self.sections) {
94            if c == '\n' {
95                layout_size.x = layout_size.x.max(line_width);
96                line_width = 0.;
97                layout_size.y += self.line_height;
98                continue;
99            }
100
101            line_width += self
102                .font
103                .get_glyph_advance(c)
104                .map(|advance| advance as f32 * self.scale)
105                .unwrap_or(self.space_advance);
106        }
107
108        layout_size.x = layout_size.x.max(line_width);
109        layout_size
110    }
111
112    /// Returns an iterator over the font strokes for this text layout, grouped into polylines
113    /// of `Vec2` points, each paired with its color from the text sections.
114    pub fn render(&'a self) -> impl Iterator<Item = (Color, impl Iterator<Item = Vec2> + 'a)> + 'a {
115        let mut chars = colored_chars(self.sections);
116        let mut x = 0.0_f32;
117        let mut y = -self.margin_top;
118        let mut current_strokes: Range<usize> = 0..0;
119        let mut current_x = 0.0_f32;
120        let mut current_color = Color::WHITE;
121
122        core::iter::from_fn(move || loop {
123            for stroke_index in current_strokes.by_ref() {
124                let stroke = self.font.strokes[stroke_index].clone();
125                if stroke.len() < 2 {
126                    continue;
127                }
128
129                // If this stroke is a closed loop, append one extra point to add a join at the seam.
130                let join = (self.font.positions[stroke.start]
131                    == self.font.positions[stroke.end - 1])
132                    .then_some(stroke.start + 1);
133
134                let color = current_color;
135                return Some((
136                    color,
137                    stroke.chain(join).map(move |index| {
138                        let [p, q] = self.font.positions[index];
139                        Vec2::new(
140                            current_x + self.scale * p as f32,
141                            y - self.scale * (self.font.cap_height - q as f32),
142                        )
143                    }),
144                ));
145            }
146
147            let (c, char_color) = chars.next()?;
148
149            if c == '\n' {
150                x = 0.0;
151                y -= self.line_height;
152                continue;
153            }
154
155            let Some((advance, strokes)) = self.font.get_glyph(c) else {
156                x += self.space_advance;
157                continue;
158            };
159            current_color = char_color;
160            current_strokes = strokes;
161            current_x = x;
162
163            x += advance as f32 * self.scale;
164        })
165    }
166}
167
168impl<Config, Clear> GizmoBuffer<Config, Clear>
169where
170    Config: GizmoConfigGroup,
171    Clear: 'static + Send + Sync,
172{
173    /// Draw text using a stroke font with the given isometry applied.
174    ///
175    /// Only ASCII characters in the range 32–126 are supported.
176    ///
177    /// # Arguments
178    ///
179    /// - `isometry`: defines the translation and rotation of the text.
180    /// - `text`: the text to be drawn.
181    /// - `size`: the size of the text in pixels.
182    /// - `anchor`: normalized anchor point relative to the text bounds,
183    ///   where `(0, 0)` is centered, `(-0.5, 0.5)` is top-left,
184    ///   and `(0.5, -0.5)` is bottom-right.
185    /// - `color`: the color of the text.
186    ///
187    /// # Example
188    /// ```
189    /// # use bevy_gizmos::prelude::*;
190    /// # use bevy_math::prelude::*;
191    /// # use bevy_color::Color;
192    /// fn system(mut gizmos: Gizmos) {
193    ///     gizmos.text(Isometry3d::IDENTITY, "text gizmo", 25., Vec2::ZERO, Color::WHITE);
194    /// }
195    /// # bevy_ecs::system::assert_is_system(system);
196    /// ```
197    pub fn text(
198        &mut self,
199        isometry: impl Into<Isometry3d>,
200        text: &str,
201        font_size: f32,
202        anchor: Vec2,
203        color: impl Into<Color>,
204    ) {
205        let color: Color = color.into();
206        self.text_sections(isometry, &[(text, color)], font_size, anchor);
207    }
208
209    /// Draw text using a stroke font with the given isometry applied, coloring each section
210    /// independently.
211    ///
212    /// Only ASCII characters in the range 32–126 are supported.
213    ///
214    /// # Arguments
215    ///
216    /// - `isometry`: defines the translation and rotation of the text.
217    /// - `sections`: a slice of `(text, color)` pairs. Each section's characters are drawn
218    ///   in its color. Sections are concatenated left-to-right on the same baseline.
219    /// - `font_size`: the size of the text in pixels.
220    /// - `anchor`: normalized anchor point relative to the combined text bounds,
221    ///   where `(0, 0)` is centered, `(-0.5, 0.5)` is top-left,
222    ///   and `(0.5, -0.5)` is bottom-right.
223    ///
224    /// # Example
225    /// ```
226    /// # use bevy_gizmos::prelude::*;
227    /// # use bevy_math::prelude::*;
228    /// # use bevy_color::Color;
229    /// fn system(mut gizmos: Gizmos) {
230    ///     gizmos.text_sections(
231    ///         Isometry3d::IDENTITY,
232    ///         &[("Hello ", Color::WHITE), ("World!", Color::srgb(1., 0.3, 0.))],
233    ///         25.,
234    ///         Vec2::ZERO,
235    ///     );
236    /// }
237    /// # bevy_ecs::system::assert_is_system(system);
238    /// ```
239    pub fn text_sections(
240        &mut self,
241        isometry: impl Into<Isometry3d>,
242        sections: &[(&str, Color)],
243        font_size: f32,
244        anchor: Vec2,
245    ) {
246        let isometry: Isometry3d = isometry.into();
247        let layout = SIMPLEX_STROKE_FONT.layout(sections, font_size);
248        let layout_anchor = layout.measure() * (vec2(-0.5, 0.5) - anchor);
249        for (color, points) in layout.render() {
250            self.linestrip(
251                points.map(|point| isometry * (layout_anchor + point).extend(0.)),
252                color,
253            );
254        }
255    }
256
257    /// Draw text using a stroke font in 2d with the given isometry applied.
258    ///
259    /// Only ASCII characters in the range 32–126 are supported.
260    ///
261    /// # Arguments
262    ///
263    /// - `isometry`: defines the translation and rotation of the text.
264    /// - `text`: the text to be drawn.
265    /// - `size`: the size of the text.
266    /// - `anchor`: normalized anchor point relative to the text bounds,
267    ///   where `(0., 0.)` is centered, `(-0.5, 0.5)` is top-left,
268    ///   and `(0.5, -0.5)` is bottom-right.
269    /// - `color`: the color of the text.
270    ///
271    /// # Example
272    /// ```
273    /// # use bevy_gizmos::prelude::*;
274    /// # use bevy_math::prelude::*;
275    /// # use bevy_color::Color;
276    /// fn system(mut gizmos: Gizmos) {
277    ///     gizmos.text_2d(Isometry2d::IDENTITY, "2D text gizmo", 25., Vec2::ZERO, Color::WHITE);
278    /// }
279    /// # bevy_ecs::system::assert_is_system(system);
280    /// ```
281    pub fn text_2d(
282        &mut self,
283        isometry: impl Into<Isometry2d>,
284        text: &str,
285        font_size: f32,
286        anchor: Vec2,
287        color: impl Into<Color>,
288    ) {
289        let color: Color = color.into();
290        self.text_sections_2d(isometry, &[(text, color)], font_size, anchor);
291    }
292
293    /// Draw text using a stroke font in 2d with the given isometry applied, coloring each section
294    /// independently.
295    ///
296    /// Only ASCII characters in the range 32–126 are supported.
297    ///
298    /// # Arguments
299    ///
300    /// - `isometry`: defines the translation and rotation of the text.
301    /// - `sections`: a slice of `(text, color)` pairs. Each section's characters are drawn
302    ///   in its color. Sections are concatenated left-to-right on the same baseline.
303    /// - `font_size`: the size of the text.
304    /// - `anchor`: normalized anchor point relative to the combined text bounds,
305    ///   where `(0., 0.)` is centered, `(-0.5, 0.5)` is top-left,
306    ///   and `(0.5, -0.5)` is bottom-right.
307    ///
308    /// # Example
309    /// ```
310    /// # use bevy_gizmos::prelude::*;
311    /// # use bevy_math::prelude::*;
312    /// # use bevy_color::Color;
313    /// fn system(mut gizmos: Gizmos) {
314    ///     gizmos.text_sections_2d(
315    ///         Isometry2d::IDENTITY,
316    ///         &[("Hello ", Color::WHITE), ("World!", Color::srgb(1., 0.3, 0.))],
317    ///         25.,
318    ///         Vec2::ZERO,
319    ///     );
320    /// }
321    /// # bevy_ecs::system::assert_is_system(system);
322    /// ```
323    pub fn text_sections_2d(
324        &mut self,
325        isometry: impl Into<Isometry2d>,
326        sections: &[(&str, Color)],
327        font_size: f32,
328        anchor: Vec2,
329    ) {
330        let isometry: Isometry2d = isometry.into();
331        let layout = SIMPLEX_STROKE_FONT.layout(sections, font_size);
332        let layout_anchor = layout.measure() * (vec2(-0.5, 0.5) - anchor);
333        for (color, points) in layout.render() {
334            self.linestrip_2d(
335                points.map(|point| isometry * (layout_anchor + point)),
336                color,
337            );
338        }
339    }
340}
341
342/// Iterates the characters across all sections, each paired with its section color.
343fn colored_chars<'a>(sections: &'a [(&'a str, Color)]) -> impl Iterator<Item = (char, Color)> + 'a {
344    sections
345        .iter()
346        .flat_map(|&(text, color)| text.chars().map(move |c| (c, color)))
347}