// 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 { Vec3Like } from "../math/vec"
import { Scene, type SceneOptions } from "../gl/Scene"
import { CAMERA_DEFAULTS } from "../gl/Camera"
import { CameraPlace } from "../gl/CameraPlace"
import { Node } from "../gl/Node"
import { Mesh } from "../gl/Mesh"
import { Model } from "../gl/Model"
import { Physics } from "../gl/Physics"
import { Lightmap } from "../gl/Lightmap"
import { NavMesh } from "../gl/NavMesh"
import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/Terrain"
import { Light, type SunOptions } from "../gl/Light"
import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"

// ---- 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

/** A stamp: the terrain conforms to another node of this file (a road) at load — see Terrain.conform. */
export type TerrainStampDef = {
  /** Path of the node (a Mesh, a Model, or a node holding them) the ground hugs. */
  node: string
  offset?: number
  falloff?: number
  mode?: "both" | "lower" | "raise"
}

/** The `terrain: {}` source block (docs/terrain-plan.md §3.3): a `.terrain` file OR a flat grid to
 *  generate from, its layers, and the stamps / holes applied at load (in order, after every node of the
 *  file exists — a road built by `make()` is a valid target). Add `Shape { heightfield: true }` +
 *  `Physics { motion: 'static' }` in `aspects` for collision. */
export type TerrainNodeDef = {
  /** `asset('./assets/terrain/valley.terrain')` — heights / holes / control from a file. */
  data?: string
  size?: number
  sizeX?: number
  sizeZ?: number
  cellSize?: number
  chunk?: number
  layers?: TerrainLayer[]
  normals?: TerrainOptions["normals"]
  tint?: TerrainOptions["tint"]
  stamps?: TerrainStampDef[]
  holes?: TerrainRegion[]
}

// The material grammar (`{ lit }` / `{ unlit }` / `{ shadow }` / `{ shader, params }` / a
// material asset handle / a code instance) lives in ./material.ts — re-exported for callers.
export type { MaterialDef }

// 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
  /** Materials by primitive SLOT of this part (`0` for a single-material mesh; the editor lists
   *  the slots with their glTF material names): a material asset, an inline def, or a custom
   *  shader. Slots left out keep the glTF material. */
  materials?: Record<number | string, MaterialDef>
}

/** 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
  /** A heightmap ground — see {@link TerrainNodeDef}. */
  terrain?: TerrainNodeDef
  /** 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
  /** Editor-only, `model` nodes: the POSE the scene editor shows — a clip looped while editing
   *  (`time` freezes it at that second instead), so attachments / sight lines / a first-person eye
   *  are placed against the animated pose, not the rest pose. Never applied when the scene runs. */
  editor?: { clip?: string, time?: number }
  /** `mesh` / `model` nodes: real-time shadow flags. On a `model` they reach every renderable of
   *  the GLB instance, so `castShadows: false` on a first-person viewmodel covers the whole gun. */
  castShadows?: boolean
  receiveShadows?: boolean
  /** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
   *  receiver and an occluder in the bake, real-time shadow casting off once the bake applies?
   *  Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
   *  `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
  lightmap?: boolean
  /** Navigation (a scene with `env.navmesh`, see navmesh.md): every STATIC body is walkable by
   *  default. `false` leaves this one out of the bake; `'unwalkable'` cuts its footprint out
   *  (nobody stands on or crosses it). */
  nav?: false | "unwalkable"
  /** The named navmesh area this static body's surface belongs to (a key of `env.navmesh.areas`). */
  navArea?: string
  // -- capabilities / hierarchy --
  aspects?: readonly AspectEntry<any>[]
  children?: Record<string, SceneNodeDef>
}

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

/** `env.lightmap` — the level's baked lighting (see lightmap.md): the two files
 *  `lecodes lightmap bake` writes, plus how the bake is applied. Absent = real-time only. */
