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    ///
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}