@sequent-org/ifc-viewer 1.2.4-ci.61.0 → 1.2.4-ci.63.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sequent-org/ifc-viewer",
3
3
  "private": false,
4
- "version": "1.2.4-ci.61.0",
4
+ "version": "1.2.4-ci.63.0",
5
5
  "type": "module",
6
6
  "description": "IFC 3D model viewer component for web applications - fully self-contained with local IFCLoader",
7
7
  "main": "src/index.js",
package/src/IfcViewer.js CHANGED
@@ -41,6 +41,7 @@ export class IfcViewer {
41
41
  * @param {boolean} [options.showSidebar=false] - Показывать ли боковую панель с деревом
42
42
  * @param {boolean} [options.showControls=false] - Показывать ли панель управления (нижние кнопки)
43
43
  * @param {boolean} [options.showToolbar=true] - Показывать ли верхнюю панель инструментов
44
+ * @param {boolean} [options.labelEditingEnabled=true] - Разрешить ли редактирование меток
44
45
  * @param {boolean} [options.autoLoad=true] - Автоматически загружать модель при инициализации (modelUrl/modelFile/ifcUrl/ifcFile)
45
46
  * @param {string} [options.theme='light'] - Тема интерфейса ('light' | 'dark')
46
47
  * @param {Object} [options.viewerOptions] - Дополнительные опции для Viewer
@@ -74,6 +75,7 @@ export class IfcViewer {
74
75
  showSidebar: options.showSidebar === true, // по умолчанию false
75
76
  showControls: options.showControls === true, // по умолчанию false
76
77
  showToolbar: options.showToolbar !== false, // по умолчанию true
78
+ labelEditingEnabled: options.labelEditingEnabled !== false,
77
79
  autoLoad: options.autoLoad !== false,
78
80
  theme: options.theme || 'light',
79
81
  viewerOptions: options.viewerOptions || {}
@@ -389,6 +391,24 @@ export class IfcViewer {
389
391
  this.labelPlacement.selectLabel(id);
390
392
  }
391
393
 
394
+ /**
395
+ * Включает/выключает режим редактирования меток.
396
+ * @param {boolean} enabled
397
+ */
398
+ setLabelEditingEnabled(enabled) {
399
+ if (!this.labelPlacement) return;
400
+ this.labelPlacement.setEditingEnabled(enabled);
401
+ }
402
+
403
+ /**
404
+ * Возвращает текущий режим редактирования меток.
405
+ * @returns {boolean}
406
+ */
407
+ getLabelEditingEnabled() {
408
+ if (!this.labelPlacement) return false;
409
+ return this.labelPlacement.getEditingEnabled();
410
+ }
411
+
392
412
  /**
393
413
  * @deprecated используйте setLabelMarkers
394
414
  */
@@ -564,7 +584,12 @@ export class IfcViewer {
564
584
  // В пакете включаем UI "меток" по умолчанию:
565
585
  // кнопка "+ Добавить метку" + режим постановки меток + сохранение/восстановление состояния.
566
586
  try {
567
- this.labelPlacement = new LabelPlacementController({ viewer: this.viewer, container: this.elements.viewerContainer, logger: console });
587
+ this.labelPlacement = new LabelPlacementController({
588
+ viewer: this.viewer,
589
+ container: this.elements.viewerContainer,
590
+ logger: console,
591
+ editingEnabled: this.options.labelEditingEnabled,
592
+ });
568
593
  this.cardPlacement = this.labelPlacement;
569
594
  } catch (e) {
570
595
  console.warn('IfcViewer: LabelPlacementController init failed', e);
package/src/main.js CHANGED
@@ -54,6 +54,16 @@ if (app) {
54
54
  // Остальные контролы (тени/солнце/материалы/визуал/цветокор) будем добавлять пошагово позже.
55
55
  // (Старый код управления панелью закомментирован ниже для последующего восстановления при необходимости.)
56
56
 
57
+ // Анимация (damping)
58
+ const dampingDynamic = document.getElementById("dampingDynamic");
59
+ const dampingDynamicValue = document.getElementById("dampingDynamicValue");
60
+ const dampingBase = document.getElementById("dampingBase");
61
+ const dampingBaseValue = document.getElementById("dampingBaseValue");
62
+ const dampingSettle = document.getElementById("dampingSettle");
63
+ const dampingSettleValue = document.getElementById("dampingSettleValue");
64
+ const dampingSettleMs = document.getElementById("dampingSettleMs");
65
+ const dampingSettleMsValue = document.getElementById("dampingSettleMsValue");
66
+
57
67
  const testPresetToggle = document.getElementById("testPresetToggle");
58
68
  // Шаг 1 (Tone mapping): в текущей версии он входит в пресет "Тест", но exposure можно подстроить.
59
69
  const step1ToneToggle = document.getElementById("step1ToneToggle");
@@ -78,6 +88,59 @@ if (app) {
78
88
  const step4SaturationValue = document.getElementById("step4SaturationValue");
79
89
  const step4Dump = document.getElementById("step4Dump");
80
90
 
91
+ const applyDampingDynamic = (v) => {
92
+ const on = Number(v) >= 1;
93
+ if (dampingDynamicValue) dampingDynamicValue.textContent = on ? "1" : "0";
94
+ try { viewer.setDampingConfig?.({ dynamic: on }); } catch (_) {}
95
+ };
96
+ const applyDampingBase = (v) => {
97
+ const value = Number(v);
98
+ if (!Number.isFinite(value)) return;
99
+ if (dampingBaseValue) dampingBaseValue.textContent = value.toFixed(2);
100
+ try { viewer.setDampingConfig?.({ base: value }); } catch (_) {}
101
+ };
102
+ const applyDampingSettle = (v) => {
103
+ const value = Number(v);
104
+ if (!Number.isFinite(value)) return;
105
+ if (dampingSettleValue) dampingSettleValue.textContent = value.toFixed(2);
106
+ try { viewer.setDampingConfig?.({ settle: value }); } catch (_) {}
107
+ };
108
+ const applyDampingSettleMs = (v) => {
109
+ const value = Math.max(0, Math.round(Number(v)));
110
+ if (!Number.isFinite(value)) return;
111
+ if (dampingSettleMsValue) dampingSettleMsValue.textContent = String(value);
112
+ try { viewer.setDampingConfig?.({ settleMs: value }); } catch (_) {}
113
+ };
114
+
115
+ // Инициализация damping UI от текущих параметров viewer
116
+ const initDampingUi = () => {
117
+ const cfg = viewer.getDampingConfig?.() || {};
118
+ if (dampingDynamic) {
119
+ dampingDynamic.value = cfg.dynamic ? "1" : "0";
120
+ applyDampingDynamic(dampingDynamic.value);
121
+ dampingDynamic.addEventListener("input", (e) => applyDampingDynamic(e.target.value));
122
+ }
123
+ if (dampingBase) {
124
+ const v = Number.isFinite(cfg.base) ? cfg.base : Number(dampingBase.value);
125
+ dampingBase.value = Number.isFinite(v) ? String(v) : "0.50";
126
+ applyDampingBase(dampingBase.value);
127
+ dampingBase.addEventListener("input", (e) => applyDampingBase(e.target.value));
128
+ }
129
+ if (dampingSettle) {
130
+ const v = Number.isFinite(cfg.settle) ? cfg.settle : Number(dampingSettle.value);
131
+ dampingSettle.value = Number.isFinite(v) ? String(v) : "0.00";
132
+ applyDampingSettle(dampingSettle.value);
133
+ dampingSettle.addEventListener("input", (e) => applyDampingSettle(e.target.value));
134
+ }
135
+ if (dampingSettleMs) {
136
+ const v = Number.isFinite(cfg.settleMs) ? cfg.settleMs : Number(dampingSettleMs.value);
137
+ dampingSettleMs.value = Number.isFinite(v) ? String(v) : "0";
138
+ applyDampingSettleMs(dampingSettleMs.value);
139
+ dampingSettleMs.addEventListener("input", (e) => applyDampingSettleMs(e.target.value));
140
+ }
141
+ };
142
+ initDampingUi();
143
+
81
144
  const setStep1UiEnabled = (enabled) => {
82
145
  const on = !!enabled;
83
146
  if (step1ToneToggle) step1ToneToggle.checked = on;
@@ -182,6 +182,7 @@
182
182
  display: block;
183
183
  transform: translate(-50%, -50%);
184
184
  will-change: transform;
185
+ transition: opacity 160ms ease;
185
186
  }
186
187
 
187
188
  .ifc-label-ghost,
@@ -223,6 +224,12 @@
223
224
  cursor: pointer;
224
225
  }
225
226
 
227
+ .ifc-label-marker--hidden,
228
+ .ifc-card-marker--hidden {
229
+ opacity: 0;
230
+ pointer-events: none;
231
+ }
232
+
226
233
  /* Клик ловим на контейнере метки, дочерние элементы пусть не перехватывают */
227
234
  .ifc-label-marker .ifc-label-dot,
228
235
  .ifc-label-marker .ifc-label-num,
@@ -13,6 +13,8 @@ class LabelMarker {
13
13
  this.localPoint = deps.localPoint;
14
14
  this.el = deps.el;
15
15
  this.sceneState = deps.sceneState || null;
16
+ this.visible = null;
17
+ this.hiddenReason = null;
16
18
  }
17
19
  }
18
20
 
@@ -34,6 +36,8 @@ export class LabelPlacementController {
34
36
  this.viewer = deps.viewer;
35
37
  this.container = deps.container;
36
38
  this.logger = deps.logger || null;
39
+ this._visibilityLogEnabled = !!deps.visibilityLogEnabled;
40
+ this._editingEnabled = deps?.editingEnabled !== false;
37
41
 
38
42
  this._placing = false;
39
43
  this._nextId = 1;
@@ -46,6 +50,8 @@ export class LabelPlacementController {
46
50
  this._ndc = new THREE.Vector2();
47
51
  this._tmpV = new THREE.Vector3();
48
52
  this._tmpLocal = new THREE.Vector3();
53
+ this._tmpV2 = new THREE.Vector3();
54
+ this._tmpNdc = new THREE.Vector3();
49
55
 
50
56
  this._lastPointer = { x: 0, y: 0 };
51
57
  this._ghostPos = { x: 0, y: 0 };
@@ -97,8 +103,22 @@ export class LabelPlacementController {
97
103
  v2: new THREE.Vector3(),
98
104
  };
99
105
 
106
+ this._autoHide = {
107
+ active: false,
108
+ prevHidden: false,
109
+ };
110
+ this._showAfterStop = {
111
+ raf: 0,
112
+ active: false,
113
+ lastChangeTs: 0,
114
+ idleMs: 0,
115
+ eps: 5e-4,
116
+ lastCamMatrix: new Float32Array(16),
117
+ };
118
+
100
119
  this._ui = this.#createUi();
101
120
  this.#attachUi();
121
+ this.#syncEditingUi();
102
122
  this.#bindEvents();
103
123
  this.#startRaf();
104
124
  }
@@ -140,6 +160,14 @@ export class LabelPlacementController {
140
160
  try { dom?.removeEventListener("contextmenu", this._onCanvasContextMenu, { capture: true }); } catch (_) {
141
161
  try { dom?.removeEventListener("contextmenu", this._onCanvasContextMenu); } catch (_) {}
142
162
  }
163
+ try {
164
+ const controls = this.viewer?.controls;
165
+ if (controls && typeof controls.removeEventListener === "function") {
166
+ try { controls.removeEventListener("start", this._onControlsStart); } catch (_) {}
167
+ try { controls.removeEventListener("end", this._onControlsEnd); } catch (_) {}
168
+ }
169
+ } catch (_) {}
170
+ try { this.#cancelShowAfterStop(); } catch (_) {}
143
171
 
144
172
  if (this._raf) cancelAnimationFrame(this._raf);
145
173
  this._raf = 0;
@@ -155,7 +183,7 @@ export class LabelPlacementController {
155
183
  }
156
184
 
157
185
  startPlacement() {
158
- if (this._placing || this._labelsHidden) return;
186
+ if (!this._editingEnabled || this._placing || this._labelsHidden) return;
159
187
  this._placing = true;
160
188
 
161
189
  const controls = this.viewer?.controls;
@@ -710,6 +738,7 @@ export class LabelPlacementController {
710
738
  try { e.preventDefault(); } catch (_) {}
711
739
  try { e.stopPropagation(); } catch (_) {}
712
740
  try { e.stopImmediatePropagation?.(); } catch (_) {}
741
+ if (!this._editingEnabled) return;
713
742
  this.startPlacement();
714
743
  };
715
744
  this._ui.btn.addEventListener("click", this._onBtnClick, { passive: false });
@@ -755,6 +784,7 @@ export class LabelPlacementController {
755
784
  const target = e.target;
756
785
  const action = target?.getAttribute?.("data-action");
757
786
  if (action !== "add") return;
787
+ if (!this._editingEnabled) return;
758
788
  if (this._labelsHidden) return;
759
789
  try { e.preventDefault(); } catch (_) {}
760
790
  try { e.stopPropagation(); } catch (_) {}
@@ -770,6 +800,7 @@ export class LabelPlacementController {
770
800
  if (e.key === "Escape") this.cancelPlacement();
771
801
  return;
772
802
  }
803
+ if (!this._editingEnabled) return;
773
804
 
774
805
  const target = e.target;
775
806
  const tag = (target && target.tagName) ? String(target.tagName).toLowerCase() : "";
@@ -904,6 +935,7 @@ export class LabelPlacementController {
904
935
 
905
936
  this._onCanvasContextMenu = (e) => {
906
937
  // Контекстное меню добавления метки по ПКМ на модели (если нет метки под курсором).
938
+ if (!this._editingEnabled) return;
907
939
  try { e.preventDefault(); } catch (_) {}
908
940
  try { e.stopPropagation(); } catch (_) {}
909
941
  try { e.stopImmediatePropagation?.(); } catch (_) {}
@@ -920,6 +952,19 @@ export class LabelPlacementController {
920
952
  this.#openCanvasMenu(hit, e.clientX, e.clientY);
921
953
  };
922
954
  dom.addEventListener("contextmenu", this._onCanvasContextMenu, { capture: true, passive: false });
955
+
956
+ const controls = this.viewer?.controls;
957
+ if (controls && typeof controls.addEventListener === "function") {
958
+ this._onControlsStart = () => {
959
+ this.#beginAutoHideForControls();
960
+ };
961
+ this._onControlsEnd = () => {
962
+ this.#scheduleShowAfterStop();
963
+ };
964
+ try { controls.addEventListener("start", this._onControlsStart); } catch (_) {}
965
+ try { controls.addEventListener("end", this._onControlsEnd); } catch (_) {}
966
+ }
967
+
923
968
  }
924
969
 
925
970
  #setGhostVisible(visible) {
@@ -1108,6 +1153,7 @@ export class LabelPlacementController {
1108
1153
  let best = null;
1109
1154
  for (const h of hits) {
1110
1155
  if (!h || !h.point) continue;
1156
+ if (this.#isPointClippedBySection(h.point)) continue;
1111
1157
  const obj = h.object;
1112
1158
  if (obj && obj.isMesh) { best = h; break; }
1113
1159
  }
@@ -1203,6 +1249,7 @@ export class LabelPlacementController {
1203
1249
  }
1204
1250
 
1205
1251
  #emitLabelAction(action, marker = null) {
1252
+ if (!this._editingEnabled) return;
1206
1253
  const target = marker || this.#getSelectedMarker();
1207
1254
  if (!target) return;
1208
1255
  const detail = this.#buildActionPayload(target);
@@ -1211,6 +1258,7 @@ export class LabelPlacementController {
1211
1258
  }
1212
1259
 
1213
1260
  #openContextMenu(marker, clientX, clientY) {
1261
+ if (!this._editingEnabled) return;
1214
1262
  const menu = this._ui?.menu;
1215
1263
  if (!menu || !marker) return;
1216
1264
 
@@ -1324,6 +1372,11 @@ export class LabelPlacementController {
1324
1372
  // если были в режиме постановки — выходим
1325
1373
  try { this.cancelPlacement(); } catch (_) {}
1326
1374
 
1375
+ if (!this._editingEnabled) {
1376
+ this.#handleMarkerClick(marker);
1377
+ return;
1378
+ }
1379
+
1327
1380
  if (e.button === 0) {
1328
1381
  this.#closeContextMenu();
1329
1382
  this.#setSelectedMarker(marker);
@@ -1355,6 +1408,7 @@ export class LabelPlacementController {
1355
1408
  try { marker.el.addEventListener("dragend", onMarkerDragEnd); } catch (_) {}
1356
1409
 
1357
1410
  const onMarkerContextMenu = (e) => {
1411
+ if (!this._editingEnabled) return;
1358
1412
  try { e.preventDefault(); } catch (_) {}
1359
1413
  try { e.stopPropagation(); } catch (_) {}
1360
1414
  try { e.stopImmediatePropagation?.(); } catch (_) {}
@@ -1460,12 +1514,12 @@ export class LabelPlacementController {
1460
1514
  if (!m || !m.el) continue;
1461
1515
 
1462
1516
  if (this._labelsHidden) {
1463
- m.el.style.display = "none";
1517
+ this.#setMarkerVisibility(m, false, "labelsHidden");
1464
1518
  continue;
1465
1519
  }
1466
1520
 
1467
1521
  if (!model) {
1468
- m.el.style.display = "none";
1522
+ this.#setMarkerVisibility(m, false, "noModel");
1469
1523
  continue;
1470
1524
  }
1471
1525
 
@@ -1474,16 +1528,20 @@ export class LabelPlacementController {
1474
1528
  model.localToWorld(this._tmpV);
1475
1529
 
1476
1530
  if (this.#isPointClippedBySection(this._tmpV)) {
1477
- m.el.style.display = "none";
1531
+ this.#setMarkerVisibility(m, false, "clipped");
1478
1532
  continue;
1479
1533
  }
1480
1534
 
1481
- const ndc = this._tmpV.project(camera);
1535
+ const ndc = this._tmpNdc.copy(this._tmpV).project(camera);
1482
1536
 
1483
- // Если точка за камерой или далеко за пределами — скрываем
1484
- const inFront = Number.isFinite(ndc.z) && ndc.z >= -1 && ndc.z <= 1;
1485
- if (!inFront) {
1486
- m.el.style.display = "none";
1537
+ // Если точка за камерой или вне кадра — скрываем
1538
+ const ndcFinite = Number.isFinite(ndc.x) && Number.isFinite(ndc.y) && Number.isFinite(ndc.z);
1539
+ const inView = ndcFinite
1540
+ && ndc.x >= -1 && ndc.x <= 1
1541
+ && ndc.y >= -1 && ndc.y <= 1
1542
+ && ndc.z >= -1 && ndc.z <= 1;
1543
+ if (!inView) {
1544
+ this.#setMarkerVisibility(m, false, "outOfView");
1487
1545
  continue;
1488
1546
  }
1489
1547
 
@@ -1491,15 +1549,62 @@ export class LabelPlacementController {
1491
1549
  const y = (-ndc.y * 0.5 + 0.5) * h;
1492
1550
 
1493
1551
  if (!Number.isFinite(x) || !Number.isFinite(y)) {
1494
- m.el.style.display = "none";
1552
+ this.#setMarkerVisibility(m, false, "invalidScreen");
1495
1553
  continue;
1496
1554
  }
1497
1555
 
1498
- m.el.style.display = "block";
1556
+ const occluded = this.#isPointOccludedByModel(this._tmpV, ndc, model, camera);
1557
+ if (occluded) {
1558
+ this.#setMarkerVisibility(m, false, "occluded");
1559
+ continue;
1560
+ }
1561
+
1562
+ this.#setMarkerVisibility(m, true, "visible");
1499
1563
  m.el.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`;
1500
1564
  }
1501
1565
  }
1502
1566
 
1567
+ #setMarkerVisibility(marker, visible, reason) {
1568
+ if (!marker || !marker.el) return;
1569
+ if (visible) {
1570
+ marker.el.style.display = "block";
1571
+ try { marker.el.classList.remove("ifc-label-marker--hidden"); } catch (_) {}
1572
+ } else {
1573
+ try { marker.el.classList.add("ifc-label-marker--hidden"); } catch (_) {}
1574
+ marker.el.style.display = "block";
1575
+ }
1576
+ if (!this._visibilityLogEnabled) return;
1577
+ if (marker.visible === visible && marker.hiddenReason === reason) return;
1578
+ marker.visible = visible;
1579
+ marker.hiddenReason = reason;
1580
+ this.logger?.log?.("[LabelVisibility]", {
1581
+ id: marker.id,
1582
+ visible,
1583
+ reason,
1584
+ });
1585
+ }
1586
+
1587
+ #isPointOccludedByModel(pointWorld, ndc, model, camera) {
1588
+ if (!pointWorld || !ndc || !model || !camera) return false;
1589
+ this._raycaster.setFromCamera(ndc, camera);
1590
+ const hits = this._raycaster.intersectObject(model, true);
1591
+ if (!hits || hits.length === 0) return false;
1592
+ let hit = null;
1593
+ for (const h of hits) {
1594
+ if (!h || !h.object || !h.object.isMesh) continue;
1595
+ if (this.#isPointClippedBySection(h.point)) continue;
1596
+ hit = h;
1597
+ break;
1598
+ }
1599
+ if (!hit || !Number.isFinite(hit.distance)) return false;
1600
+ const ray = this._raycaster.ray;
1601
+ this._tmpV2.copy(pointWorld).sub(ray.origin);
1602
+ const t = this._tmpV2.dot(ray.direction);
1603
+ if (!Number.isFinite(t) || t <= 0) return false;
1604
+ const epsilon = 1e-2;
1605
+ return hit.distance + epsilon < t;
1606
+ }
1607
+
1503
1608
  #isPointClippedBySection(pointWorld) {
1504
1609
  const planes = this.viewer?.clipping?.planes || [];
1505
1610
  for (const plane of planes) {
@@ -1510,6 +1615,12 @@ export class LabelPlacementController {
1510
1615
  return false;
1511
1616
  }
1512
1617
 
1618
+ #copyMatrix(matrix, cache) {
1619
+ if (!matrix || !cache || cache.length !== 16) return;
1620
+ const e = matrix.elements;
1621
+ for (let i = 0; i < 16; i += 1) cache[i] = e[i] ?? 0;
1622
+ }
1623
+
1513
1624
  #setLabelsHidden(hidden) {
1514
1625
  const next = !!hidden;
1515
1626
  if (this._labelsHidden === next) return;
@@ -1522,6 +1633,72 @@ export class LabelPlacementController {
1522
1633
  this.#syncHideButton();
1523
1634
  }
1524
1635
 
1636
+ #beginAutoHideForControls() {
1637
+ if (!this._autoHide.active) {
1638
+ this._autoHide.prevHidden = this._labelsHidden;
1639
+ this._autoHide.active = true;
1640
+ }
1641
+ this._labelsHidden = true;
1642
+ this.#syncHideButton();
1643
+ this.#cancelShowAfterStop();
1644
+ }
1645
+
1646
+ #scheduleShowAfterStop() {
1647
+ if (!this._autoHide.active) return;
1648
+ const cam = this.viewer?.camera;
1649
+ if (!cam) return;
1650
+ this._showAfterStop.active = true;
1651
+ this._showAfterStop.lastChangeTs = performance.now();
1652
+ this.#copyMatrix(cam.matrixWorld, this._showAfterStop.lastCamMatrix);
1653
+ if (!this._showAfterStop.raf) {
1654
+ this._showAfterStop.raf = requestAnimationFrame(() => this.#tickShowAfterStop());
1655
+ }
1656
+ }
1657
+
1658
+ #cancelShowAfterStop() {
1659
+ if (this._showAfterStop.raf) cancelAnimationFrame(this._showAfterStop.raf);
1660
+ this._showAfterStop.raf = 0;
1661
+ this._showAfterStop.active = false;
1662
+ }
1663
+
1664
+ #tickShowAfterStop() {
1665
+ if (!this._showAfterStop.active) {
1666
+ this._showAfterStop.raf = 0;
1667
+ return;
1668
+ }
1669
+ const cam = this.viewer?.camera;
1670
+ if (!cam) {
1671
+ this._showAfterStop.raf = 0;
1672
+ return;
1673
+ }
1674
+ const now = performance.now();
1675
+ if (!this.#isCameraStable(cam.matrixWorld, this._showAfterStop.lastCamMatrix, this._showAfterStop.eps)) {
1676
+ this._showAfterStop.lastChangeTs = now;
1677
+ }
1678
+ if (now - this._showAfterStop.lastChangeTs >= this._showAfterStop.idleMs) {
1679
+ this._labelsHidden = this._autoHide.prevHidden;
1680
+ this._autoHide.active = false;
1681
+ this.#syncHideButton();
1682
+ this._showAfterStop.active = false;
1683
+ this._showAfterStop.raf = 0;
1684
+ return;
1685
+ }
1686
+ this._showAfterStop.raf = requestAnimationFrame(() => this.#tickShowAfterStop());
1687
+ }
1688
+
1689
+ #isCameraStable(matrix, cache, eps) {
1690
+ if (!matrix || !cache || cache.length !== 16) return true;
1691
+ const e = matrix.elements;
1692
+ let stable = true;
1693
+ for (let i = 0; i < 16; i += 1) {
1694
+ const v = e[i] ?? 0;
1695
+ const d = Math.abs(v - cache[i]);
1696
+ if (d > eps) stable = false;
1697
+ cache[i] = v;
1698
+ }
1699
+ return stable;
1700
+ }
1701
+
1525
1702
  #syncHideButton() {
1526
1703
  const btn = this._ui?.hideBtn;
1527
1704
  if (!btn) return;
@@ -1536,7 +1713,7 @@ export class LabelPlacementController {
1536
1713
  }
1537
1714
 
1538
1715
  #syncAddAvailability() {
1539
- const disabled = !!this._labelsHidden;
1716
+ const disabled = !!this._labelsHidden || !this._editingEnabled;
1540
1717
  const btn = this._ui?.btn;
1541
1718
  if (btn) {
1542
1719
  btn.disabled = disabled;
@@ -1548,5 +1725,33 @@ export class LabelPlacementController {
1548
1725
  try { menuAdd.classList.toggle("ifc-label-menu-item--disabled", disabled); } catch (_) {}
1549
1726
  }
1550
1727
  }
1728
+
1729
+ #syncEditingUi() {
1730
+ const actions = this._ui?.actions;
1731
+ if (actions) actions.style.display = this._editingEnabled ? "" : "none";
1732
+ if (!this._editingEnabled) {
1733
+ try { this.cancelPlacement(); } catch (_) {}
1734
+ try { this.#closeContextMenu(); } catch (_) {}
1735
+ try { this.#closeCanvasMenu(); } catch (_) {}
1736
+ this._labelDrag.active = false;
1737
+ this._labelDrag.moved = false;
1738
+ this._labelDrag.pointerId = null;
1739
+ this._labelDrag.id = null;
1740
+ this._labelDrag.clickMarker = null;
1741
+ this.#setDragGhostVisible(false);
1742
+ }
1743
+ this.#syncAddAvailability();
1744
+ }
1745
+
1746
+ setEditingEnabled(enabled) {
1747
+ const next = !!enabled;
1748
+ if (this._editingEnabled === next) return;
1749
+ this._editingEnabled = next;
1750
+ this.#syncEditingUi();
1751
+ }
1752
+
1753
+ getEditingEnabled() {
1754
+ return !!this._editingEnabled;
1755
+ }
1551
1756
  }
1552
1757
 
@@ -190,6 +190,15 @@ export class Viewer {
190
190
  this._rotAngleEps = 0.01; // ~0.57° минимальный угловой сдвиг
191
191
  this._axisEmaAlpha = 0.15; // коэффициент сглаживания оси
192
192
 
193
+ this._damping = {
194
+ dynamic: true,
195
+ base: 0.5,
196
+ settle: 0,
197
+ settleMs: 0,
198
+ isSettling: false,
199
+ lastEndTs: 0,
200
+ };
201
+
193
202
  this._onPointerDown = null;
194
203
  this._onPointerUp = null;
195
204
  this._onPointerMove = null;
@@ -249,6 +258,53 @@ export class Viewer {
249
258
  return { enabled: !!this._zoomToCursor.enabled, debug: !!this._zoomToCursor.debug };
250
259
  }
251
260
 
261
+ /**
262
+ * Возвращает параметры динамического damping (для UI).
263
+ */
264
+ getDampingConfig() {
265
+ const d = this._damping || {};
266
+ return {
267
+ dynamic: !!d.dynamic,
268
+ base: Number.isFinite(d.base) ? d.base : 0,
269
+ settle: Number.isFinite(d.settle) ? d.settle : 0,
270
+ settleMs: Number.isFinite(d.settleMs) ? d.settleMs : 0,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Обновляет параметры динамического damping.
276
+ * @param {{ dynamic?: boolean, base?: number, settle?: number, settleMs?: number }} next
277
+ */
278
+ setDampingConfig(next = {}) {
279
+ if (!this._damping) return;
280
+ const clamp01 = (v) => Math.min(1, Math.max(0, v));
281
+ if (Object.prototype.hasOwnProperty.call(next, 'dynamic')) {
282
+ this._damping.dynamic = !!next.dynamic;
283
+ }
284
+ if (Object.prototype.hasOwnProperty.call(next, 'base')) {
285
+ const v = Number(next.base);
286
+ if (Number.isFinite(v)) this._damping.base = clamp01(v);
287
+ }
288
+ if (Object.prototype.hasOwnProperty.call(next, 'settle')) {
289
+ const v = Number(next.settle);
290
+ if (Number.isFinite(v)) this._damping.settle = clamp01(v);
291
+ }
292
+ if (Object.prototype.hasOwnProperty.call(next, 'settleMs')) {
293
+ const v = Number(next.settleMs);
294
+ if (Number.isFinite(v)) this._damping.settleMs = Math.max(0, v);
295
+ }
296
+ if (this.controls) {
297
+ if (!this._damping.dynamic) {
298
+ this._damping.isSettling = false;
299
+ this.controls.enableDamping = false;
300
+ this.controls.dampingFactor = this._damping.base;
301
+ } else {
302
+ this.controls.enableDamping = true;
303
+ if (!this._damping.isSettling) this.controls.dampingFactor = this._damping.base;
304
+ }
305
+ }
306
+ }
307
+
252
308
  /**
253
309
  * Возвращает "домашнюю" точку вращения для текущей модели:
254
310
  * центр bbox (совпадает с кадрированием при первичной загрузке).
@@ -701,13 +757,25 @@ export class Viewer {
701
757
  const dir = this.camera.position.clone().sub(this.controls.target).normalize();
702
758
  this._prevViewDir = dir;
703
759
  this._smoothedAxis = null;
760
+ if (this._damping.dynamic) {
761
+ this.controls.dampingFactor = this._damping.base;
762
+ this._damping.isSettling = false;
763
+ this.controls.enableDamping = true;
764
+ }
704
765
  };
705
766
  this._onControlsChange = () => {
706
767
  // Обновляем ось только при зажатой ЛКМ (вращение)
707
768
  if (!this._isLmbDown) return;
708
769
  this.#updateRotationAxisLine();
709
770
  };
710
- this._onControlsEnd = () => { this.#hideRotationAxisLine(); };
771
+ this._onControlsEnd = () => {
772
+ this.#hideRotationAxisLine();
773
+ if (this._damping.dynamic && this.controls) {
774
+ this._damping.isSettling = true;
775
+ this._damping.lastEndTs = performance.now();
776
+ this.controls.enableDamping = true;
777
+ }
778
+ };
711
779
  this.controls.addEventListener('start', this._onControlsStart);
712
780
  this.controls.addEventListener('change', this._onControlsChange);
713
781
  this.controls.addEventListener('end', this._onControlsEnd);
@@ -768,7 +836,10 @@ export class Viewer {
768
836
  this.demoCube.rotation.x += 0.005;
769
837
  }
770
838
 
771
- if (this.controls) this.controls.update();
839
+ if (this.controls) {
840
+ this.#updateDynamicDamping();
841
+ this.controls.update();
842
+ }
772
843
  // "Внутренняя" подсветка/пост-эффекты: включаются только когда камера внутри модели
773
844
  this.#updateInteriorAssist();
774
845
  this._notifyZoomIfChanged();
@@ -814,6 +885,22 @@ export class Viewer {
814
885
  this.animationId = requestAnimationFrame(this.animate);
815
886
  }
816
887
 
888
+ #updateDynamicDamping() {
889
+ if (!this.controls) return;
890
+ if (!this._damping.dynamic || !this.controls.enableDamping) return;
891
+ if (this._damping.isSettling) {
892
+ const now = performance.now();
893
+ if (now - this._damping.lastEndTs <= this._damping.settleMs) {
894
+ this.controls.dampingFactor = this._damping.settle;
895
+ } else {
896
+ this._damping.isSettling = false;
897
+ this.controls.dampingFactor = this._damping.base;
898
+ }
899
+ } else {
900
+ this.controls.dampingFactor = this._damping.base;
901
+ }
902
+ }
903
+
817
904
  dispose() {
818
905
  if (this.animationId) cancelAnimationFrame(this.animationId);
819
906
  window.removeEventListener("resize", this.handleResize);