lecodes-cli 0.17.2 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +1 -1
  2. package/dist/index.js +2376 -755
  3. package/package.json +4 -4
  4. package/runtime/scene-harness.json +1 -1
  5. package/runtime/sdk/compile/aspectMacro.ts +52 -8
  6. package/runtime/sdk/compile/assetMacro.ts +116 -15
  7. package/runtime/sdk/compile/bundler.ts +39 -4
  8. package/runtime/sdk/compile/compileProject.ts +16 -1
  9. package/runtime/sdk/compile/header.ts +6 -1
  10. package/runtime/sdk/compile/index.ts +31 -0
  11. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  12. package/runtime/sdk/compile/sceneEditor.ts +11 -1
  13. package/runtime/sdk/compile/shaderSchema.ts +202 -0
  14. package/runtime/sdk/compile/shaderTargets.ts +81 -0
  15. package/runtime/sdk/core/Aspect.ts +363 -95
  16. package/runtime/sdk/core/compWrite.ts +42 -0
  17. package/runtime/sdk/core/fields.ts +1 -1
  18. package/runtime/sdk/core/time.ts +81 -0
  19. package/runtime/sdk/g2/Camera2D.ts +8 -1
  20. package/runtime/sdk/g2/CharacterController2D.ts +253 -53
  21. package/runtime/sdk/g2/Node2D.ts +80 -10
  22. package/runtime/sdk/g2/OneWay2D.ts +66 -0
  23. package/runtime/sdk/g2/Physics2D.ts +240 -30
  24. package/runtime/sdk/g2/Scene2D.ts +33 -1
  25. package/runtime/sdk/g2/Shape2D.ts +218 -22
  26. package/runtime/sdk/g2/Trigger2D.ts +42 -12
  27. package/runtime/sdk/g2/groups2d.ts +106 -0
  28. package/runtime/sdk/g2/loop.ts +15 -4
  29. package/runtime/sdk/gl/Camera.ts +41 -0
  30. package/runtime/sdk/gl/CameraPlace.ts +52 -0
  31. package/runtime/sdk/gl/CharacterController.ts +184 -55
  32. package/runtime/sdk/gl/Gearbox.ts +212 -0
  33. package/runtime/sdk/gl/Geometry.ts +70 -9
  34. package/runtime/sdk/gl/IK.ts +193 -174
  35. package/runtime/sdk/gl/Light.ts +64 -2
  36. package/runtime/sdk/gl/Lightmap.ts +179 -0
  37. package/runtime/sdk/gl/Material.ts +36 -0
  38. package/runtime/sdk/gl/Mesh.ts +6 -23
  39. package/runtime/sdk/gl/Model.ts +23 -8
  40. package/runtime/sdk/gl/Node.ts +350 -285
  41. package/runtime/sdk/gl/Physics.ts +222 -126
  42. package/runtime/sdk/gl/Scene.ts +175 -8
  43. package/runtime/sdk/gl/Shape.ts +255 -12
  44. package/runtime/sdk/gl/Trigger.ts +1 -6
  45. package/runtime/sdk/gl/Vehicle.ts +473 -0
  46. package/runtime/sdk/gl/Wheel.ts +240 -0
  47. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  48. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  49. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  50. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  51. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  52. package/runtime/sdk/gl/animation/core.ts +294 -0
  53. package/runtime/sdk/gl/scenarios.ts +291 -349
  54. package/runtime/sdk/inject.ts +186 -162
  55. package/runtime/sdk/runtime/app.ts +13 -0
  56. package/runtime/sdk/runtime/input.ts +169 -6
  57. package/runtime/sdk/scene/defineScene.ts +1227 -1016
  58. package/runtime/sdk/scene/gizmos.ts +148 -0
  59. package/runtime/sdk/scene/material.ts +188 -0
  60. package/runtime/sdk-types.json +1 -1
  61. package/runtime/sdk/gl/Animator.ts +0 -642
  62. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -1,1016 +1,1227 @@
