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