1use std::collections::HashMap;
7use std::hash::{Hash, Hasher};
8
9use teksilo_canvas::paint::{FillRule, LineCap, LineJoin, StrokeSpace, StrokeStyle};
10use teksilo_canvas::path::{Path, PathCommand};
11
12const MAX_COSMETIC_RASTER_DIM: f32 = 2048.0;
18
19const COMPACT_SLACK_PX: u32 = 256;
23
24#[derive(Debug, Clone, Copy)]
26pub struct AtlasRegion {
27 pub x: u32,
28 pub y: u32,
29 pub w: u32,
30 pub h: u32,
31 last_used_frame: u64,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
43struct PathCacheKey(u64);
44
45impl PathCacheKey {
46 fn new(path: &Path, style: &StrokeStyle, fill_rule: FillRule, w: u32, h: u32) -> Self {
47 let mut hasher = std::hash::DefaultHasher::new();
48 for cmd in &path.commands {
50 std::mem::discriminant(cmd).hash(&mut hasher);
51 match cmd {
52 PathCommand::MoveTo(p) | PathCommand::LineTo(p) => {
53 p.x.to_bits().hash(&mut hasher);
54 p.y.to_bits().hash(&mut hasher);
55 }
56 PathCommand::QuadTo { control, to } => {
57 control.x.to_bits().hash(&mut hasher);
58 control.y.to_bits().hash(&mut hasher);
59 to.x.to_bits().hash(&mut hasher);
60 to.y.to_bits().hash(&mut hasher);
61 }
62 PathCommand::CubicTo {
63 control1,
64 control2,
65 to,
66 } => {
67 control1.x.to_bits().hash(&mut hasher);
68 control1.y.to_bits().hash(&mut hasher);
69 control2.x.to_bits().hash(&mut hasher);
70 control2.y.to_bits().hash(&mut hasher);
71 to.x.to_bits().hash(&mut hasher);
72 to.y.to_bits().hash(&mut hasher);
73 }
74 PathCommand::ArcTo {
75 rect,
76 start_angle,
77 sweep_angle,
78 } => {
79 rect.x.to_bits().hash(&mut hasher);
80 rect.y.to_bits().hash(&mut hasher);
81 rect.width.to_bits().hash(&mut hasher);
82 rect.height.to_bits().hash(&mut hasher);
83 start_angle.to_bits().hash(&mut hasher);
84 sweep_angle.to_bits().hash(&mut hasher);
85 }
86 PathCommand::Close => {}
87 }
88 }
89 style.width.to_bits().hash(&mut hasher);
91 std::mem::discriminant(&style.line_cap).hash(&mut hasher);
92 std::mem::discriminant(&style.line_join).hash(&mut hasher);
93 if let Some(ref pattern) = style.dash_pattern {
94 for &v in pattern {
95 v.to_bits().hash(&mut hasher);
96 }
97 }
98 style.dash_offset.to_bits().hash(&mut hasher);
99 style.miter_limit.to_bits().hash(&mut hasher);
100 std::mem::discriminant(&style.space).hash(&mut hasher);
103 std::mem::discriminant(&fill_rule).hash(&mut hasher);
105 w.hash(&mut hasher);
107 h.hash(&mut hasher);
108 PathCacheKey(hasher.finish())
109 }
110}
111
112pub struct PathAtlas {
114 pixels: Vec<u8>,
116 width: u32,
117 height: u32,
118 max_size: u32,
120 cache: HashMap<PathCacheKey, AtlasRegion>,
122 current_frame: u64,
124 dirty: bool,
126 shelf_y: u32,
129 shelf_x: u32,
131 shelf_height: u32,
133 oversize_skips: u64,
140}
141
142impl PathAtlas {
143 pub fn new(width: u32, height: u32) -> Self {
145 Self {
146 pixels: vec![0; (width * height * 4) as usize],
147 width,
148 height,
149 max_size: 4096,
150 cache: HashMap::new(),
151 current_frame: 0,
152 dirty: false,
153 shelf_y: 0,
154 shelf_x: 0,
155 shelf_height: 0,
156 oversize_skips: 0,
157 }
158 }
159
160 pub fn oversize_skips(&self) -> u64 {
168 self.oversize_skips
169 }
170
171 pub fn begin_frame(&mut self) {
182 self.current_frame += 1;
183
184 let keep_from = self.current_frame - 1;
187 let near_full =
188 self.shelf_y.saturating_add(self.shelf_height) + COMPACT_SLACK_PX >= self.height;
189 let has_stale = self.cache.values().any(|r| r.last_used_frame < keep_from);
190 if near_full && has_stale {
191 self.compact(keep_from);
192 }
193 }
194
195 pub fn size(&self) -> (u32, u32) {
197 (self.width, self.height)
198 }
199
200 pub fn is_dirty(&self) -> bool {
202 self.dirty
203 }
204
205 pub fn pixels(&self) -> &[u8] {
207 &self.pixels
208 }
209
210 pub fn mark_clean(&mut self) {
212 self.dirty = false;
213 }
214
215 #[allow(clippy::too_many_arguments)] pub fn lookup_or_rasterize(
233 &mut self,
234 path: &Path,
235 style: &StrokeStyle,
236 fill_rule: FillRule,
237 bounds: [f32; 4],
238 scale_factor: f32,
239 zoom: f32,
240 ) -> Option<AtlasRegion> {
241 let (geom_scale, stroke_scale) = if style.space == StrokeSpace::Device {
253 let mut g = scale_factor * zoom.max(1e-3);
254 let cap = MAX_COSMETIC_RASTER_DIM / bounds[2].max(bounds[3]).max(1.0);
256 if g > cap {
257 g = cap;
258 }
259 (g, scale_factor)
260 } else {
261 (scale_factor, scale_factor)
262 };
263
264 let raster_w = (bounds[2] * geom_scale).ceil() as u32;
265 let raster_h = (bounds[3] * geom_scale).ceil() as u32;
266 if raster_w == 0 || raster_h == 0 {
267 return None;
268 }
269
270 if raster_w > self.max_size || raster_h > self.max_size {
285 self.oversize_skips += 1;
286 return None;
287 }
288
289 let key = PathCacheKey::new(path, style, fill_rule, raster_w, raster_h);
290
291 if let Some(region) = self.cache.get_mut(&key) {
293 region.last_used_frame = self.current_frame;
294 return Some(*region);
295 }
296
297 let pixels = rasterize_path(path, style, fill_rule, bounds, geom_scale, stroke_scale)?;
300 let region = self.allocate_and_write(key, raster_w, raster_h, &pixels)?;
301 Some(region)
302 }
303
304 fn allocate_and_write(
320 &mut self,
321 key: PathCacheKey,
322 w: u32,
323 h: u32,
324 pixels: &[u8],
325 ) -> Option<AtlasRegion> {
326 if let Some(region) = self.try_allocate(w, h) {
327 self.blit(region.x, region.y, w, h, pixels);
328 self.cache.insert(key, region);
329 self.dirty = true;
330 return Some(region);
331 }
332
333 while self.try_grow() {
335 if let Some(region) = self.try_allocate(w, h) {
336 self.blit(region.x, region.y, w, h, pixels);
337 self.cache.insert(key, region);
338 self.dirty = true;
339 return Some(region);
340 }
341 }
342
343 self.evict_lru();
349 if let Some(region) = self.try_allocate(w, h) {
350 self.blit(region.x, region.y, w, h, pixels);
351 self.cache.insert(key, region);
352 self.dirty = true;
353 return Some(region);
354 }
355
356 None
357 }
358
359 fn try_allocate(&mut self, w: u32, h: u32) -> Option<AtlasRegion> {
361 if self.shelf_x + w <= self.width && self.shelf_y + h.max(self.shelf_height) <= self.height
363 {
364 let region = AtlasRegion {
365 x: self.shelf_x,
366 y: self.shelf_y,
367 w,
368 h,
369 last_used_frame: self.current_frame,
370 };
371 self.shelf_x += w;
372 self.shelf_height = self.shelf_height.max(h);
373 return Some(region);
374 }
375
376 let new_y = self.shelf_y + self.shelf_height;
378 if w <= self.width && new_y + h <= self.height {
379 self.shelf_y = new_y;
380 self.shelf_x = w;
381 self.shelf_height = h;
382 let region = AtlasRegion {
383 x: 0,
384 y: new_y,
385 w,
386 h,
387 last_used_frame: self.current_frame,
388 };
389 return Some(region);
390 }
391
392 None
393 }
394
395 fn evict_lru(&mut self) {
415 if self.cache.is_empty() {
416 return;
417 }
418
419 let current = self.current_frame;
420 let any_live = self.cache.values().any(|r| r.last_used_frame == current);
421 if any_live {
422 return;
424 }
425
426 self.cache.clear();
428 self.pixels.fill(0);
429 self.shelf_x = 0;
430 self.shelf_y = 0;
431 self.shelf_height = 0;
432 self.dirty = true;
433 }
434
435 fn compact(&mut self, keep_from_frame: u64) {
442 let mut survivors: Vec<(PathCacheKey, AtlasRegion, Vec<u8>)> = self
445 .cache
446 .iter()
447 .filter(|(_, r)| r.last_used_frame >= keep_from_frame)
448 .map(|(k, r)| (*k, *r, self.read_region(*r)))
449 .collect();
450
451 self.cache.clear();
452 self.pixels.fill(0);
453 self.shelf_x = 0;
454 self.shelf_y = 0;
455 self.shelf_height = 0;
456 self.dirty = true;
457
458 survivors.sort_by_key(|(_, r, _)| std::cmp::Reverse(r.h));
460 for (key, old_region, pixels) in survivors {
461 if let Some(new_region) = self.try_allocate(old_region.w, old_region.h) {
462 self.blit(
463 new_region.x,
464 new_region.y,
465 new_region.w,
466 new_region.h,
467 &pixels,
468 );
469 self.cache.insert(
470 key,
471 AtlasRegion {
472 x: new_region.x,
473 y: new_region.y,
474 w: new_region.w,
475 h: new_region.h,
476 last_used_frame: old_region.last_used_frame,
477 },
478 );
479 }
480 }
481 }
482
483 fn read_region(&self, region: AtlasRegion) -> Vec<u8> {
486 let mut out = vec![0u8; (region.w * region.h * 4) as usize];
487 for row in 0..region.h {
488 let src_start = ((region.y + row) * self.width * 4 + region.x * 4) as usize;
489 let src_end = src_start + (region.w * 4) as usize;
490 let dst_start = (row * region.w * 4) as usize;
491 let dst_end = dst_start + (region.w * 4) as usize;
492 if src_end <= self.pixels.len() && dst_end <= out.len() {
493 out[dst_start..dst_end].copy_from_slice(&self.pixels[src_start..src_end]);
494 }
495 }
496 out
497 }
498
499 fn try_grow(&mut self) -> bool {
501 let new_w = (self.width * 2).min(self.max_size);
502 let new_h = (self.height * 2).min(self.max_size);
503 if new_w == self.width && new_h == self.height {
504 return false; }
506 let mut new_pixels = vec![0u8; (new_w * new_h * 4) as usize];
507 for y in 0..self.height {
509 let src_start = (y * self.width * 4) as usize;
510 let src_end = src_start + (self.width * 4) as usize;
511 let dst_start = (y * new_w * 4) as usize;
512 new_pixels[dst_start..dst_start + (self.width * 4) as usize]
513 .copy_from_slice(&self.pixels[src_start..src_end]);
514 }
515 self.pixels = new_pixels;
516 self.width = new_w;
517 self.height = new_h;
518 self.dirty = true;
519 true
520 }
521
522 fn blit(&mut self, x: u32, y: u32, w: u32, h: u32, pixels: &[u8]) {
524 for row in 0..h {
525 let src_start = (row * w * 4) as usize;
526 let src_end = src_start + (w * 4) as usize;
527 let dst_start = ((y + row) * self.width * 4 + x * 4) as usize;
528 let dst_end = dst_start + (w * 4) as usize;
529 if src_end <= pixels.len() && dst_end <= self.pixels.len() {
530 self.pixels[dst_start..dst_end].copy_from_slice(&pixels[src_start..src_end]);
531 }
532 }
533 }
534}
535
536fn rasterize_path(
553 path: &Path,
554 style: &StrokeStyle,
555 fill_rule: FillRule,
556 bounds: [f32; 4],
557 geom_scale: f32,
558 stroke_scale: f32,
559) -> Option<Vec<u8>> {
560 let w = (bounds[2] * geom_scale).ceil() as u32;
561 let h = (bounds[3] * geom_scale).ceil() as u32;
562 if w == 0 || h == 0 {
563 return None;
564 }
565
566 let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
567
568 let mut pb = tiny_skia::PathBuilder::new();
570 for cmd in &path.commands {
571 match *cmd {
572 PathCommand::MoveTo(p) => {
573 pb.move_to(
574 (p.x - bounds[0]) * geom_scale,
575 (p.y - bounds[1]) * geom_scale,
576 );
577 }
578 PathCommand::LineTo(p) => {
579 pb.line_to(
580 (p.x - bounds[0]) * geom_scale,
581 (p.y - bounds[1]) * geom_scale,
582 );
583 }
584 PathCommand::QuadTo { control, to } => {
585 pb.quad_to(
586 (control.x - bounds[0]) * geom_scale,
587 (control.y - bounds[1]) * geom_scale,
588 (to.x - bounds[0]) * geom_scale,
589 (to.y - bounds[1]) * geom_scale,
590 );
591 }
592 PathCommand::CubicTo {
593 control1,
594 control2,
595 to,
596 } => {
597 pb.cubic_to(
598 (control1.x - bounds[0]) * geom_scale,
599 (control1.y - bounds[1]) * geom_scale,
600 (control2.x - bounds[0]) * geom_scale,
601 (control2.y - bounds[1]) * geom_scale,
602 (to.x - bounds[0]) * geom_scale,
603 (to.y - bounds[1]) * geom_scale,
604 );
605 }
606 PathCommand::ArcTo {
607 rect,
608 start_angle,
609 sweep_angle,
610 } => {
611 arc_to_cubics(
613 &mut pb,
614 rect.x - bounds[0],
615 rect.y - bounds[1],
616 rect.width,
617 rect.height,
618 start_angle,
619 sweep_angle,
620 geom_scale,
621 );
622 }
623 PathCommand::Close => {
624 pb.close();
625 }
626 }
627 }
628
629 let sk_path = pb.finish()?;
630
631 let paint = tiny_skia::Paint {
634 shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 1.0)?),
635 anti_alias: true,
636 ..Default::default()
637 };
638
639 if style.width > 0.0 {
640 let line_cap = match style.line_cap {
642 LineCap::Butt => tiny_skia::LineCap::Butt,
643 LineCap::Round => tiny_skia::LineCap::Round,
644 LineCap::Square => tiny_skia::LineCap::Square,
645 };
646 let line_join = match style.line_join {
647 LineJoin::Miter => tiny_skia::LineJoin::Miter,
648 LineJoin::Round => tiny_skia::LineJoin::Round,
649 LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
650 };
651 let dash = style
652 .dash_pattern
653 .as_ref()
654 .and_then(|pattern| tiny_skia::StrokeDash::new(pattern.clone(), style.dash_offset));
655 let stroke = tiny_skia::Stroke {
656 width: style.width * stroke_scale,
657 line_cap,
658 line_join,
659 miter_limit: style.miter_limit,
660 dash,
661 };
662 pixmap.stroke_path(
663 &sk_path,
664 &paint,
665 &stroke,
666 tiny_skia::Transform::identity(),
667 None,
668 );
669 } else {
670 let sk_rule = match fill_rule {
672 FillRule::Winding => tiny_skia::FillRule::Winding,
673 FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
674 };
675 pixmap.fill_path(
676 &sk_path,
677 &paint,
678 sk_rule,
679 tiny_skia::Transform::identity(),
680 None,
681 );
682 }
683
684 Some(pixmap.data().to_vec())
685}
686
687#[allow(clippy::too_many_arguments)]
695fn arc_to_cubics(
696 pb: &mut tiny_skia::PathBuilder,
697 cx: f32,
698 cy: f32,
699 w: f32,
700 h: f32,
701 start_angle: f32,
702 sweep_angle: f32,
703 scale_factor: f32,
704) {
705 let rx = w * 0.5;
706 let ry = h * 0.5;
707 let center_x = (cx + rx) * scale_factor;
708 let center_y = (cy + ry) * scale_factor;
709 let rx_s = rx * scale_factor;
710 let ry_s = ry * scale_factor;
711
712 let mut remaining = sweep_angle.to_radians();
713 let mut angle = start_angle.to_radians();
714 let sign = if remaining >= 0.0 { 1.0 } else { -1.0 };
715
716 while remaining.abs() > 0.001 {
717 let chunk = sign * remaining.abs().min(std::f32::consts::FRAC_PI_2);
718 let half = chunk * 0.5;
719 let k = (4.0 / 3.0) * (1.0 - half.cos()) / half.sin();
720
721 let cos_a = angle.cos();
722 let sin_a = angle.sin();
723 let cos_b = (angle + chunk).cos();
724 let sin_b = (angle + chunk).sin();
725
726 let p1x = center_x + rx_s * cos_a;
727 let p1y = center_y + ry_s * sin_a;
728 let p2x = center_x + rx_s * (cos_a - k * sin_a);
729 let p2y = center_y + ry_s * (sin_a + k * cos_a);
730 let p3x = center_x + rx_s * (cos_b + k * sin_b);
731 let p3y = center_y + ry_s * (sin_b - k * cos_b);
732 let p4x = center_x + rx_s * cos_b;
733 let p4y = center_y + ry_s * sin_b;
734
735 if (remaining - sweep_angle).abs() < 0.001 && pb.is_empty() {
736 pb.move_to(p1x, p1y);
741 } else {
742 pb.line_to(p1x, p1y);
746 }
747 pb.cubic_to(p2x, p2y, p3x, p3y, p4x, p4y);
748
749 angle += chunk;
750 remaining -= chunk;
751 }
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757 use teksilo_canvas::geometry::Point;
758
759 #[test]
771 fn a_path_too_big_for_the_atlas_is_never_rasterized() {
772 let mut atlas = PathAtlas::new(256, 256);
773
774 let (h, pitch) = (7563.0_f32, 10.0_f32);
776 let w = h + pitch;
777 let mut path = Path::new();
778 path.commands
779 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
780 path.commands
781 .push(PathCommand::LineTo(Point::new(pitch, 0.0)));
782 path.commands.push(PathCommand::LineTo(Point::new(w, h)));
783 path.commands.push(PathCommand::LineTo(Point::new(h, h)));
784 path.commands.push(PathCommand::Close);
785
786 let before = atlas.cache.len();
787 let region = atlas.lookup_or_rasterize(
788 &path,
789 &StrokeStyle::solid(0.0),
790 FillRule::Winding,
791 [0.0, 0.0, w, h],
792 1.0,
793 1.0,
794 );
795
796 assert!(
797 region.is_none(),
798 "a {w}x{h} path cannot fit an atlas capped at {} — it must be skipped, \
799 not rasterized into a 229 MB bitmap that is then thrown away",
800 atlas.max_size
801 );
802 assert_eq!(
803 atlas.cache.len(),
804 before,
805 "the rejected path must not leave a cache entry behind"
806 );
807 assert_eq!(
812 atlas.oversize_skips(),
813 1,
814 "the path must be rejected BEFORE rasterizing; without the early guard \
815 this call still returns None, but only after building and discarding a \
816 229 MB bitmap — every frame, forever"
817 );
818 }
819
820 #[test]
823 fn a_path_that_still_fits_the_atlas_is_rasterized() {
824 let mut atlas = PathAtlas::new(256, 256);
825 let side = atlas.max_size as f32; let mut path = Path::new();
828 path.commands
829 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
830 path.commands
831 .push(PathCommand::LineTo(Point::new(side, 0.0)));
832 path.commands
833 .push(PathCommand::LineTo(Point::new(side, side)));
834 path.commands
835 .push(PathCommand::LineTo(Point::new(0.0, side)));
836 path.commands.push(PathCommand::Close);
837
838 let region = atlas.lookup_or_rasterize(
839 &path,
840 &StrokeStyle::solid(0.0),
841 FillRule::Winding,
842 [0.0, 0.0, side, side],
843 1.0,
844 1.0,
845 );
846 assert!(
847 region.is_some(),
848 "a path exactly at max_size ({side}) must still be rasterized — the guard \
849 is for paths that can NEVER fit, not for merely large ones"
850 );
851 assert_eq!(
852 atlas.oversize_skips(),
853 0,
854 "the guard must not fire on a path that fits"
855 );
856 }
857
858 #[test]
859 fn rasterize_simple_rect_path() {
860 let mut path = Path::new();
861 path.commands
862 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
863 path.commands
864 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
865 path.commands
866 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
867 path.commands
868 .push(PathCommand::LineTo(Point::new(0.0, 10.0)));
869 path.commands.push(PathCommand::Close);
870
871 let style = StrokeStyle::solid(0.0);
872 let bounds = [0.0, 0.0, 10.0, 10.0];
873 let pixels = rasterize_path(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
874 assert!(pixels.is_some());
875 let px = pixels.unwrap();
876 assert_eq!(px.len(), 10 * 10 * 4);
877 let center = (5 * 10 + 5) * 4;
880 assert!(px[center] > 200); assert!(px[center + 1] > 200); assert!(px[center + 2] > 200); assert!(px[center + 3] > 200); }
885
886 #[test]
887 fn rasterize_stroke_path() {
888 let mut path = Path::new();
889 path.commands
890 .push(PathCommand::MoveTo(Point::new(1.0, 5.0)));
891 path.commands
892 .push(PathCommand::LineTo(Point::new(9.0, 5.0)));
893
894 let style = StrokeStyle::solid(2.0);
895 let bounds = [0.0, 0.0, 10.0, 10.0];
896 let pixels = rasterize_path(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
897 assert!(pixels.is_some());
898 }
899
900 #[test]
901 fn cache_key_distinguishes_line_join() {
902 let mut path = Path::new();
907 path.commands
908 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
909 path.commands
910 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
911 path.commands
912 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
913
914 let miter = StrokeStyle {
915 line_join: LineJoin::Miter,
916 ..StrokeStyle::solid(2.0)
917 };
918 let round = StrokeStyle {
919 line_join: LineJoin::Round,
920 ..StrokeStyle::solid(2.0)
921 };
922 assert_ne!(
923 PathCacheKey::new(&path, &miter, FillRule::Winding, 12, 12),
924 PathCacheKey::new(&path, &round, FillRule::Winding, 12, 12),
925 "miter and round joins must hash to different cache keys"
926 );
927 }
928
929 #[test]
930 fn cache_key_distinguishes_fill_rule() {
931 let mut path = Path::new();
934 path.commands
935 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
936 path.commands
937 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
938 path.commands
939 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
940 path.commands.push(PathCommand::Close);
941 let style = StrokeStyle::solid(0.0);
942 assert_ne!(
943 PathCacheKey::new(&path, &style, FillRule::Winding, 12, 12),
944 PathCacheKey::new(&path, &style, FillRule::EvenOdd, 12, 12),
945 "winding and even-odd fills must hash to different cache keys"
946 );
947 }
948
949 #[test]
950 fn atlas_cache_hit() {
951 let mut atlas = PathAtlas::new(256, 256);
952 atlas.begin_frame();
953
954 let mut path = Path::new();
955 path.commands
956 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
957 path.commands
958 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
959 path.commands
960 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
961 path.commands.push(PathCommand::Close);
962
963 let style = StrokeStyle::solid(0.0);
964 let bounds = [0.0, 0.0, 10.0, 10.0];
965
966 let r1 = atlas
967 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
968 .unwrap();
969 let r2 = atlas
970 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
971 .unwrap();
972
973 assert_eq!(r1.x, r2.x);
975 assert_eq!(r1.y, r2.y);
976 }
977
978 #[test]
979 fn cache_hit_is_independent_of_color() {
980 let mut atlas = PathAtlas::new(256, 256);
988 atlas.begin_frame();
989
990 let mut path = Path::new();
991 path.commands
992 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
993 path.commands
994 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
995 path.commands
996 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
997 path.commands.push(PathCommand::Close);
998
999 let style = StrokeStyle::solid(0.0);
1000 let bounds = [0.0, 0.0, 10.0, 10.0];
1001
1002 let r1 = atlas
1006 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
1007 .expect("first lookup rasterizes and caches");
1008 let r2 = atlas
1009 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
1010 .expect("second lookup hits the same cache entry");
1011
1012 assert_eq!(r1.x, r2.x, "cache hit: same region x");
1013 assert_eq!(r1.y, r2.y, "cache hit: same region y");
1014 assert_eq!(r1.w, r2.w);
1015 assert_eq!(r1.h, r2.h);
1016 assert_eq!(atlas.cache.len(), 1, "only one atlas entry for both calls");
1017 }
1018
1019 #[test]
1020 fn atlas_begin_frame_advances() {
1021 let mut atlas = PathAtlas::new(256, 256);
1022 assert_eq!(atlas.current_frame, 0);
1023 atlas.begin_frame();
1024 assert_eq!(atlas.current_frame, 1);
1025 atlas.begin_frame();
1026 assert_eq!(atlas.current_frame, 2);
1027 }
1028
1029 #[test]
1030 fn atlas_eviction_clears_stale() {
1031 let mut atlas = PathAtlas::new(64, 64);
1032
1033 let mut path = Path::new();
1034 path.commands
1035 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1036 path.commands
1037 .push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1038 path.commands
1039 .push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1040 path.commands.push(PathCommand::Close);
1041 let style = StrokeStyle::solid(0.0);
1042 let bounds = [0.0, 0.0, 8.0, 8.0];
1043
1044 atlas.begin_frame(); atlas.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
1046
1047 atlas.begin_frame(); atlas.begin_frame(); atlas.begin_frame(); atlas.evict_lru();
1054 assert!(atlas.cache.is_empty());
1055 }
1056
1057 #[test]
1058 fn evict_preserves_current_frame_entries() {
1059 let mut atlas = PathAtlas::new(64, 64);
1065 atlas.begin_frame();
1066
1067 let mut p1 = Path::new();
1068 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1069 p1.commands.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1070 p1.commands
1071 .push(PathCommand::LineTo(Point::new(40.0, 40.0)));
1072 p1.commands.push(PathCommand::Close);
1073
1074 let mut p2 = Path::new();
1075 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1076 p2.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1077 p2.commands
1078 .push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1079 p2.commands.push(PathCommand::Close);
1080
1081 let style = StrokeStyle::solid(0.0);
1082 let r1 = atlas
1083 .lookup_or_rasterize(
1084 &p1,
1085 &style,
1086 FillRule::Winding,
1087 [0.0, 0.0, 40.0, 40.0],
1088 1.0,
1089 1.0,
1090 )
1091 .expect("p1 fits");
1092
1093 let _r2 = atlas.lookup_or_rasterize(
1096 &p2,
1097 &style,
1098 FillRule::Winding,
1099 [0.0, 0.0, 50.0, 50.0],
1100 1.0,
1101 1.0,
1102 );
1103
1104 let r1b = atlas
1107 .lookup_or_rasterize(
1108 &p1,
1109 &style,
1110 FillRule::Winding,
1111 [0.0, 0.0, 40.0, 40.0],
1112 1.0,
1113 1.0,
1114 )
1115 .expect("p1 still cached after eviction");
1116 let _ = (r1, r1b);
1119 assert!(atlas.cache.contains_key(&PathCacheKey::new(
1120 &p1,
1121 &style,
1122 FillRule::Winding,
1123 40,
1124 40,
1125 )));
1126 }
1127
1128 #[test]
1129 fn evict_never_moves_live_entry_when_full() {
1130 let mut atlas = PathAtlas::new(64, 64);
1136 atlas.max_size = 64; atlas.begin_frame();
1138
1139 let mut p1 = Path::new();
1140 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1141 p1.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1142 p1.commands
1143 .push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1144 p1.commands.push(PathCommand::Close);
1145
1146 let mut p2 = Path::new();
1147 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1148 p2.commands.push(PathCommand::LineTo(Point::new(62.0, 0.0)));
1149 p2.commands
1150 .push(PathCommand::LineTo(Point::new(62.0, 62.0)));
1151 p2.commands.push(PathCommand::Close);
1152
1153 let style = StrokeStyle::solid(0.0);
1154 let r1 = atlas
1155 .lookup_or_rasterize(
1156 &p1,
1157 &style,
1158 FillRule::Winding,
1159 [0.0, 0.0, 60.0, 60.0],
1160 1.0,
1161 1.0,
1162 )
1163 .expect("p1 fits");
1164
1165 let r2 = atlas.lookup_or_rasterize(
1167 &p2,
1168 &style,
1169 FillRule::Winding,
1170 [0.0, 0.0, 62.0, 62.0],
1171 1.0,
1172 1.0,
1173 );
1174 assert!(
1175 r2.is_none(),
1176 "an unfittable path is skipped, never placed by evicting a live entry"
1177 );
1178
1179 let r1b = atlas
1181 .lookup_or_rasterize(
1182 &p1,
1183 &style,
1184 FillRule::Winding,
1185 [0.0, 0.0, 60.0, 60.0],
1186 1.0,
1187 1.0,
1188 )
1189 .expect("p1 still cached");
1190 assert_eq!(r1.x, r1b.x, "live entry must not move");
1191 assert_eq!(r1.y, r1b.y, "live entry must not move");
1192 }
1193
1194 #[test]
1195 fn begin_frame_compacts_stale_entries() {
1196 let mut atlas = PathAtlas::new(64, 64);
1200 atlas.begin_frame(); let mut path = Path::new();
1203 path.commands
1204 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1205 path.commands
1206 .push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1207 path.commands
1208 .push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1209 path.commands.push(PathCommand::Close);
1210 let style = StrokeStyle::solid(0.0);
1211 atlas
1212 .lookup_or_rasterize(
1213 &path,
1214 &style,
1215 FillRule::Winding,
1216 [0.0, 0.0, 8.0, 8.0],
1217 1.0,
1218 1.0,
1219 )
1220 .expect("entry fits");
1221 assert_eq!(atlas.cache.len(), 1);
1222
1223 atlas.begin_frame(); assert_eq!(
1225 atlas.cache.len(),
1226 1,
1227 "entry from the last completed frame is kept"
1228 );
1229
1230 atlas.begin_frame(); assert!(
1232 atlas.cache.is_empty(),
1233 "stale entry compacted away on begin_frame"
1234 );
1235 }
1236
1237 #[test]
1238 fn atlas_grow() {
1239 let mut atlas = PathAtlas::new(16, 16);
1240 assert!(atlas.try_grow());
1241 assert_eq!(atlas.width, 32);
1242 assert_eq!(atlas.height, 32);
1243 }
1244
1245 #[test]
1246 fn growth_preserves_earlier_frame_regions() {
1247 let mut atlas = PathAtlas::new(64, 64);
1254 atlas.begin_frame();
1255
1256 let mut p1 = Path::new();
1257 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1258 p1.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1259 p1.commands
1260 .push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1261 p1.commands.push(PathCommand::Close);
1262
1263 let mut p2 = Path::new();
1264 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1265 p2.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1266 p2.commands
1267 .push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1268 p2.commands.push(PathCommand::Close);
1269
1270 let style = StrokeStyle::solid(0.0);
1271 let r1 = atlas
1272 .lookup_or_rasterize(
1273 &p1,
1274 &style,
1275 FillRule::Winding,
1276 [0.0, 0.0, 50.0, 50.0],
1277 1.0,
1278 1.0,
1279 )
1280 .expect("p1 fits");
1281
1282 let _r2 = atlas
1286 .lookup_or_rasterize(
1287 &p2,
1288 &style,
1289 FillRule::Winding,
1290 [0.0, 0.0, 60.0, 60.0],
1291 1.0,
1292 1.0,
1293 )
1294 .expect("p2 fits after grow");
1295
1296 let r1_after = atlas
1297 .lookup_or_rasterize(
1298 &p1,
1299 &style,
1300 FillRule::Winding,
1301 [0.0, 0.0, 50.0, 50.0],
1302 1.0,
1303 1.0,
1304 )
1305 .expect("p1 still cached");
1306 assert_eq!(r1.x, r1_after.x, "p1 must not move when atlas grows");
1307 assert_eq!(r1.y, r1_after.y, "p1 must not move when atlas grows");
1308 }
1309
1310 #[test]
1311 fn cosmetic_path_raster_is_zoom_aware_logical_is_not() {
1312 let mut atlas = PathAtlas::new(512, 512);
1318 atlas.begin_frame();
1319 let mut path = Path::new();
1320 path.commands
1321 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1322 path.commands
1323 .push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1324 let bounds = [0.0, 0.0, 40.0, 4.0];
1325
1326 let cosmetic = StrokeStyle::hairline(2.0);
1327 let r1 = atlas
1328 .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 1.0)
1329 .unwrap();
1330 let r2 = atlas
1331 .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 2.0)
1332 .unwrap();
1333 assert_eq!(r1.w, 40, "cosmetic body at zoom 1: 40·sf1·zoom1");
1334 assert_eq!(
1335 r2.w, 80,
1336 "cosmetic body at zoom 2: 40·sf1·zoom2 (zoom-aware)"
1337 );
1338
1339 let logical = StrokeStyle::solid(2.0);
1340 let l1 = atlas
1341 .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 1.0)
1342 .unwrap();
1343 let l2 = atlas
1344 .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 4.0)
1345 .unwrap();
1346 assert_eq!(l1.w, l2.w, "logical raster size ignores zoom");
1347 assert_eq!(
1348 (l1.x, l1.y),
1349 (l2.x, l2.y),
1350 "logical hits the same cache entry"
1351 );
1352
1353 let k_cos = PathCacheKey::new(&path, &cosmetic, FillRule::Winding, 40, 4);
1355 let k_log = PathCacheKey::new(&path, &logical, FillRule::Winding, 40, 4);
1356 assert_ne!(
1357 k_cos, k_log,
1358 "cache key must distinguish cosmetic vs logical"
1359 );
1360 }
1361}