lecodes-cli 0.6.4 → 0.7.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 (48) hide show
  1. package/README.md +1 -0
  2. package/dist/index.js +920 -636
  3. package/package.json +9 -4
  4. package/runtime/scene-harness.json +1 -0
  5. package/runtime/sdk/compile/assetMacro.ts +4 -3
  6. package/runtime/sdk/compile/bundler.ts +35 -3
  7. package/runtime/sdk/compile/compileProject.ts +14 -2
  8. package/runtime/sdk/compile/libraryImports.ts +47 -0
  9. package/runtime/sdk/compile/sceneEditor.ts +16 -4
  10. package/runtime/sdk/core/Aspect.ts +37 -0
  11. package/runtime/sdk/core/InspectorUI.ts +212 -0
  12. package/runtime/sdk/core/fields.ts +15 -3
  13. package/runtime/sdk/g2/Scene2D.ts +51 -5
  14. package/runtime/sdk/gl/Model.ts +9 -0
  15. package/runtime/sdk/gl/Node.ts +4 -1
  16. package/runtime/sdk/gl/Scene.ts +76 -11
  17. package/runtime/sdk/gl/scenarios.ts +349 -0
  18. package/runtime/sdk/inject.ts +28 -8
  19. package/runtime/sdk/kit/UITabs.ts +105 -0
  20. package/runtime/sdk/plugins/camera.ts +81 -0
  21. package/runtime/sdk/plugins/geolocation.ts +123 -0
  22. package/runtime/sdk/plugins/permission.ts +7 -0
  23. package/runtime/sdk/plugins/qr.ts +61 -24
  24. package/runtime/sdk/runtime/app.ts +37 -0
  25. package/runtime/sdk/runtime/appEvents.ts +29 -0
  26. package/runtime/sdk/runtime/channel.ts +50 -0
  27. package/runtime/sdk/runtime/clipboard.ts +20 -0
  28. package/runtime/sdk/runtime/datetime.ts +2 -4
  29. package/runtime/sdk/runtime/device.ts +137 -4
  30. package/runtime/sdk/runtime/misc.ts +4 -0
  31. package/runtime/sdk/runtime/service.ts +82 -0
  32. package/runtime/sdk/runtime/touch.ts +26 -0
  33. package/runtime/sdk/scene/defineScene.ts +651 -17
  34. package/runtime/sdk/scene/editorPlugins.ts +86 -0
  35. package/runtime/sdk/ui/NativeView.ts +144 -0
  36. package/runtime/sdk/ui/UI.ts +6 -1
  37. package/runtime/sdk/ui/UIButton.ts +26 -3
  38. package/runtime/sdk/ui/UIInput.ts +2 -2
  39. package/runtime/sdk/ui/UINode.ts +24 -4
  40. package/runtime/sdk/ui/UIScreen.ts +86 -29
  41. package/runtime/sdk/ui/UIScreenHost.ts +213 -0
  42. package/runtime/sdk/ui/UIText.ts +1 -1
  43. package/runtime/sdk/ui/UIVideo.ts +48 -2
  44. package/runtime/sdk/ui/UIWidget.ts +34 -1
  45. package/runtime/sdk/ui/presentable.ts +116 -0
  46. package/runtime/sdk/ui/router.ts +73 -29
  47. package/runtime/sdk-types.json +1 -1
  48. package/runtime/sdk/runtime/camera.ts +0 -31
@@ -1,8 +1,9 @@
1
1
  // Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
2
2
  //
3
3
  // A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
4
- // nodes keyed by name, each with one source block (mesh / model / light, or none = group), a
5
- // transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
4
+ // nodes keyed by name, each with one source block (mesh / model / light / make / prefab, or none
5
+ // = group),
6
+ // a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
6
7
  // editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
7
8
  // node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
8
9
  //
@@ -32,7 +33,8 @@
32
33
  // update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.
33
34
 
34
35
  import { Aspect, type AspectCtor, type With } from "../core/Aspect"
35
- import { describeAspect, type AspectClassInfo } from "../core/fields"
36
+ import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
37
+ import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
36
38
  import type { ColorInput } from "../core/color"
37
39
  import type { Vec3Like } from "../math/vec"
38
40
  import { Scene, type SceneOptions } from "../gl/Scene"
@@ -42,6 +44,7 @@ import { Model } from "../gl/Model"
42
44
  import { Light, type SunOptions } from "../gl/Light"
43
45
  import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
44
46
  import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
47
+ import { edgesMesh } from "../gl/scenarios"
45
48
 
46
49
  // ---- the literal grammar (what the visual editor reads and writes) -----------
47
50
 
@@ -73,19 +76,95 @@ export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
73
76
  export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
74
77
  ({ __use: true, ctor, props })
75
78
 
