teksilo_render/path_atlas.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Path atlas: CPU rasterizes paths with tiny-skia, caches results in a texture atlas with LRU eviction.
5
6use 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
12/// Upper bound on a cosmetic path's rasterized dimension (device px). At
13/// extreme zoom the body would otherwise exceed the atlas; beyond this the
14/// body softens and the stroke drifts slightly off-cosmetic — an accepted
15/// degradation far past normal zoom. Kept well under [`PathAtlas::max_size`]
16/// (4096) to leave room for shelf packing.
17const MAX_COSMETIC_RASTER_DIM: f32 = 2048.0;
18
19/// Free vertical headroom (device px) below which `begin_frame` treats the
20/// atlas as near-full and compacts. Roughly one tall shelf — enough that a
21/// frame rarely runs out of room mid-walk (where reclaiming is unsafe).
22const COMPACT_SLACK_PX: u32 = 256;
23
24/// Transparent margin reserved after each entry, so no two entries touch.
25///
26/// The atlas is sampled with `FilterMode::Linear` and each quad's UVs run to
27/// its region's outer edge. Whenever a quad is not pixel-exact on its region
28/// — any path under a transform, where snapping is deliberately off (see
29/// [`PathAtlas::lookup_or_rasterize`]) — an edge fragment's bilinear kernel
30/// reaches past the region, and edge-to-edge packing made that the
31/// *neighbouring icon's* pixels. One transparent row and column keeps the
32/// worst case a fade to nothing rather than a smear of unrelated ink. The
33/// glyph atlas has always reserved the same gutter.
34const ENTRY_GUTTER_PX: u32 = 1;
35
36/// A region within the atlas texture.
37#[derive(Debug, Clone, Copy)]
38pub struct AtlasRegion {
39 pub x: u32,
40 pub y: u32,
41 pub w: u32,
42 pub h: u32,
43 /// Frame when this region was last used.
44 last_used_frame: u64,
45}
46
47/// A rasterized path plus the **exact** rect it must be drawn at.
48///
49/// The two travel together because they are one decision, not two. The atlas
50/// bitmap is rasterized on its own integer grid; if the quad that samples it
51/// is placed or sized even slightly differently, every texel is resampled
52/// through the atlas's `FilterMode::Linear` and the coverage mask smears.
53/// A 16 dp line-style icon does not survive that: a 1 px stroke drawn at a
54/// half-pixel offset peaks at **48 % coverage** instead of 100 %, and
55/// sub-pixel dash gaps close up entirely, so a dashed ring renders as a grey
56/// haze. Returning the rect from the same call that decides the raster is
57/// what stops the two from ever disagreeing again.
58///
59/// See [`PathAtlas::lookup_or_rasterize`] for when the rect is snapped.
60#[derive(Debug, Clone, Copy)]
61pub struct PathPlacement {
62 /// Where the coverage mask lives in the atlas texture.
63 pub region: AtlasRegion,
64 /// `[x, y, w, h]` in **pre-transform device pixels** — the quad the
65 /// caller must emit. When snapped this is integral and exactly
66 /// `region.w × region.h`, so the mask samples 1:1 onto whole pixels.
67 pub device_rect: [f32; 4],
68}
69
70/// Cache key derived from path geometry + stroke style + rasterized size +
71/// the device-space origin the bitmap was baked against.
72///
73/// Deliberately does **not** include color: the atlas now always
74/// rasterizes an opaque-white AA coverage mask (see [`rasterize_path`]),
75/// so a solid fill and a gradient fill of identical geometry share one
76/// atlas entry — the color/gradient tint is applied by the GPU at draw
77/// time, not baked into the bitmap.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79struct PathCacheKey(u64);
80
81impl PathCacheKey {
82 fn new(
83 path: &Path,
84 style: &StrokeStyle,
85 fill_rule: FillRule,
86 origin: [f32; 2],
87 w: u32,
88 h: u32,
89 ) -> Self {
90 let mut hasher = std::hash::DefaultHasher::new();
91 // Hash path commands
92 for cmd in &path.commands {
93 std::mem::discriminant(cmd).hash(&mut hasher);
94 match cmd {
95 PathCommand::MoveTo(p) | PathCommand::LineTo(p) => {
96 p.x.to_bits().hash(&mut hasher);
97 p.y.to_bits().hash(&mut hasher);
98 }
99 PathCommand::QuadTo { control, to } => {
100 control.x.to_bits().hash(&mut hasher);
101 control.y.to_bits().hash(&mut hasher);
102 to.x.to_bits().hash(&mut hasher);
103 to.y.to_bits().hash(&mut hasher);
104 }
105 PathCommand::CubicTo {
106 control1,
107 control2,
108 to,
109 } => {
110 control1.x.to_bits().hash(&mut hasher);
111 control1.y.to_bits().hash(&mut hasher);
112 control2.x.to_bits().hash(&mut hasher);
113 control2.y.to_bits().hash(&mut hasher);
114 to.x.to_bits().hash(&mut hasher);
115 to.y.to_bits().hash(&mut hasher);
116 }
117 PathCommand::ArcTo {
118 rect,
119 start_angle,
120 sweep_angle,
121 } => {
122 rect.x.to_bits().hash(&mut hasher);
123 rect.y.to_bits().hash(&mut hasher);
124 rect.width.to_bits().hash(&mut hasher);
125 rect.height.to_bits().hash(&mut hasher);
126 start_angle.to_bits().hash(&mut hasher);
127 sweep_angle.to_bits().hash(&mut hasher);
128 }
129 PathCommand::Close => {}
130 }
131 }
132 // Hash stroke style
133 style.width.to_bits().hash(&mut hasher);
134 std::mem::discriminant(&style.line_cap).hash(&mut hasher);
135 std::mem::discriminant(&style.line_join).hash(&mut hasher);
136 if let Some(ref pattern) = style.dash_pattern {
137 for &v in pattern {
138 v.to_bits().hash(&mut hasher);
139 }
140 }
141 style.dash_offset.to_bits().hash(&mut hasher);
142 style.miter_limit.to_bits().hash(&mut hasher);
143 // Cosmetic vs logical strokes bake differently (constant device width
144 // vs zoom-scaled), so they must not share a cache entry.
145 std::mem::discriminant(&style.space).hash(&mut hasher);
146 // Winding vs even-odd fill produce different pixels for the same path.
147 std::mem::discriminant(&fill_rule).hash(&mut hasher);
148 // Hash rasterized dimensions
149 w.hash(&mut hasher);
150 h.hash(&mut hasher);
151 // And the device-space origin the bitmap was baked against. The
152 // path's own commands are absolute, so two *different* paths already
153 // key apart — but the SAME path drawn once under the identity
154 // transform (snapped to the pixel grid) and once under a transform
155 // (not snapped) wants two different bitmaps at the same dimensions.
156 // Without the origin here the second draw would silently reuse the
157 // first's phase.
158 origin[0].to_bits().hash(&mut hasher);
159 origin[1].to_bits().hash(&mut hasher);
160 PathCacheKey(hasher.finish())
161 }
162}
163
164/// Shelf-packed atlas for rasterized paths with LRU eviction.
165pub struct PathAtlas {
166 /// Atlas pixel data (RGBA).
167 pixels: Vec<u8>,
168 width: u32,
169 height: u32,
170 /// Maximum atlas dimension.
171 max_size: u32,
172 /// Cache from path key to atlas region.
173 cache: HashMap<PathCacheKey, AtlasRegion>,
174 /// Current frame counter for LRU.
175 current_frame: u64,
176 /// Whether the atlas texture needs re-uploading.
177 dirty: bool,
178 // Shelf-packing state
179 /// Current Y position of the next shelf.
180 shelf_y: u32,
181 /// Current X position within the current shelf.
182 shelf_x: u32,
183 /// Height of the current shelf (tallest entry in this row).
184 shelf_height: u32,
185 /// How many paths have been skipped because they could never fit the atlas.
186 ///
187 /// Such a path is simply not drawn. That is a silent hole in the frame, so it is
188 /// counted rather than swallowed: a non-zero value means some geometry is being
189 /// asked to rasterize larger than [`max_size`](Self::max_size), which is almost
190 /// always a layout bug upstream (see [`Self::lookup_or_rasterize`]).
191 oversize_skips: u64,
192}
193
194impl PathAtlas {
195 /// Create a new path atlas with the given initial dimensions.
196 pub fn new(width: u32, height: u32) -> Self {
197 Self {
198 pixels: vec![0; (width * height * 4) as usize],
199 width,
200 height,
201 max_size: 4096,
202 cache: HashMap::new(),
203 current_frame: 0,
204 dirty: false,
205 shelf_y: 0,
206 shelf_x: 0,
207 shelf_height: 0,
208 oversize_skips: 0,
209 }
210 }
211
212 /// How many paths have been skipped for being too large to ever fit the atlas.
213 ///
214 /// Each one is a path that simply was not drawn. Non-zero means some geometry is
215 /// rasterizing bigger than `max_size` — upstream, that is a
216 /// layout that has run away (an overlay spanning a whole scrolled document, a
217 /// shape scaled by a runaway transform), and it is worth chasing rather than
218 /// leaving as a hole in the frame.
219 pub fn oversize_skips(&self) -> u64 {
220 self.oversize_skips
221 }
222
223 /// Call at the start of each frame to advance the LRU counter.
224 ///
225 /// This is also the only point at which the atlas may safely **repack**
226 /// itself: no `AtlasRegion` has been handed out for the new frame yet, so
227 /// moving surviving entries to fresh coordinates cannot invalidate any
228 /// region the renderer is still holding from the current frame. When the
229 /// atlas is near-full and there are stale entries (not touched on the last
230 /// completed frame), we compact — dropping the stale entries and repacking
231 /// the rest tightly — so steady-state reclamation never has to happen
232 /// mid-frame (which would corrupt already-placed paths).
233 pub fn begin_frame(&mut self) {
234 self.current_frame += 1;
235
236 // Only the just-completed frame's working set is worth keeping
237 // (temporal locality); anything older is fragmentation to reclaim.
238 let keep_from = self.current_frame - 1;
239 let near_full =
240 self.shelf_y.saturating_add(self.shelf_height) + COMPACT_SLACK_PX >= self.height;
241 let has_stale = self.cache.values().any(|r| r.last_used_frame < keep_from);
242 if near_full && has_stale {
243 self.compact(keep_from);
244 }
245 }
246
247 /// Current atlas dimensions.
248 pub fn size(&self) -> (u32, u32) {
249 (self.width, self.height)
250 }
251
252 /// Whether the atlas texture needs re-uploading to the GPU.
253 pub fn is_dirty(&self) -> bool {
254 self.dirty
255 }
256
257 /// Raw pixel data (RGBA).
258 pub fn pixels(&self) -> &[u8] {
259 &self.pixels
260 }
261
262 /// Mark the atlas as uploaded.
263 pub fn mark_clean(&mut self) {
264 self.dirty = false;
265 }
266
267 /// Look up or rasterize a path, returning its atlas region.
268 ///
269 /// The rasterized bitmap is always an **opaque-white AA coverage
270 /// mask** — color is applied by the GPU at draw time (solid fills tint
271 /// it via the quad pipeline; gradients sample an analytic gradient in
272 /// `path_gradient.wgsl` and modulate by the mask's alpha channel), so
273 /// this function takes no color and two fills of identical geometry
274 /// share one atlas entry regardless of their paint.
275 ///
276 /// `zoom` is the uniform scale of the view transform active where the path
277 /// is drawn. For a **cosmetic** stroke ([`StrokeSpace::Device`]) the body
278 /// is rasterized at the current zoom (so it stays sharp, matching the
279 /// transform-scaled display quad 1:1) while the stroke is baked at a
280 /// zoom-independent device width — the border holds a constant
281 /// device-pixel thickness at any zoom. **Logical** strokes ignore `zoom`
282 /// (the body bitmap is stretched by the display quad, as before).
283 ///
284 /// `snap` asks for the quad to be aligned to whole device pixels and the
285 /// bitmap baked to match, so the mask samples 1:1 — pass it when the
286 /// effective transform is the identity, and only then (see the body for
287 /// why). The returned [`PathPlacement`] carries the rect the caller must
288 /// draw; it is not to be re-derived from `bounds`.
289 #[allow(clippy::too_many_arguments)] // rasterization params; bundling adds no clarity
290 pub fn lookup_or_rasterize(
291 &mut self,
292 path: &Path,
293 style: &StrokeStyle,
294 fill_rule: FillRule,
295 bounds: [f32; 4],
296 scale_factor: f32,
297 zoom: f32,
298 snap: bool,
299 ) -> Option<PathPlacement> {
300 // Cosmetic paths rasterize the body at the current zoom (so it stays
301 // sharp 1:1 with the transform-scaled display quad). Cost: the zoom is
302 // baked into the raster dimensions, which are part of the cache key,
303 // so a CONTINUOUS zoom gesture is a cache miss every frame — each
304 // visible cosmetic path is re-rasterized per frame while zooming (the
305 // per-frame LRU keeps current-frame entries and evicts the rest, so
306 // the atlas stays bounded, but CPU rasterization scales with the
307 // visible cosmetic-path count). Cache hits resume once the zoom
308 // settles. This is the cost of "full-fidelity" cosmetic paths; coarse
309 // zoom-quantization would cut the re-raster rate but reintroduce the
310 // sub-pixel width drift the zoom-aware path was chosen to avoid.
311 let (geom_scale, stroke_scale) = if style.space == StrokeSpace::Device {
312 let mut g = scale_factor * zoom.max(1e-3);
313 // Keep the bitmap under the atlas budget at extreme zoom.
314 let cap = MAX_COSMETIC_RASTER_DIM / bounds[2].max(bounds[3]).max(1.0);
315 if g > cap {
316 g = cap;
317 }
318 (g, scale_factor)
319 } else {
320 (scale_factor, scale_factor)
321 };
322
323 // The quad the caller will emit, in pre-transform device pixels.
324 let dx = bounds[0] * scale_factor;
325 let dy = bounds[1] * scale_factor;
326 let dw = bounds[2] * scale_factor;
327 let dh = bounds[3] * scale_factor;
328
329 // Snap the quad out to whole device pixels and bake the bitmap
330 // against that same origin, so one texel lands on one pixel and the
331 // sampler has nothing to interpolate. Without this a path's mask is
332 // rasterized on its own integer grid and then drawn wherever layout
333 // put it — `Rect::expand` alone leaves a 16 dp ring's bounds at
334 // `x = 1.5`, and a half-pixel bilinear smear costs that ring more
335 // than half its ink (see `PathPlacement`). The glyph pipeline has
336 // always done this; see `QuadVertex::from_glyph_quad_transformed`'s
337 // `one_to_one` branch.
338 //
339 // Only under the identity transform (`snap`, decided by the caller):
340 // under a scale the mask is being resampled anyway, and under a
341 // translate animation rounding the origin would make the path step
342 // between pixels instead of gliding. The `geom_scale` check keeps a
343 // cosmetic (device-space) stroke out of it unless its zoom is 1,
344 // since its bitmap is baked at zoom while its quad is not.
345 let ox = dx.floor();
346 let oy = dy.floor();
347 let snapped_rect = [
348 ox,
349 oy,
350 ((dx + dw).ceil() - ox).max(1.0),
351 ((dy + dh).ceil() - oy).max(1.0),
352 ];
353 // Snapping grows the bitmap by up to a pixel on each axis. A path
354 // sitting exactly on `max_size` would then be rejected below and
355 // simply not drawn, so give up the sharpness rather than the path —
356 // at that size it is one texel in four thousand anyway.
357 let snapped = snap
358 && (geom_scale - scale_factor).abs() < 1e-4
359 && snapped_rect[2] as u32 <= self.max_size
360 && snapped_rect[3] as u32 <= self.max_size;
361 let device_rect = if snapped {
362 snapped_rect
363 } else {
364 [dx, dy, dw, dh]
365 };
366
367 // Device-space origin the bitmap is baked against, and its size.
368 let (raster_origin, raster_w, raster_h) = if snapped {
369 (
370 [device_rect[0], device_rect[1]],
371 device_rect[2] as u32,
372 device_rect[3] as u32,
373 )
374 } else {
375 (
376 [bounds[0] * geom_scale, bounds[1] * geom_scale],
377 (bounds[2] * geom_scale).ceil() as u32,
378 (bounds[3] * geom_scale).ceil() as u32,
379 )
380 };
381 if raster_w == 0 || raster_h == 0 {
382 return None;
383 }
384
385 // A path that can never fit the atlas must never be rasterized.
386 //
387 // Growth is capped at `max_size`, so `allocate_and_write` is guaranteed to
388 // fail for anything larger — meaning the bitmap would be built, thrown away,
389 // and rebuilt from scratch on the very next frame, forever. That is not a
390 // slow frame, it is a permanent freeze: a single 7573x7563 path (one hazard
391 // stripe painted across a tall overflow strip) is a 229 MB rasterization,
392 // and redoing it every frame pinned the UI thread at 100% CPU for as long as
393 // the path stayed on screen.
394 //
395 // Returning `None` here is not a new failure mode — it is the one the caller
396 // already handled (and already reached, just hundreds of megabytes later):
397 // the path is skipped for this frame. Bailing out *before* the raster turns
398 // an unbounded stall into a dropped draw.
399 if raster_w > self.max_size || raster_h > self.max_size {
400 self.oversize_skips += 1;
401 return None;
402 }
403
404 let key = PathCacheKey::new(path, style, fill_rule, raster_origin, raster_w, raster_h);
405
406 // Cache hit
407 if let Some(region) = self.cache.get_mut(&key) {
408 region.last_used_frame = self.current_frame;
409 return Some(PathPlacement {
410 region: *region,
411 device_rect,
412 });
413 }
414
415 // Rasterize — always opaque white; see PathCacheKey and this
416 // function's doc comment for why color is not a parameter.
417 let pixels = rasterize_path(
418 path,
419 style,
420 fill_rule,
421 raster_origin,
422 raster_w,
423 raster_h,
424 geom_scale,
425 stroke_scale,
426 )?;
427 let region = self.allocate_and_write(key, raster_w, raster_h, &pixels)?;
428 Some(PathPlacement {
429 region,
430 device_rect,
431 })
432 }
433
434 /// Try to allocate space in the atlas via shelf packing.
435 ///
436 /// Strategy, in order:
437 /// 1. Try the current shelf / a new shelf at the existing size.
438 /// 2. Grow the atlas (doubles up to `max_size`). Growth preserves
439 /// every existing entry's `(x, y)` so any `AtlasRegion` values
440 /// handed out earlier in the same render pass stay valid.
441 /// 3. Last resort, evict. Eviction never moves entries already handed
442 /// out this frame (that would invalidate `AtlasRegion`s the caller
443 /// cached earlier in the same render walk → wrong-pixel sampling). It
444 /// can only reclaim space when nothing has been handed out yet this
445 /// frame; otherwise the allocation fails and the path is skipped for
446 /// this frame. Steady-state reclamation happens safely in
447 /// [`PathAtlas::begin_frame`] (compaction) before any region is
448 /// handed out.
449 fn allocate_and_write(
450 &mut self,
451 key: PathCacheKey,
452 w: u32,
453 h: u32,
454 pixels: &[u8],
455 ) -> Option<AtlasRegion> {
456 if let Some(region) = self.try_allocate(w, h) {
457 self.blit(region.x, region.y, w, h, pixels);
458 self.cache.insert(key, region);
459 self.dirty = true;
460 return Some(region);
461 }
462
463 // Grow first — keeps every existing entry at the same coordinates.
464 while self.try_grow() {
465 if let Some(region) = self.try_allocate(w, h) {
466 self.blit(region.x, region.y, w, h, pixels);
467 self.cache.insert(key, region);
468 self.dirty = true;
469 return Some(region);
470 }
471 }
472
473 // Atlas at max size and still no room. Try eviction — but it will
474 // refuse to move any entry already handed out this frame, so if the
475 // frame's live working set already fills a max-size atlas this is a
476 // no-op and we return `None` (the path is skipped this frame, which is
477 // correct: it genuinely doesn't fit). It never corrupts placed paths.
478 self.evict_lru();
479 if let Some(region) = self.try_allocate(w, h) {
480 self.blit(region.x, region.y, w, h, pixels);
481 self.cache.insert(key, region);
482 self.dirty = true;
483 return Some(region);
484 }
485
486 None
487 }
488
489 /// Try to allocate a region using shelf packing.
490 fn try_allocate(&mut self, w: u32, h: u32) -> Option<AtlasRegion> {
491 // The region is `w × h`; the shelf cursor advances past a further
492 // `ENTRY_GUTTER_PX` so the next entry cannot abut this one. Only the
493 // region has to fit — a gutter running off the right edge costs
494 // nothing, since the cursor is past the edge either way.
495 if self.shelf_x + w <= self.width && self.shelf_y + h.max(self.shelf_height) <= self.height
496 {
497 let region = AtlasRegion {
498 x: self.shelf_x,
499 y: self.shelf_y,
500 w,
501 h,
502 last_used_frame: self.current_frame,
503 };
504 self.shelf_x += w + ENTRY_GUTTER_PX;
505 self.shelf_height = self.shelf_height.max(h + ENTRY_GUTTER_PX);
506 return Some(region);
507 }
508
509 // Start a new shelf
510 let new_y = self.shelf_y + self.shelf_height;
511 if w <= self.width && new_y + h <= self.height {
512 self.shelf_y = new_y;
513 self.shelf_x = w + ENTRY_GUTTER_PX;
514 self.shelf_height = h + ENTRY_GUTTER_PX;
515 let region = AtlasRegion {
516 x: 0,
517 y: new_y,
518 w,
519 h,
520 last_used_frame: self.current_frame,
521 };
522 return Some(region);
523 }
524
525 None
526 }
527
528 /// Mid-frame, last-resort space reclamation.
529 ///
530 /// Eviction must **never** move an entry that has already been handed out
531 /// this frame: the renderer's pre-pass caches each path's `AtlasRegion` in
532 /// `path_regions[..]` and reads it back later in the same frame, so moving
533 /// those pixels makes the cached region sample the wrong location (flicker
534 /// / wrong-pixel rendering on path-heavy widgets like LineChart and
535 /// PieChart). A shelf packer cannot reclaim the fragmented space held by
536 /// older entries without repacking the live ones, so:
537 ///
538 /// * If **no** region has been handed out this frame, clearing the whole
539 /// atlas is safe — do it (the next lookups re-rasterize from a clean
540 /// atlas, and `try_grow` already ran).
541 /// * If **any** region is live this frame, we leave the atlas untouched.
542 /// `allocate_and_write` then returns `None` and the path is skipped for
543 /// one frame — never corrupted.
544 ///
545 /// Steady-state reclamation that *does* repack happens in
546 /// [`PathAtlas::begin_frame`], where no region is live yet.
547 fn evict_lru(&mut self) {
548 if self.cache.is_empty() {
549 return;
550 }
551
552 let current = self.current_frame;
553 let any_live = self.cache.values().any(|r| r.last_used_frame == current);
554 if any_live {
555 // Can't reclaim without moving a live entry — bail out.
556 return;
557 }
558
559 // No live entries — safe to clear everything.
560 self.cache.clear();
561 self.pixels.fill(0);
562 self.shelf_x = 0;
563 self.shelf_y = 0;
564 self.shelf_height = 0;
565 self.dirty = true;
566 }
567
568 /// Drop every entry not used on or after `keep_from_frame` and repack the
569 /// survivors tightly from the top of the atlas.
570 ///
571 /// This **moves** surviving entries, so it is only sound when no
572 /// `AtlasRegion` has been handed out for the current frame yet — i.e. it
573 /// must be called only from [`PathAtlas::begin_frame`].
574 fn compact(&mut self, keep_from_frame: u64) {
575 // Read survivors out before we wipe the backing pixels. `read_region`
576 // and `cache.iter()` both borrow `&self` immutably, so this is fine.
577 let mut survivors: Vec<(PathCacheKey, AtlasRegion, Vec<u8>)> = self
578 .cache
579 .iter()
580 .filter(|(_, r)| r.last_used_frame >= keep_from_frame)
581 .map(|(k, r)| (*k, *r, self.read_region(*r)))
582 .collect();
583
584 self.cache.clear();
585 self.pixels.fill(0);
586 self.shelf_x = 0;
587 self.shelf_y = 0;
588 self.shelf_height = 0;
589 self.dirty = true;
590
591 // Repack tallest-first to limit shelf wastage.
592 survivors.sort_by_key(|(_, r, _)| std::cmp::Reverse(r.h));
593 for (key, old_region, pixels) in survivors {
594 if let Some(new_region) = self.try_allocate(old_region.w, old_region.h) {
595 self.blit(
596 new_region.x,
597 new_region.y,
598 new_region.w,
599 new_region.h,
600 &pixels,
601 );
602 self.cache.insert(
603 key,
604 AtlasRegion {
605 x: new_region.x,
606 y: new_region.y,
607 w: new_region.w,
608 h: new_region.h,
609 last_used_frame: old_region.last_used_frame,
610 },
611 );
612 }
613 }
614 }
615
616 /// Read a region's pixels back out of the atlas (for repacking
617 /// survivors during eviction). Returns an RGBA buffer of `w*h*4` bytes.
618 fn read_region(&self, region: AtlasRegion) -> Vec<u8> {
619 let mut out = vec![0u8; (region.w * region.h * 4) as usize];
620 for row in 0..region.h {
621 let src_start = ((region.y + row) * self.width * 4 + region.x * 4) as usize;
622 let src_end = src_start + (region.w * 4) as usize;
623 let dst_start = (row * region.w * 4) as usize;
624 let dst_end = dst_start + (region.w * 4) as usize;
625 if src_end <= self.pixels.len() && dst_end <= out.len() {
626 out[dst_start..dst_end].copy_from_slice(&self.pixels[src_start..src_end]);
627 }
628 }
629 out
630 }
631
632 /// Try to grow the atlas (double dimensions up to max_size).
633 fn try_grow(&mut self) -> bool {
634 let new_w = (self.width * 2).min(self.max_size);
635 let new_h = (self.height * 2).min(self.max_size);
636 if new_w == self.width && new_h == self.height {
637 return false; // Already at max
638 }
639 let mut new_pixels = vec![0u8; (new_w * new_h * 4) as usize];
640 // Copy existing data row by row
641 for y in 0..self.height {
642 let src_start = (y * self.width * 4) as usize;
643 let src_end = src_start + (self.width * 4) as usize;
644 let dst_start = (y * new_w * 4) as usize;
645 new_pixels[dst_start..dst_start + (self.width * 4) as usize]
646 .copy_from_slice(&self.pixels[src_start..src_end]);
647 }
648 self.pixels = new_pixels;
649 self.width = new_w;
650 self.height = new_h;
651 self.dirty = true;
652 true
653 }
654
655 /// Write pixels into the atlas at the given position.
656 fn blit(&mut self, x: u32, y: u32, w: u32, h: u32, pixels: &[u8]) {
657 for row in 0..h {
658 let src_start = (row * w * 4) as usize;
659 let src_end = src_start + (w * 4) as usize;
660 let dst_start = ((y + row) * self.width * 4 + x * 4) as usize;
661 let dst_end = dst_start + (w * 4) as usize;
662 if src_end <= pixels.len() && dst_end <= self.pixels.len() {
663 self.pixels[dst_start..dst_end].copy_from_slice(&pixels[src_start..src_end]);
664 }
665 }
666 }
667}
668
669/// Rasterize a path to RGBA pixels using tiny-skia, always as an
670/// **opaque-white AA coverage mask** (RGB = white, alpha = coverage).
671/// Color is intentionally not a parameter — see [`PathAtlas::lookup_or_rasterize`]:
672/// the mask is tinted/gradient-sampled by the GPU at draw time (matching
673/// `quad.wgsl`'s `flags = 0` monochrome-mask convention), so rasterization
674/// only needs to bake the geometry's AA coverage, letting solid and
675/// gradient fills of the same path share one atlas entry. This also fixes
676/// a pre-existing double-alpha bug: baking a translucent color into the
677/// bitmap AND multiplying by that same color's alpha again at draw time
678/// squared the effective alpha.
679///
680/// `geom_scale` scales the path **geometry** into the bitmap (= `scale_factor`
681/// for logical strokes, `scale_factor × zoom` for cosmetic ones so the body is
682/// sharp at the current zoom). `stroke_scale` scales the **stroke width** (=
683/// `scale_factor` always; for cosmetic strokes this bakes a zoom-independent
684/// device-pixel thickness). The two are equal for the logical/fill path.
685///
686/// `origin` is the bitmap's top-left in **device pixels**: a path point `p`
687/// lands at `p * geom_scale - origin`. It is a device-space origin rather
688/// than the path's own bounds because the caller may have snapped it to the
689/// pixel grid, and the bitmap has to be baked against the very grid the quad
690/// will be drawn on — see [`PathAtlas::lookup_or_rasterize`]. `w` / `h` are
691/// the bitmap's size in texels, likewise decided by the caller.
692#[allow(clippy::too_many_arguments)]
693fn rasterize_path(
694 path: &Path,
695 style: &StrokeStyle,
696 fill_rule: FillRule,
697 origin: [f32; 2],
698 w: u32,
699 h: u32,
700 geom_scale: f32,
701 stroke_scale: f32,
702) -> Option<Vec<u8>> {
703 if w == 0 || h == 0 {
704 return None;
705 }
706
707 let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
708
709 // Build the tiny-skia path in bitmap space. Scale first, then subtract
710 // the device-space origin — NOT the other way round: the origin may be
711 // snapped to a pixel the path's own bounds do not sit on, so it is not a
712 // multiple of `geom_scale` and cannot be folded into the path's units.
713 let bx = |x: f32| x * geom_scale - origin[0];
714 let by = |y: f32| y * geom_scale - origin[1];
715 let mut pb = tiny_skia::PathBuilder::new();
716 for cmd in &path.commands {
717 match *cmd {
718 PathCommand::MoveTo(p) => {
719 pb.move_to(bx(p.x), by(p.y));
720 }
721 PathCommand::LineTo(p) => {
722 pb.line_to(bx(p.x), by(p.y));
723 }
724 PathCommand::QuadTo { control, to } => {
725 pb.quad_to(bx(control.x), by(control.y), bx(to.x), by(to.y));
726 }
727 PathCommand::CubicTo {
728 control1,
729 control2,
730 to,
731 } => {
732 pb.cubic_to(
733 bx(control1.x),
734 by(control1.y),
735 bx(control2.x),
736 by(control2.y),
737 bx(to.x),
738 by(to.y),
739 );
740 }
741 PathCommand::ArcTo {
742 rect,
743 start_angle,
744 sweep_angle,
745 } => {
746 // Approximate arc with cubic Bézier segments
747 arc_to_cubics(
748 &mut pb,
749 rect.x,
750 rect.y,
751 rect.width,
752 rect.height,
753 start_angle,
754 sweep_angle,
755 geom_scale,
756 origin,
757 );
758 }
759 PathCommand::Close => {
760 pb.close();
761 }
762 }
763 }
764
765 let sk_path = pb.finish()?;
766
767 // Always opaque white — a pure AA coverage mask. Color/gradient tint
768 // is applied by the GPU at draw time (see this function's doc comment).
769 let paint = tiny_skia::Paint {
770 shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 1.0)?),
771 anti_alias: true,
772 ..Default::default()
773 };
774
775 if style.width > 0.0 {
776 // Stroke
777 let line_cap = match style.line_cap {
778 LineCap::Butt => tiny_skia::LineCap::Butt,
779 LineCap::Round => tiny_skia::LineCap::Round,
780 LineCap::Square => tiny_skia::LineCap::Square,
781 };
782 let line_join = match style.line_join {
783 LineJoin::Miter => tiny_skia::LineJoin::Miter,
784 LineJoin::Round => tiny_skia::LineJoin::Round,
785 LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
786 };
787 let dash = style
788 .dash_pattern
789 .as_ref()
790 .and_then(|pattern| tiny_skia::StrokeDash::new(pattern.clone(), style.dash_offset));
791 let stroke = tiny_skia::Stroke {
792 width: style.width * stroke_scale,
793 line_cap,
794 line_join,
795 miter_limit: style.miter_limit,
796 dash,
797 };
798 pixmap.stroke_path(
799 &sk_path,
800 &paint,
801 &stroke,
802 tiny_skia::Transform::identity(),
803 None,
804 );
805 } else {
806 // Fill
807 let sk_rule = match fill_rule {
808 FillRule::Winding => tiny_skia::FillRule::Winding,
809 FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
810 };
811 pixmap.fill_path(
812 &sk_path,
813 &paint,
814 sk_rule,
815 tiny_skia::Transform::identity(),
816 None,
817 );
818 }
819
820 Some(pixmap.data().to_vec())
821}
822
823/// Approximate an elliptical arc with cubic Bézier segments.
824/// Each 90° sweep is one cubic; smaller sweeps use one cubic.
825///
826/// `start_angle` and `sweep_angle` are in **degrees** (matching the
827/// public `Path::arc_to` API and existing call sites like
828/// `Path::circle` and `Path::rounded_rect`). They are converted to
829/// radians internally before being fed to `f32::cos`/`f32::sin`.
830///
831/// `cx` / `cy` are the arc rect's top-left in the path's own units; `origin`
832/// is the bitmap's top-left in device pixels, subtracted after scaling for
833/// the reason [`rasterize_path`] gives.
834#[allow(clippy::too_many_arguments)]
835fn arc_to_cubics(
836 pb: &mut tiny_skia::PathBuilder,
837 cx: f32,
838 cy: f32,
839 w: f32,
840 h: f32,
841 start_angle: f32,
842 sweep_angle: f32,
843 scale_factor: f32,
844 origin: [f32; 2],
845) {
846 let rx = w * 0.5;
847 let ry = h * 0.5;
848 let center_x = (cx + rx) * scale_factor - origin[0];
849 let center_y = (cy + ry) * scale_factor - origin[1];
850 let rx_s = rx * scale_factor;
851 let ry_s = ry * scale_factor;
852
853 let mut remaining = sweep_angle.to_radians();
854 let mut angle = start_angle.to_radians();
855 let sign = if remaining >= 0.0 { 1.0 } else { -1.0 };
856
857 while remaining.abs() > 0.001 {
858 let chunk = sign * remaining.abs().min(std::f32::consts::FRAC_PI_2);
859 let half = chunk * 0.5;
860 let k = (4.0 / 3.0) * (1.0 - half.cos()) / half.sin();
861
862 let cos_a = angle.cos();
863 let sin_a = angle.sin();
864 let cos_b = (angle + chunk).cos();
865 let sin_b = (angle + chunk).sin();
866
867 let p1x = center_x + rx_s * cos_a;
868 let p1y = center_y + ry_s * sin_a;
869 let p2x = center_x + rx_s * (cos_a - k * sin_a);
870 let p2y = center_y + ry_s * (sin_a + k * cos_a);
871 let p3x = center_x + rx_s * (cos_b + k * sin_b);
872 let p3y = center_y + ry_s * (sin_b - k * cos_b);
873 let p4x = center_x + rx_s * cos_b;
874 let p4y = center_y + ry_s * sin_b;
875
876 if (remaining - sweep_angle).abs() < 0.001 && pb.is_empty() {
877 // First segment of a subpath that opens with an arc (e.g. a bare
878 // `<circle>`): move_to its start point. tiny-skia would otherwise
879 // insert an implicit move_to(0,0) before this line_to and draw a
880 // stray line from the origin to the arc.
881 pb.move_to(p1x, p1y);
882 } else {
883 // Connect to the arc's start from the current point (a shared
884 // vertex on rounded rects / continued subpaths; a zero-length
885 // no-op when a move_to already placed us there).
886 pb.line_to(p1x, p1y);
887 }
888 pb.cubic_to(p2x, p2y, p3x, p3y, p4x, p4y);
889
890 angle += chunk;
891 remaining -= chunk;
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898 use teksilo_canvas::geometry::Point;
899
900 /// A path larger than the atlas can ever hold must be rejected **before** it is
901 /// rasterized — not after.
902 ///
903 /// The atlas grows only up to `max_size`, so `allocate_and_write` could never
904 /// store such a path: it was rasterized, discarded, and rasterized again on the
905 /// next frame, forever. The geometry below is the one that actually shipped the
906 /// freeze — a single 45° hazard band across a 7563px-tall overflow strip, whose
907 /// bounding box is a 229 MB bitmap. Redoing that every frame pinned the UI thread
908 /// at 100% CPU and the app never recovered.
909 ///
910 /// If this test ever hangs rather than fails, the guard is gone.
911 #[test]
912 fn a_path_too_big_for_the_atlas_is_never_rasterized() {
913 let mut atlas = PathAtlas::new(256, 256);
914
915 // The exact parallelogram from the freeze: height 7563, width 7563 + PITCH.
916 let (h, pitch) = (7563.0_f32, 10.0_f32);
917 let w = h + pitch;
918 let mut path = Path::new();
919 path.commands
920 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
921 path.commands
922 .push(PathCommand::LineTo(Point::new(pitch, 0.0)));
923 path.commands.push(PathCommand::LineTo(Point::new(w, h)));
924 path.commands.push(PathCommand::LineTo(Point::new(h, h)));
925 path.commands.push(PathCommand::Close);
926
927 let before = atlas.cache.len();
928 let region = atlas.lookup_or_rasterize(
929 &path,
930 &StrokeStyle::solid(0.0),
931 FillRule::Winding,
932 [0.0, 0.0, w, h],
933 1.0,
934 1.0,
935 false,
936 );
937
938 assert!(
939 region.is_none(),
940 "a {w}x{h} path cannot fit an atlas capped at {} — it must be skipped, \
941 not rasterized into a 229 MB bitmap that is then thrown away",
942 atlas.max_size
943 );
944 assert_eq!(
945 atlas.cache.len(),
946 before,
947 "the rejected path must not leave a cache entry behind"
948 );
949 // `is_none()` alone proves nothing: BEFORE the guard existed the call also
950 // returned None — it just rasterized 229 MB and failed to allocate first,
951 // which is precisely the bug. What must be asserted is that we bailed out
952 // *early*, so pin the counter that only the pre-raster guard increments.
953 assert_eq!(
954 atlas.oversize_skips(),
955 1,
956 "the path must be rejected BEFORE rasterizing; without the early guard \
957 this call still returns None, but only after building and discarding a \
958 229 MB bitmap — every frame, forever"
959 );
960 }
961
962 /// The guard rejects only what genuinely cannot fit: a path right at the limit
963 /// still rasterizes, so the bail-out cannot quietly swallow legitimate art.
964 #[test]
965 fn a_path_that_still_fits_the_atlas_is_rasterized() {
966 let mut atlas = PathAtlas::new(256, 256);
967 let side = atlas.max_size as f32; // exactly at the cap
968
969 let mut path = Path::new();
970 path.commands
971 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
972 path.commands
973 .push(PathCommand::LineTo(Point::new(side, 0.0)));
974 path.commands
975 .push(PathCommand::LineTo(Point::new(side, side)));
976 path.commands
977 .push(PathCommand::LineTo(Point::new(0.0, side)));
978 path.commands.push(PathCommand::Close);
979
980 let region = atlas.lookup_or_rasterize(
981 &path,
982 &StrokeStyle::solid(0.0),
983 FillRule::Winding,
984 [0.0, 0.0, side, side],
985 1.0,
986 1.0,
987 false,
988 );
989 assert!(
990 region.is_some(),
991 "a path exactly at max_size ({side}) must still be rasterized — the guard \
992 is for paths that can NEVER fit, not for merely large ones"
993 );
994 assert_eq!(
995 atlas.oversize_skips(),
996 0,
997 "the guard must not fire on a path that fits"
998 );
999 }
1000
1001 #[test]
1002 fn rasterize_simple_rect_path() {
1003 let mut path = Path::new();
1004 path.commands
1005 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1006 path.commands
1007 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1008 path.commands
1009 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1010 path.commands
1011 .push(PathCommand::LineTo(Point::new(0.0, 10.0)));
1012 path.commands.push(PathCommand::Close);
1013
1014 let style = StrokeStyle::solid(0.0);
1015 let pixels = rasterize_path(
1016 &path,
1017 &style,
1018 FillRule::Winding,
1019 [0.0, 0.0],
1020 10,
1021 10,
1022 1.0,
1023 1.0,
1024 );
1025 assert!(pixels.is_some());
1026 let px = pixels.unwrap();
1027 assert_eq!(px.len(), 10 * 10 * 4);
1028 // Center pixel should be opaque white (a pure coverage mask —
1029 // color is no longer baked into the bitmap, see C3).
1030 let center = (5 * 10 + 5) * 4;
1031 assert!(px[center] > 200); // R
1032 assert!(px[center + 1] > 200); // G
1033 assert!(px[center + 2] > 200); // B
1034 assert!(px[center + 3] > 200); // A (coverage)
1035 }
1036
1037 #[test]
1038 fn rasterize_stroke_path() {
1039 let mut path = Path::new();
1040 path.commands
1041 .push(PathCommand::MoveTo(Point::new(1.0, 5.0)));
1042 path.commands
1043 .push(PathCommand::LineTo(Point::new(9.0, 5.0)));
1044
1045 let style = StrokeStyle::solid(2.0);
1046 let pixels = rasterize_path(
1047 &path,
1048 &style,
1049 FillRule::Winding,
1050 [0.0, 0.0],
1051 10,
1052 10,
1053 1.0,
1054 1.0,
1055 );
1056 assert!(pixels.is_some());
1057 }
1058
1059 #[test]
1060 fn cache_key_distinguishes_line_join() {
1061 // Two strokes identical except for line join must NOT share a
1062 // cache entry — otherwise the atlas serves the first's pixels
1063 // for the second (the bug: line_join was honored in the
1064 // rasterizer but absent from the key).
1065 let mut path = Path::new();
1066 path.commands
1067 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1068 path.commands
1069 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1070 path.commands
1071 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1072
1073 let miter = StrokeStyle {
1074 line_join: LineJoin::Miter,
1075 ..StrokeStyle::solid(2.0)
1076 };
1077 let round = StrokeStyle {
1078 line_join: LineJoin::Round,
1079 ..StrokeStyle::solid(2.0)
1080 };
1081 assert_ne!(
1082 PathCacheKey::new(&path, &miter, FillRule::Winding, [0.0, 0.0], 12, 12),
1083 PathCacheKey::new(&path, &round, FillRule::Winding, [0.0, 0.0], 12, 12),
1084 "miter and round joins must hash to different cache keys"
1085 );
1086 }
1087
1088 #[test]
1089 fn cache_key_distinguishes_fill_rule() {
1090 // Winding vs even-odd produce different pixels for the same path, so
1091 // they must not share an atlas entry.
1092 let mut path = Path::new();
1093 path.commands
1094 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1095 path.commands
1096 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1097 path.commands
1098 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1099 path.commands.push(PathCommand::Close);
1100 let style = StrokeStyle::solid(0.0);
1101 assert_ne!(
1102 PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 12, 12),
1103 PathCacheKey::new(&path, &style, FillRule::EvenOdd, [0.0, 0.0], 12, 12),
1104 "winding and even-odd fills must hash to different cache keys"
1105 );
1106 }
1107
1108 /// A hairline icon stroke is the case the snap exists for.
1109 ///
1110 /// `Rect::expand` leaves a 16 dp ring's stroke-expanded bounds at
1111 /// `x = 1.5` (measured: the app's "no status" glyph is exactly this),
1112 /// so at scale factor 1 the quad used to be emitted at a half pixel and
1113 /// resampled through a linear sampler. The snap must round that outward
1114 /// to whole pixels AND size the bitmap to match, because a quad that is
1115 /// integral but a different size from its region is resampled just the
1116 /// same.
1117 #[test]
1118 fn a_snapped_path_draws_one_texel_per_device_pixel() {
1119 let mut atlas = PathAtlas::new(256, 256);
1120 atlas.begin_frame();
1121
1122 let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1123 let style = StrokeStyle::solid(1.0);
1124 let bounds = path.bounds().expand(style.width).to_array();
1125 assert_eq!(
1126 [bounds[0], bounds[1]],
1127 [1.5, 1.5],
1128 "the geometry this guards against: a half-pixel bounds origin"
1129 );
1130
1131 for sf in [1.0_f32, 1.2, 2.0] {
1132 let p = atlas
1133 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, sf, 1.0, true)
1134 .expect("ring rasterizes");
1135 let [x, y, w, h] = p.device_rect;
1136 assert_eq!(
1137 [x, y, w, h],
1138 [x.floor(), y.floor(), w.floor(), h.floor()],
1139 "sf {sf}: a snapped quad must land on whole device pixels"
1140 );
1141 assert_eq!(
1142 (w as u32, h as u32),
1143 (p.region.w, p.region.h),
1144 "sf {sf}: the quad must be exactly as many pixels as the region \
1145 has texels, or the mask is resampled even on the integer grid"
1146 );
1147 assert!(
1148 x <= bounds[0] * sf && x + w >= (bounds[0] + bounds[2]) * sf,
1149 "sf {sf}: snapping must grow the rect outward, never clip the path"
1150 );
1151 }
1152 }
1153
1154 /// The other half of the contract: under a transform the caller passes
1155 /// `snap: false`, and the placement must be exactly what it always was.
1156 /// Snapping there would be wrong twice over — the mask is being resampled
1157 /// by the transform anyway, and rounding a translating path's origin
1158 /// makes it step between pixels instead of gliding.
1159 #[test]
1160 fn an_unsnapped_path_keeps_the_raw_rect() {
1161 let mut atlas = PathAtlas::new(256, 256);
1162 atlas.begin_frame();
1163
1164 let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1165 let style = StrokeStyle::solid(1.0);
1166 let bounds = path.bounds().expand(style.width).to_array();
1167
1168 let p = atlas
1169 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1170 .expect("ring rasterizes");
1171 assert_eq!(p.device_rect, [1.5, 1.5, 13.0, 13.0]);
1172 assert_eq!((p.region.w, p.region.h), (13, 13));
1173 }
1174
1175 /// The same path, snapped and unsnapped, must not share one bitmap.
1176 ///
1177 /// Both rasterize at 13×13 here, and the path's commands are identical
1178 /// (they are absolute, so position alone never separates them), so
1179 /// without the raster origin in the key the second lookup would be
1180 /// served the first's phase.
1181 #[test]
1182 fn cache_key_distinguishes_the_snapped_phase() {
1183 let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1184 let style = StrokeStyle::solid(1.0);
1185 assert_ne!(
1186 PathCacheKey::new(&path, &style, FillRule::Winding, [1.0, 1.0], 13, 13),
1187 PathCacheKey::new(&path, &style, FillRule::Winding, [1.5, 1.5], 13, 13),
1188 "a snapped and an unsnapped raster of one path must key apart"
1189 );
1190 }
1191
1192 /// Two entries must never share an edge.
1193 ///
1194 /// The atlas sampler is bilinear and each quad's UVs run to its region's
1195 /// outer edge, so an edge fragment of a quad that is not pixel-exact on
1196 /// its region reads one texel past it. Packed edge to edge, that texel
1197 /// belonged to a different icon.
1198 #[test]
1199 fn atlas_entries_never_touch() {
1200 let mut atlas = PathAtlas::new(256, 256);
1201 atlas.begin_frame();
1202
1203 let style = StrokeStyle::solid(0.0);
1204 let mut placed: Vec<AtlasRegion> = Vec::new();
1205 for i in 0..6 {
1206 let mut path = Path::new();
1207 let side = 10.0 + i as f32;
1208 path.commands
1209 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1210 path.commands
1211 .push(PathCommand::LineTo(Point::new(side, 0.0)));
1212 path.commands
1213 .push(PathCommand::LineTo(Point::new(side, side)));
1214 path.commands.push(PathCommand::Close);
1215 let p = atlas
1216 .lookup_or_rasterize(
1217 &path,
1218 &style,
1219 FillRule::Winding,
1220 [0.0, 0.0, side, side],
1221 1.0,
1222 1.0,
1223 true,
1224 )
1225 .expect("rasterizes");
1226 placed.push(p.region);
1227 }
1228
1229 for (i, a) in placed.iter().enumerate() {
1230 for (j, b) in placed.iter().enumerate() {
1231 if i >= j {
1232 continue;
1233 }
1234 // Grow each region by the gutter and require they still
1235 // don't overlap: that is exactly "at least one transparent
1236 // texel apart on every side".
1237 let overlaps = a.x < b.x + b.w + ENTRY_GUTTER_PX
1238 && b.x < a.x + a.w + ENTRY_GUTTER_PX
1239 && a.y < b.y + b.h + ENTRY_GUTTER_PX
1240 && b.y < a.y + a.h + ENTRY_GUTTER_PX;
1241 assert!(
1242 !overlaps,
1243 "entries {i} {a:?} and {j} {b:?} are packed closer than the gutter"
1244 );
1245 }
1246 }
1247 }
1248
1249 #[test]
1250 fn atlas_cache_hit() {
1251 let mut atlas = PathAtlas::new(256, 256);
1252 atlas.begin_frame();
1253
1254 let mut path = Path::new();
1255 path.commands
1256 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1257 path.commands
1258 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1259 path.commands
1260 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1261 path.commands.push(PathCommand::Close);
1262
1263 let style = StrokeStyle::solid(0.0);
1264 let bounds = [0.0, 0.0, 10.0, 10.0];
1265
1266 let r1 = atlas
1267 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1268 .unwrap();
1269 let r2 = atlas
1270 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1271 .unwrap();
1272
1273 // Same region (cache hit)
1274 assert_eq!(r1.region.x, r2.region.x);
1275 assert_eq!(r1.region.y, r2.region.y);
1276 }
1277
1278 #[test]
1279 fn cache_hit_is_independent_of_color() {
1280 // C3: color is no longer part of the rasterization or the cache
1281 // key — two lookups with identical geometry/stroke/size but
1282 // DIFFERENT colors (as the caller would pass via the paint,
1283 // before this refactor) must now hit the SAME atlas entry, since
1284 // `lookup_or_rasterize` no longer takes a color at all. This is
1285 // what lets a solid fill and a gradient fill of the same path
1286 // share one atlas entry.
1287 let mut atlas = PathAtlas::new(256, 256);
1288 atlas.begin_frame();
1289
1290 let mut path = Path::new();
1291 path.commands
1292 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1293 path.commands
1294 .push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1295 path.commands
1296 .push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1297 path.commands.push(PathCommand::Close);
1298
1299 let style = StrokeStyle::solid(0.0);
1300 let bounds = [0.0, 0.0, 10.0, 10.0];
1301
1302 // Simulate two draw calls that would previously have carried
1303 // different colors — the API no longer distinguishes them, so
1304 // both lookups are for the exact same cache key.
1305 let r1 = atlas
1306 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1307 .expect("first lookup rasterizes and caches");
1308 let r2 = atlas
1309 .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1310 .expect("second lookup hits the same cache entry");
1311
1312 assert_eq!(r1.region.x, r2.region.x, "cache hit: same region x");
1313 assert_eq!(r1.region.y, r2.region.y, "cache hit: same region y");
1314 assert_eq!(r1.region.w, r2.region.w);
1315 assert_eq!(r1.region.h, r2.region.h);
1316 assert_eq!(atlas.cache.len(), 1, "only one atlas entry for both calls");
1317 }
1318
1319 #[test]
1320 fn atlas_begin_frame_advances() {
1321 let mut atlas = PathAtlas::new(256, 256);
1322 assert_eq!(atlas.current_frame, 0);
1323 atlas.begin_frame();
1324 assert_eq!(atlas.current_frame, 1);
1325 atlas.begin_frame();
1326 assert_eq!(atlas.current_frame, 2);
1327 }
1328
1329 #[test]
1330 fn atlas_eviction_clears_stale() {
1331 let mut atlas = PathAtlas::new(64, 64);
1332
1333 let mut path = Path::new();
1334 path.commands
1335 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1336 path.commands
1337 .push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1338 path.commands
1339 .push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1340 path.commands.push(PathCommand::Close);
1341 let style = StrokeStyle::solid(0.0);
1342 let bounds = [0.0, 0.0, 8.0, 8.0];
1343
1344 atlas.begin_frame(); // frame 1
1345 atlas.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false);
1346
1347 // Advance well past the entry
1348 atlas.begin_frame(); // frame 2
1349 atlas.begin_frame(); // frame 3
1350 atlas.begin_frame(); // frame 4
1351
1352 // Eviction should clear it
1353 atlas.evict_lru();
1354 assert!(atlas.cache.is_empty());
1355 }
1356
1357 #[test]
1358 fn evict_preserves_current_frame_entries() {
1359 // Regression: previously `evict_lru` cleared the entire cache,
1360 // so a second path inserted in the same frame could displace
1361 // the first — `path_regions[0]` ended up pointing at pixels
1362 // that now belonged to path #2. LineChart and PieChart hit this
1363 // routinely because their paths cover most of the plot area.
1364 let mut atlas = PathAtlas::new(64, 64);
1365 atlas.begin_frame();
1366
1367 let mut p1 = Path::new();
1368 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1369 p1.commands.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1370 p1.commands
1371 .push(PathCommand::LineTo(Point::new(40.0, 40.0)));
1372 p1.commands.push(PathCommand::Close);
1373
1374 let mut p2 = Path::new();
1375 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1376 p2.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1377 p2.commands
1378 .push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1379 p2.commands.push(PathCommand::Close);
1380
1381 let style = StrokeStyle::solid(0.0);
1382 let r1 = atlas
1383 .lookup_or_rasterize(
1384 &p1,
1385 &style,
1386 FillRule::Winding,
1387 [0.0, 0.0, 40.0, 40.0],
1388 1.0,
1389 1.0,
1390 false,
1391 )
1392 .expect("p1 fits");
1393
1394 // p2 doesn't fit in the remaining space → eviction triggers.
1395 // After the fix, p1 (current-frame) survives and gets repacked.
1396 let _r2 = atlas.lookup_or_rasterize(
1397 &p2,
1398 &style,
1399 FillRule::Winding,
1400 [0.0, 0.0, 50.0, 50.0],
1401 1.0,
1402 1.0,
1403 false,
1404 );
1405
1406 // Looking up p1 again must still hit cache (with possibly a new
1407 // region, but stable across the lookup).
1408 let r1b = atlas
1409 .lookup_or_rasterize(
1410 &p1,
1411 &style,
1412 FillRule::Winding,
1413 [0.0, 0.0, 40.0, 40.0],
1414 1.0,
1415 1.0,
1416 false,
1417 )
1418 .expect("p1 still cached after eviction");
1419 // The repacked region may have moved, but lookup_or_rasterize
1420 // must return a non-None region for p1 — i.e. it wasn't lost.
1421 let _ = (r1, r1b);
1422 assert!(atlas.cache.contains_key(&PathCacheKey::new(
1423 &p1,
1424 &style,
1425 FillRule::Winding,
1426 [0.0, 0.0],
1427 40,
1428 40,
1429 )));
1430 }
1431
1432 #[test]
1433 fn evict_never_moves_live_entry_when_full() {
1434 // Core invariant for the stale-UV fix: once a region is handed out
1435 // this frame it is frozen. If a later path can't fit and the atlas is
1436 // already at max size, the new path is skipped (returns None) — the
1437 // live entry must NOT be repacked, or `path_regions[..]` would sample
1438 // the wrong pixels later in the same frame.
1439 let mut atlas = PathAtlas::new(64, 64);
1440 atlas.max_size = 64; // forbid growth so eviction is the only path
1441 atlas.begin_frame();
1442
1443 let mut p1 = Path::new();
1444 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1445 p1.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1446 p1.commands
1447 .push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1448 p1.commands.push(PathCommand::Close);
1449
1450 let mut p2 = Path::new();
1451 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1452 p2.commands.push(PathCommand::LineTo(Point::new(62.0, 0.0)));
1453 p2.commands
1454 .push(PathCommand::LineTo(Point::new(62.0, 62.0)));
1455 p2.commands.push(PathCommand::Close);
1456
1457 let style = StrokeStyle::solid(0.0);
1458 let r1 = atlas
1459 .lookup_or_rasterize(
1460 &p1,
1461 &style,
1462 FillRule::Winding,
1463 [0.0, 0.0, 60.0, 60.0],
1464 1.0,
1465 1.0,
1466 false,
1467 )
1468 .expect("p1 fits");
1469
1470 // p2 can't fit, can't grow → must be skipped, not placed by moving p1.
1471 let r2 = atlas.lookup_or_rasterize(
1472 &p2,
1473 &style,
1474 FillRule::Winding,
1475 [0.0, 0.0, 62.0, 62.0],
1476 1.0,
1477 1.0,
1478 false,
1479 );
1480 assert!(
1481 r2.is_none(),
1482 "an unfittable path is skipped, never placed by evicting a live entry"
1483 );
1484
1485 // p1's region is byte-for-byte unchanged.
1486 let r1b = atlas
1487 .lookup_or_rasterize(
1488 &p1,
1489 &style,
1490 FillRule::Winding,
1491 [0.0, 0.0, 60.0, 60.0],
1492 1.0,
1493 1.0,
1494 false,
1495 )
1496 .expect("p1 still cached");
1497 assert_eq!(r1.region.x, r1b.region.x, "live entry must not move");
1498 assert_eq!(r1.region.y, r1b.region.y, "live entry must not move");
1499 }
1500
1501 #[test]
1502 fn begin_frame_compacts_stale_entries() {
1503 // `begin_frame` is the safe point to repack: nothing is handed out
1504 // for the new frame yet. A near-full atlas with entries not used on
1505 // the last completed frame compacts them away.
1506 let mut atlas = PathAtlas::new(64, 64);
1507 atlas.begin_frame(); // frame 1
1508
1509 let mut path = Path::new();
1510 path.commands
1511 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1512 path.commands
1513 .push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1514 path.commands
1515 .push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1516 path.commands.push(PathCommand::Close);
1517 let style = StrokeStyle::solid(0.0);
1518 atlas
1519 .lookup_or_rasterize(
1520 &path,
1521 &style,
1522 FillRule::Winding,
1523 [0.0, 0.0, 8.0, 8.0],
1524 1.0,
1525 1.0,
1526 false,
1527 )
1528 .expect("entry fits");
1529 assert_eq!(atlas.cache.len(), 1);
1530
1531 atlas.begin_frame(); // frame 2 — keep_from = 1, entry (used f1) kept
1532 assert_eq!(
1533 atlas.cache.len(),
1534 1,
1535 "entry from the last completed frame is kept"
1536 );
1537
1538 atlas.begin_frame(); // frame 3 — keep_from = 2, entry (used f1) is stale
1539 assert!(
1540 atlas.cache.is_empty(),
1541 "stale entry compacted away on begin_frame"
1542 );
1543 }
1544
1545 #[test]
1546 fn atlas_grow() {
1547 let mut atlas = PathAtlas::new(16, 16);
1548 assert!(atlas.try_grow());
1549 assert_eq!(atlas.width, 32);
1550 assert_eq!(atlas.height, 32);
1551 }
1552
1553 #[test]
1554 fn growth_preserves_earlier_frame_regions() {
1555 // Regression: when a single frame inserts more paths than fit in
1556 // the initial atlas, we must grow rather than evict — eviction
1557 // repacks current-frame survivors at fresh coordinates,
1558 // invalidating any AtlasRegion the renderer already cached for
1559 // them earlier in the same frame. With grow-first, the first
1560 // entry's region stays valid throughout the frame.
1561 let mut atlas = PathAtlas::new(64, 64);
1562 atlas.begin_frame();
1563
1564 let mut p1 = Path::new();
1565 p1.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1566 p1.commands.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1567 p1.commands
1568 .push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1569 p1.commands.push(PathCommand::Close);
1570
1571 let mut p2 = Path::new();
1572 p2.commands.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1573 p2.commands.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1574 p2.commands
1575 .push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1576 p2.commands.push(PathCommand::Close);
1577
1578 let style = StrokeStyle::solid(0.0);
1579 let r1 = atlas
1580 .lookup_or_rasterize(
1581 &p1,
1582 &style,
1583 FillRule::Winding,
1584 [0.0, 0.0, 50.0, 50.0],
1585 1.0,
1586 1.0,
1587 false,
1588 )
1589 .expect("p1 fits");
1590
1591 // p2 doesn't fit alongside p1 in 64×64 → atlas should grow,
1592 // not evict. After growth, p1's region must still be at the
1593 // same coordinates we got back the first time.
1594 let _r2 = atlas
1595 .lookup_or_rasterize(
1596 &p2,
1597 &style,
1598 FillRule::Winding,
1599 [0.0, 0.0, 60.0, 60.0],
1600 1.0,
1601 1.0,
1602 false,
1603 )
1604 .expect("p2 fits after grow");
1605
1606 let r1_after = atlas
1607 .lookup_or_rasterize(
1608 &p1,
1609 &style,
1610 FillRule::Winding,
1611 [0.0, 0.0, 50.0, 50.0],
1612 1.0,
1613 1.0,
1614 false,
1615 )
1616 .expect("p1 still cached");
1617 assert_eq!(
1618 r1.region.x, r1_after.region.x,
1619 "p1 must not move when atlas grows"
1620 );
1621 assert_eq!(
1622 r1.region.y, r1_after.region.y,
1623 "p1 must not move when atlas grows"
1624 );
1625 }
1626
1627 #[test]
1628 fn cosmetic_path_raster_is_zoom_aware_logical_is_not() {
1629 // A cosmetic stroke rasterizes its body at the view zoom (so it stays
1630 // sharp and matches the transform-scaled display quad 1:1) — the
1631 // raster dimensions scale with zoom. A logical stroke ignores zoom
1632 // (one bitmap, stretched by the quad), so its raster size and cache
1633 // entry are zoom-independent.
1634 let mut atlas = PathAtlas::new(512, 512);
1635 atlas.begin_frame();
1636 let mut path = Path::new();
1637 path.commands
1638 .push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1639 path.commands
1640 .push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1641 let bounds = [0.0, 0.0, 40.0, 4.0];
1642
1643 let cosmetic = StrokeStyle::hairline(2.0);
1644 let r1 = atlas
1645 .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 1.0, false)
1646 .unwrap();
1647 let r2 = atlas
1648 .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 2.0, false)
1649 .unwrap();
1650 assert_eq!(r1.region.w, 40, "cosmetic body at zoom 1: 40·sf1·zoom1");
1651 assert_eq!(
1652 r2.region.w, 80,
1653 "cosmetic body at zoom 2: 40·sf1·zoom2 (zoom-aware)"
1654 );
1655
1656 let logical = StrokeStyle::solid(2.0);
1657 let l1 = atlas
1658 .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 1.0, false)
1659 .unwrap();
1660 let l2 = atlas
1661 .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 4.0, false)
1662 .unwrap();
1663 assert_eq!(l1.region.w, l2.region.w, "logical raster size ignores zoom");
1664 assert_eq!(
1665 (l1.region.x, l1.region.y),
1666 (l2.region.x, l2.region.y),
1667 "logical hits the same cache entry"
1668 );
1669
1670 // Same width/dims but different stroke space must not collide.
1671 let k_cos = PathCacheKey::new(&path, &cosmetic, FillRule::Winding, [0.0, 0.0], 40, 4);
1672 let k_log = PathCacheKey::new(&path, &logical, FillRule::Winding, [0.0, 0.0], 40, 4);
1673 assert_ne!(
1674 k_cos, k_log,
1675 "cache key must distinguish cosmetic vs logical"
1676 );
1677 }
1678}