// Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
//
// A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
// nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
// PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
// a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
// editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
// node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
//
//   // city.scene.ts
//   export default defineScene({
//     env: { skybox: '#10131a' },
//     nodes: {
//       ground: {
//         mesh: { kind: 'box', size: [20, 1, 20] },
//         material: { lit: { color: '#444444' } },
//         aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
//       },
//     },
//   })
//
//   // main.ts
//   import city from './city.scene'
//   const { scene, nodes } = await city.open()
//
// Aspects are referenced by class — the import IS the registration (typechecked, DCE-safe, zero
// ceremony for user aspects). Behavior never lives in the scene file: write a custom Aspect and
// attach it via `use(...)`.
//
// EDIT MODE (`globalThis.__lecodesSceneEdit`, set by the scene editor before running the bundle):
// sources are instantiated for real so the viewport shows the scene, but aspects are held as data
// (`node._sceneAspects`) WITHOUT attaching — no onAttach side effects (physics bodies, timers), no
// update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.

import { Aspect, type AspectCtor, type With } from "../core/Aspect"
import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
import {
  collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
  type AspectEntry, type MakeEntry as SharedMakeEntry,
} from "./grammar"
import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
import type { ColorInput } from "../core/color"
import type { Vec3Like } from "../math/vec"
import { Scene, type SceneOptions } from "../gl/Scene"
import { CAMERA_DEFAULTS } from "../gl/Camera"
import { Node } from "../gl/Node"
import { Mesh } from "../gl/Mesh"
import { Model } from "../gl/Model"
import { Light, type SunOptions } from "../gl/Light"
import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
import { edgesMesh } from "../gl/scenarios"

// ---- the literal grammar (what the visual editor reads and writes) -----------

export type MeshDef =
  | { kind: "box", size?: Vec3Like | number }
  | ({ kind: "sphere" } & SphereOptions)
  | ({ kind: "cylinder" } & CylinderOptions)
  | ({ kind: "plane" } & PlaneOptions)

export type LightDef = { kind: "sun" } & SunOptions

export type MaterialDef =
  | { lit: LitMaterialOptions }
  | { unlit: MaterialColorOptions }
  | { shadow: ColorInput }
  // An imported/shared Material instance — valid at runtime; the editor shows it read-only.
  | Material

// The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
// re-exported here so this module remains the one import site for scene-file machinery.
export { use, ref, make }
export type { AspectEntry }

/** A `make(fn, args)` source entry whose factory returns a 3D {@link Node}. */
export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = SharedMakeEntry<A, Node>

/** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
 *  (`overrides` on a model/prefab node, keyed by part path). */
export type ModelOverrideDef = {
  position?: Vec3Like
  eulerAngles?: Vec3Like
  scale?: Vec3Like | number
  visible?: boolean
}

/** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
 *  block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
export type CameraProjectionDef = {
  /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
  fov?: number
  /** Near clip distance (default 0.01). */
  near?: number
  /** Far clip distance = view range (default 1000); geometry past it is culled. */
  far?: number
}

/** The `camera: {}` source block — projection settings for the node that drives the view. */
export type CameraNodeDef = CameraProjectionDef

export type SceneNodeDef = {
  // -- source (at most one; none = plain group node) --
  mesh?: MeshDef
  /** GLB url — `asset('./hero.glb')`. */
  model?: string
  light?: LightDef
  /** The scene camera as a NODE: in play mode `scene.camera` follows this node's world transform
   *  every frame (so movement aspects on it are camera flythroughs); the editor shows a frustum
   *  marker and refuses to delete the last camera node. The first camera node in file order wins;
   *  cameras inside prefabs are ignored (like a prefab's `camera:` block). */
  camera?: CameraNodeDef
  /** A code-built subtree — `make(factoryFn, { ...literal args })`. */
  make?: MakeEntry<any>
  /** Another scene file used as a reusable composition — the imported handle:
   *  `import streetlamp from './streetlamp.scene'` … `lamp: { prefab: streetlamp }`. Its nodes
   *  instantiate under this node per instance (env/camera are the instancing file's business and
   *  are ignored); `ref()`s inside the prefab resolve file-locally, per instance. */
  prefab?: SceneHandle<any>
  /** Material for a `mesh` source. */
  material?: MaterialDef
  /** Model/prefab sources: transform overrides for the INTERNAL nodes, keyed by part path
   *  (see the part-path grammar above `modelPartRows`). Unresolved paths are ignored. */
  overrides?: Record<string, ModelOverrideDef>
  /** On a CHILD of a model/prefab node: parent this node to that INTERNAL part of the parent's
   *  asset at build time (part path — the same grammar `overrides` keys use), e.g. a flashlight
   *  in a hand. The transform stays local to the part. A stale path (asset changed) falls back
   *  to the parent root with a console warning. */
  mount?: string
  // -- transform / render --
  position?: Vec3Like
  eulerAngles?: Vec3Like
  scale?: Vec3Like | number
  visible?: boolean
  /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
  locked?: boolean
  castShadows?: boolean
  receiveShadows?: boolean
  // -- capabilities / hierarchy --
  aspects?: readonly AspectEntry<any>[]
  children?: Record<string, SceneNodeDef>
}

