lecodes-cli 0.12.0 → 0.13.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "dependencies": {
5
5
  "@letary/chisel": "^0.6.0",
6
6
  "jimp": "^1.6.1"
@@ -13,9 +13,9 @@
13
13
  "sdk": "workspace:*"
14
14
  },
15
15
  "peerDependencies": {
16
- "lecodes-design": "^0.4.0",
17
- "lecodes-renderer": "^0.7.1",
18
- "lecodes-3d-editor": "^0.1.0"
16
+ "lecodes-design": "^0.5.0",
17
+ "lecodes-renderer": "^0.8.0",
18
+ "lecodes-3d-editor": "^0.2.0"
19
19
  },
20
20
  "bin": {
21
21
  "lecodes": "dist/index.js"
@@ -1 +1 @@
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"}
1
+ {"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\n//\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\n//\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\n\nimport { OrbitCamera } from \"./orbit\"\n\n// The low-level bridge global every bundle runs against — the harness only walks the entity\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\n// in the SDK's registry, so id-level walking is the safe form).\ndeclare const _creator: { getParent(entityId: number): number }\n\ntype SceneHandleLike = {\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\n _describeAspects?(): unknown[]\n /** SceneHandle._patchNode — live single-node rebuild/add/remove (absent on older SDK bundles). */\n _patchNode?(name: string, def: unknown, parentName?: string | null): Promise<Node | null>\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\n _modelParts?(name: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps include `name`. */\n _editorNodeChanged?(name: string): void\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\n _editorSetProp?(hostName: string, index: number, key: string, value: unknown): boolean\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\n _editorSetMakeArg?(name: string, key: string, value: unknown): boolean\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\n _inspectorRender?(\n hostName: string, index: number,\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\n ): unknown[] | null\n}\n\ntype Vec3Tuple = [number, number, number]\n\ntype HarnessCallbacks = {\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\n onSelect?(name: string | null): void\n}\n\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\ntype EditorOps = {\n nodes(): { name: string, kind: string }[]\n uniqueName(base: string): string\n addNode(name: string, def: Record<string, unknown>): boolean\n setProp(name: string, key: string, value: unknown): boolean\n removeNode(name: string): void\n duplicate(name: string): string | null\n transact(fn: () => void): void\n}\n\nconst state = {\n handle: null as SceneHandleLike | null,\n scene: null as Scene | null,\n nodes: {} as Record<string, Node>,\n names: new Map<Node, string>(),\n /** GLB internal nodes, addressable like nodes: key = `<model>::<part path>` (+ reverse map). */\n parts: new Map<string, Node>(),\n partNames: new Map<Node, string>(),\n orbit: null as OrbitCamera | null,\n selected: null as string | null,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\n activeTool: null as string | null,\n /** Host-implemented doc operations (the `editor` API's write half). */\n editorOps: null as EditorOps | null,\n}\n\n// ---- GLB parts ---------------------------------------------------------------------------------\n// A model's internal nodes are selectable/editable through PART KEYS: `<model>::<part path>` (the\n// path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store it).\n// Everything key-addressed (select / setNodeProp / focus) resolves through `resolveKey`, so a part\n// behaves like a node — except its persistence: the editor writes the transform into the MODEL's\n// `overrides` record instead of a node def.\n\nconst PART_SEP = \"::\"\n\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\n\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\nconst refreshModelParts = async (name: string): Promise<{ path: string, name: string, depth: number }[]> => {\n const handle = state.handle\n if (!handle?._modelParts) return []\n const rows = await handle._modelParts(name)\n for (const [ key, node ] of [ ...state.parts ]) {\n if (key.startsWith(name + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\n }\n for (const r of rows) {\n const key = name + PART_SEP + r.path\n state.parts.set(key, r.node)\n state.partNames.set(r.node, key)\n }\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\n}\n\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\n * that reference it via ref() rebuild. Part keys collapse to their model's def name. */\nconst notifyEditorChanged = (key: string | null): void => {\n if (!key) return\n const i = key.indexOf(PART_SEP)\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\n}\n\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\n\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\n// Model constructor itself, so it is live even in edit mode.\n\nconst nodeCards = new Map<string, InspectorUI>()\n\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\n const node = state.nodes[name]\n if (!(node instanceof Model)) return null\n const clips = node.anim.clips\n if (clips.length === 0) return null\n let ui = nodeCards.get(name)\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\n return ui._run((u) => {\n u.header(\"Animation\")\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\n const loop = u.switch(\"loop\", { value: true })\n if (node.anim.playing) { node.anim.speed = speed; node.anim.loop = loop }\n if (u.button(node.anim.playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n node.anim.play(clip, { loop })\n }\n if (u.button(\"Stop\")) {\n // stop AND reset the pose (time 0 re-poses the skeleton even while stopped)\n node.anim.stop()\n node.anim.time = 0\n }\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(node.anim.playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The named def node a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\nconst ownerName = (node: Node | null): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const name = state.names.get(cur)\n if (name) return name\n cur = cur.parent\n }\n return null\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n if (notify) controller.callbacks.onSelect?.(name)\n}\n\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\n\nconst isInSubtree = (node: Node, root: Node): boolean => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 64; i++) {\n if (cur === root) return true\n cur = cur.parent\n }\n return false\n}\n\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\n const scene = state.scene\n if (!scene) return null\n const ray = scene.camera.getRay(x, y)\n if (Physics.supported) {\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\n let remaining = 2000\n for (let i = 0; i < 8 && remaining > 0; i++) {\n const hit = Physics.raycast(origin, ray.dir, remaining)\n if (!hit) break\n // marker-flagged nodes (empties/cameras) — placement/tool rays step past them like editor\n // nodes (a drop must land on real geometry)\n if (hit.node && !isEditorNode(hit.node)\n && !(hit.node as { _sceneMarker?: string })._sceneMarker\n && !(exclude && isInSubtree(hit.node, exclude))) {\n return {\n point: [ hit.point.x, hit.point.y, hit.point.z ],\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\n node: hit.node ? ownerName(hit.node) : null,\n }\n }\n // an excluded/editor body — resume the cast just past it\n const step = remaining * hit.fraction + 0.01\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\n remaining -= step\n }\n }\n const t = -ray.origin.y / ray.dir.y\n if (!Number.isFinite(t) || t <= 0) return null\n const p = ray.getPoint(t)\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\n}\n\nconst editorApi = {\n get selection(): string | null { return state.selected },\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\n raycast: raycastViewport,\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\n}\n\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\nconst windowUIs = new Map<number, InspectorUI>()\n\nconst controller = {\n callbacks: {} as HarnessCallbacks,\n\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node names. */\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\n const { scene, nodes } = await handle.load()\n state.handle = handle\n state.scene = scene\n state.nodes = nodes\n state.names = new Map(Object.entries(nodes).map(([ name, node ]) => [ node, name ]))\n\n scene.open()\n\n state.orbit = new OrbitCamera(scene.camera)\n\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\n scene.addEventListener(\"touchstart\", (ev) => {\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\n // is host-driven from the tree, so a click outside a tool does nothing)\n scene.addEventListener(\"click\", (ev) => {\n if (!state.activeTool) return\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (!tool?.hooks.onViewportClick) return\n const hit = raycastViewport(ev.clientX, ev.clientY)\n if (hit) {\n // a throwing tool logs and skips — it can't take the editor down\n try { tool.hooks.onViewportClick(hit, editorApi) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the nearest tracked def node; a hit inside the CURRENTLY\n * SELECTED model resolves to its deepest part key instead (click-again drill-down). Locked\n * nodes yield null (the click selects nothing — the host keeps the current selection). */\n pickName(entityId: number): string | null {\n if (!entityId) return null\n const nodeIds = new Map<number, string>()\n for (const [ name, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, name)\n const partIds = new Map<number, string>()\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\n let part: string | null = null\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\n const name = nodeIds.get(id)\n if (name !== undefined) {\n const node = state.nodes[name]\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\n const inSelected = state.selected === name || state.selected?.startsWith(name + PART_SEP)\n return part && inSelected ? part : name\n }\n id = _creator.getParent(id)\n }\n return null\n },\n\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\n * by hand, the engines' setter conventions differ). */\n applyPose(\n name: string,\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\n const node = resolveKey(name)\n if (!node) return null\n node.position = pose.position\n node.quaternion = pose.quaternion\n node.scale = pose.scale\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n }\n },\n\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\n * (re-)binds the part keys, so call it after anything that reloads the model. */\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\n return refreshModelParts(name)\n },\n\n /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\n const node = resolveKey(name)\n if (!node) return null\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\n }\n },\n\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\n * anything else is a structural change (the host recompiles the scene bundle instead). */\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"position\") node.position = value as Vec3Tuple\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\n else if (key === \"visible\") node.visible = value === true\n else if (key === \"locked\") {\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\n // consults it; fields edit regardless)\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n return\n }\n notifyEditorChanged(name)\n },\n\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\n return state.handle?._editorSetProp?.(name, index, key, value) === true\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE named node from\n * plain def data — the SDK re-instantiates it through the same defineScene builder, so nothing\n * recompiles. The HOST guarantees the def is plain data (it bails to a re-run on `$expr` and\n * `$asset` values). Returns false when this bundle's SDK can't patch (older builds). */\n async patchNode(name: string, def: Record<string, unknown> | null, parentName?: string | null): Promise<boolean> {\n const handle = state.handle\n if (!handle?._patchNode) return false\n const node = await handle._patchNode(name, def, parentName)\n state.names = new Map(Object.entries(state.nodes).map(([ n, nd ]) => [ nd, n ]))\n notifyEditorChanged(name) // generators referencing the patched node follow it\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\n await refreshModelParts(name)\n if (state.selected === name || state.selected?.startsWith(name + PART_SEP)) {\n const alive = state.selected === name ? node : (state.selected ? resolveKey(state.selected) : null)\n if (!alive) state.selected = null\n }\n return true\n },\n\n /** Live rename: runtime maps + the engine-side name. The document is the source of truth. */\n renameNode(oldName: string, newName: string): boolean {\n const node = state.nodes[oldName]\n if (!node || state.nodes[newName]) return false\n delete state.nodes[oldName]\n state.nodes[newName] = node\n state.names.set(node, newName)\n node.name = newName\n // part keys carry the model name as their prefix — remap them (and a part selection) with it\n for (const [ key, part ] of [ ...state.parts ]) {\n if (!key.startsWith(oldName + PART_SEP)) continue\n const next = newName + key.slice(oldName.length)\n state.parts.delete(key)\n state.parts.set(next, part)\n state.partNames.set(part, next)\n }\n if (state.selected === oldName) state.selected = newName\n else if (state.selected?.startsWith(oldName + PART_SEP)) {\n state.selected = newName + state.selected.slice(oldName.length)\n }\n return true\n },\n\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\n * (a node under a transformed parent shifts in world space). `parentName` null = root. */\n reparentNode(name: string, parentName: string | null): boolean {\n const node = state.nodes[name]\n const parent = parentName ? state.nodes[parentName] : null\n if (!node || (parentName !== null && !parent)) return false\n node.setParent(parent, false)\n return true\n },\n\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\n * back to a re-run for everything else. */\n setSceneProp(key: string, value: unknown): boolean {\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\n state.scene.skybox = value\n return true\n }\n return false\n },\n\n zoom(delta: number): void { state.orbit?.zoom(delta) },\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\n\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\n return raycastViewport(x, y)\n },\n\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\n aspectSchemas(): unknown[] {\n return state.handle?._describeAspects?.() ?? []\n },\n\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\n editorWindows(): { title: string }[] {\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\n },\n\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\n * cards, except every field key is transient editor state (windows have no doc entry). */\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\n const win = __editorPlugins.windows[index]\n if (!win) return null\n let ui = windowUIs.get(index)\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\n return ui._run((u) => win.render(u, editorApi), event)\n },\n\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\n },\n\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\n * tool's hooks; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n },\n\n /** Install the host's document operations — the write half of the plugin `editor` API. */\n setEditorOps(ops: EditorOps): void {\n state.editorOps = ops\n },\n\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\n * live euler-semantics probe against the running engine (the setter convention differs across\n * shipped engines — see setFromView). */\n _debug(): unknown {\n const probe = new Node()\n probe.eulerAngles = [ 0, 0, 90 ]\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\n const probe2 = new Node()\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\n return {\n selected: state.selected, // def name OR `<owner>::<path>` part key\n activeTool: state.activeTool,\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n }\n },\n\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\n * should write to the file (never compose eulers by hand: the shipped engines apply the\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\n const node = resolveKey(name)\n const cam = state.scene?.camera\n if (!node || !cam) return null\n const parent = node.parent\n const camPos = cam.worldPosition\n node.quaternion = parent\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\n : cam.worldQuaternion\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\n },\n\n /** Frame the named node or part (or the selection). */\n focus(name?: string): void {\n const node = resolveKey(name ?? state.selected ?? \"\")\n if (!node) return\n const p = node.worldPosition\n state.orbit?.focus([ p.x, p.y, p.z ])\n },\n}\n\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure gizmo math — plain number tuples in/out, no SDK dependencies, so it unit-tests directly\n// (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n\nexport type V3 = readonly [number, number, number]\n\nexport const add = (a: V3, b: V3): V3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]\nexport const sub = (a: V3, b: V3): V3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]\nexport const scale = (a: V3, s: number): V3 => [ a[0] * s, a[1] * s, a[2] * s ]\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nexport const length = (a: V3): number => Math.hypot(a[0], a[1], a[2])\n\n/**\n * Parameter `t` along the axis line (origin + dir·t, dir unit-length) of the point closest to the\n * given ray. Standard closest-point-between-two-lines; falls back to projecting the ray origin when\n * the lines are near-parallel (denominator ~ 0).\n */\nexport const closestAxisParam = (rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3): number => {\n // minimize |axisOrigin + t·axisDir − (rayOrigin + s·rayDir)|²:\n // t = s·b + d, s = t·b − e ⇒ t = (d − b·e) / (1 − b²)\n const w = sub(rayOrigin, axisOrigin)\n const b = dot(axisDir, rayDir)\n const d = dot(axisDir, w)\n const e = dot(rayDir, w)\n const denom = 1 - b * b // axisDir·axisDir = rayDir·rayDir = 1\n if (Math.abs(denom) < 1e-8) return d\n return (d - b * e) / denom\n}\n\n/** Distance between a ray and an axis SEGMENT (t clamped to [0, segLength]). */\nexport const rayToAxisSegmentDistance = (\n rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3, segLength: number,\n): number => {\n const t = Math.max(0, Math.min(segLength, closestAxisParam(rayOrigin, rayDir, axisOrigin, axisDir)))\n const p = add(axisOrigin, scale(axisDir, t))\n // closest point on the ray to p (s clamped to ≥ 0 — the ray starts at the camera)\n const s = Math.max(0, dot(sub(p, rayOrigin), rayDir))\n const q = add(rayOrigin, scale(rayDir, s))\n return length(sub(p, q))\n}\n\nexport type AxisPick = { axis: 0 | 1 | 2, startParam: number }\n\nconst AXES: V3[] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n/**\n * Which gizmo axis (if any) a ray grabs: the closest axis whose segment [origin, origin + dir·len]\n * passes within `threshold` of the ray. Returns the axis plus the grab parameter along it (the\n * drag delta is `currentParam - startParam` on subsequent moves).\n */\nexport const pickGizmoAxis = (\n rayOrigin: V3, rayDir: V3, gizmoOrigin: V3, axisLength: number, threshold: number,\n): AxisPick | null => {\n let best: AxisPick | null = null\n let bestDist = threshold\n for (let i = 0; i < 3; i++) {\n const dist = rayToAxisSegmentDistance(rayOrigin, rayDir, gizmoOrigin, AXES[i], axisLength)\n if (dist <= bestDist) {\n bestDist = dist\n best = { axis: i as 0 | 1 | 2, startParam: closestAxisParam(rayOrigin, rayDir, gizmoOrigin, AXES[i]) }\n }\n }\n return best\n}\n\n/** The world-axis direction for an axis index. */\nexport const axisDir = (axis: 0 | 1 | 2): V3 => AXES[axis]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
@@ -22,6 +22,10 @@
22
22
  // A cell holds a material id, a part id, -1 for empty, or — below -1 — a RAW atlas tile
23
23
  // (`rawCell(index)`), so hand-placed art can sit in a material map without colliding with ids.
24
24
  //
25
+ // Any atlas index (a raw cell, a slot value) may carry an ORIENTATION packed into the same int:
26
+ // `rot90(i)` / `flipX(i)` — see the oriented-tiles section. The deriver treats oriented values as
27
+ // opaque positive ints and copies them through; renderers decode the bits into a UV permutation.
28
+ //
25
29
  // Pure and engine-free on purpose — the 2D editor keeps a mirrored copy
26
30
  // (packages/editor-2d/src/autotile.ts, like cells.ts / history.ts): keep them in sync.
27
31
 
@@ -162,6 +166,41 @@ export const isRawCell = (v: number): boolean => v <= -2
162
166
  /** The atlas index behind a raw cell (garbage in, garbage out — guard with isRawCell). */
163
167
  export const rawIndex = (v: number): number => -v - 2
164
168
 
169
+ // ---- oriented tiles -----------------------------------------------------------------------------
170
+ // Any tile value — a slot in a `.tiles.ts` material, a raw cell, a plain index in a raw map — can
171
+ // carry an orientation in the SAME int: bits 0–27 hold the atlas index, bit 28 a horizontal mirror,
172
+ // bits 29–30 the clockwise quarter-turns; the mirror applies FIRST, then the rotation. The sign bit
173
+ // is never touched, so every oriented value stays positive: -1 (empty), raw cells (≤ -2) and the
174
+ // negative "unassigned slot" sentinel all keep working, and the deriver passes oriented values
175
+ // through untouched. Renderers permute the atlas cell's UV corners — same quads, same batches.
176
+ // `rot90`/`flipX` COMPOSE (they act on the value's current orientation), so `rot90(rot90(t))` is a
177
+ // half turn and all 8 orientations of a tile are reachable; write them in `.tiles.ts` files
178
+ // directly: `ends: [121, rot90(121), rot180(121), rot270(121)]`.
179
+
180
+ export const TILE_INDEX_MASK = 0x0fffffff
181
+ const TILE_FLIP = 1 << 28
182
+
183
+ /** The atlas index behind a (possibly oriented) tile value. */
184
+ export const tileIndex = (v: number): number => v & TILE_INDEX_MASK
185
+ /** Clockwise quarter-turns (0–3) of a tile value. */
186
+ export const tileTurns = (v: number): number => (v >> 29) & 3
187
+ /** Whether the tile is mirrored horizontally (the mirror applies before the rotation). */
188
+ export const tileFlip = (v: number): boolean => (v & TILE_FLIP) !== 0
189
+ /** True when the value carries any orientation (renderers keep the fast path otherwise). */
190
+ export const tileOriented = (v: number): boolean => v > TILE_INDEX_MASK
191
+ /** Build an oriented tile value: `index`, mirrored when `flip`, then `turns` quarter-turns CW. */
192
+ export const packTile = (index: number, turns = 0, flip = false): number =>
193
+ (index & TILE_INDEX_MASK) | (flip ? TILE_FLIP : 0) | ((turns & 3) << 29)
194
+
195
+ /** The value rotated a further 90° clockwise. */
196
+ export const rot90 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 1, tileFlip(v))
197
+ /** The value rotated a further 180°. */
198
+ export const rot180 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 2, tileFlip(v))
199
+ /** The value rotated a further 270° clockwise (90° counter-clockwise). */
200
+ export const rot270 = (v: number): number => packTile(tileIndex(v), tileTurns(v) + 3, tileFlip(v))
201
+ /** The value mirrored horizontally (on screen — existing turns are re-based, group math). */
202
+ export const flipX = (v: number): number => packTile(tileIndex(v), 4 - tileTurns(v), !tileFlip(v))
203
+
165
204
  /** The tile a part draws in a cell — indexed ACROSS the run ('h' by row, 'v' by column) so one
166
205
  * element covers a lane each and repeats along the run; without an axis it is a plain block. */
