@sequent-org/ifc-viewer 1.2.4-ci.46.0 → 1.2.4-ci.47.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.
@@ -162,6 +162,8 @@ export class Viewer {
162
162
  this._home = {
163
163
  cameraPos: null,
164
164
  target: new THREE.Vector3(0, 0, 0),
165
+ // FOV для перспективы (часть "масштаба" кадра)
166
+ perspFov: null,
165
167
  edgesVisible: false,
166
168
  flatShading: true,
167
169
  quality: 'medium',
@@ -249,7 +251,7 @@ export class Viewer {
249
251
 
250
252
  /**
251
253
  * Возвращает "домашнюю" точку вращения для текущей модели:
252
- * центр bbox со смещением вниз по Y (как при первичной загрузке).
254
+ * центр bbox (совпадает с кадрированием при первичной загрузке).
253
255
  * @returns {THREE.Vector3|null}
254
256
  */
255
257
  #getDefaultPivotForActiveModel() {
@@ -258,12 +260,7 @@ export class Viewer {
258
260
  try {
259
261
  const box = new THREE.Box3().setFromObject(subject);
260
262
  const center = box.getCenter(new THREE.Vector3());
261
- const size = box.getSize(new THREE.Vector3());
262
- const pivot = center.clone();
263
- // Держим модель "выше" в кадре, как при replaceWithModel()
264
- const verticalBias = size.y * 0.30; // 30% высоты
265
- pivot.y = center.y - verticalBias;
266
- return pivot;
263
+ return center.clone();
267
264
  } catch (_) {
268
265
  return null;
269
266
  }
@@ -272,7 +269,7 @@ export class Viewer {
272
269
  /**
273
270
  * Возвращает целевой pivot для ЛКМ-вращения:
274
271
  * - если модель двигали ПКМ, используем фиксированную ось (pivotAnchor)
275
- * - иначе используем "домашний" pivot (центр bbox + verticalBias)
272
+ * - иначе используем "домашний" pivot (центр bbox)
276
273
  * @returns {THREE.Vector3|null}
277
274
  */
278
275
  #getDesiredPivotForRotate() {
@@ -665,7 +662,8 @@ export class Viewer {
665
662
  sizePx: 96,
666
663
  marginPx: 10,
667
664
  opacity: 0.6,
668
- onHome: () => this.goHome(),
665
+ // Home-кнопка NavCube: возвращаем ТОЛЬКО камеру (не трогаем инструменты/сечения/стили)
666
+ onHome: () => this.goHomeViewOnly(),
669
667
  });
670
668
 
671
669
  // Визуальная ось вращения: события мыши и контролов
@@ -740,6 +738,7 @@ export class Viewer {
740
738
  // Сохраним Home-снапшот после инициализации
741
739
  this._home.cameraPos = this.camera.position.clone();
742
740
  this._home.target = this.controls.target.clone();
741
+ this._home.perspFov = (this.camera && this.camera.isPerspectiveCamera) ? this.camera.fov : (this._projection?.persp?.fov ?? 20);
743
742
  this._home.edgesVisible = this.edgesVisible;
744
743
  this._home.flatShading = this.flatShading;
745
744
  this._home.quality = this.quality;
@@ -1032,6 +1031,160 @@ export class Viewer {
1032
1031
  }
1033
1032
  }
1034
1033
 
1034
+ /**
1035
+ * Кадрирует объект так, чтобы он гарантированно помещался в "безопасной области" кадра.
1036
+ * Идея безопасной области задаётся сеткой: например 4×4, и объект должен занимать
1037
+ * не больше spanCols×spanRows в центре (по умолчанию 2×2, т.е. ~50% по ширине/высоте).
1038
+ *
1039
+ * - Для Perspective: подбираем дистанцию через bbox и текущий aspect + FOV.
1040
+ * - Для Ortho: подбираем zoom (и при необходимости расширяем orthoHalfHeight), чтобы bbox поместился.
1041
+ *
1042
+ * @param {THREE.Object3D} object3D
1043
+ * @param {Object} [opts]
1044
+ * @param {number} [opts.gridCols=4]
1045
+ * @param {number} [opts.gridRows=4]
1046
+ * @param {number} [opts.spanCols=2]
1047
+ * @param {number} [opts.spanRows=2]
1048
+ * @param {number} [opts.extraPadding=1.05] - доп. запас поверх математического fit (>=1)
1049
+ * @param {number} [opts.perspectiveFov=20] - целевой FOV для perspective
1050
+ * @param {THREE.Vector3} [opts.viewDir] - направление от цели к камере (front-right-top)
1051
+ * @param {boolean} [opts.log=false]
1052
+ * @returns {{center:THREE.Vector3,size:THREE.Vector3,padding:number,mode:string}|null}
1053
+ */
1054
+ frameObjectToViewportGrid(object3D, opts = {}) {
1055
+ if (!object3D || !this.camera || !this.controls) return null;
1056
+
1057
+ const {
1058
+ gridCols = 4,
1059
+ gridRows = 4,
1060
+ spanCols = 2,
1061
+ spanRows = 2,
1062
+ extraPadding = 1.05,
1063
+ perspectiveFov = 20,
1064
+ viewDir = null,
1065
+ log = false,
1066
+ } = opts || {};
1067
+
1068
+ const box = new THREE.Box3().setFromObject(object3D);
1069
+ const size = box.getSize(new THREE.Vector3());
1070
+ const center = box.getCenter(new THREE.Vector3());
1071
+
1072
+ const safeRX = Math.max(1e-6, Math.min(1, Number(spanCols) / Math.max(1, Number(gridCols))));
1073
+ const safeRY = Math.max(1e-6, Math.min(1, Number(spanRows) / Math.max(1, Number(gridRows))));
1074
+ const safeR = Math.max(1e-6, Math.min(safeRX, safeRY));
1075
+ const padding = Math.max(1.0, (1 / safeR) * Math.max(1.0, Number(extraPadding) || 1.0));
1076
+
1077
+ // Направление вида: по умолчанию "front-right-top" в текущей сцене.
1078
+ // Важно: в некоторых IFC/сценах "front/right" может не совпадать со знаками мировых осей,
1079
+ // поэтому вектор легко переопределяется через opts.viewDir.
1080
+ let dir = null;
1081
+ if (viewDir && viewDir.isVector3) dir = viewDir.clone();
1082
+ else dir = new THREE.Vector3(-1, 0.6, 1);
1083
+ const dirLen = dir.length();
1084
+ if (dirLen > 1e-6) dir.multiplyScalar(1 / dirLen);
1085
+ else dir.set(0, 0.2, 1).normalize();
1086
+ const dirN = dir.clone(); // нормализованный (для логов и вычислений без мутаций)
1087
+
1088
+ // Строго по центру bbox
1089
+ this.controls.target.copy(center);
1090
+
1091
+ const aspect = this._getAspect?.() || (this.camera.aspect || 1);
1092
+
1093
+ if (this.camera.isPerspectiveCamera) {
1094
+ const fov = Number.isFinite(Number(perspectiveFov)) ? Number(perspectiveFov) : this.camera.fov;
1095
+ if (Number.isFinite(fov) && fov > 1e-3 && fov < 179) {
1096
+ this.camera.fov = fov;
1097
+ this.camera.updateProjectionMatrix();
1098
+ }
1099
+ try {
1100
+ if (this.camera.aspect !== aspect) {
1101
+ this.camera.aspect = aspect;
1102
+ this.camera.updateProjectionMatrix();
1103
+ }
1104
+ } catch (_) {}
1105
+
1106
+ const dist = this.#computeFitDistanceForSize(size, padding);
1107
+ this.camera.position.copy(center.clone().add(dirN.clone().multiplyScalar(dist)));
1108
+ this.camera.updateProjectionMatrix();
1109
+ this.controls.update();
1110
+
1111
+ if (log) {
1112
+ // eslint-disable-next-line no-console
1113
+ console.log('[Viewer] frameObjectToViewportGrid(persp)', {
1114
+ grid: { gridCols, gridRows, spanCols, spanRows, safeRX, safeRY, padding },
1115
+ bbox: {
1116
+ size: { x: +size.x.toFixed(3), y: +size.y.toFixed(3), z: +size.z.toFixed(3) },
1117
+ center: { x: +center.x.toFixed(3), y: +center.y.toFixed(3), z: +center.z.toFixed(3) },
1118
+ },
1119
+ camera: { fov: this.camera.fov, aspect: this.camera.aspect, dist: +dist.toFixed(3) },
1120
+ viewDir: { x: +dirN.x.toFixed(3), y: +dirN.y.toFixed(3), z: +dirN.z.toFixed(3) },
1121
+ });
1122
+ }
1123
+
1124
+ return { center, size, padding, mode: 'perspective' };
1125
+ }
1126
+
1127
+ if (this.camera.isOrthographicCamera) {
1128
+ const safeSizeX = Math.max(1e-6, size.x);
1129
+ const safeSizeY = Math.max(1e-6, size.y);
1130
+ const fitHeight = Math.max(safeSizeY, safeSizeX / Math.max(1e-6, aspect));
1131
+ const neededHalfVisible = (fitHeight * padding) / 2;
1132
+
1133
+ let halfH = this._projection?.orthoHalfHeight || Math.abs(this.camera.top) || 10;
1134
+ halfH = Math.max(0.01, halfH);
1135
+
1136
+ const minZoom = this.controls?.minZoom ?? this._projection?.minZoom ?? 0.25;
1137
+ const maxZoom = this.controls?.maxZoom ?? this._projection?.maxZoom ?? 8;
1138
+
1139
+ let zoomFit = halfH / Math.max(1e-6, neededHalfVisible);
1140
+
1141
+ if (zoomFit < minZoom) {
1142
+ halfH = Math.max(halfH, neededHalfVisible * minZoom);
1143
+ try { this._projection.orthoHalfHeight = halfH; } catch (_) {}
1144
+ try {
1145
+ this.camera.left = -halfH * aspect;
1146
+ this.camera.right = halfH * aspect;
1147
+ this.camera.top = halfH;
1148
+ this.camera.bottom = -halfH;
1149
+ } catch (_) {}
1150
+ zoomFit = halfH / Math.max(1e-6, neededHalfVisible);
1151
+ }
1152
+
1153
+ const zoom = Math.min(maxZoom, zoomFit);
1154
+ this.camera.zoom = Math.max(1e-6, zoom);
1155
+
1156
+ const dist = Math.max(1.0, size.length());
1157
+ this.camera.position.copy(center.clone().add(dirN.clone().multiplyScalar(dist)));
1158
+
1159
+ this.camera.updateProjectionMatrix();
1160
+ this.controls.update();
1161
+
1162
+ if (log) {
1163
+ // eslint-disable-next-line no-console
1164
+ console.log('[Viewer] frameObjectToViewportGrid(ortho)', {
1165
+ grid: { gridCols, gridRows, spanCols, spanRows, safeRX, safeRY, padding },
1166
+ bbox: {
1167
+ size: { x: +size.x.toFixed(3), y: +size.y.toFixed(3), z: +size.z.toFixed(3) },
1168
+ center: { x: +center.x.toFixed(3), y: +center.y.toFixed(3), z: +center.z.toFixed(3) },
1169
+ },
1170
+ ortho: {
1171
+ aspect: +aspect.toFixed(3),
1172
+ halfH: +halfH.toFixed(3),
1173
+ neededHalfVisible: +neededHalfVisible.toFixed(3),
1174
+ zoom: +this.camera.zoom.toFixed(3),
1175
+ minZoom,
1176
+ maxZoom,
1177
+ },
1178
+ viewDir: { x: +dirN.x.toFixed(3), y: +dirN.y.toFixed(3), z: +dirN.z.toFixed(3) },
1179
+ });
1180
+ }
1181
+
1182
+ return { center, size, padding, mode: 'ortho' };
1183
+ }
1184
+
1185
+ return { center, size, padding, mode: 'unknown' };
1186
+ }
1187
+
1035
1188
  // Вычисляет дистанцию до объекта, при которой он полностью помещается в кадр
1036
1189
  #computeFitDistanceForSize(size, padding = 1.2) {
1037
1190
  // Защита от нулевых размеров
@@ -1246,48 +1399,47 @@ export class Viewer {
1246
1399
  // Синхронизируем "сечение → shadow-pass" для материалов после назначения пресета
1247
1400
  this.#applyClipShadowsToModelMaterials();
1248
1401
 
1249
- // Настроим пределы зума и сфокусируемся на новой модели
1250
- this.applyAdaptiveZoomLimits(object3D, { padding: 1.2, slack: 2.5, minRatio: 0.05, recenter: true });
1402
+ // Настроим пределы зума под габариты модели (кадрирование делаем отдельно ниже, на следующем кадре).
1403
+ // Здесь важно в первую очередь "оздоровить" near/far под размер модели.
1404
+ this.applyAdaptiveZoomLimits(object3D, { padding: 2.1, slack: 2.5, minRatio: 0.05, recenter: false });
1251
1405
 
1252
1406
  // Если "Тест" активен, сразу применим его к только что загруженной модели (самозатенение + shadow camera по bbox)
1253
1407
  if (this._testPreset?.enabled) {
1254
1408
  try { this.#applyTestPresetToScene(); } catch (_) {}
1255
1409
  }
1256
1410
 
1257
- // На следующем кадре отъедем на 2x от вписанной дистанции (точно по размеру модели)
1411
+ // На следующем кадре выставим кадрирование безопасной зоне" (2×2 из 4×4) и ракурс front-right-top.
1258
1412
  try {
1259
- const box = new THREE.Box3().setFromObject(object3D);
1260
- const center = box.getCenter(new THREE.Vector3());
1261
1413
  requestAnimationFrame(() => {
1262
1414
  if (!this.camera || !this.controls) return;
1263
- // Центрируем точку взгляда на центр модели и ставим камеру в заданные координаты
1264
- this.controls.target.copy(center);
1265
- this.camera.position.set(-22.03, 3.17, 39.12);
1266
- // Убедимся, что FOV соответствует целевому искаженению (и сохраним кадрирование)
1267
- this.setPerspectiveFov(20, { keepFraming: true, log: true });
1415
+ // Логирование включается через ?frameDebug=1
1416
+ let log = false;
1268
1417
  try {
1269
- // Если камера слишком близко, отъедем до вписанной дистанции, сохранив направление
1270
- const size = box.getSize(new THREE.Vector3());
1271
- const fitDistExact = this.#computeFitDistanceForSize(size, 1.2);
1272
- const dirVec = this.camera.position.clone().sub(center);
1273
- const dist = dirVec.length();
1274
- if (dist < fitDistExact && dist > 1e-6) {
1275
- const dirNorm = dirVec.multiplyScalar(1 / dist);
1276
- this.camera.position.copy(center.clone().add(dirNorm.multiplyScalar(fitDistExact)));
1277
- }
1278
- // Поднимем модель в кадре: сместим точку прицеливания немного вниз по Y
1279
- const verticalBias = size.y * 0.30; // 30% высоты
1280
- this.controls.target.y = center.y - verticalBias;
1281
- } catch(_) {}
1282
- // После ручной перестановки камеры ещё раз "оздоровим" near/far под модель,
1283
- // чтобы не ловить z-fighting на фасадных накладках.
1284
- try { this.applyAdaptiveZoomLimits(object3D, { padding: 1.2, slack: 2.5, minRatio: 0.05, recenter: false }); } catch (_) {}
1285
- this.camera.updateProjectionMatrix();
1286
- this.controls.update();
1418
+ const params = new URLSearchParams(window.location.search);
1419
+ log = params.get('frameDebug') === '1';
1420
+ } catch (_) {}
1421
+
1422
+ // Кадрируем строго по центру bbox и с запасом (2×2 из 4×4 => padding≈2.0, +extraPadding)
1423
+ try {
1424
+ this.frameObjectToViewportGrid?.(object3D, {
1425
+ gridCols: 4,
1426
+ gridRows: 4,
1427
+ spanCols: 2,
1428
+ spanRows: 2,
1429
+ extraPadding: 1.05,
1430
+ perspectiveFov: 20,
1431
+ viewDir: new THREE.Vector3(-1, 0.6, 1), // front-right-top
1432
+ log,
1433
+ });
1434
+ } catch (_) {}
1435
+
1436
+ // После выставления камеры — ещё раз подстроим near/far и лимиты зума под новый кадр.
1437
+ try { this.applyAdaptiveZoomLimits(object3D, { padding: 2.1, slack: 2.5, minRatio: 0.05, recenter: false }); } catch (_) {}
1287
1438
 
1288
1439
  // Снимем актуальный «домашний» вид после всех корректировок
1289
1440
  this._home.cameraPos = this.camera.position.clone();
1290
1441
  this._home.target = this.controls.target.clone();
1442
+ this._home.perspFov = (this.camera && this.camera.isPerspectiveCamera) ? this.camera.fov : (this._projection?.persp?.fov ?? this._home.perspFov ?? 20);
1291
1443
  this._home.edgesVisible = this.edgesVisible;
1292
1444
  this._home.flatShading = this.flatShading;
1293
1445
  this._home.quality = this.quality;
@@ -3438,6 +3590,88 @@ export class Viewer {
3438
3590
  this.controls.update();
3439
3591
  }
3440
3592
 
3593
+ /**
3594
+ * Home (view only): возвращает ТОЛЬКО ракурс и масштаб (камера/target/zoom),
3595
+ * не сбрасывая активные инструменты (сечения/рёбра/тени/проекция/качество и т.п.).
3596
+ */
3597
+ goHomeViewOnly() {
3598
+ if (!this.camera || !this.controls) return;
3599
+
3600
+ // Сброс MMB-pan (viewOffset) — это часть "положения на экране"
3601
+ try { this._mmbPan?.controller?.reset?.(); } catch (_) {}
3602
+
3603
+ const homeTarget = this._home?.target;
3604
+ const homePos = this._home?.cameraPos;
3605
+ if (!homeTarget || !homePos) return;
3606
+
3607
+ // Прицел
3608
+ try { this.controls.target.copy(homeTarget); } catch (_) {}
3609
+
3610
+ // Восстановление масштаба:
3611
+ // - perspective: FOV + position
3612
+ // - ortho: zoom (подбираем так, чтобы видимый halfHeight соответствовал "домашнему" перспективному кадру)
3613
+ const homeFov = (this._home?.perspFov ?? this._projection?.persp?.fov ?? 20);
3614
+
3615
+ if (this.camera.isPerspectiveCamera) {
3616
+ if (Number.isFinite(homeFov) && homeFov > 1e-3 && homeFov < 179) {
3617
+ this.camera.fov = homeFov;
3618
+ }
3619
+ this.camera.position.copy(homePos);
3620
+ this.camera.updateProjectionMatrix();
3621
+ this.controls.update();
3622
+ return;
3623
+ }
3624
+
3625
+ if (this.camera.isOrthographicCamera) {
3626
+ // Ориентация/направление — как в домашнем виде (через homePos относительно target)
3627
+ const dirVec = homePos.clone().sub(homeTarget);
3628
+ let dist = dirVec.length();
3629
+ if (!(dist > 1e-6)) dist = 1.0;
3630
+ const dirN = dirVec.multiplyScalar(1 / dist);
3631
+
3632
+ // Ставим камеру на ту же дистанцию/направление — для Ortho это влияет на "ракурс", но не на масштаб
3633
+ this.camera.position.copy(homeTarget.clone().add(dirN.clone().multiplyScalar(dist)));
3634
+
3635
+ // Хотим, чтобы видимый halfHeight совпадал с тем, что был бы в перспективе:
3636
+ // halfVisible = dist * tan(fov/2)
3637
+ const vFov = (Number(homeFov) * Math.PI) / 180;
3638
+ const halfVisible = Math.max(0.01, dist * Math.tan(vFov / 2));
3639
+
3640
+ const aspect = this._getAspect?.() || (this.camera.aspect || 1);
3641
+ let halfH = this._projection?.orthoHalfHeight || Math.abs(this.camera.top) || 10;
3642
+ halfH = Math.max(0.01, halfH);
3643
+
3644
+ const minZoom = this.controls?.minZoom ?? this._projection?.minZoom ?? 0.25;
3645
+ const maxZoom = this.controls?.maxZoom ?? this._projection?.maxZoom ?? 8;
3646
+
3647
+ let zoomFit = halfH / Math.max(1e-6, halfVisible);
3648
+ if (zoomFit < minZoom) {
3649
+ // Расширим фрустум так, чтобы при minZoom кадр влезал
3650
+ halfH = Math.max(halfH, halfVisible * minZoom);
3651
+ try { this._projection.orthoHalfHeight = halfH; } catch (_) {}
3652
+ try {
3653
+ this.camera.left = -halfH * aspect;
3654
+ this.camera.right = halfH * aspect;
3655
+ this.camera.top = halfH;
3656
+ this.camera.bottom = -halfH;
3657
+ } catch (_) {}
3658
+ zoomFit = halfH / Math.max(1e-6, halfVisible);
3659
+ }
3660
+
3661
+ const zoom = Math.min(maxZoom, zoomFit);
3662
+ this.camera.zoom = Math.max(1e-6, zoom);
3663
+
3664
+ this.camera.updateProjectionMatrix();
3665
+ this.controls.update();
3666
+ return;
3667
+ }
3668
+
3669
+ // На неизвестной камере просто восстановим позицию
3670
+ try { this.camera.position.copy(homePos); } catch (_) {}
3671
+ try { this.camera.updateProjectionMatrix(); } catch (_) {}
3672
+ try { this.controls.update(); } catch (_) {}
3673
+ }
3674
+
3441
3675
  // ================= Вспомогательное: ось вращения =================
3442
3676
  #ensureRotationAxisLine() {
3443
3677
  if (this.rotationAxisLine) return this.rotationAxisLine;