@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/src/orbit.ts CHANGED
@@ -1,98 +1,90 @@
1
- // An orbit camera for a scene: azimuth/elevation/distance around a target,
2
- // with drag-to-rotate, pinch- and wheel-to-zoom, optional auto-orbit, and
3
- // clamps - the standard interactive-viewer camera, extracted so apps stop
4
- // rebuilding it.
1
+ // An orbit camera for a scene: azimuth/elevation/distance around a target
2
+ // - the standard interactive-viewer camera, extracted so apps stop
3
+ // rebuilding it. The control is pure (ARCHITECTURE.md: controls through
4
+ // an abstraction, never direct event handling): it consumes three axes,
5
+ // `rotate` (vec2), `zoom` (axis) and `pan` (vec2), in device-free units,
6
+ // and exposes pose verbs for scripted moves. What drives the axes is the
7
+ // app's business - an input map with a pointer feed, a pad, a debug
8
+ // command - and the `orbitBindings` preset in input.ts is the standard
9
+ // wiring (drag rotates, pinch and wheel zoom, two fingers pan).
5
10
  //
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
- //
12
- // Input goes through core's merged transform recognizer (createTransform), so
13
- // the drag participates in the gesture arena: on a viewport embedded in a
14
- // scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
15
- // instead of both acting, and rotation only starts once the drag crosses the
16
- // recognizer's slop. One finger (or a mouse drag) rotates; two-finger pinch
17
- // zooms (pair rotation is ignored); wheel zooms directly - no recognizer
18
- // needed for a discrete wheel step. What two-finger translation means
19
- // depends on `viewport`: with it, two fingers pan - the scene slides under
20
- // the fingers 1:1 at the target's depth, the touch convention everywhere
21
- // (three.js DOLLY_PAN, Sketchfab, touch CAD) - which also keeps an
22
- // imperfect pinch from smearing rotation into the zoom. Without it there
23
- // is no pixel-to-world mapping, so focal translation falls back to
24
- // rotating regardless of finger count.
11
+ // Rotation is element-relative: one element height of drag travel (the
12
+ // feed's unit) sweeps DRAG_TURNS full turns on either axis, Three's
13
+ // OrbitControls convention, so the same drag feels the same on a phone
14
+ // and a 4k window. A rate on `rotate` (a stick) turns at ROTATE_RATE
15
+ // turns per second at full deflection. `zoom` is in octaves: a delta of
16
+ // 1 halves the distance, a rate of 1 halves it per second. `pan` slides
17
+ // the target so the scene tracks the fingers 1:1 at the target's depth,
18
+ // the touch convention everywhere (three.js DOLLY_PAN, Sketchfab, touch
19
+ // CAD), mapped through the target camera's fov - which is why the driven
20
+ // target must report its camera (a Scene or a View does).
25
21
  //
26
22
  // Zoom aims at the target by default. With `zoomAnchor` the app maps the
27
- // pinch focal / wheel cursor to a world point, and the zoom scales the pose
28
- // about that point instead - the spot under the fingers stays under the
29
- // fingers, with the target sliding toward it. Only the app can own that
30
- // mapping: screen-to-ray needs the projection (fov, aspect), which lives
31
- // in the app's camera, not here.
32
- //
33
- // Anchored zoom leaves the target wherever the zoom carried it - possibly a
34
- // point in empty air near the eye, and a drag orbiting THAT swings the scene
35
- // wildly around nothing. `rotateAnchor` is the countermeasure: at gesture
36
- // start the app names the world point rotation should pivot about (what the
37
- // camera is actually looking at), and the pivot is re-seated there. The
38
- // point is projected onto the view axis first, so re-seating moves only the
39
- // target's depth - the eye and the picture do not change, only what a drag
40
- // swings around.
23
+ // gesture's focal point (a fraction of the element) to a world point,
24
+ // and the zoom scales the pose about that point instead - the spot under
25
+ // the fingers stays under the fingers, with the target sliding toward it.
26
+ // Only the app can own that mapping: focal-to-ray needs the projection,
27
+ // which lives in the app's scene (scene.unproject over the scene's size).
28
+ // A pinch keeps ONE anchor for its whole gesture, taken at its first
29
+ // delta: the two fingers' events interleave, so the measured span
30
+ // oscillates around its true value even while the fingers rest, and
31
+ // per-delta re-anchoring turns that noise into a visible crawl; about a
32
+ // fixed point the pairs cancel exactly. The wheel, unbracketed, anchors
33
+ // per notch: its notches are discrete, there is no noise to cancel, and
34
+ // each notch re-aiming at the cursor is the point.
41
35
  //
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.
36
+ // Anchored zoom leaves the target wherever the zoom carried it - possibly
37
+ // a point in empty air near the eye, and a drag orbiting THAT swings the
38
+ // scene wildly around nothing. `rotateAnchor` is the countermeasure: when
39
+ // a rotate gesture begins, the app names the world point rotation should
40
+ // pivot about (what the camera is actually looking at), and setPivot
41
+ // re-seats the target there, projected onto the view axis first, so only
42
+ // the target's depth moves - the eye and the picture do not change.
48
43
  //
