@solidrt/3d 0.0.56 → 0.0.58

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 (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
@@ -1,28 +1,102 @@
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.
1
+ // Pointer dispatch behind scene.handlers (and a view's): the element event
2
+ // model one tree deeper, with the SCENE - or the view whose leaf received
3
+ // the event - as the root of the walk. Down, move, up and wheel dispatch
4
+ // on the nearest hit, bubble from the struck instance (when the mesh is
5
+ // instanced) through the mesh and its ancestors and end at the root's
6
+ // listeners; over empty space the walk is the root alone (`mesh` null).
7
+ // Claiming is stopPropagation: a handler that stops an event keeps every
8
+ // later target, the root included, from seeing it, and the DOWN decides
9
+ // the press - a pointer whose down never reached the root stays claimed
10
+ // by the node chain for its move, up and tap too, so a mesh that drags
11
+ // itself stops its down once and an orbit control fed at the root never
12
+ // turns. Capture is per pointer to the press target, the root included: a
13
+ // drag from empty space keeps delivering to the root as it crosses meshes,
14
+ // exactly as a drag from a mesh keeps naming that mesh. Enter/leave pair
15
+ // on the struck node alone. Taps are synthesized here (DOM's click,
16
+ // Unity's IPointerClickHandler): a press that releases on the same target
17
+ // within the slop, the only pointer down for its whole press, counted up
18
+ // for repeats (DOM's detail, Unity's clickCount). @solidrt/2d's
19
+ // dispatch.ts is the same model one dimension down.
20
+ //
21
+ // Pure BY DESIGN (types only, nothing with GPU imports) so
22
+ // checks/dispatch-check.ts drives it headless with a fake pick.
8
23
 
9
- import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
10
- import type { SceneNode, ScenePointerEvent } from "./node.ts"
24
+ import type { PointerEvent as ElementPointerEvent, PointerFeed } from "@solidrt/core"
25
+ import type { NodePointerEvent, SceneNode, ScenePointerListener } from "./node.ts"
11
26
  import type { InstanceNode, Mesh } from "./mesh.ts"
12
- import type { Hit, SceneHandlers } from "./scene.ts"
27
+ import type { Hit, Scene, SceneHandlers, ViewHandle } from "./scene.ts"
28
+ import type { Vec3 } from "./math.ts"
13
29
 
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.
30
+ // Finger travel from the down point, in window pixels, past which a press
31
+ // is a drag and never a tap. Core's pan and transform recognizers engage
32
+ // at the same 8, so a press is never both a tap and a drag.
33
+ const TAP_SLOP = 8
34
+ // Successive taps closer in time than this, on the same target and within
35
+ // TAP_REPEAT_SLOP of each other, count up (Unity's 0.3 s clickCount
36
+ // window, Android's double-tap timeout).
37
+ const TAP_INTERVAL_MS = 300
38
+ // Window pixels a repeat tap may land from the previous one and still
39
+ // count: a thumb repeats within about this (Hammer.js uses 10, Android
40
+ // 100 dp); 20 tolerates touch without a mouse double-click spanning
41
+ // neighbouring meshes.
42
+ const TAP_REPEAT_SLOP = 20
43
+
44
+ type HandlerName = "onPointerDown" | "onPointerMove" | "onPointerUp" | "onWheel" | "onTap"
45
+
46
+ // What a press captures, a hover tracks and a tap compares: the struck
47
+ // mesh, and the instance when the mesh is instanced.
18
48
  type Target = { mesh: Mesh; instance: InstanceNode | null }
19
49
 
50
+ // One runtime event object serves every view (NodePointerEvent for the
51
+ // node chain, ScenePointerEvent at the root, plus the wheel and tap
52
+ // fields); the public types narrow it per handler.
53
+ type InternalEvent = {
54
+ mesh: Mesh | null
55
+ instance: InstanceNode | null
56
+ currentTarget: SceneNode | Scene | ViewHandle
57
+ point: Vec3 | null
58
+ distance: number | null
59
+ x: number
60
+ y: number
61
+ pointerId: number
62
+ pointerType: string
63
+ button?: number
64
+ shiftKey: boolean
65
+ ctrlKey: boolean
66
+ altKey: boolean
67
+ metaKey: boolean
68
+ native: ElementPointerEvent
69
+ deltaX?: number
70
+ deltaY?: number
71
+ tapCount?: number
72
+ _stopped: boolean
73
+ stopPropagation(): void
74
+ }
75
+
76
+ // A pointer between its down and up: what it pressed (null = the root),
77
+ // whether the down reached the root, the down point for the slop, and
78
+ // whether it has been the only pointer down for its whole press.
79
+ type Press = {
80
+ target: Target | null
81
+ claimed: boolean
82
+ cx: number
83
+ cy: number
84
+ moved: boolean
85
+ alone: boolean
86
+ }
87
+
20
88
  export type PointerInputDeps = {
21
- /** The scene's pick: camera ray through a scene pixel, hits nearest
22
- * first. */
89
+ /** The root's pick: the camera ray through one of its pixels, hits
90
+ * nearest first. */
23
91
  pick(x: number, y: number): Hit[]
24
- /** The scene target's current size (handlersFor's layout scaling). */
92
+ /** The root target's current size (handlersFor's layout scaling). */
25
93
  targetSize(): { width: number; height: number }
94
+ /** The scene or view: the walk's root and the listeners' currentTarget. */
95
+ root: Scene | ViewHandle
96
+ /** The root's listeners, in registration order. */
97
+ listeners: Set<ScenePointerListener>
98
+ /** The clock for tap repeats (default performance.now; checks inject). */
99
+ now?: () => number
26
100
  }
27
101
 
28
102
  export type PointerInput = {
@@ -34,133 +108,187 @@ export type PointerInput = {
34
108
  }
35
109
 
36
110
  export function makePointerInput(deps: PointerInputDeps): PointerInput {
37
- let capture = new Map<number, Target>()
111
+ let now = deps.now ?? (() => performance.now())
112
+ let presses = new Map<number, Press>()
38
113
  let hover = new Map<number, Target>()
114
+ let lastTap: { time: number; cx: number; cy: number; target: Target | null; count: number } | null = null
39
115
 
40
116
  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
117
+ let sameHit = (hit: Hit, target: Target): boolean => hit.mesh === target.mesh && (hit.instance ?? null) === target.instance
118
+ let sameTarget = (a: Target | null, b: Target | null): boolean => (a === null ? b === null : b !== null && a.mesh === b.mesh && a.instance === b.instance)
42
119
  // The node the walk starts at, and the one enter/leave fire on.
43
120
  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
121
+ // The captured target's own hit, if the ray still strikes it.
122
+ let hitOn = (target: Target | null, x: number, y: number): Hit | null => {
123
+ if (target === null) return null
124
+ for (let h of deps.pick(x, y)) if (sameHit(h, target)) return h
125
+ return null
67
126
  }
68
127
 
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 => {
128
+ // Bubble from the struck node through its ancestors; then, when the
129
+ // press lets it, the root's listeners (every one of them - the root is
130
+ // the last stop, there is nothing left to claim). Returns whether the
131
+ // root ran.
132
+ let walk = (name: HandlerName, event: InternalEvent, toRoot: boolean): boolean => {
72
133
  for (let n: SceneNode | null = event.instance ?? event.mesh; n !== null && !event._stopped; n = n.parent) {
73
- let handler = n[name]
134
+ // A node that left the scene mid-press has no handlers to run.
135
+ if (n._scene === null) continue
136
+ let handler = n[name] as ((event: InternalEvent) => void) | undefined
74
137
  if (handler) {
75
138
  event.currentTarget = n
76
139
  handler(event)
77
140
  }
78
141
  }
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
142
+ if (event._stopped || !toRoot) return false
143
+ event.currentTarget = deps.root
144
+ for (let listener of deps.listeners) {
145
+ let handler = listener[name] as ((event: InternalEvent) => void) | undefined
146
+ if (handler) handler(event)
147
+ }
148
+ return true
85
149
  }
86
150
 
87
151
  // 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.
152
+ // every transform above it, design-size fits included), so a leaf laid
153
+ // out at the target size - the built-in <Scene> leaf, a d-texture at
154
+ // natural size - is already in scene pixels. Only a leaf deliberately
155
+ // laid out at a DIFFERENT size (the supersampling pattern) needs the
156
+ // ratio, and only the app knows that layout: handlersFor takes it.
93
157
  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
- }
158
+ let toScene = (e: ElementPointerEvent): [number, number] => {
159
+ if (layout === null) return [e.localX, e.localY]
102
160
  let l = layout()
103
161
  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)
162
+ return [e.localX * (l.width > 0 ? size.width / l.width : 1), e.localY * (l.height > 0 ? size.height / l.height : 1)]
106
163
  }
164
+ let makeEvent = (e: ElementPointerEvent, target: Target | null, x: number, y: number, hit: Hit | null): InternalEvent => {
165
+ let event: InternalEvent = {
166
+ mesh: target === null ? null : target.mesh,
167
+ instance: target === null ? null : target.instance,
168
+ currentTarget: target === null ? deps.root : nodeOf(target),
169
+ point: hit === null ? null : hit.point,
170
+ distance: hit === null ? null : hit.distance,
171
+ x,
172
+ y,
173
+ pointerId: e.pointerId,
174
+ pointerType: e.pointerType,
175
+ button: e.button,
176
+ shiftKey: e.shiftKey,
177
+ ctrlKey: e.ctrlKey,
178
+ altKey: e.altKey,
179
+ metaKey: e.metaKey,
180
+ native: e,
181
+ _stopped: false,
182
+ stopPropagation() {
183
+ event._stopped = true
184
+ },
185
+ }
186
+ return event
187
+ }
188
+ let nearest = (x: number, y: number): Hit | null => deps.pick(x, y)[0] ?? null
189
+ // Enter/leave go to one node directly, outside the walk: the event
190
+ // in its node view.
191
+ let hoverEvent = (e: ElementPointerEvent, target: Target, x: number, y: number, hit: Hit | null): NodePointerEvent =>
192
+ makeEvent(e, target, x, y, hit) as unknown as NodePointerEvent
193
+
107
194
  return {
108
195
  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))
196
+ let [x, y] = toScene(e)
197
+ let hit = nearest(x, y)
198
+ // A second pointer landing ends "alone" for every press in flight.
199
+ for (let p of presses.values()) p.alone = false
200
+ let press: Press = { target: hit === null ? null : targetOf(hit), claimed: false, cx: e.clientX, cy: e.clientY, moved: false, alone: presses.size === 0 }
201
+ presses.set(e.pointerId, press)
202
+ press.claimed = !walk("onPointerDown", makeEvent(e, press.target, x, y, hit), true)
115
203
  },