export type SceneLightmapDef = {
  /** `asset('./assets/lightmap/lightmap.bake')` */
  data: string
  /** `asset('./assets/lightmap/lightmap.ktx2')` */
  texture: string
  /** `asset('./assets/lightmap/lightmap.volume')` — the light VOLUME for everything that moves (the
   *  same bake writes it): dynamic nodes, and every model loaded in code while the level runs, read
   *  the baked sun/ambient at their own position. Absent = movers stay real-time only. */
  volume?: string
  /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
  sunStrength?: number
  /** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
  ambientScale?: number
}

/** `env.navmesh` — the level's navigation mesh (see navmesh.md): the file `lecodes navmesh bake`
 *  writes, the agent size it is built for, and the named areas. Absent = no navigation. */
export type SceneNavmeshDef = {
  /** `asset('./assets/nav/<scene>.navmesh')` */
  data: string
  /** The agent the mesh is built for (radius 0.4 · height 1.8 · climb 0.4 · slope 50 by default). */
  agent?: { radius?: number, height?: number, climb?: number, slope?: number }
  /** Named areas → traversal cost (1 = plain ground); nodes join one with `navArea`. */
  areas?: Record<string, number>
}

export type SceneDef = {
  env?: SceneOptions & { lightmap?: SceneLightmapDef, navmesh?: SceneNavmeshDef }
  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 instance of a scene file built as a subtree (`handle.instantiate`). */
export type SceneInstance<D extends SceneDef> = {
  /** The wrapper node the file's nodes build under — position it, parent it, hide it. */
  root: Node
  nodes: SceneNodes<D>
  /** Anchor the instance on one of its own nodes: `root`'s local transform is set so that
   *  `inner` coincides with the frame `root` is parented to (its origin and axes). One-shot,
   *  from the CURRENT pose of `inner` — a rig's attachment frame (the eye place of a
   *  first-person arms scene, the grip of a held prop). */
  alignTo(inner: Node): void
  get: LoadedScene<D>["get"]
  /** Remove the subtree from the scene and destroy it. */
  dispose(): void
}

/** 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
    // slot materials load in like textures do (a shader package fetch) — the part keeps its glTF
    // material until then
    if (o.materials) {
      for (const [ slot, def ] of Object.entries(o.materials)) {
        const index = Number(slot)
        if (!Number.isInteger(index) || index < 0) continue
        void assignMaterialDef(node, index, def).catch((e) => {
          console.warn(`[scene] material for "${path}" slot ${index}: ${e instanceof Error ? e.message : String(e)}`)
        })
      }
    }
  }
}

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 -------------------------------------------------------------------

/** A mesh node resolves its material BEFORE it appears (a custom shader fetches its compiled
 *  package, textures decode) — the same contract a model has with its GLB. Handle users are
 *  tracked so the editor can re-assign them when the asset's shader changes. */
const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> => {
  const mat = material !== undefined ? await resolveMaterialDef(material) : undefined
  let mesh: Mesh
  switch (def.kind) {
    case "box": mesh = Mesh.box({ size: def.size, material: mat }); break
    case "sphere": { const { kind: _k, ...opts } = def; mesh = Mesh.sphere({ ...opts, material: mat }); break }
    case "cylinder": { const { kind: _k, ...opts } = def; mesh = Mesh.cylinder({ ...opts, material: mat }); break }
    case "plane": { const { kind: _k, ...opts } = def; mesh = Mesh.plane({ ...opts, material: mat }); break }
  }
  if (material instanceof MaterialHandle) material._users.add({ node: mesh, slot: 0 })
  return mesh
}

// ---- baked lighting (env.lightmap) ---------------------------------------------------------------
// Statics are derived, not declared: a model/mesh node bakes unless a Physics aspect moves it
// (`dynamic` — Physics' default — or `kinematic`); `lightmap: true | false` on the node overrides.
// Keys are node paths (prefab internals: `wrapper/inner`), so lightmap.bake stays readable and a
// rename honestly invalidates the rect. make() subtrees are code — they register themselves with
// Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).

type LightmapCtx = { prefix: string, volume: boolean, dynamicOnly?: boolean }

const movesByPhysics = (def: SceneNodeDef): boolean =>
  (def.aspects ?? []).some((e) =>
    (e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")

/** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule). */
export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)

// `lightmap` undefined = Model.load's own default ('dynamic' while a level with a light volume runs —
// this is how a weapon or arms scene instantiated from code reads the volume; an explicit false would win)
/** A terrain node: from its file or a fresh flat grid; textures load before the node appears (like a
 *  mesh's material). Stamps and holes apply later in buildNodes, once every target node exists. */
const createTerrain = async (def: TerrainNodeDef): Promise<Node> => {
  const { data, stamps: _s, holes: _h, ...opts } = def
  const t = data !== undefined ? await Terrain.load(data, opts) : Terrain.create(opts)
  await t.ready
  return t.node
}

const createSource = (path: string, def: SceneNodeDef, lightmap: boolean | "dynamic" | undefined): Node | Promise<Node> => {
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera, def.terrain ].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/terrain)`)
  if (def.model !== undefined) return Model.load(def.model, { lightmap })
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
  if (def.terrain !== undefined) return createTerrain(def.terrain)
  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()
}

/** Edit mode: play the preview pose on a model node (`editor: { clip, time }`) — idempotent, the
 *  harness re-applies it on inspector edits. No clip = back to the rest pose. */
export const applyEditorPose = (node: Node, pose: { clip?: string, time?: number } | undefined): void => {
  const anim = (node as unknown as { anim?: Model["anim"] }).anim
  if (!anim) return
  const clip = pose?.clip
  if (!clip || !anim.clip(clip)) {
    anim.speed = 1
    anim.stop()
    return
  }
  anim.speed = 1
  if (pose?.time !== undefined) {
    anim.play(clip, { restart: true }).seek(pose.time)
    anim.speed = 0
  } else {
    anim.playLoop(clip)
  }
}

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.editor !== undefined && isEditMode()) applyEditorPose(node, def.editor)
  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)
  // shadow flags: a Mesh has one renderable, a Model a whole GLB instance (Model routes the pair
  // through setGlbShadows) — every other node kind is transform-only and carries none
  if (node instanceof Mesh || node instanceof Model) {
    if (def.castShadows !== undefined) node.castShadows = def.castShadows
    if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
  } else if (def.terrain !== undefined) {
    const t = Terrain.of(node)
    if (t && def.castShadows !== undefined) t.castShadows = def.castShadows
    if (t && def.receiveShadows !== undefined) t.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 && def.terrain === undefined

/** Edit mode: otherwise-invisible nodes (empties, `camera:` nodes) get an ANCHORED gizmo marker —
 *  an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
 *  in the viewport. Never scene content: nothing renders in Filament, nothing outlines, and the
 *  engine follows the node live, so the buffer is filled once and lives on the node
 *  (`_editorMarker`) — `_editorGizmos()` reads it off the handle's own def nodes, so a removed
 *  node's marker goes with its record and prefab / instance internals (not selectable) draw none. */
const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
  const buffer = new GizmoBuffer()
  if (def.camera !== undefined) {
    withGizmoScope(buffer, () => Gizmos.frustum(def.camera?.fov ?? CAMERA_DEFAULTS.fov, { node, color: "#cfd4dd" }))
  } else if (isGroupDef(def)) {
    withGizmoScope(buffer, () => Gizmos.cross([ 0, 0, 0 ], 0.3, { node, color: "#8f96a3" }))
  } else {
    return
  }
  ;(node as { _editorMarker?: GizmoBuffer })._editorMarker = buffer
  gizmoVersion++
}

/** The ACTIVE `CameraPlace` entry in THIS file's own defs (depth-first; prefab / instance internals
 *  are another file's business): its host path + props, or null. With no `active: true` anywhere,
 *  the first place in file order is it — a file with a single place needn't say so. */
const findCameraPlace = (defs?: Record<string, SceneNodeDef>): { path: string, props: Record<string, unknown> } | null => {
  let first: { path: string, props: Record<string, unknown> } | null = null
  const walk = (d: Record<string, SceneNodeDef> | undefined, prefix: string): { path: string, props: Record<string, unknown> } | null => {
    for (const [ name, nd ] of Object.entries(d ?? {})) {
      const path = prefix === "" ? name : `${prefix}/${name}`
      for (const entry of nd.aspects ?? []) {
        if (entry.ctor !== CameraPlace) continue
        const props = (entry.props ?? {}) as Record<string, unknown>
        if (props.active === true) return { path, props }
        first ??= { path, props }
      }
      const inner = walk(nd.children, path)
      if (inner) return inner
    }
    return null
  }
  return walk(defs, "") ?? first
}

/** 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
  /** Bumped by every `clear()`. An ASYNC rebuild captures it before awaiting and drops its own
   *  result when the value moved on (a newer rebuild cleared the container in the meantime). */
  version = 0
  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 {
    this.version++
    for (const c of [ ...this.children ]) {
      dropForeignRuns(c)
      this._scene.remove(c)
      c.destroy()
    }
    return this
  }
}

// Edit mode: generator runs inside scene INSTANCES built by `instantiate()` (a weapon under the
// arms' gun socket) are not the edited handle's runs — not inspector-addressable, not
// dep-tracked — but their gizmos (the weapon's sight line) must still draw. They register here
// keyed by the instance root; `dispose()` / a hosting `generated.clear()` drops them.
const foreignRuns = new Map<Node, EditorRun[]>()
const dropForeignRuns = (root: Node): void => {
  for (const key of [ ...foreignRuns.keys() ]) {
    let n: Node | null = key
    while (n && n !== root) n = n.parent
    if (n === root) foreignRuns.delete(key)
  }
}

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 | Promise<void> }
  /** Async rebuild() supersession counter (see safeRebuild). */
  generation: number
  /** 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>
  /** Editor lines drawn by the last rebuild() (`Gizmos.*` calls — see scene/gizmos.ts). */
  gizmos: GizmoBuffer
}

/** Bumped on every editor-run rebuild — the host compares it to know when to re-push gizmos. */
let gizmoVersion = 0

const safeRebuild = (run: EditorRun): Promise<void> | void => {
  // a throwing generator must not take the editor session down with it; the gizmo scope is the
  // SYNCHRONOUS part of the call — whatever rebuild draws replaces the run's previous lines. An
  // async rebuild() (one that instantiates a scene file) is awaited; lines it wants to draw after
  // its awaits go in an optional `draw()`, run in a fresh scope once the promise settles.
  gizmoVersion++
  let result: unknown
  try { result = withGizmoScope(run.gizmos, () => run.inst.rebuild?.()) }
  catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e); return }
  if (!(result instanceof Promise)) return
  const gen = ++run.generation
  return result.then(() => {
    if (gen !== run.generation) return   // superseded by a newer rebuild
    gizmoVersion++   // generated content changed — the host re-pushes overlays
    const draw = (run.inst as { draw?(): void }).draw
    if (typeof draw !== "function") return
    try { withGizmoScope(run.gizmos, () => draw.call(run.inst)) }
    catch (e) { console.error(`[scene] editor aspect draw failed on "${run.hostPath}":`, e) }
  }, (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), gizmos: new GizmoBuffer(), generation: 0 }
    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[],
): Promise<void> | void => {
  if (isEditMode()) {
    const waits: Promise<void>[] = []
    // 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 | Promise<void> })()
      ;(inst as { node: unknown }).node = node
      ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
      ;(inst as { scene: unknown }).scene = scene
      Object.assign(inst, resolveRefs(props, nodes, path))
      // reachable through `node.get(Ctor)` like an attached aspect (a nested rig's contract is read
      // by the generator that instantiated it) — registered, never attached: no onAttach/update
      ;(node as unknown as { _aspects: Map<Function, unknown> })._aspects.set(entry.ctor as Function, inst)
      const accessor = (entry.ctor as { aspect?: string }).aspect
      if (accessor) (node as unknown as Record<string, unknown>)[accessor] = inst
      // 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), gizmos: new GizmoBuffer(), generation: 0 }
      editorRuns.push(run)
      const wait = safeRebuild(run)
      if (wait) waits.push(wait)
    })
    return waits.length > 0 ? Promise.all(waits).then(() => undefined) : undefined
  }
  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), 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>, lm: LightmapCtx | null): 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), lm)
  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>,
  lm: LightmapCtx | null = null,
): 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 lmStatic = lm !== null && !lm.dynamicOnly && isLightmapStatic(nd)
    // a mover in a level with a light volume reads the volume (models: the engine binds it; meshes: registered below)
    const lmDynamic = lm !== null && !lmStatic && lm.volume
    const node = await createSource(path, nd, lmStatic ? true : lmDynamic ? "dynamic" : undefined)
    // 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 (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
    else if (lmStatic && Terrain.of(node)) Lightmap._registerTerrain(Terrain.of(node)!, lm!.prefix + path)
    else if (lmDynamic && node instanceof Mesh) Lightmap._registerDynamic(node)
    if (isEditMode()) addEditorMarker(node, nd)
    // a prefab under a static wrapper bakes; under a mover its internals stay movers (volume-lit)
    if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/`, volume: lm!.volume } : lm && lm.volume ? { prefix: `${lm.prefix}${path}/`, volume: true, dynamicOnly: true } : null)
    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) {
    const aspectWait = attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
    if (aspectWait) makeWaits.push(aspectWait)
    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)

  // terrain stamps + holes: every node of the file exists now (make() subtrees included), and a
  // Shape { heightfield } attached above takes the edits through commit() (an in-place SetHeights)
  for (const p of pending) {
    const td = p.def.terrain
    const t = td ? Terrain.of(p.node) : null
    if (!td || !t || (!td.stamps?.length && !td.holes?.length)) continue
    for (const s of td.stamps ?? []) {
      const target = nodes[s.node]
      if (!target) { console.warn(`[scene] "${p.path}".terrain stamp "${s.node}" matches no node — skipped`); continue }
      t.conform(target, { offset: s.offset, falloff: s.falloff, mode: s.mode })
    }
    for (const h of td.holes ?? []) t.hole(h)
    t.commit()
  }
}

