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