lecodes-cli 0.17.1 → 0.18.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 (39) hide show
  1. package/dist/index.js +1340 -648
  2. package/package.json +3 -3
  3. package/runtime/scene-harness.json +1 -1
  4. package/runtime/sdk/compile/bundler.ts +1 -1
  5. package/runtime/sdk/compile/compileProject.ts +16 -1
  6. package/runtime/sdk/compile/header.ts +6 -1
  7. package/runtime/sdk/compile/index.ts +16 -0
  8. package/runtime/sdk/compile/liteMaterial.ts +247 -0
  9. package/runtime/sdk/compile/shaderTargets.ts +42 -0
  10. package/runtime/sdk/core/Aspect.ts +255 -244
  11. package/runtime/sdk/g2/CharacterController2D.ts +2 -2
  12. package/runtime/sdk/gl/Camera.ts +41 -0
  13. package/runtime/sdk/gl/CameraPlace.ts +51 -0
  14. package/runtime/sdk/gl/CharacterController.ts +6 -2
  15. package/runtime/sdk/gl/IK.ts +193 -174
  16. package/runtime/sdk/gl/Material.ts +23 -0
  17. package/runtime/sdk/gl/Model.ts +7 -6
  18. package/runtime/sdk/gl/Node.ts +270 -285
  19. package/runtime/sdk/gl/Particles.ts +676 -625
  20. package/runtime/sdk/gl/Physics.ts +53 -1
  21. package/runtime/sdk/gl/Scene.ts +323 -291
  22. package/runtime/sdk/gl/Shape.ts +248 -8
  23. package/runtime/sdk/gl/Trigger.ts +50 -46
  24. package/runtime/sdk/gl/Vehicle.ts +519 -0
  25. package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
  26. package/runtime/sdk/gl/animation/Animator.ts +87 -0
  27. package/runtime/sdk/gl/animation/Layer.ts +29 -0
  28. package/runtime/sdk/gl/animation/Loop.ts +25 -0
  29. package/runtime/sdk/gl/animation/Playback.ts +43 -0
  30. package/runtime/sdk/gl/animation/core.ts +294 -0
  31. package/runtime/sdk/gl/scenarios.ts +26 -58
  32. package/runtime/sdk/inject.ts +18 -9
  33. package/runtime/sdk/runtime/app.ts +13 -0
  34. package/runtime/sdk/runtime/input.ts +169 -6
  35. package/runtime/sdk/scene/defineScene.ts +182 -26
  36. package/runtime/sdk/scene/gizmos.ts +128 -0
  37. package/runtime/sdk-types.json +1 -1
  38. package/runtime/sdk/gl/Animator.ts +0 -642
  39. package/runtime/sdk/gl/ModelAnimation.ts +0 -95
@@ -26,13 +26,13 @@ export { Canvas, Bitmap } from "./canvas/Canvas"
26
26
 
27
27
  // ---- platform runtime (injected globals; the host provides the lower-level _creator* bridges) ----
28
28
  export { toast, openURL, SvgSource } from "./runtime/misc"
29
- export { app, type AppState } from "./runtime/app"
29
+ export { app, type AppState, type OrientationLock } from "./runtime/app"
30
30
  export { clipboard } from "./runtime/clipboard"
31
31
  export { date, type DateValue, type DateInput, type Unit, type Locale } from "./runtime/datetime"
32
32
  export { fetch, fetchLocal, FormData, File, type FetchResponse } from "./runtime/fetch"
33
33
  export { localStorage } from "./runtime/storage"
34
34
  export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
35
- export { Input } from "./runtime/input"
35
+ export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
36
36
  export { WebSocket } from "./runtime/net"
37
37
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
38
38
  // only ever imports its server functions (docs/backend-plan.md §4).
@@ -122,9 +122,14 @@ export { OAuth, type OAuthCredential, type OAuthProviderName } from "./plugins/o
122
122
  export { Service } from "./plugins/service"
123
123
 
124
124
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
125
- export { defineScene, use, ref, make, SceneHandle } from "./scene/defineScene"
126
- export type { SceneDef, SceneNodeDef, MeshDef, LightDef, MaterialDef, CameraNodeDef, AspectEntry, MakeEntry, LoadedScene } from "./scene/defineScene"
125
+ export { defineScene, use, ref, make, SceneHandle, applyEditorPose as __applyEditorPose } from "./scene/defineScene"
126
+ export type { SceneDef, SceneNodeDef, MeshDef, LightDef, MaterialDef, CameraNodeDef, AspectEntry, MakeEntry, LoadedScene, SceneInstance } from "./scene/defineScene"
127
+ export { CameraPlace } from "./gl/CameraPlace"
127
128
  export type { FieldMeta } from "./core/fields"
129
+ // Editor-only line drawing from generator rebuild() / make() factories / tool hooks — drawn by the
130
+ // scene editor's viewport overlay, a no-op everywhere else (see src/scene/gizmos.ts).
131
+ export { Gizmos, GizmoBuffer as __GizmoBuffer, withGizmoScope as __withGizmoScope } from "./scene/gizmos"
132
+ export type { GizmoStyle } from "./scene/gizmos"
128
133
 
129
134
  // ---- no-code scenario aspects (attachable in the scene editor — see src/gl/scenarios.ts) ----
