lecodes-cli 0.6.4 → 0.7.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/README.md +1 -0
- package/dist/index.js +920 -636
- package/package.json +9 -4
- package/runtime/scene-harness.json +1 -0
- package/runtime/sdk/compile/assetMacro.ts +4 -3
- package/runtime/sdk/compile/bundler.ts +35 -3
- package/runtime/sdk/compile/compileProject.ts +14 -2
- package/runtime/sdk/compile/libraryImports.ts +47 -0
- package/runtime/sdk/compile/sceneEditor.ts +16 -4
- package/runtime/sdk/core/Aspect.ts +37 -0
- package/runtime/sdk/core/InspectorUI.ts +212 -0
- package/runtime/sdk/core/fields.ts +15 -3
- package/runtime/sdk/gl/Node.ts +4 -1
- package/runtime/sdk/gl/scenarios.ts +349 -0
- package/runtime/sdk/inject.ts +14 -2
- package/runtime/sdk/kit/UITabs.ts +105 -0
- package/runtime/sdk/scene/defineScene.ts +651 -17
- package/runtime/sdk/scene/editorPlugins.ts +86 -0
- package/runtime/sdk/ui/UI.ts +1 -0
- package/runtime/sdk/ui/UIScreen.ts +23 -0
- package/runtime/sdk/ui/UIScreenHost.ts +212 -0
- package/runtime/sdk-types.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Clone, edit and push LeCodes projects from your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,18 +23,19 @@
|
|
|
23
23
|
"icon": "bun scripts/make-icon.ts",
|
|
24
24
|
"vendor-runtime": "bun scripts/vendor-runtime.ts",
|
|
25
25
|
"sync-peers": "bun scripts/sync-peer-versions.ts",
|
|
26
|
-
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --banner=\"#!/usr/bin/env node\"",
|
|
26
|
+
"build": "bun scripts/vendor-runtime.ts && bun build src/index.ts --target=node --outfile dist/index.js --external @letary/chisel --external lecodes-renderer --external lecodes-design --external lecodes-3d-editor --banner=\"#!/usr/bin/env node\"",
|
|
27
27
|
"build:bin": "bun scripts/build-bin.ts",
|
|
28
28
|
"typecheck": "tsc --noEmit",
|
|
29
29
|
"dev": "bun run src/index.ts",
|
|
30
30
|
"prepublishOnly": "bun run sync-peers && bun run build"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@letary/chisel": "^0.
|
|
33
|
+
"@letary/chisel": "^0.4.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"lecodes-design": "^0.3.1",
|
|
37
|
-
"lecodes-renderer": "^0.
|
|
37
|
+
"lecodes-renderer": "^0.6.0",
|
|
38
|
+
"lecodes-3d-editor": "^0.1.0"
|
|
38
39
|
},
|
|
39
40
|
"peerDependenciesMeta": {
|
|
40
41
|
"lecodes-design": {
|
|
@@ -42,12 +43,16 @@
|
|
|
42
43
|
},
|
|
43
44
|
"lecodes-renderer": {
|
|
44
45
|
"optional": true
|
|
46
|
+
},
|
|
47
|
+
"lecodes-3d-editor": {
|
|
48
|
+
"optional": true
|
|
45
49
|
}
|
|
46
50
|
},
|
|
47
51
|
"devDependencies": {
|
|
48
52
|
"lecodes-design": "workspace:*",
|
|
49
53
|
"lecodes-renderer": "^0.4.1",
|
|
50
54
|
"qrcode-terminal": "^0.12.0",
|
|
55
|
+
"lecodes-3d-editor": "workspace:*",
|
|
51
56
|
"sdk": "workspace:*"
|
|
52
57
|
}
|
|
53
58
|
}
|
|
@@ -0,0 +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"}
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
// loader/resolver as a hand-written import.
|
|
4
4
|
|
|
5
5
|
// Matches `asset("./img.png")` / `asset('./img.png')` calls whose argument is a single string
|
|
6
|
-
// literal (handling escaped chars inside the literal).
|
|
7
|
-
|
|
6
|
+
// literal (handling escaped chars inside the literal). The lookbehind excludes property access —
|
|
7
|
+
// a METHOD named asset (`ui.asset("model")`, InspectorUI) is not the macro.
|
|
8
|
+
const ASSET_RE = /(?<![.\w$])asset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g
|
|
8
9
|
|
|
9
10
|
/** Cheap pre-check so we don't run the regex over every file. */
|
|
10
11
|
export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
|
|
@@ -26,7 +27,7 @@ export const desugarAssetMacros = (src: string, filePath: string): string => {
|
|
|
26
27
|
return name
|
|
27
28
|
})
|
|
28
29
|
|
|
29
|
-
if (
|
|
30
|
+
if (/(?<![.\w$])asset\(/.test(out)) {
|
|
30
31
|
throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -5,6 +5,7 @@ import { dirname, join, relative } from "node:path"
|
|
|
5
5
|
import { fileURLToPath } from "node:url"
|
|
6
6
|
import { desugarAssetMacros, hasAssetMacro } from "./assetMacro"
|
|
7
7
|
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
|
|
8
|
+
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
|
|
8
9
|
|
|
9
10
|
/*
|
|
10
11
|
* The user-project bundler. Project files live in an in-memory map; the whole SDK is delivered via
|
|
@@ -44,6 +45,10 @@ const normalize = (path: string): string => {
|
|
|
44
45
|
// method-granular tree-shaking + (when minify is on) compress + mangle. Assets/shaders/svg reach it
|
|
45
46
|
// as `asset()` macros desugared to default imports; aspect names are injected as `static aspect`.
|
|
46
47
|
|
|
48
|
+
// `EDITOR` is a boolean define: `false` (const-folded, `if (EDITOR)` branches DCE'd away) in
|
|
49
|
+
// every shipped bundle, `true` only in scene-editor compiles (CompileOptions.editor). Requires a
|
|
50
|
+
// chisel with boolean-define support — an older binary ignores the entry and leaves `EDITOR` as a
|
|
51
|
+
// free identifier (a runtime ReferenceError only in code that actually uses it).
|
|
47
52
|
const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" }
|
|
48
53
|
|
|
49
54
|
// Methods the native host invokes by name have no in-bundle caller, so method-DCE can't see them.
|
|
@@ -102,9 +107,14 @@ const bundleProjectChisel = async (
|
|
|
102
107
|
files: MemoryFiles,
|
|
103
108
|
format: "esm" | "iife",
|
|
104
109
|
sourcemap: boolean,
|
|
110
|
+
minifyOverride?: boolean,
|
|
111
|
+
editor?: boolean,
|
|
112
|
+
libraries?: Record<string, MemoryFiles>,
|
|
105
113
|
): Promise<{ code: string, map: string | null }> => {
|
|
106
114
|
const entry = normalize(entrypoint)
|
|
115
|
+
const slugs = new Set(Object.keys(libraries ?? {}))
|
|
107
116
|
// Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
|
|
117
|
+
// - rewrite bare library imports (`from "acme-ui"`) → the injected `/__lib/<slug>/…` tree
|
|
108
118
|
// - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
|
|
109
119
|
// - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
|
|
110
120
|
const projFiles: MemoryFiles = {}
|
|
@@ -112,6 +122,7 @@ const bundleProjectChisel = async (
|
|
|
112
122
|
const path = normalize(k)
|
|
113
123
|
let src = v
|
|
114
124
|
if (/\.[tj]sx?$/.test(path)) {
|
|
125
|
+
if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
|
|
115
126
|
if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
|
|
116
127
|
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
117
128
|
}
|
|
@@ -119,8 +130,26 @@ const bundleProjectChisel = async (
|
|
|
119
130
|
}
|
|
120
131
|
if (!projFiles[entry]) throw new Error(`Entrypoint not found: ${entry}`)
|
|
121
132
|
|
|
133
|
+
// Library dependencies: merge each library's files under /__lib/<slug>/… (like /__sdk/…). They
|
|
134
|
+
// get the same asset/aspect desugaring as project code, but NOT the bare-import rewrite — a
|
|
135
|
+
// library can't declare dependencies (flat graph v1), so any bare import in a library is an
|
|
136
|
+
// error chisel surfaces. Library-internal relative imports resolve within the prefix untouched.
|
|
137
|
+
for (const [ slug, libFiles ] of Object.entries(libraries ?? {})) {
|
|
138
|
+
for (const [ k, v ] of Object.entries(libFiles)) {
|
|
139
|
+
const path = normalize(`/__lib/${slug}/${k}`)
|
|
140
|
+
let src = v
|
|
141
|
+
if (/\.[tj]sx?$/.test(path)) {
|
|
142
|
+
if (hasAssetMacro(src)) src = desugarAssetMacros(src, path)
|
|
143
|
+
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
144
|
+
}
|
|
145
|
+
projFiles[path] = src
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
122
149
|
const sdkFiles = collectSdkFiles(resolveSdkSrc())
|
|
123
|
-
|
|
150
|
+
// Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
|
|
151
|
+
// names, which the scene editor matches aspect schemas by (`describeAspect().className`).
|
|
152
|
+
const minify = minifyOverride ?? (process.env.LECODES_CHISEL_MINIFY !== "0")
|
|
124
153
|
const input = JSON.stringify({
|
|
125
154
|
files: { ...sdkFiles, ...projFiles },
|
|
126
155
|
entry,
|
|
@@ -128,7 +157,7 @@ const bundleProjectChisel = async (
|
|
|
128
157
|
format,
|
|
129
158
|
minify,
|
|
130
159
|
fuse: process.env.LECODES_CHISEL_FUSE === "1",
|
|
131
|
-
define: CHISEL_DEFINE,
|
|
160
|
+
define: { ...CHISEL_DEFINE, EDITOR: editor ? "true" : "false" },
|
|
132
161
|
keep: CHISEL_KEEP,
|
|
133
162
|
sourcemap,
|
|
134
163
|
// Reactive-UI desugaring (memoized children .map → __uiMap + auto-wrapped signal reads in
|
|
@@ -170,4 +199,7 @@ export const bundleProjectWithMap = async (
|
|
|
170
199
|
entrypoint: string,
|
|
171
200
|
files: MemoryFiles,
|
|
172
201
|
format: "esm" | "iife" = "esm",
|
|
173
|
-
|
|
202
|
+
minify?: boolean,
|
|
203
|
+
editor?: boolean,
|
|
204
|
+
libraries?: Record<string, MemoryFiles>,
|
|
205
|
+
): Promise<{ code: string, map: string | null }> => bundleProjectChisel(entrypoint, files, format, true, minify, editor, libraries)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// (the backend resolves asset URLs from the DB, the CLI from local metadata — same function, the
|
|
4
4
|
// URLs are passed in as `publicUrl` + per-entry `fileSrc`/`shaders`).
|
|
5
5
|
|
|
6
|
-
import { bundleProjectWithMap } from "./bundler"
|
|
6
|
+
import { bundleProjectWithMap, type MemoryFiles } from "./bundler"
|
|
7
7
|
import { buildHeader } from "./header"
|
|
8
8
|
import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
|
|
9
9
|
|
|
@@ -36,6 +36,16 @@ export type CompileOptions = {
|
|
|
36
36
|
* and returns its systemId). For hosts with no asset server (the desktop native host), where a URL
|
|
37
37
|
* can't be fetched. Off by default — the shipped/web build keeps the URL form. */
|
|
38
38
|
localAssets?: boolean
|
|
39
|
+
/** Override minification (default: on, `LECODES_CHISEL_MINIFY` env aside). Scene-editor bundles
|
|
40
|
+
* compile with `false`: mangling erases class names, which the editor matches aspect schemas by. */
|
|
41
|
+
minify?: boolean
|
|
42
|
+
/** Editor compile: the `EDITOR` global compiles to `true` (scene-editor bundles). Default false —
|
|
43
|
+
* shipped bundles get `EDITOR` = `false`, const-folded so `if (EDITOR)` branches are DCE'd. */
|
|
44
|
+
editor?: boolean
|
|
45
|
+
/** Component-library dependencies: slug → the library's files (root-relative paths). Merged under
|
|
46
|
+
* `/__lib/<slug>/…` and reachable from project code via `import … from "<slug>"`. See
|
|
47
|
+
* docs/library-system-plan.md §4. */
|
|
48
|
+
libraries?: Record<string, MemoryFiles>
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/** The default entrypoint: the largest top-level .ts/.js file with no `export` (a script entry). */
|
|
@@ -44,6 +54,8 @@ const detectEntry = (entries: CompileEntry[]): string => {
|
|
|
44
54
|
let max = -1
|
|
45
55
|
for (const e of entries) {
|
|
46
56
|
const ext = e.path.split(".").at(-1)?.toLowerCase()
|
|
57
|
+
// `.editor.ts` files are editor-only plugin modules — never a production entrypoint.
|
|
58
|
+
if (e.path.endsWith(".editor.ts")) continue
|
|
47
59
|
if (e.type === "text" && e.text !== undefined && !e.text.includes("export") && (ext === "ts" || ext === "js")) {
|
|
48
60
|
if (e.text.length > max) { entry = e.path; max = e.text.length }
|
|
49
61
|
}
|
|
@@ -84,7 +96,7 @@ export const compileProject = async (opts: CompileOptions): Promise<string> => {
|
|
|
84
96
|
}
|
|
85
97
|
}
|
|
86
98
|
|
|
87
|
-
const { code, map } = await bundleProjectWithMap(entry, files, opts.format ?? "esm")
|
|
99
|
+
const { code, map } = await bundleProjectWithMap(entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries)
|
|
88
100
|
if (opts.header === false) {
|
|
89
101
|
return map ? `${code}\n${inlineSourceMapComment(map)}` : code
|
|
90
102
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Pre-chisel rewrite of user-library bare-specifier imports. chisel's resolver hard-errors on any
|
|
2
|
+
// bare import (the whole SDK is globals), so a project that imports a component library by its slug
|
|
3
|
+
// —`import { Card } from "acme-ui"`— is rewritten here to the injected library tree under
|
|
4
|
+
// `/__lib/<slug>/…` BEFORE chisel runs. Only libraries present in the dependency map are known
|
|
5
|
+
// slugs; any other bare specifier is an author error, surfaced with a friendly message instead of
|
|
6
|
+
// chisel's generic one. Static string-literal specifiers only — dynamic `import()` is unsupported
|
|
7
|
+
// (documented). Chisel itself is untouched. See docs/library-system-plan.md §4.
|
|
8
|
+
|
|
9
|
+
/** Cheap pre-check so the regexes don't run over files with no imports at all. */
|
|
10
|
+
export const hasImportStatement = (src: string): boolean => src.includes("import") || src.includes("from")
|
|
11
|
+
|
|
12
|
+
// A static `from "…"` clause (import-from OR export-from). Anchored at a statement boundary
|
|
13
|
+
// (line start / `;` / `}`) and disallowing quotes/backticks/semicolons between the keyword and
|
|
14
|
+
// `from`, so it can never span into a string literal or a neighbouring statement. Named-import
|
|
15
|
+
// lists may span multiple lines (newlines are allowed between the keyword and `from`).
|
|
16
|
+
const IMPORT_FROM_RE = /(^|[;\n}])(\s*(?:import|export)\b[^"'`;]*?\bfrom\s*)(["'])([^"'\n]+)\3/g
|
|
17
|
+
|
|
18
|
+
// A side-effect `import "…"` (a bare quote directly after the keyword — not `import x`, not `import(`).
|
|
19
|
+
const SIDE_EFFECT_RE = /(^|[;\n}])(\s*import\s*)(["'])([^"'\n]+)\3/g
|
|
20
|
+
|
|
21
|
+
const isRelativeOrAbsolute = (spec: string): boolean =>
|
|
22
|
+
spec.startsWith("/") || spec.startsWith("./") || spec.startsWith("../")
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Rewrite every known-library bare import in `src` to its `/__lib/<slug>/…` path. Relative and
|
|
26
|
+
* absolute specifiers pass through untouched; an unknown bare specifier throws a friendly Error.
|
|
27
|
+
* `slugs` is the set of library slugs available to this project (its dependency map's keys).
|
|
28
|
+
*/
|
|
29
|
+
export const rewriteLibraryImports = (src: string, slugs: Set<string>): string => {
|
|
30
|
+
const rewriteSpec = (spec: string): string => {
|
|
31
|
+
if (isRelativeOrAbsolute(spec)) return spec
|
|
32
|
+
const slash = spec.indexOf("/")
|
|
33
|
+
const slug = slash === -1 ? spec : spec.slice(0, slash)
|
|
34
|
+
if (slugs.has(slug)) {
|
|
35
|
+
// `acme-ui` → `/__lib/acme-ui/index`; `acme-ui/theme` → `/__lib/acme-ui/theme`.
|
|
36
|
+
const sub = slash === -1 ? "/index" : spec.slice(slash)
|
|
37
|
+
return `/__lib/${slug}${sub}`
|
|
38
|
+
}
|
|
39
|
+
throw new Error(`Unknown library "${slug}" — add it in Settings → Libraries, or use a relative path like "./${slug}".`)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let out = src.replace(IMPORT_FROM_RE, (_m, boundary, head, quote, spec) =>
|
|
43
|
+
`${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`)
|
|
44
|
+
out = out.replace(SIDE_EFFECT_RE, (m, boundary, head, quote, spec) =>
|
|
45
|
+
isRelativeOrAbsolute(spec) ? m : `${boundary}${head}${quote}${rewriteSpec(spec)}${quote}`)
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
@@ -37,14 +37,21 @@ export const loadSceneHarness = (fallbackDir?: string): Record<string, string> =
|
|
|
37
37
|
* The extra entries that turn a project compile into a scene-editor compile: the harness modules
|
|
38
38
|
* plus a virtual entry that (1) sets the edit-mode flag in its own module — imports hoist, so the
|
|
39
39
|
* flag must run as a module, first — (2) imports the scene, which registers its handle on
|
|
40
|
-
* `__lecodesScenes`,
|
|
40
|
+
* `__lecodesScenes`, (3) imports the project's `*.editor.ts` plugin files (editor bundles ONLY —
|
|
41
|
+
* production compiles never import them, so they cost zero bytes in shipped `app.js`), and
|
|
42
|
+
* (4) imports the harness main, which installs `__lecodesSceneHarness`.
|
|
41
43
|
* FULL extensions everywhere — exact-match specifiers need no resolver guesswork on dotted names,
|
|
42
44
|
* so even the unpatched published chisel resolves the `.scene.ts` import.
|
|
43
45
|
*
|
|
44
|
-
* `scenePath` is the project path of the scene file ("./scenes/city.scene.ts")
|
|
45
|
-
*
|
|
46
|
+
* `scenePath` is the project path of the scene file ("./scenes/city.scene.ts"); `projectPaths` is
|
|
47
|
+
* the project's file list — `.editor.ts` entries are picked out of it. Compile with
|
|
48
|
+
* `entryOverride: SCENE_ENTRY_PATH`, `header: false`, `format: "iife"`, `editor: true`.
|
|
46
49
|
*/
|
|
47
|
-
export const sceneEditorEntries = (
|
|
50
|
+
export const sceneEditorEntries = (
|
|
51
|
+
scenePath: string,
|
|
52
|
+
harness: Record<string, string>,
|
|
53
|
+
projectPaths: string[] = [],
|
|
54
|
+
): CompileEntry[] => {
|
|
48
55
|
const entries: CompileEntry[] = Object.entries(harness).map(([ name, text ]) => ({
|
|
49
56
|
path: `${SCENE_HARNESS_DIR}/${name}`,
|
|
50
57
|
type: "text" as const,
|
|
@@ -55,11 +62,16 @@ export const sceneEditorEntries = (scenePath: string, harness: Record<string, st
|
|
|
55
62
|
type: "text",
|
|
56
63
|
text: `;(globalThis as any).__lecodesSceneEdit = true\nexport {}\n`,
|
|
57
64
|
})
|
|
65
|
+
const editorImports = projectPaths
|
|
66
|
+
.filter((p) => p.endsWith(".editor.ts"))
|
|
67
|
+
.map((p) => `import "${p}"\n`)
|
|
68
|
+
.join("")
|
|
58
69
|
entries.push({
|
|
59
70
|
path: SCENE_ENTRY_PATH,
|
|
60
71
|
type: "text",
|
|
61
72
|
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
62
73
|
+ `import "${scenePath}"\n`
|
|
74
|
+
+ editorImports
|
|
63
75
|
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`,
|
|
64
76
|
})
|
|
65
77
|
return entries
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
// the SDK's own built-in aspects declare `static readonly aspect` directly (see SpriteAnimation etc.).
|
|
17
17
|
|
|
18
18
|
import { Emitter, type EventMap } from "./events"
|
|
19
|
+
import type { InspectorUI } from "./InspectorUI"
|
|
19
20
|
import type { Node } from "../gl/Node"
|
|
20
21
|
|
|
21
22
|
// ---- type helpers -----------------------------------------------------------
|
|
@@ -173,6 +174,27 @@ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<
|
|
|
173
174
|
* engine via `node.aspect()`, never `new`.
|
|
174
175
|
*/
|
|
175
176
|
export abstract class Aspect<K extends string, P = Node> {
|
|
177
|
+
/**
|
|
178
|
+
* Opt this aspect class into EDITOR-RUN mode (generators): while a scene is edited, the scene
|
|
179
|
+
* loader constructs the instance (refs resolved, `node`/`generated` set — never `onAttach`) and
|
|
180
|
+
* calls `rebuild()` — again on inspector prop edits and when a `ref()`-referenced node moves.
|
|
181
|
+
* `static editor = { rebuild: true }`.
|
|
182
|
+
*/
|
|
183
|
+
static editor?: { rebuild?: boolean }
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Custom inspector card for this aspect in the scene editor (immediate-mode — see
|
|
187
|
+
* core/InspectorUI.ts): re-runs on every edit/event and emits a widget list. Without it, the
|
|
188
|
+
* editor shows the inferred fields; `ui.auto()` emits those same fields, so a custom inspector
|
|
189
|
+
* usually starts with `ui.auto()` and appends status lines / buttons / dynamic dropdowns:
|
|
190
|
+
*
|
|
191
|
+
* static inspector(ui: InspectorUI, self: MyAspect) {
|
|
192
|
+
* ui.auto()
|
|
193
|
+
* if (ui.button('Randomize')) self.rebuild()
|
|
194
|
+
* }
|
|
195
|
+
*/
|
|
196
|
+
static inspector?: (ui: InspectorUI, aspect: any) => void
|
|
197
|
+
|
|
176
198
|
// NOTE: P has no `extends AspectHost` constraint on purpose — Emitter<E>'s generic
|
|
177
199
|
// addEventListener makes a specific-event node (AspectHost<NodeEvents>) not assignable to
|
|
178
200
|
// AspectHost<any>, which would reject every real node. P only types `this.node`; target
|
|
@@ -180,10 +202,25 @@ export abstract class Aspect<K extends string, P = Node> {
|
|
|
180
202
|
// (3D is the default engine) — a 2D aspect declares its own P, e.g. Aspect<'x', Sprite>.
|
|
181
203
|
/** The node this aspect is attached to (set at attach time). */
|
|
182
204
|
readonly node!: P
|
|
205
|
+
/**
|
|
206
|
+
* GENERATOR aspects (`static editor = { rebuild: true }`, attached through a scene file): a
|
|
207
|
+
* scene-added container child for the aspect's generated output — `rebuild()` clears and refills
|
|
208
|
+
* it. Provided by the scene loader before `onAttach`/`rebuild` run; nodes `add()`ed to it join
|
|
209
|
+
* the scene's draw set automatically (membership is separate from parenting). Undefined for
|
|
210
|
+
* aspects attached by hand outside scene files. See docs/3d/scene-files.md.
|
|
211
|
+
*/
|
|
212
|
+
readonly generated!: Node & { clear(): void }
|
|
183
213
|
/** @internal phantom — lets the type system recover the accessor name `K`. Never read at runtime. */
|
|
184
214
|
declare protected readonly __key__?: K
|
|
185
215
|
onAttach?(): void
|
|
186
216
|
onDetach?(): void
|
|
217
|
+
/**
|
|
218
|
+
* GENERATOR aspects: (re)build the derived output under `this.generated` — must be idempotent
|
|
219
|
+
* (clear, then create). Call it from `onAttach()` for play mode; a class opting in with
|
|
220
|
+
* `static editor = { rebuild: true }` ALSO runs it while a scene is being edited: once at load,
|
|
221
|
+
* and again whenever an inspector prop changes or a `ref()`-referenced node moves.
|
|
222
|
+
*/
|
|
223
|
+
rebuild?(): void
|
|
187
224
|
/**
|
|
188
225
|
* Called every frame while attached (dt = seconds since the last frame). Render-synced: runs in the
|
|
189
226
|
* LATE phase by default — after the physics step + transform sync, right before the frame draws — so
|