116
204
  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)))
205
+ let [x, y] = toScene(e)
206
+ let press = presses.get(e.pointerId)
207
+ if (press) {
208
+ if (!press.moved && Math.hypot(e.clientX - press.cx, e.clientY - press.cy) >= TAP_SLOP) press.moved = true
209
+ walk("onPointerMove", makeEvent(e, press.target, x, y, hitOn(press.target, x, y)), !press.claimed)
121
210
  return
122
211
  }
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)
212
+ let hit = nearest(x, y)
213
+ let prev = hover.get(e.pointerId) ?? null
214
+ let changed = prev === null ? hit !== null : hit === null || !sameHit(hit, prev)
126
215
  if (changed) {
127
- if (prev !== undefined) {
216
+ if (prev !== null) {
128
217
  hover.delete(e.pointerId)
129
- nodeOf(prev).onPointerLeave?.(makeEvent(e, prev, eventX, eventY, null))
218
+ let node = nodeOf(prev)
219
+ if (node._scene !== null) node.onPointerLeave?.(hoverEvent(e, prev, x, y, null))
130
220
  }
131
- if (hit !== undefined) {
221
+ if (hit !== null) {
132
222
  let target = targetOf(hit)
133
223
  hover.set(e.pointerId, target)
134
- nodeOf(target).onPointerEnter?.(makeEvent(e, target, eventX, eventY, hit))
224
+ nodeOf(target).onPointerEnter?.(hoverEvent(e, target, x, y, hit))
135
225
  }
136
226
  }
137
- if (hit !== undefined) {
138
- bubble("onPointerMove", makeEvent(e, targetOf(hit), eventX, eventY, hit))
139
- }
227
+ walk("onPointerMove", makeEvent(e, hit === null ? null : targetOf(hit), x, y, hit), true)
140
228
  },
141
229
  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)))
