@solidrt/3d 0.0.46 → 0.0.47

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/math.ts CHANGED
@@ -9,7 +9,9 @@
9
9
  // interpreter. Mat4 is a 16-tuple so constant-index access stays plain
10
10
  // `number` under noUncheckedIndexedAccess.
11
11
 
12
+ export type Vec2 = [number, number]
12
13
  export type Vec3 = [number, number, number]
14
+ export type Vec4 = [number, number, number, number]
13
15
  // prettier-ignore
14
16
  export type Mat4 = [
15
17
  number, number, number, number,
@@ -39,6 +41,20 @@ export function copy(out: Mat4, m: Mat4): Mat4 {
39
41
  return out
40
42
  }
41
43
 
44
+ /**
45
+ * Transform a point by m with w = 1, keeping the homogeneous result: the
46
+ * clip-space building block (scene.project, picking). The caller owns the
47
+ * perspective divide and the w <= 0 behind-the-camera test.
48
+ */
49
+ export function transformPoint(out: Vec4, m: Mat4, p: Vec3): Vec4 {
50
+ let x = p[0], y = p[1], z = p[2]
51
+ out[0] = m[0] * x + m[4] * y + m[8] * z + m[12]
52
+ out[1] = m[1] * x + m[5] * y + m[9] * z + m[13]
53
+ out[2] = m[2] * x + m[6] * y + m[10] * z + m[14]
54
+ out[3] = m[3] * x + m[7] * y + m[11] * z + m[15]
55
+ return out
56
+ }
57
+
42
58
  /** out = a * b (column vectors: b applies first). out may alias a or b. */
43
59
  export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
44
60
  let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]
@@ -93,6 +109,30 @@ export function compose(out: Mat4, position: Vec3, rotation: Vec3, scale: Vec3):
93
109
  return out
94
110
  }
95
111
 
