feat(web): complete product workflow implementation
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

This commit is contained in:
mes123456
2026-09-06 08:33:27 +08:00
parent 7e3f6da78b
commit 1184a51ddf
39 changed files with 3736 additions and 160 deletions

View File

@@ -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 = {