Skip to main content

cvkg_render_gpu/
material.rs

1//! Material graph -- composable shader generation.
2//!
3//! Replaces the mode-based `if/else` dispatch in shapes.wgsl with
4//! composable material graphs that compile to WGSL at startup.
5//!
6//! # Architecture
7//!
8//! - `MaterialGraph` is a DAG of `MaterialNode`s connected by typed sockets.
9//! - `MaterialCompiler` topologically sorts nodes and emits a WGSL fragment function.
10//! - Built-in materials (rounded rect, glass, text, etc.) are pre-compiled at renderer init.
11//! - User materials compile on first use and are cached by hash.
12
13use std::collections::HashMap;
14
15/// A socket type on a material node.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum MaterialSocket {
18    Color, // vec4<f32>
19    Float, // f32
20    Vec2,  // vec2<f32>
21    Vec3,  // vec3<f32>
22    Mask,  // f32 (0..1 coverage)
23}
24
25/// An operation node in the material graph.
26#[derive(Debug, Clone)]
27pub enum MaterialOp {
28    /// Input: base color from vertex.
29    /// Output: Color
30    InputColor,
31
32    /// Output: constant color from uniform.
33    /// Parameters: rgba
34    /// Output: Color
35    ConstantColor {
36        r: f32,
37        g: f32,
38        b: f32,
39        a: f32,
40    },
41
42    /// Input: UV from vertex.
43    /// Output: sample result Color
44    SampleTexture {
45        tex_index: u32,
46    },
47
48    /// Premultiplied alpha blend (for font atlas).
49    /// Inputs: color (Color), alpha (Float from texture)
50    /// Output: Color
51    PremultipliedBlend,
52
53    /// SDF rounded rectangle mask.
54    /// Inputs: none (reads vertex logical, size, radius)
55    /// Output: Mask
56    SDFRoundRect,
57
58    /// SDF ellipse mask.
59    /// Output: Mask
60    SDFEllipse,
61
62    /// Linear gradient between two colors.
63    /// Input: t (Float, typically UV-based)
64    /// Output: Color
65    LinearGradient {
66        start: [f32; 4],
67        end: [f32; 4],
68    },
69
70    /// Radial gradient.
71    /// Input: dist (Float)
72    /// Output: Color
73    RadialGradient {
74        start: [f32; 4],
75        end: [f32; 4],
76    },
77
78    /// Neon glow effect.
79    /// Input: dist (Float), color (Color)
80    /// Output: Color
81    NeonGlow {
82        radius: f32,
83        intensity: f32,
84    },
85
86    /// Glass fresnel refraction.
87    /// Inputs: uv (Vec2), blur_mip (Float)
88    /// Output: Color
89    GlassBlur,
90
91    /// Layer two inputs with a blend mode.
92    /// Inputs: bottom (Color), top (Color), opacity (Float)
93    /// Output: Color
94    LayerBlend {
95        mode: BlendMode,
96    },
97
98    /// PBR lighting.
99    /// Input: normal (Vec3), metallic (Float), roughness (Float), opacity (Float)
100    /// Output: Color
101    PBRLighting,
102
103    /// Drop shadow.
104    /// Inputs: uv (Vec2), size (Vec2), radius (Float)
105    /// Output: Mask
106    DropShadow,
107
108    /// 9-slice UV remapping.
109    /// Input: uv (Vec2)
110    /// Output: Vec2
111    NineSlice,
112
113    /// Heatmap palette lookup.
114    /// Input: value (Float)
115    /// Output: Color
116    Heatmap,
117
118    /// Raymarched SDF shape.
119    /// Output: Color
120    Raymarch {
121        shape: RaymarchShape,
122    },
123
124    Lightning,
125    RuneGlow,
126    RaymarchReflections,
127    Stroke,
128    DashedStroke,
129}
130
131#[derive(Debug, Clone, Copy)]
132pub enum BlendMode {
133    Add,
134    Screen,
135    Multiply,
136    Overlay,
137}
138
139#[derive(Debug, Clone, Copy)]
140pub enum RaymarchShape {
141    Sphere,
142    Box,
143}
144
145/// Connection between two nodes.
146#[derive(Debug, Clone)]
147pub struct MaterialEdge {
148    pub from_node: u32,
149    pub from_socket: MaterialSocket,
150    pub to_node: u32,
151    pub to_socket: MaterialSocket,
152}
153
154/// Index into the material graph's node list.
155pub type MatNodeId = u32;
156
157/// A directed acyclic graph of material operations.
158#[derive(Debug, Clone)]
159pub struct MaterialGraph {
160    pub nodes: Vec<(MatNodeId, MaterialOp)>,
161    pub edges: Vec<MaterialEdge>,
162    pub output: Option<MatNodeId>,
163}
164
165impl MaterialGraph {
166    pub fn new() -> Self {
167        Self {
168            nodes: Vec::new(),
169            edges: Vec::new(),
170            output: None,
171        }
172    }
173
174    pub fn add_node(&mut self, op: MaterialOp) -> MatNodeId {
175        let id = self.nodes.len() as MatNodeId;
176        self.nodes.push((id, op));
177        id
178    }
179
180    pub fn connect(
181        &mut self,
182        from: MatNodeId,
183        from_socket: MaterialSocket,
184        to: MatNodeId,
185        to_socket: MaterialSocket,
186    ) {
187        self.edges.push(MaterialEdge {
188            from_node: from,
189            from_socket,
190            to_node: to,
191            to_socket,
192        });
193    }
194
195    pub fn set_output(&mut self, node: MatNodeId) {
196        self.output = Some(node);
197    }
198
199    /// Validate the graph using default (unrestricted) config.
200    pub fn validate(&self) -> Result<(), MaterialError> {
201        self.validate_with_config(&MaterialValidationConfig::default())
202    }
203
204    /// Validate the graph with strict limitations (e.g. for AI-generated graphs).
205    pub fn validate_with_config(
206        &self,
207        config: &MaterialValidationConfig,
208    ) -> Result<(), MaterialError> {
209        if self.output.is_none() {
210            return Err(MaterialError::NoOutput);
211        }
212        if self.nodes.len() > config.max_nodes {
213            return Err(MaterialError::TooManyNodes(
214                self.nodes.len(),
215                config.max_nodes,
216            ));
217        }
218        // P1-4 fix: also bound the edge count. Without this, a graph
219        // with 1024 nodes but 100K edges (very dense) could cause
220        // memory pressure and slow validation. The check is O(1).
221        if self.edges.len() > config.max_edges {
222            return Err(MaterialError::TooManyEdges(
223                self.edges.len(),
224                config.max_edges,
225            ));
226        }
227        // Cycle detection via DFS
228        let mut visited = vec![false; self.nodes.len()];
229        let mut in_stack = vec![false; self.nodes.len()];
230
231        for &(id, _) in &self.nodes {
232            if !visited[id as usize] {
233                self.dfs_check(id, &mut visited, &mut in_stack)?;
234            }
235        }
236
237        // P2-10: Reachability check -- ensure every node is reachable from the output.
238        // A node that is connected via an edge but whose input chain never reaches
239        // the output would produce incomplete WGSL.
240        if let Some(output_id) = self.output {
241            let mut reachable = vec![false; self.nodes.len()];
242            self.dfs_reachable(output_id, &mut reachable);
243            for &(id, _) in &self.nodes {
244                if !reachable[id as usize] {
245                    return Err(MaterialError::UnreachableNode(id));
246                }
247            }
248        }
249        Ok(())
250    }
251
252    fn dfs_check(
253        &self,
254        node: MatNodeId,
255        visited: &mut [bool],
256        in_stack: &mut [bool],
257    ) -> Result<(), MaterialError> {
258        let idx = node as usize;
259        if in_stack[idx] {
260            return Err(MaterialError::Cycle);
261        }
262        if visited[idx] {
263            return Ok(());
264        }
265        visited[idx] = true;
266        in_stack[idx] = true;
267
268        // Find all edges where this node is the consumer (to_node)
269        for edge in &self.edges {
270            if edge.to_node == node {
271                self.dfs_check(edge.from_node, visited, in_stack)?;
272            }
273        }
274
275        in_stack[idx] = false;
276        Ok(())
277    }
278
279    /// P2-10: DFS backwards from the output node to find all reachable nodes.
280    /// Edges go from producer (from_node) to consumer (to_node), so we walk
281    /// backwards from to_node to from_node.
282    fn dfs_reachable(&self, node: MatNodeId, reachable: &mut [bool]) {
283        let idx = node as usize;
284        if reachable[idx] {
285            return;
286        }
287        reachable[idx] = true;
288        // Find all edges where this node is the consumer (to_node)
289        for edge in &self.edges {
290            if edge.to_node == node {
291                self.dfs_reachable(edge.from_node, reachable);
292            }
293        }
294    }
295}
296
297impl Default for MaterialGraph {
298    fn default() -> Self {
299        Self::new()
300    }
301}
302
303#[derive(Debug)]
304pub enum MaterialError {
305    NoOutput,
306    Cycle,
307    DisconnectedInput {
308        node: MatNodeId,
309        socket: MaterialSocket,
310    },
311    TypeMismatch {
312        from: MaterialSocket,
313        to: MaterialSocket,
314    },
315    CompileError(String),
316    TooManyNodes(usize, usize),
317    UnsupportedNodeType(String),
318    /// P1-4 fix: graph has more edges than the configured limit.
319    TooManyEdges(usize, usize),
320    /// P2-10: node is not reachable from the output (dead subgraph).
321    UnreachableNode(MatNodeId),
322}
323
324pub struct MaterialValidationConfig {
325    pub max_nodes: usize,
326    /// P1-4 fix: max number of edges in the material graph. Limits
327    /// the complexity of the graph and prevents memory pressure from
328    /// graphs with very high node-to-edge ratios. The default of
329    /// 4096 corresponds to a max_nodes of 1024 with an average
330    /// degree of 4, which is a reasonable upper bound for typical
331    /// authoring tools.
332    pub max_edges: usize,
333}
334
335impl Default for MaterialValidationConfig {
336    fn default() -> Self {
337        // P1-4: default to 4 edges per node as a reasonable upper
338        // bound for typical material graphs. AI-generated or
339        // untrusted graphs should use a stricter config (e.g.,
340        // 512 nodes, 1024 edges) via validate_with_config.
341        Self { max_nodes: 1024, max_edges: 4096 }
342    }
343}
344
345impl std::error::Error for MaterialError {}
346
347impl std::fmt::Display for MaterialError {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        match self {
350            Self::NoOutput => write!(f, "material graph has no output node"),
351            Self::Cycle => write!(f, "material graph contains a cycle"),
352            Self::DisconnectedInput { node, socket } => {
353                write!(f, "node {:?} missing input {:?}", node, socket)
354            }
355            Self::TypeMismatch { from, to } => {
356                write!(f, "type mismatch: {:?} -> {:?}", from, to)
357            }
358            Self::CompileError(msg) => write!(f, "WGSL compilation error: {}", msg),
359            Self::TooManyNodes(count, max) => write!(f, "too many nodes: {} (max {})", count, max),
360            Self::UnsupportedNodeType(kind) => write!(f, "unsupported node type: {}", kind),
361            Self::TooManyEdges(count, max) => write!(f, "too many edges: {} (max {})", count, max),
362            Self::UnreachableNode(id) => write!(f, "unreachable node: {:?}", id),
363        }
364    }
365}
366
367/// Compiled material -- a WGSL function that can be included in the main shader.
368#[derive(Debug, Clone)]
369pub struct CompiledMaterial {
370    /// The WGSL function body (everything between the `{` and `}` of the fragment function).
371    pub wgsl_fn: String,
372    /// The function name (unique per material).
373    pub fn_name: String,
374}
375
376impl CompiledMaterial {
377    pub fn hash_code(&self) -> u64 {
378        use std::hash::{Hash, Hasher};
379        let mut hasher = std::collections::hash_map::DefaultHasher::new();
380        self.wgsl_fn.hash(&mut hasher);
381        hasher.finish()
382    }
383}
384
385/// Compiles MaterialGraph → WGSL fragment function.
386pub struct MaterialCompiler;
387
388impl MaterialCompiler {
389    /// Compile a material graph into a WGSL function.
390    ///
391    /// The emitted function has the signature:
392    ///
393    /// ```text
394    /// fn material_<id>(in: VertexOutput, col: vec4<f32>) -> vec4<f32>
395    /// ```
396    ///
397    /// where `in` provides UV/position/size/etc. from the vertex output,
398    /// and `col` is the base vertex color.
399    pub fn compile(graph: &MaterialGraph) -> Result<CompiledMaterial, MaterialError> {
400        graph.validate()?;
401
402        // Topological sort
403        let order = Self::topo_sort(graph)?;
404
405        // Generate WGSL for each node in order
406        let mut lines: Vec<String> = Vec::new();
407        let mut var_names: HashMap<(MatNodeId, MaterialSocket), String> = HashMap::new();
408        let mut next_var = 0;
409
410        let mut mk_var = |prefix: &str| -> String {
411            let v = format!("{}_{}", prefix, next_var);
412            next_var += 1;
413            v
414        };
415
416        for &node_id in &order {
417            let (_, op) = &graph.nodes[node_id as usize];
418            let result_var = mk_var("v");
419
420            let expr = match op {
421                MaterialOp::InputColor => {
422                    "col".to_string()
423                }
424                MaterialOp::ConstantColor { r, g, b, a } => {
425                    format!("vec4<f32>({:.6}, {:.6}, {:.6}, {:.6})", r, g, b, a)
426                }
427                MaterialOp::SampleTexture { tex_index } => {
428                    format!(
429                        "textureSample(t_diffuse[{}u], s_diffuse, in.uv)",
430                        tex_index
431                    )
432                }
433                MaterialOp::PremultipliedBlend => {
434                    let color_var = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
435                        .unwrap_or_else(|| "col".to_string());
436                    // Read alpha from a separate texture sample -- for fonts this is the single channel
437                    let alpha_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
438                        .unwrap_or_else(|| "1.0".to_string());
439                    format!(
440                        "vec4<f32>(({}).rgb, ({}).a * ({}))",
441                        color_var, color_var, alpha_var
442                    )
443                }
444                MaterialOp::SDFRoundRect => {
445                    let half = "in.size * 0.5";
446                    format!(
447                        r#"
448    let _d = sd_round_rect(in.logical - {0}, {0} - in.radius, in.radius);
449    let _aa = fwidth(_d);
450    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
451                        half
452                    ).trim().to_string()
453                }
454                MaterialOp::SDFEllipse => {
455                    let half = "in.size * 0.5";
456                    format!(
457                        r#"
458    let _sh = max({0}, vec2<f32>(0.001));
459    let _d = length((in.logical - {0}) / _sh) - 1.0;
460    let _aa = fwidth(_d);
461    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(0.0, _aa, _d)));"#,
462                        half
463                    ).trim().to_string()
464                }
465                MaterialOp::LinearGradient { start, end } => {
466                    let t_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
467                        .unwrap_or_else(|| "in.uv.x".to_string());
468                    format!(
469                        "mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp({}, 0.0, 1.0))",
470                        start[0], start[1], start[2], start[3],
471                        end[0], end[1], end[2], end[3],
472                        t_var
473                    )
474                }
475                MaterialOp::RadialGradient { start, end } => {
476                    format!(
477                        r#"
478    let _dist = length(in.uv - 0.5) * 2.0;
479    __RESULT__ = mix(vec4<f32>({:.6},{:.6},{:.6},{:.6}), vec4<f32>({:.6},{:.6},{:.6},{:.6}), clamp(_dist, 0.0, 1.0));"#,
480                        start[0], start[1], start[2], start[3],
481                        end[0], end[1], end[2], end[3],
482                    ).trim().to_string()
483                }
484                MaterialOp::NeonGlow { radius, intensity } => {
485                    let dist_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
486                        .unwrap_or_else(|| "length(in.logical - in.size * 0.5) / max(in.size.x, in.size.y)".to_string());
487                    format!(
488                        "vec4<f32>(col.rgb * exp(-{} * {:.6}), col.a)",
489                        dist_var, intensity / radius.max(0.001)
490                    )
491                }
492                MaterialOp::GlassBlur => {
493                    r#"
494    let uv = clamp(in.uv, vec2<f32>(0.0), vec2<f32>(1.0));
495    let local = in.logical / in.size;
496    let centered = local - vec2<f32>(0.5, 0.5);
497    let lens_dir = normalize(centered + vec2<f32>(1e-5, 1e-5));
498    let lens_dist = length(centered);
499    let fresnel = pow(lens_dist * 1.8, 2.5);
500    let lens = lens_dir * lens_dist * 0.08;
501    let blur_mip = theme.glass_blur_strength;
502    let env_base = textureSampleLevel(t_env, s_env, uv, blur_mip).rgb;
503    let brightness = dot(env_base, vec3<f32>(0.299, 0.587, 0.114));
504    var distortion = lens * 1.2;
505    distortion *= (1.0 + brightness * 0.7);
506    distortion *= 2.0;
507    let ab_offset = distortion * 0.04;
508    let r_sample = textureSampleLevel(t_env, s_env, uv + distortion + ab_offset * 1.2, blur_mip).r;
509    let g_sample = textureSampleLevel(t_env, s_env, uv + distortion, blur_mip).g;
510    let b_sample = textureSampleLevel(t_env, s_env, uv + distortion - ab_offset * 1.2, blur_mip).b;
511    let refracted = vec3<f32>(r_sample, g_sample, b_sample);
512    let tint = vec3<f32>(0.85, 0.9, 1.0);
513    var final_rgb = refracted * tint;
514    final_rgb += (brightness * 0.2) * (0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1);
515    let half_size = in.size * 0.5;
516    let p_sdf = in.logical - half_size;
517    let q_sdf = abs(p_sdf) - (half_size - in.radius);
518    let d_sdf = length(max(q_sdf, vec2(0.0))) + min(max(q_sdf.x, q_sdf.y), 0.0) - in.radius;
519    let d_norm = clamp(-d_sdf / 20.0, 0.0, 1.0);
520    let flicker = 0.9 + vnoise(uv * 20.0 + scene.time * 3.0) * 0.1;
521    final_rgb += smoothstep(1.0, 0.96, d_norm) * 0.25 * flicker * vec3<f32>(0.7, 1.0, 1.3);
522    final_rgb -= smoothstep(0.96, 0.88, d_norm) * 0.15;
523    let light_dir_h = normalize(vec2<f32>(-0.4, -0.8));
524    let l = dot(uv, light_dir_h);
525    final_rgb += smoothstep(0.45, 0.55, l) * 0.12;
526    __RESULT__ = vec4<f32>(final_rgb, 0.02 + fresnel * 0.15) * (1.0 - smoothstep(-length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), d_sdf));"#.trim().to_string()
527                }
528                MaterialOp::LayerBlend { mode } => {
529                    let bottom = Self::find_input(&var_names, node_id, MaterialSocket::Color, graph)
530                        .unwrap_or_else(|| "col".to_string());
531                    let top = Self::find_input_map(&var_names, node_id, MaterialSocket::Color, graph, 1)
532                        .unwrap_or_else(|| "col".to_string());
533                    let opacity = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
534                        .unwrap_or_else(|| "1.0".to_string());
535                    match mode {
536                        BlendMode::Add => {
537                            format!("mix({}, {}, {})", bottom, top, opacity)
538                        }
539                        BlendMode::Screen => {
540                            format!("mix({}, 1.0 - (1.0 - {}) * (1.0 - {}), {})", bottom, bottom, top, opacity)
541                        }
542                        BlendMode::Multiply => {
543                            format!("mix({}, {} * {}, {})", bottom, bottom, top, opacity)
544                        }
545                        BlendMode::Overlay => {
546                            format!("mix({}, select(2.0 * {} * {}, 1.0 - 2.0 * (1.0 - {}) * (1.0 - {}), step(vec4<f32>(0.5), {})), {})", bottom, bottom, top, bottom, top, bottom, opacity)
547                        }
548                    }
549                }
550                MaterialOp::PBRLighting => {
551                    r#"
552    let _n = normalize(in.normal);
553    let _metallic = in.slice.x;
554    let _roughness = in.slice.y;
555    let _opacity = in.slice.z;
556    let _ld = normalize(vec3<f32>(0.5, 0.8, 0.6));
557    let _lc = vec3<f32>(1.0, 0.95, 0.9);
558    let _ndl = max(dot(_n, _ld), 0.0);
559    let _diffuse = _ndl * _lc;
560    let _vd = vec3<f32>(0.0, 0.0, 1.0);
561    let _hd = normalize(_ld + _vd);
562    let _ndh = max(dot(_n, _hd), 0.0);
563    let _shiny = mix(8.0, 256.0, 1.0 - _roughness);
564    let _spec = pow(_ndh, _shiny) * _lc;
565    let _f0 = mix(vec3<f32>(0.04), col.rgb, _metallic);
566    let _fresnel = _f0 + (vec3<f32>(1.0) - _f0) * pow(1.0 - max(dot(_n, -_vd), 0.0), 5.0);
567    let _amb = vec3<f32>(0.06, 0.07, 0.1);
568    var _lit = col.rgb * (_amb + _diffuse);
569    _lit += _spec * mix(vec3<f32>(1.0), col.rgb, _metallic) * _fresnel;
570    let _depth = in.clip_position.z;
571    let _fog = clamp(1.0 - _depth * 0.0005, 0.7, 1.0);
572    _lit *= _fog;
573    __RESULT__ = vec4<f32>(_lit, col.a * _opacity);"#.trim().to_string()
574                }
575                MaterialOp::DropShadow => {
576                    r#"
577    let margin = in.uv.x;
578    let blur = max(in.uv.y, 1.0);
579    let original_size = in.size - 2.0 * margin;
580    let half_size = original_size * 0.5;
581    let p = in.logical - margin - half_size;
582    let d_sdf = sd_round_rect(p, half_size - in.radius, in.radius);
583    __RESULT__ = vec4<f32>(col.rgb, col.a * smoothstep(blur, 0.0, d_sdf));"#.trim().to_string()
584                }
585                MaterialOp::NineSlice => {
586                    "col".to_string() // Passthrough: 9-slice UV remapping is resolved on CPU
587                }
588                MaterialOp::Heatmap => {
589                    let val_var = Self::find_input(&var_names, node_id, MaterialSocket::Float, graph)
590                        .unwrap_or_else(|| "textureSample(t_diffuse[0], s_diffuse, in.uv).r".to_string());
591                    format!("vec4<f32>(heatmap_palette({}), col.a)", val_var)
592                }
593                MaterialOp::Raymarch { shape } => {
594                    match shape {
595                        RaymarchShape::Box => {
596                            r#"
597    let uv = (in.uv - 0.5) * 2.0;
598    let ro = vec3<f32>(0.0, 0.0, -2.5);
599    let rd = normalize(vec3<f32>(uv.x, uv.y, 1.5));
600    let m = rotX(in.slice.x) * rotY(in.slice.y) * rotZ(in.slice.z);
601    var t = 0.0;
602    var hit = false;
603    var d = 0.0;
604    for (var i = 0; i < 40; i++) {
605        let p = m * (ro + rd * t);
606        d = sd_box_3d(p, vec3(0.5, 0.5, 0.5));
607        if d < 0.001 {
608            hit = true;
609            break;
610        }
611        t += d;
612        if t > 5.0 { break; }
613    }
614    if hit {
615        let p = m * (ro + rd * t);
616        let eps = vec2(0.001, 0.0);
617        let n = normalize(vec3(
618            sd_box_3d(p + eps.xyy, vec3(0.5)) - sd_box_3d(p - eps.xyy, vec3(0.5)),
619            sd_box_3d(p + eps.yxy, vec3(0.5)) - sd_box_3d(p - eps.yxy, vec3(0.5)),
620            sd_box_3d(p + eps.yyx, vec3(0.5)) - sd_box_3d(p - eps.yyx, vec3(0.5))
621        ));
622        let light_dir = normalize(vec3(1.0, 1.0, -2.0));
623        let diff = max(dot(n, light_dir), 0.1);
624        let rim = pow(1.0 - max(dot(n, -rd), 0.0), 3.0) * 0.5;
625        __RESULT__ = vec4<f32>(col.rgb * diff + rim, col.a);
626    } else {
627        discard;
628    }"#.trim().to_string()
629                        }
630                        RaymarchShape::Sphere => {
631                            r#"
632    let ro = vec3<f32>(in.uv * 2.0 - 1.0, -2.0);
633    let rd = normalize(vec3<f32>(0.0, 0.0, 1.0));
634    var t = 0.0;
635    var hit = false;
636    for (var i = 0; i < 32; i++) {
637        let p = ro + rd * t;
638        let d = length(p) - 1.0;
639        if d < 0.01 { hit = true; break; }
640        t += d;
641    }
642    if hit {
643        let p = ro + rd * t;
644        let n = normalize(p);
645        let ld = normalize(vec3<f32>(1.0, 1.0, -1.0));
646        let diff = max(dot(n, ld), 0.0);
647        __RESULT__ = vec4<f32>(col.rgb * diff, col.a);
648    } else {
649        discard;
650    }"#.trim().to_string()
651                        }
652                    }
653                }
654                MaterialOp::Lightning => {
655                    r#"
656    let d = length((in.uv - 0.5) * vec2<f32>(1.0, 4.0));
657    __RESULT__ = theme.primary_neon * neon_glow(d, 0.01, 0.2);"#.trim().to_string()
658                }
659                MaterialOp::RuneGlow => {
660                    r#"
661    let p = (in.uv - 0.5) * 2.0;
662    let d = min(sd_segment(p, vec2(-0.5, -0.8), vec2(0.5, 0.8)), sd_segment(p, vec2(0.5, -0.8), vec2(-0.5, 0.8)));
663    __RESULT__ = theme.rune_glow * neon_glow(d, 0.02, 0.15) * theme.rune_opacity;"#.trim().to_string()
664                }
665                MaterialOp::RaymarchReflections => {
666                    r#"
667    let ro = vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, -2.0);
668    let rd = normalize(vec3<f32>(in.uv.x - 0.5, in.uv.y - 0.5, 1.0));
669    let t = ray_march(ro, rd);
670    if t > 0.0 {
671        let p = ro + rd * t;
672        let n = calc_normal(p);
673        let light_dir = normalize(vec3<f32>(1.0, 1.0, -1.0));
674        let diff = max(dot(n, light_dir), 0.2);
675        let ref_rd = reflect(rd, n);
676        let ref_t = ray_march(p + n * 0.01, ref_rd);
677        var reflection_color = vec3<f32>(0.05, 0.05, 0.1);
678        if ref_t > 0.0 { reflection_color = mix(theme.primary_neon.rgb, theme.shatter_neon.rgb, 0.5); }
679        __RESULT__ = vec4<f32>(mix(col.rgb * diff, reflection_color, 0.3), 1.0);
680    } else { discard; }"#.trim().to_string()
681                }
682                MaterialOp::Stroke => {
683                    r#"
684    let half_size = in.size * 0.5;
685    let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
686    let thickness = max(in.slice.x, 1.0);
687    let fw = length(vec2(dpdx(in.logical.x), dpdy(in.logical.y)));
688    __RESULT__ = vec4<f32>(col.rgb, col.a * (1.0 - smoothstep(-fw, fw, abs(d + thickness * 0.5) - thickness * 0.5)));"#.trim().to_string()
689                }
690                MaterialOp::DashedStroke => {
691                    r#"
692    let half_size = in.size * 0.5;
693    let d = sd_round_rect(in.logical - half_size, half_size - in.radius, in.radius);
694    let thickness = max(in.slice.x, 1.0);
695    let perimeter = (in.uv.x + in.uv.y) * max(in.size.x, in.size.y);
696    var alpha = 1.0 - smoothstep(-length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), length(vec2(dpdx(in.logical.x), dpdy(in.logical.y))), abs(d + thickness * 0.5) - thickness * 0.5);
697    if (perimeter + scene.time * 20.0) % (max(in.slice.y, 1.0) + max(in.slice.z, 1.0)) > max(in.slice.y, 1.0) { alpha = 0.0; }
698    __RESULT__ = vec4<f32>(col.rgb, col.a * alpha);"#.trim().to_string()
699                }
700            };
701
702            if expr.contains("__RESULT__") {
703                lines.push(format!("    var {}: vec4<f32>;", result_var));
704                lines.push("    {".to_string());
705                lines.push(expr.replace("__RESULT__", &result_var));
706                lines.push("    }".to_string());
707            } else {
708                lines.push(format!("    var {} = {};", result_var, expr));
709            }
710            var_names.insert((node_id, MaterialSocket::Color), result_var);
711        }
712
713        let body = lines.join("\n");
714        let out_id = graph.output.ok_or(MaterialError::NoOutput)?;
715        let fn_name = "material_entry".to_string();
716
717        let wgsl_fn = format!(
718            "fn {}(in: VertexOutput, col: vec4<f32>) -> vec4<f32> {{\n{}\n    return v_{};\n}}",
719            fn_name, body, out_id
720        );
721
722        Ok(CompiledMaterial { wgsl_fn, fn_name })
723    }
724
725    fn find_input(
726        names: &HashMap<(MatNodeId, MaterialSocket), String>,
727        node: MatNodeId,
728        socket: MaterialSocket,
729        graph: &MaterialGraph,
730    ) -> Option<String> {
731        for edge in &graph.edges {
732            if edge.to_node == node && edge.to_socket == socket {
733                return names.get(&(edge.from_node, edge.from_socket)).cloned();
734            }
735        }
736        None
737    }
738
739    fn find_input_map(
740        names: &HashMap<(MatNodeId, MaterialSocket), String>,
741        node: MatNodeId,
742        socket: MaterialSocket,
743        graph: &MaterialGraph,
744        offset: usize,
745    ) -> Option<String> {
746        let mut matches = graph
747            .edges
748            .iter()
749            .filter(|e| e.to_node == node && e.to_socket == socket);
750        let edge = matches.nth(offset)?;
751        names.get(&(edge.from_node, edge.from_socket)).cloned()
752    }
753
754    fn topo_sort(graph: &MaterialGraph) -> Result<Vec<MatNodeId>, MaterialError> {
755        let n = graph.nodes.len();
756        let mut in_degree = vec![0u32; n];
757        let mut adj: Vec<Vec<MatNodeId>> = vec![Vec::new(); n];
758
759        for edge in &graph.edges {
760            adj[edge.from_node as usize].push(edge.to_node);
761            in_degree[edge.to_node as usize] += 1;
762        }
763
764        let mut queue: std::collections::VecDeque<MatNodeId> = std::collections::VecDeque::new();
765        for (i, &deg) in in_degree.iter().enumerate() {
766            if deg == 0 {
767                queue.push_back(i as MatNodeId);
768            }
769        }
770
771        let mut order = Vec::with_capacity(n);
772        while let Some(node) = queue.pop_front() {
773            order.push(node);
774            for &next in &adj[node as usize] {
775                in_degree[next as usize] -= 1;
776                if in_degree[next as usize] == 0 {
777                    queue.push_back(next);
778                }
779            }
780        }
781
782        if order.len() != n {
783            return Err(MaterialError::Cycle);
784        }
785
786        Ok(order)
787    }
788}
789
790/// Pre-built material graphs for the built-in modes.
791/// These replace the if/else chains in shapes.wgsl.
792pub mod builtins {
793    use super::*;
794
795    /// Build a rounded rectangle material (old mode 3).
796    pub fn rounded_rect() -> MaterialGraph {
797        let mut g = MaterialGraph::new();
798        let input = g.add_node(MaterialOp::InputColor);
799        let sdf = g.add_node(MaterialOp::SDFRoundRect);
800        // The SDF node reads vertex data directly; input color provides the base
801        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
802        g.set_output(sdf);
803        g
804    }
805
806    /// Build a glass material (old mode 7).
807    pub fn glass() -> MaterialGraph {
808        let mut g = MaterialGraph::new();
809        let glass = g.add_node(MaterialOp::GlassBlur);
810        g.set_output(glass);
811        g
812    }
813
814    /// Build a solid color material (old mode 0 / default).
815    pub fn solid() -> MaterialGraph {
816        let mut g = MaterialGraph::new();
817        let input = g.add_node(MaterialOp::InputColor);
818        g.set_output(input);
819        g
820    }
821
822    /// Build a PBR material (old mode 13).
823    pub fn pbr() -> MaterialGraph {
824        let mut g = MaterialGraph::new();
825        let input = g.add_node(MaterialOp::InputColor);
826        let pbr = g.add_node(MaterialOp::PBRLighting);
827        g.connect(input, MaterialSocket::Color, pbr, MaterialSocket::Color);
828        g.set_output(pbr);
829        g
830    }
831
832    /// Build a text material (old mode 6) with premultiplied alpha.
833    pub fn text(tex_index: u32) -> MaterialGraph {
834        let mut g = MaterialGraph::new();
835        let input = g.add_node(MaterialOp::InputColor);
836        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
837        let blend = g.add_node(MaterialOp::PremultipliedBlend);
838        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
839        g.connect(tex, MaterialSocket::Float, blend, MaterialSocket::Float);
840        g.set_output(blend);
841        g
842    }
843
844    /// Build a texture sample material (old mode 2).
845    pub fn textured(tex_index: u32) -> MaterialGraph {
846        let mut g = MaterialGraph::new();
847        let input = g.add_node(MaterialOp::InputColor);
848        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
849        let blend = g.add_node(MaterialOp::LayerBlend {
850            mode: BlendMode::Multiply,
851        });
852        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
853        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
854        g.set_output(blend);
855        g
856    }
857
858    /// Build a neon glow material (old mode 8).
859    pub fn neon_glow(radius: f32, intensity: f32) -> MaterialGraph {
860        let mut g = MaterialGraph::new();
861        let input = g.add_node(MaterialOp::InputColor);
862        let glow = g.add_node(MaterialOp::NeonGlow { radius, intensity });
863        g.connect(input, MaterialSocket::Color, glow, MaterialSocket::Color);
864        g.set_output(glow);
865        g
866    }
867
868    /// Build a linear gradient material (old mode 15).
869    pub fn linear_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
870        let mut g = MaterialGraph::new();
871        let grad = g.add_node(MaterialOp::LinearGradient { start, end });
872        g.set_output(grad);
873        g
874    }
875
876    /// Build a radial gradient material (old mode 16).
877    pub fn radial_gradient(start: [f32; 4], end: [f32; 4]) -> MaterialGraph {
878        let mut g = MaterialGraph::new();
879        let grad = g.add_node(MaterialOp::RadialGradient { start, end });
880        g.set_output(grad);
881        g
882    }
883
884    /// Build an ellipse material (old mode 4).
885    pub fn ellipse() -> MaterialGraph {
886        let mut g = MaterialGraph::new();
887        let input = g.add_node(MaterialOp::InputColor);
888        let sdf = g.add_node(MaterialOp::SDFEllipse);
889        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
890        g.set_output(sdf);
891        g
892    }
893
894    /// Build a neon line material (old mode 1).
895    pub fn neon_line() -> MaterialGraph {
896        let mut g = MaterialGraph::new();
897        let color = g.add_node(MaterialOp::ConstantColor {
898            r: 1.5,
899            g: 1.5,
900            b: 1.5,
901            a: 1.0,
902        });
903        g.set_output(color);
904        g
905    }
906
907    /// Build a heatmap material (old mode 12).
908    pub fn heatmap(tex_index: u32) -> MaterialGraph {
909        let mut g = MaterialGraph::new();
910        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
911        let hm = g.add_node(MaterialOp::Heatmap);
912        g.connect(tex, MaterialSocket::Float, hm, MaterialSocket::Float);
913        g.set_output(hm);
914        g
915    }
916
917    /// Build a 9-slice material (old mode 20).
918    pub fn nine_slice(tex_index: u32) -> MaterialGraph {
919        let mut g = MaterialGraph::new();
920        let input = g.add_node(MaterialOp::InputColor);
921        let tex = g.add_node(MaterialOp::SampleTexture { tex_index });
922        let blend = g.add_node(MaterialOp::LayerBlend {
923            mode: BlendMode::Multiply,
924        });
925        g.connect(input, MaterialSocket::Color, blend, MaterialSocket::Color);
926        g.connect(tex, MaterialSocket::Color, blend, MaterialSocket::Color);
927        g.set_output(blend);
928        g
929    }
930
931    /// Build a raymarched cube material (old mode 21).
932    pub fn raymarch_cube() -> MaterialGraph {
933        let mut g = MaterialGraph::new();
934        let input = g.add_node(MaterialOp::InputColor);
935        let rm = g.add_node(MaterialOp::Raymarch {
936            shape: RaymarchShape::Box,
937        });
938        g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
939        g.set_output(rm);
940        g
941    }
942
943    /// Build a stroke material (old mode 17).
944    pub fn stroke() -> MaterialGraph {
945        let mut g = MaterialGraph::new();
946        let input = g.add_node(MaterialOp::InputColor);
947        let sdf = g.add_node(MaterialOp::Stroke);
948        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
949        g.set_output(sdf);
950        g
951    }
952
953    /// Build a drop shadow material (old mode 18).
954    pub fn drop_shadow() -> MaterialGraph {
955        let mut g = MaterialGraph::new();
956        let input = g.add_node(MaterialOp::InputColor);
957        let shadow = g.add_node(MaterialOp::DropShadow);
958        g.connect(input, MaterialSocket::Color, shadow, MaterialSocket::Color);
959        g.set_output(shadow);
960        g
961    }
962
963    /// Build a dashed stroke material (old mode 19).
964    pub fn dashed_stroke() -> MaterialGraph {
965        let mut g = MaterialGraph::new();
966        let input = g.add_node(MaterialOp::InputColor);
967        let sdf = g.add_node(MaterialOp::DashedStroke);
968        g.connect(input, MaterialSocket::Color, sdf, MaterialSocket::Color);
969        g.set_output(sdf);
970        g
971    }
972
973    pub fn lightning() -> MaterialGraph {
974        let mut g = MaterialGraph::new();
975        let l = g.add_node(MaterialOp::Lightning);
976        g.set_output(l);
977        g
978    }
979
980    pub fn rune_glow() -> MaterialGraph {
981        let mut g = MaterialGraph::new();
982        let r = g.add_node(MaterialOp::RuneGlow);
983        g.set_output(r);
984        g
985    }
986
987    pub fn raymarch() -> MaterialGraph {
988        let mut g = MaterialGraph::new();
989        let input = g.add_node(MaterialOp::InputColor);
990        let rm = g.add_node(MaterialOp::RaymarchReflections);
991        g.connect(input, MaterialSocket::Color, rm, MaterialSocket::Color);
992        g.set_output(rm);
993        g
994    }
995}
996
997pub fn generate_builtins_wgsl() -> String {
998    let mut out = String::new();
999    out.push_str("// ── Auto-generated material functions (Runtime) ──\n\n");
1000
1001    let builtins = vec![
1002        (0, "solid", builtins::solid()),
1003        (1, "neon_line", builtins::neon_line()),
1004        (2, "textured", builtins::textured(0)),
1005        (3, "rounded_rect", builtins::rounded_rect()),
1006        (4, "ellipse", builtins::ellipse()),
1007        (6, "text", builtins::text(0)),
1008        (7, "glass", builtins::glass()),
1009        (8, "neon_glow", builtins::neon_glow(1.0, 1.0)),
1010        (9, "lightning", builtins::lightning()),
1011        (10, "rune_glow", builtins::rune_glow()),
1012        (12, "heatmap", builtins::heatmap(0)),
1013        (13, "pbr", builtins::pbr()),
1014        (14, "raymarch", builtins::raymarch()),
1015        (
1016            15,
1017            "linear_grad",
1018            builtins::linear_gradient([0.0; 4], [0.0; 4]),
1019        ),
1020        (
1021            16,
1022            "radial_grad",
1023            builtins::radial_gradient([0.0; 4], [0.0; 4]),
1024        ),
1025        (17, "stroke", builtins::stroke()),
1026        (18, "drop_shadow", builtins::drop_shadow()),
1027        (19, "dashed", builtins::dashed_stroke()),
1028        (20, "nine_slice", builtins::nine_slice(0)),
1029        (21, "raymarch_cube", builtins::raymarch_cube()),
1030    ];
1031
1032    let mut dispatch = String::new();
1033    dispatch.push_str(
1034        "fn dispatch_material(material_id: u32, in: VertexOutput, col: vec4<f32>) -> vec4<f32> {\n",
1035    );
1036    dispatch.push_str("    switch material_id {\n");
1037
1038    for (id, name, graph) in builtins {
1039        let compiled = MaterialCompiler::compile(&graph).unwrap();
1040        let fn_name = format!("material_{}_{}", id, name);
1041        let fn_code = compiled.wgsl_fn.replace("material_entry", &fn_name);
1042        out.push_str(&fn_code);
1043        out.push_str("\n\n");
1044
1045        dispatch.push_str(&format!(
1046            "        case {}u: {{ return {}(in, col); }}\n",
1047            id, fn_name
1048        ));
1049    }
1050
1051    dispatch.push_str("        default: { return col; }\n");
1052    dispatch.push_str("    }\n}\n");
1053
1054    out.push_str(&dispatch);
1055    out
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060    use super::*;
1061
1062    #[test]
1063    fn test_solid_material_compiles() {
1064        let graph = builtins::solid();
1065        let compiled = MaterialCompiler::compile(&graph).unwrap();
1066        assert!(compiled.wgsl_fn.contains("fn material_"));
1067        assert!(compiled.wgsl_fn.contains("col"));
1068    }
1069
1070    #[test]
1071    fn test_rounded_rect_compiles() {
1072        let graph = builtins::rounded_rect();
1073        let compiled = MaterialCompiler::compile(&graph).unwrap();
1074        assert!(compiled.wgsl_fn.contains("sd_round_rect"));
1075    }
1076
1077    #[test]
1078    fn test_pbr_compiles() {
1079        let graph = builtins::pbr();
1080        let compiled = MaterialCompiler::compile(&graph).unwrap();
1081        assert!(compiled.wgsl_fn.contains("PBRLighting") || compiled.wgsl_fn.contains("_n"));
1082    }
1083
1084    #[test]
1085    fn test_graph_validation_no_output() {
1086        let mut g = MaterialGraph::new();
1087        g.add_node(MaterialOp::InputColor);
1088        assert!(g.validate().is_err());
1089    }
1090
1091    #[test]
1092    fn test_graph_validation_cycle() {
1093        let mut g = MaterialGraph::new();
1094        let a = g.add_node(MaterialOp::InputColor);
1095        let b = g.add_node(MaterialOp::NeonGlow {
1096            radius: 1.0,
1097            intensity: 1.0,
1098        });
1099        g.connect(a, MaterialSocket::Color, b, MaterialSocket::Color);
1100        g.connect(b, MaterialSocket::Color, a, MaterialSocket::Color); // cycle!
1101        g.set_output(b);
1102        assert!(g.validate().is_err());
1103    }
1104
1105    #[test]
1106    fn test_all_builtins_compile() {
1107        let graphs: Vec<MaterialGraph> = vec![
1108            builtins::solid(),
1109            builtins::rounded_rect(),
1110            builtins::glass(),
1111            builtins::pbr(),
1112            builtins::text(0),
1113            builtins::textured(0),
1114            builtins::neon_glow(4.0, 1.5),
1115            builtins::linear_gradient([1.0, 0.0, 0.0, 1.0], [0.0, 0.0, 1.0, 1.0]),
1116            builtins::radial_gradient([1.0, 1.0, 1.0, 1.0], [0.0, 0.0, 0.0, 1.0]),
1117            builtins::ellipse(),
1118            builtins::neon_line(),
1119            builtins::heatmap(0),
1120            builtins::nine_slice(0),
1121            builtins::raymarch_cube(),
1122            builtins::stroke(),
1123            builtins::drop_shadow(),
1124            builtins::dashed_stroke(),
1125        ];
1126
1127        for (i, graph) in graphs.iter().enumerate() {
1128            match MaterialCompiler::compile(graph) {
1129                Ok(compiled) => {
1130                    assert!(
1131                        !compiled.wgsl_fn.is_empty(),
1132                        "graph {} produced empty WGSL",
1133                        i
1134                    );
1135                    assert!(
1136                        !compiled.fn_name.is_empty(),
1137                        "graph {} produced empty fn name",
1138                        i
1139                    );
1140                }
1141                Err(e) => {
1142                    panic!("graph {} failed to compile: {}", i, e);
1143                }
1144            }
1145        }
1146    }
1147
1148    // =====================================================================
1149    // P1-4: Material graph complexity bounds (max edges)
1150    // =====================================================================
1151
1152    #[test]
1153    fn p1_4_validate_rejects_too_many_edges() {
1154        // P1-4 regression: max_edges is enforced.
1155        let mut graph = MaterialGraph::new();
1156        // Set output
1157        graph.output = Some(0);
1158        // Add 3 nodes so we can add 2 edges.
1159        graph.add_node(MaterialOp::InputColor);
1160        graph.add_node(MaterialOp::InputColor);
1161        graph.add_node(MaterialOp::InputColor);
1162        // Add 2 edges.
1163        graph.connect(0, MaterialSocket::Color, 1, MaterialSocket::Color);
1164        graph.connect(1, MaterialSocket::Color, 2, MaterialSocket::Color);
1165        assert_eq!(graph.edges.len(), 2, "test setup: need 2 edges");
1166        // Configure max_edges=1, so 2 edges should be rejected.
1167        let config = MaterialValidationConfig { max_nodes: 1024, max_edges: 1 };
1168        let result = graph.validate_with_config(&config);
1169        assert!(matches!(result, Err(MaterialError::TooManyEdges(2, 1))),
1170                "expected TooManyEdges(2, 1), got {result:?}");
1171    }
1172
1173    #[test]
1174    fn p1_4_default_config_has_max_edges() {
1175        // P1-4 regression: default config must have a non-zero
1176        // max_edges so the limit is actually enforced.
1177        let config = MaterialValidationConfig::default();
1178        assert!(config.max_edges > 0,
1179                "default max_edges must be > 0, got {}", config.max_edges);
1180    }
1181
1182    #[test]
1183    fn p1_4_validate_accepts_graph_within_edge_limit() {
1184        // Small graph with edges under the default max_edges.
1185        let mut graph = MaterialGraph::new();
1186        graph.output = Some(0);
1187        graph.add_node(MaterialOp::InputColor);
1188        graph.add_node(MaterialOp::InputColor);
1189        graph.connect(0, MaterialSocket::Color, 1, MaterialSocket::Color);
1190        let result = graph.validate_with_config(&MaterialValidationConfig::default());
1191        // Should pass edge check (may fail other checks like NoOutput
1192        // if not all required connections are present, but should
1193        // not fail with TooManyEdges).
1194        if let Err(MaterialError::TooManyEdges(_, _)) = result {
1195            panic!("default config should accept 1 edge, got {result:?}");
1196        }
1197    }
1198
1199    // P2-10: Test unreachable node detection
1200    #[test]
1201    fn p2_10_unreachable_node_detected() {
1202        let mut graph = MaterialGraph::new();
1203        let n0 = graph.add_node(MaterialOp::InputColor);
1204        let n1 = graph.add_node(MaterialOp::ConstantColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
1205        let n2 = graph.add_node(MaterialOp::ConstantColor { r: 0.0, g: 1.0, b: 0.0, a: 1.0 }); // unreachable
1206        graph.connect(n0, MaterialSocket::Color, n1, MaterialSocket::Color);
1207        graph.set_output(n1);
1208        // n2 is not connected to the output path
1209        let result = graph.validate();
1210        assert!(
1211            matches!(result, Err(MaterialError::UnreachableNode(id)) if id == n2),
1212            "expected UnreachableNode({n2}), got {result:?}"
1213        );
1214    }
1215
1216    #[test]
1217    fn p2_10_all_reachable_passes() {
1218        let mut graph = MaterialGraph::new();
1219        let n0 = graph.add_node(MaterialOp::InputColor);
1220        let n1 = graph.add_node(MaterialOp::ConstantColor { r: 1.0, g: 0.0, b: 0.0, a: 1.0 });
1221        graph.connect(n0, MaterialSocket::Color, n1, MaterialSocket::Color);
1222        graph.set_output(n1);
1223        // Both nodes reachable from output
1224        assert!(graph.validate().is_ok(), "valid graph should pass");
1225    }
1226}