lecodes-cli 0.12.0 → 0.13.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.
- package/dist/index.js +637 -235
- package/package.json +4 -4
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/g2/autotile.ts +39 -0
- package/runtime/sdk/gl/Camera.ts +40 -0
- package/runtime/sdk/inject.ts +1 -0
- package/runtime/sdk/scene/defineScene.ts +259 -117
- package/runtime/sdk/scene/editorPlugins.ts +23 -17
- package/runtime/sdk/scene/grammar.ts +49 -14
- package/runtime/sdk-types.json +1 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// Scenes as data: the runtime behind `.scene.ts` files (docs/scene-editor-plan.md in the repo root).
|
|
2
2
|
//
|
|
3
3
|
// A scene file default-exports one `defineScene({...})` call whose argument is a plain literal —
|
|
4
|
-
// nodes keyed by name
|
|
5
|
-
// = group),
|
|
4
|
+
// nodes keyed by name (unique among SIBLINGS; the runtime addresses them by '/'-joined absolute
|
|
5
|
+
// PATH), each with one source block (mesh / model / light / make / prefab, or none = group),
|
|
6
6
|
// a transform, an optional material, `aspects: [use(Ctor, props), …]` and `children`. The visual
|
|
7
7
|
// editor parses and rewrites that literal; at runtime it lowers to ordinary SDK calls (Mesh.box,
|
|
8
8
|
// node.aspect, scene.add), so scenes run identically on every platform with no loader ABI.
|
|
@@ -35,13 +35,14 @@
|
|
|
35
35
|
import { Aspect, type AspectCtor, type With } from "../core/Aspect"
|
|
36
36
|
import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
|
|
37
37
|
import {
|
|
38
|
-
collectRefDeps, isEditMode, isNodeRef, resolveRefs, use, ref, make, EDIT_FLAG,
|
|
38
|
+
collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
|
|
39
39
|
type AspectEntry, type MakeEntry as SharedMakeEntry,
|
|
40
40
|
} from "./grammar"
|
|
41
41
|
import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
|
|
42
42
|
import type { ColorInput } from "../core/color"
|
|
43
43
|
import type { Vec3Like } from "../math/vec"
|
|
44
44
|
import { Scene, type SceneOptions } from "../gl/Scene"
|
|
45
|
+
import { CAMERA_DEFAULTS } from "../gl/Camera"
|
|
45
46
|
import { Node } from "../gl/Node"
|
|
46
47
|
import { Mesh } from "../gl/Mesh"
|
|
47
48
|
import { Model } from "../gl/Model"
|
|
@@ -84,8 +85,19 @@ export type ModelOverrideDef = {
|
|
|
84
85
|
visible?: boolean
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
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
|
|
89
101
|
|
|
90
102
|
export type SceneNodeDef = {
|
|
91
103
|
// -- source (at most one; none = plain group node) --
|
|
@@ -115,7 +127,7 @@ export type SceneNodeDef = {
|
|
|
115
127
|
eulerAngles?: Vec3Like
|
|
116
128
|
scale?: Vec3Like | number
|
|
117
129
|
visible?: boolean
|
|
118
|
-
/** Editor-only:
|
|
130
|
+
/** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
|
|
119
131
|
locked?: boolean
|
|
120
132
|
castShadows?: boolean
|
|
121
133
|
receiveShadows?: boolean
|
|
@@ -124,7 +136,7 @@ export type SceneNodeDef = {
|
|
|
124
136
|
children?: Record<string, SceneNodeDef>
|
|
125
137
|
}
|
|
126
138
|
|
|
127
|
-
export type SceneCameraDef = {
|
|
139
|
+
export type SceneCameraDef = CameraProjectionDef & {
|
|
128
140
|
position?: Vec3Like
|
|
129
141
|
/** Point the camera looks at. */
|
|
130
142
|
target?: Vec3Like
|
|
@@ -153,16 +165,19 @@ type NodeOf<N extends SceneNodeDef> =
|
|
|
153
165
|
type UnionToIntersection<U> =
|
|
154
166
|
(U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
|
|
155
167
|
|
|
156
|
-
// The child maps of a def level, as a union (never when no
|
|
157
|
-
// since `unknown` would absorb the union and `never` would
|
|
158
|
-
|
|
159
|
-
|
|
168
|
+
// The child maps of a def level, prefixed with their parent's path, as a union (never when no
|
|
169
|
+
// node has children — guarded below, since `unknown` would absorb the union and `never` would
|
|
170
|
+
// poison the intersection).
|
|
171
|
+
type ChildMapsOf<T extends Record<string, SceneNodeDef>, P extends string> =
|
|
172
|
+
{ [K in keyof T & string]:
|
|
173
|
+
T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C, `${P}${K}/`> : never
|
|
174
|
+
}[keyof T & string]
|
|
160
175
|
|
|
161
|
-
// All nodes of a def tree,
|
|
162
|
-
//
|
|
163
|
-
type NodesOf<T extends Record<string, SceneNodeDef
|
|
164
|
-
{ [K in keyof T]: NodeOf<T[K]> } &
|
|
165
|
-
([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
|
|
176
|
+
// All nodes of a def tree, keyed by ABSOLUTE PATH — '/'-joined def keys, a root node's path is
|
|
177
|
+
// its bare name. Names are unique among SIBLINGS only; paths are unique by construction.
|
|
178
|
+
type NodesOf<T extends Record<string, SceneNodeDef>, P extends string = ""> =
|
|
179
|
+
{ [K in keyof T & string as `${P}${K}`]: NodeOf<T[K]> } &
|
|
180
|
+
([ChildMapsOf<T, P>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T, P>>)
|
|
166
181
|
|
|
167
182
|
export type SceneNodes<D extends SceneDef> =
|
|
168
183
|
D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
|
|
@@ -170,6 +185,11 @@ export type SceneNodes<D extends SceneDef> =
|
|
|
170
185
|
export type LoadedScene<D extends SceneDef> = {
|
|
171
186
|
scene: Scene
|
|
172
187
|
nodes: SceneNodes<D>
|
|
188
|
+
/** Path lookup — typed for this scene's literal paths, `Node | null` for arbitrary strings. */
|
|
189
|
+
get: {
|
|
190
|
+
<P extends keyof SceneNodes<D> & string>(path: P): SceneNodes<D>[P]
|
|
191
|
+
(path: string): Node | null
|
|
192
|
+
}
|
|
173
193
|
}
|
|
174
194
|
|
|
175
195
|
// ---- GLB internal parts --------------------------------------------------------
|
|
@@ -241,9 +261,9 @@ const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
|
|
|
241
261
|
}
|
|
242
262
|
}
|
|
243
263
|
|
|
244
|
-
const createSource = (
|
|
264
|
+
const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> => {
|
|
245
265
|
const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera ].filter((s) => s !== undefined).length
|
|
246
|
-
if (sources > 1) throw new Error(`Scene node "${
|
|
266
|
+
if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera)`)
|
|
247
267
|
if (def.model !== undefined) return Model.load(def.model)
|
|
248
268
|
if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
|
|
249
269
|
if (def.light !== undefined) { const { kind: _k, ...opts } = def.light; return Light.sun(opts) }
|
|
@@ -258,7 +278,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
|
|
|
258
278
|
if (def.eulerAngles) node.eulerAngles = def.eulerAngles
|
|
259
279
|
if (def.scale !== undefined) node.scale = def.scale
|
|
260
280
|
if (def.visible !== undefined) node.visible = def.visible
|
|
261
|
-
// editor-only flag (the harness
|
|
281
|
+
// editor-only flag (the harness's manipulation layer consults it); inert at runtime
|
|
262
282
|
if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
|
|
263
283
|
if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
|
|
264
284
|
// waypoint/def order for aspects that read children (FollowPath) — the engine's live child
|
|
@@ -317,16 +337,28 @@ class CameraRig extends Aspect<"__cameraRig"> {
|
|
|
317
337
|
}
|
|
318
338
|
}
|
|
319
339
|
|
|
320
|
-
/** The first camera-source def in file order (depth-first)
|
|
321
|
-
const
|
|
340
|
+
/** The first camera-source def in file order (depth-first) — its path and block — or null. */
|
|
341
|
+
const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: string, def: CameraNodeDef } | null => {
|
|
322
342
|
for (const [ name, nd ] of Object.entries(defs ?? {})) {
|
|
323
|
-
|
|
324
|
-
|
|
343
|
+
const path = prefix === "" ? name : `${prefix}/${name}`
|
|
344
|
+
if (nd.camera !== undefined) return { path, def: nd.camera }
|
|
345
|
+
const inner = findCamera(nd.children, path)
|
|
325
346
|
if (inner) return inner
|
|
326
347
|
}
|
|
327
348
|
return null
|
|
328
349
|
}
|
|
329
350
|
|
|
351
|
+
/** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
|
|
352
|
+
* alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
|
|
353
|
+
* omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
|
|
354
|
+
const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
|
|
355
|
+
const { fov, near, far } = def
|
|
356
|
+
if (!reset && fov === undefined && near === undefined && far === undefined) return
|
|
357
|
+
scene.camera.setProjection(reset
|
|
358
|
+
? { fov: fov ?? CAMERA_DEFAULTS.fov, near: near ?? CAMERA_DEFAULTS.near, far: far ?? CAMERA_DEFAULTS.far }
|
|
359
|
+
: { fov, near, far })
|
|
360
|
+
}
|
|
361
|
+
|
|
330
362
|
// ---- editor-run aspects (generators) ------------------------------------------------------------
|
|
331
363
|
// A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
|
|
332
364
|
// constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
|
|
@@ -367,29 +399,37 @@ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
|
|
|
367
399
|
|
|
368
400
|
/** One live editor-run aspect instance (edit mode only). */
|
|
369
401
|
type EditorRun = {
|
|
370
|
-
|
|
402
|
+
/** Absolute path of the host def node (re-keyed on rename/reparent). */
|
|
403
|
+
hostPath: string
|
|
371
404
|
node: Node
|
|
372
405
|
/** Index within the def's `aspects` array — the doc's aspect index addresses it. */
|
|
373
406
|
index: number
|
|
374
407
|
inst: { rebuild?(): void }
|
|
375
|
-
/** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`.
|
|
408
|
+
/** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
|
|
409
|
+
* DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
|
|
410
|
+
* against the file's props, so rewriting them would re-fire on every render. */
|
|
376
411
|
props: Record<string, unknown>
|
|
377
|
-
/**
|
|
412
|
+
/** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
|
|
413
|
+
* their subtrees) re-runs rebuild(). Re-derived after every structural change. */
|
|
378
414
|
deps: Set<string>
|
|
379
415
|
}
|
|
380
416
|
|
|
381
417
|
const safeRebuild = (run: EditorRun): void => {
|
|
382
418
|
// a throwing generator must not take the editor session down with it
|
|
383
|
-
try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.
|
|
419
|
+
try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
|
|
384
420
|
}
|
|
385
421
|
|
|
386
422
|
/** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
|
|
387
423
|
* instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
|
|
388
424
|
const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
|
|
425
|
+
const lookup = (r: string): Node | null => {
|
|
426
|
+
const p = resolveRefPath(nodes, run.hostPath, r)
|
|
427
|
+
return p === null ? null : nodes[p]
|
|
428
|
+
}
|
|
389
429
|
for (const [ k, v ] of Object.entries(run.props)) {
|
|
390
|
-
if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] =
|
|
430
|
+
if (isNodeRef(v)) (run.inst as Record<string, unknown>)[k] = lookup(v.$ref)
|
|
391
431
|
else if (Array.isArray(v) && v.some(isNodeRef)) {
|
|
392
|
-
;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ?
|
|
432
|
+
;(run.inst as Record<string, unknown>)[k] = v.map((el) => (isNodeRef(el) ? lookup(el.$ref) : el))
|
|
393
433
|
}
|
|
394
434
|
}
|
|
395
435
|
}
|
|
@@ -404,7 +444,7 @@ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
|
|
|
404
444
|
/** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
|
|
405
445
|
const MAKE_INDEX = -1
|
|
406
446
|
|
|
407
|
-
const createMakeInst = (
|
|
447
|
+
const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
|
|
408
448
|
let token = 0
|
|
409
449
|
const inst: Record<string, unknown> = {}
|
|
410
450
|
inst.rebuild = () => {
|
|
@@ -418,7 +458,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
|
|
|
418
458
|
void Promise.resolve(result).then((made) => {
|
|
419
459
|
// a newer rebuild superseded this call while the factory awaited — drop the stale subtree
|
|
420
460
|
if (t === token && made instanceof Node) generated.add(made)
|
|
421
|
-
}).catch((e) => console.error(`[scene] make() factory failed on "${
|
|
461
|
+
}).catch((e) => console.error(`[scene] make() factory failed on "${path}":`, e))
|
|
422
462
|
}
|
|
423
463
|
return inst as unknown as { rebuild(): void }
|
|
424
464
|
}
|
|
@@ -427,7 +467,7 @@ const createMakeInst = (name: string, entry: MakeEntry<any>, generated: Generate
|
|
|
427
467
|
* awaits it, so `open()` resolves with generated content in place. Edit mode tracks an EditorRun
|
|
428
468
|
* and returns immediately (async content pops in when ready, like a model load). */
|
|
429
469
|
const attachMake = (
|
|
430
|
-
node: Node,
|
|
470
|
+
node: Node, path: string, def: SceneNodeDef,
|
|
431
471
|
nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
|
|
432
472
|
): Promise<void> | undefined => {
|
|
433
473
|
const entry = def.make
|
|
@@ -435,21 +475,21 @@ const attachMake = (
|
|
|
435
475
|
const generated = makeGenerated(node, scene)
|
|
436
476
|
if (isEditMode()) {
|
|
437
477
|
const props = { ...(entry.args ?? {}) } as Record<string, unknown>
|
|
438
|
-
const inst = createMakeInst(
|
|
439
|
-
Object.assign(inst, resolveRefs(props, nodes))
|
|
440
|
-
const run: EditorRun = {
|
|
478
|
+
const inst = createMakeInst(path, entry, generated)
|
|
479
|
+
Object.assign(inst, resolveRefs(props, nodes, path))
|
|
480
|
+
const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
|
|
441
481
|
editorRuns.push(run)
|
|
442
482
|
safeRebuild(run)
|
|
443
483
|
return undefined
|
|
444
484
|
}
|
|
445
|
-
const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes) ?? {}
|
|
485
|
+
const args = resolveRefs({ ...(entry.args ?? {}) } as Record<string, unknown>, nodes, path) ?? {}
|
|
446
486
|
return Promise.resolve(entry.fn(args as never)).then((made) => {
|
|
447
487
|
if (made instanceof Node) generated.add(made)
|
|
448
488
|
})
|
|
449
489
|
}
|
|
450
490
|
|
|
451
491
|
const attachAspects = (
|
|
452
|
-
node: Node,
|
|
492
|
+
node: Node, path: string, def: SceneNodeDef,
|
|
453
493
|
nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
|
|
454
494
|
): void => {
|
|
455
495
|
if (isEditMode()) {
|
|
@@ -463,17 +503,17 @@ const attachAspects = (
|
|
|
463
503
|
const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
|
|
464
504
|
;(inst as { node: unknown }).node = node
|
|
465
505
|
;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
|
|
466
|
-
Object.assign(inst, resolveRefs(props, nodes))
|
|
506
|
+
Object.assign(inst, resolveRefs(props, nodes, path))
|
|
467
507
|
// the HOST node is a dep too: a generator that draws relative to its node (path lines)
|
|
468
508
|
// must re-run when the node itself is dragged, not only when its ref() targets move
|
|
469
|
-
const run: EditorRun = {
|
|
509
|
+
const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
|
|
470
510
|
editorRuns.push(run)
|
|
471
511
|
safeRebuild(run)
|
|
472
512
|
})
|
|
473
513
|
return
|
|
474
514
|
}
|
|
475
515
|
for (const entry of def.aspects ?? []) {
|
|
476
|
-
const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes)
|
|
516
|
+
const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
|
|
477
517
|
const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
|
|
478
518
|
;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
|
|
479
519
|
}
|
|
@@ -489,15 +529,15 @@ const attachAspects = (
|
|
|
489
529
|
// `overrides`, not per-instance aspect edits). `stack` guards import cycles by def identity.
|
|
490
530
|
|
|
491
531
|
/** An instance's part rows in DEF order (the live `children` walk reflects engine insertion
|
|
492
|
-
* order, which some hosts reverse) —
|
|
493
|
-
*
|
|
494
|
-
* precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
|
|
532
|
+
* order, which some hosts reverse) — the instance-local record is path-keyed, so row paths ARE
|
|
533
|
+
* the record keys. Models inside the prefab drill into their GLB parts, nested prefabs into
|
|
534
|
+
* their own precomputed rows; both keep producing the exact paths `applyModelOverrides` resolves. */
|
|
495
535
|
const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string, Node>, prefix: string, depth: number): ModelPartRow[] => {
|
|
496
536
|
const out: ModelPartRow[] = []
|
|
497
537
|
for (const [ name, nd ] of Object.entries(defs)) {
|
|
498
|
-
const node = local[name]
|
|
499
|
-
if (!node) continue
|
|
500
538
|
const path = prefix === "" ? name : `${prefix}/${name}`
|
|
539
|
+
const node = local[path]
|
|
540
|
+
if (!node) continue
|
|
501
541
|
out.push({ path, name, depth, node })
|
|
502
542
|
out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
|
|
503
543
|
const inner = node instanceof Model
|
|
@@ -508,16 +548,16 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
|
|
|
508
548
|
return out
|
|
509
549
|
}
|
|
510
550
|
|
|
511
|
-
const attachPrefab = async (wrapper: Node,
|
|
551
|
+
const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>): Promise<void> => {
|
|
512
552
|
const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
|
|
513
553
|
if (!pdef || typeof pdef !== "object") {
|
|
514
|
-
console.error(`[scene] "${
|
|
554
|
+
console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
|
|
515
555
|
return
|
|
516
556
|
}
|
|
517
557
|
// editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
|
|
518
558
|
;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
|
|
519
559
|
if (stack.has(pdef)) {
|
|
520
|
-
console.error(`[scene] prefab cycle detected at "${
|
|
560
|
+
console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
|
|
521
561
|
return
|
|
522
562
|
}
|
|
523
563
|
const local: Record<string, Node> = {}
|
|
@@ -535,26 +575,33 @@ const buildNodes = async (
|
|
|
535
575
|
defs: Record<string, SceneNodeDef>, parent: Node | null, scene: Scene,
|
|
536
576
|
nodes: Record<string, Node>, editorRuns: EditorRun[], stack: Set<SceneDef>,
|
|
537
577
|
): Promise<void> => {
|
|
538
|
-
const pending: {
|
|
578
|
+
const pending: { path: string, node: Node, def: SceneNodeDef }[] = []
|
|
539
579
|
|
|
540
|
-
const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null): Promise<void> => {
|
|
541
|
-
|
|
580
|
+
const build = async (name: string, nd: SceneNodeDef, parentNode: Node | null, parentPath: string): Promise<void> => {
|
|
581
|
+
// names are path segments — '/' would fork the path, ':' would ambiguate editor card keys
|
|
582
|
+
if (name === "" || name.includes("/") || name.includes(":")) {
|
|
583
|
+
throw new Error(`Scene node name "${name}" is invalid — names are non-empty and contain no '/' or ':'`)
|
|
584
|
+
}
|
|
585
|
+
// the path is derived from def keys BEFORE any await, so it is deterministic even though
|
|
586
|
+
// Promise.all makes build completion (and record insertion) order nondeterministic
|
|
587
|
+
const path = parentPath === "" ? name : `${parentPath}/${name}`
|
|
588
|
+
const node = await createSource(path, nd)
|
|
542
589
|
if (parentNode) parentNode.add(node)
|
|
543
590
|
// Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
|
|
544
591
|
scene.add(node)
|
|
545
|
-
applyNode(node, name, nd)
|
|
592
|
+
applyNode(node, name, nd) // engine-side name stays the bare sibling segment
|
|
546
593
|
if (isEditMode()) addEditorMarker(node, nd, scene)
|
|
547
|
-
if (nd.prefab !== undefined) await attachPrefab(node,
|
|
548
|
-
pending.push({
|
|
549
|
-
nodes[
|
|
550
|
-
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
|
|
594
|
+
if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack)
|
|
595
|
+
pending.push({ path, node, def: nd })
|
|
596
|
+
nodes[path] = node
|
|
597
|
+
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node, path)))
|
|
551
598
|
}
|
|
552
599
|
|
|
553
|
-
await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent)))
|
|
600
|
+
await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
|
|
554
601
|
const makeWaits: Promise<void>[] = []
|
|
555
602
|
for (const p of pending) {
|
|
556
|
-
attachAspects(p.node, p.
|
|
557
|
-
const wait = attachMake(p.node, p.
|
|
603
|
+
attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
|
|
604
|
+
const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
|
|
558
605
|
if (wait) makeWaits.push(wait)
|
|
559
606
|
}
|
|
560
607
|
if (makeWaits.length > 0) await Promise.all(makeWaits)
|
|
@@ -566,18 +613,22 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
|
|
|
566
613
|
await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]))
|
|
567
614
|
|
|
568
615
|
// a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
|
|
569
|
-
// (CameraRig), in edit mode it only seeds the editor's starting viewpoint
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
616
|
+
// (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
|
|
617
|
+
// in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
|
|
618
|
+
// lens the running app will use.
|
|
619
|
+
const cam = findCamera(def.nodes)
|
|
620
|
+
const camNode = cam ? nodes[cam.path] : undefined
|
|
621
|
+
if (cam && camNode) {
|
|
573
622
|
scene.camera.position = camNode.worldPosition
|
|
574
623
|
scene.camera.quaternion = camNode.worldQuaternion
|
|
624
|
+
applyCameraProjection(scene, cam.def)
|
|
575
625
|
if (!isEditMode()) {
|
|
576
626
|
;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
|
|
577
627
|
}
|
|
578
628
|
} else if (def.camera) {
|
|
579
629
|
if (def.camera.position) scene.camera.position = def.camera.position
|
|
580
630
|
if (def.camera.target) scene.camera.lookAt(def.camera.target)
|
|
631
|
+
applyCameraProjection(scene, def.camera)
|
|
581
632
|
}
|
|
582
633
|
|
|
583
634
|
return { scene, nodes }
|
|
@@ -590,7 +641,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
590
641
|
private _editorRuns: EditorRun[] = []
|
|
591
642
|
/** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
|
|
592
643
|
private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
|
|
593
|
-
/** @internal The loaded scene
|
|
644
|
+
/** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
|
|
645
|
+
* load resolves). The record object is SHARED with the returned LoadedScene — patches and
|
|
646
|
+
* rename/reparent re-keying are visible through both. */
|
|
594
647
|
private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
|
|
595
648
|
|
|
596
649
|
constructor(def: D) { this.def = def }
|
|
@@ -600,7 +653,8 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
600
653
|
if (!this._loading) {
|
|
601
654
|
this._loading = instantiate(this.def, this._editorRuns).then((live) => {
|
|
602
655
|
this._live = live
|
|
603
|
-
|
|
656
|
+
const get = (path: string): Node | null => live.nodes[path] ?? null
|
|
657
|
+
return { ...live, get } as unknown as LoadedScene<D>
|
|
604
658
|
})
|
|
605
659
|
}
|
|
606
660
|
return this._loading
|
|
@@ -615,19 +669,20 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
615
669
|
|
|
616
670
|
/**
|
|
617
671
|
* @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
|
|
618
|
-
* recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the
|
|
619
|
-
* in place (or adds it when the
|
|
620
|
-
* with its whole subtree. The def must be PLAIN
|
|
621
|
-
* for `$expr` and `$asset` values; aspect changes
|
|
672
|
+
* recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
|
|
673
|
+
* `path` in place (or adds it when the path is new — the path's parent must exist, root paths
|
|
674
|
+
* mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
|
|
675
|
+
* data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
|
|
676
|
+
* are inert in edit mode and stay out of defs.
|
|
622
677
|
*
|
|
623
678
|
* Named children survive a rebuild: they are re-parented onto the replacement node keeping their
|
|
624
679
|
* local transforms (exactly what the scene file describes). Returns the fresh node, null for a
|
|
625
|
-
* removal. Old GPU resources (geometry/material instances)
|
|
626
|
-
* re-run — acceptable churn for an edit session.
|
|
680
|
+
* removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
|
|
681
|
+
* are not reclaimed until the next full re-run — acceptable churn for an edit session.
|
|
627
682
|
*/
|
|
628
|
-
async _patchNode(
|
|
683
|
+
async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
|
|
629
684
|
const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
|
|
630
|
-
const old: Node | undefined = nodes[
|
|
685
|
+
const old: Node | undefined = nodes[path]
|
|
631
686
|
|
|
632
687
|
const dispose = (root: Node): void => {
|
|
633
688
|
// Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
|
|
@@ -644,55 +699,141 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
644
699
|
}
|
|
645
700
|
|
|
646
701
|
if (def === null) {
|
|
647
|
-
if (old)
|
|
702
|
+
if (old) {
|
|
703
|
+
dispose(old)
|
|
704
|
+
this._refreshRunDeps()
|
|
705
|
+
}
|
|
648
706
|
return null
|
|
649
707
|
}
|
|
650
708
|
|
|
651
|
-
const build = async (
|
|
652
|
-
|
|
709
|
+
const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
|
|
710
|
+
// child reuse is by FULL path — only the live subtree at this exact address is carried
|
|
711
|
+
// over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
|
|
712
|
+
const existing = p === path ? undefined : nodes[p]
|
|
653
713
|
if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
|
|
654
714
|
if (parent) parent.add(existing)
|
|
655
715
|
return existing
|
|
656
716
|
}
|
|
657
|
-
const node = await createSource(
|
|
717
|
+
const node = await createSource(p, d)
|
|
658
718
|
if (parent) parent.add(node)
|
|
659
719
|
scene.add(node)
|
|
660
|
-
applyNode(node,
|
|
720
|
+
applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
|
|
661
721
|
if (isEditMode()) addEditorMarker(node, d, scene)
|
|
662
|
-
attachAspects(node,
|
|
663
|
-
nodes[
|
|
664
|
-
await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(cn
|
|
722
|
+
attachAspects(node, p, d, nodes, scene, this._editorRuns)
|
|
723
|
+
nodes[p] = node
|
|
724
|
+
await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
|
|
665
725
|
return node
|
|
666
726
|
}
|
|
667
727
|
|
|
668
|
-
const
|
|
669
|
-
const
|
|
728
|
+
const cut = path.lastIndexOf("/")
|
|
729
|
+
const parentPath = cut < 0 ? null : path.slice(0, cut)
|
|
730
|
+
if (!old && parentPath !== null && !nodes[parentPath]) return null
|
|
731
|
+
const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
|
|
732
|
+
const fresh = await build(path, def, parent)
|
|
670
733
|
if (old) {
|
|
671
734
|
// named children not listed in the def still move over (the editor patches one node at a time)
|
|
672
735
|
const named = new Set(Object.values(nodes))
|
|
673
736
|
for (const child of old.children) if (named.has(child)) fresh.add(child)
|
|
674
|
-
dispose(old) // fresh already replaced nodes[
|
|
675
|
-
nodes[
|
|
737
|
+
dispose(old) // fresh already replaced nodes[path] in build(), so it survives
|
|
738
|
+
nodes[path] = fresh
|
|
676
739
|
}
|
|
740
|
+
// the projection lives on the scene camera, not on the node — re-apply it here so an inspector
|
|
741
|
+
// fov/near/far edit lands live (a rebuilt node alone would carry none of it)
|
|
742
|
+
if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
|
|
743
|
+
this._refreshRunDeps()
|
|
677
744
|
return fresh
|
|
678
745
|
}
|
|
679
746
|
|
|
680
|
-
/** @internal
|
|
681
|
-
*
|
|
682
|
-
*
|
|
683
|
-
|
|
684
|
-
_editorNodeChanged(name: string): void {
|
|
747
|
+
/** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
|
|
748
|
+
* absolute paths, so any structural change can invalidate them: an add can satisfy a
|
|
749
|
+
* previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
|
|
750
|
+
private _refreshRunDeps(): void {
|
|
685
751
|
const nodes = this._live?.nodes
|
|
686
752
|
if (!nodes) return
|
|
687
|
-
const
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
753
|
+
for (const run of this._editorRuns) {
|
|
754
|
+
run.deps = collectRefDeps(run.props, nodes, run.hostPath)
|
|
755
|
+
// aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
|
|
756
|
+
// wrapper's transform is editor-owned and moving it never re-calls the factory
|
|
757
|
+
if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
|
|
762
|
+
* runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
|
|
763
|
+
* the host — mutation, not replacement. */
|
|
764
|
+
private _rekey(oldPath: string, newPath: string): void {
|
|
765
|
+
const nodes = this._live!.nodes
|
|
766
|
+
const move = (key: string): string | null =>
|
|
767
|
+
key === oldPath ? newPath
|
|
768
|
+
: key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
|
|
769
|
+
: null
|
|
770
|
+
for (const key of Object.keys(nodes)) {
|
|
771
|
+
const next = move(key)
|
|
772
|
+
if (next === null) continue
|
|
773
|
+
const n = nodes[key]
|
|
774
|
+
delete nodes[key]
|
|
775
|
+
nodes[next] = n
|
|
776
|
+
}
|
|
777
|
+
for (const run of this._editorRuns) {
|
|
778
|
+
const next = move(run.hostPath)
|
|
779
|
+
if (next !== null) run.hostPath = next
|
|
780
|
+
}
|
|
781
|
+
for (const [ key, card ] of [ ...this._inspectorCards ]) {
|
|
782
|
+
const i = key.lastIndexOf(":")
|
|
783
|
+
const next = move(key.slice(0, i))
|
|
784
|
+
if (next === null) continue
|
|
785
|
+
this._inspectorCards.delete(key)
|
|
786
|
+
this._inspectorCards.set(next + key.slice(i), card)
|
|
692
787
|
}
|
|
788
|
+
this._refreshRunDeps()
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
|
|
792
|
+
* Owns the shared record's re-keying (the host re-keys only its own part/selection state).
|
|
793
|
+
* Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
|
|
794
|
+
_renameNode(path: string, newName: string): string | null {
|
|
795
|
+
const nodes = this._live?.nodes
|
|
796
|
+
const node = nodes?.[path]
|
|
797
|
+
if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
|
|
798
|
+
const cut = path.lastIndexOf("/")
|
|
799
|
+
const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
|
|
800
|
+
if (newPath === path) return path
|
|
801
|
+
if (nodes[newPath]) return null
|
|
802
|
+
this._rekey(path, newPath)
|
|
803
|
+
node.name = newName // engine-side name stays the bare segment
|
|
804
|
+
return newPath
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
|
|
808
|
+
* every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
|
|
809
|
+
* cycle, name taken among the new siblings). */
|
|
810
|
+
_reparentNode(path: string, newParentPath: string | null): string | null {
|
|
811
|
+
const nodes = this._live?.nodes
|
|
812
|
+
const node = nodes?.[path]
|
|
813
|
+
if (!nodes || !node) return null
|
|
814
|
+
const parent = newParentPath === null ? null : nodes[newParentPath]
|
|
815
|
+
if (newParentPath !== null && !parent) return null
|
|
816
|
+
if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
|
|
817
|
+
const name = path.slice(path.lastIndexOf("/") + 1)
|
|
818
|
+
const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
|
|
819
|
+
if (newPath === path) return path
|
|
820
|
+
if (nodes[newPath]) return null
|
|
821
|
+
this._rekey(path, newPath)
|
|
822
|
+
node.setParent(parent ?? null, false)
|
|
823
|
+
return newPath
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
|
|
827
|
+
* and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
|
|
828
|
+
* absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
|
|
829
|
+
* FollowPath's waypoints are the children of its referenced path node) is a prefix test:
|
|
830
|
+
* a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
|
|
831
|
+
_editorNodeChanged(path: string): void {
|
|
832
|
+
const nodes = this._live?.nodes
|
|
833
|
+
if (!nodes) return
|
|
693
834
|
for (const run of this._editorRuns) {
|
|
694
835
|
let hit = false
|
|
695
|
-
for (const
|
|
836
|
+
for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
|
|
696
837
|
if (!hit) continue
|
|
697
838
|
assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
|
|
698
839
|
safeRebuild(run)
|
|
@@ -701,22 +842,22 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
701
842
|
|
|
702
843
|
/** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
|
|
703
844
|
* run (no compile; the factory is already in the bundle). False for non-make nodes. */
|
|
704
|
-
_editorSetMakeArg(
|
|
705
|
-
return this._editorSetProp(
|
|
845
|
+
_editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
|
|
846
|
+
return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
|
|
706
847
|
}
|
|
707
848
|
|
|
708
849
|
/** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
|
|
709
850
|
* on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
|
|
710
851
|
* its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
|
|
711
|
-
_editorSetProp(
|
|
712
|
-
const run = this._editorRuns.find((r) => r.
|
|
852
|
+
_editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
|
|
853
|
+
const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
|
|
713
854
|
const nodes = this._live?.nodes
|
|
714
855
|
if (!run || !nodes) return false
|
|
715
856
|
run.props[key] = value
|
|
716
|
-
run.deps = collectRefDeps(run.props)
|
|
857
|
+
run.deps = collectRefDeps(run.props, nodes, run.hostPath)
|
|
717
858
|
// aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
|
|
718
859
|
// wrapper's transform is editor-owned and moving it never re-calls the factory
|
|
719
|
-
if (run.index !== MAKE_INDEX) run.deps.add(run.
|
|
860
|
+
if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
|
|
720
861
|
if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
|
|
721
862
|
else (run.inst as Record<string, unknown>)[key] = value
|
|
722
863
|
safeRebuild(run)
|
|
@@ -737,19 +878,19 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
737
878
|
* node instance was replaced by a live patch is rebuilt transparently.
|
|
738
879
|
*/
|
|
739
880
|
_inspectorRender(
|
|
740
|
-
|
|
881
|
+
hostPath: string, index: number,
|
|
741
882
|
props: Record<string, unknown>, event?: InspectorEvent,
|
|
742
883
|
): InspectorWidget[] | null {
|
|
743
884
|
const live = this._live
|
|
744
|
-
const node = live?.nodes[
|
|
885
|
+
const node = live?.nodes[hostPath]
|
|
745
886
|
const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
|
|
746
887
|
?._sceneAspects?.[index]
|
|
747
888
|
if (!live || !node || !entry) return null
|
|
748
889
|
const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
|
|
749
890
|
if (typeof ctor.inspector !== "function") return null
|
|
750
891
|
|
|
751
|
-
const run = this._editorRuns.find((r) => r.
|
|
752
|
-
const key = `${
|
|
892
|
+
const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
|
|
893
|
+
const key = `${hostPath}:${index}`
|
|
753
894
|
let card = this._inspectorCards.get(key)
|
|
754
895
|
if (!card || card.node !== node) {
|
|
755
896
|
// (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
|
|
@@ -761,7 +902,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
761
902
|
} else {
|
|
762
903
|
inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
|
|
763
904
|
inst.node = node
|
|
764
|
-
Object.assign(inst, resolveRefs({ ...props }, live.nodes))
|
|
905
|
+
Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
|
|
765
906
|
}
|
|
766
907
|
const ui = card?.ui ?? new InspectorUI()
|
|
767
908
|
ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
|
|
@@ -782,17 +923,17 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
782
923
|
a !== b && JSON.stringify(a) !== JSON.stringify(b)
|
|
783
924
|
if (run) {
|
|
784
925
|
for (const [ k, v ] of Object.entries(props)) {
|
|
785
|
-
if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(
|
|
926
|
+
if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
|
|
786
927
|
}
|
|
787
928
|
for (const k of Object.keys(run.props)) {
|
|
788
929
|
if (k in props) continue
|
|
789
|
-
this._editorSetProp(
|
|
930
|
+
this._editorSetProp(hostPath, index, k, fieldDefault(k))
|
|
790
931
|
delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
|
|
791
932
|
}
|
|
792
933
|
c.ui._props = run.props
|
|
793
934
|
} else {
|
|
794
935
|
const snapshot = { ...props }
|
|
795
|
-
const resolved = (resolveRefs(snapshot, live.nodes) ?? snapshot) as Record<string, unknown>
|
|
936
|
+
const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
|
|
796
937
|
for (const f of c.ui._fields) {
|
|
797
938
|
if (isExpr(resolved[f.key])) continue
|
|
798
939
|
c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
|
|
@@ -803,12 +944,12 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
803
944
|
return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
|
|
804
945
|
}
|
|
805
946
|
|
|
806
|
-
/** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance
|
|
807
|
-
* path/name/depth/live node, in the same part-path grammar
|
|
808
|
-
* plain nodes / unknown
|
|
809
|
-
async _modelParts(
|
|
947
|
+
/** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
|
|
948
|
+
* absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
|
|
949
|
+
* `overrides` keys use. Empty for plain nodes / unknown paths. */
|
|
950
|
+
async _modelParts(path: string): Promise<ModelPartRow[]> {
|
|
810
951
|
const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
|
|
811
|
-
const node = nodes[
|
|
952
|
+
const node = nodes[path]
|
|
812
953
|
if (node instanceof Model) return modelPartRows(node)
|
|
813
954
|
// prefab instances precompute their rows in DEF order (engine child order isn't stable)
|
|
814
955
|
return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
|
|
@@ -830,8 +971,9 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
|
830
971
|
|
|
831
972
|
/**
|
|
832
973
|
* Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
|
|
833
|
-
* `const { scene, nodes } = await handle.open()` gives `nodes
|
|
834
|
-
* (Mesh / Model / Light / Node) with its `use(...)`d aspects attached
|
|
974
|
+
* `const { scene, nodes, get } = await handle.open()` gives `nodes[path]` typed by its source
|
|
975
|
+
* block (Mesh / Model / Light / Node) with its `use(...)`d aspects attached — root nodes read as
|
|
976
|
+
* plain properties (`nodes.hero`), nested ones by path (`nodes['hero/halo']` / `get('hero/halo')`).
|
|
835
977
|
*/
|
|
836
978
|
export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
|
|
837
979
|
const handle = new SceneHandle(def)
|