79
+ /** A `make(fn, args)` source entry — see {@link make}. */
80
+ export type MakeEntry<A extends Record<string, unknown> = Record<string, unknown>> = {
81
+ /** @internal grammar marker. */
82
+ readonly __make: true
83
+ fn: (args: A) => Node | Promise<Node>
84
+ args?: A
85
+ }
86
+
87
+ /**
88
+ * A code-created source in a scene file: `tower: { make: make(buildTower, { floors: 5 }), … }`.
89
+ * The factory runs after every scene node exists (so `ref()` args resolve, forward references
90
+ * included) and its returned subtree mounts under the def node — the def's transform stays
91
+ * editor-owned, so moving the node never re-calls the factory. Args must be literal data (same
92
+ * grammar as aspect props); in the editor they edit as fields, and an arg change re-CALLS the
93
+ * factory live — the code is already in the bundle, so no compile happens. Keep factories pure
94
+ * builders: same args → same subtree, no side effects outside the returned nodes.
95
+ */
96
+ export const make = <A extends Record<string, unknown>>(
97
+ fn: (args: A) => Node | Promise<Node>, args?: A,
98
+ ): MakeEntry<A> => ({ __make: true, fn, args })
99
+
100
+ /**
101
+ * Reference another scene node by name in an aspect's props:
102
+ * `use(Road, { from: ref('pointA'), to: ref('pointB') })`. Resolves to the live Node when aspects
103
+ * attach — after EVERY node of the scene exists, so forward references work. An unknown name
104
+ * resolves to `null` (type your aspect field `Node | null`). Scene files only — hand-written code
105
+ * passes nodes directly: `node.aspect(Road, { from: nodes.pointA })`.
106
+ */
107
+ export const ref = <T extends Node = Node>(name: string): T => ({ $ref: name } as unknown as T)
108
+
109
+ const isNodeRef = (v: unknown): v is { $ref: string } =>
110
+ typeof v === "object" && v !== null && typeof (v as { $ref?: unknown }).$ref === "string"
111
+
112
+ /** Swap `ref()` markers in aspect props for the live nodes (top level + one array level deep). */
113
+ const resolveRefs = (props: Record<string, unknown> | undefined, nodes: Record<string, Node>): Record<string, unknown> | undefined => {
114
+ if (!props) return props
115
+ let out: Record<string, unknown> | undefined
116
+ for (const [ k, v ] of Object.entries(props)) {
117
+ if (isNodeRef(v)) {
118
+ ;(out ??= { ...props })[k] = nodes[v.$ref] ?? null
119
+ } else if (Array.isArray(v) && v.some(isNodeRef)) {
120
+ ;(out ??= { ...props })[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
121
+ }
122
+ }
123
+ return out ?? props
124
+ }
125
+
126
+ /** Transform overrides for one INTERNAL node of a GLB model or a prefab instance
127
+ * (`overrides` on a model/prefab node, keyed by part path). */
128
+ export type ModelOverrideDef = {
129
+ position?: Vec3Like
130
+ eulerAngles?: Vec3Like
131
+ scale?: Vec3Like | number
132
+ visible?: boolean
133
+ }
134
+
135
+ /** The `camera: {}` source block — reserved for projection settings (fov, …) later. */
136
+ export type CameraNodeDef = Record<string, never>
137
+
76
138
  export type SceneNodeDef = {
77
139
  // -- source (at most one; none = plain group node) --
78
140
  mesh?: MeshDef
79
141
  /** GLB url — `asset('./hero.glb')`. */
80
142
  model?: string
81
143
  light?: LightDef
144
+ /** The scene camera as a NODE: in play mode `scene.camera` follows this node's world transform
145
+ * every frame (so movement aspects on it are camera flythroughs); the editor shows a frustum
146
+ * marker and refuses to delete the last camera node. The first camera node in file order wins;
147
+ * cameras inside prefabs are ignored (like a prefab's `camera:` block). */
148
+ camera?: CameraNodeDef
149
+ /** A code-built subtree — `make(factoryFn, { ...literal args })`. */
150
+ make?: MakeEntry<any>
151
+ /** Another scene file used as a reusable composition — the imported handle:
152
+ * `import streetlamp from './streetlamp.scene'` … `lamp: { prefab: streetlamp }`. Its nodes
153
+ * instantiate under this node per instance (env/camera are the instancing file's business and
154
+ * are ignored); `ref()`s inside the prefab resolve file-locally, per instance. */
155
+ prefab?: SceneHandle<any>
82
156
  /** Material for a `mesh` source. */
83
157
  material?: MaterialDef
158
+ /** Model/prefab sources: transform overrides for the INTERNAL nodes, keyed by part path
159
+ * (see the part-path grammar above `modelPartRows`). Unresolved paths are ignored. */
160
+ overrides?: Record<string, ModelOverrideDef>
84
161
  // -- transform / render --
85
162
  position?: Vec3Like
86
163
  eulerAngles?: Vec3Like
87
164
  scale?: Vec3Like | number
88
165
  visible?: boolean
166
+ /** Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect. */
167
+ locked?: boolean
89
168
  castShadows?: boolean
90
169
  receiveShadows?: boolean
91
170
  // -- capabilities / hierarchy --
@@ -141,6 +220,56 @@ export type LoadedScene<D extends SceneDef> = {
141
220
  nodes: SceneNodes<D>
142
221
  }
143
222
 
223
+ // ---- GLB internal parts --------------------------------------------------------
224
+ // A model's internal hierarchy is addressed by PART PATHS — '/'-joined segments from the model
225
+ // root down, where a segment is the child's name, disambiguated as `name[i]` among same-named
226
+ // siblings and `[i]` for unnamed children (i = index within that same-named group). The editor
227
+ // enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
228
+ // loader resolves them back through the same enumeration, so writer and resolver can't drift.
229
+
230
+ /** One INTERNAL node of a loaded GLB (editor introspection). */
231
+ export type ModelPartRow = { path: string, name: string, depth: number, node: Node }
232
+
233
+ const partSegment = (child: Node, siblings: Node[]): string => {
234
+ const name = child.name ?? ""
235
+ const group = siblings.filter((s) => (s.name ?? "") === name)
236
+ if (name !== "" && group.length === 1) return name
237
+ return `${name}[${group.indexOf(child)}]`
238
+ }
239
+
240
+ /** Flatten a model's (or prefab instance's) internal hierarchy to rows (depth-first, the root
241
+ * excluded). `__generated` containers are derived output, not addressable parts — skipped. */
242
+ export const modelPartRows = (root: Node): ModelPartRow[] => {
243
+ const out: ModelPartRow[] = []
244
+ const walk = (node: Node, prefix: string, depth: number): void => {
245
+ const children = node.children
246
+ for (const child of children) {
247
+ if (child.name === "__generated") continue
248
+ const seg = partSegment(child, children)
249
+ const path = prefix === "" ? seg : `${prefix}/${seg}`
250
+ out.push({ path, name: child.name || seg, depth, node: child })
251
+ walk(child, path, depth + 1)
252
+ }
253
+ }
254
+ walk(root, "", 0)
255
+ return out
256
+ }
257
+
258
+ const applyOverridesToRows = (rows: ModelPartRow[], overrides: Record<string, ModelOverrideDef>): void => {
259
+ const byPath = new Map(rows.map((r) => [ r.path, r.node ]))
260
+ for (const [ path, o ] of Object.entries(overrides)) {
261
+ const node = byPath.get(path)
262
+ if (!node) continue // the source changed since the override was written — skip, don't throw
263
+ if (o.position) node.position = o.position
264
+ if (o.eulerAngles) node.eulerAngles = o.eulerAngles
265
+ if (o.scale !== undefined) node.scale = o.scale
266
+ if (o.visible !== undefined) node.visible = o.visible
267
+ }
268
+ }
269
+
270
+ const applyModelOverrides = (root: Node, overrides: Record<string, ModelOverrideDef>): void =>
271
+ applyOverridesToRows(modelPartRows(root), overrides)
272
+
144
273
  // ---- runtime -------------------------------------------------------------------
145
274
 
146
275
  const EDIT_FLAG = "__lecodesSceneEdit"
@@ -164,11 +293,13 @@ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
164
293
  }
165
294
 
166
295
  const createSource = (name: string, def: SceneNodeDef): Node | Promise<Node> => {
167
- const sources = [ def.mesh, def.model, def.light ].filter((s) => s !== undefined).length
168
- if (sources > 1) throw new Error(`Scene node "${name}" declares more than one source (mesh/model/light)`)
296
+ const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
297
+ if (sources > 1) throw new Error(`Scene node "${name}" declares more than one source (mesh/model/light/make/prefab/camera)`)
169
298
  if (def.model !== undefined) return Model.load(def.model)
170
299
  if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
171
300
  if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
301
+ // make/prefab/camera/group nodes are plain wrappers — make/prefab subtrees mount under them
302
+ // during the build (attachPrefab) or in phase 2 (attachMake); a camera node drives scene.camera
172
303
  return new Node()
173
304
  }
174
305
 
@@ -178,42 +309,333 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
178
309
  if (def.eulerAngles) node.eulerAngles = def.eulerAngles
179
310
  if (def.scale !== undefined) node.scale = def.scale
180
311
  if (def.visible !== undefined) node.visible = def.visible
312
+ // editor-only flag (the harness reads it when targeting the gizmo); inert at runtime
313
+ if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
314
+ if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
315
+ // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
316
+ // order is insertion-based and may not match the file
317
+ if (def.children) (node as { _sceneChildOrder?: string[] })._sceneChildOrder = Object.keys(def.children)
181
318
  if (node instanceof Mesh) {
182
319
  if (def.castShadows !== undefined) node.castShadows = def.castShadows
183
320
  if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
184
321
  }
322
+ if (def.overrides && def.model !== undefined) applyModelOverrides(node, def.overrides)
323
+ }
324
+
325
+ // ---- edit-mode node markers + the play-mode camera rig -------------------------------------------
326
+
327
+ /** True for a def with no source block at all — a plain group ("Empty" in the editor). */
328
+ const isGroupDef = (def: SceneNodeDef): boolean =>
329
+ def.mesh === undefined && def.model === undefined && def.light === undefined
330
+ && def.make === undefined && def.prefab === undefined && def.camera === undefined
331
+
332
+ /** Edit mode: give otherwise-invisible nodes (empties, cameras) an `__editor_marker` line-mesh
333
+ * child so they render and pick in the viewport. The harness reads `_sceneMarker` to give the
334
+ * node itself a box collider (markers are editor nodes — never raycast/placement targets). */
335
+ const addEditorMarker = (node: Node, def: SceneNodeDef, scene: Scene): void => {
336
+ let marker: Mesh
337
+ if (def.camera !== undefined) {
338
+ // wireframe frustum looking down local -Z + an "up" fin above the near rect
339
+ const z = -0.55, w = 0.3, h = 0.21
340
+ const segs: number[] = []
341
+ for (const [ cx, cy ] of [ [ -w, -h ], [ w, -h ], [ w, h ], [ -w, h ] ] as const) segs.push(0, 0, 0, cx, cy, z)
342
+ 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)
343
+ segs.push(-0.12, h, z, 0, h + 0.14, z, 0, h + 0.14, z, 0.12, h, z)
344
+ marker = edgesMesh(segs, "#cfd4dd")
345
+ ;(node as { _sceneMarker?: string })._sceneMarker = "camera"
346
+ } else if (isGroupDef(def)) {
347
+ const s = 0.3
348
+ marker = edgesMesh([ -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s, 0, 0, 0, -s, 0, 0, s ], "#8f96a3")
349
+ ;(node as { _sceneMarker?: string })._sceneMarker = "empty"
350
+ } else {
351
+ return
352
+ }
353
+ marker.name = "__editor_marker"
354
+ node.add(marker)
355
+ scene.add(marker)
356
+ }
357
+
358
+ /** Play mode: `scene.camera` follows the camera node's world transform every frame (LATE phase,
359
+ * after every other aspect has moved things — order 1000). Attached by `instantiate`, internal. */
360
+ class CameraRig extends Aspect<"__cameraRig"> {
361
+ static readonly aspect = "__cameraRig"
362
+ /** @internal */ _scene!: Scene
363
+ constructor() { super(); this.order = 1000 }
364
+ update(): void {
365
+ const cam = this._scene.camera
366
+ cam.position = this.node.worldPosition
367
+ cam.quaternion = this.node.worldQuaternion
368
+ }
369
+ }
370
+
371
+ /** The first camera-source def in file order (depth-first), or null. */
372
+ const findCameraName = (defs?: Record<string, SceneNodeDef>): string | null => {
373
+ for (const [ name, nd ] of Object.entries(defs ?? {})) {
374
+ if (nd.camera !== undefined) return name
375
+ const inner = findCameraName(nd.children)
376
+ if (inner) return inner
377
+ }
378
+ return null
379
+ }
380
+
381
+ // ---- editor-run aspects (generators) ------------------------------------------------------------
382
+ // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
383
+ // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
384
+ // editor re-runs rebuild() on inspector prop edits (`_editorSetProp`) and whenever a node its
385
+ // ref() fields point at changes (`_editorNodeChanged` — dep tracking is derived from the props).
386
+ // Generated output lives under `this.generated`, a scene-added container child: never written to
387
+ // the file, absent from the doc tree — the file stores the recipe, the viewport shows the result.
388
+
389
+ const isEditorCtor = (ctor: unknown): boolean => !!(ctor as { editor?: unknown }).editor
390
+
391
+ /** The `generated` container: children join the scene's draw set on add (membership is separate
392
+ * from parenting — `addEntityToScene` only recurses over children that exist at add time). */
393
+ class GeneratedGroup extends Node {
394
+ /** @internal */ _scene!: Scene
395
+ add(...children: Node[]): this {
396
+ super.add(...children)
397
+ this._scene.add(...children)
398
+ return this
399
+ }
400
+ /** Destroy all generated children (rebuild() calls this first — idempotent regeneration). */
401
+ clear(): this {
402
+ for (const c of [ ...this.children ]) {
403
+ this._scene.remove(c)
404
+ c.destroy()
405
+ }
406
+ return this
407
+ }
408
+ }
409
+
410
+ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
411
+ const group = new GeneratedGroup()
412
+ group.name = "__generated"
413
+ group._scene = scene
414
+ node.add(group)
415
+ scene.add(group)
416
+ return group
417
+ }
418
+
419
+ /** One live editor-run aspect instance (edit mode only). */
420
+ type EditorRun = {
421
+ hostName: string
422
+ node: Node
423
+ /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
424
+ index: number
425
+ inst: { rebuild?(): void }
426
+ /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. */
427
+ props: Record<string, unknown>
428
+ /** Names of nodes the ref() props point at — a change to any of them re-runs rebuild(). */
429
+ deps: Set<string>
430
+ }
431
+
432
+ const collectRefDeps = (props: Record<string, unknown>): Set<string> => {
433
+ const deps = new Set<string>()
434
+ for (const v of Object.values(props)) {
435
+ if (isNodeRef(v)) deps.add(v.$ref)
436
+ else if (Array.isArray(v)) for (const el of v) if (isNodeRef(el)) deps.add(el.$ref)
437
+ }
438
+ return deps
439
+ }
440
+
441
+ const safeRebuild = (run: EditorRun): void => {
442
+ // a throwing generator must not take the editor session down with it
443
+ try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostName}":`, e) }
185
444
  }
