@ifc-lite/renderer 1.6.0 → 1.7.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.
Files changed (42) hide show
  1. package/README.md +40 -0
  2. package/dist/camera-animation.d.ts +108 -0
  3. package/dist/camera-animation.d.ts.map +1 -0
  4. package/dist/camera-animation.js +606 -0
  5. package/dist/camera-animation.js.map +1 -0
  6. package/dist/camera-controls.d.ts +75 -0
  7. package/dist/camera-controls.d.ts.map +1 -0
  8. package/dist/camera-controls.js +239 -0
  9. package/dist/camera-controls.js.map +1 -0
  10. package/dist/camera-projection.d.ts +51 -0
  11. package/dist/camera-projection.d.ts.map +1 -0
  12. package/dist/camera-projection.js +147 -0
  13. package/dist/camera-projection.js.map +1 -0
  14. package/dist/camera.d.ts +33 -45
  15. package/dist/camera.d.ts.map +1 -1
  16. package/dist/camera.js +128 -815
  17. package/dist/camera.js.map +1 -1
  18. package/dist/geometry-manager.d.ts +99 -0
  19. package/dist/geometry-manager.d.ts.map +1 -0
  20. package/dist/geometry-manager.js +387 -0
  21. package/dist/geometry-manager.js.map +1 -0
  22. package/dist/index.d.ts +7 -19
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +50 -658
  25. package/dist/index.js.map +1 -1
  26. package/dist/math.d.ts +6 -0
  27. package/dist/math.d.ts.map +1 -1
  28. package/dist/math.js +20 -0
  29. package/dist/math.js.map +1 -1
  30. package/dist/picking-manager.d.ts +31 -0
  31. package/dist/picking-manager.d.ts.map +1 -0
  32. package/dist/picking-manager.js +140 -0
  33. package/dist/picking-manager.js.map +1 -0
  34. package/dist/raycast-engine.d.ts +76 -0
  35. package/dist/raycast-engine.d.ts.map +1 -0
  36. package/dist/raycast-engine.js +255 -0
  37. package/dist/raycast-engine.js.map +1 -0
  38. package/dist/scene.d.ts +8 -1
  39. package/dist/scene.d.ts.map +1 -1
  40. package/dist/scene.js +59 -25
  41. package/dist/scene.js.map +1 -1
  42. package/package.json +4 -4
package/dist/camera.js CHANGED
@@ -2,68 +2,57 @@
2
2
  * License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
  import { MathUtils } from './math.js';
5
+ import { CameraControls } from './camera-controls.js';
6
+ import { CameraAnimator } from './camera-animation.js';
7
+ import { CameraProjection } from './camera-projection.js';
5
8
  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°
9
+ state;
10
+ controls;
11
+ animator;
12
+ projection;
32
13
  constructor() {
33
14
  // 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: 100000, // Increased default far plane for large models
15
+ this.state = {
16
+ camera: {
17
+ position: { x: 50, y: 50, z: 100 },
18
+ target: { x: 0, y: 0, z: 0 },
19
+ up: { x: 0, y: 1, z: 0 }, // Y-up (standard WebGL)
20
+ fov: Math.PI / 4,
21
+ aspect: 1,
22
+ near: 0.1,
23
+ far: 100000, // Increased default far plane for large models
24
+ },
25
+ viewMatrix: MathUtils.identity(),
26
+ projMatrix: MathUtils.identity(),
27
+ viewProjMatrix: MathUtils.identity(),
28
+ projectionMode: 'perspective',
29
+ orthoSize: 50, // Default half-height in world units
42
30
  };
43
- this.viewMatrix = MathUtils.identity();
44
- this.projMatrix = MathUtils.identity();
45
- this.viewProjMatrix = MathUtils.identity();
31
+ const updateMatrices = () => this.updateMatrices();
32
+ this.controls = new CameraControls(this.state, updateMatrices);
33
+ this.projection = new CameraProjection(this.state, updateMatrices);
34
+ this.animator = new CameraAnimator(this.state, updateMatrices, this.controls, this.projection);
46
35
  this.updateMatrices();
47
36
  }
48
37
  /**
49
38
  * Set camera aspect ratio
50
39
  */
51
40
  setAspect(aspect) {
52
- this.camera.aspect = aspect;
41
+ this.state.camera.aspect = aspect;
53
42
  this.updateMatrices();
54
43
  }
55
44
  /**
56
45
  * Set camera position
57
46
  */
58
47
  setPosition(x, y, z) {
59
- this.camera.position = { x, y, z };
48
+ this.state.camera.position = { x, y, z };
60
49
  this.updateMatrices();
61
50
  }
62
51
  /**
63
52
  * Set camera target
64
53
  */
65
54
  setTarget(x, y, z) {
66
- this.camera.target = { x, y, z };
55
+ this.state.camera.target = { x, y, z };
67
56
  this.updateMatrices();
68
57
  }
69
58
  /**
@@ -71,130 +60,41 @@ export class Camera {
71
60
  * When set, orbit() will rotate around this point instead of the camera target
72
61
  */
73
62
  setOrbitPivot(pivot) {
74
- this.orbitPivot = pivot ? { ...pivot } : null;
63
+ this.controls.setOrbitPivot(pivot);
75
64
  }
76
65
  /**
77
66
  * Get current orbit pivot (returns temporary pivot if set, otherwise target)
78
67
  */
79
68
  getOrbitPivot() {
80
- return this.orbitPivot ? { ...this.orbitPivot } : { ...this.camera.target };
69
+ return this.controls.getOrbitPivot();
81
70
  }
