@sequent-org/ifc-viewer 1.0.2-ci.2.0 → 1.0.2-ci.5.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.
@@ -0,0 +1,785 @@
1
+ /**
2
+ * Класс IfcViewer - основная точка входа для просмотра IFC моделей
3
+ * Инкапсулирует всю логику создания и управления 3D просмотрщиком
4
+ *
5
+ * Пример использования:
6
+ * const viewer = new IfcViewer({
7
+ * container: document.getElementById('modal-content'),
8
+ * ifcUrl: '/path/to/model.ifc'
9
+ * })
10
+ * await viewer.init()
11
+ */
12
+
13
+ import { Viewer } from "./viewer/Viewer.js";
14
+ import { IfcService } from "./ifc/IfcService.js";
15
+ import { IfcTreeView } from "./ifc/IfcTreeView.js";
16
+
17
+ export class IfcViewer {
18
+ /**
19
+ * Создаёт новый экземпляр IfcViewer
20
+ * @param {Object} options - Параметры конфигурации
21
+ * @param {HTMLElement|string} options.container - Контейнер для рендера (элемент или селектор)
22
+ * @param {string} [options.ifcUrl] - URL для загрузки IFC файла
23
+ * @param {File} [options.ifcFile] - File объект для загрузки IFC файла
24
+ * @param {boolean} [options.showSidebar=false] - Показывать ли боковую панель с деревом
25
+ * @param {boolean} [options.showControls=false] - Показывать ли панель управления (нижние кнопки)
26
+ * @param {boolean} [options.showToolbar=true] - Показывать ли верхнюю панель инструментов
27
+ * @param {boolean} [options.autoLoad=true] - Автоматически загружать IFC файл при инициализации
28
+ * @param {string} [options.theme='light'] - Тема интерфейса ('light' | 'dark')
29
+ * @param {Object} [options.viewerOptions] - Дополнительные опции для Viewer
30
+ */
31
+ constructor(options = {}) {
32
+ // Валидация параметров
33
+ if (!options.container) {
34
+ throw new Error('IfcViewer: параметр container обязателен');
35
+ }
36
+
37
+ // Получение контейнера
38
+ this.containerElement = typeof options.container === 'string'
39
+ ? document.querySelector(options.container)
40
+ : options.container;
41
+
42
+ if (!this.containerElement) {
43
+ throw new Error('IfcViewer: контейнер не найден');
44
+ }
45
+
46
+ // Сохранение конфигурации
47
+ this.options = {
48
+ ifcUrl: options.ifcUrl || null,
49
+ ifcFile: options.ifcFile || null,
50
+ showSidebar: options.showSidebar === true, // по умолчанию false
51
+ showControls: options.showControls === true, // по умолчанию false
52
+ showToolbar: options.showToolbar !== false, // по умолчанию true
53
+ autoLoad: options.autoLoad !== false,
54
+ theme: options.theme || 'light',
55
+ viewerOptions: options.viewerOptions || {}
56
+ };
57
+
58
+ // Внутренние компоненты
59
+ this.viewer = null;
60
+ this.ifcService = null;
61
+ this.ifcTreeView = null;
62
+ this.isInitialized = false;
63
+ this.currentModel = null;
64
+
65
+ // DOM элементы интерфейса
66
+ this.elements = {
67
+ viewerContainer: null,
68
+ sidebar: null,
69
+ controls: null,
70
+ uploadInput: null
71
+ };
72
+
73
+ // Слушатели событий для очистки
74
+ this.eventListeners = new Map();
75
+
76
+ // Внутренние состояния управления
77
+ this.viewerState = {
78
+ quality: 'medium', // 'low' | 'medium' | 'high'
79
+ edgesVisible: true,
80
+ flatShading: true,
81
+ clipping: {
82
+ x: false,
83
+ y: false,
84
+ z: false,
85
+ active: null // текущая активная ось
86
+ }
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Инициализирует просмотрщик и создаёт интерфейс
92
+ * @returns {Promise<void>}
93
+ */
94
+ async init() {
95
+ if (this.isInitialized) {
96
+ console.warn('IfcViewer: уже инициализирован');
97
+ return;
98
+ }
99
+
100
+ try {
101
+ // Создаём разметку интерфейса
102
+ this._createInterface();
103
+
104
+ // Применяем тему
105
+ this._applyTheme();
106
+
107
+ // Инициализируем компоненты
108
+ this._initViewer();
109
+ this._initIfcService();
110
+ this._initTreeView();
111
+
112
+ // Настраиваем обработчики событий
113
+ this._setupEventHandlers();
114
+
115
+ // Автозагрузка файла если указан
116
+ if (this.options.autoLoad && (this.options.ifcUrl || this.options.ifcFile)) {
117
+ await this.loadModel();
118
+ }
119
+
120
+ this.isInitialized = true;
121
+
122
+ // Диспетчируем событие готовности
123
+ this._dispatchEvent('ready', { viewer: this });
124
+
125
+ } catch (error) {
126
+ console.error('IfcViewer: ошибка инициализации', error);
127
+ throw error;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Загружает IFC модель из URL или File
133
+ * @param {string|File} [source] - Источник модели (URL или File). Если не указан, использует из options
134
+ * @returns {Promise<Object|null>} - Загруженная модель или null при ошибке
135
+ */
136
+ async loadModel(source) {
137
+ if (!this.ifcService) {
138
+ throw new Error('IfcViewer: не инициализирован. Вызовите init() сначала');
139
+ }
140
+
141
+ try {
142
+ let model = null;
143
+ const loadSource = source || this.options.ifcUrl || this.options.ifcFile;
144
+
145
+ if (!loadSource) {
146
+ throw new Error('Не указан источник IFC модели');
147
+ }
148
+
149
+ // Показываем прелоадер если есть
150
+ this._showPreloader();
151
+
152
+ // Загружаем модель в зависимости от типа источника
153
+ if (typeof loadSource === 'string') {
154
+ model = await this.ifcService.loadUrl(loadSource);
155
+ } else if (loadSource instanceof File) {
156
+ model = await this.ifcService.loadFile(loadSource);
157
+ } else {
158
+ throw new Error('Неподдерживаемый тип источника модели');
159
+ }
160
+
161
+ if (model) {
162
+ this.currentModel = model;
163
+
164
+ // Обновляем дерево структуры
165
+ await this._updateTreeView(model);
166
+
167
+ // Обновляем информационную панель
168
+ this._updateInfoPanel();
169
+
170
+ // Показываем сайдбар при успешной загрузке
171
+ if (this.options.showSidebar) {
172
+ this._setSidebarVisible(true);
173
+ }
174
+
175
+ // Диспетчируем событие загрузки модели
176
+ this._dispatchEvent('model-loaded', { model, viewer: this });
177
+ }
178
+
179
+ this._hidePreloader();
180
+ return model;
181
+
182
+ } catch (error) {
183
+ console.error('IfcViewer: ошибка загрузки модели', error);
184
+ this._hidePreloader();
185
+ this._dispatchEvent('error', { error, viewer: this });
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Освобождает ресурсы и очищает интерфейс
192
+ */
193
+ dispose() {
194
+ if (!this.isInitialized) return;
195
+
196
+ // Очищаем слушатели событий
197
+ this.eventListeners.forEach((listener, key) => {
198
+ const [element, event] = key.split('.');
199
+ const el = element === 'document' ? document :
200
+ element === 'window' ? window : this.elements[element];
201
+ if (el && listener) {
202
+ el.removeEventListener(event, listener);
203
+ }
204
+ });
205
+ this.eventListeners.clear();
206
+
207
+ // Освобождаем компоненты
208
+ if (this.ifcService) {
209
+ this.ifcService.dispose();
210
+ this.ifcService = null;
211
+ }
212
+
213
+ if (this.viewer) {
214
+ this.viewer.dispose();
215
+ this.viewer = null;
216
+ }
217
+
218
+ // Очищаем DOM
219
+ if (this.containerElement) {
220
+ this.containerElement.innerHTML = '';
221
+ }
222
+
223
+ this.isInitialized = false;
224
+ this.currentModel = null;
225
+
226
+ // Диспетчируем событие освобождения ресурсов
227
+ this._dispatchEvent('disposed', { viewer: this });
228
+ }
229
+
230
+ /**
231
+ * Получает информацию о текущей модели
232
+ * @returns {Object|null} Информация о модели или null
233
+ */
234
+ getModelInfo() {
235
+ if (!this.ifcService) return null;
236
+ return this.ifcService.getLastInfo();
237
+ }
238
+
239
+ /**
240
+ * Получает экземпляр Viewer для прямого доступа к функциям просмотра
241
+ * @returns {Viewer|null}
242
+ */
243
+ getViewer() {
244
+ return this.viewer;
245
+ }
246
+
247
+ /**
248
+ * Получает экземпляр IfcService для работы с IFC данными
249
+ * @returns {IfcService|null}
250
+ */
251
+ getIfcService() {
252
+ return this.ifcService;
253
+ }
254
+
255
+ /**
256
+ * Устанавливает видимость боковой панели
257
+ * @param {boolean} visible - Показать или скрыть
258
+ */
259
+ setSidebarVisible(visible) {
260
+ this._setSidebarVisible(visible);
261
+ }
262
+
263
+ /**
264
+ * Переключает тему интерфейса
265
+ * @param {string} theme - Новая тема ('light' | 'dark')
266
+ */
267
+ setTheme(theme) {
268
+ this.options.theme = theme;
269
+ this._applyTheme();
270
+ }
271
+
272
+ // ==================== ПРИВАТНЫЕ МЕТОДЫ ====================
273
+
274
+ /**
275
+ * Создаёт HTML разметку интерфейса
276
+ * @private
277
+ */
278
+ _createInterface() {
279
+ // Основная разметка просмотрщика
280
+ const html = `
281
+ <div class="ifc-viewer-container" style="width: 100%; height: 100%; position: relative; display: flex; flex-direction: column;">
282
+ <!-- Верхняя панель управления -->
283
+ <div id="ifcToolbar" class="navbar bg-neutral text-neutral-content shrink-0 px-4" style="${this.options.showToolbar ? '' : 'display: none;'}">
284
+ <div class="navbar-start">
285
+ <span class="text-lg font-semibold">IFC Viewer</span>
286
+ </div>
287
+ <div class="navbar-end flex gap-2">
288
+ <!-- Качество рендеринга -->
289
+ <div class="join">
290
+ <button class="btn btn-sm join-item" id="ifcQualLow">Low</button>
291
+ <button class="btn btn-sm join-item btn-active" id="ifcQualMed">Med</button>
292
+ <button class="btn btn-sm join-item" id="ifcQualHigh">High</button>
293
+ </div>
294
+
295
+ <!-- Стили отображения -->
296
+ <div class="join">
297
+ <button class="btn btn-sm join-item btn-active" id="ifcToggleEdges">Edges</button>
298
+ <button class="btn btn-sm join-item btn-active" id="ifcToggleShading">Flat</button>
299
+ </div>
300
+
301
+ <!-- Секущие плоскости -->
302
+ <div class="join">
303
+ <button class="btn btn-sm join-item" id="ifcClipX">Clip X</button>
304
+ <button class="btn btn-sm join-item" id="ifcClipY">Clip Y</button>
305
+ <button class="btn btn-sm join-item" id="ifcClipZ">Clip Z</button>
306
+ </div>
307
+
308
+ <!-- Кнопка загрузки файла -->
309
+ <button id="ifcUploadBtnTop" class="btn btn-sm bg-white text-black">📁 Загрузить</button>
310
+ </div>
311
+ </div>
312
+
313
+ <!-- Слайдеры секущих плоскостей (изначально скрыты) -->
314
+ <div id="ifcClipControls" class="bg-base-200 px-4 py-2 border-b border-base-300" style="display: ${this.options.showToolbar ? 'none' : 'none'};">
315
+ <div class="flex items-center gap-4 text-sm">
316
+ <!-- Слайдер X -->
317
+ <div id="ifcClipXControl" class="flex items-center gap-2" style="display: none;">
318
+ <span class="w-12">Clip X:</span>
319
+ <input type="range" id="ifcClipXRange" class="range range-sm flex-1" min="0" max="1" step="0.01" value="0.5">
320
+ </div>
321
+ <!-- Слайдер Y -->
322
+ <div id="ifcClipYControl" class="flex items-center gap-2" style="display: none;">
323
+ <span class="w-12">Clip Y:</span>
324
+ <input type="range" id="ifcClipYRange" class="range range-sm flex-1" min="0" max="1" step="0.01" value="0.5">
325
+ </div>
326
+ <!-- Слайдер Z -->
327
+ <div id="ifcClipZControl" class="flex items-center gap-2" style="display: none;">
328
+ <span class="w-12">Clip Z:</span>
329
+ <input type="range" id="ifcClipZRange" class="range range-sm flex-1" min="0" max="1" step="0.01" value="0.5">
330
+ </div>
331
+ </div>
332
+ </div>
333
+
334
+ <!-- Прелоадер -->
335
+ <div id="ifcPreloader" class="absolute inset-0 bg-base-100 flex items-center justify-center z-50">
336
+ <div class="text-center">
337
+ <span class="loading loading-spinner loading-lg"></span>
338
+ <div class="mt-2 text-sm opacity-70">Загрузка модели...</div>
339
+ </div>
340
+ </div>
341
+
342
+ <!-- Основной контейнер просмотрщика -->
343
+ <div id="ifcViewerMain" class="w-full flex-1 relative"></div>
344
+
345
+ <!-- Боковая панель (временно скрыта) -->
346
+ <div id="ifcSidebar" class="absolute left-0 top-0 h-full w-80 bg-base-200 shadow-lg transform -translate-x-full transition-transform duration-300 pointer-events-none z-40" style="display: none;">
347
+ <div class="flex flex-col h-full">
348
+ <!-- Заголовок панели -->
349
+ <div class="flex items-center justify-between p-4 border-b border-base-300">
350
+ <h3 class="font-medium">Структура модели</h3>
351
+ <button id="ifcSidebarClose" class="btn btn-ghost btn-sm">✕</button>
352
+ </div>
353
+
354
+ <!-- Информация о модели -->
355
+ <div id="ifcInfo" class="p-4 border-b border-base-300 bg-base-100">
356
+ <div class="text-sm opacity-70">Модель не загружена</div>
357
+ </div>
358
+
359
+ <!-- Дерево структуры -->
360
+ <div class="flex-1 overflow-auto">
361
+ <div id="ifcTree" class="p-2"></div>
362
+ </div>
363
+
364
+ <!-- Переключатель режима изоляции -->
365
+ <div class="p-4 border-t border-base-300">
366
+ <label class="label cursor-pointer">
367
+ <span class="label-text">Режим изоляции</span>
368
+ <input type="checkbox" id="ifcIsolateToggle" class="toggle toggle-primary" />
369
+ </label>
370
+ </div>
371
+ </div>
372
+ </div>
373
+
374
+ <!-- Кнопка сайдбара (временно скрыта) -->
375
+ <div id="ifcSidebarToggleContainer" class="absolute top-4 left-4 z-30" style="display: none;">
376
+ <button id="ifcSidebarToggle" class="btn btn-primary btn-sm">☰</button>
377
+ </div>
378
+
379
+ <!-- Панель управления (дополнительные кнопки) -->
380
+ <div id="ifcControls" class="absolute top-4 left-4 z-30" style="${this.options.showControls ? 'margin-top: 3rem;' : 'display: none;'}">
381
+ <!-- Кнопка загрузки -->
382
+ <button id="ifcUploadBtn" class="btn btn-secondary btn-sm">📁</button>
383
+ <input type="file" id="ifcFileInput" accept=".ifc,.ifczip,.zip" style="display: none;">
384
+ </div>
385
+
386
+ <!-- Панель зума (будет создана Viewer'ом) -->
387
+ <div id="ifcZoomPanel" class="absolute bottom-4 right-4 z-30"></div>
388
+ </div>
389
+ `;
390
+
391
+ this.containerElement.innerHTML = html;
392
+
393
+ // Сохраняем ссылки на элементы
394
+ this.elements.viewerContainer = this.containerElement.querySelector('#ifcViewerMain');
395
+ this.elements.sidebar = this.containerElement.querySelector('#ifcSidebar');
396
+ this.elements.controls = this.containerElement.querySelector('#ifcControls');
397
+ this.elements.uploadInput = this.containerElement.querySelector('#ifcFileInput');
398
+ }
399
+
400
+ /**
401
+ * Применяет тему интерфейса
402
+ * @private
403
+ */
404
+ _applyTheme() {
405
+ const container = this.containerElement.querySelector('.ifc-viewer-container');
406
+ if (container) {
407
+ container.setAttribute('data-theme', this.options.theme);
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Инициализирует компонент Viewer
413
+ * @private
414
+ */
415
+ _initViewer() {
416
+ if (!this.elements.viewerContainer) {
417
+ throw new Error('Контейнер для viewer не найден');
418
+ }
419
+
420
+ this.viewer = new Viewer(this.elements.viewerContainer);
421
+ this.viewer.init();
422
+ }
423
+
424
+ /**
425
+ * Инициализирует сервис IFC
426
+ * @private
427
+ */
428
+ _initIfcService() {
429
+ if (!this.viewer) {
430
+ throw new Error('Viewer должен быть инициализирован перед IfcService');
431
+ }
432
+
433
+ this.ifcService = new IfcService(this.viewer);
434
+ this.ifcService.init();
435
+ }
436
+
437
+ /**
438
+ * Инициализирует компонент дерева IFC
439
+ * @private
440
+ */
441
+ _initTreeView() {
442
+ const treeElement = this.containerElement.querySelector('#ifcTree');
443
+ if (treeElement) {
444
+ this.ifcTreeView = new IfcTreeView(treeElement);
445
+
446
+ // Настраиваем обработчик выбора узла
447
+ this.ifcTreeView.onSelect(async (node) => {
448
+ if (this.ifcService) {
449
+ const ids = this.ifcService.collectElementIDsFromStructure(node);
450
+ await this.ifcService.highlightByIds(ids);
451
+ }
452
+ });
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Настраивает обработчики событий
458
+ * @private
459
+ */
460
+ _setupEventHandlers() {
461
+ // Кнопка переключения сайдбара
462
+ this._addEventListener('#ifcSidebarToggle', 'click', () => {
463
+ this._setSidebarVisible(true);
464
+ });
465
+
466
+ // Кнопка закрытия сайдбара
467
+ this._addEventListener('#ifcSidebarClose', 'click', () => {
468
+ this._setSidebarVisible(false);
469
+ });
470
+
471
+ // Загрузка файла
472
+ this._addEventListener('#ifcUploadBtn', 'click', () => {
473
+ this.elements.uploadInput?.click();
474
+ });
475
+
476
+ this._addEventListener('#ifcFileInput', 'change', async (e) => {
477
+ const file = e.target.files?.[0];
478
+ if (file) {
479
+ await this.loadModel(file);
480
+ e.target.value = ''; // Очистка input
481
+ }
482
+ });
483
+
484
+ // Переключатель изоляции
485
+ this._addEventListener('#ifcIsolateToggle', 'change', (e) => {
486
+ if (this.ifcService) {
487
+ this.ifcService.setIsolateMode(e.target.checked);
488
+ }
489
+ });
490
+
491
+ // ==================== ОБРАБОТЧИКИ ВЕРХНЕЙ ПАНЕЛИ ====================
492
+
493
+ // Кнопки качества рендеринга
494
+ this._addEventListener('#ifcQualLow', 'click', () => {
495
+ this._setQuality('low');
496
+ });
497
+ this._addEventListener('#ifcQualMed', 'click', () => {
498
+ this._setQuality('medium');
499
+ });
500
+ this._addEventListener('#ifcQualHigh', 'click', () => {
501
+ this._setQuality('high');
502
+ });
503
+
504
+ // Переключатели отображения
505
+ this._addEventListener('#ifcToggleEdges', 'click', () => {
506
+ this._toggleEdges();
507
+ });
508
+ this._addEventListener('#ifcToggleShading', 'click', () => {
509
+ this._toggleShading();
510
+ });
511
+
512
+ // Секущие плоскости
513
+ this._addEventListener('#ifcClipX', 'click', () => {
514
+ this._toggleClipAxis('x');
515
+ });
516
+ this._addEventListener('#ifcClipY', 'click', () => {
517
+ this._toggleClipAxis('y');
518
+ });
519
+ this._addEventListener('#ifcClipZ', 'click', () => {
520
+ this._toggleClipAxis('z');
521
+ });
522
+
523
+ // Слайдеры позиции секущих плоскостей
524
+ this._addEventListener('#ifcClipXRange', 'input', (e) => {
525
+ if (this.viewer && this.viewerState.clipping.x) {
526
+ const t = parseFloat(e.target.value);
527
+ this.viewer.setSectionNormalized('x', true, t);
528
+ }
529
+ });
530
+ this._addEventListener('#ifcClipYRange', 'input', (e) => {
531
+ if (this.viewer && this.viewerState.clipping.y) {
532
+ const t = parseFloat(e.target.value);
533
+ this.viewer.setSectionNormalized('y', true, t);
534
+ }
535
+ });
536
+ this._addEventListener('#ifcClipZRange', 'input', (e) => {
537
+ if (this.viewer && this.viewerState.clipping.z) {
538
+ const t = parseFloat(e.target.value);
539
+ this.viewer.setSectionNormalized('z', true, t);
540
+ }
541
+ });
542
+
543
+ // Дополнительная кнопка загрузки в верхней панели
544
+ this._addEventListener('#ifcUploadBtnTop', 'click', () => {
545
+ this.elements.uploadInput?.click();
546
+ });
547
+ }
548
+
549
+ /**
550
+ * Обновляет дерево структуры модели
551
+ * @param {Object} model - Загруженная модель
552
+ * @private
553
+ */
554
+ async _updateTreeView(model) {
555
+ if (!this.ifcTreeView || !this.ifcService || !model) return;
556
+
557
+ try {
558
+ const structure = await this.ifcService.getSpatialStructure(model.modelID);
559
+ if (structure) {
560
+ this.ifcTreeView.render(structure);
561
+ }
562
+ } catch (error) {
563
+ console.error('Ошибка обновления дерева структуры:', error);
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Обновляет информационную панель
569
+ * @private
570
+ */
571
+ _updateInfoPanel() {
572
+ const infoElement = this.containerElement.querySelector('#ifcInfo');
573
+ if (!infoElement || !this.ifcService) return;
574
+
575
+ const info = this.ifcService.getLastInfo();
576
+ infoElement.innerHTML = `
577
+ <div class="flex items-center justify-between">
578
+ <div>
579
+ <div class="font-medium text-xs">${info.name || '—'}</div>
580
+ <div class="opacity-70">modelID: ${info.modelID || '—'}</div>
581
+ </div>
582
+ </div>
583
+ `;
584
+ }
585
+
586
+ /**
587
+ * Показывает/скрывает боковую панель
588
+ * @param {boolean} visible - Видимость панели
589
+ * @private
590
+ */
591
+ _setSidebarVisible(visible) {
592
+ const sidebar = this.containerElement.querySelector('#ifcSidebar');
593
+ if (!sidebar) return;
594
+
595
+ if (visible) {
596
+ sidebar.classList.remove('-translate-x-full');
597
+ sidebar.classList.add('translate-x-0');
598
+ sidebar.classList.remove('pointer-events-none');
599
+ } else {
600
+ sidebar.classList.add('-translate-x-full');
601
+ sidebar.classList.remove('translate-x-0');
602
+ sidebar.classList.add('pointer-events-none');
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Показывает прелоадер
608
+ * @private
609
+ */
610
+ _showPreloader() {
611
+ const preloader = this.containerElement.querySelector('#ifcPreloader');
612
+ if (preloader) {
613
+ preloader.style.opacity = '1';
614
+ preloader.style.visibility = 'visible';
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Скрывает прелоадер
620
+ * @private
621
+ */
622
+ _hidePreloader() {
623
+ const preloader = this.containerElement.querySelector('#ifcPreloader');
624
+ if (preloader) {
625
+ preloader.style.transition = 'opacity 400ms ease';
626
+ preloader.style.opacity = '0';
627
+ setTimeout(() => {
628
+ preloader.style.visibility = 'hidden';
629
+ }, 400);
630
+ }
631
+ }
632
+
633
+ /**
634
+ * Добавляет слушатель события с автоматической очисткой
635
+ * @param {string} selector - Селектор элемента
636
+ * @param {string} event - Тип события
637
+ * @param {Function} handler - Обработчик события
638
+ * @private
639
+ */
640
+ _addEventListener(selector, event, handler) {
641
+ const element = this.containerElement.querySelector(selector);
642
+ if (element) {
643
+ element.addEventListener(event, handler);
644
+ this.eventListeners.set(`${selector}.${event}`, handler);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Отправляет пользовательское событие
650
+ * @param {string} eventName - Имя события
651
+ * @param {Object} detail - Детали события
652
+ * @private
653
+ */
654
+ _dispatchEvent(eventName, detail = {}) {
655
+ try {
656
+ const event = new CustomEvent(`ifcviewer:${eventName}`, {
657
+ detail,
658
+ bubbles: true
659
+ });
660
+ this.containerElement.dispatchEvent(event);
661
+ } catch (error) {
662
+ console.error('Ошибка отправки события:', error);
663
+ }
664
+ }
665
+
666
+ // ==================== МЕТОДЫ УПРАВЛЕНИЯ ВЕРХНЕЙ ПАНЕЛИ ====================
667
+
668
+ /**
669
+ * Устанавливает качество рендеринга
670
+ * @param {string} preset - Качество ('low' | 'medium' | 'high')
671
+ * @private
672
+ */
673
+ _setQuality(preset) {
674
+ if (!this.viewer) return;
675
+
676
+ this.viewerState.quality = preset;
677
+ this.viewer.setQuality(preset);
678
+
679
+ // Обновляем активное состояние кнопок
680
+ const buttons = ['#ifcQualLow', '#ifcQualMed', '#ifcQualHigh'];
681
+ const activeButton = preset === 'low' ? '#ifcQualLow' :
682
+ preset === 'high' ? '#ifcQualHigh' : '#ifcQualMed';
683
+
684
+ buttons.forEach(selector => {
685
+ const btn = this.containerElement.querySelector(selector);
686
+ if (btn) {
687
+ btn.classList.toggle('btn-active', selector === activeButton);
688
+ }
689
+ });
690
+ }
691
+
692
+ /**
693
+ * Переключает отображение граней
694
+ * @private
695
+ */
696
+ _toggleEdges() {
697
+ if (!this.viewer) return;
698
+
699
+ this.viewerState.edgesVisible = !this.viewerState.edgesVisible;
700
+ this.viewer.setEdgesVisible(this.viewerState.edgesVisible);
701
+
702
+ // Обновляем состояние кнопки
703
+ const btn = this.containerElement.querySelector('#ifcToggleEdges');
704
+ if (btn) {
705
+ btn.classList.toggle('btn-active', this.viewerState.edgesVisible);
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Переключает плоское затенение
711
+ * @private
712
+ */
713
+ _toggleShading() {
714
+ if (!this.viewer) return;
715
+
716
+ this.viewerState.flatShading = !this.viewerState.flatShading;
717
+ this.viewer.setFlatShading(this.viewerState.flatShading);
718
+
719
+ // Обновляем состояние кнопки
720
+ const btn = this.containerElement.querySelector('#ifcToggleShading');
721
+ if (btn) {
722
+ btn.classList.toggle('btn-active', this.viewerState.flatShading);
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Переключает секущую плоскость по оси
728
+ * @param {string} axis - Ось ('x' | 'y' | 'z')
729
+ * @private
730
+ */
731
+ _toggleClipAxis(axis) {
732
+ if (!this.viewer) return;
733
+
734
+ const clipping = this.viewerState.clipping;
735
+ const currentState = clipping[axis];
736
+ const newState = !currentState;
737
+
738
+ // Если включаем новую ось, отключаем предыдущую активную
739
+ if (newState && clipping.active && clipping.active !== axis) {
740
+ const prevAxis = clipping.active;
741
+ clipping[prevAxis] = false;
742
+ this.viewer.setSection(prevAxis, false, 0);
743
+
744
+ // Обновляем кнопку предыдущей оси
745
+ const prevBtn = this.containerElement.querySelector(`#ifcClip${prevAxis.toUpperCase()}`);
746
+ if (prevBtn) prevBtn.classList.remove('btn-active');
747
+
748
+ // Скрываем слайдер предыдущей оси
749
+ const prevControl = this.containerElement.querySelector(`#ifcClip${prevAxis.toUpperCase()}Control`);
750
+ if (prevControl) prevControl.style.display = 'none';
751
+ }
752
+
753
+ // Устанавливаем новое состояние
754
+ clipping[axis] = newState;
755
+ clipping.active = newState ? axis : null;
756
+ this.viewer.setSection(axis, newState, 0);
757
+
758
+ // Обновляем состояние кнопки
759
+ const btn = this.containerElement.querySelector(`#ifcClip${axis.toUpperCase()}`);
760
+ if (btn) {
761
+ btn.classList.toggle('btn-active', newState);
762
+ }
763
+
764
+ // Показываем/скрываем панель слайдеров
765
+ this._updateClipControls();
766
+
767
+ // Показываем/скрываем слайдер текущей оси
768
+ const control = this.containerElement.querySelector(`#ifcClip${axis.toUpperCase()}Control`);
769
+ if (control) {
770
+ control.style.display = newState ? 'flex' : 'none';
771
+ }
772
+ }
773
+
774
+ /**
775
+ * Обновляет видимость панели управления секущими плоскостями
776
+ * @private
777
+ */
778
+ _updateClipControls() {
779
+ const panel = this.containerElement.querySelector('#ifcClipControls');
780
+ if (!panel) return;
781
+
782
+ const hasActiveClipping = this.viewerState.clipping.active !== null;
783
+ panel.style.display = hasActiveClipping ? 'block' : 'none';
784
+ }
785
+ }