use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use teksilo_canvas::paint::{FillRule, LineCap, LineJoin, StrokeSpace, StrokeStyle};
use teksilo_canvas::path::{Path, PathCommand};
const MAX_COSMETIC_RASTER_DIM: f32 = 2048.0;
const COMPACT_SLACK_PX: u32 = 256;
#[derive(Debug, Clone, Copy)]
pub struct AtlasRegion {
pub x: u32,
pub y: u32,
pub w: u32,
pub h: u32,
last_used_frame: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct PathCacheKey(u64);
impl PathCacheKey {
fn new(path: &Path, style: &StrokeStyle, fill_rule: FillRule, w: u32, h: u32) -> Self {
let mut hasher = std::hash::DefaultHasher::new();
for cmd in &path.commands {
std::mem::discriminant(cmd).hash(&mut hasher);
match cmd {
PathCommand::MoveTo(p) | PathCommand::LineTo(p) => {
p.x.to_bits().hash(&mut hasher);
p.y.to_bits().hash(&mut hasher);
}
PathCommand::QuadTo { control, to } => {
control.x.to_bits().hash(&mut hasher);
control.y.to_bits().hash(&mut hasher);
to.x.to_bits().hash(&mut hasher);
to.y.to_bits().hash(&mut hasher);
}
PathCommand::CubicTo {
control1,
control2,
to,
} => {
control1.x.to_bits().hash(&mut hasher);
control1.y.to_bits().hash(&mut hasher);
control2.x.to_bits().hash(&mut hasher);
control2.y.to_bits().hash(&mut hasher);
to.x.to_bits().hash(&mut hasher);
to.y.to_bits().hash(&mut hasher);
}
PathCommand::ArcTo {
rect,
start_angle,
sweep_angle,
} => {
rect.x.to_bits().hash(&mut hasher);
rect.y.to_bits().hash(&mut hasher);
rect.width.to_bits().hash(&mut hasher);
rect.height.to_bits().hash(&mut hasher);
start_angle.to_bits().hash(&mut hasher);
sweep_angle.to_bits().hash(&mut hasher);
}
PathCommand::Close => {}
}
}
style.width.to_bits().hash(&mut hasher);
std::mem::discriminant(&style.line_cap).hash(&mut hasher);
std::mem::discriminant(&style.line_join).hash(&mut hasher);
if let Some(ref pattern) = style.dash_pattern {
for &v in pattern {
v.to_bits().hash(&mut hasher);
}
}
style.dash_offset.to_bits().hash(&mut hasher);
style.miter_limit.to_bits().hash(&mut hasher);
std::mem::discriminant(&style.space).hash(&mut hasher);
std::mem::discriminant(&fill_rule).hash(&mut hasher);
w.hash(&mut hasher);
h.hash(&mut hasher);
PathCacheKey(hasher.finish())
}
}
pub struct PathAtlas {
pixels: Vec<u8>,
width: u32,
height: u32,
max_size: u32,
cache: HashMap<PathCacheKey, AtlasRegion>,
current_frame: u64,
dirty: bool,
shelf_y: u32,
shelf_x: u32,
shelf_height: u32,
oversize_skips: u64,
}
impl PathAtlas {
pub fn new(width: u32, height: u32) -> Self {
Self {
pixels: vec![0; (width * height * 4) as usize],
width,
height,
max_size: 4096,
cache: HashMap::new(),
current_frame: 0,
dirty: false,
shelf_y: 0,
shelf_x: 0,
shelf_height: 0,
oversize_skips: 0,
}
}
pub fn oversize_skips(&self) -> u64 {
self.oversize_skips
}
pub fn begin_frame(&mut self) {
self.current_frame += 1;
let keep_from = self.current_frame - 1;
let near_full =
self.shelf_y.saturating_add(self.shelf_height) + COMPACT_SLACK_PX >= self.height;
let has_stale = self.cache.values().any(|r| r.last_used_frame < keep_from);
if near_full && has_stale {
self.compact(keep_from);
}
}
pub fn size(&self) -> (u32, u32) {
(self.width, self.height)
}
pub fn is_dirty(&self) -> bool {
self.dirty
}
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
pub fn mark_clean(&mut self) {
self.dirty = false;
}
#[allow(clippy::too_many_arguments)] pub fn lookup_or_rasterize(
&mut self,
path: &Path,
style: &StrokeStyle,
fill_rule: FillRule,
bounds: [f32; 4],
scale_factor: f32,
zoom: f32,
) -> Option<AtlasRegion> {
let (geom_scale, stroke_scale) = if style.space == StrokeSpace::Device {
let mut g = scale_factor * zoom.max(1e-3);
let cap = MAX_COSMETIC_RASTER_DIM / bounds[2].max(bounds[3]).max(1.0);
if g > cap {
g = cap;
}
(g, scale_factor)
} else {
(scale_factor, scale_factor)
};
let raster_w = (bounds[2] * geom_scale).ceil() as u32;
let raster_h = (bounds[3] * geom_scale).ceil() as u32;
if raster_w == 0 || raster_h == 0 {
return None;
}
if raster_w > self.max_size || raster_h > self.max_size {
self.oversize_skips += 1;
return None;
}
let key = PathCacheKey::new(path, style, fill_rule, raster_w, raster_h);
if let Some(region) = self.cache.get_mut(&key) {
region.last_used_frame = self.current_frame;
return Some(*region);
}
let pixels = rasterize_path(path, style, fill_rule, bounds, geom_scale, stroke_scale)?;
let region = self.allocate_and_write(key, raster_w, raster_h, &pixels)?;
Some(region)
}
fn allocate_and_write(
&mut self,
key: PathCacheKey,
w: u32,
h: u32,
pixels: &[u8],
) -> Option<AtlasRegion> {
if let Some(region) = self.try_allocate(w, h) {
self.blit(region.x, region.y, w, h, pixels);
self.cache.insert(key, region);
self.dirty = true;
return Some(region);
}
while self.try_grow() {
if let Some(region) = self.try_allocate(w, h) {
self.blit(region.x, region.y, w, h, pixels);
self.cache.insert(key, region);
self.dirty = true;
return Some(region);
}
}
self.evict_lru();
if let Some(region) = self.try_allocate(w, h) {
self.blit(region.x, region.y, w, h, pixels);
self.cache.insert(key, region);
self.dirty = true;
return Some(region);
}
None
}
fn try_allocate(&mut self, w: u32, h: u32) -> Option<AtlasRegion> {
if self.shelf_x + w <= self.width && self.shelf_y + h.max(self.shelf_height) <= self.height
{
let region = AtlasRegion {
x: self.shelf_x,
y: self.shelf_y,
w,
h,
last_used_frame: self.current_frame,
};
self.shelf_x += w;
self.shelf_height = self.shelf_height.max(h);
return Some(region);
}
let new_y = self.shelf_y + self.shelf_height;
if w <= self.width && new_y + h <= self.height {
self.shelf_y = new_y;
self.shelf_x = w;
self.shelf_height = h;
let region = AtlasRegion {
x: 0,
y: new_y,
w,
h,
last_used_frame: self.current_frame,
};
return Some(region);
}
None
}
fn evict_lru(&mut self) {
if self.cache.is_empty() {
return;
}
let current = self.current_frame;
let any_live = self.cache.values().any(|r| r.last_used_frame == current);
if any_live {
return;
}
self.cache.clear();
self.pixels.fill(0);
self.shelf_x = 0;
self.shelf_y = 0;
self.shelf_height = 0;
self.dirty = true;
}
fn compact(&mut self, keep_from_frame: u64) {
let mut survivors: Vec<(PathCacheKey, AtlasRegion, Vec<u8>)> = self
.cache
.iter()
.filter(|(_, r)| r.last_used_frame >= keep_from_frame)
.map(|(k, r)| (*k, *r, self.read_region(*r)))
.collect();
self.cache.clear();
self.pixels.fill(0);
self.shelf_x = 0;
self.shelf_y = 0;
self.shelf_height = 0;
self.dirty = true;
survivors.sort_by_key(|(_, r, _)| std::cmp::Reverse(r.h));
for (key, old_region, pixels) in survivors {
if let Some(new_region) = self.try_allocate(old_region.w, old_region.h) {
self.blit(
new_region.x,
new_region.y,
new_region.w,
new_region.h,
&pixels,
);
self.cache.insert(
key,
AtlasRegion {
x: new_region.x,
y: new_region.y,
w: new_region.w,
h: new_region.h,
last_used_frame: old_region.last_used_frame,
},
);
}
}
}
fn read_region(&self, region: AtlasRegion) -> Vec<u8> {
let mut out = vec![0u8; (region.w * region.h * 4) as usize];
for row in 0..region.h {
let src_start = ((region.y + row) * self.width * 4 + region.x * 4) as usize;
let src_end = src_start + (region.w * 4) as usize;
let dst_start = (row * region.w * 4) as usize;
let dst_end = dst_start + (region.w * 4) as usize;
if src_end <= self.pixels.len() && dst_end <= out.len() {
out[dst_start..dst_end].copy_from_slice(&self.pixels[src_start..src_end]);
}
}
out
}
fn try_grow(&mut self) -> bool {
let new_w = (self.width * 2).min(self.max_size);
let new_h = (self.height * 2).min(self.max_size);
if new_w == self.width && new_h == self.height {
return false; }
let mut new_pixels = vec![0u8; (new_w * new_h * 4) as usize];
for y in 0..self.height {
let src_start = (y * self.width * 4) as usize;
let src_end = src_start + (self.width * 4) as usize;
let dst_start = (y * new_w * 4) as usize;
new_pixels[dst_start..dst_start + (self.width * 4) as usize]
.copy_from_slice(&self.pixels[src_start..src_end]);
}
self.pixels = new_pixels;
self.width = new_w;
self.height = new_h;
self.dirty = true;
true
}
fn blit(&mut self, x: u32, y: u32, w: u32, h: u32, pixels: &[u8]) {
for row in 0..h {
let src_start = (row * w * 4) as usize;
let src_end = src_start + (w * 4) as usize;
let dst_start = ((y + row) * self.width * 4 + x * 4) as usize;
let dst_end = dst_start + (w * 4) as usize;
if src_end <= pixels.len() && dst_end <= self.pixels.len() {
self.pixels[dst_start..dst_end].copy_from_slice(&pixels[src_start..src_end]);
}
}
}
}
fn rasterize_path(
path: &Path,
style: &StrokeStyle,
fill_rule: FillRule,
bounds: [f32; 4],
geom_scale: f32,
stroke_scale: f32,
) -> Option<Vec<u8>> {
let w = (bounds[2] * geom_scale).ceil() as u32;
let h = (bounds[3] * geom_scale).ceil() as u32;
if w == 0 || h == 0 {
return None;
}
let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
let mut pb = tiny_skia::PathBuilder::new();
for cmd in &path.commands {
match *cmd {
PathCommand::MoveTo(p) => {
pb.move_to(
(p.x - bounds[0]) * geom_scale,
(p.y - bounds[1]) * geom_scale,
);
}
PathCommand::LineTo(p) => {
pb.line_to(
(p.x - bounds[0]) * geom_scale,
(p.y - bounds[1]) * geom_scale,
);
}
PathCommand::QuadTo { control, to } => {
pb.quad_to(
(control.x - bounds[0]) * geom_scale,
(control.y - bounds[1]) * geom_scale,
(to.x - bounds[0]) * geom_scale,
(to.y - bounds[1]) * geom_scale,
);
}
PathCommand::CubicTo {
control1,
control2,
to,
} => {
pb.cubic_to(
(control1.x - bounds[0]) * geom_scale,
(control1.y - bounds[1]) * geom_scale,
(control2.x - bounds[0]) * geom_scale,
(control2.y - bounds[1]) * geom_scale,
(to.x - bounds[0]) * geom_scale,
(to.y - bounds[1]) * geom_scale,
);
}
PathCommand::ArcTo {
rect,
start_angle,
sweep_angle,
} => {
arc_to_cubics(
&mut pb,
rect.x - bounds[0],
rect.y - bounds[1],
rect.width,
rect.height,
start_angle,
sweep_angle,
geom_scale,
);
}
PathCommand::Close => {
pb.close();
}
}
}
let sk_path = pb.finish()?;
let paint = tiny_skia::Paint {
shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 1.0)?),
anti_alias: true,
..Default::default()
};
if style.width > 0.0 {
let line_cap = match style.line_cap {
LineCap::Butt => tiny_skia::LineCap::Butt,
LineCap::Round => tiny_skia::LineCap::Round,
LineCap::Square => tiny_skia::LineCap::Square,
};
let line_join = match style.line_join {
LineJoin::Miter => tiny_skia::LineJoin::Miter,
LineJoin::Round => tiny_skia::LineJoin::Round,
LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
};
let dash = style
.dash_pattern
.as_ref()
.and_then(|pattern| tiny_skia::StrokeDash::new(pattern.clone(), style.dash_offset));
let stroke = tiny_skia::Stroke {
width: style.width * stroke_scale,
line_cap,
line_join,
miter_limit: style.miter_limit,
dash,
};
pixmap.stroke_path(
&sk_path,
&paint,
&stroke,
tiny_skia::Transform::identity(),
None,
);
} else {
let sk_rule = match fill_rule {
FillRule::Winding => tiny_skia::FillRule::Winding,
FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
};
pixmap.fill_path(
&sk_path,
&paint,
sk_rule,
tiny_skia::Transform::identity(),
None,
);
}
Some(pixmap.data().to_vec())
}
#[allow(clippy::too_many_arguments)]
fn arc_to_cubics(
pb: &mut tiny_skia::PathBuilder,
cx: f32,
cy: f32,
w: f32,
h: f32,
start_angle: f32,
sweep_angle: f32,
scale_factor: f32,
) {
let rx = w * 0.5;
let ry = h * 0.5;
let center_x = (cx + rx) * scale_factor;
let center_y = (cy + ry) * scale_factor;
let rx_s = rx * scale_factor;
let ry_s = ry * scale_factor;
let mut remaining = sweep_angle.to_radians();
let mut angle = start_angle.to_radians();
let sign = if remaining >= 0.0 { 1.0 } else { -1.0 };
while remaining.abs() > 0.001 {
let chunk = sign * remaining.abs().min(std::f32::consts::FRAC_PI_2);
let half = chunk * 0.5;
let k = (4.0 / 3.0) * (1.0 - half.cos()) / half.sin();
let cos_a = angle.cos();
let sin_a = angle.sin();
let cos_b = (angle + chunk).cos();
let sin_b = (angle + chunk).sin();
let p1x = center_x + rx_s * cos_a;
let p1y = center_y + ry_s * sin_a;
let p2x = center_x + rx_s * (cos_a - k * sin_a);
let p2y = center_y + ry_s * (sin_a + k * cos_a);
let p3x = center_x + rx_s * (cos_b + k * sin_b);
let p3y = center_y + ry_s * (sin_b - k * cos_b);
let p4x = center_x + rx_s * cos_b;
let p4y = center_y + ry_s * sin_b;
if (remaining - sweep_angle).abs() < 0.001 && pb.is_empty() {
pb.move_to(p1x, p1y);
} else {
pb.line_to(p1x, p1y);
}
pb.cubic_to(p2x, p2y, p3x, p3y, p4x, p4y);
angle += chunk;
remaining -= chunk;
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_canvas::geometry::Point;
#[test]
fn a_path_too_big_for_the_atlas_is_never_rasterized() {
let mut atlas = PathAtlas::new(256, 256);
let (h, pitch) = (7563.0_f32, 10.0_f32);
let w = h + pitch;
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(pitch, 0.0)));
path.commands.push(PathCommand::LineTo(Point::new(w, h)));
path.commands.push(PathCommand::LineTo(Point::new(h, h)));
path.commands.push(PathCommand::Close);
let before = atlas.cache.len();
let region = atlas.lookup_or_rasterize(
&path,
&StrokeStyle::solid(0.0),
FillRule::Winding,
[0.0, 0.0, w, h],
1.0,
1.0,
);
assert!(
region.is_none(),
"a {w}x{h} path cannot fit an atlas capped at {} — it must be skipped, \
not rasterized into a 229 MB bitmap that is then thrown away",
atlas.max_size
);
assert_eq!(
atlas.cache.len(),
before,
"the rejected path must not leave a cache entry behind"
);
assert_eq!(
atlas.oversize_skips(),
1,
"the path must be rejected BEFORE rasterizing; without the early guard \
this call still returns None, but only after building and discarding a \
229 MB bitmap — every frame, forever"
);
}
#[test]
fn a_path_that_still_fits_the_atlas_is_rasterized() {
let mut atlas = PathAtlas::new(256, 256);
let side = atlas.max_size as f32;
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(side, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(side, side)));
path.commands
.push(PathCommand::LineTo(Point::new(0.0, side)));
path.commands.push(PathCommand::Close);
let region = atlas.lookup_or_rasterize(
&path,
&StrokeStyle::solid(0.0),
FillRule::Winding,
[0.0, 0.0, side, side],
1.0,
1.0,
);
assert!(
region.is_some(),
"a path exactly at max_size ({side}) must still be rasterized — the guard \
is for paths that can NEVER fit, not for merely large ones"
);
assert_eq!(
atlas.oversize_skips(),
0,
"the guard must not fire on a path that fits"
);
}
#[test]
fn rasterize_simple_rect_path() {
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.commands
.push(PathCommand::LineTo(Point::new(0.0, 10.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let bounds = [0.0, 0.0, 10.0, 10.0];
let pixels = rasterize_path(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
assert!(pixels.is_some());
let px = pixels.unwrap();
assert_eq!(px.len(), 10 * 10 * 4);
let center = (5 * 10 + 5) * 4;
assert!(px[center] > 200); assert!(px[center + 1] > 200); assert!(px[center + 2] > 200); assert!(px[center + 3] > 200); }
#[test]
fn rasterize_stroke_path() {
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(1.0, 5.0)));
path.commands
.push(PathCommand::LineTo(Point::new(9.0, 5.0)));
let style = StrokeStyle::solid(2.0);
let bounds = [0.0, 0.0, 10.0, 10.0];
let pixels = rasterize_path(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
assert!(pixels.is_some());
}
#[test]
fn cache_key_distinguishes_line_join() {
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
let miter = StrokeStyle {
line_join: LineJoin::Miter,
..StrokeStyle::solid(2.0)
};
let round = StrokeStyle {
line_join: LineJoin::Round,
..StrokeStyle::solid(2.0)
};
assert_ne!(
PathCacheKey::new(&path, &miter, FillRule::Winding, 12, 12),
PathCacheKey::new(&path, &round, FillRule::Winding, 12, 12),
"miter and round joins must hash to different cache keys"
);
}
#[test]
fn cache_key_distinguishes_fill_rule() {
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
assert_ne!(
PathCacheKey::new(&path, &style, FillRule::Winding, 12, 12),
PathCacheKey::new(&path, &style, FillRule::EvenOdd, 12, 12),
"winding and even-odd fills must hash to different cache keys"
);
}
#[test]
fn atlas_cache_hit() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let bounds = [0.0, 0.0, 10.0, 10.0];
let r1 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
.unwrap();
let r2 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
.unwrap();
assert_eq!(r1.x, r2.x);
assert_eq!(r1.y, r2.y);
}
#[test]
fn cache_hit_is_independent_of_color() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let bounds = [0.0, 0.0, 10.0, 10.0];
let r1 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
.expect("first lookup rasterizes and caches");
let r2 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0)
.expect("second lookup hits the same cache entry");
assert_eq!(r1.x, r2.x, "cache hit: same region x");
assert_eq!(r1.y, r2.y, "cache hit: same region y");
assert_eq!(r1.w, r2.w);
assert_eq!(r1.h, r2.h);
assert_eq!(atlas.cache.len(), 1, "only one atlas entry for both calls");
}
#[test]
fn atlas_begin_frame_advances() {
let mut atlas = PathAtlas::new(256, 256);
assert_eq!(atlas.current_frame, 0);
atlas.begin_frame();
assert_eq!(atlas.current_frame, 1);
atlas.begin_frame();
assert_eq!(atlas.current_frame, 2);
}
#[test]
fn atlas_eviction_clears_stale() {
let mut atlas = PathAtlas::new(64, 64);
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let bounds = [0.0, 0.0, 8.0, 8.0];
atlas.begin_frame(); atlas.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0);
atlas.begin_frame(); atlas.begin_frame(); atlas.begin_frame();
atlas.evict_lru();
assert!(atlas.cache.is_empty());
}
#[test]
fn evict_preserves_current_frame_entries() {
let mut atlas = PathAtlas::new(64, 64);
atlas.begin_frame();
let mut p1 = Path::new();
p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.commands.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
p1.commands
.push(PathCommand::LineTo(Point::new(40.0, 40.0)));
p1.commands.push(PathCommand::Close);
let mut p2 = Path::new();
p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
p2.commands
.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
p2.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let r1 = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 40.0, 40.0],
1.0,
1.0,
)
.expect("p1 fits");
let _r2 = atlas.lookup_or_rasterize(
&p2,
&style,
FillRule::Winding,
[0.0, 0.0, 50.0, 50.0],
1.0,
1.0,
);
let r1b = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 40.0, 40.0],
1.0,
1.0,
)
.expect("p1 still cached after eviction");
let _ = (r1, r1b);
assert!(atlas.cache.contains_key(&PathCacheKey::new(
&p1,
&style,
FillRule::Winding,
40,
40,
)));
}
#[test]
fn evict_never_moves_live_entry_when_full() {
let mut atlas = PathAtlas::new(64, 64);
atlas.max_size = 64; atlas.begin_frame();
let mut p1 = Path::new();
p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
p1.commands
.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
p1.commands.push(PathCommand::Close);
let mut p2 = Path::new();
p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.commands.push(PathCommand::LineTo(Point::new(62.0, 0.0)));
p2.commands
.push(PathCommand::LineTo(Point::new(62.0, 62.0)));
p2.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let r1 = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 60.0, 60.0],
1.0,
1.0,
)
.expect("p1 fits");
let r2 = atlas.lookup_or_rasterize(
&p2,
&style,
FillRule::Winding,
[0.0, 0.0, 62.0, 62.0],
1.0,
1.0,
);
assert!(
r2.is_none(),
"an unfittable path is skipped, never placed by evicting a live entry"
);
let r1b = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 60.0, 60.0],
1.0,
1.0,
)
.expect("p1 still cached");
assert_eq!(r1.x, r1b.x, "live entry must not move");
assert_eq!(r1.y, r1b.y, "live entry must not move");
}
#[test]
fn begin_frame_compacts_stale_entries() {
let mut atlas = PathAtlas::new(64, 64);
atlas.begin_frame();
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
path.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
atlas
.lookup_or_rasterize(
&path,
&style,
FillRule::Winding,
[0.0, 0.0, 8.0, 8.0],
1.0,
1.0,
)
.expect("entry fits");
assert_eq!(atlas.cache.len(), 1);
atlas.begin_frame(); assert_eq!(
atlas.cache.len(),
1,
"entry from the last completed frame is kept"
);
atlas.begin_frame(); assert!(
atlas.cache.is_empty(),
"stale entry compacted away on begin_frame"
);
}
#[test]
fn atlas_grow() {
let mut atlas = PathAtlas::new(16, 16);
assert!(atlas.try_grow());
assert_eq!(atlas.width, 32);
assert_eq!(atlas.height, 32);
}
#[test]
fn growth_preserves_earlier_frame_regions() {
let mut atlas = PathAtlas::new(64, 64);
atlas.begin_frame();
let mut p1 = Path::new();
p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
p1.commands
.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
p1.commands.push(PathCommand::Close);
let mut p2 = Path::new();
p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
p2.commands
.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
p2.commands.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let r1 = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 50.0, 50.0],
1.0,
1.0,
)
.expect("p1 fits");
let _r2 = atlas
.lookup_or_rasterize(
&p2,
&style,
FillRule::Winding,
[0.0, 0.0, 60.0, 60.0],
1.0,
1.0,
)
.expect("p2 fits after grow");
let r1_after = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 50.0, 50.0],
1.0,
1.0,
)
.expect("p1 still cached");
assert_eq!(r1.x, r1_after.x, "p1 must not move when atlas grows");
assert_eq!(r1.y, r1_after.y, "p1 must not move when atlas grows");
}
#[test]
fn cosmetic_path_raster_is_zoom_aware_logical_is_not() {
let mut atlas = PathAtlas::new(512, 512);
atlas.begin_frame();
let mut path = Path::new();
path.commands
.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.commands
.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
let bounds = [0.0, 0.0, 40.0, 4.0];
let cosmetic = StrokeStyle::hairline(2.0);
let r1 = atlas
.lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 1.0)
.unwrap();
let r2 = atlas
.lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 2.0)
.unwrap();
assert_eq!(r1.w, 40, "cosmetic body at zoom 1: 40·sf1·zoom1");
assert_eq!(
r2.w, 80,
"cosmetic body at zoom 2: 40·sf1·zoom2 (zoom-aware)"
);
let logical = StrokeStyle::solid(2.0);
let l1 = atlas
.lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 1.0)
.unwrap();
let l2 = atlas
.lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 4.0)
.unwrap();
assert_eq!(l1.w, l2.w, "logical raster size ignores zoom");
assert_eq!(
(l1.x, l1.y),
(l2.x, l2.y),
"logical hits the same cache entry"
);
let k_cos = PathCacheKey::new(&path, &cosmetic, FillRule::Winding, 40, 4);
let k_log = PathCacheKey::new(&path, &logical, FillRule::Winding, 40, 4);
assert_ne!(
k_cos, k_log,
"cache key must distinguish cosmetic vs logical"
);
}
}