lecodes-cli 0.11.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/dist/index.js +48869 -7399
- package/package.json +6 -5
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/g2/Node2D.ts +3 -0
- package/runtime/sdk/g2/SpriteAnimation.ts +101 -20
- package/runtime/sdk/g2/SpriteSheet.ts +166 -0
- package/runtime/sdk/g2/Tileset.ts +71 -0
- package/runtime/sdk/g2/autotile.ts +433 -0
- package/runtime/sdk/g2/cells.ts +91 -0
- package/runtime/sdk/g2/defineScene2d.ts +381 -0
- package/runtime/sdk/g2/scenarios2d.ts +69 -0
- package/runtime/sdk/gl/Camera.ts +40 -0
- package/runtime/sdk/inject.ts +17 -0
- package/runtime/sdk/runtime/app.ts +21 -3
- package/runtime/sdk/scene/defineScene.ts +53 -83
- package/runtime/sdk/scene/editorPlugins.ts +1 -1
- package/runtime/sdk/scene/grammar.ts +85 -0
- package/runtime/sdk/ui/NativeView.ts +6 -12
- package/runtime/sdk/ui/UIBottomSheet.ts +0 -4
- package/runtime/sdk/ui/UIButton.ts +2 -21
- package/runtime/sdk/ui/UIContainer.ts +4 -58
- package/runtime/sdk/ui/UIImage.ts +2 -13
- package/runtime/sdk/ui/UIInput.ts +9 -21
- package/runtime/sdk/ui/UIModal.ts +0 -4
- package/runtime/sdk/ui/UINode.ts +37 -0
- package/runtime/sdk/ui/UIPager.ts +2 -13
- package/runtime/sdk/ui/UIScreen.ts +4 -20
- package/runtime/sdk/ui/UIScrollable.ts +2 -20
- package/runtime/sdk/ui/UISpacer.ts +2 -8
- package/runtime/sdk/ui/UIText.ts +2 -13
- package/runtime/sdk/ui/UIVideo.ts +6 -13
- package/runtime/sdk/ui/UIVirtualizedList.ts +10 -10
- package/runtime/sdk/ui/UIWidget.ts +3 -19
- package/runtime/sdk-types.json +1 -1
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"dependencies": {
|
|
5
|
-
"@letary/chisel": "^0.6.0"
|
|
5
|
+
"@letary/chisel": "^0.6.0",
|
|
6
|
+
"jimp": "^1.6.1"
|
|
6
7
|
},
|
|
7
8
|
"devDependencies": {
|
|
8
9
|
"lecodes-design": "workspace:*",
|
|
@@ -12,9 +13,9 @@
|
|
|
12
13
|
"sdk": "workspace:*"
|
|
13
14
|
},
|
|
14
15
|
"peerDependencies": {
|
|
15
|
-
"lecodes-design": "^0.
|
|
16
|
-
"lecodes-renderer": "^0.
|
|
17
|
-
"lecodes-3d-editor": "^0.
|
|
16
|
+
"lecodes-design": "^0.5.0",
|
|
17
|
+
"lecodes-renderer": "^0.8.0",
|
|
18
|
+
"lecodes-3d-editor": "^0.2.0"
|
|
18
19
|
},
|
|
19
20
|
"bin": {
|
|
20
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"}
|
package/runtime/sdk/g2/Node2D.ts
CHANGED
|
@@ -39,6 +39,9 @@ export class Node2D extends AspectHost<Node2DEvents> {
|
|
|
39
39
|
/** Native entity handle. */
|
|
40
40
|
readonly id: number
|
|
41
41
|
|
|
42
|
+
/** Optional debug/editor name (plain JS data — scene2d files stamp their node names here). */
|
|
43
|
+
name?: string
|
|
44
|
+
|
|
42
45
|
protected _x = 0
|
|
43
46
|
protected _y = 0
|
|
44
47
|
protected _rotation = 0
|
|
@@ -4,32 +4,55 @@
|
|
|
4
4
|
// sprite.anim.play('walk')
|
|
5
5
|
// Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
|
|
6
6
|
// names for the node's 'loopReached' / 'completed' events.
|
|
7
|
+
//
|
|
8
|
+
// Directional sheets (one texture row per facing): set `directions` in row order and mark clips
|
|
9
|
+
// `perDirection` — each expands to `name_DIR` with the frames offset by that row. Then
|
|
10
|
+
// `play('walk', 'SE')` or `play('walk', moveVector)` picks the row (compass tokens: E/NE/N/NW/W/SW/S/SE),
|
|
11
|
+
// and the last direction sticks, so a later `play('idle')` keeps facing.
|
|
12
|
+
//
|
|
13
|
+
// The native clips need the texture's dimensions, so when the Sprite has no texture yet (SpriteSheet's
|
|
14
|
+
// deferred load), the define is postponed: play() calls queue by name and start once define() runs.
|
|
7
15
|
|
|
8
16
|
import { Aspect } from "../core/Aspect"
|
|
17
|
+
import { cx, cy, type Vec2Like } from "../math/vec"
|
|
9
18
|
import { ensureAnimEvents } from "./loop"
|
|
10
19
|
import type { Sprite } from "./Sprite"
|
|
11
20
|
|
|
12
|
-
/** Per-clip frame list, or an object for per-clip fps/loop overrides. */
|
|
13
|
-
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean }
|
|
21
|
+
/** Per-clip frame list, or an object for per-clip fps/loop/direction overrides. */
|
|
22
|
+
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean, perDirection?: boolean }
|
|
23
|
+
|
|
24
|
+
// Compass angles in Y-up world degrees (E = +x, N = +y) for vector → direction resolution.
|
|
25
|
+
const COMPASS: Record<string, number> = { E: 0, NE: 45, N: 90, NW: 135, W: 180, SW: 225, S: 270, SE: 315 }
|
|
14
26
|
|
|
15
27
|
export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
16
28
|
static readonly aspect = "anim"
|
|
17
29
|
|
|
18
30
|
/** Grid cell size in texture pixels (defaults to the full texture). */
|
|
19
31
|
size?: [number, number]
|
|
32
|
+
/** Grid columns override — for sheets with padding; defaults to floor(texWidth / cellWidth). */
|
|
33
|
+
cols?: number
|
|
34
|
+
/** Pixel origin of the grid in the texture — cells index from here (a SpriteSheet region). */
|
|
35
|
+
origin?: [number, number]
|
|
20
36
|
/** Default fps for clips that don't override it. */
|
|
21
37
|
fps = 12
|
|
22
38
|
/** Default loop for clips that don't override it. */
|
|
23
39
|
loop = true
|
|
24
40
|
/** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
|
|
25
41
|
clips: Record<string, Clip> = {}
|
|
42
|
+
/** Facing names in texture-row order (row i = directions[i]) for `perDirection` clips. */
|
|
43
|
+
directions?: string[]
|
|
26
44
|
|
|
27
45
|
private _clipIds: Record<string, number> = {}
|
|
28
46
|
private _clipNames: Record<number, string> = {}
|
|
29
47
|
private _current: string | null = null
|
|
48
|
+
private _defined = false
|
|
49
|
+
private _queued: string | null = null
|
|
50
|
+
private _dir: string | null = null
|
|
30
51
|
|
|
31
52
|
onAttach(): void {
|
|
32
|
-
|
|
53
|
+
// No texture yet (deferred sheet load): postpone the native define — SpriteSheet (or the user)
|
|
54
|
+
// calls define() after assigning the texture; play() calls queue until then.
|
|
55
|
+
if (this.node.texture) this._define()
|
|
33
56
|
// route native animation events for this entity to named 'loopReached'/'completed' events.
|
|
34
57
|
;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
|
|
35
58
|
(clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
|
|
@@ -42,20 +65,16 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
|
42
65
|
private _define(): void {
|
|
43
66
|
const node = this.node
|
|
44
67
|
const tex = node.texture
|
|
45
|
-
if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before
|
|
68
|
+
if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before defining clips")
|
|
46
69
|
const [ fw, fh ] = this.size ?? [ tex.width, tex.height ]
|
|
47
|
-
const cols = Math.max(1, Math.floor(tex.width / fw))
|
|
48
|
-
|
|
49
|
-
for (const name of Object.keys(this.clips)) {
|
|
50
|
-
const value = this.clips[name]
|
|
51
|
-
const indices = Array.isArray(value) ? value : value.frames
|
|
52
|
-
const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
|
|
53
|
-
const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
|
|
70
|
+
const cols = this.cols ?? Math.max(1, Math.floor(tex.width / fw))
|
|
71
|
+
const [ ox, oy ] = this.origin ?? [ 0, 0 ]
|
|
54
72
|
|
|
73
|
+
const defineOne = (name: string, indices: number[], fps: number, loop: boolean): void => {
|
|
55
74
|
const frames = new Float32Array(indices.length * 4)
|
|
56
75
|
indices.forEach((idx, i) => {
|
|
57
|
-
const px = (idx % cols) * fw
|
|
58
|
-
const py = Math.floor(idx / cols) * fh
|
|
76
|
+
const px = ox + (idx % cols) * fw
|
|
77
|
+
const py = oy + Math.floor(idx / cols) * fh
|
|
59
78
|
frames[i * 4 + 0] = px / tex.width
|
|
60
79
|
frames[i * 4 + 1] = py / tex.height
|
|
61
80
|
frames[i * 4 + 2] = (px + fw) / tex.width
|
|
@@ -65,26 +84,88 @@ export class SpriteAnimation extends Aspect<"anim", Sprite> {
|
|
|
65
84
|
this._clipIds[name] = clipId
|
|
66
85
|
this._clipNames[clipId] = name
|
|
67
86
|
}
|
|
87
|
+
|
|
88
|
+
for (const name of Object.keys(this.clips)) {
|
|
89
|
+
const value = this.clips[name]
|
|
90
|
+
const indices = Array.isArray(value) ? value : value.frames
|
|
91
|
+
const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
|
|
92
|
+
const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
|
|
93
|
+
const dirs = !Array.isArray(value) && value.perDirection ? this.directions : undefined
|
|
94
|
+
|
|
95
|
+
if (dirs && dirs.length > 0) {
|
|
96
|
+
// one clip per facing: row i's frames are the base (row-0) frames shifted down i rows.
|
|
97
|
+
dirs.forEach((dir, row) => defineOne(`${name}_${dir}`, indices.map((f) => f + row * cols), fps, loop))
|
|
98
|
+
} else {
|
|
99
|
+
defineOne(name, indices, fps, loop)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
68
102
|
if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
|
|
103
|
+
this._defined = true
|
|
104
|
+
|
|
105
|
+
if (this._queued) {
|
|
106
|
+
const queued = this._queued
|
|
107
|
+
this._queued = null
|
|
108
|
+
const id = this._clipIds[queued]
|
|
109
|
+
if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${queued}"`)
|
|
110
|
+
_creator2d.playAnimation(node.id, id)
|
|
111
|
+
}
|
|
69
112
|
}
|
|
70
113
|
|
|
71
|
-
/**
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
114
|
+
/**
|
|
115
|
+
* Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly).
|
|
116
|
+
* For a `perDirection` clip, `dir` picks the facing — a direction name or a movement vector
|
|
117
|
+
* (nearest compass row wins); omitted, the last direction (or the first row) is kept.
|
|
118
|
+
*/
|
|
119
|
+
play(name: string, dir?: string | Vec2Like): this {
|
|
120
|
+
let target = name
|
|
121
|
+
const clip = this.clips[name]
|
|
122
|
+
if (clip && !Array.isArray(clip) && clip.perDirection && this.directions?.length) {
|
|
123
|
+
const resolved = this._resolveDir(dir)
|
|
124
|
+
if (resolved) { this._dir = resolved; target = `${name}_${resolved}` }
|
|
125
|
+
}
|
|
126
|
+
if (this._current === target) return this
|
|
127
|
+
this._current = target
|
|
128
|
+
if (!this._defined) { this._queued = target; return this }
|
|
129
|
+
const id = this._clipIds[target]
|
|
130
|
+
if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${target}"`)
|
|
77
131
|
_creator2d.playAnimation(this.node.id, id)
|
|
78
132
|
return this
|
|
79
133
|
}
|
|
80
134
|
|
|
81
135
|
stop(): this {
|
|
82
136
|
this._current = null
|
|
83
|
-
|
|
137
|
+
this._queued = null
|
|
138
|
+
if (this._defined) _creator2d.stopAnimation(this.node.id)
|
|
84
139
|
return this
|
|
85
140
|
}
|
|
86
141
|
|
|
142
|
+
/** Current facing (last direction resolved by play()), or null before the first directional play. */
|
|
143
|
+
get direction(): string | null { return this._dir }
|
|
144
|
+
|
|
87
145
|
set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
|
|
88
146
|
get current(): string | null { return this._current }
|
|
89
147
|
get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }
|
|
148
|
+
|
|
149
|
+
// A direction name passes through; a vector resolves to the nearest compass row this sheet has.
|
|
150
|
+
// A zero vector (standing still) keeps the current facing.
|
|
151
|
+
private _resolveDir(dir?: string | Vec2Like): string | null {
|
|
152
|
+
const dirs = this.directions!
|
|
153
|
+
if (typeof dir === "string") return dir
|
|
154
|
+
if (dir !== undefined) {
|
|
155
|
+
const x = cx(dir), y = cy(dir)
|
|
156
|
+
if (x !== 0 || y !== 0) {
|
|
157
|
+
const angle = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360
|
|
158
|
+
let best: string | null = null
|
|
159
|
+
let bestDist = Infinity
|
|
160
|
+
for (const d of dirs) {
|
|
161
|
+
const a = COMPASS[d]
|
|
162
|
+
if (a === undefined) continue
|
|
163
|
+
const dist = Math.min(Math.abs(a - angle), 360 - Math.abs(a - angle))
|
|
164
|
+
if (dist < bestDist) { bestDist = dist; best = d }
|
|
165
|
+
}
|
|
166
|
+
if (best) return best
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return this._dir ?? dirs[0] ?? null
|
|
170
|
+
}
|
|
90
171
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Sprite sheets as data: one `.sprite.ts` file per image, `export default defineSpriteSheet({...})`.
|
|
2
|
+
// ONE model: every named entry is a region of the image (a rect), optionally SLICED into an
|
|
3
|
+
// animation grid, optionally carrying named clips over that grid — so game code (and the scene
|
|
4
|
+
// editor) reference frames by NAME and never hardcode rects:
|
|
5
|
+
//
|
|
6
|
+
// import props from './props.sprite'
|
|
7
|
+
// import hero from './hero.sprite'
|
|
8
|
+
// scene.add(props.make('tree_orange', { position: [80, 144], layer: 1 }))
|
|
9
|
+
// scene.add(props.make('water', { position: [0, 0] })) // sliced, loops on its own
|
|
10
|
+
// const player = hero.make('hero', { clip: 'idle', layer: 1 })
|
|
11
|
+
// player.anim.play('walk', inputVector)
|
|
12
|
+
//
|
|
13
|
+
// A sheet describes ART only — rects, slices, clips, anchors. World semantics (colliders, bodies)
|
|
14
|
+
// are the SCENE's job: attach Shape2D / Physics2D / Trigger2D aspects to the placed node (a
|
|
15
|
+
// `.scene2d.ts` `aspects:` list, or `.aspect(...)` in code). Each tool owns its own part.
|
|
16
|
+
//
|
|
17
|
+
// The texture loads lazily on the first make()/load(); sprites created before it resolves get their
|
|
18
|
+
// texture/frame applied on arrival (the world size is known up front from the rect / slice cell, so
|
|
19
|
+
// layout doesn't wait). The sprite editor reads and writes these files — hand edits are preserved
|
|
20
|
+
// (see packages/scene-doc).
|
|
21
|
+
|
|
22
|
+
import { type Vec2Like } from "../math/vec"
|
|
23
|
+
import { Sprite } from "./Sprite"
|
|
24
|
+
import { SpriteAnimation, type Clip } from "./SpriteAnimation"
|
|
25
|
+
import { Texture2D } from "./Texture2D"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A named region of the image. Just a rect is a static sprite; `slice` subdivides the rect into an
|
|
29
|
+
* animation grid ([cols, rows] — the cell is rect size / counts); `clips` names frame runs over
|
|
30
|
+
* that grid (local, row-major). A sliced entry with NO clips loops all of its cells in order.
|
|
31
|
+
*/
|
|
32
|
+
export type SheetSprite = {
|
|
33
|
+
/** Pixel rect [x, y, w, h] in the image. */
|
|
34
|
+
rect?: [number, number, number, number]
|
|
35
|
+
/** Subdivide the rect into an animation grid: [cols, rows]. */
|
|
36
|
+
slice?: [number, number]
|
|
37
|
+
/** Playback fps for this sprite's animation (defaults to the sheet fps, then 12). */
|
|
38
|
+
fps?: number
|
|
39
|
+
/** Named clips over the local grid (see SpriteAnimation). Omit to loop every cell. */
|
|
40
|
+
clips?: Record<string, Clip>
|
|
41
|
+
/** Facing names in local-row order for `perDirection` clips (compass tokens: S/SE/E/…). */
|
|
42
|
+
directions?: string[]
|
|
43
|
+
/** Overrides the sheet anchor. */
|
|
44
|
+
anchor?: Vec2Like
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SpriteSheetDef = {
|
|
48
|
+
/** The image URL — write `asset('./sheet.png')`. */
|
|
49
|
+
image: string
|
|
50
|
+
/** Default pivot for every sprite made from this sheet ([0.5, 1] = feet, for Y-sorted worlds). */
|
|
51
|
+
anchor?: Vec2Like
|
|
52
|
+
/** Default fps for sliced sprites / clips that don't override it. */
|
|
53
|
+
fps?: number
|
|
54
|
+
/** The named sprites. */
|
|
55
|
+
sprites?: Record<string, SheetSprite>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type SpriteMakeOptions = {
|
|
59
|
+
position?: Vec2Like
|
|
60
|
+
layer?: number
|
|
61
|
+
/** Overrides the sheet/sprite anchor. */
|
|
62
|
+
anchor?: Vec2Like
|
|
63
|
+
/** Start this clip immediately (sliced sprites with clips). */
|
|
64
|
+
clip?: string
|
|
65
|
+
/** Initial facing for `perDirection` clips. */
|
|
66
|
+
direction?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The name a sliced-but-clipless sprite's implicit everything-loop plays under. */
|
|
70
|
+
const LOOP_ALL = "all"
|
|
71
|
+
|
|
72
|
+
export class SpriteSheet {
|
|
73
|
+
private _def: SpriteSheetDef
|
|
74
|
+
private _tex: Texture2D | null = null
|
|
75
|
+
private _loading: Promise<Texture2D> | null = null
|
|
76
|
+
|
|
77
|
+
constructor(def: SpriteSheetDef) {
|
|
78
|
+
this._def = def
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sheet definition (read-only by convention — the editor owns the file). */
|
|
82
|
+
get def(): SpriteSheetDef { return this._def }
|
|
83
|
+
|
|
84
|
+
/** The loaded texture, or null before load() resolves. */
|
|
85
|
+
get texture(): Texture2D | null { return this._tex }
|
|
86
|
+
|
|
87
|
+
/** Load the sheet's texture (idempotent). make() starts this automatically. */
|
|
88
|
+
load(): Promise<Texture2D> {
|
|
89
|
+
if (!this._loading) {
|
|
90
|
+
this._loading = Texture2D.load(this._def.image).then((tex) => { this._tex = tex; return tex })
|
|
91
|
+
}
|
|
92
|
+
return this._loading
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Names of the sheet's sprites. */
|
|
96
|
+
get spriteNames(): string[] { return Object.keys(this._def.sprites ?? {}) }
|
|
97
|
+
|
|
98
|
+
/** Build a named sprite; starting a clip hands back the animation handle. */
|
|
99
|
+
make(name: string, opts: SpriteMakeOptions & { clip: string }): Sprite & { anim: SpriteAnimation }
|
|
100
|
+
make(name: string, opts?: SpriteMakeOptions): Sprite
|
|
101
|
+
make(name: string, opts: SpriteMakeOptions = {}): Sprite {
|
|
102
|
+
const def = this._def
|
|
103
|
+
const named = def.sprites?.[name]
|
|
104
|
+
if (!named) {
|
|
105
|
+
throw new Error(`SpriteSheet.make(): unknown sprite "${name}" (have: ${this.spriteNames.join(", ") || "none"})`)
|
|
106
|
+
}
|
|
107
|
+
const rect = named.rect
|
|
108
|
+
const slice = named.slice
|
|
109
|
+
|
|
110
|
+
const sprite = new Sprite({
|
|
111
|
+
anchor: opts.anchor ?? named.anchor ?? def.anchor,
|
|
112
|
+
layer: opts.layer,
|
|
113
|
+
position: opts.position,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// The world size is known without the texture (the rect, or its slice cell) — set it up front
|
|
117
|
+
// so layout, Y-sort and colliders are correct even while the image is still loading.
|
|
118
|
+
if (rect) sprite.size = slice ? [ rect[2] / slice[0], rect[3] / slice[1] ] : [ rect[2], rect[3] ]
|
|
119
|
+
|
|
120
|
+
// sliced sprite: all animation flows through SpriteAnimation, anchored at the rect's origin.
|
|
121
|
+
// No clips declared = one implicit clip looping every cell (animated tiles).
|
|
122
|
+
if (rect && slice) {
|
|
123
|
+
const [ cols, rows ] = slice
|
|
124
|
+
const clips = named.clips && Object.keys(named.clips).length > 0
|
|
125
|
+
? named.clips
|
|
126
|
+
: { [LOOP_ALL]: Array.from({ length: cols * rows }, (_, i) => i) }
|
|
127
|
+
const animOpts: Partial<SpriteAnimation> = {
|
|
128
|
+
size: [ rect[2] / cols, rect[3] / rows ],
|
|
129
|
+
cols,
|
|
130
|
+
origin: [ rect[0], rect[1] ],
|
|
131
|
+
clips,
|
|
132
|
+
}
|
|
133
|
+
const fps = named.fps ?? def.fps
|
|
134
|
+
if (fps !== undefined) animOpts.fps = fps
|
|
135
|
+
if (named.directions) animOpts.directions = named.directions
|
|
136
|
+
sprite.aspect(SpriteAnimation, animOpts)
|
|
137
|
+
const start = opts.clip ?? (named.clips ? undefined : LOOP_ALL)
|
|
138
|
+
if (start) (sprite as Sprite & { anim: SpriteAnimation }).anim.play(start, opts.direction)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// texture + frame: now if loaded, else on arrival
|
|
142
|
+
if (this._tex) this._applyTexture(sprite, named, this._tex)
|
|
143
|
+
else this.load().then((tex) => this._applyTexture(sprite, named, tex))
|
|
144
|
+
return sprite
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The pixel rect [x, y, w, h] of a named sprite's REGION (the whole rect, sliced or not). */
|
|
148
|
+
rectOf(name: string): [number, number, number, number] | null {
|
|
149
|
+
const rect = this._def.sprites?.[name]?.rect
|
|
150
|
+
return rect ? [ ...rect ] : null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private _applyTexture(sprite: Sprite, named: SheetSprite, tex: Texture2D): void {
|
|
154
|
+
sprite.texture = tex // native resets UV/size; the Sprite setter re-applies our explicit size
|
|
155
|
+
const rect = named.rect
|
|
156
|
+
if (rect) {
|
|
157
|
+
const [ cw, ch ] = named.slice ? [ rect[2] / named.slice[0], rect[3] / named.slice[1] ] : [ rect[2], rect[3] ]
|
|
158
|
+
sprite.setFramePx(rect[0], rect[1], cw, ch) // cell 0 (or the whole static rect)
|
|
159
|
+
}
|
|
160
|
+
const anim = (sprite as Partial<{ anim: SpriteAnimation }>).anim
|
|
161
|
+
if (anim) anim.define()
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Declare a sprite sheet (the default export of a `.sprite.ts` file). */
|
|
166
|
+
export const defineSpriteSheet = (def: SpriteSheetDef): SpriteSheet => new SpriteSheet(def)
|