82
71
  /**
83
72
  * Check if a temporary orbit pivot is set
84
73
  */
85
74
  hasOrbitPivot() {
86
- return this.orbitPivot !== null;
75
+ return this.controls.hasOrbitPivot();
87
76
  }
88
77
  /**
89
78
  * Orbit around target or pivot (Y-up coordinate system)
90
79
  * If an orbit pivot is set, orbits around that point and moves target along
91
80
  */
92
81
  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;
82
+ this.animator.resetPresetTracking();
83
+ this.controls.orbit(deltaX, deltaY);
147
84
  if (addVelocity) {
148
- this.velocity.orbit.x += deltaX * 0.001;
149
- this.velocity.orbit.y += deltaY * 0.001;
85
+ this.animator.addOrbitVelocity(deltaX, deltaY);
150
86
  }
151
- this.updateMatrices();
152
87
  }
153
88
  /**
154
89
  * Pan camera (Y-up coordinate system)
155
90
  */
156
91
  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;
92
+ // Pan speed depends on distance; compute before pan (pan preserves distance)
93
+ const panSpeed = this.getDistance() * 0.001;
94
+ this.controls.pan(deltaX, deltaY);
193
95
  if (addVelocity) {
194
- this.velocity.pan.x += deltaX * panSpeed * 0.1;
195
- this.velocity.pan.y += deltaY * panSpeed * 0.1;
96
+ this.animator.addPanVelocity(deltaX, deltaY, panSpeed);
196
97
  }
197
- this.updateMatrices();
198
98
  }
199
99
  /**
200
100
  * Zoom camera towards mouse position
@@ -206,74 +106,11 @@ export class Camera {
206
106
  * @param canvasHeight - Canvas height
207
107
  */
208
108
  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;
109
+ this.controls.zoom(delta, mouseX, mouseY, canvasWidth, canvasHeight);
273
110
  if (addVelocity) {
274
- this.velocity.zoom += normalizedDelta * 0.1;
111
+ const normalizedDelta = Math.sign(delta) * Math.min(Math.abs(delta) * 0.001, 0.1);
112
+ this.animator.addZoomVelocity(normalizedDelta);
275
113
  }
276
- this.updateMatrices();
277
114
  }
278
115
  /**
279
116
  * Fit view to bounding box
@@ -281,619 +118,114 @@ export class Camera {
281
118
  * Y-up coordinate system: Y is vertical
282
119
  */
283
120
  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
- // Dynamic near/far plane calculation to prevent Z-fighting
305
- // Keep near/far ratio under 10000:1 for better depth precision
306
- const optimalNear = Math.max(0.01, distance * 0.001); // 0.1% of distance
307
- const optimalFar = distance * 10; // 10x distance for safety margin
308
- // Ensure ratio is reasonable (max 10000:1)
309
- const maxRatio = 10000;
310
- if (optimalFar / optimalNear > maxRatio) {
311
- // Adjust far plane to maintain ratio
312
- this.camera.far = optimalNear * maxRatio;
313
- }
314
- else {
315
- this.camera.far = optimalFar;
316
- }
317
- this.camera.near = optimalNear;
318
- this.updateMatrices();
121
+ this.projection.fitToBounds(min, max);
319
122
  }
320
123
  /**
321
124
  * Update camera animation and inertia
322
125
  * Returns true if camera is still animating
323
126
  */
