@ifc-lite/renderer 1.0.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/dist/camera.js ADDED
@@ -0,0 +1,921 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+ import { MathUtils } from './math.js';
5
+ export class Camera {
6
+ camera;
7
+ viewMatrix;
8
+ projMatrix;
9
+ viewProjMatrix;
10
+ // Inertia system
11
+ velocity = { orbit: { x: 0, y: 0 }, pan: { x: 0, y: 0 }, zoom: 0 };
12
+ damping = 0.92; // Inertia factor (0-1), higher = more damping
13
+ minVelocity = 0.001; // Minimum velocity threshold
14
+ // Animation system
15
+ animationStartTime = 0;
16
+ animationDuration = 0;
17
+ animationStartPos = null;
18
+ animationStartTarget = null;
19
+ animationEndPos = null;
20
+ animationEndTarget = null;
21
+ animationStartUp = null;
22
+ animationEndUp = null;
23
+ animationEasing = null;
24
+ // First-person mode
25
+ isFirstPersonMode = false;
26
+ firstPersonSpeed = 0.1;
27
+ // Dynamic orbit pivot (for orbiting around selected element or cursor point)
28
+ orbitPivot = null;
29
+ // Track preset view for rotation cycling (clicking same view rotates 90°)
30
+ lastPresetView = null;
31
+ presetViewRotation = 0; // 0, 1, 2, 3 = 0°, 90°, 180°, 270°
32
+ constructor() {
33
+ // Geometry is converted from IFC Z-up to WebGL Y-up during import
34
+ this.camera = {
35
+ position: { x: 50, y: 50, z: 100 },
36
+ target: { x: 0, y: 0, z: 0 },
37
+ up: { x: 0, y: 1, z: 0 }, // Y-up (standard WebGL)
38
+ fov: Math.PI / 4,
39
+ aspect: 1,
40
+ near: 0.1,
41
+ far: 10000,
42
+ };
43
+ this.viewMatrix = MathUtils.identity();
44
+ this.projMatrix = MathUtils.identity();
45
+ this.viewProjMatrix = MathUtils.identity();
46
+ this.updateMatrices();
47
+ }
48
+ /**
49
+ * Set camera aspect ratio
50
+ */
51
+ setAspect(aspect) {
52
+ this.camera.aspect = aspect;
53
+ this.updateMatrices();
54
+ }
55
+ /**
56
+ * Set camera position
57
+ */
58
+ setPosition(x, y, z) {
59
+ this.camera.position = { x, y, z };
60
+ this.updateMatrices();
61
+ }
62
+ /**
63
+ * Set camera target
64
+ */
65
+ setTarget(x, y, z) {
66
+ this.camera.target = { x, y, z };
67
+ this.updateMatrices();
68
+ }
69
+ /**
70
+ * Set temporary orbit pivot (for orbiting around selected element or cursor point)
71
+ * When set, orbit() will rotate around this point instead of the camera target
72
+ */
73
+ setOrbitPivot(pivot) {
74
+ this.orbitPivot = pivot ? { ...pivot } : null;
75
+ }
76
+ /**
77
+ * Get current orbit pivot (returns temporary pivot if set, otherwise target)
78
+ */
79
+ getOrbitPivot() {
80
+ return this.orbitPivot ? { ...this.orbitPivot } : { ...this.camera.target };
81
+ }
82
+ /**
83
+ * Check if a temporary orbit pivot is set
84
+ */
85
+ hasOrbitPivot() {
86
+ return this.orbitPivot !== null;
87
+ }
88
+ /**
89
+ * Orbit around target or pivot (Y-up coordinate system)
90
+ * If an orbit pivot is set, orbits around that point and moves target along
91
+ */
92
+ orbit(deltaX, deltaY, addVelocity = false) {
93
+ // Always ensure Y-up for consistent orbit behavior
94
+ this.camera.up = { x: 0, y: 1, z: 0 };
95
+ // Reset preset view tracking when user orbits
96
+ this.lastPresetView = null;
97
+ this.presetViewRotation = 0;
98
+ // Invert controls: mouse movement direction = model rotation direction
99
+ const dx = -deltaX * 0.01;
100
+ const dy = -deltaY * 0.01;
101
+ // Use orbit pivot if set, otherwise use target
102
+ const pivotPoint = this.orbitPivot || this.camera.target;
103
+ const dir = {
104
+ x: this.camera.position.x - pivotPoint.x,
105
+ y: this.camera.position.y - pivotPoint.y,
106
+ z: this.camera.position.z - pivotPoint.z,
107
+ };
108
+ const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
109
+ if (distance < 1e-6)
110
+ return;
111
+ // Y-up coordinate system using standard spherical coordinates
112
+ // theta: horizontal rotation around Y axis
113
+ // phi: vertical angle from Y axis (0 = top, PI = bottom)
114
+ let currentPhi = Math.acos(Math.max(-1, Math.min(1, dir.y / distance)));
115
+ // When at poles (top/bottom view), use a stable theta based on current direction
116
+ // to avoid gimbal lock issues
117
+ let theta;
118
+ const sinPhi = Math.sin(currentPhi);
119
+ if (sinPhi > 0.05) {
120
+ // Normal case - calculate theta from horizontal position
121
+ theta = Math.atan2(dir.x, dir.z);
122
+ }
123
+ else {
124
+ // At a pole - determine which one and push away
125
+ theta = 0; // Default theta when at pole
126
+ if (currentPhi < Math.PI / 2) {
127
+ // Top pole (phi ≈ 0) - push down
128
+ currentPhi = 0.15;
129
+ }
130
+ else {
131
+ // Bottom pole (phi ≈ π) - push up
132
+ currentPhi = Math.PI - 0.15;
133
+ }
134
+ }
135
+ theta += dx;
136
+ const phi = currentPhi + dy;
137
+ // Clamp phi to prevent gimbal lock (stay away from exact poles)
138
+ const phiClamped = Math.max(0.15, Math.min(Math.PI - 0.15, phi));
139
+ // Calculate new camera position around pivot
140
+ const newPosX = pivotPoint.x + distance * Math.sin(phiClamped) * Math.sin(theta);
141
+ const newPosY = pivotPoint.y + distance * Math.cos(phiClamped);
142
+ const newPosZ = pivotPoint.z + distance * Math.sin(phiClamped) * Math.cos(theta);
143
+ // Update camera position
144
+ this.camera.position.x = newPosX;
145
+ this.camera.position.y = newPosY;
146
+ this.camera.position.z = newPosZ;
147
+ if (addVelocity) {
148
+ this.velocity.orbit.x += deltaX * 0.001;
149
+ this.velocity.orbit.y += deltaY * 0.001;
150
+ }
151
+ this.updateMatrices();
152
+ }
153
+ /**
154
+ * Pan camera (Y-up coordinate system)
155
+ */
156
+ pan(deltaX, deltaY, addVelocity = false) {
157
+ const dir = {
158
+ x: this.camera.position.x - this.camera.target.x,
159
+ y: this.camera.position.y - this.camera.target.y,
160
+ z: this.camera.position.z - this.camera.target.z,
161
+ };
162
+ const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
163
+ // Right vector: cross product of direction and up (0,1,0)
164
+ const right = {
165
+ x: -dir.z,
166
+ y: 0,
167
+ z: dir.x,
168
+ };
169
+ const rightLen = Math.sqrt(right.x * right.x + right.z * right.z);
170
+ if (rightLen > 1e-10) {
171
+ right.x /= rightLen;
172
+ right.z /= rightLen;
173
+ }
174
+ // Up vector: cross product of right and direction
175
+ const up = {
176
+ x: (right.z * dir.y - right.y * dir.z),
177
+ y: (right.x * dir.z - right.z * dir.x),
178
+ z: (right.y * dir.x - right.x * dir.y),
179
+ };
180
+ const upLen = Math.sqrt(up.x * up.x + up.y * up.y + up.z * up.z);
181
+ if (upLen > 1e-10) {
182
+ up.x /= upLen;
183
+ up.y /= upLen;
184
+ up.z /= upLen;
185
+ }
186
+ const panSpeed = distance * 0.001;
187
+ this.camera.target.x += (right.x * deltaX + up.x * deltaY) * panSpeed;
188
+ this.camera.target.y += (right.y * deltaX + up.y * deltaY) * panSpeed;
189
+ this.camera.target.z += (right.z * deltaX + up.z * deltaY) * panSpeed;
190
+ this.camera.position.x += (right.x * deltaX + up.x * deltaY) * panSpeed;
191
+ this.camera.position.y += (right.y * deltaX + up.y * deltaY) * panSpeed;
192
+ this.camera.position.z += (right.z * deltaX + up.z * deltaY) * panSpeed;
193
+ if (addVelocity) {
194
+ this.velocity.pan.x += deltaX * panSpeed * 0.1;
195
+ this.velocity.pan.y += deltaY * panSpeed * 0.1;
196
+ }
197
+ this.updateMatrices();
198
+ }
199
+ /**
200
+ * Zoom camera towards mouse position
201
+ * @param delta - Zoom delta (positive = zoom out, negative = zoom in)
202
+ * @param addVelocity - Whether to add velocity for inertia
203
+ * @param mouseX - Mouse X position in canvas coordinates
204
+ * @param mouseY - Mouse Y position in canvas coordinates
205
+ * @param canvasWidth - Canvas width
206
+ * @param canvasHeight - Canvas height
207
+ */
208
+ zoom(delta, addVelocity = false, mouseX, mouseY, canvasWidth, canvasHeight) {
209
+ const dir = {
210
+ x: this.camera.position.x - this.camera.target.x,
211
+ y: this.camera.position.y - this.camera.target.y,
212
+ z: this.camera.position.z - this.camera.target.z,
213
+ };
214
+ const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
215
+ // Normalize delta (wheel events can have large values)
216
+ const normalizedDelta = Math.sign(delta) * Math.min(Math.abs(delta) * 0.001, 0.1);
217
+ const zoomFactor = 1 + normalizedDelta;
218
+ // If mouse position provided, zoom towards that point
219
+ if (mouseX !== undefined && mouseY !== undefined && canvasWidth && canvasHeight) {
220
+ // Convert mouse to normalized device coordinates (-1 to 1)
221
+ const ndcX = (mouseX / canvasWidth) * 2 - 1;
222
+ const ndcY = 1 - (mouseY / canvasHeight) * 2; // Flip Y
223
+ // Calculate offset from center in world space
224
+ // Use the camera's right and up vectors
225
+ const forward = {
226
+ x: -dir.x / distance,
227
+ y: -dir.y / distance,
228
+ z: -dir.z / distance,
229
+ };
230
+ // Right = forward × up
231
+ const up = this.camera.up;
232
+ const right = {
233
+ x: forward.y * up.z - forward.z * up.y,
234
+ y: forward.z * up.x - forward.x * up.z,
235
+ z: forward.x * up.y - forward.y * up.x,
236
+ };
237
+ const rightLen = Math.sqrt(right.x * right.x + right.y * right.y + right.z * right.z);
238
+ if (rightLen > 1e-10) {
239
+ right.x /= rightLen;
240
+ right.y /= rightLen;
241
+ right.z /= rightLen;
242
+ }
243
+ // Actual up = right × forward
244
+ const actualUp = {
245
+ x: right.y * forward.z - right.z * forward.y,
246
+ y: right.z * forward.x - right.x * forward.z,
247
+ z: right.x * forward.y - right.y * forward.x,
248
+ };
249
+ // Calculate view frustum size at target distance
250
+ const halfHeight = distance * Math.tan(this.camera.fov / 2);
251
+ const halfWidth = halfHeight * this.camera.aspect;
252
+ // World offset from center towards mouse position
253
+ const worldOffsetX = ndcX * halfWidth;
254
+ const worldOffsetY = ndcY * halfHeight;
255
+ // Point in world space that mouse is pointing at (on the target plane)
256
+ const mouseWorldPoint = {
257
+ x: this.camera.target.x + right.x * worldOffsetX + actualUp.x * worldOffsetY,
258
+ y: this.camera.target.y + right.y * worldOffsetX + actualUp.y * worldOffsetY,
259
+ z: this.camera.target.z + right.z * worldOffsetX + actualUp.z * worldOffsetY,
260
+ };
261
+ // Move both camera and target towards mouse point while zooming
262
+ const moveAmount = (1 - zoomFactor); // Negative when zooming in
263
+ this.camera.target.x += (mouseWorldPoint.x - this.camera.target.x) * moveAmount;
264
+ this.camera.target.y += (mouseWorldPoint.y - this.camera.target.y) * moveAmount;
265
+ this.camera.target.z += (mouseWorldPoint.z - this.camera.target.z) * moveAmount;
266
+ }
267
+ // Apply zoom (scale distance)
268
+ const newDistance = Math.max(0.1, distance * zoomFactor);
269
+ const scale = newDistance / distance;
270
+ this.camera.position.x = this.camera.target.x + dir.x * scale;
271
+ this.camera.position.y = this.camera.target.y + dir.y * scale;
272
+ this.camera.position.z = this.camera.target.z + dir.z * scale;
273
+ if (addVelocity) {
274
+ this.velocity.zoom += normalizedDelta * 0.1;
275
+ }
276
+ this.updateMatrices();
277
+ }
278
+ /**
279
+ * Fit view to bounding box
280
+ * Sets camera to southeast isometric view (typical BIM starting view)
281
+ * Y-up coordinate system: Y is vertical
282
+ */
283
+ fitToBounds(min, max) {
284
+ const center = {
285
+ x: (min.x + max.x) / 2,
286
+ y: (min.y + max.y) / 2,
287
+ z: (min.z + max.z) / 2,
288
+ };
289
+ const size = {
290
+ x: max.x - min.x,
291
+ y: max.y - min.y,
292
+ z: max.z - min.z,
293
+ };
294
+ const maxSize = Math.max(size.x, size.y, size.z);
295
+ const distance = maxSize * 2.0;
296
+ this.camera.target = center;
297
+ // Southeast isometric view for Y-up:
298
+ // Position camera above and to the front-right of the model
299
+ this.camera.position = {
300
+ x: center.x + distance * 0.6, // Right
301
+ y: center.y + distance * 0.5, // Above
302
+ z: center.z + distance * 0.6, // Front
303
+ };
304
+ // Adjust far plane for large models
305
+ this.camera.far = Math.max(10000, distance * 20);
306
+ this.camera.near = Math.max(0.01, distance * 0.0001);
307
+ this.updateMatrices();
308
+ }
309
+ /**
310
+ * Update camera animation and inertia
311
+ * Returns true if camera is still animating
312
+ */
313
+ update(_deltaTime) {
314
+ // deltaTime reserved for future physics-based animation smoothing
315
+ void _deltaTime;
316
+ let isAnimating = false;
317
+ // Handle animation
318
+ if (this.animationStartTime > 0 && this.animationDuration > 0) {
319
+ const elapsed = Date.now() - this.animationStartTime;
320
+ const progress = Math.min(elapsed / this.animationDuration, 1);
321
+ if (progress < 1 && this.animationStartPos && this.animationEndPos &&
322
+ this.animationStartTarget && this.animationEndTarget && this.animationEasing) {
323
+ const t = this.animationEasing(progress);
324
+ this.camera.position.x = this.animationStartPos.x + (this.animationEndPos.x - this.animationStartPos.x) * t;
325
+ this.camera.position.y = this.animationStartPos.y + (this.animationEndPos.y - this.animationStartPos.y) * t;
326
+ this.camera.position.z = this.animationStartPos.z + (this.animationEndPos.z - this.animationStartPos.z) * t;
327
+ this.camera.target.x = this.animationStartTarget.x + (this.animationEndTarget.x - this.animationStartTarget.x) * t;
328
+ this.camera.target.y = this.animationStartTarget.y + (this.animationEndTarget.y - this.animationStartTarget.y) * t;
329
+ this.camera.target.z = this.animationStartTarget.z + (this.animationEndTarget.z - this.animationStartTarget.z) * t;
330
+ // Interpolate up vector if animating with up
331
+ if (this.animationStartUp && this.animationEndUp) {
332
+ // SLERP-like interpolation for up vector (normalized lerp)
333
+ let upX = this.animationStartUp.x + (this.animationEndUp.x - this.animationStartUp.x) * t;
334
+ let upY = this.animationStartUp.y + (this.animationEndUp.y - this.animationStartUp.y) * t;
335
+ let upZ = this.animationStartUp.z + (this.animationEndUp.z - this.animationStartUp.z) * t;
336
+ // Normalize
337
+ const len = Math.sqrt(upX * upX + upY * upY + upZ * upZ);
338
+ if (len > 0.0001) {
339
+ this.camera.up.x = upX / len;
340
+ this.camera.up.y = upY / len;
341
+ this.camera.up.z = upZ / len;
342
+ }
343
+ }
344
+ this.updateMatrices();
345
+ isAnimating = true;
346
+ }
347
+ else {
348
+ // Animation complete - set final values
349
+ if (this.animationEndPos) {
350
+ this.camera.position.x = this.animationEndPos.x;
351
+ this.camera.position.y = this.animationEndPos.y;
352
+ this.camera.position.z = this.animationEndPos.z;
353
+ }
354
+ if (this.animationEndTarget) {
355
+ this.camera.target.x = this.animationEndTarget.x;
356
+ this.camera.target.y = this.animationEndTarget.y;
357
+ this.camera.target.z = this.animationEndTarget.z;
358
+ }
359
+ if (this.animationEndUp) {
360
+ this.camera.up.x = this.animationEndUp.x;
361
+ this.camera.up.y = this.animationEndUp.y;
362
+ this.camera.up.z = this.animationEndUp.z;
363
+ }
364
+ this.updateMatrices();
365
+ this.animationStartTime = 0;
366
+ this.animationDuration = 0;
367
+ this.animationStartPos = null;
368
+ this.animationEndPos = null;
369
+ this.animationStartTarget = null;
370
+ this.animationEndTarget = null;
371
+ this.animationStartUp = null;
372
+ this.animationEndUp = null;
373
+ this.animationEasing = null;
374
+ }
375
+ }
376
+ // Apply inertia
377
+ if (Math.abs(this.velocity.orbit.x) > this.minVelocity || Math.abs(this.velocity.orbit.y) > this.minVelocity) {
378
+ this.orbit(this.velocity.orbit.x * 100, this.velocity.orbit.y * 100, false);
379
+ this.velocity.orbit.x *= this.damping;
380
+ this.velocity.orbit.y *= this.damping;
381
+ isAnimating = true;
382
+ }
383
+ if (Math.abs(this.velocity.pan.x) > this.minVelocity || Math.abs(this.velocity.pan.y) > this.minVelocity) {
384
+ this.pan(this.velocity.pan.x * 1000, this.velocity.pan.y * 1000, false);
385
+ this.velocity.pan.x *= this.damping;
386
+ this.velocity.pan.y *= this.damping;
387
+ isAnimating = true;
388
+ }
389
+ if (Math.abs(this.velocity.zoom) > this.minVelocity) {
390
+ this.zoom(this.velocity.zoom * 1000, false);
391
+ this.velocity.zoom *= this.damping;
392
+ isAnimating = true;
393
+ }
394
+ return isAnimating;
395
+ }
396
+ /**
397
+ * Animate camera to fit bounds (southeast isometric view)
398
+ * Y-up coordinate system
399
+ */
400
+ async zoomToFit(min, max, duration = 500) {
401
+ const center = {
402
+ x: (min.x + max.x) / 2,
403
+ y: (min.y + max.y) / 2,
404
+ z: (min.z + max.z) / 2,
405
+ };
406
+ const size = {
407
+ x: max.x - min.x,
408
+ y: max.y - min.y,
409
+ z: max.z - min.z,
410
+ };
411
+ const maxSize = Math.max(size.x, size.y, size.z);
412
+ const distance = maxSize * 2.0;
413
+ const endTarget = center;
414
+ // Southeast isometric view for Y-up (same as fitToBounds)
415
+ const endPos = {
416
+ x: center.x + distance * 0.6,
417
+ y: center.y + distance * 0.5,
418
+ z: center.z + distance * 0.6,
419
+ };
420
+ return this.animateTo(endPos, endTarget, duration);
421
+ }
422
+ /**
423
+ * Zoom to fit bounds WITHOUT changing view direction
424
+ * Just centers on bounds and adjusts distance to fit
425
+ */
426
+ /**
427
+ * Frame/center view on a point (keeps current distance and direction)
428
+ * Standard CAD "Frame Selection" behavior
429
+ */
430
+ async framePoint(point, duration = 300) {
431
+ // Keep current viewing direction and distance
432
+ const dir = {
433
+ x: this.camera.position.x - this.camera.target.x,
434
+ y: this.camera.position.y - this.camera.target.y,
435
+ z: this.camera.position.z - this.camera.target.z,
436
+ };
437
+ // New position: point + current offset
438
+ const endPos = {
439
+ x: point.x + dir.x,
440
+ y: point.y + dir.y,
441
+ z: point.z + dir.z,
442
+ };
443
+ return this.animateTo(endPos, point, duration);
444
+ }
445
+ /**
446
+ * Frame selection - zoom to fit bounds while keeping current view direction
447
+ * This is what "Frame Selection" should do - zoom to fill screen
448
+ */
449
+ async frameBounds(min, max, duration = 300) {
450
+ const center = {
451
+ x: (min.x + max.x) / 2,
452
+ y: (min.y + max.y) / 2,
453
+ z: (min.z + max.z) / 2,
454
+ };
455
+ const size = {
456
+ x: max.x - min.x,
457
+ y: max.y - min.y,
458
+ z: max.z - min.z,
459
+ };
460
+ const maxSize = Math.max(size.x, size.y, size.z);
461
+ if (maxSize < 1e-6) {
462
+ // Very small or zero size - just center on it
463
+ return this.framePoint(center, duration);
464
+ }
465
+ // Calculate required distance based on FOV to fit bounds
466
+ const fovFactor = Math.tan(this.camera.fov / 2);
467
+ const distance = (maxSize / 2) / fovFactor * 1.2; // 1.2x padding for nice framing
468
+ // Get current viewing direction from view matrix (more reliable than position-target)
469
+ // View matrix forward is -Z axis in view space
470
+ const viewMatrix = this.viewMatrix.m;
471
+ // Extract forward direction from view matrix (negative Z column, normalized)
472
+ let dir = {
473
+ x: -viewMatrix[8], // -m[2][0] (forward X)
474
+ y: -viewMatrix[9], // -m[2][1] (forward Y)
475
+ z: -viewMatrix[10], // -m[2][2] (forward Z)
476
+ };
477
+ const dirLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
478
+ // Normalize direction
479
+ if (dirLen > 1e-6) {
480
+ dir.x /= dirLen;
481
+ dir.y /= dirLen;
482
+ dir.z /= dirLen;
483
+ }
484
+ else {
485
+ // Fallback: use position-target if view matrix is invalid
486
+ dir = {
487
+ x: this.camera.position.x - this.camera.target.x,
488
+ y: this.camera.position.y - this.camera.target.y,
489
+ z: this.camera.position.z - this.camera.target.z,
490
+ };
491
+ const fallbackLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
492
+ if (fallbackLen > 1e-6) {
493
+ dir.x /= fallbackLen;
494
+ dir.y /= fallbackLen;
495
+ dir.z /= fallbackLen;
496
+ }
497
+ else {
498
+ // Last resort: southeast isometric
499
+ dir.x = 0.6;
500
+ dir.y = 0.5;
501
+ dir.z = 0.6;
502
+ const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
503
+ dir.x /= len;
504
+ dir.y /= len;
505
+ dir.z /= len;
506
+ }
507
+ }
508
+ // New position: center + direction * distance
509
+ const endPos = {
510
+ x: center.x + dir.x * distance,
511
+ y: center.y + dir.y * distance,
512
+ z: center.z + dir.z * distance,
513
+ };
514
+ return this.animateTo(endPos, center, duration);
515
+ }
516
+ async zoomExtent(min, max, duration = 300) {
517
+ const center = {
518
+ x: (min.x + max.x) / 2,
519
+ y: (min.y + max.y) / 2,
520
+ z: (min.z + max.z) / 2,
521
+ };
522
+ const size = {
523
+ x: max.x - min.x,
524
+ y: max.y - min.y,
525
+ z: max.z - min.z,
526
+ };
527
+ const maxSize = Math.max(size.x, size.y, size.z);
528
+ // Calculate required distance based on FOV
529
+ const fovFactor = Math.tan(this.camera.fov / 2);
530
+ const distance = (maxSize / 2) / fovFactor * 1.5; // 1.5x for padding
531
+ // Keep current viewing direction
532
+ const dir = {
533
+ x: this.camera.position.x - this.camera.target.x,
534
+ y: this.camera.position.y - this.camera.target.y,
535
+ z: this.camera.position.z - this.camera.target.z,
536
+ };
537
+ const currentDistance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
538
+ // Normalize direction
539
+ if (currentDistance > 1e-10) {
540
+ dir.x /= currentDistance;
541
+ dir.y /= currentDistance;
542
+ dir.z /= currentDistance;
543
+ }
544
+ else {
545
+ // Fallback direction
546
+ dir.x = 0.6;
547
+ dir.y = 0.5;
548
+ dir.z = 0.6;
549
+ const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
550
+ dir.x /= len;
551
+ dir.y /= len;
552
+ dir.z /= len;
553
+ }
554
+ // New position: center + direction * distance
555
+ const endPos = {
556
+ x: center.x + dir.x * distance,
557
+ y: center.y + dir.y * distance,
558
+ z: center.z + dir.z * distance,
559
+ };
560
+ return this.animateTo(endPos, center, duration);
561
+ }
562
+ /**
563
+ * Animate camera to position and target
564
+ */
565
+ async animateTo(endPos, endTarget, duration = 500) {
566
+ this.animationStartPos = { ...this.camera.position };
567
+ this.animationStartTarget = { ...this.camera.target };
568
+ this.animationEndPos = endPos;
569
+ this.animationEndTarget = endTarget;
570
+ this.animationStartUp = null;
571
+ this.animationEndUp = null;
572
+ this.animationDuration = duration;
573
+ this.animationStartTime = Date.now();
574
+ this.animationEasing = this.easeOutCubic;
575
+ // Wait for animation to complete
576
+ return new Promise((resolve) => {
577
+ const checkAnimation = () => {
578
+ if (this.animationStartTime === 0) {
579
+ resolve();
580
+ }
581
+ else {
582
+ requestAnimationFrame(checkAnimation);
583
+ }
584
+ };
585
+ checkAnimation();
586
+ });
587
+ }
588
+ /**
589
+ * Animate camera to position, target, and up vector (for orthogonal preset views)
590
+ */
591
+ async animateToWithUp(endPos, endTarget, endUp, duration = 500) {
592
+ // Clear all velocities to prevent inertia from interfering with animation
593
+ this.velocity.orbit.x = 0;
594
+ this.velocity.orbit.y = 0;
595
+ this.velocity.pan.x = 0;
596
+ this.velocity.pan.y = 0;
597
+ this.velocity.zoom = 0;
598
+ this.animationStartPos = { ...this.camera.position };
599
+ this.animationStartTarget = { ...this.camera.target };
600
+ this.animationStartUp = { ...this.camera.up };
601
+ this.animationEndPos = endPos;
602
+ this.animationEndTarget = endTarget;
603
+ this.animationEndUp = endUp;
604
+ this.animationDuration = duration;
605
+ this.animationStartTime = Date.now();
606
+ this.animationEasing = this.easeOutCubic;
607
+ // Wait for animation to complete
608
+ return new Promise((resolve) => {
609
+ const checkAnimation = () => {
610
+ if (this.animationStartTime === 0) {
611
+ resolve();
612
+ }
613
+ else {
614
+ requestAnimationFrame(checkAnimation);
615
+ }
616
+ };
617
+ checkAnimation();
618
+ });
619
+ }
620
+ /**
621
+ * Easing function: easeOutCubic
622
+ */
623
+ easeOutCubic(t) {
624
+ return 1 - Math.pow(1 - t, 3);
625
+ }
626
+ /**
627
+ * Set first-person mode
628
+ */
629
+ enableFirstPersonMode(enabled) {
630
+ this.isFirstPersonMode = enabled;
631
+ }
632
+ /**
633
+ * Move in first-person mode (Y-up coordinate system)
634
+ */
635
+ moveFirstPerson(forward, right, up) {
636
+ if (!this.isFirstPersonMode)
637
+ return;
638
+ const dir = {
639
+ x: this.camera.target.x - this.camera.position.x,
640
+ y: this.camera.target.y - this.camera.position.y,
641
+ z: this.camera.target.z - this.camera.position.z,
642
+ };
643
+ const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
644
+ if (len > 1e-10) {
645
+ dir.x /= len;
646
+ dir.y /= len;
647
+ dir.z /= len;
648
+ }
649
+ // Right vector: cross product of direction and up (0,1,0)
650
+ const rightVec = {
651
+ x: -dir.z,
652
+ y: 0,
653
+ z: dir.x,
654
+ };
655
+ const rightLen = Math.sqrt(rightVec.x * rightVec.x + rightVec.z * rightVec.z);
656
+ if (rightLen > 1e-10) {
657
+ rightVec.x /= rightLen;
658
+ rightVec.z /= rightLen;
659
+ }
660
+ // Up vector: cross product of right and direction
661
+ const upVec = {
662
+ x: (rightVec.z * dir.y - rightVec.y * dir.z),
663
+ y: (rightVec.x * dir.z - rightVec.z * dir.x),
664
+ z: (rightVec.y * dir.x - rightVec.x * dir.y),
665
+ };
666
+ const speed = this.firstPersonSpeed;
667
+ this.camera.position.x += (dir.x * forward + rightVec.x * right + upVec.x * up) * speed;
668
+ this.camera.position.y += (dir.y * forward + rightVec.y * right + upVec.y * up) * speed;
669
+ this.camera.position.z += (dir.z * forward + rightVec.z * right + upVec.z * up) * speed;
670
+ this.camera.target.x += (dir.x * forward + rightVec.x * right + upVec.x * up) * speed;
671
+ this.camera.target.y += (dir.y * forward + rightVec.y * right + upVec.y * up) * speed;
672
+ this.camera.target.z += (dir.z * forward + rightVec.z * right + upVec.z * up) * speed;
673
+ this.updateMatrices();
674
+ }
675
+ /**
676
+ * Set preset view with explicit bounds (Y-up coordinate system)
677
+ * Clicking the same view again rotates 90° around the view axis
678
+ */
679
+ setPresetView(view, bounds) {
680
+ const useBounds = bounds || this.getCurrentBounds();
681
+ if (!useBounds) {
682
+ console.warn('[Camera] No bounds available for setPresetView');
683
+ return;
684
+ }
685
+ // Check if clicking the same view again - cycle rotation
686
+ if (this.lastPresetView === view) {
687
+ this.presetViewRotation = (this.presetViewRotation + 1) % 4;
688
+ }
689
+ else {
690
+ this.lastPresetView = view;
691
+ this.presetViewRotation = 0;
692
+ }
693
+ const center = {
694
+ x: (useBounds.min.x + useBounds.max.x) / 2,
695
+ y: (useBounds.min.y + useBounds.max.y) / 2,
696
+ z: (useBounds.min.z + useBounds.max.z) / 2,
697
+ };
698
+ const size = {
699
+ x: useBounds.max.x - useBounds.min.x,
700
+ y: useBounds.max.y - useBounds.min.y,
701
+ z: useBounds.max.z - useBounds.min.z,
702
+ };
703
+ const maxSize = Math.max(size.x, size.y, size.z);
704
+ // Calculate distance based on FOV for proper fit
705
+ const fovFactor = Math.tan(this.camera.fov / 2);
706
+ const distance = (maxSize / 2) / fovFactor * 1.5; // 1.5x for padding
707
+ let endPos;
708
+ const endTarget = center;
709
+ // WebGL uses Y-up coordinate system internally
710
+ // We set both position AND up vector for proper orthogonal views
711
+ let upVector = { x: 0, y: 1, z: 0 }; // Default Y-up
712
+ // Up vector rotation options for top/bottom views (rotate around Y axis)
713
+ // 0: -Z, 1: -X, 2: +Z, 3: +X
714
+ const topUpVectors = [
715
+ { x: 0, y: 0, z: -1 }, // 0° - North up
716
+ { x: -1, y: 0, z: 0 }, // 90° - West up
717
+ { x: 0, y: 0, z: 1 }, // 180° - South up
718
+ { x: 1, y: 0, z: 0 }, // 270° - East up
719
+ ];
720
+ const bottomUpVectors = [
721
+ { x: 0, y: 0, z: 1 }, // 0° - South up
722
+ { x: 1, y: 0, z: 0 }, // 90° - East up
723
+ { x: 0, y: 0, z: -1 }, // 180° - North up
724
+ { x: -1, y: 0, z: 0 }, // 270° - West up
725
+ ];
726
+ switch (view) {
727
+ case 'top':
728
+ // Top view: looking straight down from above (+Y)
729
+ endPos = { x: center.x, y: center.y + distance, z: center.z };
730
+ upVector = topUpVectors[this.presetViewRotation];
731
+ break;
732
+ case 'bottom':
733
+ // Bottom view: looking straight up from below (-Y)
734
+ endPos = { x: center.x, y: center.y - distance, z: center.z };
735
+ upVector = bottomUpVectors[this.presetViewRotation];
736
+ break;
737
+ case 'front':
738
+ // Front view: from +Z looking at model
739
+ endPos = { x: center.x, y: center.y, z: center.z + distance };
740
+ upVector = { x: 0, y: 1, z: 0 }; // Y-up
741
+ break;
742
+ case 'back':
743
+ // Back view: from -Z looking at model
744
+ endPos = { x: center.x, y: center.y, z: center.z - distance };
745
+ upVector = { x: 0, y: 1, z: 0 }; // Y-up
746
+ break;
747
+ case 'left':
748
+ // Left view: from -X looking at model
749
+ endPos = { x: center.x - distance, y: center.y, z: center.z };
750
+ upVector = { x: 0, y: 1, z: 0 }; // Y-up
751
+ break;
752
+ case 'right':
753
+ // Right view: from +X looking at model
754
+ endPos = { x: center.x + distance, y: center.y, z: center.z };
755
+ upVector = { x: 0, y: 1, z: 0 }; // Y-up
756
+ break;
757
+ }
758
+ this.animateToWithUp(endPos, endTarget, upVector, 300);
759
+ }
760
+ /**
761
+ * Get current bounds estimate (simplified - in production would use scene bounds)
762
+ */
763
+ getCurrentBounds() {
764
+ // Estimate bounds from camera distance
765
+ const dir = {
766
+ x: this.camera.position.x - this.camera.target.x,
767
+ y: this.camera.position.y - this.camera.target.y,
768
+ z: this.camera.position.z - this.camera.target.z,
769
+ };
770
+ const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
771
+ const size = distance / 2;
772
+ return {
773
+ min: {
774
+ x: this.camera.target.x - size,
775
+ y: this.camera.target.y - size,
776
+ z: this.camera.target.z - size,
777
+ },
778
+ max: {
779
+ x: this.camera.target.x + size,
780
+ y: this.camera.target.y + size,
781
+ z: this.camera.target.z + size,
782
+ },
783
+ };
784
+ }
785
+ /**
786
+ * Reset velocity (stop inertia)
787
+ */
788
+ stopInertia() {
789
+ this.velocity.orbit.x = 0;
790
+ this.velocity.orbit.y = 0;
791
+ this.velocity.pan.x = 0;
792
+ this.velocity.pan.y = 0;
793
+ this.velocity.zoom = 0;
794
+ }
795
+ /**
796
+ * Reset camera state (clear orbit pivot, stop inertia, cancel animations)
797
+ * Called when loading a new model to ensure clean state
798
+ */
799
+ reset() {
800
+ this.orbitPivot = null;
801
+ this.stopInertia();
802
+ // Cancel any ongoing animations
803
+ this.animationStartTime = 0;
804
+ this.animationDuration = 0;
805
+ this.animationStartPos = null;
806
+ this.animationStartTarget = null;
807
+ this.animationEndPos = null;
808
+ this.animationEndTarget = null;
809
+ this.animationStartUp = null;
810
+ this.animationEndUp = null;
811
+ this.animationEasing = null;
812
+ // Reset preset view tracking
813
+ this.lastPresetView = null;
814
+ this.presetViewRotation = 0;
815
+ }
816
+ getViewProjMatrix() {
817
+ return this.viewProjMatrix;
818
+ }
819
+ getPosition() {
820
+ return { ...this.camera.position };
821
+ }
822
+ getTarget() {
823
+ return { ...this.camera.target };
824
+ }
825
+ /**
826
+ * Get camera FOV in radians
827
+ */
828
+ getFOV() {
829
+ return this.camera.fov;
830
+ }
831
+ /**
832
+ * Get distance from camera position to target
833
+ */
834
+ getDistance() {
835
+ const dir = {
836
+ x: this.camera.position.x - this.camera.target.x,
837
+ y: this.camera.position.y - this.camera.target.y,
838
+ z: this.camera.position.z - this.camera.target.z,
839
+ };
840
+ return Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
841
+ }
842
+ /**
843
+ * Get current camera rotation angles in degrees
844
+ * Returns { azimuth, elevation } where:
845
+ * - azimuth: horizontal rotation (0-360), 0 = front
846
+ * - elevation: vertical rotation (-90 to 90), 0 = horizon
847
+ */
848
+ getRotation() {
849
+ const dir = {
850
+ x: this.camera.position.x - this.camera.target.x,
851
+ y: this.camera.position.y - this.camera.target.y,
852
+ z: this.camera.position.z - this.camera.target.z,
853
+ };
854
+ const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
855
+ if (distance < 1e-6)
856
+ return { azimuth: 0, elevation: 0 };
857
+ // Elevation: angle from horizontal plane
858
+ const elevation = Math.asin(Math.max(-1, Math.min(1, dir.y / distance))) * 180 / Math.PI;
859
+ // Calculate azimuth smoothly using up vector
860
+ // The up vector defines the "screen up" direction, which determines rotation
861
+ const upX = this.camera.up.x;
862
+ const upY = this.camera.up.y;
863
+ const upZ = this.camera.up.z;
864
+ // Project up vector onto horizontal plane (XZ plane)
865
+ const upLen = Math.sqrt(upX * upX + upZ * upZ);
866
+ let azimuth;
867
+ if (upLen > 0.01) {
868
+ // Use up vector projection for azimuth (smooth and consistent)
869
+ azimuth = (Math.atan2(-upX, -upZ) * 180 / Math.PI + 360) % 360;
870
+ // For bottom view, flip azimuth
871
+ if (elevation < -80 && upY < 0) {
872
+ azimuth = (azimuth + 180) % 360;
873
+ }
874
+ }
875
+ else {
876
+ // Fallback: use position-based azimuth when up vector is vertical
877
+ azimuth = (Math.atan2(dir.x, dir.z) * 180 / Math.PI + 360) % 360;
878
+ }
879
+ return { azimuth, elevation };
880
+ }
881
+ /**
882
+ * Project a world position to screen coordinates
883
+ * @param worldPos - Position in world space
884
+ * @param canvasWidth - Canvas width in pixels
885
+ * @param canvasHeight - Canvas height in pixels
886
+ * @returns Screen coordinates { x, y } or null if behind camera
887
+ */
888
+ projectToScreen(worldPos, canvasWidth, canvasHeight) {
889
+ // Transform world position by view-projection matrix
890
+ const m = this.viewProjMatrix.m;
891
+ // Manual matrix-vector multiplication for vec4(worldPos, 1.0)
892
+ const clipX = m[0] * worldPos.x + m[4] * worldPos.y + m[8] * worldPos.z + m[12];
893
+ const clipY = m[1] * worldPos.x + m[5] * worldPos.y + m[9] * worldPos.z + m[13];
894
+ const clipZ = m[2] * worldPos.x + m[6] * worldPos.y + m[10] * worldPos.z + m[14];
895
+ const clipW = m[3] * worldPos.x + m[7] * worldPos.y + m[11] * worldPos.z + m[15];
896
+ // Check if behind camera
897
+ if (clipW <= 0) {
898
+ return null;
899
+ }
900
+ // Perspective divide to get NDC
901
+ const ndcX = clipX / clipW;
902
+ const ndcY = clipY / clipW;
903
+ const ndcZ = clipZ / clipW;
904
+ // Check if outside clip volume
905
+ if (ndcZ < -1 || ndcZ > 1) {
906
+ return null;
907
+ }
908
+ // Convert NDC to screen coordinates
909
+ // NDC: (-1,-1) = bottom-left, (1,1) = top-right
910
+ // Screen: (0,0) = top-left, (width, height) = bottom-right
911
+ const screenX = (ndcX + 1) * 0.5 * canvasWidth;
912
+ const screenY = (1 - ndcY) * 0.5 * canvasHeight; // Flip Y
913
+ return { x: screenX, y: screenY };
914
+ }
915
+ updateMatrices() {
916
+ this.viewMatrix = MathUtils.lookAt(this.camera.position, this.camera.target, this.camera.up);
917
+ this.projMatrix = MathUtils.perspective(this.camera.fov, this.camera.aspect, this.camera.near, this.camera.far);
918
+ this.viewProjMatrix = MathUtils.multiply(this.projMatrix, this.viewMatrix);
919
+ }
920
+ }
921
+ //# sourceMappingURL=camera.js.map