@sequent-org/ifc-viewer 1.0.2-ci.2.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/.github/workflows/npm-publish.yml +39 -0
- package/console-log.txt +1924 -0
- package/fragments.html +46 -0
- package/index.html +101 -0
- package/package.json +27 -0
- package/postcss.config.cjs +7 -0
- package/public/wasm/web-ifc.wasm +0 -0
- package/src/compat/three-buffer-geometry-utils.js +39 -0
- package/src/fragments-main.js +22 -0
- package/src/ifc/IfcService.js +268 -0
- package/src/ifc/IfcTreeView.js +96 -0
- package/src/main.js +216 -0
- package/src/style.css +2 -0
- package/src/viewer/NavCube.js +395 -0
- package/src/viewer/SectionManipulator.js +260 -0
- package/src/viewer/Viewer.js +880 -0
- package/tailwind.config.cjs +5 -0
- package/vite.config.js +36 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
// Класс Viewer инкапсулирует настройку three.js сцены
|
|
2
|
+
// Чистый JS, без фреймворков. Комментарии на русском.
|
|
3
|
+
|
|
4
|
+
import * as THREE from "three";
|
|
5
|
+
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
6
|
+
import { NavCube } from "./NavCube.js";
|
|
7
|
+
import { SectionManipulator } from "./SectionManipulator.js";
|
|
8
|
+
|
|
9
|
+
export class Viewer {
|
|
10
|
+
constructor(containerElement) {
|
|
11
|
+
/** @type {HTMLElement} */
|
|
12
|
+
this.container = containerElement;
|
|
13
|
+
|
|
14
|
+
this.renderer = null;
|
|
15
|
+
this.scene = null;
|
|
16
|
+
this.camera = null;
|
|
17
|
+
this.animationId = null;
|
|
18
|
+
this.controls = null;
|
|
19
|
+
this.zoomListeners = new Set();
|
|
20
|
+
this.lastZoomPercent = null;
|
|
21
|
+
this.resizeObserver = null;
|
|
22
|
+
this.demoCube = null;
|
|
23
|
+
this.activeModel = null;
|
|
24
|
+
this.autoRotateDemo = true;
|
|
25
|
+
this.edgesVisible = true;
|
|
26
|
+
this.flatShading = true;
|
|
27
|
+
this.quality = 'medium'; // low | medium | high
|
|
28
|
+
this.navCube = null;
|
|
29
|
+
this.sectionOverlayScene = null;
|
|
30
|
+
this.clipping = {
|
|
31
|
+
enabled: false,
|
|
32
|
+
planes: [
|
|
33
|
+
new THREE.Plane(new THREE.Vector3(1, 0, 0), Infinity),
|
|
34
|
+
new THREE.Plane(new THREE.Vector3(0, 1, 0), Infinity),
|
|
35
|
+
new THREE.Plane(new THREE.Vector3(0, 0, 1), Infinity),
|
|
36
|
+
],
|
|
37
|
+
gizmos: {
|
|
38
|
+
x: null,
|
|
39
|
+
y: null,
|
|
40
|
+
z: null,
|
|
41
|
+
},
|
|
42
|
+
manipulators: {
|
|
43
|
+
x: null,
|
|
44
|
+
y: null,
|
|
45
|
+
z: null,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Snapshot начального состояния для Home
|
|
50
|
+
this._home = {
|
|
51
|
+
cameraPos: null,
|
|
52
|
+
target: new THREE.Vector3(0, 0, 0),
|
|
53
|
+
edgesVisible: true,
|
|
54
|
+
flatShading: true,
|
|
55
|
+
quality: 'medium',
|
|
56
|
+
clipEnabled: [Infinity, Infinity, Infinity],
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// Визуализация оси вращения
|
|
60
|
+
this.rotationAxisLine = null;
|
|
61
|
+
this._isLmbDown = false;
|
|
62
|
+
this._wasRotating = false;
|
|
63
|
+
this._prevViewDir = null;
|
|
64
|
+
this._smoothedAxis = null;
|
|
65
|
+
this._recentPointerDelta = 0;
|
|
66
|
+
this._pointerPxThreshold = 2; // минимальный экранный сдвиг
|
|
67
|
+
this._rotAngleEps = 0.01; // ~0.57° минимальный угловой сдвиг
|
|
68
|
+
this._axisEmaAlpha = 0.15; // коэффициент сглаживания оси
|
|
69
|
+
|
|
70
|
+
this._onPointerDown = null;
|
|
71
|
+
this._onPointerUp = null;
|
|
72
|
+
this._onPointerMove = null;
|
|
73
|
+
this._onControlsStart = null;
|
|
74
|
+
this._onControlsChange = null;
|
|
75
|
+
this._onControlsEnd = null;
|
|
76
|
+
|
|
77
|
+
this.handleResize = this.handleResize.bind(this);
|
|
78
|
+
this.animate = this.animate.bind(this);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
init() {
|
|
82
|
+
if (!this.container) throw new Error("Viewer: контейнер не найден");
|
|
83
|
+
|
|
84
|
+
// Рендерер
|
|
85
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
|
86
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
87
|
+
this.renderer.autoClear = false; // управляем очисткой вручную для мульти-проходов
|
|
88
|
+
// Спрячем канвас до первого корректного измерения
|
|
89
|
+
this.renderer.domElement.style.visibility = "hidden";
|
|
90
|
+
this.renderer.domElement.style.display = "block";
|
|
91
|
+
this.renderer.domElement.style.width = "100%";
|
|
92
|
+
this.renderer.domElement.style.height = "100%";
|
|
93
|
+
this.container.appendChild(this.renderer.domElement);
|
|
94
|
+
|
|
95
|
+
// Сцена
|
|
96
|
+
this.scene = new THREE.Scene();
|
|
97
|
+
// Оверлей-сцена для секущих манипуляторов (без клиппинга)
|
|
98
|
+
this.sectionOverlayScene = new THREE.Scene();
|
|
99
|
+
|
|
100
|
+
// Камера
|
|
101
|
+
const width = this.container.clientWidth || window.innerWidth;
|
|
102
|
+
const height = this.container.clientHeight || window.innerHeight;
|
|
103
|
+
const aspect = width / height;
|
|
104
|
+
this.camera = new THREE.PerspectiveCamera(60, aspect, 0.1, 1000);
|
|
105
|
+
this.camera.position.set(-22.03, 23.17, 39.12);
|
|
106
|
+
this.camera.lookAt(0, 0, 0);
|
|
107
|
+
|
|
108
|
+
// OrbitControls
|
|
109
|
+
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
110
|
+
this.controls.enableDamping = true;
|
|
111
|
+
this.controls.target.set(0, 0, 0);
|
|
112
|
+
this.controls.minDistance = 1;
|
|
113
|
+
this.controls.maxDistance = 20;
|
|
114
|
+
|
|
115
|
+
// Свет
|
|
116
|
+
this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
|
|
117
|
+
const dir = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
118
|
+
dir.position.set(5, 5, 5);
|
|
119
|
+
this.scene.add(dir);
|
|
120
|
+
|
|
121
|
+
// Демонстрационный куб отключён для чистого прелоадера
|
|
122
|
+
// Оставим сцену пустой до загрузки модели
|
|
123
|
+
// Добавим метод фокусировки объекта
|
|
124
|
+
this.focusObject = (object3D, padding = 1.2) => {
|
|
125
|
+
if (!object3D || !this.camera || !this.controls) return;
|
|
126
|
+
const box = new THREE.Box3().setFromObject(object3D);
|
|
127
|
+
const size = box.getSize(new THREE.Vector3());
|
|
128
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
129
|
+
// Вычисляем дистанцию вписывания с учётом аспекта
|
|
130
|
+
const dist = this.#computeFitDistanceForSize(size, padding);
|
|
131
|
+
const dir = this.camera.position.clone().sub(this.controls.target).normalize();
|
|
132
|
+
this.controls.target.copy(center);
|
|
133
|
+
this.camera.position.copy(center.clone().add(dir.multiplyScalar(dist)));
|
|
134
|
+
this.camera.updateProjectionMatrix();
|
|
135
|
+
this.controls.update();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Навигационный куб (интерактивный overlay в правом верхнем углу)
|
|
139
|
+
this.navCube = new NavCube(this.renderer, this.camera, this.controls, this.container, {
|
|
140
|
+
sizePx: 96,
|
|
141
|
+
marginPx: 10,
|
|
142
|
+
opacity: 0.6,
|
|
143
|
+
onHome: () => this.goHome(),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// Визуальная ось вращения: события мыши и контролов
|
|
147
|
+
this._onPointerDown = (e) => { if (e.button === 0) this._isLmbDown = true; };
|
|
148
|
+
this._onPointerUp = (e) => { if (e.button === 0) { this._isLmbDown = false; this.#hideRotationAxisLine(); } };
|
|
149
|
+
this._onPointerMove = (e) => {
|
|
150
|
+
const rect = this.renderer?.domElement?.getBoundingClientRect?.();
|
|
151
|
+
if (!rect) return;
|
|
152
|
+
// Копим абсолютный сдвиг курсора для простого порога
|
|
153
|
+
const now = { x: e.clientX, y: e.clientY };
|
|
154
|
+
if (!this._lastPointer) this._lastPointer = now;
|
|
155
|
+
const dx = Math.abs(now.x - this._lastPointer.x);
|
|
156
|
+
const dy = Math.abs(now.y - this._lastPointer.y);
|
|
157
|
+
this._recentPointerDelta = dx + dy;
|
|
158
|
+
this._lastPointer = now;
|
|
159
|
+
};
|
|
160
|
+
this.renderer.domElement.addEventListener('pointerdown', this._onPointerDown, { passive: true });
|
|
161
|
+
this.renderer.domElement.addEventListener('pointerup', this._onPointerUp, { passive: true });
|
|
162
|
+
this.renderer.domElement.addEventListener('pointermove', this._onPointerMove, { passive: true });
|
|
163
|
+
|
|
164
|
+
this._onControlsStart = () => {
|
|
165
|
+
// Инициализируем предыдущий вектор направления вида
|
|
166
|
+
if (!this.camera || !this.controls) return;
|
|
167
|
+
const dir = this.camera.position.clone().sub(this.controls.target).normalize();
|
|
168
|
+
this._prevViewDir = dir;
|
|
169
|
+
this._smoothedAxis = null;
|
|
170
|
+
};
|
|
171
|
+
this._onControlsChange = () => {
|
|
172
|
+
// Обновляем ось только при зажатой ЛКМ (вращение)
|
|
173
|
+
if (!this._isLmbDown) return;
|
|
174
|
+
this.#updateRotationAxisLine();
|
|
175
|
+
};
|
|
176
|
+
this._onControlsEnd = () => { this.#hideRotationAxisLine(); };
|
|
177
|
+
this.controls.addEventListener('start', this._onControlsStart);
|
|
178
|
+
this.controls.addEventListener('change', this._onControlsChange);
|
|
179
|
+
this.controls.addEventListener('end', this._onControlsEnd);
|
|
180
|
+
|
|
181
|
+
// Визуализация секущих плоскостей (манипуляторы с квадратиком и стрелкой)
|
|
182
|
+
this.#initClippingGizmos();
|
|
183
|
+
|
|
184
|
+
// Обработчики изменения размеров
|
|
185
|
+
window.addEventListener("resize", this.handleResize);
|
|
186
|
+
this.resizeObserver = new ResizeObserver((entries) => {
|
|
187
|
+
for (const entry of entries) {
|
|
188
|
+
const cr = entry.contentRect;
|
|
189
|
+
const w = Math.max(1, Math.floor(cr.width));
|
|
190
|
+
const h = Math.max(1, Math.floor(cr.height));
|
|
191
|
+
this._updateSize(w, h);
|
|
192
|
+
// После первого валидного измерения показываем канвас
|
|
193
|
+
this.renderer.domElement.style.visibility = "visible";
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
this.resizeObserver.observe(this.container);
|
|
197
|
+
// Первичная попытка подгонки
|
|
198
|
+
const { width: initW, height: initH } = this._getContainerSize();
|
|
199
|
+
this._updateSize(Math.max(1, initW), Math.max(1, initH));
|
|
200
|
+
|
|
201
|
+
// Старт цикла
|
|
202
|
+
this.animate();
|
|
203
|
+
|
|
204
|
+
// Сохраним Home-снапшот после инициализации
|
|
205
|
+
this._home.cameraPos = this.camera.position.clone();
|
|
206
|
+
this._home.target = this.controls.target.clone();
|
|
207
|
+
this._home.edgesVisible = this.edgesVisible;
|
|
208
|
+
this._home.flatShading = this.flatShading;
|
|
209
|
+
this._home.quality = this.quality;
|
|
210
|
+
this._home.clipEnabled = this.clipping.planes.map(p => p.constant);
|
|
211
|
+
|
|
212
|
+
// Сигнал о готовности после первого кадра
|
|
213
|
+
requestAnimationFrame(() => {
|
|
214
|
+
this._dispatchReady();
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
handleResize() {
|
|
219
|
+
if (!this.container || !this.camera || !this.renderer) return;
|
|
220
|
+
const { width, height } = this._getContainerSize();
|
|
221
|
+
this._updateSize(Math.max(1, width), Math.max(1, height));
|
|
222
|
+
// Обновим пределы зума под текущий объект без переразмещения камеры
|
|
223
|
+
const subject = this.activeModel || this.demoCube;
|
|
224
|
+
if (subject) this.applyAdaptiveZoomLimits(subject, { recenter: false });
|
|
225
|
+
// Обновим вспомогательные overlay-виджеты
|
|
226
|
+
if (this.navCube) this.navCube.onResize();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
animate() {
|
|
230
|
+
if (this.autoRotateDemo && this.demoCube) {
|
|
231
|
+
this.demoCube.rotation.y += 0.01;
|
|
232
|
+
this.demoCube.rotation.x += 0.005;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (this.controls) this.controls.update();
|
|
236
|
+
this._notifyZoomIfChanged();
|
|
237
|
+
if (this.renderer && this.camera && this.scene) {
|
|
238
|
+
// Применим ТОЛЬКО активные (конечные) плоскости отсечения
|
|
239
|
+
const activePlanes = this.clipping.planes.filter((p) => isFinite(p.constant));
|
|
240
|
+
this.renderer.clippingPlanes = activePlanes.length > 0 ? activePlanes : [];
|
|
241
|
+
this.renderer.localClippingEnabled = activePlanes.length > 0;
|
|
242
|
+
// Обновим манипуляторы секущих плоскостей
|
|
243
|
+
this.#updateClippingGizmos();
|
|
244
|
+
// Рендер основной сцены
|
|
245
|
+
this.renderer.clear(true, true, true);
|
|
246
|
+
this.renderer.render(this.scene, this.camera);
|
|
247
|
+
// Рендер оверлея манипуляторов без глобального клиппинга поверх
|
|
248
|
+
const prevLocal = this.renderer.localClippingEnabled;
|
|
249
|
+
const prevPlanes = this.renderer.clippingPlanes;
|
|
250
|
+
this.renderer.localClippingEnabled = false;
|
|
251
|
+
this.renderer.clippingPlanes = [];
|
|
252
|
+
this.renderer.clearDepth();
|
|
253
|
+
this.renderer.render(this.sectionOverlayScene, this.camera);
|
|
254
|
+
// Восстановление настроек клиппинга
|
|
255
|
+
this.renderer.localClippingEnabled = prevLocal;
|
|
256
|
+
this.renderer.clippingPlanes = prevPlanes;
|
|
257
|
+
}
|
|
258
|
+
// Рендер навигационного куба поверх основной сцены
|
|
259
|
+
if (this.navCube) this.navCube.renderOverlay();
|
|
260
|
+
this.animationId = requestAnimationFrame(this.animate);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
dispose() {
|
|
264
|
+
if (this.animationId) cancelAnimationFrame(this.animationId);
|
|
265
|
+
window.removeEventListener("resize", this.handleResize);
|
|
266
|
+
|
|
267
|
+
// Освободим манипуляторы секущих плоскостей до удаления канваса
|
|
268
|
+
if (this.clipping?.manipulators) {
|
|
269
|
+
const { x, y, z } = this.clipping.manipulators;
|
|
270
|
+
x && x.dispose && x.dispose();
|
|
271
|
+
y && y.dispose && y.dispose();
|
|
272
|
+
z && z.dispose && z.dispose();
|
|
273
|
+
this.clipping.manipulators.x = null;
|
|
274
|
+
this.clipping.manipulators.y = null;
|
|
275
|
+
this.clipping.manipulators.z = null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Снимем события оси вращения
|
|
279
|
+
if (this.renderer?.domElement) {
|
|
280
|
+
try { this.renderer.domElement.removeEventListener('pointerdown', this._onPointerDown); } catch(_) {}
|
|
281
|
+
try { this.renderer.domElement.removeEventListener('pointerup', this._onPointerUp); } catch(_) {}
|
|
282
|
+
try { this.renderer.domElement.removeEventListener('pointermove', this._onPointerMove); } catch(_) {}
|
|
283
|
+
}
|
|
284
|
+
if (this.controls) {
|
|
285
|
+
try { this.controls.removeEventListener('start', this._onControlsStart); } catch(_) {}
|
|
286
|
+
try { this.controls.removeEventListener('change', this._onControlsChange); } catch(_) {}
|
|
287
|
+
try { this.controls.removeEventListener('end', this._onControlsEnd); } catch(_) {}
|
|
288
|
+
}
|
|
289
|
+
if (this.rotationAxisLine && this.scene) {
|
|
290
|
+
try { this.scene.remove(this.rotationAxisLine); } catch(_) {}
|
|
291
|
+
if (this.rotationAxisLine.geometry?.dispose) this.rotationAxisLine.geometry.dispose();
|
|
292
|
+
if (this.rotationAxisLine.material?.dispose) this.rotationAxisLine.material.dispose();
|
|
293
|
+
this.rotationAxisLine = null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (this.renderer) {
|
|
297
|
+
this.renderer.dispose();
|
|
298
|
+
const el = this.renderer.domElement;
|
|
299
|
+
if (el && el.parentNode) el.parentNode.removeChild(el);
|
|
300
|
+
}
|
|
301
|
+
if (this.resizeObserver) {
|
|
302
|
+
this.resizeObserver.disconnect();
|
|
303
|
+
this.resizeObserver = null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (this.controls) {
|
|
307
|
+
this.controls.dispose();
|
|
308
|
+
this.controls = null;
|
|
309
|
+
}
|
|
310
|
+
if (this.navCube) {
|
|
311
|
+
this.navCube.dispose();
|
|
312
|
+
this.navCube = null;
|
|
313
|
+
}
|
|
314
|
+
if (this.scene) {
|
|
315
|
+
this.scene.traverse((obj) => {
|
|
316
|
+
if (obj.isMesh) {
|
|
317
|
+
obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
|
|
318
|
+
const m = obj.material;
|
|
319
|
+
if (Array.isArray(m)) m.forEach((mi) => mi && mi.dispose && mi.dispose());
|
|
320
|
+
else if (m && m.dispose) m.dispose();
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (this.sectionOverlayScene) {
|
|
325
|
+
this.sectionOverlayScene.traverse((obj) => {
|
|
326
|
+
if (obj.isMesh) {
|
|
327
|
+
obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
|
|
328
|
+
const m = obj.material;
|
|
329
|
+
if (Array.isArray(m)) m.forEach((mi) => mi && mi.dispose && mi.dispose());
|
|
330
|
+
else if (m && m.dispose) m.dispose();
|
|
331
|
+
}
|
|
332
|
+
if (obj.isLineSegments || obj.isLine) {
|
|
333
|
+
obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
|
|
334
|
+
obj.material && obj.material.dispose && obj.material.dispose();
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
this.sectionOverlayScene = null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
this.renderer = null;
|
|
341
|
+
this.scene = null;
|
|
342
|
+
this.camera = null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// --- Zoom API ---
|
|
346
|
+
getDistance() {
|
|
347
|
+
if (!this.camera || !this.controls) return 0;
|
|
348
|
+
return this.camera.position.distanceTo(this.controls.target);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
getZoomPercent() {
|
|
352
|
+
if (!this.controls) return 0;
|
|
353
|
+
const d = this.getDistance();
|
|
354
|
+
const minD = this.controls.minDistance || 1;
|
|
355
|
+
const maxD = this.controls.maxDistance || 20;
|
|
356
|
+
const clamped = Math.min(Math.max(d, minD), maxD);
|
|
357
|
+
const t = (maxD - clamped) / (maxD - minD); // 0..1
|
|
358
|
+
return t * 100; // 0%..100%
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
zoomIn(factor = 0.9) {
|
|
362
|
+
if (!this.camera || !this.controls) return;
|
|
363
|
+
this.#moveAlongView(factor);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
zoomOut(factor = 1.1) {
|
|
367
|
+
if (!this.camera || !this.controls) return;
|
|
368
|
+
this.#moveAlongView(factor);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
#moveAlongView(scale) {
|
|
372
|
+
const target = this.controls.target;
|
|
373
|
+
const position = this.camera.position.clone();
|
|
374
|
+
const dir = position.sub(target).normalize();
|
|
375
|
+
let dist = this.getDistance() * scale;
|
|
376
|
+
dist = Math.min(Math.max(dist, this.controls.minDistance), this.controls.maxDistance);
|
|
377
|
+
const newPos = target.clone().add(dir.multiplyScalar(dist));
|
|
378
|
+
this.camera.position.copy(newPos);
|
|
379
|
+
this.camera.updateProjectionMatrix();
|
|
380
|
+
if (this.controls) this.controls.update();
|
|
381
|
+
this._notifyZoomIfChanged(true);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Адаптивная настройка пределов зума под габариты объекта
|
|
385
|
+
applyAdaptiveZoomLimits(object3D, options = {}) {
|
|
386
|
+
if (!object3D || !this.camera || !this.controls) return;
|
|
387
|
+
const padding = options.padding ?? 1.2; // запас на краях кадра
|
|
388
|
+
const slack = options.slack ?? 2.5; // во сколько раз можно отъехать дальше «вписанной» дистанции
|
|
389
|
+
const minRatio = options.minRatio ?? 0.05; // минимальная дистанция как доля от «вписанной»
|
|
390
|
+
const recenter = options.recenter ?? false; // перемещать ли камеру на «вписанную» дистанцию
|
|
391
|
+
|
|
392
|
+
const box = new THREE.Box3().setFromObject(object3D);
|
|
393
|
+
const size = box.getSize(new THREE.Vector3());
|
|
394
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
395
|
+
|
|
396
|
+
const fitDist = this.#computeFitDistanceForSize(size, padding);
|
|
397
|
+
|
|
398
|
+
const newMin = Math.max(0.01, fitDist * Math.max(0.0, minRatio));
|
|
399
|
+
const newMax = Math.max(newMin * 1.5, fitDist * Math.max(1.0, slack));
|
|
400
|
+
this.controls.minDistance = newMin;
|
|
401
|
+
this.controls.maxDistance = newMax;
|
|
402
|
+
|
|
403
|
+
// Расширим дальнюю плоскость, чтобы исключить клиппинг при большом отъезде
|
|
404
|
+
const desiredFar = Math.max(this.camera.far, newMax * 4);
|
|
405
|
+
if (desiredFar !== this.camera.far) {
|
|
406
|
+
this.camera.far = desiredFar;
|
|
407
|
+
this.camera.updateProjectionMatrix();
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (recenter) {
|
|
411
|
+
const dir = this.camera.position.clone().sub(this.controls.target).normalize();
|
|
412
|
+
this.controls.target.copy(center);
|
|
413
|
+
this.camera.position.copy(center.clone().add(dir.multiplyScalar(fitDist)));
|
|
414
|
+
this.camera.updateProjectionMatrix();
|
|
415
|
+
this.controls.update();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Вычисляет дистанцию до объекта, при которой он полностью помещается в кадр
|
|
420
|
+
#computeFitDistanceForSize(size, padding = 1.2) {
|
|
421
|
+
// Защита от нулевых размеров
|
|
422
|
+
const safeSizeX = Math.max(1e-6, size.x);
|
|
423
|
+
const safeSizeY = Math.max(1e-6, size.y);
|
|
424
|
+
const aspect = this.camera.aspect || 1;
|
|
425
|
+
const vFov = (this.camera.fov * Math.PI) / 180; // вертикальный FOV в радианах
|
|
426
|
+
// Требуемая «высота» кадра: максимум между реальной высотой и шириной, приведённой к высоте через аспект
|
|
427
|
+
const fitHeight = Math.max(safeSizeY, safeSizeX / aspect);
|
|
428
|
+
const dist = (fitHeight * padding) / (2 * Math.tan(vFov / 2));
|
|
429
|
+
return Math.max(0.01, dist);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
addZoomListener(listener) {
|
|
433
|
+
this.zoomListeners.add(listener);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
removeZoomListener(listener) {
|
|
437
|
+
this.zoomListeners.delete(listener);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
_notifyZoomIfChanged(force = false) {
|
|
441
|
+
const p = this.getZoomPercent();
|
|
442
|
+
const rounded = Math.round(p);
|
|
443
|
+
if (force || this.lastZoomPercent !== rounded) {
|
|
444
|
+
this.lastZoomPercent = rounded;
|
|
445
|
+
this.zoomListeners.forEach((fn) => {
|
|
446
|
+
try { fn(rounded); } catch (_) {}
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_getContainerSize() {
|
|
452
|
+
const rect = this.container.getBoundingClientRect();
|
|
453
|
+
const width = rect.width || this.container.clientWidth || 1;
|
|
454
|
+
const height = rect.height || this.container.clientHeight || 1;
|
|
455
|
+
return { width, height };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_updateSize(width, height) {
|
|
459
|
+
if (!this.camera || !this.renderer) return;
|
|
460
|
+
this.camera.aspect = width / height;
|
|
461
|
+
this.camera.updateProjectionMatrix();
|
|
462
|
+
// Третий аргумент false — не менять стилевые размеры, только буфер
|
|
463
|
+
this.renderer.setSize(width, height, false);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
_dispatchReady() {
|
|
467
|
+
try {
|
|
468
|
+
this.container.dispatchEvent(new CustomEvent("viewer:ready", { bubbles: true }));
|
|
469
|
+
} catch (_) {}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Заменяет демо-куб на реальную модель и отключает автоповорот
|
|
473
|
+
replaceWithModel(object3D) {
|
|
474
|
+
if (!object3D) return;
|
|
475
|
+
// Удалить предыдущую модель
|
|
476
|
+
if (this.activeModel) {
|
|
477
|
+
this.scene.remove(this.activeModel);
|
|
478
|
+
this.#disposeObject(this.activeModel);
|
|
479
|
+
this.activeModel = null;
|
|
480
|
+
}
|
|
481
|
+
// Удалить демо-куб
|
|
482
|
+
if (this.demoCube) {
|
|
483
|
+
this.scene.remove(this.demoCube);
|
|
484
|
+
this.#disposeObject(this.demoCube);
|
|
485
|
+
this.demoCube = null;
|
|
486
|
+
}
|
|
487
|
+
this.autoRotateDemo = false;
|
|
488
|
+
this.activeModel = object3D;
|
|
489
|
+
this.scene.add(object3D);
|
|
490
|
+
|
|
491
|
+
// Подчеркнуть грани: полигон оффсет + контуры
|
|
492
|
+
object3D.traverse?.((node) => {
|
|
493
|
+
if (node.isMesh) {
|
|
494
|
+
this.#applyPolygonOffsetToMesh(node, this.flatShading);
|
|
495
|
+
this.#attachEdgesToMesh(node, this.edgesVisible);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Настроим пределы зума и сфокусируемся на новой модели
|
|
500
|
+
this.applyAdaptiveZoomLimits(object3D, { padding: 1.2, slack: 2.5, minRatio: 0.05, recenter: true });
|
|
501
|
+
|
|
502
|
+
// На следующем кадре отъедем на 2x от вписанной дистанции (точно по размеру модели)
|
|
503
|
+
try {
|
|
504
|
+
const box = new THREE.Box3().setFromObject(object3D);
|
|
505
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
506
|
+
requestAnimationFrame(() => {
|
|
507
|
+
if (!this.camera || !this.controls) return;
|
|
508
|
+
// Центрируем точку взгляда на центр модели и ставим камеру в заданные координаты
|
|
509
|
+
this.controls.target.copy(center);
|
|
510
|
+
this.camera.position.set(-22.03, 23.17, 39.12);
|
|
511
|
+
try {
|
|
512
|
+
// Если камера слишком близко, отъедем до вписанной дистанции, сохранив направление
|
|
513
|
+
const size = box.getSize(new THREE.Vector3());
|
|
514
|
+
const fitDistExact = this.#computeFitDistanceForSize(size, 1.2);
|
|
515
|
+
const dirVec = this.camera.position.clone().sub(center);
|
|
516
|
+
const dist = dirVec.length();
|
|
517
|
+
if (dist < fitDistExact && dist > 1e-6) {
|
|
518
|
+
const dirNorm = dirVec.multiplyScalar(1 / dist);
|
|
519
|
+
this.camera.position.copy(center.clone().add(dirNorm.multiplyScalar(fitDistExact)));
|
|
520
|
+
}
|
|
521
|
+
// Поднимем модель в кадре: сместим точку прицеливания немного вниз по Y
|
|
522
|
+
const verticalBias = size.y * 0.30; // 30% высоты
|
|
523
|
+
this.controls.target.y = center.y - verticalBias;
|
|
524
|
+
} catch(_) {}
|
|
525
|
+
if (this.camera.far < 1000) {
|
|
526
|
+
this.camera.far = 1000;
|
|
527
|
+
}
|
|
528
|
+
this.camera.updateProjectionMatrix();
|
|
529
|
+
this.controls.update();
|
|
530
|
+
|
|
531
|
+
// Снимем актуальный «домашний» вид после всех корректировок
|
|
532
|
+
this._home.cameraPos = this.camera.position.clone();
|
|
533
|
+
this._home.target = this.controls.target.clone();
|
|
534
|
+
this._home.edgesVisible = this.edgesVisible;
|
|
535
|
+
this._home.flatShading = this.flatShading;
|
|
536
|
+
this._home.quality = this.quality;
|
|
537
|
+
this._home.clipEnabled = this.clipping.planes.map(p => p.constant);
|
|
538
|
+
});
|
|
539
|
+
} catch(_) {}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
#disposeObject(obj) {
|
|
543
|
+
obj.traverse?.((node) => {
|
|
544
|
+
if (node.isMesh) {
|
|
545
|
+
// Удалить и освободить дочерние линии-контуры, если есть
|
|
546
|
+
const toRemove = [];
|
|
547
|
+
node.children?.forEach((c) => {
|
|
548
|
+
if (c.isLineSegments || c.isLine) toRemove.push(c);
|
|
549
|
+
});
|
|
550
|
+
toRemove.forEach((c) => {
|
|
551
|
+
if (c.geometry?.dispose) c.geometry.dispose();
|
|
552
|
+
if (c.material?.dispose) c.material.dispose();
|
|
553
|
+
node.remove(c);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
// Геометрия/материалы самого меша
|
|
557
|
+
node.geometry && node.geometry.dispose && node.geometry.dispose();
|
|
558
|
+
const m = node.material;
|
|
559
|
+
if (Array.isArray(m)) m.forEach((mi) => mi && mi.dispose && mi.dispose());
|
|
560
|
+
else if (m && m.dispose) m.dispose();
|
|
561
|
+
}
|
|
562
|
+
if (node.isLineSegments || node.isLine) {
|
|
563
|
+
node.geometry && node.geometry.dispose && node.geometry.dispose();
|
|
564
|
+
node.material && node.material.dispose && node.material.dispose();
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
#applyPolygonOffsetToMesh(mesh, flat) {
|
|
570
|
+
const apply = (mat) => {
|
|
571
|
+
if (!mat) return;
|
|
572
|
+
mat.polygonOffset = true;
|
|
573
|
+
mat.polygonOffsetFactor = 1;
|
|
574
|
+
mat.polygonOffsetUnits = 1;
|
|
575
|
+
// Улучшим читаемость плоскостей
|
|
576
|
+
if ("flatShading" in mat) {
|
|
577
|
+
mat.flatShading = !!flat;
|
|
578
|
+
mat.needsUpdate = true;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
if (Array.isArray(mesh.material)) mesh.material.forEach(apply);
|
|
582
|
+
else apply(mesh.material);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
#attachEdgesToMesh(mesh, visible) {
|
|
586
|
+
if (!mesh.geometry) return;
|
|
587
|
+
// Не дублировать
|
|
588
|
+
if (mesh.userData.__edgesAttached) return;
|
|
589
|
+
const geom = new THREE.EdgesGeometry(mesh.geometry, 30); // thresholdAngle=30°
|
|
590
|
+
const mat = new THREE.LineBasicMaterial({ color: 0x111111, depthTest: true });
|
|
591
|
+
const lines = new THREE.LineSegments(geom, mat);
|
|
592
|
+
lines.name = "edges-overlay";
|
|
593
|
+
lines.renderOrder = 999;
|
|
594
|
+
mesh.add(lines);
|
|
595
|
+
mesh.userData.__edgesAttached = true;
|
|
596
|
+
lines.visible = !!visible;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Публичные методы управления качеством и стилем
|
|
600
|
+
setEdgesVisible(visible) {
|
|
601
|
+
this.edgesVisible = !!visible;
|
|
602
|
+
const apply = (obj) => {
|
|
603
|
+
obj.traverse?.((node) => {
|
|
604
|
+
if (node.isMesh) {
|
|
605
|
+
node.children?.forEach((c) => {
|
|
606
|
+
if (c.name === 'edges-overlay') c.visible = !!visible;
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
if (this.activeModel) apply(this.activeModel);
|
|
612
|
+
if (this.demoCube) apply(this.demoCube);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
setFlatShading(enabled) {
|
|
616
|
+
this.flatShading = !!enabled;
|
|
617
|
+
const apply = (obj) => {
|
|
618
|
+
obj.traverse?.((node) => {
|
|
619
|
+
if (node.isMesh) this.#applyPolygonOffsetToMesh(node, this.flatShading);
|
|
620
|
+
});
|
|
621
|
+
};
|
|
622
|
+
if (this.activeModel) apply(this.activeModel);
|
|
623
|
+
if (this.demoCube) apply(this.demoCube);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
setQuality(preset) {
|
|
627
|
+
this.quality = preset; // 'low' | 'medium' | 'high'
|
|
628
|
+
// Настройки рендера
|
|
629
|
+
if (preset === 'low') {
|
|
630
|
+
this.renderer.setPixelRatio(1);
|
|
631
|
+
this.renderer.shadowMap.enabled = false;
|
|
632
|
+
this.controls.enableDamping = false;
|
|
633
|
+
} else if (preset === 'high') {
|
|
634
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
635
|
+
this.renderer.shadowMap.enabled = false;
|
|
636
|
+
this.controls.enableDamping = true;
|
|
637
|
+
} else {
|
|
638
|
+
// medium
|
|
639
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
|
|
640
|
+
this.renderer.shadowMap.enabled = false;
|
|
641
|
+
this.controls.enableDamping = true;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// --- Clipping API ---
|
|
646
|
+
// axis: 'x' | 'y' | 'z', enabled: boolean, distance: number (в мировых единицах)
|
|
647
|
+
setSection(axis, enabled, distance = 0) {
|
|
648
|
+
const idx = axis === 'x' ? 0 : axis === 'y' ? 1 : 2;
|
|
649
|
+
const plane = this.clipping.planes[idx];
|
|
650
|
+
if (enabled) {
|
|
651
|
+
const subject = this.activeModel || this.demoCube;
|
|
652
|
+
let dist = distance;
|
|
653
|
+
// Если дистанция не задана, выбираем границу bbox со стороны камеры
|
|
654
|
+
if ((plane.constant === Infinity) && subject && distance === 0) {
|
|
655
|
+
const box = new THREE.Box3().setFromObject(subject);
|
|
656
|
+
const center = box.getCenter(new THREE.Vector3());
|
|
657
|
+
const cam = this.camera.position;
|
|
658
|
+
if (idx === 0) dist = (cam.x >= center.x) ? box.max.x : box.min.x;
|
|
659
|
+
else if (idx === 1) dist = (cam.y >= center.y) ? box.max.y : box.min.y;
|
|
660
|
+
else dist = (cam.z >= center.z) ? box.max.z : box.min.z;
|
|
661
|
+
}
|
|
662
|
+
// Выберем нормаль/константу так, чтобы сохранялась «внутренняя» сторона модели (d >= 0)
|
|
663
|
+
let normal;
|
|
664
|
+
if (idx === 0) normal = new THREE.Vector3(1, 0, 0);
|
|
665
|
+
else if (idx === 1) normal = new THREE.Vector3(0, 1, 0);
|
|
666
|
+
else normal = new THREE.Vector3(0, 0, 1);
|
|
667
|
+
const camPos = this.camera.position;
|
|
668
|
+
const subjBox = subject ? new THREE.Box3().setFromObject(subject) : null;
|
|
669
|
+
const subjCenter = subjBox ? subjBox.getCenter(new THREE.Vector3()) : new THREE.Vector3();
|
|
670
|
+
const camOnPositive = idx === 0 ? (camPos.x >= subjCenter.x) : idx === 1 ? (camPos.y >= subjCenter.y) : (camPos.z >= subjCenter.z);
|
|
671
|
+
// Если камера на + стороне — используем нормаль в -ось и constant=+dist, иначе нормаль +ось и constant=-dist
|
|
672
|
+
if (camOnPositive) {
|
|
673
|
+
normal.multiplyScalar(-1);
|
|
674
|
+
plane.set(normal, +dist);
|
|
675
|
+
} else {
|
|
676
|
+
plane.set(normal, -dist);
|
|
677
|
+
}
|
|
678
|
+
// Убедимся, что ни одна из вершин bbox не уходит на «отбрасываемую» сторону из-за неточности
|
|
679
|
+
if (subjBox) {
|
|
680
|
+
const corners = this.#getBoxCorners(subjBox);
|
|
681
|
+
let minSigned = Infinity;
|
|
682
|
+
for (const c of corners) {
|
|
683
|
+
const s = plane.normal.dot(c) + plane.constant;
|
|
684
|
+
if (s < minSigned) minSigned = s;
|
|
685
|
+
}
|
|
686
|
+
if (minSigned < 0) {
|
|
687
|
+
plane.constant -= (minSigned - (-1e-4)); // сдвинем чуть так, чтобы все вершины имели s >= -1e-4
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
this.#setGizmoVisible(axis, true);
|
|
691
|
+
} else {
|
|
692
|
+
// Уберём влияние — отодвинем плоскость на бесконечность
|
|
693
|
+
plane.constant = Infinity;
|
|
694
|
+
this.#setGizmoVisible(axis, false);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Устанавливает позицию секущей плоскости по нормализованному значению [0..1] в пределах габаритов модели
|
|
699
|
+
setSectionNormalized(axis, enabled, t = 0.5) {
|
|
700
|
+
const subject = this.activeModel || this.demoCube;
|
|
701
|
+
if (!subject) { this.setSection(axis, enabled, 0); return; }
|
|
702
|
+
const box = new THREE.Box3().setFromObject(subject);
|
|
703
|
+
const size = box.getSize(new THREE.Vector3());
|
|
704
|
+
const min = box.min, max = box.max;
|
|
705
|
+
let distance = 0;
|
|
706
|
+
if (axis === 'x') distance = min.x + (max.x - min.x) * t;
|
|
707
|
+
else if (axis === 'y') distance = min.y + (max.y - min.y) * t;
|
|
708
|
+
else distance = min.z + (max.z - min.z) * t;
|
|
709
|
+
this.setSection(axis, enabled, distance);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
#initClippingGizmos() {
|
|
713
|
+
// Создаём по манипулятору на ось. Используем общие глобальные плоскости отсечения.
|
|
714
|
+
const create = (axis, idx) => {
|
|
715
|
+
const manip = new SectionManipulator({
|
|
716
|
+
scene: this.sectionOverlayScene,
|
|
717
|
+
camera: this.camera,
|
|
718
|
+
controls: this.controls,
|
|
719
|
+
domElement: this.renderer.domElement,
|
|
720
|
+
plane: this.clipping.planes[idx],
|
|
721
|
+
axis,
|
|
722
|
+
});
|
|
723
|
+
// Изначально скрыт
|
|
724
|
+
manip.setEnabled(false);
|
|
725
|
+
return manip;
|
|
726
|
+
};
|
|
727
|
+
this.clipping.manipulators.x = create('x', 0);
|
|
728
|
+
this.clipping.manipulators.y = create('y', 1);
|
|
729
|
+
this.clipping.manipulators.z = create('z', 2);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Гарантирует, что центр модели не будет полностью отсечён плоскостью
|
|
733
|
+
#ensureCenterKeptByPlane(plane, subject) {
|
|
734
|
+
try {
|
|
735
|
+
const center = new THREE.Box3().setFromObject(subject).getCenter(new THREE.Vector3());
|
|
736
|
+
const signed = plane.normal.dot(center) + plane.constant;
|
|
737
|
+
// В three.js отсекается положительная сторона (signed > 0)
|
|
738
|
+
// Если центр модели на отсекаемой стороне, инвертируем плоскость.
|
|
739
|
+
if (signed > 0) {
|
|
740
|
+
plane.normal.multiplyScalar(-1);
|
|
741
|
+
plane.constant *= -1;
|
|
742
|
+
}
|
|
743
|
+
} catch (_) {}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
#setGizmoVisible(axis, visible) {
|
|
747
|
+
const m = this.clipping.manipulators[axis];
|
|
748
|
+
if (m) m.setEnabled(!!visible);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
#updateClippingGizmos() {
|
|
752
|
+
const subject = this.activeModel || this.demoCube;
|
|
753
|
+
const mx = this.clipping.manipulators.x;
|
|
754
|
+
const my = this.clipping.manipulators.y;
|
|
755
|
+
const mz = this.clipping.manipulators.z;
|
|
756
|
+
mx && mx.update(subject);
|
|
757
|
+
my && my.update(subject);
|
|
758
|
+
mz && mz.update(subject);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// резерв: помощники больше не используются
|
|
762
|
+
#getBoxCorners(box) {
|
|
763
|
+
const min = box.min, max = box.max;
|
|
764
|
+
return [
|
|
765
|
+
new THREE.Vector3(min.x, min.y, min.z),
|
|
766
|
+
new THREE.Vector3(max.x, min.y, min.z),
|
|
767
|
+
new THREE.Vector3(min.x, max.y, min.z),
|
|
768
|
+
new THREE.Vector3(min.x, min.y, max.z),
|
|
769
|
+
new THREE.Vector3(max.x, max.y, min.z),
|
|
770
|
+
new THREE.Vector3(max.x, min.y, max.z),
|
|
771
|
+
new THREE.Vector3(min.x, max.y, max.z),
|
|
772
|
+
new THREE.Vector3(max.x, max.y, max.z),
|
|
773
|
+
];
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
// Вернуть стартовый вид
|
|
777
|
+
goHome() {
|
|
778
|
+
if (!this.camera || !this.controls) return;
|
|
779
|
+
// Камера и прицел
|
|
780
|
+
this.controls.target.copy(this._home.target);
|
|
781
|
+
this.camera.position.copy(this._home.cameraPos);
|
|
782
|
+
// Визуальные настройки
|
|
783
|
+
this.setEdgesVisible(this._home.edgesVisible);
|
|
784
|
+
this.setFlatShading(this._home.flatShading);
|
|
785
|
+
this.setQuality(this._home.quality);
|
|
786
|
+
// Клиппинг
|
|
787
|
+
['x','y','z'].forEach((axis, i) => {
|
|
788
|
+
const enabled = isFinite(this._home.clipEnabled[i]);
|
|
789
|
+
const dist = -this._home.clipEnabled[i];
|
|
790
|
+
this.setSection(axis, enabled, enabled ? dist : 0);
|
|
791
|
+
});
|
|
792
|
+
this.camera.updateProjectionMatrix();
|
|
793
|
+
this.controls.update();
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// ================= Вспомогательное: ось вращения =================
|
|
797
|
+
#ensureRotationAxisLine() {
|
|
798
|
+
if (this.rotationAxisLine) return this.rotationAxisLine;
|
|
799
|
+
const geom = new THREE.BufferGeometry().setFromPoints([
|
|
800
|
+
new THREE.Vector3(0, 0, 0),
|
|
801
|
+
new THREE.Vector3(0, 1, 0),
|
|
802
|
+
]);
|
|
803
|
+
const mat = new THREE.LineDashedMaterial({
|
|
804
|
+
color: 0x84ffff,
|
|
805
|
+
dashSize: 0.06, // меньше базовый размер
|
|
806
|
+
gapSize: 0.02, // маленький промежуток
|
|
807
|
+
depthTest: false,
|
|
808
|
+
depthWrite: false,
|
|
809
|
+
});
|
|
810
|
+
const line = new THREE.Line(geom, mat);
|
|
811
|
+
line.computeLineDistances();
|
|
812
|
+
line.visible = false;
|
|
813
|
+
line.renderOrder = 1002;
|
|
814
|
+
line.name = 'rotation-axis-line';
|
|
815
|
+
this.scene.add(line);
|
|
816
|
+
this.rotationAxisLine = line;
|
|
817
|
+
return line;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
#hideRotationAxisLine() {
|
|
821
|
+
if (this.rotationAxisLine) this.rotationAxisLine.visible = false;
|
|
822
|
+
this._prevViewDir = null;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
#updateRotationAxisLine() {
|
|
826
|
+
if (!this.camera || !this.controls) return;
|
|
827
|
+
// Порог по экранному движению
|
|
828
|
+
if (this._recentPointerDelta < this._pointerPxThreshold) return;
|
|
829
|
+
|
|
830
|
+
const currentDir = this.camera.position.clone().sub(this.controls.target).normalize();
|
|
831
|
+
if (!this._prevViewDir) { this._prevViewDir = currentDir.clone(); return; }
|
|
832
|
+
|
|
833
|
+
// Угловой порог (dead zone)
|
|
834
|
+
const dot = THREE.MathUtils.clamp(this._prevViewDir.dot(currentDir), -1, 1);
|
|
835
|
+
const angle = Math.acos(dot);
|
|
836
|
+
if (angle < this._rotAngleEps) return;
|
|
837
|
+
|
|
838
|
+
// Ось = prev × current
|
|
839
|
+
let axis = this._prevViewDir.clone().cross(currentDir);
|
|
840
|
+
const axisLen = axis.length();
|
|
841
|
+
if (axisLen < 1e-6) return; // почти нет вращения
|
|
842
|
+
axis.normalize();
|
|
843
|
+
|
|
844
|
+
// Сглаживание оси (EMA)
|
|
845
|
+
if (this._smoothedAxis) {
|
|
846
|
+
this._smoothedAxis.lerp(axis, this._axisEmaAlpha).normalize();
|
|
847
|
+
} else {
|
|
848
|
+
this._smoothedAxis = axis.clone();
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const subject = this.activeModel || this.demoCube;
|
|
852
|
+
let sizeLen = 1.0;
|
|
853
|
+
if (subject) {
|
|
854
|
+
const box = new THREE.Box3().setFromObject(subject);
|
|
855
|
+
const size = box.getSize(new THREE.Vector3());
|
|
856
|
+
sizeLen = Math.max(size.x, size.y, size.z) * 0.7;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const line = this.#ensureRotationAxisLine();
|
|
860
|
+
const target = this.controls.target.clone();
|
|
861
|
+
const p1 = target.clone().add(this._smoothedAxis.clone().multiplyScalar(sizeLen));
|
|
862
|
+
const p2 = target.clone().add(this._smoothedAxis.clone().multiplyScalar(-sizeLen));
|
|
863
|
+
line.geometry.setFromPoints([p1, p2]);
|
|
864
|
+
line.computeLineDistances();
|
|
865
|
+
const mat = line.material;
|
|
866
|
+
if (mat && 'dashSize' in mat) {
|
|
867
|
+
// ~в 3 раза мельче штрихи и с очень маленьким промежутком
|
|
868
|
+
mat.dashSize = sizeLen * 0.026;
|
|
869
|
+
mat.gapSize = sizeLen * 0.010;
|
|
870
|
+
mat.needsUpdate = true;
|
|
871
|
+
}
|
|
872
|
+
line.visible = true;
|
|
873
|
+
|
|
874
|
+
// Обновим предыдущее направление и сбросим экранный сдвиг
|
|
875
|
+
this._prevViewDir = currentDir.clone();
|
|
876
|
+
this._recentPointerDelta = 0;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
|