Advance M8-M11 parity workflows
This commit is contained in:
@@ -2,10 +2,77 @@ 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 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;
|
||||
@@ -75,7 +142,8 @@ export class GeometryNodeGraphError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
const supportedNodeTypes = new Set([
|
||||
export const GEOMETRY_NODE_ALLOWLIST_SCHEMA = 1 as const;
|
||||
export const GEOMETRY_NODE_ALLOWLIST = Object.freeze([
|
||||
"NodeGroupInput",
|
||||
"NodeGroupOutput",
|
||||
"GeometryNodeTransform",
|
||||
@@ -85,14 +153,15 @@ const supportedNodeTypes = new Set([
|
||||
"GeometryNodeRealizeInstances",
|
||||
"GeometryNodeStoreNamedAttribute",
|
||||
"FunctionNodeInputInt",
|
||||
"FunctionNodeInputFloat",
|
||||
"FunctionNodeInputVector",
|
||||
"FunctionNodeCompare",
|
||||
"ShaderNodeValue",
|
||||
"ShaderNodeMath",
|
||||
"GeometryNodeObjectInfo",
|
||||
"GeometryNodeCollectionInfo",
|
||||
"GeometryNodeImageInfo",
|
||||
]);
|
||||
] as const);
|
||||
const supportedNodeTypes = new Set<string>(GEOMETRY_NODE_ALLOWLIST);
|
||||
|
||||
const externalResourceNodeTypes = new Set([
|
||||
"GeometryNodeObjectInfo",
|
||||
@@ -109,35 +178,262 @@ 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) || 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`);
|
||||
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");
|
||||
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);
|
||||
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) || 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`);
|
||||
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"))) throw new GeometryNodeGraphError("GN_INVALID_GRAPH", "groupReferences must contain strings", "groupReferences");
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -222,6 +518,9 @@ export function validateGeometryNodeGraphSet(values: readonly unknown[]): Geomet
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user