112
+ /**
113
+ * The normal matrix for a world matrix: the inverse-transpose of its upper
114
+ * 3x3 (the cofactor matrix over the determinant), packed into a mat4 - the
115
+ * engine's settable uniform set has no mat3, so shaders take `mat3(uNormal)`.
116
+ * Correct under any transform including non-uniform scale, where
117
+ * `mat3(uModel)` would bend normals off the surface. A degenerate
118
+ * (zero-scale) input yields the raw cofactors instead of NaNs.
119
+ */
120
+ export function normalMatrix(out: Mat4, m: Mat4): Mat4 {
121
+ let a = m[0], b = m[4], c = m[8]
122
+ let d = m[1], e = m[5], f = m[9]
123
+ let g = m[2], h = m[6], i = m[10]
124
+ let c00 = e * i - f * h
125
+ let c01 = f * g - d * i
126
+ let c02 = d * h - e * g
127
+ let det = a * c00 + b * c01 + c * c02
128
+ let s = 1 / (det || 1)
129
+ out[0] = c00 * s; out[1] = (c * h - b * i) * s; out[2] = (b * f - c * e) * s; out[3] = 0
130
+ out[4] = c01 * s; out[5] = (a * i - c * g) * s; out[6] = (c * d - a * f) * s; out[7] = 0
131
+ out[8] = c02 * s; out[9] = (b * g - a * h) * s; out[10] = (a * e - b * d) * s; out[11] = 0
132
+ out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1
133
+ return out
134
+ }
135
+
96
136
  /**
97
137
  * Right-handed perspective projection with the engine's y-down clip flip
98
138
  * BAKED IN (row two is negated): geometry authored y-up displays y-up, and
package/src/orbit.ts CHANGED
@@ -1,6 +1,38 @@
1
1
  // An orbit camera for a scene: azimuth/elevation/distance around a target,
2
- // with drag-to-rotate, wheel-to-zoom, optional auto-orbit, and clamps - the
3
- // standard interactive-viewer camera, extracted so apps stop rebuilding it.
2
+ // with drag-to-rotate, pinch- and wheel-to-zoom, optional auto-orbit, and
3
+ // clamps - the standard interactive-viewer camera, extracted so apps stop
4
+ // rebuilding it.
5
+ //
6
+ // Input goes through core's merged transform recognizer (createTransform), so
7
+ // the drag participates in the gesture arena: on a viewport embedded in a
8
+ // scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
9
+ // instead of both acting, and rotation only starts once the drag crosses the
10
+ // recognizer's slop. One finger (or a mouse drag) rotates; two-finger pinch
11
+ // zooms (pair rotation is ignored); wheel zooms directly - no recognizer
12
+ // needed for a discrete wheel step. What two-finger translation means
13
+ // depends on `viewport`: with it, two fingers pan - the scene slides under
14
+ // the fingers 1:1 at the target's depth, the touch convention everywhere
15
+ // (three.js DOLLY_PAN, Sketchfab, touch CAD) - which also keeps an
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.
19
+ //
20
+ // Zoom aims at the target by default. With `zoomAnchor` the app maps the
21
+ // pinch focal / wheel cursor to a world point, and the zoom scales the pose
22
+ // about that point instead - the spot under the fingers stays under the
23
+ // fingers, with the target sliding toward it. Only the app can own that
24
+ // mapping: screen-to-ray needs the projection and the element's placement
25
+ // (fov, aspect, viewBox scaling), which live in the app's camera and layout,
26
+ // not here.
27
+ //
28
+ // Anchored zoom leaves the target wherever the zoom carried it - possibly a
29
+ // point in empty air near the eye, and a drag orbiting THAT swings the scene
30
+ // wildly around nothing. `rotateAnchor` is the countermeasure: at gesture
31
+ // start the app names the world point rotation should pivot about (what the
32
+ // camera is actually looking at), and the pivot is re-seated there. The
33
+ // point is projected onto the view axis first, so re-seating moves only the
34
+ // target's depth - the eye and the picture do not change, only what a drag
35
+ // swings around.
4
36
  //
5
37
  // Pose is plain mutable state advanced by update(dt) from the app's own
6
38
  // onFrame; the control registers no frame loop of its own, so whether
@@ -8,11 +40,14 @@
8
40
  // animation costs nothing). Only `orbiting` is a signal - slow UI state
9
41
  // that HUDs read - while the pose moves at frame rate and bypasses
10
42
  // reactivity: the package's structure-vs-motion split. update() pushes the
11
- // pose to the scene camera only when it actually changed, and reports
12
- // that, so per-frame dependents (a uCamPos uniform) can follow the camera
13
- // without writing every frame.
43
+ // pose to the scene camera only when it actually changed (one setCamera:
44
+ // the scene's own shared write carries uViewProj and uCamPos), and reports
45
+ // that, so per-frame dependents (reprojecting HUD overlays via
46
+ // scene.project) can follow the camera without recomputing every frame.
14
47
 
15
48
  import { createSignal } from "@solidjs/signals"
49
+ import { createTransform } from "@solidrt/core"
50
+ import type { PointerEvent } from "@solidrt/core"
16
51
  import type { Scene } from "./scene.ts"
17
52
  import type { Vec3 } from "./math.ts"
18
53
 
@@ -35,11 +70,38 @@ export type OrbitCameraOptions = {
35
70
  minElevation?: number
36
71
  maxElevation?: number
37
72
  /** Auto-orbit rate in radians/second (default 0: none). Runs while
38
- * `orbiting()` and not dragging; toggle with set({ orbiting }). */
73
+ * `orbiting()` and no drag/pinch is in progress; toggle with
74
+ * set({ orbiting }). */
39
75
  orbitSpeed?: number
40
- /** Multipliers over the built-in drag/wheel sensitivities. */
76
+ /** Multipliers over the built-in drag and zoom (wheel + pinch) sensitivities. */
41
77
  rotateSpeed?: number
42
78
  zoomSpeed?: number
79
+ /** Multiplier over the two-finger pan (1 = the scene tracks the fingers
80
+ * exactly at the target's depth). */
81
+ panSpeed?: number
82
+ /** The viewport the pointer coordinates live in: height in the same
83
+ * logical units as clientX/clientY, and the camera's vertical fov in
84
+ * degrees. Providing it enables two-finger pan; without it two-finger
85
+ * translation rotates, as it always did. */
86
+ viewport?: () => { height: number; fov: number }
87
+ /** Constrain where a pan may put the target - return the target to use.
88
+ * The typical use: keep the pivot within a few radii of the subject so
89
+ * panning cannot strand the camera. Zoom and rotation do not consult it. */
90
+ clampTarget?: (target: Vec3) => Vec3
91
+ /** Map a screen point (window coordinates, the clientX/clientY frame) to
92
+ * the world point a zoom there should keep pinned. Called once per pinch
93
+ * gesture (at its first span change - the anchor then holds for the whole
94
+ * gesture, see the jitter note at `pinchAnchor`) and once per wheel event,
95
+ * with the pose the zoom is about to apply to. Return null to zoom toward
96
+ * the target as usual (also the default). */
97
+ zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
98
+ /** The world point rotation should pivot about - called when a drag or
99
+ * pinch starts. It is projected onto the view axis and becomes the new
100
+ * target, preserving the picture exactly (only the pivot's depth moves),
101
+ * so a drag after an anchored zoom orbits the scene under the camera
102
+ * instead of wherever the zoom left the target. Points at or behind the
103
+ * eye are ignored, as is null (both keep the current pivot). */
104
+ rotateAnchor?: (view: { eye: Vec3; target: Vec3 }) => Vec3 | null
43
105
  }