186
445
 
187
- const attachAspects = (node: Node, def: SceneNodeDef): void => {
446
+ /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
447
+ * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
448
+ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
449
+ for (const [ k, v ] of Object.entries(run.props)) {
450
+ if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = nodes[v.$ref] ?? null
451
+ else if (Array.isArray(v) && v.some(isNodeRef)) {
452
+ ;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? nodes[el.$ref] ?? null : el))
453
+ }
454
+ }
455
+ }
456
+
457
+ // ---- make() sources (code-built subtrees) -------------------------------------------------------
458
+ // The factory runs in phase 2 (every node exists → ref() args resolve) and its result mounts in a
459
+ // `generated` container under the def-node wrapper — the wrapper's transform is editor-owned, so
460
+ // moving a make node never re-calls the factory. In edit mode the run is tracked as an EditorRun
461
+ // (index MAKE_INDEX): arg edits and ref-dep changes re-call the factory through the exact same
462
+ // machinery generator aspects use — live, no compile (the code is already in the bundle).
463
+
464
+ /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
465
+ const MAKE_INDEX = -1
466
+
467
+ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
468
+ let token = 0
469
+ const inst: Record<string, unknown> = {}
470
+ inst.rebuild = () => {
471
+ const t = ++token
472
+ generated.clear()
473
+ // current args = the instance's own fields (that's where _editorSetProp/assignRefProps write)
474
+ const args: Record<string, unknown> = {}
475
+ for (const [ k, v ] of Object.entries(inst)) if (k !== "rebuild") args[k] = v
476
+ const result = entry.fn(args as never)
477
+ if (result instanceof Node) { generated.add(result); return }
478
+ void Promise.resolve(result).then((made) => {
479
+ // a newer rebuild superseded this call while the factory awaited — drop the stale subtree
480
+ if (t === token && made instanceof Node) generated.add(made)
481
+ }).catch((e) => console.error(`[scene] make() factory failed on "${name}":`, e))
482
+ }
483
+ return inst as unknown as { rebuild(): void }
484
+ }
485
+
486
+ /** Run a def's make() factory (phase 2). Play mode returns the factory's completion — the loader
487
+ * awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
488
+ * and returns immediately (async content pops in when ready, like a model load). */
489
+ const attachMake = (
490
+ node: Node, name: string, def: SceneNodeDef,
491
+ nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
492
+ ): Promise<void> | undefined => {
493
+ const entry = def.make
494
+ if (!entry) return undefined
495
+ const generated = makeGenerated(node, scene)
496
+ if (isEditMode()) {
497
+ const props = { ...(entry.args ?? {}) } as Record<string, unknown>
498
+ const inst = createMakeInst(name, entry, generated)
499
+ Object.assign(inst, resolveRefs(props, nodes))
500
+ const run: EditorRun = { hostName: name, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props) }
501
+ editorRuns.push(run)
502
+ safeRebuild(run)
503
+ return undefined
504
+ }
505
+ const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
506
+ return Promise.resolve(entry.fn(args as never)).then((made) => {
507
+ if (made instanceof Node) generated.add(made)
508
+ })
509
+ }
510
+
511
+ const attachAspects = (
512
+ node: Node, name: string, def: SceneNodeDef,
513
+ nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
514
+ ): void => {
188
515
  if (isEditMode()) {
189
516
  // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
190
- // effects (physics bodies, loops) run while editing. Play mode = the normal branch below.
517
+ // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
518
+ // EXCEPT editor-run classes (generators) — they get a real, tracked instance (above).
191
519
  ;(node as unknown as { _sceneAspects: readonly AspectEntry<any>[] })._sceneAspects = def.aspects ?? []
520
+ ;(def.aspects ?? []).forEach((entry, index) => {
521
+ if (!isEditorCtor(entry.ctor)) return
522
+ const props = { ...(entry.props ?? {}) } as Record<string, unknown>
523
+ const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
524
+ ;(inst as { node: unknown }).node = node
525
+ ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
526
+ Object.assign(inst, resolveRefs(props, nodes))
527
+ // the HOST node is a dep too: a generator that draws relative to its node (path lines)
528
+ // must re-run when the node itself is dragged, not only when its ref() targets move
529
+ const run: EditorRun = { hostName: name, node, index, inst, props, deps: collectRefDeps(props).add(name) }
530
+ editorRuns.push(run)
531
+ safeRebuild(run)
532
+ })
192
533
  return
193
534
  }
194
535
  for (const entry of def.aspects ?? []) {
195
- ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, entry.props)
536
+ const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
537
+ const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
538
+ ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
196
539
  }