324
- update(_deltaTime) {
325
- // deltaTime reserved for future physics-based animation smoothing
326
- void _deltaTime;
327
- let isAnimating = false;
328
- // Handle animation
329
- if (this.animationStartTime > 0 && this.animationDuration > 0) {
330
- const elapsed = Date.now() - this.animationStartTime;
331
- const progress = Math.min(elapsed / this.animationDuration, 1);
332
- if (progress < 1 && this.animationStartPos && this.animationEndPos &&
333
- this.animationStartTarget && this.animationEndTarget && this.animationEasing) {
334
- const t = this.animationEasing(progress);
335
- this.camera.position.x = this.animationStartPos.x + (this.animationEndPos.x - this.animationStartPos.x) * t;
336
- this.camera.position.y = this.animationStartPos.y + (this.animationEndPos.y - this.animationStartPos.y) * t;
337
- this.camera.position.z = this.animationStartPos.z + (this.animationEndPos.z - this.animationStartPos.z) * t;
338
- this.camera.target.x = this.animationStartTarget.x + (this.animationEndTarget.x - this.animationStartTarget.x) * t;
339
- this.camera.target.y = this.animationStartTarget.y + (this.animationEndTarget.y - this.animationStartTarget.y) * t;
340
- this.camera.target.z = this.animationStartTarget.z + (this.animationEndTarget.z - this.animationStartTarget.z) * t;
341
- // Interpolate up vector if animating with up
342
- if (this.animationStartUp && this.animationEndUp) {
343
- // SLERP-like interpolation for up vector (normalized lerp)
344
- let upX = this.animationStartUp.x + (this.animationEndUp.x - this.animationStartUp.x) * t;
345
- let upY = this.animationStartUp.y + (this.animationEndUp.y - this.animationStartUp.y) * t;
346
- let upZ = this.animationStartUp.z + (this.animationEndUp.z - this.animationStartUp.z) * t;
347
- // Normalize
348
- const len = Math.sqrt(upX * upX + upY * upY + upZ * upZ);
349
- if (len > 0.0001) {
350
- this.camera.up.x = upX / len;
351
- this.camera.up.y = upY / len;
352
- this.camera.up.z = upZ / len;
353
- }
354
- }
355
- this.updateMatrices();
356
- isAnimating = true;
357
- }
358
- else {
359
- // Animation complete - set final values
360
- if (this.animationEndPos) {
361
- this.camera.position.x = this.animationEndPos.x;
362
- this.camera.position.y = this.animationEndPos.y;
363
- this.camera.position.z = this.animationEndPos.z;
364
- }
365
- if (this.animationEndTarget) {
366
- this.camera.target.x = this.animationEndTarget.x;
367
- this.camera.target.y = this.animationEndTarget.y;
368
- this.camera.target.z = this.animationEndTarget.z;
369
- }
370
- if (this.animationEndUp) {
371
- this.camera.up.x = this.animationEndUp.x;
372
- this.camera.up.y = this.animationEndUp.y;
373
- this.camera.up.z = this.animationEndUp.z;
374
- }
375
- this.updateMatrices();
376
- this.animationStartTime = 0;
377
- this.animationDuration = 0;
378
- this.animationStartPos = null;
379
- this.animationEndPos = null;
380
- this.animationStartTarget = null;
381
- this.animationEndTarget = null;
382
- this.animationStartUp = null;
383
- this.animationEndUp = null;
384
- this.animationEasing = null;
385
- }
386
- }
387
- // Apply inertia
388
- if (Math.abs(this.velocity.orbit.x) > this.minVelocity || Math.abs(this.velocity.orbit.y) > this.minVelocity) {
389
- this.orbit(this.velocity.orbit.x * 100, this.velocity.orbit.y * 100, false);
390
- this.velocity.orbit.x *= this.damping;
391
- this.velocity.orbit.y *= this.damping;
392
- isAnimating = true;
393
- }
394
- if (Math.abs(this.velocity.pan.x) > this.minVelocity || Math.abs(this.velocity.pan.y) > this.minVelocity) {
395
- this.pan(this.velocity.pan.x * 1000, this.velocity.pan.y * 1000, false);
396
- this.velocity.pan.x *= this.damping;
397
- this.velocity.pan.y *= this.damping;
398
- isAnimating = true;
399
- }
400
- if (Math.abs(this.velocity.zoom) > this.minVelocity) {
401
- this.zoom(this.velocity.zoom * 1000, false);
402
- this.velocity.zoom *= this.damping;
403
- isAnimating = true;
404
- }
405
- return isAnimating;
127
+ update(deltaTime) {
128
+ return this.animator.update(deltaTime);
406
129
  }
407
130
  /**
408
131
  * Animate camera to fit bounds (southeast isometric view)
409
132
  * Y-up coordinate system
410
133
  */
411
134
  async zoomToFit(min, max, duration = 500) {
412
- const center = {
413
- x: (min.x + max.x) / 2,
414
- y: (min.y + max.y) / 2,
415
- z: (min.z + max.z) / 2,
416
- };
417
- const size = {
418
- x: max.x - min.x,
419
- y: max.y - min.y,
420
- z: max.z - min.z,
421
- };
422
- const maxSize = Math.max(size.x, size.y, size.z);
423
- const distance = maxSize * 2.0;
424
- const endTarget = center;
425
- // Southeast isometric view for Y-up (same as fitToBounds)
426
- const endPos = {
427
- x: center.x + distance * 0.6,
428
- y: center.y + distance * 0.5,
429
- z: center.z + distance * 0.6,
430
- };
431
- return this.animateTo(endPos, endTarget, duration);
135
+ return this.animator.zoomToFit(min, max, duration);
432
136
  }
433
- /**
434
- * Zoom to fit bounds WITHOUT changing view direction
435
- * Just centers on bounds and adjusts distance to fit
436
- */
437
137
  /**
438
138
  * Frame/center view on a point (keeps current distance and direction)
439
139
  * Standard CAD "Frame Selection" behavior
440
140
  */
441
141
  async framePoint(point, duration = 300) {
442
- // Keep current viewing direction and distance
443
- const dir = {
444
- x: this.camera.position.x - this.camera.target.x,
445
- y: this.camera.position.y - this.camera.target.y,
446
- z: this.camera.position.z - this.camera.target.z,
447
- };
448
- // New position: point + current offset
449
- const endPos = {
450
- x: point.x + dir.x,
451
- y: point.y + dir.y,
452
- z: point.z + dir.z,
453
- };
454
- return this.animateTo(endPos, point, duration);
142
+ return this.animator.framePoint(point, duration);
455
143
  }
456
144
  /**
457
145
  * Frame selection - zoom to fit bounds while keeping current view direction
458
146
  * This is what "Frame Selection" should do - zoom to fill screen
459
147
  */
460
148
  async frameBounds(min, max, duration = 300) {
461
- const center = {
462
- x: (min.x + max.x) / 2,
463
- y: (min.y + max.y) / 2,
464
- z: (min.z + max.z) / 2,
465
- };
466
- const size = {
467
- x: max.x - min.x,
468
- y: max.y - min.y,
469
- z: max.z - min.z,
470
- };
471
- const maxSize = Math.max(size.x, size.y, size.z);
472
- if (maxSize < 1e-6) {
473
- // Very small or zero size - just center on it
474
- return this.framePoint(center, duration);
475
- }
476
- // Calculate required distance based on FOV to fit bounds
477
- const fovFactor = Math.tan(this.camera.fov / 2);
478
- const distance = (maxSize / 2) / fovFactor * 1.2; // 1.2x padding for nice framing
479
- // Get current viewing direction from view matrix (more reliable than position-target)
480
- // View matrix forward is -Z axis in view space
481
- const viewMatrix = this.viewMatrix.m;
482
- // Extract forward direction from view matrix (negative Z column, normalized)
483
- let dir = {
484
- x: -viewMatrix[8], // -m[2][0] (forward X)
485
- y: -viewMatrix[9], // -m[2][1] (forward Y)
486
- z: -viewMatrix[10], // -m[2][2] (forward Z)
487
- };
488
- const dirLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
489
- // Normalize direction
490
- if (dirLen > 1e-6) {
491
- dir.x /= dirLen;
492
- dir.y /= dirLen;
493
- dir.z /= dirLen;
494
- }
495
- else {
496
- // Fallback: use position-target if view matrix is invalid
497
- dir = {
498
- x: this.camera.position.x - this.camera.target.x,
499
- y: this.camera.position.y - this.camera.target.y,
500
- z: this.camera.position.z - this.camera.target.z,
501
- };
502
- const fallbackLen = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
503
- if (fallbackLen > 1e-6) {
504
- dir.x /= fallbackLen;
505
- dir.y /= fallbackLen;
506
- dir.z /= fallbackLen;
507
- }
508
- else {
509
- // Last resort: southeast isometric
510
- dir.x = 0.6;
511
- dir.y = 0.5;
512
- dir.z = 0.6;
513
- const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
514
- dir.x /= len;
515
- dir.y /= len;
516
- dir.z /= len;
517
- }
518
- }
519
- // New position: center + direction * distance
520
- const endPos = {
521
- x: center.x + dir.x * distance,
522
- y: center.y + dir.y * distance,
523
- z: center.z + dir.z * distance,
524
- };
525
- return this.animateTo(endPos, center, duration);
149
+ return this.animator.frameBounds(min, max, duration);
526
150
  }
527
151
  async zoomExtent(min, max, duration = 300) {
528
- const center = {
529
- x: (min.x + max.x) / 2,
530
- y: (min.y + max.y) / 2,
531
- z: (min.z + max.z) / 2,
532
- };
533
- const size = {
534
- x: max.x - min.x,
535
- y: max.y - min.y,
536
- z: max.z - min.z,
537
- };
538
- const maxSize = Math.max(size.x, size.y, size.z);
539
- // Calculate required distance based on FOV
540
- const fovFactor = Math.tan(this.camera.fov / 2);
541
- const distance = (maxSize / 2) / fovFactor * 1.5; // 1.5x for padding
542
- // Update near/far planes dynamically
543
- this.updateNearFarPlanes(distance);
544
- // Keep current viewing direction
545
- const dir = {
546
- x: this.camera.position.x - this.camera.target.x,
547
- y: this.camera.position.y - this.camera.target.y,
548
- z: this.camera.position.z - this.camera.target.z,
549
- };
550
- const currentDistance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
551
- // Normalize direction
552
- if (currentDistance > 1e-10) {
553
- dir.x /= currentDistance;
554
- dir.y /= currentDistance;
555
- dir.z /= currentDistance;
556
- }
557
- else {
558
- // Fallback direction
559
- dir.x = 0.6;
560
- dir.y = 0.5;
561
- dir.z = 0.6;
562
- const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
563
- dir.x /= len;
564
- dir.y /= len;
565
- dir.z /= len;
566
- }
567
- // New position: center + direction * distance
568
- const endPos = {
569
- x: center.x + dir.x * distance,
570
- y: center.y + dir.y * distance,
571
- z: center.z + dir.z * distance,
572
- };
573
- return this.animateTo(endPos, center, duration);
152
+ return this.animator.zoomExtent(min, max, duration);
574
153
  }
575
154
  /**
576
155
  * Animate camera to position and target
577
156
  */
578
157
  async animateTo(endPos, endTarget, duration = 500) {
579
- this.animationStartPos = { ...this.camera.position };
580
- this.animationStartTarget = { ...this.camera.target };
581
- this.animationEndPos = endPos;
582
- this.animationEndTarget = endTarget;
583
- this.animationStartUp = null;
584
- this.animationEndUp = null;
585
- this.animationDuration = duration;
586
- this.animationStartTime = Date.now();
587
- this.animationEasing = this.easeOutCubic;
588
- // Wait for animation to complete
589
- return new Promise((resolve) => {
590
- const checkAnimation = () => {
591
- if (this.animationStartTime === 0) {
592
- resolve();
593
- }
594
- else {
595
- requestAnimationFrame(checkAnimation);
596
- }
597
- };
598
- checkAnimation();
599
- });
158
+ return this.animator.animateTo(endPos, endTarget, duration);
600
159
  }
601
160
  /**
602
161
  * Animate camera to position, target, and up vector (for orthogonal preset views)
603
162
  */
604
163
  async animateToWithUp(endPos, endTarget, endUp, duration = 500) {
605
- // Clear all velocities to prevent inertia from interfering with animation
606
- this.velocity.orbit.x = 0;
607
- this.velocity.orbit.y = 0;
608
- this.velocity.pan.x = 0;
609
- this.velocity.pan.y = 0;
610
- this.velocity.zoom = 0;
611
- this.animationStartPos = { ...this.camera.position };
612
- this.animationStartTarget = { ...this.camera.target };
613
- this.animationStartUp = { ...this.camera.up };
614
- this.animationEndPos = endPos;
615
- this.animationEndTarget = endTarget;
616
- this.animationEndUp = endUp;
617
- this.animationDuration = duration;
618
- this.animationStartTime = Date.now();
619
- this.animationEasing = this.easeOutCubic;
620
- // Wait for animation to complete
621
- return new Promise((resolve) => {
622
- const checkAnimation = () => {
623
- if (this.animationStartTime === 0) {
624
- resolve();
625
- }
626
- else {
627
- requestAnimationFrame(checkAnimation);
628
- }
629
- };
630
- checkAnimation();
631
- });
632
- }
633
- /**
634
- * Easing function: easeOutCubic
635
- */
636
- easeOutCubic(t) {
637
- return 1 - Math.pow(1 - t, 3);
164
+ return this.animator.animateToWithUp(endPos, endTarget, endUp, duration);
638
165
  }
639
166
  /**
640
167
  * Set first-person mode
641
168
  */
642
169
  enableFirstPersonMode(enabled) {
643
- this.isFirstPersonMode = enabled;
170
+ this.animator.enableFirstPersonMode(enabled);
644
171
  }
645
172
  /**
646
173
  * Move in first-person mode (Y-up coordinate system)
647
174
  */
648
175
  moveFirstPerson(forward, right, up) {
649
- if (!this.isFirstPersonMode)
650
- return;
651
- const dir = {
652
- x: this.camera.target.x - this.camera.position.x,
653
- y: this.camera.target.y - this.camera.position.y,
654
- z: this.camera.target.z - this.camera.position.z,
655
- };
656
- const len = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
657
- if (len > 1e-10) {
658
- dir.x /= len;
659
- dir.y /= len;
660
- dir.z /= len;
661
- }
662
- // Right vector: cross product of direction and up (0,1,0)
663
- const rightVec = {
664
- x: -dir.z,
665
- y: 0,
666
- z: dir.x,
667
- };
668
- const rightLen = Math.sqrt(rightVec.x * rightVec.x + rightVec.z * rightVec.z);
669
- if (rightLen > 1e-10) {
670
- rightVec.x /= rightLen;
671
- rightVec.z /= rightLen;
672
- }
673
- // Up vector: cross product of right and direction
674
- const upVec = {
675
- x: (rightVec.z * dir.y - rightVec.y * dir.z),
676
- y: (rightVec.x * dir.z - rightVec.z * dir.x),
677
- z: (rightVec.y * dir.x - rightVec.x * dir.y),
678
- };
679
- const speed = this.firstPersonSpeed;
680
- this.camera.position.x += (dir.x * forward + rightVec.x * right + upVec.x * up) * speed;
681
- this.camera.position.y += (dir.y * forward + rightVec.y * right + upVec.y * up) * speed;
682
- this.camera.position.z += (dir.z * forward + rightVec.z * right + upVec.z * up) * speed;
683
- this.camera.target.x += (dir.x * forward + rightVec.x * right + upVec.x * up) * speed;
684
- this.camera.target.y += (dir.y * forward + rightVec.y * right + upVec.y * up) * speed;
685
- this.camera.target.z += (dir.z * forward + rightVec.z * right + upVec.z * up) * speed;
686
- this.updateMatrices();
176
+ this.animator.moveFirstPerson(forward, right, up);
687
177
  }
688
178
  /**
689
179
  * Set preset view with explicit bounds (Y-up coordinate system)
690
- * Clicking the same view again rotates 90° around the view axis
180
+ * Clicking the same view again rotates 90 degrees around the view axis
691
181
  * @param buildingRotation Optional building rotation in radians (from IfcSite placement)
692
182
  */
693
183
  setPresetView(view, bounds, buildingRotation) {
694
- const useBounds = bounds || this.getCurrentBounds();
695
- if (!useBounds) {
696
- console.warn('[Camera] No bounds available for setPresetView');
697
- return;
698
- }
699
- // Check if clicking the same view again - cycle rotation
700
- if (this.lastPresetView === view) {
701
- this.presetViewRotation = (this.presetViewRotation + 1) % 4;
702
- }
703
- else {
704
- this.lastPresetView = view;
705
- this.presetViewRotation = 0;
706
- }
707
- const center = {
708
- x: (useBounds.min.x + useBounds.max.x) / 2,
709
- y: (useBounds.min.y + useBounds.max.y) / 2,
710
- z: (useBounds.min.z + useBounds.max.z) / 2,
711
- };
712
- const size = {
713
- x: useBounds.max.x - useBounds.min.x,
714
- y: useBounds.max.y - useBounds.min.y,
715
- z: useBounds.max.z - useBounds.min.z,
716
- };
717
- const maxSize = Math.max(size.x, size.y, size.z);
718
- // Calculate distance based on FOV for proper fit
719
- const fovFactor = Math.tan(this.camera.fov / 2);
720
- const distance = (maxSize / 2) / fovFactor * 1.5; // 1.5x for padding
721
- let endPos;
722
- const endTarget = center;
723
- // WebGL uses Y-up coordinate system internally
724
- // We set both position AND up vector for proper orthogonal views
725
- let upVector = { x: 0, y: 1, z: 0 }; // Default Y-up
726
- // Up vector rotation options for top/bottom views (rotate around Y axis)
727
- // 0: -Z, 1: -X, 2: +Z, 3: +X
728
- const topUpVectors = [
729
- { x: 0, y: 0, z: -1 }, // 0° - North up
730
- { x: -1, y: 0, z: 0 }, // 90° - West up
731
- { x: 0, y: 0, z: 1 }, // 180° - South up
732
- { x: 1, y: 0, z: 0 }, // 270° - East up
733
- ];
734
- const bottomUpVectors = [
735
- { x: 0, y: 0, z: 1 }, // 0° - South up
736
- { x: 1, y: 0, z: 0 }, // 90° - East up
737
- { x: 0, y: 0, z: -1 }, // 180° - North up
738
- { x: -1, y: 0, z: 0 }, // 270° - West up
739
- ];
740
- // Apply building rotation if present (rotate around Y axis)
741
- const cosR = buildingRotation !== undefined && buildingRotation !== 0 ? Math.cos(buildingRotation) : 1.0;
742
- const sinR = buildingRotation !== undefined && buildingRotation !== 0 ? Math.sin(buildingRotation) : 0.0;
743
- switch (view) {
744
- case 'top':
745
- // Top view: looking straight down from above (+Y)
746
- // Counter-rotate up vector by NEGATIVE building rotation to align with building axes
747
- const topUp = topUpVectors[this.presetViewRotation];
748
- endPos = { x: center.x, y: center.y + distance, z: center.z };
749
- upVector = {
750
- x: topUp.x * cosR + topUp.z * sinR,
751
- y: topUp.y,
752
- z: -topUp.x * sinR + topUp.z * cosR,
753
- };
754
- break;
755
- case 'bottom':
756
- // Bottom view: looking straight up from below (-Y)
757
- // Counter-rotate up vector by NEGATIVE building rotation to align with building axes
758
- const bottomUp = bottomUpVectors[this.presetViewRotation];
759
- endPos = { x: center.x, y: center.y - distance, z: center.z };
760
- upVector = {
761
- x: bottomUp.x * cosR + bottomUp.z * sinR,
762
- y: bottomUp.y,
763
- z: -bottomUp.x * sinR + bottomUp.z * cosR,
764
- };
765
- break;
766
- case 'front':
767
- // Front view: from +Z looking at model
768
- // Rotate camera position around Y axis by building rotation
769
- // Standard rotation: x' = x*cos - z*sin, z' = x*sin + z*cos
770
- // For +Z direction (0,0,1): x' = -sin, z' = cos
771
- // But we need to look at building's front, so use negative rotation
772
- endPos = {
773
- x: center.x + sinR * distance,
774
- y: center.y,
775
- z: center.z + cosR * distance,
776
- };
777
- upVector = { x: 0, y: 1, z: 0 }; // Y-up
778
- break;
779
- case 'back':
780
- // Back view: from -Z looking at model
781
- // For -Z direction (0,0,-1) rotated: x' = sin, z' = -cos
782
- endPos = {
783
- x: center.x - sinR * distance,
784
- y: center.y,
785
- z: center.z - cosR * distance,
786
- };
787
- upVector = { x: 0, y: 1, z: 0 }; // Y-up
788
- break;
789
- case 'left':
790
- // Left view: from -X looking at model
791
- // For -X direction (-1,0,0) rotated: x' = -cos, z' = sin
792
- endPos = {
793
- x: center.x - cosR * distance,
794
- y: center.y,
795
- z: center.z + sinR * distance,
796
- };
797
- upVector = { x: 0, y: 1, z: 0 }; // Y-up
798
- break;
799
- case 'right':
800
- // Right view: from +X looking at model
801
- // For +X direction (1,0,0) rotated: x' = cos, z' = -sin
802
- endPos = {
803
- x: center.x + cosR * distance,
804
- y: center.y,
805
- z: center.z - sinR * distance,
806
- };
807
- upVector = { x: 0, y: 1, z: 0 }; // Y-up
808
- break;
809
- }
810
- this.animateToWithUp(endPos, endTarget, upVector, 300);
811
- }
812
- /**
813
- * Get current bounds estimate (simplified - in production would use scene bounds)
814
- */
815
- getCurrentBounds() {
816
- // Estimate bounds from camera distance
817
- const dir = {
818
- x: this.camera.position.x - this.camera.target.x,
819
- y: this.camera.position.y - this.camera.target.y,
820
- z: this.camera.position.z - this.camera.target.z,
821
- };
822
- const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
823
- const size = distance / 2;
824
- return {
825
- min: {
826
- x: this.camera.target.x - size,
827
- y: this.camera.target.y - size,
828
- z: this.camera.target.z - size,
829
- },
830
- max: {
831
- x: this.camera.target.x + size,
832
- y: this.camera.target.y + size,
833
- z: this.camera.target.z + size,
834
- },
835
- };
184
+ this.animator.setPresetView(view, bounds, buildingRotation);
836
185
  }
837
186
  /**
838
187
  * Reset velocity (stop inertia)
839
188
  */
840
189
  stopInertia() {
841
- this.velocity.orbit.x = 0;
842
- this.velocity.orbit.y = 0;
843
- this.velocity.pan.x = 0;
844
- this.velocity.pan.y = 0;
845
- this.velocity.zoom = 0;
190
+ this.animator.stopInertia();
846
191
  }
847
192
  /**
848
193
  * Reset camera state (clear orbit pivot, stop inertia, cancel animations)
849
194
  * Called when loading a new model to ensure clean state
850
195
  */
851
196
  reset() {
852
- this.orbitPivot = null;
853
- this.stopInertia();
854
- // Cancel any ongoing animations
855
- this.animationStartTime = 0;
856
- this.animationDuration = 0;
857
- this.animationStartPos = null;
858
- this.animationStartTarget = null;
859
- this.animationEndPos = null;
860
- this.animationEndTarget = null;
861
- this.animationStartUp = null;
862
- this.animationEndUp = null;
863
- this.animationEasing = null;
864
- // Reset preset view tracking
865
- this.lastPresetView = null;
866
- this.presetViewRotation = 0;
197
+ this.controls.clearOrbitPivot();
198
+ this.animator.reset();
867
199
  }
868
200
  getViewProjMatrix() {
869
- return this.viewProjMatrix;
201
+ return this.state.viewProjMatrix;
870
202
  }
871
203
  getPosition() {
872
- return { ...this.camera.position };
204
+ return { ...this.state.camera.position };
873
205
  }
874
206
  getTarget() {
875
- return { ...this.camera.target };
207
+ return { ...this.state.camera.target };
876
208
  }
877
209
  /**
878
210
  * Get camera up vector
879
211
  */
880
212
  getUp() {
881
- return { ...this.camera.up };
213
+ return { ...this.state.camera.up };
882
214
  }
883
215
  /**
884
216
  * Get camera FOV in radians
885
217
  */
886
218
  getFOV() {
887
- return this.camera.fov;
219
+ return this.state.camera.fov;
888
220
  }
889
221
  /**
890
222
  * Get distance from camera position to target
891
223
  */
892
224
  getDistance() {
893
225
  const dir = {
894
- x: this.camera.position.x - this.camera.target.x,
895
- y: this.camera.position.y - this.camera.target.y,
896
- z: this.camera.position.z - this.camera.target.z,
226
+ x: this.state.camera.position.x - this.state.camera.target.x,
227
+ y: this.state.camera.position.y - this.state.camera.target.y,
228
+ z: this.state.camera.position.z - this.state.camera.target.z,
897
229
  };
898
230
  return Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
899
231
  }
@@ -905,9 +237,9 @@ export class Camera {
905
237
  */
906
238
  getRotation() {
907
239
  const dir = {
908
- x: this.camera.position.x - this.camera.target.x,
909
- y: this.camera.position.y - this.camera.target.y,
910
- z: this.camera.position.z - this.camera.target.z,
240
+ x: this.state.camera.position.x - this.state.camera.target.x,
241
+ y: this.state.camera.position.y - this.state.camera.target.y,
242
+ z: this.state.camera.position.z - this.state.camera.target.z,
911
243
  };
912
244
  const distance = Math.sqrt(dir.x * dir.x + dir.y * dir.y + dir.z * dir.z);
913
245
  if (distance < 1e-6)
@@ -916,9 +248,9 @@ export class Camera {
916
248
  const elevation = Math.asin(Math.max(-1, Math.min(1, dir.y / distance))) * 180 / Math.PI;
917
249
  // Calculate azimuth smoothly using up vector
918
250
  // The up vector defines the "screen up" direction, which determines rotation
919
- const upX = this.camera.up.x;
920
- const upY = this.camera.up.y;
921
- const upZ = this.camera.up.z;
251
+ const upX = this.state.camera.up.x;
252
+ const upY = this.state.camera.up.y;
253
+ const upZ = this.state.camera.up.z;
922
254
  // Project up vector onto horizontal plane (XZ plane)
923
255
  const upLen = Math.sqrt(upX * upX + upZ * upZ);
924
256
  let azimuth;
@@ -936,13 +268,6 @@ export class Camera {
936
268
  }
937
269
  return { azimuth, elevation };
938
270
  }
939
- /**
940
- * Project a world position to screen coordinates
941
- * @param worldPos - Position in world space
942
- * @param canvasWidth - Canvas width in pixels
943
- * @param canvasHeight - Canvas height in pixels
944
- * @returns Screen coordinates { x, y } or null if behind camera
945
- */
946
271
  /**
947
272
  * Unproject screen coordinates to a ray in world space
948
273
  * @param screenX - X position in screen coordinates
@@ -952,83 +277,71 @@ export class Camera {
952
277
  * @returns Ray origin and direction in world space
953
278
  */
954
279
  unprojectToRay(screenX, screenY, canvasWidth, canvasHeight) {
955
- // For perspective camera, ray origin is always the camera position
956
- // Direction is computed through the screen point
957
- // Convert screen coords to NDC (-1 to 1)
958
- const ndcX = (screenX / canvasWidth) * 2 - 1;
959
- const ndcY = 1 - (screenY / canvasHeight) * 2; // Flip Y
960
- // Invert the view-projection matrix
961
- const invViewProj = MathUtils.invert(this.viewProjMatrix);
962
- if (!invViewProj) {
963
- // Fallback: return ray from camera position towards target
964
- const dir = MathUtils.normalize({
965
- x: this.camera.target.x - this.camera.position.x,
966
- y: this.camera.target.y - this.camera.position.y,
967
- z: this.camera.target.z - this.camera.position.z,
968
- });
969
- return { origin: { ...this.camera.position }, direction: dir };
970
- }
971
- // Unproject a point at some depth to get a point on the ray
972
- // Using z=0.5 (midpoint in Reverse-Z: 1.0=near, 0.0=far) to get a finite point
973
- const worldPoint = MathUtils.transformPoint(invViewProj, { x: ndcX, y: ndcY, z: 0.5 });
974
- // Ray origin is camera position, direction is towards unprojected point
975
- const origin = { ...this.camera.position };
976
- const direction = MathUtils.normalize({
977
- x: worldPoint.x - origin.x,
978
- y: worldPoint.y - origin.y,
979
- z: worldPoint.z - origin.z,
980
- });
981
- return { origin, direction };
280
+ return this.projection.unprojectToRay(screenX, screenY, canvasWidth, canvasHeight);
982
281
  }
282
+ /**
283
+ * Project a world position to screen coordinates
284
+ * @param worldPos - Position in world space
285
+ * @param canvasWidth - Canvas width in pixels
286
+ * @param canvasHeight - Canvas height in pixels
287
+ * @returns Screen coordinates { x, y } or null if behind camera
288
+ */
983
289
  projectToScreen(worldPos, canvasWidth, canvasHeight) {
984
- // Transform world position by view-projection matrix
985
- const m = this.viewProjMatrix.m;
986
- // Manual matrix-vector multiplication for vec4(worldPos, 1.0)
987
- const clipX = m[0] * worldPos.x + m[4] * worldPos.y + m[8] * worldPos.z + m[12];
988
- const clipY = m[1] * worldPos.x + m[5] * worldPos.y + m[9] * worldPos.z + m[13];
989
- const clipZ = m[2] * worldPos.x + m[6] * worldPos.y + m[10] * worldPos.z + m[14];
990
- const clipW = m[3] * worldPos.x + m[7] * worldPos.y + m[11] * worldPos.z + m[15];
991
- // Check if behind camera
992
- if (clipW <= 0) {
993
- return null;
994
- }
995
- // Perspective divide to get NDC
996
- const ndcX = clipX / clipW;
997
- const ndcY = clipY / clipW;
998
- const ndcZ = clipZ / clipW;
999
- // Check if outside clip volume
1000
- if (ndcZ < -1 || ndcZ > 1) {
1001
- return null;
290
+ return this.projection.projectToScreen(worldPos, canvasWidth, canvasHeight);
291
+ }
292
+ /**
293
+ * Set projection mode (perspective or orthographic)
294
+ * When switching to orthographic, calculates initial orthoSize from current view.
295
+ */
296
+ setProjectionMode(mode) {
297
+ if (this.state.projectionMode === mode)
298
+ return;
299
+ if (mode === 'orthographic') {
300
+ // Calculate orthoSize from current perspective view so the model appears the same size
301
+ const distance = this.getDistance();
302
+ this.state.orthoSize = distance * Math.tan(this.state.camera.fov / 2);
1002
303
  }
1003
- // Convert NDC to screen coordinates
1004
- // NDC: (-1,-1) = bottom-left, (1,1) = top-right
1005
- // Screen: (0,0) = top-left, (width, height) = bottom-right
1006
- const screenX = (ndcX + 1) * 0.5 * canvasWidth;
1007
- const screenY = (1 - ndcY) * 0.5 * canvasHeight; // Flip Y
1008
- return { x: screenX, y: screenY };
304
+ this.state.projectionMode = mode;
305
+ this.updateMatrices();
306
+ }
307
+ /**
308
+ * Toggle between perspective and orthographic projection
309
+ */
310
+ toggleProjectionMode() {
311
+ this.setProjectionMode(this.state.projectionMode === 'perspective' ? 'orthographic' : 'perspective');
1009
312
  }
1010
313
  /**
1011
- * Update near/far planes dynamically based on camera distance
1012
- * Keeps ratio under 10000:1 to prevent Z-fighting
314
+ * Get current projection mode
1013
315
  */
1014
- updateNearFarPlanes(distance) {
1015
- const optimalNear = Math.max(0.01, distance * 0.001); // 0.1% of distance
1016
- const optimalFar = distance * 10; // 10x distance for safety margin
1017
- // Ensure ratio is reasonable (max 10000:1)
1018
- const maxRatio = 10000;
1019
- if (optimalFar / optimalNear > maxRatio) {
1020
- this.camera.far = optimalNear * maxRatio;
316
+ getProjectionMode() {
317
+ return this.state.projectionMode;
318
+ }
319
+ /**
320
+ * Get orthographic view half-height
321
+ */
322
+ getOrthoSize() {
323
+ return this.state.orthoSize;
324
+ }
325
+ updateMatrices() {
326
+ // Dynamically adapt near/far planes based on camera-to-target distance.
327
+ // This prevents near-plane clipping when zooming in close to geometry.
328
+ const dx = this.state.camera.position.x - this.state.camera.target.x;
329
+ const dy = this.state.camera.position.y - this.state.camera.target.y;
330
+ const dz = this.state.camera.position.z - this.state.camera.target.z;
331
+ const distance = Math.sqrt(dx * dx + dy * dy + dz * dz);
332
+ this.state.camera.near = Math.max(0.01, distance * 0.001);
333
+ this.state.camera.far = Math.max(distance * 10, 1000);
334
+ this.state.viewMatrix = MathUtils.lookAt(this.state.camera.position, this.state.camera.target, this.state.camera.up);
335
+ if (this.state.projectionMode === 'orthographic') {
336
+ const h = this.state.orthoSize;
337
+ const w = h * this.state.camera.aspect;
338
+ this.state.projMatrix = MathUtils.orthographicReverseZ(-w, w, -h, h, this.state.camera.near, this.state.camera.far);
1021
339
  }
1022
340
  else {
1023
- this.camera.far = Math.max(optimalFar, this.camera.far);
341
+ // Use reverse-Z projection for better depth precision
342
+ this.state.projMatrix = MathUtils.perspectiveReverseZ(this.state.camera.fov, this.state.camera.aspect, this.state.camera.near, this.state.camera.far);
1024
343
  }
1025
- this.camera.near = Math.min(optimalNear, this.camera.near);
1026
- }
1027
- updateMatrices() {
1028
- this.viewMatrix = MathUtils.lookAt(this.camera.position, this.camera.target, this.camera.up);
1029
- // Use reverse-Z projection for better depth precision
1030
- this.projMatrix = MathUtils.perspectiveReverseZ(this.camera.fov, this.camera.aspect, this.camera.near, this.camera.far);
1031
- this.viewProjMatrix = MathUtils.multiply(this.projMatrix, this.viewMatrix);
344
+ this.state.viewProjMatrix = MathUtils.multiply(this.state.projMatrix, this.state.viewMatrix);
1032
345
  }
1033
346
  }
1034
347
  //# sourceMappingURL=camera.js.map