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); 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; 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; } 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; blockedResourceIds?: ReadonlySet; 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(GEOMETRY_NODE_ALLOWLIST); const externalResourceNodeTypes = new Set([ "GeometryNodeObjectInfo", "GeometryNodeCollectionInfo", "GeometryNodeImageInfo", ]); const externalResourcePrefixes: Readonly> = { GeometryNodeObjectInfo: "object:", GeometryNodeCollectionInfo: "collection:", GeometryNodeImageInfo: "image:", }; function record(value: unknown): value is Record { 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(geometryNodeDomains); const fieldLayout: Readonly> = { 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, 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(); 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([ "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(); 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(); 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(); 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(); const sockets = new Map(); 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(); 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(); 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(); 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(); 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(); 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)]); } }