197
540
  }
198
541
 
199
- const instantiate = async (def: SceneDef): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
200
- const scene = new Scene(def.env)
201
- const nodes: Record<string, Node> = {}
542
+ // ---- prefabs (scene-in-scene) --------------------------------------------------------------------
543
+ // A `prefab:` node instantiates another scene file's nodes under a plain wrapper — per instance,
544
+ // from the imported handle's DEF (never handle.load(): that would share one singleton instance).
545
+ // Each instance gets its own LOCAL node map, so `ref()`s inside the prefab resolve file-locally
546
+ // and instances never collide in the parent's flat map. The instance's aspects attach normally in
547
+ // play mode; in edit mode they stay data-only like everything else, and its generators / make()
548
+ // factories run ONCE for display but are NOT tracked for editing (internals are posed through
549
+ // `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
550
+
551
+ /** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
552
+ * order, which some hosts reverse) — def names are unique per file, so paths are just the names
553
+ * joined. Models inside the prefab drill into their GLB parts, nested prefabs into their own
554
+ * precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
555
+ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
556
+ const out: ModelPartRow[] = []
557
+ for (const [ name, nd ] of Object.entries(defs)) {
558
+ const node = local[name]
559
+ if (!node) continue
560
+ const path = prefix === "" ? name : `${prefix}/${name}`
561
+ out.push({ path, name, depth, node })
562
+ out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
563
+ const inner = node instanceof Model
564
+ ? modelPartRows(node)
565
+ : (node as { _prefabParts?: ModelPartRow[] })._prefabParts
566
+ if (inner) out.push(...inner.map((r) => ({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })))
567
+ }
568
+ return out
569
+ }
570
+
571
+ const attachPrefab = async (wrapper: Node, name: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
572
+ const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
573
+ if (!pdef || typeof pdef !== "object") {
574
+ console.error(`[scene] "${name}".prefab is not a scene handle (import the .scene file's default export)`)
575
+ return
576
+ }
577
+ // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
578
+ ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
579
+ if (stack.has(pdef)) {
580
+ console.error(`[scene] prefab cycle detected at "${name}" — instance skipped`)
581
+ return
582
+ }
583
+ const local: Record<string, Node> = {}
584
+ const localRuns: EditorRun[] = [] // discarded: instance internals render, but aren't editor-tracked
585
+ await buildNodes(pdef.nodes ?? {}, wrapper, scene, local, localRuns, new Set(stack).add(pdef))
586
+ const rows = prefabPartRows(pdef.nodes ?? {}, local, "", 0)
587
+ ;(wrapper as { _prefabParts?: ModelPartRow[] })._prefabParts = rows
588
+ if (def.overrides) applyOverridesToRows(rows, def.overrides)
589
+ }
202
590
 
203
- const build = async (name: string, nd: SceneNodeDef, parent: Node | null): Promise<void> => {
591
+ /** Build one defs record into `scene` under `parent`, two-phase (all nodes first, then
592
+ * aspects/make — so `ref()`s resolve regardless of declaration order), into the given flat node
593
+ * map. The top-level scene and every prefab instance run through here, each with its own map. */
594
+ const buildNodes = async (
595
+ defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
596
+ nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
597
+ ): Promise<void> => {
598
+ const pending: { name: string, node: Node, def: SceneNodeDef }[] = []
599
+
600
+ const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null): Promise<void> => {
204
601
  const node = await createSource(name, nd)
205
- if (parent) parent.add(node)
602
+ if (parentNode) parentNode.add(node)
206
603
  // Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
207
604
  scene.add(node)
208
605
  applyNode(node, name, nd)
209
- attachAspects(node, nd)
606
+ if (isEditMode()) addEditorMarker(node, nd, scene)
607
+ if (nd.prefab !== undefined) await attachPrefab(node, name, nd, scene, stack)
608
+ pending.push({ name, node, def: nd })
210
609
  nodes[name] = node
211
610
  await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
212
611
  }
