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, {
|
||||
|
||||
Reference in New Issue
Block a user