49
- // Pose is plain mutable state advanced by update(dt) from the app's own
50
- // onFrame; the control registers no frame loop of its own, so whether
51
- // anything animates stays the app's decision (a paused orbit with no other
52
- // animation costs nothing). Only `orbiting` is a signal - slow UI state
53
- // that HUDs read - while the pose moves at frame rate and bypasses
54
- // reactivity: the package's structure-vs-motion split. update() pushes the
55
- // pose to the scene camera only when it actually changed (one setCamera:
56
- // the scene's own shared write carries uViewProj and uCamPos), and reports
57
- // that, so per-frame dependents (reprojecting HUD overlays via
58
- // scene.project) can follow the camera without recomputing every frame.
44
+ // Pose is plain mutable state; a nudge or a verb pushes it to the target
45
+ // at once (the next paint carries the new camera, no frame loop needed
46
+ // for a drag), and update(dt) integrates the rates and the auto-orbit
47
+ // from the app's own onFrame - the control registers no frame loop of its
48
+ // own. Only `orbiting` and `active` are reactive: slow UI state a HUD
49
+ // reads, and the frame-loop gate. update() returns whether the pose
50
+ // changed since the previous update (pushes included), so per-frame
51
+ // dependents (reprojecting HUD overlays via scene.project) can follow the
52
+ // camera without recomputing every frame.
59
53
  //
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().
54
+ // Options are read where they apply, not copied out: the clamps, the
55
+ // rates and the anchor callbacks are re-read from the options object on
56
+ // every input or update, so a caller may change a field (or hand in an
57
+ // object of getters, which is what `<OrbitCamera>` does with its props)
58
+ // and the next gesture or frame sees it. Only the initial pose is copied
59
+ // at creation; later pose changes go through set() and the verbs.
67
60
 
68
- import { createSignal } from "@solidjs/signals"
69
- import { createTransform } from "@solidrt/core"
70
- import type { PointerEvent } from "@solidrt/core"
71
- import type { CameraUpdate } from "./camera.ts"
61
+ import { createMemo, createSignal, untrack } from "@solidjs/signals"
62
+ import { createAxes } from "@solidrt/core/input"
63
+ import type { Axes, Vec2 } from "@solidrt/core/input"
64
+ import type { CameraState, CameraUpdate } from "./camera.ts"
72
65
  import type { Vec3 } from "./math.ts"
73
66
 
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.
67
+ // One element height of drag sweeps this many full turns on either axis
68
+ // (Three's OrbitControls convention).
80
69
  const DRAG_TURNS = 1
81
- const DRAG_AZIMUTH = 0.008
82
- const DRAG_ELEVATION = 0.006
83
- const WHEEL_ZOOM = 0.0015
70
+ // Rate axes at full deflection, per second: turns of rotation, octaves of
71
+ // zoom (1 = the distance halves each second), element heights of pan.
72
+ const ROTATE_RATE = 0.5
73
+ const ZOOM_RATE = 1
74
+ const PAN_RATE = 1
84
75
  // Default clamps: the distance floor keeps the eye off the target; the
85
76
  // elevation limits (radians) stop just short of the poles.
86
77
  const MIN_DISTANCE = 0.01
87
78
  const ELEVATION_LIMIT = 1.55
88
79
 
