lecodes-cli 0.18.0 → 0.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/index.js +1222 -293
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +52 -8
- package/runtime/sdk/compile/assetMacro.ts +116 -15
- package/runtime/sdk/compile/bundler.ts +38 -3
- package/runtime/sdk/compile/index.ts +15 -0
- package/runtime/sdk/compile/sceneEditor.ts +11 -1
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -3
- package/runtime/sdk/core/Aspect.ts +512 -255
- package/runtime/sdk/core/compWrite.ts +42 -0
- package/runtime/sdk/core/fields.ts +1 -1
- package/runtime/sdk/core/time.ts +81 -0
- package/runtime/sdk/g2/Camera2D.ts +8 -1
- package/runtime/sdk/g2/CharacterController2D.ts +253 -53
- package/runtime/sdk/g2/Node2D.ts +80 -10
- package/runtime/sdk/g2/OneWay2D.ts +66 -0
- package/runtime/sdk/g2/Physics2D.ts +240 -30
- package/runtime/sdk/g2/Scene2D.ts +33 -1
- package/runtime/sdk/g2/Shape2D.ts +218 -22
- package/runtime/sdk/g2/Trigger2D.ts +42 -12
- package/runtime/sdk/g2/groups2d.ts +106 -0
- package/runtime/sdk/g2/loop.ts +15 -4
- package/runtime/sdk/gl/Camera.ts +1 -1
- package/runtime/sdk/gl/CameraPlace.ts +4 -3
- package/runtime/sdk/gl/CharacterController.ts +184 -56
- package/runtime/sdk/gl/Gearbox.ts +212 -0
- package/runtime/sdk/gl/Geometry.ts +70 -9
- package/runtime/sdk/gl/Light.ts +64 -2
- package/runtime/sdk/gl/Lightmap.ts +179 -0
- package/runtime/sdk/gl/Material.ts +25 -0
- package/runtime/sdk/gl/Mesh.ts +6 -23
- package/runtime/sdk/gl/Model.ts +16 -2
- package/runtime/sdk/gl/Node.ts +119 -39
- package/runtime/sdk/gl/Physics.ts +75 -24
- package/runtime/sdk/gl/Scene.ts +148 -5
- package/runtime/sdk/gl/Shape.ts +42 -3
- package/runtime/sdk/gl/Trigger.ts +1 -6
- package/runtime/sdk/gl/Vehicle.ts +276 -322
- package/runtime/sdk/gl/Wheel.ts +240 -0
- package/runtime/sdk/gl/scenarios.ts +291 -317
- package/runtime/sdk/inject.ts +186 -171
- package/runtime/sdk/scene/defineScene.ts +1227 -1172
- package/runtime/sdk/scene/gizmos.ts +148 -128
- package/runtime/sdk/scene/material.ts +188 -0
- package/runtime/sdk-types.json +1 -1
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"dependencies": {
|
|
5
|
-
"@letary/chisel": "^0.
|
|
5
|
+
"@letary/chisel": "^0.10.0",
|
|
6
6
|
"jimp": "^1.6.1"
|
|
7
7
|
},
|
|
8
8
|
"devDependencies": {
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
},
|
|
17
17
|
"peerDependencies": {
|
|
18
18
|
"lecodes-design": "^0.8.1",
|
|
19
|
-
"lecodes-renderer": "^0.8.
|
|
20
|
-
"lecodes-3d-editor": "^0.
|
|
19
|
+
"lecodes-renderer": "^0.8.9",
|
|
20
|
+
"lecodes-3d-editor": "^0.5.0",
|
|
21
21
|
"lecodes-assets": "^0.1.0"
|
|
22
22
|
},
|
|
23
23
|
"bin": {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\n//\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\n//\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\n\nimport { OrbitCamera } from \"./orbit\"\n\n// The low-level bridge global every bundle runs against — the harness only walks the entity\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\n// in the SDK's registry, so id-level walking is the safe form). `computeBoundingBox` (subtree AABB\n// in the entity's local space) is a lite-engine host API — absent on filament.\ndeclare const _creator: {\n getParent(entityId: number): number\n computeBoundingBox?(entityId: number): Float32Array\n getWorldMatrix(entityId: number, mat: Float32Array): void\n}\n\ntype SceneHandleLike = {\n /** `nodes` is keyed by ABSOLUTE PATH ('/'-joined def keys) — the SDK owns this record; the\n * harness holds the same object and must never re-key it itself (see renameNode). */\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\n _describeAspects?(): unknown[]\n /** SceneHandle._patchNode — live single-node rebuild/add/remove; the path carries the mount\n * point (its parent must exist for adds). */\n _patchNode?(path: string, def: unknown): Promise<Node | null>\n /** SceneHandle._renameNode — re-keys the node record (subtree included) + the engine name;\n * returns the new path, null on refusal. */\n _renameNode?(path: string, newName: string): string | null\n /** SceneHandle._reparentNode — re-keys + setParent keeping the local transform. */\n _reparentNode?(path: string, newParentPath: string | null): string | null\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\n _modelParts?(path: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps cover `path`. */\n _editorNodeChanged?(path: string): void\n /** SceneHandle._editorGizmos — every editor run's `Gizmos` lines + a change version. */\n _editorGizmos?(): { version: number, batches: { color: string, alpha: number, segments: number[] }[] }\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\n _editorSetProp?(hostPath: string, index: number, key: string, value: unknown): boolean\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\n _editorSetMakeArg?(path: string, key: string, value: unknown): boolean\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\n _inspectorRender?(\n hostPath: string, index: number,\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\n ): unknown[] | null\n}\n\ntype Vec3Tuple = [number, number, number]\n/** The SDK Shape one-of, as the host names it (see fitShape / SceneHarness). */\ntype ShapeKindName = \"auto\" | \"box\" | \"sphere\" | \"cylinder\" | \"capsule\" | \"mesh\" | \"convex\"\n\ntype HarnessCallbacks = {\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\n onSelect?(name: string | null): void\n}\n\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\ntype EditorOps = {\n nodes(): { path: string, name: string, kind: string }[]\n uniqueName(base: string, parentPath?: string): string\n addNode(name: string, def: Record<string, unknown>): boolean\n setProp(path: string, key: string, value: unknown): boolean\n removeNode(path: string): void\n duplicate(path: string): string | null\n transact(fn: () => void): void\n}\n\nconst state = {\n handle: null as SceneHandleLike | null,\n scene: null as Scene | null,\n /** The SDK's path-keyed node record — the SAME object the handle owns (rename/reparent re-key\n * it SDK-side; the harness re-keys only its own part/selection state). */\n nodes: {} as Record<string, Node>,\n /** Reverse map: live node → absolute def path. */\n paths: new Map<Node, string>(),\n /** GLB internal nodes, addressable like nodes: key = `<defPath>::<partPath>` (+ reverse map). */\n parts: new Map<string, Node>(),\n partNames: new Map<Node, string>(),\n orbit: null as OrbitCamera | null,\n /** Selection key: absolute def path or `<defPath>::<partPath>`. */\n selected: null as string | null,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\n activeTool: null as string | null,\n /** Host-implemented doc operations (the `editor` API's write half). */\n editorOps: null as EditorOps | null,\n}\n\n// ---- part keys ---------------------------------------------------------------------------------\n// A model's internal nodes are selectable/editable through PART KEYS: `<defPath>::<partPath>` (the\n// part-path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store\n// it; names exclude ':', so the first `::` is always the boundary). Everything key-addressed\n// (select / setNodeProp / focus) resolves through `resolveKey`, so a part behaves like a node —\n// except its persistence: the editor writes the transform into the MODEL's `overrides` record.\n\nconst PART_SEP = \"::\"\n\n/** Re-key `key` after `oldPath` moved to `newPath` (the path itself, `/` descendants, and `::`\n * part keys of either). Null = untouched. Mirrors scene-editor/types.ts `rekeyPath` — the\n * harness compiles standalone and cannot import it. */\nconst rekeyPath = (key: string, oldPath: string, newPath: string): string | null => {\n if (key === oldPath) return newPath\n if (key.startsWith(oldPath + \"/\") || key.startsWith(oldPath + PART_SEP)) {\n return newPath + key.slice(oldPath.length)\n }\n return null\n}\n\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\n\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\nconst refreshModelParts = async (path: string): Promise<{ path: string, name: string, depth: number }[]> => {\n const handle = state.handle\n if (!handle?._modelParts) return []\n const rows = await handle._modelParts(path)\n for (const [ key, node ] of [ ...state.parts ]) {\n if (key.startsWith(path + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\n }\n for (const r of rows) {\n const key = path + PART_SEP + r.path\n state.parts.set(key, r.node)\n state.partNames.set(r.node, key)\n }\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\n}\n\n/** Editor-tool gizmo lines (a tool's hooks draw through `Gizmos` too) — one buffer for the active\n * tool, cleared on every hook call and on tool switch. */\nconst toolGizmos = new __GizmoBuffer()\nlet toolGizmosVersion = 0\n\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\n * that reference it via ref() rebuild. Part keys collapse to their model's def path. */\nconst notifyEditorChanged = (key: string | null): void => {\n if (!key) return\n const i = key.indexOf(PART_SEP)\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\n}\n\n/** After the SDK re-keyed the shared node record (rename/reparent), re-key the HARNESS-owned\n * state that mirrors those paths: the reverse map, part keys, node-card cache, selection. */\nconst rekeyLocal = (oldPath: string, newPath: string): void => {\n state.paths = new Map(Object.entries(state.nodes).map(([ p, n ]) => [ n, p ]))\n for (const [ key, part ] of [ ...state.parts ]) {\n const next = rekeyPath(key, oldPath, newPath)\n if (next === null) continue\n state.parts.delete(key)\n state.parts.set(next, part)\n state.partNames.set(part, next)\n }\n for (const [ key, ui ] of [ ...nodeCards ]) {\n const next = rekeyPath(key, oldPath, newPath)\n if (next === null) continue\n nodeCards.delete(key)\n nodeCards.set(next, ui)\n }\n if (state.selected) state.selected = rekeyPath(state.selected, oldPath, newPath) ?? state.selected\n}\n\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\n\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\n// Model constructor itself, so it is live even in edit mode.\n\nconst nodeCards = new Map<string, InspectorUI>()\n\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\n const node = state.nodes[name]\n if (!(node instanceof Model)) return null\n const clips = node.anim.clips\n if (clips.length === 0) return null\n let ui = nodeCards.get(name)\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\n return ui._run((u) => {\n u.header(\"Animation\")\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\n const loop = u.switch(\"loop\", { value: true })\n const playing = node.anim.busy || node.anim.loop !== undefined\n if (playing) node.anim.speed = speed\n if (u.button(playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n if (loop) node.anim.playLoop(clip)\n else node.anim.play(clip, { restart: true })\n }\n if (u.button(\"Stop\")) node.anim.stop() // fades to the rest pose\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The def path a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\nconst ownerPath = (node: Node | null): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const path = state.paths.get(cur)\n if (path) return path\n cur = cur.parent\n }\n return null\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n if (notify) controller.callbacks.onSelect?.(name)\n}\n\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\n\nconst isInSubtree = (node: Node, root: Node): boolean => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 64; i++) {\n if (cur === root) return true\n cur = cur.parent\n }\n return false\n}\n\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\n const scene = state.scene\n if (!scene) return null\n const ray = scene.camera.getRay(x, y)\n if (Physics.supported) {\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\n let remaining = 2000\n for (let i = 0; i < 8 && remaining > 0; i++) {\n const hit = Physics.raycast(origin, ray.dir, remaining)\n if (!hit) break\n // marker-flagged nodes (empties/cameras) — placement/tool rays step past them like editor\n // nodes (a drop must land on real geometry)\n if (hit.node && !isEditorNode(hit.node)\n && !(hit.node as { _sceneMarker?: string })._sceneMarker\n && !(exclude && isInSubtree(hit.node, exclude))) {\n return {\n point: [ hit.point.x, hit.point.y, hit.point.z ],\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\n node: hit.node ? ownerPath(hit.node) : null,\n }\n }\n // an excluded/editor body — resume the cast just past it\n const step = remaining * hit.fraction + 0.01\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\n remaining -= step\n }\n }\n const t = -ray.origin.y / ray.dir.y\n if (!Number.isFinite(t) || t <= 0) return null\n const p = ray.getPoint(t)\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\n}\n\nconst editorApi = {\n get selection(): string | null { return state.selected },\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\n raycast: raycastViewport,\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\n}\n\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\nconst windowUIs = new Map<number, InspectorUI>()\n\nconst controller = {\n callbacks: {} as HarnessCallbacks,\n\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node PATHS\n * (build-completion order, not file order — the host sorts for display). */\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\n const { scene, nodes } = await handle.load()\n state.handle = handle\n state.scene = scene\n state.nodes = nodes\n state.paths = new Map(Object.entries(nodes).map(([ path, node ]) => [ node, path ]))\n\n scene.open()\n\n state.orbit = new OrbitCamera(scene.camera)\n\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\n scene.addEventListener(\"touchstart\", (ev) => {\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\n // is host-driven from the tree, so a click outside a tool does nothing)\n scene.addEventListener(\"click\", (ev) => {\n if (!state.activeTool) return\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (!tool?.hooks.onViewportClick) return\n const hit = raycastViewport(ev.clientX, ev.clientY)\n if (hit) {\n // a throwing tool logs and skips — it can't take the editor down; Gizmos calls inside\n // the hook land in the tool's own buffer (cleared per call)\n try { toolGizmosVersion++; __withGizmoScope(toolGizmos, () => tool.hooks.onViewportClick!(hit, editorApi)) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the NEAREST tracked def node (its absolute path — with\n * nested def nodes the deepest one wins); a hit inside the CURRENTLY SELECTED model resolves\n * to its deepest part key instead (click-again drill-down). Locked nodes yield null (the\n * click selects nothing — the host keeps the current selection). */\n pickName(entityId: number): string | null {\n if (!entityId) return null\n const nodeIds = new Map<number, string>()\n for (const [ path, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, path)\n const partIds = new Map<number, string>()\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\n let part: string | null = null\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\n const path = nodeIds.get(id)\n if (path !== undefined) {\n const node = state.nodes[path]\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\n const inSelected = state.selected === path || state.selected?.startsWith(path + PART_SEP)\n return part && inSelected ? part : path\n }\n id = _creator.getParent(id)\n }\n return null\n },\n\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\n * by hand, the engines' setter conventions differ). */\n applyPose(\n name: string,\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\n const node = resolveKey(name)\n if (!node) return null\n node.position = pose.position\n node.quaternion = pose.quaternion\n node.scale = pose.scale\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n }\n },\n\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\n * (re-)binds the part keys, so call it after anything that reloads the model. */\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\n return refreshModelParts(name)\n },\n\n /** The camera PREVIEW source for a node carrying a `CameraPlace` (edit mode registers the\n * generator instance on the node): its entity + lens, plus the editor-marker entities to hide\n * from the inset. Null when the node is unknown or has no place. */\n cameraPreview(name: string): { entityId: number, fov: number, near: number, far: number, hide: number[] } | null {\n const node = resolveKey(name)\n if (!node) return null\n const place = (node as Node & { cameraPlace?: { fov: number, near: number, far: number } }).cameraPlace\n ?? (node.get(CameraPlace) as { fov: number, near: number, far: number } | undefined)\n if (!place) return null\n const hide: number[] = []\n const walk = (n: Node): void => {\n for (const c of n.children) {\n if (c.name === \"__editor_marker\") hide.push(c.id)\n walk(c)\n }\n }\n for (const n of Object.values(state.nodes)) if (!n.parent || !(n.parent as { _sceneDef?: boolean })._sceneDef) walk(n)\n return { entityId: node.id, fov: place.fov, near: place.near, far: place.far, hide }\n },\n\n /** Clip names of a loaded model node (the inspector's preview-pose dropdown); [] otherwise. */\n modelClips(name: string): string[] {\n const node = resolveKey(name) as (Node & { anim?: { clips: readonly { name: string }[] } }) | null\n return node?.anim ? node.anim.clips.map((c) => c.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\" | \"editor\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"editor\") {\n // the preview pose (`editor: { clip, time }`) — edit-mode only, never a runtime default\n __applyEditorPose(node, (value ?? undefined) as { clip?: string, time?: number } | undefined)\n return\n }\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 /** Every editor gizmo line currently drawn (generator rebuilds + the active tool) as world-space\n * LINES batches, with a version that changes whenever any of them re-ran — the host pushes to\n * the engine overlay only when it moves. Empty on bundles predating gizmos. */\n gizmos(): { version: number, batches: { color: string, alpha: number, segments: number[] }[] } {\n const runs = state.handle?._editorGizmos?.() ?? { version: 0, batches: [] }\n const batches = runs.batches.slice()\n for (const b of toolGizmos.batches) if (b.segments.length > 0) batches.push(b)\n return { version: runs.version * 1000003 + toolGizmosVersion, batches }\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE node at `path`\n * from plain def data — the SDK re-instantiates it through the same defineScene builder, so\n * nothing recompiles. The path carries the mount point. The HOST guarantees the def is plain\n * data (it bails to a re-run on `$expr` and `$asset` values). False = the SDK can't patch. */\n async patchNode(path: string, def: Record<string, unknown> | null): Promise<boolean> {\n const handle = state.handle\n if (!handle?._patchNode) return false\n const node = await handle._patchNode(path, def)\n state.paths = new Map(Object.entries(state.nodes).map(([ p, nd ]) => [ nd, p ]))\n notifyEditorChanged(path) // generators referencing the patched node follow it\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\n await refreshModelParts(path)\n const sel = state.selected\n if (sel === path || sel?.startsWith(path + \"/\") || sel?.startsWith(path + PART_SEP)) {\n const alive = sel === path ? node : (sel ? resolveKey(sel) : null)\n if (!alive) state.selected = null\n }\n return true\n },\n\n /** Live rename (bare sibling segment). The SDK owns the shared node record and re-keys it —\n * subtree, editor runs, and the engine-side display name included; the harness re-keys only\n * its OWN state (reverse path map, part keys, node-card cache, selection). The document is\n * the source of truth. */\n renameNode(path: string, newName: string): boolean {\n const newPath = state.handle?._renameNode?.(path, newName) ?? null\n if (newPath === null) return false\n rekeyLocal(path, newPath)\n return true\n },\n\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\n * (a node under a transformed parent shifts in world space). `newParentPath` null = root.\n * The node's (and its subtree's) PATHS change with the move — same re-key story as rename. */\n reparentNode(path: string, newParentPath: string | null): boolean {\n const newPath = state.handle?._reparentNode?.(path, newParentPath) ?? null\n if (newPath === null) return false\n rekeyLocal(path, newPath)\n return true\n },\n\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\n * back to a re-run for everything else. */\n setSceneProp(key: string, value: unknown): boolean {\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\n state.scene.skybox = value\n return true\n }\n return false\n },\n\n zoom(delta: number): void { state.orbit?.zoom(delta) },\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\n\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\n return raycastViewport(x, y)\n },\n\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\n aspectSchemas(): unknown[] {\n return state.handle?._describeAspects?.() ?? []\n },\n\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\n editorWindows(): { title: string }[] {\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\n },\n\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\n * cards, except every field key is transient editor state (windows have no doc entry). */\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\n const win = __editorPlugins.windows[index]\n if (!win) return null\n let ui = windowUIs.get(index)\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\n return ui._run((u) => win.render(u, editorApi), event)\n },\n\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\n },\n\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\n * tool's hooks; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n toolGizmos.clear()\n toolGizmosVersion++\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 // world poses of every def node + any node named in `names` under them — a numeric probe\n const poses: Record<string, number[]> = {}\n const visit = (n: Node, key: string, depth: number): void => {\n const m = n.worldMatrix\n const pos = m.position, z = m.basisZ, y = m.basisY\n poses[key] = [ pos.x, pos.y, pos.z, z.x, z.y, z.z, y.x, y.y, y.z ].map((v) => Math.round(v * 1000) / 1000)\n if (depth > 6) return\n for (const c of n.children) if (c.name && c.name !== \"__editor_marker\") visit(c, `${key}/${c.name}`, depth + 1)\n }\n for (const [ path, n ] of Object.entries(state.nodes)) if (!path.includes(\"/\")) visit(n, path, 0)\n return {\n poses,\n selected: state.selected, // def PATH (\"city/in1/pt1\") OR `<defPath>::<partPath>` part key\n activeTool: state.activeTool,\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n }\n },\n\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\n * should write to the file (never compose eulers by hand: the shipped engines apply the\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\n const node = resolveKey(name)\n const cam = state.scene?.camera\n if (!node || !cam) return null\n const parent = node.parent\n const camPos = cam.worldPosition\n node.quaternion = parent\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\n : cam.worldQuaternion\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\n },\n\n /** Frame the named node or part (or the selection). */\n focus(name?: string): void {\n const node = resolveKey(name ?? state.selected ?? \"\")\n if (!node) return\n const p = node.worldPosition\n state.orbit?.focus([ p.x, p.y, p.z ])\n },\n\n /** \"Fit to mesh\": dimensions + origin that make `kind` hug what the node actually renders, as\n * plain numbers for the host to write into the file (nothing is applied here — aspects are\n * inert in edit mode, and the collider overlay redraws from the document). Null while the\n * subtree measures as nothing: an empty node, or a model still loading — the host can retry.\n *\n * The measuring is `Shape.fitBounds` from the SDK, the same code `shape.fit()` runs at runtime.\n * Both inputs come from the ENGINE rather than from the node object: the node belongs to the\n * user's bundle, whose accessors the bundler may have shaken out (see the `_creator` note). */\n fitShape(name: string, kind?: ShapeKindName): unknown {\n const node = resolveKey(name)\n const box = node ? _creator.computeBoundingBox?.(node.id) : undefined\n if (!node || !box) return null\n if (!(box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) return null\n const m = new Float32Array(16)\n _creator.getWorldMatrix(node.id, m)\n const scale: Vec3Tuple = [\n Math.hypot(m[0], m[1], m[2]) || 1,\n Math.hypot(m[4], m[5], m[6]) || 1,\n Math.hypot(m[8], m[9], m[10]) || 1,\n ]\n const bounds = { min: [ box[0], box[1], box[2] ] as Vec3Tuple, max: [ box[3], box[4], box[5] ] as Vec3Tuple }\n return Shape.fitBounds(bounds, scale, kind ?? \"box\")\n },\n\n /** Frame the named node from its subtree AABB: orbit target = world-space center, distance from\n * the extent (the host's preview captures + a future \"frame selection\"). False while the AABB is\n * degenerate — the engine answers zeros for empty/not-yet-loaded subtrees, so the host can poll\n * this as its \"model loaded\" signal — or without engine bounds support (filament). */\n frameNode(name: string): boolean {\n const node = resolveKey(name)\n const box = node ? _creator.computeBoundingBox?.(node.id) : undefined\n if (!node || !box) return false\n if (!(box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) return false\n // world AABB from the 8 local-space corners (the local box doesn't include the node's own\n // transform — worldMatrix carries it)\n const m = node.worldMatrix\n const min: Vec3Tuple = [ Infinity, Infinity, Infinity ]\n const max: Vec3Tuple = [ -Infinity, -Infinity, -Infinity ]\n for (let i = 0; i < 8; i++) {\n const p = m.transformPoint([ box[i & 1 ? 3 : 0], box[i & 2 ? 4 : 1], box[i & 4 ? 5 : 2] ])\n const c = [ p.x, p.y, p.z ]\n for (let k = 0; k < 3; k++) {\n if (c[k] < min[k]) min[k] = c[k]\n if (c[k] > max[k]) max[k] = c[k]\n }\n }\n const size = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2])\n state.orbit?.focus(\n [ (min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2 ],\n Math.max(1, size * 1.4),\n )\n return true\n },\n}\n\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure orbit-camera math — plain number tuples in/out, no SDK dependencies, so it unit-tests\n// directly (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n// (The gizmo math that used to live beside it moved into the lite ENGINE's editor layer,\n// viewer-lite/src/gl/editor.ts, together with the gizmo itself.)\n\nexport type V3 = readonly [number, number, number]\n\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
|
|
1
|
+
{"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\r\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\r\n//\r\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\r\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\r\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\r\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\r\n//\r\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\r\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\r\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\r\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\r\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\r\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\r\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\r\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\r\n\r\nimport { OrbitCamera } from \"./orbit\"\r\n\r\n// The low-level bridge global every bundle runs against — the harness only walks the entity\r\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\r\n// in the SDK's registry, so id-level walking is the safe form). `computeBoundingBox` (subtree AABB\r\n// in the entity's local space) is a lite-engine host API — absent on filament.\r\ndeclare const _creator: {\r\n getParent(entityId: number): number\r\n computeBoundingBox?(entityId: number): Float32Array\r\n getWorldMatrix(entityId: number, mat: Float32Array): void\r\n setMaterial(entityId: number, materialId: number, index: number): void\r\n getMaterial(entityId: number, index: number): number\r\n}\r\n\r\ntype SceneHandleLike = {\r\n /** `nodes` is keyed by ABSOLUTE PATH ('/'-joined def keys) — the SDK owns this record; the\r\n * harness holds the same object and must never re-key it itself (see renameNode). */\r\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\r\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\r\n _describeAspects?(): unknown[]\r\n /** SceneHandle._patchNode — live single-node rebuild/add/remove; the path carries the mount\r\n * point (its parent must exist for adds). */\r\n _patchNode?(path: string, def: unknown): Promise<Node | null>\r\n /** SceneHandle._renameNode — re-keys the node record (subtree included) + the engine name;\r\n * returns the new path, null on refusal. */\r\n _renameNode?(path: string, newName: string): string | null\r\n /** SceneHandle._reparentNode — re-keys + setParent keeping the local transform. */\r\n _reparentNode?(path: string, newParentPath: string | null): string | null\r\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\r\n _modelParts?(path: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\r\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps cover `path`. */\r\n _editorNodeChanged?(path: string): void\r\n /** SceneHandle._editorGizmos — every editor run's `Gizmos` lines + a change version. */\r\n _editorGizmos?(): { version: number, batches: { color: string, alpha: number, segments: number[] }[] }\r\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\r\n _editorSetProp?(hostPath: string, index: number, key: string, value: unknown): boolean\r\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\r\n _editorSetMakeArg?(path: string, key: string, value: unknown): boolean\r\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\r\n _inspectorRender?(\r\n hostPath: string, index: number,\r\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\r\n ): unknown[] | null\r\n}\r\n\r\ntype Vec3Tuple = [number, number, number]\r\n/** The SDK Shape one-of, as the host names it (see fitShape / SceneHarness). */\r\ntype ShapeKindName = \"auto\" | \"box\" | \"sphere\" | \"cylinder\" | \"capsule\" | \"mesh\" | \"convex\"\r\n\r\ntype HarnessCallbacks = {\r\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\r\n onSelect?(name: string | null): void\r\n}\r\n\r\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\r\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\r\ntype EditorOps = {\r\n nodes(): { path: string, name: string, kind: string }[]\r\n uniqueName(base: string, parentPath?: string): string\r\n addNode(name: string, def: Record<string, unknown>): boolean\r\n setProp(path: string, key: string, value: unknown): boolean\r\n removeNode(path: string): void\r\n duplicate(path: string): string | null\r\n transact(fn: () => void): void\r\n}\r\n\r\nconst state = {\r\n handle: null as SceneHandleLike | null,\r\n scene: null as Scene | null,\r\n /** The SDK's path-keyed node record — the SAME object the handle owns (rename/reparent re-key\r\n * it SDK-side; the harness re-keys only its own part/selection state). */\r\n nodes: {} as Record<string, Node>,\r\n /** Reverse map: live node → absolute def path. */\r\n paths: new Map<Node, string>(),\r\n /** GLB internal nodes, addressable like nodes: key = `<defPath>::<partPath>` (+ reverse map). */\r\n parts: new Map<string, Node>(),\r\n partNames: new Map<Node, string>(),\r\n orbit: null as OrbitCamera | null,\r\n /** Selection key: absolute def path or `<defPath>::<partPath>`. */\r\n selected: null as string | null,\r\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\r\n activeTool: null as string | null,\r\n /** Host-implemented doc operations (the `editor` API's write half). */\r\n editorOps: null as EditorOps | null,\r\n}\r\n\r\n// ---- part keys ---------------------------------------------------------------------------------\r\n// A model's internal nodes are selectable/editable through PART KEYS: `<defPath>::<partPath>` (the\r\n// part-path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store\r\n// it; names exclude ':', so the first `::` is always the boundary). Everything key-addressed\r\n// (select / setNodeProp / focus) resolves through `resolveKey`, so a part behaves like a node —\r\n// except its persistence: the editor writes the transform into the MODEL's `overrides` record.\r\n\r\nconst PART_SEP = \"::\"\r\n\r\n/** Re-key `key` after `oldPath` moved to `newPath` (the path itself, `/` descendants, and `::`\r\n * part keys of either). Null = untouched. Mirrors scene-editor/types.ts `rekeyPath` — the\r\n * harness compiles standalone and cannot import it. */\r\nconst rekeyPath = (key: string, oldPath: string, newPath: string): string | null => {\r\n if (key === oldPath) return newPath\r\n if (key.startsWith(oldPath + \"/\") || key.startsWith(oldPath + PART_SEP)) {\r\n return newPath + key.slice(oldPath.length)\r\n }\r\n return null\r\n}\r\n\r\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\r\n\r\n// ---- materials ---------------------------------------------------------------------------------\r\n// Live material edits (docs/scene-materials-plan.md §4): a parameter lands on the LIVE material of\r\n// a mesh node / a GLB-part slot / a material asset with `.set()` — the value tier, no rebuild; a\r\n// shader or kind switch reloads ONE material. Doc defs reach the harness as plain data in which a\r\n// material asset is `{ $material: \"./x.material.ts\" }` — lowered here to the handle the compile's\r\n// virtual entry registered on `__lecodesMaterials`.\r\n\r\ntype MaterialTarget = { node: string } | { part: string, slot?: number } | { asset: string }\r\n\r\n/** The registry captured at attach — a second bundle run in the same page (the preview host)\r\n * overwrites the global with ITS handles, so each harness keeps the one that was current when it\r\n * attached (the host sets it right before). Falls back to the global for older hosts. */\r\nlet capturedMaterials: Record<string, MaterialHandle> | null = null\r\nconst materialHandles = (): Record<string, MaterialHandle> =>\r\n capturedMaterials ?? ((globalThis as any).__lecodesMaterials as Record<string, MaterialHandle> | undefined) ?? {}\r\n\r\nconst isMaterialRef = (v: unknown): v is { $material: string } =>\r\n typeof v === \"object\" && v !== null && typeof (v as { $material?: unknown }).$material === \"string\"\r\n\r\n/** A doc material def → runtime def (asset refs resolved to handles). Null = unknown asset path. */\r\nconst lowerMaterial = (def: unknown): unknown => (isMaterialRef(def) ? materialHandles()[def.$material] ?? null : def)\r\n\r\n/** Lower every material position of a node def (its own, part-override slots, children). Null\r\n * when a reference doesn't resolve — the host falls back to a re-run. */\r\nconst lowerNodeDef = (def: Record<string, unknown>): Record<string, unknown> | null => {\r\n const out = { ...def }\r\n if (out.material !== undefined) {\r\n const m = lowerMaterial(out.material)\r\n if (m === null) return null\r\n out.material = m\r\n }\r\n if (out.overrides && typeof out.overrides === \"object\") {\r\n const ov: Record<string, unknown> = {}\r\n for (const [ p, entry ] of Object.entries(out.overrides as Record<string, Record<string, unknown>>)) {\r\n const e = { ...entry }\r\n if (e.materials && typeof e.materials === \"object\") {\r\n const mats: Record<string, unknown> = {}\r\n for (const [ slot, m ] of Object.entries(e.materials as Record<string, unknown>)) {\r\n const lowered = lowerMaterial(m)\r\n if (lowered === null) return null\r\n mats[slot] = lowered\r\n }\r\n e.materials = mats\r\n }\r\n ov[p] = e\r\n }\r\n out.overrides = ov\r\n }\r\n if (out.children && typeof out.children === \"object\") {\r\n const ch: Record<string, unknown> = {}\r\n for (const [ n, c ] of Object.entries(out.children as Record<string, Record<string, unknown>>)) {\r\n const lowered = lowerNodeDef(c)\r\n if (lowered === null) return null\r\n ch[n] = lowered\r\n }\r\n out.children = ch\r\n }\r\n return out\r\n}\r\n\r\n/** The live Material behind a target (null = none assigned yet / unknown key). */\r\nconst materialAt = (target: MaterialTarget): Material | null => {\r\n if (\"asset\" in target) return materialHandles()[target.asset]?.material ?? null\r\n if (\"node\" in target) return resolveKey(target.node)?.getMaterial(0) ?? null\r\n return resolveKey(target.part)?.getMaterial(target.slot ?? 0) ?? null\r\n}\r\n\r\n/** Push one parameter into a live material. Built-in materials take `color`/`map` through their\r\n * accessors (they know the shader's uniform names); everything else is a `.set()`, where a\r\n * non-`#` string is a texture URL that loads in. */\r\nconst pushMaterialParam = (m: Material, name: string, value: unknown): void => {\r\n const builtin = (m as unknown as { _colorKey: string | null })._colorKey !== null\r\n if (builtin && name === \"color\") { m.color = value as string; return }\r\n if (builtin && name === \"map\") {\r\n if (typeof value === \"string\") void Texture.load(value).then((t) => { m.map = t })\r\n else m.map = null\r\n return\r\n }\r\n void __applyMaterialParam(m, name, value as never)\r\n}\r\n\r\n/** The original (glTF) material of every part slot overridden live, so clearing restores it. */\r\nconst originalSlotMaterials = new Map<string, number>()\r\n\r\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\r\nconst refreshModelParts = async (path: string): Promise<{ path: string, name: string, depth: number }[]> => {\r\n const handle = state.handle\r\n if (!handle?._modelParts) return []\r\n const rows = await handle._modelParts(path)\r\n for (const [ key, node ] of [ ...state.parts ]) {\r\n if (key.startsWith(path + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\r\n }\r\n for (const r of rows) {\r\n const key = path + PART_SEP + r.path\r\n state.parts.set(key, r.node)\r\n state.partNames.set(r.node, key)\r\n }\r\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\r\n}\r\n\r\n/** Editor-tool gizmo lines (a tool's hooks draw through `Gizmos` too) — one buffer for the active\r\n * tool, cleared on every hook call and on tool switch. */\r\nconst toolGizmos = new __GizmoBuffer()\r\nlet toolGizmosVersion = 0\r\n\r\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\r\n * that reference it via ref() rebuild. Part keys collapse to their model's def path. */\r\nconst notifyEditorChanged = (key: string | null): void => {\r\n if (!key) return\r\n const i = key.indexOf(PART_SEP)\r\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\r\n}\r\n\r\n/** After the SDK re-keyed the shared node record (rename/reparent), re-key the HARNESS-owned\r\n * state that mirrors those paths: the reverse map, part keys, node-card cache, selection. */\r\nconst rekeyLocal = (oldPath: string, newPath: string): void => {\r\n state.paths = new Map(Object.entries(state.nodes).map(([ p, n ]) => [ n, p ]))\r\n for (const [ key, part ] of [ ...state.parts ]) {\r\n const next = rekeyPath(key, oldPath, newPath)\r\n if (next === null) continue\r\n state.parts.delete(key)\r\n state.parts.set(next, part)\r\n state.partNames.set(part, next)\r\n }\r\n for (const [ key, ui ] of [ ...nodeCards ]) {\r\n const next = rekeyPath(key, oldPath, newPath)\r\n if (next === null) continue\r\n nodeCards.delete(key)\r\n nodeCards.set(next, ui)\r\n }\r\n if (state.selected) state.selected = rekeyPath(state.selected, oldPath, newPath) ?? state.selected\r\n}\r\n\r\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\r\n\r\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\r\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\r\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\r\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\r\n// Model constructor itself, so it is live even in edit mode.\r\n\r\nconst nodeCards = new Map<string, InspectorUI>()\r\n\r\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\r\n const node = state.nodes[name]\r\n if (!(node instanceof Model)) return null\r\n const clips = node.anim.clips\r\n if (clips.length === 0) return null\r\n let ui = nodeCards.get(name)\r\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\r\n return ui._run((u) => {\r\n u.header(\"Animation\")\r\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\r\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\r\n const loop = u.switch(\"loop\", { value: true })\r\n const playing = node.anim.busy || node.anim.loop !== undefined\r\n if (playing) node.anim.speed = speed\r\n if (u.button(playing ? \"Restart\" : \"Play\")) {\r\n node.anim.speed = speed\r\n if (loop) node.anim.playLoop(clip)\r\n else node.anim.play(clip, { restart: true })\r\n }\r\n if (u.button(\"Stop\")) node.anim.stop() // fades to the rest pose\r\n const duration = clips.find((c) => c.name === clip)?.duration\r\n u.info(playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\r\n }, event)\r\n}\r\n\r\n/** The def path a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\r\nconst ownerPath = (node: Node | null): string | null => {\r\n let cur: Node | null = node\r\n for (let i = 0; cur && i < 32; i++) {\r\n const path = state.paths.get(cur)\r\n if (path) return path\r\n cur = cur.parent\r\n }\r\n return null\r\n}\r\n\r\nconst applySelection = (name: string | null, notify: boolean): void => {\r\n state.selected = name\r\n if (notify) controller.callbacks.onSelect?.(name)\r\n}\r\n\r\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\r\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\r\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\r\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\r\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\r\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\r\n\r\nconst isInSubtree = (node: Node, root: Node): boolean => {\r\n let cur: Node | null = node\r\n for (let i = 0; cur && i < 64; i++) {\r\n if (cur === root) return true\r\n cur = cur.parent\r\n }\r\n return false\r\n}\r\n\r\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\r\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\r\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\r\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\r\n const scene = state.scene\r\n if (!scene) return null\r\n const ray = scene.camera.getRay(x, y)\r\n if (Physics.supported) {\r\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\r\n let remaining = 2000\r\n for (let i = 0; i < 8 && remaining > 0; i++) {\r\n const hit = Physics.raycast(origin, ray.dir, remaining)\r\n if (!hit) break\r\n // (empties/cameras have no geometry — their markers are overlay gizmos, never hit here)\r\n if (hit.node && !isEditorNode(hit.node)\r\n && !(exclude && isInSubtree(hit.node, exclude))) {\r\n return {\r\n point: [ hit.point.x, hit.point.y, hit.point.z ],\r\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\r\n node: hit.node ? ownerPath(hit.node) : null,\r\n }\r\n }\r\n // an excluded/editor body — resume the cast just past it\r\n const step = remaining * hit.fraction + 0.01\r\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\r\n remaining -= step\r\n }\r\n }\r\n const t = -ray.origin.y / ray.dir.y\r\n if (!Number.isFinite(t) || t <= 0) return null\r\n const p = ray.getPoint(t)\r\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\r\n}\r\n\r\nconst editorApi = {\r\n get selection(): string | null { return state.selected },\r\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\r\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\r\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\r\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\r\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\r\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\r\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\r\n raycast: raycastViewport,\r\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\r\n}\r\n\r\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\r\nconst windowUIs = new Map<number, InspectorUI>()\r\n\r\n/** A node's subtree AABB in WORLD space (the engine's local box through the node's own world\r\n * matrix), null while degenerate / unsupported. */\r\nconst worldBounds = (node: Node): { min: Vec3Tuple, max: Vec3Tuple } | null => {\r\n const box = _creator.computeBoundingBox?.(node.id)\r\n if (!box) return null\r\n if (!(box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) return null\r\n // world AABB from the 8 local-space corners (the local box doesn't include the node's own\r\n // transform — worldMatrix carries it)\r\n const m = node.worldMatrix\r\n const min: Vec3Tuple = [ Infinity, Infinity, Infinity ]\r\n const max: Vec3Tuple = [ -Infinity, -Infinity, -Infinity ]\r\n for (let i = 0; i < 8; i++) {\r\n const p = m.transformPoint([ box[i & 1 ? 3 : 0], box[i & 2 ? 4 : 1], box[i & 4 ? 5 : 2] ])\r\n const c = [ p.x, p.y, p.z ]\r\n for (let k = 0; k < 3; k++) {\r\n if (c[k] < min[k]) min[k] = c[k]\r\n if (c[k] > max[k]) max[k] = c[k]\r\n }\r\n }\r\n return { min, max }\r\n}\r\n\r\n/** Orbit onto a world AABB: target = center, distance from the largest extent. */\r\nconst frameBounds = (min: Vec3Tuple, max: Vec3Tuple) => {\r\n const size = Math.max(max[0] - min[0], max[1] - min[1], max[2] - min[2])\r\n state.orbit?.focus(\r\n [ (min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2 ],\r\n Math.max(1, size * 1.4),\r\n )\r\n}\r\n\r\nconst controller = {\r\n callbacks: {} as HarnessCallbacks,\r\n\r\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node PATHS\r\n * (build-completion order, not file order — the host sorts for display). */\r\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\r\n // synchronously, before any await: the host sets the registry for THIS world right around\r\n // the call (a second bundle run in the page — the preview host — writes the same global)\r\n capturedMaterials = ((globalThis as any).__lecodesMaterials as Record<string, MaterialHandle> | undefined) ?? null\r\n const { scene, nodes } = await handle.load()\r\n state.handle = handle\r\n state.scene = scene\r\n state.nodes = nodes\r\n state.paths = new Map(Object.entries(nodes).map(([ path, node ]) => [ node, path ]))\r\n\r\n scene.open()\r\n\r\n state.orbit = new OrbitCamera(scene.camera)\r\n\r\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\r\n scene.addEventListener(\"touchstart\", (ev) => {\r\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\r\n })\r\n\r\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\r\n // is host-driven from the tree, so a click outside a tool does nothing)\r\n scene.addEventListener(\"click\", (ev) => {\r\n if (!state.activeTool) return\r\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\r\n if (!tool?.hooks.onViewportClick) return\r\n const hit = raycastViewport(ev.clientX, ev.clientY)\r\n if (hit) {\r\n // a throwing tool logs and skips — it can't take the editor down; Gizmos calls inside\r\n // the hook land in the tool's own buffer (cleared per call)\r\n try { toolGizmosVersion++; __withGizmoScope(toolGizmos, () => tool.hooks.onViewportClick!(hit, editorApi)) }\r\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\r\n }\r\n })\r\n\r\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\r\n // keys resolve before the host asks for the rows\r\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\r\n\r\n return { nodes: Object.keys(nodes) }\r\n },\r\n\r\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\r\n select(name: string | null): void {\r\n applySelection(name && resolveKey(name) ? name : null, false)\r\n },\r\n\r\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\r\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\r\n entityOf(name: string): number {\r\n return resolveKey(name)?.id ?? 0\r\n },\r\n\r\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\r\n * picking. Walks the hit entity up to the NEAREST tracked def node (its absolute path — with\r\n * nested def nodes the deepest one wins); a hit inside the CURRENTLY SELECTED model resolves\r\n * to its deepest part key instead (click-again drill-down). Locked nodes yield null (the\r\n * click selects nothing — the host keeps the current selection). */\r\n pickName(entityId: number): string | null {\r\n if (!entityId) return null\r\n const nodeIds = new Map<number, string>()\r\n for (const [ path, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, path)\r\n const partIds = new Map<number, string>()\r\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\r\n let part: string | null = null\r\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\r\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\r\n const path = nodeIds.get(id)\r\n if (path !== undefined) {\r\n const node = state.nodes[path]\r\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\r\n const inSelected = state.selected === path || state.selected?.startsWith(path + PART_SEP)\r\n return part && inSelected ? part : path\r\n }\r\n id = _creator.getParent(id)\r\n }\r\n return null\r\n },\r\n\r\n /** The selection key an entity belongs to — its deepest enumerated GLB part, else the owning\r\n * def node's path — with no lock / drill-down policy (drop targets: a material tile lands on\r\n * whatever is under the cursor). Null for editor-only / untracked entities. */\r\n hitKey(entityId: number): string | null {\r\n if (!entityId) return null\r\n const nodeIds = new Map<number, string>()\r\n for (const [ path, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, path)\r\n const partIds = new Map<number, string>()\r\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\r\n let part: string | null = null\r\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\r\n part ??= partIds.get(id) ?? null\r\n const path = nodeIds.get(id)\r\n if (path !== undefined) return part ?? path\r\n id = _creator.getParent(id)\r\n }\r\n return null\r\n },\r\n\r\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\r\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\r\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\r\n * by hand, the engines' setter conventions differ). */\r\n applyPose(\r\n name: string,\r\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\r\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\r\n const node = resolveKey(name)\r\n if (!node) return null\r\n node.position = pose.position\r\n node.quaternion = pose.quaternion\r\n node.scale = pose.scale\r\n notifyEditorChanged(name)\r\n const p = node.position, e = node.eulerAngles, s = node.scale\r\n return {\r\n position: [ p.x, p.y, p.z ],\r\n eulerAngles: [ e.x, e.y, e.z ],\r\n scale: [ s.x, s.y, s.z ],\r\n }\r\n },\r\n\r\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\r\n * (re-)binds the part keys, so call it after anything that reloads the model. */\r\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\r\n return refreshModelParts(name)\r\n },\r\n\r\n /** The camera PREVIEW source for a node carrying a `CameraPlace` (edit mode registers the\r\n * generator instance on the node): its entity + lens. Null when the node is unknown or has no\r\n * place. (`hide` stays for older engine builds — markers are overlay gizmos now, not scene\r\n * entities, and the inset skips overlay roots by itself.) */\r\n cameraPreview(name: string): { entityId: number, fov: number, near: number, far: number, hide: number[] } | null {\r\n const node = resolveKey(name)\r\n if (!node) return null\r\n const place = (node as Node & { cameraPlace?: { fov: number, near: number, far: number } }).cameraPlace\r\n ?? (node.get(CameraPlace) as { fov: number, near: number, far: number } | undefined)\r\n if (!place) return null\r\n return { entityId: node.id, fov: place.fov, near: place.near, far: place.far, hide: [] }\r\n },\r\n\r\n /** Clip names of a loaded model node (the inspector's preview-pose dropdown); [] otherwise. */\r\n modelClips(name: string): string[] {\r\n const node = resolveKey(name) as (Node & { anim?: { clips: readonly { name: string }[] } }) | null\r\n return node?.anim ? node.anim.clips.map((c) => c.name) : []\r\n },\r\n\r\n /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\r\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\r\n const node = resolveKey(name)\r\n if (!node) return null\r\n const p = node.position, e = node.eulerAngles, s = node.scale\r\n return {\r\n position: [ p.x, p.y, p.z ],\r\n eulerAngles: [ e.x, e.y, e.z ],\r\n scale: [ s.x, s.y, s.z ],\r\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\r\n }\r\n },\r\n\r\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\r\n * anything else is a structural change (the host recompiles the scene bundle instead). */\r\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\" | \"editor\", value: unknown): void {\r\n const node = resolveKey(name)\r\n if (!node) return\r\n if (key === \"editor\") {\r\n // the preview pose (`editor: { clip, time }`) — edit-mode only, never a runtime default\r\n __applyEditorPose(node, (value ?? undefined) as { clip?: string, time?: number } | undefined)\r\n return\r\n }\r\n if (key === \"position\") node.position = value as Vec3Tuple\r\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\r\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\r\n else if (key === \"visible\") node.visible = value === true\r\n else if (key === \"locked\") {\r\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\r\n // consults it; fields edit regardless)\r\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\r\n return\r\n }\r\n notifyEditorChanged(name)\r\n },\r\n\r\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\r\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\r\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\r\n return state.handle?._editorSetProp?.(name, index, key, value) === true\r\n },\r\n\r\n /** Every editor gizmo line currently drawn (generator rebuilds + the active tool) as world-space\r\n * LINES batches, with a version that changes whenever any of them re-ran — the host pushes to\r\n * the engine overlay only when it moves. Empty on bundles predating gizmos. */\r\n gizmos(): { version: number, batches: { color: string, alpha: number, segments: number[] }[] } {\r\n const runs = state.handle?._editorGizmos?.() ?? { version: 0, batches: [] }\r\n const batches = runs.batches.slice()\r\n for (const b of toolGizmos.batches) if (b.segments.length > 0) batches.push(b)\r\n return { version: runs.version * 1000003 + toolGizmosVersion, batches }\r\n },\r\n\r\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\r\n * (the factory is already in the bundle). False = not a make node / older SDK. */\r\n setMakeArg(name: string, key: string, value: unknown): boolean {\r\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\r\n },\r\n\r\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\r\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\r\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\r\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\r\n * Null = no card — the host falls back to plain fields. */\r\n inspector(\r\n target: { name: string, aspect?: number },\r\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\r\n ): unknown[] | null {\r\n if (typeof target.aspect === \"number\") {\r\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\r\n }\r\n return renderNodeCard(target.name, opts?.event)\r\n },\r\n\r\n /** Live parameter write on a mesh node's material, a GLB part's slot material, or a material\r\n * asset (every user follows). `value` is plain: number / bool / `#colour` / texture URL /\r\n * vector; null clears a texture. False = no live material there (the host re-runs/patches). */\r\n setMaterialParam(target: MaterialTarget, name: string, value: unknown): boolean {\r\n if (\"asset\" in target) {\r\n const h = materialHandles()[target.asset]\r\n if (!h) return false\r\n h.setParam(name, value as never)\r\n return true\r\n }\r\n const m = materialAt(target)\r\n if (!m) return false\r\n pushMaterialParam(m, name, value)\r\n return true\r\n },\r\n\r\n /** Replace a material asset's whole definition (a shader / kind switch): the shared instance\r\n * rebuilds and re-assigns to every node using it. False = unknown asset. */\r\n async patchMaterial(assetPath: string, def: Record<string, unknown>): Promise<boolean> {\r\n const h = materialHandles()[assetPath]\r\n if (!h) return false\r\n await h.reset(def as never)\r\n return true\r\n },\r\n\r\n /** Assign (def) or clear (null) the material of one GLB-part slot live. Clearing restores the\r\n * glTF material captured on the first live override; a slot never overridden live has nothing\r\n * to restore → false (the host re-runs, which rebuilds the model from its def). */\r\n async setPartMaterial(partKey: string, slot: number, def: Record<string, unknown> | null): Promise<boolean> {\r\n const node = resolveKey(partKey)\r\n if (!node) return false\r\n const key = `${partKey}#${slot}`\r\n if (def === null) {\r\n const orig = originalSlotMaterials.get(key)\r\n if (orig === undefined) return false\r\n _creator.setMaterial(node.id, orig, slot)\r\n const cache = (node as unknown as { _materials?: (Material | undefined)[] })._materials\r\n if (cache) cache[slot] = undefined\r\n return true\r\n }\r\n const lowered = lowerMaterial(def)\r\n if (lowered === null) return false\r\n if (!originalSlotMaterials.has(key)) originalSlotMaterials.set(key, _creator.getMaterial(node.id, slot))\r\n await __assignMaterialDef(node, slot, lowered as never)\r\n return true\r\n },\r\n\r\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE node at `path`\r\n * from plain def data — the SDK re-instantiates it through the same defineScene builder, so\r\n * nothing recompiles. The path carries the mount point. The HOST guarantees the def is plain\r\n * data (it bails to a re-run on `$expr` and `$asset` values). False = the SDK can't patch. */\r\n async patchNode(path: string, def: Record<string, unknown> | null): Promise<boolean> {\r\n const handle = state.handle\r\n if (!handle?._patchNode) return false\r\n // material asset refs → registered handles (an unknown one means the bundle predates the\r\n // asset — only a compile picks it up)\r\n const lowered = def === null ? null : lowerNodeDef(def)\r\n if (def !== null && lowered === null) return false\r\n const node = await handle._patchNode(path, lowered)\r\n state.paths = new Map(Object.entries(state.nodes).map(([ p, nd ]) => [ nd, p ]))\r\n notifyEditorChanged(path) // generators referencing the patched node follow it\r\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\r\n await refreshModelParts(path)\r\n const sel = state.selected\r\n if (sel === path || sel?.startsWith(path + \"/\") || sel?.startsWith(path + PART_SEP)) {\r\n const alive = sel === path ? node : (sel ? resolveKey(sel) : null)\r\n if (!alive) state.selected = null\r\n }\r\n return true\r\n },\r\n\r\n /** Live rename (bare sibling segment). The SDK owns the shared node record and re-keys it —\r\n * subtree, editor runs, and the engine-side display name included; the harness re-keys only\r\n * its OWN state (reverse path map, part keys, node-card cache, selection). The document is\r\n * the source of truth. */\r\n renameNode(path: string, newName: string): boolean {\r\n const newPath = state.handle?._renameNode?.(path, newName) ?? null\r\n if (newPath === null) return false\r\n rekeyLocal(path, newPath)\r\n return true\r\n },\r\n\r\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\r\n * (a node under a transformed parent shifts in world space). `newParentPath` null = root.\r\n * The node's (and its subtree's) PATHS change with the move — same re-key story as rename. */\r\n reparentNode(path: string, newParentPath: string | null): boolean {\r\n const newPath = state.handle?._reparentNode?.(path, newParentPath) ?? null\r\n if (newPath === null) return false\r\n rekeyLocal(path, newPath)\r\n return true\r\n },\r\n\r\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\r\n * back to a re-run for everything else. */\r\n setSceneProp(key: string, value: unknown): boolean {\r\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\r\n state.scene.skybox = value\r\n return true\r\n }\r\n return false\r\n },\r\n\r\n zoom(delta: number): void { state.orbit?.zoom(delta) },\r\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\r\n\r\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\r\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\r\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\r\n return raycastViewport(x, y)\r\n },\r\n\r\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\r\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\r\n aspectSchemas(): unknown[] {\r\n return state.handle?._describeAspects?.() ?? []\r\n },\r\n\r\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\r\n editorWindows(): { title: string }[] {\r\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\r\n },\r\n\r\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\r\n * cards, except every field key is transient editor state (windows have no doc entry). */\r\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\r\n const win = __editorPlugins.windows[index]\r\n if (!win) return null\r\n let ui = windowUIs.get(index)\r\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\r\n return ui._run((u) => win.render(u, editorApi), event)\r\n },\r\n\r\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\r\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\r\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\r\n },\r\n\r\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\r\n * tool's hooks; selection itself is unaffected. */\r\n setActiveTool(name: string | null): void {\r\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\r\n toolGizmos.clear()\r\n toolGizmosVersion++\r\n },\r\n\r\n /** Install the host's document operations — the write half of the plugin `editor` API. */\r\n setEditorOps(ops: EditorOps): void {\r\n state.editorOps = ops\r\n },\r\n\r\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\r\n * live euler-semantics probe against the running engine (the setter convention differs across\r\n * shipped engines — see setFromView). */\r\n _debug(): unknown {\r\n const probe = new Node()\r\n probe.eulerAngles = [ 0, 0, 90 ]\r\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\r\n const probe2 = new Node()\r\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\r\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\r\n // world poses of every def node + any node named in `names` under them — a numeric probe\r\n const poses: Record<string, number[]> = {}\r\n const visit = (n: Node, key: string, depth: number): void => {\r\n const m = n.worldMatrix\r\n const pos = m.position, z = m.basisZ, y = m.basisY\r\n poses[key] = [ pos.x, pos.y, pos.z, z.x, z.y, z.z, y.x, y.y, y.z ].map((v) => Math.round(v * 1000) / 1000)\r\n if (depth > 6) return\r\n for (const c of n.children) if (c.name && !isEditorNode(c)) visit(c, `${key}/${c.name}`, depth + 1)\r\n }\r\n for (const [ path, n ] of Object.entries(state.nodes)) if (!path.includes(\"/\")) visit(n, path, 0)\r\n return {\r\n poses,\r\n selected: state.selected, // def PATH (\"city/in1/pt1\") OR `<defPath>::<partPath>` part key\r\n activeTool: state.activeTool,\r\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\r\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\r\n }\r\n },\r\n\r\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\r\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\r\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\r\n * should write to the file (never compose eulers by hand: the shipped engines apply the\r\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\r\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\r\n const node = resolveKey(name)\r\n const cam = state.scene?.camera\r\n if (!node || !cam) return null\r\n const parent = node.parent\r\n const camPos = cam.worldPosition\r\n node.quaternion = parent\r\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\r\n : cam.worldQuaternion\r\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\r\n notifyEditorChanged(name)\r\n const p = node.position, e = node.eulerAngles\r\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\r\n },\r\n\r\n /** Frame the named node or part (or the selection). */\r\n focus(name?: string): void {\r\n const node = resolveKey(name ?? state.selected ?? \"\")\r\n if (!node) return\r\n const p = node.worldPosition\r\n state.orbit?.focus([ p.x, p.y, p.z ])\r\n },\r\n\r\n /** \"Fit to mesh\": dimensions + origin that make `kind` hug what the node actually renders, as\r\n * plain numbers for the host to write into the file (nothing is applied here — aspects are\r\n * inert in edit mode, and the collider overlay redraws from the document). Null while the\r\n * subtree measures as nothing: an empty node, or a model still loading — the host can retry.\r\n *\r\n * The measuring is `Shape.fitBounds` from the SDK, the same code `shape.fit()` runs at runtime.\r\n * Both inputs come from the ENGINE rather than from the node object: the node belongs to the\r\n * user's bundle, whose accessors the bundler may have shaken out (see the `_creator` note). */\r\n fitShape(name: string, kind?: ShapeKindName): unknown {\r\n const node = resolveKey(name)\r\n const box = node ? _creator.computeBoundingBox?.(node.id) : undefined\r\n if (!node || !box) return null\r\n if (!(box[3] - box[0] > 1e-6 || box[4] - box[1] > 1e-6 || box[5] - box[2] > 1e-6)) return null\r\n const m = new Float32Array(16)\r\n _creator.getWorldMatrix(node.id, m)\r\n const scale: Vec3Tuple = [\r\n Math.hypot(m[0], m[1], m[2]) || 1,\r\n Math.hypot(m[4], m[5], m[6]) || 1,\r\n Math.hypot(m[8], m[9], m[10]) || 1,\r\n ]\r\n const bounds = { min: [ box[0], box[1], box[2] ] as Vec3Tuple, max: [ box[3], box[4], box[5] ] as Vec3Tuple }\r\n return Shape.fitBounds(bounds, scale, kind ?? \"box\")\r\n },\r\n\r\n /** Frame the named node from its subtree AABB: orbit target = world-space center, distance from\r\n * the extent (the host's preview captures + a future \"frame selection\"). False while the AABB is\r\n * degenerate — the engine answers zeros for empty/not-yet-loaded subtrees, so the host can poll\r\n * this as its \"model loaded\" signal — or without engine bounds support (filament). */\r\n frameNode(name: string): boolean {\r\n const node = resolveKey(name)\r\n const bounds = node ? worldBounds(node) : null\r\n if (!bounds) return false\r\n frameBounds(bounds.min, bounds.max)\r\n return true\r\n },\r\n\r\n /** Frame the whole scene — the union AABB of every top-level node (a prefab file's preview\r\n * capture). False while nothing has real bounds yet (same polling contract as `frameNode`). */\r\n frameAll(): boolean {\r\n const min: Vec3Tuple = [ Infinity, Infinity, Infinity ]\r\n const max: Vec3Tuple = [ -Infinity, -Infinity, -Infinity ]\r\n let any = false\r\n for (const [ path, node ] of Object.entries(state.nodes)) {\r\n if (path.includes(\"/\")) continue\r\n const b = worldBounds(node)\r\n if (!b) continue\r\n any = true\r\n for (let k = 0; k < 3; k++) {\r\n if (b.min[k] < min[k]) min[k] = b.min[k]\r\n if (b.max[k] > max[k]) max[k] = b.max[k]\r\n }\r\n }\r\n if (!any) return false\r\n frameBounds(min, max)\r\n return true\r\n },\r\n}\r\n\r\n;(globalThis as any).__lecodesSceneHarness = controller\r\n","math.ts":"// Pure orbit-camera math — plain number tuples in/out, no SDK dependencies, so it unit-tests\n// directly (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n// (The gizmo math that used to live beside it moved into the lite ENGINE's editor layer,\n// viewer-lite/src/gl/editor.ts, together with the gizmo itself.)\n\nexport type V3 = readonly [number, number, number]\n\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\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"}
|
|
@@ -2,25 +2,31 @@
|
|
|
2
2
|
// (type-erased) generic — `class Health extends Aspect<'health', Sprite> {…}` — so neither the
|
|
3
3
|
// bundler nor the runtime can see it after types are stripped. This pass injects the matching
|
|
4
4
|
// `static aspect = "health"` so node.aspect()/node.health work at runtime, exactly like the SDK's
|
|
5
|
-
// built-in aspects declare it by hand.
|
|
5
|
+
// built-in aspects declare it by hand. Systems (`class Hud extends System<'hud'>`) are the same
|
|
6
|
+
// thing on a scene and get the same injection.
|
|
6
7
|
//
|
|
7
8
|
// Runs on PROJECT files only (the SDK's own aspects already declare `static aspect`). Idempotent:
|
|
8
9
|
// skips a class that already declares one. String-based, like assetMacro — no parser dependency.
|
|
10
|
+
// The generic tail is walked with a bracket counter (not a regex) because the third parameter is
|
|
11
|
+
// an event map — an object type with braces and `=>` arrows: `Aspect<'barrel', Node, { explode:
|
|
12
|
+
// (b: Barrel) => void }>` — and `> implements Hittable {` may follow the closing bracket.
|
|
9
13
|
|
|
10
|
-
// `class X extends Aspect<'name'
|
|
11
|
-
const
|
|
14
|
+
// `class X extends Aspect<'name'` / `extends System<'name'` — up to and including the name literal.
|
|
15
|
+
const HEAD_RE = /\bclass\s+\w+\s+extends\s+(?:Aspect|System)\s*<\s*(["'])((?:[^"'\\]|\\.)*)\1/g
|
|
12
16
|
|
|
13
17
|
/** Cheap pre-check so we don't scan files that define no aspects. */
|
|
14
|
-
export const hasAspectExtends = (src: string): boolean => /\bextends\s+Aspect\s*</.test(src)
|
|
18
|
+
export const hasAspectExtends = (src: string): boolean => /\bextends\s+(?:Aspect|System)\s*</.test(src)
|
|
15
19
|
|
|
16
|
-
/** Inject `static aspect = "<name>"` into each `class … extends Aspect<'<name>',
|
|
20
|
+
/** Inject `static aspect = "<name>"` into each `class … extends Aspect<'<name>', …>` (or System). */
|
|
17
21
|
export const injectAspectNames = (src: string): string => {
|
|
18
22
|
let out = ""
|
|
19
23
|
let last = 0
|
|
20
|
-
|
|
24
|
+
HEAD_RE.lastIndex = 0
|
|
21
25
|
let m: RegExpExecArray | null
|
|
22
|
-
while ((m =
|
|
23
|
-
const braceEnd = m.index + m[0].length
|
|
26
|
+
while ((m = HEAD_RE.exec(src)) !== null) {
|
|
27
|
+
const braceEnd = classBodyStart(src, m.index + m[0].length)
|
|
28
|
+
if (braceEnd < 0) continue
|
|
29
|
+
HEAD_RE.lastIndex = braceEnd
|
|
24
30
|
if (!classBodyDeclaresAspect(src, braceEnd)) {
|
|
25
31
|
out += src.slice(last, braceEnd) + ` static aspect = ${JSON.stringify(m[2])};`
|
|
26
32
|
last = braceEnd
|
|
@@ -29,6 +35,44 @@ export const injectAspectNames = (src: string): string => {
|
|
|
29
35
|
return last === 0 ? src : out + src.slice(last)
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/** From just past the name literal (inside the `<…>`), walk to the end of the type-argument list,
|
|
39
|
+
* then past an optional `implements …` clause, and return the index just past the class body's
|
|
40
|
+
* opening `{` — or -1 if the source doesn't look like a class header. */
|
|
41
|
+
const classBodyStart = (src: string, from: number): number => {
|
|
42
|
+
let angle = 1 // we are inside the `<` that HEAD_RE opened
|
|
43
|
+
let i = from
|
|
44
|
+
for (; i < src.length && angle > 0; i++) {
|
|
45
|
+
const c = src[i]
|
|
46
|
+
if (c === '"' || c === "'" || c === "`") { i = skipString(src, i) - 1; continue }
|
|
47
|
+
if (c === "=" && src[i + 1] === ">") { i++; continue } // an arrow, not a closing bracket
|
|
48
|
+
if (c === "<") angle++
|
|
49
|
+
else if (c === ">") angle--
|
|
50
|
+
}
|
|
51
|
+
if (angle !== 0) return -1
|
|
52
|
+
// `>` closed the generic; the class body's `{` follows, possibly after `implements A, B<C>`.
|
|
53
|
+
// Scan to the first `{` at angle depth 0.
|
|
54
|
+
angle = 0
|
|
55
|
+
for (; i < src.length; i++) {
|
|
56
|
+
const c = src[i]
|
|
57
|
+
if (c === "<") angle++
|
|
58
|
+
else if (c === ">") angle--
|
|
59
|
+
else if (c === "{" && angle === 0) return i + 1
|
|
60
|
+
else if (c === ";" || c === "}") return -1
|
|
61
|
+
}
|
|
62
|
+
return -1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Index just past a string literal starting at `at` (its opening quote). */
|
|
66
|
+
const skipString = (src: string, at: number): number => {
|
|
67
|
+
const q = src[at]
|
|
68
|
+
let i = at + 1
|
|
69
|
+
for (; i < src.length; i++) {
|
|
70
|
+
if (src[i] === "\\") { i++; continue }
|
|
71
|
+
if (src[i] === q) return i + 1
|
|
72
|
+
}
|
|
73
|
+
return i
|
|
74
|
+
}
|
|
75
|
+
|
|
32
76
|
/** Does the class body starting just after `from` (its `{`) already declare a `static aspect`? */
|
|
33
77
|
const classBodyDeclaresAspect = (src: string, from: number): boolean => {
|
|
34
78
|
let depth = 1
|
|
@@ -6,32 +6,133 @@
|
|
|
6
6
|
// literal (handling escaped chars inside the literal). The lookbehind excludes property access —
|
|
7
7
|
// a METHOD named asset (`ui.asset("model")`, InspectorUI) is not the macro.
|
|
8
8
|
const ASSET_RE = /(?<![.\w$])asset\(\s*(["'])((?:[^"'\\]|\\.)*)\1\s*\)/g
|
|
9
|
+
const ASSET_CALL_RE = /(?<![.\w$])asset\(/g
|
|
9
10
|
|
|
10
11
|
/** Cheap pre-check so we don't run the regex over every file. */
|
|
11
12
|
export const hasAssetMacro = (src: string): boolean => src.includes("asset(")
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* Blanks out comments and string/template-literal TEXT (spaces, newlines kept) so the macro scan
|
|
16
|
+
* only ever fires on real code. Offsets and line numbers are preserved 1:1 with the input, so a
|
|
17
|
+
* match position in the mask indexes the original source. `${…}` interpolations stay live — an
|
|
18
|
+
* `asset()` inside one is a genuine call.
|
|
19
|
+
*
|
|
20
|
+
* Not a full lexer: a regex LITERAL containing `asset(` would still be seen as code. That case
|
|
21
|
+
* doesn't occur in practice and behaves exactly as it did before this masking existed.
|
|
16
22
|
*/
|
|
17
|
-
|
|
18
|
-
const
|
|
23
|
+
const maskNonCode = (src: string): string => {
|
|
24
|
+
const out = src.split("")
|
|
25
|
+
const blank = (from: number, to: number) => {
|
|
26
|
+
for (let k = from; k < to && k < out.length; k++) if (out[k] !== "\n") out[k] = " "
|
|
27
|
+
}
|
|
28
|
+
const tpl: number[] = [] // brace depth outside each open `${`
|
|
29
|
+
let braces = 0
|
|
19
30
|
let i = 0
|
|
20
31
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
32
|
+
// Blank a template's raw text from `i` up to `${` (which resumes code mode) or the closing tick.
|
|
33
|
+
const scanTemplate = () => {
|
|
34
|
+
const start = i
|
|
35
|
+
while (i < src.length) {
|
|
36
|
+
const c = src[i]
|
|
37
|
+
if (c === "\\") { i += 2; continue }
|
|
38
|
+
if (c === "`") { blank(start, i); i++; return }
|
|
39
|
+
if (c === "$" && src[i + 1] === "{") { blank(start, i); tpl.push(braces); i += 2; return }
|
|
40
|
+
i++
|
|
41
|
+
}
|
|
42
|
+
blank(start, i)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
while (i < src.length) {
|
|
46
|
+
const c = src[i], d = src[i + 1]
|
|
47
|
+
if (c === "/" && d === "/") {
|
|
48
|
+
const e = src.indexOf("\n", i)
|
|
49
|
+
const stop = e === -1 ? src.length : e
|
|
50
|
+
blank(i, stop); i = stop; continue
|
|
51
|
+
}
|
|
52
|
+
if (c === "/" && d === "*") {
|
|
53
|
+
const e = src.indexOf("*/", i + 2)
|
|
54
|
+
const stop = e === -1 ? src.length : e + 2
|
|
55
|
+
blank(i, stop); i = stop; continue
|
|
56
|
+
}
|
|
57
|
+
if (c === "'" || c === "\"") {
|
|
58
|
+
const start = i
|
|
59
|
+
i++
|
|
60
|
+
while (i < src.length) {
|
|
61
|
+
const q = src[i]
|
|
62
|
+
if (q === "\\") { i += 2; continue }
|
|
63
|
+
i++
|
|
64
|
+
if (q === c || q === "\n") break
|
|
65
|
+
}
|
|
66
|
+
blank(start, i); continue
|
|
67
|
+
}
|
|
68
|
+
if (c === "`") { i++; scanTemplate(); continue }
|
|
69
|
+
if (c === "{") { braces++; i++; continue }
|
|
70
|
+
if (c === "}") {
|
|
71
|
+
if (tpl.length > 0 && braces === tpl[tpl.length - 1]) { tpl.pop(); i++; scanTemplate(); continue }
|
|
72
|
+
if (braces > 0) braces--
|
|
73
|
+
i++; continue
|
|
26
74
|
}
|
|
27
|
-
|
|
28
|
-
}
|
|
75
|
+
i++
|
|
76
|
+
}
|
|
77
|
+
return out.join("")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One `asset('…')` call site: the path as written, plus where it sits in the file. */
|
|
81
|
+
export type AssetMacroRef = { path: string, index: number, length: number, line: number }
|
|
29
82
|
|
|
30
|
-
|
|
31
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Every `asset('…')` macro call in `src`, skipping the ones inside comments and string literals
|
|
85
|
+
* (a commented-out call must not pull in an asset — nor be reported missing). Throws when a call
|
|
86
|
+
* has a dynamic argument, which the macro can't resolve at compile time.
|
|
87
|
+
*/
|
|
88
|
+
export const collectAssetMacros = (src: string, filePath: string): AssetMacroRef[] => {
|
|
89
|
+
const mask = maskNonCode(src)
|
|
90
|
+
const refs: AssetMacroRef[] = []
|
|
91
|
+
const literal = new Set<number>()
|
|
92
|
+
// Matches arrive in source order, so the line counter walks the file once.
|
|
93
|
+
let scanned = 0
|
|
94
|
+
let line = 1
|
|
95
|
+
for (const m of src.matchAll(ASSET_RE)) {
|
|
96
|
+
const index = m.index ?? 0
|
|
97
|
+
if (mask[index] !== "a") continue // inside a comment or a string — not code
|
|
98
|
+
for (; scanned < index; scanned++) if (src[scanned] === "\n") line++
|
|
99
|
+
refs.push({ path: m[2], index, length: m[0].length, line })
|
|
100
|
+
literal.add(index)
|
|
101
|
+
}
|
|
102
|
+
// Both patterns match at the `a` of `asset`, so any live call site the string-literal form
|
|
103
|
+
// didn't claim was called with something the compiler can't read.
|
|
104
|
+
for (const m of src.matchAll(ASSET_CALL_RE)) {
|
|
105
|
+
const index = m.index ?? 0
|
|
106
|
+
if (mask[index] === "a" && !literal.has(index)) {
|
|
107
|
+
throw new Error(`asset() expects a string literal, found a dynamic argument in: ${filePath}`)
|
|
108
|
+
}
|
|
32
109
|
}
|
|
110
|
+
return refs
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Desugars `asset('./path.png')` into the exact import the user could have written by hand.
|
|
115
|
+
* Identical paths within a file map to one identifier. Only string-literal arguments are supported.
|
|
116
|
+
* `found` is this file's `collectAssetMacros` result when the caller already has it (the bundler
|
|
117
|
+
* collects them to check the files exist first) — otherwise it's scanned here.
|
|
118
|
+
*/
|
|
119
|
+
export const desugarAssetMacros = (src: string, filePath: string, found?: AssetMacroRef[]): string => {
|
|
120
|
+
const refs = found ?? collectAssetMacros(src, filePath)
|
|
121
|
+
if (refs.length === 0) return src
|
|
33
122
|
|
|
34
|
-
|
|
123
|
+
const byPath = new Map<string, string>()
|
|
124
|
+
const parts: string[] = []
|
|
125
|
+
let at = 0
|
|
126
|
+
for (const ref of refs) {
|
|
127
|
+
let name = byPath.get(ref.path)
|
|
128
|
+
if (!name) {
|
|
129
|
+
name = `__asset_${byPath.size}`
|
|
130
|
+
byPath.set(ref.path, name)
|
|
131
|
+
}
|
|
132
|
+
parts.push(src.slice(at, ref.index), name)
|
|
133
|
+
at = ref.index + ref.length
|
|
134
|
+
}
|
|
135
|
+
parts.push(src.slice(at))
|
|
35
136
|
|
|
36
137
|
const imports = [ ...byPath ]
|
|
37
138
|
.map(([ path, name ]) => `import ${name} from ${JSON.stringify(path)};`)
|
|
@@ -41,5 +142,5 @@ export const desugarAssetMacros = (src: string, filePath: string): string => {
|
|
|
41
142
|
// but lines added at the TOP would shift every original line, and the bundle's source map would
|
|
42
143
|
// then disagree with the file the editor shows: inspector jumps and error-overlay stack frames
|
|
43
144
|
// land N lines off in exactly (and only) the files that use asset(). Appending adds no offsets.
|
|
44
|
-
return
|
|
145
|
+
return parts.join("") + "\n" + imports
|
|
45
146
|
}
|
|
@@ -3,7 +3,7 @@ import { spawnSync } from "node:child_process"
|
|
|
3
3
|
import { createRequire } from "node:module"
|
|
4
4
|
import { dirname, join, relative } from "node:path"
|
|
5
5
|
import { fileURLToPath } from "node:url"
|
|
6
|
-
import { desugarAssetMacros, hasAssetMacro } from "./assetMacro"
|
|
6
|
+
import { collectAssetMacros, desugarAssetMacros, hasAssetMacro, type AssetMacroRef } from "./assetMacro"
|
|
7
7
|
import type { ScanModule } from "./detectEntry"
|
|
8
8
|
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
|
|
9
9
|
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
|
|
@@ -131,6 +131,30 @@ export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule
|
|
|
131
131
|
}
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Every `asset('…')` must name a file that's actually in the compile input — a missing one is a
|
|
136
|
+
* COMPILE ERROR, never a fallback.
|
|
137
|
+
*
|
|
138
|
+
* chisel used to turn an asset import with no backing module into `const x = "<the specifier>"`:
|
|
139
|
+
* a relative source path nothing resolves at runtime, so the app got a silently broken reference
|
|
140
|
+
* (and, since the desugared imports are appended, a const declared after its own use — a TDZ
|
|
141
|
+
* ReferenceError at boot that read like a bundler crash). Reporting the call site here instead
|
|
142
|
+
* names the real problem: the file isn't there.
|
|
143
|
+
*/
|
|
144
|
+
const assertAssetsExist = (refs: { file: string, ref: AssetMacroRef }[], files: MemoryFiles) => {
|
|
145
|
+
const missing: string[] = []
|
|
146
|
+
for (const { file, ref } of refs) {
|
|
147
|
+
// Bare specifiers aren't paths at all — chisel reports those as bare imports.
|
|
148
|
+
if (!ref.path.startsWith("./") && !ref.path.startsWith("../") && !ref.path.startsWith("/")) continue
|
|
149
|
+
const dir = file.slice(0, file.lastIndexOf("/"))
|
|
150
|
+
const base = ref.path.startsWith("/") ? normalize(ref.path) : normalize(`${dir}/${ref.path}`)
|
|
151
|
+
// Same candidate list chisel's resolver uses, so "found here" means "found there".
|
|
152
|
+
const found = [ base, `${base}.ts`, `${base}.js`, `${base}/index.ts` ].some(c => files[c] !== undefined)
|
|
153
|
+
if (!found) missing.push(`asset not found: ${ref.path} (${file.replace(/^\//, "")}:${ref.line})`)
|
|
154
|
+
}
|
|
155
|
+
if (missing.length > 0) throw new Error(missing.join("\n"))
|
|
156
|
+
}
|
|
157
|
+
|
|
134
158
|
const bundleProjectChisel = async (
|
|
135
159
|
entrypoint: string,
|
|
136
160
|
files: MemoryFiles,
|
|
@@ -148,12 +172,17 @@ const bundleProjectChisel = async (
|
|
|
148
172
|
// - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
|
|
149
173
|
// - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
|
|
150
174
|
const projFiles: MemoryFiles = {}
|
|
175
|
+
const assetRefs: { file: string, ref: AssetMacroRef }[] = []
|
|
151
176
|
for (const [ k, v ] of Object.entries(files)) {
|
|
152
177
|
const path = normalize(k)
|
|
153
178
|
let src = v
|
|
154
179
|
if (/\.[tj]sx?$/.test(path)) {
|
|
155
180
|
if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
|
|
156
|
-
if (hasAssetMacro(src))
|
|
181
|
+
if (hasAssetMacro(src)) {
|
|
182
|
+
const found = collectAssetMacros(src, path)
|
|
183
|
+
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
184
|
+
src = desugarAssetMacros(src, path, found)
|
|
185
|
+
}
|
|
157
186
|
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
158
187
|
}
|
|
159
188
|
projFiles[path] = src
|
|
@@ -169,13 +198,19 @@ const bundleProjectChisel = async (
|
|
|
169
198
|
const path = normalize(`/__lib/${slug}/${k}`)
|
|
170
199
|
let src = v
|
|
171
200
|
if (/\.[tj]sx?$/.test(path)) {
|
|
172
|
-
if (hasAssetMacro(src))
|
|
201
|
+
if (hasAssetMacro(src)) {
|
|
202
|
+
const found = collectAssetMacros(src, path)
|
|
203
|
+
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
204
|
+
src = desugarAssetMacros(src, path, found)
|
|
205
|
+
}
|
|
173
206
|
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
174
207
|
}
|
|
175
208
|
projFiles[path] = src
|
|
176
209
|
}
|
|
177
210
|
}
|
|
178
211
|
|
|
212
|
+
assertAssetsExist(assetRefs, projFiles)
|
|
213
|
+
|
|
179
214
|
const sdkFiles = collectSdkFiles(resolveSdkSrc())
|
|
180
215
|
// Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
|
|
181
216
|
// names, which the scene editor matches aspect schemas by (`describeAspect().className`).
|
|
@@ -30,11 +30,18 @@ export {
|
|
|
30
30
|
export { sanitizeAssetName, isCanonicalAssetName } from "./assetName"
|
|
31
31
|
export {
|
|
32
32
|
PLATFORM_BUILD,
|
|
33
|
+
LOCAL_PLATFORM_BUILD,
|
|
34
|
+
ALL_PLATFORM_BUILD,
|
|
35
|
+
LOCAL_ONLY_PLATFORMS,
|
|
36
|
+
TARGET_MATRIX_GENERATION,
|
|
33
37
|
externalSamplerReg,
|
|
34
38
|
matcPlatformsFor,
|
|
35
39
|
matcArgs,
|
|
36
40
|
type MatcPlatform,
|
|
41
|
+
type LocalMatcPlatform,
|
|
42
|
+
type AnyMatcPlatform,
|
|
37
43
|
type ShaderPlatform,
|
|
44
|
+
type AnyShaderPlatform,
|
|
38
45
|
} from "./shaderTargets"
|
|
39
46
|
export {
|
|
40
47
|
buildLiteMaterialArtifact,
|
|
@@ -44,6 +51,14 @@ export {
|
|
|
44
51
|
type LiteMaterialParam,
|
|
45
52
|
type JsonishValue,
|
|
46
53
|
} from "./liteMaterial"
|
|
54
|
+
export {
|
|
55
|
+
parseShaderSchema,
|
|
56
|
+
shaderDefaults,
|
|
57
|
+
type ShaderSchema,
|
|
58
|
+
type ShaderParam,
|
|
59
|
+
type ShaderParamEditor,
|
|
60
|
+
type ShaderParamValue,
|
|
61
|
+
} from "./shaderSchema"
|
|
47
62
|
export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
|
|
48
63
|
export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
|
|
49
64
|
export {
|
|
@@ -66,13 +66,23 @@ export const sceneEditorEntries = (
|
|
|
66
66
|
.filter((p) => p.endsWith(".editor.ts"))
|
|
67
67
|
.map((p) => `import "${p}"\n`)
|
|
68
68
|
.join("")
|
|
69
|
+
// Every material asset registers on `__lecodesMaterials` BY PROJECT PATH, so the editor can
|
|
70
|
+
// resolve a scene's `material: handle` reference (an import the doc sees as an identifier) to
|
|
71
|
+
// the live handle and edit it in place. Imports dedupe with the scene's own, so a material the
|
|
72
|
+
// scene uses is the same instance here.
|
|
73
|
+
const materialPaths = projectPaths.filter((p) => p.endsWith(".material.ts"))
|
|
74
|
+
const materialImports = materialPaths.map((p, i) => `import __mat${i} from "${p}"\n`).join("")
|
|
75
|
+
const materialRegister = materialPaths.length === 0 ? ""
|
|
76
|
+
: `;(globalThis as any).__lecodesMaterials = { ${materialPaths.map((p, i) => `${JSON.stringify(p)}: __mat${i}`).join(", ")} }\n`
|
|
69
77
|
entries.push({
|
|
70
78
|
path: SCENE_ENTRY_PATH,
|
|
71
79
|
type: "text",
|
|
72
80
|
text: `import "${SCENE_HARNESS_DIR}/.edit-mode.ts"\n`
|
|
81
|
+
+ materialImports
|
|
73
82
|
+ `import "${scenePath}"\n`
|
|
74
83
|
+ editorImports
|
|
75
|
-
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n
|
|
84
|
+
+ `import "${SCENE_HARNESS_DIR}/main.ts"\n`
|
|
85
|
+
+ materialRegister,
|
|
76
86
|
})
|
|
77
87
|
return entries
|
|
78
88
|
}
|