@solidrt/3d 0.0.55 → 0.0.57
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 +1206 -224
- package/README.md +16 -7
- package/examples/README.md +88 -9
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +1 -4
- package/examples/collision.tsx +263 -0
- package/examples/first-person.tsx +129 -0
- package/examples/fleet.tsx +200 -0
- package/examples/fog.tsx +2 -3
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +35 -9
- package/examples/pick.tsx +145 -37
- package/examples/probe.tsx +107 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +68 -48
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +92 -0
- package/examples/skybox.tsx +134 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +138 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +20 -0
- package/src/color.ts +32 -0
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +258 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1343 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +61 -9
- package/src/input.ts +70 -0
- package/src/light.ts +383 -0
- package/src/material.ts +667 -232
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +204 -35
- package/src/node.ts +579 -0
- package/src/orbit.ts +292 -212
- package/src/scene-pointer.ts +294 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1435 -1576
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
- package/src/components.tsx +0 -420
package/src/orbit.ts
CHANGED
|
@@ -1,105 +1,125 @@
|
|
|
1
|
-
// An orbit camera for a scene: azimuth/elevation/distance around a target
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// An orbit camera for a scene: azimuth/elevation/distance around a target
|
|
2
|
+
// - the standard interactive-viewer camera, extracted so apps stop
|
|
3
|
+
// rebuilding it. The control is pure (ARCHITECTURE.md: controls through
|
|
4
|
+
// an abstraction, never direct event handling): it consumes three axes,
|
|
5
|
+
// `rotate` (vec2), `zoom` (axis) and `pan` (vec2), in device-free units,
|
|
6
|
+
// and exposes pose verbs for scripted moves. What drives the axes is the
|
|
7
|
+
// app's business - an input map with a pointer feed, a pad, a debug
|
|
8
|
+
// command - and the `orbitBindings` preset in input.ts is the standard
|
|
9
|
+
// wiring (drag rotates, pinch and wheel zoom, two fingers pan).
|
|
5
10
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// imperfect pinch from smearing rotation into the zoom. Without it there
|
|
17
|
-
// is no pixel-to-world mapping, so focal translation falls back to
|
|
18
|
-
// rotating regardless of finger count.
|
|
11
|
+
// Rotation is element-relative: one element height of drag travel (the
|
|
12
|
+
// feed's unit) sweeps DRAG_TURNS full turns on either axis, Three's
|
|
13
|
+
// OrbitControls convention, so the same drag feels the same on a phone
|
|
14
|
+
// and a 4k window. A rate on `rotate` (a stick) turns at ROTATE_RATE
|
|
15
|
+
// turns per second at full deflection. `zoom` is in octaves: a delta of
|
|
16
|
+
// 1 halves the distance, a rate of 1 halves it per second. `pan` slides
|
|
17
|
+
// the target so the scene tracks the fingers 1:1 at the target's depth,
|
|
18
|
+
// the touch convention everywhere (three.js DOLLY_PAN, Sketchfab, touch
|
|
19
|
+
// CAD), mapped through the target camera's fov - which is why the driven
|
|
20
|
+
// target must report its camera (a Scene or a View does).
|
|
19
21
|
//
|
|
20
22
|
// Zoom aims at the target by default. With `zoomAnchor` the app maps the
|
|
21
|
-
//
|
|
22
|
-
// about that point instead - the spot under
|
|
23
|
-
// fingers, with the target sliding toward it.
|
|
24
|
-
// mapping:
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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.
|
|
27
35
|
//
|
|
28
|
-
// Anchored zoom leaves the target wherever the zoom carried it - possibly
|
|
29
|
-
// point in empty air near the eye, and a drag orbiting THAT swings the
|
|
30
|
-
// wildly around nothing. `rotateAnchor` is the countermeasure:
|
|
31
|
-
//
|
|
32
|
-
// camera is actually looking at), and
|
|
33
|
-
//
|
|
34
|
-
// target's depth - the eye and the picture do not change
|
|
35
|
-
// swings around.
|
|
36
|
+
// Anchored zoom leaves the target wherever the zoom carried it - possibly
|
|
37
|
+
// a point in empty air near the eye, and a drag orbiting THAT swings the
|
|
38
|
+
// scene wildly around nothing. `rotateAnchor` is the countermeasure: when
|
|
39
|
+
// a rotate gesture begins, the app names the world point rotation should
|
|
40
|
+
// pivot about (what the camera is actually looking at), and setPivot
|
|
41
|
+
// re-seats the target there, projected onto the view axis first, so only
|
|
42
|
+
// the target's depth moves - the eye and the picture do not change.
|
|
36
43
|
//
|
|
37
|
-
// Pose is plain mutable state
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
44
|
+
// Pose is plain mutable state; a nudge or a verb pushes it to the target
|
|
45
|
+
// at once (the next paint carries the new camera, no frame loop needed
|
|
46
|
+
// for a drag), and update(dt) integrates the rates and the auto-orbit
|
|
47
|
+
// from the app's own onFrame - the control registers no frame loop of its
|
|
48
|
+
// own. Only `orbiting` and `active` are reactive: slow UI state a HUD
|
|
49
|
+
// reads, and the frame-loop gate. update() returns whether the pose
|
|
50
|
+
// changed since the previous update (pushes included), so per-frame
|
|
51
|
+
// dependents (reprojecting HUD overlays via scene.project) can follow the
|
|
52
|
+
// camera without recomputing every frame.
|
|
53
|
+
//
|
|
54
|
+
// Options are read where they apply, not copied out: the clamps, the
|
|
55
|
+
// rates and the anchor callbacks are re-read from the options object on
|
|
56
|
+
// every input or update, so a caller may change a field (or hand in an
|
|
57
|
+
// object of getters, which is what `<OrbitCamera>` does with its props)
|
|
58
|
+
// and the next gesture or frame sees it. Only the initial pose is copied
|
|
59
|
+
// at creation; later pose changes go through set() and the verbs.
|
|
47
60
|
|
|
48
|
-
import { createSignal } from "@solidjs/signals"
|
|
49
|
-
import {
|
|
50
|
-
import type {
|
|
51
|
-
import type { CameraUpdate } from "./
|
|
61
|
+
import { createMemo, createSignal, untrack } from "@solidjs/signals"
|
|
62
|
+
import { createAxes } from "@solidrt/core/input"
|
|
63
|
+
import type { Axes, Vec2 } from "@solidrt/core/input"
|
|
64
|
+
import type { CameraState, CameraUpdate } from "./camera.ts"
|
|
52
65
|
import type { Vec3 } from "./math.ts"
|
|
53
66
|
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
// One element height of drag sweeps this many full turns on either axis
|
|
68
|
+
// (Three's OrbitControls convention).
|
|
69
|
+
const DRAG_TURNS = 1
|
|
70
|
+
// Rate axes at full deflection, per second: turns of rotation, octaves of
|
|
71
|
+
// zoom (1 = the distance halves each second), element heights of pan.
|
|
72
|
+
const ROTATE_RATE = 0.5
|
|
73
|
+
const ZOOM_RATE = 1
|
|
74
|
+
const PAN_RATE = 1
|
|
75
|
+
// Default clamps: the distance floor keeps the eye off the target; the
|
|
76
|
+
// elevation limits (radians) stop just short of the poles.
|
|
77
|
+
const MIN_DISTANCE = 0.01
|
|
78
|
+
const ELEVATION_LIMIT = 1.55
|
|
59
79
|
|
|
60
|
-
/** What an orbit camera drives: a Scene, or one of its Views
|
|
61
|
-
|
|
80
|
+
/** What an orbit camera drives: a Scene, or one of its Views - anything
|
|
81
|
+
* with their setCamera and camera() (the fov maps pan travel to world). */
|
|
82
|
+
export type OrbitTarget = { setCamera(update: CameraUpdate): void; camera(): Pick<CameraState, "fov"> }
|
|
62
83
|
|
|
84
|
+
/** The pose fields (target, azimuth, elevation, distance) are initial
|
|
85
|
+
* values, copied at creation and changed through set() afterwards. Every
|
|
86
|
+
* other field is live: read from this object where it applies, so a
|
|
87
|
+
* change takes effect on the next input or update. */
|
|
63
88
|
export type OrbitCameraOptions = {
|
|
64
|
-
/**
|
|
89
|
+
/** Initial point the camera orbits and looks at (default origin). */
|
|
65
90
|
target?: Vec3
|
|
66
91
|
/** Initial pose, radians and world units. */
|
|
67
92
|
azimuth?: number
|
|
68
93
|
elevation?: number
|
|
69
94
|
distance?: number
|
|
95
|
+
/** Distance clamps, world units. */
|
|
70
96
|
minDistance?: number
|
|
71
97
|
maxDistance?: number
|
|
72
98
|
/** Elevation clamps, radians; the defaults stop just short of the poles. */
|
|
73
99
|
minElevation?: number
|
|
74
100
|
maxElevation?: number
|
|
75
101
|
/** Auto-orbit rate in radians/second (default 0: none). Runs while
|
|
76
|
-
* `orbiting()` and no
|
|
77
|
-
* set({ orbiting }).
|
|
102
|
+
* `orbiting()` and no gesture is in progress; `orbiting` starts on when
|
|
103
|
+
* the initial rate is positive and toggles with set({ orbiting }). A new
|
|
104
|
+
* rate applies from the next update. */
|
|
78
105
|
orbitSpeed?: number
|
|
79
|
-
/** Multipliers over the built-in
|
|
106
|
+
/** Multipliers over the built-in rotate, zoom and pan sensitivities
|
|
107
|
+
* (drags and rates alike). */
|
|
80
108
|
rotateSpeed?: number
|
|
81
109
|
zoomSpeed?: number
|
|
82
|
-
/** Multiplier over the two-finger pan (1 = the scene tracks the fingers
|
|
83
|
-
* exactly at the target's depth). */
|
|
84
110
|
panSpeed?: number
|
|
85
|
-
/** The viewport the pointer coordinates live in: height in the same
|
|
86
|
-
* logical units as clientX/clientY, and the camera's vertical fov in
|
|
87
|
-
* degrees. Providing it enables two-finger pan; without it two-finger
|
|
88
|
-
* translation rotates, as it always did. */
|
|
89
|
-
viewport?: () => { height: number; fov: number }
|
|
90
111
|
/** Constrain where a pan may put the target - return the target to use.
|
|
91
112
|
* The typical use: keep the pivot within a few radii of the subject so
|
|
92
113
|
* panning cannot strand the camera. Zoom and rotation do not consult it. */
|
|
93
114
|
clampTarget?: (target: Vec3) => Vec3
|
|
94
|
-
/** Map a
|
|
95
|
-
* the world point
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
* pinch starts. It is projected onto the view axis and becomes the new
|
|
115
|
+
/** Map a zoom gesture's focal point - a fraction of the driven element,
|
|
116
|
+
* [0..1, 0..1] - to the world point the zoom should keep pinned, with
|
|
117
|
+
* the pose the zoom is about to apply to. Called once per pinch gesture
|
|
118
|
+
* (the anchor holds for the whole gesture) and once per wheel notch.
|
|
119
|
+
* Return null to zoom toward the target as usual (also the default). */
|
|
120
|
+
zoomAnchor?: (focal: Vec2, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
121
|
+
/** The world point rotation should pivot about - called when a rotate
|
|
122
|
+
* gesture begins. It is projected onto the view axis and becomes the new
|
|
103
123
|
* target, preserving the picture exactly (only the pivot's depth moves),
|
|
104
124
|
* so a drag after an anchored zoom orbits the scene under the camera
|
|
105
125
|
* instead of wherever the zoom left the target. Points at or behind the
|
|
@@ -115,83 +135,90 @@ export type OrbitPose = {
|
|
|
115
135
|
orbiting?: boolean
|
|
116
136
|
}
|
|
117
137
|
|
|
138
|
+
export type OrbitAxes = { rotate: "vec2"; zoom: "axis"; pan: "vec2" }
|
|
139
|
+
|
|
118
140
|
export type OrbitCamera = {
|
|
119
141
|
/** Camera position for the current pose (a fresh array per call). */
|
|
120
142
|
eye(): Vec3
|
|
121
143
|
/** Pose snapshot - the shape debug commands return and set() takes. */
|
|
122
|
-
pose(): { azimuth: number; elevation: number; distance: number }
|
|
123
|
-
/** Merge a pose in (clamps apply)
|
|
124
|
-
*
|
|
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 }). */
|
|
125
147
|
set(pose: OrbitPose): void
|
|
126
148
|
/** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
|
|
127
149
|
* text can read it. */
|
|
128
150
|
orbiting(): boolean
|
|
129
|
-
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
151
|
+
/** Whether update(dt) has work: the auto-orbit on with a non-zero
|
|
152
|
+
* `orbitSpeed`, or any axis rate non-zero. The frame-loop gate,
|
|
153
|
+
* reactive - the same predicate every camera control exposes. */
|
|
154
|
+
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). */
|
|
132
158
|
update(dt: number): boolean
|
|
133
|
-
/**
|
|
134
|
-
*
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
/** The input abstraction: `rotate` (vec2, element heights of drag /
|
|
160
|
+
* turns per second), `zoom` (axis, octaves, positive in) and `pan`
|
|
161
|
+
* (vec2, element heights). An input map drives it by name
|
|
162
|
+
* (InputMap.drive); an app may add sources and nudge directly. */
|
|
163
|
+
axes: Axes<OrbitAxes>
|
|
164
|
+
/** Turn by radians (azimuth positive counterclockwise seen from above,
|
|
165
|
+
* elevation positive upward; clamps apply) and push. */
|
|
166
|
+
rotateBy(azimuth: number, elevation: number): void
|
|
167
|
+
/** Scale the distance by 1/factor (factor > 1 zooms in, as the 2d
|
|
168
|
+
* camera's zoomAt) about `anchor`, a world point that stays where it
|
|
169
|
+
* projects (default: toward the target), and push. */
|
|
170
|
+
zoomBy(factor: number, anchor?: Vec3 | null): void
|
|
171
|
+
/** Slide the target (and the eye with it) by world units along the
|
|
172
|
+
* camera's right and up, through `clampTarget`, and push. */
|
|
173
|
+
panBy(right: number, up: number): void
|
|
174
|
+
/** Re-seat the pivot at a world point without moving the picture: the
|
|
175
|
+
* point is projected onto the view axis and becomes the target at that
|
|
176
|
+
* depth. Points at or behind the eye are ignored. */
|
|
177
|
+
setPivot(point: Vec3): void
|
|
143
178
|
}
|
|
144
179
|
|
|
145
180
|
let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
|
|
146
181
|
|
|
182
|
+
function finite(what: string, v: number): void {
|
|
183
|
+
if (!Number.isFinite(v)) throw new Error(`createOrbitCamera: ${what} must be a finite number, got ${v}`)
|
|
184
|
+
}
|
|
185
|
+
|
|
147
186
|
/**
|
|
148
187
|
* Create an orbit camera driving `camera`'s position and target, where
|
|
149
|
-
* `camera` is a Scene or one of its Views (fov, near, and far stay yours
|
|
150
|
-
* its setCamera). The initial pose applies immediately. In a component
|
|
151
|
-
*
|
|
152
|
-
*
|
|
188
|
+
* `camera` is a Scene or one of its Views (fov, near, and far stay yours
|
|
189
|
+
* via its setCamera). The initial pose applies immediately. In a component
|
|
190
|
+
* tree, prefer the `<OrbitCamera>` component: it drives the enclosing
|
|
191
|
+
* scene or view through context and takes an input map as a prop.
|
|
153
192
|
*/
|
|
154
193
|
export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptions = {}): OrbitCamera {
|
|
194
|
+
if (!camera || typeof camera.setCamera !== "function" || typeof camera.camera !== "function") {
|
|
195
|
+
throw new Error("createOrbitCamera: the target needs setCamera() and camera() (a Scene or a View)")
|
|
196
|
+
}
|
|
155
197
|
let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
|
|
156
198
|
let azimuth = options.azimuth ?? 0
|
|
157
199
|
let elevation = options.elevation ?? 0
|
|
158
200
|
let distance = options.distance ?? 5
|
|
159
|
-
|
|
160
|
-
let
|
|
161
|
-
let
|
|
162
|
-
let
|
|
163
|
-
let
|
|
164
|
-
let
|
|
165
|
-
let
|
|
166
|
-
let
|
|
201
|
+
// Everything below the pose is read from `options` where it applies.
|
|
202
|
+
let minDistance = () => options.minDistance ?? MIN_DISTANCE
|
|
203
|
+
let maxDistance = () => options.maxDistance ?? Infinity
|
|
204
|
+
let minElevation = () => options.minElevation ?? -ELEVATION_LIMIT
|
|
205
|
+
let maxElevation = () => options.maxElevation ?? ELEVATION_LIMIT
|
|
206
|
+
let orbitSpeed = () => options.orbitSpeed ?? 0
|
|
207
|
+
let rotateSpeed = () => options.rotateSpeed ?? 1
|
|
208
|
+
let zoomSpeed = () => options.zoomSpeed ?? 1
|
|
209
|
+
let panSpeed = () => options.panSpeed ?? 1
|
|
167
210
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
211
|
+
// The auto-orbit switch: starts on when the initial rate is positive.
|
|
212
|
+
// Plain state is the truth (update() reads it in the same block set()
|
|
213
|
+
// wrote it); the signal notifies HUDs and the active() gate.
|
|
214
|
+
let running = orbitSpeed() > 0
|
|
215
|
+
let [orbiting, setOrbiting] = createSignal(running, { ownedWrite: true })
|
|
216
|
+
// Whether the pose changed since the last update() (see the header).
|
|
217
|
+
let changed = false
|
|
171
218
|
|
|
172
219
|
let clampPose = () => {
|
|
173
|
-
elevation = clampNum(elevation, minElevation, maxElevation)
|
|
174
|
-
distance = clampNum(distance, minDistance, maxDistance)
|
|
175
|
-
}
|
|
176
|
-
// Slide eye and target together along the camera's right/up so the scene
|
|
177
|
-
// tracks the fingers: dragged pixels map to world units through the
|
|
178
|
-
// frustum height at the target's depth. Screen +y is down, so the up-axis
|
|
179
|
-
// term is added (fingers down -> camera up -> scene follows down).
|
|
180
|
-
let pan = (dx: number, dy: number) => {
|
|
181
|
-
let vp = options.viewport!()
|
|
182
|
-
let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
|
|
183
|
-
let sa = Math.sin(azimuth)
|
|
184
|
-
let ca = Math.cos(azimuth)
|
|
185
|
-
let se = Math.sin(elevation)
|
|
186
|
-
let ce = Math.cos(elevation)
|
|
187
|
-
// right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
|
|
188
|
-
// camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
|
|
189
|
-
let next: Vec3 = [
|
|
190
|
-
target[0] - dx * wpp * ca - dy * wpp * sa * se,
|
|
191
|
-
target[1] + dy * wpp * ce,
|
|
192
|
-
target[2] + dx * wpp * sa - dy * wpp * ca * se,
|
|
193
|
-
]
|
|
194
|
-
target = options.clampTarget ? options.clampTarget(next) : next
|
|
220
|
+
elevation = clampNum(elevation, minElevation(), maxElevation())
|
|
221
|
+
distance = clampNum(distance, minDistance(), maxDistance())
|
|
195
222
|
}
|
|
196
223
|
let eye = (): Vec3 => {
|
|
197
224
|
let ce = Math.cos(elevation)
|
|
@@ -201,8 +228,26 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
201
228
|
target[2] + distance * ce * Math.cos(azimuth),
|
|
202
229
|
]
|
|
203
230
|
}
|
|
204
|
-
let
|
|
231
|
+
let view = () => ({ eye: eye(), target: [target[0], target[1], target[2]] as Vec3 })
|
|
232
|
+
let push = () => {
|
|
233
|
+
changed = true
|
|
234
|
+
camera.setCamera({ position: eye(), target })
|
|
235
|
+
}
|
|
236
|
+
// World units per element height at the target's depth: the frustum's
|
|
237
|
+
// height there, from the target camera's vertical fov.
|
|
238
|
+
let worldPerHeight = () => 2 * Math.tan((camera.camera().fov * Math.PI) / 360) * distance
|
|
205
239
|
|
|
240
|
+
// Slide eye and target together along the camera's right/up. The
|
|
241
|
+
// basis for this pose: right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se),
|
|
242
|
+
// written out from eye() = target + distance * (ce*sa, se, ce*ca).
|
|
243
|
+
let slide = (right: number, up: number) => {
|
|
244
|
+
let sa = Math.sin(azimuth)
|
|
245
|
+
let ca = Math.cos(azimuth)
|
|
246
|
+
let se = Math.sin(elevation)
|
|
247
|
+
let ce = Math.cos(elevation)
|
|
248
|
+
let next: Vec3 = [target[0] + right * ca - up * sa * se, target[1] + up * ce, target[2] - right * sa - up * ca * se]
|
|
249
|
+
target = options.clampTarget ? options.clampTarget(next) : next
|
|
250
|
+
}
|
|
206
251
|
// Zoom by `ratio` (new distance over old, before clamping) about a world
|
|
207
252
|
// anchor (null zooms toward the target). Scaling eye and target about the
|
|
208
253
|
// anchor by the distance ratio keeps it projecting to the same pixel, so
|
|
@@ -212,117 +257,152 @@ export function createOrbitCamera(camera: OrbitTarget, options: OrbitCameraOptio
|
|
|
212
257
|
let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
|
|
213
258
|
let prev = distance
|
|
214
259
|
// Bounds widen to the current distance so a pose already outside them
|
|
215
|
-
// (a
|
|
260
|
+
// (a setPivot re-seat may land anywhere) zooms back toward range
|
|
216
261
|
// instead of snap-jumping into it.
|
|
217
|
-
distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
|
|
262
|
+
distance = clampNum(distance * ratio, Math.min(minDistance(), prev), Math.max(maxDistance(), prev))
|
|
218
263
|
if (!anchor) return
|
|
219
264
|
let s = distance / prev
|
|
220
|
-
target = [
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
]
|
|
265
|
+
target = [anchor[0] + (target[0] - anchor[0]) * s, anchor[1] + (target[1] - anchor[1]) * s, anchor[2] + (target[2] - anchor[2]) * s]
|
|
266
|
+
}
|
|
267
|
+
let setPivot = (point: Vec3) => {
|
|
268
|
+
let e = eye()
|
|
269
|
+
let fx = (target[0] - e[0]) / distance
|
|
270
|
+
let fy = (target[1] - e[1]) / distance
|
|
271
|
+
let fz = (target[2] - e[2]) / distance
|
|
272
|
+
let depth = (point[0] - e[0]) * fx + (point[1] - e[1]) * fy + (point[2] - e[2]) * fz
|
|
273
|
+
if (!(depth > 0)) return
|
|
274
|
+
// Deliberately unclamped: the picture is unchanged, this only decides
|
|
275
|
+
// what the next gesture pivots about. The zoom clamps widen to the
|
|
276
|
+
// current distance, so a pivot outside [min, max] cannot make the next
|
|
277
|
+
// zoom snap-jump either.
|
|
278
|
+
distance = depth
|
|
279
|
+
target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
280
|
+
}
|
|
281
|
+
let rotate = (dAzimuth: number, dElevation: number) => {
|
|
282
|
+
azimuth += dAzimuth
|
|
283
|
+
elevation = clampNum(elevation + dElevation, minElevation(), maxElevation())
|
|
225
284
|
}
|
|
226
|
-
let anchorAt = (x?: number, y?: number) =>
|
|
227
|
-
x !== undefined && y !== undefined && options.zoomAnchor
|
|
228
|
-
? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
|
|
229
|
-
: null
|
|
230
285
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
// practice: the two fingers' events interleave, so the measured span
|
|
234
|
-
// oscillates around its true value even while the fingers rest, and every
|
|
235
|
-
// event zooms slightly in or back out. About a fixed point those pairs
|
|
236
|
-
// cancel exactly (the ratios telescope); about a fresh anchor each time -
|
|
237
|
-
// a new raycast against a pose the previous event just moved, or the
|
|
238
|
-
// app's hit/fallback choice flipping between events - each pair leaves a
|
|
239
|
-
// residual target slide and the model visibly crawls under resting
|
|
240
|
-
// fingers. The wheel stays per-event: its notches are discrete, there is
|
|
241
|
-
// no noise to cancel, and each notch re-aiming at the cursor is the point.
|
|
286
|
+
// A pinch holds one anchor for its whole gesture (see the header); the
|
|
287
|
+
// wheel, unbracketed, anchors per notch.
|
|
242
288
|
let pinchAnchor: Vec3 | null = null
|
|
243
289
|
let pinchSeen = false
|
|
290
|
+
let anchorAt = (focal: Vec2 | undefined): Vec3 | null => (focal && options.zoomAnchor ? options.zoomAnchor(focal, view()) : null)
|
|
244
291
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
onTransformStart: () => {
|
|
253
|
-
interacting = true
|
|
254
|
-
let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
|
|
255
|
-
if (anchor) {
|
|
256
|
-
let e = eye()
|
|
257
|
-
let fx = (target[0] - e[0]) / distance
|
|
258
|
-
let fy = (target[1] - e[1]) / distance
|
|
259
|
-
let fz = (target[2] - e[2]) / distance
|
|
260
|
-
let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
|
|
261
|
-
if (depth > 0) {
|
|
262
|
-
// Deliberately unclamped: the pose is unchanged, this only decides
|
|
263
|
-
// what the gesture pivots about. The zoom clamps below widen to
|
|
264
|
-
// the current distance, so a pivot outside [min, max] cannot make
|
|
265
|
-
// the next zoom snap-jump either.
|
|
266
|
-
distance = depth
|
|
267
|
-
target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
292
|
+
let axes = createAxes<OrbitAxes>(
|
|
293
|
+
{ rotate: "vec2", zoom: "axis", pan: "vec2" },
|
|
294
|
+
{
|
|
295
|
+
onBegin: name => {
|
|
296
|
+
if (name === "rotate") {
|
|
297
|
+
let anchor = options.rotateAnchor?.(view())
|
|
298
|
+
if (anchor) setPivot(anchor)
|
|
268
299
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
if (t.pointers >= 2 && options.viewport) {
|
|
273
|
-
pan(t.dx, t.dy)
|
|
274
|
-
} else {
|
|
275
|
-
azimuth -= t.dx * dragAzimuth
|
|
276
|
-
elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
|
|
277
|
-
}
|
|
278
|
-
if (t.scale !== 1) {
|
|
279
|
-
// Fingers spreading (scale > 1) zooms in: the distance shrinks by the
|
|
280
|
-
// span ratio, exponent-weighted by the zoomSpeed multiplier, about
|
|
281
|
-
// the gesture's anchor when the app provides one.
|
|
282
|
-
if (!pinchSeen) {
|
|
283
|
-
pinchSeen = true
|
|
284
|
-
pinchAnchor = anchorAt(t.x, t.y)
|
|
300
|
+
if (name === "zoom") {
|
|
301
|
+
pinchSeen = false
|
|
302
|
+
pinchAnchor = null
|
|
285
303
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
304
|
+
},
|
|
305
|
+
onNudge: (name, delta, focal) => {
|
|
306
|
+
if (name === "rotate") {
|
|
307
|
+
let d = delta as Vec2
|
|
308
|
+
let rel = DRAG_TURNS * 2 * Math.PI * rotateSpeed()
|
|
309
|
+
rotate(-d[0] * rel, d[1] * rel)
|
|
310
|
+
} else if (name === "zoom") {
|
|
311
|
+
let anchor: Vec3 | null
|
|
312
|
+
if (axes.inGesture("zoom")) {
|
|
313
|
+
if (!pinchSeen) {
|
|
314
|
+
pinchSeen = true
|
|
315
|
+
pinchAnchor = anchorAt(focal)
|
|
316
|
+
}
|
|
317
|
+
anchor = pinchAnchor
|
|
318
|
+
} else {
|
|
319
|
+
anchor = anchorAt(focal)
|
|
320
|
+
}
|
|
321
|
+
zoomAbout(Math.pow(2, -(delta as number) * zoomSpeed()), anchor)
|
|
322
|
+
} else {
|
|
323
|
+
let d = delta as Vec2
|
|
324
|
+
let w = worldPerHeight() * panSpeed()
|
|
325
|
+
slide(-d[0] * w, d[1] * w)
|
|
326
|
+
}
|
|
327
|
+
push()
|
|
328
|
+
},
|
|
294
329
|
},
|
|
295
|
-
|
|
330
|
+
)
|
|
331
|
+
let interacting = () => axes.inGesture("rotate") || axes.inGesture("zoom") || axes.inGesture("pan")
|
|
332
|
+
let active = createMemo(() => (orbiting() && orbitSpeed() !== 0) || axes.active())
|
|
333
|
+
|
|
334
|
+
clampPose()
|
|
335
|
+
push()
|
|
336
|
+
changed = false
|
|
296
337
|
|
|
297
338
|
return {
|
|
298
339
|
eye,
|
|
299
|
-
pose: () => ({ azimuth, elevation, distance }),
|
|
340
|
+
pose: () => ({ azimuth, elevation, distance, target: [target[0], target[1], target[2]] }),
|
|
300
341
|
orbiting,
|
|
342
|
+
active,
|
|
343
|
+
axes,
|
|
301
344
|
set(pose) {
|
|
302
345
|
if (pose.azimuth !== undefined) azimuth = pose.azimuth
|
|
303
346
|
if (pose.elevation !== undefined) elevation = pose.elevation
|
|
304
347
|
if (pose.distance !== undefined) distance = pose.distance
|
|
305
348
|
if (pose.target) target = [pose.target[0], pose.target[1], pose.target[2]]
|
|
306
349
|
clampPose()
|
|
307
|
-
if (pose.orbiting !== undefined)
|
|
308
|
-
|
|
350
|
+
if (pose.orbiting !== undefined) {
|
|
351
|
+
running = pose.orbiting
|
|
352
|
+
setOrbiting(running)
|
|
353
|
+
}
|
|
354
|
+
push()
|
|
309
355
|
},
|
|
310
356
|
update(dt) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
357
|
+
finite("update dt", dt)
|
|
358
|
+
let moved = false
|
|
359
|
+
let rate = orbitSpeed()
|
|
360
|
+
if (rate !== 0 && !interacting() && running) {
|
|
361
|
+
azimuth += dt * rate
|
|
362
|
+
moved = true
|
|
363
|
+
}
|
|
364
|
+
let [rx, ry] = untrack(() => axes.rate("rotate"))
|
|
365
|
+
if (rx !== 0 || ry !== 0) {
|
|
366
|
+
let step = ROTATE_RATE * 2 * Math.PI * rotateSpeed() * dt
|
|
367
|
+
rotate(-rx * step, ry * step)
|
|
368
|
+
moved = true
|
|
369
|
+
}
|
|
370
|
+
let z = untrack(() => axes.rate("zoom"))
|
|
371
|
+
if (z !== 0) {
|
|
372
|
+
zoomAbout(Math.pow(2, -z * ZOOM_RATE * zoomSpeed() * dt), null)
|
|
373
|
+
moved = true
|
|
314
374
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
375
|
+
let [px, py] = untrack(() => axes.rate("pan"))
|
|
376
|
+
if (px !== 0 || py !== 0) {
|
|
377
|
+
let w = worldPerHeight() * PAN_RATE * panSpeed() * dt
|
|
378
|
+
slide(-px * w, py * w)
|
|
379
|
+
moved = true
|
|
380
|
+
}
|
|
381
|
+
if (moved) push()
|
|
382
|
+
let result = changed
|
|
383
|
+
changed = false
|
|
384
|
+
return result
|
|
319
385
|
},
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
386
|
+
rotateBy(dAzimuth, dElevation) {
|
|
387
|
+
finite("rotateBy azimuth", dAzimuth)
|
|
388
|
+
finite("rotateBy elevation", dElevation)
|
|
389
|
+
rotate(dAzimuth, dElevation)
|
|
390
|
+
push()
|
|
391
|
+
},
|
|
392
|
+
zoomBy(factor, anchor) {
|
|
393
|
+
if (!(Number.isFinite(factor) && factor > 0)) throw new Error(`createOrbitCamera: zoomBy factor must be positive, got ${factor}`)
|
|
394
|
+
zoomAbout(1 / factor, anchor)
|
|
395
|
+
push()
|
|
396
|
+
},
|
|
397
|
+
panBy(right, up) {
|
|
398
|
+
finite("panBy right", right)
|
|
399
|
+
finite("panBy up", up)
|
|
400
|
+
slide(right, up)
|
|
401
|
+
push()
|
|
402
|
+
},
|
|
403
|
+
setPivot(point) {
|
|
404
|
+
setPivot(point)
|
|
405
|
+
push()
|
|
326
406
|
},
|
|
327
407
|
}
|
|
328
408
|
}
|