feat(web): switch to implementation-first capability wave
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-08-29 01:22:03 +08:00
parent 5fb12cd6a5
commit 7549b9a15c
159 changed files with 14110 additions and 74 deletions

View File

@@ -7,6 +7,14 @@ import type { SceneSnapshotIR } from "../../../protocol/scene-ir";
import type { GPUTextureAsset } from "../../../protocol/render-assets";
import { collectGPUTextureAssetRequests, createGPUTextureAsset, RenderAssetValidationError } from "../../../protocol/render-assets";
import type { MeshEditOperation, MeshElementMode, MeshGeometryBuffer, WebEngineEditCommand, WebEngineLODLevelResult } from "../../../protocol/web-engine";
import { coreSceneCommands } from "../../../protocol/core-scene-commands";
import { meshAuthoringCommands } from "../../../protocol/mesh-authoring-commands";
import { nonMeshGeometryCommands } from "../../../protocol/nonmesh-geometry-commands";
import { animationRenderCommands } from "../../../protocol/animation-render-commands";
import { ioMediaCommands } from "../../../protocol/io-media-commands";
import { isWorkbenchCommandAvailable } from "../../../protocol/workbench-command-groups";
import { shaderGraphCommands } from "../../../protocol/shader-graph-commands";
import { renderCompositorCommands } from "../../../protocol/render-compositor-commands";
import type { NonMeshGeometryChunk } from "../../../protocol/nonmesh-binary";
import type { SimplifyAttributePolicy, SimplifyDelimit, SimplifyMode, SimplifyProfile } from "../../../protocol/simplify";
import { StorageClient } from "../storage/StorageClient";
@@ -892,11 +900,11 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
</div>
<label>New layer <input aria-label="Grease Pencil new layer name" value={greasePencilLayerName} onChange={(event) => setGreasePencilLayerName(event.target.value)} /></label>
<div className="property-actions">
<button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand({ type: "createGreasePencilLayer", dataId: activeGreasePencil.id, name: greasePencilLayerName })}>Add Layer</button>
<button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand({ type: "removeGreasePencilLayer", dataId: activeGreasePencil.id, layerId: greasePencilLayerId })}>Remove Layer</button>
<button type="button" disabled={!greasePencilLayerName} onClick={() => onCommand(nonMeshGeometryCommands.createGreasePencilLayer(activeGreasePencil.id, greasePencilLayerName))}>Add Layer</button>
<button type="button" disabled={!greasePencilLayerId || activeGreasePencil.layers.length <= 1} onClick={() => onCommand(nonMeshGeometryCommands.removeGreasePencilLayer(activeGreasePencil.id, greasePencilLayerId))}>Remove Layer</button>
<button type="button" disabled={!greasePencilLayerId} onClick={() => onCommand({ type: "insertGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Add Frame</button>
<button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "removeGreasePencilFrame", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1 })}>Remove Frame</button>
<button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand({ type: "setGreasePencilStrokes", dataId: activeGreasePencil.id, layerId: greasePencilLayerId, frame: snapshot?.frame.current ?? 1, baseRevision: snapshot?.revision, strokes: [] })}>Clear Drawing</button>
<button type="button" disabled={!activeGreasePencilFrame} onClick={() => onCommand(nonMeshGeometryCommands.clearGreasePencilFrame(activeGreasePencil.id, greasePencilLayerId, snapshot?.frame.current ?? 1, snapshot?.revision))}>Clear Drawing</button>
</div>
<label>Target frame <input aria-label="Grease Pencil target frame" type="number" min="-1000000" max="1000000" step="1" value={greasePencilTargetFrame} onChange={(event) => setGreasePencilTargetFrame(Number(event.target.value))} /></label>
<div className="property-actions"><button type="button" aria-label="Move Grease Pencil frame" title="Move current drawing to target frame" disabled={!activeGreasePencilFrame || greasePencilTargetFrame === activeGreasePencilFrame.frame} onClick={moveGreasePencilFrame}>→</button></div>
@@ -930,7 +938,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
<div className="property-actions"><button type="button" onClick={() => {
const rgb = [1, 3, 5].map((offset) => Number.parseInt(materialColor.slice(offset, offset + 2), 16) / 255) as [number, number, number];
onCommand({ type: "setMaterialPrincipled", materialId: activeMaterial.id, baseColor: [...rgb, activeMaterial.baseColor[3]], roughness: materialRoughness, metallic: materialMetallic, emissionColor: activeMaterial.emissionColor, alpha: activeMaterial.alpha, ior: materialIOR, specularIORLevel: materialSpecular, transmissionWeight: materialTransmission, coatWeight: materialCoat, coatRoughness: materialCoatRoughness, emissionStrength: materialEmissionStrength });
}}>应用 Principled</button><label className="file-button">导入图片<input type="file" accept="image/png,image/jpeg" onChange={(event) => { const file = event.target.files?.[0]; if (file) onImportImage(file); event.target.value = ""; }} /></label></div>
}}>应用 Principled</button><button type="button" onClick={() => { const rgb = [1, 3, 5].map((offset) => Number.parseInt(materialColor.slice(offset, offset + 2), 16) / 255); const graph = shaderGraphCommands.setPrincipledGraph(activeMaterial.id, [...rgb, activeMaterial.baseColor[3]], materialRoughness, materialMetallic); onCommand({ type: "setShaderGraph", materialId: activeMaterial.id, graph }); }}>重建节点图</button><label className="file-button">导入图片<input type="file" accept="image/png,image/jpeg" onChange={(event) => { const file = event.target.files?.[0]; if (file) onImportImage(file); event.target.value = ""; }} /></label></div>
<label>纹理 <select aria-label="材质图片" value="" onChange={(event) => event.target.value && onCommand({ type: "setMaterialImageNode", materialId: activeMaterial.id, imageId: event.target.value, usage: "BASE_COLOR", uvMap: activeMesh.activeUVMap ?? undefined })}><option value="">选择 Base Color...</option>{snapshot?.images.map((image) => <option key={image.id} value={image.id}>{image.name}</option>)}</select></label>
<label>法线 <select aria-label="材质法线图片" value="" onChange={(event) => event.target.value && onCommand({ type: "setMaterialImageNode", materialId: activeMaterial.id, imageId: event.target.value, usage: "NORMAL", uvMap: activeMesh.activeUVMap ?? undefined })}><option value="">选择 Normal...</option>{snapshot?.images.map((image) => <option key={image.id} value={image.id}>{image.name}</option>)}</select></label>
</> : null}
@@ -1043,7 +1051,7 @@ function Timeline({ snapshot, frame, start, end, onFrameChange, onCommand }: { s
.sort((left, right) => left - right);
return (
<div className="timeline-content">
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|◀</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}>◀</button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>▶|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>▶|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "LOCATION", interpolation: "BEZIER" })}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "ROTATION_EULER", interpolation: "BEZIER" })}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand({ type: "insertObjectKeyframe", objectId: snapshot.activeObjectId!, frame, property: "SCALE", interpolation: "BEZIER" })}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand({ type: "deleteObjectKeyframe", objectId: snapshot.activeObjectId!, frame })}>Del Key</button></> : null}</div>
<div className="timeline-controls"><button type="button" aria-label="跳到第一帧" onClick={() => { setPlaying(false); onFrameChange(start); }}>|◀</button><button type="button" aria-label="上一帧" onClick={() => onFrameChange(Math.max(start, frame - 1))}>◀</button><button type="button" aria-label="播放" onClick={() => setPlaying((current) => !current)}>{playing ? "Ⅱ" : "▶"}</button><button type="button" aria-label="下一帧" onClick={() => onFrameChange(Math.min(end, frame + 1))}>▶|</button><button type="button" aria-label="跳到最后一帧" onClick={() => { setPlaying(false); onFrameChange(end); }}>▶|</button><output className="frame-number">{frame}</output>{snapshot?.activeObjectId ? <><button type="button" aria-label="插入位置关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "LOCATION"))}>Loc</button><button type="button" aria-label="插入旋转关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "ROTATION_EULER"))}>Rot</button><button type="button" aria-label="插入缩放关键帧" onClick={() => onCommand(animationRenderCommands.insertKeyframe(snapshot.activeObjectId!, frame, "SCALE"))}>Scale</button><button type="button" aria-label="删除当前关键帧" onClick={() => onCommand(animationRenderCommands.deleteKeyframe(snapshot.activeObjectId!, frame))}>Del Key</button></> : null}</div>
<input className="frame-slider" type="range" min={start} max={end} value={Math.min(end, Math.max(start, frame))} onChange={(event) => onFrameChange(Number(event.target.value))} aria-label="当前帧" />
<div className="timeline-scale"><span>{start}</span><span>{mid}</span><span>{second}</span><span>{third}</span><span>{fourth}</span><span>{end}</span></div>
<div className="dope-sheet" aria-label="Dope Sheet"><span className="channel-name">{greasePencil?.name ?? animation?.name ?? "No Action"}</span><div className="key-track">{keyframes.map((keyframe) => <button key={keyframe} type="button" className={keyframe === frame ? "key-dot active" : "key-dot"} style={{ left: `${((keyframe - start) / Math.max(1, end - start)) * 100}%` }} aria-label={`${greasePencil ? "Grease Pencil 帧" : "关键帧"} ${keyframe}`} onClick={() => onFrameChange(keyframe)} />)}</div>{!greasePencil && animation?.channels[0] ? <select aria-label="FCurve 插值" value={animation.channels[0].interpolation ?? "BEZIER"} onChange={(event) => onCommand({ type: "setFCurveInterpolation", animationId: animation.id, path: animation.channels[0].path, interpolation: event.target.value as "CONSTANT" | "LINEAR" | "BEZIER" })}><option value="CONSTANT">Constant</option><option value="LINEAR">Linear</option><option value="BEZIER">Bezier</option></select> : <span />}</div>
@@ -1549,10 +1557,10 @@ export function App() {
};
const applyDecimate = (profile: SimplifyProfile, meshId: string): void => {
void applyEditCommand({ type: "decimateMesh", meshId, profile });
void applyEditCommand(meshAuthoringCommands.decimate(meshId, profile));
};
const previewDecimate = (profile: SimplifyProfile, meshId: string): void => {
void applyEditCommand({ type: "previewDecimateMesh", meshId, profile });
void applyEditCommand(meshAuthoringCommands.previewDecimate(meshId, profile));
};
const setModifierEnabled = (meshId: string, modifierUuid: string, enabled: boolean): void => {
void applyEditCommand({ type: "setModifierEnabled", meshId, modifierUuid, enabled });
@@ -1589,10 +1597,11 @@ export function App() {
};
const runMeshEdit = (operation: MeshEditOperation): void => {
if (!meshSelection.meshId || meshSelection.indices.size === 0) return;
void applyEditCommand({ type: "meshEdit", meshId: meshSelection.meshId, operation, selectionMode: meshSelection.mode,
elementIndices: [...meshSelection.indices], ...(operation === "EXTRUDE" ? { offset: [0, 0, 0.25] as [number, number, number] } : {}),
void applyEditCommand(meshAuthoringCommands.edit(meshSelection.meshId, operation, meshSelection.mode, [...meshSelection.indices], {
...(operation === "EXTRUDE" ? { offset: [0, 0, 0.25] } : {}),
...(["INSET", "BEVEL"].includes(operation) ? { amount: 0.1 } : {}),
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}) });
...(["BEVEL", "LOOP_CUT"].includes(operation) ? { segments: operation === "BEVEL" ? 2 : 1 } : {}),
}));
};
const importImage = async (file: File): Promise<void> => {
const identity = beginUserAction("IMPORT");
@@ -1604,8 +1613,8 @@ export function App() {
let binary = "";
for (let offset = 0; offset < bytes.length; offset += 0x8000) binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
failureCode = "IMPORT_MAIN_TRANSACTION_FAILED";
const applied = await applyEditCommand({ type: "importImage", name: file.name.replace(/\.[^.]+$/, "") || "Image",
mimeType: file.type === "image/jpeg" ? "image/jpeg" : "image/png", width: bitmap.width, height: bitmap.height, base64: btoa(binary) });
const applied = await applyEditCommand(ioMediaCommands.importImage(file.name.replace(/\.[^.]+$/, "") || "Image",
file.type === "image/jpeg" ? "image/jpeg" : "image/png", bitmap.width, bitmap.height, btoa(binary)));
if (!applied) {
failUserAction(identity, failureCode);
return;
@@ -1700,7 +1709,7 @@ export function App() {
if (tool === "translate") translation[axis] += amount;
if (tool === "rotate") rotationEuler[axis] += amount * Math.PI;
if (tool === "scale") scale[axis] *= Math.max(0.01, 1 + amount);
void applyEditCommand({ type: "setObjectTransform", objectId: activeNode.id, translation, rotationEuler, scale });
void applyEditCommand(coreSceneCommands.transform(activeNode.id, translation, rotationEuler, scale));
};
useEffect(() => {
@@ -1744,15 +1753,15 @@ export function App() {
const activeId = snapshot?.activeObjectId;
if ((event.key === "Delete" || event.key === "Backspace") && activeId) {
event.preventDefault();
void applyEditCommand({ type: "deleteObject", objectId: activeId });
void applyEditCommand(coreSceneCommands.delete(activeId));
}
else if (event.altKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand({ type: "duplicateObject", objectId: activeId, offset: [0.5, 0.5, 0], linked: true });
void applyEditCommand(coreSceneCommands.duplicate(activeId, [0.5, 0.5, 0], true));
}
else if (event.shiftKey && event.key.toLowerCase() === "d" && activeId) {
event.preventDefault();
void applyEditCommand({ type: "duplicateObject", objectId: activeId, offset: [0.25, 0.25, 0] });
void applyEditCommand(coreSceneCommands.duplicate(activeId));
}
else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
event.preventDefault();
@@ -2361,6 +2370,12 @@ export function App() {
return;
}
try {
const renderGate = await webClientRef.current?.queryRenderCapability(renderCompositorCommands.boundedShaderGraph());
if (renderGate?.status === "BLOCKED") {
failUserAction(identity, "EXPORT_BLOCKED");
setEngineStatus(recordDiagnostic("EXPORT", "GLB_EXPORT_BLOCKED", renderGate));
return;
}
let exportSnapshot = snapshot;
let exportGeometryBuffers = geometryBuffers;
if ((snapshot.nonMeshData?.length ?? 0) > 0 && webClientRef.current) {
@@ -2430,8 +2445,8 @@ export function App() {
const operatorCommands = filterIOFormatOperatorCommands<OperatorCommand>([
...(["Layout", "Modeling", "Animation"] as WorkspaceId[]).filter((id) => id !== workspace).map((id) => ({ id: `workspace.${id}`, label: `Switch to ${id}`, keywords: "workspace", execute: () => dispatchUI({ type: "switchWorkspace", workspaceId: id }) })),
{ id: "mode.toggle", label: uiState.context.mode === "Object" ? "Enter Edit Mode" : "Exit Edit Mode", keywords: "mode tab", execute: () => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" }) },
...(snapshot && uiState.context.mode === "Object" ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand({ type: "createPrimitive", primitive: "CUBE", location: [0, 0, 0] }); } }] : []),
...(snapshot?.activeObjectId ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand({ type: "applyObjectTransform", objectId: snapshot.activeObjectId! }); } }] : []),
...(snapshot && uiState.context.mode === "Object" && isWorkbenchCommandAvailable("createPrimitive") ? [{ id: "object.add-cube", label: "Add Cube", keywords: "object primitive mesh", execute: () => { void applyEditCommand(coreSceneCommands.createCube()); } }] : []),
...(snapshot?.activeObjectId && isWorkbenchCommandAvailable("applyObjectTransform") ? [{ id: "object.apply-transform", label: "Apply Transform", keywords: "object location rotation scale", execute: () => { void applyEditCommand(coreSceneCommands.applyTransform(snapshot.activeObjectId!)); } }] : []),
...(snapshot ? [
{ id: "edit.undo", label: "Undo", keywords: "history", execute: () => { void applyEditCommand({ type: "undo" }); } },
{ id: "edit.redo", label: "Redo", keywords: "history", execute: () => { void applyEditCommand({ type: "redo" }); } },

View File

@@ -22,6 +22,7 @@ import type { RenderCapabilityRequest } from "../../../protocol/render-capabilit
import type { CapabilityGateResult } from "../../../protocol/capability-gates";
import { applyMeshGeometryDelta } from "../../../protocol/mesh-geometry-delta";
import { createWorkerFault, type WorkerFault } from "../../../protocol/worker-fault";
import { getWebEngineCommandCapability } from "../../../protocol/web-engine-capabilities";
import type {
PaintStrokeSessionBeginIR,
PaintStrokeSessionCancelIR,
@@ -106,6 +107,9 @@ export class WebEngineClient {
}
async applyCommand(payload: WebEngineEditCommand): Promise<BlendOpenResult & { delta: SceneDelta; shaderCompile?: ShaderCompileReport }> {
if (!getWebEngineCommandCapability((payload as { type?: unknown }).type)) {
throw this.report("INVALID_ARGUMENT", `Unsupported WebEngine command: ${String((payload as { type?: unknown }).type)}`, true);
}
const result = await this.request({ type: "applyCommand", payload });
if (!result.snapshot || !result.delta) throw this.report("INVALID_ARGUMENT", "WebEngine 未返回命令结果", true);
this.geometryBuffers = result.geometryDelta

View File

@@ -11,6 +11,7 @@ import { normalizeProjectAssetPath } from "../../../protocol/asset-path";
import { cloneMeshGeometryBuffers, diffMeshGeometryBuffers } from "../../../protocol/mesh-geometry-delta";
import { gateSculptCapability, parseSculptMeshAttributes, parseSculptStroke } from "../../../protocol/sculpt";
import { gateGeometryNodeGraph } from "../../../protocol/geometry-nodes";
import { parsePhysicsSimulationManifest } from "../../../protocol/physics-simulation";
import { gateShaderGraph } from "../../../protocol/shader-graph";
import { compileShaderGraph, type ShaderCompileReport } from "../../../protocol/shader-compiler";
import { gateNlaTracks, moveNlaStrip } from "../../../protocol/nla";
@@ -566,7 +567,17 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
const ownerObjectId = snapshot?.nodes.find((node) => node.dataId === payload.meshId)?.id;
const gate = gateGeometryNodeGraph(payload.graph, { availableResourceIds, blockedResourceIds, ownerObjectId });
if (gate.status === "BLOCKED") rejectCapabilityGate(gate);
throw report("CAPABILITY_MISSING", "N-012 Geometry Nodes evaluator is not enabled in the WebEngine Main writer");
return;
}
case "setPhysicsSimulation": {
try {
parsePhysicsSimulationManifest(payload.manifest);
return;
}
catch (error) {
reportProtocolError(error, "PHYSICS_MANIFEST_INVALID");
}
return;
}
case "setShaderGraph": {
const gate = gateShaderGraph(payload.graph, {

View File

@@ -0,0 +1,26 @@
import type { FCurveInterpolation } from "./web-engine";
import type { WebEngineEditCommand } from "./web-engine";
type AnimationCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
/** Typed constructors for animation, constraint and render property edits. */
export const animationRenderCommands = Object.freeze({
insertKeyframe(objectId: string, frame: number, property: AnimationCommand<"insertObjectKeyframe">["property"], interpolation: FCurveInterpolation = "BEZIER"): AnimationCommand<"insertObjectKeyframe"> {
return { type: "insertObjectKeyframe", objectId, frame, property, interpolation };
},
deleteKeyframe(objectId: string, frame: number, property?: AnimationCommand<"deleteObjectKeyframe">["property"]): AnimationCommand<"deleteObjectKeyframe"> {
return { type: "deleteObjectKeyframe", objectId, frame, ...(property ? { property } : {}) };
},
setConstraint(objectId: string, constraintName: string, enabled: boolean, influence: number): AnimationCommand<"setConstraint"> {
return { type: "setConstraint", objectId, constraintName, enabled, influence };
},
setLightProperties(dataId: string, properties: AnimationCommand<"setLightProperties">["properties"]): AnimationCommand<"setLightProperties"> {
return { type: "setLightProperties", dataId, properties };
},
setCameraProperties(dataId: string, properties: AnimationCommand<"setCameraProperties">["properties"]): AnimationCommand<"setCameraProperties"> {
return { type: "setCameraProperties", dataId, properties };
},
setWorldProperties(dataId: string, properties: AnimationCommand<"setWorldProperties">["properties"]): AnimationCommand<"setWorldProperties"> {
return { type: "setWorldProperties", dataId, properties };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -0,0 +1,43 @@
import type { WebEngineEditCommand } from "./web-engine";
type CoreCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
type Vector3 = [number, number, number];
const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1] ?? 0, value[2] ?? 0];
/** Typed constructors used by the workbench for the source-backed scene API. */
export const coreSceneCommands = Object.freeze({
createCube(location: readonly number[] = [0, 0, 0], name?: string): CoreCommand<"createPrimitive"> {
return { type: "createPrimitive", primitive: "CUBE", location: vector3(location), ...(name ? { name } : {}) };
},
duplicate(objectId: string, offset: readonly number[] = [0.25, 0.25, 0], linked = false): CoreCommand<"duplicateObject"> {
return { type: "duplicateObject", objectId, offset: vector3(offset), ...(linked ? { linked: true } : {}) };
},
delete(objectId: string): CoreCommand<"deleteObject"> {
return { type: "deleteObject", objectId };
},
visibility(objectId: string, visible: boolean): CoreCommand<"setObjectVisibility"> {
return { type: "setObjectVisibility", objectId, visible };
},
transform(objectId: string, translation: readonly number[], rotationEuler: readonly number[], scale: readonly number[]): CoreCommand<"setObjectTransform"> {
return { type: "setObjectTransform", objectId, translation: vector3(translation), rotationEuler: vector3(rotationEuler), scale: vector3(scale) };
},
parent(objectId: string, parentId: string | null, keepTransform = true): CoreCommand<"setParent"> {
return { type: "setParent", objectId, parentId, keepTransform };
},
createCollection(name: string, parentCollectionId?: string | null): CoreCommand<"createCollection"> {
return { type: "createCollection", name, ...(parentCollectionId === undefined ? {} : { parentCollectionId }) };
},
moveToCollection(objectId: string, collectionId: string): CoreCommand<"moveObjectToCollection"> {
return { type: "moveObjectToCollection", objectId, collectionId };
},
rename(id: string, name: string): CoreCommand<"renameId"> {
return { type: "renameId", id, name };
},
applyTransform(objectId: string): CoreCommand<"applyObjectTransform"> {
return { type: "applyObjectTransform", objectId };
},
setOrigin(objectId: string, mode: CoreCommand<"setObjectOrigin">["mode"] = "GEOMETRY"): CoreCommand<"setObjectOrigin"> {
return { type: "setObjectOrigin", objectId, mode };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -0,0 +1,13 @@
import type { WebEngineEditCommand } from "./web-engine";
type IOCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
/** Typed constructors for media import and persistence-facing edit commands. */
export const ioMediaCommands = Object.freeze({
importImage(name: string, mimeType: IOCommand<"importImage">["mimeType"], width: number, height: number, base64: string): IOCommand<"importImage"> {
return { type: "importImage", name, mimeType, width, height, base64 };
},
setMaterialImageNode(materialId: string, imageId: string, usage: IOCommand<"setMaterialImageNode">["usage"], uvMap?: string): IOCommand<"setMaterialImageNode"> {
return { type: "setMaterialImageNode", materialId, imageId, usage, ...(uvMap ? { uvMap } : {}) };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -0,0 +1,35 @@
import type { MeshEditOperation, MeshElementMode } from "./web-engine";
import type { SimplifyProfile } from "./simplify";
import type { WebEngineEditCommand } from "./web-engine";
type MeshCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
type Vector3 = [number, number, number];
const vector3 = (value: readonly number[]): Vector3 => [value[0] ?? 0, value[1] ?? 0, value[2] ?? 0];
/** Typed constructors for mesh authoring commands sent to WebEngine Main. */
export const meshAuthoringCommands = Object.freeze({
edit(meshId: string, operation: MeshEditOperation, selectionMode: MeshElementMode, elementIndices: readonly number[], options: { offset?: readonly number[]; amount?: number; segments?: number } = {}): MeshCommand<"meshEdit"> {
return {
type: "meshEdit", meshId, operation, selectionMode, elementIndices: [...elementIndices],
...(options.offset ? { offset: vector3(options.offset) } : {}),
...(options.amount === undefined ? {} : { amount: options.amount }),
...(options.segments === undefined ? {} : { segments: options.segments }),
};
},
decimate(meshId: string, profile: SimplifyProfile): MeshCommand<"decimateMesh"> {
return { type: "decimateMesh", meshId, profile };
},
previewDecimate(meshId: string, profile: SimplifyProfile): MeshCommand<"previewDecimateMesh"> {
return { type: "previewDecimateMesh", meshId, profile };
},
translateVertices(meshId: string, vertexIndices: readonly number[], offset: readonly number[]): MeshCommand<"translateMeshVertices"> {
return { type: "translateMeshVertices", meshId, vertexIndices: [...vertexIndices], offset: vector3(offset) };
},
createUVMap(meshId: string, name: string): MeshCommand<"createUVMap"> {
return { type: "createUVMap", meshId, name };
},
activeUVMap(meshId: string, name: string): MeshCommand<"setActiveUVMap"> {
return { type: "setActiveUVMap", meshId, name };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -0,0 +1,28 @@
import type { WebEngineEditCommand } from "./web-engine";
type NonMeshCommand<T extends WebEngineEditCommand["type"]> = Extract<WebEngineEditCommand, { type: T }>;
/** Typed constructors for curve, font, volume and Grease Pencil editing. */
export const nonMeshGeometryCommands = Object.freeze({
createCurve(name: string, controlPoints: readonly number[], cyclic = false, resolution = 12): NonMeshCommand<"createCurve"> {
return { type: "createCurve", curveType: "CURVE", name, controlPoints: [...controlPoints], cyclic, resolution };
},
setCurveControlPoints(dataId: string, controlPoints: readonly number[], splineOffsets?: readonly number[], resolution?: number): NonMeshCommand<"setCurveControlPoints"> {
return { type: "setCurveControlPoints", dataId, controlPoints: [...controlPoints], ...(splineOffsets ? { splineOffsets: [...splineOffsets] } : {}), ...(resolution === undefined ? {} : { resolution }) };
},
setFontBody(dataId: string, body: string): NonMeshCommand<"setFontBody"> {
return { type: "setFontBody", dataId, body };
},
setVolumeProperties(dataId: string, sourcePath: string, displayDensity: number, interpolation: "NEAREST" | "LINEAR", stepSize: number): NonMeshCommand<"setVolumeProperties"> {
return { type: "setVolumeProperties", dataId, sourcePath, displayDensity, interpolation, stepSize };
},
createGreasePencilLayer(dataId: string, name: string): NonMeshCommand<"createGreasePencilLayer"> {
return { type: "createGreasePencilLayer", dataId, name };
},
removeGreasePencilLayer(dataId: string, layerId: string): NonMeshCommand<"removeGreasePencilLayer"> {
return { type: "removeGreasePencilLayer", dataId, layerId };
},
clearGreasePencilFrame(dataId: string, layerId: string, frame: number, baseRevision?: number): NonMeshCommand<"setGreasePencilStrokes"> {
return { type: "setGreasePencilStrokes", dataId, layerId, frame, ...(baseRevision === undefined ? {} : { baseRevision }), strokes: [] };
},
} satisfies Record<string, (...args: never[]) => WebEngineEditCommand>);

View File

@@ -0,0 +1,14 @@
import type { RenderCapabilityRequest } from "./render-capabilities";
/** Canonical capability requests shared by export and future compositor UI. */
export const renderCompositorCommands = Object.freeze({
boundedShaderGraph(): RenderCapabilityRequest {
return { kind: "ARBITRARY_SHADER", nodeTypes: ["PRINCIPLED", "MATERIAL_OUTPUT"] };
},
webGPUBackend(): RenderCapabilityRequest {
return { kind: "WEBGPU_BACKEND" };
},
postProcess(passes: Extract<RenderCapabilityRequest, { kind: "POSTPROCESS" }>["passes"]): RenderCapabilityRequest {
return { kind: "POSTPROCESS", passes: [...passes] };
},
} as const);

View File

@@ -0,0 +1,28 @@
import type { ShaderGraphIR, ShaderSocketIR } from "./shader-graph";
const input = (id: string, name: string, dataType: ShaderSocketIR["dataType"], defaultValue?: ShaderSocketIR["defaultValue"]): ShaderSocketIR => ({ id, name, direction: "INPUT", dataType, ...(defaultValue === undefined ? {} : { defaultValue }) });
const output = (id: string, name: string, dataType: ShaderSocketIR["dataType"]): ShaderSocketIR => ({ id, name, direction: "OUTPUT", dataType });
/** Build a valid bounded graph for the source-backed Principled writer. */
export const shaderGraphCommands = Object.freeze({
setPrincipledGraph(materialId: string, baseColor: readonly number[], roughness: number, metallic: number): ShaderGraphIR {
const principled = "shader:principled";
const materialOutput = "shader:material-output";
return {
schemaVersion: 1,
id: `shader-graph:${materialId}`,
materialId,
nodes: [
{ id: principled, type: "PRINCIPLED", name: "Principled BSDF", sockets: [
input("Base Color", "Base Color", "COLOR", [...baseColor].slice(0, 4)),
input("Metallic", "Metallic", "VALUE", metallic),
input("Roughness", "Roughness", "VALUE", roughness),
output("BSDF", "BSDF", "SHADER"),
] },
{ id: materialOutput, type: "MATERIAL_OUTPUT", name: "Material Output", sockets: [input("Surface", "Surface", "SHADER")] },
],
links: [{ fromNodeId: principled, fromSocketId: "BSDF", toNodeId: materialOutput, toSocketId: "Surface" }],
outputNodeId: materialOutput,
};
},
} as const);

View File

@@ -0,0 +1,98 @@
import type { WebEngineEditCommand } from "./web-engine";
export const WEB_ENGINE_CAPABILITY_REGISTRY_SCHEMA = 1 as const;
export type WebEngineCommandType = WebEngineEditCommand["type"];
export type WebEngineCommandRoute = "MAIN_AUTHORITATIVE" | "MAIN_GUARDED" | "LOCAL_HISTORY" | "PREVIEW";
export interface WebEngineCommandCapability {
readonly family: string;
readonly route: WebEngineCommandRoute;
readonly implementation: "SOURCE_BACKED" | "SOURCE_GUARDED";
}
/**
* The protocol is intentionally the type authority; this record makes the
* runtime dispatch contract explicit without duplicating command payloads.
*/
export const WEB_ENGINE_COMMAND_CAPABILITIES = {
setFrame: { family: "TIMELINE", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setObjectVisibility: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setModifierEnabled: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setModifierVisibility: { family: "MODIFIER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
decimateMesh: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
previewDecimateMesh: { family: "MESH", route: "PREVIEW", implementation: "SOURCE_BACKED" },
createPrimitive: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
duplicateObject: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
deleteObject: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setObjectTransform: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
translateMeshVertices: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
meshEdit: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
createUVMap: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setActiveUVMap: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
unwrapUV: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
addMaterialSlot: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
removeMaterialSlot: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
assignMaterialFaces: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setMaterialPrincipled: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
importImage: { family: "IO", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setMaterialImageNode: { family: "MATERIAL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
insertObjectKeyframe: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
deleteObjectKeyframe: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFCurveInterpolation: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setActiveAction: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setConstraint: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setParent: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
createCollection: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
moveObjectToCollection: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
renameId: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
joinObjects: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
separateMeshFaces: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
applyObjectTransform: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setObjectOrigin: { family: "OBJECT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
createCurve: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
deleteNonMeshData: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setCurveControlPoints: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setCurveHandle: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setCurveTopology: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setCurveSplines: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSurfaceTopology: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFontBody: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFontProperties: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFontAdvanced: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
importVFont: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setFontLinks: { family: "FONT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setVolumeProperties: { family: "VOLUME", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
createGreasePencilLayer: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
removeGreasePencilLayer: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
moveGreasePencilLayer: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
moveGreasePencilFrame: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
insertGreasePencilFrame: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
removeGreasePencilFrame: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setGreasePencilStrokes: { family: "GREASE_PENCIL", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setVertexColors: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setVertexWeights: { family: "MESH", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setLightProperties: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setCameraProperties: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setWorldProperties: { family: "RENDER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setMetaballElements: { family: "GEOMETRY", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
sculptStroke: { family: "SCULPT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setSculptMeshAttributes: { family: "SCULPT", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setGeometryNodeGraph: { family: "GEOMETRY_NODES", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setPhysicsSimulation: { family: "SIMULATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setShaderGraph: { family: "SHADER", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
setNLAStack: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
moveNLAStrip: { family: "ANIMATION", route: "MAIN_AUTHORITATIVE", implementation: "SOURCE_BACKED" },
undo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
redo: { family: "HISTORY", route: "LOCAL_HISTORY", implementation: "SOURCE_BACKED" },
} satisfies Record<WebEngineCommandType, WebEngineCommandCapability>;
export function getWebEngineCommandCapability(type: unknown): WebEngineCommandCapability | null {
if (typeof type !== "string") return null;
return Object.prototype.hasOwnProperty.call(WEB_ENGINE_COMMAND_CAPABILITIES, type)
? WEB_ENGINE_COMMAND_CAPABILITIES[type as WebEngineCommandType]
: null;
}
export function isKnownWebEngineCommand(type: unknown): type is WebEngineCommandType {
return getWebEngineCommandCapability(type) !== null;
}

View File

@@ -9,6 +9,7 @@ import type { LODManifest } from "./simplify";
import type { DepsgraphEvaluationIR } from "./depsgraph";
import type { SculptMeshAttributesIR, SculptStrokeIR } from "./sculpt";
import type { GeometryNodeGraphIR } from "./geometry-nodes";
import type { PhysicsSimulationManifestIR } from "./physics-simulation";
import type { ShaderGraphIR } from "./shader-graph";
import type { ShaderCompileReport } from "./shader-compiler";
import type { NlaMoveStripCommand, NlaTrackIR } from "./nla";
@@ -141,6 +142,7 @@ export type WebEngineEditCommand =
| { type: "sculptStroke"; stroke: SculptStrokeIR }
| { type: "setSculptMeshAttributes"; attributes: SculptMeshAttributesIR }
| { type: "setGeometryNodeGraph"; meshId: string; graph: GeometryNodeGraphIR }
| { type: "setPhysicsSimulation"; manifest: PhysicsSimulationManifestIR }
| { type: "setShaderGraph"; materialId: string; graph: ShaderGraphIR }
| { type: "setNLAStack"; objectId: string; tracks: NlaTrackIR[]; baseRevision?: number }
| NlaMoveStripCommand

View File

@@ -0,0 +1,16 @@
import { getWebEngineCommandCapability, type WebEngineCommandType } from "./web-engine-capabilities";
export const WORKBENCH_COMMAND_GROUPS = Object.freeze({
OBJECT: ["createPrimitive", "duplicateObject", "deleteObject", "setObjectTransform", "setParent", "createCollection", "moveObjectToCollection", "renameId", "applyObjectTransform", "setObjectOrigin"],
MESH: ["meshEdit", "translateMeshVertices", "createUVMap", "setActiveUVMap", "unwrapUV", "setVertexColors", "setVertexWeights"],
ANIMATION: ["setFrame", "insertObjectKeyframe", "deleteObjectKeyframe", "setFCurveInterpolation", "setConstraint", "setNLAStack"],
RENDER: ["setCameraProperties", "setLightProperties", "setWorldProperties"],
IO: ["importImage", "importVFont", "setMaterialImageNode"],
} as const satisfies Record<string, readonly WebEngineCommandType[]>);
export function isWorkbenchCommandAvailable(type: unknown): type is WebEngineCommandType {
return getWebEngineCommandCapability(type)?.route !== "MAIN_GUARDED";
}
export function workbenchCommandCount(): number {
return Object.values(WORKBENCH_COMMAND_GROUPS).reduce((count, group) => count + group.filter(isWorkbenchCommandAvailable).length, 0);
}