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