lecodes-sdk 2.6.0 → 2.8.1
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 +11 -7
- package/dist/types/inject.d.ts +2 -1
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
- package/dist/types/runtime/firstCall.d.ts +12 -0
- package/dist/types/runtime/gestures.d.ts +52 -0
- package/dist/types/runtime/rpc.d.ts +24 -0
- package/dist/types/runtime/wire.d.ts +11 -4
- package/dist/types/server/channel.d.ts +16 -0
- package/dist/types/server/computed.d.ts +59 -0
- package/dist/types/server/context.d.ts +13 -1
- package/dist/types/server/db/fields.d.ts +2 -1
- package/dist/types/server/db/marci/query.d.ts +53 -6
- package/dist/types/server/inject.d.ts +2 -0
- package/dist/types/server/ordered.d.ts +18 -0
- package/dist/types/ui/UIModal.d.ts +5 -3
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/src/bridges/tree.d.ts +14 -4
- package/src/compile/serverSplit.ts +8 -4
- package/src/compile/serverTypes.ts +17 -3
- package/src/inject.ts +5 -2
- package/src/plugin.ts +3 -1
- package/src/plugins/gen/ar/sdk/ar.ts +59 -14
- package/src/runtime/firstCall.ts +28 -0
- package/src/runtime/gestures.ts +190 -0
- package/src/runtime/rpc.ts +141 -12
- package/src/runtime/wire.ts +11 -6
- package/src/server/channel.ts +32 -0
- package/src/server/channelHub.ts +246 -16
- package/src/server/clock.ts +50 -0
- package/src/server/computed.ts +84 -0
- package/src/server/computedHost.ts +413 -0
- package/src/server/context.ts +14 -2
- package/src/server/db/defineDb.ts +47 -7
- package/src/server/db/fields.ts +2 -1
- package/src/server/db/httpTransport.ts +37 -2
- package/src/server/db/httpWatch.ts +180 -0
- package/src/server/db/marci/query.ts +112 -7
- package/src/server/db/watch.ts +160 -0
- package/src/server/files/host.ts +2 -1
- package/src/server/host.ts +19 -4
- package/src/server/inject.ts +6 -2
- package/src/server/ordered.ts +41 -0
- package/src/server/rateLimit.ts +72 -0
- package/src/server/runtime.ts +127 -9
- package/src/ui/UIModal.ts +44 -9
- package/src/ui/UINode.ts +4 -0
- package/src/ui/tree.ts +8 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
|
@@ -40,10 +40,10 @@
|
|
|
40
40
|
"@types/node": "^25.9.1",
|
|
41
41
|
"typescript": "~5.8.3",
|
|
42
42
|
"gl-matrix": "^3.4.4",
|
|
43
|
-
"marcidb-embedded": "^0.
|
|
43
|
+
"marcidb-embedded": "^0.18.0",
|
|
44
44
|
"sharp": "^0.35.2"
|
|
45
45
|
},
|
|
46
|
-
"version": "2.
|
|
46
|
+
"version": "2.8.1",
|
|
47
47
|
"files": [
|
|
48
48
|
"src",
|
|
49
49
|
"dist",
|
package/src/bridges/tree.d.ts
CHANGED
|
@@ -217,6 +217,11 @@ declare global {
|
|
|
217
217
|
* listeners are a flag mask, events go back through the runtime's entry points with the node id.
|
|
218
218
|
* One implementation per platform: renderers/tgfx (desktop, Quest), the Kotlin views (Android),
|
|
219
219
|
* UIKit (Apple), the null renderer of lecodes-server.
|
|
220
|
+
* The runtime calls it through a QUEUE (runtime/native src/host/hostqueue.h, docs/plans/
|
|
221
|
+
* host-commit-plan.md): a write reaches the host at the next commit — the end of the tick, before
|
|
222
|
+
* a layout, before a read — in the order the runtime made it; a slot with a result commits first
|
|
223
|
+
* and answers at once; an `@immediate` slot commits first and is called at once (its callbacks and
|
|
224
|
+
* borrowed values are never kept).
|
|
220
225
|
* @host ui HostUI */
|
|
221
226
|
export interface HostUI {
|
|
222
227
|
/** Create the host view for a node of `type` ("column", "text", "image", "input", "screen", …).
|
|
@@ -259,7 +264,8 @@ export interface HostUI {
|
|
|
259
264
|
viewVersion?(name: string): i32
|
|
260
265
|
/** NativeView channel: invoke `method` on the live instance `viewId`; `args` is the array of the
|
|
261
266
|
* call's arguments, valid for the call. Settle exactly one of the two callbacks through the
|
|
262
|
-
* dispatch bridge — `onComplete` with the result as ONE value, or with none.
|
|
267
|
+
* dispatch bridge — `onComplete` with the result as ONE value, or with none.
|
|
268
|
+
* @immediate its callbacks and the borrowed `args` cannot wait for a commit */
|
|
263
269
|
viewCall?(viewId: i32, method: string, args: value, onComplete: (result?: value) => void, onReject: (message: string) => void): void
|
|
264
270
|
/** A LAYER root is shown: a pass-through root the runtime lays out at the frame, above every
|
|
265
271
|
* destination (the app layer) or over the presented surface (a scene's / a native view's / a
|
|
@@ -272,8 +278,10 @@ export interface HostUI {
|
|
|
272
278
|
/** The layer root is hidden (its view stays; releaseNode says when it is gone). */
|
|
273
279
|
layerClose(node: nodeid): void
|
|
274
280
|
/** Load a font face for `fontFamily` (weight 100–900, style 0 normal / 2 italic) from `url`; settle
|
|
275
|
-
* one of the callbacks through the dispatch bridge.
|
|
281
|
+
* one of the callbacks through the dispatch bridge.
|
|
282
|
+
* @immediate its callbacks cannot wait for a commit */
|
|
276
283
|
registerFont(url: string, fontFamily: string, weight: i32, style: i32, onComplete: () => void, onReject: (err: string) => void): void
|
|
284
|
+
/** @immediate not a tree write */
|
|
277
285
|
showToast(message: string, duration: i32): void
|
|
278
286
|
/** Is the button node currently pressed (the UIButton.isPressed getter)? */
|
|
279
287
|
getButtonPressed(node: nodeid): boolean
|
|
@@ -284,9 +292,11 @@ export interface HostUI {
|
|
|
284
292
|
* it. @out 4 */
|
|
285
293
|
getBoundingRect?(node: nodeid): Float32Array | null
|
|
286
294
|
/** A node's touchStart / longPress listener took the gesture (`ev.track`): keep feeding this
|
|
287
|
-
* pointer's moves (`claim` = a PanDirection bitmask, `hasMove` = it wants onMove).
|
|
295
|
+
* pointer's moves (`claim` = a PanDirection bitmask, `hasMove` = it wants onMove).
|
|
296
|
+
* @immediate the answer belongs to the gesture the host is tracking */
|
|
288
297
|
touchHandler?(pointerId: i32, hasMove: boolean, claim: u8, node: nodeid): void
|
|
289
|
-
/** A long press was HANDLED: swallow the click that would follow the release.
|
|
298
|
+
/** A long press was HANDLED: swallow the click that would follow the release.
|
|
299
|
+
* @immediate the answer belongs to the gesture the host is tracking */
|
|
290
300
|
longPressHandler?(pointerId: i32, node: nodeid): void
|
|
291
301
|
/** Virtualized list: apply a scroll position (physical px). `animated` false = anchoring (idempotent,
|
|
292
302
|
* after the content height update), true = a user scroll command. */
|
|
@@ -2,14 +2,15 @@
|
|
|
2
2
|
* The client/server split of a project (docs/backend-plan.md §4).
|
|
3
3
|
*
|
|
4
4
|
* `*.server.ts` files never reach the client bundle: `compileProject` swaps each one for a STUB
|
|
5
|
-
* module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__pushChannel`, `__serverOnly` — client SDK
|
|
5
|
+
* module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__computed`, `__pushChannel`, `__serverOnly` — client SDK
|
|
6
6
|
* globals, src/runtime/rpc.ts), so `import { listNotes } from "./notes.server"` in client code keeps
|
|
7
7
|
* its types and calls the server. `compileServerBundle` bundles the same files with the SERVER inject
|
|
8
8
|
* behind a synthetic entry that registers every module on `globalThis.__lecodesServer` (chisel keeps
|
|
9
9
|
* side effects, not entry exports) — the runner reads that (src/server/runtime.ts).
|
|
10
10
|
*
|
|
11
11
|
* Export kinds are found by a static scan (no TS program needed): a function export is an
|
|
12
|
-
* endpoint, a `channel<…>()…` export is a channel, a `
|
|
12
|
+
* endpoint, a `channel<…>()…` export is a channel, a `computed(…)` export a computed (an endpoint the app
|
|
13
|
+
* may also subscribe to), a `pushChannel<…>()` export a push channel, anything else is server-only.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import type { AppConfig } from "../server/auth/appConfig"
|
|
@@ -26,11 +27,12 @@ export const serverModuleId = (path: string) => path.split("\\").join("/").repla
|
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* What an export of a server module is to the app: `fn` = an endpoint — an ASYNC function, called
|
|
29
|
-
* over the network; `channel` = a channel; `
|
|
30
|
+
* over the network; `channel` = a channel; `computed` = a computed (../server/computed.ts): called like
|
|
31
|
+
* an endpoint, or subscribed to; `push` = a push channel; `other` = server-only — a model, the db, a constant, or
|
|
30
32
|
* a helper: a function that is not async is used by server files alone, and the app's import of
|
|
31
33
|
* it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
|
|
32
34
|
*/
|
|
33
|
-
export type ServerExportKind = "fn" | "channel" | "push" | "other"
|
|
35
|
+
export type ServerExportKind = "fn" | "channel" | "computed" | "push" | "other"
|
|
34
36
|
export type ServerExport = { name: string, kind: ServerExportKind }
|
|
35
37
|
|
|
36
38
|
// ───────────────────────────── static export scan ─────────────────────────────
|
|
@@ -86,6 +88,7 @@ const IDENT = "[A-Za-z_$][\\w$]*"
|
|
|
86
88
|
const classifyInit = (init: string): ServerExportKind => {
|
|
87
89
|
const s = init.trimStart()
|
|
88
90
|
if (/^channel\s*[<(]/.test(s)) return "channel"
|
|
91
|
+
if (/^computed\s*[<(]/.test(s)) return "computed"
|
|
89
92
|
if (/^pushChannel\s*[<(]/.test(s)) return "push"
|
|
90
93
|
if (/^async\s+function\b/.test(s)) return "fn"
|
|
91
94
|
if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
|
|
@@ -164,6 +167,7 @@ export const serverStubSource = (path: string, exports: ServerExport[], serverUr
|
|
|
164
167
|
const id = JSON.stringify(`${mod}#${name}`)
|
|
165
168
|
if (kind === "fn") return `export const ${name} = __rpc(${url}, ${id})`
|
|
166
169
|
if (kind === "channel") return `export const ${name} = __channel(${url}, ${id})`
|
|
170
|
+
if (kind === "computed") return `export const ${name} = __computed(${url}, ${id})`
|
|
167
171
|
if (kind === "push") return `export const ${name} = __pushChannel(${url}, ${id})`
|
|
168
172
|
return `export const ${name} = __serverOnly(${id})`
|
|
169
173
|
})
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
* "endpoint args must be JSON-serializable". Return types are not validated.
|
|
15
15
|
*
|
|
16
16
|
* A channel's `groupBy(fn)` is the same case: `fn` is called with what the app passed to
|
|
17
|
-
* `subscribe`, so its parameters get a schema under the channel's id
|
|
17
|
+
* `subscribe`, so its parameters get a schema under the channel's id; and so is a `computed(fn)` —
|
|
18
|
+
* called and subscribed to with the same arguments, under the computed's id.
|
|
18
19
|
*
|
|
19
20
|
* `typescript` is loaded lazily (it's a devDependency of the SDK and a dependency of the platform
|
|
20
21
|
* compile step); the browser-safe parts of the compiler never import this module's runtime.
|
|
@@ -252,6 +253,11 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
252
253
|
if (!hook) { params[id] = []; continue }
|
|
253
254
|
type = checker.getTypeAtLocation(hook)
|
|
254
255
|
where = `${id}.groupBy`
|
|
256
|
+
} else if (ex.kind === "computed") {
|
|
257
|
+
// `computed` too: its function is the call's first argument
|
|
258
|
+
const fn = computedFn(ts, decl)
|
|
259
|
+
if (!fn) { params[id] = []; continue }
|
|
260
|
+
type = checker.getTypeAtLocation(fn)
|
|
255
261
|
} else {
|
|
256
262
|
type = checker.getTypeOfSymbolAtLocation(sym, decl)
|
|
257
263
|
}
|
|
@@ -261,8 +267,8 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
261
267
|
for (const p of sig.getParameters()) {
|
|
262
268
|
const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
|
|
263
269
|
// nothing here gives a hook's parameter a type but its own annotation — without one it is `any`, unchecked
|
|
264
|
-
if (ex.kind === "channel" && pd && !pd.type && !pd.initializer) {
|
|
265
|
-
problems.push(`${where}(${p.name}): give the parameter a type — it is what the app passes to subscribe, and it is checked against it`)
|
|
270
|
+
if ((ex.kind === "channel" || ex.kind === "computed") && pd && !pd.type && !pd.initializer) {
|
|
271
|
+
problems.push(`${where}(${p.name}): give the parameter a type — it is what the app passes to ${ex.kind === "channel" ? "subscribe" : "the call and to subscribe"}, and it is checked against it`)
|
|
266
272
|
continue
|
|
267
273
|
}
|
|
268
274
|
const ptype = checker.getTypeOfSymbolAtLocation(p, pd ?? decl)
|
|
@@ -294,6 +300,14 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
294
300
|
return { params, warnings }
|
|
295
301
|
}
|
|
296
302
|
|
|
303
|
+
/** The function a computed export is made of (`computed(async (…) => …)`). */
|
|
304
|
+
const computedFn = (ts: TS, decl: import("typescript").Declaration): import("typescript").Expression | null => {
|
|
305
|
+
let node: import("typescript").Expression | undefined = ts.isVariableDeclaration(decl) ? decl.initializer : undefined
|
|
306
|
+
while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node))) node = node.expression
|
|
307
|
+
if (node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "computed") return node.arguments[0] ?? null
|
|
308
|
+
return null
|
|
309
|
+
}
|
|
310
|
+
|
|
297
311
|
/** The function given to `.groupBy(…)` in a channel export's initializer (`channel<M>().authorize(…).groupBy(fn)`). */
|
|
298
312
|
const groupByHook = (ts: TS, decl: import("typescript").Declaration): import("typescript").Expression | null => {
|
|
299
313
|
let node: import("typescript").Expression | undefined = ts.isVariableDeclaration(decl) ? decl.initializer : undefined
|
package/src/inject.ts
CHANGED
|
@@ -43,8 +43,9 @@ export { WebSocket } from "./runtime/net"
|
|
|
43
43
|
// Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
|
|
44
44
|
export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
|
|
45
45
|
// App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
|
|
46
|
-
// only ever imports its server functions (docs/backend-plan.md §4)
|
|
47
|
-
|
|
46
|
+
// only ever imports its server functions (docs/backend-plan.md §4) — and reads `online`, whether the
|
|
47
|
+
// transport reaches the backend.
|
|
48
|
+
export { __rpc, __channel, __computed, __pushChannel, __serverOnly, RpcError, online } from "./runtime/rpc"
|
|
48
49
|
// The font() compile macro's generated `.__fonts.ts` modules call this per face (UI tree + Font2D).
|
|
49
50
|
export { __registerFont } from "./ui/fonts"
|
|
50
51
|
// What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
|
|
@@ -62,6 +63,8 @@ export { audio, type PlaySoundOptions, type SpatialOptions, type Rolloff, type A
|
|
|
62
63
|
export { openFilePicker, files, type FileData } from "./runtime/files"
|
|
63
64
|
export { share } from "./runtime/share"
|
|
64
65
|
export { ClickEvent, TouchStartEvent, LongPressEvent, MouseEnterEvent } from "./runtime/touch"
|
|
66
|
+
// Pan, pinch and twist as one gesture over the per-pointer tracks (see src/runtime/gestures.ts).
|
|
67
|
+
export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures"
|
|
65
68
|
|
|
66
69
|
// ---- 2D engine (creator-2d) ----
|
|
67
70
|
export { Scene2D } from "./g2/Scene2D"
|
package/src/plugin.ts
CHANGED
|
@@ -31,7 +31,8 @@ export type { NativeView } from "./ui/NativeView"
|
|
|
31
31
|
export type { FetchResponse } from "./runtime/fetch"
|
|
32
32
|
// The 3D a wrapper builds on (the "ar" plugin's ARScene — docs/plans/ar-plugin-plan.md): the scene it
|
|
33
33
|
// extends, the node its anchors are, the mesh it draws a plane's grid with, the math of a pose, the
|
|
34
|
-
// touch a placement gesture follows
|
|
34
|
+
// touch a placement gesture follows (and `trackGesture`, the pan / pinch / twist over it), the System
|
|
35
|
+
// its per-frame work rides on (the scene's first frame).
|
|
35
36
|
// Re-exported, never copied, as the UI above.
|
|
36
37
|
export { Scene, type SceneOptions } from "./gl/Scene"
|
|
37
38
|
export { System } from "./core/Aspect"
|
|
@@ -43,6 +44,7 @@ export { Vec2, Vec3, type Vec3Like } from "./math/vec"
|
|
|
43
44
|
export { Quat, type QuatLike } from "./math/quat"
|
|
44
45
|
export { Mat4 } from "./math/mat4"
|
|
45
46
|
export type { ClickEvent, TouchStartEvent } from "./runtime/touch"
|
|
47
|
+
export { trackGesture, type Gesture, type GestureHandler, type GestureTarget } from "./runtime/gestures"
|
|
46
48
|
export type { DismissOptions } from "./ui/presentable"
|
|
47
49
|
/** The seam the push plugin's wrapper installs its runtime on (src/runtime/pushSeam.ts): what the app's transport
|
|
48
50
|
* reads the device's address from, registers through, and a push channel's handlers are kept by. */
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// the present (a host mounts and sizes the scene's view after it). A host without the plugin is
|
|
13
13
|
// refused before anything is asked.
|
|
14
14
|
//
|
|
15
|
-
// `root` is a pose anchor like any other (`scene.anchor(pose)`)
|
|
16
|
-
//
|
|
15
|
+
// `root` is a pose anchor like any other (`scene.anchor(pose)`), placed by the wrapper — in front of
|
|
16
|
+
// the user on the first floor the session reports (`_placeRoot`) — so the halves know no root.
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
Geometry, Mat4, Material, Mesh, Node, Presentable, Quat, Router, Scene, System, Vec3, _ensurePermission, _navEpoch, _toFile,
|
|
@@ -63,6 +63,9 @@ export type ARSceneOptions = SceneOptions & {
|
|
|
63
63
|
/** "world" (default): planes, hit tests, anchors, image and QR targets. "images": image and QR
|
|
64
64
|
* targets only — lighter on iOS; no planes, no hit test, no `root`. */
|
|
65
65
|
mode?: ARMode
|
|
66
|
+
/** How far in front of the user `root` is placed, metres (default 1): along the camera's heading,
|
|
67
|
+
* on the floor. */
|
|
68
|
+
rootDistance?: number
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export type AROpenOptions = PresentOptions & Omit<StartOptions, "mode" | "images" | "codes"> & {
|
|
@@ -269,6 +272,8 @@ export class ARScene extends Scene {
|
|
|
269
272
|
static get isSupported(): boolean { return supports() }
|
|
270
273
|
|
|
271
274
|
readonly mode: ARMode
|
|
275
|
+
/** Metres between the user and `root` when it is placed (`rootDistance` option, default 1). */
|
|
276
|
+
readonly rootDistance: number
|
|
272
277
|
/** `open()` warm-renders the scene first (default true). */
|
|
273
278
|
useWarmRender = true
|
|
274
279
|
|
|
@@ -291,8 +296,9 @@ export class ARScene extends Scene {
|
|
|
291
296
|
private _codesWarned = false
|
|
292
297
|
private _options: AROpenOptions = {}
|
|
293
298
|
private _root: Anchor | null = null
|
|
294
|
-
/** The first horizontal plane
|
|
295
|
-
|
|
299
|
+
/** The floor's height (world Y, metres): the first horizontal plane below the camera this session
|
|
300
|
+
* found. Null until one is; `root` is placed on it. */
|
|
301
|
+
private _floor: number | null = null
|
|
296
302
|
/** The image targets. `source` is held for the bytes: a fetched response owns its buffer, which the
|
|
297
303
|
* host frees once the response is collected — the File the session is handed only names it. */
|
|
298
304
|
private readonly _images: { anchor: ImageAnchor, image: File, width: number, source: FetchResponse | File }[] = []
|
|
@@ -310,6 +316,7 @@ export class ARScene extends Scene {
|
|
|
310
316
|
constructor(options: ARSceneOptions = {}) {
|
|
311
317
|
super(options)
|
|
312
318
|
this.mode = options.mode ?? "world"
|
|
319
|
+
this.rootDistance = options.rootDistance ?? 1
|
|
313
320
|
}
|
|
314
321
|
|
|
315
322
|
/** The tracking quality: "notAvailable" before a session, "limited" (with `trackingReason`) while
|
|
@@ -328,8 +335,12 @@ export class ARScene extends Scene {
|
|
|
328
335
|
for (const plane of this._planes.values()) this._drawPlane(plane)
|
|
329
336
|
}
|
|
330
337
|
|
|
331
|
-
/** The
|
|
332
|
-
*
|
|
338
|
+
/** The place in front of the user: an anchor `rootDistance` metres ahead of the camera along its
|
|
339
|
+
* heading, on the floor — the first horizontal plane below the camera the session finds; it is
|
|
340
|
+
* pinned there and fires `track` then — turned the way the user looks (its −Z along the heading,
|
|
341
|
+
* so a model's front, +Z, faces the user). Until the floor is found (and on the web, which finds
|
|
342
|
+
* none) it sits the same way at the camera's own height, not pinned. Made on first use; put
|
|
343
|
+
* content under it. `placeRoot()` puts it in front of the user again. Not in "images" mode. */
|
|
333
344
|
get root(): Anchor {
|
|
334
345
|
if (this._root) return this._root
|
|
335
346
|
if (this.mode === "images") throw new Error('ARScene.root: there is no root in the "images" mode')
|
|
@@ -337,10 +348,19 @@ export class ARScene extends Scene {
|
|
|
337
348
|
root._scene = this
|
|
338
349
|
this.add(root)
|
|
339
350
|
this._root = root
|
|
340
|
-
|
|
351
|
+
this._placeRoot()
|
|
341
352
|
return root
|
|
342
353
|
}
|
|
343
354
|
|
|
355
|
+
/** Put `root` in front of the user again — where they look now, `rootDistance` metres ahead, on the
|
|
356
|
+
* floor if one is known (the camera's height otherwise), facing them: a "reset" button, or a
|
|
357
|
+
* placement after the user has moved. Makes `root` when there is none yet. */
|
|
358
|
+
placeRoot(): Anchor {
|
|
359
|
+
if (!this._root) return this.root
|
|
360
|
+
this._placeRoot()
|
|
361
|
+
return this._root
|
|
362
|
+
}
|
|
363
|
+
|
|
344
364
|
/** An anchor that follows an image (a printed marker, a poster): the session finds it in the camera
|
|
345
365
|
* image and keeps the anchor on it — a rigid pose in metres (its centre, Y out of the image, the
|
|
346
366
|
* image in XZ), its size `anchor.extent`. `physicalWidth` = the printed width in metres. Hidden
|
|
@@ -679,11 +699,34 @@ export class ARScene extends Scene {
|
|
|
679
699
|
this._pin(anchor, pose)
|
|
680
700
|
}
|
|
681
701
|
|
|
682
|
-
/** `root`
|
|
683
|
-
|
|
702
|
+
/** Where `root` goes now: `rootDistance` metres from the camera along its heading (its forward
|
|
703
|
+
* flattened to the floor; looking straight down or up, the way the top of the screen points), at
|
|
704
|
+
* the floor's height when one is known, else the camera's; turned so its −Z runs along the
|
|
705
|
+
* heading — a model's front (+Z) faces the user. */
|
|
706
|
+
private _rootPose(): ARPose {
|
|
707
|
+
const camera = this.camera
|
|
708
|
+
const eye = camera.worldPosition
|
|
709
|
+
const forward = camera.forward
|
|
710
|
+
let heading = new Vec3(forward.x, 0, forward.z)
|
|
711
|
+
if (heading.lengthSq() < 1e-6) {
|
|
712
|
+
// straight down (the usual way to look at a floor) or up: the screen's top points the way
|
|
713
|
+
const up = Vec3.up.rotate(camera.worldMatrix.rotation)
|
|
714
|
+
heading = new Vec3(up.x, 0, up.z)
|
|
715
|
+
if (forward.y > 0) heading = heading.negate()
|
|
716
|
+
if (heading.lengthSq() < 1e-6) heading = new Vec3(0, 0, -1)
|
|
717
|
+
}
|
|
718
|
+
heading = heading.normalize()
|
|
719
|
+
const position = new Vec3(eye.x + heading.x * this.rootDistance, this._floor ?? eye.y, eye.z + heading.z * this.rootDistance)
|
|
720
|
+
return { position, rotation: Quat.lookRotation(heading) }
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/** `root` in front of the user: moved there now, and pinned there when the floor is known (a
|
|
724
|
+
* session holds a pin; without one `_pinAll` does not re-pin root — it waits for its own floor). */
|
|
725
|
+
private _placeRoot(): void {
|
|
684
726
|
const root = this._root!
|
|
727
|
+
const pose = this._rootPose()
|
|
685
728
|
root.worldMatrix = Mat4.compose(pose.position, pose.rotation)
|
|
686
|
-
this._pin(root, pose)
|
|
729
|
+
if (this._floor !== null) this._pin(root, pose)
|
|
687
730
|
}
|
|
688
731
|
|
|
689
732
|
private _pin(anchor: Anchor, pose: ARPoseLike): void {
|
|
@@ -741,10 +784,12 @@ export class ARScene extends Scene {
|
|
|
741
784
|
plane._polygon = p.polygon
|
|
742
785
|
plane._setTracked(true)
|
|
743
786
|
this._drawPlane(plane)
|
|
744
|
-
// the floor: the first horizontal plane
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
787
|
+
// the floor: the first horizontal plane below the camera (a ceiling is "horizontal" too), where
|
|
788
|
+
// root is placed — in front of the user, at the plane's height; the plane's centre and turn are
|
|
789
|
+
// a patch's and say nothing about where the user is
|
|
790
|
+
if (added && p.type === "horizontal" && this._floor === null && pose.position.y < this.camera.worldPosition.y) {
|
|
791
|
+
this._floor = pose.position.y
|
|
792
|
+
if (this._root) this._placeRoot()
|
|
748
793
|
}
|
|
749
794
|
this._emit("plane", { plane, change: added ? "added" : "updated", mergedInto: null })
|
|
750
795
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client transport's first-call rule (./rpc.ts), kept apart so a host that calls a backend as a
|
|
3
|
+
* device — a flow's `call` step, `lecodes call` (tools/cli/src/dev/backendCall.ts) — follows the very
|
|
4
|
+
* same rule: a device with no token yet sends its first call alone and the calls behind it wait, so a
|
|
5
|
+
* backend that mints a token on first contact (`request.session`, a guest session) mints ONE — two
|
|
6
|
+
* calls in flight would each get their own and the device would end up with whichever landed last.
|
|
7
|
+
* Once per device per run: the gate opens for good when the first call is back, whether or not it
|
|
8
|
+
* brought a token. Pure: no bridges, no globals.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type FirstCallGate = <T>(hasToken: () => boolean, send: () => Promise<T>) => Promise<T>
|
|
12
|
+
|
|
13
|
+
/** One gate per device (and backend): `gate(hasToken, send)` runs `send` under the rule. */
|
|
14
|
+
export const firstCallGate = (): FirstCallGate => {
|
|
15
|
+
/** undefined: no call yet; a promise: the first call is out; null: it came back */
|
|
16
|
+
let first: Promise<void> | null | undefined
|
|
17
|
+
return async (hasToken, send) => {
|
|
18
|
+
if (!hasToken()) {
|
|
19
|
+
if (first) await first
|
|
20
|
+
else if (first === undefined) {
|
|
21
|
+
let release!: () => void
|
|
22
|
+
first = new Promise<void>((r) => { release = r })
|
|
23
|
+
try { return await send() } finally { first = null; release() }
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return send()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// trackGesture: one recognizer for pan, pinch and twist, built on the per-pointer tracks. Every
|
|
2
|
+
// host already sends a touchstart per finger and the SDK keeps a TrackHandler per pointer id
|
|
3
|
+
// (ui/tree.ts, gestures; g2/touch.ts for 2D scenes): this module only listens for those
|
|
4
|
+
// touchstarts, `ev.track(...)`s each finger with its own handler, and turns the live set of
|
|
5
|
+
// fingers into one reading. No runtime, contract or host part; the AR plugin's `attachControls`
|
|
6
|
+
// (plugins/ar/sdk/controls.ts) is the same thing done by hand.
|
|
7
|
+
//
|
|
8
|
+
// Two target shapes, one adapter (`listen`):
|
|
9
|
+
// - `onTouchStart(cb)` — UIButton, UIScreen, UIWidget. Such a listener cannot be removed:
|
|
10
|
+
// `remove()` leaves the callback in place and it ignores every event from then on.
|
|
11
|
+
// - `addEventListener("touchstart", cb)` / `removeEventListener` — Scene, Scene2D, Node,
|
|
12
|
+
// Node2D. `remove()` takes the listener off.
|
|
13
|
+
//
|
|
14
|
+
// The reading (`Gesture`, a fresh object per callback — safe to keep) is an INCREMENT, like a
|
|
15
|
+
// track's own deltaX / deltaY: every onMove reports the change since the previous onMove (or since
|
|
16
|
+
// the gesture started), so applying it to the current camera / transform is one line and there is
|
|
17
|
+
// no start state to keep. Moves arrive per finger, so increments compose: over any run of moves
|
|
18
|
+
// the deltas sum and the scales multiply to the change of the whole set (rotations sum exactly for
|
|
19
|
+
// two fingers, closely for more). Only positions are read, never a track's deltas (the web's are
|
|
20
|
+
// measured from the touch-down).
|
|
21
|
+
// centerX / centerY the centroid of the live fingers (the event's client coordinates, logical px)
|
|
22
|
+
// deltaX / deltaY the centroid's move since the previous onMove
|
|
23
|
+
// scale the mean finger distance to the centroid over the previous one
|
|
24
|
+
// rotation the mean change of each finger's angle about the centroid, radians;
|
|
25
|
+
// positive = clockwise on screen (y grows downward, so atan2(dy, dx) increasing)
|
|
26
|
+
// pointers the number of live fingers
|
|
27
|
+
// One finger is a gesture too: its moves are pans with scale 1 and rotation 0 (a finger AT the
|
|
28
|
+
// centroid has no distance and no angle — that, not a finger count, is what makes them neutral;
|
|
29
|
+
// three and more fingers run the same formulas). A finger joining or leaving re-bases silently: the
|
|
30
|
+
// set's previous positions are its current ones, so the next onMove shows no jump. onStart (the
|
|
31
|
+
// first finger) and onEnd (the last one lifted: the centroid is its end position, pointers 0) carry
|
|
32
|
+
// deltas 0, scale 1, rotation 0. A cancel of any finger cancels the whole gesture: onCancel once,
|
|
33
|
+
// then the remaining fingers are ignored (and a finger landing meanwhile is not taken) until all
|
|
34
|
+
// have lifted; the next touch starts a new gesture. `claim` (default true) goes to every finger's
|
|
35
|
+
// TrackHandler, so a scrollable above never takes the pan.
|
|
36
|
+
|
|
37
|
+
import type { TouchStartEvent, TrackHandler } from "./touch"
|
|
38
|
+
|
|
39
|
+
/** One reading of a gesture, handed to `onStart` / `onMove` / `onEnd` (a fresh object each time). */
|
|
40
|
+
export interface Gesture {
|
|
41
|
+
/** The centroid of the live fingers, in the event's client coordinates (logical px). */
|
|
42
|
+
centerX: number
|
|
43
|
+
centerY: number
|
|
44
|
+
/** The centroid's move since the previous `onMove` (0 on start / end). */
|
|
45
|
+
deltaX: number
|
|
46
|
+
deltaY: number
|
|
47
|
+
/** Mean finger distance to the centroid over the previous one (1 with one finger, 1 on start / end). */
|
|
48
|
+
scale: number
|
|
49
|
+
/** The mean change of each finger's angle about the centroid since the previous `onMove`, radians,
|
|
50
|
+
* positive = clockwise on screen (0 with one finger, 0 on start / end). */
|
|
51
|
+
rotation: number
|
|
52
|
+
/** The number of live fingers (0 in `onEnd`: the last one lifted). */
|
|
53
|
+
pointers: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What `trackGesture` takes: the gesture's callbacks and the claim every finger's track makes. */
|
|
57
|
+
export interface GestureHandler {
|
|
58
|
+
/** Passed to every finger's `ev.track(...)` (default `true`: a scrollable above never takes the pan). */
|
|
59
|
+
claim?: TrackHandler["claim"]
|
|
60
|
+
/** The first finger landed. */
|
|
61
|
+
onStart?(g: Gesture): void
|
|
62
|
+
/** Any finger moved: the change since the previous `onMove`. */
|
|
63
|
+
onMove?(g: Gesture): void
|
|
64
|
+
/** The last finger lifted. */
|
|
65
|
+
onEnd?(g: Gesture): void
|
|
66
|
+
/** The host cancelled one of the fingers: the whole gesture is over. */
|
|
67
|
+
onCancel?(): void
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Anything that hands out touchstarts: a UI element (`onTouchStart`) or a scene / node
|
|
71
|
+
* (`addEventListener("touchstart", …)`). */
|
|
72
|
+
export type GestureTarget =
|
|
73
|
+
| { onTouchStart(callback: (ev: TouchStartEvent<any>) => void): unknown }
|
|
74
|
+
| {
|
|
75
|
+
addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void
|
|
76
|
+
removeEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<any>) => void): void
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
type Finger = { id: number, x: number, y: number }
|
|
80
|
+
|
|
81
|
+
// A finger this close to the centroid (logical px) has no angle, and a set this tight has no scale.
|
|
82
|
+
const EPS = 1e-3
|
|
83
|
+
|
|
84
|
+
/** Start listening on `target`; returns the off switch. */
|
|
85
|
+
const listen = (target: GestureTarget, cb: (ev: TouchStartEvent<any>) => void): (() => void) => {
|
|
86
|
+
if ("addEventListener" in target) {
|
|
87
|
+
target.addEventListener("touchstart", cb)
|
|
88
|
+
return () => target.removeEventListener("touchstart", cb)
|
|
89
|
+
}
|
|
90
|
+
target.onTouchStart(cb)
|
|
91
|
+
return () => {}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Follow pan, pinch and twist on `target` as one gesture: every finger that lands on it is tracked,
|
|
96
|
+
* and `onMove` reports the change of the whole set since the previous move — the centroid's move,
|
|
97
|
+
* the spread's ratio, the twist's angle. One finger is a gesture too (a pan, scale 1, rotation 0).
|
|
98
|
+
*
|
|
99
|
+
* ```ts
|
|
100
|
+
* trackGesture(scene, {
|
|
101
|
+
* onMove(g) { zoom *= g.scale; angle += g.rotation; panX += g.deltaX; panY += g.deltaY },
|
|
102
|
+
* })
|
|
103
|
+
* ```
|
|
104
|
+
*/
|
|
105
|
+
export const trackGesture = (target: GestureTarget, handler: GestureHandler): { remove(): void } => {
|
|
106
|
+
const fingers: Finger[] = []
|
|
107
|
+
let cancelled = false // a finger was cancelled: ignore the rest until all have lifted
|
|
108
|
+
let removed = false
|
|
109
|
+
|
|
110
|
+
const drop = (f: Finger): boolean => {
|
|
111
|
+
const i = fingers.indexOf(f)
|
|
112
|
+
if (i < 0) return false
|
|
113
|
+
fingers.splice(i, 1)
|
|
114
|
+
if (fingers.length === 0) cancelled = false
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One finger moved from where it was to (x, y): the set's change, before → after.
|
|
119
|
+
const step = (f: Finger, x: number, y: number): Gesture => {
|
|
120
|
+
const n = fingers.length
|
|
121
|
+
let sx = 0, sy = 0
|
|
122
|
+
for (const p of fingers) { sx += p.x; sy += p.y }
|
|
123
|
+
const c0x = sx / n, c0y = sy / n
|
|
124
|
+
const c1x = (sx - f.x + x) / n, c1y = (sy - f.y + y) / n
|
|
125
|
+
let d0 = 0, d1 = 0, turn = 0, turned = 0
|
|
126
|
+
for (const p of fingers) {
|
|
127
|
+
const ax = p.x - c0x, ay = p.y - c0y
|
|
128
|
+
const bx = (p === f ? x : p.x) - c1x, by = (p === f ? y : p.y) - c1y
|
|
129
|
+
const r0 = Math.hypot(ax, ay), r1 = Math.hypot(bx, by)
|
|
130
|
+
d0 += r0
|
|
131
|
+
d1 += r1
|
|
132
|
+
if (r0 > EPS && r1 > EPS) {
|
|
133
|
+
let a = Math.atan2(by, bx) - Math.atan2(ay, ax)
|
|
134
|
+
if (a > Math.PI) a -= 2 * Math.PI
|
|
135
|
+
else if (a <= -Math.PI) a += 2 * Math.PI
|
|
136
|
+
turn += a
|
|
137
|
+
turned++
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
f.x = x
|
|
141
|
+
f.y = y
|
|
142
|
+
return {
|
|
143
|
+
centerX: c1x, centerY: c1y, deltaX: c1x - c0x, deltaY: c1y - c0y,
|
|
144
|
+
scale: d0 > EPS * n ? d1 / d0 : 1, rotation: turned ? turn / turned : 0, pointers: n,
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const onTouch = (ev: TouchStartEvent<any>): void => {
|
|
149
|
+
if (removed || cancelled) return
|
|
150
|
+
// A pointer id already in the set never got its end (its tracker was replaced): the new touch
|
|
151
|
+
// takes its place in the same gesture.
|
|
152
|
+
const stale = fingers.findIndex((p) => p.id === ev.pointerId)
|
|
153
|
+
if (stale >= 0) fingers.splice(stale, 1)
|
|
154
|
+
const first = fingers.length === 0 && stale < 0
|
|
155
|
+
const f: Finger = { id: ev.pointerId, x: ev.clientX, y: ev.clientY }
|
|
156
|
+
fingers.push(f)
|
|
157
|
+
ev.track({
|
|
158
|
+
claim: handler.claim ?? true,
|
|
159
|
+
// only when the gesture has an onMove: otherwise the host never crosses into JS for the moves
|
|
160
|
+
onMove: handler.onMove ? (pos) => {
|
|
161
|
+
if (removed || cancelled || fingers.indexOf(f) < 0) return
|
|
162
|
+
handler.onMove?.(step(f, pos.clientX, pos.clientY))
|
|
163
|
+
} : undefined,
|
|
164
|
+
onEnd: (pos) => {
|
|
165
|
+
if (removed) return
|
|
166
|
+
const live = !cancelled
|
|
167
|
+
if (!drop(f) || !live || fingers.length > 0) return
|
|
168
|
+
handler.onEnd?.({ centerX: pos.clientX, centerY: pos.clientY, deltaX: 0, deltaY: 0, scale: 1, rotation: 0, pointers: 0 })
|
|
169
|
+
},
|
|
170
|
+
onCancel: () => {
|
|
171
|
+
if (removed || fingers.indexOf(f) < 0) return
|
|
172
|
+
const was = cancelled
|
|
173
|
+
cancelled = true
|
|
174
|
+
drop(f)
|
|
175
|
+
if (!was) handler.onCancel?.()
|
|
176
|
+
},
|
|
177
|
+
})
|
|
178
|
+
if (first) handler.onStart?.({ centerX: f.x, centerY: f.y, deltaX: 0, deltaY: 0, scale: 1, rotation: 0, pointers: 1 })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const off = listen(target, onTouch)
|
|
182
|
+
return {
|
|
183
|
+
remove() {
|
|
184
|
+
if (removed) return
|
|
185
|
+
removed = true
|
|
186
|
+
fingers.length = 0
|
|
187
|
+
off()
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|