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