lecodes-cli 0.16.1 → 0.17.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 (40) hide show
  1. package/README.md +27 -4
  2. package/dist/index.js +171621 -382
  3. package/package.json +13 -7
  4. package/runtime/sdk/compile/bundler.ts +10 -2
  5. package/runtime/sdk/compile/compileProject.ts +10 -2
  6. package/runtime/sdk/compile/detectEntry.ts +5 -2
  7. package/runtime/sdk/compile/index.ts +17 -1
  8. package/runtime/sdk/compile/serverSplit.ts +233 -0
  9. package/runtime/sdk/compile/serverTypes.ts +227 -0
  10. package/runtime/sdk/gl/AnimationClip.ts +174 -0
  11. package/runtime/sdk/gl/Animator.ts +642 -0
  12. package/runtime/sdk/gl/CharacterController.ts +6 -1
  13. package/runtime/sdk/gl/IK.ts +170 -0
  14. package/runtime/sdk/gl/Material.ts +1 -1
  15. package/runtime/sdk/gl/ModelAnimation.ts +34 -3
  16. package/runtime/sdk/gl/Node.ts +27 -1
  17. package/runtime/sdk/gl/Scene.ts +291 -289
  18. package/runtime/sdk/gl/state.ts +6 -6
  19. package/runtime/sdk/inject.ts +10 -0
  20. package/runtime/sdk/plugins/oauth.ts +61 -0
  21. package/runtime/sdk/runtime/rpc.ts +218 -0
  22. package/runtime/sdk/server/auth/appConfig.ts +12 -0
  23. package/runtime/sdk/server/auth/global.ts +80 -0
  24. package/runtime/sdk/server/auth/host.ts +318 -0
  25. package/runtime/sdk/server/auth/models.ts +83 -0
  26. package/runtime/sdk/server/auth/types.ts +50 -0
  27. package/runtime/sdk/server/channel.ts +56 -0
  28. package/runtime/sdk/server/context.ts +36 -0
  29. package/runtime/sdk/server/db/defineDb.ts +237 -0
  30. package/runtime/sdk/server/db/fields.ts +132 -0
  31. package/runtime/sdk/server/db/httpTransport.ts +93 -0
  32. package/runtime/sdk/server/db/index.ts +7 -0
  33. package/runtime/sdk/server/db/marci/query.ts +412 -0
  34. package/runtime/sdk/server/db/types.ts +202 -0
  35. package/runtime/sdk/server/errors.ts +12 -0
  36. package/runtime/sdk/server/host.ts +74 -0
  37. package/runtime/sdk/server/inject.ts +13 -0
  38. package/runtime/sdk/server/runtime.ts +133 -0
  39. package/runtime/sdk/server/validate.ts +87 -0
  40. package/runtime/sdk-types.json +1 -1
