283 lines
15 KiB
TypeScript
283 lines
15 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 type GeometryNodeDataType = "BOOLEAN" | "INT" | "FLOAT" | "VECTOR" | "STRING" | "GEOMETRY" | "INSTANCE" | "OBJECT" | "IMAGE";
|
|
export type GeometryNodeDomain = "POINT" | "EDGE" | "FACE" | "CORNER" | "INSTANCE";
|
|
export type GeometryNodeSocketDirection = "INPUT" | "OUTPUT";
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
const supportedNodeTypes = new Set([
|
|
"NodeGroupInput",
|
|
"NodeGroupOutput",
|
|
"GeometryNodeTransform",
|
|
"GeometryNodeSetPosition",
|
|
"GeometryNodeJoinGeometry",
|
|
"GeometryNodeSeparateGeometry",
|
|
"GeometryNodeRealizeInstances",
|
|
"GeometryNodeStoreNamedAttribute",
|
|
"FunctionNodeInputInt",
|
|
"FunctionNodeInputFloat",
|
|
"FunctionNodeInputVector",
|
|
"FunctionNodeCompare",
|
|
"ShaderNodeMath",
|
|
"GeometryNodeObjectInfo",
|
|
"GeometryNodeCollectionInfo",
|
|
"GeometryNodeImageInfo",
|
|
]);
|
|
|
|
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);
|
|
}
|
|
|
|
function validSocket(value: unknown, path: string): value is GeometryNodeSocketIR {
|
|
if (!record(value) || typeof value.id !== "string" || typeof value.name !== "string" || !["INPUT", "OUTPUT"].includes(value.direction as string) || !["BOOLEAN", "INT", "FLOAT", "VECTOR", "STRING", "GEOMETRY", "INSTANCE", "OBJECT", "IMAGE"].includes(value.dataType as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path} is not a valid socket`, path);
|
|
if (value.domain !== undefined && !["POINT", "EDGE", "FACE", "CORNER", "INSTANCE"].includes(value.domain as string)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.domain is invalid`, `${path}.domain`);
|
|
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 GeometryNodeGraphError("GN_INVALID_GRAPH", `${path}.defaultValue is invalid`, `${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");
|
|
for (const field of ["id", "name"] as const) if (typeof value[field] !== "string" || value[field].length === 0) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `${field} is required`, field);
|
|
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");
|
|
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`);
|
|
});
|
|
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`);
|
|
});
|
|
value.nodes.forEach((node, index) => {
|
|
if (!record(node) || typeof node.id !== "string" || typeof node.type !== "string" || typeof node.name !== "string" || !Array.isArray(node.sockets)) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}] is invalid`, `nodes[${index}]`);
|
|
node.sockets.forEach((socket, socketIndex) => validSocket(socket, `nodes[${index}].sockets[${socketIndex}]`));
|
|
if (node.groupTreeId !== undefined && node.groupTreeId !== null && typeof node.groupTreeId !== "string") throw new GeometryNodeGraphError("GN_INVALID_GRAPH", `nodes[${index}].groupTreeId is invalid`, `nodes[${index}].groupTreeId`);
|
|
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 GeometryNodeGraphError("GN_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 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"))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain strings", "groupReferences");
|
|
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>();
|
|
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)]);
|
|
}
|
|
}
|