@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.
- package/README.md +18 -0
- package/package.json +1 -1
- package/src/IfcViewer.js +33 -3
- package/src/ifc/IfcService.js +10 -6
- package/src/main.js +495 -5
- package/src/viewer/Viewer.js +1471 -16
package/src/viewer/Viewer.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
526
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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;
|