lecodes-cli 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +26 -10
- package/package.json +1 -1
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/scene/defineScene.ts +221 -109
- package/runtime/sdk/scene/editorPlugins.ts +22 -16
- package/runtime/sdk/scene/grammar.ts +49 -14
package/dist/index.js
CHANGED
|
@@ -45213,7 +45213,7 @@ var require_main = __commonJS((exports, module) => {
|
|
|
45213
45213
|
// package.json
|
|
45214
45214
|
var package_default = {
|
|
45215
45215
|
name: "lecodes-cli",
|
|
45216
|
-
version: "0.13.
|
|
45216
|
+
version: "0.13.1",
|
|
45217
45217
|
dependencies: {
|
|
45218
45218
|
"@letary/chisel": "^0.6.0",
|
|
45219
45219
|
jimp: "^1.6.1"
|
|
@@ -49636,15 +49636,26 @@ var DEFAULT_PORT2 = 4499;
|
|
|
49636
49636
|
var DEBOUNCE_MS = 120;
|
|
49637
49637
|
var WATCH_SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
|
|
49638
49638
|
var WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
|
|
49639
|
-
var
|
|
49639
|
+
var VIRTUAL_IFACE = /wsl|docker|hyper-v|vethernet|virtualbox|vmware|tap|tun|zerotier|tailscale|loopback/i;
|
|
49640
|
+
var lanCandidates = () => {
|
|
49640
49641
|
const nets = networkInterfaces();
|
|
49641
|
-
|
|
49642
|
-
|
|
49643
|
-
|
|
49644
|
-
|
|
49645
|
-
|
|
49646
|
-
|
|
49647
|
-
|
|
49642
|
+
const out = [];
|
|
49643
|
+
for (const iface of Object.keys(nets)) {
|
|
49644
|
+
for (const net of nets[iface] ?? []) {
|
|
49645
|
+
if (net.family !== "IPv4" || net.internal)
|
|
49646
|
+
continue;
|
|
49647
|
+
const a2 = net.address;
|
|
49648
|
+
let rank = 0;
|
|
49649
|
+
if (VIRTUAL_IFACE.test(iface))
|
|
49650
|
+
rank += 4;
|
|
49651
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(a2))
|
|
49652
|
+
rank += 2;
|
|
49653
|
+
if (a2.startsWith("169.254."))
|
|
49654
|
+
rank += 8;
|
|
49655
|
+
out.push({ address: a2, iface, rank });
|
|
49656
|
+
}
|
|
49657
|
+
}
|
|
49658
|
+
return out.sort((x2, y2) => x2.rank - y2.rank).map(({ address, iface }) => ({ address, iface }));
|
|
49648
49659
|
};
|
|
49649
49660
|
var loadDevToken = (root) => {
|
|
49650
49661
|
const path2 = join20(root, LECODES_DIR, "dev.json");
|
|
@@ -49685,9 +49696,11 @@ var dev = async (args) => {
|
|
|
49685
49696
|
warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).");
|
|
49686
49697
|
}
|
|
49687
49698
|
const port = Number(flagStr(args, "port")) || DEFAULT_PORT2;
|
|
49688
|
-
const
|
|
49699
|
+
const candidates = lanCandidates();
|
|
49700
|
+
const host = flagStr(args, "host") ?? candidates[0]?.address;
|
|
49689
49701
|
if (!host)
|
|
49690
49702
|
throw new CliError("No LAN address found — pass one with --host <ip>.");
|
|
49703
|
+
const alternatives = candidates.filter((c4) => c4.address !== host);
|
|
49691
49704
|
const origin = `http://${host}:${port}`;
|
|
49692
49705
|
const token = loadDevToken(root);
|
|
49693
49706
|
const server = await startDevServer({
|
|
@@ -49767,6 +49780,9 @@ ${result.error}`);
|
|
|
49767
49780
|
await printQr(server.url);
|
|
49768
49781
|
log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`);
|
|
49769
49782
|
note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`);
|
|
49783
|
+
if (alternatives.length > 0) {
|
|
49784
|
+
note(`Device can't reach it? This machine also has ${alternatives.map((a2) => `${a2.address} (${a2.iface})`).join(", ")} — retry with --host <ip>.`);
|
|
49785
|
+
}
|
|
49770
49786
|
if (existsSync20(join20(root, LECODES_DIR, "manifest.json")))
|
|
49771
49787
|
note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`);
|
|
49772
49788
|
log("");
|
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\n//\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\n//\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\n\nimport { OrbitCamera } from \"./orbit\"\n\n// The low-level bridge global every bundle runs against — the harness only walks the entity\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\n// in the SDK's registry, so id-level walking is the safe form).\ndeclare const _creator: { getParent(entityId: number): number }\n\ntype SceneHandleLike = {\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\n _describeAspects?(): unknown[]\n /** SceneHandle._patchNode — live single-node rebuild/add/remove (absent on older SDK bundles). */\n _patchNode?(name: string, def: unknown, parentName?: string | null): Promise<Node | null>\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\n _modelParts?(name: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps include `name`. */\n _editorNodeChanged?(name: string): void\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\n _editorSetProp?(hostName: string, index: number, key: string, value: unknown): boolean\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\n _editorSetMakeArg?(name: string, key: string, value: unknown): boolean\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\n _inspectorRender?(\n hostName: string, index: number,\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\n ): unknown[] | null\n}\n\ntype Vec3Tuple = [number, number, number]\n\ntype HarnessCallbacks = {\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\n onSelect?(name: string | null): void\n}\n\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\ntype EditorOps = {\n nodes(): { name: string, kind: string }[]\n uniqueName(base: string): string\n addNode(name: string, def: Record<string, unknown>): boolean\n setProp(name: string, key: string, value: unknown): boolean\n removeNode(name: string): void\n duplicate(name: string): string | null\n transact(fn: () => void): void\n}\n\nconst state = {\n handle: null as SceneHandleLike | null,\n scene: null as Scene | null,\n nodes: {} as Record<string, Node>,\n names: new Map<Node, string>(),\n /** GLB internal nodes, addressable like nodes: key = `<model>::<part path>` (+ reverse map). */\n parts: new Map<string, Node>(),\n partNames: new Map<Node, string>(),\n orbit: null as OrbitCamera | null,\n selected: null as string | null,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\n activeTool: null as string | null,\n /** Host-implemented doc operations (the `editor` API's write half). */\n editorOps: null as EditorOps | null,\n}\n\n// ---- GLB parts ---------------------------------------------------------------------------------\n// A model's internal nodes are selectable/editable through PART KEYS: `<model>::<part path>` (the\n// path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store it).\n// Everything key-addressed (select / setNodeProp / focus) resolves through `resolveKey`, so a part\n// behaves like a node — except its persistence: the editor writes the transform into the MODEL's\n// `overrides` record instead of a node def.\n\nconst PART_SEP = \"::\"\n\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\n\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\nconst refreshModelParts = async (name: string): Promise<{ path: string, name: string, depth: number }[]> => {\n const handle = state.handle\n if (!handle?._modelParts) return []\n const rows = await handle._modelParts(name)\n for (const [ key, node ] of [ ...state.parts ]) {\n if (key.startsWith(name + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\n }\n for (const r of rows) {\n const key = name + PART_SEP + r.path\n state.parts.set(key, r.node)\n state.partNames.set(r.node, key)\n }\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\n}\n\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\n * that reference it via ref() rebuild. Part keys collapse to their model's def name. */\nconst notifyEditorChanged = (key: string | null): void => {\n if (!key) return\n const i = key.indexOf(PART_SEP)\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\n}\n\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\n\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\n// Model constructor itself, so it is live even in edit mode.\n\nconst nodeCards = new Map<string, InspectorUI>()\n\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\n const node = state.nodes[name]\n if (!(node instanceof Model)) return null\n const clips = node.anim.clips\n if (clips.length === 0) return null\n let ui = nodeCards.get(name)\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\n return ui._run((u) => {\n u.header(\"Animation\")\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\n const loop = u.switch(\"loop\", { value: true })\n if (node.anim.playing) { node.anim.speed = speed; node.anim.loop = loop }\n if (u.button(node.anim.playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n node.anim.play(clip, { loop })\n }\n if (u.button(\"Stop\")) {\n // stop AND reset the pose (time 0 re-poses the skeleton even while stopped)\n node.anim.stop()\n node.anim.time = 0\n }\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(node.anim.playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The named def node a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\nconst ownerName = (node: Node | null): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const name = state.names.get(cur)\n if (name) return name\n cur = cur.parent\n }\n return null\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n if (notify) controller.callbacks.onSelect?.(name)\n}\n\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\n\nconst isInSubtree = (node: Node, root: Node): boolean => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 64; i++) {\n if (cur === root) return true\n cur = cur.parent\n }\n return false\n}\n\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\n const scene = state.scene\n if (!scene) return null\n const ray = scene.camera.getRay(x, y)\n if (Physics.supported) {\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\n let remaining = 2000\n for (let i = 0; i < 8 && remaining > 0; i++) {\n const hit = Physics.raycast(origin, ray.dir, remaining)\n if (!hit) break\n // marker-flagged nodes (empties/cameras) — placement/tool rays step past them like editor\n // nodes (a drop must land on real geometry)\n if (hit.node && !isEditorNode(hit.node)\n && !(hit.node as { _sceneMarker?: string })._sceneMarker\n && !(exclude && isInSubtree(hit.node, exclude))) {\n return {\n point: [ hit.point.x, hit.point.y, hit.point.z ],\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\n node: hit.node ? ownerName(hit.node) : null,\n }\n }\n // an excluded/editor body — resume the cast just past it\n const step = remaining * hit.fraction + 0.01\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\n remaining -= step\n }\n }\n const t = -ray.origin.y / ray.dir.y\n if (!Number.isFinite(t) || t <= 0) return null\n const p = ray.getPoint(t)\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\n}\n\nconst editorApi = {\n get selection(): string | null { return state.selected },\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\n raycast: raycastViewport,\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\n}\n\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\nconst windowUIs = new Map<number, InspectorUI>()\n\nconst controller = {\n callbacks: {} as HarnessCallbacks,\n\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node names. */\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\n const { scene, nodes } = await handle.load()\n state.handle = handle\n state.scene = scene\n state.nodes = nodes\n state.names = new Map(Object.entries(nodes).map(([ name, node ]) => [ node, name ]))\n\n scene.open()\n\n state.orbit = new OrbitCamera(scene.camera)\n\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\n scene.addEventListener(\"touchstart\", (ev) => {\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\n // is host-driven from the tree, so a click outside a tool does nothing)\n scene.addEventListener(\"click\", (ev) => {\n if (!state.activeTool) return\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (!tool?.hooks.onViewportClick) return\n const hit = raycastViewport(ev.clientX, ev.clientY)\n if (hit) {\n // a throwing tool logs and skips — it can't take the editor down\n try { tool.hooks.onViewportClick(hit, editorApi) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the nearest tracked def node; a hit inside the CURRENTLY\n * SELECTED model resolves to its deepest part key instead (click-again drill-down). Locked\n * nodes yield null (the click selects nothing — the host keeps the current selection). */\n pickName(entityId: number): string | null {\n if (!entityId) return null\n const nodeIds = new Map<number, string>()\n for (const [ name, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, name)\n const partIds = new Map<number, string>()\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\n let part: string | null = null\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\n const name = nodeIds.get(id)\n if (name !== undefined) {\n const node = state.nodes[name]\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\n const inSelected = state.selected === name || state.selected?.startsWith(name + PART_SEP)\n return part && inSelected ? part : name\n }\n id = _creator.getParent(id)\n }\n return null\n },\n\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\n * by hand, the engines' setter conventions differ). */\n applyPose(\n name: string,\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\n const node = resolveKey(name)\n if (!node) return null\n node.position = pose.position\n node.quaternion = pose.quaternion\n node.scale = pose.scale\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n }\n },\n\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\n * (re-)binds the part keys, so call it after anything that reloads the model. */\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\n return refreshModelParts(name)\n },\n\n /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\n const node = resolveKey(name)\n if (!node) return null\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\n }\n },\n\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\n * anything else is a structural change (the host recompiles the scene bundle instead). */\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"position\") node.position = value as Vec3Tuple\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\n else if (key === \"visible\") node.visible = value === true\n else if (key === \"locked\") {\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\n // consults it; fields edit regardless)\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n return\n }\n notifyEditorChanged(name)\n },\n\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\n return state.handle?._editorSetProp?.(name, index, key, value) === true\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE named node from\n * plain def data — the SDK re-instantiates it through the same defineScene builder, so nothing\n * recompiles. The HOST guarantees the def is plain data (it bails to a re-run on `$expr` and\n * `$asset` values). Returns false when this bundle's SDK can't patch (older builds). */\n async patchNode(name: string, def: Record<string, unknown> | null, parentName?: string | null): Promise<boolean> {\n const handle = state.handle\n if (!handle?._patchNode) return false\n const node = await handle._patchNode(name, def, parentName)\n state.names = new Map(Object.entries(state.nodes).map(([ n, nd ]) => [ nd, n ]))\n notifyEditorChanged(name) // generators referencing the patched node follow it\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\n await refreshModelParts(name)\n if (state.selected === name || state.selected?.startsWith(name + PART_SEP)) {\n const alive = state.selected === name ? node : (state.selected ? resolveKey(state.selected) : null)\n if (!alive) state.selected = null\n }\n return true\n },\n\n /** Live rename: runtime maps + the engine-side name. The document is the source of truth. */\n renameNode(oldName: string, newName: string): boolean {\n const node = state.nodes[oldName]\n if (!node || state.nodes[newName]) return false\n delete state.nodes[oldName]\n state.nodes[newName] = node\n state.names.set(node, newName)\n node.name = newName\n // part keys carry the model name as their prefix — remap them (and a part selection) with it\n for (const [ key, part ] of [ ...state.parts ]) {\n if (!key.startsWith(oldName + PART_SEP)) continue\n const next = newName + key.slice(oldName.length)\n state.parts.delete(key)\n state.parts.set(next, part)\n state.partNames.set(part, next)\n }\n if (state.selected === oldName) state.selected = newName\n else if (state.selected?.startsWith(oldName + PART_SEP)) {\n state.selected = newName + state.selected.slice(oldName.length)\n }\n return true\n },\n\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\n * (a node under a transformed parent shifts in world space). `parentName` null = root. */\n reparentNode(name: string, parentName: string | null): boolean {\n const node = state.nodes[name]\n const parent = parentName ? state.nodes[parentName] : null\n if (!node || (parentName !== null && !parent)) return false\n node.setParent(parent, false)\n return true\n },\n\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\n * back to a re-run for everything else. */\n setSceneProp(key: string, value: unknown): boolean {\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\n state.scene.skybox = value\n return true\n }\n return false\n },\n\n zoom(delta: number): void { state.orbit?.zoom(delta) },\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\n\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\n return raycastViewport(x, y)\n },\n\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\n aspectSchemas(): unknown[] {\n return state.handle?._describeAspects?.() ?? []\n },\n\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\n editorWindows(): { title: string }[] {\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\n },\n\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\n * cards, except every field key is transient editor state (windows have no doc entry). */\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\n const win = __editorPlugins.windows[index]\n if (!win) return null\n let ui = windowUIs.get(index)\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\n return ui._run((u) => win.render(u, editorApi), event)\n },\n\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\n },\n\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\n * tool's hooks; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n },\n\n /** Install the host's document operations — the write half of the plugin `editor` API. */\n setEditorOps(ops: EditorOps): void {\n state.editorOps = ops\n },\n\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\n * live euler-semantics probe against the running engine (the setter convention differs across\n * shipped engines — see setFromView). */\n _debug(): unknown {\n const probe = new Node()\n probe.eulerAngles = [ 0, 0, 90 ]\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\n const probe2 = new Node()\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\n return {\n selected: state.selected, // def name OR `<owner>::<path>` part key\n activeTool: state.activeTool,\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n }\n },\n\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\n * should write to the file (never compose eulers by hand: the shipped engines apply the\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\n const node = resolveKey(name)\n const cam = state.scene?.camera\n if (!node || !cam) return null\n const parent = node.parent\n const camPos = cam.worldPosition\n node.quaternion = parent\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\n : cam.worldQuaternion\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\n },\n\n /** Frame the named node or part (or the selection). */\n focus(name?: string): void {\n const node = resolveKey(name ?? state.selected ?? \"\")\n if (!node) return\n const p = node.worldPosition\n state.orbit?.focus([ p.x, p.y, p.z ])\n },\n}\n\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure gizmo math — plain number tuples in/out, no SDK dependencies, so it unit-tests directly\n// (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n\nexport type V3 = readonly [number, number, number]\n\nexport const add = (a: V3, b: V3): V3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]\nexport const sub = (a: V3, b: V3): V3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]\nexport const scale = (a: V3, s: number): V3 => [ a[0] * s, a[1] * s, a[2] * s ]\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nexport const length = (a: V3): number => Math.hypot(a[0], a[1], a[2])\n\n/**\n * Parameter `t` along the axis line (origin + dir·t, dir unit-length) of the point closest to the\n * given ray. Standard closest-point-between-two-lines; falls back to projecting the ray origin when\n * the lines are near-parallel (denominator ~ 0).\n */\nexport const closestAxisParam = (rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3): number => {\n // minimize |axisOrigin + t·axisDir − (rayOrigin + s·rayDir)|²:\n // t = s·b + d, s = t·b − e ⇒ t = (d − b·e) / (1 − b²)\n const w = sub(rayOrigin, axisOrigin)\n const b = dot(axisDir, rayDir)\n const d = dot(axisDir, w)\n const e = dot(rayDir, w)\n const denom = 1 - b * b // axisDir·axisDir = rayDir·rayDir = 1\n if (Math.abs(denom) < 1e-8) return d\n return (d - b * e) / denom\n}\n\n/** Distance between a ray and an axis SEGMENT (t clamped to [0, segLength]). */\nexport const rayToAxisSegmentDistance = (\n rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3, segLength: number,\n): number => {\n const t = Math.max(0, Math.min(segLength, closestAxisParam(rayOrigin, rayDir, axisOrigin, axisDir)))\n const p = add(axisOrigin, scale(axisDir, t))\n // closest point on the ray to p (s clamped to ≥ 0 — the ray starts at the camera)\n const s = Math.max(0, dot(sub(p, rayOrigin), rayDir))\n const q = add(rayOrigin, scale(rayDir, s))\n return length(sub(p, q))\n}\n\nexport type AxisPick = { axis: 0 | 1 | 2, startParam: number }\n\nconst AXES: V3[] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n/**\n * Which gizmo axis (if any) a ray grabs: the closest axis whose segment [origin, origin + dir·len]\n * passes within `threshold` of the ray. Returns the axis plus the grab parameter along it (the\n * drag delta is `currentParam - startParam` on subsequent moves).\n */\nexport const pickGizmoAxis = (\n rayOrigin: V3, rayDir: V3, gizmoOrigin: V3, axisLength: number, threshold: number,\n): AxisPick | null => {\n let best: AxisPick | null = null\n let bestDist = threshold\n for (let i = 0; i < 3; i++) {\n const dist = rayToAxisSegmentDistance(rayOrigin, rayDir, gizmoOrigin, AXES[i], axisLength)\n if (dist <= bestDist) {\n bestDist = dist\n best = { axis: i as 0 | 1 | 2, startParam: closestAxisParam(rayOrigin, rayDir, gizmoOrigin, AXES[i]) }\n }\n }\n return best\n}\n\n/** The world-axis direction for an axis index. */\nexport const axisDir = (axis: 0 | 1 | 2): V3 => AXES[axis]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
|
|
1
|
+
{"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\n//\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\n//\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\n\nimport { OrbitCamera } from \"./orbit\"\n\n// The low-level bridge global every bundle runs against — the harness only walks the entity\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\n// in the SDK's registry, so id-level walking is the safe form).\ndeclare const _creator: { getParent(entityId: number): number }\n\ntype SceneHandleLike = {\n /** `nodes` is keyed by ABSOLUTE PATH ('/'-joined def keys) — the SDK owns this record; the\n * harness holds the same object and must never re-key it itself (see renameNode). */\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\n _describeAspects?(): unknown[]\n /** SceneHandle._patchNode — live single-node rebuild/add/remove; the path carries the mount\n * point (its parent must exist for adds). */\n _patchNode?(path: string, def: unknown): Promise<Node | null>\n /** SceneHandle._renameNode — re-keys the node record (subtree included) + the engine name;\n * returns the new path, null on refusal. */\n _renameNode?(path: string, newName: string): string | null\n /** SceneHandle._reparentNode — re-keys + setParent keeping the local transform. */\n _reparentNode?(path: string, newParentPath: string | null): string | null\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\n _modelParts?(path: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps cover `path`. */\n _editorNodeChanged?(path: string): void\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\n _editorSetProp?(hostPath: string, index: number, key: string, value: unknown): boolean\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\n _editorSetMakeArg?(path: string, key: string, value: unknown): boolean\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\n _inspectorRender?(\n hostPath: string, index: number,\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\n ): unknown[] | null\n}\n\ntype Vec3Tuple = [number, number, number]\n\ntype HarnessCallbacks = {\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\n onSelect?(name: string | null): void\n}\n\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\ntype EditorOps = {\n nodes(): { path: string, name: string, kind: string }[]\n uniqueName(base: string, parentPath?: string): string\n addNode(name: string, def: Record<string, unknown>): boolean\n setProp(path: string, key: string, value: unknown): boolean\n removeNode(path: string): void\n duplicate(path: string): string | null\n transact(fn: () => void): void\n}\n\nconst state = {\n handle: null as SceneHandleLike | null,\n scene: null as Scene | null,\n /** The SDK's path-keyed node record — the SAME object the handle owns (rename/reparent re-key\n * it SDK-side; the harness re-keys only its own part/selection state). */\n nodes: {} as Record<string, Node>,\n /** Reverse map: live node → absolute def path. */\n paths: new Map<Node, string>(),\n /** GLB internal nodes, addressable like nodes: key = `<defPath>::<partPath>` (+ reverse map). */\n parts: new Map<string, Node>(),\n partNames: new Map<Node, string>(),\n orbit: null as OrbitCamera | null,\n /** Selection key: absolute def path or `<defPath>::<partPath>`. */\n selected: null as string | null,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\n activeTool: null as string | null,\n /** Host-implemented doc operations (the `editor` API's write half). */\n editorOps: null as EditorOps | null,\n}\n\n// ---- part keys ---------------------------------------------------------------------------------\n// A model's internal nodes are selectable/editable through PART KEYS: `<defPath>::<partPath>` (the\n// part-path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store\n// it; names exclude ':', so the first `::` is always the boundary). Everything key-addressed\n// (select / setNodeProp / focus) resolves through `resolveKey`, so a part behaves like a node —\n// except its persistence: the editor writes the transform into the MODEL's `overrides` record.\n\nconst PART_SEP = \"::\"\n\n/** Re-key `key` after `oldPath` moved to `newPath` (the path itself, `/` descendants, and `::`\n * part keys of either). Null = untouched. Mirrors scene-editor/types.ts `rekeyPath` — the\n * harness compiles standalone and cannot import it. */\nconst rekeyPath = (key: string, oldPath: string, newPath: string): string | null => {\n if (key === oldPath) return newPath\n if (key.startsWith(oldPath + \"/\") || key.startsWith(oldPath + PART_SEP)) {\n return newPath + key.slice(oldPath.length)\n }\n return null\n}\n\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\n\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\nconst refreshModelParts = async (path: string): Promise<{ path: string, name: string, depth: number }[]> => {\n const handle = state.handle\n if (!handle?._modelParts) return []\n const rows = await handle._modelParts(path)\n for (const [ key, node ] of [ ...state.parts ]) {\n if (key.startsWith(path + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\n }\n for (const r of rows) {\n const key = path + PART_SEP + r.path\n state.parts.set(key, r.node)\n state.partNames.set(r.node, key)\n }\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\n}\n\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\n * that reference it via ref() rebuild. Part keys collapse to their model's def path. */\nconst notifyEditorChanged = (key: string | null): void => {\n if (!key) return\n const i = key.indexOf(PART_SEP)\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\n}\n\n/** After the SDK re-keyed the shared node record (rename/reparent), re-key the HARNESS-owned\n * state that mirrors those paths: the reverse map, part keys, node-card cache, selection. */\nconst rekeyLocal = (oldPath: string, newPath: string): void => {\n state.paths = new Map(Object.entries(state.nodes).map(([ p, n ]) => [ n, p ]))\n for (const [ key, part ] of [ ...state.parts ]) {\n const next = rekeyPath(key, oldPath, newPath)\n if (next === null) continue\n state.parts.delete(key)\n state.parts.set(next, part)\n state.partNames.set(part, next)\n }\n for (const [ key, ui ] of [ ...nodeCards ]) {\n const next = rekeyPath(key, oldPath, newPath)\n if (next === null) continue\n nodeCards.delete(key)\n nodeCards.set(next, ui)\n }\n if (state.selected) state.selected = rekeyPath(state.selected, oldPath, newPath) ?? state.selected\n}\n\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\n\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\n// Model constructor itself, so it is live even in edit mode.\n\nconst nodeCards = new Map<string, InspectorUI>()\n\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\n const node = state.nodes[name]\n if (!(node instanceof Model)) return null\n const clips = node.anim.clips\n if (clips.length === 0) return null\n let ui = nodeCards.get(name)\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\n return ui._run((u) => {\n u.header(\"Animation\")\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\n const loop = u.switch(\"loop\", { value: true })\n if (node.anim.playing) { node.anim.speed = speed; node.anim.loop = loop }\n if (u.button(node.anim.playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n node.anim.play(clip, { loop })\n }\n if (u.button(\"Stop\")) {\n // stop AND reset the pose (time 0 re-poses the skeleton even while stopped)\n node.anim.stop()\n node.anim.time = 0\n }\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(node.anim.playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The def path a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\nconst ownerPath = (node: Node | null): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const path = state.paths.get(cur)\n if (path) return path\n cur = cur.parent\n }\n return null\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n if (notify) controller.callbacks.onSelect?.(name)\n}\n\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\n\nconst isInSubtree = (node: Node, root: Node): boolean => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 64; i++) {\n if (cur === root) return true\n cur = cur.parent\n }\n return false\n}\n\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\n const scene = state.scene\n if (!scene) return null\n const ray = scene.camera.getRay(x, y)\n if (Physics.supported) {\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\n let remaining = 2000\n for (let i = 0; i < 8 && remaining > 0; i++) {\n const hit = Physics.raycast(origin, ray.dir, remaining)\n if (!hit) break\n // marker-flagged nodes (empties/cameras) — placement/tool rays step past them like editor\n // nodes (a drop must land on real geometry)\n if (hit.node && !isEditorNode(hit.node)\n && !(hit.node as { _sceneMarker?: string })._sceneMarker\n && !(exclude && isInSubtree(hit.node, exclude))) {\n return {\n point: [ hit.point.x, hit.point.y, hit.point.z ],\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\n node: hit.node ? ownerPath(hit.node) : null,\n }\n }\n // an excluded/editor body — resume the cast just past it\n const step = remaining * hit.fraction + 0.01\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\n remaining -= step\n }\n }\n const t = -ray.origin.y / ray.dir.y\n if (!Number.isFinite(t) || t <= 0) return null\n const p = ray.getPoint(t)\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\n}\n\nconst editorApi = {\n get selection(): string | null { return state.selected },\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\n raycast: raycastViewport,\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\n}\n\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\nconst windowUIs = new Map<number, InspectorUI>()\n\nconst controller = {\n callbacks: {} as HarnessCallbacks,\n\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node PATHS\n * (build-completion order, not file order — the host sorts for display). */\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\n const { scene, nodes } = await handle.load()\n state.handle = handle\n state.scene = scene\n state.nodes = nodes\n state.paths = new Map(Object.entries(nodes).map(([ path, node ]) => [ node, path ]))\n\n scene.open()\n\n state.orbit = new OrbitCamera(scene.camera)\n\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\n scene.addEventListener(\"touchstart\", (ev) => {\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\n // is host-driven from the tree, so a click outside a tool does nothing)\n scene.addEventListener(\"click\", (ev) => {\n if (!state.activeTool) return\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (!tool?.hooks.onViewportClick) return\n const hit = raycastViewport(ev.clientX, ev.clientY)\n if (hit) {\n // a throwing tool logs and skips — it can't take the editor down\n try { tool.hooks.onViewportClick(hit, editorApi) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the NEAREST tracked def node (its absolute path — with\n * nested def nodes the deepest one wins); a hit inside the CURRENTLY SELECTED model resolves\n * to its deepest part key instead (click-again drill-down). Locked nodes yield null (the\n * click selects nothing — the host keeps the current selection). */\n pickName(entityId: number): string | null {\n if (!entityId) return null\n const nodeIds = new Map<number, string>()\n for (const [ path, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, path)\n const partIds = new Map<number, string>()\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\n let part: string | null = null\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\n const path = nodeIds.get(id)\n if (path !== undefined) {\n const node = state.nodes[path]\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\n const inSelected = state.selected === path || state.selected?.startsWith(path + PART_SEP)\n return part && inSelected ? part : path\n }\n id = _creator.getParent(id)\n }\n return null\n },\n\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\n * by hand, the engines' setter conventions differ). */\n applyPose(\n name: string,\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\n const node = resolveKey(name)\n if (!node) return null\n node.position = pose.position\n node.quaternion = pose.quaternion\n node.scale = pose.scale\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n }\n },\n\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\n * (re-)binds the part keys, so call it after anything that reloads the model. */\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\n return refreshModelParts(name)\n },\n\n /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\n const node = resolveKey(name)\n if (!node) return null\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\n }\n },\n\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\n * anything else is a structural change (the host recompiles the scene bundle instead). */\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"position\") node.position = value as Vec3Tuple\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\n else if (key === \"visible\") node.visible = value === true\n else if (key === \"locked\") {\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\n // consults it; fields edit regardless)\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n return\n }\n notifyEditorChanged(name)\n },\n\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\n return state.handle?._editorSetProp?.(name, index, key, value) === true\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE node at `path`\n * from plain def data — the SDK re-instantiates it through the same defineScene builder, so\n * nothing recompiles. The path carries the mount point. The HOST guarantees the def is plain\n * data (it bails to a re-run on `$expr` and `$asset` values). False = the SDK can't patch. */\n async patchNode(path: string, def: Record<string, unknown> | null): Promise<boolean> {\n const handle = state.handle\n if (!handle?._patchNode) return false\n const node = await handle._patchNode(path, def)\n state.paths = new Map(Object.entries(state.nodes).map(([ p, nd ]) => [ nd, p ]))\n notifyEditorChanged(path) // generators referencing the patched node follow it\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\n await refreshModelParts(path)\n const sel = state.selected\n if (sel === path || sel?.startsWith(path + \"/\") || sel?.startsWith(path + PART_SEP)) {\n const alive = sel === path ? node : (sel ? resolveKey(sel) : null)\n if (!alive) state.selected = null\n }\n return true\n },\n\n /** Live rename (bare sibling segment). The SDK owns the shared node record and re-keys it —\n * subtree, editor runs, and the engine-side display name included; the harness re-keys only\n * its OWN state (reverse path map, part keys, node-card cache, selection). The document is\n * the source of truth. */\n renameNode(path: string, newName: string): boolean {\n const newPath = state.handle?._renameNode?.(path, newName) ?? null\n if (newPath === null) return false\n rekeyLocal(path, newPath)\n return true\n },\n\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\n * (a node under a transformed parent shifts in world space). `newParentPath` null = root.\n * The node's (and its subtree's) PATHS change with the move — same re-key story as rename. */\n reparentNode(path: string, newParentPath: string | null): boolean {\n const newPath = state.handle?._reparentNode?.(path, newParentPath) ?? null\n if (newPath === null) return false\n rekeyLocal(path, newPath)\n return true\n },\n\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\n * back to a re-run for everything else. */\n setSceneProp(key: string, value: unknown): boolean {\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\n state.scene.skybox = value\n return true\n }\n return false\n },\n\n zoom(delta: number): void { state.orbit?.zoom(delta) },\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\n\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\n return raycastViewport(x, y)\n },\n\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\n aspectSchemas(): unknown[] {\n return state.handle?._describeAspects?.() ?? []\n },\n\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\n editorWindows(): { title: string }[] {\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\n },\n\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\n * cards, except every field key is transient editor state (windows have no doc entry). */\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\n const win = __editorPlugins.windows[index]\n if (!win) return null\n let ui = windowUIs.get(index)\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\n return ui._run((u) => win.render(u, editorApi), event)\n },\n\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\n },\n\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\n * tool's hooks; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n },\n\n /** Install the host's document operations — the write half of the plugin `editor` API. */\n setEditorOps(ops: EditorOps): void {\n state.editorOps = ops\n },\n\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\n * live euler-semantics probe against the running engine (the setter convention differs across\n * shipped engines — see setFromView). */\n _debug(): unknown {\n const probe = new Node()\n probe.eulerAngles = [ 0, 0, 90 ]\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\n const probe2 = new Node()\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\n return {\n selected: state.selected, // def PATH (\"city/in1/pt1\") OR `<defPath>::<partPath>` part key\n activeTool: state.activeTool,\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n }\n },\n\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\n * should write to the file (never compose eulers by hand: the shipped engines apply the\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\n const node = resolveKey(name)\n const cam = state.scene?.camera\n if (!node || !cam) return null\n const parent = node.parent\n const camPos = cam.worldPosition\n node.quaternion = parent\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\n : cam.worldQuaternion\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\n },\n\n /** Frame the named node or part (or the selection). */\n focus(name?: string): void {\n const node = resolveKey(name ?? state.selected ?? \"\")\n if (!node) return\n const p = node.worldPosition\n state.orbit?.focus([ p.x, p.y, p.z ])\n },\n}\n\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure gizmo math — plain number tuples in/out, no SDK dependencies, so it unit-tests directly\n// (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n\nexport type V3 = readonly [number, number, number]\n\nexport const add = (a: V3, b: V3): V3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]\nexport const sub = (a: V3, b: V3): V3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]\nexport const scale = (a: V3, s: number): V3 => [ a[0] * s, a[1] * s, a[2] * s ]\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nexport const length = (a: V3): number => Math.hypot(a[0], a[1], a[2])\n\n/**\n * Parameter `t` along the axis line (origin + dir·t, dir unit-length) of the point closest to the\n * given ray. Standard closest-point-between-two-lines; falls back to projecting the ray origin when\n * the lines are near-parallel (denominator ~ 0).\n */\nexport const closestAxisParam = (rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3): number => {\n // minimize |axisOrigin + t·axisDir − (rayOrigin + s·rayDir)|²:\n // t = s·b + d, s = t·b − e ⇒ t = (d − b·e) / (1 − b²)\n const w = sub(rayOrigin, axisOrigin)\n const b = dot(axisDir, rayDir)\n const d = dot(axisDir, w)\n const e = dot(rayDir, w)\n const denom = 1 - b * b // axisDir·axisDir = rayDir·rayDir = 1\n if (Math.abs(denom) < 1e-8) return d\n return (d - b * e) / denom\n}\n\n/** Distance between a ray and an axis SEGMENT (t clamped to [0, segLength]). */\nexport const rayToAxisSegmentDistance = (\n rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3, segLength: number,\n): number => {\n const t = Math.max(0, Math.min(segLength, closestAxisParam(rayOrigin, rayDir, axisOrigin, axisDir)))\n const p = add(axisOrigin, scale(axisDir, t))\n // closest point on the ray to p (s clamped to ≥ 0 — the ray starts at the camera)\n const s = Math.max(0, dot(sub(p, rayOrigin), rayDir))\n const q = add(rayOrigin, scale(rayDir, s))\n return length(sub(p, q))\n}\n\nexport type AxisPick = { axis: 0 | 1 | 2, startParam: number }\n\nconst AXES: V3[] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n/**\n * Which gizmo axis (if any) a ray grabs: the closest axis whose segment [origin, origin + dir·len]\n * passes within `threshold` of the ray. Returns the axis plus the grab parameter along it (the\n * drag delta is `currentParam - startParam` on subsequent moves).\n */\nexport const pickGizmoAxis = (\n rayOrigin: V3, rayDir: V3, gizmoOrigin: V3, axisLength: number, threshold: number,\n): AxisPick | null => {\n let best: AxisPick | null = null\n let bestDist = threshold\n for (let i = 0; i < 3; i++) {\n const dist = rayToAxisSegmentDistance(rayOrigin, rayDir, gizmoOrigin, AXES[i], axisLength)\n if (dist <= bestDist) {\n bestDist = dist\n best = { axis: i as 0 | 1 | 2, startParam: closestAxisParam(rayOrigin, rayDir, gizmoOrigin, AXES[i]) }\n }\n }\n return best\n}\n\n/** The world-axis direction for an axis index. */\nexport const axisDir = (axis: 0 | 1 | 2): V3 => AXES[axis]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
|
|
2
2
|
//
|
|
3
3
|
// A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
|
|
4
|
-
// nodes keyed by name
|
|
5
|
-
// = group),
|
|
4
|
+
// nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
|
|
5
|
+
// PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
|
|
6
6
|
// a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
|
|
7
7
|
// editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
|
|
8
8
|
// node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
import { Aspect, type AspectCtor, type With } from "../core/Aspect"
|
|
36
36
|
import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
|
|
37
37
|
import {
|
|
38
|
-
collectRefDeps, isEditMode, isNodeRef, resolveRefs, use, ref, make, EDIT_FLAG,
|
|
38
|
+
collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
|
|
39
39
|
type AspectEntry, type MakeEntry as SharedMakeEntry,
|
|
40
40
|
} from "./grammar"
|
|
41
41
|
import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
|
|
@@ -165,16 +165,19 @@ type NodeOf<N extends SceneNodeDef> =
|
|
|
165
165
|
type UnionToIntersection<U> =
|
|
166
166
|
(U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
|
|
167
167
|
|
|
168
|
-
// The child maps of a def level, as a union (never when no
|
|
169
|
-
// since `unknown` would absorb the union and `never` would
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
// The child maps of a def level, prefixed with their parent's path, as a union (never when no
|
|
169
|
+
// node has children — guarded below, since `unknown` would absorb the union and `never` would
|
|
170
|
+
// poison the intersection).
|
|
171
|
+
type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
|
|
172
|
+
{ [K in keyof T & string]:
|
|
173
|
+
T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
|
|
174
|
+
}[keyof T & string]
|
|
172
175
|
|
|
173
|
-
// All nodes of a def tree,
|
|
174
|
-
//
|
|
175
|
-
type NodesOf<T extends Record<string, SceneNodeDef
|
|
176
|
-
{ [K in keyof T]: NodeOf<T[K]> } &
|
|
177
|
-
([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
|
|
176
|
+
// All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
|
|
177
|
+
// its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
|
|
178
|
+
type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
|
|
179
|
+
{ [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
|
|
180
|
+
([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)
|
|
178
181
|
|
|
179
182
|
export type SceneNodes<D extends SceneDef> =
|
|
180
183
|
D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
|
|
@@ -182,6 +185,11 @@ export type SceneNodes<D extends SceneDef> =
|
|
|
182
185
|
export type LoadedScene<D extends SceneDef> = {
|
|
183
186
|
scene: Scene
|
|
184
187
|
nodes: SceneNodes<D>
|
|
188
|
+
/** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
|
|
189
|
+
get: {
|
|
190
|
+
<P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
|
|
191
|
+
(path: string): Node | null
|
|
192
|
+
}
|
|
185
193
|
}
|
|
186
194
|
|
|
187
195
|
// ---- GLB internal parts --------------------------------------------------------
|
|
@@ -253,9 +261,9 @@ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
|
|
|
253
261
|
}
|
|
254
262
|
}
|
|
255
263
|
|
|
256
|
-
const createSource = (
|
|
264
|
+
const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
|
|
257
265
|
const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
|
|
258
|
-
if (sources > 1) throw new Error(`Scene node "${
|
|
266
|
+
if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
|
|
259
267
|
if (def.model !== undefined) return Model.load(def.model)
|
|
260
268
|
if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
|
|
261
269
|
if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
|
|
@@ -329,11 +337,12 @@ class CameraRig extends Aspect<"__cameraRig"> {
|
|
|
329
337
|
}
|
|
330
338
|
}
|
|
331
339
|
|
|
332
|
-
/** The first camera-source def in file order (depth-first) — its
|
|
333
|
-
const findCamera = (defs?: Record<string, SceneNodeDef
|
|
340
|
+
/** The first camera-source def in file order (depth-first) — its path and block — or null. */
|
|
341
|
+
const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
|
|
334
342
|
for (const [ name, nd ] of Object.entries(defs ?? {})) {
|
|
335
|
-
|
|
336
|
-
|
|
343
|
+
const path = prefix === "" ? name : `${prefix}/${name}`
|
|
344
|
+
if (nd.camera !== undefined) return { path, def: nd.camera }
|
|
345
|
+
const inner = findCamera(nd.children, path)
|
|
337
346
|
if (inner) return inner
|
|
338
347
|
}
|
|
339
348
|
return null
|
|
@@ -390,29 +399,37 @@ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
|
|
|
390
399
|
|
|
391
400
|
/** One live editor-run aspect instance (edit mode only). */
|
|
392
401
|
type EditorRun = {
|
|
393
|
-
|
|
402
|
+
/** Absolute path of the host def node (re-keyed on rename/reparent). */
|
|
403
|
+
hostPath: string
|
|
394
404
|
node: Node
|
|
395
405
|
/** Index within the def's `aspects` array — the doc's aspect index addresses it. */
|
|
396
406
|
index: number
|
|
397
407
|
inst: { rebuild?(): void }
|
|
398
|
-
/** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`.
|
|
408
|
+
/** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
|
|
409
|
+
* DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
|
|
410
|
+
* against the file's props, so rewriting them would re-fire on every render. */
|
|
399
411
|
props: Record<string, unknown>
|
|
400
|
-
/**
|
|
412
|
+
/** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
|
|
413
|
+
* their subtrees) re-runs rebuild(). Re-derived after every structural change. */
|
|
401
414
|
deps: Set<string>
|
|
402
415
|
}
|
|
403
416
|
|
|
404
417
|
const safeRebuild = (run: EditorRun): void => {
|
|
405
418
|
// a throwing generator must not take the editor session down with it
|
|
406
|
-
try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.
|
|
419
|
+
try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
|
|
407
420
|
}
|
|
408
421
|
|
|
409
422
|
/** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
|
|
410
423
|
* instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
|
|
411
424
|
const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
|
|
425
|
+
const lookup = (r: string): Node | null => {
|
|
426
|
+
const p = resolveRefPath(nodes, run.hostPath, r)
|
|
427
|
+
return p === null ? null : nodes[p]
|
|
428
|
+
}
|
|
412
429
|
for (const [ k, v ] of Object.entries(run.props)) {
|
|
413
|
-
if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] =
|
|
430
|
+
if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
|
|
414
431
|
else if (Array.isArray(v) && v.some(isNodeRef)) {
|
|
415
|
-
;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ?
|
|
432
|
+
;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
|
|
416
433
|
}
|
|
417
434
|
}
|
|
418
435
|
}
|
|
@@ -427,7 +444,7 @@ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
|
|
|
427
444
|
/** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
|
|
428
445
|
const MAKE_INDEX = -1
|
|
429
446
|
|
|
430
|
-
const createMakeInst = (
|
|
447
|
+
const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
|
|
431
448
|
let token = 0
|
|
432
449
|
const inst: Record<string, unknown> = {}
|
|
433
450
|
inst.rebuild = () => {
|
|
@@ -441,7 +458,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
|
|
|
441
458
|
void Promise.resolve(result).then((made) => {
|
|
442
459
|
// a newer rebuild superseded this call while the factory awaited — drop the stale subtree
|
|
443
460
|
if (t === token && made instanceof Node) generated.add(made)
|
|
444
|
-
}).catch((e) => console.error(`[scene] make() factory failed on "${
|
|
461
|
+
}).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
|
|
445
462
|
}
|
|
446
463
|
return inst as unknown as { rebuild(): void }
|
|
447
464
|
}
|
|
@@ -450,7 +467,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
|
|
|
450
467
|
* awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
|
|
451
468
|
* and returns immediately (async content pops in when ready, like a model load). */
|
|
452
469
|
const attachMake = (
|
|
453
|
-
node: Node,
|
|
470
|
+
node: Node, path: string, def: SceneNodeDef,
|
|
454
471
|
nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
|
|
455
472
|
): Promise<void> | undefined => {
|
|
456
473
|
const entry = def.make
|
|
@@ -458,21 +475,21 @@ const attachMake = (
|
|
|
458
475
|
const generated = makeGenerated(node, scene)
|
|
459
476
|
if (isEditMode()) {
|
|
460
477
|
const props = { ...(entry.args ?? {}) } as Record<string, unknown>
|
|
461
|
-
const inst = createMakeInst(
|
|
462
|
-
Object.assign(inst, resolveRefs(props, nodes))
|
|
463
|
-
const run: EditorRun = {
|
|
478
|
+
const inst = createMakeInst(path, entry, generated)
|
|
479
|
+
Object.assign(inst, resolveRefs(props, nodes, path))
|
|
480
|
+
const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
|
|
464
481
|
editorRuns.push(run)
|
|
465
482
|
safeRebuild(run)
|
|
466
483
|
return undefined
|
|
467
484
|
}
|
|
468
|
-
const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
|
|
485
|
+
const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
|
|
469
486
|
return Promise.resolve(entry.fn(args as never)).then((made) => {
|
|
470
487
|
if (made instanceof Node) generated.add(made)
|
|
471
488
|
})
|
|
472
489
|
}
|
|
473
490
|
|
|
474
491
|
const attachAspects = (
|
|
475
|
-
node: Node,
|
|
492
|
+
node: Node, path: string, def: SceneNodeDef,
|
|
476
493
|
nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
|
|
477
494
|
): void => {
|
|
478
495
|
if (isEditMode()) {
|
|
@@ -486,17 +503,17 @@ const attachAspects = (
|
|
|
486
503
|
const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
|
|
487
504
|
;(inst as { node: unknown }).node = node
|
|
488
505
|
;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
|
|
489
|
-
Object.assign(inst, resolveRefs(props, nodes))
|
|
506
|
+
Object.assign(inst, resolveRefs(props, nodes, path))
|
|
490
507
|
// the HOST node is a dep too: a generator that draws relative to its node (path lines)
|
|
491
508
|
// must re-run when the node itself is dragged, not only when its ref() targets move
|
|
492
|
-
const run: EditorRun = {
|
|
509
|
+
const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
|
|
493
510
|
editorRuns.push(run)
|
|
494
511
|
safeRebuild(run)
|
|
495
512
|
})
|
|
496
513
|
return
|
|
497
514
|
}
|
|
498
515
|
for (const entry of def.aspects ?? []) {
|
|
499
|
-
const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
|
|
516
|
+
const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
|
|
500
517
|
const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
|
|
501
518
|
;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
|
|
502
519
|
}
|
|
@@ -512,15 +529,15 @@ const attachAspects = (
|
|
|
512
529
|
// `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
|
|
513
530
|
|
|
514
531
|
/** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
|
|
515
|
-
* order, which some hosts reverse) —
|
|
516
|
-
*
|
|
517
|
-
* precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
|
|
532
|
+
* order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
|
|
533
|
+
* the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
|
|
534
|
+
* their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
|
|
518
535
|
const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
|
|
519
536
|
const out: ModelPartRow[] = []
|
|
520
537
|
for (const [ name, nd ] of Object.entries(defs)) {
|
|
521
|
-
const node = local[name]
|
|
522
|
-
if (!node) continue
|
|
523
538
|
const path = prefix === "" ? name : `${prefix}/${name}`
|
|
539
|
+
const node = local[path]
|
|
540
|
+
if (!node) continue
|
|
524
541
|
out.push({ path, name, depth, node })
|
|
525
542
|
out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
|
|
526
543
|
const inner = node instanceof Model
|
|
@@ -531,16 +548,16 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
|
|
|
531
548
|
return out
|
|
532
549
|
}
|
|
533
550
|
|
|
534
|
-
const attachPrefab = async (wrapper: Node,
|
|
551
|
+
const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
|
|
535
552
|
const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
|
|
536
553
|
if (!pdef || typeof pdef !== "object") {
|
|
537
|
-
console.error(`[scene] "${
|
|
554
|
+
console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
|
|
538
555
|
return
|
|
539
556
|
}
|
|
540
557
|
// editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
|
|
541
558
|
;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
|
|
542
559
|
if (stack.has(pdef)) {
|
|
543
|
-
console.error(`[scene] prefab cycle detected at "${
|
|
560
|
+
console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
|
|
544
561
|
return
|
|
545
562
|
}
|
|
546
563
|
const local: Record<string, Node> = {}
|
|
@@ -558,26 +575,33 @@ const buildNodes = async (
|
|
|
558
575
|
defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
|
|
559
576
|
nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
|
|
560
577
|
): Promise<void> => {
|
|
561
|
-
const pending: {
|
|
578
|
+
const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
|
|
562
579
|
|
|
563
|
-
const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null): Promise<void> => {
|
|
564
|
-
|
|
580
|
+
const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
|
|
581
|
+
// names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
|
|
582
|
+
if (name === "" || name.includes("/") || name.includes(":")) {
|
|
583
|
+
throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
|
|
584
|
+
}
|
|
585
|
+
// the path is derived from def keys BEFORE any await, so it is deterministic even though
|
|
586
|
+
// Promise.all makes build completion (and record insertion) order nondeterministic
|
|
587
|
+
const path = parentPath === "" ? name : `${parentPath}/${name}`
|
|
588
|
+
const node = await createSource(path, nd)
|
|
565
589
|
if (parentNode) parentNode.add(node)
|
|
566
590
|
// Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
|
|
567
591
|
scene.add(node)
|
|
568
|
-
applyNode(node, name, nd)
|
|
592
|
+
applyNode(node, name, nd) // engine-side name stays the bare sibling segment
|
|
569
593
|
if (isEditMode()) addEditorMarker(node, nd, scene)
|
|
570
|
-
if (nd.prefab !== undefined) await attachPrefab(node,
|
|
571
|
-
pending.push({
|
|
572
|
-
nodes[
|
|
573
|
-
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
|
|
594
|
+
if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
|
|
595
|
+
pending.push({ path, node, def: nd })
|
|
596
|
+
nodes[path] = node
|
|
597
|
+
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
|
|
574
598
|
}
|
|
575
599
|
|
|
576
|
-
await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
|
|
600
|
+
await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
|
|
577
601
|
const makeWaits: Promise<void>[] = []
|
|
578
602
|
for (const p of pending) {
|
|
579
|
-
attachAspects(p.node, p.
|
|
580
|
-
const wait = attachMake(p.node, p.
|
|
603
|
+
attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
|
|
604
|
+
const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
|
|
581
605
|
if (wait) makeWaits.push(wait)
|
|
582
606
|
}
|
|
583
607
|
if (makeWaits.length > 0) await Promise.all(makeWaits)
|
|
@@ -593,7 +617,7 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
|
|
|
593
617
|
// in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
|
|
594
618
|
// lens the running app will use.
|
|
595
619
|
const cam = findCamera(def.nodes)
|
|
596
|
-
const camNode = cam ? nodes[cam.
|
|
620
|
+
const camNode = cam ? nodes[cam.path] : undefined
|
|
597
621
|
if (cam && camNode) {
|
|
598
622
|
scene.camera.position = camNode.worldPosition
|
|
599
623
|
scene.camera.quaternion = camNode.worldQuaternion
|
|
@@ -617,7 +641,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
617
641
|
private _editorRuns: EditorRun[] = []
|
|
618
642
|
/** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
|
|
619
643
|
private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
|
|
620
|
-
/** @internal The loaded scene
|
|
644
|
+
/** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
|
|
645
|
+
* load resolves). The record object is SHARED with the returned LoadedScene — patches and
|
|
646
|
+
* rename/reparent re-keying are visible through both. */
|
|
621
647
|
private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
|
|
622
648
|
|
|
623
649
|
constructor(def: D) { this.def = def }
|
|
@@ -627,7 +653,8 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
627
653
|
if (!this._loading) {
|
|
628
654
|
this._loading = instantiate(this.def, this._editorRuns).then((live) => {
|
|
629
655
|
this._live = live
|
|
630
|
-
|
|
656
|
+
const get = (path: string): Node | null => live.nodes[path] ?? null
|
|
657
|
+
return { ...live, get } as unknown as LoadedScene<D>
|
|
631
658
|
})
|
|
632
659
|
}
|
|
633
660
|
return this._loading
|
|
@@ -642,19 +669,20 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
642
669
|
|
|
643
670
|
/**
|
|
644
671
|
* @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
|
|
645
|
-
* recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the
|
|
646
|
-
* in place (or adds it when the
|
|
647
|
-
* with its whole subtree. The def must be PLAIN
|
|
648
|
-
* for `$expr` and `$asset` values; aspect changes
|
|
672
|
+
* recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
|
|
673
|
+
* `path` in place (or adds it when the path is new — the path's parent must exist, root paths
|
|
674
|
+
* mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
|
|
675
|
+
* data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
|
|
676
|
+
* are inert in edit mode and stay out of defs.
|
|
649
677
|
*
|
|
650
678
|
* Named children survive a rebuild: they are re-parented onto the replacement node keeping their
|
|
651
679
|
* local transforms (exactly what the scene file describes). Returns the fresh node, null for a
|
|
652
|
-
* removal. Old GPU resources (geometry/material instances)
|
|
653
|
-
* re-run — acceptable churn for an edit session.
|
|
680
|
+
* removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
|
|
681
|
+
* are not reclaimed until the next full re-run — acceptable churn for an edit session.
|
|
654
682
|
*/
|
|
655
|
-
async _patchNode(
|
|
683
|
+
async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
|
|
656
684
|
const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
|
|
657
|
-
const old: Node | undefined = nodes[
|
|
685
|
+
const old: Node | undefined = nodes[path]
|
|
658
686
|
|
|
659
687
|
const dispose = (root: Node): void => {
|
|
660
688
|
// Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
|
|
@@ -671,58 +699,141 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
671
699
|
}
|
|
672
700
|
|
|
673
701
|
if (def === null) {
|
|
674
|
-
if (old)
|
|
702
|
+
if (old) {
|
|
703
|
+
dispose(old)
|
|
704
|
+
this._refreshRunDeps()
|
|
705
|
+
}
|
|
675
706
|
return null
|
|
676
707
|
}
|
|
677
708
|
|
|
678
|
-
const build = async (
|
|
679
|
-
|
|
709
|
+
const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
|
|
710
|
+
// child reuse is by FULL path — only the live subtree at this exact address is carried
|
|
711
|
+
// over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
|
|
712
|
+
const existing = p === path ? undefined : nodes[p]
|
|
680
713
|
if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
|
|
681
714
|
if (parent) parent.add(existing)
|
|
682
715
|
return existing
|
|
683
716
|
}
|
|
684
|
-
const node = await createSource(
|
|
717
|
+
const node = await createSource(p, d)
|
|
685
718
|
if (parent) parent.add(node)
|
|
686
719
|
scene.add(node)
|
|
687
|
-
applyNode(node,
|
|
720
|
+
applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
|
|
688
721
|
if (isEditMode()) addEditorMarker(node, d, scene)
|
|
689
|
-
attachAspects(node,
|
|
690
|
-
nodes[
|
|
691
|
-
await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(cn
|
|
722
|
+
attachAspects(node, p, d, nodes, scene, this._editorRuns)
|
|
723
|
+
nodes[p] = node
|
|
724
|
+
await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
|
|
692
725
|
return node
|
|
693
726
|
}
|
|
694
727
|
|
|
695
|
-
const
|
|
696
|
-
const
|
|
728
|
+
const cut = path.lastIndexOf("/")
|
|
729
|
+
const parentPath = cut < 0 ? null : path.slice(0, cut)
|
|
730
|
+
if (!old && parentPath !== null && !nodes[parentPath]) return null
|
|
731
|
+
const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
|
|
732
|
+
const fresh = await build(path, def, parent)
|
|
697
733
|
if (old) {
|
|
698
734
|
// named children not listed in the def still move over (the editor patches one node at a time)
|
|
699
735
|
const named = new Set(Object.values(nodes))
|
|
700
736
|
for (const child of old.children) if (named.has(child)) fresh.add(child)
|
|
701
|
-
dispose(old) // fresh already replaced nodes[
|
|
702
|
-
nodes[
|
|
737
|
+
dispose(old) // fresh already replaced nodes[path] in build(), so it survives
|
|
738
|
+
nodes[path] = fresh
|
|
703
739
|
}
|
|
704
740
|
// the projection lives on the scene camera, not on the node — re-apply it here so an inspector
|
|
705
741
|
// fov/near/far edit lands live (a rebuilt node alone would carry none of it)
|
|
706
742
|
if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
|
|
743
|
+
this._refreshRunDeps()
|
|
707
744
|
return fresh
|
|
708
745
|
}
|
|
709
746
|
|
|
710
|
-
/** @internal
|
|
711
|
-
*
|
|
712
|
-
*
|
|
713
|
-
|
|
714
|
-
_editorNodeChanged(name: string): void {
|
|
747
|
+
/** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
|
|
748
|
+
* absolute paths, so any structural change can invalidate them: an add can satisfy a
|
|
749
|
+
* previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
|
|
750
|
+
private _refreshRunDeps(): void {
|
|
715
751
|
const nodes = this._live?.nodes
|
|
716
752
|
if (!nodes) return
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
753
|
+
for (const run of this._editorRuns) {
|
|
754
|
+
run.deps = collectRefDeps(run.props, nodes, run.hostPath)
|
|
755
|
+
// aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
|
|
756
|
+
// wrapper's transform is editor-owned and moving it never re-calls the factory
|
|
757
|
+
if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
|
|
762
|
+
* runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
|
|
763
|
+
* the host — mutation, not replacement. */
|
|
764
|
+
private _rekey(oldPath: string, newPath: string): void {
|
|
765
|
+
const nodes = this._live!.nodes
|
|
766
|
+
const move = (key: string): string | null =>
|
|
767
|
+
key === oldPath ? newPath
|
|
768
|
+
: key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
|
|
769
|
+
: null
|
|
770
|
+
for (const key of Object.keys(nodes)) {
|
|
771
|
+
const next = move(key)
|
|
772
|
+
if (next === null) continue
|
|
773
|
+
const n = nodes[key]
|
|
774
|
+
delete nodes[key]
|
|
775
|
+
nodes[next] = n
|
|
776
|
+
}
|
|
777
|
+
for (const run of this._editorRuns) {
|
|
778
|
+
const next = move(run.hostPath)
|
|
779
|
+
if (next !== null) run.hostPath = next
|
|
780
|
+
}
|
|
781
|
+
for (const [ key, card ] of [ ...this._inspectorCards ]) {
|
|
782
|
+
const i = key.lastIndexOf(":")
|
|
783
|
+
const next = move(key.slice(0, i))
|
|
784
|
+
if (next === null) continue
|
|
785
|
+
this._inspectorCards.delete(key)
|
|
786
|
+
this._inspectorCards.set(next + key.slice(i), card)
|
|
722
787
|
}
|
|
788
|
+
this._refreshRunDeps()
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
|
|
792
|
+
* Owns the shared record's re-keying (the host re-keys only its own part/selection state).
|
|
793
|
+
* Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
|
|
794
|
+
_renameNode(path: string, newName: string): string | null {
|
|
795
|
+
const nodes = this._live?.nodes
|
|
796
|
+
const node = nodes?.[path]
|
|
797
|
+
if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
|
|
798
|
+
const cut = path.lastIndexOf("/")
|
|
799
|
+
const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
|
|
800
|
+
if (newPath === path) return path
|
|
801
|
+
if (nodes[newPath]) return null
|
|
802
|
+
this._rekey(path, newPath)
|
|
803
|
+
node.name = newName // engine-side name stays the bare segment
|
|
804
|
+
return newPath
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
|
|
808
|
+
* every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
|
|
809
|
+
* cycle, name taken among the new siblings). */
|
|
810
|
+
_reparentNode(path: string, newParentPath: string | null): string | null {
|
|
811
|
+
const nodes = this._live?.nodes
|
|
812
|
+
const node = nodes?.[path]
|
|
813
|
+
if (!nodes || !node) return null
|
|
814
|
+
const parent = newParentPath === null ? null : nodes[newParentPath]
|
|
815
|
+
if (newParentPath !== null && !parent) return null
|
|
816
|
+
if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
|
|
817
|
+
const name = path.slice(path.lastIndexOf("/") + 1)
|
|
818
|
+
const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
|
|
819
|
+
if (newPath === path) return path
|
|
820
|
+
if (nodes[newPath]) return null
|
|
821
|
+
this._rekey(path, newPath)
|
|
822
|
+
node.setParent(parent ?? null, false)
|
|
823
|
+
return newPath
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
|
|
827
|
+
* and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
|
|
828
|
+
* absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
|
|
829
|
+
* FollowPath's waypoints are the children of its referenced path node) is a prefix test:
|
|
830
|
+
* a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
|
|
831
|
+
_editorNodeChanged(path: string): void {
|
|
832
|
+
const nodes = this._live?.nodes
|
|
833
|
+
if (!nodes) return
|
|
723
834
|
for (const run of this._editorRuns) {
|
|
724
835
|
let hit = false
|
|
725
|
-
for (const
|
|
836
|
+
for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
|
|
726
837
|
if (!hit) continue
|
|
727
838
|
assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
|
|
728
839
|
safeRebuild(run)
|
|
@@ -731,22 +842,22 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
731
842
|
|
|
732
843
|
/** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
|
|
733
844
|
* run (no compile; the factory is already in the bundle). False for non-make nodes. */
|
|
734
|
-
_editorSetMakeArg(
|
|
735
|
-
return this._editorSetProp(
|
|
845
|
+
_editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
|
|
846
|
+
return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
|
|
736
847
|
}
|
|
737
848
|
|
|
738
849
|
/** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
|
|
739
850
|
* on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
|
|
740
851
|
* its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
|
|
741
|
-
_editorSetProp(
|
|
742
|
-
const run = this._editorRuns.find((r) => r.
|
|
852
|
+
_editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
|
|
853
|
+
const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
|
|
743
854
|
const nodes = this._live?.nodes
|
|
744
855
|
if (!run || !nodes) return false
|
|
745
856
|
run.props[key] = value
|
|
746
|
-
run.deps = collectRefDeps(run.props)
|
|
857
|
+
run.deps = collectRefDeps(run.props, nodes, run.hostPath)
|
|
747
858
|
// aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
|
|
748
859
|
// wrapper's transform is editor-owned and moving it never re-calls the factory
|
|
749
|
-
if (run.index !== MAKE_INDEX) run.deps.add(run.
|
|
860
|
+
if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
|
|
750
861
|
if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
|
|
751
862
|
else (run.inst as Record<string, unknown>)[key] = value
|
|
752
863
|
safeRebuild(run)
|
|
@@ -767,19 +878,19 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
767
878
|
* node instance was replaced by a live patch is rebuilt transparently.
|
|
768
879
|
*/
|
|
769
880
|
_inspectorRender(
|
|
770
|
-
|
|
881
|
+
hostPath: string, index: number,
|
|
771
882
|
props: Record<string, unknown>, event?: InspectorEvent,
|
|
772
883
|
): InspectorWidget[] | null {
|
|
773
884
|
const live = this._live
|
|
774
|
-
const node = live?.nodes[
|
|
885
|
+
const node = live?.nodes[hostPath]
|
|
775
886
|
const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
|
|
776
887
|
?._sceneAspects?.[index]
|
|
777
888
|
if (!live || !node || !entry) return null
|
|
778
889
|
const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
|
|
779
890
|
if (typeof ctor.inspector !== "function") return null
|
|
780
891
|
|
|
781
|
-
const run = this._editorRuns.find((r) => r.
|
|
782
|
-
const key = `${
|
|
892
|
+
const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
|
|
893
|
+
const key = `${hostPath}:${index}`
|
|
783
894
|
let card = this._inspectorCards.get(key)
|
|
784
895
|
if (!card || card.node !== node) {
|
|
785
896
|
// (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
|
|
@@ -791,7 +902,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
791
902
|
} else {
|
|
792
903
|
inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
|
|
793
904
|
inst.node = node
|
|
794
|
-
Object.assign(inst, resolveRefs({ ...props }, live.nodes))
|
|
905
|
+
Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
|
|
795
906
|
}
|
|
796
907
|
const ui = card?.ui ?? new InspectorUI()
|
|
797
908
|
ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
|
|
@@ -812,17 +923,17 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
812
923
|
a !== b && JSON.stringify(a) !== JSON.stringify(b)
|
|
813
924
|
if (run) {
|
|
814
925
|
for (const [ k, v ] of Object.entries(props)) {
|
|
815
|
-
if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(
|
|
926
|
+
if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
|
|
816
927
|
}
|
|
817
928
|
for (const k of Object.keys(run.props)) {
|
|
818
929
|
if (k in props) continue
|
|
819
|
-
this._editorSetProp(
|
|
930
|
+
this._editorSetProp(hostPath, index, k, fieldDefault(k))
|
|
820
931
|
delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
|
|
821
932
|
}
|
|
822
933
|
c.ui._props = run.props
|
|
823
934
|
} else {
|
|
824
935
|
const snapshot = { ...props }
|
|
825
|
-
const resolved = (resolveRefs(snapshot, live.nodes) ?? snapshot) as Record<string, unknown>
|
|
936
|
+
const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
|
|
826
937
|
for (const f of c.ui._fields) {
|
|
827
938
|
if (isExpr(resolved[f.key])) continue
|
|
828
939
|
c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
|
|
@@ -833,12 +944,12 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
833
944
|
return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
|
|
834
945
|
}
|
|
835
946
|
|
|
836
|
-
/** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance
|
|
837
|
-
* path/name/depth/live node, in the same part-path grammar
|
|
838
|
-
* plain nodes / unknown
|
|
839
|
-
async _modelParts(
|
|
947
|
+
/** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
|
|
948
|
+
* absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
|
|
949
|
+
* `overrides` keys use. Empty for plain nodes / unknown paths. */
|
|
950
|
+
async _modelParts(path: string): Promise<ModelPartRow[]> {
|
|
840
951
|
const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
|
|
841
|
-
const node = nodes[
|
|
952
|
+
const node = nodes[path]
|
|
842
953
|
if (node instanceof Model) return modelPartRows(node)
|
|
843
954
|
// prefab instances precompute their rows in DEF order (engine child order isn't stable)
|
|
844
955
|
return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
|
|
@@ -860,8 +971,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
860
971
|
|
|
861
972
|
/**
|
|
862
973
|
* Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
|
|
863
|
-
* `const { scene, nodes } = await handle.open()` gives `nodes
|
|
864
|
-
* (Mesh / Model / Light / Node) with its `use(...)`d aspects attached
|
|
974
|
+
* `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
|
|
975
|
+
* block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
|
|
976
|
+
* plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
|
|
865
977
|
*/
|
|
866
978
|
export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
|
|
867
979
|
const handle = new SceneHandle(def)
|
|
@@ -17,31 +17,37 @@ import type { InspectorUI } from "../core/InspectorUI"
|
|
|
17
17
|
export type EditorRayHit = {
|
|
18
18
|
point: [number, number, number]
|
|
19
19
|
normal: [number, number, number]
|
|
20
|
-
/**
|
|
20
|
+
/** Absolute path of the scene-file node the hit belongs to ("city/in1/pt1") — null for
|
|
21
|
+
* ground-plane fallback hits. */
|
|
21
22
|
node: string | null
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
* The editor scripting API handed to windows and tools.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* The editor scripting API handed to windows and tools. Nodes are addressed by ABSOLUTE PATH —
|
|
27
|
+
* '/'-joined names from the scene root; a root node's path is its bare name. Doc-op methods write
|
|
28
|
+
* the scene DOCUMENT through the editor's normal commit path (undo, file, live patching all
|
|
29
|
+
* included); they return false / no-op when the document can't take the edit (sibling-name
|
|
30
|
+
* collision, unknown path).
|
|
28
31
|
*/
|
|
29
32
|
export type EditorApi = {
|
|
30
|
-
/** The currently selected node
|
|
33
|
+
/** The currently selected node path (or `path::part` key for asset internals), null when
|
|
34
|
+
* nothing is selected. */
|
|
31
35
|
readonly selection: string | null
|
|
32
|
-
select(
|
|
33
|
-
/** The scene document's nodes
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
select(path: string | null): void
|
|
37
|
+
/** The scene document's nodes: absolute path, sibling-unique display name, source kind
|
|
38
|
+
* (mesh / model / light / group / …). */
|
|
39
|
+
nodes(): { path: string, name: string, kind: string }[]
|
|
40
|
+
/** First unused "base", "base2", "base3", … name among the SIBLINGS under `parentPath`
|
|
41
|
+
* (scene root when omitted). */
|
|
42
|
+
uniqueName(base: string, parentPath?: string): string
|
|
43
|
+
/** Add a ROOT node from plain def data (scene-file grammar; a string `model` value means an
|
|
44
|
+
* asset path). `name` may not contain '/' or ':'. One undo step unless grouped by `transact`. */
|
|
39
45
|
addNode(name: string, def: Record<string, unknown>): boolean
|
|
40
46
|
/** Write one def prop (transforms apply live; anything else patches/re-runs the node). */
|
|
41
|
-
setProp(
|
|
42
|
-
removeNode(
|
|
43
|
-
/** Duplicate a node; returns the copy's
|
|
44
|
-
duplicate(
|
|
47
|
+
setProp(path: string, key: string, value: unknown): boolean
|
|
48
|
+
removeNode(path: string): void
|
|
49
|
+
/** Duplicate a node; returns the copy's path (null when it can't). */
|
|
50
|
+
duplicate(path: string): string | null
|
|
45
51
|
/** Raycast the scene under a viewport pixel (same hit rules as tool clicks). */
|
|
46
52
|
raycast(screenX: number, screenY: number): EditorRayHit | null
|
|
47
53
|
/** Group every doc edit inside `fn` into ONE undo step. */
|
|
@@ -43,39 +43,74 @@ export const make = <A extends Record<string, unknown>, N>(
|
|
|
43
43
|
): MakeEntry<A, N> => ({ __make: true, fn, args })
|
|
44
44
|
|
|
45
45
|
/**
|
|
46
|
-
* Reference another scene node by
|
|
47
|
-
* `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live node when
|
|
48
|
-
* attach — after EVERY node of the scene exists, so
|
|
49
|
-
*
|
|
50
|
-
*
|
|
46
|
+
* Reference another scene node by PATH in an aspect's props:
|
|
47
|
+
* `use(Road, { from: ref('pointA'), to: ref('lane/pointB') })`. Resolves to the live node when
|
|
48
|
+
* aspects attach — after EVERY node of the scene exists, so declaration order doesn't matter.
|
|
49
|
+
* Resolution is scoped upward from the host node (like variable scoping): the host's own children
|
|
50
|
+
* first, then its siblings, then each ancestor's scope up to the scene root. The FIRST segment
|
|
51
|
+
* binds the scope; the remaining segments descend from there. Refs address def nodes only (no
|
|
52
|
+
* `::`/`name[i]` asset-internal segments); an unknown path resolves to `null` (type your aspect
|
|
53
|
+
* field `Node | null`). Scene files only — hand-written code passes nodes directly:
|
|
54
|
+
* `node.aspect(Road, { from: nodes.pointA })`.
|
|
51
55
|
*/
|
|
52
|
-
export const ref = <T = Node>(
|
|
56
|
+
export const ref = <T = Node>(path: string): T => ({ $ref: path } as unknown as T)
|
|
53
57
|
|
|
54
58
|
export const isNodeRef = (v: unknown): v is { $ref: string } =>
|
|
55
59
|
typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
|
|
56
60
|
|
|
57
|
-
/**
|
|
61
|
+
/** Resolve one ref string against a path-keyed nodes record, scoped upward from `hostPath` (own
|
|
62
|
+
* children → parent's scope (siblings + the host itself) → each ancestor scope → root, `""`).
|
|
63
|
+
* The ref's FIRST segment binds the scope — a match there is final even when the rest of the
|
|
64
|
+
* path doesn't exist (lexical shadowing). Returns the target's ABSOLUTE path, or null.
|
|
65
|
+
* `Object.hasOwn`, not indexing: a node named `constructor` must not resolve via the prototype. */
|
|
66
|
+
export const resolveRefPath = (
|
|
67
|
+
nodes: Record<string, unknown>, hostPath: string, ref: string,
|
|
68
|
+
): string | null => {
|
|
69
|
+
const i = ref.indexOf("/")
|
|
70
|
+
const first = i < 0 ? ref : ref.slice(0, i)
|
|
71
|
+
for (let scope = hostPath; ; ) {
|
|
72
|
+
const base = scope === "" ? "" : `${scope}/`
|
|
73
|
+
if (Object.hasOwn(nodes, base + first)) return Object.hasOwn(nodes, base + ref) ? base + ref : null
|
|
74
|
+
if (scope === "") return null
|
|
75
|
+
const cut = scope.lastIndexOf("/")
|
|
76
|
+
scope = cut < 0 ? "" : scope.slice(0, cut)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep),
|
|
81
|
+
* resolved scoped-upward from `hostPath` ("" = root scope only — the 2D flat-map case). */
|
|
58
82
|
export const resolveRefs = <N>(
|
|
59
|
-
props: Record<string, unknown> | undefined, nodes: Record<string, N>,
|
|
83
|
+
props: Record<string, unknown> | undefined, nodes: Record<string, N>, hostPath = "",
|
|
60
84
|
): Record<string, unknown> | undefined => {
|
|
61
85
|
if (!props) return props
|
|
86
|
+
const lookup = (r: string): N | null => {
|
|
87
|
+
const p = resolveRefPath(nodes, hostPath, r)
|
|
88
|
+
return p === null ? null : nodes[p]
|
|
89
|
+
}
|
|
62
90
|
let out: Record<string, unknown> | undefined
|
|
63
91
|
for (const [ k, v ] of Object.entries(props)) {
|
|
64
92
|
if (isNodeRef(v)) {
|
|
65
|
-
;(out ??= { ...props })[k] =
|
|
93
|
+
;(out ??= { ...props })[k] = lookup(v.$ref)
|
|
66
94
|
} else if (Array.isArray(v) && v.some(isNodeRef)) {
|
|
67
|
-
;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ?
|
|
95
|
+
;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
|
|
68
96
|
}
|
|
69
97
|
}
|
|
70
98
|
return out ?? props
|
|
71
99
|
}
|
|
72
100
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
101
|
+
/** ABSOLUTE paths of the nodes a props record's ref() values resolve to (editor dep tracking).
|
|
102
|
+
* An unresolved ref contributes no dep — the handle re-derives deps after structural changes. */
|
|
103
|
+
export const collectRefDeps = (
|
|
104
|
+
props: Record<string, unknown>, nodes: Record<string, unknown>, hostPath: string,
|
|
105
|
+
): Set<string> => {
|
|
75
106
|
const deps = new Set<string>()
|
|
107
|
+
const add = (r: string): void => {
|
|
108
|
+
const p = resolveRefPath(nodes, hostPath, r)
|
|
109
|
+
if (p !== null) deps.add(p)
|
|
110
|
+
}
|
|
76
111
|
for (const v of Object.values(props)) {
|
|
77
|
-
if (isNodeRef(v))
|
|
78
|
-
else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el))
|
|
112
|
+
if (isNodeRef(v)) add(v.$ref)
|
|
113
|
+
else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) add(el.$ref)
|
|
79
114
|
}
|
|
80
115
|
return deps
|
|
81
116
|
}
|