- generate a desktop-Blender PBR asset library and seed catalog v2 into OPFS - add React asset browser, material preview sphere and Web PBR render panel - extend shader graph/compiler to the finite 14-node set and native properties - add image colour-space editing, scene-camera still rendering and asset repair - add focused E2E/unit coverage and implementation status notes Co-Authored-By: Claude Code <noreply@anthropic.com>
225 lines
13 KiB
TypeScript
225 lines
13 KiB
TypeScript
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" | "UV_MAP" | "SEPARATE_COLOR" | "OBJECT_INFO" | "IMAGE_TEXTURE" | "NORMAL_MAP" | "BUMP" | "PRINCIPLED" | "MATERIAL_OUTPUT";
|
|
export type ShaderSocketValue = boolean | number | string | number[];
|
|
export type ShaderNodeEdit =
|
|
| {action:'ADD';nodeType:'RGB'|'VALUE'|'MATH'|'MIX'|'MAPPING'|'TEX_COORD'|'UV_MAP'|'SEPARATE_COLOR'|'OBJECT_INFO'|'IMAGE_TEXTURE'|'NORMAL_MAP'|'BUMP'|'PRINCIPLED'|'OUTPUT';location:[number,number]}
|
|
| {action:'MOVE';nodeId:string;location:[number,number]}
|
|
| {action:'RENAME';nodeId:string;name:string}
|
|
| {action:'REMOVE';nodeId:string}
|
|
| {action:'SET_SOCKET';nodeId:string;socketId:string;direction:'INPUT'|'OUTPUT';value:number|boolean|number[]}
|
|
| {action:'SET_PROPERTY';nodeId:string;property:string;value:string|number|boolean}
|
|
| {action:'SET_IMAGE';nodeId:string;imageId:string}
|
|
| {action:'SET_IMAGE_COLORSPACE';nodeId:string;colorSpace:'SRGB'|'NON_COLOR'|'LINEAR'}
|
|
| {action:'LINK'|'UNLINK';nodeId:string;socketId:string;fromNodeId:string;fromSocketId:string};
|
|
|
|
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<string, ShaderSocketValue>;
|
|
}
|
|
|
|
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<string>;
|
|
blockedImageIds?: ReadonlySet<string>;
|
|
materialIds?: ReadonlySet<string>;
|
|
}
|
|
|
|
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<string, unknown> {
|
|
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<ShaderNodeType>([
|
|
"RGB", "VALUE", "MIX", "MATH", "MAPPING", "TEX_COORD", "UV_MAP", "SEPARATE_COLOR",
|
|
"OBJECT_INFO", "IMAGE_TEXTURE", "NORMAL_MAP", "BUMP", "PRINCIPLED", "MATERIAL_OUTPUT",
|
|
]);
|
|
const supportedMathOperations = new Set(["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"]);
|
|
const integerInRange = (value: unknown, minimum: number, maximum: number): boolean =>
|
|
typeof value === "number" && Number.isInteger(value) && value >= minimum && value <= maximum;
|
|
const nodePropertyRules: Partial<Record<ShaderNodeType, Record<string, (value: unknown) => boolean>>> = {
|
|
MATH: { operation: (value) => typeof value === "string" && supportedMathOperations.has(value) },
|
|
MIX: {
|
|
data_type: (value) => typeof value === "string",
|
|
blend_type: (value) => typeof value === "string",
|
|
clamp_factor: (value) => typeof value === "boolean",
|
|
clamp_result: (value) => typeof value === "boolean",
|
|
factor_mode: (value) => integerInRange(value, 0, 1),
|
|
},
|
|
MAPPING: { vector_type: (value) => typeof value === "string" },
|
|
NORMAL_MAP: { space: (value) => typeof value === "string", uv_map: (value) => typeof value === "string" },
|
|
BUMP: { invert: (value) => typeof value === "boolean" },
|
|
UV_MAP: { uv_map: (value) => typeof value === "string" },
|
|
SEPARATE_COLOR: { mode: (value) => value === "RGB" || value === "HSV" || value === "HSL" },
|
|
IMAGE_TEXTURE: {
|
|
interpolation: (value) => integerInRange(value, 0, 2),
|
|
extension: (value) => integerInRange(value, 0, 3),
|
|
projection: (value) => integerInRange(value, 0, 6),
|
|
},
|
|
};
|
|
|
|
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<string, ShaderNodeIR>();
|
|
const sockets = new Map<string, ShaderSocketIR>();
|
|
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 ?? {});
|
|
const rules = node.type === "UNSUPPORTED" ? undefined : nodePropertyRules[node.type];
|
|
if (node.type === "MATH" && !rules) {
|
|
issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: "Math has no writable property schema", path: `nodes.${index}.properties` });
|
|
}
|
|
else if (propertyKeys.length > 0 && !rules) {
|
|
issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `shader node properties are not writable for ${node.type}`, path: `nodes.${index}.properties` });
|
|
}
|
|
else if (rules) {
|
|
for (const key of propertyKeys) {
|
|
const check = rules[key];
|
|
if (!check || !check(node.properties?.[key])) {
|
|
issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: `unsupported ${node.type} property: ${key}`, path: `nodes.${index}.properties.${key}` });
|
|
}
|
|
}
|
|
if (node.type === "MATH" && !propertyKeys.includes("operation")) {
|
|
issues.push({ code: "SHADER_NODE_UNSUPPORTED", message: "Math requires one supported operation property", path: `nodes.${index}.properties.operation` });
|
|
}
|
|
}
|
|
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<string, string[]>();
|
|
const linkedInputs = new Set<string>();
|
|
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<string, 0 | 1 | 2>();
|
|
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<string>() }): 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)]);
|
|
}
|
|
}
|