@solidrt/3d 0.0.55 → 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 (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
package/src/orbit.ts CHANGED
@@ -1,105 +1,125 @@
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
- // Input goes through core's merged transform recognizer (createTransform), so
7
- // the drag participates in the gesture arena: on a viewport embedded in a
8
- // scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
9
- // instead of both acting, and rotation only starts once the drag crosses the
10
- // recognizer's slop. One finger (or a mouse drag) rotates; two-finger pinch
11
- // zooms (pair rotation is ignored); wheel zooms directly - no recognizer
12
- // needed for a discrete wheel step. What two-finger translation means
13
- // depends on `viewport`: with it, two fingers pan - the scene slides under
14
- // the fingers 1:1 at the target's depth, the touch convention everywhere
15
- // (three.js DOLLY_PAN, Sketchfab, touch CAD) - which also keeps an
16
- // imperfect pinch from smearing rotation into the zoom. Without it there
17
- // is no pixel-to-world mapping, so focal translation falls back to
18
- // 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).
19
21
  //
20
22
  // Zoom aims at the target by default. With `zoomAnchor` the app maps the
21
- // pinch focal / wheel cursor to a world point, and the zoom scales the pose
22
- // about that point instead - the spot under the fingers stays under the
23
- // fingers, with the target sliding toward it. Only the app can own that
24
- // mapping: screen-to-ray needs the projection and the element's placement
25
- // (fov, aspect, designSize scaling), which live in the app's camera and layout,
26
- // not here.
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.
27
35
  //
28
- // Anchored zoom leaves the target wherever the zoom carried it - possibly a
29
- // point in empty air near the eye, and a drag orbiting THAT swings the scene
30
- // wildly around nothing. `rotateAnchor` is the countermeasure: at gesture
31
- // start the app names the world point rotation should pivot about (what the
32
- // camera is actually looking at), and the pivot is re-seated there. The
33
- // point is projected onto the view axis first, so re-seating moves only the
34
- // target's depth - the eye and the picture do not change, only what a drag
35
- // swings around.
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.
36
43
  //
37
- // Pose is plain mutable state advanced by update(dt) from the app's own
38
- // onFrame; the control registers no frame loop of its own, so whether
39
- // anything animates stays the app's decision (a paused orbit with no other
40
- // animation costs nothing). Only `orbiting` is a signal - slow UI state
41
- // that HUDs read - while the pose moves at frame rate and bypasses
42
- // reactivity: the package's structure-vs-motion split. update() pushes the
43
- // pose to the scene camera only when it actually changed (one setCamera:
44
- // the scene's own shared write carries uViewProj and uCamPos), and reports
45
- // that, so per-frame dependents (reprojecting HUD overlays via
46
- // 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.
53
+ //
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.
47
60
 
48
- import { createSignal } from "@solidjs/signals"
49
- import { createTransform } from "@solidrt/core"
50
- import type { PointerEvent } from "@solidrt/core"
51
- import type { CameraUpdate } from "./scene.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"
52
65
  import type { Vec3 } from "./math.ts"
53
66
 
54
- // Baseline sensitivities at rotateSpeed/zoomSpeed 1, in radians per dragged
55
- // pixel and zoom exponent per wheel-delta unit.
56
- const DRAG_AZIMUTH = 0.008
57
- const DRAG_ELEVATION = 0.006
58
- const WHEEL_ZOOM = 0.0015
67
+ // One element height of drag sweeps this many full turns on either axis
68
+ // (Three's OrbitControls convention).
69
+ const DRAG_TURNS = 1
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
75
+ // Default clamps: the distance floor keeps the eye off the target; the
76
+ // elevation limits (radians) stop just short of the poles.
77
+ const MIN_DISTANCE = 0.01
78
+ const ELEVATION_LIMIT = 1.55
59
79
 
60
- /** What an orbit camera drives: a Scene, or one of its Views. */
61
- 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"> }
62
83
 
84
+ /** The pose fields (target, azimuth, elevation, distance) are initial
85
+ * values, copied at creation and changed through set() afterwards. Every
86
+ * other field is live: read from this object where it applies, so a
87
+ * change takes effect on the next input or update. */
63
88
  export type OrbitCameraOptions = {
64
- /** The point the camera orbits and looks at (default origin). */
89
+ /** Initial point the camera orbits and looks at (default origin). */
65
90
  target?: Vec3
66
91
  /** Initial pose, radians and world units. */
67
92
  azimuth?: number
68
93
  elevation?: number
69
94
  distance?: number
95
+ /** Distance clamps, world units. */
70
96
  minDistance?: number
71
97
  maxDistance?: number
72
98
  /** Elevation clamps, radians; the defaults stop just short of the poles. */
73
99
  minElevation?: number
74
100
  maxElevation?: number
75
101
  /** Auto-orbit rate in radians/second (default 0: none). Runs while
76
- * `orbiting()` and no drag/pinch is in progress; toggle with
77
- * set({ orbiting }). */
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. */
78
105
  orbitSpeed?: number
79
- /** 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). */
80
108
  rotateSpeed?: number
81
109
  zoomSpeed?: number
82
- /** Multiplier over the two-finger pan (1 = the scene tracks the fingers
83
- * exactly at the target's depth). */
84
110
  panSpeed?: number
85
- /** The viewport the pointer coordinates live in: height in the same
86
- * logical units as clientX/clientY, and the camera's vertical fov in
87
- * degrees. Providing it enables two-finger pan; without it two-finger
88
- * translation rotates, as it always did. */
89
- viewport?: () => { height: number; fov: number }
90
111
  /** Constrain where a pan may put the target - return the target to use.
91
112
  * The typical use: keep the pivot within a few radii of the subject so
92
113
  * panning cannot strand the camera. Zoom and rotation do not consult it. */
93
114
  clampTarget?: (target: Vec3) => Vec3
94
- /** Map a screen point (window coordinates, the clientX/clientY frame) to
95
- * the world point a zoom there should keep pinned. Called once per pinch
96
- * gesture (at its first span change - the anchor then holds for the whole
97
- * gesture, see the jitter note at `pinchAnchor`) and once per wheel event,
98
- * with the pose the zoom is about to apply to. Return null to zoom toward
99
- * the target as usual (also the default). */
100
- zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
101
- /** The world point rotation should pivot about - called when a drag or
102
- * 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
103
123
  * target, preserving the picture exactly (only the pivot's depth moves),
104
124
  * so a drag after an anchored zoom orbits the scene under the camera
105
125
  * instead of wherever the zoom left the target. Points at or behind the
@@ -115,83 +135,90 @@ export type OrbitPose = {
115
135
  orbiting?: boolean
116
136
  }
117
137
 
138
+ export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2" }
139
+
118
140
  export type OrbitCamera = {
119
141
  /** Camera position for the current pose (a fresh array per call). */
120
142
  eye(): Vec3
121
143
  /** Pose snapshot - the shape debug commands return and set() takes. */
122
- pose(): { azimuth: number; elevation: number; distance: number }
123
- /** Merge a pose in (clamps apply); reaches the scene at the next
124
- * 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 }). */
125
147
  set(pose: OrbitPose): void
126
148
  /** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
127
149
  * text can read it. */
128
150
  orbiting(): boolean
129
- /** Advance the auto-orbit and push any pose change to the driven camera.
130
- * Call from onFrame with the frame's dt in seconds; returns whether the
131
- * pose changed. */
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. */
154
+ active(): boolean
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). */
132
158
  update(dt: number): boolean
133
- /** Spread onto the element that receives input:
134
- * `<window {...orbit.handlers} />`. */
135
- handlers: {
136
- onPointerDown(e: PointerEvent): void
137
- onPointerMove(e: PointerEvent): void
138
- onPointerUp(e: PointerEvent): void
139
- /** Position is optional so a bare `{ deltaY }` still zooms; without it
140
- * a zoomAnchor cannot apply and the zoom falls back to the target. */
141
- onWheel(e: { deltaY: number; clientX?: number; clientY?: number }): void
142
- }
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
143
178
  }
