@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 +1 -1
- package/src/IfcViewer.js +46 -0
- package/src/main.js +47 -0
- package/src/viewer/Viewer.js +262 -9
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.
|
|
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
|
|
package/src/viewer/Viewer.js
CHANGED
|
@@ -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
|
-
|
|
189
|
-
this.camera.
|
|
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
|
-
|
|
587
|
-
this.camera.
|
|
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,
|
|
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
|
}
|