export type SceneCameraDef = CameraProjectionDef & {
  position?: Vec3Like
  /** Point the camera looks at. */
  target?: Vec3Like
}

export type SceneDef = {
  env?: SceneOptions
  camera?: SceneCameraDef
  nodes?: Record<string, SceneNodeDef>
}

// ---- typed handle -------------------------------------------------------------

type SourceNodeOf<N extends SceneNodeDef> =
  N extends { model: string } ? Model
  : N extends { mesh: MeshDef } ? Mesh
  : N extends { light: LightDef } ? Light
  : Node

type AspectsOf<N extends SceneNodeDef> =
  N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never

type NodeOf<N extends SceneNodeDef> =
  [AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>

type UnionToIntersection<U> =
  (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never

// The child maps of a def level, prefixed with their parent's path, as a union (never when no
// node has children — guarded below, since `unknown` would absorb the union and `never` would
// poison the intersection).
type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
  { [K in keyof T & string]:
      T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
  }[keyof T & string]

// All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
// its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
  { [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
  ([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)

export type SceneNodes<D extends SceneDef> =
  D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>

export type LoadedScene<D extends SceneDef> = {
  scene: Scene
  nodes: SceneNodes<D>
  /** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
  get: {
    <P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
    (path: string): Node | null
  }
}

// ---- GLB internal parts --------------------------------------------------------
// A model's internal hierarchy is addressed by PART PATHS — '/'-joined segments from the model
// root down, where a segment is the child's name, disambiguated as `name[i]` among same-named
// siblings and `[i]` for unnamed children (i = index within that same-named group). The editor
// enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
// loader resolves them back through the same enumeration, so writer and resolver can't drift.

/** One INTERNAL node of a loaded GLB (editor introspection). */
export type ModelPartRow = { path: string, name: string, depth: number, node: Node }

const partSegment = (child: Node, siblings: Node[]): string => {
  const name = child.name ?? ""
  const group = siblings.filter((s) => (s.name ?? "") === name)
  if (name !== "" && group.length === 1) return name
  return `${name}[${group.indexOf(child)}]`
}

/** Flatten a model's (or prefab instance's) internal hierarchy to rows (depth-first, the root
 *  excluded). `__generated` containers are derived output, and def-built nodes (`_sceneDef` —
 *  plain or `mount`ed children of the model) are addressed by their own def paths — both are
 *  not parts, skipped. Filtering BEFORE segment math keeps `name[i]` indices stable no matter
 *  what defs are parented in. */
export const modelPartRows = (root: Node): ModelPartRow[] => {
  const out: ModelPartRow[] = []
  const walk = (node: Node, prefix: string, depth: number): void => {
    const children = node.children.filter((c) =>
      c.name !== "__generated" && !(c as { _sceneDef?: boolean })._sceneDef)
    for (const child of children) {
      const seg = partSegment(child, children)
      const path = prefix === "" ? seg : `${prefix}/${seg}`
      out.push({ path, name: child.name || seg, depth, node: child })
      walk(child, path, depth + 1)
    }
  }
  walk(root, "", 0)
  return out
}

const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, ModelOverrideDef>): void => {
  const byPath = new Map(rows.map((r) => [ r.path, r.node ]))
  for (const [ path, o ] of Object.entries(overrides)) {
    const node = byPath.get(path)
    if (!node) continue   // the source changed since the override was written — skip, don't throw
    if (o.position) node.position = o.position
    if (o.eulerAngles) node.eulerAngles = o.eulerAngles
    if (o.scale !== undefined) node.scale = o.scale
    if (o.visible !== undefined) node.visible = o.visible
  }
}

const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverrideDef>): void =>
  applyOverridesToRows(modelPartRows(root), overrides)

/** Resolve a def's `mount` part path against its parent's internal rows (the same enumeration
 *  `overrides` resolves through). Null when the parent carries no parts at all — a rebuild of an
 *  already-mounted child passes its live part parent here, which is not an error — while a KNOWN
 *  part list that misses the path (asset changed) warns, mirroring the overrides skip rule. */
const resolveMountNode = (parent: Node, mountPath: string, ownerPath: string): Node | null => {
  const rows = parent instanceof Model
    ? modelPartRows(parent)
    : (parent as { _prefabParts?: ModelPartRow[] })._prefabParts
  if (!rows || rows.length === 0) return null
  const hit = rows.find((r) => r.path === mountPath)
  if (!hit) console.warn(`[scene] "${ownerPath}".mount = "${mountPath}" matches no part — attached to the parent root`)
  return hit?.node ?? null
}

/** Where a def node attaches: its `mount` part when it resolves, else the parent itself. */
const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
  def.mount !== undefined ? resolveMountNode(parent, def.mount, path) ?? parent : parent

// ---- runtime -------------------------------------------------------------------

const resolveMaterial = (def: MaterialDef): Material => {
  if (def instanceof Material) return def
  if ("lit" in def) return Material.lit(def.lit)
  if ("unlit" in def) return Material.unlit(def.unlit)
  return Material.shadow(def.shadow)
}

const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
  const mat = material !== undefined ? resolveMaterial(material) : undefined
  switch (def.kind) {
    case "box": return Mesh.box({ size: def.size, material: mat })
    case "sphere": { const { kind: _k, ...opts } = def; return Mesh.sphere({ ...opts, material: mat }) }
    case "cylinder": { const { kind: _k, ...opts } = def; return Mesh.cylinder({ ...opts, material: mat }) }
    case "plane": { const { kind: _k, ...opts } = def; return Mesh.plane({ ...opts, material: mat }) }
  }
}

const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
  if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
  if (def.model !== undefined) return Model.load(def.model)
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
  // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
  // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
  return new Node()
}

const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
  node.name = name
  // def-built marker: part enumeration must skip this node (it is not asset-internal — it has
  // its own def path), even when it is parented inside a model via `mount`
  ;(node as { _sceneDef?: boolean })._sceneDef = true
  if (def.position) node.position = def.position
  if (def.eulerAngles) node.eulerAngles = def.eulerAngles
  if (def.scale !== undefined) node.scale = def.scale
  if (def.visible !== undefined) node.visible = def.visible
  // editor-only flag (the harness's manipulation layer consults it); inert at runtime
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
  // order is insertion-based and may not match the file
  if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
  if (node instanceof Mesh) {
    if (def.castShadows !== undefined) node.castShadows = def.castShadows
    if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
  }
  if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
}

// ---- edit-mode node markers + the play-mode camera rig -------------------------------------------

/** True for a def with no source block at all — a plain group ("Empty" in the editor). */
const isGroupDef = (def: SceneNodeDef): boolean =>
  def.mesh === undefined && def.model === undefined && def.light === undefined
  && def.make === undefined && def.prefab === undefined && def.camera === undefined

/** Edit mode: give otherwise-invisible nodes (empties, cameras) an `__editor_marker` line-mesh
 *  child so they render and pick in the viewport. The harness reads `_sceneMarker` to give the
 *  node itself a box collider (markers are editor nodes — never raycast/placement targets). */
const addEditorMarker = (node: Node, def: SceneNodeDef, scene: Scene): void => {
  let marker: Mesh
  if (def.camera !== undefined) {
    // wireframe frustum looking down local -Z + an "up" fin above the near rect
    const z = -0.55, w = 0.3, h = 0.21
    const segs: number[] = []
    for (const [ cx, cy ] of [ [ -w, -h ], [ w, -h ], [ w, h ], [ -w, h ] ] as const) segs.push(0, 0, 0, cx, cy, z)
    segs.push(-w, -h, z, w, -h, z, w, -h, z, w, h, z, w, h, z, -w, h, z, -w, h, z, -w, -h, z)
    segs.push(-0.12, h, z, 0, h + 0.14, z, 0, h + 0.14, z, 0.12, h, z)
    marker = edgesMesh(segs, "#cfd4dd")
    ;(node as { _sceneMarker?: string })._sceneMarker = "camera"
  } else if (isGroupDef(def)) {
    const s = 0.3
    marker = edgesMesh([ -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s ], "#8f96a3")
    ;(node as { _sceneMarker?: string })._sceneMarker = "empty"
  } else {
    return
  }
  marker.name = "__editor_marker"
  node.add(marker)
  scene.add(marker)
}

/** Play mode: `scene.camera` follows the camera node's world transform every frame (LATE phase,
 *  after every other aspect has moved things — order 1000). Attached by `instantiate`, internal. */
class CameraRig extends Aspect<"__cameraRig"> {
  static readonly aspect = "__cameraRig"
  /** @internal */ _scene!: Scene
  constructor() { super(); this.order = 1000 }
  update(): void {
    const cam = this._scene.camera
    cam.position = this.node.worldPosition
    cam.quaternion = this.node.worldQuaternion
  }
}

/** The first camera-source def in file order (depth-first) — its path and block — or null. */
const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
  for (const [ name, nd ] of Object.entries(defs ?? {})) {
    const path = prefix === "" ? name : `${prefix}/${name}`
    if (nd.camera !== undefined) return { path, def: nd.camera }
    const inner = findCamera(nd.children, path)
    if (inner) return inner
  }
  return null
}

/** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
 *  alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
 *  omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
  const { fov, near, far } = def
  if (!reset && fov === undefined && near === undefined && far === undefined) return
  scene.camera.setProjection(reset
    ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
    : { fov, near, far })
}

// ---- editor-run aspects (generators) ------------------------------------------------------------
// A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
// constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
// editor re-runs rebuild() on inspector prop edits (`_editorSetProp`) and whenever a node its
// ref() fields point at changes (`_editorNodeChanged` — dep tracking is derived from the props).
// Generated output lives under `this.generated`, a scene-added container child: never written to
// the file, absent from the doc tree — the file stores the recipe, the viewport shows the result.

const isEditorCtor = (ctor: unknown): boolean => !!(ctor as { editor?: unknown }).editor

/** The `generated` container: children join the scene's draw set on add (membership is separate
 *  from parenting — `addEntityToScene` only recurses over children that exist at add time). */
class GeneratedGroup extends Node {
  /** @internal */ _scene!: Scene
  add(...children: Node[]): this {
    super.add(...children)
    this._scene.add(...children)
    return this
  }
  /** Destroy all generated children (rebuild() calls this first — idempotent regeneration). */
  clear(): this {
    for (const c of [ ...this.children ]) {
      this._scene.remove(c)
      c.destroy()
    }
    return this
  }
}

