reze-engine 0.2.18 → 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/README.md +67 -66
- package/dist/bezier-interpolate.d.ts +15 -0
- package/dist/bezier-interpolate.d.ts.map +1 -0
- package/dist/bezier-interpolate.js +40 -0
- package/dist/engine.d.ts +10 -9
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +284 -144
- package/dist/ik-solver.d.ts +26 -0
- package/dist/ik-solver.d.ts.map +1 -0
- package/dist/ik-solver.js +372 -0
- package/dist/math.d.ts +1 -0
- package/dist/math.d.ts.map +1 -1
- package/dist/math.js +8 -0
- package/dist/model.d.ts +82 -3
- package/dist/model.d.ts.map +1 -1
- package/dist/model.js +357 -4
- package/dist/pmx-loader.d.ts +3 -1
- package/dist/pmx-loader.d.ts.map +1 -1
- package/dist/pmx-loader.js +218 -130
- package/dist/vmd-loader.d.ts +11 -1
- package/dist/vmd-loader.d.ts.map +1 -1
- package/dist/vmd-loader.js +91 -15
- package/package.json +1 -1
- package/src/bezier-interpolate.ts +47 -0
- package/src/camera.ts +358 -358
- package/src/engine.ts +308 -165
- package/src/ik-solver.ts +488 -0
- package/src/math.ts +555 -546
- package/src/model.ts +930 -421
- package/src/physics.ts +752 -752
- package/src/pmx-loader.ts +1173 -1054
- package/src/vmd-loader.ts +276 -179
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
|
+
}
|