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::geometry::{Point, Rect};
10use teksilo_canvas::paint::{FillRule, LineCap, LineJoin, StrokeSpace, StrokeStyle};
11use teksilo_canvas::path::{Path, PathCommand};
12
13/// Upper bound on a cosmetic path's rasterized dimension (device px). At
14/// extreme zoom the body would otherwise exceed the atlas; beyond this the
15/// body softens and the stroke drifts slightly off-cosmetic — an accepted
16/// degradation far past normal zoom. Kept well under [`PathAtlas::max_size`]
17/// (4096) to leave room for shelf packing.
18const MAX_COSMETIC_RASTER_DIM: f32 = 2048.0;
19
20/// Free vertical headroom (device px) below which `begin_frame` treats the
21/// atlas as near-full and compacts. Roughly one tall shelf — enough that a
22/// frame rarely runs out of room mid-walk (where reclaiming is unsafe).
23const COMPACT_SLACK_PX: u32 = 256;
24
25/// Transparent margin reserved after each entry, so no two entries touch.
26///
27/// The atlas is sampled with `FilterMode::Linear` and each quad's UVs run to
28/// its region's outer edge. Whenever a quad is not pixel-exact on its region
29/// — any path under a transform, where snapping is deliberately off (see
30/// [`PathAtlas::lookup_or_rasterize`]) — an edge fragment's bilinear kernel
31/// reaches past the region, and edge-to-edge packing made that the
32/// *neighbouring icon's* pixels. One transparent row and column keeps the
33/// worst case a fade to nothing rather than a smear of unrelated ink. The
34/// glyph atlas has always reserved the same gutter.
35const ENTRY_GUTTER_PX: u32 = 1;
36
37/// A region within the atlas texture.
38#[derive(Debug, Clone, Copy)]
39pub struct AtlasRegion {
40    pub x: u32,
41    pub y: u32,
42    pub w: u32,
43    pub h: u32,
44    /// Frame when this region was last used.
45    last_used_frame: u64,
46}
47
48/// A rasterized path plus the **exact** rect it must be drawn at.
49///
50/// The two travel together because they are one decision, not two. The atlas
51/// bitmap is rasterized on its own integer grid; if the quad that samples it
52/// is placed or sized even slightly differently, every texel is resampled
53/// through the atlas's `FilterMode::Linear` and the coverage mask smears.
54/// A 16 dp line-style icon does not survive that: a 1 px stroke drawn at a
55/// half-pixel offset peaks at **48 % coverage** instead of 100 %, and
56/// sub-pixel dash gaps close up entirely, so a dashed ring renders as a grey
57/// haze. Returning the rect from the same call that decides the raster is
58/// what stops the two from ever disagreeing again.
59///
60/// See [`PathAtlas::lookup_or_rasterize`] for when the rect is snapped.
61#[derive(Debug, Clone, Copy)]
62pub struct PathPlacement {
63    /// Where the coverage mask lives in the atlas texture.
64    pub region: AtlasRegion,
65    /// `[x, y, w, h]` in **pre-transform device pixels** — the quad the
66    /// caller must emit. When snapped this is integral and exactly
67    /// `region.w × region.h`, so the mask samples 1:1 onto whole pixels.
68    pub device_rect: [f32; 4],
69}
70
71/// Cache key derived from path geometry + stroke style + rasterized size +
72/// the device-space origin the bitmap was baked against + the geometry
73/// scale it was baked at.
74///
75/// `geom_scale` is in the key because it is a rasterization input the size
76/// does not always recover: `w`/`h` are the bounds *ceiled* into texels, so
77/// a sub-pixel path aliases several scales onto one bitmap size, and a
78/// cosmetic stroke's `geom_scale` moves continuously with the view zoom.
79/// It scales the dash pattern (a length along the path), so a collision
80/// would serve a bitmap whose dashes are cut for a different zoom.
81///
82/// Deliberately does **not** include color: the atlas now always
83/// rasterizes an opaque-white AA coverage mask (see [`rasterize_path`]),
84/// so a solid fill and a gradient fill of identical geometry share one
85/// atlas entry — the color/gradient tint is applied by the GPU at draw
86/// time, not baked into the bitmap.
87///
88/// # The geometry is read as one word, not walked
89///
90/// [`Path::stamp`] is a rolling 64-bit fold of the command list, maintained
91/// as commands are appended, so building the key is O(1) in the path's
92/// length. It used to hash every `PathCommand`, which made a **cache hit**
93/// cost O(n) — linear in the point count, with the measured table in
94/// docs/ink.md §5, the one place those figures are written down.
95/// That is the whole of the ink cliff — a wet stroke that grows by a point
96/// per pointer sample paid a full walk every frame just to discover the
97/// bitmap it wanted was already resident, so one stroke was quadratic before
98/// the rasterizer was even reached.
99///
100/// The stamp is content-addressed exactly as the walk was: two paths with the
101/// same commands produce the same key, two with different commands collide
102/// with 64-bit probability. Nothing else about the cache's semantics moves.
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
104struct PathCacheKey(u64);
105
106impl PathCacheKey {
107    fn new(
108        path: &Path,
109        style: &StrokeStyle,
110        fill_rule: FillRule,
111        origin: [f32; 2],
112        w: u32,
113        h: u32,
114        geom_scale: f32,
115    ) -> Self {
116        let mut hasher = std::hash::DefaultHasher::new();
117        // The path's geometry, as one word. See this type's doc comment.
118        path.stamp().hash(&mut hasher);
119        // Hash stroke style
120        style.width.to_bits().hash(&mut hasher);
121        std::mem::discriminant(&style.line_cap).hash(&mut hasher);
122        std::mem::discriminant(&style.line_join).hash(&mut hasher);
123        if let Some(ref pattern) = style.dash_pattern {
124            for &v in pattern {
125                v.to_bits().hash(&mut hasher);
126            }
127        }
128        style.dash_offset.to_bits().hash(&mut hasher);
129        style.miter_limit.to_bits().hash(&mut hasher);
130        // Cosmetic vs logical strokes bake differently (constant device width
131        // vs zoom-scaled), so they must not share a cache entry.
132        std::mem::discriminant(&style.space).hash(&mut hasher);
133        // Winding vs even-odd fill produce different pixels for the same path.
134        std::mem::discriminant(&fill_rule).hash(&mut hasher);
135        // Hash rasterized dimensions
136        w.hash(&mut hasher);
137        h.hash(&mut hasher);
138        // And the device-space origin the bitmap was baked against. The
139        // path's own commands are absolute, so two *different* paths already
140        // key apart — but the SAME path drawn once under the identity
141        // transform (snapped to the pixel grid) and once under a transform
142        // (not snapped) wants two different bitmaps at the same dimensions.
143        // Without the origin here the second draw would silently reuse the
144        // first's phase.
145        origin[0].to_bits().hash(&mut hasher);
146        origin[1].to_bits().hash(&mut hasher);
147        // And the scale the geometry (and the dash pattern along it) was
148        // baked at — see this type's doc comment for why `w`/`h` don't
149        // already say it.
150        geom_scale.to_bits().hash(&mut hasher);
151        PathCacheKey(hasher.finish())
152    }
153}
154
155/// Shelf-packed atlas for rasterized paths with LRU eviction.
156pub struct PathAtlas {
157    /// Atlas pixel data (RGBA).
158    pixels: Vec<u8>,
159    width: u32,
160    height: u32,
161    /// Maximum atlas dimension.
162    ///
163    /// The default is the size this renderer wants; [`Self::cap_max_size`]
164    /// lowers it to what the device can actually create.
165    max_size: u32,
166    /// Cache from path key to atlas region.
167    cache: HashMap<PathCacheKey, AtlasRegion>,
168    /// Current frame counter for LRU.
169    current_frame: u64,
170    /// Whether the atlas texture needs re-uploading.
171    dirty: bool,
172    // Shelf-packing state
173    /// Current Y position of the next shelf.
174    shelf_y: u32,
175    /// Current X position within the current shelf.
176    shelf_x: u32,
177    /// Height of the current shelf (tallest entry in this row).
178    shelf_height: u32,
179    /// How many paths have been skipped because they could never fit the atlas.
180    ///
181    /// Such a path is simply not drawn. That is a silent hole in the frame, so it is
182    /// counted rather than swallowed: a non-zero value means some geometry is being
183    /// asked to rasterize larger than [`max_size`](Self::max_size), which is almost
184    /// always a layout bug upstream (see [`Self::lookup_or_rasterize`]).
185    oversize_skips: u64,
186}
187
188impl PathAtlas {
189    /// Create a new path atlas with the given initial dimensions.
190    pub fn new(width: u32, height: u32) -> Self {
191        Self {
192            pixels: vec![0; (width * height * 4) as usize],
193            width,
194            height,
195            max_size: 4096,
196            cache: HashMap::new(),
197            current_frame: 0,
198            dirty: false,
199            shelf_y: 0,
200            shelf_x: 0,
201            shelf_height: 0,
202            oversize_skips: 0,
203        }
204    }
205
206    /// Lower the growth cap to what the GPU can actually allocate.
207    ///
208    /// The 4096 default is this renderer's own ceiling, not a fact about the
209    /// hardware. `Limits::downlevel_defaults` guarantees only 2048, and the
210    /// window path can legitimately open a device on an adapter's own limits,
211    /// which on GLES-3-class hardware may sit below 4096. Growing past what the
212    /// device allows is a `create_texture` validation error at the first
213    /// path-heavy frame — a crash on the machine least able to report it.
214    ///
215    /// Only ever lowers: a device that allows more than this renderer asks for
216    /// does not get a bigger atlas, because the cap is also a memory bound.
217    pub fn cap_max_size(&mut self, device_max: u32) {
218        self.max_size = self.max_size.min(device_max);
219    }
220
221    /// How many paths have been skipped for being too large to ever fit the atlas.
222    ///
223    /// Each one is a path that simply was not drawn. Non-zero means some geometry is
224    /// rasterizing bigger than `max_size` — upstream, that is a
225    /// layout that has run away (an overlay spanning a whole scrolled document, a
226    /// shape scaled by a runaway transform), and it is worth chasing rather than
227    /// leaving as a hole in the frame.
228    pub fn oversize_skips(&self) -> u64 {
229        self.oversize_skips
230    }
231
232    /// How many distinct masks are resident.
233    ///
234    /// The observable side of the cache key: two draws that share a key share
235    /// an entry, and two that do not each get one. A test asserting that
236    /// identical geometry is not rasterized twice reads this.
237    pub fn entry_count(&self) -> usize {
238        self.cache.len()
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(
423            path,
424            style,
425            fill_rule,
426            raster_origin,
427            raster_w,
428            raster_h,
429            geom_scale,
430        );
431
432        // Cache hit
433        if let Some(region) = self.cache.get_mut(&key) {
434            region.last_used_frame = self.current_frame;
435            return Some(PathPlacement {
436                region: *region,
437                device_rect,
438            });
439        }
440
441        // Rasterize — always opaque white; see PathCacheKey and this
442        // function's doc comment for why color is not a parameter.
443        let pixels = rasterize_path(
444            path,
445            style,
446            fill_rule,
447            raster_origin,
448            raster_w,
449            raster_h,
450            geom_scale,
451            stroke_scale,
452        )?;
453        let region = self.allocate_and_write(key, raster_w, raster_h, &pixels)?;
454        Some(PathPlacement {
455            region,
456            device_rect,
457        })
458    }
459
460    /// Try to allocate space in the atlas via shelf packing.
461    ///
462    /// Strategy, in order:
463    ///   1. Try the current shelf / a new shelf at the existing size.
464    ///   2. Grow the atlas (doubles up to `max_size`). Growth preserves
465    ///      every existing entry's `(x, y)` so any `AtlasRegion` values
466    ///      handed out earlier in the same render pass stay valid.
467    ///   3. Last resort, evict. Eviction never moves entries already handed
468    ///      out this frame (that would invalidate `AtlasRegion`s the caller
469    ///      cached earlier in the same render walk → wrong-pixel sampling). It
470    ///      can only reclaim space when nothing has been handed out yet this
471    ///      frame; otherwise the allocation fails and the path is skipped for
472    ///      this frame. Steady-state reclamation happens safely in
473    ///      [`PathAtlas::begin_frame`] (compaction) before any region is
474    ///      handed out.
475    fn allocate_and_write(
476        &mut self,
477        key: PathCacheKey,
478        w: u32,
479        h: u32,
480        pixels: &[u8],
481    ) -> Option<AtlasRegion> {
482        if let Some(region) = self.try_allocate(w, h) {
483            self.blit(region.x, region.y, w, h, pixels);
484            self.cache.insert(key, region);
485            self.dirty = true;
486            return Some(region);
487        }
488
489        // Grow first — keeps every existing entry at the same coordinates.
490        while self.try_grow() {
491            if let Some(region) = self.try_allocate(w, h) {
492                self.blit(region.x, region.y, w, h, pixels);
493                self.cache.insert(key, region);
494                self.dirty = true;
495                return Some(region);
496            }
497        }
498
499        // Atlas at max size and still no room. Try eviction — but it will
500        // refuse to move any entry already handed out this frame, so if the
501        // frame's live working set already fills a max-size atlas this is a
502        // no-op and we return `None` (the path is skipped this frame, which is
503        // correct: it genuinely doesn't fit). It never corrupts placed paths.
504        self.evict_lru();
505        if let Some(region) = self.try_allocate(w, h) {
506            self.blit(region.x, region.y, w, h, pixels);
507            self.cache.insert(key, region);
508            self.dirty = true;
509            return Some(region);
510        }
511
512        None
513    }
514
515    /// Try to allocate a region using shelf packing.
516    fn try_allocate(&mut self, w: u32, h: u32) -> Option<AtlasRegion> {
517        // The region is `w × h`; the shelf cursor advances past a further
518        // `ENTRY_GUTTER_PX` so the next entry cannot abut this one. Only the
519        // region has to fit — a gutter running off the right edge costs
520        // nothing, since the cursor is past the edge either way.
521        if self.shelf_x + w <= self.width && self.shelf_y + h.max(self.shelf_height) <= self.height
522        {
523            let region = AtlasRegion {
524                x: self.shelf_x,
525                y: self.shelf_y,
526                w,
527                h,
528                last_used_frame: self.current_frame,
529            };
530            self.shelf_x += w + ENTRY_GUTTER_PX;
531            self.shelf_height = self.shelf_height.max(h + ENTRY_GUTTER_PX);
532            return Some(region);
533        }
534
535        // Start a new shelf
536        let new_y = self.shelf_y + self.shelf_height;
537        if w <= self.width && new_y + h <= self.height {
538            self.shelf_y = new_y;
539            self.shelf_x = w + ENTRY_GUTTER_PX;
540            self.shelf_height = h + ENTRY_GUTTER_PX;
541            let region = AtlasRegion {
542                x: 0,
543                y: new_y,
544                w,
545                h,
546                last_used_frame: self.current_frame,
547            };
548            return Some(region);
549        }
550
551        None
552    }
553
554    /// Mid-frame, last-resort space reclamation.
555    ///
556    /// Eviction must **never** move an entry that has already been handed out
557    /// this frame: the renderer's pre-pass caches each path's `AtlasRegion` in
558    /// `path_placements[..]` and reads it back later in the same frame, so moving
559    /// those pixels makes the cached region sample the wrong location (flicker
560    /// / wrong-pixel rendering on path-heavy widgets like LineChart and
561    /// PieChart). A shelf packer cannot reclaim the fragmented space held by
562    /// older entries without repacking the live ones, so:
563    ///
564    /// * If **no** region has been handed out this frame, clearing the whole
565    ///   atlas is safe — do it (the next lookups re-rasterize from a clean
566    ///   atlas, and `try_grow` already ran).
567    /// * If **any** region is live this frame, we leave the atlas untouched.
568    ///   `allocate_and_write` then returns `None` and the path is skipped for
569    ///   one frame — never corrupted.
570    ///
571    /// Steady-state reclamation that *does* repack happens in
572    /// [`PathAtlas::begin_frame`], where no region is live yet.
573    fn evict_lru(&mut self) {
574        if self.cache.is_empty() {
575            return;
576        }
577
578        let current = self.current_frame;
579        let any_live = self.cache.values().any(|r| r.last_used_frame == current);
580        if any_live {
581            // Can't reclaim without moving a live entry — bail out.
582            return;
583        }
584
585        // No live entries — safe to clear everything.
586        self.cache.clear();
587        self.pixels.fill(0);
588        self.shelf_x = 0;
589        self.shelf_y = 0;
590        self.shelf_height = 0;
591        self.dirty = true;
592    }
593
594    /// Drop every entry not used on or after `keep_from_frame` and repack the
595    /// survivors tightly from the top of the atlas.
596    ///
597    /// This **moves** surviving entries, so it is only sound when no
598    /// `AtlasRegion` has been handed out for the current frame yet — i.e. it
599    /// must be called only from [`PathAtlas::begin_frame`].
600    fn compact(&mut self, keep_from_frame: u64) {
601        // Read survivors out before we wipe the backing pixels. `read_region`
602        // and `cache.iter()` both borrow `&self` immutably, so this is fine.
603        let mut survivors: Vec<(PathCacheKey, AtlasRegion, Vec<u8>)> = self
604            .cache
605            .iter()
606            .filter(|(_, r)| r.last_used_frame >= keep_from_frame)
607            .map(|(k, r)| (*k, *r, self.read_region(*r)))
608            .collect();
609
610        self.cache.clear();
611        self.pixels.fill(0);
612        self.shelf_x = 0;
613        self.shelf_y = 0;
614        self.shelf_height = 0;
615        self.dirty = true;
616
617        // Repack tallest-first to limit shelf wastage.
618        survivors.sort_by_key(|(_, r, _)| std::cmp::Reverse(r.h));
619        for (key, old_region, pixels) in survivors {
620            if let Some(new_region) = self.try_allocate(old_region.w, old_region.h) {
621                self.blit(
622                    new_region.x,
623                    new_region.y,
624                    new_region.w,
625                    new_region.h,
626                    &pixels,
627                );
628                self.cache.insert(
629                    key,
630                    AtlasRegion {
631                        x: new_region.x,
632                        y: new_region.y,
633                        w: new_region.w,
634                        h: new_region.h,
635                        last_used_frame: old_region.last_used_frame,
636                    },
637                );
638            }
639        }
640    }
641
642    /// Read a region's pixels back out of the atlas (for repacking
643    /// survivors during compaction). Returns an RGBA buffer of `w*h*4` bytes.
644    fn read_region(&self, region: AtlasRegion) -> Vec<u8> {
645        let mut out = vec![0u8; (region.w * region.h * 4) as usize];
646        for row in 0..region.h {
647            let src_start = ((region.y + row) * self.width * 4 + region.x * 4) as usize;
648            let src_end = src_start + (region.w * 4) as usize;
649            let dst_start = (row * region.w * 4) as usize;
650            let dst_end = dst_start + (region.w * 4) as usize;
651            if src_end <= self.pixels.len() && dst_end <= out.len() {
652                out[dst_start..dst_end].copy_from_slice(&self.pixels[src_start..src_end]);
653            }
654        }
655        out
656    }
657
658    /// Try to grow the atlas (double dimensions up to max_size).
659    fn try_grow(&mut self) -> bool {
660        let new_w = (self.width * 2).min(self.max_size);
661        let new_h = (self.height * 2).min(self.max_size);
662        if new_w == self.width && new_h == self.height {
663            return false; // Already at max
664        }
665        let mut new_pixels = vec![0u8; (new_w * new_h * 4) as usize];
666        // Copy existing data row by row
667        for y in 0..self.height {
668            let src_start = (y * self.width * 4) as usize;
669            let src_end = src_start + (self.width * 4) as usize;
670            let dst_start = (y * new_w * 4) as usize;
671            new_pixels[dst_start..dst_start + (self.width * 4) as usize]
672                .copy_from_slice(&self.pixels[src_start..src_end]);
673        }
674        self.pixels = new_pixels;
675        self.width = new_w;
676        self.height = new_h;
677        self.dirty = true;
678        true
679    }
680
681    /// Write pixels into the atlas at the given position.
682    fn blit(&mut self, x: u32, y: u32, w: u32, h: u32, pixels: &[u8]) {
683        for row in 0..h {
684            let src_start = (row * w * 4) as usize;
685            let src_end = src_start + (w * 4) as usize;
686            let dst_start = ((y + row) * self.width * 4 + x * 4) as usize;
687            let dst_end = dst_start + (w * 4) as usize;
688            if src_end <= pixels.len() && dst_end <= self.pixels.len() {
689                self.pixels[dst_start..dst_end].copy_from_slice(&pixels[src_start..src_end]);
690            }
691        }
692    }
693}
694
695/// Rasterize a path to RGBA pixels using tiny-skia, always as an
696/// **opaque-white AA coverage mask** (RGB = white, alpha = coverage).
697/// Color is intentionally not a parameter — see [`PathAtlas::lookup_or_rasterize`]:
698/// the mask is tinted/gradient-sampled by the GPU at draw time (matching
699/// `quad.wgsl`'s `flags = 0` monochrome-mask convention), so rasterization
700/// only needs to bake the geometry's AA coverage, letting solid and
701/// gradient fills of the same path share one atlas entry. This also fixes
702/// a pre-existing double-alpha bug: baking a translucent color into the
703/// bitmap AND multiplying by that same color's alpha again at draw time
704/// squared the effective alpha.
705///
706/// `geom_scale` scales the path **geometry** into the bitmap (= `scale_factor`
707/// for logical strokes, `scale_factor × zoom` for cosmetic ones so the body is
708/// sharp at the current zoom). `stroke_scale` scales the **stroke width** (=
709/// `scale_factor` always; for cosmetic strokes this bakes a zoom-independent
710/// device-pixel thickness). The two are equal for the logical/fill path.
711///
712/// `origin` is the bitmap's top-left in **device pixels**: a path point `p`
713/// lands at `p * geom_scale - origin`. It is a device-space origin rather
714/// than the path's own bounds because the caller may have snapped it to the
715/// pixel grid, and the bitmap has to be baked against the very grid the quad
716/// will be drawn on — see [`PathAtlas::lookup_or_rasterize`]. `w` / `h` are
717/// the bitmap's size in texels, likewise decided by the caller.
718#[allow(clippy::too_many_arguments)]
719fn rasterize_path(
720    path: &Path,
721    style: &StrokeStyle,
722    fill_rule: FillRule,
723    origin: [f32; 2],
724    w: u32,
725    h: u32,
726    geom_scale: f32,
727    stroke_scale: f32,
728) -> Option<Vec<u8>> {
729    if w == 0 || h == 0 {
730        return None;
731    }
732
733    let mut pixmap = tiny_skia::Pixmap::new(w, h)?;
734
735    let sk_path = build_sk_path(path, geom_scale, origin)?;
736
737    // Always opaque white — a pure AA coverage mask. Color/gradient tint
738    // is applied by the GPU at draw time (see this function's doc comment).
739    let paint = tiny_skia::Paint {
740        shader: tiny_skia::Shader::SolidColor(tiny_skia::Color::from_rgba(1.0, 1.0, 1.0, 1.0)?),
741        anti_alias: true,
742        ..Default::default()
743    };
744
745    if style.width > 0.0 {
746        // Stroke
747        let line_cap = match style.line_cap {
748            LineCap::Butt => tiny_skia::LineCap::Butt,
749            LineCap::Round => tiny_skia::LineCap::Round,
750            LineCap::Square => tiny_skia::LineCap::Square,
751        };
752        let line_join = match style.line_join {
753            LineJoin::Miter => tiny_skia::LineJoin::Miter,
754            LineJoin::Round => tiny_skia::LineJoin::Round,
755            LineJoin::Bevel => tiny_skia::LineJoin::Bevel,
756        };
757        // Dash lengths are measured ALONG the path, so they live in the
758        // path's units and must be scaled by `geom_scale` — the same factor
759        // the geometry was baked with — not by `stroke_scale`, which is the
760        // across-the-path thickness. Passing the pattern unscaled made a
761        // dash shorter by exactly `1 / geom_scale`: a `dashed(2, 4, 4)` line
762        // showed 3 dashes at scale 1 and 5 at scale 2, so every dashed
763        // stroke in the framework — chart gridlines included — was drawn
764        // with half-length dashes on a 2× HiDPI display, and a cosmetic
765        // dashed stroke re-cut its pattern on every zoom step.
766        //
767        // `StrokeSpace::Device` still only pins the *thickness*: the doc on
768        // `StrokeStyle::hairline` says position follows the full transform,
769        // and a longitudinal measure is position, not thickness. So a
770        // cosmetic dashed connector zooms its dashes with its geometry while
771        // holding its width.
772        let dash = style.dash_pattern.as_ref().and_then(|pattern| {
773            tiny_skia::StrokeDash::new(
774                pattern.iter().map(|d| d * geom_scale).collect(),
775                style.dash_offset * geom_scale,
776            )
777        });
778        let stroke = tiny_skia::Stroke {
779            width: style.width * stroke_scale,
780            line_cap,
781            line_join,
782            miter_limit: style.miter_limit,
783            dash,
784        };
785        pixmap.stroke_path(
786            &sk_path,
787            &paint,
788            &stroke,
789            tiny_skia::Transform::identity(),
790            None,
791        );
792    } else {
793        // Fill
794        let sk_rule = match fill_rule {
795            FillRule::Winding => tiny_skia::FillRule::Winding,
796            FillRule::EvenOdd => tiny_skia::FillRule::EvenOdd,
797        };
798        pixmap.fill_path(
799            &sk_path,
800            &paint,
801            sk_rule,
802            tiny_skia::Transform::identity(),
803            None,
804        );
805    }
806
807    Some(pixmap.data().to_vec())
808}
809
810/// Translate a [`Path`] into a tiny-skia path in bitmap space.
811///
812/// Scale first, then subtract the device-space `origin` — NOT the other way
813/// round: the origin may be snapped to a pixel the path's own bounds do not
814/// sit on, so it is not a multiple of `geom_scale` and cannot be folded into
815/// the path's units.
816///
817/// The command walk mirrors [`Path::flatten`]'s: the flattener is the oracle
818/// for where a subpath begins, because it is what the scene tier hit-tests
819/// against. A rasteriser that opened a subpath somewhere else would paint ink
820/// no click could reach — see [`emit_arc`], the one command where tiny-skia's
821/// own defaults do not already agree.
822fn build_sk_path(path: &Path, geom_scale: f32, origin: [f32; 2]) -> Option<tiny_skia::Path> {
823    let bx = |x: f32| x * geom_scale - origin[0];
824    let by = |y: f32| y * geom_scale - origin[1];
825    let mut pb = tiny_skia::PathBuilder::new();
826    let mut cursor = SubpathCursor::new();
827    for cmd in path.commands() {
828        match *cmd {
829            PathCommand::MoveTo(p) => {
830                pb.move_to(bx(p.x), by(p.y));
831                cursor.open_at(p);
832            }
833            PathCommand::LineTo(p) => {
834                open_implicit(&mut pb, &cursor, geom_scale, origin);
835                pb.line_to(bx(p.x), by(p.y));
836                cursor.extend_to(p);
837            }
838            PathCommand::QuadTo { control, to } => {
839                open_implicit(&mut pb, &cursor, geom_scale, origin);
840                pb.quad_to(bx(control.x), by(control.y), bx(to.x), by(to.y));
841                cursor.extend_to(to);
842            }
843            PathCommand::CubicTo {
844                control1,
845                control2,
846                to,
847            } => {
848                open_implicit(&mut pb, &cursor, geom_scale, origin);
849                pb.cubic_to(
850                    bx(control1.x),
851                    by(control1.y),
852                    bx(control2.x),
853                    by(control2.y),
854                    bx(to.x),
855                    by(to.y),
856                );
857                cursor.extend_to(to);
858            }
859            PathCommand::ArcTo {
860                rect,
861                start_angle,
862                sweep_angle,
863            } => {
864                emit_arc(
865                    &mut pb,
866                    &mut cursor,
867                    rect,
868                    start_angle,
869                    sweep_angle,
870                    geom_scale,
871                    origin,
872                );
873            }
874            PathCommand::Close => {
875                pb.close();
876                cursor.close();
877            }
878        }
879    }
880    pb.finish()
881}
882
883/// The subpath bookkeeping [`Path::flatten`] keeps as it walks a path's
884/// commands, mirrored so the rasteriser can ask the same question the
885/// flattener asks: *is a subpath open, and where is its current point?*
886///
887/// Every segment command consults it, because tiny-skia's
888/// `inject_move_to_if_needed` and `flatten` open an implicit subpath at two
889/// different points. tiny-skia opens at the last `MoveTo` **as fed to the
890/// builder**, i.e. already in bitmap space, and at bitmap `(0, 0)` for a path
891/// that has no `MoveTo` at all; `flatten` opens at the current point in the
892/// path's **own** units. After a [`PathCommand::Close`] the two agree, because
893/// the last `MoveTo` is the point `flatten` re-seeds its cursor with. For a
894/// path opening on a bare segment they agree only at `origin == [0, 0]` — and
895/// the atlas pins the origin to zero unless the path carries a negative
896/// coordinate, which is precisely when the divergence becomes reachable.
897///
898/// So the walk opens every implicit subpath explicitly, at the point `flatten`
899/// would open it, and tiny-skia's injection never fires.
900#[derive(Debug, Clone, Copy)]
901struct SubpathCursor {
902    /// Current point, in the path's own units.
903    at: Point,
904    /// First point of the subpath being built, in the path's own units.
905    start: Point,
906    /// Whether a subpath is open — `flatten`'s `!current.is_empty()`.
907    open: bool,
908}
909
910/// Open a subpath at the flattener's current point when a segment arrives with
911/// none open, so tiny-skia never injects one of its own at bitmap `(0, 0)`.
912///
913/// A no-op while a subpath is open, which is every command in a well-formed
914/// path.
915fn open_implicit(
916    pb: &mut tiny_skia::PathBuilder,
917    cursor: &SubpathCursor,
918    geom_scale: f32,
919    origin: [f32; 2],
920) {
921    if !cursor.open {
922        pb.move_to(
923            cursor.at.x * geom_scale - origin[0],
924            cursor.at.y * geom_scale - origin[1],
925        );
926    }
927}
928
929impl SubpathCursor {
930    fn new() -> Self {
931        Self {
932            at: Point::ZERO,
933            start: Point::ZERO,
934            open: false,
935        }
936    }
937
938    /// A `MoveTo`: end any open subpath and start one at `p`.
939    fn open_at(&mut self, p: Point) {
940        self.at = p;
941        self.start = p;
942        self.open = true;
943    }
944
945    /// A segment drawn *from the current point*. When no subpath is open both
946    /// `flatten` and tiny-skia open one at that current point, so the start
947    /// is the cursor, not `to`.
948    fn extend_to(&mut self, to: Point) {
949        if !self.open {
950            self.start = self.at;
951            self.open = true;
952        }
953        self.at = to;
954    }
955
956    /// A `Close`: the current point returns to the subpath's start, and the
957    /// next segment opens a fresh subpath.
958    fn close(&mut self) {
959        if self.open {
960            self.at = self.start;
961            self.open = false;
962        }
963    }
964}
965
966/// Feed one [`PathCommand::ArcTo`] into a tiny-skia path builder, scaled into
967/// bitmap space.
968///
969/// The arc → cubic maths lives once, in [`teksilo_canvas::arc_to_cubics`] —
970/// `Path::flatten` and the scene tier's hit-testing read the same conversion,
971/// so a rendered arc and a clicked arc can never be different curves. This
972/// function's remaining jobs are the atlas's own — scale each returned point
973/// by `scale_factor`, subtract the bitmap `origin`, emit — plus the one place
974/// tiny-skia's defaults diverge from the flattener: **where the subpath
975/// starts.**
976///
977/// An arc is the only command that can begin somewhere other than the current
978/// point, so it is the only one that has to say whether it *opens* a subpath
979/// or *continues* one. `flatten` opens at the arc's own first point; tiny-skia's
980/// `line_to` would instead inject a `MoveTo` of its own — `(0, 0)` **in bitmap
981/// space** for a path that opens with a bare arc (so the phantom vertex sits
982/// at the atlas bitmap's top-left corner and moves with the scale factor), or
983/// the *previous* subpath's start after a `Close`. Either way the emitted
984/// spoke is ink `Path::contains_point` cannot see: a lone 90° arc came out as
985/// a filled triangle spanning the whole bitmap, 30× the area of the quarter-arc
986/// the shape reports. So open the subpath explicitly, and keep the straight
987/// connector only where `flatten` keeps one — when a subpath is already open
988/// and its current point is not the arc's start (a rounded rect's edge running
989/// into its corner).
990///
991/// `start_angle` and `sweep_angle` are in **degrees**, matching the public
992/// `Path::arc_to` API and its call sites (`Path::circle`,
993/// `Path::rounded_rect`). `rect` is the arc's bounding rectangle in the path's
994/// own units; `origin` is the bitmap's top-left in device pixels, subtracted
995/// after scaling for the reason [`build_sk_path`] gives.
996fn emit_arc(
997    pb: &mut tiny_skia::PathBuilder,
998    cursor: &mut SubpathCursor,
999    rect: Rect,
1000    start_angle: f32,
1001    sweep_angle: f32,
1002    scale_factor: f32,
1003    origin: [f32; 2],
1004) {
1005    let map = |p: Point| {
1006        (
1007            p.x * scale_factor - origin[0],
1008            p.y * scale_factor - origin[1],
1009        )
1010    };
1011    for seg in teksilo_canvas::arc_to_cubics(rect, start_angle, sweep_angle) {
1012        let (sx, sy) = map(seg.from);
1013        if !cursor.open {
1014            pb.move_to(sx, sy);
1015            cursor.open_at(seg.from);
1016        } else if cursor.at != seg.from {
1017            pb.line_to(sx, sy);
1018        }
1019        let (c1x, c1y) = map(seg.control1);
1020        let (c2x, c2y) = map(seg.control2);
1021        let (tx, ty) = map(seg.to);
1022        pb.cubic_to(c1x, c1y, c2x, c2y, tx, ty);
1023        cursor.extend_to(seg.to);
1024    }
1025}
1026
1027#[cfg(test)]
1028mod tests {
1029    use super::*;
1030    use teksilo_canvas::geometry::Point;
1031
1032    /// The device cap lowers the growth ceiling and never raises it.
1033    ///
1034    /// Both directions matter. A device that allows less than this renderer
1035    /// wants must win, or the first atlas growth past its
1036    /// `max_texture_dimension_2d` is a `create_texture` validation error — a
1037    /// crash on downlevel hardware. A device that allows *more* must not win,
1038    /// because the ceiling is also the memory bound: a 16384-capable GPU is not
1039    /// an invitation to spend 1 GiB on rasterized paths.
1040    #[test]
1041    fn cap_max_size_only_lowers() {
1042        let mut atlas = PathAtlas::new(512, 512);
1043        let default_cap = atlas.max_size;
1044
1045        atlas.cap_max_size(16384);
1046        assert_eq!(
1047            atlas.max_size, default_cap,
1048            "a device with more headroom must not raise the renderer's own ceiling"
1049        );
1050
1051        atlas.cap_max_size(2048);
1052        assert_eq!(
1053            atlas.max_size, 2048,
1054            "a device that allows less than the renderer wants must lower the ceiling"
1055        );
1056
1057        atlas.cap_max_size(4096);
1058        assert_eq!(
1059            atlas.max_size, 2048,
1060            "capping is a floor-taking operation, so it never undoes an earlier cap"
1061        );
1062    }
1063
1064    /// Growth stops at the capped size, not at the compiled-in default.
1065    ///
1066    /// `cap_max_size` would be decorative if `grow` still doubled past it.
1067    #[test]
1068    fn growth_honours_the_device_cap() {
1069        let mut atlas = PathAtlas::new(512, 512);
1070        atlas.cap_max_size(1024);
1071
1072        while atlas.try_grow() {}
1073
1074        assert!(
1075            atlas.width <= 1024 && atlas.height <= 1024,
1076            "atlas grew to {}x{}, past the device cap of 1024",
1077            atlas.width,
1078            atlas.height
1079        );
1080    }
1081
1082    /// A path larger than the atlas can ever hold must be rejected **before** it is
1083    /// rasterized — not after.
1084    ///
1085    /// The atlas grows only up to `max_size`, so `allocate_and_write` could never
1086    /// store such a path: it was rasterized, discarded, and rasterized again on the
1087    /// next frame, forever. The geometry below is the one that actually shipped the
1088    /// freeze — a single 45° hazard band across a 7563px-tall overflow strip, whose
1089    /// bounding box is a 229 MB bitmap. Redoing that every frame pinned the UI thread
1090    /// at 100% CPU and the app never recovered.
1091    ///
1092    /// If this test ever hangs rather than fails, the guard is gone.
1093    #[test]
1094    fn a_path_too_big_for_the_atlas_is_never_rasterized() {
1095        let mut atlas = PathAtlas::new(256, 256);
1096
1097        // The exact parallelogram from the freeze: height 7563, width 7563 + PITCH.
1098        let (h, pitch) = (7563.0_f32, 10.0_f32);
1099        let w = h + pitch;
1100        let mut path = Path::new();
1101        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1102        path.push(PathCommand::LineTo(Point::new(pitch, 0.0)));
1103        path.push(PathCommand::LineTo(Point::new(w, h)));
1104        path.push(PathCommand::LineTo(Point::new(h, h)));
1105        path.push(PathCommand::Close);
1106
1107        let before = atlas.cache.len();
1108        let region = atlas.lookup_or_rasterize(
1109            &path,
1110            &StrokeStyle::solid(0.0),
1111            FillRule::Winding,
1112            [0.0, 0.0, w, h],
1113            1.0,
1114            1.0,
1115            false,
1116        );
1117
1118        assert!(
1119            region.is_none(),
1120            "a {w}x{h} path cannot fit an atlas capped at {} — it must be skipped, \
1121             not rasterized into a 229 MB bitmap that is then thrown away",
1122            atlas.max_size
1123        );
1124        assert_eq!(
1125            atlas.cache.len(),
1126            before,
1127            "the rejected path must not leave a cache entry behind"
1128        );
1129        // `is_none()` alone proves nothing: BEFORE the guard existed the call also
1130        // returned None — it just rasterized 229 MB and failed to allocate first,
1131        // which is precisely the bug. What must be asserted is that we bailed out
1132        // *early*, so pin the counter that only the pre-raster guard increments.
1133        assert_eq!(
1134            atlas.oversize_skips(),
1135            1,
1136            "the path must be rejected BEFORE rasterizing; without the early guard \
1137             this call still returns None, but only after building and discarding a \
1138             229 MB bitmap — every frame, forever"
1139        );
1140    }
1141
1142    /// The guard rejects only what genuinely cannot fit: a path right at the limit
1143    /// still rasterizes, so the bail-out cannot quietly swallow legitimate art.
1144    #[test]
1145    fn a_path_that_still_fits_the_atlas_is_rasterized() {
1146        let mut atlas = PathAtlas::new(256, 256);
1147        let side = atlas.max_size as f32; // exactly at the cap
1148
1149        let mut path = Path::new();
1150        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1151        path.push(PathCommand::LineTo(Point::new(side, 0.0)));
1152        path.push(PathCommand::LineTo(Point::new(side, side)));
1153        path.push(PathCommand::LineTo(Point::new(0.0, side)));
1154        path.push(PathCommand::Close);
1155
1156        let region = atlas.lookup_or_rasterize(
1157            &path,
1158            &StrokeStyle::solid(0.0),
1159            FillRule::Winding,
1160            [0.0, 0.0, side, side],
1161            1.0,
1162            1.0,
1163            false,
1164        );
1165        assert!(
1166            region.is_some(),
1167            "a path exactly at max_size ({side}) must still be rasterized — the guard \
1168             is for paths that can NEVER fit, not for merely large ones"
1169        );
1170        assert_eq!(
1171            atlas.oversize_skips(),
1172            0,
1173            "the guard must not fire on a path that fits"
1174        );
1175    }
1176
1177    #[test]
1178    fn rasterize_simple_rect_path() {
1179        let mut path = Path::new();
1180        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1181        path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1182        path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1183        path.push(PathCommand::LineTo(Point::new(0.0, 10.0)));
1184        path.push(PathCommand::Close);
1185
1186        let style = StrokeStyle::solid(0.0);
1187        let pixels = rasterize_path(
1188            &path,
1189            &style,
1190            FillRule::Winding,
1191            [0.0, 0.0],
1192            10,
1193            10,
1194            1.0,
1195            1.0,
1196        );
1197        assert!(pixels.is_some());
1198        let px = pixels.unwrap();
1199        assert_eq!(px.len(), 10 * 10 * 4);
1200        // Center pixel should be opaque white (a pure coverage mask —
1201        // color is no longer baked into the bitmap, see C3).
1202        let center = (5 * 10 + 5) * 4;
1203        assert!(px[center] > 200); // R
1204        assert!(px[center + 1] > 200); // G
1205        assert!(px[center + 2] > 200); // B
1206        assert!(px[center + 3] > 200); // A (coverage)
1207    }
1208
1209    #[test]
1210    fn rasterize_stroke_path() {
1211        let mut path = Path::new();
1212        path.push(PathCommand::MoveTo(Point::new(1.0, 5.0)));
1213        path.push(PathCommand::LineTo(Point::new(9.0, 5.0)));
1214
1215        let style = StrokeStyle::solid(2.0);
1216        let pixels = rasterize_path(
1217            &path,
1218            &style,
1219            FillRule::Winding,
1220            [0.0, 0.0],
1221            10,
1222            10,
1223            1.0,
1224            1.0,
1225        );
1226        assert!(pixels.is_some());
1227    }
1228
1229    #[test]
1230    fn cache_key_distinguishes_line_join() {
1231        // Two strokes identical except for line join must NOT share a
1232        // cache entry — otherwise the atlas serves the first's pixels
1233        // for the second (the bug: line_join was honored in the
1234        // rasterizer but absent from the key).
1235        let mut path = Path::new();
1236        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1237        path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1238        path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1239
1240        let miter = StrokeStyle {
1241            line_join: LineJoin::Miter,
1242            ..StrokeStyle::solid(2.0)
1243        };
1244        let round = StrokeStyle {
1245            line_join: LineJoin::Round,
1246            ..StrokeStyle::solid(2.0)
1247        };
1248        assert_ne!(
1249            PathCacheKey::new(&path, &miter, FillRule::Winding, [0.0, 0.0], 12, 12, 1.0),
1250            PathCacheKey::new(&path, &round, FillRule::Winding, [0.0, 0.0], 12, 12, 1.0),
1251            "miter and round joins must hash to different cache keys"
1252        );
1253    }
1254
1255    #[test]
1256    fn cache_key_distinguishes_fill_rule() {
1257        // Winding vs even-odd produce different pixels for the same path, so
1258        // they must not share an atlas entry.
1259        let mut path = Path::new();
1260        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1261        path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1262        path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1263        path.push(PathCommand::Close);
1264        let style = StrokeStyle::solid(0.0);
1265        assert_ne!(
1266            PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 12, 12, 1.0),
1267            PathCacheKey::new(&path, &style, FillRule::EvenOdd, [0.0, 0.0], 12, 12, 1.0),
1268            "winding and even-odd fills must hash to different cache keys"
1269        );
1270    }
1271
1272    /// A hairline icon stroke is the case the snap exists for.
1273    ///
1274    /// `Rect::expand` leaves a 16 dp ring's stroke-expanded bounds at
1275    /// `x = 1.5` (measured: the app's "no status" glyph is exactly this),
1276    /// so at scale factor 1 the quad used to be emitted at a half pixel and
1277    /// resampled through a linear sampler. The snap must round that outward
1278    /// to whole pixels AND size the bitmap to match, because a quad that is
1279    /// integral but a different size from its region is resampled just the
1280    /// same.
1281    #[test]
1282    fn a_snapped_path_draws_one_texel_per_device_pixel() {
1283        let mut atlas = PathAtlas::new(256, 256);
1284        atlas.begin_frame();
1285
1286        let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1287        let style = StrokeStyle::solid(1.0);
1288        let bounds = path.bounds().expand(style.width).to_array();
1289        assert_eq!(
1290            [bounds[0], bounds[1]],
1291            [1.5, 1.5],
1292            "the geometry this guards against: a half-pixel bounds origin"
1293        );
1294
1295        for sf in [1.0_f32, 1.2, 2.0] {
1296            let p = atlas
1297                .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, sf, 1.0, true)
1298                .expect("ring rasterizes");
1299            let [x, y, w, h] = p.device_rect;
1300            assert_eq!(
1301                [x, y, w, h],
1302                [x.floor(), y.floor(), w.floor(), h.floor()],
1303                "sf {sf}: a snapped quad must land on whole device pixels"
1304            );
1305            assert_eq!(
1306                (w as u32, h as u32),
1307                (p.region.w, p.region.h),
1308                "sf {sf}: the quad must be exactly as many pixels as the region \
1309                 has texels, or the mask is resampled even on the integer grid"
1310            );
1311            assert!(
1312                x <= bounds[0] * sf && x + w >= (bounds[0] + bounds[2]) * sf,
1313                "sf {sf}: snapping must grow the rect outward, never clip the path"
1314            );
1315        }
1316    }
1317
1318    /// The other half of the contract: under a transform the caller passes
1319    /// `snap: false`, and the placement must be exactly what it always was.
1320    /// Snapping there would be wrong twice over — the mask is being resampled
1321    /// by the transform anyway, and rounding a translating path's origin
1322    /// makes it step between pixels instead of gliding.
1323    #[test]
1324    fn an_unsnapped_path_keeps_the_raw_rect() {
1325        let mut atlas = PathAtlas::new(256, 256);
1326        atlas.begin_frame();
1327
1328        let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1329        let style = StrokeStyle::solid(1.0);
1330        let bounds = path.bounds().expand(style.width).to_array();
1331
1332        let p = atlas
1333            .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1334            .expect("ring rasterizes");
1335        assert_eq!(p.device_rect, [1.5, 1.5, 13.0, 13.0]);
1336        assert_eq!((p.region.w, p.region.h), (13, 13));
1337    }
1338
1339    /// The same path, snapped and unsnapped, must not share one bitmap.
1340    ///
1341    /// Both rasterize at 13×13 here, and the path's commands are identical
1342    /// (they are absolute, so position alone never separates them), so
1343    /// without the raster origin in the key the second lookup would be
1344    /// served the first's phase.
1345    #[test]
1346    fn cache_key_distinguishes_the_snapped_phase() {
1347        let path = Path::circle(Point::new(8.0, 8.0), 5.5);
1348        let style = StrokeStyle::solid(1.0);
1349        assert_ne!(
1350            PathCacheKey::new(&path, &style, FillRule::Winding, [1.0, 1.0], 13, 13, 1.0),
1351            PathCacheKey::new(&path, &style, FillRule::Winding, [1.5, 1.5], 13, 13, 1.0),
1352            "a snapped and an unsnapped raster of one path must key apart"
1353        );
1354    }
1355
1356    /// Two entries must never share an edge.
1357    ///
1358    /// The atlas sampler is bilinear and each quad's UVs run to its region's
1359    /// outer edge, so an edge fragment of a quad that is not pixel-exact on
1360    /// its region reads one texel past it. Packed edge to edge, that texel
1361    /// belonged to a different icon.
1362    #[test]
1363    fn atlas_entries_never_touch() {
1364        let mut atlas = PathAtlas::new(256, 256);
1365        atlas.begin_frame();
1366
1367        let style = StrokeStyle::solid(0.0);
1368        let mut placed: Vec<AtlasRegion> = Vec::new();
1369        for i in 0..6 {
1370            let mut path = Path::new();
1371            let side = 10.0 + i as f32;
1372            path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1373            path.push(PathCommand::LineTo(Point::new(side, 0.0)));
1374            path.push(PathCommand::LineTo(Point::new(side, side)));
1375            path.push(PathCommand::Close);
1376            let p = atlas
1377                .lookup_or_rasterize(
1378                    &path,
1379                    &style,
1380                    FillRule::Winding,
1381                    [0.0, 0.0, side, side],
1382                    1.0,
1383                    1.0,
1384                    true,
1385                )
1386                .expect("rasterizes");
1387            placed.push(p.region);
1388        }
1389
1390        for (i, a) in placed.iter().enumerate() {
1391            for (j, b) in placed.iter().enumerate() {
1392                if i >= j {
1393                    continue;
1394                }
1395                // Grow each region by the gutter and require they still
1396                // don't overlap: that is exactly "at least one transparent
1397                // texel apart on every side".
1398                let overlaps = a.x < b.x + b.w + ENTRY_GUTTER_PX
1399                    && b.x < a.x + a.w + ENTRY_GUTTER_PX
1400                    && a.y < b.y + b.h + ENTRY_GUTTER_PX
1401                    && b.y < a.y + a.h + ENTRY_GUTTER_PX;
1402                assert!(
1403                    !overlaps,
1404                    "entries {i} {a:?} and {j} {b:?} are packed closer than the gutter"
1405                );
1406            }
1407        }
1408    }
1409
1410    #[test]
1411    fn atlas_cache_hit() {
1412        let mut atlas = PathAtlas::new(256, 256);
1413        atlas.begin_frame();
1414
1415        let mut path = Path::new();
1416        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1417        path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1418        path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1419        path.push(PathCommand::Close);
1420
1421        let style = StrokeStyle::solid(0.0);
1422        let bounds = [0.0, 0.0, 10.0, 10.0];
1423
1424        let r1 = atlas
1425            .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1426            .unwrap();
1427        let r2 = atlas
1428            .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1429            .unwrap();
1430
1431        // Same region (cache hit)
1432        assert_eq!(r1.region.x, r2.region.x);
1433        assert_eq!(r1.region.y, r2.region.y);
1434    }
1435
1436    #[test]
1437    fn cache_hit_is_independent_of_color() {
1438        // C3: color is no longer part of the rasterization or the cache
1439        // key — two lookups with identical geometry/stroke/size but
1440        // DIFFERENT colors (as the caller would pass via the paint,
1441        // before this refactor) must now hit the SAME atlas entry, since
1442        // `lookup_or_rasterize` no longer takes a color at all. This is
1443        // what lets a solid fill and a gradient fill of the same path
1444        // share one atlas entry.
1445        let mut atlas = PathAtlas::new(256, 256);
1446        atlas.begin_frame();
1447
1448        let mut path = Path::new();
1449        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1450        path.push(PathCommand::LineTo(Point::new(10.0, 0.0)));
1451        path.push(PathCommand::LineTo(Point::new(10.0, 10.0)));
1452        path.push(PathCommand::Close);
1453
1454        let style = StrokeStyle::solid(0.0);
1455        let bounds = [0.0, 0.0, 10.0, 10.0];
1456
1457        // Simulate two draw calls that would previously have carried
1458        // different colors — the API no longer distinguishes them, so
1459        // both lookups are for the exact same cache key.
1460        let r1 = atlas
1461            .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1462            .expect("first lookup rasterizes and caches");
1463        let r2 = atlas
1464            .lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false)
1465            .expect("second lookup hits the same cache entry");
1466
1467        assert_eq!(r1.region.x, r2.region.x, "cache hit: same region x");
1468        assert_eq!(r1.region.y, r2.region.y, "cache hit: same region y");
1469        assert_eq!(r1.region.w, r2.region.w);
1470        assert_eq!(r1.region.h, r2.region.h);
1471        assert_eq!(atlas.cache.len(), 1, "only one atlas entry for both calls");
1472    }
1473
1474    #[test]
1475    fn atlas_begin_frame_advances() {
1476        let mut atlas = PathAtlas::new(256, 256);
1477        assert_eq!(atlas.current_frame, 0);
1478        atlas.begin_frame();
1479        assert_eq!(atlas.current_frame, 1);
1480        atlas.begin_frame();
1481        assert_eq!(atlas.current_frame, 2);
1482    }
1483
1484    #[test]
1485    fn atlas_eviction_clears_stale() {
1486        let mut atlas = PathAtlas::new(64, 64);
1487
1488        let mut path = Path::new();
1489        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1490        path.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1491        path.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1492        path.push(PathCommand::Close);
1493        let style = StrokeStyle::solid(0.0);
1494        let bounds = [0.0, 0.0, 8.0, 8.0];
1495
1496        atlas.begin_frame(); // frame 1
1497        atlas.lookup_or_rasterize(&path, &style, FillRule::Winding, bounds, 1.0, 1.0, false);
1498
1499        // Advance well past the entry
1500        atlas.begin_frame(); // frame 2
1501        atlas.begin_frame(); // frame 3
1502        atlas.begin_frame(); // frame 4
1503
1504        // Eviction should clear it
1505        atlas.evict_lru();
1506        assert!(atlas.cache.is_empty());
1507    }
1508
1509    #[test]
1510    fn evict_preserves_current_frame_entries() {
1511        // Regression: previously `evict_lru` cleared the entire cache,
1512        // so a second path inserted in the same frame could displace
1513        // the first — `path_placements[0]` ended up pointing at pixels
1514        // that now belonged to path #2. LineChart and PieChart hit this
1515        // routinely because their paths cover most of the plot area.
1516        let mut atlas = PathAtlas::new(64, 64);
1517        atlas.begin_frame();
1518
1519        let mut p1 = Path::new();
1520        p1.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1521        p1.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1522        p1.push(PathCommand::LineTo(Point::new(40.0, 40.0)));
1523        p1.push(PathCommand::Close);
1524
1525        let mut p2 = Path::new();
1526        p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1527        p2.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1528        p2.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1529        p2.push(PathCommand::Close);
1530
1531        let style = StrokeStyle::solid(0.0);
1532        let r1 = atlas
1533            .lookup_or_rasterize(
1534                &p1,
1535                &style,
1536                FillRule::Winding,
1537                [0.0, 0.0, 40.0, 40.0],
1538                1.0,
1539                1.0,
1540                false,
1541            )
1542            .expect("p1 fits");
1543
1544        // p2 doesn't fit in the remaining space → the atlas grows rather than
1545        // evicting, so p1 (current-frame) survives at the same coordinates.
1546        let _r2 = atlas.lookup_or_rasterize(
1547            &p2,
1548            &style,
1549            FillRule::Winding,
1550            [0.0, 0.0, 50.0, 50.0],
1551            1.0,
1552            1.0,
1553            false,
1554        );
1555
1556        // Looking up p1 again must still hit cache (with possibly a new
1557        // region, but stable across the lookup).
1558        let r1b = atlas
1559            .lookup_or_rasterize(
1560                &p1,
1561                &style,
1562                FillRule::Winding,
1563                [0.0, 0.0, 40.0, 40.0],
1564                1.0,
1565                1.0,
1566                false,
1567            )
1568            .expect("p1 still cached after eviction");
1569        // The repacked region may have moved, but lookup_or_rasterize
1570        // must return a non-None region for p1 — i.e. it wasn't lost.
1571        let _ = (r1, r1b);
1572        assert!(atlas.cache.contains_key(&PathCacheKey::new(
1573            &p1,
1574            &style,
1575            FillRule::Winding,
1576            [0.0, 0.0],
1577            40,
1578            40,
1579            1.0,
1580        )));
1581    }
1582
1583    #[test]
1584    fn evict_never_moves_live_entry_when_full() {
1585        // Core invariant for the stale-UV fix: once a region is handed out
1586        // this frame it is frozen. If a later path can't fit and the atlas is
1587        // already at max size, the new path is skipped (returns None) — the
1588        // live entry must NOT be repacked, or `path_placements[..]` would sample
1589        // the wrong pixels later in the same frame.
1590        let mut atlas = PathAtlas::new(64, 64);
1591        atlas.max_size = 64; // forbid growth so eviction is the only path
1592        atlas.begin_frame();
1593
1594        let mut p1 = Path::new();
1595        p1.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1596        p1.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1597        p1.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1598        p1.push(PathCommand::Close);
1599
1600        let mut p2 = Path::new();
1601        p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1602        p2.push(PathCommand::LineTo(Point::new(62.0, 0.0)));
1603        p2.push(PathCommand::LineTo(Point::new(62.0, 62.0)));
1604        p2.push(PathCommand::Close);
1605
1606        let style = StrokeStyle::solid(0.0);
1607        let r1 = atlas
1608            .lookup_or_rasterize(
1609                &p1,
1610                &style,
1611                FillRule::Winding,
1612                [0.0, 0.0, 60.0, 60.0],
1613                1.0,
1614                1.0,
1615                false,
1616            )
1617            .expect("p1 fits");
1618
1619        // p2 can't fit, can't grow → must be skipped, not placed by moving p1.
1620        let r2 = atlas.lookup_or_rasterize(
1621            &p2,
1622            &style,
1623            FillRule::Winding,
1624            [0.0, 0.0, 62.0, 62.0],
1625            1.0,
1626            1.0,
1627            false,
1628        );
1629        assert!(
1630            r2.is_none(),
1631            "an unfittable path is skipped, never placed by evicting a live entry"
1632        );
1633
1634        // p1's region is byte-for-byte unchanged.
1635        let r1b = atlas
1636            .lookup_or_rasterize(
1637                &p1,
1638                &style,
1639                FillRule::Winding,
1640                [0.0, 0.0, 60.0, 60.0],
1641                1.0,
1642                1.0,
1643                false,
1644            )
1645            .expect("p1 still cached");
1646        assert_eq!(r1.region.x, r1b.region.x, "live entry must not move");
1647        assert_eq!(r1.region.y, r1b.region.y, "live entry must not move");
1648    }
1649
1650    #[test]
1651    fn begin_frame_compacts_stale_entries() {
1652        // `begin_frame` is the safe point to repack: nothing is handed out
1653        // for the new frame yet. A near-full atlas with entries not used on
1654        // the last completed frame compacts them away.
1655        let mut atlas = PathAtlas::new(64, 64);
1656        atlas.begin_frame(); // frame 1
1657
1658        let mut path = Path::new();
1659        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1660        path.push(PathCommand::LineTo(Point::new(8.0, 0.0)));
1661        path.push(PathCommand::LineTo(Point::new(8.0, 8.0)));
1662        path.push(PathCommand::Close);
1663        let style = StrokeStyle::solid(0.0);
1664        atlas
1665            .lookup_or_rasterize(
1666                &path,
1667                &style,
1668                FillRule::Winding,
1669                [0.0, 0.0, 8.0, 8.0],
1670                1.0,
1671                1.0,
1672                false,
1673            )
1674            .expect("entry fits");
1675        assert_eq!(atlas.cache.len(), 1);
1676
1677        atlas.begin_frame(); // frame 2 — keep_from = 1, entry (used f1) kept
1678        assert_eq!(
1679            atlas.cache.len(),
1680            1,
1681            "entry from the last completed frame is kept"
1682        );
1683
1684        atlas.begin_frame(); // frame 3 — keep_from = 2, entry (used f1) is stale
1685        assert!(
1686            atlas.cache.is_empty(),
1687            "stale entry compacted away on begin_frame"
1688        );
1689    }
1690
1691    #[test]
1692    fn atlas_grow() {
1693        let mut atlas = PathAtlas::new(16, 16);
1694        assert!(atlas.try_grow());
1695        assert_eq!(atlas.width, 32);
1696        assert_eq!(atlas.height, 32);
1697    }
1698
1699    #[test]
1700    fn growth_preserves_earlier_frame_regions() {
1701        // Regression: when a single frame inserts more paths than fit in
1702        // the initial atlas, we must grow rather than evict — eviction
1703        // repacks current-frame survivors at fresh coordinates,
1704        // invalidating any AtlasRegion the renderer already cached for
1705        // them earlier in the same frame. With grow-first, the first
1706        // entry's region stays valid throughout the frame.
1707        let mut atlas = PathAtlas::new(64, 64);
1708        atlas.begin_frame();
1709
1710        let mut p1 = Path::new();
1711        p1.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1712        p1.push(PathCommand::LineTo(Point::new(50.0, 0.0)));
1713        p1.push(PathCommand::LineTo(Point::new(50.0, 50.0)));
1714        p1.push(PathCommand::Close);
1715
1716        let mut p2 = Path::new();
1717        p2.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1718        p2.push(PathCommand::LineTo(Point::new(60.0, 0.0)));
1719        p2.push(PathCommand::LineTo(Point::new(60.0, 60.0)));
1720        p2.push(PathCommand::Close);
1721
1722        let style = StrokeStyle::solid(0.0);
1723        let r1 = atlas
1724            .lookup_or_rasterize(
1725                &p1,
1726                &style,
1727                FillRule::Winding,
1728                [0.0, 0.0, 50.0, 50.0],
1729                1.0,
1730                1.0,
1731                false,
1732            )
1733            .expect("p1 fits");
1734
1735        // p2 doesn't fit alongside p1 in 64×64 → atlas should grow,
1736        // not evict. After growth, p1's region must still be at the
1737        // same coordinates we got back the first time.
1738        let _r2 = atlas
1739            .lookup_or_rasterize(
1740                &p2,
1741                &style,
1742                FillRule::Winding,
1743                [0.0, 0.0, 60.0, 60.0],
1744                1.0,
1745                1.0,
1746                false,
1747            )
1748            .expect("p2 fits after grow");
1749
1750        let r1_after = atlas
1751            .lookup_or_rasterize(
1752                &p1,
1753                &style,
1754                FillRule::Winding,
1755                [0.0, 0.0, 50.0, 50.0],
1756                1.0,
1757                1.0,
1758                false,
1759            )
1760            .expect("p1 still cached");
1761        assert_eq!(
1762            r1.region.x, r1_after.region.x,
1763            "p1 must not move when atlas grows"
1764        );
1765        assert_eq!(
1766            r1.region.y, r1_after.region.y,
1767            "p1 must not move when atlas grows"
1768        );
1769    }
1770
1771    #[test]
1772    fn cosmetic_path_raster_is_zoom_aware_logical_is_not() {
1773        // A cosmetic stroke rasterizes its body at the view zoom (so it stays
1774        // sharp and matches the transform-scaled display quad 1:1) — the
1775        // raster dimensions scale with zoom. A logical stroke ignores zoom
1776        // (one bitmap, stretched by the quad), so its raster size and cache
1777        // entry are zoom-independent.
1778        let mut atlas = PathAtlas::new(512, 512);
1779        atlas.begin_frame();
1780        let mut path = Path::new();
1781        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1782        path.push(PathCommand::LineTo(Point::new(40.0, 0.0)));
1783        let bounds = [0.0, 0.0, 40.0, 4.0];
1784
1785        let cosmetic = StrokeStyle::hairline(2.0);
1786        let r1 = atlas
1787            .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 1.0, false)
1788            .unwrap();
1789        let r2 = atlas
1790            .lookup_or_rasterize(&path, &cosmetic, FillRule::Winding, bounds, 1.0, 2.0, false)
1791            .unwrap();
1792        assert_eq!(r1.region.w, 40, "cosmetic body at zoom 1: 40·sf1·zoom1");
1793        assert_eq!(
1794            r2.region.w, 80,
1795            "cosmetic body at zoom 2: 40·sf1·zoom2 (zoom-aware)"
1796        );
1797
1798        let logical = StrokeStyle::solid(2.0);
1799        let l1 = atlas
1800            .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 1.0, false)
1801            .unwrap();
1802        let l2 = atlas
1803            .lookup_or_rasterize(&path, &logical, FillRule::Winding, bounds, 1.0, 4.0, false)
1804            .unwrap();
1805        assert_eq!(l1.region.w, l2.region.w, "logical raster size ignores zoom");
1806        assert_eq!(
1807            (l1.region.x, l1.region.y),
1808            (l2.region.x, l2.region.y),
1809            "logical hits the same cache entry"
1810        );
1811
1812        // Same width/dims but different stroke space must not collide.
1813        let k_cos = PathCacheKey::new(&path, &cosmetic, FillRule::Winding, [0.0, 0.0], 40, 4, 1.0);
1814        let k_log = PathCacheKey::new(&path, &logical, FillRule::Winding, [0.0, 0.0], 40, 4, 1.0);
1815        assert_ne!(
1816            k_cos, k_log,
1817            "cache key must distinguish cosmetic vs logical"
1818        );
1819    }
1820
1821    // ── Dash lengths are measured along the path, so they scale with it ──
1822
1823    /// A 20-logical-px horizontal line, dashed 4 on / 4 off, rasterized at
1824    /// `geom_scale`. Returns how many separate ink runs the middle row has.
1825    fn dashed_line_runs(geom_scale: f32) -> usize {
1826        let mut path = Path::new();
1827        path.push(PathCommand::MoveTo(Point::new(0.0, 4.0)));
1828        path.push(PathCommand::LineTo(Point::new(20.0, 4.0)));
1829        let style = StrokeStyle::dashed(2.0, 4.0, 4.0);
1830
1831        let w = (20.0 * geom_scale).ceil() as u32;
1832        let h = (8.0 * geom_scale).ceil() as u32;
1833        let px = rasterize_path(
1834            &path,
1835            &style,
1836            FillRule::Winding,
1837            [0.0, 0.0],
1838            w,
1839            h,
1840            geom_scale,
1841            geom_scale,
1842        )
1843        .expect("rasterizes");
1844
1845        let row = (4.0 * geom_scale) as u32;
1846        let mut runs = 0usize;
1847        let mut inked = false;
1848        for x in 0..w {
1849            let now = px[((row * w + x) * 4 + 3) as usize] > 100;
1850            if now && !inked {
1851                runs += 1;
1852            }
1853            inked = now;
1854        }
1855        runs
1856    }
1857
1858    /// The defect: the dash pattern reached tiny-skia unscaled while the
1859    /// geometry was baked at `geom_scale`, so the dashes came out
1860    /// `1 / geom_scale` too short. A `dashed(2, 4, 4)` line showed 3 dashes
1861    /// at scale 1 and 5 at scale 2 — i.e. every dashed stroke, chart
1862    /// gridlines included, was drawn with half-length dashes on a 2× HiDPI
1863    /// display, and a cosmetic dashed stroke re-cut its pattern at every
1864    /// zoom step.
1865    #[test]
1866    fn dash_count_is_invariant_to_the_geometry_scale() {
1867        let baseline = dashed_line_runs(1.0);
1868        assert!(baseline > 1, "the probe line must actually dash");
1869        for scale in [2.0f32, 3.0, 4.0] {
1870            assert_eq!(
1871                dashed_line_runs(scale),
1872                baseline,
1873                "a dash is a length along the path: scaling the geometry by \
1874                 {scale} must scale the dashes with it, not cut more of them"
1875            );
1876        }
1877    }
1878
1879    /// The dash pattern is a rasterization input that `w` / `h` do not
1880    /// always recover — they are the bounds *ceiled* into texels, so a
1881    /// sub-pixel path aliases several scales onto one bitmap size, and a
1882    /// cosmetic stroke's `geom_scale` slides continuously with the view
1883    /// zoom. Without `geom_scale` in the key, the second zoom step would
1884    /// be served the first's dashes.
1885    #[test]
1886    fn cache_key_distinguishes_the_geometry_scale() {
1887        let mut path = Path::new();
1888        path.push(PathCommand::MoveTo(Point::new(0.0, 0.0)));
1889        path.push(PathCommand::LineTo(Point::new(0.4, 0.0)));
1890        let style = StrokeStyle::dashed(1.0, 4.0, 4.0);
1891        // Same ceiled bitmap size (1x1) and same origin at both scales.
1892        assert_ne!(
1893            PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 1, 1, 1.0),
1894            PathCacheKey::new(&path, &style, FillRule::Winding, [0.0, 0.0], 1, 1, 2.0),
1895            "two geometry scales that ceil to the same bitmap must not share \
1896             an atlas entry — their dashes are cut differently"
1897        );
1898    }
1899
1900    /// A dashed stroke really does leave gaps — the rasterizer is what the
1901    /// canvas routing exists to reach, so pin that it does the job.
1902    #[test]
1903    fn a_dashed_stroke_leaves_gaps_where_a_solid_one_does_not() {
1904        let mut path = Path::new();
1905        path.push(PathCommand::MoveTo(Point::new(0.0, 4.0)));
1906        path.push(PathCommand::LineTo(Point::new(20.0, 4.0)));
1907
1908        let ink = |style: &StrokeStyle| -> usize {
1909            let px = rasterize_path(&path, style, FillRule::Winding, [0.0, 0.0], 20, 8, 1.0, 1.0)
1910                .expect("rasterizes");
1911            (0..20)
1912                .filter(|x| px[((4 * 20 + x) * 4 + 3) as usize] > 100)
1913                .count()
1914        };
1915
1916        let solid = ink(&StrokeStyle::solid(2.0));
1917        let dashed = ink(&StrokeStyle::dashed(2.0, 4.0, 4.0));
1918        assert!(
1919            solid >= 19,
1920            "solid stroke inks the whole line (got {solid})"
1921        );
1922        assert!(
1923            dashed < solid,
1924            "dashed stroke must leave gaps (solid={solid}, dashed={dashed})"
1925        );
1926    }
1927
1928    // ── The rasterizer opens a subpath where the flattener opens one ──
1929    //
1930    // `Path::flatten` is the oracle for both halves of the parity the
1931    // `ItemShape` work exists to guarantee: the scene tier hit-tests the
1932    // polyline it produces, so ink the rasterizer lays down outside that
1933    // polyline is ink no click can reach.
1934
1935    /// The arc that exposes the defect: a 40x40 disc centred at (200, 0), so
1936    /// its own first point (220, 0) is 220 units from the origin and 220 from
1937    /// any bitmap corner a spoke could be welded to.
1938    fn probe_arc() -> teksilo_canvas::geometry::Rect {
1939        teksilo_canvas::geometry::Rect::new(180.0, -20.0, 40.0, 40.0)
1940    }
1941
1942    /// Paths covering every way an arc can meet a subpath, plus non-arc
1943    /// shapes that must not regress.
1944    fn subpath_battery() -> Vec<(&'static str, Path)> {
1945        let mut bare_circle_arc = Path::new();
1946        bare_circle_arc.arc_to(probe_arc(), 0.0, 360.0);
1947
1948        let mut bare_quarter_arc = Path::new();
1949        bare_quarter_arc.arc_to(probe_arc(), 0.0, 90.0);
1950
1951        let mut arc_after_close = Path::new();
1952        arc_after_close
1953            .move_to(Point::new(0.0, 0.0))
1954            .line_to(Point::new(10.0, 0.0))
1955            .line_to(Point::new(10.0, 10.0));
1956        arc_after_close.close();
1957        arc_after_close.arc_to(probe_arc(), 0.0, 360.0);
1958
1959        let mut two_arcs = Path::new();
1960        two_arcs.arc_to(probe_arc(), 0.0, 180.0);
1961        two_arcs.close();
1962        two_arcs.arc_to(
1963            teksilo_canvas::geometry::Rect::new(0.0, 0.0, 30.0, 30.0),
1964            90.0,
1965            180.0,
1966        );
1967        two_arcs.close();
1968
1969        // A `MoveTo` that lands away from the arc's start: `flatten` keeps a
1970        // straight connector here, so the rasterizer must keep one too —
1971        // this is the case the fix must NOT turn into a second subpath.
1972        let mut arc_from_elsewhere = Path::new();
1973        arc_from_elsewhere.move_to(Point::new(0.0, 0.0));
1974        arc_from_elsewhere.arc_to(probe_arc(), 0.0, 270.0);
1975
1976        let mut arc_after_cubic = Path::new();
1977        arc_after_cubic.move_to(Point::new(0.0, 0.0)).cubic_to(
1978            Point::new(20.0, 40.0),
1979            Point::new(60.0, -40.0),
1980            Point::new(90.0, 5.0),
1981        );
1982        arc_after_cubic.arc_to(probe_arc(), 90.0, 180.0);
1983
1984        let mut curves_and_lines = Path::new();
1985        curves_and_lines
1986            .move_to(Point::new(0.0, 0.0))
1987            .line_to(Point::new(40.0, 0.0))
1988            .quad_to(Point::new(60.0, 20.0), Point::new(40.0, 40.0))
1989            .line_to(Point::new(0.0, 40.0));
1990        curves_and_lines.close();
1991        curves_and_lines
1992            .move_to(Point::new(80.0, 10.0))
1993            .line_to(Point::new(120.0, 10.0))
1994            .line_to(Point::new(120.0, 30.0));
1995
1996        // A path that opens on a bare segment, with a negative coordinate so
1997        // the atlas origin is non-zero. tiny-skia injects its own `MoveTo` at
1998        // *bitmap* (0, 0) here; `flatten` opens at *path-space* (0, 0). Those
1999        // agree only while the origin is zero, which is exactly what a
2000        // negative coordinate stops being true.
2001        let mut bare_line_negative = Path::new();
2002        bare_line_negative
2003            .line_to(Point::new(-30.0, -20.0))
2004            .line_to(Point::new(40.0, 25.0));
2005
2006        let mut bare_cubic_negative = Path::new();
2007        bare_cubic_negative.cubic_to(
2008            Point::new(-20.0, 40.0),
2009            Point::new(60.0, -40.0),
2010            Point::new(-90.0, 5.0),
2011        );
2012
2013        let mut bare_quad_negative = Path::new();
2014        bare_quad_negative.quad_to(Point::new(-15.0, 30.0), Point::new(35.0, -10.0));
2015
2016        vec![
2017            ("bare 360 arc", bare_circle_arc),
2018            ("bare 90 arc", bare_quarter_arc),
2019            ("bare line, negative", bare_line_negative),
2020            ("bare cubic, negative", bare_cubic_negative),
2021            ("bare quad, negative", bare_quad_negative),
2022            ("arc after close", arc_after_close),
2023            ("two arcs split by close", two_arcs),
2024            ("arc reached from elsewhere", arc_from_elsewhere),
2025            ("arc after a cubic", arc_after_cubic),
2026            ("curves and lines", curves_and_lines),
2027            ("circle", Path::circle(Point::new(50.0, 50.0), 25.0)),
2028            (
2029                "rounded rect",
2030                Path::rounded_rect(
2031                    teksilo_canvas::geometry::Rect::new(0.0, 0.0, 100.0, 60.0),
2032                    teksilo_tokens::CornerRadius {
2033                        top_left: 12.0,
2034                        top_right: 4.0,
2035                        bottom_left: 0.0,
2036                        bottom_right: 20.0,
2037                    },
2038                ),
2039            ),
2040        ]
2041    }
2042
2043    /// Where the tiny-skia path opens each of its subpaths.
2044    fn sk_subpath_starts(p: &tiny_skia::Path) -> Vec<(f32, f32)> {
2045        p.segments()
2046            .filter_map(|seg| match seg {
2047                tiny_skia::PathSegment::MoveTo(pt) => Some((pt.x, pt.y)),
2048                _ => None,
2049            })
2050            .collect()
2051    }
2052
2053    /// Where the flattener opens each of its subpaths.
2054    ///
2055    /// Single-vertex subpaths are dropped: a lone `MoveTo` with no segment
2056    /// after it paints nothing and `contains_point` cannot see it either, but
2057    /// tiny-skia coalesces consecutive `MoveTo` verbs into one while `flatten`
2058    /// keeps a one-point subpath for each. That difference is inert on both
2059    /// sides, and is not the parity this test is about.
2060    fn flatten_subpath_starts(p: &Path) -> Vec<(f32, f32)> {
2061        p.flatten(0.01)
2062            .iter()
2063            .filter(|sp| sp.points.len() > 1)
2064            .map(|sp| (sp.points[0].x, sp.points[0].y))
2065            .collect()
2066    }
2067
2068    /// The defect, stated against the oracle: the arc emitter used to emit an
2069    /// unconditional `line_to` for every arc segment, so tiny-skia injected a
2070    /// `MoveTo` of its own — `(0, 0)` **in bitmap space** for a path opening
2071    /// with a bare arc, or the *previous* subpath's start after a `Close` —
2072    /// and welded a spoke from it to the arc. `Path::flatten`, which the scene
2073    /// tier hit-tests, opens at the arc's own first point instead. That gap is
2074    /// painted ink no click can reach.
2075    ///
2076    /// Comparing against `flatten` rather than against a recorded verb list
2077    /// is what keeps this from going stale: change the arc expansion and both
2078    /// sides move together, or the test reddens.
2079    #[test]
2080    fn the_rasterizer_starts_subpaths_where_the_flattener_does() {
2081        for (name, path) in subpath_battery() {
2082            let sk = build_sk_path(&path, 1.0, [0.0, 0.0]).expect("path builds");
2083            let got = sk_subpath_starts(&sk);
2084            let want = flatten_subpath_starts(&path);
2085            assert_eq!(
2086                got, want,
2087                "{name}: the rasterizer must open its subpaths exactly where \
2088                 Path::flatten opens its own — anywhere else is ink the scene \
2089                 tier's hit-test cannot see"
2090            );
2091        }
2092    }
2093
2094    /// The same claim in device space: the scale factor must not move a
2095    /// subpath start relative to the geometry. The injected `MoveTo` went to
2096    /// the **bitmap's** `(0, 0)`, so the phantom vertex slid with the scale
2097    /// while the real geometry scaled around it.
2098    #[test]
2099    fn subpath_starts_track_the_geometry_through_scale_and_origin() {
2100        for (name, path) in subpath_battery() {
2101            let want = flatten_subpath_starts(&path);
2102            for (scale, origin) in [
2103                (1.0_f32, [0.0_f32, 0.0]),
2104                (2.0, [7.0, -3.0]),
2105                (0.5, [1.0, 1.0]),
2106            ] {
2107                let sk = build_sk_path(&path, scale, origin).expect("path builds");
2108                let got = sk_subpath_starts(&sk);
2109                let mapped: Vec<(f32, f32)> = want
2110                    .iter()
2111                    .map(|(x, y)| (x * scale - origin[0], y * scale - origin[1]))
2112                    .collect();
2113                assert_eq!(
2114                    got, mapped,
2115                    "{name} at scale {scale} origin {origin:?}: a subpath start \
2116                     is a point of the geometry, so it must map through the same \
2117                     affine transform every other point does"
2118                );
2119            }
2120        }
2121    }
2122
2123    /// Fill `path` at 1:1 and return the inked bounding box in the path's own
2124    /// units as `(left, top, right, bottom)`, or `None` when nothing inked.
2125    ///
2126    /// The bitmap is the path's bounds plus a margin — which is also where a
2127    /// welded spoke shows up, since the injected `MoveTo` lands on the
2128    /// bitmap's own corner.
2129    fn painted_fill_bounds(path: &Path) -> Option<(f32, f32, f32, f32)> {
2130        const MARGIN: f32 = 2.0;
2131        let b = path.bounds();
2132        let (ox, oy) = (b.x - MARGIN, b.y - MARGIN);
2133        let w = (b.width + 2.0 * MARGIN).ceil() as u32;
2134        let h = (b.height + 2.0 * MARGIN).ceil() as u32;
2135        let px = rasterize_path(
2136            path,
2137            &StrokeStyle::solid(0.0),
2138            FillRule::Winding,
2139            [ox, oy],
2140            w,
2141            h,
2142            1.0,
2143            1.0,
2144        )
2145        .expect("rasterizes");
2146
2147        let (mut min_x, mut min_y, mut max_x, mut max_y) = (u32::MAX, u32::MAX, 0u32, 0u32);
2148        let mut any = false;
2149        for y in 0..h {
2150            for x in 0..w {
2151                if px[((y * w + x) * 4 + 3) as usize] > 0 {
2152                    any = true;
2153                    min_x = min_x.min(x);
2154                    min_y = min_y.min(y);
2155                    max_x = max_x.max(x);
2156                    max_y = max_y.max(y);
2157                }
2158            }
2159        }
2160        any.then_some((
2161            min_x as f32 + ox,
2162            min_y as f32 + oy,
2163            max_x as f32 + ox + 1.0,
2164            max_y as f32 + oy + 1.0,
2165        ))
2166    }
2167
2168    /// End to end, in pixels: what the rasterizer paints must sit inside what
2169    /// the shape reports. A bare 90° arc was the worst case — `flatten` gives
2170    /// a quarter-arc bounded by `x ∈ [200, 220]`, while the fill came out as a
2171    /// solid triangle running from the bitmap's top-left corner all the way to
2172    /// the arc, 2728 inked pixels against the ~86 the shape covers. Every one
2173    /// of those pixels was unclickable.
2174    #[test]
2175    fn a_fill_paints_only_where_the_shape_says_it_is() {
2176        // AA writes partial coverage into the pixel a boundary crosses, so a
2177        // one-pixel ring around the exact outline is expected; a spoke is
2178        // orders of magnitude more than that.
2179        const SLACK: f32 = 1.5;
2180        for (name, path) in subpath_battery() {
2181            let want = path.exact_bounds(0.01);
2182            let (l, t, r, b) = painted_fill_bounds(&path)
2183                .unwrap_or_else(|| panic!("{name}: the probe must actually ink something"));
2184            assert!(
2185                l >= want.x - SLACK
2186                    && t >= want.y - SLACK
2187                    && r <= want.right() + SLACK
2188                    && b <= want.bottom() + SLACK,
2189                "{name}: painted ({l}, {t})-({r}, {b}) escapes the shape's own \
2190                 extent ({}, {})-({}, {}) — that ink is unreachable by a click",
2191                want.x,
2192                want.y,
2193                want.right(),
2194                want.bottom()
2195            );
2196        }
2197    }
2198
2199    /// The same defect where it is loudest. A fill traverses the spoke and
2200    /// comes back along the closing edge, so a 360° arc's spoke cancels to a
2201    /// sub-pixel needle; a *stroke* draws it outright — a 2 dp bar running the
2202    /// full 220 units from the phantom vertex to the disc.
2203    #[test]
2204    fn a_stroked_bare_arc_draws_no_spoke_to_the_bitmap_corner() {
2205        let mut path = Path::new();
2206        path.arc_to(probe_arc(), 0.0, 360.0);
2207
2208        // A bitmap wide enough to hold the whole spoke: x from 0 to 240.
2209        let (w, h) = (242u32, 44u32);
2210        let px = rasterize_path(
2211            &path,
2212            &StrokeStyle::solid(2.0),
2213            FillRule::Winding,
2214            [-1.0, -22.0],
2215            w,
2216            h,
2217            1.0,
2218            1.0,
2219        )
2220        .expect("rasterizes");
2221
2222        // The disc's own left edge is x = 180, i.e. bitmap column 181; the
2223        // 2 dp stroke reaches one column further left. Nothing may ink before
2224        // that.
2225        let leftmost = (0..w)
2226            .find(|&x| (0..h).any(|y| px[((y * w + x) * 4 + 3) as usize] > 0))
2227            .expect("the arc must ink");
2228        assert!(
2229            leftmost >= 179,
2230            "a stroked bare arc inked from column {leftmost}: the subpath was \
2231             opened at the bitmap's corner and a spoke stroked from there to \
2232             the arc, 220 units of ink the shape does not have"
2233        );
2234    }
2235}