const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
  const group = new GeneratedGroup()
  group.name = "__generated"
  group._scene = scene
  node.add(group)
  scene.add(group)
  return group
}

/** One live editor-run aspect instance (edit mode only). */
type EditorRun = {
  /** Absolute path of the host def node (re-keyed on rename/reparent). */
  hostPath: string
  node: Node
  /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
  index: number
  inst: { rebuild?(): void }
  /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
   *  DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
   *  against the file's props, so rewriting them would re-fire on every render. */
  props: Record<string, unknown>
  /** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
   *  their subtrees) re-runs rebuild(). Re-derived after every structural change. */
  deps: Set<string>
}

const safeRebuild = (run: EditorRun): void => {
  // a throwing generator must not take the editor session down with it
  try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
}

/** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
 *  instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
  const lookup = (r: string): Node | null => {
    const p = resolveRefPath(nodes, run.hostPath, r)
    return p === null ? null : nodes[p]
  }
  for (const [ k, v ] of Object.entries(run.props)) {
    if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
    else if (Array.isArray(v) && v.some(isNodeRef)) {
      ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
    }
  }
}

// ---- make() sources (code-built subtrees) -------------------------------------------------------
// The factory runs in phase 2 (every node exists → ref() args resolve) and its result mounts in a
// `generated` container under the def-node wrapper — the wrapper's transform is editor-owned, so
// moving a make node never re-calls the factory. In edit mode the run is tracked as an EditorRun
// (index MAKE_INDEX): arg edits and ref-dep changes re-call the factory through the exact same
// machinery generator aspects use — live, no compile (the code is already in the bundle).

/** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
const MAKE_INDEX = -1

const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
  let token = 0
  const inst: Record<string, unknown> = {}
  inst.rebuild = () => {
    const t = ++token
    generated.clear()
    // current args = the instance's own fields (that's where _editorSetProp/assignRefProps write)
    const args: Record<string, unknown> = {}
    for (const [ k, v ] of Object.entries(inst)) if (k !== "rebuild") args[k] = v
    const result = entry.fn(args as never)
    if (result instanceof Node) { generated.add(result); return }
    void Promise.resolve(result).then((made) => {
      // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
      if (t === token && made instanceof Node) generated.add(made)
    }).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
  }
  return inst as unknown as { rebuild(): void }
}

/** Run a def's make() factory (phase 2). Play mode returns the factory's completion — the loader
 *  awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
 *  and returns immediately (async content pops in when ready, like a model load). */
