Add full Blender desktop parity validation workflow
This commit is contained in:
205
docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal file
205
docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal 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 功能与界面完整对标”。
|
||||
44
docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal file
44
docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md
Normal 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 节的发布检查。
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>>>></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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user