feat(web): complete product workflow implementation
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
import type { ErrorReport } from "../../../protocol/error";
|
||||
import { parseSceneDelta, type SceneDelta } from "../../../protocol/scene-delta";
|
||||
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import type { MeshGeometryBuffer, WebEngineEditCommand, WebEngineJsonCommand, WebEngineLODError, WebEngineLODLevelResult, WebEngineOpenResourceStatus, WebEngineOperatorInvocation, WebEngineOperatorProperty, WebEngineRequest, WebEngineResponse, WebEngineResult, WebEngineStatus } from "../../../protocol/web-engine";
|
||||
import { parseSceneSnapshotIR } from "../../../protocol/scene-ir";
|
||||
import { parseSequencerTimeline } from "../../../protocol/sequencer";
|
||||
import { parseEditorWorkflow, EditorWorkflowValidationError } from "../../../protocol/editor-workflow";
|
||||
import { parseLODManifest, type SimplifyResult } from "../../../protocol/simplify";
|
||||
import type { LODGenerationRequest } from "../../../protocol/lod";
|
||||
@@ -151,6 +152,42 @@ function validPropertyKeys(value: unknown, allowed: readonly string[]): value is
|
||||
Object.keys(value).length > 0 && Object.keys(value).every((key) => allowed.includes(key));
|
||||
}
|
||||
|
||||
function validOperatorJson(value: unknown, depth = 0): value is WebEngineOperatorProperty {
|
||||
if (depth > 8 || value === null || typeof value === "string" || typeof value === "boolean") return true;
|
||||
if (typeof value === "number") return Number.isFinite(value);
|
||||
if (Array.isArray(value)) return value.length <= 1024 && value.every((item) => validOperatorJson(item, depth + 1));
|
||||
if (typeof value === "object") {
|
||||
const entries = Object.entries(value);
|
||||
return entries.length <= 128 && entries.every(([key, item]) => key.length > 0 && key.length <= 64 && validOperatorJson(item, depth + 1));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function validateOperatorInvocation(value: unknown): WebEngineOperatorInvocation {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires an object params payload", true);
|
||||
}
|
||||
const params = value as Record<string, unknown>;
|
||||
if (Object.keys(params).some((key) => !["operator", "properties", "baseRevision"].includes(key))) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderOperator received an unknown parameter", true);
|
||||
}
|
||||
if (typeof params.operator !== "string" || params.operator.length === 0 || params.operator.length > 128) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderOperator requires a bounded operator identifier", true);
|
||||
}
|
||||
const properties = params.properties === undefined ? {} : params.properties;
|
||||
if (typeof properties !== "object" || properties === null || Array.isArray(properties) || !validOperatorJson(properties)) {
|
||||
throw report("INVALID_ARGUMENT", "invokeBlenderOperator properties must be typed JSON", true);
|
||||
}
|
||||
if (params.baseRevision !== undefined && (!Number.isSafeInteger(params.baseRevision) || params.baseRevision < 0)) {
|
||||
throw report("REVISION_CONFLICT", "invokeBlenderOperator baseRevision must be a non-negative integer", true);
|
||||
}
|
||||
return {
|
||||
operator: params.operator,
|
||||
properties: properties as Record<string, WebEngineOperatorProperty>,
|
||||
...(params.baseRevision === undefined ? {} : { baseRevision: params.baseRevision as number }),
|
||||
};
|
||||
}
|
||||
|
||||
function reportProtocolError(error: unknown, fallbackCode: ErrorReport["code"]): never {
|
||||
if (typeof error === "object" && error !== null && "code" in error && typeof (error as { code?: unknown }).code === "string") {
|
||||
const candidate = error as { code: ErrorReport["code"]; message?: unknown };
|
||||
@@ -246,13 +283,25 @@ function sculptMeshGate(meshId: string): CapabilityGateResult {
|
||||
}
|
||||
|
||||
function assertFutureCapability(payload: Extract<WebEngineRequest["command"], { type: "applyCommand" }>["payload"]): void {
|
||||
if (payload.type === "createAction") {
|
||||
if (typeof payload.name !== "string" || payload.name.trim().length === 0 || payload.name.length > 256) throw report("ANIMATION_ACTION_INVALID", "Action name is required");
|
||||
if (payload.objectId !== undefined && typeof payload.objectId !== "string") throw report("ANIMATION_ACTION_INVALID", "Action objectId is invalid");
|
||||
}
|
||||
if (payload.type === "removeAction" && (typeof payload.actionId !== "string" || payload.actionId.length === 0)) throw report("ANIMATION_ACTION_INVALID", "Action id is required");
|
||||
if (payload.type === "setKeyframe") {
|
||||
if (typeof payload.animationId !== "string" || typeof payload.path !== "string" || !Number.isFinite(payload.frame)) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe identity is invalid");
|
||||
if (payload.value !== undefined && (!Array.isArray(payload.value) || !payload.value.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe value is invalid");
|
||||
for (const handle of [payload.handleLeft, payload.handleRight]) if (handle !== undefined && (!Array.isArray(handle) || handle.length !== 2 || !handle.every((value) => typeof value === "number" && Number.isFinite(value)))) throw report("ANIMATION_KEYFRAME_INVALID", "Keyframe handle is invalid");
|
||||
}
|
||||
if (payload.type === "setTimelineMarkers") {
|
||||
if (typeof payload.dataId !== "string" || !Array.isArray(payload.markers) || payload.markers.length > 10000 || payload.markers.some((marker) => !marker || !Number.isFinite(marker.frame) || typeof marker.name !== "string" || marker.name.length > 256)) throw report("ANIMATION_MARKER_INVALID", "Timeline markers are invalid");
|
||||
}
|
||||
const modifierAuthoringCommands = new Set(["addModifier", "removeModifier", "moveModifier", "setModifierParameters"]);
|
||||
if (modifierAuthoringCommands.has(payload.type)) return;
|
||||
switch (payload.type) {
|
||||
case "sculptStroke": {
|
||||
try {
|
||||
const stroke = parseSculptStroke(payload.stroke);
|
||||
if (!stroke.mirrorObjectSpace) {
|
||||
throw report("CAPABILITY_MISSING", "N-011 bounded Sculpt strokes require object-space mirror samples");
|
||||
}
|
||||
const gate = sculptMeshGate(stroke.meshId);
|
||||
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
|
||||
const mesh = currentSnapshot?.meshes.find((candidate) => candidate.id === stroke.meshId);
|
||||
@@ -466,6 +515,9 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (payload.velocityScale !== undefined && (!Number.isFinite(payload.velocityScale) || payload.velocityScale < -1_000_000 || payload.velocityScale > 1_000_000)) throw report("NON_MESH_PROPERTY_INVALID", "Volume velocity scale is outside the bounded range");
|
||||
return;
|
||||
}
|
||||
case "setArmatureBones":
|
||||
case "setPoseBoneTransform":
|
||||
return;
|
||||
case "setMetaballElements": {
|
||||
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
|
||||
if (!data || data.type !== "METABALL") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Metaball data block is unavailable: ${payload.dataId}`);
|
||||
@@ -904,6 +956,19 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
|
||||
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
|
||||
return;
|
||||
}
|
||||
case "setSequencerStrips": {
|
||||
const scene = currentSnapshot?.scenes.find((candidate) => candidate.id === payload.sceneId);
|
||||
if (!scene) throw report("SEQUENCER_SCHEMA_INVALID", `Sequencer scene does not exist: ${payload.sceneId}`);
|
||||
try {
|
||||
const timeline = parseSequencerTimeline(payload.timeline);
|
||||
if (timeline.revision !== currentSnapshot?.revision) throw report("REVISION_CONFLICT", "Sequencer timeline revision does not match the current SceneIR");
|
||||
if (timeline.id !== `sequencer:${scene.id}`) throw report("SEQUENCER_SCHEMA_INVALID", "Sequencer timeline does not target the requested scene");
|
||||
}
|
||||
catch (error) {
|
||||
reportProtocolError(error, "SEQUENCER_SCHEMA_INVALID");
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1024,8 +1089,9 @@ function callUnifiedJson<T>(cmd: string, params: Record<string, unknown> = {}, t
|
||||
}
|
||||
}
|
||||
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineJsonCommand> = new Set(["applyCommand", "undo", "redo"]);
|
||||
type WebEngineRouteJsonCommand = WebEngineJsonCommand | "importScene" | "exportScene";
|
||||
const READ_ONLY_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["snapshot", "delta", "depsgraph", "memoryStats", "listBlenderOperators", "exportScene"]);
|
||||
const STATEFUL_JSON_COMMANDS: ReadonlySet<WebEngineRouteJsonCommand> = new Set(["applyCommand", "undo", "redo", "invokeBlenderOperator", "importScene"]);
|
||||
|
||||
function readSnapshot(): ReturnType<typeof parseSceneSnapshotIR> {
|
||||
const snapshot = parseSceneSnapshotIR(callUnifiedJson<unknown>("snapshot"));
|
||||
@@ -1510,15 +1576,23 @@ async function handleRequest(event: MessageEvent<WebEngineRequest>): Promise<voi
|
||||
break;
|
||||
case "jsonCommand": {
|
||||
await initialize();
|
||||
if (!READ_ONLY_JSON_COMMANDS.has(request.command.cmd) && !STATEFUL_JSON_COMMANDS.has(request.command.cmd)) {
|
||||
throw report("INVALID_ARGUMENT", `Unsupported JSON command: ${request.command.cmd}`, true);
|
||||
const commandName = request.command.cmd as WebEngineRouteJsonCommand;
|
||||
if (!READ_ONLY_JSON_COMMANDS.has(commandName) && !STATEFUL_JSON_COMMANDS.has(commandName)) {
|
||||
throw report("INVALID_ARGUMENT", `Unsupported JSON command: ${commandName}`, true);
|
||||
}
|
||||
if (STATEFUL_JSON_COMMANDS.has(request.command.cmd) && request.command.cmd === "applyCommand" &&
|
||||
if (STATEFUL_JSON_COMMANDS.has(commandName) && commandName === "applyCommand" &&
|
||||
(!request.command.params || typeof request.command.params.command !== "object" || request.command.params.command === null)) {
|
||||
throw report("INVALID_ARGUMENT", "cmd json applyCommand requires params.command", true);
|
||||
}
|
||||
const jsonResult = callUnifiedJson<unknown>(request.command.cmd, request.command.params ?? {});
|
||||
if (STATEFUL_JSON_COMMANDS.has(request.command.cmd)) {
|
||||
if (commandName === "invokeBlenderOperator") validateOperatorInvocation(request.command.params);
|
||||
if (commandName === "importScene" && (!request.command.params || typeof request.command.params.format !== "string" || typeof request.command.params.base64 !== "string")) {
|
||||
throw report("INVALID_ARGUMENT", "importScene requires format and base64 payload", true);
|
||||
}
|
||||
if (commandName === "exportScene" && (!request.command.params || typeof request.command.params.format !== "string")) {
|
||||
throw report("INVALID_ARGUMENT", "exportScene requires format", true);
|
||||
}
|
||||
const jsonResult = callUnifiedJson<unknown>(commandName, request.command.params ?? {});
|
||||
if (STATEFUL_JSON_COMMANDS.has(commandName)) {
|
||||
const scene = await readSnapshotResult();
|
||||
currentSnapshot = scene.snapshot;
|
||||
result = {
|
||||
|
||||
Reference in New Issue
Block a user