@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
package/README.md CHANGED
@@ -97,7 +97,8 @@ named channel to a geometry's interleave, `withColors` is the `aColor`
97
97
  vec4 case - tint, baked AO, any four scalars - and materials read channels
98
98
  by name), one perspective camera
99
99
  with an orbit control (`<OrbitCamera>` as a Scene child, or
100
- `createOrbitCamera` imperatively: drag, pinch/wheel zoom, auto-orbit),
100
+ `createOrbitCamera` imperatively; drag, pinch/wheel zoom and a pad reach
101
+ it through an input map the app binds, auto-orbit),
101
102
  mesh picking with pointer events, scene backgrounds, transparency, and
102
103
  lights (`lit` material with hemisphere ambient, up to four directional
103
104
  light nodes, Blinn-Phong highlight and triplanar mapping).
@@ -21,10 +21,14 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
21
21
  target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
22
22
  and a `quatSlerp` damped follow that visibly lags; all driven from
23
23
  onFrame through refs, no per-frame signals.
24
- - `pick.tsx` - mesh pointer events: hover tints (enter/leave), click
25
- pops (down), a Group hearing its children's clicks through bubbling
26
- and one mesh stopping the walk; a STATIC scene rendered only when an
27
- event changes something, with hit testing over the scene's BVH.
24
+ - `pick.tsx` - mesh pointer events under an `<OrbitCamera>`, the scene
25
+ as the root of the walk: hover tints (enter/leave), a tap pops, a drag
26
+ on the crate slides it over the floor without orbiting (its down claims
27
+ the press), a drag elsewhere orbits and a wheel zooms through the feed
28
+ listening at the root, the ring claims the wheel to spin, a tap on
29
+ nothing un-pops (the scene's own `onTap` with `mesh` null), and a
30
+ Group hears its children's downs through bubbling; a STATIC scene
31
+ rendered only when an event changes something.
28
32
  - `scene-background.tsx` - a fragment-GLSL background drawn inside the
29
33
  scene's own pass (`<Scene background>`): one target, no stacked
30
34
  backdrop texture, no resize plumbing; the source is shader-target
@@ -85,10 +89,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
85
89
  Drag to orbit; the `formation` and `state` debug commands drive it
86
90
  headlessly.
87
91
  - `first-person.tsx` - a first-person walk: `<FirstPersonCamera>` over
88
- a walled courtyard of shadow-casting pillars, WASD/arrows and the pad
89
- sticks to walk, mouse look under pointer lock (click locks, Escape
90
- releases - the app's calls, not the control's), a drag to look on
91
- touch; `clampPosition` keeps the walker inside the walls, the whole of
92
+ a walled courtyard of shadow-casting pillars, driven by an input map
93
+ the app binds (`firstPersonBindings`: WASD/arrows and the pad sticks
94
+ to walk, the scene's drag to look on touch, mouse motion while the
95
+ pointer is locked - click locks, Escape releases, the app's calls, not
96
+ the control's); `clampPosition` keeps the walker inside the walls, the whole of
92
97
  the collision a camera control offers; the `pose` debug command reads
93
98
  and sets the pose headlessly.
94
99
  - `collision.tsx` - collision without a physics engine: the same walker
@@ -137,6 +142,12 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
137
142
  transparent dome and a mesh without normals) parsed with `parseGltf`
138
143
  from a binary import and built by `createModel` into a Group of named
139
144
  parts; clicking a part hides it, clicking the body restores all.
145
+ - `wireframe.tsx` - the rover drawn solid, as a wireframe or as its
146
+ feature edges: `wireframeGeometry`/`edgesGeometry` build "lines"
147
+ geometry over each part's own vertices (one shared vertex upload, a
148
+ second index buffer) and `setGeometry`/`setMaterial` swap it onto the
149
+ live mesh, no node touched. Space, a tap on the rover, or the `wire`
150
+ debug command (`{ mode: "solid" | "wireframe" | "edges" }`) cycles.
140
151
  - `model-load.tsx` - the same rover loaded ASYNC with `loadModel` from
141
152
  `assets/` under a `<Loading>` boundary: the async read lives in a memo,
142
153
  a second memo derives the scene JSX after that read, and the shell
@@ -11,13 +11,15 @@
11
11
  // click locks the pointer, Escape releases it. Debug commands: `pose`
