diff --git a/docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md b/docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md new file mode 100644 index 000000000..83f2d3eb3 --- /dev/null +++ b/docs/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md @@ -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 --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/.md`:本次模块、通过/失败/阻断和下一任务; +- `tests/golden//manifest.json`:构件、测试、hash、source coverage; +- `tests/golden//task-context.json`:上下文指纹和交接; +- 最终审计报告:按工作区、功能族、内部子模块分别统计 `READY/PARTIAL/BLOCKED`。 + +只有最终审计报告中所有声明为 `READY` 的功能都有桌面 receipt、Web 行为 gate 和界面 gate,才能对外宣称“桌面版 Blender 功能与界面完整对标”。 diff --git a/docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md b/docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md new file mode 100644 index 000000000..2187b63ce --- /dev/null +++ b/docs/status/BLENDER_DESKTOP_WEB_FULL_PARITY_VALIDATION.md @@ -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 节的发布检查。 diff --git a/tests/golden/corrective/rehydration/M12-manifest-sha-drift.json b/tests/golden/corrective/rehydration/M12-manifest-sha-drift.json index 6c144ec0f..53dd9adbc 100644 --- a/tests/golden/corrective/rehydration/M12-manifest-sha-drift.json +++ b/tests/golden/corrective/rehydration/M12-manifest-sha-drift.json @@ -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" }, { diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 5269f513a..4f7a0b750 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -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 = { + "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 = { + 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 (
@@ -434,6 +466,47 @@ function Area({ className = "", editor, children }: AreaProps) { ); } +function WorkspaceEditorSurface({ editor, snapshot, engineStatus, onOpen, onSearch }: { + editor: Exclude; + 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
+
{EDITOR_ICONS[editor]} {editor}ViewImage{editor === "Image Editor" ? UV : null}
+
{editor === "Render Result" ? "Render Result" : activeNode?.name ?? "UVMap"}
+
; + } + 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
+
{EDITOR_ICONS[editor]} {editor}ViewSelectAddNode
+
+
; + } + if (editor === "File Browser") { + return
{EDITOR_ICONS[editor]} Asset Browser
; + } + if (editor === "Spreadsheet") { + return
{EDITOR_ICONS[editor]} SpreadsheetGeometryOriginal
{(snapshot?.nodes ?? []).slice(0, 24).map((node) => )}
NameTypeVisible
{node.name}{node.type}{node.visible ? "True" : "False"}
{!snapshot ? No geometry data : null}
; + } + 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
{EDITOR_ICONS[editor]} Text EditorViewTextTemplates
1{source.split("\n").slice(1).map((_, index) => `\n${index + 2}`)}{source}
; + } + if (editor === "Python Console") { + return
{EDITOR_ICONS[editor]} Python ConsoleViewConsole
PYTHON INTERACTIVE CONSOLE 3.11Blender 5.2.0 LTS
; + } + if (editor === "Info") { + return
{EDITOR_ICONS[editor]} InfoView
{engineStatus}Scene: {snapshot?.scenes[0]?.name ?? "Scene"}
; + } + return
Camera Perspective
{activeNode?.name ?? "Cube"}
; +} + interface MeshEditSelection { meshId: string | null; mode: MeshElementMode; @@ -1981,7 +2054,9 @@ export function App() { const saveBlendRef = useRef<() => Promise>(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 => { const entry = createAppDiagnosticEntry({ @@ -3521,12 +3596,19 @@ export function App() {
{workerFault ?
{workerFault.source === "engine" ? "Engine Worker" : "Storage Worker"} stopped; current project list and scene are retained.{workerRecoveryStatus === "FAILED" ? Recovery failed; retry is safe. : null}
: null} {recentProjectIssues.length > 0 ?
最近项目中有 {recentProjectIssues.length} 个条目无法验证;当前场景不会被删除。
{recentProjectIssues.map((issue) =>
{issue.project.displayName}: {recentProjectIssueMessage(issue.code)}
)}
: null} -
{workspaceLabel}{uiState.context.mode === "Edit" ? <>
{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => )} : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => )}}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
+
{workspaceLabel}{uiState.context.mode === "Edit" ? <>
{(["VERT", "EDGE", "FACE"] as MeshElementMode[]).map((mode) => )}
{(["MERGE", "DISSOLVE", "EXTRUDE", "INSET", "BEVEL", "LOOP_CUT", "SUBDIVIDE", "FILL", "TRIANGULATE", "RECALCULATE_NORMALS", "BRIDGE_LOOPS", "DELETE"] as MeshEditOperation[]).map((operation) => )} : <>{(["CUBE", "PLANE", "UV_SPHERE", "ICO_SPHERE", "CYLINDER", "CONE", "TORUS"] as MeshPrimitive[]).map((primitive) => )}}{uiState.context.mode === "Edit" ? `${meshSelection.greasePencilPoints?.length ?? meshSelection.indices.size} ${meshSelection.mode.toLowerCase()} selected` : `${selectedObjectIds.size} selected`}{saved ? "已保存" : "未保存"}
+ {workspace === "UV Editing" || workspace === "Texture Paint" || workspace === "Shading" || workspace === "Compositing" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspace === "Rendering" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspaceNodeEditor ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspace === "Shading" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspace === "Animation" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspace === "Geometry Nodes" ? fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> : null} + {workspace === "Scripting" ? <> fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> fileInputRef.current?.click()} onSearch={() => dispatchUI({ type: "toggleOperatorSearch", open: true })} /> void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> : null} void applyEditCommand({ type: "setObjectVisibility", objectId: id, visible })} /> { 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}`); }} /> - void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} /> + void applyEditCommand({ type: "setFrame", frame: value })} onCommand={(command) => void applyEditCommand(command)} />
{uiState.operatorSearchOpen ? { 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}
LMB Select · MMB Rotate View · Wheel ZoomCollection | Cube · Objects {objectCount} · Vertices {vertexCount} · Faces {faceCount}{openProgress ? 0 ? openProgress.totalBytes : 1} value={openProgress.bytesRead ?? openProgress.fraction ?? 0} />{openProgress.message ?? "Opening"}{canCancelOpenRead ? : null} : null}{manifestStatus}{wasmStatus}{engineStatus}{storageStatus} void cleanupProjectAssets()} />5.2.0 LTS
diff --git a/web/app/src/app/app-shell.css b/web/app/src/app/app-shell.css index 7ef597a24..ae75b8e0f 100644 --- a/web/app/src/app/app-shell.css +++ b/web/app/src/app/app-shell.css @@ -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; } diff --git a/web/protocol/ui-schema.ts b/web/protocol/ui-schema.ts index 75ecbcd32..a0295ec23 100644 --- a/web/protocol/ui-schema.ts +++ b/web/protocol/ui-schema.ts @@ -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>)[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, }, }; diff --git a/web/tests/e2e/responsive-layout.spec.ts b/web/tests/e2e/responsive-layout.spec.ts index 1e689a9ed..cf1ec5acb 100644 --- a/web/tests/e2e/responsive-layout.spec.ts +++ b/web/tests/e2e/responsive-layout.spec.ts @@ -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(".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([]); + } + } + }); }); diff --git a/web/tests/unit/ui-schema.test.mjs b/web/tests/unit/ui-schema.test.mjs index 5ecdd515a..150590546 100644 --- a/web/tests/unit/ui-schema.test.mjs +++ b/web/tests/unit/ui-schema.test.mjs @@ -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();