lecodes-sdk 1.2.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +5 -2
  2. package/dist/editor.d.ts +12 -0
  3. package/dist/global.d.ts +4 -8
  4. package/dist/host.d.ts +2 -3
  5. package/dist/types/animate/tween/Animation.d.ts +0 -3
  6. package/dist/types/animate/tween/animateValue.d.ts +4 -2
  7. package/dist/types/animate/tween/easing.d.ts +8 -0
  8. package/dist/types/animate/tween/spec.d.ts +15 -7
  9. package/dist/types/audio/audio.d.ts +2 -1
  10. package/dist/types/canvas/Canvas.d.ts +40 -108
  11. package/dist/types/canvas/gen/cssColor.d.ts +17 -0
  12. package/dist/types/canvas/gen/recorder.d.ts +118 -0
  13. package/dist/types/canvas/gen/spec.d.ts +144 -0
  14. package/dist/types/core/color.d.ts +3 -1
  15. package/dist/types/core/pins.d.ts +18 -0
  16. package/dist/types/g2/Node2D.d.ts +5 -8
  17. package/dist/types/g2/Scene2D.d.ts +6 -2
  18. package/dist/types/gl/Foliage.d.ts +30 -7
  19. package/dist/types/gl/Light.d.ts +8 -0
  20. package/dist/types/gl/Lightmap.d.ts +13 -2
  21. package/dist/types/gl/Material.d.ts +17 -3
  22. package/dist/types/gl/Model.d.ts +6 -2
  23. package/dist/types/gl/Node.d.ts +3 -6
  24. package/dist/types/gl/Scene.d.ts +41 -13
  25. package/dist/types/gl/Texture.d.ts +1 -1
  26. package/dist/types/gl/animation/Locomotion.d.ts +8 -1
  27. package/dist/types/inject.d.ts +11 -11
  28. package/dist/types/inject.editor.d.ts +1 -0
  29. package/dist/types/net/core.d.ts +7 -0
  30. package/dist/types/plugin.d.ts +76 -0
  31. package/dist/types/plugins/gen/camera/sdk/camera.d.ts +24 -0
  32. package/dist/types/plugins/gen/camera/sdk/camera.gen.d.ts +25 -0
  33. package/dist/types/plugins/{geolocation.d.ts → gen/geolocation/sdk/geolocation.d.ts} +2 -20
  34. package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +31 -0
  35. package/dist/types/plugins/{map.d.ts → gen/map/sdk/map.d.ts} +9 -50
  36. package/dist/types/plugins/gen/map/sdk/map.gen.d.ts +53 -0
  37. package/dist/types/plugins/gen/push/sdk/push.d.ts +23 -0
  38. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +35 -0
  39. package/dist/types/plugins/{qr.d.ts → gen/qr-scanner/sdk/qr-scanner.d.ts} +2 -3
  40. package/dist/types/plugins/gen/qr-scanner/sdk/qr-scanner.gen.d.ts +15 -0
  41. package/dist/types/runtime/app.d.ts +9 -2
  42. package/dist/types/runtime/fetch.d.ts +2 -0
  43. package/dist/types/runtime/input.d.ts +1 -1
  44. package/dist/types/runtime/media.d.ts +6 -10
  45. package/dist/types/runtime/misc.d.ts +4 -1
  46. package/dist/types/runtime/net.d.ts +3 -2
  47. package/dist/types/runtime/touch.d.ts +32 -0
  48. package/dist/types/scene/defineScene.d.ts +43 -2
  49. package/dist/types/scene/editor.d.ts +52 -0
  50. package/dist/types/scene/gizmos.d.ts +7 -4
  51. package/dist/types/ui/NativeView.d.ts +6 -4
  52. package/dist/types/ui/UI.d.ts +1 -1
  53. package/dist/types/ui/UIBottomSheet.d.ts +6 -12
  54. package/dist/types/ui/UIButton.d.ts +12 -14
  55. package/dist/types/ui/UIContainer.d.ts +0 -6
  56. package/dist/types/ui/UIImage.d.ts +1 -4
  57. package/dist/types/ui/UIInput.d.ts +8 -24
  58. package/dist/types/ui/UIModal.d.ts +0 -2
  59. package/dist/types/ui/UINode.d.ts +70 -56
  60. package/dist/types/ui/UIPager.d.ts +28 -27
  61. package/dist/types/ui/UIPopover.d.ts +0 -2
  62. package/dist/types/ui/UIScreen.d.ts +12 -17
  63. package/dist/types/ui/UIScrollable.d.ts +1 -4
  64. package/dist/types/ui/UIText.d.ts +0 -2
  65. package/dist/types/ui/UIVideo.d.ts +3 -5
  66. package/dist/types/ui/UIVirtualizedList.d.ts +14 -16
  67. package/dist/types/ui/UIWidget.d.ts +10 -9
  68. package/dist/types/ui/colorKeys.gen.d.ts +9 -0
  69. package/dist/types/ui/presentable.d.ts +46 -32
  70. package/dist/types/ui/router.d.ts +18 -7
  71. package/dist/types/ui/styleColor.d.ts +1 -0
  72. package/dist/types/ui/transitions.d.ts +18 -0
  73. package/dist/types/ui/tree.d.ts +75 -0
  74. package/dist/types/version.d.ts +10 -0
  75. package/dist/types.json +1 -1
  76. package/package.json +12 -3
  77. package/prompts/2d.md +2 -6
  78. package/prompts/3d.md +1 -5
  79. package/prompts/README.md +1 -1
  80. package/prompts/canvas.md +9 -8
  81. package/prompts/compose.ts +1 -1
  82. package/prompts/core.md +3 -3
  83. package/prompts/design.md +1 -1
  84. package/prompts/dist/2d-game.md +473 -239
  85. package/prompts/dist/3d-app.md +553 -205
  86. package/prompts/dist/ar-app.md +435 -202
  87. package/prompts/dist/design.md +113 -95
  88. package/prompts/dist/ui-app.md +386 -170
  89. package/prompts/ui-design.md +2 -3
  90. package/prompts/ui.md +45 -37
  91. package/src/animate/tween/Animation.ts +34 -150
  92. package/src/animate/tween/Timeline.ts +175 -175
  93. package/src/animate/tween/animateValue.ts +6 -3
  94. package/src/animate/tween/easing.ts +10 -3
  95. package/src/animate/tween/spec.ts +41 -15
  96. package/src/audio/Sound.ts +3 -3
  97. package/src/audio/audio.ts +2 -1
  98. package/src/bridges/2d.d.ts +317 -0
  99. package/src/bridges/app.d.ts +91 -0
  100. package/src/bridges/audio.d.ts +97 -0
  101. package/src/bridges/canvas.d.ts +79 -0
  102. package/src/bridges/device.d.ts +72 -0
  103. package/src/bridges/fetch.d.ts +80 -0
  104. package/src/bridges/files.d.ts +70 -0
  105. package/src/bridges/gl.d.ts +1133 -0
  106. package/src/bridges/input.d.ts +72 -0
  107. package/src/bridges/media.d.ts +55 -0
  108. package/src/bridges/nav.d.ts +71 -0
  109. package/src/bridges/net.d.ts +52 -0
  110. package/src/bridges/service.d.ts +52 -0
  111. package/src/bridges/socket.d.ts +31 -0
  112. package/src/bridges/storage.d.ts +33 -0
  113. package/src/bridges/tree.d.ts +301 -0
  114. package/src/bridges/types.d.ts +49 -0
  115. package/src/canvas/Canvas.ts +114 -159
  116. package/src/canvas/gen/cssColor.ts +224 -0
  117. package/src/canvas/gen/recorder.ts +212 -0
  118. package/src/canvas/gen/spec.ts +201 -0
  119. package/src/chisel.ts +193 -0
  120. package/src/compile/assetMacro.ts +1 -1
  121. package/src/compile/bundler.ts +11 -2
  122. package/src/compile/compileProject.ts +43 -4
  123. package/src/compile/fontMacro.ts +3 -4
  124. package/src/compile/header.ts +26 -5
  125. package/src/compile/index.ts +3 -1
  126. package/src/compile/liteMaterial.ts +1 -1
  127. package/src/compile/sceneEditor.ts +11 -26
  128. package/src/core/color.ts +73 -30
  129. package/src/core/pins.ts +51 -0
  130. package/src/core/signals.ts +8 -1
  131. package/src/g2/CharacterController2D.ts +3 -3
  132. package/src/g2/Node2D.ts +57 -39
  133. package/src/g2/Physics2D.ts +2 -2
  134. package/src/g2/Scene2D.ts +35 -23
  135. package/src/g2/Texture2D.ts +1 -1
  136. package/src/g2/loop.ts +4 -4
  137. package/src/gl/CameraPlace.ts +52 -52
  138. package/src/gl/Foliage.ts +72 -17
  139. package/src/gl/Geometry.ts +1 -2
  140. package/src/gl/Light.ts +10 -0
  141. package/src/gl/Lightmap.ts +45 -29
  142. package/src/gl/Material.ts +95 -51
  143. package/src/gl/Mesh.ts +120 -120
  144. package/src/gl/Model.ts +21 -16
  145. package/src/gl/Node.ts +91 -24
  146. package/src/gl/Particles.ts +1 -1
  147. package/src/gl/Scene.ts +100 -44
  148. package/src/gl/Texture.ts +8 -7
  149. package/src/gl/animation/AnimationClip.ts +1 -1
  150. package/src/gl/animation/DynamicBone.ts +482 -482
  151. package/src/gl/animation/Locomotion.ts +8 -3
  152. package/src/gl/nav/NavMesh.ts +3 -4
  153. package/src/gl/physics/Physics.ts +2 -2
  154. package/src/gl/physics/physicsEvents.ts +3 -3
  155. package/src/gl/scenarios.ts +291 -291
  156. package/src/gl/terrain/Terrain.ts +4 -5
  157. package/src/gl/touch.ts +14 -15
  158. package/src/host.d.ts +2 -3
  159. package/src/inject.editor.ts +7 -0
  160. package/src/inject.ts +13 -16
  161. package/src/net/core.ts +6 -5
  162. package/src/net/index.ts +1 -1
  163. package/src/net/replication.ts +1 -1
  164. package/src/plugin.ts +191 -0
  165. package/src/plugins/gen/camera/contract.d.ts +27 -0
  166. package/src/plugins/gen/camera/sdk/camera.gen.ts +46 -0
  167. package/src/plugins/gen/camera/sdk/camera.ts +57 -0
  168. package/src/plugins/gen/geolocation/contract.d.ts +50 -0
  169. package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +54 -0
  170. package/src/plugins/{geolocation.ts → gen/geolocation/sdk/geolocation.ts} +22 -43
  171. package/src/plugins/gen/map/contract.d.ts +144 -0
  172. package/src/plugins/gen/map/sdk/map.gen.ts +88 -0
  173. package/src/plugins/{map.ts → gen/map/sdk/map.ts} +68 -102
  174. package/src/plugins/gen/push/contract.d.ts +61 -0
  175. package/src/plugins/gen/push/sdk/push.gen.ts +60 -0
  176. package/src/plugins/gen/push/sdk/push.ts +105 -0
  177. package/src/plugins/gen/qr-scanner/contract.d.ts +16 -0
  178. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.gen.ts +29 -0
  179. package/src/plugins/gen/qr-scanner/sdk/qr-scanner.ts +52 -0
  180. package/src/plugins/permission.ts +5 -4
  181. package/src/runtime/app.ts +20 -9
  182. package/src/runtime/appEvents.ts +5 -4
  183. package/src/runtime/channel.ts +18 -15
  184. package/src/runtime/clipboard.ts +4 -3
  185. package/src/runtime/datetime.ts +2 -1
  186. package/src/runtime/device.ts +17 -15
  187. package/src/runtime/fetch.ts +30 -20
  188. package/src/runtime/files.ts +16 -15
  189. package/src/runtime/input.ts +12 -10
  190. package/src/runtime/media.ts +50 -46
  191. package/src/runtime/misc.ts +7 -3
  192. package/src/runtime/net.ts +8 -7
  193. package/src/runtime/rpc.ts +1 -3
  194. package/src/runtime/service.ts +19 -14
  195. package/src/runtime/share.ts +4 -3
  196. package/src/runtime/storage.ts +6 -4
  197. package/src/runtime/touch.ts +32 -0
  198. package/src/scene/defineScene.ts +61 -365
  199. package/src/scene/editor.ts +408 -0
  200. package/src/scene/editorPlugins.ts +3 -3
  201. package/src/scene/gizmos.ts +15 -9
  202. package/src/server/db/marci/query.ts +1 -1
  203. package/src/server/host.ts +1 -1
  204. package/src/server/runtime.ts +1 -1
  205. package/src/ui/NativeView.ts +72 -25
  206. package/src/ui/UI.ts +3 -3
  207. package/src/ui/UIBottomSheet.ts +16 -17
  208. package/src/ui/UIButton.ts +54 -16
  209. package/src/ui/UIContainer.ts +0 -6
  210. package/src/ui/UIImage.ts +34 -30
  211. package/src/ui/UIInput.ts +29 -37
  212. package/src/ui/UIModal.ts +1 -3
  213. package/src/ui/UINode.ts +347 -297
  214. package/src/ui/UIPager.ts +93 -78
  215. package/src/ui/UIPopover.ts +0 -2
  216. package/src/ui/UIScreen.ts +41 -36
  217. package/src/ui/UIScrollable.ts +19 -13
  218. package/src/ui/UISpacer.ts +1 -1
  219. package/src/ui/UITabs.ts +8 -6
  220. package/src/ui/UIText.ts +7 -17
  221. package/src/ui/UIVideo.ts +30 -27
  222. package/src/ui/UIVirtualizedList.ts +58 -59
  223. package/src/ui/UIWidget.ts +38 -18
  224. package/src/ui/colorKeys.gen.ts +37 -0
  225. package/src/ui/fonts.ts +2 -2
  226. package/src/ui/presentable.ts +59 -42
  227. package/src/ui/router.ts +52 -35
  228. package/src/ui/styleColor.ts +56 -0
  229. package/src/ui/theme.ts +7 -6
  230. package/src/ui/transitions.ts +249 -0
  231. package/src/ui/tree.ts +346 -0
  232. package/src/version.ts +24 -0
  233. package/tests/helpers/engineWorld.ts +11 -0
  234. package/tests/helpers/fakeTree.ts +353 -0
  235. package/tests/helpers/hostStubs.ts +31 -0
  236. package/tests/helpers/index.ts +14 -0
  237. package/tests/helpers/memoryMarci.ts +124 -0
  238. package/tests/helpers/phases.ts +23 -0
  239. package/tests/helpers/preload.ts +18 -0
  240. package/tests/helpers/stubApp.ts +2 -0
  241. package/tests/helpers/stubDevice.ts +2 -0
  242. package/tests/helpers/stubFetch.ts +2 -0
  243. package/tests/helpers/stubInput.ts +2 -0
  244. package/dist/inject.js +0 -4629
  245. package/dist/types/core/registry.d.ts +0 -7
  246. package/dist/types/plugins/camera.d.ts +0 -25
  247. package/dist/types/plugins/push.d.ts +0 -46
  248. package/src/bridges.d.ts +0 -1769
  249. package/src/compile/__tests__/assetIconMacro.test.ts +0 -219
  250. package/src/compile/__tests__/assetMacro.test.ts +0 -100
  251. package/src/compile/__tests__/assetName.test.ts +0 -55
  252. package/src/compile/__tests__/compile.test.ts +0 -310
  253. package/src/compile/__tests__/detectEntry.test.ts +0 -151
  254. package/src/compile/__tests__/fontMacro.test.ts +0 -199
  255. package/src/compile/__tests__/serverSplit.test.ts +0 -27
  256. package/src/core/__tests__/stateMachine.test.ts +0 -132
  257. package/src/core/registry.ts +0 -23
  258. package/src/plugins/camera.ts +0 -81
  259. package/src/plugins/push.ts +0 -132
  260. package/src/plugins/qr.ts +0 -73
