Files
workinf_Blender_Wasm/web/protocol/curve-topology-editor.ts
mes123456 0fe8d2bb56
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled
Advance M8-M11 parity workflows
2026-08-17 04:37:07 -04:00

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,
};
}