130
135
  export { MoveTo, FollowPath, Spin, LookAt, PlayAnimation } from "./gl/scenarios"
@@ -142,11 +147,15 @@ export { Shape } from "./gl/Shape"
142
147
  export { Physics } from "./gl/Physics"
143
148
  export { Trigger } from "./gl/Trigger"
144
149
  export { CharacterController } from "./gl/CharacterController"
145
- export { ModelAnimation } from "./gl/ModelAnimation"
146
- export { AnimationClip } from "./gl/AnimationClip"
147
- export type { ClipDef, ClipTrackDef, ClipKey } from "./gl/AnimationClip"
148
- export { Animator } from "./gl/Animator"
149
- export type { AnimatorPlayOptions } from "./gl/Animator"
150
+ export { Vehicle } from "./gl/Vehicle"
151
+ export type { WheelConfig, WheelState, DriveLayout, SuspensionConfig, EngineConfig } from "./gl/Vehicle"
152
+ export { AnimationClip } from "./gl/animation/AnimationClip"
153
+ export type { ClipDef, ClipTrackDef, ClipKey } from "./gl/animation/AnimationClip"
154
+ export { Animator } from "./gl/animation/Animator"
155
+ export { Playback } from "./gl/animation/Playback"
156
+ export { Loop } from "./gl/animation/Loop"
157
+ export { Layer } from "./gl/animation/Layer"
158
+ export type { PlayOptions, StopOptions, LayerOptions, LoopOptions, LoopDef, BlendPosition } from "./gl/animation/core"
150
159
  export { IK } from "./gl/IK"
151
160
  export type { IKTwoBone, IKLookAt } from "./gl/IK"
152
161
  export { Light } from "./gl/Light"
@@ -6,6 +6,10 @@ import { appEventsOn, appEventsOff, keyboardEventsOn, keyboardEventsOff } from "
6
6
 
7
7
  export type AppState = "active" | "background"
8
8
 