89
- /** What an orbit camera drives: a Scene, or one of its Views. */
90
- export type OrbitTarget = { setCamera(update: CameraUpdate): void }
80
+ /** What an orbit camera drives: a Scene, or one of its Views - anything
81
+ * with their setCamera and camera() (the fov maps pan travel to world). */
82
+ export type OrbitTarget = { setCamera(update: CameraUpdate): void; camera(): Pick<CameraState, "fov"> }
91
83
 
92
84
  /** The pose fields (target, azimuth, elevation, distance) are initial
93
85
  * values, copied at creation and changed through set() afterwards. Every
94
86
  * other field is live: read from this object where it applies, so a
95
- * change takes effect on the next gesture or update. */
87
+ * change takes effect on the next input or update. */
96
88
  export type OrbitCameraOptions = {
97
89
  /** Initial point the camera orbits and looks at (default origin). */
98
90
  target?: Vec3
@@ -107,38 +99,27 @@ export type OrbitCameraOptions = {
107
99
  minElevation?: number
108
100
  maxElevation?: number
109
101
  /** Auto-orbit rate in radians/second (default 0: none). Runs while
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. */
102
+ * `orbiting()` and no gesture is in progress; `orbiting` starts on when
103
+ * the initial rate is positive and toggles with set({ orbiting }). A new
104
+ * rate applies from the next update. */
113
105
  orbitSpeed?: number
114
- /** Multipliers over the built-in drag and zoom (wheel + pinch) sensitivities. */
106
+ /** Multipliers over the built-in rotate, zoom and pan sensitivities
107
+ * (drags and rates alike). */
115
108
  rotateSpeed?: number
116
109
  zoomSpeed?: number
117
- /** Multiplier over the two-finger pan (1 = the scene tracks the fingers
118
- * exactly at the target's depth). */
119
110
  panSpeed?: 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
128
111
  /** Constrain where a pan may put the target - return the target to use.
129
112
  * The typical use: keep the pivot within a few radii of the subject so
130
113
  * panning cannot strand the camera. Zoom and rotation do not consult it. */
131
114
  clampTarget?: (target: Vec3) => Vec3
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). */
139
- zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
140
- /** The world point rotation should pivot about - called when a drag or
141
- * pinch starts. It is projected onto the view axis and becomes the new
115
+ /** Map a zoom gesture's focal point - a fraction of the driven element,
116
+ * [0..1, 0..1] - to the world point the zoom should keep pinned, with
117
+ * the pose the zoom is about to apply to. Called once per pinch gesture
118
+ * (the anchor holds for the whole gesture) and once per wheel notch.
119
+ * Return null to zoom toward the target as usual (also the default). */
120
+ zoomAnchor?: (focal: Vec2, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
121
+ /** The world point rotation should pivot about - called when a rotate
122
+ * gesture begins. It is projected onto the view axis and becomes the new
142
123
  * target, preserving the picture exactly (only the pivot's depth moves),
143
124
  * so a drag after an anchored zoom orbits the scene under the camera
144
125
  * instead of wherever the zoom left the target. Points at or behind the
@@ -154,48 +135,65 @@ export type OrbitPose = {
154
135
  orbiting?: boolean
155
136
  }
156
137
 
138
+ export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2" }
139
+
157
140
  export type OrbitCamera = {
158
141
  /** Camera position for the current pose (a fresh array per call). */
159
142
  eye(): Vec3
160
143
  /** Pose snapshot - the shape debug commands return and set() takes. */
161
- pose(): { azimuth: number; elevation: number; distance: number }
162
- /** Merge a pose in (clamps apply); reaches the scene at the next
163
- * update(). Also the auto-orbit switch: set({ orbiting: false }). */
144
+ pose(): { azimuth: number; elevation: number; distance: number; target: Vec3 }
145
+ /** Merge a pose in (clamps apply) and push it. Also the auto-orbit
146
+ * switch: set({ orbiting: false }). */
164
147
  set(pose: OrbitPose): void
165
148
  /** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
166
149
  * text can read it. */
167
150
  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. */
151
+ /** Whether update(dt) has work: the auto-orbit on with a non-zero
152
+ * `orbitSpeed`, or any axis rate non-zero. The frame-loop gate,
153
+ * reactive - the same predicate every camera control exposes. */
171
154
  active(): boolean
172
- /** Advance the auto-orbit and push any pose change to the driven camera.
173
- * Call from onFrame with the frame's dt in seconds; returns whether the
174
- * pose changed. */
155
+ /** Integrate the axis rates and the auto-orbit over dt seconds and push
156
+ * any pose change. Call from onFrame; returns whether the pose changed
157
+ * since the previous update (nudges and verbs included). */
175
158
  update(dt: number): boolean
176
- /** Spread onto the element that receives input:
177
- * `<window {...orbit.handlers} />`. */
178
- handlers: {
179
- onPointerDown(e: PointerEvent): void
180
- onPointerMove(e: PointerEvent): void
181
- onPointerUp(e: PointerEvent): 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
186
- }
159
+ /** The input abstraction: `rotate` (vec2, element heights of drag /
160
+ * turns per second), `zoom` (axis, octaves, positive in) and `pan`
161
+ * (vec2, element heights). An input map drives it by name
162
+ * (InputMap.drive); an app may add sources and nudge directly. */
163
+ axes: Axes<OrbitAxes>
164
+ /** Turn by radians (azimuth positive counterclockwise seen from above,
165
+ * elevation positive upward; clamps apply) and push. */
166
+ rotateBy(azimuth: number, elevation: number): void
167
+ /** Scale the distance by 1/factor (factor > 1 zooms in, as the 2d
168
+ * camera's zoomAt) about `anchor`, a world point that stays where it
169
+ * projects (default: toward the target), and push. */
170
+ zoomBy(factor: number, anchor?: Vec3 | null): void
171
+ /** Slide the target (and the eye with it) by world units along the
172
+ * camera's right and up, through `clampTarget`, and push. */
173
+ panBy(right: number, up: number): void
174
+ /** Re-seat the pivot at a world point without moving the picture: the
175
+ * point is projected onto the view axis and becomes the target at that
176
+ * depth. Points at or behind the eye are ignored. */
177
+ setPivot(point: Vec3): void
187
178
  }
188
179
 
189
180
  let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
190
181
 
182
+ function finite(what: string, v: number): void {
183
+ if (!Number.isFinite(v)) throw new Error(`createOrbitCamera: ${what} must be a finite number, got ${v}`)
184
+ }
185
+
191
186
  /**
192
187
  * Create an orbit camera driving `camera`'s position and target, where
193
- * `camera` is a Scene or one of its Views (fov, near, and far stay yours via
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.
188
+ * `camera` is a Scene or one of its Views (fov, near, and far stay yours
189
+ * via its setCamera). The initial pose applies immediately. In a component
190
+ * tree, prefer the `<OrbitCamera>` component: it drives the enclosing
191
+ * scene or view through context and takes an input map as a prop.
197
192
  */
198
193
  export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptions = {}): OrbitCamera {
194
+ if (!camera || typeof camera.setCamera !== "function" || typeof camera.camera !== "function") {
195
+ throw new Error("createOrbitCamera: the target needs setCamera() and camera() (a Scene or a View)")
196
+ }
199
197
  let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
200
198
  let azimuth = options.azimuth ?? 0
201
199
  let elevation = options.elevation ?? 0
@@ -208,35 +206,20 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
208
206
  let orbitSpeed = () => options.orbitSpeed ?? 0
209
207
  let rotateSpeed = () => options.rotateSpeed ?? 1
210
208
  let zoomSpeed = () => options.zoomSpeed ?? 1
209
+ let panSpeed = () => options.panSpeed ?? 1
211
210
 
212
211
  // The auto-orbit switch: starts on when the initial rate is positive.
213
- let [orbiting, setOrbiting] = createSignal(orbitSpeed() > 0)
214
- let interacting = false
215
- let dirty = false
212
+ // Plain state is the truth (update() reads it in the same block set()
213
+ // wrote it); the signal notifies HUDs and the active() gate.
214
+ let running = orbitSpeed() > 0
215
+ let [orbiting, setOrbiting] = createSignal(running, { ownedWrite: true })
216
+ // Whether the pose changed since the last update() (see the header).
217
+ let changed = false
216
218
 
217
219
  let clampPose = () => {
218
220
  elevation = clampNum(elevation, minElevation(), maxElevation())
219
221
  distance = clampNum(distance, minDistance(), maxDistance())
220
222
  }
221
- // Slide eye and target together along the camera's right/up so the scene
222
- // tracks the fingers: dragged pixels map to world units through the
223
- // frustum height at the target's depth. Screen +y is down, so the up-axis
224
- // term is added (fingers down -> camera up -> scene follows down).
225
- let pan = (dx: number, dy: number, vp: { height: number; fov: number }) => {
226
- let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
227
- let sa = Math.sin(azimuth)
228
- let ca = Math.cos(azimuth)
229
- let se = Math.sin(elevation)
230
- let ce = Math.cos(elevation)
231
- // right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
232
- // camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
233
- let next: Vec3 = [
234
- target[0] - dx * wpp * ca - dy * wpp * sa * se,
235
- target[1] + dy * wpp * ce,
236
- target[2] + dx * wpp * sa - dy * wpp * ca * se,
237
- ]
238
- target = options.clampTarget ? options.clampTarget(next) : next
239
- }
240
223
  let eye = (): Vec3 => {
241
224
  let ce = Math.cos(elevation)
242
225
  return [
@@ -245,8 +228,26 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
245
228
  target[2] + distance * ce * Math.cos(azimuth),
246
229
  ]
247
230
  }
248
- let apply = () => camera.setCamera({ position: eye(), target })
231
+ let view = () => ({ eye: eye(), target: [target[0], target[1], target[2]] as Vec3 })
232
+ let push = () => {
233
+ changed = true
234
+ camera.setCamera({ position: eye(), target })
235
+ }
236
+ // World units per element height at the target's depth: the frustum's
237
+ // height there, from the target camera's vertical fov.
238
+ let worldPerHeight = () => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * distance
249
239
 
240
+ // Slide eye and target together along the camera's right/up. The
241
+ // basis for this pose: right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se),
242
+ // written out from eye() = target + distance * (ce*sa, se, ce*ca).
243
+ let slide = (right: number, up: number) => {
244
+ let sa = Math.sin(azimuth)
245
+ let ca = Math.cos(azimuth)
246
+ let se = Math.sin(elevation)
247
+ let ce = Math.cos(elevation)
248
+ let next: Vec3 = [target[0] + right * ca - up * sa * se, target[1] + up * ce, target[2] - right * sa - up * ca * se]
249
+ target = options.clampTarget ? options.clampTarget(next) : next
250
+ }
250
251
  // Zoom by `ratio` (new distance over old, before clamping) about a world
251
252
  // anchor (null zooms toward the target). Scaling eye and target about the
252
253
  // anchor by the distance ratio keeps it projecting to the same pixel, so
@@ -256,122 +257,152 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
256
257
  let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
257
258
  let prev = distance
258
259
  // Bounds widen to the current distance so a pose already outside them
259
- // (a rotateAnchor re-seat may land anywhere) zooms back toward range
260
+ // (a setPivot re-seat may land anywhere) zooms back toward range
260
261
  // instead of snap-jumping into it.
261
262
  distance = clampNum(distance * ratio, Math.min(minDistance(), prev), Math.max(maxDistance(), prev))
262
263
  if (!anchor) return
263
264
  let s = distance / prev
264
- target = [
265
- anchor[0] + (target[0] - anchor[0]) * s,
266
- anchor[1] + (target[1] - anchor[1]) * s,
267
- anchor[2] + (target[2] - anchor[2]) * s,
268
- ]
265
+ target = [anchor[0] + (target[0] - anchor[0]) * s, anchor[1] + (target[1] - anchor[1]) * s, anchor[2] + (target[2] - anchor[2]) * s]
266
+ }
267
+ let setPivot = (point: Vec3) => {
268
+ let e = eye()
269
+ let fx = (target[0] - e[0]) / distance
270
+ let fy = (target[1] - e[1]) / distance
271
+ let fz = (target[2] - e[2]) / distance
272
+ let depth = (point[0] - e[0]) * fx + (point[1] - e[1]) * fy + (point[2] - e[2]) * fz
273
+ if (!(depth > 0)) return
274
+ // Deliberately unclamped: the picture is unchanged, this only decides
275
+ // what the next gesture pivots about. The zoom clamps widen to the
276
+ // current distance, so a pivot outside [min, max] cannot make the next
277
+ // zoom snap-jump either.
278
+ distance = depth
279
+ target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
280
+ }
281
+ let rotate = (dAzimuth: number, dElevation: number) => {
282
+ azimuth += dAzimuth
283
+ elevation = clampNum(elevation + dElevation, minElevation(), maxElevation())
269
284
  }
