lecodes-cli 0.17.2 → 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.
- package/dist/index.js +1340 -648
- package/package.json +3 -3
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/bundler.ts +1 -1
- package/runtime/sdk/compile/compileProject.ts +16 -1
- package/runtime/sdk/compile/header.ts +6 -1
- package/runtime/sdk/compile/index.ts +16 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/shaderTargets.ts +42 -0
- package/runtime/sdk/core/Aspect.ts +255 -244
- package/runtime/sdk/g2/CharacterController2D.ts +2 -2
- package/runtime/sdk/gl/Camera.ts +41 -0
- package/runtime/sdk/gl/CameraPlace.ts +51 -0
- package/runtime/sdk/gl/CharacterController.ts +3 -2
- package/runtime/sdk/gl/IK.ts +193 -174
- package/runtime/sdk/gl/Material.ts +11 -0
- package/runtime/sdk/gl/Model.ts +7 -6
- package/runtime/sdk/gl/Node.ts +270 -285
- package/runtime/sdk/gl/Physics.ts +171 -126
- package/runtime/sdk/gl/Scene.ts +27 -3
- package/runtime/sdk/gl/Shape.ts +214 -10
- package/runtime/sdk/gl/Vehicle.ts +519 -0
- package/runtime/sdk/gl/{AnimationClip.ts → animation/AnimationClip.ts} +37 -7
- package/runtime/sdk/gl/animation/Animator.ts +87 -0
- package/runtime/sdk/gl/animation/Layer.ts +29 -0
- package/runtime/sdk/gl/animation/Loop.ts +25 -0
- package/runtime/sdk/gl/animation/Playback.ts +43 -0
- package/runtime/sdk/gl/animation/core.ts +294 -0
- package/runtime/sdk/gl/scenarios.ts +26 -58
- package/runtime/sdk/inject.ts +18 -9
- package/runtime/sdk/runtime/app.ts +13 -0
- package/runtime/sdk/runtime/input.ts +169 -6
- package/runtime/sdk/scene/defineScene.ts +182 -26
- package/runtime/sdk/scene/gizmos.ts +128 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/sdk/gl/Animator.ts +0 -642
- package/runtime/sdk/gl/ModelAnimation.ts +0 -95
package/runtime/sdk/inject.ts
CHANGED
|
@@ -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 {
|
|
146
|
-
export {
|
|
147
|
-
export
|
|
148
|
-
export {
|
|
149
|
-
export
|
|
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/
|
|
2
|
-
//
|
|
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
|
|
7
|
-
* '
|
|
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
|
-
/**
|
|
357
|
-
*
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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 (
|
|
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). */
|