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}