const attachMake = (
  node: Node, path: string, def: SceneNodeDef,
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
): Promise<void> | undefined => {
  const entry = def.make
  if (!entry) return undefined
  const generated = makeGenerated(node, scene)
  if (isEditMode()) {
    const props = { ...(entry.args ?? {}) } as Record<string, unknown>
    const inst = createMakeInst(path, entry, generated)
    Object.assign(inst, resolveRefs(props, nodes, path))
    const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
    editorRuns.push(run)
    safeRebuild(run)
    return undefined
  }
  const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
  return Promise.resolve(entry.fn(args as never)).then((made) => {
    if (made instanceof Node) generated.add(made)
  })
}

const attachAspects = (
  node: Node, path: string, def: SceneNodeDef,
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
): void => {
  if (isEditMode()) {
    // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
    // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
    // EXCEPT editor-run classes (generators) — they get a real, tracked instance (above).
    ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
    ;(def.aspects ?? []).forEach((entry, index) => {
      if (!isEditorCtor(entry.ctor)) return
      const props = { ...(entry.props ?? {}) } as Record<string, unknown>
      const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
      ;(inst as { node: unknown }).node = node
      ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
      Object.assign(inst, resolveRefs(props, nodes, path))
      // the HOST node is a dep too: a generator that draws relative to its node (path lines)
      // must re-run when the node itself is dragged, not only when its ref() targets move
      const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
      editorRuns.push(run)
      safeRebuild(run)
    })
    return
  }
  for (const entry of def.aspects ?? []) {
    const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
    const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
    ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
  }
}

// ---- prefabs (scene-in-scene) --------------------------------------------------------------------
// A `prefab:` node instantiates another scene file's nodes under a plain wrapper — per instance,
// from the imported handle's DEF (never handle.load(): that would share one singleton instance).
// Each instance gets its own LOCAL node map, so `ref()`s inside the prefab resolve file-locally
// and instances never collide in the parent's flat map. The instance's aspects attach normally in
// play mode; in edit mode they stay data-only like everything else, and its generators / make()
// factories run ONCE for display but are NOT tracked for editing (internals are posed through
// `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.

/** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
 *  order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
 *  the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
 *  their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
  const out: ModelPartRow[] = []
  for (const [ name, nd ] of Object.entries(defs)) {
    const path = prefix === "" ? name : `${prefix}/${name}`
    const node = local[path]
    if (!node) continue
    out.push({ path, name, depth, node })
    out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
    const inner = node instanceof Model
      ? modelPartRows(node)
      : (node as { _prefabParts?: ModelPartRow[] })._prefabParts
    if (inner) out.push(...inner.map((r) => ({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })))
  }
  return out
}

const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
  if (!pdef || typeof pdef !== "object") {
    console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
    return
  }
  // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
  ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
  if (stack.has(pdef)) {
    console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
    return
  }
  const local: Record<string, Node> = {}
  const localRuns: EditorRun[] = []   // discarded: instance internals render, but aren't editor-tracked
  await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef))
  const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
  ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
  if (def.overrides) applyOverridesToRows(rows, def.overrides)
}

/** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
 *  aspects/make — so `ref()`s resolve regardless of declaration order), into the given flat node
 *  map. The top-level scene and every prefab instance run through here, each with its own map. */