// ---- navigation (env.navmesh) ------------------------------------------------------------------
// Walkable = every STATIC Physics body (what the CharacterController collides with), derived like
// the lightmap statics — no per-node declaration. `nav: false | 'unwalkable'` and `navArea` are the
// per-node overrides, keyed by the node's entity for the bake (NavMesh.exclude / unwalkable / area).
// Prefab internals are not walked (v1): override them in the prefab's own file.

const forEachNodeDef = (defs: Record<string, SceneNodeDef> | undefined, prefix: string, fn: (path: string, def: SceneNodeDef) => void): void => {
  for (const [ key, d ] of Object.entries(defs ?? {})) {
    const path = prefix ? `${prefix}/${key}` : key
    fn(path, d)
    forEachNodeDef(d.children, path, fn)
  }
}

const registerNavOverrides = (defs: Record<string, SceneNodeDef> | undefined, nodes: Record<string, Node>): void => {
  NavMesh.clear()
  forEachNodeDef(defs, "", (path, d) => {
    const node = nodes[path]
    if (!node) return
    if (d.nav === false) NavMesh.exclude(node)
    else if (d.nav === "unwalkable") NavMesh.unwalkable(node)
    else if (d.navArea) NavMesh.area(node, d.navArea)
  })
}

const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
  const { lightmap, navmesh, ...env } = def.env ?? {}
  const scene = new Scene(env)
  const nodes: Record<string, Node> = {}
  // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
  const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "", volume: !!lightmap.volume } : null
  if (lm) Lightmap.clear()
  // with a light volume every model loaded from now on (weapons, spawns, make() subtrees) is a mover by default
  if (lm) Model._lightmapDefault = lm.volume ? "dynamic" : false
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
  if (navmesh && !isEditMode()) registerNavOverrides(def.nodes, nodes)

  // 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 place = findCameraPlace(def.nodes)
  const placeNode = place ? nodes[place.path] : undefined
  const cam = findCamera(def.nodes)
  const camNode = cam ? nodes[cam.path] : undefined
  if (place && placeNode) {
    // the active CameraPlace (an ASPECT on any node — the preferred form; one per file)
    const proj = { fov: place.props.fov as number | undefined, near: place.props.near as number | undefined, far: place.props.far as number | undefined }
    scene.camera.position = placeNode.worldPosition
    scene.camera.quaternion = placeNode.worldQuaternion
    applyCameraProjection(scene, proj)
    if (!isEditMode()) scene.camera.follow(placeNode)
  } else if (cam && camNode) {
    // legacy: the `camera: {}` node source block (still honoured — prefer a CameraPlace aspect)
    scene.camera.position = camNode.worldPosition
    scene.camera.quaternion = camNode.worldQuaternion
    applyCameraProjection(scene, cam.def)
    if (!isEditMode()) scene.camera.follow(camNode)
  } 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)
  }

  // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
  if (lm && lightmap) {
    await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture, volume: lightmap.volume },
      { sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale })
  }
  // navigation: load the baked mesh — or, under `lecodes navmesh bake`, dump the level's collision
  // geometry for it (the static bodies exist by now; edit mode never loads it — v1)
  if (navmesh && !isEditMode()) {
    await NavMesh.load(scene, { data: navmesh.data, agent: navmesh.agent, areas: navmesh.areas })
  }

  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
  }

  /**
   * Build this scene file as a reusable SUBTREE inside an existing scene — a weapon under a hand
   * bone, a streetlamp per street corner — as many times as you like (unlike `load()`, which is
   * the one-instance "scene as a level" path). The nodes build under a fresh wrapper node (`root`)
   * parented to `parent` (or left unparented); `env` / `camera` are ignored like a prefab's. The
   * file's transforms are local to the wrapper, so author it with the wrapper as the attachment
   * point. `ref()`s resolve per instance; aspects attach per instance. `dispose()` removes the
   * subtree from the scene and destroys it.
   */
  async instantiate(opts: { scene: Scene, parent?: Node | null, name?: string }): Promise<SceneInstance<D>> {
    const { scene } = opts
    const root = new Node()
    root.name = opts.name ?? "__instance"
    if (opts.parent) opts.parent.add(root)
    scene.add(root)
    const nodes: Record<string, Node> = {}
    // instance internals are not inspector-addressable (like prefab internals), but in edit mode
    // their generators' gizmos still draw (foreignRuns — a nested rig's sight line)
    const runs: EditorRun[] = []
    await buildNodes(this.def.nodes ?? {}, root, scene, nodes, runs, new Set([ this.def ]))
    if (isEditMode() && runs.length > 0) { foreignRuns.set(root, runs); gizmoVersion++ }
    const get = (path: string): Node | null => nodes[path] ?? null
    const dispose = (): void => {
      foreignRuns.delete(root)
      root.visible = false   // the visibility cascade retires the subtree's pick colliders first
      scene.remove(root)
      root.destroy()
      for (const key of Object.keys(nodes)) delete nodes[key]
    }
    const alignTo = (inner: Node): void => {
      // inner's pose in root's frame is independent of root's own local transform:
      // rel = root.world⁻¹ · inner.world; root.local = rel⁻¹ puts inner on root's parent frame
      root.matrix = root.worldMatrix.invert().mul(inner.worldMatrix).invert()
    }
    return { root, nodes, get, dispose, alignTo } as unknown as SceneInstance<D>
  }

  /**
   * @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))
      gizmoVersion++
      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, undefined)
      if (parent) attachHost(parent, d, p).add(node)
      scene.add(node)
      applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
      if (isEditMode()) addEditorMarker(node, d)
      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 Editor: every run's gizmo lines (world-space LINES batches) + a version that
   *  changes whenever any rebuild ran — the host re-pushes to the engine only on a change. */
  _editorGizmos(): { version: number, batches: GizmoBatch[] } {
    const batches: GizmoBatch[] = []
    for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
    for (const runs of foreignRuns.values()) for (const run of runs) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
    for (const node of Object.values(this._live?.nodes ?? {})) {
      const marker = (node as { _editorMarker?: GizmoBuffer })._editorMarker
      if (marker) for (const b of marker.batches) batches.push(b)
    }
    return { version: gizmoVersion, batches }
  }

  /** @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
}