@@ -1,289 +1,291 @@
1
- // A 3D scene: a draw set, a Camera, lights and environment. Opening a scene asks the host to bring up
2
- // the 3D engine; only the active scene renders. Grown from the Phase 0 seed. Ported from
3
- // worker/src/Scene.ts (Scene + ARScene), with the camera exposed as scene.camera.
4
- //
5
- // Lights are explicit: a scene gets image-based ambient (IBL) by default; add a Light.sun() for
6
- // direct light + shadows.
7
-
8
- import { Color, type ColorInput } from "../core/color"
9
- import { _installAspectFrames } from "../core/Aspect"
10
- import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
11
- import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
12
- import type { FetchResponse } from "../runtime/fetch"
13
- import { _requestCameraPermission } from "../plugins/permission"
14
- import { Camera } from "./Camera"
15
- import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
16
- import { Material } from "./Material"
17
- import { Node, nodeRegistry } from "./Node"
18
- import { glState } from "./state"
19
- import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
20
-
21
- export type SceneOptions = {
22
- /** Image-based ambient lighting (default on). */
23
- ibl?: boolean
24
- /** IBL intensity (default 20000). */
25
- environmentIntensity?: number
26
- /** Bloom post-processing. */
27
- bloom?: boolean
28
- bloomIntensity?: number
29
- /** Skybox / clear color. */
30
- skybox?: ColorInput
31
- /** Multisample anti-aliasing. */
32
- antialias?: boolean
33
- }
34
-
35
- // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
36
- // late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
37
- // the 3D layer (not core) so a 2D-only bundle never pulls in the 3D bridge. Installed once, lazily on
38
- // the first Scene. On an older wasm without the hooks the installer returns false → setLoop fallback.
39
- let framesInstalled = false
40
- const installRenderSyncedFrames = (): void => {
41
- if (framesInstalled) return
42
- framesInstalled = true
43
- _installAspectFrames((early, late) => {
44
- if (typeof _creator.setEarlyUpdate !== "function") return false
45
- _creator.setEarlyUpdate(early)
46
- _creator.setLateUpdate(late)
47
- return true
48
- })
49
- }
50
-
51
- export class Scene implements Presentable {
52
- /** @internal native scene handle. */
53
- readonly _id: number
54
- readonly camera: Camera
55
-
56
- /** @internal touch listeners, read by the dispatch system. */
57
- readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
58
- readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node | null>) => void> = []
59
-
60
- private _material?: Material
61
-
62
- private static _active: Scene | null = null
63
- static get active(): Scene | null { return Scene._active }
64
-
65
- constructor(options: SceneOptions = {}) {
66
- installRenderSyncedFrames()
67
- const parent = (options as any)._parent as Scene | undefined
68
- if (parent) {
69
- this.camera = parent.camera
70
- this._id = _creator.createOverlayScene(parent._id)
71
- // No camera re-attach: the native createOverlayScene wires the parent's camera into the
72
- // overlay view. Re-attaching would (a) point camera._sceneId — the source for getRay /
73
- // getViewDirection / fov — at the overlay's view, whose viewport/projection aren't
74
- // maintained like the render view's, and (b) create a second native camera component on
75
- // the shared entity. (Found via the scene editor: gizmo pick rays came out distorted.)
76
- } else {
77
- this.camera = new Camera()
78
- this._id = _creator.createScene()
79
- this.camera._attach(this._id)
80
- }
81
-
82
- if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
83
- if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
84
- if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
85
- if (options.antialias) _creator.setSceneMultiSampleAntiAliasing(this._id, true, 4)
86
- }
87
-
88
- /** Scene occlusion material. */
89
- get occlusionMaterial(): Material {
90
- if (!this._material) this._material = new Material(_creator.getSceneMaterial(this._id))
91
- return this._material
92
- }
93
-
94
- set skybox(color: ColorInput) { _creator.setSkybox(this._id, Color.toPackedRgb(color)) }
95
-
96
- setMaterialGlobalParameter(i: number, x: number, y: number, z: number, w: number): void {
97
- _creator.setMaterialGlobalParameter(this._id, i, x, y, z, w)
98
- }
99
- setAntialias(enabled: boolean, scale = 4): void {
100
- _creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
101
- }
102
-
103
- add(...nodes: Node[]): this {
104
- for (const n of nodes) _creator.addEntityToScene(this._id, n.id)
105
- return this
106
- }
107
- remove(...nodes: Node[]): this {
108
- for (const n of nodes) _creator.removeEntityFromScene(this._id, n.id)
109
- return this
110
- }
111
-
112
- createOverlay(options: SceneOptions = {}): Scene {
113
- return new Scene({ ...options, _parent: this } as any)
114
- }
115
-
116
- warmRender(): Promise<void> {
117
- return new Promise<void>((res) => _creator.warmRender(this._id, res))
118
- }
119
-
120
- // ---- Presentable (docs/navigation-presentable-plan.md) ----
121
- // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
122
- // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
123
- // scene graph survives while covered — only the view detaches and rendering pauses.
124
-
125
- /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
126
- * or synchronously by open()/close() on legacy hosts. */
127
- readonly ol: (() => void)[] = []
128
- readonly cl: (() => void)[] = []
129
- _backButtonCallback?: () => void
130
- private _vd?: object
131
-
132
- /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
133
- onOpen(callback: () => void): this {
134
- this.ol.push(callback)
135
- return this
136
- }
137
- /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
138
- onClose(callback: () => void): this {
139
- this.cl.push(callback)
140
- return this
141
- }
142
- onBackPressed(callback: () => void): this {
143
- this._backButtonCallback = callback
144
- return this
145
- }
146
-
147
- /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
148
- _viewDesc(): object {
149
- return this._vd ??= { type: "scene3d", sceneId: this._id, _p: this }
150
- }
151
-
152
- /** Make this the active scene — shows it as the current destination (replaces a screen /
153
- * another scene; only the active scene renders). */
154
- open(options?: PresentOptions): void | Promise<void> {
155
- _bumpNavEpoch()
156
- if (_navSupported()) {
157
- // The host activates the engine (openScene) as part of presenting the destination.
158
- _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
159
- } else {
160
- _creator.openScene(this._id)
161
- for (const cb of this.ol) cb()
162
- }
163
- Scene._active = this
164
- glState.activeScene = this
165
- _setCurrent(this)
166
- }
167
- close(): void {
168
- _bumpNavEpoch()
169
- if (_navSupported()) {
170
- _creatorUI.closeView!()
171
- } else {
172
- _creator.closeScene()
173
- for (const cb of this.cl) cb()
174
- }
175
- if (Scene._active === this) Scene._active = null
176
- if (glState.activeScene === this) glState.activeScene = null
177
- if (Presentable.current === this) _setCurrent(null)
178
- }
179
-
180
- addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
181
- addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node | null>) => void): void
182
- addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
183
- if (channel === "click") {
184
- if (!this._clickListeners.includes(callback)) { this._clickListeners.push(callback); registerTouchEndEvent() }
185
- } else if (channel === "touchstart") {
186
- if (!this._touchStartListeners.includes(callback)) { this._touchStartListeners.push(callback); registerTouchStartEvent() }
187
- }
188
- }
189
- removeEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
190
- const list = channel === "click" ? this._clickListeners : this._touchStartListeners
191
- const i = list.indexOf(callback)
192
- if (i >= 0) list.splice(i, 1)
193
- }
194
- }
195
-
196
- // --- AR ---
197
-
198
- export type ARMode = "default" | "markers" | "arcore" | "detached"
199
-
200
- const onTrackStateChange = (entityId: number, track: boolean): void => {
201
- const node = nodeRegistry.get(entityId)
202
- if (!node) return
203
- node.isTracked = track
204
- if (track) node._dispatchEvent("track")
205
- else node._dispatchEvent("untrack")
206
- }
207
-
208
- export class ARScene<T extends ARMode = "default"> extends Scene {
209
- readonly mode: T
210
- useWarmRender = true
211
- private _root?: Node
212
-
213
- constructor(options: SceneOptions & { mode?: T } = {}) {
214
- super(options)
215
- this.mode = options.mode ?? ("default" as T)
216
- _creator.createARController(this._id, this.camera.id, this.mode, onTrackStateChange)
217
- }
218
-
219
- /** Root anchor node (not available in marker mode). */
220
- get root(): Node {
221
- if (this._root) return this._root
222
- this._root = new Node(_creator.createRootAnchor(this._id))
223
- return this._root
224
- }
225
-
226
- /** Create an anchor from a tracking source (e.g. an image target). */
227
- createAnchor(source: FetchResponse, physicalWidth = 0.2): Node {
228
- return new Node(_creator.createAnchor(this._id, physicalWidth, (source as any)._id))
229
- }
230
-
231
- /**
232
- * Let the user place & manipulate a node with touch gestures — the staple AR object-placement
233
- * interaction: ONE finger drags it across the floor (relative to where the camera looks), TWO fingers
234
- * pinch to scale and twist to rotate. Returns a handle; call `.remove()` to detach. Tunable via
235
- * options (min/max scale, and toggles for `pan` / `pinch` / `twist`).
236
- */
237
- addControls(target: Node, options: ControlsOptions = {}): ControlsHandle {
238
- return attachControls(this, target, options)
239
- }
240
-
241
- override async open(options?: PresentOptions): Promise<void> {
242
- await this._prepare()
243
- super.open(options)
244
- }
245
-
246
- /** @internal Presentable prepare hook: camera permission + warm render + AR session, all while
247
- * the previous destination (typically a loading screen) stays visible — the swap to this scene
248
- * happens only after it resolves. Warm render is headless on every platform (offscreen
249
- * swapchain), so no GL view is created here — the host mounts the scene view at present time.
250
- * Rejects on camera denial / AR launch failure, or if another navigation happened while
251
- * preparing (superseded) — in every rejection case the current destination stays visible. */
252
- async _prepare(): Promise<void> {
253
- const epoch = _navEpoch()
254
- await _requestCameraPermission()
255
- if (this.useWarmRender) await this.warmRender()
256
- await new Promise<void>((res, rej) => _creator.launchAR(this._id, res, rej))
257
- if (_navEpoch() !== epoch) {
258
- _creator.stopAR()
259
- throw new Error("ARScene.open() superseded: another destination was opened while preparing")
260
- }
261
- }
262
-
263
- override close(): void {
264
- _creator.stopAR()
265
- super.close()
266
- }
267
- }
268
-
269
- // --- VR ---
270
-
271
- /**
272
- * A 3D scene rendered in a VR headset (OpenXR — e.g. Meta Quest). The headset drives the camera:
273
- * every frame both eyes are rendered from the tracked head pose, so `scene.camera` position/lookAt
274
- * have no effect while the scene is open. The world origin is at floor level (stage space) —
275
- * author content with the ground at y = 0 and the user standing at the origin.
276
- *
277
- * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
278
- */
279
- export class VRScene extends Scene {
280
- override async open(options?: PresentOptions): Promise<void> {
281
- await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
282
- super.open(options)
283
- }
284
-
285
- override close(): void {
286
- _creator.stopVR()
287
- super.close()
288
- }
289
- }
1
+ // A 3D scene: a draw set, a Camera, lights and environment. Opening a scene asks the host to bring up
2
+ // the 3D engine; only the active scene renders. Grown from the Phase 0 seed. Ported from
3
+ // worker/src/Scene.ts (Scene + ARScene), with the camera exposed as scene.camera.
4
+ //
5
+ // Lights are explicit: a scene gets image-based ambient (IBL) by default; add a Light.sun() for
6
+ // direct light + shadows.
7
+
8
+ import { Color, type ColorInput } from "../core/color"
9
+ import { _installAspectFrames } from "../core/Aspect"
10
+ import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
11
+ import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
12
+ import type { FetchResponse } from "../runtime/fetch"
13
+ import { _requestCameraPermission } from "../plugins/permission"
14
+ import { Camera } from "./Camera"
15
+ import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
16
+ import { Material } from "./Material"
17
+ import { Node, nodeRegistry } from "./Node"
18
+ import { glState } from "./state"
19
+ import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
20
+
21
+ export type SceneOptions = {
22
+ /** Image-based ambient lighting (default on). */
23
+ ibl?: boolean
24
+ /** IBL intensity (default 20000). */
25
+ environmentIntensity?: number
26
+ /** Bloom post-processing. */
27
+ bloom?: boolean
28
+ bloomIntensity?: number
29
+ /** Skybox / clear color. */
30
+ skybox?: ColorInput
31
+ /** Multisample anti-aliasing. */
32
+ antialias?: boolean
33
+ }
34
+
35
+ // Route aspect update(dt) through the render-synced native phases (render() early = before physics,
36
+ // late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
37
+ // the 3D layer (not core) so a 2D-only bundle never pulls in the 3D bridge. Installed once, lazily on
38
+ // the first Scene. On an older wasm without the hooks the installer returns false → setLoop fallback.
39
+ let framesInstalled = false
40
+ const installRenderSyncedFrames = (): void => {
41
+ if (framesInstalled) return
42
+ framesInstalled = true
43
+ _installAspectFrames((early, late) => {
44
+ if (typeof _creator.setEarlyUpdate !== "function") return false
45
+ // the frame stamp invalidates every Node's local-matrix cache once per frame (physics / character
46
+ // controllers / animation rewrite transforms natively between frames)
47
+ _creator.setEarlyUpdate((dt: number) => { glState.frame++; early(dt) })
48
+ _creator.setLateUpdate(late)
49
+ return true
50
+ })
51
+ }
52
+
53
+ export class Scene implements Presentable {
54
+ /** @internal native scene handle. */
55
+ readonly _id: number
56
+ readonly camera: Camera
57
+
58
+ /** @internal touch listeners, read by the dispatch system. */
59
+ readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
60
+ readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node | null>) => void> = []
61
+
62
+ private _material?: Material
63
+
64
+ private static _active: Scene | null = null
65
+ static get active(): Scene | null { return Scene._active }
66
+
67
+ constructor(options: SceneOptions = {}) {
68
+ installRenderSyncedFrames()
69
+ const parent = (options as any)._parent as Scene | undefined
70
+ if (parent) {
71
+ this.camera = parent.camera
72
+ this._id = _creator.createOverlayScene(parent._id)
73
+ // No camera re-attach: the native createOverlayScene wires the parent's camera into the
74
+ // overlay view. Re-attaching would (a) point camera._sceneId — the source for getRay /
75
+ // getViewDirection / fov — at the overlay's view, whose viewport/projection aren't
76
+ // maintained like the render view's, and (b) create a second native camera component on
77
+ // the shared entity. (Found via the scene editor: gizmo pick rays came out distorted.)
78
+ } else {
79
+ this.camera = new Camera()
80
+ this._id = _creator.createScene()
81
+ this.camera._attach(this._id)
82
+ }
83
+
84
+ if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
85
+ if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
86
+ if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
87
+ if (options.antialias) _creator.setSceneMultiSampleAntiAliasing(this._id, true, 4)
88
+ }
89
+
90
+ /** Scene occlusion material. */
91
+ get occlusionMaterial(): Material {
92
+ if (!this._material) this._material = new Material(_creator.getSceneMaterial(this._id))
93
+ return this._material
94
+ }
95
+
96
+ set skybox(color: ColorInput) { _creator.setSkybox(this._id, Color.toPackedRgb(color)) }
97
+
98
+ setMaterialGlobalParameter(i: number, x: number, y: number, z: number, w: number): void {
99
+ _creator.setMaterialGlobalParameter(this._id, i, x, y, z, w)
100
+ }
101
+ setAntialias(enabled: boolean, scale = 4): void {
102
+ _creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
103
+ }
104
+
105
+ add(...nodes: Node[]): this {
106
+ for (const n of nodes) _creator.addEntityToScene(this._id, n.id)
107
+ return this
108
+ }
109
+ remove(...nodes: Node[]): this {
110
+ for (const n of nodes) _creator.removeEntityFromScene(this._id, n.id)
111
+ return this
112
+ }
113
+
114
+ createOverlay(options: SceneOptions = {}): Scene {
115
+ return new Scene({ ...options, _parent: this } as any)
116
+ }
117
+
118
+ warmRender(): Promise<void> {
119
+ return new Promise<void>((res) => _creator.warmRender(this._id, res))
120
+ }
121
+
122
+ // ---- Presentable (docs/navigation-presentable-plan.md) ----
123
+ // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
124
+ // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
125
+ // scene graph survives while covered — only the view detaches and rendering pauses.
126
+
127
+ /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
128
+ * or synchronously by open()/close() on legacy hosts. */
129
+ readonly ol: (() => void)[] = []
130
+ readonly cl: (() => void)[] = []
131
+ _backButtonCallback?: () => void
132
+ private _vd?: object
133
+
134
+ /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
135
+ onOpen(callback: () => void): this {
136
+ this.ol.push(callback)
137
+ return this
138
+ }
139
+ /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
140
+ onClose(callback: () => void): this {
141
+ this.cl.push(callback)
142
+ return this
143
+ }
144
+ onBackPressed(callback: () => void): this {
145
+ this._backButtonCallback = callback
146
+ return this
147
+ }
148
+
149
+ /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
150
+ _viewDesc(): object {
151
+ return this._vd ??= { type: "scene3d", sceneId: this._id, _p: this }
152
+ }
153
+
154
+ /** Make this the active scene — shows it as the current destination (replaces a screen /
155
+ * another scene; only the active scene renders). */
156
+ open(options?: PresentOptions): void | Promise<void> {
157
+ _bumpNavEpoch()
158
+ if (_navSupported()) {
159
+ // The host activates the engine (openScene) as part of presenting the destination.
160
+ _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
161
+ } else {
162
+ _creator.openScene(this._id)
163
+ for (const cb of this.ol) cb()
164
+ }
165
+ Scene._active = this
166
+ glState.activeScene = this
167
+ _setCurrent(this)
168
+ }
169
+ close(): void {
170
+ _bumpNavEpoch()
171
+ if (_navSupported()) {
172
+ _creatorUI.closeView!()
173
+ } else {
174
+ _creator.closeScene()
175
+ for (const cb of this.cl) cb()
176
+ }
177
+ if (Scene._active === this) Scene._active = null
178
+ if (glState.activeScene === this) glState.activeScene = null
179
+ if (Presentable.current === this) _setCurrent(null)
180
+ }
181
+
182
+ addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
183
+ addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node | null>) => void): void
184
+ addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
185
+ if (channel === "click") {
186
+ if (!this._clickListeners.includes(callback)) { this._clickListeners.push(callback); registerTouchEndEvent() }
187
+ } else if (channel === "touchstart") {
188
+ if (!this._touchStartListeners.includes(callback)) { this._touchStartListeners.push(callback); registerTouchStartEvent() }
189
+ }
190
+ }
191
+ removeEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
192
+ const list = channel === "click" ? this._clickListeners : this._touchStartListeners
193
+ const i = list.indexOf(callback)
194
+ if (i >= 0) list.splice(i, 1)
195
+ }
196
+ }
197
+
198
+ // --- AR ---
199
+
200
+ export type ARMode = "default" | "markers" | "arcore" | "detached"
201
+
202
+ const onTrackStateChange = (entityId: number, track: boolean): void => {
203
+ const node = nodeRegistry.get(entityId)
204
+ if (!node) return
205
+ node.isTracked = track
206
+ if (track) node._dispatchEvent("track")
207
+ else node._dispatchEvent("untrack")
208
+ }
209
+
210
+ export class ARScene<T extends ARMode = "default"> extends Scene {
211
+ readonly mode: T
212
+ useWarmRender = true
213
+ private _root?: Node
214
+
215
+ constructor(options: SceneOptions & { mode?: T } = {}) {
216
+ super(options)
217
+ this.mode = options.mode ?? ("default" as T)
218
+ _creator.createARController(this._id, this.camera.id, this.mode, onTrackStateChange)
219
+ }
220
+
221
+ /** Root anchor node (not available in marker mode). */
222
+ get root(): Node {
223
+ if (this._root) return this._root
224
+ this._root = new Node(_creator.createRootAnchor(this._id))
225
+ return this._root
226
+ }
227
+
228
+ /** Create an anchor from a tracking source (e.g. an image target). */
229
+ createAnchor(source: FetchResponse, physicalWidth = 0.2): Node {
230
+ return new Node(_creator.createAnchor(this._id, physicalWidth, (source as any)._id))
231
+ }
232
+
233
+ /**
234
+ * Let the user place & manipulate a node with touch gestures — the staple AR object-placement
235
+ * interaction: ONE finger drags it across the floor (relative to where the camera looks), TWO fingers
236
+ * pinch to scale and twist to rotate. Returns a handle; call `.remove()` to detach. Tunable via
237
+ * options (min/max scale, and toggles for `pan` / `pinch` / `twist`).
238
+ */
239
+ addControls(target: Node, options: ControlsOptions = {}): ControlsHandle {
240
+ return attachControls(this, target, options)
241
+ }
242
+
243
+ override async open(options?: PresentOptions): Promise<void> {
244
+ await this._prepare()
245
+ super.open(options)
246
+ }
247
+
248
+ /** @internal Presentable prepare hook: camera permission + warm render + AR session, all while
249
+ * the previous destination (typically a loading screen) stays visible — the swap to this scene
250
+ * happens only after it resolves. Warm render is headless on every platform (offscreen
251
+ * swapchain), so no GL view is created here — the host mounts the scene view at present time.
252
+ * Rejects on camera denial / AR launch failure, or if another navigation happened while
253
+ * preparing (superseded) — in every rejection case the current destination stays visible. */
254
+ async _prepare(): Promise<void> {
255
+ const epoch = _navEpoch()
256
+ await _requestCameraPermission()
257
+ if (this.useWarmRender) await this.warmRender()
258
+ await new Promise<void>((res, rej) => _creator.launchAR(this._id, res, rej))
259
+ if (_navEpoch() !== epoch) {
260
+ _creator.stopAR()
261
+ throw new Error("ARScene.open() superseded: another destination was opened while preparing")
262
+ }
263
+ }
264
+
265
+ override close(): void {
266
+ _creator.stopAR()
267
+ super.close()
268
+ }
269
+ }
270
+
271
+ // --- VR ---
272
+
273
+ /**
274
+ * A 3D scene rendered in a VR headset (OpenXR — e.g. Meta Quest). The headset drives the camera:
275
+ * every frame both eyes are rendered from the tracked head pose, so `scene.camera` position/lookAt
276
+ * have no effect while the scene is open. The world origin is at floor level (stage space) —
277
+ * author content with the ground at y = 0 and the user standing at the origin.
278
+ *
279
+ * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
280
+ */
281
+ export class VRScene extends Scene {
282
+ override async open(options?: PresentOptions): Promise<void> {
283
+ await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
284
+ super.open(options)
285
+ }
286
+
287
+ override close(): void {
288
+ _creator.stopVR()
289
+ super.close()
290
+ }
291
+ }
@@ -1,6 +1,6 @@
1
- // Tiny shared module to break the Node ↔ Scene ↔ touch dependency cycle. The active 3D scene is set
2
- // here by Scene.open()/close() and read by the touch dispatch system.
3
-
4
- import type { Scene } from "./Scene"
5
-
6
- export const glState: { activeScene: Scene | null } = { activeScene: null }
1
+ // Tiny shared module to break the Node ↔ Scene ↔ touch dependency cycle. The active 3D scene is set
2
+ // here by Scene.open()/close() and read by the touch dispatch system.
3
+
4
+ import type { Scene } from "./Scene"
5
+
6
+ export const glState: { activeScene: Scene | null, frame: number } = { activeScene: null, frame: 0 }