diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index aaeb930a0..6dce04bad 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -184,6 +184,37 @@ - 线上验证(冷缓存 157 s):SceneIR、studio 6 对象、4 个区域切换器、动画相机视口 canvas=1、 Graph Editor=1;把 Properties 区域切到 Spreadsheet 后 `data-editor=Spreadsheet` 且表格出现。 +### 11. OPFS 偏好设置与可改键位(2026-11-13 第三轮) + +- 新 `protocol/web-preferences.ts`(schema/默认值/校验/legacy 迁移)、 + `storage/web-settings.ts`(OPFS `settings/web-interface.json`,原子写 + localStorage 镜像)、 + `ui/PreferencesWindow.tsx`(Interface/Viewport/Animation/输入导航/Keymap/保存与加载/文件/主题/系统 分栏)。 +- 支持的设置真实生效:状态栏、快捷栏、区域切换器、状态栏版本;Keymap 覆盖 17 个顶层动作, + 录制组合键、冲突/浏览器保留键拒绝、恢复默认;`use_preferences_save` 控制 OPFS 写入与脏标记。 +- 未接线项按 `data-unsupported` + 桌面 RNA 路径/默认值标注,不伪装可编辑。 +- 验证:`web-preferences.spec.ts` **2/2 PASS**(OPFS 重载保持、旧键失效、Shift+F3 生效、冲突拒绝、 + unsupported 标记)。 + +### 12. Area ⋮ 菜单与动态 split/join + +- 复用 `ui-schema` 的 `AreaIR` 模型;新 `protocol/area-layout.ts` 提供 split/join/swap/duplicate/close/ + maximize/rect 纯函数与 DOM rect 归一化;`Area` 增加浮动 ⋮ `DesktopMenu`(分割/合并/交换/复制/最大化/ + 聚焦/关闭/重置)与绝对 rect 渲染。 +- 默认仍使用 CSS grid;首次操作测量可见 `.area-frame` 并切换到自定义布局,保留每个 slot 的原 React + 内容;新区域用 `renderAreaEditor`。布局、editor override、每视口 cameraLock 写入 OPFS,重开恢复。 +- 验证:`web-area-layout.spec.ts` **2/2 PASS**(split/join/swap/duplicate/maximize/reset、OPFS reload); + 既有 `web-area-graph-editor` 区域切换 **2/2 PASS**。 + +### 13. 相机视图 / Camera to View + +- `viewport-camera.ts` 增加 CAMERA 视图与命令;`ViewportNavigation` 实现 Numpad0 相机视图(保存/恢复 + last view)、未锁定时的取景 pan/zoom、锁定 Camera to View 时把视口 pose 去抖写回相机、 + Ctrl+Alt+Numpad0 Camera to View;`scene-camera.ts` 共享场景相机 frame。 +- 原生 `setObjectTransform` 改为 rotation-mode-aware(QUATERNION/AXIS_ANGLE/EULER),增量 WASM 重建, + 写回可撤销。导航工具栏增加相机视图/对齐视图/锁定按钮;cameraLock 按区域持久化。 +- 验证:`web-camera-view.spec.ts` **2/2 PASS**(进入/退出恢复、未锁定不动相机、锁定写回 revision、 + Ctrl+Alt+Numpad0)。 + ## 待实现 1. 逐模块短视频:计划中的 15 条 3–8 分钟视频目前只完成 2 条(编辑器专课 + 渲染专课); diff --git a/web/tests/unit/area-layout.test.mjs b/web/tests/unit/area-layout.test.mjs new file mode 100644 index 000000000..4b8dced0a --- /dev/null +++ b/web/tests/unit/area-layout.test.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import test from "node:test"; +import ts from "typescript"; + +const repoRoot = path.resolve(import.meta.dirname, "../../.."); +const sourcePath = path.join(repoRoot, "web/protocol/area-layout.ts"); +const transpiled = ts.transpileModule(fs.readFileSync(sourcePath, "utf8"), { + compilerOptions: { module: ts.ModuleKind.ES2022, target: ts.ScriptTarget.ES2022 }, + fileName: sourcePath, + reportDiagnostics: true, +}); +assert.deepEqual(transpiled.diagnostics, []); +const moduleUrl = "data:text/javascript;base64," + Buffer.from(transpiled.outputText).toString("base64"); +const { applyAreaOperation, findAreaNeighbor, measureAreaRects } = await import(moduleUrl); + +function area(id, x, y, width, height, editor = "3D Viewport") { + return { id, editor, regions: [], rect: { x, y, width, height }, maximized: false, contentKey: id }; +} +function workspace(areas) { + return { id: "Layout", name: "Layout", areas, activeAreaId: areas[0].id, revision: 0 }; +} + +test("split creates a new generic area inside the active rect", () => { + const state = workspace([area("viewport", 0, 0, 1, 1)]); + const next = applyAreaOperation(state, { type: "SPLIT_AREA", areaId: "viewport", direction: "VERTICAL", factor: 0.5 }); + assert.equal(next.areas.length, 2); + assert.equal(next.areas[0].rect.width, 0.5); + assert.equal(next.areas[1].rect.x, 0.5); + assert.equal(next.areas[1].contentKey, null); + assert.equal(next.areas[1].editor, "3D Viewport"); +}); + +test("neighbor lookup uses perpendicular overlap and join unions rects", () => { + const state = workspace([area("left", 0, 0, 0.5, 1), area("right", 0.5, 0, 0.5, 1, "Properties")]); + assert.equal(findAreaNeighbor(state.areas, "right", "LEFT")?.id, "left"); + const next = applyAreaOperation(state, { type: "JOIN_AREA", areaId: "right", targetAreaId: "left" }); + assert.equal(next.areas.length, 1); + assert.deepEqual(next.areas[0].rect, { x: 0, y: 0, width: 1, height: 1 }); + assert.equal(next.areas[0].editor, "Properties"); +}); + +test("swap exchanges editors but keeps slot identity", () => { + const state = workspace([area("left", 0, 0, 0.5, 1), area("right", 0.5, 0, 0.5, 1, "Properties")]); + const next = applyAreaOperation(state, { type: "SWAP_AREAS", areaId: "left", targetAreaId: "right" }); + assert.equal(next.areas.find((item) => item.id === "left")?.editor, "Properties"); + assert.equal(next.areas.find((item) => item.id === "right")?.editor, "3D Viewport"); + assert.equal(next.areas.find((item) => item.id === "left")?.contentKey, "left"); +}); + +test("close keeps at least one area and maximize toggles", () => { + const state = workspace([area("viewport", 0, 0, 1, 1)]); + assert.equal(applyAreaOperation(state, { type: "CLOSE_AREA", areaId: "viewport" }).areas.length, 1); + const maximized = applyAreaOperation(state, { type: "TOGGLE_MAXIMIZE_AREA", areaId: "viewport" }); + assert.equal(maximized.areas[0].maximized, true); + assert.equal(applyAreaOperation(maximized, { type: "TOGGLE_MAXIMIZE_AREA", areaId: "viewport" }).areas[0].maximized, false); +}); + +test("DOM rect measurement converts to bottom-left normalized Blender rects", () => { + const rect = (left, top, width, height) => ({ left, top, width, height, right: left + width, bottom: top + height }); + const rects = measureAreaRects([{ id: "viewport", editor: "3D Viewport", contentKey: "viewport", rect: rect(100, 50, 800, 600) }], rect(100, 50, 1000, 800)); + assert.equal(rects[0].rect.x, 0); + assert.equal(rects[0].rect.width, 0.8); + assert.equal(rects[0].rect.height, 0.75); + assert.equal(rects[0].rect.y, 0.25); +});