lecodes-sdk 2.8.0 → 2.8.2

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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.18.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.8.0",
46
+ "version": "2.8.2",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -317,6 +317,10 @@ declare global {
317
317
  /** @count count=pages */
318
318
  staticsBuildKind?(kind: u32, templateEntity: u32, pages: Uint32Array): u32
319
319
  staticsEnd?(): u32
320
+ /** The cached placement an engine-built static came from: its full node path, "" = not one. The paths outlive
321
+ * `staticsEnd` (until the next `staticsBegin`; a destroyed entity is forgotten) so gl/Node.ts `nodeOf` can give
322
+ * a hit wall its passive aspects from the scene's def. */
323
+ staticsPathOf?(entityId: u32): string
320
324
  setCulling(entityId: u32, culling: boolean): void
321
325
  /** Filament RenderableManager::setPriority — the coarse draw order, 0..7 (Mesh.renderPriority).
322
326
  * Optional: an older host leaves everything at the default 4. */
@@ -912,6 +916,9 @@ declare global {
912
916
  physicsCreateBody(entityId: u32, shapeId: u32, motion: i32, mass: f32, sensor: boolean, pickOnly: boolean): u32
913
917
  /** @ifdef CREATOR_GL_PHYSICS */
914
918
  physicsSetPickable(bodyId: u32, pickable: boolean): void
919
+ /** The motion of the body on `entityId`: 0 static, 1 kinematic, 2 dynamic, -1 no body — read off the engine, so it
920
+ * answers for a body the SDK never made (a static the level cache built). @ifdef CREATOR_GL_PHYSICS */
921
+ physicsMotionOf?(entityId: u32): i32
915
922
  /** Surface friction of one body (0 = ice, ~1 = grippy asphalt). Values COMBINE as sqrt(a * b), so
916
923
  * a low value on either side dominates. New bodies start at 0.6 — a neutral solid surface.
917
924
  * The vehicle wheel cast reads the GROUND body's value — this is what caps a car's cornering. @ifdef CREATOR_GL_PHYSICS */
@@ -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. */
@@ -533,6 +533,13 @@ export abstract class Aspect<K extends string, P = Node, E extends EventMap = {}
533
533
  static after?: Function[]
534
534
  /** Tick BEFORE these classes within the same phase (`static before = [Camera]`). */
535
535
  static before?: Function[]
536
+ /** DATA ONLY. A `use(...)` of this aspect in a scene file leaves its node STATIC for the level cache (gl/Statics.ts):
537
+ * the engine builds the node without a JS object, and the aspect is attached the first time a hit, a contact or
538
+ * a pick reaches the node — `hit.node.get(Surface)` answers then. So: fields and the methods the game calls on a
539
+ * hit (a tag like `Surface { kind }`, a breakable's hit points), no `update()` (`use()` refuses one) and nothing in
540
+ * `onAttach()` the level needs from its first frame. Without it a custom aspect keeps its node on the JS path,
541
+ * built at load like any mover. */
542
+ static passive?: boolean
536
543
 
537
544
  /** Every live instance of an aspect/system class, in attach order — the registry a game used to
538
545
  * hand-roll as `static all[]`. Live, read-only: copy it (`[...Aspect.all(Barrel)]`) before a loop
package/src/gl/Node.ts CHANGED
@@ -51,8 +51,14 @@ const registerLoose = (n: Node): void => {
51
51
  sinceSweep = 0
52
52
  for (const [id, ref] of loose) { const w = ref.deref(); if (!w || w._h.id !== id) loose.delete(id) }
53
53
  }
54
+ /** @internal The static level cache's hand into `nodeOf` (gl/Statics.ts sets it while a cached level is up):
55
+ * `pathOf` names the placement an engine-built entity came from ("" = not one), `attach` gives that entity's
56
+ * fresh wrapper the PASSIVE aspects of the placement's def (Aspect.passive). A wall the engine built has no JS
57
+ * node until a hit, a contact or a pick reaches it — this is where it gets one, and its `Surface`, and only then. */
58
+ export const lazyPlacements: { current: { pathOf(id: number): string, attach(node: Node, path: string): void } | null } = { current: null }
59
+
54
60
  /** @internal The wrapper for an entity id the runtime handed back: the pinned one, else the
55
- * app's live loose handle, else a throwaway over the engine-owned id. */
61
+ * app's live loose handle, else a wrapper over the engine-owned id (pinned: the next ask finds it). */
56
62
  export const nodeOf = (id: number): Node => {
57
63
  const pinned = nodes.get(id)
58
64
  if (pinned) return pinned
@@ -62,7 +68,18 @@ export const nodeOf = (id: number): Node => {
62
68
  if (w && w._h.id === id) return w
63
69
  loose.delete(id)
64
70
  }
65
- return new Node(id)
71
+ const n = new Node(id)
72
+ const lazy = lazyPlacements.current
73
+ if (lazy) { const path = lazy.pathOf(id); if (path) lazy.attach(n, path) }
74
+ return n
75
+ }
76
+
77
+ /** @internal `nodeOf(id)` when `id` is a placement the level cache built, else null — a hit inside a model walks
78
+ * up to the placement's own entity (gl/touch.ts). */
79
+ export const placementNodeOf = (id: number): Node | null => {
80
+ if (nodes.get(id)) return nodeOf(id)
81
+ const lazy = lazyPlacements.current
82
+ return lazy && lazy.pathOf(id) ? nodeOf(id) : null
66
83
  }
67
84
 
68
85
  export type NodeEvents = {
@@ -472,6 +489,8 @@ export class Node extends AspectHost<NodeEvents> implements CompWriter {
472
489
 
473
490
  /** @internal — emit a physics contact/trigger event (the physics-event router can't reach dispatch). */
474
491
  _emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }
492
+ /** @internal — does anyone listen on `channel` (the physics-event router asks before it makes a wrapper). */
493
+ _hasListeners(channel: keyof NodeEvents): boolean { return this.hasListeners(channel) }
475
494
 
476
495
 
477
496
  /** Destroy this node and its whole subtree. Every aspect in the subtree is detached first (its
package/src/gl/Statics.ts CHANGED
@@ -24,7 +24,7 @@
24
24
 
25
25
  import { fetch, assetNameOf, assetUrlOf, type FetchResponse } from "../runtime/fetch"
26
26
  import { Model, foliageKindOf, type FoliageKind } from "./Model"
27
- import { Node } from "./Node"
27
+ import { Node, lazyPlacements } from "./Node"
28
28
  import { Scene } from "./Scene"
29
29
  import { Physics } from "./physics/Physics"
30
30
  import { Shape } from "./physics/Shape"
@@ -57,11 +57,49 @@ const movesByPhysics = (def: SceneNodeDef): boolean =>
57
57
  ((e.ctor as unknown) === Physics && ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") !== "static")
58
58
  || (e.ctor as unknown) === CharacterController)
59
59
 
60
+ /** A passive aspect (Aspect.passive): data the engine does not need — attached to the placement's wrapper on the
61
+ * first hit (`attachPassive`), never built at load. */
62
+ export const isPassive = (e: AspectEntry<any>): boolean => (e.ctor as { passive?: boolean }).passive === true
63
+
60
64
  const dataAspect = (e: AspectEntry<any>): boolean => {
61
65
  const c = e.ctor as unknown
62
66
  if (c === Shape) return true
63
67
  if (c === Physics) return ((e.props as { motion?: string } | undefined)?.motion ?? "dynamic") === "static"
64
- return false
68
+ return isPassive(e)
69
+ }
70
+
71
+ /** Attach the passive aspects of `def` to `node` (props as written: a passive aspect is data, `ref()`s are not
72
+ * resolved). Who gets them: the entity a hit comes back with. A placement's clone, on the first hit, through
73
+ * `lazyPlacements` — a covered root with a model included, since its clone is what the ray strikes, not the plain
74
+ * JS node the clone hangs under. A root WITHOUT a model (a group tagged `Surface` over its walls) has no clone:
75
+ * its JS node takes them at load, and a hit on a wall under it finds them one `parent` up (a game's surface lookup
76
+ * walks `hit.node.parent`). */
77
+ export const attachPassive = (node: Node, def: SceneNodeDef): void => {
78
+ for (const e of def.aspects ?? []) if (isPassive(e)) (node as Node & { aspect(c: unknown, p?: unknown): unknown }).aspect(e.ctor, e.props)
79
+ }
80
+
81
+ /** The covered placements that carry passive aspects, by path: every def under the roots, and a root itself when
82
+ * it has a model (its clone is a placement at the root's own path). A model-less root is not here — `attachPassive`
83
+ * put its tags on its JS node at load. */
84
+ const passiveByPath = (roots: CoveredRoot[], scene: SceneDef): Map<string, SceneNodeDef> => {
85
+ const out = new Map<string, SceneNodeDef>()
86
+ const walk = (defs: Record<string, SceneNodeDef> | undefined, prefix: string, stack: Set<SceneDef>): void => {
87
+ for (const [ name, nd ] of Object.entries(defs ?? {})) {
88
+ const path = prefix === "" ? name : `${prefix}/${name}`
89
+ if ((nd.aspects ?? []).some(isPassive)) out.set(path, nd)
90
+ walk(nd.children, path, stack)
91
+ const pdef = nd.prefab !== undefined ? (nd.prefab as { def?: SceneDef }).def : undefined
92
+ if (pdef && !stack.has(pdef)) walk(pdef.nodes, path, new Set(stack).add(pdef))
93
+ }
94
+ }
95
+ const stack = new Set<SceneDef>([ scene ])
96
+ for (const r of roots) {
97
+ if (r.def.model !== undefined && (r.def.aspects ?? []).some(isPassive)) out.set(r.path, r.def)
98
+ walk(r.def.children, r.path, stack)
99
+ const pdef = r.def.prefab !== undefined ? (r.def.prefab as { def?: SceneDef }).def : undefined
100
+ if (pdef && !stack.has(pdef)) walk(pdef.nodes, r.path, new Set(stack).add(pdef))
101
+ }
102
+ return out
65
103
  }
66
104
 
67
105
  const cacheable = (def: SceneNodeDef, stack: Set<SceneDef>): boolean => {
@@ -288,8 +326,16 @@ const templates: Model[] = []
288
326
  export const build = async (
289
327
  opened: OpenedCache, scene: Scene, roots: Node[], pages: Uint32Array,
290
328
  template: (url: string, lightmap: boolean, foliage: FoliageKind) => Promise<Model>,
329
+ covered: CoveredRoot[], def: SceneDef,
291
330
  ): Promise<number> => {
292
331
  const sceneId = (scene as unknown as { _id: number })._id
332
+ // the hand into nodeOf for this level's life (the next cached level replaces it; a level without passive
333
+ // aspects under its roots installs none — nodeOf then costs what it did)
334
+ const passive = passiveByPath(covered, def)
335
+ lazyPlacements.current = passive.size > 0 && _creator.staticsPathOf ? {
336
+ pathOf: (id) => _creator.staticsPathOf!(id),
337
+ attach: (node, path) => { const d = passive.get(path); if (d) attachPassive(node, d) },
338
+ } : null
293
339
  const kinds = _creator.staticsBegin!(sceneId, Uint32Array.from(roots.map((r) => r.id)), (opened.resp as unknown as { _h: { id: number } })._h.id)
294
340
  if (kinds !== opened.kinds.length) { console.warn(`[static] the engine declined the cache (${kinds} of ${opened.kinds.length} kinds)`); _creator.staticsEnd!(); return 0 }
295
341
  await Promise.all(opened.kinds.map(async (k, i) => {
@@ -33,7 +33,9 @@
33
33
  // scaled directly instead of needing a child node for the visuals (a capsule is symmetric about its up
34
34
  // axis, so its yaw is physically irrelevant either way).
35
35
  //
36
- // CROUCHING goes through the Shape aspect (`node.aspect(Shape, { capsule: … })`) — see `resizing`.
36
+ // CROUCHING is a property write on the Shape aspect (`hero.shape.capsule = …`) — see `resizing`. The
37
+ // node (the capsule's centre) has moved by the feet difference when the write returns; a first-person
38
+ // camera is placed from the FEET plus an eased eye height, never from the node (docs/3d/physics.md).
37
39
  //
38
40
  // Note: it collides with solid bodies but PASSES THROUGH triggers, and (v1) is not itself detected by
39
41
  // triggers and isn't pointer-pickable while active.
@@ -160,13 +162,18 @@ export class CharacterController extends Aspect<"controller", Node> implements C
160
162
  * True while a requested collider resize hasn't taken — you asked to stand up and there is something
161
163
  * overhead. Resizing goes through the `Shape` aspect itself:
162
164
  *
163
- * hero.aspect(Shape, { capsule: CROUCHED }) // always fits — you are shrinking
164
- * hero.aspect(Shape, { capsule: STANDING }) // may be refused under a low ceiling
165
- * if (hero.controller.resizing) … // still crouched; call it again next frame
165
+ * hero.shape.capsule = CROUCHED // always fits — you are shrinking
166
+ * hero.shape.capsule = STANDING // may be refused under a low ceiling
167
+ * if (hero.controller.resizing) … // still crouched; write it again next frame
166
168
  *
167
169
  * A refusal changes nothing, so the retry is just the same call again — and it is an exact headroom
168
170
  * test against the real capsule, unlike a hand-rolled raycast (a ray is a line; a capsule has girth).
169
- * The FEET stay planted across a resize, so the character neither hovers nor sinks.
171
+ * The FEET stay planted across a resize, so the character neither hovers nor sinks — which means the
172
+ * node (the capsule's centre) has ALREADY moved by the feet difference when the write returns. Place
173
+ * a first-person camera from the feet (`node.y − (halfHeight + radius)` of the capsule worn) plus an
174
+ * eased eye height; placed from the node it pops by that difference. Write the property: a second
175
+ * `node.aspect(Shape, …)` builds a fresh Shape (it is handed to the character too, but it is a
176
+ * rebuild per stance, not a resize).
170
177
  */
171
178
  get resizing(): boolean {
172
179
  return this.node.get(Shape)?._resizeRefused ?? false
@@ -15,7 +15,7 @@ import { Aspect } from "../../core/Aspect"
15
15
  import type { FieldMeta } from "../../core/fields"
16
16
  import type { CompAxis, CompWriter } from "../../core/compWrite"
17
17
  import { Vec3, cx, cy, cz, type Vec3Like } from "../../math/vec"
18
- import { nodes, type Node } from "../Node"
18
+ import { nodeOf, type Node } from "../Node"
19
19
  import { Shape } from "./Shape"
20
20
 
21
21
  export type MotionType = "static" | "dynamic" | "kinematic"
@@ -214,13 +214,27 @@ export class Physics extends Aspect<"physics", Node> implements CompWriter {
214
214
  return typeof _creator !== "undefined" && !!_creator && typeof _creator.physicsHasSupport === "function" && _creator.physicsHasSupport()
215
215
  }
216
216
 
217
- /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
217
+ /** The motion of the body on `node`, read off the engine: it answers for a body the SDK never made (a static the
218
+ * level cache built, which has no `Physics` aspect until a hit reaches it, and no body-making one ever). null = no
219
+ * body on this node. The way to tell a wall from a prop on a hit: `Physics.motionOf(hit.node) === "static"`. */
220
+ static motionOf(node: Node | null): MotionType | null {
221
+ if (!node) return null
222
+ if (_creator.physicsMotionOf) {
223
+ const m = _creator.physicsMotionOf(node.id)
224
+ return m === 0 ? "static" : m === 1 ? "kinematic" : m === 2 ? "dynamic" : null
225
+ }
226
+ return node.get(Physics)?.motion ?? null // an older host: the aspect is all there is
227
+ }
228
+
229
+ /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. `node` is the body's
230
+ * node — for a static the level cache built, a wrapper made on this hit (Node.ts nodeOf), carrying the placement's
231
+ * passive aspects; its `Physics` is read with `Physics.motionOf`. */
218
232
  static raycast(origin: Vec3Like, dir: Vec3Like, maxDist = 1000): RayHit | null {
219
233
  const out = new Float32Array(7)
220
234
  const id = _creator.physicsRaycast(cx(origin), cy(origin), cz(origin), cx(dir), cy(dir), cz(dir), maxDist, out)
221
235
  if (!id) return null
222
236
  return {
223
- node: nodes.get(id) ?? null,
237
+ node: nodeOf(id),
224
238
  point: new Vec3(out[0], out[1], out[2]),
225
239
  normal: new Vec3(out[3], out[4], out[5]),
226
240
  fraction: out[6],
@@ -127,14 +127,25 @@ export class Shape extends Aspect<"shape", Node> {
127
127
  onAttach(): void {
128
128
  if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
129
129
  this._shapeId = this._build()
130
- if (this._shapeId) this._createPickBody()
130
+ if (!this._shapeId) return
131
+ // A fresh Shape on a node a live CharacterController owns: `node.aspect(Shape, { capsule })`
132
+ // RE-ATTACHES (aspect() always builds), which is how a game is tempted to crouch. Hand the geometry
133
+ // to the character exactly as a property write does. A pick body here would instead take the
134
+ // node's position routing away from the character, and the capsule it wears would never change —
135
+ // the player's "how tall am I" switches while the body stays, and the eye pops by the difference.
136
+ if (this.node._xfKind === 1) {
137
+ if (!this._offerToCharacter(this.node._xf, this._shapeId)) this._sig = "" // uncommitted: the next write retries
138
+ return
139
+ }
140
+ this._createPickBody()
131
141
  }
132
142
 
133
143
  onDetach(): void {
134
144
  if (this._bodyId) { _creator.physicsRemoveBody(this._bodyId); this._bodyId = 0 }
135
145
  if (this._shapeId) { _creator.physicsDestroyShape(this._shapeId); this._shapeId = 0 }
136
- this.node._xf = 0
137
- this.node._xfKind = 0
146
+ // The routing is this Shape's to clear only while one of ITS bodies carries it. A live
147
+ // CharacterController keeps its own, and the geometry it wears (Jolt holds a reference to it).
148
+ if (this.node._xfKind !== 1) { this.node._xf = 0; this.node._xfKind = 0 }
138
149
  }
139
150
 
140
151
  /** A dimension, the kind or the origin was written: rebuild the geometry when it actually changed.
@@ -298,17 +309,12 @@ export class Shape extends Aspect<"shape", Node> {
298
309
  // and tries again. That retry is the headroom test, and it is exact (the real capsule, not a ray).
299
310
  const charId = this.node._xfKind === 1 ? this.node._xf : 0
300
311
  if (charId) {
301
- if (_creator.characterSetShape?.(charId, id)) {
312
+ if (this._offerToCharacter(charId, id)) {
302
313
  this._shapeId = id
303
- this._resizeRefused = false
304
314
  _creator.physicsDestroyShape(previous)
305
315
  } else {
306
316
  _creator.physicsDestroyShape(id) // drop what we just built; the old collider stays live
307
317
  this._sig = previousSig
308
- this._resizeRefused = !!_creator.characterSetShape
309
- if (!_creator.characterSetShape) {
310
- console.warn(`Shape: this host can't resize a live CharacterController — "${this.node.name}" keeps its current collider`)
311
- }
312
318
  }
313
319
  return
314
320
  }
@@ -327,6 +333,20 @@ export class Shape extends Aspect<"shape", Node> {
327
333
  * is still using another. Surfaced as `controller.resizing`. */
328
334
  _resizeRefused = false
329
335
 
336
+ /** The character this node belongs to tries the geometry on. Jolt validates the fit (standing up
337
+ * under a pipe) and can refuse; a refusal changes nothing in the engine. The feet stay planted, so
338
+ * on success the node has already moved by the change in the shape's lower bound when this returns. */
339
+ private _offerToCharacter(charId: number, shapeId: number): boolean {
340
+ if (!_creator.characterSetShape) {
341
+ console.warn(`Shape: this host can't resize a live CharacterController — "${this.node.name}" keeps its current collider`)
342
+ this._resizeRefused = false
343
+ return false
344
+ }
345
+ const ok = _creator.characterSetShape(charId, shapeId)
346
+ this._resizeRefused = !ok
347
+ return ok
348
+ }
349
+
330
350
  private _build(): number {
331
351
  this._autoOrigin = null
332
352
  this._sig = this._signature()
@@ -2,7 +2,7 @@
2
2
  // is delivered to BOTH nodes with the other as the argument. type: 0 = enter (contact/overlap began),
3
3
  // 1 = exit (ended). Registered lazily on the first trigger/physics body or 'enter'/'exit' listener.
4
4
 
5
- import { nodes } from "../Node"
5
+ import { nodes, nodeOf } from "../Node"
6
6
 
7
7
  let registered = false
8
8
  export const ensurePhysicsEvents = (): void => {
@@ -10,10 +10,15 @@ export const ensurePhysicsEvents = (): void => {
10
10
  if (!_creator.setOnPhysicsEvent) return // physics-less build / bridge without the hook
11
11
  registered = true
12
12
  _creator.setOnPhysicsEvent((a, b, type) => {
13
- const na = nodes.get(a)
14
- const nb = nodes.get(b)
15
- if (!na || !nb) return
13
+ const ka = nodes.get(a)
14
+ const kb = nodes.get(b)
16
15
  const channel = type === 1 ? "exit" : "enter"
16
+ // nobody listening on either side: nothing to deliver, and no wrapper made for the other side (a car's
17
+ // wheels touch every tile of a road the level cache built — a wrapper each would be 15k for nothing)
18
+ if (!ka?._hasListeners(channel) && !kb?._hasListeners(channel)) return
19
+ // the side nobody holds (a static the engine built) gets its wrapper now, passive aspects and all (Node.ts nodeOf)
20
+ const na = ka ?? nodeOf(a)
21
+ const nb = kb ?? nodeOf(b)
17
22
  na._emitCollision(channel, nb)
18
23
  nb._emitCollision(channel, na)
19
24
  })
package/src/gl/touch.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { ClickEvent, TouchStartEvent, type TrackHandler } from "../runtime/touch"
5
5
  import { ensureEmitter, setSceneTouchHandlers } from "../ui/tree"
6
- import { nodes } from "./Node"
6
+ import { nodes, placementNodeOf } from "./Node"
7
7
  import { glState } from "./state"
8
8
 
9
9
  // Hits inside a loaded model (GLB) land on sub-entities the user never created a Node for — walk up
@@ -15,6 +15,9 @@ const resolveHitNode = (entityId: number) => {
15
15
  if (id === 0) break
16
16
  node = nodes.get(id)
17
17
  }
18
+ // nobody holds any of them: a static the level cache built — its placement's entity up the chain gets a wrapper
19
+ // now (Node.ts nodeOf), passive aspects and all
20
+ for (let id = entityId, i = 0; !node && id !== 0 && i < 32; i++, id = _creator.getParent(id)) node = placementNodeOf(id) ?? undefined
18
21
  return node ?? null
19
22
  }
20
23
 
package/src/inject.ts CHANGED
@@ -63,6 +63,8 @@ export { audio, type PlaySoundOptions, type SpatialOptions, type Rolloff, type A
63
63
  export { openFilePicker, files, type FileData } from "./runtime/files"
64
64
  export { share } from "./runtime/share"
65
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"
66
68
 
67
69
  // ---- 2D engine (creator-2d) ----
68
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, the System its per-frame work rides on (the scene's first frame).
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)`): the wrapper pins it on the first
16
- // horizontal plane the session reports, so the halves know no root.
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's pose: where `root` is pinned. */
295
- private _floor: ARPose | null = null
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 floor: an anchor pinned on the first horizontal plane the session finds (it fires `track`
332
- * then). Until that it is the world's origin — where the session began. Not in "images" mode. */
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
- if (this._floor) this._pinRoot(this._floor)
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` onto the floor (the first horizontal plane of this session). */
683
- private _pinRoot(pose: ARPose): void {
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, where root is pinned
745
- if (added && p.type === "horizontal" && !this._floor) {
746
- this._floor = pose
747
- if (this._root) this._pinRoot(pose)
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
  }