lecodes-cli 0.6.3 → 0.7.0
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/README.md +7 -0
- package/dist/index.js +1035 -549
- package/package.json +9 -4
- package/runtime/scene-harness.json +1 -0
- package/runtime/sdk/compile/assetMacro.ts +4 -3
- package/runtime/sdk/compile/bundler.ts +35 -3
- package/runtime/sdk/compile/compileProject.ts +14 -2
- package/runtime/sdk/compile/index.ts +6 -0
- package/runtime/sdk/compile/libraryImports.ts +47 -0
- package/runtime/sdk/compile/sceneEditor.ts +78 -0
- package/runtime/sdk/core/Aspect.ts +37 -0
- package/runtime/sdk/core/InspectorUI.ts +212 -0
- package/runtime/sdk/core/fields.ts +120 -0
- package/runtime/sdk/gl/Material.ts +11 -1
- package/runtime/sdk/gl/Node.ts +4 -1
- package/runtime/sdk/gl/Scene.ts +6 -1
- package/runtime/sdk/gl/scenarios.ts +349 -0
- package/runtime/sdk/inject.ts +18 -0
- package/runtime/sdk/kit/UITabs.ts +105 -0
- package/runtime/sdk/runtime/datetime.ts +331 -0
- package/runtime/sdk/scene/defineScene.ts +903 -0
- package/runtime/sdk/scene/editorPlugins.ts +86 -0
- package/runtime/sdk/ui/UI.ts +1 -0
- package/runtime/sdk/ui/UIScreen.ts +23 -0
- package/runtime/sdk/ui/UIScreenHost.ts +212 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,903 @@
|
|
|
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, 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
|
|
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 { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
|
|
38
|
+
import type { ColorInput } from "../core/color"
|
|
39
|
+
import type { Vec3Like } from "../math/vec"
|
|
40
|
+
import { Scene, type SceneOptions } from "../gl/Scene"
|
|
41
|
+
import { Node } from "../gl/Node"
|
|
42
|
+
import { Mesh } from "../gl/Mesh"
|
|
43
|
+
import { Model } from "../gl/Model"
|
|
44
|
+
import { Light, type SunOptions } from "../gl/Light"
|
|
45
|
+
import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
|
|
46
|
+
import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
|
|
47
|
+
import { edgesMesh } from "../gl/scenarios"
|
|
48
|
+
|
|
49
|
+
// ---- the literal grammar (what the visual editor reads and writes) -----------
|
|
50
|
+
|
|
51
|
+
export type MeshDef =
|
|
52
|
+
| { kind: "box", size?: Vec3Like | number }
|
|
53
|
+
| ({ kind: "sphere" } & SphereOptions)
|
|
54
|
+
| ({ kind: "cylinder" } & CylinderOptions)
|
|
55
|
+
| ({ kind: "plane" } & PlaneOptions)
|
|
56
|
+
|
|
57
|
+
export type LightDef = { kind: "sun" } & SunOptions
|
|
58
|
+
|
|
59
|
+
export type MaterialDef =
|
|
60
|
+
| { lit: LitMaterialOptions }
|
|
61
|
+
| { unlit: MaterialColorOptions }
|
|
62
|
+
| { shadow: ColorInput }
|
|
63
|
+
// An imported/shared Material instance — valid at runtime; the editor shows it read-only.
|
|
64
|
+
| Material
|
|
65
|
+
|
|
66
|
+
/** One aspect to attach, created by `use(Ctor, props)`. */
|
|
67
|
+
export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
|
|
68
|
+
/** @internal grammar marker. */
|
|
69
|
+
readonly __use: true
|
|
70
|
+
ctor: AspectCtor<A>
|
|
71
|
+
props?: Partial<A>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
|
|
75
|
+
* typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
|
|
76
|
+
export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
|
|
77
|
+
({ __use: true, ctor, props })
|
|
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
|
+
|
|
138
|
+
export type SceneNodeDef = {
|
|
139
|
+
// -- source (at most one; none = plain group node) --
|
|
140
|
+
mesh?: MeshDef
|
|
141
|
+
/** GLB url — `asset('./hero.glb')`. */
|
|
142
|
+
model?: string
|
|
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>
|
|
156
|
+
/** Material for a `mesh` source. */
|
|
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>
|
|
161
|
+
// -- transform / render --
|
|
162
|
+
position?: Vec3Like
|
|
163
|
+
eulerAngles?: Vec3Like
|
|
164
|
+
scale?: Vec3Like | number
|
|
165
|
+
visible?: boolean
|
|
166
|
+
/** Editor-only: the transform gizmo won't target this node (fields still edit). No runtime effect. */
|
|
167
|
+
locked?: boolean
|
|
168
|
+
castShadows?: boolean
|
|
169
|
+
receiveShadows?: boolean
|
|
170
|
+
// -- capabilities / hierarchy --
|
|
171
|
+
aspects?: readonly AspectEntry<any>[]
|
|
172
|
+
children?: Record<string, SceneNodeDef>
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type SceneCameraDef = {
|
|
176
|
+
position?: Vec3Like
|
|
177
|
+
/** Point the camera looks at. */
|
|
178
|
+
target?: Vec3Like
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export type SceneDef = {
|
|
182
|
+
env?: SceneOptions
|
|
183
|
+
camera?: SceneCameraDef
|
|
184
|
+
nodes?: Record<string, SceneNodeDef>
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---- typed handle -------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
type SourceNodeOf<N extends SceneNodeDef> =
|
|
190
|
+
N extends { model: string } ? Model
|
|
191
|
+
: N extends { mesh: MeshDef } ? Mesh
|
|
192
|
+
: N extends { light: LightDef } ? Light
|
|
193
|
+
: Node
|
|
194
|
+
|
|
195
|
+
type AspectsOf<N extends SceneNodeDef> =
|
|
196
|
+
N extends { aspects: readonly AspectEntry<infer A>[] } ? A : never
|
|
197
|
+
|
|
198
|
+
type NodeOf<N extends SceneNodeDef> =
|
|
199
|
+
[AspectsOf<N>] extends [never] ? SourceNodeOf<N> : With<SourceNodeOf<N>, AspectsOf<N>>
|
|
200
|
+
|
|
201
|
+
type UnionToIntersection<U> =
|
|
202
|
+
(U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never
|
|
203
|
+
|
|
204
|
+
// The child maps of a def level, as a union (never when no node has children — guarded below,
|
|
205
|
+
// since `unknown` would absorb the union and `never` would poison the intersection).
|
|
206
|
+
type ChildMapsOf<T extends Record<string, SceneNodeDef>> =
|
|
207
|
+
{ [K in keyof T]: T[K] extends { children: infer C extends Record<string, SceneNodeDef> } ? NodesOf<C> : never }[keyof T]
|
|
208
|
+
|
|
209
|
+
// All nodes of a def tree, flattened into one name → node map (names are unique per scene file —
|
|
210
|
+
// the editor enforces it; at runtime a duplicate key simply overwrites in the map).
|
|
211
|
+
type NodesOf<T extends Record<string, SceneNodeDef>> =
|
|
212
|
+
{ [K in keyof T]: NodeOf<T[K]> } &
|
|
213
|
+
([ChildMapsOf<T>] extends [never] ? unknown : UnionToIntersection<ChildMapsOf<T>>)
|
|
214
|
+
|
|
215
|
+
export type SceneNodes<D extends SceneDef> =
|
|
216
|
+
D["nodes"] extends Record<string, SceneNodeDef> ? NodesOf<D["nodes"]> : Record<string, Node>
|
|
217
|
+
|
|
218
|
+
export type LoadedScene<D extends SceneDef> = {
|
|
219
|
+
scene: Scene
|
|
220
|
+
nodes: SceneNodes<D>
|
|
221
|
+
}
|
|
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
|
+
|
|
273
|
+
// ---- runtime -------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
const EDIT_FLAG = "__lecodesSceneEdit"
|
|
276
|
+
const isEditMode = (): boolean => (globalThis as Record<string, unknown>)[EDIT_FLAG] === true
|
|
277
|
+
|
|
278
|
+
const resolveMaterial = (def: MaterialDef): Material => {
|
|
279
|
+
if (def instanceof Material) return def
|
|
280
|
+
if ("lit" in def) return Material.lit(def.lit)
|
|
281
|
+
if ("unlit" in def) return Material.unlit(def.unlit)
|
|
282
|
+
return Material.shadow(def.shadow)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const createMesh = (def: MeshDef, material?: MaterialDef): Mesh => {
|
|
286
|
+
const mat = material !== undefined ? resolveMaterial(material) : undefined
|
|
287
|
+
switch (def.kind) {
|
|
288
|
+
case "box": return Mesh.box({ size: def.size, material: mat })
|
|
289
|
+
case "sphere": { const { kind: _k, ...opts } = def; return Mesh.sphere({ ...opts, material: mat }) }
|
|
290
|
+
case "cylinder": { const { kind: _k, ...opts } = def; return Mesh.cylinder({ ...opts, material: mat }) }
|
|
291
|
+
case "plane": { const { kind: _k, ...opts } = def; return Mesh.plane({ ...opts, material: mat }) }
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const createSource = (name: string, def: SceneNodeDef): Node | Promise<Node> => {
|
|
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)`)
|
|
298
|
+
if (def.model !== undefined) return Model.load(def.model)
|
|
299
|
+
if (def.mesh !== undefined) return createMesh(def.mesh, def.material)
|
|
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
|
|
303
|
+
return new Node()
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
|
|
307
|
+
node.name = name
|
|
308
|
+
if (def.position) node.position = def.position
|
|
309
|
+
if (def.eulerAngles) node.eulerAngles = def.eulerAngles
|
|
310
|
+
if (def.scale !== undefined) node.scale = def.scale
|
|
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)
|
|
318
|
+
if (node instanceof Mesh) {
|
|
319
|
+
if (def.castShadows !== undefined) node.castShadows = def.castShadows
|
|
320
|
+
if (def.receiveShadows !== undefined) node.receiveShadows = def.receiveShadows
|
|
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) }
|
|
444
|
+
}
|
|
445
|
+
|
|
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 => {
|
|
515
|
+
if (isEditMode()) {
|
|
516
|
+
// Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
|
|
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).
|
|
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
|
+
})
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
for (const entry of def.aspects ?? []) {
|
|
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)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
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
|
+
}
|
|
590
|
+
|
|
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> => {
|
|
601
|
+
const node = await createSource(name, nd)
|
|
602
|
+
if (parentNode) parentNode.add(node)
|
|
603
|
+
// Draw-set membership is separate from parenting (see docs/3d/node.md) — every def node joins.
|
|
604
|
+
scene.add(node)
|
|
605
|
+
applyNode(node, name, 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 })
|
|
609
|
+
nodes[name] = node
|
|
610
|
+
await Promise.all(Object.entries(nd.children ?? {}).map(([ childName, child ]) => build(childName, child, node)))
|
|
611
|
+
}
|
|
612
|
+
|
|
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
|
+
}
|
|
622
|
+
|
|
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) {
|
|
639
|
+
if (def.camera.position) scene.camera.position = def.camera.position
|
|
640
|
+
if (def.camera.target) scene.camera.lookAt(def.camera.target)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
return { scene, nodes }
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
export class SceneHandle<D extends SceneDef = SceneDef> {
|
|
647
|
+
readonly def: D
|
|
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
|
|
655
|
+
|
|
656
|
+
constructor(def: D) { this.def = def }
|
|
657
|
+
|
|
658
|
+
/** Instantiate the scene (idempotent — subsequent calls return the same instance). Does not open. */
|
|
659
|
+
load(): 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
|
+
}
|
|
666
|
+
return this._loading
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** Load and make active. */
|
|
670
|
+
async open(): Promise<LoadedScene<D>> {
|
|
671
|
+
const loaded = await this.load()
|
|
672
|
+
loaded.scene.open()
|
|
673
|
+
return loaded
|
|
674
|
+
}
|
|
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
|
+
|
|
877
|
+
/** @internal Editor: describe every aspect class this scene references (fields + defaults). */
|
|
878
|
+
_describeAspects(): AspectClassInfo[] {
|
|
879
|
+
const ctors = new Set<AspectCtor<any>>()
|
|
880
|
+
const walk = (defs?: Record<string, SceneNodeDef>): void => {
|
|
881
|
+
for (const nd of Object.values(defs ?? {})) {
|
|
882
|
+
for (const e of nd.aspects ?? []) ctors.add(e.ctor)
|
|
883
|
+
walk(nd.children)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
walk(this.def.nodes)
|
|
887
|
+
return [ ...ctors ].map((c) => describeAspect(c))
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Define a scene as data — the default export of a `.scene.ts` file. Returns a typed handle:
|
|
893
|
+
* `const { scene, nodes } = await handle.open()` gives `nodes.<name>` typed by its source block
|
|
894
|
+
* (Mesh / Model / Light / Node) with its `use(...)`d aspects attached.
|
|
895
|
+
*/
|
|
896
|
+
export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> => {
|
|
897
|
+
const handle = new SceneHandle(def)
|
|
898
|
+
const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
|
|
899
|
+
// Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
|
|
900
|
+
// up the handle to load it in edit mode and drive the inspector).
|
|
901
|
+
if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
|
|
902
|
+
return handle
|
|
903
|
+
}
|