feat(web): complete PBR shader, asset library and offscreen render chain
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- 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>
This commit is contained in:
mes123456
2026-11-11 14:59:43 +08:00
parent d1e6b63d54
commit a078648297
65 changed files with 6083 additions and 157 deletions

View File

@@ -16,9 +16,16 @@ export type ShaderTextureColorSpace = "SRGB" | "NON_COLOR" | "LINEAR";
export const SHADER_COMPILE_ALLOWLIST = [
"RGB",
"VALUE",
"MIX",
"MATH",
"MAPPING",
"TEX_COORD",
"UV_MAP",
"SEPARATE_COLOR",
"OBJECT_INFO",
"IMAGE_TEXTURE",
"NORMAL_MAP",
"BUMP",
"PRINCIPLED",
"OUTPUT",
] as const;
@@ -51,6 +58,19 @@ export interface ShaderCompiledMaterial {
normalImageId?: string;
}
export type ShaderTextureUsage = "BASE_COLOR" | "NORMAL" | "BUMP";
export interface ShaderTextureBindingIR {
imageId: string;
usage: ShaderTextureUsage;
strength?: number;
uvMap?: string;
uvTransform?: {
offset: [number, number];
rotation: number;
scale: [number, number];
};
}
export interface ShaderCompileReport {
schemaVersion: typeof SHADER_COMPILE_SCHEMA;
taskId: typeof SHADER_COMPILE_TASK;
@@ -61,7 +81,7 @@ export interface ShaderCompileReport {
compileKey: string | null;
nodeOrder: string[];
compiledNodeTypes: CompileNodeType[];
textureBindings: Array<{ imageId: string; usage: "BASE_COLOR" | "NORMAL" }>;
textureBindings: ShaderTextureBindingIR[];
instructions: Array<{ nodeId: string; type: CompileNodeType; operation?: string }>;
material?: ShaderCompiledMaterial;
issues: Issue[];
@@ -83,7 +103,7 @@ export interface ShaderCompileKeyInput {
rendererBackend: string;
textures: ReadonlyArray<{
imageId: string;
usage: "BASE_COLOR" | "NORMAL";
usage: ShaderTextureUsage;
assetId: string | null;
sha256: string | null;
colorSpace: ShaderTextureColorSpace;
@@ -153,7 +173,7 @@ function normalizeSocket(value: string): string {
function scalarSocket(value: string): boolean {
const normalized = normalizeSocket(value);
return normalized === "value" || normalized === "value001" || normalized === "a" || normalized === "b";
return normalized === "value" || normalized === "value001" || normalized === "a" || normalized === "b" || normalized === "factor";
}
function canonicalGraph(material: MaterialIR): string {
@@ -250,7 +270,7 @@ function sourceLink(incoming: ReadonlyMap<string, MaterialLinkIR>, nodeId: strin
}
function isScalarSource(node: MaterialNodeIR | undefined): boolean {
return node?.type === "VALUE" || node?.type === "MATH";
return node?.type === "VALUE" || node?.type === "MATH" || node?.type === "MIX" || node?.type === "SEPARATE_COLOR";
}
function targetScalarSocket(socket: string): boolean {
@@ -321,7 +341,6 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
byId.set(node.id, node);
if (!(SHADER_COMPILE_ALLOWLIST as readonly string[]).includes(node.type)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `shader node ${node.type} is outside the M10-07 compiler allowlist`, `nodes.${index}.type`));
if (node.type === "MATH" && !["ADD", "SUBTRACT", "MULTIPLY", "DIVIDE", "MINIMUM", "MAXIMUM"].includes(node.properties?.operation ?? "")) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Math operation is outside the M10-07 compiler allowlist", `nodes.${index}.properties.operation`));
if (node.type !== "MATH" && node.properties && Object.keys(node.properties).length > 0) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `properties are not compiled for ${node.type}`, `nodes.${index}.properties`));
}
const incoming = new Map<string, MaterialLinkIR>();
const edges = new Map<string, string[]>();
@@ -363,7 +382,7 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
const output = outputs[0];
const shader = principled[0];
if (output && shader && !sourceLink(incoming, output.id, "Surface")) base.issues.push(issue("SHADER_INVALID_GRAPH", "Material Output Surface is not connected", "links"));
const textures = new Map<string, { imageId: string; usage: "BASE_COLOR" | "NORMAL" }>();
const textures = new Map<string, ShaderTextureBindingIR>();
const compiled: ShaderCompiledMaterial = {
baseColor: material.baseColor,
roughness: material.roughness,
@@ -377,6 +396,72 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
emissionStrength: material.emissionStrength,
emissionColor: material.emissionColor,
};
const socketNumber = (node: MaterialNodeIR, names: string[]): number | undefined => {
for (const socket of node.sockets ?? []) {
if (socket.direction !== "INPUT") continue;
if (!names.includes(normalizeSocket(socket.id)) && !names.includes(normalizeSocket(socket.name))) continue;
if (finite(socket.value)) return socket.value;
}
return undefined;
};
const socketVector2 = (node: MaterialNodeIR, names: string[]): [number, number] | undefined => {
for (const socket of node.sockets ?? []) {
if (socket.direction !== "INPUT") continue;
if (!names.includes(normalizeSocket(socket.id)) && !names.includes(normalizeSocket(socket.name))) continue;
if (Array.isArray(socket.value) && socket.value.length >= 2 && finite(socket.value[0]) && finite(socket.value[1])) {
return [socket.value[0], socket.value[1]];
}
}
return undefined;
};
const meshUvMap = (node: MaterialNodeIR): string | undefined => {
const properties = node.properties as Record<string, unknown> | undefined;
const value = properties?.uvMap ?? properties?.uv_map;
return typeof value === "string" && value.length > 0 ? value : undefined;
};
const textureUv = (imageNode: MaterialNodeIR): { uvMap?: string; uvTransform?: ShaderTextureBindingIR["uvTransform"] } => {
const vectorLink = sourceLink(incoming, imageNode.id, "Vector");
if (!vectorLink) return {};
const source = byId.get(vectorLink.fromNodeId);
if (!source) return {};
if (source.type === "UV_MAP") return { uvMap: meshUvMap(source) };
if (source.type === "TEX_COORD") return {};
if (source.type !== "MAPPING") {
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Texture Vector must come from Mapping, UV Map, or Texture Coordinate", `nodes.${imageNode.id}.Vector`));
return {};
}
const offset = socketVector2(source, ["location"]) ?? [0, 0];
const scale = socketVector2(source, ["scale"]) ?? [1, 1];
const rotation = socketVector2(source, ["rotation"])?.[0] ?? 0;
const uvLink = sourceLink(incoming, source.id, "Vector");
const uvNode = uvLink ? byId.get(uvLink.fromNodeId) : undefined;
let uvMap: string | undefined;
if (uvNode?.type === "UV_MAP") uvMap = meshUvMap(uvNode);
else if (uvNode && uvNode.type !== "TEX_COORD") {
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Mapping Vector must come from UV Map or Texture Coordinate", `nodes.${source.id}.Vector`));
}
return { uvMap, uvTransform: { offset, rotation, scale } };
};
const registerTexture = (imageNode: MaterialNodeIR, usage: ShaderTextureUsage, strength?: number): ShaderTextureBindingIR | undefined => {
if (!imageNode.imageId) return undefined;
if (context.imageIds && !context.imageIds.has(imageNode.imageId)) {
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
}
if (context.blockedImageIds?.has(imageNode.imageId)) {
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
}
const uv = textureUv(imageNode);
const binding: ShaderTextureBindingIR = {
imageId: imageNode.imageId,
usage,
...(strength !== undefined ? { strength } : {}),
...(uv.uvMap ? { uvMap: uv.uvMap } : {}),
...(uv.uvTransform ? { uvTransform: uv.uvTransform } : {}),
};
textures.set(`${usage}:${imageNode.imageId}`, binding);
if (usage === "BASE_COLOR") compiled.baseColorImageId = imageNode.imageId;
return binding;
};
const evaluated = new Map<string, number>();
const evaluating = new Set<string>();
const evalScalar = (nodeId: string, depth: number): number | undefined => {
@@ -394,12 +479,8 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
else if (node.type === "MATH") {
const first = sourceLink(incoming, node.id, "Value") ?? sourceLink(incoming, node.id, "A");
const second = sourceLink(incoming, node.id, "Value_001") ?? sourceLink(incoming, node.id, "B");
const socketDefault=(id:string):number|undefined=>{
const value=node.sockets?.find(socket=>socket.direction==='INPUT'&&normalizeSocket(socket.id)===normalizeSocket(id))?.value;
return finite(value)?value:undefined;
};
const left = first ? evalScalar(first.fromNodeId, depth + 1) : socketDefault('Value');
const right = second ? evalScalar(second.fromNodeId, depth + 1) : socketDefault('Value_001');
const left = first ? evalScalar(first.fromNodeId, depth + 1) : socketNumber(node, ["value"]);
const right = second ? evalScalar(second.fromNodeId, depth + 1) : socketNumber(node, ["value001"]);
if (left !== undefined && right !== undefined) {
switch (node.properties?.operation) {
case "ADD": value = left + right; break;
@@ -411,56 +492,113 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
}
}
}
else if (node.type === "MIX") {
const factorLink = sourceLink(incoming, node.id, "Factor");
const aLink = sourceLink(incoming, node.id, "A");
const bLink = sourceLink(incoming, node.id, "B");
const factor = factorLink ? evalScalar(factorLink.fromNodeId, depth + 1) : socketNumber(node, ["factor"]);
const left = aLink ? evalScalar(aLink.fromNodeId, depth + 1) : socketNumber(node, ["a"]);
const right = bLink ? evalScalar(bLink.fromNodeId, depth + 1) : socketNumber(node, ["b"]);
if (factor !== undefined && left !== undefined && right !== undefined) {
const blend = String(node.properties?.blendType ?? "MIX");
if (blend === "MIX") value = left * (1 - factor) + right * factor;
else if (blend === "ADD") value = left + right * factor;
else if (blend === "MULTIPLY") value = left * (1 - factor + right * factor);
else base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Mix blend ${blend} is not a compiled scalar constant`, `nodes.${node.id}`));
}
}
evaluating.delete(nodeId);
if (value !== undefined && finite(value)) evaluated.set(nodeId, value);
return value;
};
const evalColor = (link: MaterialLinkIR): [number, number, number, number] | undefined => {
const evalColorLink = (link: MaterialLinkIR, depth: number): [number, number, number, number] | undefined => {
if (depth > SHADER_COMPILE_BUDGET.maxDepth) return undefined;
const node = byId.get(link.fromNodeId);
if (node?.type === "RGB" && finiteColor(node.defaultValue)) {
if (!node) return undefined;
if (node.type === "RGB" && finiteColor(node.defaultValue)) {
return [node.defaultValue[0], node.defaultValue[1], node.defaultValue[2], node.defaultValue[3] ?? 1];
}
if (node?.type === "IMAGE_TEXTURE" && node.imageId) {
if (context.imageIds && !context.imageIds.has(node.imageId)) {
base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${node.imageId}`, `nodes.${node.id}.imageId`));
}
if (context.blockedImageIds?.has(node.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${node.imageId}`, `nodes.${node.id}.imageId`));
textures.set(`BASE_COLOR:${node.imageId}`, { imageId: node.imageId, usage: "BASE_COLOR" });
compiled.baseColorImageId = node.imageId;
if (node.type === "IMAGE_TEXTURE") {
registerTexture(node, "BASE_COLOR");
return undefined;
}
if (node.type === "MIX") {
const factorLink = sourceLink(incoming, node.id, "Factor");
const aLink = sourceLink(incoming, node.id, "A");
const bLink = sourceLink(incoming, node.id, "B");
const factor = factorLink ? evalScalar(factorLink.fromNodeId, depth + 1) : socketNumber(node, ["factor"]);
const left = aLink ? evalColorLink(aLink, depth + 1) : undefined;
const right = bLink ? evalColorLink(bLink, depth + 1) : undefined;
if (factor === undefined || !left || !right) {
base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Mix color inputs must be bounded constants", `nodes.${node.id}`));
return undefined;
}
const blend = String(node.properties?.blendType ?? "MIX");
const mix = (a: number, b: number): number => blend === "MULTIPLY" ? a * b : blend === "ADD" ? Math.min(1, a + b) : a * (1 - factor) + b * factor;
return [mix(left[0], right[0]), mix(left[1], right[1]), mix(left[2], right[2]), mix(left[3], right[3])];
}
if (node.type === "SEPARATE_COLOR") {
const colorLink = sourceLink(incoming, node.id, "Color");
return colorLink ? evalColorLink(colorLink, depth + 1) : undefined;
}
return undefined;
};
const evalScalarLink = (link: MaterialLinkIR, depth: number): number | undefined => {
const node = byId.get(link.fromNodeId);
if (node?.type === "SEPARATE_COLOR") {
const colorLink = sourceLink(incoming, node.id, "Color");
const color = colorLink ? evalColorLink(colorLink, depth + 1) : undefined;
if (!color) return undefined;
const channel = normalizeSocket(link.fromSocket);
return channel === "red" || channel === "r" ? color[0]
: channel === "green" || channel === "g" ? color[1]
: channel === "blue" || channel === "b" ? color[2]
: channel === "alpha" || channel === "a" ? color[3]
: undefined;
}
return evalScalar(link.fromNodeId, depth);
};
if (shader) {
for (const [socket, field] of [["Roughness", "roughness"], ["Metallic", "metallic"], ["Alpha", "alpha"], ["IOR", "ior"], ["Specular IOR Level", "specularIORLevel"], ["Transmission Weight", "transmissionWeight"], ["Coat Weight", "coatWeight"], ["Coat Roughness", "coatRoughness"], ["Emission Strength", "emissionStrength"]] as const) {
const link = sourceLink(incoming, shader.id, socket);
if (!link) continue;
const value = evalScalar(link.fromNodeId, 0);
const value = evalScalarLink(link, 0);
const normalized = normalizeSocket(field);
if (value === undefined || !boundedMaterialValue(normalized, value)) base.issues.push(issue("SHADER_INVALID_GRAPH", `${socket} input is not a finite bounded constant`, `links.${socket}`));
else (compiled as unknown as Record<string, unknown>)[field] = value;
}
const colorLink = sourceLink(incoming, shader.id, "Base Color");
if (colorLink) {
const color = evalColor(colorLink);
const source = byId.get(colorLink.fromNodeId);
const color = evalColorLink(colorLink, 0);
if (color) compiled.baseColor = color;
else if (byId.get(colorLink.fromNodeId)?.type !== "IMAGE_TEXTURE") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB constant or Image Texture", "links.BaseColor"));
else if (source?.type !== "IMAGE_TEXTURE" && source?.type !== "MIX") base.issues.push(issue("SHADER_INVALID_GRAPH", "Base Color must be an RGB constant, Mix constant, or Image Texture", "links.BaseColor"));
}
const normalLink = sourceLink(incoming, shader.id, "Normal");
if (normalLink) {
const normalNode = byId.get(normalLink.fromNodeId);
if (normalNode?.type !== "NORMAL_MAP") base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by the declared Normal Map node", "links.Normal"));
else {
if (normalNode?.type === "NORMAL_MAP") {
const imageLink = sourceLink(incoming, normalNode.id, "Color");
const imageNode = imageLink ? byId.get(imageLink.fromNodeId) : undefined;
if (!imageLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) base.issues.push(issue("SHADER_INVALID_GRAPH", "Normal Map requires an Image Texture Color input", `nodes.${normalNode.id}`));
else {
if (context.imageIds && !context.imageIds.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is not present: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
if (context.blockedImageIds?.has(imageNode.imageId)) base.issues.push(issue("SHADER_EXTERNAL_RESOURCE_MISSING", `shader image is blocked: ${imageNode.imageId}`, `nodes.${imageNode.id}.imageId`));
textures.set(`NORMAL:${imageNode.imageId}`, { imageId: imageNode.imageId, usage: "NORMAL" });
const strengthLink = sourceLink(incoming, normalNode.id, "Strength");
const strength = strengthLink ? evalScalarLink(strengthLink, 0) : socketNumber(normalNode, ["strength"]);
registerTexture(imageNode, "NORMAL", strength);
compiled.normalImageId = imageNode.imageId;
}
}
else if (normalNode?.type === "BUMP") {
const heightLink = sourceLink(incoming, normalNode.id, "Height");
const imageNode = heightLink ? byId.get(heightLink.fromNodeId) : undefined;
if (!heightLink || imageNode?.type !== "IMAGE_TEXTURE" || !imageNode.imageId) base.issues.push(issue("SHADER_INVALID_GRAPH", "Bump requires an Image Texture Height input", `nodes.${normalNode.id}`));
else {
const strengthLink = sourceLink(incoming, normalNode.id, "Strength");
const strength = strengthLink ? evalScalarLink(strengthLink, 0) : socketNumber(normalNode, ["strength"]);
registerTexture(imageNode, "BUMP", strength);
}
}
else base.issues.push(issue("SHADER_NODE_UNSUPPORTED", "Principled Normal must be driven by Normal Map or Bump", "links.Normal"));
}
}
const supportedLink = (link: MaterialLinkIR): boolean => {
@@ -471,12 +609,34 @@ export function compileMaterialGraph(material: MaterialIR, context: ShaderCompil
const toSocket = normalizeSocket(link.toSocket);
if (to.type === "OUTPUT") return from.type === "PRINCIPLED" && fromSocket === "bsdf" && toSocket === "surface";
if (to.type === "PRINCIPLED") {
if (toSocket === "basecolor") return (from.type === "RGB" && fromSocket === "color") || (from.type === "IMAGE_TEXTURE" && fromSocket === "color");
if (toSocket === "normal") return from.type === "NORMAL_MAP" && fromSocket === "normal";
if (toSocket === "basecolor") {
return (from.type === "RGB" || from.type === "MIX" || from.type === "IMAGE_TEXTURE" || from.type === "SEPARATE_COLOR") && (fromSocket === "color" || fromSocket === "result");
}
if (toSocket === "normal") return (from.type === "NORMAL_MAP" || from.type === "BUMP") && fromSocket === "normal";
return targetScalarSocket(toSocket) && isScalarSource(from) && (from.type !== "MATH" || fromSocket === "value");
}
if (to.type === "NORMAL_MAP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "color";
if (to.type === "BUMP") return from.type === "IMAGE_TEXTURE" && fromSocket === "color" && toSocket === "height";
if (to.type === "MATH") return isScalarSource(from) && scalarSocket(toSocket) && fromSocket === "value";
if (to.type === "IMAGE_TEXTURE") {
return toSocket === "vector" && (
(from.type === "MAPPING" && fromSocket === "vector") ||
(from.type === "UV_MAP" && (fromSocket === "uv" || fromSocket === "uvmap")) ||
(from.type === "TEX_COORD" && fromSocket === "uv")
);
}
if (to.type === "MAPPING") {
return toSocket === "vector" && (
(from.type === "UV_MAP" && (fromSocket === "uv" || fromSocket === "uvmap")) ||
(from.type === "TEX_COORD" && fromSocket === "uv")
);
}
if (to.type === "MIX") {
if (toSocket === "factor") return isScalarSource(from);
if (toSocket === "a" || toSocket === "b") return isScalarSource(from) || from.type === "RGB" || from.type === "IMAGE_TEXTURE";
return false;
}
if (to.type === "SEPARATE_COLOR") return toSocket === "color" && from.type === "RGB";
return false;
};
for (const [index, link] of links.entries()) if (!supportedLink(link)) base.issues.push(issue("SHADER_NODE_UNSUPPORTED", `Shader link is outside the M10-07 compiled closure: ${link.fromNodeId}.${link.fromSocket} -> ${link.toNodeId}.${link.toSocket}`, `links.${index}`));

View File

@@ -13,6 +13,7 @@ export type ShaderNodeEdit =
| {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 {
@@ -83,8 +84,33 @@ function socket(value: unknown, path: string): value is ShaderSocketIR {
return true;
}
const supportedTypes = new Set<ShaderNodeType>(["RGB", "VALUE", "MATH", "IMAGE_TEXTURE", "NORMAL_MAP", "PRINCIPLED", "MATERIAL_OUTPUT"]);
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");
@@ -117,13 +143,24 @@ export function validateShaderGraph(graph: ShaderGraphIR, context: ShaderGraphVa
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)) {
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` });
}
}
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) {

View File

@@ -4,6 +4,7 @@ import type { RecentProjectIssue, RecentProjectRecord } from "./recent-projects"
import type { StorageBudgetBreakdown } from "./storage-budget";
import type { ErrorCode } from "./error";
import type { TexturePaintTileBindingRequestIR, TexturePaintTileBindingResultIR, TexturePaintTileCommitIR, TexturePaintTileCommitResultIR } from "./texture-paint-asset";
import type { AssetCatalogManifestV2IR, AssetCatalogV2PreviewIR } from "./asset-catalog-v2";
export interface StorageSmokeResult {
backend: "indexeddb";
@@ -141,6 +142,39 @@ export interface StorageAssetListResult {
assets: StorageAssetRecord[];
}
export interface StorageAssetCatalogSeedIR {
schemaVersion: 1;
seedId: string;
revision: number;
librarySha256: string;
sourcePath: string;
}
export interface StorageAssetCatalogResult {
manifest: AssetCatalogManifestV2IR | null;
seed: StorageAssetCatalogSeedIR | null;
}
export interface StorageAssetCatalogPreviewReadResult {
projectId: string;
sha256: string;
data: ArrayBuffer;
}
export interface StorageAssetCatalogCommitResult {
manifest: AssetCatalogManifestV2IR;
preview: AssetCatalogV2PreviewIR;
deduplicated: boolean;
receipt: {
schemaVersion: number;
assetId: string;
committedRevision: number;
contentSha256: string;
contentByteLength: number;
opfsPath: string;
};
}
export interface StorageLODResult {
projectId: string;
cacheKey: string;
@@ -261,6 +295,10 @@ export interface StorageRequest {
| { type: "putAsset"; projectId: string; data: ArrayBuffer; mimeType: string; sourcePath?: string; faultAt?: "quota" }
| { type: "readAsset"; projectId: string; sha256: string }
| { type: "listAssets"; projectId: string }
| { type: "getAssetCatalog" }
| { type: "seedAssetCatalog"; projectId: string; manifest: unknown; library: ArrayBuffer; previews: Array<{ sha256: string; data: ArrayBuffer }>; seedId: string; repair?: boolean }
| { type: "readAssetCatalogPreview"; projectId: string; sha256: string }
| { type: "commitAssetCatalogPreview"; projectId: string; expectedRevision: number; identity: unknown; data: ArrayBuffer }
| { type: "commitTexturePaintTile"; commit: TexturePaintTileCommitIR }
| { type: "readTexturePaintTileBinding"; request: TexturePaintTileBindingRequestIR }
| { type: "saveLOD"; projectId: string; cacheKey: string; data: ArrayBuffer }
@@ -283,7 +321,7 @@ export interface StorageRequest {
export interface StorageResponse {
requestId: string;
ok: boolean;
result?: StorageSmokeResult | StorageInfoResult | StorageBudgetResult | StorageRecentProjectsResult | StorageProjectResult | StorageProjectCleanupResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | TexturePaintTileCommitResultIR | TexturePaintTileBindingResultIR | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCachePlaybackReadyResult | StorageSimulationCachePlaybackReleaseResult | StorageSimulationCachePruneResult | StorageSimulationCacheListResult;
result?: StorageSmokeResult | StorageInfoResult | StorageBudgetResult | StorageRecentProjectsResult | StorageProjectResult | StorageProjectCleanupResult | StorageSaveResult | StorageRecoveryResult | StorageProjectReadResult | StorageOperationResult | StorageOperationListResult | StorageOperationPruneResult | StorageSnapshotResult | StorageSnapshotListResult | StorageSnapshotReadResult | StorageAssetPutResult | StorageAssetReadResult | StorageAssetListResult | StorageAssetCatalogResult | StorageAssetCatalogPreviewReadResult | StorageAssetCatalogCommitResult | TexturePaintTileCommitResultIR | TexturePaintTileBindingResultIR | StorageLODResult | StorageLODManifestResult | StorageLODManifestListResult | StorageLODReadResult | StorageLODPruneResult | StorageSimulationCacheResult | StorageSimulationCacheReadResult | StorageSimulationCacheFrameReadResult | StorageSimulationCachePlaybackReadyResult | StorageSimulationCachePlaybackReleaseResult | StorageSimulationCachePruneResult | StorageSimulationCacheListResult;
error?: string;
errorCode?: ErrorCode;
}

View File

@@ -25,6 +25,7 @@ export type EditorType =
| "Compositor"
| "Geometry Nodes"
| "File Browser"
| "Asset Library"
| "Spreadsheet"
| "Text Editor"
| "Python Console"