@solidrt/3d 0.0.57 → 0.0.59
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 +1479 -678
- package/README.md +4 -2
- package/examples/README.md +56 -4
- package/examples/bloom.tsx +113 -0
- package/examples/cascades.tsx +4 -4
- package/examples/collision.tsx +6 -6
- package/examples/exits.tsx +96 -0
- package/examples/first-person.tsx +5 -5
- package/examples/fleet.tsx +4 -4
- package/examples/fog.tsx +5 -5
- package/examples/instanced.tsx +70 -50
- package/examples/lamps.tsx +4 -4
- package/examples/lod.tsx +125 -0
- package/examples/materials.tsx +9 -9
- package/examples/morph.tsx +174 -0
- package/examples/normals.tsx +118 -0
- package/examples/{lit.tsx → phong.tsx} +9 -9
- package/examples/polyhedra.tsx +61 -0
- package/examples/scene-atlas.tsx +70 -0
- package/examples/scene-views.tsx +5 -5
- package/examples/shadows.tsx +9 -9
- package/examples/sky-lit.tsx +3 -3
- package/examples/skybox.tsx +5 -5
- package/examples/standard.tsx +2 -2
- package/examples/streams.tsx +122 -0
- package/examples/sweep-paths.tsx +5 -5
- package/examples/wireframe.tsx +129 -0
- package/package.json +3 -3
- package/src/clip.ts +18 -5
- package/src/color.ts +11 -2
- package/src/components/context.tsx +11 -2
- package/src/components/directional-light.tsx +2 -2
- package/src/components/first-person-camera.tsx +4 -4
- package/src/components/group.tsx +9 -5
- package/src/components/hemisphere-light.tsx +2 -2
- package/src/components/index.ts +4 -1
- package/src/components/instanced-mesh.tsx +28 -10
- package/src/components/lod.tsx +102 -0
- package/src/components/mesh.tsx +24 -6
- package/src/components/node-props.ts +35 -5
- package/src/components/orbit-camera.tsx +15 -12
- package/src/components/point-light.tsx +2 -2
- package/src/components/record-mesh.tsx +12 -7
- package/src/components/scene.tsx +55 -2
- package/src/components/spot-light.tsx +2 -2
- package/src/components/sprite.tsx +2 -2
- package/src/components/view3d.tsx +18 -1
- package/src/environment.ts +9 -8
- package/src/first-person.ts +125 -38
- package/src/geometry-gpu.ts +187 -22
- package/src/geometry.ts +1718 -203
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +457 -154
- package/src/index.ts +32 -21
- package/src/input.ts +11 -2
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +322 -190
- package/src/mesh.ts +492 -259
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +151 -54
- package/src/motion.ts +17 -0
- package/src/node.ts +423 -23
- package/src/orbit.ts +378 -116
- package/src/profile.ts +4 -25
- package/src/resolve.ts +181 -0
- package/src/scene-shadows.ts +94 -93
- package/src/scene.ts +608 -185
- package/src/skeleton.ts +211 -0
- package/src/sweep.ts +27 -8
- package/tools/model.ts +3 -1
package/src/orbit.ts
CHANGED
|
@@ -41,15 +41,38 @@
|
|
|
41
41
|
// re-seats the target there, projected onto the view axis first, so only
|
|
42
42
|
// the target's depth moves - the eye and the picture do not change.
|
|
43
43
|
//
|
|
44
|
+
// The pose moves on its own in one way only: toward a goal pose, eased
|
|
45
|
+
// by update(dt) (motion.ts). Two things write the goal. A delta that
|
|
46
|
+
// arrives outside a gesture - a wheel notch, a key step - is an impulse,
|
|
47
|
+
// not a finger, and with `damping` on (the default) it retargets the
|
|
48
|
+
// goal instead of jumping the pose, notches compounding on the pending
|
|
49
|
+
// value so a fast scroll is one long push; a delta bracketed by a
|
|
50
|
+
// gesture (a drag, a pinch, two fingers) applies at once, the content
|
|
51
|
+
// staying under the fingers, which is how the control tells a finger
|
|
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.
|
|
59
|
+
//
|
|
44
60
|
// Pose is plain mutable state; a nudge or a verb pushes it to the target
|
|
45
61
|
// at once (the next paint carries the new camera, no frame loop needed
|
|
46
|
-
// for a drag), and update(dt) integrates the rates
|
|
47
|
-
// from the app's own onFrame - the control registers no frame
|
|
48
|
-
// own. Only `orbiting` and `active` are reactive: slow UI
|
|
49
|
-
// reads, and the frame-loop gate. update() returns whether
|
|
50
|
-
// changed since the previous update (pushes included), so
|
|
51
|
-
// dependents (reprojecting HUD overlays via scene.project) can
|
|
52
|
-
// camera without recomputing every frame.
|
|
62
|
+
// for a drag), and update(dt) integrates the rates, the auto-orbit and
|
|
63
|
+
// the motion from the app's own onFrame - the control registers no frame
|
|
64
|
+
// loop of its own. Only `orbiting` and `active` are reactive: slow UI
|
|
65
|
+
// state a HUD reads, and the frame-loop gate. update() returns whether
|
|
66
|
+
// the pose changed since the previous update (pushes included), so
|
|
67
|
+
// per-frame dependents (reprojecting HUD overlays via scene.project) can
|
|
68
|
+
// follow the camera without recomputing every frame.
|
|
69
|
+
//
|
|
70
|
+
// Every pose write funnels through one clamp: the elevation and distance
|
|
71
|
+
// ranges, then the app's `clampPose` hook, which sees the whole pose and
|
|
72
|
+
// so can express a clamp that depends on it (an eye held above a floor
|
|
73
|
+
// tightens the elevation floor as the zoom pulls out). A goal is clamped
|
|
74
|
+
// when it is set and the eased pose again every frame, so a motion heads
|
|
75
|
+
// for a legal pose and never shows an illegal one on the way.
|
|
53
76
|
//
|
|
54
77
|
// Options are read where they apply, not copied out: the clamps, the
|
|
55
78
|
// rates and the anchor callbacks are re-read from the options object on
|
|
@@ -63,6 +86,7 @@ import { createAxes } from "@solidrt/core/input"
|
|
|
63
86
|
import type { Axes, Vec2 } from "@solidrt/core/input"
|
|
64
87
|
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
65
88
|
import type { Vec3 } from "./math.ts"
|
|
89
|
+
import { easeStep, GLIDE_EASE, GLIDE_EPSILON } from "./motion.ts"
|
|
66
90
|
|
|
67
91
|
// One element height of drag sweeps this many full turns on either axis
|
|
68
92
|
// (Three's OrbitControls convention).
|
|
@@ -78,20 +102,26 @@ const MIN_DISTANCE = 0.01
|
|
|
78
102
|
const ELEVATION_LIMIT = 1.55
|
|
79
103
|
|
|
80
104
|
/** What an orbit camera drives: a Scene, or one of its Views - anything
|
|
81
|
-
* with their setCamera
|
|
82
|
-
|
|
105
|
+
* with their setCamera, camera() (the fov maps pan travel to world; the
|
|
106
|
+
* projection decides what fit() frames) and size() (the aspect fit()
|
|
107
|
+
* frames against). */
|
|
108
|
+
export type OrbitTarget = {
|
|
109
|
+
setCamera(update: CameraUpdate): void
|
|
110
|
+
camera(): Pick<CameraState, "fov" | "ortho">
|
|
111
|
+
size(): { width: number; height: number }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A full pose: what pose() returns and clampPose sees. */
|
|
115
|
+
export type OrbitPoseState = { azimuth: number; elevation: number; distance: number; target: Vec3 }
|
|
116
|
+
|
|
117
|
+
/** A partial pose: what set() and glideTo() take and clampPose returns. */
|
|
118
|
+
export type OrbitPose = Partial<OrbitPoseState>
|
|
83
119
|
|
|
84
120
|
/** The pose fields (target, azimuth, elevation, distance) are initial
|
|
85
121
|
* values, copied at creation and changed through set() afterwards. Every
|
|
86
122
|
* other field is live: read from this object where it applies, so a
|
|
87
123
|
* change takes effect on the next input or update. */
|
|
88
|
-
export type OrbitCameraOptions = {
|
|
89
|
-
/** Initial point the camera orbits and looks at (default origin). */
|
|
90
|
-
target?: Vec3
|
|
91
|
-
/** Initial pose, radians and world units. */
|
|
92
|
-
azimuth?: number
|
|
93
|
-
elevation?: number
|
|
94
|
-
distance?: number
|
|
124
|
+
export type OrbitCameraOptions = OrbitPose & {
|
|
95
125
|
/** Distance clamps, world units. */
|
|
96
126
|
minDistance?: number
|
|
97
127
|
maxDistance?: number
|
|
@@ -108,10 +138,19 @@ export type OrbitCameraOptions = {
|
|
|
108
138
|
rotateSpeed?: number
|
|
109
139
|
zoomSpeed?: number
|
|
110
140
|
panSpeed?: number
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
|
|
141
|
+
/** How long an unbracketed delta (a wheel notch, a key step) takes to
|
|
142
|
+
* ease in, as a multiple of the built-in settle time: 1 (the default)
|
|
143
|
+
* is the built-in ease, 2 coasts twice as long, 0 applies such deltas
|
|
144
|
+
* at once. Gesture-bracketed deltas and the rates are never damped. */
|
|
145
|
+
damping?: number
|
|
146
|
+
/** Constrain the pose: called with the whole pose after every input,
|
|
147
|
+
* verb, motion step and set() (never setPivot, which leaves the picture
|
|
148
|
+
* as it is), after the range clamps; return the fields to change, or
|
|
149
|
+
* nothing to accept it. The typical uses: keep the target within a few
|
|
150
|
+
* radii of the subject so a pan cannot strand the camera, or hold the
|
|
151
|
+
* eye above a floor - an elevation floor that depends on the distance,
|
|
152
|
+
* which a fixed `minElevation` cannot say. */
|
|
153
|
+
clampPose?: (pose: OrbitPoseState) => OrbitPose | void
|
|
115
154
|
/** Map a zoom gesture's focal point - a fraction of the driven element,
|
|
116
155
|
* [0..1, 0..1] - to the world point the zoom should keep pinned, with
|
|
117
156
|
* the pose the zoom is about to apply to. Called once per pinch gesture
|
|
@@ -127,34 +166,43 @@ export type OrbitCameraOptions = {
|
|
|
127
166
|
rotateAnchor?: (view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
128
167
|
}
|
|
129
168
|
|
|
130
|
-
export type OrbitPose = {
|
|
131
|
-
azimuth?: number
|
|
132
|
-
elevation?: number
|
|
133
|
-
distance?: number
|
|
134
|
-
target?: Vec3
|
|
135
|
-
orbiting?: boolean
|
|
136
|
-
}
|
|
137
|
-
|
|
138
169
|
export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2" }
|
|
139
170
|
|
|
140
171
|
export type OrbitCamera = {
|
|
141
172
|
/** Camera position for the current pose (a fresh array per call). */
|
|
142
173
|
eye(): Vec3
|
|
143
174
|
/** Pose snapshot - the shape debug commands return and set() takes. */
|
|
144
|
-
pose():
|
|
145
|
-
/** Merge a pose in (clamps apply) and push it
|
|
146
|
-
*
|
|
147
|
-
|
|
175
|
+
pose(): OrbitPoseState
|
|
176
|
+
/** 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. Also
|
|
178
|
+
* the auto-orbit switch: set({ orbiting: false }), which leaves a
|
|
179
|
+
* motion running. */
|
|
180
|
+
set(pose: OrbitPose & { orbiting?: boolean }): void
|
|
181
|
+
/** Ease to a pose (the fields given, the rest as they are; clamps
|
|
182
|
+
* apply) inside update(dt). Dropped by any input and by a set() that
|
|
183
|
+
* writes a pose field; a new glideTo retargets. */
|
|
184
|
+
glideTo(pose: OrbitPose): void
|
|
185
|
+
/** Frame a box, `[minX, minY, minZ, maxX, maxY, maxZ]` (geometryBounds'
|
|
186
|
+
* shape, a model's `bounds`): the target moves to its centre and the
|
|
187
|
+
* distance to where its bounding sphere fills the view at the target's
|
|
188
|
+
* fov and aspect (the tighter of the two), azimuth and elevation as
|
|
189
|
+
* they are, clamps applied. A snap, so a park-then-snapshot repeats;
|
|
190
|
+
* `{ glide: true }` eases there through glideTo. Under an orthographic
|
|
191
|
+
* camera only the target moves: the extents frame, not the distance.
|
|
192
|
+
* Reads the target's size when called. */
|
|
193
|
+
fit(bounds: ArrayLike<number>, opts?: { glide?: boolean }): void
|
|
148
194
|
/** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
|
|
149
195
|
* text can read it. */
|
|
150
196
|
orbiting(): boolean
|
|
151
197
|
/** Whether update(dt) has work: the auto-orbit on with a non-zero
|
|
152
|
-
* `orbitSpeed`, or
|
|
153
|
-
* reactive - the same predicate every
|
|
198
|
+
* `orbitSpeed`, a glide or damped motion in flight, or any axis rate
|
|
199
|
+
* non-zero. The frame-loop gate, reactive - the same predicate every
|
|
200
|
+
* camera control exposes. */
|
|
154
201
|
active(): boolean
|
|
155
|
-
/** Integrate the axis rates
|
|
156
|
-
* any pose change. Call from onFrame; returns whether
|
|
157
|
-
* since the previous update (nudges and verbs
|
|
202
|
+
/** Integrate the axis rates, the auto-orbit and the motion over dt
|
|
203
|
+
* seconds and push any pose change. Call from onFrame; returns whether
|
|
204
|
+
* the pose changed since the previous update (nudges and verbs
|
|
205
|
+
* included). */
|
|
158
206
|
update(dt: number): boolean
|
|
159
207
|
/** The input abstraction: `rotate` (vec2, element heights of drag /
|
|
160
208
|
* turns per second), `zoom` (axis, octaves, positive in) and `pan`
|
|
@@ -169,7 +217,7 @@ export type OrbitCamera = {
|
|
|
169
217
|
* projects (default: toward the target), and push. */
|
|
170
218
|
zoomBy(factor: number, anchor?: Vec3 | null): void
|
|
171
219
|
/** Slide the target (and the eye with it) by world units along the
|
|
172
|
-
* camera's right and up, through `
|
|
220
|
+
* camera's right and up, through `clampPose`, and push. */
|
|
173
221
|
panBy(right: number, up: number): void
|
|
174
222
|
/** Re-seat the pivot at a world point without moving the picture: the
|
|
175
223
|
* point is projected onto the view axis and becomes the target at that
|
|
@@ -177,12 +225,36 @@ export type OrbitCamera = {
|
|
|
177
225
|
setPivot(point: Vec3): void
|
|
178
226
|
}
|
|
179
227
|
|
|
228
|
+
// The goal pose a motion eases toward (see the header). A damped zoom
|
|
229
|
+
// keeps its anchor and the pose it was taken from, and derives the target
|
|
230
|
+
// from the eased distance each frame so the anchor stays pinned; a glide
|
|
231
|
+
// or a damped pan eases the target directly (anchor null).
|
|
232
|
+
type Motion = OrbitPoseState & {
|
|
233
|
+
anchor: Vec3 | null
|
|
234
|
+
refTarget: Vec3
|
|
235
|
+
refDistance: number
|
|
236
|
+
/** E-foldings per second. */
|
|
237
|
+
rate: number
|
|
238
|
+
/** A commanded glide is dropped by any input; damped input compounds. */
|
|
239
|
+
commanded: boolean
|
|
240
|
+
/** The fraction of the initial gap still open. */
|
|
241
|
+
remaining: number
|
|
242
|
+
}
|
|
243
|
+
|
|
180
244
|
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
245
|
+
let copy = (v: Vec3): Vec3 => [v[0], v[1], v[2]]
|
|
181
246
|
|
|
182
247
|
function finite(what: string, v: number): void {
|
|
183
248
|
if (!Number.isFinite(v)) throw new Error(`createOrbitCamera: ${what} must be a finite number, got ${v}`)
|
|
184
249
|
}
|
|
185
250
|
|
|
251
|
+
function checkPose(verb: string, pose: OrbitPose): void {
|
|
252
|
+
if (pose.azimuth !== undefined) finite(`${verb} azimuth`, pose.azimuth)
|
|
253
|
+
if (pose.elevation !== undefined) finite(`${verb} elevation`, pose.elevation)
|
|
254
|
+
if (pose.distance !== undefined) finite(`${verb} distance`, pose.distance)
|
|
255
|
+
if (pose.target) for (let i = 0; i < 3; i++) finite(`${verb} target[${i}]`, pose.target[i]!)
|
|
256
|
+
}
|
|
257
|
+
|
|
186
258
|
/**
|
|
187
259
|
* Create an orbit camera driving `camera`'s position and target, where
|
|
188
260
|
* `camera` is a Scene or one of its Views (fov, near, and far stay yours
|
|
@@ -191,13 +263,18 @@ function finite(what: string, v: number): void {
|
|
|
191
263
|
* scene or view through context and takes an input map as a prop.
|
|
192
264
|
*/
|
|
193
265
|
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
|
|
266
|
+
if (!camera || typeof camera.setCamera !== "function" || typeof camera.camera !== "function" || typeof camera.size !== "function") {
|
|
267
|
+
throw new Error("createOrbitCamera: the target needs setCamera(), camera() and size() (a Scene or a View)")
|
|
268
|
+
}
|
|
269
|
+
checkPose("initial", options)
|
|
270
|
+
// The live pose: one object, mutated in place, so the mutations below
|
|
271
|
+
// apply to it and to a motion's goal alike.
|
|
272
|
+
let pose: OrbitPoseState = {
|
|
273
|
+
azimuth: options.azimuth ?? 0,
|
|
274
|
+
elevation: options.elevation ?? 0,
|
|
275
|
+
distance: options.distance ?? 5,
|
|
276
|
+
target: options.target ? copy(options.target) : [0, 0, 0],
|
|
196
277
|
}
|
|
197
|
-
let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
|
|
198
|
-
let azimuth = options.azimuth ?? 0
|
|
199
|
-
let elevation = options.elevation ?? 0
|
|
200
|
-
let distance = options.distance ?? 5
|
|
201
278
|
// Everything below the pose is read from `options` where it applies.
|
|
202
279
|
let minDistance = () => options.minDistance ?? MIN_DISTANCE
|
|
203
280
|
let maxDistance = () => options.maxDistance ?? Infinity
|
|
@@ -207,6 +284,7 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
207
284
|
let rotateSpeed = () => options.rotateSpeed ?? 1
|
|
208
285
|
let zoomSpeed = () => options.zoomSpeed ?? 1
|
|
209
286
|
let panSpeed = () => options.panSpeed ?? 1
|
|
287
|
+
let damping = () => options.damping ?? 1
|
|
210
288
|
|
|
211
289
|
// The auto-orbit switch: starts on when the initial rate is positive.
|
|
212
290
|
// Plain state is the truth (update() reads it in the same block set()
|
|
@@ -215,72 +293,171 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
215
293
|
let [orbiting, setOrbiting] = createSignal(running, { ownedWrite: true })
|
|
216
294
|
// Whether the pose changed since the last update() (see the header).
|
|
217
295
|
let changed = false
|
|
296
|
+
// The motion in flight, and its half of active(): a signal every entry
|
|
297
|
+
// that starts or drops a motion refreshes (ownedWrite: entries run from
|
|
298
|
+
// component bodies and handlers alike).
|
|
299
|
+
let motion: Motion | null = null
|
|
300
|
+
let [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
|
|
301
|
+
let notify = () => {
|
|
302
|
+
let now = motion !== null
|
|
303
|
+
if (now !== untrack(motionActive)) setMotionActive(now)
|
|
304
|
+
}
|
|
305
|
+
let interrupt = () => {
|
|
306
|
+
motion = null
|
|
307
|
+
}
|
|
218
308
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
309
|
+
// Distance bounds for a write from `prev`: they widen to it, so a pose
|
|
310
|
+
// already outside them (a setPivot re-seat may land anywhere) moves back
|
|
311
|
+
// toward range instead of snap-jumping into it. null = the bounds
|
|
312
|
+
// themselves (a set()).
|
|
313
|
+
let distanceBounds = (prev: number | null): [number, number] =>
|
|
314
|
+
prev === null ? [minDistance(), maxDistance()] : [Math.min(minDistance(), prev), Math.max(maxDistance(), prev)]
|
|
315
|
+
// The one clamp every write goes through: the ranges, then the hook.
|
|
316
|
+
let clampPose = (p: OrbitPoseState, prevDistance: number | null) => {
|
|
317
|
+
p.elevation = clampNum(p.elevation, minElevation(), maxElevation())
|
|
318
|
+
let [lo, hi] = distanceBounds(prevDistance)
|
|
319
|
+
p.distance = clampNum(p.distance, lo, hi)
|
|
320
|
+
let hook = options.clampPose
|
|
321
|
+
if (!hook) return
|
|
322
|
+
let r = hook({ azimuth: p.azimuth, elevation: p.elevation, distance: p.distance, target: copy(p.target) })
|
|
323
|
+
if (!r) return
|
|
324
|
+
checkPose("clampPose", r)
|
|
325
|
+
if (r.azimuth !== undefined) p.azimuth = r.azimuth
|
|
326
|
+
if (r.elevation !== undefined) p.elevation = r.elevation
|
|
327
|
+
if (r.distance !== undefined) p.distance = r.distance
|
|
328
|
+
if (r.target) p.target = copy(r.target)
|
|
222
329
|
}
|
|
223
|
-
let
|
|
224
|
-
let ce = Math.cos(elevation)
|
|
330
|
+
let eyeOf = (p: OrbitPoseState): Vec3 => {
|
|
331
|
+
let ce = Math.cos(p.elevation)
|
|
225
332
|
return [
|
|
226
|
-
target[0] + distance * ce * Math.sin(azimuth),
|
|
227
|
-
target[1] + distance * Math.sin(elevation),
|
|
228
|
-
target[2] + distance * ce * Math.cos(azimuth),
|
|
333
|
+
p.target[0] + p.distance * ce * Math.sin(p.azimuth),
|
|
334
|
+
p.target[1] + p.distance * Math.sin(p.elevation),
|
|
335
|
+
p.target[2] + p.distance * ce * Math.cos(p.azimuth),
|
|
229
336
|
]
|
|
230
337
|
}
|
|
231
|
-
let
|
|
338
|
+
let eye = () => eyeOf(pose)
|
|
339
|
+
let view = () => ({ eye: eye(), target: copy(pose.target) })
|
|
232
340
|
let push = () => {
|
|
233
341
|
changed = true
|
|
234
|
-
camera.setCamera({ position: eye(), target })
|
|
342
|
+
camera.setCamera({ position: eye(), target: pose.target })
|
|
235
343
|
}
|
|
236
344
|
// World units per element height at the target's depth: the frustum's
|
|
237
345
|
// height there, from the target camera's vertical fov.
|
|
238
|
-
let worldPerHeight = () => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * distance
|
|
346
|
+
let worldPerHeight = () => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * pose.distance
|
|
239
347
|
|
|
240
348
|
// Slide eye and target together along the camera's right/up. The
|
|
241
349
|
// basis for this pose: right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se),
|
|
242
350
|
// 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
|
-
|
|
249
|
-
|
|
351
|
+
let slide = (p: OrbitPoseState, right: number, up: number) => {
|
|
352
|
+
let sa = Math.sin(p.azimuth)
|
|
353
|
+
let ca = Math.cos(p.azimuth)
|
|
354
|
+
let se = Math.sin(p.elevation)
|
|
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]
|
|
357
|
+
}
|
|
358
|
+
// The target a zoom about `anchor` leaves when the distance goes from
|
|
359
|
+
// `refDistance` (target `refTarget`) to `distance`: scaling eye and
|
|
360
|
+
// target about the anchor by the distance ratio keeps it projecting to
|
|
361
|
+
// the same pixel. The ratio is the one that actually applied after
|
|
362
|
+
// clamping - once distance pins at a clamp the target stops moving too,
|
|
363
|
+
// instead of sliding the view sideways under a dead zoom.
|
|
364
|
+
let anchored = (anchor: Vec3, refTarget: Vec3, refDistance: number, distance: number): Vec3 => {
|
|
365
|
+
let s = distance / refDistance
|
|
366
|
+
return [anchor[0] + (refTarget[0] - anchor[0]) * s, anchor[1] + (refTarget[1] - anchor[1]) * s, anchor[2] + (refTarget[2] - anchor[2]) * s]
|
|
250
367
|
}
|
|
251
368
|
// Zoom by `ratio` (new distance over old, before clamping) about a world
|
|
252
|
-
// anchor (null zooms toward the target)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
let prev = distance
|
|
259
|
-
// Bounds widen to the current distance so a pose already outside them
|
|
260
|
-
// (a setPivot re-seat may land anywhere) zooms back toward range
|
|
261
|
-
// instead of snap-jumping into it.
|
|
262
|
-
distance = clampNum(distance * ratio, Math.min(minDistance(), prev), Math.max(maxDistance(), prev))
|
|
263
|
-
if (!anchor) return
|
|
264
|
-
let s = distance / prev
|
|
265
|
-
target = [anchor[0] + (target[0] - anchor[0]) * s, anchor[1] + (target[1] - anchor[1]) * s, anchor[2] + (target[2] - anchor[2]) * s]
|
|
369
|
+
// anchor (null zooms toward the target), at once.
|
|
370
|
+
let zoomAbout = (p: OrbitPoseState, ratio: number, anchor: Vec3 | null | undefined) => {
|
|
371
|
+
let prev = p.distance
|
|
372
|
+
let [lo, hi] = distanceBounds(prev)
|
|
373
|
+
p.distance = clampNum(p.distance * ratio, lo, hi)
|
|
374
|
+
if (anchor) p.target = anchored(anchor, p.target, prev, p.distance)
|
|
266
375
|
}
|
|
267
376
|
let setPivot = (point: Vec3) => {
|
|
268
377
|
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
|
|
378
|
+
let fx = (pose.target[0] - e[0]) / pose.distance
|
|
379
|
+
let fy = (pose.target[1] - e[1]) / pose.distance
|
|
380
|
+
let fz = (pose.target[2] - e[2]) / pose.distance
|
|
272
381
|
let depth = (point[0] - e[0]) * fx + (point[1] - e[1]) * fy + (point[2] - e[2]) * fz
|
|
273
382
|
if (!(depth > 0)) return
|
|
274
383
|
// Deliberately unclamped: the picture is unchanged, this only decides
|
|
275
|
-
// what the next gesture pivots about. The
|
|
276
|
-
//
|
|
384
|
+
// what the next gesture pivots about. The distance bounds widen to the
|
|
385
|
+
// pose's distance, so a pivot outside [min, max] cannot make the next
|
|
277
386
|
// zoom snap-jump either.
|
|
278
|
-
distance = depth
|
|
279
|
-
target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
387
|
+
pose.distance = depth
|
|
388
|
+
pose.target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
280
389
|
}
|
|
281
|
-
let rotate = (dAzimuth: number, dElevation: number) => {
|
|
282
|
-
azimuth += dAzimuth
|
|
283
|
-
elevation
|
|
390
|
+
let rotate = (p: OrbitPoseState, dAzimuth: number, dElevation: number) => {
|
|
391
|
+
p.azimuth += dAzimuth
|
|
392
|
+
p.elevation += dElevation
|
|
393
|
+
}
|
|
394
|
+
// A write to the live pose: mutate, then clamp from the distance before
|
|
395
|
+
// the write. `write` is the entry's form, pushing at once; update(dt)
|
|
396
|
+
// mutates and pushes once at its end.
|
|
397
|
+
let mutateLive = (mutate: (p: OrbitPoseState) => void) => {
|
|
398
|
+
let prev = pose.distance
|
|
399
|
+
mutate(pose)
|
|
400
|
+
clampPose(pose, prev)
|
|
401
|
+
}
|
|
402
|
+
let write = (mutate: (p: OrbitPoseState) => void) => {
|
|
403
|
+
mutateLive(mutate)
|
|
404
|
+
push()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---- Motion: the goal pose and its ease ----
|
|
408
|
+
let startMotion = (goal: OrbitPoseState, rate: number, commanded: boolean): Motion => {
|
|
409
|
+
motion = { ...goal, target: copy(goal.target), anchor: null, refTarget: copy(pose.target), refDistance: pose.distance, rate, commanded, remaining: 1 }
|
|
410
|
+
return motion
|
|
411
|
+
}
|
|
412
|
+
// The damped motion an unbracketed delta compounds on: the one in
|
|
413
|
+
// flight, or a fresh goal at the live pose (a commanded glide is
|
|
414
|
+
// dropped: the input wins).
|
|
415
|
+
let dampedMotion = (): Motion => {
|
|
416
|
+
let m = motion !== null && !motion.commanded ? motion : startMotion(pose, GLIDE_EASE / damping(), false)
|
|
417
|
+
m.remaining = 1
|
|
418
|
+
return m
|
|
419
|
+
}
|
|
420
|
+
// A damped write: the mutation lands on the goal, clamped from the
|
|
421
|
+
// goal's distance so notches compound within the widened bounds.
|
|
422
|
+
let writeDamped = (mutate: (m: Motion) => void) => {
|
|
423
|
+
let m = dampedMotion()
|
|
424
|
+
let prev = m.distance
|
|
425
|
+
mutate(m)
|
|
426
|
+
clampPose(m, prev)
|
|
427
|
+
}
|
|
428
|
+
let step = (dt: number) => {
|
|
429
|
+
let m = motion!
|
|
430
|
+
let k = easeStep(m.rate, dt)
|
|
431
|
+
m.remaining *= 1 - k
|
|
432
|
+
let prev = pose.distance
|
|
433
|
+
if (m.remaining < GLIDE_EPSILON) {
|
|
434
|
+
pose.azimuth = m.azimuth
|
|
435
|
+
pose.elevation = m.elevation
|
|
436
|
+
pose.distance = m.distance
|
|
437
|
+
pose.target = copy(m.target)
|
|
438
|
+
motion = null
|
|
439
|
+
} else {
|
|
440
|
+
pose.azimuth += (m.azimuth - pose.azimuth) * k
|
|
441
|
+
pose.elevation += (m.elevation - pose.elevation) * k
|
|
442
|
+
// Distance eases in log space so a long glide reads evenly: the
|
|
443
|
+
// same ratio per second going in as coming out.
|
|
444
|
+
pose.distance *= Math.pow(m.distance / pose.distance, k)
|
|
445
|
+
if (m.anchor) pose.target = anchored(m.anchor, m.refTarget, m.refDistance, pose.distance)
|
|
446
|
+
else pose.target = [pose.target[0] + (m.target[0] - pose.target[0]) * k, pose.target[1] + (m.target[1] - pose.target[1]) * k, pose.target[2] + (m.target[2] - pose.target[2]) * k]
|
|
447
|
+
}
|
|
448
|
+
clampPose(pose, prev)
|
|
449
|
+
}
|
|
450
|
+
let glideTo = (p: OrbitPose) => {
|
|
451
|
+
checkPose("glideTo", p)
|
|
452
|
+
let goal: OrbitPoseState = {
|
|
453
|
+
azimuth: p.azimuth ?? pose.azimuth,
|
|
454
|
+
elevation: p.elevation ?? pose.elevation,
|
|
455
|
+
distance: p.distance ?? pose.distance,
|
|
456
|
+
target: p.target ? copy(p.target) : copy(pose.target),
|
|
457
|
+
}
|
|
458
|
+
clampPose(goal, null)
|
|
459
|
+
startMotion(goal, GLIDE_EASE, true)
|
|
460
|
+
notify()
|
|
284
461
|
}
|
|
285
462
|
|
|
286
463
|
// A pinch holds one anchor for its whole gesture (see the header); the
|
|
@@ -288,11 +465,27 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
288
465
|
let pinchAnchor: Vec3 | null = null
|
|
289
466
|
let pinchSeen = false
|
|
290
467
|
let anchorAt = (focal: Vec2 | undefined): Vec3 | null => (focal && options.zoomAnchor ? options.zoomAnchor(focal, view()) : null)
|
|
468
|
+
// A wheel notch with damping on: the goal's distance compounds, and the
|
|
469
|
+
// goal's target is re-derived about this notch's anchor from the live
|
|
470
|
+
// pose (the 2d camera's anchor glide), so the eased target stays pinned.
|
|
471
|
+
let dampedZoom = (ratio: number, anchor: Vec3 | null) => {
|
|
472
|
+
writeDamped(m => {
|
|
473
|
+
let prev = m.distance
|
|
474
|
+
let [lo, hi] = distanceBounds(prev)
|
|
475
|
+
m.distance = clampNum(m.distance * ratio, lo, hi)
|
|
476
|
+
m.anchor = anchor
|
|
477
|
+
m.refTarget = copy(pose.target)
|
|
478
|
+
m.refDistance = pose.distance
|
|
479
|
+
m.target = anchor ? anchored(anchor, pose.target, pose.distance, m.distance) : copy(pose.target)
|
|
480
|
+
})
|
|
481
|
+
}
|
|
291
482
|
|
|
292
483
|
let axes = createAxes<OrbitAxes>(
|
|
293
484
|
{ rotate: "vec2", zoom: "axis", pan: "vec2" },
|
|
294
485
|
{
|
|
295
486
|
onBegin: name => {
|
|
487
|
+
// A finger landing holds a glide where it is.
|
|
488
|
+
interrupt()
|
|
296
489
|
if (name === "rotate") {
|
|
297
490
|
let anchor = options.rotateAnchor?.(view())
|
|
298
491
|
if (anchor) setPivot(anchor)
|
|
@@ -301,84 +494,150 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
301
494
|
pinchSeen = false
|
|
302
495
|
pinchAnchor = null
|
|
303
496
|
}
|
|
497
|
+
notify()
|
|
304
498
|
},
|
|
305
499
|
onNudge: (name, delta, focal) => {
|
|
500
|
+
let bracketed = axes.inGesture(name)
|
|
501
|
+
let eased = !bracketed && damping() > 0
|
|
306
502
|
if (name === "rotate") {
|
|
307
503
|
let d = delta as Vec2
|
|
308
504
|
let rel = DRAG_TURNS * 2 * Math.PI * rotateSpeed()
|
|
309
|
-
rotate(-d[0] * rel, d[1] * rel)
|
|
505
|
+
let mutate = (p: OrbitPoseState) => rotate(p, -d[0] * rel, d[1] * rel)
|
|
506
|
+
if (eased) writeDamped(mutate)
|
|
507
|
+
else {
|
|
508
|
+
interrupt()
|
|
509
|
+
write(mutate)
|
|
510
|
+
}
|
|
310
511
|
} else if (name === "zoom") {
|
|
311
|
-
let
|
|
312
|
-
if (
|
|
512
|
+
let ratio = Math.pow(2, -(delta as number) * zoomSpeed())
|
|
513
|
+
if (bracketed) {
|
|
313
514
|
if (!pinchSeen) {
|
|
314
515
|
pinchSeen = true
|
|
315
516
|
pinchAnchor = anchorAt(focal)
|
|
316
517
|
}
|
|
317
|
-
anchor = pinchAnchor
|
|
518
|
+
let anchor = pinchAnchor
|
|
519
|
+
write(p => zoomAbout(p, ratio, anchor))
|
|
318
520
|
} else {
|
|
319
|
-
anchor = anchorAt(focal)
|
|
521
|
+
let anchor = anchorAt(focal)
|
|
522
|
+
if (eased) dampedZoom(ratio, anchor)
|
|
523
|
+
else {
|
|
524
|
+
interrupt()
|
|
525
|
+
write(p => zoomAbout(p, ratio, anchor))
|
|
526
|
+
}
|
|
320
527
|
}
|
|
321
|
-
zoomAbout(Math.pow(2, -(delta as number) * zoomSpeed()), anchor)
|
|
322
528
|
} else {
|
|
323
529
|
let d = delta as Vec2
|
|
324
530
|
let w = worldPerHeight() * panSpeed()
|
|
325
|
-
slide(-d[0] * w, d[1] * w)
|
|
531
|
+
let mutate = (p: OrbitPoseState) => slide(p, -d[0] * w, d[1] * w)
|
|
532
|
+
if (eased) {
|
|
533
|
+
writeDamped(m => {
|
|
534
|
+
mutate(m)
|
|
535
|
+
m.anchor = null
|
|
536
|
+
})
|
|
537
|
+
} else {
|
|
538
|
+
interrupt()
|
|
539
|
+
write(mutate)
|
|
540
|
+
}
|
|
326
541
|
}
|
|
327
|
-
|
|
542
|
+
notify()
|
|
328
543
|
},
|
|
329
544
|
},
|
|
330
545
|
)
|
|
331
546
|
let interacting = () => axes.inGesture("rotate") || axes.inGesture("zoom") || axes.inGesture("pan")
|
|
332
|
-
let active = createMemo(() => (orbiting() && orbitSpeed() !== 0) || axes.active())
|
|
547
|
+
let active = createMemo(() => (orbiting() && orbitSpeed() !== 0) || motionActive() || axes.active())
|
|
333
548
|
|
|
334
|
-
clampPose()
|
|
549
|
+
clampPose(pose, null)
|
|
335
550
|
push()
|
|
336
551
|
changed = false
|
|
337
552
|
|
|
338
553
|
return {
|
|
339
554
|
eye,
|
|
340
|
-
pose: () => ({ azimuth, elevation, distance
|
|
555
|
+
pose: () => ({ azimuth: pose.azimuth, elevation: pose.elevation, distance: pose.distance, target: copy(pose.target) }),
|
|
341
556
|
orbiting,
|
|
342
557
|
active,
|
|
343
558
|
axes,
|
|
344
|
-
set(
|
|
345
|
-
|
|
346
|
-
if (
|
|
347
|
-
if (
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
if (pose.
|
|
351
|
-
|
|
559
|
+
set(p) {
|
|
560
|
+
checkPose("set", p)
|
|
561
|
+
if (p.azimuth !== undefined || p.elevation !== undefined || p.distance !== undefined || p.target) interrupt()
|
|
562
|
+
if (p.azimuth !== undefined) pose.azimuth = p.azimuth
|
|
563
|
+
if (p.elevation !== undefined) pose.elevation = p.elevation
|
|
564
|
+
if (p.distance !== undefined) pose.distance = p.distance
|
|
565
|
+
if (p.target) pose.target = copy(p.target)
|
|
566
|
+
clampPose(pose, null)
|
|
567
|
+
if (p.orbiting !== undefined) {
|
|
568
|
+
running = p.orbiting
|
|
352
569
|
setOrbiting(running)
|
|
353
570
|
}
|
|
354
571
|
push()
|
|
572
|
+
notify()
|
|
573
|
+
},
|
|
574
|
+
glideTo,
|
|
575
|
+
fit(bounds, opts) {
|
|
576
|
+
if (bounds.length !== 6) throw new Error(`createOrbitCamera: fit bounds must be [minX, minY, minZ, maxX, maxY, maxZ], got ${bounds.length} values`)
|
|
577
|
+
for (let i = 0; i < 6; i++) finite(`fit bounds[${i}]`, bounds[i]!)
|
|
578
|
+
let center: Vec3 = [(bounds[0]! + bounds[3]!) / 2, (bounds[1]! + bounds[4]!) / 2, (bounds[2]! + bounds[5]!) / 2]
|
|
579
|
+
let radius = Math.hypot(bounds[3]! - bounds[0]!, bounds[4]! - bounds[1]!, bounds[5]! - bounds[2]!) / 2
|
|
580
|
+
let distance = pose.distance
|
|
581
|
+
let cam = camera.camera()
|
|
582
|
+
if (!cam.ortho && radius > 0) {
|
|
583
|
+
// The sphere is tangent to the frustum's side planes at
|
|
584
|
+
// radius / sin(half-angle); the tighter of the vertical fov and
|
|
585
|
+
// the horizontal one it implies at the target's aspect wins.
|
|
586
|
+
let size = camera.size()
|
|
587
|
+
let vertical = (cam.fov * Math.PI) / 360
|
|
588
|
+
let aspect = size.height > 0 ? size.width / size.height : 1
|
|
589
|
+
let horizontal = Math.atan(Math.tan(vertical) * aspect)
|
|
590
|
+
distance = radius / Math.sin(Math.min(vertical, horizontal))
|
|
591
|
+
}
|
|
592
|
+
let goal: OrbitPose = { target: center, distance }
|
|
593
|
+
if (opts?.glide) {
|
|
594
|
+
glideTo(goal)
|
|
595
|
+
return
|
|
596
|
+
}
|
|
597
|
+
interrupt()
|
|
598
|
+
pose.target = center
|
|
599
|
+
pose.distance = distance
|
|
600
|
+
clampPose(pose, null)
|
|
601
|
+
push()
|
|
602
|
+
notify()
|
|
355
603
|
},
|
|
356
604
|
update(dt) {
|
|
357
605
|
finite("update dt", dt)
|
|
358
606
|
let moved = false
|
|
359
607
|
let rate = orbitSpeed()
|
|
360
608
|
if (rate !== 0 && !interacting() && running) {
|
|
361
|
-
|
|
609
|
+
// The goal turns with the pose, so a glide on an orbiting camera
|
|
610
|
+
// lands where the orbit has carried its goal.
|
|
611
|
+
pose.azimuth += dt * rate
|
|
612
|
+
if (motion !== null) motion.azimuth += dt * rate
|
|
362
613
|
moved = true
|
|
363
614
|
}
|
|
364
615
|
let [rx, ry] = untrack(() => axes.rate("rotate"))
|
|
365
616
|
if (rx !== 0 || ry !== 0) {
|
|
366
|
-
let
|
|
367
|
-
|
|
617
|
+
let s = ROTATE_RATE * 2 * Math.PI * rotateSpeed() * dt
|
|
618
|
+
interrupt()
|
|
619
|
+
mutateLive(p => rotate(p, -rx * s, ry * s))
|
|
368
620
|
moved = true
|
|
369
621
|
}
|
|
370
622
|
let z = untrack(() => axes.rate("zoom"))
|
|
371
623
|
if (z !== 0) {
|
|
372
|
-
|
|
624
|
+
interrupt()
|
|
625
|
+
mutateLive(p => zoomAbout(p, Math.pow(2, -z * ZOOM_RATE * zoomSpeed() * dt), null))
|
|
373
626
|
moved = true
|
|
374
627
|
}
|
|
375
628
|
let [px, py] = untrack(() => axes.rate("pan"))
|
|
376
629
|
if (px !== 0 || py !== 0) {
|
|
377
630
|
let w = worldPerHeight() * PAN_RATE * panSpeed() * dt
|
|
378
|
-
|
|
631
|
+
interrupt()
|
|
632
|
+
mutateLive(p => slide(p, -px * w, py * w))
|
|
633
|
+
moved = true
|
|
634
|
+
}
|
|
635
|
+
if (motion !== null && dt > 0) {
|
|
636
|
+
step(dt)
|
|
379
637
|
moved = true
|
|
380
638
|
}
|
|
381
639
|
if (moved) push()
|
|
640
|
+
notify()
|
|
382
641
|
let result = changed
|
|
383
642
|
changed = false
|
|
384
643
|
return result
|
|
@@ -386,19 +645,22 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
386
645
|
rotateBy(dAzimuth, dElevation) {
|
|
387
646
|
finite("rotateBy azimuth", dAzimuth)
|
|
388
647
|
finite("rotateBy elevation", dElevation)
|
|
389
|
-
|
|
390
|
-
|
|
648
|
+
interrupt()
|
|
649
|
+
write(p => rotate(p, dAzimuth, dElevation))
|
|
650
|
+
notify()
|
|
391
651
|
},
|
|
392
652
|
zoomBy(factor, anchor) {
|
|
393
653
|
if (!(Number.isFinite(factor) && factor > 0)) throw new Error(`createOrbitCamera: zoomBy factor must be positive, got ${factor}`)
|
|
394
|
-
|
|
395
|
-
|
|
654
|
+
interrupt()
|
|
655
|
+
write(p => zoomAbout(p, 1 / factor, anchor))
|
|
656
|
+
notify()
|
|
396
657
|
},
|
|
397
658
|
panBy(right, up) {
|
|
398
659
|
finite("panBy right", right)
|
|
399
660
|
finite("panBy up", up)
|
|
400
|
-
|
|
401
|
-
|
|
661
|
+
interrupt()
|
|
662
|
+
write(p => slide(p, right, up))
|
|
663
|
+
notify()
|
|
402
664
|
},
|
|
403
665
|
setPivot(point) {
|
|
404
666
|
setPivot(point)
|