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    /// Glow falloff outside the shape (intensity controls decay rate).
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: higher intensity = tighter glow
539    // Inside shape (d <= 0): full opacity
540    // Outside shape (d > 0): exponential decay based on intensity
541    let glow_alpha = exp(-max(d, 0.0) * glow_intensity);
542    out_color = vec4<f32>(glow_color.rgb, glow_color.a * glow_alpha * in_opacity);"#,
543                color[0], color[1], color[2], color[3],
544                intensity
545            )
546        }
547        SdfFill::CosinePalette { a, b, c, d: d_param } => {
548            format!(
549                r#"    // Cosine palette fill with adaptive AA (fwidth-based)
550    let pal_a = vec3<f32>({:.6}, {:.6}, {:.6});
551    let pal_b = vec3<f32>({:.6}, {:.6}, {:.6});
552    let pal_c = vec3<f32>({:.6}, {:.6}, {:.6});
553    let pal_d = vec3<f32>({:.6}, {:.6}, {:.6});
554    // t derived from distance, normalized by bounds
555    let pal_t = d / in_bounds;
556    let pal_color = pal_a + pal_b * cos(6.283185 * (pal_c * pal_t + pal_d));
557    let aa_width = fwidth(d) * 0.5;
558    let aa = 1.0 - smoothstep(-aa_width, aa_width, d);
559    out_color = vec4<f32>(clamp(pal_color, vec3<f32>(0.0), vec3<f32>(1.0)), aa * in_opacity);"#,
560                a[0], a[1], a[2],
561                b[0], b[1], b[2],
562                c[0], c[1], c[2],
563                d_param[0], d_param[1], d_param[2]
564            )
565        }
566    }
567}
568
569/// Generate a complete, self-contained WGSL shader module for the given SDF
570/// expression and fill mode.
571///
572/// The output can be compiled directly by `wgpu::Device::create_shader_module`.
573///
574/// # Arguments
575///
576/// * `sdf_expr` - A WGSL expression that evaluates to `f32` given a variable
577///   `p: vec2<f32>` in local space (coordinates range roughly -bounds..bounds).
578///   Example: `"sd_circle(p, 50.0)"`
579/// * `fill` - Determines how the distance field is colored.
580pub fn generate_sdf_shader(sdf_expr: &str, fill: &SdfFill) -> String {
581    let fill_code = generate_fill_wgsl(fill);
582
583    format!(
584        r#"// Auto-generated SDF shader
585// Expression: {sdf_expr}
586
587{SDF_PRIMITIVES_WGSL}
588
589// ---- Bindings ----
590
591struct CameraUniform {{
592    view_proj: mat4x4<f32>,
593}};
594
595@group(0) @binding(0)
596var<uniform> camera: CameraUniform;
597
598struct TimeUniform {{
599    time: f32,
600}};
601
602@group(1) @binding(0)
603var<uniform> time_data: TimeUniform;
604
605// ---- Vertex stage ----
606
607struct VertexInput {{
608    @location(0) position: vec2<f32>,
609    @location(1) uv: vec2<f32>,
610}};
611
612struct InstanceInput {{
613    @location(2) inst_position: vec2<f32>,
614    @location(3) inst_bounds: f32,
615    @location(4) inst_rotation: f32,
616    @location(5) inst_scale: f32,
617    @location(6) inst_opacity: f32,
618    @location(7) inst_pad: vec2<f32>,
619    @location(8) inst_color: vec4<f32>,
620}};
621
622struct VertexOutput {{
623    @builtin(position) clip_position: vec4<f32>,
624    @location(0) local_pos: vec2<f32>,
625    @location(1) v_opacity: f32,
626    @location(2) v_bounds: f32,
627    @location(3) v_color: vec4<f32>,
628    @location(4) v_scale: f32,
629}};
630
631@vertex
632fn vs_main(vertex: VertexInput, instance: InstanceInput) -> VertexOutput {{
633    var out: VertexOutput;
634
635    let scaled_bounds = instance.inst_bounds * instance.inst_scale;
636
637    // Quad vertex in local space: vertex.position is in [-1, 1]
638    // Scale the quad for visual scaling
639    var pos = vertex.position * scaled_bounds;
640
641    // Apply instance rotation around center
642    let cos_r = cos(instance.inst_rotation);
643    let sin_r = sin(instance.inst_rotation);
644    let rotated = vec2<f32>(
645        pos.x * cos_r - pos.y * sin_r,
646        pos.x * sin_r + pos.y * cos_r,
647    );
648
649    // Translate to world position
650    let world_xy = rotated + instance.inst_position;
651    let world = vec4<f32>(world_xy.x, world_xy.y, 0.0, 1.0);
652    out.clip_position = camera.view_proj * world;
653
654    // Pass local-space coordinate to fragment WITHOUT scale
655    // This ensures SDF is evaluated in original coordinate space
656    // Flip Y to match Arcane's screen coordinate system (Y=0 at top, Y increases down)
657    out.local_pos = vec2<f32>(vertex.position.x, -vertex.position.y) * instance.inst_bounds;
658    out.v_opacity = instance.inst_opacity;
659    out.v_bounds = instance.inst_bounds;
660    out.v_scale = instance.inst_scale;
661    out.v_color = instance.inst_color;
662
663    return out;
664}}
665
666// ---- Fragment stage ----
667
668@fragment
669fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {{
670    let p = in.local_pos;
671    let in_bounds = in.v_bounds;
672    let in_opacity = in.v_opacity;
673    let in_color = in.v_color;
674    let in_scale = in.v_scale;
675    let time = time_data.time;
676
677    // Evaluate the SDF expression, then scale the distance for proper anti-aliasing
678    let d = {sdf_expr} * in_scale;
679
680    // Apply fill
681    var out_color: vec4<f32>;
682{fill_code}
683
684    return out_color;
685}}
686"#,
687        sdf_expr = sdf_expr,
688        SDF_PRIMITIVES_WGSL = SDF_PRIMITIVES_WGSL,
689        fill_code = fill_code,
690    )
691}
692
693// ---------------------------------------------------------------------------
694// SDF pipeline store
695// ---------------------------------------------------------------------------
696
697/// Camera uniform buffer data (matches sprite pipeline).
698#[repr(C)]
699#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
700struct CameraUniform {
701    view_proj: [f32; 16],
702}
703
704/// Manages cached SDF pipelines and renders SDF commands.
705pub struct SdfPipelineStore {
706    /// Cached render pipelines keyed by `compute_pipeline_key(expr, fill)`.
707    pipelines: HashMap<u64, wgpu::RenderPipeline>,
708    /// Shared pipeline layout (all SDF pipelines use the same bind group layout).
709    pipeline_layout: wgpu::PipelineLayout,
710    /// Camera bind group layout (group 0).
711    #[allow(dead_code)]
712    camera_bind_group_layout: wgpu::BindGroupLayout,
713    /// Camera uniform buffer.
714    camera_buffer: wgpu::Buffer,
715    /// Camera bind group.
716    camera_bind_group: wgpu::BindGroup,
717    /// Time uniform bind group layout (group 1).
718    /// Retained so the layout stays alive for the pipeline layout's internal reference.
719    #[allow(dead_code)]
720    time_bind_group_layout: wgpu::BindGroupLayout,
721    /// Time uniform buffer.
722    time_buffer: wgpu::Buffer,
723    /// Time uniform bind group.
724    time_bind_group: wgpu::BindGroup,
725    /// Static quad vertex buffer (shared across all SDF draws).
726    vertex_buffer: wgpu::Buffer,
727    /// Static quad index buffer.
728    index_buffer: wgpu::Buffer,
729    /// Surface texture format (needed when creating new pipelines).
730    surface_format: wgpu::TextureFormat,
731}
732
733impl SdfPipelineStore {
734    /// Create a new SDF pipeline store.
735    pub fn new(gpu: &GpuContext) -> Self {
736        Self::new_internal(&gpu.device, gpu.config.format)
737    }
738
739    /// Create for headless testing (no surface required).
740    pub fn new_headless(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
741        Self::new_internal(device, format)
742    }
743
744    fn new_internal(device: &wgpu::Device, surface_format: wgpu::TextureFormat) -> Self {
745        // Camera uniform bind group layout (group 0)
746        let camera_bind_group_layout =
747            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
748                label: Some("sdf_camera_bgl"),
749                entries: &[wgpu::BindGroupLayoutEntry {
750                    binding: 0,
751                    visibility: wgpu::ShaderStages::VERTEX,
752                    ty: wgpu::BindingType::Buffer {
753                        ty: wgpu::BufferBindingType::Uniform,
754                        has_dynamic_offset: false,
755                        min_binding_size: None,
756                    },
757                    count: None,
758                }],
759            });
760
761        // Time uniform bind group layout (group 1)
762        let time_bind_group_layout =
763            device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
764                label: Some("sdf_time_bgl"),
765                entries: &[wgpu::BindGroupLayoutEntry {
766                    binding: 0,
767                    visibility: wgpu::ShaderStages::FRAGMENT,
768                    ty: wgpu::BindingType::Buffer {
769                        ty: wgpu::BufferBindingType::Uniform,
770                        has_dynamic_offset: false,
771                        min_binding_size: None,
772                    },
773                    count: None,
774                }],
775            });
776
777        let pipeline_layout =
778            device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
779                label: Some("sdf_pipeline_layout"),
780                bind_group_layouts: &[&camera_bind_group_layout, &time_bind_group_layout],
781                push_constant_ranges: &[],
782            });
783
784        // Time uniform buffer (single f32, padded to 4 bytes minimum)
785        let time_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
786            label: Some("sdf_time_buffer"),
787            contents: bytemuck::cast_slice(&[0.0f32]),
788            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
789        });
790
791        let time_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
792            label: Some("sdf_time_bind_group"),
793            layout: &time_bind_group_layout,
794            entries: &[wgpu::BindGroupEntry {
795                binding: 0,
796                resource: time_buffer.as_entire_binding(),
797            }],
798        });
799
800        // Camera uniform buffer
801        let camera_uniform = CameraUniform {
802            view_proj: [
803                1.0, 0.0, 0.0, 0.0,
804                0.0, 1.0, 0.0, 0.0,
805                0.0, 0.0, 1.0, 0.0,
806                0.0, 0.0, 0.0, 1.0,
807            ],
808        };
809        let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
810            label: Some("sdf_camera_buffer"),
811            contents: bytemuck::cast_slice(&[camera_uniform]),
812            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
813        });
814
815        let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
816            label: Some("sdf_camera_bind_group"),
817            layout: &camera_bind_group_layout,
818            entries: &[wgpu::BindGroupEntry {
819                binding: 0,
820                resource: camera_buffer.as_entire_binding(),
821            }],
822        });
823
824        // Static quad geometry
825        let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
826            label: Some("sdf_quad_vertex_buffer"),
827            contents: bytemuck::cast_slice(SDF_QUAD_VERTICES),
828            usage: wgpu::BufferUsages::VERTEX,
829        });
830
831        let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
832            label: Some("sdf_quad_index_buffer"),
833            contents: bytemuck::cast_slice(SDF_QUAD_INDICES),
834            usage: wgpu::BufferUsages::INDEX,
835        });
836
837        Self {
838            pipelines: HashMap::new(),
839            pipeline_layout,
840            camera_bind_group_layout,
841            camera_buffer,
842            camera_bind_group,
843            time_bind_group_layout,
844            time_buffer,
845            time_bind_group,
846            vertex_buffer,
847            index_buffer,
848            surface_format,
849        }
850    }
851
852    /// Update the camera and time uniforms. Call once per frame before rendering.
853    pub fn prepare(&self, queue: &wgpu::Queue, camera: &super::Camera2D, time: f32) {
854        let camera_uniform = CameraUniform {
855            view_proj: camera.view_proj(),
856        };
857        queue.write_buffer(
858            &self.camera_buffer,
859            0,
860            bytemuck::cast_slice(&[camera_uniform]),
861        );
862        queue.write_buffer(&self.time_buffer, 0, bytemuck::cast_slice(&[time]));
863    }
864
865    /// Update the time uniform. Call once per frame before rendering.
866    pub fn set_time(&self, queue: &wgpu::Queue, time: f32) {
867        queue.write_buffer(&self.time_buffer, 0, bytemuck::cast_slice(&[time]));
868    }
869
870    /// Get or create the render pipeline for the given SDF expression + fill.
871    /// Returns a reference to the cached pipeline.
872    pub fn get_or_create_pipeline(
873        &mut self,
874        device: &wgpu::Device,
875        sdf_expr: &str,
876        fill: &SdfFill,
877    ) -> u64 {
878        let key = compute_pipeline_key(sdf_expr, fill);
879        if self.pipelines.contains_key(&key) {
880            return key;
881        }
882
883        let wgsl = generate_sdf_shader(sdf_expr, fill);
884        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
885            label: Some("sdf_shader"),
886            source: wgpu::ShaderSource::Wgsl(wgsl.into()),
887        });
888
889        // Vertex buffer layout: per-vertex quad data
890        let vertex_layout = wgpu::VertexBufferLayout {
891            array_stride: std::mem::size_of::<SdfQuadVertex>() as wgpu::BufferAddress,
892            step_mode: wgpu::VertexStepMode::Vertex,
893            attributes: &[
894                wgpu::VertexAttribute {
895                    offset: 0,
896                    shader_location: 0,
897                    format: wgpu::VertexFormat::Float32x2, // position
898                },
899                wgpu::VertexAttribute {
900                    offset: 8,
901                    shader_location: 1,
902                    format: wgpu::VertexFormat::Float32x2, // uv
903                },
904            ],
905        };
906
907        // Instance buffer layout: per-instance SDF entity data
908        let instance_layout = wgpu::VertexBufferLayout {
909            array_stride: std::mem::size_of::<SdfInstance>() as wgpu::BufferAddress,
910            step_mode: wgpu::VertexStepMode::Instance,
911            attributes: &[
912                wgpu::VertexAttribute {
913                    offset: 0,
914                    shader_location: 2,
915                    format: wgpu::VertexFormat::Float32x2, // position
916                },
917                wgpu::VertexAttribute {
918                    offset: 8,
919                    shader_location: 3,
920                    format: wgpu::VertexFormat::Float32, // bounds
921                },
922                wgpu::VertexAttribute {
923                    offset: 12,
924                    shader_location: 4,
925                    format: wgpu::VertexFormat::Float32, // rotation
926                },
927                wgpu::VertexAttribute {
928                    offset: 16,
929                    shader_location: 5,
930                    format: wgpu::VertexFormat::Float32, // scale
931                },
932                wgpu::VertexAttribute {
933                    offset: 20,
934                    shader_location: 6,
935                    format: wgpu::VertexFormat::Float32, // opacity
936                },
937                wgpu::VertexAttribute {
938                    offset: 24,
939                    shader_location: 7,
940                    format: wgpu::VertexFormat::Float32x2, // _pad
941                },
942                wgpu::VertexAttribute {
943                    offset: 32,
944                    shader_location: 8,
945                    format: wgpu::VertexFormat::Float32x4, // color
946                },
947            ],
948        };
949
950        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
951            label: Some("sdf_render_pipeline"),
952            layout: Some(&self.pipeline_layout),
953            vertex: wgpu::VertexState {
954                module: &shader,
955                entry_point: Some("vs_main"),
956                buffers: &[vertex_layout, instance_layout],
957                compilation_options: Default::default(),
958            },
959            fragment: Some(wgpu::FragmentState {
960                module: &shader,
961                entry_point: Some("fs_main"),
962                targets: &[Some(wgpu::ColorTargetState {
963                    format: self.surface_format,
964                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
965                    write_mask: wgpu::ColorWrites::ALL,
966                })],
967                compilation_options: Default::default(),
968            }),
969            primitive: wgpu::PrimitiveState {
970                topology: wgpu::PrimitiveTopology::TriangleList,
971                strip_index_format: None,
972                front_face: wgpu::FrontFace::Ccw,
973                cull_mode: None,
974                polygon_mode: wgpu::PolygonMode::Fill,
975                unclipped_depth: false,
976                conservative: false,
977            },
978            depth_stencil: None,
979            multisample: wgpu::MultisampleState::default(),
980            multiview: None,
981            cache: None,
982        });
983
984        self.pipelines.insert(key, pipeline);
985        key
986    }
987
988    /// Return the camera bind group layout for sharing with the sprite pipeline.
989    pub fn camera_bind_group_layout(&self) -> &wgpu::BindGroupLayout {
990        &self.camera_bind_group_layout
991    }
992
993    /// Render a sorted slice of SDF commands.
994    ///
995    /// Commands should be pre-sorted by layer. Within each pipeline key, instances
996    /// are batched into a single instanced draw call.
997    ///
998    /// Call `prepare()` once per frame before calling `render()`.
999    /// `clear_color`: `Some(color)` -> `LoadOp::Clear`, `None` -> `LoadOp::Load`.
1000    pub fn render(
1001        &mut self,
1002        device: &wgpu::Device,
1003        encoder: &mut wgpu::CommandEncoder,
1004        target: &wgpu::TextureView,
1005        commands: &[SdfCommand],
1006        clear_color: Option<wgpu::Color>,
1007    ) {
1008        if commands.is_empty() {
1009            return;
1010        }
1011
1012        // Ensure all pipelines are compiled
1013        for cmd in commands {
1014            self.get_or_create_pipeline(device, &cmd.sdf_expr, &cmd.fill);
1015        }
1016
1017        let load_op = match clear_color {
1018            Some(color) => wgpu::LoadOp::Clear(color),
1019            None => wgpu::LoadOp::Load,
1020        };
1021
1022        let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1023            label: Some("sdf_render_pass"),
1024            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1025                view: target,
1026                resolve_target: None,
1027                ops: wgpu::Operations {
1028                    load: load_op,
1029                    store: wgpu::StoreOp::Store,
1030                },
1031            })],
1032            depth_stencil_attachment: None,
1033            timestamp_writes: None,
1034            occlusion_query_set: None,
1035        });
1036
1037        render_pass.set_bind_group(0, &self.camera_bind_group, &[]);
1038        render_pass.set_bind_group(1, &self.time_bind_group, &[]);
1039        render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
1040        render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16);
1041
1042        // Batch commands by pipeline key (commands are pre-sorted by layer;
1043        // within the same key, gather all instances for one draw call).
1044        let mut i = 0;
1045        while i < commands.len() {
1046            let key = compute_pipeline_key(&commands[i].sdf_expr, &commands[i].fill);
1047            let batch_start = i;
1048
1049            // Gather contiguous commands with the same pipeline key
1050            while i < commands.len()
1051                && compute_pipeline_key(&commands[i].sdf_expr, &commands[i].fill) == key
1052            {
1053                i += 1;
1054            }
1055
1056            let batch = &commands[batch_start..i];
1057            let pipeline = match self.pipelines.get(&key) {
1058                Some(p) => p,
1059                None => continue, // should not happen after ensure step
1060            };
1061
1062            // Build instance data for this batch
1063            let instances: Vec<SdfInstance> = batch
1064                .iter()
1065                .map(|cmd| {
1066                    let primary_color = primary_color_from_fill(&cmd.fill);
1067                    SdfInstance {
1068                        position: [cmd.x, cmd.y],
1069                        bounds: cmd.bounds,
1070                        rotation: cmd.rotation,
1071                        scale: cmd.scale,
1072                        opacity: cmd.opacity,
1073                        _pad: [0.0; 2],
1074                        color: primary_color,
1075                    }
1076                })
1077                .collect();
1078
1079            let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1080                label: Some("sdf_instance_buffer"),
1081                contents: bytemuck::cast_slice(&instances),
1082                usage: wgpu::BufferUsages::VERTEX,
1083            });
1084
1085            render_pass.set_pipeline(pipeline);
1086            render_pass.set_vertex_buffer(1, instance_buffer.slice(..));
1087            render_pass.draw_indexed(0..6, 0, 0..instances.len() as u32);
1088        }
1089    }
1090
1091    /// Number of cached pipelines (useful for diagnostics).
1092    pub fn pipeline_count(&self) -> usize {
1093        self.pipelines.len()
1094    }
1095
1096    /// Remove all cached pipelines (e.g. after a hot-reload).
1097    pub fn clear(&mut self) {
1098        self.pipelines.clear();
1099    }
1100}
1101
1102/// Extract the primary color from a fill for passing to the instance buffer.
1103/// This allows the shader to read a per-instance color even though fills
1104/// with fixed colors bake them into the shader source.
1105fn primary_color_from_fill(fill: &SdfFill) -> [f32; 4] {
1106    match fill {
1107        SdfFill::Solid { color } => *color,
1108        SdfFill::Outline { color, .. } => *color,
1109        SdfFill::SolidWithOutline { fill, .. } => *fill,
1110        SdfFill::Gradient { from, .. } => *from,
1111        SdfFill::Glow { color, .. } => *color,
1112        SdfFill::CosinePalette { a, .. } => [a[0], a[1], a[2], 1.0],
1113    }
1114}
1115
1116// ---------------------------------------------------------------------------
1117// Tests
1118// ---------------------------------------------------------------------------
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123
1124    #[test]
1125    fn sdf_instance_is_48_bytes() {
1126        // position (2*4=8) + bounds (4) + rotation (4) + scale (4) + opacity (4)
1127        // + pad (2*4=8) + color (4*4=16) = 48 bytes
1128        assert_eq!(std::mem::size_of::<SdfInstance>(), 48);
1129    }
1130
1131    #[test]
1132    fn sdf_quad_vertex_is_16_bytes() {
1133        // position (2*4=8) + uv (2*4=8) = 16 bytes
1134        assert_eq!(std::mem::size_of::<SdfQuadVertex>(), 16);
1135    }
1136
1137    #[test]
1138    fn generate_shader_contains_expression() {
1139        let shader = generate_sdf_shader(
1140            "sd_circle(p, 50.0)",
1141            &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] },
1142        );
1143        assert!(shader.contains("sd_circle(p, 50.0)"), "shader should contain the SDF expression");
1144        assert!(shader.contains("fn sd_circle"), "shader should contain primitive definitions");
1145        assert!(shader.contains("fn vs_main"), "shader should contain vertex entry point");
1146        assert!(shader.contains("fn fs_main"), "shader should contain fragment entry point");
1147    }
1148
1149    #[test]
1150    fn generate_shader_includes_all_primitives() {
1151        let shader = generate_sdf_shader(
1152            "sd_circle(p, 10.0)",
1153            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1154        );
1155        assert!(shader.contains("fn sd_circle"));
1156        assert!(shader.contains("fn sd_box"));
1157        assert!(shader.contains("fn sd_rounded_box"));
1158        assert!(shader.contains("fn sd_segment"));
1159        assert!(shader.contains("fn sd_capsule"));
1160        assert!(shader.contains("fn sd_ring"));
1161        assert!(shader.contains("fn sd_ellipse"));
1162        assert!(shader.contains("fn sd_hexagon"));
1163        assert!(shader.contains("fn sd_star5"));
1164        assert!(shader.contains("fn sd_cross"));
1165    }
1166
1167    #[test]
1168    fn generate_shader_includes_all_ops() {
1169        let shader = generate_sdf_shader(
1170            "op_union(sd_circle(p, 10.0), sd_box(p, vec2(5.0, 5.0)))",
1171            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1172        );
1173        assert!(shader.contains("fn op_union"));
1174        assert!(shader.contains("fn op_subtract"));
1175        assert!(shader.contains("fn op_intersect"));
1176        assert!(shader.contains("fn op_smooth_union"));
1177        assert!(shader.contains("fn op_smooth_subtract"));
1178        assert!(shader.contains("fn op_smooth_intersect"));
1179        assert!(shader.contains("fn op_round"));
1180        assert!(shader.contains("fn op_annular"));
1181        assert!(shader.contains("fn op_repeat"));
1182        assert!(shader.contains("fn op_translate"));
1183        assert!(shader.contains("fn op_rotate"));
1184        assert!(shader.contains("fn op_scale"));
1185    }
1186
1187    #[test]
1188    fn generate_shader_includes_camera_and_time_bindings() {
1189        let shader = generate_sdf_shader(
1190            "sd_circle(p, 1.0)",
1191            &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] },
1192        );
1193        assert!(shader.contains("struct CameraUniform"));
1194        assert!(shader.contains("@group(0) @binding(0)"));
1195        assert!(shader.contains("struct TimeUniform"));
1196        assert!(shader.contains("@group(1) @binding(0)"));
1197        assert!(shader.contains("var<uniform> camera: CameraUniform"));
1198        assert!(shader.contains("var<uniform> time_data: TimeUniform"));
1199    }
1200
1201    #[test]
1202    fn fill_solid_generates_smoothstep_aa() {
1203        let code = generate_fill_wgsl(&SdfFill::Solid {
1204            color: [1.0, 0.0, 0.0, 1.0],
1205        });
1206        assert!(code.contains("smoothstep"), "solid fill should use smoothstep for AA");
1207        assert!(code.contains("1.000000, 0.000000, 0.000000, 1.000000"));
1208    }
1209
1210    #[test]
1211    fn fill_outline_generates_abs_distance() {
1212        let code = generate_fill_wgsl(&SdfFill::Outline {
1213            color: [0.0, 1.0, 0.0, 1.0],
1214            thickness: 2.0,
1215        });
1216        assert!(code.contains("abs(d)"), "outline fill should use abs(d)");
1217        assert!(code.contains("2.000000"), "outline fill should include thickness");
1218    }
1219
1220    #[test]
1221    fn fill_glow_generates_exp_falloff() {
1222        let code = generate_fill_wgsl(&SdfFill::Glow {
1223            color: [0.0, 0.5, 1.0, 1.0],
1224            intensity: 3.0,
1225        });
1226        assert!(code.contains("glow_intensity"), "glow fill should use intensity parameter");
1227        assert!(code.contains("3.000000"), "glow fill should include intensity value");
1228    }
1229
1230    #[test]
1231    fn fill_gradient_generates_direction_mapping() {
1232        let code = generate_fill_wgsl(&SdfFill::Gradient {
1233            from: [1.0, 0.0, 0.0, 1.0],
1234            to: [0.0, 0.0, 1.0, 1.0],
1235            angle: 1.5708,
1236            scale: 1.0,
1237        });
1238        assert!(code.contains("grad_dir"), "gradient fill should compute direction");
1239        assert!(code.contains("mix("), "gradient fill should interpolate colors");
1240    }
1241
1242    #[test]
1243    fn fill_cosine_palette_generates_cosine_function() {
1244        let code = generate_fill_wgsl(&SdfFill::CosinePalette {
1245            a: [0.5, 0.5, 0.5],
1246            b: [0.5, 0.5, 0.5],
1247            c: [1.0, 1.0, 1.0],
1248            d: [0.0, 0.33, 0.67],
1249        });
1250        assert!(code.contains("cos("), "cosine palette should use cos()");
1251        assert!(code.contains("6.283185"), "cosine palette should use 2*pi");
1252    }
1253
1254    #[test]
1255    fn fill_solid_with_outline_generates_both_colors() {
1256        let code = generate_fill_wgsl(&SdfFill::SolidWithOutline {
1257            fill: [1.0, 0.0, 0.0, 1.0],
1258            outline: [1.0, 1.0, 1.0, 1.0],
1259            thickness: 1.5,
1260        });
1261        assert!(code.contains("fill_color"), "should have fill color");
1262        assert!(code.contains("outline_color"), "should have outline color");
1263        assert!(code.contains("1.500000"), "should include thickness");
1264    }
1265
1266    #[test]
1267    fn pipeline_key_differs_for_different_expressions() {
1268        let fill = SdfFill::Solid { color: [1.0; 4] };
1269        let k1 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1270        let k2 = compute_pipeline_key("sd_box(p, vec2(5.0, 5.0))", &fill);
1271        assert_ne!(k1, k2, "different expressions should produce different keys");
1272    }
1273
1274    #[test]
1275    fn pipeline_key_differs_for_different_fills() {
1276        let expr = "sd_circle(p, 10.0)";
1277        let k1 = compute_pipeline_key(expr, &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] });
1278        let k2 = compute_pipeline_key(expr, &SdfFill::Solid { color: [0.0, 1.0, 0.0, 1.0] });
1279        assert_ne!(k1, k2, "different fills should produce different keys");
1280    }
1281
1282    #[test]
1283    fn pipeline_key_is_deterministic() {
1284        let fill = SdfFill::Glow { color: [0.0, 0.5, 1.0, 1.0], intensity: 3.0 };
1285        let k1 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1286        let k2 = compute_pipeline_key("sd_circle(p, 10.0)", &fill);
1287        assert_eq!(k1, k2, "same input should produce the same key");
1288    }
1289
1290    #[test]
1291    fn compute_fill_hash_varies_by_discriminant() {
1292        let h1 = compute_fill_hash(&SdfFill::Solid { color: [1.0; 4] });
1293        let h2 = compute_fill_hash(&SdfFill::Glow { color: [1.0; 4], intensity: 1.0 });
1294        assert_ne!(h1, h2, "different fill types should hash differently");
1295    }
1296
1297    #[test]
1298    fn primary_color_extraction() {
1299        assert_eq!(
1300            primary_color_from_fill(&SdfFill::Solid { color: [0.1, 0.2, 0.3, 0.4] }),
1301            [0.1, 0.2, 0.3, 0.4],
1302        );
1303        assert_eq!(
1304            primary_color_from_fill(&SdfFill::Outline { color: [0.5, 0.6, 0.7, 0.8], thickness: 1.0 }),
1305            [0.5, 0.6, 0.7, 0.8],
1306        );
1307        assert_eq!(
1308            primary_color_from_fill(&SdfFill::SolidWithOutline {
1309                fill: [0.1, 0.2, 0.3, 0.4],
1310                outline: [0.9, 0.9, 0.9, 1.0],
1311                thickness: 2.0,
1312            }),
1313            [0.1, 0.2, 0.3, 0.4],
1314        );
1315        assert_eq!(
1316            primary_color_from_fill(&SdfFill::Gradient {
1317                from: [1.0, 0.0, 0.0, 1.0],
1318                to: [0.0, 0.0, 1.0, 1.0],
1319                angle: 0.0,
1320                scale: 1.0,
1321            }),
1322            [1.0, 0.0, 0.0, 1.0],
1323        );
1324        assert_eq!(
1325            primary_color_from_fill(&SdfFill::Glow { color: [0.0, 1.0, 0.0, 1.0], intensity: 5.0 }),
1326            [0.0, 1.0, 0.0, 1.0],
1327        );
1328        let cosine = primary_color_from_fill(&SdfFill::CosinePalette {
1329            a: [0.5, 0.5, 0.5],
1330            b: [0.5, 0.5, 0.5],
1331            c: [1.0, 1.0, 1.0],
1332            d: [0.0, 0.33, 0.67],
1333        });
1334        assert_eq!(cosine, [0.5, 0.5, 0.5, 1.0]);
1335    }
1336
1337    #[test]
1338    fn generated_shader_has_valid_structure() {
1339        // Verify the shader has proper WGSL structure (all sections present)
1340        let shader = generate_sdf_shader(
1341            "op_smooth_union(sd_circle(p, 30.0), sd_box(p, vec2<f32>(20.0, 20.0)), 5.0)",
1342            &SdfFill::SolidWithOutline {
1343                fill: [0.2, 0.4, 0.8, 1.0],
1344                outline: [1.0, 1.0, 1.0, 1.0],
1345                thickness: 2.0,
1346            },
1347        );
1348
1349        // Check structure ordering: primitives -> bindings -> vertex -> fragment
1350        let prim_pos = shader.find("fn sd_circle").unwrap();
1351        let binding_pos = shader.find("struct CameraUniform").unwrap();
1352        let vs_pos = shader.find("fn vs_main").unwrap();
1353        let fs_pos = shader.find("fn fs_main").unwrap();
1354
1355        assert!(prim_pos < binding_pos, "primitives should come before bindings");
1356        assert!(binding_pos < vs_pos, "bindings should come before vertex shader");
1357        assert!(vs_pos < fs_pos, "vertex shader should come before fragment shader");
1358    }
1359
1360    #[test]
1361    fn sdf_quad_vertices_are_centered() {
1362        // The quad should span from -1 to 1 in both axes
1363        assert_eq!(SDF_QUAD_VERTICES[0].position, [-1.0, -1.0]);
1364        assert_eq!(SDF_QUAD_VERTICES[1].position, [1.0, -1.0]);
1365        assert_eq!(SDF_QUAD_VERTICES[2].position, [1.0, 1.0]);
1366        assert_eq!(SDF_QUAD_VERTICES[3].position, [-1.0, 1.0]);
1367    }
1368
1369    #[test]
1370    fn sdf_quad_indices_form_two_triangles() {
1371        assert_eq!(SDF_QUAD_INDICES.len(), 6);
1372        assert_eq!(SDF_QUAD_INDICES, &[0, 1, 2, 0, 2, 3]);
1373    }
1374
1375    // -----------------------------------------------------------------------
1376    // WGSL validation tests (using naga parser)
1377    //
1378    // These tests verify that the generated WGSL shaders are syntactically
1379    // and semantically valid by parsing them through naga's WGSL front-end.
1380    // This catches type mismatches, undefined variables, and syntax errors
1381    // without needing a GPU device.
1382    // -----------------------------------------------------------------------
1383
1384    /// Parse the given WGSL source through naga and return Ok if valid.
1385    fn validate_wgsl(source: &str) -> Result<(), String> {
1386        match naga::front::wgsl::parse_str(source) {
1387            Ok(_module) => Ok(()),
1388            Err(e) => Err(format!("{e:?}")),
1389        }
1390    }
1391
1392    // === Primitive compilation tests ===
1393
1394    #[test]
1395    fn wgsl_circle_compiles() {
1396        let shader = generate_sdf_shader(
1397            "sd_circle(p, 50.0)",
1398            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1399        );
1400        let result = validate_wgsl(&shader);
1401        assert!(result.is_ok(), "Circle shader failed: {}", result.unwrap_err());
1402    }
1403
1404    #[test]
1405    fn wgsl_box_compiles() {
1406        let shader = generate_sdf_shader(
1407            "sd_box(p, vec2<f32>(20.0, 10.0))",
1408            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1409        );
1410        let result = validate_wgsl(&shader);
1411        assert!(result.is_ok(), "Box shader failed: {}", result.unwrap_err());
1412    }
1413
1414    #[test]
1415    fn wgsl_rounded_box_compiles() {
1416        let shader = generate_sdf_shader(
1417            "sd_rounded_box(p, vec2<f32>(20.0, 10.0), vec4<f32>(3.0, 3.0, 3.0, 3.0))",
1418            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1419        );
1420        let result = validate_wgsl(&shader);
1421        assert!(result.is_ok(), "Rounded box shader failed: {}", result.unwrap_err());
1422    }
1423
1424    #[test]
1425    fn wgsl_all_primitives_compile() {
1426        // Test every SDF primitive in the inline WGSL library.
1427        // Each expression must be a valid WGSL f32 expression given `p: vec2<f32>`.
1428        let primitives: &[(&str, &str)] = &[
1429            ("sd_circle", "sd_circle(p, 50.0)"),
1430            ("sd_box", "sd_box(p, vec2<f32>(20.0, 10.0))"),
1431            ("sd_rounded_box", "sd_rounded_box(p, vec2<f32>(20.0, 10.0), vec4<f32>(3.0, 3.0, 3.0, 3.0))"),
1432            ("sd_segment", "sd_segment(p, vec2<f32>(0.0, 0.0), vec2<f32>(20.0, 10.0))"),
1433            ("sd_capsule", "sd_capsule(p, vec2<f32>(-10.0, 0.0), vec2<f32>(10.0, 0.0), 5.0)"),
1434            ("sd_equilateral_triangle", "sd_equilateral_triangle(p, 20.0)"),
1435            ("sd_ring", "sd_ring(p, 20.0, 3.0)"),
1436            ("sd_ellipse", "sd_ellipse(p, vec2<f32>(30.0, 15.0))"),
1437            ("sd_hexagon", "sd_hexagon(p, 20.0)"),
1438            ("sd_star5", "sd_star5(p, 15.0, 0.4)"),
1439            ("sd_cross", "sd_cross(p, vec2<f32>(20.0, 5.0), 2.0)"),
1440        ];
1441        for (name, expr) in primitives {
1442            let shader = generate_sdf_shader(expr, &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] });
1443            let result = validate_wgsl(&shader);
1444            assert!(result.is_ok(), "Primitive '{name}' failed to compile.\nExpression: {expr}\nError: {}", result.unwrap_err());
1445        }
1446    }
1447
1448    // === Composition operator tests ===
1449
1450    #[test]
1451    fn wgsl_composition_operators_compile() {
1452        let exprs: &[(&str, &str)] = &[
1453            ("op_union", "op_union(sd_circle(p, 20.0), sd_box(p, vec2<f32>(15.0, 15.0)))"),
1454            ("op_subtract", "op_subtract(sd_circle(p, 20.0), sd_circle(p - vec2<f32>(10.0, 0.0), 15.0))"),
1455            ("op_intersect", "op_intersect(sd_circle(p, 20.0), sd_box(p, vec2<f32>(15.0, 15.0)))"),
1456            ("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)"),
1457            ("op_smooth_subtract", "op_smooth_subtract(sd_circle(p, 20.0), sd_circle(p - vec2<f32>(5.0, 0.0), 15.0), 3.0)"),
1458            ("op_smooth_intersect", "op_smooth_intersect(sd_circle(p, 25.0), sd_box(p, vec2<f32>(15.0, 15.0)), 4.0)"),
1459        ];
1460        for (name, expr) in exprs {
1461            let shader = generate_sdf_shader(expr, &SdfFill::Solid { color: [1.0, 0.0, 0.0, 1.0] });
1462            let result = validate_wgsl(&shader);
1463            assert!(result.is_ok(), "Composition '{name}' failed.\nExpression: {expr}\nError: {}", result.unwrap_err());
1464        }
1465    }
1466
1467    // === Fill type compilation tests ===
1468
1469    #[test]
1470    fn wgsl_all_fill_types_compile() {
1471        let expr = "sd_circle(p, 30.0)";
1472        let fills: Vec<(&str, SdfFill)> = vec![
1473            ("Solid", SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] }),
1474            ("Outline", SdfFill::Outline { color: [1.0, 0.0, 0.0, 1.0], thickness: 2.0 }),
1475            ("SolidWithOutline", SdfFill::SolidWithOutline {
1476                fill: [0.0, 0.0, 1.0, 1.0],
1477                outline: [1.0, 1.0, 1.0, 1.0],
1478                thickness: 1.5,
1479            }),
1480            ("Gradient", SdfFill::Gradient {
1481                from: [1.0, 0.0, 0.0, 1.0],
1482                to: [0.0, 0.0, 1.0, 1.0],
1483                angle: 1.5707,
1484                scale: 1.0,
1485            }),
1486            ("Glow", SdfFill::Glow { color: [0.0, 1.0, 0.5, 1.0], intensity: 0.8 }),
1487            ("CosinePalette", SdfFill::CosinePalette {
1488                a: [0.5, 0.5, 0.5],
1489                b: [0.5, 0.5, 0.5],
1490                c: [1.0, 1.0, 1.0],
1491                d: [0.0, 0.33, 0.67],
1492            }),
1493        ];
1494        for (name, fill) in &fills {
1495            let shader = generate_sdf_shader(expr, fill);
1496            let result = validate_wgsl(&shader);
1497            assert!(result.is_ok(), "Fill '{name}' failed to compile.\nError: {}", result.unwrap_err());
1498        }
1499    }
1500
1501    // === Complex expression tests ===
1502
1503    #[test]
1504    fn wgsl_deeply_nested_tree_compiles() {
1505        // A tree-like shape: trunk (rounded box) + layered foliage (smooth-unioned circles)
1506        let expr = concat!(
1507            "op_smooth_union(",
1508                "op_smooth_union(",
1509                    "op_smooth_union(",
1510                        "sd_rounded_box(p, vec2<f32>(8.0, 30.0), vec4<f32>(2.0, 2.0, 2.0, 2.0)), ",
1511                        "sd_circle(p - vec2<f32>(0.0, 25.0), 18.0), ",
1512                    "4.0), ",
1513                    "sd_circle(p - vec2<f32>(-10.0, 20.0), 14.0), ",
1514                "4.0), ",
1515                "sd_circle(p - vec2<f32>(10.0, 20.0), 14.0), ",
1516            "4.0)",
1517        );
1518        let shader = generate_sdf_shader(expr, &SdfFill::Gradient {
1519            from: [0.35, 0.23, 0.1, 1.0],
1520            to: [0.18, 0.54, 0.3, 1.0],
1521            angle: 1.5707,
1522            scale: 1.0,
1523        });
1524        let result = validate_wgsl(&shader);
1525        assert!(result.is_ok(), "Nested tree failed: {}", result.unwrap_err());
1526    }
1527
1528    #[test]
1529    fn wgsl_subtract_creates_crescent() {
1530        // Classic crescent moon via subtraction of two circles
1531        let expr = "op_subtract(sd_circle(p, 25.0), sd_circle(p - vec2<f32>(10.0, 5.0), 22.0))";
1532        let shader = generate_sdf_shader(expr, &SdfFill::Glow {
1533            color: [0.9, 0.9, 0.6, 1.0],
1534            intensity: 0.5,
1535        });
1536        let result = validate_wgsl(&shader);
1537        assert!(result.is_ok(), "Crescent moon failed: {}", result.unwrap_err());
1538    }
1539
1540    // === Domain transform tests ===
1541
1542    #[test]
1543    fn wgsl_time_uniform_in_expression() {
1544        // The time uniform is available in the fragment shader as time_data.time
1545        let shader = generate_sdf_shader(
1546            "sd_circle(p, 20.0 + sin(time_data.time) * 5.0)",
1547            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1548        );
1549        let result = validate_wgsl(&shader);
1550        assert!(result.is_ok(), "Time uniform shader failed: {}", result.unwrap_err());
1551    }
1552
1553    #[test]
1554    fn wgsl_repeat_domain_compiles() {
1555        // Infinite repetition via op_repeat domain transform
1556        let shader = generate_sdf_shader(
1557            "sd_circle(op_repeat(p, vec2<f32>(40.0, 40.0)), 10.0)",
1558            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1559        );
1560        let result = validate_wgsl(&shader);
1561        assert!(result.is_ok(), "Repeat domain failed: {}", result.unwrap_err());
1562    }
1563
1564    #[test]
1565    fn wgsl_rotate_transform_compiles() {
1566        // Rotate a box by 45 degrees using op_rotate
1567        let shader = generate_sdf_shader(
1568            "sd_box(op_rotate(p, 0.785), vec2<f32>(20.0, 10.0))",
1569            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1570        );
1571        let result = validate_wgsl(&shader);
1572        assert!(result.is_ok(), "Rotate transform failed: {}", result.unwrap_err());
1573    }
1574
1575    #[test]
1576    fn wgsl_translate_transform_compiles() {
1577        // Translate a circle using op_translate
1578        let shader = generate_sdf_shader(
1579            "sd_circle(op_translate(p, vec2<f32>(15.0, 10.0)), 12.0)",
1580            &SdfFill::Outline { color: [0.0, 1.0, 0.0, 1.0], thickness: 2.0 },
1581        );
1582        let result = validate_wgsl(&shader);
1583        assert!(result.is_ok(), "Translate transform failed: {}", result.unwrap_err());
1584    }
1585
1586    #[test]
1587    fn wgsl_scale_transform_compiles() {
1588        // Scale a star using op_scale
1589        let shader = generate_sdf_shader(
1590            "sd_star5(op_scale(p, 2.0), 15.0, 0.4) * 2.0",
1591            &SdfFill::Solid { color: [1.0, 0.8, 0.0, 1.0] },
1592        );
1593        let result = validate_wgsl(&shader);
1594        assert!(result.is_ok(), "Scale transform failed: {}", result.unwrap_err());
1595    }
1596
1597    // === Modifier tests ===
1598
1599    #[test]
1600    fn wgsl_round_modifier_compiles() {
1601        let shader = generate_sdf_shader(
1602            "op_round(sd_box(p, vec2<f32>(20.0, 10.0)), 3.0)",
1603            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1604        );
1605        let result = validate_wgsl(&shader);
1606        assert!(result.is_ok(), "Round modifier failed: {}", result.unwrap_err());
1607    }
1608
1609    #[test]
1610    fn wgsl_annular_modifier_compiles() {
1611        // op_annular hollows out a shape (like op_onion in the standalone WGSL)
1612        let shader = generate_sdf_shader(
1613            "op_annular(sd_circle(p, 20.0), 2.0)",
1614            &SdfFill::Solid { color: [1.0, 1.0, 1.0, 1.0] },
1615        );
1616        let result = validate_wgsl(&shader);
1617        assert!(result.is_ok(), "Annular modifier failed: {}", result.unwrap_err());
1618    }
1619
1620    // === Combined transforms + compositions ===
1621
1622    #[test]
1623    fn wgsl_complex_scene_compiles() {
1624        // A complex scene: repeated circles unioned with a rotated cross,
1625        // all with a gradient fill. Tests multiple features together.
1626        let expr = concat!(
1627            "op_union(",
1628                "sd_circle(op_repeat(p, vec2<f32>(50.0, 50.0)), 8.0), ",
1629                "sd_cross(op_rotate(p, 0.785), vec2<f32>(30.0, 6.0), 2.0)",
1630            ")",
1631        );
1632        let shader = generate_sdf_shader(expr, &SdfFill::CosinePalette {
1633            a: [0.5, 0.5, 0.5],
1634            b: [0.5, 0.5, 0.5],
1635            c: [1.0, 1.0, 1.0],
1636            d: [0.0, 0.33, 0.67],
1637        });
1638        let result = validate_wgsl(&shader);
1639        assert!(result.is_ok(), "Complex scene failed: {}", result.unwrap_err());
1640    }
1641
1642    #[test]
1643    fn wgsl_animated_pulsing_ring() {
1644        // Animated ring that pulses with time
1645        let shader = generate_sdf_shader(
1646            "sd_ring(p, 25.0 + sin(time_data.time * 2.0) * 5.0, 3.0)",
1647            &SdfFill::Glow { color: [0.2, 0.8, 1.0, 1.0], intensity: 1.5 },
1648        );
1649        let result = validate_wgsl(&shader);
1650        assert!(result.is_ok(), "Animated ring failed: {}", result.unwrap_err());
1651    }
1652
1653    #[test]
1654    fn wgsl_three_way_smooth_union() {
1655        // Three shapes blended together
1656        let expr = concat!(
1657            "op_smooth_union(",
1658                "op_smooth_union(",
1659                    "sd_circle(p - vec2<f32>(-15.0, 0.0), 12.0), ",
1660                    "sd_circle(p - vec2<f32>(15.0, 0.0), 12.0), ",
1661                "6.0), ",
1662                "sd_circle(p - vec2<f32>(0.0, 15.0), 12.0), ",
1663            "6.0)",
1664        );
1665        let shader = generate_sdf_shader(expr, &SdfFill::SolidWithOutline {
1666            fill: [0.3, 0.3, 0.8, 1.0],
1667            outline: [1.0, 1.0, 1.0, 1.0],
1668            thickness: 1.0,
1669        });
1670        let result = validate_wgsl(&shader);
1671        assert!(result.is_ok(), "Three-way smooth union failed: {}", result.unwrap_err());
1672    }
1673}