230
+ let [x, y] = toScene(e)
231
+ let press = presses.get(e.pointerId)
232
+ if (!press) {
233
+ // A press this root never saw go down: deliver to what is under
234
+ // it, as the element model does.
235
+ let hit = nearest(x, y)
236
+ walk("onPointerUp", makeEvent(e, hit === null ? null : targetOf(hit), x, y, hit), true)
147
237
  return
148
238
  }
149
- let hit = deps.pick(eventX, eventY)[0]
150
- if (hit !== undefined) {
151
- bubble("onPointerUp", makeEvent(e, targetOf(hit), eventX, eventY, hit))
152
- }
239
+ presses.delete(e.pointerId)
240
+ let under = hitOn(press.target, x, y)
241
+ walk("onPointerUp", makeEvent(e, press.target, x, y, under), !press.claimed)
242
+ // The tap rule: no travel past the slop, alone for the whole
243
+ // press, released over the target it pressed (empty space for the
244
+ // root itself).
245
+ if (press.moved || !press.alone) return
246
+ let release = nearest(x, y)
247
+ if (press.target === null ? release !== null : release === null || !sameHit(release, press.target)) return
248
+ let time = now()
249
+ let repeat = lastTap !== null && time - lastTap.time <= TAP_INTERVAL_MS && sameTarget(lastTap.target, press.target) && Math.hypot(e.clientX - lastTap.cx, e.clientY - lastTap.cy) <= TAP_REPEAT_SLOP
250
+ let count = repeat ? lastTap!.count + 1 : 1
251
+ lastTap = { time, cx: e.clientX, cy: e.clientY, target: press.target, count }
252
+ let tap = makeEvent(e, press.target, x, y, release)
253
+ tap.tapCount = count
254
+ walk("onTap", tap, !press.claimed)
255
+ },
256
+ onWheel(e) {
257
+ let [x, y] = toScene(e)
258
+ let hit = nearest(x, y)
259
+ let event = makeEvent(e, hit === null ? null : targetOf(hit), x, y, hit)
260
+ event.deltaX = e.deltaX
261
+ event.deltaY = e.deltaY
262
+ walk("onWheel", event, true)
153
263
  },