144
179
 
145
180
  let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
146
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
+
147
186
  /**
148
187
  * Create an orbit camera driving `camera`'s position and target, where
149
- * `camera` is a Scene or one of its Views (fov, near, and far stay yours via
150
- * its setCamera). The initial pose applies immediately. In a component tree,
151
- * reach the scene via `<Scene ref>` or useScene() (a view via `<View ref>`)
152
- * and hand the handlers to whichever element owns input.
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.
153
192
  */
154
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
+ }
155
197
  let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
156
198
  let azimuth = options.azimuth ?? 0
157
199
  let elevation = options.elevation ?? 0
158
200
  let distance = options.distance ?? 5
159
- let minDistance = options.minDistance ?? 0.01
160
- let maxDistance = options.maxDistance ?? Infinity
161
- let minElevation = options.minElevation ?? -1.55
162
- let maxElevation = options.maxElevation ?? 1.55
163
- let orbitSpeed = options.orbitSpeed ?? 0
164
- let dragAzimuth = DRAG_AZIMUTH * (options.rotateSpeed ?? 1)
165
- let dragElevation = DRAG_ELEVATION * (options.rotateSpeed ?? 1)
166
- let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
201
+ // Everything below the pose is read from `options` where it applies.
202
+ let minDistance = () => options.minDistance ?? MIN_DISTANCE
203
+ let maxDistance = () => options.maxDistance ?? Infinity
204
+ let minElevation = () => options.minElevation ?? -ELEVATION_LIMIT
205
+ let maxElevation = () => options.maxElevation ?? ELEVATION_LIMIT
206
+ let orbitSpeed = () => options.orbitSpeed ?? 0
207
+ let rotateSpeed = () => options.rotateSpeed ?? 1
208
+ let zoomSpeed = () => options.zoomSpeed ?? 1
209
+ let panSpeed = () => options.panSpeed ?? 1
167
210
 
