316 lines
14 KiB
TypeScript
316 lines
14 KiB
TypeScript
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<CurveTopologyOperator, typeof CURVE_TOPOLOGY_EDITOR_OPERATORS[number]>(
|
|
CURVE_TOPOLOGY_EDITOR_OPERATORS.map((operator) => [operator.id, operator]),
|
|
);
|
|
const VERIFIED = new Set<CurveTopologyOperator>(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<string, unknown>;
|
|
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,
|
|
};
|
|
}
|