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