@@ -31,14 +31,18 @@
31
31
  // sources are instantiated for real so the viewport shows the scene, but aspects are held as data
32
32
  // (`node._sceneAspects`) WITHOUT attaching — no onAttach side effects (physics bodies, timers), no
33
33
  // update() ticks. Defined handles register on `globalThis.__lecodesScenes` for the host to pick up.
34
+ //
35
+ // Edit mode exists in EDITOR BUNDLES only. Every edit-mode branch here reads `EDITOR && isEditMode()`
36
+ // — `EDITOR` is the compile-time half (bundler.ts: `false` in a shipped bundle, where chisel folds
37
+ // the branch away and tree-shakes what only it reached), the flag the run-time one. What the editor
38
+ // DOES to a loaded scene (patch / rename / live props / inspector cards) is not here at all: it is
39
+ // ./editor.ts, which only an editor bundle injects. The `@internal` exports below are its seam.
34
40
 
35
- import { Aspect, type AspectCtor, type With } from "../core/Aspect"
36
- import { describeAspect, describeFields, type AspectClassInfo } from "../core/fields"
41
+ import { Aspect, type With } from "../core/Aspect"
37
42
  import {
38
43
  collectRefDeps, isEditMode, isNodeRef, resolveRefPath, resolveRefs, use, ref, make, EDIT_FLAG,
39
44
  type AspectEntry, type MakeEntry as SharedMakeEntry,
40
45
  } from "./grammar"
