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.
- package/dist/global.d.ts +42 -1
- package/dist/types/gl/Camera.d.ts +5 -2
- package/dist/types/gl/Node.d.ts +0 -7
- package/dist/types/gl/Scene.d.ts +6 -24
- package/dist/types/inject.d.ts +5 -1
- package/dist/types/plugin.d.ts +27 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +228 -0
- package/dist/types/plugins/gen/ar/sdk/ar.gen.d.ts +73 -0
- package/dist/types/plugins/gen/ar/sdk/controls.d.ts +28 -0
- package/dist/types/plugins/gen/audio-recorder/sdk/audio-recorder.d.ts +43 -0
- package/dist/types/plugins/gen/audio-recorder/sdk/audio-recorder.gen.d.ts +47 -0
- package/dist/types/plugins/gen/geolocation/sdk/geolocation.gen.d.ts +6 -2
- package/dist/types/plugins/gen/speech/sdk/speech.d.ts +44 -0
- package/dist/types/plugins/gen/speech/sdk/speech.gen.d.ts +39 -0
- package/dist/types/runtime/permissions.d.ts +16 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/prompts/ar.md +81 -48
- package/prompts/dist/2d-game.md +1567 -0
- package/prompts/dist/3d-app.md +1712 -0
- package/prompts/dist/ar-app.md +1585 -0
- package/prompts/dist/concept.md +49 -0
- package/prompts/dist/design.md +521 -0
- package/prompts/dist/ui-app.md +1175 -0
- package/prompts/index.md +3 -1
- package/src/animate/tween/Timeline.ts +175 -175
- package/src/bridges/device.d.ts +20 -6
- package/src/bridges/gl.d.ts +33 -19
- package/src/compile/header.ts +11 -4
- package/src/gl/Camera.ts +5 -2
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +257 -257
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Node.ts +0 -8
- package/src/gl/Scene.ts +14 -81
- package/src/gl/animation/DynamicBone.ts +482 -482
- package/src/gl/scenarios.ts +291 -291
- package/src/inject.ts +13 -1
- package/src/plugin.ts +45 -2
- package/src/plugins/gen/ar/contract.d.ts +118 -0
- package/src/plugins/gen/ar/sdk/ar.gen.ts +112 -0
- package/src/plugins/gen/ar/sdk/ar.ts +699 -0
- package/src/plugins/gen/ar/sdk/controls.ts +148 -0
- package/src/plugins/gen/audio-recorder/contract.d.ts +64 -0
- package/src/plugins/gen/audio-recorder/sdk/audio-recorder.gen.ts +76 -0
- package/src/plugins/gen/audio-recorder/sdk/audio-recorder.ts +113 -0
- package/src/plugins/gen/geolocation/contract.d.ts +7 -5
- package/src/plugins/gen/geolocation/sdk/geolocation.gen.ts +6 -2
- package/src/plugins/gen/speech/contract.d.ts +67 -0
- package/src/plugins/gen/speech/sdk/speech.gen.ts +64 -0
- package/src/plugins/gen/speech/sdk/speech.ts +104 -0
- package/src/runtime/permissions.ts +40 -0
- package/src/server/auth/host.ts +1 -1
- package/src/ui/presentable.ts +5 -0
- package/src/ui/router.ts +4 -3
- package/src/version.ts +1 -1
- package/dist/types/gl/controls.d.ts +0 -19
- package/dist/types/plugins/permission.d.ts +0 -1
- package/src/gl/controls.ts +0 -95
- 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
|
+
}
|