44
106
 
45
107
  export type OrbitPose = {
@@ -68,10 +130,12 @@ export type OrbitCamera = {
68
130
  /** Spread onto the element that receives input:
69
131
  * `<window {...orbit.handlers} />`. */
70
132
  handlers: {
71
- onPointerDown(e: { clientX: number; clientY: number }): void
72
- onPointerMove(e: { clientX: number; clientY: number }): void
73
- onPointerUp(): void
74
- onWheel(e: { deltaY: number }): void
133
+ onPointerDown(e: PointerEvent): void
134
+ onPointerMove(e: PointerEvent): void
135
+ onPointerUp(e: PointerEvent): void
136
+ /** Position is optional so a bare `{ deltaY }` still zooms; without it
137
+ * a zoomAnchor cannot apply and the zoom falls back to the target. */
138
+ onWheel(e: { deltaY: number; clientX?: number; clientY?: number }): void
75
139
  }
76
140
  }
77
141
 
@@ -98,13 +162,33 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
98
162
  let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
99
163
 
100
164
  let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
101
- let drag: { x: number; y: number } | null = null
165
+ let interacting = false
102
166
  let dirty = false
103
167
 
104
168
  let clampPose = () => {
105
169
  elevation = clampNum(elevation, minElevation, maxElevation)
106
170
  distance = clampNum(distance, minDistance, maxDistance)
107
171
  }
172
+ // Slide eye and target together along the camera's right/up so the scene
173
+ // tracks the fingers: dragged pixels map to world units through the
174
+ // frustum height at the target's depth. Screen +y is down, so the up-axis
175
+ // term is added (fingers down -> camera up -> scene follows down).
176
+ let pan = (dx: number, dy: number) => {
177
+ let vp = options.viewport!()
178
+ let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
179
+ let sa = Math.sin(azimuth)
180
+ let ca = Math.cos(azimuth)
181
+ let se = Math.sin(elevation)
182
+ let ce = Math.cos(elevation)
183
+ // right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
184
+ // camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
185
+ let next: Vec3 = [
186
+ target[0] - dx * wpp * ca - dy * wpp * sa * se,
187
+ target[1] + dy * wpp * ce,
188
+ target[2] + dx * wpp * sa - dy * wpp * ca * se,
189
+ ]
190
+ target = options.clampTarget ? options.clampTarget(next) : next
191
+ }
108
192
  let eye = (): Vec3 => {
109
193
  let ce = Math.cos(elevation)
110
194
  return [
@@ -115,9 +199,97 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
115
199
  }
116
200
  let apply = () => scene.setCamera({ position: eye(), target })
117
201
 
202
+ // Zoom by `ratio` (new distance over old, before clamping) about a world
203
+ // anchor (null zooms toward the target). Scaling eye and target about the
204
+ // anchor by the distance ratio keeps it projecting to the same pixel, so
205
+ // the shift below uses the ratio that actually applied after clamping -
206
+ // once distance pins at a clamp the target stops moving too, instead of
207
+ // sliding the view sideways under a dead zoom.
208
+ let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
209
+ let prev = distance
210
+ // Bounds widen to the current distance so a pose already outside them
211
+ // (a rotateAnchor re-seat may land anywhere) zooms back toward range
212
+ // instead of snap-jumping into it.
213
+ distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
214
+ if (!anchor) return
215
+ let s = distance / prev
216
+ target = [
217
+ anchor[0] + (target[0] - anchor[0]) * s,
218
+ anchor[1] + (target[1] - anchor[1]) * s,
219
+ anchor[2] + (target[2] - anchor[2]) * s,
220
+ ]
221
+ }
222
+ let anchorAt = (x?: number, y?: number) =>
223
+ x !== undefined && y !== undefined && options.zoomAnchor
224
+ ? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
225
+ : null
226
+
227
+ // The pinch keeps ONE anchor for its whole gesture, taken at the first
228
+ // span change. Per-event re-derivation looks equivalent but jitters in
229
+ // practice: the two fingers' events interleave, so the measured span
230
+ // oscillates around its true value even while the fingers rest, and every
231
+ // event zooms slightly in or back out. About a fixed point those pairs
232
+ // cancel exactly (the ratios telescope); about a fresh anchor each time -
233
+ // a new raycast against a pose the previous event just moved, or the
234
+ // app's hit/fallback choice flipping between events - each pair leaves a
235
+ // residual target slide and the model visibly crawls under resting
236
+ // fingers. The wheel stays per-event: its notches are discrete, there is
237
+ // no noise to cancel, and each notch re-aiming at the cursor is the point.
238
+ let pinchAnchor: Vec3 | null = null
239
+ let pinchSeen = false
240
+
118
241
  clampPose()
119
242
  apply()
120
243
 
244
+ // One finger rotates. With two down, the span change zooms and the focal
245
+ // translation pans (or, with no viewport to map pixels through, keeps
246
+ // rotating). Pair rotation has no orbit meaning and is ignored.
247
+ let transform = createTransform({
248
+ onTransformStart: () => {
249
+ interacting = true
250
+ let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
251
+ if (anchor) {
252
+ let e = eye()
253
+ let fx = (target[0] - e[0]) / distance
254
+ let fy = (target[1] - e[1]) / distance
255
+ let fz = (target[2] - e[2]) / distance
256
+ let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
257
+ if (depth > 0) {
258
+ // Deliberately unclamped: the pose is unchanged, this only decides
259
+ // what the gesture pivots about. The zoom clamps below widen to
260
+ // the current distance, so a pivot outside [min, max] cannot make
261
+ // the next zoom snap-jump either.
262
+ distance = depth
263
+ target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
264
+ }
265
+ }
266
+ },
267
+ onTransformMove: (t) => {
268
+ if (t.pointers >= 2 && options.viewport) {
269
+ pan(t.dx, t.dy)
270
+ } else {
271
+ azimuth -= t.dx * dragAzimuth
272
+ elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
273
+ }
274
+ if (t.scale !== 1) {
275
+ // Fingers spreading (scale > 1) zooms in: the distance shrinks by the
276
+ // span ratio, exponent-weighted by the zoomSpeed multiplier, about
277
+ // the gesture's anchor when the app provides one.
278
+ if (!pinchSeen) {
279
+ pinchSeen = true
280
+ pinchAnchor = anchorAt(t.x, t.y)
281
+ }
282
+ zoomAbout(1 / Math.pow(t.scale, options.zoomSpeed ?? 1), pinchAnchor)
283
+ }
284
+ dirty = true
285
+ },
286
+ onTransformEnd: () => {
287
+ interacting = false
288
+ pinchSeen = false
289
+ pinchAnchor = null
290
+ },
291
+ })
292
+
121
293
  return {
122
294
  eye,
123
295
  pose: () => ({ azimuth, elevation, distance }),
@@ -132,7 +304,7 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
132
304
  dirty = true
133
305
  },
134
306
  update(dt) {
135
- if (orbitSpeed !== 0 && drag === null && orbiting()) {
307
+ if (orbitSpeed !== 0 && !interacting && orbiting()) {
136
308
  azimuth += dt * orbitSpeed
137
309
  dirty = true
138
310
  }
@@ -142,21 +314,9 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
142
314
  return true
143
315
  },
144
316
  handlers: {
145
- onPointerDown(e) {
146
- drag = { x: e.clientX, y: e.clientY }
147
- },
148
- onPointerMove(e) {
149
- if (!drag) return
150
- azimuth -= (e.clientX - drag.x) * dragAzimuth
151
- elevation = clampNum(elevation + (e.clientY - drag.y) * dragElevation, minElevation, maxElevation)
152
- drag = { x: e.clientX, y: e.clientY }
153
- dirty = true
154
- },
155
- onPointerUp() {
156
- drag = null
157
- },
317
+ ...transform.handlers,
158
318
  onWheel(e) {
159
- distance = clampNum(distance * Math.exp(e.deltaY * wheelZoom), minDistance, maxDistance)
319
+ zoomAbout(Math.exp(e.deltaY * wheelZoom), anchorAt(e.clientX, e.clientY))
160
320
  dirty = true
161
321
  },
162
322
  },