Skip to main content

viewport_lib/resources/overlay/
font.rs

1//! Font atlas and single-line text layout for overlay rendering.
2//!
3//! This module is the text back-end for [`LabelItem`], [`ScalarBarItem`], and
4//! [`RulerItem`].  It uses [`fontdue`] for glyph rasterization and packs glyphs
5//! into a single GPU texture atlas on demand.
6//!
7//! Public surface: [`FontHandle`] (opaque font identifier) and
8//! [`super::DeviceResources::upload_font`].  Everything else is `pub(crate)`.
9
10use std::collections::HashMap;
11
12/// Default font embedded in the library binary (Inter Regular, SIL OFL 1.1).
13const DEFAULT_FONT_BYTES: &[u8] = include_bytes!("../../fonts/Inter-Regular.ttf");
14
15// ---------------------------------------------------------------------------
16// FontHandle : public opaque identifier
17// ---------------------------------------------------------------------------
18
19/// Opaque handle to a font uploaded via
20/// [`DeviceResources::upload_font`](super::DeviceResources::upload_font).
21///
22/// Pass `None` (or omit the field) on overlay items to use the built-in default
23/// font.  Pass `Some(handle)` to use a user-supplied TTF font.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub struct FontHandle(pub usize);
26
27// ---------------------------------------------------------------------------
28// GlyphKey / GlyphEntry : atlas bookkeeping
29// ---------------------------------------------------------------------------
30
31/// Unique key for a rasterized glyph in the atlas.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33struct GlyphKey {
34    font_index: usize,
35    glyph_index: u16,
36    /// Font size in tenths of a pixel (e.g. 140 = 14.0 px).
37    /// Quantised to avoid unbounded atlas growth from fractional sizes.
38    size_tenths: u32,
39}
40
41/// Location and metrics of a single rasterized glyph in the atlas texture.
42#[derive(Debug, Clone, Copy)]
43struct GlyphEntry {
44    /// Top-left pixel coordinate in the atlas.
45    x: u32,
46    y: u32,
47    /// Rasterized bitmap dimensions.
48    width: u32,
49    height: u32,
50    /// Offset from the pen position to the top-left of the bitmap.
51    offset_x: f32,
52    offset_y: f32,
53}
54
55// ---------------------------------------------------------------------------
56// GlyphQuad / TextLayout : internal layout output
57// ---------------------------------------------------------------------------
58
59/// A positioned, textured quad for one glyph, ready for vertex generation.
60#[derive(Debug, Clone, Copy)]
61pub(crate) struct GlyphQuad {
62    /// Screen-space top-left corner (pixels from top-left of viewport).
63    pub pos: [f32; 2],
64    /// Screen-space size [w, h] in pixels.
65    pub size: [f32; 2],
66    /// UV top-left in the atlas (0..1).
67    pub uv_min: [f32; 2],
68    /// UV bottom-right in the atlas (0..1).
69    pub uv_max: [f32; 2],
70}
71
72/// Layout result for a single-line text string.
73#[derive(Debug, Clone)]
74pub(crate) struct TextLayout {
75    /// One quad per visible glyph (whitespace characters are skipped).
76    pub quads: Vec<GlyphQuad>,
77    /// Total advance width of the laid-out string in pixels.
78    pub total_width: f32,
79    /// Line height in pixels (ascent - descent + line gap at the requested size).
80    pub height: f32,
81}
82
83// ---------------------------------------------------------------------------
84// GlyphAtlas
85// ---------------------------------------------------------------------------
86
87/// A dynamically-growing glyph atlas backed by a single `Rgba8Unorm` texture.
88///
89/// Owned by [`DeviceResources`]; never exposed in the public API.
90pub(crate) struct GlyphAtlas {
91    /// Parsed fontdue fonts.  Index 0 is always the built-in default.
92    fonts: Vec<fontdue::Font>,
93
94    /// Cached rasterized glyphs.
95    entries: HashMap<GlyphKey, GlyphEntry>,
96
97    /// CPU-side atlas pixel data (single-channel alpha, packed row-major).
98    /// Stored as RGBA for direct GPU upload: R=G=B=255, A=coverage.
99    pixels: Vec<[u8; 4]>,
100
101    /// Current atlas dimensions (always square, power of two).
102    size: u32,
103
104    /// Simple row-based packer state.
105    cursor_x: u32,
106    cursor_y: u32,
107    row_height: u32,
108
109    /// GPU texture (recreated when the atlas grows).
110    pub texture: wgpu::Texture,
111    /// View into the atlas texture.
112    pub view: wgpu::TextureView,
113
114    /// Set to `true` whenever new glyphs have been rasterized since the last
115    /// GPU upload.  Cleared by [`GlyphAtlas::upload_if_dirty`].
116    dirty: bool,
117}
118
119impl GlyphAtlas {
120    /// Initial atlas size in pixels (width = height).
121    const INITIAL_SIZE: u32 = 512;
122
123    /// Create a new atlas with the built-in default font pre-loaded.
124    pub fn new(device: &wgpu::Device) -> Self {
125        let default_font =
126            fontdue::Font::from_bytes(DEFAULT_FONT_BYTES, fontdue::FontSettings::default())
127                .expect("built-in default font must parse");
128
129        let size = Self::INITIAL_SIZE;
130        let pixel_count = (size * size) as usize;
131        let pixels = vec![[255, 255, 255, 0]; pixel_count];
132
133        let (texture, view) = Self::create_texture(device, size);
134
135        Self {
136            fonts: vec![default_font],
137            entries: HashMap::new(),
138            pixels,
139            size,
140            cursor_x: 0,
141            cursor_y: 0,
142            row_height: 0,
143            texture,
144            view,
145            dirty: false,
146        }
147    }
148
149    /// Register a user-supplied TTF font.  Returns a [`FontHandle`] that can be
150    /// passed to overlay items.
151    pub fn upload_font(&mut self, ttf_bytes: &[u8]) -> Result<FontHandle, FontError> {
152        let font = fontdue::Font::from_bytes(ttf_bytes, fontdue::FontSettings::default())
153            .map_err(|e| FontError::ParseFailed(e.to_string()))?;
154        let index = self.fonts.len();
155        self.fonts.push(font);
156        Ok(FontHandle(index))
157    }
158
159    /// Lay out a single-line string and return positioned glyph quads.
160    ///
161    /// Glyphs that are not yet in the atlas are rasterized and packed on the
162    /// fly.  Call [`upload_if_dirty`] after all layout calls for the frame to
163    /// push new glyphs to the GPU.
164    pub fn layout_text(
165        &mut self,
166        text: &str,
167        font_size: f32,
168        font: Option<FontHandle>,
169        device: &wgpu::Device,
170    ) -> TextLayout {
171        let font_index = font.map_or(0, |h| h.0);
172        let size_tenths = (font_size * 10.0).round() as u32;
173        let px = font_size;
174
175        let metrics = self.fonts[font_index].horizontal_line_metrics(px);
176        let line_height = metrics
177            .map(|m| m.ascent - m.descent + m.line_gap)
178            .unwrap_or(px * 1.2);
179
180        let mut quads = Vec::new();
181        let mut pen_x: f32 = 0.0;
182        let mut pen_y: f32 = 0.0;
183        let mut max_width: f32 = 0.0;
184
185        let mut prev_glyph: Option<u16> = None;
186        for ch in text.chars() {
187            if ch == '\n' {
188                max_width = max_width.max(pen_x);
189                pen_x = 0.0;
190                pen_y += line_height;
191                prev_glyph = None;
192                continue;
193            }
194
195            let glyph_index = self.fonts[font_index].lookup_glyph_index(ch);
196
197            // Kerning.
198            if let Some(prev) = prev_glyph {
199                if let Some(kern) =
200                    self.fonts[font_index].horizontal_kern_indexed(prev, glyph_index, px)
201                {
202                    pen_x += kern;
203                }
204            }
205            prev_glyph = Some(glyph_index);
206
207            // Get metrics for advance, even for whitespace.
208            let m = self.fonts[font_index].metrics_indexed(glyph_index, px);
209
210            // Only emit a quad for glyphs with visible bitmap area.
211            if m.width > 0 && m.height > 0 {
212                let entry = self.ensure_glyph(device, font_index, glyph_index, size_tenths, px);
213                let atlas_size = self.size as f32;
214
215                quads.push(GlyphQuad {
216                    pos: [pen_x + entry.offset_x, pen_y + entry.offset_y],
217                    size: [entry.width as f32, entry.height as f32],
218                    uv_min: [entry.x as f32 / atlas_size, entry.y as f32 / atlas_size],
219                    uv_max: [
220                        (entry.x + entry.width) as f32 / atlas_size,
221                        (entry.y + entry.height) as f32 / atlas_size,
222                    ],
223                });
224            }
225
226            pen_x += m.advance_width;
227        }
228        max_width = max_width.max(pen_x);
229
230        TextLayout {
231            quads,
232            total_width: max_width,
233            height: pen_y + line_height,
234        }
235    }
236
237    /// Lay out text with word wrapping at a maximum width.
238    ///
239    /// Words that exceed `max_width` on their own are not broken: they extend
240    /// past the boundary.  The returned `total_width` is the maximum line width
241    /// actually used.
242    pub fn layout_text_wrapped(
243        &mut self,
244        text: &str,
245        font_size: f32,
246        font: Option<FontHandle>,
247        max_width: f32,
248        device: &wgpu::Device,
249    ) -> TextLayout {
250        let font_index = font.map_or(0, |h| h.0);
251        let px = font_size;
252
253        let metrics = self.fonts[font_index].horizontal_line_metrics(px);
254        let line_height = metrics
255            .map(|m| m.ascent - m.descent + m.line_gap)
256            .unwrap_or(px * 1.2);
257
258        let size_tenths = (px * 10.0).round() as u32;
259        let space_advance = {
260            let gi = self.fonts[font_index].lookup_glyph_index(' ');
261            self.fonts[font_index].metrics_indexed(gi, px).advance_width
262        };
263
264        let mut quads = Vec::new();
265        let mut line_x: f32 = 0.0;
266        let mut line_y: f32 = 0.0;
267        let mut max_line_width: f32 = 0.0;
268
269        // Process each hard line (\n-delimited) independently, then word-wrap within it.
270        for (logical_line_idx, logical_line) in text.split('\n').enumerate() {
271            if logical_line_idx > 0 {
272                max_line_width = max_line_width.max(line_x);
273                line_x = 0.0;
274                line_y += line_height;
275            }
276
277            let words: Vec<&str> = logical_line.split_whitespace().collect();
278            if words.is_empty() {
279                continue;
280            }
281
282            let mut first_on_line = true;
283
284            for word in &words {
285                let mut word_quads: Vec<GlyphQuad> = Vec::new();
286                let mut pen_x: f32 = 0.0;
287                let mut prev_glyph: Option<u16> = None;
288
289                for ch in word.chars() {
290                    let glyph_index = self.fonts[font_index].lookup_glyph_index(ch);
291                    if let Some(prev) = prev_glyph {
292                        if let Some(kern) =
293                            self.fonts[font_index].horizontal_kern_indexed(prev, glyph_index, px)
294                        {
295                            pen_x += kern;
296                        }
297                    }
298                    prev_glyph = Some(glyph_index);
299                    let m = self.fonts[font_index].metrics_indexed(glyph_index, px);
300                    if m.width > 0 && m.height > 0 {
301                        let entry =
302                            self.ensure_glyph(device, font_index, glyph_index, size_tenths, px);
303                        let atlas_size = self.size as f32;
304                        word_quads.push(GlyphQuad {
305                            pos: [pen_x + entry.offset_x, entry.offset_y],
306                            size: [entry.width as f32, entry.height as f32],
307                            uv_min: [entry.x as f32 / atlas_size, entry.y as f32 / atlas_size],
308                            uv_max: [
309                                (entry.x + entry.width) as f32 / atlas_size,
310                                (entry.y + entry.height) as f32 / atlas_size,
311                            ],
312                        });
313                    }
314                    pen_x += m.advance_width;
315                }
316                let word_width = pen_x;
317
318                // Soft-wrap if the word doesn't fit on the current line.
319                let test_x = if first_on_line {
320                    line_x
321                } else {
322                    line_x + space_advance
323                };
324                if !first_on_line && test_x + word_width > max_width {
325                    max_line_width = max_line_width.max(line_x);
326                    line_x = 0.0;
327                    line_y += line_height;
328                    first_on_line = true;
329                }
330
331                let start_x = if first_on_line {
332                    line_x
333                } else {
334                    line_x + space_advance
335                };
336                for mut gq in word_quads {
337                    gq.pos[0] += start_x;
338                    gq.pos[1] += line_y;
339                    quads.push(gq);
340                }
341                line_x = start_x + word_width;
342                first_on_line = false;
343            }
344        }
345
346        max_line_width = max_line_width.max(line_x);
347        let total_height = if quads.is_empty() && text.is_empty() {
348            line_height
349        } else {
350            line_y + line_height
351        };
352
353        TextLayout {
354            quads,
355            total_width: max_line_width,
356            height: total_height,
357        }
358    }
359
360    /// Return the font ascent in pixels for the given font index and size.
361    ///
362    /// The ascent is the distance from the baseline to the top of the tallest
363    /// glyph.  Used to position glyph quads relative to a text origin at the
364    /// top-left corner of the bounding box.
365    pub fn font_ascent(&self, font_index: usize, font_size: f32) -> f32 {
366        self.fonts[font_index]
367            .horizontal_line_metrics(font_size)
368            .map(|m| m.ascent)
369            .unwrap_or(font_size * 0.8)
370    }
371
372    /// Upload new glyph data to the GPU if any glyphs were rasterized since
373    /// the last upload.
374    pub fn upload_if_dirty(&mut self, queue: &wgpu::Queue) {
375        if !self.dirty {
376            return;
377        }
378        let flat: Vec<u8> = self.pixels.iter().flat_map(|p| p.iter().copied()).collect();
379        queue.write_texture(
380            wgpu::TexelCopyTextureInfo {
381                texture: &self.texture,
382                mip_level: 0,
383                origin: wgpu::Origin3d::ZERO,
384                aspect: wgpu::TextureAspect::All,
385            },
386            &flat,
387            wgpu::TexelCopyBufferLayout {
388                offset: 0,
389                bytes_per_row: Some(self.size * 4),
390                rows_per_image: Some(self.size),
391            },
392            wgpu::Extent3d {
393                width: self.size,
394                height: self.size,
395                depth_or_array_layers: 1,
396            },
397        );
398        self.dirty = false;
399    }
400
401    // ------------------------------------------------------------------
402    // Private helpers
403    // ------------------------------------------------------------------
404
405    /// Ensure a glyph is in the atlas, rasterizing and packing it if needed.
406    /// Returns the atlas entry.
407    fn ensure_glyph(
408        &mut self,
409        device: &wgpu::Device,
410        font_index: usize,
411        glyph_index: u16,
412        size_tenths: u32,
413        px: f32,
414    ) -> GlyphEntry {
415        let key = GlyphKey {
416            font_index,
417            glyph_index,
418            size_tenths,
419        };
420
421        if let Some(&entry) = self.entries.get(&key) {
422            return entry;
423        }
424
425        // Rasterize.
426        let (metrics, bitmap) = self.fonts[font_index].rasterize_indexed(glyph_index, px);
427        let w = metrics.width as u32;
428        let h = metrics.height as u32;
429
430        if w == 0 || h == 0 {
431            // Whitespace glyph: insert a zero-area entry.
432            let entry = GlyphEntry {
433                x: 0,
434                y: 0,
435                width: 0,
436                height: 0,
437                offset_x: metrics.xmin as f32,
438                offset_y: -(metrics.ymin as f32 + h as f32),
439            };
440            self.entries.insert(key, entry);
441            return entry;
442        }
443
444        // Pack into the atlas (simple row packer with 1px padding).
445        let pad = 1;
446        if self.cursor_x + w + pad > self.size {
447            // Move to next row.
448            self.cursor_y += self.row_height + pad;
449            self.cursor_x = 0;
450            self.row_height = 0;
451        }
452        if self.cursor_y + h + pad > self.size {
453            // Atlas is full : grow.
454            self.grow(device);
455        }
456
457        let x = self.cursor_x;
458        let y = self.cursor_y;
459
460        // Blit coverage into the RGBA pixel buffer.
461        for row in 0..h {
462            for col in 0..w {
463                let src = (row * w + col) as usize;
464                let dst = ((y + row) * self.size + (x + col)) as usize;
465                self.pixels[dst] = [255, 255, 255, bitmap[src]];
466            }
467        }
468        self.dirty = true;
469
470        self.cursor_x = x + w + pad;
471        self.row_height = self.row_height.max(h);
472
473        let entry = GlyphEntry {
474            x,
475            y,
476            width: w,
477            height: h,
478            offset_x: metrics.xmin as f32,
479            offset_y: -(metrics.ymin as f32 + h as f32),
480        };
481        self.entries.insert(key, entry);
482        entry
483    }
484
485    /// Double the atlas size, copying existing pixel data into the new buffer
486    /// and recreating the GPU texture.
487    fn grow(&mut self, device: &wgpu::Device) {
488        let old_size = self.size;
489        let new_size = old_size * 2;
490        tracing::info!(
491            "Growing glyph atlas from {}x{} to {}x{}",
492            old_size,
493            old_size,
494            new_size,
495            new_size
496        );
497
498        let mut new_pixels = vec![[255, 255, 255, 0u8]; (new_size * new_size) as usize];
499        for row in 0..old_size {
500            let src_start = (row * old_size) as usize;
501            let dst_start = (row * new_size) as usize;
502            new_pixels[dst_start..dst_start + old_size as usize]
503                .copy_from_slice(&self.pixels[src_start..src_start + old_size as usize]);
504        }
505
506        self.pixels = new_pixels;
507        self.size = new_size;
508
509        let (texture, view) = Self::create_texture(device, new_size);
510        self.texture = texture;
511        self.view = view;
512        self.dirty = true; // Full re-upload needed.
513    }
514
515    fn create_texture(device: &wgpu::Device, size: u32) -> (wgpu::Texture, wgpu::TextureView) {
516        let texture = device.create_texture(&wgpu::TextureDescriptor {
517            label: Some("glyph_atlas"),
518            size: wgpu::Extent3d {
519                width: size,
520                height: size,
521                depth_or_array_layers: 1,
522            },
523            mip_level_count: 1,
524            sample_count: 1,
525            dimension: wgpu::TextureDimension::D2,
526            format: wgpu::TextureFormat::Rgba8Unorm,
527            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
528            view_formats: &[],
529        });
530        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
531        (texture, view)
532    }
533}
534
535// ---------------------------------------------------------------------------
536// FontError
537// ---------------------------------------------------------------------------
538
539/// Error returned by [`super::DeviceResources::upload_font`].
540#[derive(Debug, Clone, thiserror::Error)]
541pub enum FontError {
542    /// The TTF data could not be parsed.
543    #[error("font parsing failed: {0}")]
544    ParseFailed(String),
545}
546
547// ---------------------------------------------------------------------------
548// DeviceResources integration
549// ---------------------------------------------------------------------------
550
551impl crate::resources::DeviceResources {
552    /// Upload a user-supplied TTF font for use with overlay items.
553    ///
554    /// Returns an opaque [`FontHandle`] that can be passed to [`LabelItem`],
555    /// [`ScalarBarItem`], or [`RulerItem`] via their `font` field.  Pass
556    /// `None` on those items to use the built-in default font instead.
557    ///
558    /// The font bytes must be a valid TrueType (`.ttf`) file.
559    pub fn upload_font(&mut self, ttf_bytes: &[u8]) -> Result<FontHandle, FontError> {
560        self.content.glyph_atlas.upload_font(ttf_bytes)
561    }
562}