@solidrt/3d 0.0.59 → 0.0.61

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/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 three axes,
5
- // `rotate` (vec2), `zoom` (axis) and `pan` (vec2), in device-free units,
6
- // and exposes pose verbs for scripted moves. What drives the axes is the
7
- // app's business - an input map with a pointer feed, a pad, a debug
8
- // command - and the `orbitBindings` preset in input.ts is the standard
9
- // wiring (drag rotates, pinch and wheel zoom, two fingers pan).
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. `pan` slides
17
- // the target so the scene tracks the fingers 1:1 at the target's depth,
18
- // the touch convention everywhere (three.js DOLLY_PAN, Sketchfab, touch
19
- // CAD), mapped through the target camera's fov - which is why the driven
20
- // target must report its camera (a Scene or a View does).
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 app maps the
23
- // gesture's focal point (a fraction of the element) to a world point,
24
- // and the zoom scales the pose about that point instead - the spot under
25
- // the fingers stays under the fingers, with the target sliding toward it.
26
- // Only the app can own that mapping: focal-to-ray needs the projection,
27
- // which lives in the app's scene (scene.unproject over the scene's size).
28
- // A pinch keeps ONE anchor for its whole gesture, taken at its first
29
- // delta: the two fingers' events interleave, so the measured span
30
- // oscillates around its true value even while the fingers rest, and
31
- // per-delta re-anchoring turns that noise into a visible crawl; about a
32
- // fixed point the pairs cancel exactly. The wheel, unbracketed, anchors
33
- // per notch: its notches are discrete, there is no noise to cancel, and
34
- // each notch re-aiming at the cursor is the point.
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
- // 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.
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 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.
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 anchor callbacks are re-read from the options object on
79
- // every input or update, so a caller may change a field (or hand in an
80
- // object of getters, which is what `<OrbitCamera>` does with its props)
81
- // and the next gesture or frame sees it. Only the initial pose is copied
82
- // at creation; later pose changes go through set() and the verbs.
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 { createMemo, createSignal, untrack } from "@solidjs/signals"
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 element,
155
- * [0..1, 0..1] - to the world point the zoom should keep pinned, with
156
- * the pose the zoom is about to apply to. Called once per pinch gesture
157
- * (the anchor holds for the whole gesture) and once per wheel notch.
158
- * Return null to zoom toward the target as usual (also the default). */
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. Also
178
- * the auto-orbit switch: set({ orbiting: false }), which leaves a
179
- * motion running. */
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, or any axis rate
199
- * non-zero. The frame-loop gate, reactive - the same predicate every
200
- * camera control exposes. */
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 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). */
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) and `pan`
209
- * (vec2, element heights). An input map drives it by name
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, through `clampPose`, and push. */
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) for (let i = 0; i < 3; i++) finite(`${verb} target[${i}]`, pose.target[i]!)
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 and takes an input map as a prop.
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 pose changed since the last update() (see the header).
422
+ // Whether the camera changed since the last update() (see the header).
295
423
  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).
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 [motionActive, setMotionActive] = createSignal(false, { ownedWrite: true })
301
- let notify = () => {
302
- let now = motion !== null
303
- if (now !== untrack(motionActive)) setMotionActive(now)
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
- camera.setCamera({ position: eye(), target: pose.target })
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/up. The
349
- // basis for this pose: right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se),
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 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]
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
- p.distance = clampNum(p.distance * ratio, lo, hi)
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
- m.distance = clampNum(m.distance * ratio, lo, hi)
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
- let active = createMemo(() => (orbiting() && orbitSpeed() !== 0) || motionActive() || axes.active())
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 aspect = size.height > 0 ? size.width / size.height : 1
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
  }