@sequent-org/ifc-viewer 1.2.4-ci.32.0 → 1.2.4-ci.34.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.
|
|
4
|
+
"version": "1.2.4-ci.34.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/main.js
CHANGED
|
@@ -9,6 +9,29 @@ if (app) {
|
|
|
9
9
|
const viewer = new Viewer(app);
|
|
10
10
|
viewer.init();
|
|
11
11
|
|
|
12
|
+
// ===== Диагностика (включается через query-параметры) =====
|
|
13
|
+
// ?debugViewer=1 -> window.__viewer = viewer
|
|
14
|
+
// ?zoomDebug=1 -> включает логирование zoom-to-cursor
|
|
15
|
+
// ?zoomCursor=0 -> отключает zoom-to-cursor (для сравнения с OrbitControls)
|
|
16
|
+
try {
|
|
17
|
+
const params = new URLSearchParams(location.search);
|
|
18
|
+
const debugViewer = params.get("debugViewer") === "1" || params.get("zoomDebug") === "1";
|
|
19
|
+
if (debugViewer) {
|
|
20
|
+
// eslint-disable-next-line no-undef
|
|
21
|
+
window.__viewer = viewer;
|
|
22
|
+
}
|
|
23
|
+
if (params.get("zoomDebug") === "1") {
|
|
24
|
+
viewer.setZoomToCursorDebug?.(true);
|
|
25
|
+
// eslint-disable-next-line no-console
|
|
26
|
+
console.log("[Viewer] zoom-to-cursor debug enabled");
|
|
27
|
+
}
|
|
28
|
+
if (params.get("zoomCursor") === "0") {
|
|
29
|
+
viewer.setZoomToCursorEnabled?.(false);
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
console.log("[Viewer] zoom-to-cursor disabled (zoomCursor=0)");
|
|
32
|
+
}
|
|
33
|
+
} catch (_) {}
|
|
34
|
+
|
|
12
35
|
// ===== Левая панель: временно оставляем только "Тест" =====
|
|
13
36
|
// Остальные контролы (тени/солнце/материалы/визуал/цветокор) будем добавлять пошагово позже.
|
|
14
37
|
// (Старый код управления панелью закомментирован ниже для последующего восстановления при необходимости.)
|
|
@@ -532,7 +555,10 @@ if (app) {
|
|
|
532
555
|
|
|
533
556
|
// Очистка при HMR (vite)
|
|
534
557
|
if (import.meta.hot) {
|
|
535
|
-
import.meta.hot.dispose(() => {
|
|
558
|
+
import.meta.hot.dispose(() => {
|
|
559
|
+
ifc.dispose();
|
|
560
|
+
viewer.dispose();
|
|
561
|
+
});
|
|
536
562
|
}
|
|
537
563
|
}
|
|
538
564
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Middle-mouse (MMB) pan controller.
|
|
5
|
+
*
|
|
6
|
+
* Цель: панорамирование "как в Autodesk" — сдвигать всю картинку (вид) при drag
|
|
7
|
+
* средней кнопкой мыши (нажатое колесо), но НЕ менять pivot вращения (controls.target).
|
|
8
|
+
*
|
|
9
|
+
* Реализация: camera.setViewOffset(...) — экранное смещение проекции (off-axis).
|
|
10
|
+
*
|
|
11
|
+
* Важно:
|
|
12
|
+
* - перехватывает события в capture-phase, чтобы OrbitControls не выполнял dolly на MMB
|
|
13
|
+
* - не трогает поведение ЛКМ/ПКМ
|
|
14
|
+
*/
|
|
15
|
+
export class MiddleMousePanController {
|
|
16
|
+
/**
|
|
17
|
+
* @param {object} deps
|
|
18
|
+
* @param {HTMLElement} deps.domElement
|
|
19
|
+
* @param {() => (THREE.Camera|null)} deps.getCamera
|
|
20
|
+
* @param {() => (import('three/examples/jsm/controls/OrbitControls').OrbitControls|null)} deps.getControls
|
|
21
|
+
* @param {() => boolean} [deps.isEnabled]
|
|
22
|
+
* @param {(e: PointerEvent) => boolean} [deps.shouldIgnoreEvent]
|
|
23
|
+
* @param {() => boolean} [deps.isDebug]
|
|
24
|
+
*/
|
|
25
|
+
constructor(deps) {
|
|
26
|
+
this.domElement = deps.domElement;
|
|
27
|
+
this.getCamera = deps.getCamera;
|
|
28
|
+
this.getControls = deps.getControls;
|
|
29
|
+
this.isEnabled = deps.isEnabled || (() => true);
|
|
30
|
+
this.shouldIgnoreEvent = deps.shouldIgnoreEvent || (() => false);
|
|
31
|
+
this.isDebug = deps.isDebug || (() => false);
|
|
32
|
+
|
|
33
|
+
this._activePointerId = null;
|
|
34
|
+
this._last = { x: 0, y: 0 };
|
|
35
|
+
this._prevControlsEnabled = null;
|
|
36
|
+
|
|
37
|
+
// Смещение вида в пикселях (screen-space)
|
|
38
|
+
this._offsetPx = { x: 0, y: 0 };
|
|
39
|
+
|
|
40
|
+
// Векторы оставлены только для возможных будущих расширений (не обязаны, но безопасно)
|
|
41
|
+
this._tmp = new THREE.Vector2();
|
|
42
|
+
|
|
43
|
+
/** @type {(e: PointerEvent) => void} */
|
|
44
|
+
this._onPointerDown = (e) => this.#handlePointerDown(e);
|
|
45
|
+
/** @type {(e: PointerEvent) => void} */
|
|
46
|
+
this._onPointerMove = (e) => this.#handlePointerMove(e);
|
|
47
|
+
/** @type {(e: PointerEvent) => void} */
|
|
48
|
+
this._onPointerUp = (e) => this.#handlePointerUp(e);
|
|
49
|
+
|
|
50
|
+
// Capture-phase: чтобы остановить OrbitControls (MMB по умолчанию = dolly)
|
|
51
|
+
this.domElement.addEventListener("pointerdown", this._onPointerDown, { capture: true, passive: false });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Сбрасывает MMB-pan смещение (возвращает вид как без viewOffset).
|
|
56
|
+
* Используется Home-кнопкой.
|
|
57
|
+
*/
|
|
58
|
+
reset() {
|
|
59
|
+
this._offsetPx.x = 0;
|
|
60
|
+
this._offsetPx.y = 0;
|
|
61
|
+
const camera = this.getCamera?.();
|
|
62
|
+
if (!camera) return;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (typeof camera.clearViewOffset === "function") {
|
|
66
|
+
camera.clearViewOffset();
|
|
67
|
+
camera.updateProjectionMatrix();
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
} catch (_) {}
|
|
71
|
+
|
|
72
|
+
// Fallback: setViewOffset(…, 0,0, …) с текущим размером
|
|
73
|
+
try {
|
|
74
|
+
const rect = this.domElement.getBoundingClientRect?.();
|
|
75
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
76
|
+
const w = Math.max(1, Math.floor(rect.width));
|
|
77
|
+
const h = Math.max(1, Math.floor(rect.height));
|
|
78
|
+
camera.setViewOffset(w, h, 0, 0, w, h);
|
|
79
|
+
camera.updateProjectionMatrix();
|
|
80
|
+
} catch (_) {}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Добавляет смещение viewOffset в пикселях (в координатах camera.setViewOffset).
|
|
85
|
+
* Используется для внутренней компенсации (например, чтобы убрать "скачок" при смене pivot).
|
|
86
|
+
* @param {number} dxPx
|
|
87
|
+
* @param {number} dyPx
|
|
88
|
+
*/
|
|
89
|
+
addOffsetPx(dxPx, dyPx) {
|
|
90
|
+
const dx = Number(dxPx);
|
|
91
|
+
const dy = Number(dyPx);
|
|
92
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return;
|
|
93
|
+
this._offsetPx.x += dx;
|
|
94
|
+
this._offsetPx.y += dy;
|
|
95
|
+
this.applyCurrentOffset();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Переустанавливает текущий viewOffset (например, после resize / смены камеры).
|
|
100
|
+
* @param {number} [width]
|
|
101
|
+
* @param {number} [height]
|
|
102
|
+
*/
|
|
103
|
+
applyCurrentOffset(width, height) {
|
|
104
|
+
const camera = this.getCamera?.();
|
|
105
|
+
if (!camera) return;
|
|
106
|
+
let w = width, h = height;
|
|
107
|
+
if (!(Number.isFinite(w) && Number.isFinite(h))) {
|
|
108
|
+
const rect = this.domElement.getBoundingClientRect?.();
|
|
109
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
110
|
+
w = rect.width;
|
|
111
|
+
h = rect.height;
|
|
112
|
+
}
|
|
113
|
+
this.#applyViewOffset(camera, w, h);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dispose() {
|
|
117
|
+
try {
|
|
118
|
+
this.domElement.removeEventListener("pointerdown", this._onPointerDown, { capture: true });
|
|
119
|
+
} catch (_) {
|
|
120
|
+
try { this.domElement.removeEventListener("pointerdown", this._onPointerDown); } catch (_) {}
|
|
121
|
+
}
|
|
122
|
+
this.#stopDrag(null);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#handlePointerDown(e) {
|
|
126
|
+
if (!this.isEnabled()) return;
|
|
127
|
+
if (e.button !== 1) return; // MMB only
|
|
128
|
+
if (this.shouldIgnoreEvent && this.shouldIgnoreEvent(e)) return;
|
|
129
|
+
|
|
130
|
+
const camera = this.getCamera?.();
|
|
131
|
+
const controls = this.getControls?.();
|
|
132
|
+
if (!camera || !controls) return;
|
|
133
|
+
|
|
134
|
+
// Заблокируем дефолт браузера (автоскролл) и OrbitControls dolly.
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
e.stopPropagation();
|
|
137
|
+
// eslint-disable-next-line no-undef
|
|
138
|
+
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
|
139
|
+
|
|
140
|
+
// Начинаем drag
|
|
141
|
+
this._activePointerId = e.pointerId;
|
|
142
|
+
this._last.x = e.clientX;
|
|
143
|
+
this._last.y = e.clientY;
|
|
144
|
+
|
|
145
|
+
// На время MMB-pan отключаем controls, чтобы не срабатывали внутренние обработчики
|
|
146
|
+
try {
|
|
147
|
+
this._prevControlsEnabled = !!controls.enabled;
|
|
148
|
+
controls.enabled = false;
|
|
149
|
+
} catch (_) {
|
|
150
|
+
this._prevControlsEnabled = null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try { this.domElement.setPointerCapture?.(e.pointerId); } catch (_) {}
|
|
154
|
+
|
|
155
|
+
// Слушаем move/up на window — надёжно при уходе курсора за пределы канваса
|
|
156
|
+
window.addEventListener("pointermove", this._onPointerMove, { passive: false });
|
|
157
|
+
window.addEventListener("pointerup", this._onPointerUp, { passive: false });
|
|
158
|
+
window.addEventListener("pointercancel", this._onPointerUp, { passive: false });
|
|
159
|
+
|
|
160
|
+
if (this.isDebug()) {
|
|
161
|
+
try { console.log("[MMB-Pan] start"); } catch (_) {}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
#handlePointerMove(e) {
|
|
166
|
+
if (this._activePointerId == null) return;
|
|
167
|
+
if (e.pointerId !== this._activePointerId) return;
|
|
168
|
+
|
|
169
|
+
const camera = this.getCamera?.();
|
|
170
|
+
if (!camera) return;
|
|
171
|
+
|
|
172
|
+
const rect = this.domElement.getBoundingClientRect?.();
|
|
173
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
174
|
+
|
|
175
|
+
// Заблокируем дефолт (на некоторых браузерах автоскролл может пытаться включиться)
|
|
176
|
+
e.preventDefault();
|
|
177
|
+
e.stopPropagation();
|
|
178
|
+
// eslint-disable-next-line no-undef
|
|
179
|
+
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
|
180
|
+
|
|
181
|
+
const dxPx = e.clientX - this._last.x;
|
|
182
|
+
const dyPx = e.clientY - this._last.y;
|
|
183
|
+
if (dxPx === 0 && dyPx === 0) return;
|
|
184
|
+
this._last.x = e.clientX;
|
|
185
|
+
this._last.y = e.clientY;
|
|
186
|
+
|
|
187
|
+
// Screen-space смещение: двигаем "картинку", pivot (controls.target) НЕ трогаем.
|
|
188
|
+
// Подбор знака: drag вправо => объект на экране тоже уходит вправо (как "тащим" сцену рукой).
|
|
189
|
+
// Интуитивное направление: "тащим" сцену рукой.
|
|
190
|
+
// Если курсор уходит влево — картинка должна уйти влево (и наоборот).
|
|
191
|
+
this._offsetPx.x -= dxPx;
|
|
192
|
+
this._offsetPx.y -= dyPx;
|
|
193
|
+
this.#applyViewOffset(camera, rect.width, rect.height);
|
|
194
|
+
|
|
195
|
+
if (this.isDebug()) {
|
|
196
|
+
try {
|
|
197
|
+
console.log("[MMB-Pan]", {
|
|
198
|
+
dxPx,
|
|
199
|
+
dyPx,
|
|
200
|
+
offsetPx: { x: this._offsetPx.x, y: this._offsetPx.y },
|
|
201
|
+
});
|
|
202
|
+
} catch (_) {}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#applyViewOffset(camera, width, height) {
|
|
207
|
+
const w = Math.max(1, Math.floor(width));
|
|
208
|
+
const h = Math.max(1, Math.floor(height));
|
|
209
|
+
const ox = Math.round(this._offsetPx.x);
|
|
210
|
+
const oy = Math.round(this._offsetPx.y);
|
|
211
|
+
try {
|
|
212
|
+
// fullWidth/fullHeight == width/height => off-axis shift (без "подматриц")
|
|
213
|
+
camera.setViewOffset(w, h, ox, oy, w, h);
|
|
214
|
+
camera.updateProjectionMatrix();
|
|
215
|
+
} catch (_) {}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#handlePointerUp(e) {
|
|
219
|
+
if (this._activePointerId == null) return;
|
|
220
|
+
if (e && e.pointerId !== this._activePointerId) return;
|
|
221
|
+
this.#stopDrag(e);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
#stopDrag(e) {
|
|
225
|
+
// Снимем window listeners
|
|
226
|
+
try { window.removeEventListener("pointermove", this._onPointerMove); } catch (_) {}
|
|
227
|
+
try { window.removeEventListener("pointerup", this._onPointerUp); } catch (_) {}
|
|
228
|
+
try { window.removeEventListener("pointercancel", this._onPointerUp); } catch (_) {}
|
|
229
|
+
|
|
230
|
+
// Восстановим OrbitControls
|
|
231
|
+
try {
|
|
232
|
+
const controls = this.getControls?.();
|
|
233
|
+
if (controls && this._prevControlsEnabled != null) controls.enabled = this._prevControlsEnabled;
|
|
234
|
+
} catch (_) {}
|
|
235
|
+
this._prevControlsEnabled = null;
|
|
236
|
+
|
|
237
|
+
// Освободим pointer capture
|
|
238
|
+
try {
|
|
239
|
+
if (e && this.domElement.releasePointerCapture) this.domElement.releasePointerCapture(e.pointerId);
|
|
240
|
+
} catch (_) {}
|
|
241
|
+
|
|
242
|
+
this._activePointerId = null;
|
|
243
|
+
|
|
244
|
+
if (this.isDebug()) {
|
|
245
|
+
try { console.log("[MMB-Pan] end"); } catch (_) {}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Right-mouse (RMB) drag controller that moves the MODEL (activeModel) in screen plane,
|
|
5
|
+
* while keeping OrbitControls pivot (controls.target) fixed.
|
|
6
|
+
*
|
|
7
|
+
* Это восстанавливает поведение:
|
|
8
|
+
* - ПКМ: "таскаем" модель относительно оси (pivot остаётся на месте)
|
|
9
|
+
* - ЛКМ: вращаем вокруг pivot (модель может ездить по окружности)
|
|
10
|
+
*/
|
|
11
|
+
export class RightMouseModelMoveController {
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} deps
|
|
14
|
+
* @param {HTMLElement} deps.domElement
|
|
15
|
+
* @param {() => (THREE.Camera|null)} deps.getCamera
|
|
16
|
+
* @param {() => (import('three/examples/jsm/controls/OrbitControls').OrbitControls|null)} deps.getControls
|
|
17
|
+
* @param {() => (THREE.Object3D|null)} deps.getModel
|
|
18
|
+
* @param {() => boolean} [deps.isEnabled]
|
|
19
|
+
* @param {(e: PointerEvent) => boolean} [deps.shouldIgnoreEvent]
|
|
20
|
+
* @param {() => boolean} [deps.isDebug]
|
|
21
|
+
* @param {(pivotWorld: THREE.Vector3) => void} [deps.onRmbStart]
|
|
22
|
+
*/
|
|
23
|
+
constructor(deps) {
|
|
24
|
+
this.domElement = deps.domElement;
|
|
25
|
+
this.getCamera = deps.getCamera;
|
|
26
|
+
this.getControls = deps.getControls;
|
|
27
|
+
this.getModel = deps.getModel;
|
|
28
|
+
this.isEnabled = deps.isEnabled || (() => true);
|
|
29
|
+
this.shouldIgnoreEvent = deps.shouldIgnoreEvent || (() => false);
|
|
30
|
+
this.isDebug = deps.isDebug || (() => false);
|
|
31
|
+
this.onRmbStart = typeof deps.onRmbStart === "function" ? deps.onRmbStart : null;
|
|
32
|
+
|
|
33
|
+
this._activePointerId = null;
|
|
34
|
+
this._last = { x: 0, y: 0 };
|
|
35
|
+
this._prevControlsEnabled = null;
|
|
36
|
+
this._suppressContextMenu = false;
|
|
37
|
+
|
|
38
|
+
this._vRight = new THREE.Vector3();
|
|
39
|
+
this._vUp = new THREE.Vector3();
|
|
40
|
+
this._vDelta = new THREE.Vector3();
|
|
41
|
+
|
|
42
|
+
/** @type {(e: PointerEvent) => void} */
|
|
43
|
+
this._onPointerDown = (e) => this.#handlePointerDown(e);
|
|
44
|
+
/** @type {(e: PointerEvent) => void} */
|
|
45
|
+
this._onPointerMove = (e) => this.#handlePointerMove(e);
|
|
46
|
+
/** @type {(e: PointerEvent) => void} */
|
|
47
|
+
this._onPointerUp = (e) => this.#handlePointerUp(e);
|
|
48
|
+
/** @type {(e: MouseEvent) => void} */
|
|
49
|
+
this._onContextMenu = (e) => this.#handleContextMenu(e);
|
|
50
|
+
|
|
51
|
+
this.domElement.addEventListener("pointerdown", this._onPointerDown, { capture: true, passive: false });
|
|
52
|
+
this.domElement.addEventListener("contextmenu", this._onContextMenu, { capture: true, passive: false });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
dispose() {
|
|
56
|
+
try { this.domElement.removeEventListener("pointerdown", this._onPointerDown, { capture: true }); } catch (_) {
|
|
57
|
+
try { this.domElement.removeEventListener("pointerdown", this._onPointerDown); } catch (_) {}
|
|
58
|
+
}
|
|
59
|
+
try { this.domElement.removeEventListener("contextmenu", this._onContextMenu, { capture: true }); } catch (_) {
|
|
60
|
+
try { this.domElement.removeEventListener("contextmenu", this._onContextMenu); } catch (_) {}
|
|
61
|
+
}
|
|
62
|
+
this.#stopDrag(null);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#handleContextMenu(e) {
|
|
66
|
+
if (!this._suppressContextMenu) return;
|
|
67
|
+
try { e.preventDefault(); } catch (_) {}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
#handlePointerDown(e) {
|
|
71
|
+
if (!this.isEnabled()) return;
|
|
72
|
+
if (e.button !== 2) return; // RMB only
|
|
73
|
+
if (this.shouldIgnoreEvent && this.shouldIgnoreEvent(e)) return;
|
|
74
|
+
|
|
75
|
+
const camera = this.getCamera?.();
|
|
76
|
+
const controls = this.getControls?.();
|
|
77
|
+
const model = this.getModel?.();
|
|
78
|
+
if (!camera || !controls || !model) return;
|
|
79
|
+
|
|
80
|
+
// Блокируем context menu и OrbitControls pan на ПКМ
|
|
81
|
+
this._suppressContextMenu = true;
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
e.stopPropagation();
|
|
84
|
+
// eslint-disable-next-line no-undef
|
|
85
|
+
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
|
86
|
+
|
|
87
|
+
// Сохраним pivot-ось для режима "двигаем модель вокруг оси"
|
|
88
|
+
try {
|
|
89
|
+
const pivot = controls.target?.clone?.();
|
|
90
|
+
if (pivot && this.onRmbStart) this.onRmbStart(pivot);
|
|
91
|
+
} catch (_) {}
|
|
92
|
+
|
|
93
|
+
this._activePointerId = e.pointerId;
|
|
94
|
+
this._last.x = e.clientX;
|
|
95
|
+
this._last.y = e.clientY;
|
|
96
|
+
|
|
97
|
+
// На время drag отключаем OrbitControls, чтобы он не панорамировал камеру
|
|
98
|
+
try {
|
|
99
|
+
this._prevControlsEnabled = !!controls.enabled;
|
|
100
|
+
controls.enabled = false;
|
|
101
|
+
} catch (_) {
|
|
102
|
+
this._prevControlsEnabled = null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
try { this.domElement.setPointerCapture?.(e.pointerId); } catch (_) {}
|
|
106
|
+
window.addEventListener("pointermove", this._onPointerMove, { passive: false });
|
|
107
|
+
window.addEventListener("pointerup", this._onPointerUp, { passive: false });
|
|
108
|
+
window.addEventListener("pointercancel", this._onPointerUp, { passive: false });
|
|
109
|
+
|
|
110
|
+
if (this.isDebug()) {
|
|
111
|
+
try { console.log("[RMB-ModelMove] start"); } catch (_) {}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
#handlePointerMove(e) {
|
|
116
|
+
if (this._activePointerId == null) return;
|
|
117
|
+
if (e.pointerId !== this._activePointerId) return;
|
|
118
|
+
|
|
119
|
+
const camera = this.getCamera?.();
|
|
120
|
+
const controls = this.getControls?.();
|
|
121
|
+
const model = this.getModel?.();
|
|
122
|
+
if (!camera || !controls || !model) return;
|
|
123
|
+
|
|
124
|
+
const rect = this.domElement.getBoundingClientRect?.();
|
|
125
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
126
|
+
|
|
127
|
+
e.preventDefault();
|
|
128
|
+
e.stopPropagation();
|
|
129
|
+
// eslint-disable-next-line no-undef
|
|
130
|
+
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
|
131
|
+
|
|
132
|
+
const dxPx = e.clientX - this._last.x;
|
|
133
|
+
const dyPx = e.clientY - this._last.y;
|
|
134
|
+
if (dxPx === 0 && dyPx === 0) return;
|
|
135
|
+
this._last.x = e.clientX;
|
|
136
|
+
this._last.y = e.clientY;
|
|
137
|
+
|
|
138
|
+
// Перевод пикселей в world-сдвиг в плоскости экрана (right/up).
|
|
139
|
+
const w = rect.width;
|
|
140
|
+
const h = rect.height;
|
|
141
|
+
const aspect = w / h;
|
|
142
|
+
|
|
143
|
+
let worldPerPxX = 0;
|
|
144
|
+
let worldPerPxY = 0;
|
|
145
|
+
|
|
146
|
+
if (camera.isPerspectiveCamera) {
|
|
147
|
+
// Масштабируем по расстоянию до pivot (так поведение стабильно при орбит-камере)
|
|
148
|
+
const dist = camera.position.distanceTo(controls.target);
|
|
149
|
+
const vFov = (camera.fov * Math.PI) / 180;
|
|
150
|
+
const visibleH = 2 * dist * Math.tan(vFov / 2);
|
|
151
|
+
const visibleW = visibleH * aspect;
|
|
152
|
+
worldPerPxX = visibleW / w;
|
|
153
|
+
worldPerPxY = visibleH / h;
|
|
154
|
+
} else if (camera.isOrthographicCamera) {
|
|
155
|
+
const visibleW = (camera.right - camera.left) / Math.max(1e-6, camera.zoom || 1);
|
|
156
|
+
const visibleH = (camera.top - camera.bottom) / Math.max(1e-6, camera.zoom || 1);
|
|
157
|
+
worldPerPxX = visibleW / w;
|
|
158
|
+
worldPerPxY = visibleH / h;
|
|
159
|
+
} else {
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Интуитивно: модель "идёт" за мышью.
|
|
164
|
+
const moveX = dxPx * worldPerPxX;
|
|
165
|
+
const moveY = -dyPx * worldPerPxY;
|
|
166
|
+
|
|
167
|
+
try { camera.updateMatrixWorld?.(true); } catch (_) {}
|
|
168
|
+
this._vRight.setFromMatrixColumn(camera.matrixWorld, 0).normalize();
|
|
169
|
+
this._vUp.setFromMatrixColumn(camera.matrixWorld, 1).normalize();
|
|
170
|
+
this._vDelta.copy(this._vRight).multiplyScalar(moveX).add(this._vUp.multiplyScalar(moveY));
|
|
171
|
+
|
|
172
|
+
model.position.add(this._vDelta);
|
|
173
|
+
model.updateMatrixWorld?.(true);
|
|
174
|
+
|
|
175
|
+
if (this.isDebug()) {
|
|
176
|
+
try {
|
|
177
|
+
console.log("[RMB-ModelMove]", {
|
|
178
|
+
dxPx,
|
|
179
|
+
dyPx,
|
|
180
|
+
delta: { x: +this._vDelta.x.toFixed(4), y: +this._vDelta.y.toFixed(4), z: +this._vDelta.z.toFixed(4) },
|
|
181
|
+
});
|
|
182
|
+
} catch (_) {}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
#handlePointerUp(e) {
|
|
187
|
+
if (this._activePointerId == null) return;
|
|
188
|
+
if (e && e.pointerId !== this._activePointerId) return;
|
|
189
|
+
this.#stopDrag(e);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
#stopDrag(e) {
|
|
193
|
+
try { window.removeEventListener("pointermove", this._onPointerMove); } catch (_) {}
|
|
194
|
+
try { window.removeEventListener("pointerup", this._onPointerUp); } catch (_) {}
|
|
195
|
+
try { window.removeEventListener("pointercancel", this._onPointerUp); } catch (_) {}
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const controls = this.getControls?.();
|
|
199
|
+
if (controls && this._prevControlsEnabled != null) controls.enabled = this._prevControlsEnabled;
|
|
200
|
+
} catch (_) {}
|
|
201
|
+
this._prevControlsEnabled = null;
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
if (e && this.domElement.releasePointerCapture) this.domElement.releasePointerCapture(e.pointerId);
|
|
205
|
+
} catch (_) {}
|
|
206
|
+
|
|
207
|
+
this._activePointerId = null;
|
|
208
|
+
// чуть задержим сброс, чтобы contextmenu не проскочил на mouseup
|
|
209
|
+
setTimeout(() => { this._suppressContextMenu = false; }, 0);
|
|
210
|
+
|
|
211
|
+
if (this.isDebug()) {
|
|
212
|
+
try { console.log("[RMB-ModelMove] end"); } catch (_) {}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
package/src/viewer/Viewer.js
CHANGED
|
@@ -12,6 +12,9 @@ import { BrightnessContrastShader } from "three/examples/jsm/shaders/BrightnessC
|
|
|
12
12
|
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
|
13
13
|
import { NavCube } from "./NavCube.js";
|
|
14
14
|
import { SectionManipulator } from "./SectionManipulator.js";
|
|
15
|
+
import { ZoomToCursorController } from "./ZoomToCursorController.js";
|
|
16
|
+
import { MiddleMousePanController } from "./MiddleMousePanController.js";
|
|
17
|
+
import { RightMouseModelMoveController } from "./RightMouseModelMoveController.js";
|
|
15
18
|
|
|
16
19
|
export class Viewer {
|
|
17
20
|
constructor(containerElement) {
|
|
@@ -149,11 +152,16 @@ export class Viewer {
|
|
|
149
152
|
flatShading: true,
|
|
150
153
|
quality: 'medium',
|
|
151
154
|
clipEnabled: [Infinity, Infinity, Infinity],
|
|
155
|
+
// Трансформ модели (для сброса ПКМ-сдвигов)
|
|
156
|
+
modelTransform: null, // { position: Vector3, quaternion: Quaternion, scale: Vector3 }
|
|
152
157
|
};
|
|
153
158
|
|
|
154
159
|
// Визуализация оси вращения
|
|
155
160
|
this.rotationAxisLine = null;
|
|
156
161
|
this._isLmbDown = false;
|
|
162
|
+
// OrbitControls 'start' может прийти раньше нашего bubble pointerdown.
|
|
163
|
+
// Поэтому сохраняем кнопку последнего pointerdown в capture-phase.
|
|
164
|
+
this._lastPointerDownButton = null;
|
|
157
165
|
this._wasRotating = false;
|
|
158
166
|
this._prevViewDir = null;
|
|
159
167
|
this._smoothedAxis = null;
|
|
@@ -169,10 +177,174 @@ export class Viewer {
|
|
|
169
177
|
this._onControlsChange = null;
|
|
170
178
|
this._onControlsEnd = null;
|
|
171
179
|
|
|
180
|
+
// Zoom-to-cursor (wheel): включено по умолчанию, debug выключен
|
|
181
|
+
this._zoomToCursor = {
|
|
182
|
+
enabled: true,
|
|
183
|
+
debug: false,
|
|
184
|
+
controller: null,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
// MMB-pan (wheel-click drag): включено по умолчанию, debug выключен
|
|
188
|
+
this._mmbPan = {
|
|
189
|
+
enabled: true,
|
|
190
|
+
debug: false,
|
|
191
|
+
controller: null,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
// RMB: перемещение модели относительно "оси" (pivot), pivot остаётся на месте
|
|
195
|
+
this._rmbModelMove = {
|
|
196
|
+
enabled: true,
|
|
197
|
+
debug: false,
|
|
198
|
+
controller: null,
|
|
199
|
+
pivotAnchor: null, // THREE.Vector3|null (фиксированная ось после ПКМ)
|
|
200
|
+
};
|
|
201
|
+
|
|
172
202
|
this.handleResize = this.handleResize.bind(this);
|
|
173
203
|
this.animate = this.animate.bind(this);
|
|
174
204
|
}
|
|
175
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Включает/выключает zoom-to-cursor (wheel).
|
|
208
|
+
* @param {boolean} enabled
|
|
209
|
+
*/
|
|
210
|
+
setZoomToCursorEnabled(enabled) {
|
|
211
|
+
if (!this._zoomToCursor) return;
|
|
212
|
+
this._zoomToCursor.enabled = !!enabled;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Включает/выключает диагностическое логирование zoom-to-cursor.
|
|
217
|
+
* @param {boolean} debug
|
|
218
|
+
*/
|
|
219
|
+
setZoomToCursorDebug(debug) {
|
|
220
|
+
if (!this._zoomToCursor) return;
|
|
221
|
+
this._zoomToCursor.debug = !!debug;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Возвращает текущие флаги zoom-to-cursor (для диагностики).
|
|
226
|
+
*/
|
|
227
|
+
getZoomToCursorState() {
|
|
228
|
+
if (!this._zoomToCursor) return { enabled: false, debug: false };
|
|
229
|
+
return { enabled: !!this._zoomToCursor.enabled, debug: !!this._zoomToCursor.debug };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Возвращает "домашнюю" точку вращения для текущей модели:
|
|
234
|
+
* центр bbox со смещением вниз по Y (как при первичной загрузке).
|
|
235
|
+
* @returns {THREE.Vector3|null}
|
|
236
|
+
*/
|
|
237
|
+
#getDefaultPivotForActiveModel() {
|
|
238
|
+
const subject = this.activeModel || this.demoCube;
|
|
239
|
+
if (!subject) return null;
|
|
240
|
+
try {
|
|
241
|
+
const box = new THREE.Box3().setFromObject(subject);
|
|
242
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
243
|
+
const size = box.getSize(new THREE.Vector3());
|
|
244
|
+
const pivot = center.clone();
|
|
245
|
+
// Держим модель "выше" в кадре, как при replaceWithModel()
|
|
246
|
+
const verticalBias = size.y * 0.30; // 30% высоты
|
|
247
|
+
pivot.y = center.y - verticalBias;
|
|
248
|
+
return pivot;
|
|
249
|
+
} catch (_) {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Возвращает целевой pivot для ЛКМ-вращения:
|
|
256
|
+
* - если модель двигали ПКМ, используем фиксированную ось (pivotAnchor)
|
|
257
|
+
* - иначе используем "домашний" pivot (центр bbox + verticalBias)
|
|
258
|
+
* @returns {THREE.Vector3|null}
|
|
259
|
+
*/
|
|
260
|
+
#getDesiredPivotForRotate() {
|
|
261
|
+
try {
|
|
262
|
+
const fixed = this._rmbModelMove?.pivotAnchor;
|
|
263
|
+
if (fixed) return fixed.clone();
|
|
264
|
+
} catch (_) {}
|
|
265
|
+
return this.#getDefaultPivotForActiveModel();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* После zoom-to-cursor target может сместиться к точке под курсором (например, к углу),
|
|
270
|
+
* и вращение начнёт происходить вокруг этой точки.
|
|
271
|
+
* Здесь мы возвращаем pivot к "домашнему" центру модели перед началом LMB-вращения,
|
|
272
|
+
* сохраняя кадр (camera смещается на тот же delta).
|
|
273
|
+
*/
|
|
274
|
+
#rebaseRotatePivotToModelCenterIfNeeded() {
|
|
275
|
+
if (!this.camera || !this.controls) return;
|
|
276
|
+
const desired = this.#getDesiredPivotForRotate();
|
|
277
|
+
if (!desired) return;
|
|
278
|
+
|
|
279
|
+
const current = this.controls.target;
|
|
280
|
+
const dx = desired.x - current.x;
|
|
281
|
+
const dy = desired.y - current.y;
|
|
282
|
+
const dz = desired.z - current.z;
|
|
283
|
+
const dist2 = dx * dx + dy * dy + dz * dz;
|
|
284
|
+
// Порог: чтобы не дергать pivot от микросдвигов зума
|
|
285
|
+
if (dist2 < 1e-6) return;
|
|
286
|
+
|
|
287
|
+
// Важно: нельзя "двигать модель к оси" визуально. Поэтому:
|
|
288
|
+
// 1) запоминаем положение старого target на экране
|
|
289
|
+
// 2) меняем pivot (controls.target) на центр модели
|
|
290
|
+
// 3) компенсируем экранный сдвиг через viewOffset (MMB-pan controller), чтобы картинка не дернулась
|
|
291
|
+
const dom = this.renderer?.domElement;
|
|
292
|
+
const rect = dom?.getBoundingClientRect?.();
|
|
293
|
+
const w = rect?.width || 0;
|
|
294
|
+
const h = rect?.height || 0;
|
|
295
|
+
const canProject = w > 1 && h > 1;
|
|
296
|
+
|
|
297
|
+
const oldTarget = this.controls.target.clone();
|
|
298
|
+
let p0 = null;
|
|
299
|
+
if (canProject) {
|
|
300
|
+
try { this.camera.updateMatrixWorld?.(true); } catch (_) {}
|
|
301
|
+
try { p0 = oldTarget.clone().project(this.camera); } catch (_) { p0 = null; }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
try { this.controls.target.copy(desired); } catch (_) {}
|
|
305
|
+
try { this.controls.update(); } catch (_) {}
|
|
306
|
+
|
|
307
|
+
if (canProject && p0) {
|
|
308
|
+
let p1 = null;
|
|
309
|
+
try { this.camera.updateMatrixWorld?.(true); } catch (_) {}
|
|
310
|
+
try { p1 = oldTarget.clone().project(this.camera); } catch (_) { p1 = null; }
|
|
311
|
+
if (p1) {
|
|
312
|
+
// NDC -> px. Y: NDC вверх, а viewOffset.y увеличением поднимает картинку (см. MMB-pan).
|
|
313
|
+
const dNdcX = (p1.x - p0.x);
|
|
314
|
+
const dNdcY = (p1.y - p0.y);
|
|
315
|
+
const dxPx = dNdcX * (w / 2);
|
|
316
|
+
const dyPx = -dNdcY * (h / 2);
|
|
317
|
+
try { this._mmbPan?.controller?.addOffsetPx?.(dxPx, dyPx); } catch (_) {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Включает/выключает MMB-pan (нажатое колесо + drag).
|
|
324
|
+
* @param {boolean} enabled
|
|
325
|
+
*/
|
|
326
|
+
setMiddleMousePanEnabled(enabled) {
|
|
327
|
+
if (!this._mmbPan) return;
|
|
328
|
+
this._mmbPan.enabled = !!enabled;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Включает/выключает диагностическое логирование MMB-pan.
|
|
333
|
+
* @param {boolean} debug
|
|
334
|
+
*/
|
|
335
|
+
setMiddleMousePanDebug(debug) {
|
|
336
|
+
if (!this._mmbPan) return;
|
|
337
|
+
this._mmbPan.debug = !!debug;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Возвращает текущие флаги MMB-pan (для диагностики).
|
|
342
|
+
*/
|
|
343
|
+
getMiddleMousePanState() {
|
|
344
|
+
if (!this._mmbPan) return { enabled: false, debug: false };
|
|
345
|
+
return { enabled: !!this._mmbPan.enabled, debug: !!this._mmbPan.debug };
|
|
346
|
+
}
|
|
347
|
+
|
|
176
348
|
/**
|
|
177
349
|
* Меняет FOV перспективной камеры, сохраняя кадрирование (масштаб объекта на экране) по текущему target.
|
|
178
350
|
* Это позволяет "ослабить перспективу" без резкого зума.
|
|
@@ -312,6 +484,69 @@ export class Viewer {
|
|
|
312
484
|
this.controls.minZoom = this._projection.minZoom;
|
|
313
485
|
this.controls.maxZoom = this._projection.maxZoom;
|
|
314
486
|
|
|
487
|
+
// Zoom-to-cursor: перехватываем wheel в capture-phase, чтобы OrbitControls не выполнял dolly сам
|
|
488
|
+
try {
|
|
489
|
+
this._zoomToCursor.controller = new ZoomToCursorController({
|
|
490
|
+
domElement: this.renderer.domElement,
|
|
491
|
+
getCamera: () => this.camera,
|
|
492
|
+
getControls: () => this.controls,
|
|
493
|
+
getPickRoot: () => this.activeModel,
|
|
494
|
+
onZoomChanged: (force) => this._notifyZoomIfChanged(force),
|
|
495
|
+
isEnabled: () => !!this._zoomToCursor.enabled,
|
|
496
|
+
isDebug: () => !!this._zoomToCursor.debug,
|
|
497
|
+
});
|
|
498
|
+
} catch (e) {
|
|
499
|
+
// eslint-disable-next-line no-console
|
|
500
|
+
console.warn("ZoomToCursor init failed:", e);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// MMB-pan: перехватываем pointerdown в capture-phase, чтобы OrbitControls не делал dolly на MMB
|
|
504
|
+
try {
|
|
505
|
+
this._mmbPan.controller = new MiddleMousePanController({
|
|
506
|
+
domElement: this.renderer.domElement,
|
|
507
|
+
getCamera: () => this.camera,
|
|
508
|
+
getControls: () => this.controls,
|
|
509
|
+
isEnabled: () => !!this._mmbPan.enabled,
|
|
510
|
+
isDebug: () => !!this._mmbPan.debug,
|
|
511
|
+
// Не стартуем пан, если нажали на overlay NavCube (иначе "тащит" камеру при клике по кубу)
|
|
512
|
+
shouldIgnoreEvent: (e) => {
|
|
513
|
+
try {
|
|
514
|
+
return !!(this.navCube && typeof this.navCube._isInsideOverlay === "function" && this.navCube._isInsideOverlay(e.clientX, e.clientY));
|
|
515
|
+
} catch (_) {
|
|
516
|
+
return false;
|
|
517
|
+
}
|
|
518
|
+
},
|
|
519
|
+
});
|
|
520
|
+
} catch (e) {
|
|
521
|
+
// eslint-disable-next-line no-console
|
|
522
|
+
console.warn("MMB-pan init failed:", e);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// RMB model move: перехватываем ПКМ и двигаем МОДЕЛЬ (activeModel), не трогая pivot (target)
|
|
526
|
+
try {
|
|
527
|
+
this._rmbModelMove.controller = new RightMouseModelMoveController({
|
|
528
|
+
domElement: this.renderer.domElement,
|
|
529
|
+
getCamera: () => this.camera,
|
|
530
|
+
getControls: () => this.controls,
|
|
531
|
+
getModel: () => this.activeModel,
|
|
532
|
+
isEnabled: () => !!this._rmbModelMove.enabled,
|
|
533
|
+
isDebug: () => !!this._rmbModelMove.debug,
|
|
534
|
+
shouldIgnoreEvent: (e) => {
|
|
535
|
+
try {
|
|
536
|
+
return !!(this.navCube && typeof this.navCube._isInsideOverlay === "function" && this.navCube._isInsideOverlay(e.clientX, e.clientY));
|
|
537
|
+
} catch (_) {
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
},
|
|
541
|
+
onRmbStart: (pivot) => {
|
|
542
|
+
try { this._rmbModelMove.pivotAnchor = pivot?.clone?.() || null; } catch (_) { this._rmbModelMove.pivotAnchor = null; }
|
|
543
|
+
},
|
|
544
|
+
});
|
|
545
|
+
} catch (e) {
|
|
546
|
+
// eslint-disable-next-line no-console
|
|
547
|
+
console.warn("RMB-model-move init failed:", e);
|
|
548
|
+
}
|
|
549
|
+
|
|
315
550
|
// Создадим вторую камеру "без перспективы" (orthographic), но не включаем её по умолчанию.
|
|
316
551
|
// Фрустум подбираем так, чтобы при переключении вид менялся только за счёт перспективных искажений.
|
|
317
552
|
try {
|
|
@@ -381,8 +616,15 @@ export class Viewer {
|
|
|
381
616
|
});
|
|
382
617
|
|
|
383
618
|
// Визуальная ось вращения: события мыши и контролов
|
|
384
|
-
this._onPointerDown = (e) => {
|
|
385
|
-
|
|
619
|
+
this._onPointerDown = (e) => {
|
|
620
|
+
this._lastPointerDownButton = e?.button;
|
|
621
|
+
if (e.button === 0) this._isLmbDown = true;
|
|
622
|
+
};
|
|
623
|
+
this._onPointerUp = (e) => {
|
|
624
|
+
// Сбросим "последнюю кнопку" на отпускании, чтобы не использовать устаревшее значение.
|
|
625
|
+
this._lastPointerDownButton = null;
|
|
626
|
+
if (e.button === 0) { this._isLmbDown = false; this.#hideRotationAxisLine(); }
|
|
627
|
+
};
|
|
386
628
|
this._onPointerMove = (e) => {
|
|
387
629
|
const rect = this.renderer?.domElement?.getBoundingClientRect?.();
|
|
388
630
|
if (!rect) return;
|
|
@@ -394,13 +636,17 @@ export class Viewer {
|
|
|
394
636
|
this._recentPointerDelta = dx + dy;
|
|
395
637
|
this._lastPointer = now;
|
|
396
638
|
};
|
|
397
|
-
|
|
639
|
+
// Capture-phase: чтобы успеть выставить флаги до OrbitControls (его start может прийти раньше bubble pointerdown)
|
|
640
|
+
this.renderer.domElement.addEventListener('pointerdown', this._onPointerDown, { capture: true, passive: true });
|
|
398
641
|
this.renderer.domElement.addEventListener('pointerup', this._onPointerUp, { passive: true });
|
|
399
642
|
this.renderer.domElement.addEventListener('pointermove', this._onPointerMove, { passive: true });
|
|
400
643
|
|
|
401
644
|
this._onControlsStart = () => {
|
|
402
645
|
// Инициализируем предыдущий вектор направления вида
|
|
403
646
|
if (!this.camera || !this.controls) return;
|
|
647
|
+
// Если стартовали вращение ЛКМ после zoom-to-cursor, то target мог сместиться к "углу".
|
|
648
|
+
// Возвращаем pivot к центру модели (как при загрузке), сохраняя кадр.
|
|
649
|
+
if (this._lastPointerDownButton === 0) this.#rebaseRotatePivotToModelCenterIfNeeded();
|
|
404
650
|
const dir = this.camera.position.clone().sub(this.controls.target).normalize();
|
|
405
651
|
this._prevViewDir = dir;
|
|
406
652
|
this._smoothedAxis = null;
|
|
@@ -445,6 +691,7 @@ export class Viewer {
|
|
|
445
691
|
this._home.flatShading = this.flatShading;
|
|
446
692
|
this._home.quality = this.quality;
|
|
447
693
|
this._home.clipEnabled = this.clipping.planes.map(p => p.constant);
|
|
694
|
+
// Модель может быть ещё не загружена — modelTransform снимем после replaceWithModel()
|
|
448
695
|
|
|
449
696
|
// Сигнал о готовности после первого кадра
|
|
450
697
|
requestAnimationFrame(() => {
|
|
@@ -523,6 +770,15 @@ export class Viewer {
|
|
|
523
770
|
try { this.renderer.domElement.removeEventListener('pointerup', this._onPointerUp); } catch(_) {}
|
|
524
771
|
try { this.renderer.domElement.removeEventListener('pointermove', this._onPointerMove); } catch(_) {}
|
|
525
772
|
}
|
|
773
|
+
// Снимем wheel zoom-to-cursor
|
|
774
|
+
try { this._zoomToCursor?.controller?.dispose?.(); } catch (_) {}
|
|
775
|
+
if (this._zoomToCursor) this._zoomToCursor.controller = null;
|
|
776
|
+
// Снимем MMB-pan
|
|
777
|
+
try { this._mmbPan?.controller?.dispose?.(); } catch (_) {}
|
|
778
|
+
if (this._mmbPan) this._mmbPan.controller = null;
|
|
779
|
+
// Снимем RMB model move
|
|
780
|
+
try { this._rmbModelMove?.controller?.dispose?.(); } catch (_) {}
|
|
781
|
+
if (this._rmbModelMove) this._rmbModelMove.controller = null;
|
|
526
782
|
if (this.controls) {
|
|
527
783
|
try { this.controls.removeEventListener('start', this._onControlsStart); } catch(_) {}
|
|
528
784
|
try { this.controls.removeEventListener('change', this._onControlsChange); } catch(_) {}
|
|
@@ -684,8 +940,8 @@ export class Viewer {
|
|
|
684
940
|
|
|
685
941
|
const newMin = Math.max(0.01, fitDist * Math.max(0.0, minRatio));
|
|
686
942
|
const newMax = Math.max(newMin * 1.5, fitDist * Math.max(1.0, slack));
|
|
687
|
-
this.controls.minDistance = newMin;
|
|
688
943
|
this.controls.maxDistance = newMax;
|
|
944
|
+
this.controls.minDistance = newMin;
|
|
689
945
|
|
|
690
946
|
// Настройка near/far для стабильной глубины (уменьшает z-fighting на тонких/накладных деталях).
|
|
691
947
|
// Важно: far должен быть "как можно меньше", но достаточен для maxDistance.
|
|
@@ -771,6 +1027,9 @@ export class Viewer {
|
|
|
771
1027
|
if (this._ssaoPass?.setSize) {
|
|
772
1028
|
try { this._ssaoPass.setSize(width, height); } catch (_) {}
|
|
773
1029
|
}
|
|
1030
|
+
|
|
1031
|
+
// Если активен MMB-pan (viewOffset), нужно переустановить его под новый размер
|
|
1032
|
+
try { this._mmbPan?.controller?.applyCurrentOffset?.(width, height); } catch (_) {}
|
|
774
1033
|
}
|
|
775
1034
|
|
|
776
1035
|
// ================= Projection (Perspective / Ortho) =================
|
|
@@ -838,6 +1097,9 @@ export class Viewer {
|
|
|
838
1097
|
this.controls.target.copy(target);
|
|
839
1098
|
this.controls.update();
|
|
840
1099
|
|
|
1100
|
+
// ViewOffset (MMB-pan) должен примениться к новой камере, если он включён
|
|
1101
|
+
try { this._mmbPan?.controller?.applyCurrentOffset?.(); } catch (_) {}
|
|
1102
|
+
|
|
841
1103
|
// Внутренние зависимости, которые держат ссылку на camera
|
|
842
1104
|
if (this.navCube) this.navCube.mainCamera = this.camera;
|
|
843
1105
|
try {
|
|
@@ -955,6 +1217,21 @@ export class Viewer {
|
|
|
955
1217
|
this._home.flatShading = this.flatShading;
|
|
956
1218
|
this._home.quality = this.quality;
|
|
957
1219
|
this._home.clipEnabled = this.clipping.planes.map(p => p.constant);
|
|
1220
|
+
|
|
1221
|
+
// Снимем исходный трансформ модели для Home (ПКМ-сдвиги должны сбрасываться)
|
|
1222
|
+
try {
|
|
1223
|
+
const m = this.activeModel;
|
|
1224
|
+
if (m) {
|
|
1225
|
+
this._home.modelTransform = {
|
|
1226
|
+
position: m.position.clone(),
|
|
1227
|
+
quaternion: m.quaternion.clone(),
|
|
1228
|
+
scale: m.scale.clone(),
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
} catch (_) {}
|
|
1232
|
+
|
|
1233
|
+
// После загрузки модели сбрасываем "фиксированную ось" от ПКМ
|
|
1234
|
+
try { if (this._rmbModelMove) this._rmbModelMove.pivotAnchor = null; } catch (_) {}
|
|
958
1235
|
});
|
|
959
1236
|
} catch(_) {}
|
|
960
1237
|
}
|
|
@@ -2785,9 +3062,29 @@ export class Viewer {
|
|
|
2785
3062
|
// Вернуть стартовый вид
|
|
2786
3063
|
goHome() {
|
|
2787
3064
|
if (!this.camera || !this.controls) return;
|
|
3065
|
+
|
|
3066
|
+
// Сброс MMB-pan (viewOffset), чтобы оси/вид вернулись как при загрузке
|
|
3067
|
+
try { this._mmbPan?.controller?.reset?.(); } catch (_) {}
|
|
3068
|
+
|
|
2788
3069
|
// Камера и прицел
|
|
2789
3070
|
this.controls.target.copy(this._home.target);
|
|
2790
3071
|
this.camera.position.copy(this._home.cameraPos);
|
|
3072
|
+
|
|
3073
|
+
// Сброс трансформа модели (ПКМ-сдвиг): вернуть как при загрузке
|
|
3074
|
+
try {
|
|
3075
|
+
const mt = this._home?.modelTransform;
|
|
3076
|
+
const m = this.activeModel;
|
|
3077
|
+
if (m && mt && mt.position && mt.quaternion && mt.scale) {
|
|
3078
|
+
m.position.copy(mt.position);
|
|
3079
|
+
m.quaternion.copy(mt.quaternion);
|
|
3080
|
+
m.scale.copy(mt.scale);
|
|
3081
|
+
m.updateMatrixWorld?.(true);
|
|
3082
|
+
}
|
|
3083
|
+
} catch (_) {}
|
|
3084
|
+
|
|
3085
|
+
// Home: сбрасываем "фиксированную ось" от ПКМ
|
|
3086
|
+
try { if (this._rmbModelMove) this._rmbModelMove.pivotAnchor = null; } catch (_) {}
|
|
3087
|
+
|
|
2791
3088
|
// Визуальные настройки
|
|
2792
3089
|
this.setEdgesVisible(this._home.edgesVisible);
|
|
2793
3090
|
this.setFlatShading(this._home.flatShading);
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import * as THREE from "three";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Zoom-to-cursor для three.js камер (Perspective + Orthographic).
|
|
5
|
+
* Делает зум колёсиком относительно точки под курсором.
|
|
6
|
+
*
|
|
7
|
+
* Реализовано через:
|
|
8
|
+
* - выбор "опорной плоскости" (hit по модели, иначе плоскость через target)
|
|
9
|
+
* - изменение дистанции/zoom
|
|
10
|
+
* - компенсацию сдвига (camera + controls.target), чтобы опорная точка оставалась под курсором
|
|
11
|
+
*/
|
|
12
|
+
export class ZoomToCursorController {
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} deps
|
|
15
|
+
* @param {HTMLElement} deps.domElement
|
|
16
|
+
* @param {() => (THREE.Camera|null)} deps.getCamera
|
|
17
|
+
* @param {() => (import('three/examples/jsm/controls/OrbitControls').OrbitControls|null)} deps.getControls
|
|
18
|
+
* @param {() => (THREE.Object3D|null)} deps.getPickRoot
|
|
19
|
+
* @param {(force?: boolean) => void} [deps.onZoomChanged]
|
|
20
|
+
* @param {() => boolean} [deps.isEnabled]
|
|
21
|
+
* @param {() => boolean} [deps.isDebug]
|
|
22
|
+
*/
|
|
23
|
+
constructor(deps) {
|
|
24
|
+
this.domElement = deps.domElement;
|
|
25
|
+
this.getCamera = deps.getCamera;
|
|
26
|
+
this.getControls = deps.getControls;
|
|
27
|
+
this.getPickRoot = deps.getPickRoot;
|
|
28
|
+
this.onZoomChanged = deps.onZoomChanged || null;
|
|
29
|
+
this.isEnabled = deps.isEnabled || (() => true);
|
|
30
|
+
this.isDebug = deps.isDebug || (() => false);
|
|
31
|
+
|
|
32
|
+
this._raycaster = new THREE.Raycaster();
|
|
33
|
+
this._ndc = new THREE.Vector2();
|
|
34
|
+
this._plane = new THREE.Plane();
|
|
35
|
+
|
|
36
|
+
this._vDir = new THREE.Vector3();
|
|
37
|
+
this._vA = new THREE.Vector3();
|
|
38
|
+
this._vB = new THREE.Vector3();
|
|
39
|
+
this._vC = new THREE.Vector3();
|
|
40
|
+
this._vBefore = new THREE.Vector3();
|
|
41
|
+
this._vAfter = new THREE.Vector3();
|
|
42
|
+
|
|
43
|
+
/** @type {(e: WheelEvent) => void} */
|
|
44
|
+
this._onWheel = (e) => this.#handleWheel(e);
|
|
45
|
+
this.domElement.addEventListener("wheel", this._onWheel, { capture: true, passive: false });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
dispose() {
|
|
49
|
+
try {
|
|
50
|
+
this.domElement.removeEventListener("wheel", this._onWheel, { capture: true });
|
|
51
|
+
} catch (_) {
|
|
52
|
+
try {
|
|
53
|
+
// fallback для браузеров/реализаций, которые игнорируют options при remove
|
|
54
|
+
this.domElement.removeEventListener("wheel", this._onWheel);
|
|
55
|
+
} catch (_) {}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#handleWheel(e) {
|
|
60
|
+
if (!this.isEnabled()) return;
|
|
61
|
+
const camera = this.getCamera?.();
|
|
62
|
+
const controls = this.getControls?.();
|
|
63
|
+
if (!camera || !controls) return;
|
|
64
|
+
|
|
65
|
+
// Предотвратить скролл страницы и работу OrbitControls (у него свой wheel listener)
|
|
66
|
+
e.preventDefault();
|
|
67
|
+
e.stopPropagation();
|
|
68
|
+
// eslint-disable-next-line no-undef
|
|
69
|
+
if (typeof e.stopImmediatePropagation === "function") e.stopImmediatePropagation();
|
|
70
|
+
|
|
71
|
+
const rect = this.domElement.getBoundingClientRect?.();
|
|
72
|
+
if (!rect || rect.width <= 0 || rect.height <= 0) return;
|
|
73
|
+
|
|
74
|
+
// --- NDC курсора ---
|
|
75
|
+
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
76
|
+
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
|
|
77
|
+
this._ndc.set(x, y);
|
|
78
|
+
|
|
79
|
+
// --- нормализуем deltaY для разных режимов прокрутки ---
|
|
80
|
+
let dy = e.deltaY;
|
|
81
|
+
if (e.deltaMode === 1) dy *= 16; // lines -> px (примерно)
|
|
82
|
+
else if (e.deltaMode === 2) dy *= 100; // pages -> px (грубо)
|
|
83
|
+
|
|
84
|
+
const zoomSpeed = Number(controls.zoomSpeed ?? 1);
|
|
85
|
+
const speed = Number.isFinite(zoomSpeed) ? zoomSpeed : 1;
|
|
86
|
+
// scale > 1 => zoom out, scale < 1 => zoom in
|
|
87
|
+
let scale = Math.exp(dy * 0.002 * speed);
|
|
88
|
+
// защита от экстремальных значений
|
|
89
|
+
scale = Math.min(5, Math.max(0.2, scale));
|
|
90
|
+
|
|
91
|
+
// --- Опорная плоскость: hit по модели, иначе плоскость через target ---
|
|
92
|
+
// ВАЖНО: для "пролёта" внутрь здания нам нельзя зависеть от ближайшей стены.
|
|
93
|
+
// Поэтому при попытке приблизиться дальше minDistance мы используем target-plane,
|
|
94
|
+
// а "излишек" приближения превращаем в поступательное движение вперёд (camera+target).
|
|
95
|
+
const pickRoot = this.getPickRoot?.();
|
|
96
|
+
let anchor = null;
|
|
97
|
+
let source = "target-plane";
|
|
98
|
+
const flyMinEps = 1e-6;
|
|
99
|
+
|
|
100
|
+
// Определим, упёрлись ли мы в minDistance и пытаемся приблизиться ещё
|
|
101
|
+
let distToTargetNow = null;
|
|
102
|
+
let minDNow = null;
|
|
103
|
+
let wantFlyForward = false;
|
|
104
|
+
if (camera.isPerspectiveCamera) {
|
|
105
|
+
distToTargetNow = camera.position.distanceTo(controls.target);
|
|
106
|
+
minDNow = controls.minDistance || 0.01;
|
|
107
|
+
// scale < 1 => приближение
|
|
108
|
+
const desired = distToTargetNow * scale;
|
|
109
|
+
wantFlyForward = scale < 1 && desired < (minDNow - flyMinEps);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 1) пробуем попадание по модели (только если не в режиме fly-forward)
|
|
113
|
+
if (pickRoot && !wantFlyForward) {
|
|
114
|
+
this._raycaster.setFromCamera(this._ndc, camera);
|
|
115
|
+
const hits = this._raycaster.intersectObject(pickRoot, true);
|
|
116
|
+
if (hits && hits.length > 0 && hits[0] && hits[0].point) {
|
|
117
|
+
anchor = this._vA.copy(hits[0].point);
|
|
118
|
+
source = "model-hit";
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 2) если по модели не попали — используем target как точку на плоскости
|
|
123
|
+
if (!anchor) {
|
|
124
|
+
anchor = this._vA.copy(controls.target);
|
|
125
|
+
if (wantFlyForward) source = "fly-target";
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Для корректной диагностики проекций/лучей обновим матрицы (не меняет поведения)
|
|
129
|
+
try { camera.updateMatrixWorld?.(true); } catch (_) {}
|
|
130
|
+
|
|
131
|
+
// Плоскость перпендикулярна направлению взгляда.
|
|
132
|
+
// ВАЖНО: при сильном приближении камера может "пересечь" плоскость через anchor,
|
|
133
|
+
// и ray.intersectPlane() начнёт возвращать null (пересечение "позади" луча),
|
|
134
|
+
// что раньше полностью "останавливало" зум. Поэтому:
|
|
135
|
+
// - сначала пробуем anchor-plane
|
|
136
|
+
// - если не получилось — переключаемся на fallback-plane перед камерой
|
|
137
|
+
// - если всё равно не получилось — продолжаем зум БЕЗ cursor-компенсации (но зум не замирает)
|
|
138
|
+
camera.getWorldDirection(this._vDir).normalize();
|
|
139
|
+
let planeKind = "anchor-plane";
|
|
140
|
+
this._plane.setFromNormalAndCoplanarPoint(this._vDir, anchor);
|
|
141
|
+
|
|
142
|
+
// Точка под курсором ДО зума (пересечение луча курсора с плоскостью)
|
|
143
|
+
this._raycaster.setFromCamera(this._ndc, camera);
|
|
144
|
+
let okBefore = this._raycaster.ray.intersectPlane(this._plane, this._vBefore);
|
|
145
|
+
|
|
146
|
+
if (!okBefore) {
|
|
147
|
+
// Fallback-plane: гарантированно "перед" камерой по направлению взгляда
|
|
148
|
+
planeKind = "camera-plane";
|
|
149
|
+
const distToTarget = camera.position.distanceTo(controls.target);
|
|
150
|
+
const depth = Math.max(0.01, (camera.near || 0.01) * 2, distToTarget);
|
|
151
|
+
const p = this._vC.copy(camera.position).add(this._vB.copy(this._vDir).multiplyScalar(depth));
|
|
152
|
+
this._plane.setFromNormalAndCoplanarPoint(this._vDir, p);
|
|
153
|
+
okBefore = this._raycaster.ray.intersectPlane(this._plane, this._vBefore);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const canCompensate = !!okBefore;
|
|
157
|
+
|
|
158
|
+
// Диагностика: посчитаем дрейф anchor в пикселях (до/после) — то, что вы визуально наблюдаете
|
|
159
|
+
let debugPayload = null;
|
|
160
|
+
if (this.isDebug()) {
|
|
161
|
+
const anchorNdc0 = this._vB.copy(anchor).project(camera);
|
|
162
|
+
const driftPx0 = {
|
|
163
|
+
dx: (anchorNdc0.x - this._ndc.x) * (rect.width / 2),
|
|
164
|
+
dy: (anchorNdc0.y - this._ndc.y) * (rect.height / 2),
|
|
165
|
+
};
|
|
166
|
+
debugPayload = {
|
|
167
|
+
src: source,
|
|
168
|
+
mode: camera.isOrthographicCamera ? "ortho" : "perspective",
|
|
169
|
+
plane: planeKind,
|
|
170
|
+
canCompensate,
|
|
171
|
+
dy,
|
|
172
|
+
scale: +scale.toFixed(4),
|
|
173
|
+
cursorNdc: { x: +this._ndc.x.toFixed(4), y: +this._ndc.y.toFixed(4) },
|
|
174
|
+
anchorWorld: { x: +anchor.x.toFixed(3), y: +anchor.y.toFixed(3), z: +anchor.z.toFixed(3) },
|
|
175
|
+
anchorNdcBefore: { x: +anchorNdc0.x.toFixed(4), y: +anchorNdc0.y.toFixed(4), z: +anchorNdc0.z.toFixed(4) },
|
|
176
|
+
anchorDriftPxBefore: { dx: +driftPx0.dx.toFixed(2), dy: +driftPx0.dy.toFixed(2) },
|
|
177
|
+
viewDir: { x: +this._vDir.x.toFixed(4), y: +this._vDir.y.toFixed(4), z: +this._vDir.z.toFixed(4) },
|
|
178
|
+
rayDir: {
|
|
179
|
+
x: +this._raycaster.ray.direction.x.toFixed(4),
|
|
180
|
+
y: +this._raycaster.ray.direction.y.toFixed(4),
|
|
181
|
+
z: +this._raycaster.ray.direction.z.toFixed(4),
|
|
182
|
+
},
|
|
183
|
+
camPosBefore: {
|
|
184
|
+
x: +camera.position.x.toFixed(3),
|
|
185
|
+
y: +camera.position.y.toFixed(3),
|
|
186
|
+
z: +camera.position.z.toFixed(3),
|
|
187
|
+
},
|
|
188
|
+
targetBefore: {
|
|
189
|
+
x: +controls.target.x.toFixed(3),
|
|
190
|
+
y: +controls.target.y.toFixed(3),
|
|
191
|
+
z: +controls.target.z.toFixed(3),
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const t0 = this.isDebug() ? performance.now() : 0;
|
|
197
|
+
|
|
198
|
+
// --- Применяем зум ---
|
|
199
|
+
if (camera.isOrthographicCamera) {
|
|
200
|
+
const minZ = controls.minZoom ?? 0.01;
|
|
201
|
+
const maxZ = controls.maxZoom ?? 100;
|
|
202
|
+
const curr = camera.zoom || 1;
|
|
203
|
+
const next = curr / scale; // scale>1 => zoom out (уменьшаем zoom)
|
|
204
|
+
camera.zoom = Math.min(Math.max(next, minZ), maxZ);
|
|
205
|
+
camera.updateProjectionMatrix();
|
|
206
|
+
} else {
|
|
207
|
+
const target = controls.target;
|
|
208
|
+
const camPos = camera.position;
|
|
209
|
+
const dir = this._vB.copy(camPos).sub(target).normalize(); // от target к камере
|
|
210
|
+
const dist = camPos.distanceTo(target);
|
|
211
|
+
const minD = controls.minDistance || 0.01;
|
|
212
|
+
const maxD = controls.maxDistance || Infinity;
|
|
213
|
+
const desiredDist = dist * scale; // scale>1 => zoom out (увеличиваем дистанцию), scale<1 => zoom in
|
|
214
|
+
let nextDist = Math.min(Math.max(desiredDist, minD), maxD);
|
|
215
|
+
|
|
216
|
+
// Базовый dolly до minDistance
|
|
217
|
+
camPos.copy(this._vC.copy(target).add(dir.multiplyScalar(nextDist)));
|
|
218
|
+
|
|
219
|
+
// Fly-through: преднамеренный "пролёт" дальше minDistance (только на zoom-in)
|
|
220
|
+
// Излишек приближения превращаем в движение вперёд (camera+target) вдоль направления взгляда.
|
|
221
|
+
// Это снимает "упор" в minDistance и позволяет проходить через стены/внутрь помещений.
|
|
222
|
+
if (scale < 1 && desiredDist < minD - flyMinEps) {
|
|
223
|
+
const forward = this._vC.copy(target).sub(camPos).normalize(); // от камеры к target
|
|
224
|
+
const leftover = (minD - desiredDist);
|
|
225
|
+
// Сдвигаем и камеру, и target: дистанция остаётся minD, но мы "летим" вперёд
|
|
226
|
+
camPos.add(this._vA.copy(forward).multiplyScalar(leftover));
|
|
227
|
+
target.add(this._vA.copy(forward).multiplyScalar(leftover));
|
|
228
|
+
camera.updateProjectionMatrix();
|
|
229
|
+
}
|
|
230
|
+
camera.updateProjectionMatrix();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Матрицы могли устареть после изменения позиции/zoom
|
|
234
|
+
try { camera.updateMatrixWorld?.(true); } catch (_) {}
|
|
235
|
+
|
|
236
|
+
// --- Компенсация: чтобы точка на плоскости осталась под курсором ---
|
|
237
|
+
let didCompensate = false;
|
|
238
|
+
if (canCompensate) {
|
|
239
|
+
this._raycaster.setFromCamera(this._ndc, camera);
|
|
240
|
+
const okAfter = this._raycaster.ray.intersectPlane(this._plane, this._vAfter);
|
|
241
|
+
if (okAfter) {
|
|
242
|
+
const delta = this._vC.copy(this._vBefore).sub(this._vAfter);
|
|
243
|
+
camera.position.add(delta);
|
|
244
|
+
controls.target.add(delta);
|
|
245
|
+
didCompensate = true;
|
|
246
|
+
} else {
|
|
247
|
+
// Нельзя компенсировать на этом шаге (например, камера пересекла плоскость) —
|
|
248
|
+
// но зум уже применён, поэтому не останавливаемся.
|
|
249
|
+
didCompensate = false;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
controls.update();
|
|
254
|
+
if (this.onZoomChanged) this.onZoomChanged(true);
|
|
255
|
+
|
|
256
|
+
if (this.isDebug()) {
|
|
257
|
+
const ms = performance.now() - t0;
|
|
258
|
+
const anchorNdc1 = this._vB.copy(anchor).project(camera);
|
|
259
|
+
const driftPx1 = {
|
|
260
|
+
dx: (anchorNdc1.x - this._ndc.x) * (rect.width / 2),
|
|
261
|
+
dy: (anchorNdc1.y - this._ndc.y) * (rect.height / 2),
|
|
262
|
+
};
|
|
263
|
+
const out = debugPayload || {};
|
|
264
|
+
out.dtMs = +ms.toFixed(2);
|
|
265
|
+
out.didCompensate = didCompensate;
|
|
266
|
+
out.anchorNdcAfter = { x: +anchorNdc1.x.toFixed(4), y: +anchorNdc1.y.toFixed(4), z: +anchorNdc1.z.toFixed(4) };
|
|
267
|
+
out.anchorDriftPxAfter = { dx: +driftPx1.dx.toFixed(2), dy: +driftPx1.dy.toFixed(2) };
|
|
268
|
+
out.camPosAfter = {
|
|
269
|
+
x: +camera.position.x.toFixed(3),
|
|
270
|
+
y: +camera.position.y.toFixed(3),
|
|
271
|
+
z: +camera.position.z.toFixed(3),
|
|
272
|
+
};
|
|
273
|
+
out.targetAfter = {
|
|
274
|
+
x: +controls.target.x.toFixed(3),
|
|
275
|
+
y: +controls.target.y.toFixed(3),
|
|
276
|
+
z: +controls.target.z.toFixed(3),
|
|
277
|
+
};
|
|
278
|
+
out.zoom = camera.isOrthographicCamera ? +(camera.zoom || 1).toFixed(4) : null;
|
|
279
|
+
// eslint-disable-next-line no-console
|
|
280
|
+
console.log("[ZoomToCursor]", out);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
|