154
264
  onPointerLeave(e) {
155
265
  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
- }
266
+ if (prev === undefined) return
267
+ hover.delete(e.pointerId)
268
+ let node = nodeOf(prev)
269
+ if (node._scene === null) return
270
+ let [x, y] = toScene(e)
271
+ node.onPointerLeave?.(hoverEvent(e, prev, x, y, null))
161
272
  },
162
273
  }
163
274
  }
164
275
 
165
276
  return { handlers: makeHandlers(null), handlersFor: makeHandlers }
166
277
  }
278
+
279
+ /**
280
+ * Feed a pointer feed (createPointerFeed) from a root's listeners: the
281
+ * events the meshes let through - a mesh that claims its press
282
+ * (stopPropagation on its down) keeps a camera control bound to the feed
283
+ * out of that drag, a drag on empty space or an unclaimed mesh orbits, a
284
+ * wheel anywhere zooms. Returns the detach. The components do this for
285
+ * their `pointer` prop; an imperative scene or view calls it directly.
286
+ */
287
+ export function feedPointer(root: { listen(listener: ScenePointerListener): () => void }, feed: PointerFeed): () => void {
288
+ return root.listen({
289
+ onPointerDown: e => feed.handlers.onPointerDown(e.native),
290
+ onPointerMove: e => feed.handlers.onPointerMove(e.native),
291
+ onPointerUp: e => feed.handlers.onPointerUp(e.native),
292
+ onWheel: e => feed.handlers.onWheel(e.native),
293
+ })
294
+ }