reze-engine 0.2.19 → 0.3.0

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/camera.ts CHANGED
@@ -1,358 +1,358 @@
1
- import { Mat4, Vec3 } from "./math"
2
-
3
- const FAR = 1000
4
-
5
- export class Camera {
6
- alpha: number
7
- beta: number
8
- radius: number
9
- target: Vec3
10
- fov: number
11
- aspect: number = 1
12
- near: number = 0.05
13
- far: number = FAR
14
-
15
- // Input state
16
- private canvas: HTMLCanvasElement | null = null
17
- private isDragging: boolean = false
18
- private mouseButton: number | null = null // Track which mouse button is pressed (0 = left, 2 = right)
19
- private lastMousePos = { x: 0, y: 0 }
20
- private lastTouchPos = { x: 0, y: 0 }
21
- private touchIdentifier: number | null = null
22
- private isPinching: boolean = false
23
- private lastPinchDistance: number = 0
24
- private lastPinchMidpoint = { x: 0, y: 0 } // Midpoint of two fingers for panning
25
- private initialPinchDistance: number = 0 // Initial distance when pinch started
26
-
27
- // Camera settings
28
- angularSensitivity: number = 0.005
29
- panSensitivity: number = 0.0002 // Sensitivity for right-click panning
30
- wheelPrecision: number = 0.01
31
- pinchPrecision: number = 0.05
32
- minZ: number = 0.1
33
- maxZ: number = FAR
34
- lowerBetaLimit: number = 0.001
35
- upperBetaLimit: number = Math.PI - 0.001
36
-
37
- constructor(alpha: number, beta: number, radius: number, target: Vec3, fov: number = Math.PI / 4) {
38
- this.alpha = alpha
39
- this.beta = beta
40
- this.radius = radius
41
- this.target = target
42
- this.fov = fov
43
-
44
- // Bind event handlers
45
- this.onMouseDown = this.onMouseDown.bind(this)
46
- this.onMouseMove = this.onMouseMove.bind(this)
47
- this.onMouseUp = this.onMouseUp.bind(this)
48
- this.onWheel = this.onWheel.bind(this)
49
- this.onContextMenu = this.onContextMenu.bind(this)
50
- this.onTouchStart = this.onTouchStart.bind(this)
51
- this.onTouchMove = this.onTouchMove.bind(this)
52
- this.onTouchEnd = this.onTouchEnd.bind(this)
53
- }
54
-
55
- getPosition(): Vec3 {
56
- // Convert spherical coordinates to Cartesian position
57
- const x = this.target.x + this.radius * Math.sin(this.beta) * Math.sin(this.alpha)
58
- const y = this.target.y + this.radius * Math.cos(this.beta)
59
- const z = this.target.z + this.radius * Math.sin(this.beta) * Math.cos(this.alpha)
60
- return new Vec3(x, y, z)
61
- }
62
-
63
- getViewMatrix(): Mat4 {
64
- const eye = this.getPosition()
65
- const up = new Vec3(0, 1, 0)
66
- return Mat4.lookAt(eye, this.target, up)
67
- }
68
-
69
- // Get camera's right and up vectors for panning
70
- // Uses a more robust calculation similar to BabylonJS
71
- private getCameraVectors(): { right: Vec3; up: Vec3 } {
72
- const eye = this.getPosition()
73
- const forward = this.target.subtract(eye)
74
- const forwardLen = forward.length()
75
-
76
- // Handle edge case where camera is at target
77
- if (forwardLen < 0.0001) {
78
- return { right: new Vec3(1, 0, 0), up: new Vec3(0, 1, 0) }
79
- }
80
-
81
- const forwardNorm = forward.scale(1 / forwardLen)
82
- const worldUp = new Vec3(0, 1, 0)
83
-
84
- // Calculate right vector: right = worldUp × forward
85
- // Use a more stable calculation that handles parallel vectors
86
- let right = worldUp.cross(forwardNorm)
87
- const rightLen = right.length()
88
-
89
- // If forward is parallel to worldUp, use a fallback
90
- if (rightLen < 0.0001) {
91
- // Camera is looking straight up or down, use X-axis as right
92
- right = new Vec3(1, 0, 0)
93
- } else {
94
- right = right.scale(1 / rightLen)
95
- }
96
-
97
- // Calculate camera up vector: up = forward × right (ensures orthogonality)
98
- let up = forwardNorm.cross(right)
99
- const upLen = up.length()
100
-
101
- if (upLen < 0.0001) {
102
- // Fallback to world up
103
- up = new Vec3(0, 1, 0)
104
- } else {
105
- up = up.scale(1 / upLen)
106
- }
107
-
108
- return { right, up }
109
- }
110
-
111
- // Pan the camera target based on mouse movement
112
- // Uses screen-space to world-space translation similar to BabylonJS
113
- private panCamera(deltaX: number, deltaY: number) {
114
- const { right, up } = this.getCameraVectors()
115
-
116
- // Calculate pan distance based on camera distance
117
- // The pan amount is proportional to the camera distance (radius) for consistent feel
118
- // This makes panning feel natural at all zoom levels
119
- const panDistance = this.radius * this.panSensitivity
120
-
121
- // Horizontal movement: drag right pans left (opposite direction)
122
- // Vertical movement: drag up pans up (positive up vector)
123
- const panRight = right.scale(-deltaX * panDistance)
124
- const panUp = up.scale(deltaY * panDistance)
125
-
126
- // Update target position smoothly
127
- this.target = this.target.add(panRight).add(panUp)
128
- }
129
-
130
- getProjectionMatrix(): Mat4 {
131
- return Mat4.perspective(this.fov, this.aspect, this.near, this.far)
132
- }
133
-
134
- attachControl(canvas: HTMLCanvasElement) {
135
- this.canvas = canvas
136
-
137
- // Attach mouse event listeners
138
- // mousedown on canvas, but move/up on window so dragging works everywhere
139
- this.canvas.addEventListener("mousedown", this.onMouseDown)
140
- window.addEventListener("mousemove", this.onMouseMove)
141
- window.addEventListener("mouseup", this.onMouseUp)
142
- this.canvas.addEventListener("wheel", this.onWheel, { passive: false })
143
- this.canvas.addEventListener("contextmenu", this.onContextMenu)
144
-
145
- // Attach touch event listeners for mobile
146
- this.canvas.addEventListener("touchstart", this.onTouchStart, { passive: false })
147
- window.addEventListener("touchmove", this.onTouchMove, { passive: false })
148
- window.addEventListener("touchend", this.onTouchEnd)
149
- }
150
-
151
- detachControl() {
152
- if (!this.canvas) return
153
-
154
- // Remove mouse event listeners
155
- this.canvas.removeEventListener("mousedown", this.onMouseDown)
156
- window.removeEventListener("mousemove", this.onMouseMove)
157
- window.removeEventListener("mouseup", this.onMouseUp)
158
- this.canvas.removeEventListener("wheel", this.onWheel)
159
- this.canvas.removeEventListener("contextmenu", this.onContextMenu)
160
-
161
- // Remove touch event listeners
162
- this.canvas.removeEventListener("touchstart", this.onTouchStart)
163
- window.removeEventListener("touchmove", this.onTouchMove)
164
- window.removeEventListener("touchend", this.onTouchEnd)
165
-
166
- this.canvas = null
167
- }
168
-
169
- private onMouseDown(e: MouseEvent) {
170
- this.isDragging = true
171
- this.mouseButton = e.button
172
- this.lastMousePos = { x: e.clientX, y: e.clientY }
173
- }
174
-
175
- private onMouseMove(e: MouseEvent) {
176
- if (!this.isDragging) return
177
-
178
- const deltaX = e.clientX - this.lastMousePos.x
179
- const deltaY = e.clientY - this.lastMousePos.y
180
-
181
- if (this.mouseButton === 2) {
182
- // Right-click: pan the camera target
183
- this.panCamera(deltaX, deltaY)
184
- } else {
185
- // Left-click (or default): rotate the camera
186
- this.alpha += deltaX * this.angularSensitivity
187
- this.beta -= deltaY * this.angularSensitivity
188
-
189
- // Clamp beta to prevent flipping
190
- this.beta = Math.max(this.lowerBetaLimit, Math.min(this.upperBetaLimit, this.beta))
191
- }
192
-
193
- this.lastMousePos = { x: e.clientX, y: e.clientY }
194
- }
195
-
196
- private onMouseUp() {
197
- this.isDragging = false
198
- this.mouseButton = null
199
- }
200
-
201
- private onWheel(e: WheelEvent) {
202
- e.preventDefault()
203
-
204
- // Update camera radius (zoom)
205
- this.radius += e.deltaY * this.wheelPrecision
206
-
207
- // Clamp radius to reasonable bounds
208
- this.radius = Math.max(this.minZ, Math.min(this.maxZ, this.radius))
209
- // Expand far plane to keep scene visible when zooming out
210
- this.far = Math.max(FAR, this.radius * 4)
211
- }
212
-
213
- private onContextMenu(e: Event) {
214
- e.preventDefault()
215
- }
216
-
217
- private onTouchStart(e: TouchEvent) {
218
- e.preventDefault()
219
-
220
- if (e.touches.length === 1) {
221
- // Single touch - rotation
222
- const touch = e.touches[0]
223
- this.isDragging = true
224
- this.isPinching = false
225
- this.touchIdentifier = touch.identifier
226
- this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
227
- } else if (e.touches.length === 2) {
228
- // Two touches - can be pinch zoom or pan
229
- this.isDragging = false
230
- this.isPinching = true
231
- const touch1 = e.touches[0]
232
- const touch2 = e.touches[1]
233
- const dx = touch2.clientX - touch1.clientX
234
- const dy = touch2.clientY - touch1.clientY
235
- this.lastPinchDistance = Math.sqrt(dx * dx + dy * dy)
236
- this.initialPinchDistance = this.lastPinchDistance
237
-
238
- // Calculate initial midpoint for panning
239
- this.lastPinchMidpoint = {
240
- x: (touch1.clientX + touch2.clientX) / 2,
241
- y: (touch1.clientY + touch2.clientY) / 2,
242
- }
243
- }
244
- }
245
-
246
- private onTouchMove(e: TouchEvent) {
247
- e.preventDefault()
248
-
249
- if (this.isPinching && e.touches.length === 2) {
250
- // Two-finger gesture: can be pinch zoom or pan
251
- const touch1 = e.touches[0]
252
- const touch2 = e.touches[1]
253
- const dx = touch2.clientX - touch1.clientX
254
- const dy = touch2.clientY - touch1.clientY
255
- const distance = Math.sqrt(dx * dx + dy * dy)
256
-
257
- // Calculate current midpoint
258
- const currentMidpoint = {
259
- x: (touch1.clientX + touch2.clientX) / 2,
260
- y: (touch1.clientY + touch2.clientY) / 2,
261
- }
262
-
263
- // Calculate distance change and midpoint movement
264
- const distanceDelta = Math.abs(distance - this.lastPinchDistance)
265
- const midpointDeltaX = currentMidpoint.x - this.lastPinchMidpoint.x
266
- const midpointDeltaY = currentMidpoint.y - this.lastPinchMidpoint.y
267
- const midpointDelta = Math.sqrt(midpointDeltaX * midpointDeltaX + midpointDeltaY * midpointDeltaY)
268
-
269
- // Determine gesture type based on relative changes
270
- // Calculate relative change in distance (as percentage of initial distance)
271
- const distanceChangeRatio = distanceDelta / Math.max(this.initialPinchDistance, 10.0)
272
-
273
- // Threshold: if distance changes more than 3% of initial, it's primarily a zoom gesture
274
- // Otherwise, if midpoint moves significantly, it's a pan gesture
275
- const ZOOM_THRESHOLD = 0.03
276
- const PAN_THRESHOLD = 2.0 // Minimum pixels of midpoint movement for pan
277
-
278
- const isZoomGesture = distanceChangeRatio > ZOOM_THRESHOLD
279
- const isPanGesture = midpointDelta > PAN_THRESHOLD && distanceChangeRatio < ZOOM_THRESHOLD * 2
280
-
281
- if (isZoomGesture) {
282
- // Primary gesture is zoom (pinch)
283
- const delta = this.lastPinchDistance - distance
284
- this.radius += delta * this.pinchPrecision
285
-
286
- // Clamp radius to reasonable bounds
287
- this.radius = Math.max(this.minZ, Math.min(this.maxZ, this.radius))
288
- // Expand far plane for pinch zoom as well
289
- this.far = Math.max(FAR, this.radius * 4)
290
- }
291
-
292
- if (isPanGesture) {
293
- // Primary gesture is pan (two-finger drag)
294
- // Use panning similar to right-click pan
295
- this.panCamera(midpointDeltaX, midpointDeltaY)
296
- }
297
-
298
- // Update tracking values
299
- this.lastPinchDistance = distance
300
- this.lastPinchMidpoint = currentMidpoint
301
- } else if (this.isDragging && this.touchIdentifier !== null) {
302
- // Single-finger rotation
303
- // Find the touch we're tracking
304
- let touch: Touch | null = null
305
- for (let i = 0; i < e.touches.length; i++) {
306
- if (e.touches[i].identifier === this.touchIdentifier) {
307
- touch = e.touches[i]
308
- break
309
- }
310
- }
311
-
312
- if (!touch) return
313
-
314
- const deltaX = touch.clientX - this.lastTouchPos.x
315
- const deltaY = touch.clientY - this.lastTouchPos.y
316
-
317
- this.alpha += deltaX * this.angularSensitivity
318
- this.beta -= deltaY * this.angularSensitivity
319
-
320
- // Clamp beta to prevent flipping
321
- this.beta = Math.max(this.lowerBetaLimit, Math.min(this.upperBetaLimit, this.beta))
322
-
323
- this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
324
- }
325
- }
326
-
327
- private onTouchEnd(e: TouchEvent) {
328
- if (e.touches.length === 0) {
329
- // All touches ended
330
- this.isDragging = false
331
- this.isPinching = false
332
- this.touchIdentifier = null
333
- this.initialPinchDistance = 0
334
- } else if (e.touches.length === 1 && this.isPinching) {
335
- // Went from 2 fingers to 1 - switch to rotation
336
- const touch = e.touches[0]
337
- this.isPinching = false
338
- this.isDragging = true
339
- this.touchIdentifier = touch.identifier
340
- this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
341
- this.initialPinchDistance = 0
342
- } else if (this.touchIdentifier !== null) {
343
- // Check if our tracked touch ended
344
- let touchStillActive = false
345
- for (let i = 0; i < e.touches.length; i++) {
346
- if (e.touches[i].identifier === this.touchIdentifier) {
347
- touchStillActive = true
348
- break
349
- }
350
- }
351
-
352
- if (!touchStillActive) {
353
- this.isDragging = false
354
- this.touchIdentifier = null
355
- }
356
- }
357
- }
358
- }
1
+ import { Mat4, Vec3 } from "./math"
2
+
3
+ const FAR = 1000
4
+
5
+ export class Camera {
6
+ alpha: number
7
+ beta: number
8
+ radius: number
9
+ target: Vec3
10
+ fov: number
11
+ aspect: number = 1
12
+ near: number = 0.05
13
+ far: number = FAR
14
+
15
+ // Input state
16
+ private canvas: HTMLCanvasElement | null = null
17
+ private isDragging: boolean = false
18
+ private mouseButton: number | null = null // Track which mouse button is pressed (0 = left, 2 = right)
19
+ private lastMousePos = { x: 0, y: 0 }
20
+ private lastTouchPos = { x: 0, y: 0 }
21
+ private touchIdentifier: number | null = null
22
+ private isPinching: boolean = false
23
+ private lastPinchDistance: number = 0
24
+ private lastPinchMidpoint = { x: 0, y: 0 } // Midpoint of two fingers for panning
25
+ private initialPinchDistance: number = 0 // Initial distance when pinch started
26
+
27
+ // Camera settings
28
+ angularSensitivity: number = 0.005
29
+ panSensitivity: number = 0.0002 // Sensitivity for right-click panning
30
+ wheelPrecision: number = 0.01
31
+ pinchPrecision: number = 0.05
32
+ minZ: number = 0.1
33
+ maxZ: number = FAR
34
+ lowerBetaLimit: number = 0.001
35
+ upperBetaLimit: number = Math.PI - 0.001
36
+
37
+ constructor(alpha: number, beta: number, radius: number, target: Vec3, fov: number = Math.PI / 4) {
38
+ this.alpha = alpha
39
+ this.beta = beta
40
+ this.radius = radius
41
+ this.target = target
42
+ this.fov = fov
43
+
44
+ // Bind event handlers
45
+ this.onMouseDown = this.onMouseDown.bind(this)
46
+ this.onMouseMove = this.onMouseMove.bind(this)
47
+ this.onMouseUp = this.onMouseUp.bind(this)
48
+ this.onWheel = this.onWheel.bind(this)
49
+ this.onContextMenu = this.onContextMenu.bind(this)
50
+ this.onTouchStart = this.onTouchStart.bind(this)
51
+ this.onTouchMove = this.onTouchMove.bind(this)
52
+ this.onTouchEnd = this.onTouchEnd.bind(this)
53
+ }
54
+
55
+ getPosition(): Vec3 {
56
+ // Convert spherical coordinates to Cartesian position
57
+ const x = this.target.x + this.radius * Math.sin(this.beta) * Math.sin(this.alpha)
58
+ const y = this.target.y + this.radius * Math.cos(this.beta)
59
+ const z = this.target.z + this.radius * Math.sin(this.beta) * Math.cos(this.alpha)
60
+ return new Vec3(x, y, z)
61
+ }
62
+
63
+ getViewMatrix(): Mat4 {
64
+ const eye = this.getPosition()
65
+ const up = new Vec3(0, 1, 0)
66
+ return Mat4.lookAt(eye, this.target, up)
67
+ }
68
+
69
+ // Get camera's right and up vectors for panning
70
+ // Uses a more robust calculation similar to BabylonJS
71
+ private getCameraVectors(): { right: Vec3; up: Vec3 } {
72
+ const eye = this.getPosition()
73
+ const forward = this.target.subtract(eye)
74
+ const forwardLen = forward.length()
75
+
76
+ // Handle edge case where camera is at target
77
+ if (forwardLen < 0.0001) {
78
+ return { right: new Vec3(1, 0, 0), up: new Vec3(0, 1, 0) }
79
+ }
80
+
81
+ const forwardNorm = forward.scale(1 / forwardLen)
82
+ const worldUp = new Vec3(0, 1, 0)
83
+
84
+ // Calculate right vector: right = worldUp × forward
85
+ // Use a more stable calculation that handles parallel vectors
86
+ let right = worldUp.cross(forwardNorm)
87
+ const rightLen = right.length()
88
+
89
+ // If forward is parallel to worldUp, use a fallback
90
+ if (rightLen < 0.0001) {
91
+ // Camera is looking straight up or down, use X-axis as right
92
+ right = new Vec3(1, 0, 0)
93
+ } else {
94
+ right = right.scale(1 / rightLen)
95
+ }
96
+
97
+ // Calculate camera up vector: up = forward × right (ensures orthogonality)
98
+ let up = forwardNorm.cross(right)
99
+ const upLen = up.length()
100
+
101
+ if (upLen < 0.0001) {
102
+ // Fallback to world up
103
+ up = new Vec3(0, 1, 0)
104
+ } else {
105
+ up = up.scale(1 / upLen)
106
+ }
107
+
108
+ return { right, up }
109
+ }
110
+
111
+ // Pan the camera target based on mouse movement
112
+ // Uses screen-space to world-space translation similar to BabylonJS
113
+ private panCamera(deltaX: number, deltaY: number) {
114
+ const { right, up } = this.getCameraVectors()
115
+
116
+ // Calculate pan distance based on camera distance
117
+ // The pan amount is proportional to the camera distance (radius) for consistent feel
118
+ // This makes panning feel natural at all zoom levels
119
+ const panDistance = this.radius * this.panSensitivity
120
+
121
+ // Horizontal movement: drag right pans left (opposite direction)
122
+ // Vertical movement: drag up pans up (positive up vector)
123
+ const panRight = right.scale(-deltaX * panDistance)
124
+ const panUp = up.scale(deltaY * panDistance)
125
+
126
+ // Update target position smoothly
127
+ this.target = this.target.add(panRight).add(panUp)
128
+ }
129
+
130
+ getProjectionMatrix(): Mat4 {
131
+ return Mat4.perspective(this.fov, this.aspect, this.near, this.far)
132
+ }
133
+
134
+ attachControl(canvas: HTMLCanvasElement) {
135
+ this.canvas = canvas
136
+
137
+ // Attach mouse event listeners
138
+ // mousedown on canvas, but move/up on window so dragging works everywhere
139
+ this.canvas.addEventListener("mousedown", this.onMouseDown)
140
+ window.addEventListener("mousemove", this.onMouseMove)
141
+ window.addEventListener("mouseup", this.onMouseUp)
142
+ this.canvas.addEventListener("wheel", this.onWheel, { passive: false })
143
+ this.canvas.addEventListener("contextmenu", this.onContextMenu)
144
+
145
+ // Attach touch event listeners for mobile
146
+ this.canvas.addEventListener("touchstart", this.onTouchStart, { passive: false })
147
+ window.addEventListener("touchmove", this.onTouchMove, { passive: false })
148
+ window.addEventListener("touchend", this.onTouchEnd)
149
+ }
150
+
151
+ detachControl() {
152
+ if (!this.canvas) return
153
+
154
+ // Remove mouse event listeners
155
+ this.canvas.removeEventListener("mousedown", this.onMouseDown)
156
+ window.removeEventListener("mousemove", this.onMouseMove)
157
+ window.removeEventListener("mouseup", this.onMouseUp)
158
+ this.canvas.removeEventListener("wheel", this.onWheel)
159
+ this.canvas.removeEventListener("contextmenu", this.onContextMenu)
160
+
161
+ // Remove touch event listeners
162
+ this.canvas.removeEventListener("touchstart", this.onTouchStart)
163
+ window.removeEventListener("touchmove", this.onTouchMove)
164
+ window.removeEventListener("touchend", this.onTouchEnd)
165
+
166
+ this.canvas = null
167
+ }
168
+
169
+ private onMouseDown(e: MouseEvent) {
170
+ this.isDragging = true
171
+ this.mouseButton = e.button
172
+ this.lastMousePos = { x: e.clientX, y: e.clientY }
173
+ }
174
+
175
+ private onMouseMove(e: MouseEvent) {
176
+ if (!this.isDragging) return
177
+
178
+ const deltaX = e.clientX - this.lastMousePos.x
179
+ const deltaY = e.clientY - this.lastMousePos.y
180
+
181
+ if (this.mouseButton === 2) {
182
+ // Right-click: pan the camera target
183
+ this.panCamera(deltaX, deltaY)
184
+ } else {
185
+ // Left-click (or default): rotate the camera
186
+ this.alpha += deltaX * this.angularSensitivity
187
+ this.beta -= deltaY * this.angularSensitivity
188
+
189
+ // Clamp beta to prevent flipping
190
+ this.beta = Math.max(this.lowerBetaLimit, Math.min(this.upperBetaLimit, this.beta))
191
+ }
192
+
193
+ this.lastMousePos = { x: e.clientX, y: e.clientY }
194
+ }
195
+
196
+ private onMouseUp() {
197
+ this.isDragging = false
198
+ this.mouseButton = null
199
+ }
200
+
201
+ private onWheel(e: WheelEvent) {
202
+ e.preventDefault()
203
+
204
+ // Update camera radius (zoom)
205
+ this.radius += e.deltaY * this.wheelPrecision
206
+
207
+ // Clamp radius to reasonable bounds
208
+ this.radius = Math.max(this.minZ, Math.min(this.maxZ, this.radius))
209
+ // Expand far plane to keep scene visible when zooming out
210
+ this.far = Math.max(FAR, this.radius * 4)
211
+ }
212
+
213
+ private onContextMenu(e: Event) {
214
+ e.preventDefault()
215
+ }
216
+
217
+ private onTouchStart(e: TouchEvent) {
218
+ e.preventDefault()
219
+
220
+ if (e.touches.length === 1) {
221
+ // Single touch - rotation
222
+ const touch = e.touches[0]
223
+ this.isDragging = true
224
+ this.isPinching = false
225
+ this.touchIdentifier = touch.identifier
226
+ this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
227
+ } else if (e.touches.length === 2) {
228
+ // Two touches - can be pinch zoom or pan
229
+ this.isDragging = false
230
+ this.isPinching = true
231
+ const touch1 = e.touches[0]
232
+ const touch2 = e.touches[1]
233
+ const dx = touch2.clientX - touch1.clientX
234
+ const dy = touch2.clientY - touch1.clientY
235
+ this.lastPinchDistance = Math.sqrt(dx * dx + dy * dy)
236
+ this.initialPinchDistance = this.lastPinchDistance
237
+
238
+ // Calculate initial midpoint for panning
239
+ this.lastPinchMidpoint = {
240
+ x: (touch1.clientX + touch2.clientX) / 2,
241
+ y: (touch1.clientY + touch2.clientY) / 2,
242
+ }
243
+ }
244
+ }
245
+
246
+ private onTouchMove(e: TouchEvent) {
247
+ e.preventDefault()
248
+
249
+ if (this.isPinching && e.touches.length === 2) {
250
+ // Two-finger gesture: can be pinch zoom or pan
251
+ const touch1 = e.touches[0]
252
+ const touch2 = e.touches[1]
253
+ const dx = touch2.clientX - touch1.clientX
254
+ const dy = touch2.clientY - touch1.clientY
255
+ const distance = Math.sqrt(dx * dx + dy * dy)
256
+
257
+ // Calculate current midpoint
258
+ const currentMidpoint = {
259
+ x: (touch1.clientX + touch2.clientX) / 2,
260
+ y: (touch1.clientY + touch2.clientY) / 2,
261
+ }
262
+
263
+ // Calculate distance change and midpoint movement
264
+ const distanceDelta = Math.abs(distance - this.lastPinchDistance)
265
+ const midpointDeltaX = currentMidpoint.x - this.lastPinchMidpoint.x
266
+ const midpointDeltaY = currentMidpoint.y - this.lastPinchMidpoint.y
267
+ const midpointDelta = Math.sqrt(midpointDeltaX * midpointDeltaX + midpointDeltaY * midpointDeltaY)
268
+
269
+ // Determine gesture type based on relative changes
270
+ // Calculate relative change in distance (as percentage of initial distance)
271
+ const distanceChangeRatio = distanceDelta / Math.max(this.initialPinchDistance, 10.0)
272
+
273
+ // Threshold: if distance changes more than 3% of initial, it's primarily a zoom gesture
274
+ // Otherwise, if midpoint moves significantly, it's a pan gesture
275
+ const ZOOM_THRESHOLD = 0.03
276
+ const PAN_THRESHOLD = 2.0 // Minimum pixels of midpoint movement for pan
277
+
278
+ const isZoomGesture = distanceChangeRatio > ZOOM_THRESHOLD
279
+ const isPanGesture = midpointDelta > PAN_THRESHOLD && distanceChangeRatio < ZOOM_THRESHOLD * 2
280
+
281
+ if (isZoomGesture) {
282
+ // Primary gesture is zoom (pinch)
283
+ const delta = this.lastPinchDistance - distance
284
+ this.radius += delta * this.pinchPrecision
285
+
286
+ // Clamp radius to reasonable bounds
287
+ this.radius = Math.max(this.minZ, Math.min(this.maxZ, this.radius))
288
+ // Expand far plane for pinch zoom as well
289
+ this.far = Math.max(FAR, this.radius * 4)
290
+ }
291
+
292
+ if (isPanGesture) {
293
+ // Primary gesture is pan (two-finger drag)
294
+ // Use panning similar to right-click pan
295
+ this.panCamera(midpointDeltaX, midpointDeltaY)
296
+ }
297
+
298
+ // Update tracking values
299
+ this.lastPinchDistance = distance
300
+ this.lastPinchMidpoint = currentMidpoint
301
+ } else if (this.isDragging && this.touchIdentifier !== null) {
302
+ // Single-finger rotation
303
+ // Find the touch we're tracking
304
+ let touch: Touch | null = null
305
+ for (let i = 0; i < e.touches.length; i++) {
306
+ if (e.touches[i].identifier === this.touchIdentifier) {
307
+ touch = e.touches[i]
308
+ break
309
+ }
310
+ }
311
+
312
+ if (!touch) return
313
+
314
+ const deltaX = touch.clientX - this.lastTouchPos.x
315
+ const deltaY = touch.clientY - this.lastTouchPos.y
316
+
317
+ this.alpha += deltaX * this.angularSensitivity
318
+ this.beta -= deltaY * this.angularSensitivity
319
+
320
+ // Clamp beta to prevent flipping
321
+ this.beta = Math.max(this.lowerBetaLimit, Math.min(this.upperBetaLimit, this.beta))
322
+
323
+ this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
324
+ }
325
+ }
326
+
327
+ private onTouchEnd(e: TouchEvent) {
328
+ if (e.touches.length === 0) {
329
+ // All touches ended
330
+ this.isDragging = false
331
+ this.isPinching = false
332
+ this.touchIdentifier = null
333
+ this.initialPinchDistance = 0
334
+ } else if (e.touches.length === 1 && this.isPinching) {
335
+ // Went from 2 fingers to 1 - switch to rotation
336
+ const touch = e.touches[0]
337
+ this.isPinching = false
338
+ this.isDragging = true
339
+ this.touchIdentifier = touch.identifier
340
+ this.lastTouchPos = { x: touch.clientX, y: touch.clientY }
341
+ this.initialPinchDistance = 0
342
+ } else if (this.touchIdentifier !== null) {
343
+ // Check if our tracked touch ended
344
+ let touchStillActive = false
345
+ for (let i = 0; i < e.touches.length; i++) {
346
+ if (e.touches[i].identifier === this.touchIdentifier) {
347
+ touchStillActive = true
348
+ break
349
+ }
350
+ }
351
+
352
+ if (!touchStillActive) {
353
+ this.isDragging = false
354
+ this.touchIdentifier = null
355
+ }
356
+ }
357
+ }
358
+ }