feat(web): switch to implementation-first capability wave
This commit is contained in:
@@ -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" }); } },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
26
web/protocol/animation-render-commands.ts
Normal file
26
web/protocol/animation-render-commands.ts
Normal 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>);
|
||||
43
web/protocol/core-scene-commands.ts
Normal file
43
web/protocol/core-scene-commands.ts
Normal 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>);
|
||||
13
web/protocol/io-media-commands.ts
Normal file
13
web/protocol/io-media-commands.ts
Normal 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>);
|
||||
35
web/protocol/mesh-authoring-commands.ts
Normal file
35
web/protocol/mesh-authoring-commands.ts
Normal 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>);
|
||||
28
web/protocol/nonmesh-geometry-commands.ts
Normal file
28
web/protocol/nonmesh-geometry-commands.ts
Normal 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>);
|
||||
14
web/protocol/render-compositor-commands.ts
Normal file
14
web/protocol/render-compositor-commands.ts
Normal 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);
|
||||
28
web/protocol/shader-graph-commands.ts
Normal file
28
web/protocol/shader-graph-commands.ts
Normal 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);
|
||||
98
web/protocol/web-engine-capabilities.ts
Normal file
98
web/protocol/web-engine-capabilities.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
16
web/protocol/workbench-command-groups.ts
Normal file
16
web/protocol/workbench-command-groups.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user