feat(web): harden continuous implementation handoff
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 07:17:09 +08:00
parent 43d0c437bb
commit 01d535b362
43 changed files with 2061 additions and 54 deletions

View File

@@ -15,6 +15,7 @@ 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 { parseLibraryMainAppendRequest, type LibraryMainAppendRequestIR } from "../../../protocol/library-main-append";
import type { RenderCapabilityRequest } from "../../../protocol/render-capabilities";
import { compositorCommands } from "../../../protocol/compositor-commands";
import { geometryNodeCommands } from "../../../protocol/geometry-node-commands";
@@ -641,7 +642,7 @@ function GreasePencilCanvas2D({ drawing, scope, selection, onSelect }: {
onKeyDown={keyboardSelect} />;
}
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onRequestAsset, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, greasePencilSelection, onGreasePencilCanvasPointSelect, onCommand, onImportImage, onRequestAsset, onAppendLibraryObject, onQueryRenderCapability, onApplyDecimate, onPreviewDecimate, onGenerateLOD, onSetModifierEnabled, previewActive, onCancelPreview }: {
snapshot: SceneSnapshotIR | null;
selectedFaceIndices: number[];
selectedVertexIndices: number[];
@@ -650,6 +651,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
onCommand: (command: WebEngineEditCommand) => void;
onImportImage: (file: File) => void;
onRequestAsset: (assetId: string) => void;
onAppendLibraryObject: (source: ArrayBuffer, request: LibraryMainAppendRequestIR) => Promise<void>;
onQueryRenderCapability: (request: RenderCapabilityRequest) => void;
onApplyDecimate: (profile: SimplifyProfile, meshId: string) => void;
onPreviewDecimate: (profile: SimplifyProfile, meshId: string) => void;
@@ -698,6 +700,9 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const [curveTopologyError, setCurveTopologyError] = useState("");
const [fontBody, setFontBody] = useState("");
const [textSource, setTextSource] = useState("");
const [librarySourceFile, setLibrarySourceFile] = useState<File | null>(null);
const [libraryRequestFile, setLibraryRequestFile] = useState<File | null>(null);
const [libraryAppendError, setLibraryAppendError] = useState("");
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const activeMesh = activeNode?.dataId ? snapshot?.meshes.find((mesh) => mesh.id === activeNode.dataId) : undefined;
const activeCurve = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "CURVE") : undefined;
@@ -706,6 +711,7 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
const activeSound = activeNode?.dataId ? snapshot?.sounds?.find((data) => data.id === activeNode.dataId) : undefined;
const activeSpeaker = activeNode?.dataId ? snapshot?.speakers?.find((data) => data.id === activeNode.dataId) : undefined;
const activeParticleSettings = activeNode?.dataId ? snapshot?.particleSettings?.find((data) => data.id === activeNode.dataId) : undefined;
const activeTexture = activeNode?.dataId ? snapshot?.textures?.find((data) => data.id === activeNode.dataId) : undefined;
const activeVolume = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "VOLUME") : undefined;
const activeMetaball = activeNode?.dataId ? snapshot?.nonMeshData?.find((data) => data.id === activeNode.dataId && data.type === "METABALL") : undefined;
const activeGreasePencil = activeNode?.dataId ? snapshot?.greasePencils?.find((data) => data.id === activeNode.dataId) : undefined;
@@ -864,6 +870,18 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
setCurveTopologyError(error instanceof Error ? error.message : String(error));
}
};
const appendLibrary = async (): Promise<void> => {
if (!snapshot || !librarySourceFile || !libraryRequestFile) return;
try {
const requestValue: unknown = JSON.parse(await libraryRequestFile.text());
const request = await parseLibraryMainAppendRequest(requestValue, snapshot.revision);
setLibraryAppendError("");
await onAppendLibraryObject(await librarySourceFile.arrayBuffer(), request);
}
catch (error) {
setLibraryAppendError(error instanceof Error ? error.message : String(error));
}
};
const toggleDelimit = (value: SimplifyDelimit): void => {
setDelimit((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value]);
};
@@ -913,6 +931,8 @@ function Properties({ snapshot, selectedFaceIndices, selectedVertexIndices, grea
{activeSound ? <div className="property-section" data-testid="audio-workbench"><h3>Sound</h3><output>{activeSound.name} · {activeSound.audioChannels}ch @ {activeSound.sampleRate}Hz</output><output>Volume {activeSound.volume.toFixed(2)} · Pitch {activeSound.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { volume: Math.min(1000000, activeSound.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSoundProperties(activeSound.id, { pitch: Math.min(1000000, activeSound.pitch + 0.05) }))}>Increase Pitch</button></div></div> : null}
{activeSpeaker ? <div className="property-section" data-testid="speaker-workbench"><h3>Speaker</h3><output>{activeSpeaker.name} · {activeSpeaker.soundId ?? "No sound"}</output><output>Volume {activeSpeaker.volume.toFixed(2)} · Pitch {activeSpeaker.pitch.toFixed(2)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { volume: Math.min(1000000, activeSpeaker.volume + 0.1) }))}>Increase Volume</button><button type="button" onClick={() => onCommand(ioMediaCommands.setSpeakerProperties(activeSpeaker.id, { coneVolumeOuter: Math.max(0, Math.min(1, activeSpeaker.coneVolumeOuter - 0.05)) }))}>Reduce Cone</button></div></div> : null}
{activeParticleSettings ? <div className="property-section" data-testid="particle-settings-workbench"><h3>Particle Settings</h3><output>{activeParticleSettings.name} · {activeParticleSettings.totalParticles} particles</output><output>Lifetime {activeParticleSettings.lifetime.toFixed(2)} · Size {activeParticleSettings.size.toFixed(3)}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { totalParticles: Math.min(1000000, activeParticleSettings.totalParticles + 100) }))}>Increase Count</button><button type="button" onClick={() => onCommand(ioMediaCommands.setParticleSettings(activeParticleSettings.id, { size: Math.min(1000000, activeParticleSettings.size + 0.01) }))}>Increase Size</button></div></div> : null}
{activeTexture ? <div className="property-section" data-testid="texture-properties-workbench"><h3>Texture</h3><output>{activeTexture.name} · Type {activeTexture.type}</output><output>Noise {activeTexture.noiseScale.toFixed(3)} · Depth {activeTexture.noiseDepth}</output><div className="property-actions"><button type="button" onClick={() => onCommand(ioMediaCommands.setTextureProperties(activeTexture.id, { noiseScale: Math.min(1000000, activeTexture.noiseScale + 0.05) }))}>Increase Noise</button><button type="button" onClick={() => onCommand(ioMediaCommands.setTextureProperties(activeTexture.id, { noiseDepth: Math.min(30, activeTexture.noiseDepth + 1) }))}>Increase Depth</button></div></div> : null}
{(snapshot?.libraries?.length ?? 0) > 0 ? <div className="property-section" data-testid="library-append-workbench"><h3>Library Append</h3>{snapshot?.libraries?.slice(0, 16).map((library) => <output key={library.id} data-resource-id={library.id}>{library.name} · {library.status} · {library.sourcePath}</output>)}<label>Source .blend <input aria-label="Library source blend" type="file" accept=".blend,application/octet-stream" onChange={(event) => { setLibrarySourceFile(event.target.files?.[0] ?? null); setLibraryAppendError(""); }} /></label><label>Binding JSON <input aria-label="Library append binding" type="file" accept="application/json,.json" onChange={(event) => { setLibraryRequestFile(event.target.files?.[0] ?? null); setLibraryAppendError(""); }} /></label><button type="button" disabled={!librarySourceFile || !libraryRequestFile} onClick={() => void appendLibrary()}>Append to Main</button>{libraryAppendError ? <output role="alert">{libraryAppendError}</output> : null}</div> : null}
{activeCamera ? <div className="property-section" data-testid="camera-workbench"><h3>Camera</h3><output>{activeCamera.projection} · {activeCamera.lensMm.toFixed(1)}mm</output><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { projection: activeCamera.projection === "ORTHOGRAPHIC" ? "PERSPECTIVE" : "ORTHOGRAPHIC" }))}>切换投影</button><button type="button" onClick={() => onCommand(animationRenderCommands.setCameraProperties(activeCamera.id, { lensMm: Math.min(10000, activeCamera.lensMm + 5) }))}>增加焦距</button></div> : null}
{activeLight ? <div className="property-section" data-testid="light-workbench"><h3>Light</h3><output>{activeLight.energy.toFixed(1)} energy</output><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { energy: Math.min(1e9, activeLight.energy + 10) }))}>增加能量</button><button type="button" onClick={() => onCommand(animationRenderCommands.setLightProperties(activeLight.id, { castsShadow: !(activeLight.castsShadow ?? true) }))}>切换阴影</button></div> : null}
{activeWorld ? <div className="property-section" data-testid="world-workbench"><h3>World</h3><output>Exposure {activeWorld.exposure.toFixed(2)}</output><button type="button" onClick={() => onCommand(animationRenderCommands.setWorldProperties(activeWorld.id, { exposure: Math.max(-20, Math.min(20, activeWorld.exposure + 0.5)) }))}>增加曝光</button></div> : null}
@@ -2546,7 +2566,7 @@ export function App() {
<div className="workspace-grid">
<Area className="viewport-area" editor="3D Viewport"><ViewportPlaceholder snapshot={preview?.snapshot ?? snapshot} geometryBuffers={preview?.geometryBuffers ?? geometryBuffers} nonMeshGeometryBuffers={preview?.nonMeshGeometryBuffers ?? nonMeshGeometryBuffers} textureAssets={gpuTextureAssets} volumeProject={volumeProject} lodLevels={preview ? null : lodLevels} selectedObjectIds={selectedObjectIds} editMode={uiState.context.mode === "Edit"} meshSelection={meshSelection} greasePencilSelectionRevision={greasePencilSelection?.revision ?? 0} onSelect={selectObject} onElementSelect={selectMeshElement} onGreasePencilPointSelect={selectGreasePencilPoint} onGreasePencilMarqueeSelect={selectGreasePencilMarquee} onTransform={transformActive} onDiagnostic={recordDiagnostic} /></Area>
<Area className="outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="properties-area" editor="Properties"><Properties snapshot={snapshot} selectedFaceIndices={meshSelection.mode === "FACE" ? [...meshSelection.indices] : []} selectedVertexIndices={meshSelection.mode === "VERT" ? [...meshSelection.indices] : []} greasePencilSelection={greasePencilSelection} onGreasePencilCanvasPointSelect={(point, additive, baseSelectionRevision) => { commitGreasePencilSelection("CANVAS_2D", additive ? "TOGGLE" : "REPLACE", [point], point, baseSelectionRevision); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onRequestAsset={(assetId) => { const client = webClientRef.current; if (!client) return; void client.requestAsset(assetId).then((asset) => setEngineStatus(`Asset: ${asset.assetId} (${asset.status})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "PBR_ASSET_INVALID", error, { assetId }))); }} onAppendLibraryObject={async (source, request) => { const client = webClientRef.current; if (!client) return; try { const result = await client.appendLibraryObject(source, request); setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; setEngineStatus(`Library append: ${result.receipt.sourceDataBlockId}`); } catch (error) { setEngineStatus(recordDiagnostic("ENGINE", "COMMAND_FAILED", error)); } }} onQueryRenderCapability={(request) => { const client = webClientRef.current; if (!client) return; void client.queryRenderCapability(request).then((gate) => setEngineStatus(`Render: ${gate.status} (${gate.capability})`)).catch((error: unknown) => setEngineStatus(recordDiagnostic("ENGINE", "GLB_EXPORT_BLOCKED", error))); }} onApplyDecimate={applyDecimate} onPreviewDecimate={previewDecimate} onGenerateLOD={(meshId, triangleCount) => void generateLOD(meshId, triangleCount)} onSetModifierEnabled={setModifierEnabled} previewActive={Boolean(preview)} onCancelPreview={() => { setPreview(null); setLodLevels(null); setEngineStatus(`Engine: SceneIR r${snapshot?.revision ?? 0}`); }} /></Area>
<Area className="timeline-area" editor="Timeline"><Timeline snapshot={snapshot} frame={frame} start={frameRange.start} end={frameRange.end} onFrameChange={(value) => void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /></Area>
</div>
{uiState.operatorSearchOpen ? <OperatorSearch commands={operatorCommands} onClose={closeOperatorSearch} /> : null}

Binary file not shown.

View File

@@ -447,6 +447,16 @@ function assertFutureCapability(payload: Extract<WebEngineRequest["command"], {
}
return;
}
case "setTextureProperties": {
const texture = currentSnapshot?.textures?.find((candidate) => candidate.id === payload.dataId);
if (!texture) throw report("NON_MESH_DATA_UNSUPPORTED", `Texture data block is unavailable: ${payload.dataId}`);
if (!validPropertyKeys(payload.properties, ["noiseScale", "noiseDepth", "intensity", "contrast", "saturation"])) throw report("NON_MESH_PROPERTY_INVALID", "Texture properties are empty or contain unsupported fields");
for (const [key, value] of Object.entries(payload.properties)) {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 1_000_000) throw report("NON_MESH_PROPERTY_INVALID", `Texture setting ${key} is outside the bounded range`);
if (key === "noiseDepth" && (!Number.isSafeInteger(value) || value > 30)) throw report("NON_MESH_PROPERTY_INVALID", "Texture noise depth must be an integer between 0 and 30");
}
return;
}
case "setVolumeProperties": {
const data = currentSnapshot?.nonMeshData?.find((candidate) => candidate.id === payload.dataId);
if (!data || data.type !== "VOLUME") throw report("NON_MESH_DATA_UNSUPPORTED", `N-015 Volume data block is unavailable: ${payload.dataId}`);