lecodes-cli 0.12.0 → 0.13.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.
@@ -1,8 +1,8 @@
1
1
  // Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
2
2
  //
3
3
  // A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
4
- // nodes keyed by name, each with one source block (mesh / model / light / make / prefab, or none
5
- // = group),
4
+ // nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
5
+ // PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
6
6
  // a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
7
7
  // editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
8
8
  // node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
@@ -35,13 +35,14 @@
35
35
  import { Aspect, type AspectCtor, type With } from "../core/Aspect"
36
36
  import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
37
37
  import {
38
- collectRefDeps, isEditMode, isNodeRef, resolveRefs, use, ref, make, EDIT_FLAG,
38
+ collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
39
39
  type AspectEntry, type MakeEntry as SharedMakeEntry,
40
40
  } from "./grammar"
41
41
  import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
42
42
  import type { ColorInput } from "../core/color"
43
43
  import type { Vec3Like } from "../math/vec"
44
44
  import { Scene, type SceneOptions } from "../gl/Scene"
45
+ import { CAMERA_DEFAULTS } from "../gl/Camera"
45
46
  import { Node } from "../gl/Node"
46
47
  import { Mesh } from "../gl/Mesh"
47
48
  import { Model } from "../gl/Model"
@@ -84,8 +85,19 @@ export type ModelOverrideDef = {
84
85
  visible?: boolean
85
86
  }
86
87
 
87
- /** The `camera: {}` source block — reserved for projection settings (fov, …) later. */
88
- export type CameraNodeDef = Record<string, never>
88
+ /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
89
+ * block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
90
+ export type CameraProjectionDef = {
91
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
92
+ fov?: number
93
+ /** Near clip distance (default 0.01). */
94
+ near?: number
95
+ /** Far clip distance = view range (default 1000); geometry past it is culled. */
96
+ far?: number
97
+ }
98
+
99
+ /** The `camera: {}` source block — projection settings for the node that drives the view. */
100
+ export type CameraNodeDef = CameraProjectionDef
89
101
 
