lecodes-sdk 2.2.9 → 2.2.10

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 (61) hide show
  1. package/dist/global.d.ts +42 -1
  2. package/dist/types/gl/Camera.d.ts +5 -2
  3. package/dist/types/gl/Node.d.ts +0 -7
  4. package/dist/types/gl/Scene.d.ts +6 -24
  5. package/dist/types/inject.d.ts +5 -1
  6. package/dist/types/plugin.d.ts +27 -0
  7. package/dist/types/plugins/gen/ar/sdk/ar.d.ts +228 -0
  8. package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +73 -0
  9. package/dist/types/plugins/gen/ar/sdk/controls.d.ts +28 -0
  10. package/dist/types/plugins/gen/audio-recorder/sdk/audio-recorder.d.ts +43 -0
  11. package/dist/types/plugins/gen/audio-recorder/sdk/audio-recorder.gen.d.ts +47 -0
  12. package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +6 -2
  13. package/dist/types/plugins/gen/speech/sdk/speech.d.ts +44 -0
  14. package/dist/types/plugins/gen/speech/sdk/speech.gen.d.ts +39 -0
  15. package/dist/types/runtime/permissions.d.ts +16 -0
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +1 -1
  19. package/prompts/ar.md +81 -48
  20. package/prompts/dist/2d-game.md +1567 -0
  21. package/prompts/dist/3d-app.md +1712 -0
  22. package/prompts/dist/ar-app.md +1585 -0
  23. package/prompts/dist/concept.md +49 -0
  24. package/prompts/dist/design.md +521 -0
  25. package/prompts/dist/ui-app.md +1175 -0
  26. package/prompts/index.md +3 -1
  27. package/src/animate/tween/Timeline.ts +175 -175
  28. package/src/bridges/device.d.ts +20 -6
  29. package/src/bridges/gl.d.ts +33 -19
  30. package/src/compile/header.ts +11 -4
  31. package/src/gl/Camera.ts +5 -2
  32. package/src/gl/CameraPlace.ts +52 -52
  33. package/src/gl/Light.ts +257 -257
  34. package/src/gl/Mesh.ts +120 -120
  35. package/src/gl/Node.ts +0 -8
  36. package/src/gl/Scene.ts +14 -81
  37. package/src/gl/animation/DynamicBone.ts +482 -482
  38. package/src/gl/scenarios.ts +291 -291
  39. package/src/inject.ts +13 -1
  40. package/src/plugin.ts +45 -2
  41. package/src/plugins/gen/ar/contract.d.ts +118 -0
  42. package/src/plugins/gen/ar/sdk/ar.gen.ts +112 -0
  43. package/src/plugins/gen/ar/sdk/ar.ts +699 -0
  44. package/src/plugins/gen/ar/sdk/controls.ts +148 -0
  45. package/src/plugins/gen/audio-recorder/contract.d.ts +64 -0
  46. package/src/plugins/gen/audio-recorder/sdk/audio-recorder.gen.ts +76 -0
  47. package/src/plugins/gen/audio-recorder/sdk/audio-recorder.ts +113 -0
  48. package/src/plugins/gen/geolocation/contract.d.ts +7 -5
  49. package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +6 -2
  50. package/src/plugins/gen/speech/contract.d.ts +67 -0
  51. package/src/plugins/gen/speech/sdk/speech.gen.ts +64 -0
  52. package/src/plugins/gen/speech/sdk/speech.ts +104 -0
  53. package/src/runtime/permissions.ts +40 -0
  54. package/src/server/auth/host.ts +1 -1
  55. package/src/ui/presentable.ts +5 -0
  56. package/src/ui/router.ts +4 -3
  57. package/src/version.ts +1 -1
  58. package/dist/types/gl/controls.d.ts +0 -19
  59. package/dist/types/plugins/permission.d.ts +0 -1
  60. package/src/gl/controls.ts +0 -95
  61. package/src/plugins/permission.ts +0 -8
