lecodes-cli 0.10.4 → 0.12.0

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.
Files changed (54) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +48887 -6261
  3. package/package.json +4 -3
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +42 -0
  6. package/runtime/sdk/compile/assetIconMacro.ts +382 -0
  7. package/runtime/sdk/compile/assetMacro.ts +45 -0
  8. package/runtime/sdk/compile/assetName.ts +50 -0
  9. package/runtime/sdk/compile/bundler.ts +244 -0
  10. package/runtime/sdk/compile/compileProject.ts +106 -0
  11. package/runtime/sdk/compile/detectEntry.ts +125 -0
  12. package/runtime/sdk/compile/fontMacro.ts +459 -0
  13. package/runtime/sdk/compile/fontRegistry.ts +78 -0
  14. package/runtime/sdk/compile/header.ts +55 -0
  15. package/runtime/sdk/compile/index.ts +53 -0
  16. package/runtime/sdk/compile/libraryImports.ts +52 -0
  17. package/runtime/sdk/compile/sceneEditor.ts +78 -0
  18. package/runtime/sdk/compile/sfnt.ts +98 -0
  19. package/runtime/sdk/compile/sourcemap.ts +25 -0
  20. package/runtime/sdk/g2/Node2D.ts +3 -0
  21. package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
  22. package/runtime/sdk/g2/SpriteSheet.ts +166 -0
  23. package/runtime/sdk/g2/Tileset.ts +71 -0
  24. package/runtime/sdk/g2/autotile.ts +394 -0
  25. package/runtime/sdk/g2/cells.ts +91 -0
  26. package/runtime/sdk/g2/defineScene2d.ts +381 -0
  27. package/runtime/sdk/g2/scenarios2d.ts +69 -0
  28. package/runtime/sdk/inject.ts +18 -0
  29. package/runtime/sdk/runtime/app.ts +21 -3
  30. package/runtime/sdk/scene/defineScene.ts +10 -70
  31. package/runtime/sdk/scene/grammar.ts +85 -0
  32. package/runtime/sdk/ui/NativeView.ts +9 -12
  33. package/runtime/sdk/ui/UI.ts +9 -1
  34. package/runtime/sdk/ui/UIBottomSheet.ts +10 -20
  35. package/runtime/sdk/ui/UIButton.ts +19 -36
  36. package/runtime/sdk/ui/UIContainer.ts +41 -103
  37. package/runtime/sdk/ui/UIImage.ts +10 -18
  38. package/runtime/sdk/ui/UIInput.ts +14 -26
  39. package/runtime/sdk/ui/UIModal.ts +10 -20
  40. package/runtime/sdk/ui/UINode.ts +142 -36
  41. package/runtime/sdk/ui/UIPager.ts +29 -28
  42. package/runtime/sdk/ui/UIPopover.ts +10 -16
  43. package/runtime/sdk/ui/UIScreen.ts +23 -40
  44. package/runtime/sdk/ui/UIScrollable.ts +37 -47
  45. package/runtime/sdk/ui/UISpacer.ts +4 -8
  46. package/runtime/sdk/ui/UITabs.ts +175 -0
  47. package/runtime/sdk/ui/UIText.ts +7 -13
  48. package/runtime/sdk/ui/UIVideo.ts +11 -13
  49. package/runtime/sdk/ui/UIVirtualizedList.ts +43 -49
  50. package/runtime/sdk/ui/UIWidget.ts +24 -36
  51. package/runtime/sdk/ui/fonts.ts +3 -0
  52. package/runtime/sdk/ui/router.ts +13 -0
  53. package/runtime/sdk/ui/theme.ts +21 -30
  54. package/runtime/sdk-types.json +1 -1
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.10.4",
3
+ "version": "0.12.0",
4
4
  "dependencies": {
5
- "@letary/chisel": "^0.6.0"
5
+ "@letary/chisel": "^0.6.0",
6
+ "jimp": "^1.6.1"
6
7
  },
7
8
  "devDependencies": {
8
9
  "lecodes-design": "workspace:*",
@@ -12,7 +13,7 @@
12
13
  "sdk": "workspace:*"
13
14
  },
14
15
  "peerDependencies": {
15
- "lecodes-design": "^0.3.2",
16
+ "lecodes-design": "^0.4.0",
16
17
  "lecodes-renderer": "^0.7.1",
17
18
  "lecodes-3d-editor": "^0.1.0"
18
19
  },
