use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use teksilo_canvas::geometry::{Point, Rect};
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;
const ENTRY_GUTTER_PX: u32 = 1;
#[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)]
pub struct PathPlacement {
pub region: AtlasRegion,
pub device_rect: [f32; 4],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct PathCacheKey(u64);
impl PathCacheKey {
fn new(
path: &Path,
style: &StrokeStyle,
fill_rule: FillRule,
origin: [f32; 2],
w: u32,
h: u32,
geom_scale: f32,
) -> Self {
let mut hasher = std::hash::DefaultHasher::new();
path.stamp().hash(&mut hasher);
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);
origin[0].to_bits().hash(&mut hasher);
origin[1].to_bits().hash(&mut hasher);
geom_scale.to_bits().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 cap_max_size(&mut self, device_max: u32) {
self.max_size = self.max_size.min(device_max);
}
pub fn oversize_skips(&self) -> u64 {
self.oversize_skips
}
pub fn entry_count(&self) -> usize {
self.cache.len()
}
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,
snap: bool,
) -> Option<PathPlacement> {
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 dx = bounds[0] * scale_factor;
let dy = bounds[1] * scale_factor;
let dw = bounds[2] * scale_factor;
let dh = bounds[3] * scale_factor;
let ox = dx.floor();
let oy = dy.floor();
let snapped_rect = [
ox,
oy,
((dx + dw).ceil() - ox).max(1.0),
((dy + dh).ceil() - oy).max(1.0),
];
let snapped = snap
&& (geom_scale - scale_factor).abs() < 1e-4
&& snapped_rect[2] as u32 <= self.max_size
&& snapped_rect[3] as u32 <= self.max_size;
let device_rect = if snapped {
snapped_rect
} else {
[dx, dy, dw, dh]
};
let (raster_origin, raster_w, raster_h) = if snapped {
(
[device_rect[0], device_rect[1]],
device_rect[2] as u32,
device_rect[3] as u32,
)
} else {
(
[bounds[0] * geom_scale, bounds[1] * geom_scale],
(bounds[2] * geom_scale).ceil() as u32,
(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_origin,
raster_w,
raster_h,
geom_scale,
);
if let Some(region) = self.cache.get_mut(&key) {
region.last_used_frame = self.current_frame;
return Some(PathPlacement {
region: *region,
device_rect,
});
}
let pixels = rasterize_path(
path,
style,
fill_rule,
raster_origin,
raster_w,
raster_h,
geom_scale,
stroke_scale,
)?;
let region = self.allocate_and_write(key, raster_w, raster_h, &pixels)?;
Some(PathPlacement {
region,
device_rect,
})
}
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 + ENTRY_GUTTER_PX;
self.shelf_height = self.shelf_height.max(h + ENTRY_GUTTER_PX);
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 + ENTRY_GUTTER_PX;
self.shelf_height = h + ENTRY_GUTTER_PX;
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]);
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn rasterize_path(
path: &Path,
style: &StrokeStyle,
fill_rule: FillRule,
origin: [f32; 2],
w: u32,
h: u32,
geom_scale: f32,
stroke_scale: f32,
) -> Option<Vec<u8>> {
if w == 0 || h == 0 {
return None;
}
let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
let sk_path = build_sk_path(path, geom_scale, origin)?;
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.iter().map(|d| d * geom_scale).collect(),
style.dash_offset * geom_scale,
)
});
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())
}
fn build_sk_path(path: &Path, geom_scale: f32, origin: [f32; 2]) -> Option<tiny_skia::Path> {
let bx = |x: f32| x * geom_scale - origin[0];
let by = |y: f32| y * geom_scale - origin[1];
let mut pb = tiny_skia::PathBuilder::new();
let mut cursor = SubpathCursor::new();
for cmd in path.commands() {
match *cmd {
PathCommand::MoveTo(p) => {
pb.move_to(bx(p.x), by(p.y));
cursor.open_at(p);
}
PathCommand::LineTo(p) => {
open_implicit(&mut pb, &cursor, geom_scale, origin);
pb.line_to(bx(p.x), by(p.y));
cursor.extend_to(p);
}
PathCommand::QuadTo { control, to } => {
open_implicit(&mut pb, &cursor, geom_scale, origin);
pb.quad_to(bx(control.x), by(control.y), bx(to.x), by(to.y));
cursor.extend_to(to);
}
PathCommand::CubicTo {
control1,
control2,
to,
} => {
open_implicit(&mut pb, &cursor, geom_scale, origin);
pb.cubic_to(
bx(control1.x),
by(control1.y),
bx(control2.x),
by(control2.y),
bx(to.x),
by(to.y),
);
cursor.extend_to(to);
}
PathCommand::ArcTo {
rect,
start_angle,
sweep_angle,
} => {
emit_arc(
&mut pb,
&mut cursor,
rect,
start_angle,
sweep_angle,
geom_scale,
origin,
);
}
PathCommand::Close => {
pb.close();
cursor.close();
}
}
}
pb.finish()
}
#[derive(Debug, Clone, Copy)]
struct SubpathCursor {
at: Point,
start: Point,
open: bool,
}
fn open_implicit(
pb: &mut tiny_skia::PathBuilder,
cursor: &SubpathCursor,
geom_scale: f32,
origin: [f32; 2],
) {
if !cursor.open {
pb.move_to(
cursor.at.x * geom_scale - origin[0],
cursor.at.y * geom_scale - origin[1],
);
}
}
impl SubpathCursor {
fn new() -> Self {
Self {
at: Point::ZERO,
start: Point::ZERO,
open: false,
}
}
fn open_at(&mut self, p: Point) {
self.at = p;
self.start = p;
self.open = true;
}
fn extend_to(&mut self, to: Point) {
if !self.open {
self.start = self.at;
self.open = true;
}
self.at = to;
}
fn close(&mut self) {
if self.open {
self.at = self.start;
self.open = false;
}
}
}
fn emit_arc(
pb: &mut tiny_skia::PathBuilder,
cursor: &mut SubpathCursor,
rect: Rect,
start_angle: f32,
sweep_angle: f32,
scale_factor: f32,
origin: [f32; 2],
) {
let map = |p: Point| {
(
p.x * scale_factor - origin[0],
p.y * scale_factor - origin[1],
)
};
for seg in teksilo_canvas::arc_to_cubics(rect, start_angle, sweep_angle) {
let (sx, sy) = map(seg.from);
if !cursor.open {
pb.move_to(sx, sy);
cursor.open_at(seg.from);
} else if cursor.at != seg.from {
pb.line_to(sx, sy);
}
let (c1x, c1y) = map(seg.control1);
let (c2x, c2y) = map(seg.control2);
let (tx, ty) = map(seg.to);
pb.cubic_to(c1x, c1y, c2x, c2y, tx, ty);
cursor.extend_to(seg.to);
}
}
#[cfg(test)]
mod tests {
use super::*;
use teksilo_canvas::geometry::Point;
#[test]
fn cap_max_size_only_lowers() {
let mut atlas = PathAtlas::new(512, 512);
let default_cap = atlas.max_size;
atlas.cap_max_size(16384);
assert_eq!(
atlas.max_size, default_cap,
"a device with more headroom must not raise the renderer's own ceiling"
);
atlas.cap_max_size(2048);
assert_eq!(
atlas.max_size, 2048,
"a device that allows less than the renderer wants must lower the ceiling"
);
atlas.cap_max_size(4096);
assert_eq!(
atlas.max_size, 2048,
"capping is a floor-taking operation, so it never undoes an earlier cap"
);
}
#[test]
fn growth_honours_the_device_cap() {
let mut atlas = PathAtlas::new(512, 512);
atlas.cap_max_size(1024);
while atlas.try_grow() {}
assert!(
atlas.width <= 1024 && atlas.height <= 1024,
"atlas grew to {}x{}, past the device cap of 1024",
atlas.width,
atlas.height
);
}
#[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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(pitch, 0.0)));
path.push(PathCommand::LineTo(Point::new(w, h)));
path.push(PathCommand::LineTo(Point::new(h, h)));
path.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,
false,
);
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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(side, 0.0)));
path.push(PathCommand::LineTo(Point::new(side, side)));
path.push(PathCommand::LineTo(Point::new(0.0, side)));
path.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,
false,
);
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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.push(PathCommand::LineTo(Point::new(0.0, 10.0)));
path.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
let pixels = rasterize_path(
&path,
&style,
FillRule::Winding,
[0.0, 0.0],
10,
10,
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.push(PathCommand::MoveTo(Point::new(1.0, 5.0)));
path.push(PathCommand::LineTo(Point::new(9.0, 5.0)));
let style = StrokeStyle::solid(2.0);
let pixels = rasterize_path(
&path,
&style,
FillRule::Winding,
[0.0, 0.0],
10,
10,
1.0,
1.0,
);
assert!(pixels.is_some());
}
#[test]
fn cache_key_distinguishes_line_join() {
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.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, [0.0, 0.0], 12, 12, 1.0),
PathCacheKey::new(&path, &round, FillRule::Winding, [0.0, 0.0], 12, 12, 1.0),
"miter and round joins must hash to different cache keys"
);
}
#[test]
fn cache_key_distinguishes_fill_rule() {
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.push(PathCommand::Close);
let style = StrokeStyle::solid(0.0);
assert_ne!(
PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 12, 12, 1.0),
PathCacheKey::new(&path, &style, FillRule::EvenOdd, [0.0, 0.0], 12, 12, 1.0),
"winding and even-odd fills must hash to different cache keys"
);
}
#[test]
fn a_snapped_path_draws_one_texel_per_device_pixel() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let path = Path::circle(Point::new(8.0, 8.0), 5.5);
let style = StrokeStyle::solid(1.0);
let bounds = path.bounds().expand(style.width).to_array();
assert_eq!(
[bounds[0], bounds[1]],
[1.5, 1.5],
"the geometry this guards against: a half-pixel bounds origin"
);
for sf in [1.0_f32, 1.2, 2.0] {
let p = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, sf, 1.0, true)
.expect("ring rasterizes");
let [x, y, w, h] = p.device_rect;
assert_eq!(
[x, y, w, h],
[x.floor(), y.floor(), w.floor(), h.floor()],
"sf {sf}: a snapped quad must land on whole device pixels"
);
assert_eq!(
(w as u32, h as u32),
(p.region.w, p.region.h),
"sf {sf}: the quad must be exactly as many pixels as the region \
has texels, or the mask is resampled even on the integer grid"
);
assert!(
x <= bounds[0] * sf && x + w >= (bounds[0] + bounds[2]) * sf,
"sf {sf}: snapping must grow the rect outward, never clip the path"
);
}
}
#[test]
fn an_unsnapped_path_keeps_the_raw_rect() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let path = Path::circle(Point::new(8.0, 8.0), 5.5);
let style = StrokeStyle::solid(1.0);
let bounds = path.bounds().expand(style.width).to_array();
let p = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
.expect("ring rasterizes");
assert_eq!(p.device_rect, [1.5, 1.5, 13.0, 13.0]);
assert_eq!((p.region.w, p.region.h), (13, 13));
}
#[test]
fn cache_key_distinguishes_the_snapped_phase() {
let path = Path::circle(Point::new(8.0, 8.0), 5.5);
let style = StrokeStyle::solid(1.0);
assert_ne!(
PathCacheKey::new(&path, &style, FillRule::Winding, [1.0, 1.0], 13, 13, 1.0),
PathCacheKey::new(&path, &style, FillRule::Winding, [1.5, 1.5], 13, 13, 1.0),
"a snapped and an unsnapped raster of one path must key apart"
);
}
#[test]
fn atlas_entries_never_touch() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let style = StrokeStyle::solid(0.0);
let mut placed: Vec<AtlasRegion> = Vec::new();
for i in 0..6 {
let mut path = Path::new();
let side = 10.0 + i as f32;
path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(side, 0.0)));
path.push(PathCommand::LineTo(Point::new(side, side)));
path.push(PathCommand::Close);
let p = atlas
.lookup_or_rasterize(
&path,
&style,
FillRule::Winding,
[0.0, 0.0, side, side],
1.0,
1.0,
true,
)
.expect("rasterizes");
placed.push(p.region);
}
for (i, a) in placed.iter().enumerate() {
for (j, b) in placed.iter().enumerate() {
if i >= j {
continue;
}
let overlaps = a.x < b.x + b.w + ENTRY_GUTTER_PX
&& b.x < a.x + a.w + ENTRY_GUTTER_PX
&& a.y < b.y + b.h + ENTRY_GUTTER_PX
&& b.y < a.y + a.h + ENTRY_GUTTER_PX;
assert!(
!overlaps,
"entries {i} {a:?} and {j} {b:?} are packed closer than the gutter"
);
}
}
}
#[test]
fn atlas_cache_hit() {
let mut atlas = PathAtlas::new(256, 256);
atlas.begin_frame();
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.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, false)
.unwrap();
let r2 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
.unwrap();
assert_eq!(r1.region.x, r2.region.x);
assert_eq!(r1.region.y, r2.region.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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
path.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, false)
.expect("first lookup rasterizes and caches");
let r2 = atlas
.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
.expect("second lookup hits the same cache entry");
assert_eq!(r1.region.x, r2.region.x, "cache hit: same region x");
assert_eq!(r1.region.y, r2.region.y, "cache hit: same region y");
assert_eq!(r1.region.w, r2.region.w);
assert_eq!(r1.region.h, r2.region.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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
path.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, false);
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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(40.0, 40.0)));
p1.push(PathCommand::Close);
let mut p2 = Path::new();
p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
p2.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,
false,
)
.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,
false,
);
let r1b = atlas
.lookup_or_rasterize(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0, 40.0, 40.0],
1.0,
1.0,
false,
)
.expect("p1 still cached after eviction");
let _ = (r1, r1b);
assert!(atlas.cache.contains_key(&PathCacheKey::new(
&p1,
&style,
FillRule::Winding,
[0.0, 0.0],
40,
40,
1.0,
)));
}
#[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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
p1.push(PathCommand::Close);
let mut p2 = Path::new();
p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(62.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(62.0, 62.0)));
p2.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,
false,
)
.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,
false,
);
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,
false,
)
.expect("p1 still cached");
assert_eq!(r1.region.x, r1b.region.x, "live entry must not move");
assert_eq!(r1.region.y, r1b.region.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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
path.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,
false,
)
.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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
p1.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
p1.push(PathCommand::Close);
let mut p2 = Path::new();
p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
p2.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
p2.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,
false,
)
.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,
false,
)
.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,
false,
)
.expect("p1 still cached");
assert_eq!(
r1.region.x, r1_after.region.x,
"p1 must not move when atlas grows"
);
assert_eq!(
r1.region.y, r1_after.region.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.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.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, false)
.unwrap();
let r2 = atlas
.lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 2.0, false)
.unwrap();
assert_eq!(r1.region.w, 40, "cosmetic body at zoom 1: 40·sf1·zoom1");
assert_eq!(
r2.region.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, false)
.unwrap();
let l2 = atlas
.lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 4.0, false)
.unwrap();
assert_eq!(l1.region.w, l2.region.w, "logical raster size ignores zoom");
assert_eq!(
(l1.region.x, l1.region.y),
(l2.region.x, l2.region.y),
"logical hits the same cache entry"
);
let k_cos = PathCacheKey::new(&path, &cosmetic, FillRule::Winding, [0.0, 0.0], 40, 4, 1.0);
let k_log = PathCacheKey::new(&path, &logical, FillRule::Winding, [0.0, 0.0], 40, 4, 1.0);
assert_ne!(
k_cos, k_log,
"cache key must distinguish cosmetic vs logical"
);
}
fn dashed_line_runs(geom_scale: f32) -> usize {
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 4.0)));
path.push(PathCommand::LineTo(Point::new(20.0, 4.0)));
let style = StrokeStyle::dashed(2.0, 4.0, 4.0);
let w = (20.0 * geom_scale).ceil() as u32;
let h = (8.0 * geom_scale).ceil() as u32;
let px = rasterize_path(
&path,
&style,
FillRule::Winding,
[0.0, 0.0],
w,
h,
geom_scale,
geom_scale,
)
.expect("rasterizes");
let row = (4.0 * geom_scale) as u32;
let mut runs = 0usize;
let mut inked = false;
for x in 0..w {
let now = px[((row * w + x) * 4 + 3) as usize] > 100;
if now && !inked {
runs += 1;
}
inked = now;
}
runs
}
#[test]
fn dash_count_is_invariant_to_the_geometry_scale() {
let baseline = dashed_line_runs(1.0);
assert!(baseline > 1, "the probe line must actually dash");
for scale in [2.0f32, 3.0, 4.0] {
assert_eq!(
dashed_line_runs(scale),
baseline,
"a dash is a length along the path: scaling the geometry by \
{scale} must scale the dashes with it, not cut more of them"
);
}
}
#[test]
fn cache_key_distinguishes_the_geometry_scale() {
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
path.push(PathCommand::LineTo(Point::new(0.4, 0.0)));
let style = StrokeStyle::dashed(1.0, 4.0, 4.0);
assert_ne!(
PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 1, 1, 1.0),
PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 1, 1, 2.0),
"two geometry scales that ceil to the same bitmap must not share \
an atlas entry — their dashes are cut differently"
);
}
#[test]
fn a_dashed_stroke_leaves_gaps_where_a_solid_one_does_not() {
let mut path = Path::new();
path.push(PathCommand::MoveTo(Point::new(0.0, 4.0)));
path.push(PathCommand::LineTo(Point::new(20.0, 4.0)));
let ink = |style: &StrokeStyle| -> usize {
let px = rasterize_path(&path, style, FillRule::Winding, [0.0, 0.0], 20, 8, 1.0, 1.0)
.expect("rasterizes");
(0..20)
.filter(|x| px[((4 * 20 + x) * 4 + 3) as usize] > 100)
.count()
};
let solid = ink(&StrokeStyle::solid(2.0));
let dashed = ink(&StrokeStyle::dashed(2.0, 4.0, 4.0));
assert!(
solid >= 19,
"solid stroke inks the whole line (got {solid})"
);
assert!(
dashed < solid,
"dashed stroke must leave gaps (solid={solid}, dashed={dashed})"
);
}
fn probe_arc() -> teksilo_canvas::geometry::Rect {
teksilo_canvas::geometry::Rect::new(180.0, -20.0, 40.0, 40.0)
}
fn subpath_battery() -> Vec<(&'static str, Path)> {
let mut bare_circle_arc = Path::new();
bare_circle_arc.arc_to(probe_arc(), 0.0, 360.0);
let mut bare_quarter_arc = Path::new();
bare_quarter_arc.arc_to(probe_arc(), 0.0, 90.0);
let mut arc_after_close = Path::new();
arc_after_close
.move_to(Point::new(0.0, 0.0))
.line_to(Point::new(10.0, 0.0))
.line_to(Point::new(10.0, 10.0));
arc_after_close.close();
arc_after_close.arc_to(probe_arc(), 0.0, 360.0);
let mut two_arcs = Path::new();
two_arcs.arc_to(probe_arc(), 0.0, 180.0);
two_arcs.close();
two_arcs.arc_to(
teksilo_canvas::geometry::Rect::new(0.0, 0.0, 30.0, 30.0),
90.0,
180.0,
);
two_arcs.close();
let mut arc_from_elsewhere = Path::new();
arc_from_elsewhere.move_to(Point::new(0.0, 0.0));
arc_from_elsewhere.arc_to(probe_arc(), 0.0, 270.0);
let mut arc_after_cubic = Path::new();
arc_after_cubic.move_to(Point::new(0.0, 0.0)).cubic_to(
Point::new(20.0, 40.0),
Point::new(60.0, -40.0),
Point::new(90.0, 5.0),
);
arc_after_cubic.arc_to(probe_arc(), 90.0, 180.0);
let mut curves_and_lines = Path::new();
curves_and_lines
.move_to(Point::new(0.0, 0.0))
.line_to(Point::new(40.0, 0.0))
.quad_to(Point::new(60.0, 20.0), Point::new(40.0, 40.0))
.line_to(Point::new(0.0, 40.0));
curves_and_lines.close();
curves_and_lines
.move_to(Point::new(80.0, 10.0))
.line_to(Point::new(120.0, 10.0))
.line_to(Point::new(120.0, 30.0));
let mut bare_line_negative = Path::new();
bare_line_negative
.line_to(Point::new(-30.0, -20.0))
.line_to(Point::new(40.0, 25.0));
let mut bare_cubic_negative = Path::new();
bare_cubic_negative.cubic_to(
Point::new(-20.0, 40.0),
Point::new(60.0, -40.0),
Point::new(-90.0, 5.0),
);
let mut bare_quad_negative = Path::new();
bare_quad_negative.quad_to(Point::new(-15.0, 30.0), Point::new(35.0, -10.0));
vec![
("bare 360 arc", bare_circle_arc),
("bare 90 arc", bare_quarter_arc),
("bare line, negative", bare_line_negative),
("bare cubic, negative", bare_cubic_negative),
("bare quad, negative", bare_quad_negative),
("arc after close", arc_after_close),
("two arcs split by close", two_arcs),
("arc reached from elsewhere", arc_from_elsewhere),
("arc after a cubic", arc_after_cubic),
("curves and lines", curves_and_lines),
("circle", Path::circle(Point::new(50.0, 50.0), 25.0)),
(
"rounded rect",
Path::rounded_rect(
teksilo_canvas::geometry::Rect::new(0.0, 0.0, 100.0, 60.0),
teksilo_tokens::CornerRadius {
top_left: 12.0,
top_right: 4.0,
bottom_left: 0.0,
bottom_right: 20.0,
},
),
),
]
}
fn sk_subpath_starts(p: &tiny_skia::Path) -> Vec<(f32, f32)> {
p.segments()
.filter_map(|seg| match seg {
tiny_skia::PathSegment::MoveTo(pt) => Some((pt.x, pt.y)),
_ => None,
})
.collect()
}
fn flatten_subpath_starts(p: &Path) -> Vec<(f32, f32)> {
p.flatten(0.01)
.iter()
.filter(|sp| sp.points.len() > 1)
.map(|sp| (sp.points[0].x, sp.points[0].y))
.collect()
}
#[test]
fn the_rasterizer_starts_subpaths_where_the_flattener_does() {
for (name, path) in subpath_battery() {
let sk = build_sk_path(&path, 1.0, [0.0, 0.0]).expect("path builds");
let got = sk_subpath_starts(&sk);
let want = flatten_subpath_starts(&path);
assert_eq!(
got, want,
"{name}: the rasterizer must open its subpaths exactly where \
Path::flatten opens its own — anywhere else is ink the scene \
tier's hit-test cannot see"
);
}
}
#[test]
fn subpath_starts_track_the_geometry_through_scale_and_origin() {
for (name, path) in subpath_battery() {
let want = flatten_subpath_starts(&path);
for (scale, origin) in [
(1.0_f32, [0.0_f32, 0.0]),
(2.0, [7.0, -3.0]),
(0.5, [1.0, 1.0]),
] {
let sk = build_sk_path(&path, scale, origin).expect("path builds");
let got = sk_subpath_starts(&sk);
let mapped: Vec<(f32, f32)> = want
.iter()
.map(|(x, y)| (x * scale - origin[0], y * scale - origin[1]))
.collect();
assert_eq!(
got, mapped,
"{name} at scale {scale} origin {origin:?}: a subpath start \
is a point of the geometry, so it must map through the same \
affine transform every other point does"
);
}
}
}
fn painted_fill_bounds(path: &Path) -> Option<(f32, f32, f32, f32)> {
const MARGIN: f32 = 2.0;
let b = path.bounds();
let (ox, oy) = (b.x - MARGIN, b.y - MARGIN);
let w = (b.width + 2.0 * MARGIN).ceil() as u32;
let h = (b.height + 2.0 * MARGIN).ceil() as u32;
let px = rasterize_path(
path,
&StrokeStyle::solid(0.0),
FillRule::Winding,
[ox, oy],
w,
h,
1.0,
1.0,
)
.expect("rasterizes");
let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
let mut any = false;
for y in 0..h {
for x in 0..w {
if px[((y * w + x) * 4 + 3) as usize] > 0 {
any = true;
min_x = min_x.min(x);
min_y = min_y.min(y);
max_x = max_x.max(x);
max_y = max_y.max(y);
}
}
}
any.then_some((
min_x as f32 + ox,
min_y as f32 + oy,
max_x as f32 + ox + 1.0,
max_y as f32 + oy + 1.0,
))
}
#[test]
fn a_fill_paints_only_where_the_shape_says_it_is() {
const SLACK: f32 = 1.5;
for (name, path) in subpath_battery() {
let want = path.exact_bounds(0.01);
let (l, t, r, b) = painted_fill_bounds(&path)
.unwrap_or_else(|| panic!("{name}: the probe must actually ink something"));
assert!(
l >= want.x - SLACK
&& t >= want.y - SLACK
&& r <= want.right() + SLACK
&& b <= want.bottom() + SLACK,
"{name}: painted ({l}, {t})-({r}, {b}) escapes the shape's own \
extent ({}, {})-({}, {}) — that ink is unreachable by a click",
want.x,
want.y,
want.right(),
want.bottom()
);
}
}
#[test]
fn a_stroked_bare_arc_draws_no_spoke_to_the_bitmap_corner() {
let mut path = Path::new();
path.arc_to(probe_arc(), 0.0, 360.0);
let (w, h) = (242u32, 44u32);
let px = rasterize_path(
&path,
&StrokeStyle::solid(2.0),
FillRule::Winding,
[-1.0, -22.0],
w,
h,
1.0,
1.0,
)
.expect("rasterizes");
let leftmost = (0..w)
.find(|&x| (0..h).any(|y| px[((y * w + x) * 4 + 3) as usize] > 0))
.expect("the arc must ink");
assert!(
leftmost >= 179,
"a stroked bare arc inked from column {leftmost}: the subpath was \
opened at the bitmap's corner and a spoke stroked from there to \
the arc, 220 units of ink the shape does not have"
);
}
}