Another project
1
fork

Configure Feed

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

feat(ui): sdf atlas, 8ssedt field, shelf packer

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

+881
+881
crates/bone-ui/src/text/raster/sdf.rs
··· 1 + use std::collections::HashMap; 2 + 3 + use bone_text::{FontFace, GlyphId, load_font}; 4 + use swash::{ 5 + FontRef, 6 + scale::{Render, ScaleContext, Source, image::Image}, 7 + zeno::Format, 8 + }; 9 + use thiserror::Error; 10 + 11 + use crate::layout::{LayoutOffset, LayoutPx, LayoutSize}; 12 + use crate::theme::FontSize; 13 + 14 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 15 + pub struct SdfBaseSize(u32); 16 + 17 + impl SdfBaseSize { 18 + pub const DEFAULT: Self = Self(32); 19 + 20 + #[must_use] 21 + pub const fn new(value: u32) -> Option<Self> { 22 + if value >= 8 && value <= 256 { 23 + Some(Self(value)) 24 + } else { 25 + None 26 + } 27 + } 28 + 29 + #[must_use] 30 + pub const fn px(self) -> u32 { 31 + self.0 32 + } 33 + } 34 + 35 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 36 + pub struct SdfSpread(u32); 37 + 38 + impl SdfSpread { 39 + pub const DEFAULT: Self = Self(4); 40 + 41 + #[must_use] 42 + pub const fn new(value: u32) -> Option<Self> { 43 + if value >= 1 && value <= 32 { 44 + Some(Self(value)) 45 + } else { 46 + None 47 + } 48 + } 49 + 50 + #[must_use] 51 + pub const fn px(self) -> u32 { 52 + self.0 53 + } 54 + } 55 + 56 + #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] 57 + pub struct GlyphAtlasKey { 58 + pub face: FontFace, 59 + pub glyph: GlyphId, 60 + } 61 + 62 + impl GlyphAtlasKey { 63 + #[must_use] 64 + pub const fn new(face: FontFace, glyph: GlyphId) -> Self { 65 + Self { face, glyph } 66 + } 67 + } 68 + 69 + #[derive(Copy, Clone, Debug, PartialEq)] 70 + pub struct AtlasEntry { 71 + pub uv_min: [f32; 2], 72 + pub uv_max: [f32; 2], 73 + pub atlas_origin: [u32; 2], 74 + pub atlas_size: [u32; 2], 75 + pub bearing: LayoutOffset, 76 + pub glyph_size: LayoutSize, 77 + pub spread: LayoutPx, 78 + pub base_size: FontSize, 79 + } 80 + 81 + impl AtlasEntry { 82 + #[must_use] 83 + pub fn display_extent(self, target: FontSize) -> LayoutSize { 84 + let scale = self.scale_to(target); 85 + LayoutSize::new( 86 + LayoutPx::saturating_nonneg(self.glyph_size.width.value() * scale), 87 + LayoutPx::saturating_nonneg(self.glyph_size.height.value() * scale), 88 + ) 89 + } 90 + 91 + #[must_use] 92 + pub fn display_bearing(self, target: FontSize) -> LayoutOffset { 93 + let scale = self.scale_to(target); 94 + LayoutOffset::new( 95 + LayoutPx::saturating(self.bearing.dx.value() * scale), 96 + LayoutPx::saturating(self.bearing.dy.value() * scale), 97 + ) 98 + } 99 + 100 + fn scale_to(self, target: FontSize) -> f32 { 101 + target.as_px_f32() / self.base_size.as_px_f32() 102 + } 103 + } 104 + 105 + #[derive(Copy, Clone, Debug, PartialEq)] 106 + pub struct SdfAtlasParams { 107 + pub base_size: SdfBaseSize, 108 + pub spread: SdfSpread, 109 + pub atlas_extent: u32, 110 + } 111 + 112 + impl SdfAtlasParams { 113 + pub const STANDARD: Self = Self { 114 + base_size: SdfBaseSize::DEFAULT, 115 + spread: SdfSpread::DEFAULT, 116 + atlas_extent: 1024, 117 + }; 118 + } 119 + 120 + #[derive(Debug, Error, PartialEq, Eq)] 121 + pub enum SdfAtlasError { 122 + #[error("glyph id {0} exceeds the swash u16 range")] 123 + GlyphOutOfRange(u32), 124 + #[error("tile {tile_width}x{tile_height} exceeds atlas extent {atlas_extent}")] 125 + TileExceedsAtlas { 126 + tile_width: u32, 127 + tile_height: u32, 128 + atlas_extent: u32, 129 + }, 130 + #[error("atlas full: no shelf space for {tile_width}x{tile_height} tile")] 131 + Full { tile_width: u32, tile_height: u32 }, 132 + } 133 + 134 + const SDF_OUTSIDE_FAR: u8 = 255; 135 + 136 + pub struct SdfAtlas { 137 + params: SdfAtlasParams, 138 + pixels: Vec<u8>, 139 + sans: FontRef<'static>, 140 + mono: FontRef<'static>, 141 + scale_ctx: ScaleContext, 142 + entries: HashMap<GlyphAtlasKey, AtlasEntry>, 143 + packer: ShelfPacker, 144 + version: u64, 145 + } 146 + 147 + impl SdfAtlas { 148 + #[must_use] 149 + pub fn new(params: SdfAtlasParams) -> Self { 150 + let extent = params.atlas_extent as usize; 151 + Self { 152 + params, 153 + pixels: vec![SDF_OUTSIDE_FAR; extent * extent], 154 + sans: load_font(FontFace::Sans), 155 + mono: load_font(FontFace::Mono), 156 + scale_ctx: ScaleContext::new(), 157 + entries: HashMap::new(), 158 + packer: ShelfPacker::new(params.atlas_extent), 159 + version: 0, 160 + } 161 + } 162 + 163 + #[must_use] 164 + pub fn params(&self) -> SdfAtlasParams { 165 + self.params 166 + } 167 + 168 + #[must_use] 169 + pub fn extent(&self) -> u32 { 170 + self.params.atlas_extent 171 + } 172 + 173 + #[must_use] 174 + pub fn pixels(&self) -> &[u8] { 175 + &self.pixels 176 + } 177 + 178 + #[must_use] 179 + pub fn version(&self) -> u64 { 180 + self.version 181 + } 182 + 183 + #[must_use] 184 + pub fn entry(&self, key: GlyphAtlasKey) -> Option<AtlasEntry> { 185 + self.entries.get(&key).copied() 186 + } 187 + 188 + #[must_use] 189 + pub fn entries_len(&self) -> usize { 190 + self.entries.len() 191 + } 192 + 193 + pub fn ensure(&mut self, key: GlyphAtlasKey) -> Result<AtlasEntry, SdfAtlasError> { 194 + if let Some(entry) = self.entries.get(&key) { 195 + return Ok(*entry); 196 + } 197 + let glyph_id_u16 = u16::try_from(key.glyph.raw()) 198 + .map_err(|_| SdfAtlasError::GlyphOutOfRange(key.glyph.raw()))?; 199 + let font = match key.face { 200 + FontFace::Sans => self.sans, 201 + FontFace::Mono => self.mono, 202 + }; 203 + let tile = rasterise_glyph_sdf(&font, glyph_id_u16, &mut self.scale_ctx, self.params)?; 204 + let placed = self 205 + .packer 206 + .place(tile.width, tile.height) 207 + .ok_or(SdfAtlasError::Full { 208 + tile_width: tile.width, 209 + tile_height: tile.height, 210 + })?; 211 + blit_tile(&mut self.pixels, self.params.atlas_extent, placed, &tile); 212 + let entry = atlas_entry(self.params, placed, &tile); 213 + self.entries.insert(key, entry); 214 + self.version = self.version.wrapping_add(1); 215 + Ok(entry) 216 + } 217 + } 218 + 219 + impl core::fmt::Debug for SdfAtlas { 220 + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 221 + f.debug_struct("SdfAtlas") 222 + .field("entries", &self.entries.len()) 223 + .field("extent", &self.params.atlas_extent) 224 + .field("base_size", &self.params.base_size.px()) 225 + .field("spread", &self.params.spread.px()) 226 + .field("version", &self.version) 227 + .finish_non_exhaustive() 228 + } 229 + } 230 + 231 + struct GlyphTile { 232 + pixels: Vec<u8>, 233 + width: u32, 234 + height: u32, 235 + inner_width: u32, 236 + inner_height: u32, 237 + bearing_left: f32, 238 + bearing_top: f32, 239 + } 240 + 241 + #[allow(clippy::cast_precision_loss)] 242 + fn rasterise_glyph_sdf( 243 + font: &FontRef<'_>, 244 + glyph: u16, 245 + scale_ctx: &mut ScaleContext, 246 + params: SdfAtlasParams, 247 + ) -> Result<GlyphTile, SdfAtlasError> { 248 + let base = params.base_size.px(); 249 + let spread = params.spread.px(); 250 + let mut scaler = scale_ctx 251 + .builder(*font) 252 + .size(base as f32) 253 + .hint(false) 254 + .build(); 255 + let mut image = Image::new(); 256 + if !Render::new(&[Source::Outline]) 257 + .format(Format::Alpha) 258 + .render_into(&mut scaler, glyph, &mut image) 259 + { 260 + return Ok(empty_tile(spread)); 261 + } 262 + let placement = image.placement; 263 + let inner_w = placement.width; 264 + let inner_h = placement.height; 265 + if inner_w == 0 || inner_h == 0 { 266 + return Ok(empty_tile(spread)); 267 + } 268 + let tile_w = inner_w + 2 * spread; 269 + let tile_h = inner_h + 2 * spread; 270 + if tile_w > params.atlas_extent || tile_h > params.atlas_extent { 271 + return Err(SdfAtlasError::TileExceedsAtlas { 272 + tile_width: tile_w, 273 + tile_height: tile_h, 274 + atlas_extent: params.atlas_extent, 275 + }); 276 + } 277 + let mask = build_padded_mask(&image.data, inner_w, inner_h, spread); 278 + let pixels = compute_sdf_bytes(&mask, tile_w, tile_h, spread); 279 + Ok(GlyphTile { 280 + pixels, 281 + width: tile_w, 282 + height: tile_h, 283 + inner_width: inner_w, 284 + inner_height: inner_h, 285 + bearing_left: placement.left as f32, 286 + bearing_top: placement.top as f32, 287 + }) 288 + } 289 + 290 + fn empty_tile(spread: u32) -> GlyphTile { 291 + let tile_w = 2 * spread; 292 + let tile_h = 2 * spread; 293 + let pixels = vec![SDF_OUTSIDE_FAR; (tile_w as usize) * (tile_h as usize)]; 294 + GlyphTile { 295 + pixels, 296 + width: tile_w, 297 + height: tile_h, 298 + inner_width: 0, 299 + inner_height: 0, 300 + bearing_left: 0.0, 301 + bearing_top: 0.0, 302 + } 303 + } 304 + 305 + fn build_padded_mask(coverage: &[u8], inner_w: u32, inner_h: u32, pad: u32) -> Vec<bool> { 306 + let tile_w = inner_w + 2 * pad; 307 + let tile_h = inner_h + 2 * pad; 308 + let mut mask = vec![false; (tile_w as usize) * (tile_h as usize)]; 309 + (0..inner_h).for_each(|y| { 310 + (0..inner_w).for_each(|x| { 311 + let src = (y as usize) * (inner_w as usize) + (x as usize); 312 + let dst = ((y + pad) as usize) * (tile_w as usize) + (x + pad) as usize; 313 + mask[dst] = coverage[src] >= 128; 314 + }); 315 + }); 316 + mask 317 + } 318 + 319 + const FAR: i32 = i32::MAX / 4; 320 + 321 + #[allow(clippy::cast_precision_loss)] 322 + fn compute_sdf_bytes(mask: &[bool], width: u32, height: u32, spread: u32) -> Vec<u8> { 323 + let outside = distance_field_to(mask, width, height, true); 324 + let inside = distance_field_to(mask, width, height, false); 325 + let spread_f = spread as f32; 326 + (0..(width as usize) * (height as usize)) 327 + .map(|i| { 328 + let do_ = (outside[i] as f32).sqrt(); 329 + let di = (inside[i] as f32).sqrt(); 330 + let signed = if mask[i] { -di } else { do_ }; 331 + let normalised = ((signed / spread_f) * 0.5 + 0.5).clamp(0.0, 1.0); 332 + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] 333 + let byte = (normalised * 255.0 + 0.5) as u8; 334 + byte 335 + }) 336 + .collect() 337 + } 338 + 339 + fn distance_field_to(mask: &[bool], width: u32, height: u32, target_inside: bool) -> Vec<i32> { 340 + let w = width as usize; 341 + let h = height as usize; 342 + let n = w * h; 343 + let mut dx: Vec<i32> = vec![FAR; n]; 344 + let mut dy: Vec<i32> = vec![FAR; n]; 345 + let mut sq: Vec<i32> = vec![FAR; n]; 346 + (0..h).for_each(|y| { 347 + (0..w).for_each(|x| { 348 + if mask[y * w + x] == target_inside { 349 + let i = y * w + x; 350 + dx[i] = 0; 351 + dy[i] = 0; 352 + sq[i] = 0; 353 + } 354 + }); 355 + }); 356 + sweep_forward(&mut dx, &mut dy, &mut sq, w, h); 357 + sweep_backward(&mut dx, &mut dy, &mut sq, w, h); 358 + sq 359 + } 360 + 361 + fn compare( 362 + dx: &mut [i32], 363 + dy: &mut [i32], 364 + sq: &mut [i32], 365 + target: usize, 366 + other: usize, 367 + odx: i32, 368 + ody: i32, 369 + ) { 370 + let nx = dx[other] + odx; 371 + let ny = dy[other] + ody; 372 + let nsq = nx.saturating_mul(nx).saturating_add(ny.saturating_mul(ny)); 373 + if nsq < sq[target] { 374 + dx[target] = nx; 375 + dy[target] = ny; 376 + sq[target] = nsq; 377 + } 378 + } 379 + 380 + fn sweep_forward(dx: &mut [i32], dy: &mut [i32], sq: &mut [i32], w: usize, h: usize) { 381 + (0..h).for_each(|y| { 382 + (0..w).for_each(|x| { 383 + let i = y * w + x; 384 + if x > 0 { 385 + compare(dx, dy, sq, i, i - 1, 1, 0); 386 + } 387 + if y > 0 { 388 + let up = i - w; 389 + compare(dx, dy, sq, i, up, 0, 1); 390 + if x > 0 { 391 + compare(dx, dy, sq, i, up - 1, 1, 1); 392 + } 393 + if x + 1 < w { 394 + compare(dx, dy, sq, i, up + 1, 1, 1); 395 + } 396 + } 397 + }); 398 + (0..w).rev().for_each(|x| { 399 + let i = y * w + x; 400 + if x + 1 < w { 401 + compare(dx, dy, sq, i, i + 1, 1, 0); 402 + } 403 + }); 404 + }); 405 + } 406 + 407 + fn sweep_backward(dx: &mut [i32], dy: &mut [i32], sq: &mut [i32], w: usize, h: usize) { 408 + (0..h).rev().for_each(|y| { 409 + (0..w).rev().for_each(|x| { 410 + let i = y * w + x; 411 + if x + 1 < w { 412 + compare(dx, dy, sq, i, i + 1, 1, 0); 413 + } 414 + if y + 1 < h { 415 + let down = i + w; 416 + compare(dx, dy, sq, i, down, 0, 1); 417 + if x + 1 < w { 418 + compare(dx, dy, sq, i, down + 1, 1, 1); 419 + } 420 + if x > 0 { 421 + compare(dx, dy, sq, i, down - 1, 1, 1); 422 + } 423 + } 424 + }); 425 + (0..w).for_each(|x| { 426 + let i = y * w + x; 427 + if x > 0 { 428 + compare(dx, dy, sq, i, i - 1, 1, 0); 429 + } 430 + }); 431 + }); 432 + } 433 + 434 + #[derive(Copy, Clone, Debug, PartialEq, Eq)] 435 + struct PlacedTile { 436 + x: u32, 437 + y: u32, 438 + width: u32, 439 + height: u32, 440 + } 441 + 442 + struct ShelfPacker { 443 + extent: u32, 444 + cursor_x: u32, 445 + shelf_y: u32, 446 + shelf_height: u32, 447 + } 448 + 449 + impl ShelfPacker { 450 + fn new(extent: u32) -> Self { 451 + Self { 452 + extent, 453 + cursor_x: 0, 454 + shelf_y: 0, 455 + shelf_height: 0, 456 + } 457 + } 458 + 459 + fn place(&mut self, tile_w: u32, tile_h: u32) -> Option<PlacedTile> { 460 + if tile_w > self.extent || tile_h > self.extent { 461 + return None; 462 + } 463 + if self.cursor_x.saturating_add(tile_w) > self.extent { 464 + self.shelf_y = self.shelf_y.checked_add(self.shelf_height)?; 465 + self.cursor_x = 0; 466 + self.shelf_height = 0; 467 + } 468 + if self.shelf_y.saturating_add(tile_h) > self.extent { 469 + return None; 470 + } 471 + let placed = PlacedTile { 472 + x: self.cursor_x, 473 + y: self.shelf_y, 474 + width: tile_w, 475 + height: tile_h, 476 + }; 477 + self.cursor_x += tile_w; 478 + if tile_h > self.shelf_height { 479 + self.shelf_height = tile_h; 480 + } 481 + Some(placed) 482 + } 483 + } 484 + 485 + fn blit_tile(dst: &mut [u8], extent: u32, placed: PlacedTile, tile: &GlyphTile) { 486 + (0..placed.height).for_each(|row| { 487 + let src_off = (row as usize) * (placed.width as usize); 488 + let dst_off = ((placed.y + row) as usize) * (extent as usize) + placed.x as usize; 489 + let len = placed.width as usize; 490 + dst[dst_off..dst_off + len].copy_from_slice(&tile.pixels[src_off..src_off + len]); 491 + }); 492 + } 493 + 494 + #[allow(clippy::cast_precision_loss)] 495 + fn atlas_entry(params: SdfAtlasParams, placed: PlacedTile, tile: &GlyphTile) -> AtlasEntry { 496 + let extent = params.atlas_extent as f32; 497 + let uv_min = [placed.x as f32 / extent, placed.y as f32 / extent]; 498 + let uv_max = [ 499 + (placed.x + placed.width) as f32 / extent, 500 + (placed.y + placed.height) as f32 / extent, 501 + ]; 502 + let pad = params.spread.px() as f32; 503 + let bearing = LayoutOffset::new( 504 + LayoutPx::saturating(tile.bearing_left - pad), 505 + LayoutPx::saturating(tile.bearing_top + pad), 506 + ); 507 + let glyph_size = LayoutSize::new( 508 + LayoutPx::saturating_nonneg(tile.inner_width as f32 + 2.0 * pad), 509 + LayoutPx::saturating_nonneg(tile.inner_height as f32 + 2.0 * pad), 510 + ); 511 + AtlasEntry { 512 + uv_min, 513 + uv_max, 514 + atlas_origin: [placed.x, placed.y], 515 + atlas_size: [placed.width, placed.height], 516 + bearing, 517 + glyph_size, 518 + spread: LayoutPx::saturating_nonneg(pad), 519 + base_size: FontSize::from_px(f64::from(params.base_size.px())), 520 + } 521 + } 522 + 523 + #[cfg(test)] 524 + mod tests { 525 + use super::{ 526 + AtlasEntry, GlyphAtlasKey, PlacedTile, SdfAtlas, SdfAtlasError, SdfAtlasParams, 527 + SdfBaseSize, SdfSpread, ShelfPacker, 528 + }; 529 + use crate::theme::FontSize; 530 + use bone_text::{FontFace, GlyphId, load_font}; 531 + 532 + #[derive(Copy, Clone, Debug, PartialEq)] 533 + struct SdfPixel(u8); 534 + 535 + impl SdfPixel { 536 + const ON_EDGE: Self = Self(128); 537 + 538 + const fn raw(self) -> u8 { 539 + self.0 540 + } 541 + 542 + const fn is_inside(self) -> bool { 543 + self.0 < Self::ON_EDGE.0 544 + } 545 + } 546 + 547 + fn glyph_for(face: FontFace, ch: char) -> GlyphId { 548 + let font = load_font(face); 549 + GlyphId::new(u32::from(font.charmap().map(u32::from(ch)))) 550 + } 551 + 552 + fn small_params() -> SdfAtlasParams { 553 + SdfAtlasParams { 554 + base_size: SdfBaseSize::DEFAULT, 555 + spread: SdfSpread::DEFAULT, 556 + atlas_extent: 256, 557 + } 558 + } 559 + 560 + #[track_caller] 561 + fn ensure_ok(atlas: &mut SdfAtlas, key: GlyphAtlasKey) -> AtlasEntry { 562 + let Ok(entry) = atlas.ensure(key) else { 563 + panic!("ensure must succeed for {key:?}") 564 + }; 565 + entry 566 + } 567 + 568 + #[track_caller] 569 + fn place_ok(packer: &mut ShelfPacker, w: u32, h: u32) -> PlacedTile { 570 + let Some(p) = packer.place(w, h) else { 571 + panic!("place {w}x{h} must succeed") 572 + }; 573 + p 574 + } 575 + 576 + #[test] 577 + fn base_size_constructor_rejects_extremes() { 578 + assert!(SdfBaseSize::new(0).is_none()); 579 + assert!(SdfBaseSize::new(7).is_none()); 580 + assert!(SdfBaseSize::new(8).is_some()); 581 + assert!(SdfBaseSize::new(256).is_some()); 582 + assert!(SdfBaseSize::new(257).is_none()); 583 + } 584 + 585 + #[test] 586 + fn spread_constructor_rejects_extremes() { 587 + assert!(SdfSpread::new(0).is_none()); 588 + assert!(SdfSpread::new(1).is_some()); 589 + assert!(SdfSpread::new(32).is_some()); 590 + assert!(SdfSpread::new(33).is_none()); 591 + } 592 + 593 + #[test] 594 + fn pixel_threshold_distinguishes_inside_and_outside() { 595 + assert!(SdfPixel::ON_EDGE.raw() == 128); 596 + assert!(!SdfPixel::ON_EDGE.is_inside()); 597 + assert!(SdfPixel(0).is_inside()); 598 + assert!(!SdfPixel(255).is_inside()); 599 + } 600 + 601 + #[test] 602 + fn fresh_atlas_has_zero_entries_and_zero_version() { 603 + let atlas = SdfAtlas::new(small_params()); 604 + assert_eq!(atlas.entries_len(), 0); 605 + assert_eq!(atlas.version(), 0); 606 + assert_eq!(atlas.extent(), 256); 607 + assert_eq!(atlas.pixels().len(), 256 * 256); 608 + } 609 + 610 + #[test] 611 + fn ensure_glyph_writes_atlas_and_bumps_version() { 612 + let mut atlas = SdfAtlas::new(small_params()); 613 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'A')); 614 + let entry = ensure_ok(&mut atlas, key); 615 + assert!(atlas.entries_len() == 1); 616 + assert!(atlas.version() == 1); 617 + assert!(entry.uv_max[0] > entry.uv_min[0]); 618 + assert!(entry.uv_max[1] > entry.uv_min[1]); 619 + assert!(entry.glyph_size.width.value() > 0.0); 620 + assert!(entry.glyph_size.height.value() > 0.0); 621 + } 622 + 623 + #[test] 624 + fn ensure_idempotent_for_same_key() { 625 + let mut atlas = SdfAtlas::new(small_params()); 626 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'B')); 627 + let first = ensure_ok(&mut atlas, key); 628 + let v_after_first = atlas.version(); 629 + let second = ensure_ok(&mut atlas, key); 630 + assert_eq!(first, second); 631 + assert_eq!(atlas.version(), v_after_first); 632 + assert_eq!(atlas.entries_len(), 1); 633 + } 634 + 635 + #[test] 636 + fn ensure_writes_inside_pixels_for_solid_letter() { 637 + let mut atlas = SdfAtlas::new(small_params()); 638 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'I')); 639 + let entry = ensure_ok(&mut atlas, key); 640 + let extent = atlas.extent(); 641 + let any_inside = (entry.atlas_origin[1]..entry.atlas_origin[1] + entry.atlas_size[1]) 642 + .flat_map(|y| { 643 + (entry.atlas_origin[0]..entry.atlas_origin[0] + entry.atlas_size[0]) 644 + .map(move |x| (x, y)) 645 + }) 646 + .any(|(x, y)| { 647 + let v = atlas.pixels()[(y as usize) * (extent as usize) + x as usize]; 648 + SdfPixel(v).is_inside() 649 + }); 650 + assert!( 651 + any_inside, 652 + "expected at least one inside pixel inside I tile" 653 + ); 654 + } 655 + 656 + #[test] 657 + fn donut_glyph_centre_stays_outside() { 658 + let mut atlas = SdfAtlas::new(small_params()); 659 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'O')); 660 + let entry = ensure_ok(&mut atlas, key); 661 + let extent = atlas.extent(); 662 + let cx = entry.atlas_origin[0] + entry.atlas_size[0] / 2; 663 + let cy = entry.atlas_origin[1] + entry.atlas_size[1] / 2; 664 + let value = atlas.pixels()[(cy as usize) * (extent as usize) + cx as usize]; 665 + assert!( 666 + !SdfPixel(value).is_inside(), 667 + "centre of O lies inside the donut hole, must be outside fill, raw={value}", 668 + ); 669 + } 670 + 671 + #[test] 672 + fn empty_glyph_returns_no_geometry_but_inserts_entry() { 673 + let mut atlas = SdfAtlas::new(small_params()); 674 + let space = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, ' ')); 675 + let entry = ensure_ok(&mut atlas, space); 676 + assert!( 677 + entry.glyph_size.width.value() > 0.0, 678 + "tile retains spread padding" 679 + ); 680 + } 681 + 682 + #[test] 683 + fn empty_glyph_tile_is_filled_with_outside_pixels() { 684 + let mut atlas = SdfAtlas::new(small_params()); 685 + let space = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, ' ')); 686 + let entry = ensure_ok(&mut atlas, space); 687 + let extent = atlas.extent(); 688 + let any_inside = (entry.atlas_origin[1]..entry.atlas_origin[1] + entry.atlas_size[1]) 689 + .flat_map(|y| { 690 + (entry.atlas_origin[0]..entry.atlas_origin[0] + entry.atlas_size[0]) 691 + .map(move |x| (x, y)) 692 + }) 693 + .any(|(x, y)| { 694 + let v = atlas.pixels()[(y as usize) * (extent as usize) + x as usize]; 695 + SdfPixel(v).is_inside() 696 + }); 697 + assert!( 698 + !any_inside, 699 + "empty glyph tile must be all outside pixels, never sampled as inside fill", 700 + ); 701 + } 702 + 703 + #[test] 704 + fn out_of_range_glyph_yields_typed_error() { 705 + let mut atlas = SdfAtlas::new(small_params()); 706 + let key = GlyphAtlasKey::new(FontFace::Sans, GlyphId::new(0x0001_0000)); 707 + match atlas.ensure(key) { 708 + Err(SdfAtlasError::GlyphOutOfRange(g)) => assert_eq!(g, 0x0001_0000), 709 + other => panic!("expected GlyphOutOfRange, got {other:?}"), 710 + } 711 + } 712 + 713 + #[test] 714 + fn distinct_faces_produce_distinct_entries() { 715 + let mut atlas = SdfAtlas::new(small_params()); 716 + let sans = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'A')); 717 + let mono = GlyphAtlasKey::new(FontFace::Mono, glyph_for(FontFace::Mono, 'A')); 718 + let _ = ensure_ok(&mut atlas, sans); 719 + let _ = ensure_ok(&mut atlas, mono); 720 + assert_eq!(atlas.entries_len(), 2); 721 + assert_ne!(atlas.entry(sans), atlas.entry(mono)); 722 + } 723 + 724 + #[test] 725 + fn entries_lookup_returns_inserted() { 726 + let mut atlas = SdfAtlas::new(small_params()); 727 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'C')); 728 + assert!(atlas.entry(key).is_none()); 729 + let inserted = ensure_ok(&mut atlas, key); 730 + assert_eq!(atlas.entry(key), Some(inserted)); 731 + } 732 + 733 + #[test] 734 + fn shelf_packer_lays_tiles_left_to_right_then_wraps() { 735 + let mut packer = ShelfPacker::new(64); 736 + let a = place_ok(&mut packer, 20, 30); 737 + let b = place_ok(&mut packer, 20, 30); 738 + let c = place_ok(&mut packer, 20, 30); 739 + let d = place_ok(&mut packer, 20, 30); 740 + assert_eq!((a.x, a.y), (0, 0)); 741 + assert_eq!((b.x, b.y), (20, 0)); 742 + assert_eq!((c.x, c.y), (40, 0)); 743 + assert_eq!((d.x, d.y), (0, 30)); 744 + } 745 + 746 + #[test] 747 + fn shelf_packer_returns_none_when_full() { 748 + let mut packer = ShelfPacker::new(32); 749 + assert!(packer.place(32, 16).is_some()); 750 + assert!(packer.place(32, 16).is_some()); 751 + assert!(packer.place(32, 16).is_none()); 752 + } 753 + 754 + #[test] 755 + fn shelf_packer_rejects_oversized_tile() { 756 + let mut packer = ShelfPacker::new(64); 757 + assert!(packer.place(65, 4).is_none()); 758 + assert!(packer.place(4, 65).is_none()); 759 + } 760 + 761 + #[test] 762 + fn tile_exceeding_extent_yields_typed_error() { 763 + let mut atlas = SdfAtlas::new(SdfAtlasParams { 764 + base_size: SdfBaseSize::DEFAULT, 765 + spread: SdfSpread::DEFAULT, 766 + atlas_extent: 16, 767 + }); 768 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'A')); 769 + match atlas.ensure(key) { 770 + Err(SdfAtlasError::TileExceedsAtlas { 771 + tile_width, 772 + tile_height, 773 + atlas_extent, 774 + }) => { 775 + assert_eq!(atlas_extent, 16); 776 + assert!(tile_width > atlas_extent || tile_height > atlas_extent); 777 + } 778 + other => panic!("expected TileExceedsAtlas, got {other:?}"), 779 + } 780 + } 781 + 782 + #[test] 783 + fn shelf_full_yields_typed_error() { 784 + let mut atlas = SdfAtlas::new(SdfAtlasParams { 785 + base_size: SdfBaseSize::DEFAULT, 786 + spread: SdfSpread::DEFAULT, 787 + atlas_extent: 64, 788 + }); 789 + let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%"; 790 + let outcome = chars.chars().try_fold((), |(), ch| { 791 + atlas 792 + .ensure(GlyphAtlasKey::new( 793 + FontFace::Sans, 794 + glyph_for(FontFace::Sans, ch), 795 + )) 796 + .map(|_| ()) 797 + }); 798 + assert!( 799 + matches!(outcome, Err(SdfAtlasError::Full { .. })), 800 + "expected Full once shelves run out, got {outcome:?}", 801 + ); 802 + } 803 + 804 + #[test] 805 + fn display_extent_scales_with_target_size() { 806 + let mut atlas = SdfAtlas::new(small_params()); 807 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'A')); 808 + let entry = ensure_ok(&mut atlas, key); 809 + let small = entry.display_extent(FontSize::from_px(6.0)); 810 + let big = entry.display_extent(FontSize::from_px(96.0)); 811 + assert!(big.width.value() > small.width.value() * 15.0); 812 + assert!(big.height.value() > small.height.value() * 15.0); 813 + } 814 + 815 + #[test] 816 + fn atlas_size_supports_target_size_range() { 817 + let mut atlas = SdfAtlas::new(small_params()); 818 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'g')); 819 + let entry = ensure_ok(&mut atlas, key); 820 + let extent_at_min = entry.display_extent(FontSize::from_px(6.0)); 821 + let extent_at_max = entry.display_extent(FontSize::from_px(96.0)); 822 + assert!(extent_at_min.width.value() > 0.0 && extent_at_min.height.value() > 0.0); 823 + assert!(extent_at_max.width.value() > extent_at_min.width.value()); 824 + assert!(extent_at_max.height.value() > extent_at_min.height.value()); 825 + } 826 + 827 + #[test] 828 + fn display_bearing_scales_with_target_size_and_preserves_sign() { 829 + let mut atlas = SdfAtlas::new(small_params()); 830 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'A')); 831 + let entry = ensure_ok(&mut atlas, key); 832 + let bearing_small = entry.display_bearing(FontSize::from_px(8.0)); 833 + let bearing_large = entry.display_bearing(FontSize::from_px(64.0)); 834 + assert!((bearing_large.dx.value() - bearing_small.dx.value() * 8.0).abs() < 1e-3); 835 + assert!((bearing_large.dy.value() - bearing_small.dy.value() * 8.0).abs() < 1e-3); 836 + } 837 + 838 + #[test] 839 + fn ascender_bearing_dy_exceeds_descender_in_font_y_up_space() { 840 + let mut atlas = SdfAtlas::new(small_params()); 841 + let target = FontSize::from_px(64.0); 842 + let ascender = ensure_ok( 843 + &mut atlas, 844 + GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'l')), 845 + ); 846 + let descender = ensure_ok( 847 + &mut atlas, 848 + GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'g')), 849 + ); 850 + let asc_dy = ascender.display_bearing(target).dy.value(); 851 + let desc_dy = descender.display_bearing(target).dy.value(); 852 + assert!( 853 + asc_dy > 0.0, 854 + "swash returns Y-up: ascender 'l' bearing.dy must be positive, got {asc_dy}", 855 + ); 856 + assert!( 857 + asc_dy > desc_dy, 858 + "ascender bearing.dy {asc_dy} must exceed descender bearing.dy {desc_dy} in Y-up space", 859 + ); 860 + } 861 + 862 + #[test] 863 + fn signed_distance_byte_at_edge_is_close_to_threshold() { 864 + let mut atlas = SdfAtlas::new(small_params()); 865 + let key = GlyphAtlasKey::new(FontFace::Sans, glyph_for(FontFace::Sans, 'I')); 866 + let entry = ensure_ok(&mut atlas, key); 867 + let extent = atlas.extent(); 868 + let column = entry.atlas_origin[0] + entry.atlas_size[0] / 2; 869 + let edge_band: Vec<u8> = (entry.atlas_origin[1] 870 + ..entry.atlas_origin[1] + entry.atlas_size[1]) 871 + .map(|row| atlas.pixels()[(row as usize) * (extent as usize) + column as usize]) 872 + .collect(); 873 + let near_edge = edge_band 874 + .iter() 875 + .any(|v| (i32::from(*v) - i32::from(SdfPixel::ON_EDGE.raw())).abs() <= 32); 876 + assert!( 877 + near_edge, 878 + "expected at least one pixel near edge value 128, got {edge_band:?}" 879 + ); 880 + } 881 + }