Files
workinf_Blender_Wasm/web/protocol/geometry-nodes.ts
mes123456 0fe8d2bb56
Some checks are pending
M6 deployable RC / quick (push) Waiting to run
M6 deployable RC / chromium (push) Blocked by required conditions
M6 deployable RC / release (push) Blocked by required conditions
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

582 lines
30 KiB
TypeScript

import type { ErrorCode } from "./error";
import { blockedGate, capabilityIssue, readyGate, type CapabilityGateResult } from "./capability-gates";
export const GEOMETRY_NODE_GRAPH_SCHEMA = 1 as const;
export const GEOMETRY_NODE_GRAPH_BUDGET = Object.freeze({
maxGraphs: 4_096,
maxNodesPerGraph: 4_096,
maxLinksPerGraph: 16_384,
maxSocketsPerGraph: 65_536,
maxInterfaceSocketsPerGraph: 4_096,
maxIdentifierBytes: 256,
maxNameBytes: 1_024,
});
export type GeometryNodeDataType =
| "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "INT_VECTOR" | "COLOR" | "STRING"
| "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE" | "COLLECTION" | "TEXTURE"
| "MATERIAL" | "ROTATION" | "MENU" | "MATRIX" | "SHADER" | "BUNDLE" | "CLOSURE"
| "FONT" | "SCENE" | "TEXT" | "MASK" | "SOUND" | "CUSTOM";
export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "CURVE" | "INSTANCE" | "LAYER";
export type GeometryNodeSocketDirection = "INPUT" | "OUTPUT";
export const GEOMETRY_NODE_FIELD_SCHEMA = 1 as const;
export const GEOMETRY_NODE_FIELD_DOMAIN_BUDGET = Object.freeze({
POINT: 1_000_000,
EDGE: 2_000_000,
FACE: 2_000_000,
CORNER: 4_000_000,
CURVE: 100_000,
INSTANCE: 100_000,
LAYER: 4_096,
} satisfies Record<GeometryNodeDomain, number>);
export const GEOMETRY_NODE_FIELD_BUDGET = Object.freeze({
maxFieldsPerBatch: 64,
maxDomainConversionsPerBatch: 32,
maxMaterializedElementsPerBatch: 4_000_000,
maxMaterializedBytesPerBatch: 64 * 1024 * 1024,
maxJsonScalarValuesPerField: 65_536,
maxIdentifierBytes: 256,
});
export type GeometryNodeFieldDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "COLOR";
export type GeometryNodeFieldSourceDomain = GeometryNodeDomain | "CONSTANT";
export type GeometryNodeFieldTransport = "JSON" | "BINARY";
export type GeometryNodeDomainCardinalityIR = Record<GeometryNodeDomain, number>;
export interface GeometryNodeFieldMaterializationIR {
schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA;
graphId: string;
graphHash: string;
fieldId: string;
revision: number;
sourceDomain: GeometryNodeFieldSourceDomain;
targetDomain: GeometryNodeDomain;
dataType: GeometryNodeFieldDataType;
transport: GeometryNodeFieldTransport;
domainCardinality: GeometryNodeDomainCardinalityIR;
}
export interface GeometryNodeFieldMaterializationReceiptIR extends GeometryNodeFieldMaterializationIR {
sourceElementCount: number;
targetElementCount: number;
scalarValueCount: number;
materializedByteLength: number;
domainConversion: boolean;
}
export interface GeometryNodeFieldMaterializationBatchIR {
schemaVersion: typeof GEOMETRY_NODE_FIELD_SCHEMA;
fields: GeometryNodeFieldMaterializationReceiptIR[];
fieldCount: number;
domainConversionCount: number;
materializedElementCount: number;
materializedByteLength: number;
}
export interface GeometryNodeSocketIR {
id: string;
name: string;
direction: GeometryNodeSocketDirection;
dataType: GeometryNodeDataType;
domain?: GeometryNodeDomain;
defaultValue?: boolean | number | string | number[];
}
export interface GeometryNodeIR {
id: string;
type: string;
name: string;
sockets: GeometryNodeSocketIR[];
groupTreeId?: string | null;
properties?: Record<string, boolean | number | string | number[]>;
}
export interface GeometryNodeLinkIR {
fromNodeId: string;
fromSocketId: string;
toNodeId: string;
toSocketId: string;
}
export interface GeometryNodeGraphIR {
schemaVersion: typeof GEOMETRY_NODE_GRAPH_SCHEMA;
id: string;
name: string;
interfaceInputs: GeometryNodeSocketIR[];
interfaceOutputs: GeometryNodeSocketIR[];
nodes: GeometryNodeIR[];
links: GeometryNodeLinkIR[];
groupReferences?: string[];
graphHash?: string;
}
export interface GeometryNodeGraphValidation {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
supportedNodes: string[];
unsupportedNodes: string[];
cycles: string[][];
}
export interface GeometryNodeGraphSetValidation {
status: "SUPPORTED" | "BLOCKED";
issues: Array<{ code: ErrorCode; message: string; path?: string }>;
cycles: string[][];
}
export interface GeometryNodeResourceContext {
availableResourceIds: ReadonlySet<string>;
blockedResourceIds?: ReadonlySet<string>;
ownerObjectId?: string;
}
export class GeometryNodeGraphError extends Error {
readonly code: ErrorCode;
readonly path?: string;
constructor(code: ErrorCode, message: string, path?: string) {
super(message);
this.name = "GeometryNodeGraphError";
this.code = code;
this.path = path;
}
}
export const GEOMETRY_NODE_ALLOWLIST_SCHEMA = 1 as const;
export const GEOMETRY_NODE_ALLOWLIST = Object.freeze([
"NodeGroupInput",
"NodeGroupOutput",
"GeometryNodeTransform",
"GeometryNodeSetPosition",
"GeometryNodeJoinGeometry",
"GeometryNodeSeparateGeometry",
"GeometryNodeRealizeInstances",
"GeometryNodeStoreNamedAttribute",
"FunctionNodeInputInt",
"FunctionNodeInputVector",
"FunctionNodeCompare",
"ShaderNodeValue",
"ShaderNodeMath",
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo",
] as const);
const supportedNodeTypes = new Set<string>(GEOMETRY_NODE_ALLOWLIST);
const externalResourceNodeTypes = new Set([
"GeometryNodeObjectInfo",
"GeometryNodeCollectionInfo",
"GeometryNodeImageInfo",
]);
const externalResourcePrefixes: Readonly<Record<string, string>> = {
GeometryNodeObjectInfo: "object:",
GeometryNodeCollectionInfo: "collection:",
GeometryNodeImageInfo: "image:",
};
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const geometryNodeDomains = Object.freeze([
"POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER",
] as const);
const geometryNodeDomainSet = new Set<string>(geometryNodeDomains);
const fieldLayout: Readonly<Record<GeometryNodeFieldDataType, { components: number; bytesPerComponent: number }>> = {
BOOLEAN: { components: 1, bytesPerComponent: 1 },
INT: { components: 1, bytesPerComponent: 4 },
FLOAT: { components: 1, bytesPerComponent: 4 },
VECTOR: { components: 3, bytesPerComponent: 4 },
COLOR: { components: 4, bytesPerComponent: 4 },
};
function exactKeys(value: Record<string, unknown>, allowed: readonly string[], path: string): void {
const allowedSet = new Set(allowed);
const unexpected = Object.keys(value).filter((key) => !allowedSet.has(key));
if (unexpected.length > 0) {
const code: ErrorCode = unexpected.some((key) => key === "values" || key === "jsonValues") ?
"GN_FIELD_JSON_BUDGET_EXCEEDED" : "GN_INVALID_GRAPH";
throw new GeometryNodeGraphError(code, `${path} contains undeclared fields: ${unexpected.join(", ")}`, path);
}
}
function safeProduct(values: readonly number[], path: string): number {
let result = 1;
for (const value of values) {
if (!Number.isSafeInteger(value) || value < 0 || (value !== 0 && result > Number.MAX_SAFE_INTEGER / value)) {
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} overflows its numeric budget`, path);
}
result *= value;
}
return result;
}
export function parseGeometryNodeDomainCardinality(
value: unknown,
path = "domainCardinality",
): GeometryNodeDomainCardinalityIR {
if (!record(value)) {
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path} must declare every domain`, path);
}
const unexpected = Object.keys(value).filter((domain) => !geometryNodeDomainSet.has(domain));
if (unexpected.length > 0) {
throw new GeometryNodeGraphError(
"GN_DOMAIN_CARDINALITY_MISMATCH",
`${path} contains undeclared domains: ${unexpected.join(", ")}`,
path,
);
}
const result = {} as GeometryNodeDomainCardinalityIR;
for (const domain of geometryNodeDomains) {
const count = value[domain];
if (!Number.isSafeInteger(count) || (count as number) < 0) {
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.${domain} is not a non-negative integer`, `${path}.${domain}`);
}
if ((count as number) > GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]) {
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path}.${domain} exceeds ${GEOMETRY_NODE_FIELD_DOMAIN_BUDGET[domain]}`, `${path}.${domain}`);
}
result[domain] = count as number;
}
return result;
}
export function parseGeometryNodeFieldMaterialization(
value: unknown,
path = "field",
): GeometryNodeFieldMaterializationReceiptIR {
if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_FIELD_SCHEMA) {
throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported Geometry Node field materialization schema", path);
}
exactKeys(value, [
"schemaVersion", "graphId", "graphHash", "fieldId", "revision", "sourceDomain",
"targetDomain", "dataType", "transport", "domainCardinality",
], path);
boundedText(value.graphId, `${path}.graphId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes);
boundedText(value.fieldId, `${path}.fieldId`, GEOMETRY_NODE_FIELD_BUDGET.maxIdentifierBytes);
if (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash)) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.graphHash must be a lowercase SHA-256`, `${path}.graphHash`);
}
if (!Number.isSafeInteger(value.revision) || (value.revision as number) < 0) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.revision is invalid`, `${path}.revision`);
}
if (value.sourceDomain !== "CONSTANT" && !geometryNodeDomainSet.has(value.sourceDomain as string)) {
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.sourceDomain is invalid`, `${path}.sourceDomain`);
}
if (!geometryNodeDomainSet.has(value.targetDomain as string)) {
throw new GeometryNodeGraphError("GN_DOMAIN_CARDINALITY_MISMATCH", `${path}.targetDomain is invalid`, `${path}.targetDomain`);
}
if (!Object.hasOwn(fieldLayout, value.dataType as PropertyKey)) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.dataType is not materializable`, `${path}.dataType`);
}
if (value.transport !== "JSON" && value.transport !== "BINARY") {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.transport is invalid`, `${path}.transport`);
}
const domainCardinality = parseGeometryNodeDomainCardinality(value.domainCardinality, `${path}.domainCardinality`);
const sourceDomain = value.sourceDomain as GeometryNodeFieldSourceDomain;
const targetDomain = value.targetDomain as GeometryNodeDomain;
const dataType = value.dataType as GeometryNodeFieldDataType;
const layout = fieldLayout[dataType];
const sourceElementCount = sourceDomain === "CONSTANT" ? 1 : domainCardinality[sourceDomain];
const targetElementCount = domainCardinality[targetDomain];
const scalarValueCount = safeProduct([targetElementCount, layout.components], `${path}.scalarValueCount`);
const materializedByteLength = safeProduct([scalarValueCount, layout.bytesPerComponent], `${path}.materializedByteLength`);
if (materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch) {
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", `${path} exceeds the field byte budget`, path);
}
if (value.transport === "JSON" && scalarValueCount > GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField) {
throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} must use binary transport above ${GEOMETRY_NODE_FIELD_BUDGET.maxJsonScalarValuesPerField} scalar values`, `${path}.transport`);
}
return {
schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA,
graphId: value.graphId as string,
graphHash: value.graphHash,
fieldId: value.fieldId as string,
revision: value.revision as number,
sourceDomain,
targetDomain,
dataType,
transport: value.transport,
domainCardinality,
sourceElementCount,
targetElementCount,
scalarValueCount,
materializedByteLength,
domainConversion: sourceDomain !== "CONSTANT" && sourceDomain !== targetDomain,
};
}
export function parseGeometryNodeFieldMaterializationBatch(
values: unknown,
): GeometryNodeFieldMaterializationBatchIR {
if (!Array.isArray(values)) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Geometry Node field batch must be an array", "fields");
}
if (values.length > GEOMETRY_NODE_FIELD_BUDGET.maxFieldsPerBatch) {
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds 64 fields", "fields");
}
const fields = values.map((value, index) => parseGeometryNodeFieldMaterialization(value, `fields[${index}]`));
const identities = new Set<string>();
let domainConversionCount = 0;
let materializedElementCount = 0;
let materializedByteLength = 0;
for (const [index, field] of fields.entries()) {
const identity = `${field.graphId}:${field.fieldId}`;
if (identities.has(identity)) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate field materialization: ${identity}`, `fields[${index}].fieldId`);
}
identities.add(identity);
domainConversionCount += field.domainConversion ? 1 : 0;
materializedElementCount += field.targetElementCount;
materializedByteLength += field.materializedByteLength;
}
if (domainConversionCount > GEOMETRY_NODE_FIELD_BUDGET.maxDomainConversionsPerBatch ||
materializedElementCount > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedElementsPerBatch ||
materializedByteLength > GEOMETRY_NODE_FIELD_BUDGET.maxMaterializedBytesPerBatch)
{
throw new GeometryNodeGraphError("GN_FIELD_BUDGET_EXCEEDED", "Geometry Node field batch exceeds its aggregate materialization budget", "fields");
}
return {
schemaVersion: GEOMETRY_NODE_FIELD_SCHEMA,
fields,
fieldCount: fields.length,
domainConversionCount,
materializedElementCount,
materializedByteLength,
};
}
const geometryNodeDataTypes = new Set<GeometryNodeDataType>([
"BOOLEAN", "INT", "FLOAT", "VECTOR", "INT_VECTOR", "COLOR", "STRING", "GEOMETRY",
"INSTANCE", "OBJECT", "IMAGE", "COLLECTION", "TEXTURE", "MATERIAL", "ROTATION",
"MENU", "MATRIX", "SHADER", "BUNDLE", "CLOSURE", "FONT", "SCENE", "TEXT", "MASK",
"SOUND", "CUSTOM",
]);
function boundedText(value: unknown, path: string, maximum: number, allowEmpty = false): value is string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0) || new TextEncoder().encode(value).byteLength > maximum) {
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is outside the string budget`, path);
}
return true;
}
function boundedLiteral(value: unknown, path: string): boolean {
if (typeof value === "boolean") return true;
if (typeof value === "number") {
if (Number.isFinite(value)) return true;
throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not finite`, path);
}
if (typeof value === "string") {
boundedText(value, path, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
return true;
}
if (Array.isArray(value) && value.length <= 16 && value.every((item) => typeof item === "number" && Number.isFinite(item))) return true;
throw new GeometryNodeGraphError("GN_FIELD_JSON_BUDGET_EXCEEDED", `${path} is outside the bounded literal array budget`, path);
}
function validSocket(value: unknown, path: string): value is GeometryNodeSocketIR {
if (!record(value) || !["INPUT", "OUTPUT"].includes(value.direction as string) || !geometryNodeDataTypes.has(value.dataType as GeometryNodeDataType)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path);
boundedText(value.id, `${path}.id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
boundedText(value.name, `${path}.name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "CURVE", "INSTANCE", "LAYER"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`);
if (value.defaultValue !== undefined) boundedLiteral(value.defaultValue, `${path}.defaultValue`);
return true;
}
export function parseGeometryNodeGraph(value: unknown): GeometryNodeGraphIR {
if (!record(value) || value.schemaVersion !== GEOMETRY_NODE_GRAPH_SCHEMA) throw new GeometryNodeGraphError("PROTOCOL_MISMATCH", "Unsupported GeometryNodeGraph schema");
boundedText(value.id, "id", GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
boundedText(value.name, "name", GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes);
if (!Array.isArray(value.interfaceInputs) || !Array.isArray(value.interfaceOutputs) || !Array.isArray(value.nodes) || !Array.isArray(value.links)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "Graph arrays are required");
if (value.interfaceInputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.interfaceOutputs.length > GEOMETRY_NODE_GRAPH_BUDGET.maxInterfaceSocketsPerGraph || value.nodes.length > GEOMETRY_NODE_GRAPH_BUDGET.maxNodesPerGraph || value.links.length > GEOMETRY_NODE_GRAPH_BUDGET.maxLinksPerGraph) {
throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its topology budget");
}
const interfaceSocketIds = new Set<string>();
value.interfaceInputs.forEach((socket, index) => {
validSocket(socket, `interfaceInputs[${index}]`);
if (record(socket) && socket.direction !== "INPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface input must be an INPUT socket", `interfaceInputs[${index}].direction`);
if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceInputs[${index}].id`);
if (record(socket)) interfaceSocketIds.add(socket.id as string);
});
value.interfaceOutputs.forEach((socket, index) => {
validSocket(socket, `interfaceOutputs[${index}]`);
if (record(socket) && socket.direction !== "OUTPUT") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "interface output must be an OUTPUT socket", `interfaceOutputs[${index}].direction`);
if (record(socket) && interfaceSocketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate interface socket ID: ${String(socket.id)}`, `interfaceOutputs[${index}].id`);
if (record(socket)) interfaceSocketIds.add(socket.id as string);
});
let socketCount = 0;
const nodeIds = new Set<string>();
value.nodes.forEach((node, index) => {
if (!record(node) || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
boundedText(node.id, `nodes[${index}].id`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
boundedText(node.type, `nodes[${index}].type`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
boundedText(node.name, `nodes[${index}].name`, GEOMETRY_NODE_GRAPH_BUDGET.maxNameBytes, true);
if (nodeIds.has(node.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate node ID: ${String(node.id)}`, `nodes[${index}].id`);
nodeIds.add(node.id as string);
const socketIds = new Set<string>();
node.sockets.forEach((socket, socketIndex) => {
validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`);
socketCount++;
if (socketCount > GEOMETRY_NODE_GRAPH_BUDGET.maxSocketsPerGraph) throw new GeometryNodeGraphError("GN_GRAPH_BUDGET_EXCEEDED", "Geometry Node graph exceeds its socket budget", `nodes[${index}].sockets`);
if (record(socket) && socketIds.has(socket.id as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `duplicate socket ID: ${String(node.id)}:${String(socket.id)}`, `nodes[${index}].sockets[${socketIndex}].id`);
if (record(socket)) socketIds.add(socket.id as string);
});
if (node.groupTreeId !== undefined && node.groupTreeId !== null) boundedText(node.groupTreeId, `nodes[${index}].groupTreeId`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
if (node.properties !== undefined) {
if (!record(node.properties) || Object.keys(node.properties).length > 64) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].properties is invalid`, `nodes[${index}].properties`);
for (const [name, property] of Object.entries(node.properties)) {
boundedText(name, `nodes[${index}].properties.${name}`, GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes);
boundedLiteral(property, `nodes[${index}].properties.${name}`);
}
}
});
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 GeometryNodeGraphError("GN_INVALID_GRAPH", `links[${index}] is invalid`, `links[${index}]`);
});
if (value.groupReferences !== undefined && (!Array.isArray(value.groupReferences) || value.groupReferences.some((item) => typeof item !== "string" || item.length === 0 || new TextEncoder().encode(item).byteLength > GEOMETRY_NODE_GRAPH_BUDGET.maxIdentifierBytes) || new Set(value.groupReferences).size !== value.groupReferences.length)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain unique bounded strings", "groupReferences");
if (value.graphHash !== undefined && (typeof value.graphHash !== "string" || !/^[0-9a-f]{64}$/.test(value.graphHash))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "graphHash must be a lowercase SHA-256", "graphHash");
return value as unknown as GeometryNodeGraphIR;
}
function compatible(from: GeometryNodeSocketIR, to: GeometryNodeSocketIR): boolean {
if (from.direction !== "OUTPUT" || to.direction !== "INPUT") return false;
if (from.dataType !== to.dataType && !((from.dataType === "INT" || from.dataType === "FLOAT") && (to.dataType === "INT" || to.dataType === "FLOAT"))) return false;
return from.domain === undefined || to.domain === undefined || from.domain === to.domain;
}
export function validateGeometryNodeGraph(
graph: GeometryNodeGraphIR,
options: { allowResolvedGroups?: boolean; resources?: GeometryNodeResourceContext } = {},
): GeometryNodeGraphValidation {
const issues: GeometryNodeGraphValidation["issues"] = [];
const nodes = new Map<string, GeometryNodeIR>();
const sockets = new Map<string, GeometryNodeSocketIR>();
for (const node of graph.nodes) {
if (nodes.has(node.id)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate node ID: ${node.id}`, path: "nodes" });
nodes.set(node.id, node);
if (!supportedNodeTypes.has(node.type)) issues.push({ code: "GN_NODE_UNSUPPORTED", message: `unsupported node type: ${node.type}`, path: `nodes.${node.id}` });
if (externalResourceNodeTypes.has(node.type)) {
const resourceId = typeof node.properties?.resourceId === "string" ? node.properties.resourceId : "";
const expectedPrefix = externalResourcePrefixes[node.type];
if (resourceId && expectedPrefix && !resourceId.startsWith(expectedPrefix)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource type does not match ${node.type}: ${resourceId}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (!resourceId || !options.resources?.availableResourceIds.has(resourceId)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource is missing: ${resourceId || "none"}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (options.resources.blockedResourceIds?.has(resourceId)) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `Geometry Node resource is blocked by the library/path sandbox: ${resourceId}`, path: `nodes.${node.id}.properties.resourceId` });
}
else if (options.resources.ownerObjectId && resourceId === options.resources.ownerObjectId) {
issues.push({ code: "GN_DEPENDENCY_CYCLE", message: "Geometry Node graph cannot depend on its owner object", path: `nodes.${node.id}.properties.resourceId` });
}
}
for (const socket of node.sockets) {
if (sockets.has(`${node.id}:${socket.id}`)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate socket ID: ${node.id}:${socket.id}`, path: `nodes.${node.id}.sockets` });
sockets.set(`${node.id}:${socket.id}`, socket);
}
}
const edges = new Map<string, 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: "GN_INVALID_GRAPH", message: "link references an unknown socket", path: `links.${index}` });
continue;
}
if (!compatible(from, to)) issues.push({ code: "GN_SOCKET_TYPE_MISMATCH", message: `incompatible link ${link.fromNodeId}:${link.fromSocketId} -> ${link.toNodeId}:${link.toSocketId}`, path: `links.${index}` });
const outgoing = edges.get(link.fromNodeId) ?? [];
outgoing.push(link.toNodeId);
edges.set(link.fromNodeId, outgoing);
}
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: "GN_DEPENDENCY_CYCLE", message: "Geometry Node graph contains a cycle", path: "links" });
if (graph.groupReferences?.includes(graph.id) || graph.nodes.some((node) => node.groupTreeId === graph.id)) issues.push({ code: "GN_GROUP_RECURSION", message: "Geometry Node group recursively references itself", path: "groupReferences" });
if (!options.allowResolvedGroups && (graph.groupReferences?.some((reference) => reference !== graph.id) || graph.nodes.some((node) => node.groupTreeId !== undefined && node.groupTreeId !== null && node.groupTreeId !== graph.id))) {
issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: "Nested Geometry Node groups require an explicit graph set", path: "groupReferences" });
}
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, supportedNodes: graph.nodes.filter((node) => supportedNodeTypes.has(node.type)).map((node) => node.id), unsupportedNodes: graph.nodes.filter((node) => !supportedNodeTypes.has(node.type)).map((node) => node.id), cycles };
}
export function validateGeometryNodeGraphSet(values: readonly unknown[]): GeometryNodeGraphSetValidation {
const issues: GeometryNodeGraphSetValidation["issues"] = [];
const graphs: GeometryNodeGraphIR[] = [];
const graphIds = new Set<string>();
if (values.length > GEOMETRY_NODE_GRAPH_BUDGET.maxGraphs) {
return { status: "BLOCKED", issues: [{ code: "GN_GRAPH_BUDGET_EXCEEDED", message: "Geometry Node graph set exceeds 4096 graphs", path: "graphs" }], cycles: [] };
}
for (const [index, value] of values.entries()) {
try {
const graph = parseGeometryNodeGraph(value);
if (graphIds.has(graph.id)) issues.push({ code: "GN_INVALID_GRAPH", message: `duplicate graph ID: ${graph.id}`, path: `graphs.${index}.id` });
graphIds.add(graph.id);
graphs.push(graph);
}
catch (error) {
const issue = error as GeometryNodeGraphError;
issues.push({ code: issue.code ?? "GN_INVALID_GRAPH", message: issue.message, path: issue.path });
}
}
const dependencies = new Map<string, string[]>();
for (const graph of graphs) {
const references = new Set([...(graph.groupReferences ?? []), ...graph.nodes.flatMap((node) => node.groupTreeId ? [node.groupTreeId] : [])]);
for (const reference of references) {
if (!graphIds.has(reference)) issues.push({ code: "GN_EXTERNAL_RESOURCE_MISSING", message: `missing Geometry Node group: ${reference}`, path: `graphs.${graph.id}.groupReferences` });
dependencies.set(graph.id, [...(dependencies.get(graph.id) ?? []), reference]);
}
}
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 dependencies.get(id) ?? []) if (graphIds.has(next)) visit(next);
path.pop();
state.set(id, 2);
};
for (const graph of graphs) visit(graph.id);
if (cycles.length > 0) issues.push({ code: "GN_GROUP_RECURSION", message: "Geometry Node group set contains recursive references", path: "graphs" });
for (const graph of graphs) {
const validation = validateGeometryNodeGraph(graph, { allowResolvedGroups: true });
issues.push(...validation.issues.map((issue) => ({ ...issue, path: issue.path ? `graphs.${graph.id}.${issue.path}` : `graphs.${graph.id}` })));
}
return { status: issues.length > 0 ? "BLOCKED" : "SUPPORTED", issues, cycles };
}
export function gateGeometryNodeGraph(value: unknown, resources?: GeometryNodeResourceContext): CapabilityGateResult {
try {
const graph = parseGeometryNodeGraph(value);
const validation = validateGeometryNodeGraph(graph, { resources });
if (validation.status === "SUPPORTED") return readyGate("N-012", "GEOMETRY_NODE_GRAPH");
return blockedGate("N-012", "GEOMETRY_NODE_GRAPH", validation.issues.map((issue) => capabilityIssue(issue.code, issue.message, issue.path)));
}
catch (error) {
const issue = error as GeometryNodeGraphError;
return blockedGate("N-012", "GEOMETRY_NODE_GRAPH", [capabilityIssue(issue.code ?? "GN_INVALID_GRAPH", issue.message, issue.path)]);
}
}