@@ -0,0 +1,699 @@
1
+ // GENERATED by scripts/vendor-plugins.ts from lecodes-plugins/plugins/ar/sdk/ar.ts — do not edit here.
2
+ // Change it in the plugin repo and run `bun run vendor:plugins`; `bun run check:plugins` fails when this copy is stale.
3
+ // ARScene — world tracking over the device camera, on the "ar" plugin: a Scene whose camera and
4
+ // background a host SESSION drives every frame (ARKit on iOS, ARCore on Android, the browser's camera
5
+ // turned by the sensors on the web).
6
+ //
7
+ // Hand-written over the generated wire because what an app holds is not the wire: the anchors and the
8
+ // planes are NODES of the scene (`Anchor extends Node`, `ARPlane extends Anchor`), kept here by their
9
+ // entity / plane id and moved by the session's events; the session is the scene's, opened by `open()`
10
+ // — the camera permission and a warm render first, while the current destination stays on screen —
11
+ // and closed by `close()`. `open()` resolves at the first frame drawn of the presented scene, not at
12
+ // the present (a host mounts and sizes the scene's view after it). A host without the plugin is
13
+ // refused before anything is asked.
14
+ //
15
+ // `root` is a pose anchor like any other (`scene.anchor(pose)`): the wrapper pins it on the first
16
+ // horizontal plane the session reports, so the halves know no root.
17
+
18
+ import {
19
+ Geometry, Mat4, Material, Mesh, Node, Presentable, Quat, Scene, System, Vec3, _ensurePermission, _navEpoch, _toFile,
20
+ type ClickEvent, type DismissOptions, type FetchResponse, type File, type NodeEvents, type PresentOptions,
21
+ type QuatLike, type SceneOptions, type TouchStartEvent, type Vec3Like,
22
+ } from "../../../../plugin"
23
+ import { ARBase, supports } from "./ar.gen"
24
+ import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
25
+ import type {
26
+ ARErrorCode, ARMode, HitTarget, ImageTarget, PlaneChange, PlaneDetection, PlaneType, Pose, StartOptions,
27
+ TrackingReason, TrackingState, WorldAlignment,
28
+ } from "../contract"
29
+
30
+ export type {
31
+ ARErrorCode, ARMode, ControlsHandle, ControlsOptions, HitTarget, PlaneChange, PlaneDetection, PlaneType, TrackingReason,
32
+ TrackingState, WorldAlignment,
33
+ }
34
+
35
+ /** A pose in the scene's world: metres, Y up. */
36
+ export type ARPose = { position: Vec3, rotation: Quat }
37
+ /** A pose as `scene.anchor()` takes it. */
38
+ export type ARPoseLike = { position: Vec3Like, rotation: QuatLike }
39
+
40
+ /** What `scene.hitTest()` finds under a point, nearest first. */
41
+ export type ARHitResult = {
42
+ pose: ARPose
43
+ /** "plane": on a detected plane (`plane`); "estimated": on a point of the feature cloud or an
44
+ * estimated surface. */
45
+ type: "plane" | "estimated"
46
+ plane: ARPlane | null
47
+ /** Metres from the camera. */
48
+ distance: number
49
+ }
50
+
51
+ /** The estimated light (`lightEstimation`): an ambient intensity (1000 = neutral) and a colour
52
+ * temperature in kelvin. */
53
+ export type ARLightEstimate = { intensity: number, temperature: number }
54
+
55
+ /** The session failed and is over: "unavailable" (no ARKit / ARCore, ARCore declined), "camera",
56
+ * "failed". */
57
+ export type ARError = { code: ARErrorCode, message: string }
58
+
59
+ /** A plane found, moved or grown, or gone (`mergedInto`: the plane that absorbed it). */
60
+ export type ARPlaneEvent = { plane: ARPlane, change: PlaneChange, mergedInto: ARPlane | null }
61
+
62
+ export type ARSceneOptions = SceneOptions & {
63
+ /** "world" (default): planes, hit tests, anchors, image targets. "images": image targets only —
64
+ * lighter on iOS; no planes, no hit test, no `root`. */
65
+ mode?: ARMode
66
+ }
67
+
68
+ export type AROpenOptions = PresentOptions & Omit<StartOptions, "mode" | "images"> & {
69
+ /** The scene's ambient (its `environmentIntensity`) follows the estimated light; turns
70
+ * `lightEstimation` on. */
71
+ followLight?: boolean
72
+ }
73
+
74
+ export type ARSceneEvents = {
75
+ tracking: (state: TrackingState, reason: TrackingReason | null) => void
76
+ plane: (event: ARPlaneEvent) => void
77
+ light: (estimate: ARLightEstimate) => void
78
+ error: (error: ARError) => void
79
+ }
80
+
81
+ export type AnchorEvents = NodeEvents & {
82
+ /** The anchor began tracking: found, placed, or regained after a relocalization. */
83
+ track: () => void
84
+ /** The anchor lost tracking: it stays where it was last seen. */
85
+ untrack: () => void
86
+ }
87
+
88
+ /** A wire pose → the app's. */
89
+ const poseOf = (w: Pose): ARPose => ({
90
+ position: new Vec3(w.position.x, w.position.y, w.position.z),
91
+ rotation: new Quat(w.rotation.x, w.rotation.y, w.rotation.z, w.rotation.w),
92
+ })
93
+ /** …and back. */
94
+ const wirePose = (p: ARPoseLike): Pose => {
95
+ const v = new Vec3(p.position), q = new Quat(p.rotation)
96
+ return { position: { x: v.x, y: v.y, z: v.z }, rotation: { x: q.x, y: q.y, z: q.z, w: q.w } }
97
+ }
98
+
99
+ /**
100
+ * A node the AR session drives: it follows a place in the room (`scene.anchor(pose)`, `scene.root`)
101
+ * or an image (`scene.createAnchor(image)`). Put content under it. `tracked` says whether the session
102
+ * holds it now; `track` / `untrack` fire when that changes.
103
+ */
104
+ export class Anchor extends Node {
105
+ /** @internal */
106
+ _tracked = false
107
+ /** @internal Hidden while not tracked (an image's: where it is is unknown until it is seen). */
108
+ _hideUntracked = false
109
+ /** @internal The scene whose session holds it. */
110
+ _scene: ARScene | null = null
111
+
112
+ /** Whether the session holds the anchor now (it moves with what it follows). */
113
+ get tracked(): boolean { return this._tracked }
114
+
115
+ override addEventListener<K extends keyof AnchorEvents>(channel: K, callback: AnchorEvents[K]): void {
116
+ super.addEventListener(channel as keyof NodeEvents, callback as never)
117
+ }
118
+ override removeEventListener<K extends keyof AnchorEvents>(channel: K, callback: AnchorEvents[K]): void {
119
+ super.removeEventListener(channel as keyof NodeEvents, callback as never)
120
+ }
121
+
122
+ /** Stop following: the session lets it go (it stays where it was) and it leaves the scene. */
123
+ remove(): void {
124
+ this._scene?._removeAnchor(this)
125
+ }
126
+
127
+ /** @internal */
128
+ _setTracked(tracked: boolean): void {
129
+ if (this._hideUntracked) this.visible = tracked
130
+ if (this._tracked === tracked) return
131
+ this._tracked = tracked
132
+ ;(this.dispatch as (channel: string) => void).call(this, tracked ? "track" : "untrack")
133
+ }
134
+ }
135
+
136
+ /**
137
+ * An anchor that follows an image (`scene.createAnchor(image, physicalWidth)`): while tracked it sits on
138
+ * the image — a rigid pose in metres, no scale: its centre, Y out of the image, the image in X (its
139
+ * width) and Z (towards its bottom). How big the image is, is `extent`.
140
+ */
141
+ export class ImageAnchor extends Anchor {
142
+ /** @internal */
143
+ _extent: [number, number]
144
+
145
+ constructor(physicalWidth: number) {
146
+ super()
147
+ this._extent = [physicalWidth, 0]
148
+ }
149
+
150
+ /** [width, height] in metres, along the anchor's X and Z: the session's estimate of the printed
151
+ * image, refined while it tracks it. Before the image is first found: [physicalWidth, 0] — the
152
+ * height comes with the image's proportions, which the session reads, at the first `track`. */
153
+ get extent(): [number, number] { return [this._extent[0], this._extent[1]] }
154
+ }
155
+
156
+ /** A surface the session found: a floor, a table, a wall. Its node sits at the plane's centre with Y
157
+ * along its normal; `extent` and `polygon` are in that space. */
158
+ export class ARPlane extends Anchor {
159
+ /** The session's id of the plane (`ARHitResult.plane`, `mergedInto`). */
160
+ readonly planeId: number
161
+ /** @internal */
162
+ _type: PlaneType = "horizontal"
163
+ /** @internal */
164
+ _extent: [number, number] = [0, 0]
165
+ /** @internal */
166
+ _polygon: number[] = []
167
+ /** @internal */
168
+ _grid: Mesh | null = null
169
+
170
+ constructor(planeId: number) {
171
+ super()
172
+ this.planeId = planeId
173
+ }
174
+
175
+ get type(): PlaneType { return this._type }
176
+ /** [width, depth] in metres, along the plane's X and Z. */
177
+ get extent(): [number, number] { return [this._extent[0], this._extent[1]] }
178
+ /** The boundary as x, z pairs in the plane's space (metres). */
179
+ get polygon(): readonly number[] { return this._polygon }
180
+ /** The plane's centre in the world. */
181
+ get center(): Vec3 { return this.worldPosition }
182
+ }
183
+
184
+ /** One grid material for every plane drawn: white lines. */
185
+ let gridMaterial: Material | null = null
186
+
187
+ /** A plane's grid: its outline and lines every `step` metres across it, cut to the polygon (a
188
+ * scanline per line), as edges a millimetre above the plane in its own space. */
189
+ const gridGeometry = (polygon: readonly number[], step = 0.1): Geometry => {
190
+ const segs: number[] = []
191
+ const n = polygon.length >> 1
192
+ let minX = Infinity, maxX = -Infinity, minZ = Infinity, maxZ = -Infinity
193
+ for (let i = 0; i < n; i++) {
194
+ const x = polygon[2 * i]!, z = polygon[2 * i + 1]!
195
+ const j = (i + 1) % n
196
+ segs.push(x, z, polygon[2 * j]!, polygon[2 * j + 1]!)
197
+ if (x < minX) minX = x; if (x > maxX) maxX = x
198
+ if (z < minZ) minZ = z; if (z > maxZ) maxZ = z
199
+ }
200
+ // lines along X at each z, then along Z at each x: where the line crosses the outline, in pairs
201
+ const cut = (along: 0 | 1, from: number, to: number) => {
202
+ for (let c = Math.ceil(from / step) * step, lines = 0; c <= to && lines < 200; c += step, lines++) {
203
+ const hits: number[] = []
204
+ for (let i = 0; i < n; i++) {
205
+ const j = (i + 1) % n
206
+ const a0 = polygon[2 * i + (1 - along)]!, a1 = polygon[2 * j + (1 - along)]!
207
+ if ((a0 <= c) === (a1 <= c)) continue
208
+ const b0 = polygon[2 * i + along]!, b1 = polygon[2 * j + along]!
209
+ hits.push(b0 + (c - a0) / (a1 - a0) * (b1 - b0))
210
+ }
211
+ hits.sort((p, q) => p - q)
212
+ for (let k = 0; k + 1 < hits.length; k += 2) {
213
+ if (along === 0) segs.push(hits[k]!, c, hits[k + 1]!, c)
214
+ else segs.push(c, hits[k]!, c, hits[k + 1]!)
215
+ }
216
+ }
217
+ }
218
+ if (n >= 3) { cut(0, minZ, maxZ); cut(1, minX, maxX) }
219
+ const count = segs.length / 2
220
+ const vertices = new Float32Array(count * 3), normals = new Float32Array(count * 3)
221
+ const indices = new Uint16Array(count), uv = new Float32Array(count * 2)
222
+ for (let v = 0; v < count; v++) {
223
+ vertices[3 * v] = segs[2 * v]!; vertices[3 * v + 1] = 0.001; vertices[3 * v + 2] = segs[2 * v + 1]!
224
+ normals[3 * v + 1] = 1
225
+ indices[v] = v
226
+ }
227
+ const geometry = new Geometry(vertices, normals, indices, uv)
228
+ geometry.kind = "edges"
229
+ return geometry
230
+ }
231
+
232
+ /** The first frame of a presented ARScene: a System — the scene-wide per-frame hook, ticked in the
233
+ * late phase of every frame the engine draws, and the engine draws only the scene on show, once its
234
+ * view has a surface and a size — attached by `_presented()`, gone at its first tick. */
235
+ class FirstFrame extends System<"arFirstFrame", ARScene> {
236
+ // a paused game still draws its scene: the wait is not the game's
237
+ protected override updateWhilePaused = true
238
+ protected override update(): void { this.scene._framed() }
239
+ }
240
+
241
+ /**
242
+ * A 3D scene over the device camera with world tracking: the session (the "ar" plugin) moves
243
+ * `scene.camera` and draws the camera image behind everything, every frame. Everything of `Scene`
244
+ * applies. `ARScene.isSupported` says whether this host has the plugin.
245
+ *
246
+ * ```ts
247
+ * const scene = new ARScene()
248
+ * scene.root.add(await Model.load(asset('./chair.glb')))
249
+ * await scene.open() // camera permission → warm render → session → on screen, drawn
250
+ * ```
251
+ */
252
+ export class ARScene extends Scene {
253
+ /** Whether this host registered the "ar" service (the app lists the plugin, the host has a half). */
254
+ static get isSupported(): boolean { return supports() }
255
+
256
+ readonly mode: ARMode
257
+ /** `open()` warm-renders the scene first (default true). */
258
+ useWarmRender = true
259
+
260
+ /** @internal The running session, null when there is none. */
261
+ _session: ARBase | null = null
262
+ private _opening: Promise<void> | null = null
263
+ /** The wait of `open()` / `Router.push` for the presented scene's first frame (`_presented`), and
264
+ * the navigation epoch of its present. */
265
+ private _shown: { promise: Promise<void>, resolve: () => void, reject: (error: Error) => void, epoch: number } | null = null
266
+ /** The open's start while it is out (`_begin`). `closed`: close() ended its session meanwhile — the
267
+ * half refuses a start its close cut short ("failed"), which is the close's doing: the open was
268
+ * superseded, not failed. `images`: the image targets changed meanwhile, which the start did not
269
+ * carry (it read them when it was sent) — the set goes to the session once it runs. */
270
+ private _startOut: { session: ARBase, closed: boolean, images: boolean } | null = null
271
+ /** A `setImages` is due at the end of this turn (`_setImages`). */
272
+ private _imagesDue = false
273
+ private _options: AROpenOptions = {}
274
+ private _root: Anchor | null = null
275
+ /** The first horizontal plane's pose: where `root` is pinned. */
276
+ private _floor: ARPose | null = null
277
+ /** The image targets. `source` is held for the bytes: a fetched response owns its buffer, which the
278
+ * host frees once the response is collected — the File the session is handed only names it. */
279
+ private readonly _images: { anchor: ImageAnchor, image: File, width: number, source: FetchResponse | File }[] = []
280
+ /** The pose anchors: pinned again where they are when a session starts. (`root` is not one: a
281
+ * session pins it on its own first floor.) */
282
+ private readonly _pinned = new Set<Anchor>()
283
+ private readonly _planes = new Map<number, ARPlane>()
284
+ private _trackingState: TrackingState = "notAvailable"
285
+ private _trackingReason: TrackingReason | null = null
286
+ private _light: ARLightEstimate | null = null
287
+ private _showPlanes = false
288
+ private _baseIntensity = 0
289
+ private readonly _arListeners: { [K in keyof ARSceneEvents]?: ARSceneEvents[K][] } = {}
290
+
291
+ constructor(options: ARSceneOptions = {}) {
292
+ super(options)
293
+ this.mode = options.mode ?? "world"
294
+ }
295
+
296
+ /** The tracking quality: "notAvailable" before a session, "limited" (with `trackingReason`) while
297
+ * it settles or struggles, "normal". */
298
+ get trackingState(): TrackingState { return this._trackingState }
299
+ get trackingReason(): TrackingReason | null { return this._trackingReason }
300
+ /** The planes the session holds now. */
301
+ get planes(): ARPlane[] { return [...this._planes.values()] }
302
+ /** The latest light estimate (`lightEstimation`), null before one. */
303
+ get lightEstimate(): ARLightEstimate | null { return this._light }
304
+
305
+ /** A grid drawn on every plane (each plane's child mesh). */
306
+ get showPlanes(): boolean { return this._showPlanes }
307
+ set showPlanes(on: boolean) {
308
+ this._showPlanes = on
309
+ for (const plane of this._planes.values()) this._drawPlane(plane)
310
+ }
311
+
312
+ /** The floor: an anchor pinned on the first horizontal plane the session finds (it fires `track`
313
+ * then). Until that it is the world's origin — where the session began. Not in "images" mode. */
314
+ get root(): Anchor {
315
+ if (this._root) return this._root
316
+ if (this.mode === "images") throw new Error('ARScene.root: there is no root in the "images" mode')
317
+ const root = new Anchor()
318
+ root._scene = this
319
+ this.add(root)
320
+ this._root = root
321
+ if (this._floor) this._pinRoot(this._floor)
322
+ return root
323
+ }
324
+
325
+ /** An anchor that follows an image (a printed marker, a poster): the session finds it in the camera
326
+ * image and keeps the anchor on it — a rigid pose in metres (its centre, Y out of the image, the
327
+ * image in XZ), its size `anchor.extent`. `physicalWidth` = the printed width in metres. Hidden
328
+ * while not tracked. Before `open()`, or any time: on a running scene the image set is updated in
329
+ * place — the tracking, the planes and the anchors stay. */
330
+ createAnchor(source: FetchResponse | File, physicalWidth = 0.2): ImageAnchor {
331
+ const anchor = new ImageAnchor(physicalWidth)
332
+ anchor._scene = this
333
+ anchor._hideUntracked = true
334
+ anchor.visible = false
335
+ this.add(anchor)
336
+ const id = (source as unknown as { _h: Handle })._h.id
337
+ this._images.push({ anchor, image: _toFile({ systemId: id, name: "image", size: 0 }), width: physicalWidth, source })
338
+ this._setImages()
339
+ return anchor
340
+ }
341
+
342
+ /** An anchor at `pose` (a hit test's, say): the session keeps it where it is in the room as its
343
+ * map of the world improves. `anchor.remove()` lets it go. */
344
+ anchor(pose: ARPoseLike): Anchor {
345
+ const anchor = new Anchor()
346
+ anchor._scene = this
347
+ this.add(anchor)
348
+ this._place(anchor, pose)
349
+ return anchor
350
+ }
351
+
352
+ /** What is under a point of the scene's view, nearest first — the point as the scene's touches
353
+ * give it (`ev.clientX` / `ev.clientY` of a `click` or a `touchstart`); empty when nothing is — and
354
+ * always on the web, which finds no surfaces. Rejects without a running session. */
355
+ async hitTest(x: number, y: number, target?: HitTarget): Promise<ARHitResult[]> {
356
+ const hits = await this._wire("hitTest", session => session.hitTest(x, y, target))
357
+ return hits.map(h => ({
358
+ pose: poseOf(h.pose), type: h.type, distance: h.distance,
359
+ plane: h.plane === undefined ? null : this._planes.get(h.plane) ?? null,
360
+ }))
361
+ }
362
+
363
+ /** The latest camera image as a JPEG `File` (the camera's alone; `capture()` is the composite).
364
+ * Rejects without a running session. */
365
+ captureFrame(): Promise<File> {
366
+ return this._wire("captureFrame", session => session.captureFrame())
367
+ }
368
+
369
+ /**
370
+ * Let the user place & manipulate a node with touch gestures — the staple AR object-placement
371
+ * interaction: ONE finger drags it over what the session finds under the finger (a plane), or across
372
+ * the ground where it finds nothing; TWO fingers pinch to scale and twist to rotate. Returns a
373
+ * handle; `.remove()` detaches. Tunable via options (min/max scale, `pan` / `pinch` / `twist`).
374
+ */
375
+ addControls(target: Node, options: ControlsOptions = {}): ControlsHandle {
376
+ return attachControls(this, target, options)
377
+ }
378
+
379
+ // ---- presenting ----
380
+
381
+ /** Camera permission → warm render → the session → on screen, resolved at the scene's first frame:
382
+ * after `await scene.open()` the scene is shown, `camera.displaySize` is its view's size, and
383
+ * `hitTest` / `captureFrame` answer. The current destination (a loading screen) stays until the
384
+ * session runs; rejects — and leaves it — when the camera is denied, AR is unavailable
385
+ * ("unavailable": no ARKit / ARCore), or another navigation came meanwhile; one that comes before
386
+ * the first frame (a `close()` too) rejects it as superseded. */
387
+ override async open(options: AROpenOptions = {}): Promise<void> {
388
+ this._options = options
389
+ await this._prepare()
390
+ super.open(options)
391
+ await this._presented()
392
+ }
393
+
394
+ /** @internal Presentable prepare hook (Router.push / replace await it, with the options of the last
395
+ * `open()`). */
396
+ _prepare(): Promise<void> {
397
+ if (!ARScene.isSupported) {
398
+ return Promise.reject(new Error('ARScene isn\'t available on this host — it needs the "ar" plugin (app.json "plugins").'))
399
+ }
400
+ if (this._opening) return this._opening // one preparation at a time
401
+ if (this._session) return Promise.resolve() // running: a pop back to it presents it again
402
+ const opening = this._begin()
403
+ this._opening = opening
404
+ opening.then(() => { if (this._opening === opening) this._opening = null }, () => { if (this._opening === opening) this._opening = null })
405
+ return opening
406
+ }
407
+
408
+ /** @internal Presentable's after-present hook (`open()`, Router.push / replace): resolves in the
409
+ * first frame drawn of this scene. A host mounts the scene's view after the present and sizes it
410
+ * later still (Android's surface): at the present `camera.displaySize` read 0 × 0 and a hit test
411
+ * had no viewport; in that frame both are known, and the session's frame is drawn. Rejects as
412
+ * superseded when the scene left the screen first. */
413
+ _presented(): Promise<void> {
414
+ // the epoch of this present: a close event of an EARLIER presentation (the runtime's events come
415
+ // later) finds it unmoved and is not one
416
+ const epoch = _navEpoch()
417
+ const shown = this._shown
418
+ if (shown) {
419
+ shown.epoch = epoch // two opens at once: one wait, the last present's
420
+ return shown.promise
421
+ }
422
+ let resolve!: () => void, reject!: (error: Error) => void
423
+ const promise = new Promise<void>((res, rej) => { resolve = res; reject = rej })
424
+ this._shown = { promise, resolve, reject, epoch }
425
+ this.system(FirstFrame)
426
+ return promise
427
+ }
428
+
429
+ /** @internal FirstFrame's tick: a frame was drawn — this scene's while it is the one on show. */
430
+ _framed(): void {
431
+ this._settleShown(Presentable.current === this ? null : "another destination was opened")
432
+ }
433
+
434
+ private _settleShown(superseded: string | null): void {
435
+ const shown = this._shown
436
+ if (!shown) return
437
+ this._shown = null
438
+ this.removeSystem(FirstFrame)
439
+ if (superseded === null) shown.resolve()
440
+ else shown.reject(new Error(`ARScene.open() superseded before the scene's first frame: ${superseded}`))
441
+ }
442
+
443
+ /** Close the scene; its session stops (the camera is let go). An `open()` still waiting — for the
444
+ * session's start or the first frame — rejects as superseded. */
445
+ override close(options?: DismissOptions): void {
446
+ this._settleShown("the scene was closed")
447
+ // a start still out ends with this session: the half's refusal of it is this close's (_begin)
448
+ const out = this._startOut
449
+ if (out && out.session === this._session) out.closed = true
450
+ this._end()
451
+ super.close(options)
452
+ }
453
+
454
+ /** @internal The scene left the screen (replaced, covered, closed: the runtime's lifecycle event).
455
+ * A wait for its first frame is over — no frame of it comes now. The session stays: its life is
456
+ * `close()`'s, as after a resolved open() (a covered scene comes back with a pop). */
457
+ override _emitClose(): void {
458
+ if (this._shown && _navEpoch() !== this._shown.epoch) this._settleShown("another destination was opened")
459
+ super._emitClose()
460
+ }
461
+
462
+ // ---- events ----
463
+
464
+ override addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
465
+ override addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node | null>) => void): void
466
+ override addEventListener<K extends keyof ARSceneEvents>(channel: K, callback: ARSceneEvents[K]): void
467
+ override addEventListener(channel: string, callback: (...args: any[]) => void): void {
468
+ if (channel === "click" || channel === "touchstart") { super.addEventListener(channel as "click", callback); return }
469
+ const list = (this._arListeners[channel as keyof ARSceneEvents] ??= []) as ((...args: any[]) => void)[]
470
+ if (!list.includes(callback)) list.push(callback)
471
+ }
472
+ override removeEventListener(channel: "click" | "touchstart" | keyof ARSceneEvents, callback: (...args: any[]) => void): void {
473
+ if (channel === "click" || channel === "touchstart") { super.removeEventListener(channel, callback); return }
474
+ const list = this._arListeners[channel] as ((...args: any[]) => void)[] | undefined
475
+ const at = list ? list.indexOf(callback) : -1
476
+ if (at >= 0) list!.splice(at, 1)
477
+ }
478
+ private _emit<K extends keyof ARSceneEvents>(channel: K, ...args: Parameters<ARSceneEvents[K]>): void {
479
+ for (const fn of (this._arListeners[channel] ?? []).slice()) (fn as (...a: any[]) => void)(...args)
480
+ }
481
+
482
+ // ---- the session ----
483
+
484
+ private _startOptions(): StartOptions {
485
+ const { transition: _t, followLight, ...rest } = this._options
486
+ const options: StartOptions = { ...rest, mode: this.mode }
487
+ if (followLight) options.lightEstimation = true
488
+ if (this._images.length) options.images = this._targets()
489
+ return options
490
+ }
491
+
492
+ private _targets(): ImageTarget[] {
493
+ return this._images.map(t => ({ image: t.image, physicalWidth: t.width, entity: t.anchor }))
494
+ }
495
+
496
+ private async _begin(): Promise<void> {
497
+ const epoch = _navEpoch()
498
+ const superseded = (why = "another destination was opened") => new Error(`ARScene.open() superseded: ${why} while preparing`)
499
+ await _ensurePermission("camera")
500
+ if (this.useWarmRender) await this.warmRender()
501
+ if (_navEpoch() !== epoch) throw superseded() // (a close() too: no session for a scene that went)
502
+ const session = new ARBase({ scene: this, camera: this.camera })
503
+ session.on("tracking", p => {
504
+ if (this._session !== session || !p) return
505
+ this._trackingState = p.state
506
+ this._trackingReason = p.reason ?? null
507
+ this._emit("tracking", p.state, this._trackingReason)
508
+ })
509
+ session.on("plane", p => { if (this._session === session && p) this._onPlane(p) })
510
+ session.on("image", p => {
511
+ if (this._session !== session || !p || !(p.entity instanceof ImageAnchor)) return
512
+ if (p.extent.length >= 2) p.entity._extent = [p.extent[0]!, p.extent[1]!]
513
+ p.entity._setTracked(p.tracked)
514
+ })
515
+ session.on("anchor", p => { if (this._session === session && p && p.entity instanceof Anchor) p.entity._setTracked(p.tracked) })
516
+ session.on("light", p => {
517
+ if (this._session !== session || !p) return
518
+ this._light = { intensity: p.intensity, temperature: p.temperature }
519
+ if (this._options.followLight) this.environmentIntensity = this._baseIntensity * p.intensity / 1000
520
+ this._emit("light", this._light)
521
+ })
522
+ session.on("error", p => {
523
+ if (this._session !== session || !p) return
524
+ this._drop(session)
525
+ this._emit("error", { code: p.code, message: p.message })
526
+ })
527
+ this._session = session
528
+ this._baseIntensity = this.environmentIntensity
529
+ const out = { session, closed: false, images: false }
530
+ this._startOut = out
531
+ try {
532
+ await session.start(this._startOptions())
533
+ } catch (e) {
534
+ // closed while it started: every half's end() refuses a start still out ("failed") — the open
535
+ // was superseded, it did not fail; close() closed the session, once
536
+ if (out.closed) throw superseded("the scene was closed")
537
+ this._drop(session)
538
+ throw e
539
+ } finally {
540
+ if (this._startOut === out) this._startOut = null
541
+ }
542
+ if (this._session !== session) throw superseded("the scene was closed")
543
+ if (_navEpoch() !== epoch) {
544
+ this._drop(session)
545
+ throw superseded()
546
+ }
547
+ this._pinAll()
548
+ // the image targets changed while the start was out: the running session takes the set now
549
+ if (out.images) this._setImages()
550
+ }
551
+
552
+ /** The session is over (closed, failed): every anchor untracked, the planes gone. */
553
+ private _drop(session: ARBase): void {
554
+ if (this._session !== session) return
555
+ this._session = null
556
+ session.close()
557
+ this._lost()
558
+ }
559
+
560
+ private _end(): void {
561
+ const session = this._session
562
+ if (session) this._drop(session)
563
+ }
564
+
565
+ private _lost(): void {
566
+ this._trackingState = "notAvailable"
567
+ this._trackingReason = null
568
+ this._light = null
569
+ this._floor = null
570
+ for (const plane of this._planes.values()) { plane._setTracked(false); this.remove(plane) }
571
+ this._planes.clear()
572
+ for (const anchor of this._pinned) anchor._setTracked(false)
573
+ for (const image of this._images) image.anchor._setTracked(false)
574
+ this._root?._setTracked(false)
575
+ if (this._options.followLight) this.environmentIntensity = this._baseIntensity
576
+ }
577
+
578
+ /** The image targets changed (one added or removed): a running session takes the whole set in place
579
+ * (`setImages` — the tracking, the planes and the anchors stay). Sent at the end of the turn: several
580
+ * targets added in a row are one set. While the open's start is out it waits for it (the start
581
+ * carried the set it read when it was sent); before a session there is nothing to tell — the start
582
+ * carries the set. Nothing waits for it: a set the half refuses is a console line, and an anchor
583
+ * whose image the session did not take never tracks. */
584
+ private _setImages(): void {
585
+ const session = this._session
586
+ if (!session) return
587
+ const out = this._startOut
588
+ if (out && out.session === session) { out.images = true; return }
589
+ if (this._imagesDue) return
590
+ this._imagesDue = true
591
+ void Promise.resolve().then(() => {
592
+ this._imagesDue = false
593
+ // closed meanwhile: a call on the closed channel would open a new session (the next open's start
594
+ // carries the set)
595
+ if (this._session !== session) return
596
+ session.setImages(this._targets()).catch((e: unknown) => {
597
+ // a close while it was out: the half's refusal is the close's, nothing failed
598
+ if (this._session !== session) return
599
+ console.warn(`ARScene: the session did not take the image targets (${e instanceof Error ? e.message : String(e)}) — their anchors are not tracked`)
600
+ })
601
+ })
602
+ }
603
+
604
+ /** A wire call to the running session; rejects without one. */
605
+ private _wire<T>(name: string, call: (session: ARBase) => Promise<T>): Promise<T> {
606
+ const session = this._session
607
+ if (!session) return Promise.reject(new Error(`ARScene.${name}(): no session — open() the scene first`))
608
+ return call(session)
609
+ }
610
+
611
+ /** Pin `anchor` at `pose` (where it is until the session holds it) — now, or when a session starts. */
612
+ private _place(anchor: Anchor, pose: ARPoseLike): void {
613
+ anchor.worldMatrix = Mat4.compose(pose.position, pose.rotation)
614
+ this._pinned.add(anchor)
615
+ this._pin(anchor, pose)
616
+ }
617
+
618
+ /** `root` onto the floor (the first horizontal plane of this session). */
619
+ private _pinRoot(pose: ARPose): void {
620
+ const root = this._root!
621
+ root.worldMatrix = Mat4.compose(pose.position, pose.rotation)
622
+ this._pin(root, pose)
623
+ }
624
+
625
+ private _pin(anchor: Anchor, pose: ARPoseLike): void {
626
+ const session = this._session
627
+ if (!session) return
628
+ const wire = wirePose(pose)
629
+ this._wire("anchor", s => s.anchor(anchor, wire)).then(() => { if (this._session === session) anchor._setTracked(true) }, () => {})
630
+ }
631
+
632
+ /** A session began: every pose anchor is pinned where it is. */
633
+ private _pinAll(): void {
634
+ for (const anchor of this._pinned) {
635
+ const m = anchor.worldMatrix
636
+ this._pin(anchor, { position: m.position, rotation: m.rotation })
637
+ }
638
+ }
639
+
640
+ /** @internal anchor.remove() */
641
+ _removeAnchor(anchor: Anchor): void {
642
+ this._pinned.delete(anchor)
643
+ if (anchor === this._root) this._root = null
644
+ const image = this._images.findIndex(t => t.anchor === anchor)
645
+ if (image >= 0) this._images.splice(image, 1)
646
+ this._wire("release", s => s.release(anchor)).catch(() => {})
647
+ anchor._setTracked(false)
648
+ this.remove(anchor)
649
+ if (image >= 0) this._setImages()
650
+ }
651
+
652
+ private _onPlane(p: { change: PlaneChange, id: number, type: PlaneType, pose: Pose, extent: number[], polygon: number[], mergedInto?: number }): void {
653
+ let plane = this._planes.get(p.id)
654
+ if (p.change === "removed") {
655
+ if (!plane) return
656
+ this._planes.delete(p.id)
657
+ plane._setTracked(false)
658
+ this.remove(plane)
659
+ const into = p.mergedInto === undefined ? null : this._planes.get(p.mergedInto) ?? null
660
+ this._emit("plane", { plane, change: "removed", mergedInto: into })
661
+ return
662
+ }
663
+ const added = !plane
664
+ if (!plane) {
665
+ plane = new ARPlane(p.id)
666
+ plane._scene = this
667
+ this._planes.set(p.id, plane)
668
+ this.add(plane)
669
+ }
670
+ const pose = poseOf(p.pose)
671
+ plane.matrix = Mat4.compose(pose.position, pose.rotation)
672
+ plane._type = p.type
673
+ plane._extent = [p.extent[0] ?? 0, p.extent[1] ?? 0]
674
+ plane._polygon = p.polygon
675
+ plane._setTracked(true)
676
+ this._drawPlane(plane)
677
+ // the floor: the first horizontal plane, where root is pinned
678
+ if (added && p.type === "horizontal" && !this._floor) {
679
+ this._floor = pose
680
+ if (this._root) this._pinRoot(pose)
681
+ }
682
+ this._emit("plane", { plane, change: added ? "added" : "updated", mergedInto: null })
683
+ }
684
+
685
+ private _drawPlane(plane: ARPlane): void {
686
+ if (!this._showPlanes) {
687
+ if (plane._grid) plane._grid.visible = false
688
+ return
689
+ }
690
+ const geometry = gridGeometry(plane._polygon)
691
+ if (plane._grid) {
692
+ plane._grid.setGeometry(geometry)
693
+ plane._grid.visible = true
694
+ } else {
695
+ plane._grid = new Mesh(geometry, gridMaterial ??= Material.unlit({ color: "#ffffff" }))
696
+ plane.add(plane._grid)
697
+ }
698
+ }
699
+ }