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.
@@ -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,7 +35,7 @@
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"
@@ -165,16 +165,19 @@ type NodeOf<N extends SceneNodeDef> =
165
165
  type UnionToIntersection<U> =
166
166
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
167
167
 
168
- // The child maps of a def level, as a union (never when no node has children — guarded below,
169
- // since `unknown` would absorb the union and `never` would poison the intersection).
170
- type ChildMapsOf<T extends Record<string, SceneNodeDef>> =
171
- { [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]
172
175
 
173
- // All nodes of a def tree, flattened into one name → node map (names are unique per scene file —
174
- // the editor enforces it; at runtime a duplicate key simply overwrites in the map).
175
- type NodesOf<T extends Record<string, SceneNodeDef>> =
176
- { [K in keyof T]: NodeOf<T[K]> } &
177
- ([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>>)
178
181
 
179
182
  export type SceneNodes<D extends SceneDef> =
180
183
  D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
@@ -182,6 +185,11 @@ export type SceneNodes<D extends SceneDef> =
182
185
  export type LoadedScene<D extends SceneDef> = {
183
186
  scene: Scene
184
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
+ }
185
193
  }
186
194
 
187
195
  // ---- GLB internal parts --------------------------------------------------------
@@ -253,9 +261,9 @@ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
253
261
  }
254
262
  }
255
263
 
256
- const createSource = (name: string, def: SceneNodeDef): Node | Promise<Node> => {
264
+ const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
257
265
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
258
- 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)`)
259
267
  if (def.model !== undefined) return Model.load(def.model)
260
268
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
261
269
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
@@ -329,11 +337,12 @@ class CameraRig extends Aspect<"__cameraRig"> {
329
337
  }
330
338
  }
331
339
 
332
- /** The first camera-source def in file order (depth-first) — its name and block — or null. */
333
- const findCamera = (defs?: Record<string, SceneNodeDef>): { name: string, def: CameraNodeDef } | 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 => {
334
342
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
335
- if (nd.camera !== undefined) return { name, def: nd.camera }
336
- const inner = findCamera(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)
337
346
  if (inner) return inner
338
347
  }
339
348
  return null
@@ -390,29 +399,37 @@ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
390
399
 
391
400
  /** One live editor-run aspect instance (edit mode only). */
392
401
  type EditorRun = {
393
- hostName: string
402
+ /** Absolute path of the host def node (re-keyed on rename/reparent). */
403
+ hostPath: string
394
404
  node: Node
395
405
  /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
396
406
  index: number
397
407
  inst: { rebuild?(): void }
398
- /** 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. */
399
411
  props: Record<string, unknown>
400
- /** 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. */
401
414
  deps: Set<string>
402
415
  }
403
416
 
404
417
  const safeRebuild = (run: EditorRun): void => {
405
418
  // a throwing generator must not take the editor session down with it
406
- 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) }
407
420
  }
408
421
 
409
422
  /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
410
423
  * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
411
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
+ }
412
429
  for (const [ k, v ] of Object.entries(run.props)) {
413
- 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)
414
431
  else if (Array.isArray(v) && v.some(isNodeRef)) {
415
- ;(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))
416
433
  }
417
434
  }
418
435
  }
@@ -427,7 +444,7 @@ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
427
444
  /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
428
445
  const MAKE_INDEX = -1
429
446
 
430
- const createMakeInst = (name: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
447
+ const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
431
448
  let token = 0
432
449
  const inst: Record<string, unknown> = {}
433
450
  inst.rebuild = () => {
@@ -441,7 +458,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
441
458
  void Promise.resolve(result).then((made) => {
442
459
  // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
443
460
  if (t === token && made instanceof Node) generated.add(made)
444
- }).catch((e) => console.error(`[scene] make() factory failed on "${name}":`, e))
461
+ }).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
445
462
  }
446
463
  return inst as unknown as { rebuild(): void }
447
464
  }
@@ -450,7 +467,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
450
467
  * awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
451
468
  * and returns immediately (async content pops in when ready, like a model load). */
452
469
  const attachMake = (
453
- node: Node, name: string, def: SceneNodeDef,
470
+ node: Node, path: string, def: SceneNodeDef,
454
471
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
455
472
  ): Promise<void> | undefined => {
456
473
  const entry = def.make
@@ -458,21 +475,21 @@ const attachMake = (
458
475
  const generated = makeGenerated(node, scene)
459
476
  if (isEditMode()) {
460
477
  const props = { ...(entry.args ?? {}) } as Record<string, unknown>
461
- const inst = createMakeInst(name, entry, generated)
462
- Object.assign(inst, resolveRefs(props, nodes))
463
- 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) }
464
481
  editorRuns.push(run)
465
482
  safeRebuild(run)
466
483
  return undefined
467
484
  }
468
- const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
485
+ const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
469
486
  return Promise.resolve(entry.fn(args as never)).then((made) => {
470
487
  if (made instanceof Node) generated.add(made)
471
488
  })
472
489
  }
473
490
 
474
491
  const attachAspects = (
475
- node: Node, name: string, def: SceneNodeDef,
492
+ node: Node, path: string, def: SceneNodeDef,
476
493
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
477
494
  ): void => {
478
495
  if (isEditMode()) {
@@ -486,17 +503,17 @@ const attachAspects = (
486
503
  const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
487
504
  ;(inst as { node: unknown }).node = node
488
505
  ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
489
- Object.assign(inst, resolveRefs(props, nodes))
506
+ Object.assign(inst, resolveRefs(props, nodes, path))
490
507
  // the HOST node is a dep too: a generator that draws relative to its node (path lines)
491
508
  // must re-run when the node itself is dragged, not only when its ref() targets move
492
- 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) }
493
510
  editorRuns.push(run)
494
511
  safeRebuild(run)
495
512
  })
496
513
  return
497
514
  }
498
515
  for (const entry of def.aspects ?? []) {
499
- const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
516
+ const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
500
517
  const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
501
518
  ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
502
519
  }
@@ -512,15 +529,15 @@ const attachAspects = (
512
529
  // `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
