Advance N-015 through N-018 bounded workflows
This commit is contained in:
92
web/protocol/grease-pencil-editor.ts
Normal file
92
web/protocol/grease-pencil-editor.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
export const GREASE_PENCIL_EDITOR_SCHEMA = 1 as const;
|
||||
export const GREASE_PENCIL_EDITOR_BUDGET = { maxSelection: 1_000_000, maxIdLength: 256 } as const;
|
||||
|
||||
export interface GreasePencilPointSelectionIR { strokeIndex: number; pointIndex: number }
|
||||
export interface GreasePencilEditorIR {
|
||||
schemaVersion: typeof GREASE_PENCIL_EDITOR_SCHEMA;
|
||||
revision: number;
|
||||
dataId: string;
|
||||
layerId: string;
|
||||
frame: number;
|
||||
onionSkinning: boolean;
|
||||
selectedStrokeIndices: number[];
|
||||
selectedPoints: GreasePencilPointSelectionIR[];
|
||||
}
|
||||
|
||||
export type GreasePencilEditorEditIR =
|
||||
| { type: "SET_FRAME"; revision: number; frame: number }
|
||||
| { type: "SET_LAYER"; revision: number; layerId: string }
|
||||
| { type: "SET_ONION"; revision: number; enabled: boolean }
|
||||
| { type: "SET_SELECTION"; revision: number; strokeIndices: number[]; points: GreasePencilPointSelectionIR[] };
|
||||
|
||||
function fail(path: string, message: string): never { throw new Error(`GREASE_PENCIL_EDITOR_INVALID: ${path} ${message}`); }
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function id(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0 || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxIdLength) fail(path, "is invalid");
|
||||
return value;
|
||||
}
|
||||
function integer(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < -1_000_000 || value > 1_000_000) fail(path, "is outside the supported frame/index range");
|
||||
return value;
|
||||
}
|
||||
function nonnegativeIndex(value: unknown, path: string): number {
|
||||
const result = integer(value, path);
|
||||
if (result < 0) fail(path, "must be non-negative");
|
||||
return result;
|
||||
}
|
||||
function uniqueIndices(value: unknown, path: string): number[] {
|
||||
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
||||
const result = value.map((item, itemIndex) => nonnegativeIndex(item, `${path}[${itemIndex}]`));
|
||||
if (new Set(result).size !== result.length) fail(path, "contains duplicates");
|
||||
return result.sort((left, right) => left - right);
|
||||
}
|
||||
function points(value: unknown, path: string): GreasePencilPointSelectionIR[] {
|
||||
if (!Array.isArray(value) || value.length > GREASE_PENCIL_EDITOR_BUDGET.maxSelection) fail(path, "exceeds the selection budget");
|
||||
const seen = new Set<string>();
|
||||
const result = value.map((item, index) => {
|
||||
const point = record(item, `${path}[${index}]`);
|
||||
const parsed = { strokeIndex: nonnegativeIndex(point.strokeIndex, `${path}[${index}].strokeIndex`), pointIndex: nonnegativeIndex(point.pointIndex, `${path}[${index}].pointIndex`) };
|
||||
const key = `${parsed.strokeIndex}:${parsed.pointIndex}`;
|
||||
if (seen.has(key)) fail(`${path}[${index}]`, "contains duplicates");
|
||||
seen.add(key);
|
||||
return parsed;
|
||||
});
|
||||
return result.sort((left, right) => left.strokeIndex - right.strokeIndex || left.pointIndex - right.pointIndex);
|
||||
}
|
||||
|
||||
export function parseGreasePencilEditor(value: unknown): GreasePencilEditorIR {
|
||||
const editor = record(value, "editor");
|
||||
if (editor.schemaVersion !== GREASE_PENCIL_EDITOR_SCHEMA) fail("schemaVersion", "is unsupported");
|
||||
if (typeof editor.revision !== "number" || !Number.isSafeInteger(editor.revision) || editor.revision < 0) fail("revision", "is invalid");
|
||||
if (typeof editor.frame !== "number" || !Number.isSafeInteger(editor.frame) || editor.frame < -1_000_000 || editor.frame > 1_000_000) fail("frame", "is invalid");
|
||||
if (typeof editor.onionSkinning !== "boolean") fail("onionSkinning", "must be boolean");
|
||||
return {
|
||||
schemaVersion: GREASE_PENCIL_EDITOR_SCHEMA,
|
||||
revision: editor.revision,
|
||||
dataId: id(editor.dataId, "dataId"),
|
||||
layerId: id(editor.layerId, "layerId"),
|
||||
frame: editor.frame,
|
||||
onionSkinning: editor.onionSkinning,
|
||||
selectedStrokeIndices: uniqueIndices(editor.selectedStrokeIndices, "selectedStrokeIndices"),
|
||||
selectedPoints: points(editor.selectedPoints, "selectedPoints"),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyGreasePencilEditorEdit(value: unknown, editValue: unknown): GreasePencilEditorIR {
|
||||
const editor = parseGreasePencilEditor(value);
|
||||
const edit = record(editValue, "edit");
|
||||
if (edit.revision !== editor.revision) throw new Error("REVISION_CONFLICT: Grease Pencil editor state is stale");
|
||||
const revision = editor.revision + 1;
|
||||
switch (edit.type) {
|
||||
case "SET_FRAME": return parseGreasePencilEditor({ ...editor, revision, frame: integer(edit.frame, "edit.frame") });
|
||||
case "SET_LAYER": return parseGreasePencilEditor({ ...editor, revision, layerId: id(edit.layerId, "edit.layerId") });
|
||||
case "SET_ONION":
|
||||
if (typeof edit.enabled !== "boolean") fail("edit.enabled", "must be boolean");
|
||||
return parseGreasePencilEditor({ ...editor, revision, onionSkinning: edit.enabled });
|
||||
case "SET_SELECTION": return parseGreasePencilEditor({ ...editor, revision, selectedStrokeIndices: uniqueIndices(edit.strokeIndices, "edit.strokeIndices"), selectedPoints: points(edit.points, "edit.points") });
|
||||
default: fail("edit.type", "is unsupported");
|
||||
}
|
||||
}
|
||||
93
web/protocol/nonmesh-interaction.ts
Normal file
93
web/protocol/nonmesh-interaction.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
export const CURVE_GIZMO_SCHEMA = 1 as const;
|
||||
|
||||
export const CURVE_GIZMO_BUDGET = {
|
||||
maxHandles: 256,
|
||||
maxIdLength: 256,
|
||||
maxCoordinate: 1_000_000,
|
||||
} as const;
|
||||
|
||||
export type CurveGizmoHandleSide = "LEFT" | "RIGHT" | "CONTROL";
|
||||
export type CurveGizmoPhase = "PREVIEW" | "COMMIT";
|
||||
|
||||
export interface CurveGizmoHandleIR {
|
||||
pointIndex: number;
|
||||
side: CurveGizmoHandleSide;
|
||||
position: [number, number, number];
|
||||
}
|
||||
|
||||
export interface CurveGizmoDragIR {
|
||||
schemaVersion: typeof CURVE_GIZMO_SCHEMA;
|
||||
dataId: string;
|
||||
baseRevision: number;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
delta: [number, number, number];
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
|
||||
export interface AppliedCurveGizmoDragIR {
|
||||
dataId: string;
|
||||
phase: CurveGizmoPhase;
|
||||
axis: 0 | 1 | 2;
|
||||
revision: number;
|
||||
handles: CurveGizmoHandleIR[];
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
throw new Error(`CURVE_GIZMO_INVALID: ${path} ${message}`);
|
||||
}
|
||||
|
||||
function record(value: unknown, path: string): Record<string, unknown> {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) fail(path, "must be an object");
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function integer(value: unknown, path: string, minimum = 0): number {
|
||||
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) fail(path, "must be a bounded integer");
|
||||
return value;
|
||||
}
|
||||
|
||||
function finite(value: unknown, path: string): number {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || Math.abs(value) > CURVE_GIZMO_BUDGET.maxCoordinate) fail(path, "is outside the finite coordinate budget");
|
||||
return value;
|
||||
}
|
||||
|
||||
function vector(value: unknown, path: string): [number, number, number] {
|
||||
if (!Array.isArray(value) || value.length !== 3) fail(path, "must contain three coordinates");
|
||||
return [finite(value[0], `${path}[0]`), finite(value[1], `${path}[1]`), finite(value[2], `${path}[2]` )];
|
||||
}
|
||||
|
||||
export function parseCurveGizmoDrag(value: unknown, expectedRevision?: number): CurveGizmoDragIR {
|
||||
const drag = record(value, "drag");
|
||||
if (drag.schemaVersion !== CURVE_GIZMO_SCHEMA) fail("schemaVersion", "is unsupported");
|
||||
if (typeof drag.dataId !== "string" || drag.dataId.length === 0 || drag.dataId.length > CURVE_GIZMO_BUDGET.maxIdLength) fail("dataId", "is invalid");
|
||||
const baseRevision = integer(drag.baseRevision, "baseRevision");
|
||||
if (expectedRevision !== undefined && baseRevision !== expectedRevision) throw new Error("REVISION_CONFLICT: Curve gizmo request is stale");
|
||||
if (drag.phase !== "PREVIEW" && drag.phase !== "COMMIT") fail("phase", "is invalid");
|
||||
if (drag.axis !== 0 && drag.axis !== 1 && drag.axis !== 2) fail("axis", "must be X, Y or Z");
|
||||
const delta = vector(drag.delta, "delta");
|
||||
if (delta.some((component, axis) => axis !== drag.axis && component !== 0)) fail("delta", "must only move along the selected axis");
|
||||
if (!Array.isArray(drag.handles) || drag.handles.length === 0 || drag.handles.length > CURVE_GIZMO_BUDGET.maxHandles) fail("handles", "exceeds the handle budget");
|
||||
const seen = new Set<string>();
|
||||
const handles = drag.handles.map((item, index) => {
|
||||
const handle = record(item, `handles[${index}]`);
|
||||
const pointIndex = integer(handle.pointIndex, `handles[${index}].pointIndex`);
|
||||
if (handle.side !== "LEFT" && handle.side !== "RIGHT" && handle.side !== "CONTROL") fail(`handles[${index}].side`, "is invalid");
|
||||
const side = handle.side as CurveGizmoHandleSide;
|
||||
const key = `${pointIndex}:${side}`;
|
||||
if (seen.has(key)) fail(`handles[${index}]`, "duplicates a selected handle");
|
||||
seen.add(key);
|
||||
return { pointIndex, side, position: vector(handle.position, `handles[${index}].position`) };
|
||||
});
|
||||
return { schemaVersion: CURVE_GIZMO_SCHEMA, dataId: drag.dataId, baseRevision, phase: drag.phase, axis: drag.axis, delta, handles };
|
||||
}
|
||||
|
||||
export function applyCurveGizmoDelta(value: unknown, expectedRevision?: number): AppliedCurveGizmoDragIR {
|
||||
const drag = parseCurveGizmoDrag(value, expectedRevision);
|
||||
const handles = drag.handles.map((handle) => ({
|
||||
...handle,
|
||||
position: [handle.position[0] + drag.delta[0], handle.position[1] + drag.delta[1], handle.position[2] + drag.delta[2]] as [number, number, number],
|
||||
}));
|
||||
handles.forEach((handle, index) => vector(handle.position, `handles[${index}].position`));
|
||||
return { dataId: drag.dataId, phase: drag.phase, axis: drag.axis, revision: drag.baseRevision + (drag.phase === "COMMIT" ? 1 : 0), handles };
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { parseTrackingMaskProject, type TrackingMaskProjectIR } from "./tracking
|
||||
import { normalizeProjectAssetPath } from "./asset-path";
|
||||
import { parseEditorWorkflow, type EditorWorkflowIR } from "./editor-workflow";
|
||||
import { parseScriptSourceInventory, type ScriptSourceInventoryIR } from "./scripting-platform";
|
||||
import { parsePhysicsSimulationManifest, type PhysicsSimulationManifestIR } from "./physics-simulation";
|
||||
|
||||
export type SceneNodeType =
|
||||
| "EMPTY"
|
||||
@@ -482,6 +483,7 @@ export interface SceneSnapshotIR {
|
||||
editorWorkflowStatus?: "AVAILABLE" | "BLOCKED";
|
||||
scriptSources?: ScriptSourceInventoryIR;
|
||||
scriptSourceStatus?: "AVAILABLE" | "BLOCKED";
|
||||
physicsSimulation?: PhysicsSimulationManifestIR;
|
||||
libraries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -1132,5 +1134,6 @@ export function parseSceneSnapshotIR(value: unknown): SceneSnapshotIR {
|
||||
parseScriptSourceInventory(value.scriptSources);
|
||||
if (value.scriptSourceStatus !== "AVAILABLE") throw new Error("SceneIR.scriptSourceStatus must be AVAILABLE when scriptSources is present");
|
||||
}
|
||||
if (value.physicsSimulation !== undefined) parsePhysicsSimulationManifest(value.physicsSimulation);
|
||||
return value as unknown as SceneSnapshotIR;
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export type WebEngineEditCommand =
|
||||
| { type: "deleteNonMeshData"; dataId: string }
|
||||
| { type: "setCurveControlPoints"; dataId: string; controlPoints: number[]; splineOffsets?: number[]; resolution?: number }
|
||||
| { type: "setCurveHandle"; dataId: string; pointIndex: number; side: "LEFT" | "RIGHT"; position: [number, number, number] }
|
||||
| { type: "setCurveTopology"; dataId: string; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
|
||||
| { type: "setCurveTopology"; dataId: string; baseRevision?: number; splineTypes?: NonMeshCurveSplineType[]; cyclicU?: boolean[]; cyclicV?: boolean[]; handleTypes?: number[]; handlePoints?: number[] }
|
||||
| { type: "setCurveSplines"; dataId: string; splineTypes: NonMeshCurveSplineType[]; splineOffsets: number[]; ordersU: number[]; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; handleTypes: number[]; handlePoints: number[] }
|
||||
| { type: "setSurfaceTopology"; dataId: string; splineDimensions: Array<{ u: number; v: number; orderU: number; orderV: number }>; controlPoints: number[]; pointWeights: number[]; cyclicU: boolean[]; cyclicV: boolean[] }
|
||||
| { type: "setFontBody"; dataId: string; body: string }
|
||||
|
||||
Reference in New Issue
Block a user