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