@solidrt/3d 0.0.56 → 0.0.57

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 (40) hide show
  1. package/AGENTS.md +199 -117
  2. package/README.md +2 -1
  3. package/examples/README.md +13 -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/package.json +2 -2
  15. package/src/collision.ts +14 -193
  16. package/src/components/context.tsx +35 -0
  17. package/src/components/directional-light.tsx +48 -0
  18. package/src/components/first-person-camera.tsx +72 -0
  19. package/src/components/group.tsx +18 -0
  20. package/src/components/hemisphere-light.tsx +24 -0
  21. package/src/components/index.ts +46 -0
  22. package/src/components/instanced-mesh.tsx +105 -0
  23. package/src/components/mesh.tsx +92 -0
  24. package/src/components/node-props.ts +90 -0
  25. package/src/components/orbit-camera.tsx +75 -0
  26. package/src/components/perspective-camera.tsx +32 -0
  27. package/src/components/point-light.tsx +52 -0
  28. package/src/components/record-mesh.tsx +68 -0
  29. package/src/components/scene.tsx +281 -0
  30. package/src/components/spot-light.tsx +65 -0
  31. package/src/components/sprite.tsx +38 -0
  32. package/src/components/view3d.tsx +152 -0
  33. package/src/first-person.ts +140 -188
  34. package/src/index.ts +13 -9
  35. package/src/input.ts +70 -0
  36. package/src/node.ts +98 -25
  37. package/src/orbit.ts +266 -235
  38. package/src/scene-pointer.ts +229 -101
  39. package/src/scene.ts +250 -76
  40. package/src/components.tsx +0 -1101
