@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.
- package/AGENTS.md +1052 -639
- package/README.md +2 -1
- package/examples/README.md +19 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +15 -10
- package/src/input.ts +79 -0
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +100 -26
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +256 -79
- package/src/skeleton.ts +211 -0
- 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
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// the
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
//
|
|
28
|
-
// about that point instead - the spot under
|
|
29
|
-
// fingers, with the target sliding toward it.
|
|
30
|
-
// mapping:
|
|
31
|
-
// in the app's
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
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
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
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 {
|
|
70
|
-
import type {
|
|
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
|
-
//
|
|
75
|
-
//
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* anchor
|
|
136
|
-
*
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|
|
163
|
-
*
|
|
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)
|
|
169
|
-
* `orbitSpeed
|
|
170
|
-
*
|
|
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
|
-
/**
|
|
173
|
-
* Call from onFrame
|
|
174
|
-
*
|
|
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
|
-
/**
|
|
177
|
-
*
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
194
|
-
* its setCamera). The initial pose applies immediately. In a component
|
|
195
|
-
* tree, prefer the `<OrbitCamera>` component: it
|
|
196
|
-
* scene
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
let
|
|
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
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
//
|
|
276
|
-
//
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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)
|
|
356
|
-
|
|
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 &&
|
|
360
|
+
if (rate !== 0 && !interacting() && running) {
|
|
361
361
|
azimuth += dt * rate
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
}
|