import type { ErrorCode } from "./error"; import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates"; export const SHADER_GRAPH_SCHEMA = 1 as const; export type ShaderSocketType = "VALUE" | "VECTOR" | "COLOR" | "BOOLEAN" | "SHADER" | "CLOSURE"; export type ShaderNodeType = "RGB" | "VALUE" | "MIX" | "MATH" | "MAPPING" | "TEX_COORD" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "BUMP" | "PRINCIPLED" | "MATERIAL_OUTPUT"; export type ShaderSocketValue = boolean | number | string | number[]; export interface ShaderSocketIR { id: string; name: string; direction: "INPUT" | "OUTPUT"; dataType: ShaderSocketType; defaultValue?: ShaderSocketValue; } export interface ShaderNodeIR { id: string; type: ShaderNodeType | "UNSUPPORTED"; name: string; sockets: ShaderSocketIR[]; imageId?: string | null; properties?: Record; } export interface ShaderLinkIR { fromNodeId: string; fromSocketId: string; toNodeId: string; toSocketId: string; } export interface ShaderGraphIR { schemaVersion: typeof SHADER_GRAPH_SCHEMA; id: string; materialId: string; nodes: ShaderNodeIR[]; links: ShaderLinkIR[]; outputNodeId?: string; graphHash?: string; } export interface ShaderGraphValidationContext { imageIds: ReadonlySet; blockedImageIds?: ReadonlySet; materialIds?: ReadonlySet; } export interface ShaderGraphValidation { status: "SUPPORTED" | "BLOCKED"; issues: Array<{ code: ErrorCode; message: string; path?: string }>; cycles: string[][]; } export class ShaderGraphError extends Error { readonly code: ErrorCode; readonly path?: string; constructor(code: ErrorCode, message: string, path?: string) { super(message); this.name = "ShaderGraphError"; this.code = code; this.path = path; } } function record(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function socket(value: unknown, path: string): value is ShaderSocketIR { if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["VALUE", "VECTOR", "COLOR", "BOOLEAN", "SHADER", "CLOSURE"].includes(value.dataType as string)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `${path} is invalid`, path); if (value.defaultValue !== undefined && !(typeof value.defaultValue === "boolean" || typeof value.defaultValue === "number" || typeof value.defaultValue === "string" || (Array.isArray(value.defaultValue) && value.defaultValue.every((item) => typeof item === "number" && Number.isFinite(item))))) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${path}.defaultValue`); return true; } const supportedTypes = new Set(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "NORMAL_MAP", "PRINCIPLED", "MATERIAL_OUTPUT"]); const supportedMathOperations = new Set(["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"]); export function parseShaderGraph(value: unknown): ShaderGraphIR { if (!record(value) || value.schemaVersion !== SHADER_GRAPH_SCHEMA) throw new ShaderGraphError("PROTOCOL_MISMATCH", "Unsupported ShaderGraph schema"); if (typeof value.id !== "string" || typeof value.materialId !== "string" || value.id.length === 0 || value.materialId.length === 0 || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", "ShaderGraph metadata or arrays are invalid"); value.nodes.forEach((node, index) => { if (!record(node) || typeof node.id !== "string" || typeof node.name !== "string" || typeof node.type !== "string" || !Array.isArray(node.sockets)) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`); node.sockets.forEach((item, socketIndex) => socket(item, `nodes[${index}].sockets[${socketIndex}]`)); if (node.imageId !== undefined && node.imageId !== null && typeof node.imageId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}].imageId is invalid`, `nodes[${index}].imageId`); if (node.properties !== undefined && (!record(node.properties) || Object.values(node.properties).some((item) => !(typeof item === "boolean" || typeof item === "number" || typeof item === "string" || (Array.isArray(item) && item.every((entry) => typeof entry === "number" && Number.isFinite(entry))))))) throw new ShaderGraphError("SHADER_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`); }); value.links.forEach((link, index) => { if (!record(link) || typeof link.fromNodeId !== "string" || typeof link.fromSocketId !== "string" || typeof link.toNodeId !== "string" || typeof link.toSocketId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`); }); if (value.outputNodeId !== undefined && typeof value.outputNodeId !== "string") throw new ShaderGraphError("SHADER_INVALID_GRAPH", "outputNodeId is invalid", "outputNodeId"); return value as unknown as ShaderGraphIR; } function compatible(from: ShaderSocketIR, to: ShaderSocketIR): boolean { if (from.direction !== "OUTPUT" || to.direction !== "INPUT") return false; if (from.dataType === to.dataType) return true; return (from.dataType === "COLOR" && to.dataType === "VECTOR") || (from.dataType === "VECTOR" && to.dataType === "COLOR") || (from.dataType === "VALUE" && to.dataType === "VECTOR"); } export function validateShaderGraph(graph: ShaderGraphIR, context: ShaderGraphValidationContext): ShaderGraphValidation { const issues: ShaderGraphValidation["issues"] = []; const nodes = new Map(); const sockets = new Map(); for (const [index, node] of graph.nodes.entries()) { if (nodes.has(node.id)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `duplicate node ID: ${node.id}`, path: `nodes.${index}` }); nodes.set(node.id, node); if (node.type === "UNSUPPORTED" || !supportedTypes.has(node.type)) issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `unsupported shader node: ${node.type}`, path: `nodes.${index}.type` }); const propertyKeys = Object.keys(node.properties ?? {}); if (node.type === "MATH") { const operation = node.properties?.operation; if (propertyKeys.length !== 1 || typeof operation !== "string" || !supportedMathOperations.has(operation)) { issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: "Math requires one supported operation property", path: `nodes.${index}.properties.operation` }); } } else if (propertyKeys.length > 0) issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `shader node properties are not writable for ${node.type}`, path: `nodes.${index}.properties` }); if (node.type === "IMAGE_TEXTURE" && (!node.imageId || !context.imageIds.has(node.imageId))) issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader node references a missing image: ${node.imageId ?? "none"}`, path: `nodes.${index}.imageId` }); else if (node.type === "IMAGE_TEXTURE" && node.imageId && context.blockedImageIds?.has(node.imageId)) issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader image is blocked by the library/path sandbox: ${node.imageId}`, path: `nodes.${index}.imageId` }); for (const item of node.sockets) { if (sockets.has(`${node.id}:${item.id}`)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `duplicate socket ID: ${node.id}:${item.id}`, path: `nodes.${index}.sockets` }); sockets.set(`${node.id}:${item.id}`, item); } } if (context.materialIds && !context.materialIds.has(graph.materialId)) { issues.push({ code: "SHADER_EXTERNAL_RESOURCE_MISSING", message: `shader graph references a missing material: ${graph.materialId}`, path: "materialId" }); } const edges = new Map(); const linkedInputs = new Set(); for (const [index, link] of graph.links.entries()) { const from = sockets.get(`${link.fromNodeId}:${link.fromSocketId}`); const to = sockets.get(`${link.toNodeId}:${link.toSocketId}`); if (!from || !to) { issues.push({ code: "SHADER_INVALID_GRAPH", message: "link references an unknown socket", path: `links.${index}` }); continue; } if (!compatible(from, to)) issues.push({ code: "SHADER_SOCKET_TYPE_MISMATCH", message: `incompatible shader link ${link.fromNodeId}:${link.fromSocketId} -> ${link.toNodeId}:${link.toSocketId}`, path: `links.${index}` }); const inputKey = `${link.toNodeId}:${link.toSocketId}`; if (linkedInputs.has(inputKey)) issues.push({ code: "SHADER_INVALID_GRAPH", message: `shader input has more than one link: ${inputKey}`, path: `links.${index}` }); linkedInputs.add(inputKey); edges.set(link.fromNodeId, [...(edges.get(link.fromNodeId) ?? []), link.toNodeId]); } const cycles: string[][] = []; const state = new Map(); const path: string[] = []; const visit = (id: string): void => { const current = state.get(id) ?? 0; if (current === 2) return; if (current === 1) { const start = path.indexOf(id); cycles.push(start < 0 ? [id] : [...path.slice(start), id]); return; } state.set(id, 1); path.push(id); for (const next of edges.get(id) ?? []) visit(next); path.pop(); state.set(id, 2); }; for (const node of graph.nodes) visit(node.id); if (cycles.length > 0) issues.push({ code: "SHADER_GRAPH_CYCLE", message: "Shader graph contains a cycle", path: "links" }); const outputNodes = graph.nodes.filter((node) => node.type === "MATERIAL_OUTPUT"); if (outputNodes.length !== 1 || (graph.outputNodeId !== undefined && graph.outputNodeId !== outputNodes[0]?.id)) issues.push({ code: "SHADER_INVALID_GRAPH", message: "Shader graph must have exactly one Material Output", path: "outputNodeId" }); return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, cycles }; } export function gateShaderGraph(value: unknown, context: ShaderGraphValidationContext = { imageIds: new Set() }): CapabilityGateResult { try { const graph = parseShaderGraph(value); const result = validateShaderGraph(graph, context); if (result.status === "SUPPORTED") return readyGate("N-013", "SHADER_NODE_GRAPH"); return blockedGate("N-013", "SHADER_NODE_GRAPH", result.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path))); } catch (error) { const issue = error as ShaderGraphError; return blockedGate("N-013", "SHADER_NODE_GRAPH", [capabilityIssue(issue.code ?? "SHADER_INVALID_GRAPH", issue.message, issue.path)]); } }