@sequent-org/ifc-viewer 1.2.4-ci.28.0 → 1.2.4-ci.30.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sequent-org/ifc-viewer",
3
3
  "private": false,
4
- "version": "1.2.4-ci.28.0",
4
+ "version": "1.2.4-ci.30.0",
5
5
  "type": "module",
6
6
  "description": "IFC 3D model viewer component for web applications - fully self-contained with local IFCLoader",
7
7
  "main": "src/index.js",
package/src/IfcViewer.js CHANGED
@@ -85,6 +85,7 @@ export class IfcViewer {
85
85
  quality: 'medium', // 'low' | 'medium' | 'high'
86
86
  edgesVisible: false,
87
87
  flatShading: true,
88
+ shadowsEnabled: true,
88
89
  clipping: {
89
90
  x: false,
90
91
  y: false,
@@ -501,6 +502,12 @@ export class IfcViewer {
501
502
  this._addEventListener('#ifcToggleEdges', 'click', () => {
502
503
  this._toggleEdges();
503
504
  });
505
+ this._addEventListener('#ifcToggleShadows', 'click', () => {
506
+ this._toggleShadows();
507
+ });
508
+ this._addEventListener('#ifcToggleProjection', 'click', () => {
509
+ this._toggleProjection();
510
+ });
504
511
  this._addEventListener('#ifcToggleShading', 'click', () => {
505
512
  this._toggleShading();
506
513
  });
@@ -702,6 +709,45 @@ export class IfcViewer {
702
709
  }
703
710
  }
704
711
 
712
+ /**
713
+ * Переключает тени (вкл/выкл) для сцены.
714
+ * @private
715
+ */
716
+ _toggleShadows() {
717
+ if (!this.viewer) return;
718
+ this.viewerState.shadowsEnabled = !this.viewerState.shadowsEnabled;
719
+ try { this.viewer.setShadowsEnabled(this.viewerState.shadowsEnabled); } catch (_) {}
720
+ const btn = this.containerElement.querySelector('#ifcToggleShadows');
721
+ if (btn) btn.classList.toggle('btn-active', this.viewerState.shadowsEnabled);
722
+ }
723
+
724
+ /**
725
+ * Переключает режим проекции (Perspective ↔ Ortho) и меняет иконку по правилу "показываем действие".
726
+ * @private
727
+ */
728
+ _toggleProjection() {
729
+ if (!this.viewer) return;
730
+
731
+ // Иконки: показываем альтернативный режим
732
+ const ICON_PERSPECTIVE = `
733
+ <svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
734
+ <path fill="#000000" d="M365.50 333.29 A 0.30 0.30 0.0 0 0 365.95 333.55 L 492.36 259.80 A 0.47 0.47 0.0 0 0 492.51 259.12 Q 489.74 255.31 492.90 252.78 A 0.30 0.30 0.0 0 0 492.83 252.27 C 489.14 250.57 490.13 245.43 493.90 244.50 C 496.33 243.90 501.93 247.88 504.97 249.79 A 1.50 1.48 -85.3 0 1 505.54 250.47 L 505.97 251.53 A 0.72 0.71 76.6 0 0 506.67 251.97 C 509.70 251.84 512.28 254.84 511.15 257.67 Q 510.77 258.62 508.18 260.14 C 355.38 349.68 251.70 410.06 149.28 469.74 A 3.94 3.93 -44.9 0 1 145.31 469.74 Q 7.70 389.45 2.96 386.69 C 0.09 385.02 0.50 382.93 0.50 379.49 Q 0.50 259.79 0.50 128.77 C 0.50 127.21 1.85 125.96 3.27 125.13 Q 68.02 87.24 145.61 41.87 C 146.90 41.11 148.92 41.81 150.33 42.63 Q 219.34 82.64 289.83 124.16 C 291.25 125.00 292.80 126.11 294.76 127.15 Q 299.89 129.89 301.84 131.37 C 305.49 134.15 301.99 140.40 297.26 138.18 Q 295.67 137.42 294.41 136.58 A 0.26 0.26 0.0 0 0 294.00 136.80 L 294.00 209.83 A 0.44 0.44 0.0 0 0 294.36 210.26 Q 340.50 219.23 361.26 223.22 C 366.12 224.15 365.53 227.44 365.51 232.03 Q 365.50 234.52 365.49 251.11 A 0.73 0.73 0.0 0 0 366.22 251.84 L 370.02 251.84 A 3.64 3.64 0.0 0 1 373.66 255.48 L 373.66 256.72 A 3.45 3.44 0.0 0 1 370.21 260.16 L 366.15 260.16 A 0.65 0.65 0.0 0 0 365.50 260.81 L 365.50 333.29 Z"/>
735
+ </svg>
736
+ `;
737
+ const ICON_ORTHO = `
738
+ <svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
739
+ <path fill="#000000" d="M256.02 48.55 Q 257.33 48.55 258.06 48.94 Q 381.49 115.11 442.91 148.14 Q 445.24 149.39 445.26 152.25 Q 445.52 184.71 445.52 256.00 Q 445.52 327.29 445.26 359.75 Q 445.24 362.61 442.91 363.86 Q 381.49 396.89 258.06 463.06 Q 257.33 463.45 256.02 463.45 Q 254.71 463.45 253.98 463.06 Q 130.55 396.89 69.13 363.86 Q 66.80 362.61 66.78 359.75 Q 66.52 327.29 66.52 256.00 Q 66.52 184.71 66.78 152.25 Q 66.80 149.39 69.13 148.14 Q 130.55 115.11 253.98 48.94 Q 254.71 48.55 256.02 48.55 Z"/>
740
+ </svg>
741
+ `;
742
+
743
+ const next = this.viewer.toggleProjection?.();
744
+ const mode = next || this.viewer.getProjectionMode?.() || 'perspective';
745
+ const btn = this.containerElement.querySelector('#ifcToggleProjection');
746
+ if (btn) {
747
+ btn.innerHTML = (mode === 'perspective') ? ICON_ORTHO : ICON_PERSPECTIVE;
748
+ }
749
+ }
750
+
705
751
  /**
706
752
  * Переключает плоское затенение
707
753
  * @private
package/src/main.js CHANGED
@@ -176,9 +176,14 @@ if (app) {
176
176
  // Дефолт (из текущих подобранных значений)
177
177
  shadowToggle.checked = true;
178
178
  viewer.setShadowsEnabled(true);
179
+ // синхронизируем тулбар-кнопку, если есть
180
+ const _btn = document.getElementById("ifcToggleShadows");
181
+ if (_btn) _btn.classList.toggle('btn-active', true);
179
182
  shadowToggle.addEventListener("change", (e) => {
180
183
  const on = !!e.target.checked;
181
184
  viewer.setShadowsEnabled(on);
185
+ const btn = document.getElementById("ifcToggleShadows");
186
+ if (btn) btn.classList.toggle('btn-active', on);
182
187
  // UI градиента имеет смысл только когда тени включены
183
188
  if (shadowGradToggle) shadowGradToggle.disabled = !on;
184
189
  if (shadowGradLen) shadowGradLen.disabled = !on;
@@ -540,6 +545,10 @@ if (app) {
540
545
  const qualHigh = document.getElementById("qualHigh");
541
546
  // Нижний тулбар пакета (index.html): Edges
542
547
  const toggleEdges = document.getElementById("ifcToggleEdges");
548
+ // Нижний тулбар пакета (index.html): Shadows
549
+ const toggleShadowsBtn = document.getElementById("ifcToggleShadows");
550
+ // Нижний тулбар пакета (index.html): Projection (Perspective/Ortho)
551
+ const toggleProjectionBtn = document.getElementById("ifcToggleProjection");
543
552
  const toggleShading = document.getElementById("toggleShading");
544
553
  // Нижний тулбар пакета (index.html): секущие плоскости
545
554
  const clipXBtn = document.getElementById("ifcClipX");
@@ -561,6 +570,44 @@ if (app) {
561
570
  let edgesOn = false;
562
571
  viewer.setEdgesVisible(edgesOn);
563
572
  toggleEdges?.addEventListener("click", () => { edgesOn = !edgesOn; viewer.setEdgesVisible(edgesOn); });
573
+
574
+ // Переключение вида: "без перспективы" (Ortho) ↔ Perspective
575
+ // Вариант 2: на кнопке показываем действие (альтернативный режим)
576
+ const PROJ_ICON_PERSPECTIVE = `
577
+ <svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
578
+ <path fill="#000000" d="M365.50 333.29 A 0.30 0.30 0.0 0 0 365.95 333.55 L 492.36 259.80 A 0.47 0.47 0.0 0 0 492.51 259.12 Q 489.74 255.31 492.90 252.78 A 0.30 0.30 0.0 0 0 492.83 252.27 C 489.14 250.57 490.13 245.43 493.90 244.50 C 496.33 243.90 501.93 247.88 504.97 249.79 A 1.50 1.48 -85.3 0 1 505.54 250.47 L 505.97 251.53 A 0.72 0.71 76.6 0 0 506.67 251.97 C 509.70 251.84 512.28 254.84 511.15 257.67 Q 510.77 258.62 508.18 260.14 C 355.38 349.68 251.70 410.06 149.28 469.74 A 3.94 3.93 -44.9 0 1 145.31 469.74 Q 7.70 389.45 2.96 386.69 C 0.09 385.02 0.50 382.93 0.50 379.49 Q 0.50 259.79 0.50 128.77 C 0.50 127.21 1.85 125.96 3.27 125.13 Q 68.02 87.24 145.61 41.87 C 146.90 41.11 148.92 41.81 150.33 42.63 Q 219.34 82.64 289.83 124.16 C 291.25 125.00 292.80 126.11 294.76 127.15 Q 299.89 129.89 301.84 131.37 C 305.49 134.15 301.99 140.40 297.26 138.18 Q 295.67 137.42 294.41 136.58 A 0.26 0.26 0.0 0 0 294.00 136.80 L 294.00 209.83 A 0.44 0.44 0.0 0 0 294.36 210.26 Q 340.50 219.23 361.26 223.22 C 366.12 224.15 365.53 227.44 365.51 232.03 Q 365.50 234.52 365.49 251.11 A 0.73 0.73 0.0 0 0 366.22 251.84 L 370.02 251.84 A 3.64 3.64 0.0 0 1 373.66 255.48 L 373.66 256.72 A 3.45 3.44 0.0 0 1 370.21 260.16 L 366.15 260.16 A 0.65 0.65 0.0 0 0 365.50 260.81 L 365.50 333.29 Z"/>
579
+ </svg>
580
+ `;
581
+ const PROJ_ICON_ORTHO = `
582
+ <svg width="24" height="24" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
583
+ <path fill="#000000" d="M256.02 48.55 Q 257.33 48.55 258.06 48.94 Q 381.49 115.11 442.91 148.14 Q 445.24 149.39 445.26 152.25 Q 445.52 184.71 445.52 256.00 Q 445.52 327.29 445.26 359.75 Q 445.24 362.61 442.91 363.86 Q 381.49 396.89 258.06 463.06 Q 257.33 463.45 256.02 463.45 Q 254.71 463.45 253.98 463.06 Q 130.55 396.89 69.13 363.86 Q 66.80 362.61 66.78 359.75 Q 66.52 327.29 66.52 256.00 Q 66.52 184.71 66.78 152.25 Q 66.80 149.39 69.13 148.14 Q 130.55 115.11 253.98 48.94 Q 254.71 48.55 256.02 48.55 Z"/>
584
+ </svg>
585
+ `;
586
+ const syncProjectionIcon = () => {
587
+ if (!toggleProjectionBtn) return;
588
+ const mode = viewer.getProjectionMode?.() || 'perspective';
589
+ toggleProjectionBtn.innerHTML = (mode === 'perspective') ? PROJ_ICON_ORTHO : PROJ_ICON_PERSPECTIVE;
590
+ };
591
+ syncProjectionIcon();
592
+ toggleProjectionBtn?.addEventListener("click", () => {
593
+ viewer.toggleProjection?.();
594
+ syncProjectionIcon();
595
+ });
596
+
597
+ // Тени по умолчанию включены (как и в левой панели)
598
+ const setToolbarShadowsActive = (on) => {
599
+ if (toggleShadowsBtn) toggleShadowsBtn.classList.toggle('btn-active', !!on);
600
+ };
601
+ setToolbarShadowsActive(true);
602
+ toggleShadowsBtn?.addEventListener("click", () => {
603
+ const next = !(shadowToggle ? !!shadowToggle.checked : true);
604
+ // Меняем состояние у Viewer
605
+ viewer.setShadowsEnabled(next);
606
+ // Синхронизируем UI слева, если он есть
607
+ if (shadowToggle) shadowToggle.checked = next;
608
+ setToolbarShadowsActive(next);
609
+ });
610
+
564
611
  let flatOn = true;
565
612
  toggleShading?.addEventListener("click", () => { flatOn = !flatOn; viewer.setFlatShading(flatOn); });
566
613
 
@@ -21,6 +21,19 @@ export class Viewer {
21
21
  this.renderer = null;
22
22
  this.scene = null;
23
23
  this.camera = null;
24
+ // Переключение проекции (добавляем второй "вид без перспективы", не трогая свет/материалы/постпроцесс)
25
+ this._projection = {
26
+ mode: 'perspective', // 'perspective' | 'ortho'
27
+ /** @type {THREE.PerspectiveCamera|null} */
28
+ persp: null,
29
+ /** @type {THREE.OrthographicCamera|null} */
30
+ ortho: null,
31
+ // Половина высоты орто-фрустума (top=+h,bottom=-h). Подбираем из текущего perspective-вида.
32
+ orthoHalfHeight: 10,
33
+ // Пределы зума для Ortho
34
+ minZoom: 0.25,
35
+ maxZoom: 8,
36
+ };
24
37
  this.animationId = null;
25
38
  this.controls = null;
26
39
  this.zoomListeners = new Set();
@@ -147,6 +160,91 @@ export class Viewer {
147
160
  this.animate = this.animate.bind(this);
148
161
  }
149
162
 
163
+ /**
164
+ * Меняет FOV перспективной камеры, сохраняя кадрирование (масштаб объекта на экране) по текущему target.
165
+ * Это позволяет "ослабить перспективу" без резкого зума.
166
+ * @param {number} fovDeg
167
+ * @param {{keepFraming?: boolean, log?: boolean}} [opts]
168
+ */
169
+ setPerspectiveFov(fovDeg, opts = {}) {
170
+ const keepFraming = opts.keepFraming !== false;
171
+ const log = opts.log !== false;
172
+ if (!this.camera || !this.controls) return;
173
+ if (!this.camera.isPerspectiveCamera) return;
174
+
175
+ const nextFov = Number(fovDeg);
176
+ if (!Number.isFinite(nextFov)) return;
177
+ const clamped = Math.min(80, Math.max(10, nextFov));
178
+
179
+ const prevFov = this.camera.fov;
180
+ if (Math.abs(prevFov - clamped) < 1e-6) return;
181
+
182
+ const target = this.controls.target.clone();
183
+ const prevPos = this.camera.position.clone();
184
+ const prevDist = prevPos.distanceTo(target);
185
+
186
+ this.camera.fov = clamped;
187
+ this.camera.updateProjectionMatrix();
188
+
189
+ if (keepFraming) {
190
+ // dist_new = dist_old * tan(fov_old/2) / tan(fov_new/2)
191
+ const prev = (prevFov * Math.PI) / 180;
192
+ const next = (clamped * Math.PI) / 180;
193
+ const denom = Math.tan(next / 2);
194
+ const num = Math.tan(prev / 2);
195
+ if (Number.isFinite(denom) && denom > 1e-9 && Number.isFinite(num)) {
196
+ const newDist = Math.max(0.01, prevDist * (num / denom));
197
+ const dir = prevPos.clone().sub(target).normalize();
198
+ this.camera.position.copy(target.clone().add(dir.multiplyScalar(newDist)));
199
+ this.camera.updateProjectionMatrix();
200
+ }
201
+ }
202
+
203
+ this.controls.update();
204
+ if (log) {
205
+ try {
206
+ console.log('[Viewer][FOV]', {
207
+ prevFov,
208
+ nextFov: clamped,
209
+ prevDist: +prevDist.toFixed(3),
210
+ nextDist: +this.camera.position.distanceTo(target).toFixed(3),
211
+ });
212
+ } catch (_) {}
213
+ }
214
+ }
215
+
216
+ _getAspect() {
217
+ try {
218
+ const { width, height } = this._getContainerSize();
219
+ return Math.max(1e-6, width) / Math.max(1e-6, height);
220
+ } catch (_) {
221
+ return 1;
222
+ }
223
+ }
224
+
225
+ _dumpProjectionDebug(label) {
226
+ try {
227
+ const cam = this.camera;
228
+ const tgt = this.controls?.target;
229
+ const isPersp = !!cam?.isPerspectiveCamera;
230
+ const isOrtho = !!cam?.isOrthographicCamera;
231
+ console.log('[Viewer][Projection]', label, {
232
+ mode: this._projection?.mode,
233
+ camera: isPersp ? 'perspective' : isOrtho ? 'ortho' : 'unknown',
234
+ pos: cam?.position ? { x: +cam.position.x.toFixed(3), y: +cam.position.y.toFixed(3), z: +cam.position.z.toFixed(3) } : null,
235
+ target: tgt ? { x: +tgt.x.toFixed(3), y: +tgt.y.toFixed(3), z: +tgt.z.toFixed(3) } : null,
236
+ dist: (cam?.position && tgt) ? +cam.position.distanceTo(tgt).toFixed(3) : null,
237
+ fov: isPersp ? cam.fov : null,
238
+ zoom: isOrtho ? cam.zoom : null,
239
+ near: cam?.near,
240
+ far: cam?.far,
241
+ composer: !!this._composer,
242
+ renderPassHasCamera: !!this._renderPass?.camera,
243
+ ssaoPassHasCamera: !!this._ssaoPass?.camera,
244
+ });
245
+ } catch (_) {}
246
+ }
247
+
150
248
  init() {
151
249
  if (!this.container) throw new Error("Viewer: контейнер не найден");
152
250
 
@@ -185,9 +283,11 @@ export class Viewer {
185
283
  const width = this.container.clientWidth || window.innerWidth;
186
284
  const height = this.container.clientHeight || window.innerHeight;
187
285
  const aspect = width / height;
188
- this.camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
189
- this.camera.position.set(-22.03, 23.17, 39.12);
286
+ // Перспектива: уменьшаем FOV (меньше "сужение" вдаль)
287
+ this.camera = new THREE.PerspectiveCamera(20, aspect, 0.1, 1000);
288
+ this.camera.position.set(-22.03, 3.17, 39.12);
190
289
  this.camera.lookAt(0, 0, 0);
290
+ this._projection.persp = this.camera;
191
291
 
192
292
  // OrbitControls
193
293
  this.controls = new OrbitControls(this.camera, this.renderer.domElement);
@@ -195,6 +295,30 @@ export class Viewer {
195
295
  this.controls.target.set(0, 0, 0);
196
296
  this.controls.minDistance = 1;
197
297
  this.controls.maxDistance = 20;
298
+ // Zoom для орто-режима (в перспективе OrbitControls эти поля просто не мешают)
299
+ this.controls.minZoom = this._projection.minZoom;
300
+ this.controls.maxZoom = this._projection.maxZoom;
301
+
302
+ // Создадим вторую камеру "без перспективы" (orthographic), но не включаем её по умолчанию.
303
+ // Фрустум подбираем так, чтобы при переключении вид менялся только за счёт перспективных искажений.
304
+ try {
305
+ const dist = this.camera.position.distanceTo(this.controls.target);
306
+ const vFov = (this.camera.fov * Math.PI) / 180;
307
+ const halfH = Math.max(0.01, dist * Math.tan(vFov / 2));
308
+ this._projection.orthoHalfHeight = halfH;
309
+ const ortho = new THREE.OrthographicCamera(
310
+ -halfH * aspect,
311
+ halfH * aspect,
312
+ halfH,
313
+ -halfH,
314
+ this.camera.near,
315
+ this.camera.far
316
+ );
317
+ ortho.position.copy(this.camera.position);
318
+ ortho.zoom = 1;
319
+ ortho.updateProjectionMatrix();
320
+ this._projection.ortho = ortho;
321
+ } catch (_) {}
198
322
 
199
323
  // Свет
200
324
  const amb = new THREE.AmbientLight(0xffffff, 0.6);
@@ -319,9 +443,9 @@ export class Viewer {
319
443
  if (!this.container || !this.camera || !this.renderer) return;
320
444
  const { width, height } = this._getContainerSize();
321
445
  this._updateSize(Math.max(1, width), Math.max(1, height));
322
- // Обновим пределы зума под текущий объект без переразмещения камеры
446
+ // Обновим пределы зума под текущий объект без переразмещения камеры (только в перспективе)
323
447
  const subject = this.activeModel || this.demoCube;
324
- if (subject) this.applyAdaptiveZoomLimits(subject, { recenter: false });
448
+ if (subject && this.camera.isPerspectiveCamera) this.applyAdaptiveZoomLimits(subject, { recenter: false });
325
449
  // Обновим вспомогательные overlay-виджеты
326
450
  if (this.navCube) this.navCube.onResize();
327
451
  }
@@ -469,6 +593,14 @@ export class Viewer {
469
593
 
470
594
  getZoomPercent() {
471
595
  if (!this.controls) return 0;
596
+ if (this.camera?.isOrthographicCamera) {
597
+ const z = this.camera.zoom || 1;
598
+ const minZ = this.controls.minZoom ?? this._projection.minZoom;
599
+ const maxZ = this.controls.maxZoom ?? this._projection.maxZoom;
600
+ const clampedZ = Math.min(Math.max(z, minZ), maxZ);
601
+ const t = (clampedZ - minZ) / (maxZ - minZ);
602
+ return t * 100;
603
+ }
472
604
  const d = this.getDistance();
473
605
  const minD = this.controls.minDistance || 1;
474
606
  const maxD = this.controls.maxDistance || 20;
@@ -479,11 +611,31 @@ export class Viewer {
479
611
 
480
612
  zoomIn(factor = 0.9) {
481
613
  if (!this.camera || !this.controls) return;
614
+ if (this.camera.isOrthographicCamera) {
615
+ const minZ = this.controls.minZoom ?? this._projection.minZoom;
616
+ const maxZ = this.controls.maxZoom ?? this._projection.maxZoom;
617
+ const next = (this.camera.zoom || 1) * (1 / factor);
618
+ this.camera.zoom = Math.min(Math.max(next, minZ), maxZ);
619
+ this.camera.updateProjectionMatrix();
620
+ this.controls.update();
621
+ this._notifyZoomIfChanged(true);
622
+ return;
623
+ }
482
624
  this.#moveAlongView(factor);
483
625
  }
484
626
 
485
627
  zoomOut(factor = 1.1) {
486
628
  if (!this.camera || !this.controls) return;
629
+ if (this.camera.isOrthographicCamera) {
630
+ const minZ = this.controls.minZoom ?? this._projection.minZoom;
631
+ const maxZ = this.controls.maxZoom ?? this._projection.maxZoom;
632
+ const next = (this.camera.zoom || 1) * (1 / factor);
633
+ this.camera.zoom = Math.min(Math.max(next, minZ), maxZ);
634
+ this.camera.updateProjectionMatrix();
635
+ this.controls.update();
636
+ this._notifyZoomIfChanged(true);
637
+ return;
638
+ }
487
639
  this.#moveAlongView(factor);
488
640
  }
489
641
 
@@ -503,6 +655,8 @@ export class Viewer {
503
655
  // Адаптивная настройка пределов зума под габариты объекта
504
656
  applyAdaptiveZoomLimits(object3D, options = {}) {
505
657
  if (!object3D || !this.camera || !this.controls) return;
658
+ // Для орто-камеры эта функция (fit по FOV) не применима — здесь мы сознательно не меняем кадр.
659
+ if (this.camera.isOrthographicCamera) return;
506
660
  const padding = options.padding ?? 1.2; // запас на краях кадра
507
661
  const slack = options.slack ?? 2.5; // во сколько раз можно отъехать дальше «вписанной» дистанции
508
662
  const minRatio = options.minRatio ?? 0.05; // минимальная дистанция как доля от «вписанной»
@@ -583,8 +737,18 @@ export class Viewer {
583
737
 
584
738
  _updateSize(width, height) {
585
739
  if (!this.camera || !this.renderer) return;
586
- this.camera.aspect = width / height;
587
- this.camera.updateProjectionMatrix();
740
+ const aspect = width / height;
741
+ if (this.camera.isPerspectiveCamera) {
742
+ this.camera.aspect = aspect;
743
+ this.camera.updateProjectionMatrix();
744
+ } else if (this.camera.isOrthographicCamera) {
745
+ const h = this._projection.orthoHalfHeight || 10;
746
+ this.camera.left = -h * aspect;
747
+ this.camera.right = h * aspect;
748
+ this.camera.top = h;
749
+ this.camera.bottom = -h;
750
+ this.camera.updateProjectionMatrix();
751
+ }
588
752
  // Третий аргумент false — не менять стилевые размеры, только буфер
589
753
  this.renderer.setSize(width, height, false);
590
754
  if (this._composer) {
@@ -595,6 +759,93 @@ export class Viewer {
595
759
  }
596
760
  }
597
761
 
762
+ // ================= Projection (Perspective / Ortho) =================
763
+ getProjectionMode() {
764
+ return this._projection?.mode || 'perspective';
765
+ }
766
+
767
+ setProjectionMode(mode) {
768
+ const next = (mode === 'ortho') ? 'ortho' : 'perspective';
769
+ if (!this.camera || !this.controls || !this._projection?.persp || !this._projection?.ortho) return;
770
+ if (next === this._projection.mode) return;
771
+
772
+ this._dumpProjectionDebug('before');
773
+
774
+ const target = this.controls.target.clone();
775
+ const currentPos = this.camera.position.clone();
776
+ const dirVec = currentPos.clone().sub(target);
777
+ const dirLen = dirVec.length();
778
+ const viewDir = dirLen > 1e-6 ? dirVec.multiplyScalar(1 / dirLen) : new THREE.Vector3(0, 0, 1);
779
+ const aspect = this._getAspect();
780
+
781
+ if (next === 'ortho') {
782
+ // Подбираем фрустум под текущий perspective-вью: halfH = dist * tan(fov/2)
783
+ const persp = this._projection.persp;
784
+ const dist = Math.max(0.01, currentPos.distanceTo(target));
785
+ const vFov = (persp.fov * Math.PI) / 180;
786
+ const halfH = Math.max(0.01, dist * Math.tan(vFov / 2));
787
+ this._projection.orthoHalfHeight = halfH;
788
+
789
+ const ortho = this._projection.ortho;
790
+ ortho.left = -halfH * aspect;
791
+ ortho.right = halfH * aspect;
792
+ ortho.top = halfH;
793
+ ortho.bottom = -halfH;
794
+ ortho.near = this.camera.near;
795
+ ortho.far = this.camera.far;
796
+ ortho.position.copy(currentPos);
797
+ ortho.zoom = 1;
798
+ ortho.updateProjectionMatrix();
799
+
800
+ this.camera = ortho;
801
+ } else {
802
+ // Перевод Ortho → Perspective с сохранением масштаба в кадре:
803
+ // видимая halfHeight = orthoHalfHeight / zoom => dist = halfVisible / tan(fov/2)
804
+ const persp = this._projection.persp;
805
+ const ortho = this._projection.ortho;
806
+ const zoom = ortho.zoom || 1;
807
+ const halfVisible = Math.max(0.01, (this._projection.orthoHalfHeight || Math.abs(ortho.top) || 10) / zoom);
808
+ const vFov = (persp.fov * Math.PI) / 180;
809
+ const dist = Math.max(0.01, halfVisible / Math.tan(vFov / 2));
810
+
811
+ persp.near = this.camera.near;
812
+ persp.far = this.camera.far;
813
+ persp.aspect = aspect;
814
+ persp.position.copy(target.clone().add(viewDir.multiplyScalar(dist)));
815
+ persp.updateProjectionMatrix();
816
+
817
+ this.camera = persp;
818
+ }
819
+
820
+ this._projection.mode = next;
821
+
822
+ // Переключаем controls на новую камеру
823
+ this.controls.object = this.camera;
824
+ this.controls.target.copy(target);
825
+ this.controls.update();
826
+
827
+ // Внутренние зависимости, которые держат ссылку на camera
828
+ if (this.navCube) this.navCube.mainCamera = this.camera;
829
+ try {
830
+ if (this._renderPass) this._renderPass.camera = this.camera;
831
+ if (this._ssaoPass) this._ssaoPass.camera = this.camera;
832
+ } catch (_) {}
833
+ try {
834
+ ['x', 'y', 'z'].forEach((axis) => {
835
+ const m = this.clipping?.manipulators?.[axis];
836
+ if (m) m.camera = this.camera;
837
+ });
838
+ } catch (_) {}
839
+
840
+ this._dumpProjectionDebug('after');
841
+ }
842
+
843
+ toggleProjection() {
844
+ const next = (this.getProjectionMode() === 'ortho') ? 'perspective' : 'ortho';
845
+ this.setProjectionMode(next);
846
+ return this.getProjectionMode();
847
+ }
848
+
598
849
  _dispatchReady() {
599
850
  try {
600
851
  this.container.dispatchEvent(new CustomEvent("viewer:ready", { bubbles: true }));
@@ -629,7 +880,7 @@ export class Viewer {
629
880
  // Тени управляются единообразно через setShadowsEnabled()
630
881
  node.castShadow = !!this.shadowsEnabled;
631
882
  // Самозатенение включается только в пресете "Тест"
632
- node.receiveShadow = !!this._testPreset?.enabled;
883
+ node.receiveShadow = !!this.shadowsEnabled && !!this._testPreset?.enabled;
633
884
  // Стекло/прозрачность: рендерим после непрозрачных (уменьшает мерцание сортировки)
634
885
  try {
635
886
  const mats = Array.isArray(node.material) ? node.material : [node.material];
@@ -660,7 +911,9 @@ export class Viewer {
660
911
  if (!this.camera || !this.controls) return;
661
912
  // Центрируем точку взгляда на центр модели и ставим камеру в заданные координаты
662
913
  this.controls.target.copy(center);
663
- this.camera.position.set(-22.03, 23.17, 39.12);
914
+ this.camera.position.set(-22.03, 3.17, 39.12);
915
+ // Убедимся, что FOV соответствует целевому искаженению (и сохраним кадрирование)
916
+ this.setPerspectiveFov(20, { keepFraming: true, log: true });
664
917
  try {
665
918
  // Если камера слишком близко, отъедем до вписанной дистанции, сохранив направление
666
919
  const size = box.getSize(new THREE.Vector3());
@@ -1093,7 +1346,7 @@ export class Viewer {
1093
1346
  if (!node?.isMesh) return;
1094
1347
  node.castShadow = next;
1095
1348
  // Самозатенение включается только в пресете "Тест"
1096
- node.receiveShadow = !!this._testPreset?.enabled;
1349
+ node.receiveShadow = next && !!this._testPreset?.enabled;
1097
1350
  });
1098
1351
  }
1099
1352
  }