1
- // Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
2
- //
3
- // A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
4
- // nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
5
- // PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
6
- // a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
7
- // editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
8
- // node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
9
- //
10
- // // city.scene.ts
11
- // export default defineScene({
12
- // env: { skybox: '#10131a' },
13
- // nodes: {
14
- // ground: {
15
- // mesh: { kind: 'box', size: [20, 1, 20] },
16
- // material: { lit: { color: '#444444' } },
17
- // aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
18
- // },
19
- // },
20
- // })
21
- //
22
- // // main.ts
23
- // import city from './city.scene'
24
- // const { scene, nodes } = await city.open()
25
- //
26
- // Aspects are referenced by class — the import IS the registration (typechecked, DCE-safe, zero
27
- // ceremony for user aspects). Behavior never lives in the scene file: write a custom Aspect and
28
- // attach it via `use(...)`.
29
- //
30
- // EDIT MODE (`globalThis.__lecodesSceneEdit`, set by the scene editor before running the bundle):
31
- // sources are instantiated for real so the viewport shows the scene, but aspects are held as data
32
- // (`node._sceneAspects`) WITHOUT attaching — no onAttach side effects (physics bodies, timers), no
33
- // update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.
34
-
35
- import { Aspect, type AspectCtor, type With } from "../core/Aspect"
36
- import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
37
- import {
38
- collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
39
- type AspectEntry, type MakeEntry as SharedMakeEntry,
40
- } from "./grammar"
41
- import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
42
- import type { ColorInput } from "../core/color"
43
- import type { Vec3Like } from "../math/vec"
44
- import { Scene, type SceneOptions } from "../gl/Scene"
45
- import { CAMERA_DEFAULTS } from "../gl/Camera"
46
- import { Node } from "../gl/Node"
47
- import { Mesh } from "../gl/Mesh"
48
- import { Model } from "../gl/Model"
49
- import { Light, type SunOptions } from "../gl/Light"
50
- import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
51
- import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
52
- import { edgesMesh } from "../gl/scenarios"
53
-
54
- // ---- the literal grammar (what the visual editor reads and writes) -----------
55
-
56
- export type MeshDef =
57
- | { kind: "box", size?: Vec3Like | number }
58
- | ({ kind: "sphere" } & SphereOptions)
59
- | ({ kind: "cylinder" } & CylinderOptions)
60
- | ({ kind: "plane" } & PlaneOptions)
61
-
62
- export type LightDef = { kind: "sun" } & SunOptions
63
-
64
- export type MaterialDef =
65
- | { lit: LitMaterialOptions }
66
- | { unlit: MaterialColorOptions }
67
- | { shadow: ColorInput }
68
- // An imported/shared Material instance — valid at runtime; the editor shows it read-only.
69
- | Material
70
-
71
- // The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
72
- // re-exported here so this module remains the one import site for scene-file machinery.
73
- export { use, ref, make }
74
- export type { AspectEntry }
75
-
76
- /** A `make(fn, args)` source entry whose factory returns a 3D {@link Node}. */
77
- export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = SharedMakeEntry<A, Node>
78
-
79
- /** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
80
- * (`overrides` on a model/prefab node, keyed by part path). */
81
- export type ModelOverrideDef = {
82
- position?: Vec3Like
83
- eulerAngles?: Vec3Like
84
- scale?: Vec3Like | number
85
- visible?: boolean
86
- }
87
-
88
- /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
89
- * block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
90
- export type CameraProjectionDef = {
91
- /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
92
- fov?: number
93
- /** Near clip distance (default 0.01). */
94
- near?: number
95
- /** Far clip distance = view range (default 1000); geometry past it is culled. */
96
- far?: number
97
- }
98
-
99
- /** The `camera: {}` source block — projection settings for the node that drives the view. */
100
- export type CameraNodeDef = CameraProjectionDef
101
-
102
- export type SceneNodeDef = {
103
- // -- source (at most one; none = plain group node) --
104
- mesh?: MeshDef
105
- /** GLB url — `asset('./hero.glb')`. */
106
- model?: string
107
- light?: LightDef
108
- /** The scene camera as a NODE: in play mode `scene.camera` follows this node's world transform
109
- * every frame (so movement aspects on it are camera flythroughs); the editor shows a frustum
110
- * marker and refuses to delete the last camera node. The first camera node in file order wins;
111
- * cameras inside prefabs are ignored (like a prefab's `camera:` block). */
112
- camera?: CameraNodeDef
113
- /** A code-built subtree — `make(factoryFn, { ...literal args })`. */
114
- make?: MakeEntry<any>
115
- /** Another scene file used as a reusable composition — the imported handle:
116
- * `import streetlamp from './streetlamp.scene'` … `lamp: { prefab: streetlamp }`. Its nodes
117
- * instantiate under this node per instance (env/camera are the instancing file's business and
118
- * are ignored); `ref()`s inside the prefab resolve file-locally, per instance. */
119
- prefab?: SceneHandle<any>
120
- /** Material for a `mesh` source. */
121
- material?: MaterialDef
122
- /** Model/prefab sources: transform overrides for the INTERNAL nodes, keyed by part path
123
- * (see the part-path grammar above `modelPartRows`). Unresolved paths are ignored. */
124
- overrides?: Record<string, ModelOverrideDef>
125
- /** On a CHILD of a model/prefab node: parent this node to that INTERNAL part of the parent's
126
- * asset at build time (part path — the same grammar `overrides` keys use), e.g. a flashlight
127
- * in a hand. The transform stays local to the part. A stale path (asset changed) falls back
128
- * to the parent root with a console warning. */
129
- mount?: string
130
- // -- transform / render --
131
- position?: Vec3Like
132
- eulerAngles?: Vec3Like
133
- scale?: Vec3Like | number
134
- visible?: boolean
135
- /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
136
- locked?: boolean
137
- castShadows?: boolean
138
- receiveShadows?: boolean
139
- // -- capabilities / hierarchy --
140
- aspects?: readonly AspectEntry<any>[]
141
- children?: Record<string, SceneNodeDef>
142
- }
143
-
144
- export type SceneCameraDef = CameraProjectionDef & {
145
- position?: Vec3Like
146
- /** Point the camera looks at. */
147
- target?: Vec3Like
148
- }
149
-
150
- export type SceneDef = {
151
- env?: SceneOptions
152
- camera?: SceneCameraDef
153
- nodes?: Record<string, SceneNodeDef>
154
- }
155
-
156
- // ---- typed handle -------------------------------------------------------------
157
-
158
- type SourceNodeOf<N extends SceneNodeDef> =
159
- N extends { model: string } ? Model
160
- : N extends { mesh: MeshDef } ? Mesh
161
- : N extends { light: LightDef } ? Light
162
- : Node
163
-
164
- type AspectsOf<N extends SceneNodeDef> =
165
- N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never
166
-
167
- type NodeOf<N extends SceneNodeDef> =
168
- [AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>
169
-
170
- type UnionToIntersection<U> =
171
- (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
172
-
173
- // The child maps of a def level, prefixed with their parent's path, as a union (never when no
174
- // node has children — guarded below, since `unknown` would absorb the union and `never` would
175
- // poison the intersection).
176
- type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
177
- { [K in keyof T & string]:
178
- T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
179
- }[keyof T & string]
180
-
181
- // All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
182
- // its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
183
- type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
184
- { [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
185
- ([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)
186
-
187
- export type SceneNodes<D extends SceneDef> =
188
- D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
189
-
190
- export type LoadedScene<D extends SceneDef> = {
191
- scene: Scene
192
- nodes: SceneNodes<D>
193
- /** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
194
- get: {
195
- <P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
196
- (path: string): Node | null
197
- }
198
- }
199
-
200
- // ---- GLB internal parts --------------------------------------------------------
201
- // A model's internal hierarchy is addressed by PART PATHS — '/'-joined segments from the model
202
- // root down, where a segment is the child's name, disambiguated as `name[i]` among same-named
203
- // siblings and `[i]` for unnamed children (i = index within that same-named group). The editor
204
- // enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
205
- // loader resolves them back through the same enumeration, so writer and resolver can't drift.
206
-
207
- /** One INTERNAL node of a loaded GLB (editor introspection). */
208
- export type ModelPartRow = { path: string, name: string, depth: number, node: Node }
209
-
210
- const partSegment = (child: Node, siblings: Node[]): string => {
211
- const name = child.name ?? ""
212
- const group = siblings.filter((s) => (s.name ?? "") === name)
213
- if (name !== "" && group.length === 1) return name
214
- return `${name}[${group.indexOf(child)}]`
215
- }
216
-
217
- /** Flatten a model's (or prefab instance's) internal hierarchy to rows (depth-first, the root
218
- * excluded). `__generated` containers are derived output, and def-built nodes (`_sceneDef` —
219
- * plain or `mount`ed children of the model) are addressed by their own def paths — both are
220
- * not parts, skipped. Filtering BEFORE segment math keeps `name[i]` indices stable no matter
221
- * what defs are parented in. */
222
- export const modelPartRows = (root: Node): ModelPartRow[] => {
223
- const out: ModelPartRow[] = []
224
- const walk = (node: Node, prefix: string, depth: number): void => {
225
- const children = node.children.filter((c) =>
226
- c.name !== "__generated" && !(c as { _sceneDef?: boolean })._sceneDef)
227
- for (const child of children) {
228
- const seg = partSegment(child, children)
229
- const path = prefix === "" ? seg : `${prefix}/${seg}`
230
- out.push({ path, name: child.name || seg, depth, node: child })
231
- walk(child, path, depth + 1)
232
- }
233
- }
234
- walk(root, "", 0)
235
- return out
236
- }
237
-
238
- const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, ModelOverrideDef>): void => {
239
- const byPath = new Map(rows.map((r) => [ r.path, r.node ]))
240
- for (const [ path, o ] of Object.entries(overrides)) {
241
- const node = byPath.get(path)
242
- if (!node) continue // the source changed since the override was written — skip, don't throw
243
- if (o.position) node.position = o.position
244
- if (o.eulerAngles) node.eulerAngles = o.eulerAngles
245
- if (o.scale !== undefined) node.scale = o.scale
246
- if (o.visible !== undefined) node.visible = o.visible
247
- }
248
- }
249
-
250
- const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverrideDef>): void =>
251
- applyOverridesToRows(modelPartRows(root), overrides)
252
-
253
- /** Resolve a def's `mount` part path against its parent's internal rows (the same enumeration
254
- * `overrides` resolves through). Null when the parent carries no parts at all — a rebuild of an
255
- * already-mounted child passes its live part parent here, which is not an error — while a KNOWN
256
- * part list that misses the path (asset changed) warns, mirroring the overrides skip rule. */
257
- const resolveMountNode = (parent: Node, mountPath: string, ownerPath: string): Node | null => {
258
- const rows = parent instanceof Model
259
- ? modelPartRows(parent)
260
- : (parent as { _prefabParts?: ModelPartRow[] })._prefabParts
261
- if (!rows || rows.length === 0) return null
262
- const hit = rows.find((r) => r.path === mountPath)
263
- if (!hit) console.warn(`[scene] "${ownerPath}".mount = "${mountPath}" matches no part — attached to the parent root`)
264
- return hit?.node ?? null
265
- }
266
-
267
- /** Where a def node attaches: its `mount` part when it resolves, else the parent itself. */
268
- const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
269
- def.mount !== undefined ? resolveMountNode(parent, def.mount, path) ?? parent : parent
270
-
271
- // ---- runtime -------------------------------------------------------------------
272
-
273
- const resolveMaterial = (def: MaterialDef): Material => {
274
- if (def instanceof Material) return def
275
- if ("lit" in def) return Material.lit(def.lit)
276
- if ("unlit" in def) return Material.unlit(def.unlit)
277
- return Material.shadow(def.shadow)
278
- }
279
-
280
- const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
281
- const mat = material !== undefined ? resolveMaterial(material) : undefined
282
- switch (def.kind) {
283
- case "box": return Mesh.box({ size: def.size, material: mat })
284
- case "sphere": { const { kind: _k, ...opts } = def; return Mesh.sphere({ ...opts, material: mat }) }
285
- case "cylinder": { const { kind: _k, ...opts } = def; return Mesh.cylinder({ ...opts, material: mat }) }
286
- case "plane": { const { kind: _k, ...opts } = def; return Mesh.plane({ ...opts, material: mat }) }
287
- }
288
- }
289
-
290
- const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
291
- const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
292
- if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
293
- if (def.model !== undefined) return Model.load(def.model)
294
- if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
295
- if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
296
- // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
297
- // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
298
- return new Node()
299
- }
300
-
301
- const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
302
- node.name = name
303
- // def-built marker: part enumeration must skip this node (it is not asset-internal — it has
304
- // its own def path), even when it is parented inside a model via `mount`
305
- ;(node as { _sceneDef?: boolean })._sceneDef = true
306
- if (def.position) node.position = def.position
307
- if (def.eulerAngles) node.eulerAngles = def.eulerAngles
308
- if (def.scale !== undefined) node.scale = def.scale
309
- if (def.visible !== undefined) node.visible = def.visible
310
- // editor-only flag (the harness's manipulation layer consults it); inert at runtime
311
- if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
312
- if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
313
- // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
314
- // order is insertion-based and may not match the file
315
- if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
316
- if (node instanceof Mesh) {
317
- if (def.castShadows !== undefined) node.castShadows = def.castShadows
318
- if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
319
- }
320
- if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
321
- }
322
-
323
- // ---- edit-mode node markers + the play-mode camera rig -------------------------------------------
324
-
325
- /** True for a def with no source block at all — a plain group ("Empty" in the editor). */
326
- const isGroupDef = (def: SceneNodeDef): boolean =>
327
- def.mesh === undefined && def.model === undefined && def.light === undefined
328
- && def.make === undefined && def.prefab === undefined && def.camera === undefined
329
-
330
- /** Edit mode: give otherwise-invisible nodes (empties, cameras) an `__editor_marker` line-mesh
331
- * child so they render and pick in the viewport. The harness reads `_sceneMarker` to give the
332
- * node itself a box collider (markers are editor nodes — never raycast/placement targets). */
333
- const addEditorMarker = (node: Node, def: SceneNodeDef, scene: Scene): void => {
334
- let marker: Mesh
335
- if (def.camera !== undefined) {
336
- // wireframe frustum looking down local -Z + an "up" fin above the near rect
337
- const z = -0.55, w = 0.3, h = 0.21
338
- const segs: number[] = []
339
- for (const [ cx, cy ] of [ [ -w, -h ], [ w, -h ], [ w, h ], [ -w, h ] ] as const) segs.push(0, 0, 0, cx, cy, z)
340
- 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)
341
- segs.push(-0.12, h, z, 0, h + 0.14, z, 0, h + 0.14, z, 0.12, h, z)
342
- marker = edgesMesh(segs, "#cfd4dd")
343
- ;(node as { _sceneMarker?: string })._sceneMarker = "camera"
344
- } else if (isGroupDef(def)) {
345
- const s = 0.3
346
- marker = edgesMesh([ -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s ], "#8f96a3")
347
- ;(node as { _sceneMarker?: string })._sceneMarker = "empty"
348
- } else {
349
- return
350
- }
351
- marker.name = "__editor_marker"
352
- node.add(marker)
353
- scene.add(marker)
354
- }
355
-
356
- /** Play mode: `scene.camera` follows the camera node's world transform every frame (LATE phase,
357
- * after every other aspect has moved things — order 1000). Attached by `instantiate`, internal. */
358
- class CameraRig extends Aspect<"__cameraRig"> {
359
- static readonly aspect = "__cameraRig"
360
- /** @internal */ _scene!: Scene
361
- constructor() { super(); this.order = 1000 }
362
- update(): void {
363
- const cam = this._scene.camera
364
- cam.position = this.node.worldPosition
365
- cam.quaternion = this.node.worldQuaternion
366
- }
367
- }
368
-
369
- /** The first camera-source def in file order (depth-first) — its path and block — or null. */
370
- const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
371
- for (const [ name, nd ] of Object.entries(defs ?? {})) {
372
- const path = prefix === "" ? name : `${prefix}/${name}`
373
- if (nd.camera !== undefined) return { path, def: nd.camera }
374
- const inner = findCamera(nd.children, path)
375
- if (inner) return inner
376
- }
377
- return null
378
- }
379
-
380
- /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
381
- * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
382
- * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
383
- const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
384
- const { fov, near, far } = def
385
- if (!reset && fov === undefined && near === undefined && far === undefined) return
386
- scene.camera.setProjection(reset
387
- ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
388
- : { fov, near, far })
389
- }
390
-
391
- // ---- editor-run aspects (generators) ------------------------------------------------------------
392
- // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
393
- // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
394
- // editor re-runs rebuild() on inspector prop edits (`_editorSetProp`) and whenever a node its
395
- // ref() fields point at changes (`_editorNodeChanged` — dep tracking is derived from the props).
396
- // Generated output lives under `this.generated`, a scene-added container child: never written to
397
- // the file, absent from the doc tree — the file stores the recipe, the viewport shows the result.
398
-
399
- const isEditorCtor = (ctor: unknown): boolean => !!(ctor as { editor?: unknown }).editor
400
-
401
- /** The `generated` container: children join the scene's draw set on add (membership is separate
402
- * from parenting — `addEntityToScene` only recurses over children that exist at add time). */
403
- class GeneratedGroup extends Node {
404
- /** @internal */ _scene!: Scene
405
- add(...children: Node[]): this {
406
- super.add(...children)
407
- this._scene.add(...children)
408
- return this
409
- }
410
- /** Destroy all generated children (rebuild() calls this first — idempotent regeneration). */
411
- clear(): this {
412
- for (const c of [ ...this.children ]) {
413
- this._scene.remove(c)
414
- c.destroy()
415
- }
416
- return this
417
- }
418
- }
419
-
420
- const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
421
- const group = new GeneratedGroup()
422
- group.name = "__generated"
423
- group._scene = scene
424
- node.add(group)
425
- scene.add(group)
426
- return group
427
- }
428
-
429
- /** One live editor-run aspect instance (edit mode only). */
430
- type EditorRun = {
431
- /** Absolute path of the host def node (re-keyed on rename/reparent). */
432
- hostPath: string
433
- node: Node
434
- /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
435
- index: number
436
- inst: { rebuild?(): void }
437
- /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
438
- * DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
439
- * against the file's props, so rewriting them would re-fire on every render. */
440
- props: Record<string, unknown>
441
- /** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
442
- * their subtrees) re-runs rebuild(). Re-derived after every structural change. */
443
- deps: Set<string>
444
- }
445
-
446
- const safeRebuild = (run: EditorRun): void => {
447
- // a throwing generator must not take the editor session down with it
448
- try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
449
- }
450
-
451
- /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
452
- * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
453
- const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
454
- const lookup = (r: string): Node | null => {
455
- const p = resolveRefPath(nodes, run.hostPath, r)
456
- return p === null ? null : nodes[p]
457
- }
458
- for (const [ k, v ] of Object.entries(run.props)) {
459
- if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
460
- else if (Array.isArray(v) && v.some(isNodeRef)) {
461
- ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
462
- }
463
- }
464
- }
465
-
466
- // ---- make() sources (code-built subtrees) -------------------------------------------------------
467
- // The factory runs in phase 2 (every node exists → ref() args resolve) and its result mounts in a
468
- // `generated` container under the def-node wrapper — the wrapper's transform is editor-owned, so
469
- // moving a make node never re-calls the factory. In edit mode the run is tracked as an EditorRun
470
- // (index MAKE_INDEX): arg edits and ref-dep changes re-call the factory through the exact same
471
- // machinery generator aspects use — live, no compile (the code is already in the bundle).
472
-
473
- /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
474
- const MAKE_INDEX = -1
475
-
476
- const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
477
- let token = 0
478
- const inst: Record<string, unknown> = {}
479
- inst.rebuild = () => {
480
- const t = ++token
481
- generated.clear()
482
- // current args = the instance's own fields (that's where _editorSetProp/assignRefProps write)
483
- const args: Record<string, unknown> = {}
484
- for (const [ k, v ] of Object.entries(inst)) if (k !== "rebuild") args[k] = v
485
- const result = entry.fn(args as never)
486
- if (result instanceof Node) { generated.add(result); return }
487
- void Promise.resolve(result).then((made) => {
488
- // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
489
- if (t === token && made instanceof Node) generated.add(made)
490
- }).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
491
- }
492
- return inst as unknown as { rebuild(): void }
493
- }
494
-
495
- /** Run a def's make() factory (phase 2). Play mode returns the factory's completion — the loader
496
- * awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
497
- * and returns immediately (async content pops in when ready, like a model load). */
498
- const attachMake = (
499
- node: Node, path: string, def: SceneNodeDef,
500
- nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
501
- ): Promise<void> | undefined => {
502
- const entry = def.make
503
- if (!entry) return undefined
504
- const generated = makeGenerated(node, scene)
505
- if (isEditMode()) {
506
- const props = { ...(entry.args ?? {}) } as Record<string, unknown>
507
- const inst = createMakeInst(path, entry, generated)
508
- Object.assign(inst, resolveRefs(props, nodes, path))
509
- const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
510
- editorRuns.push(run)
511
- safeRebuild(run)
512
- return undefined
513
- }
514
- const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
515
- return Promise.resolve(entry.fn(args as never)).then((made) => {
516
- if (made instanceof Node) generated.add(made)
517
- })
518
- }
519
-
520
- const attachAspects = (
521
- node: Node, path: string, def: SceneNodeDef,
522
- nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
523
- ): void => {
524
- if (isEditMode()) {
525
- // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
526
- // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
527
- // EXCEPT editor-run classes (generators) — they get a real, tracked instance (above).
528
- ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
529
- ;(def.aspects ?? []).forEach((entry, index) => {
530
- if (!isEditorCtor(entry.ctor)) return
531
- const props = { ...(entry.props ?? {}) } as Record<string, unknown>
532
- const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
533
- ;(inst as { node: unknown }).node = node
534
- ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
535
- Object.assign(inst, resolveRefs(props, nodes, path))
536
- // the HOST node is a dep too: a generator that draws relative to its node (path lines)
537
- // must re-run when the node itself is dragged, not only when its ref() targets move
538
- const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
539
- editorRuns.push(run)
540
- safeRebuild(run)
541
- })
542
- return
543
- }
544
- for (const entry of def.aspects ?? []) {
545
- const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
546
- const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
547
- ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
548
- }
549
- }
550
-
551
- // ---- prefabs (scene-in-scene) --------------------------------------------------------------------
552
- // A `prefab:` node instantiates another scene file's nodes under a plain wrapper — per instance,
553
- // from the imported handle's DEF (never handle.load(): that would share one singleton instance).
554
- // Each instance gets its own LOCAL node map, so `ref()`s inside the prefab resolve file-locally
555
- // and instances never collide in the parent's flat map. The instance's aspects attach normally in
556
- // play mode; in edit mode they stay data-only like everything else, and its generators / make()
557
- // factories run ONCE for display but are NOT tracked for editing (internals are posed through
558
- // `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
559
-
560
- /** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
561
- * order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
562
- * the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
563
- * their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
564
- const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
565
- const out: ModelPartRow[] = []
566
- for (const [ name, nd ] of Object.entries(defs)) {
567
- const path = prefix === "" ? name : `${prefix}/${name}`
568
- const node = local[path]
569
- if (!node) continue
570
- out.push({ path, name, depth, node })
571
- out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
572
- const inner = node instanceof Model
573
- ? modelPartRows(node)
574
- : (node as { _prefabParts?: ModelPartRow[] })._prefabParts
575
- if (inner) out.push(...inner.map((r) => ({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })))
576
- }
577
- return out
578
- }
579
-
580
- const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
581
- const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
582
- if (!pdef || typeof pdef !== "object") {
583
- console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
584
- return
585
- }
586
- // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
587
- ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
588
- if (stack.has(pdef)) {
589
- console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
590
- return
591
- }
592
- const local: Record<string, Node> = {}
593
- const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
594
- await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef))
595
- const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
596
- ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
597
- if (def.overrides) applyOverridesToRows(rows, def.overrides)
598
- }
599
-
600
- /** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
601
- * aspects/make — so `ref()`s resolve regardless of declaration order), into the given flat node
602
- * map. The top-level scene and every prefab instance run through here, each with its own map. */
603
- const buildNodes = async (
604
- defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
605
- nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
606
- ): Promise<void> => {
607
- const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
608
-
609
- const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
610
- // names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
611
- if (name === "" || name.includes("/") || name.includes(":")) {
612
- throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
613
- }
614
- // the path is derived from def keys BEFORE any await, so it is deterministic even though
615
- // Promise.all makes build completion (and record insertion) order nondeterministic
616
- const path = parentPath === "" ? name : `${parentPath}/${name}`
617
- const node = await createSource(path, nd)
618
- // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
619
- // model was awaited and attachPrefab completed before its children build
620
- if (parentNode) attachHost(parentNode, nd, path).add(node)
621
- // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
622
- scene.add(node)
623
- applyNode(node, name, nd) // engine-side name stays the bare sibling segment
624
- if (isEditMode()) addEditorMarker(node, nd, scene)
625
- if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
626
- pending.push({ path, node, def: nd })
627
- nodes[path] = node
628
- await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
629
- }
630
-
631
- await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
632
- const makeWaits: Promise<void>[] = []
633
- for (const p of pending) {
634
- attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
635
- const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
636
- if (wait) makeWaits.push(wait)
637
- }
638
- if (makeWaits.length > 0) await Promise.all(makeWaits)
639
- }
640
-
641
- const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
642
- const scene = new Scene(def.env)
643
- const nodes: Record<string, Node> = {}
644
- await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
645
-
646
- // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
647
- // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
648
- // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
649
- // lens the running app will use.
650
- const cam = findCamera(def.nodes)
651
- const camNode = cam ? nodes[cam.path] : undefined
652
- if (cam && camNode) {
653
- scene.camera.position = camNode.worldPosition
654
- scene.camera.quaternion = camNode.worldQuaternion
655
- applyCameraProjection(scene, cam.def)
656
- if (!isEditMode()) {
657
- ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
658
- }
659
- } else if (def.camera) {
660
- if (def.camera.position) scene.camera.position = def.camera.position
661
- if (def.camera.target) scene.camera.lookAt(def.camera.target)
662
- applyCameraProjection(scene, def.camera)
663
- }
664
-
665
- return { scene, nodes }
666
- }
667
-
668
- export class SceneHandle<D extends SceneDef = SceneDef> {
669
- readonly def: D
670
- private _loading?: Promise<LoadedScene<D>>
671
- /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). */
672
- private _editorRuns: EditorRun[] = []
673
- /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
674
- private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
675
- /** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
676
- * load resolves). The record object is SHARED with the returned LoadedScene — patches and
677
- * rename/reparent re-keying are visible through both. */
678
- private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
679
-
680
- constructor(def: D) { this.def = def }
681
-
682
- /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
683
- load(): Promise<LoadedScene<D>> {
684
- if (!this._loading) {
685
- this._loading = instantiate(this.def, this._editorRuns).then((live) => {
686
- this._live = live
687
- const get = (path: string): Node | null => live.nodes[path] ?? null
688
- return { ...live, get } as unknown as LoadedScene<D>
689
- })
690
- }
691
- return this._loading
692
- }
693
-
694
- /** Load and make active. */
695
- async open(): Promise<LoadedScene<D>> {
696
- const loaded = await this.load()
697
- loaded.scene.open()
698
- return loaded
699
- }
700
-
701
- /**
702
- * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
703
- * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
704
- * `path` in place (or adds it when the path is new — the path's parent must exist, root paths
705
- * mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
706
- * data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
707
- * are inert in edit mode and stay out of defs.
708
- *
709
- * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
710
- * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
711
- * removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
712
- * are not reclaimed until the next full re-run — acceptable churn for an edit session.
713
- */
714
- async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
715
- const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
716
- const old: Node | undefined = nodes[path]
717
-
718
- const dispose = (root: Node): void => {
719
- // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
720
- // no removeCollider — a destroyed entity's stale collider entry must never pick again).
721
- root.visible = false
722
- const doomed = new Set<Node>()
723
- root.traverse((n) => doomed.add(n))
724
- for (const [ key, n ] of Object.entries(nodes)) if (doomed.has(n)) delete nodes[key]
725
- // editor-run aspect instances hosted in the doomed subtree go with it (generated children
726
- // are subtree children, so the destroy below reclaims them too)
727
- this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
728
- scene.remove(root)
729
- root.destroy() // native destroyEntity recurses over remaining children
730
- }
731
-
732
- if (def === null) {
733
- if (old) {
734
- dispose(old)
735
- this._refreshRunDeps()
736
- }
737
- return null
738
- }
739
-
740
- const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
741
- // child reuse is by FULL path — only the live subtree at this exact address is carried
742
- // over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
743
- const existing = p === path ? undefined : nodes[p]
744
- if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
745
- if (parent) attachHost(parent, d, p).add(existing)
746
- return existing
747
- }
748
- const node = await createSource(p, d)
749
- if (parent) attachHost(parent, d, p).add(node)
750
- scene.add(node)
751
- applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
752
- if (isEditMode()) addEditorMarker(node, d, scene)
753
- attachAspects(node, p, d, nodes, scene, this._editorRuns)
754
- nodes[p] = node
755
- await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
756
- return node
757
- }
758
-
759
- const cut = path.lastIndexOf("/")
760
- const parentPath = cut < 0 ? null : path.slice(0, cut)
761
- if (!old && parentPath !== null && !nodes[parentPath]) return null
762
- const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
763
- const fresh = await build(path, def, parent)
764
- if (old) {
765
- // named children not listed in the def still move over (the editor patches one node at a time)
766
- const named = new Set(Object.values(nodes))
767
- for (const child of old.children) if (named.has(child)) fresh.add(child)
768
- dispose(old) // fresh already replaced nodes[path] in build(), so it survives
769
- nodes[path] = fresh
770
- }
771
- // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
772
- // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
773
- if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
774
- this._refreshRunDeps()
775
- return fresh
776
- }
777
-
778
- /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
779
- * absolute paths, so any structural change can invalidate them: an add can satisfy a
780
- * previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
781
- private _refreshRunDeps(): void {
782
- const nodes = this._live?.nodes
783
- if (!nodes) return
784
- for (const run of this._editorRuns) {
785
- run.deps = collectRefDeps(run.props, nodes, run.hostPath)
786
- // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
787
- // wrapper's transform is editor-owned and moving it never re-calls the factory
788
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
789
- }
790
- }
791
-
792
- /** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
793
- * runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
794
- * the host — mutation, not replacement. */
795
- private _rekey(oldPath: string, newPath: string): void {
796
- const nodes = this._live!.nodes
797
- const move = (key: string): string | null =>
798
- key === oldPath ? newPath
799
- : key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
800
- : null
801
- for (const key of Object.keys(nodes)) {
802
- const next = move(key)
803
- if (next === null) continue
804
- const n = nodes[key]
805
- delete nodes[key]
806
- nodes[next] = n
807
- }
808
- for (const run of this._editorRuns) {
809
- const next = move(run.hostPath)
810
- if (next !== null) run.hostPath = next
811
- }
812
- for (const [ key, card ] of [ ...this._inspectorCards ]) {
813
- const i = key.lastIndexOf(":")
814
- const next = move(key.slice(0, i))
815
- if (next === null) continue
816
- this._inspectorCards.delete(key)
817
- this._inspectorCards.set(next + key.slice(i), card)
818
- }
819
- this._refreshRunDeps()
820
- }
821
-
822
- /** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
823
- * Owns the shared record's re-keying (the host re-keys only its own part/selection state).
824
- * Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
825
- _renameNode(path: string, newName: string): string | null {
826
- const nodes = this._live?.nodes
827
- const node = nodes?.[path]
828
- if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
829
- const cut = path.lastIndexOf("/")
830
- const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
831
- if (newPath === path) return path
832
- if (nodes[newPath]) return null
833
- this._rekey(path, newPath)
834
- node.name = newName // engine-side name stays the bare segment
835
- return newPath
836
- }
837
-
838
- /** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
839
- * every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
840
- * cycle, name taken among the new siblings). */
841
- _reparentNode(path: string, newParentPath: string | null): string | null {
842
- const nodes = this._live?.nodes
843
- const node = nodes?.[path]
844
- if (!nodes || !node) return null
845
- const parent = newParentPath === null ? null : nodes[newParentPath]
846
- if (newParentPath !== null && !parent) return null
847
- if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
848
- const name = path.slice(path.lastIndexOf("/") + 1)
849
- const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
850
- if (newPath === path) return path
851
- if (nodes[newPath]) return null
852
- this._rekey(path, newPath)
853
- node.setParent(parent ?? null, false)
854
- return newPath
855
- }
856
-
857
- /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
858
- * and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
859
- * absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
860
- * FollowPath's waypoints are the children of its referenced path node) is a prefix test:
861
- * a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
862
- _editorNodeChanged(path: string): void {
863
- const nodes = this._live?.nodes
864
- if (!nodes) return
865
- for (const run of this._editorRuns) {
866
- let hit = false
867
- for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
868
- if (!hit) continue
869
- assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
870
- safeRebuild(run)
871
- }
872
- }
873
-
874
- /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
875
- * run (no compile; the factory is already in the bundle). False for non-make nodes. */
876
- _editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
877
- return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
878
- }
879
-
880
- /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
881
- * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
882
- * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
883
- _editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
884
- const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
885
- const nodes = this._live?.nodes
886
- if (!run || !nodes) return false
887
- run.props[key] = value
888
- run.deps = collectRefDeps(run.props, nodes, run.hostPath)
889
- // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
890
- // wrapper's transform is editor-owned and moving it never re-calls the factory
891
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
892
- if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
893
- else (run.inst as Record<string, unknown>)[key] = value
894
- safeRebuild(run)
895
- return true
896
- }
897
-
898
- /**
899
- * @internal Editor: run an aspect's custom `static inspector` card (immediate-mode — see
900
- * core/InspectorUI.ts) and return its widget list. Null when the class has no inspector (the
901
- * editor falls back to the inferred fields).
902
- *
903
- * `props` is the entry's CURRENT doc props, passed on EVERY call — the world syncs its side
904
- * from it (a generator syncs through the `_editorSetProp` machinery, so an undo that changes a
905
- * prop rebuilds for free; a plain aspect's preview instance is reassigned). `event` carries only
906
- * buttons and editor-state field edits — doc-bound field edits arrive as changed `props`.
907
- *
908
- * Cards persist per `<host>:<index>` across calls (that's where `ui.state` lives); a card whose
909
- * node instance was replaced by a live patch is rebuilt transparently.
910
- */
911
- _inspectorRender(
912
- hostPath: string, index: number,
913
- props: Record<string, unknown>, event?: InspectorEvent,
914
- ): InspectorWidget[] | null {
915
- const live = this._live
916
- const node = live?.nodes[hostPath]
917
- const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
918
- ?._sceneAspects?.[index]
919
- if (!live || !node || !entry) return null
920
- const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
921
- if (typeof ctor.inspector !== "function") return null
922
-
923
- const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
924
- const key = `${hostPath}:${index}`
925
- let card = this._inspectorCards.get(key)
926
- if (!card || card.node !== node) {
927
- // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
928
- // preview instance: node set, refs resolved, NEVER onAttach (edit mode stays side-effect
929
- // free). Editor state (ui._state) survives a node patch by carrying the old ui over.
930
- let inst: Record<string, unknown>
931
- if (run) {
932
- inst = run.inst as unknown as Record<string, unknown>
933
- } else {
934
- inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
935
- inst.node = node
936
- Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
937
- }
938
- const ui = card?.ui ?? new InspectorUI()
939
- ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
940
- ui._docKeys = new Set(ui._fields.map((f) => f.key))
941
- card = { ui, inst, node }
942
- this._inspectorCards.set(key, card)
943
- }
944
-
945
- // Sync the doc props into the world side. A key REMOVED from the doc (undo past its first
946
- // edit) resets to the class-field default — otherwise the instance would keep the stale value.
947
- const c = card
948
- const fieldDefault = (k: string): unknown => c.ui._fields.find((f) => f.key === k)?.value
949
- // `$expr` markers (values set in code) never sync into instances — the widget shows them
950
- // read-only; the instance keeps the compile-time evaluation.
951
- const isExpr = (v: unknown): boolean =>
952
- typeof v === "object" && v !== null && typeof (v as { $expr?: unknown }).$expr === "string"
953
- const changed = (a: unknown, b: unknown): boolean =>
954
- a !== b && JSON.stringify(a) !== JSON.stringify(b)
955
- if (run) {
956
- for (const [ k, v ] of Object.entries(props)) {
957
- if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
958
- }
959
- for (const k of Object.keys(run.props)) {
960
- if (k in props) continue
961
- this._editorSetProp(hostPath, index, k, fieldDefault(k))
962
- delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
963
- }
964
- c.ui._props = run.props
965
- } else {
966
- const snapshot = { ...props }
967
- const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
968
- for (const f of c.ui._fields) {
969
- if (isExpr(resolved[f.key])) continue
970
- c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
971
- }
972
- c.ui._props = snapshot
973
- }
974
-
975
- return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
976
- }
977
-
978
- /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
979
- * absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
980
- * `overrides` keys use. Empty for plain nodes / unknown paths. */
981
- async _modelParts(path: string): Promise<ModelPartRow[]> {
982
- const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
983
- const node = nodes[path]
984
- if (node instanceof Model) return modelPartRows(node)
985
- // prefab instances precompute their rows in DEF order (engine child order isn't stable)
986
- return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
987
- }
988
-
989
- /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
990
- _describeAspects(): AspectClassInfo[] {
991
- const ctors = new Set<AspectCtor<any>>()
992
- const walk = (defs?: Record<string, SceneNodeDef>): void => {
993
- for (const nd of Object.values(defs ?? {})) {
994
- for (const e of nd.aspects ?? []) ctors.add(e.ctor)
995
- walk(nd.children)
996
- }
997
- }
998
- walk(this.def.nodes)
999
- return [ ...ctors ].map((c) => describeAspect(c))
1000
- }
1001
- }
1002
-
1003
- /**
1004
- * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
1005
- * `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
1006
- * block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
1007
- * plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
1008
- */
1009
- export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
1010
- const handle = new SceneHandle(def)
1011
- const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
1012
- // Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
1013
- // up the handle to load it in edit mode and drive the inspector).
1014
- if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
1015
- return handle
1016
- }
1
+ // Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
2
+ //
3
+ // A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
4
+ // nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
5
+ // PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
6
+ // a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
7
+ // editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
8
+ // node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
9
+ //
10
+ // // city.scene.ts
11
+ // export default defineScene({
12
+ // env: { skybox: '#10131a' },
13
+ // nodes: {
14
+ // ground: {
15
+ // mesh: { kind: 'box', size: [20, 1, 20] },
16
+ // material: { lit: { color: '#444444' } },
17
+ // aspects: [use(Shape, { box: [10, 0.5, 10] }), use(Physics, { motion: 'static' })],
18
+ // },
19
+ // },
20
+ // })
21
+ //
22
+ // // main.ts
23
+ // import city from './city.scene'
24
+ // const { scene, nodes } = await city.open()
25
+ //
26
+ // Aspects are referenced by class — the import IS the registration (typechecked, DCE-safe, zero
27
+ // ceremony for user aspects). Behavior never lives in the scene file: write a custom Aspect and
28
+ // attach it via `use(...)`.
29
+ //
30
+ // EDIT MODE (`globalThis.__lecodesSceneEdit`, set by the scene editor before running the bundle):
31
+ // sources are instantiated for real so the viewport shows the scene, but aspects are held as data
32
+ // (`node._sceneAspects`) WITHOUT attaching — no onAttach side effects (physics bodies, timers), no
33
+ // update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.
34
+
35
+ import { Aspect, type AspectCtor, type With } from "../core/Aspect"
36
+ import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
37
+ import {
38
+ collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
39
+ type AspectEntry, type MakeEntry as SharedMakeEntry,
40
+ } from "./grammar"
41
+ import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
42
+ import type { Vec3Like } from "../math/vec"
43
+ import { Scene, type SceneOptions } from "../gl/Scene"
44
+ import { CAMERA_DEFAULTS } from "../gl/Camera"
45
+ import { CameraPlace } from "../gl/CameraPlace"
46
+ import { Node } from "../gl/Node"
47
+ import { Mesh } from "../gl/Mesh"
48
+ import { Model } from "../gl/Model"
49
+ import { Physics } from "../gl/Physics"
50
+ import { Lightmap } from "../gl/Lightmap"
51
+ import { Light, type SunOptions } from "../gl/Light"
52
+ import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
53
+ import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
54
+ import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"
55
+
56
+ // ---- the literal grammar (what the visual editor reads and writes) -----------
57
+
58
+ export type MeshDef =
59
+ | { kind: "box", size?: Vec3Like | number }
60
+ | ({ kind: "sphere" } & SphereOptions)
61
+ | ({ kind: "cylinder" } & CylinderOptions)
62
+ | ({ kind: "plane" } & PlaneOptions)
63
+
64
+ export type LightDef = { kind: "sun" } & SunOptions
65
+
66
+ // The material grammar (`{ lit }` / `{ unlit }` / `{ shadow }` / `{ shader, params }` / a
67
+ // material asset handle / a code instance) lives in ./material.ts — re-exported for callers.
68
+ export type { MaterialDef }
69
+
70
+ // The grammar markers (`use`/`ref`/`make`) live in ./grammar.ts, shared with defineScene2d —
71
+ // re-exported here so this module remains the one import site for scene-file machinery.
72
+ export { use, ref, make }
73
+ export type { AspectEntry }
74
+
75
+ /** A `make(fn, args)` source entry whose factory returns a 3D {@link Node}. */
76
+ export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = SharedMakeEntry<A, Node>
77
+
78
+ /** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
79
+ * (`overrides` on a model/prefab node, keyed by part path). */
80
+ export type ModelOverrideDef = {
81
+ position?: Vec3Like
82
+ eulerAngles?: Vec3Like
83
+ scale?: Vec3Like | number
84
+ visible?: boolean
85
+ /** Materials by primitive SLOT of this part (`0` for a single-material mesh; the editor lists
86
+ * the slots with their glTF material names): a material asset, an inline def, or a custom
87
+ * shader. Slots left out keep the glTF material. */
88
+ materials?: Record<number | string, MaterialDef>
89
+ }
90
+
91
+ /** Camera projection settings, shared by the `camera:` source block and the top-level `camera:`
92
+ * block. All optional — an omitted key keeps the host default (60° / 0.01 / 1000). */
93
+ export type CameraProjectionDef = {
94
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
95
+ fov?: number
96
+ /** Near clip distance (default 0.01). */
97
+ near?: number
98
+ /** Far clip distance = view range (default 1000); geometry past it is culled. */
99
+ far?: number
100
+ }
101
+
102
+ /** The `camera: {}` source block — projection settings for the node that drives the view. */
103
+ export type CameraNodeDef = CameraProjectionDef
104
+
105
+ export type SceneNodeDef = {
106
+ // -- source (at most one; none = plain group node) --
107
+ mesh?: MeshDef
108
+ /** GLB url — `asset('./hero.glb')`. */
109
+ model?: string
110
+ light?: LightDef
111
+ /** The scene camera as a NODE: in play mode `scene.camera` follows this node's world transform
112
+ * every frame (so movement aspects on it are camera flythroughs); the editor shows a frustum
113
+ * marker and refuses to delete the last camera node. The first camera node in file order wins;
114
+ * cameras inside prefabs are ignored (like a prefab's `camera:` block). */
115
+ camera?: CameraNodeDef
116
+ /** A code-built subtree — `make(factoryFn, { ...literal args })`. */
117
+ make?: MakeEntry<any>
118
+ /** Another scene file used as a reusable composition — the imported handle:
119
+ * `import streetlamp from './streetlamp.scene'` … `lamp: { prefab: streetlamp }`. Its nodes
120
+ * instantiate under this node per instance (env/camera are the instancing file's business and
121
+ * are ignored); `ref()`s inside the prefab resolve file-locally, per instance. */
122
+ prefab?: SceneHandle<any>
123
+ /** Material for a `mesh` source. */
124
+ material?: MaterialDef
125
+ /** Model/prefab sources: transform overrides for the INTERNAL nodes, keyed by part path
126
+ * (see the part-path grammar above `modelPartRows`). Unresolved paths are ignored. */
127
+ overrides?: Record<string, ModelOverrideDef>
128
+ /** On a CHILD of a model/prefab node: parent this node to that INTERNAL part of the parent's
129
+ * asset at build time (part path — the same grammar `overrides` keys use), e.g. a flashlight
130
+ * in a hand. The transform stays local to the part. A stale path (asset changed) falls back
131
+ * to the parent root with a console warning. */
132
+ mount?: string
133
+ // -- transform / render --
134
+ position?: Vec3Like
135
+ eulerAngles?: Vec3Like
136
+ scale?: Vec3Like | number
137
+ visible?: boolean
138
+ /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
139
+ locked?: boolean
140
+ /** Editor-only, `model` nodes: the POSE the scene editor shows — a clip looped while editing
141
+ * (`time` freezes it at that second instead), so attachments / sight lines / a first-person eye
142
+ * are placed against the animated pose, not the rest pose. Never applied when the scene runs. */
143
+ editor?: { clip?: string, time?: number }
144
+ castShadows?: boolean
145
+ receiveShadows?: boolean
146
+ /** Baked lighting (a scene with `env.lightmap`): is this model/mesh node a lightmap STATIC — a
147
+ * receiver and an occluder in the bake, real-time shadow casting off once the bake applies?
148
+ * Default: static unless a `Physics` aspect moves the node (`dynamic` — Physics' default — or
149
+ * `kinematic`). Set it only to override that rule; prefab subtrees inherit the verdict. */
150
+ lightmap?: boolean
151
+ // -- capabilities / hierarchy --
152
+ aspects?: readonly AspectEntry<any>[]
153
+ children?: Record<string, SceneNodeDef>
154
+ }
155
+
156
+ export type SceneCameraDef = CameraProjectionDef & {
157
+ position?: Vec3Like
158
+ /** Point the camera looks at. */
159
+ target?: Vec3Like
160
+ }
161
+
162
+ /** `env.lightmap` — the level's baked lighting (see lightmap.md): the two files
163
+ * `lecodes lightmap bake` writes, plus how the bake is applied. Absent = real-time only. */
164
+ export type SceneLightmapDef = {
165
+ /** `asset('./assets/lightmap/lightmap.bake')` */
166
+ data: string
167
+ /** `asset('./assets/lightmap/lightmap.ktx2')` */
168
+ texture: string
169
+ /** 1 = baked sun shadows at full strength, 0 = ambient occlusion only. Default 1. */
170
+ sunStrength?: number
171
+ /** Multiplier on the ambient share in the shadow math (1 = filament's own darkness). Default 1. */
172
+ ambientScale?: number
173
+ }
174
+
175
+ export type SceneDef = {
176
+ env?: SceneOptions & { lightmap?: SceneLightmapDef }
177
+ camera?: SceneCameraDef
178
+ nodes?: Record<string, SceneNodeDef>
179
+ }
180
+
181
+ // ---- typed handle -------------------------------------------------------------
182
+
183
+ type SourceNodeOf<N extends SceneNodeDef> =
184
+ N extends { model: string } ? Model
185
+ : N extends { mesh: MeshDef } ? Mesh
186
+ : N extends { light: LightDef } ? Light
187
+ : Node
188
+
189
+ type AspectsOf<N extends SceneNodeDef> =
190
+ N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never
191
+
192
+ type NodeOf<N extends SceneNodeDef> =
193
+ [AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>
194
+
195
+ type UnionToIntersection<U> =
196
+ (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
197
+
198
+ // The child maps of a def level, prefixed with their parent's path, as a union (never when no
199
+ // node has children — guarded below, since `unknown` would absorb the union and `never` would
200
+ // poison the intersection).
201
+ type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
202
+ { [K in keyof T & string]:
203
+ T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
204
+ }[keyof T & string]
205
+
206
+ // All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
207
+ // its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
208
+ type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
209
+ { [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
210
+ ([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)
211
+
212
+ export type SceneNodes<D extends SceneDef> =
213
+ D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
214
+
215
+ export type LoadedScene<D extends SceneDef> = {
216
+ scene: Scene
217
+ nodes: SceneNodes<D>
218
+ /** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
219
+ get: {
220
+ <P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
221
+ (path: string): Node | null
222
+ }
223
+ }
224
+
225
+ // ---- GLB internal parts --------------------------------------------------------
226
+ // A model's internal hierarchy is addressed by PART PATHS — '/'-joined segments from the model
227
+ // root down, where a segment is the child's name, disambiguated as `name[i]` among same-named
228
+ // siblings and `[i]` for unnamed children (i = index within that same-named group). The editor
229
+ // enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
230
+ // loader resolves them back through the same enumeration, so writer and resolver can't drift.
231
+
232
+ /** One instance of a scene file built as a subtree (`handle.instantiate`). */
233
+ export type SceneInstance<D extends SceneDef> = {
234
+ /** The wrapper node the file's nodes build under — position it, parent it, hide it. */
235
+ root: Node
236
+ nodes: SceneNodes<D>
237
+ /** Anchor the instance on one of its own nodes: `root`'s local transform is set so that
238
+ * `inner` coincides with the frame `root` is parented to (its origin and axes). One-shot,
239
+ * from the CURRENT pose of `inner` — a rig's attachment frame (the eye place of a
240
+ * first-person arms scene, the grip of a held prop). */
241
+ alignTo(inner: Node): void
242
+ get: LoadedScene<D>["get"]
243
+ /** Remove the subtree from the scene and destroy it. */
244
+ dispose(): void
245
+ }
246
+
247
+ /** One INTERNAL node of a loaded GLB (editor introspection). */
248
+ export type ModelPartRow = { path: string, name: string, depth: number, node: Node }
249
+
250
+ const partSegment = (child: Node, siblings: Node[]): string => {
251
+ const name = child.name ?? ""
252
+ const group = siblings.filter((s) => (s.name ?? "") === name)
253
+ if (name !== "" && group.length === 1) return name
254
+ return `${name}[${group.indexOf(child)}]`
255
+ }
256
+
257
+ /** Flatten a model's (or prefab instance's) internal hierarchy to rows (depth-first, the root
258
+ * excluded). `__generated` containers are derived output, and def-built nodes (`_sceneDef` —
259
+ * plain or `mount`ed children of the model) are addressed by their own def paths — both are
260
+ * not parts, skipped. Filtering BEFORE segment math keeps `name[i]` indices stable no matter
261
+ * what defs are parented in. */
262
+ export const modelPartRows = (root: Node): ModelPartRow[] => {
263
+ const out: ModelPartRow[] = []
264
+ const walk = (node: Node, prefix: string, depth: number): void => {
265
+ const children = node.children.filter((c) =>
266
+ c.name !== "__generated" && !(c as { _sceneDef?: boolean })._sceneDef)
267
+ for (const child of children) {
268
+ const seg = partSegment(child, children)
269
+ const path = prefix === "" ? seg : `${prefix}/${seg}`
270
+ out.push({ path, name: child.name || seg, depth, node: child })
271
+ walk(child, path, depth + 1)
272
+ }
273
+ }
274
+ walk(root, "", 0)
275
+ return out
276
+ }
277
+
278
+ const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, ModelOverrideDef>): void => {
279
+ const byPath = new Map(rows.map((r) => [ r.path, r.node ]))
280
+ for (const [ path, o ] of Object.entries(overrides)) {
281
+ const node = byPath.get(path)
282
+ if (!node) continue // the source changed since the override was written — skip, don't throw
283
+ if (o.position) node.position = o.position
284
+ if (o.eulerAngles) node.eulerAngles = o.eulerAngles
285
+ if (o.scale !== undefined) node.scale = o.scale
286
+ if (o.visible !== undefined) node.visible = o.visible
287
+ // slot materials load in like textures do (a shader package fetch) — the part keeps its glTF
288
+ // material until then
289
+ if (o.materials) {
290
+ for (const [ slot, def ] of Object.entries(o.materials)) {
291
+ const index = Number(slot)
292
+ if (!Number.isInteger(index) || index < 0) continue
293
+ void assignMaterialDef(node, index, def).catch((e) => {
294
+ console.warn(`[scene] material for "${path}" slot ${index}: ${e instanceof Error ? e.message : String(e)}`)
295
+ })
296
+ }
297
+ }
298
+ }
299
+ }
300
+
301
+ const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverrideDef>): void =>
302
+ applyOverridesToRows(modelPartRows(root), overrides)
303
+
304
+ /** Resolve a def's `mount` part path against its parent's internal rows (the same enumeration
305
+ * `overrides` resolves through). Null when the parent carries no parts at all — a rebuild of an
306
+ * already-mounted child passes its live part parent here, which is not an error — while a KNOWN
307
+ * part list that misses the path (asset changed) warns, mirroring the overrides skip rule. */
308
+ const resolveMountNode = (parent: Node, mountPath: string, ownerPath: string): Node | null => {
309
+ const rows = parent instanceof Model
310
+ ? modelPartRows(parent)
311
+ : (parent as { _prefabParts?: ModelPartRow[] })._prefabParts
312
+ if (!rows || rows.length === 0) return null
313
+ const hit = rows.find((r) => r.path === mountPath)
314
+ if (!hit) console.warn(`[scene] "${ownerPath}".mount = "${mountPath}" matches no part — attached to the parent root`)
315
+ return hit?.node ?? null
316
+ }
317
+
318
+ /** Where a def node attaches: its `mount` part when it resolves, else the parent itself. */
319
+ const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
320
+ def.mount !== undefined ? resolveMountNode(parent, def.mount, path) ?? parent : parent
321
+
322
+ // ---- runtime -------------------------------------------------------------------
323
+
324
+ /** A mesh node resolves its material BEFORE it appears (a custom shader fetches its compiled
325
+ * package, textures decode) — the same contract a model has with its GLB. Handle users are
326
+ * tracked so the editor can re-assign them when the asset's shader changes. */
327
+ const createMesh = async (def: MeshDef, material?: MaterialDef): Promise<Mesh> => {
328
+ const mat = material !== undefined ? await resolveMaterialDef(material) : undefined
329
+ let mesh: Mesh
330
+ switch (def.kind) {
331
+ case "box": mesh = Mesh.box({ size: def.size, material: mat }); break
332
+ case "sphere": { const { kind: _k, ...opts } = def; mesh = Mesh.sphere({ ...opts, material: mat }); break }
333
+ case "cylinder": { const { kind: _k, ...opts } = def; mesh = Mesh.cylinder({ ...opts, material: mat }); break }
334
+ case "plane": { const { kind: _k, ...opts } = def; mesh = Mesh.plane({ ...opts, material: mat }); break }
335
+ }
336
+ if (material instanceof MaterialHandle) material._users.add({ node: mesh, slot: 0 })
337
+ return mesh
338
+ }
339
+
340
+ // ---- baked lighting (env.lightmap) ---------------------------------------------------------------
341
+ // Statics are derived, not declared: a model/mesh node bakes unless a Physics aspect moves it
342
+ // (`dynamic` — Physics' default — or `kinematic`); `lightmap: true | false` on the node overrides.
343
+ // Keys are node paths (prefab internals: `wrapper/inner`), so lightmap.bake stays readable and a
344
+ // rename honestly invalidates the rect. make() subtrees are code — they register themselves with
345
+ // Lightmap.add. Edit mode never bakes or applies (the editor shows real-time shadows).
346
+
347
+ type LightmapCtx = { prefix: string }
348
+
349
+ const movesByPhysics = (def: SceneNodeDef): boolean =>
350
+ (def.aspects ?? []).some((e) =>
351
+ (e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
352
+
353
+ /** The static verdict for one node def (the editor's "Baked lighting" switch shows the same rule). */
354
+ export const isLightmapStatic = (def: SceneNodeDef): boolean => def.lightmap ?? !movesByPhysics(def)
355
+
356
+ const createSource = (path: string, def: SceneNodeDef, lightmap = false): Node | Promise<Node> => {
357
+ const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
358
+ if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
359
+ if (def.model !== undefined) return Model.load(def.model, { lightmap })
360
+ if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
361
+ if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
362
+ // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
363
+ // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
364
+ return new Node()
365
+ }
366
+
367
+ /** Edit mode: play the preview pose on a model node (`editor: { clip, time }`) — idempotent, the
368
+ * harness re-applies it on inspector edits. No clip = back to the rest pose. */
369
+ export const applyEditorPose = (node: Node, pose: { clip?: string, time?: number } | undefined): void => {
370
+ const anim = (node as unknown as { anim?: Model["anim"] }).anim
371
+ if (!anim) return
372
+ const clip = pose?.clip
373
+ if (!clip || !anim.clip(clip)) {
374
+ anim.speed = 1
375
+ anim.stop()
376
+ return
377
+ }
378
+ anim.speed = 1
379
+ if (pose?.time !== undefined) {
380
+ anim.play(clip, { restart: true }).seek(pose.time)
381
+ anim.speed = 0
382
+ } else {
383
+ anim.playLoop(clip)
384
+ }
385
+ }
386
+
387
+ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
388
+ node.name = name
389
+ // def-built marker: part enumeration must skip this node (it is not asset-internal — it has
390
+ // its own def path), even when it is parented inside a model via `mount`
391
+ ;(node as { _sceneDef?: boolean })._sceneDef = true
392
+ if (def.position) node.position = def.position
393
+ if (def.eulerAngles) node.eulerAngles = def.eulerAngles
394
+ if (def.scale !== undefined) node.scale = def.scale
395
+ if (def.visible !== undefined) node.visible = def.visible
396
+ // editor-only flag (the harness's manipulation layer consults it); inert at runtime
397
+ if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
398
+ if (def.editor !== undefined && isEditMode()) applyEditorPose(node, def.editor)
399
+ if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
400
+ // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
401
+ // order is insertion-based and may not match the file
402
+ if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
403
+ if (node instanceof Mesh) {
404
+ if (def.castShadows !== undefined) node.castShadows = def.castShadows
405
+ if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
406
+ }
407
+ if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
408
+ }
409
+
410
+ // ---- edit-mode node markers + the play-mode camera rig -------------------------------------------
411
+
412
+ /** True for a def with no source block at all — a plain group ("Empty" in the editor). */
413
+ const isGroupDef = (def: SceneNodeDef): boolean =>
414
+ def.mesh === undefined && def.model === undefined && def.light === undefined
415
+ && def.make === undefined && def.prefab === undefined && def.camera === undefined
416
+
417
+ /** Edit mode: otherwise-invisible nodes (empties, `camera:` nodes) get an ANCHORED gizmo marker —
418
+ * an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
419
+ * in the viewport. Never scene content: nothing renders in Filament, nothing outlines, and the
420
+ * engine follows the node live, so the buffer is filled once and lives on the node
421
+ * (`_editorMarker`) — `_editorGizmos()` reads it off the handle's own def nodes, so a removed
422
+ * node's marker goes with its record and prefab / instance internals (not selectable) draw none. */
423
+ const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
424
+ const buffer = new GizmoBuffer()
425
+ if (def.camera !== undefined) {
426
+ withGizmoScope(buffer, () => Gizmos.frustum(def.camera?.fov ?? CAMERA_DEFAULTS.fov, { node, color: "#cfd4dd" }))
427
+ } else if (isGroupDef(def)) {
428
+ withGizmoScope(buffer, () => Gizmos.cross([ 0, 0, 0 ], 0.3, { node, color: "#8f96a3" }))
429
+ } else {
430
+ return
431
+ }
432
+ ;(node as { _editorMarker?: GizmoBuffer })._editorMarker = buffer
433
+ gizmoVersion++
434
+ }
435
+
436
+ /** The ACTIVE `CameraPlace` entry in THIS file's own defs (depth-first; prefab / instance internals
437
+ * are another file's business): its host path + props, or null. With no `active: true` anywhere,
438
+ * the first place in file order is it — a file with a single place needn't say so. */
439
+ const findCameraPlace = (defs?: Record<string, SceneNodeDef>): { path: string, props: Record<string, unknown> } | null => {
440
+ let first: { path: string, props: Record<string, unknown> } | null = null
441
+ const walk = (d: Record<string, SceneNodeDef> | undefined, prefix: string): { path: string, props: Record<string, unknown> } | null => {
442
+ for (const [ name, nd ] of Object.entries(d ?? {})) {
443
+ const path = prefix === "" ? name : `${prefix}/${name}`
444
+ for (const entry of nd.aspects ?? []) {
445
+ if (entry.ctor !== CameraPlace) continue
446
+ const props = (entry.props ?? {}) as Record<string, unknown>
447
+ if (props.active === true) return { path, props }
448
+ first ??= { path, props }
449
+ }
450
+ const inner = walk(nd.children, path)
451
+ if (inner) return inner
452
+ }
453
+ return null
454
+ }
455
+ return walk(defs, "") ?? first
456
+ }
457
+
458
+ /** The first camera-source def in file order (depth-first) — its path and block — or null. */
459
+ const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
460
+ for (const [ name, nd ] of Object.entries(defs ?? {})) {
461
+ const path = prefix === "" ? name : `${prefix}/${name}`
462
+ if (nd.camera !== undefined) return { path, def: nd.camera }
463
+ const inner = findCamera(nd.children, path)
464
+ if (inner) return inner
465
+ }
466
+ return null
467
+ }
468
+
469
+ /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
470
+ * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
471
+ * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
472
+ const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
473
+ const { fov, near, far } = def
474
+ if (!reset && fov === undefined && near === undefined && far === undefined) return
475
+ scene.camera.setProjection(reset
476
+ ? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
477
+ : { fov, near, far })
478
+ }
479
+
480
+ // ---- editor-run aspects (generators) ------------------------------------------------------------
481
+ // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
482
+ // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
483
+ // editor re-runs rebuild() on inspector prop edits (`_editorSetProp`) and whenever a node its
484
+ // ref() fields point at changes (`_editorNodeChanged` — dep tracking is derived from the props).
485
+ // Generated output lives under `this.generated`, a scene-added container child: never written to
486
+ // the file, absent from the doc tree — the file stores the recipe, the viewport shows the result.
487
+
488
+ const isEditorCtor = (ctor: unknown): boolean => !!(ctor as { editor?: unknown }).editor
489
+
490
+ /** The `generated` container: children join the scene's draw set on add (membership is separate
491
+ * from parenting — `addEntityToScene` only recurses over children that exist at add time). */
492
+ class GeneratedGroup extends Node {
493
+ /** @internal */ _scene!: Scene
494
+ /** Bumped by every `clear()`. An ASYNC rebuild captures it before awaiting and drops its own
495
+ * result when the value moved on (a newer rebuild cleared the container in the meantime). */
496
+ version = 0
497
+ add(...children: Node[]): this {
498
+ super.add(...children)
499
+ this._scene.add(...children)
500
+ return this
501
+ }
502
+ /** Destroy all generated children (rebuild() calls this first — idempotent regeneration). */
503
+ clear(): this {
504
+ this.version++
505
+ for (const c of [ ...this.children ]) {
506
+ dropForeignRuns(c)
507
+ this._scene.remove(c)
508
+ c.destroy()
509
+ }
510
+ return this
511
+ }
512
+ }
513
+
514
+ // Edit mode: generator runs inside scene INSTANCES built by `instantiate()` (a weapon under the
515
+ // arms' gun socket) are not the edited handle's runs — not inspector-addressable, not
516
+ // dep-tracked — but their gizmos (the weapon's sight line) must still draw. They register here
517
+ // keyed by the instance root; `dispose()` / a hosting `generated.clear()` drops them.
518
+ const foreignRuns = new Map<Node, EditorRun[]>()
519
+ const dropForeignRuns = (root: Node): void => {
520
+ for (const key of [ ...foreignRuns.keys() ]) {
521
+ let n: Node | null = key
522
+ while (n && n !== root) n = n.parent
523
+ if (n === root) foreignRuns.delete(key)
524
+ }
525
+ }
526
+
527
+ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
528
+ const group = new GeneratedGroup()
529
+ group.name = "__generated"
530
+ group._scene = scene
531
+ node.add(group)
532
+ scene.add(group)
533
+ return group
534
+ }
535
+
536
+ /** One live editor-run aspect instance (edit mode only). */
537
+ type EditorRun = {
538
+ /** Absolute path of the host def node (re-keyed on rename/reparent). */
539
+ hostPath: string
540
+ node: Node
541
+ /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
542
+ index: number
543
+ inst: { rebuild?(): void | Promise<void> }
544
+ /** Async rebuild() supersession counter (see safeRebuild). */
545
+ generation: number
546
+ /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
547
+ * DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
548
+ * against the file's props, so rewriting them would re-fire on every render. */
549
+ props: Record<string, unknown>
550
+ /** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
551
+ * their subtrees) re-runs rebuild(). Re-derived after every structural change. */
552
+ deps: Set<string>
553
+ /** Editor lines drawn by the last rebuild() (`Gizmos.*` calls — see scene/gizmos.ts). */
554
+ gizmos: GizmoBuffer
555
+ }
556
+
557
+ /** Bumped on every editor-run rebuild — the host compares it to know when to re-push gizmos. */
558
+ let gizmoVersion = 0
559
+
560
+ const safeRebuild = (run: EditorRun): Promise<void> | void => {
561
+ // a throwing generator must not take the editor session down with it; the gizmo scope is the
562
+ // SYNCHRONOUS part of the call — whatever rebuild draws replaces the run's previous lines. An
563
+ // async rebuild() (one that instantiates a scene file) is awaited; lines it wants to draw after
564
+ // its awaits go in an optional `draw()`, run in a fresh scope once the promise settles.
565
+ gizmoVersion++
566
+ let result: unknown
567
+ try { result = withGizmoScope(run.gizmos, () => run.inst.rebuild?.()) }
568
+ catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e); return }
569
+ if (!(result instanceof Promise)) return
570
+ const gen = ++run.generation
571
+ return result.then(() => {
572
+ if (gen !== run.generation) return // superseded by a newer rebuild
573
+ gizmoVersion++ // generated content changed — the host re-pushes overlays
574
+ const draw = (run.inst as { draw?(): void }).draw
575
+ if (typeof draw !== "function") return
576
+ try { withGizmoScope(run.gizmos, () => draw.call(run.inst)) }
577
+ catch (e) { console.error(`[scene] editor aspect draw failed on "${run.hostPath}":`, e) }
578
+ }, (e) => console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e))
579
+ }
580
+
581
+ /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
582
+ * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
583
+ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
584
+ const lookup = (r: string): Node | null => {
585
+ const p = resolveRefPath(nodes, run.hostPath, r)
586
+ return p === null ? null : nodes[p]
587
+ }
588
+ for (const [ k, v ] of Object.entries(run.props)) {
589
+ if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
590
+ else if (Array.isArray(v) && v.some(isNodeRef)) {
591
+ ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
592
+ }
593
+ }
594
+ }
595
+
596
+ // ---- make() sources (code-built subtrees) -------------------------------------------------------
597
+ // The factory runs in phase 2 (every node exists → ref() args resolve) and its result mounts in a
598
+ // `generated` container under the def-node wrapper — the wrapper's transform is editor-owned, so
599
+ // moving a make node never re-calls the factory. In edit mode the run is tracked as an EditorRun
600
+ // (index MAKE_INDEX): arg edits and ref-dep changes re-call the factory through the exact same
601
+ // machinery generator aspects use — live, no compile (the code is already in the bundle).
602
+
603
+ /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
604
+ const MAKE_INDEX = -1
605
+
606
+ const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
607
+ let token = 0
608
+ const inst: Record<string, unknown> = {}
609
+ inst.rebuild = () => {
610
+ const t = ++token
611
+ generated.clear()
612
+ // current args = the instance's own fields (that's where _editorSetProp/assignRefProps write)
613
+ const args: Record<string, unknown> = {}
614
+ for (const [ k, v ] of Object.entries(inst)) if (k !== "rebuild") args[k] = v
615
+ const result = entry.fn(args as never)
616
+ if (result instanceof Node) { generated.add(result); return }
617
+ void Promise.resolve(result).then((made) => {
618
+ // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
619
+ if (t === token && made instanceof Node) generated.add(made)
620
+ }).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
621
+ }
622
+ return inst as unknown as { rebuild(): void }
623
+ }
624
+
625
+ /** Run a def's make() factory (phase 2). Play mode returns the factory's completion — the loader
626
+ * awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
627
+ * and returns immediately (async content pops in when ready, like a model load). */
628
+ const attachMake = (
629
+ node: Node, path: string, def: SceneNodeDef,
630
+ nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
631
+ ): Promise<void> | undefined => {
632
+ const entry = def.make
633
+ if (!entry) return undefined
634
+ const generated = makeGenerated(node, scene)
635
+ if (isEditMode()) {
636
+ const props = { ...(entry.args ?? {}) } as Record<string, unknown>
637
+ const inst = createMakeInst(path, entry, generated)
638
+ Object.assign(inst, resolveRefs(props, nodes, path))
639
+ const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path), gizmos: new GizmoBuffer(), generation: 0 }
640
+ editorRuns.push(run)
641
+ safeRebuild(run)
642
+ return undefined
643
+ }
644
+ const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
645
+ return Promise.resolve(entry.fn(args as never)).then((made) => {
646
+ if (made instanceof Node) generated.add(made)
647
+ })
648
+ }
649
+
650
+ const attachAspects = (
651
+ node: Node, path: string, def: SceneNodeDef,
652
+ nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
653
+ ): Promise<void> | void => {
654
+ if (isEditMode()) {
655
+ const waits: Promise<void>[] = []
656
+ // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
657
+ // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
658
+ // EXCEPT editor-run classes (generators) — they get a real, tracked instance (above).
659
+ ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
660
+ ;(def.aspects ?? []).forEach((entry, index) => {
661
+ if (!isEditorCtor(entry.ctor)) return
662
+ const props = { ...(entry.props ?? {}) } as Record<string, unknown>
663
+ const inst = new (entry.ctor as unknown as new () => { rebuild?(): void | Promise<void> })()
664
+ ;(inst as { node: unknown }).node = node
665
+ ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
666
+ ;(inst as { scene: unknown }).scene = scene
667
+ Object.assign(inst, resolveRefs(props, nodes, path))
668
+ // reachable through `node.get(Ctor)` like an attached aspect (a nested rig's contract is read
669
+ // by the generator that instantiated it) — registered, never attached: no onAttach/update
670
+ ;(node as unknown as { _aspects: Map<Function, unknown> })._aspects.set(entry.ctor as Function, inst)
671
+ const accessor = (entry.ctor as { aspect?: string }).aspect
672
+ if (accessor) (node as unknown as Record<string, unknown>)[accessor] = inst
673
+ // the HOST node is a dep too: a generator that draws relative to its node (path lines)
674
+ // must re-run when the node itself is dragged, not only when its ref() targets move
675
+ const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path), gizmos: new GizmoBuffer(), generation: 0 }
676
+ editorRuns.push(run)
677
+ const wait = safeRebuild(run)
678
+ if (wait) waits.push(wait)
679
+ })
680
+ return waits.length > 0 ? Promise.all(waits).then(() => undefined) : undefined
681
+ }
682
+ for (const entry of def.aspects ?? []) {
683
+ const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
684
+ const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene), scene } : props
685
+ ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
686
+ }
687
+ }
688
+
689
+ // ---- prefabs (scene-in-scene) --------------------------------------------------------------------
690
+ // A `prefab:` node instantiates another scene file's nodes under a plain wrapper — per instance,
691
+ // from the imported handle's DEF (never handle.load(): that would share one singleton instance).
692
+ // Each instance gets its own LOCAL node map, so `ref()`s inside the prefab resolve file-locally
693
+ // and instances never collide in the parent's flat map. The instance's aspects attach normally in
694
+ // play mode; in edit mode they stay data-only like everything else, and its generators / make()
695
+ // factories run ONCE for display but are NOT tracked for editing (internals are posed through
696
+ // `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
697
+
698
+ /** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
699
+ * order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
700
+ * the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
701
+ * their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
702
+ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
703
+ const out: ModelPartRow[] = []
704
+ for (const [ name, nd ] of Object.entries(defs)) {
705
+ const path = prefix === "" ? name : `${prefix}/${name}`
706
+ const node = local[path]
707
+ if (!node) continue
708
+ out.push({ path, name, depth, node })
709
+ out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
710
+ const inner = node instanceof Model
711
+ ? modelPartRows(node)
712
+ : (node as { _prefabParts?: ModelPartRow[] })._prefabParts
713
+ if (inner) out.push(...inner.map((r) => ({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })))
714
+ }
715
+ return out
716
+ }
717
+
718
+ const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
719
+ const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
720
+ if (!pdef || typeof pdef !== "object") {
721
+ console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
722
+ return
723
+ }
724
+ // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
725
+ ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
726
+ if (stack.has(pdef)) {
727
+ console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
728
+ return
729
+ }
730
+ const local: Record<string, Node> = {}
731
+ const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
732
+ await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef), lm)
733
+ const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
734
+ ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
735
+ if (def.overrides) applyOverridesToRows(rows, def.overrides)
736
+ }
737
+
738
+ /** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
739
+ * aspects/make — so `ref()`s resolve regardless of declaration order), into the given flat node
740
+ * map. The top-level scene and every prefab instance run through here, each with its own map. */
741
+ const buildNodes = async (
742
+ defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
743
+ nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
744
+ lm: LightmapCtx | null = null,
745
+ ): Promise<void> => {
746
+ const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
747
+
748
+ const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
749
+ // names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
750
+ if (name === "" || name.includes("/") || name.includes(":")) {
751
+ throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
752
+ }
753
+ // the path is derived from def keys BEFORE any await, so it is deterministic even though
754
+ // Promise.all makes build completion (and record insertion) order nondeterministic
755
+ const path = parentPath === "" ? name : `${parentPath}/${name}`
756
+ const lmStatic = lm !== null && isLightmapStatic(nd)
757
+ const node = await createSource(path, nd, lmStatic)
758
+ // a `mount` def parents to an internal part of the parent asset — safe here: the parent's
759
+ // model was awaited and attachPrefab completed before its children build
760
+ if (parentNode) attachHost(parentNode, nd, path).add(node)
761
+ // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
762
+ scene.add(node)
763
+ applyNode(node, name, nd) // engine-side name stays the bare sibling segment
764
+ if (lmStatic && (node instanceof Model || node instanceof Mesh)) Lightmap._register(node, lm!.prefix + path)
765
+ if (isEditMode()) addEditorMarker(node, nd)
766
+ if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
767
+ pending.push({ path, node, def: nd })
768
+ nodes[path] = node
769
+ await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
770
+ }
771
+
772
+ await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
773
+ const makeWaits: Promise<void>[] = []
774
+ for (const p of pending) {
775
+ const aspectWait = attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
776
+ if (aspectWait) makeWaits.push(aspectWait)
777
+ const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
778
+ if (wait) makeWaits.push(wait)
779
+ }
780
+ if (makeWaits.length > 0) await Promise.all(makeWaits)
781
+ }
782
+
783
+ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
784
+ const { lightmap, ...env } = def.env ?? {}
785
+ const scene = new Scene(env)
786
+ const nodes: Record<string, Node> = {}
787
+ // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
788
+ const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "" } : null
789
+ if (lm) Lightmap.clear()
790
+ await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
791
+
792
+ // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
793
+ // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
794
+ // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
795
+ // lens the running app will use.
796
+ const place = findCameraPlace(def.nodes)
797
+ const placeNode = place ? nodes[place.path] : undefined
798
+ const cam = findCamera(def.nodes)
799
+ const camNode = cam ? nodes[cam.path] : undefined
800
+ if (place && placeNode) {
801
+ // the active CameraPlace (an ASPECT on any node — the preferred form; one per file)
802
+ const proj = { fov: place.props.fov as number | undefined, near: place.props.near as number | undefined, far: place.props.far as number | undefined }
803
+ scene.camera.position = placeNode.worldPosition
804
+ scene.camera.quaternion = placeNode.worldQuaternion
805
+ applyCameraProjection(scene, proj)
806
+ if (!isEditMode()) scene.camera.follow(placeNode)
807
+ } else if (cam && camNode) {
808
+ // legacy: the `camera: {}` node source block (still honoured — prefer a CameraPlace aspect)
809
+ scene.camera.position = camNode.worldPosition
810
+ scene.camera.quaternion = camNode.worldQuaternion
811
+ applyCameraProjection(scene, cam.def)
812
+ if (!isEditMode()) scene.camera.follow(camNode)
813
+ } else if (def.camera) {
814
+ if (def.camera.position) scene.camera.position = def.camera.position
815
+ if (def.camera.target) scene.camera.lookAt(def.camera.target)
816
+ applyCameraProjection(scene, def.camera)
817
+ }
818
+
819
+ // the level is complete: apply the bake — or, under `lecodes lightmap bake`, run it
820
+ if (lm && lightmap) {
821
+ await Lightmap.load(scene, { data: lightmap.data, texture: lightmap.texture },
822
+ { sunStrength: lightmap.sunStrength, ambientScale: lightmap.ambientScale })
823
+ }
824
+
825
+ return { scene, nodes }
826
+ }
827
+
828
+ export class SceneHandle<D extends SceneDef = SceneDef> {
829
+ readonly def: D
830
+ private _loading?: Promise<LoadedScene<D>>
831
+ /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). */
832
+ private _editorRuns: EditorRun[] = []
833
+ /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
834
+ private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
835
+ /** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
836
+ * load resolves). The record object is SHARED with the returned LoadedScene — patches and
837
+ * rename/reparent re-keying are visible through both. */
838
+ private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
839
+
840
+ constructor(def: D) { this.def = def }
841
+
842
+ /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
843
+ load(): Promise<LoadedScene<D>> {
844
+ if (!this._loading) {
845
+ this._loading = instantiate(this.def, this._editorRuns).then((live) => {
846
+ this._live = live
847
+ const get = (path: string): Node | null => live.nodes[path] ?? null
848
+ return { ...live, get } as unknown as LoadedScene<D>
849
+ })
850
+ }
851
+ return this._loading
852
+ }
853
+
854
+ /** Load and make active. */
855
+ async open(): Promise<LoadedScene<D>> {
856
+ const loaded = await this.load()
857
+ loaded.scene.open()
858
+ return loaded
859
+ }
860
+
861
+ /**
862
+ * Build this scene file as a reusable SUBTREE inside an existing scene — a weapon under a hand
863
+ * bone, a streetlamp per street corner — as many times as you like (unlike `load()`, which is
864
+ * the one-instance "scene as a level" path). The nodes build under a fresh wrapper node (`root`)
865
+ * parented to `parent` (or left unparented); `env` / `camera` are ignored like a prefab's. The
866
+ * file's transforms are local to the wrapper, so author it with the wrapper as the attachment
867
+ * point. `ref()`s resolve per instance; aspects attach per instance. `dispose()` removes the
868
+ * subtree from the scene and destroys it.
869
+ */
870
+ async instantiate(opts: { scene: Scene, parent?: Node | null, name?: string }): Promise<SceneInstance<D>> {
871
+ const { scene } = opts
872
+ const root = new Node()
873
+ root.name = opts.name ?? "__instance"
874
+ if (opts.parent) opts.parent.add(root)
875
+ scene.add(root)
876
+ const nodes: Record<string, Node> = {}
877
+ // instance internals are not inspector-addressable (like prefab internals), but in edit mode
878
+ // their generators' gizmos still draw (foreignRuns — a nested rig's sight line)
879
+ const runs: EditorRun[] = []
880
+ await buildNodes(this.def.nodes ?? {}, root, scene, nodes, runs, new Set([ this.def ]))
881
+ if (isEditMode() && runs.length > 0) { foreignRuns.set(root, runs); gizmoVersion++ }
882
+ const get = (path: string): Node | null => nodes[path] ?? null
883
+ const dispose = (): void => {
884
+ foreignRuns.delete(root)
885
+ root.visible = false // the visibility cascade retires the subtree's pick colliders first
886
+ scene.remove(root)
887
+ root.destroy()
888
+ for (const key of Object.keys(nodes)) delete nodes[key]
889
+ }
890
+ const alignTo = (inner: Node): void => {
891
+ // inner's pose in root's frame is independent of root's own local transform:
892
+ // rel = root.world⁻¹ · inner.world; root.local = rel⁻¹ puts inner on root's parent frame
893
+ root.matrix = root.worldMatrix.invert().mul(inner.worldMatrix).invert()
894
+ }
895
+ return { root, nodes, get, dispose, alignTo } as unknown as SceneInstance<D>
896
+ }
897
+
898
+ /**
899
+ * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
900
+ * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
901
+ * `path` in place (or adds it when the path is new — the path's parent must exist, root paths
902
+ * mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
903
+ * data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
904
+ * are inert in edit mode and stay out of defs.
905
+ *
906
+ * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
907
+ * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
908
+ * removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
909
+ * are not reclaimed until the next full re-run — acceptable churn for an edit session.
910
+ */
911
+ async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
912
+ const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
913
+ const old: Node | undefined = nodes[path]
914
+
915
+ const dispose = (root: Node): void => {
916
+ // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
917
+ // no removeCollider — a destroyed entity's stale collider entry must never pick again).
918
+ root.visible = false
919
+ const doomed = new Set<Node>()
920
+ root.traverse((n) => doomed.add(n))
921
+ for (const [ key, n ] of Object.entries(nodes)) if (doomed.has(n)) delete nodes[key]
922
+ // editor-run aspect instances hosted in the doomed subtree go with it (generated children
923
+ // are subtree children, so the destroy below reclaims them too)
924
+ this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
925
+ gizmoVersion++
926
+ scene.remove(root)
927
+ root.destroy() // native destroyEntity recurses over remaining children
928
+ }
929
+
930
+ if (def === null) {
931
+ if (old) {
932
+ dispose(old)
933
+ this._refreshRunDeps()
934
+ }
935
+ return null
936
+ }
937
+
938
+ const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
939
+ // child reuse is by FULL path — only the live subtree at this exact address is carried
940
+ // over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
941
+ const existing = p === path ? undefined : nodes[p]
942
+ if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
943
+ if (parent) attachHost(parent, d, p).add(existing)
944
+ return existing
945
+ }
946
+ const node = await createSource(p, d)
947
+ if (parent) attachHost(parent, d, p).add(node)
948
+ scene.add(node)
949
+ applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
950
+ if (isEditMode()) addEditorMarker(node, d)
951
+ attachAspects(node, p, d, nodes, scene, this._editorRuns)
952
+ nodes[p] = node
953
+ await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
954
+ return node
955
+ }
956
+
957
+ const cut = path.lastIndexOf("/")
958
+ const parentPath = cut < 0 ? null : path.slice(0, cut)
959
+ if (!old && parentPath !== null && !nodes[parentPath]) return null
960
+ const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
961
+ const fresh = await build(path, def, parent)
962
+ if (old) {
963
+ // named children not listed in the def still move over (the editor patches one node at a time)
964
+ const named = new Set(Object.values(nodes))
965
+ for (const child of old.children) if (named.has(child)) fresh.add(child)
966
+ dispose(old) // fresh already replaced nodes[path] in build(), so it survives
967
+ nodes[path] = fresh
968
+ }
969
+ // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
970
+ // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
971
+ if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
972
+ this._refreshRunDeps()
973
+ return fresh
974
+ }
975
+
976
+ /** @internal Editor: every run's gizmo lines (world-space LINES batches) + a version that
977
+ * changes whenever any rebuild ran — the host re-pushes to the engine only on a change. */
978
+ _editorGizmos(): { version: number, batches: GizmoBatch[] } {
979
+ const batches: GizmoBatch[] = []
980
+ for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
981
+ 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)
982
+ for (const node of Object.values(this._live?.nodes ?? {})) {
983
+ const marker = (node as { _editorMarker?: GizmoBuffer })._editorMarker
984
+ if (marker) for (const b of marker.batches) batches.push(b)
985
+ }
986
+ return { version: gizmoVersion, batches }
987
+ }
988
+
989
+ /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
990
+ * absolute paths, so any structural change can invalidate them: an add can satisfy a
991
+ * previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
992
+ private _refreshRunDeps(): void {
993
+ const nodes = this._live?.nodes
994
+ if (!nodes) return
995
+ for (const run of this._editorRuns) {
996
+ run.deps = collectRefDeps(run.props, nodes, run.hostPath)
997
+ // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
998
+ // wrapper's transform is editor-owned and moving it never re-calls the factory
999
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
1000
+ }
1001
+ }
1002
+
1003
+ /** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
1004
+ * runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
1005
+ * the host — mutation, not replacement. */
1006
+ private _rekey(oldPath: string, newPath: string): void {
1007
+ const nodes = this._live!.nodes
1008
+ const move = (key: string): string | null =>
1009
+ key === oldPath ? newPath
1010
+ : key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
1011
+ : null
1012
+ for (const key of Object.keys(nodes)) {
1013
+ const next = move(key)
1014
+ if (next === null) continue
1015
+ const n = nodes[key]
1016
+ delete nodes[key]
1017
+ nodes[next] = n
1018
+ }
1019
+ for (const run of this._editorRuns) {
1020
+ const next = move(run.hostPath)
1021
+ if (next !== null) run.hostPath = next
1022
+ }
1023
+ for (const [ key, card ] of [ ...this._inspectorCards ]) {
1024
+ const i = key.lastIndexOf(":")
1025
+ const next = move(key.slice(0, i))
1026
+ if (next === null) continue
1027
+ this._inspectorCards.delete(key)
1028
+ this._inspectorCards.set(next + key.slice(i), card)
1029
+ }
1030
+ this._refreshRunDeps()
1031
+ }
1032
+
1033
+ /** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
1034
+ * Owns the shared record's re-keying (the host re-keys only its own part/selection state).
1035
+ * Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
1036
+ _renameNode(path: string, newName: string): string | null {
1037
+ const nodes = this._live?.nodes
1038
+ const node = nodes?.[path]
1039
+ if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
1040
+ const cut = path.lastIndexOf("/")
1041
+ const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
1042
+ if (newPath === path) return path
1043
+ if (nodes[newPath]) return null
1044
+ this._rekey(path, newPath)
1045
+ node.name = newName // engine-side name stays the bare segment
1046
+ return newPath
1047
+ }
1048
+
1049
+ /** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
1050
+ * every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
1051
+ * cycle, name taken among the new siblings). */
1052
+ _reparentNode(path: string, newParentPath: string | null): string | null {
1053
+ const nodes = this._live?.nodes
1054
+ const node = nodes?.[path]
1055
+ if (!nodes || !node) return null
1056
+ const parent = newParentPath === null ? null : nodes[newParentPath]
1057
+ if (newParentPath !== null && !parent) return null
1058
+ if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
1059
+ const name = path.slice(path.lastIndexOf("/") + 1)
1060
+ const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
1061
+ if (newPath === path) return path
1062
+ if (nodes[newPath]) return null
1063
+ this._rekey(path, newPath)
1064
+ node.setParent(parent ?? null, false)
1065
+ return newPath
1066
+ }
1067
+
1068
+ /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
1069
+ * and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
1070
+ * absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
1071
+ * FollowPath's waypoints are the children of its referenced path node) is a prefix test:
1072
+ * a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
1073
+ _editorNodeChanged(path: string): void {
1074
+ const nodes = this._live?.nodes
1075
+ if (!nodes) return
1076
+ for (const run of this._editorRuns) {
1077
+ let hit = false
1078
+ for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
1079
+ if (!hit) continue
1080
+ assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
1081
+ safeRebuild(run)
1082
+ }
1083
+ }
1084
+
1085
+ /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
1086
+ * run (no compile; the factory is already in the bundle). False for non-make nodes. */
1087
+ _editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
1088
+ return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
1089
+ }
1090
+
1091
+ /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
1092
+ * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
1093
+ * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
1094
+ _editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
1095
+ const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
1096
+ const nodes = this._live?.nodes
1097
+ if (!run || !nodes) return false
1098
+ run.props[key] = value
1099
+ run.deps = collectRefDeps(run.props, nodes, run.hostPath)
1100
+ // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
1101
+ // wrapper's transform is editor-owned and moving it never re-calls the factory
1102
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
1103
+ if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
1104
+ else (run.inst as Record<string, unknown>)[key] = value
1105
+ safeRebuild(run)
1106
+ return true
1107
+ }
1108
+
1109
+ /**
1110
+ * @internal Editor: run an aspect's custom `static inspector` card (immediate-mode — see
1111
+ * core/InspectorUI.ts) and return its widget list. Null when the class has no inspector (the
1112
+ * editor falls back to the inferred fields).
1113
+ *
1114
+ * `props` is the entry's CURRENT doc props, passed on EVERY call — the world syncs its side
1115
+ * from it (a generator syncs through the `_editorSetProp` machinery, so an undo that changes a
1116
+ * prop rebuilds for free; a plain aspect's preview instance is reassigned). `event` carries only
1117
+ * buttons and editor-state field edits — doc-bound field edits arrive as changed `props`.
1118
+ *
1119
+ * Cards persist per `<host>:<index>` across calls (that's where `ui.state` lives); a card whose
1120
+ * node instance was replaced by a live patch is rebuilt transparently.
1121
+ */
1122
+ _inspectorRender(
1123
+ hostPath: string, index: number,
1124
+ props: Record<string, unknown>, event?: InspectorEvent,
1125
+ ): InspectorWidget[] | null {
1126
+ const live = this._live
1127
+ const node = live?.nodes[hostPath]
1128
+ const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
1129
+ ?._sceneAspects?.[index]
1130
+ if (!live || !node || !entry) return null
1131
+ const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
1132
+ if (typeof ctor.inspector !== "function") return null
1133
+
1134
+ const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
1135
+ const key = `${hostPath}:${index}`
1136
+ let card = this._inspectorCards.get(key)
1137
+ if (!card || card.node !== node) {
1138
+ // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
1139
+ // preview instance: node set, refs resolved, NEVER onAttach (edit mode stays side-effect
1140
+ // free). Editor state (ui._state) survives a node patch by carrying the old ui over.
1141
+ let inst: Record<string, unknown>
1142
+ if (run) {
1143
+ inst = run.inst as unknown as Record<string, unknown>
1144
+ } else {
1145
+ inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
1146
+ inst.node = node
1147
+ Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
1148
+ }
1149
+ const ui = card?.ui ?? new InspectorUI()
1150
+ ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
1151
+ ui._docKeys = new Set(ui._fields.map((f) => f.key))
1152
+ card = { ui, inst, node }
1153
+ this._inspectorCards.set(key, card)
1154
+ }
1155
+
1156
+ // Sync the doc props into the world side. A key REMOVED from the doc (undo past its first
1157
+ // edit) resets to the class-field default — otherwise the instance would keep the stale value.
1158
+ const c = card
1159
+ const fieldDefault = (k: string): unknown => c.ui._fields.find((f) => f.key === k)?.value
1160
+ // `$expr` markers (values set in code) never sync into instances — the widget shows them
1161
+ // read-only; the instance keeps the compile-time evaluation.
1162
+ const isExpr = (v: unknown): boolean =>
1163
+ typeof v === "object" && v !== null && typeof (v as { $expr?: unknown }).$expr === "string"
1164
+ const changed = (a: unknown, b: unknown): boolean =>
1165
+ a !== b && JSON.stringify(a) !== JSON.stringify(b)
1166
+ if (run) {
1167
+ for (const [ k, v ] of Object.entries(props)) {
1168
+ if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
1169
+ }
1170
+ for (const k of Object.keys(run.props)) {
1171
+ if (k in props) continue
1172
+ this._editorSetProp(hostPath, index, k, fieldDefault(k))
1173
+ delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
1174
+ }
1175
+ c.ui._props = run.props
1176
+ } else {
1177
+ const snapshot = { ...props }
1178
+ const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
1179
+ for (const f of c.ui._fields) {
1180
+ if (isExpr(resolved[f.key])) continue
1181
+ c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
1182
+ }
1183
+ c.ui._props = snapshot
1184
+ }
1185
+
1186
+ return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
1187
+ }
1188
+
1189
+ /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
1190
+ * absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
1191
+ * `overrides` keys use. Empty for plain nodes / unknown paths. */
1192
+ async _modelParts(path: string): Promise<ModelPartRow[]> {
1193
+ const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
1194
+ const node = nodes[path]
1195
+ if (node instanceof Model) return modelPartRows(node)
1196
+ // prefab instances precompute their rows in DEF order (engine child order isn't stable)
1197
+ return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
1198
+ }
1199
+
1200
+ /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
1201
+ _describeAspects(): AspectClassInfo[] {
1202
+ const ctors = new Set<AspectCtor<any>>()
1203
+ const walk = (defs?: Record<string, SceneNodeDef>): void => {
1204
+ for (const nd of Object.values(defs ?? {})) {
1205
+ for (const e of nd.aspects ?? []) ctors.add(e.ctor)
1206
+ walk(nd.children)
1207
+ }
1208
+ }
1209
+ walk(this.def.nodes)
1210
+ return [ ...ctors ].map((c) => describeAspect(c))
1211
+ }
1212
+ }
1213
+
1214
+ /**
1215
+ * Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
1216
+ * `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
1217
+ * block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
1218
+ * plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
1219
+ */
1220
+ export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
1221
+ const handle = new SceneHandle(def)
1222
+ const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
1223
+ // Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
1224
+ // up the handle to load it in edit mode and drive the inspector).
1225
+ if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
1226
+ return handle
1227
+ }