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, [0.0, 0.0], 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.
380///
381/// Geometry is normalized into the **quad's** 0..1 UV space, which is not
382/// always the space the paint was authored in: a Tier-3 path's quad may be
383/// snapped outward to whole device pixels, leaving its origin up to a pixel
384/// before the bounds the gradient endpoints are relative to. `origin` is
385/// where the quad starts in the paint's own coordinate space (`[0, 0]`
386/// when they coincide, which is every Tier-2 shape), and `width` / `height`
387/// are the quad's size in that same space. Without the shift a snapped
388/// path's gradient would sit a pixel off the identical unsnapped one.
389pub(crate) fn encode_paint_data(
390    paint_data: &PaintData,
391    origin: [f32; 2],
392    width: f32,
393    height: f32,
394) -> (u32, [f32; 4], [[f32; 4]; 4], [f32; 4]) {
395    let zero_colors = [[0.0; 4]; 4];
396    let zero_offsets = [0.0; 4];
397    let u = |x: f32| (x - origin[0]) / width;
398    let v = |y: f32| (y - origin[1]) / height;
399
400    match paint_data {
401        PaintData::Solid => (0, [0.0; 4], zero_colors, zero_offsets),
402        PaintData::LinearGradient { start, end, stops } => {
403            // Normalize coordinates to 0..1 UV space
404            let geo = [u(start[0]), v(start[1]), u(end[0]), v(end[1])];
405            let (colors, offsets) = encode_stops(stops);
406            (1, geo, colors, offsets)
407        }
408        PaintData::RadialGradient {
409            center,
410            radius,
411            stops,
412        } => {
413            // Normalize center and radius to UV space, accounting for aspect ratio.
414            // The shader computes distance in UV space where both axes span 0..1,
415            // so we normalize the radius relative to width (x-axis) and let the
416            // shader use aspect-corrected distance.
417            let aspect = height / width.max(0.0001);
418            let geo = [u(center[0]), v(center[1]), *radius / width, aspect];
419            let (colors, offsets) = encode_stops(stops);
420            (2, geo, colors, offsets)
421        }
422        PaintData::ConicGradient {
423            center,
424            start_angle,
425            stops,
426        } => {
427            let geo = [u(center[0]), v(center[1]), *start_angle, 0.0];
428            let (colors, offsets) = encode_stops(stops);
429            (3, geo, colors, offsets)
430        }
431    }
432}
433
434/// Encode up to 4 gradient stops into arrays. `pub(crate)` — see
435/// [`encode_paint_data`].
436pub(crate) fn encode_stops(stops: &[teksilo_canvas::GradientStop]) -> ([[f32; 4]; 4], [f32; 4]) {
437    let mut colors = [[0.0f32; 4]; 4];
438    let mut offsets = [0.0f32; 4];
439    for (i, stop) in stops.iter().take(4).enumerate() {
440        colors[i] = stop.color.to_array();
441        offsets[i] = stop.offset;
442    }
443    // If fewer than 4 stops, repeat last to fill
444    if !stops.is_empty() {
445        let last_idx = stops.len().min(4) - 1;
446        for i in stops.len()..4 {
447            colors[i] = colors[last_idx];
448            offsets[i] = offsets[last_idx];
449        }
450    }
451    (colors, offsets)
452}
453
454/// Vertex for the gradient-filled path pipeline (Tier 3 arbitrary paths
455/// filled with a `Paint` gradient — linear/radial/conic). Solid-filled
456/// paths keep using the lean `QuadVertex`/`quad_pipeline`, tinted by a
457/// flat vertex color (see `path_quad_verts` in `renderer.rs`); this
458/// vertex type is only built when `PathEntry::paint_data` is a gradient
459/// variant, and is drawn by the dedicated `path_gradient` pipeline
460/// (`shaders/path_gradient.wgsl`).
461///
462/// `tex_coord` samples the path atlas's AA **coverage mask** (alpha
463/// channel only — the atlas always rasterizes opaque white, see
464/// `path_atlas::rasterize_path`), exactly like `QuadVertex`'s monochrome-
465/// glyph path. `local_uv` is the shape-local 0..1 placement used for the
466/// analytic gradient math — same meaning as `SdfVertex::local_uv`. The
467/// gradient fields mirror `SdfVertex`'s layout exactly so the shared
468/// `encode_paint_data`/`encode_stops` helpers apply unchanged.
469#[repr(C)]
470#[derive(Debug, Clone, Copy, Pod, Zeroable)]
471pub struct PathGradientVertex {
472    pub position: [f32; 2],
473    /// Atlas UV — samples the path atlas's AA coverage mask.
474    pub tex_coord: [f32; 2],
475    /// Shape-local UV (0..1 across the path's bounds) — gradient placement.
476    pub local_uv: [f32; 2],
477    /// 1 = linear, 2 = radial, 3 = conic. Never 0 (Solid) — solid fills
478    /// never build a `PathGradientVertex`; see `path_gradient_quad_verts`
479    /// in `renderer.rs`, which branches on `PathEntry::paint_data` before
480    /// choosing this pipeline.
481    pub paint_type: u32,
482    /// Padding so the struct stride stays a multiple of 8 bytes.
483    pub _pad: u32,
484    /// Gradient geometry: `[start_x, start_y, end_x, end_y]` (or
485    /// center/radius for radial, center/angle for conic) in shape-local
486    /// UV space — see `encode_paint_data`.
487    pub gradient_geo: [f32; 4],
488    /// Gradient stop 0: [r, g, b, a]
489    pub gradient_color0: [f32; 4],
490    /// Gradient stop 1: [r, g, b, a]
491    pub gradient_color1: [f32; 4],
492    /// Gradient stop 2: [r, g, b, a]
493    pub gradient_color2: [f32; 4],
494    /// Gradient stop 3: [r, g, b, a]
495    pub gradient_color3: [f32; 4],
496    /// Gradient stop offsets: [offset0, offset1, offset2, offset3]
497    pub gradient_offsets: [f32; 4],
498}
499
500impl PathGradientVertex {
501    /// Build the 4 vertices for a gradient-filled path quad. Mirrors
502    /// `path_quad_verts`'s bounds/atlas-UV/position math (Tier 3, solid
503    /// paths) — same pixel-space quad, same atlas-region UV lookup, same
504    /// `transform` composition inside the function — but emits full
505    /// `paint_type` + gradient fields via the shared `encode_paint_data`
506    /// instead of a single flat tinted color.
507    ///
508    /// `opacity` is folded into EACH gradient stop's alpha
509    /// (`gradient_colorN[3] *= opacity`), not a flat `color` field,
510    /// because `path_gradient.wgsl`'s fragment shader ignores any flat
511    /// color for gradient paint types and only ever reads the gradient
512    /// stops — unlike the SDF pipeline, which (pre-existingly, and out of
513    /// scope here) does not fold `SetOpacity` into gradient `ShapeQuad`s.
514    pub(crate) fn from_path_entry(
515        entry: &PathEntry,
516        placement: &crate::path_atlas::PathPlacement,
517        scale_factor: f32,
518        atlas_width: u32,
519        atlas_height: u32,
520        opacity: f32,
521        transform: &Transform2D,
522    ) -> [PathGradientVertex; 4] {
523        // Same rule as `path_quad_verts`: the rect is the placement's, never
524        // re-derived from `entry.bounds`.
525        let region = &placement.region;
526        let [sx, sy, sw, sh] = placement.device_rect;
527
528        let aw = atlas_width.max(1) as f32;
529        let ah = atlas_height.max(1) as f32;
530        let u0 = region.x as f32 / aw;
531        let v0 = region.y as f32 / ah;
532        let u1 = (region.x + region.w) as f32 / aw;
533        let v1 = (region.y + region.h) as f32 / ah;
534
535        // Gradient endpoints are authored in logical pixels relative to
536        // `entry.bounds`' origin, but the quad may have been snapped outward
537        // to the device pixel grid. Re-base into the quad's space, in those
538        // same logical units, so the gradient lands where the author put it
539        // whether or not the snap moved the quad.
540        let sf = scale_factor.max(1e-4);
541        let origin = [
542            (sx - entry.bounds[0] * scale_factor) / sf,
543            (sy - entry.bounds[1] * scale_factor) / sf,
544        ];
545        let (paint_type, gradient_geo, raw_colors, gradient_offsets) =
546            encode_paint_data(&entry.paint_data, origin, sw / sf, sh / sf);
547        // Linearize (sRGB → linear, matching every other pipeline) and
548        // fold opacity into alpha — see the doc comment above.
549        let colors: [[f32; 4]; 4] = std::array::from_fn(|i| {
550            let mut c = srgb_to_linear_rgba(raw_colors[i]);
551            c[3] *= opacity;
552            c
553        });
554
555        let positions = [
556            apply_affine([sx, sy], transform),
557            apply_affine([sx + sw, sy], transform),
558            apply_affine([sx + sw, sy + sh], transform),
559            apply_affine([sx, sy + sh], transform),
560        ];
561        let tex_coords = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
562        let local_uvs: [[f32; 2]; 4] = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]];
563
564        std::array::from_fn(|i| PathGradientVertex {
565            position: positions[i],
566            tex_coord: tex_coords[i],
567            local_uv: local_uvs[i],
568            paint_type,
569            _pad: 0,
570            gradient_geo,
571            gradient_color0: colors[0],
572            gradient_color1: colors[1],
573            gradient_color2: colors[2],
574            gradient_color3: colors[3],
575            gradient_offsets,
576        })
577    }
578}
579
580/// Standard quad indices for two triangles from 4 vertices.
581///
582/// 32-bit indices: a frame can carry far more than 16 384 quads (the u16
583/// vertex-index ceiling) — large text runs, dense data grids, big scenes —
584/// and a single contiguous batch can approach that count. u16 indices would
585/// silently wrap past vertex 65 535, corrupting draw calls in release and
586/// panicking in debug. The quad index buffer is therefore `Uint32`.
587pub const QUAD_INDICES: [u32; 6] = [0, 1, 2, 0, 2, 3];
588
589/// Generate indices for N quads.
590pub fn generate_quad_indices(count: usize) -> Vec<u32> {
591    let mut indices = Vec::with_capacity(count * 6);
592    for i in 0..count {
593        let base = (i * 4) as u32;
594        for &offset in &QUAD_INDICES {
595            indices.push(base + offset);
596        }
597    }
598    indices
599}
600
601/// Vertex for the shadow pipeline (box shadows with Gaussian blur).
602#[repr(C)]
603#[derive(Debug, Clone, Copy, Pod, Zeroable)]
604pub struct ShadowVertex {
605    pub position: [f32; 2],
606    /// Local UV coordinates (0..1) within the shadow quad bounds.
607    pub local_uv: [f32; 2],
608    pub shadow_color: [f32; 4],
609    pub corner_radii: [f32; 4],
610    /// [shape_width, shape_height, blur_radius, spread].
611    pub shadow_params: [f32; 4],
612    /// [offset_x, offset_y, 0, 0] — offset of inner shape center within shadow quad.
613    pub shape_offset: [f32; 4],
614}
615
616impl ShadowVertex {
617    /// Convert a shadow quad to 4 vertices.
618    pub fn from_shadow_quad(shadow: &ShadowQuad, scale_factor: f32) -> [ShadowVertex; 4] {
619        let [x, y, w, h] = shadow.screen;
620        let sx = x * scale_factor;
621        let sy = y * scale_factor;
622        let sw = w * scale_factor;
623        let sh = h * scale_factor;
624
625        let [sr_x, sr_y, sr_w, sr_h] = shadow.shape_rect;
626        let shape_w = sr_w * scale_factor;
627        let shape_h = sr_h * scale_factor;
628
629        // Offset of shape center relative to shadow quad center
630        let shadow_cx = sx + sw * 0.5;
631        let shadow_cy = sy + sh * 0.5;
632        let shape_cx = (sr_x + sr_w * 0.5) * scale_factor;
633        let shape_cy = (sr_y + sr_h * 0.5) * scale_factor;
634        let offset_x = shape_cx - shadow_cx;
635        let offset_y = shape_cy - shadow_cy;
636
637        let params = [
638            shape_w,
639            shape_h,
640            shadow.blur_radius * scale_factor,
641            shadow.spread * scale_factor,
642        ];
643        let offset = [offset_x, offset_y, 0.0, 0.0];
644        // Match the SDF pipeline: shadow_params.xy is in physical px after
645        // scale_factor, so the matching corner radii also have to be in
646        // physical px. Otherwise circular/pill shadow shapes degenerate
647        // into rounded squares on Retina.
648        let scaled_corner_radii = [
649            shadow.corner_radii[0] * scale_factor,
650            shadow.corner_radii[1] * scale_factor,
651            shadow.corner_radii[2] * scale_factor,
652            shadow.corner_radii[3] * scale_factor,
653        ];
654
655        [
656            ShadowVertex {
657                position: [sx, sy],
658                local_uv: [0.0, 0.0],
659                shadow_color: srgb_to_linear_rgba(shadow.color),
660                corner_radii: scaled_corner_radii,
661                shadow_params: params,
662                shape_offset: offset,
663            },
664            ShadowVertex {
665                position: [sx + sw, sy],
666                local_uv: [1.0, 0.0],
667                shadow_color: srgb_to_linear_rgba(shadow.color),
668                corner_radii: scaled_corner_radii,
669                shadow_params: params,
670                shape_offset: offset,
671            },
672            ShadowVertex {
673                position: [sx + sw, sy + sh],
674                local_uv: [1.0, 1.0],
675                shadow_color: srgb_to_linear_rgba(shadow.color),
676                corner_radii: scaled_corner_radii,
677                shadow_params: params,
678                shape_offset: offset,
679            },
680            ShadowVertex {
681                position: [sx, sy + sh],
682                local_uv: [0.0, 1.0],
683                shadow_color: srgb_to_linear_rgba(shadow.color),
684                corner_radii: scaled_corner_radii,
685                shadow_params: params,
686                shape_offset: offset,
687            },
688        ]
689    }
690}
691
692/// Vertex for the shader-driven animated-quad pipeline (procedural
693/// and sprite kinds). All four vertices of a quad carry the same
694/// `slot`, which the fragment shader uses to look up per-frame state
695/// (phase, resolved colors, atlas dims) in the `anim_uniforms` buffer.
696/// No color or timing is baked in the vertex — that's the whole point:
697/// rebuilding the vertex batch is unnecessary when only the phase
698/// changes, so the widget's `paint()` doesn't re-run per frame.
699#[repr(C)]
700#[derive(Debug, Clone, Copy, Pod, Zeroable)]
701pub struct AnimQuadVertex {
702    /// Pixel position; converted to NDC in the render loop.
703    pub position: [f32; 2],
704    /// Local UV within the quad (0..1 across each axis). The fragment
705    /// shader uses `uv.x` to decide sweep inclusion; the sprite shader
706    /// combines it with `AnimParams::atlas_cols`/`atlas_rows` to sample
707    /// the atlas cell.
708    pub uv: [f32; 2],
709    /// Index into the renderer's `AnimParams` uniform array. Same for
710    /// all four vertices of a quad; declared `@interpolate(flat)` in
711    /// WGSL to preserve the integer across rasterization.
712    pub slot: u32,
713    /// Struct padding to keep stride a multiple of 8 bytes (matches
714    /// `QuadVertex` convention for wgpu vertex-buffer layouts).
715    pub _pad: u32,
716}
717
718impl AnimQuadVertex {
719    pub fn from_animated_quad(
720        draw: &teksilo_canvas::AnimatedQuadDraw,
721        scale_factor: f32,
722    ) -> [AnimQuadVertex; 4] {
723        let [x, y, w, h] = draw.screen;
724        let sx = x * scale_factor;
725        let sy = y * scale_factor;
726        let sw = w * scale_factor;
727        let sh = h * scale_factor;
728        [
729            AnimQuadVertex {
730                position: [sx, sy],
731                uv: [0.0, 0.0],
732                slot: draw.slot,
733                _pad: 0,
734            },
735            AnimQuadVertex {
736                position: [sx + sw, sy],
737                uv: [1.0, 0.0],
738                slot: draw.slot,
739                _pad: 0,
740            },
741            AnimQuadVertex {
742                position: [sx + sw, sy + sh],
743                uv: [1.0, 1.0],
744                slot: draw.slot,
745                _pad: 0,
746            },
747            AnimQuadVertex {
748                position: [sx, sy + sh],
749                uv: [0.0, 1.0],
750                slot: draw.slot,
751                _pad: 0,
752            },
753        ]
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760    use teksilo_canvas::{DecorationKind, GradientStop, PaintData, ShapeKind, StrokeSpace};
761    use teksilo_tokens::Color;
762
763    /// Build a glyph quad with the given screen rect and atlas rect.
764    fn glyph(screen: [f32; 4], atlas: [f32; 4], is_color: bool) -> GlyphQuad {
765        GlyphQuad {
766            screen,
767            atlas,
768            color: [1.0, 1.0, 1.0, 1.0],
769            is_color,
770        }
771    }
772
773    fn assert_pos_near(actual: [f32; 2], expected: [f32; 2]) {
774        assert!(
775            (actual[0] - expected[0]).abs() < 1e-3 && (actual[1] - expected[1]).abs() < 1e-3,
776            "position {actual:?} != expected {expected:?}"
777        );
778    }
779
780    #[test]
781    fn glyph_quad_to_vertices() {
782        // Quad size (30×40) ≠ atlas size (64×64) → no snap; identity
783        // transform passes positions through unchanged.
784        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 64.0, 64.0], false);
785        let verts =
786            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
787        assert_eq!(verts.len(), 4);
788        assert_eq!(verts[0].position, [10.0, 20.0]);
789        assert_eq!(verts[1].position, [40.0, 20.0]); // x + w
790        assert_eq!(verts[2].position, [40.0, 60.0]); // x + w, y + h
791        // Atlas coords normalized: 64/256 = 0.25
792        assert_eq!(verts[0].tex_coord, [0.0, 0.0]);
793        assert_eq!(verts[2].tex_coord, [0.25, 0.25]);
794    }
795
796    #[test]
797    fn scale_factor_applied_to_glyph_coords() {
798        // Physical size 60×80 ≠ atlas 128×128 → no snap.
799        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 128.0, 128.0], false);
800        let verts =
801            QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &Transform2D::IDENTITY);
802        assert_eq!(verts[0].position, [20.0, 40.0]);
803        assert_eq!(verts[1].position, [80.0, 40.0]);
804    }
805
806    #[test]
807    fn glyph_snap_identity_fractional_origin() {
808        // 1:1 quad (30×40 == atlas 30×40) at a fractional origin: the
809        // origin rounds to the pixel grid and the far corner is pinned at
810        // exactly origin + bitmap size.
811        let quad = glyph([10.3, 20.7, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
812        let verts =
813            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
814        assert_eq!(verts[0].position, [10.0, 21.0]);
815        assert_eq!(verts[1].position, [40.0, 21.0]);
816        assert_eq!(verts[2].position, [40.0, 61.0]);
817        assert_eq!(verts[3].position, [10.0, 61.0]);
818    }
819
820    #[test]
821    fn glyph_snap_hidpi_scale_factor() {
822        // sf=2: logical 16×16 → physical 32×32 == atlas bitmap. Fractional
823        // logical origin (5.7, 8.3) → physical (11.4, 16.6) → snaps to
824        // (11, 17).
825        let quad = glyph([5.7, 8.3, 16.0, 16.0], [0.0, 0.0, 32.0, 32.0], false);
826        let verts =
827            QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &Transform2D::IDENTITY);
828        assert_eq!(verts[0].position, [11.0, 17.0]);
829        assert_eq!(verts[2].position, [43.0, 49.0]);
830    }
831
832    #[test]
833    fn glyph_snap_fractional_dpi() {
834        // sf=1.25 (Linux fractional scaling): logical 20×20 → physical
835        // 25×25 == atlas bitmap. Origin (4.2, 7.8) → (5.25, 9.75) →
836        // snaps to (5, 10).
837        let quad = glyph([4.2, 7.8, 20.0, 20.0], [0.0, 0.0, 25.0, 25.0], false);
838        let verts =
839            QuadVertex::from_glyph_quad_transformed(&quad, 1.25, 256, 256, &Transform2D::IDENTITY);
840        assert_eq!(verts[0].position, [5.0, 10.0]);
841        assert_eq!(verts[2].position, [30.0, 35.0]);
842    }
843
844    #[test]
845    fn glyph_snap_exact_bucket_zoom() {
846        // A 1.25× zoom transform over a raster_scale=1.25 bucket: the
847        // bitmap is 1.25× denser (atlas 50×50 for a 20×20-logical glyph at
848        // sf=2 → pre-transform physical 40×40), so the transformed size
849        // (1.25·40 = 50) matches the bitmap exactly → snap fires even
850        // under zoom. Fractional translation rounds away.
851        let quad = glyph([4.0, 8.0, 20.0, 20.0], [0.0, 0.0, 50.0, 50.0], false);
852        let zoom = Transform2D {
853            m: [1.25, 0.0, 0.0, 1.25, 3.3, 7.8],
854        };
855        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &zoom);
856        // origin: (1.25·8 + 3.3, 1.25·16 + 7.8) = (13.3, 27.8) → (13, 28)
857        assert_eq!(verts[0].position, [13.0, 28.0]);
858        assert_eq!(verts[2].position, [63.0, 78.0]);
859    }
860
861    #[test]
862    fn glyph_no_snap_mid_bucket_residual() {
863        // A 1.1× zoom over a 1.25-bucket raster: transformed size
864        // (1.1·40 = 44) ≠ bitmap (50) → residual GPU scaling, no snap;
865        // all corners go through the plain affine transform.
866        let quad = glyph([4.0, 8.0, 20.0, 20.0], [0.0, 0.0, 50.0, 50.0], false);
867        let zoom = Transform2D {
868            m: [1.1, 0.0, 0.0, 1.1, 3.3, 7.8],
869        };
870        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 2.0, 256, 256, &zoom);
871        assert_pos_near(verts[0].position, [1.1 * 8.0 + 3.3, 1.1 * 16.0 + 7.8]);
872        assert_pos_near(verts[2].position, [1.1 * 48.0 + 3.3, 1.1 * 56.0 + 7.8]);
873    }
874
875    #[test]
876    fn glyph_no_snap_rotation() {
877        // Rotated transform (b, c ≠ 0) never snaps, even at matching size.
878        let quad = glyph([10.0, 20.0, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
879        let (s, c) = (0.1_f32.sin(), 0.1_f32.cos());
880        let rot = Transform2D {
881            m: [c, s, -s, c, 0.0, 0.0],
882        };
883        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &rot);
884        assert_pos_near(
885            verts[0].position,
886            [c * 10.0 - s * 20.0, s * 10.0 + c * 20.0],
887        );
888        assert_pos_near(
889            verts[2].position,
890            [c * 40.0 - s * 60.0, s * 40.0 + c * 60.0],
891        );
892    }
893
894    #[test]
895    fn glyph_snap_translation_only_transform() {
896        // Pure fractional translation (e.g. scroll offset) still maps 1:1
897        // → snapped.
898        let quad = glyph([10.3, 20.0, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], false);
899        let pan = Transform2D {
900            m: [1.0, 0.0, 0.0, 1.0, 5.7, 3.2],
901        };
902        let verts = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &pan);
903        // origin: (10.3 + 5.7, 20.0 + 3.2) = (16.0, 23.2) → (16, 23)
904        assert_eq!(verts[0].position, [16.0, 23.0]);
905        assert_eq!(verts[2].position, [46.0, 63.0]);
906    }
907
908    #[test]
909    fn glyph_snap_color_emoji_flag_preserved() {
910        let quad = glyph([10.3, 20.7, 30.0, 40.0], [0.0, 0.0, 30.0, 40.0], true);
911        let verts =
912            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
913        assert_eq!(verts[0].position, [10.0, 21.0]);
914        for v in &verts {
915            assert_eq!(v.flags, QUAD_FLAG_COLOR_GLYPH);
916        }
917    }
918
919    #[test]
920    fn glyph_uvs_independent_of_snapping() {
921        // UVs come from the atlas rect alone — identical whether the
922        // position path snapped or not.
923        let quad = glyph([10.3, 20.7, 30.0, 40.0], [16.0, 32.0, 30.0, 40.0], false);
924        let snapped =
925            QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &Transform2D::IDENTITY);
926        let residual = Transform2D {
927            m: [1.1, 0.0, 0.0, 1.1, 0.0, 0.0],
928        };
929        let unsnapped = QuadVertex::from_glyph_quad_transformed(&quad, 1.0, 256, 256, &residual);
930        for (a, b) in snapped.iter().zip(unsnapped.iter()) {
931            assert_eq!(a.tex_coord, b.tex_coord);
932        }
933        assert_eq!(snapped[0].tex_coord, [16.0 / 256.0, 32.0 / 256.0]);
934        assert_eq!(snapped[2].tex_coord, [46.0 / 256.0, 72.0 / 256.0]);
935    }
936
937    #[test]
938    fn decoration_rect_to_vertices() {
939        let rect = DecorationRect {
940            rect: [0.0, 0.0, 100.0, 2.0],
941            color: [1.0, 0.0, 0.0, 1.0],
942            kind: DecorationKind::FocusRing,
943        };
944        let verts = RectVertex::from_decoration(&rect, 1.0);
945        assert_eq!(verts.len(), 4);
946        assert_eq!(verts[0].position, [0.0, 0.0]);
947        assert_eq!(verts[2].position, [100.0, 2.0]);
948    }
949
950    #[test]
951    fn shape_quad_to_sdf_vertices() {
952        let shape = ShapeQuad {
953            screen: [0.0, 0.0, 100.0, 40.0],
954            color: [0.0, 0.5, 0.0, 1.0],
955            shape: ShapeKind::RoundedRect,
956            stroke_width: 0.0,
957            stroke_space: StrokeSpace::Logical,
958            corner_radii: [6.0, 6.0, 6.0, 6.0],
959            paint_data: PaintData::Solid,
960        };
961        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
962        assert_eq!(verts.len(), 4);
963        assert_eq!(verts[0].corner_radii, [6.0, 6.0, 6.0, 6.0]);
964        // Unfilled: quad is padded by the 1 dp AA margin on each side.
965        // local_uv is extrapolated correspondingly.
966        assert_eq!(verts[0].position, [-1.0, -1.0]);
967        assert_eq!(verts[2].position, [101.0, 41.0]);
968        assert!((verts[0].local_uv[0] - (-0.01)).abs() < 1e-5);
969        assert!((verts[0].local_uv[1] - (-0.025)).abs() < 1e-5);
970        assert!((verts[2].local_uv[0] - 1.01).abs() < 1e-5);
971        assert!((verts[2].local_uv[1] - 1.025).abs() < 1e-5);
972    }
973
974    #[test]
975    fn sdf_scale_factor() {
976        let shape = ShapeQuad {
977            screen: [10.0, 10.0, 100.0, 40.0],
978            color: [0.0, 0.0, 0.0, 1.0],
979            shape: ShapeKind::RoundedRect,
980            stroke_width: 2.0,
981            stroke_space: StrokeSpace::Logical,
982            corner_radii: [4.0; 4],
983            paint_data: PaintData::Solid,
984        };
985        let verts = SdfVertex::from_shape_quad(&shape, 2.0);
986        // Scaled origin (20, 20) is further offset by the rasterization pad
987        // (stroke/2 + 1) = (2*2)/2 + 1 = 3 pixels.
988        assert_eq!(verts[0].position, [17.0, 17.0]);
989        assert_eq!(verts[0].shape_params[2], 4.0); // stroke_width * 2
990        // Corner radii must scale with the rect so a circle stays a circle
991        // on HiDPI. shape_params.xy is in physical px; corner_radii has to
992        // match or radius/half_size diverges.
993        assert_eq!(verts[0].corner_radii, [8.0; 4]);
994    }
995
996    #[test]
997    fn sdf_circle_stays_circle_on_hidpi() {
998        // Regression: a 19×19 logical rect with 9.5 px corner radius is a
999        // perfect circle. On Retina (scale_factor 2) the shader works in
1000        // physical px against `shape_params.xy`. If corner_radii is left in
1001        // logical px, the radio button / toggle pill renders as a rounded
1002        // square instead of a circle.
1003        let shape = ShapeQuad {
1004            screen: [0.0, 0.0, 19.0, 19.0],
1005            color: [0.0, 0.0, 0.0, 1.0],
1006            shape: ShapeKind::RoundedRect,
1007            stroke_width: 0.0,
1008            stroke_space: StrokeSpace::Logical,
1009            corner_radii: [9.5; 4],
1010            paint_data: PaintData::Solid,
1011        };
1012        let verts = SdfVertex::from_shape_quad(&shape, 2.0);
1013        assert_eq!(verts[0].shape_params[0], 38.0);
1014        assert_eq!(verts[0].shape_params[1], 38.0);
1015        assert_eq!(verts[0].corner_radii, [19.0; 4]);
1016    }
1017
1018    #[test]
1019    fn cosmetic_shape_stroke_param_is_inverse_zoom() {
1020        // Cosmetic border: the baked SDF stroke param = width·sf / zoom, so
1021        // after the shader's per-unit ×zoom mapping the border lands at a
1022        // constant width·sf device px at any zoom. The body size params stay
1023        // put (the body still zooms via the view transform).
1024        let shape = ShapeQuad {
1025            screen: [0.0, 0.0, 100.0, 100.0],
1026            color: [0.0, 0.0, 0.0, 1.0],
1027            shape: ShapeKind::RoundedRect,
1028            stroke_width: 2.0,
1029            stroke_space: StrokeSpace::Device,
1030            corner_radii: [10.0; 4],
1031            paint_data: PaintData::Solid,
1032        };
1033        let sf = 2.0;
1034        let logical = SdfVertex::from_shape_quad(&shape, sf);
1035        let z1 = SdfVertex::from_shape_quad_cosmetic(&shape, sf, 1.0);
1036        let z2 = SdfVertex::from_shape_quad_cosmetic(&shape, sf, 2.0);
1037        // zoom 1 matches the logical bake: width·sf = 2·2 = 4.
1038        assert!((z1[0].shape_params[2] - logical[0].shape_params[2]).abs() < 1e-4);
1039        assert!((z1[0].shape_params[2] - 4.0).abs() < 1e-4);
1040        // zoom 2 halves the param so the on-screen width stays width·sf.
1041        assert!((z2[0].shape_params[2] - 2.0).abs() < 1e-4);
1042        // Body size params unchanged across zoom (the quad corners zoom, not
1043        // the SDF body units): width·sf = 100·2 = 200.
1044        assert_eq!(z1[0].shape_params[0], z2[0].shape_params[0]);
1045        assert_eq!(z2[0].shape_params[0], 200.0);
1046    }
1047
1048    #[test]
1049    fn sdf_linear_gradient_encoding() {
1050        let shape = ShapeQuad {
1051            screen: [0.0, 0.0, 100.0, 50.0],
1052            color: [1.0, 1.0, 1.0, 1.0],
1053            shape: ShapeKind::RoundedRect,
1054            stroke_width: 0.0,
1055            stroke_space: StrokeSpace::Logical,
1056            corner_radii: [0.0; 4],
1057            paint_data: PaintData::LinearGradient {
1058                start: [0.0, 0.0],
1059                end: [100.0, 0.0],
1060                stops: vec![
1061                    GradientStop {
1062                        offset: 0.0,
1063                        color: Color::RED,
1064                    },
1065                    GradientStop {
1066                        offset: 1.0,
1067                        color: Color::BLUE,
1068                    },
1069                ],
1070            },
1071        };
1072        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
1073        // paint_type = 1 (linear)
1074        assert!((verts[0].shape_params[3] - 1.0).abs() < 0.01);
1075        // gradient_geo: start=(0,0), end=(1,0) in UV
1076        assert!((verts[0].gradient_geo[0]).abs() < 0.01);
1077        assert!((verts[0].gradient_geo[2] - 1.0).abs() < 0.01);
1078        // First stop is red
1079        assert!((verts[0].gradient_color0[0] - 1.0).abs() < 0.01);
1080        // Offsets
1081        assert!((verts[0].gradient_offsets[0]).abs() < 0.01);
1082        assert!((verts[0].gradient_offsets[1] - 1.0).abs() < 0.01);
1083    }
1084
1085    #[test]
1086    fn linear_gradient_endpoints_are_rect_local_not_absolute() {
1087        // Regression for the HSV-canvas bug: the gradient endpoints
1088        // are normalized by the rect's width/height (`encode_paint_data`
1089        // doesn't see the rect origin), so callers MUST pass them in
1090        // rect-local coordinates. A rect at non-origin with rect-local
1091        // endpoints (0,0)→(0,h) must encode to start_uv=(0,0) and
1092        // end_uv=(0,1) — full gradient sampling across the rect.
1093        // Passing absolute coords would shift the endpoints away and
1094        // visibly squash the gradient.
1095        let shape = ShapeQuad {
1096            screen: [50.0, 100.0, 200.0, 200.0],
1097            color: [1.0, 1.0, 1.0, 1.0],
1098            shape: ShapeKind::RoundedRect,
1099            stroke_width: 0.0,
1100            stroke_space: StrokeSpace::Logical,
1101            corner_radii: [0.0; 4],
1102            paint_data: PaintData::LinearGradient {
1103                start: [0.0, 0.0],
1104                end: [0.0, 200.0],
1105                stops: vec![
1106                    GradientStop {
1107                        offset: 0.0,
1108                        color: Color::new(0.0, 0.0, 0.0, 0.0),
1109                    },
1110                    GradientStop {
1111                        offset: 1.0,
1112                        color: Color::BLACK,
1113                    },
1114                ],
1115            },
1116        };
1117        let verts = SdfVertex::from_shape_quad(&shape, 1.0);
1118        assert!((verts[0].gradient_geo[0]).abs() < 1e-5, "start_uv.x");
1119        assert!((verts[0].gradient_geo[1]).abs() < 1e-5, "start_uv.y");
1120        assert!((verts[0].gradient_geo[2]).abs() < 1e-5, "end_uv.x");
1121        assert!((verts[0].gradient_geo[3] - 1.0).abs() < 1e-5, "end_uv.y");
1122    }
1123
1124    /// Rasterize `entry`'s path into a scratch atlas and return the
1125    /// resulting region — the public-API way to obtain an `AtlasRegion`
1126    /// for a `PathGradientVertex` test (its `last_used_frame` field is
1127    /// private to `path_atlas`, so tests outside that module can't
1128    /// construct one by hand).
1129    fn rasterize_for_test(entry: &PathEntry, atlas_size: u32) -> crate::path_atlas::PathPlacement {
1130        place_for_test(entry, atlas_size, 1.0, false)
1131    }
1132
1133    fn place_for_test(
1134        entry: &PathEntry,
1135        atlas_size: u32,
1136        scale_factor: f32,
1137        snap: bool,
1138    ) -> crate::path_atlas::PathPlacement {
1139        let mut atlas = crate::path_atlas::PathAtlas::new(atlas_size, atlas_size);
1140        atlas.begin_frame();
1141        atlas
1142            .lookup_or_rasterize(
1143                &entry.path,
1144                &entry.stroke_style,
1145                entry.fill_rule,
1146                entry.bounds,
1147                scale_factor,
1148                1.0,
1149                snap,
1150            )
1151            .expect("test path rasterizes")
1152    }
1153
1154    /// Snapping the quad to the pixel grid must not move the gradient.
1155    ///
1156    /// The quad grows outward by up to a pixel on each side, and gradient
1157    /// geometry is normalized across the quad — so without re-basing, the
1158    /// same gradient would land in a different place depending on whether
1159    /// the snap happened to fire. The invariant: the device position of the
1160    /// gradient's start point is the path bounds' own origin, either way.
1161    #[test]
1162    fn snapping_the_quad_does_not_move_the_gradient() {
1163        // A half-pixel bounds origin: the case the snap exists for.
1164        let bounds_rect = teksilo_canvas::Rect::new(1.5, 1.5, 13.0, 13.0);
1165        let entry = gradient_path_entry(
1166            bounds_rect,
1167            PaintData::LinearGradient {
1168                start: [0.0, 0.0],
1169                end: [13.0, 0.0],
1170                stops: vec![
1171                    GradientStop {
1172                        offset: 0.0,
1173                        color: Color::RED,
1174                    },
1175                    GradientStop {
1176                        offset: 1.0,
1177                        color: Color::BLUE,
1178                    },
1179                ],
1180            },
1181        );
1182
1183        for snap in [false, true] {
1184            let placement = place_for_test(&entry, 256, 1.0, snap);
1185            let verts = PathGradientVertex::from_path_entry(
1186                &entry,
1187                &placement,
1188                1.0,
1189                256,
1190                256,
1191                1.0,
1192                &Transform2D::IDENTITY,
1193            );
1194            let [qx, _, qw, _] = placement.device_rect;
1195            // Where the gradient's first stop lands, in device pixels.
1196            let start_x = qx + verts[0].gradient_geo[0] * qw;
1197            let end_x = qx + verts[0].gradient_geo[2] * qw;
1198            assert!(
1199                (start_x - 1.5).abs() < 0.01,
1200                "snap={snap}: gradient start must sit at the bounds origin, got {start_x}"
1201            );
1202            assert!(
1203                (end_x - 14.5).abs() < 0.01,
1204                "snap={snap}: gradient end must sit at the bounds' far edge, got {end_x}"
1205            );
1206        }
1207    }
1208
1209    fn gradient_path_entry(bounds_rect: teksilo_canvas::Rect, paint_data: PaintData) -> PathEntry {
1210        use teksilo_canvas::{FillRule, StrokeStyle};
1211        PathEntry {
1212            path: teksilo_canvas::Path::rect(bounds_rect),
1213            color: [1.0, 1.0, 1.0, 1.0],
1214            stroke_style: StrokeStyle::solid(0.0),
1215            fill_rule: FillRule::Winding,
1216            bounds: bounds_rect.to_array(),
1217            paint_data,
1218        }
1219    }
1220
1221    #[test]
1222    fn path_gradient_linear_encoding() {
1223        let bounds_rect = teksilo_canvas::Rect::new(0.0, 0.0, 100.0, 50.0);
1224        let entry = gradient_path_entry(
1225            bounds_rect,
1226            PaintData::LinearGradient {
1227                start: [0.0, 0.0],
1228                end: [100.0, 0.0],
1229                stops: vec![
1230                    GradientStop {
1231                        offset: 0.0,
1232                        color: Color::RED,
1233                    },
1234                    GradientStop {
1235                        offset: 1.0,
1236                        color: Color::BLUE,
1237                    },
1238                ],
1239            },
1240        );
1241        let placement = rasterize_for_test(&entry, 256);
1242
1243        let verts = PathGradientVertex::from_path_entry(
1244            &entry,
1245            &placement,
1246            1.0,
1247            256,
1248            256,
1249            1.0,
1250            &Transform2D::IDENTITY,
1251        );
1252
1253        // paint_type = 1 (linear)
1254        assert_eq!(verts[0].paint_type, 1);
1255        // gradient_geo: start=(0,0), end=(1,0) in UV
1256        assert!((verts[0].gradient_geo[0]).abs() < 0.01);
1257        assert!((verts[0].gradient_geo[2] - 1.0).abs() < 0.01);
1258        // First stop is red (pure red/blue are fixed points of sRGB→linear)
1259        assert!((verts[0].gradient_color0[0] - 1.0).abs() < 0.01);
1260        assert!((verts[0].gradient_color0[1]).abs() < 0.01);
1261        // Offsets
1262        assert!((verts[0].gradient_offsets[0]).abs() < 0.01);
1263        assert!((verts[0].gradient_offsets[1] - 1.0).abs() < 0.01);
1264        // local_uv corners follow the same 0..1 convention as SdfVertex.
1265        assert_eq!(verts[0].local_uv, [0.0, 0.0]);
1266        assert_eq!(verts[1].local_uv, [1.0, 0.0]);
1267        assert_eq!(verts[2].local_uv, [1.0, 1.0]);
1268        assert_eq!(verts[3].local_uv, [0.0, 1.0]);
1269    }
1270
1271    #[test]
1272    fn path_gradient_endpoints_are_rect_local_not_absolute() {
1273        // Same regression as `linear_gradient_endpoints_are_rect_local_not_absolute`,
1274        // for the Tier-3 path case: gradient endpoints are normalized by
1275        // the path bounds' width/height alone (`encode_paint_data` never
1276        // sees the bounds origin), so a path positioned away from the
1277        // origin must still encode the same normalized start/end UVs.
1278        let bounds_rect = teksilo_canvas::Rect::new(50.0, 100.0, 200.0, 200.0);
1279        let entry = gradient_path_entry(
1280            bounds_rect,
1281            PaintData::LinearGradient {
1282                start: [0.0, 0.0],
1283                end: [0.0, 200.0],
1284                stops: vec![
1285                    GradientStop {
1286                        offset: 0.0,
1287                        color: Color::new(0.0, 0.0, 0.0, 0.0),
1288                    },
1289                    GradientStop {
1290                        offset: 1.0,
1291                        color: Color::BLACK,
1292                    },
1293                ],
1294            },
1295        );
1296        let placement = rasterize_for_test(&entry, 512);
1297
1298        let verts = PathGradientVertex::from_path_entry(
1299            &entry,
1300            &placement,
1301            1.0,
1302            512,
1303            512,
1304            1.0,
1305            &Transform2D::IDENTITY,
1306        );
1307        assert!((verts[0].gradient_geo[0]).abs() < 1e-5, "start_uv.x");
1308        assert!((verts[0].gradient_geo[1]).abs() < 1e-5, "start_uv.y");
1309        assert!((verts[0].gradient_geo[2]).abs() < 1e-5, "end_uv.x");
1310        assert!((verts[0].gradient_geo[3] - 1.0).abs() < 1e-5, "end_uv.y");
1311    }
1312
1313    #[test]
1314    fn path_gradient_opacity_folds_into_every_stop_alpha() {
1315        // The fold-opacity-into-gradient-stops fix this pipeline adds
1316        // (see `PathGradientVertex::from_path_entry` doc comment): unlike
1317        // the SDF pipeline's pre-existing gap, opacity must reach EVERY
1318        // gradient stop's alpha, since the fragment shader ignores any
1319        // flat vertex color for gradient paint types.
1320        let bounds_rect = teksilo_canvas::Rect::new(0.0, 0.0, 40.0, 20.0);
1321        let entry = gradient_path_entry(
1322            bounds_rect,
1323            PaintData::LinearGradient {
1324                start: [0.0, 0.0],
1325                end: [40.0, 0.0],
1326                stops: vec![
1327                    GradientStop {
1328                        offset: 0.0,
1329                        color: Color::RED,
1330                    },
1331                    GradientStop {
1332                        offset: 1.0,
1333                        color: Color::BLUE,
1334                    },
1335                ],
1336            },
1337        );
1338        let placement = rasterize_for_test(&entry, 128);
1339
1340        let full = PathGradientVertex::from_path_entry(
1341            &entry,
1342            &placement,
1343            1.0,
1344            128,
1345            128,
1346            1.0,
1347            &Transform2D::IDENTITY,
1348        );
1349        let half = PathGradientVertex::from_path_entry(
1350            &entry,
1351            &placement,
1352            1.0,
1353            128,
1354            128,
1355            0.5,
1356            &Transform2D::IDENTITY,
1357        );
1358
1359        assert!((full[0].gradient_color0[3] - 1.0).abs() < 1e-5);
1360        assert!((half[0].gradient_color0[3] - 0.5).abs() < 1e-5);
1361        assert!((half[0].gradient_color1[3] - 0.5).abs() < 1e-5);
1362    }
1363
1364    #[test]
1365    fn generate_indices_for_multiple_quads() {
1366        let indices = generate_quad_indices(2);
1367        assert_eq!(indices.len(), 12);
1368        assert_eq!(&indices[0..6], &[0, 1, 2, 0, 2, 3]);
1369        assert_eq!(&indices[6..12], &[4, 5, 6, 4, 6, 7]);
1370    }
1371
1372    #[test]
1373    fn shadow_quad_to_vertices() {
1374        let shadow = ShadowQuad {
1375            screen: [0.0, 0.0, 120.0, 60.0],
1376            color: [0.0, 0.0, 0.0, 0.3],
1377            corner_radii: [6.0; 4],
1378            shape_rect: [10.0, 8.0, 100.0, 40.0],
1379            blur_radius: 4.0,
1380            spread: 0.0,
1381        };
1382        let verts = ShadowVertex::from_shadow_quad(&shadow, 1.0);
1383        assert_eq!(verts.len(), 4);
1384        assert_eq!(verts[0].position, [0.0, 0.0]);
1385        assert_eq!(verts[2].position, [120.0, 60.0]);
1386        assert_eq!(verts[0].shadow_params[2], 4.0); // blur_radius
1387        assert_eq!(verts[0].corner_radii, [6.0; 4]);
1388    }
1389
1390    #[test]
1391    fn shadow_scale_factor() {
1392        let shadow = ShadowQuad {
1393            screen: [10.0, 10.0, 120.0, 60.0],
1394            color: [0.0, 0.0, 0.0, 0.3],
1395            corner_radii: [6.0; 4],
1396            shape_rect: [20.0, 18.0, 100.0, 40.0],
1397            blur_radius: 4.0,
1398            spread: 2.0,
1399        };
1400        let verts = ShadowVertex::from_shadow_quad(&shadow, 2.0);
1401        assert_eq!(verts[0].position, [20.0, 20.0]);
1402        assert_eq!(verts[0].shadow_params[0], 200.0); // shape_w * 2
1403        assert_eq!(verts[0].shadow_params[2], 8.0); // blur * 2
1404        assert_eq!(verts[0].shadow_params[3], 4.0); // spread * 2
1405        // Corner radii are compared against shadow_params.xy in the
1406        // shadow shader's SDF; both have to live in the same px space.
1407        assert_eq!(verts[0].corner_radii, [12.0; 4]);
1408    }
1409}