90
102
  export type SceneNodeDef = {
91
103
  // -- source (at most one; none = plain group node) --
@@ -115,7 +127,7 @@ export type SceneNodeDef = {
115
127
  eulerAngles?: Vec3Like
116
128
  scale?: Vec3Like | number
117
129
  visible?: boolean
118
- /** Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect. */
130
+ /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
119
131
  locked?: boolean
120
132
  castShadows?: boolean
121
133
  receiveShadows?: boolean
@@ -124,7 +136,7 @@ export type SceneNodeDef = {
124
136
  children?: Record<string, SceneNodeDef>
125
137
  }
126
138
 
127
- export type SceneCameraDef = {
139
+ export type SceneCameraDef = CameraProjectionDef & {
128
140
  position?: Vec3Like
129
141
  /** Point the camera looks at. */
130
142
  target?: Vec3Like
@@ -153,16 +165,19 @@ type NodeOf<N extends SceneNodeDef> =
153
165
  type UnionToIntersection<U> =
154
166
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
155
167
 
156
- // The child maps of a def level, as a union (never when no node has children — guarded below,
157
- // since `unknown` would absorb the union and `never` would poison the intersection).
158
- type ChildMapsOf<T extends Record<string, SceneNodeDef>> =
159
- { [K in keyof T]: T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C> : never }[keyof T]
168
+ // The child maps of a def level, prefixed with their parent's path, as a union (never when no
169
+ // node has children — guarded below, since `unknown` would absorb the union and `never` would
170
+ // poison the intersection).
171
+ type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
172
+ { [K in keyof T & string]:
173
+ T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
174
+ }[keyof T & string]
160
175
 
161
- // All nodes of a def tree, flattened into one name → node map (names are unique per scene file —
162
- // the editor enforces it; at runtime a duplicate key simply overwrites in the map).
163
- type NodesOf<T extends Record<string, SceneNodeDef>> =
164
- { [K in keyof T]: NodeOf<T[K]> } &
165
- ([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
176
+ // All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
177
+ // its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
178
+ type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
179
+ { [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
180
+ ([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)
166
181
 
167
182
  export type SceneNodes<D extends SceneDef> =
168
183
  D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
@@ -170,6 +185,11 @@ export type SceneNodes<D extends SceneDef> =
170
185
  export type LoadedScene<D extends SceneDef> = {
171
186
  scene: Scene
172
187
  nodes: SceneNodes<D>
188
+ /** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
189
+ get: {
190
+ <P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
191
+ (path: string): Node | null
192
+ }
173
193
  }
174
194
 
175
195
  // ---- GLB internal parts --------------------------------------------------------
@@ -241,9 +261,9 @@ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
241
261
  }
242
262
  }
243
263
 
244
- const createSource = (name: string, def: SceneNodeDef): Node | Promise<Node> => {
264
+ const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
245
265
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
246
- if (sources > 1) throw new Error(`Scene node "${name}" declares more than one source (mesh/model/light/make/prefab/camera)`)
266
+ if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
247
267
  if (def.model !== undefined) return Model.load(def.model)
248
268
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
249
269
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
@@ -258,7 +278,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
258
278
  if (def.eulerAngles) node.eulerAngles = def.eulerAngles
259
279
  if (def.scale !== undefined) node.scale = def.scale
260
280
  if (def.visible !== undefined) node.visible = def.visible
261
- // editor-only flag (the harness reads it when targeting the gizmo); inert at runtime
281
+ // editor-only flag (the harness's manipulation layer consults it); inert at runtime
262
282
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
263
283
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
264
284
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
@@ -317,16 +337,28 @@ class CameraRig extends Aspect<"__cameraRig"> {
317
337
  }
318
338
  }
319
339
 
320
- /** The first camera-source def in file order (depth-first), or null. */
321
- const findCameraName = (defs?: Record<string, SceneNodeDef>): string | null => {
340
+ /** The first camera-source def in file order (depth-first) — its path and block — or null. */
341
+ const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
322
342
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
323
- if (nd.camera !== undefined) return name
324
- const inner = findCameraName(nd.children)
343
+ const path = prefix === "" ? name : `${prefix}/${name}`
344
+ if (nd.camera !== undefined) return { path, def: nd.camera }
345
+ const inner = findCamera(nd.children, path)
325
346
  if (inner) return inner
326
347
  }
327
348
  return null
328
349
  }
329
350
 
351
+ /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
352
+ * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
353
+ * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
354
+ const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
355
+ const { fov, near, far } = def
356
+ if (!reset && fov === undefined && near === undefined && far === undefined) return
357
+ scene.camera.setProjection(reset
358
+ ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
359
+ : { fov, near, far })
360
+ }
361
+
330
362
  // ---- editor-run aspects (generators) ------------------------------------------------------------
331
363
  // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
332
364
  // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
@@ -367,29 +399,37 @@ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
367
399
 
368
400
  /** One live editor-run aspect instance (edit mode only). */
369
401
  type EditorRun = {
370
- hostName: string
402
+ /** Absolute path of the host def node (re-keyed on rename/reparent). */
403
+ hostPath: string
371
404
  node: Node
372
405
  /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
373
406
  index: number
374
407
  inst: { rebuild?(): void }
375
- /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. */
408
+ /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
409
+ * DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
410
+ * against the file's props, so rewriting them would re-fire on every render. */
376
411
  props: Record<string, unknown>
377
- /** Names of nodes the ref() props point at — a change to any of them re-runs rebuild(). */
412
+ /** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
413
+ * their subtrees) re-runs rebuild(). Re-derived after every structural change. */
378
414
  deps: Set<string>
379
415
  }
380
416
 
381
417
  const safeRebuild = (run: EditorRun): void => {
382
418
  // a throwing generator must not take the editor session down with it
383
- try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostName}":`, e) }
419
+ try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
384
420
  }
385
421
 
386
422
  /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
387
423
  * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
388
424
  const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
425
+ const lookup = (r: string): Node | null => {
426
+ const p = resolveRefPath(nodes, run.hostPath, r)
427
+ return p === null ? null : nodes[p]
428
+ }
389
429
  for (const [ k, v ] of Object.entries(run.props)) {
390
- if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = nodes[v.$ref] ?? null
430
+ if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
391
431
  else if (Array.isArray(v) && v.some(isNodeRef)) {
392
- ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
432
+ ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
393
433
  }
394
434
  }
395
435
  }
@@ -404,7 +444,7 @@ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
404
444
  /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
405
445
  const MAKE_INDEX = -1
406
446
 
407
- const createMakeInst = (name: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
447
+ const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
408
448
  let token = 0
409
449
  const inst: Record<string, unknown> = {}
410
450
  inst.rebuild = () => {
@@ -418,7 +458,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
418
458
  void Promise.resolve(result).then((made) => {
419
459
  // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
420
460
  if (t === token && made instanceof Node) generated.add(made)
421
- }).catch((e) => console.error(`[scene] make() factory failed on "${name}":`, e))
461
+ }).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
422
462
  }
423
463
  return inst as unknown as { rebuild(): void }
424
464
  }
@@ -427,7 +467,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
427
467
  * awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
428
468
  * and returns immediately (async content pops in when ready, like a model load). */
429
469
  const attachMake = (
430
- node: Node, name: string, def: SceneNodeDef,
470
+ node: Node, path: string, def: SceneNodeDef,
431
471
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
432
472
  ): Promise<void> | undefined => {
433
473
  const entry = def.make
@@ -435,21 +475,21 @@ const attachMake = (
435
475
  const generated = makeGenerated(node, scene)
436
476
  if (isEditMode()) {
437
477
  const props = { ...(entry.args ?? {}) } as Record<string, unknown>
438
- const inst = createMakeInst(name, entry, generated)
439
- Object.assign(inst, resolveRefs(props, nodes))
440
- const run: EditorRun = { hostName: name, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props) }
478
+ const inst = createMakeInst(path, entry, generated)
479
+ Object.assign(inst, resolveRefs(props, nodes, path))
480
+ const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
441
481
  editorRuns.push(run)
442
482
  safeRebuild(run)
443
483
  return undefined
444
484
  }
445
- const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
485
+ const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
446
486
  return Promise.resolve(entry.fn(args as never)).then((made) => {
447
487
  if (made instanceof Node) generated.add(made)
448
488
  })
449
489
  }
450
490
 
451
491
  const attachAspects = (
452
- node: Node, name: string, def: SceneNodeDef,
492
+ node: Node, path: string, def: SceneNodeDef,
453
493
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
454
494
  ): void => {
455
495
  if (isEditMode()) {
@@ -463,17 +503,17 @@ const attachAspects = (
463
503
  const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
464
504
  ;(inst as { node: unknown }).node = node
465
505
  ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
466
- Object.assign(inst, resolveRefs(props, nodes))
506
+ Object.assign(inst, resolveRefs(props, nodes, path))
467
507
  // the HOST node is a dep too: a generator that draws relative to its node (path lines)
468
508
  // must re-run when the node itself is dragged, not only when its ref() targets move
469
- const run: EditorRun = { hostName: name, node, index, inst, props, deps: collectRefDeps(props).add(name) }
509
+ const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
470
510
  editorRuns.push(run)
471
511
  safeRebuild(run)
472
512
  })
473
513
  return
474
514
  }
475
515
  for (const entry of def.aspects ?? []) {
476
- const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
516
+ const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
477
517
  const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
478
518
  ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
479
519
  }
@@ -489,15 +529,15 @@ const attachAspects = (
489
529
  // `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
490
530
 
491
531
  /** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
492
- * order, which some hosts reverse) — def names are unique per file, so paths are just the names
493
- * joined. Models inside the prefab drill into their GLB parts, nested prefabs into their own
494
- * precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
532
+ * order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
533
+ * the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
534
+ * their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
495
535
  const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
496
536
  const out: ModelPartRow[] = []
497
537
  for (const [ name, nd ] of Object.entries(defs)) {
498
- const node = local[name]
499
- if (!node) continue
500
538
  const path = prefix === "" ? name : `${prefix}/${name}`
539
+ const node = local[path]
540
+ if (!node) continue
501
541
  out.push({ path, name, depth, node })
502
542
  out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
503
543
  const inner = node instanceof Model
@@ -508,16 +548,16 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
508
548
  return out
509
549
  }
510
550
 
511
- const attachPrefab = async (wrapper: Node, name: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
551
+ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
512
552
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
513
553
  if (!pdef || typeof pdef !== "object") {
514
- console.error(`[scene] "${name}".prefab is not a scene handle (import the .scene file's default export)`)
554
+ console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
515
555
  return
516
556
  }
517
557
  // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
518
558
  ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
519
559
  if (stack.has(pdef)) {
520
- console.error(`[scene] prefab cycle detected at "${name}" — instance skipped`)
560
+ console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
521
561
  return
522
562
  }
523
563
  const local: Record<string, Node> = {}
@@ -535,26 +575,33 @@ const buildNodes = async (
535
575
  defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
536
576
  nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
537
577
  ): Promise<void> => {
538
- const pending: { name: string, node: Node, def: SceneNodeDef }[] = []
578
+ const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
539
579
 
540
- const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null): Promise<void> => {
541
- const node = await createSource(name, nd)
580
+ const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
581
+ // names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
582
+ if (name === "" || name.includes("/") || name.includes(":")) {
583
+ throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
584
+ }
585
+ // the path is derived from def keys BEFORE any await, so it is deterministic even though
586
+ // Promise.all makes build completion (and record insertion) order nondeterministic
587
+ const path = parentPath === "" ? name : `${parentPath}/${name}`
588
+ const node = await createSource(path, nd)
542
589
  if (parentNode) parentNode.add(node)
543
590
  // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
544
591
  scene.add(node)
545
- applyNode(node, name, nd)
592
+ applyNode(node, name, nd) // engine-side name stays the bare sibling segment
546
593
  if (isEditMode()) addEditorMarker(node, nd, scene)
547
- if (nd.prefab !== undefined) await attachPrefab(node, name, nd, scene, stack)
548
- pending.push({ name, node, def: nd })
549
- nodes[name] = node
550
- await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
594
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
595
+ pending.push({ path, node, def: nd })
596
+ nodes[path] = node
597
+ await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
551
598
  }
552
599
 
553
- await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
600
+ await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
554
601
  const makeWaits: Promise<void>[] = []
555
602
  for (const p of pending) {
556
- attachAspects(p.node, p.name, p.def, nodes, scene, editorRuns)
557
- const wait = attachMake(p.node, p.name, p.def, nodes, scene, editorRuns)
603
+ attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
604
+ const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
558
605
  if (wait) makeWaits.push(wait)
559
606
  }
560
607
  if (makeWaits.length > 0) await Promise.all(makeWaits)
@@ -566,18 +613,22 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
566
613
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
567
614
 
568
615
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
569
- // (CameraRig), in edit mode it only seeds the editor's starting viewpoint
570
- const camName = findCameraName(def.nodes)
571
- const camNode = camName ? nodes[camName] : undefined
572
- if (camNode) {
616
+ // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
617
+ // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
618
+ // lens the running app will use.
619
+ const cam = findCamera(def.nodes)
620
+ const camNode = cam ? nodes[cam.path] : undefined
621
+ if (cam && camNode) {
573
622
  scene.camera.position = camNode.worldPosition
574
623
  scene.camera.quaternion = camNode.worldQuaternion
624
+ applyCameraProjection(scene, cam.def)
575
625
  if (!isEditMode()) {
576
626
  ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
577
627
  }
578
628
  } else if (def.camera) {
579
629
  if (def.camera.position) scene.camera.position = def.camera.position
580
630
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
631
+ applyCameraProjection(scene, def.camera)
581
632
  }
582
633
 
583
634
  return { scene, nodes }
@@ -590,7 +641,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
590
641
  private _editorRuns: EditorRun[] = []
591
642
  /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
592
643
  private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
593
- /** @internal The loaded scene/nodes, for the editor methods below (set once load resolves). */
644
+ /** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
645
+ * load resolves). The record object is SHARED with the returned LoadedScene — patches and
646
+ * rename/reparent re-keying are visible through both. */
594
647
  private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
595
648
 
596
649
  constructor(def: D) { this.def = def }
@@ -600,7 +653,8 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
600
653
  if (!this._loading) {
601
654
  this._loading = instantiate(this.def, this._editorRuns).then((live) => {
602
655
  this._live = live
603
- return live as LoadedScene<D>
656
+ const get = (path: string): Node | null => live.nodes[path] ?? null
657
+ return { ...live, get } as unknown as LoadedScene<D>
604
658
  })
605
659
  }
606
660
  return this._loading
@@ -615,19 +669,20 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
615
669
 
616
670
  /**
617
671
  * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
618
- * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the named node
619
- * in place (or adds it when the name is new, under `parentName`/root), `def === null` removes it
620
- * with its whole subtree. The def must be PLAIN data — the editor falls back to a full re-run
621
- * for `$expr` and `$asset` values; aspect changes are inert in edit mode and stay out of defs.
672
+ * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
673
+ * `path` in place (or adds it when the path is new — the path's parent must exist, root paths
674
+ * mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
675
+ * data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
676
+ * are inert in edit mode and stay out of defs.
622
677
  *
623
678
  * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
624
679
  * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
625
- * removal. Old GPU resources (geometry/material instances) are not reclaimed until the next full
626
- * re-run — acceptable churn for an edit session.
680
+ * removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
681
+ * are not reclaimed until the next full re-run — acceptable churn for an edit session.
627
682
  */
628
- async _patchNode(name: string, def: SceneNodeDef | null, parentName?: string | null): Promise<Node | null> {
683
+ async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
629
684
  const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
630
- const old: Node | undefined = nodes[name]
685
+ const old: Node | undefined = nodes[path]
631
686
 
632
687
  const dispose = (root: Node): void => {
633
688
  // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
@@ -644,55 +699,141 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
644
699
  }
645
700
 
646
701
  if (def === null) {
647
- if (old) dispose(old)
702
+ if (old) {
703
+ dispose(old)
704
+ this._refreshRunDeps()
705
+ }
648
706
  return null
649
707
  }
650
708
 
651
- const build = async (n: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
652
- const existing = n === name ? undefined : nodes[n]
709
+ const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
710
+ // child reuse is by FULL path — only the live subtree at this exact address is carried
711
+ // over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
712
+ const existing = p === path ? undefined : nodes[p]
653
713
  if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
654
714
  if (parent) parent.add(existing)
655
715
  return existing
656
716
  }
657
- const node = await createSource(n, d)
717
+ const node = await createSource(p, d)
658
718
  if (parent) parent.add(node)
659
719
  scene.add(node)
660
- applyNode(node, n, d)
720
+ applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
661
721
  if (isEditMode()) addEditorMarker(node, d, scene)
662
- attachAspects(node, n, d, nodes, scene, this._editorRuns)
663
- nodes[n] = node
664
- await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(cn, cd, node)))
722
+ attachAspects(node, p, d, nodes, scene, this._editorRuns)
723
+ nodes[p] = node
724
+ await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
665
725
  return node
666
726
  }
667
727
 
668
- const parent = old ? old.parent : (parentName ? nodes[parentName] ?? null : null)
669
- const fresh = await build(name, def, parent)
728
+ const cut = path.lastIndexOf("/")
729
+ const parentPath = cut < 0 ? null : path.slice(0, cut)
730
+ if (!old && parentPath !== null && !nodes[parentPath]) return null
731
+ const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
732
+ const fresh = await build(path, def, parent)
670
733
  if (old) {
671
734
  // named children not listed in the def still move over (the editor patches one node at a time)
672
735
  const named = new Set(Object.values(nodes))
673
736
  for (const child of old.children) if (named.has(child)) fresh.add(child)
674
- dispose(old) // fresh already replaced nodes[name] in build(), so it survives
675
- nodes[name] = fresh
737
+ dispose(old) // fresh already replaced nodes[path] in build(), so it survives
738
+ nodes[path] = fresh
676
739
  }
740
+ // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
741
+ // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
742
+ if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
743
+ this._refreshRunDeps()
677
744
  return fresh
678
745
  }
679
746
 
680
- /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
681
- * and re-run rebuild() on every editor-run aspect whose ref() props point at it. A change also
682
- * counts for the node's ANCESTORS: generators read subtrees (FollowPath's waypoints are the
683
- * children of its referenced path node), so dragging a child must rebuild the parent's deps. */
684
- _editorNodeChanged(name: string): void {
747
+ /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
748
+ * absolute paths, so any structural change can invalidate them: an add can satisfy a
749
+ * previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
750
+ private _refreshRunDeps(): void {
685
751
  const nodes = this._live?.nodes
686
752
  if (!nodes) return
687
- const changed = new Set([ name ])
688
- let cur = nodes[name]?.parent ?? null
689
- for (let i = 0; cur && i < 64; i++) {
690
- for (const [ n, node ] of Object.entries(nodes)) if (node === cur) { changed.add(n); break }
691
- cur = cur.parent
753
+ for (const run of this._editorRuns) {
754
+ run.deps = collectRefDeps(run.props, nodes, run.hostPath)
755
+ // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
756
+ // wrapper's transform is editor-owned and moving it never re-calls the factory
757
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
758
+ }
759
+ }
760
+
761
+ /** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
762
+ * runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
763
+ * the host — mutation, not replacement. */
764
+ private _rekey(oldPath: string, newPath: string): void {
765
+ const nodes = this._live!.nodes
766
+ const move = (key: string): string | null =>
767
+ key === oldPath ? newPath
768
+ : key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
769
+ : null
770
+ for (const key of Object.keys(nodes)) {
771
+ const next = move(key)
772
+ if (next === null) continue
773
+ const n = nodes[key]
774
+ delete nodes[key]
775
+ nodes[next] = n
776
+ }
777
+ for (const run of this._editorRuns) {
778
+ const next = move(run.hostPath)
779
+ if (next !== null) run.hostPath = next
780
+ }
781
+ for (const [ key, card ] of [ ...this._inspectorCards ]) {
782
+ const i = key.lastIndexOf(":")
783
+ const next = move(key.slice(0, i))
784
+ if (next === null) continue
785
+ this._inspectorCards.delete(key)
786
+ this._inspectorCards.set(next + key.slice(i), card)
692
787
  }
788
+ this._refreshRunDeps()
789
+ }
790
+
791
+ /** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
792
+ * Owns the shared record's re-keying (the host re-keys only its own part/selection state).
793
+ * Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
794
+ _renameNode(path: string, newName: string): string | null {
795
+ const nodes = this._live?.nodes
796
+ const node = nodes?.[path]
797
+ if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
798
+ const cut = path.lastIndexOf("/")
799
+ const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
800
+ if (newPath === path) return path
801
+ if (nodes[newPath]) return null
802
+ this._rekey(path, newPath)
803
+ node.name = newName // engine-side name stays the bare segment
804
+ return newPath
805
+ }
806
+
807
+ /** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
808
+ * every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
809
+ * cycle, name taken among the new siblings). */
810
+ _reparentNode(path: string, newParentPath: string | null): string | null {
811
+ const nodes = this._live?.nodes
812
+ const node = nodes?.[path]
813
+ if (!nodes || !node) return null
814
+ const parent = newParentPath === null ? null : nodes[newParentPath]
815
+ if (newParentPath !== null && !parent) return null
816
+ if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
817
+ const name = path.slice(path.lastIndexOf("/") + 1)
818
+ const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
819
+ if (newPath === path) return path
820
+ if (nodes[newPath]) return null
821
+ this._rekey(path, newPath)
822
+ node.setParent(parent ?? null, false)
823
+ return newPath
824
+ }
825
+
826
+ /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
827
+ * and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
828
+ * absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
829
+ * FollowPath's waypoints are the children of its referenced path node) is a prefix test:
830
+ * a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
831
+ _editorNodeChanged(path: string): void {
832
+ const nodes = this._live?.nodes
833
+ if (!nodes) return
693
834
  for (const run of this._editorRuns) {
694
835
  let hit = false
695
- for (const n of changed) if (run.deps.has(n)) { hit = true; break }
836
+ for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
696
837
  if (!hit) continue
697
838
  assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
698
839
  safeRebuild(run)
@@ -701,22 +842,22 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
701
842
 
702
843
  /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
703
844
  * run (no compile; the factory is already in the bundle). False for non-make nodes. */
704
- _editorSetMakeArg(hostName: string, key: string, value: unknown): boolean {
705
- return this._editorSetProp(hostName, MAKE_INDEX, key, value)
845
+ _editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
846
+ return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
706
847
  }
707
848
 
708
849
  /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
709
850
  * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
710
851
  * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
711
- _editorSetProp(hostName: string, index: number, key: string, value: unknown): boolean {
712
- const run = this._editorRuns.find((r) => r.hostName === hostName && r.index === index)
852
+ _editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
853
+ const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
713
854
  const nodes = this._live?.nodes
714
855
  if (!run || !nodes) return false
715
856
  run.props[key] = value
716
- run.deps = collectRefDeps(run.props)
857
+ run.deps = collectRefDeps(run.props, nodes, run.hostPath)
717
858
  // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
718
859
  // wrapper's transform is editor-owned and moving it never re-calls the factory
719
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostName)
860
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
720
861
  if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
721
862
  else (run.inst as Record<string, unknown>)[key] = value
722
863
  safeRebuild(run)
@@ -737,19 +878,19 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
737
878
  * node instance was replaced by a live patch is rebuilt transparently.
738
879
  */
739
880
  _inspectorRender(
740
- hostName: string, index: number,
881
+ hostPath: string, index: number,
741
882
  props: Record<string, unknown>, event?: InspectorEvent,
742
883
  ): InspectorWidget[] | null {
743
884
  const live = this._live
744
- const node = live?.nodes[hostName]
885
+ const node = live?.nodes[hostPath]
745
886
  const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
746
887
  ?._sceneAspects?.[index]
747
888
  if (!live || !node || !entry) return null
748
889
  const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
749
890
  if (typeof ctor.inspector !== "function") return null
750
891
 
751
- const run = this._editorRuns.find((r) => r.hostName === hostName && r.index === index)
752
- const key = `${hostName}:${index}`
892
+ const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
893
+ const key = `${hostPath}:${index}`
753
894
  let card = this._inspectorCards.get(key)
754
895
  if (!card || card.node !== node) {
755
896
  // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
@@ -761,7 +902,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
761
902
  } else {
762
903
  inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
763
904
  inst.node = node
764
- Object.assign(inst, resolveRefs({ ...props }, live.nodes))
905
+ Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
765
906
  }
766
907
  const ui = card?.ui ?? new InspectorUI()
767
908
  ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
@@ -782,17 +923,17 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
782
923
  a !== b && JSON.stringify(a) !== JSON.stringify(b)
783
924
  if (run) {
784
925
  for (const [ k, v ] of Object.entries(props)) {
785
- if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostName, index, k, v)
926
+ if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
786
927
  }
787
928
  for (const k of Object.keys(run.props)) {
788
929
  if (k in props) continue
789
- this._editorSetProp(hostName, index, k, fieldDefault(k))
930
+ this._editorSetProp(hostPath, index, k, fieldDefault(k))
790
931
  delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
791
932
  }
792
933
  c.ui._props = run.props
793
934
  } else {
794
935
  const snapshot = { ...props }
795
- const resolved = (resolveRefs(snapshot, live.nodes) ?? snapshot) as Record<string, unknown>
936
+ const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
796
937
  for (const f of c.ui._fields) {
797
938
  if (isExpr(resolved[f.key])) continue
798
939
  c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
@@ -803,12 +944,12 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
803
944
  return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
804
945
  }
805
946
 
806
- /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance —
807
- * path/name/depth/live node, in the same part-path grammar `overrides` keys use. Empty for
808
- * plain nodes / unknown names. */
809
- async _modelParts(name: string): Promise<ModelPartRow[]> {
947
+ /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
948
+ * absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
949
+ * `overrides` keys use. Empty for plain nodes / unknown paths. */
950
+ async _modelParts(path: string): Promise<ModelPartRow[]> {
810
951
  const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
811
- const node = nodes[name]
952
+ const node = nodes[path]
812
953
  if (node instanceof Model) return modelPartRows(node)
813
954
  // prefab instances precompute their rows in DEF order (engine child order isn't stable)
814
955
  return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
@@ -830,8 +971,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
830
971
 
831
972
  /**
832
973
  * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
833
- * `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
834
- * (Mesh / Model / Light / Node) with its `use(...)`d aspects attached.
974
+ * `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
975
+ * block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
976
+ * plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
835
977
  */
836
978
  export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
837
979
  const handle = new SceneHandle(def)