213
612
 
214
- await Promise.all(Object.entries(def.nodes ?? {}).map(([ name, nd ]) => build(name, nd, null)))
613
+ await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
614
+ const makeWaits: Promise<void>[] = []
615
+ for (const p of pending) {
616
+ attachAspects(p.node, p.name, p.def, nodes, scene, editorRuns)
617
+ const wait = attachMake(p.node, p.name, p.def, nodes, scene, editorRuns)
618
+ if (wait) makeWaits.push(wait)
619
+ }
620
+ if (makeWaits.length > 0) await Promise.all(makeWaits)
621
+ }
215
622
 
216
- if (def.camera) {
623
+ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ scene: Scene, nodes: Record<string, Node> }> => {
624
+ const scene = new Scene(def.env)
625
+ const nodes: Record<string, Node> = {}
626
+ await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
627
+
628
+ // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
629
+ // (CameraRig), in edit mode it only seeds the editor's starting viewpoint
630
+ const camName = findCameraName(def.nodes)
631
+ const camNode = camName ? nodes[camName] : undefined
632
+ if (camNode) {
633
+ scene.camera.position = camNode.worldPosition
634
+ scene.camera.quaternion = camNode.worldQuaternion
635
+ if (!isEditMode()) {
636
+ ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
637
+ }
638
+ } else if (def.camera) {
217
639
  if (def.camera.position) scene.camera.position = def.camera.position
218
640
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
219
641
  }
@@ -224,12 +646,23 @@ const instantiate = async (def: SceneDef): Promise<{ scene: Scene, nodes: Record
224
646
  export class SceneHandle<D extends SceneDef = SceneDef> {
225
647
  readonly def: D
226
648
  private _loading?: Promise<LoadedScene<D>>
649
+ /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). */
650
+ private _editorRuns: EditorRun[] = []
651
+ /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
652
+ private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
653
+ /** @internal The loaded scene/nodes, for the editor methods below (set once load resolves). */
654
+ private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
227
655
 
228
656
  constructor(def: D) { this.def = def }
229
657
 
230
658
  /** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
231
659
  load(): Promise<LoadedScene<D>> {
232
- if (!this._loading) this._loading = instantiate(this.def) as Promise<LoadedScene<D>>
660
+ if (!this._loading) {
661
+ this._loading = instantiate(this.def, this._editorRuns).then((live) => {
662
+ this._live = live
663
+ return live as LoadedScene<D>
664
+ })
665
+ }
233
666
  return this._loading
234
667
  }
235
668
 
@@ -240,6 +673,207 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
240
673
  return loaded
241
674
  }
242
675
 
676
+ /**
677
+ * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
678
+ * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the named node
679
+ * in place (or adds it when the name is new, under `parentName`/root), `def === null` removes it
680
+ * with its whole subtree. The def must be PLAIN data — the editor falls back to a full re-run
681
+ * for `$expr` and `$asset` values; aspect changes are inert in edit mode and stay out of defs.
682
+ *
683
+ * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
684
+ * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
685
+ * removal. Old GPU resources (geometry/material instances) are not reclaimed until the next full
686
+ * re-run — acceptable churn for an edit session.
687
+ */
688
+ async _patchNode(name: string, def: SceneNodeDef | null, parentName?: string | null): Promise<Node | null> {
689
+ const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
690
+ const old: Node | undefined = nodes[name]
691
+
692
+ const dispose = (root: Node): void => {
693
+ // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
694
+ // no removeCollider — a destroyed entity's stale collider entry must never pick again).
695
+ root.visible = false
696
+ const doomed = new Set<Node>()
697
+ root.traverse((n) => doomed.add(n))
698
+ for (const [ key, n ] of Object.entries(nodes)) if (doomed.has(n)) delete nodes[key]
699
+ // editor-run aspect instances hosted in the doomed subtree go with it (generated children
700
+ // are subtree children, so the destroy below reclaims them too)
701
+ this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
702
+ scene.remove(root)
703
+ root.destroy() // native destroyEntity recurses over remaining children
704
+ }
705
+
706
+ if (def === null) {
707
+ if (old) dispose(old)
708
+ return null
709
+ }
710
+
711
+ const build = async (n: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
712
+ const existing = n === name ? undefined : nodes[n]
713
+ if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
714
+ if (parent) parent.add(existing)
715
+ return existing
716
+ }
717
+ const node = await createSource(n, d)
718
+ if (parent) parent.add(node)
719
+ scene.add(node)
720
+ applyNode(node, n, d)
721
+ if (isEditMode()) addEditorMarker(node, d, scene)
722
+ attachAspects(node, n, d, nodes, scene, this._editorRuns)
723
+ nodes[n] = node
724
+ await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(cn, cd, node)))
725
+ return node
726
+ }
727
+
728
+ const parent = old ? old.parent : (parentName ? nodes[parentName] ?? null : null)
729
+ const fresh = await build(name, def, parent)
730
+ if (old) {
731
+ // named children not listed in the def still move over (the editor patches one node at a time)
732
+ const named = new Set(Object.values(nodes))
733
+ for (const child of old.children) if (named.has(child)) fresh.add(child)
734
+ dispose(old) // fresh already replaced nodes[name] in build(), so it survives
735
+ nodes[name] = fresh
736
+ }
737
+ return fresh
738
+ }
739
+
740
+ /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
741
+ * and re-run rebuild() on every editor-run aspect whose ref() props point at it. A change also
742
+ * counts for the node's ANCESTORS: generators read subtrees (FollowPath's waypoints are the
743
+ * children of its referenced path node), so dragging a child must rebuild the parent's deps. */
744
+ _editorNodeChanged(name: string): void {
745
+ const nodes = this._live?.nodes
746
+ if (!nodes) return
747
+ const changed = new Set([ name ])
748
+ let cur = nodes[name]?.parent ?? null
749
+ for (let i = 0; cur && i < 64; i++) {
750
+ for (const [ n, node ] of Object.entries(nodes)) if (node === cur) { changed.add(n); break }
751
+ cur = cur.parent
752
+ }
753
+ for (const run of this._editorRuns) {
754
+ let hit = false
755
+ for (const n of changed) if (run.deps.has(n)) { hit = true; break }
756
+ if (!hit) continue
757
+ assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
758
+ safeRebuild(run)
759
+ }
760
+ }
761
+
762
+ /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
763
+ * run (no compile; the factory is already in the bundle). False for non-make nodes. */
764
+ _editorSetMakeArg(hostName: string, key: string, value: unknown): boolean {
765
+ return this._editorSetProp(hostName, MAKE_INDEX, key, value)
766
+ }
767
+
768
+ /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
769
+ * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
770
+ * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
771
+ _editorSetProp(hostName: string, index: number, key: string, value: unknown): boolean {
772
+ const run = this._editorRuns.find((r) => r.hostName === hostName && r.index === index)
773
+ const nodes = this._live?.nodes
774
+ if (!run || !nodes) return false
775
+ run.props[key] = value
776
+ run.deps = collectRefDeps(run.props)
777
+ // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
778
+ // wrapper's transform is editor-owned and moving it never re-calls the factory
779
+ if (run.index !== MAKE_INDEX) run.deps.add(run.hostName)
780
+ if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
781
+ else (run.inst as Record<string, unknown>)[key] = value
782
+ safeRebuild(run)
783
+ return true
784
+ }
785
+
786
+ /**
787
+ * @internal Editor: run an aspect's custom `static inspector` card (immediate-mode — see
788
+ * core/InspectorUI.ts) and return its widget list. Null when the class has no inspector (the
789
+ * editor falls back to the inferred fields).
790
+ *
791
+ * `props` is the entry's CURRENT doc props, passed on EVERY call — the world syncs its side
792
+ * from it (a generator syncs through the `_editorSetProp` machinery, so an undo that changes a
793
+ * prop rebuilds for free; a plain aspect's preview instance is reassigned). `event` carries only
794
+ * buttons and editor-state field edits — doc-bound field edits arrive as changed `props`.
795
+ *
796
+ * Cards persist per `<host>:<index>` across calls (that's where `ui.state` lives); a card whose
797
+ * node instance was replaced by a live patch is rebuilt transparently.
798
+ */
799
+ _inspectorRender(
800
+ hostName: string, index: number,
801
+ props: Record<string, unknown>, event?: InspectorEvent,
802
+ ): InspectorWidget[] | null {
803
+ const live = this._live
804
+ const node = live?.nodes[hostName]
805
+ const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
806
+ ?._sceneAspects?.[index]
807
+ if (!live || !node || !entry) return null
808
+ const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
809
+ if (typeof ctor.inspector !== "function") return null
810
+
811
+ const run = this._editorRuns.find((r) => r.hostName === hostName && r.index === index)
812
+ const key = `${hostName}:${index}`
813
+ let card = this._inspectorCards.get(key)
814
+ if (!card || card.node !== node) {
815
+ // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
816
+ // preview instance: node set, refs resolved, NEVER onAttach (edit mode stays side-effect
817
+ // free). Editor state (ui._state) survives a node patch by carrying the old ui over.
818
+ let inst: Record<string, unknown>
819
+ if (run) {
820
+ inst = run.inst as unknown as Record<string, unknown>
821
+ } else {
822
+ inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
823
+ inst.node = node
824
+ Object.assign(inst, resolveRefs({ ...props }, live.nodes))
825
+ }
826
+ const ui = card?.ui ?? new InspectorUI()
827
+ ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
828
+ ui._docKeys = new Set(ui._fields.map((f) => f.key))
829
+ card = { ui, inst, node }
830
+ this._inspectorCards.set(key, card)
831
+ }
832
+
833
+ // Sync the doc props into the world side. A key REMOVED from the doc (undo past its first
834
+ // edit) resets to the class-field default — otherwise the instance would keep the stale value.
835
+ const c = card
836
+ const fieldDefault = (k: string): unknown => c.ui._fields.find((f) => f.key === k)?.value
837
+ // `$expr` markers (values set in code) never sync into instances — the widget shows them
838
+ // read-only; the instance keeps the compile-time evaluation.
839
+ const isExpr = (v: unknown): boolean =>
840
+ typeof v === "object" && v !== null && typeof (v as { $expr?: unknown }).$expr === "string"
841
+ const changed = (a: unknown, b: unknown): boolean =>
842
+ a !== b && JSON.stringify(a) !== JSON.stringify(b)
843
+ if (run) {
844
+ for (const [ k, v ] of Object.entries(props)) {
845
+ if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostName, index, k, v)
846
+ }
847
+ for (const k of Object.keys(run.props)) {
848
+ if (k in props) continue
849
+ this._editorSetProp(hostName, index, k, fieldDefault(k))
850
+ delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
851
+ }
852
+ c.ui._props = run.props
853
+ } else {
854
+ const snapshot = { ...props }
855
+ const resolved = (resolveRefs(snapshot, live.nodes) ?? snapshot) as Record<string, unknown>
856
+ for (const f of c.ui._fields) {
857
+ if (isExpr(resolved[f.key])) continue
858
+ c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
859
+ }
860
+ c.ui._props = snapshot
861
+ }
862
+
863
+ return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
864
+ }
865
+
866
+ /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance —
867
+ * path/name/depth/live node, in the same part-path grammar `overrides` keys use. Empty for
868
+ * plain nodes / unknown names. */
869
+ async _modelParts(name: string): Promise<ModelPartRow[]> {
870
+ const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
871
+ const node = nodes[name]
872
+ if (node instanceof Model) return modelPartRows(node)
873
+ // prefab instances precompute their rows in DEF order (engine child order isn't stable)
874
+ return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
875
+ }
876
+
243
877
  /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
244
878
  _describeAspects(): AspectClassInfo[] {
245
879
  const ctors = new Set<AspectCtor<any>>()