167
206
  export const partTile = (part: TilePart, n: number, lx: number, ly: number): number | undefined => {
@@ -5,21 +5,61 @@ import { Vec3 } from "../math/vec"
5
5
  import { Node } from "./Node"
6
6
  import { Ray } from "./Ray"
7
7
 
8
+ /** Projection every host starts a scene with (creator-gl scene.cpp / the lite core agree on these). */
9
+ export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }
10
+
8
11
  export class Camera extends Node {
9
12
  private _sceneId = -1
10
13
  /** Live projection matrix (updated by the host when the viewport/fov changes). */
11
14
  readonly projectionMatrix = new Float32Array(16)
12
15
 
16
+ // Projection the app asked for (host defaults until it does). The host owns the aspect and
17
+ // re-applies these on every viewport change, so they are set, not maintained.
18
+ private _fov = CAMERA_DEFAULTS.fov
19
+ private _near = CAMERA_DEFAULTS.near
20
+ private _far = CAMERA_DEFAULTS.far
21
+ /** Stay quiet until the app actually sets something — hosts may be configured with their own
22
+ * defaults (the headless core takes a `fov` option) and a blind push would overwrite them. */
23
+ private _projSet = false
24
+
13
25
  /** @internal — Scene wires the camera to its native scene id + projection callback. */
14
26
  _attach(sceneId: number): void {
15
27
  this._sceneId = sceneId
16
28
  _creator.attachCameraToAR(sceneId, this.id, (mat) => {
17
29
  for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
18
30
  })
31
+ if (this._projSet) this._applyProjection()
19
32
  }
20
33
 
34
+ /** Vertical field of view in degrees (default 60) — a smaller angle is a longer lens. */
21
35
  get fov(): number { return _creator.getCameraFov(this._sceneId, 0) }
36
+ set fov(degrees: number) { this._fov = degrees; this._projSet = true; this._applyProjection() }
22
37
  get horizontalFov(): number { return _creator.getCameraFov(this._sceneId, 1) }
38
+
39
+ /** Near clip distance (default 0.01) — nothing closer than this draws. */
40
+ get near(): number { return this._near }
41
+ set near(distance: number) { this._near = distance; this._projSet = true; this._applyProjection() }
42
+
43
+ /** Far clip distance / view range (default 1000) — geometry past it is culled. Shadows keep
44
+ * their own (shorter) range, so a long view distance doesn't cost shadow sharpness. */
45
+ get far(): number { return this._far }
46
+ set far(distance: number) { this._far = distance; this._projSet = true; this._applyProjection() }
47
+
48
+ /** Set any of fov / near / far in one call: `camera.setProjection({ fov: 45, far: 5000 })`. */
49
+ setProjection(projection: { fov?: number, near?: number, far?: number }): this {
50
+ if (projection.fov !== undefined) this._fov = projection.fov
51
+ if (projection.near !== undefined) this._near = projection.near
52
+ if (projection.far !== undefined) this._far = projection.far
53
+ this._projSet = true
54
+ this._applyProjection()
55
+ return this
56
+ }
57
+
58
+ /** Hosts that predate the call keep the fixed defaults (older wasm / native builds). */
59
+ private _applyProjection(): void {
60
+ if (this._sceneId < 0 || typeof _creator.setCameraProjection !== "function") return
61
+ _creator.setCameraProjection(this._sceneId, this._fov, this._near, this._far)
62
+ }
23
63
  get displaySize(): [number, number] {
24
64
  const s = _creator.getDisplaySize()
25
65
  return [ s[0], s[1] ]
@@ -57,6 +57,7 @@ export type { SpriteSheetDef, SheetSprite, SpriteMakeOptions } from "./g2/Sprite
57
57
  // tilesets as data (.tiles.ts files — autotile materials, see src/g2/Tileset.ts + autotile.ts)
58
58
  export { Tileset, defineTileset, Autotile2D } from "./g2/Tileset"
59
59
  export { rawCell, isRawCell, rawIndex } from "./g2/autotile"
60
+ export { rot90, rot180, rot270, flipX, packTile, tileIndex, tileTurns, tileFlip, tileOriented } from "./g2/autotile"
60
61
  export type {
61
62
  TilesetDef, TileMaterial, PatchMaterial, PathMaterial, PatchSlots, EdgeSlot, FillBlock, BlockEntry,
62
63
  TileBlock, TilePart,
@@ -42,6 +42,7 @@ import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/
42
42
  import type { ColorInput } from "../core/color"
43
43
  import type { Vec3Like } from "../math/vec"
44
44
  import { Scene, type SceneOptions } from "../gl/Scene"
45
+ import { CAMERA_DEFAULTS } from "../gl/Camera"
45
46
  import { Node } from "../gl/Node"
46
47
  import { Mesh } from "../gl/Mesh"
47
48
  import { Model } from "../gl/Model"
@@ -84,8 +85,19 @@ export type ModelOverrideDef = {
84
85
  visible?: boolean
85
86
  }
86
87
 
87
- /** The `camera: {}` source block — reserved for projection settings (fov, …) later. */
88
- export type CameraNodeDef = Record<string, never>
88
+ /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
89
+ * block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
90
+ export type CameraProjectionDef = {
91
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
92
+ fov?: number
93
+ /** Near clip distance (default 0.01). */
94
+ near?: number
95
+ /** Far clip distance = view range (default 1000); geometry past it is culled. */
96
+ far?: number
97
+ }
98
+
99
+ /** The `camera: {}` source block — projection settings for the node that drives the view. */
100
+ export type CameraNodeDef = CameraProjectionDef
89
101
 
90
102
  export type SceneNodeDef = {
91
103
  // -- source (at most one; none = plain group node) --
@@ -115,7 +127,7 @@ export type SceneNodeDef = {
115
127
  eulerAngles?: Vec3Like
116
128
  scale?: Vec3Like | number
117
129
  visible?: boolean
118
- /** Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect. */
130
+ /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
119
131
  locked?: boolean
120
132
  castShadows?: boolean
121
133
  receiveShadows?: boolean
@@ -124,7 +136,7 @@ export type SceneNodeDef = {
124
136
  children?: Record<string, SceneNodeDef>
125
137
  }
126
138
 
127
- export type SceneCameraDef = {
139
+ export type SceneCameraDef = CameraProjectionDef & {
128
140
  position?: Vec3Like
129
141
  /** Point the camera looks at. */
130
142
  target?: Vec3Like
@@ -258,7 +270,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
258
270
  if (def.eulerAngles) node.eulerAngles = def.eulerAngles
259
271
  if (def.scale !== undefined) node.scale = def.scale
260
272
  if (def.visible !== undefined) node.visible = def.visible
261
- // editor-only flag (the harness reads it when targeting the gizmo); inert at runtime
273
+ // editor-only flag (the harness's manipulation layer consults it); inert at runtime
262
274
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
263
275
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
264
276
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
@@ -317,16 +329,27 @@ class CameraRig extends Aspect<"__cameraRig"> {
317
329
  }
318
330
  }
319
331
 
320
- /** The first camera-source def in file order (depth-first), or null. */
321
- const findCameraName = (defs?: Record<string, SceneNodeDef>): string | null => {
332
+ /** The first camera-source def in file order (depth-first) — its name and block — or null. */
333
+ const findCamera = (defs?: Record<string, SceneNodeDef>): { name: string, def: CameraNodeDef } | null => {
322
334
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
323
- if (nd.camera !== undefined) return name
324
- const inner = findCameraName(nd.children)
335
+ if (nd.camera !== undefined) return { name, def: nd.camera }
336
+ const inner = findCamera(nd.children)
325
337
  if (inner) return inner
326
338
  }
327
339
  return null
328
340
  }
329
341
 
342
+ /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
343
+ * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
344
+ * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
345
+ const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
346
+ const { fov, near, far } = def
347
+ if (!reset && fov === undefined && near === undefined && far === undefined) return
348
+ scene.camera.setProjection(reset
349
+ ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
350
+ : { fov, near, far })
351
+ }
352
+
330
353
  // ---- editor-run aspects (generators) ------------------------------------------------------------
331
354
  // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
332
355
  // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
@@ -566,18 +589,22 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
566
589
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
567
590
 
568
591
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
569
- // (CameraRig), in edit mode it only seeds the editor's starting viewpoint
570
- const camName = findCameraName(def.nodes)
571
- const camNode = camName ? nodes[camName] : undefined
572
- if (camNode) {
592
+ // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
593
+ // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
594
+ // lens the running app will use.
595
+ const cam = findCamera(def.nodes)
596
+ const camNode = cam ? nodes[cam.name] : undefined
597
+ if (cam && camNode) {
573
598
  scene.camera.position = camNode.worldPosition
574
599
  scene.camera.quaternion = camNode.worldQuaternion
600
+ applyCameraProjection(scene, cam.def)
575
601
  if (!isEditMode()) {
576
602
  ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
577
603
  }
578
604
  } else if (def.camera) {
579
605
  if (def.camera.position) scene.camera.position = def.camera.position
580
606
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
607
+ applyCameraProjection(scene, def.camera)
581
608
  }
582
609
 
583
610
  return { scene, nodes }
@@ -674,6 +701,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
674
701
  dispose(old) // fresh already replaced nodes[name] in build(), so it survives
675
702
  nodes[name] = fresh
676
703
  }
704
+ // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
705
+ // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
706
+ if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
677
707
  return fresh
678
708
  }
679
709
 
@@ -57,7 +57,7 @@ export type EditorToolHooks = {
57
57
  * share the generic wand icon, so any editor with two or more tools should set it. */
58
58
  icon?: string
59
59
  /** A viewport click while the tool is active — `hit` is the raycast result under the pointer.
60
- * Selection clicks and the transform gizmo are suspended while a tool is active. */
60
+ * Viewport clicks route to the tool while it is active. */
61
61
  onViewportClick?(hit: EditorRayHit, editor: EditorApi): void
62
62
  }
63
63