Add full Blender desktop parity validation workflow
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

This commit is contained in:
mes123456
2026-09-08 05:18:12 +08:00
parent 39b188c574
commit a69e41457a
8 changed files with 564 additions and 23 deletions

View File

@@ -82,7 +82,7 @@ import {
} from "../volume/nanovdb-viewport";
import { composePaintColorPatch, composePaintWeightPatch, createSculptMeshAttributes, createSculptStroke } from "../../../protocol/paint";
import type { SculptBrush, SculptStrokeSampleIR } from "../../../protocol/sculpt";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand } from "../../../protocol/ui-schema";
import { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand, type EditorType, type UICommand, type WorkspaceId } from "../../../protocol/ui-schema";
import { parseEditorWorkflow, type EditorWorkflowIR } from "../../../protocol/editor-workflow";
import { importExternalVFontIntoMain } from "../fonts/external-vfont-import";
import { createIMECompositionState, reduceIMEComposition, shouldBlockOperatorShortcuts, type IMECompositionEvent } from "../../../protocol/ime-composition";
@@ -418,12 +418,44 @@ interface AreaProps {
children: React.ReactNode;
}
const EDITOR_ICONS: Record<EditorType, string> = {
"3D Viewport": "◇",
Outliner: "☷",
Properties: "⚙",
Timeline: "▥",
"Dope Sheet": "◆",
"Image Editor": "▧",
"Render Result": "▧",
"Shader Editor": "⬡",
Compositor: "⬡",
"Geometry Nodes": "⬡",
"File Browser": "▤",
Spreadsheet: "▦",
"Text Editor": "▥",
"Python Console": ">_",
Info: "☰",
};
const WORKSPACE_EDITOR_MENUS: Record<WorkspaceId, string> = {
Layout: "View Select Add Object",
Modeling: "View Select Add Mesh Vertex Edge Face UV",
Sculpting: "Sculpt Mask Face Sets View",
"UV Editing": "View Select Image UV",
"Texture Paint": "View Paint Mask Face Selection",
Shading: "View Select Add Node",
Animation: "View Select Marker Channel Key",
Rendering: "View Image",
Compositing: "View Select Add Node",
"Geometry Nodes": "View Select Add Node",
Scripting: "View Text Templates",
};
function Area({ className = "", editor, children }: AreaProps) {
return (
<section className={`area-frame ${className}`} data-editor={editor}>
<header className="editor-header">
<button className="editor-selector" type="button" aria-label={`切换 ${editor} 编辑器`}>
<span className="editor-icon" aria-hidden="true">{editor === "3D Viewport" ? "◇" : editor === "Outliner" ? "☷" : editor === "Properties" ? "⚙" : "▥"}</span>
<span className="editor-icon" aria-hidden="true">{EDITOR_ICONS[editor]}</span>
<span>{editor}</span>
</button>
<span className="header-spacer" />
@@ -434,6 +466,47 @@ function Area({ className = "", editor, children }: AreaProps) {
);
}
function WorkspaceEditorSurface({ editor, snapshot, engineStatus, onOpen, onSearch }: {
editor: Exclude<EditorType, "Outliner" | "Properties" | "Timeline" | "Dope Sheet">;
snapshot: SceneSnapshotIR | null;
engineStatus: string;
onOpen: () => void;
onSearch: () => void;
}) {
const activeNode = snapshot?.nodes.find((node) => node.id === snapshot.activeObjectId);
const nodeCount = editor === "Geometry Nodes" ? snapshot?.geometryNodeGraphs?.length ?? 0 : snapshot?.materials.filter((material) => material.shaderGraphHash).length ?? 0;
if (editor === "Image Editor" || editor === "Render Result") {
return <div className={`workspace-editor-surface image-editor-surface${editor === "Render Result" ? " render-result-surface" : ""}`}>
<div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} {editor}</strong><span>View</span><span>Image</span>{editor === "Image Editor" ? <span>UV</span> : null}<button type="button" onClick={editor === "Render Result" ? onSearch : onOpen}>{editor === "Render Result" ? "Render" : "Open"}</button></div>
<div className="image-canvas"><div className="uv-island" aria-label="UV preview"><i /><i /><i /><i /></div><span>{editor === "Render Result" ? "Render Result" : activeNode?.name ?? "UVMap"}</span></div>
</div>;
}
if (editor === "Shader Editor" || editor === "Compositor" || editor === "Geometry Nodes") {
const labels = editor === "Shader Editor" ? ["Principled BSDF", "Material Output"] : editor === "Compositor" ? ["Render Layers", "Composite"] : ["Group Input", "Group Output"];
return <div className="workspace-editor-surface node-editor-surface">
<div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} {editor}</strong><span>View</span><span>Select</span><span>Add</span><span>Node</span><button type="button" onClick={onSearch}>Search</button></div>
<div className="node-canvas" data-node-editor={editor}><span className="node-link" aria-hidden="true" />{labels.map((label, index) => <button key={label} type="button" className={`node-card node-card-${index + 1}`} onClick={onSearch}><strong>{label}</strong><span>{index === 0 ? "Output" : "Surface"}</span></button>)}<output>{nodeCount} graphs</output></div>
</div>;
}
if (editor === "File Browser") {
return <div className="workspace-editor-surface file-browser-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Asset Browser</strong><button type="button" onClick={onOpen}>Import</button></div><div className="file-browser-body"><nav><span>Current File</span><span>Essentials</span><span>Unassigned</span></nav><div className="asset-grid"><button type="button" onClick={onOpen}><span>◇</span>Material</button><button type="button" onClick={onOpen}><span>◫</span>World</button></div></div></div>;
}
if (editor === "Spreadsheet") {
return <div className="workspace-editor-surface spreadsheet-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Spreadsheet</strong><span>Geometry</span><span>Original</span></div><table><thead><tr><th>Name</th><th>Type</th><th>Visible</th></tr></thead><tbody>{(snapshot?.nodes ?? []).slice(0, 24).map((node) => <tr key={node.id}><td>{node.name}</td><td>{node.type}</td><td>{node.visible ? "True" : "False"}</td></tr>)}</tbody></table>{!snapshot ? <span className="surface-empty">No geometry data</span> : null}</div>;
}
if (editor === "Text Editor") {
const source = snapshot?.texts?.[0]?.source ?? "# Blender Python\nimport bpy\n\n# Select a text data-block or create a new script.";
return <div className="workspace-editor-surface text-editor-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Text Editor</strong><span>View</span><span>Text</span><span>Templates</span><button type="button" onClick={onOpen}>Open</button><button type="button" onClick={onSearch}>Run</button></div><pre><span className="line-numbers">1{source.split("\n").slice(1).map((_, index) => `\n${index + 2}`)}</span><code>{source}</code></pre></div>;
}
if (editor === "Python Console") {
return <div className="workspace-editor-surface console-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Python Console</strong><span>View</span><span>Console</span></div><div className="console-output"><span>PYTHON INTERACTIVE CONSOLE 3.11</span><span>Blender 5.2.0 LTS</span><label><span>&gt;&gt;&gt;</span><input aria-label="Python Console command" value="" readOnly placeholder="Python execution is policy-gated" /></label></div></div>;
}
if (editor === "Info") {
return <div className="workspace-editor-surface info-surface"><div className="surface-menubar"><strong>{EDITOR_ICONS[editor]} Info</strong><span>View</span></div><output>{engineStatus}</output><output>Scene: {snapshot?.scenes[0]?.name ?? "Scene"}</output></div>;
}
return <div className="workspace-editor-surface camera-preview-surface"><span>Camera Perspective</span><div className="camera-frame"><span>{activeNode?.name ?? "Cube"}</span></div></div>;
}
interface MeshEditSelection {
meshId: string | null;
mode: MeshElementMode;
@@ -1981,7 +2054,9 @@ export function App() {
const saveBlendRef = useRef<() => Promise<void>>(async () => undefined);
const workspace = uiState.context.workspaceId;
const saved = !dirtyState.dirty;
const workspaceLabel = useMemo(() => `${workspace} Workspace`, [workspace]);
const workspaceLabel = WORKSPACE_EDITOR_MENUS[workspace];
const preferredWorkspaceMode = workspace === "Sculpting" ? "Sculpt" : workspace === "Texture Paint" ? "Texture Paint" : "Edit";
const workspaceNodeEditor: "Shader Editor" | "Compositor" | "Geometry Nodes" | null = workspace === "Shading" ? "Shader Editor" : workspace === "Compositing" ? "Compositor" : workspace === "Geometry Nodes" ? "Geometry Nodes" : null;
const activeGreasePencilDrawing = useMemo(() => currentGreasePencilDrawing(snapshot), [snapshot]);
const recordDiagnostic = (area: AppDiagnosticArea, code: AppDiagnosticCode, error: unknown, context?: Record<string, AppDiagnosticContextValue>): string => {
const entry = createAppDiagnosticEntry({
@@ -3521,12 +3596,19 @@ export function App() {
</header>
{workerFault ? <div className="worker-fault-banner" role="alert" data-testid="worker-fault-banner"><span>{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.</span><button type="button" data-testid="restart-and-recover" onClick={() => void restartWorkersAndRecover()} disabled={workerRecoveryStatus === "RUNNING"}>{workerRecoveryStatus === "RUNNING" ? "恢复中..." : "重启并恢复"}</button>{workerRecoveryStatus === "FAILED" ? <span data-testid="worker-recovery-error">Recovery failed; retry is safe.</span> : null}</div> : null}
{recentProjectIssues.length > 0 ? <div className="recent-project-repair-banner" role="alert" data-testid="recent-project-repair-banner"><span>最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。</span><div className="recent-project-repair-list">{recentProjectIssues.map((issue) => <div key={issue.project.projectId} className="recent-project-repair-item" data-project-id={issue.project.projectId} data-issue-code={issue.code}><span>{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}</span><button type="button" data-testid={`remove-recent-project-${issue.project.projectId}`} aria-label={`移除失效项目引用 ${issue.project.displayName}`} onClick={() => void removeInvalidRecentProject(issue.project.projectId)}>移除引用</button></div>)}</div></div> : null}
<div className="workspace-toolbar"><span>{workspaceLabel}</span><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? "Edit" : "Object" })}>{uiState.context.mode} Mode</button>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-toolbar"><button type="button" className="mode-chip" onClick={() => dispatchUI({ type: "setMode", mode: uiState.context.mode === "Object" ? preferredWorkspaceMode : "Object" })}>{uiState.context.mode} Mode</button><span className="workspace-editor-menus">{workspaceLabel}</span>{uiState.context.mode === "Edit" ? <><div className="segmented" aria-label="网格选择模式">{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => <button key={mode} type="button" className={meshSelection.mode === mode ? "active" : ""} onClick={() => setMeshSelectionMode(mode)}>{mode === "VERT" ? "1 Vertex" : mode === "EDGE" ? "2 Edge" : "3 Face"}</button>)}</div><button type="button" onClick={selectAllMeshElements}>Select All</button>{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => <button key={operation} type="button" disabled={meshSelection.indices.size === 0 && operation !== "RECALCULATE_NORMALS"} onClick={() => runMeshEdit(operation)}>{operation.replaceAll("_", " ")}</button>)}<button type="button" disabled={meshSelection.mode !== "FACE" || meshSelection.indices.size === 0 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "separateMeshFaces", objectId: snapshot.activeObjectId, faceIndices: [...meshSelection.indices], name: "Separated" })}>Separate</button></> : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => <button key={primitive} type="button" aria-label={primitive === "CUBE" ? "添加立方体" : `添加 ${primitive.toLowerCase().replaceAll("_", " ")}`} onClick={() => void applyEditCommand(coreSceneCommands.createPrimitive(primitive))}>Add {primitive.replaceAll("_", " ")}</button>)}<button type="button" aria-label="复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.25, 0.25, 0] })}>Duplicate</button><button type="button" aria-label="链接复制对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "duplicateObject", objectId: snapshot.activeObjectId, offset: [0.5, 0.5, 0], linked: true })}>Linked Duplicate</button><button type="button" aria-label="删除对象" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "deleteObject", objectId: snapshot.activeObjectId })}>Delete</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => { const child = snapshot?.activeObjectId; const parent = [...selectedObjectIds].find((id) => id !== child); if (child && parent) void applyEditCommand({ type: "setParent", objectId: child, parentId: parent, keepTransform: true }); }}>Parent</button><button type="button" disabled={!snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "setParent", objectId: snapshot.activeObjectId, parentId: null, keepTransform: true })}>Unparent</button><button type="button" disabled={selectedObjectIds.size < 2 || !snapshot?.activeObjectId} onClick={() => snapshot?.activeObjectId && void applyEditCommand({ type: "joinObjects", activeObjectId: snapshot.activeObjectId, objectIds: [...selectedObjectIds] })}>Join</button><button type="button" onClick={() => void applyEditCommand({ type: "createCollection", name: `Collection ${(snapshot?.collections.length ?? 0) + 1}` })}>New Collection</button></>}<span className="toolbar-spacer" /><span>{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}</span><output data-testid="dirty-status">{saved ? "已保存" : "未保存"}</output></div>
<div className="workspace-grid">
{workspace === "UV Editing" || workspace === "Texture Paint" || workspace === "Shading" || workspace === "Compositing" ? <Area className="image-editor-area" editor="Image Editor"><WorkspaceEditorSurface editor="Image Editor" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Rendering" ? <Area className="render-result-area" editor="Render Result"><WorkspaceEditorSurface editor="Render Result" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspaceNodeEditor ? <Area className="node-editor-area" editor={workspaceNodeEditor}><WorkspaceEditorSurface editor={workspaceNodeEditor} snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Shading" ? <Area className="file-browser-area" editor="File Browser"><WorkspaceEditorSurface editor="File Browser" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Animation" ? <Area className="camera-view-area" editor="3D Viewport"><WorkspaceEditorSurface editor="3D Viewport" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Geometry Nodes" ? <Area className="spreadsheet-area" editor="Spreadsheet"><WorkspaceEditorSurface editor="Spreadsheet" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area> : null}
{workspace === "Scripting" ? <><Area className="text-editor-area" editor="Text Editor"><WorkspaceEditorSurface editor="Text Editor" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="console-area" editor="Python Console"><WorkspaceEditorSurface editor="Python Console" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="info-area" editor="Info"><WorkspaceEditorSurface editor="Info" snapshot={snapshot} engineStatus={engineStatus} onOpen={() => fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /></Area><Area className="secondary-outliner-area" editor="Outliner"><Outliner snapshot={snapshot} onSelect={selectObject} onToggleVisibility={(id, visible) => void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /></Area></> : null}
<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 client={webClientRef.current} snapshot={snapshot} editorWorkflow={editorWorkflow} 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], baseSelectionRevision, point); }} onCommand={(command) => void applyEditCommand(command)} onImportImage={(file) => void importImage(file)} onImportVFont={(file) => void importVFont(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)); } }} onAssetLibraryCommand={onAssetLibraryCommand} 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>
<Area className="timeline-area" editor={workspace === "Animation" ? "Dope Sheet" : "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 ? <BlenderOperatorPalette client={webClientRef.current} commands={operatorCommands} onClose={closeOperatorSearch} onApplied={(result, route) => { setSnapshot(result.snapshot); setGeometryBuffers(result.geometryBuffers); setNonMeshGeometryBuffers(result.nonMeshGeometryBuffers ?? []); setFrame(result.snapshot.frame.current); projectRevisionRef.current = result.snapshot.revision; const routeLabel = (route as unknown as { identifier?: string; id?: string }).identifier ?? (route as unknown as { id?: string }).id ?? "unknown"; setEngineStatus(`Route: ${routeLabel}`); }} /> : null}
<footer className="status-bar"><span className="status-hint">LMB Select · MMB Rotate View · Wheel Zoom</span><span data-testid="scene-stats">Collection | Cube · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}</span>{openProgress ? <span className="open-progress" data-testid="open-progress" data-phase={openProgress.phase} data-stage={openProgress.stage} data-cancellable={openProgress.cancellable} data-bytes-read={openProgress.bytesRead} data-total-bytes={openProgress.totalBytes}><progress aria-label="打开文件进度" max={(openProgress.totalBytes ?? 0) > 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} /><span>{openProgress.message ?? "Opening"}</span>{canCancelOpenRead ? <button type="button" aria-label="取消打开" title="取消打开" onClick={cancelOpenFileRead}>×</button> : null}</span> : null}<span className="status-spacer" /><span className="runtime-status">{manifestStatus}</span><span className="runtime-status" data-testid="wasm-status">{wasmStatus}</span><span className="runtime-status" data-testid="engine-status">{engineStatus}</span><span className="runtime-status">{storageStatus}</span><StorageBudgetPanel budget={storageBudget} onCleanup={() => void cleanupProjectAssets()} /><span className="blender-version">5.2.0 LTS</span></footer>

