Another project
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(ui): glyph outline tessellator

Lewis: May this revision serve well! <lu5a@proton.me>

+314
+5
crates/bone-ui/src/text/raster/mod.rs
··· 1 + mod outline; 2 + mod sdf; 3 + 4 + pub use outline::{OutlineTessellator, TessellatedGlyph}; 5 + pub use sdf::{AtlasEntry, SdfAtlas, SdfAtlasError, SdfAtlasParams};
+309
crates/bone-ui/src/text/raster/outline.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use bone_text::{FontFace, GlyphId, load_font, outline_to_path, tessellate_path}; 4 + use lyon_tessellation::FillTessellator; 5 + use swash::{ 6 + FontRef, 7 + scale::{ScaleContext, outline::Outline}, 8 + }; 9 + 10 + use crate::layout::{LayoutOffset, LayoutPos, LayoutPx, LayoutRect, LayoutSize}; 11 + use crate::theme::FontSize; 12 + 13 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 14 + pub struct GlyphTessellationKey { 15 + face: FontFace, 16 + glyph: GlyphId, 17 + size_px_bits: u32, 18 + } 19 + 20 + impl GlyphTessellationKey { 21 + #[must_use] 22 + pub fn new(face: FontFace, glyph: GlyphId, size: FontSize) -> Self { 23 + Self { 24 + face, 25 + glyph, 26 + size_px_bits: size.as_px_f32().to_bits(), 27 + } 28 + } 29 + } 30 + 31 + #[derive(Clone, Debug, PartialEq)] 32 + pub struct TessellatedGlyph { 33 + pub vertices_px: Vec<LayoutOffset>, 34 + pub indices: Vec<u32>, 35 + pub bbox_px: LayoutRect, 36 + } 37 + 38 + impl TessellatedGlyph { 39 + #[must_use] 40 + pub fn empty() -> Self { 41 + Self { 42 + vertices_px: Vec::new(), 43 + indices: Vec::new(), 44 + bbox_px: LayoutRect::new(LayoutPos::ORIGIN, LayoutSize::ZERO), 45 + } 46 + } 47 + 48 + #[must_use] 49 + pub fn is_empty(&self) -> bool { 50 + self.indices.is_empty() 51 + } 52 + } 53 + 54 + pub struct OutlineTessellator { 55 + sans: FontRef<'static>, 56 + mono: FontRef<'static>, 57 + scale_ctx: ScaleContext, 58 + fill: FillTessellator, 59 + cache: HashMap<GlyphTessellationKey, TessellatedGlyph>, 60 + } 61 + 62 + impl OutlineTessellator { 63 + #[must_use] 64 + pub fn new() -> Self { 65 + Self { 66 + sans: load_font(FontFace::Sans), 67 + mono: load_font(FontFace::Mono), 68 + scale_ctx: ScaleContext::new(), 69 + fill: FillTessellator::new(), 70 + cache: HashMap::new(), 71 + } 72 + } 73 + 74 + pub fn tessellate( 75 + &mut self, 76 + face: FontFace, 77 + glyph: GlyphId, 78 + size: FontSize, 79 + ) -> &TessellatedGlyph { 80 + let key = GlyphTessellationKey::new(face, glyph, size); 81 + let font = match face { 82 + FontFace::Sans => self.sans, 83 + FontFace::Mono => self.mono, 84 + }; 85 + self.cache.entry(key).or_insert_with(|| { 86 + tessellate_glyph(&font, glyph, size, &mut self.scale_ctx, &mut self.fill) 87 + }) 88 + } 89 + } 90 + 91 + impl Default for OutlineTessellator { 92 + fn default() -> Self { 93 + Self::new() 94 + } 95 + } 96 + 97 + impl core::fmt::Debug for OutlineTessellator { 98 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 99 + f.debug_struct("OutlineTessellator") 100 + .field("cache_len", &self.cache.len()) 101 + .finish_non_exhaustive() 102 + } 103 + } 104 + 105 + fn tessellate_glyph( 106 + font: &FontRef<'_>, 107 + glyph: GlyphId, 108 + size: FontSize, 109 + scale_ctx: &mut ScaleContext, 110 + fill: &mut FillTessellator, 111 + ) -> TessellatedGlyph { 112 + let Ok(glyph_id_u16) = u16::try_from(glyph.raw()) else { 113 + return TessellatedGlyph::empty(); 114 + }; 115 + let mut scaler = scale_ctx.builder(*font).size(size.as_px_f32()).build(); 116 + let mut outline = Outline::new(); 117 + if !scaler.scale_outline_into(glyph_id_u16, &mut outline) { 118 + return TessellatedGlyph::empty(); 119 + } 120 + if outline.is_empty() { 121 + return TessellatedGlyph::empty(); 122 + } 123 + let bbox_px = outline_bounds(&outline); 124 + let path = outline_to_path(&outline); 125 + let Ok(raw) = tessellate_path(&path, fill) else { 126 + return TessellatedGlyph::empty(); 127 + }; 128 + let vertices_px = raw 129 + .vertices_px 130 + .into_iter() 131 + .map(|[x, y]| LayoutOffset::new(LayoutPx::saturating(x), LayoutPx::saturating(y))) 132 + .collect(); 133 + TessellatedGlyph { 134 + vertices_px, 135 + indices: raw.indices, 136 + bbox_px, 137 + } 138 + } 139 + 140 + fn outline_bounds(outline: &Outline) -> LayoutRect { 141 + let bounds = outline.bounds(); 142 + LayoutRect::new( 143 + LayoutPos::new( 144 + LayoutPx::saturating(bounds.min.x), 145 + LayoutPx::saturating(bounds.min.y), 146 + ), 147 + LayoutSize::new( 148 + LayoutPx::saturating_nonneg(bounds.max.x - bounds.min.x), 149 + LayoutPx::saturating_nonneg(bounds.max.y - bounds.min.y), 150 + ), 151 + ) 152 + } 153 + 154 + #[cfg(test)] 155 + mod tests { 156 + use super::{GlyphTessellationKey, OutlineTessellator, TessellatedGlyph}; 157 + use crate::theme::FontSize; 158 + use bone_text::{FontFace, GlyphId, load_font}; 159 + 160 + fn glyph_for(face: FontFace, ch: char) -> GlyphId { 161 + let font = load_font(face); 162 + GlyphId::new(u32::from(font.charmap().map(u32::from(ch)))) 163 + } 164 + 165 + fn px(v: f64) -> FontSize { 166 + FontSize::from_px(v) 167 + } 168 + 169 + #[test] 170 + fn tessellation_key_hashes_distinct_sizes_independently() { 171 + let a = GlyphTessellationKey::new(FontFace::Sans, GlyphId::new(7), px(16.0)); 172 + let b = GlyphTessellationKey::new(FontFace::Sans, GlyphId::new(7), px(17.0)); 173 + let c = GlyphTessellationKey::new(FontFace::Sans, GlyphId::new(7), px(16.0)); 174 + assert_ne!(a, b); 175 + assert_eq!(a, c); 176 + } 177 + 178 + #[test] 179 + fn ascii_glyph_produces_triangles_and_positive_bounds() { 180 + let mut tess = OutlineTessellator::new(); 181 + let glyph = glyph_for(FontFace::Sans, 'A'); 182 + let result = tess.tessellate(FontFace::Sans, glyph, px(32.0)).clone(); 183 + assert!(!result.indices.is_empty(), "A must tessellate to triangles"); 184 + assert!( 185 + result.indices.len().is_multiple_of(3), 186 + "lyon emits triangle list" 187 + ); 188 + assert!(result.bbox_px.size.width.value() > 0.0); 189 + assert!(result.bbox_px.size.height.value() > 0.0); 190 + } 191 + 192 + #[test] 193 + fn space_glyph_returns_empty_geometry() { 194 + let mut tess = OutlineTessellator::new(); 195 + let glyph = glyph_for(FontFace::Sans, ' '); 196 + let result = tess.tessellate(FontFace::Sans, glyph, px(32.0)).clone(); 197 + assert!(result.is_empty(), "space has no fill geometry"); 198 + } 199 + 200 + #[test] 201 + fn unknown_glyph_returns_empty() { 202 + let mut tess = OutlineTessellator::new(); 203 + let result = tess 204 + .tessellate(FontFace::Sans, GlyphId::new(0xFFFF), px(32.0)) 205 + .clone(); 206 + assert!(result.is_empty()); 207 + } 208 + 209 + #[test] 210 + fn glyph_id_outside_u16_returns_empty() { 211 + let mut tess = OutlineTessellator::new(); 212 + let result = tess 213 + .tessellate(FontFace::Sans, GlyphId::new(0x0001_0000), px(32.0)) 214 + .clone(); 215 + assert!(result.is_empty()); 216 + } 217 + 218 + #[test] 219 + fn cache_returns_same_geometry_for_repeated_calls() { 220 + let mut tess = OutlineTessellator::new(); 221 + let glyph = glyph_for(FontFace::Sans, 'B'); 222 + let first = tess.tessellate(FontFace::Sans, glyph, px(24.0)).clone(); 223 + let second = tess.tessellate(FontFace::Sans, glyph, px(24.0)).clone(); 224 + assert_eq!(first, second); 225 + } 226 + 227 + #[test] 228 + fn distinct_sizes_yield_distinct_geometry() { 229 + let mut tess = OutlineTessellator::new(); 230 + let glyph = glyph_for(FontFace::Mono, 'O'); 231 + let small = tess.tessellate(FontFace::Mono, glyph, px(12.0)).clone(); 232 + let large = tess.tessellate(FontFace::Mono, glyph, px(24.0)).clone(); 233 + assert_ne!(small, large); 234 + } 235 + 236 + #[test] 237 + fn larger_size_yields_proportionally_larger_bounds() { 238 + let mut tess = OutlineTessellator::new(); 239 + let glyph = glyph_for(FontFace::Sans, 'M'); 240 + let small = tess 241 + .tessellate(FontFace::Sans, glyph, px(16.0)) 242 + .bbox_px 243 + .size; 244 + let large = tess 245 + .tessellate(FontFace::Sans, glyph, px(32.0)) 246 + .bbox_px 247 + .size; 248 + assert!(large.width.value() > small.width.value()); 249 + assert!(large.height.value() > small.height.value()); 250 + let ratio_w = large.width.value() / small.width.value(); 251 + assert!( 252 + (ratio_w - 2.0).abs() < 0.1, 253 + "scaling 2x must preserve width ratio: got {ratio_w}", 254 + ); 255 + } 256 + 257 + #[test] 258 + fn vertex_count_matches_index_consumers() { 259 + let mut tess = OutlineTessellator::new(); 260 + let glyph = glyph_for(FontFace::Sans, 'g'); 261 + let TessellatedGlyph { 262 + vertices_px, 263 + indices, 264 + .. 265 + } = tess.tessellate(FontFace::Sans, glyph, px(32.0)).clone(); 266 + let max_idx = indices.iter().copied().max().unwrap_or(0) as usize; 267 + assert!( 268 + max_idx < vertices_px.len(), 269 + "every index must reference a real vertex", 270 + ); 271 + } 272 + 273 + #[test] 274 + fn empty_glyph_helper_is_default_state() { 275 + let g = TessellatedGlyph::empty(); 276 + assert!(g.is_empty()); 277 + assert!(g.vertices_px.is_empty()); 278 + assert!(g.indices.is_empty()); 279 + } 280 + 281 + #[test] 282 + fn descender_glyph_bbox_extends_below_baseline_in_font_y_up_space() { 283 + let mut tess = OutlineTessellator::new(); 284 + let glyph = glyph_for(FontFace::Sans, 'g'); 285 + let bbox = tess.tessellate(FontFace::Sans, glyph, px(64.0)).bbox_px; 286 + assert!( 287 + bbox.origin.y.value() < 0.0, 288 + "swash returns Y-up font coords: descender of 'g' must have negative origin.y, got {}", 289 + bbox.origin.y.value(), 290 + ); 291 + } 292 + 293 + #[test] 294 + fn ascender_glyph_bbox_lies_above_baseline_in_font_y_up_space() { 295 + let mut tess = OutlineTessellator::new(); 296 + let glyph = glyph_for(FontFace::Sans, 'l'); 297 + let bbox = tess.tessellate(FontFace::Sans, glyph, px(64.0)).bbox_px; 298 + assert!( 299 + bbox.max_y().value() > 0.0, 300 + "ascender of 'l' must reach above baseline (max y > 0) in Y-up space, got {}", 301 + bbox.max_y().value(), 302 + ); 303 + assert!( 304 + bbox.min_y().value() >= -1.0, 305 + "ascender-only glyph 'l' should not extend significantly below baseline, got min y {}", 306 + bbox.min_y().value(), 307 + ); 308 + } 309 + }