Skip to main content

teksilo_render/
vertex.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4use bytemuck::{Pod, Zeroable};
5
6use teksilo_canvas::render_frame::PaintData;
7use teksilo_canvas::{DecorationRect, GlyphQuad, PathEntry, ShadowQuad, ShapeQuad, Transform2D};
8
9/// Convert a single sRGB channel (0..1) to linear light (0..1).
10///
11/// `teksilo_tokens::Color::from_hex` parses hex values as sRGB-encoded f32
12/// without gamma conversion, which matches how designers specify colors.
13/// The wgpu surface is `Rgba8UnormSrgb`, which expects **linear** shader
14/// output and applies sRGB encoding on write. To avoid double gamma-
15/// encoding we linearize color data at the vertex-packing boundary.
16#[inline]
17fn srgb_to_linear(c: f32) -> f32 {
18    if c <= 0.04045 {
19        c / 12.92
20    } else {
21        ((c + 0.055) / 1.055).powf(2.4)
22    }
23}
24
25/// Linearize an RGBA color for GPU upload. Alpha passes through unchanged
26/// because `Rgba8UnormSrgb` only gamma-encodes RGB.
27#[inline]
28pub fn srgb_to_linear_rgba(c: [f32; 4]) -> [f32; 4] {
29    [
30        srgb_to_linear(c[0]),
31        srgb_to_linear(c[1]),
32        srgb_to_linear(c[2]),
33        c[3],
34    ]
35}
36
37/// Per-vertex flag: sample atlas `texture.rgb` directly (color emoji)
38/// instead of using the texture as an alpha mask tinted by vertex color.
39pub const QUAD_FLAG_COLOR_GLYPH: u32 = 1;
40
41/// Vertex for the textured quad pipeline (glyphs, images).
42#[repr(C)]
43#[derive(Debug, Clone, Copy, Pod, Zeroable)]
44pub struct QuadVertex {
45    pub position: [f32; 2],
46    pub tex_coord: [f32; 2],
47    pub color: [f32; 4],
48    /// Per-vertex bitfield. Bit 0 ([`QUAD_FLAG_COLOR_GLYPH`]) selects the
49    /// color emoji path in the fragment shader.
50    pub flags: u32,
51    /// Padding so the struct stride stays a multiple of 8 bytes, matching
52    /// wgpu's vertex buffer layout expectations.
53    pub _pad: u32,
54}
55
56/// Off-diagonal tolerance below which a transform counts as axis-aligned
57/// for glyph pixel-snapping. Matrix composition leaves float dust on `b`/`c`;
58/// any intentional rotation puts `|sin θ|` there, far above this bound.
59const GLYPH_SNAP_AXIS_EPS: f32 = 1e-4;
60
61/// Tolerance (in physical pixels) between the transformed glyph quad size
62/// and its atlas bitmap size for the quad to count as 1:1. Tight enough
63/// that residual mid-bucket zoom scaling (≥1% on any glyph bigger than
64/// ~6 px) never snaps, loose enough to absorb the float error of the
65/// logical→physical round-trip (`bitmap / (sf·rs) · sf · scale`).
66const GLYPH_SNAP_SIZE_EPS: f32 = 1.0 / 16.0;
67
68/// Apply a 2D affine transform to a physical-pixel position.
69/// Same convention as the renderer: `m = [a, b, c, d, tx, ty]` maps
70/// `(x, y) → (a·x + c·y + tx, b·x + d·y + ty)`.
71#[inline]
72fn apply_affine(p: [f32; 2], t: &Transform2D) -> [f32; 2] {
73    let [a, b, c, d, tx, ty] = t.m;
74    [a * p[0] + c * p[1] + tx, b * p[0] + d * p[1] + ty]
75}
76
77impl QuadVertex {
78    /// Convert a glyph quad to 4 vertices (two triangles via index buffer),
79    /// applying `scale_factor` (logical → physical pixels) and the current
80    /// affine `transform` (linear part unitless, translation already in
81    /// physical pixels). Atlas coordinates are in texels and normalized to
82    /// 0..1 using the atlas dimensions. Returned positions are in physical
83    /// pixels — the caller converts to NDC; it must NOT transform again.
84    ///
85    /// **Pixel-snap invariant.** The glyph atlas is sampled with bilinear
86    /// filtering so the residual GPU scaling between raster-scale buckets
87    /// (≤ ~12%, see `quantize_raster_scale`) stays smooth. Bilinear is only
88    /// loss-free when each texel maps exactly onto one framebuffer pixel,
89    /// and glyph origins are inherently fractional: shaping pen advances,
90    /// widget offsets, and scroll positions are all fractional floats. An
91    /// unsnapped origin makes the 2×2 kernel mix neighboring texels at
92    /// every edge (uniform blur) and bleed the glyph's last row into the
93    /// transparent atlas gutter (visibly cropping the bottom of letters
94    /// like "c"/"e"). So whenever the transformed quad maps 1:1 onto its
95    /// atlas bitmap — axis-aligned transform and transformed size equal to
96    /// the bitmap size within `GLYPH_SNAP_SIZE_EPS` — the origin is
97    /// rounded to the integer pixel grid and the opposite corner pinned at
98    /// exactly `origin + bitmap size`, making linear sampling an identity.
99    /// This covers identity, fractional DPI, pure translations, and
100    /// exact-bucket zoom (e.g. a 1.25× transform over a 1.25-bucket
101    /// raster). Rotated or residually scaled quads (mid-bucket zoom) skip
102    /// the snap and ride bilinear filtering as intended.
103    pub fn from_glyph_quad_transformed(
104        quad: &GlyphQuad,
105        scale_factor: f32,
106        atlas_width: u32,
107        atlas_height: u32,
108        transform: &Transform2D,
109    ) -> [QuadVertex; 4] {
110        let [x, y, w, h] = quad.screen;
111        let [ax, ay, aw, ah] = quad.atlas;
112        let sx = x * scale_factor;
113        let sy = y * scale_factor;
114        let sw = w * scale_factor;
115        let sh = h * scale_factor;
116
117        // Normalize atlas pixel coords to 0..1 UVs
118        let aw_f = atlas_width.max(1) as f32;
119        let ah_f = atlas_height.max(1) as f32;
120        let u0 = ax / aw_f;
121        let v0 = ay / ah_f;
122        let u1 = (ax + aw) / aw_f;
123        let v1 = (ay + ah) / ah_f;
124
125        let [a, b, c, d, _, _] = transform.m;
126        let axis_aligned = b.abs() < GLYPH_SNAP_AXIS_EPS && c.abs() < GLYPH_SNAP_AXIS_EPS;
127        let one_to_one = axis_aligned
128            && (a * sw - aw).abs() < GLYPH_SNAP_SIZE_EPS
129            && (d * sh - ah).abs() < GLYPH_SNAP_SIZE_EPS;
130        let positions: [[f32; 2]; 4] = if one_to_one {
131            let [ox, oy] = apply_affine([sx, sy], transform);
132            let ox = ox.round();
133            let oy = oy.round();
134            [[ox, oy], [ox + aw, oy], [ox + aw, oy + ah], [ox, oy + ah]]
135        } else {
136            [
137                apply_affine([sx, sy], transform),
138                apply_affine([sx + sw, sy], transform),
139                apply_affine([sx + sw, sy + sh], transform),
140                apply_affine([sx, sy + sh], transform),
141            ]
142        };
143
144        // Color emoji glyphs carry their RGB in the atlas bitmap. Mark
145        // them with a per-vertex flag so the fragment shader can sample
146        // `texture.rgb` directly instead of applying the alpha-mask path.
147        //
148        // The upstream color for color emoji is already `[1, 1, 1, 1]`
149        // (see text-typeset's `rasterize_glyph_quad`); srgb_to_linear
150        // leaves that unchanged, so the cached value still multiplies
151        // cleanly against the sampled RGB as an opacity factor.
152        let flags = if quad.is_color {
153            QUAD_FLAG_COLOR_GLYPH
154        } else {
155            0
156        };
157        let color = srgb_to_linear_rgba(quad.color);
158
159        [
160            QuadVertex {
161                position: positions[0],
162                tex_coord: [u0, v0],
163                color,
164                flags,
165                _pad: 0,
166            },
167            QuadVertex {
168                position: positions[1],
169                tex_coord: [u1, v0],
170                color,
171                flags,
172                _pad: 0,
173            },
174            QuadVertex {
175                position: positions[2],
176                tex_coord: [u1, v1],
177                color,
178                flags,
179                _pad: 0,
180            },
181            QuadVertex {
182                position: positions[3],
183                tex_coord: [u0, v1],
184                color,
185                flags,
186                _pad: 0,
187            },
188        ]
189    }
190}
191
192/// Vertex for the colored rectangle pipeline (decorations).
193#[repr(C)]
194#[derive(Debug, Clone, Copy, Pod, Zeroable)]
195pub struct RectVertex {
196    pub position: [f32; 2],
197    pub color: [f32; 4],
198}
199
200impl RectVertex {
201    /// Convert a decoration rect to 4 vertices.
202    pub fn from_decoration(rect: &DecorationRect, scale_factor: f32) -> [RectVertex; 4] {
203        let [x, y, w, h] = rect.rect;
204        let sx = x * scale_factor;
205        let sy = y * scale_factor;
206        let sw = w * scale_factor;
207        let sh = h * scale_factor;
208
209        [
210            RectVertex {
211                position: [sx, sy],
212                color: srgb_to_linear_rgba(rect.color),
213            },
214            RectVertex {
215                position: [sx + sw, sy],
216                color: srgb_to_linear_rgba(rect.color),
217            },
218            RectVertex {
219                position: [sx + sw, sy + sh],
220                color: srgb_to_linear_rgba(rect.color),
221            },
222            RectVertex {
223                position: [sx, sy + sh],
224                color: srgb_to_linear_rgba(rect.color),
225            },
226        ]
227    }
228}
229
230/// Vertex for the SDF shape pipeline (rounded rects, circles).
231#[repr(C)]
232#[derive(Debug, Clone, Copy, Pod, Zeroable)]
233pub struct SdfVertex {
234    pub position: [f32; 2],
235    /// Local UV coordinates (0..1) within the shape bounds.
236    pub local_uv: [f32; 2],
237    pub color: [f32; 4],
238    pub corner_radii: [f32; 4],
239    /// Shape bounds in pixels: [width, height, stroke_width, paint_type].
240    /// paint_type: 0=solid, 1=linear, 2=radial, 3=conic
241    pub shape_params: [f32; 4],
242    /// Gradient geometry: [start_x, start_y, end_x, end_y] (or center/radius for radial)
243    pub gradient_geo: [f32; 4],
244    /// Gradient stop 0: [r, g, b, a]
245    pub gradient_color0: [f32; 4],
246    /// Gradient stop 1: [r, g, b, a]
247    pub gradient_color1: [f32; 4],
248    /// Gradient stop 2: [r, g, b, a]
249    pub gradient_color2: [f32; 4],
250    /// Gradient stop 3: [r, g, b, a]
251    pub gradient_color3: [f32; 4],
252    /// Gradient stop offsets: [offset0, offset1, offset2, offset3]
253    pub gradient_offsets: [f32; 4],
254}
255
256impl SdfVertex {
257    /// Convert a shape quad to 4 vertices with a **logical** stroke width —
258    /// the border scales with the view transform (the default).
259    ///
260    /// The rasterized quad is expanded outward by `stroke_width / 2 + 1` on
261    /// every side. The SDF shader paints strokes **centered** on the rect
262    /// edge, so the outer half of the stroke falls outside the shape's
263    /// bounds — if the quad isn't padded, those fragments are never
264    /// rasterized and the stroke is visibly truncated by 1 dp on every
265    /// side (most noticeable on focus rings). `local_uv` is extrapolated
266    /// past `[0, 1]` for the padding fragments; the SDF still clips
267    /// correctly because `sd_rounded_rect` returns positive distances
268    /// outside the shape.
269    pub fn from_shape_quad(shape: &ShapeQuad, scale_factor: f32) -> [SdfVertex; 4] {
270        Self::shape_quad_verts(shape, scale_factor, shape.stroke_width * scale_factor)
271    }
272
273    /// Convert a shape quad to 4 vertices with a **cosmetic** stroke: the
274    /// border width is held constant in device pixels (`width × scale_factor`)
275    /// regardless of `zoom`, while the shape body still scales with the
276    /// transform. `zoom` is the uniform scale of the active view transform
277    /// (`hypot(m[0], m[1])`).
278    ///
279    /// The shader measures the SDF in `shape_params.xy = [w·sf, h·sf]` units,
280    /// and one such unit maps to `zoom` device px on screen, so baking the
281    /// stroke param as `width·sf / zoom` lands the rendered border at exactly
282    /// `width·sf` device px at every zoom. Assumes a uniform (non-anisotropic)
283    /// transform — the scene zoom is uniform and rotation preserves the column
284    /// norm.
285    pub fn from_shape_quad_cosmetic(
286        shape: &ShapeQuad,
287        scale_factor: f32,
288        zoom: f32,
289    ) -> [SdfVertex; 4] {
290        let zoom = zoom.max(1e-3);
291        Self::shape_quad_verts(
292            shape,
293            scale_factor,
294            shape.stroke_width * scale_factor / zoom,
295        )
296    }
297
298    /// Shared core for [`from_shape_quad`](Self::from_shape_quad) and
299    /// [`from_shape_quad_cosmetic`](Self::from_shape_quad_cosmetic). Bakes
300    /// `stroke_px` (physical device pixels) as the SDF stroke param and
301    /// derives the rasterization pad from it.
302    fn shape_quad_verts(shape: &ShapeQuad, scale_factor: f32, stroke_px: f32) -> [SdfVertex; 4] {
303        let [x, y, w, h] = shape.screen;
304        let sx = x * scale_factor;
305        let sy = y * scale_factor;
306        let sw = w * scale_factor;
307        let sh = h * scale_factor;
308
309        // Encode paint type and gradient data
310        let (paint_type, gradient_geo, colors, offsets) =
311            encode_paint_data(&shape.paint_data, w, h);
312
313        let stroke = stroke_px;
314        // Rasterization padding: enough to contain the outer half of the
315        // centered stroke plus a 1 px anti-aliasing margin. Filled shapes
316        // (stroke = 0) still get the AA margin so their edges don't clip.
317        let pad = stroke * 0.5 + 1.0;
318        let u_pad = if sw > 0.0 { pad / sw } else { 0.0 };
319        let v_pad = if sh > 0.0 { pad / sh } else { 0.0 };
320
321        let params = [sw, sh, stroke, paint_type as f32];
322        // Corner radii are authored in logical px but the shader compares
323        // them against `shape_params.xy`, which is in physical px after the
324        // scale_factor multiply above. Without scaling here, a 19×19
325        // logical circle (radius 9.5) renders as a 38×38 physical rect
326        // with 9.5 px corners on Retina — a rounded square instead of a
327        // circle.
328        let scaled_corner_radii = [
329            shape.corner_radii[0] * scale_factor,
330            shape.corner_radii[1] * scale_factor,
331            shape.corner_radii[2] * scale_factor,
332            shape.corner_radii[3] * scale_factor,
333        ];
334
335        let base = SdfVertex {
336            position: [0.0, 0.0],
337            local_uv: [0.0, 0.0],
338            color: srgb_to_linear_rgba(shape.color),
339            corner_radii: scaled_corner_radii,
340            shape_params: params,
341            gradient_geo,
342            gradient_color0: srgb_to_linear_rgba(colors[0]),
343            gradient_color1: srgb_to_linear_rgba(colors[1]),
344            gradient_color2: srgb_to_linear_rgba(colors[2]),
345            gradient_color3: srgb_to_linear_rgba(colors[3]),
346            gradient_offsets: offsets,
347        };
348
349        [
350            SdfVertex {
351                position: [sx - pad, sy - pad],
352                local_uv: [-u_pad, -v_pad],
353                ..base
354            },
355            SdfVertex {
356                position: [sx + sw + pad, sy - pad],
357                local_uv: [1.0 + u_pad, -v_pad],
358                ..base
359            },
360            SdfVertex {
361                position: [sx + sw + pad, sy + sh + pad],
362                local_uv: [1.0 + u_pad, 1.0 + v_pad],
363                ..base
364            },
365            SdfVertex {
366                position: [sx - pad, sy + sh + pad],
367                local_uv: [-u_pad, 1.0 + v_pad],
368                ..base
369            },
370        ]
371    }
372}
373
374/// Encode PaintData into vertex-friendly arrays.
375/// Returns (paint_type, gradient_geo, [4 colors], [4 offsets]).
376///
377/// `pub(crate)` — shared by [`SdfVertex`] (Tier 2) and
378/// [`PathGradientVertex`] (Tier 3 gradient paths), which both encode the
379/// same `PaintData` into the same vertex-attribute shape.
380pub(crate) fn encode_paint_data(
381    paint_data: &PaintData,
382    width: f32,
383    height: f32,
384) -> (u32, [f32; 4], [[f32; 4]; 4], [f32; 4]) {
385    let zero_colors = [[0.0; 4]; 4];
386    let zero_offsets = [0.0; 4];
387
388    match paint_data {
389        PaintData::Solid => (0, [0.0; 4], zero_colors, zero_offsets),
390        PaintData::LinearGradient { start, end, stops } => {
391            // Normalize coordinates to 0..1 UV space
392            let geo = [
393                start[0] / width,
394                start[1] / height,
395                end[0] / width,
396                end[1] / height,
397            ];
398            let (colors, offsets) = encode_stops(stops);
399            (1, geo, colors, offsets)
400        }
401        PaintData::RadialGradient {
402            center,
403            radius,
404            stops,
405        } => {
406            // Normalize center and radius to UV space, accounting for aspect ratio.
407            // The shader computes distance in UV space where both axes span 0..1,
408            // so we normalize the radius relative to width (x-axis) and let the
409            // shader use aspect-corrected distance.
410            let aspect = height / width.max(0.0001);
411            let geo = [
412                center[0] / width,
413                center[1] / height,
414                *radius / width,
415                aspect,
416            ];
417            let (colors, offsets) = encode_stops(stops);
418            (2, geo, colors, offsets)
419        }
420        PaintData::ConicGradient {
421            center,
422            start_angle,
423            stops,
424        } => {
425            let geo = [center[0] / width, center[1] / height, *start_angle, 0.0];
426            let (colors, offsets) = encode_stops(stops);
427            (3, geo, colors, offsets)
428        }
429    }
430}
431
432/// Encode up to 4 gradient stops into arrays. `pub(crate)` — see
433/// [`encode_paint_data`].
434pub(crate) fn encode_stops(stops: &[teksilo_canvas::GradientStop]) -> ([[f32; 4]; 4], [f32; 4]) {
435    let mut colors = [[0.0f32; 4]; 4];
436    let mut offsets = [0.0f32; 4];
437    for (i, stop) in stops.iter().take(4).enumerate() {
438        colors[i] = stop.color.to_array();
439        offsets[i] = stop.offset;
440    }
441    // If fewer than 4 stops, repeat last to fill
442    if !stops.is_empty() {
443        let last_idx = stops.len().min(4) - 1;
444        for i in stops.len()..4 {
445            colors[i] = colors[last_idx];
446            offsets[i] = offsets[last_idx];
447        }
448    }
449    (colors, offsets)
450}
451
452/// Vertex for the gradient-filled path pipeline (Tier 3 arbitrary paths
453/// filled with a `Paint` gradient — linear/radial/conic). Solid-filled
454/// paths keep using the lean `QuadVertex`/`quad_pipeline`, tinted by a
455/// flat vertex color (see `path_quad_verts` in `renderer.rs`); this
456/// vertex type is only built when `PathEntry::paint_data` is a gradient
457/// variant, and is drawn by the dedicated `path_gradient` pipeline
458/// (`shaders/path_gradient.wgsl`).
459///
460/// `tex_coord` samples the path atlas's AA **coverage mask** (alpha
461/// channel only — the atlas always rasterizes opaque white, see
462/// `path_atlas::rasterize_path`), exactly like `QuadVertex`'s monochrome-
463/// glyph path. `local_uv` is the shape-local 0..1 placement used for the
464/// analytic gradient math — same meaning as `SdfVertex::local_uv`. The
465/// gradient fields mirror `SdfVertex`'s layout exactly so the shared
466/// `encode_paint_data`/`encode_stops` helpers apply unchanged.
467#[repr(C)]
468#[derive(Debug, Clone, Copy, Pod, Zeroable)]
469pub struct PathGradientVertex {
470    pub position: [f32; 2],
471    /// Atlas UV — samples the path atlas's AA coverage mask.
472    pub tex_coord: [f32; 2],
473    /// Shape-local UV (0..1 across the path's bounds) — gradient placement.
474    pub local_uv: [f32; 2],
475    /// 1 = linear, 2 = radial, 3 = conic. Never 0 (Solid) — solid fills
476    /// never build a `PathGradientVertex`; see `path_gradient_quad_verts`
477    /// in `renderer.rs`, which branches on `PathEntry::paint_data` before
478    /// choosing this pipeline.
479    pub paint_type: u32,
480    /// Padding so the struct stride stays a multiple of 8 bytes.
481    pub _pad: u32,
482    /// Gradient geometry: `[start_x, start_y, end_x, end_y]` (or
483    /// center/radius for radial, center/angle for conic) in shape-local
484    /// UV space — see `encode_paint_data`.
485    pub gradient_geo: [f32; 4],
486    /// Gradient stop 0: [r, g, b, a]
487    pub gradient_color0: [f32; 4],
488    /// Gradient stop 1: [r, g, b, a]
489    pub gradient_color1: [f32; 4],
490    /// Gradient stop 2: [r, g, b, a]
491    pub gradient_color2: [f32; 4],
492    /// Gradient stop 3: [r, g, b, a]
493    pub gradient_color3: [f32; 4],
494    /// Gradient stop offsets: [offset0, offset1, offset2, offset3]
495    pub gradient_offsets: [f32; 4],
496}
497
498impl PathGradientVertex {
499    /// Build the 4 vertices for a gradient-filled path quad. Mirrors
500    /// `path_quad_verts`'s bounds/atlas-UV/position math (Tier 3, solid
501    /// paths) — same pixel-space quad, same atlas-region UV lookup, same
502    /// `transform` composition inside the function — but emits full
503    /// `paint_type` + gradient fields via the shared `encode_paint_data`
504    /// instead of a single flat tinted color.
505    ///
506    /// `opacity` is folded into EACH gradient stop's alpha
507    /// (`gradient_colorN[3] *= opacity`), not a flat `color` field,
508    /// because `path_gradient.wgsl`'s fragment shader ignores any flat
509    /// color for gradient paint types and only ever reads the gradient
510    /// stops — unlike the SDF pipeline, which (pre-existingly, and out of
511    /// scope here) does not fold `SetOpacity` into gradient `ShapeQuad`s.
512    pub(crate) fn from_path_entry(
513        entry: &PathEntry,
514        region: &crate::path_atlas::AtlasRegion,
515        scale_factor: f32,
516        atlas_width: u32,
517        atlas_height: u32,
518        opacity: f32,
519        transform: &Transform2D,
520    ) -> [PathGradientVertex; 4] {
521        let [bx, by, bw, bh] = entry.bounds;
522        let sx = bx * scale_factor;
523        let sy = by * scale_factor;
524        let sw = bw * scale_factor;
525        let sh = bh * scale_factor;
526
527        let aw = atlas_width.max(1) as f32;
528        let ah = atlas_height.max(1) as f32;
529        let u0 = region.x as f32 / aw;
530        let v0 = region.y as f32 / ah;
531        let u1 = (region.x + region.w) as f32 / aw;
532        let v1 = (region.y + region.h) as f32 / ah;
533
534        let (paint_type, gradient_geo, raw_colors, gradient_offsets) =
535            encode_paint_data(&entry.paint_data, bw, bh);
536        // Linearize (sRGB → linear, matching every other pipeline) and
537        // fold opacity into alpha — see the doc comment above.
538        let colors: [[f32; 4]; 4] = std::array::from_fn(|i| {
539            let mut c = srgb_to_linear_rgba(raw_colors[i]);
540            c[3] *= opacity;
541            c
542        });
543
544        let positions = [
545            apply_affine([sx, sy], transform),
546            apply_affine([sx + sw, sy], transform),
547            apply_affine([sx + sw, sy + sh], transform),
548            apply_affine([sx, sy + sh], transform),
549        ];
550        let tex_coords = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
551        let local_uvs: [[f32; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
552
553        std::array::from_fn(|i| PathGradientVertex {
554            position: positions[i],
555            tex_coord: tex_coords[i],
556            local_uv: local_uvs[i],
557            paint_type,
558            _pad: 0,
559            gradient_geo,
560            gradient_color0: colors[0],
561            gradient_color1: colors[1],
562            gradient_color2: colors[2],
563            gradient_color3: colors[3],
564            gradient_offsets,
565        })
566    }
567}
568
569/// Standard quad indices for two triangles from 4 vertices.
570///
571/// 32-bit indices: a frame can carry far more than 16 384 quads (the u16
572/// vertex-index ceiling) — large text runs, dense data grids, big scenes —
573/// and a single contiguous batch can approach that count. u16 indices would
574/// silently wrap past vertex 65 535, corrupting draw calls in release and
575/// panicking in debug. The quad index buffer is therefore `Uint32`.
576pub const QUAD_INDICES: [u32; 6] = [0, 1, 2, 0, 2, 3];
577
578/// Generate indices for N quads.
579pub fn generate_quad_indices(count: usize) -> Vec<u32> {
580    let mut indices = Vec::with_capacity(count * 6);
581    for i in 0..count {
582        let base = (i * 4) as u32;
583        for &offset in &QUAD_INDICES {
584            indices.push(base + offset);
585        }
586    }
587    indices
588}
589
590/// Vertex for the shadow pipeline (box shadows with Gaussian blur).
591#[repr(C)]
592#[derive(Debug, Clone, Copy, Pod, Zeroable)]
593pub struct ShadowVertex {
594    pub position: [f32; 2],
595    /// Local UV coordinates (0..1) within the shadow quad bounds.
596    pub local_uv: [f32; 2],
597    pub shadow_color: [f32; 4],
598    pub corner_radii: [f32; 4],
599    /// [shape_width, shape_height, blur_radius, spread].
600    pub shadow_params: [f32; 4],
601    /// [offset_x, offset_y, 0, 0] — offset of inner shape center within shadow quad.
602    pub shape_offset: [f32; 4],
603}
604
605impl ShadowVertex {
606    /// Convert a shadow quad to 4 vertices.
607    pub fn from_shadow_quad(shadow: &ShadowQuad, scale_factor: f32) -> [ShadowVertex; 4] {
608        let [x, y, w, h] = shadow.screen;
609        let sx = x * scale_factor;
610        let sy = y * scale_factor;
611        let sw = w * scale_factor;
612        let sh = h * scale_factor;
613
614        let [sr_x, sr_y, sr_w, sr_h] = shadow.shape_rect;
615        let shape_w = sr_w * scale_factor;
616        let shape_h = sr_h * scale_factor;
617
618        // Offset of shape center relative to shadow quad center
619        let shadow_cx = sx + sw * 0.5;
620        let shadow_cy = sy + sh * 0.5;
621        let shape_cx = (sr_x + sr_w * 0.5) * scale_factor;
622        let shape_cy = (sr_y + sr_h * 0.5) * scale_factor;
623        let offset_x = shape_cx - shadow_cx;
624        let offset_y = shape_cy - shadow_cy;
625
626        let params = [
627            shape_w,
628            shape_h,
629            shadow.blur_radius * scale_factor,
630            shadow.spread * scale_factor,
631        ];
632        let offset = [offset_x, offset_y, 0.0, 0.0];
633        // Match the SDF pipeline: shadow_params.xy is in physical px after
634        // scale_factor, so the matching corner radii also have to be in
635        // physical px. Otherwise circular/pill shadow shapes degenerate
636        // into rounded squares on Retina.
637        let scaled_corner_radii = [
638            shadow.corner_radii[0] * scale_factor,
639            shadow.corner_radii[1] * scale_factor,
640            shadow.corner_radii[2] * scale_factor,
641            shadow.corner_radii[3] * scale_factor,
642        ];
643
644        [
645            ShadowVertex {
646                position: [sx, sy],
647                local_uv: [0.0, 0.0],
648                shadow_color: srgb_to_linear_rgba(shadow.color),
649                corner_radii: scaled_corner_radii,
650                shadow_params: params,
651                shape_offset: offset,
652            },
653            ShadowVertex {
654                position: [sx + sw, sy],
655                local_uv: [1.0, 0.0],
656                shadow_color: srgb_to_linear_rgba(shadow.color),
657                corner_radii: scaled_corner_radii,
658                shadow_params: params,
659                shape_offset: offset,
660            },
661            ShadowVertex {
662                position: [sx + sw, sy + sh],
663                local_uv: [1.0, 1.0],
664                shadow_color: srgb_to_linear_rgba(shadow.color),
665                corner_radii: scaled_corner_radii,
666                shadow_params: params,
667                shape_offset: offset,
668            },
669            ShadowVertex {
670                position: [sx, sy + sh],
671                local_uv: [0.0, 1.0],
672                shadow_color: srgb_to_linear_rgba(shadow.color),
673                corner_radii: scaled_corner_radii,
674                shadow_params: params,
675                shape_offset: offset,
676            },
677        ]
678    }
679}
680
681/// Vertex for the shader-driven animated-quad pipeline (procedural
682/// and sprite kinds). All four vertices of a quad carry the same
683/// `slot`, which the fragment shader uses to look up per-frame state
684/// (phase, resolved colors, atlas dims) in the `anim_uniforms` buffer.
685/// No color or timing is baked in the vertex — that's the whole point:
686/// rebuilding the vertex batch is unnecessary when only the phase
687/// changes, so the widget's `paint()` doesn't re-run per frame.
688#[repr(C)]
689#[derive(Debug, Clone, Copy, Pod, Zeroable)]
690pub struct AnimQuadVertex {
691    /// Pixel position; converted to NDC in the render loop.
692    pub position: [f32; 2],
693    /// Local UV within the quad (0..1 across each axis). The fragment
694    /// shader uses `uv.x` to decide sweep inclusion; the sprite shader
695    /// combines it with `AnimParams::atlas_cols`/`atlas_rows` to sample
696    /// the atlas cell.
697    pub uv: [f32; 2],
698    /// Index into the renderer's `AnimParams` uniform array. Same for
699    /// all four vertices of a quad; declared `@interpolate(flat)` in
700    /// WGSL to preserve the integer across rasterization.
701    pub slot: u32,
702    /// Struct padding to keep stride a multiple of 8 bytes (matches
703    /// `QuadVertex` convention for wgpu vertex-buffer layouts).
704    pub _pad: u32,
705}
706
707impl AnimQuadVertex {
708    pub fn from_animated_quad(
709        draw: &teksilo_canvas::AnimatedQuadDraw,
710        scale_factor: f32,
711    ) -> [AnimQuadVertex; 4] {
712        let [x, y, w, h] = draw.screen;
713        let sx = x * scale_factor;
714        let sy = y * scale_factor;
715        let sw = w * scale_factor;
716        let sh = h * scale_factor;
717        [
718            AnimQuadVertex {
719                position: [sx, sy],
720                uv: [0.0, 0.0],
721                slot: draw.slot,
722                _pad: 0,
723            },
724            AnimQuadVertex {
725                position: [sx + sw, sy],
726                uv: [1.0, 0.0],
727                slot: draw.slot,
728                _pad: 0,
729            },
730            AnimQuadVertex {
731                position: [sx + sw, sy + sh],
732                uv: [1.0, 1.0],
733                slot: draw.slot,
734                _pad: 0,
735            },
736            AnimQuadVertex {
737                position: [sx, sy + sh],
738                uv: [0.0, 1.0],
739                slot: draw.slot,
740                _pad: 0,
741            },
742        ]
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749    use teksilo_canvas::{DecorationKind, GradientStop, PaintData, ShapeKind, StrokeSpace};
750    use teksilo_tokens::Color;
751
752    /// Build a glyph quad with the given screen rect and atlas rect.
753    fn glyph(screen: [f32; 4], atlas: [f32; 4], is_color: bool) -> GlyphQuad {
754        GlyphQuad {
755            screen,
756            atlas,
757            color: [1.0, 1.0, 1.0, 1.0],
758            is_color,
759        }
760    }
761
762    fn assert_pos_near(actual: [f32; 2], expected: [f32; 2]) {
763        assert!(
764            (actual[0] - expected[0]).abs() < 1e-3 && (actual[1] - expected[1]).abs() < 1e-3,
765            "position {actual:?} != expected {expected:?}"
766        );
767    }
768
769    #[test]
770    fn glyph_quad_to_vertices() {
771        // Quad size (30×40) ≠ atlas size (64×64) → no snap; identity
772        // transform passes positions through unchanged.
773        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 64.0, 64.0], false);
774        let verts =
775            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
776        assert_eq!(verts.len(), 4);
777        assert_eq!(verts[0].position, [10.0, 20.0]);
778        assert_eq!(verts[1].position, [40.0, 20.0]); // x + w
779        assert_eq!(verts[2].position, [40.0, 60.0]); // x + w, y + h
780        // Atlas coords normalized: 64/256 = 0.25
781        assert_eq!(verts[0].tex_coord, [0.0, 0.0]);
782        assert_eq!(verts[2].tex_coord, [0.25, 0.25]);
783    }
784
785    #[test]
786    fn scale_factor_applied_to_glyph_coords() {
787        // Physical size 60×80 ≠ atlas 128×128 → no snap.
788        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 128.0, 128.0], false);
789        let verts =
790            QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &Transform2D::IDENTITY);
791        assert_eq!(verts[0].position, [20.0, 40.0]);
792        assert_eq!(verts[1].position, [80.0, 40.0]);
793    }
794
795    #[test]
796    fn glyph_snap_identity_fractional_origin() {
797        // 1:1 quad (30×40 == atlas 30×40) at a fractional origin: the
798        // origin rounds to the pixel grid and the far corner is pinned at
799        // exactly origin + bitmap size.
800        let quad = glyph([10.3, 20.7, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
801        let verts =
802            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
803        assert_eq!(verts[0].position, [10.0, 21.0]);
804        assert_eq!(verts[1].position, [40.0, 21.0]);
805        assert_eq!(verts[2].position, [40.0, 61.0]);
806        assert_eq!(verts[3].position, [10.0, 61.0]);
807    }
808
809    #[test]
810    fn glyph_snap_hidpi_scale_factor() {
811        // sf=2: logical 16×16 → physical 32×32 == atlas bitmap. Fractional
812        // logical origin (5.7, 8.3) → physical (11.4, 16.6) → snaps to
813        // (11, 17).
814        let quad = glyph([5.7, 8.3, 16.0, 16.0], [0.0, 0.0, 32.0, 32.0], false);
815        let verts =
816            QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &Transform2D::IDENTITY);
817        assert_eq!(verts[0].position, [11.0, 17.0]);
818        assert_eq!(verts[2].position, [43.0, 49.0]);
819    }
820
821    #[test]
822    fn glyph_snap_fractional_dpi() {
823        // sf=1.25 (Linux fractional scaling): logical 20×20 → physical
824        // 25×25 == atlas bitmap. Origin (4.2, 7.8) → (5.25, 9.75) →
825        // snaps to (5, 10).
826        let quad = glyph([4.2, 7.8, 20.0, 20.0], [0.0, 0.0, 25.0, 25.0], false);
827        let verts =
828            QuadVertex::from_glyph_quad_transformed(&quad, 1.25, 256, 256, &Transform2D::IDENTITY);
829        assert_eq!(verts[0].position, [5.0, 10.0]);
830        assert_eq!(verts[2].position, [30.0, 35.0]);
831    }
832
833    #[test]
834    fn glyph_snap_exact_bucket_zoom() {
835        // A 1.25× zoom transform over a raster_scale=1.25 bucket: the
836        // bitmap is 1.25× denser (atlas 50×50 for a 20×20-logical glyph at
837        // sf=2 → pre-transform physical 40×40), so the transformed size
838        // (1.25·40 = 50) matches the bitmap exactly → snap fires even
839        // under zoom. Fractional translation rounds away.
840        let quad = glyph([4.0, 8.0, 20.0, 20.0], [0.0, 0.0, 50.0, 50.0], false);
841        let zoom = Transform2D {
842            m: [1.25, 0.0, 0.0, 1.25, 3.3, 7.8],
843        };
844        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &zoom);
845        // origin: (1.25·8 + 3.3, 1.25·16 + 7.8) = (13.3, 27.8) → (13, 28)
846        assert_eq!(verts[0].position, [13.0, 28.0]);
847        assert_eq!(verts[2].position, [63.0, 78.0]);
848    }
849
850    #[test]
851    fn glyph_no_snap_mid_bucket_residual() {
852        // A 1.1× zoom over a 1.25-bucket raster: transformed size
853        // (1.1·40 = 44) ≠ bitmap (50) → residual GPU scaling, no snap;
854        // all corners go through the plain affine transform.
855        let quad = glyph([4.0, 8.0, 20.0, 20.0], [0.0, 0.0, 50.0, 50.0], false);
856        let zoom = Transform2D {
857            m: [1.1, 0.0, 0.0, 1.1, 3.3, 7.8],
858        };
859        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &zoom);
860        assert_pos_near(verts[0].position, [1.1 * 8.0 + 3.3, 1.1 * 16.0 + 7.8]);
861        assert_pos_near(verts[2].position, [1.1 * 48.0 + 3.3, 1.1 * 56.0 + 7.8]);
862    }
863
864    #[test]
865    fn glyph_no_snap_rotation() {
866        // Rotated transform (b, c ≠ 0) never snaps, even at matching size.
867        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
868        let (s, c) = (0.1_f32.sin(), 0.1_f32.cos());
869        let rot = Transform2D {
870            m: [c, s, -s, c, 0.0, 0.0],
871        };
872        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &rot);
873        assert_pos_near(
874            verts[0].position,
875            [c * 10.0 - s * 20.0, s * 10.0 + c * 20.0],
876        );
877        assert_pos_near(
878            verts[2].position,
879            [c * 40.0 - s * 60.0, s * 40.0 + c * 60.0],
880        );
881    }
882
883    #[test]
884    fn glyph_snap_translation_only_transform() {
885        // Pure fractional translation (e.g. scroll offset) still maps 1:1
886        // → snapped.
887        let quad = glyph([10.3, 20.0, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
888        let pan = Transform2D {
889            m: [1.0, 0.0, 0.0, 1.0, 5.7, 3.2],
890        };
891        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &pan);
892        // origin: (10.3 + 5.7, 20.0 + 3.2) = (16.0, 23.2) → (16, 23)
893        assert_eq!(verts[0].position, [16.0, 23.0]);
894        assert_eq!(verts[2].position, [46.0, 63.0]);
895    }
896
897    #[test]
898    fn glyph_snap_color_emoji_flag_preserved() {
899        let quad = glyph([10.3, 20.7, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], true);
900        let verts =
901            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
902        assert_eq!(verts[0].position, [10.0, 21.0]);
903        for v in &verts {
904            assert_eq!(v.flags, QUAD_FLAG_COLOR_GLYPH);
905        }
906    }
907
908    #[test]
909    fn glyph_uvs_independent_of_snapping() {
910        // UVs come from the atlas rect alone — identical whether the
911        // position path snapped or not.
912        let quad = glyph([10.3, 20.7, 30.0, 40.0], [16.0, 32.0, 30.0, 40.0], false);
913        let snapped =
914            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
915        let residual = Transform2D {
916            m: [1.1, 0.0, 0.0, 1.1, 0.0, 0.0],
917        };
918        let unsnapped = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &residual);
919        for (a, b) in snapped.iter().zip(unsnapped.iter()) {
920            assert_eq!(a.tex_coord, b.tex_coord);
921        }
922        assert_eq!(snapped[0].tex_coord, [16.0 / 256.0, 32.0 / 256.0]);
923        assert_eq!(snapped[2].tex_coord, [46.0 / 256.0, 72.0 / 256.0]);
924    }
925
926    #[test]
927    fn decoration_rect_to_vertices() {
928        let rect = DecorationRect {
929            rect: [0.0, 0.0, 100.0, 2.0],
930            color: [1.0, 0.0, 0.0, 1.0],
931            kind: DecorationKind::FocusRing,
932        };
933        let verts = RectVertex::from_decoration(&rect, 1.0);
934        assert_eq!(verts.len(), 4);
935        assert_eq!(verts[0].position, [0.0, 0.0]);
936        assert_eq!(verts[2].position, [100.0, 2.0]);
937    }
938
939    #[test]
940    fn shape_quad_to_sdf_vertices() {
941        let shape = ShapeQuad {
942            screen: [0.0, 0.0, 100.0, 40.0],
943            color: [0.0, 0.5, 0.0, 1.0],
944            shape: ShapeKind::RoundedRect,
945            stroke_width: 0.0,
946            stroke_space: StrokeSpace::Logical,
947            corner_radii: [6.0, 6.0, 6.0, 6.0],
948            paint_data: PaintData::Solid,
949        };
950        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
951        assert_eq!(verts.len(), 4);
952        assert_eq!(verts[0].corner_radii, [6.0, 6.0, 6.0, 6.0]);
953        // Unfilled: quad is padded by the 1 dp AA margin on each side.
954        // local_uv is extrapolated correspondingly.
955        assert_eq!(verts[0].position, [-1.0, -1.0]);
956        assert_eq!(verts[2].position, [101.0, 41.0]);
957        assert!((verts[0].local_uv[0] - (-0.01)).abs() < 1e-5);
958        assert!((verts[0].local_uv[1] - (-0.025)).abs() < 1e-5);
959        assert!((verts[2].local_uv[0] - 1.01).abs() < 1e-5);
960        assert!((verts[2].local_uv[1] - 1.025).abs() < 1e-5);
961    }
962
963    #[test]
964    fn sdf_scale_factor() {
965        let shape = ShapeQuad {
966            screen: [10.0, 10.0, 100.0, 40.0],
967            color: [0.0, 0.0, 0.0, 1.0],
968            shape: ShapeKind::RoundedRect,
969            stroke_width: 2.0,
970            stroke_space: StrokeSpace::Logical,
971            corner_radii: [4.0; 4],
972            paint_data: PaintData::Solid,
973        };
974        let verts = SdfVertex::from_shape_quad(&shape, 2.0);
975        // Scaled origin (20, 20) is further offset by the rasterization pad
976        // (stroke/2 + 1) = (2*2)/2 + 1 = 3 pixels.
977        assert_eq!(verts[0].position, [17.0, 17.0]);
978        assert_eq!(verts[0].shape_params[2], 4.0); // stroke_width * 2
979        // Corner radii must scale with the rect so a circle stays a circle
980        // on HiDPI. shape_params.xy is in physical px; corner_radii has to
981        // match or radius/half_size diverges.
982        assert_eq!(verts[0].corner_radii, [8.0; 4]);
983    }
984
985    #[test]
986    fn sdf_circle_stays_circle_on_hidpi() {
987        // Regression: a 19×19 logical rect with 9.5 px corner radius is a
988        // perfect circle. On Retina (scale_factor 2) the shader works in
989        // physical px against `shape_params.xy`. If corner_radii is left in
990        // logical px, the radio button / toggle pill renders as a rounded
991        // square instead of a circle.
992        let shape = ShapeQuad {
993            screen: [0.0, 0.0, 19.0, 19.0],
994            color: [0.0, 0.0, 0.0, 1.0],
995            shape: ShapeKind::RoundedRect,
996            stroke_width: 0.0,
997            stroke_space: StrokeSpace::Logical,
998            corner_radii: [9.5; 4],
999            paint_data: PaintData::Solid,
1000        };
1001        let verts = SdfVertex::from_shape_quad(&shape, 2.0);
1002        assert_eq!(verts[0].shape_params[0], 38.0);
1003        assert_eq!(verts[0].shape_params[1], 38.0);
1004        assert_eq!(verts[0].corner_radii, [19.0; 4]);
1005    }
1006
1007    #[test]
1008    fn cosmetic_shape_stroke_param_is_inverse_zoom() {
1009        // Cosmetic border: the baked SDF stroke param = width·sf / zoom, so
1010        // after the shader's per-unit ×zoom mapping the border lands at a
1011        // constant width·sf device px at any zoom. The body size params stay
1012        // put (the body still zooms via the view transform).
1013        let shape = ShapeQuad {
1014            screen: [0.0, 0.0, 100.0, 100.0],
1015            color: [0.0, 0.0, 0.0, 1.0],
1016            shape: ShapeKind::RoundedRect,
1017            stroke_width: 2.0,
1018            stroke_space: StrokeSpace::Device,
1019            corner_radii: [10.0; 4],
1020            paint_data: PaintData::Solid,
1021        };
1022        let sf = 2.0;
1023        let logical = SdfVertex::from_shape_quad(&shape, sf);
1024        let z1 = SdfVertex::from_shape_quad_cosmetic(&shape, sf, 1.0);
1025        let z2 = SdfVertex::from_shape_quad_cosmetic(&shape, sf, 2.0);
1026        // zoom 1 matches the logical bake: width·sf = 2·2 = 4.
1027        assert!((z1[0].shape_params[2] - logical[0].shape_params[2]).abs() < 1e-4);
1028        assert!((z1[0].shape_params[2] - 4.0).abs() < 1e-4);
1029        // zoom 2 halves the param so the on-screen width stays width·sf.
1030        assert!((z2[0].shape_params[2] - 2.0).abs() < 1e-4);
1031        // Body size params unchanged across zoom (the quad corners zoom, not
1032        // the SDF body units): width·sf = 100·2 = 200.
1033        assert_eq!(z1[0].shape_params[0], z2[0].shape_params[0]);
1034        assert_eq!(z2[0].shape_params[0], 200.0);
1035    }
1036
1037    #[test]
1038    fn sdf_linear_gradient_encoding() {
1039        let shape = ShapeQuad {
1040            screen: [0.0, 0.0, 100.0, 50.0],
1041            color: [1.0, 1.0, 1.0, 1.0],
1042            shape: ShapeKind::RoundedRect,
1043            stroke_width: 0.0,
1044            stroke_space: StrokeSpace::Logical,
1045            corner_radii: [0.0; 4],
1046            paint_data: PaintData::LinearGradient {
1047                start: [0.0, 0.0],
1048                end: [100.0, 0.0],
1049                stops: vec![
1050                    GradientStop {
1051                        offset: 0.0,
1052                        color: Color::RED,
1053                    },
1054                    GradientStop {
1055                        offset: 1.0,
1056                        color: Color::BLUE,
1057                    },
1058                ],
1059            },
1060        };
1061        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
1062        // paint_type = 1 (linear)
1063        assert!((verts[0].shape_params[3] - 1.0).abs() < 0.01);
1064        // gradient_geo: start=(0,0), end=(1,0) in UV
1065        assert!((verts[0].gradient_geo[0]).abs() < 0.01);
1066        assert!((verts[0].gradient_geo[2] - 1.0).abs() < 0.01);
1067        // First stop is red
1068        assert!((verts[0].gradient_color0[0] - 1.0).abs() < 0.01);
1069        // Offsets
1070        assert!((verts[0].gradient_offsets[0]).abs() < 0.01);
1071        assert!((verts[0].gradient_offsets[1] - 1.0).abs() < 0.01);
1072    }
1073
1074    #[test]
1075    fn linear_gradient_endpoints_are_rect_local_not_absolute() {
1076        // Regression for the HSV-canvas bug: the gradient endpoints
1077        // are normalized by the rect's width/height (`encode_paint_data`
1078        // doesn't see the rect origin), so callers MUST pass them in
1079        // rect-local coordinates. A rect at non-origin with rect-local
1080        // endpoints (0,0)→(0,h) must encode to start_uv=(0,0) and
1081        // end_uv=(0,1) — full gradient sampling across the rect.
1082        // Passing absolute coords would shift the endpoints away and
1083        // visibly squash the gradient.
1084        let shape = ShapeQuad {
1085            screen: [50.0, 100.0, 200.0, 200.0],
1086            color: [1.0, 1.0, 1.0, 1.0],
1087            shape: ShapeKind::RoundedRect,
1088            stroke_width: 0.0,
1089            stroke_space: StrokeSpace::Logical,
1090            corner_radii: [0.0; 4],
1091            paint_data: PaintData::LinearGradient {
1092                start: [0.0, 0.0],
1093                end: [0.0, 200.0],
1094                stops: vec![
1095                    GradientStop {
1096                        offset: 0.0,
1097                        color: Color::new(0.0, 0.0, 0.0, 0.0),
1098                    },
1099                    GradientStop {
1100                        offset: 1.0,
1101                        color: Color::BLACK,
1102                    },
1103                ],
1104            },
1105        };
1106        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
1107        assert!((verts[0].gradient_geo[0]).abs() < 1e-5, "start_uv.x");
1108        assert!((verts[0].gradient_geo[1]).abs() < 1e-5, "start_uv.y");
1109        assert!((verts[0].gradient_geo[2]).abs() < 1e-5, "end_uv.x");
1110        assert!((verts[0].gradient_geo[3] - 1.0).abs() < 1e-5, "end_uv.y");
1111    }
1112
1113    /// Rasterize `entry`'s path into a scratch atlas and return the
1114    /// resulting region — the public-API way to obtain an `AtlasRegion`
1115    /// for a `PathGradientVertex` test (its `last_used_frame` field is
1116    /// private to `path_atlas`, so tests outside that module can't
1117    /// construct one by hand).
1118    fn rasterize_for_test(entry: &PathEntry, atlas_size: u32) -> crate::path_atlas::AtlasRegion {
1119        let mut atlas = crate::path_atlas::PathAtlas::new(atlas_size, atlas_size);
1120        atlas.begin_frame();
1121        atlas
1122            .lookup_or_rasterize(
1123                &entry.path,
1124                &entry.stroke_style,
1125                entry.fill_rule,
1126                entry.bounds,
1127                1.0,
1128                1.0,
1129            )
1130            .expect("test path rasterizes")
1131    }
1132
1133    fn gradient_path_entry(bounds_rect: teksilo_canvas::Rect, paint_data: PaintData) -> PathEntry {
1134        use teksilo_canvas::{FillRule, StrokeStyle};
1135        PathEntry {
1136            path: teksilo_canvas::Path::rect(bounds_rect),
1137            color: [1.0, 1.0, 1.0, 1.0],
1138            stroke_style: StrokeStyle::solid(0.0),
1139            fill_rule: FillRule::Winding,
1140            bounds: bounds_rect.to_array(),
1141            paint_data,
1142        }
1143    }
1144
1145    #[test]
1146    fn path_gradient_linear_encoding() {
1147        let bounds_rect = teksilo_canvas::Rect::new(0.0, 0.0, 100.0, 50.0);
1148        let entry = gradient_path_entry(
1149            bounds_rect,
1150            PaintData::LinearGradient {
1151                start: [0.0, 0.0],
1152                end: [100.0, 0.0],
1153                stops: vec![
1154                    GradientStop {
1155                        offset: 0.0,
1156                        color: Color::RED,
1157                    },
1158                    GradientStop {
1159                        offset: 1.0,
1160                        color: Color::BLUE,
1161                    },
1162                ],
1163            },
1164        );
1165        let region = rasterize_for_test(&entry, 256);
1166
1167        let verts = PathGradientVertex::from_path_entry(
1168            &entry,
1169            &region,
1170            1.0,
1171            256,
1172            256,
1173            1.0,
1174            &Transform2D::IDENTITY,
1175        );
1176
1177        // paint_type = 1 (linear)
1178        assert_eq!(verts[0].paint_type, 1);
1179        // gradient_geo: start=(0,0), end=(1,0) in UV
1180        assert!((verts[0].gradient_geo[0]).abs() < 0.01);
1181        assert!((verts[0].gradient_geo[2] - 1.0).abs() < 0.01);
1182        // First stop is red (pure red/blue are fixed points of sRGB→linear)
1183        assert!((verts[0].gradient_color0[0] - 1.0).abs() < 0.01);
1184        assert!((verts[0].gradient_color0[1]).abs() < 0.01);
1185        // Offsets
1186        assert!((verts[0].gradient_offsets[0]).abs() < 0.01);
1187        assert!((verts[0].gradient_offsets[1] - 1.0).abs() < 0.01);
1188        // local_uv corners follow the same 0..1 convention as SdfVertex.
1189        assert_eq!(verts[0].local_uv, [0.0, 0.0]);
1190        assert_eq!(verts[1].local_uv, [1.0, 0.0]);
1191        assert_eq!(verts[2].local_uv, [1.0, 1.0]);
1192        assert_eq!(verts[3].local_uv, [0.0, 1.0]);
1193    }
1194
1195    #[test]
1196    fn path_gradient_endpoints_are_rect_local_not_absolute() {
1197        // Same regression as `linear_gradient_endpoints_are_rect_local_not_absolute`,
1198        // for the Tier-3 path case: gradient endpoints are normalized by
1199        // the path bounds' width/height alone (`encode_paint_data` never
1200        // sees the bounds origin), so a path positioned away from the
1201        // origin must still encode the same normalized start/end UVs.
1202        let bounds_rect = teksilo_canvas::Rect::new(50.0, 100.0, 200.0, 200.0);
1203        let entry = gradient_path_entry(
1204            bounds_rect,
1205            PaintData::LinearGradient {
1206                start: [0.0, 0.0],
1207                end: [0.0, 200.0],
1208                stops: vec![
1209                    GradientStop {
1210                        offset: 0.0,
1211                        color: Color::new(0.0, 0.0, 0.0, 0.0),
1212                    },
1213                    GradientStop {
1214                        offset: 1.0,
1215                        color: Color::BLACK,
1216                    },
1217                ],
1218            },
1219        );
1220        let region = rasterize_for_test(&entry, 512);
1221
1222        let verts = PathGradientVertex::from_path_entry(
1223            &entry,
1224            &region,
1225            1.0,
1226            512,
1227            512,
1228            1.0,
1229            &Transform2D::IDENTITY,
1230        );
1231        assert!((verts[0].gradient_geo[0]).abs() < 1e-5, "start_uv.x");
1232        assert!((verts[0].gradient_geo[1]).abs() < 1e-5, "start_uv.y");
1233        assert!((verts[0].gradient_geo[2]).abs() < 1e-5, "end_uv.x");
1234        assert!((verts[0].gradient_geo[3] - 1.0).abs() < 1e-5, "end_uv.y");
1235    }
1236
1237    #[test]
1238    fn path_gradient_opacity_folds_into_every_stop_alpha() {
1239        // The fold-opacity-into-gradient-stops fix this pipeline adds
1240        // (see `PathGradientVertex::from_path_entry` doc comment): unlike
1241        // the SDF pipeline's pre-existing gap, opacity must reach EVERY
1242        // gradient stop's alpha, since the fragment shader ignores any
1243        // flat vertex color for gradient paint types.
1244        let bounds_rect = teksilo_canvas::Rect::new(0.0, 0.0, 40.0, 20.0);
1245        let entry = gradient_path_entry(
1246            bounds_rect,
1247            PaintData::LinearGradient {
1248                start: [0.0, 0.0],
1249                end: [40.0, 0.0],
1250                stops: vec![
1251                    GradientStop {
1252                        offset: 0.0,
1253                        color: Color::RED,
1254                    },
1255                    GradientStop {
1256                        offset: 1.0,
1257                        color: Color::BLUE,
1258                    },
1259                ],
1260            },
1261        );
1262        let region = rasterize_for_test(&entry, 128);
1263
1264        let full = PathGradientVertex::from_path_entry(
1265            &entry,
1266            &region,
1267            1.0,
1268            128,
1269            128,
1270            1.0,
1271            &Transform2D::IDENTITY,
1272        );
1273        let half = PathGradientVertex::from_path_entry(
1274            &entry,
1275            &region,
1276            1.0,
1277            128,
1278            128,
1279            0.5,
1280            &Transform2D::IDENTITY,
1281        );
1282
1283        assert!((full[0].gradient_color0[3] - 1.0).abs() < 1e-5);
1284        assert!((half[0].gradient_color0[3] - 0.5).abs() < 1e-5);
1285        assert!((half[0].gradient_color1[3] - 0.5).abs() < 1e-5);
1286    }
1287
1288    #[test]
1289    fn generate_indices_for_multiple_quads() {
1290        let indices = generate_quad_indices(2);
1291        assert_eq!(indices.len(), 12);
1292        assert_eq!(&indices[0..6], &[0, 1, 2, 0, 2, 3]);
1293        assert_eq!(&indices[6..12], &[4, 5, 6, 4, 6, 7]);
1294    }
1295
1296    #[test]
1297    fn shadow_quad_to_vertices() {
1298        let shadow = ShadowQuad {
1299            screen: [0.0, 0.0, 120.0, 60.0],
1300            color: [0.0, 0.0, 0.0, 0.3],
1301            corner_radii: [6.0; 4],
1302            shape_rect: [10.0, 8.0, 100.0, 40.0],
1303            blur_radius: 4.0,
1304            spread: 0.0,
1305        };
1306        let verts = ShadowVertex::from_shadow_quad(&shadow, 1.0);
1307        assert_eq!(verts.len(), 4);
1308        assert_eq!(verts[0].position, [0.0, 0.0]);
1309        assert_eq!(verts[2].position, [120.0, 60.0]);
1310        assert_eq!(verts[0].shadow_params[2], 4.0); // blur_radius
1311        assert_eq!(verts[0].corner_radii, [6.0; 4]);
1312    }
1313
1314    #[test]
1315    fn shadow_scale_factor() {
1316        let shadow = ShadowQuad {
1317            screen: [10.0, 10.0, 120.0, 60.0],
1318            color: [0.0, 0.0, 0.0, 0.3],
1319            corner_radii: [6.0; 4],
1320            shape_rect: [20.0, 18.0, 100.0, 40.0],
1321            blur_radius: 4.0,
1322            spread: 2.0,
1323        };
1324        let verts = ShadowVertex::from_shadow_quad(&shadow, 2.0);
1325        assert_eq!(verts[0].position, [20.0, 20.0]);
1326        assert_eq!(verts[0].shadow_params[0], 200.0); // shape_w * 2
1327        assert_eq!(verts[0].shadow_params[2], 8.0); // blur * 2
1328        assert_eq!(verts[0].shadow_params[3], 4.0); // spread * 2
1329        // Corner radii are compared against shadow_params.xy in the
1330        // shadow shader's SDF; both have to live in the same px space.
1331        assert_eq!(verts[0].corner_radii, [12.0; 4]);
1332    }
1333}