@solidrt/3d 0.0.58 → 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.
Files changed (70) hide show
  1. package/AGENTS.md +734 -264
  2. package/README.md +4 -2
  3. package/examples/README.md +50 -4
  4. package/examples/bloom.tsx +113 -0
  5. package/examples/cascades.tsx +4 -4
  6. package/examples/collision.tsx +6 -6
  7. package/examples/exits.tsx +96 -0
  8. package/examples/first-person.tsx +5 -5
  9. package/examples/fleet.tsx +4 -4
  10. package/examples/fog.tsx +5 -5
  11. package/examples/instanced.tsx +70 -50
  12. package/examples/lamps.tsx +4 -4
  13. package/examples/lod.tsx +125 -0
  14. package/examples/materials.tsx +9 -9
  15. package/examples/morph.tsx +174 -0
  16. package/examples/normals.tsx +118 -0
  17. package/examples/{lit.tsx → phong.tsx} +9 -9
  18. package/examples/polyhedra.tsx +61 -0
  19. package/examples/scene-atlas.tsx +70 -0
  20. package/examples/scene-views.tsx +5 -5
  21. package/examples/shadows.tsx +9 -9
  22. package/examples/sky-lit.tsx +3 -3
  23. package/examples/skybox.tsx +5 -5
  24. package/examples/standard.tsx +2 -2
  25. package/examples/streams.tsx +122 -0
  26. package/examples/sweep-paths.tsx +5 -5
  27. package/examples/wireframe.tsx +24 -2
  28. package/package.json +3 -3
  29. package/src/clip.ts +18 -5
  30. package/src/color.ts +11 -2
  31. package/src/components/context.tsx +11 -2
  32. package/src/components/directional-light.tsx +2 -2
  33. package/src/components/first-person-camera.tsx +4 -4
  34. package/src/components/group.tsx +9 -5
  35. package/src/components/hemisphere-light.tsx +2 -2
  36. package/src/components/index.ts +4 -1
  37. package/src/components/instanced-mesh.tsx +28 -10
  38. package/src/components/lod.tsx +102 -0
  39. package/src/components/mesh.tsx +24 -6
  40. package/src/components/node-props.ts +35 -5
  41. package/src/components/orbit-camera.tsx +15 -12
  42. package/src/components/point-light.tsx +2 -2
  43. package/src/components/record-mesh.tsx +12 -7
  44. package/src/components/scene.tsx +55 -2
  45. package/src/components/spot-light.tsx +2 -2
  46. package/src/components/sprite.tsx +2 -2
  47. package/src/components/view3d.tsx +18 -1
  48. package/src/environment.ts +9 -8
  49. package/src/first-person.ts +125 -38
  50. package/src/geometry-gpu.ts +172 -39
  51. package/src/geometry.ts +1573 -239
  52. package/src/glsl.ts +398 -113
  53. package/src/gltf.ts +363 -141
  54. package/src/index.ts +31 -21
  55. package/src/light.ts +7 -8
  56. package/src/lod.ts +148 -0
  57. package/src/material.ts +316 -202
  58. package/src/mesh.ts +490 -256
  59. package/src/mixer.ts +27 -14
  60. package/src/model-file.ts +54 -15
  61. package/src/model.ts +86 -21
  62. package/src/motion.ts +17 -0
  63. package/src/node.ts +421 -22
  64. package/src/orbit.ts +378 -116
  65. package/src/profile.ts +4 -25
  66. package/src/resolve.ts +181 -0
  67. package/src/scene-shadows.ts +94 -93
  68. package/src/scene.ts +605 -185
  69. package/src/sweep.ts +27 -8
  70. 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 and the auto-orbit
47
- // from the app's own onFrame - the control registers no frame loop of its
48
- // own. Only `orbiting` and `active` are reactive: slow UI state a HUD
49
- // reads, and the frame-loop gate. update() returns whether the pose
50
- // changed since the previous update (pushes included), so per-frame
51
- // dependents (reprojecting HUD overlays via scene.project) can follow the
52
- // camera without recomputing every frame.
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 and camera() (the fov maps pan travel to world). */
82
- export type OrbitTarget = { setCamera(update: CameraUpdate): void; camera(): Pick<CameraState, "fov"> }
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
- /** Constrain where a pan may put the target - return the target to use.
112
- * The typical use: keep the pivot within a few radii of the subject so
113
- * panning cannot strand the camera. Zoom and rotation do not consult it. */
114
- clampTarget?: (target: Vec3) => Vec3
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(): { azimuth: number; elevation: number; distance: number; target: Vec3 }
145
- /** Merge a pose in (clamps apply) and push it. Also the auto-orbit
146
- * switch: set({ orbiting: false }). */
147
- set(pose: OrbitPose): void
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 any axis rate non-zero. The frame-loop gate,
153
- * reactive - the same predicate every camera control exposes. */
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 and the auto-orbit over dt seconds and push
156
- * any pose change. Call from onFrame; returns whether the pose changed
157
- * since the previous update (nudges and verbs included). */
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 `clampTarget`, and push. */
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 camera() (a Scene or a View)")
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
- let clampPose = () => {
220
- elevation = clampNum(elevation, minElevation(), maxElevation())
221
- distance = clampNum(distance, minDistance(), maxDistance())
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 eye = (): Vec3 => {
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 view = () => ({ eye: eye(), target: [target[0], target[1], target[2]] as Vec3 })
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
- let next: Vec3 = [target[0] + right * ca - up * sa * se, target[1] + up * ce, target[2] - right * sa - up * ca * se]
249
- target = options.clampTarget ? options.clampTarget(next) : next
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). Scaling eye and target about the
253
- // anchor by the distance ratio keeps it projecting to the same pixel, so
254
- // the shift below uses the ratio that actually applied after clamping -
255
- // once distance pins at a clamp the target stops moving too, instead of
256
- // sliding the view sideways under a dead zoom.
257
- let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
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 zoom clamps widen to the
276
- // current distance, so a pivot outside [min, max] cannot make the next
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 = clampNum(elevation + dElevation, minElevation(), maxElevation())
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 anchor: Vec3 | null
312
- if (axes.inGesture("zoom")) {
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
- push()
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, target: [target[0], target[1], target[2]] }),
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(pose) {
345
- if (pose.azimuth !== undefined) azimuth = pose.azimuth
346
- if (pose.elevation !== undefined) elevation = pose.elevation
347
- if (pose.distance !== undefined) distance = pose.distance
348
- if (pose.target) target = [pose.target[0], pose.target[1], pose.target[2]]
349
- clampPose()
350
- if (pose.orbiting !== undefined) {
351
- running = pose.orbiting
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
- azimuth += dt * rate
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 step = ROTATE_RATE * 2 * Math.PI * rotateSpeed() * dt
367
- rotate(-rx * step, ry * step)
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
- zoomAbout(Math.pow(2, -z * ZOOM_RATE * zoomSpeed() * dt), null)
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
- slide(-px * w, py * w)
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
- rotate(dAzimuth, dElevation)
390
- push()
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
- zoomAbout(1 / factor, anchor)
395
- push()
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
- slide(right, up)
401
- push()
661
+ interrupt()
662
+ write(p => slide(p, right, up))
663
+ notify()
402
664
  },
403
665
  setPivot(point) {
404
666
  setPivot(point)