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

@@ -0,0 +1,205 @@
# 桌面版 Blender → Web Blender 全功能与界面对标验证流程
版本:`2026-09-08`
基准:官方 Blender `5.2.0 LTS`(`USD=True`)
浏览器范围:Chromium;禁止用 Firefox/WebKit 结果替代本流程。
执行原则:以功能模块为单位增量执行;已通过的模块只复用证据,不重复启动完整验证。
## 1. 目标与完成定义
本流程用于判断 Web Blender 是否在“功能行为、工作区结构、交互语义和失败边界”上对标桌面版 Blender。完成不是“页面能打开”或“控件名称相同”,而是每个模块都有桌面基准、Web 接线和可追溯证据。
一个模块只有同时满足以下条件才标记 `READY`:
1. 桌面 Blender 5.2 正例已生成可复用 fixture、字段快照或像素/结构基准。
2. Web 路径调用编译后的 Blender WebEngine/WASM Main;不得用 JS 重实现 Blender 业务逻辑。
3. 正例至少包含一次真实 mutation 或精确 Main reader/context 读取。
4. 负例在提交前失败,且 revision、对象数、资源数等状态保持不变。
5. 需要持久化的模块完成 undo/redo、save/reopen;需要 Worker 的模块完成重启/释放证据。
6. Chromium focused gate、manifest、源码 unit 绑定和 SHA-256 均已落盘。
`PARTIAL` 表示界面或协议已接线,但仍有桌面行为、字段或资源未闭环;`BLOCKED` 只用于桌面能力明确不可在浏览器执行、依赖外部资源或安全策略拒绝的路径。禁止用“隐藏按钮”“跳过验证”或伪造结果提升状态。
## 2. 固定基准与证据
### 2.1 桌面基准
固定使用本机官方二进制:
```bash
/tmp/blender-5.2.0-linux-x64/blender --version
/tmp/blender-5.2.0-linux-x64/blender -b --factory-startup --python-expr \
'import bpy,json; print(json.dumps({w.name:[a.type for s in w.screens for a in s.areas] for w in bpy.data.workspaces}, sort_keys=True))'
```
记录二进制和归档 SHA-256。默认 Layout 以及每个工作区的 Area 集合必须保留原始输出;截图只用于人工视觉复核,结构判断以 API 输出为准。
### 2.2 Web 基准
每个批次记录:
- `gitCommit`、`releaseId`、源码 `sourceTreeSha256`;
- WASM 构件 SHA-256、入口 HTML/CSS/JS SHA-256;
- Chromium 版本、viewport、`crossOriginIsolated`、请求失败列表;
- 任务 manifest、focused gate 退出码、正/负例结果和恢复结果。
生产入口必须先通过:
```bash
node tools/web/print-task-context.mjs
node tools/web/check-task-context.mjs
node tools/web/check-context-governance.mjs
```
源码检索强制使用批次入口,禁止全量源码扫描:
```bash
node tools/web/search-wbv2-batch-source.mjs --task <task-id> --pattern '<pattern>' \
--runtime web/app/src/app/App.tsx \
--runtime web/protocol/scene-ir.ts
```
`--runtime` 只能使用该工具中的 allowlist;完整 inventory 仅在最终审计命令显式执行。
## 3. 桌面工作区结构基准
下表是 Blender 5.2 `bpy.data.workspaces` 的 Area 类型集合。Web 必须保留这些区域的语义和相对布局;移动端可折叠非主区域,但不得改变桌面工作区定义。
| 工作区 | 桌面 Area 集合 | Web 对应表面 | 默认模式 |
| --- | --- | --- | --- |
| Layout | 3D View、Outliner、Properties、Dope Sheet/Timeline | 3D Viewport、Outliner、Properties、Timeline | Object |
| Modeling | 3D View、Outliner、Properties | 3D Viewport、Outliner、Properties | Edit |
| Sculpting | 3D View、Outliner、Properties | 3D Viewport、Outliner、Properties | Sculpt |
| UV Editing | Image Editor、3D View、Outliner、Properties | Image Editor、3D Viewport、Outliner、Properties | Edit |
| Texture Paint | Image Editor、3D View、Outliner、Properties | Image Editor、3D Viewport、Outliner、Properties | Texture Paint |
| Shading | File Browser、Image Editor、Node Editor、3D View、Outliner、Properties | File Browser、Image Editor、Shader Editor、3D Viewport、Outliner、Properties | Object |
| Animation | 2×3D View、Dope Sheet、Outliner、Properties | Camera View、3D Viewport、Dope Sheet、Outliner、Properties | Object |
| Rendering | Image Editor、Properties、Dope Sheet/Timeline | Render Result、Properties、Timeline | Object |
| Compositing | Image Editor、Node Editor、Properties、Dope Sheet/Timeline | Image Editor、Compositor、Properties、Timeline | Object |
| Geometry Nodes | Geometry Node Editor、Spreadsheet、3D View、Outliner、Properties、Timeline | Geometry Nodes、Spreadsheet、3D Viewport、Outliner、Properties、Timeline | Object |
| Scripting | Info、Console、Text Editor、3D View、2×Outliner、Properties | Info、Python Console、Text Editor、3D Viewport、2×Outliner、Properties | Object |
结构门禁必须检查:
- 工作区切换后 `data-workspace`、模式和编辑器集合一致;
- 区域可见矩形不重叠,右侧 Properties/Outliner 位置符合基准;
- 1440×900、1280×720、360×640、320×568 均无根横向溢出;
- 新增编辑器表面具备标题、菜单、键盘焦点和严重级别 WCAG 通过证据;
- 诊断信息不遮挡主编辑区域,工程状态只在 Status Bar 或明确的诊断区域显示。
## 4. 功能模块验证顺序
按依赖从底到顶执行,每个模块只执行一次 focused gate;同一模块的多个命令共享一个 fixture、一个 WASM adapter、一个 dispatcher 和一个 evidence JSON。
### A. 文件、场景和对象
- `.blend` 打开、分段读取、取消、恢复、最近项目、保存、另存为、关闭;
- 对象创建、复制/链接复制、删除、重命名、可见性、父子关系、Collection;
- Scene/Main reader、对象变换、原点、数据块和资源清单。
优先复用:`v1-user-loop`、`file-import-progress`、`dirty-state`、`recent-projects-recovery`、`main-datablocks-reader-production`、`wbv2-object-*-production`。
### B. Mesh、UV、非 Mesh 与编辑域
- Vertex/Edge/Face 选择、Merge/Dissolve/Extrude/Inset/Bevel/Loop Cut/Subdivide;
- UV unwrap、Curve/Surface/Metaball、Point Cloud、Volume/NanoVDB;
- Grease Pencil 选择、框选、图层/帧/笔画;
- Sculpt stroke、Vertex Paint/Weight Paint、PBVH 可见性。
每个 mutation 必须检查 revision 单调递增、非法索引 no-mutation、undo/redo 和 save/reopen。
### C. 材质、Shader、Geometry Nodes 与资源
- Material slot、Principled、Shader graph、图片/字体/声音/纹理/粒子;
- Geometry Node graph、allowlist、field budget、Spreadsheet;
- Asset catalog、library link/override/append、OPFS 资源 hash、LOD cache。
Shader、Geometry Nodes、Asset/Library 只允许调用对应 protocol/Worker/Main reader;UI 节点卡片是编辑表面,不能代替实际 WASM graph mutation。
### D. 动画、Sequencer、灯光、渲染与合成
- Action/FCurve/Driver/Keying Set/NLA/Marker;
- Sequencer strip、媒体 codec probe、音频恢复和最终导出;
- Camera、Light subtype/field、Render routing、Render reference、Compositor CPU/WebGPU;
- 渲染像素、资源预算、设备丢失、OOM、网络中断和重启恢复。
像素类 gate 必须复用已有 golden;浏览器仅执行 focused render,不在每个普通 API 回合重复启动浏览器。
### E. Scripting、安全和发布
- Text Editor、Python Console 的只读/受策略控制入口;
- manifest canonical、签名、权限、sandbox scope/budget、host call、取消、dispose、recovery;
- CSP、COOP/COEP/CORP、Range、缓存、离线归档、回滚和发布 metadata。
脚本策略拒绝必须是显式 `BLOCKED`,不能通过前端按钮绕过。
## 5. 单模块验收契约
每个模块的 manifest 使用以下最小结构:
```json
{
"task": "WBV2-…",
"desktop": { "binarySha256": "…", "fixture": "…", "receipt": "…" },
"web": { "wasmSha256": "…", "browser": "chromium", "gate": "…" },
"cases": {
"positive": { "status": "READY", "before": "…", "after": "…" },
"negative": { "status": "REJECTED", "revisionUnchanged": true },
"undoRedo": { "status": "EXACT" },
"saveReopen": { "status": "EXACT" },
"recovery": { "status": "READY" }
},
"sourceCoverage": { "unitCount": 0, "verifiedUnits": 0, "unverifiedUnits": 0, "sourceTreeSha256": "…" },
"nextTask": "…"
}
```
失败时保持 `in_progress` 或 `blocked`,不得写入 `status=done`,不得人工修改 `nextTask`。
## 6. 高效率执行规则
1. 回合启动只读取一次失败账本和当前批次 manifest;命令事件更新内存对象,回合结束集中写入。
2. 上下文 manifest 先比较 `bytes`/`mtime_ns`,未变化直接输出 `CONTEXT_PACKET_REUSED`。
3. 卡片按需读取;只有最终审计才解析完整任务树。
4. 实现回合只运行当前源码/依赖接线、最小 fixture、focused gate 和 `validate_incremental_context.py`。
5. 默认每回合最多 48 个工具命令、读取/探索不超过 16 个;达到预算先写 checkpoint。
6. 代理回合只返回结果和 `phase=CONTINUE`,rotate/sync/prepare-next 由外层 scheduler 统一执行。
7. 最近 3 次周期按 `(module,tier,family)` 自适应批次大小;超时或超过 900 秒减半,低于 300 秒且无 focused gate 失败增加 4。
8. 已通过的模块只复用 manifest、receipt、构件 hash 和截图;除非输入指纹变化,不重跑相同 gate。
## 7. 本次执行入口与交付
实现工作区改造后,按以下顺序验证:
```bash
npm --prefix web run typecheck
npm --prefix web run lint
node --test web/tests/unit/ui-schema.test.mjs
CHROME_PATH=/usr/bin/google-chrome WEB_TEST_PORT=4173 \
npx playwright test --config playwright.config.ts --workers=1 \
tests/e2e/responsive-layout.spec.ts \
tests/e2e/ui-context.spec.ts \
tests/e2e/keyboard-accessibility.spec.ts
npm --prefix web run build
CHROME_PATH=/usr/bin/google-chrome-stable WEB_ORIGIN=https://49.232.250.35:58014 \
node tools/web/check-deployed-web-blender.mjs
```
发布前确认:
- 当前 release metadata 的 commit、入口、CSS/JS、WASM hash 相互绑定;
- 线上 `/`、`/wasm-module-manifest.json`、WASM Range 和隔离头通过;
- Chromium 真实打开 `basic_scene.blend`,对象 mutation 正常;
- 线上截图覆盖 Layout、UV/Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting;
- 回滚目标仍保留,任何失败不切换 `current`。
## 8. 当前实现状态记录方式
本流程本身不是“全部功能已完成”的声明。每次执行后必须同步:
- `docs/status/<task>.md`:本次模块、通过/失败/阻断和下一任务;
- `tests/golden/<task>/manifest.json`:构件、测试、hash、source coverage;
- `tests/golden/<task>/task-context.json`:上下文指纹和交接;
- 最终审计报告:按工作区、功能族、内部子模块分别统计 `READY/PARTIAL/BLOCKED`。
只有最终审计报告中所有声明为 `READY` 的功能都有桌面 receipt、Web 行为 gate 和界面 gate,才能对外宣称“桌面版 Blender 功能与界面完整对标”。

