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/dist/global.d.ts +4 -0
- package/dist/types/core/Aspect.d.ts +7 -0
- package/dist/types/gl/Statics.d.ts +12 -1
- package/dist/types/gl/physics/CharacterController.d.ts +9 -4
- package/dist/types/gl/physics/Physics.d.ts +7 -1
- package/dist/types/gl/physics/Shape.d.ts +4 -0
- package/dist/types/inject.d.ts +1 -0
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
- package/dist/types/runtime/gestures.d.ts +52 -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 +1 -1
- package/src/bridges/gl.d.ts +7 -0
- package/src/bridges/tree.d.ts +14 -4
- package/src/core/Aspect.ts +7 -0
- package/src/gl/Node.ts +21 -2
- package/src/gl/Statics.ts +48 -2
- package/src/gl/physics/CharacterController.ts +12 -5
- package/src/gl/physics/Physics.ts +17 -3
- package/src/gl/physics/Shape.ts +29 -9
- package/src/gl/physics/physicsEvents.ts +9 -4
- package/src/gl/touch.ts +4 -1
- package/src/inject.ts +2 -0
- package/src/plugin.ts +3 -1
- package/src/plugins/gen/ar/sdk/ar.ts +59 -14
- package/src/runtime/gestures.ts +190 -0
- package/src/scene/defineScene.ts +5 -2
- package/src/scene/grammar.ts +10 -2
- 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
package/src/bridges/gl.d.ts
CHANGED
|
@@ -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 */
|
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. */
|
package/src/core/Aspect.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
164
|
-
* hero.
|
|
165
|
-
* if (hero.controller.resizing) … // still crouched;
|
|
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 {
|
|
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
|
-
/**
|
|
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:
|
|
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],
|
package/src/gl/physics/Shape.ts
CHANGED
|
@@ -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)
|
|
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.
|
|
137
|
-
|
|
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 (
|
|
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
|
|
14
|
-
const
|
|
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
|
|
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
|
}
|