package/src/scene.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The retained scene: plain objects, no signals - the hot path (a moved
2
2
  // node) is flat imperative code, and reactivity stays at the component
3
- // boundary (components.tsx). The transform hierarchy itself lives in the
3
+ // boundary (components/). The transform hierarchy itself lives in the
4
4
  // spatial core (flux:spatial): every node in a scene has a core node, JS
5
5
  // keeps the LOCAL transform as the readable source of truth and forwards
6
6
  // each write, and the core's flush recomputes only the moved subtrees and
@@ -38,10 +38,10 @@
38
38
 
39
39
  import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawOrder, setDrawParams, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
40
40
  import * as spatial from "flux:spatial"
41
- import type { NodeId } from "flux:spatial"
41
+ import type { Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
42
42
  import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
43
43
  import { getOwner, onCleanup } from "@solidrt/core"
44
- import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
44
+ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
45
45
  import { copy, mat4, transformPoint } from "./math.ts"
46
46
  import { linearColor } from "./color.ts"
47
47
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
@@ -59,7 +59,7 @@ import type { Prefilter } from "./environment.ts"
59
59
  import type { Material } from "./material.ts"
60
60
  import { orderEntries } from "./order.ts"
61
61
  import { fillTransform, leaveScene, makeNode, worldInto } from "./node.ts"
62
- import type { SceneHooks, SceneNode, ScenePointerEvent } from "./node.ts"
62
+ import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
63
63
  import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishInstanceStyle } from "./mesh.ts"
64
64
  import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
65
65
  import type { CastingLight, Light } from "./light.ts"
@@ -183,15 +183,52 @@ export type Overlap = { mesh: Mesh; instance?: InstanceNode; point: Vec3; normal
183
183
  * Godot's KinematicCollision3D). `instance` as on Overlap. */
184
184
  export type Impact = { mesh: Mesh; instance?: InstanceNode; time: number; point: Vec3; normal: Vec3 }
185
185
 
186
- /** Element handlers wiring a scene's pointer events: spread onto whatever
187
- * element shows `scene.texture` (the built-in `<Scene>` leaf wires them
188
- * automatically). `scene.handlers` expects the leaf laid out at the target
189
- * size; a split-resolution leaf (supersampling) uses scene.handlersFor. */
186
+ export type MoveOptions = QueryOptions & {
187
+ /** The direction floors face (default [0, 1, 0]). */
188
+ up?: Vec3
189
+ /** Largest angle (radians) between a contact normal and `up` that still
190
+ * counts as floor (default 45 degrees). Steeper contacts are walls, and
191
+ * the body slides down them. */
192
+ floorMaxAngle?: number
193
+ /** Most contacts one call slides along (default 6). */
194
+ maxSlides?: number
195
+ /** Gap kept from every surface, world units (default 0.01). */
196
+ skin?: number
197
+ /** How far below the body a floor is pulled to when the motion does not
198
+ * rise (default 0.1; 0 disables): what keeps a walker on a ramp going
199
+ * down, and what decides `floor` at the end of the move - a body that
200
+ * ends higher than this above its floor is airborne, whatever it
201
+ * touched on the way. A rising motion (a jump) never snaps. */
202
+ floorSnap?: number
203
+ }
204
+
205
+ export type MoveResult = {
206
+ /** The displacement the body gets: add it to the body's position. */
207
+ motion: Vec3
208
+ /** The unit normal of the floor the body ends the move on - within
209
+ * `floorSnap` below it, snapped onto - else null (airborne, or on a
210
+ * slope too steep to stand on). With `floorSnap: 0` it is a floor met
211
+ * during the move instead. */
212
+ floor: Vec3 | null
213
+ /** Whether a wall (a contact steeper than a floor and flatter than a
214
+ * ceiling) or a ceiling was met. */
215
+ wall: boolean
216
+ ceiling: boolean
217
+ /** Every contact met, in order, the floor snap's last. */
218
+ hits: Impact[]
219
+ }
220
+
221
+ /** Element handlers wiring a scene's (or a view's) pointer events: spread
222
+ * onto whatever element shows its texture (the built-in `<Scene>` and
223
+ * `<View3d>` leaves wire them automatically). `scene.handlers` expects
224
+ * the leaf laid out at the target size; a split-resolution leaf
225
+ * (supersampling) uses scene.handlersFor. */
190
226
  export type SceneHandlers = {
191
227
  onPointerDown(event: ElementPointerEvent): void
192
228
  onPointerMove(event: ElementPointerEvent): void
193
229
  onPointerUp(event: ElementPointerEvent): void
194
230
  onPointerLeave(event: ElementPointerEvent): void
231
+ onWheel(event: ElementWheelEvent): void
195
232
  }
196
233
 
197
234
  /**
@@ -447,7 +484,7 @@ export type ViewOptions = {
447
484
  }
448
485
 
449
486
  /** A second rendering of a scene from its own camera; see Scene.createView. */
450
- export type View = {
487
+ export type ViewHandle = {
451
488
  /** The view's output, an ordinary texture id. */
452
489
  texture: TextureId
453
490
  /** The view target's depth as a sampler-only texture id when created
@@ -469,6 +506,22 @@ export type View = {
469
506
  /** Replace the view's layer mask (bitmask): entries for newly admitted
470
507
  * meshes attach, masked-out ones detach. */
471
508
  setLayers(mask: number): void
509
+ /** The camera ray through one of the VIEW's pixels, hits nearest first:
510
+ * scene.pick through this view's camera and size, so a mesh under a
511
+ * minimap is picked where the minimap shows it (the scene's query
512
+ * filters apply: visibility, the view's own layer mask is not one). */
513
+ pick(x: number, y: number): Hit[]
514
+ /** Element pointer handlers for the element showing `texture`: the
515
+ * scene's dispatch with THIS VIEW as the root of the walk - nodes get
516
+ * their ordinary handlers, picked through the view's camera, the
517
+ * view's `listen` is the last stop, `x`/`y` are view pixels. The
518
+ * `<View3d>` leaf carries them; an `output` leaf spreads them itself. */
519
+ handlers: SceneHandlers
520
+ /** handlers for a leaf laid out at another size; exactly
521
+ * scene.handlersFor. */
522
+ handlersFor(layout: () => { width: number; height: number }): SceneHandlers
523
+ /** A listener at this view's root; exactly scene.listen. */
524
+ listen(listener: ScenePointerListener): () => void
472
525
  /** Destroy the view's target (its entries die with it). Idempotent;
473
526
  * views also die with their scene. */
474
527
  dispose(): void
@@ -708,6 +761,21 @@ export type Scene = {
708
761
  * a wall. A zero motion touches nothing. Same testing as overlap().
709
762
  */
710
763
  sweep(volume: Volume, motion: Vec3, opts?: QueryOptions): Impact[]
764
+ /**
765
+ * Move a body `motion` through the scene's colliders, sliding along
766
+ * what it hits (Godot's move_and_slide, Unity's CharacterController.Move):
767
+ * a capsule for a character, a sphere for a ball, a box for a crate.
768
+ * The body first pushes out of anything it starts inside, then sweeps
769
+ * and slides up to `maxSlides` times, then, unless the motion rises,
770
+ * snaps down onto a floor within `floorSnap` - the floor it reports is
771
+ * the one it ends on. One core call per body per frame; the loop's
772
+ * sweeps and overlaps never leave the core. Colliders are whatever
773
+ * `opts` selects (`layers`/`meshes`, as for sweep). Gravity is the
774
+ * caller's: fold the fall into `motion` each frame, zero it while
775
+ * `floor` is set (the snap keeps reporting the floor while the body
776
+ * stands still); a walkable floor absorbs the vertical part.
777
+ */
778
+ moveAndSlide(volume: Volume, motion: Vec3, opts?: MoveOptions): MoveResult
711
779
  /**
712
780
  * pick()'s ray half (Unity's ScreenPointToRay, Godot's
713
781
  * project_ray_origin/normal): the camera ray through a scene pixel,
@@ -719,17 +787,25 @@ export type Scene = {
719
787
  */
720
788
  screenRay(x: number, y: number): ScreenRay
721
789
  /**
722
- * Element pointer handlers driving the mesh event fields
723
- * (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
724
- * that shows `scene.texture`. The `<Scene>` component's built-in leaf
725
- * carries them automatically; with `output` (or imperative use), spread
726
- * them yourself: `<texture src={scene.texture} {...scene.handlers} />`.
727
- * Semantics mirror element pointer events: nearest hit wins, down/move/
728
- * up bubble mesh -> ancestors, pointer-down captures the mesh until up
729
- * (moves keep flowing to it off-mesh, the platform's captured-drag
730
- * rule), enter/leave pair on hover changes. Hover reacts to pointer
731
- * MOTION - a mesh animating under a still pointer fires nothing until
732
- * the pointer moves (the element hit-test has the same limit).
790
+ * Element pointer handlers driving the node event fields
791
+ * (onPointerDown/Move/Up/Enter/Leave/Wheel/Tap on nodes) and the
792
+ * scene's listeners: spread onto the element that shows
793
+ * `scene.texture`. The `<Scene>` component's built-in leaf carries them
794
+ * automatically; with `output` (or imperative use), spread them
795
+ * yourself: `<texture src={scene.texture} {...scene.handlers} />`.
796
+ * The element event model one tree deeper, with the scene as the root
797
+ * of the walk: the nearest hit is the target (the struck instance of an
798
+ * instanced mesh, else the mesh), down/move/up/wheel bubble through its
799
+ * ancestors and end at the scene's listeners (`listen`); over empty
800
+ * space the walk is the scene alone. stopPropagation stops the walk,
801
+ * and a stopped down claims the whole press. Capture is per pointer to
802
+ * the press target, the scene included: a drag keeps delivering to what
803
+ * it pressed, with `point`/`distance` null while the ray misses it.
804
+ * Enter/leave pair on the struck node alone, on hover changes. Taps are
805
+ * synthesized (a same-target release within the slop, alone for the
806
+ * press, `tapCount` for repeats). Hover reacts to pointer MOTION - a
807
+ * mesh animating under a still pointer fires nothing until the pointer
808
+ * moves (the element hit-test has the same limit).
733
809
  *
734
810
  * Coordinates assume the leaf is LAID OUT at the target size - true for
735
811
  * the built-in leaf and a d-texture at natural size, under any ancestor
@@ -743,6 +819,19 @@ export type Scene = {
743
819
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
744
820
  * h() }))`. */
745
821
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
822
+ /**
823
+ * Add a listener at the root of the pointer walk (the `<Scene>`
824
+ * component's onPointerDown/Move/Up, onWheel and onTap props do this):
825
+ * it sees every event the node chain lets through, hit or miss -
826
+ * `event.mesh` is the hit it bubbled from, null over empty space - with
827
+ * `x`/`y` in scene pixels. Listeners all run, in registration order
828
+ * (the root is the last stop, nothing is left to claim); a stopped DOWN
829
+ * claims the whole press for the chain, so that pointer's move, up and
830
+ * tap never arrive. Returns the remover. A pointer feed listens here
831
+ * through feedPointer, so a camera control bound to the feed respects a
832
+ * mesh's claim.
833
+ */
834
+ listen(listener: ScenePointerListener): () => void
746
835
  /**
747
836
  * A second rendering of this scene: its own draw target and camera,
748
837
  * the same meshes and lights. Each mesh gets one entry in the view's
@@ -756,7 +845,7 @@ export type Scene = {
756
845
  * or pointer events. Views die with the scene; `view.dispose()` drops
757
846
  * one early.
758
847
  */
759
- createView(opts: ViewOptions): View
848
+ createView(opts: ViewOptions): ViewHandle
760
849
  /**
761
850
  * A reflection probe: the scene rendered into a cube map from a point,
762
851
  * six faces at 90 degrees - Three's CubeCamera, Unity's and Godot's
@@ -849,23 +938,44 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
849
938
  // map turns a hit's core node back into the mesh, or the instance of an
850
939
  // instanced mesh (each instance node is its own leaf).
851
940
  let byNode = new Map<NodeId, Mesh | InstanceNode>()
852
- // The per-query filter raycast/overlap/sweep share: a hit's core node
853
- // back to its mesh (and instance), or null when the query mask excludes
854
- // the mesh (skipped like an invisible one; the mask defaults to the
855
- // scene's, so an undrawn layer needs an explicit opts.layers to report)
856
- // or the include-list leaves it out.
857
- let admitted = (qopts: QueryOptions | undefined, site: string) => {
858
- let mask = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
859
- let include = qopts?.meshes !== undefined ? new Set(qopts.meshes) : null
860
- return (node: NodeId): { mesh: Mesh; instance: InstanceNode | null } | null => {
861
- let leaf = byNode.get(node)
862
- if (leaf === undefined) return null
863
- let instance = leaf.kind === "instance" ? leaf : null
864
- let mesh = instance !== null ? instance.mesh : (leaf as Mesh)
865
- if (mesh === null || (mesh.layers & mask) === 0) return null
866
- if (include !== null && !include.has(mesh)) return null
867
- return { mesh, instance }
941
+ // The filter raycast/overlap/sweep hand the core: the scene root (the
942
+ // arena is shared with other scenes and 2d layers), the query mask (the
943
+ // scene's by default, so an undrawn layer needs an explicit opts.layers
944
+ // to report) and, for an include-list, the meshes' nodes - an instanced
945
+ // mesh's instances each being a leaf of their own.
946
+ // One filter object per scene, refilled per query (the bindings read it
947
+ // synchronously), so a query allocates nothing but its hits.
948
+ let filter: QueryFilter = {}
949
+ let queryFilter = (qopts: QueryOptions | undefined, site: string): QueryFilter => {
950
+ filter.root = root._node!
951
+ filter.layers = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
952
+ if (qopts?.meshes === undefined) {
953
+ filter.nodes = undefined
954
+ } else {
955
+ let nodes: NodeId[] = []
956
+ for (let mesh of qopts.meshes) {
957
+ if (mesh._node !== null) nodes.push(mesh._node)
958
+ if (mesh._instances !== null) nodes.push(...instanceGroup(mesh as InstancedMesh))
959
+ }
960
+ filter.nodes = nodes
868
961
  }
962
+ return filter
963
+ }
964
+ // A hit's core node back to its mesh (and instance).
965
+ let leafOf = (node: NodeId): { mesh: Mesh; instance: InstanceNode | null } | null => {
966
+ let leaf = byNode.get(node)
967
+ if (leaf === undefined) return null
968
+ let instance = leaf.kind === "instance" ? leaf : null
969
+ let mesh = instance !== null ? instance.mesh : (leaf as Mesh)
970
+ if (mesh === null) return null
971
+ return { mesh, instance }
972
+ }
973
+ let impactOf = (h: CoreImpact): Impact | null => {
974
+ let leaf = leafOf(h.node)
975
+ if (leaf === null) return null
976
+ let impact: Impact = { mesh: leaf.mesh, time: h.time, point: h.point, normal: h.normal }
977
+ if (leaf.instance !== null) impact.instance = leaf.instance
978
+ return impact
869
979
  }
870
980
  // Nodes whose transform changed since the last sync (deduped by the
871
981
  // _moved flag): what the light and transparent-order bookkeeping
@@ -1070,44 +1180,46 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1070
1180
  let clip: Vec4 = [0, 0, 0, 0]
1071
1181
  let pickOrigin: Vec3 = [0, 0, 0]
1072
1182
 
1073
- // The camera ray through a scene pixel, into the pickOrigin/pickDir
1183
+ // The camera ray through a pixel of `cam` over a tw x th target, into the
1184
+ // pickOrigin/pickDir
1074
1185
  // scratches. The direction keeps a camera-forward component of 1
1075
1186
  // (perspective: (cx, cy, -1) in the camera frame; ortho: unit forward),
1076
1187
  // so origin + w * direction is the world point at camera-forward
1077
1188
  // distance w - what unproject() banks on. pick()/screenRay()/
1078
1189
  // unproject() all cast exactly this ray.
1079
- let pixelRay = (x: number, y: number): void => {
1080
- ensureCamera(camera, width, height)
1081
- let v = camera.view
1082
- let o = camera.ortho
1190
+ let pixelRayOf = (cam: Camera, tw: number, th: number, x: number, y: number): void => {
1191
+ ensureCamera(cam, tw, th)
1192
+ let v = cam.view
1193
+ let o = cam.ortho
1083
1194
  if (o === null) {
1084
1195
  // The camera-frame ray through the pixel, inverting project()'s
1085
1196
  // mapping: the baked y-down clip flip is why pixel y converts with
1086
1197
  // no negation there and one here.
1087
- let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
1088
- let cx = (((x / width) * 2 - 1) * (width / height)) / f
1089
- let cy = -((y / height) * 2 - 1) / f
1198
+ let f = 1 / Math.tan(((cam.fov * Math.PI) / 180) / 2)
1199
+ let cx = (((x / tw) * 2 - 1) * (tw / th)) / f
1200
+ let cy = -((y / th) * 2 - 1) / f
1090
1201
  // The view's upper 3x3 rows are the camera axes, so its transpose
1091
1202
  // carries the camera-space direction (cx, cy, -1) to world.
1092
1203
  pickDir[0] = cx * v[0] + cy * v[1] - v[2]
1093
1204
  pickDir[1] = cx * v[4] + cy * v[5] - v[6]
1094
1205
  pickDir[2] = cx * v[8] + cy * v[9] - v[10]
1095
- pickOrigin[0] = camera.eye[0]
1096
- pickOrigin[1] = camera.eye[1]
1097
- pickOrigin[2] = camera.eye[2]
1206
+ pickOrigin[0] = cam.eye[0]
1207
+ pickOrigin[1] = cam.eye[1]
1208
+ pickOrigin[2] = cam.eye[2]
1098
1209
  return
1099
1210
  }
1100
1211
  // Orthographic: every ray runs along the camera's forward axis; the
1101
1212
  // pixel picks where on the camera plane it starts (top row = top).
1102
- let cx = o.left + (x / width) * (o.right - o.left)
1103
- let cy = o.top + (y / height) * (o.bottom - o.top)
1104
- pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
1105
- pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
1106
- pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
1213
+ let cx = o.left + (x / tw) * (o.right - o.left)
1214
+ let cy = o.top + (y / th) * (o.bottom - o.top)
1215
+ pickOrigin[0] = cam.eye[0] + cx * v[0] + cy * v[1]
1216
+ pickOrigin[1] = cam.eye[1] + cx * v[4] + cy * v[5]
1217
+ pickOrigin[2] = cam.eye[2] + cx * v[8] + cy * v[9]
1107
1218
  pickDir[0] = -v[2]
1108
1219
  pickDir[1] = -v[6]
1109
1220
  pickDir[2] = -v[10]
1110
1221
  }
1222
+ let pixelRay = (x: number, y: number): void => pixelRayOf(camera, width, height, x, y)
1111
1223
 
1112
1224
  // Views (scene.createView): more targets drawing the same meshes from
1113
1225
  // their own cameras. A view holds one entry per mesh in its target,
@@ -1487,6 +1599,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1487
1599
  },
1488
1600
  _setLayers(mesh) {
1489
1601
  if (mesh._buffers === null || disposed) return
1602
+ if (mesh._node !== null) spatial.setLayers(mesh._node, mesh.layers)
1603
+ if (mesh._instances !== null) {
1604
+ for (let n of instanceGroup(mesh as InstancedMesh)) spatial.setLayers(n, mesh.layers)
1605
+ }
1490
1606
  if ((mesh.layers & sceneMask) !== 0) attachScene(mesh)
1491
1607
  else detachScene(mesh)
1492
1608
  for (let v of views) {
@@ -1598,6 +1714,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1598
1714
  if (mesh === null || mesh._buffers === null || mesh._node === null || instance._node === null) return
1599
1715
  spatial.setBounds(instance._node, geometryBounds(mesh.geometry))
1600
1716
  spatial.setShape(instance._node, mesh._buffers.shape)
1717
+ spatial.setLayers(instance._node, mesh.layers)
1601
1718
  // The record is the instance's placement inside the mesh (the mesh
1602
1719
  // node is the anchor), staged by the core at the next flush.
1603
1720
  spatial.bindMatrixRecord(instance._node, mesh._instances.buffer, instance._slot, mesh._node)
@@ -1674,13 +1791,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1674
1791
  lightsDirty = true
1675
1792
  hooks._schedule()
1676
1793
 
1677
- // Pointer dispatch (scene.handlers): capture/hover bookkeeping and the
1678
- // bubble walk live in scene-pointer.ts; all it needs of the scene is
1679
- // pick() and the target size.
1680
- let pointer = makePointerInput({
1681
- pick: (x, y) => scene.pick(x, y),
1682
- targetSize: () => ({ width, height }),
1683
- })
1794
+ // The scene's root listeners (scene.listen): the last stop of the
1795
+ // pointer walk scene-pointer.ts runs behind scene.handlers.
1796
+ let listeners = new Set<ScenePointerListener>()
1684
1797
 
1685
1798
  let scene: Scene = {
1686
1799
  texture,
@@ -1830,7 +1943,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1830
1943
  // microtask still runs and finds nothing dirty - harmless.)
1831
1944
  if (scheduled) sync()
1832
1945
  if (disposed) return []
1833
- let admit = admitted(rayOpts, "raycast")
1946
+ let f = queryFilter(rayOpts, "raycast")
1834
1947
  let hits: Hit[] = []
1835
1948
  rayOriginScratch[0] = origin[0]
1836
1949
  rayOriginScratch[1] = origin[1]
@@ -1838,8 +1951,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1838
1951
  rayDirScratch[0] = direction[0]
1839
1952
  rayDirScratch[1] = direction[1]
1840
1953
  rayDirScratch[2] = direction[2]
1841
- for (let h of spatial.raycast(rayOriginScratch, rayDirScratch)) {
1842
- let leaf = admit(h.node)
1954
+ for (let h of spatial.raycast(rayOriginScratch, rayDirScratch, f)) {
1955
+ let leaf = leafOf(h.node)
1843
1956
  if (leaf === null) continue
1844
1957
  let hit: Hit = { mesh: leaf.mesh, distance: h.distance, point: h.point, normal: h.normal }
1845
1958
  if (leaf.instance !== null) hit.instance = leaf.instance
@@ -1852,11 +1965,11 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1852
1965
  overlap(volume, qopts) {
1853
1966
  if (scheduled) sync()
1854
1967
  if (disposed) return []
1855
- let admit = admitted(qopts, "overlap")
1968
+ let f = queryFilter(qopts, "overlap")
1856
1969
  let kind = packVolume(volume, "overlap")
1857
1970
  let out: Overlap[] = []
1858
- for (let h of spatial.overlap(kind, kind === "box" ? boxScratch : capsuleScratch)) {
1859
- let leaf = admit(h.node)
1971
+ for (let h of spatial.overlap(kind, kind === "box" ? boxScratch : capsuleScratch, f)) {
1972
+ let leaf = leafOf(h.node)
1860
1973
  if (leaf === null) continue
1861
1974
  let contact: Overlap = { mesh: leaf.mesh, point: h.point, normal: h.normal, depth: h.depth }
1862
1975
  if (leaf.instance !== null) contact.instance = leaf.instance
@@ -1867,27 +1980,53 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1867
1980
  sweep(volume, motion, qopts) {
1868
1981
  if (scheduled) sync()
1869
1982
  if (disposed) return []
1870
- let admit = admitted(qopts, "sweep")
1983
+ let f = queryFilter(qopts, "sweep")
1871
1984
  let kind = packVolume(volume, "sweep")
1872
1985
  motionScratch[0] = motion[0]
1873
1986
  motionScratch[1] = motion[1]
1874
1987
  motionScratch[2] = motion[2]
1875
1988
  let out: Impact[] = []
1876
- for (let h of spatial.sweep(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch)) {
1877
- let leaf = admit(h.node)
1878
- if (leaf === null) continue
1879
- let impact: Impact = { mesh: leaf.mesh, time: h.time, point: h.point, normal: h.normal }
1880
- if (leaf.instance !== null) impact.instance = leaf.instance
1881
- out.push(impact)
1989
+ for (let h of spatial.sweep(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch, f)) {
1990
+ let impact = impactOf(h)
1991
+ if (impact !== null) out.push(impact)
1882
1992
  }
1883
1993
  return out
1884
1994
  },
1885
- handlers: pointer.handlers,
1886
- handlersFor: pointer.handlersFor,
1995
+ moveAndSlide(volume, motion, mopts) {
1996
+ if (scheduled) sync()
1997
+ let kind = packVolume(volume, "moveAndSlide")
1998
+ motionScratch[0] = motion[0]
1999
+ motionScratch[1] = motion[1]
2000
+ motionScratch[2] = motion[2]
2001
+ if (disposed) return { motion: [motion[0], motion[1], motion[2]], floor: null, wall: false, ceiling: false, hits: [] }
2002
+ let f = queryFilter(mopts, "moveAndSlide")
2003
+ let r = spatial.moveAndSlide(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch, mopts, f)
2004
+ let hits: Impact[] = []
2005
+ for (let h of r.hits) {
2006
+ let impact = impactOf(h)
2007
+ if (impact !== null) hits.push(impact)
2008
+ }
2009
+ return { motion: r.motion, floor: r.floor, wall: r.wall, ceiling: r.ceiling, hits }
2010
+ },
2011
+ get handlers() {
2012
+ return pointer.handlers
2013
+ },
2014
+ handlersFor(layout) {
2015
+ return pointer.handlersFor(layout)
2016
+ },
2017
+ listen(listener) {
2018
+ listeners.add(listener)
2019
+ return () => {
2020
+ listeners.delete(listener)
2021
+ }
2022
+ },
1887
2023
  createView(vopts) {
1888
2024
  if (disposed) throw new Error("createView: the scene is disposed")
1889
2025
  let v = makeView(vopts, null)
1890
- return {
2026
+ // The view's own walk: its listeners, its camera's pick, and a
2027
+ // dispatch with its own capture and hover bookkeeping.
2028
+ let viewListeners = new Set<ScenePointerListener>()
2029
+ let handle: ViewHandle = {
1891
2030
  texture: v.texture,
1892
2031
  depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
1893
2032
  setCamera(update) {
@@ -1927,10 +2066,35 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1927
2066
  }
1928
2067
  hooks._schedule()
1929
2068
  },
2069
+ pick(x, y) {
2070
+ if (v.disposed) return []
2071
+ pixelRayOf(v.camera, v.width, v.height, x, y)
2072
+ return scene.raycast(pickOrigin, pickDir)
2073
+ },
2074
+ get handlers() {
2075
+ return viewPointer.handlers
2076
+ },
2077
+ handlersFor(layout) {
2078
+ return viewPointer.handlersFor(layout)
2079
+ },
2080
+ listen(listener) {
2081
+ viewListeners.add(listener)
2082
+ return () => {
2083
+ viewListeners.delete(listener)
2084
+ }
2085
+ },
1930
2086
  dispose() {
2087
+ viewListeners.clear()
1931
2088
  disposeView(v)
1932
2089
  },
1933
2090
  }
2091
+ let viewPointer = makePointerInput({
2092
+ pick: (x, y) => handle.pick(x, y),
2093
+ targetSize: () => ({ width: v.width, height: v.height }),
2094
+ root: handle,
2095
+ listeners: viewListeners,
2096
+ })
2097
+ return handle
1934
2098
  },
1935
2099
  createReflectionProbe(popts) {
1936
2100
  if (disposed) throw new Error("createReflectionProbe: the scene is disposed")
@@ -1950,6 +2114,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1950
2114
  dispose() {
1951
2115
  if (disposed) return
1952
2116
  disposed = true
2117
+ listeners.clear()
1953
2118
  // Full tree-side teardown, not just the target: every node leaves
1954
2119
  // the scene (entries' geometry-buffer references and pick leaves
1955
2120
  // dropped, core nodes freed), so a disposed scene leaves no
@@ -1977,6 +2142,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1977
2142
  }
1978
2143
  },
1979
2144
  }
2145
+ // Pointer dispatch (scene.handlers): capture/hover bookkeeping, the
2146
+ // walk and the tap tracker live in scene-pointer.ts; all it needs of
2147
+ // the scene is pick(), the target size, and the root it ends at.
2148
+ let pointer = makePointerInput({
2149
+ pick: (x, y) => scene.pick(x, y),
2150
+ targetSize: () => ({ width, height }),
2151
+ root: scene,
2152
+ listeners,
2153
+ })
1980
2154
  // A reflection probe over layer mask `mask` (see createReflectionProbe):
1981
2155
  // the public object plus `finish`, which keeps the prefiltered chain and
1982
2156
  // drops everything else - the bake's one-shot use.