View File

@@ -0,0 +1,44 @@
# Blender 桌面版 → Web Blender 全面对标验证状态
日期:`2026-09-08`
状态:`IN_PROGRESS`
流程文档:[BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md](../BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md)
## 本轮完成
- 使用本机官方 Blender 5.2.0 LTS API 导出 11 个工作区 Area 基准。
- Web 协议增加每个工作区的编辑器集合、相对区域和默认模式。
- Web UI 增加并接线 Image Editor、Render Result、Shader Editor、Compositor、Geometry Nodes、File Browser、Spreadsheet、Text Editor、Python Console、Info、双 3D View 工作区表面。
- Modeling/Sculpting/UV Editing/Texture Paint/Shading/Animation/Rendering/Compositing/Geometry Nodes/Scripting 均可切换,且保留现有 Layout 主功能入口。
- 新增工作区表面仅作为编辑器 UI 和上下文入口;业务 mutation 仍必须通过已存在的 WASM Main/protocol/Worker 路径,不能用表面占位冒充功能完成。
## 增量验证结果
| 验证 | 结果 |
| --- | --- |
| `npm --prefix web run typecheck` | PASS |
| `npm --prefix web run lint` | PASS |
| `node --test web/tests/unit/ui-schema.test.mjs` | PASS,2/2 |
| `responsive-layout.spec.ts` | PASS,6/6 |
| `ui-context.spec.ts` | PASS |
| `keyboard-accessibility.spec.ts` | PASS |
| 11 工作区编辑器集合 | PASS |
| 工作区区域交叠检查 | PASS,0 overlap |
| 1440/1280/360/320 根宽度 | PASS,无横向溢出 |
| 新增编辑器表面 WCAG serious/critical | PASS |
## 当前边界
本轮完成的是桌面版工作区和编辑器界面对标,以及现有功能模块入口的正确分区。以下内容仍必须按流程文档逐模块执行 focused gate 后才能声明 `READY`:
- Shader/Compositor/Geometry Nodes 编辑器中的真实 graph mutation;
- Spreadsheet 与 Main reader 的字段一致性;
- Rendering/Compositor 的像素 golden、资源预算和设备故障;
- Scripting 的真实权限、签名、sandbox、取消和 dispose;
- 每个功能族的负例 no-mutation、undo/redo、save/reopen、恢复证据。
这些路径已有测试入口和协议实现时优先复用已有证据;只有输入指纹或模块 manifest 变化时才重跑。
## 交付门禁
本轮未推进 `nextTask`,未宣称全部功能完成。完成全量对标时必须继续写入模块 manifest、桌面 receipt、Chromium gate、源码 coverage 和 release metadata,并执行流程文档第 7 节的发布检查。

View File

@@ -514,7 +514,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "74216aac70992f8d879e983237ca324b998008582f0cd4658e90994cf9fae0a8",
"observedSha256": "a75e57041e5c2667616a3ec3a29ae3627397b8052190851a814952667f130276",
"observedSha256": "c9d93c4d396974d9310308b29558d934dfd308c700ab6e63380a0cd51bb5271e",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{
@@ -578,7 +578,7 @@
"artifact": "app",
"path": "web/app/src/app/App.tsx",
"originalSha256": "043ff3a2f39ef3a1303791081b80851b427e7db5dfd9af2161926dd6f1ea9ca4",
"observedSha256": "a75e57041e5c2667616a3ec3a29ae3627397b8052190851a814952667f130276",
"observedSha256": "c9d93c4d396974d9310308b29558d934dfd308c700ab6e63380a0cd51bb5271e",
"provenanceState": "BLOCKED_SOURCE_LOST"
},
{

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();