513
530
 
514
531
  /** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
515
- * order, which some hosts reverse) — def names are unique per file, so paths are just the names
516
- * joined. Models inside the prefab drill into their GLB parts, nested prefabs into their own
517
- * 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. */
518
535
  const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
519
536
  const out: ModelPartRow[] = []
520
537
  for (const [ name, nd ] of Object.entries(defs)) {
521
- const node = local[name]
522
- if (!node) continue
523
538
  const path = prefix === "" ? name : `${prefix}/${name}`
539
+ const node = local[path]
540
+ if (!node) continue
524
541
  out.push({ path, name, depth, node })
525
542
  out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
526
543
  const inner = node instanceof Model
@@ -531,16 +548,16 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
531
548
  return out
532
549
  }
533
550
 
534
- 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> => {
535
552
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
536
553
  if (!pdef || typeof pdef !== "object") {
537
- 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)`)
538
555
  return
539
556
  }
540
557
  // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
541
558
  ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
542
559
  if (stack.has(pdef)) {
543
- console.error(`[scene] prefab cycle detected at "${name}" — instance skipped`)
560
+ console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
544
561
  return
545
562
  }
546
563
  const local: Record<string, Node> = {}
@@ -558,26 +575,33 @@ const buildNodes = async (
558
575
  defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
559
576
  nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
560
577
  ): Promise<void> => {
561
- const pending: { name: string, node: Node, def: SceneNodeDef }[] = []
578
+ const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
562
579
 
563
- const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null): Promise<void> => {
564
- 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)
565
589
  if (parentNode) parentNode.add(node)
566
590
  // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
567
591
  scene.add(node)
568
- applyNode(node, name, nd)
592
+ applyNode(node, name, nd) // engine-side name stays the bare sibling segment
569
593
  if (isEditMode()) addEditorMarker(node, nd, scene)
570
- if (nd.prefab !== undefined) await attachPrefab(node, name, nd, scene, stack)
571
- pending.push({ name, node, def: nd })
572
- nodes[name] = node
573
- 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)))
574
598
  }
575
599
 
576
- 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, "")))
577
601
  const makeWaits: Promise<void>[] = []
578
602
  for (const p of pending) {
579
- attachAspects(p.node, p.name, p.def, nodes, scene, editorRuns)
580
- 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)
581
605
  if (wait) makeWaits.push(wait)
582
606
  }
583
607
  if (makeWaits.length > 0) await Promise.all(makeWaits)
@@ -593,7 +617,7 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
593
617
  // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
594
618
  // lens the running app will use.
595
619
  const cam = findCamera(def.nodes)
596
- const camNode = cam ? nodes[cam.name] : undefined
620
+ const camNode = cam ? nodes[cam.path] : undefined
597
621
  if (cam && camNode) {
598
622
  scene.camera.position = camNode.worldPosition
599
623
  scene.camera.quaternion = camNode.worldQuaternion
@@ -617,7 +641,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
617
641
  private _editorRuns: EditorRun[] = []
618
642
  /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
619
643
  private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
620
- /** @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. */
621
647
  private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
622
648
 
623
649
  constructor(def: D) { this.def = def }
@@ -627,7 +653,8 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
627
653
  if (!this._loading) {
628
654
  this._loading = instantiate(this.def, this._editorRuns).then((live) => {
629
655
  this._live = live
630
- 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>
631
658
  })
632
659
  }
633
660
  return this._loading
@@ -642,19 +669,20 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
642
669
 
643
670
  /**
644
671
  * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
645
- * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the named node
646
- * in place (or adds it when the name is new, under `parentName`/root), `def === null` removes it
647
- * with its whole subtree. The def must be PLAIN data — the editor falls back to a full re-run
648
- * 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.
649
677
  *
650
678
  * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
651
679
  * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
652
- * removal. Old GPU resources (geometry/material instances) are not reclaimed until the next full
653
- * 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.
654
682
  */
655
- async _patchNode(name: string, def: SceneNodeDef | null, parentName?: string | null): Promise<Node | null> {
683
+ async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
656
684
  const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
657
- const old: Node | undefined = nodes[name]
685
+ const old: Node | undefined = nodes[path]
658
686
 
659
687
  const dispose = (root: Node): void => {
660
688
  // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
@@ -671,58 +699,141 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
671
699
  }
672
700
 
673
701
  if (def === null) {
674
- if (old) dispose(old)
702
+ if (old) {
703
+ dispose(old)
704
+ this._refreshRunDeps()
705
+ }
675
706
  return null
676
707
  }
677
708
 
678
- const build = async (n: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
679
- 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]
680
713
  if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
681
714
  if (parent) parent.add(existing)
682
715
  return existing
683
716
  }
684
- const node = await createSource(n, d)
717
+ const node = await createSource(p, d)
685
718
  if (parent) parent.add(node)
686
719
  scene.add(node)
687
- applyNode(node, n, d)
720
+ applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
688
721
  if (isEditMode()) addEditorMarker(node, d, scene)
689
- attachAspects(node, n, d, nodes, scene, this._editorRuns)
690
- nodes[n] = node
691
- 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)))
692
725
  return node
693
726
  }
694
727
 
695
- const parent = old ? old.parent : (parentName ? nodes[parentName] ?? null : null)
696
- 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)
697
733
  if (old) {
698
734
  // named children not listed in the def still move over (the editor patches one node at a time)
699
735
  const named = new Set(Object.values(nodes))
700
736
  for (const child of old.children) if (named.has(child)) fresh.add(child)
701
- dispose(old) // fresh already replaced nodes[name] in build(), so it survives
702
- nodes[name] = fresh
737
+ dispose(old) // fresh already replaced nodes[path] in build(), so it survives
738
+ nodes[path] = fresh
703
739
  }
704
740
  // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
705
741
  // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
706
742
  if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
743
+ this._refreshRunDeps()
707
744
  return fresh
708
745
  }
709
746
 
710
- /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
711
- * and re-run rebuild() on every editor-run aspect whose ref() props point at it. A change also
712
- * counts for the node's ANCESTORS: generators read subtrees (FollowPath's waypoints are the
713
- * children of its referenced path node), so dragging a child must rebuild the parent's deps. */
714
- _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 {
715
751
  const nodes = this._live?.nodes
716
752
  if (!nodes) return
717
- const changed = new Set([ name ])
718
- let cur = nodes[name]?.parent ?? null
719
- for (let i = 0; cur && i < 64; i++) {
720
- for (const [ n, node ] of Object.entries(nodes)) if (node === cur) { changed.add(n); break }
721
- 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)
722
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
723
834
  for (const run of this._editorRuns) {
724
835
  let hit = false
725
- 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 }
726
837
  if (!hit) continue
727
838
  assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
728
839
  safeRebuild(run)
@@ -731,22 +842,22 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
731
842
 
732
843
  /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
733
844
  * run (no compile; the factory is already in the bundle). False for non-make nodes. */
734
- _editorSetMakeArg(hostName: string, key: string, value: unknown): boolean {
735
- 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)
736
847
  }
737
848
 
738
849
  /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
739
850
  * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
740
851
  * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
741
- _editorSetProp(hostName: string, index: number, key: string, value: unknown): boolean {
742
- 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)
743
854
  const nodes = this._live?.nodes
744
855
  if (!run || !nodes) return false
745
856
  run.props[key] = value
746
- run.deps = collectRefDeps(run.props)
857
+ run.deps = collectRefDeps(run.props, nodes, run.hostPath)
747
858
  // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
748
859
  // wrapper's transform is editor-owned and moving it never re-calls the factory
749
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostName)
860
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
750
861
  if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
751
862
  else (run.inst as Record<string, unknown>)[key] = value
752
863
  safeRebuild(run)
@@ -767,19 +878,19 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
767
878
  * node instance was replaced by a live patch is rebuilt transparently.
768
879
  */
769
880
  _inspectorRender(
770
- hostName: string, index: number,
881
+ hostPath: string, index: number,
771
882
  props: Record<string, unknown>, event?: InspectorEvent,
772
883
  ): InspectorWidget[] | null {
773
884
  const live = this._live
774
- const node = live?.nodes[hostName]
885
+ const node = live?.nodes[hostPath]
775
886
  const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
776
887
  ?._sceneAspects?.[index]
777
888
  if (!live || !node || !entry) return null
778
889
  const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
779
890
  if (typeof ctor.inspector !== "function") return null
780
891
 
781
- const run = this._editorRuns.find((r) => r.hostName === hostName && r.index === index)
782
- const key = `${hostName}:${index}`
892
+ const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
893
+ const key = `${hostPath}:${index}`
783
894
  let card = this._inspectorCards.get(key)
784
895
  if (!card || card.node !== node) {
785
896
  // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
@@ -791,7 +902,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
791
902
  } else {
792
903
  inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
793
904
  inst.node = node
794
- Object.assign(inst, resolveRefs({ ...props }, live.nodes))
905
+ Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
795
906
  }
796
907
  const ui = card?.ui ?? new InspectorUI()
797
908
  ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
@@ -812,17 +923,17 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
812
923
  a !== b && JSON.stringify(a) !== JSON.stringify(b)
813
924
  if (run) {
814
925
  for (const [ k, v ] of Object.entries(props)) {
815
- 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)
816
927
  }
817
928
  for (const k of Object.keys(run.props)) {
818
929
  if (k in props) continue
819
- this._editorSetProp(hostName, index, k, fieldDefault(k))
930
+ this._editorSetProp(hostPath, index, k, fieldDefault(k))
820
931
  delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
821
932
  }
822
933
  c.ui._props = run.props
823
934
  } else {
824
935
  const snapshot = { ...props }
825
- const resolved = (resolveRefs(snapshot, live.nodes) ?? snapshot) as Record<string, unknown>
936
+ const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
826
937
  for (const f of c.ui._fields) {
827
938
  if (isExpr(resolved[f.key])) continue
828
939
  c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
@@ -833,12 +944,12 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
833
944
  return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
834
945
  }
835
946
 
836
- /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance —
837
- * path/name/depth/live node, in the same part-path grammar `overrides` keys use. Empty for
838
- * plain nodes / unknown names. */
839
- 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[]> {
840
951
  const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
841
- const node = nodes[name]
952
+ const node = nodes[path]
842
953
  if (node instanceof Model) return modelPartRows(node)
843
954
  // prefab instances precompute their rows in DEF order (engine child order isn't stable)
844
955
  return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
@@ -860,8 +971,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
860
971
 
861
972
  /**
862
973
  * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
863
- * `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
864
- * (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')`).
865
977
  */
866
978
  export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
867
979
  const handle = new SceneHandle(def)