@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/LICENSE +373 -0
- package/dist/camera.d.ts +178 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +921 -0
- package/dist/camera.js.map +1 -0
- package/dist/device.d.ts +46 -0
- package/dist/device.d.ts.map +1 -0
- package/dist/device.js +135 -0
- package/dist/device.js.map +1 -0
- package/dist/index.d.ts +63 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +397 -0
- package/dist/index.js.map +1 -0
- package/dist/math.d.ts +23 -0
- package/dist/math.d.ts.map +1 -0
- package/dist/math.js +102 -0
- package/dist/math.js.map +1 -0
- package/dist/picker.d.ts +22 -0
- package/dist/picker.d.ts.map +1 -0
- package/dist/picker.js +215 -0
- package/dist/picker.js.map +1 -0
- package/dist/pipeline.d.ts +40 -0
- package/dist/pipeline.d.ts.map +1 -0
- package/dist/pipeline.js +266 -0
- package/dist/pipeline.js.map +1 -0
- package/dist/post-processor.d.ts +32 -0
- package/dist/post-processor.d.ts.map +1 -0
- package/dist/post-processor.js +42 -0
- package/dist/post-processor.js.map +1 -0
- package/dist/scene.d.ts +35 -0
- package/dist/scene.d.ts.map +1 -0
- package/dist/scene.js +46 -0
- package/dist/scene.js.map +1 -0
- package/dist/section-plane.d.ts +34 -0
- package/dist/section-plane.d.ts.map +1 -0
- package/dist/section-plane.js +232 -0
- package/dist/section-plane.js.map +1 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/package.json +47 -0
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
|