const buildNodes = async (
  defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
  nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
): Promise<void> => {
  const pending: { path: string, node: Node, def: SceneNodeDef }[] = []

  const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
    // names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
    if (name === "" || name.includes("/") || name.includes(":")) {
      throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
    }
    // the path is derived from def keys BEFORE any await, so it is deterministic even though
    // Promise.all makes build completion (and record insertion) order nondeterministic
    const path = parentPath === "" ? name : `${parentPath}/${name}`
    const node = await createSource(path, nd)
    // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
    // model was awaited and attachPrefab completed before its children build
    if (parentNode) attachHost(parentNode, nd, path).add(node)
    // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
    scene.add(node)
    applyNode(node, name, nd)   // engine-side name stays the bare sibling segment
    if (isEditMode()) addEditorMarker(node, nd, scene)
    if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
    pending.push({ path, node, def: nd })
    nodes[path] = node
    await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
  }

  await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
  const makeWaits: Promise<void>[] = []
  for (const p of pending) {
    attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
    const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
    if (wait) makeWaits.push(wait)
  }
  if (makeWaits.length > 0) await Promise.all(makeWaits)
}

const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
  const scene = new Scene(def.env)
  const nodes: Record<string, Node> = {}
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))

  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
  // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
  // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
  // lens the running app will use.
  const cam = findCamera(def.nodes)
  const camNode = cam ? nodes[cam.path] : undefined
  if (cam && camNode) {
    scene.camera.position = camNode.worldPosition
    scene.camera.quaternion = camNode.worldQuaternion
    applyCameraProjection(scene, cam.def)
    if (!isEditMode()) {
      ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
    }
  } else if (def.camera) {
    if (def.camera.position) scene.camera.position = def.camera.position
    if (def.camera.target) scene.camera.lookAt(def.camera.target)
    applyCameraProjection(scene, def.camera)
  }

  return { scene, nodes }
}

