@sequent-org/ifc-viewer 1.2.4-ci.53.0 → 1.2.4-ci.55.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,1178 @@
1
+ import * as THREE from "three";
2
+
3
+ class LabelMarker {
4
+ /**
5
+ * @param {object} deps
6
+ * @param {number|string} deps.id
7
+ * @param {THREE.Vector3} deps.localPoint
8
+ * @param {HTMLElement} deps.el
9
+ * @param {object|null} deps.sceneState
10
+ */
11
+ constructor(deps) {
12
+ this.id = deps.id;
13
+ this.localPoint = deps.localPoint;
14
+ this.el = deps.el;
15
+ this.sceneState = deps.sceneState || null;
16
+ }
17
+ }
18
+
19
+ /**
20
+ * UI-контроллер: "+ Добавить метку" → призрак у курсора → установка метки кликом по модели.
21
+ *
22
+ * Важно:
23
+ * - метка хранит координату в ЛОКАЛЬНЫХ координатах activeModel, чтобы "ехать" вместе с моделью при её перемещении.
24
+ * - во время режима постановки блокируем обработку LMB у OrbitControls/других контроллеров (capture-phase).
25
+ */
26
+ export class LabelPlacementController {
27
+ /**
28
+ * @param {object} deps
29
+ * @param {import('../viewer/Viewer.js').Viewer} deps.viewer
30
+ * @param {HTMLElement} deps.container Контейнер viewer (обычно #app)
31
+ * @param {object} [deps.logger]
32
+ */
33
+ constructor(deps) {
34
+ this.viewer = deps.viewer;
35
+ this.container = deps.container;
36
+ this.logger = deps.logger || null;
37
+
38
+ this._placing = false;
39
+ this._nextId = 1;
40
+ /** @type {LabelMarker[]} */
41
+ this._markers = [];
42
+ this._selectedId = null;
43
+
44
+ this._raycaster = new THREE.Raycaster();
45
+ this._ndc = new THREE.Vector2();
46
+ this._tmpV = new THREE.Vector3();
47
+ this._tmpLocal = new THREE.Vector3();
48
+
49
+ this._lastPointer = { x: 0, y: 0 };
50
+ this._ghostPos = { x: 0, y: 0 };
51
+ this._raf = 0;
52
+
53
+ this._controlsWasEnabled = null;
54
+
55
+ this._containerOffset = { left: 0, top: 0 };
56
+ this._containerOffsetValid = false;
57
+
58
+ this._contextMenu = {
59
+ open: false,
60
+ marker: null,
61
+ };
62
+ this._canvasMenu = {
63
+ open: false,
64
+ hit: null,
65
+ };
66
+
67
+ this._fly = {
68
+ raf: 0,
69
+ active: false,
70
+ prevControlsEnabled: null,
71
+ startTs: 0,
72
+ durationMs: 550,
73
+ // start/end snapshots
74
+ from: null,
75
+ to: null,
76
+ // tmp vectors
77
+ v0: new THREE.Vector3(),
78
+ v1: new THREE.Vector3(),
79
+ v2: new THREE.Vector3(),
80
+ };
81
+
82
+ this._ui = this.#createUi();
83
+ this.#attachUi();
84
+ this.#bindEvents();
85
+ this.#startRaf();
86
+ }
87
+
88
+ dispose() {
89
+ try { this.cancelPlacement(); } catch (_) {}
90
+ try { this.#cancelFly(); } catch (_) {}
91
+ try { this.#closeContextMenu(); } catch (_) {}
92
+
93
+ const dom = this.viewer?.renderer?.domElement;
94
+ try { dom?.removeEventListener("pointermove", this._onPointerMove); } catch (_) {}
95
+ try { dom?.removeEventListener("pointerrawupdate", this._onPointerRawUpdate); } catch (_) {}
96
+ try { dom?.removeEventListener("pointerdown", this._onPointerDownCapture, { capture: true }); } catch (_) {
97
+ try { dom?.removeEventListener("pointerdown", this._onPointerDownCapture); } catch (_) {}
98
+ }
99
+ try { window.removeEventListener("keydown", this._onKeyDown); } catch (_) {}
100
+ try { window.removeEventListener("resize", this._onWindowResize); } catch (_) {}
101
+ try { window.removeEventListener("scroll", this._onWindowScroll, true); } catch (_) {}
102
+ try { window.removeEventListener("pointerdown", this._onWindowPointerDown, true); } catch (_) {}
103
+ try { this._ui?.btn?.removeEventListener("click", this._onBtnClick); } catch (_) {}
104
+ try { this._ui?.menu?.removeEventListener("pointerdown", this._onMenuPointerDown); } catch (_) {}
105
+ try { this._ui?.menu?.removeEventListener("click", this._onMenuClick); } catch (_) {}
106
+ try { this._ui?.canvasMenu?.removeEventListener("pointerdown", this._onCanvasMenuPointerDown); } catch (_) {}
107
+ try { this._ui?.canvasMenu?.removeEventListener("click", this._onCanvasMenuClick); } catch (_) {}
108
+ try { dom?.removeEventListener("contextmenu", this._onCanvasContextMenu, { capture: true }); } catch (_) {
109
+ try { dom?.removeEventListener("contextmenu", this._onCanvasContextMenu); } catch (_) {}
110
+ }
111
+
112
+ if (this._raf) cancelAnimationFrame(this._raf);
113
+ this._raf = 0;
114
+
115
+ try { this._markers.forEach((m) => m?.el?.remove?.()); } catch (_) {}
116
+ this._markers.length = 0;
117
+
118
+ try { this._ui?.ghost?.remove?.(); } catch (_) {}
119
+ try { this._ui?.btn?.remove?.(); } catch (_) {}
120
+ try { this._ui?.menu?.remove?.(); } catch (_) {}
121
+ try { this._ui?.canvasMenu?.remove?.(); } catch (_) {}
122
+ }
123
+
124
+ startPlacement() {
125
+ if (this._placing) return;
126
+ this._placing = true;
127
+
128
+ const controls = this.viewer?.controls;
129
+ if (controls) {
130
+ // Запоминаем и временно выключаем OrbitControls целиком
131
+ this._controlsWasEnabled = !!controls.enabled;
132
+ controls.enabled = false;
133
+ }
134
+
135
+ this.#refreshContainerOffset();
136
+ this.#syncGhost();
137
+ this.#setGhostVisible(true);
138
+ this.#log("startPlacement", { nextId: this._nextId });
139
+ }
140
+
141
+ cancelPlacement() {
142
+ if (!this._placing) return;
143
+ this._placing = false;
144
+ this.#setGhostVisible(false);
145
+
146
+ const controls = this.viewer?.controls;
147
+ if (controls && this._controlsWasEnabled != null) {
148
+ controls.enabled = !!this._controlsWasEnabled;
149
+ this._controlsWasEnabled = null;
150
+ }
151
+
152
+ this.#log("cancelPlacement", {});
153
+ }
154
+
155
+ #log(event, payload) {
156
+ try {
157
+ this.logger?.log?.("[LabelPlacement]", event, payload);
158
+ } catch (_) {}
159
+ }
160
+
161
+ #dispatchLabelEvent(name, detail, legacyName = null) {
162
+ try {
163
+ const ev = new CustomEvent(name, { detail, bubbles: true });
164
+ this.container?.dispatchEvent?.(ev);
165
+ } catch (_) {}
166
+ if (!legacyName) return;
167
+ try {
168
+ const ev = new CustomEvent(legacyName, { detail, bubbles: true });
169
+ this.container?.dispatchEvent?.(ev);
170
+ } catch (_) {}
171
+ }
172
+
173
+ #easeOutCubic(t) {
174
+ const x = Math.min(1, Math.max(0, Number(t) || 0));
175
+ const inv = 1 - x;
176
+ return 1 - inv * inv * inv; // быстрый старт, плавный конец
177
+ }
178
+
179
+ #cancelFly() {
180
+ if (this._fly.raf) cancelAnimationFrame(this._fly.raf);
181
+ this._fly.raf = 0;
182
+ this._fly.active = false;
183
+ this._fly.startTs = 0;
184
+ this._fly.from = null;
185
+ this._fly.to = null;
186
+
187
+ // Вернём OrbitControls, если отключали
188
+ try {
189
+ const controls = this.viewer?.controls;
190
+ if (controls && this._fly.prevControlsEnabled != null) controls.enabled = this._fly.prevControlsEnabled;
191
+ } catch (_) {}
192
+ this._fly.prevControlsEnabled = null;
193
+ }
194
+
195
+ #getCurrentCameraSnapshot() {
196
+ const viewer = this.viewer;
197
+ if (!viewer) return null;
198
+ const camera = viewer.camera;
199
+ const controls = viewer.controls;
200
+ if (!camera || !controls) return null;
201
+
202
+ const snap = {
203
+ projectionMode: (typeof viewer.getProjectionMode === "function")
204
+ ? viewer.getProjectionMode()
205
+ : (camera.isOrthographicCamera ? "ortho" : "perspective"),
206
+ camPos: camera.position.clone(),
207
+ target: controls.target.clone(),
208
+ fov: camera.isPerspectiveCamera ? Number(camera.fov) : null,
209
+ zoom: camera.isOrthographicCamera ? Number(camera.zoom || 1) : null,
210
+ viewOffset: (camera.view && camera.view.enabled)
211
+ ? { enabled: true, offsetX: camera.view.offsetX || 0, offsetY: camera.view.offsetY || 0 }
212
+ : { enabled: false, offsetX: 0, offsetY: 0 },
213
+ };
214
+ return snap;
215
+ }
216
+
217
+ #captureSceneState() {
218
+ const viewer = this.viewer;
219
+ if (!viewer) return null;
220
+
221
+ const camera = viewer.camera;
222
+ const controls = viewer.controls;
223
+ const model = viewer.activeModel;
224
+
225
+ const projectionMode = (typeof viewer.getProjectionMode === "function")
226
+ ? viewer.getProjectionMode()
227
+ : (camera?.isOrthographicCamera ? "ortho" : "perspective");
228
+
229
+ const camPos = camera?.position ? { x: camera.position.x, y: camera.position.y, z: camera.position.z } : null;
230
+ const target = controls?.target ? { x: controls.target.x, y: controls.target.y, z: controls.target.z } : null;
231
+
232
+ const cam = {
233
+ projectionMode,
234
+ position: camPos,
235
+ target,
236
+ fov: (camera && camera.isPerspectiveCamera) ? Number(camera.fov) : null,
237
+ zoom: (camera && camera.isOrthographicCamera) ? Number(camera.zoom || 1) : null,
238
+ // MMB-pan (camera.viewOffset) — сохраняем как есть, в пикселях
239
+ viewOffset: (camera && camera.view && camera.view.enabled)
240
+ ? { enabled: true, offsetX: camera.view.offsetX || 0, offsetY: camera.view.offsetY || 0 }
241
+ : { enabled: false, offsetX: 0, offsetY: 0 },
242
+ };
243
+
244
+ const modelTransform = model ? {
245
+ position: { x: model.position.x, y: model.position.y, z: model.position.z },
246
+ quaternion: { x: model.quaternion.x, y: model.quaternion.y, z: model.quaternion.z, w: model.quaternion.w },
247
+ scale: { x: model.scale.x, y: model.scale.y, z: model.scale.z },
248
+ } : null;
249
+
250
+ const planes = viewer.clipping?.planes || [];
251
+ const clipConstants = [
252
+ planes?.[0]?.constant,
253
+ planes?.[1]?.constant,
254
+ planes?.[2]?.constant,
255
+ ];
256
+
257
+ return {
258
+ camera: cam,
259
+ modelTransform,
260
+ clipping: { constants: clipConstants },
261
+ };
262
+ }
263
+
264
+ #restoreSceneState(sceneState) {
265
+ const viewer = this.viewer;
266
+ if (!viewer || !sceneState) return;
267
+
268
+ // 1) Проекция (может заменить ссылку viewer.camera)
269
+ try {
270
+ const pm = sceneState?.camera?.projectionMode;
271
+ if (pm && typeof viewer.setProjectionMode === "function") viewer.setProjectionMode(pm);
272
+ } catch (_) {}
273
+
274
+ const camera = viewer.camera;
275
+ const controls = viewer.controls;
276
+ const model = viewer.activeModel;
277
+
278
+ // 2) Камера + target + zoom/fov
279
+ try {
280
+ const t = sceneState?.camera?.target;
281
+ if (controls && t) controls.target.set(Number(t.x) || 0, Number(t.y) || 0, Number(t.z) || 0);
282
+ } catch (_) {}
283
+ try {
284
+ const p = sceneState?.camera?.position;
285
+ if (camera && p) camera.position.set(Number(p.x) || 0, Number(p.y) || 0, Number(p.z) || 0);
286
+ } catch (_) {}
287
+ try {
288
+ if (camera?.isPerspectiveCamera && sceneState?.camera?.fov != null) {
289
+ const fov = Number(sceneState.camera.fov);
290
+ if (Number.isFinite(fov) && fov > 1e-3 && fov < 179) camera.fov = fov;
291
+ }
292
+ } catch (_) {}
293
+ try {
294
+ if (camera?.isOrthographicCamera && sceneState?.camera?.zoom != null) {
295
+ const z = Number(sceneState.camera.zoom);
296
+ if (Number.isFinite(z) && z > 1e-6) camera.zoom = z;
297
+ }
298
+ } catch (_) {}
299
+
300
+ // 3) Трансформ модели (позиция/поворот/масштаб)
301
+ try {
302
+ const mt = sceneState?.modelTransform;
303
+ if (model && mt?.position && mt?.quaternion && mt?.scale) {
304
+ model.position.set(Number(mt.position.x) || 0, Number(mt.position.y) || 0, Number(mt.position.z) || 0);
305
+ model.quaternion.set(
306
+ Number(mt.quaternion.x) || 0,
307
+ Number(mt.quaternion.y) || 0,
308
+ Number(mt.quaternion.z) || 0,
309
+ Number(mt.quaternion.w) || 1
310
+ );
311
+ model.scale.set(Number(mt.scale.x) || 1, Number(mt.scale.y) || 1, Number(mt.scale.z) || 1);
312
+ model.updateMatrixWorld?.(true);
313
+ }
314
+ } catch (_) {}
315
+
316
+ // 4) ViewOffset (MMB-pan) — применяем после смены камеры/проекции
317
+ try {
318
+ const vo = sceneState?.camera?.viewOffset;
319
+ if (camera && vo && typeof camera.setViewOffset === "function") {
320
+ const dom = viewer?.renderer?.domElement;
321
+ const rect = dom?.getBoundingClientRect?.();
322
+ const w = Math.max(1, Math.floor(rect?.width || 1));
323
+ const h = Math.max(1, Math.floor(rect?.height || 1));
324
+ if (vo.enabled) {
325
+ camera.setViewOffset(w, h, Math.round(Number(vo.offsetX) || 0), Math.round(Number(vo.offsetY) || 0), w, h);
326
+ } else if (typeof camera.clearViewOffset === "function") {
327
+ camera.clearViewOffset();
328
+ } else {
329
+ camera.setViewOffset(w, h, 0, 0, w, h);
330
+ }
331
+ }
332
+ } catch (_) {}
333
+
334
+ // 5) Клиппинг (как Home: сначала восстановили камеру+модель, затем planes)
335
+ try {
336
+ const constants = sceneState?.clipping?.constants || [];
337
+ if (typeof viewer.setSection === "function") {
338
+ ["x", "y", "z"].forEach((axis, i) => {
339
+ const c = constants[i];
340
+ const enabled = Number.isFinite(c);
341
+ const dist = -Number(c);
342
+ viewer.setSection(axis, enabled, enabled ? dist : 0);
343
+ });
344
+ }
345
+ } catch (_) {}
346
+
347
+ try { camera?.updateProjectionMatrix?.(); } catch (_) {}
348
+ try { controls?.update?.(); } catch (_) {}
349
+ }
350
+
351
+ #applyModelTransformFromState(sceneState) {
352
+ const viewer = this.viewer;
353
+ if (!viewer || !sceneState) return;
354
+ const model = viewer.activeModel;
355
+ if (!model) return;
356
+ const mt = sceneState?.modelTransform;
357
+ if (!mt?.position || !mt?.quaternion || !mt?.scale) return;
358
+ try {
359
+ model.position.set(Number(mt.position.x) || 0, Number(mt.position.y) || 0, Number(mt.position.z) || 0);
360
+ model.quaternion.set(
361
+ Number(mt.quaternion.x) || 0,
362
+ Number(mt.quaternion.y) || 0,
363
+ Number(mt.quaternion.z) || 0,
364
+ Number(mt.quaternion.w) || 1
365
+ );
366
+ model.scale.set(Number(mt.scale.x) || 1, Number(mt.scale.y) || 1, Number(mt.scale.z) || 1);
367
+ model.updateMatrixWorld?.(true);
368
+ } catch (_) {}
369
+ }
370
+
371
+ #applyClippingFromState(sceneState) {
372
+ const viewer = this.viewer;
373
+ if (!viewer || !sceneState) return;
374
+ try {
375
+ const constants = sceneState?.clipping?.constants || [];
376
+ if (typeof viewer.setSection === "function") {
377
+ ["x", "y", "z"].forEach((axis, i) => {
378
+ const c = constants[i];
379
+ const enabled = Number.isFinite(c);
380
+ const dist = -Number(c);
381
+ viewer.setSection(axis, enabled, enabled ? dist : 0);
382
+ });
383
+ }
384
+ } catch (_) {}
385
+ }
386
+
387
+ #applyViewOffset(camera, viewOffset) {
388
+ if (!camera || !viewOffset) return;
389
+ try {
390
+ const viewer = this.viewer;
391
+ const dom = viewer?.renderer?.domElement;
392
+ const rect = dom?.getBoundingClientRect?.();
393
+ const w = Math.max(1, Math.floor(rect?.width || 1));
394
+ const h = Math.max(1, Math.floor(rect?.height || 1));
395
+
396
+ if (viewOffset.enabled) {
397
+ camera.setViewOffset(w, h, Math.round(Number(viewOffset.offsetX) || 0), Math.round(Number(viewOffset.offsetY) || 0), w, h);
398
+ } else if (typeof camera.clearViewOffset === "function") {
399
+ camera.clearViewOffset();
400
+ } else {
401
+ camera.setViewOffset(w, h, 0, 0, w, h);
402
+ }
403
+ } catch (_) {}
404
+ }
405
+
406
+ #animateToSceneState(sceneState, durationMs = 550) {
407
+ const viewer = this.viewer;
408
+ if (!viewer || !sceneState) return;
409
+
410
+ // Если уже летим — отменим предыдущую анимацию
411
+ this.#cancelFly();
412
+
413
+ // 1) Сразу применяем проекцию (может сменить viewer.camera)
414
+ try {
415
+ const pm = sceneState?.camera?.projectionMode;
416
+ if (pm && typeof viewer.setProjectionMode === "function") viewer.setProjectionMode(pm);
417
+ } catch (_) {}
418
+
419
+ // 2) Сразу применяем трансформ модели (если нужен) — камера полетит уже к нужной сцене
420
+ this.#applyModelTransformFromState(sceneState);
421
+
422
+ const camera = viewer.camera;
423
+ const controls = viewer.controls;
424
+ if (!camera || !controls) return;
425
+
426
+ const from = this.#getCurrentCameraSnapshot();
427
+ if (!from) return;
428
+
429
+ // Целевые значения (камера/target/zoom/fov/offset)
430
+ const to = {
431
+ projectionMode: sceneState?.camera?.projectionMode || from.projectionMode,
432
+ camPos: sceneState?.camera?.position
433
+ ? new THREE.Vector3(Number(sceneState.camera.position.x) || 0, Number(sceneState.camera.position.y) || 0, Number(sceneState.camera.position.z) || 0)
434
+ : from.camPos.clone(),
435
+ target: sceneState?.camera?.target
436
+ ? new THREE.Vector3(Number(sceneState.camera.target.x) || 0, Number(sceneState.camera.target.y) || 0, Number(sceneState.camera.target.z) || 0)
437
+ : from.target.clone(),
438
+ fov: (camera.isPerspectiveCamera && sceneState?.camera?.fov != null) ? Number(sceneState.camera.fov) : from.fov,
439
+ zoom: (camera.isOrthographicCamera && sceneState?.camera?.zoom != null) ? Number(sceneState.camera.zoom) : from.zoom,
440
+ viewOffset: sceneState?.camera?.viewOffset || from.viewOffset,
441
+ };
442
+
443
+ // На время "долеталки" отключаем controls
444
+ try {
445
+ this._fly.prevControlsEnabled = !!controls.enabled;
446
+ controls.enabled = false;
447
+ } catch (_) {
448
+ this._fly.prevControlsEnabled = null;
449
+ }
450
+
451
+ this._fly.active = true;
452
+ this._fly.startTs = performance.now();
453
+ this._fly.durationMs = Math.max(50, Number(durationMs) || 550);
454
+ this._fly.from = from;
455
+ this._fly.to = to;
456
+
457
+ const tick = () => {
458
+ if (!this._fly.active) return;
459
+ const now = performance.now();
460
+ const t = (now - this._fly.startTs) / this._fly.durationMs;
461
+ const k = this.#easeOutCubic(t);
462
+
463
+ // position lerp
464
+ this._fly.v0.copy(from.camPos).lerp(to.camPos, k);
465
+ camera.position.copy(this._fly.v0);
466
+
467
+ // target lerp
468
+ this._fly.v1.copy(from.target).lerp(to.target, k);
469
+ controls.target.copy(this._fly.v1);
470
+
471
+ // fov/zoom
472
+ try {
473
+ if (camera.isPerspectiveCamera && to.fov != null && from.fov != null) {
474
+ const f = Number(from.fov) + (Number(to.fov) - Number(from.fov)) * k;
475
+ if (Number.isFinite(f) && f > 1e-3 && f < 179) camera.fov = f;
476
+ }
477
+ } catch (_) {}
478
+ try {
479
+ if (camera.isOrthographicCamera && to.zoom != null && from.zoom != null) {
480
+ const z = Number(from.zoom) + (Number(to.zoom) - Number(from.zoom)) * k;
481
+ if (Number.isFinite(z) && z > 1e-6) camera.zoom = z;
482
+ }
483
+ } catch (_) {}
484
+
485
+ // viewOffset lerp
486
+ try {
487
+ const vo0 = from.viewOffset || { enabled: false, offsetX: 0, offsetY: 0 };
488
+ const vo1 = to.viewOffset || { enabled: false, offsetX: 0, offsetY: 0 };
489
+ const enabled = !!(vo0.enabled || vo1.enabled);
490
+ const ox = Number(vo0.offsetX) + (Number(vo1.offsetX) - Number(vo0.offsetX)) * k;
491
+ const oy = Number(vo0.offsetY) + (Number(vo1.offsetY) - Number(vo0.offsetY)) * k;
492
+ this.#applyViewOffset(camera, { enabled, offsetX: ox, offsetY: oy });
493
+ } catch (_) {}
494
+
495
+ try { camera.updateProjectionMatrix?.(); } catch (_) {}
496
+ try { controls.update?.(); } catch (_) {}
497
+
498
+ if (t >= 1) {
499
+ // Финал: зафиксируем точные значения
500
+ try { camera.position.copy(to.camPos); } catch (_) {}
501
+ try { controls.target.copy(to.target); } catch (_) {}
502
+ try {
503
+ if (camera.isPerspectiveCamera && to.fov != null) camera.fov = to.fov;
504
+ if (camera.isOrthographicCamera && to.zoom != null) camera.zoom = to.zoom;
505
+ } catch (_) {}
506
+ try { this.#applyViewOffset(camera, to.viewOffset); } catch (_) {}
507
+
508
+ // В конце применяем clipping (чтобы ориентация плоскостей считалась по финальной камере)
509
+ this.#applyClippingFromState(sceneState);
510
+
511
+ try { camera.updateProjectionMatrix?.(); } catch (_) {}
512
+ try { controls.update?.(); } catch (_) {}
513
+
514
+ this.#cancelFly();
515
+ return;
516
+ }
517
+
518
+ this._fly.raf = requestAnimationFrame(tick);
519
+ };
520
+
521
+ this._fly.raf = requestAnimationFrame(tick);
522
+ }
523
+
524
+ #createUi() {
525
+ const btn = document.createElement("button");
526
+ btn.type = "button";
527
+ btn.className = "ifc-label-add-btn";
528
+ btn.textContent = "+ Добавить метку";
529
+
530
+ const ghost = document.createElement("div");
531
+ ghost.className = "ifc-label-ghost";
532
+ ghost.setAttribute("aria-hidden", "true");
533
+ ghost.style.display = "none";
534
+ // Базовая позиция: двигаем transform'ом, поэтому left/top держим в 0
535
+ ghost.style.left = "0px";
536
+ ghost.style.top = "0px";
537
+
538
+ const dot = document.createElement("div");
539
+ dot.className = "ifc-label-dot";
540
+ const num = document.createElement("div");
541
+ num.className = "ifc-label-num";
542
+
543
+ ghost.appendChild(dot);
544
+ ghost.appendChild(num);
545
+
546
+ const menu = document.createElement("div");
547
+ menu.className = "ifc-label-menu";
548
+ menu.style.display = "none";
549
+ menu.setAttribute("role", "menu");
550
+
551
+ const menuCopy = document.createElement("button");
552
+ menuCopy.type = "button";
553
+ menuCopy.className = "ifc-label-menu-item";
554
+ menuCopy.textContent = "Копировать";
555
+ menuCopy.setAttribute("data-action", "copy");
556
+
557
+ const menuMove = document.createElement("button");
558
+ menuMove.type = "button";
559
+ menuMove.className = "ifc-label-menu-item";
560
+ menuMove.textContent = "Переместить";
561
+ menuMove.setAttribute("data-action", "move");
562
+
563
+ const menuDelete = document.createElement("button");
564
+ menuDelete.type = "button";
565
+ menuDelete.className = "ifc-label-menu-item";
566
+ menuDelete.textContent = "Удалить";
567
+ menuDelete.setAttribute("data-action", "delete");
568
+
569
+ menu.appendChild(menuCopy);
570
+ menu.appendChild(menuMove);
571
+ menu.appendChild(menuDelete);
572
+
573
+ const canvasMenu = document.createElement("div");
574
+ canvasMenu.className = "ifc-label-menu";
575
+ canvasMenu.style.display = "none";
576
+ canvasMenu.setAttribute("role", "menu");
577
+
578
+ const menuAdd = document.createElement("button");
579
+ menuAdd.type = "button";
580
+ menuAdd.className = "ifc-label-menu-item";
581
+ menuAdd.textContent = "Добавить метку";
582
+ menuAdd.setAttribute("data-action", "add");
583
+
584
+ canvasMenu.appendChild(menuAdd);
585
+
586
+ return { btn, ghost, dot, num, menu, canvasMenu };
587
+ }
588
+
589
+ #attachUi() {
590
+ // Важно: container должен быть position:relative (в index.html уже так).
591
+ this.container.appendChild(this._ui.btn);
592
+ this.container.appendChild(this._ui.ghost);
593
+ this.container.appendChild(this._ui.menu);
594
+ this.container.appendChild(this._ui.canvasMenu);
595
+ }
596
+
597
+ #bindEvents() {
598
+ this._onBtnClick = (e) => {
599
+ // Не даём событию уйти в canvas/OrbitControls и не ставим метку "тем же кликом".
600
+ try { e.preventDefault(); } catch (_) {}
601
+ try { e.stopPropagation(); } catch (_) {}
602
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
603
+ this.startPlacement();
604
+ };
605
+ this._ui.btn.addEventListener("click", this._onBtnClick, { passive: false });
606
+
607
+ this._onMenuPointerDown = (e) => {
608
+ // Не даём клику меню попасть в canvas/OrbitControls
609
+ try { e.preventDefault(); } catch (_) {}
610
+ try { e.stopPropagation(); } catch (_) {}
611
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
612
+ };
613
+ this._ui.menu.addEventListener("pointerdown", this._onMenuPointerDown, { passive: false });
614
+
615
+ this._onMenuClick = (e) => {
616
+ const target = e.target;
617
+ const action = target?.getAttribute?.("data-action");
618
+ if (!action) return;
619
+ try { e.preventDefault(); } catch (_) {}
620
+ try { e.stopPropagation(); } catch (_) {}
621
+ const marker = this._contextMenu?.marker || null;
622
+ this.#emitLabelAction(action, marker);
623
+ this.#closeContextMenu();
624
+ };
625
+ this._ui.menu.addEventListener("click", this._onMenuClick);
626
+
627
+ this._onCanvasMenuPointerDown = (e) => {
628
+ // Не даём клику меню попасть в canvas/OrbitControls
629
+ try { e.preventDefault(); } catch (_) {}
630
+ try { e.stopPropagation(); } catch (_) {}
631
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
632
+ };
633
+ this._ui.canvasMenu.addEventListener("pointerdown", this._onCanvasMenuPointerDown, { passive: false });
634
+
635
+ this._onCanvasMenuClick = (e) => {
636
+ const target = e.target;
637
+ const action = target?.getAttribute?.("data-action");
638
+ if (action !== "add") return;
639
+ try { e.preventDefault(); } catch (_) {}
640
+ try { e.stopPropagation(); } catch (_) {}
641
+
642
+ const hit = this._canvasMenu?.hit || null;
643
+ if (hit) this.#createMarkerAtHit(hit);
644
+ this.#closeCanvasMenu();
645
+ };
646
+ this._ui.canvasMenu.addEventListener("click", this._onCanvasMenuClick);
647
+
648
+ this._onKeyDown = (e) => {
649
+ if (this._placing) {
650
+ if (e.key === "Escape") this.cancelPlacement();
651
+ return;
652
+ }
653
+
654
+ const target = e.target;
655
+ const tag = (target && target.tagName) ? String(target.tagName).toLowerCase() : "";
656
+ if (tag === "input" || tag === "textarea" || target?.isContentEditable) return;
657
+
658
+ const hasSelection = this.#getSelectedMarker() != null;
659
+ if (!hasSelection) return;
660
+
661
+ const key = String(e.key || "").toLowerCase();
662
+ const withCmd = !!(e.ctrlKey || e.metaKey);
663
+
664
+ if (withCmd && key === "c") {
665
+ try { e.preventDefault(); } catch (_) {}
666
+ this.#emitLabelAction("copy");
667
+ return;
668
+ }
669
+ if (key === "delete" || key === "backspace") {
670
+ try { e.preventDefault(); } catch (_) {}
671
+ this.#emitLabelAction("delete");
672
+ return;
673
+ }
674
+ if (key === "m" || (withCmd && key === "m")) {
675
+ try { e.preventDefault(); } catch (_) {}
676
+ this.#emitLabelAction("move");
677
+ }
678
+ };
679
+ window.addEventListener("keydown", this._onKeyDown);
680
+
681
+ this._onWindowPointerDown = (e) => {
682
+ if (!this._contextMenu.open && !this._canvasMenu.open) return;
683
+ const menu = this._ui?.menu;
684
+ const canvasMenu = this._ui?.canvasMenu;
685
+ if (menu && menu.contains(e.target)) return;
686
+ if (canvasMenu && canvasMenu.contains(e.target)) return;
687
+ this.#closeContextMenu();
688
+ this.#closeCanvasMenu();
689
+ };
690
+ window.addEventListener("pointerdown", this._onWindowPointerDown, true);
691
+
692
+ // Смещение контейнера (#app) относительно viewport может меняться при resize/scroll.
693
+ // Обновляем кэш, чтобы призрак/метки не "плыли".
694
+ this._onWindowResize = () => {
695
+ this.#refreshContainerOffset();
696
+ if (this._placing) this.#syncGhost();
697
+ this.#closeContextMenu();
698
+ };
699
+ this._onWindowScroll = () => {
700
+ this.#refreshContainerOffset();
701
+ if (this._placing) this.#syncGhost();
702
+ this.#closeContextMenu();
703
+ };
704
+ window.addEventListener("resize", this._onWindowResize, { passive: true });
705
+ // scroll слушаем в capture, чтобы ловить скролл вложенных контейнеров
706
+ window.addEventListener("scroll", this._onWindowScroll, true);
707
+
708
+ const dom = this.viewer?.renderer?.domElement;
709
+ if (!dom) return;
710
+
711
+ this._onPointerMove = (e) => {
712
+ if (!this._placing) return;
713
+ this.#updateGhostFromClient(e.clientX, e.clientY);
714
+ };
715
+ dom.addEventListener("pointermove", this._onPointerMove, { passive: true });
716
+
717
+ // Best-effort: более частые координаты, чем pointermove (Chrome).
718
+ this._onPointerRawUpdate = (e) => {
719
+ if (!this._placing) return;
720
+ this.#updateGhostFromClient(e.clientX, e.clientY);
721
+ };
722
+ try { dom.addEventListener("pointerrawupdate", this._onPointerRawUpdate, { passive: true }); } catch (_) {}
723
+
724
+ this._onPointerDownCapture = (e) => {
725
+ if (!this._placing) return;
726
+ // Только ЛКМ ставит метку. Остальные кнопки не блокируем (например, колесо/ПКМ).
727
+ if (e.button !== 0) return;
728
+
729
+ // Блокируем другие контроллеры (OrbitControls/MMB/RMB) на время постановки.
730
+ try { e.preventDefault(); } catch (_) {}
731
+ try { e.stopPropagation(); } catch (_) {}
732
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
733
+
734
+ const hit = this.#pickModelPoint(e.clientX, e.clientY);
735
+ if (!hit) {
736
+ this.#log("placeAttempt:no-hit", {});
737
+ return; // остаёмся в режиме, пользователь может кликнуть ещё раз
738
+ }
739
+ this.#createMarkerAtHit(hit);
740
+ this.cancelPlacement(); // по ТЗ: “вторым кликом устанавливаем”
741
+ };
742
+ dom.addEventListener("pointerdown", this._onPointerDownCapture, { capture: true, passive: false });
743
+
744
+ this._onCanvasContextMenu = (e) => {
745
+ // Контекстное меню добавления метки по ПКМ на модели (если нет метки под курсором).
746
+ try { e.preventDefault(); } catch (_) {}
747
+ try { e.stopPropagation(); } catch (_) {}
748
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
749
+ try { this.cancelPlacement(); } catch (_) {}
750
+
751
+ const el = document.elementFromPoint?.(e.clientX, e.clientY);
752
+ if (el && el.closest?.(".ifc-label-marker")) return;
753
+ if (el && el.closest?.(".ifc-label-menu")) return;
754
+
755
+ const hit = this.#pickModelPoint(e.clientX, e.clientY);
756
+ if (!hit) return;
757
+
758
+ this.#closeContextMenu();
759
+ this.#openCanvasMenu(hit, e.clientX, e.clientY);
760
+ };
761
+ dom.addEventListener("contextmenu", this._onCanvasContextMenu, { capture: true, passive: false });
762
+ }
763
+
764
+ #setGhostVisible(visible) {
765
+ if (!this._ui?.ghost) return;
766
+ this._ui.ghost.style.display = visible ? "block" : "none";
767
+ }
768
+
769
+ #refreshContainerOffset() {
770
+ const cr = this.container?.getBoundingClientRect?.();
771
+ if (!cr) {
772
+ this._containerOffset.left = 0;
773
+ this._containerOffset.top = 0;
774
+ this._containerOffsetValid = false;
775
+ return;
776
+ }
777
+ this._containerOffset.left = cr.left || 0;
778
+ this._containerOffset.top = cr.top || 0;
779
+ this._containerOffsetValid = true;
780
+ }
781
+
782
+ #applyGhostTransform() {
783
+ const g = this._ui?.ghost;
784
+ if (!g) return;
785
+ g.style.transform = `translate3d(${this._ghostPos.x}px, ${this._ghostPos.y}px, 0) translate(-50%, -50%)`;
786
+ }
787
+
788
+ #updateGhostFromClient(clientX, clientY) {
789
+ this._lastPointer = { x: clientX, y: clientY };
790
+ if (!this._containerOffsetValid) this.#refreshContainerOffset();
791
+ const x = (clientX - this._containerOffset.left);
792
+ const y = (clientY - this._containerOffset.top);
793
+ this._ghostPos.x = x;
794
+ this._ghostPos.y = y;
795
+ // Обновляем transform сразу в обработчике — без ожидания RAF.
796
+ this.#applyGhostTransform();
797
+ }
798
+
799
+ #syncGhost() {
800
+ const g = this._ui?.ghost;
801
+ if (!g) return;
802
+ this._ui.num.textContent = String(this._nextId);
803
+ // Число может меняться (id), позиция — из последних координат курсора.
804
+ this.#updateGhostFromClient(this._lastPointer.x, this._lastPointer.y);
805
+ }
806
+
807
+ #pickModelPoint(clientX, clientY) {
808
+ const model = this.viewer?.activeModel;
809
+ const camera = this.viewer?.camera;
810
+ const dom = this.viewer?.renderer?.domElement;
811
+ if (!model || !camera || !dom) return null;
812
+
813
+ const rect = dom.getBoundingClientRect?.();
814
+ if (!rect || rect.width <= 0 || rect.height <= 0) return null;
815
+
816
+ const x = ((clientX - rect.left) / rect.width) * 2 - 1;
817
+ const y = -(((clientY - rect.top) / rect.height) * 2 - 1);
818
+ this._ndc.set(x, y);
819
+
820
+ this._raycaster.setFromCamera(this._ndc, camera);
821
+ const hits = this._raycaster.intersectObject(model, true);
822
+ if (!hits || hits.length <= 0) return null;
823
+
824
+ // ВАЖНО: у модели могут быть контурные линии/оверлеи (LineSegments),
825
+ // которые перехватывают hit раньше "реального" Mesh фасада.
826
+ // Для постановки метки выбираем первый hit именно по Mesh.
827
+ let best = null;
828
+ for (const h of hits) {
829
+ if (!h || !h.point) continue;
830
+ const obj = h.object;
831
+ if (obj && obj.isMesh) { best = h; break; }
832
+ }
833
+ // fallback: если Mesh не найден (редко), берём первый валидный hit
834
+ if (!best) {
835
+ for (const h of hits) {
836
+ if (h && h.point) { best = h; break; }
837
+ }
838
+ }
839
+
840
+ if (!best || !best.point) return null;
841
+ return best;
842
+ }
843
+
844
+ #createMarkerAtHit(hit) {
845
+ const model = this.viewer?.activeModel;
846
+ if (!model) return;
847
+
848
+ const id = this._nextId++;
849
+
850
+ // Храним локальную координату модели, чтобы метка оставалась “приклеенной” к модели
851
+ this._tmpLocal.copy(hit.point);
852
+ model.worldToLocal(this._tmpLocal);
853
+
854
+ const sceneState = this.#captureSceneState();
855
+ const marker = this.#createMarkerFromData({
856
+ id,
857
+ localPoint: { x: this._tmpLocal.x, y: this._tmpLocal.y, z: this._tmpLocal.z },
858
+ sceneState,
859
+ }, true);
860
+
861
+ this.#log("placed", {
862
+ id,
863
+ local: { x: +marker.localPoint.x.toFixed(4), y: +marker.localPoint.y.toFixed(4), z: +marker.localPoint.z.toFixed(4) },
864
+ sceneState: sceneState ? { hasCamera: !!sceneState.camera, hasModel: !!sceneState.modelTransform, hasClip: !!sceneState.clipping } : null,
865
+ });
866
+ }
867
+
868
+ #createMarkerElement(id) {
869
+ const el = document.createElement("div");
870
+ el.className = "ifc-label-marker";
871
+ el.setAttribute("data-id", String(id));
872
+ el.innerHTML = `<div class="ifc-label-dot"></div><div class="ifc-label-num">${id}</div>`;
873
+ // Базовая позиция: двигаем transform'ом, поэтому left/top держим в 0
874
+ el.style.left = "0px";
875
+ el.style.top = "0px";
876
+ this.container.appendChild(el);
877
+ return el;
878
+ }
879
+
880
+ #setSelectedMarker(marker) {
881
+ if (!marker) {
882
+ this.#clearSelection();
883
+ return;
884
+ }
885
+ if (this._selectedId === marker.id) return;
886
+ this.#clearSelection();
887
+ this._selectedId = marker.id;
888
+ try { marker.el?.classList?.add?.("ifc-label-marker--active"); } catch (_) {}
889
+ }
890
+
891
+ #clearSelection() {
892
+ if (this._selectedId == null) return;
893
+ const marker = this._markers.find((m) => String(m?.id) === String(this._selectedId));
894
+ try { marker?.el?.classList?.remove?.("ifc-label-marker--active"); } catch (_) {}
895
+ this._selectedId = null;
896
+ }
897
+
898
+ #getSelectedMarker() {
899
+ if (this._selectedId == null) return null;
900
+ return this._markers.find((m) => String(m?.id) === String(this._selectedId)) || null;
901
+ }
902
+
903
+ #buildActionPayload(marker) {
904
+ if (!marker) return null;
905
+ return {
906
+ id: marker.id,
907
+ localPoint: { x: marker.localPoint.x, y: marker.localPoint.y, z: marker.localPoint.z },
908
+ sceneState: marker.sceneState || null,
909
+ };
910
+ }
911
+
912
+ #emitLabelAction(action, marker = null) {
913
+ const target = marker || this.#getSelectedMarker();
914
+ if (!target) return;
915
+ const detail = this.#buildActionPayload(target);
916
+ if (!detail) return;
917
+ this.#dispatchLabelEvent(`ifcviewer:label-${action}`, detail, null);
918
+ }
919
+
920
+ #openContextMenu(marker, clientX, clientY) {
921
+ const menu = this._ui?.menu;
922
+ if (!menu || !marker) return;
923
+
924
+ this.#closeContextMenu();
925
+
926
+ this._contextMenu.open = true;
927
+ this._contextMenu.marker = marker;
928
+
929
+ if (!this._containerOffsetValid) this.#refreshContainerOffset();
930
+ const x = clientX - this._containerOffset.left;
931
+ const y = clientY - this._containerOffset.top;
932
+
933
+ menu.style.display = "block";
934
+ // Зафиксируем базовую точку, чтобы transform не смещался от offsetTop/Left
935
+ menu.style.left = "0px";
936
+ menu.style.top = "0px";
937
+ menu.style.transform = `translate3d(${x}px, ${y}px, 0) translate(8px, 8px)`;
938
+
939
+ // Корректируем позицию, чтобы не выходить за контейнер
940
+ try {
941
+ const rect = this.container?.getBoundingClientRect?.();
942
+ const mw = menu.offsetWidth || 0;
943
+ const mh = menu.offsetHeight || 0;
944
+ if (rect && mw && mh) {
945
+ let px = x + 8;
946
+ let py = y + 8;
947
+ if (px + mw > rect.width) px = Math.max(0, rect.width - mw - 4);
948
+ if (py + mh > rect.height) py = Math.max(0, rect.height - mh - 4);
949
+ menu.style.transform = `translate3d(${px}px, ${py}px, 0)`;
950
+ }
951
+ } catch (_) {}
952
+ }
953
+
954
+ #closeContextMenu() {
955
+ const menu = this._ui?.menu;
956
+ if (!menu) return;
957
+ this._contextMenu.open = false;
958
+ this._contextMenu.marker = null;
959
+ menu.style.display = "none";
960
+ }
961
+
962
+ #openCanvasMenu(hit, clientX, clientY) {
963
+ const menu = this._ui?.canvasMenu;
964
+ if (!menu || !hit) return;
965
+
966
+ this.#closeCanvasMenu();
967
+
968
+ this._canvasMenu.open = true;
969
+ this._canvasMenu.hit = hit;
970
+
971
+ if (!this._containerOffsetValid) this.#refreshContainerOffset();
972
+ const x = clientX - this._containerOffset.left;
973
+ const y = clientY - this._containerOffset.top;
974
+
975
+ menu.style.display = "block";
976
+ menu.style.left = "0px";
977
+ menu.style.top = "0px";
978
+ menu.style.transform = `translate3d(${x}px, ${y}px, 0) translate(8px, 8px)`;
979
+
980
+ try {
981
+ const rect = this.container?.getBoundingClientRect?.();
982
+ const mw = menu.offsetWidth || 0;
983
+ const mh = menu.offsetHeight || 0;
984
+ if (rect && mw && mh) {
985
+ let px = x + 8;
986
+ let py = y + 8;
987
+ if (px + mw > rect.width) px = Math.max(0, rect.width - mw - 4);
988
+ if (py + mh > rect.height) py = Math.max(0, rect.height - mh - 4);
989
+ menu.style.transform = `translate3d(${px}px, ${py}px, 0)`;
990
+ }
991
+ } catch (_) {}
992
+ }
993
+
994
+ #closeCanvasMenu() {
995
+ const menu = this._ui?.canvasMenu;
996
+ if (!menu) return;
997
+ this._canvasMenu.open = false;
998
+ this._canvasMenu.hit = null;
999
+ menu.style.display = "none";
1000
+ }
1001
+
1002
+ #createMarkerFromData(data, emitPlacedEvent) {
1003
+ if (!data) return null;
1004
+ const localPoint = data.localPoint || {};
1005
+ const marker = new LabelMarker({
1006
+ id: data.id,
1007
+ localPoint: new THREE.Vector3(
1008
+ Number(localPoint.x) || 0,
1009
+ Number(localPoint.y) || 0,
1010
+ Number(localPoint.z) || 0
1011
+ ),
1012
+ el: this.#createMarkerElement(data.id),
1013
+ sceneState: data.sceneState || null,
1014
+ });
1015
+ this._markers.push(marker);
1016
+
1017
+ const onMarkerPointerDown = (e) => {
1018
+ // Важно: не даём клику попасть в canvas/OrbitControls
1019
+ try { e.preventDefault(); } catch (_) {}
1020
+ try { e.stopPropagation(); } catch (_) {}
1021
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
1022
+ // если были в режиме постановки — выходим
1023
+ try { this.cancelPlacement(); } catch (_) {}
1024
+
1025
+ if (e.button === 0) {
1026
+ this.#closeContextMenu();
1027
+ this.#setSelectedMarker(marker);
1028
+ this.#dispatchLabelEvent("ifcviewer:label-click", {
1029
+ id: marker.id,
1030
+ sceneState: marker.sceneState || null,
1031
+ }, "ifcviewer:card-click");
1032
+ // "Долеталка" камеры: быстрый старт + мягкий конец
1033
+ this.#animateToSceneState(marker.sceneState, 550);
1034
+ }
1035
+ };
1036
+ // capture-phase, чтобы обогнать любые handlers на canvas
1037
+ try { marker.el.addEventListener("pointerdown", onMarkerPointerDown, { capture: true, passive: false }); } catch (_) {
1038
+ try { marker.el.addEventListener("pointerdown", onMarkerPointerDown); } catch (_) {}
1039
+ }
1040
+
1041
+ const onMarkerContextMenu = (e) => {
1042
+ try { e.preventDefault(); } catch (_) {}
1043
+ try { e.stopPropagation(); } catch (_) {}
1044
+ try { e.stopImmediatePropagation?.(); } catch (_) {}
1045
+ try { this.cancelPlacement(); } catch (_) {}
1046
+
1047
+ this.#setSelectedMarker(marker);
1048
+ this.#openContextMenu(marker, e.clientX, e.clientY);
1049
+ };
1050
+ try { marker.el.addEventListener("contextmenu", onMarkerContextMenu, { capture: true, passive: false }); } catch (_) {
1051
+ try { marker.el.addEventListener("contextmenu", onMarkerContextMenu); } catch (_) {}
1052
+ }
1053
+
1054
+ if (emitPlacedEvent) {
1055
+ this.#dispatchLabelEvent("ifcviewer:label-placed", {
1056
+ id: marker.id,
1057
+ localPoint: { x: marker.localPoint.x, y: marker.localPoint.y, z: marker.localPoint.z },
1058
+ sceneState: marker.sceneState || null,
1059
+ }, "ifcviewer:card-placed");
1060
+ }
1061
+
1062
+ return marker;
1063
+ }
1064
+
1065
+ #clearMarkers() {
1066
+ try { this._markers.forEach((m) => m?.el?.remove?.()); } catch (_) {}
1067
+ this._markers.length = 0;
1068
+ this.#clearSelection();
1069
+ }
1070
+
1071
+ setLabelMarkers(items) {
1072
+ if (!Array.isArray(items)) return;
1073
+ const prevSelectedId = this._selectedId;
1074
+ this.#clearMarkers();
1075
+
1076
+ let maxNumericId = null;
1077
+ for (const item of items) {
1078
+ const marker = this.#createMarkerFromData(item, false);
1079
+ if (marker && typeof marker.id === "number" && Number.isFinite(marker.id)) {
1080
+ maxNumericId = (maxNumericId == null) ? marker.id : Math.max(maxNumericId, marker.id);
1081
+ }
1082
+ }
1083
+ if (maxNumericId != null) this._nextId = Math.max(1, Math.floor(maxNumericId) + 1);
1084
+ if (prevSelectedId != null) this.selectLabel(prevSelectedId);
1085
+ }
1086
+
1087
+ getLabelMarkers() {
1088
+ return this._markers.map((m) => ({
1089
+ id: m.id,
1090
+ localPoint: { x: m.localPoint.x, y: m.localPoint.y, z: m.localPoint.z },
1091
+ sceneState: m.sceneState || null,
1092
+ }));
1093
+ }
1094
+
1095
+ /**
1096
+ * @deprecated используйте setLabelMarkers
1097
+ */
1098
+ setCardMarkers(items) {
1099
+ try { this.logger?.warn?.("[LabelPlacement] setCardMarkers is deprecated, use setLabelMarkers"); } catch (_) {}
1100
+ this.setLabelMarkers(items);
1101
+ }
1102
+
1103
+ /**
1104
+ * @deprecated используйте getLabelMarkers
1105
+ */
1106
+ getCardMarkers() {
1107
+ try { this.logger?.warn?.("[LabelPlacement] getCardMarkers is deprecated, use getLabelMarkers"); } catch (_) {}
1108
+ return this.getLabelMarkers();
1109
+ }
1110
+
1111
+ selectLabel(id) {
1112
+ if (id == null) {
1113
+ this.#clearSelection();
1114
+ return;
1115
+ }
1116
+ const marker = this._markers.find((m) => String(m?.id) === String(id)) || null;
1117
+ if (!marker) {
1118
+ this.#clearSelection();
1119
+ return;
1120
+ }
1121
+ this.#setSelectedMarker(marker);
1122
+ }
1123
+
1124
+ #startRaf() {
1125
+ const tick = () => {
1126
+ this._raf = requestAnimationFrame(tick);
1127
+ this.#updateMarkerScreenspace();
1128
+ };
1129
+ this._raf = requestAnimationFrame(tick);
1130
+ }
1131
+
1132
+ #updateMarkerScreenspace() {
1133
+ const model = this.viewer?.activeModel;
1134
+ const camera = this.viewer?.camera;
1135
+ const dom = this.viewer?.renderer?.domElement;
1136
+ if (!camera || !dom) return;
1137
+
1138
+ const rect = dom.getBoundingClientRect?.();
1139
+ if (!rect || rect.width <= 0 || rect.height <= 0) return;
1140
+
1141
+ const w = rect.width;
1142
+ const h = rect.height;
1143
+
1144
+ for (const m of this._markers) {
1145
+ if (!m || !m.el) continue;
1146
+
1147
+ if (!model) {
1148
+ m.el.style.display = "none";
1149
+ continue;
1150
+ }
1151
+
1152
+ // local -> world (учитывает позицию/поворот/scale модели)
1153
+ this._tmpV.copy(m.localPoint);
1154
+ model.localToWorld(this._tmpV);
1155
+
1156
+ const ndc = this._tmpV.project(camera);
1157
+
1158
+ // Если точка за камерой или далеко за пределами — скрываем
1159
+ const inFront = Number.isFinite(ndc.z) && ndc.z >= -1 && ndc.z <= 1;
1160
+ if (!inFront) {
1161
+ m.el.style.display = "none";
1162
+ continue;
1163
+ }
1164
+
1165
+ const x = (ndc.x * 0.5 + 0.5) * w;
1166
+ const y = (-ndc.y * 0.5 + 0.5) * h;
1167
+
1168
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
1169
+ m.el.style.display = "none";
1170
+ continue;
1171
+ }
1172
+
1173
+ m.el.style.display = "block";
1174
+ m.el.style.transform = `translate3d(${x}px, ${y}px, 0) translate(-50%, -50%)`;
1175
+ }
1176
+ }
1177
+ }
1178
+