168
- let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
169
- let interacting = false
170
- let dirty = false
211
+ // The auto-orbit switch: starts on when the initial rate is positive.
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
171
218
 
172
219
  let clampPose = () => {
173
- elevation = clampNum(elevation, minElevation, maxElevation)
174
- distance = clampNum(distance, minDistance, maxDistance)
175
- }
176
- // Slide eye and target together along the camera's right/up so the scene
177
- // tracks the fingers: dragged pixels map to world units through the
178
- // frustum height at the target's depth. Screen +y is down, so the up-axis
179
- // term is added (fingers down -> camera up -> scene follows down).
180
- let pan = (dx: number, dy: number) => {
181
- let vp = options.viewport!()
182
- let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
183
- let sa = Math.sin(azimuth)
184
- let ca = Math.cos(azimuth)
185
- let se = Math.sin(elevation)
186
- let ce = Math.cos(elevation)
187
- // right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
188
- // camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
189
- let next: Vec3 = [
190
- target[0] - dx * wpp * ca - dy * wpp * sa * se,
191
- target[1] + dy * wpp * ce,
192
- target[2] + dx * wpp * sa - dy * wpp * ca * se,
193
- ]
194
- target = options.clampTarget ? options.clampTarget(next) : next
220
+ elevation = clampNum(elevation, minElevation(), maxElevation())
221
+ distance = clampNum(distance, minDistance(), maxDistance())
195
222
  }
196
223
  let eye = (): Vec3 => {
197
224
  let ce = Math.cos(elevation)
@@ -201,8 +228,26 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
201
228
  target[2] + distance * ce * Math.cos(azimuth),
202
229
  ]
203
230
  }
204
- 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
205
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
+ }
206
251
  // Zoom by `ratio` (new distance over old, before clamping) about a world
207
252
  // anchor (null zooms toward the target). Scaling eye and target about the
208
253
  // anchor by the distance ratio keeps it projecting to the same pixel, so
@@ -212,117 +257,152 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
212
257
  let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
213
258
  let prev = distance
214
259
  // Bounds widen to the current distance so a pose already outside them
215
- // (a rotateAnchor re-seat may land anywhere) zooms back toward range
260
+ // (a setPivot re-seat may land anywhere) zooms back toward range
216
261
  // instead of snap-jumping into it.
217
- distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
262
+ distance = clampNum(distance * ratio, Math.min(minDistance(), prev), Math.max(maxDistance(), prev))
218
263
  if (!anchor) return
219
264
  let s = distance / prev
220
- target = [
221
- anchor[0] + (target[0] - anchor[0]) * s,
222
- anchor[1] + (target[1] - anchor[1]) * s,
223
- anchor[2] + (target[2] - anchor[2]) * s,
224
- ]
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())
225
284
  }
226
- let anchorAt = (x?: number, y?: number) =>
227
- x !== undefined && y !== undefined && options.zoomAnchor
228
- ? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
229
- : null
230
285
 
231
- // The pinch keeps ONE anchor for its whole gesture, taken at the first
232
- // span change. Per-event re-derivation looks equivalent but jitters in
233
- // practice: the two fingers' events interleave, so the measured span
234
- // oscillates around its true value even while the fingers rest, and every
235
- // event zooms slightly in or back out. About a fixed point those pairs
236
- // cancel exactly (the ratios telescope); about a fresh anchor each time -
237
- // a new raycast against a pose the previous event just moved, or the
238
- // app's hit/fallback choice flipping between events - each pair leaves a
239
- // residual target slide and the model visibly crawls under resting
240
- // fingers. The wheel stays per-event: its notches are discrete, there is
241
- // 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.
242
288
  let pinchAnchor: Vec3 | null = null
243
289
  let pinchSeen = false
290
+ let anchorAt = (focal: Vec2 | undefined): Vec3 | null => (focal && options.zoomAnchor ? options.zoomAnchor(focal, view()) : null)
244
291
 
245
- clampPose()
246
- apply()
247
-
248
- // One finger rotates. With two down, the span change zooms and the focal
249
- // translation pans (or, with no viewport to map pixels through, keeps
250
- // rotating). Pair rotation has no orbit meaning and is ignored.
251
- let transform = createTransform({
252
- onTransformStart: () => {
253
- interacting = true
254
- let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
255
- if (anchor) {
256
- let e = eye()
257
- let fx = (target[0] - e[0]) / distance
258
- let fy = (target[1] - e[1]) / distance
259
- let fz = (target[2] - e[2]) / distance
260
- let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
261
- if (depth > 0) {
262
- // Deliberately unclamped: the pose is unchanged, this only decides
263
- // what the gesture pivots about. The zoom clamps below widen to
264
- // the current distance, so a pivot outside [min, max] cannot make
265
- // the next zoom snap-jump either.
266
- distance = depth
267
- 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)
268
299
  }
269
- }
270
- },
271
- onTransformMove: (t) => {
272
- if (t.pointers >= 2 && options.viewport) {
273
- pan(t.dx, t.dy)
274
- } else {
275
- azimuth -= t.dx * dragAzimuth
276
- elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
277
- }
278
- if (t.scale !== 1) {
279
- // Fingers spreading (scale > 1) zooms in: the distance shrinks by the
280
- // span ratio, exponent-weighted by the zoomSpeed multiplier, about
281
- // the gesture's anchor when the app provides one.
282
- if (!pinchSeen) {
283
- pinchSeen = true
284
- pinchAnchor = anchorAt(t.x, t.y)
300
+ if (name === "zoom") {
301
+ pinchSeen = false
302
+ pinchAnchor = null
285
303
  }
286
- zoomAbout(1 / Math.pow(t.scale, options.zoomSpeed ?? 1), pinchAnchor)
287
- }
288
- dirty = true
289
- },
290
- onTransformEnd: () => {
291
- interacting = false
292
- pinchSeen = false
293
- 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
+ },
294
329
  },
295
- })
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
296
337
 
297
338
  return {
298
339
  eye,
299
- pose: () => ({ azimuth, elevation, distance }),
340
+ pose: () => ({ azimuth, elevation, distance, target: [target[0], target[1], target[2]] }),
300
341
  orbiting,
342
+ active,
343
+ axes,
301
344
  set(pose) {
302
345
  if (pose.azimuth !== undefined) azimuth = pose.azimuth
303
346
  if (pose.elevation !== undefined) elevation = pose.elevation
304
347
  if (pose.distance !== undefined) distance = pose.distance
305
348
  if (pose.target) target = [pose.target[0], pose.target[1], pose.target[2]]
306
349
  clampPose()
307
- if (pose.orbiting !== undefined) setOrbiting(pose.orbiting)
308
- dirty = true
350
+ if (pose.orbiting !== undefined) {
351
+ running = pose.orbiting
352
+ setOrbiting(running)
353
+ }
354
+ push()
309
355
  },
310
356
  update(dt) {
311
- if (orbitSpeed !== 0 && !interacting && orbiting()) {
312
- azimuth += dt * orbitSpeed
313
- dirty = true
357
+ finite("update dt", dt)
358
+ let moved = false
359
+ let rate = orbitSpeed()
360
+ if (rate !== 0 && !interacting() && running) {
361
+ azimuth += dt * rate
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
314
374
  }
315
- if (!dirty) return false
316
- dirty = false
317
- apply()
318
- 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
319
385
  },
320
- handlers: {
321
- ...transform.handlers,
322
- onWheel(e) {
323
- zoomAbout(Math.exp(e.deltaY * wheelZoom), anchorAt(e.clientX, e.clientY))
324
- dirty = true
325
- },
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()
326
406
  },
327
407
  }
328
408
  }