export class SceneHandle<D extends SceneDef = SceneDef> {
  readonly def: D
  private _loading?: Promise<LoadedScene<D>>
  /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). */
  private _editorRuns: EditorRun[] = []
  /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
  private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
  /** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
   *  load resolves). The record object is SHARED with the returned LoadedScene — patches and
   *  rename/reparent re-keying are visible through both. */
  private _live: { scene: Scene, nodes: Record<string, Node> } | null = null

  constructor(def: D) { this.def = def }

  /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
  load(): Promise<LoadedScene<D>> {
    if (!this._loading) {
      this._loading = instantiate(this.def, this._editorRuns).then((live) => {
        this._live = live
        const get = (path: string): Node | null => live.nodes[path] ?? null
        return { ...live, get } as unknown as LoadedScene<D>
      })
    }
    return this._loading
  }

  /** Load and make active. */
  async open(): Promise<LoadedScene<D>> {
    const loaded = await this.load()
    loaded.scene.open()
    return loaded
  }

  /**
   * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
   * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
   * `path` in place (or adds it when the path is new — the path's parent must exist, root paths
   * mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
   * data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
   * are inert in edit mode and stay out of defs.
   *
   * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
   * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
   * removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
   * are not reclaimed until the next full re-run — acceptable churn for an edit session.
   */
  async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
    const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
    const old: Node | undefined = nodes[path]

    const dispose = (root: Node): void => {
      // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
      // no removeCollider — a destroyed entity's stale collider entry must never pick again).
      root.visible = false
      const doomed = new Set<Node>()
      root.traverse((n) => doomed.add(n))
      for (const [ key, n ] of Object.entries(nodes)) if (doomed.has(n)) delete nodes[key]
      // editor-run aspect instances hosted in the doomed subtree go with it (generated children
      // are subtree children, so the destroy below reclaims them too)
      this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
      scene.remove(root)
      root.destroy()   // native destroyEntity recurses over remaining children
    }

    if (def === null) {
      if (old) {
        dispose(old)
        this._refreshRunDeps()
      }
      return null
    }

    const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
      // child reuse is by FULL path — only the live subtree at this exact address is carried
      // over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
      const existing = p === path ? undefined : nodes[p]
      if (existing) {   // an already-live child subtree — keep it, just re-parent (local transform stays)
        if (parent) attachHost(parent, d, p).add(existing)
        return existing
      }
      const node = await createSource(p, d)
      if (parent) attachHost(parent, d, p).add(node)
      scene.add(node)
      applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
      if (isEditMode()) addEditorMarker(node, d, scene)
      attachAspects(node, p, d, nodes, scene, this._editorRuns)
      nodes[p] = node
      await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
      return node
    }

    const cut = path.lastIndexOf("/")
    const parentPath = cut < 0 ? null : path.slice(0, cut)
    if (!old && parentPath !== null && !nodes[parentPath]) return null
    const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
    const fresh = await build(path, def, parent)
    if (old) {
      // named children not listed in the def still move over (the editor patches one node at a time)
      const named = new Set(Object.values(nodes))
      for (const child of old.children) if (named.has(child)) fresh.add(child)
      dispose(old)               // fresh already replaced nodes[path] in build(), so it survives
      nodes[path] = fresh
    }
    // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
    // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
    if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
    this._refreshRunDeps()
    return fresh
  }

  /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
   *  absolute paths, so any structural change can invalidate them: an add can satisfy a
   *  previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
  private _refreshRunDeps(): void {
    const nodes = this._live?.nodes
    if (!nodes) return
    for (const run of this._editorRuns) {
      run.deps = collectRefDeps(run.props, nodes, run.hostPath)
      // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
      // wrapper's transform is editor-owned and moving it never re-calls the factory
      if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
    }
  }

  /** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
   *  runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
   *  the host — mutation, not replacement. */
  private _rekey(oldPath: string, newPath: string): void {
    const nodes = this._live!.nodes
    const move = (key: string): string | null =>
      key === oldPath ? newPath
      : key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
      : null
    for (const key of Object.keys(nodes)) {
      const next = move(key)
      if (next === null) continue
      const n = nodes[key]
      delete nodes[key]
      nodes[next] = n
    }
    for (const run of this._editorRuns) {
      const next = move(run.hostPath)
      if (next !== null) run.hostPath = next
    }
    for (const [ key, card ] of [ ...this._inspectorCards ]) {
      const i = key.lastIndexOf(":")
      const next = move(key.slice(0, i))
      if (next === null) continue
      this._inspectorCards.delete(key)
      this._inspectorCards.set(next + key.slice(i), card)
    }
    this._refreshRunDeps()
  }

  /** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
   *  Owns the shared record's re-keying (the host re-keys only its own part/selection state).
   *  Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
  _renameNode(path: string, newName: string): string | null {
    const nodes = this._live?.nodes
    const node = nodes?.[path]
    if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
    const cut = path.lastIndexOf("/")
    const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
    if (newPath === path) return path
    if (nodes[newPath]) return null
    this._rekey(path, newPath)
    node.name = newName   // engine-side name stays the bare segment
    return newPath
  }

  /** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
   *  every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
   *  cycle, name taken among the new siblings). */
  _reparentNode(path: string, newParentPath: string | null): string | null {
    const nodes = this._live?.nodes
    const node = nodes?.[path]
    if (!nodes || !node) return null
    const parent = newParentPath === null ? null : nodes[newParentPath]
    if (newParentPath !== null && !parent) return null
    if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
    const name = path.slice(path.lastIndexOf("/") + 1)
    const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
    if (newPath === path) return path
    if (nodes[newPath]) return null
    this._rekey(path, newPath)
    node.setParent(parent ?? null, false)
    return newPath
  }

  /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
   *  and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
   *  absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
   *  FollowPath's waypoints are the children of its referenced path node) is a prefix test:
   *  a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
  _editorNodeChanged(path: string): void {
    const nodes = this._live?.nodes
    if (!nodes) return
    for (const run of this._editorRuns) {
      let hit = false
      for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
      if (!hit) continue
      assignRefProps(run, nodes)   // a patch may have replaced the referenced node instance
      safeRebuild(run)
    }
  }

  /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
   *  run (no compile; the factory is already in the bundle). False for non-make nodes. */
  _editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
    return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
  }

  /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
   *  on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
   *  its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
  _editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
    const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
    const nodes = this._live?.nodes
    if (!run || !nodes) return false
    run.props[key] = value
    run.deps = collectRefDeps(run.props, nodes, run.hostPath)
    // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
    // wrapper's transform is editor-owned and moving it never re-calls the factory
    if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
    if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
    else (run.inst as Record<string, unknown>)[key] = value
    safeRebuild(run)
    return true
  }

  /**
   * @internal Editor: run an aspect's custom `static inspector` card (immediate-mode — see
   * core/InspectorUI.ts) and return its widget list. Null when the class has no inspector (the
   * editor falls back to the inferred fields).
   *
   * `props` is the entry's CURRENT doc props, passed on EVERY call — the world syncs its side
   * from it (a generator syncs through the `_editorSetProp` machinery, so an undo that changes a
   * prop rebuilds for free; a plain aspect's preview instance is reassigned). `event` carries only
   * buttons and editor-state field edits — doc-bound field edits arrive as changed `props`.
   *
   * Cards persist per `<host>:<index>` across calls (that's where `ui.state` lives); a card whose
   * node instance was replaced by a live patch is rebuilt transparently.
   */
  _inspectorRender(
    hostPath: string, index: number,
    props: Record<string, unknown>, event?: InspectorEvent,
  ): InspectorWidget[] | null {
    const live = this._live
    const node = live?.nodes[hostPath]
    const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
      ?._sceneAspects?.[index]
    if (!live || !node || !entry) return null
    const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
    if (typeof ctor.inspector !== "function") return null

    const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
    const key = `${hostPath}:${index}`
    let card = this._inspectorCards.get(key)
    if (!card || card.node !== node) {
      // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
      // preview instance: node set, refs resolved, NEVER onAttach (edit mode stays side-effect
      // free). Editor state (ui._state) survives a node patch by carrying the old ui over.
      let inst: Record<string, unknown>
      if (run) {
        inst = run.inst as unknown as Record<string, unknown>
      } else {
        inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
        inst.node = node
        Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
      }
      const ui = card?.ui ?? new InspectorUI()
      ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
      ui._docKeys = new Set(ui._fields.map((f) => f.key))
      card = { ui, inst, node }
      this._inspectorCards.set(key, card)
    }

    // Sync the doc props into the world side. A key REMOVED from the doc (undo past its first
    // edit) resets to the class-field default — otherwise the instance would keep the stale value.
    const c = card
    const fieldDefault = (k: string): unknown => c.ui._fields.find((f) => f.key === k)?.value
    // `$expr` markers (values set in code) never sync into instances — the widget shows them
    // read-only; the instance keeps the compile-time evaluation.
    const isExpr = (v: unknown): boolean =>
      typeof v === "object" && v !== null && typeof (v as { $expr?: unknown }).$expr === "string"
    const changed = (a: unknown, b: unknown): boolean =>
      a !== b && JSON.stringify(a) !== JSON.stringify(b)
    if (run) {
      for (const [ k, v ] of Object.entries(props)) {
        if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
      }
      for (const k of Object.keys(run.props)) {
        if (k in props) continue
        this._editorSetProp(hostPath, index, k, fieldDefault(k))
        delete run.props[k]   // keep run.props mirroring the doc, or this reset re-fires every call
      }
      c.ui._props = run.props
    } else {
      const snapshot = { ...props }
      const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
      for (const f of c.ui._fields) {
        if (isExpr(resolved[f.key])) continue
        c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
      }
      c.ui._props = snapshot
    }

    return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
  }

  /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
   *  absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
   *  `overrides` keys use. Empty for plain nodes / unknown paths. */
  async _modelParts(path: string): Promise<ModelPartRow[]> {
    const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
    const node = nodes[path]
    if (node instanceof Model) return modelPartRows(node)
    // prefab instances precompute their rows in DEF order (engine child order isn't stable)
    return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
  }

  /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
  _describeAspects(): AspectClassInfo[] {
    const ctors = new Set<AspectCtor<any>>()
    const walk = (defs?: Record<string, SceneNodeDef>): void => {
      for (const nd of Object.values(defs ?? {})) {
        for (const e of nd.aspects ?? []) ctors.add(e.ctor)
        walk(nd.children)
      }
    }
    walk(this.def.nodes)
    return [ ...ctors ].map((c) => describeAspect(c))
  }
}

/**
 * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
 * `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
 * block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
 * plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
 */
export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
  const handle = new SceneHandle(def)
  const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
  // Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
  // up the handle to load it in edit mode and drive the inspector).
  if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
  return handle
}
