@sequent-org/ifc-viewer 1.2.4-ci.26.0 → 1.2.4-ci.28.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.
@@ -3,6 +3,13 @@
3
3
 
4
4
  import * as THREE from "three";
5
5
  import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
6
+ import { EffectComposer } from "three/examples/jsm/postprocessing/EffectComposer.js";
7
+ import { RenderPass } from "three/examples/jsm/postprocessing/RenderPass.js";
8
+ import { SSAOPass } from "three/examples/jsm/postprocessing/SSAOPass.js";
9
+ import { ShaderPass } from "three/examples/jsm/postprocessing/ShaderPass.js";
10
+ import { HueSaturationShader } from "three/examples/jsm/shaders/HueSaturationShader.js";
11
+ import { BrightnessContrastShader } from "three/examples/jsm/shaders/BrightnessContrastShader.js";
12
+ import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
6
13
  import { NavCube } from "./NavCube.js";
7
14
  import { SectionManipulator } from "./SectionManipulator.js";
8
15
 
@@ -22,11 +29,73 @@ export class Viewer {
22
29
  this.demoCube = null;
23
30
  this.activeModel = null;
24
31
  this.autoRotateDemo = true;
25
- this.edgesVisible = true;
32
+ this.edgesVisible = false;
26
33
  this.flatShading = true;
27
34
  this.quality = 'medium'; // low | medium | high
28
35
  this.navCube = null;
29
36
  this.sectionOverlayScene = null;
37
+ // Плоскость под моделью (приёмник теней). Ничего не "включает" само по себе:
38
+ // если тени в рендерере отключены — плоскость останется невидимой.
39
+ this.shadowReceiver = null;
40
+ // Управление тенями через публичный API
41
+ this.shadowsEnabled = true;
42
+ /** @type {THREE.DirectionalLight|null} */
43
+ this.sunLight = null;
44
+ /** @type {THREE.AmbientLight|null} */
45
+ this.ambientLight = null;
46
+ // Базовые координаты солнца (чтобы менять только высоту по Y)
47
+ this._sunBaseXZ = { x: 5, z: 5 };
48
+ // Параметры градиента тени на земле (модифицирует только ShadowMaterial приёмника)
49
+ this.shadowGradient = {
50
+ enabled: true,
51
+ // Длина градиента (в мировых единицах, от контура bbox здания наружу)
52
+ length: 14.4,
53
+ // 0..1 — насколько тень "растворяется" на дальнем краю
54
+ strength: 1.0,
55
+ // Кривая затухания (нелинейность). 1 = линейно, >1 = дольше темно у здания, быстрее растворение в конце.
56
+ curve: 0.5,
57
+ // bbox здания в XZ (центр и halfSize)
58
+ buildingCenterXZ: new THREE.Vector2(0, 0),
59
+ buildingHalfSizeXZ: new THREE.Vector2(0.5, 0.5),
60
+ // ссылка на скомпилированный шейдер ShadowMaterial (для обновления uniforms)
61
+ _shader: null,
62
+ };
63
+ // Настройки вида тени
64
+ this.shadowStyle = {
65
+ opacity: 0.14, // прозрачность тени на земле (ShadowMaterial.opacity)
66
+ softness: 0.0, // мягкость края (DirectionalLight.shadow.radius)
67
+ };
68
+
69
+ // Материалы (пресеты)
70
+ this.materialStyle = {
71
+ preset: 'original', // original | matte | glossy | plastic | concrete
72
+ roughness: null, // override (0..1) или null = использовать пресет
73
+ metalness: null, // override (0..1) или null = использовать пресет
74
+ };
75
+ /** @type {WeakMap<THREE.Mesh, any>} */
76
+ this._meshOriginalMaterial = new WeakMap();
77
+ /** @type {WeakMap<any, any>} */
78
+ this._origToConvertedMaterial = new WeakMap();
79
+
80
+ // Визуал: диагностика (по умолчанию ВСЁ выключено, чтобы не менять стартовую картинку)
81
+ this.visual = {
82
+ environment: { enabled: false, intensity: 1.0 },
83
+ tone: { enabled: false, exposure: 1.0 },
84
+ ao: { enabled: false, intensity: 0.75, radius: 12, minDistance: 0.001, maxDistance: 0.2 },
85
+ color: { enabled: false, hue: 0.0, saturation: 0.0, brightness: 0.0, contrast: 0.0 },
86
+ };
87
+ // Пресет Realtime-quality: хранит снимок пользовательских настроек для восстановления
88
+ this._rtQuality = { enabled: false, snapshot: null };
89
+ // Пресет "Тест": полностью изолированная настройка (тени+самозатенение+визуал из рекомендаций)
90
+ this._testPreset = { enabled: false, snapshot: null };
91
+ this._baselineRenderer = null;
92
+ this._pmrem = null;
93
+ this._roomEnvTex = null;
94
+ this._composer = null;
95
+ this._renderPass = null;
96
+ this._ssaoPass = null;
97
+ this._hueSatPass = null;
98
+ this._bcPass = null;
30
99
  this.clipping = {
31
100
  enabled: false,
32
101
  planes: [
@@ -50,7 +119,7 @@ export class Viewer {
50
119
  this._home = {
51
120
  cameraPos: null,
52
121
  target: new THREE.Vector3(0, 0, 0),
53
- edgesVisible: true,
122
+ edgesVisible: false,
54
123
  flatShading: true,
55
124
  quality: 'medium',
56
125
  clipEnabled: [Infinity, Infinity, Infinity],
@@ -82,9 +151,14 @@ export class Viewer {
82
151
  if (!this.container) throw new Error("Viewer: контейнер не найден");
83
152
 
84
153
  // Рендерер
85
- this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
154
+ // logarithmicDepthBuffer: уменьшает z-fighting на почти копланарных поверхностях (часто в IFC).
155
+ // Это заметно снижает "мигание" тонких накладных деталей на фасадах.
156
+ this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, logarithmicDepthBuffer: true });
86
157
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
87
158
  this.renderer.autoClear = false; // управляем очисткой вручную для мульти-проходов
159
+ // Тени по умолчанию выключены (включаются только через setShadowsEnabled)
160
+ this.renderer.shadowMap.enabled = false;
161
+ this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
88
162
  // Спрячем канвас до первого корректного измерения
89
163
  this.renderer.domElement.style.visibility = "hidden";
90
164
  this.renderer.domElement.style.display = "block";
@@ -92,6 +166,16 @@ export class Viewer {
92
166
  this.renderer.domElement.style.height = "100%";
93
167
  this.container.appendChild(this.renderer.domElement);
94
168
 
169
+ // Базовые настройки рендера для корректного "выкл"
170
+ this._baselineRenderer = {
171
+ outputEncoding: this.renderer.outputEncoding,
172
+ outputColorSpace: this.renderer.outputColorSpace,
173
+ toneMapping: this.renderer.toneMapping,
174
+ toneMappingExposure: this.renderer.toneMappingExposure,
175
+ physicallyCorrectLights: this.renderer.physicallyCorrectLights,
176
+ useLegacyLights: this.renderer.useLegacyLights,
177
+ };
178
+
95
179
  // Сцена
96
180
  this.scene = new THREE.Scene();
97
181
  // Оверлей-сцена для секущих манипуляторов (без клиппинга)
@@ -113,10 +197,26 @@ export class Viewer {
113
197
  this.controls.maxDistance = 20;
114
198
 
115
199
  // Свет
116
- this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
200
+ const amb = new THREE.AmbientLight(0xffffff, 0.6);
201
+ this.scene.add(amb);
202
+ this.ambientLight = amb;
117
203
  const dir = new THREE.DirectionalLight(0xffffff, 0.8);
118
- dir.position.set(5, 5, 5);
204
+ dir.position.set(5, 5.9, 5);
205
+ // Тени у источника тоже включаются только через setShadowsEnabled
206
+ dir.castShadow = false;
207
+ dir.shadow.mapSize.set(2048, 2048);
208
+ dir.shadow.radius = this.shadowStyle.softness;
119
209
  this.scene.add(dir);
210
+ this.sunLight = dir;
211
+ this._sunBaseXZ = { x: dir.position.x, z: dir.position.z };
212
+
213
+ // Плоскость-приёмник теней (под моделью). Позицию/размер выставим, когда появится модель.
214
+ this.#ensureShadowReceiver();
215
+
216
+ // Применим дефолтные флаги после создания света/приёмника
217
+ this.setSunEnabled(true);
218
+ this.setSunHeight(5.9);
219
+ this.setShadowsEnabled(this.shadowsEnabled);
120
220
 
121
221
  // Демонстрационный куб отключён для чистого прелоадера
122
222
  // Оставим сцену пустой до загрузки модели
@@ -243,7 +343,12 @@ export class Viewer {
243
343
  this.#updateClippingGizmos();
244
344
  // Рендер основной сцены
245
345
  this.renderer.clear(true, true, true);
246
- this.renderer.render(this.scene, this.camera);
346
+ const useComposer = !!(this._composer && (this.visual?.ao?.enabled || this.visual?.color?.enabled));
347
+ if (useComposer) {
348
+ this._composer.render();
349
+ } else {
350
+ this.renderer.render(this.scene, this.camera);
351
+ }
247
352
  // Рендер оверлея манипуляторов без глобального клиппинга поверх
248
353
  const prevLocal = this.renderer.localClippingEnabled;
249
354
  const prevPlanes = this.renderer.clippingPlanes;
@@ -298,6 +403,14 @@ export class Viewer {
298
403
  const el = this.renderer.domElement;
299
404
  if (el && el.parentNode) el.parentNode.removeChild(el);
300
405
  }
406
+ if (this._composer) {
407
+ try { this._composer.dispose?.(); } catch (_) {}
408
+ this._composer = null;
409
+ this._renderPass = null;
410
+ this._ssaoPass = null;
411
+ this._hueSatPass = null;
412
+ this._bcPass = null;
413
+ }
301
414
  if (this.resizeObserver) {
302
415
  this.resizeObserver.disconnect();
303
416
  this.resizeObserver = null;
@@ -321,6 +434,12 @@ export class Viewer {
321
434
  }
322
435
  });
323
436
  }
437
+ if (this.shadowReceiver) {
438
+ try { this.scene?.remove(this.shadowReceiver); } catch (_) {}
439
+ try { this.shadowReceiver.geometry?.dispose?.(); } catch (_) {}
440
+ try { this.shadowReceiver.material?.dispose?.(); } catch (_) {}
441
+ this.shadowReceiver = null;
442
+ }
324
443
  if (this.sectionOverlayScene) {
325
444
  this.sectionOverlayScene.traverse((obj) => {
326
445
  if (obj.isMesh) {
@@ -400,10 +519,17 @@ export class Viewer {
400
519
  this.controls.minDistance = newMin;
401
520
  this.controls.maxDistance = newMax;
402
521
 
403
- // Расширим дальнюю плоскость, чтобы исключить клиппинг при большом отъезде
404
- const desiredFar = Math.max(this.camera.far, newMax * 4);
405
- if (desiredFar !== this.camera.far) {
406
- this.camera.far = desiredFar;
522
+ // Настройка near/far для стабильной глубины (уменьшает z-fighting на тонких/накладных деталях).
523
+ // Важно: far должен быть "как можно меньше", но достаточен для maxDistance.
524
+ // near не должен быть слишком маленьким относительно far.
525
+ const desiredNear = Math.max(0.05, fitDist * 0.001); // 0.1% от вписанной дистанции (но не меньше 0.05)
526
+ const desiredFar = Math.max(50, newMax * 4); // гарантированно покрываем maxDistance
527
+ // Защитимся от некорректного отношения near/far
528
+ const safeNear = Math.min(desiredNear, desiredFar / 1000);
529
+ const safeFar = Math.max(desiredFar, safeNear * 1000);
530
+ if (this.camera.near !== safeNear || this.camera.far !== safeFar) {
531
+ this.camera.near = safeNear;
532
+ this.camera.far = safeFar;
407
533
  this.camera.updateProjectionMatrix();
408
534
  }
409
535
 
@@ -461,6 +587,12 @@ export class Viewer {
461
587
  this.camera.updateProjectionMatrix();
462
588
  // Третий аргумент false — не менять стилевые размеры, только буфер
463
589
  this.renderer.setSize(width, height, false);
590
+ if (this._composer) {
591
+ try { this._composer.setSize(width, height); } catch (_) {}
592
+ }
593
+ if (this._ssaoPass?.setSize) {
594
+ try { this._ssaoPass.setSize(width, height); } catch (_) {}
595
+ }
464
596
  }
465
597
 
466
598
  _dispatchReady() {
@@ -488,17 +620,38 @@ export class Viewer {
488
620
  this.activeModel = object3D;
489
621
  this.scene.add(object3D);
490
622
 
623
+ // Пересчитать плоскость под моделью (3x по площади bbox по X/Z)
624
+ this.#updateShadowReceiverFromModel(object3D);
625
+
491
626
  // Подчеркнуть грани: полигон оффсет + контуры
492
627
  object3D.traverse?.((node) => {
493
628
  if (node.isMesh) {
629
+ // Тени управляются единообразно через setShadowsEnabled()
630
+ node.castShadow = !!this.shadowsEnabled;
631
+ // Самозатенение включается только в пресете "Тест"
632
+ node.receiveShadow = !!this._testPreset?.enabled;
633
+ // Стекло/прозрачность: рендерим после непрозрачных (уменьшает мерцание сортировки)
634
+ try {
635
+ const mats = Array.isArray(node.material) ? node.material : [node.material];
636
+ const anyTransparent = mats.some((m) => !!m && !!m.transparent && (Number(m.opacity ?? 1) < 0.999));
637
+ node.renderOrder = anyTransparent ? 10 : 0;
638
+ } catch (_) {}
494
639
  this.#applyPolygonOffsetToMesh(node, this.flatShading);
495
640
  this.#attachEdgesToMesh(node, this.edgesVisible);
496
641
  }
497
642
  });
498
643
 
644
+ // Материальный пресет (если выбран не original)
645
+ this.#applyMaterialStyleToModel(object3D);
646
+
499
647
  // Настроим пределы зума и сфокусируемся на новой модели
500
648
  this.applyAdaptiveZoomLimits(object3D, { padding: 1.2, slack: 2.5, minRatio: 0.05, recenter: true });
501
649
 
650
+ // Если "Тест" активен, сразу применим его к только что загруженной модели (самозатенение + shadow camera по bbox)
651
+ if (this._testPreset?.enabled) {
652
+ try { this.#applyTestPresetToScene(); } catch (_) {}
653
+ }
654
+
502
655
  // На следующем кадре отъедем на 2x от вписанной дистанции (точно по размеру модели)
503
656
  try {
504
657
  const box = new THREE.Box3().setFromObject(object3D);
@@ -522,9 +675,9 @@ export class Viewer {
522
675
  const verticalBias = size.y * 0.30; // 30% высоты
523
676
  this.controls.target.y = center.y - verticalBias;
524
677
  } catch(_) {}
525
- if (this.camera.far < 1000) {
526
- this.camera.far = 1000;
527
- }
678
+ // После ручной перестановки камеры ещё раз "оздоровим" near/far под модель,
679
+ // чтобы не ловить z-fighting на фасадных накладках.
680
+ try { this.applyAdaptiveZoomLimits(object3D, { padding: 1.2, slack: 2.5, minRatio: 0.05, recenter: false }); } catch (_) {}
528
681
  this.camera.updateProjectionMatrix();
529
682
  this.controls.update();
530
683
 
@@ -582,6 +735,157 @@ export class Viewer {
582
735
  else apply(mesh.material);
583
736
  }
584
737
 
738
+ #ensureShadowReceiver() {
739
+ if (!this.scene || this.shadowReceiver) return;
740
+ // ShadowMaterial рисует только тени, сама плоскость прозрачная.
741
+ // Если тени отключены — визуально ничего не изменится.
742
+ const mat = new THREE.ShadowMaterial({ opacity: this.shadowStyle.opacity });
743
+ // Градиент тени: модифицируем шейдер только приёмника (не влияет на остальные материалы)
744
+ mat.onBeforeCompile = (shader) => {
745
+ // uniforms
746
+ shader.uniforms.uShadowGradEnabled = { value: this.shadowGradient.enabled ? 1.0 : 0.0 };
747
+ shader.uniforms.uShadowGradLength = { value: this.shadowGradient.length };
748
+ shader.uniforms.uShadowGradStrength = { value: this.shadowGradient.strength };
749
+ shader.uniforms.uShadowGradCurve = { value: this.shadowGradient.curve };
750
+ shader.uniforms.uBuildingCenterXZ = { value: this.shadowGradient.buildingCenterXZ.clone() };
751
+ shader.uniforms.uBuildingHalfSizeXZ = { value: this.shadowGradient.buildingHalfSizeXZ.clone() };
752
+
753
+ // сохраняем ссылку для последующих обновлений
754
+ this.shadowGradient._shader = shader;
755
+
756
+ // varying world position
757
+ if (!shader.vertexShader.includes('varying vec3 vWorldPosition')) {
758
+ shader.vertexShader = shader.vertexShader.replace(
759
+ '#include <common>',
760
+ '#include <common>\nvarying vec3 vWorldPosition;'
761
+ );
762
+ }
763
+ // worldpos_vertex в three определяет worldPosition; используем его
764
+ shader.vertexShader = shader.vertexShader.replace(
765
+ '#include <worldpos_vertex>',
766
+ '#include <worldpos_vertex>\n vWorldPosition = worldPosition.xyz;'
767
+ );
768
+
769
+ if (!shader.fragmentShader.includes('varying vec3 vWorldPosition')) {
770
+ shader.fragmentShader = shader.fragmentShader.replace(
771
+ '#include <common>',
772
+ '#include <common>\nvarying vec3 vWorldPosition;\n' +
773
+ 'uniform float uShadowGradEnabled;\n' +
774
+ 'uniform float uShadowGradLength;\n' +
775
+ 'uniform float uShadowGradStrength;\n' +
776
+ 'uniform float uShadowGradCurve;\n' +
777
+ 'uniform vec2 uBuildingCenterXZ;\n' +
778
+ 'uniform vec2 uBuildingHalfSizeXZ;\n' +
779
+ 'float distToRect(vec2 p, vec2 c, vec2 h) {\n' +
780
+ ' vec2 d = abs(p - c) - h;\n' +
781
+ ' return length(max(d, 0.0));\n' +
782
+ '}\n'
783
+ + 'float computeShadowGrad(vec3 worldPos) {\n' +
784
+ ' if (uShadowGradEnabled <= 0.5) return 1.0;\n' +
785
+ ' float d = distToRect(worldPos.xz, uBuildingCenterXZ, uBuildingHalfSizeXZ);\n' +
786
+ ' float t = clamp(d / max(1e-6, uShadowGradLength), 0.0, 1.0);\n' +
787
+ ' float fade = smoothstep(0.0, 1.0, t);\n' +
788
+ ' fade = pow(fade, max(0.05, uShadowGradCurve));\n' +
789
+ ' return 1.0 - clamp(uShadowGradStrength, 0.0, 1.0) * fade;\n' +
790
+ '}\n'
791
+ );
792
+ }
793
+
794
+ let injected = false;
795
+ if (shader.fragmentShader.includes('#include <dithering_fragment>')) {
796
+ shader.fragmentShader = shader.fragmentShader.replace(
797
+ '#include <dithering_fragment>',
798
+ 'gl_FragColor.a *= computeShadowGrad(vWorldPosition);\n#include <dithering_fragment>'
799
+ );
800
+ injected = true;
801
+ } else if (shader.fragmentShader.includes('#include <fog_fragment>')) {
802
+ shader.fragmentShader = shader.fragmentShader.replace(
803
+ '#include <fog_fragment>',
804
+ 'gl_FragColor.a *= computeShadowGrad(vWorldPosition);\n#include <fog_fragment>'
805
+ );
806
+ injected = true;
807
+ } else {
808
+ // Фолбэк: домножаем перед последней закрывающей скобкой файла
809
+ const before = shader.fragmentShader;
810
+ shader.fragmentShader = shader.fragmentShader.replace(/\}\s*$/, ' gl_FragColor.a *= computeShadowGrad(vWorldPosition);\n}');
811
+ injected = before !== shader.fragmentShader;
812
+ }
813
+
814
+ // Диагностика (в консоль только если не удалось встроиться ожидаемым способом)
815
+ if (!injected) {
816
+ console.warn('[shadowReceiverGradient] Injection failed: no insertion point found');
817
+ }
818
+ };
819
+ // стабильный ключ для кеша программы (чтобы onBeforeCompile применялся предсказуемо)
820
+ mat.customProgramCacheKey = () => 'shadowReceiverGradient-v1';
821
+
822
+ const geo = new THREE.PlaneGeometry(1, 1, 1, 1);
823
+ const plane = new THREE.Mesh(geo, mat);
824
+ plane.name = "shadow-receiver";
825
+ plane.rotation.x = -Math.PI / 2;
826
+ plane.receiveShadow = !!this.shadowsEnabled;
827
+ plane.castShadow = false;
828
+ plane.visible = !!this.shadowsEnabled;
829
+ // Чуть выше, чтобы избежать z-fighting с "нулевым" уровнем
830
+ plane.position.set(0, -9999, 0); // спрячем до первого апдейта по модели
831
+ this.scene.add(plane);
832
+ this.shadowReceiver = plane;
833
+ }
834
+
835
+ #updateShadowReceiverFromModel(model) {
836
+ if (!model) return;
837
+ this.#ensureShadowReceiver();
838
+ if (!this.shadowReceiver) return;
839
+ try {
840
+ const box = new THREE.Box3().setFromObject(model);
841
+ const size = box.getSize(new THREE.Vector3());
842
+ const center = box.getCenter(new THREE.Vector3());
843
+ const minY = box.min.y;
844
+
845
+ // Требование: площадь плоскости = 3x площади объекта (bbox по X/Z).
846
+ // => множитель по размерам = sqrt(3).
847
+ const areaMultiplier = 3;
848
+ const dimMul = Math.sqrt(areaMultiplier);
849
+
850
+ this.shadowReceiver.position.set(center.x, minY + 0.001, center.z);
851
+ this.shadowReceiver.scale.set(Math.max(0.001, size.x * dimMul), Math.max(0.001, size.z * dimMul), 1);
852
+ this.shadowReceiver.updateMatrixWorld();
853
+
854
+ // Обновим bbox здания для градиента тени (в XZ)
855
+ this.shadowGradient.buildingCenterXZ.set(center.x, center.z);
856
+ this.shadowGradient.buildingHalfSizeXZ.set(Math.max(0.001, size.x / 2), Math.max(0.001, size.z / 2));
857
+ // Важно: длину градиента НЕ автокорректируем по размеру здания.
858
+ // Она задаётся пользователем/дефолтами через setShadowGradientLength().
859
+ this.#applyShadowGradientUniforms();
860
+
861
+ // Подгоняем shadow-camera направленного света под габариты плоскости,
862
+ // чтобы при включении теней они не "обрезались" слишком маленькой областью.
863
+ if (this.sunLight) {
864
+ const cam = this.sunLight.shadow.camera;
865
+ const halfX = (size.x * dimMul) / 2;
866
+ const halfZ = (size.z * dimMul) / 2;
867
+ cam.left = -halfX;
868
+ cam.right = halfX;
869
+ cam.top = halfZ;
870
+ cam.bottom = -halfZ;
871
+ cam.near = 0.1;
872
+ cam.far = Math.max(50, size.y * 6);
873
+ cam.updateProjectionMatrix();
874
+ }
875
+ } catch (_) {}
876
+ }
877
+
878
+ #applyShadowGradientUniforms() {
879
+ const shader = this.shadowGradient?._shader;
880
+ if (!shader) return;
881
+ if (shader.uniforms?.uShadowGradEnabled) shader.uniforms.uShadowGradEnabled.value = this.shadowGradient.enabled ? 1.0 : 0.0;
882
+ if (shader.uniforms?.uShadowGradLength) shader.uniforms.uShadowGradLength.value = this.shadowGradient.length;
883
+ if (shader.uniforms?.uShadowGradStrength) shader.uniforms.uShadowGradStrength.value = this.shadowGradient.strength;
884
+ if (shader.uniforms?.uShadowGradCurve) shader.uniforms.uShadowGradCurve.value = this.shadowGradient.curve;
885
+ if (shader.uniforms?.uBuildingCenterXZ) shader.uniforms.uBuildingCenterXZ.value.copy(this.shadowGradient.buildingCenterXZ);
886
+ if (shader.uniforms?.uBuildingHalfSizeXZ) shader.uniforms.uBuildingHalfSizeXZ.value.copy(this.shadowGradient.buildingHalfSizeXZ);
887
+ }
888
+
585
889
  #attachEdgesToMesh(mesh, visible) {
586
890
  if (!mesh.geometry) return;
587
891
  // Не дублировать
@@ -628,20 +932,1166 @@ export class Viewer {
628
932
  // Настройки рендера
629
933
  if (preset === 'low') {
630
934
  this.renderer.setPixelRatio(1);
631
- this.renderer.shadowMap.enabled = false;
935
+ this.renderer.shadowMap.enabled = !!this.shadowsEnabled;
632
936
  this.controls.enableDamping = false;
633
937
  } else if (preset === 'high') {
634
938
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
635
- this.renderer.shadowMap.enabled = false;
939
+ this.renderer.shadowMap.enabled = !!this.shadowsEnabled;
636
940
  this.controls.enableDamping = true;
637
941
  } else {
638
942
  // medium
639
943
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5));
640
- this.renderer.shadowMap.enabled = false;
944
+ this.renderer.shadowMap.enabled = !!this.shadowsEnabled;
641
945
  this.controls.enableDamping = true;
642
946
  }
643
947
  }
644
948
 
949
+ /**
950
+ * Режим "Realtime-quality": применяет рекомендованные настройки рендера/постпроцесса
951
+ * и может восстановить прежние, когда режим выключают.
952
+ * @param {boolean} enabled
953
+ */
954
+ setRealtimeQualityEnabled(enabled) {
955
+ const next = !!enabled;
956
+ if (next === this._rtQuality.enabled) return;
957
+
958
+ if (next) {
959
+ // Снимок состояния для восстановления
960
+ this._rtQuality.snapshot = {
961
+ quality: this.quality,
962
+ shadowsEnabled: this.shadowsEnabled,
963
+ shadowOpacity: this.shadowStyle.opacity,
964
+ shadowSoftness: this.shadowStyle.softness,
965
+ sunEnabled: !!(this.sunLight && this.sunLight.visible),
966
+ sunHeight: this.sunLight ? this.sunLight.position.y : null,
967
+ visual: JSON.parse(JSON.stringify(this.visual)),
968
+ materialStyle: { ...this.materialStyle },
969
+ renderer: this.renderer ? {
970
+ physicallyCorrectLights: this.renderer.physicallyCorrectLights,
971
+ useLegacyLights: this.renderer.useLegacyLights,
972
+ } : null,
973
+ };
974
+
975
+ // Рекомендованный пресет (баланс "красиво" / "стабильно")
976
+ this.setQuality('high');
977
+ this.setSunEnabled(true);
978
+ this.setShadowsEnabled(true);
979
+ this.setShadowSoftness(2.0);
980
+ this.setShadowOpacity(0.18);
981
+
982
+ // Материалы: убираем "металлические" блики, делаем архитектурно-матовый вид
983
+ this.setMaterialPreset('matte');
984
+ this.setMaterialRoughness(0.92);
985
+ this.setMaterialMetalness(0.0);
986
+
987
+ // Окружение: оставляем, но значительно слабее (иначе даёт резкие блики на фасаде)
988
+ this.setEnvironmentEnabled(true);
989
+ this.setEnvironmentIntensity(0.55);
990
+
991
+ this.setToneMappingEnabled(true);
992
+ this.setExposure(1.0);
993
+
994
+ this.setAOEnabled(true);
995
+ // AO чуть мягче, чтобы не давал "грязь"/мыло на расстоянии
996
+ this.setAOIntensity(0.45);
997
+ this.setAORadius(10);
998
+
999
+ // Цветокор — обычно спорная, выключаем в пресете
1000
+ this.setColorCorrectionEnabled(false);
1001
+ this.setColorHue(0.0);
1002
+ this.setColorSaturation(0.0);
1003
+ this.setColorBrightness(0.0);
1004
+ this.setColorContrast(0.0);
1005
+
1006
+ // Физически корректный свет (если доступно в этой версии three)
1007
+ if (this.renderer) {
1008
+ try { this.renderer.physicallyCorrectLights = true; } catch (_) {}
1009
+ try { this.renderer.useLegacyLights = false; } catch (_) {}
1010
+ }
1011
+
1012
+ this._rtQuality.enabled = true;
1013
+ return;
1014
+ }
1015
+
1016
+ // Выключаем: восстанавливаем снимок
1017
+ const snap = this._rtQuality.snapshot;
1018
+ this._rtQuality.enabled = false;
1019
+ this._rtQuality.snapshot = null;
1020
+ if (!snap) return;
1021
+
1022
+ // Порядок важен: сначала базовые тумблеры, потом параметры
1023
+ this.setQuality(snap.quality || 'medium');
1024
+ this.setSunEnabled(!!snap.sunEnabled);
1025
+ if (typeof snap.sunHeight === 'number') this.setSunHeight(snap.sunHeight);
1026
+
1027
+ this.setShadowsEnabled(!!snap.shadowsEnabled);
1028
+ if (typeof snap.shadowSoftness === 'number') this.setShadowSoftness(snap.shadowSoftness);
1029
+ if (typeof snap.shadowOpacity === 'number') this.setShadowOpacity(snap.shadowOpacity);
1030
+
1031
+ // Визуал
1032
+ try {
1033
+ // environment
1034
+ this.setEnvironmentEnabled(!!snap.visual?.environment?.enabled);
1035
+ if (typeof snap.visual?.environment?.intensity === 'number') this.setEnvironmentIntensity(snap.visual.environment.intensity);
1036
+ // tone
1037
+ this.setToneMappingEnabled(!!snap.visual?.tone?.enabled);
1038
+ if (typeof snap.visual?.tone?.exposure === 'number') this.setExposure(snap.visual.tone.exposure);
1039
+ // AO
1040
+ this.setAOEnabled(!!snap.visual?.ao?.enabled);
1041
+ if (typeof snap.visual?.ao?.intensity === 'number') this.setAOIntensity(snap.visual.ao.intensity);
1042
+ if (typeof snap.visual?.ao?.radius === 'number') this.setAORadius(snap.visual.ao.radius);
1043
+ // color
1044
+ this.setColorCorrectionEnabled(!!snap.visual?.color?.enabled);
1045
+ if (typeof snap.visual?.color?.hue === 'number') this.setColorHue(snap.visual.color.hue);
1046
+ if (typeof snap.visual?.color?.saturation === 'number') this.setColorSaturation(snap.visual.color.saturation);
1047
+ if (typeof snap.visual?.color?.brightness === 'number') this.setColorBrightness(snap.visual.color.brightness);
1048
+ if (typeof snap.visual?.color?.contrast === 'number') this.setColorContrast(snap.visual.color.contrast);
1049
+ } catch (_) {}
1050
+
1051
+ // Материалы (если пользователь успел переключить пресет до включения)
1052
+ try {
1053
+ if (snap.materialStyle?.preset) this.setMaterialPreset(snap.materialStyle.preset);
1054
+ this.setMaterialRoughness(snap.materialStyle?.roughness ?? null);
1055
+ this.setMaterialMetalness(snap.materialStyle?.metalness ?? null);
1056
+ } catch (_) {}
1057
+
1058
+ // Рендерер флаги света
1059
+ if (this.renderer && snap.renderer) {
1060
+ try { this.renderer.physicallyCorrectLights = snap.renderer.physicallyCorrectLights; } catch (_) {}
1061
+ try { this.renderer.useLegacyLights = snap.renderer.useLegacyLights; } catch (_) {}
1062
+ }
1063
+ }
1064
+
1065
+ /**
1066
+ * Включить/выключить тени.
1067
+ * Управляет shadowMap, castShadow/receiveShadow и видимостью приёмника.
1068
+ * @param {boolean} enabled
1069
+ */
1070
+ setShadowsEnabled(enabled) {
1071
+ const next = !!enabled;
1072
+ this.shadowsEnabled = next;
1073
+
1074
+ if (this.renderer) {
1075
+ this.renderer.shadowMap.enabled = next;
1076
+ }
1077
+ if (this.sunLight) {
1078
+ this.sunLight.castShadow = next;
1079
+ this.sunLight.shadow.radius = this.shadowStyle.softness;
1080
+ }
1081
+ if (this.shadowReceiver) {
1082
+ this.shadowReceiver.visible = next;
1083
+ this.shadowReceiver.receiveShadow = next;
1084
+ // Прозрачность тени на земле
1085
+ if (this.shadowReceiver.material && 'opacity' in this.shadowReceiver.material) {
1086
+ this.shadowReceiver.material.opacity = this.shadowStyle.opacity;
1087
+ this.shadowReceiver.material.needsUpdate = true;
1088
+ }
1089
+ }
1090
+ this.#applyShadowGradientUniforms();
1091
+ if (this.activeModel) {
1092
+ this.activeModel.traverse?.((node) => {
1093
+ if (!node?.isMesh) return;
1094
+ node.castShadow = next;
1095
+ // Самозатенение включается только в пресете "Тест"
1096
+ node.receiveShadow = !!this._testPreset?.enabled;
1097
+ });
1098
+ }
1099
+ }
1100
+
1101
+ /**
1102
+ * Пресет "Тест": полностью изолированная настройка теней/визуала из рекомендаций.
1103
+ * При включении: переопределяет renderer/sun/AO/tone/env/materialPreset и включает самозатенение модели.
1104
+ * При выключении: восстанавливает предыдущее состояние.
1105
+ * @param {boolean} enabled
1106
+ */
1107
+ setTestPresetEnabled(enabled) {
1108
+ const next = !!enabled;
1109
+ if (next === this._testPreset.enabled) return;
1110
+
1111
+ if (next) {
1112
+ // Снимок состояния для восстановления (минимально необходимое для независимости теста)
1113
+ this._testPreset.snapshot = {
1114
+ quality: this.quality,
1115
+ edgesVisible: this.edgesVisible,
1116
+ flatShading: this.flatShading,
1117
+ shadowsEnabled: this.shadowsEnabled,
1118
+ shadowOpacity: this.shadowStyle.opacity,
1119
+ shadowSoftness: this.shadowStyle.softness,
1120
+ shadowGradient: {
1121
+ enabled: this.shadowGradient.enabled,
1122
+ length: this.shadowGradient.length,
1123
+ strength: this.shadowGradient.strength,
1124
+ curve: this.shadowGradient.curve,
1125
+ },
1126
+ sun: this.sunLight ? {
1127
+ visible: this.sunLight.visible,
1128
+ intensity: this.sunLight.intensity,
1129
+ position: this.sunLight.position.clone(),
1130
+ castShadow: this.sunLight.castShadow,
1131
+ shadow: {
1132
+ mapSize: this.sunLight.shadow?.mapSize?.clone?.() || null,
1133
+ bias: this.sunLight.shadow?.bias ?? null,
1134
+ normalBias: this.sunLight.shadow?.normalBias ?? null,
1135
+ radius: this.sunLight.shadow?.radius ?? null,
1136
+ camera: this.sunLight.shadow?.camera ? {
1137
+ left: this.sunLight.shadow.camera.left,
1138
+ right: this.sunLight.shadow.camera.right,
1139
+ top: this.sunLight.shadow.camera.top,
1140
+ bottom: this.sunLight.shadow.camera.bottom,
1141
+ near: this.sunLight.shadow.camera.near,
1142
+ far: this.sunLight.shadow.camera.far,
1143
+ } : null,
1144
+ },
1145
+ } : null,
1146
+ ambient: this.ambientLight ? {
1147
+ visible: this.ambientLight.visible,
1148
+ intensity: this.ambientLight.intensity,
1149
+ } : null,
1150
+ visual: JSON.parse(JSON.stringify(this.visual)),
1151
+ materialStyle: { ...this.materialStyle },
1152
+ renderer: this.renderer ? {
1153
+ shadowMapEnabled: this.renderer.shadowMap?.enabled,
1154
+ shadowMapType: this.renderer.shadowMap?.type,
1155
+ outputEncoding: this.renderer.outputEncoding,
1156
+ outputColorSpace: this.renderer.outputColorSpace,
1157
+ toneMapping: this.renderer.toneMapping,
1158
+ toneMappingExposure: this.renderer.toneMappingExposure,
1159
+ physicallyCorrectLights: this.renderer.physicallyCorrectLights,
1160
+ useLegacyLights: this.renderer.useLegacyLights,
1161
+ } : null,
1162
+ };
1163
+
1164
+ this._testPreset.enabled = true;
1165
+
1166
+ // Применяем "Тест" (из рекомендаций)
1167
+ this.#applyTestPresetToScene();
1168
+ this.dumpTestPresetDebug();
1169
+ return;
1170
+ }
1171
+
1172
+ // Выключаем: восстановление
1173
+ const snap = this._testPreset.snapshot;
1174
+ this._testPreset.enabled = false;
1175
+ this._testPreset.snapshot = null;
1176
+ if (!snap) return;
1177
+
1178
+ // Порядок восстановления важен: базовые флаги → рендерер → свет → тени/модель → визуал
1179
+ try { this.setQuality(snap.quality || 'medium'); } catch (_) {}
1180
+ try { this.setEdgesVisible(!!snap.edgesVisible); } catch (_) {}
1181
+ try { this.setFlatShading(!!snap.flatShading); } catch (_) {}
1182
+
1183
+ // renderer
1184
+ if (this.renderer && snap.renderer) {
1185
+ try { this.renderer.shadowMap.enabled = !!snap.renderer.shadowMapEnabled; } catch (_) {}
1186
+ try { if (snap.renderer.shadowMapType != null) this.renderer.shadowMap.type = snap.renderer.shadowMapType; } catch (_) {}
1187
+ try { if ('outputColorSpace' in this.renderer) this.renderer.outputColorSpace = snap.renderer.outputColorSpace; } catch (_) {}
1188
+ try { if ('outputEncoding' in this.renderer) this.renderer.outputEncoding = snap.renderer.outputEncoding; } catch (_) {}
1189
+ try { this.renderer.toneMapping = snap.renderer.toneMapping; } catch (_) {}
1190
+ try { this.renderer.toneMappingExposure = snap.renderer.toneMappingExposure; } catch (_) {}
1191
+ try { this.renderer.physicallyCorrectLights = snap.renderer.physicallyCorrectLights; } catch (_) {}
1192
+ try { this.renderer.useLegacyLights = snap.renderer.useLegacyLights; } catch (_) {}
1193
+ }
1194
+
1195
+ // visual (env/tone/ao/color) — через публичные сеттеры
1196
+ try {
1197
+ this.setEnvironmentEnabled(!!snap.visual?.environment?.enabled);
1198
+ this.setEnvironmentIntensity(snap.visual?.environment?.intensity ?? 1.0);
1199
+ this.setToneMappingEnabled(!!snap.visual?.tone?.enabled);
1200
+ this.setExposure(snap.visual?.tone?.exposure ?? 1.0);
1201
+ this.setAOEnabled(!!snap.visual?.ao?.enabled);
1202
+ this.setAOIntensity(snap.visual?.ao?.intensity ?? 0.75);
1203
+ this.setAORadius(snap.visual?.ao?.radius ?? 12);
1204
+ this.setColorCorrectionEnabled(!!snap.visual?.color?.enabled);
1205
+ this.setColorHue(snap.visual?.color?.hue ?? 0.0);
1206
+ this.setColorSaturation(snap.visual?.color?.saturation ?? 0.0);
1207
+ this.setColorBrightness(snap.visual?.color?.brightness ?? 0.0);
1208
+ this.setColorContrast(snap.visual?.color?.contrast ?? 0.0);
1209
+ } catch (_) {}
1210
+
1211
+ // materials: вернём как было
1212
+ try {
1213
+ if (snap.materialStyle?.preset) this.setMaterialPreset(snap.materialStyle.preset);
1214
+ this.setMaterialRoughness(snap.materialStyle?.roughness ?? null);
1215
+ this.setMaterialMetalness(snap.materialStyle?.metalness ?? null);
1216
+ } catch (_) {}
1217
+
1218
+ // sun/ambient
1219
+ if (this.sunLight && snap.sun) {
1220
+ try { this.sunLight.visible = !!snap.sun.visible; } catch (_) {}
1221
+ try { this.sunLight.intensity = snap.sun.intensity; } catch (_) {}
1222
+ try { this.sunLight.position.copy(snap.sun.position); } catch (_) {}
1223
+ try { this._sunBaseXZ = { x: this.sunLight.position.x, z: this.sunLight.position.z }; } catch (_) {}
1224
+ try { this.sunLight.castShadow = !!snap.sun.castShadow; } catch (_) {}
1225
+ try {
1226
+ if (snap.sun.shadow?.mapSize && this.sunLight.shadow?.mapSize) this.sunLight.shadow.mapSize.copy(snap.sun.shadow.mapSize);
1227
+ if (snap.sun.shadow?.bias != null) this.sunLight.shadow.bias = snap.sun.shadow.bias;
1228
+ if (snap.sun.shadow?.normalBias != null) this.sunLight.shadow.normalBias = snap.sun.shadow.normalBias;
1229
+ if (snap.sun.shadow?.radius != null) this.sunLight.shadow.radius = snap.sun.shadow.radius;
1230
+ if (snap.sun.shadow?.camera && this.sunLight.shadow?.camera) {
1231
+ const c = snap.sun.shadow.camera;
1232
+ this.sunLight.shadow.camera.left = c.left;
1233
+ this.sunLight.shadow.camera.right = c.right;
1234
+ this.sunLight.shadow.camera.top = c.top;
1235
+ this.sunLight.shadow.camera.bottom = c.bottom;
1236
+ this.sunLight.shadow.camera.near = c.near;
1237
+ this.sunLight.shadow.camera.far = c.far;
1238
+ this.sunLight.shadow.camera.updateProjectionMatrix();
1239
+ }
1240
+ } catch (_) {}
1241
+ }
1242
+ if (this.ambientLight && snap.ambient) {
1243
+ try { this.ambientLight.visible = !!snap.ambient.visible; } catch (_) {}
1244
+ try { this.ambientLight.intensity = snap.ambient.intensity; } catch (_) {}
1245
+ }
1246
+
1247
+ // shadows & receiver style
1248
+ try {
1249
+ this.setShadowOpacity(snap.shadowOpacity);
1250
+ this.setShadowSoftness(snap.shadowSoftness);
1251
+ this.setShadowGradientEnabled(!!snap.shadowGradient?.enabled);
1252
+ this.setShadowGradientLength(snap.shadowGradient?.length ?? this.shadowGradient.length);
1253
+ this.setShadowGradientStrength(snap.shadowGradient?.strength ?? this.shadowGradient.strength);
1254
+ this.setShadowGradientCurve(snap.shadowGradient?.curve ?? this.shadowGradient.curve);
1255
+ this.setShadowsEnabled(!!snap.shadowsEnabled);
1256
+ } catch (_) {}
1257
+
1258
+ // Восстановим самозатенение как было до теста (по текущей логике viewer: receiveShadow=false)
1259
+ if (this.activeModel) {
1260
+ this.activeModel.traverse?.((node) => {
1261
+ if (!node?.isMesh) return;
1262
+ node.castShadow = !!this.shadowsEnabled;
1263
+ node.receiveShadow = false;
1264
+ });
1265
+ }
1266
+ }
1267
+
1268
+ /**
1269
+ * Дамп текущих параметров тест-пресета в консоль.
1270
+ */
1271
+ dumpTestPresetDebug() {
1272
+ if (!this._testPreset?.enabled) return;
1273
+ const r = this.renderer;
1274
+ const sun = this.sunLight;
1275
+ const cam = sun?.shadow?.camera;
1276
+ const model = this.activeModel;
1277
+ const receiver = this.shadowReceiver;
1278
+ let meshCount = 0;
1279
+ let castOn = 0;
1280
+ let recvOn = 0;
1281
+ const matTypes = new Map();
1282
+ const sampleMats = [];
1283
+ model?.traverse?.((n) => {
1284
+ if (!n?.isMesh) return;
1285
+ meshCount++;
1286
+ if (n.castShadow) castOn++;
1287
+ if (n.receiveShadow) recvOn++;
1288
+ // Материалы: статистика по типам
1289
+ const m = n.material;
1290
+ const arr = Array.isArray(m) ? m : [m];
1291
+ for (const mi of arr) {
1292
+ if (!mi) continue;
1293
+ const t = mi.type || 'UnknownMaterial';
1294
+ matTypes.set(t, (matTypes.get(t) || 0) + 1);
1295
+ // Возьмём несколько первых материалов как сэмпл (для свойств, которые могут влиять на тени)
1296
+ if (sampleMats.length < 6) sampleMats.push(mi);
1297
+ }
1298
+ });
1299
+
1300
+ // Геометрия/габариты модели (bbox)
1301
+ let bbox = null;
1302
+ try {
1303
+ if (model) {
1304
+ const box = new THREE.Box3().setFromObject(model);
1305
+ const size = box.getSize(new THREE.Vector3());
1306
+ const center = box.getCenter(new THREE.Vector3());
1307
+ bbox = {
1308
+ min: { x: box.min.x, y: box.min.y, z: box.min.z },
1309
+ max: { x: box.max.x, y: box.max.y, z: box.max.z },
1310
+ size: { x: size.x, y: size.y, z: size.z },
1311
+ center: { x: center.x, y: center.y, z: center.z },
1312
+ };
1313
+ }
1314
+ } catch (_) {
1315
+ bbox = null;
1316
+ }
1317
+
1318
+ // Наличие shadow map (может появиться только после первого рендера)
1319
+ const getShadowMapInfo = () => {
1320
+ try {
1321
+ const map = sun?.shadow?.map;
1322
+ const tex = map?.texture;
1323
+ const img = tex?.image;
1324
+ return {
1325
+ hasMap: !!map,
1326
+ type: map?.type || null,
1327
+ tex: tex ? { format: tex.format, type: tex.type, colorSpace: tex.colorSpace } : null,
1328
+ image: img ? { width: img.width, height: img.height } : null,
1329
+ };
1330
+ } catch (_) {
1331
+ return { hasMap: false };
1332
+ }
1333
+ };
1334
+
1335
+ const getShadowReceiverInfo = () => {
1336
+ try {
1337
+ if (!receiver) return null;
1338
+ const mat = receiver.material;
1339
+ return {
1340
+ visible: !!receiver.visible,
1341
+ receiveShadow: !!receiver.receiveShadow,
1342
+ position: { x: receiver.position.x, y: receiver.position.y, z: receiver.position.z },
1343
+ scale: { x: receiver.scale.x, y: receiver.scale.y, z: receiver.scale.z },
1344
+ material: mat ? { type: mat.type, opacity: mat.opacity } : null,
1345
+ };
1346
+ } catch (_) {
1347
+ return null;
1348
+ }
1349
+ };
1350
+
1351
+ const getSunTargetInfo = () => {
1352
+ try {
1353
+ const t = sun?.target;
1354
+ if (!t) return null;
1355
+ return {
1356
+ inScene: !!t.parent,
1357
+ position: { x: t.position.x, y: t.position.y, z: t.position.z },
1358
+ };
1359
+ } catch (_) {
1360
+ return null;
1361
+ }
1362
+ };
1363
+
1364
+ // eslint-disable-next-line no-console
1365
+ console.groupCollapsed('[Viewer][TestPreset] dump');
1366
+ // eslint-disable-next-line no-console
1367
+ console.log('renderer.shadowMap', { enabled: r?.shadowMap?.enabled, type: r?.shadowMap?.type });
1368
+ // eslint-disable-next-line no-console
1369
+ console.log('renderer.color', { outputColorSpace: r?.outputColorSpace, outputEncoding: r?.outputEncoding, toneMapping: r?.toneMapping, exposure: r?.toneMappingExposure });
1370
+ // eslint-disable-next-line no-console
1371
+ console.log('sun', {
1372
+ intensity: sun?.intensity,
1373
+ position: sun ? { x: sun.position.x, y: sun.position.y, z: sun.position.z } : null,
1374
+ mapSize: sun?.shadow?.mapSize ? { x: sun.shadow.mapSize.x, y: sun.shadow.mapSize.y } : null,
1375
+ bias: sun?.shadow?.bias,
1376
+ normalBias: sun?.shadow?.normalBias,
1377
+ radius: sun?.shadow?.radius,
1378
+ });
1379
+ // eslint-disable-next-line no-console
1380
+ console.log('sun.target', getSunTargetInfo());
1381
+ // eslint-disable-next-line no-console
1382
+ console.log('sun.shadow.camera', cam ? { left: cam.left, right: cam.right, top: cam.top, bottom: cam.bottom, near: cam.near, far: cam.far } : null);
1383
+ // eslint-disable-next-line no-console
1384
+ console.log('sun.shadow.map (now)', getShadowMapInfo());
1385
+ // eslint-disable-next-line no-console
1386
+ console.log('modelMeshes', { meshCount, castOn, recvOn });
1387
+ // eslint-disable-next-line no-console
1388
+ console.log('model.bbox', bbox);
1389
+ // eslint-disable-next-line no-console
1390
+ console.log('shadowReceiver', getShadowReceiverInfo());
1391
+ // eslint-disable-next-line no-console
1392
+ console.log('materials.types', Object.fromEntries(matTypes.entries()));
1393
+ // eslint-disable-next-line no-console
1394
+ console.log('materials.sample', sampleMats.map((m) => ({
1395
+ type: m?.type,
1396
+ transparent: !!m?.transparent,
1397
+ opacity: (m && 'opacity' in m) ? m.opacity : undefined,
1398
+ depthWrite: (m && 'depthWrite' in m) ? m.depthWrite : undefined,
1399
+ depthTest: (m && 'depthTest' in m) ? m.depthTest : undefined,
1400
+ side: (m && 'side' in m) ? m.side : undefined,
1401
+ color: m?.color ? `#${m.color.getHexString?.()}` : undefined,
1402
+ })));
1403
+ // eslint-disable-next-line no-console
1404
+ console.log('visual', { environment: this.visual.environment, tone: this.visual.tone, ao: this.visual.ao });
1405
+ // eslint-disable-next-line no-console
1406
+ console.log('renderer.info', r?.info ? { memory: r.info.memory, programs: r.info.programs?.length } : null);
1407
+ // eslint-disable-next-line no-console
1408
+ console.groupEnd();
1409
+
1410
+ // Post-frame: проверим, появилась ли shadow map после реального рендера
1411
+ // (без повторов — логируем только при активном тесте)
1412
+ try {
1413
+ requestAnimationFrame(() => {
1414
+ if (!this._testPreset?.enabled) return;
1415
+ // eslint-disable-next-line no-console
1416
+ console.groupCollapsed('[Viewer][TestPreset] post-frame shadow map');
1417
+ // eslint-disable-next-line no-console
1418
+ console.log('sun.shadow.map (raf1)', getShadowMapInfo());
1419
+ // eslint-disable-next-line no-console
1420
+ console.groupEnd();
1421
+ requestAnimationFrame(() => {
1422
+ if (!this._testPreset?.enabled) return;
1423
+ // eslint-disable-next-line no-console
1424
+ console.groupCollapsed('[Viewer][TestPreset] post-frame shadow map (raf2)');
1425
+ // eslint-disable-next-line no-console
1426
+ console.log('sun.shadow.map (raf2)', getShadowMapInfo());
1427
+ // eslint-disable-next-line no-console
1428
+ console.groupEnd();
1429
+ });
1430
+ });
1431
+ } catch (_) {}
1432
+ }
1433
+
1434
+ #applyTestPresetToScene() {
1435
+ if (!this.renderer || !this.scene) return;
1436
+
1437
+ // 1) Renderer shadows
1438
+ try { this.renderer.shadowMap.enabled = true; } catch (_) {}
1439
+ try { this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; } catch (_) {}
1440
+
1441
+ // 2) Tone mapping (ACES + sRGB)
1442
+ try {
1443
+ if ('outputColorSpace' in this.renderer && THREE.SRGBColorSpace) {
1444
+ this.renderer.outputColorSpace = THREE.SRGBColorSpace;
1445
+ } else if ('outputEncoding' in this.renderer && THREE.sRGBEncoding) {
1446
+ this.renderer.outputEncoding = THREE.sRGBEncoding;
1447
+ }
1448
+ } catch (_) {}
1449
+ try { this.renderer.toneMapping = THREE.ACESFilmicToneMapping; } catch (_) {}
1450
+ try { this.renderer.toneMappingExposure = 1.0; } catch (_) {}
1451
+
1452
+ // 3) Visual from recommendations
1453
+ try { this.setEnvironmentEnabled(true); } catch (_) {}
1454
+ try { this.setEnvironmentIntensity(0.65); } catch (_) {}
1455
+ try { this.setToneMappingEnabled(true); } catch (_) {}
1456
+ try { this.setExposure(1.0); } catch (_) {}
1457
+ try { this.setAOEnabled(true); } catch (_) {}
1458
+ try { this.setAOIntensity(0.52); } catch (_) {}
1459
+ try { this.setAORadius(8); } catch (_) {}
1460
+ // Цветокор не упоминался — выключаем, чтобы не влиял
1461
+ try { this.setColorCorrectionEnabled(false); } catch (_) {}
1462
+
1463
+ // 4) Materials: рекомендации не требуют — фиксируем "original", чтобы исключить влияние панели
1464
+ try {
1465
+ this.setMaterialPreset('original');
1466
+ this.setMaterialRoughness(null);
1467
+ this.setMaterialMetalness(null);
1468
+ } catch (_) {}
1469
+
1470
+ // 5) Edges/flat shading: фиксируем, чтобы исключить влияние панели
1471
+ try { this.setEdgesVisible(false); } catch (_) {}
1472
+ try { this.setFlatShading(false); } catch (_) {}
1473
+
1474
+ // 6) Shadows: включаем и задаём параметры как в рекомендациях
1475
+ try { this.setShadowGradientEnabled(false); } catch (_) {}
1476
+ try { this.setShadowOpacity(0.30); } catch (_) {}
1477
+ try { this.setShadowSoftness(2.0); } catch (_) {}
1478
+ try { this.setShadowsEnabled(true); } catch (_) {}
1479
+
1480
+ // 7) Lights: directional + ambient (как в примере)
1481
+ if (this.ambientLight) {
1482
+ this.ambientLight.visible = true;
1483
+ this.ambientLight.intensity = 0.4;
1484
+ }
1485
+ if (this.sunLight) {
1486
+ this.sunLight.visible = true;
1487
+ this.sunLight.intensity = 1.0;
1488
+ this.sunLight.castShadow = true;
1489
+ // mapSize: форсируем пересоздание shadow map (иначе WebGLRenderTarget может остаться старого размера)
1490
+ try { this.sunLight.shadow.mapSize.set(4096, 4096); } catch (_) {}
1491
+ try {
1492
+ if (this.sunLight.shadow?.map) {
1493
+ this.sunLight.shadow.map.dispose?.();
1494
+ this.sunLight.shadow.map = null;
1495
+ }
1496
+ } catch (_) {}
1497
+ try { this.sunLight.shadow.needsUpdate = true; } catch (_) {}
1498
+ try { this.sunLight.shadow.bias = -0.0001; } catch (_) {}
1499
+ try { this.sunLight.shadow.normalBias = 0.02; } catch (_) {}
1500
+
1501
+ // Подгоняем shadow-camera под размер модели (если есть), иначе используем дефолтные рамки
1502
+ const model = this.activeModel;
1503
+ if (model) {
1504
+ try {
1505
+ const box = new THREE.Box3().setFromObject(model);
1506
+ const size = box.getSize(new THREE.Vector3());
1507
+ const center = box.getCenter(new THREE.Vector3());
1508
+ const maxDim = Math.max(size.x, size.y, size.z);
1509
+
1510
+ // Важно: DirectionalLight использует target для ориентации. Добавляем target в сцену и целимся в центр модели.
1511
+ try {
1512
+ if (this.sunLight.target && !this.sunLight.target.parent) {
1513
+ this.scene.add(this.sunLight.target);
1514
+ }
1515
+ this.sunLight.target.position.copy(center);
1516
+ this.sunLight.target.updateMatrixWorld?.();
1517
+ } catch (_) {}
1518
+
1519
+ // Позиция солнца: фиксированное направление, масштаб по размеру модели
1520
+ const sunOffset = new THREE.Vector3(1, 2, 1).normalize().multiplyScalar(Math.max(10, maxDim * 1.5));
1521
+ const sunPos = center.clone().add(sunOffset);
1522
+ this.sunLight.position.copy(sunPos);
1523
+ this._sunBaseXZ = { x: this.sunLight.position.x, z: this.sunLight.position.z };
1524
+ try { this.sunLight.updateMatrixWorld?.(); } catch (_) {}
1525
+
1526
+ const cam = this.sunLight.shadow.camera;
1527
+ cam.near = 0.5;
1528
+ cam.far = Math.max(500, maxDim * 10);
1529
+ cam.left = -maxDim;
1530
+ cam.right = maxDim;
1531
+ cam.top = maxDim;
1532
+ cam.bottom = -maxDim;
1533
+ cam.updateProjectionMatrix();
1534
+ try { this.sunLight.shadow.needsUpdate = true; } catch (_) {}
1535
+ } catch (_) {}
1536
+ } else {
1537
+ try {
1538
+ const cam = this.sunLight.shadow.camera;
1539
+ cam.near = 0.5;
1540
+ cam.far = 500;
1541
+ cam.left = -100;
1542
+ cam.right = 100;
1543
+ cam.top = 100;
1544
+ cam.bottom = -100;
1545
+ cam.updateProjectionMatrix();
1546
+ try { this.sunLight.shadow.needsUpdate = true; } catch (_) {}
1547
+ } catch (_) {}
1548
+ }
1549
+ }
1550
+
1551
+ // 8) Самозатенение: все меши модели cast+receive
1552
+ if (this.activeModel) {
1553
+ this.activeModel.traverse?.((node) => {
1554
+ if (!node?.isMesh) return;
1555
+ node.castShadow = true;
1556
+ node.receiveShadow = true;
1557
+ });
1558
+ }
1559
+ // Приёмник теней на земле оставляем (ShadowMaterial), но без градиента
1560
+ if (this.shadowReceiver) {
1561
+ try { this.shadowReceiver.visible = true; } catch (_) {}
1562
+ try { this.shadowReceiver.receiveShadow = true; } catch (_) {}
1563
+ try {
1564
+ if (this.shadowReceiver.material && 'opacity' in this.shadowReceiver.material) {
1565
+ this.shadowReceiver.material.opacity = this.shadowStyle.opacity;
1566
+ this.shadowReceiver.material.needsUpdate = true;
1567
+ }
1568
+ } catch (_) {}
1569
+ }
1570
+ }
1571
+
1572
+ /**
1573
+ * Прозрачность тени на земле (0..1).
1574
+ * Это opacity у ShadowMaterial приёмника.
1575
+ * @param {number} opacity
1576
+ */
1577
+ setShadowOpacity(opacity) {
1578
+ const v = Number(opacity);
1579
+ if (!Number.isFinite(v)) return;
1580
+ this.shadowStyle.opacity = Math.min(1, Math.max(0, v));
1581
+ if (this.shadowReceiver?.material && 'opacity' in this.shadowReceiver.material) {
1582
+ this.shadowReceiver.material.opacity = this.shadowStyle.opacity;
1583
+ this.shadowReceiver.material.needsUpdate = true;
1584
+ }
1585
+ }
1586
+
1587
+ /**
1588
+ * Мягкость края тени (radius для PCFSoftShadowMap).
1589
+ * @param {number} softness
1590
+ */
1591
+ setShadowSoftness(softness) {
1592
+ const v = Number(softness);
1593
+ if (!Number.isFinite(v)) return;
1594
+ this.shadowStyle.softness = Math.max(0, v);
1595
+ if (this.sunLight) {
1596
+ this.sunLight.shadow.radius = this.shadowStyle.softness;
1597
+ }
1598
+ }
1599
+
1600
+ // ===================== Materials =====================
1601
+ /**
1602
+ * Установить пресет материалов для модели.
1603
+ * @param {'original'|'matte'|'glossy'|'plastic'|'concrete'} preset
1604
+ */
1605
+ setMaterialPreset(preset) {
1606
+ const allowed = new Set(['original', 'matte', 'glossy', 'plastic', 'concrete']);
1607
+ const next = allowed.has(preset) ? preset : 'original';
1608
+ this.materialStyle.preset = next;
1609
+ // При смене пресета сбрасываем ручные override-ы (чтобы пресет был предсказуемым)
1610
+ this.materialStyle.roughness = null;
1611
+ this.materialStyle.metalness = null;
1612
+ this.#applyMaterialStyleToModel(this.activeModel);
1613
+ }
1614
+
1615
+ // ===================== Visual diagnostics (Environment / Tone / AO) =====================
1616
+ setEnvironmentEnabled(enabled) {
1617
+ const next = !!enabled;
1618
+ this.visual.environment.enabled = next;
1619
+ if (next) this.#ensureEnvironment();
1620
+ if (this.scene) this.scene.environment = next ? this._roomEnvTex : null;
1621
+ this.#applyEnvIntensityToModel(this.activeModel);
1622
+ }
1623
+
1624
+ setEnvironmentIntensity(intensity) {
1625
+ const v = Number(intensity);
1626
+ if (!Number.isFinite(v)) return;
1627
+ this.visual.environment.intensity = Math.min(5, Math.max(0, v));
1628
+ this.#applyEnvIntensityToModel(this.activeModel);
1629
+ }
1630
+
1631
+ setToneMappingEnabled(enabled) {
1632
+ const next = !!enabled;
1633
+ this.visual.tone.enabled = next;
1634
+ this.#applyToneSettings();
1635
+ }
1636
+
1637
+ setExposure(exposure) {
1638
+ const v = Number(exposure);
1639
+ if (!Number.isFinite(v)) return;
1640
+ this.visual.tone.exposure = Math.min(2.5, Math.max(0.1, v));
1641
+ this.#applyToneSettings();
1642
+ }
1643
+
1644
+ setAOEnabled(enabled) {
1645
+ const next = !!enabled;
1646
+ this.visual.ao.enabled = next;
1647
+ if (next) this.#ensureComposer();
1648
+ if (this._ssaoPass) this._ssaoPass.enabled = next;
1649
+ }
1650
+
1651
+ // ===== Color correction =====
1652
+ setColorCorrectionEnabled(enabled) {
1653
+ const next = !!enabled;
1654
+ this.visual.color.enabled = next;
1655
+ if (next) this.#ensureComposer();
1656
+ if (this._hueSatPass) this._hueSatPass.enabled = next;
1657
+ if (this._bcPass) this._bcPass.enabled = next;
1658
+ this.#applyColorCorrectionUniforms();
1659
+ }
1660
+
1661
+ setColorHue(hue) {
1662
+ const v = Number(hue);
1663
+ if (!Number.isFinite(v)) return;
1664
+ this.visual.color.hue = Math.min(1, Math.max(-1, v));
1665
+ this.#applyColorCorrectionUniforms();
1666
+ }
1667
+
1668
+ setColorSaturation(sat) {
1669
+ const v = Number(sat);
1670
+ if (!Number.isFinite(v)) return;
1671
+ this.visual.color.saturation = Math.min(1, Math.max(-1, v));
1672
+ this.#applyColorCorrectionUniforms();
1673
+ }
1674
+
1675
+ setColorBrightness(brightness) {
1676
+ const v = Number(brightness);
1677
+ if (!Number.isFinite(v)) return;
1678
+ this.visual.color.brightness = Math.min(1, Math.max(-1, v));
1679
+ this.#applyColorCorrectionUniforms();
1680
+ }
1681
+
1682
+ setColorContrast(contrast) {
1683
+ const v = Number(contrast);
1684
+ if (!Number.isFinite(v)) return;
1685
+ this.visual.color.contrast = Math.min(1, Math.max(-1, v));
1686
+ this.#applyColorCorrectionUniforms();
1687
+ }
1688
+
1689
+ #applyColorCorrectionUniforms() {
1690
+ if (this._hueSatPass?.uniforms) {
1691
+ this._hueSatPass.uniforms.hue.value = this.visual.color.hue ?? 0.0;
1692
+ this._hueSatPass.uniforms.saturation.value = this.visual.color.saturation ?? 0.0;
1693
+ }
1694
+ if (this._bcPass?.uniforms) {
1695
+ this._bcPass.uniforms.brightness.value = this.visual.color.brightness ?? 0.0;
1696
+ this._bcPass.uniforms.contrast.value = this.visual.color.contrast ?? 0.0;
1697
+ }
1698
+ }
1699
+
1700
+ setAOIntensity(intensity) {
1701
+ const v = Number(intensity);
1702
+ if (!Number.isFinite(v)) return;
1703
+ this.visual.ao.intensity = Math.min(2, Math.max(0, v));
1704
+ if (this._ssaoPass) this._ssaoPass.intensity = this.visual.ao.intensity;
1705
+ }
1706
+
1707
+ setAORadius(radius) {
1708
+ const v = Number(radius);
1709
+ if (!Number.isFinite(v)) return;
1710
+ this.visual.ao.radius = Math.min(64, Math.max(1, Math.round(v)));
1711
+ if (this._ssaoPass) this._ssaoPass.kernelRadius = this.visual.ao.radius;
1712
+ }
1713
+
1714
+ dumpVisualDebug() {
1715
+ const r = this.renderer;
1716
+ const s = this.scene;
1717
+ const model = this.activeModel;
1718
+ const mats = new Map();
1719
+ const flags = { totalMeshes: 0, totalMaterials: 0, withMap: 0, withNormalMap: 0, withRoughnessMap: 0, withMetalnessMap: 0 };
1720
+
1721
+ model?.traverse?.((node) => {
1722
+ if (!node?.isMesh) return;
1723
+ flags.totalMeshes++;
1724
+ const m = node.material;
1725
+ const arr = Array.isArray(m) ? m : [m];
1726
+ for (const mi of arr) {
1727
+ if (!mi) continue;
1728
+ flags.totalMaterials++;
1729
+ const key = mi.type || 'UnknownMaterial';
1730
+ mats.set(key, (mats.get(key) || 0) + 1);
1731
+ if (mi.map) flags.withMap++;
1732
+ if (mi.normalMap) flags.withNormalMap++;
1733
+ if (mi.roughnessMap) flags.withRoughnessMap++;
1734
+ if (mi.metalnessMap) flags.withMetalnessMap++;
1735
+ }
1736
+ });
1737
+
1738
+ // eslint-disable-next-line no-console
1739
+ console.groupCollapsed('[Viewer] Visual dump');
1740
+ // eslint-disable-next-line no-console
1741
+ console.log('three', THREE.REVISION);
1742
+ // eslint-disable-next-line no-console
1743
+ console.log('renderer', {
1744
+ outputEncoding: r?.outputEncoding,
1745
+ outputColorSpace: r?.outputColorSpace,
1746
+ toneMapping: r?.toneMapping,
1747
+ toneMappingExposure: r?.toneMappingExposure,
1748
+ physicallyCorrectLights: r?.physicallyCorrectLights,
1749
+ useLegacyLights: r?.useLegacyLights,
1750
+ });
1751
+ // eslint-disable-next-line no-console
1752
+ console.log('scene', { environment: !!s?.environment, background: !!s?.background });
1753
+ // eslint-disable-next-line no-console
1754
+ console.log('toggles', {
1755
+ env: this.visual.environment,
1756
+ tone: this.visual.tone,
1757
+ ao: this.visual.ao,
1758
+ materialPreset: this.materialStyle?.preset,
1759
+ });
1760
+ // eslint-disable-next-line no-console
1761
+ console.log('model', flags);
1762
+ // eslint-disable-next-line no-console
1763
+ console.table(Object.fromEntries(mats.entries()));
1764
+ // eslint-disable-next-line no-console
1765
+ console.groupEnd();
1766
+ }
1767
+
1768
+ #ensureEnvironment() {
1769
+ if (!this.renderer || !this.scene) return;
1770
+ if (!this._pmrem) this._pmrem = new THREE.PMREMGenerator(this.renderer);
1771
+ if (this._roomEnvTex) return;
1772
+ try {
1773
+ const env = new RoomEnvironment();
1774
+ const rt = this._pmrem.fromScene(env, 0.04);
1775
+ this._roomEnvTex = rt.texture;
1776
+ env.dispose?.();
1777
+ } catch (_) {
1778
+ this._roomEnvTex = null;
1779
+ }
1780
+ }
1781
+
1782
+ #applyEnvIntensityToModel(model) {
1783
+ if (!model) return;
1784
+ const intensity = this.visual?.environment?.intensity ?? 1.0;
1785
+ model.traverse?.((node) => {
1786
+ if (!node?.isMesh) return;
1787
+ const m = node.material;
1788
+ const arr = Array.isArray(m) ? m : [m];
1789
+ for (const mi of arr) {
1790
+ if (!mi) continue;
1791
+ if ('envMapIntensity' in mi) mi.envMapIntensity = intensity;
1792
+ }
1793
+ });
1794
+ }
1795
+
1796
+ #applyToneSettings() {
1797
+ if (!this.renderer || !this._baselineRenderer) return;
1798
+ const enabled = !!this.visual?.tone?.enabled;
1799
+ if (enabled) {
1800
+ // sRGB output
1801
+ if ('outputColorSpace' in this.renderer && THREE.SRGBColorSpace) {
1802
+ try { this.renderer.outputColorSpace = THREE.SRGBColorSpace; } catch (_) {}
1803
+ } else if ('outputEncoding' in this.renderer && THREE.sRGBEncoding) {
1804
+ this.renderer.outputEncoding = THREE.sRGBEncoding;
1805
+ }
1806
+ this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
1807
+ this.renderer.toneMappingExposure = this.visual.tone.exposure ?? 1.0;
1808
+ } else {
1809
+ // restore baseline
1810
+ if ('outputColorSpace' in this.renderer) {
1811
+ try { this.renderer.outputColorSpace = this._baselineRenderer.outputColorSpace; } catch (_) {}
1812
+ }
1813
+ if ('outputEncoding' in this.renderer) {
1814
+ this.renderer.outputEncoding = this._baselineRenderer.outputEncoding;
1815
+ }
1816
+ this.renderer.toneMapping = this._baselineRenderer.toneMapping;
1817
+ this.renderer.toneMappingExposure = this._baselineRenderer.toneMappingExposure;
1818
+ }
1819
+ }
1820
+
1821
+ #ensureComposer() {
1822
+ if (!this.renderer || !this.scene || !this.camera) return;
1823
+ if (this._composer) return;
1824
+ const { width, height } = this._getContainerSize();
1825
+ const w = Math.max(1, Math.floor(width));
1826
+ const h = Math.max(1, Math.floor(height));
1827
+ this._composer = new EffectComposer(this.renderer);
1828
+ this._renderPass = new RenderPass(this.scene, this.camera);
1829
+ this._composer.addPass(this._renderPass);
1830
+ this._ssaoPass = new SSAOPass(this.scene, this.camera, w, h);
1831
+ this._ssaoPass.enabled = !!this.visual?.ao?.enabled;
1832
+ this._ssaoPass.intensity = this.visual.ao.intensity;
1833
+ this._ssaoPass.kernelRadius = this.visual.ao.radius;
1834
+ this._ssaoPass.minDistance = this.visual.ao.minDistance;
1835
+ this._ssaoPass.maxDistance = this.visual.ao.maxDistance;
1836
+ this._composer.addPass(this._ssaoPass);
1837
+
1838
+ // Цветокоррекция (выключена по умолчанию, включается через setColorCorrectionEnabled)
1839
+ this._hueSatPass = new ShaderPass(HueSaturationShader);
1840
+ this._hueSatPass.enabled = !!this.visual?.color?.enabled;
1841
+ this._composer.addPass(this._hueSatPass);
1842
+
1843
+ this._bcPass = new ShaderPass(BrightnessContrastShader);
1844
+ this._bcPass.enabled = !!this.visual?.color?.enabled;
1845
+ this._composer.addPass(this._bcPass);
1846
+
1847
+ this.#applyColorCorrectionUniforms();
1848
+ try { this._composer.setSize(w, h); } catch (_) {}
1849
+ }
1850
+
1851
+ /** @param {number|null} roughness */
1852
+ setMaterialRoughness(roughness) {
1853
+ if (roughness === null) {
1854
+ this.materialStyle.roughness = null;
1855
+ } else {
1856
+ const v = Number(roughness);
1857
+ if (!Number.isFinite(v)) return;
1858
+ this.materialStyle.roughness = Math.min(1, Math.max(0, v));
1859
+ }
1860
+ this.#applyMaterialStyleToModel(this.activeModel);
1861
+ }
1862
+
1863
+ /** @param {number|null} metalness */
1864
+ setMaterialMetalness(metalness) {
1865
+ if (metalness === null) {
1866
+ this.materialStyle.metalness = null;
1867
+ } else {
1868
+ const v = Number(metalness);
1869
+ if (!Number.isFinite(v)) return;
1870
+ this.materialStyle.metalness = Math.min(1, Math.max(0, v));
1871
+ }
1872
+ this.#applyMaterialStyleToModel(this.activeModel);
1873
+ }
1874
+
1875
+ #getMaterialPresetDefaults(preset) {
1876
+ switch (preset) {
1877
+ case 'matte': return { roughness: 0.90, metalness: 0.00 };
1878
+ case 'glossy': return { roughness: 0.05, metalness: 0.00 };
1879
+ // Пластик не должен быть "металлом": metalness=0, roughness повыше для архитектурного вида
1880
+ case 'plastic': return { roughness: 0.65, metalness: 0.00 };
1881
+ case 'concrete': return { roughness: 0.95, metalness: 0.00 };
1882
+ default: return { roughness: null, metalness: null };
1883
+ }
1884
+ }
1885
+
1886
+ #ensureMeshOriginalMaterial(mesh) {
1887
+ if (this._meshOriginalMaterial.has(mesh)) return;
1888
+ this._meshOriginalMaterial.set(mesh, mesh.material);
1889
+ }
1890
+
1891
+ #restoreOriginalMaterials(model) {
1892
+ if (!model) return;
1893
+ model.traverse?.((node) => {
1894
+ if (!node?.isMesh) return;
1895
+ const orig = this._meshOriginalMaterial.get(node);
1896
+ if (orig) node.material = orig;
1897
+ });
1898
+ }
1899
+
1900
+ #getConvertedMaterial(origMat) {
1901
+ if (!origMat) return origMat;
1902
+ const cached = this._origToConvertedMaterial.get(origMat);
1903
+ if (cached) return cached;
1904
+
1905
+ let converted = null;
1906
+ try {
1907
+ const origOpacity = ('opacity' in origMat) ? Number(origMat.opacity ?? 1) : 1;
1908
+ const origTransparent = ('transparent' in origMat) ? !!origMat.transparent : false;
1909
+ const hasAlphaMap = ('alphaMap' in origMat) ? !!origMat.alphaMap : false;
1910
+ const hasMap = ('map' in origMat) ? !!origMat.map : false;
1911
+ const looksTransparent = origTransparent || (Number.isFinite(origOpacity) && origOpacity < 0.999) || hasAlphaMap;
1912
+
1913
+ if (origMat.isMeshStandardMaterial || origMat.isMeshPhysicalMaterial) {
1914
+ converted = origMat.clone();
1915
+ } else {
1916
+ converted = new THREE.MeshStandardMaterial();
1917
+ if (origMat.color) converted.color = origMat.color.clone();
1918
+ if ('map' in origMat) converted.map = origMat.map || null;
1919
+ if ('alphaMap' in origMat) converted.alphaMap = origMat.alphaMap || null;
1920
+ if ('transparent' in origMat) converted.transparent = !!origMat.transparent;
1921
+ if ('opacity' in origMat) converted.opacity = Number(origMat.opacity ?? 1);
1922
+ if ('side' in origMat) converted.side = origMat.side;
1923
+ if ('alphaTest' in origMat) converted.alphaTest = Number(origMat.alphaTest ?? 0);
1924
+ if ('depthWrite' in origMat) converted.depthWrite = !!origMat.depthWrite;
1925
+ if ('depthTest' in origMat) converted.depthTest = !!origMat.depthTest;
1926
+ }
1927
+ // Прозрачность: стекло/окна (самый частый источник мерцания).
1928
+ // Для стабильности: transparent=true + depthWrite=false, и НЕ форсить DoubleSide без нужды.
1929
+ if (looksTransparent) {
1930
+ const op = Number.isFinite(origOpacity) ? origOpacity : 1;
1931
+ // Бывает, что материал помечен transparent, но opacity почти 1 — делаем его непрозрачным (сильный прирост стабильности).
1932
+ if (op >= 0.995 && !hasAlphaMap) {
1933
+ converted.transparent = false;
1934
+ converted.opacity = 1;
1935
+ converted.depthWrite = true;
1936
+ } else {
1937
+ converted.transparent = true;
1938
+ converted.opacity = Math.min(1, Math.max(0.02, op));
1939
+ converted.depthTest = true;
1940
+ converted.depthWrite = false;
1941
+ const origSide = ('side' in origMat) ? origMat.side : undefined;
1942
+ converted.side = (origSide === THREE.DoubleSide) ? THREE.DoubleSide : THREE.FrontSide;
1943
+ }
1944
+ } else {
1945
+ // IFC часто содержит перевёрнутые нормали/тонкие накладки.
1946
+ // Для устойчивого отображения фасадов делаем НЕпрозрачные материалы двусторонними.
1947
+ converted.side = THREE.DoubleSide;
1948
+ }
1949
+
1950
+ // Чёткость текстур на расстоянии (анизотропия), если есть карты
1951
+ try {
1952
+ const maxAniso = this.renderer?.capabilities?.getMaxAnisotropy?.() || 0;
1953
+ const aniso = Math.min(8, Math.max(0, maxAniso));
1954
+ if (aniso > 1) {
1955
+ const texList = [];
1956
+ if (hasMap && converted.map) texList.push(converted.map);
1957
+ if ('roughnessMap' in converted && converted.roughnessMap) texList.push(converted.roughnessMap);
1958
+ if ('metalnessMap' in converted && converted.metalnessMap) texList.push(converted.metalnessMap);
1959
+ if ('normalMap' in converted && converted.normalMap) texList.push(converted.normalMap);
1960
+ if ('aoMap' in converted && converted.aoMap) texList.push(converted.aoMap);
1961
+ if ('alphaMap' in converted && converted.alphaMap) texList.push(converted.alphaMap);
1962
+ for (const t of texList) {
1963
+ if (!t) continue;
1964
+ t.anisotropy = Math.max(t.anisotropy || 1, aniso);
1965
+ t.needsUpdate = true;
1966
+ }
1967
+ }
1968
+ } catch (_) {}
1969
+ // Сохраняем polygonOffset (важно для edges-overlay)
1970
+ if ('polygonOffset' in origMat) converted.polygonOffset = !!origMat.polygonOffset;
1971
+ if ('polygonOffsetFactor' in origMat) converted.polygonOffsetFactor = Number(origMat.polygonOffsetFactor ?? 0);
1972
+ if ('polygonOffsetUnits' in origMat) converted.polygonOffsetUnits = Number(origMat.polygonOffsetUnits ?? 0);
1973
+ converted.needsUpdate = true;
1974
+ } catch (_) {
1975
+ converted = origMat;
1976
+ }
1977
+
1978
+ this._origToConvertedMaterial.set(origMat, converted);
1979
+ return converted;
1980
+ }
1981
+
1982
+ #applyMaterialStyleToModel(model) {
1983
+ if (!model) return;
1984
+ const preset = this.materialStyle.preset || 'original';
1985
+ if (preset === 'original') {
1986
+ this.#restoreOriginalMaterials(model);
1987
+ return;
1988
+ }
1989
+
1990
+ const defaults = this.#getMaterialPresetDefaults(preset);
1991
+ const rough = (this.materialStyle.roughness !== null) ? this.materialStyle.roughness : defaults.roughness;
1992
+ const metal = (this.materialStyle.metalness !== null) ? this.materialStyle.metalness : defaults.metalness;
1993
+ const targetRough = (rough === null) ? 0.8 : rough;
1994
+ const targetMetal = (metal === null) ? 0.0 : metal;
1995
+
1996
+ model.traverse?.((node) => {
1997
+ if (!node?.isMesh) return;
1998
+ this.#ensureMeshOriginalMaterial(node);
1999
+ const orig = this._meshOriginalMaterial.get(node);
2000
+ if (!orig) return;
2001
+
2002
+ const applyToMat = (m) => {
2003
+ const cm = this.#getConvertedMaterial(m);
2004
+ if (cm?.isMeshStandardMaterial || cm?.isMeshPhysicalMaterial) {
2005
+ cm.roughness = targetRough;
2006
+ cm.metalness = targetMetal;
2007
+ cm.needsUpdate = true;
2008
+ }
2009
+ return cm;
2010
+ };
2011
+
2012
+ if (Array.isArray(orig)) {
2013
+ node.material = orig.map(applyToMat);
2014
+ } else {
2015
+ node.material = applyToMat(orig);
2016
+ }
2017
+ });
2018
+ }
2019
+
2020
+ /**
2021
+ * Включить/выключить градиент тени на земле.
2022
+ * @param {boolean} enabled
2023
+ */
2024
+ setShadowGradientEnabled(enabled) {
2025
+ this.shadowGradient.enabled = !!enabled;
2026
+ this.#applyShadowGradientUniforms();
2027
+ }
2028
+
2029
+ /**
2030
+ * Длина градиента тени (в мировых единицах).
2031
+ * @param {number} length
2032
+ */
2033
+ setShadowGradientLength(length) {
2034
+ const v = Number(length);
2035
+ if (!Number.isFinite(v)) return;
2036
+ this.shadowGradient.length = Math.max(0.001, v);
2037
+ this.#applyShadowGradientUniforms();
2038
+ }
2039
+
2040
+ /**
2041
+ * Сила градиента тени (0..1).
2042
+ * @param {number} strength
2043
+ */
2044
+ setShadowGradientStrength(strength) {
2045
+ const v = Number(strength);
2046
+ if (!Number.isFinite(v)) return;
2047
+ this.shadowGradient.strength = Math.min(1, Math.max(0, v));
2048
+ this.#applyShadowGradientUniforms();
2049
+ }
2050
+
2051
+ /**
2052
+ * Кривая затухания градиента (нелинейность).
2053
+ * 1 = линейно, >1 = дольше темно у основания, <1 = быстрее убывает в начале.
2054
+ * @param {number} curve
2055
+ */
2056
+ setShadowGradientCurve(curve) {
2057
+ const v = Number(curve);
2058
+ if (!Number.isFinite(v)) return;
2059
+ this.shadowGradient.curve = Math.max(0.05, v);
2060
+ this.#applyShadowGradientUniforms();
2061
+ }
2062
+
2063
+ /**
2064
+ * Включить/выключить глобальное освещение сцены ("Солнце"):
2065
+ * directional light + ambient light.
2066
+ * @param {boolean} enabled
2067
+ */
2068
+ setSunEnabled(enabled) {
2069
+ const next = !!enabled;
2070
+ if (this.sunLight) {
2071
+ this.sunLight.visible = next;
2072
+ // Если солнце выключено — тени от него бессмысленны
2073
+ this.sunLight.castShadow = next && !!this.shadowsEnabled;
2074
+ }
2075
+ if (this.ambientLight) {
2076
+ this.ambientLight.visible = next;
2077
+ }
2078
+ }
2079
+
2080
+ /**
2081
+ * Регулировка высоты солнца (Y координата DirectionalLight).
2082
+ * Чем ниже солнце — тем тени длиннее.
2083
+ * @param {number} y
2084
+ */
2085
+ setSunHeight(y) {
2086
+ if (!this.sunLight) return;
2087
+ const nextY = Number.isFinite(y) ? y : this.sunLight.position.y;
2088
+ const clamped = Math.max(0, nextY);
2089
+ this.sunLight.position.set(this._sunBaseXZ.x, clamped, this._sunBaseXZ.z);
2090
+ this.sunLight.updateMatrixWorld();
2091
+ // При активных тенях обновим shadow-camera (ориентация/проекция)
2092
+ try { this.sunLight.shadow?.camera?.updateProjectionMatrix?.(); } catch (_) {}
2093
+ }
2094
+
645
2095
  // --- Clipping API ---
646
2096
  // axis: 'x' | 'y' | 'z', enabled: boolean, distance: number (в мировых единицах)
647
2097
  setSection(axis, enabled, distance = 0) {
@@ -823,6 +2273,11 @@ export class Viewer {
823
2273
  }
824
2274
 
825
2275
  #updateRotationAxisLine() {
2276
+ // Визуализацию оси вращения временно отключаем:
2277
+ // логика вычисления оси и создание линии оставлены в коде,
2278
+ // но сейчас просто не показываем её, чтобы не мешала.
2279
+ return;
2280
+
826
2281
  if (!this.camera || !this.controls) return;
827
2282
  // Порог по экранному движению
828
2283
  if (this._recentPointerDelta < this._pointerPxThreshold) return;