diff --git a/docs/roadmap.md b/docs/roadmap.md index 075f1db..8c18e28 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -122,7 +122,10 @@ - [x] **A1 · 契约 + Babylon headless + conformance**([#37](https://github.com/longyi-xw/lowcode-3d/pull/37)):契约加引擎中立 `describeNode(): RuntimeNodeInfo`(读引擎对象)+ `dispose()`;`BabylonAdapter`(`@babylonjs/core` `NullEngine`,headless)实现 `syncNode` 核心 kind(group/mesh/light/camera);conformance 套件让 ThreeAdapter + BabylonAdapter 跑同一批断言全绿 = 契约对第二引擎成立。纯 headless,不接实时视口。 - [x] **A2 · behaviors 跨引擎运行时**([#38](https://github.com/longyi-xw/lowcode-3d/pull/38)):`installBehaviors`/`tickBehaviors`/`uninstallBehaviors` 纳入 `IRuntimeAdapter`;Babylon behavior 框架(registry + auto-rotate + bob,操作 Babylon 节点,共享引擎中立 `BehaviorDefinition`);conformance 验运行时对等——**bob 跨引擎精确对等**(位置突变同公式)、auto-rotate ran/累积/卸载、enabled/unknown 隔离。纯 headless。hover-highlight(事件型)+ behavior codegen 留后续。 - [ ] **A3 · glTF + 导出**:prefab_instance/glTF 加载 + `babylon` 导出 target(含 behavior codegen 跨引擎)。 - - [ ] **B · 实时视口切引擎**:抽 `IRenderHost`(渲染循环/相机控制/gizmo/拾取/outline),ThreeViewport 改为面向它,Babylon 渲染宿主落地,用户可把实时编辑器切到 Babylon。spec §8 已备边界清单。 + - [x] **B1 · render host:实时切引擎(看+转相机)**:引擎中立 `IRenderHost` 契约(B1 子集:mount/渲染循环/相机/resize/dispose)+ `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`,`BabylonAdapter` 构造可注入引擎、默认仍 NullEngine 保 conformance)+ `BabylonViewport`(`diffSceneNodes` 增量同步,warn-skip 错误隔离)+ 视口工具栏 Three/Babylon 开关(`useUIStore.viewportEngine` 会话态,切换强制退 play)。Babylon 场景 `useRightHandedSystem` 对齐 three/glTF 坐标系(无镜像)。Babylon 模式 view-only:play/gizmo/拾取/拖放/F focus 经 `isEngineEditingCapable` 统一禁用。**ThreeViewport 零改动**(「并行+定接口」,B2/B3 按 A1 spec §8 清单收敛)。 + - [ ] **B2 · 拾取 + 选中描边跨引擎**:`IRenderHost` 扩 pick/选中高亮,ThreeViewport 开始收敛(迁 `scene-diff`)。 + - [ ] **B3 · gizmo + snap 跨引擎**。 + - [ ] **B4 · 视口能力剩余**:socket 标记 / 资源拖放落点 / F focus / play 行为预览跨引擎;Babylon 视口底色 sRGB 对齐(Three OutputPass 提亮 vs Babylon 直写 raw,见 B1 smoke 记录);Babylon 光强/材质观感对齐。 - **Depends on**: v0.5 行为系统全部完成(v0.5 Stage C) ### v1.x — Planned diff --git a/docs/superpowers/plans/2026-06-10-v1.0b1-render-host.md b/docs/superpowers/plans/2026-06-10-v1.0b1-render-host.md new file mode 100644 index 0000000..e0f450d --- /dev/null +++ b/docs/superpowers/plans/2026-06-10-v1.0b1-render-host.md @@ -0,0 +1,1307 @@ +# v1.0 B1 — Render Host 实时视口切引擎(看 + 转相机)实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 编辑器视口可在 Three.js 与 Babylon.js 之间实时切换;Babylon 模式最小可用——场景实时同步渲染 + 轨道相机,无视口内编辑交互。 + +**架构:** 「并行 + 定接口」——新增引擎中立 `IRenderHost` 契约 + `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`)+ `BabylonViewport` 组件 + 顶层 `Viewport` 按 `useUIStore.viewportEngine`(会话态)切换。**`ThreeViewport.tsx` 一行不改**;增量同步走新的引擎中立纯函数 `diffSceneNodes`。规格:`docs/superpowers/specs/2026-06-10-v1.0b1-render-host-design.md`。 + +**技术栈:** React 18 + zustand + `@babylonjs/core` 9.11.0(已装)+ vitest(jsdom,无 WebGL → Babylon 侧测试全部经 `NullEngine` 注入 seam)。 + +**约定提醒(来自仓库惯例,违反会翻车):** + +- 测试与源码同目录共置(`*.test.ts(x)`)。 +- husky 强制 Conventional Commits;commit 用显式 pathspec,绝不 `git add -A`(防把 `design/` 截图带进去)。 +- `setState` 不许出现在 `useEffect` body(eslint react-hooks 会 error 并被 husky 拦下)。 +- vitest 全绿 ≠ 功能正确:最后必须 `pnpm tauri dev` 视觉 smoke(任务 9)。 + +--- + +## 文件结构 + +**创建:** + +| 文件 | 职责 | +| ------------------------------------------ | ----------------------------------------------------------------------------------- | +| `src/runtime/render-host.ts` | `IRenderHost` 契约 + `ViewportEngine` 类型 + `isEngineEditingCapable` 能力门 | +| `src/runtime/render-host.test.ts` | `isEngineEditingCapable` 单测 | +| `src/runtime/babylon/render-host.ts` | `BabylonRenderHost`:真 Engine + ArcRotateCamera + 渲染循环 + dispose 链 | +| `src/runtime/babylon/render-host.test.ts` | NullEngine 注入下的完整生命周期单测 | +| `src/ui/viewport/scene-diff.ts` | 引擎中立纯函数 `diffSceneNodes`(镜像 ThreeViewport.diffAndApply 的节点同一性语义) | +| `src/ui/viewport/scene-diff.test.ts` | diff 语义单测 | +| `src/ui/viewport/BabylonViewport.tsx` | Babylon 视口组件:种场景 + 订阅增量同步 + resize + 清理 | +| `src/ui/viewport/BabylonViewport.test.tsx` | NullEngine seam 下 mount/同步/卸载组件测试 | +| `src/ui/viewport/Viewport.tsx` | 按 `viewportEngine` 切换两个视口组件 | +| `src/ui/viewport/Viewport.test.tsx` | 切换渲染正确子组件(mock 两个视口) | +| `src/ui/viewport/EngineToggle.tsx` | Three/Babylon 分段开关(切换时强制退出 play) | +| `src/ui/viewport/EngineToggle.test.tsx` | 开关行为单测 | + +**修改:** + +| 文件 | 改动 | +| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | +| `src/runtime/babylon/adapter.ts` | 构造注入 `engine?`(默认 NullEngine)、`scene` 改 public、`useRightHandedSystem = true` | +| `src/runtime/babylon/adapter.test.ts` | 注入/默认/dispose-ownership 测试 | +| `src/services/ui/store.ts` | 加 `viewportEngine` + `setViewportEngine`(会话态) | +| `src/ui/views/EditorView.tsx` | `` → ``;挂 ``;gizmo pill 在 Babylon 模式禁用 | +| `src/ui/viewport/PlayButton.tsx` + `.test.tsx` | Babylon 模式禁用 + tooltip | +| `src/ui/viewport/use-editor-shortcuts.ts` + `.test.tsx` | Space(play)与 F(focus)在 Babylon 模式 no-op | +| `src/services/library/asset-drag.ts` + `.test.ts` | Babylon 模式不启动资源拖拽 | +| `src/i18n/locales/en-US/editor.json`、`zh-CN/editor.json` | `viewport.engine.*` + `play.engine_unavailable` key(现有 namespace 加 key,无需动 i18n types) | + +**不动:** `ThreeViewport.tsx`、`IRuntimeAdapter`(`src/runtime/adapter.ts`)、conformance 套件、所有 Rust。 + +--- + +### 任务 1:`scene-diff.ts` 引擎中立场景 diff 纯函数 + +**文件:** + +- 创建:`src/ui/viewport/scene-diff.ts` +- 测试:`src/ui/viewport/scene-diff.test.ts` + +语义对照源:`ThreeViewport.tsx:747-795` 的 `diffAndApply` —— BFS 遍历 `next` 的 roots(保证父先于子)、`!old.nodes[id]` = added、引用不等 = updated、old 有 next 无 = removed。本函数只算 diff 不执行(执行端各视口自理)。 + +- [ ] **步骤 1:编写失败的测试** + +`src/ui/viewport/scene-diff.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import type { SceneGraph, SceneNode } from "@/core/scene/types"; + +import { diffSceneNodes, EMPTY_SCENE_GRAPH } from "./scene-diff"; + +const ID_TRANSFORM = { + position: [0, 0, 0] as [number, number, number], + rotation: [0, 0, 0, 1] as [number, number, number, number], + scale: [1, 1, 1] as [number, number, number], +}; +const baseFields = { + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, +}; + +function node( + partial: Pick & Partial, +): SceneNode { + return { + transform: ID_TRANSFORM, + parent_id: null, + ...baseFields, + ...partial, + } as SceneNode; +} + +function graph(roots: string[], nodes: SceneNode[]): SceneGraph { + return { + root_node_ids: roots, + nodes: Object.fromEntries(nodes.map((n) => [n.id, n])), + }; +} + +const groupNode = (id: string, children: string[] = [], parent: string | null = null) => + node({ + id, + name: id, + type: "group", + data: { type: "group" }, + children_ids: children, + parent_id: parent, + }); + +describe("diffSceneNodes", () => { + it("identical graphs produce an empty diff", () => { + const g = graph(["a"], [groupNode("a")]); + const diff = diffSceneNodes(g, g); + expect(diff.added).toEqual([]); + expect(diff.updated).toEqual([]); + expect(diff.removed).toEqual([]); + }); + + it("seeding from EMPTY_SCENE_GRAPH reports every node as added, parents before children", () => { + const child = groupNode("child", [], "parent"); + const parent = groupNode("parent", ["child"]); + const diff = diffSceneNodes(EMPTY_SCENE_GRAPH, graph(["parent"], [parent, child])); + expect(diff.added.map((n) => n.id)).toEqual(["parent", "child"]); + expect(diff.updated).toEqual([]); + expect(diff.removed).toEqual([]); + }); + + it("reference change == updated; untouched siblings are not reported", () => { + const a = groupNode("a"); + const b = groupNode("b"); + const old = graph(["a", "b"], [a, b]); + const next = graph(["a", "b"], [{ ...a, name: "renamed" }, b]); + const diff = diffSceneNodes(old, next); + expect(diff.updated.map((n) => n.id)).toEqual(["a"]); + expect(diff.added).toEqual([]); + expect(diff.removed).toEqual([]); + }); + + it("node present in old but missing from next == removed", () => { + const a = groupNode("a"); + const b = groupNode("b"); + const diff = diffSceneNodes(graph(["a", "b"], [a, b]), graph(["a"], [a])); + expect(diff.removed.map((n) => n.id)).toEqual(["b"]); + }); + + it("mixed add/update/remove in one pass", () => { + const a = groupNode("a"); + const b = groupNode("b"); + const c = groupNode("c"); + const old = graph(["a", "b"], [a, b]); + const next = graph(["a", "c"], [{ ...a, name: "a2" }, c]); + const diff = diffSceneNodes(old, next); + expect(diff.added.map((n) => n.id)).toEqual(["c"]); + expect(diff.updated.map((n) => n.id)).toEqual(["a"]); + expect(diff.removed.map((n) => n.id)).toEqual(["b"]); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/ui/viewport/scene-diff.test.ts` +预期:FAIL — `Cannot find module './scene-diff'`(或等价报错)。 + +- [ ] **步骤 3:编写实现** + +`src/ui/viewport/scene-diff.ts`: + +```ts +import type { SceneGraph, SceneNode } from "@/core/scene/types"; + +/** + * Engine-neutral scene diff (v1.0 B1). Mirrors the node-identity semantics of + * ThreeViewport's diffAndApply walk — BFS over `next` roots so parents always + * precede their children in `added`, reference inequality == updated — but + * only computes the diff; callers apply it via their adapter's syncNode. + * ThreeViewport keeps its own inline walk until B2 converges it onto this. + */ +export interface SceneDiff { + /** BFS order from the roots — parents precede children, so applying adds + * in order never references an unregistered parent. */ + added: SceneNode[]; + updated: SceneNode[]; + removed: SceneNode[]; +} + +/** Seed a fresh viewport via diffSceneNodes(EMPTY_SCENE_GRAPH, project.scene). */ +export const EMPTY_SCENE_GRAPH: SceneGraph = { nodes: {}, root_node_ids: [] }; + +export function diffSceneNodes(old: SceneGraph, next: SceneGraph): SceneDiff { + const added: SceneNode[] = []; + const updated: SceneNode[] = []; + const removed: SceneNode[] = []; + const queue: string[] = [...next.root_node_ids]; + const seen = new Set(); + while (queue.length > 0) { + const id = queue.shift(); + if (id === undefined || seen.has(id)) continue; + seen.add(id); + const n = next.nodes[id]; + if (!n) continue; + const o = old.nodes[id]; + if (!o) added.push(n); + else if (n !== o) updated.push(n); + queue.push(...n.children_ids); + } + for (const id of Object.keys(old.nodes)) { + if (!next.nodes[id]) { + const r = old.nodes[id]; + if (r) removed.push(r); + } + } + return { added, updated, removed }; +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/ui/viewport/scene-diff.test.ts` +预期:PASS(5 个用例)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/scene-diff.ts src/ui/viewport/scene-diff.test.ts +git commit -m "feat(viewport): engine-neutral diffSceneNodes — mirrors diffAndApply identity walk" +``` + +--- + +### 任务 2:`BabylonAdapter` 构造注入引擎 + 右手坐标系 + +**文件:** + +- 修改:`src/runtime/babylon/adapter.ts:62-66`(class 头部字段) +- 测试:`src/runtime/babylon/adapter.test.ts`(追加) + +背景:当前 `private readonly engine = new NullEngine(); private readonly scene = new Scene(this.engine);` 写死。B1 要让 `BabylonRenderHost` 传入真 `Engine`。**所有权规则(spec §4):adapter 始终 dispose 自己持有的 engine,无论注入与否**——现有 `dispose()`(`adapter.ts:184-187`,`scene.dispose(); engine.dispose()`)已是这个语义,不用改。 + +- [ ] **步骤 1:编写失败的测试** + +在 `src/runtime/babylon/adapter.test.ts` 追加(`NullEngine` 若未 import,从 `@babylonjs/core` 加入现有 import): + +```ts +describe("engine injection (v1.0 B1)", () => { + it("uses the injected engine and disposes it with the adapter", () => { + const engine = new NullEngine(); + const adapter = new BabylonAdapter({ engine }); + expect(adapter.scene.getEngine()).toBe(engine); + adapter.dispose(); + expect(engine.isDisposed).toBe(true); + }); + + it("defaults to a NullEngine when nothing is injected", () => { + const adapter = new BabylonAdapter(); + expect(adapter.scene.getEngine()).toBeInstanceOf(NullEngine); + adapter.dispose(); + }); + + it("scene uses the right-handed system (matches three.js / glTF transforms)", () => { + const adapter = new BabylonAdapter(); + expect(adapter.scene.useRightHandedSystem).toBe(true); + adapter.dispose(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/runtime/babylon/adapter.test.ts` +预期:FAIL — 构造函数不接受参数 / `scene` 为 private(typecheck 报错也算 RED)。 + +- [ ] **步骤 3:实现** + +`src/runtime/babylon/adapter.ts`:在 `@babylonjs/core` import 中加入 `type AbstractEngine`;把 + +```ts + private readonly engine = new NullEngine(); + private readonly scene = new Scene(this.engine); +``` + +替换为: + +```ts + /** Engine-specific escape hatch (mirrors ThreeAdapter.scene) — used by + * BabylonRenderHost to mount the editor camera. Not on IRuntimeAdapter. */ + readonly scene: Scene; + private readonly engine: AbstractEngine; + + constructor(options?: { engine?: AbstractEngine }) { + this.engine = options?.engine ?? new NullEngine(); + this.scene = new Scene(this.engine); + // Project transforms are right-handed (three.js was the first engine and + // glTF is RH); Babylon defaults to left-handed, which would mirror the + // rendered scene on z. Raw stored values are unaffected, so headless + // conformance/describeNode behavior does not change. + this.scene.useRightHandedSystem = true; + } +``` + +注意:`objects` / `behaviorRegistry` / `behaviorRuntime` 字段初始化器不依赖 engine/scene,原样保留,不要搬进构造函数。 + +- [ ] **步骤 4:运行测试验证通过(含回归)** + +运行:`pnpm vitest run src/runtime/babylon src/runtime/conformance` +预期:PASS — 新增 3 用例 + 既有 Babylon 单测 + conformance 双引擎全绿(默认路径仍 NullEngine)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/adapter.ts src/runtime/babylon/adapter.test.ts +git commit -m "feat(babylon): injectable engine (NullEngine default) + right-handed system" +``` + +--- + +### 任务 3:`render-host.ts` 契约 + 能力门 + +**文件:** + +- 创建:`src/runtime/render-host.ts` +- 测试:`src/runtime/render-host.test.ts` + +- [ ] **步骤 1:编写失败的测试** + +`src/runtime/render-host.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { isEngineEditingCapable } from "./render-host"; + +describe("isEngineEditingCapable", () => { + it("three.js viewport supports editing interactions", () => { + expect(isEngineEditingCapable("three.js")).toBe(true); + }); + + it("babylon.js viewport is view-only in B1", () => { + expect(isEngineEditingCapable("babylon.js")).toBe(false); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/runtime/render-host.test.ts` +预期:FAIL — module not found。 + +- [ ] **步骤 3:实现** + +`src/runtime/render-host.ts`: + +```ts +import type { RuntimeTarget } from "@/core/scene/types"; + +/** Engines the editor viewport can render with — the subset of RuntimeTarget + * kinds that have a render-host implementation. */ +export type ViewportEngine = Extract; + +/** + * Engine-neutral render host contract (v1.0 B1 subset: mount / render loop / + * camera / resize / dispose). B2 extends it with picking + selection + * highlight, B3 with the gizmo — converging ThreeViewport onto it per the + * A1 spec §8 boundary list. B1's only implementation is BabylonRenderHost; + * the Three side stays inside ThreeViewport untouched. + * + * Call order: mount → start → (resize/stop/start)* → dispose. An instance is + * not reusable after dispose. + */ +export interface IRenderHost { + readonly engine: ViewportEngine; + /** Create the real engine + editor camera + camera controls on the canvas. */ + mount(canvas: HTMLCanvasElement): void; + start(): void; + stop(): void; + resize(width: number, height: number): void; + dispose(): void; +} + +/** + * B1 capability gate: only the Three viewport supports editing interactions + * (gizmo / play / pick / drop / focus). Every UI surface that disables itself + * in Babylon mode reads this single helper, so B2/B3/B4 flip capabilities in + * one place instead of hunting scattered engine === "..." checks. + */ +export function isEngineEditingCapable(engine: ViewportEngine): boolean { + return engine === "three.js"; +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/runtime/render-host.test.ts` +预期:PASS。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/render-host.ts src/runtime/render-host.test.ts +git commit -m "feat(runtime): IRenderHost contract + isEngineEditingCapable gate (v1.0 B1)" +``` + +--- + +### 任务 4:`BabylonRenderHost` + +**文件:** + +- 创建:`src/runtime/babylon/render-host.ts` +- 测试:`src/runtime/babylon/render-host.test.ts` + +要点:真路径 `new Engine(canvas, true)`;测试 seam 是 `createEngine` 工厂(注入 `NullEngine`)。`attachControl` 只在 `engine.getRenderingCanvas()` 非 null 时调(NullEngine 无渲染 canvas,指针控制无意义)。dispose 链:停 loop → 相机 → `adapter.dispose()`(按任务 2 的所有权规则内含 engine.dispose()),host **不**二次 dispose engine。 + +- [ ] **步骤 1:编写失败的测试** + +`src/runtime/babylon/render-host.test.ts`: + +```ts +import { ArcRotateCamera, NullEngine } from "@babylonjs/core"; +import { describe, expect, it } from "vitest"; + +import { BabylonRenderHost } from "./render-host"; + +function makeHost() { + const engine = new NullEngine(); + const host = new BabylonRenderHost({ createEngine: () => engine }); + return { host, engine }; +} + +describe("BabylonRenderHost", () => { + it("identifies as the babylon.js engine", () => { + expect(makeHost().host.engine).toBe("babylon.js"); + }); + + it("adapter throws before mount", () => { + expect(() => makeHost().host.adapter).toThrow(/mount/); + }); + + it("mount creates the adapter and an ArcRotate editor camera at [4,3,4]", () => { + const { host } = makeHost(); + host.mount(document.createElement("canvas")); + const scene = host.adapter.scene; + expect(scene.activeCamera).toBeInstanceOf(ArcRotateCamera); + const cam = scene.activeCamera as ArcRotateCamera; + expect(cam.position.x).toBeCloseTo(4); + expect(cam.position.y).toBeCloseTo(3); + expect(cam.position.z).toBeCloseTo(4); + host.dispose(); + }); + + it("full lifecycle mount → start → resize → stop → dispose does not throw", () => { + const { host, engine } = makeHost(); + host.mount(document.createElement("canvas")); + host.start(); + host.resize(800, 600); + host.stop(); + host.dispose(); + expect(engine.isDisposed).toBe(true); + }); + + it("dispose releases the adapter (engine ownership lives there)", () => { + const { host, engine } = makeHost(); + host.mount(document.createElement("canvas")); + host.dispose(); + expect(engine.isDisposed).toBe(true); + expect(() => host.adapter).toThrow(); + }); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/runtime/babylon/render-host.test.ts` +预期:FAIL — module not found。 + +- [ ] **步骤 3:实现** + +`src/runtime/babylon/render-host.ts`: + +```ts +import { + ArcRotateCamera, + Color4, + Engine, + Vector3, + type AbstractEngine, +} from "@babylonjs/core"; + +import type { IRenderHost } from "@/runtime/render-host"; + +import { BabylonAdapter } from "./adapter"; + +export interface BabylonRenderHostOptions { + /** Test seam — defaults to a real WebGL Engine on the mounted canvas. + * Tests inject () => new NullEngine() because jsdom has no WebGL. */ + createEngine?: (canvas: HTMLCanvasElement) => AbstractEngine; +} + +/** Matches ThreeViewport's renderer.setClearColor(0x101418). */ +const CLEAR_COLOR = new Color4(0x10 / 255, 0x14 / 255, 0x18 / 255, 1); + +/** + * Babylon render host (v1.0 B1) — owns the real Engine, the editor + * ArcRotateCamera and the render loop. The BabylonAdapter it creates owns the + * scene AND the engine disposal (spec §4 ownership rule), so dispose() here + * never calls engine.dispose() itself. + */ +export class BabylonRenderHost implements IRenderHost { + readonly engine = "babylon.js" as const; + private readonly createEngine: (canvas: HTMLCanvasElement) => AbstractEngine; + private babylonEngine: AbstractEngine | null = null; + private camera: ArcRotateCamera | null = null; + private adapterInstance: BabylonAdapter | null = null; + + constructor(options?: BabylonRenderHostOptions) { + this.createEngine = options?.createEngine ?? ((canvas) => new Engine(canvas, true)); + } + + /** Engine-specific surface (not on IRenderHost) — BabylonViewport reaches + * through it for syncNode. Throws before mount() / after dispose(). */ + get adapter(): BabylonAdapter { + if (!this.adapterInstance) { + throw new Error("BabylonRenderHost: call mount() before adapter"); + } + return this.adapterInstance; + } + + mount(canvas: HTMLCanvasElement): void { + const engine = this.createEngine(canvas); + this.babylonEngine = engine; + const adapter = new BabylonAdapter({ engine }); + this.adapterInstance = adapter; + const scene = adapter.scene; + scene.clearColor = CLEAR_COLOR; + // Editor orbit camera — framing matches the Three editor camera defaults + // (position [4,3,4] looking at the origin, vertical fov 50°; see + // ThreeAdapter defaultCamera). Alpha/beta/radius args are placeholders: + // setPosition() recomputes them from the actual position. + const camera = new ArcRotateCamera("editor-camera", 0, 1, 8, Vector3.Zero(), scene); + camera.setPosition(new Vector3(4, 3, 4)); + camera.fov = (50 * Math.PI) / 180; + camera.minZ = 0.1; + camera.maxZ = 1000; + // NullEngine has no rendering canvas — pointer controls only make sense + // on a real Engine. + if (engine.getRenderingCanvas()) camera.attachControl(); + scene.activeCamera = camera; + this.camera = camera; + } + + start(): void { + const engine = this.babylonEngine; + const scene = this.adapterInstance?.scene; + if (!engine || !scene) return; + engine.runRenderLoop(() => scene.render()); + } + + stop(): void { + this.babylonEngine?.stopRenderLoop(); + } + + resize(_width: number, _height: number): void { + // Babylon reads the canvas client size itself; params exist for the + // engine-neutral contract. + this.babylonEngine?.resize(); + } + + dispose(): void { + this.stop(); + this.camera?.dispose(); + this.camera = null; + this.adapterInstance?.dispose(); + this.adapterInstance = null; + this.babylonEngine = null; + } +} +``` + +若 eslint 对 `_width` / `_height` 报 unused:确认 eslint 配置的 `argsIgnorePattern: "^_"`;若没有该配置,把参数改名为 `width`/`height` 并在函数体首行 `void width; void height;` —— 不要为此改全局 eslint 规则。 + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/runtime/babylon/render-host.test.ts` +预期:PASS(5 用例)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/render-host.ts src/runtime/babylon/render-host.test.ts +git commit -m "feat(babylon): BabylonRenderHost — real Engine + ArcRotate editor camera + render loop" +``` + +--- + +### 任务 5:`useUIStore.viewportEngine` 会话态 + +**文件:** + +- 修改:`src/services/ui/store.ts` + +无独立测试(纯 zustand setter,模式同既有字段);行为由任务 6–8 的组件测试覆盖。 + +- [ ] **步骤 1:实现** + +`src/services/ui/store.ts`:顶部加 import: + +```ts +import type { ViewportEngine } from "@/runtime/render-host"; +``` + +`interface UIState` 内(`playState` 块后)加: + +```ts + /** Which engine the editor viewport renders with (v1.0 B1). Session-only + * preview switch — never persisted, not part of project data. */ + viewportEngine: ViewportEngine; + setViewportEngine: (engine: ViewportEngine) => void; +``` + +`create` 实现内(`setPlayState` 行后)加: + +```ts + viewportEngine: "three.js", + setViewportEngine: (viewportEngine) => set({ viewportEngine }), +``` + +- [ ] **步骤 2:验证** + +运行:`pnpm typecheck` +预期:通过。 + +- [ ] **步骤 3:Commit** + +```bash +git add src/services/ui/store.ts +git commit -m "feat(ui): viewportEngine session state (three.js default)" +``` + +--- + +### 任务 6:`BabylonViewport` 组件 + +**文件:** + +- 创建:`src/ui/viewport/BabylonViewport.tsx` +- 测试:`src/ui/viewport/BabylonViewport.test.tsx` + +镜像 ThreeViewport 的 mount-effect 约定(dep 只有 project id;编辑节点绝不重建 canvas)。种场景 = `diffSceneNodes(EMPTY_SCENE_GRAPH, scene)`(DRY,天然父先子)。单节点 sync 失败 warn + 跳过(错误隔离:demo 项目的 grid helper 在 Babylon 没有专门 builder,绝不能拉黑整个视口)。无拾取/gizmo/play/drop/focus(B2–B4)。 + +- [ ] **步骤 1:编写失败的测试** + +`src/ui/viewport/BabylonViewport.test.tsx`: + +```tsx +import { NullEngine } from "@babylonjs/core"; +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BabylonRenderHost } from "@/runtime/babylon/render-host"; +import { createDemoProject } from "@/services/scene/demo-project"; +import { useSceneStore } from "@/services/scene/store"; + +import { BabylonViewport } from "./BabylonViewport"; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +describe("BabylonViewport", () => { + let host: BabylonRenderHost | null = null; + const createHost = () => { + host = new BabylonRenderHost({ createEngine: () => new NullEngine() }); + return host; + }; + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + host = null; + useSceneStore.getState().setProject(createDemoProject()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + useSceneStore.getState().setProject(null); + }); + + function firstMeshId(): string { + const project = useSceneStore.getState().project; + const mesh = Object.values(project!.scene.nodes).find((n) => n.type === "mesh"); + if (!mesh) throw new Error("demo project has no mesh"); + return mesh.id; + } + + it("mounts, seeds the scene into the adapter, and unmounts cleanly", () => { + const { unmount } = render(); + expect(host).not.toBeNull(); + // Demo cube landed in the Babylon scene (helper nodes may warn-skip). + expect(host!.adapter.describeNode(firstMeshId())).not.toBeNull(); + unmount(); + // dispose() ran — adapter accessor throws after teardown. + expect(() => host!.adapter).toThrow(); + }); + + it("store mutations sync incrementally into the adapter", () => { + render(); + const meshId = firstMeshId(); + useSceneStore.getState().setNodeTransform(meshId, { + position: [5, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }); + expect(host!.adapter.describeNode(meshId)?.position).toEqual([5, 0, 0]); + }); + + it("a node the adapter cannot build is skipped without killing the viewport", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + // Demo project contains a grid helper — unsupported kinds must warn-skip, + // and the mesh must still be present. + expect(host!.adapter.describeNode(firstMeshId())).not.toBeNull(); + warn.mockRestore(); + }); +}); +``` + +注意:若 `createDemoProject` 种入时 helper 实际不抛(Babylon 端静默处理),第 3 个用例对 warn 不做次数断言——它锚定的是"mesh 仍在、render 不抛"。 + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/ui/viewport/BabylonViewport.test.tsx` +预期:FAIL — module not found。 + +- [ ] **步骤 3:实现** + +`src/ui/viewport/BabylonViewport.tsx`: + +```tsx +import { useEffect, useRef } from "react"; + +import { BabylonRenderHost } from "@/runtime/babylon/render-host"; +import type { SceneNode } from "@/core/scene/types"; +import type { SyncOp } from "@/runtime/adapter"; +import { useSceneStore } from "@/services/scene/store"; + +import { diffSceneNodes, EMPTY_SCENE_GRAPH, type SceneDiff } from "./scene-diff"; + +/** + * Babylon.js viewport (v1.0 B1 — view + orbit camera only). Mirrors + * ThreeViewport's lifecycle conventions: + * - Mount effect re-runs only when the active project id changes; node + * edits flow through a useSceneStore subscription → diffSceneNodes → + * adapter.syncNode, so the canvas / camera state survive edits. + * - No picking / gizmo / play / drop / focus — those are B2–B4; the + * surrounding UI disables itself via isEngineEditingCapable. + * Per-node sync failures warn + skip (one unbuildable node — e.g. a helper, + * which has no Babylon builder yet — must not kill the whole viewport). + */ +export function BabylonViewport({ + createHost, +}: { + /** Test seam — defaults to a real-Engine host. */ + createHost?: () => BabylonRenderHost; +}) { + const containerRef = useRef(null); + const projectId = useSceneStore((s) => s.project?.metadata.id ?? null); + + useEffect(() => { + if (!projectId) return; + const container = containerRef.current; + if (!container) return; + + const canvas = document.createElement("canvas"); + canvas.style.display = "block"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + container.appendChild(canvas); + + const host = createHost ? createHost() : new BabylonRenderHost(); + host.mount(canvas); + const adapter = host.adapter; + + const trySync = (node: SceneNode, op: SyncOp) => { + try { + adapter.syncNode(node, op); + } catch (e) { + console.warn( + `BabylonViewport: syncNode ${op} failed for ${node.id} — skipped`, + e, + ); + } + }; + const applyDiff = (diff: SceneDiff) => { + for (const n of diff.added) trySync(n, "add"); + for (const n of diff.updated) trySync(n, "update"); + for (const n of diff.removed) trySync(n, "remove"); + }; + + const initial = useSceneStore.getState().project; + if (initial) applyDiff(diffSceneNodes(EMPTY_SCENE_GRAPH, initial.scene)); + + host.start(); + + const unsubscribe = useSceneStore.subscribe((state, prev) => { + const next = state.project; + const old = prev.project; + if (!next || !old) return; + if (next === old) return; + if (next.metadata.id !== old.metadata.id) return; // handled by effect re-run + applyDiff(diffSceneNodes(old.scene, next.scene)); + }); + + const resize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + host.resize(w, h); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(container); + + return () => { + unsubscribe(); + ro.disconnect(); + host.dispose(); + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [projectId, createHost]); + + return
; +} +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/ui/viewport/BabylonViewport.test.tsx` +预期:PASS(3 用例;console.warn 噪音可接受)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/BabylonViewport.tsx src/ui/viewport/BabylonViewport.test.tsx +git commit -m "feat(viewport): BabylonViewport — seed + incremental sync + resize, view-only (B1)" +``` + +--- + +### 任务 7:顶层 `Viewport` 切换 + `EngineToggle` + EditorView 接线 + i18n + +**文件:** + +- 创建:`src/ui/viewport/Viewport.tsx`、`src/ui/viewport/Viewport.test.tsx` +- 创建:`src/ui/viewport/EngineToggle.tsx`、`src/ui/viewport/EngineToggle.test.tsx` +- 修改:`src/ui/views/EditorView.tsx`(line 22 import、line 112 挂载点、line 113-117 gizmo pill disabled) +- 修改:`src/i18n/locales/en-US/editor.json`、`src/i18n/locales/zh-CN/editor.json` + +- [ ] **步骤 1:i18n key(先加,组件测试经 `src/test/setup.ts` 读真实串)** + +`en-US/editor.json` 的 `"viewport"` 对象追加 `engine` 子对象(保留现有 key): + +```json + "viewport": { + "empty": "Empty scene", + "empty_hint": "Drop a .glb file here or pick a template from the menu.", + "engine": { + "title": "Render engine", + "three": "Three", + "babylon": "Babylon" + } + }, +``` + +`"play"` 对象追加: + +```json + "engine_unavailable": "Play is not available in the Babylon preview yet" +``` + +`zh-CN/editor.json` 对应位置追加(其余 key 原样保留): + +```json + "engine": { + "title": "渲染引擎", + "three": "Three", + "babylon": "Babylon" + } +``` + +和 play 下: + +```json + "engine_unavailable": "Babylon 预览暂不支持播放" +``` + +- [ ] **步骤 2:编写失败的测试(两个组件)** + +`src/ui/viewport/Viewport.test.tsx`: + +```tsx +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { useUIStore } from "@/services/ui/store"; + +import { Viewport } from "./Viewport"; + +vi.mock("./ThreeViewport", () => ({ + ThreeViewport: () =>
, +})); +vi.mock("./BabylonViewport", () => ({ + BabylonViewport: () =>
, +})); + +describe("Viewport", () => { + beforeEach(() => useUIStore.setState({ viewportEngine: "three.js" })); + + it("renders ThreeViewport by default", () => { + render(); + expect(screen.getByTestId("three-viewport")).toBeInTheDocument(); + expect(screen.queryByTestId("babylon-viewport")).toBeNull(); + }); + + it("renders BabylonViewport when viewportEngine is babylon.js", () => { + useUIStore.setState({ viewportEngine: "babylon.js" }); + render(); + expect(screen.getByTestId("babylon-viewport")).toBeInTheDocument(); + expect(screen.queryByTestId("three-viewport")).toBeNull(); + }); +}); +``` + +`src/ui/viewport/EngineToggle.test.tsx`: + +```tsx +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useUIStore } from "@/services/ui/store"; + +import { EngineToggle } from "./EngineToggle"; + +describe("EngineToggle", () => { + beforeEach(() => + useUIStore.setState({ viewportEngine: "three.js", playState: "edit" }), + ); + + it("switches the engine on click", () => { + render(); + fireEvent.click(screen.getByText("Babylon")); + expect(useUIStore.getState().viewportEngine).toBe("babylon.js"); + }); + + it("forces play state back to edit when switching", () => { + useUIStore.setState({ playState: "play" }); + render(); + fireEvent.click(screen.getByText("Babylon")); + expect(useUIStore.getState().playState).toBe("edit"); + }); + + it("clicking the active engine is a no-op", () => { + useUIStore.setState({ playState: "play" }); + render(); + fireEvent.click(screen.getByText("Three")); + expect(useUIStore.getState().viewportEngine).toBe("three.js"); + expect(useUIStore.getState().playState).toBe("play"); + }); +}); +``` + +- [ ] **步骤 3:运行测试验证失败** + +运行:`pnpm vitest run src/ui/viewport/Viewport.test.tsx src/ui/viewport/EngineToggle.test.tsx` +预期:FAIL — module not found。 + +- [ ] **步骤 4:实现两个组件** + +`src/ui/viewport/Viewport.tsx`: + +```tsx +import { useUIStore } from "@/services/ui/store"; + +import { BabylonViewport } from "./BabylonViewport"; +import { ThreeViewport } from "./ThreeViewport"; + +/** + * Engine-switching viewport wrapper (v1.0 B1). Each child owns its full + * mount/teardown lifecycle; switching engines swaps the component type, so + * React unmounts one and mounts the other — the viewports never know about + * each other. Camera pose is intentionally not preserved across a switch. + */ +export function Viewport() { + const engine = useUIStore((s) => s.viewportEngine); + if (engine === "babylon.js") return ; + return ; +} +``` + +`src/ui/viewport/EngineToggle.tsx`(pill 样式抄 `EditorView.tsx:416` 的 `GizmoModeToolbar`,定位改 `left-3`): + +```tsx +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; +import type { ViewportEngine } from "@/runtime/render-host"; +import { useUIStore } from "@/services/ui/store"; + +const ENGINES: { engine: ViewportEngine; labelKey: string }[] = [ + { engine: "three.js", labelKey: "viewport.engine.three" }, + { engine: "babylon.js", labelKey: "viewport.engine.babylon" }, +]; + +/** + * Three/Babylon viewport engine switch (v1.0 B1). Switching forces playState + * back to "edit" first: the Three viewport tears down play mode on unmount, + * but the store flag would otherwise stay "play" with nothing ticking — + * and remounting ThreeViewport while playState === "play" would show a + * paused-looking play mode with no behaviors installed. + */ +export function EngineToggle() { + const { t } = useTranslation("editor"); + const engine = useUIStore((s) => s.viewportEngine); + const setViewportEngine = useUIStore((s) => s.setViewportEngine); + const setPlayState = useUIStore((s) => s.setPlayState); + + return ( +
+ {ENGINES.map((entry) => { + const active = entry.engine === engine; + return ( + + ); + })} +
+ ); +} +``` + +- [ ] **步骤 5:运行测试验证通过** + +运行:`pnpm vitest run src/ui/viewport/Viewport.test.tsx src/ui/viewport/EngineToggle.test.tsx` +预期:PASS(5 用例)。 + +- [ ] **步骤 6:接线 EditorView** + +`src/ui/views/EditorView.tsx`: + +line 22 的 import 替换 + 新增: + +```tsx +import { isEngineEditingCapable } from "@/runtime/render-host"; +import { EngineToggle } from "@/ui/viewport/EngineToggle"; +import { Viewport } from "@/ui/viewport/Viewport"; +``` + +(删掉 `import { ThreeViewport } ...` —— EditorView 不再直接引用它。) + +selector 区(line 47 `playState` 后)加: + +```tsx +const viewportEngine = useUIStore((s) => s.viewportEngine); +``` + +line 110-121 的视口区块改为: + +```tsx +<> + + + +
+ +
+ +``` + +- [ ] **步骤 7:验证 + Commit** + +运行:`pnpm typecheck && pnpm vitest run src/ui` +预期:全绿(EditorView 无既有测试文件、仓库内无人 mock ThreeViewport,已核实——失败即真问题)。 + +```bash +git add src/ui/viewport/Viewport.tsx src/ui/viewport/Viewport.test.tsx \ + src/ui/viewport/EngineToggle.tsx src/ui/viewport/EngineToggle.test.tsx \ + src/ui/views/EditorView.tsx \ + src/i18n/locales/en-US/editor.json src/i18n/locales/zh-CN/editor.json +git commit -m "feat(viewport): engine switch — Viewport wrapper + EngineToggle pill + gizmo gate" +``` + +--- + +### 任务 8:Babylon 模式禁用面 — PlayButton / Space / F / 资源拖拽 + +**文件:** + +- 修改:`src/ui/viewport/PlayButton.tsx`、`src/ui/viewport/PlayButton.test.tsx` +- 修改:`src/ui/viewport/use-editor-shortcuts.ts`、`src/ui/viewport/use-editor-shortcuts.test.tsx` +- 修改:`src/services/library/asset-drag.ts`、`src/services/library/asset-drag.test.ts` + +所有判断统一走 `isEngineEditingCapable`(任务 3),不散落 `engine === "..."`。 + +- [ ] **步骤 1:编写失败的测试** + +`PlayButton.test.tsx`:`beforeEach` 改为 `useUIStore.setState({ playState: "edit", viewportEngine: "three.js" })`,追加: + +```tsx +it("is disabled in the Babylon viewport (B1 — play lands in B4)", () => { + useUIStore.setState({ viewportEngine: "babylon.js" }); + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(useUIStore.getState().playState).toBe("edit"); +}); +``` + +`use-editor-shortcuts.test.tsx`:在既有 describe 内追加(套用该文件现有的 keydown 派发 helper;`beforeEach` 里补 `viewportEngine: "three.js"` 重置): + +```tsx +describe("babylon viewport (B1 view-only)", () => { + beforeEach(() => useUIStore.setState({ viewportEngine: "babylon.js" })); + + it("Space does not toggle play", () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: " " })); + expect(useUIStore.getState().playState).toBe("edit"); + }); + + it("F does not request camera focus", () => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "f" })); + expect(useUIStore.getState().pendingFocusNodeId).toBeUndefined(); + }); +}); +``` + +(若该文件用自定义 `press(key)` helper,改用之;断言不变。) + +`asset-drag.test.ts`:`beforeEach` 改为 `useUIStore.setState({ assetDragItemId: null, viewportEngine: "three.js" })`,追加: + +```ts +it("does not start a drag while the Babylon viewport is active (B1 — drop lands in B4)", () => { + useUIStore.setState({ viewportEngine: "babylon.js" }); + beginAssetDrag("geo-box", 100, 100); + move(110, 100); // would activate in the three viewport + expect(useUIStore.getState().assetDragItemId).toBeNull(); + up(); +}); +``` + +- [ ] **步骤 2:运行测试验证失败** + +运行:`pnpm vitest run src/ui/viewport/PlayButton.test.tsx src/ui/viewport/use-editor-shortcuts.test.tsx src/services/library/asset-drag.test.ts` +预期:新增用例 FAIL,既有用例 PASS。 + +- [ ] **步骤 3:实现三处门** + +`PlayButton.tsx` 整体替换为: + +```tsx +import { useTranslation } from "react-i18next"; + +import { isEngineEditingCapable } from "@/runtime/render-host"; +import { useUIStore } from "@/services/ui/store"; + +export function PlayButton() { + const { t } = useTranslation("editor"); + const playState = useUIStore((s) => s.playState); + const setPlayState = useUIStore((s) => s.setPlayState); + const viewportEngine = useUIStore((s) => s.viewportEngine); + const isPlay = playState === "play"; + const editingCapable = isEngineEditingCapable(viewportEngine); + + return ( + + ); +} +``` + +`use-editor-shortcuts.ts`:顶部加 `import { isEngineEditingCapable } from "@/runtime/render-host";`。 + +F 分支(line 109-114)改为: + +```ts +// ── F (focus camera) ─────────────────────────────────────── +if (key.toLowerCase() === "f" && !isMod) { + const ui = useUIStore.getState(); + // Focus is viewport-owned and only the Three viewport implements it + // (B1); a request queued in Babylon mode would fire stale on the next + // ThreeViewport remount. + if (!isEngineEditingCapable(ui.viewportEngine)) return; + ui.requestFocus(ui.selectedNodeId); + e.preventDefault(); + return; +} +``` + +Space 分支(line 117-123)改为: + +```ts +// ── Space (Play/Pause toggle) ────────────────────────────── +if (key === " " && !isMod) { + if (isOnButton(e)) return; + const ui = useUIStore.getState(); + if (!isEngineEditingCapable(ui.viewportEngine)) return; // B1: babylon is view-only + ui.setPlayState(ui.playState === "play" ? "edit" : "play"); + e.preventDefault(); + return; +} +``` + +`asset-drag.ts`:顶部加 `import { isEngineEditingCapable } from "@/runtime/render-host";`,`beginAssetDrag` 函数体首行(`teardown()` 之前)加: + +```ts +// B1: the Babylon viewport has no drop handler (raycastGroundPoint lands in +// B4) — without a window-pointerup consumer the drag flag would leak. +if (!isEngineEditingCapable(useUIStore.getState().viewportEngine)) return; +``` + +- [ ] **步骤 4:运行测试验证通过** + +运行:`pnpm vitest run src/ui/viewport/PlayButton.test.tsx src/ui/viewport/use-editor-shortcuts.test.tsx src/services/library/asset-drag.test.ts` +预期:PASS(含全部既有用例)。 + +- [ ] **步骤 5:Commit** + +```bash +git add src/ui/viewport/PlayButton.tsx src/ui/viewport/PlayButton.test.tsx \ + src/ui/viewport/use-editor-shortcuts.ts src/ui/viewport/use-editor-shortcuts.test.tsx \ + src/services/library/asset-drag.ts src/services/library/asset-drag.test.ts +git commit -m "feat(viewport): gate play/focus/asset-drag behind isEngineEditingCapable (B1)" +``` + +--- + +### 任务 9:全量验证 + 视觉 smoke + +- [ ] **步骤 1:全量门禁** + +运行:`pnpm lint && pnpm typecheck && pnpm test` +预期:三者全绿(test 含 conformance 双引擎 + 全部新增用例)。任何红都先修再继续——不许带病进 smoke。 + +- [ ] **步骤 2:视觉 smoke(必跑——vitest 绿 ≠ 功能正确)** + +运行:`pnpm tauri dev`,打开 demo/starter 项目,逐项核对: + +1. 视口左上出现 Three/Babylon pill,默认 Three,既有编辑功能(gizmo/拾取/拖放/play)无任何回归。 +2. 切到 Babylon:同一场景渲染出来(立方体在原位、构图与 Three 大致一致、**没有左右镜像**——右手系生效的证据)、背景色一致(#101418)。 +3. Babylon 模式拖动鼠标可轨道转相机、滚轮缩放;拉伸窗口视口跟随 resize 不变形。 +4. Babylon 模式下:gizmo pill 与 play 按钮呈禁用态(play hover 有 tooltip);点击视口不改变选中;F / Space 无效果;从资源库拖卡片不出 ghost。 +5. Babylon 模式下从 hierarchy 选中节点、在属性面板改 position / 材质 baseColor → Babylon 视口即时更新。 +6. play 状态下切引擎 → 自动退回 edit;切回 Three 一切正常(再 play 一次行为正常 tick)。 +7. 来回切换 5+ 次无报错、无明显内存暴涨(devtools console 干净,warn 仅允许 helper/prefab skip 类)。 + +发现问题回到对应任务修复(同一分支追加 commit),重跑步骤 1。 + +- [ ] **步骤 3:收尾** + +视觉 smoke 全过后,使用 superpowers:finishing-a-development-branch 技能走分支收尾(roadmap 勾选 + PR;PR 描述按 `.github/PULL_REQUEST_TEMPLATE.md` What/Why/How-to-test,How-to-test 直接引用上面 smoke 清单)。 + +--- + +## 偏差记录约定 + +实现中任何与本计划/spec 的偏差(API 不存在、行为不符、测试发现设计漏洞),在对应任务下追加一行 `> ⚠ 偏差:...` 并同步到 spec 的偏差注记,merge 时进 memory。 diff --git a/docs/superpowers/specs/2026-06-10-v1.0b1-render-host-design.md b/docs/superpowers/specs/2026-06-10-v1.0b1-render-host-design.md new file mode 100644 index 0000000..23bf5bf --- /dev/null +++ b/docs/superpowers/specs/2026-06-10-v1.0b1-render-host-design.md @@ -0,0 +1,172 @@ +# v1.0 sub-stage B1 — Render Host:实时视口切引擎(看 + 转相机)设计 + +日期:2026-06-10 +分支:`feat/v1.0b-render-host` +前置:v1.0 A1(多适配器契约 + Babylon headless + conformance,PR #37)、A2(Babylon behaviors 跨引擎运行时,PR #38) + +--- + +## 1. 背景与目标 + +lowcode-3d 是多框架低代码 3D 平台,渲染引擎可切换是战略目标。A1/A2 已让 `BabylonAdapter` 在 headless(`NullEngine`)下通过与 `ThreeAdapter` 相同的 conformance 套件;但用户在编辑器里仍然只能看到 Three.js 渲染。 + +**B1 目标**:编辑器视口可以在 Three.js 与 Babylon.js 之间实时切换。Babylon 模式为最小可用——**只能"看 + 转相机"**:场景实时同步渲染 + 轨道相机交互,无视口内编辑交互。 + +### B 阶段整体拆分(已与用户确认) + +- **B1(本期)**:`IRenderHost` 接口 + Babylon 真 `Engine` + `ArcRotateCamera` + 引擎切换开关。 +- **B2**:拾取 + 选中描边跨引擎。 +- **B3**:gizmo + snap 跨引擎。 +- **B4**:socket 标记 / 资源拖放 / focus / play 行为预览等剩余视口能力。 + +### 落地策略(已与用户确认):「并行 + 定接口」 + +定义 `IRenderHost` + `BabylonRenderHost` + 新 `BabylonViewport` 组件 + 顶层 `Viewport` 按引擎切换。**`ThreeViewport.tsx`(795 行单 mount effect)本期不动**——不破坏可用的 Three 编辑器;B2/B3 按 A1 spec §8 的边界清单逐步把 Three 侧收敛到 `IRenderHost` 后面。已否决的备选「现在就抽 ThreeRenderHost」:动 fragile 大文件、回归风险陡增、且在只有一个 host 消费方时抽象容易抽错。 + +--- + +## 2. 范围 + +### In + +- `IRenderHost` 接口(B1 子集:mount / 渲染循环 / 相机 / resize / dispose)。 +- `BabylonAdapter` 构造支持注入引擎(默认仍 `NullEngine`)。 +- `BabylonRenderHost`(真 `Engine` + `ArcRotateCamera`)。 +- `BabylonViewport` 组件(场景种入 + 增量同步 + resize + 清理)。 +- 引擎中立的场景 diff 纯函数 `scene-diff.ts`。 +- 顶层 `Viewport` 切换组件 + 视口工具栏 Three/Babylon 开关(UI store 会话态)。 +- Babylon 模式下不支持的交互入口禁用(见 §7)。 + +### Out(延后) + +- 拾取 / 选中描边(B2)、gizmo / snap(B3)、socket 标记 / 拖放 / focus / play(B4)。 +- ThreeViewport 任何改动(B2/B3 收敛)。 +- `generateBehaviorCode` / Babylon 导出(A3)。 +- camera 朝向、ambient→Hemispheric 等已记录的跨引擎 gap(roadmap Backlog)。 + +--- + +## 3. `IRenderHost` 接口 + +新文件 `src/runtime/render-host.ts`,引擎中立: + +```ts +import type { RuntimeTarget } from "@/core/scene/types"; + +export interface IRenderHost { + /** Which engine this host renders with. */ + readonly engine: RuntimeTarget["kind"]; + /** Create the real engine + editor camera + camera controls on the canvas. */ + mount(canvas: HTMLCanvasElement): void; + /** Start / stop the render loop. */ + start(): void; + stop(): void; + /** Resize the drawing buffer + camera aspect. */ + resize(width: number, height: number): void; + /** Tear down controls, engine, GPU resources. */ + dispose(): void; +} +``` + +- 只覆盖 B1 能力。B2 扩 `pick` / 选中高亮,B3 扩 gizmo——届时按 A1 spec §8 清单收敛 ThreeViewport。 +- **Three 侧本期不实现该接口**;B1 唯一实现是 `BabylonRenderHost`。接口形状以 §8 清单 + ThreeViewport 现状反推,避免单实现臆造抽象。 +- 调用顺序约定:`mount` → `start` →(任意次 `resize` / `stop` / `start`)→ `dispose`。`dispose` 后实例不可复用。 + +## 4. `BabylonAdapter` 构造注入引擎 + +```ts +constructor(options?: { engine?: AbstractEngine }) +``` + +- 缺省 `new NullEngine()`——conformance / headless 测试零改动(硬约束)。 +- `AbstractEngine` 是 `Engine` 与 `NullEngine` 的公共基类(`@babylonjs/core` 9.11 已导出,已核实)。 +- 所有权约定:为保持 `dispose()` 语义简单,**adapter 始终 dispose 自己持有的 engine(无论注入与否)**。`BabylonRenderHost.dispose` 顺序:停 loop → detach 相机 → `adapter.dispose()`(内含 `engine.dispose()`),host 不再二次 dispose engine。 + +## 5. `BabylonRenderHost` + `BabylonViewport` + `scene-diff` + +### 5.1 `src/runtime/babylon/render-host.ts` + +- `mount(canvas)`:`new Engine(canvas, true)` → `new BabylonAdapter({ engine })` → `new ArcRotateCamera(...)` 设为 `scene.activeCamera` 并 `attachControl(canvas)`(轨道交互,对位 Three 侧 OrbitControls)。 +- 相机初始参数对齐 ThreeViewport 编辑器相机的观感(位置 / 目标 / fov 在 plan 阶段从 ThreeViewport 读取换算,目标:切换引擎时构图大致一致,不要求逐像素)。 +- `start()` = `engine.runRenderLoop(() => scene.render())`;`stop()` = `engine.stopRenderLoop()`。 +- `resize(w, h)` = `engine.resize()`(Babylon 从 canvas 尺寸自取,参数用于契约对称)。 +- 测试 seam:构造可注入 engine 工厂 `new BabylonRenderHost({ createEngine?: (canvas) => AbstractEngine })`,测试传 `NullEngine`(jsdom 无 WebGL)。 +- host 暴露 `adapter`(`BabylonAdapter`)给 viewport 做 `syncNode`。 + +### 5.2 `src/ui/viewport/scene-diff.ts`(引擎中立纯函数) + +ThreeViewport 的 `diffAndApply` 耦合 gizmo / outlinePass,不复用。抽纯函数: + +```ts +diffSceneNodes(old: Record, next: Record): + { added: SceneNode[]; removed: SceneNode[]; updated: SceneNode[] } +``` + +- 判定沿用 ThreeViewport 现行节点同一性语义(id 存在性 + 节点对象引用/内容变化;plan 阶段对照 `diffAndApply` 现实现,保证两个视口对同一 store 变更得出一致结论)。 +- ThreeViewport 本期不迁移到该函数;B2 收敛时迁移。 + +### 5.3 `src/ui/viewport/BabylonViewport.tsx` + +镜像 ThreeViewport 的 mount-effect 约定: + +- mount effect **dep 只有 `project?.metadata.id`**(沿用 PR #7 视口同步约定:编辑节点绝不重建 canvas,相机状态存活)。 +- 流程:建 canvas(`display:block; width/height:100%`,沿用布局约定)→ `host.mount(canvas)` → 种场景(按 hierarchy 顺序遍历 nodes,`adapter.syncNode(node, "add")`)→ `host.start()` → 订阅 `useSceneStore`,用 `diffSceneNodes` 增量 `syncNode("add"/"update"/"remove")` → `ResizeObserver` 调 `host.resize` → cleanup:退订 → `host.stop()` → `host.dispose()`。 +- 节点种入失败(如 `prefab_instance` 在 Babylon 报 unknown、自定义 kind 抛错):`console.warn` + 跳过该节点继续(镜像 behaviors 的错误隔离哲学——一个节点坏不拉黑整个视口)。已知 gap 不在 B1 修。 + +## 6. 顶层切换 + +- `useUIStore` 加会话态(**用户已选方案 A**,不持久化、不进项目数据): + +```ts +viewportEngine: "three.js" | "babylon.js"; // default "three.js" +setViewportEngine(engine): void; +``` + +- 新 `src/ui/viewport/Viewport.tsx`:按 `viewportEngine` 渲染 `` 或 ``,**带 `key={engine}`** 强制卸载重挂——两个视口各自的 mount effect 自洁,互不知晓。`EditorView` 改挂 ``(原 `` 处)。 +- 开关 UI:视口工具栏(gizmo pill 旁)一个 Three/Babylon 分段 toggle。i18n 走现有 editor namespace(UI 字符串英文,沿用项目惯例)。 +- 切换即丢当前视口相机姿态(卸载重挂),B1 接受;跨引擎相机姿态保持延后。 + +## 7. Babylon 模式功能边界(B1 = 看 + 转相机) + +**可用**: + +- 场景实时同步:hierarchy / 属性面板 / AI 命令对节点的修改照常走 store → `diffSceneNodes` → `syncNode`,Babylon 视口即时可见。 +- 轨道相机(ArcRotate)、视口 resize、引擎来回切换。 + +**禁用(disabled 态 + 不响应,不隐藏)**: + +- gizmo 模式 pill(B3)。 +- play 按钮(B4 接 behaviors 预览;tooltip 说明 Babylon 模式暂不支持)。 +- 视口点选 / 拾取(B2)——hierarchy 选中仍可用,但视口无描边反馈。 +- 资源库拖放入视口(依赖 `raycastGroundPoint`,B4)。 +- socket 标记 / F focus(focus 是 viewport-owned,Babylon 视口 B1 不实现,快捷键在 Babylon 模式 no-op)。 + +实现方式:上述 UI 读 `viewportEngine` 判 disabled,集中一个 helper(如 `isEngineEditingCapable(engine)`)避免散落布尔判断,B2/B3/B4 逐项放开。 + +## 8. 测试策略 + +- `scene-diff.test.ts`:added / removed / updated / 无变化 / 混合变更。 +- `babylon/adapter.test.ts` 追加:缺省构造仍 headless(NullEngine);注入 engine 被采用且 `dispose()` 释放之。 +- `babylon/render-host.test.ts`:注入 `NullEngine` 工厂走 mount → start → resize → stop → dispose 生命周期不抛、dispose 后引擎已释放。 +- `BabylonViewport.test.tsx`:注入 NullEngine seam 下 mount / 卸载不抛、store 变更触发 `syncNode`(spy)。 +- conformance 套件零改动全绿(NullEngine 缺省保证)。 +- **视觉验收(必跑,vitest 绿 ≠ 功能正确)**:`pnpm tauri dev` — 切到 Babylon 看到同一场景;转相机;面板改 transform/材质色即时同步;切回 Three 编辑功能无回归;play/gizmo/拖放在 Babylon 模式呈禁用态。 + +## 9. 成功标准 + +1. 视口工具栏可在 Three/Babylon 间来回切换,Babylon 模式正确渲染当前场景并可轨道转相机。 +2. Babylon 模式下面板编辑实时反映到视口。 +3. Three 模式所有既有功能零回归(ThreeViewport 未动)。 +4. conformance / 既有测试全绿 + 新增单测绿;`pnpm lint && pnpm typecheck && pnpm test` 全绿。 +5. 视觉 smoke(§8)通过。 + +## 9.1 实现偏差注记 + +- ⚠ **偏差(2026-06-11,真机 smoke 反馈修复)**:「ThreeViewport 本期不动」被一处 3 行的根因修复打破——`ThreeViewport` 挂载时的初始 `syncSelection` 与 async `seedScene` 存在竞态(挂载时 adapter 还没有对象,初始同步落空;重选同一节点不触发 store 订阅)。pre-B1 潜伏 bug:以前 ThreeViewport 只在选中为 null 时重挂,B1 引擎切换是第一条「带活选中重挂」的路径。修复 = `seedScene` 完成后重放 `syncSelection` + `rebuildSocketMarkers`(带 `unmounted` 守卫)。 + +## 10. 后续路径 + +- **B2**:`IRenderHost` 扩拾取 + 选中高亮;ThreeViewport 开始向 host 收敛(迁 `scene-diff`)。 +- **B3**:gizmo + snap 跨引擎。 +- **B4**:socket 标记 / 拖放 / focus / play 行为预览。 +- Backlog 不变:Babylon camera 朝向 gap、ambient→Hemispheric position gap、prefab_instance kind gap(A3)、有状态 behavior 的 `dispose` 遍历。 diff --git a/src/i18n/locales/en-US/editor.json b/src/i18n/locales/en-US/editor.json index 40232ae..ee891f3 100644 --- a/src/i18n/locales/en-US/editor.json +++ b/src/i18n/locales/en-US/editor.json @@ -21,7 +21,12 @@ }, "viewport": { "empty": "Empty scene", - "empty_hint": "Drop a .glb file here or pick a template from the menu." + "empty_hint": "Drop a .glb file here or pick a template from the menu.", + "engine": { + "title": "Render engine", + "three": "Three", + "babylon": "Babylon" + } }, "behaviors": { "tab_title": "Behaviors", @@ -46,7 +51,8 @@ }, "play": { "play": "Play", - "pause": "Pause" + "pause": "Pause", + "engine_unavailable": "Play is not available in the Babylon preview yet" }, "close_project": "Close project", "help": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index ee30ba6..553bb9d 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -21,7 +21,12 @@ }, "viewport": { "empty": "空场景", - "empty_hint": "拖入 .glb 文件,或从菜单选一个模板。" + "empty_hint": "拖入 .glb 文件,或从菜单选一个模板。", + "engine": { + "title": "渲染引擎", + "three": "Three", + "babylon": "Babylon" + } }, "behaviors": { "tab_title": "行为", @@ -46,7 +51,8 @@ }, "play": { "play": "播放", - "pause": "暂停" + "pause": "暂停", + "engine_unavailable": "Babylon 预览暂不支持播放" }, "close_project": "关闭项目", "help": { diff --git a/src/runtime/babylon/adapter.test.ts b/src/runtime/babylon/adapter.test.ts index 7c34a04..6392f16 100644 --- a/src/runtime/babylon/adapter.test.ts +++ b/src/runtime/babylon/adapter.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { NullEngine } from "@babylonjs/core"; import type { SceneNode } from "@/core/scene/types"; @@ -200,3 +201,25 @@ describe("BabylonAdapter behaviors", () => { a.dispose(); }); }); + +describe("engine injection (v1.0 B1)", () => { + it("uses the injected engine and disposes it with the adapter", () => { + const engine = new NullEngine(); + const adapter = new BabylonAdapter({ engine }); + expect(adapter.scene.getEngine()).toBe(engine); + adapter.dispose(); + expect(engine.isDisposed).toBe(true); + }); + + it("defaults to a NullEngine when nothing is injected", () => { + const adapter = new BabylonAdapter(); + expect(adapter.scene.getEngine()).toBeInstanceOf(NullEngine); + adapter.dispose(); + }); + + it("scene uses the right-handed system (matches three.js / glTF transforms)", () => { + const adapter = new BabylonAdapter(); + expect(adapter.scene.useRightHandedSystem).toBe(true); + adapter.dispose(); + }); +}); diff --git a/src/runtime/babylon/adapter.ts b/src/runtime/babylon/adapter.ts index d8493f0..d762ee1 100644 --- a/src/runtime/babylon/adapter.ts +++ b/src/runtime/babylon/adapter.ts @@ -11,6 +11,7 @@ import { TransformNode, UniversalCamera, Vector3, + type AbstractEngine, type Node as BabylonNode, } from "@babylonjs/core"; @@ -61,8 +62,10 @@ function notImplemented(method: string): never { */ export class BabylonAdapter implements IRuntimeAdapter { readonly target = BABYLON_TARGET; - private readonly engine = new NullEngine(); - private readonly scene = new Scene(this.engine); + /** Engine-specific escape hatch (mirrors ThreeAdapter.scene) — used by + * BabylonRenderHost to mount the editor camera. Not on IRuntimeAdapter. */ + readonly scene: Scene; + private readonly engine: AbstractEngine; private readonly objects = new Map(); private readonly behaviorRegistry: BabylonBehaviorRegistry = createBabylonBehaviorRegistry(); @@ -74,6 +77,16 @@ export class BabylonAdapter implements IRuntimeAdapter { > >(); + constructor(options?: { engine?: AbstractEngine }) { + this.engine = options?.engine ?? new NullEngine(); + this.scene = new Scene(this.engine); + // Project transforms are right-handed (three.js was the first engine and + // glTF is RH); Babylon defaults to left-handed, which would mirror the + // rendered scene on z. Raw stored values are unaffected, so headless + // conformance/describeNode behavior does not change. + this.scene.useRightHandedSystem = true; + } + syncNode(node: SceneNode, op: SyncOp): void { if (op === "remove") { const existing = this.objects.get(node.id); diff --git a/src/runtime/babylon/render-host.test.ts b/src/runtime/babylon/render-host.test.ts new file mode 100644 index 0000000..42ef1c6 --- /dev/null +++ b/src/runtime/babylon/render-host.test.ts @@ -0,0 +1,50 @@ +import { ArcRotateCamera, NullEngine } from "@babylonjs/core"; +import { describe, expect, it } from "vitest"; + +import { BabylonRenderHost } from "./render-host"; + +function makeHost() { + const engine = new NullEngine(); + const host = new BabylonRenderHost({ createEngine: () => engine }); + return { host, engine }; +} + +describe("BabylonRenderHost", () => { + it("identifies as the babylon.js engine", () => { + expect(makeHost().host.engine).toBe("babylon.js"); + }); + + it("adapter throws before mount", () => { + expect(() => makeHost().host.adapter).toThrow(/mount/); + }); + + it("mount creates the adapter and an ArcRotate editor camera at [4,3,4]", () => { + const { host } = makeHost(); + host.mount(document.createElement("canvas")); + const scene = host.adapter.scene; + expect(scene.activeCamera).toBeInstanceOf(ArcRotateCamera); + const cam = scene.activeCamera as ArcRotateCamera; + expect(cam.position.x).toBeCloseTo(4); + expect(cam.position.y).toBeCloseTo(3); + expect(cam.position.z).toBeCloseTo(4); + host.dispose(); + }); + + it("full lifecycle mount → start → resize → stop → dispose does not throw", () => { + const { host, engine } = makeHost(); + host.mount(document.createElement("canvas")); + host.start(); + host.resize(800, 600); + host.stop(); + host.dispose(); + expect(engine.isDisposed).toBe(true); + }); + + it("dispose releases the adapter (engine ownership lives there)", () => { + const { host, engine } = makeHost(); + host.mount(document.createElement("canvas")); + host.dispose(); + expect(engine.isDisposed).toBe(true); + expect(() => host.adapter).toThrow(); + }); +}); diff --git a/src/runtime/babylon/render-host.ts b/src/runtime/babylon/render-host.ts new file mode 100644 index 0000000..a2cb105 --- /dev/null +++ b/src/runtime/babylon/render-host.ts @@ -0,0 +1,96 @@ +import { + ArcRotateCamera, + Color4, + Engine, + Vector3, + type AbstractEngine, +} from "@babylonjs/core"; + +import type { IRenderHost } from "@/runtime/render-host"; + +import { BabylonAdapter } from "./adapter"; + +export interface BabylonRenderHostOptions { + /** Test seam — defaults to a real WebGL Engine on the mounted canvas. + * Tests inject () => new NullEngine() because jsdom has no WebGL. */ + createEngine?: (canvas: HTMLCanvasElement) => AbstractEngine; +} + +/** Matches ThreeViewport's renderer.setClearColor(0x101418). */ +const CLEAR_COLOR = new Color4(0x10 / 255, 0x14 / 255, 0x18 / 255, 1); + +/** + * Babylon render host (v1.0 B1) — owns the real Engine, the editor + * ArcRotateCamera and the render loop. The BabylonAdapter it creates owns the + * scene AND the engine disposal (spec §4 ownership rule), so dispose() here + * never calls engine.dispose() itself. + */ +export class BabylonRenderHost implements IRenderHost { + readonly engine = "babylon.js" as const; + private readonly createEngine: (canvas: HTMLCanvasElement) => AbstractEngine; + private babylonEngine: AbstractEngine | null = null; + private camera: ArcRotateCamera | null = null; + private adapterInstance: BabylonAdapter | null = null; + + constructor(options?: BabylonRenderHostOptions) { + this.createEngine = options?.createEngine ?? ((canvas) => new Engine(canvas, true)); + } + + /** Engine-specific surface (not on IRenderHost) — BabylonViewport reaches + * through it for syncNode. Throws before mount() / after dispose(). */ + get adapter(): BabylonAdapter { + if (!this.adapterInstance) { + throw new Error("BabylonRenderHost: call mount() before adapter"); + } + return this.adapterInstance; + } + + mount(canvas: HTMLCanvasElement): void { + const engine = this.createEngine(canvas); + this.babylonEngine = engine; + const adapter = new BabylonAdapter({ engine }); + this.adapterInstance = adapter; + const scene = adapter.scene; + scene.clearColor = CLEAR_COLOR; + // Editor orbit camera — framing matches the Three editor camera defaults + // (position [4,3,4] looking at the origin, vertical fov 50°; see + // ThreeAdapter defaultCamera). Alpha/beta/radius args are placeholders: + // setPosition() recomputes them from the actual position. + const camera = new ArcRotateCamera("editor-camera", 0, 1, 8, Vector3.Zero(), scene); + camera.setPosition(new Vector3(4, 3, 4)); + camera.fov = (50 * Math.PI) / 180; + camera.minZ = 0.1; + camera.maxZ = 1000; + // NullEngine has no rendering canvas — pointer controls only make sense + // on a real Engine. + if (engine.getRenderingCanvas()) camera.attachControl(); + scene.activeCamera = camera; + this.camera = camera; + } + + start(): void { + const engine = this.babylonEngine; + const scene = this.adapterInstance?.scene; + if (!engine || !scene) return; + engine.runRenderLoop(() => scene.render()); + } + + stop(): void { + this.babylonEngine?.stopRenderLoop(); + } + + resize(_width: number, _height: number): void { + // Babylon reads the canvas client size itself; params exist for the + // engine-neutral contract. + this.babylonEngine?.resize(); + } + + dispose(): void { + this.stop(); + this.camera?.dispose(); + this.camera = null; + this.adapterInstance?.dispose(); + this.adapterInstance = null; + this.babylonEngine = null; + } +} diff --git a/src/runtime/render-host.test.ts b/src/runtime/render-host.test.ts new file mode 100644 index 0000000..988b268 --- /dev/null +++ b/src/runtime/render-host.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; + +import { isEngineEditingCapable } from "./render-host"; + +describe("isEngineEditingCapable", () => { + it("three.js viewport supports editing interactions", () => { + expect(isEngineEditingCapable("three.js")).toBe(true); + }); + + it("babylon.js viewport is view-only in B1", () => { + expect(isEngineEditingCapable("babylon.js")).toBe(false); + }); +}); diff --git a/src/runtime/render-host.ts b/src/runtime/render-host.ts new file mode 100644 index 0000000..7f92f35 --- /dev/null +++ b/src/runtime/render-host.ts @@ -0,0 +1,35 @@ +import type { RuntimeTarget } from "@/core/scene/types"; + +/** Engines the editor viewport can render with — the subset of RuntimeTarget + * kinds that have a render-host implementation. */ +export type ViewportEngine = Extract; + +/** + * Engine-neutral render host contract (v1.0 B1 subset: mount / render loop / + * camera / resize / dispose). B2 extends it with picking + selection + * highlight, B3 with the gizmo — converging ThreeViewport onto it per the + * A1 spec §8 boundary list. B1's only implementation is BabylonRenderHost; + * the Three side stays inside ThreeViewport untouched. + * + * Call order: mount → start → (resize/stop/start)* → dispose. An instance is + * not reusable after dispose. + */ +export interface IRenderHost { + readonly engine: ViewportEngine; + /** Create the real engine + editor camera + camera controls on the canvas. */ + mount(canvas: HTMLCanvasElement): void; + start(): void; + stop(): void; + resize(width: number, height: number): void; + dispose(): void; +} + +/** + * B1 capability gate: only the Three viewport supports editing interactions + * (gizmo / play / pick / drop / focus). Every UI surface that disables itself + * in Babylon mode reads this single helper, so B2/B3/B4 flip capabilities in + * one place instead of hunting scattered engine === "..." checks. + */ +export function isEngineEditingCapable(engine: ViewportEngine): boolean { + return engine === "three.js"; +} diff --git a/src/services/library/asset-drag.test.ts b/src/services/library/asset-drag.test.ts index cde2cf9..91fdca3 100644 --- a/src/services/library/asset-drag.test.ts +++ b/src/services/library/asset-drag.test.ts @@ -12,7 +12,9 @@ function up() { } describe("beginAssetDrag", () => { - beforeEach(() => useUIStore.setState({ assetDragItemId: null })); + beforeEach(() => + useUIStore.setState({ assetDragItemId: null, viewportEngine: "three.js" }), + ); it("does not activate the drag below the 5px threshold", () => { beginAssetDrag("geo-box", 100, 100); @@ -33,4 +35,12 @@ describe("beginAssetDrag", () => { move(200, 200); // listeners removed → no late activation expect(useUIStore.getState().assetDragItemId).toBeNull(); }); + + it("does not start a drag while the Babylon viewport is active (B1 — drop lands in B4)", () => { + useUIStore.setState({ viewportEngine: "babylon.js" }); + beginAssetDrag("geo-box", 100, 100); + move(110, 100); // would activate in the three viewport + expect(useUIStore.getState().assetDragItemId).toBeNull(); + up(); + }); }); diff --git a/src/services/library/asset-drag.ts b/src/services/library/asset-drag.ts index b8f5c6a..9ce1e01 100644 --- a/src/services/library/asset-drag.ts +++ b/src/services/library/asset-drag.ts @@ -1,3 +1,4 @@ +import { isEngineEditingCapable } from "@/runtime/render-host"; import { useUIStore } from "@/services/ui/store"; /** @@ -16,6 +17,9 @@ let onMove: ((e: PointerEvent) => void) | null = null; let onUp: (() => void) | null = null; export function beginAssetDrag(id: string, clientX: number, clientY: number): void { + // B1: the Babylon viewport has no drop handler (raycastGroundPoint lands in + // B4) — without a window-pointerup consumer the drag flag would leak. + if (!isEngineEditingCapable(useUIStore.getState().viewportEngine)) return; teardown(); // drop any stale candidate before starting a new one candidate = { id, x: clientX, y: clientY }; onMove = (e) => { diff --git a/src/services/ui/store.ts b/src/services/ui/store.ts index e8070e5..8bde96a 100644 --- a/src/services/ui/store.ts +++ b/src/services/ui/store.ts @@ -1,4 +1,5 @@ import { create } from "zustand"; +import type { ViewportEngine } from "@/runtime/render-host"; /** * UI store — ephemeral, never persisted. @@ -35,6 +36,10 @@ interface UIState { * via undo/redo / commands are swallowed by command-history. */ playState: PlayState; setPlayState: (state: PlayState) => void; + /** Which engine the editor viewport renders with (v1.0 B1). Session-only + * preview switch — never persisted, not part of project data. */ + viewportEngine: ViewportEngine; + setViewportEngine: (engine: ViewportEngine) => void; /** Whether the keyboard-shortcuts help dialog is visible. */ helpOpen: boolean; setHelpOpen: (open: boolean) => void; @@ -93,6 +98,8 @@ export const useUIStore = create((set) => ({ setRightPanelTab: (rightPanelTab) => set({ rightPanelTab }), playState: "edit", setPlayState: (playState) => set({ playState }), + viewportEngine: "three.js", + setViewportEngine: (viewportEngine) => set({ viewportEngine }), helpOpen: false, setHelpOpen: (helpOpen) => set({ helpOpen }), newProjectOpen: false, diff --git a/src/ui/viewport/BabylonViewport.test.tsx b/src/ui/viewport/BabylonViewport.test.tsx new file mode 100644 index 0000000..69a1dad --- /dev/null +++ b/src/ui/viewport/BabylonViewport.test.tsx @@ -0,0 +1,70 @@ +import { NullEngine } from "@babylonjs/core"; +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { BabylonRenderHost } from "@/runtime/babylon/render-host"; +import { createDemoProject } from "@/services/scene/demo-project"; +import { useSceneStore } from "@/services/scene/store"; + +import { BabylonViewport } from "./BabylonViewport"; + +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +describe("BabylonViewport", () => { + let host: BabylonRenderHost | null = null; + const createHost = () => { + host = new BabylonRenderHost({ createEngine: () => new NullEngine() }); + return host; + }; + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", ResizeObserverStub); + host = null; + useSceneStore.getState().setProject(createDemoProject()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + useSceneStore.getState().setProject(null); + }); + + function firstMeshId(): string { + const project = useSceneStore.getState().project; + const mesh = Object.values(project!.scene.nodes).find((n) => n.type === "mesh"); + if (!mesh) throw new Error("demo project has no mesh"); + return mesh.id; + } + + it("mounts, seeds the scene into the adapter, and unmounts cleanly", () => { + const { unmount } = render(); + expect(host).not.toBeNull(); + // Demo cube landed in the Babylon scene (helper nodes may warn-skip). + expect(host!.adapter.describeNode(firstMeshId())).not.toBeNull(); + unmount(); + // dispose() ran — adapter accessor throws after teardown. + expect(() => host!.adapter).toThrow(); + }); + + it("store mutations sync incrementally into the adapter", () => { + render(); + const meshId = firstMeshId(); + useSceneStore.getState().setNodeTransform(meshId, { + position: [5, 0, 0], + rotation: [0, 0, 0, 1], + scale: [1, 1, 1], + }); + expect(host!.adapter.describeNode(meshId)?.position).toEqual([5, 0, 0]); + }); + + it("a node the adapter cannot build is skipped without killing the viewport", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + render(); + // Demo project contains a grid helper — unsupported kinds must warn-skip, + // and the mesh must still be present. + expect(host!.adapter.describeNode(firstMeshId())).not.toBeNull(); + warn.mockRestore(); + }); +}); diff --git a/src/ui/viewport/BabylonViewport.tsx b/src/ui/viewport/BabylonViewport.tsx new file mode 100644 index 0000000..ca3ad08 --- /dev/null +++ b/src/ui/viewport/BabylonViewport.tsx @@ -0,0 +1,96 @@ +import { useEffect, useRef } from "react"; + +import type { SceneNode } from "@/core/scene/types"; +import type { SyncOp } from "@/runtime/adapter"; +import { BabylonRenderHost } from "@/runtime/babylon/render-host"; +import { useSceneStore } from "@/services/scene/store"; + +import { diffSceneNodes, EMPTY_SCENE_GRAPH, type SceneDiff } from "./scene-diff"; + +/** + * Babylon.js viewport (v1.0 B1 — view + orbit camera only). Mirrors + * ThreeViewport's lifecycle conventions: + * - Mount effect re-runs only when the active project id changes; node + * edits flow through a useSceneStore subscription → diffSceneNodes → + * adapter.syncNode, so the canvas / camera state survive edits. + * - No picking / gizmo / play / drop / focus — those are B2–B4; the + * surrounding UI disables itself via isEngineEditingCapable. + * Per-node sync failures warn + skip (one unbuildable node — e.g. a helper, + * which has no Babylon builder yet — must not kill the whole viewport). + */ +export function BabylonViewport({ + createHost, +}: { + /** Test seam — defaults to a real-Engine host. */ + createHost?: () => BabylonRenderHost; +}) { + const containerRef = useRef(null); + const projectId = useSceneStore((s) => s.project?.metadata.id ?? null); + + useEffect(() => { + if (!projectId) return; + const container = containerRef.current; + if (!container) return; + + const canvas = document.createElement("canvas"); + canvas.style.display = "block"; + canvas.style.width = "100%"; + canvas.style.height = "100%"; + container.appendChild(canvas); + + const host = createHost ? createHost() : new BabylonRenderHost(); + host.mount(canvas); + const adapter = host.adapter; + + const trySync = (node: SceneNode, op: SyncOp) => { + try { + adapter.syncNode(node, op); + } catch (e) { + console.warn( + `BabylonViewport: syncNode ${op} failed for ${node.id} — skipped`, + e, + ); + } + }; + const applyDiff = (diff: SceneDiff) => { + for (const n of diff.added) trySync(n, "add"); + for (const n of diff.updated) trySync(n, "update"); + for (const n of diff.removed) trySync(n, "remove"); + }; + + const initial = useSceneStore.getState().project; + if (initial) applyDiff(diffSceneNodes(EMPTY_SCENE_GRAPH, initial.scene)); + + host.start(); + + const unsubscribe = useSceneStore.subscribe((state, prev) => { + const next = state.project; + const old = prev.project; + if (!next || !old) return; + if (next === old) return; + if (next.metadata.id !== old.metadata.id) return; // handled by effect re-run + applyDiff(diffSceneNodes(old.scene, next.scene)); + }); + + const resize = () => { + const w = container.clientWidth; + const h = container.clientHeight; + if (w === 0 || h === 0) return; + host.resize(w, h); + }; + resize(); + const ro = new ResizeObserver(resize); + ro.observe(container); + + return () => { + unsubscribe(); + ro.disconnect(); + host.dispose(); + if (canvas.parentNode === container) { + container.removeChild(canvas); + } + }; + }, [projectId, createHost]); + + return
; +} diff --git a/src/ui/viewport/EngineToggle.test.tsx b/src/ui/viewport/EngineToggle.test.tsx new file mode 100644 index 0000000..96a7ec9 --- /dev/null +++ b/src/ui/viewport/EngineToggle.test.tsx @@ -0,0 +1,33 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { useUIStore } from "@/services/ui/store"; + +import { EngineToggle } from "./EngineToggle"; + +describe("EngineToggle", () => { + beforeEach(() => + useUIStore.setState({ viewportEngine: "three.js", playState: "edit" }), + ); + + it("switches the engine on click", () => { + render(); + fireEvent.click(screen.getByText("Babylon")); + expect(useUIStore.getState().viewportEngine).toBe("babylon.js"); + }); + + it("forces play state back to edit when switching", () => { + useUIStore.setState({ playState: "play" }); + render(); + fireEvent.click(screen.getByText("Babylon")); + expect(useUIStore.getState().playState).toBe("edit"); + }); + + it("clicking the active engine is a no-op", () => { + useUIStore.setState({ playState: "play" }); + render(); + fireEvent.click(screen.getByText("Three")); + expect(useUIStore.getState().viewportEngine).toBe("three.js"); + expect(useUIStore.getState().playState).toBe("play"); + }); +}); diff --git a/src/ui/viewport/EngineToggle.tsx b/src/ui/viewport/EngineToggle.tsx new file mode 100644 index 0000000..ad681eb --- /dev/null +++ b/src/ui/viewport/EngineToggle.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; + +import { cn } from "@/lib/utils"; +import type { ViewportEngine } from "@/runtime/render-host"; +import { useUIStore } from "@/services/ui/store"; + +const ENGINES: { engine: ViewportEngine; labelKey: string }[] = [ + { engine: "three.js", labelKey: "viewport.engine.three" }, + { engine: "babylon.js", labelKey: "viewport.engine.babylon" }, +]; + +/** + * Three/Babylon viewport engine switch (v1.0 B1). Switching forces playState + * back to "edit" first: the Three viewport tears down play mode on unmount, + * but the store flag would otherwise stay "play" with nothing ticking — + * and remounting ThreeViewport while playState === "play" would show a + * paused-looking play mode with no behaviors installed. + */ +export function EngineToggle() { + const { t } = useTranslation("editor"); + const engine = useUIStore((s) => s.viewportEngine); + const setViewportEngine = useUIStore((s) => s.setViewportEngine); + const setPlayState = useUIStore((s) => s.setPlayState); + + return ( +
+ {ENGINES.map((entry) => { + const active = entry.engine === engine; + return ( + + ); + })} +
+ ); +} diff --git a/src/ui/viewport/PlayButton.test.tsx b/src/ui/viewport/PlayButton.test.tsx index bf36546..854124d 100644 --- a/src/ui/viewport/PlayButton.test.tsx +++ b/src/ui/viewport/PlayButton.test.tsx @@ -6,7 +6,9 @@ import { useUIStore } from "@/services/ui/store"; import { PlayButton } from "./PlayButton"; describe("PlayButton", () => { - beforeEach(() => useUIStore.setState({ playState: "edit" })); + beforeEach(() => + useUIStore.setState({ playState: "edit", viewportEngine: "three.js" }), + ); it("shows 'Play' label when in edit mode", () => { render(); @@ -31,4 +33,13 @@ describe("PlayButton", () => { fireEvent.click(screen.getByText(/pause/i)); expect(useUIStore.getState().playState).toBe("edit"); }); + + it("is disabled in the Babylon viewport (B1 — play lands in B4)", () => { + useUIStore.setState({ viewportEngine: "babylon.js" }); + render(); + const button = screen.getByRole("button"); + expect(button).toBeDisabled(); + fireEvent.click(button); + expect(useUIStore.getState().playState).toBe("edit"); + }); }); diff --git a/src/ui/viewport/PlayButton.tsx b/src/ui/viewport/PlayButton.tsx index 3ce640d..4536809 100644 --- a/src/ui/viewport/PlayButton.tsx +++ b/src/ui/viewport/PlayButton.tsx @@ -1,18 +1,23 @@ import { useTranslation } from "react-i18next"; +import { isEngineEditingCapable } from "@/runtime/render-host"; import { useUIStore } from "@/services/ui/store"; export function PlayButton() { const { t } = useTranslation("editor"); const playState = useUIStore((s) => s.playState); const setPlayState = useUIStore((s) => s.setPlayState); + const viewportEngine = useUIStore((s) => s.viewportEngine); const isPlay = playState === "play"; + const editingCapable = isEngineEditingCapable(viewportEngine); return (