270
- let anchorAt = (x?: number, y?: number) =>
271
- x !== undefined && y !== undefined && options.zoomAnchor
272
- ? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
273
- : null
274
285
 
275
- // The pinch keeps ONE anchor for its whole gesture, taken at the first
276
- // span change. Per-event re-derivation looks equivalent but jitters in
277
- // practice: the two fingers' events interleave, so the measured span
278
- // oscillates around its true value even while the fingers rest, and every
279
- // event zooms slightly in or back out. About a fixed point those pairs
280
- // cancel exactly (the ratios telescope); about a fresh anchor each time -
281
- // a new raycast against a pose the previous event just moved, or the
282
- // app's hit/fallback choice flipping between events - each pair leaves a
283
- // residual target slide and the model visibly crawls under resting
284
- // fingers. The wheel stays per-event: its notches are discrete, there is
285
- // no noise to cancel, and each notch re-aiming at the cursor is the point.
286
+ // A pinch holds one anchor for its whole gesture (see the header); the
287
+ // wheel, unbracketed, anchors per notch.
286
288
  let pinchAnchor: Vec3 | null = null
287
289
  let pinchSeen = false
290
+ let anchorAt = (focal: Vec2 | undefined): Vec3 | null => (focal && options.zoomAnchor ? options.zoomAnchor(focal, view()) : null)
288
291
 
