@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.32.0",
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(() => { ifc.dispose(); viewer.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
+
@@ -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) => { if (e.button === 0) this._isLmbDown = true; };
385
- this._onPointerUp = (e) => { if (e.button === 0) { this._isLmbDown = false; this.#hideRotationAxisLine(); } };
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
- this.renderer.domElement.addEventListener('pointerdown', this._onPointerDown, { passive: true });
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
+