From edcd44c26d924154429014a6f66e9fc128d65df2 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:15:09 +0800 Subject: [PATCH 1/8] =?UTF-8?q?docs(spec):=20v1.0=20sub-stage=20A1=20?= =?UTF-8?q?=E2=80=94=20=E5=A4=9A=E9=80=82=E9=85=8D=E5=99=A8=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20+=20Babylon=20headless=20+=20conformance=20?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...-v1.0a-multi-adapter-conformance-design.md | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-v1.0a-multi-adapter-conformance-design.md diff --git a/docs/superpowers/specs/2026-06-09-v1.0a-multi-adapter-conformance-design.md b/docs/superpowers/specs/2026-06-09-v1.0a-multi-adapter-conformance-design.md new file mode 100644 index 0000000..4e6eff4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-v1.0a-multi-adapter-conformance-design.md @@ -0,0 +1,265 @@ +# v1.0 sub-stage A1 — 多适配器契约 + Babylon headless + conformance 设计 + +**状态**: 已批准(2026-06-09) +**前置**: Phase 0-3 + v0.2/v0.3/v0.4 已合并。`IRuntimeAdapter` 契约在 `src/runtime/adapter.ts`;唯一实现 `ThreeAdapter`(`src/runtime/three/adapter.ts`);`src/runtime/babylon/` 为架构预留空目录。adapter-guide §7-9 已有假想 BabylonAdapter 教程 + conformance 套件承诺。 +**定位**: v1.0「多适配器」的**第一个 sub-stage**,是「多框架低代码平台」这一项目本质目标的地基。 +**架构依据**: 架构 §7 v1.0「Babylon.js 适配器(验证多适配器架构)」+ §4.1 适配器契约。 + +--- + +## 0. 战略定位与分阶段(关键上下文) + +本项目本质是**多框架低代码 3D 平台**:渲染引擎可切换是必然目标,未来出现新的优秀 3D 框架时,系统应能**最小改动**接入。Three.js 只是第一个落地引擎,不是终点。 + +因此 v1.0a 不是"为了测试搞个 headless Babylon",而是**把引擎中立的适配器契约确立为真正的接缝**——用第二个引擎(Babylon)+ conformance 套件证明契约对第二引擎成立,并明确标出未来"实时切换引擎"还差什么,让它成为增量而非重写。 + +多阶段产品线(各自独立 spec→plan→实现): + +| 阶段 | 内容 | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| **A1(本期·地基)** | 契约加 `describeNode` 引擎中立 introspection + BabylonAdapter(headless `NullEngine`)覆盖 `syncNode` 核心 kind + conformance 套件跑双适配器 | +| A2 | behaviors install/tick + behavior codegen 跨引擎对等 | +| A3 | prefab_instance / glTF 加载 + `babylon` 导出 target | +| B(更大) | **实时视口切引擎**:把渲染循环/相机控制/gizmo/拾取/outline 抽到"渲染宿主"接口背后,用户可把实时编辑器切到 Babylon | +| 后续 | r3f / Unity 等更多引擎(架构 v1.x) | + +--- + +## 1. 目标 / 非目标 + +### 目标 + +- 把 `IRuntimeAdapter` 确立为引擎中立接缝;新增引擎中立 `describeNode(id): RuntimeNodeInfo | null`(读引擎对象本身,非回显输入)。 +- 新增 `BabylonAdapter`(Babylon `NullEngine`,无 WebGL,可在 vitest headless 跑),实现 `syncNode` 的核心 kind:group / mesh / light / camera。 +- 新增 **conformance 套件**:一组共享断言,参数化适配器工厂,让 `ThreeAdapter` 与 `BabylonAdapter` 跑**同一批 SceneProject** 并断言等价结构 → 契约对第二引擎成立。 +- `ThreeAdapter` 实现 `describeNode`(契约新方法)。 + +### 非目标(各自后续 sub-stage / roadmap Backlog) + +- **实时视口切引擎(B)**:本期不碰 ThreeViewport,只把 B 要解耦的 three 专属边界写进 §8 清单。 +- **behaviors**(install/tick/codegen 跨引擎)→ A2。 +- **prefab_instance / glTF 加载 + `babylon` 导出 target** → A3。 +- **Babylon 的 `pickAt` / `syncAsset` / `exportProject` / behaviors 方法**:A1 实现为抛 `NotImplementedYet`(adapter-guide 已有此约定),不进 conformance。 +- **helper / custom node kind 的 Babylon 映射**:A1 只做 group/mesh/light/camera;helper(grid/axes)是编辑器 chrome,headless 适配器可不建(describeNode 对未建节点返回 null,conformance 不覆盖 helper)。 + +### 成功标准 + +1. conformance 套件对 `ThreeAdapter` 与 `BabylonAdapter` 各跑一遍**全绿**:add 后节点存在、remove 后为 null、父子结构、transform 应用、kind 映射(mesh / light+subtype / camera+kind / group)、visible、geometry kind 一致。 +2. `describeNode` 读的是**引擎对象的实际状态**(Three 的 `Object3D.position`、Babylon 的 `node.position`),非回显 SceneNode 输入。 +3. BabylonAdapter 未实现的方法抛 `NotImplementedYet`,不在 conformance 覆盖范围内。 +4. `pnpm lint && pnpm typecheck && pnpm test` 全绿。**无视觉验证**(A1 纯 headless,不接 UI)。 + +--- + +## 2. 契约改动(`src/runtime/adapter.ts`) + +```ts +/** 引擎中立的运行时节点快照——conformance 断言 + 未来 inspector/AI 读场景的依据。 + * 字段只放当前需要断言的,避免做成大杂烩;新 kind 字段按需增。 */ +export interface RuntimeNodeInfo { + kind: "group" | "mesh" | "light" | "camera" | "helper" | "unknown"; + /** 引擎对象本地变换(读引擎对象,非回显 SceneNode)。 */ + position: [number, number, number]; + rotation: [number, number, number, number]; // 四元数 [x,y,z,w] + scale: [number, number, number]; + visible: boolean; + /** 父运行时对象对应的 SceneNode id;挂在场景根下为 null。 */ + parentId: string | null; + lightKind?: "directional" | "point" | "spot" | "ambient"; + cameraKind?: "perspective" | "orthographic"; + geometryKind?: "box" | "sphere" | "plane" | "cylinder"; +} +``` + +`IRuntimeAdapter` 新增方法(放在 `getRuntimeObject` 附近): + +```ts + /** + * Engine-neutral snapshot of a synced node's runtime object, or null when no + * object exists for the id. Reads the engine object's actual state (not the + * input SceneNode) so conformance can verify the adapter mapped transform / + * kind correctly. Also the seam for a future inspector panel / AI scene read. + */ + describeNode(node_id: string): RuntimeNodeInfo | null; +``` + +- 只有两个实现者(现有 ThreeAdapter + 新 BabylonAdapter),加方法成本可控。 +- `RuntimeNodeInfo` 故意精简:只放 conformance 要断言的字段。后续 kind/字段按需追加(加字段不破坏)。 + +--- + +## 3. ThreeAdapter 改动(`src/runtime/three/adapter.ts`) + +实现 `describeNode`: + +- 由 `this.objects.get(node_id)` 取 `THREE.Object3D`;无则 null。 +- `position`/`rotation`(quaternion)/`scale`/`visible` 读该 Object3D。 +- `parentId`:`obj.parent?.userData.nodeId ?? null`(场景根或 gizmo helper 下为 null;现有代码已给每个节点 Object3D 设 `userData.nodeId`,plan 阶段确认)。 +- `kind` + 每 kind 额外字段: + - `THREE.Mesh` → `kind:"mesh"`;`geometryKind` 由 `userData`(builder 建时打的几何 kind 标记)或几何类型判断(plan 阶段读 `node-builders/mesh.ts` 确认 builder 是否记录 geometry kind;若没有则从 `nodeSnapshots` 取该节点 `data.geometry.kind`——但要保证"读引擎对象"语义:geometry kind 是建模意图,从 snapshot 取可接受,因为引擎对象的几何类型本身就是按它建的)。 + - `THREE.{Directional|Point|Spot|Ambient}Light` → `kind:"light"` + `lightKind`。 + - `THREE.{Perspective|Orthographic}Camera` → `kind:"camera"` + `cameraKind`。 + - `THREE.Group`(且非上述)→ `kind:"group"`。 + - 其它 → `kind:"unknown"`。 +- 其余方法不动;`describeNode` 是纯增量,不影响 ThreeViewport。 + +> **决策(geometry kind / light kind 来源)**:transform/visible/parent **必须**读引擎对象(验证映射)。kind 与子类型:能从引擎对象类型判定的(light 子类、camera 子类)直接判;geometry kind 在 Three 里 `BoxGeometry`/`SphereGeometry` 等可由 `geometry.type` 反推,但 builder 现状是 placeholder cube(见 mesh.ts),所以 geometry kind 从该节点 `nodeSnapshots` 的 `data.geometry.kind` 取 —— plan 阶段统一两个适配器的口径(都以"建该对象时用的 geometry kind"为准),保证 conformance 对等。 + +--- + +## 4. BabylonAdapter(`src/runtime/babylon/adapter.ts`) + +```ts +import { + NullEngine, + Scene, + TransformNode, + MeshBuilder, + DirectionalLight, + PointLight, + SpotLight, + HemisphericLight, + UniversalCamera, + Vector3, + Quaternion, +} from "@babylonjs/core"; +``` + +- **构造**:`new NullEngine()` + `new Scene(engine)`。无 canvas / WebGL → vitest headless 可跑。`target: RuntimeTarget = { kind: "babylon.js", version: "8.x", module_format: "esm" }`。 +- **`syncNode(node, op)`**: + - `add`:按 `node.data.type` 建对象(见映射表),设 `metadata = { nodeId: node.id }`,应用 transform(position/rotation 四元数/scale),`setEnabled(node.visible)`;parent 用 `obj.parent = parentObj`(按 `node.parent_id` 查已建对象,未注册则抛——同 ThreeAdapter 约定)。存入 `Map`。 + - `update`:取已建对象,重应用 transform/visible(核心 kind 不重建)。 + - `remove`:`dispose()` + 从 map 删。 + - 映射表: + + | SceneNode kind | Babylon 对象 | + | ------------------------------------ | ---------------------------------------------------------------------------------- | ------ | ----- | ----------------------------------------------- | + | group | `TransformNode` | + | mesh | `MeshBuilder.Create{Box | Sphere | Plane | Cylinder}`(按 `data.geometry.kind`,缺省 box) | + | light directional/point/spot/ambient | `DirectionalLight` / `PointLight` / `SpotLight` / `HemisphericLight`(ambient 近似) | + | camera perspective/orthographic | `UniversalCamera`(`mode` 设 PERSPECTIVE/ORTHOGRAPHIC) | + +- **`describeNode(id)`**:读 Babylon 对象的 `position`/`rotationQuaternion`/`scaling`/`isEnabled()`/`parent.metadata.nodeId`,kind 由对象类型判 + 子类型。 +- **未实现方法**:`getRuntimeObject` 返回 map 里的对象(可直接实现,无害);`pickAt` / `syncAsset` / `exportProject` / `getSupportedBehaviors` / `generateBehaviorCode` → 抛 `NotImplementedYet`(A1 不覆盖,adapter-guide §2 约定)。 +- **`dispose()`**:`scene.dispose()` + `engine.dispose()`。 + +> Babylon transform 注意:Babylon 默认欧拉 `rotation`;用四元数须设 `obj.rotationQuaternion = Quaternion.fromArray([x,y,z,w])`。describeNode 读回时若 `rotationQuaternion` 存在用它,否则由 `rotation` 欧拉转。统一走四元数路径,保证与 SceneNode 的 `[x,y,z,w]` 对齐。 + +--- + +## 5. conformance 套件(`src/runtime/conformance/`) + +### 5.1 共享套件 `conformance-suite.ts` + +```ts +import { describe, expect, it } from "vitest"; +import type { IRuntimeAdapter } from "@/runtime/adapter"; +import type { SceneNode } from "@/core/scene/types"; + +/** 对任意 IRuntimeAdapter 实现跑同一批断言。两个适配器各调一次 → + * 同一套断言双引擎都过 = 契约对该引擎成立。 */ +export function describeAdapterConformance( + makeAdapter: () => IRuntimeAdapter, + label: string, +): void { + describe(`IRuntimeAdapter conformance — ${label}`, () => { + // 用 helper 造 fixture SceneNode(group/mesh/light/camera + 父子 + transform) + // it: add 后 describeNode 非 null 且 kind/transform/visible 正确 + // it: 父子 → 子 describeNode.parentId === 父 id + // it: remove 后 describeNode === null + // it: light 子类映射;camera 子类映射;mesh geometryKind 映射 + // it: update transform 后 describeNode 反映新值 + }); +} +``` + +(fixture SceneNode 用本地 helper 构造,复用项目里 `IDENTITY` 模式;具体断言在 plan 展开。) + +### 5.2 跑双适配器 `conformance.test.ts` + +```ts +import { ThreeAdapter } from "@/runtime/three/adapter"; +import { BabylonAdapter } from "@/runtime/babylon/adapter"; +import { describeAdapterConformance } from "./conformance-suite"; + +describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter"); +describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter"); +``` + +- `conformance-suite.ts` 内 import vitest 的 `describe/it/expect`——它只在 vitest 下被 `.test.ts` 调用,不是独立测试文件,合法且常见。 +- ThreeAdapter 构造只建 `THREE.Scene` + 相机,无 WebGL,已被现有单测证明 headless 可跑;BabylonAdapter 用 NullEngine 同样 headless。 + +--- + +## 6. 依赖 + +`pnpm add @babylonjs/core`(Babylon 8.x;`NullEngine` 在 `@babylonjs/core`)。仅 `dependencies`(BabylonAdapter 是运行时代码,不是 devDep)。注意包体积——A1 只在 headless/测试用,但导入 `@babylonjs/core` 子模块按需引入(tree-shake),plan 阶段确认 import 路径走具名导入。 + +> **⚠️ 首要风险(keystone 假设,plan 第一步就 de-risk)**:整个 A1 押在「`NullEngine` 能在 vitest/jsdom 下无 WebGL/canvas 构造」上。**plan 的第 1 个任务必须是一个最小验证测试**:`new NullEngine(); new Scene(engine); new TransformNode(...)` 跑通即可,**先证明这条路通,再建其余**。若 jsdom 缺 WebGL2/DOM 全局导致失败:评估 `@babylonjs/core` 的 headless 要求(NullEngine 设计上就是为 server-side/headless),或给该测试加最小 globals shim,或评估 `happy-dom`。这条不通则 A1 方案需回头调整——故放最前面验。 + +--- + +## 7. 错误处理 / 边界 + +- `syncNode("add")` 父未注册 → 抛(同 ThreeAdapter);重复 id → 抛。 +- `describeNode(未知 id)` → `null`。 +- BabylonAdapter 未实现方法 → 抛 `NotImplementedYet`(定义一个简单 error 或复用现有约定;plan 阶段查项目是否已有 NotImplementedYet 类型,无则在 babylon/adapter.ts 本地定义并在 adapter-guide 标注)。 +- ambient light:Three 是 `AmbientLight`,Babylon 无直接等价,用 `HemisphericLight` 近似 → `describeNode.lightKind` 仍报 `"ambient"`(按建模意图,conformance 对等)。 + +--- + +## 8. 为 B(实时切引擎)铺路 —— 边界清单(本期不实现,写进 spec) + +ThreeViewport 当前直接耦合 Three.js 的部分,是 B 阶段要抽到"渲染宿主(RenderHost)"接口背后的清单: + +- 渲染:`THREE.WebGLRenderer` + 渲染循环 `requestAnimationFrame` + `composer.render()`。 +- 相机控制:`OrbitControls`(依赖 `adapter.camera` + canvas)。 +- 变换 gizmo:`TransformControls`(依赖 `adapter.camera` + canvas + `adapter.scene`)。 +- 选中高亮:`EffectComposer` + `OutlinePass`(依赖 `adapter.scene` + `adapter.camera`)。 +- 吸附/拾取:`adapter.pickAt` / `raycastGroundPoint` / 投影 helper(依赖 three 的 raycaster/project)。 +- 直接访问 `adapter.scene` / `adapter.camera`。 + +B 阶段方向:定义 `IRenderHost`(mount canvas / 渲染循环 / 相机控制 / gizmo / 拾取 / 选中高亮),Three 与 Babylon 各实现一个;ThreeViewport 改为面向 RenderHost。**A1 不动这些**,只记录边界,使 B 是增量。 + +--- + +## 9. 测试策略 + +- **conformance**(`conformance.test.ts`):双适配器各跑共享套件,全绿 = 契约成立。这是 A1 的核心交付。 +- **ThreeAdapter.describeNode 单测**(`adapter.test.ts` 追加):group/mesh/light/camera/未知 id,transform/parent/kind 正确。 +- **BabylonAdapter 单测**(`babylon/adapter.test.ts`):构造 NullEngine、syncNode add/update/remove、describeNode、未实现方法抛 NotImplementedYet。 +- 无视觉验证(headless)。`pnpm lint && pnpm typecheck && pnpm test` 全绿。 + +--- + +## 10. 文件清单 + +**新增**: + +- `src/runtime/babylon/adapter.ts` + `adapter.test.ts` +- `src/runtime/conformance/conformance-suite.ts` + `conformance.test.ts` + +**修改**: + +- `src/runtime/adapter.ts`(`RuntimeNodeInfo` 类型 + `IRuntimeAdapter.describeNode`) +- `src/runtime/three/adapter.ts`(实现 `describeNode`)+ `adapter.test.ts`(describeNode 单测) +- `package.json`(`@babylonjs/core` 依赖) + +**不改**:`ThreeViewport.tsx`(B 阶段才动)、导出/behaviors(A2/A3)。 + +--- + +## 11. 反推源(plan 阶段读真实代码确认的符号) + +- `src/runtime/adapter.ts`:`IRuntimeAdapter` 现有方法签名、`RuntimeTarget` 形状(babylon target 怎么写)。 +- `src/runtime/three/adapter.ts`:`this.objects` map、`userData.nodeId` 是否每节点都设、light/camera/group 建对象处(`node-builders/`)、geometry kind 在哪记录(`node-builders/mesh.ts`)、有无 `NotImplementedYet` 约定。 +- `src/core/scene/types.ts`:`SceneNode` / `NodeData`(light_kind / camera_kind / geometry.kind 字段名)。 +- `src/core/scene/schemas.ts`:light/camera/mesh data 的枚举值(directional/point/spot/ambient、perspective/orthographic、box/sphere/plane/cylinder)。 +- `src/services/scene/store.test.ts` / `adapter.test.ts`:fixture SceneNode 构造模式(IDENTITY、节点字面量)复用到 conformance helper。 +- `@babylonjs/core` 8.x:`NullEngine` / `MeshBuilder` / 各 Light / `UniversalCamera` / `rotationQuaternion` 的具名导入路径。 + +--- + +## 12. 验收 + +满足 §1 成功标准:conformance 套件双适配器全绿、`describeNode` 读引擎对象、Babylon 未实现方法抛 `NotImplementedYet`、A1 不碰 ThreeViewport、`pnpm lint/typecheck/test` 全绿。下一步:A2(behaviors 跨引擎)或按需 A3(glTF + babylon 导出);更远是 B(实时切引擎,§8 边界清单已备)。 From 0d5cd65cfa5b3b49b943a35bb5d3210b2e5aa450 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:26:59 +0800 Subject: [PATCH 2/8] =?UTF-8?q?test(babylon):=20keystone=20=E2=80=94=20Nul?= =?UTF-8?q?lEngine=20constructs=20headless=20under=20vitest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + pnpm-lock.yaml | 8 ++++++++ src/runtime/babylon/headless.test.ts | 17 +++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 src/runtime/babylon/headless.test.ts diff --git a/package.json b/package.json index bca6387..8f031de 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ ] }, "dependencies": { + "@babylonjs/core": "^9.11.0", "@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist-mono": "^5.2.8", "@radix-ui/react-dialog": "^1.1.15", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c49c8ab..5c01c14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@babylonjs/core': + specifier: ^9.11.0 + version: 9.11.0 '@fontsource-variable/geist': specifier: ^5.2.9 version: 5.2.9 @@ -292,6 +295,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@babylonjs/core@9.11.0': + resolution: {integrity: sha512-MQH6Lop6Dn06n3gW8/CADb3FrO3jVudYHYZVu2TPNH0hXyJGAVPtVRpudF12UHn5ZV0ByQ1gu71vfKj88rFDCg==} + '@bramus/specificity@2.4.2': resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} hasBin: true @@ -3530,6 +3536,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@babylonjs/core@9.11.0': {} + '@bramus/specificity@2.4.2': dependencies: css-tree: 3.2.1 diff --git a/src/runtime/babylon/headless.test.ts b/src/runtime/babylon/headless.test.ts new file mode 100644 index 0000000..668b646 --- /dev/null +++ b/src/runtime/babylon/headless.test.ts @@ -0,0 +1,17 @@ +import { NullEngine, Scene, TransformNode, Vector3 } from "@babylonjs/core"; +import { describe, expect, it } from "vitest"; + +// Keystone for v1.0a: the whole approach assumes Babylon's NullEngine can be +// constructed under vitest/jsdom with no WebGL / canvas. Prove it here before +// building the adapter on top. +describe("Babylon NullEngine headless", () => { + it("constructs an engine + scene + transform node and reads back a position", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const node = new TransformNode("t", scene); + node.position = new Vector3(1, 2, 3); + expect([node.position.x, node.position.y, node.position.z]).toEqual([1, 2, 3]); + scene.dispose(); + engine.dispose(); + }); +}); From ea53d623381510b821f60b74d0ad5daddd914577 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:30:38 +0800 Subject: [PATCH 3/8] feat(adapter): RuntimeNodeInfo + describeNode contract; ThreeAdapter impl Co-Authored-By: Claude Opus 4.8 --- src/runtime/adapter.test.ts | 4 + src/runtime/adapter.ts | 18 +++++ src/runtime/three/adapter.test.ts | 117 ++++++++++++++++++++++++++++++ src/runtime/three/adapter.ts | 42 +++++++++++ 4 files changed, 181 insertions(+) diff --git a/src/runtime/adapter.test.ts b/src/runtime/adapter.test.ts index eed81ef..d5583b2 100644 --- a/src/runtime/adapter.test.ts +++ b/src/runtime/adapter.test.ts @@ -7,6 +7,7 @@ import type { ExportOptions, ExportResult, IRuntimeAdapter, + RuntimeNodeInfo, SyncOp, } from "./adapter"; import type { @@ -34,6 +35,9 @@ class NoopAdapter implements IRuntimeAdapter { getRuntimeObject(_node_id: string): unknown { return null; } + describeNode(_node_id: string): RuntimeNodeInfo | null { + return null; + } pickAt(_screen_x: number, _screen_y: number): string | null { return null; } diff --git a/src/runtime/adapter.ts b/src/runtime/adapter.ts index 1b67130..edde00c 100644 --- a/src/runtime/adapter.ts +++ b/src/runtime/adapter.ts @@ -15,6 +15,23 @@ import type { */ export type SyncOp = "add" | "update" | "remove"; +/** + * Engine-neutral snapshot of a synced node's runtime object. Conformance + * asserts on this; it also seams a future inspector panel / AI scene read. + * Reads the engine object's actual state, not the input SceneNode. + */ +export interface RuntimeNodeInfo { + kind: "group" | "mesh" | "light" | "camera" | "helper" | "unknown"; + position: [number, number, number]; + rotation: [number, number, number, number]; + scale: [number, number, number]; + visible: boolean; + parentId: string | null; + lightKind?: "directional" | "point" | "spot" | "ambient"; + cameraKind?: "perspective" | "orthographic"; + geometryKind?: "box" | "sphere" | "plane" | "cylinder"; +} + /** * Identifier for an export emitter. Each target shapes the on-disk output: * - `vite` — full Vite project source (package.json + vite.config @@ -132,6 +149,7 @@ export interface IRuntimeAdapter { syncAsset(asset: AssetReference): Promise; getRuntimeObject(node_id: string): unknown; + describeNode(node_id: string): RuntimeNodeInfo | null; pickAt(screen_x: number, screen_y: number): string | null; // ───── Export ─────────────────────────────────────────────────── diff --git a/src/runtime/three/adapter.test.ts b/src/runtime/three/adapter.test.ts index fc8ba17..2fd511a 100644 --- a/src/runtime/three/adapter.test.ts +++ b/src/runtime/three/adapter.test.ts @@ -877,3 +877,120 @@ describe("ThreeAdapter.raycastGroundPoint", () => { expect(adapter.raycastGroundPoint(50, 50)).toBeNull(); }); }); + +describe("ThreeAdapter.describeNode", () => { + it("returns null for an unknown id", () => { + expect(new ThreeAdapter(target).describeNode("nope")).toBeNull(); + }); + + it("describes a mesh node: kind + geometryKind + transform + visible", () => { + const adapter = new ThreeAdapter(target); + adapter.syncNode( + { + id: "m1", + name: "m", + type: "mesh", + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 2, 2] }, + parent_id: null, + children_ids: [], + visible: false, + locked: false, + data: { type: "mesh", geometry: { kind: "sphere" } }, + behaviors: [], + user_data: {}, + }, + "add", + ); + const info = adapter.describeNode("m1"); + expect(info?.kind).toBe("mesh"); + expect(info?.geometryKind).toBe("sphere"); + expect(info?.position).toEqual([1, 2, 3]); + expect(info?.scale).toEqual([2, 2, 2]); + expect(info?.visible).toBe(false); + expect(info?.parentId).toBeNull(); + }); + + it("maps light + camera subtypes", () => { + const adapter = new ThreeAdapter(target); + adapter.syncNode( + { + id: "L", + name: "L", + type: "light", + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "light", light_kind: "spot", color: "#fff", intensity: 1 }, + behaviors: [], + user_data: {}, + }, + "add", + ); + adapter.syncNode( + { + id: "C", + name: "C", + type: "camera", + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "camera", camera_kind: "orthographic", near: 0.1, far: 100 }, + behaviors: [], + user_data: {}, + }, + "add", + ); + expect(adapter.describeNode("L")).toMatchObject({ + kind: "light", + lightKind: "spot", + }); + expect(adapter.describeNode("C")).toMatchObject({ + kind: "camera", + cameraKind: "orthographic", + }); + }); + + it("reports parentId from the parent's nodeId", () => { + const adapter = new ThreeAdapter(target); + const base = { + 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], + }, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }; + adapter.syncNode( + { + ...base, + id: "g", + name: "g", + type: "group", + parent_id: null, + data: { type: "group" }, + }, + "add", + ); + adapter.syncNode( + { + ...base, + id: "c", + name: "c", + type: "group", + parent_id: "g", + data: { type: "group" }, + }, + "add", + ); + expect(adapter.describeNode("c")?.parentId).toBe("g"); + expect(adapter.describeNode("g")?.parentId).toBeNull(); + }); +}); diff --git a/src/runtime/three/adapter.ts b/src/runtime/three/adapter.ts index 971910b..b746851 100644 --- a/src/runtime/three/adapter.ts +++ b/src/runtime/three/adapter.ts @@ -13,6 +13,7 @@ import type { ExportOptions, ExportResult, IRuntimeAdapter, + RuntimeNodeInfo, SyncOp, } from "../adapter"; import { screenToNdc } from "@/lib/drop-helpers"; @@ -268,6 +269,39 @@ export class ThreeAdapter implements IRuntimeAdapter { return this.objects.get(node_id); } + describeNode(node_id: string): RuntimeNodeInfo | null { + const obj = this.objects.get(node_id); + if (!obj) return null; + const parent = obj.parent; + const parentNodeId = + parent && typeof parent.userData.nodeId === "string" + ? parent.userData.nodeId + : null; + const info: RuntimeNodeInfo = { + kind: threeRuntimeKind(obj), + position: [obj.position.x, obj.position.y, obj.position.z], + rotation: [ + obj.quaternion.x, + obj.quaternion.y, + obj.quaternion.z, + obj.quaternion.w, + ], + scale: [obj.scale.x, obj.scale.y, obj.scale.z], + visible: obj.visible, + parentId: parentNodeId, + }; + if (obj instanceof THREE.Mesh && typeof obj.userData.geometryKind === "string") { + info.geometryKind = obj.userData.geometryKind as RuntimeNodeInfo["geometryKind"]; + } + if (obj instanceof THREE.DirectionalLight) info.lightKind = "directional"; + else if (obj instanceof THREE.SpotLight) info.lightKind = "spot"; + else if (obj instanceof THREE.PointLight) info.lightKind = "point"; + else if (obj instanceof THREE.AmbientLight) info.lightKind = "ambient"; + if (obj instanceof THREE.PerspectiveCamera) info.cameraKind = "perspective"; + else if (obj instanceof THREE.OrthographicCamera) info.cameraKind = "orthographic"; + return info; + } + /** Mounted viewport updates this whenever the canvas resizes, so pickAt can * convert pixel coords to normalized device coords without owning the DOM. */ setViewportSize(width: number, height: number): void { @@ -476,3 +510,11 @@ function findNodeId(object: THREE.Object3D | null): string | null { } return null; } + +function threeRuntimeKind(obj: THREE.Object3D): RuntimeNodeInfo["kind"] { + if (obj instanceof THREE.Mesh) return "mesh"; + if (obj instanceof THREE.Light) return "light"; + if (obj instanceof THREE.Camera) return "camera"; + if (obj instanceof THREE.Group) return "group"; + return "unknown"; +} From 76a09e403875d1fc5d9ad190fb0e56df455f1d09 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:35:13 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(babylon):=20headless=20BabylonAdapter?= =?UTF-8?q?=20=E2=80=94=20syncNode=20core=20kinds=20+=20describeNode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/runtime/babylon/adapter.test.ts | 124 +++++++++++++ src/runtime/babylon/adapter.ts | 265 ++++++++++++++++++++++++++++ 2 files changed, 389 insertions(+) create mode 100644 src/runtime/babylon/adapter.test.ts create mode 100644 src/runtime/babylon/adapter.ts diff --git a/src/runtime/babylon/adapter.test.ts b/src/runtime/babylon/adapter.test.ts new file mode 100644 index 0000000..e6d9079 --- /dev/null +++ b/src/runtime/babylon/adapter.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from "vitest"; + +import type { SceneNode } from "@/core/scene/types"; + +import { BabylonAdapter } from "./adapter"; + +const base = { + 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], + }, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, +}; + +function meshNode( + id: string, + kind: "box" | "sphere" = "box", + parent_id: string | null = null, +): SceneNode { + return { + ...base, + id, + name: id, + type: "mesh", + parent_id, + data: { type: "mesh", geometry: { kind } }, + }; +} + +describe("BabylonAdapter", () => { + it("has a babylon.js target", () => { + expect(new BabylonAdapter().target.kind).toBe("babylon.js"); + }); + + it("syncNode add → describeNode returns mesh info with transform", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...meshNode("m1", "sphere"), + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 2, 2] }, + visible: false, + }, + "add", + ); + const info = a.describeNode("m1"); + expect(info?.kind).toBe("mesh"); + expect(info?.geometryKind).toBe("sphere"); + expect(info?.position).toEqual([1, 2, 3]); + expect(info?.scale).toEqual([2, 2, 2]); + expect(info?.visible).toBe(false); + a.dispose(); + }); + + it("remove → describeNode null", () => { + const a = new BabylonAdapter(); + a.syncNode(meshNode("m1"), "add"); + a.syncNode(meshNode("m1"), "remove"); + expect(a.describeNode("m1")).toBeNull(); + a.dispose(); + }); + + it("parents a child under its parent's nodeId", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...base, + id: "g", + name: "g", + type: "group", + parent_id: null, + data: { type: "group" }, + }, + "add", + ); + a.syncNode(meshNode("c", "box", "g"), "add"); + expect(a.describeNode("c")?.parentId).toBe("g"); + a.dispose(); + }); + + it("maps light + camera subtypes", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...base, + id: "L", + name: "L", + type: "light", + parent_id: null, + data: { type: "light", light_kind: "point", color: "#fff", intensity: 1 }, + }, + "add", + ); + a.syncNode( + { + ...base, + id: "C", + name: "C", + type: "camera", + parent_id: null, + data: { type: "camera", camera_kind: "perspective", near: 0.1, far: 100 }, + }, + "add", + ); + expect(a.describeNode("L")).toMatchObject({ kind: "light", lightKind: "point" }); + expect(a.describeNode("C")).toMatchObject({ + kind: "camera", + cameraKind: "perspective", + }); + a.dispose(); + }); + + it("throws NotImplemented for pickAt / syncAsset / exportProject", async () => { + const a = new BabylonAdapter(); + expect(() => a.pickAt(0, 0)).toThrow(/not implemented/i); + await expect(a.syncAsset({} as never)).rejects.toThrow(/not implemented/i); + await expect(a.exportProject({} as never, {})).rejects.toThrow(/not implemented/i); + a.dispose(); + }); +}); diff --git a/src/runtime/babylon/adapter.ts b/src/runtime/babylon/adapter.ts new file mode 100644 index 0000000..8b49d87 --- /dev/null +++ b/src/runtime/babylon/adapter.ts @@ -0,0 +1,265 @@ +import { + Camera, + DirectionalLight, + HemisphericLight, + MeshBuilder, + NullEngine, + PointLight, + Quaternion, + Scene, + SpotLight, + TransformNode, + UniversalCamera, + Vector3, + type Node as BabylonNode, +} from "@babylonjs/core"; + +import type { + AssetReference, + BehaviorBinding, + NodeData, + RuntimeTarget, + SceneNode, + SceneProject, +} from "@/core/scene/types"; +import type { + BehaviorDefinition, + CodegenContext, + ExportOptions, + ExportResult, + IRuntimeAdapter, + RuntimeNodeInfo, + SyncOp, +} from "../adapter"; + +const BABYLON_TARGET: RuntimeTarget = { kind: "babylon.js", version: "9.11.0" }; + +interface NodeMeta { + nodeId: string; + kind: RuntimeNodeInfo["kind"]; + lightKind?: RuntimeNodeInfo["lightKind"]; + cameraKind?: RuntimeNodeInfo["cameraKind"]; + geometryKind?: RuntimeNodeInfo["geometryKind"]; +} + +function notImplemented(method: string): never { + throw new Error(`BabylonAdapter.${method}: not implemented in v1.0a`); +} + +/** + * Headless Babylon.js adapter (v1.0a). Implements the engine-neutral parts of + * IRuntimeAdapter — syncNode for group/mesh/light/camera + describeNode — to + * prove the contract holds for a second engine via the conformance suite. + * Runs on a NullEngine (no WebGL / canvas). Live-editor + export + behavior + * methods throw until later sub-stages. + */ +export class BabylonAdapter implements IRuntimeAdapter { + readonly target = BABYLON_TARGET; + private readonly engine = new NullEngine(); + private readonly scene = new Scene(this.engine); + private readonly objects = new Map(); + + syncNode(node: SceneNode, op: SyncOp): void { + if (op === "remove") { + const existing = this.objects.get(node.id); + if (existing) { + existing.dispose(); + this.objects.delete(node.id); + } + return; + } + if (op === "update") { + const existing = this.objects.get(node.id); + if (existing) { + applyBabylonTransform(existing, node); + existing.setEnabled(node.visible); + } + return; + } + if (this.objects.get(node.id)) { + throw new Error(`BabylonAdapter.syncNode: node ${node.id} already exists`); + } + const { object, meta } = this.create(node); + object.metadata = meta; + applyBabylonTransform(object, node); + object.setEnabled(node.visible); + if (node.parent_id !== null) { + const parent = this.objects.get(node.parent_id); + if (!parent) { + throw new Error( + `BabylonAdapter.syncNode: parent ${node.parent_id} not found for ${node.id}`, + ); + } + object.parent = parent; + } + this.objects.set(node.id, object); + } + + private create(node: SceneNode): { object: BabylonNode; meta: NodeMeta } { + const data = node.data; + switch (data.type) { + case "group": + return { + object: new TransformNode(node.name, this.scene), + meta: { nodeId: node.id, kind: "group" }, + }; + case "mesh": { + const kind = data.geometry?.kind ?? "box"; + return { + object: createMesh(node.name, kind, this.scene), + meta: { nodeId: node.id, kind: "mesh", geometryKind: kind }, + }; + } + case "light": + return { + object: createLight(node.name, data, this.scene), + meta: { nodeId: node.id, kind: "light", lightKind: data.light_kind }, + }; + case "camera": + return { + object: createCamera(node.name, data, this.scene), + meta: { nodeId: node.id, kind: "camera", cameraKind: data.camera_kind }, + }; + case "helper": + case "prefab_instance": + case "custom": + return { + object: new TransformNode(node.name, this.scene), + meta: { nodeId: node.id, kind: "unknown" }, + }; + } + } + + describeNode(node_id: string): RuntimeNodeInfo | null { + const obj = this.objects.get(node_id); + if (!obj) return null; + const meta = (obj.metadata ?? {}) as NodeMeta; + const t = obj as unknown as { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + scaling?: Vector3; + }; + const pos = t.position ?? Vector3.Zero(); + const q = t.rotationQuaternion; + const scl = t.scaling ?? new Vector3(1, 1, 1); + const parentMeta = obj.parent?.metadata as NodeMeta | undefined; + const info: RuntimeNodeInfo = { + kind: meta.kind ?? "unknown", + position: [pos.x, pos.y, pos.z], + rotation: q ? [q.x, q.y, q.z, q.w] : [0, 0, 0, 1], + scale: [scl.x, scl.y, scl.z], + visible: obj.isEnabled(false), + parentId: parentMeta?.nodeId ?? null, + }; + if (meta.lightKind) info.lightKind = meta.lightKind; + if (meta.cameraKind) info.cameraKind = meta.cameraKind; + if (meta.geometryKind) info.geometryKind = meta.geometryKind; + return info; + } + + getRuntimeObject(node_id: string): unknown { + return this.objects.get(node_id); + } + + dispose(): void { + this.scene.dispose(); + this.engine.dispose(); + this.objects.clear(); + } + + pickAt(_screen_x: number, _screen_y: number): string | null { + return notImplemented("pickAt"); + } + syncAsset(_asset: AssetReference): Promise { + return Promise.reject( + new Error("BabylonAdapter.syncAsset: not implemented in v1.0a"), + ); + } + exportProject( + _project: SceneProject, + _options: ExportOptions, + ): Promise { + return Promise.reject( + new Error("BabylonAdapter.exportProject: not implemented in v1.0a"), + ); + } + getSupportedBehaviors(): BehaviorDefinition[] { + return notImplemented("getSupportedBehaviors"); + } + generateBehaviorCode(_binding: BehaviorBinding, _context: CodegenContext): string { + return notImplemented("generateBehaviorCode"); + } +} + +function applyBabylonTransform(obj: BabylonNode, node: SceneNode): void { + const t = obj as unknown as { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + scaling?: Vector3; + }; + const [px, py, pz] = node.transform.position; + if (t.position) t.position.set(px, py, pz); + if ("rotationQuaternion" in obj) { + const [x, y, z, w] = node.transform.rotation; + t.rotationQuaternion = new Quaternion(x, y, z, w); + } + if (t.scaling) { + const [sx, sy, sz] = node.transform.scale; + t.scaling.set(sx, sy, sz); + } +} + +function createMesh( + name: string, + kind: NonNullable["geometry"]>["kind"], + scene: Scene, +) { + switch (kind) { + case "sphere": + return MeshBuilder.CreateSphere(name, { diameter: 1 }, scene); + case "plane": + return MeshBuilder.CreatePlane(name, { size: 1 }, scene); + case "cylinder": + return MeshBuilder.CreateCylinder(name, { height: 1, diameter: 1 }, scene); + case "box": + default: + return MeshBuilder.CreateBox(name, { size: 1 }, scene); + } +} + +function createLight( + name: string, + data: Extract, + scene: Scene, +): BabylonNode { + switch (data.light_kind) { + case "directional": + return new DirectionalLight(name, new Vector3(0, -1, 0), scene); + case "point": + return new PointLight(name, Vector3.Zero(), scene); + case "spot": + return new SpotLight( + name, + Vector3.Zero(), + new Vector3(0, -1, 0), + Math.PI / 4, + 1, + scene, + ); + case "ambient": + return new HemisphericLight(name, new Vector3(0, 1, 0), scene); + } +} + +function createCamera( + name: string, + data: Extract, + scene: Scene, +): BabylonNode { + const cam = new UniversalCamera(name, Vector3.Zero(), scene); + cam.mode = + data.camera_kind === "orthographic" + ? Camera.ORTHOGRAPHIC_CAMERA + : Camera.PERSPECTIVE_CAMERA; + return cam; +} From 25b7350deb10b1f73f39f08df4e4ca4c87e8e3e2 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:37:54 +0800 Subject: [PATCH 5/8] =?UTF-8?q?test(conformance):=20shared=20IRuntimeAdapt?= =?UTF-8?q?er=20suite=20=E2=80=94=20Three=20+=20Babylon=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/runtime/conformance/conformance-suite.ts | 210 +++++++++++++++++++ src/runtime/conformance/conformance.test.ts | 7 + 2 files changed, 217 insertions(+) create mode 100644 src/runtime/conformance/conformance-suite.ts create mode 100644 src/runtime/conformance/conformance.test.ts diff --git a/src/runtime/conformance/conformance-suite.ts b/src/runtime/conformance/conformance-suite.ts new file mode 100644 index 0000000..cd17131 --- /dev/null +++ b/src/runtime/conformance/conformance-suite.ts @@ -0,0 +1,210 @@ +import { describe, expect, it } from "vitest"; + +import type { SceneNode } from "@/core/scene/types"; +import type { IRuntimeAdapter } from "@/runtime/adapter"; + +const ID = { + 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, parent_id: null, ...baseFields, ...partial } as SceneNode; +} + +/** + * Run the engine-neutral adapter conformance assertions against an adapter + * factory. Call once per implementation; passing the same suite for two engines + * proves the IRuntimeAdapter contract holds across them. + * + * Transform (pos/rot/scale) is asserted on group/mesh only — Babylon lights are + * not TransformNodes (no scaling/rotationQuaternion), a documented cross-engine + * gap (spec §1.5); light/camera assert kind/subtype/visible/parentId. + */ +export function describeAdapterConformance( + makeAdapter: () => IRuntimeAdapter, + label: string, +): void { + describe(`IRuntimeAdapter conformance — ${label}`, () => { + it("describeNode is null before add and after remove", () => { + const a = makeAdapter(); + expect(a.describeNode("m")).toBeNull(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + expect(a.describeNode("m")).not.toBeNull(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "remove", + ); + expect(a.describeNode("m")).toBeNull(); + }); + + it("applies a mesh transform + visible", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 3, 4] }, + visible: false, + }), + "add", + ); + const info = a.describeNode("m")!; + expect(info.kind).toBe("mesh"); + expect(info.position).toEqual([1, 2, 3]); + expect(info.scale).toEqual([2, 3, 4]); + expect(info.visible).toBe(false); + }); + + it("applies a group transform (pos/scale)", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "g", + name: "g", + type: "group", + data: { type: "group" }, + transform: { position: [5, 0, -5], rotation: [0, 0, 0, 1], scale: [1, 2, 1] }, + }), + "add", + ); + const info = a.describeNode("g")!; + expect(info.kind).toBe("group"); + expect(info.position).toEqual([5, 0, -5]); + expect(info.scale).toEqual([1, 2, 1]); + }); + + it("reports parentId for a child under a group", () => { + const a = makeAdapter(); + a.syncNode( + node({ id: "g", name: "g", type: "group", data: { type: "group" } }), + "add", + ); + a.syncNode( + node({ + id: "c", + name: "c", + type: "mesh", + parent_id: "g", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + expect(a.describeNode("c")!.parentId).toBe("g"); + expect(a.describeNode("g")!.parentId).toBeNull(); + }); + + it("maps mesh geometry kinds", () => { + const a = makeAdapter(); + for (const kind of ["box", "sphere", "plane", "cylinder"] as const) { + a.syncNode( + node({ + id: kind, + name: kind, + type: "mesh", + data: { type: "mesh", geometry: { kind } }, + }), + "add", + ); + expect(a.describeNode(kind)!.geometryKind).toBe(kind); + } + }); + + it("maps light subtypes", () => { + const a = makeAdapter(); + for (const lk of ["directional", "point", "spot", "ambient"] as const) { + a.syncNode( + node({ + id: lk, + name: lk, + type: "light", + data: { type: "light", light_kind: lk, color: "#ffffff", intensity: 1 }, + }), + "add", + ); + const info = a.describeNode(lk)!; + expect(info.kind).toBe("light"); + expect(info.lightKind).toBe(lk); + } + }); + + it("maps camera kinds", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "p", + name: "p", + type: "camera", + data: { type: "camera", camera_kind: "perspective", near: 0.1, far: 100 }, + }), + "add", + ); + a.syncNode( + node({ + id: "o", + name: "o", + type: "camera", + data: { type: "camera", camera_kind: "orthographic", near: 0.1, far: 100 }, + }), + "add", + ); + expect(a.describeNode("p")).toMatchObject({ + kind: "camera", + cameraKind: "perspective", + }); + expect(a.describeNode("o")).toMatchObject({ + kind: "camera", + cameraKind: "orthographic", + }); + }); + + it("reflects an updated transform", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { position: [7, 8, 9], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + }), + "update", + ); + expect(a.describeNode("m")!.position).toEqual([7, 8, 9]); + }); + }); +} diff --git a/src/runtime/conformance/conformance.test.ts b/src/runtime/conformance/conformance.test.ts new file mode 100644 index 0000000..3f25a64 --- /dev/null +++ b/src/runtime/conformance/conformance.test.ts @@ -0,0 +1,7 @@ +import { BabylonAdapter } from "@/runtime/babylon/adapter"; +import { ThreeAdapter } from "@/runtime/three/adapter"; + +import { describeAdapterConformance } from "./conformance-suite"; + +describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter"); +describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter"); From f9807116fe4ac148c5c7b4265a9083216afe59cc Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:53:56 +0800 Subject: [PATCH 6/8] =?UTF-8?q?fix(adapter):=20review=20fixes=20=E2=80=94?= =?UTF-8?q?=20dispose()=20on=20contract,=20update=20parity,=20rotation=20c?= =?UTF-8?q?onformance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add dispose() to IRuntimeAdapter (both adapters already implement it); conformance suite now disposes adapters in afterEach (was leaking a NullEngine + Scene per Babylon test). - BabylonAdapter.syncNode('update') on a missing node now throws, matching ThreeAdapter — keeps the contract symmetric across engines; conformance asserts the parity. - Conformance now asserts the rotation round-trip on group/mesh (was position/scale only), closing a gap that masked engine rotation bugs. Co-Authored-By: Claude Opus 4.8 --- src/runtime/adapter.test.ts | 2 + src/runtime/adapter.ts | 5 ++ src/runtime/babylon/adapter.ts | 11 +++- src/runtime/conformance/conformance-suite.ts | 62 +++++++++++++++----- 4 files changed, 63 insertions(+), 17 deletions(-) diff --git a/src/runtime/adapter.test.ts b/src/runtime/adapter.test.ts index d5583b2..af4be46 100644 --- a/src/runtime/adapter.test.ts +++ b/src/runtime/adapter.test.ts @@ -49,6 +49,8 @@ class NoopAdapter implements IRuntimeAdapter { return { files: new Map(), warnings: [] }; } + dispose(): void {} + getSupportedBehaviors(): BehaviorDefinition[] { return [ { diff --git a/src/runtime/adapter.ts b/src/runtime/adapter.ts index edde00c..08fde3b 100644 --- a/src/runtime/adapter.ts +++ b/src/runtime/adapter.ts @@ -158,4 +158,9 @@ export interface IRuntimeAdapter { // ───── Behaviors ──────────────────────────────────────────────── getSupportedBehaviors(): BehaviorDefinition[]; generateBehaviorCode(binding: BehaviorBinding, context: CodegenContext): string; + + // ───── Lifecycle ──────────────────────────────────────────────── + /** Release all engine handles (renderer/scene/GPU resources). Both shipped + * adapters already implement this; the viewport calls it on teardown. */ + dispose(): void; } diff --git a/src/runtime/babylon/adapter.ts b/src/runtime/babylon/adapter.ts index 8b49d87..12cec8d 100644 --- a/src/runtime/babylon/adapter.ts +++ b/src/runtime/babylon/adapter.ts @@ -70,10 +70,15 @@ export class BabylonAdapter implements IRuntimeAdapter { } if (op === "update") { const existing = this.objects.get(node.id); - if (existing) { - applyBabylonTransform(existing, node); - existing.setEnabled(node.visible); + if (!existing) { + // Match ThreeAdapter: updating an unregistered node is a caller bug, + // not a silent no-op — keeps the contract symmetric across engines. + throw new Error( + `BabylonAdapter.syncNode: cannot update unknown node ${node.id}`, + ); } + applyBabylonTransform(existing, node); + existing.setEnabled(node.visible); return; } if (this.objects.get(node.id)) { diff --git a/src/runtime/conformance/conformance-suite.ts b/src/runtime/conformance/conformance-suite.ts index cd17131..b3487de 100644 --- a/src/runtime/conformance/conformance-suite.ts +++ b/src/runtime/conformance/conformance-suite.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import type { SceneNode } from "@/core/scene/types"; import type { IRuntimeAdapter } from "@/runtime/adapter"; @@ -8,6 +8,10 @@ const ID = { rotation: [0, 0, 0, 1] as [number, number, number, number], scale: [1, 1, 1] as [number, number, number], }; +/** Non-identity rotation (90° about Y) used to verify the transform round-trip + * on group/mesh — both engines store the quaternion verbatim (no normalize), + * so describeNode must read back these exact components. */ +const ROT_Y90: [number, number, number, number] = [0, Math.SQRT1_2, 0, Math.SQRT1_2]; const baseFields = { children_ids: [] as string[], visible: true, @@ -27,17 +31,35 @@ function node( * factory. Call once per implementation; passing the same suite for two engines * proves the IRuntimeAdapter contract holds across them. * - * Transform (pos/rot/scale) is asserted on group/mesh only — Babylon lights are - * not TransformNodes (no scaling/rotationQuaternion), a documented cross-engine - * gap (spec §1.5); light/camera assert kind/subtype/visible/parentId. + * Transform (pos/rot/scale) is asserted on group/mesh only. Known cross-engine + * gaps NOT asserted here (spec §1.5 + A1 known divergences): Babylon lights are + * not TransformNodes (no scaling/rotationQuaternion; HemisphericLight also has + * no position), and Babylon's UniversalCamera carries rotation as Euler (no + * rotationQuaternion) — so light/camera assert only kind/subtype/visible/ + * parentId. Camera/light orientation parity is deferred to when it becomes + * in-scope (A2/B). */ export function describeAdapterConformance( makeAdapter: () => IRuntimeAdapter, label: string, ): void { describe(`IRuntimeAdapter conformance — ${label}`, () => { + // Track every adapter a test creates so we can dispose them (Babylon holds + // a NullEngine + Scene per instance; leaking them across the suite would + // accumulate engines). make() is used in place of makeAdapter() directly. + let live: IRuntimeAdapter[] = []; + const make = (): IRuntimeAdapter => { + const adapter = makeAdapter(); + live.push(adapter); + return adapter; + }; + afterEach(() => { + for (const adapter of live) adapter.dispose(); + live = []; + }); + it("describeNode is null before add and after remove", () => { - const a = makeAdapter(); + const a = make(); expect(a.describeNode("m")).toBeNull(); a.syncNode( node({ @@ -62,14 +84,14 @@ export function describeAdapterConformance( }); it("applies a mesh transform + visible", () => { - const a = makeAdapter(); + const a = make(); a.syncNode( node({ id: "m", name: "m", type: "mesh", data: { type: "mesh", geometry: { kind: "box" } }, - transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 3, 4] }, + transform: { position: [1, 2, 3], rotation: ROT_Y90, scale: [2, 3, 4] }, visible: false, }), "add", @@ -77,30 +99,32 @@ export function describeAdapterConformance( const info = a.describeNode("m")!; expect(info.kind).toBe("mesh"); expect(info.position).toEqual([1, 2, 3]); + expect(info.rotation).toEqual(ROT_Y90); expect(info.scale).toEqual([2, 3, 4]); expect(info.visible).toBe(false); }); it("applies a group transform (pos/scale)", () => { - const a = makeAdapter(); + const a = make(); a.syncNode( node({ id: "g", name: "g", type: "group", data: { type: "group" }, - transform: { position: [5, 0, -5], rotation: [0, 0, 0, 1], scale: [1, 2, 1] }, + transform: { position: [5, 0, -5], rotation: ROT_Y90, scale: [1, 2, 1] }, }), "add", ); const info = a.describeNode("g")!; expect(info.kind).toBe("group"); expect(info.position).toEqual([5, 0, -5]); + expect(info.rotation).toEqual(ROT_Y90); expect(info.scale).toEqual([1, 2, 1]); }); it("reports parentId for a child under a group", () => { - const a = makeAdapter(); + const a = make(); a.syncNode( node({ id: "g", name: "g", type: "group", data: { type: "group" } }), "add", @@ -120,7 +144,7 @@ export function describeAdapterConformance( }); it("maps mesh geometry kinds", () => { - const a = makeAdapter(); + const a = make(); for (const kind of ["box", "sphere", "plane", "cylinder"] as const) { a.syncNode( node({ @@ -136,7 +160,7 @@ export function describeAdapterConformance( }); it("maps light subtypes", () => { - const a = makeAdapter(); + const a = make(); for (const lk of ["directional", "point", "spot", "ambient"] as const) { a.syncNode( node({ @@ -154,7 +178,7 @@ export function describeAdapterConformance( }); it("maps camera kinds", () => { - const a = makeAdapter(); + const a = make(); a.syncNode( node({ id: "p", @@ -184,7 +208,7 @@ export function describeAdapterConformance( }); it("reflects an updated transform", () => { - const a = makeAdapter(); + const a = make(); a.syncNode( node({ id: "m", @@ -206,5 +230,15 @@ export function describeAdapterConformance( ); expect(a.describeNode("m")!.position).toEqual([7, 8, 9]); }); + + it("syncNode('update') on a missing node throws (contract parity)", () => { + const a = make(); + expect(() => + a.syncNode( + node({ id: "ghost", name: "ghost", type: "group", data: { type: "group" } }), + "update", + ), + ).toThrow(); + }); }); } From 283c6a2bc3cb60368122b9feff5433bcddf749b5 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:55:04 +0800 Subject: [PATCH 7/8] =?UTF-8?q?docs(plan):=20v1.0=20sub-stage=20A1=20?= =?UTF-8?q?=E2=80=94=20=E5=A4=9A=E9=80=82=E9=85=8D=E5=99=A8=E5=A5=91?= =?UTF-8?q?=E7=BA=A6=20+=20conformance=20=E5=AE=9E=E6=96=BD=E8=AE=A1?= =?UTF-8?q?=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- ...09-v1.0a-multi-adapter-conformance-plan.md | 1038 +++++++++++++++++ 1 file changed, 1038 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-v1.0a-multi-adapter-conformance-plan.md diff --git a/docs/superpowers/plans/2026-06-09-v1.0a-multi-adapter-conformance-plan.md b/docs/superpowers/plans/2026-06-09-v1.0a-multi-adapter-conformance-plan.md new file mode 100644 index 0000000..89e53c5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-v1.0a-multi-adapter-conformance-plan.md @@ -0,0 +1,1038 @@ +# v1.0 sub-stage A1 · 多适配器契约 + Babylon headless + conformance 实现计划 + +> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。 + +**目标:** 把 `IRuntimeAdapter` 确立为引擎中立接缝——契约加 `describeNode(): RuntimeNodeInfo | null`(读引擎对象本身);新增 headless `BabylonAdapter`(Babylon `NullEngine`)实现 `syncNode` 核心 kind(group/mesh/light/camera);新增 conformance 套件让两个适配器跑同一批断言。 + +**架构:** `describeNode` 是新契约方法,两适配器各实现,读引擎对象的实际 transform/类型。conformance 套件 = 一个参数化 `describeAdapterConformance(makeAdapter, label)`,对 `() => new ThreeAdapter()` 与 `() => new BabylonAdapter()` 各跑一遍。纯 headless(NullEngine + THREE.Scene 都无需 WebGL),不接 UI。 + +**技术栈:** TypeScript + `@babylonjs/core`(NullEngine)+ three + vitest/jsdom + zod。 + +--- + +## 关键约束与发现(实施前必读) + +实施前已通读真实代码。spec:`docs/superpowers/specs/2026-06-09-v1.0a-multi-adapter-conformance-design.md`。 + +1. **首要风险(keystone)→ 任务 1 先 de-risk**:整个 A1 押在「`NullEngine` 能在 vitest/jsdom headless 构造」。任务 1 是最小验证测试,**不通则停下 BLOCKED 上报**,不要继续。 +2. **`RuntimeTargetSchema` 已支持 `babylon.js`**(`schemas.ts:31`):`{ kind: "babylon.js", version: string }`——**没有 `module_format` 字段**(与 three.js 不同)。BabylonAdapter 的 `target` 照此写。 +3. **没有 `NotImplementedYet` 类型**:项目无此约定。BabylonAdapter 未实现的方法直接 `throw new Error("BabylonAdapter.: not implemented in v1.0a")`(统一前缀),不要新建 error 类(YAGNI)。 +4. **Three 的 `describeNode` 数据来源全部现成**:`applyMeta`(`node-builders/index.ts:77`)设 `obj.userData.nodeId` + `obj.visible`;`applyTransform`(:71)设 `position`/`quaternion`/`scale`;`mesh.ts:45` 设 `obj.userData.geometryKind`。light/camera 子类型用 `instanceof` 判(three 里各 Light/Camera 子类是 `THREE.Light`/`THREE.Camera` 的**兄弟**,instanceof 顺序无关)。parent:`obj.parent?.userData.nodeId`。 +5. **跨引擎 transform 差异(建第二适配器暴露的抽象 gap,A1 范围决策)**:Babylon 的 **light 不是 TransformNode**(无 `scaling`/`rotationQuaternion`,只有 `position`),camera 有 position+rotation 但无 scaling。故 **conformance 只在 group+mesh 上断言完整 transform(pos/rot/scale)**;light/camera 只断言 kind/subtype/visible/parentId。`describeNode` 对 light/camera 的 rot/scale 走 best-effort(缺失则报 identity `[0,0,0,1]`/`[1,1,1]`)。这是 A1 记录的发现,不是 bug。 +6. **BabylonAdapter 不进 app bundle**:A1 只被 conformance 测试 + 自身测试 import,不被实时 app 代码 import,故 `@babylonjs/core` barrel import 无 bundle 体积顾虑(tree-shake 留到真正接 UI 时)。 +7. **执行前提**:已在分支 `feat/v1.0-babylon-adapter`(spec `edcd44c` 已在)。直接 `pnpm`/`git`(Node 20);commit 触发 husky+lint-staged。 + +--- + +## 文件结构 + +**新增** + +| 文件 | 职责 | +| ------------------------------------------------------ | -------------------------------------------------------------------------- | +| `src/runtime/babylon/headless.test.ts` | keystone:NullEngine headless 可构造验证 | +| `src/runtime/babylon/adapter.ts` (+ `adapter.test.ts`) | `BabylonAdapter`(syncNode 核心 kind + describeNode + 未实现 throw stubs) | +| `src/runtime/conformance/conformance-suite.ts` | 导出 `describeAdapterConformance(makeAdapter, label)` 共享套件 | +| `src/runtime/conformance/conformance.test.ts` | 对 Three + Babylon 各跑套件 | + +**修改** + +| 文件 | 改动 | +| ----------------------------------- | ------------------------------------------------------- | +| `package.json` | `@babylonjs/core` 依赖 | +| `src/runtime/adapter.ts` | `RuntimeNodeInfo` 类型 + `IRuntimeAdapter.describeNode` | +| `src/runtime/three/adapter.ts` | 实现 `describeNode` | +| `src/runtime/three/adapter.test.ts` | `describeNode` 单测 | + +依赖顺序:1→2→3→4→5(严格串行,2 改契约后 Three 必须同步实现,3 依赖 2 的接口,4 依赖 2+3)。 + +--- + +### 任务 1:keystone — @babylonjs/core 依赖 + NullEngine headless 验证 + +**文件:** 改 `package.json`(加依赖);新建 `src/runtime/babylon/headless.test.ts` + +- [ ] **步骤 1:装依赖** + +运行:`pnpm add @babylonjs/core` +预期:`package.json` 的 `dependencies` 出现 `@babylonjs/core`,`pnpm-lock.yaml` 更新,无报错。 + +- [ ] **步骤 2:写 keystone 验证测试** + +创建 `src/runtime/babylon/headless.test.ts`: + +```ts +import { NullEngine, Scene, TransformNode, Vector3 } from "@babylonjs/core"; +import { describe, expect, it } from "vitest"; + +// Keystone for v1.0a: the whole approach assumes Babylon's NullEngine can be +// constructed under vitest/jsdom with no WebGL / canvas. Prove it here before +// building the adapter on top. +describe("Babylon NullEngine headless", () => { + it("constructs an engine + scene + transform node and reads back a position", () => { + const engine = new NullEngine(); + const scene = new Scene(engine); + const node = new TransformNode("t", scene); + node.position = new Vector3(1, 2, 3); + expect([node.position.x, node.position.y, node.position.z]).toEqual([1, 2, 3]); + scene.dispose(); + engine.dispose(); + }); +}); +``` + +- [ ] **步骤 3:运行(keystone 判定)** + +运行:`pnpm vitest run src/runtime/babylon/headless.test.ts` +预期:PASS。 +**若 FAIL**(import 失败 / NullEngine 构造抛错 / jsdom 缺全局):**停下,以 BLOCKED 上报**,附完整错误。可先尝试:vite 配置 `test.deps.inline` 或 `optimizeDeps`、或给测试加最小 globals shim、或评估 `happy-dom`——但**不要**自行大改方案,先上报让控制者决定。这条不通则 A1 整体需回调。 + +- [ ] **步骤 4:Commit** + +```bash +git add package.json pnpm-lock.yaml src/runtime/babylon/headless.test.ts +git commit -m "test(babylon): keystone — NullEngine constructs headless under vitest" +``` + +--- + +### 任务 2:契约 `RuntimeNodeInfo` + `describeNode`(接口 + ThreeAdapter 实现) + +**文件:** 改 `src/runtime/adapter.ts`、`src/runtime/three/adapter.ts`;测试 `src/runtime/three/adapter.test.ts` + +> 加接口方法会让 ThreeAdapter 立刻编译失败,故契约 + Three 实现同一任务完成。 + +- [ ] **步骤 1:写失败的测试** + +在 `src/runtime/three/adapter.test.ts` 末尾追加(`target` 变量该文件已有;其余 fixture 用文件现有风格): + +```ts +describe("ThreeAdapter.describeNode", () => { + it("returns null for an unknown id", () => { + expect(new ThreeAdapter(target).describeNode("nope")).toBeNull(); + }); + + it("describes a mesh node: kind + geometryKind + transform + visible", () => { + const adapter = new ThreeAdapter(target); + adapter.syncNode( + { + id: "m1", + name: "m", + type: "mesh", + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 2, 2] }, + parent_id: null, + children_ids: [], + visible: false, + locked: false, + data: { type: "mesh", geometry: { kind: "sphere" } }, + behaviors: [], + user_data: {}, + }, + "add", + ); + const info = adapter.describeNode("m1"); + expect(info?.kind).toBe("mesh"); + expect(info?.geometryKind).toBe("sphere"); + expect(info?.position).toEqual([1, 2, 3]); + expect(info?.scale).toEqual([2, 2, 2]); + expect(info?.visible).toBe(false); + expect(info?.parentId).toBeNull(); + }); + + it("maps light + camera subtypes", () => { + const adapter = new ThreeAdapter(target); + adapter.syncNode( + { + id: "L", + name: "L", + type: "light", + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "light", light_kind: "spot", color: "#fff", intensity: 1 }, + behaviors: [], + user_data: {}, + }, + "add", + ); + adapter.syncNode( + { + id: "C", + name: "C", + type: "camera", + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + parent_id: null, + children_ids: [], + visible: true, + locked: false, + data: { type: "camera", camera_kind: "orthographic", near: 0.1, far: 100 }, + behaviors: [], + user_data: {}, + }, + "add", + ); + expect(adapter.describeNode("L")).toMatchObject({ + kind: "light", + lightKind: "spot", + }); + expect(adapter.describeNode("C")).toMatchObject({ + kind: "camera", + cameraKind: "orthographic", + }); + }); + + it("reports parentId from the parent's nodeId", () => { + const adapter = new ThreeAdapter(target); + const base = { + transform: { position: [0, 0, 0], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, + }; + adapter.syncNode( + { + ...base, + id: "g", + name: "g", + type: "group", + parent_id: null, + data: { type: "group" }, + }, + "add", + ); + adapter.syncNode( + { + ...base, + id: "c", + name: "c", + type: "group", + parent_id: "g", + data: { type: "group" }, + }, + "add", + ); + expect(adapter.describeNode("c")?.parentId).toBe("g"); + expect(adapter.describeNode("g")?.parentId).toBeNull(); + }); +}); +``` + +- [ ] **步骤 2:运行验证失败** + +运行:`pnpm vitest run src/runtime/three/adapter.test.ts -t describeNode` +预期:FAIL(`describeNode is not a function` / 类型缺失)。 + +- [ ] **步骤 3:契约加类型 + 方法(`src/runtime/adapter.ts`)** + +在 `SyncOp` 定义之后(或文件类型区合适处)加: + +```ts +/** + * Engine-neutral snapshot of a synced node's runtime object. Conformance + * asserts on this; it also seams a future inspector panel / AI scene read. + * Reads the engine object's actual state, not the input SceneNode. + */ +export interface RuntimeNodeInfo { + kind: "group" | "mesh" | "light" | "camera" | "helper" | "unknown"; + position: [number, number, number]; + rotation: [number, number, number, number]; + scale: [number, number, number]; + visible: boolean; + parentId: string | null; + lightKind?: "directional" | "point" | "spot" | "ambient"; + cameraKind?: "perspective" | "orthographic"; + geometryKind?: "box" | "sphere" | "plane" | "cylinder"; +} +``` + +在 `IRuntimeAdapter` 里、`getRuntimeObject` 那行附近加: + +```ts + describeNode(node_id: string): RuntimeNodeInfo | null; +``` + +- [ ] **步骤 4:ThreeAdapter 实现(`src/runtime/three/adapter.ts`)** + +import 区确保有 `RuntimeNodeInfo`(与现有 `from "../adapter"` 类型同组导入)。在 `getRuntimeObject` 方法后加: + +```ts + describeNode(node_id: string): RuntimeNodeInfo | null { + const obj = this.objects.get(node_id); + if (!obj) return null; + const parent = obj.parent; + const parentNodeId = + parent && typeof parent.userData.nodeId === "string" + ? parent.userData.nodeId + : null; + const info: RuntimeNodeInfo = { + kind: threeRuntimeKind(obj), + position: [obj.position.x, obj.position.y, obj.position.z], + rotation: [obj.quaternion.x, obj.quaternion.y, obj.quaternion.z, obj.quaternion.w], + scale: [obj.scale.x, obj.scale.y, obj.scale.z], + visible: obj.visible, + parentId: parentNodeId, + }; + if (obj instanceof THREE.Mesh && typeof obj.userData.geometryKind === "string") { + info.geometryKind = obj.userData.geometryKind as RuntimeNodeInfo["geometryKind"]; + } + if (obj instanceof THREE.DirectionalLight) info.lightKind = "directional"; + else if (obj instanceof THREE.SpotLight) info.lightKind = "spot"; + else if (obj instanceof THREE.PointLight) info.lightKind = "point"; + else if (obj instanceof THREE.AmbientLight) info.lightKind = "ambient"; + if (obj instanceof THREE.PerspectiveCamera) info.cameraKind = "perspective"; + else if (obj instanceof THREE.OrthographicCamera) info.cameraKind = "orthographic"; + return info; + } +``` + +在文件底部(与现有模块级 helper 同区,如 `findNodeId` 附近)加: + +```ts +function threeRuntimeKind(obj: THREE.Object3D): RuntimeNodeInfo["kind"] { + if (obj instanceof THREE.Mesh) return "mesh"; + if (obj instanceof THREE.Light) return "light"; + if (obj instanceof THREE.Camera) return "camera"; + if (obj instanceof THREE.Group) return "group"; + return "unknown"; +} +``` + +(注意 `instanceof` 顺序:Mesh/Light/Camera/Group 互为兄弟,先判具体类;prefab_instance 建的是 Group → 报 "group",A1 conformance 不覆盖它,无碍。) + +- [ ] **步骤 5:运行验证通过** + +运行:`pnpm vitest run src/runtime/three/adapter.test.ts && pnpm typecheck` +预期:PASS(含新 4 个 describeNode 用例)+ typecheck 0 error。 + +- [ ] **步骤 6:Commit** + +```bash +git add src/runtime/adapter.ts src/runtime/three/adapter.ts src/runtime/three/adapter.test.ts +git commit -m "feat(adapter): RuntimeNodeInfo + describeNode contract; ThreeAdapter impl" +``` + +--- + +### 任务 3:BabylonAdapter + +**文件:** 创建 `src/runtime/babylon/adapter.ts`、`src/runtime/babylon/adapter.test.ts` + +- [ ] **步骤 1:写失败的测试** + +创建 `src/runtime/babylon/adapter.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import type { SceneNode } from "@/core/scene/types"; + +import { BabylonAdapter } from "./adapter"; + +const base = { + 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], + }, + children_ids: [] as string[], + visible: true, + locked: false, + behaviors: [], + user_data: {}, +}; + +function meshNode( + id: string, + kind: "box" | "sphere" = "box", + parent_id: string | null = null, +): SceneNode { + return { + ...base, + id, + name: id, + type: "mesh", + parent_id, + data: { type: "mesh", geometry: { kind } }, + }; +} + +describe("BabylonAdapter", () => { + it("has a babylon.js target", () => { + expect(new BabylonAdapter().target.kind).toBe("babylon.js"); + }); + + it("syncNode add → describeNode returns mesh info with transform", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...meshNode("m1", "sphere"), + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 2, 2] }, + visible: false, + }, + "add", + ); + const info = a.describeNode("m1"); + expect(info?.kind).toBe("mesh"); + expect(info?.geometryKind).toBe("sphere"); + expect(info?.position).toEqual([1, 2, 3]); + expect(info?.scale).toEqual([2, 2, 2]); + expect(info?.visible).toBe(false); + a.dispose(); + }); + + it("remove → describeNode null", () => { + const a = new BabylonAdapter(); + a.syncNode(meshNode("m1"), "add"); + a.syncNode(meshNode("m1"), "remove"); + expect(a.describeNode("m1")).toBeNull(); + a.dispose(); + }); + + it("parents a child under its parent's nodeId", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...base, + id: "g", + name: "g", + type: "group", + parent_id: null, + data: { type: "group" }, + }, + "add", + ); + a.syncNode(meshNode("c", "box", "g"), "add"); + expect(a.describeNode("c")?.parentId).toBe("g"); + a.dispose(); + }); + + it("maps light + camera subtypes", () => { + const a = new BabylonAdapter(); + a.syncNode( + { + ...base, + id: "L", + name: "L", + type: "light", + parent_id: null, + data: { type: "light", light_kind: "point", color: "#fff", intensity: 1 }, + }, + "add", + ); + a.syncNode( + { + ...base, + id: "C", + name: "C", + type: "camera", + parent_id: null, + data: { type: "camera", camera_kind: "perspective", near: 0.1, far: 100 }, + }, + "add", + ); + expect(a.describeNode("L")).toMatchObject({ kind: "light", lightKind: "point" }); + expect(a.describeNode("C")).toMatchObject({ + kind: "camera", + cameraKind: "perspective", + }); + a.dispose(); + }); + + it("throws NotImplemented for pickAt / syncAsset / exportProject", async () => { + const a = new BabylonAdapter(); + expect(() => a.pickAt(0, 0)).toThrow(/not implemented/i); + await expect(a.syncAsset({} as never)).rejects.toThrow(/not implemented/i); + await expect(a.exportProject({} as never, {})).rejects.toThrow(/not implemented/i); + a.dispose(); + }); +}); +``` + +- [ ] **步骤 2:运行验证失败** + +运行:`pnpm vitest run src/runtime/babylon/adapter.test.ts` +预期:FAIL(模块不存在)。 + +- [ ] **步骤 3:实现 `src/runtime/babylon/adapter.ts`** + +```ts +import { + Camera, + DirectionalLight, + HemisphericLight, + MeshBuilder, + NullEngine, + PointLight, + Quaternion, + Scene, + SpotLight, + TransformNode, + UniversalCamera, + Vector3, + type Node as BabylonNode, +} from "@babylonjs/core"; + +import type { + AssetReference, + BehaviorBinding, + NodeData, + RuntimeTarget, + SceneNode, + SceneProject, +} from "@/core/scene/types"; +import type { + BehaviorDefinition, + CodegenContext, + ExportOptions, + ExportResult, + IRuntimeAdapter, + RuntimeNodeInfo, + SyncOp, +} from "../adapter"; + +const BABYLON_TARGET: RuntimeTarget = { kind: "babylon.js", version: "8.0.0" }; + +interface NodeMeta { + nodeId: string; + kind: RuntimeNodeInfo["kind"]; + lightKind?: RuntimeNodeInfo["lightKind"]; + cameraKind?: RuntimeNodeInfo["cameraKind"]; + geometryKind?: RuntimeNodeInfo["geometryKind"]; +} + +function notImplemented(method: string): never { + throw new Error(`BabylonAdapter.${method}: not implemented in v1.0a`); +} + +/** + * Headless Babylon.js adapter (v1.0a). Implements the engine-neutral parts of + * IRuntimeAdapter — syncNode for group/mesh/light/camera + describeNode — to + * prove the contract holds for a second engine via the conformance suite. + * Runs on a NullEngine (no WebGL / canvas). Live-editor + export + behavior + * methods throw until later sub-stages. + */ +export class BabylonAdapter implements IRuntimeAdapter { + readonly target = BABYLON_TARGET; + private readonly engine = new NullEngine(); + private readonly scene = new Scene(this.engine); + private readonly objects = new Map(); + + syncNode(node: SceneNode, op: SyncOp): void { + if (op === "remove") { + const existing = this.objects.get(node.id); + if (existing) { + existing.dispose(); + this.objects.delete(node.id); + } + return; + } + if (op === "update") { + const existing = this.objects.get(node.id); + if (existing) { + applyBabylonTransform(existing, node); + existing.setEnabled(node.visible); + } + return; + } + // add + if (this.objects.get(node.id)) { + throw new Error(`BabylonAdapter.syncNode: node ${node.id} already exists`); + } + const { object, meta } = this.create(node); + object.metadata = meta; + applyBabylonTransform(object, node); + object.setEnabled(node.visible); + if (node.parent_id !== null) { + const parent = this.objects.get(node.parent_id); + if (!parent) { + throw new Error( + `BabylonAdapter.syncNode: parent ${node.parent_id} not found for ${node.id}`, + ); + } + object.parent = parent; + } + this.objects.set(node.id, object); + } + + private create(node: SceneNode): { object: BabylonNode; meta: NodeMeta } { + const data = node.data; + switch (data.type) { + case "group": + return { + object: new TransformNode(node.name, this.scene), + meta: { nodeId: node.id, kind: "group" }, + }; + case "mesh": { + const kind = data.geometry?.kind ?? "box"; + return { + object: createMesh(node.name, kind, this.scene), + meta: { nodeId: node.id, kind: "mesh", geometryKind: kind }, + }; + } + case "light": + return { + object: createLight(node.name, data, this.scene), + meta: { nodeId: node.id, kind: "light", lightKind: data.light_kind }, + }; + case "camera": + return { + object: createCamera(node.name, data, this.scene), + meta: { nodeId: node.id, kind: "camera", cameraKind: data.camera_kind }, + }; + case "helper": + case "prefab_instance": + case "custom": + // Not modelled in v1.0a — represent as a bare TransformNode so the tree + // stays intact; describeNode reports kind "unknown". + return { + object: new TransformNode(node.name, this.scene), + meta: { nodeId: node.id, kind: "unknown" }, + }; + } + } + + describeNode(node_id: string): RuntimeNodeInfo | null { + const obj = this.objects.get(node_id); + if (!obj) return null; + const meta = (obj.metadata ?? {}) as NodeMeta; + const t = obj as unknown as { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + scaling?: Vector3; + }; + const pos = t.position ?? Vector3.Zero(); + const q = t.rotationQuaternion; + const scl = t.scaling ?? new Vector3(1, 1, 1); + const parentMeta = obj.parent?.metadata as NodeMeta | undefined; + const info: RuntimeNodeInfo = { + kind: meta.kind ?? "unknown", + position: [pos.x, pos.y, pos.z], + rotation: q ? [q.x, q.y, q.z, q.w] : [0, 0, 0, 1], + scale: [scl.x, scl.y, scl.z], + visible: obj.isEnabled(false), + parentId: parentMeta?.nodeId ?? null, + }; + if (meta.lightKind) info.lightKind = meta.lightKind; + if (meta.cameraKind) info.cameraKind = meta.cameraKind; + if (meta.geometryKind) info.geometryKind = meta.geometryKind; + return info; + } + + getRuntimeObject(node_id: string): unknown { + return this.objects.get(node_id); + } + + dispose(): void { + this.scene.dispose(); + this.engine.dispose(); + this.objects.clear(); + } + + // ── Not implemented in v1.0a (later sub-stages) ─────────────────── + pickAt(): string | null { + return notImplemented("pickAt"); + } + syncAsset(_asset: AssetReference): Promise { + return notImplemented("syncAsset"); + } + exportProject( + _project: SceneProject, + _options: ExportOptions, + ): Promise { + return notImplemented("exportProject"); + } + getSupportedBehaviors(): BehaviorDefinition[] { + return notImplemented("getSupportedBehaviors"); + } + generateBehaviorCode(_binding: BehaviorBinding, _context: CodegenContext): string { + return notImplemented("generateBehaviorCode"); + } +} + +function applyBabylonTransform(obj: BabylonNode, node: SceneNode): void { + const t = obj as unknown as { + position?: Vector3; + rotationQuaternion?: Quaternion | null; + scaling?: Vector3; + }; + const [px, py, pz] = node.transform.position; + if (t.position) t.position.set(px, py, pz); + // Lights/cameras may lack rotationQuaternion/scaling — only set where present. + if ("rotationQuaternion" in obj) { + const [x, y, z, w] = node.transform.rotation; + t.rotationQuaternion = new Quaternion(x, y, z, w); + } + if (t.scaling) { + const [sx, sy, sz] = node.transform.scale; + t.scaling.set(sx, sy, sz); + } +} + +function createMesh( + name: string, + kind: NonNullable["geometry"]>["kind"], + scene: Scene, +) { + switch (kind) { + case "sphere": + return MeshBuilder.CreateSphere(name, { diameter: 1 }, scene); + case "plane": + return MeshBuilder.CreatePlane(name, { size: 1 }, scene); + case "cylinder": + return MeshBuilder.CreateCylinder(name, { height: 1, diameter: 1 }, scene); + case "box": + default: + return MeshBuilder.CreateBox(name, { size: 1 }, scene); + } +} + +function createLight( + name: string, + data: Extract, + scene: Scene, +): BabylonNode { + switch (data.light_kind) { + case "directional": + return new DirectionalLight(name, new Vector3(0, -1, 0), scene); + case "point": + return new PointLight(name, Vector3.Zero(), scene); + case "spot": + return new SpotLight( + name, + Vector3.Zero(), + new Vector3(0, -1, 0), + Math.PI / 4, + 1, + scene, + ); + case "ambient": + return new HemisphericLight(name, new Vector3(0, 1, 0), scene); + } +} + +function createCamera( + name: string, + data: Extract, + scene: Scene, +): BabylonNode { + const cam = new UniversalCamera(name, Vector3.Zero(), scene); + cam.mode = + data.camera_kind === "orthographic" + ? Camera.ORTHOGRAPHIC_CAMERA + : Camera.PERSPECTIVE_CAMERA; + return cam; +} +``` + +- [ ] **步骤 4:运行验证通过** + +运行:`pnpm vitest run src/runtime/babylon/adapter.test.ts && pnpm typecheck` +预期:PASS(6 用例)+ typecheck 0 error。 +(若某 Babylon 构造 API 签名与上略有出入导致编译/运行错,按 `@babylonjs/core` 8.x 实际签名微调构造参数——保持行为不变;若卡住超过两次尝试,BLOCKED 上报。) + +- [ ] **步骤 5:Commit** + +```bash +git add src/runtime/babylon/adapter.ts src/runtime/babylon/adapter.test.ts +git commit -m "feat(babylon): headless BabylonAdapter — syncNode core kinds + describeNode" +``` + +--- + +### 任务 4:conformance 套件(双适配器) + +**文件:** 创建 `src/runtime/conformance/conformance-suite.ts`、`src/runtime/conformance/conformance.test.ts` + +- [ ] **步骤 1:写共享套件 `conformance-suite.ts`** + +```ts +import { describe, expect, it } from "vitest"; + +import type { SceneNode } from "@/core/scene/types"; +import type { IRuntimeAdapter } from "@/runtime/adapter"; + +const ID = { + 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, parent_id: null, ...baseFields, ...partial } as SceneNode; +} + +/** + * Run the engine-neutral adapter conformance assertions against an adapter + * factory. Call once per implementation; passing the same suite for two engines + * proves the IRuntimeAdapter contract holds across them. + * + * Transform (pos/rot/scale) is asserted on group/mesh only — Babylon lights are + * not TransformNodes (no scaling/rotationQuaternion), a documented cross-engine + * gap (spec §1.5); light/camera assert kind/subtype/visible/parentId. + */ +export function describeAdapterConformance( + makeAdapter: () => IRuntimeAdapter, + label: string, +): void { + describe(`IRuntimeAdapter conformance — ${label}`, () => { + it("describeNode is null before add and after remove", () => { + const a = makeAdapter(); + expect(a.describeNode("m")).toBeNull(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + expect(a.describeNode("m")).not.toBeNull(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "remove", + ); + expect(a.describeNode("m")).toBeNull(); + }); + + it("applies a mesh transform + visible", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { position: [1, 2, 3], rotation: [0, 0, 0, 1], scale: [2, 3, 4] }, + visible: false, + }), + "add", + ); + const info = a.describeNode("m")!; + expect(info.kind).toBe("mesh"); + expect(info.position).toEqual([1, 2, 3]); + expect(info.scale).toEqual([2, 3, 4]); + expect(info.visible).toBe(false); + }); + + it("applies a group transform (pos/scale)", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "g", + name: "g", + type: "group", + data: { type: "group" }, + transform: { position: [5, 0, -5], rotation: [0, 0, 0, 1], scale: [1, 2, 1] }, + }), + "add", + ); + const info = a.describeNode("g")!; + expect(info.kind).toBe("group"); + expect(info.position).toEqual([5, 0, -5]); + expect(info.scale).toEqual([1, 2, 1]); + }); + + it("reports parentId for a child under a group", () => { + const a = makeAdapter(); + a.syncNode( + node({ id: "g", name: "g", type: "group", data: { type: "group" } }), + "add", + ); + a.syncNode( + node({ + id: "c", + name: "c", + type: "mesh", + parent_id: "g", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + expect(a.describeNode("c")!.parentId).toBe("g"); + expect(a.describeNode("g")!.parentId).toBeNull(); + }); + + it("maps mesh geometry kinds", () => { + const a = makeAdapter(); + for (const kind of ["box", "sphere", "plane", "cylinder"] as const) { + a.syncNode( + node({ + id: kind, + name: kind, + type: "mesh", + data: { type: "mesh", geometry: { kind } }, + }), + "add", + ); + expect(a.describeNode(kind)!.geometryKind).toBe(kind); + } + }); + + it("maps light subtypes", () => { + const a = makeAdapter(); + for (const lk of ["directional", "point", "spot", "ambient"] as const) { + a.syncNode( + node({ + id: lk, + name: lk, + type: "light", + data: { type: "light", light_kind: lk, color: "#ffffff", intensity: 1 }, + }), + "add", + ); + const info = a.describeNode(lk)!; + expect(info.kind).toBe("light"); + expect(info.lightKind).toBe(lk); + } + }); + + it("maps camera kinds", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "p", + name: "p", + type: "camera", + data: { type: "camera", camera_kind: "perspective", near: 0.1, far: 100 }, + }), + "add", + ); + a.syncNode( + node({ + id: "o", + name: "o", + type: "camera", + data: { type: "camera", camera_kind: "orthographic", near: 0.1, far: 100 }, + }), + "add", + ); + expect(a.describeNode("p")).toMatchObject({ + kind: "camera", + cameraKind: "perspective", + }); + expect(a.describeNode("o")).toMatchObject({ + kind: "camera", + cameraKind: "orthographic", + }); + }); + + it("reflects an updated transform", () => { + const a = makeAdapter(); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + }), + "add", + ); + a.syncNode( + node({ + id: "m", + name: "m", + type: "mesh", + data: { type: "mesh", geometry: { kind: "box" } }, + transform: { position: [7, 8, 9], rotation: [0, 0, 0, 1], scale: [1, 1, 1] }, + }), + "update", + ); + expect(a.describeNode("m")!.position).toEqual([7, 8, 9]); + }); + }); +} +``` + +- [ ] **步骤 2:写 `conformance.test.ts` 跑双适配器** + +```ts +import { BabylonAdapter } from "@/runtime/babylon/adapter"; +import { ThreeAdapter } from "@/runtime/three/adapter"; + +import { describeAdapterConformance } from "./conformance-suite"; + +describeAdapterConformance(() => new ThreeAdapter(), "ThreeAdapter"); +describeAdapterConformance(() => new BabylonAdapter(), "BabylonAdapter"); +``` + +- [ ] **步骤 3:运行验证** + +运行:`pnpm vitest run src/runtime/conformance/conformance.test.ts` +预期:PASS——**两个 label(ThreeAdapter / BabylonAdapter)下所有用例全绿** = 契约对第二引擎成立。 +(若某用例在 Babylon 下因构造默认值/单位差异失败:先确认是断言写法问题还是真实映射差异。若是 spec §1.5 已知的 light transform 类差异——本套件已只对 group/mesh 断言 transform,不应触发;其它差异如确属实现 bug 则修 BabylonAdapter。卡住超两次 BLOCKED 上报。) + +- [ ] **步骤 4:Commit** + +```bash +git add src/runtime/conformance/conformance-suite.ts src/runtime/conformance/conformance.test.ts +git commit -m "test(conformance): shared IRuntimeAdapter suite — Three + Babylon parity" +``` + +--- + +### 任务 5:全量验证 + 收尾 + +- [ ] **步骤 1:全绿** + +运行:`pnpm lint && pnpm typecheck && pnpm test` +预期:0 error / 全绿。**无视觉验证**(A1 纯 headless,不接 UI——这是 spec 明确的)。 + +- [ ] **步骤 2:收尾** + +用 superpowers:finishing-a-development-branch:commit plan + roadmap(v1.0 标 in-progress、sub-stage A1 完成 + PR 链接、把 A2/A3/B 记进结构)+ 开 PR。PR 描述写明:A1 验证了什么(契约 + 双适配器 conformance)、§8 为 B 铺的边界清单、无视觉验证的理由。 + +--- + +## 自检 + +**1. 规格覆盖度(spec §1 成功标准逐条):** + +- conformance 双适配器全绿(add/remove/父子/transform/kind 映射/visible/geometry/update)→ 任务 4 ✓ +- `describeNode` 读引擎对象 → 任务 2(Three:obj.position/quaternion/userData.geometryKind/instanceof)+ 任务 3(Babylon:obj.position/metadata)✓ +- Babylon 未实现方法抛错 → 任务 3(`notImplemented` + 测试断言 `/not implemented/i`)✓ +- lint/typecheck/test 全绿、无视觉 → 任务 5 ✓ +- keystone(NullEngine headless)→ 任务 1 ✓ + +**2. 占位符扫描:** 各任务含完整可粘贴代码 + 精确命令 + 预期;无 TODO/待定。任务 3 步骤 4、任务 4 步骤 3 留了"API 签名微调/失败定位"的应对指引(不是占位,是对真实第三方库不确定性的有界处理 + BLOCKED 阈值)。✓ + +**3. 类型一致性:** + +- `RuntimeNodeInfo`(任务 2 定义)→ 任务 3 BabylonAdapter、任务 4 conformance 断言字段一致(kind/position/rotation/scale/visible/parentId/lightKind/cameraKind/geometryKind)✓ +- `describeNode(node_id): RuntimeNodeInfo | null`(任务 2 契约)→ 任务 3 实现、任务 4 调用同签名 ✓ +- `BabylonAdapter` 实现 `IRuntimeAdapter` 全部方法(syncNode/syncAsset/getRuntimeObject/pickAt/describeNode/exportProject/getSupportedBehaviors/generateBehaviorCode)——未实现的抛错但**方法存在**,满足接口 ✓ +- `RuntimeTarget` babylon 变体 `{kind,version}` 无 module_format(spec §约束 2)→ 任务 3 `BABYLON_TARGET` 一致 ✓ +- conformance fixture 的 SceneNode 字段(含 camera 必填 `near`/`far`)与 schema 一致 ✓ + +**4. 边界:** 未知 id→null、remove→null、父未注册→抛、未建 kind(helper/prefab/custom)→ Babylon 报 unknown(A1 不覆盖)、light/camera transform 跨引擎差异→ conformance 不在其上断言 transform。均覆盖。 + +发现即修,无遗留。 + +--- + +## 执行交接 + +计划已保存到 `docs/superpowers/plans/2026-06-09-v1.0a-multi-adapter-conformance-plan.md`。两种执行方式: + +1. **子代理驱动(推荐)** —— 每任务一个新子代理;任务 1 是 keystone(不通即停)、2-4 机械 TDD、5 收尾。 +2. **内联执行** —— 当前会话用 executing-plans 批量执行 + 检查点。 + +选哪种? From a6794f5c74fa213018161d03f666b6b1a5411272 Mon Sep 17 00:00:00 2001 From: longyi-xw <2691049525@qq.com> Date: Tue, 9 Jun 2026 17:55:06 +0800 Subject: [PATCH 8/8] =?UTF-8?q?docs(roadmap):=20v1.0=20in=20progress=20?= =?UTF-8?q?=E2=80=94=20A1=20=E5=A4=9A=E9=80=82=E9=85=8D=E5=99=A8=E5=A5=91?= =?UTF-8?q?=E7=BA=A6+conformance=20=E5=AE=8C=E6=88=90=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- docs/roadmap.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/roadmap.md b/docs/roadmap.md index ad35528..047e753 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -115,9 +115,14 @@ - **Depends on**: v0.3 release - **后续**:A 网格(#33)+ B 节点对齐(#34)+ 资源拖拽(#35)+ C Socket 系统地基(#36)均已完成,v0.4 收口。Socket 后续阶段(朝向咬合 / 规则引擎 / glb 内嵌识别 / 点选放置 / 类型库)见 Backlog。 -### v1.0 — Planned +### v1.0 — In progress(多适配器;拆为 A1/A2/A3 + B) -- **Goals**: 多运行时适配器(架构 §7 v1.0)。Babylon.js 适配器实现,验证 `IRuntimeAdapter` 抽象在第二个引擎下能跑;同时落地 adapter conformance test 套件。 +- **Goals**: 多运行时适配器(架构 §7 v1.0)。把 `IRuntimeAdapter` 确立为引擎中立接缝,用第二个引擎(Babylon.js)+ conformance 套件验证抽象成立;最终支持实时切换渲染引擎、新引擎最小改动接入(项目本质:多框架低代码 3D 平台)。 +- **Sub-stages**: + - [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,不接实时视口。 + - [ ] **A2 · behaviors 跨引擎**:install/tick + behavior codegen 在 Babylon 对等。 + - [ ] **A3 · glTF + 导出**:prefab_instance/glTF 加载 + `babylon` 导出 target。 + - [ ] **B · 实时视口切引擎**:抽 `IRenderHost`(渲染循环/相机控制/gizmo/拾取/outline),ThreeViewport 改为面向它,Babylon 渲染宿主落地,用户可把实时编辑器切到 Babylon。spec §8 已备边界清单。 - **Depends on**: v0.5 行为系统全部完成(v0.5 Stage C) ### v1.x — Planned @@ -131,6 +136,7 @@ - **材质贴图 pipeline**(从 v0.2 材质编辑拆出):normalMap / map / roughnessMap / metalnessMap / aoMap 等。需 `MaterialOverrideSchema` 加贴图字段(引用 `kind:"texture"` 的 `AssetReference`)+ texture 资源上传入库 + runtime `TextureLoader`(`mesh.ts applyOverrides` 加贴图通道)+ codegen emit `TextureLoader().load("./assets/…")` + UI 贴图选择器。独立子系统,单独 spec→plan→实现。 - **Socket 系统后续阶段**(从 v0.4 sub-stage C 地基拆出):本期只做位置对齐 + tag 兼容 + 面板手填。延后(各自独立 spec→plan→实现):**朝向咬合**(socket 带 normal/up,转动节点让两 socket 面对面「插上」)、**模型特性驱动的对齐规则引擎**(超越 tag 精确匹配)、**glb 内嵌 socket 自动识别**(importer 读 glTF 命名空节点)、**视口点选放置 socket**(raycast 模型表面落点)、**socket 类型库 / male-female 配对 / 批量拼装**、**socket 标记 LOD / 合批**(本期每次拖拽全量重建标记,大场景需按节点索引只刷被拖节点——视觉验证审查记录的次要优化项)。 +- **Babylon 适配器跨引擎差异(A1 记录,待 in-scope 时对齐)**:A1 conformance 只在 group/mesh 断言完整 transform,已知未对齐项延后到各自变 in-scope 时处理——(a) **camera 朝向**:Babylon `UniversalCamera` 用 Euler `.rotation`(无 `rotationQuaternion`),`applyBabylonTransform` 当前跳过其旋转;(b) **ambient light**:映射为 `HemisphericLight`,无 `position`,落点被丢;(c) **prefab_instance kind**:ThreeAdapter 占位报 `mesh`、BabylonAdapter 报 `unknown`(A3 加 glTF 时统一)。camera/light 朝向并入 A2/B,prefab 并入 A3。 - **多源资源上传**(从 v0.2 资源库拆出):`AssetSourceSchema` 已含 `builtin/user_upload/online/ai_generated` 且 catalog 来源无关;目前只实装 `builtin`(几何/灯光预设)+ `user_upload`(.glb)。延后:`online`(在线模型/材质库浏览+下载入库)、`ai_generated`(AI 生成模型/贴图)。 - **多材质槽**(从 v0.2 材质编辑拆出):本期材质编辑只支持 slot 0;prefab/glTF 多材质对象的 slot 1+ 延后。 - **agentic 多轮 Skill 执行**(从 v0.3 sub-stage B 拆出):本期 Skill 是**单轮结构化输出**(NL→LLM 一次返回 operations→Command)。延后:架构 §4.3 的 `call_tool` / `allowed_tools` 多轮 agent 循环(LLM 多轮调工具、读场景、迭代纠错)+ `SkillContext.memory`(`MemoryStore` 项目暂无实现)。用户明确「多轮后续肯定要完善」。