lecodes-cli 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +51 -28
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/scene/defineScene.ts +221 -109
- package/runtime/sdk/scene/editorPlugins.ts +22 -16
- package/runtime/sdk/scene/grammar.ts +49 -14
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +0 -42
- package/runtime/sdk/compile/assetIconMacro.ts +0 -382
- package/runtime/sdk/compile/assetMacro.ts +0 -45
- package/runtime/sdk/compile/assetName.ts +0 -50
- package/runtime/sdk/compile/bundler.ts +0 -244
- package/runtime/sdk/compile/compileProject.ts +0 -106
- package/runtime/sdk/compile/detectEntry.ts +0 -125
- package/runtime/sdk/compile/fontMacro.ts +0 -459
- package/runtime/sdk/compile/fontRegistry.ts +0 -78
- package/runtime/sdk/compile/header.ts +0 -55
- package/runtime/sdk/compile/index.ts +0 -53
- package/runtime/sdk/compile/libraryImports.ts +0 -52
- package/runtime/sdk/compile/sceneEditor.ts +0 -78
- package/runtime/sdk/compile/sfnt.ts +0 -98
- package/runtime/sdk/compile/sourcemap.ts +0 -25
package/dist/index.js
CHANGED
|
@@ -43217,13 +43217,13 @@ This is an intent-capture stage — screens run on inline mock data, before any
|
|
|
43217
43217
|
## Read this first, then meta.json
|
|
43218
43218
|
|
|
43219
43219
|
\`meta.json\` is the app map: every screen (position, description) and the edges between them
|
|
43220
|
-
(navigation intent). **Read it before opening any screen file.**
|
|
43221
|
-
|
|
43222
|
-
|
|
43223
|
-
|
|
43224
|
-
\`lecodes design arrange
|
|
43225
|
-
|
|
43226
|
-
positions
|
|
43220
|
+
(navigation intent). **Read it before opening any screen file.** When you create a screen, add its
|
|
43221
|
+
meta entry **without a \`pos\`** and add edges — the board places it from its structure (its tab
|
|
43222
|
+
lane, its group region, its flow neighbours) and **persists that position itself**; you never
|
|
43223
|
+
hand-pick coordinates (you can't see the board). To tidy after a batch of changes, call the
|
|
43224
|
+
\`arrange_screens\` MCP tool (or \`lecodes design arrange\`) — the layout engine knows tab rails,
|
|
43225
|
+
group regions and flow direction. Never reposition or rewrite existing \`pos\` values or
|
|
43226
|
+
human-authored descriptions — positions the developer dragged are theirs.
|
|
43227
43227
|
|
|
43228
43228
|
\`\`\`json
|
|
43229
43229
|
{
|
|
@@ -43247,11 +43247,15 @@ positions or descriptions.
|
|
|
43247
43247
|
- **An edge from a screen to itself** (\`"from": "home", "to": "home@empty"\`) is a *state link*: the
|
|
43248
43248
|
board draws it as a chip on the activator element (clicking it switches the tile's state), never
|
|
43249
43249
|
as an arrow. Use it to record "tapping X puts this screen into state Y".
|
|
43250
|
-
- **Group
|
|
43251
|
-
|
|
43252
|
-
|
|
43253
|
-
|
|
43254
|
-
|
|
43250
|
+
- **Group only what the tab bar doesn't already section.** Screens on a tab bar are sectioned by
|
|
43251
|
+
their lanes — never set a \`group\` on a tab root or any screen that mounts the bar (a group that
|
|
43252
|
+
duplicates a lane draws nothing and gets flagged for deletion). Group the screens *outside* the
|
|
43253
|
+
bar by user-facing area — \`"group": "Onboarding"\` — 2–6 screens per group (Onboarding,
|
|
43254
|
+
Recovery, Pickers…; on an app with no tab bar that means every screen). Groups render as labeled
|
|
43255
|
+
regions and \`arrange\` lays each out as its own sub-flow, so grouping keeps big boards legible.
|
|
43256
|
+
Semantic clustering is your job (you're good at it); coordinates are \`arrange\`'s (it can see
|
|
43257
|
+
the board, you can't). Don't regroup existing screens unless asked — groups are part of the
|
|
43258
|
+
human's map.
|
|
43255
43259
|
- **Tag a screen's \`role\`** in its meta entry — its *kind* (\`list\`, \`detail\`, \`form\`, \`auth\`,
|
|
43256
43260
|
\`modal\`…). Optional, renders on the board, captures concept the screen source can't;
|
|
43257
43261
|
\`lecodes design check\` lists screens still untagged (never blocking). *(A screen's **states** are
|
|
@@ -43332,6 +43336,8 @@ Most apps have one — declare it ONCE, in source, and never draw edges for it:
|
|
|
43332
43336
|
- The canvas discovers all of this from the source (like screen states) and draws the bar as a
|
|
43333
43337
|
**rail + lane bands** — tab switching needs NO edges, and \`design_state\` serves the discovered
|
|
43334
43338
|
\`hubs\`. In the live prototype, tapping a tab button jumps the board to that tab's root screen.
|
|
43339
|
+
- The lane bands replace groups for bar screens: don't \`group\` anything that mounts the bar
|
|
43340
|
+
(see the meta.json section) — groups are for the flows *outside* the bar.
|
|
43335
43341
|
- Style through theme variables: the bar reads \`var(--primaryColor)\`, \`var(--mutedColor)\`,
|
|
43336
43342
|
\`var(--tabbarBg)\`, \`var(--screenBg)\` (with fallbacks) — one \`theme({ primaryColor: … })\` call
|
|
43337
43343
|
restyles it app-wide. You may restyle \`shared/tabs.ts\` itself; keep the \`defineTabs({ … })\`
|
|
@@ -43497,8 +43503,8 @@ JSON by hand (a message missing its \`pending\` flag is never delivered, and not
|
|
|
43497
43503
|
1. \`board_activity\` (if the server is up), then \`design_comments\` for anything open, then read
|
|
43498
43504
|
\`meta.json\`, the \`shared/\` signatures, and only the screens you need.
|
|
43499
43505
|
2. Create/edit screen files; register new screens in \`meta.json\` **without \`pos\`** and add edges —
|
|
43500
|
-
|
|
43501
|
-
|
|
43506
|
+
the board places them from their lane/group/flow structure and persists the result. After a
|
|
43507
|
+
larger batch, \`arrange_screens\` (MCP) or \`lecodes design arrange\` re-tidies the whole board.
|
|
43502
43508
|
3. \`check_screens\` → \`render_screen\` (or \`lecodes design snapshot\`) → verify → fix.
|
|
43503
43509
|
4. When the developer edits a description, text, or the flow on the canvas — visible in
|
|
43504
43510
|
\`board_activity\` and in \`meta.json\`/the screen source — treat it as the updated spec.
|
|
@@ -43849,11 +43855,12 @@ var DEFAULT_DIR = "design", DEFAULT_PORT = 4477, getContext = (args) => {
|
|
|
43849
43855
|
const designServer = await loadDesignServer(ctx);
|
|
43850
43856
|
const onlyNew = flagBool(args, "new");
|
|
43851
43857
|
const summary = designServer.arrangeDesign(ctx.designDir, { onlyNew });
|
|
43858
|
+
const score = summary.score ? ` (score ${summary.score.before.toFixed(0)} → ${summary.score.after.toFixed(0)}, lower is better)` : "";
|
|
43852
43859
|
if (!summary.changed) {
|
|
43853
|
-
log(onlyNew ? "No unplaced screens — nothing to arrange." :
|
|
43860
|
+
log(onlyNew ? "No unplaced screens — nothing to arrange." : `Already in its arranged shape — nothing moved${score}.`);
|
|
43854
43861
|
return;
|
|
43855
43862
|
}
|
|
43856
|
-
log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json.`);
|
|
43863
|
+
log(`${c.green("✓")} ${onlyNew ? "Placed" : "Arranged"} ${summary.moved} screen${summary.moved === 1 ? "" : "s"} in ${ctx.dirName}/meta.json${score}.`);
|
|
43857
43864
|
}, share = async (ctx, args) => {
|
|
43858
43865
|
if (!ctx.manifest) {
|
|
43859
43866
|
throw new CliError("`lecodes design share` needs a cloned project. Run it inside one, and push the design first (lecodes push).");
|
|
@@ -45213,7 +45220,7 @@ var require_main = __commonJS((exports, module) => {
|
|
|
45213
45220
|
// package.json
|
|
45214
45221
|
var package_default = {
|
|
45215
45222
|
name: "lecodes-cli",
|
|
45216
|
-
version: "0.13.
|
|
45223
|
+
version: "0.13.2",
|
|
45217
45224
|
dependencies: {
|
|
45218
45225
|
"@letary/chisel": "^0.6.0",
|
|
45219
45226
|
jimp: "^1.6.1"
|
|
@@ -45226,8 +45233,8 @@ var package_default = {
|
|
|
45226
45233
|
sdk: "workspace:*"
|
|
45227
45234
|
},
|
|
45228
45235
|
peerDependencies: {
|
|
45229
|
-
"lecodes-design": "^0.
|
|
45230
|
-
"lecodes-renderer": "^0.8.
|
|
45236
|
+
"lecodes-design": "^0.6.0",
|
|
45237
|
+
"lecodes-renderer": "^0.8.1",
|
|
45231
45238
|
"lecodes-3d-editor": "^0.2.0"
|
|
45232
45239
|
},
|
|
45233
45240
|
bin: {
|
|
@@ -49636,15 +49643,26 @@ var DEFAULT_PORT2 = 4499;
|
|
|
49636
49643
|
var DEBOUNCE_MS = 120;
|
|
49637
49644
|
var WATCH_SKIP_DIRS = new Set([".lecodes", ".git", "node_modules"]);
|
|
49638
49645
|
var WATCH_SKIP_FILES = new Set(["tsconfig.json", "jsconfig.json", ".gitignore", ".lecodesignore", ".DS_Store"]);
|
|
49639
|
-
var
|
|
49646
|
+
var VIRTUAL_IFACE = /wsl|docker|hyper-v|vethernet|virtualbox|vmware|tap|tun|zerotier|tailscale|loopback/i;
|
|
49647
|
+
var lanCandidates = () => {
|
|
49640
49648
|
const nets = networkInterfaces();
|
|
49641
|
-
|
|
49642
|
-
|
|
49643
|
-
|
|
49644
|
-
|
|
49645
|
-
|
|
49646
|
-
|
|
49647
|
-
|
|
49649
|
+
const out = [];
|
|
49650
|
+
for (const iface of Object.keys(nets)) {
|
|
49651
|
+
for (const net of nets[iface] ?? []) {
|
|
49652
|
+
if (net.family !== "IPv4" || net.internal)
|
|
49653
|
+
continue;
|
|
49654
|
+
const a2 = net.address;
|
|
49655
|
+
let rank = 0;
|
|
49656
|
+
if (VIRTUAL_IFACE.test(iface))
|
|
49657
|
+
rank += 4;
|
|
49658
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(a2))
|
|
49659
|
+
rank += 2;
|
|
49660
|
+
if (a2.startsWith("169.254."))
|
|
49661
|
+
rank += 8;
|
|
49662
|
+
out.push({ address: a2, iface, rank });
|
|
49663
|
+
}
|
|
49664
|
+
}
|
|
49665
|
+
return out.sort((x2, y2) => x2.rank - y2.rank).map(({ address, iface }) => ({ address, iface }));
|
|
49648
49666
|
};
|
|
49649
49667
|
var loadDevToken = (root) => {
|
|
49650
49668
|
const path2 = join20(root, LECODES_DIR, "dev.json");
|
|
@@ -49685,9 +49703,11 @@ var dev = async (args) => {
|
|
|
49685
49703
|
warnErr("Running under Bun: the websocket reload channel doesn't work here — run `lecodes dev` with Node (or use ?t=poll on the device URL).");
|
|
49686
49704
|
}
|
|
49687
49705
|
const port = Number(flagStr(args, "port")) || DEFAULT_PORT2;
|
|
49688
|
-
const
|
|
49706
|
+
const candidates = lanCandidates();
|
|
49707
|
+
const host = flagStr(args, "host") ?? candidates[0]?.address;
|
|
49689
49708
|
if (!host)
|
|
49690
49709
|
throw new CliError("No LAN address found — pass one with --host <ip>.");
|
|
49710
|
+
const alternatives = candidates.filter((c4) => c4.address !== host);
|
|
49691
49711
|
const origin = `http://${host}:${port}`;
|
|
49692
49712
|
const token = loadDevToken(root);
|
|
49693
49713
|
const server = await startDevServer({
|
|
@@ -49767,6 +49787,9 @@ ${result.error}`);
|
|
|
49767
49787
|
await printQr(server.url);
|
|
49768
49788
|
log(`Scan the QR with the LeCodes app, or open: ${c.bold(server.url)}`);
|
|
49769
49789
|
note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`);
|
|
49790
|
+
if (alternatives.length > 0) {
|
|
49791
|
+
note(`Device can't reach it? This machine also has ${alternatives.map((a2) => `${a2.address} (${a2.iface})`).join(", ")} — retry with --host <ip>.`);
|
|
49792
|
+
}
|
|
49770
49793
|
if (existsSync20(join20(root, LECODES_DIR, "manifest.json")))
|
|
49771
49794
|
note(`Token stored in ${LECODES_DIR}/dev.json (delete it to rotate the URL).`);
|
|
49772
49795
|
log("");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lecodes-cli",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.2",
|
|
4
4
|
"dependencies": {
|
|
5
5
|
"@letary/chisel": "^0.6.0",
|
|
6
6
|
"jimp": "^1.6.1"
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"sdk": "workspace:*"
|
|
14
14
|
},
|
|
15
15
|
"peerDependencies": {
|
|
16
|
-
"lecodes-design": "^0.
|
|
17
|
-
"lecodes-renderer": "^0.8.
|
|
16
|
+
"lecodes-design": "^0.6.0",
|
|
17
|
+
"lecodes-renderer": "^0.8.1",
|
|
18
18
|
"lecodes-3d-editor": "^0.2.0"
|
|
19
19
|
},
|
|
20
20
|
"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).\ndeclare const _creator: { getParent(entityId: number): number }\n\ntype SceneHandleLike = {\n load(): Promise<{ scene: Scene, nodes: Record<string, Node> }>\n /** SceneHandle._describeAspects — aspect field schemas for the inspector (plain data). */\n _describeAspects?(): unknown[]\n /** SceneHandle._patchNode — live single-node rebuild/add/remove (absent on older SDK bundles). */\n _patchNode?(name: string, def: unknown, parentName?: string | null): Promise<Node | null>\n /** SceneHandle._modelParts — a model node's INTERNAL rows (the `overrides` path grammar). */\n _modelParts?(name: string): Promise<{ path: string, name: string, depth: number, node: Node }[]>\n /** SceneHandle._editorNodeChanged — rebuild editor-run aspects whose ref() deps include `name`. */\n _editorNodeChanged?(name: string): void\n /** SceneHandle._editorSetProp — live prop edit on one editor-run aspect (rebuilds it). */\n _editorSetProp?(hostName: string, index: number, key: string, value: unknown): boolean\n /** SceneHandle._editorSetMakeArg — live arg edit on a `make()` node (re-calls the factory). */\n _editorSetMakeArg?(name: string, key: string, value: unknown): boolean\n /** SceneHandle._inspectorRender — run an aspect's custom `static inspector` card. */\n _inspectorRender?(\n hostName: string, index: number,\n props: Record<string, unknown>, event?: { id: string, value?: unknown },\n ): unknown[] | null\n}\n\ntype Vec3Tuple = [number, number, number]\n\ntype HarnessCallbacks = {\n /** Selection changed from INSIDE the world (a plugin's `editor.select`) — mirror host-side. */\n onSelect?(name: string | null): void\n}\n\n/** Document operations the HOST implements (its commit/undo/patch machinery) — the doc-op half of\n * the `editor` API handed to plugins (windows/tools). Installed via `controller.setEditorOps`. */\ntype EditorOps = {\n nodes(): { name: string, kind: string }[]\n uniqueName(base: string): string\n addNode(name: string, def: Record<string, unknown>): boolean\n setProp(name: string, key: string, value: unknown): boolean\n removeNode(name: string): void\n duplicate(name: string): string | null\n transact(fn: () => void): void\n}\n\nconst state = {\n handle: null as SceneHandleLike | null,\n scene: null as Scene | null,\n nodes: {} as Record<string, Node>,\n names: new Map<Node, string>(),\n /** GLB internal nodes, addressable like nodes: key = `<model>::<part path>` (+ reverse map). */\n parts: new Map<string, Node>(),\n partNames: new Map<Node, string>(),\n orbit: null as OrbitCamera | null,\n selected: null as string | null,\n /** The active viewport tool (`registerEditorTool` name) — clicks route to it. */\n activeTool: null as string | null,\n /** Host-implemented doc operations (the `editor` API's write half). */\n editorOps: null as EditorOps | null,\n}\n\n// ---- GLB parts ---------------------------------------------------------------------------------\n// A model's internal nodes are selectable/editable through PART KEYS: `<model>::<part path>` (the\n// path grammar is the SDK's — SceneHandle._modelParts enumerates it, `overrides` keys store it).\n// Everything key-addressed (select / setNodeProp / focus) resolves through `resolveKey`, so a part\n// behaves like a node — except its persistence: the editor writes the transform into the MODEL's\n// `overrides` record instead of a node def.\n\nconst PART_SEP = \"::\"\n\nconst resolveKey = (key: string): Node | null => state.nodes[key] ?? state.parts.get(key) ?? null\n\n/** (Re-)enumerate one model's internal nodes into the part maps; returns plain rows for the host. */\nconst refreshModelParts = async (name: string): Promise<{ path: string, name: string, depth: number }[]> => {\n const handle = state.handle\n if (!handle?._modelParts) return []\n const rows = await handle._modelParts(name)\n for (const [ key, node ] of [ ...state.parts ]) {\n if (key.startsWith(name + PART_SEP)) { state.parts.delete(key); state.partNames.delete(node) }\n }\n for (const r of rows) {\n const key = name + PART_SEP + r.path\n state.parts.set(key, r.node)\n state.partNames.set(r.node, key)\n }\n return rows.map((r) => ({ path: r.path, name: r.name, depth: r.depth }))\n}\n\n/** A node (or a part inside a model) changed — let the scene's editor-run aspects (generators)\n * that reference it via ref() rebuild. Part keys collapse to their model's def name. */\nconst notifyEditorChanged = (key: string | null): void => {\n if (!key) return\n const i = key.indexOf(PART_SEP)\n state.handle?._editorNodeChanged?.(i < 0 ? key : key.slice(0, i))\n}\n\nconst isEditorNode = (node: Node): boolean => (node.name ?? \"\").startsWith(\"__editor\")\n\n// ---- built-in node cards (immediate-mode inspector protocol — sdk core/InspectorUI.ts) ----------\n// The ANIMATION card on model nodes with baked clips: clip dropdown (live from model.anim.clips),\n// speed/loop, Play/Stop preview. Pure editor state — nothing here writes to the scene file\n// (it's a preview; play-mode behavior belongs in aspects/code). ModelAnimation is attached by the\n// Model constructor itself, so it is live even in edit mode.\n\nconst nodeCards = new Map<string, InspectorUI>()\n\nconst renderNodeCard = (name: string, event?: { id: string, value?: unknown }): unknown[] | null => {\n const node = state.nodes[name]\n if (!(node instanceof Model)) return null\n const clips = node.anim.clips\n if (clips.length === 0) return null\n let ui = nodeCards.get(name)\n if (!ui) { ui = new InspectorUI(); nodeCards.set(name, ui) }\n return ui._run((u) => {\n u.header(\"Animation\")\n const clip = String(u.select(\"clip\", clips.map((c) => c.name)))\n const speed = u.number(\"speed\", { min: 0.1, max: 4, step: 0.1, value: 1 })\n const loop = u.switch(\"loop\", { value: true })\n if (node.anim.playing) { node.anim.speed = speed; node.anim.loop = loop }\n if (u.button(node.anim.playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n node.anim.play(clip, { loop })\n }\n if (u.button(\"Stop\")) {\n // stop AND reset the pose (time 0 re-poses the skeleton even while stopped)\n node.anim.stop()\n node.anim.time = 0\n }\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(node.anim.playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The named def node a raycast-hit entity belongs to (a GLB hit resolves to its Model, etc.). */\nconst ownerName = (node: Node | null): string | null => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 32; i++) {\n const name = state.names.get(cur)\n if (name) return name\n cur = cur.parent\n }\n return null\n}\n\nconst applySelection = (name: string | null, notify: boolean): void => {\n state.selected = name\n if (notify) controller.callbacks.onSelect?.(name)\n}\n\n// ---- editor plugins (windows + viewport tools — sdk scene/editorPlugins.ts) ---------------------\n// `*.editor.ts` files register into the injected `__editorPlugins` registry (same bundle → same\n// module instance). Windows render through the immediate-mode InspectorUI protocol like inspector\n// cards; tools receive viewport clicks as raycast hits. Both get the `editor` scripting API:\n// selection/raycast are answered here, doc writes delegate to the host's EditorOps (plugins write\n// the DOCUMENT, never live state — one undo stack for humans and plugins alike).\n\nconst isInSubtree = (node: Node, root: Node): boolean => {\n let cur: Node | null = node\n for (let i = 0; cur && i < 64; i++) {\n if (cur === root) return true\n cur = cur.parent\n }\n return false\n}\n\n/** Raycast under a viewport pixel: precise mesh hit via Jolt bodies when the build/scene has any\n * (edit mode attaches none itself — aspects are inert), else / on miss the ground plane.\n * `exclude` steps the ray past any hit inside that subtree (editor nodes are always stepped). */\nconst raycastViewport = (x: number, y: number, exclude?: Node | null): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null => {\n const scene = state.scene\n if (!scene) return null\n const ray = scene.camera.getRay(x, y)\n if (Physics.supported) {\n let origin: Vec3Tuple = [ ray.origin.x, ray.origin.y, ray.origin.z ]\n let remaining = 2000\n for (let i = 0; i < 8 && remaining > 0; i++) {\n const hit = Physics.raycast(origin, ray.dir, remaining)\n if (!hit) break\n // marker-flagged nodes (empties/cameras) — placement/tool rays step past them like editor\n // nodes (a drop must land on real geometry)\n if (hit.node && !isEditorNode(hit.node)\n && !(hit.node as { _sceneMarker?: string })._sceneMarker\n && !(exclude && isInSubtree(hit.node, exclude))) {\n return {\n point: [ hit.point.x, hit.point.y, hit.point.z ],\n normal: [ hit.normal.x, hit.normal.y, hit.normal.z ],\n node: hit.node ? ownerName(hit.node) : null,\n }\n }\n // an excluded/editor body — resume the cast just past it\n const step = remaining * hit.fraction + 0.01\n origin = [ origin[0] + ray.dir.x * step, origin[1] + ray.dir.y * step, origin[2] + ray.dir.z * step ]\n remaining -= step\n }\n }\n const t = -ray.origin.y / ray.dir.y\n if (!Number.isFinite(t) || t <= 0) return null\n const p = ray.getPoint(t)\n return { point: [ p.x, p.y, p.z ], normal: [ 0, 1, 0 ], node: null }\n}\n\nconst editorApi = {\n get selection(): string | null { return state.selected },\n select(name: string | null): void { applySelection(name && resolveKey(name) ? name : null, true) },\n nodes: (): { name: string, kind: string }[] => state.editorOps?.nodes() ?? [],\n uniqueName: (base: string): string => state.editorOps?.uniqueName(base) ?? base,\n addNode: (name: string, def: Record<string, unknown>): boolean => state.editorOps?.addNode(name, def) === true,\n setProp: (name: string, key: string, value: unknown): boolean => state.editorOps?.setProp(name, key, value) === true,\n removeNode: (name: string): void => { state.editorOps?.removeNode(name) },\n duplicate: (name: string): string | null => state.editorOps?.duplicate(name) ?? null,\n raycast: raycastViewport,\n transact: (fn: () => void): void => { state.editorOps ? state.editorOps.transact(fn) : fn() },\n}\n\n/** Per-window InspectorUI instances (all their field keys are transient editor state). */\nconst windowUIs = new Map<number, InspectorUI>()\n\nconst controller = {\n callbacks: {} as HarnessCallbacks,\n\n /** Attach to a user scene handle (from `globalThis.__lecodesScenes`). Returns the node names. */\n async attach(handle: SceneHandleLike): Promise<{ nodes: string[] }> {\n const { scene, nodes } = await handle.load()\n state.handle = handle\n state.scene = scene\n state.nodes = nodes\n state.names = new Map(Object.entries(nodes).map(([ name, node ]) => [ node, name ]))\n\n scene.open()\n\n state.orbit = new OrbitCamera(scene.camera)\n\n // any drag orbits (nothing else claims viewport touches — no pick colliders in the world)\n scene.addEventListener(\"touchstart\", (ev) => {\n ev.track({ onMove: (pos) => state.orbit?.rotate(pos.deltaX, pos.deltaY) })\n })\n\n // an active plugin tool owns viewport clicks — they arrive as raycast hits (selection itself\n // is host-driven from the tree, so a click outside a tool does nothing)\n scene.addEventListener(\"click\", (ev) => {\n if (!state.activeTool) return\n const tool = __editorPlugins.tools.find((t) => t.name === state.activeTool)\n if (!tool?.hooks.onViewportClick) return\n const hit = raycastViewport(ev.clientX, ev.clientY)\n if (hit) {\n // a throwing tool logs and skips — it can't take the editor down\n try { tool.hooks.onViewportClick(hit, editorApi) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the nearest tracked def node; a hit inside the CURRENTLY\n * SELECTED model resolves to its deepest part key instead (click-again drill-down). Locked\n * nodes yield null (the click selects nothing — the host keeps the current selection). */\n pickName(entityId: number): string | null {\n if (!entityId) return null\n const nodeIds = new Map<number, string>()\n for (const [ name, node ] of Object.entries(state.nodes)) nodeIds.set(node.id, name)\n const partIds = new Map<number, string>()\n for (const [ key, node ] of state.parts) partIds.set(node.id, key)\n let part: string | null = null\n for (let id = entityId, i = 0; id !== 0 && i < 64; i++) {\n part ??= partIds.get(id) ?? null // the deepest enumerated part containing the hit\n const name = nodeIds.get(id)\n if (name !== undefined) {\n const node = state.nodes[name]\n if (node && (node as { _sceneLocked?: boolean })._sceneLocked) return null\n const inSelected = state.selected === name || state.selected?.startsWith(name + PART_SEP)\n return part && inSelected ? part : name\n }\n id = _creator.getParent(id)\n }\n return null\n },\n\n /** Apply a completed gizmo drag: set the node's (or part's) local transform from the engine's\n * decomposed pose, then return position/eulerAngles/scale read BACK from the engine — exactly\n * the values the host should persist (same read-back rule as setFromView: never compose eulers\n * by hand, the engines' setter conventions differ). */\n applyPose(\n name: string,\n pose: { position: Vec3Tuple, quaternion: [number, number, number, number], scale: Vec3Tuple },\n ): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple } | null {\n const node = resolveKey(name)\n if (!node) return null\n node.position = pose.position\n node.quaternion = pose.quaternion\n node.scale = pose.scale\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n }\n },\n\n /** A model node's internal GLB rows (path/name/depth) — the tree's expandable part list. Also\n * (re-)binds the part keys, so call it after anything that reloads the model. */\n modelParts(name: string): Promise<{ path: string, name: string, depth: number }[]> {\n return refreshModelParts(name)\n },\n\n /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\n const node = resolveKey(name)\n if (!node) return null\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\n }\n },\n\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\n * anything else is a structural change (the host recompiles the scene bundle instead). */\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"position\") node.position = value as Vec3Tuple\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\n else if (key === \"visible\") node.visible = value === true\n else if (key === \"locked\") {\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\n // consults it; fields edit regardless)\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n return\n }\n notifyEditorChanged(name)\n },\n\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\n return state.handle?._editorSetProp?.(name, index, key, value) === true\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE named node from\n * plain def data — the SDK re-instantiates it through the same defineScene builder, so nothing\n * recompiles. The HOST guarantees the def is plain data (it bails to a re-run on `$expr` and\n * `$asset` values). Returns false when this bundle's SDK can't patch (older builds). */\n async patchNode(name: string, def: Record<string, unknown> | null, parentName?: string | null): Promise<boolean> {\n const handle = state.handle\n if (!handle?._patchNode) return false\n const node = await handle._patchNode(name, def, parentName)\n state.names = new Map(Object.entries(state.nodes).map(([ n, nd ]) => [ nd, n ]))\n notifyEditorChanged(name) // generators referencing the patched node follow it\n // the node object was replaced/removed — its part bindings (if it was a model) went with it\n await refreshModelParts(name)\n if (state.selected === name || state.selected?.startsWith(name + PART_SEP)) {\n const alive = state.selected === name ? node : (state.selected ? resolveKey(state.selected) : null)\n if (!alive) state.selected = null\n }\n return true\n },\n\n /** Live rename: runtime maps + the engine-side name. The document is the source of truth. */\n renameNode(oldName: string, newName: string): boolean {\n const node = state.nodes[oldName]\n if (!node || state.nodes[newName]) return false\n delete state.nodes[oldName]\n state.nodes[newName] = node\n state.names.set(node, newName)\n node.name = newName\n // part keys carry the model name as their prefix — remap them (and a part selection) with it\n for (const [ key, part ] of [ ...state.parts ]) {\n if (!key.startsWith(oldName + PART_SEP)) continue\n const next = newName + key.slice(oldName.length)\n state.parts.delete(key)\n state.parts.set(next, part)\n state.partNames.set(part, next)\n }\n if (state.selected === oldName) state.selected = newName\n else if (state.selected?.startsWith(oldName + PART_SEP)) {\n state.selected = newName + state.selected.slice(oldName.length)\n }\n return true\n },\n\n /** Live reparent keeping the LOCAL transform — the same semantics the scene file describes\n * (a node under a transformed parent shifts in world space). `parentName` null = root. */\n reparentNode(name: string, parentName: string | null): boolean {\n const node = state.nodes[name]\n const parent = parentName ? state.nodes[parentName] : null\n if (!node || (parentName !== null && !parent)) return false\n node.setParent(parent, false)\n return true\n },\n\n /** Live scene-level tweak. Only `skybox` is applicable post-creation for now — the host falls\n * back to a re-run for everything else. */\n setSceneProp(key: string, value: unknown): boolean {\n if (key === \"skybox\" && state.scene && typeof value === \"string\") {\n state.scene.skybox = value\n return true\n }\n return false\n },\n\n zoom(delta: number): void { state.orbit?.zoom(delta) },\n pan(deltaX: number, deltaY: number): void { state.orbit?.pan(deltaX, deltaY) },\n\n /** Raycast under a viewport pixel (physics bodies when present, ground-plane fallback) — the\n * host's drop-to-place path; same hit shape the plugin `editor.raycast` answers. */\n raycast(x: number, y: number): { point: Vec3Tuple, normal: Vec3Tuple, node: string | null } | null {\n return raycastViewport(x, y)\n },\n\n /** Aspect field schemas (AspectClassInfo[]) for every aspect class the scene references — the\n * inspector's editors. Computed in-bundle because only the bundle holds the ctors. */\n aspectSchemas(): unknown[] {\n return state.handle?._describeAspects?.() ?? []\n },\n\n /** Registered editor windows (`registerEditorWindow` in `*.editor.ts` files), display order. */\n editorWindows(): { title: string }[] {\n return __editorPlugins.windows.map((w) => ({ title: w.title }))\n },\n\n /** One immediate-mode pass of a registered window — same widget-list protocol as inspector\n * cards, except every field key is transient editor state (windows have no doc entry). */\n renderWindow(index: number, event?: { id: string, value?: unknown }): unknown[] | null {\n const win = __editorPlugins.windows[index]\n if (!win) return null\n let ui = windowUIs.get(index)\n if (!ui) { ui = new InspectorUI(); windowUIs.set(index, ui) }\n return ui._run((u) => win.render(u, editorApi), event)\n },\n\n /** Registered viewport tools (`registerEditorTool`) — the host's extra toolbar entries. */\n editorTools(): { name: string, cursor?: string, icon?: string }[] {\n return __editorPlugins.tools.map((t) => ({ name: t.name, cursor: t.hooks.cursor, icon: t.hooks.icon }))\n },\n\n /** Activate a plugin tool (null = deactivate). While active, viewport clicks route to the\n * tool's hooks; selection itself is unaffected. */\n setActiveTool(name: string | null): void {\n state.activeTool = name && __editorPlugins.tools.some((t) => t.name === name) ? name : null\n },\n\n /** Install the host's document operations — the write half of the plugin `editor` API. */\n setEditorOps(ops: EditorOps): void {\n state.editorOps = ops\n },\n\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\n * live euler-semantics probe against the running engine (the setter convention differs across\n * shipped engines — see setFromView). */\n _debug(): unknown {\n const probe = new Node()\n probe.eulerAngles = [ 0, 0, 90 ]\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\n const probe2 = new Node()\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\n return {\n selected: state.selected, // def name OR `<owner>::<path>` part key\n activeTool: state.activeTool,\n eulerZ90MapsYTo: [ y.x, y.y, y.z ],\n quatZ90MapsYTo: [ y2.x, y2.y, y2.z ], // standard/faithful: (−1, 0, 0)\n }\n },\n\n /** Pose the named node from the CURRENT editor viewpoint (the camera node's \"Set from view\"):\n * applies the live camera's world pose to the node quaternion-faithfully, then returns the\n * parent-local position/eulerAngles read BACK from the engine — exactly the values the host\n * should write to the file (never compose eulers by hand: the shipped engines apply the\n * eulerAngles setter with differing conventions, so only an engine read-back round-trips). */\n setFromView(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple } | null {\n const node = resolveKey(name)\n const cam = state.scene?.camera\n if (!node || !cam) return null\n const parent = node.parent\n const camPos = cam.worldPosition\n node.quaternion = parent\n ? parent.worldQuaternion.invert().mul(cam.worldQuaternion)\n : cam.worldQuaternion\n node.position = parent ? parent.worldMatrix.invert().transformPoint(camPos) : camPos\n notifyEditorChanged(name)\n const p = node.position, e = node.eulerAngles\n return { position: [ p.x, p.y, p.z ], eulerAngles: [ e.x, e.y, e.z ] }\n },\n\n /** Frame the named node or part (or the selection). */\n focus(name?: string): void {\n const node = resolveKey(name ?? state.selected ?? \"\")\n if (!node) return\n const p = node.worldPosition\n state.orbit?.focus([ p.x, p.y, p.z ])\n },\n}\n\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure gizmo math — plain number tuples in/out, no SDK dependencies, so it unit-tests directly\n// (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n\nexport type V3 = readonly [number, number, number]\n\nexport const add = (a: V3, b: V3): V3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]\nexport const sub = (a: V3, b: V3): V3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]\nexport const scale = (a: V3, s: number): V3 => [ a[0] * s, a[1] * s, a[2] * s ]\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nexport const length = (a: V3): number => Math.hypot(a[0], a[1], a[2])\n\n/**\n * Parameter `t` along the axis line (origin + dir·t, dir unit-length) of the point closest to the\n * given ray. Standard closest-point-between-two-lines; falls back to projecting the ray origin when\n * the lines are near-parallel (denominator ~ 0).\n */\nexport const closestAxisParam = (rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3): number => {\n // minimize |axisOrigin + t·axisDir − (rayOrigin + s·rayDir)|²:\n // t = s·b + d, s = t·b − e ⇒ t = (d − b·e) / (1 − b²)\n const w = sub(rayOrigin, axisOrigin)\n const b = dot(axisDir, rayDir)\n const d = dot(axisDir, w)\n const e = dot(rayDir, w)\n const denom = 1 - b * b // axisDir·axisDir = rayDir·rayDir = 1\n if (Math.abs(denom) < 1e-8) return d\n return (d - b * e) / denom\n}\n\n/** Distance between a ray and an axis SEGMENT (t clamped to [0, segLength]). */\nexport const rayToAxisSegmentDistance = (\n rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3, segLength: number,\n): number => {\n const t = Math.max(0, Math.min(segLength, closestAxisParam(rayOrigin, rayDir, axisOrigin, axisDir)))\n const p = add(axisOrigin, scale(axisDir, t))\n // closest point on the ray to p (s clamped to ≥ 0 — the ray starts at the camera)\n const s = Math.max(0, dot(sub(p, rayOrigin), rayDir))\n const q = add(rayOrigin, scale(rayDir, s))\n return length(sub(p, q))\n}\n\nexport type AxisPick = { axis: 0 | 1 | 2, startParam: number }\n\nconst AXES: V3[] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n/**\n * Which gizmo axis (if any) a ray grabs: the closest axis whose segment [origin, origin + dir·len]\n * passes within `threshold` of the ray. Returns the axis plus the grab parameter along it (the\n * drag delta is `currentParam - startParam` on subsequent moves).\n */\nexport const pickGizmoAxis = (\n rayOrigin: V3, rayDir: V3, gizmoOrigin: V3, axisLength: number, threshold: number,\n): AxisPick | null => {\n let best: AxisPick | null = null\n let bestDist = threshold\n for (let i = 0; i < 3; i++) {\n const dist = rayToAxisSegmentDistance(rayOrigin, rayDir, gizmoOrigin, AXES[i], axisLength)\n if (dist <= bestDist) {\n bestDist = dist\n best = { axis: i as 0 | 1 | 2, startParam: closestAxisParam(rayOrigin, rayDir, gizmoOrigin, AXES[i]) }\n }\n }\n return best\n}\n\n/** The world-axis direction for an axis index. */\nexport const axisDir = (axis: 0 | 1 | 2): V3 => AXES[axis]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
|
|
1
|
+
{"main.ts":"// The 3D scene-editor harness — a LeCodes app the editor host runs alongside a user scene bundle\n// (compiled with the modern SDK; replaces the legacy worker-SDK scene-viewer). The host:\n//\n// 1. runs the user's scene bundle with `__lecodesSceneEdit` set → the scene handle registers on\n// `globalThis.__lecodesScenes` (sources real, aspects inert — see sdk docs/3d/scene-files.md);\n// 2. runs this bundle → it installs `globalThis.__lecodesSceneHarness` (the controller);\n// 3. calls `controller.attach(handle)` and wires `controller.callbacks`.\n//\n// The viewport DISPLAY layer (grid, selection outline, transform gizmo, viewport picking) lives in\n// the ENGINE now — viewer-lite's editor layer (`_creator._editorConnect`), lite viewport only; the\n// filament preview renders the bare scene. This harness stays the POLICY half: the host feeds it\n// engine picks (`pickName` — locked nodes, GLB part drill-down) and completed gizmo drags\n// (`applyPose` — applied live, transform read back for the file), and `entityOf` hands the host\n// the entity id behind a selection key. Orbit is any drag; zoom/pan/focus come from the host\n// (DOM wheel/keys belong to the Vue viewport). Plugin viewport tools keep working: an active tool\n// receives clicks as raycast hits (physics bodies when the build has them, ground plane otherwise).\n\nimport { OrbitCamera } from \"./orbit\"\n\n// The low-level bridge global every bundle runs against — the harness only walks the entity\n// hierarchy with it (wrapping foreign entity ids in `new Node(id)` would OVERWRITE the real nodes\n// in the SDK's registry, so id-level walking is the safe form).\ndeclare const _creator: { getParent(entityId: number): number }\n\ntype SceneHandleLike = {\n /** `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._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\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/** 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 if (node.anim.playing) { node.anim.speed = speed; node.anim.loop = loop }\n if (u.button(node.anim.playing ? \"Restart\" : \"Play\")) {\n node.anim.speed = speed\n node.anim.play(clip, { loop })\n }\n if (u.button(\"Stop\")) {\n // stop AND reset the pose (time 0 re-poses the skeleton even while stopped)\n node.anim.stop()\n node.anim.time = 0\n }\n const duration = clips.find((c) => c.name === clip)?.duration\n u.info(node.anim.playing ? \"playing…\" : duration !== undefined ? `${duration.toFixed(2)}s` : \"\")\n }, event)\n}\n\n/** The 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\n try { tool.hooks.onViewportClick(hit, editorApi) }\n catch (e) { console.error(\"[scene-editor] tool click failed:\", e) }\n }\n })\n\n // GLB internal hierarchies are part of the editable scene — enumerate them up front so part\n // keys resolve before the host asks for the rows\n await Promise.all(Object.keys(nodes).map((name) => refreshModelParts(name)))\n\n return { nodes: Object.keys(nodes) }\n },\n\n /** Host-driven selection (tree click). Accepts node names AND part keys. Does not echo onSelect. */\n select(name: string | null): void {\n applySelection(name && resolveKey(name) ? name : null, false)\n },\n\n /** The entity id behind a selection key (node name or GLB part key); 0 = unknown. The host\n * feeds it to the engine's editor layer (selection outline + gizmo target). */\n entityOf(name: string): number {\n return resolveKey(name)?.id ?? 0\n },\n\n /** Resolve an ENGINE pick (raw entity id) to a selection key — the policy half of viewport\n * picking. Walks the hit entity up to the NEAREST tracked def node (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 /** Current live transform of a node OR part (part inspectors have no doc def to read from). */\n getNodeProps(name: string): { position: Vec3Tuple, eulerAngles: Vec3Tuple, scale: Vec3Tuple, visible: boolean } | null {\n const node = resolveKey(name)\n if (!node) return null\n const p = node.position, e = node.eulerAngles, s = node.scale\n return {\n position: [ p.x, p.y, p.z ],\n eulerAngles: [ e.x, e.y, e.z ],\n scale: [ s.x, s.y, s.z ],\n visible: !!node.visible, // native bridges answer 0/1 — normalize for the inspector\n }\n },\n\n /** Apply an inspector value edit to the live node or GLB part. Transform/visibility/locked only —\n * anything else is a structural change (the host recompiles the scene bundle instead). */\n setNodeProp(name: string, key: \"position\" | \"eulerAngles\" | \"scale\" | \"visible\" | \"locked\", value: unknown): void {\n const node = resolveKey(name)\n if (!node) return\n if (key === \"position\") node.position = value as Vec3Tuple\n else if (key === \"eulerAngles\") node.eulerAngles = value as Vec3Tuple\n else if (key === \"scale\") node.scale = (typeof value === \"number\" ? value : value as Vec3Tuple) as any\n else if (key === \"visible\") node.visible = value === true\n else if (key === \"locked\") {\n // an editor-only flag kept on the live node (the coming selection/manipulation layer\n // consults it; fields edit regardless)\n ;(node as { _sceneLocked?: boolean })._sceneLocked = value === true\n return\n }\n notifyEditorChanged(name)\n },\n\n /** Live prop edit on an editor-run aspect (generator): update the instance + rebuild. False =\n * that entry isn't editor-run (inert doc data) or the bundle predates generators. */\n updateEditorAspect(name: string, index: number, key: string, value: unknown): boolean {\n return state.handle?._editorSetProp?.(name, index, key, value) === true\n },\n\n /** Live arg edit on a `make()` node: re-calls the factory through the tracked run — no compile\n * (the factory is already in the bundle). False = not a make node / older SDK. */\n setMakeArg(name: string, key: string, value: unknown): boolean {\n return state.handle?._editorSetMakeArg?.(name, key, value) === true\n },\n\n /** Custom inspector cards (immediate-mode widget lists — sdk core/InspectorUI.ts). An aspect\n * target runs the class's `static inspector` through the scene handle (`opts.props` = the\n * entry's CURRENT doc props, passed on every call; `opts.event` = buttons/state edits only);\n * a bare node name renders the built-in node card (the ANIMATION card on models with clips).\n * Null = no card — the host falls back to plain fields. */\n inspector(\n target: { name: string, aspect?: number },\n opts?: { props?: Record<string, unknown>, event?: { id: string, value?: unknown } },\n ): unknown[] | null {\n if (typeof target.aspect === \"number\") {\n return state.handle?._inspectorRender?.(target.name, target.aspect, opts?.props ?? {}, opts?.event) ?? null\n }\n return renderNodeCard(target.name, opts?.event)\n },\n\n /** Live material tweak on a mesh node (color/roughness/metallic) — mirrors the inspector while\n * the document commit re-runs lazily. Unknown node / non-mesh is a no-op. */\n setMaterialProp(name: string, key: \"color\" | \"roughness\" | \"metallic\", value: unknown): void {\n const node = state.nodes[name]\n if (!(node instanceof Mesh)) return\n if (key === \"color\") node.material.color = value as string\n else if (typeof value === \"number\") node.material.uniforms[key] = value\n },\n\n /** Live structural patch: rebuild / add (`def`) or remove (`def === null`) ONE 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 },\n\n /** Install the host's document operations — the write half of the plugin `editor` API. */\n setEditorOps(ops: EditorOps): void {\n state.editorOps = ops\n },\n\n /** Introspection for driving the editor headlessly (CDP/tests): selection + tool state, plus a\n * live euler-semantics probe against the running engine (the setter convention differs across\n * shipped engines — see setFromView). */\n _debug(): unknown {\n const probe = new Node()\n probe.eulerAngles = [ 0, 0, 90 ]\n const y = probe.quaternion.rotateVec3([ 0, 1, 0 ])\n const probe2 = new Node()\n probe2.quaternion = Quat.fromAxisAngle([ 0, 0, 1 ], Math.PI / 2)\n const y2 = probe2.quaternion.rotateVec3([ 0, 1, 0 ])\n return {\n selected: state.selected, // def 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\n;(globalThis as any).__lecodesSceneHarness = controller\n","math.ts":"// Pure gizmo math — plain number tuples in/out, no SDK dependencies, so it unit-tests directly\n// (packages/sdk/tests import it) and the harness bundle stays engine-agnostic here.\n\nexport type V3 = readonly [number, number, number]\n\nexport const add = (a: V3, b: V3): V3 => [ a[0] + b[0], a[1] + b[1], a[2] + b[2] ]\nexport const sub = (a: V3, b: V3): V3 => [ a[0] - b[0], a[1] - b[1], a[2] - b[2] ]\nexport const scale = (a: V3, s: number): V3 => [ a[0] * s, a[1] * s, a[2] * s ]\nexport const dot = (a: V3, b: V3): number => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]\nexport const length = (a: V3): number => Math.hypot(a[0], a[1], a[2])\n\n/**\n * Parameter `t` along the axis line (origin + dir·t, dir unit-length) of the point closest to the\n * given ray. Standard closest-point-between-two-lines; falls back to projecting the ray origin when\n * the lines are near-parallel (denominator ~ 0).\n */\nexport const closestAxisParam = (rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3): number => {\n // minimize |axisOrigin + t·axisDir − (rayOrigin + s·rayDir)|²:\n // t = s·b + d, s = t·b − e ⇒ t = (d − b·e) / (1 − b²)\n const w = sub(rayOrigin, axisOrigin)\n const b = dot(axisDir, rayDir)\n const d = dot(axisDir, w)\n const e = dot(rayDir, w)\n const denom = 1 - b * b // axisDir·axisDir = rayDir·rayDir = 1\n if (Math.abs(denom) < 1e-8) return d\n return (d - b * e) / denom\n}\n\n/** Distance between a ray and an axis SEGMENT (t clamped to [0, segLength]). */\nexport const rayToAxisSegmentDistance = (\n rayOrigin: V3, rayDir: V3, axisOrigin: V3, axisDir: V3, segLength: number,\n): number => {\n const t = Math.max(0, Math.min(segLength, closestAxisParam(rayOrigin, rayDir, axisOrigin, axisDir)))\n const p = add(axisOrigin, scale(axisDir, t))\n // closest point on the ray to p (s clamped to ≥ 0 — the ray starts at the camera)\n const s = Math.max(0, dot(sub(p, rayOrigin), rayDir))\n const q = add(rayOrigin, scale(rayDir, s))\n return length(sub(p, q))\n}\n\nexport type AxisPick = { axis: 0 | 1 | 2, startParam: number }\n\nconst AXES: V3[] = [ [ 1, 0, 0 ], [ 0, 1, 0 ], [ 0, 0, 1 ] ]\n\n/**\n * Which gizmo axis (if any) a ray grabs: the closest axis whose segment [origin, origin + dir·len]\n * passes within `threshold` of the ray. Returns the axis plus the grab parameter along it (the\n * drag delta is `currentParam - startParam` on subsequent moves).\n */\nexport const pickGizmoAxis = (\n rayOrigin: V3, rayDir: V3, gizmoOrigin: V3, axisLength: number, threshold: number,\n): AxisPick | null => {\n let best: AxisPick | null = null\n let bestDist = threshold\n for (let i = 0; i < 3; i++) {\n const dist = rayToAxisSegmentDistance(rayOrigin, rayDir, gizmoOrigin, AXES[i], axisLength)\n if (dist <= bestDist) {\n bestDist = dist\n best = { axis: i as 0 | 1 | 2, startParam: closestAxisParam(rayOrigin, rayDir, gizmoOrigin, AXES[i]) }\n }\n }\n return best\n}\n\n/** The world-axis direction for an axis index. */\nexport const axisDir = (axis: 0 | 1 | 2): V3 => AXES[axis]\n\n/** Yaw/pitch (radians) + radius around a target → camera position. */\nexport const orbitPosition = (target: V3, yaw: number, pitch: number, radius: number): V3 => {\n const cp = Math.cos(pitch)\n return [\n target[0] + radius * cp * Math.sin(yaw),\n target[1] + radius * Math.sin(pitch),\n target[2] + radius * cp * Math.cos(yaw),\n ]\n}\n\n/**\n * The camera's LOCAL screen basis at a yaw/pitch: `right` is horizontal (the orbit never rolls the\n * view) and `up` leans away from the camera as the pitch grows — both perpendicular to the view\n * direction, i.e. the plane a screen-space pan slides along (panning up must NOT climb world Y).\n */\nexport const orbitBasis = (yaw: number, pitch: number): { right: V3, up: V3 } => {\n const sy = Math.sin(yaw), cy = Math.cos(yaw)\n const sp = Math.sin(pitch), cp = Math.cos(pitch)\n return { right: [ cy, 0, -sy ], up: [ -sp * sy, cp, -sp * cy ] }\n}\n","orbit.ts":"// Orbit camera controller: drag anywhere (that the gizmo didn't claim) rotates around a target;\n// zoom/pan/focus are driven by the host (wheel events belong to the DOM, which the harness never\n// touches — the Vue viewport forwards them through the controller).\n\nimport { orbitBasis, orbitPosition, type V3 } from \"./math\"\n\nconst PITCH_LIMIT = Math.PI / 2 - 0.05\n\nexport class OrbitCamera {\n target: V3 = [ 0, 0, 0 ]\n yaw = Math.PI / 4\n pitch = 0.5\n radius = 8\n\n constructor(private readonly camera: Camera) {\n this.apply()\n }\n\n apply(): void {\n this.camera.position = orbitPosition(this.target, this.yaw, this.pitch, this.radius) as [number, number, number]\n this.camera.lookAt([ ...this.target ])\n }\n\n rotate(deltaX: number, deltaY: number): void {\n this.yaw -= deltaX * 0.008\n this.pitch = Math.min(PITCH_LIMIT, Math.max(-PITCH_LIMIT, this.pitch + deltaY * 0.008))\n this.apply()\n }\n\n /** Exponential zoom (host wheel: pass ±1-ish steps). */\n zoom(delta: number): void {\n this.radius = Math.min(200, Math.max(0.5, this.radius * Math.exp(delta * 0.2)))\n this.apply()\n }\n\n /** Pan the target in the camera's screen plane (host drives, e.g. middle-drag). */\n pan(deltaX: number, deltaY: number): void {\n const k = this.radius * 0.0015\n // Camera-LOCAL screen basis, so a vertical drag slides along the view plane (like the\n // horizontal one already did) instead of climbing the world Y axis.\n const { right, up } = orbitBasis(this.yaw, this.pitch)\n this.target = [\n this.target[0] - (right[0] * deltaX - up[0] * deltaY) * k,\n this.target[1] - (right[1] * deltaX - up[1] * deltaY) * k,\n this.target[2] - (right[2] * deltaX - up[2] * deltaY) * k,\n ]\n this.apply()\n }\n\n focus(point: V3, radius?: number): void {\n this.target = point\n if (radius !== undefined) this.radius = Math.max(0.5, radius)\n this.apply()\n }\n}\n"}
|