View File

@@ -37,7 +37,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.topbar-context { padding: 2px 6px; color: #bdbdbd; border-left: 1px solid #383838; white-space: nowrap; }
.workspace-toolbar { gap: 2px; padding: 0 6px; overflow-x: auto; color: #bababa; background: #292929; white-space: nowrap; scrollbar-width: none; }
.workspace-toolbar::before { flex: none; color: #e67e22; font-size: 14px; content: "◇"; }
.workspace-toolbar > span:first-child { display: none; }
.workspace-editor-menus { flex: none; padding: 0 5px; color: #d0d0d0; }
.mode-chip { min-width: 86px; padding: 2px 8px; color: #e3e3e3; background: #343434 !important; border: 1px solid #484848; }
.segmented { display: inline-flex; flex: none; height: 22px; border: 1px solid #4b4b4b; border-radius: 2px; overflow: hidden; }.segmented button { min-height: 20px; border-radius: 0; border-right: 1px solid #4b4b4b; }.segmented button:last-child { border-right: 0; }.segmented button.active { color: #fff; background: #4775a3; }
.toolbar-spacer, .header-spacer, .status-spacer { flex: 1; }
@@ -81,11 +81,59 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.properties-content > [data-testid="feature-route-coverage"], .properties-content > [data-testid="release-status-workbench"] { order: 100; }
.property-section > button { min-height: 24px; margin: 2px; padding: 3px 7px; color: #e2e2e2; background: #454545; border: 1px solid #575757; border-radius: 2px; }
.property-section > button:hover { background: #555; }
.workspace-editor-surface { width: 100%; height: 100%; min-width: 0; min-height: 0; color: #c8c8c8; background: #282828; overflow: hidden; }
.image-editor-area > .editor-header, .render-result-area > .editor-header, .node-editor-area > .editor-header, .file-browser-area > .editor-header, .spreadsheet-area > .editor-header, .text-editor-area > .editor-header, .console-area > .editor-header, .info-area > .editor-header { display: none; }
.surface-menubar { display: flex; align-items: center; gap: 12px; height: 26px; padding: 0 7px; color: #c8c8c8; background: #292929; border-bottom: 1px solid #181818; white-space: nowrap; }
.surface-menubar strong { color: #ddd; font-size: 11px; font-weight: 500; }
.surface-menubar button { min-height: 22px; margin-left: auto; padding: 2px 8px; color: #ddd; background: #424242; border: 1px solid #555; border-radius: 2px; }
.image-canvas { position: relative; display: grid; place-items: center; height: calc(100% - 26px); color: #888; background-color: #202020; background-image: linear-gradient(45deg, #292929 25%, transparent 25%), linear-gradient(-45deg, #292929 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #292929 75%), linear-gradient(-45deg, transparent 75%, #292929 75%); background-position: 0 0, 0 8px, 8px -8px, -8px 0; background-size: 16px 16px; }
.uv-island { position: absolute; width: min(52%, 260px); aspect-ratio: 1; border: 1px solid #e58b35; transform: rotate(8deg); }
.uv-island::before, .uv-island::after, .uv-island i { position: absolute; background: #e58b35; content: ""; }
.uv-island::before { left: 50%; top: 0; width: 1px; height: 100%; }.uv-island::after { left: 0; top: 50%; width: 100%; height: 1px; }
.uv-island i:nth-child(1), .uv-island i:nth-child(2) { top: 0; width: 1px; height: 141%; transform-origin: top; }.uv-island i:nth-child(1) { left: 0; transform: rotate(-45deg); }.uv-island i:nth-child(2) { right: 0; transform: rotate(45deg); }
.uv-island i:nth-child(3), .uv-island i:nth-child(4) { width: 6px; height: 6px; border-radius: 50%; }.uv-island i:nth-child(3) { left: -3px; top: -3px; }.uv-island i:nth-child(4) { right: -3px; bottom: -3px; }
.render-result-surface .image-canvas { background-color: #181818; background-image: linear-gradient(45deg, #202020 25%, transparent 25%), linear-gradient(-45deg, #202020 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #202020 75%), linear-gradient(-45deg, transparent 75%, #202020 75%); }
.render-result-surface .uv-island { display: none; }
.node-canvas { position: relative; height: calc(100% - 26px); background-color: #1f1f1f; background-image: linear-gradient(#292929 1px, transparent 1px), linear-gradient(90deg, #292929 1px, transparent 1px); background-size: 20px 20px; overflow: hidden; }
.node-canvas > output { position: absolute; right: 8px; bottom: 6px; color: #aaa; }
.node-card { position: absolute; z-index: 2; display: grid; align-content: start; gap: 10px; width: 150px; height: 88px; padding: 26px 10px 8px; text-align: left; color: #ddd; background: #353535; border: 1px solid #565656; border-radius: 5px; box-shadow: 0 4px 10px #0007; }
.node-card::before { position: absolute; inset: 0 0 auto; height: 20px; background: #4775a3; border-radius: 4px 4px 0 0; content: ""; }.node-card strong { z-index: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.node-card span { color: #d0d0d0; }.node-card-1 { left: 14%; top: 32%; }.node-card-2 { right: 14%; top: 46%; }.node-link { position: absolute; left: 32%; top: 48%; width: 40%; height: 2px; background: #d09b45; transform: rotate(7deg); transform-origin: left; }
.file-browser-body { display: grid; grid-template-columns: 118px minmax(0, 1fr); height: calc(100% - 26px); }.file-browser-body nav { display: grid; align-content: start; gap: 5px; padding: 8px; color: #aaa; background: #242424; border-right: 1px solid #151515; }.file-browser-body nav span { padding: 4px; }.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(76px, 1fr)); align-content: start; gap: 7px; padding: 8px; }.asset-grid button { display: grid; justify-items: center; gap: 4px; min-height: 70px; padding: 8px; color: #ccc; background: transparent; border-radius: 2px; }.asset-grid button:hover { background: #3c3c3c; }.asset-grid button span { font-size: 25px; color: #cb873f; }
.spreadsheet-surface { overflow: auto; }.spreadsheet-surface table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }.spreadsheet-surface th, .spreadsheet-surface td { height: 24px; padding: 3px 7px; text-align: left; border-right: 1px solid #404040; border-bottom: 1px solid #383838; }.spreadsheet-surface th { position: sticky; top: 26px; background: #333; }.surface-empty { display: block; padding: 20px; color: #b0b0b0; text-align: center; }
.text-editor-surface pre { display: grid; grid-template-columns: 38px minmax(0, 1fr); height: calc(100% - 26px); margin: 0; padding: 8px 0; overflow: auto; color: #d6d6d6; background: #202020; font: 12px/1.7 ui-monospace, SFMono-Regular, Consolas, monospace; }.line-numbers { padding-right: 8px; color: #aaa; text-align: right; border-right: 1px solid #383838; }.text-editor-surface code { padding: 0 10px; color: #9ecbff; white-space: pre; }
.console-output { display: grid; align-content: start; gap: 6px; height: calc(100% - 26px); padding: 9px; color: #bbb; background: #181818; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }.console-output label { display: flex; align-items: center; gap: 7px; }.console-output input { min-width: 0; flex: 1; height: 25px; color: #ddd; background: #202020; border: 0; }
.info-surface output { display: block; padding: 6px 9px; overflow: hidden; color: #aaa; text-overflow: ellipsis; white-space: nowrap; border-bottom: 1px solid #373737; }
.camera-preview-surface { position: relative; display: grid; place-items: center; background-color: #383838; background-image: linear-gradient(#454545 1px, transparent 1px), linear-gradient(90deg, #454545 1px, transparent 1px); background-size: 26px 26px; }.camera-preview-surface > span { position: absolute; left: 8px; top: 7px; }.camera-frame { display: grid; place-items: center; width: 68%; aspect-ratio: 16 / 10; color: #aaa; border: 2px solid #777; box-shadow: 0 0 0 999px #0002; }
[data-workspace="Modeling"] .workspace-grid, [data-workspace="Sculpting"] .workspace-grid { grid-template-rows: 190px minmax(0, 1fr); }
[data-workspace="Modeling"] .viewport-area, [data-workspace="Sculpting"] .viewport-area { grid-row: 1 / 3; }
[data-workspace="Modeling"] .properties-area, [data-workspace="Sculpting"] .properties-area { grid-row: 2; }
[data-workspace="Modeling"] .timeline-area, [data-workspace="Sculpting"] .timeline-area { display: none; }
[data-workspace="UV Editing"] .workspace-grid, [data-workspace="Texture Paint"] .workspace-grid { grid-template-columns: minmax(260px, 1fr) minmax(260px, 1fr) 320px; grid-template-rows: 190px minmax(0, 1fr); }
[data-workspace="UV Editing"] .image-editor-area, [data-workspace="Texture Paint"] .image-editor-area { grid-column: 1; grid-row: 1 / 3; }
[data-workspace="UV Editing"] .viewport-area, [data-workspace="Texture Paint"] .viewport-area { grid-column: 2; grid-row: 1 / 3; }
[data-workspace="UV Editing"] .outliner-area, [data-workspace="Texture Paint"] .outliner-area { grid-column: 3; grid-row: 1; }
[data-workspace="UV Editing"] .properties-area, [data-workspace="Texture Paint"] .properties-area { grid-column: 3; grid-row: 2; }
[data-workspace="UV Editing"] .timeline-area, [data-workspace="Texture Paint"] .timeline-area { display: none; }
[data-workspace="Shading"] .workspace-grid { grid-template-columns: 290px minmax(0, 1fr) 320px; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); }
[data-workspace="Shading"] .file-browser-area { grid-column: 1; grid-row: 1; } [data-workspace="Shading"] .viewport-area { grid-column: 2; grid-row: 1; } [data-workspace="Shading"] .image-editor-area { grid-column: 1; grid-row: 2; } [data-workspace="Shading"] .node-editor-area { grid-column: 2; grid-row: 2; } [data-workspace="Shading"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Shading"] .properties-area { grid-column: 3; grid-row: 2; } [data-workspace="Shading"] .timeline-area { display: none; }
[data-workspace="Animation"] .workspace-grid { grid-template-columns: 36% minmax(0, 1fr) 320px; grid-template-rows: 190px minmax(0, 1fr) minmax(170px, .7fr); }
[data-workspace="Animation"] .camera-view-area { grid-column: 1; grid-row: 1 / 3; } [data-workspace="Animation"] .viewport-area { grid-column: 2; grid-row: 1 / 3; } [data-workspace="Animation"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Animation"] .properties-area { grid-column: 3; grid-row: 2 / 4; } [data-workspace="Animation"] .timeline-area { grid-column: 1 / 3; grid-row: 3; }
[data-workspace="Rendering"] .workspace-grid { grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: minmax(0, 1fr) 108px; }
[data-workspace="Rendering"] .render-result-area { grid-column: 1; grid-row: 1; } [data-workspace="Rendering"] .timeline-area { grid-column: 1; grid-row: 2; } [data-workspace="Rendering"] .properties-area { grid-column: 2; grid-row: 1 / 3; } [data-workspace="Rendering"] .viewport-area, [data-workspace="Rendering"] .outliner-area { display: none; }
[data-workspace="Compositing"] .workspace-grid { grid-template-columns: minmax(0, 1fr) 320px; grid-template-rows: 44% minmax(0, 1fr) 54px; }
[data-workspace="Compositing"] .image-editor-area { grid-column: 1; grid-row: 1; } [data-workspace="Compositing"] .properties-area { grid-column: 2; grid-row: 1; } [data-workspace="Compositing"] .node-editor-area { grid-column: 1 / 3; grid-row: 2; } [data-workspace="Compositing"] .timeline-area { grid-column: 1 / 3; grid-row: 3; } [data-workspace="Compositing"] .viewport-area, [data-workspace="Compositing"] .outliner-area { display: none; }
[data-workspace="Geometry Nodes"] .workspace-grid { grid-template-columns: 42% minmax(0, 1fr) 320px; grid-template-rows: 42% minmax(0, 1fr) 74px; }
[data-workspace="Geometry Nodes"] .spreadsheet-area { grid-column: 1; grid-row: 1; } [data-workspace="Geometry Nodes"] .viewport-area { grid-column: 2; grid-row: 1; } [data-workspace="Geometry Nodes"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Geometry Nodes"] .properties-area { grid-column: 3; grid-row: 2 / 4; } [data-workspace="Geometry Nodes"] .node-editor-area { grid-column: 1 / 3; grid-row: 2; } [data-workspace="Geometry Nodes"] .timeline-area { grid-column: 1 / 3; grid-row: 3; }
[data-workspace="Scripting"] .workspace-grid { grid-template-columns: 36% minmax(0, 1fr) 320px; grid-template-rows: 19% 43% 38%; }
[data-workspace="Scripting"] .viewport-area { grid-column: 1; grid-row: 1; } [data-workspace="Scripting"] .viewport-area > .editor-header { display: flex; } [data-workspace="Scripting"] .console-area { grid-column: 1; grid-row: 2; } [data-workspace="Scripting"] .info-area { grid-column: 1; grid-row: 3; } [data-workspace="Scripting"] .text-editor-area { grid-column: 2; grid-row: 1 / 4; } [data-workspace="Scripting"] .outliner-area { grid-column: 3; grid-row: 1; } [data-workspace="Scripting"] .secondary-outliner-area { grid-column: 3; grid-row: 2; } [data-workspace="Scripting"] .properties-area { grid-column: 3; grid-row: 3; } [data-workspace="Scripting"] .timeline-area { display: none; }
[data-workspace="Sculpting"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Texture Paint"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Shading"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Animation"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Rendering"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Compositing"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Geometry Nodes"] .workspace-toolbar > button:not(.mode-chip), [data-workspace="Scripting"] .workspace-toolbar > button:not(.mode-chip) { display: none; }
.modifier-row { display: grid; grid-template-columns: minmax(0, 1fr) repeat(4, 26px); align-items: center; gap: 3px; min-height: 26px; }.modifier-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.property-section .modifier-row label { justify-content: center; gap: 2px; min-height: 20px; font-size: 10px; }
.property-section input[type="number"] { width: 72px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="text"], .property-section label > input:not([type]) { min-width: 0; width: 130px; padding: 3px 5px; color: #e0e2e6; background: #26282c; border: 1px solid #484c53; border-radius: 2px; }.property-section input[type="checkbox"] { accent-color: #e37a2c; }.property-actions { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; }.property-actions button, .file-button { padding: 4px 7px; color: #e6e7e9; background: #393c42; border: 1px solid #4d5158; border-radius: 2px; }.property-actions button:disabled { opacity: .45; cursor: default; }.file-button { position: relative; cursor: pointer; }.file-button input { position: absolute; width: 1px; height: 1px; opacity: 0; }.material-slots { display: grid; gap: 2px; max-height: 62px; overflow: auto; color: #b9bdc5; font-size: 11px; }.delimit-options { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2px 8px; margin: 6px 0; padding: 5px 0 3px; border: 0; border-top: 1px solid #383a40; }.delimit-options legend { padding: 0 5px 0 0; color: #7f858e; font-size: 10px; }.property-section .delimit-options label { justify-content: flex-start; min-width: 0; min-height: 20px; font-size: 10px; }
.grease-pencil-canvas-2d { display: block; width: 100%; max-width: 280px; aspect-ratio: 28 / 15; margin: 7px 0; border: 1px solid #4d5158; border-radius: 3px; background: #202328; cursor: crosshair; touch-action: none; }
.grease-pencil-canvas-2d:focus-visible { outline: 2px solid #f2a15b; outline-offset: 1px; }
.timeline-content { display: grid; grid-template-rows: 27px 24px 15px 28px; height: auto; min-height: 100%; padding: 0; background: #292929; }.timeline-controls { display: flex; align-items: center; justify-content: center; gap: 1px; padding: 1px 5px; overflow-x: auto; border-bottom: 1px solid #151515; scrollbar-width: none; }.timeline-controls button { min-width: 25px; height: 24px; padding: 0 5px; color: #d0d0d0; background: transparent; border-radius: 2px; white-space: nowrap; }.timeline-controls button:hover { background: #484848; }.frame-number { min-width: 48px; height: 22px; margin-left: 8px; padding: 3px 7px; text-align: center; color: #fff; background: #464646; border: 1px solid #545454; border-radius: 2px; }.frame-slider { width: 100%; min-height: 24px; margin: 0; accent-color: #4775a3; }.timeline-scale { display: flex; justify-content: space-between; padding: 0 7px; color: #b6b6b6; background: #303030; font-size: 10px; }.dope-sheet { display: grid; grid-template-columns: 90px minmax(120px, 1fr) 78px; align-items: center; gap: 5px; padding: 2px 5px; border-top: 1px solid #444; }.channel-name { overflow: hidden; color: #c0c0c0; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 17px; background: #1f1f1f; border: 1px solid #3d3d3d; }.key-dot { position: absolute; top: 4px; width: 8px; height: 8px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #d6a348; border: 1px solid #f2c977; }.key-dot.active { background: #e36d2d; }.dope-sheet select { min-width: 0; color: #ddd; background: #292929; border: 1px solid #484848; }
.timeline-content { display: grid; grid-template-rows: 27px 24px 15px minmax(28px, 1fr); height: auto; min-height: 100%; padding: 0; background: #292929; }.timeline-controls { display: flex; align-items: center; justify-content: center; gap: 1px; padding: 1px 5px; overflow-x: auto; border-bottom: 1px solid #151515; scrollbar-width: none; }.timeline-controls button { min-width: 25px; height: 24px; padding: 0 5px; color: #d0d0d0; background: transparent; border-radius: 2px; white-space: nowrap; }.timeline-controls button:hover { background: #484848; }.frame-number { min-width: 48px; height: 22px; margin-left: 8px; padding: 3px 7px; text-align: center; color: #fff; background: #464646; border: 1px solid #545454; border-radius: 2px; }.frame-slider { width: 100%; min-height: 24px; margin: 0; accent-color: #4775a3; }.timeline-scale { display: flex; justify-content: space-between; padding: 0 7px; color: #b6b6b6; background: #303030; font-size: 10px; }.dope-sheet { display: grid; grid-template-columns: 90px minmax(120px, 1fr) 78px; align-items: center; gap: 5px; padding: 2px 5px; border-top: 1px solid #444; }.channel-name { overflow: hidden; color: #c0c0c0; text-overflow: ellipsis; white-space: nowrap; }.key-track { position: relative; height: 17px; background: #1f1f1f; border: 1px solid #3d3d3d; }.key-dot { position: absolute; top: 4px; width: 8px; height: 8px; padding: 0; transform: translateX(-50%) rotate(45deg); background: #f0b24f; border: 1px solid #fff0bf; }.key-dot.active { background: #f07835; }.dope-sheet select { min-width: 0; color: #ddd; background: #292929; border: 1px solid #484848; }
.dope-sheet > button { min-height: 24px; color: #ededed; background: #454545; border: 1px solid #666; border-radius: 2px; }
.dope-sheet input { min-width: 80px; min-height: 24px; margin-block: 4px; }.keying-set-tools > button, .timeline-markers > button { min-height: 24px; padding: 3px 7px; color: #f1f2f4; background: #41444a; border: 1px solid #656a73; border-radius: 2px; }
.status-bar { gap: 10px; min-width: 0; min-height: 20px; padding: 0 6px; overflow: hidden; color: #aaa; background: #181818; font-size: 10px; border: 0; }.status-bar span:not(.status-spacer) { white-space: nowrap; }.status-hint { color: #888; }.runtime-status:not([data-testid="engine-status"]):not([data-testid="wasm-status"]) { display: none; }.blender-version { color: #7fa8cc; }
.storage-budget-panel { display: flex; align-items: center; flex: none; gap: 5px; min-width: 0; height: 100%; padding: 0 4px; color: #aaa; background: transparent; border: 0; white-space: nowrap; }.storage-budget-panel strong { color: #aaa; font-size: 10px; font-weight: 500; }.storage-budget-panel span { display: none; color: #aaa; font-variant-numeric: tabular-nums; }.storage-budget-panel output { color: #c9c9c9; font-variant-numeric: tabular-nums; }.storage-budget-panel button { flex: none; width: 19px; height: 18px; padding: 0; color: #ccc; background: #303030; border: 1px solid #444; border-radius: 2px; }.storage-budget-panel button:disabled { opacity: .45; cursor: default; }
@@ -108,11 +156,12 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.topbar-actions select { width: 62px; }
.menu-bar { display: none; }
.workspace-tabs .workspace-tab:nth-child(n+4) { display: none; }
.workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 108px; }
.outliner-area, .properties-area { display: none; }
.workspace-grid, [data-workspace] .workspace-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 108px; }
.outliner-area, .properties-area, .image-editor-area, .render-result-area, .node-editor-area, .file-browser-area, .camera-view-area, .spreadsheet-area, .text-editor-area, .console-area, .info-area, .secondary-outliner-area { display: none !important; }
[data-workspace] .viewport-area, [data-workspace] .timeline-area { display: flex; }
.viewport-area, .timeline-area { grid-column: 1; }
.viewport-area { grid-row: 1; }
.timeline-area { grid-row: 2; }
[data-workspace] .viewport-area { grid-row: 1; }
[data-workspace] .timeline-area { grid-row: 2; }
.workspace-tab { min-width: auto; padding-inline: 8px; }
.status-hint, .status-bar > [data-testid="scene-stats"], .storage-budget-panel strong, .storage-budget-panel output, .blender-version { display: none; }
.status-bar { gap: 5px; overflow: hidden; }

View File

@@ -13,8 +13,23 @@ export const BLENDER_WORKSPACE_IDS = [
] as const;
export type WorkspaceId = typeof BLENDER_WORKSPACE_IDS[number];
export type EditorType = "3D Viewport" | "Outliner" | "Properties" | "Timeline";
export type BlenderMode = "Object" | "Edit" | "Pose";
export type EditorType =
| "3D Viewport"
| "Outliner"
| "Properties"
| "Timeline"
| "Dope Sheet"
| "Image Editor"
| "Render Result"
| "Shader Editor"
| "Compositor"
| "Geometry Nodes"
| "File Browser"
| "Spreadsheet"
| "Text Editor"
| "Python Console"
| "Info";
export type BlenderMode = "Object" | "Edit" | "Pose" | "Sculpt" | "Texture Paint";
export interface RegionIR {
id: string;
@@ -75,17 +90,84 @@ function regions(): RegionIR[] {
];
}
function layoutAreas(): AreaIR[] {
return [
{ id: "viewport", editor: "3D Viewport", regions: regions(), rect: { x: 0, y: 0, width: 0.8, height: 0.78 }, maximized: false },
{ id: "outliner", editor: "Outliner", regions: regions(), rect: { x: 0.8, y: 0, width: 0.2, height: 0.42 }, maximized: false },
{ id: "properties", editor: "Properties", regions: regions(), rect: { x: 0.8, y: 0.42, width: 0.2, height: 0.36 }, maximized: false },
{ id: "timeline", editor: "Timeline", regions: regions(), rect: { x: 0, y: 0.78, width: 1, height: 0.22 }, maximized: false },
function area(id: string, editor: EditorType, x: number, y: number, width: number, height: number): AreaIR {
return { id, editor, regions: regions(), rect: { x, y, width, height }, maximized: false };
}
function workspaceAreas(id: WorkspaceId): AreaIR[] {
const right = [
area("outliner", "Outliner", 0.822, 0.819, 0.176, 0.179),
area("properties", "Properties", 0.822, 0.022, 0.176, 0.794),
];
switch (id) {
case "Layout":
return [area("viewport", "3D Viewport", 0.001, 0.095, 0.819, 0.903), area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07), ...right];
case "Modeling":
case "Sculpting":
return [area("viewport", "3D Viewport", 0.001, 0.022, 0.819, 0.976), ...right];
case "UV Editing":
case "Texture Paint":
return [area("image-editor", "Image Editor", 0.001, 0.022, 0.409, 0.976), area("viewport", "3D Viewport", 0.412, 0.022, 0.408, 0.976), ...right];
case "Shading":
return [
area("file-browser", "File Browser", 0.001, 0.511, 0.151, 0.487),
area("viewport", "3D Viewport", 0.153, 0.511, 0.667, 0.487),
area("image-editor", "Image Editor", 0.001, 0.022, 0.151, 0.487),
area("shader-editor", "Shader Editor", 0.153, 0.022, 0.667, 0.487),
...right,
];
case "Animation":
return [
area("camera-view", "3D Viewport", 0.001, 0.435, 0.291, 0.563),
area("viewport", "3D Viewport", 0.294, 0.435, 0.526, 0.563),
area("dope-sheet", "Dope Sheet", 0.001, 0.022, 0.819, 0.41),
...right,
];
case "Rendering":
return [area("render-result", "Render Result", 0.001, 0.095, 0.819, 0.903), area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07), area("properties", "Properties", 0.822, 0.022, 0.176, 0.976)];
case "Compositing":
return [area("image-editor", "Image Editor", 0.001, 0.552, 0.819, 0.446), area("properties", "Properties", 0.822, 0.552, 0.176, 0.446), area("compositor", "Compositor", 0.001, 0.071, 0.997, 0.476), area("timeline", "Timeline", 0.001, 0.022, 0.997, 0.046)];
case "Geometry Nodes":
return [
area("spreadsheet", "Spreadsheet", 0.001, 0.552, 0.341, 0.446),
area("viewport", "3D Viewport", 0.343, 0.552, 0.477, 0.446),
area("geometry-nodes", "Geometry Nodes", 0.001, 0.095, 0.819, 0.452),
area("timeline", "Timeline", 0.001, 0.022, 0.819, 0.07),
...right,
];
case "Scripting":
return [
area("viewport", "3D Viewport", 0.001, 0.626, 0.291, 0.372),
area("console", "Python Console", 0.001, 0.185, 0.291, 0.438),
area("info", "Info", 0.001, 0.022, 0.291, 0.16),
area("text-editor", "Text Editor", 0.294, 0.022, 0.526, 0.976),
area("outliner", "Outliner", 0.822, 0.807, 0.176, 0.191),
area("secondary-outliner", "Outliner", 0.822, 0.598, 0.176, 0.206),
area("properties", "Properties", 0.822, 0.022, 0.176, 0.573),
];
}
}
function workspace(id: WorkspaceId): WorkspaceIR {
return { id, name: id, areas: layoutAreas(), activeAreaId: "viewport", revision: 0 };
const areas = workspaceAreas(id);
const preferred = ({
"UV Editing": "image-editor",
"Texture Paint": "image-editor",
Shading: "shader-editor",
Animation: "dope-sheet",
Rendering: "render-result",
Compositing: "compositor",
"Geometry Nodes": "geometry-nodes",
Scripting: "text-editor",
} as Partial<Record<WorkspaceId, string>>)[id];
return { id, name: id, areas, activeAreaId: preferred ?? "viewport", revision: 0 };
}
function defaultMode(id: WorkspaceId): BlenderMode {
if (id === "Modeling" || id === "UV Editing") return "Edit";
if (id === "Sculpting") return "Sculpt";
if (id === "Texture Paint") return "Texture Paint";
return "Object";
}
export function createDefaultWebWorkspaceState(): WebWorkspaceState {
@@ -120,6 +202,7 @@ export function reduceUICommand(state: WebWorkspaceState, command: UICommand): W
workspaceId: command.workspaceId,
activeAreaId: next.activeAreaId,
activeEditor: next.areas.find((area) => area.id === next.activeAreaId)?.editor ?? "3D Viewport",
mode: defaultMode(command.workspaceId),
revision: state.context.revision + 1,
},
};

View File

@@ -1,3 +1,4 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
const viewports = [
@@ -84,4 +85,59 @@ test.describe("M7-14 responsive layout", () => {
expect(geometry.timeline.height).toBeLessThanOrEqual(120);
expect(geometry.propertyTabsDirection).toBe("column");
});
test("desktop cycles every Blender 5.2 workspace with its source editor set", async ({ page }) => {
const expected = {
Layout: { mode: "Object Mode", menus: "View Select Add Object", editors: ["3D Viewport", "Outliner", "Properties", "Timeline"] },
Modeling: { mode: "Edit Mode", menus: "View Select Add Mesh Vertex Edge Face UV", editors: ["3D Viewport", "Outliner", "Properties"] },
Sculpting: { mode: "Sculpt Mode", menus: "Sculpt Mask Face Sets View", editors: ["3D Viewport", "Outliner", "Properties"] },
"UV Editing": { mode: "Edit Mode", menus: "View Select Image UV", editors: ["3D Viewport", "Image Editor", "Outliner", "Properties"] },
"Texture Paint": { mode: "Texture Paint Mode", menus: "View Paint Mask Face Selection", editors: ["3D Viewport", "Image Editor", "Outliner", "Properties"] },
Shading: { mode: "Object Mode", menus: "View Select Add Node", editors: ["3D Viewport", "File Browser", "Image Editor", "Outliner", "Properties", "Shader Editor"] },
Animation: { mode: "Object Mode", menus: "View Select Marker Channel Key", editors: ["3D Viewport", "3D Viewport", "Dope Sheet", "Outliner", "Properties"] },
Rendering: { mode: "Object Mode", menus: "View Image", editors: ["Properties", "Render Result", "Timeline"] },
Compositing: { mode: "Object Mode", menus: "View Select Add Node", editors: ["Compositor", "Image Editor", "Properties", "Timeline"] },
"Geometry Nodes": { mode: "Object Mode", menus: "View Select Add Node", editors: ["3D Viewport", "Geometry Nodes", "Outliner", "Properties", "Spreadsheet", "Timeline"] },
Scripting: { mode: "Object Mode", menus: "View Text Templates", editors: ["3D Viewport", "Info", "Outliner", "Outliner", "Properties", "Python Console", "Text Editor"] },
} as const;
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/");
await expect(page.getByTestId("engine-status")).toContainText("ready, open a .blend file", { timeout: 20_000 });
for (const [workspace, contract] of Object.entries(expected)) {
await page.getByRole("button", { name: workspace, exact: true }).click();
await expect(page.locator("main.blender-app")).toHaveAttribute("data-workspace", workspace);
await expect(page.locator(".mode-chip")).toHaveText(contract.mode);
await expect(page.locator(".workspace-editor-menus")).toHaveText(contract.menus);
const report = await page.evaluate(() => {
const visibleAreas = [...document.querySelectorAll<HTMLElement>(".workspace-grid > .area-frame")].filter((area) => {
const bounds = area.getBoundingClientRect();
return getComputedStyle(area).display !== "none" && bounds.width > 0 && bounds.height > 0;
});
const overlaps: string[] = [];
for (let leftIndex = 0; leftIndex < visibleAreas.length; leftIndex += 1) {
for (let rightIndex = leftIndex + 1; rightIndex < visibleAreas.length; rightIndex += 1) {
const left = visibleAreas[leftIndex].getBoundingClientRect();
const right = visibleAreas[rightIndex].getBoundingClientRect();
if (Math.min(left.right, right.right) - Math.max(left.left, right.left) > 1 && Math.min(left.bottom, right.bottom) - Math.max(left.top, right.top) > 1) {
overlaps.push(`${visibleAreas[leftIndex].dataset.editor}/${visibleAreas[rightIndex].dataset.editor}`);
}
}
}
return {
editors: visibleAreas.map((area) => area.dataset.editor).sort(),
overlaps,
rootScrollWidth: document.documentElement.scrollWidth,
viewportWidth: window.innerWidth,
};
});
expect(report.editors, workspace).toEqual([...contract.editors].sort());
expect(report.overlaps, workspace).toEqual([]);
expect(report.rootScrollWidth, workspace).toBeLessThanOrEqual(report.viewportWidth);
if (await page.locator(".workspace-editor-surface").count() > 0) {
const accessibility = await new AxeBuilder({ page }).include(".workspace-editor-surface").withTags(["wcag2aa", "wcag21aa", "wcag22aa"]).analyze();
expect(accessibility.violations.filter((violation) => violation.impact === "critical" || violation.impact === "serious").map((violation) => violation.id), workspace).toEqual([]);
}
}
});
});

View File

@@ -13,7 +13,29 @@ const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), {
});
assert.deepEqual(transpiled.diagnostics, []);
const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64");
const { createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl);
const { BLENDER_WORKSPACE_IDS, createDefaultWebWorkspaceState, reduceUICommand } = await import(moduleUrl);
test("Blender 5.2 workspaces retain their source editor area sets", () => {
const state = createDefaultWebWorkspaceState();
const expected = {
Layout: ["3D Viewport", "Outliner", "Properties", "Timeline"],
Modeling: ["3D Viewport", "Outliner", "Properties"],
Sculpting: ["3D Viewport", "Outliner", "Properties"],
"UV Editing": ["3D Viewport", "Image Editor", "Outliner", "Properties"],
"Texture Paint": ["3D Viewport", "Image Editor", "Outliner", "Properties"],
Shading: ["3D Viewport", "File Browser", "Image Editor", "Outliner", "Properties", "Shader Editor"],
Animation: ["3D Viewport", "3D Viewport", "Dope Sheet", "Outliner", "Properties"],
Rendering: ["Properties", "Render Result", "Timeline"],
Compositing: ["Compositor", "Image Editor", "Properties", "Timeline"],
"Geometry Nodes": ["3D Viewport", "Geometry Nodes", "Outliner", "Properties", "Spreadsheet", "Timeline"],
Scripting: ["3D Viewport", "Info", "Outliner", "Outliner", "Properties", "Python Console", "Text Editor"],
};
assert.deepEqual(Object.keys(state.workspaces), BLENDER_WORKSPACE_IDS);
for (const id of BLENDER_WORKSPACE_IDS) {
assert.deepEqual(state.workspaces[id].areas.map((area) => area.editor).sort(), expected[id].toSorted(), id);
assert.ok(state.workspaces[id].areas.every((area) => area.rect.width > 0 && area.rect.height > 0), id);
}
});
test("M7-13 menu and modal overlays are mutually exclusive and Escape-closeable", () => {
let state = createDefaultWebWorkspaceState();