@@ -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// Picking is NATIVE: every selectable node (and every gizmo arrow) gets a collider, so the\n// engine's raycastView resolves hits and the SDK dispatches click/touchstart to the right node.\n// raycastView picks overlay CPU colliders first (gizmo arrows, always on top), then Jolt pick\n// bodies on physics builds (`Shape` aspects attached below for precise mesh picking), then the\n// scene's CPU colliders (`setColliderFromMesh` — the only path on non-physics builds). Orbit is\n// any unclaimed drag; zoom/pan/focus come from the host (DOM wheel/keys belong to the Vue viewport).\n\nimport { createGrid } from \"./grid\"\nimport { OrbitCamera } from \"./orbit\"\nimport { Gizmo, type DragHandle, type GizmoMode, type GizmoSpace } from \"./gizmo\"\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._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\n/** Which node property a gizmo drag changes — matches the mode. */\ntype TransformKey = \"position\" | \"eulerAngles\" | \"scale\"\n\ntype HarnessCallbacks = {\n /** Selection changed (click in the viewport, or a select() echo). */\n onSelect?(name: string | null): void\n /** Live gizmo drag (every move) — mirror into the inspector, don't save yet. */\n onTransform?(name: string, key: TransformKey, value: Vec3Tuple): void\n /** Drag finished — write the value through to the scene document. */\n onTransformEnd?(name: string, key: TransformKey, value: Vec3Tuple): 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 overlay: 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 gizmo: null as Gizmo | null,\n selected: null as string | null,\n /** Snap increments while dragging (toolbar toggles; the host's Ctrl key inverts them):\n * move 0.25u + scale 0.1 steps (`move`), rotate 15° steps (`angle`). */\n snap: { move: false, angle: false },\n /** Surface placement (host holds Shift): translate drags raycast-drop onto the geometry below. */\n surfaceSnap: false,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it, gizmo yields. */\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 / drag reporting) resolves through\n// `resolveKey`, so a part behaves like a node — except its persistence: the editor writes the\n// transform into the MODEL's `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/** The selection/report key of a live node: its def name, or its part key inside a model. */\nconst keyForNode = (node: Node): string | null =>\n state.names.get(node) ?? state.partNames.get(node) ?? 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/** The part key of a picked entity (walking up to the nearest enumerated part), if it is one. */\nconst partKeyOf = (node: Node): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const key = state.partNames.get(cur)\n if (key) return key\n if (state.names.get(cur)) return null // reached a def node without crossing a part\n cur = cur.parent\n }\n return null\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 snapAngle = (deg: number): number => state.snap.angle ? Math.round(deg / 15) * 15 : deg\nconst snapMove = (d: number): number => state.snap.move ? Math.round(d / 0.25) * 0.25 : d\nconst snapFactor = (f: number): number => state.snap.move ? Math.max(0.1, Math.round(f * 10) / 10) : f\n\n// The euler tuple saved to the scene file must reproduce the dragged rotation THROUGH the\n// node.eulerAngles setter (that's how the file re-applies it). The shipped engines apply that\n// setter as the INVERSE of the standard rotation (verified live via probe; transformUtils.cpp\n// builds the matrix transposed) — so the naive read-back does NOT round-trip. Try both\n// conventions on a probe node and keep the one that verifiably reproduces `q`.\nlet eulerProbe: Node | null = null\nconst eulerFor = (q: Quat): Vec3Tuple => {\n eulerProbe ??= new Node()\n const candidates = [ q.toEuler(\"YXZ\"), q.invert().toEuler(\"XYZ\") ]\n for (const e of candidates) {\n eulerProbe.eulerAngles = [ e.x, e.y, e.z ]\n if (eulerProbe.quaternion.sameRotation(q, 1e-4)) return [ e.x, e.y, e.z ]\n }\n return [ candidates[0].x, candidates[0].y, candidates[0].z ]\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 picked 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\n/** Make a user node pointer-pickable: CPU collider(s) from its renderable(s) — a model registers\n * every sub-renderable, hits resolve back up to the node — plus a Shape pick body on physics\n * builds (their raycast only sees Jolt bodies). */\nconst installPickColliders = (node: Node): void => {\n // editor-drawn helper meshes (path lines, node markers) must not register colliders — a\n // polyline's bbox would eat clicks across its whole span; markers pick via the owner's box below\n node.traverse((n) => { if (!isEditorNode(n)) _creator.setColliderFromMesh(n.id, n.id, 0) })\n // marker-carrying nodes (empties/cameras — edit-mode `__editor_marker` children, see the SDK's\n // addEditorMarker) have no renderable of their own: give the node itself a box collider sized\n // to its marker so it clicks in the viewport\n const marker = (node as { _sceneMarker?: string })._sceneMarker\n if (marker === \"empty\") _creator.setColliderBox(node.id, 0, 0, 0, 0.6, 0.6, 0.6)\n else if (marker === \"camera\") _creator.setColliderBox(node.id, 0, 0.05, -0.27, 0.6, 0.56, 0.6)\n if (_creator.physicsHasSupport && _creator.physicsHasSupport()) {\n if (node instanceof Mesh) {\n try { node.aspect(Shape, {}) } catch { /* no derivable shape — stays unpickable by physics ray */ }\n } else if (marker) {\n // raycastView checks Jolt pick bodies BEFORE scene CPU colliders — on physics builds a\n // marker needs its own body or any mesh along the ray outpicks it (half-extents box)\n try { node.aspect(Shape, { box: [ 0.3, 0.3, 0.3 ] }) } catch { /* unpickable */ }\n }\n }\n}\n\n/** `locked: true` def nodes: selectable and field-editable, but never a gizmo target. */\nconst isLocked = (node: Node | null): boolean =>\n (node as { _sceneLocked?: boolean } | null)?._sceneLocked === true\n\nconst gizmoTarget = (name: string | null): Node | null => {\n if (!name || state.activeTool) return null // an active plugin tool owns the viewport\n const node = resolveKey(name)\n return isLocked(node) ? null : node\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n state.gizmo?.setTarget(gizmoTarget(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 the Jolt pick bodies on physics builds\n * (installPickColliders attaches a Shape per mesh in edit mode), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree — a surface-dragged node must not\n * catch its own ray (nor may gizmo/grid editor nodes). */\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) carry invisible pick bodies for SELECTION only —\n // placement/tool rays step past them like editor 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\n/** Track a gizmo drag: `apply` turns pointer coords into the new local value (and applies it to\n * the live node); every move/end reports through the callbacks with the changed key. */\nconst trackTransform = (\n ev: TouchStartEvent<Node>,\n key: TransformKey,\n apply: (screenX: number, screenY: number) => Vec3Tuple | null,\n): void => {\n const name = keyForNode(state.gizmo!.target!)\n ev.track({\n onMove: (pos) => {\n const value = apply(pos.clientX, pos.clientY)\n if (value && name) {\n notifyEditorChanged(name)\n controller.callbacks.onTransform?.(name, key, value)\n }\n },\n onEnd: (pos) => {\n state.gizmo?.solo(null)\n const value = apply(pos.clientX, pos.clientY)\n if (value && name) {\n notifyEditorChanged(name)\n controller.callbacks.onTransformEnd?.(name, key, value)\n }\n },\n })\n}\n\n/** Translate: world positions from the drag plane, applied as a local-position delta rotated\n * into the parent's space (exact for rotated parents too). While the host holds Shift\n * (`surfaceSnap`), the axis constraint yields to SURFACE PLACEMENT: the node's pivot follows the\n * raycast hit under the cursor (its own subtree stepped past), ground plane on a miss. */\nconst beginTranslate = (drag: DragHandle): ((x: number, y: number) => Vec3Tuple | null) => {\n const target = state.gizmo!.target!\n const startLocal = target.position\n const startWorld = target.worldPosition\n const parentInv = target.parent ? target.parent.worldQuaternion.invert() : Quat.identity\n return (x, y) => {\n let local: Vec3Tuple\n if (state.surfaceSnap) {\n const hit = raycastViewport(x, y, target)\n if (!hit) return null\n const d = parentInv.rotateVec3(new Vec3(hit.point[0], hit.point[1], hit.point[2]).sub(startWorld))\n local = [ startLocal.x + d.x, startLocal.y + d.y, startLocal.z + d.z ]\n } else {\n const world = drag.move(x, y)\n if (!world) return null\n const d = parentInv.rotateVec3(world.sub(startWorld))\n local = [\n startLocal.x + snapMove(d.x),\n startLocal.y + snapMove(d.y),\n startLocal.z + snapMove(d.z),\n ]\n }\n target.position = local\n state.gizmo!.sync()\n return local\n }\n}\n\n/** The pointer-down dispatch for a grabbed handle (arrow group or ring), by the gizmo's mode.\n * `grab` is the handle group to keep visible while dragging. */\nconst beginHandleDrag = (ev: TouchStartEvent<Node>, axis: 0 | 1 | 2, grab: Node): boolean => {\n const gizmo = state.gizmo\n const target = gizmo?.target\n if (!gizmo || !target) return false\n\n if (gizmo.mode === \"rotate\") {\n const handle = gizmo.beginRotate(axis, ev.clientX, ev.clientY)\n if (!handle) return false\n // Rotation about the gizmo axis (world axis in global space, the target's own axis in local\n // space) composed in quaternion space: local' = parent⁻¹ ⊗ R ⊗ parent ⊗ local — correct for\n // pre-rotated nodes and rotated parents. The euler tuple written to the file is read BACK\n // from the node, so it's exactly the engine's representation of the result.\n const startLocal = target.quaternion\n const parentQ = target.parent ? target.parent.worldQuaternion : Quat.identity\n const parentInv = parentQ.invert()\n gizmo.solo(grab)\n trackTransform(ev, \"eulerAngles\", (x, y) => {\n const angle = handle.move(x, y)\n if (angle === null) return null\n const spin = Quat.fromAxisAngle(handle.dir, snapAngle(angle) * Math.PI / 180)\n const q = parentInv.mul(spin).mul(parentQ).mul(startLocal)\n target.quaternion = q\n return eulerFor(q)\n })\n return true\n }\n\n if (gizmo.mode === \"scale\") {\n const handle = gizmo.beginScale(axis, ev.clientX, ev.clientY)\n if (!handle) return false\n const start = target.scale\n const startTuple: Vec3Tuple = [ start.x, start.y, start.z ]\n gizmo.solo(grab)\n trackTransform(ev, \"scale\", (x, y) => {\n const factor = handle.move(x, y)\n if (factor === null) return null\n const next: Vec3Tuple = [ startTuple[0], startTuple[1], startTuple[2] ]\n next[axis] = startTuple[axis] * snapFactor(factor)\n target.scale = next\n return next\n })\n return true\n }\n\n const drag = gizmo.beginDrag(axis, ev.clientX, ev.clientY)\n if (!drag) return false\n gizmo.solo(grab)\n trackTransform(ev, \"position\", beginTranslate(drag))\n return true\n}\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 const grid = createGrid()\n scene.add(...grid)\n\n // overlay BEFORE open(): openScene syncs the viewport of overlays that exist at open time\n state.overlay = scene.createOverlay({ ibl: false })\n state.gizmo = new Gizmo(scene.camera)\n state.overlay.add(...state.gizmo.parts)\n scene.open()\n\n // colliders go into the entity's CONTAINING scene — only valid after draw-set membership.\n // setTarget(null) AFTER installColliders: hiding the root cascades over the freshly\n // registered colliders and deactivates them (hidden gizmos must not eat clicks).\n for (const node of Object.values(nodes)) installPickColliders(node)\n state.gizmo.installColliders()\n state.gizmo.setTarget(null)\n\n state.orbit = new OrbitCamera(scene.camera)\n setLoop(() => state.gizmo?.sync())\n\n const debug = (globalThis as any).__lecodesSceneDebug\n ? (...args: unknown[]) => console.log(\"[scene-editor]\", ...args)\n : () => {}\n\n // gizmo handles claim their touch via native picking (collider hit → node touchstart);\n // ring hits arrive on the segment nodes, so every segment gets the listener\n for (const { node, axis } of [ ...state.gizmo.translateArrows, ...state.gizmo.scaleArrows ]) {\n node.addEventListener(\"touchstart\", (ev) => {\n const started = beginHandleDrag(ev, axis, node)\n debug(\"handle touchstart\", \"xyz\"[axis], state.gizmo?.mode, started ? \"ok\" : \"null\")\n })\n }\n for (const ring of state.gizmo.rings) {\n for (const seg of ring.segments) {\n seg.addEventListener(\"touchstart\", (ev) => {\n const started = beginHandleDrag(ev, ring.axis, ring.group)\n debug(\"ring touchstart\", \"xyz\"[ring.axis], started ? \"ok\" : \"null\")\n })\n }\n }\n state.gizmo.center.addEventListener(\"touchstart\", (ev) => {\n const gizmo = state.gizmo\n const target = gizmo?.target\n if (!gizmo || !target || gizmo.mode !== \"scale\") return\n const handle = gizmo.beginScaleUniform(ev.clientX, ev.clientY)\n if (!handle) return\n const start = target.scale\n const startTuple: Vec3Tuple = [ start.x, start.y, start.z ]\n gizmo.solo(gizmo.center)\n trackTransform(ev, \"scale\", (x, y) => {\n const factor = snapFactor(handle(x, y))\n const next: Vec3Tuple = [ startTuple[0] * factor, startTuple[1] * factor, startTuple[2] * factor ]\n target.scale = next\n return next\n })\n debug(\"center touchstart uniform-scale ok\")\n })\n\n // any unclaimed drag orbits\n scene.addEventListener(\"touchstart\", (ev) => {\n debug(\"scene touchstart hit:\", ev.target ? `${ev.target.name || \"?\"} (#${ev.target.id})` : \"nothing\")\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n scene.addEventListener(\"click\", (ev) => {\n // an active plugin tool owns viewport clicks — they arrive as raycast hits, not selections\n if (state.activeTool) {\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (tool?.hooks.onViewportClick) {\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 return\n }\n }\n // node markers (empties/cameras) select their OWNER; every other editor node eats the click\n if (ev.target && isEditorNode(ev.target)\n && !(ev.target.name ?? \"\").startsWith(\"__editor_marker\")) return\n const name = ownerName(ev.target)\n // drill-down: clicking a model that is ALREADY selected (or one of its parts) selects the\n // GLB part under the cursor — first click grabs the model, the next one goes inside\n if (name && ev.target && state.selected &&\n (state.selected === name || state.selected.startsWith(name + PART_SEP))) {\n const part = partKeyOf(ev.target)\n if (part && part !== state.selected) { applySelection(part, true); return }\n }\n applySelection(name, true)\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve for selection/drill-down even 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 /** 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 ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n if (name === state.selected) state.gizmo?.setTarget(gizmoTarget(name))\n return\n }\n notifyEditorChanged(name)\n if (name === state.selected) state.gizmo?.sync()\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 if (node) installPickColliders(node)\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 // the selected node object was just replaced (or removed) — re-target the gizmo\n const alive = state.selected === name ? node : (state.selected ? resolveKey(state.selected) : null)\n state.gizmo?.setTarget(state.selected ? gizmoTarget(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 if (state.selected === name) state.gizmo?.sync()\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 /** Switch the gizmo between translate / rotate / scale. */\n setGizmoMode(mode: GizmoMode): void { state.gizmo?.setMode(mode) },\n\n /** Gizmo orientation: world axes (\"global\") or the target's axes (\"local\"). Scale is always local. */\n setGizmoSpace(space: GizmoSpace): void { state.gizmo?.setSpace(space) },\n\n /** Drag snapping (toolbar toggles XOR the host's Ctrl key): `move` = 0.25u move + 0.1 scale\n * steps, `angle` = 15° rotate steps. A plain boolean (older hosts) sets both. */\n setSnap(on: boolean | { move?: boolean, angle?: boolean }): void {\n if (typeof on === \"boolean\") state.snap = { move: on, angle: on }\n else state.snap = { move: on?.move === true, angle: on?.angle === true }\n },\n\n /** Surface placement (host mirrors its Shift key): translate drags follow the raycast hit\n * under the cursor instead of the grabbed axis — see beginTranslate. */\n setSurfaceSnap(on: boolean): void { state.surfaceSnap = on === true },\n\n /** Raycast under a viewport pixel (mesh pick bodies, ground-plane fallback) — the host's\n * click/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`) — toolbar entries beside move/rotate/scale. */\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 = back to the gizmo). While active, viewport clicks route to\n * the tool's hooks and the transform gizmo hides; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n state.gizmo?.setTarget(gizmoTarget(state.selected))\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 /** Native view pick under a viewport pixel (the touch system's own raycast — overlay colliders\n * included, so gizmo handles answer). Entity id, 0 on miss. CDP drag-driving only. */\n _pick(x: number, y: number): number {\n return (_creator as { _raycastView?(x: number, y: number): number })._raycastView?.(x, y) ?? 0\n },\n\n /** World point → viewport pixel under the live camera: a pinhole estimate (fov/displaySize)\n * Newton-refined against the engine's OWN `getViewDirection`, so a ray cast through the\n * returned pixel passes exactly through `p` (the engine's projection details — offsets,\n * exact fov semantics — don't have to match the estimate). Null when behind the camera.\n * Used to aim CDP-driven drags. */\n _screenPos(p: Vec3Tuple): [number, number] | null {\n const cam = state.scene?.camera\n if (!cam) return null\n const camPos = cam.worldPosition\n const v = cam.worldQuaternion.invert().rotateVec3(new Vec3(p[0], p[1], p[2]).sub(camPos))\n if (v.z >= -1e-6) return null // cameras look down their local -Z\n const [ w, h ] = cam.displaySize\n const tanV = Math.tan(cam.fov * Math.PI / 360)\n let x = ((v.x / -v.z) / (tanV * (w / h)) + 1) / 2 * w\n let y = (1 - (v.y / -v.z) / tanV) / 2 * h\n const want = new Vec3(p[0], p[1], p[2]).sub(camPos).normalize()\n for (let i = 0; i < 8; i++) {\n const d0 = cam.getViewDirection(x, y)\n const dx = cam.getViewDirection(x + 2, y).sub(d0)\n const dy = cam.getViewDirection(x, y + 2).sub(d0)\n const err = want.sub(d0)\n // least-squares 2×2 solve of [dx dy]·s ≈ err, step in px\n const a11 = dx.dot(dx), a12 = dx.dot(dy), a22 = dy.dot(dy)\n const det = a11 * a22 - a12 * a12\n if (Math.abs(det) < 1e-18) break\n const sx = ((dx.dot(err) * a22 - dy.dot(err) * a12) / det) * 2\n const sy = ((a11 * dy.dot(err) - a12 * dx.dot(err)) / det) * 2\n x += sx\n y += sy\n if (Math.abs(sx) + Math.abs(sy) < 1e-3) break\n }\n return [ x, y ]\n },\n\n /** Introspection for driving the editor headlessly (CDP debugging): per-part visibility flags\n * plus a live euler-semantics probe against the running engine. */\n _debug(): unknown {\n const g = state.gizmo\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 mode: g?.mode,\n space: g?.space,\n snap: { ...state.snap, surface: state.surfaceSnap },\n target: g?.target ? keyForNode(g.target) : null, // def name OR `<owner>::<path>` part key\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n parts: g?.parts.map((n) => ({ name: n.name || \"(mesh)\", id: n.id, visible: n.visible })),\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 (the proven gizmo pattern; never compose eulers by hand). */\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 if (name === state.selected) state.gizmo?.sync()\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","gizmo.ts":"// Transform gizmo: three modes on an overlay scene (always on top, shares the user scene's\n// camera) — translate (cone arrows), rotate (torus rings, like standard 3D editors), scale\n// (cube arrows + a center cube for uniform scale). Grabbing uses NATIVE picking — handles get\n// colliders, the engine's raycastView hits them and dispatches touchstart on the handle node.\n//\n// VISIBILITY RULE (learned in-browser, twice): creator-gl's setVisible interacts with the\n// subtree in ways that differ between what renderable.cpp reads and what the shipped wasm does —\n// ancestor shows clobbered per-leaf flags in one build, group-level shows didn't propagate in\n// another. So the gizmo NEVER relies on cascades: `refresh()` re-derives the state from\n// (mode, grabbed) and writes an explicit flag on EVERY node, top-down (a node is visible only if\n// its whole ancestor chain is). Explicit per-node calls also (de)activate each node's collider.\n//\n// ORIENTATION: `space` = global (world axes) or local (the target's axes). Scale ALWAYS uses\n// local axes — node.scale is local, so a world-aligned scale handle on a rotated node is a lie.\n//\n// EULER RULE: older shipped engines apply setEulerAngles as the INVERSE of the standard rotation\n// (transformUtils.cpp stored the matrix transposed — fixed on web 2026-07-10; native hosts pick\n// the fix up on their next creator-gl rebuild), while setQuaternion was always faithful. All\n// gizmo construction therefore orients through QUATERNION setters only — never node.eulerAngles —\n// so it renders correctly on both engine generations.\n//\n// Drag math per mode:\n// translate — project the pointer ray onto the grabbed axis via a camera-facing plane through\n// the gizmo (the method proven by the legacy scene-viewer gizmo).\n// rotate — intersect rays with the plane PERPENDICULAR to the axis through the gizmo and\n// take the signed angle between hit points (the ring lies in that plane, so the grab point\n// is exactly where the finger is); screen-space fallback when the plane is edge-on.\n// scale — the translate projection turned into a factor relative to the gizmo's size.\n\nconst AXIS_COLORS = [ \"#ff4545\", \"#3fe04d\", \"#4079ff\" ] as const\nconst AXIS_DIRS: [number, number, number][] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n// Arrow/cube handles render with a porcelain matcap (gizmo.filamat samples the matcap by\n// view-space normal, tinted by `color`) when the host preloaded both resources — the scene\n// editor's web host does. Otherwise (native hosts, older viewers) they fall back to flat unlit\n// color. The matcap texture decodes async: materials come up flat and get the sampler as soon as\n// it lands (the gizmo is hidden until the first selection anyway). Rotation RINGS stay unlit on\n// purpose — normal-based matcap shading reads as noise on thin tubes.\nlet matcapTexture: Texture | null = null\nconst matcapPending: Material[] = []\nconst createHandleMaterial = (color: string): Material => {\n const shader = _creatorUtils.fetchLocal(\"gizmo.filamat\")\n const image = _creatorUtils.fetchLocal(\"matcap_porcelain_white.jpg\")\n if (shader < 0 || image < 0) return Material.unlit({ color })\n const m = new Material({ _id: shader } as any).set(\"color\", color + \"ff\")\n if (matcapTexture) {\n m.set(\"matcap\", matcapTexture)\n } else {\n matcapPending.push(m)\n if (matcapPending.length === 1) {\n Texture.load({ _id: image } as any).then((t) => {\n matcapTexture = t\n for (const mat of matcapPending) mat.set(\"matcap\", t)\n matcapPending.length = 0\n }).catch(() => {})\n }\n }\n return m\n}\n\nexport type GizmoMode = \"translate\" | \"rotate\" | \"scale\"\nexport type GizmoSpace = \"global\" | \"local\"\n\nconst RAD2DEG_F = 180 / Math.PI\n\n// The engine has no torus primitive and mesh colliders are bbox-only (collider.cpp\n// setColliderFromMesh), so each ring is a circle of short cylinder segments — their individual\n// bboxes approximate torus picking.\nconst RING_RADIUS = 1.0\nconst RING_SEGMENTS = 24\n\n/** Overall gizmo size: world scale = camera distance × this (approx. constant on screen). */\nexport const GIZMO_SCREEN_SIZE = 0.14\n\ntype AxisHandle = { node: Node, axis: 0 | 1 | 2 }\n\n/** Set `visible` on a node AND every descendant, parents first — see the refresh() note. */\nconst setSubtree = (node: Node, visible: boolean): void => {\n node.visible = visible\n for (const child of node.children) setSubtree(child, visible)\n}\n\n/** Shaft + tip along +Y, rotated onto its world axis. Tip: cone (translate) or cube (scale). */\nconst createArrow = (axis: 0 | 1 | 2, tip: \"cone\" | \"cube\"): Node => {\n const material = createHandleMaterial(AXIS_COLORS[axis])\n const group = new Node()\n const shaft = Mesh.cylinder({ radius: 0.014, material, position: [ 0, 0.4, 0 ], scale: [ 1, 0.8, 1 ] })\n const tipMesh = tip === \"cone\"\n ? Mesh.cylinder({ radiusBottom: 0.048, radiusTop: 0.0001, material, position: [ 0, 0.9, 0 ], scale: [ 1, 0.2, 1 ] })\n : Mesh.box({ size: 0.095, material, position: [ 0, 0.9, 0 ] })\n for (const m of [ shaft, tipMesh ]) { m.castShadows = false; m.receiveShadows = false }\n group.add(shaft, tipMesh)\n // the arrow is modeled along +Y; rotate the group onto its axis — QUATERNIONS ONLY (see header)\n if (axis === 0) group.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], -Math.PI / 2) // +Y → +X\n if (axis === 2) group.quaternion = Quat.fromAxisAngle([ 1, 0, 0 ], Math.PI / 2) // +Y → +Z\n group.name = `__editor_gizmo_${tip}_${\"xyz\"[axis]}`\n return group\n}\n\n/** Segmented torus ring around `axis` (built in the XY plane, then rotated onto the axis).\n * Rings are UNLIT flat color (see the material note above). `dirs` is each segment's outward\n * unit direction in gizmo-root space — sync() uses it to show only the camera-facing half of\n * each ring; hiding a segment also deactivates its collider, so only the front arc is\n * grabbable. While a rotation drag is active, refresh() brings the full circle back. */\nconst createRing = (axis: 0 | 1 | 2, segGeo: Geometry): { group: Node, segments: Mesh[], dirs: Vec3[] } => {\n const material = Material.unlit({ color: AXIS_COLORS[axis] })\n const group = new Node()\n const segments: Mesh[] = []\n const dirs: Vec3[] = []\n const groupQuat = axis === 0 ? Quat.fromAxisAngle([ 0, 1, 0 ], Math.PI / 2) // XY ring → YZ plane (around X)\n : axis === 1 ? Quat.fromAxisAngle([ 1, 0, 0 ], Math.PI / 2) // XY ring → XZ plane (around Y)\n : Quat.identity\n const segLen = 2 * Math.PI * RING_RADIUS / RING_SEGMENTS + 0.02\n for (let i = 0; i < RING_SEGMENTS; i++) {\n const theta = (i / RING_SEGMENTS) * 2 * Math.PI\n const seg = new Mesh(segGeo, material)\n seg.position = [ Math.cos(theta) * RING_RADIUS, Math.sin(theta) * RING_RADIUS, 0 ]\n // rotating +Y (the cylinder axis) by θ about Z gives (−sinθ, cosθ) — the tangent at θ\n seg.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], theta)\n seg.scale = [ 1, segLen, 1 ]\n seg.castShadows = false\n seg.receiveShadows = false\n segments.push(seg)\n dirs.push(groupQuat.rotateVec3([ Math.cos(theta), Math.sin(theta), 0 ]))\n }\n group.add(...segments)\n group.quaternion = groupQuat\n group.name = `__editor_gizmo_ring_${\"xyz\"[axis]}`\n return { group, segments, dirs }\n}\n\nexport type DragHandle = {\n axis: 0 | 1 | 2\n /** New world position of the target for a pointer at (x, y); null when the ray misses the plane. */\n move(screenX: number, screenY: number): Vec3 | null\n}\n\nexport type RotateHandle = {\n axis: 0 | 1 | 2\n /** World-space rotation axis at grab time (the gizmo's axis in the current space). */\n dir: Vec3\n /** Signed rotation (degrees) around `dir` since the grab; null when the ray misses. */\n move(screenX: number, screenY: number): number | null\n}\n\nexport type ScaleHandle = {\n axis: 0 | 1 | 2\n /** Scale factor along the axis since the grab (1 = unchanged); null when the ray misses. */\n move(screenX: number, screenY: number): number | null\n}\n\nexport class Gizmo {\n readonly root: Node\n /** All gizmo nodes (for overlay draw-set membership). */\n readonly parts: Node[]\n /** Grabbable arrow groups (translate mode) — main.ts wires touchstart on these. */\n readonly translateArrows: AxisHandle[]\n /** Grabbable arrow groups (scale mode). */\n readonly scaleArrows: AxisHandle[]\n /** Rings (rotate mode) — hits arrive on the SEGMENT nodes, wire touchstart on each. */\n readonly rings: { group: Node, segments: Mesh[], dirs: Vec3[], axis: 0 | 1 | 2 }[]\n /** Last visibility written per ring segment — Node.visible reads hit the bridge, so sync()\n * diffs against this cache instead. refresh() re-fills it with whatever it wrote. */\n private readonly ringVis: boolean[][]\n /** Uniform-scale center cube (scale mode). */\n readonly center: Mesh\n mode: GizmoMode = \"translate\"\n space: GizmoSpace = \"global\"\n private readonly modeRoots: Record<GizmoMode, Node>\n private grabbed: Node | null = null\n private _target: Node | null = null\n\n constructor(private readonly camera: Camera) {\n this.root = new Node()\n this.root.name = \"__editor_gizmo\"\n const translateRoot = new Node()\n const rotateRoot = new Node()\n const scaleRoot = new Node()\n translateRoot.name = \"__editor_gizmo_translate\"\n rotateRoot.name = \"__editor_gizmo_rotate\"\n scaleRoot.name = \"__editor_gizmo_scale\"\n this.modeRoots = { translate: translateRoot, rotate: rotateRoot, scale: scaleRoot }\n\n this.translateArrows = ([ 0, 1, 2 ] as const).map((axis) => ({ node: createArrow(axis, \"cone\"), axis }))\n translateRoot.add(...this.translateArrows.map((a) => a.node))\n\n const segGeo = Geometry.cylinder({ radius: 0.028, edges: 8 })\n this.rings = ([ 0, 1, 2 ] as const).map((axis) => ({ ...createRing(axis, segGeo), axis }))\n this.ringVis = this.rings.map((r) => r.segments.map(() => true))\n rotateRoot.add(...this.rings.map((r) => r.group))\n\n this.scaleArrows = ([ 0, 1, 2 ] as const).map((axis) => ({ node: createArrow(axis, \"cube\"), axis }))\n this.center = Mesh.box({ size: 0.11, material: createHandleMaterial(\"#e8e8e8\") })\n this.center.castShadows = false\n this.center.receiveShadows = false\n this.center.name = \"__editor_gizmo_center\"\n scaleRoot.add(...this.scaleArrows.map((a) => a.node), this.center)\n\n this.root.add(translateRoot, rotateRoot, scaleRoot)\n const parts: Node[] = []\n const collect = (n: Node) => { parts.push(n); for (const c of n.children) collect(c) }\n collect(this.root)\n this.parts = parts\n }\n\n /** Register pick colliders on the handles. Call AFTER the parts joined the overlay's draw set\n * (the native collider store is per containing scene) and BEFORE the first visibility refresh\n * (colliders registered on already-hidden nodes would stay active — hide AFTER registering). */\n installColliders(): void {\n for (const { node } of this.translateArrows) {\n _creator.setColliderBox(node.id, 0, 0.55, 0, 0.24, 1.1, 0.24)\n }\n // scale arrows start above the origin so the uniform-scale center cube owns it\n for (const { node } of this.scaleArrows) {\n _creator.setColliderBox(node.id, 0, 0.65, 0, 0.24, 0.9, 0.24)\n }\n for (const ring of this.rings) {\n for (const seg of ring.segments) _creator.setColliderFromMesh(seg.id, seg.id, 0)\n }\n _creator.setColliderFromMesh(this.center.id, this.center.id, 0)\n }\n\n /** Re-derive EVERY gizmo node's visibility from (mode, grabbed), explicitly and top-down\n * (parents before children; a node is visible only if its whole ancestor chain is). This is\n * deliberately cascade-independent: in-browser, group-level shows did not propagate to\n * descendants the way renderable.cpp reads, so no node's render state may depend on an\n * ancestor's cascade — and explicit per-node calls also (de)activate each node's collider. */\n private refresh(): void {\n const modeOn = (m: GizmoMode) => m === this.mode\n const groupOn = (group: Node) => this.grabbed === null || this.grabbed === group\n for (const m of [ \"translate\", \"rotate\", \"scale\" ] as const) this.modeRoots[m].visible = modeOn(m)\n for (const { node } of this.translateArrows) setSubtree(node, modeOn(\"translate\") && groupOn(node))\n for (let ri = 0; ri < this.rings.length; ri++) {\n const ring = this.rings[ri]\n const on = modeOn(\"rotate\") && groupOn(ring.group)\n setSubtree(ring.group, on)\n this.ringVis[ri].fill(on)\n }\n for (const { node } of this.scaleArrows) setSubtree(node, modeOn(\"scale\") && groupOn(node))\n this.center.visible = modeOn(\"scale\") && groupOn(this.center)\n }\n\n setMode(mode: GizmoMode): void {\n this.mode = mode\n if (this._target) this.refresh()\n this.sync()\n }\n\n setSpace(space: GizmoSpace): void {\n this.space = space\n this.sync()\n }\n\n /** While dragging, show only the grabbed handle (arrow group / ring group / center); null restores. */\n solo(grabbed: Node | null): void {\n this.grabbed = grabbed\n if (this._target) this.refresh()\n }\n\n get target(): Node | null { return this._target }\n\n setTarget(node: Node | null): void {\n this._target = node\n this.grabbed = null\n if (node) {\n this.root.visible = true\n this.refresh()\n } else {\n // explicit subtree hide (cascade-independent) — also deactivates every gizmo collider,\n // so a hidden gizmo can't eat clicks\n setSubtree(this.root, false)\n }\n this.sync()\n }\n\n /** The gizmo's orientation: the target's world rotation in local space (scale is ALWAYS local —\n * node.scale is a local property), world axes otherwise. */\n private orientation(): Quat {\n if (this._target && (this.space === \"local\" || this.mode === \"scale\")) {\n return this._target.worldQuaternion\n }\n return Quat.identity\n }\n\n /** The world-space direction of a handle axis under the current orientation. */\n axisDir(axis: 0 | 1 | 2): Vec3 {\n return this.orientation().rotateVec3(AXIS_DIRS[axis]).normalize()\n }\n\n /** Follow the target (+ orientation) and keep an approximately constant screen size. Call every frame. */\n sync(): void {\n if (!this._target) return\n const p = this._target.worldPosition\n this.root.position = [ p.x, p.y, p.z ]\n const q = this.orientation()\n this.root.quaternion = q\n const cam = this.camera.worldPosition\n const dist = Math.hypot(p.x - cam.x, p.y - cam.y, p.z - cam.z)\n const s = Math.max(0.0001, dist * GIZMO_SCREEN_SIZE)\n this.root.scale = [ s, s, s ]\n\n // Rotate mode shows only the camera-facing half of each ring (à la three.js/Blender); the\n // hidden segments also drop their colliders, so the back arc can't be grabbed through the\n // model. Skipped while a drag is active — refresh() shows the grabbed ring's full circle\n // for the duration of the rotation.\n if (this.mode === \"rotate\" && this.grabbed === null) {\n const inv = 1 / Math.max(1e-6, dist)\n const vx = (cam.x - p.x) * inv, vy = (cam.y - p.y) * inv, vz = (cam.z - p.z) * inv\n for (let ri = 0; ri < this.rings.length; ri++) {\n const ring = this.rings[ri]\n const vis = this.ringVis[ri]\n for (let i = 0; i < ring.segments.length; i++) {\n const w = q.rotateVec3(ring.dirs[i])\n // −0.1 margin: a ring seen face-on has every segment at dot ≈ 0 — it must stay a FULL\n // circle (Blender does the same), not flicker into dashes at the silhouette\n const facing = w.x * vx + w.y * vy + w.z * vz > -0.1\n if (vis[i] !== facing) {\n vis[i] = facing\n ring.segments[i].visible = facing\n }\n }\n }\n }\n }\n\n /**\n * Start dragging along an axis from the pointer-down position (viewport coords). The drag plane\n * contains the axis and faces the camera: normal = (camForward × dir) × dir — every subsequent\n * ray is intersected with it and the delta projected onto the axis (the legacy gizmo's method).\n */\n beginDrag(axis: 0 | 1 | 2, screenX: number, screenY: number): DragHandle | null {\n if (!this._target) return null\n const dir = this.axisDir(axis)\n const origin = this.root.worldPosition\n\n const right = this.camera.forward.cross(dir)\n const normal = right.cross(dir)\n const plane = new Plane(normal, origin)\n\n const startRay = this.camera.getRay(screenX, screenY)\n const t0 = plane.intersectLine(startRay)\n if (t0 === null) return null\n const initialPoint = startRay.getPoint(t0)\n\n const startTarget = this._target.worldPosition\n\n return {\n axis,\n move: (x: number, y: number): Vec3 | null => {\n const ray = this.camera.getRay(x, y)\n const t = plane.intersectLine(ray)\n if (t === null) return null\n const delta = ray.getPoint(t).sub(initialPoint)\n return startTarget.add(dir.scale(dir.dot(delta)))\n },\n }\n }\n\n /** Start rotating around an axis: signed angle between plane hits around the gizmo center. */\n beginRotate(axis: 0 | 1 | 2, screenX: number, screenY: number): RotateHandle | null {\n if (!this._target) return null\n const dir = this.axisDir(axis)\n const origin = this.root.worldPosition\n\n // Plane nearly edge-on (axis ⊥ view direction) → the intersection blows up; use a\n // screen-space fallback: horizontal+vertical pointer travel in degrees.\n if (Math.abs(this.camera.forward.dot(dir)) < 0.25) {\n const sx = screenX, sy = screenY\n return { axis, dir, move: (x, y) => (x - sx + y - sy) * 0.4 }\n }\n\n const plane = new Plane(dir, origin)\n const startRay = this.camera.getRay(screenX, screenY)\n const t0 = plane.intersectLine(startRay)\n if (t0 === null) return null\n const p0 = startRay.getPoint(t0).sub(origin)\n\n return {\n axis,\n dir,\n move: (x: number, y: number): number | null => {\n const ray = this.camera.getRay(x, y)\n const t = plane.intersectLine(ray)\n if (t === null) return null\n const p = ray.getPoint(t).sub(origin)\n return Math.atan2(p0.cross(p).dot(dir), p0.dot(p)) * RAD2DEG_F\n },\n }\n }\n\n /** Start a uniform scale from the center handle: screen-space drag → factor (right/up = grow). */\n beginScaleUniform(screenX: number, screenY: number): ((x: number, y: number) => number) | null {\n if (!this._target) return null\n const sx = screenX, sy = screenY\n return (x, y) => Math.max(0.01, 1 + ((x - sx) - (y - sy)) * 0.005)\n }\n\n /** Start scaling along a LOCAL axis: the translate projection relative to the gizmo's size. */\n beginScale(axis: 0 | 1 | 2, screenX: number, screenY: number): ScaleHandle | null {\n const drag = this.beginDrag(axis, screenX, screenY)\n if (!drag || !this._target) return null\n const dir = this.axisDir(axis)\n const startTarget = this._target.worldPosition\n const size = Math.max(0.0001, this.root.scale.x)\n return {\n axis,\n move: (x: number, y: number): number | null => {\n const world = drag.move(x, y)\n if (!world) return null\n const along = dir.dot(world.sub(startTarget))\n return Math.max(0.01, 1 + along / size)\n },\n }\n }\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 { 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 const right: V3 = [ Math.cos(this.yaw), 0, -Math.sin(this.yaw) ]\n const sp = Math.sin(this.pitch), cp = Math.cos(this.pitch)\n const up: V3 = [ sp * Math.sin(this.yaw), cp, sp * Math.cos(this.yaw) ]\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","grid.ts":"// The editor ground grid: a 20×20 unit line grid + emphasized center axes, built as an \"edges\"\n// geometry (line rendering) with an unlit material. Lives in the USER's scene (depth-tested against\n// scene content) — the gizmo lives on an overlay instead.\n\nconst HALF = 10\n\n/** Returns the grid meshes — parented together, but each must join the scene's draw set. */\nexport const createGrid = (): Mesh[] => {\n const lines: number[] = []\n for (let i = -HALF; i <= HALF; i++) {\n if (i === 0) continue // center cross drawn separately (brighter)\n lines.push(i, 0, -HALF, i, 0, HALF)\n lines.push(-HALF, 0, i, HALF, 0, i)\n }\n\n const vertices = new Float32Array(lines)\n const count = vertices.length / 3\n const normals = new Float32Array(count * 3)\n for (let i = 0; i < count; i++) normals[i * 3 + 1] = 1\n const indices = new Uint16Array(count)\n for (let i = 0; i < count; i++) indices[i] = i\n const uv = new Float32Array(count * 2)\n\n const geo = new Geometry(vertices, normals, indices, uv)\n geo.kind = \"edges\"\n const grid = Mesh.from(geo, { material: Material.unlit({ color: \"#3a3f4a\" }), name: \"__editor_grid\" })\n grid.castShadows = false\n grid.receiveShadows = false\n\n // center axes: X / Z lines through the origin in their gizmo colors, slightly raised to win the\n // depth tie against the grid plane\n const axes = new Float32Array([ -HALF, 0.001, 0, HALF, 0.001, 0, 0, 0.001, -HALF, 0, 0.001, HALF ])\n const axisNormals = new Float32Array([ 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0 ])\n const axisGeo = new Geometry(axes, axisNormals, new Uint16Array([ 0, 1, 2, 3 ]), new Float32Array(8))\n axisGeo.kind = \"edges\"\n const cross = Mesh.from(axisGeo, { material: Material.unlit({ color: \"#8a8f9a\" }), name: \"__editor_grid_axes\" })\n grid.add(cross)\n\n return [ grid, cross ]\n}\n"}
1
+ {"gizmo.ts":"// Transform gizmo: three modes on an overlay scene (always on top, shares the user scene's\n// camera) — translate (cone arrows), rotate (torus rings, like standard 3D editors), scale\n// (cube arrows + a center cube for uniform scale). Grabbing uses NATIVE picking — handles get\n// colliders, the engine's raycastView hits them and dispatches touchstart on the handle node.\n//\n// VISIBILITY RULE (learned in-browser, twice): creator-gl's setVisible interacts with the\n// subtree in ways that differ between what renderable.cpp reads and what the shipped wasm does —\n// ancestor shows clobbered per-leaf flags in one build, group-level shows didn't propagate in\n// another. So the gizmo NEVER relies on cascades: `refresh()` re-derives the state from\n// (mode, grabbed) and writes an explicit flag on EVERY node, top-down (a node is visible only if\n// its whole ancestor chain is). Explicit per-node calls also (de)activate each node's collider.\n//\n// ORIENTATION: `space` = global (world axes) or local (the target's axes). Scale ALWAYS uses\n// local axes — node.scale is local, so a world-aligned scale handle on a rotated node is a lie.\n//\n// EULER RULE: older shipped engines apply setEulerAngles as the INVERSE of the standard rotation\n// (transformUtils.cpp stored the matrix transposed — fixed on web 2026-07-10; native hosts pick\n// the fix up on their next creator-gl rebuild), while setQuaternion was always faithful. All\n// gizmo construction therefore orients through QUATERNION setters only — never node.eulerAngles —\n// so it renders correctly on both engine generations.\n//\n// Drag math per mode:\n// translate — project the pointer ray onto the grabbed axis via a camera-facing plane through\n// the gizmo (the method proven by the legacy scene-viewer gizmo).\n// rotate — intersect rays with the plane PERPENDICULAR to the axis through the gizmo and\n// take the signed angle between hit points (the ring lies in that plane, so the grab point\n// is exactly where the finger is); screen-space fallback when the plane is edge-on.\n// scale — the translate projection turned into a factor relative to the gizmo's size.\n\nconst AXIS_COLORS = [ \"#ff4545\", \"#3fe04d\", \"#4079ff\" ] as const\nconst AXIS_DIRS: [number, number, number][] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n// Arrow/cube handles render with a porcelain matcap (gizmo.filamat samples the matcap by\n// view-space normal, tinted by `color`) when the host preloaded both resources — the scene\n// editor's web host does. Otherwise (native hosts, older viewers) they fall back to flat unlit\n// color. The matcap texture decodes async: materials come up flat and get the sampler as soon as\n// it lands (the gizmo is hidden until the first selection anyway). Rotation RINGS stay unlit on\n// purpose — normal-based matcap shading reads as noise on thin tubes.\nlet matcapTexture: Texture | null = null\nconst matcapPending: Material[] = []\nconst createHandleMaterial = (color: string): Material => {\n const shader = _creatorUtils.fetchLocal(\"gizmo.filamat\")\n const image = _creatorUtils.fetchLocal(\"matcap_porcelain_white.jpg\")\n if (shader < 0 || image < 0) return Material.unlit({ color })\n const m = new Material({ _id: shader } as any).set(\"color\", color + \"ff\")\n if (matcapTexture) {\n m.set(\"matcap\", matcapTexture)\n } else {\n matcapPending.push(m)\n if (matcapPending.length === 1) {\n Texture.load({ _id: image } as any).then((t) => {\n matcapTexture = t\n for (const mat of matcapPending) mat.set(\"matcap\", t)\n matcapPending.length = 0\n }).catch(() => {})\n }\n }\n return m\n}\n\nexport type GizmoMode = \"translate\" | \"rotate\" | \"scale\"\nexport type GizmoSpace = \"global\" | \"local\"\n\nconst RAD2DEG_F = 180 / Math.PI\n\n// The engine has no torus primitive and mesh colliders are bbox-only (collider.cpp\n// setColliderFromMesh), so each ring is a circle of short cylinder segments — their individual\n// bboxes approximate torus picking.\nconst RING_RADIUS = 1.0\nconst RING_SEGMENTS = 24\n\n/** Overall gizmo size: world scale = camera distance × this (approx. constant on screen). */\nexport const GIZMO_SCREEN_SIZE = 0.14\n\ntype AxisHandle = { node: Node, axis: 0 | 1 | 2 }\n\n/** Set `visible` on a node AND every descendant, parents first — see the refresh() note. */\nconst setSubtree = (node: Node, visible: boolean): void => {\n node.visible = visible\n for (const child of node.children) setSubtree(child, visible)\n}\n\n/** Shaft + tip along +Y, rotated onto its world axis. Tip: cone (translate) or cube (scale). */\nconst createArrow = (axis: 0 | 1 | 2, tip: \"cone\" | \"cube\"): Node => {\n const material = createHandleMaterial(AXIS_COLORS[axis])\n const group = new Node()\n const shaft = Mesh.cylinder({ radius: 0.014, material, position: [ 0, 0.4, 0 ], scale: [ 1, 0.8, 1 ] })\n const tipMesh = tip === \"cone\"\n ? Mesh.cylinder({ radiusBottom: 0.048, radiusTop: 0.0001, material, position: [ 0, 0.9, 0 ], scale: [ 1, 0.2, 1 ] })\n : Mesh.box({ size: 0.095, material, position: [ 0, 0.9, 0 ] })\n for (const m of [ shaft, tipMesh ]) { m.castShadows = false; m.receiveShadows = false }\n group.add(shaft, tipMesh)\n // the arrow is modeled along +Y; rotate the group onto its axis — QUATERNIONS ONLY (see header)\n if (axis === 0) group.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], -Math.PI / 2) // +Y → +X\n if (axis === 2) group.quaternion = Quat.fromAxisAngle([ 1, 0, 0 ], Math.PI / 2) // +Y → +Z\n group.name = `__editor_gizmo_${tip}_${\"xyz\"[axis]}`\n return group\n}\n\n/** Segmented torus ring around `axis` (built in the XY plane, then rotated onto the axis).\n * Rings are UNLIT flat color (see the material note above). `dirs` is each segment's outward\n * unit direction in gizmo-root space — sync() uses it to show only the camera-facing half of\n * each ring; hiding a segment also deactivates its collider, so only the front arc is\n * grabbable. While a rotation drag is active, refresh() brings the full circle back. */\nconst createRing = (axis: 0 | 1 | 2, segGeo: Geometry): { group: Node, segments: Mesh[], dirs: Vec3[] } => {\n const material = Material.unlit({ color: AXIS_COLORS[axis] })\n const group = new Node()\n const segments: Mesh[] = []\n const dirs: Vec3[] = []\n const groupQuat = axis === 0 ? Quat.fromAxisAngle([ 0, 1, 0 ], Math.PI / 2) // XY ring → YZ plane (around X)\n : axis === 1 ? Quat.fromAxisAngle([ 1, 0, 0 ], Math.PI / 2) // XY ring → XZ plane (around Y)\n : Quat.identity\n const segLen = 2 * Math.PI * RING_RADIUS / RING_SEGMENTS + 0.02\n for (let i = 0; i < RING_SEGMENTS; i++) {\n const theta = (i / RING_SEGMENTS) * 2 * Math.PI\n const seg = new Mesh(segGeo, material)\n seg.position = [ Math.cos(theta) * RING_RADIUS, Math.sin(theta) * RING_RADIUS, 0 ]\n // rotating +Y (the cylinder axis) by θ about Z gives (−sinθ, cosθ) — the tangent at θ\n seg.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], theta)\n seg.scale = [ 1, segLen, 1 ]\n seg.castShadows = false\n seg.receiveShadows = false\n segments.push(seg)\n dirs.push(groupQuat.rotateVec3([ Math.cos(theta), Math.sin(theta), 0 ]))\n }\n group.add(...segments)\n group.quaternion = groupQuat\n group.name = `__editor_gizmo_ring_${\"xyz\"[axis]}`\n return { group, segments, dirs }\n}\n\nexport type DragHandle = {\n axis: 0 | 1 | 2\n /** New world position of the target for a pointer at (x, y); null when the ray misses the plane. */\n move(screenX: number, screenY: number): Vec3 | null\n}\n\nexport type RotateHandle = {\n axis: 0 | 1 | 2\n /** World-space rotation axis at grab time (the gizmo's axis in the current space). */\n dir: Vec3\n /** Signed rotation (degrees) around `dir` since the grab; null when the ray misses. */\n move(screenX: number, screenY: number): number | null\n}\n\nexport type ScaleHandle = {\n axis: 0 | 1 | 2\n /** Scale factor along the axis since the grab (1 = unchanged); null when the ray misses. */\n move(screenX: number, screenY: number): number | null\n}\n\nexport class Gizmo {\n readonly root: Node\n /** All gizmo nodes (for overlay draw-set membership). */\n readonly parts: Node[]\n /** Grabbable arrow groups (translate mode) — main.ts wires touchstart on these. */\n readonly translateArrows: AxisHandle[]\n /** Grabbable arrow groups (scale mode). */\n readonly scaleArrows: AxisHandle[]\n /** Rings (rotate mode) — hits arrive on the SEGMENT nodes, wire touchstart on each. */\n readonly rings: { group: Node, segments: Mesh[], dirs: Vec3[], axis: 0 | 1 | 2 }[]\n /** Last visibility written per ring segment — Node.visible reads hit the bridge, so sync()\n * diffs against this cache instead. refresh() re-fills it with whatever it wrote. */\n private readonly ringVis: boolean[][]\n /** Uniform-scale center cube (scale mode). */\n readonly center: Mesh\n mode: GizmoMode = \"translate\"\n space: GizmoSpace = \"global\"\n private readonly modeRoots: Record<GizmoMode, Node>\n private grabbed: Node | null = null\n private _target: Node | null = null\n\n constructor(private readonly camera: Camera) {\n this.root = new Node()\n this.root.name = \"__editor_gizmo\"\n const translateRoot = new Node()\n const rotateRoot = new Node()\n const scaleRoot = new Node()\n translateRoot.name = \"__editor_gizmo_translate\"\n rotateRoot.name = \"__editor_gizmo_rotate\"\n scaleRoot.name = \"__editor_gizmo_scale\"\n this.modeRoots = { translate: translateRoot, rotate: rotateRoot, scale: scaleRoot }\n\n this.translateArrows = ([ 0, 1, 2 ] as const).map((axis) => ({ node: createArrow(axis, \"cone\"), axis }))\n translateRoot.add(...this.translateArrows.map((a) => a.node))\n\n const segGeo = Geometry.cylinder({ radius: 0.028, edges: 8 })\n this.rings = ([ 0, 1, 2 ] as const).map((axis) => ({ ...createRing(axis, segGeo), axis }))\n this.ringVis = this.rings.map((r) => r.segments.map(() => true))\n rotateRoot.add(...this.rings.map((r) => r.group))\n\n this.scaleArrows = ([ 0, 1, 2 ] as const).map((axis) => ({ node: createArrow(axis, \"cube\"), axis }))\n this.center = Mesh.box({ size: 0.11, material: createHandleMaterial(\"#e8e8e8\") })\n this.center.castShadows = false\n this.center.receiveShadows = false\n this.center.name = \"__editor_gizmo_center\"\n scaleRoot.add(...this.scaleArrows.map((a) => a.node), this.center)\n\n this.root.add(translateRoot, rotateRoot, scaleRoot)\n const parts: Node[] = []\n const collect = (n: Node) => { parts.push(n); for (const c of n.children) collect(c) }\n collect(this.root)\n this.parts = parts\n }\n\n /** Register pick colliders on the handles. Call AFTER the parts joined the overlay's draw set\n * (the native collider store is per containing scene) and BEFORE the first visibility refresh\n * (colliders registered on already-hidden nodes would stay active — hide AFTER registering). */\n installColliders(): void {\n for (const { node } of this.translateArrows) {\n _creator.setColliderBox(node.id, 0, 0.55, 0, 0.24, 1.1, 0.24)\n }\n // scale arrows start above the origin so the uniform-scale center cube owns it\n for (const { node } of this.scaleArrows) {\n _creator.setColliderBox(node.id, 0, 0.65, 0, 0.24, 0.9, 0.24)\n }\n for (const ring of this.rings) {\n for (const seg of ring.segments) _creator.setColliderFromMesh(seg.id, seg.id, 0)\n }\n _creator.setColliderFromMesh(this.center.id, this.center.id, 0)\n }\n\n /** Re-derive EVERY gizmo node's visibility from (mode, grabbed), explicitly and top-down\n * (parents before children; a node is visible only if its whole ancestor chain is). This is\n * deliberately cascade-independent: in-browser, group-level shows did not propagate to\n * descendants the way renderable.cpp reads, so no node's render state may depend on an\n * ancestor's cascade — and explicit per-node calls also (de)activate each node's collider. */\n private refresh(): void {\n const modeOn = (m: GizmoMode) => m === this.mode\n const groupOn = (group: Node) => this.grabbed === null || this.grabbed === group\n for (const m of [ \"translate\", \"rotate\", \"scale\" ] as const) this.modeRoots[m].visible = modeOn(m)\n for (const { node } of this.translateArrows) setSubtree(node, modeOn(\"translate\") && groupOn(node))\n for (let ri = 0; ri < this.rings.length; ri++) {\n const ring = this.rings[ri]\n const on = modeOn(\"rotate\") && groupOn(ring.group)\n setSubtree(ring.group, on)\n this.ringVis[ri].fill(on)\n }\n for (const { node } of this.scaleArrows) setSubtree(node, modeOn(\"scale\") && groupOn(node))\n this.center.visible = modeOn(\"scale\") && groupOn(this.center)\n }\n\n setMode(mode: GizmoMode): void {\n this.mode = mode\n if (this._target) this.refresh()\n this.sync()\n }\n\n setSpace(space: GizmoSpace): void {\n this.space = space\n this.sync()\n }\n\n /** While dragging, show only the grabbed handle (arrow group / ring group / center); null restores. */\n solo(grabbed: Node | null): void {\n this.grabbed = grabbed\n if (this._target) this.refresh()\n }\n\n get target(): Node | null { return this._target }\n\n setTarget(node: Node | null): void {\n this._target = node\n this.grabbed = null\n if (node) {\n this.root.visible = true\n this.refresh()\n } else {\n // explicit subtree hide (cascade-independent) — also deactivates every gizmo collider,\n // so a hidden gizmo can't eat clicks\n setSubtree(this.root, false)\n }\n this.sync()\n }\n\n /** The gizmo's orientation: the target's world rotation in local space (scale is ALWAYS local —\n * node.scale is a local property), world axes otherwise. */\n private orientation(): Quat {\n if (this._target && (this.space === \"local\" || this.mode === \"scale\")) {\n return this._target.worldQuaternion\n }\n return Quat.identity\n }\n\n /** The world-space direction of a handle axis under the current orientation. */\n axisDir(axis: 0 | 1 | 2): Vec3 {\n return this.orientation().rotateVec3(AXIS_DIRS[axis]).normalize()\n }\n\n /** Follow the target (+ orientation) and keep an approximately constant screen size. Call every frame. */\n sync(): void {\n if (!this._target) return\n const p = this._target.worldPosition\n this.root.position = [ p.x, p.y, p.z ]\n const q = this.orientation()\n this.root.quaternion = q\n const cam = this.camera.worldPosition\n const dist = Math.hypot(p.x - cam.x, p.y - cam.y, p.z - cam.z)\n const s = Math.max(0.0001, dist * GIZMO_SCREEN_SIZE)\n this.root.scale = [ s, s, s ]\n\n // Rotate mode shows only the camera-facing half of each ring (à la three.js/Blender); the\n // hidden segments also drop their colliders, so the back arc can't be grabbed through the\n // model. Skipped while a drag is active — refresh() shows the grabbed ring's full circle\n // for the duration of the rotation.\n if (this.mode === \"rotate\" && this.grabbed === null) {\n const inv = 1 / Math.max(1e-6, dist)\n const vx = (cam.x - p.x) * inv, vy = (cam.y - p.y) * inv, vz = (cam.z - p.z) * inv\n for (let ri = 0; ri < this.rings.length; ri++) {\n const ring = this.rings[ri]\n const vis = this.ringVis[ri]\n for (let i = 0; i < ring.segments.length; i++) {\n const w = q.rotateVec3(ring.dirs[i])\n // −0.1 margin: a ring seen face-on has every segment at dot ≈ 0 — it must stay a FULL\n // circle (Blender does the same), not flicker into dashes at the silhouette\n const facing = w.x * vx + w.y * vy + w.z * vz > -0.1\n if (vis[i] !== facing) {\n vis[i] = facing\n ring.segments[i].visible = facing\n }\n }\n }\n }\n }\n\n /**\n * Start dragging along an axis from the pointer-down position (viewport coords). The drag plane\n * contains the axis and faces the camera: normal = (camForward × dir) × dir — every subsequent\n * ray is intersected with it and the delta projected onto the axis (the legacy gizmo's method).\n */\n beginDrag(axis: 0 | 1 | 2, screenX: number, screenY: number): DragHandle | null {\n if (!this._target) return null\n const dir = this.axisDir(axis)\n const origin = this.root.worldPosition\n\n const right = this.camera.forward.cross(dir)\n const normal = right.cross(dir)\n const plane = new Plane(normal, origin)\n\n const startRay = this.camera.getRay(screenX, screenY)\n const t0 = plane.intersectLine(startRay)\n if (t0 === null) return null\n const initialPoint = startRay.getPoint(t0)\n\n const startTarget = this._target.worldPosition\n\n return {\n axis,\n move: (x: number, y: number): Vec3 | null => {\n const ray = this.camera.getRay(x, y)\n const t = plane.intersectLine(ray)\n if (t === null) return null\n const delta = ray.getPoint(t).sub(initialPoint)\n return startTarget.add(dir.scale(dir.dot(delta)))\n },\n }\n }\n\n /** Start rotating around an axis: signed angle between plane hits around the gizmo center. */\n beginRotate(axis: 0 | 1 | 2, screenX: number, screenY: number): RotateHandle | null {\n if (!this._target) return null\n const dir = this.axisDir(axis)\n const origin = this.root.worldPosition\n\n // Plane nearly edge-on (axis ⊥ view direction) → the intersection blows up; use a\n // screen-space fallback: horizontal+vertical pointer travel in degrees.\n if (Math.abs(this.camera.forward.dot(dir)) < 0.25) {\n const sx = screenX, sy = screenY\n return { axis, dir, move: (x, y) => (x - sx + y - sy) * 0.4 }\n }\n\n const plane = new Plane(dir, origin)\n const startRay = this.camera.getRay(screenX, screenY)\n const t0 = plane.intersectLine(startRay)\n if (t0 === null) return null\n const p0 = startRay.getPoint(t0).sub(origin)\n\n return {\n axis,\n dir,\n move: (x: number, y: number): number | null => {\n const ray = this.camera.getRay(x, y)\n const t = plane.intersectLine(ray)\n if (t === null) return null\n const p = ray.getPoint(t).sub(origin)\n return Math.atan2(p0.cross(p).dot(dir), p0.dot(p)) * RAD2DEG_F\n },\n }\n }\n\n /** Start a uniform scale from the center handle: screen-space drag → factor (right/up = grow). */\n beginScaleUniform(screenX: number, screenY: number): ((x: number, y: number) => number) | null {\n if (!this._target) return null\n const sx = screenX, sy = screenY\n return (x, y) => Math.max(0.01, 1 + ((x - sx) - (y - sy)) * 0.005)\n }\n\n /** Start scaling along a LOCAL axis: the translate projection relative to the gizmo's size. */\n beginScale(axis: 0 | 1 | 2, screenX: number, screenY: number): ScaleHandle | null {\n const drag = this.beginDrag(axis, screenX, screenY)\n if (!drag || !this._target) return null\n const dir = this.axisDir(axis)\n const startTarget = this._target.worldPosition\n const size = Math.max(0.0001, this.root.scale.x)\n return {\n axis,\n move: (x: number, y: number): number | null => {\n const world = drag.move(x, y)\n if (!world) return null\n const along = dir.dot(world.sub(startTarget))\n return Math.max(0.01, 1 + along / size)\n },\n }\n }\n}\n","grid.ts":"// The editor ground grid: a 20×20 unit line grid + emphasized center axes, built as an \"edges\"\n// geometry (line rendering) with an unlit material. Lives in the USER's scene (depth-tested against\n// scene content) — the gizmo lives on an overlay instead.\n\nconst HALF = 10\n\n/** Returns the grid meshes — parented together, but each must join the scene's draw set. */\nexport const createGrid = (): Mesh[] => {\n const lines: number[] = []\n for (let i = -HALF; i <= HALF; i++) {\n if (i === 0) continue // center cross drawn separately (brighter)\n lines.push(i, 0, -HALF, i, 0, HALF)\n lines.push(-HALF, 0, i, HALF, 0, i)\n }\n\n const vertices = new Float32Array(lines)\n const count = vertices.length / 3\n const normals = new Float32Array(count * 3)\n for (let i = 0; i < count; i++) normals[i * 3 + 1] = 1\n const indices = new Uint16Array(count)\n for (let i = 0; i < count; i++) indices[i] = i\n const uv = new Float32Array(count * 2)\n\n const geo = new Geometry(vertices, normals, indices, uv)\n geo.kind = \"edges\"\n const grid = Mesh.from(geo, { material: Material.unlit({ color: \"#3a3f4a\" }), name: \"__editor_grid\" })\n grid.castShadows = false\n grid.receiveShadows = false\n\n // center axes: X / Z lines through the origin in their gizmo colors, slightly raised to win the\n // depth tie against the grid plane\n const axes = new Float32Array([ -HALF, 0.001, 0, HALF, 0.001, 0, 0, 0.001, -HALF, 0, 0.001, HALF ])\n const axisNormals = new Float32Array([ 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0 ])\n const axisGeo = new Geometry(axes, axisNormals, new Uint16Array([ 0, 1, 2, 3 ]), new Float32Array(8))\n axisGeo.kind = \"edges\"\n const cross = Mesh.from(axisGeo, { material: Material.unlit({ color: \"#8a8f9a\" }), name: \"__editor_grid_axes\" })\n grid.add(cross)\n\n return [ grid, cross ]\n}\n","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// Picking is NATIVE: every selectable node (and every gizmo arrow) gets a collider, so the\n// engine's raycastView resolves hits and the SDK dispatches click/touchstart to the right node.\n// raycastView picks overlay CPU colliders first (gizmo arrows, always on top), then Jolt pick\n// bodies on physics builds (`Shape` aspects attached below for precise mesh picking), then the\n// scene's CPU colliders (`setColliderFromMesh` — the only path on non-physics builds). Orbit is\n// any unclaimed drag; zoom/pan/focus come from the host (DOM wheel/keys belong to the Vue viewport).\n\nimport { createGrid } from \"./grid\"\nimport { OrbitCamera } from \"./orbit\"\nimport { Gizmo, type DragHandle, type GizmoMode, type GizmoSpace } from \"./gizmo\"\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._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\n/** Which node property a gizmo drag changes — matches the mode. */\ntype TransformKey = \"position\" | \"eulerAngles\" | \"scale\"\n\ntype HarnessCallbacks = {\n /** Selection changed (click in the viewport, or a select() echo). */\n onSelect?(name: string | null): void\n /** Live gizmo drag (every move) — mirror into the inspector, don't save yet. */\n onTransform?(name: string, key: TransformKey, value: Vec3Tuple): void\n /** Drag finished — write the value through to the scene document. */\n onTransformEnd?(name: string, key: TransformKey, value: Vec3Tuple): 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 overlay: 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 gizmo: null as Gizmo | null,\n selected: null as string | null,\n /** Snap increments while dragging (toolbar toggles; the host's Ctrl key inverts them):\n * move 0.25u + scale 0.1 steps (`move`), rotate 15° steps (`angle`). */\n snap: { move: false, angle: false },\n /** Surface placement (host holds Shift): translate drags raycast-drop onto the geometry below. */\n surfaceSnap: false,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it, gizmo yields. */\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 / drag reporting) resolves through\n// `resolveKey`, so a part behaves like a node — except its persistence: the editor writes the\n// transform into the MODEL's `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/** The selection/report key of a live node: its def name, or its part key inside a model. */\nconst keyForNode = (node: Node): string | null =>\n state.names.get(node) ?? state.partNames.get(node) ?? 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/** The part key of a picked entity (walking up to the nearest enumerated part), if it is one. */\nconst partKeyOf = (node: Node): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const key = state.partNames.get(cur)\n if (key) return key\n if (state.names.get(cur)) return null // reached a def node without crossing a part\n cur = cur.parent\n }\n return null\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 snapAngle = (deg: number): number => state.snap.angle ? Math.round(deg / 15) * 15 : deg\nconst snapMove = (d: number): number => state.snap.move ? Math.round(d / 0.25) * 0.25 : d\nconst snapFactor = (f: number): number => state.snap.move ? Math.max(0.1, Math.round(f * 10) / 10) : f\n\n// The euler tuple saved to the scene file must reproduce the dragged rotation THROUGH the\n// node.eulerAngles setter (that's how the file re-applies it). The shipped engines apply that\n// setter as the INVERSE of the standard rotation (verified live via probe; transformUtils.cpp\n// builds the matrix transposed) — so the naive read-back does NOT round-trip. Try both\n// conventions on a probe node and keep the one that verifiably reproduces `q`.\nlet eulerProbe: Node | null = null\nconst eulerFor = (q: Quat): Vec3Tuple => {\n eulerProbe ??= new Node()\n const candidates = [ q.toEuler(\"YXZ\"), q.invert().toEuler(\"XYZ\") ]\n for (const e of candidates) {\n eulerProbe.eulerAngles = [ e.x, e.y, e.z ]\n if (eulerProbe.quaternion.sameRotation(q, 1e-4)) return [ e.x, e.y, e.z ]\n }\n return [ candidates[0].x, candidates[0].y, candidates[0].z ]\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 picked 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\n/** Make a user node pointer-pickable: CPU collider(s) from its renderable(s) — a model registers\n * every sub-renderable, hits resolve back up to the node — plus a Shape pick body on physics\n * builds (their raycast only sees Jolt bodies). */\nconst installPickColliders = (node: Node): void => {\n // editor-drawn helper meshes (path lines, node markers) must not register colliders — a\n // polyline's bbox would eat clicks across its whole span; markers pick via the owner's box below\n node.traverse((n) => { if (!isEditorNode(n)) _creator.setColliderFromMesh(n.id, n.id, 0) })\n // marker-carrying nodes (empties/cameras — edit-mode `__editor_marker` children, see the SDK's\n // addEditorMarker) have no renderable of their own: give the node itself a box collider sized\n // to its marker so it clicks in the viewport\n const marker = (node as { _sceneMarker?: string })._sceneMarker\n if (marker === \"empty\") _creator.setColliderBox(node.id, 0, 0, 0, 0.6, 0.6, 0.6)\n else if (marker === \"camera\") _creator.setColliderBox(node.id, 0, 0.05, -0.27, 0.6, 0.56, 0.6)\n if (_creator.physicsHasSupport && _creator.physicsHasSupport()) {\n if (node instanceof Mesh) {\n try { node.aspect(Shape, {}) } catch { /* no derivable shape — stays unpickable by physics ray */ }\n } else if (marker) {\n // raycastView checks Jolt pick bodies BEFORE scene CPU colliders — on physics builds a\n // marker needs its own body or any mesh along the ray outpicks it (half-extents box)\n try { node.aspect(Shape, { box: [ 0.3, 0.3, 0.3 ] }) } catch { /* unpickable */ }\n }\n }\n}\n\n/** `locked: true` def nodes: selectable and field-editable, but never a gizmo target. */\nconst isLocked = (node: Node | null): boolean =>\n (node as { _sceneLocked?: boolean } | null)?._sceneLocked === true\n\nconst gizmoTarget = (name: string | null): Node | null => {\n if (!name || state.activeTool) return null // an active plugin tool owns the viewport\n const node = resolveKey(name)\n return isLocked(node) ? null : node\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n state.gizmo?.setTarget(gizmoTarget(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 the Jolt pick bodies on physics builds\n * (installPickColliders attaches a Shape per mesh in edit mode), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree — a surface-dragged node must not\n * catch its own ray (nor may gizmo/grid editor nodes). */\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) carry invisible pick bodies for SELECTION only —\n // placement/tool rays step past them like editor 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\n/** Track a gizmo drag: `apply` turns pointer coords into the new local value (and applies it to\n * the live node); every move/end reports through the callbacks with the changed key. */\nconst trackTransform = (\n ev: TouchStartEvent<Node>,\n key: TransformKey,\n apply: (screenX: number, screenY: number) => Vec3Tuple | null,\n): void => {\n const name = keyForNode(state.gizmo!.target!)\n ev.track({\n onMove: (pos) => {\n const value = apply(pos.clientX, pos.clientY)\n if (value && name) {\n notifyEditorChanged(name)\n controller.callbacks.onTransform?.(name, key, value)\n }\n },\n onEnd: (pos) => {\n state.gizmo?.solo(null)\n const value = apply(pos.clientX, pos.clientY)\n if (value && name) {\n notifyEditorChanged(name)\n controller.callbacks.onTransformEnd?.(name, key, value)\n }\n },\n })\n}\n\n/** Translate: world positions from the drag plane, applied as a local-position delta rotated\n * into the parent's space (exact for rotated parents too). While the host holds Shift\n * (`surfaceSnap`), the axis constraint yields to SURFACE PLACEMENT: the node's pivot follows the\n * raycast hit under the cursor (its own subtree stepped past), ground plane on a miss. */\nconst beginTranslate = (drag: DragHandle): ((x: number, y: number) => Vec3Tuple | null) => {\n const target = state.gizmo!.target!\n const startLocal = target.position\n const startWorld = target.worldPosition\n const parentInv = target.parent ? target.parent.worldQuaternion.invert() : Quat.identity\n return (x, y) => {\n let local: Vec3Tuple\n if (state.surfaceSnap) {\n const hit = raycastViewport(x, y, target)\n if (!hit) return null\n const d = parentInv.rotateVec3(new Vec3(hit.point[0], hit.point[1], hit.point[2]).sub(startWorld))\n local = [ startLocal.x + d.x, startLocal.y + d.y, startLocal.z + d.z ]\n } else {\n const world = drag.move(x, y)\n if (!world) return null\n const d = parentInv.rotateVec3(world.sub(startWorld))\n local = [\n startLocal.x + snapMove(d.x),\n startLocal.y + snapMove(d.y),\n startLocal.z + snapMove(d.z),\n ]\n }\n target.position = local\n state.gizmo!.sync()\n return local\n }\n}\n\n/** The pointer-down dispatch for a grabbed handle (arrow group or ring), by the gizmo's mode.\n * `grab` is the handle group to keep visible while dragging. */\nconst beginHandleDrag = (ev: TouchStartEvent<Node>, axis: 0 | 1 | 2, grab: Node): boolean => {\n const gizmo = state.gizmo\n const target = gizmo?.target\n if (!gizmo || !target) return false\n\n if (gizmo.mode === \"rotate\") {\n const handle = gizmo.beginRotate(axis, ev.clientX, ev.clientY)\n if (!handle) return false\n // Rotation about the gizmo axis (world axis in global space, the target's own axis in local\n // space) composed in quaternion space: local' = parent⁻¹ ⊗ R ⊗ parent ⊗ local — correct for\n // pre-rotated nodes and rotated parents. The euler tuple written to the file is read BACK\n // from the node, so it's exactly the engine's representation of the result.\n const startLocal = target.quaternion\n const parentQ = target.parent ? target.parent.worldQuaternion : Quat.identity\n const parentInv = parentQ.invert()\n gizmo.solo(grab)\n trackTransform(ev, \"eulerAngles\", (x, y) => {\n const angle = handle.move(x, y)\n if (angle === null) return null\n const spin = Quat.fromAxisAngle(handle.dir, snapAngle(angle) * Math.PI / 180)\n const q = parentInv.mul(spin).mul(parentQ).mul(startLocal)\n target.quaternion = q\n return eulerFor(q)\n })\n return true\n }\n\n if (gizmo.mode === \"scale\") {\n const handle = gizmo.beginScale(axis, ev.clientX, ev.clientY)\n if (!handle) return false\n const start = target.scale\n const startTuple: Vec3Tuple = [ start.x, start.y, start.z ]\n gizmo.solo(grab)\n trackTransform(ev, \"scale\", (x, y) => {\n const factor = handle.move(x, y)\n if (factor === null) return null\n const next: Vec3Tuple = [ startTuple[0], startTuple[1], startTuple[2] ]\n next[axis] = startTuple[axis] * snapFactor(factor)\n target.scale = next\n return next\n })\n return true\n }\n\n const drag = gizmo.beginDrag(axis, ev.clientX, ev.clientY)\n if (!drag) return false\n gizmo.solo(grab)\n trackTransform(ev, \"position\", beginTranslate(drag))\n return true\n}\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 const grid = createGrid()\n scene.add(...grid)\n\n // overlay BEFORE open(): openScene syncs the viewport of overlays that exist at open time\n state.overlay = scene.createOverlay({ ibl: false })\n state.gizmo = new Gizmo(scene.camera)\n state.overlay.add(...state.gizmo.parts)\n scene.open()\n\n // colliders go into the entity's CONTAINING scene — only valid after draw-set membership.\n // setTarget(null) AFTER installColliders: hiding the root cascades over the freshly\n // registered colliders and deactivates them (hidden gizmos must not eat clicks).\n for (const node of Object.values(nodes)) installPickColliders(node)\n state.gizmo.installColliders()\n state.gizmo.setTarget(null)\n\n state.orbit = new OrbitCamera(scene.camera)\n setLoop(() => state.gizmo?.sync())\n\n const debug = (globalThis as any).__lecodesSceneDebug\n ? (...args: unknown[]) => console.log(\"[scene-editor]\", ...args)\n : () => {}\n\n // gizmo handles claim their touch via native picking (collider hit → node touchstart);\n // ring hits arrive on the segment nodes, so every segment gets the listener\n for (const { node, axis } of [ ...state.gizmo.translateArrows, ...state.gizmo.scaleArrows ]) {\n node.addEventListener(\"touchstart\", (ev) => {\n const started = beginHandleDrag(ev, axis, node)\n debug(\"handle touchstart\", \"xyz\"[axis], state.gizmo?.mode, started ? \"ok\" : \"null\")\n })\n }\n for (const ring of state.gizmo.rings) {\n for (const seg of ring.segments) {\n seg.addEventListener(\"touchstart\", (ev) => {\n const started = beginHandleDrag(ev, ring.axis, ring.group)\n debug(\"ring touchstart\", \"xyz\"[ring.axis], started ? \"ok\" : \"null\")\n })\n }\n }\n state.gizmo.center.addEventListener(\"touchstart\", (ev) => {\n const gizmo = state.gizmo\n const target = gizmo?.target\n if (!gizmo || !target || gizmo.mode !== \"scale\") return\n const handle = gizmo.beginScaleUniform(ev.clientX, ev.clientY)\n if (!handle) return\n const start = target.scale\n const startTuple: Vec3Tuple = [ start.x, start.y, start.z ]\n gizmo.solo(gizmo.center)\n trackTransform(ev, \"scale\", (x, y) => {\n const factor = snapFactor(handle(x, y))\n const next: Vec3Tuple = [ startTuple[0] * factor, startTuple[1] * factor, startTuple[2] * factor ]\n target.scale = next\n return next\n })\n debug(\"center touchstart uniform-scale ok\")\n })\n\n // any unclaimed drag orbits\n scene.addEventListener(\"touchstart\", (ev) => {\n debug(\"scene touchstart hit:\", ev.target ? `${ev.target.name || \"?\"} (#${ev.target.id})` : \"nothing\")\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n scene.addEventListener(\"click\", (ev) => {\n // an active plugin tool owns viewport clicks — they arrive as raycast hits, not selections\n if (state.activeTool) {\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (tool?.hooks.onViewportClick) {\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 return\n }\n }\n // node markers (empties/cameras) select their OWNER; every other editor node eats the click\n if (ev.target && isEditorNode(ev.target)\n && !(ev.target.name ?? \"\").startsWith(\"__editor_marker\")) return\n const name = ownerName(ev.target)\n // drill-down: clicking a model that is ALREADY selected (or one of its parts) selects the\n // GLB part under the cursor — first click grabs the model, the next one goes inside\n if (name && ev.target && state.selected &&\n (state.selected === name || state.selected.startsWith(name + PART_SEP))) {\n const part = partKeyOf(ev.target)\n if (part && part !== state.selected) { applySelection(part, true); return }\n }\n applySelection(name, true)\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve for selection/drill-down even 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 /** 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 ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n if (name === state.selected) state.gizmo?.setTarget(gizmoTarget(name))\n return\n }\n notifyEditorChanged(name)\n if (name === state.selected) state.gizmo?.sync()\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 if (node) installPickColliders(node)\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 // the selected node object was just replaced (or removed) — re-target the gizmo\n const alive = state.selected === name ? node : (state.selected ? resolveKey(state.selected) : null)\n state.gizmo?.setTarget(state.selected ? gizmoTarget(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 if (state.selected === name) state.gizmo?.sync()\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 /** Switch the gizmo between translate / rotate / scale. */\n setGizmoMode(mode: GizmoMode): void { state.gizmo?.setMode(mode) },\n\n /** Gizmo orientation: world axes (\"global\") or the target's axes (\"local\"). Scale is always local. */\n setGizmoSpace(space: GizmoSpace): void { state.gizmo?.setSpace(space) },\n\n /** Drag snapping (toolbar toggles XOR the host's Ctrl key): `move` = 0.25u move + 0.1 scale\n * steps, `angle` = 15° rotate steps. A plain boolean (older hosts) sets both. */\n setSnap(on: boolean | { move?: boolean, angle?: boolean }): void {\n if (typeof on === \"boolean\") state.snap = { move: on, angle: on }\n else state.snap = { move: on?.move === true, angle: on?.angle === true }\n },\n\n /** Surface placement (host mirrors its Shift key): translate drags follow the raycast hit\n * under the cursor instead of the grabbed axis — see beginTranslate. */\n setSurfaceSnap(on: boolean): void { state.surfaceSnap = on === true },\n\n /** Raycast under a viewport pixel (mesh pick bodies, ground-plane fallback) — the host's\n * click/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`) — toolbar entries beside move/rotate/scale. */\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 = back to the gizmo). While active, viewport clicks route to\n * the tool's hooks and the transform gizmo hides; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n state.gizmo?.setTarget(gizmoTarget(state.selected))\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 /** Native view pick under a viewport pixel (the touch system's own raycast — overlay colliders\n * included, so gizmo handles answer). Entity id, 0 on miss. CDP drag-driving only. */\n _pick(x: number, y: number): number {\n return (_creator as { _raycastView?(x: number, y: number): number })._raycastView?.(x, y) ?? 0\n },\n\n /** World point → viewport pixel under the live camera: a pinhole estimate (fov/displaySize)\n * Newton-refined against the engine's OWN `getViewDirection`, so a ray cast through the\n * returned pixel passes exactly through `p` (the engine's projection details — offsets,\n * exact fov semantics — don't have to match the estimate). Null when behind the camera.\n * Used to aim CDP-driven drags. */\n _screenPos(p: Vec3Tuple): [number, number] | null {\n const cam = state.scene?.camera\n if (!cam) return null\n const camPos = cam.worldPosition\n const v = cam.worldQuaternion.invert().rotateVec3(new Vec3(p[0], p[1], p[2]).sub(camPos))\n if (v.z >= -1e-6) return null // cameras look down their local -Z\n const [ w, h ] = cam.displaySize\n const tanV = Math.tan(cam.fov * Math.PI / 360)\n let x = ((v.x / -v.z) / (tanV * (w / h)) + 1) / 2 * w\n let y = (1 - (v.y / -v.z) / tanV) / 2 * h\n const want = new Vec3(p[0], p[1], p[2]).sub(camPos).normalize()\n for (let i = 0; i < 8; i++) {\n const d0 = cam.getViewDirection(x, y)\n const dx = cam.getViewDirection(x + 2, y).sub(d0)\n const dy = cam.getViewDirection(x, y + 2).sub(d0)\n const err = want.sub(d0)\n // least-squares 2×2 solve of [dx dy]·s ≈ err, step in px\n const a11 = dx.dot(dx), a12 = dx.dot(dy), a22 = dy.dot(dy)\n const det = a11 * a22 - a12 * a12\n if (Math.abs(det) < 1e-18) break\n const sx = ((dx.dot(err) * a22 - dy.dot(err) * a12) / det) * 2\n const sy = ((a11 * dy.dot(err) - a12 * dx.dot(err)) / det) * 2\n x += sx\n y += sy\n if (Math.abs(sx) + Math.abs(sy) < 1e-3) break\n }\n return [ x, y ]\n },\n\n /** Introspection for driving the editor headlessly (CDP debugging): per-part visibility flags\n * plus a live euler-semantics probe against the running engine. */\n _debug(): unknown {\n const g = state.gizmo\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 mode: g?.mode,\n space: g?.space,\n snap: { ...state.snap, surface: state.surfaceSnap },\n target: g?.target ? keyForNode(g.target) : null, // def name OR `<owner>::<path>` part key\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n parts: g?.parts.map((n) => ({ name: n.name || \"(mesh)\", id: n.id, visible: n.visible })),\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 (the proven gizmo pattern; never compose eulers by hand). */\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 if (name === state.selected) state.gizmo?.sync()\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","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 { 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 const right: V3 = [ Math.cos(this.yaw), 0, -Math.sin(this.yaw) ]\n const sp = Math.sin(this.pitch), cp = Math.cos(this.pitch)\n const up: V3 = [ sp * Math.sin(this.yaw), cp, sp * Math.cos(this.yaw) ]\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"}
@@ -0,0 +1,42 @@
1
+ // Compile-time injection of an aspect's runtime name. A user aspect carries its accessor name in the
2
+ // (type-erased) generic — `class Health extends Aspect<'health', Sprite> {…}` — so neither the
3
+ // bundler nor the runtime can see it after types are stripped. This pass injects the matching
4
+ // `static aspect = "health"` so node.aspect()/node.health work at runtime, exactly like the SDK's
5
+ // built-in aspects declare it by hand.
6
+ //
7
+ // Runs on PROJECT files only (the SDK's own aspects already declare `static aspect`). Idempotent:
8
+ // skips a class that already declares one. String-based, like assetMacro — no parser dependency.
9
+
10
+ // `class X extends Aspect<'name', …>` up to the class body's opening brace. Group 3 = the name.
11
+ const ASPECT_RE = /\bclass\s+\w+\s+extends\s+Aspect\s*<\s*(["'])((?:[^"'\\]|\\.)*)\1[^{};]*>\s*\{/g
12
+
13
+ /** Cheap pre-check so we don't scan files that define no aspects. */
14
+ export const hasAspectExtends = (src: string): boolean => /\bextends\s+Aspect\s*</.test(src)
15
+
16
+ /** Inject `static aspect = "<name>"` into each `class … extends Aspect<'<name>', …>`. */
17
+ export const injectAspectNames = (src: string): string => {
18
+ let out = ""
19
+ let last = 0
20
+ ASPECT_RE.lastIndex = 0
21
+ let m: RegExpExecArray | null
22
+ while ((m = ASPECT_RE.exec(src)) !== null) {
23
+ const braceEnd = m.index + m[0].length // index just past the opening `{`
24
+ if (!classBodyDeclaresAspect(src, braceEnd)) {
25
+ out += src.slice(last, braceEnd) + ` static aspect = ${JSON.stringify(m[2])};`
26
+ last = braceEnd
27
+ }
28
+ }
29
+ return last === 0 ? src : out + src.slice(last)
30
+ }
31
+
32
+ /** Does the class body starting just after `from` (its `{`) already declare a `static aspect`? */
33
+ const classBodyDeclaresAspect = (src: string, from: number): boolean => {
34
+ let depth = 1
35
+ let i = from
36
+ for (; i < src.length && depth > 0; i++) {
37
+ const c = src[i]
38
+ if (c === "{") depth++
39
+ else if (c === "}") depth--
40
+ }
41
+ return /\bstatic\b[^;{}()=]*\baspect\b/.test(src.slice(from, i))
42
+ }