@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/src/main.js ADDED
@@ -0,0 +1,216 @@
1
+ import "./style.css";
2
+ import { Viewer } from "./viewer/Viewer.js";
3
+ import { IfcService } from "./ifc/IfcService.js";
4
+ import { IfcTreeView } from "./ifc/IfcTreeView.js";
5
+
6
+ // Инициализация three.js Viewer в контейнере #app
7
+ const app = document.getElementById("app");
8
+ if (app) {
9
+ const viewer = new Viewer(app);
10
+ viewer.init();
11
+ // IFC загрузка
12
+ const ifc = new IfcService(viewer);
13
+ ifc.init();
14
+ const ifcTreeEl = document.getElementById("ifcTree");
15
+ const ifcInfoEl = document.getElementById("ifcInfo");
16
+ const ifcTree = ifcTreeEl ? new IfcTreeView(ifcTreeEl) : null;
17
+ const ifcIsolateToggle = document.getElementById("ifcIsolateToggle");
18
+
19
+ const uploadBtn = document.getElementById("uploadBtn");
20
+ const ifcInput = document.getElementById("ifcInput");
21
+ if (uploadBtn && ifcInput) {
22
+ uploadBtn.addEventListener("click", () => ifcInput.click());
23
+ ifcInput.addEventListener("change", async (e) => {
24
+ const file = e.target.files?.[0];
25
+ if (!file) return;
26
+ await ifc.loadFile(file);
27
+ ifcInput.value = "";
28
+ // Обновим дерево IFC и инфо
29
+ const last = ifc.getLastInfo();
30
+ const struct = await ifc.getSpatialStructure(last.modelID ? Number(last.modelID) : undefined);
31
+ if (!struct) console.warn('IFC spatial structure not available for modelID', last?.modelID);
32
+ if (ifcTree) ifcTree.render(struct);
33
+ if (ifcInfoEl) {
34
+ const info = ifc.getLastInfo();
35
+ ifcInfoEl.innerHTML = `
36
+ <div class="flex items-center justify-between">
37
+ <div>
38
+ <div class="font-medium text-xs">${info.name || '—'}</div>
39
+ <div class="opacity-70">modelID: ${info.modelID || '—'}</div>
40
+ </div>
41
+ </div>`;
42
+ }
43
+ // Авто-открытие панели при ручной загрузке
44
+ setSidebarVisible(true);
45
+ hidePreloader();
46
+ });
47
+ }
48
+
49
+
50
+
51
+ // Кнопки качества и стиля
52
+ const qualLow = document.getElementById("qualLow");
53
+ const qualMed = document.getElementById("qualMed");
54
+ const qualHigh = document.getElementById("qualHigh");
55
+ const toggleEdges = document.getElementById("toggleEdges");
56
+ const toggleShading = document.getElementById("toggleShading");
57
+ const clipXBtn = document.getElementById("clipX");
58
+ const clipYBtn = document.getElementById("clipY");
59
+ const clipZBtn = document.getElementById("clipZ");
60
+ const clipXRange = document.getElementById("clipXRange");
61
+ const clipYRange = document.getElementById("clipYRange");
62
+ const clipZRange = document.getElementById("clipZRange");
63
+
64
+ const setActive = (btn) => {
65
+ [qualLow, qualMed, qualHigh].forEach((b) => b && b.classList.remove("btn-active"));
66
+ btn && btn.classList.add("btn-active");
67
+ };
68
+ qualLow?.addEventListener("click", () => { viewer.setQuality('low'); setActive(qualLow); });
69
+ qualMed?.addEventListener("click", () => { viewer.setQuality('medium'); setActive(qualMed); });
70
+ qualHigh?.addEventListener("click", () => { viewer.setQuality('high'); setActive(qualHigh); });
71
+
72
+ let edgesOn = true;
73
+ toggleEdges?.addEventListener("click", () => { edgesOn = !edgesOn; viewer.setEdgesVisible(edgesOn); });
74
+ let flatOn = true;
75
+ toggleShading?.addEventListener("click", () => { flatOn = !flatOn; viewer.setFlatShading(flatOn); });
76
+
77
+ // Переключатели секущих плоскостей: одиночный выбор без изменения логики Viewer
78
+ let clipX = false, clipY = false, clipZ = false;
79
+ let clipActive = null; // 'x' | 'y' | 'z' | null
80
+ function setClipAxis(axis, enable) {
81
+ // Сбросим предыдущую активную
82
+ if (clipActive && clipActive !== axis) {
83
+ viewer.setSection(clipActive, false, 0);
84
+ if (clipActive === 'x') { clipX = false; clipXBtn?.classList.remove('btn-active'); }
85
+ if (clipActive === 'y') { clipY = false; clipYBtn?.classList.remove('btn-active'); }
86
+ if (clipActive === 'z') { clipZ = false; clipZBtn?.classList.remove('btn-active'); }
87
+ clipActive = null;
88
+ }
89
+ // Применим новую ось
90
+ viewer.setSection(axis, enable, 0);
91
+ if (axis === 'x') { clipX = enable; clipXBtn?.classList.toggle('btn-active', enable); }
92
+ if (axis === 'y') { clipY = enable; clipYBtn?.classList.toggle('btn-active', enable); }
93
+ if (axis === 'z') { clipZ = enable; clipZBtn?.classList.toggle('btn-active', enable); }
94
+ clipActive = enable ? axis : null;
95
+ }
96
+ clipXBtn?.addEventListener('click', () => setClipAxis('x', !clipX));
97
+ clipYBtn?.addEventListener('click', () => setClipAxis('y', !clipY));
98
+ clipZBtn?.addEventListener('click', () => setClipAxis('z', !clipZ));
99
+
100
+ // Слайдеры позиции плоскостей: значение [0..1] маппим на габариты модели
101
+ clipXRange?.addEventListener('input', (e) => {
102
+ const t = Number(e.target.value);
103
+ viewer.setSectionNormalized('x', clipX, t);
104
+ });
105
+ clipYRange?.addEventListener('input', (e) => {
106
+ const t = Number(e.target.value);
107
+ viewer.setSectionNormalized('y', clipY, t);
108
+ });
109
+ clipZRange?.addEventListener('input', (e) => {
110
+ const t = Number(e.target.value);
111
+ viewer.setSectionNormalized('z', clipZ, t);
112
+ });
113
+
114
+ // Прелоадер: скрыть, когда Viewer готов, или через фолбэк 1с
115
+ const preloader = document.getElementById("preloader");
116
+ const zoomPanel = document.getElementById("zoomPanel");
117
+ const hidePreloader = () => {
118
+ if (preloader) {
119
+ preloader.style.transition = "opacity 400ms ease";
120
+ preloader.style.willChange = "opacity";
121
+ preloader.style.opacity = "0";
122
+ // удалим из DOM после анимации
123
+ setTimeout(() => { preloader.parentNode && preloader.parentNode.removeChild(preloader); }, 450);
124
+ }
125
+ if (zoomPanel) zoomPanel.classList.remove("invisible");
126
+ };
127
+
128
+ // ЛЕВАЯ ПАНЕЛЬ: показать/скрыть
129
+ const sidebar = document.getElementById("ifcSidebar");
130
+ const sidebarToggle = document.getElementById("sidebarToggle");
131
+ const sidebarClose = document.getElementById("sidebarClose");
132
+ const setSidebarVisible = (visible) => {
133
+ if (!sidebar) return;
134
+ if (visible) {
135
+ sidebar.classList.remove("-translate-x-full");
136
+ sidebar.classList.add("translate-x-0");
137
+ sidebar.classList.remove("pointer-events-none");
138
+ } else {
139
+ sidebar.classList.add("-translate-x-full");
140
+ sidebar.classList.remove("translate-x-0");
141
+ sidebar.classList.add("pointer-events-none");
142
+ }
143
+ };
144
+ sidebarToggle?.addEventListener("click", () => setSidebarVisible(true));
145
+ sidebarClose?.addEventListener("click", () => setSidebarVisible(false));
146
+
147
+ // Переключатель изоляции
148
+ ifcIsolateToggle?.addEventListener("change", (e) => {
149
+ const enabled = e.target.checked;
150
+ ifc.setIsolateMode(enabled);
151
+ });
152
+
153
+ // Выбор узла в дереве → подсветка/изоляция
154
+ if (ifcTree) {
155
+ ifcTree.onSelect(async (node) => {
156
+ const ids = ifc.collectElementIDsFromStructure(node);
157
+ await ifc.highlightByIds(ids);
158
+ });
159
+ }
160
+
161
+ // Скрывать прелоадер, когда модель реально загружена (страховка от гонок)
162
+ document.addEventListener('ifc:model-loaded', () => {
163
+ hidePreloader();
164
+ }, { once: true });
165
+
166
+ // Автозагрузка IFC: используем образец по умолчанию, параметр ?ifc= может переопределить
167
+ try {
168
+ const DEFAULT_IFC_URL = "/ifc/170ОК-23_1_1_АР_П.ifc";
169
+ const params = new URLSearchParams(location.search);
170
+ const ifcUrlParam = params.get('ifc');
171
+ const ifcUrl = ifcUrlParam || DEFAULT_IFC_URL;
172
+ const model = await ifc.loadUrl(encodeURI(ifcUrl));
173
+ if (model) {
174
+ const struct = await ifc.getSpatialStructure();
175
+ if (ifcTree) ifcTree.render(struct);
176
+ if (ifcInfoEl) {
177
+ const info = ifc.getLastInfo();
178
+ ifcInfoEl.innerHTML = `
179
+ <div class="flex items-center justify-between">
180
+ <div>
181
+ <div class="font-medium text-xs">${info.name || '—'}</div>
182
+ <div class="opacity-70">modelID: ${info.modelID || '—'}</div>
183
+ </div>
184
+ </div>`;
185
+ }
186
+ // Не открываем панель автоматически при автозагрузке
187
+ hidePreloader();
188
+ }
189
+ } catch (e) {
190
+ console.warn('IFC autoload error', e);
191
+ }
192
+
193
+ // Панель зума
194
+ const zoomValue = document.getElementById("zoomValue");
195
+ const zoomInBtn = document.getElementById("zoomIn");
196
+ const zoomOutBtn = document.getElementById("zoomOut");
197
+ if (zoomValue && zoomInBtn && zoomOutBtn) {
198
+ const update = (p) => { zoomValue.textContent = `${p}%`; };
199
+ viewer.addZoomListener(update);
200
+ update(Math.round(viewer.getZoomPercent()));
201
+
202
+ zoomInBtn.addEventListener("click", () => viewer.zoomIn());
203
+ zoomOutBtn.addEventListener("click", () => viewer.zoomOut());
204
+ }
205
+
206
+ // Очистка при HMR (vite)
207
+ if (import.meta.hot) {
208
+ import.meta.hot.dispose(() => { ifc.dispose(); viewer.dispose(); });
209
+ }
210
+ }
211
+
212
+ // Переключение темы daisyUI (light/dark)
213
+ document.getElementById("theme")?.addEventListener("click", () => {
214
+ const root = document.documentElement;
215
+ root.setAttribute("data-theme", root.getAttribute("data-theme") === "dark" ? "light" : "dark");
216
+ });
package/src/style.css ADDED
@@ -0,0 +1,2 @@
1
+ @import "tailwindcss";
2
+ @plugin "daisyui";
@@ -0,0 +1,395 @@
1
+ // Класс NavCube — интерактивный навигационный куб в правом верхнем углу
2
+ // Без внешних зависимостей. Рендерится во второй проход в тот же WebGLRenderer.
3
+
4
+ import * as THREE from "three";
5
+
6
+ export class NavCube {
7
+ /**
8
+ * @param {THREE.WebGLRenderer} renderer
9
+ * @param {THREE.PerspectiveCamera} mainCamera
10
+ * @param {any} controls OrbitControls (ожидается target, update())
11
+ * @param {HTMLElement} container контейнер, содержащий канвас
12
+ * @param {{ sizePx?: number, marginPx?: number, opacity?: number, onHome?: () => void }} [opts]
13
+ */
14
+ constructor(renderer, mainCamera, controls, container, opts = {}) {
15
+ this.renderer = renderer;
16
+ this.mainCamera = mainCamera;
17
+ this.controls = controls;
18
+ this.container = container;
19
+
20
+ this.sizePx = opts.sizePx ?? 96;
21
+ this.marginPx = opts.marginPx ?? 10;
22
+ this.faceOpacity = opts.opacity ?? 0.6;
23
+ this.onHome = typeof opts.onHome === 'function' ? opts.onHome : null;
24
+
25
+ this.scene = new THREE.Scene();
26
+ this.camera = new THREE.PerspectiveCamera(35, 1, 0.1, 10);
27
+ this.camera.position.set(0, 0, 3);
28
+ this.camera.lookAt(0, 0, 0);
29
+
30
+ // Полупрозрачный куб с окрашенными сторонами (+X/-X, +Y/-Y, +Z/-Z)
31
+ const geom = new THREE.BoxGeometry(1, 1, 1);
32
+ const mats = [
33
+ new THREE.MeshBasicMaterial({ color: 0xd32f2f, transparent: true, opacity: this.faceOpacity }), // +X (red)
34
+ new THREE.MeshBasicMaterial({ color: 0x7f0000, transparent: true, opacity: this.faceOpacity }), // -X (dark red)
35
+ new THREE.MeshBasicMaterial({ color: 0x388e3c, transparent: true, opacity: this.faceOpacity }), // +Y (green)
36
+ new THREE.MeshBasicMaterial({ color: 0x1b5e20, transparent: true, opacity: this.faceOpacity }), // -Y (dark green)
37
+ new THREE.MeshBasicMaterial({ color: 0x1976d2, transparent: true, opacity: this.faceOpacity }), // +Z (blue)
38
+ new THREE.MeshBasicMaterial({ color: 0x0d47a1, transparent: true, opacity: this.faceOpacity }), // -Z (dark blue)
39
+ ];
40
+ this.cube = new THREE.Mesh(geom, mats);
41
+ this.cube.name = "nav-cube";
42
+ this.scene.add(this.cube);
43
+
44
+ // Увеличим куб в 1.5 раза
45
+ this._cubeScale = 1.5;
46
+ this.cube.scale.setScalar(this._cubeScale);
47
+ // Подвинем камеру, чтобы увеличенный куб целиком помещался во вьюпорте
48
+ const vFov = (this.camera.fov * Math.PI) / 180;
49
+ const radius = Math.sqrt(3) * 0.5 * this._cubeScale; // радиус сферы, описанной вокруг куба
50
+ const fitDist = (radius / Math.tan(vFov / 2)) * 1.12; // небольшой запас
51
+ this.camera.position.set(0, 0, Math.max(fitDist, this.camera.near + 0.2));
52
+ this.camera.lookAt(0, 0, 0);
53
+
54
+ // Рёбра для читабельности
55
+ const edges = new THREE.EdgesGeometry(geom, 1);
56
+ const lineMat = new THREE.LineBasicMaterial({ color: 0x111111, depthTest: true });
57
+ this.cubeEdges = new THREE.LineSegments(edges, lineMat);
58
+ this.cubeEdges.renderOrder = 999;
59
+ this.cube.add(this.cubeEdges);
60
+
61
+ // Подписи граней (крупные, чёрные, на самих гранях)
62
+ this.#addFaceLabels();
63
+
64
+ // Raycaster для интерактивности
65
+ this.raycaster = new THREE.Raycaster();
66
+ this.pointerNdc = new THREE.Vector2();
67
+ this._isPointerInside = false;
68
+ this._lastDownPos = null;
69
+ this._clickTolerance = 4; // px
70
+
71
+ // Анимация камеры до заданного направления
72
+ this._tweenActive = false;
73
+ this._tweenStart = 0;
74
+ this._tweenDuration = 450; // мс
75
+ this._startPos = new THREE.Vector3();
76
+ this._startUp = new THREE.Vector3();
77
+ this._targetPos = new THREE.Vector3();
78
+ this._targetUp = new THREE.Vector3();
79
+
80
+ // Слушатели мыши на канвасе рендера
81
+ this.dom = this.renderer.domElement;
82
+ this._onPointerMove = this._onPointerMove.bind(this);
83
+ this._onPointerDown = this._onPointerDown.bind(this);
84
+ this._onPointerUp = this._onPointerUp.bind(this);
85
+ this.dom.addEventListener("pointermove", this._onPointerMove, { passive: true });
86
+ this.dom.addEventListener("pointerdown", this._onPointerDown, { passive: false });
87
+ this.dom.addEventListener("pointerup", this._onPointerUp, { passive: false });
88
+
89
+ // Кнопка Home слева от куба
90
+ this.homeBtn = document.createElement('button');
91
+ this.homeBtn.type = 'button';
92
+ this.homeBtn.title = 'Home';
93
+ this.homeBtn.textContent = '⌂';
94
+ this.homeBtn.style.position = 'absolute';
95
+ this.homeBtn.style.zIndex = '40';
96
+ this.homeBtn.style.width = '28px';
97
+ this.homeBtn.style.height = '28px';
98
+ this.homeBtn.style.lineHeight = '28px';
99
+ this.homeBtn.style.textAlign = 'center';
100
+ this.homeBtn.style.borderRadius = '6px';
101
+ this.homeBtn.style.border = '1px solid rgba(255,255,255,0.4)';
102
+ this.homeBtn.style.background = 'rgba(0,0,0,0.45)';
103
+ this.homeBtn.style.color = '#fff';
104
+ this.homeBtn.style.cursor = 'pointer';
105
+ this.homeBtn.style.userSelect = 'none';
106
+ this.homeBtn.style.font = '14px/28px system-ui, sans-serif';
107
+ // Позиция задаётся в renderOverlay относительно текущего размера
108
+ this.homeBtn.addEventListener('click', () => { try { this.onHome && this.onHome(); } catch(_) {} });
109
+ try { this.container.appendChild(this.homeBtn); } catch(_) {}
110
+ }
111
+
112
+ dispose() {
113
+ this.dom.removeEventListener("pointermove", this._onPointerMove);
114
+ this.dom.removeEventListener("pointerdown", this._onPointerDown);
115
+ this.dom.removeEventListener("pointerup", this._onPointerUp);
116
+ if (this.homeBtn && this.homeBtn.parentNode) {
117
+ try { this.homeBtn.parentNode.removeChild(this.homeBtn); } catch(_) {}
118
+ this.homeBtn = null;
119
+ }
120
+ this.scene.traverse((obj) => {
121
+ if (obj.isMesh) {
122
+ obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
123
+ const m = obj.material;
124
+ if (Array.isArray(m)) m.forEach((mi) => mi && mi.dispose && mi.dispose());
125
+ else if (m && m.dispose) m.dispose();
126
+ }
127
+ if (obj.isLineSegments || obj.isLine) {
128
+ obj.geometry && obj.geometry.dispose && obj.geometry.dispose();
129
+ obj.material && obj.material.dispose && obj.material.dispose();
130
+ }
131
+ });
132
+ }
133
+
134
+ onResize() {
135
+ // ничего, камера NavCube — квадратная, viewport зададим при рендере
136
+ }
137
+
138
+ // Рендер маленького вида в правом верхнем углу
139
+ renderOverlay() {
140
+ if (!this.renderer) return;
141
+ const canvas = this.renderer.domElement;
142
+ const rect = canvas.getBoundingClientRect();
143
+ const fullW = Math.max(1, Math.floor(rect.width));
144
+ const fullH = Math.max(1, Math.floor(rect.height));
145
+
146
+ const vpSize = Math.min(this.sizePx, Math.min(fullW, fullH));
147
+ const x = fullW - this.marginPx - vpSize;
148
+ // В WebGL (и three.js) ось Y viewport-а идёт снизу вверх, поэтому для
149
+ // «верхнего правого» угла считаем Y от нижнего края канваса
150
+ const y = fullH - this.marginPx - vpSize;
151
+
152
+ // Синхронизируем ориентацию куба с камерой сцены
153
+ // Инвертируем quaternion камеры, чтобы куб отражал мировые оси корректно
154
+ this.cube.quaternion.copy(this.mainCamera.quaternion).invert();
155
+
156
+ // Сохраним и отключим клиппинг, чтобы куб не отсекался
157
+ const prevLocal = this.renderer.localClippingEnabled;
158
+ const prevPlanes = this.renderer.clippingPlanes;
159
+ this.renderer.localClippingEnabled = false;
160
+ this.renderer.clippingPlanes = [];
161
+
162
+ // Включим scissor, чтобы не затрагивать основную сцену
163
+ this.renderer.clearDepth();
164
+ this.renderer.setScissorTest(true);
165
+ this.renderer.setViewport(x, y, vpSize, vpSize);
166
+ this.renderer.setScissor(x, y, vpSize, vpSize);
167
+ this.camera.aspect = 1;
168
+ this.camera.updateProjectionMatrix();
169
+ this.renderer.render(this.scene, this.camera);
170
+ this.renderer.setScissorTest(false);
171
+ // Восстановим полный viewport на всякий случай
172
+ this.renderer.setViewport(0, 0, fullW, fullH);
173
+
174
+ // Восстановим клиппинг
175
+ this.renderer.localClippingEnabled = prevLocal;
176
+ this.renderer.clippingPlanes = prevPlanes;
177
+
178
+ // Позиционируем кнопку Home слева от куба
179
+ if (this.homeBtn) {
180
+ this.homeBtn.style.top = `${this.marginPx + 2}px`;
181
+ this.homeBtn.style.left = `${Math.max(2, fullW - this.marginPx - vpSize - 32)}px`;
182
+ }
183
+ }
184
+
185
+ // ================= Подписи граней =================
186
+ #addFaceLabels() {
187
+ const makeFaceTexture = (text) => {
188
+ const size = 512; // квадрат для равномерности
189
+ const canvas = document.createElement('canvas');
190
+ canvas.width = size; canvas.height = size;
191
+ const ctx = canvas.getContext('2d');
192
+ if (!ctx) return null;
193
+ ctx.clearRect(0, 0, size, size);
194
+ // Чёрный крупный текст по центру
195
+ ctx.fillStyle = '#000';
196
+ // Уменьшаем шрифт примерно в 1.2 раза относительно прошлого (0.42 -> ~0.35)
197
+ const fontPx = Math.floor(size * 0.35);
198
+ ctx.font = `bold ${fontPx}px sans-serif`;
199
+ ctx.textAlign = 'center';
200
+ ctx.textBaseline = 'middle';
201
+ ctx.fillText(text.toUpperCase(), size / 2, size / 2);
202
+ const tex = new THREE.CanvasTexture(canvas);
203
+ tex.minFilter = THREE.LinearFilter;
204
+ tex.magFilter = THREE.LinearFilter;
205
+ return tex;
206
+ };
207
+
208
+ const makeFaceLabelMesh = (text, normal) => {
209
+ const tex = makeFaceTexture(text);
210
+ if (!tex) return null;
211
+ const mat = new THREE.MeshBasicMaterial({ map: tex, transparent: true, depthTest: true, depthWrite: false });
212
+ // Уменьшаем паддинги плашки: максимально приближаем к размеру грани
213
+ const plane = new THREE.Mesh(new THREE.PlaneGeometry(0.995, 0.995), mat);
214
+ // Ориентируем плоскость, чтобы нормаль смотрела как normal
215
+ const q = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), normal);
216
+ plane.setRotationFromQuaternion(q);
217
+ // Позиционируем чуть снаружи от грани
218
+ plane.position.copy(normal.clone().multiplyScalar(0.505));
219
+ // Компенсируем масштаб куба, чтобы размер текста остался прежним
220
+ const s = this._cubeScale || 1;
221
+ plane.scale.setScalar(1 / s);
222
+ plane.renderOrder = 1001;
223
+ return plane;
224
+ };
225
+
226
+ // Переназначаем подписи: слева->спереди, справа->сзади, сзади->слева, спереди->справа
227
+ const faces = [
228
+ { text: 'сверху', normal: new THREE.Vector3(0, 1, 0) }, // +Y
229
+ { text: 'снизу', normal: new THREE.Vector3(0, -1, 0) }, // -Y
230
+ { text: 'справа', normal: new THREE.Vector3(0, 0, 1) }, // +Z (было "спереди") -> "справа"
231
+ { text: 'слева', normal: new THREE.Vector3(0, 0, -1) }, // -Z (было "сзади") -> "слева"
232
+ { text: 'спереди',normal: new THREE.Vector3(-1, 0, 0) }, // -X (было "слева") -> "спереди"
233
+ { text: 'сзади', normal: new THREE.Vector3(1, 0, 0) }, // +X (было "справа") -> "сзади"
234
+ ];
235
+
236
+ faces.forEach(({ text, normal }) => {
237
+ const mesh = makeFaceLabelMesh(text, normal);
238
+ if (mesh) this.cube.add(mesh);
239
+ });
240
+ }
241
+
242
+ // ================= Ввод мыши =================
243
+ _isInsideOverlay(clientX, clientY) {
244
+ const canvas = this.renderer.domElement;
245
+ const rect = canvas.getBoundingClientRect();
246
+ const fullW = Math.max(1, Math.floor(rect.width));
247
+ const fullH = Math.max(1, Math.floor(rect.height));
248
+ const vpSize = Math.min(this.sizePx, Math.min(fullW, fullH));
249
+ const x = rect.left + fullW - this.marginPx - vpSize;
250
+ const y = rect.top + this.marginPx;
251
+ return clientX >= x && clientX <= x + vpSize && clientY >= y && clientY <= y + vpSize;
252
+ }
253
+
254
+ _toNdcInOverlay(clientX, clientY) {
255
+ const canvas = this.renderer.domElement;
256
+ const rect = canvas.getBoundingClientRect();
257
+ const fullW = Math.max(1, Math.floor(rect.width));
258
+ const fullH = Math.max(1, Math.floor(rect.height));
259
+ const vpSize = Math.min(this.sizePx, Math.min(fullW, fullH));
260
+ const x = rect.left + fullW - this.marginPx - vpSize;
261
+ const y = rect.top + this.marginPx;
262
+ // локальные координаты внутри вьюпорта
263
+ const lx = (clientX - x) / vpSize;
264
+ const ly = (clientY - y) / vpSize;
265
+ // в NDC [-1,1]
266
+ this.pointerNdc.set(lx * 2 - 1, -(ly * 2 - 1));
267
+ }
268
+
269
+ _onPointerMove(e) {
270
+ this._isPointerInside = this._isInsideOverlay(e.clientX, e.clientY);
271
+ }
272
+
273
+ _onPointerDown(e) {
274
+ if (!this._isInsideOverlay(e.clientX, e.clientY)) return;
275
+ // Блокируем орбит-контролы на время клика по кубу
276
+ e.preventDefault();
277
+ this._lastDownPos = { x: e.clientX, y: e.clientY };
278
+ }
279
+
280
+ _onPointerUp(e) {
281
+ if (!this._isInsideOverlay(e.clientX, e.clientY)) return;
282
+ e.preventDefault();
283
+ if (this._lastDownPos) {
284
+ const dx = e.clientX - this._lastDownPos.x;
285
+ const dy = e.clientY - this._lastDownPos.y;
286
+ if (dx * dx + dy * dy <= this._clickTolerance * this._clickTolerance) {
287
+ this._handleClick(e.clientX, e.clientY);
288
+ }
289
+ }
290
+ this._lastDownPos = null;
291
+ }
292
+
293
+ _handleClick(clientX, clientY) {
294
+ this._toNdcInOverlay(clientX, clientY);
295
+ this.raycaster.setFromCamera(this.pointerNdc, this.camera);
296
+ const intersects = this.raycaster.intersectObject(this.cube, false);
297
+ if (!intersects || intersects.length === 0) return;
298
+ const hit = intersects[0];
299
+ // Точка пересечения в локальных координатах куба
300
+ const localPoint = this.cube.worldToLocal(hit.point.clone());
301
+ const dir = this._directionFromLocalPoint(localPoint);
302
+ this._animateCameraTo(dir);
303
+ }
304
+
305
+ // Определяем тип/направление по точке на поверхности куба
306
+ _directionFromLocalPoint(p) {
307
+ // Локальный куб размера 1: поверхность на координатах = ±0.5 по одной из осей
308
+ const ax = Math.abs(p.x);
309
+ const ay = Math.abs(p.y);
310
+ const az = Math.abs(p.z);
311
+ const sgn = (v) => (v >= 0 ? 1 : -1);
312
+
313
+ // Какая ось зафиксирована (лицевая грань)
314
+ let axis = 0; // 0=x, 1=y, 2=z
315
+ let s = 1;
316
+ if (ax >= ay && ax >= az) { axis = 0; s = sgn(p.x); }
317
+ else if (ay >= ax && ay >= az) { axis = 1; s = sgn(p.y); }
318
+ else { axis = 2; s = sgn(p.z); }
319
+
320
+ // Проекционные координаты вдоль других осей для определения крайности
321
+ const u = axis === 0 ? p.y : p.x; // первая свободная ось
322
+ const v = axis === 2 ? p.y : p.z; // вторая свободная ось (подобрано так, чтобы покрыть все случаи)
323
+ const au = Math.abs(u);
324
+ const av = Math.abs(v);
325
+
326
+ // Порог близости к ребру/углу
327
+ const edgeThresh = 0.35; // ближе к 0.5 — ребра/углы
328
+
329
+ let dir = new THREE.Vector3();
330
+ if (au > edgeThresh && av > edgeThresh) {
331
+ // Угол — сумма трёх осей
332
+ const vx = axis === 0 ? s : sgn(p.x);
333
+ const vy = axis === 1 ? s : sgn(p.y);
334
+ const vz = axis === 2 ? s : sgn(p.z);
335
+ dir.set(vx, vy, vz).normalize();
336
+ } else if (au > edgeThresh || av > edgeThresh) {
337
+ // Ребро — сумма двух осей
338
+ if (axis === 0) dir.set(s, sgn(p.y), 0);
339
+ else if (axis === 1) dir.set(sgn(p.x), s, 0);
340
+ else dir.set(sgn(p.x), sgn(p.y), s);
341
+ dir.normalize();
342
+ } else {
343
+ // Лицевая грань — единичный вектор по оси
344
+ if (axis === 0) dir.set(s, 0, 0);
345
+ else if (axis === 1) dir.set(0, s, 0);
346
+ else dir.set(0, 0, s);
347
+ }
348
+ return dir;
349
+ }
350
+
351
+ // ======== Анимация камеры к новому направлению ========
352
+ _animateCameraTo(direction) {
353
+ if (!this.mainCamera || !this.controls) return;
354
+ const target = this.controls.target.clone();
355
+ const dist = this.mainCamera.position.distanceTo(target);
356
+
357
+ const newPos = target.clone().add(direction.clone().normalize().multiplyScalar(dist));
358
+ const newUp = Math.abs(direction.y) > 0.9 ? new THREE.Vector3(0, 0, 1) : new THREE.Vector3(0, 1, 0);
359
+
360
+ this._startPos.copy(this.mainCamera.position);
361
+ this._startUp.copy(this.mainCamera.up);
362
+ this._targetPos.copy(newPos);
363
+ this._targetUp.copy(newUp);
364
+ this._tweenStart = performance.now();
365
+ this._tweenActive = true;
366
+
367
+ const step = () => {
368
+ if (!this._tweenActive) return;
369
+ const t = (performance.now() - this._tweenStart) / this._tweenDuration;
370
+ const k = t >= 1 ? 1 : this._easeInOutCubic(t);
371
+
372
+ // Интерполяция позиции
373
+ this.mainCamera.position.copy(this._startPos.clone().lerp(this._targetPos, k));
374
+ // Интерполяция up-вектора
375
+ const up = this._startUp.clone().lerp(this._targetUp, k).normalize();
376
+ this.mainCamera.up.copy(up);
377
+ this.mainCamera.lookAt(target);
378
+ this.mainCamera.updateProjectionMatrix();
379
+ if (this.controls) this.controls.update();
380
+
381
+ if (t >= 1) {
382
+ this._tweenActive = false;
383
+ return;
384
+ }
385
+ requestAnimationFrame(step);
386
+ };
387
+ requestAnimationFrame(step);
388
+ }
389
+
390
+ _easeInOutCubic(x) {
391
+ return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
392
+ }
393
+ }
394
+
395
+