289
- clampPose()
290
- apply()
291
-
292
- // One finger rotates. With two down, the span change zooms and the focal
293
- // translation pans (or, with no viewport to map pixels through, keeps
294
- // rotating). Pair rotation has no orbit meaning and is ignored.
295
- let transform = createTransform({
296
- onTransformStart: () => {
297
- interacting = true
298
- let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
299
- if (anchor) {
300
- let e = eye()
301
- let fx = (target[0] - e[0]) / distance
302
- let fy = (target[1] - e[1]) / distance
303
- let fz = (target[2] - e[2]) / distance
304
- let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
305
- if (depth > 0) {
306
- // Deliberately unclamped: the pose is unchanged, this only decides
307
- // what the gesture pivots about. The zoom clamps below widen to
308
- // the current distance, so a pivot outside [min, max] cannot make
309
- // the next zoom snap-jump either.
310
- distance = depth
311
- target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
292
+ let axes = createAxes<OrbitAxes>(
293
+ { rotate: "vec2", zoom: "axis", pan: "vec2" },
294
+ {
295
+ onBegin: name => {
296
+ if (name === "rotate") {
297
+ let anchor = options.rotateAnchor?.(view())
298
+ if (anchor) setPivot(anchor)
312
299
  }
313
- }
314
- },
315
- onTransformMove: (t) => {
316
- let vp = options.viewport?.() ?? null
317
- if (t.pointers >= 2 && vp !== null) {
318
- pan(t.dx, t.dy, vp)
319
- } else {
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())
324
- }
325
- if (t.scale !== 1) {
326
- // Fingers spreading (scale > 1) zooms in: the distance shrinks by the
327
- // span ratio, exponent-weighted by the zoomSpeed multiplier, about
328
- // the gesture's anchor when the app provides one.
329
- if (!pinchSeen) {
330
- pinchSeen = true
331
- pinchAnchor = anchorAt(t.x, t.y)
300
+ if (name === "zoom") {
301
+ pinchSeen = false
302
+ pinchAnchor = null
332
303
  }
333
- zoomAbout(1 / Math.pow(t.scale, zoomSpeed()), pinchAnchor)
334
- }
335
- dirty = true
336
- },
337
- onTransformEnd: () => {
338
- interacting = false
339
- pinchSeen = false
340
- pinchAnchor = null
304
+ },
305
+ onNudge: (name, delta, focal) => {
306
+ if (name === "rotate") {
307
+ let d = delta as Vec2
308
+ let rel = DRAG_TURNS * 2 * Math.PI * rotateSpeed()
309
+ rotate(-d[0] * rel, d[1] * rel)
310
+ } else if (name === "zoom") {
311
+ let anchor: Vec3 | null
312
+ if (axes.inGesture("zoom")) {
313
+ if (!pinchSeen) {
314
+ pinchSeen = true
315
+ pinchAnchor = anchorAt(focal)
316
+ }
317
+ anchor = pinchAnchor
318
+ } else {
319
+ anchor = anchorAt(focal)
320
+ }
321
+ zoomAbout(Math.pow(2, -(delta as number) * zoomSpeed()), anchor)
322
+ } else {
323
+ let d = delta as Vec2
324
+ let w = worldPerHeight() * panSpeed()
325
+ slide(-d[0] * w, d[1] * w)
326
+ }
327
+ push()
328
+ },
341
329
  },
342
- })
330
+ )
331
+ let interacting = () => axes.inGesture("rotate") || axes.inGesture("zoom") || axes.inGesture("pan")
332
+ let active = createMemo(() => (orbiting() && orbitSpeed() !== 0) || axes.active())
333
+
334
+ clampPose()
335
+ push()
336
+ changed = false
343
337
 
344
338
  return {
345
339
  eye,
346
- pose: () => ({ azimuth, elevation, distance }),
340
+ pose: () => ({ azimuth, elevation, distance, target: [target[0], target[1], target[2]] }),
347
341
  orbiting,
348
- active: () => orbiting() && orbitSpeed() !== 0,
342
+ active,
343
+ axes,
349
344
  set(pose) {
350
345
  if (pose.azimuth !== undefined) azimuth = pose.azimuth
351
346
  if (pose.elevation !== undefined) elevation = pose.elevation
352
347
  if (pose.distance !== undefined) distance = pose.distance
353
348
  if (pose.target) target = [pose.target[0], pose.target[1], pose.target[2]]
354
349
  clampPose()
355
- if (pose.orbiting !== undefined) setOrbiting(pose.orbiting)
356
- dirty = true
350
+ if (pose.orbiting !== undefined) {
351
+ running = pose.orbiting
352
+ setOrbiting(running)
353
+ }
354
+ push()
357
355
  },
358
356
  update(dt) {
357
+ finite("update dt", dt)
358
+ let moved = false
359
359
  let rate = orbitSpeed()
360
- if (rate !== 0 && !interacting && orbiting()) {
360
+ if (rate !== 0 && !interacting() && running) {
361
361
  azimuth += dt * rate
362
- dirty = true
362
+ moved = true
363
+ }
364
+ let [rx, ry] = untrack(() => axes.rate("rotate"))
365
+ if (rx !== 0 || ry !== 0) {
366
+ let step = ROTATE_RATE * 2 * Math.PI * rotateSpeed() * dt
367
+ rotate(-rx * step, ry * step)
368
+ moved = true
369
+ }
370
+ let z = untrack(() => axes.rate("zoom"))
371
+ if (z !== 0) {
372
+ zoomAbout(Math.pow(2, -z * ZOOM_RATE * zoomSpeed() * dt), null)
373
+ moved = true
363
374
  }
364
- if (!dirty) return false
365
- dirty = false
366
- apply()
367
- return true
375
+ let [px, py] = untrack(() => axes.rate("pan"))
376
+ if (px !== 0 || py !== 0) {
377
+ let w = worldPerHeight() * PAN_RATE * panSpeed() * dt
378
+ slide(-px * w, py * w)
379
+ moved = true
380
+ }
381
+ if (moved) push()
382
+ let result = changed
383
+ changed = false
384
+ return result
368
385
  },
369
- handlers: {
370
- ...transform.handlers,
371
- onWheel(e) {
372
- zoomAbout(Math.exp(e.deltaY * WHEEL_ZOOM * zoomSpeed()), anchorAt(e.localX, e.localY))
373
- dirty = true
374
- },
386
+ rotateBy(dAzimuth, dElevation) {
387
+ finite("rotateBy azimuth", dAzimuth)
388
+ finite("rotateBy elevation", dElevation)
389
+ rotate(dAzimuth, dElevation)
390
+ push()
391
+ },
392
+ zoomBy(factor, anchor) {
393
+ if (!(Number.isFinite(factor) && factor > 0)) throw new Error(`createOrbitCamera: zoomBy factor must be positive, got ${factor}`)
394
+ zoomAbout(1 / factor, anchor)
395
+ push()
396
+ },
397
+ panBy(right, up) {
398
+ finite("panBy right", right)
399
+ finite("panBy up", up)
400
+ slide(right, up)
401
+ push()
402
+ },
403
+ setPivot(point) {
404
+ setPivot(point)
405
+ push()
375
406
  },
376
407
  }
377
408
  }