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