@solidrt/3d 0.0.55 → 0.0.56

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.
Files changed (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
package/src/orbit.ts CHANGED
@@ -3,6 +3,12 @@
3
3
  // clamps - the standard interactive-viewer camera, extracted so apps stop
4
4
  // rebuilding it.
5
5
  //
6
+ // Rotation is viewport-relative when the control knows its viewport
7
+ // (OrbitCameraOptions.viewport): dragging one viewport height sweeps one
8
+ // full turn, Three's OrbitControls convention, so the same drag feels the
9
+ // same on a phone and a 4k window. Without a viewport it falls back to a
10
+ // fixed angle per pixel.
11
+ //
6
12
  // Input goes through core's merged transform recognizer (createTransform), so
7
13
  // the drag participates in the gesture arena: on a viewport embedded in a
8
14
  // scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
@@ -21,9 +27,8 @@
21
27
  // pinch focal / wheel cursor to a world point, and the zoom scales the pose
22
28
  // about that point instead - the spot under the fingers stays under the
23
29
  // fingers, with the target sliding toward it. Only the app can own that
24
- // mapping: screen-to-ray needs the projection and the element's placement
25
- // (fov, aspect, designSize scaling), which live in the app's camera and layout,
26
- // not here.
30
+ // mapping: screen-to-ray needs the projection (fov, aspect), which lives
31
+ // in the app's camera, not here.
27
32
  //
28
33
  // Anchored zoom leaves the target wherever the zoom carried it - possibly a
29
34
  // point in empty air near the eye, and a drag orbiting THAT swings the scene
@@ -34,6 +39,13 @@
34
39
  // target's depth - the eye and the picture do not change, only what a drag
35
40
  // swings around.
36
41
  //
42
+ // Every pointer coordinate here is in the input element's own frame: the
43
+ // recognizer measures drag deltas in the element's parent frame and the
44
+ // pinch focal in its local frame, and the wheel uses localX/localY, so a
45
+ // viewport under a designSize fit or any scaled ancestor behaves the same
46
+ // as one filling the window. `viewport().height` is therefore the
47
+ // element's laid-out height, which the OrbitCamera component passes.
48
+ //
37
49
  // Pose is plain mutable state advanced by update(dt) from the app's own
38
50
  // onFrame; the control registers no frame loop of its own, so whether
39
51
  // anything animates stays the app's decision (a paused orbit with no other
@@ -44,37 +56,60 @@
44
56
  // the scene's own shared write carries uViewProj and uCamPos), and reports
45
57
  // that, so per-frame dependents (reprojecting HUD overlays via
46
58
  // scene.project) can follow the camera without recomputing every frame.
59
+ //
60
+ // Options are read where they apply, not copied out: the clamps, the rates
61
+ // (auto-orbit, rotate, zoom, pan), `viewport` and the anchor callbacks are
62
+ // re-read from the options object on every input or update, so a caller
63
+ // may change a field (or hand in an object of getters, which is what
64
+ // `<OrbitCamera>` does with its props) and the next gesture or frame sees
65
+ // it. Only the initial pose is copied at creation; later pose changes go
66
+ // through set().
47
67
 
48
68
  import { createSignal } from "@solidjs/signals"
49
69
  import { createTransform } from "@solidrt/core"
50
70
  import type { PointerEvent } from "@solidrt/core"
51
- import type { CameraUpdate } from "./scene.ts"
71
+ import type { CameraUpdate } from "./camera.ts"
52
72
  import type { Vec3 } from "./math.ts"
53
73
 
54
- // Baseline sensitivities at rotateSpeed/zoomSpeed 1, in radians per dragged
55
- // pixel and zoom exponent per wheel-delta unit.
74
+ // Baseline sensitivities at rotateSpeed/zoomSpeed 1. With a `viewport` the
75
+ // rotation is viewport-relative: dragging one viewport height sweeps
76
+ // DRAG_TURNS full turns on either axis (Three's OrbitControls convention),
77
+ // so the feel survives any window size or form factor. Without one it
78
+ // falls back to fixed radians per dragged pixel - the relative rule frozen
79
+ // at a ~785 px viewport. Wheel zoom is an exponent per wheel-delta unit.
80
+ const DRAG_TURNS = 1
56
81
  const DRAG_AZIMUTH = 0.008
57
82
  const DRAG_ELEVATION = 0.006
58
83
  const WHEEL_ZOOM = 0.0015
84
+ // Default clamps: the distance floor keeps the eye off the target; the
85
+ // elevation limits (radians) stop just short of the poles.
86
+ const MIN_DISTANCE = 0.01
87
+ const ELEVATION_LIMIT = 1.55
59
88
 
60
89
  /** What an orbit camera drives: a Scene, or one of its Views. */
61
90
  export type OrbitTarget = { setCamera(update: CameraUpdate): void }
62
91
 
92
+ /** The pose fields (target, azimuth, elevation, distance) are initial
93
+ * values, copied at creation and changed through set() afterwards. Every
94
+ * other field is live: read from this object where it applies, so a
95
+ * change takes effect on the next gesture or update. */
63
96
  export type OrbitCameraOptions = {
64
- /** The point the camera orbits and looks at (default origin). */
97
+ /** Initial point the camera orbits and looks at (default origin). */
65
98
  target?: Vec3
66
99
  /** Initial pose, radians and world units. */
67
100
  azimuth?: number
68
101
  elevation?: number
69
102
  distance?: number
103
+ /** Distance clamps, world units. */
70
104
  minDistance?: number
71
105
  maxDistance?: number
72
106
  /** Elevation clamps, radians; the defaults stop just short of the poles. */
73
107
  minElevation?: number
74
108
  maxElevation?: number
75
109
  /** Auto-orbit rate in radians/second (default 0: none). Runs while
76
- * `orbiting()` and no drag/pinch is in progress; toggle with
77
- * set({ orbiting }). */
110
+ * `orbiting()` and no drag/pinch is in progress; `orbiting` starts on
111
+ * when the initial rate is positive and toggles with set({ orbiting }).
112
+ * A new rate applies from the next update. */
78
113
  orbitSpeed?: number
79
114
  /** Multipliers over the built-in drag and zoom (wheel + pinch) sensitivities. */
80
115
  rotateSpeed?: number
@@ -82,21 +117,25 @@ export type OrbitCameraOptions = {
82
117
  /** Multiplier over the two-finger pan (1 = the scene tracks the fingers
83
118
  * exactly at the target's depth). */
84
119
  panSpeed?: number
85
- /** The viewport the pointer coordinates live in: height in the same
86
- * logical units as clientX/clientY, and the camera's vertical fov in
87
- * degrees. Providing it enables two-finger pan; without it two-finger
88
- * translation rotates, as it always did. */
89
- viewport?: () => { height: number; fov: number }
120
+ /** The viewport the drag lives in: the input element's own height (its
121
+ * laid-out size, the frame the recognizer's deltas arrive in), and the
122
+ * camera's vertical fov in degrees; return null while neither is known
123
+ * yet. Providing it makes rotation viewport-relative (a drag across the
124
+ * viewport height is one full turn, whatever the window size) and
125
+ * enables two-finger pan; without it rotation is a fixed angle per pixel
126
+ * and two-finger translation rotates. */
127
+ viewport?: () => { height: number; fov: number } | null
90
128
  /** Constrain where a pan may put the target - return the target to use.
91
129
  * The typical use: keep the pivot within a few radii of the subject so
92
130
  * panning cannot strand the camera. Zoom and rotation do not consult it. */
93
131
  clampTarget?: (target: Vec3) => Vec3
94
- /** Map a screen point (window coordinates, the clientX/clientY frame) to
95
- * the world point a zoom there should keep pinned. Called once per pinch
96
- * gesture (at its first span change - the anchor then holds for the whole
97
- * gesture, see the jitter note at `pinchAnchor`) and once per wheel event,
98
- * with the pose the zoom is about to apply to. Return null to zoom toward
99
- * the target as usual (also the default). */
132
+ /** Map a point in the input element's own pixels (the frame
133
+ * localX/localY report in) to the world point a zoom there should keep
134
+ * pinned. Called once per pinch gesture (at its first span change - the
135
+ * anchor then holds for the whole gesture, see the jitter note at
136
+ * `pinchAnchor`) and once per wheel event, with the pose the zoom is
137
+ * about to apply to. Return null to zoom toward the target as usual
138
+ * (also the default). */
100
139
  zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
101
140
  /** The world point rotation should pivot about - called when a drag or
102
141
  * pinch starts. It is projected onto the view axis and becomes the new
@@ -126,6 +165,10 @@ export type OrbitCamera = {
126
165
  /** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
127
166
  * text can read it. */
128
167
  orbiting(): boolean
168
+ /** Whether update(dt) still has work: the auto-orbit on with a non-zero
169
+ * `orbitSpeed`. The frame-loop gate, reactive like orbiting() - the
170
+ * same predicate the 2d camera and the first-person camera expose. */
171
+ active(): boolean
129
172
  /** Advance the auto-orbit and push any pose change to the driven camera.
130
173
  * Call from onFrame with the frame's dt in seconds; returns whether the
131
174
  * pose changed. */
@@ -136,9 +179,10 @@ export type OrbitCamera = {
136
179
  onPointerDown(e: PointerEvent): void
137
180
  onPointerMove(e: PointerEvent): void
138
181
  onPointerUp(e: PointerEvent): void
139
- /** Position is optional so a bare `{ deltaY }` still zooms; without it
140
- * a zoomAnchor cannot apply and the zoom falls back to the target. */
141
- onWheel(e: { deltaY: number; clientX?: number; clientY?: number }): void
182
+ /** Position (the element's own pixels) is optional so a bare
183
+ * `{ deltaY }` still zooms; without it a zoomAnchor cannot apply and
184
+ * the zoom falls back to the target. */
185
+ onWheel(e: { deltaY: number; localX?: number; localY?: number }): void
142
186
  }
143
187
  }
144
188
 
@@ -147,38 +191,38 @@ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo,
147
191
  /**
148
192
  * Create an orbit camera driving `camera`'s position and target, where
149
193
  * `camera` is a Scene or one of its Views (fov, near, and far stay yours via
150
- * its setCamera). The initial pose applies immediately. In a component tree,
151
- * reach the scene via `<Scene ref>` or useScene() (a view via `<View ref>`)
152
- * and hand the handlers to whichever element owns input.
194
+ * its setCamera). The initial pose applies immediately. In a component
195
+ * tree, prefer the `<OrbitCamera>` component: it wires all of this -
196
+ * scene, input, viewport, frame loop - through the Scene context.
153
197
  */
154
198
  export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptions = {}): OrbitCamera {
155
199
  let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
156
200
  let azimuth = options.azimuth ?? 0
157
201
  let elevation = options.elevation ?? 0
158
202
  let distance = options.distance ?? 5
159
- let minDistance = options.minDistance ?? 0.01
160
- let maxDistance = options.maxDistance ?? Infinity
161
- let minElevation = options.minElevation ?? -1.55
162
- let maxElevation = options.maxElevation ?? 1.55
163
- let orbitSpeed = options.orbitSpeed ?? 0
164
- let dragAzimuth = DRAG_AZIMUTH * (options.rotateSpeed ?? 1)
165
- let dragElevation = DRAG_ELEVATION * (options.rotateSpeed ?? 1)
166
- let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
203
+ // Everything below the pose is read from `options` where it applies.
204
+ let minDistance = () => options.minDistance ?? MIN_DISTANCE
205
+ let maxDistance = () => options.maxDistance ?? Infinity
206
+ let minElevation = () => options.minElevation ?? -ELEVATION_LIMIT
207
+ let maxElevation = () => options.maxElevation ?? ELEVATION_LIMIT
208
+ let orbitSpeed = () => options.orbitSpeed ?? 0
209
+ let rotateSpeed = () => options.rotateSpeed ?? 1
210
+ let zoomSpeed = () => options.zoomSpeed ?? 1
167
211
 
168
- let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
212
+ // The auto-orbit switch: starts on when the initial rate is positive.
213
+ let [orbiting, setOrbiting] = createSignal(orbitSpeed() > 0)
169
214
  let interacting = false
170
215
  let dirty = false
171
216
 
172
217
  let clampPose = () => {
173
- elevation = clampNum(elevation, minElevation, maxElevation)
174
- distance = clampNum(distance, minDistance, maxDistance)
218
+ elevation = clampNum(elevation, minElevation(), maxElevation())
219
+ distance = clampNum(distance, minDistance(), maxDistance())
175
220
  }
176
221
  // Slide eye and target together along the camera's right/up so the scene
177
222
  // tracks the fingers: dragged pixels map to world units through the
178
223
  // frustum height at the target's depth. Screen +y is down, so the up-axis
179
224
  // term is added (fingers down -> camera up -> scene follows down).
180
- let pan = (dx: number, dy: number) => {
181
- let vp = options.viewport!()
225
+ let pan = (dx: number, dy: number, vp: { height: number; fov: number }) => {
182
226
  let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
183
227
  let sa = Math.sin(azimuth)
184
228
  let ca = Math.cos(azimuth)
@@ -214,7 +258,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
214
258
  // Bounds widen to the current distance so a pose already outside them
215
259
  // (a rotateAnchor re-seat may land anywhere) zooms back toward range
216
260
  // instead of snap-jumping into it.
217
- distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
261
+ distance = clampNum(distance * ratio, Math.min(minDistance(), prev), Math.max(maxDistance(), prev))
218
262
  if (!anchor) return
219
263
  let s = distance / prev
220
264
  target = [
@@ -269,11 +313,14 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
269
313
  }
270
314
  },
271
315
  onTransformMove: (t) => {
272
- if (t.pointers >= 2 && options.viewport) {
273
- pan(t.dx, t.dy)
316
+ let vp = options.viewport?.() ?? null
317
+ if (t.pointers >= 2 && vp !== null) {
318
+ pan(t.dx, t.dy, vp)
274
319
  } else {
275
- azimuth -= t.dx * dragAzimuth
276
- elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
320
+ // Viewport-relative when the height is known, per-pixel otherwise.
321
+ let rel = vp !== null ? (DRAG_TURNS * 2 * Math.PI) / vp.height : null
322
+ azimuth -= t.dx * (rel ?? DRAG_AZIMUTH) * rotateSpeed()
323
+ elevation = clampNum(elevation + t.dy * (rel ?? DRAG_ELEVATION) * rotateSpeed(), minElevation(), maxElevation())
277
324
  }
278
325
  if (t.scale !== 1) {
279
326
  // Fingers spreading (scale > 1) zooms in: the distance shrinks by the
@@ -283,7 +330,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
283
330
  pinchSeen = true
284
331
  pinchAnchor = anchorAt(t.x, t.y)
285
332
  }
286
- zoomAbout(1 / Math.pow(t.scale, options.zoomSpeed ?? 1), pinchAnchor)
333
+ zoomAbout(1 / Math.pow(t.scale, zoomSpeed()), pinchAnchor)
287
334
  }
288
335
  dirty = true
289
336
  },
@@ -298,6 +345,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
298
345
  eye,
299
346
  pose: () => ({ azimuth, elevation, distance }),
300
347
  orbiting,
348
+ active: () => orbiting() && orbitSpeed() !== 0,
301
349
  set(pose) {
302
350
  if (pose.azimuth !== undefined) azimuth = pose.azimuth
303
351
  if (pose.elevation !== undefined) elevation = pose.elevation
@@ -308,8 +356,9 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
308
356
  dirty = true
309
357
  },
310
358
  update(dt) {
311
- if (orbitSpeed !== 0 && !interacting && orbiting()) {
312
- azimuth += dt * orbitSpeed
359
+ let rate = orbitSpeed()
360
+ if (rate !== 0 && !interacting && orbiting()) {
361
+ azimuth += dt * rate
313
362
  dirty = true
314
363
  }
315
364
  if (!dirty) return false
@@ -320,7 +369,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
320
369
  handlers: {
321
370
  ...transform.handlers,
322
371
  onWheel(e) {
323
- zoomAbout(Math.exp(e.deltaY * wheelZoom), anchorAt(e.clientX, e.clientY))
372
+ zoomAbout(Math.exp(e.deltaY * WHEEL_ZOOM * zoomSpeed()), anchorAt(e.localX, e.localY))
324
373
  dirty = true
325
374
  },
326
375
  },
@@ -0,0 +1,166 @@
1
+ // Pointer dispatch behind scene.handlers: element pointer events in,
2
+ // instance/mesh/group handler calls out - nearest hit wins, down/move/up
3
+ // bubble from the struck instance (or the mesh) up through the ancestors,
4
+ // pointer-down captures until up, enter/leave pair on hover changes. One
5
+ // system per scene; everything it needs of the scene is `pick` and the
6
+ // target size (makePointerInput's deps), the capture/hover bookkeeping is
7
+ // its own.
8
+
9
+ import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
10
+ import type { SceneNode, ScenePointerEvent } from "./node.ts"
11
+ import type { InstanceNode, Mesh } from "./mesh.ts"
12
+ import type { Hit, SceneHandlers } from "./scene.ts"
13
+
14
+ type BubbleName = "onPointerDown" | "onPointerMove" | "onPointerUp"
15
+ type InternalEvent = ScenePointerEvent & { _stopped: boolean }
16
+ // What a press captures and a hover tracks: the struck mesh, and the
17
+ // instance when the mesh is instanced.
18
+ type Target = { mesh: Mesh; instance: InstanceNode | null }
19
+
20
+ export type PointerInputDeps = {
21
+ /** The scene's pick: camera ray through a scene pixel, hits nearest
22
+ * first. */
23
+ pick(x: number, y: number): Hit[]
24
+ /** The scene target's current size (handlersFor's layout scaling). */
25
+ targetSize(): { width: number; height: number }
26
+ }
27
+
28
+ export type PointerInput = {
29
+ /** scene.handlers: for a leaf laid out at the target size. */
30
+ handlers: SceneHandlers
31
+ /** scene.handlersFor: for a leaf whose layout size differs (the
32
+ * supersampling pattern); `layout` is read per event. */
33
+ handlersFor(layout: () => { width: number; height: number }): SceneHandlers
34
+ }
35
+
36
+ export function makePointerInput(deps: PointerInputDeps): PointerInput {
37
+ let capture = new Map<number, Target>()
38
+ let hover = new Map<number, Target>()
39
+
40
+ let targetOf = (hit: Hit): Target => ({ mesh: hit.mesh, instance: hit.instance ?? null })
41
+ let sameTarget = (hit: Hit, target: Target): boolean => hit.mesh === target.mesh && (hit.instance ?? null) === target.instance
42
+ // The node the walk starts at, and the one enter/leave fire on.
43
+ let nodeOf = (target: Target): SceneNode => target.instance ?? target.mesh
44
+
45
+ let makeEvent = (e: ElementPointerEvent, target: Target, x: number, y: number, hit: Hit | null): InternalEvent => {
46
+ let event: InternalEvent = {
47
+ mesh: target.mesh,
48
+ instance: target.instance,
49
+ currentTarget: nodeOf(target),
50
+ point: hit !== null ? hit.point : null,
51
+ distance: hit !== null ? hit.distance : null,
52
+ x,
53
+ y,
54
+ pointerId: e.pointerId,
55
+ pointerType: e.pointerType,
56
+ button: e.button,
57
+ shiftKey: e.shiftKey,
58
+ ctrlKey: e.ctrlKey,
59
+ altKey: e.altKey,
60
+ metaKey: e.metaKey,
61
+ _stopped: false,
62
+ stopPropagation() {
63
+ event._stopped = true
64
+ },
65
+ }
66
+ return event
67
+ }
68
+
69
+ // The struck instance first (its chain passes through the mesh), then
70
+ // the mesh, then its ancestors.
71
+ let bubble = (name: BubbleName, event: InternalEvent): void => {
72
+ for (let n: SceneNode | null = event.instance ?? event.mesh; n !== null && !event._stopped; n = n.parent) {
73
+ let handler = n[name]
74
+ if (handler) {
75
+ event.currentTarget = n
76
+ handler(event)
77
+ }
78
+ }
79
+ }
80
+
81
+ // The captured target's own hit, if the ray still strikes it.
82
+ let hitOn = (target: Target, x: number, y: number): Hit | null => {
83
+ for (let h of deps.pick(x, y)) if (sameTarget(h, target)) return h
84
+ return null
85
+ }
86
+
87
+ // localX/localY arrive in the leaf's LAYOUT frame (the hit test undoes
88
+ // every transform above it, design-size fits included), so a leaf laid out at
89
+ // the target size - the built-in <Scene> leaf, a d-texture at natural
90
+ // size - is already in scene pixels. Only a leaf deliberately laid out at
91
+ // a DIFFERENT size (the supersampling pattern) needs the ratio, and only
92
+ // the app knows that layout: handlersFor takes it.
93
+ let makeHandlers = (layout: (() => { width: number; height: number }) | null): SceneHandlers => {
94
+ let eventX = 0
95
+ let eventY = 0
96
+ let toScene = (e: ElementPointerEvent): void => {
97
+ if (layout === null) {
98
+ eventX = e.localX
99
+ eventY = e.localY
100
+ return
101
+ }
102
+ let l = layout()
103
+ let size = deps.targetSize()
104
+ eventX = e.localX * (l.width > 0 ? size.width / l.width : 1)
105
+ eventY = e.localY * (l.height > 0 ? size.height / l.height : 1)
106
+ }
107
+ return {
108
+ onPointerDown(e) {
109
+ toScene(e)
110
+ let hit = deps.pick(eventX, eventY)[0]
111
+ if (hit === undefined) return
112
+ let target = targetOf(hit)
113
+ capture.set(e.pointerId, target)
114
+ bubble("onPointerDown", makeEvent(e, target, eventX, eventY, hit))
115
+ },
116
+ onPointerMove(e) {
117
+ toScene(e)
118
+ let captured = capture.get(e.pointerId)
119
+ if (captured !== undefined) {
120
+ bubble("onPointerMove", makeEvent(e, captured, eventX, eventY, hitOn(captured, eventX, eventY)))
121
+ return
122
+ }
123
+ let hit = deps.pick(eventX, eventY)[0]
124
+ let prev = hover.get(e.pointerId)
125
+ let changed = prev === undefined ? hit !== undefined : hit === undefined || !sameTarget(hit, prev)
126
+ if (changed) {
127
+ if (prev !== undefined) {
128
+ hover.delete(e.pointerId)
129
+ nodeOf(prev).onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null))
130
+ }
131
+ if (hit !== undefined) {
132
+ let target = targetOf(hit)
133
+ hover.set(e.pointerId, target)
134
+ nodeOf(target).onPointerEnter?.(makeEvent(e, target, eventX, eventY, hit))
135
+ }
136
+ }
137
+ if (hit !== undefined) {
138
+ bubble("onPointerMove", makeEvent(e, targetOf(hit), eventX, eventY, hit))
139
+ }
140
+ },
141
+ onPointerUp(e) {
142
+ toScene(e)
143
+ let captured = capture.get(e.pointerId)
144
+ if (captured !== undefined) {
145
+ capture.delete(e.pointerId)
146
+ bubble("onPointerUp", makeEvent(e, captured, eventX, eventY, hitOn(captured, eventX, eventY)))
147
+ return
148
+ }
149
+ let hit = deps.pick(eventX, eventY)[0]
150
+ if (hit !== undefined) {
151
+ bubble("onPointerUp", makeEvent(e, targetOf(hit), eventX, eventY, hit))
152
+ }
153
+ },
154
+ onPointerLeave(e) {
155
+ let prev = hover.get(e.pointerId)
156
+ if (prev !== undefined) {
157
+ hover.delete(e.pointerId)
158
+ toScene(e)
159
+ nodeOf(prev).onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null))
160
+ }
161
+ },
162
+ }
163
+ }
164
+
165
+ return { handlers: makeHandlers(null), handlersFor: makeHandlers }
166
+ }