@solidrt/3d 0.0.60 → 0.0.62
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 +436 -139
- package/examples/cascades.tsx +10 -4
- package/examples/pick.tsx +6 -4
- package/examples/sprites.tsx +7 -1
- package/package.json +4 -3
- package/src/components/context.tsx +13 -2
- package/src/components/first-person-camera.tsx +1 -1
- package/src/components/index.ts +2 -0
- package/src/components/instanced-mesh.tsx +25 -6
- package/src/components/lod.tsx +3 -1
- package/src/components/orbit-camera.tsx +105 -20
- package/src/components/shots.tsx +93 -0
- package/src/first-person.ts +48 -16
- package/src/gltf.ts +316 -62
- package/src/index.ts +8 -3
- package/src/input.ts +61 -17
- package/src/light.ts +25 -2
- package/src/material.ts +101 -25
- package/src/math.ts +11 -0
- package/src/mesh.ts +68 -21
- package/src/model-data.ts +27 -0
- package/src/model-file.ts +112 -37
- package/src/model.ts +90 -29
- package/src/node.ts +1 -1
- package/src/orbit.ts +526 -108
- package/src/scene-shadows.ts +4 -3
- package/src/scene.ts +201 -189
- package/src/shots.ts +63 -0
- package/src/skeleton.ts +39 -7
- package/tools/environment.ts +1 -0
- package/tools/model.ts +4 -3
- package/tools/node-env.d.ts +25 -0
- package/src/motion.ts +0 -17
- package/src/order.ts +0 -58
package/src/orbit.ts
CHANGED
|
@@ -1,37 +1,53 @@
|
|
|
1
1
|
// An orbit camera for a scene: azimuth/elevation/distance around a target
|
|
2
2
|
// - the standard interactive-viewer camera, extracted so apps stop
|
|
3
3
|
// rebuilding it. The control is pure (ARCHITECTURE.md: controls through
|
|
4
|
-
// an abstraction, never direct event handling): it consumes
|
|
5
|
-
// `rotate` (vec2), `zoom` (axis)
|
|
6
|
-
// and exposes pose verbs for scripted moves. What
|
|
7
|
-
// app's business - an input map with a pointer
|
|
8
|
-
// command - and the `orbitBindings` preset in
|
|
9
|
-
// wiring (drag rotates, pinch and wheel zoom,
|
|
4
|
+
// an abstraction, never direct event handling): it consumes four axes,
|
|
5
|
+
// `rotate` (vec2), `zoom` (axis), `pan` (vec2) and `focus` (axis), in
|
|
6
|
+
// device-free units, and exposes pose verbs for scripted moves. What
|
|
7
|
+
// drives the axes is the app's business - an input map with a pointer
|
|
8
|
+
// feed, a pad, a debug command - and the `orbitBindings` preset in
|
|
9
|
+
// input.ts is the standard wiring (drag rotates, pinch and wheel zoom,
|
|
10
|
+
// two fingers pan, a double tap focuses).
|
|
11
|
+
//
|
|
12
|
+
// The control runs the pipeline of okf/design/camera-controls.md: a
|
|
13
|
+
// SOURCE (an input nudge, a verb, or the follow) writes the pose; the
|
|
14
|
+
// FRAMING eases the followed point back into its zones; the LANES (a
|
|
15
|
+
// screen offset, shakes) are summed on top of the pose; the CONSTRAINTS
|
|
16
|
+
// (the clamps, `clampPose`, occlusion) apply; and the PUSH writes the
|
|
17
|
+
// final camera. pose() is the pose alone: a lane never enters it.
|
|
10
18
|
//
|
|
11
19
|
// Rotation is element-relative: one element height of drag travel (the
|
|
12
20
|
// feed's unit) sweeps DRAG_TURNS full turns on either axis, Three's
|
|
13
21
|
// OrbitControls convention, so the same drag feels the same on a phone
|
|
14
22
|
// and a 4k window. A rate on `rotate` (a stick) turns at ROTATE_RATE
|
|
15
23
|
// 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
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
24
|
+
// 1 halves the distance, a rate of 1 halves it per second - a DOLLY,
|
|
25
|
+
// bounded by the target, unless `push` lets a step past `minDistance`
|
|
26
|
+
// carry eye and target forward together by the overflow (camera-controls'
|
|
27
|
+
// infinityDolly), so a pinch moves through a model at a speed that stays
|
|
28
|
+
// continuous across the floor. `pan` slides the target so the scene
|
|
29
|
+
// tracks the fingers 1:1 at the target's depth, the touch convention
|
|
30
|
+
// everywhere (three.js DOLLY_PAN, Sketchfab, touch CAD), mapped through
|
|
31
|
+
// the target camera's fov - which is why the driven target must report
|
|
32
|
+
// its camera (a Scene or a View does); `panPlane: "ground"` pans in the
|
|
33
|
+
// plane orthogonal to world up instead (Three's screenSpacePanning off,
|
|
34
|
+
// MapControls' choice).
|
|
21
35
|
//
|
|
22
|
-
// Zoom aims at the target by default. With `zoomAnchor` the
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
// per-delta re-anchoring turns
|
|
32
|
-
// fixed point the pairs cancel
|
|
33
|
-
// per notch: its notches are
|
|
34
|
-
// each notch re-aiming at the
|
|
36
|
+
// Zoom aims at the target by default. With `zoomAnchor` the gesture's
|
|
37
|
+
// focal point (a fraction of the element) maps to a world point, and the
|
|
38
|
+
// zoom scales the pose about that point instead - the spot under the
|
|
39
|
+
// fingers stays under the fingers, with the target sliding toward it.
|
|
40
|
+
// Focal-to-world needs the projection, which the `<OrbitCamera>`
|
|
41
|
+
// component builds from its scene (a pick, else the target-depth plane)
|
|
42
|
+
// and a function-face caller supplies itself. A pinch keeps ONE anchor
|
|
43
|
+
// for its whole gesture, taken at its first delta: the two fingers'
|
|
44
|
+
// events interleave, so the measured span oscillates around its true
|
|
45
|
+
// value even while the fingers rest, and per-delta re-anchoring turns
|
|
46
|
+
// that noise into a visible crawl; about a fixed point the pairs cancel
|
|
47
|
+
// exactly. The wheel, unbracketed, anchors per notch: its notches are
|
|
48
|
+
// discrete, there is no noise to cancel, and each notch re-aiming at the
|
|
49
|
+
// cursor is the point. `focus` uses the same mapping: a nudge (a double
|
|
50
|
+
// tap) glides the target to the point under it.
|
|
35
51
|
//
|
|
36
52
|
// Anchored zoom leaves the target wherever the zoom carried it - possibly
|
|
37
53
|
// a point in empty air near the eye, and a drag orbiting THAT swings the
|
|
@@ -40,32 +56,57 @@
|
|
|
40
56
|
// pivot about (what the camera is actually looking at), and setPivot
|
|
41
57
|
// re-seats the target there, projected onto the view axis first, so only
|
|
42
58
|
// the target's depth moves - the eye and the picture do not change.
|
|
59
|
+
// setOrbitPoint is the exact form: the target moves to the point itself,
|
|
60
|
+
// off the view axis, and the offset lane takes up the difference so the
|
|
61
|
+
// picture still does not change (camera-controls' setOrbitPoint with a
|
|
62
|
+
// focal offset) - a drag then orbits that point, which is how a viewer
|
|
63
|
+
// pushed through a model keeps turning about the model's centre.
|
|
64
|
+
//
|
|
65
|
+
// The pose moves on its own toward a goal pose, eased by update(dt). Two
|
|
66
|
+
// things write the goal. A delta that arrives outside a gesture - a wheel
|
|
67
|
+
// notch, a key step - is an impulse, not a finger, and with `damping` on
|
|
68
|
+
// (the default) it retargets the goal instead of jumping the pose,
|
|
69
|
+
// notches compounding on the pending value so a fast scroll is one long
|
|
70
|
+
// push; a delta bracketed by a gesture (a drag, a pinch, two fingers)
|
|
71
|
+
// applies at once, the content staying under the fingers, which is how
|
|
72
|
+
// the control tells a finger from an impulse (the 2d camera's rule); the
|
|
73
|
+
// gesture's end brings the release velocity, and with damping on the view
|
|
74
|
+
// keeps turning or sliding from that speed, decaying (Three's
|
|
75
|
+
// enableDamping glide). And a commanded move - glideTo(pose), fit(bounds,
|
|
76
|
+
// { glide }) - eases there the same way. Any input drops a commanded
|
|
77
|
+
// glide (a finger landing holds the view); a pose write through set()
|
|
78
|
+
// lands at once and drops any motion, so a parked pose is a still frame.
|
|
79
|
+
// A zoom's goal keeps its anchor: the target is derived from the eased
|
|
80
|
+
// distance each frame, so the point under the cursor stays pinned through
|
|
81
|
+
// the glide, not just at its end.
|
|
82
|
+
//
|
|
83
|
+
// The follow (Cinemachine's Orbital Follow): follow(point) makes the
|
|
84
|
+
// target chase a world point through the framing of `follow` - a dead
|
|
85
|
+
// zone, hard limits and damping per axis in view space (right, up,
|
|
86
|
+
// forward), lookahead along the point's velocity - while the orbit's own
|
|
87
|
+
// input keeps working around it: a rotate orbits the followed point, a
|
|
88
|
+
// zoom dollies toward it. A pan moves the target and the follow eases it
|
|
89
|
+
// back; unfollow(), or a verb that writes the target, ends it.
|
|
43
90
|
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
// from an impulse (the 2d camera's rule). And a commanded move -
|
|
53
|
-
// glideTo(pose), fit(bounds, { glide }) - eases there the same way. Any
|
|
54
|
-
// input drops a commanded glide (a finger landing holds the view); a
|
|
55
|
-
// pose write through set() lands at once and drops any motion, so a
|
|
56
|
-
// parked pose is a still frame. A zoom's goal keeps its anchor: the
|
|
57
|
-
// target is derived from the eased distance each frame, so the point
|
|
58
|
-
// under the cursor stays pinned through the glide, not just at its end.
|
|
91
|
+
// Occlusion is a constraint on the FINAL camera, not the pose: the
|
|
92
|
+
// `occluder` hook reports the free distance from the target toward the
|
|
93
|
+
// eye (the `<OrbitCamera>` component fills it from a scene raycast), and
|
|
94
|
+
// when it is shorter than the pose's distance the eye is pulled in to it
|
|
95
|
+
// at once, then eased back out as the obstacle clears (Cinemachine's
|
|
96
|
+
// deoccluder damping; Godot's SpringArm3D snaps both ways). The pose's
|
|
97
|
+
// distance is untouched, so a zoom out from behind a wall still goes
|
|
98
|
+
// where the pose says once the wall is gone.
|
|
59
99
|
//
|
|
60
100
|
// Pose is plain mutable state; a nudge or a verb pushes it to the target
|
|
61
101
|
// at once (the next paint carries the new camera, no frame loop needed
|
|
62
|
-
// for a drag), and update(dt) integrates the rates, the auto-orbit
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
// state a HUD reads, and
|
|
66
|
-
// the
|
|
67
|
-
//
|
|
68
|
-
//
|
|
102
|
+
// for a drag), and update(dt) integrates the rates, the auto-orbit, the
|
|
103
|
+
// motion, the follow, the lanes and the occlusion return from the app's
|
|
104
|
+
// own onFrame - the control registers no frame loop of its own. Only
|
|
105
|
+
// `orbiting` and `active` are reactive: slow UI state a HUD reads, and
|
|
106
|
+
// the frame-loop gate. update() returns whether the camera changed since
|
|
107
|
+
// the previous update (pushes included), so per-frame dependents
|
|
108
|
+
// (reprojecting HUD overlays via scene.project) can follow the camera
|
|
109
|
+
// without recomputing every frame.
|
|
69
110
|
//
|
|
70
111
|
// Every pose write funnels through one clamp: the elevation and distance
|
|
71
112
|
// ranges, then the app's `clampPose` hook, which sees the whole pose and
|
|
@@ -75,18 +116,20 @@
|
|
|
75
116
|
// for a legal pose and never shows an illegal one on the way.
|
|
76
117
|
//
|
|
77
118
|
// Options are read where they apply, not copied out: the clamps, the
|
|
78
|
-
// rates and the
|
|
79
|
-
// every input or update, so a caller may change a field (or
|
|
80
|
-
// object of getters, which is what `<OrbitCamera>` does with
|
|
81
|
-
// and the next gesture or frame sees it. Only the initial pose
|
|
82
|
-
// at creation; later pose changes go through set() and the
|
|
119
|
+
// rates, the follow's zones and the hooks are re-read from the options
|
|
120
|
+
// object on every input or update, so a caller may change a field (or
|
|
121
|
+
// hand in an object of getters, which is what `<OrbitCamera>` does with
|
|
122
|
+
// its props) and the next gesture or frame sees it. Only the initial pose
|
|
123
|
+
// is copied at creation; later pose changes go through set() and the
|
|
124
|
+
// verbs.
|
|
83
125
|
|
|
84
|
-
import {
|
|
126
|
+
import { createSignal, untrack } from "@solidjs/signals"
|
|
85
127
|
import { createAxes } from "@solidrt/core/input"
|
|
86
128
|
import type { Axes, Vec2 } from "@solidrt/core/input"
|
|
129
|
+
import { checkFollowOptions, checkOffset, checkShake, createActivity, createLanes, createLookahead, easeStep, followRate, frame, GLIDE_EASE, GLIDE_EPSILON } from "@solidrt/core/camera-control"
|
|
130
|
+
import type { FollowOptions } from "@solidrt/core/camera-control"
|
|
87
131
|
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
88
132
|
import type { Vec3 } from "./math.ts"
|
|
89
|
-
import { easeStep, GLIDE_EASE, GLIDE_EPSILON } from "./motion.ts"
|
|
90
133
|
|
|
91
134
|
// One element height of drag sweeps this many full turns on either axis
|
|
92
135
|
// (Three's OrbitControls convention).
|
|
@@ -100,6 +143,16 @@ const PAN_RATE = 1
|
|
|
100
143
|
// elevation limits (radians) stop just short of the poles.
|
|
101
144
|
const MIN_DISTANCE = 0.01
|
|
102
145
|
const ELEVATION_LIMIT = 1.55
|
|
146
|
+
// A follow or an occlusion return lands once the remaining travel is
|
|
147
|
+
// under this many screen pixels (the 2d camera's threshold), so a
|
|
148
|
+
// resting follow writes nothing.
|
|
149
|
+
const LAND_PX = 0.5
|
|
150
|
+
// A heading recentre lands once the remaining turn is under this many
|
|
151
|
+
// radians (a hundredth of a degree: under a pixel at any distance).
|
|
152
|
+
const HEADING_EPSILON = 1e-4
|
|
153
|
+
// Default seconds without rotate input before the azimuth recentres on
|
|
154
|
+
// the followed heading (Cinemachine's recentering wait).
|
|
155
|
+
const RECENTER_WAIT = 1
|
|
103
156
|
|
|
104
157
|
/** What an orbit camera drives: a Scene, or one of its Views - anything
|
|
105
158
|
* with their setCamera, camera() (the fov maps pan travel to world; the
|
|
@@ -143,6 +196,30 @@ export type OrbitCameraOptions = OrbitPose & {
|
|
|
143
196
|
* is the built-in ease, 2 coasts twice as long, 0 applies such deltas
|
|
144
197
|
* at once. Gesture-bracketed deltas and the rates are never damped. */
|
|
145
198
|
damping?: number
|
|
199
|
+
/** A zoom step that would cross `minDistance` (or `maxDistance`) moves
|
|
200
|
+
* eye and target together by the overflow instead - along the view
|
|
201
|
+
* axis, or the anchor's ray when the step is anchored - so the camera
|
|
202
|
+
* passes through a model at a speed continuous across the floor
|
|
203
|
+
* (default false: the dolly stops at the floor). */
|
|
204
|
+
push?: boolean
|
|
205
|
+
/** Where a pan slides: "screen" (default) along the view's right and
|
|
206
|
+
* up; "ground" along right and the horizontal forward, the plane
|
|
207
|
+
* orthogonal to world up (a map, a table top). */
|
|
208
|
+
panPlane?: "screen" | "ground"
|
|
209
|
+
/** How follow(point) chases its point: damping per view axis (x right,
|
|
210
|
+
* y up, z forward), the dead zone and hard limits as fractions of the
|
|
211
|
+
* view, lookahead; and `heading`, how the azimuth recentres on a
|
|
212
|
+
* followed heading (follow's second argument): `damping` as the
|
|
213
|
+
* others (default 1), after `wait` seconds without rotate input
|
|
214
|
+
* (default 1, Cinemachine's recentering) - a drag looks around, and
|
|
215
|
+
* the camera settles back behind the walker. Read where it applies. */
|
|
216
|
+
follow?: FollowOptions & { heading?: { damping?: number; wait?: number } }
|
|
217
|
+
/** The offset lane: where the target sits in the view, in view HEIGHTS
|
|
218
|
+
* from its centre (the feed's unit; x right, y down: [0.25, 0] shows
|
|
219
|
+
* the target a quarter of the view's height right of centre). Added on
|
|
220
|
+
* top of the pose at push; setOrbitPoint's own offset compounds with
|
|
221
|
+
* it. */
|
|
222
|
+
offset?: Vec2
|
|
146
223
|
/** Constrain the pose: called with the whole pose after every input,
|
|
147
224
|
* verb, motion step and set() (never setPivot, which leaves the picture
|
|
148
225
|
* as it is), after the range clamps; return the fields to change, or
|
|
@@ -151,11 +228,12 @@ export type OrbitCameraOptions = OrbitPose & {
|
|
|
151
228
|
* eye above a floor - an elevation floor that depends on the distance,
|
|
152
229
|
* which a fixed `minElevation` cannot say. */
|
|
153
230
|
clampPose?: (pose: OrbitPoseState) => OrbitPose | void
|
|
154
|
-
/** Map a zoom gesture's focal point - a fraction of the driven
|
|
155
|
-
* [0..1, 0..1] - to the world point
|
|
156
|
-
* the
|
|
157
|
-
*
|
|
158
|
-
* Return null to zoom toward the target as usual (also
|
|
231
|
+
/** Map a zoom or focus gesture's focal point - a fraction of the driven
|
|
232
|
+
* element, [0..1, 0..1] - to the world point it means, with the pose
|
|
233
|
+
* the gesture is about to apply to. Called once per pinch gesture (the
|
|
234
|
+
* anchor holds for the whole gesture), once per wheel notch and once
|
|
235
|
+
* per focus nudge. Return null to zoom toward the target as usual (also
|
|
236
|
+
* the default) or to ignore the focus. */
|
|
159
237
|
zoomAnchor?: (focal: Vec2, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
160
238
|
/** The world point rotation should pivot about - called when a rotate
|
|
161
239
|
* gesture begins. It is projected onto the view axis and becomes the new
|
|
@@ -164,23 +242,35 @@ export type OrbitCameraOptions = OrbitPose & {
|
|
|
164
242
|
* instead of wherever the zoom left the target. Points at or behind the
|
|
165
243
|
* eye are ignored, as is null (both keep the current pivot). */
|
|
166
244
|
rotateAnchor?: (view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
245
|
+
/** The occlusion constraint: the free distance from `target` toward
|
|
246
|
+
* `eye` (world units) when something stands between them, else null.
|
|
247
|
+
* A free distance under the pose's pulls the final eye in to it at
|
|
248
|
+
* once; the return eases. Evaluated at every push and every update. */
|
|
249
|
+
occluder?: (target: Vec3, eye: Vec3) => number | null
|
|
167
250
|
}
|
|
168
251
|
|
|
169
|
-
export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2" }
|
|
252
|
+
export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2"; focus: "axis" }
|
|
170
253
|
|
|
171
254
|
export type OrbitCamera = {
|
|
172
|
-
/** Camera position for the current pose (a fresh array per call)
|
|
255
|
+
/** Camera position for the current pose (a fresh array per call): the
|
|
256
|
+
* pose's eye, before the lanes and occlusion; camera() has the final
|
|
257
|
+
* one. */
|
|
173
258
|
eye(): Vec3
|
|
259
|
+
/** The final camera as last pushed: the pose plus the lanes, the eye
|
|
260
|
+
* pulled in by occlusion (fresh arrays per call). */
|
|
261
|
+
camera(): { position: Vec3; target: Vec3 }
|
|
174
262
|
/** Pose snapshot - the shape debug commands return and set() takes. */
|
|
175
263
|
pose(): OrbitPoseState
|
|
176
264
|
/** Merge a pose in (clamps apply) and push it: a snap, dropping any
|
|
177
|
-
* glide or damped motion in flight when it writes a pose field
|
|
178
|
-
*
|
|
179
|
-
*
|
|
265
|
+
* glide or damped motion in flight when it writes a pose field, and the
|
|
266
|
+
* follow and setOrbitPoint's offset when it writes the target. Also the
|
|
267
|
+
* auto-orbit switch: set({ orbiting: false }), which leaves a motion
|
|
268
|
+
* running. */
|
|
180
269
|
set(pose: OrbitPose & { orbiting?: boolean }): void
|
|
181
270
|
/** Ease to a pose (the fields given, the rest as they are; clamps
|
|
182
271
|
* apply) inside update(dt). Dropped by any input and by a set() that
|
|
183
|
-
* writes a pose field; a new glideTo retargets.
|
|
272
|
+
* writes a pose field; a new glideTo retargets. A target ends the
|
|
273
|
+
* follow and setOrbitPoint's offset. */
|
|
184
274
|
glideTo(pose: OrbitPose): void
|
|
185
275
|
/** Frame a box, `[minX, minY, minZ, maxX, maxY, maxZ]` (geometryBounds'
|
|
186
276
|
* shape, a model's `bounds`): the target moves to its centre and the
|
|
@@ -189,24 +279,38 @@ export type OrbitCamera = {
|
|
|
189
279
|
* they are, clamps applied. A snap, so a park-then-snapshot repeats;
|
|
190
280
|
* `{ glide: true }` eases there through glideTo. Under an orthographic
|
|
191
281
|
* camera only the target moves: the extents frame, not the distance.
|
|
192
|
-
* Reads the target's size when called. */
|
|
282
|
+
* Reads the target's size when called. Ends the follow. */
|
|
193
283
|
fit(bounds: ArrayLike<number>, opts?: { glide?: boolean }): void
|
|
284
|
+
/** Chase a world point (the framing stage, options.follow): the target
|
|
285
|
+
* eases toward it per view axis, through the dead zone and hard
|
|
286
|
+
* limits, with lookahead. `heading`, when given, is the followed
|
|
287
|
+
* thing's yaw in the first-person convention (0 faces -z, positive
|
|
288
|
+
* turns left; a walker's `pose().yaw`): the azimuth recentres on it -
|
|
289
|
+
* the camera settles behind the walker - after `follow.heading.wait`
|
|
290
|
+
* seconds without rotate input, by the shortest turn. Call it every
|
|
291
|
+
* frame for a moving point; the control settles once the point rests
|
|
292
|
+
* inside the zones and the heading is reached. */
|
|
293
|
+
follow(point: Vec3, heading?: number): void
|
|
294
|
+
unfollow(): void
|
|
194
295
|
/** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
|
|
195
296
|
* text can read it. */
|
|
196
297
|
orbiting(): boolean
|
|
197
298
|
/** Whether update(dt) has work: the auto-orbit on with a non-zero
|
|
198
|
-
* `orbitSpeed`, a glide or damped motion in flight,
|
|
199
|
-
*
|
|
200
|
-
*
|
|
299
|
+
* `orbitSpeed`, a glide or damped motion in flight, a follow not yet
|
|
300
|
+
* settled, a shake running, an occlusion return easing, or any axis
|
|
301
|
+
* rate non-zero. The frame-loop gate, reactive - the same predicate
|
|
302
|
+
* every camera control exposes. */
|
|
201
303
|
active(): boolean
|
|
202
|
-
/** Integrate the axis rates, the auto-orbit
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* included). */
|
|
304
|
+
/** Integrate the axis rates, the auto-orbit, the motion, the follow,
|
|
305
|
+
* the lanes and the occlusion return over dt seconds and push any
|
|
306
|
+
* camera change. Call from onFrame; returns whether the camera changed
|
|
307
|
+
* since the previous update (nudges and verbs included). */
|
|
206
308
|
update(dt: number): boolean
|
|
207
309
|
/** The input abstraction: `rotate` (vec2, element heights of drag /
|
|
208
|
-
* turns per second), `zoom` (axis, octaves, positive in)
|
|
209
|
-
*
|
|
310
|
+
* turns per second), `zoom` (axis, octaves, positive in), `pan` (vec2,
|
|
311
|
+
* element heights) and `focus` (axis: a nudge with a focal glides the
|
|
312
|
+
* target to the point under it through `zoomAnchor`; without a focal,
|
|
313
|
+
* to the point under the view centre). An input map drives it by name
|
|
210
314
|
* (InputMap.drive); an app may add sources and nudge directly. */
|
|
211
315
|
axes: Axes<OrbitAxes>
|
|
212
316
|
/** Turn by radians (azimuth positive counterclockwise seen from above,
|
|
@@ -214,15 +318,30 @@ export type OrbitCamera = {
|
|
|
214
318
|
rotateBy(azimuth: number, elevation: number): void
|
|
215
319
|
/** Scale the distance by 1/factor (factor > 1 zooms in, as the 2d
|
|
216
320
|
* camera's zoomAt) about `anchor`, a world point that stays where it
|
|
217
|
-
* projects (default: toward the target), and push.
|
|
321
|
+
* projects (default: toward the target), and push. With `push` on, the
|
|
322
|
+
* overflow past the floor moves through. */
|
|
218
323
|
zoomBy(factor: number, anchor?: Vec3 | null): void
|
|
219
324
|
/** Slide the target (and the eye with it) by world units along the
|
|
220
|
-
* camera's right and up
|
|
325
|
+
* camera's right and up (or the ground plane, `panPlane`), through
|
|
326
|
+
* `clampPose`, and push. */
|
|
221
327
|
panBy(right: number, up: number): void
|
|
222
328
|
/** Re-seat the pivot at a world point without moving the picture: the
|
|
223
329
|
* point is projected onto the view axis and becomes the target at that
|
|
224
330
|
* depth. Points at or behind the eye are ignored. */
|
|
225
331
|
setPivot(point: Vec3): void
|
|
332
|
+
/** Re-seat the pivot at a world point EXACTLY, without moving the
|
|
333
|
+
* picture: the target becomes the point, the distance its depth, and
|
|
334
|
+
* the offset lane takes up the point's offset from the view axis, so
|
|
335
|
+
* the camera's position and orientation do not change and the next
|
|
336
|
+
* drag orbits that point (camera-controls' setOrbitPoint). Points at or
|
|
337
|
+
* behind the eye are ignored. A later verb that writes the target
|
|
338
|
+
* clears the offset this added. */
|
|
339
|
+
setOrbitPoint(point: Vec3): void
|
|
340
|
+
/** A shake on the lanes: `strength` is the peak displacement in view
|
|
341
|
+
* heights, `duration` seconds, an optional frequency (cycles per
|
|
342
|
+
* second) and direction (a unit [x, y], y down). Shakes sum and decay;
|
|
343
|
+
* the pose is untouched. */
|
|
344
|
+
shake(strength: number, duration: number, opts?: { frequency?: number; direction?: Vec2 }): void
|
|
226
345
|
}
|
|
227
346
|
|
|
228
347
|
// The goal pose a motion eases toward (see the header). A damped zoom
|
|
@@ -248,11 +367,16 @@ function finite(what: string, v: number): void {
|
|
|
248
367
|
if (!Number.isFinite(v)) throw new Error(`createOrbitCamera: ${what} must be a finite number, got ${v}`)
|
|
249
368
|
}
|
|
250
369
|
|
|
370
|
+
function checkVec3(what: string, v: Vec3): void {
|
|
371
|
+
if (!Array.isArray(v) || v.length !== 3) throw new Error(`createOrbitCamera: ${what} must be [x, y, z], got ${JSON.stringify(v)}`)
|
|
372
|
+
for (let i = 0; i < 3; i++) finite(`${what}[${i}]`, v[i]!)
|
|
373
|
+
}
|
|
374
|
+
|
|
251
375
|
function checkPose(verb: string, pose: OrbitPose): void {
|
|
252
376
|
if (pose.azimuth !== undefined) finite(`${verb} azimuth`, pose.azimuth)
|
|
253
377
|
if (pose.elevation !== undefined) finite(`${verb} elevation`, pose.elevation)
|
|
254
378
|
if (pose.distance !== undefined) finite(`${verb} distance`, pose.distance)
|
|
255
|
-
if (pose.target)
|
|
379
|
+
if (pose.target) checkVec3(`${verb} target`, pose.target)
|
|
256
380
|
}
|
|
257
381
|
|
|
258
382
|
/**
|
|
@@ -260,13 +384,16 @@ function checkPose(verb: string, pose: OrbitPose): void {
|
|
|
260
384
|
* `camera` is a Scene or one of its Views (fov, near, and far stay yours
|
|
261
385
|
* via its setCamera). The initial pose applies immediately. In a component
|
|
262
386
|
* tree, prefer the `<OrbitCamera>` component: it drives the enclosing
|
|
263
|
-
* scene or view through context
|
|
387
|
+
* scene or view through context, builds the anchor and occlusion hooks
|
|
388
|
+
* from the scene, and takes an input map as a prop.
|
|
264
389
|
*/
|
|
265
390
|
export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptions = {}): OrbitCamera {
|
|
266
391
|
if (!camera || typeof camera.setCamera !== "function" || typeof camera.camera !== "function" || typeof camera.size !== "function") {
|
|
267
392
|
throw new Error("createOrbitCamera: the target needs setCamera(), camera() and size() (a Scene or a View)")
|
|
268
393
|
}
|
|
269
394
|
checkPose("initial", options)
|
|
395
|
+
checkFollowOptions("createOrbitCamera", options.follow)
|
|
396
|
+
checkOffset("createOrbitCamera", options.offset)
|
|
270
397
|
// The live pose: one object, mutated in place, so the mutations below
|
|
271
398
|
// apply to it and to a motion's goal alike.
|
|
272
399
|
let pose: OrbitPoseState = {
|
|
@@ -285,26 +412,46 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
285
412
|
let zoomSpeed = () => options.zoomSpeed ?? 1
|
|
286
413
|
let panSpeed = () => options.panSpeed ?? 1
|
|
287
414
|
let damping = () => options.damping ?? 1
|
|
415
|
+
let pushing = () => options.push ?? false
|
|
288
416
|
|
|
289
417
|
// The auto-orbit switch: starts on when the initial rate is positive.
|
|
290
418
|
// Plain state is the truth (update() reads it in the same block set()
|
|
291
419
|
// wrote it); the signal notifies HUDs and the active() gate.
|
|
292
420
|
let running = orbitSpeed() > 0
|
|
293
421
|
let [orbiting, setOrbiting] = createSignal(running, { ownedWrite: true })
|
|
294
|
-
// Whether the
|
|
422
|
+
// Whether the camera changed since the last update() (see the header).
|
|
295
423
|
let changed = false
|
|
296
|
-
// The motion in flight,
|
|
297
|
-
//
|
|
298
|
-
// component bodies and handlers alike).
|
|
424
|
+
// The motion in flight, the follow, the lanes and the occlusion return:
|
|
425
|
+
// together the motion half of active() (core's createActivity).
|
|
299
426
|
let motion: Motion | null = null
|
|
300
|
-
let
|
|
301
|
-
let
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
427
|
+
let followAt: Vec3 | null = null
|
|
428
|
+
let followHeading: number | null = null
|
|
429
|
+
let followSettled = false
|
|
430
|
+
// Seconds since the last rotate input, for the heading recentre.
|
|
431
|
+
let rotateIdle = Infinity
|
|
432
|
+
let lookahead = createLookahead()
|
|
433
|
+
let lanes = createLanes()
|
|
434
|
+
// setOrbitPoint's share of the offset lane, on top of options.offset.
|
|
435
|
+
let pivotOffset: Vec2 = [0, 0]
|
|
436
|
+
// The occlusion displacement: the final eye's distance while pulled in
|
|
437
|
+
// (null when the pose's distance stands), easing back to null.
|
|
438
|
+
let occluded: number | null = null
|
|
439
|
+
let occlusionReturning = false
|
|
440
|
+
let busy = () => motion !== null || (followAt !== null && !followSettled) || lanes.active() || occlusionReturning
|
|
441
|
+
// The frame-loop gate (core's createActivity), built once the axes exist.
|
|
442
|
+
let activity!: ReturnType<typeof createActivity>
|
|
443
|
+
let notify = () => activity.notify()
|
|
305
444
|
let interrupt = () => {
|
|
306
445
|
motion = null
|
|
307
446
|
}
|
|
447
|
+
// A verb that writes the target owns it: the follow and the orbit
|
|
448
|
+
// point's offset both go.
|
|
449
|
+
let retarget = () => {
|
|
450
|
+
followAt = null
|
|
451
|
+
followHeading = null
|
|
452
|
+
pivotOffset[0] = 0
|
|
453
|
+
pivotOffset[1] = 0
|
|
454
|
+
}
|
|
308
455
|
|
|
309
456
|
// Distance bounds for a write from `prev`: they widen to it, so a pose
|
|
310
457
|
// already outside them (a setPivot re-seat may land anywhere) moves back
|
|
@@ -327,6 +474,22 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
327
474
|
if (r.distance !== undefined) p.distance = r.distance
|
|
328
475
|
if (r.target) p.target = copy(r.target)
|
|
329
476
|
}
|
|
477
|
+
// The view basis for a pose: the unit vector from the target to the
|
|
478
|
+
// eye, and the camera's right and up, written out from
|
|
479
|
+
// eye() = target + distance * (ce*sa, se, ce*ca).
|
|
480
|
+
let basis = (p: OrbitPoseState) => {
|
|
481
|
+
let sa = Math.sin(p.azimuth)
|
|
482
|
+
let ca = Math.cos(p.azimuth)
|
|
483
|
+
let se = Math.sin(p.elevation)
|
|
484
|
+
let ce = Math.cos(p.elevation)
|
|
485
|
+
return {
|
|
486
|
+
out: [ce * sa, se, ce * ca] as Vec3,
|
|
487
|
+
right: [ca, 0, -sa] as Vec3,
|
|
488
|
+
up: [-sa * se, ce, -ca * se] as Vec3,
|
|
489
|
+
// The horizontal forward, for a ground-plane pan.
|
|
490
|
+
ahead: [-sa, 0, -ca] as Vec3,
|
|
491
|
+
}
|
|
492
|
+
}
|
|
330
493
|
let eyeOf = (p: OrbitPoseState): Vec3 => {
|
|
331
494
|
let ce = Math.cos(p.elevation)
|
|
332
495
|
return [
|
|
@@ -337,41 +500,117 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
337
500
|
}
|
|
338
501
|
let eye = () => eyeOf(pose)
|
|
339
502
|
let view = () => ({ eye: eye(), target: copy(pose.target) })
|
|
503
|
+
// World units per element height at the target's depth: the frustum's
|
|
504
|
+
// height there, from the target camera's vertical fov.
|
|
505
|
+
let worldPerHeightAt = (distance: number) => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * distance
|
|
506
|
+
let worldPerHeight = () => worldPerHeightAt(pose.distance)
|
|
507
|
+
let aspect = () => {
|
|
508
|
+
let size = camera.size()
|
|
509
|
+
return size.height > 0 ? size.width / size.height : 1
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// ---- The lanes and the push: the final camera ----
|
|
513
|
+
// The lane shift in world units: an offset of (ox, oy) view heights
|
|
514
|
+
// shows the target ox right of centre and oy below it, so the look
|
|
515
|
+
// point moves the other way along right and along up.
|
|
516
|
+
let laneShift = (total: Vec2, p: OrbitPoseState): Vec3 => {
|
|
517
|
+
let { right, up } = basis(p)
|
|
518
|
+
let w = worldPerHeightAt(p.distance)
|
|
519
|
+
let sx = -total[0] * w
|
|
520
|
+
let sy = total[1] * w
|
|
521
|
+
return [right[0] * sx + up[0] * sy, right[1] * sx + up[1] * sy, right[2] * sx + up[2] * sy]
|
|
522
|
+
}
|
|
523
|
+
let laneTotal = (): Vec2 => {
|
|
524
|
+
let base = options.offset
|
|
525
|
+
let t = lanes.total(base)
|
|
526
|
+
t[0] += pivotOffset[0]
|
|
527
|
+
t[1] += pivotOffset[1]
|
|
528
|
+
return t
|
|
529
|
+
}
|
|
530
|
+
let last: { position: Vec3; target: Vec3 } = { position: eye(), target: copy(pose.target) }
|
|
531
|
+
// The occlusion constraint at push: a free distance under the pose's
|
|
532
|
+
// pulls the eye in at once; a longer one starts the eased return.
|
|
533
|
+
let occlude = (target: Vec3, eyePos: Vec3) => {
|
|
534
|
+
let hook = options.occluder
|
|
535
|
+
let free = hook ? hook(copy(target), copy(eyePos)) : null
|
|
536
|
+
if (free !== null && Number.isFinite(free) && free < pose.distance) {
|
|
537
|
+
let d = Math.max(free, 0)
|
|
538
|
+
if (occluded === null || d < occluded) occluded = d
|
|
539
|
+
occlusionReturning = false
|
|
540
|
+
} else if (occluded !== null) occlusionReturning = true
|
|
541
|
+
}
|
|
340
542
|
let push = () => {
|
|
341
543
|
changed = true
|
|
342
|
-
|
|
544
|
+
let shift = laneShift(laneTotal(), pose)
|
|
545
|
+
let target: Vec3 = [pose.target[0] + shift[0], pose.target[1] + shift[1], pose.target[2] + shift[2]]
|
|
546
|
+
let e = eye()
|
|
547
|
+
let position: Vec3 = [e[0] + shift[0], e[1] + shift[1], e[2] + shift[2]]
|
|
548
|
+
occlude(target, position)
|
|
549
|
+
if (occluded !== null) {
|
|
550
|
+
let { out } = basis(pose)
|
|
551
|
+
position = [target[0] + out[0] * occluded, target[1] + out[1] * occluded, target[2] + out[2] * occluded]
|
|
552
|
+
}
|
|
553
|
+
last = { position, target }
|
|
554
|
+
camera.setCamera({ position, target })
|
|
343
555
|
}
|
|
344
|
-
// World units per element height at the target's depth: the frustum's
|
|
345
|
-
// height there, from the target camera's vertical fov.
|
|
346
|
-
let worldPerHeight = () => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * pose.distance
|
|
347
556
|
|
|
348
|
-
// Slide eye and target together along the camera's right
|
|
349
|
-
//
|
|
350
|
-
// written out from eye() = target + distance * (ce*sa, se, ce*ca).
|
|
557
|
+
// Slide eye and target together along the camera's right and up (or
|
|
558
|
+
// the ground plane's forward).
|
|
351
559
|
let slide = (p: OrbitPoseState, right: number, up: number) => {
|
|
352
|
-
let
|
|
353
|
-
let
|
|
354
|
-
|
|
355
|
-
let ce = Math.cos(p.elevation)
|
|
356
|
-
p.target = [p.target[0] + right * ca - up * sa * se, p.target[1] + up * ce, p.target[2] - right * sa - up * ca * se]
|
|
560
|
+
let b = basis(p)
|
|
561
|
+
let u = options.panPlane === "ground" ? b.ahead : b.up
|
|
562
|
+
p.target = [p.target[0] + right * b.right[0] + up * u[0], p.target[1] + right * b.right[1] + up * u[1], p.target[2] + right * b.right[2] + up * u[2]]
|
|
357
563
|
}
|
|
358
564
|
// The target a zoom about `anchor` leaves when the distance goes from
|
|
359
565
|
// `refDistance` (target `refTarget`) to `distance`: scaling eye and
|
|
360
566
|
// target about the anchor by the distance ratio keeps it projecting to
|
|
361
567
|
// the same pixel. The ratio is the one that actually applied after
|
|
362
568
|
// clamping - once distance pins at a clamp the target stops moving too,
|
|
363
|
-
// instead of sliding the view sideways under a dead zoom
|
|
569
|
+
// instead of sliding the view sideways under a dead zoom; with `push`
|
|
570
|
+
// the overflow past the clamp moves through instead (below).
|
|
364
571
|
let anchored = (anchor: Vec3, refTarget: Vec3, refDistance: number, distance: number): Vec3 => {
|
|
365
572
|
let s = distance / refDistance
|
|
366
573
|
return [anchor[0] + (refTarget[0] - anchor[0]) * s, anchor[1] + (refTarget[1] - anchor[1]) * s, anchor[2] + (refTarget[2] - anchor[2]) * s]
|
|
367
574
|
}
|
|
575
|
+
// The push: move eye and target together by `overflow` world units
|
|
576
|
+
// (positive forward) along the ray from the eye through the anchor, or
|
|
577
|
+
// the view axis. Along the anchor ray the anchor keeps its pixel.
|
|
578
|
+
let pushThrough = (p: OrbitPoseState, overflow: number, anchor: Vec3 | null | undefined) => {
|
|
579
|
+
if (overflow === 0) return
|
|
580
|
+
let e = eyeOf(p)
|
|
581
|
+
let dx: number
|
|
582
|
+
let dy: number
|
|
583
|
+
let dz: number
|
|
584
|
+
if (anchor) {
|
|
585
|
+
dx = anchor[0] - e[0]
|
|
586
|
+
dy = anchor[1] - e[1]
|
|
587
|
+
dz = anchor[2] - e[2]
|
|
588
|
+
} else {
|
|
589
|
+
dx = p.target[0] - e[0]
|
|
590
|
+
dy = p.target[1] - e[1]
|
|
591
|
+
dz = p.target[2] - e[2]
|
|
592
|
+
}
|
|
593
|
+
let len = Math.hypot(dx, dy, dz)
|
|
594
|
+
if (!(len > 0)) return
|
|
595
|
+
let s = overflow / len
|
|
596
|
+
p.target = [p.target[0] + dx * s, p.target[1] + dy * s, p.target[2] + dz * s]
|
|
597
|
+
}
|
|
598
|
+
// The overflow of a wanted distance past the bounds for a write from
|
|
599
|
+
// `prev`: positive when the step wanted to go closer than the floor.
|
|
600
|
+
let overflowOf = (prev: number, wanted: number): number => {
|
|
601
|
+
if (!pushing()) return 0
|
|
602
|
+
let [lo, hi] = distanceBounds(prev)
|
|
603
|
+
return wanted < lo ? lo - wanted : wanted > hi ? hi - wanted : 0
|
|
604
|
+
}
|
|
368
605
|
// Zoom by `ratio` (new distance over old, before clamping) about a world
|
|
369
606
|
// anchor (null zooms toward the target), at once.
|
|
370
607
|
let zoomAbout = (p: OrbitPoseState, ratio: number, anchor: Vec3 | null | undefined) => {
|
|
371
608
|
let prev = p.distance
|
|
372
609
|
let [lo, hi] = distanceBounds(prev)
|
|
373
|
-
|
|
610
|
+
let wanted = prev * ratio
|
|
611
|
+
p.distance = clampNum(wanted, lo, hi)
|
|
374
612
|
if (anchor) p.target = anchored(anchor, p.target, prev, p.distance)
|
|
613
|
+
pushThrough(p, overflowOf(prev, wanted), anchor)
|
|
375
614
|
}
|
|
376
615
|
let setPivot = (point: Vec3) => {
|
|
377
616
|
let e = eye()
|
|
@@ -387,6 +626,34 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
387
626
|
pose.distance = depth
|
|
388
627
|
pose.target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
389
628
|
}
|
|
629
|
+
// The exact re-seat: the target becomes the point, the distance its
|
|
630
|
+
// depth along the view axis, and the offset lane absorbs the point's
|
|
631
|
+
// sideways offset from the FINAL eye, so position and orientation hold.
|
|
632
|
+
let setOrbitPoint = (point: Vec3) => {
|
|
633
|
+
let { out, right, up } = basis(pose)
|
|
634
|
+
// The final eye without the shakes: where the picture is anchored.
|
|
635
|
+
let baseX = options.offset?.[0] ?? 0
|
|
636
|
+
let baseY = options.offset?.[1] ?? 0
|
|
637
|
+
let shift = laneShift([baseX + pivotOffset[0], baseY + pivotOffset[1]], pose)
|
|
638
|
+
let e = eye()
|
|
639
|
+
let dx = e[0] + shift[0] - point[0]
|
|
640
|
+
let dy = e[1] + shift[1] - point[1]
|
|
641
|
+
let dz = e[2] + shift[2] - point[2]
|
|
642
|
+
let depth = dx * out[0] + dy * out[1] + dz * out[2]
|
|
643
|
+
if (!(depth > 0)) return
|
|
644
|
+
// The sideways remainder lies in the view plane; the lane shift that
|
|
645
|
+
// reproduces it at the new depth is its right and up components.
|
|
646
|
+
let sx = dx - out[0] * depth
|
|
647
|
+
let sy = dy - out[1] * depth
|
|
648
|
+
let sz = dz - out[2] * depth
|
|
649
|
+
let w = worldPerHeightAt(depth)
|
|
650
|
+
let ox = -(sx * right[0] + sy * right[1] + sz * right[2]) / w
|
|
651
|
+
let oy = (sx * up[0] + sy * up[1] + sz * up[2]) / w
|
|
652
|
+
pose.distance = depth
|
|
653
|
+
pose.target = copy(point)
|
|
654
|
+
pivotOffset[0] = ox - baseX
|
|
655
|
+
pivotOffset[1] = oy - baseY
|
|
656
|
+
}
|
|
390
657
|
let rotate = (p: OrbitPoseState, dAzimuth: number, dElevation: number) => {
|
|
391
658
|
p.azimuth += dAzimuth
|
|
392
659
|
p.elevation += dElevation
|
|
@@ -398,6 +665,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
398
665
|
let prev = pose.distance
|
|
399
666
|
mutate(pose)
|
|
400
667
|
clampPose(pose, prev)
|
|
668
|
+
followSettled = false
|
|
401
669
|
}
|
|
402
670
|
let write = (mutate: (p: OrbitPoseState) => void) => {
|
|
403
671
|
mutateLive(mutate)
|
|
@@ -449,6 +717,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
449
717
|
}
|
|
450
718
|
let glideTo = (p: OrbitPose) => {
|
|
451
719
|
checkPose("glideTo", p)
|
|
720
|
+
if (p.target) retarget()
|
|
452
721
|
let goal: OrbitPoseState = {
|
|
453
722
|
azimuth: p.azimuth ?? pose.azimuth,
|
|
454
723
|
elevation: p.elevation ?? pose.elevation,
|
|
@@ -468,25 +737,85 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
468
737
|
// A wheel notch with damping on: the goal's distance compounds, and the
|
|
469
738
|
// goal's target is re-derived about this notch's anchor from the live
|
|
470
739
|
// pose (the 2d camera's anchor glide), so the eased target stays pinned.
|
|
740
|
+
// A pushed overflow moves the goal's target instead, eased as a pose.
|
|
471
741
|
let dampedZoom = (ratio: number, anchor: Vec3 | null) => {
|
|
472
742
|
writeDamped(m => {
|
|
473
743
|
let prev = m.distance
|
|
474
744
|
let [lo, hi] = distanceBounds(prev)
|
|
475
|
-
|
|
745
|
+
let wanted = prev * ratio
|
|
746
|
+
m.distance = clampNum(wanted, lo, hi)
|
|
476
747
|
m.anchor = anchor
|
|
477
748
|
m.refTarget = copy(pose.target)
|
|
478
749
|
m.refDistance = pose.distance
|
|
479
750
|
m.target = anchor ? anchored(anchor, pose.target, pose.distance, m.distance) : copy(pose.target)
|
|
751
|
+
let overflow = overflowOf(prev, wanted)
|
|
752
|
+
if (overflow !== 0) {
|
|
753
|
+
pushThrough(m, overflow, anchor)
|
|
754
|
+
m.anchor = null
|
|
755
|
+
}
|
|
480
756
|
})
|
|
481
757
|
}
|
|
482
758
|
|
|
759
|
+
// ---- The follow: the framing stage ----
|
|
760
|
+
let predicted: number[] = [0, 0, 0]
|
|
761
|
+
let stepFollow = (dt: number) => {
|
|
762
|
+
let point = lookahead.predict(followAt!, options.follow?.lookahead, dt, predicted)
|
|
763
|
+
let { out, right, up } = basis(pose)
|
|
764
|
+
let dx = point[0]! - pose.target[0]
|
|
765
|
+
let dy = point[1]! - pose.target[1]
|
|
766
|
+
let dz = point[2]! - pose.target[2]
|
|
767
|
+
let r = dx * right[0] + dy * right[1] + dz * right[2]
|
|
768
|
+
let u = dx * up[0] + dy * up[1] + dz * up[2]
|
|
769
|
+
// Forward is toward the target from the eye: minus `out`.
|
|
770
|
+
let f = -(dx * out[0] + dy * out[1] + dz * out[2])
|
|
771
|
+
let w = worldPerHeight()
|
|
772
|
+
let ww = w * aspect()
|
|
773
|
+
let size = camera.size()
|
|
774
|
+
let epsilon = size.height > 0 ? LAND_PX / size.height : LAND_PX
|
|
775
|
+
// The point's offset from the view centre in view fractions, y down.
|
|
776
|
+
let framed = frame([r / ww, -u / w], options.follow, dt, epsilon)
|
|
777
|
+
let kz = easeStep(followRate(options.follow, "z"), dt)
|
|
778
|
+
let depthRest = Math.abs(f) * (w > 0 ? 1 / w : 1) < epsilon
|
|
779
|
+
let mz = depthRest ? f : f * kz
|
|
780
|
+
let mx = framed.x * ww
|
|
781
|
+
let mu = -framed.y * w
|
|
782
|
+
if (framed.x !== 0 || framed.y !== 0 || mz !== 0) {
|
|
783
|
+
mutateLive(p => {
|
|
784
|
+
p.target = [
|
|
785
|
+
p.target[0] + right[0] * mx + up[0] * mu - out[0] * mz,
|
|
786
|
+
p.target[1] + right[1] * mx + up[1] * mu - out[1] * mz,
|
|
787
|
+
p.target[2] + right[2] * mx + up[2] * mu - out[2] * mz,
|
|
788
|
+
]
|
|
789
|
+
})
|
|
790
|
+
}
|
|
791
|
+
let moved = framed.x !== 0 || framed.y !== 0 || mz !== 0
|
|
792
|
+
// The heading recentre: the azimuth eases onto the followed heading
|
|
793
|
+
// by the shortest turn once the rotate input has rested for `wait`.
|
|
794
|
+
let headingRest = true
|
|
795
|
+
let h = options.follow?.heading
|
|
796
|
+
if (followHeading !== null && !interacting() && rotateIdle >= (h?.wait ?? RECENTER_WAIT)) {
|
|
797
|
+
let turn = Math.atan2(Math.sin(followHeading - pose.azimuth), Math.cos(followHeading - pose.azimuth))
|
|
798
|
+
if (Math.abs(turn) >= HEADING_EPSILON) {
|
|
799
|
+
let d = h?.damping ?? 1
|
|
800
|
+
let k = d <= 0 ? 1 : easeStep(followRate({ damping: d }, "x"), dt)
|
|
801
|
+
let step = Math.abs(turn) * (1 - k) < HEADING_EPSILON ? turn : turn * k
|
|
802
|
+
mutateLive(p => rotate(p, step, 0))
|
|
803
|
+
headingRest = Math.abs(turn - step) < HEADING_EPSILON
|
|
804
|
+
moved = true
|
|
805
|
+
}
|
|
806
|
+
} else if (followHeading !== null) headingRest = false
|
|
807
|
+
followSettled = framed.settled && depthRest && headingRest
|
|
808
|
+
return moved
|
|
809
|
+
}
|
|
810
|
+
|
|
483
811
|
let axes = createAxes<OrbitAxes>(
|
|
484
|
-
{ rotate: "vec2", zoom: "axis", pan: "vec2" },
|
|
812
|
+
{ rotate: "vec2", zoom: "axis", pan: "vec2", focus: "axis" },
|
|
485
813
|
{
|
|
486
814
|
onBegin: name => {
|
|
487
815
|
// A finger landing holds a glide where it is.
|
|
488
816
|
interrupt()
|
|
489
817
|
if (name === "rotate") {
|
|
818
|
+
rotateIdle = 0
|
|
490
819
|
let anchor = options.rotateAnchor?.(view())
|
|
491
820
|
if (anchor) setPivot(anchor)
|
|
492
821
|
}
|
|
@@ -496,10 +825,32 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
496
825
|
}
|
|
497
826
|
notify()
|
|
498
827
|
},
|
|
828
|
+
onEnd: (name, velocity) => {
|
|
829
|
+
// A lift at speed keeps turning or sliding (Three's enableDamping
|
|
830
|
+
// glide): the damped goal is set the fling's whole travel ahead,
|
|
831
|
+
// velocity over the ease rate, so the motion starts at exactly
|
|
832
|
+
// the release speed and decays. Nothing with damping off, and
|
|
833
|
+
// nothing for a rested finger (no velocity).
|
|
834
|
+
if (velocity === undefined || damping() <= 0 || name === "zoom" || name === "focus") return
|
|
835
|
+
let v = velocity as Vec2
|
|
836
|
+
let travel = 1 / (GLIDE_EASE / damping())
|
|
837
|
+
if (name === "rotate") {
|
|
838
|
+
let rel = DRAG_TURNS * 2 * Math.PI * rotateSpeed()
|
|
839
|
+
writeDamped(m => rotate(m, -v[0] * travel * rel, v[1] * travel * rel))
|
|
840
|
+
} else {
|
|
841
|
+
let w = worldPerHeight() * panSpeed()
|
|
842
|
+
writeDamped(m => {
|
|
843
|
+
slide(m, -v[0] * travel * w, v[1] * travel * w)
|
|
844
|
+
m.anchor = null
|
|
845
|
+
})
|
|
846
|
+
}
|
|
847
|
+
notify()
|
|
848
|
+
},
|
|
499
849
|
onNudge: (name, delta, focal) => {
|
|
500
850
|
let bracketed = axes.inGesture(name)
|
|
501
851
|
let eased = !bracketed && damping() > 0
|
|
502
852
|
if (name === "rotate") {
|
|
853
|
+
rotateIdle = 0
|
|
503
854
|
let d = delta as Vec2
|
|
504
855
|
let rel = DRAG_TURNS * 2 * Math.PI * rotateSpeed()
|
|
505
856
|
let mutate = (p: OrbitPoseState) => rotate(p, -d[0] * rel, d[1] * rel)
|
|
@@ -525,6 +876,10 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
525
876
|
write(p => zoomAbout(p, ratio, anchor))
|
|
526
877
|
}
|
|
527
878
|
}
|
|
879
|
+
} else if (name === "focus") {
|
|
880
|
+
if ((delta as number) === 0) return
|
|
881
|
+
let point = anchorAt(focal ?? [0.5, 0.5])
|
|
882
|
+
if (point) glideTo({ target: point })
|
|
528
883
|
} else {
|
|
529
884
|
let d = delta as Vec2
|
|
530
885
|
let w = worldPerHeight() * panSpeed()
|
|
@@ -544,7 +899,8 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
544
899
|
},
|
|
545
900
|
)
|
|
546
901
|
let interacting = () => axes.inGesture("rotate") || axes.inGesture("zoom") || axes.inGesture("pan")
|
|
547
|
-
|
|
902
|
+
activity = createActivity(busy, () => axes.active())
|
|
903
|
+
let active = () => (orbiting() && orbitSpeed() !== 0) || activity.active()
|
|
548
904
|
|
|
549
905
|
clampPose(pose, null)
|
|
550
906
|
push()
|
|
@@ -552,18 +908,25 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
552
908
|
|
|
553
909
|
return {
|
|
554
910
|
eye,
|
|
911
|
+
camera: () => ({ position: copy(last.position), target: copy(last.target) }),
|
|
555
912
|
pose: () => ({ azimuth: pose.azimuth, elevation: pose.elevation, distance: pose.distance, target: copy(pose.target) }),
|
|
556
913
|
orbiting,
|
|
557
914
|
active,
|
|
558
915
|
axes,
|
|
559
916
|
set(p) {
|
|
560
917
|
checkPose("set", p)
|
|
918
|
+
// The re-validate entry for live options too (set({}) after an
|
|
919
|
+
// options change), so they are checked here as at creation.
|
|
920
|
+
checkFollowOptions("createOrbitCamera", options.follow)
|
|
921
|
+
checkOffset("createOrbitCamera", options.offset)
|
|
561
922
|
if (p.azimuth !== undefined || p.elevation !== undefined || p.distance !== undefined || p.target) interrupt()
|
|
923
|
+
if (p.target) retarget()
|
|
562
924
|
if (p.azimuth !== undefined) pose.azimuth = p.azimuth
|
|
563
925
|
if (p.elevation !== undefined) pose.elevation = p.elevation
|
|
564
926
|
if (p.distance !== undefined) pose.distance = p.distance
|
|
565
927
|
if (p.target) pose.target = copy(p.target)
|
|
566
928
|
clampPose(pose, null)
|
|
929
|
+
followSettled = false
|
|
567
930
|
if (p.orbiting !== undefined) {
|
|
568
931
|
running = p.orbiting
|
|
569
932
|
setOrbiting(running)
|
|
@@ -583,10 +946,8 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
583
946
|
// The sphere is tangent to the frustum's side planes at
|
|
584
947
|
// radius / sin(half-angle); the tighter of the vertical fov and
|
|
585
948
|
// the horizontal one it implies at the target's aspect wins.
|
|
586
|
-
let size = camera.size()
|
|
587
949
|
let vertical = (cam.fov * Math.PI) / 360
|
|
588
|
-
let
|
|
589
|
-
let horizontal = Math.atan(Math.tan(vertical) * aspect)
|
|
950
|
+
let horizontal = Math.atan(Math.tan(vertical) * aspect())
|
|
590
951
|
distance = radius / Math.sin(Math.min(vertical, horizontal))
|
|
591
952
|
}
|
|
592
953
|
let goal: OrbitPose = { target: center, distance }
|
|
@@ -595,12 +956,36 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
595
956
|
return
|
|
596
957
|
}
|
|
597
958
|
interrupt()
|
|
959
|
+
retarget()
|
|
598
960
|
pose.target = center
|
|
599
961
|
pose.distance = distance
|
|
600
962
|
clampPose(pose, null)
|
|
601
963
|
push()
|
|
602
964
|
notify()
|
|
603
965
|
},
|
|
966
|
+
follow(point, heading) {
|
|
967
|
+
checkVec3("follow point", point)
|
|
968
|
+
if (heading !== undefined) finite("follow heading", heading)
|
|
969
|
+
// A commanded glide yields to the follow; a damped notch keeps
|
|
970
|
+
// running (a per-frame follow must not cancel the zoom under it).
|
|
971
|
+
if (motion !== null && motion.commanded) motion = null
|
|
972
|
+
if (followAt === null) {
|
|
973
|
+
followAt = copy(point)
|
|
974
|
+
lookahead.reset()
|
|
975
|
+
} else {
|
|
976
|
+
followAt[0] = point[0]
|
|
977
|
+
followAt[1] = point[1]
|
|
978
|
+
followAt[2] = point[2]
|
|
979
|
+
}
|
|
980
|
+
followHeading = heading ?? null
|
|
981
|
+
followSettled = false
|
|
982
|
+
notify()
|
|
983
|
+
},
|
|
984
|
+
unfollow() {
|
|
985
|
+
followAt = null
|
|
986
|
+
followHeading = null
|
|
987
|
+
notify()
|
|
988
|
+
},
|
|
604
989
|
update(dt) {
|
|
605
990
|
finite("update dt", dt)
|
|
606
991
|
let moved = false
|
|
@@ -616,9 +1001,10 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
616
1001
|
if (rx !== 0 || ry !== 0) {
|
|
617
1002
|
let s = ROTATE_RATE * 2 * Math.PI * rotateSpeed() * dt
|
|
618
1003
|
interrupt()
|
|
1004
|
+
rotateIdle = 0
|
|
619
1005
|
mutateLive(p => rotate(p, -rx * s, ry * s))
|
|
620
1006
|
moved = true
|
|
621
|
-
}
|
|
1007
|
+
} else rotateIdle += dt
|
|
622
1008
|
let z = untrack(() => axes.rate("zoom"))
|
|
623
1009
|
if (z !== 0) {
|
|
624
1010
|
interrupt()
|
|
@@ -636,6 +1022,26 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
636
1022
|
step(dt)
|
|
637
1023
|
moved = true
|
|
638
1024
|
}
|
|
1025
|
+
if (followAt !== null && !followSettled && dt > 0) {
|
|
1026
|
+
if (stepFollow(dt)) moved = true
|
|
1027
|
+
}
|
|
1028
|
+
if (lanes.active() && dt > 0) {
|
|
1029
|
+
lanes.step(dt)
|
|
1030
|
+
moved = true
|
|
1031
|
+
}
|
|
1032
|
+
if (occlusionReturning && occluded !== null && dt > 0) {
|
|
1033
|
+
// The eased return: toward the pose's distance, landing under a
|
|
1034
|
+
// pixel of travel at the target's depth.
|
|
1035
|
+
let gap = pose.distance - occluded
|
|
1036
|
+
let w = worldPerHeight()
|
|
1037
|
+
let size = camera.size()
|
|
1038
|
+
let epsilon = (size.height > 0 ? LAND_PX / size.height : LAND_PX) * w
|
|
1039
|
+
if (gap <= epsilon) {
|
|
1040
|
+
occluded = null
|
|
1041
|
+
occlusionReturning = false
|
|
1042
|
+
} else occluded += gap * easeStep(GLIDE_EASE, dt)
|
|
1043
|
+
moved = true
|
|
1044
|
+
}
|
|
639
1045
|
if (moved) push()
|
|
640
1046
|
notify()
|
|
641
1047
|
let result = changed
|
|
@@ -651,6 +1057,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
651
1057
|
},
|
|
652
1058
|
zoomBy(factor, anchor) {
|
|
653
1059
|
if (!(Number.isFinite(factor) && factor > 0)) throw new Error(`createOrbitCamera: zoomBy factor must be positive, got ${factor}`)
|
|
1060
|
+
if (anchor) checkVec3("zoomBy anchor", anchor)
|
|
654
1061
|
interrupt()
|
|
655
1062
|
write(p => zoomAbout(p, 1 / factor, anchor))
|
|
656
1063
|
notify()
|
|
@@ -663,8 +1070,19 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
663
1070
|
notify()
|
|
664
1071
|
},
|
|
665
1072
|
setPivot(point) {
|
|
1073
|
+
checkVec3("setPivot point", point)
|
|
666
1074
|
setPivot(point)
|
|
667
1075
|
push()
|
|
668
1076
|
},
|
|
1077
|
+
setOrbitPoint(point) {
|
|
1078
|
+
checkVec3("setOrbitPoint point", point)
|
|
1079
|
+
setOrbitPoint(point)
|
|
1080
|
+
push()
|
|
1081
|
+
},
|
|
1082
|
+
shake(strength, duration, opts) {
|
|
1083
|
+
checkShake("createOrbitCamera", strength, duration, opts)
|
|
1084
|
+
lanes.shake(strength, duration, opts)
|
|
1085
|
+
notify()
|
|
1086
|
+
},
|
|
669
1087
|
}
|
|
670
1088
|
}
|