Skip to main content

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}