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