export const CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION = 1 as const; export const CURVE_TOPOLOGY_EDITOR_BUDGET = { maxSplines: 65_536, maxPoints: 1_000_000, maxSelectedElements: 100_000, maxAddedSplinesPerOperation: 4_096, maxAddedPointsPerOperation: 100_000, maxPayloadBytes: 64 * 1024 * 1024, maxSubdivideCuts: 64, maxDataIdBytes: 256, maxOperationsPerMainTransaction: 1, } as const; export type CurveTopologySelectionDomain = "NONE" | "POINTS" | "SPLINES" | "POINTS_OR_SPLINES"; export const CURVE_TOPOLOGY_EDITOR_OPERATORS = [ { id: "ADD_SPLINE", blenderOperators: ["CURVE_OT_primitive_bezier_curve_add", "CURVE_OT_primitive_bezier_circle_add", "CURVE_OT_primitive_nurbs_curve_add", "CURVE_OT_primitive_nurbs_circle_add", "CURVE_OT_primitive_nurbs_path_add"], selection: "NONE", parameters: ["splineType", "cyclic"] }, { id: "DECIMATE", blenderOperators: ["CURVE_OT_decimate"], selection: "SPLINES", parameters: ["ratio"] }, { id: "DELETE", blenderOperators: ["CURVE_OT_delete"], selection: "POINTS_OR_SPLINES", parameters: ["mode"] }, { id: "DISSOLVE_VERTICES", blenderOperators: ["CURVE_OT_dissolve_verts"], selection: "POINTS", parameters: [] }, { id: "DUPLICATE", blenderOperators: ["CURVE_OT_duplicate"], selection: "POINTS_OR_SPLINES", parameters: [] }, { id: "EXTRUDE", blenderOperators: ["CURVE_OT_extrude"], selection: "POINTS", parameters: ["position"] }, { id: "MAKE_SEGMENT", blenderOperators: ["CURVE_OT_make_segment"], selection: "POINTS", parameters: [] }, { id: "SEPARATE", blenderOperators: ["CURVE_OT_separate"], selection: "POINTS_OR_SPLINES", parameters: [] }, { id: "SET_HANDLE_TYPE", blenderOperators: ["CURVE_OT_handle_type_set"], selection: "POINTS", parameters: ["handleType"] }, { id: "SET_SPLINE_TYPE", blenderOperators: ["CURVE_OT_spline_type_set"], selection: "SPLINES", parameters: ["splineType"] }, { id: "SPLIT", blenderOperators: ["CURVE_OT_split"], selection: "POINTS", parameters: [] }, { id: "SUBDIVIDE", blenderOperators: ["CURVE_OT_subdivide"], selection: "POINTS_OR_SPLINES", parameters: ["cuts"] }, { id: "SWITCH_DIRECTION", blenderOperators: ["CURVE_OT_switch_direction"], selection: "SPLINES", parameters: [] }, { id: "TOGGLE_CYCLIC", blenderOperators: ["CURVE_OT_cyclic_toggle"], selection: "SPLINES", parameters: [] }, ] as const satisfies readonly { id: string; blenderOperators: readonly string[]; selection: CurveTopologySelectionDomain; parameters: readonly string[]; }[]; export type CurveTopologyOperator = typeof CURVE_TOPOLOGY_EDITOR_OPERATORS[number]["id"]; // Keep this list limited to operators with an independent Main/undo/save/golden gate. export const CURVE_TOPOLOGY_VERIFIED_OPERATORS = ["TOGGLE_CYCLIC"] as const satisfies readonly CurveTopologyOperator[]; export interface CurveTopologyOperationClaimIR { schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; operator: CurveTopologyOperator; dataId: string; baseRevision: number; inputSplineCount: number; inputPointCount: number; selectedSplineIndices: number[]; selectedPointIndices: number[]; addedSplineCount: number; addedPointCount: number; outputSplineCount: number; outputPointCount: number; payloadBytes: number; subdivideCuts?: number; } export interface CurveTopologyOperatorGateIR { operator: CurveTopologyOperator; status: "READY" | "BLOCKED"; reasonCode?: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED"; } export interface CurveTopologyEditorManifestIR { schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc"; atomicMainTransaction: true; budget: typeof CURVE_TOPOLOGY_EDITOR_BUDGET; operators: Array<{ id: CurveTopologyOperator; blenderOperators: string[]; selection: CurveTopologySelectionDomain; parameters: string[]; gate: CurveTopologyOperatorGateIR; }>; } export interface CurveToggleCyclicOperationInputIR { schemaVersion: typeof CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION; dataId: string; baseRevision: number; splineIndex: number; splineCount: number; pointCount: number; cyclicU: boolean[]; } export interface CurveToggleCyclicMainCommandIR { type: "setCurveTopology"; dataId: string; baseRevision: number; cyclicU: boolean[]; } export interface CurveToggleCyclicOperationIR { operator: "TOGGLE_CYCLIC"; splineIndex: number; previousCyclic: boolean; nextCyclic: boolean; claim: CurveTopologyOperationClaimIR; command: CurveToggleCyclicMainCommandIR; } export class CurveTopologyEditorValidationError extends Error { constructor( readonly code: "NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED" | "NON_MESH_DATA_BUDGET_EXCEEDED" | "NON_MESH_PROPERTY_INVALID" | "REVISION_CONFLICT", message: string, ) { super(`${code}: ${message}`); this.name = "CurveTopologyEditorValidationError"; } } const OPERATOR_BY_ID = new Map( CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => [operator.id, operator]), ); const VERIFIED = new Set(CURVE_TOPOLOGY_VERIFIED_OPERATORS); const CLAIM_FIELDS = new Set([ "schemaVersion", "operator", "dataId", "baseRevision", "inputSplineCount", "inputPointCount", "selectedSplineIndices", "selectedPointIndices", "addedSplineCount", "addedPointCount", "outputSplineCount", "outputPointCount", "payloadBytes", "subdivideCuts", ]); function fail(code: CurveTopologyEditorValidationError["code"], message: string): never { throw new CurveTopologyEditorValidationError(code, message); } function integer(value: unknown, field: string, maximum: number): number { if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0 || value > maximum) { fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen Curve topology budget`); } return value; } function selection(value: unknown, field: string, inputCount: number): number[] { if (!Array.isArray(value) || value.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) { fail("NON_MESH_DATA_BUDGET_EXCEEDED", `${field} exceeds the frozen selection budget`); } if (inputCount === 0 && value.length !== 0) { fail("NON_MESH_PROPERTY_INVALID", `${field} cannot select an empty input domain`); } const result = value.map((item) => integer(item, field, Math.max(0, inputCount - 1))); if (result.some((item, index) => index > 0 && item <= result[index - 1])) { fail("NON_MESH_PROPERTY_INVALID", `${field} must be strictly increasing and duplicate-free`); } return result; } function assertSelectionDomain( domain: CurveTopologySelectionDomain, selectedSplines: readonly number[], selectedPoints: readonly number[], ): void { if (domain === "NONE" && (selectedSplines.length !== 0 || selectedPoints.length !== 0)) { fail("NON_MESH_PROPERTY_INVALID", "operator does not accept a selection"); } if (domain === "POINTS" && (selectedPoints.length === 0 || selectedSplines.length !== 0)) { fail("NON_MESH_PROPERTY_INVALID", "operator requires only a point selection"); } if (domain === "SPLINES" && (selectedSplines.length === 0 || selectedPoints.length !== 0)) { fail("NON_MESH_PROPERTY_INVALID", "operator requires only a spline selection"); } if (domain === "POINTS_OR_SPLINES" && ((selectedPoints.length === 0) === (selectedSplines.length === 0))) { fail("NON_MESH_PROPERTY_INVALID", "operator requires exactly one selection domain"); } } export function curveTopologyOperatorGate(operator: CurveTopologyOperator): CurveTopologyOperatorGateIR { if (!OPERATOR_BY_ID.has(operator)) fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(operator)} is not allowlisted`); return VERIFIED.has(operator) ? { operator, status: "READY" } : { operator, status: "BLOCKED", reasonCode: "CURVE_TOPOLOGY_OPERATOR_NOT_VERIFIED" }; } export function createCurveTopologyEditorManifest(): CurveTopologyEditorManifestIR { return { schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, sourceAuthority: "blender-5.2.0/source/blender/editors/curve/curve_ops.cc", atomicMainTransaction: true, budget: { ...CURVE_TOPOLOGY_EDITOR_BUDGET }, operators: CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => ({ id: operator.id, blenderOperators: [...operator.blenderOperators], selection: operator.selection, parameters: [...operator.parameters], gate: curveTopologyOperatorGate(operator.id), })), }; } export function parseCurveTopologyOperationClaim( value: unknown, expectedRevision?: number, ): CurveTopologyOperationClaimIR { if (!value || typeof value !== "object" || Array.isArray(value)) { fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim must be an object"); } const claim = value as Record; if (Object.keys(claim).some((field) => !CLAIM_FIELDS.has(field)) || claim.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION) { fail("NON_MESH_PROPERTY_INVALID", "Curve topology operation claim schema is invalid"); } if (typeof claim.operator !== "string" || !OPERATOR_BY_ID.has(claim.operator as CurveTopologyOperator)) { fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", `Curve operator ${String(claim.operator)} is not allowlisted`); } const operator = claim.operator as CurveTopologyOperator; const descriptor = OPERATOR_BY_ID.get(operator)!; if (typeof claim.dataId !== "string" || !claim.dataId.startsWith("curve:") || new TextEncoder().encode(claim.dataId).byteLength > CURVE_TOPOLOGY_EDITOR_BUDGET.maxDataIdBytes) { fail("NON_MESH_PROPERTY_INVALID", "Curve topology data ID is invalid"); } const baseRevision = integer(claim.baseRevision, "baseRevision", Number.MAX_SAFE_INTEGER); if (expectedRevision !== undefined && baseRevision !== expectedRevision) { fail("REVISION_CONFLICT", "Curve topology operation claim is stale"); } const inputSplineCount = integer(claim.inputSplineCount, "inputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines); const inputPointCount = integer(claim.inputPointCount, "inputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints); const selectedSplineIndices = selection(claim.selectedSplineIndices, "selectedSplineIndices", inputSplineCount); const selectedPointIndices = selection(claim.selectedPointIndices, "selectedPointIndices", inputPointCount); if (selectedSplineIndices.length + selectedPointIndices.length > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSelectedElements) { fail("NON_MESH_DATA_BUDGET_EXCEEDED", "combined Curve topology selection exceeds the budget"); } assertSelectionDomain(descriptor.selection, selectedSplineIndices, selectedPointIndices); const addedSplineCount = integer(claim.addedSplineCount, "addedSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedSplinesPerOperation); const addedPointCount = integer(claim.addedPointCount, "addedPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxAddedPointsPerOperation); const outputSplineCount = integer(claim.outputSplineCount, "outputSplineCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines); const outputPointCount = integer(claim.outputPointCount, "outputPointCount", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints); const payloadBytes = integer(claim.payloadBytes, "payloadBytes", CURVE_TOPOLOGY_EDITOR_BUDGET.maxPayloadBytes); if (outputSplineCount > inputSplineCount + addedSplineCount || outputPointCount > inputPointCount + addedPointCount) { fail("NON_MESH_PROPERTY_INVALID", "Curve topology output contains undeclared additions"); } if (operator === "ADD_SPLINE" && (addedSplineCount < 1 || addedPointCount < addedSplineCount * 2 || outputSplineCount !== inputSplineCount + addedSplineCount || outputPointCount !== inputPointCount + addedPointCount)) { fail("NON_MESH_PROPERTY_INVALID", "ADD_SPLINE must declare every added spline and point"); } let subdivideCuts: number | undefined; if (operator === "SUBDIVIDE") { subdivideCuts = integer(claim.subdivideCuts, "subdivideCuts", CURVE_TOPOLOGY_EDITOR_BUDGET.maxSubdivideCuts); if (subdivideCuts < 1) fail("NON_MESH_PROPERTY_INVALID", "SUBDIVIDE requires at least one cut"); } else if (claim.subdivideCuts !== undefined) { fail("NON_MESH_PROPERTY_INVALID", "subdivideCuts is only valid for SUBDIVIDE"); } return { schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, operator, dataId: claim.dataId, baseRevision, inputSplineCount, inputPointCount, selectedSplineIndices, selectedPointIndices, addedSplineCount, addedPointCount, outputSplineCount, outputPointCount, payloadBytes, ...(subdivideCuts === undefined ? {} : { subdivideCuts }), }; } export function buildCurveToggleCyclicOperation( input: CurveToggleCyclicOperationInputIR, expectedRevision: number, ): CurveToggleCyclicOperationIR { const gate = curveTopologyOperatorGate("TOGGLE_CYCLIC"); if (gate.status !== "READY") { fail("NON_MESH_TOPOLOGY_EDIT_UNSUPPORTED", "TOGGLE_CYCLIC has not passed its independent Main gate"); } if (input.schemaVersion !== CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION || !Number.isSafeInteger(input.splineIndex) || input.splineIndex < 0 || input.splineIndex >= input.splineCount || !Number.isSafeInteger(input.splineCount) || input.splineCount < 1 || input.splineCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxSplines || !Number.isSafeInteger(input.pointCount) || input.pointCount < 0 || input.pointCount > CURVE_TOPOLOGY_EDITOR_BUDGET.maxPoints || !Array.isArray(input.cyclicU) || input.cyclicU.length !== input.splineCount || input.cyclicU.some((value) => typeof value !== "boolean")) { fail("NON_MESH_PROPERTY_INVALID", "TOGGLE_CYCLIC input does not describe one bounded Curve spline"); } const cyclicU = [...input.cyclicU]; const previousCyclic = cyclicU[input.splineIndex]; cyclicU[input.splineIndex] = !previousCyclic; const command: CurveToggleCyclicMainCommandIR = { type: "setCurveTopology", dataId: input.dataId, baseRevision: input.baseRevision, cyclicU, }; const payloadBytes = new TextEncoder().encode(JSON.stringify(command)).byteLength; const claim = parseCurveTopologyOperationClaim({ schemaVersion: CURVE_TOPOLOGY_EDITOR_SCHEMA_VERSION, operator: "TOGGLE_CYCLIC", dataId: input.dataId, baseRevision: input.baseRevision, inputSplineCount: input.splineCount, inputPointCount: input.pointCount, selectedSplineIndices: [input.splineIndex], selectedPointIndices: [], addedSplineCount: 0, addedPointCount: 0, outputSplineCount: input.splineCount, outputPointCount: input.pointCount, payloadBytes, }, expectedRevision); return { operator: "TOGGLE_CYCLIC", splineIndex: input.splineIndex, previousCyclic, nextCyclic: cyclicU[input.splineIndex], claim, command, }; }