41
- import { InspectorUI, type InspectorEvent, type InspectorWidget } from "../core/InspectorUI"
42
46
  import type { Vec3Like } from "../math/vec"
43
47
  import { Scene, type SceneOptions } from "../gl/Scene"
44
48
  import { CAMERA_DEFAULTS } from "../gl/Camera"
@@ -56,7 +60,7 @@ import { Light, type SunOptions } from "../gl/Light"
56
60
  import { Material } from "../gl/Material"
57
61
  import { assignMaterialDef, MaterialHandle, resolveMaterialDef, type MaterialDef } from "./material"
58
62
  import type { CapsuleOptions, CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
59
- import { GizmoBuffer, Gizmos, withGizmoScope, type GizmoBatch } from "./gizmos"
63
+ import { GizmoBuffer, Gizmos, withGizmoScope } from "./gizmos"
60
64
 
61
65
  // ---- the literal grammar (what the visual editor reads and writes) -----------
62
66
 
@@ -207,7 +211,7 @@ export type SceneCameraDef = CameraProjectionDef & {
207
211
  target?: Vec3Like
208
212
  }
209
213
 
210
- /** `env.lightmap` — the level's baked lighting (packages/creator-bake): the files `lecodes lightmap bake`
214
+ /** `env.lightmap` — the level's baked lighting (engines/bake): the files `lecodes lightmap bake`
211
215
  * writes. Absent = real-time only. */
212
216
  export type SceneLightmapDef = {
213
217
  /** `asset('./assets/lightmap/level.bake')` */
@@ -312,7 +316,7 @@ export type LoadedScene<D extends SceneDef> = {
312
316
  // A model's internal hierarchy is addressed by PART PATHS — '/'-joined segments from the model
313
317
  // root down, where a segment is the child's name, disambiguated as `name[i]` among same-named
314
318
  // siblings and `[i]` for unnamed children (i = index within that same-named group). The editor
315
- // enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
319
+ // enumerates rows via `sceneEditor.modelParts` (./editor.ts) and writes the paths as `overrides` keys; the
316
320
  // loader resolves them back through the same enumeration, so writer and resolver can't drift.
317
321
 
318
322
  /** One instance of a scene file built as a subtree (`handle.instantiate`). */
@@ -402,7 +406,7 @@ const resolveMountNode = (parent: Node, mountPath: string, ownerPath: string): N
402
406
  }
403
407
 
404
408
  /** Where a def node attaches: its `mount` part when it resolves, else the parent itself. */
405
- const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
409
+ export const attachHost = (parent: Node, def: SceneNodeDef, path: string): Node =>
406
410
  def.mount !== undefined ? resolveMountNode(parent, def.mount, path) ?? parent : parent
407
411
 
408
412
  // ---- runtime -------------------------------------------------------------------
@@ -478,7 +482,8 @@ const loadModel = (source: string, lightmap: boolean, foliage: boolean): Promise
478
482
  return template.then((t) => t.clone())
479
483
  }
480
484
 
481
- const createSource = (path: string, def: SceneNodeDef, lightmap: boolean): Node | Promise<Node> => {
485
+ /** @internal */
486
+ export const createSource = (path: string, def: SceneNodeDef, lightmap: boolean): Node | Promise<Node> => {
482
487
  const sources = [ def.mesh, def.model, def.light, def.make, def.prefab, def.camera, def.terrain ].filter((s) => s !== undefined).length
483
488
  if (sources > 1) throw new Error(`Scene node "${path}" declares more than one source (mesh/model/light/make/prefab/camera/terrain)`)
484
489
  if (def.model !== undefined) return loadModel(def.model, lightmap, !!def.foliage)
@@ -510,7 +515,8 @@ export const applyEditorPose = (node: Node, pose: { clip?: string, time?: number
510
515
  }
511
516
  }
512
517
 
513
- const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
518
+ /** @internal */
519
+ export const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
514
520
  node.name = name
515
521
  // def-built marker: part enumeration must skip this node (it is not asset-internal — it has
516
522
  // its own def path), even when it is parented inside a model via `mount`
@@ -520,8 +526,8 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
520
526
  if (def.scale !== undefined) node.scale = def.scale
521
527
  if (def.visible !== undefined) node.visible = def.visible
522
528
  // editor-only flag (the harness's manipulation layer consults it); inert at runtime
523
- if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
524
- if (def.editor !== undefined && isEditMode()) applyEditorPose(node, def.editor)
529
+ if (EDITOR && def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
530
+ if (EDITOR && def.editor !== undefined && isEditMode()) applyEditorPose(node, def.editor)
525
531
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
526
532
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
527
533
  // order is insertion-based and may not match the file
@@ -552,9 +558,9 @@ const isGroupDef = (def: SceneNodeDef): boolean =>
552
558
  * an axis cross / a frustum in the node's local frame (scene/gizmos.ts) — so they show and pick
553
559
  * in the viewport. Never scene content: nothing renders in Filament, nothing outlines, and the
554
560
  * engine follows the node live, so the buffer is filled once and lives on the node
555
- * (`_editorMarker`) — `_editorGizmos()` reads it off the handle's own def nodes, so a removed
561
+ * (`_editorMarker`) — `sceneEditor.gizmos()` reads it off the handle's own def nodes, so a removed
556
562
  * node's marker goes with its record and prefab / instance internals (not selectable) draw none. */
557
- const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
563
+ export const addEditorMarker = (node: Node, def: SceneNodeDef): void => {
558
564
  const buffer = new GizmoBuffer()
559
565
  if (def.camera !== undefined) {
560
566
  withGizmoScope(buffer, () => Gizmos.frustum(def.camera?.fov ?? CAMERA_DEFAULTS.fov, { node, color: "#cfd4dd" }))
@@ -603,7 +609,7 @@ const findCamera = (defs?: Record<string, SceneNodeDef>, prefix = ""): { path: s
603
609
  /** fov / near / far from a camera block onto the live camera. The build path leaves an empty block
604
610
  * alone (a host may run with its own configured fov); `reset` — the editor's live patch — fills
605
611
  * omitted keys with the defaults instead, so clearing a field in the inspector takes effect. */
606
- const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
612
+ export const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = false): void => {
607
613
  const { fov, near, far } = def
608
614
  if (!reset && fov === undefined && near === undefined && far === undefined) return
609
615
  scene.camera.setProjection(reset
@@ -614,8 +620,8 @@ const applyCameraProjection = (scene: Scene, def: CameraProjectionDef, reset = f
614
620
  // ---- editor-run aspects (generators) ------------------------------------------------------------
615
621
  // A class with `static editor = { rebuild: true }` runs while a scene is edited: the loader
616
622
  // constructs it (refs resolved, node + `generated` set — NEVER onAttach) and calls rebuild(); the
617
- // editor re-runs rebuild() on inspector prop edits (`_editorSetProp`) and whenever a node its
618
- // ref() fields point at changes (`_editorNodeChanged` — dep tracking is derived from the props).
623
+ // editor re-runs rebuild() on inspector prop edits (`sceneEditor.setProp`, ./editor.ts) and whenever a
624
+ // node its ref() fields point at changes (`sceneEditor.nodeChanged` — dep tracking is derived from the props).
619
625
  // Generated output lives under `this.generated`, a scene-added container child: never written to
620
626
  // the file, absent from the doc tree — the file stores the recipe, the viewport shows the result.
621
627
 
@@ -637,7 +643,7 @@ class GeneratedGroup extends Node {
637
643
  clear(): this {
638
644
  this.version++
639
645
  for (const c of [ ...this.children ]) {
640
- dropForeignRuns(c)
646
+ if (EDITOR) dropForeignRuns(c)
641
647
  this._scene.remove(c)
642
648
  c.destroy()
643
649
  }
@@ -649,7 +655,7 @@ class GeneratedGroup extends Node {
649
655
  // arms' gun socket) are not the edited handle's runs — not inspector-addressable, not
650
656
  // dep-tracked — but their gizmos (the weapon's sight line) must still draw. They register here
651
657
  // keyed by the instance root; `dispose()` / a hosting `generated.clear()` drops them.
652
- const foreignRuns = new Map<Node, EditorRun[]>()
658
+ export const foreignRuns = new Map<Node, EditorRun[]>()
653
659
  const dropForeignRuns = (root: Node): void => {
654
660
  for (const key of [ ...foreignRuns.keys() ]) {
655
661
  let n: Node | null = key
@@ -668,7 +674,7 @@ const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
668
674
  }
669
675
 
670
676
  /** One live editor-run aspect instance (edit mode only). */
671
- type EditorRun = {
677
+ export type EditorRun = {
672
678
  /** Absolute path of the host def node (re-keyed on rename/reparent). */
673
679
  hostPath: string
674
680
  node: Node
@@ -677,7 +683,7 @@ type EditorRun = {
677
683
  inst: { rebuild?(): void | Promise<void> }
678
684
  /** Async rebuild() supersession counter (see safeRebuild). */
679
685
  generation: number
680
- /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
686
+ /** Mutable props snapshot — `sceneEditor.setProp` updates it and re-derives `deps`. Holds the
681
687
  * DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
682
688
  * against the file's props, so rewriting them would re-fire on every render. */
683
689
  props: Record<string, unknown>
@@ -690,8 +696,13 @@ type EditorRun = {
690
696
 
691
697
  /** Bumped on every editor-run rebuild — the host compares it to know when to re-push gizmos. */
692
698
  let gizmoVersion = 0
699
+ /** @internal */
700
+ export const bumpGizmoVersion = (): number => ++gizmoVersion
701
+ /** @internal */
702
+ export const currentGizmoVersion = (): number => gizmoVersion
693
703
 
694
- const safeRebuild = (run: EditorRun): Promise<void> | void => {
704
+ /** @internal */
705
+ export const safeRebuild = (run: EditorRun): Promise<void> | void => {
695
706
  // a throwing generator must not take the editor session down with it; the gizmo scope is the
696
707
  // SYNCHRONOUS part of the call — whatever rebuild draws replaces the run's previous lines. An
697
708
  // async rebuild() (one that instantiates a scene file) is awaited; lines it wants to draw after
@@ -714,7 +725,7 @@ const safeRebuild = (run: EditorRun): Promise<void> | void => {
714
725
 
715
726
  /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
716
727
  * instances, so a generator's resolved fields would otherwise point at destroyed nodes. */
717
- const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
728
+ export const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
718
729
  const lookup = (r: string): Node | null => {
719
730
  const p = resolveRefPath(nodes, run.hostPath, r)
720
731
  return p === null ? null : nodes[p]
@@ -735,7 +746,7 @@ const assignRefProps = (run: EditorRun, nodes: Record<string, Node>): void => {
735
746
  // machinery generator aspects use — live, no compile (the code is already in the bundle).
736
747
 
737
748
  /** EditorRun.index for a node's make() run (aspect runs use their array index, always >= 0). */
738
- const MAKE_INDEX = -1
749
+ export const MAKE_INDEX = -1
739
750
 
740
751
  const createMakeInst = (path: string, entry: MakeEntry<any>, generated: GeneratedGroup): { rebuild(): void } => {
741
752
  let token = 0
@@ -743,7 +754,7 @@ const createMakeInst = (path: string, entry: MakeEntry<any>, generated: Generate
743
754
  inst.rebuild = () => {
744
755
  const t = ++token
745
756
  generated.clear()
746
- // current args = the instance's own fields (that's where _editorSetProp/assignRefProps write)
757
+ // current args = the instance's own fields (that's where sceneEditor.setProp / assignRefProps write)
747
758
  const args: Record<string, unknown> = {}
748
759
  for (const [ k, v ] of Object.entries(inst)) if (k !== "rebuild") args[k] = v
749
760
  const result = entry.fn(args as never)
@@ -766,7 +777,7 @@ const attachMake = (
766
777
  const entry = def.make
767
778
  if (!entry) return undefined
768
779
  const generated = makeGenerated(node, scene)
769
- if (isEditMode()) {
780
+ if (EDITOR && isEditMode()) {
770
781
  const props = { ...(entry.args ?? {}) } as Record<string, unknown>
771
782
  const inst = createMakeInst(path, entry, generated)
772
783
  Object.assign(inst, resolveRefs(props, nodes, path))
@@ -781,11 +792,12 @@ const attachMake = (
781
792
  })
782
793
  }
783
794
 
784
- const attachAspects = (
795
+ /** @internal */
796
+ export const attachAspects = (
785
797
  node: Node, path: string, def: SceneNodeDef,
786
798
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
787
799
  ): Promise<void> | void => {
788
- if (isEditMode()) {
800
+ if (EDITOR && isEditMode()) {
789
801
  const waits: Promise<void>[] = []
790
802
  // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
791
803
  // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
@@ -840,23 +852,26 @@ const prefabPartRows = (defs: Record<string, SceneNodeDef>, local: Record<string
840
852
  const node = local[path]
841
853
  if (!node) continue
842
854
  out.push({ path, name, depth, node })
843
- out.push(...prefabPartRows(nd.children ?? {}, local, path, depth + 1))
855
+ // no argument spreads here: a 78 000-node level (an imported Unity scene, every rock and grass tuft
856
+ // a placement) blew QuickJS's 65 534-argument cap on the top-level prefab's rows
857
+ for (const r of prefabPartRows(nd.children ?? {}, local, path, depth + 1)) out.push(r)
844
858
  const inner = node instanceof Model
845
859
  ? modelPartRows(node)
846
860
  : (node as { _prefabParts?: ModelPartRow[] })._prefabParts
847
- if (inner) out.push(...inner.map((r) => ({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })))
861
+ if (inner) for (const r of inner) out.push({ ...r, path: `${path}/${r.path}`, depth: depth + 1 + r.depth })
848
862
  }
849
863
  return out
850
864
  }
851
865
 
852
- const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
866
+ /** @internal */
867
+ export const attachPrefab = async (wrapper: Node, path: string, def: SceneNodeDef, scene: Scene, stack: Set<SceneDef>, lm: LightmapCtx | null): Promise<void> => {
853
868
  const pdef = (def.prefab as { def?: SceneDef } | undefined)?.def
854
869
  if (!pdef || typeof pdef !== "object") {
855
870
  console.error(`[scene] "${path}".prefab is not a scene handle (import the .scene file's default export)`)
856
871
  return
857
872
  }
858
- // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`_modelParts`)
859
- ;(wrapper as { _scenePrefab?: boolean })._scenePrefab = true
873
+ // editor marker: the harness enumerates prefab internals as parts, like a GLB's (`sceneEditor.modelParts`)
874
+ if (EDITOR) (wrapper as { _scenePrefab?: boolean })._scenePrefab = true
860
875
  if (stack.has(pdef)) {
861
876
  console.error(`[scene] prefab cycle detected at "${path}" — instance skipped`)
862
877
  return
@@ -903,7 +918,7 @@ const buildNodes = async (
903
918
  else if (lmStatic && nd.terrain !== undefined) Lightmap._registerGround(node, lm!.prefix + path)
904
919
  // the top of a moving subtree: everything under it reads the level's light grid (Lightmap.track walks it at load)
905
920
  if (lm !== null && !lmStatic && !parentMoves) moverRoots.push(node)
906
- if (isEditMode()) addEditorMarker(node, nd)
921
+ if (EDITOR && isEditMode()) addEditorMarker(node, nd)
907
922
  // a prefab under a static wrapper bakes; under a mover its internals stay movers (volume-lit)
908
923
  if (nd.prefab !== undefined) await attachPrefab(node, path, nd, scene, stack, lmStatic ? { prefix: `${lm!.prefix}${path}/` } : null)
909
924
  pending.push({ path, node, def: nd })
@@ -971,10 +986,10 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
971
986
  if (foliage) Foliage.configure(foliage)
972
987
  const nodes: Record<string, Node> = {}
973
988
  // baked lighting is a play-mode concern; the level owns the registry (a rebuild starts clean)
974
- const lm: LightmapCtx | null = lightmap && !isEditMode() ? { prefix: "" } : null
989
+ const lm: LightmapCtx | null = lightmap && !(EDITOR && isEditMode()) ? { prefix: "" } : null
975
990
  if (lm) Lightmap.clear()
976
991
  await buildNodes(def.nodes ?? {}, null, scene, nodes, editorRuns, new Set([ def ]), lm)
977
- if (navmesh && !isEditMode()) registerNavOverrides(def.nodes, nodes)
992
+ if (navmesh && !(EDITOR && isEditMode())) registerNavOverrides(def.nodes, nodes)
978
993
 
979
994
  // a camera NODE wins over the top-level `camera:` block; in play mode it keeps driving the view
980
995
  // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
@@ -990,13 +1005,13 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
990
1005
  scene.camera.position = placeNode.worldPosition
991
1006
  scene.camera.quaternion = placeNode.worldQuaternion
992
1007
  applyCameraProjection(scene, proj)
993
- if (!isEditMode()) scene.camera.follow(placeNode)
1008
+ if (!(EDITOR && isEditMode())) scene.camera.follow(placeNode)
994
1009
  } else if (cam && camNode) {
995
1010
  // legacy: the `camera: {}` node source block (still honoured — prefer a CameraPlace aspect)
996
1011
  scene.camera.position = camNode.worldPosition
997
1012
  scene.camera.quaternion = camNode.worldQuaternion
998
1013
  applyCameraProjection(scene, cam.def)
999
- if (!isEditMode()) scene.camera.follow(camNode)
1014
+ if (!(EDITOR && isEditMode())) scene.camera.follow(camNode)
1000
1015
  } else if (def.camera) {
1001
1016
  if (def.camera.position) scene.camera.position = def.camera.position
1002
1017
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
@@ -1010,7 +1025,7 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
1010
1025
  }
1011
1026
  // navigation: load the baked mesh — or, under `lecodes navmesh bake`, dump the level's collision
1012
1027
  // geometry for it (the static bodies exist by now; edit mode never loads it — v1)
1013
- if (navmesh && !isEditMode()) {
1028
+ if (navmesh && !(EDITOR && isEditMode())) {
1014
1029
  await NavMesh.load(scene, { data: navmesh.data, agent: navmesh.agent, areas: navmesh.areas })
1015
1030
  }
1016
1031
 
@@ -1020,14 +1035,13 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
1020
1035
  export class SceneHandle<D extends SceneDef = SceneDef> {
1021
1036
  readonly def: D
1022
1037
  private _loading?: Promise<LoadedScene<D>>
1023
- /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). */
1024
- private _editorRuns: EditorRun[] = []
1025
- /** @internal Custom-inspector cards (`static inspector`), per `<host>:<index>` — see _inspectorRender. */
1026
- private _inspectorCards = new Map<string, { ui: InspectorUI, inst: Record<string, unknown>, node: Node }>()
1027
- /** @internal The loaded scene + path-keyed node record, for the editor methods below (set once
1038
+ /** @internal Live editor-run aspect instances (edit mode only — see attachAspects). The editor
1039
+ * (./editor.ts) reads and replaces the list. */
1040
+ _editorRuns: EditorRun[] = []
1041
+ /** @internal The loaded scene + path-keyed node record, for the editor (./editor.ts; set once
1028
1042
  * load resolves). The record object is SHARED with the returned LoadedScene — patches and
1029
1043
  * rename/reparent re-keying are visible through both. */
1030
- private _live: { scene: Scene, nodes: Record<string, Node> } | null = null
1044
+ _live: { scene: Scene, nodes: Record<string, Node> } | null = null
1031
1045
 
1032
1046
  constructor(def: D) { this.def = def }
1033
1047
 
@@ -1070,10 +1084,10 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
1070
1084
  // their generators' gizmos still draw (foreignRuns — a nested rig's sight line)
1071
1085
  const runs: EditorRun[] = []
1072
1086
  await buildNodes(this.def.nodes ?? {}, root, scene, nodes, runs, new Set([ this.def ]))
1073
- if (isEditMode() && runs.length > 0) { foreignRuns.set(root, runs); gizmoVersion++ }
1087
+ if (EDITOR && isEditMode() && runs.length > 0) { foreignRuns.set(root, runs); gizmoVersion++ }
1074
1088
  const get = (path: string): Node | null => nodes[path] ?? null
1075
1089
  const dispose = (): void => {
1076
- foreignRuns.delete(root)
1090
+ if (EDITOR) foreignRuns.delete(root)
1077
1091
  root.visible = false // the visibility cascade retires the subtree's pick colliders first
1078
1092
  scene.remove(root)
1079
1093
  root.destroy()
@@ -1086,324 +1100,6 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
1086
1100
  }
1087
1101
  return { root, nodes, get, dispose, alignTo } as unknown as SceneInstance<D>
1088
1102
  }
1089
-
1090
- /**
1091
- * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
1092
- * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
1093
- * `path` in place (or adds it when the path is new — the path's parent must exist, root paths
1094
- * mount at the root), `def === null` removes it with its whole subtree. The def must be PLAIN
1095
- * data — the editor falls back to a full re-run for `$expr` and `$asset` values; aspect changes
1096
- * are inert in edit mode and stay out of defs.
1097
- *
1098
- * Named children survive a rebuild: they are re-parented onto the replacement node keeping their
1099
- * local transforms (exactly what the scene file describes). Returns the fresh node, null for a
1100
- * removal (or an add under an unknown parent). Old GPU resources (geometry/material instances)
1101
- * are not reclaimed until the next full re-run — acceptable churn for an edit session.
1102
- */
1103
- async _patchNode(path: string, def: SceneNodeDef | null): Promise<Node | null> {
1104
- const { scene, nodes } = (await this.load()) as unknown as { scene: Scene, nodes: Record<string, Node> }
1105
- const old: Node | undefined = nodes[path]
1106
-
1107
- const dispose = (root: Node): void => {
1108
- // Hide first: the visibility cascade deactivates the subtree's pick colliders (the ABI has
1109
- // no removeCollider — a destroyed entity's stale collider entry must never pick again).
1110
- root.visible = false
1111
- const doomed = new Set<Node>()
1112
- root.traverse((n) => doomed.add(n))
1113
- for (const [ key, n ] of Object.entries(nodes)) if (doomed.has(n)) delete nodes[key]
1114
- // editor-run aspect instances hosted in the doomed subtree go with it (generated children
1115
- // are subtree children, so the destroy below reclaims them too)
1116
- this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
1117
- gizmoVersion++
1118
- scene.remove(root)
1119
- root.destroy() // native destroyEntity recurses over remaining children
1120
- }
1121
-
1122
- if (def === null) {
1123
- if (old) {
1124
- dispose(old)
1125
- this._refreshRunDeps()
1126
- }
1127
- return null
1128
- }
1129
-
1130
- const build = async (p: string, d: SceneNodeDef, parent: Node | null): Promise<Node> => {
1131
- // child reuse is by FULL path — only the live subtree at this exact address is carried
1132
- // over (a bare-name lookup would adopt a like-named node from anywhere in the scene)
1133
- const existing = p === path ? undefined : nodes[p]
1134
- if (existing) { // an already-live child subtree — keep it, just re-parent (local transform stays)
1135
- if (parent) attachHost(parent, d, p).add(existing)
1136
- return existing
1137
- }
1138
- const node = await createSource(p, d, false)
1139
- if (parent) attachHost(parent, d, p).add(node)
1140
- scene.add(node)
1141
- applyNode(node, p.slice(p.lastIndexOf("/") + 1), d)
1142
- if (isEditMode()) addEditorMarker(node, d)
1143
- // a prefab wrapper instantiates its file's nodes (same path as buildNodes; no lightmap
1144
- // context — a patched node is an edit-mode rebuild, baked lighting is the level's business)
1145
- if (d.prefab !== undefined) await attachPrefab(node, p, d, scene, new Set([ this.def ]), null)
1146
- attachAspects(node, p, d, nodes, scene, this._editorRuns)
1147
- nodes[p] = node
1148
- await Promise.all(Object.entries(d.children ?? {}).map(([ cn, cd ]) => build(`${p}/${cn}`, cd, node)))
1149
- return node
1150
- }
1151
-
1152
- const cut = path.lastIndexOf("/")
1153
- const parentPath = cut < 0 ? null : path.slice(0, cut)
1154
- if (!old && parentPath !== null && !nodes[parentPath]) return null
1155
- const parent = old ? old.parent : (parentPath !== null ? nodes[parentPath] : null)
1156
- const fresh = await build(path, def, parent)
1157
- if (old) {
1158
- // named children not listed in the def still move over (the editor patches one node at a time)
1159
- const named = new Set(Object.values(nodes))
1160
- for (const child of old.children) if (named.has(child)) fresh.add(child)
1161
- dispose(old) // fresh already replaced nodes[path] in build(), so it survives
1162
- nodes[path] = fresh
1163
- }
1164
- // the projection lives on the scene camera, not on the node — re-apply it here so an inspector
1165
- // fov/near/far edit lands live (a rebuilt node alone would carry none of it)
1166
- if (def.camera !== undefined) applyCameraProjection(scene, def.camera, true)
1167
- this._refreshRunDeps()
1168
- return fresh
1169
- }
1170
-
1171
- /** @internal Editor: every run's gizmo lines (world-space LINES batches) + a version that
1172
- * changes whenever any rebuild ran — the host re-pushes to the engine only on a change. */
1173
- _editorGizmos(): { version: number, batches: GizmoBatch[] } {
1174
- const batches: GizmoBatch[] = []
1175
- for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
1176
- 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)
1177
- for (const node of Object.values(this._live?.nodes ?? {})) {
1178
- const marker = (node as { _editorMarker?: GizmoBuffer })._editorMarker
1179
- if (marker) for (const b of marker.batches) batches.push(b)
1180
- }
1181
- return { version: gizmoVersion, batches }
1182
- }
1183
-
1184
- /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
1185
- * absolute paths, so any structural change can invalidate them: an add can satisfy a
1186
- * previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */
1187
- private _refreshRunDeps(): void {
1188
- const nodes = this._live?.nodes
1189
- if (!nodes) return
1190
- for (const run of this._editorRuns) {
1191
- run.deps = collectRefDeps(run.props, nodes, run.hostPath)
1192
- // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
1193
- // wrapper's transform is editor-owned and moving it never re-calls the factory
1194
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
1195
- }
1196
- }
1197
-
1198
- /** @internal Re-key everything addressed under `oldPath` (the node itself, descendants, editor
1199
- * runs, inspector cards) to `newPath`, then re-derive deps. The record object is shared with
1200
- * the host — mutation, not replacement. */
1201
- private _rekey(oldPath: string, newPath: string): void {
1202
- const nodes = this._live!.nodes
1203
- const move = (key: string): string | null =>
1204
- key === oldPath ? newPath
1205
- : key.startsWith(oldPath + "/") ? newPath + key.slice(oldPath.length)
1206
- : null
1207
- for (const key of Object.keys(nodes)) {
1208
- const next = move(key)
1209
- if (next === null) continue
1210
- const n = nodes[key]
1211
- delete nodes[key]
1212
- nodes[next] = n
1213
- }
1214
- for (const run of this._editorRuns) {
1215
- const next = move(run.hostPath)
1216
- if (next !== null) run.hostPath = next
1217
- }
1218
- for (const [ key, card ] of [ ...this._inspectorCards ]) {
1219
- const i = key.lastIndexOf(":")
1220
- const next = move(key.slice(0, i))
1221
- if (next === null) continue
1222
- this._inspectorCards.delete(key)
1223
- this._inspectorCards.set(next + key.slice(i), card)
1224
- }
1225
- this._refreshRunDeps()
1226
- }
1227
-
1228
- /** @internal Editor: rename ONE node (bare sibling segment — the subtree's paths follow).
1229
- * Owns the shared record's re-keying (the host re-keys only its own part/selection state).
1230
- * Returns the new path; null on refusal (unknown node, invalid name, sibling collision). */
1231
- _renameNode(path: string, newName: string): string | null {
1232
- const nodes = this._live?.nodes
1233
- const node = nodes?.[path]
1234
- if (!nodes || !node || newName === "" || newName.includes("/") || newName.includes(":")) return null
1235
- const cut = path.lastIndexOf("/")
1236
- const newPath = cut < 0 ? newName : path.slice(0, cut + 1) + newName
1237
- if (newPath === path) return path
1238
- if (nodes[newPath]) return null
1239
- this._rekey(path, newPath)
1240
- node.name = newName // engine-side name stays the bare segment
1241
- return newPath
1242
- }
1243
-
1244
- /** @internal Editor: reparent keeping the LOCAL transform (null = scene root) — the node's and
1245
- * every descendant's paths follow. Returns the new path; null on refusal (unknown node/parent,
1246
- * cycle, name taken among the new siblings). */
1247
- _reparentNode(path: string, newParentPath: string | null): string | null {
1248
- const nodes = this._live?.nodes
1249
- const node = nodes?.[path]
1250
- if (!nodes || !node) return null
1251
- const parent = newParentPath === null ? null : nodes[newParentPath]
1252
- if (newParentPath !== null && !parent) return null
1253
- if (newParentPath !== null && (newParentPath === path || newParentPath.startsWith(path + "/"))) return null
1254
- const name = path.slice(path.lastIndexOf("/") + 1)
1255
- const newPath = newParentPath === null ? name : `${newParentPath}/${name}`
1256
- if (newPath === path) return path
1257
- if (nodes[newPath]) return null
1258
- this._rekey(path, newPath)
1259
- node.setParent(parent ?? null, false)
1260
- return newPath
1261
- }
1262
-
1263
- /** @internal Editor: a node changed (transform edit / gizmo drag / live patch) — re-resolve refs
1264
- * and re-run rebuild() on every editor-run aspect whose ref() props point at it. Deps are
1265
- * absolute paths, so "the change counts for its ancestors too" (generators read subtrees —
1266
- * FollowPath's waypoints are the children of its referenced path node) is a prefix test:
1267
- * a dep hits when the changed path IS the dep or lies inside the dep's subtree. */
1268
- _editorNodeChanged(path: string): void {
1269
- const nodes = this._live?.nodes
1270
- if (!nodes) return
1271
- for (const run of this._editorRuns) {
1272
- let hit = false
1273
- for (const d of run.deps) if (path === d || path.startsWith(`${d}/`)) { hit = true; break }
1274
- if (!hit) continue
1275
- assignRefProps(run, nodes) // a patch may have replaced the referenced node instance
1276
- safeRebuild(run)
1277
- }
1278
- }
1279
-
1280
- /** @internal Editor: live arg edit on a make() node — re-calls the factory through the tracked
1281
- * run (no compile; the factory is already in the bundle). False for non-make nodes. */
1282
- _editorSetMakeArg(hostPath: string, key: string, value: unknown): boolean {
1283
- return this._editorSetProp(hostPath, MAKE_INDEX, key, value)
1284
- }
1285
-
1286
- /** @internal Editor: live prop edit on ONE editor-run aspect (`index` = the doc's aspect index
1287
- * on the host node) — updates the instance (`{ $ref }` values resolve to live nodes), re-derives
1288
- * its deps, and rebuilds. False when that entry isn't editor-run (inert data — nothing to do). */
1289
- _editorSetProp(hostPath: string, index: number, key: string, value: unknown): boolean {
1290
- const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
1291
- const nodes = this._live?.nodes
1292
- if (!run || !nodes) return false
1293
- run.props[key] = value
1294
- run.deps = collectRefDeps(run.props, nodes, run.hostPath)
1295
- // aspect runs keep their host as a dep (see attachAspects); make() runs must NOT — the
1296
- // wrapper's transform is editor-owned and moving it never re-calls the factory
1297
- if (run.index !== MAKE_INDEX) run.deps.add(run.hostPath)
1298
- if (isNodeRef(value) || (Array.isArray(value) && value.some(isNodeRef))) assignRefProps(run, nodes)
1299
- else (run.inst as Record<string, unknown>)[key] = value
1300
- safeRebuild(run)
1301
- return true
1302
- }
1303
-
1304
- /**
1305
- * @internal Editor: run an aspect's custom `static inspector` card (immediate-mode — see
1306
- * core/InspectorUI.ts) and return its widget list. Null when the class has no inspector (the
1307
- * editor falls back to the inferred fields).
1308
- *
1309
- * `props` is the entry's CURRENT doc props, passed on EVERY call — the world syncs its side
1310
- * from it (a generator syncs through the `_editorSetProp` machinery, so an undo that changes a
1311
- * prop rebuilds for free; a plain aspect's preview instance is reassigned). `event` carries only
1312
- * buttons and editor-state field edits — doc-bound field edits arrive as changed `props`.
1313
- *
1314
- * Cards persist per `<host>:<index>` across calls (that's where `ui.state` lives); a card whose
1315
- * node instance was replaced by a live patch is rebuilt transparently.
1316
- */
1317
- _inspectorRender(
1318
- hostPath: string, index: number,
1319
- props: Record<string, unknown>, event?: InspectorEvent,
1320
- ): InspectorWidget[] | null {
1321
- const live = this._live
1322
- const node = live?.nodes[hostPath]
1323
- const entry = (node as unknown as { _sceneAspects?: readonly AspectEntry<any>[] } | undefined)
1324
- ?._sceneAspects?.[index]
1325
- if (!live || !node || !entry) return null
1326
- const ctor = entry.ctor as unknown as { inspector?: (ui: InspectorUI, aspect: unknown) => void }
1327
- if (typeof ctor.inspector !== "function") return null
1328
-
1329
- const run = this._editorRuns.find((r) => r.hostPath === hostPath && r.index === index)
1330
- const key = `${hostPath}:${index}`
1331
- let card = this._inspectorCards.get(key)
1332
- if (!card || card.node !== node) {
1333
- // (Re)create — generators reuse their tracked live instance; plain aspects get a persistent
1334
- // preview instance: node set, refs resolved, NEVER onAttach (edit mode stays side-effect
1335
- // free). Editor state (ui._state) survives a node patch by carrying the old ui over.
1336
- let inst: Record<string, unknown>
1337
- if (run) {
1338
- inst = run.inst as unknown as Record<string, unknown>
1339
- } else {
1340
- inst = new (entry.ctor as unknown as new () => Record<string, unknown>)()
1341
- inst.node = node
1342
- Object.assign(inst, resolveRefs({ ...props }, live.nodes, hostPath))
1343
- }
1344
- const ui = card?.ui ?? new InspectorUI()
1345
- ui._fields = describeFields(entry.ctor as unknown as abstract new () => unknown)
1346
- ui._docKeys = new Set(ui._fields.map((f) => f.key))
1347
- card = { ui, inst, node }
1348
- this._inspectorCards.set(key, card)
1349
- }
1350
-
1351
- // Sync the doc props into the world side. A key REMOVED from the doc (undo past its first
1352
- // edit) resets to the class-field default — otherwise the instance would keep the stale value.
1353
- const c = card
1354
- const fieldDefault = (k: string): unknown => c.ui._fields.find((f) => f.key === k)?.value
1355
- // `$expr` markers (values set in code) never sync into instances — the widget shows them
1356
- // read-only; the instance keeps the compile-time evaluation.
1357
- const isExpr = (v: unknown): boolean =>
1358
- typeof v === "object" && v !== null && typeof (v as { $expr?: unknown }).$expr === "string"
1359
- const changed = (a: unknown, b: unknown): boolean =>
1360
- a !== b && JSON.stringify(a) !== JSON.stringify(b)
1361
- if (run) {
1362
- for (const [ k, v ] of Object.entries(props)) {
1363
- if (!isExpr(v) && changed(run.props[k], v)) this._editorSetProp(hostPath, index, k, v)
1364
- }
1365
- for (const k of Object.keys(run.props)) {
1366
- if (k in props) continue
1367
- this._editorSetProp(hostPath, index, k, fieldDefault(k))
1368
- delete run.props[k] // keep run.props mirroring the doc, or this reset re-fires every call
1369
- }
1370
- c.ui._props = run.props
1371
- } else {
1372
- const snapshot = { ...props }
1373
- const resolved = (resolveRefs(snapshot, live.nodes, hostPath) ?? snapshot) as Record<string, unknown>
1374
- for (const f of c.ui._fields) {
1375
- if (isExpr(resolved[f.key])) continue
1376
- c.inst[f.key] = f.key in resolved ? resolved[f.key] : f.value
1377
- }
1378
- c.ui._props = snapshot
1379
- }
1380
-
1381
- return c.ui._run((u) => ctor.inspector!(u, c.inst), event)
1382
- }
1383
-
1384
- /** @internal Editor: the INTERNAL part rows of a loaded model node or prefab instance (by its
1385
- * absolute def path) — path/name/depth/live node, in the same asset-internal part-path grammar
1386
- * `overrides` keys use. Empty for plain nodes / unknown paths. */
1387
- async _modelParts(path: string): Promise<ModelPartRow[]> {
1388
- const { nodes } = (await this.load()) as unknown as { nodes: Record<string, Node> }
1389
- const node = nodes[path]
1390
- if (node instanceof Model) return modelPartRows(node)
1391
- // prefab instances precompute their rows in DEF order (engine child order isn't stable)
1392
- return (node as { _prefabParts?: ModelPartRow[] } | undefined)?._prefabParts ?? []
1393
- }
1394
-
1395
- /** @internal Editor: describe every aspect class this scene references (fields + defaults). */
1396
- _describeAspects(): AspectClassInfo[] {
1397
- const ctors = new Set<AspectCtor<any>>()
1398
- const walk = (defs?: Record<string, SceneNodeDef>): void => {
1399
- for (const nd of Object.values(defs ?? {})) {
1400
- for (const e of nd.aspects ?? []) ctors.add(e.ctor)
1401
- walk(nd.children)
1402
- }
1403
- }
1404
- walk(this.def.nodes)
1405
- return [ ...ctors ].map((c) => describeAspect(c))
1406
- }
1407
1103
  }
1408
1104
 
1409
1105
  /**
@@ -1417,6 +1113,6 @@ export const defineScene = <const D extends SceneDef>(def: D): SceneHandle<D> =>
1417
1113
  const g = globalThis as unknown as { [EDIT_FLAG]?: boolean, __lecodesScenes?: SceneHandle[] }
1418
1114
  // Editor hook: expose defined handles to the host (the scene editor runs the bundle, then picks
1419
1115
  // up the handle to load it in edit mode and drive the inspector).
1420
- if (g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
1116
+ if (EDITOR && g[EDIT_FLAG]) (g.__lecodesScenes ??= []).push(handle as SceneHandle)
1421
1117
  return handle
1422
1118
  }