lecodes-cli 0.18.2 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. package/dist/index.js +1866 -301
  2. package/package.json +14 -12
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk-types.json +1 -1
  5. package/src/api.ts +302 -0
  6. package/src/browserAuth.ts +87 -0
  7. package/src/cmgenTool.ts +104 -0
  8. package/src/commands/app.ts +891 -0
  9. package/src/commands/appAndroid.ts +603 -0
  10. package/src/commands/appDesktop.ts +326 -0
  11. package/src/commands/appDesktopMac.ts +470 -0
  12. package/src/commands/appIcon.ts +187 -0
  13. package/src/commands/appShared.ts +448 -0
  14. package/src/commands/appTemplates.ts +883 -0
  15. package/src/commands/appTemplatesAndroid.ts +599 -0
  16. package/src/commands/appTemplatesGradlew.ts +9 -0
  17. package/src/commands/assets.ts +28 -0
  18. package/src/commands/clone.ts +60 -0
  19. package/src/commands/compile.ts +253 -0
  20. package/src/commands/create.ts +59 -0
  21. package/src/commands/design.ts +863 -0
  22. package/src/commands/designTemplates.ts +11 -0
  23. package/src/commands/desktop.ts +122 -0
  24. package/src/commands/dev.ts +214 -0
  25. package/src/commands/diff.ts +59 -0
  26. package/src/commands/init.ts +191 -0
  27. package/src/commands/install.ts +198 -0
  28. package/src/commands/lightmap.ts +290 -0
  29. package/src/commands/link.ts +147 -0
  30. package/src/commands/login.ts +24 -0
  31. package/src/commands/navmesh.ts +225 -0
  32. package/src/commands/pn.ts +305 -0
  33. package/src/commands/projectTemplates.ts +129 -0
  34. package/src/commands/pull.ts +109 -0
  35. package/src/commands/push.ts +173 -0
  36. package/src/commands/render.ts +414 -0
  37. package/src/commands/scene.ts +148 -0
  38. package/src/commands/shaders.ts +157 -0
  39. package/src/commands/status.ts +29 -0
  40. package/src/commands/test.ts +370 -0
  41. package/src/commands/thumbs.ts +178 -0
  42. package/src/commands/types.ts +87 -0
  43. package/src/commands/update.ts +190 -0
  44. package/src/compile/assetIcons.ts +214 -0
  45. package/src/compile/collect.ts +46 -0
  46. package/src/compile/collectLocal.ts +37 -0
  47. package/src/compile/designCompile.ts +104 -0
  48. package/src/compile/fonts.ts +156 -0
  49. package/src/compile/headlessBundle.ts +129 -0
  50. package/src/compile/nativeStack.ts +21 -0
  51. package/src/compile/projectCompile.ts +109 -0
  52. package/src/compile/sceneCompile.ts +113 -0
  53. package/src/compile/screenEntry.ts +127 -0
  54. package/src/compile/shaders.ts +245 -0
  55. package/src/config.ts +42 -0
  56. package/src/designMeta.ts +35 -0
  57. package/src/desktopRenderer.ts +532 -0
  58. package/src/desktopScript.ts +276 -0
  59. package/src/dev/clientTemplates.ts +160 -0
  60. package/src/dev/devServer.ts +292 -0
  61. package/src/dev/wsServer.ts +144 -0
  62. package/src/distRoot.ts +20 -0
  63. package/src/ignore.ts +163 -0
  64. package/src/index.ts +491 -0
  65. package/src/lecodes-3d-editor.d.ts +41 -0
  66. package/src/lecodes-assets.d.ts +7 -0
  67. package/src/lecodes-design.d.ts +191 -0
  68. package/src/lecodes-renderer.d.ts +131 -0
  69. package/src/localFiles.ts +144 -0
  70. package/src/manifest.ts +46 -0
  71. package/src/matcTool.ts +137 -0
  72. package/src/peers.ts +40 -0
  73. package/src/project.ts +61 -0
  74. package/src/projectEnv.ts +94 -0
  75. package/src/qrcode-terminal.d.ts +9 -0
  76. package/src/releases.ts +125 -0
  77. package/src/serverDiff.ts +78 -0
  78. package/src/textDiff.ts +103 -0
  79. package/src/types.ts +0 -0
  80. package/src/util.ts +146 -0
  81. package/runtime/sdk/animate/animate.ts +0 -238
  82. package/runtime/sdk/animate/bezier.ts +0 -138
  83. package/runtime/sdk/animate/easings.ts +0 -126
  84. package/runtime/sdk/canvas/Canvas.ts +0 -305
  85. package/runtime/sdk/core/Aspect.ts +0 -512
  86. package/runtime/sdk/core/InspectorUI.ts +0 -212
  87. package/runtime/sdk/core/color.ts +0 -66
  88. package/runtime/sdk/core/compWrite.ts +0 -42
  89. package/runtime/sdk/core/events.ts +0 -38
  90. package/runtime/sdk/core/fields.ts +0 -120
  91. package/runtime/sdk/core/registry.ts +0 -23
  92. package/runtime/sdk/core/signals.ts +0 -277
  93. package/runtime/sdk/core/time.ts +0 -81
  94. package/runtime/sdk/g2/Camera2D.ts +0 -40
  95. package/runtime/sdk/g2/CharacterController2D.ts +0 -276
  96. package/runtime/sdk/g2/Node2D.ts +0 -267
  97. package/runtime/sdk/g2/OneWay2D.ts +0 -66
  98. package/runtime/sdk/g2/Physics2D.ts +0 -346
  99. package/runtime/sdk/g2/Scene2D.ts +0 -209
  100. package/runtime/sdk/g2/Shape2D.ts +0 -259
  101. package/runtime/sdk/g2/Sprite.ts +0 -89
  102. package/runtime/sdk/g2/SpriteAnimation.ts +0 -171
  103. package/runtime/sdk/g2/SpriteSheet.ts +0 -166
  104. package/runtime/sdk/g2/Texture2D.ts +0 -47
  105. package/runtime/sdk/g2/Tilemap.ts +0 -41
  106. package/runtime/sdk/g2/Tileset.ts +0 -71
  107. package/runtime/sdk/g2/Trigger2D.ts +0 -77
  108. package/runtime/sdk/g2/autotile.ts +0 -433
  109. package/runtime/sdk/g2/cells.ts +0 -91
  110. package/runtime/sdk/g2/defineScene2d.ts +0 -381
  111. package/runtime/sdk/g2/groups2d.ts +0 -106
  112. package/runtime/sdk/g2/loop.ts +0 -50
  113. package/runtime/sdk/g2/scenarios2d.ts +0 -69
  114. package/runtime/sdk/g2/touch.ts +0 -83
  115. package/runtime/sdk/gl/Camera.ts +0 -160
  116. package/runtime/sdk/gl/CameraPlace.ts +0 -52
  117. package/runtime/sdk/gl/CharacterController.ts +0 -238
  118. package/runtime/sdk/gl/Gearbox.ts +0 -212
  119. package/runtime/sdk/gl/Geometry.ts +0 -279
  120. package/runtime/sdk/gl/IK.ts +0 -193
  121. package/runtime/sdk/gl/InstancedMesh.ts +0 -132
  122. package/runtime/sdk/gl/Light.ts +0 -99
  123. package/runtime/sdk/gl/Lightmap.ts +0 -179
  124. package/runtime/sdk/gl/Material.ts +0 -245
  125. package/runtime/sdk/gl/Mesh.ts +0 -83
  126. package/runtime/sdk/gl/Model.ts +0 -64
  127. package/runtime/sdk/gl/Node.ts +0 -350
  128. package/runtime/sdk/gl/Noise.ts +0 -30
  129. package/runtime/sdk/gl/Particles.ts +0 -676
  130. package/runtime/sdk/gl/Physics.ts +0 -222
  131. package/runtime/sdk/gl/Plane.ts +0 -53
  132. package/runtime/sdk/gl/Ray.ts +0 -16
  133. package/runtime/sdk/gl/Scene.ts +0 -479
  134. package/runtime/sdk/gl/Shape.ts +0 -377
  135. package/runtime/sdk/gl/Texture.ts +0 -46
  136. package/runtime/sdk/gl/Trigger.ts +0 -45
  137. package/runtime/sdk/gl/Vehicle.ts +0 -473
  138. package/runtime/sdk/gl/Wheel.ts +0 -240
  139. package/runtime/sdk/gl/animation/AnimationClip.ts +0 -204
  140. package/runtime/sdk/gl/animation/Animator.ts +0 -87
  141. package/runtime/sdk/gl/animation/Layer.ts +0 -29
  142. package/runtime/sdk/gl/animation/Loop.ts +0 -25
  143. package/runtime/sdk/gl/animation/Playback.ts +0 -43
  144. package/runtime/sdk/gl/animation/core.ts +0 -294
  145. package/runtime/sdk/gl/controls.ts +0 -95
  146. package/runtime/sdk/gl/physicsEvents.ts +0 -20
  147. package/runtime/sdk/gl/scenarios.ts +0 -291
  148. package/runtime/sdk/gl/state.ts +0 -6
  149. package/runtime/sdk/gl/touch.ts +0 -68
  150. package/runtime/sdk/inject.ts +0 -186
  151. package/runtime/sdk/math/Mathf.ts +0 -118
  152. package/runtime/sdk/math/mat4.ts +0 -278
  153. package/runtime/sdk/math/quat.ts +0 -232
  154. package/runtime/sdk/math/vec.ts +0 -255
  155. package/runtime/sdk/plugins/camera.ts +0 -81
  156. package/runtime/sdk/plugins/geolocation.ts +0 -123
  157. package/runtime/sdk/plugins/oauth.ts +0 -61
  158. package/runtime/sdk/plugins/permission.ts +0 -7
  159. package/runtime/sdk/plugins/push.ts +0 -132
  160. package/runtime/sdk/plugins/qr.ts +0 -73
  161. package/runtime/sdk/plugins/service.ts +0 -47
  162. package/runtime/sdk/runtime/app.ts +0 -101
  163. package/runtime/sdk/runtime/appEvents.ts +0 -54
  164. package/runtime/sdk/runtime/channel.ts +0 -61
  165. package/runtime/sdk/runtime/clipboard.ts +0 -20
  166. package/runtime/sdk/runtime/datetime.ts +0 -329
  167. package/runtime/sdk/runtime/device.ts +0 -293
  168. package/runtime/sdk/runtime/fetch.ts +0 -77
  169. package/runtime/sdk/runtime/files.ts +0 -23
  170. package/runtime/sdk/runtime/input.ts +0 -175
  171. package/runtime/sdk/runtime/media.ts +0 -111
  172. package/runtime/sdk/runtime/misc.ts +0 -16
  173. package/runtime/sdk/runtime/net.ts +0 -36
  174. package/runtime/sdk/runtime/rpc.ts +0 -218
  175. package/runtime/sdk/runtime/service.ts +0 -83
  176. package/runtime/sdk/runtime/share.ts +0 -9
  177. package/runtime/sdk/runtime/storage.ts +0 -13
  178. package/runtime/sdk/runtime/touch.ts +0 -76
  179. package/runtime/sdk/scene/defineScene.ts +0 -1227
  180. package/runtime/sdk/scene/editorPlugins.ts +0 -92
  181. package/runtime/sdk/scene/gizmos.ts +0 -148
  182. package/runtime/sdk/scene/grammar.ts +0 -120
  183. package/runtime/sdk/scene/material.ts +0 -188
  184. package/runtime/sdk/server/auth/appConfig.ts +0 -12
  185. package/runtime/sdk/server/auth/global.ts +0 -80
  186. package/runtime/sdk/server/auth/host.ts +0 -318
  187. package/runtime/sdk/server/auth/models.ts +0 -83
  188. package/runtime/sdk/server/auth/types.ts +0 -50
  189. package/runtime/sdk/server/channel.ts +0 -56
  190. package/runtime/sdk/server/context.ts +0 -36
  191. package/runtime/sdk/server/db/defineDb.ts +0 -237
  192. package/runtime/sdk/server/db/fields.ts +0 -132
  193. package/runtime/sdk/server/db/httpTransport.ts +0 -93
  194. package/runtime/sdk/server/db/index.ts +0 -7
  195. package/runtime/sdk/server/db/marci/query.ts +0 -412
  196. package/runtime/sdk/server/db/types.ts +0 -202
  197. package/runtime/sdk/server/errors.ts +0 -12
  198. package/runtime/sdk/server/host.ts +0 -74
  199. package/runtime/sdk/server/inject.ts +0 -13
  200. package/runtime/sdk/server/runtime.ts +0 -133
  201. package/runtime/sdk/server/validate.ts +0 -87
  202. package/runtime/sdk/ui/NativeView.ts +0 -142
  203. package/runtime/sdk/ui/UI.ts +0 -39
  204. package/runtime/sdk/ui/UIBottomSheet.ts +0 -139
  205. package/runtime/sdk/ui/UIButton.ts +0 -101
  206. package/runtime/sdk/ui/UIContainer.ts +0 -60
  207. package/runtime/sdk/ui/UIImage.ts +0 -83
  208. package/runtime/sdk/ui/UIInput.ts +0 -185
  209. package/runtime/sdk/ui/UIModal.ts +0 -139
  210. package/runtime/sdk/ui/UINode.ts +0 -826
  211. package/runtime/sdk/ui/UIPager.ts +0 -362
  212. package/runtime/sdk/ui/UIPopover.ts +0 -100
  213. package/runtime/sdk/ui/UIScreen.ts +0 -123
  214. package/runtime/sdk/ui/UIScrollable.ts +0 -87
  215. package/runtime/sdk/ui/UISpacer.ts +0 -14
  216. package/runtime/sdk/ui/UITabs.ts +0 -236
  217. package/runtime/sdk/ui/UIText.ts +0 -51
  218. package/runtime/sdk/ui/UIVideo.ts +0 -88
  219. package/runtime/sdk/ui/UIVirtualizedList.ts +0 -241
  220. package/runtime/sdk/ui/UIWidget.ts +0 -127
  221. package/runtime/sdk/ui/fonts.ts +0 -13
  222. package/runtime/sdk/ui/presentable.ts +0 -117
  223. package/runtime/sdk/ui/router.ts +0 -132
  224. package/runtime/sdk/ui/theme.ts +0 -84
@@ -1,1227 +0,0 @@
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
- }