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.
@@ -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
+ }
@@ -53,7 +53,7 @@ import { Model, foliageKindOf, type FoliageKind } from "../gl/Model"
53
53
  import { Physics } from "../gl/physics/Physics"
54
54
  import { CharacterController } from "../gl/physics/CharacterController"
55
55
  import { Lightmap, type LightmapDef, type LightmapPrepared } from "../gl/Lightmap"
56
- import { coveredRoots, defsDigest, bakeDigest, findNode, open as staticsOpen, build as staticsBuild, snapshot as staticsSnapshot, staticsConfig, toolRun, type CoveredRoot, type OpenedCache } from "../gl/Statics"
56
+ import { coveredRoots, defsDigest, bakeDigest, findNode, open as staticsOpen, build as staticsBuild, snapshot as staticsSnapshot, staticsConfig, toolRun, attachPassive, type CoveredRoot, type OpenedCache } from "../gl/Statics"
57
57
  import { Foliage, type FoliageOptions } from "../gl/Foliage"
58
58
  import { NavMesh } from "../gl/nav/NavMesh"
59
59
  import { Terrain, type TerrainLayer, type TerrainRegion, type TerrainOptions } from "../gl/terrain/Terrain"
@@ -918,6 +918,9 @@ const buildNodes = async (
918
918
  if (parentNode) attachHost(parentNode, nd, path).add(node)
919
919
  scene.add(node)
920
920
  applyNode(node, name, nd)
921
+ // a root with a model is itself a placement: its clone takes the tags on the first hit (Statics.ts). A group
922
+ // root has no clone — its tags go here, one `parent` up from a hit on a wall under it
923
+ if (nd.model === undefined) attachPassive(node, nd)
921
924
  cov.node = node
922
925
  nodes[path] = node
923
926
  return
@@ -1028,7 +1031,7 @@ const instantiate = async (def: SceneDef, editorRuns: EditorRun[]): Promise<{ sc
1028
1031
  let cachedMade = 0
1029
1032
  if (opened) {
1030
1033
  const pages = prepared ? Lightmap.pageIds(prepared) : new Uint32Array(0)
1031
- cachedMade = await staticsBuild(opened, scene, roots.map((r) => covered.get(r.path)!.node!), pages, templateModel)
1034
+ cachedMade = await staticsBuild(opened, scene, roots.map((r) => covered.get(r.path)!.node!), pages, templateModel, roots, def)
1032
1035
  }
1033
1036
  const nodesMs = Date.now() - tLoad
1034
1037
  if (navmesh && !(EDITOR && isEditMode())) registerNavOverrides(def.nodes, nodes)
@@ -17,8 +17,16 @@ export type AspectEntry<A extends Aspect<any, any> = Aspect<any, any>> = {
17
17
 
18
18
  /** Reference an aspect in a scene file: `aspects: [use(Physics, { motion: 'static' })]`. Props are
19
19
  * typechecked against the aspect's fields, exactly like `node.aspect(Ctor, props)`. */
20
- export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> =>
21
- ({ __use: true, ctor, props })
20
+ export const use = <A extends Aspect<any, any>>(ctor: AspectCtor<A>, props?: Partial<A>): AspectEntry<A> => {
21
+ // a passive aspect (Aspect.passive) is data: the level cache attaches it on the first hit, so an update() on it
22
+ // would never have run from the level's first frame — refused here, at the scene's own line, not in a raycast
23
+ if ((ctor as { passive?: boolean }).passive === true) {
24
+ const proto = (ctor as unknown as { prototype: Record<string, unknown> }).prototype
25
+ if (typeof proto.update === "function" || typeof proto.updateBefore === "function" || typeof proto.updateFixed === "function")
26
+ throw new Error(`use(${(ctor as { name?: string }).name ?? "?"}): a passive aspect carries data only — it cannot have update(); drop \`static passive\` or the update`)
27
+ }
28
+ return { __use: true, ctor, props }
29
+ }
22
30
 
23
31
  /** A `make(fn, args)` source entry — see {@link make}. `N` is the node family the factory returns
24
32
  * (3D `Node` by default; a `.scene2d.ts` file's factories return `Node2D`). */
package/src/ui/UIModal.ts CHANGED
@@ -23,6 +23,14 @@ export type UIModalShow = (() => boolean) | Signal<boolean>
23
23
  const MODAL_ANIM_MS = 200
24
24
  const SHOW_BINDING = "#show"
25
25
 
26
+ // The transition period's `display` on a modal: said once per run, not per write.
27
+ let displayWarned = false
28
+ function warnDisplayDeprecated(): void {
29
+ if (displayWarned) return
30
+ displayWarned = true
31
+ console.warn("deprecated: a modal's `display` — show it with show() / hide(), or bind it with bindShow(() => …) (+ onDismiss for the scrim tap / back); `display` on a modal will throw again")
32
+ }
33
+
26
34
  /**
27
35
  * A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
28
36
  * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), is HIDDEN until shown, animates on
@@ -38,9 +46,10 @@ const SHOW_BINDING = "#show"
38
46
  * request, `onDismiss`. By default it hides an unbound modal and writes `false` into a bound
39
47
  * signal; a bound function has nowhere to write, so a modal bound to one without `onDismiss`
40
48
  * cannot be dismissed (a forced choice — the same as `dismissible(false)`). `display` is not a
41
- * modal's switch: a `.style({ display })` on it throws (a bound display died on the first
42
- * dismissal, which wrote a static `display: "none"` over the binding — the dialog never opened
43
- * again).
49
+ * modal's switch (a bound display died on the first dismissal, which wrote a static
50
+ * `display: "none"` over the binding — the dialog never opened again). Deprecated, for the
51
+ * transition period a `.style({ display })` is still accepted, with a warning: a function becomes
52
+ * `bindShow(() => fn() !== "none")`, a value a show() / hide(); it will throw again.
44
53
  */
45
54
  export interface UIModal extends UIWidget {
46
55
  /**
@@ -107,14 +116,40 @@ export class ModalElement extends WidgetElement {
107
116
  this._syncPose()
108
117
  }
109
118
 
110
- /** @internal `display` is not a modal's switch. Refused on every write of the app's — `.style({
111
- * display })`, `el.style.display =` — and let through for the widget's own (`_ownWrite`). During
112
- * the constructor the field is still undefined (it is initialised after `super()`), which lets
113
- * the default `display: "none"` through. */
119
+ /** @internal `display` is not a modal's switch — DEPRECATED, accepted for the transition period
120
+ * and translated: a function → `bindShow(() => fn() !== "none")`, a value → the start state of
121
+ * an unplaced modal or a show() / hide() of a placed one (replacing a binding, as a static write
122
+ * did). The widget's own writes (`_ownWrite`) pass untouched. During the constructor the field is
123
+ * still undefined (it is initialised after `super()`), which lets the default `display: "none"`
124
+ * through. */
114
125
  _mapStyle(style: any): void {
115
- if ("display" in style && this._ownWrite === false) {
116
- throw new Error("a modal's `display` is not its switch — a dialog, a sheet or a menu is shown with show() / hide(), or bound to the data with bindShow(…), never by its display")
126
+ if (!("display" in style) || this._ownWrite !== false) return
127
+ const value = style.display
128
+ delete style.display
129
+ warnDisplayDeprecated()
130
+ if (typeof value === "function") { this._bindDisplay(value); return }
131
+ if (this._bound) {
132
+ disposeBinding(this, SHOW_BINDING)
133
+ this._bound = false
134
+ this._showSignal = undefined
135
+ this._syncDismissible()
117
136
  }
137
+ const shown = value !== "none"
138
+ if (this._h.id === 0) return
139
+ if (tree().parent(this._h.id) === 0 && !this._shown) this._writeDisplay(shown ? "flex" : "none") // the start state
140
+ else this._setOpen(shown)
141
+ }
142
+
143
+ /** @internal `el.style.display = () => …` (the style proxy's binding form) — DEPRECATED, as `_mapStyle`. */
144
+ _bindStyle(key: string, fn: () => any): boolean {
145
+ if (key !== "display") return false
146
+ warnDisplayDeprecated()
147
+ this._bindDisplay(fn)
148
+ return true
149
+ }
150
+
151
+ private _bindDisplay(fn: () => any): void {
152
+ this.bindShow(() => fn() !== "none")
118
153
  }
119
154
 
120
155
  // The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
package/src/ui/UINode.ts CHANGED
@@ -399,6 +399,7 @@ const handler: ProxyHandler<any> = {
399
399
  // dispose the binding (this is the hot path) — override statically via `.style({ prop })`.
400
400
  const el = target._el
401
401
  const fn = value as unknown as () => any
402
+ if (el._bindStyle?.(key, fn) === true) return true // the element took the binding itself
402
403
  createBinding(el, key, () => { el.style[key] = fn() })
403
404
  return true
404
405
  }
@@ -518,6 +519,9 @@ export class Element<T extends string> implements TreeElement {
518
519
  * `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
519
520
  * every entry point: the constructor's style, `.style()`, and single-key `el.style.k =` writes. */
520
521
  _mapStyle?(style: any): void
522
+ /** @internal A function written as `el.style.key = () => …`: true when the element bound it
523
+ * itself (a modal's deprecated `display`), false for the ordinary per-key binding. */
524
+ _bindStyle?(key: string, fn: () => any): boolean
521
525
 
522
526
  // Per-type default styles are inlined at each component's construction site (spread UNDER the
523
527
  // user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
package/src/ui/tree.ts CHANGED
@@ -233,6 +233,14 @@ export const unregisterNativeView = (viewId: number): void => { nativeViews.dele
233
233
  // ---- gestures -----------------------------------------------------------------------------------------------
234
234
  // The track handler of every pointer a listener claimed (`ev.track(...)`): the runtime feeds the
235
235
  // pointer's moves / end / cancel here by pointer id — nothing of it is retained natively.
236
+ //
237
+ // Several fingers on one node — the rule every host keeps (renderers/uikit + renderers/android
238
+ // TouchPipeline, renderers/tgfx ui_internal.h Gesture, renderers/web-lite events.ts, web-canvas
239
+ // TreeUI): every finger is its own pointer — its own touchStart and so its own `ev.track`, its own
240
+ // moves and end / cancel at its own point, keyed here by its id. The click and the long press are the
241
+ // node's PRIMARY finger's, the one that pressed it while no other held it: a two-finger tap is ONE
242
+ // click, a second finger landing cancels a hold still pending (a pinch is not a hold). `$pressed`
243
+ // holds until the last finger lifts.
236
244
  const trackers = new Map<number, TrackLike>()
237
245
 
238
246
  // The hover's own slot — the cursor is one, and it is NOT a gesture: a mouse drag keeps its touch
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.8.0"
7
+ export const SDK_VERSION = "2.8.2"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {