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}