9
+ /** `"landscape"` — either landscape direction (sensor landscape), `"portrait"` — upright only,
10
+ * `"auto"` — released: the device's own rotation rules apply. */
11
+ export type OrientationLock = "auto" | "portrait" | "landscape"
12
+
9
13
  type AppEventMap = {
10
14
  /** The app left the foreground (home button, tab hidden, another app on top). Delivered before
11
15
  * the host halts the frame loop — the last chance to persist state / pause work. */
@@ -40,6 +44,15 @@ export const app = {
40
44
  get keyboardHeight(): number {
41
45
  return _creatorUtils.keyboardHeight?.() ?? 0
42
46
  },
47
+ /** Lock the screen orientation — `"landscape"` for a horizontal game (the device rotates to
48
+ * landscape right away and stays there whichever way it is held), `"portrait"` for upright
49
+ * only, `"auto"` to release. Call it once at startup; the lock is per app run (it resets
50
+ * when the app quits or restarts), so there is nothing to undo. The rotation arrives as an
51
+ * ordinary resize — layouts reflow, a scene's viewport follows. Silent no-op on hosts
52
+ * without a rotatable screen (desktop, headless, web). */
53
+ setOrientation(mode: OrientationLock): void {
54
+ _creatorUtils.setOrientation?.(mode)
55
+ },
43
56
  /** Re-run this app's bundle in a fresh world — the platform's `location.reload()`. Pass `url`
44
57
  * to reboot with different launch arguments (the new run reads them via `app.launchUrl`),
45
58
  * `null` to reboot with a cleared one, or omit it to replay the current one. No-op on hosts
@@ -1,12 +1,175 @@
1
- // Keyboard/pointer input. Replaces worker's bare `inputKey(code)` global with a small `Input`
2
- // namespace so the surface reads better and has room to grow (pointer, gamepad) without new globals.
1
+ // Keyboard / mouse / gamepad input behind one `Input` global (docs/input-plan.md).
2
+ //
3
+ // Two kinds of signal, two shapes of API:
4
+ // - BUTTONS — anything that goes down and up: keyboard keys, mouse buttons, gamepad buttons. One
5
+ // code vocabulary ('KeyW', 'MouseLeft', 'GamepadSouth'), one held-poll (`Input.key`) and one
6
+ // event pair (`Input.on('keydown' | 'keyup')`). A mouse button IS a key.
7
+ // - CONTINUOUS — mouse motion/wheel, sticks, triggers: polled every frame (`Input.mouse.delta`,
8
+ // `Input.gamepad(0).axis('leftX')`), never events.
9
+ // Bindings ("jump = Space | GamepadSouth") are app code: compare `e.code`, OR a few `Input.key`s.
10
+ // Hosts report physical state only (`_creatorUtils.inputKey/inputRead/registerInputEvent`).
11
+
12
+ /** Continuous channel ids — the `_creatorUtils.inputRead` contract (bridges.d.ts). */
13
+ export const InputChannel = {
14
+ MouseX: 0, MouseY: 1, MouseDX: 2, MouseDY: 3, WheelX: 4, WheelY: 5, PointerLocked: 6,
15
+ GamepadCount: 7,
16
+ GamepadBase: 16, GamepadStride: 16,
17
+ PadConnected: 0, PadLeftX: 1, PadLeftY: 2, PadRightX: 3, PadRightY: 4, PadLeftTrigger: 5, PadRightTrigger: 6,
18
+ } as const
19
+
20
+ export type InputEventName = "keydown" | "keyup" | "gamepadconnected" | "gamepaddisconnected"
21
+
22
+ /** `keydown` / `keyup` payload. `code` is the same string `Input.key()` takes. */
23
+ export interface InputKeyEvent {
24
+ readonly type: "keydown" | "keyup"
25
+ /** Button code: KeyboardEvent.code ('KeyW', 'Space'), 'MouseLeft|Right|Middle|Back|Forward', or
26
+ * 'GamepadSouth|East|West|North|L1|R1|L2|R2|Select|Start|L3|R3|Up|Down|Left|Right'. */
27
+ readonly code: string
28
+ /** Which device: 'keyboard' | 'mouse' | 'gamepad'. */
29
+ readonly source: "keyboard" | "mouse" | "gamepad"
30
+ /** Pad index (0..3) for gamepad buttons, undefined otherwise. */
31
+ readonly gamepad?: number
32
+ /** OS auto-repeat keydown (key still held) — ignore for one-shot actions. */
33
+ readonly repeat: boolean
34
+ }
35
+
36
+ export interface InputGamepadEvent {
37
+ readonly type: "gamepadconnected" | "gamepaddisconnected"
38
+ readonly gamepad: number
39
+ }
40
+
41
+ type Listener<E> = (e: E) => void
42
+ type InputEventMap = {
43
+ keydown: InputKeyEvent
44
+ keyup: InputKeyEvent
45
+ gamepadconnected: InputGamepadEvent
46
+ gamepaddisconnected: InputGamepadEvent
47
+ }
48
+
49
+ const listeners: { [K in InputEventName]?: Listener<InputEventMap[K]>[] } = {}
50
+ let hostListenerInstalled = false
51
+
52
+ const sourceOf = (code: string): InputKeyEvent["source"] =>
53
+ code.startsWith("Mouse") ? "mouse" : code.startsWith("Gamepad") ? "gamepad" : "keyboard"
54
+
55
+ const emit = <K extends InputEventName>(name: K, e: InputEventMap[K]) => {
56
+ const list = listeners[name]
57
+ if (!list || list.length === 0) return
58
+ for (const fn of [...list]) fn(e as any)
59
+ }
60
+
61
+ // One fan-out for the host channel and the test/host `_emit` seam.
62
+ const deliver = (kind: number, code: string, gamepad: number, repeat: number) => {
63
+ if (kind === 0 || kind === 1) {
64
+ const src = sourceOf(code)
65
+ emit(kind === 0 ? "keydown" : "keyup", {
66
+ type: kind === 0 ? "keydown" : "keyup", code, source: src,
67
+ gamepad: src === "gamepad" && gamepad >= 0 ? gamepad : undefined, repeat: repeat !== 0,
68
+ })
69
+ } else if (kind === 2 || kind === 3) {
70
+ emit(kind === 2 ? "gamepadconnected" : "gamepaddisconnected",
71
+ { type: kind === 2 ? "gamepadconnected" : "gamepaddisconnected", gamepad })
72
+ }
73
+ }
74
+
75
+ // Lazily claim the host's single listener slot (never at module scope — chisel drops top-level
76
+ // side effects, and a host without the channel simply never fires).
77
+ const ensureHostListener = () => {
78
+ if (hostListenerInstalled) return
79
+ hostListenerInstalled = true
80
+ _creatorUtils.registerInputEvent?.(deliver)
81
+ }
82
+
83
+ const read = (channel: number): number => _creatorUtils.inputRead?.(channel) ?? 0
84
+
85
+ /** Mouse state — polled. Position is the cursor inside the viewport in logical px; `delta` is the
86
+ * motion during the previous frame (raw where the OS offers it) and keeps counting while locked —
87
+ * that's FPS look. Hosts without a mouse report zeros. */
88
+ export const mouse = {
89
+ get position(): { x: number, y: number } { return { x: read(InputChannel.MouseX), y: read(InputChannel.MouseY) } },
90
+ get delta(): { x: number, y: number } { return { x: read(InputChannel.MouseDX), y: read(InputChannel.MouseDY) } },
91
+ /** Wheel notches during the previous frame (+y = wheel toward you / scroll down). */
92
+ get wheel(): { x: number, y: number } { return { x: read(InputChannel.WheelX), y: read(InputChannel.WheelY) } },
93
+ /** Pointer lock: cursor hidden + confined, `delta` keeps flowing. Web grants it only from a user
94
+ * gesture (call it in a 'keydown' handler for MouseLeft); hosts drop it on focus loss and
95
+ * re-acquire on focus. Escape does NOT unlock by itself on native hosts — call `unlock()`. */
96
+ get locked(): boolean { return read(InputChannel.PointerLocked) !== 0 },
97
+ lock(): boolean { return _creatorUtils.inputSetPointerLock?.(true) ?? false },
98
+ unlock(): void { _creatorUtils.inputSetPointerLock?.(false) },
99
+ }
100
+
101
+ export type GamepadAxisName = "leftX" | "leftY" | "rightX" | "rightY" | "leftTrigger" | "rightTrigger"
102
+ const PAD_AXIS: Record<GamepadAxisName, number> = {
103
+ leftX: InputChannel.PadLeftX, leftY: InputChannel.PadLeftY, rightX: InputChannel.PadRightX,
104
+ rightY: InputChannel.PadRightY, leftTrigger: InputChannel.PadLeftTrigger, rightTrigger: InputChannel.PadRightTrigger,
105
+ }
106
+
107
+ export interface GamepadState {
108
+ readonly index: number
109
+ readonly connected: boolean
110
+ /** Stick axes −1..1 (+Y = down, like the web Gamepad API), triggers 0..1. `deadzone` (default
111
+ * 0.15) zeroes small stick drift — pass 0 for the raw value. */
112
+ axis(name: GamepadAxisName, deadzone?: number): number
113
+ /** Held? Same as `Input.key(code, index)`. */
114
+ button(code: string): boolean
115
+ }
116
+
117
+ const padCache: GamepadState[] = []
118
+ const gamepadAt = (index: number): GamepadState => {
119
+ let g = padCache[index]
120
+ if (!g) {
121
+ const base = InputChannel.GamepadBase + InputChannel.GamepadStride * index
122
+ g = padCache[index] = {
123
+ index,
124
+ get connected() { return read(base + InputChannel.PadConnected) !== 0 },
125
+ axis(name, deadzone = 0.15) {
126
+ const v = read(base + PAD_AXIS[name])
127
+ return deadzone > 0 && Math.abs(v) < deadzone ? 0 : v
128
+ },
129
+ button(code) { return _creatorUtils.inputKey(code, index) },
130
+ }
131
+ }
132
+ return g
133
+ }
3
134
 
4
135
  export const Input = {
5
136
  /**
6
- * Is a key currently held? `code` is a KeyboardEvent.code-style string, e.g. 'ArrowRight',
7
- * 'KeyW', 'Space'. Poll this inside setLoop for frame-independent movement.
137
+ * Is a button held? `code` is the physical key (`KeyboardEvent.code`: 'ArrowRight', 'KeyW',
138
+ * 'Space'), a mouse button ('MouseLeft', 'MouseRight', 'MouseMiddle') or a gamepad button
139
+ * ('GamepadSouth' = A/Cross, 'GamepadR2' = right trigger as a button …). Poll inside setLoop for
140
+ * frame-independent movement. `gamepad` scopes a Gamepad* code to one pad; omitted = any pad.
8
141
  */
9
- key(code: string): boolean {
10
- return _creatorUtils.inputKey(code)
142
+ key(code: string, gamepad?: number): boolean {
143
+ return _creatorUtils.inputKey(code, gamepad)
144
+ },
145
+
146
+ /** Listen for a discrete input moment. `keydown`/`keyup` cover EVERY button — keyboard, mouse,
147
+ * gamepad — filter on `e.code`. Use these for one-shot actions (jump, shoot, charge-release);
148
+ * `Input.key` for continuous ones (walk). */
149
+ on<K extends InputEventName>(name: K, listener: Listener<InputEventMap[K]>): void {
150
+ ensureHostListener()
151
+ const list = (listeners[name] ??= []) as Listener<InputEventMap[K]>[]
152
+ if (!list.includes(listener)) list.push(listener)
153
+ },
154
+ off<K extends InputEventName>(name: K, listener: Listener<InputEventMap[K]>): void {
155
+ const list = listeners[name] as Listener<InputEventMap[K]>[] | undefined
156
+ if (!list) return
157
+ const i = list.indexOf(listener)
158
+ if (i >= 0) list.splice(i, 1)
11
159
  },
160
+
161
+ mouse,
162
+
163
+ /** Gamepad `index` (0..3). Always returns an object — check `.connected`. */
164
+ gamepad(index = 0): GamepadState { return gamepadAt(index) },
165
+ /** Indices of the currently connected gamepads. */
166
+ gamepads(): number[] {
167
+ const out: number[] = []
168
+ const n = read(InputChannel.GamepadCount)
169
+ for (let i = 0; i < 4 && out.length < n; i++) if (gamepadAt(i).connected) out.push(i)
170
+ return out
171
+ },
172
+
173
+ /** @internal — hosts/tests: deliver an event exactly as the host listener would. */
174
+ _emit(kind: number, code: string, gamepad: number, repeat: number): void { deliver(kind, code, gamepad, repeat) },
12
175
  }
@@ -43,6 +43,7 @@ import type { ColorInput } from "../core/color"
43
43
  import type { Vec3Like } from "../math/vec"
44
44
  import { Scene, type SceneOptions } from "../gl/Scene"
45
45
  import { CAMERA_DEFAULTS } from "../gl/Camera"
46
+ import { CameraPlace } from "../gl/CameraPlace"
46
47
  import { Node } from "../gl/Node"
47
48
  import { Mesh } from "../gl/Mesh"
48
49
  import { Model } from "../gl/Model"
@@ -50,6 +51,7 @@ import { Light, type SunOptions } from "../gl/Light"
50
51
  import { Material, type LitMaterialOptions, type MaterialColorOptions } from "../gl/Material"
51
52
  import type { CylinderOptions, PlaneOptions, SphereOptions } from "../gl/Geometry"
52
53
  import { edgesMesh } from "../gl/scenarios"
54
+ import { GizmoBuffer, withGizmoScope, type GizmoBatch } from "./gizmos"
53
55
 
54
56
  // ---- the literal grammar (what the visual editor reads and writes) -----------
55
57
 
@@ -134,6 +136,10 @@ export type SceneNodeDef = {
134
136
  visible?: boolean
135
137
  /** Editor-only: viewport manipulation won't target this node (fields still edit). No runtime effect. */
136
138
  locked?: boolean
139
+ /** Editor-only, `model` nodes: the POSE the scene editor shows — a clip looped while editing
140
+ * (`time` freezes it at that second instead), so attachments / sight lines / a first-person eye
141
+ * are placed against the animated pose, not the rest pose. Never applied when the scene runs. */
142
+ editor?: { clip?: string, time?: number }
137
143
  castShadows?: boolean
138
144
  receiveShadows?: boolean
139
145
  // -- capabilities / hierarchy --
@@ -204,6 +210,21 @@ export type LoadedScene<D extends SceneDef> = {
204
210
  // enumerates rows via `SceneHandle._modelParts` and writes the paths as `overrides` keys; the
205
211
  // loader resolves them back through the same enumeration, so writer and resolver can't drift.
206
212
 
213
+ /** One instance of a scene file built as a subtree (`handle.instantiate`). */
214
+ export type SceneInstance<D extends SceneDef> = {
215
+ /** The wrapper node the file's nodes build under — position it, parent it, hide it. */
216
+ root: Node
217
+ nodes: SceneNodes<D>
218
+ /** Anchor the instance on one of its own nodes: `root`'s local transform is set so that
219
+ * `inner` coincides with the frame `root` is parented to (its origin and axes). One-shot,
220
+ * from the CURRENT pose of `inner` — a rig's attachment frame (the eye place of a
221
+ * first-person arms scene, the grip of a held prop). */
222
+ alignTo(inner: Node): void
223
+ get: LoadedScene<D>["get"]
224
+ /** Remove the subtree from the scene and destroy it. */
225
+ dispose(): void
226
+ }
227
+
207
228
  /** One INTERNAL node of a loaded GLB (editor introspection). */
208
229
  export type ModelPartRow = { path: string, name: string, depth: number, node: Node }
209
230
 
@@ -298,6 +319,26 @@ const createSource = (path: string, def: SceneNodeDef): Node | Promise<Node> =>
298
319
  return new Node()
299
320
  }
300
321
 
322
+ /** Edit mode: play the preview pose on a model node (`editor: { clip, time }`) — idempotent, the
323
+ * harness re-applies it on inspector edits. No clip = back to the rest pose. */
324
+ export const applyEditorPose = (node: Node, pose: { clip?: string, time?: number } | undefined): void => {
325
+ const anim = (node as unknown as { anim?: Model["anim"] }).anim
326
+ if (!anim) return
327
+ const clip = pose?.clip
328
+ if (!clip || !anim.clip(clip)) {
329
+ anim.speed = 1
330
+ anim.stop()
331
+ return
332
+ }
333
+ anim.speed = 1
334
+ if (pose?.time !== undefined) {
335
+ anim.play(clip, { restart: true }).seek(pose.time)
336
+ anim.speed = 0
337
+ } else {
338
+ anim.playLoop(clip)
339
+ }
340
+ }
341
+
301
342
  const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
302
343
  node.name = name
303
344
  // def-built marker: part enumeration must skip this node (it is not asset-internal — it has
@@ -309,6 +350,7 @@ const applyNode = (node: Node, name: string, def: SceneNodeDef): void => {
309
350
  if (def.visible !== undefined) node.visible = def.visible
310
351
  // editor-only flag (the harness's manipulation layer consults it); inert at runtime
311
352
  if (def.locked !== undefined) (node as { _sceneLocked?: boolean })._sceneLocked = def.locked
353
+ if (def.editor !== undefined && isEditMode()) applyEditorPose(node, def.editor)
312
354
  if (def.camera !== undefined) (node as { _sceneCamera?: boolean })._sceneCamera = true
313
355
  // waypoint/def order for aspects that read children (FollowPath) — the engine's live child
314
356
  // order is insertion-based and may not match the file
@@ -353,17 +395,26 @@ const addEditorMarker = (node: Node, def: SceneNodeDef, scene: Scene): void => {
353
395
  scene.add(marker)
354
396
  }
355
397
 
356
- /** Play mode: `scene.camera` follows the camera node's world transform every frame (LATE phase,
357
- * after every other aspect has moved things — order 1000). Attached by `instantiate`, internal. */
358
- class CameraRig extends Aspect<"__cameraRig"> {
359
- static readonly aspect = "__cameraRig"
360
- /** @internal */ _scene!: Scene
361
- constructor() { super(); this.order = 1000 }
362
- update(): void {
363
- const cam = this._scene.camera
364
- cam.position = this.node.worldPosition
365
- cam.quaternion = this.node.worldQuaternion
398
+ /** The ACTIVE `CameraPlace` entry in THIS file's own defs (depth-first; prefab / instance internals
399
+ * are another file's business): its host path + props, or null. With no `active: true` anywhere,
400
+ * the first place in file order is it — a file with a single place needn't say so. */
401
+ const findCameraPlace = (defs?: Record<string, SceneNodeDef>): { path: string, props: Record<string, unknown> } | null => {
402
+ let first: { path: string, props: Record<string, unknown> } | null = null
403
+ const walk = (d: Record<string, SceneNodeDef> | undefined, prefix: string): { path: string, props: Record<string, unknown> } | null => {
404
+ for (const [ name, nd ] of Object.entries(d ?? {})) {
405
+ const path = prefix === "" ? name : `${prefix}/${name}`
406
+ for (const entry of nd.aspects ?? []) {
407
+ if (entry.ctor !== CameraPlace) continue
408
+ const props = (entry.props ?? {}) as Record<string, unknown>
409
+ if (props.active === true) return { path, props }
410
+ first ??= { path, props }
411
+ }
412
+ const inner = walk(nd.children, path)
413
+ if (inner) return inner
414
+ }
415
+ return null
366
416
  }
417
+ return walk(defs, "") ?? first
367
418
  }
368
419
 
369
420
  /** The first camera-source def in file order (depth-first) — its path and block — or null. */
@@ -402,6 +453,9 @@ const isEditorCtor = (ctor: unknown): boolean => !!(ctor as { editor?: unknown }
402
453
  * from parenting — `addEntityToScene` only recurses over children that exist at add time). */
403
454
  class GeneratedGroup extends Node {
404
455
  /** @internal */ _scene!: Scene
456
+ /** Bumped by every `clear()`. An ASYNC rebuild captures it before awaiting and drops its own
457
+ * result when the value moved on (a newer rebuild cleared the container in the meantime). */
458
+ version = 0
405
459
  add(...children: Node[]): this {
406
460
  super.add(...children)
407
461
  this._scene.add(...children)
@@ -409,7 +463,9 @@ class GeneratedGroup extends Node {
409
463
  }
410
464
  /** Destroy all generated children (rebuild() calls this first — idempotent regeneration). */
411
465
  clear(): this {
466
+ this.version++
412
467
  for (const c of [ ...this.children ]) {
468
+ dropForeignRuns(c)
413
469
  this._scene.remove(c)
414
470
  c.destroy()
415
471
  }
@@ -417,6 +473,19 @@ class GeneratedGroup extends Node {
417
473
  }
418
474
  }
419
475
 
476
+ // Edit mode: generator runs inside scene INSTANCES built by `instantiate()` (a weapon under the
477
+ // arms' gun socket) are not the edited handle's runs — not inspector-addressable, not
478
+ // dep-tracked — but their gizmos (the weapon's sight line) must still draw. They register here
479
+ // keyed by the instance root; `dispose()` / a hosting `generated.clear()` drops them.
480
+ const foreignRuns = new Map<Node, EditorRun[]>()
481
+ const dropForeignRuns = (root: Node): void => {
482
+ for (const key of [ ...foreignRuns.keys() ]) {
483
+ let n: Node | null = key
484
+ while (n && n !== root) n = n.parent
485
+ if (n === root) foreignRuns.delete(key)
486
+ }
487
+ }
488
+
420
489
  const makeGenerated = (node: Node, scene: Scene): GeneratedGroup => {
421
490
  const group = new GeneratedGroup()
422
491
  group.name = "__generated"
@@ -433,7 +502,9 @@ type EditorRun = {
433
502
  node: Node
434
503
  /** Index within the def's `aspects` array — the doc's aspect index addresses it. */
435
504
  index: number
436
- inst: { rebuild?(): void }
505
+ inst: { rebuild?(): void | Promise<void> }
506
+ /** Async rebuild() supersession counter (see safeRebuild). */
507
+ generation: number
437
508
  /** Mutable props snapshot — `_editorSetProp` updates it and re-derives `deps`. Holds the
438
509
  * DOC-LITERAL `$ref` strings (never resolved paths): the inspector's doc-sync compares these
439
510
  * against the file's props, so rewriting them would re-fire on every render. */
@@ -441,11 +512,32 @@ type EditorRun = {
441
512
  /** ABSOLUTE paths the ref() props resolved to — a change to any of them (or anything inside
442
513
  * their subtrees) re-runs rebuild(). Re-derived after every structural change. */
443
514
  deps: Set<string>
515
+ /** Editor lines drawn by the last rebuild() (`Gizmos.*` calls — see scene/gizmos.ts). */
516
+ gizmos: GizmoBuffer
444
517
  }
445
518
 
446
- const safeRebuild = (run: EditorRun): void => {
447
- // a throwing generator must not take the editor session down with it
448
- try { run.inst.rebuild?.() } catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e) }
519
+ /** Bumped on every editor-run rebuild — the host compares it to know when to re-push gizmos. */
520
+ let gizmoVersion = 0
521
+
522
+ const safeRebuild = (run: EditorRun): Promise<void> | void => {
523
+ // a throwing generator must not take the editor session down with it; the gizmo scope is the
524
+ // SYNCHRONOUS part of the call — whatever rebuild draws replaces the run's previous lines. An
525
+ // async rebuild() (one that instantiates a scene file) is awaited; lines it wants to draw after
526
+ // its awaits go in an optional `draw()`, run in a fresh scope once the promise settles.
527
+ gizmoVersion++
528
+ let result: unknown
529
+ try { result = withGizmoScope(run.gizmos, () => run.inst.rebuild?.()) }
530
+ catch (e) { console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e); return }
531
+ if (!(result instanceof Promise)) return
532
+ const gen = ++run.generation
533
+ return result.then(() => {
534
+ if (gen !== run.generation) return // superseded by a newer rebuild
535
+ gizmoVersion++ // generated content changed — the host re-pushes overlays
536
+ const draw = (run.inst as { draw?(): void }).draw
537
+ if (typeof draw !== "function") return
538
+ try { withGizmoScope(run.gizmos, () => draw.call(run.inst)) }
539
+ catch (e) { console.error(`[scene] editor aspect draw failed on "${run.hostPath}":`, e) }
540
+ }, (e) => console.error(`[scene] editor aspect rebuild failed on "${run.hostPath}":`, e))
449
541
  }
450
542
 
451
543
  /** Re-assign every ref-carrying prop from the CURRENT nodes map — a live patch replaces node
@@ -506,7 +598,7 @@ const attachMake = (
506
598
  const props = { ...(entry.args ?? {}) } as Record<string, unknown>
507
599
  const inst = createMakeInst(path, entry, generated)
508
600
  Object.assign(inst, resolveRefs(props, nodes, path))
509
- const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path) }
601
+ const run: EditorRun = { hostPath: path, node, index: MAKE_INDEX, inst, props, deps: collectRefDeps(props, nodes, path), gizmos: new GizmoBuffer(), generation: 0 }
510
602
  editorRuns.push(run)
511
603
  safeRebuild(run)
512
604
  return undefined
@@ -520,8 +612,9 @@ const attachMake = (
520
612
  const attachAspects = (
521
613
  node: Node, path: string, def: SceneNodeDef,
522
614
  nodes: Record<string, Node>, scene: Scene, editorRuns: EditorRun[],
523
- ): void => {
615
+ ): Promise<void> | void => {
524
616
  if (isEditMode()) {
617
+ const waits: Promise<void>[] = []
525
618
  // Data-only: the inspector reads [ctor, props] from here; nothing attaches, so no onAttach side
526
619
  // effects (physics bodies, loops) run while editing. `ref()` markers stay unresolved data too.
527
620
  // EXCEPT editor-run classes (generators) — they get a real, tracked instance (above).
@@ -529,21 +622,28 @@ const attachAspects = (
529
622
  ;(def.aspects ?? []).forEach((entry, index) => {
530
623
  if (!isEditorCtor(entry.ctor)) return
531
624
  const props = { ...(entry.props ?? {}) } as Record<string, unknown>
532
- const inst = new (entry.ctor as unknown as new () => { rebuild?(): void })()
625
+ const inst = new (entry.ctor as unknown as new () => { rebuild?(): void | Promise<void> })()
533
626
  ;(inst as { node: unknown }).node = node
534
627
  ;(inst as { generated: unknown }).generated = makeGenerated(node, scene)
628
+ ;(inst as { scene: unknown }).scene = scene
535
629
  Object.assign(inst, resolveRefs(props, nodes, path))
630
+ // reachable through `node.get(Ctor)` like an attached aspect (a nested rig's contract is read
631
+ // by the generator that instantiated it) — registered, never attached: no onAttach/update
632
+ ;(node as unknown as { _aspects: Map<Function, unknown> })._aspects.set(entry.ctor as Function, inst)
633
+ const accessor = (entry.ctor as { aspect?: string }).aspect
634
+ if (accessor) (node as unknown as Record<string, unknown>)[accessor] = inst
536
635
  // the HOST node is a dep too: a generator that draws relative to its node (path lines)
537
636
  // must re-run when the node itself is dragged, not only when its ref() targets move
538
- const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path) }
637
+ const run: EditorRun = { hostPath: path, node, index, inst, props, deps: collectRefDeps(props, nodes, path).add(path), gizmos: new GizmoBuffer(), generation: 0 }
539
638
  editorRuns.push(run)
540
- safeRebuild(run)
639
+ const wait = safeRebuild(run)
640
+ if (wait) waits.push(wait)
541
641
  })
542
- return
642
+ return waits.length > 0 ? Promise.all(waits).then(() => undefined) : undefined
543
643
  }
544
644
  for (const entry of def.aspects ?? []) {
545
645
  const props = resolveRefs(entry.props as Record<string, unknown> | undefined, nodes, path)
546
- const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene) } : props
646
+ const withGenerated = isEditorCtor(entry.ctor) ? { ...props, generated: makeGenerated(node, scene), scene } : props
547
647
  ;(node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(entry.ctor, withGenerated)
548
648
  }
549
649
  }
@@ -631,7 +731,8 @@ const buildNodes = async (
631
731
  await Promise.all(Object.entries(defs).map(([ name, nd ]) => build(name, nd, parent, "")))
632
732
  const makeWaits: Promise<void>[] = []
633
733
  for (const p of pending) {
634
- attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
734
+ const aspectWait = attachAspects(p.node, p.path, p.def, nodes, scene, editorRuns)
735
+ if (aspectWait) makeWaits.push(aspectWait)
635
736
  const wait = attachMake(p.node, p.path, p.def, nodes, scene, editorRuns)
636
737
  if (wait) makeWaits.push(wait)
637
738
  }
@@ -647,15 +748,23 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
647
748
  // (CameraRig), in edit mode it only seeds the editor's starting viewpoint. The PROJECTION applies
648
749
  // in both modes — it is a property of the scene, not of the viewpoint, so the editor shows the
649
750
  // lens the running app will use.
751
+ const place = findCameraPlace(def.nodes)
752
+ const placeNode = place ? nodes[place.path] : undefined
650
753
  const cam = findCamera(def.nodes)
651
754
  const camNode = cam ? nodes[cam.path] : undefined
652
- if (cam && camNode) {
755
+ if (place && placeNode) {
756
+ // the active CameraPlace (an ASPECT on any node — the preferred form; one per file)
757
+ const proj = { fov: place.props.fov as number | undefined, near: place.props.near as number | undefined, far: place.props.far as number | undefined }
758
+ scene.camera.position = placeNode.worldPosition
759
+ scene.camera.quaternion = placeNode.worldQuaternion
760
+ applyCameraProjection(scene, proj)
761
+ if (!isEditMode()) scene.camera.follow(placeNode)
762
+ } else if (cam && camNode) {
763
+ // legacy: the `camera: {}` node source block (still honoured — prefer a CameraPlace aspect)
653
764
  scene.camera.position = camNode.worldPosition
654
765
  scene.camera.quaternion = camNode.worldQuaternion
655
766
  applyCameraProjection(scene, cam.def)
656
- if (!isEditMode()) {
657
- ;(camNode as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(CameraRig, { _scene: scene })
658
- }
767
+ if (!isEditMode()) scene.camera.follow(camNode)
659
768
  } else if (def.camera) {
660
769
  if (def.camera.position) scene.camera.position = def.camera.position
661
770
  if (def.camera.target) scene.camera.lookAt(def.camera.target)
@@ -698,6 +807,43 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
698
807
  return loaded
699
808
  }
700
809
 
810
+ /**
811
+ * Build this scene file as a reusable SUBTREE inside an existing scene — a weapon under a hand
812
+ * bone, a streetlamp per street corner — as many times as you like (unlike `load()`, which is
813
+ * the one-instance "scene as a level" path). The nodes build under a fresh wrapper node (`root`)
814
+ * parented to `parent` (or left unparented); `env` / `camera` are ignored like a prefab's. The
815
+ * file's transforms are local to the wrapper, so author it with the wrapper as the attachment
816
+ * point. `ref()`s resolve per instance; aspects attach per instance. `dispose()` removes the
817
+ * subtree from the scene and destroys it.
818
+ */
819
+ async instantiate(opts: { scene: Scene, parent?: Node | null, name?: string }): Promise<SceneInstance<D>> {
820
+ const { scene } = opts
821
+ const root = new Node()
822
+ root.name = opts.name ?? "__instance"
823
+ if (opts.parent) opts.parent.add(root)
824
+ scene.add(root)
825
+ const nodes: Record<string, Node> = {}
826
+ // instance internals are not inspector-addressable (like prefab internals), but in edit mode
827
+ // their generators' gizmos still draw (foreignRuns — a nested rig's sight line)
828
+ const runs: EditorRun[] = []
829
+ await buildNodes(this.def.nodes ?? {}, root, scene, nodes, runs, new Set([ this.def ]))
830
+ if (isEditMode() && runs.length > 0) { foreignRuns.set(root, runs); gizmoVersion++ }
831
+ const get = (path: string): Node | null => nodes[path] ?? null
832
+ const dispose = (): void => {
833
+ foreignRuns.delete(root)
834
+ root.visible = false // the visibility cascade retires the subtree's pick colliders first
835
+ scene.remove(root)
836
+ root.destroy()
837
+ for (const key of Object.keys(nodes)) delete nodes[key]
838
+ }
839
+ const alignTo = (inner: Node): void => {
840
+ // inner's pose in root's frame is independent of root's own local transform:
841
+ // rel = root.world⁻¹ · inner.world; root.local = rel⁻¹ puts inner on root's parent frame
842
+ root.matrix = root.worldMatrix.invert().mul(inner.worldMatrix).invert()
843
+ }
844
+ return { root, nodes, get, dispose, alignTo } as unknown as SceneInstance<D>
845
+ }
846
+
701
847
  /**
702
848
  * @internal Editor (edit mode): apply ONE node's change to the already-loaded scene without
703
849
  * recompiling — the same "scenes as data" grammar, but as a patch: `def` rebuilds the node at
@@ -725,6 +871,7 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
725
871
  // editor-run aspect instances hosted in the doomed subtree go with it (generated children
726
872
  // are subtree children, so the destroy below reclaims them too)
727
873
  this._editorRuns = this._editorRuns.filter((r) => !doomed.has(r.node))
874
+ gizmoVersion++
728
875
  scene.remove(root)
729
876
  root.destroy() // native destroyEntity recurses over remaining children
730
877
  }
@@ -775,6 +922,15 @@ export class SceneHandle<D extends SceneDef = SceneDef> {
775
922
  return fresh
776
923
  }
777
924
 
925
+ /** @internal Editor: every run's gizmo lines (world-space LINES batches) + a version that
926
+ * changes whenever any rebuild ran — the host re-pushes to the engine only on a change. */
927
+ _editorGizmos(): { version: number, batches: GizmoBatch[] } {
928
+ const batches: GizmoBatch[] = []
929
+ for (const run of this._editorRuns) for (const b of run.gizmos.batches) if (b.segments.length > 0) batches.push(b)
930
+ 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)
931
+ return { version: gizmoVersion, batches }
932
+ }
933
+
778
934
  /** @internal Re-derive every editor run's deps from the CURRENT record. Deps are RESOLVED
779
935
  * absolute paths, so any structural change can invalidate them: an add can satisfy a
780
936
  * previously-null ref, a remove/rename can re-bind one to a different scope (shadowing). */