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