Skip to main content

arcane_core/renderer/
sdf.rs

1/// SDF (Signed Distance Function) rendering pipeline.
2///
3/// Compiles SDF shape expressions + fill modes into standalone WGSL shaders,
4/// caches the resulting GPU pipelines keyed by (expression, fill) hash, and
5/// renders SDF entities as instanced screen-aligned quads.
6///
7/// ## How it works
8///
9/// 1. TypeScript calls `drawSdf(expr, fill, x, y, bounds, ...)` which queues
10///    an `SdfCommand`.
11/// 2. Before rendering, each unique (sdf_expr, fill) pair is compiled into a
12///    complete WGSL shader via `generate_sdf_shader`. The shader includes:
13///    - A library of SDF primitive functions (`sd_circle`, `sd_box`, etc.)
14///    - Composition operations (`op_union`, `op_subtract`, etc.)
15///    - A vertex stage that transforms instanced quads through the camera
16///    - A fragment stage that evaluates the SDF expression, applies the fill,
17///      and anti-aliases shape edges via smoothstep.
18/// 3. The compiled pipeline is cached in a `HashMap<u64, wgpu::RenderPipeline>`
19///    so repeated frames with the same shapes skip recompilation.
20/// 4. Commands are sorted by layer, then batched by pipeline key. Each batch
21///    uploads per-instance data and issues a single instanced draw call.
22///
23/// ## Bind groups
24///
25/// - Group 0, binding 0: Camera uniform (`mat4x4<f32>` view-projection).
26///   Same layout as the sprite pipeline, so the camera bind group can be shared.
27/// - Group 1, binding 0: Time uniform (`f32`) for animated SDF effects.
28///
29/// ## Per-instance vertex data (step_mode = Instance)
30///
31/// | Field    | Format      | Description                              |
32/// |----------|-------------|------------------------------------------|
33/// | position | Float32x2   | World-space center of the quad            |
34/// | bounds   | Float32     | Half-size of the quad in world units      |
35/// | rotation | Float32     | Rotation in radians                       |
36/// | scale    | Float32     | Uniform scale multiplier                  |
37/// | opacity  | Float32     | Alpha multiplier (0..1)                   |
38/// | _pad     | Float32x2   | Padding for alignment                     |
39/// | color    | Float32x4   | Primary color from fill (passed to shader)|
40
41use std::collections::hash_map::DefaultHasher;
42use std::collections::HashMap;
43use std::hash::{Hash, Hasher};
44
45use bytemuck::{Pod, Zeroable};
46use wgpu::util::DeviceExt;
47
48use super::gpu::GpuContext;
49
50// ---------------------------------------------------------------------------
51// Types
52// ---------------------------------------------------------------------------
53
54/// How to color an SDF shape.
55#[derive(Debug, Clone, PartialEq)]
56pub enum SdfFill {
57    /// Flat color inside the shape.
58    Solid { color: [f32; 4] },
59    /// Stroke along the zero-isoline only.
60    Outline { color: [f32; 4], thickness: f32 },
61    /// Filled interior with a differently-colored stroke.
62    SolidWithOutline { fill: [f32; 4], outline: [f32; 4], thickness: f32 },
63    /// Linear gradient mapped through a rotation angle (radians).
64    /// Scale > 1.0 makes the gradient span a smaller region (tighter fit to shape).
65    Gradient { from: [f32; 4], to: [f32; 4], angle: f32, scale: f32 },
66    /// Exponential glow falloff outside the shape.
67    Glow { color: [f32; 4], intensity: f32 },
68    /// Cosine palette: `a + b * cos(2pi * (c * t + d))` where `t` = distance.
69    CosinePalette { a: [f32; 3], b: [f32; 3], c: [f32; 3], d: [f32; 3] },
70}
71
72/// A queued SDF draw command (parallels `SpriteCommand` for the sprite pipeline).
73#[derive(Debug, Clone)]
74pub struct SdfCommand {
75    /// The SDF expression string (WGSL code that evaluates to `f32` given `p: vec2<f32>`).
76    pub sdf_expr: String,
77    /// How to fill/color the shape.
78    pub fill: SdfFill,
79    /// World-space X center of the rendering quad.
80    pub x: f32,
81    /// World-space Y center of the rendering quad.
82    pub y: f32,
83    /// Half-size of the rendering quad in world units.
84    pub bounds: f32,
85    /// Render layer (for sorting with sprites and geometry).
86    pub layer: i32,
87    /// Rotation in radians.
88    pub rotation: f32,
89    /// Uniform scale.
90    pub scale: f32,
91    /// Opacity (0..1).
92    pub opacity: f32,
93}
94
95// ---------------------------------------------------------------------------
96// GPU data layouts
97// ---------------------------------------------------------------------------
98
99/// Per-vertex data for the unit quad (centered at origin, spans -1..1).
100#[repr(C)]
101#[derive(Copy, Clone, Pod, Zeroable)]
102struct SdfQuadVertex {
103    position: [f32; 2],
104    uv: [f32; 2],
105}
106
107/// Quad vertices: centered unit quad from (-1,-1) to (1,1).
108/// UV maps from (0,0) top-left to (1,1) bottom-right.
109const SDF_QUAD_VERTICES: &[SdfQuadVertex] = &[
110    SdfQuadVertex { position: [-1.0, -1.0], uv: [0.0, 0.0] },
111    SdfQuadVertex { position: [ 1.0, -1.0], uv: [1.0, 0.0] },
112    SdfQuadVertex { position: [ 1.0,  1.0], uv: [1.0, 1.0] },
113    SdfQuadVertex { position: [-1.0,  1.0], uv: [0.0, 1.0] },
114];
115
116const SDF_QUAD_INDICES: &[u16] = &[0, 1, 2, 0, 2, 3];
117
118/// Per-instance data uploaded to the GPU for each SDF entity.
119#[repr(C)]
120#[derive(Copy, Clone, Pod, Zeroable)]
121struct SdfInstance {
122    /// World-space center position.
123    position: [f32; 2],
124    /// Half-extent of the quad in world units.
125    bounds: f32,
126    /// Rotation in radians.
127    rotation: f32,
128    /// Uniform scale multiplier.
129    scale: f32,
130    /// Alpha multiplier.
131    opacity: f32,
132    /// Padding for 16-byte alignment before color vec4.
133    _pad: [f32; 2],
134    /// Primary color from the fill (interpretation varies by fill type).
135    color: [f32; 4],
136}
137
138// ---------------------------------------------------------------------------
139// Pipeline cache key
140// ---------------------------------------------------------------------------
141
142/// Compute a deterministic hash from the SDF expression and fill parameters.
143/// Used to key the pipeline cache so identical (expr, fill) pairs share a pipeline.
144pub fn compute_pipeline_key(sdf_expr: &str, fill: &SdfFill) -> u64 {
145    let mut hasher = DefaultHasher::new();
146    sdf_expr.hash(&mut hasher);
147    compute_fill_hash_into(fill, &mut hasher);
148    hasher.finish()
149}
150
151/// Compute a hash of the fill parameters alone.
152pub fn compute_fill_hash(fill: &SdfFill) -> u64 {
153    let mut hasher = DefaultHasher::new();
154    compute_fill_hash_into(fill, &mut hasher);
155    hasher.finish()
156}
157
158/// Hash the fill discriminant and payload into the given hasher.
159fn compute_fill_hash_into(fill: &SdfFill, hasher: &mut DefaultHasher) {
160    // Hash discriminant
161    std::mem::discriminant(fill).hash(hasher);
162    // Hash payload (convert f32 to bits for deterministic hashing)
163    match fill {
164        SdfFill::Solid { color } => {
165            hash_f32_array(color, hasher);
166        }
167        SdfFill::Outline { color, thickness } => {
168            hash_f32_array(color, hasher);
169            thickness.to_bits().hash(hasher);
170        }
171        SdfFill::SolidWithOutline { fill, outline, thickness } => {
172            hash_f32_array(fill, hasher);
173            hash_f32_array(outline, hasher);
174            thickness.to_bits().hash(hasher);
175        }
176        SdfFill::Gradient { from, to, angle, scale } => {
177            hash_f32_array(from, hasher);
178            hash_f32_array(to, hasher);
179            angle.to_bits().hash(hasher);
180            scale.to_bits().hash(hasher);
181        }
182        SdfFill::Glow { color, intensity } => {
183            hash_f32_array(color, hasher);
184            intensity.to_bits().hash(hasher);
185        }
186        SdfFill::CosinePalette { a, b, c, d } => {
187            hash_f32_array(a, hasher);
188            hash_f32_array(b, hasher);
189            hash_f32_array(c, hasher);
190            hash_f32_array(d, hasher);
191        }
192    }
193}
194
195fn hash_f32_array(arr: &[f32], hasher: &mut DefaultHasher) {
196    for v in arr {
197        v.to_bits().hash(hasher);
198    }
199}
200
201// ---------------------------------------------------------------------------
202// WGSL shader generation
203// ---------------------------------------------------------------------------
204
205/// SDF primitive functions included in every generated shader.
206/// WGSL has no `#import`, so these are inlined. The compiler strips unused functions.
207const SDF_PRIMITIVES_WGSL: &str = r#"
208// ---- SDF Primitives ----
209
210fn sd_circle(p: vec2<f32>, r: f32) -> f32 {
211    return length(p) - r;
212}
213
214fn sd_box(p: vec2<f32>, b: vec2<f32>) -> f32 {
215    let d = abs(p) - b;
216    return length(max(d, vec2<f32>(0.0))) + min(max(d.x, d.y), 0.0);
217}
218
219fn sd_rounded_box(p: vec2<f32>, b: vec2<f32>, r: vec4<f32>) -> f32 {
220    // r.x = top-left, r.y = top-right, r.z = bottom-right, r.w = bottom-left
221    var radius = r.x;
222    if (p.x > 0.0 && p.y > 0.0) { radius = r.y; }  // top-right
223    else if (p.x > 0.0 && p.y < 0.0) { radius = r.z; }  // bottom-right
224    else if (p.x < 0.0 && p.y < 0.0) { radius = r.w; }  // bottom-left
225    let q = abs(p) - b + vec2<f32>(radius);
226    return length(max(q, vec2<f32>(0.0))) + min(max(q.x, q.y), 0.0) - radius;
227}
228
229fn sd_segment(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
230    let pa = p - a;
231    let ba = b - a;
232    let h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
233    return length(pa - ba * h);
234}
235
236fn sd_capsule(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>, r: f32) -> f32 {
237    return sd_segment(p, a, b) - r;
238}
239
240fn sd_equilateral_triangle(p_in: vec2<f32>, r: f32) -> f32 {
241    let k = sqrt(3.0);
242    var p = p_in;
243    p.x = abs(p.x) - r;
244    p.y = p.y + r / k;
245    if (p.x + k * p.y > 0.0) {
246        p = vec2<f32>(p.x - k * p.y, -k * p.x - p.y) / 2.0;
247    }
248    p.x -= clamp(p.x, -2.0 * r, 0.0);
249    return -length(p) * sign(p.y);
250}
251
252fn sd_ring(p: vec2<f32>, r: f32, thickness: f32) -> f32 {
253    return abs(length(p) - r) - thickness;
254}
255
256fn sd_ellipse(p: vec2<f32>, ab: vec2<f32>) -> f32 {
257    // Approximate ellipse SDF via scaling
258    let scaled = p / ab;
259    let d = length(scaled) - 1.0;
260    return d * min(ab.x, ab.y);
261}
262
263fn sd_hexagon(p_in: vec2<f32>, r: f32) -> f32 {
264    let k = vec3<f32>(-0.866025404, 0.5, 0.577350269);
265    var p = abs(p_in);
266    p = p - 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
267    p = p - vec2<f32>(clamp(p.x, -k.z * r, k.z * r), r);
268    return length(p) * sign(p.y);
269}
270
271fn sd_star5(p_in: vec2<f32>, r: f32, rf: f32) -> f32 {
272    let k1 = vec2<f32>(0.809016994, -0.587785252);
273    let k2 = vec2<f32>(-k1.x, k1.y);
274    var p = vec2<f32>(abs(p_in.x), p_in.y);
275    p = p - 2.0 * max(dot(k1, p), 0.0) * k1;
276    p = p - 2.0 * max(dot(k2, p), 0.0) * k2;
277    p = vec2<f32>(abs(p.x), p.y - r);
278    let ba = rf * vec2<f32>(-k1.y, k1.x) - vec2<f32>(0.0, 1.0);
279    let h = clamp(dot(p, ba) / dot(ba, ba), 0.0, r);
280    return length(p - ba * h) * sign(p.y * ba.x - p.x * ba.y);
281}
282
283fn sd_cross(p: vec2<f32>, b: vec2<f32>, r: f32) -> f32 {
284    var pp = abs(p);
285    if (pp.y > pp.x) { pp = pp.yx; }
286    let q = pp - b;
287    let k = max(q.y, q.x);
288    let w = select(vec2<f32>(b.y - pp.x, -k), q, k > 0.0);
289    return sign(k) * length(max(w, vec2<f32>(0.0))) + r;
290}
291
292fn sd_triangle(p: vec2<f32>, p0: vec2<f32>, p1: vec2<f32>, p2: vec2<f32>) -> f32 {
293    let e0 = p1 - p0;
294    let e1 = p2 - p1;
295    let e2 = p0 - p2;
296    let v0 = p - p0;
297    let v1 = p - p1;
298    let v2 = p - p2;
299    let pq0 = v0 - e0 * clamp(dot(v0, e0) / dot(e0, e0), 0.0, 1.0);
300    let pq1 = v1 - e1 * clamp(dot(v1, e1) / dot(e1, e1), 0.0, 1.0);
301    let pq2 = v2 - e2 * clamp(dot(v2, e2) / dot(e2, e2), 0.0, 1.0);
302    let s = sign(e0.x * e2.y - e0.y * e2.x);
303    let d0 = vec2<f32>(dot(pq0, pq0), s * (v0.x * e0.y - v0.y * e0.x));
304    let d1 = vec2<f32>(dot(pq1, pq1), s * (v1.x * e1.y - v1.y * e1.x));
305    let d2 = vec2<f32>(dot(pq2, pq2), s * (v2.x * e2.y - v2.y * e2.x));
306    let d = min(min(d0, d1), d2);
307    return -sqrt(d.x) * sign(d.y);
308}
309
310fn sd_egg(p_in: vec2<f32>, ra: f32, rb: f32) -> f32 {
311    // Egg shape: ra is the main radius, rb is the bulge factor
312    let k = sqrt(3.0);
313    var p = vec2<f32>(abs(p_in.x), -p_in.y);  // flip Y for our coordinate system
314    let r = ra - rb;
315    if (p.y < 0.0) {
316        return length(p) - r - rb;
317    } else if (k * (p.x + r) < p.y) {
318        return length(vec2<f32>(p.x, p.y - k * r)) - rb;
319    } else {
320        return length(vec2<f32>(p.x + r, p.y)) - 2.0 * r - rb;
321    }
322}
323
324fn sd_heart(p_in: vec2<f32>, size: f32) -> f32 {
325    // Normalize to unit heart, flip Y so point is at bottom
326    var p = vec2<f32>(abs(p_in.x), p_in.y) / size;
327    // IQ's heart SDF (modified for our coordinate system)
328    if (p.y + p.x > 1.0) {
329        return (sqrt(dot(p - vec2<f32>(0.25, 0.75), p - vec2<f32>(0.25, 0.75))) - sqrt(2.0) / 4.0) * size;
330    }
331    return sqrt(min(
332        dot(p - vec2<f32>(0.0, 1.0), p - vec2<f32>(0.0, 1.0)),
333        dot(p - 0.5 * max(p.x + p.y, 0.0), p - 0.5 * max(p.x + p.y, 0.0))
334    )) * sign(p.x - p.y) * size;
335}
336
337fn sd_moon(p_in: vec2<f32>, d: f32, ra: f32, rb: f32) -> f32 {
338    var p = vec2<f32>(p_in.x, abs(p_in.y));
339    let a = (ra * ra - rb * rb + d * d) / (2.0 * d);
340    let b = sqrt(max(ra * ra - a * a, 0.0));
341    if (d * (p.x * b - p.y * a) > d * d * max(b - p.y, 0.0)) {
342        return length(p - vec2<f32>(a, b));
343    }
344    return max(length(p) - ra, -(length(p - vec2<f32>(d, 0.0)) - rb));
345}
346
347fn sd_pentagon(p_in: vec2<f32>, r: f32) -> f32 {
348    // Regular pentagon SDF
349    let k = vec3<f32>(0.809016994, 0.587785252, 0.726542528);
350    var p = vec2<f32>(abs(p_in.x), -p_in.y);  // flip Y for our coordinate system
351    p = p - 2.0 * min(dot(vec2<f32>(-k.x, k.y), p), 0.0) * vec2<f32>(-k.x, k.y);
352    p = p - 2.0 * min(dot(vec2<f32>(k.x, k.y), p), 0.0) * vec2<f32>(k.x, k.y);
353    p = p - vec2<f32>(clamp(p.x, -r * k.z, r * k.z), r);
354    return length(p) * sign(p.y);
355}
356
357fn sd_star(p_in: vec2<f32>, r: f32, n: f32, inner_ratio: f32) -> f32 {
358    // n-pointed star with inner radius = r * inner_ratio
359    // Based on IQ's approach but with inner_ratio parameter
360    let pi = 3.141592653;
361    let an = pi / n;  // half angle between points
362
363    // Map to first sector using polar coordinates
364    let angle = atan2(p_in.y, p_in.x);
365    // Use fract-based modulo for correct handling of negative angles
366    let sector = (angle / (2.0 * an));
367    let sector_fract = sector - floor(sector);
368    let bn = sector_fract * 2.0 * an - an;
369
370    // Transform point to first sector
371    let radius = length(p_in);
372    var p = vec2<f32>(radius * cos(bn), abs(radius * sin(bn)));
373
374    // Outer tip at (r, 0), inner valley at angle an
375    let inner_r = r * inner_ratio;
376
377    // Vector from outer tip to inner valley
378    let tip = vec2<f32>(r, 0.0);
379    let valley = vec2<f32>(inner_r * cos(an), inner_r * sin(an));
380
381    // Distance to the edge line segment
382    p = p - tip;
383    let edge = valley - tip;
384    let h = clamp(dot(p, edge) / dot(edge, edge), 0.0, 1.0);
385    p = p - edge * h;
386
387    // Sign: negative inside, positive outside
388    // p.x < 0 means we're past the edge toward center (inside)
389    return length(p) * sign(p.x);
390}
391
392// ---- Transform Operations ----
393
394fn rotate_rad(p: vec2<f32>, angle: f32) -> vec2<f32> {
395    let c = cos(angle);
396    let s = sin(angle);
397    return vec2<f32>(p.x * c - p.y * s, p.x * s + p.y * c);
398}
399
400fn op_symmetry_x(p: vec2<f32>) -> vec2<f32> {
401    return vec2<f32>(abs(p.x), p.y);
402}
403
404// ---- Composition Operations ----
405
406fn op_union(d1: f32, d2: f32) -> f32 {
407    return min(d1, d2);
408}
409
410fn op_subtract(d1: f32, d2: f32) -> f32 {
411    return max(d1, -d2);
412}
413
414fn op_intersect(d1: f32, d2: f32) -> f32 {
415    return max(d1, d2);
416}
417
418fn op_smooth_union(d1: f32, d2: f32, k: f32) -> f32 {
419    let h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
420    return mix(d2, d1, h) - k * h * (1.0 - h);
421}
422
423fn op_smooth_subtract(d1: f32, d2: f32, k: f32) -> f32 {
424    let h = clamp(0.5 - 0.5 * (d1 + d2) / k, 0.0, 1.0);
425    return mix(d1, -d2, h) + k * h * (1.0 - h);
426}
427
428fn op_smooth_intersect(d1: f32, d2: f32, k: f32) -> f32 {
429    let h = clamp(0.5 - 0.5 * (d2 - d1) / k, 0.0, 1.0);
430    return mix(d2, d1, h) + k * h * (1.0 - h);
431}
432
433fn op_round(d: f32, r: f32) -> f32 {
434    return d - r;
435}
436
437fn op_annular(d: f32, r: f32) -> f32 {
438    return abs(d) - r;
439}
440
441fn op_repeat(p: vec2<f32>, spacing: vec2<f32>) -> vec2<f32> {
442    return p - spacing * round(p / spacing);
443}
444
445fn op_translate(p: vec2<f32>, offset: vec2<f32>) -> vec2<f32> {
446    return p - offset;
447}
448
449fn op_rotate(p: vec2<f32>, angle: f32) -> vec2<f32> {
450    let c = cos(angle);
451    let s = sin(angle);
452    return vec2<f32>(p.x * c + p.y * s, -p.x * s + p.y * c);
453}
454
455fn op_scale(p: vec2<f32>, s: f32) -> vec2<f32> {
456    return p / s;
457}
458"#;
459
460/// Generate the WGSL code for the fill logic in the fragment shader.
461///
462/// The generated code expects these variables to be in scope:
463/// - `d: f32` — the SDF distance value
464/// - `p: vec2<f32>` — the local-space coordinate
465/// - `in_bounds: f32` — the instance bounds value
466/// - `in_opacity: f32` — the instance opacity
467/// - `in_color: vec4<f32>` — the primary color from the instance
468///
469/// The code must assign to `var out_color: vec4<f32>`.
470pub fn generate_fill_wgsl(fill: &SdfFill) -> String {
471    match fill {
472        SdfFill::Solid { color } => {
473            format!(
474                r#"    // Solid fill with adaptive AA (fwidth-based)
475    let fill_color = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
476    let aa_width = fwidth(d) * 0.5;
477    let aa = 1.0 - smoothstep(-aa_width, aa_width, d);
478    out_color = vec4<f32>(fill_color.rgb, fill_color.a * aa * in_opacity);"#,
479                color[0], color[1], color[2], color[3]
480            )
481        }
482        SdfFill::Outline { color, thickness } => {
483            format!(
484                r#"    // Outline fill with adaptive AA (fwidth-based)
485    let outline_color = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
486    let half_t = {:.6};
487    let aa_width = fwidth(d) * 0.5;
488    let aa_outer = 1.0 - smoothstep(-aa_width, aa_width, abs(d) - half_t);
489    out_color = vec4<f32>(outline_color.rgb, outline_color.a * aa_outer * in_opacity);"#,
490                color[0], color[1], color[2], color[3], thickness
491            )
492        }
493        SdfFill::SolidWithOutline { fill, outline, thickness } => {
494            format!(
495                r#"    // Solid + outline fill with adaptive AA (fwidth-based)
496    let fill_color = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
497    let outline_color = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
498    let outline_t = {:.6};
499    let aa_width = fwidth(d) * 0.5;
500    let aa_fill = 1.0 - smoothstep(-aa_width, aa_width, d);
501    let aa_outline = 1.0 - smoothstep(-aa_width, aa_width, abs(d) - outline_t);
502    // Inside the shape: fill color. On the edge: outline color.
503    let is_inside = smoothstep(aa_width, -aa_width, d + outline_t);
504    let base = mix(outline_color, fill_color, is_inside);
505    let alpha = max(aa_fill, aa_outline);
506    out_color = vec4<f32>(base.rgb, base.a * alpha * in_opacity);"#,
507                fill[0], fill[1], fill[2], fill[3],
508                outline[0], outline[1], outline[2], outline[3],
509                thickness
510            )
511        }
512        SdfFill::Gradient { from, to, angle, scale } => {
513            format!(
514                r#"    // Gradient fill with adaptive AA (fwidth-based)
515    let grad_from = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
516    let grad_to = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
517    let grad_angle = {:.6};
518    let grad_scale = {:.6};
519    let grad_dir = vec2<f32>(cos(grad_angle), sin(grad_angle));
520    // Map local-space p through the gradient direction, normalized to 0..1
521    // Scale > 1 makes the gradient span a smaller region (tighter fit to shape)
522    let grad_t = clamp(dot(p / in_bounds, grad_dir) * grad_scale * 0.5 + 0.5, 0.0, 1.0);
523    let grad_color = mix(grad_from, grad_to, grad_t);
524    let aa_width = fwidth(d) * 0.5;
525    let aa = 1.0 - smoothstep(-aa_width, aa_width, d);
526    out_color = vec4<f32>(grad_color.rgb, grad_color.a * aa * in_opacity);"#,
527                from[0], from[1], from[2], from[3],
528                to[0], to[1], to[2], to[3],
529                angle,
530                scale
531            )
532        }
533        SdfFill::Glow { color, intensity } => {
534            format!(
535                r#"    // Glow fill
536    let glow_color = vec4<f32>({:.6}, {:.6}, {:.6}, {:.6});
537    let glow_intensity = {:.6};
538    // Exponential falloff outside the shape; full brightness inside
539    let glow = exp(-max(d, 0.0) * glow_intensity);
540    out_color = vec4<f32>(glow_color.rgb, glow_color.a * glow * in_opacity);"#,
541                color[0], color[1], color[2], color[3],
542                intensity
543            )
544        }
545        SdfFill::CosinePalette { a, b, c, d: d_param } => {
546            format!(
547                r#"    // Cosine palette fill with adaptive AA (fwidth-based)
548    let pal_a = vec3<f32>({:.6}, {:.6}, {:.6});
549    let pal_b = vec3<f32>({:.6}, {:.6}, {:.6});
550    let pal_c = vec3<f32>({:.6}, {:.6}, {:.6});
551    let pal_d = vec3<f32>({:.6}, {:.6}, {:.6});
552    // t derived from distance, normalized by bounds
553    let pal_t = d / in_bounds;
554    let pal_color = pal_a + pal_b * cos(6.283185 * (pal_c * pal_t + pal_d));
555    let aa_width = fwidth(d) * 0.5;
556    let aa = 1.0 - smoothstep(-aa_width, aa_width, d);
557    out_color = vec4<f32>(clamp(pal_color, vec3<f32>(0.0), vec3<f32>(1.0)), aa * in_opacity);"#,
558                a[0], a[1], a[2],
559                b[0], b[1], b[2],
560                c[0], c[1], c[2],
561                d_param[0], d_param[1], d_param[2]
562            )
563        }
564    }
565}
566
567/// Generate a complete, self-contained WGSL shader module for the given SDF
568/// expression and fill mode.
569///
570/// The output can be compiled directly by `wgpu::Device::create_shader_module`.
571///
572/// # Arguments
573///
574/// * `sdf_expr` - A WGSL expression that evaluates to `f32` given a variable
575///   `p: vec2<f32>` in local space (coordinates range roughly -bounds..bounds).
576///   Example: `"sd_circle(p, 50.0)"`
577/// * `fill` - Determines how the distance field is colored.
578pub fn generate_sdf_shader(sdf_expr: &str, fill: &SdfFill) -> String {
579    let fill_code = generate_fill_wgsl(fill);
580
581    format!(
582        r#"// Auto-generated SDF shader
583// Expression: {sdf_expr}
584
585{SDF_PRIMITIVES_WGSL}
586
587// ---- Bindings ----
588
589struct CameraUniform {{
590    view_proj: mat4x4<f32>,
591}};
592
593@group(0) @binding(0)
594var<uniform> camera: CameraUniform;
595
596struct TimeUniform {{
597    time: f32,
598}};
599
600@group(1) @binding(0)
601var<uniform> time_data: TimeUniform;
602
603// ---- Vertex stage ----
604
605struct VertexInput {{
606    @location(0) position: vec2<f32>,
607    @location(1) uv: vec2<f32>,
608}};
609
610struct InstanceInput {{
611    @location(2) inst_position: vec2<f32>,
612    @location(3) inst_bounds: f32,
613    @location(4) inst_rotation: f32,
614    @location(5) inst_scale: f32,
615    @location(6) inst_opacity: f32,
616    @location(7) inst_pad: vec2<f32>,
617    @location(8) inst_color: vec4<f32>,
618}};
619
620struct VertexOutput {{
621    @builtin(position) clip_position: vec4<f32>,
622    @location(0) local_pos: vec2<f32>,
623    @location(1) v_opacity: f32,
624    @location(2) v_bounds: f32,
625    @location(3) v_color: vec4<f32>,
626    @location(4) v_scale: f32,
627}};
628
629@vertex
630fn vs_main(vertex: VertexInput, instance: InstanceInput) -> VertexOutput {{
631    var out: VertexOutput;
632
633    let scaled_bounds = instance.inst_bounds * instance.inst_scale;
634
635    // Quad vertex in local space: vertex.position is in [-1, 1]
636    // Scale the quad for visual scaling
637    var pos = vertex.position * scaled_bounds;
638
639    // Apply instance rotation around center
640    let cos_r = cos(instance.inst_rotation);
641    let sin_r = sin(instance.inst_rotation);
642    let rotated = vec2<f32>(
643        pos.x * cos_r - pos.y * sin_r,
644        pos.x * sin_r + pos.y * cos_r,
645    );
646
647    // Translate to world position
648    let world_xy = rotated + instance.inst_position;
649    let world = vec4<f32>(world_xy.x, world_xy.y, 0.0, 1.0);
650    out.clip_position = camera.view_proj * world;
651
652    // Pass local-space coordinate to fragment WITHOUT scale
653    // This ensures SDF is evaluated in original coordinate space
654    // Flip Y to match Arcane's screen coordinate system (Y=0 at top, Y increases down)
655    out.local_pos = vec2<f32>(vertex.position.x, -vertex.position.y) * instance.inst_bounds;
656    out.v_opacity = instance.inst_opacity;
657    out.v_bounds = instance.inst_bounds;
658    out.v_scale = instance.inst_scale;
659    out.v_color = instance.inst_color;
660
661    return out;
662}}
663
664// ---- Fragment stage ----
665
666@fragment
667fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {{
668    let p = in.local_pos;
669    let in_bounds = in.v_bounds;
670    let in_opacity = in.v_opacity;
671    let in_color = in.v_color;
672    let in_scale = in.v_scale;
673    let time = time_data.time;
674
675    // Evaluate the SDF expression, then scale the distance for proper anti-aliasing
676    let d = {sdf_expr} * in_scale;
677
678    // Apply fill
679    var out_color: vec4<f32>;
680{fill_code}
681
682    return out_color;
683}}
684"#,
685        sdf_expr = sdf_expr,
686        SDF_PRIMITIVES_WGSL = SDF_PRIMITIVES_WGSL,
687        fill_code = fill_code,
688    )
689}
690
691// ---------------------------------------------------------------------------
692// SDF pipeline store
693// ---------------------------------------------------------------------------
694
695/// Camera uniform buffer data (matches sprite pipeline).
696#[repr(C)]
697#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
698struct CameraUniform {
699    view_proj: [f32; 16],
700}
701
702/// Manages cached SDF pipelines and renders SDF commands.
703pub struct SdfPipelineStore {
704    /// Cached render pipelines keyed by `compute_pipeline_key(expr, fill)`.
705    pipelines: HashMap<u64, wgpu::RenderPipeline>,
706    /// Shared pipeline layout (all SDF pipelines use the same bind group layout).
707    pipeline_layout: wgpu::PipelineLayout,
708    /// Camera bind group layout (group 0).
709    #[allow(dead_code)]
710    camera_bind_group_layout: wgpu::BindGroupLayout,
711    /// Camera uniform buffer.
712    camera_buffer: wgpu::Buffer,
713    /// Camera bind group.
714    camera_bind_group: wgpu::BindGroup,
715    /// Time uniform bind group layout (group 1).
716    /// Retained so the layout stays alive for the pipeline layout's internal reference.
717    #[allow(dead_code)]
718    time_bind_group_layout: wgpu::BindGroupLayout,
719    /// Time uniform buffer.
720    time_buffer: wgpu::Buffer,
721    /// Time uniform bind group.
722    time_bind_group: wgpu::BindGroup,
723    /// Static quad vertex buffer (shared across all SDF draws).
724    vertex_buffer: wgpu::Buffer,
725    /// Static quad index buffer.
726    index_buffer: wgpu::Buffer,
727    /// Surface texture format (needed when creating new pipelines).
728    surface_format: wgpu::TextureFormat,
729}
730
731impl SdfPipelineStore {
732    /// Create a new SDF pipeline store.
733    pub fn new(gpu: &GpuContext) -> Self {
734        Self::new_internal(&gpu.device, gpu.config.format)
735    }
736
737    /// Create for headless testing (no surface required).
738    pub fn new_headless(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
739        Self::new_internal(device, format)
740    }
741
742    fn new_internal(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
743        // Camera uniform bind group layout (group 0)
744        let camera_bind_group_layout =
745            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
746                label: Some("sdf_camera_bgl"),
747                entries: &[wgpu::BindGroupLayoutEntry {
748                    binding: 0,
749                    visibility: wgpu::ShaderStages::VERTEX,
750                    ty: wgpu::BindingType::Buffer {
751                        ty: wgpu::BufferBindingType::Uniform,
752                        has_dynamic_offset: false,
753                        min_binding_size: None,
754                    },
755                    count: None,
756                }],
757            });
758
759        // Time uniform bind group layout (group 1)
760        let time_bind_group_layout =
761            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
762                label: Some("sdf_time_bgl"),
763                entries: &[wgpu::BindGroupLayoutEntry {
764                    binding: 0,
765                    visibility: wgpu::ShaderStages::FRAGMENT,
766                    ty: wgpu::BindingType::Buffer {
767                        ty: wgpu::BufferBindingType::Uniform,
768                        has_dynamic_offset: false,
769                        min_binding_size: None,
770                    },
771                    count: None,
772                }],
773            });
774
775        let pipeline_layout =
776            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
777                label: Some("sdf_pipeline_layout"),
778                bind_group_layouts: &[&camera_bind_group_layout, &time_bind_group_layout],
779                push_constant_ranges: &[],
780            });
781
782        // Time uniform buffer (single f32, padded to 4 bytes minimum)
783        let time_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
784            label: Some("sdf_time_buffer"),
785            contents: bytemuck::cast_slice(&[0.0f32]),
786            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
787        });
788
789        let time_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
790            label: Some("sdf_time_bind_group"),
791            layout: &time_bind_group_layout,
792            entries: &[wgpu::BindGroupEntry {
793                binding: 0,
794                resource: time_buffer.as_entire_binding(),
795            }],
796        });
797
798        // Camera uniform buffer
799        let camera_uniform = CameraUniform {
800            view_proj: [
801                1.0, 0.0, 0.0, 0.0,
802                0.0, 1.0, 0.0, 0.0,
803                0.0, 0.0, 1.0, 0.0,
804                0.0, 0.0, 0.0, 1.0,
805            ],
806        };
807        let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
808            label: Some("sdf_camera_buffer"),
809            contents: bytemuck::cast_slice(&[camera_uniform]),
810            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
811        });
812
813        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
814            label: Some("sdf_camera_bind_group"),
815            layout: &camera_bind_group_layout,
816            entries: &[wgpu::BindGroupEntry {
817                binding: 0,
818                resource: camera_buffer.as_entire_binding(),
819            }],
820        });
821
822        // Static quad geometry
823        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
824            label: Some("sdf_quad_vertex_buffer"),
825            contents: bytemuck::cast_slice(SDF_QUAD_VERTICES),
826            usage: wgpu::BufferUsages::VERTEX,
827        });
828
829        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
830            label: Some("sdf_quad_index_buffer"),
831            contents: bytemuck::cast_slice(SDF_QUAD_INDICES),
832            usage: wgpu::BufferUsages::INDEX,
833        });
834
835        Self {
836            pipelines: HashMap::new(),
837            pipeline_layout,
838            camera_bind_group_layout,
839            camera_buffer,
840            camera_bind_group,
841            time_bind_group_layout,
842            time_buffer,
843            time_bind_group,
844            vertex_buffer,
845            index_buffer,
846            surface_format,
847        }
848    }
849
850    /// Update the camera and time uniforms. Call once per frame before rendering.
851    pub fn prepare(&self, queue: &wgpu::Queue, camera: &super::Camera2D, time: f32) {
852        let camera_uniform = CameraUniform {
853            view_proj: camera.view_proj(),
854        };
855        queue.write_buffer(
856            &self.camera_buffer,
857            0,
858            bytemuck::cast_slice(&[camera_uniform]),
859        );
860        queue.write_buffer(&self.time_buffer, 0, bytemuck::cast_slice(&[time]));
861    }
862
863    /// Update the time uniform. Call once per frame before rendering.
864    pub fn set_time(&self, queue: &wgpu::Queue, time: f32) {
865        queue.write_buffer(&self.time_buffer, 0, bytemuck::cast_slice(&[time]));
866    }
867
868    /// Get or create the render pipeline for the given SDF expression + fill.
869    /// Returns a reference to the cached pipeline.
870    pub fn get_or_create_pipeline(
871        &mut self,
872        device: &wgpu::Device,
873        sdf_expr: &str,
874        fill: &SdfFill,
875    ) -> u64 {
876        let key = compute_pipeline_key(sdf_expr, fill);
877        if self.pipelines.contains_key(&key) {
878            return key;
879        }
880
881        let wgsl = generate_sdf_shader(sdf_expr, fill);
882        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
883            label: Some("sdf_shader"),
884            source: wgpu::ShaderSource::Wgsl(wgsl.into()),
885        });
886
887        // Vertex buffer layout: per-vertex quad data
888        let vertex_layout = wgpu::VertexBufferLayout {
889            array_stride: std::mem::size_of::<SdfQuadVertex>() as wgpu::BufferAddress,
890            step_mode: wgpu::VertexStepMode::Vertex,
891            attributes: &[
892                wgpu::VertexAttribute {
893                    offset: 0,
894                    shader_location: 0,
895                    format: wgpu::VertexFormat::Float32x2, // position
896                },
897                wgpu::VertexAttribute {
898                    offset: 8,
899                    shader_location: 1,
900                    format: wgpu::VertexFormat::Float32x2, // uv
901                },
902            ],
903        };
904
905        // Instance buffer layout: per-instance SDF entity data
906        let instance_layout = wgpu::VertexBufferLayout {
907            array_stride: std::mem::size_of::<SdfInstance>() as wgpu::BufferAddress,
908            step_mode: wgpu::VertexStepMode::Instance,
909            attributes: &[
910                wgpu::VertexAttribute {
911                    offset: 0,
912                    shader_location: 2,
913                    format: wgpu::VertexFormat::Float32x2, // position
914                },
915                wgpu::VertexAttribute {
916                    offset: 8,
917                    shader_location: 3,
918                    format: wgpu::VertexFormat::Float32, // bounds
919                },
920                wgpu::VertexAttribute {
921                    offset: 12,
922                    shader_location: 4,
923                    format: wgpu::VertexFormat::Float32, // rotation
924                },
925                wgpu::VertexAttribute {
926                    offset: 16,
927                    shader_location: 5,
928                    format: wgpu::VertexFormat::Float32, // scale
929                },
930                wgpu::VertexAttribute {
931                    offset: 20,
932                    shader_location: 6,
933                    format: wgpu::VertexFormat::Float32, // opacity
934                },
935                wgpu::VertexAttribute {
936                    offset: 24,
937                    shader_location: 7,
938                    format: wgpu::VertexFormat::Float32x2, // _pad
939                },
940                wgpu::VertexAttribute {
941                    offset: 32,
942                    shader_location: 8,
943                    format: wgpu::VertexFormat::Float32x4, // color
944                },
945            ],
946        };
947
948        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
949            label: Some("sdf_render_pipeline"),
950            layout: Some(&self.pipeline_layout),
951            vertex: wgpu::VertexState {
952                module: &shader,
953                entry_point: Some("vs_main"),
954                buffers: &[vertex_layout, instance_layout],
955                compilation_options: Default::default(),
956            },
957            fragment: Some(wgpu::FragmentState {
958                module: &shader,
959                entry_point: Some("fs_main"),
960                targets: &[Some(wgpu::ColorTargetState {
961                    format: self.surface_format,
962                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
963                    write_mask: wgpu::ColorWrites::ALL,
964                })],
965                compilation_options: Default::default(),
966            }),
967            primitive: wgpu::PrimitiveState {
968                topology: wgpu::PrimitiveTopology::TriangleList,
969                strip_index_format: None,
970                front_face: wgpu::FrontFace::Ccw,
971                cull_mode: None,
972                polygon_mode: wgpu::PolygonMode::Fill,
973                unclipped_depth: false,
974                conservative: false,
975            },
976            depth_stencil: None,
977            multisample: wgpu::MultisampleState::default(),
978            multiview: None,
979            cache: None,
980        });
981
982        self.pipelines.insert(key, pipeline);
983        key
984    }
985
986    /// Return the camera bind group layout for sharing with the sprite pipeline.
987    pub fn camera_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
988        &self.camera_bind_group_layout
989    }
990
991    /// Render a sorted slice of SDF commands.
992    ///
993    /// Commands should be pre-sorted by layer. Within each pipeline key, instances
994    /// are batched into a single instanced draw call.
995    ///
996    /// Call `prepare()` once per frame before calling `render()`.
997    /// `clear_color`: `Some(color)` -> `LoadOp::Clear`, `None` -> `LoadOp::Load`.
998    pub fn render(
999        &mut self,
1000        device: &wgpu::Device,
1001        encoder: &mut wgpu::CommandEncoder,
1002        target: &wgpu::TextureView,
1003        commands: &[SdfCommand],
1004        clear_color: Option<wgpu::Color>,
1005    ) {
1006        if commands.is_empty() {
1007            return;
1008        }
1009
1010        // Ensure all pipelines are compiled
1011        for cmd in commands {
1012            self.get_or_create_pipeline(device, &cmd.sdf_expr, &cmd.fill);
1013        }
1014
1015        let load_op = match clear_color {
1016            Some(color) => wgpu::LoadOp::Clear(color),
1017            None => wgpu::LoadOp::Load,
1018        };
1019
1020        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1021            label: Some("sdf_render_pass"),
1022            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1023                view: target,
1024                resolve_target: None,
1025                ops: wgpu::Operations {
1026                    load: load_op,
1027                    store: wgpu::StoreOp::Store,
1028                },
1029            })],
1030            depth_stencil_attachment: None,
1031            timestamp_writes: None,
1032            occlusion_query_set: None,
1033        });
1034
1035        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
1036        render_pass.set_bind_group(1, &self.time_bind_group, &[]);
1037        render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1038        render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
1039
1040        // Batch commands by pipeline key (commands are pre-sorted by layer;
1041        // within the same key, gather all instances for one draw call).
1042        let mut i = 0;
1043        while i < commands.len() {
1044            let key = compute_pipeline_key(&commands[i].sdf_expr, &commands[i].fill);
1045            let batch_start = i;
1046
1047            // Gather contiguous commands with the same pipeline key
1048            while i < commands.len()
1049                && compute_pipeline_key(&commands[i].sdf_expr, &commands[i].fill) == key
1050            {
1051                i += 1;
1052            }
1053
1054            let batch = &commands[batch_start..i];
1055            let pipeline = match self.pipelines.get(&key) {
1056                Some(p) => p,
1057                None => continue, // should not happen after ensure step
1058            };
1059
1060            // Build instance data for this batch
1061            let instances: Vec<SdfInstance> = batch
1062                .iter()
1063                .map(|cmd| {
1064                    let primary_color = primary_color_from_fill(&cmd.fill);
1065                    SdfInstance {
1066                        position: [cmd.x, cmd.y],
1067                        bounds: cmd.bounds,
1068                        rotation: cmd.rotation,
1069                        scale: cmd.scale,
1070                        opacity: cmd.opacity,
1071                        _pad: [0.0; 2],
1072                        color: primary_color,
1073                    }
1074                })
1075                .collect();
1076
1077            let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1078                label: Some("sdf_instance_buffer"),
1079                contents: bytemuck::cast_slice(&instances),
1080                usage: wgpu::BufferUsages::VERTEX,
1081            });
1082
1083            render_pass.set_pipeline(pipeline);
1084            render_pass.set_vertex_buffer(1, instance_buffer.slice(..));
1085            render_pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
1086        }
1087    }
1088
1089    /// Number of cached pipelines (useful for diagnostics).
1090    pub fn pipeline_count(&self) -> usize {
1091        self.pipelines.len()
1092    }
1093
1094    /// Remove all cached pipelines (e.g. after a hot-reload).
1095    pub fn clear(&mut self) {
1096        self.pipelines.clear();
1097    }
1098}
1099
1100/// Extract the primary color from a fill for passing to the instance buffer.
1101/// This allows the shader to read a per-instance color even though fills
1102/// with fixed colors bake them into the shader source.
1103fn primary_color_from_fill(fill: &SdfFill) -> [f32; 4] {
1104    match fill {
1105        SdfFill::Solid { color } => *color,
1106        SdfFill::Outline { color, .. } => *color,
1107        SdfFill::SolidWithOutline { fill, .. } => *fill,
1108        SdfFill::Gradient { from, .. } => *from,
1109        SdfFill::Glow { color, .. } => *color,
1110        SdfFill::CosinePalette { a, .. } => [a[0], a[1], a[2], 1.0],
1111    }
1112}
1113
1114// ---------------------------------------------------------------------------
1115// Tests
1116// ---------------------------------------------------------------------------
1117
1118#[cfg(test)]
1119mod tests {
1120    use super::*;
1121
1122    #[test]
1123    fn sdf_instance_is_48_bytes() {
1124        // position (2*4=8) + bounds (4) + rotation (4) + scale (4) + opacity (4)
1125        // + pad (2*4=8) + color (4*4=16) = 48 bytes
1126        assert_eq!(std::mem::size_of::<SdfInstance>(), 48);
1127    }
1128
1129    #[test]
1130    fn sdf_quad_vertex_is_16_bytes() {
1131        // position (2*4=8) + uv (2*4=8) = 16 bytes
1132        assert_eq!(std::mem::size_of::<SdfQuadVertex>(), 16);
1133    }
1134
1135    #[test]
1136    fn generate_shader_contains_expression() {
1137        let shader = generate_sdf_shader(
1138            "sd_circle(p, 50.0)",
1139            &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] },
1140        );
1141        assert!(shader.contains("sd_circle(p, 50.0)"), "shader should contain the SDF expression");
1142        assert!(shader.contains("fn sd_circle"), "shader should contain primitive definitions");
1143        assert!(shader.contains("fn vs_main"), "shader should contain vertex entry point");
1144        assert!(shader.contains("fn fs_main"), "shader should contain fragment entry point");
1145    }
1146
1147    #[test]
1148    fn generate_shader_includes_all_primitives() {
1149        let shader = generate_sdf_shader(
1150            "sd_circle(p, 10.0)",
1151            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1152        );
1153        assert!(shader.contains("fn sd_circle"));
1154        assert!(shader.contains("fn sd_box"));
1155        assert!(shader.contains("fn sd_rounded_box"));
1156        assert!(shader.contains("fn sd_segment"));
1157        assert!(shader.contains("fn sd_capsule"));
1158        assert!(shader.contains("fn sd_ring"));
1159        assert!(shader.contains("fn sd_ellipse"));
1160        assert!(shader.contains("fn sd_hexagon"));
1161        assert!(shader.contains("fn sd_star5"));
1162        assert!(shader.contains("fn sd_cross"));
1163    }
1164
1165    #[test]
1166    fn generate_shader_includes_all_ops() {
1167        let shader = generate_sdf_shader(
1168            "op_union(sd_circle(p, 10.0), sd_box(p, vec2(5.0, 5.0)))",
1169            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1170        );
1171        assert!(shader.contains("fn op_union"));
1172        assert!(shader.contains("fn op_subtract"));
1173        assert!(shader.contains("fn op_intersect"));
1174        assert!(shader.contains("fn op_smooth_union"));
1175        assert!(shader.contains("fn op_smooth_subtract"));
1176        assert!(shader.contains("fn op_smooth_intersect"));
1177        assert!(shader.contains("fn op_round"));
1178        assert!(shader.contains("fn op_annular"));
1179        assert!(shader.contains("fn op_repeat"));
1180        assert!(shader.contains("fn op_translate"));
1181        assert!(shader.contains("fn op_rotate"));
1182        assert!(shader.contains("fn op_scale"));
1183    }
1184
1185    #[test]
1186    fn generate_shader_includes_camera_and_time_bindings() {
1187        let shader = generate_sdf_shader(
1188            "sd_circle(p, 1.0)",
1189            &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] },
1190        );
1191        assert!(shader.contains("struct CameraUniform"));
1192        assert!(shader.contains("@group(0) @binding(0)"));
1193        assert!(shader.contains("struct TimeUniform"));
1194        assert!(shader.contains("@group(1) @binding(0)"));
1195        assert!(shader.contains("var<uniform> camera: CameraUniform"));
1196        assert!(shader.contains("var<uniform> time_data: TimeUniform"));
1197    }
1198
1199    #[test]
1200    fn fill_solid_generates_smoothstep_aa() {
1201        let code = generate_fill_wgsl(&SdfFill::Solid {
1202            color: [1.0, 0.0, 0.0, 1.0],
1203        });
1204        assert!(code.contains("smoothstep"), "solid fill should use smoothstep for AA");
1205        assert!(code.contains("1.000000, 0.000000, 0.000000, 1.000000"));
1206    }
1207
1208    #[test]
1209    fn fill_outline_generates_abs_distance() {
1210        let code = generate_fill_wgsl(&SdfFill::Outline {
1211            color: [0.0, 1.0, 0.0, 1.0],
1212            thickness: 2.0,
1213        });
1214        assert!(code.contains("abs(d)"), "outline fill should use abs(d)");
1215        assert!(code.contains("2.000000"), "outline fill should include thickness");
1216    }
1217
1218    #[test]
1219    fn fill_glow_generates_exp_falloff() {
1220        let code = generate_fill_wgsl(&SdfFill::Glow {
1221            color: [0.0, 0.5, 1.0, 1.0],
1222            intensity: 3.0,
1223        });
1224        assert!(code.contains("exp("), "glow fill should use exponential falloff");
1225        assert!(code.contains("3.000000"), "glow fill should include intensity");
1226    }
1227
1228    #[test]
1229    fn fill_gradient_generates_direction_mapping() {
1230        let code = generate_fill_wgsl(&SdfFill::Gradient {
1231            from: [1.0, 0.0, 0.0, 1.0],
1232            to: [0.0, 0.0, 1.0, 1.0],
1233            angle: 1.5708,
1234            scale: 1.0,
1235        });
1236        assert!(code.contains("grad_dir"), "gradient fill should compute direction");
1237        assert!(code.contains("mix("), "gradient fill should interpolate colors");
1238    }
1239
1240    #[test]
1241    fn fill_cosine_palette_generates_cosine_function() {
1242        let code = generate_fill_wgsl(&SdfFill::CosinePalette {
1243            a: [0.5, 0.5, 0.5],
1244            b: [0.5, 0.5, 0.5],
1245            c: [1.0, 1.0, 1.0],
1246            d: [0.0, 0.33, 0.67],
1247        });
1248        assert!(code.contains("cos("), "cosine palette should use cos()");
1249        assert!(code.contains("6.283185"), "cosine palette should use 2*pi");
1250    }
1251
1252    #[test]
1253    fn fill_solid_with_outline_generates_both_colors() {
1254        let code = generate_fill_wgsl(&SdfFill::SolidWithOutline {
1255            fill: [1.0, 0.0, 0.0, 1.0],
1256            outline: [1.0, 1.0, 1.0, 1.0],
1257            thickness: 1.5,
1258        });
1259        assert!(code.contains("fill_color"), "should have fill color");
1260        assert!(code.contains("outline_color"), "should have outline color");
1261        assert!(code.contains("1.500000"), "should include thickness");
1262    }
1263
1264    #[test]
1265    fn pipeline_key_differs_for_different_expressions() {
1266        let fill = SdfFill::Solid { color: [1.0; 4] };
1267        let k1 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1268        let k2 = compute_pipeline_key("sd_box(p, vec2(5.0, 5.0))", &fill);
1269        assert_ne!(k1, k2, "different expressions should produce different keys");
1270    }
1271
1272    #[test]
1273    fn pipeline_key_differs_for_different_fills() {
1274        let expr = "sd_circle(p, 10.0)";
1275        let k1 = compute_pipeline_key(expr, &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] });
1276        let k2 = compute_pipeline_key(expr, &SdfFill::Solid { color: [0.0, 1.0, 0.0, 1.0] });
1277        assert_ne!(k1, k2, "different fills should produce different keys");
1278    }
1279
1280    #[test]
1281    fn pipeline_key_is_deterministic() {
1282        let fill = SdfFill::Glow { color: [0.0, 0.5, 1.0, 1.0], intensity: 3.0 };
1283        let k1 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1284        let k2 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1285        assert_eq!(k1, k2, "same input should produce the same key");
1286    }
1287
1288    #[test]
1289    fn compute_fill_hash_varies_by_discriminant() {
1290        let h1 = compute_fill_hash(&SdfFill::Solid { color: [1.0; 4] });
1291        let h2 = compute_fill_hash(&SdfFill::Glow { color: [1.0; 4], intensity: 1.0 });
1292        assert_ne!(h1, h2, "different fill types should hash differently");
1293    }
1294
1295    #[test]
1296    fn primary_color_extraction() {
1297        assert_eq!(
1298            primary_color_from_fill(&SdfFill::Solid { color: [0.1, 0.2, 0.3, 0.4] }),
1299            [0.1, 0.2, 0.3, 0.4],
1300        );
1301        assert_eq!(
1302            primary_color_from_fill(&SdfFill::Outline { color: [0.5, 0.6, 0.7, 0.8], thickness: 1.0 }),
1303            [0.5, 0.6, 0.7, 0.8],
1304        );
1305        assert_eq!(
1306            primary_color_from_fill(&SdfFill::SolidWithOutline {
1307                fill: [0.1, 0.2, 0.3, 0.4],
1308                outline: [0.9, 0.9, 0.9, 1.0],
1309                thickness: 2.0,
1310            }),
1311            [0.1, 0.2, 0.3, 0.4],
1312        );
1313        assert_eq!(
1314            primary_color_from_fill(&SdfFill::Gradient {
1315                from: [1.0, 0.0, 0.0, 1.0],
1316                to: [0.0, 0.0, 1.0, 1.0],
1317                angle: 0.0,
1318                scale: 1.0,
1319            }),
1320            [1.0, 0.0, 0.0, 1.0],
1321        );
1322        assert_eq!(
1323            primary_color_from_fill(&SdfFill::Glow { color: [0.0, 1.0, 0.0, 1.0], intensity: 5.0 }),
1324            [0.0, 1.0, 0.0, 1.0],
1325        );
1326        let cosine = primary_color_from_fill(&SdfFill::CosinePalette {
1327            a: [0.5, 0.5, 0.5],
1328            b: [0.5, 0.5, 0.5],
1329            c: [1.0, 1.0, 1.0],
1330            d: [0.0, 0.33, 0.67],
1331        });
1332        assert_eq!(cosine, [0.5, 0.5, 0.5, 1.0]);
1333    }
1334
1335    #[test]
1336    fn generated_shader_has_valid_structure() {
1337        // Verify the shader has proper WGSL structure (all sections present)
1338        let shader = generate_sdf_shader(
1339            "op_smooth_union(sd_circle(p, 30.0), sd_box(p, vec2<f32>(20.0, 20.0)), 5.0)",
1340            &SdfFill::SolidWithOutline {
1341                fill: [0.2, 0.4, 0.8, 1.0],
1342                outline: [1.0, 1.0, 1.0, 1.0],
1343                thickness: 2.0,
1344            },
1345        );
1346
1347        // Check structure ordering: primitives -> bindings -> vertex -> fragment
1348        let prim_pos = shader.find("fn sd_circle").unwrap();
1349        let binding_pos = shader.find("struct CameraUniform").unwrap();
1350        let vs_pos = shader.find("fn vs_main").unwrap();
1351        let fs_pos = shader.find("fn fs_main").unwrap();
1352
1353        assert!(prim_pos < binding_pos, "primitives should come before bindings");
1354        assert!(binding_pos < vs_pos, "bindings should come before vertex shader");
1355        assert!(vs_pos < fs_pos, "vertex shader should come before fragment shader");
1356    }
1357
1358    #[test]
1359    fn sdf_quad_vertices_are_centered() {
1360        // The quad should span from -1 to 1 in both axes
1361        assert_eq!(SDF_QUAD_VERTICES[0].position, [-1.0, -1.0]);
1362        assert_eq!(SDF_QUAD_VERTICES[1].position, [1.0, -1.0]);
1363        assert_eq!(SDF_QUAD_VERTICES[2].position, [1.0, 1.0]);
1364        assert_eq!(SDF_QUAD_VERTICES[3].position, [-1.0, 1.0]);
1365    }
1366
1367    #[test]
1368    fn sdf_quad_indices_form_two_triangles() {
1369        assert_eq!(SDF_QUAD_INDICES.len(), 6);
1370        assert_eq!(SDF_QUAD_INDICES, &[0, 1, 2, 0, 2, 3]);
1371    }
1372
1373    // -----------------------------------------------------------------------
1374    // WGSL validation tests (using naga parser)
1375    //
1376    // These tests verify that the generated WGSL shaders are syntactically
1377    // and semantically valid by parsing them through naga's WGSL front-end.
1378    // This catches type mismatches, undefined variables, and syntax errors
1379    // without needing a GPU device.
1380    // -----------------------------------------------------------------------
1381
1382    /// Parse the given WGSL source through naga and return Ok if valid.
1383    fn validate_wgsl(source: &str) -> Result<(), String> {
1384        match naga::front::wgsl::parse_str(source) {
1385            Ok(_module) => Ok(()),
1386            Err(e) => Err(format!("{e:?}")),
1387        }
1388    }
1389
1390    // === Primitive compilation tests ===
1391
1392    #[test]
1393    fn wgsl_circle_compiles() {
1394        let shader = generate_sdf_shader(
1395            "sd_circle(p, 50.0)",
1396            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1397        );
1398        let result = validate_wgsl(&shader);
1399        assert!(result.is_ok(), "Circle shader failed: {}", result.unwrap_err());
1400    }
1401
1402    #[test]
1403    fn wgsl_box_compiles() {
1404        let shader = generate_sdf_shader(
1405            "sd_box(p, vec2<f32>(20.0, 10.0))",
1406            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1407        );
1408        let result = validate_wgsl(&shader);
1409        assert!(result.is_ok(), "Box shader failed: {}", result.unwrap_err());
1410    }
1411
1412    #[test]
1413    fn wgsl_rounded_box_compiles() {
1414        let shader = generate_sdf_shader(
1415            "sd_rounded_box(p, vec2<f32>(20.0, 10.0), vec4<f32>(3.0, 3.0, 3.0, 3.0))",
1416            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1417        );
1418        let result = validate_wgsl(&shader);
1419        assert!(result.is_ok(), "Rounded box shader failed: {}", result.unwrap_err());
1420    }
1421
1422    #[test]
1423    fn wgsl_all_primitives_compile() {
1424        // Test every SDF primitive in the inline WGSL library.
1425        // Each expression must be a valid WGSL f32 expression given `p: vec2<f32>`.
1426        let primitives: &[(&str, &str)] = &[
1427            ("sd_circle", "sd_circle(p, 50.0)"),
1428            ("sd_box", "sd_box(p, vec2<f32>(20.0, 10.0))"),
1429            ("sd_rounded_box", "sd_rounded_box(p, vec2<f32>(20.0, 10.0), vec4<f32>(3.0, 3.0, 3.0, 3.0))"),
1430            ("sd_segment", "sd_segment(p, vec2<f32>(0.0, 0.0), vec2<f32>(20.0, 10.0))"),
1431            ("sd_capsule", "sd_capsule(p, vec2<f32>(-10.0, 0.0), vec2<f32>(10.0, 0.0), 5.0)"),
1432            ("sd_equilateral_triangle", "sd_equilateral_triangle(p, 20.0)"),
1433            ("sd_ring", "sd_ring(p, 20.0, 3.0)"),
1434            ("sd_ellipse", "sd_ellipse(p, vec2<f32>(30.0, 15.0))"),
1435            ("sd_hexagon", "sd_hexagon(p, 20.0)"),
1436            ("sd_star5", "sd_star5(p, 15.0, 0.4)"),
1437            ("sd_cross", "sd_cross(p, vec2<f32>(20.0, 5.0), 2.0)"),
1438        ];
1439        for (name, expr) in primitives {
1440            let shader = generate_sdf_shader(expr, &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] });
1441            let result = validate_wgsl(&shader);
1442            assert!(result.is_ok(), "Primitive '{name}' failed to compile.\nExpression: {expr}\nError: {}", result.unwrap_err());
1443        }
1444    }
1445
1446    // === Composition operator tests ===
1447
1448    #[test]
1449    fn wgsl_composition_operators_compile() {
1450        let exprs: &[(&str, &str)] = &[
1451            ("op_union", "op_union(sd_circle(p, 20.0), sd_box(p, vec2<f32>(15.0, 15.0)))"),
1452            ("op_subtract", "op_subtract(sd_circle(p, 20.0), sd_circle(p - vec2<f32>(10.0, 0.0), 15.0))"),
1453            ("op_intersect", "op_intersect(sd_circle(p, 20.0), sd_box(p, vec2<f32>(15.0, 15.0)))"),
1454            ("op_smooth_union", "op_smooth_union(sd_circle(p, 20.0), sd_box(p - vec2<f32>(15.0, 0.0), vec2<f32>(10.0, 10.0)), 5.0)"),
1455            ("op_smooth_subtract", "op_smooth_subtract(sd_circle(p, 20.0), sd_circle(p - vec2<f32>(5.0, 0.0), 15.0), 3.0)"),
1456            ("op_smooth_intersect", "op_smooth_intersect(sd_circle(p, 25.0), sd_box(p, vec2<f32>(15.0, 15.0)), 4.0)"),
1457        ];
1458        for (name, expr) in exprs {
1459            let shader = generate_sdf_shader(expr, &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] });
1460            let result = validate_wgsl(&shader);
1461            assert!(result.is_ok(), "Composition '{name}' failed.\nExpression: {expr}\nError: {}", result.unwrap_err());
1462        }
1463    }
1464
1465    // === Fill type compilation tests ===
1466
1467    #[test]
1468    fn wgsl_all_fill_types_compile() {
1469        let expr = "sd_circle(p, 30.0)";
1470        let fills: Vec<(&str, SdfFill)> = vec![
1471            ("Solid", SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] }),
1472            ("Outline", SdfFill::Outline { color: [1.0, 0.0, 0.0, 1.0], thickness: 2.0 }),
1473            ("SolidWithOutline", SdfFill::SolidWithOutline {
1474                fill: [0.0, 0.0, 1.0, 1.0],
1475                outline: [1.0, 1.0, 1.0, 1.0],
1476                thickness: 1.5,
1477            }),
1478            ("Gradient", SdfFill::Gradient {
1479                from: [1.0, 0.0, 0.0, 1.0],
1480                to: [0.0, 0.0, 1.0, 1.0],
1481                angle: 1.5707,
1482                scale: 1.0,
1483            }),
1484            ("Glow", SdfFill::Glow { color: [0.0, 1.0, 0.5, 1.0], intensity: 0.8 }),
1485            ("CosinePalette", SdfFill::CosinePalette {
1486                a: [0.5, 0.5, 0.5],
1487                b: [0.5, 0.5, 0.5],
1488                c: [1.0, 1.0, 1.0],
1489                d: [0.0, 0.33, 0.67],
1490            }),
1491        ];
1492        for (name, fill) in &fills {
1493            let shader = generate_sdf_shader(expr, fill);
1494            let result = validate_wgsl(&shader);
1495            assert!(result.is_ok(), "Fill '{name}' failed to compile.\nError: {}", result.unwrap_err());
1496        }
1497    }
1498
1499    // === Complex expression tests ===
1500
1501    #[test]
1502    fn wgsl_deeply_nested_tree_compiles() {
1503        // A tree-like shape: trunk (rounded box) + layered foliage (smooth-unioned circles)
1504        let expr = concat!(
1505            "op_smooth_union(",
1506                "op_smooth_union(",
1507                    "op_smooth_union(",
1508                        "sd_rounded_box(p, vec2<f32>(8.0, 30.0), vec4<f32>(2.0, 2.0, 2.0, 2.0)), ",
1509                        "sd_circle(p - vec2<f32>(0.0, 25.0), 18.0), ",
1510                    "4.0), ",
1511                    "sd_circle(p - vec2<f32>(-10.0, 20.0), 14.0), ",
1512                "4.0), ",
1513                "sd_circle(p - vec2<f32>(10.0, 20.0), 14.0), ",
1514            "4.0)",
1515        );
1516        let shader = generate_sdf_shader(expr, &SdfFill::Gradient {
1517            from: [0.35, 0.23, 0.1, 1.0],
1518            to: [0.18, 0.54, 0.3, 1.0],
1519            angle: 1.5707,
1520            scale: 1.0,
1521        });
1522        let result = validate_wgsl(&shader);
1523        assert!(result.is_ok(), "Nested tree failed: {}", result.unwrap_err());
1524    }
1525
1526    #[test]
1527    fn wgsl_subtract_creates_crescent() {
1528        // Classic crescent moon via subtraction of two circles
1529        let expr = "op_subtract(sd_circle(p, 25.0), sd_circle(p - vec2<f32>(10.0, 5.0), 22.0))";
1530        let shader = generate_sdf_shader(expr, &SdfFill::Glow {
1531            color: [0.9, 0.9, 0.6, 1.0],
1532            intensity: 0.5,
1533        });
1534        let result = validate_wgsl(&shader);
1535        assert!(result.is_ok(), "Crescent moon failed: {}", result.unwrap_err());
1536    }
1537
1538    // === Domain transform tests ===
1539
1540    #[test]
1541    fn wgsl_time_uniform_in_expression() {
1542        // The time uniform is available in the fragment shader as time_data.time
1543        let shader = generate_sdf_shader(
1544            "sd_circle(p, 20.0 + sin(time_data.time) * 5.0)",
1545            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1546        );
1547        let result = validate_wgsl(&shader);
1548        assert!(result.is_ok(), "Time uniform shader failed: {}", result.unwrap_err());
1549    }
1550
1551    #[test]
1552    fn wgsl_repeat_domain_compiles() {
1553        // Infinite repetition via op_repeat domain transform
1554        let shader = generate_sdf_shader(
1555            "sd_circle(op_repeat(p, vec2<f32>(40.0, 40.0)), 10.0)",
1556            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1557        );
1558        let result = validate_wgsl(&shader);
1559        assert!(result.is_ok(), "Repeat domain failed: {}", result.unwrap_err());
1560    }
1561
1562    #[test]
1563    fn wgsl_rotate_transform_compiles() {
1564        // Rotate a box by 45 degrees using op_rotate
1565        let shader = generate_sdf_shader(
1566            "sd_box(op_rotate(p, 0.785), vec2<f32>(20.0, 10.0))",
1567            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1568        );
1569        let result = validate_wgsl(&shader);
1570        assert!(result.is_ok(), "Rotate transform failed: {}", result.unwrap_err());
1571    }
1572
1573    #[test]
1574    fn wgsl_translate_transform_compiles() {
1575        // Translate a circle using op_translate
1576        let shader = generate_sdf_shader(
1577            "sd_circle(op_translate(p, vec2<f32>(15.0, 10.0)), 12.0)",
1578            &SdfFill::Outline { color: [0.0, 1.0, 0.0, 1.0], thickness: 2.0 },
1579        );
1580        let result = validate_wgsl(&shader);
1581        assert!(result.is_ok(), "Translate transform failed: {}", result.unwrap_err());
1582    }
1583
1584    #[test]
1585    fn wgsl_scale_transform_compiles() {
1586        // Scale a star using op_scale
1587        let shader = generate_sdf_shader(
1588            "sd_star5(op_scale(p, 2.0), 15.0, 0.4) * 2.0",
1589            &SdfFill::Solid { color: [1.0, 0.8, 0.0, 1.0] },
1590        );
1591        let result = validate_wgsl(&shader);
1592        assert!(result.is_ok(), "Scale transform failed: {}", result.unwrap_err());
1593    }
1594
1595    // === Modifier tests ===
1596
1597    #[test]
1598    fn wgsl_round_modifier_compiles() {
1599        let shader = generate_sdf_shader(
1600            "op_round(sd_box(p, vec2<f32>(20.0, 10.0)), 3.0)",
1601            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1602        );
1603        let result = validate_wgsl(&shader);
1604        assert!(result.is_ok(), "Round modifier failed: {}", result.unwrap_err());
1605    }
1606
1607    #[test]
1608    fn wgsl_annular_modifier_compiles() {
1609        // op_annular hollows out a shape (like op_onion in the standalone WGSL)
1610        let shader = generate_sdf_shader(
1611            "op_annular(sd_circle(p, 20.0), 2.0)",
1612            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1613        );
1614        let result = validate_wgsl(&shader);
1615        assert!(result.is_ok(), "Annular modifier failed: {}", result.unwrap_err());
1616    }
1617
1618    // === Combined transforms + compositions ===
1619
1620    #[test]
1621    fn wgsl_complex_scene_compiles() {
1622        // A complex scene: repeated circles unioned with a rotated cross,
1623        // all with a gradient fill. Tests multiple features together.
1624        let expr = concat!(
1625            "op_union(",
1626                "sd_circle(op_repeat(p, vec2<f32>(50.0, 50.0)), 8.0), ",
1627                "sd_cross(op_rotate(p, 0.785), vec2<f32>(30.0, 6.0), 2.0)",
1628            ")",
1629        );
1630        let shader = generate_sdf_shader(expr, &SdfFill::CosinePalette {
1631            a: [0.5, 0.5, 0.5],
1632            b: [0.5, 0.5, 0.5],
1633            c: [1.0, 1.0, 1.0],
1634            d: [0.0, 0.33, 0.67],
1635        });
1636        let result = validate_wgsl(&shader);
1637        assert!(result.is_ok(), "Complex scene failed: {}", result.unwrap_err());
1638    }
1639
1640    #[test]
1641    fn wgsl_animated_pulsing_ring() {
1642        // Animated ring that pulses with time
1643        let shader = generate_sdf_shader(
1644            "sd_ring(p, 25.0 + sin(time_data.time * 2.0) * 5.0, 3.0)",
1645            &SdfFill::Glow { color: [0.2, 0.8, 1.0, 1.0], intensity: 1.5 },
1646        );
1647        let result = validate_wgsl(&shader);
1648        assert!(result.is_ok(), "Animated ring failed: {}", result.unwrap_err());
1649    }
1650
1651    #[test]
1652    fn wgsl_three_way_smooth_union() {
1653        // Three shapes blended together
1654        let expr = concat!(
1655            "op_smooth_union(",
1656                "op_smooth_union(",
1657                    "sd_circle(p - vec2<f32>(-15.0, 0.0), 12.0), ",
1658                    "sd_circle(p - vec2<f32>(15.0, 0.0), 12.0), ",
1659                "6.0), ",
1660                "sd_circle(p - vec2<f32>(0.0, 15.0), 12.0), ",
1661            "6.0)",
1662        );
1663        let shader = generate_sdf_shader(expr, &SdfFill::SolidWithOutline {
1664            fill: [0.3, 0.3, 0.8, 1.0],
1665            outline: [1.0, 1.0, 1.0, 1.0],
1666            thickness: 1.0,
1667        });
1668        let result = validate_wgsl(&shader);
1669        assert!(result.is_ok(), "Three-way smooth union failed: {}", result.unwrap_err());
1670    }
1671}