12
12
  // (read/set), `walk` ({ dx, dz } through the collision), `jump`, `fall`
13
13
  // ({ dt }: one airborne step by hand, for headless checks), `state`.
14
- import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
14
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
15
15
  import type { PointerEvent } from "@solidrt/core"
16
16
  import {
17
17
  box,
18
18
  cylinder,
19
19
  DirectionalLight,
20
20
  FirstPersonCamera,
21
+ firstPersonActions,
22
+ firstPersonBindings,
21
23
  HemisphereLight,
22
24
  lit,
23
25
  Mesh,
@@ -68,6 +70,14 @@ let feetOf = (eye: Vec3): Vec3 => [eye[0], eye[1] - EYE, eye[2]]
68
70
  function App() {
69
71
  let scene!: SceneHandle
70
72
  let camera!: FirstPersonCameraHandle
73
+ // The walker's input plus the game's own `jump` action: the standard
74
+ // first-person bindings, and Space or the pad's south button for the
75
+ // jump - one map, read by name (ARCHITECTURE.md).
76
+ let pointer = createPointerFeed()
77
+ let pad = gamepad()
78
+ let input = createInputMap({ ...firstPersonActions, jump: "button" })
79
+ input.bind(firstPersonBindings({ pointer, gamepad: pad, keyboard }))
80
+ input.bind("jump", keyboard.key("Space"), pad.button("south"))
71
81
  let [hud, setHud] = createSignal("")
72
82
  let [near, setNear] = createSignal<Set<number>>(new Set())
73
83
  // The same set, readable synchronously (a signal read right after its
@@ -135,6 +145,7 @@ function App() {
135
145
  grounded = false
136
146
  fall()
137
147
  }
148
+ input.onPress("jump", jump)
138
149
 
139
150
  let lastHud = 0
140
151
  onFrame(tick => {
@@ -204,9 +215,11 @@ function App() {
204
215
  return (
205
216
  <window
206
217
  onKeyDown={e => {
218
+ input.handlers.onKeyDown(e)
207
219
  if (e.key === "Escape") lockPointer(false)
208
- if (e.key === " " || e.code === "Space") jump()
209
220
  }}
221
+ onKeyUp={input.handlers.onKeyUp}
222
+ onBlur={input.handlers.onBlur}
210
223
  >
211
224
  <view
212
225
  width={pct(100)}
@@ -215,9 +228,9 @@ function App() {
215
228
  if (e.pointerType === "mouse") lockPointer(true)
216
229
  }}
217
230
  >
218
- <Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision">
231
+ <Scene ref={s => (scene = s)} clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="collision" pointer={pointer}>
219
232
  <PerspectiveCamera fov={70} near={0.1} far={80} />
220
- <FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
233
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 10]} clampPosition={clamp} />
221
234
  <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
222
235
  <DirectionalLight color={[1, 0.95, 0.85]} intensity={0.9} position={[8, 14, 6]} direction={[-8, -14, -6]} castShadow shadow={{ mapSize: 2048, normalBias: 0.02, cascades: 3, distance: 40 }} />
223
236
  <Mesh geometry={plane({ width: FLOOR * 2 + 2, height: FLOOR * 2 + 2 })} material={ground} rotation={[-Math.PI / 2, 0, 0]} layers={1 | COLLIDER} />
@@ -1,16 +1,17 @@
1
1
  // A first-person walk: `<FirstPersonCamera>` driving the scene camera from
2
- // WASD/arrows and the gamepad sticks, mouse look under pointer lock and
3
- // drag-to-look without it. Click the scene to lock the pointer (the scene
4
- // leaf takes focus on the same click, which routes the keys to the
5
- // control), Escape releases it; on a touch screen a drag looks around and
6
- // the left stick of a pad walks. Movement runs a frame loop only while a
7
- // key is held or a stick deflected - a still scene renders nothing new.
2
+ // an input map the app wires - WASD/arrows and the sticks through the
3
+ // standard bindings, the scene leaf's drag to look around on touch, and
4
+ // mouse motion while the pointer is locked. Click the scene to lock the
5
+ // pointer, Escape releases it; the keys reach the map through the window
6
+ // (`input.handlers`), so nothing needs focus. Movement runs a frame loop
7
+ // only while a key is held or a stick deflected - a still scene renders
8
+ // nothing new.
8
9
  // `clampPosition` keeps the walker inside the courtyard walls, the whole
9
10
  // of the collision an app gets from a camera control. The `pose` debug
10
11
  // command reads and sets the pose for headless checks.
11
- import { createSignal, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
12
+ import { createInputMap, createPointerFeed, createSignal, gamepad, keyboard, lockPointer, onFrame, pct, pointerLocked, render } from "@solidrt/core"
12
13
  import type { PointerEvent } from "@solidrt/core"
13
- import { box, cylinder, DirectionalLight, FirstPersonCamera, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
14
+ import { box, cylinder, DirectionalLight, FirstPersonCamera, firstPersonActions, firstPersonBindings, HemisphereLight, lit, Mesh, PerspectiveCamera, plane, Scene } from "@solidrt/3d"
14
15
  import type { FirstPersonCameraHandle, Vec3 } from "@solidrt/3d"
15
16
  import { registerDebug } from "srt:dev"
16
17
 
@@ -43,6 +44,12 @@ let inside = (p: Vec3): Vec3 => [clampNum(p[0], -COURT + WALL_GAP, COURT - WALL_
43
44
 
44
45
  function App() {
45
46
  let camera!: FirstPersonCameraHandle
47
+ // The walker's input: the scene leaf's gestures, the keyboard and any
48
+ // pad, bound to the control's actions (ARCHITECTURE.md: the app wires
49
+ // devices, the component takes the map).
50
+ let pointer = createPointerFeed()
51
+ let input = createInputMap(firstPersonActions)
52
+ input.bind(firstPersonBindings({ pointer, gamepad: gamepad(), keyboard }))
46
53
  let [hud, setHud] = createSignal("")
47
54
  let lastHud = 0
48
55
  onFrame(tick => {
@@ -77,8 +84,11 @@ function App() {
77
84
  return (
78
85
  <window
79
86
  onKeyDown={e => {
87
+ input.handlers.onKeyDown(e)
80
88
  if (e.key === "Escape") lockPointer(false)
81
89
  }}
90
+ onKeyUp={input.handlers.onKeyUp}
91
+ onBlur={input.handlers.onBlur}
82
92
  >
83
93
  <view
84
94
  width={pct(100)}
@@ -87,9 +97,9 @@ function App() {
87
97
  if (e.pointerType === "mouse") lockPointer(true)
88
98
  }}
89
99
  >
90
- <Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person">
100
+ <Scene clearColor={[0.6, 0.72, 0.88, 1]} samples={4} label="first-person" pointer={pointer}>
91
101
  <PerspectiveCamera fov={70} near={0.1} far={60} />
92
- <FirstPersonCamera ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
102
+ <FirstPersonCamera input={input} ref={c => (camera = c)} position={[0, EYE, 7]} clampPosition={inside} />
93
103
  <HemisphereLight sky={[0.5, 0.56, 0.68]} ground={[0.25, 0.22, 0.18]} />
94
104
  <DirectionalLight
95
105
  color={[1, 0.95, 0.85]}
@@ -15,8 +15,8 @@
15
15
  // the last tapped instance, how many are flipped) and `pixel` ({ i }: the
16
16
  // scene pixel instance i projects to and what a pick there finds, for a
17
17
  // synthetic tap).
18
- import { createSignal, flush, For, pct, render } from "@solidrt/core"
19
- import { box, DirectionalLight, HemisphereLight, Instance, InstancedMesh, lit, Mesh, OrbitCamera, plane, Scene, worldPosition } from "@solidrt/3d"
18
+ import { createInputMap, createPointerFeed, createSignal, flush, For, gamepad, pct, render } from "@solidrt/core"
19
+ import { box, DirectionalLight, HemisphereLight, Instance, InstancedMesh, lit, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, worldPosition } from "@solidrt/3d"
20
20
  import type { InstanceNode, NodeTransition, SceneHandle, Vec3 } from "@solidrt/3d"
21
21
  import { registerDebug } from "srt:dev"
22
22
 
@@ -143,12 +143,18 @@ registerDebug("pixel", (args?: Record<string, unknown>) => {
143
143
  })
144
144
 
145
145
  function App() {
146
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
147
+ // orbit control's actions - the app wires devices, the component takes
148
+ // the map (ARCHITECTURE.md).
149
+ let pointer = createPointerFeed()
150
+ let input = createInputMap(orbitActions)
151
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
146
152
  let place = (i: number): Vec3 => FORMATIONS[formation()]!.at(i)
147
153
  return (
148
154
  <window>
149
155
  <view width={pct(100)} height={pct(100)}>
150
- <Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={{ color: SKY, near: 12, far: 30 }} label="fleet" ref={s => (scene = s)}>
151
- <OrbitCamera target={[0, 2, 0]} distance={15} elevation={0.45} azimuth={0.6} />
156
+ <Scene clearColor={[SKY[0], SKY[1], SKY[2], 1]} fog={{ color: SKY, near: 12, far: 30 }} label="fleet" ref={s => (scene = s)} pointer={pointer}>
157
+ <OrbitCamera input={input} target={[0, 2, 0]} distance={15} elevation={0.45} azimuth={0.6} />
152
158
  <HemisphereLight sky={[0.45, 0.47, 0.5]} ground={[0.12, 0.13, 0.11]} />
153
159
  <DirectionalLight
154
160
  color={[1, 0.95, 0.85]}
@@ -179,7 +185,7 @@ function App() {
179
185
  position={place(i)}
180
186
  style={tints[i]![0]()}
181
187
  transition={SPRING}
182
- onPointerDown={() => flip(i)}
188
+ onTap={() => flip(i)}
183
189
  ref={node => (nodes[i] = node)}
184
190
  />
185
191
  )}
@@ -52,10 +52,10 @@ function App() {
52
52
  let center: [number, number, number] = [(b[0]! + b[3]!) / 2, (b[1]! + b[4]!) / 2, (b[2]! + b[5]!) / 2]
53
53
  let radius = Math.hypot(b[3]! - b[0]!, b[4]! - b[1]!, b[5]! - b[2]!) / 2
54
54
 
55
- // Parts are named after the glTF nodes: a click hides that part, a
56
- // click on the body brings everything back.
55
+ // Parts are named after the glTF nodes: a tap hides that part, a tap
56
+ // on the body brings everything back.
57
57
  for (let part of model.parts) {
58
- part.mesh.onPointerDown = () => {
58
+ part.mesh.onTap = () => {
59
59
  if (part.name === "body") for (let p of model.parts) setVisible(p.mesh, true)
60
60
  else setVisible(part.mesh, false)
61
61
  }
package/examples/pick.tsx CHANGED
@@ -1,60 +1,136 @@
1
- // Mesh pointer events: hover to tint (enter/leave), click to pop (down),
2
- // with a Group seeing its children's clicks through bubbling and one mesh
3
- // stopping the walk. The scene is STATIC - no onFrame - so it renders only
4
- // when an event changes something: picking is demand-driven like
5
- // everything else. Events flow because the built-in <Scene> leaf carries
6
- // scene.handlers (default on); hit testing runs over the scene's BVH, so a
7
- // pointer move costs O(log meshes).
1
+ // Mesh pointer events under an orbit control, the scene as the root of
2
+ // the walk: hover tints (enter/leave), a TAP pops (a down may be the start
3
+ // of an orbit drag, so the pop waits for the release), a drag on the
4
+ // crate slides it over the floor and never orbits - its down claims the
5
+ // press, so the orbit control's feed, listening at the root, sees none
6
+ // of it - while a drag anywhere else orbits and a wheel zooms; the ring
7
+ // claims the wheel to spin itself; a tap on empty space un-pops
8
+ // everything (the scene's own onTap with `mesh` null, the deselect
9
+ // idiom). The Group hears the cone's downs through bubbling (the crate's
10
+ // claim stops the walk at the crate). The scene is STATIC - no onFrame - so it renders only when an event changes
11
+ // something, and the orbit runs frames only while it glides; hit testing
12
+ // runs over the scene's BVH, so a pointer move costs O(log meshes).
8
13
  //
9
14
  // The volume tier is honest about its shape: hits test each mesh's
10
15
  // bounding box, so hovering just outside the ball's silhouette (inside its
11
16
  // box corners) still counts. Triangle-accurate hits are a later tier.
17
+ //
18
+ // Debug commands: `state` (the orbit pose, the crate's position, the
19
+ // ring's spin, the popped and hovered names) and `pixel` ({ name }: the
20
+ // WINDOW pixel a mesh's position projects to, for a synthetic tap).
12
21
 
13
- import { createSignal, pct, render } from "@solidrt/core"
14
- import { box, cone, Group, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit } from "@solidrt/3d"
15
- import type { Geometry, ScenePointerEvent, Vec3 } from "@solidrt/3d"
22
+ import { createInputMap, createPointerFeed, createSignal, displayScale, pct, render } from "@solidrt/core"
23
+ import { box, cone, getRotation, Group, Mesh, OrbitCamera, orbitActions, orbitBindings, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit, useScene } from "@solidrt/3d"
24
+ import type { Geometry, MeshNode, NodePointerEvent, NodeTapEvent, NodeWheelEvent, OrbitCameraHandle, SceneHandle, Vec3 } from "@solidrt/3d"
25
+ import { registerDebug } from "srt:dev"
16
26
 
17
27
  type Color = [number, number, number]
18
28
 
29
+ // How much a popped mesh grows.
30
+ const POP_SCALE = 1.25
31
+ // Radians of ring spin per wheel pixel.
32
+ const WHEEL_SPIN = 0.01
33
+ // The orbit's starting pose: the old fixed camera at [0, 2.6, 5] looking
34
+ // at the scene's middle.
35
+ const ORBIT_TARGET: Vec3 = [0, 0.4, 0]
36
+ const ORBIT_DISTANCE = 5.5
37
+ const ORBIT_ELEVATION = 0.42
38
+
19
39
  let [hovered, setHovered] = createSignal("nothing")
20
- let popped = new Set<object>()
40
+ // Popped meshes and the scale they pop back to.
41
+ let popped = new Map<MeshNode, number>()
42
+ let meshes: Record<string, MeshNode> = {}
43
+ let orbit!: OrbitCameraHandle
44
+ let scene!: SceneHandle
45
+
46
+ let unpopAll = () => {
47
+ for (let [mesh, base] of popped) setTransform(mesh, { scale: base })
48
+ popped.clear()
49
+ }
21
50
 
22
51
  /** A pickable mesh: unlit color that brightens on hover, scale-pop on
23
- * click. setMeshParams writes raw uniform values, so the tint premultiplies
24
- * here (alpha 1: the rgb passes through). */
25
- function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; stop?: boolean }) {
52
+ * tap; `drag` slides it over the floor (claiming the press), `spin`
53
+ * turns it on the wheel (claiming the wheel). setMeshParams writes raw
54
+ * uniform values, so the tint premultiplies here (alpha 1: the rgb passes
55
+ * through). */
56
+ function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; drag?: boolean; spin?: boolean }) {
57
+ let scene = useScene().scene
26
58
  let lift = (up: number): [number, number, number, number] => [
27
59
  Math.min(p.color[0] + up, 1),
28
60
  Math.min(p.color[1] + up, 1),
29
61
  Math.min(p.color[2] + up, 1),
30
62
  1,
31
63
  ]
64
+ // The floor point under the pointer at the mesh's height: the camera
65
+ // ray through the event's scene pixel meets the plane y = mesh y.
66
+ let floorPoint = (e: NodePointerEvent): [number, number] | null => {
67
+ let ray = scene.screenRay(e.x, e.y)
68
+ if (Math.abs(ray.direction[1]) < 1e-6) return null
69
+ let t = (e.mesh.position[1] - ray.origin[1]) / ray.direction[1]
70
+ if (t < 0) return null
71
+ return [ray.origin[0] + t * ray.direction[0], ray.origin[2] + t * ray.direction[2]]
72
+ }
73
+ // The mesh's offset from the grabbed floor point while a drag is on.
74
+ let grab: [number, number] | null = null
75
+ let spin = 0
32
76
  return (
33
77
  <Mesh
34
78
  geometry={p.geometry}
35
79
  material={unlit({ color: p.color })}
36
80
  position={p.position}
37
81
  scale={p.scale}
38
- onPointerEnter={(e: ScenePointerEvent) => {
82
+ ref={m => (meshes[p.name] = m)}
83
+ onPointerEnter={(e: NodePointerEvent) => {
39
84
  setHovered(p.name)
40
85
  setMeshParams(e.mesh, { uColor: lift(0.25) })
41
- console.log(`enter ${p.name}`)
42
86
  }}
43
- onPointerLeave={(e: ScenePointerEvent) => {
87
+ onPointerLeave={(e: NodePointerEvent) => {
44
88
  setHovered("nothing")
45
89
  setMeshParams(e.mesh, { uColor: lift(0) })
46
- console.log(`leave ${p.name}`)
47
90
  }}
48
- onPointerDown={(e: ScenePointerEvent) => {
49
- let pop = !popped.has(e.mesh)
50
- if (pop) popped.add(e.mesh)
51
- else popped.delete(e.mesh)
52
- setTransform(e.mesh, { scale: pop ? (p.scale ?? 1) * 1.25 : (p.scale ?? 1) })
91
+ onTap={(e: NodeTapEvent) => {
92
+ let base = popped.get(e.mesh)
93
+ if (base === undefined) {
94
+ popped.set(e.mesh, p.scale ?? 1)
95
+ setTransform(e.mesh, { scale: (p.scale ?? 1) * POP_SCALE })
96
+ } else {
97
+ popped.delete(e.mesh)
98
+ setTransform(e.mesh, { scale: base })
99
+ }
53
100
  let pt = e.point!
54
- console.log(`down ${p.name} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
55
- // The cone demonstrates stopping the bubble: its group never hears it.
56
- if (p.stop) e.stopPropagation()
101
+ console.log(`tap ${p.name} x${e.tapCount} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
57
102
  }}
103
+ onPointerDown={
104
+ p.drag
105
+ ? (e: NodePointerEvent) => {
106
+ let f = floorPoint(e)
107
+ if (f === null) return
108
+ grab = [e.mesh.position[0] - f[0], e.mesh.position[2] - f[1]]
109
+ // The claim: this press is the crate's, the orbit never sees it.
110
+ e.stopPropagation()
111
+ }
112
+ : undefined
113
+ }
114
+ onPointerMove={
115
+ p.drag
116
+ ? (e: NodePointerEvent) => {
117
+ if (grab === null) return
118
+ let f = floorPoint(e)
119
+ if (f === null) return
120
+ setTransform(e.mesh, { position: [f[0] + grab[0], e.mesh.position[1], f[1] + grab[1]] })
121
+ }
122
+ : undefined
123
+ }
124
+ onPointerUp={p.drag ? () => (grab = null) : undefined}
125
+ onWheel={
126
+ p.spin
127
+ ? (e: NodeWheelEvent) => {
128
+ spin += e.deltaY * WHEEL_SPIN
129
+ setTransform(e.mesh, { rotation: [0, spin, 0] })
130
+ e.stopPropagation()
131
+ }
132
+ : undefined
133
+ }
58
134
  />
59
135
  )
60
136
  }
@@ -65,27 +141,61 @@ function App() {
65
141
  let ball = sphere({ radius: 0.45 })
66
142
  let spike = cone({ radius: 0.45, height: 1 })
67
143
  let ring = torus({ radius: 0.4, tube: 0.14 })
144
+ // The orbit control's input: the scene leaf's pointer feed, bound to
145
+ // the standard orbit actions (drag rotates, wheel and pinch zoom).
146
+ let pointer = createPointerFeed()
147
+ let input = createInputMap(orbitActions)
148
+ input.bind(orbitBindings({ pointer }))
149
+
150
+ registerDebug("state", () => ({
151
+ pose: orbit.pose(),
152
+ crate: meshes.crate?.position,
153
+ ringSpin: meshes.ring ? getRotation(meshes.ring)[1] : null,
154
+ popped: Object.keys(meshes).filter(n => popped.has(meshes[n]!)),
155
+ hovered: hovered(),
156
+ }))
157
+ // The scene fills the window at device density, so a scene pixel over
158
+ // the display scale is a window pixel.
159
+ registerDebug("pixel", (args?: { name?: string }) => {
160
+ let mesh = meshes[args?.name ?? ""]
161
+ if (!mesh) return null
162
+ let p = scene.project(mesh.position)
163
+ if (p === null) return null
164
+ return { x: p.x / displayScale(), y: p.y / displayScale() }
165
+ })
68
166
 
69
167
  return (
70
168
  <window>
71
169
  <view width={pct(100)} height={pct(100)}>
72
- <Scene clearColor={[0.07, 0.07, 0.1, 1]} label="pick">
73
- <PerspectiveCamera fov={55} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
170
+ <Scene
171
+ clearColor={[0.07, 0.07, 0.1, 1]}
172
+ label="pick"
173
+ pointer={pointer}
174
+ ref={s => (scene = s)}
175
+ onTap={e => {
176
+ if (e.mesh !== null) return
177
+ console.log(`tap on nothing at ${e.x.toFixed(0)},${e.y.toFixed(0)}: un-pop`)
178
+ unpopAll()
179
+ }}
180
+ >
181
+ <PerspectiveCamera fov={55} />
182
+ <OrbitCamera input={input} target={ORBIT_TARGET} distance={ORBIT_DISTANCE} elevation={ORBIT_ELEVATION} ref={o => (orbit = o)} />
74
183
  <Mesh geometry={floor} material={unlit({ color: [0.15, 0.16, 0.2] })} rotation={[-Math.PI / 2, 0, 0]} />
75
- {/* The group hears every child click that is not stopped. */}
76
- <Group onPointerDown={() => console.log("group saw down")}>
77
- <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} />
78
- <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} stop />
184
+ {/* The group hears the cone's downs through bubbling; the crate's
185
+ claim stops the walk at the crate, so the group never sees those. */}
186
+ <Group onPointerDown={e => console.log(`group saw down on ${e.mesh === meshes.crate ? "crate" : "cone"}`)}>
187
+ <Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} drag />
188
+ <Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} />
79
189
  </Group>
80
190
  <Pickable name="ball" color={[0.9, 0.8, 0.35]} geometry={ball} position={[0, 0.45, 0]} />
81
- <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} />
191
+ <Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} spin />
82
192
  </Scene>
83
193
  <view position="absolute" x={0} y={0} padding={16} gap={4}>
84
194
  <text color="#eef4ff" fontSize={22} fontWeight={700}>
85
195
  {`hover: ${hovered()}`}
86
196
  </text>
87
197
  <text color="#8fa6c8" fontSize={13}>
88
- hover tints, click pops - a static scene, rendered only on change
198
+ hover tints, tap pops, drag the crate; drag elsewhere orbits, wheel zooms (over the ring: spins it), tap nothing to un-pop
89
199
  </text>
90
200
  </view>
91
201
  </view>
@@ -12,8 +12,8 @@
12
12
  // (the chain is the frame's largest GPU item; see AGENTS.md). Drag to
13
13
  // look around.
14
14
 
15
- import { createSignal, onFrame, pct, render } from "@solidrt/core"
16
- import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, plane, Scene, sphere, standard, unlit } from "@solidrt/3d"
15
+ import { createInputMap, createPointerFeed, createSignal, gamepad, onFrame, pct, render } from "@solidrt/core"
16
+ import { DirectionalLight, HemisphereLight, Mesh, OrbitCamera, orbitActions, orbitBindings, plane, Scene, sphere, standard, unlit } from "@solidrt/3d"
17
17
  import type { ReflectionProbe, SceneHandle } from "@solidrt/3d"
18
18
  import type { Vec3 } from "@solidrt/3d/math"
19
19
 
@@ -58,6 +58,12 @@ const ORBITER_COLORS: [number, number, number][] = [
58
58
  ]
59
59
 
60
60
  function App() {
61
+ // The camera's input: the scene leaf's gestures and any pad, bound to the
62
+ // orbit control's actions - the app wires devices, the component takes
63
+ // the map (ARCHITECTURE.md).
64
+ let pointer = createPointerFeed()
65
+ let input = createInputMap(orbitActions)
66
+ input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
61
67
  let [t, setT] = createSignal(0)
62
68
  let scene: SceneHandle | undefined
63
69
  let probe: ReflectionProbe | undefined
@@ -81,8 +87,8 @@ function App() {
81
87
  return (
82
88
  <window>
83
89
  <view width={pct(100)} height={pct(100)}>
84
- <Scene clearColor={[0.05, 0.05, 0.06, 1]} samples={4} label="probe-demo" ref={s => (scene = s)}>
85
- <OrbitCamera target={[0, 0, 0]} azimuth={0.5} elevation={0.2} distance={4} />
90
+ <Scene clearColor={[0.05, 0.05, 0.06, 1]} samples={4} label="probe-demo" ref={s => (scene = s)} pointer={pointer}>
91
+ <OrbitCamera input={input} target={[0, 0, 0]} azimuth={0.5} elevation={0.2} distance={4} />
86
92
  <HemisphereLight sky={[0.6, 0.65, 0.7]} ground={[0.3, 0.28, 0.26]} intensity={0.6} />
87
93
  <DirectionalLight direction={[0.4, -0.8, 0.3]} intensity={0.8} />
88
94
  {WALLS.map((w, i) => (