lecodes-cli 0.19.0 → 0.20.0

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