@chocozhang/three-model-render 1.0.1

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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +609 -0
  3. package/dist/camera/index.d.ts +133 -0
  4. package/dist/camera/index.js +291 -0
  5. package/dist/camera/index.js.map +1 -0
  6. package/dist/camera/index.mjs +265 -0
  7. package/dist/camera/index.mjs.map +1 -0
  8. package/dist/core/index.d.ts +102 -0
  9. package/dist/core/index.js +455 -0
  10. package/dist/core/index.js.map +1 -0
  11. package/dist/core/index.mjs +432 -0
  12. package/dist/core/index.mjs.map +1 -0
  13. package/dist/effect/index.d.ts +214 -0
  14. package/dist/effect/index.js +749 -0
  15. package/dist/effect/index.js.map +1 -0
  16. package/dist/effect/index.mjs +728 -0
  17. package/dist/effect/index.mjs.map +1 -0
  18. package/dist/index.d.ts +852 -0
  19. package/dist/index.js +3268 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/index.mjs +3223 -0
  22. package/dist/index.mjs.map +1 -0
  23. package/dist/interaction/index.d.ts +160 -0
  24. package/dist/interaction/index.js +661 -0
  25. package/dist/interaction/index.js.map +1 -0
  26. package/dist/interaction/index.mjs +637 -0
  27. package/dist/interaction/index.mjs.map +1 -0
  28. package/dist/loader/index.d.ts +175 -0
  29. package/dist/loader/index.js +786 -0
  30. package/dist/loader/index.js.map +1 -0
  31. package/dist/loader/index.mjs +758 -0
  32. package/dist/loader/index.mjs.map +1 -0
  33. package/dist/setup/index.d.ts +47 -0
  34. package/dist/setup/index.js +199 -0
  35. package/dist/setup/index.js.map +1 -0
  36. package/dist/setup/index.mjs +178 -0
  37. package/dist/setup/index.mjs.map +1 -0
  38. package/dist/ui/index.d.ts +36 -0
  39. package/dist/ui/index.js +292 -0
  40. package/dist/ui/index.js.map +1 -0
  41. package/dist/ui/index.mjs +271 -0
  42. package/dist/ui/index.mjs.map +1 -0
  43. package/package.json +98 -0
package/dist/index.js ADDED
@@ -0,0 +1,3268 @@
1
+ 'use strict';
2
+
3
+ var THREE = require('three');
4
+ var EffectComposer = require('three/examples/jsm/postprocessing/EffectComposer');
5
+ var RenderPass = require('three/examples/jsm/postprocessing/RenderPass');
6
+ var OutlinePass = require('three/examples/jsm/postprocessing/OutlinePass');
7
+ var GammaCorrectionShader = require('three/examples/jsm/shaders/GammaCorrectionShader');
8
+ var ShaderPass = require('three/examples/jsm/postprocessing/ShaderPass');
9
+ var BufferGeometryUtils = require('three/examples/jsm/utils/BufferGeometryUtils.js');
10
+ var EXRLoader_js = require('three/examples/jsm/loaders/EXRLoader.js');
11
+
12
+ function _interopNamespaceDefault(e) {
13
+ var n = Object.create(null);
14
+ if (e) {
15
+ Object.keys(e).forEach(function (k) {
16
+ if (k !== 'default') {
17
+ var d = Object.getOwnPropertyDescriptor(e, k);
18
+ Object.defineProperty(n, k, d.get ? d : {
19
+ enumerable: true,
20
+ get: function () { return e[k]; }
21
+ });
22
+ }
23
+ });
24
+ }
25
+ n.default = e;
26
+ return Object.freeze(n);
27
+ }
28
+
29
+ var THREE__namespace = /*#__PURE__*/_interopNamespaceDefault(THREE);
30
+ var BufferGeometryUtils__namespace = /*#__PURE__*/_interopNamespaceDefault(BufferGeometryUtils);
31
+
32
+ /**
33
+ * 给子模型添加头顶标签(支持 Mesh 和 Group)- 优化版
34
+ *
35
+ * ✨ 性能优化:
36
+ * - 缓存包围盒,避免每帧重复计算
37
+ * - 支持暂停/恢复更新
38
+ * - 可配置更新间隔,降低 CPU 占用
39
+ * - 只在可见时更新,隐藏时自动暂停
40
+ *
41
+ * @param camera THREE.Camera - 场景摄像机
42
+ * @param renderer THREE.WebGLRenderer - 渲染器,用于屏幕尺寸
43
+ * @param parentModel THREE.Object3D - FBX 根节点或 Group
44
+ * @param modelLabelsMap Record<string,string> - 模型 name → 标签文字 映射表
45
+ * @param options LabelOptions - 可选标签样式配置
46
+ * @returns LabelManager - 包含 pause/resume/dispose 的管理接口
47
+ */
48
+ function addChildModelLabels(camera, renderer, parentModel, modelLabelsMap, options) {
49
+ // 防御性检查:确保 parentModel 已加载
50
+ if (!parentModel || typeof parentModel.traverse !== 'function') {
51
+ console.error('parentModel 无效,请确保 FBX 模型已加载完成');
52
+ return {
53
+ pause: () => { },
54
+ resume: () => { },
55
+ dispose: () => { },
56
+ isRunning: () => false
57
+ };
58
+ }
59
+ // 配置项
60
+ const enableCache = (options === null || options === void 0 ? void 0 : options.enableCache) !== false;
61
+ const updateInterval = (options === null || options === void 0 ? void 0 : options.updateInterval) || 0;
62
+ // 创建标签容器,绝对定位,放在 body 中
63
+ const container = document.createElement('div');
64
+ container.style.position = 'absolute';
65
+ container.style.top = '0';
66
+ container.style.left = '0';
67
+ container.style.pointerEvents = 'none'; // 避免阻挡鼠标事件
68
+ container.style.zIndex = '1000';
69
+ document.body.appendChild(container);
70
+ const labels = [];
71
+ // 状态管理
72
+ let rafId = null;
73
+ let isPaused = false;
74
+ let lastUpdateTime = 0;
75
+ // 遍历所有子模型
76
+ parentModel.traverse((child) => {
77
+ var _a;
78
+ // 只处理 Mesh 或 Group
79
+ if ((child.isMesh || child.type === 'Group')) {
80
+ // 动态匹配 name,防止 undefined
81
+ const labelText = (_a = Object.entries(modelLabelsMap).find(([key]) => child.name.includes(key))) === null || _a === void 0 ? void 0 : _a[1];
82
+ if (!labelText)
83
+ return; // 没有匹配标签则跳过
84
+ // 创建 DOM 标签
85
+ const el = document.createElement('div');
86
+ el.innerText = labelText;
87
+ // 样式直接在 JS 中定义,可通过 options 覆盖
88
+ el.style.position = 'absolute';
89
+ el.style.color = (options === null || options === void 0 ? void 0 : options.color) || '#fff';
90
+ el.style.background = (options === null || options === void 0 ? void 0 : options.background) || 'rgba(0,0,0,0.6)';
91
+ el.style.padding = (options === null || options === void 0 ? void 0 : options.padding) || '4px 8px';
92
+ el.style.borderRadius = (options === null || options === void 0 ? void 0 : options.borderRadius) || '4px';
93
+ el.style.fontSize = (options === null || options === void 0 ? void 0 : options.fontSize) || '14px';
94
+ el.style.transform = 'translate(-50%, -100%)'; // 让标签在模型正上方
95
+ el.style.whiteSpace = 'nowrap';
96
+ el.style.pointerEvents = 'none';
97
+ el.style.transition = 'opacity 0.2s ease';
98
+ // 加入容器
99
+ container.appendChild(el);
100
+ // 初始化缓存
101
+ const cachedBox = new THREE__namespace.Box3().setFromObject(child);
102
+ const center = new THREE__namespace.Vector3();
103
+ cachedBox.getCenter(center);
104
+ const cachedTopPos = new THREE__namespace.Vector3(center.x, cachedBox.max.y, center.z);
105
+ labels.push({
106
+ object: child,
107
+ el,
108
+ cachedBox,
109
+ cachedTopPos,
110
+ needsUpdate: true
111
+ });
112
+ }
113
+ });
114
+ /**
115
+ * 更新缓存的包围盒(仅在模型变换时调用)
116
+ */
117
+ const updateCache = (labelData) => {
118
+ labelData.cachedBox.setFromObject(labelData.object);
119
+ const center = new THREE__namespace.Vector3();
120
+ labelData.cachedBox.getCenter(center);
121
+ labelData.cachedTopPos.set(center.x, labelData.cachedBox.max.y, center.z);
122
+ labelData.needsUpdate = false;
123
+ };
124
+ /**
125
+ * 获取对象顶部世界坐标(使用缓存)
126
+ */
127
+ const getObjectTopPosition = (labelData) => {
128
+ if (enableCache) {
129
+ // 检查对象是否发生变换
130
+ if (labelData.needsUpdate || labelData.object.matrixWorldNeedsUpdate) {
131
+ updateCache(labelData);
132
+ }
133
+ return labelData.cachedTopPos;
134
+ }
135
+ else {
136
+ // 不使用缓存,每次都重新计算
137
+ const box = new THREE__namespace.Box3().setFromObject(labelData.object);
138
+ const center = new THREE__namespace.Vector3();
139
+ box.getCenter(center);
140
+ return new THREE__namespace.Vector3(center.x, box.max.y, center.z);
141
+ }
142
+ };
143
+ /**
144
+ * 更新标签位置函数
145
+ */
146
+ function updateLabels(timestamp = 0) {
147
+ // 检查是否暂停
148
+ if (isPaused) {
149
+ rafId = null;
150
+ return;
151
+ }
152
+ // 检查更新间隔
153
+ if (updateInterval > 0 && timestamp - lastUpdateTime < updateInterval) {
154
+ rafId = requestAnimationFrame(updateLabels);
155
+ return;
156
+ }
157
+ lastUpdateTime = timestamp;
158
+ const width = renderer.domElement.clientWidth;
159
+ const height = renderer.domElement.clientHeight;
160
+ labels.forEach((labelData) => {
161
+ const { el } = labelData;
162
+ const pos = getObjectTopPosition(labelData); // 使用缓存的顶部坐标
163
+ pos.project(camera); // 转到屏幕坐标
164
+ const x = (pos.x * 0.5 + 0.5) * width; // 屏幕 X
165
+ const y = (-(pos.y * 0.5) + 0.5) * height; // 屏幕 Y
166
+ // 控制标签显示/隐藏(摄像机后方隐藏)
167
+ const isVisible = pos.z < 1;
168
+ el.style.opacity = isVisible ? '1' : '0';
169
+ el.style.display = isVisible ? 'block' : 'none';
170
+ el.style.transform = `translate(-50%, -100%) translate(${x}px, ${y}px)`; // 屏幕位置
171
+ });
172
+ rafId = requestAnimationFrame(updateLabels); // 循环更新
173
+ }
174
+ // 启动更新
175
+ updateLabels();
176
+ /**
177
+ * 暂停更新
178
+ */
179
+ const pause = () => {
180
+ isPaused = true;
181
+ if (rafId !== null) {
182
+ cancelAnimationFrame(rafId);
183
+ rafId = null;
184
+ }
185
+ };
186
+ /**
187
+ * 恢复更新
188
+ */
189
+ const resume = () => {
190
+ if (!isPaused)
191
+ return;
192
+ isPaused = false;
193
+ updateLabels();
194
+ };
195
+ /**
196
+ * 检查是否正在运行
197
+ */
198
+ const isRunning = () => !isPaused;
199
+ /**
200
+ * 清理函数:卸载所有 DOM 标签,取消动画,避免内存泄漏
201
+ */
202
+ const dispose = () => {
203
+ pause();
204
+ labels.forEach(({ el }) => {
205
+ if (container.contains(el)) {
206
+ container.removeChild(el);
207
+ }
208
+ });
209
+ if (document.body.contains(container)) {
210
+ document.body.removeChild(container);
211
+ }
212
+ labels.length = 0;
213
+ };
214
+ return {
215
+ pause,
216
+ resume,
217
+ dispose,
218
+ isRunning
219
+ };
220
+ }
221
+
222
+ // src/utils/hoverBreathEffectByNameSingleton.ts
223
+ /**
224
+ * 创建单例高亮器 —— 推荐在 mounted 时创建一次
225
+ * 返回 { updateHighlightNames, dispose, getHoveredName } 接口
226
+ *
227
+ * ✨ 性能优化:
228
+ * - 无 hover 对象时自动暂停动画
229
+ * - mousemove 节流处理,避免过度计算
230
+ * - 使用 passive 事件监听器提升滚动性能
231
+ */
232
+ function enableHoverBreath(opts) {
233
+ const { camera, scene, renderer, outlinePass, highlightNames = null, minStrength = 2, maxStrength = 5, speed = 4, throttleDelay = 16, // 默认约 60fps
234
+ } = opts;
235
+ const raycaster = new THREE__namespace.Raycaster();
236
+ const mouse = new THREE__namespace.Vector2();
237
+ let hovered = null;
238
+ let time = 0;
239
+ let animationId = null;
240
+ // highlightSet: null 表示 all; empty Set 表示 none
241
+ let highlightSet = highlightNames === null ? null : new Set(highlightNames);
242
+ // 节流相关
243
+ let lastMoveTime = 0;
244
+ let rafPending = false;
245
+ function setHighlightNames(names) {
246
+ highlightSet = names === null ? null : new Set(names);
247
+ // 如果当前 hovered 不在新名单中,及时清理 selection
248
+ if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
249
+ hovered = null;
250
+ outlinePass.selectedObjects = [];
251
+ // 暂停动画
252
+ if (animationId !== null) {
253
+ cancelAnimationFrame(animationId);
254
+ animationId = null;
255
+ }
256
+ }
257
+ }
258
+ /**
259
+ * 节流版本的 mousemove 处理
260
+ */
261
+ function onMouseMove(ev) {
262
+ const now = performance.now();
263
+ // 节流:如果距上次处理时间小于阈值,跳过
264
+ if (now - lastMoveTime < throttleDelay) {
265
+ // 使用 RAF 延迟处理,避免丢失最后一次事件
266
+ if (!rafPending) {
267
+ rafPending = true;
268
+ requestAnimationFrame(() => {
269
+ rafPending = false;
270
+ processMouseMove(ev);
271
+ });
272
+ }
273
+ return;
274
+ }
275
+ lastMoveTime = now;
276
+ processMouseMove(ev);
277
+ }
278
+ /**
279
+ * 实际的 mousemove 逻辑
280
+ */
281
+ function processMouseMove(ev) {
282
+ const rect = renderer.domElement.getBoundingClientRect();
283
+ mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
284
+ mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
285
+ raycaster.setFromCamera(mouse, camera);
286
+ // 深度检测 scene 的所有子对象(true)
287
+ const intersects = raycaster.intersectObjects(scene.children, true);
288
+ if (intersects.length > 0) {
289
+ const obj = intersects[0].object;
290
+ // 判断是否允许被高亮
291
+ const allowed = highlightSet === null ? true : highlightSet.has(obj.name);
292
+ if (allowed) {
293
+ if (hovered !== obj) {
294
+ hovered = obj;
295
+ outlinePass.selectedObjects = [obj];
296
+ // 启动动画(如果未运行)
297
+ if (animationId === null) {
298
+ animate();
299
+ }
300
+ }
301
+ }
302
+ else {
303
+ if (hovered !== null) {
304
+ hovered = null;
305
+ outlinePass.selectedObjects = [];
306
+ // 停止动画
307
+ if (animationId !== null) {
308
+ cancelAnimationFrame(animationId);
309
+ animationId = null;
310
+ }
311
+ }
312
+ }
313
+ }
314
+ else {
315
+ if (hovered !== null) {
316
+ hovered = null;
317
+ outlinePass.selectedObjects = [];
318
+ // 停止动画
319
+ if (animationId !== null) {
320
+ cancelAnimationFrame(animationId);
321
+ animationId = null;
322
+ }
323
+ }
324
+ }
325
+ }
326
+ /**
327
+ * 动画循环 - 只在有 hovered 对象时运行
328
+ */
329
+ function animate() {
330
+ // 如果没有 hovered 对象,停止动画
331
+ if (!hovered) {
332
+ animationId = null;
333
+ return;
334
+ }
335
+ animationId = requestAnimationFrame(animate);
336
+ time += speed * 0.02;
337
+ const strength = minStrength + ((Math.sin(time) + 1) / 2) * (maxStrength - minStrength);
338
+ outlinePass.edgeStrength = strength;
339
+ }
340
+ // 启动(只调用一次)
341
+ // 使用 passive 提升滚动性能
342
+ renderer.domElement.addEventListener('mousemove', onMouseMove, { passive: true });
343
+ // 注意:不在这里启动 animate,等有 hover 对象时再启动
344
+ // refresh: 如果你在某些情况下需要强制清理 selectedObjects
345
+ function refreshSelection() {
346
+ if (hovered && highlightSet && !highlightSet.has(hovered.name)) {
347
+ hovered = null;
348
+ outlinePass.selectedObjects = [];
349
+ if (animationId !== null) {
350
+ cancelAnimationFrame(animationId);
351
+ animationId = null;
352
+ }
353
+ }
354
+ }
355
+ function getHoveredName() {
356
+ return hovered ? hovered.name : null;
357
+ }
358
+ function dispose() {
359
+ renderer.domElement.removeEventListener('mousemove', onMouseMove);
360
+ if (animationId) {
361
+ cancelAnimationFrame(animationId);
362
+ animationId = null;
363
+ }
364
+ outlinePass.selectedObjects = [];
365
+ // 清空引用
366
+ hovered = null;
367
+ highlightSet = null;
368
+ }
369
+ return {
370
+ updateHighlightNames: setHighlightNames,
371
+ dispose,
372
+ refreshSelection,
373
+ getHoveredName,
374
+ };
375
+ }
376
+
377
+ /**
378
+ * 初始化描边相关信息(包含 OutlinePass)- 优化版
379
+ *
380
+ * ✨ 功能增强:
381
+ * - 支持窗口 resize 自动更新
382
+ * - 可配置分辨率缩放提升性能
383
+ * - 完善的资源释放管理
384
+ *
385
+ * @param renderer THREE.WebGLRenderer
386
+ * @param scene THREE.Scene
387
+ * @param camera THREE.Camera
388
+ * @param options PostProcessingOptions - 可选配置
389
+ * @returns PostProcessingManager - 包含 composer/outlinePass/resize/dispose 的管理接口
390
+ */
391
+ function initPostProcessing(renderer, scene, camera, options = {}) {
392
+ // 默认配置
393
+ const { edgeStrength = 4, edgeGlow = 1, edgeThickness = 2, visibleEdgeColor = '#ffee00', hiddenEdgeColor = '#000000', resolutionScale = 1.0 } = options;
394
+ // 获取渲染器实际尺寸
395
+ const getSize = () => {
396
+ const width = renderer.domElement.clientWidth;
397
+ const height = renderer.domElement.clientHeight;
398
+ return {
399
+ width: Math.floor(width * resolutionScale),
400
+ height: Math.floor(height * resolutionScale)
401
+ };
402
+ };
403
+ const size = getSize();
404
+ // 创建 EffectComposer
405
+ const composer = new EffectComposer.EffectComposer(renderer);
406
+ // 基础 RenderPass
407
+ const renderPass = new RenderPass.RenderPass(scene, camera);
408
+ composer.addPass(renderPass);
409
+ // OutlinePass 用于模型描边
410
+ const outlinePass = new OutlinePass.OutlinePass(new THREE__namespace.Vector2(size.width, size.height), scene, camera);
411
+ outlinePass.edgeStrength = edgeStrength;
412
+ outlinePass.edgeGlow = edgeGlow;
413
+ outlinePass.edgeThickness = edgeThickness;
414
+ outlinePass.visibleEdgeColor.set(visibleEdgeColor);
415
+ outlinePass.hiddenEdgeColor.set(hiddenEdgeColor);
416
+ composer.addPass(outlinePass);
417
+ // Gamma 校正
418
+ const gammaPass = new ShaderPass.ShaderPass(GammaCorrectionShader.GammaCorrectionShader);
419
+ composer.addPass(gammaPass);
420
+ /**
421
+ * resize 处理函数
422
+ * @param width 可选宽度,不传则使用 renderer.domElement 的实际宽度
423
+ * @param height 可选高度,不传则使用 renderer.domElement 的实际高度
424
+ */
425
+ const resize = (width, height) => {
426
+ const actualSize = width !== undefined && height !== undefined
427
+ ? { width: Math.floor(width * resolutionScale), height: Math.floor(height * resolutionScale) }
428
+ : getSize();
429
+ // 更新 composer 尺寸
430
+ composer.setSize(actualSize.width, actualSize.height);
431
+ // 更新 outlinePass 分辨率
432
+ outlinePass.resolution.set(actualSize.width, actualSize.height);
433
+ };
434
+ /**
435
+ * 释放资源
436
+ */
437
+ const dispose = () => {
438
+ // 释放所有 passes
439
+ composer.passes.forEach(pass => {
440
+ if (pass.dispose) {
441
+ pass.dispose();
442
+ }
443
+ });
444
+ // 清空 passes 数组
445
+ composer.passes.length = 0;
446
+ };
447
+ return {
448
+ composer,
449
+ outlinePass,
450
+ resize,
451
+ dispose
452
+ };
453
+ }
454
+
455
+ /**
456
+ * 创建模型点击高亮工具(OutlinePass 版)- 优化版
457
+ *
458
+ * ✨ 功能增强:
459
+ * - 使用 AbortController 统一管理事件生命周期
460
+ * - 支持防抖处理避免频繁触发
461
+ * - 可自定义 Raycaster 参数
462
+ * - 根据相机距离动态调整描边厚度
463
+ *
464
+ * @param camera 相机
465
+ * @param scene 场景
466
+ * @param renderer 渲染器
467
+ * @param outlinePass 已初始化的 OutlinePass
468
+ * @param onClick 点击回调
469
+ * @param options 可选配置
470
+ * @returns dispose 函数,用于清理事件和资源
471
+ */
472
+ function createModelClickHandler(camera, scene, renderer, outlinePass, onClick, options = {}) {
473
+ // 配置项
474
+ const { clickThreshold = 3, debounceDelay = 0, raycasterParams = {}, enableDynamicThickness = true, minThickness = 1, maxThickness = 10 } = options;
475
+ const raycaster = new THREE__namespace.Raycaster();
476
+ const mouse = new THREE__namespace.Vector2();
477
+ // 应用 raycaster 自定义参数
478
+ if (raycasterParams.near !== undefined)
479
+ raycaster.near = raycasterParams.near;
480
+ if (raycasterParams.far !== undefined)
481
+ raycaster.far = raycasterParams.far;
482
+ if (raycasterParams.pointsPrecision !== undefined) {
483
+ if (!raycaster.params.Points) {
484
+ raycaster.params.Points = { threshold: raycasterParams.pointsPrecision };
485
+ }
486
+ else {
487
+ raycaster.params.Points.threshold = raycasterParams.pointsPrecision;
488
+ }
489
+ }
490
+ let startX = 0;
491
+ let startY = 0;
492
+ let selectedObject = null;
493
+ let debounceTimer = null;
494
+ // 使用 AbortController 统一管理事件
495
+ const abortController = new AbortController();
496
+ const signal = abortController.signal;
497
+ /** 恢复对象高亮(清空 OutlinePass.selectedObjects) */
498
+ function restoreObject() {
499
+ outlinePass.selectedObjects = [];
500
+ }
501
+ /** 鼠标按下记录位置 */
502
+ function handleMouseDown(event) {
503
+ startX = event.clientX;
504
+ startY = event.clientY;
505
+ }
506
+ /** 鼠标抬起判定点击或拖动(带防抖) */
507
+ function handleMouseUp(event) {
508
+ const dx = Math.abs(event.clientX - startX);
509
+ const dy = Math.abs(event.clientY - startY);
510
+ if (dx > clickThreshold || dy > clickThreshold)
511
+ return; // 拖动不触发点击
512
+ // 防抖处理
513
+ if (debounceDelay > 0) {
514
+ if (debounceTimer !== null) {
515
+ clearTimeout(debounceTimer);
516
+ }
517
+ debounceTimer = window.setTimeout(() => {
518
+ processClick(event);
519
+ debounceTimer = null;
520
+ }, debounceDelay);
521
+ }
522
+ else {
523
+ processClick(event);
524
+ }
525
+ }
526
+ /** 实际的点击处理逻辑 */
527
+ function processClick(event) {
528
+ const rect = renderer.domElement.getBoundingClientRect();
529
+ mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
530
+ mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
531
+ raycaster.setFromCamera(mouse, camera);
532
+ const intersects = raycaster.intersectObjects(scene.children, true);
533
+ if (intersects.length > 0) {
534
+ let object = intersects[0].object;
535
+ // 点击不同模型,先清除之前高亮
536
+ if (selectedObject && selectedObject !== object)
537
+ restoreObject();
538
+ selectedObject = object;
539
+ // highlightObject(selectedObject); // 可选:是否自动高亮
540
+ onClick(selectedObject, {
541
+ name: selectedObject.name || '未命名模型',
542
+ position: selectedObject.getWorldPosition(new THREE__namespace.Vector3()),
543
+ uuid: selectedObject.uuid
544
+ });
545
+ }
546
+ else {
547
+ // 点击空白 → 清除高亮
548
+ if (selectedObject)
549
+ restoreObject();
550
+ selectedObject = null;
551
+ onClick(null);
552
+ }
553
+ }
554
+ // 使用 AbortController 的 signal 注册事件
555
+ renderer.domElement.addEventListener('mousedown', handleMouseDown, { signal });
556
+ renderer.domElement.addEventListener('mouseup', handleMouseUp, { signal });
557
+ /** 销毁函数:解绑事件并清除高亮 */
558
+ return () => {
559
+ // 清理防抖定时器
560
+ if (debounceTimer !== null) {
561
+ clearTimeout(debounceTimer);
562
+ debounceTimer = null;
563
+ }
564
+ // 一次性解绑所有事件
565
+ abortController.abort();
566
+ // 清除高亮
567
+ restoreObject();
568
+ // 清空引用
569
+ selectedObject = null;
570
+ };
571
+ }
572
+
573
+ // src/utils/ArrowGuide.ts
574
+ /**
575
+ * ArrowGuide - 优化版
576
+ * 箭头引导效果工具,支持高亮模型并淡化其他对象
577
+ *
578
+ * ✨ 优化内容:
579
+ * - 使用 WeakMap 自动回收材质,避免内存泄漏
580
+ * - 使用 AbortController 管理事件生命周期
581
+ * - 添加材质复用机制,减少重复创建
582
+ * - 改进 dispose 逻辑,确保完全释放资源
583
+ * - 添加错误处理和边界检查
584
+ */
585
+ class ArrowGuide {
586
+ constructor(renderer, camera, scene, options) {
587
+ var _a, _b, _c;
588
+ this.renderer = renderer;
589
+ this.camera = camera;
590
+ this.scene = scene;
591
+ this.lxMesh = null;
592
+ this.flowActive = false;
593
+ this.modelBrightArr = [];
594
+ this.pointerDownPos = new THREE__namespace.Vector2();
595
+ this.clickThreshold = 10;
596
+ this.raycaster = new THREE__namespace.Raycaster();
597
+ this.mouse = new THREE__namespace.Vector2();
598
+ // ✨ 使用 WeakMap 自动回收材质(GC 友好)
599
+ this.originalMaterials = new WeakMap();
600
+ this.fadedMaterials = new WeakMap();
601
+ // ✨ AbortController 用于事件管理
602
+ this.abortController = null;
603
+ // 配置:非高亮透明度和亮度
604
+ this.fadeOpacity = 0.5;
605
+ this.fadeBrightness = 0.1;
606
+ this.clickThreshold = (_a = options === null || options === void 0 ? void 0 : options.clickThreshold) !== null && _a !== void 0 ? _a : 10;
607
+ this.ignoreRaycastNames = new Set((options === null || options === void 0 ? void 0 : options.ignoreRaycastNames) || []);
608
+ this.fadeOpacity = (_b = options === null || options === void 0 ? void 0 : options.fadeOpacity) !== null && _b !== void 0 ? _b : 0.5;
609
+ this.fadeBrightness = (_c = options === null || options === void 0 ? void 0 : options.fadeBrightness) !== null && _c !== void 0 ? _c : 0.1;
610
+ this.abortController = new AbortController();
611
+ this.initEvents();
612
+ }
613
+ // —— 工具:缓存原材质(仅首次)
614
+ cacheOriginalMaterial(mesh) {
615
+ if (!this.originalMaterials.has(mesh)) {
616
+ this.originalMaterials.set(mesh, mesh.material);
617
+ }
618
+ }
619
+ // —— 工具:为某个材质克隆一个"半透明版本",保留所有贴图与参数
620
+ makeFadedClone(mat) {
621
+ const clone = mat.clone();
622
+ const c = clone;
623
+ // 只改透明相关参数,不改 map / normalMap / roughnessMap 等细节
624
+ c.transparent = true;
625
+ if (typeof c.opacity === 'number')
626
+ c.opacity = this.fadeOpacity;
627
+ if (c.color && c.color.isColor) {
628
+ c.color.multiplyScalar(this.fadeBrightness); // 颜色整体变暗
629
+ }
630
+ // 为了让箭头在透明建筑后也能顺畅显示,常用策略:不写深度,仅测试深度
631
+ clone.depthWrite = false;
632
+ clone.depthTest = true;
633
+ clone.needsUpdate = true;
634
+ return clone;
635
+ }
636
+ // —— 工具:为 mesh.material(可能是数组)批量克隆"半透明版本"
637
+ createFadedMaterialFrom(mesh) {
638
+ const orig = mesh.material;
639
+ if (Array.isArray(orig)) {
640
+ return orig.map(m => this.makeFadedClone(m));
641
+ }
642
+ return this.makeFadedClone(orig);
643
+ }
644
+ /**
645
+ * 设置箭头 Mesh
646
+ */
647
+ setArrowMesh(mesh) {
648
+ this.lxMesh = mesh;
649
+ this.cacheOriginalMaterial(mesh);
650
+ try {
651
+ const mat = mesh.material;
652
+ if (mat && mat.map) {
653
+ const map = mat.map;
654
+ map.wrapS = THREE__namespace.RepeatWrapping;
655
+ map.wrapT = THREE__namespace.RepeatWrapping;
656
+ map.needsUpdate = true;
657
+ }
658
+ mesh.visible = false;
659
+ }
660
+ catch (error) {
661
+ console.error('ArrowGuide: 设置箭头材质失败', error);
662
+ }
663
+ }
664
+ /**
665
+ * 高亮指定模型
666
+ */
667
+ highlight(models) {
668
+ if (!models || models.length === 0) {
669
+ console.warn('ArrowGuide: 高亮模型列表为空');
670
+ return;
671
+ }
672
+ this.modelBrightArr = models;
673
+ this.flowActive = true;
674
+ if (this.lxMesh)
675
+ this.lxMesh.visible = true;
676
+ this.applyHighlight();
677
+ }
678
+ // ✅ 应用高亮效果:非高亮模型保留细节 → 使用"克隆后的半透明材质"
679
+ applyHighlight() {
680
+ // ✨ 使用 Set 提升查找性能
681
+ const keepMeshes = new Set();
682
+ this.modelBrightArr.forEach(obj => {
683
+ obj.traverse(child => {
684
+ if (child.isMesh)
685
+ keepMeshes.add(child);
686
+ });
687
+ });
688
+ try {
689
+ this.scene.traverse(obj => {
690
+ if (obj.isMesh) {
691
+ const mesh = obj;
692
+ // 缓存原材质(用于恢复)
693
+ this.cacheOriginalMaterial(mesh);
694
+ if (!keepMeshes.has(mesh)) {
695
+ // 非高亮:如果还没给它生成过"半透明克隆材质",就创建一次
696
+ if (!this.fadedMaterials.has(mesh)) {
697
+ const faded = this.createFadedMaterialFrom(mesh);
698
+ this.fadedMaterials.set(mesh, faded);
699
+ }
700
+ // 替换为克隆材质(保留所有贴图/法线等细节)
701
+ const fadedMat = this.fadedMaterials.get(mesh);
702
+ if (fadedMat)
703
+ mesh.material = fadedMat;
704
+ }
705
+ else {
706
+ // 高亮对象:确保回到原材质(避免上一次高亮后遗留)
707
+ const orig = this.originalMaterials.get(mesh);
708
+ if (orig && mesh.material !== orig) {
709
+ mesh.material = orig;
710
+ mesh.material.needsUpdate = true;
711
+ }
712
+ }
713
+ }
714
+ });
715
+ }
716
+ catch (error) {
717
+ console.error('ArrowGuide: 应用高亮失败', error);
718
+ }
719
+ }
720
+ // ✅ 恢复为原材质 & 释放克隆材质
721
+ restore() {
722
+ this.flowActive = false;
723
+ if (this.lxMesh)
724
+ this.lxMesh.visible = false;
725
+ try {
726
+ // ✨ 收集所有需要释放的材质
727
+ const materialsToDispose = [];
728
+ this.scene.traverse(obj => {
729
+ if (obj.isMesh) {
730
+ const mesh = obj;
731
+ const orig = this.originalMaterials.get(mesh);
732
+ if (orig) {
733
+ mesh.material = orig;
734
+ mesh.material.needsUpdate = true;
735
+ }
736
+ // ✨ 收集待释放的淡化材质
737
+ const faded = this.fadedMaterials.get(mesh);
738
+ if (faded) {
739
+ if (Array.isArray(faded)) {
740
+ materialsToDispose.push(...faded);
741
+ }
742
+ else {
743
+ materialsToDispose.push(faded);
744
+ }
745
+ }
746
+ }
747
+ });
748
+ // ✨ 批量释放材质(不触碰贴图资源)
749
+ materialsToDispose.forEach(mat => {
750
+ try {
751
+ mat.dispose();
752
+ }
753
+ catch (error) {
754
+ console.error('ArrowGuide: 释放材质失败', error);
755
+ }
756
+ });
757
+ // ✨ 创建新的 WeakMap(相当于清空)
758
+ this.fadedMaterials = new WeakMap();
759
+ }
760
+ catch (error) {
761
+ console.error('ArrowGuide: 恢复材质失败', error);
762
+ }
763
+ }
764
+ /**
765
+ * 动画更新(每帧调用)
766
+ */
767
+ animate() {
768
+ if (!this.flowActive || !this.lxMesh)
769
+ return;
770
+ try {
771
+ const mat = this.lxMesh.material;
772
+ if (mat && mat.map) {
773
+ const map = mat.map;
774
+ map.offset.y -= 0.01;
775
+ map.needsUpdate = true;
776
+ }
777
+ }
778
+ catch (error) {
779
+ console.error('ArrowGuide: 动画更新失败', error);
780
+ }
781
+ }
782
+ /**
783
+ * 初始化事件监听器
784
+ */
785
+ initEvents() {
786
+ const dom = this.renderer.domElement;
787
+ const signal = this.abortController.signal;
788
+ // ✨ 使用 AbortController signal 自动管理事件生命周期
789
+ dom.addEventListener('pointerdown', (e) => {
790
+ this.pointerDownPos.set(e.clientX, e.clientY);
791
+ }, { signal });
792
+ dom.addEventListener('pointerup', (e) => {
793
+ const dx = Math.abs(e.clientX - this.pointerDownPos.x);
794
+ const dy = Math.abs(e.clientY - this.pointerDownPos.y);
795
+ if (dx > this.clickThreshold || dy > this.clickThreshold)
796
+ return; // 拖拽
797
+ const rect = dom.getBoundingClientRect();
798
+ this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
799
+ this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
800
+ this.raycaster.setFromCamera(this.mouse, this.camera);
801
+ const intersects = this.raycaster.intersectObjects(this.scene.children, true);
802
+ const filtered = intersects.filter(i => {
803
+ if (!i.object)
804
+ return false;
805
+ if (this.ignoreRaycastNames.has(i.object.name))
806
+ return false;
807
+ return true;
808
+ });
809
+ if (filtered.length === 0)
810
+ this.restore(); // 点击空白恢复
811
+ }, { signal });
812
+ }
813
+ /**
814
+ * 释放所有资源
815
+ */
816
+ dispose() {
817
+ // ✨ 先恢复材质
818
+ this.restore();
819
+ // ✨ 使用 AbortController 一次性解绑所有事件
820
+ if (this.abortController) {
821
+ this.abortController.abort();
822
+ this.abortController = null;
823
+ }
824
+ // ✨ 清空引用
825
+ this.modelBrightArr = [];
826
+ this.lxMesh = null;
827
+ this.fadedMaterials = new WeakMap();
828
+ this.originalMaterials = new WeakMap();
829
+ this.ignoreRaycastNames.clear();
830
+ }
831
+ }
832
+
833
+ // utils/LiquidFillerGroup.ts
834
+ /**
835
+ * LiquidFillerGroup - 优化版
836
+ * 支持单模型或多模型液位动画、独立颜色控制
837
+ *
838
+ * ✨ 优化内容:
839
+ * - 使用 renderer.domElement 替代 window 事件
840
+ * - 使用 AbortController 管理事件生命周期
841
+ * - 添加错误处理和边界检查
842
+ * - 优化动画管理,避免内存泄漏
843
+ * - 完善资源释放逻辑
844
+ */
845
+ class LiquidFillerGroup {
846
+ /**
847
+ * 构造函数
848
+ * @param models 单个或多个 THREE.Object3D
849
+ * @param scene 场景
850
+ * @param camera 相机
851
+ * @param renderer 渲染器
852
+ * @param defaultOptions 默认液体选项
853
+ * @param clickThreshold 点击阈值,单位像素
854
+ */
855
+ constructor(models, scene, camera, renderer, defaultOptions, clickThreshold = 10) {
856
+ this.items = [];
857
+ this.raycaster = new THREE__namespace.Raycaster();
858
+ this.pointerDownPos = new THREE__namespace.Vector2();
859
+ this.clickThreshold = 10;
860
+ this.abortController = null; // ✨ 事件管理器
861
+ /** pointerdown 记录位置 */
862
+ this.handlePointerDown = (event) => {
863
+ this.pointerDownPos.set(event.clientX, event.clientY);
864
+ };
865
+ /** pointerup 判断点击空白,恢复原始材质 */
866
+ this.handlePointerUp = (event) => {
867
+ const dx = event.clientX - this.pointerDownPos.x;
868
+ const dy = event.clientY - this.pointerDownPos.y;
869
+ const distance = Math.sqrt(dx * dx + dy * dy);
870
+ if (distance > this.clickThreshold)
871
+ return; // 拖拽不触发
872
+ // ✨ 使用 renderer.domElement 的实际尺寸
873
+ const rect = this.renderer.domElement.getBoundingClientRect();
874
+ const pointerNDC = new THREE__namespace.Vector2(((event.clientX - rect.left) / rect.width) * 2 - 1, -((event.clientY - rect.top) / rect.height) * 2 + 1);
875
+ this.raycaster.setFromCamera(pointerNDC, this.camera);
876
+ // 点击空白 -> 所有模型恢复
877
+ const intersectsAny = this.items.some(item => this.raycaster.intersectObject(item.model, true).length > 0);
878
+ if (!intersectsAny) {
879
+ this.restoreAll();
880
+ }
881
+ };
882
+ this.scene = scene;
883
+ this.camera = camera;
884
+ this.renderer = renderer;
885
+ this.clickThreshold = clickThreshold;
886
+ // ✨ 创建 AbortController 用于事件管理
887
+ this.abortController = new AbortController();
888
+ const modelArray = Array.isArray(models) ? models : [models];
889
+ modelArray.forEach(model => {
890
+ var _a, _b, _c;
891
+ try {
892
+ const options = {
893
+ color: (_a = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.color) !== null && _a !== void 0 ? _a : 0x00ff00,
894
+ opacity: (_b = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.opacity) !== null && _b !== void 0 ? _b : 0.6,
895
+ speed: (_c = defaultOptions === null || defaultOptions === void 0 ? void 0 : defaultOptions.speed) !== null && _c !== void 0 ? _c : 0.05,
896
+ };
897
+ // 保存原始材质
898
+ const originalMaterials = new Map();
899
+ model.traverse(obj => {
900
+ if (obj.isMesh) {
901
+ const mesh = obj;
902
+ originalMaterials.set(mesh, mesh.material);
903
+ }
904
+ });
905
+ // ✨ 边界检查:确保有材质可以保存
906
+ if (originalMaterials.size === 0) {
907
+ console.warn('LiquidFillerGroup: 模型没有 Mesh 对象', model);
908
+ return;
909
+ }
910
+ // 应用淡线框材质
911
+ model.traverse(obj => {
912
+ if (obj.isMesh) {
913
+ const mesh = obj;
914
+ mesh.material = new THREE__namespace.MeshBasicMaterial({
915
+ color: 0xffffff,
916
+ wireframe: true,
917
+ transparent: true,
918
+ opacity: 0.2,
919
+ });
920
+ }
921
+ });
922
+ // 创建液体 Mesh
923
+ const geometries = [];
924
+ model.traverse(obj => {
925
+ if (obj.isMesh) {
926
+ const mesh = obj;
927
+ const geom = mesh.geometry.clone();
928
+ geom.applyMatrix4(mesh.matrixWorld);
929
+ geometries.push(geom);
930
+ }
931
+ });
932
+ if (geometries.length === 0) {
933
+ console.warn('LiquidFillerGroup: 模型没有几何体', model);
934
+ return;
935
+ }
936
+ const mergedGeometry = BufferGeometryUtils__namespace.mergeGeometries(geometries, false);
937
+ if (!mergedGeometry) {
938
+ console.error('LiquidFillerGroup: 几何体合并失败', model);
939
+ return;
940
+ }
941
+ const material = new THREE__namespace.MeshPhongMaterial({
942
+ color: options.color,
943
+ transparent: true,
944
+ opacity: options.opacity,
945
+ side: THREE__namespace.DoubleSide,
946
+ });
947
+ const liquidMesh = new THREE__namespace.Mesh(mergedGeometry, material);
948
+ this.scene.add(liquidMesh);
949
+ // 设置 clippingPlane
950
+ const clipPlane = new THREE__namespace.Plane(new THREE__namespace.Vector3(0, -1, 0), 0);
951
+ const mat = liquidMesh.material;
952
+ mat.clippingPlanes = [clipPlane];
953
+ this.renderer.localClippingEnabled = true;
954
+ this.items.push({
955
+ model,
956
+ liquidMesh,
957
+ clipPlane,
958
+ originalMaterials,
959
+ options,
960
+ animationId: null // ✨ 初始化动画 ID
961
+ });
962
+ }
963
+ catch (error) {
964
+ console.error('LiquidFillerGroup: 初始化模型失败', model, error);
965
+ }
966
+ });
967
+ // ✨ 使用 renderer.domElement 替代 window,使用 AbortController signal
968
+ const signal = this.abortController.signal;
969
+ this.renderer.domElement.addEventListener('pointerdown', this.handlePointerDown, { signal });
970
+ this.renderer.domElement.addEventListener('pointerup', this.handlePointerUp, { signal });
971
+ }
972
+ /**
973
+ * 设置液位
974
+ * @param models 单个模型或模型数组
975
+ * @param percent 液位百分比 0~1
976
+ */
977
+ fillTo(models, percent) {
978
+ // ✨ 边界检查
979
+ if (percent < 0 || percent > 1) {
980
+ console.warn('LiquidFillerGroup: percent 必须在 0~1 之间', percent);
981
+ percent = Math.max(0, Math.min(1, percent));
982
+ }
983
+ const modelArray = Array.isArray(models) ? models : [models];
984
+ modelArray.forEach(model => {
985
+ const item = this.items.find(i => i.model === model);
986
+ if (!item) {
987
+ console.warn('LiquidFillerGroup: 未找到模型', model);
988
+ return;
989
+ }
990
+ if (!item.liquidMesh) {
991
+ console.warn('LiquidFillerGroup: liquidMesh 已被释放', model);
992
+ return;
993
+ }
994
+ // ✨ 取消之前的动画
995
+ if (item.animationId !== null) {
996
+ cancelAnimationFrame(item.animationId);
997
+ item.animationId = null;
998
+ }
999
+ try {
1000
+ const box = new THREE__namespace.Box3().setFromObject(item.liquidMesh);
1001
+ const min = box.min.y;
1002
+ const max = box.max.y;
1003
+ const targetHeight = min + (max - min) * percent;
1004
+ const animate = () => {
1005
+ if (!item.liquidMesh) {
1006
+ item.animationId = null;
1007
+ return;
1008
+ }
1009
+ const diff = targetHeight - item.clipPlane.constant;
1010
+ if (Math.abs(diff) > 0.01) {
1011
+ item.clipPlane.constant += diff * item.options.speed;
1012
+ item.animationId = requestAnimationFrame(animate);
1013
+ }
1014
+ else {
1015
+ item.clipPlane.constant = targetHeight;
1016
+ item.animationId = null;
1017
+ }
1018
+ };
1019
+ animate();
1020
+ }
1021
+ catch (error) {
1022
+ console.error('LiquidFillerGroup: fillTo 执行失败', model, error);
1023
+ }
1024
+ });
1025
+ }
1026
+ /** 设置多个模型液位,percentList 与 items 顺序对应 */
1027
+ fillToAll(percentList) {
1028
+ if (percentList.length !== this.items.length) {
1029
+ console.warn(`LiquidFillerGroup: percentList 长度 (${percentList.length}) 与 items 长度 (${this.items.length}) 不匹配`);
1030
+ }
1031
+ percentList.forEach((p, idx) => {
1032
+ if (idx < this.items.length) {
1033
+ this.fillTo(this.items[idx].model, p);
1034
+ }
1035
+ });
1036
+ }
1037
+ /** 恢复单个模型原始材质并移除液体 */
1038
+ restore(model) {
1039
+ const item = this.items.find(i => i.model === model);
1040
+ if (!item)
1041
+ return;
1042
+ // ✨ 取消动画
1043
+ if (item.animationId !== null) {
1044
+ cancelAnimationFrame(item.animationId);
1045
+ item.animationId = null;
1046
+ }
1047
+ // 恢复原始材质
1048
+ item.model.traverse(obj => {
1049
+ if (obj.isMesh) {
1050
+ const mesh = obj;
1051
+ const original = item.originalMaterials.get(mesh);
1052
+ if (original)
1053
+ mesh.material = original;
1054
+ }
1055
+ });
1056
+ // 释放液体 Mesh
1057
+ if (item.liquidMesh) {
1058
+ this.scene.remove(item.liquidMesh);
1059
+ item.liquidMesh.geometry.dispose();
1060
+ if (Array.isArray(item.liquidMesh.material)) {
1061
+ item.liquidMesh.material.forEach(m => m.dispose());
1062
+ }
1063
+ else {
1064
+ item.liquidMesh.material.dispose();
1065
+ }
1066
+ item.liquidMesh = null;
1067
+ }
1068
+ }
1069
+ /** 恢复所有模型 */
1070
+ restoreAll() {
1071
+ this.items.forEach(item => this.restore(item.model));
1072
+ }
1073
+ /** 销毁方法,释放事件和资源 */
1074
+ dispose() {
1075
+ // ✨ 先恢复所有模型
1076
+ this.restoreAll();
1077
+ // ✨ 使用 AbortController 一次性解绑所有事件
1078
+ if (this.abortController) {
1079
+ this.abortController.abort();
1080
+ this.abortController = null;
1081
+ }
1082
+ // ✨ 清空 items
1083
+ this.items.length = 0;
1084
+ }
1085
+ }
1086
+
1087
+ // src/utils/followModels.ts - 优化版
1088
+ // ✨ 使用 WeakMap 跟踪动画,支持取消
1089
+ const _animationMap = new WeakMap();
1090
+ /**
1091
+ * 推荐角度枚举,便于快速选取常见视角
1092
+ */
1093
+ const FOLLOW_ANGLES = {
1094
+ /** 等距斜视(默认视角)- 适合建筑、机械设备展示 */
1095
+ ISOMETRIC: { azimuth: Math.PI / 4, elevation: Math.PI / 4 },
1096
+ /** 正前视角 - 适合正面展示、UI 对齐 */
1097
+ FRONT: { azimuth: 0, elevation: 0 },
1098
+ /** 右侧视角 - 适合机械剖面、侧视检查 */
1099
+ RIGHT: { azimuth: Math.PI / 2, elevation: 0 },
1100
+ /** 左侧视角 */
1101
+ LEFT: { azimuth: -Math.PI / 2, elevation: 0 },
1102
+ /** 后视角 */
1103
+ BACK: { azimuth: Math.PI, elevation: 0 },
1104
+ /** 顶视图 - 适合地图、平面布局展示 */
1105
+ TOP: { azimuth: 0, elevation: Math.PI / 2 },
1106
+ /** 低角度俯视 - 适合车辆、人物等近地物体 */
1107
+ LOW_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 6 },
1108
+ /** 高角度俯视 - 适合鸟瞰、全景浏览 */
1109
+ HIGH_ANGLE: { azimuth: Math.PI / 4, elevation: Math.PI / 3 }
1110
+ };
1111
+ /**
1112
+ * 缓动函数集合
1113
+ */
1114
+ const EASING_FUNCTIONS = {
1115
+ linear: (t) => t,
1116
+ easeInOut: (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2,
1117
+ easeOut: (t) => 1 - Math.pow(1 - t, 3),
1118
+ easeIn: (t) => t * t * t
1119
+ };
1120
+ /**
1121
+ * 自动将相机移到目标的斜上角位置,并保证目标在可视范围内(平滑过渡)- 优化版
1122
+ *
1123
+ * ✨ 优化内容:
1124
+ * - 支持多种缓动函数
1125
+ * - 添加进度回调
1126
+ * - 支持取消动画
1127
+ * - WeakMap 跟踪防止泄漏
1128
+ * - 完善错误处理
1129
+ */
1130
+ function followModels(camera, targets, options = {}) {
1131
+ var _a, _b, _c, _d, _e, _f;
1132
+ // ✨ 取消之前的动画
1133
+ cancelFollow(camera);
1134
+ // ✨ 边界检查
1135
+ const arr = [];
1136
+ if (!targets)
1137
+ return Promise.resolve();
1138
+ if (Array.isArray(targets))
1139
+ arr.push(...targets.filter(Boolean));
1140
+ else
1141
+ arr.push(targets);
1142
+ if (arr.length === 0) {
1143
+ console.warn('followModels: 目标对象为空');
1144
+ return Promise.resolve();
1145
+ }
1146
+ try {
1147
+ const box = new THREE__namespace.Box3();
1148
+ arr.forEach((o) => box.expandByObject(o));
1149
+ // ✨ 检查包围盒有效性
1150
+ if (!isFinite(box.min.x) || !isFinite(box.max.x)) {
1151
+ console.warn('followModels: 包围盒计算失败');
1152
+ return Promise.resolve();
1153
+ }
1154
+ const sphere = new THREE__namespace.Sphere();
1155
+ box.getBoundingSphere(sphere);
1156
+ const center = sphere.center.clone();
1157
+ const radiusBase = Math.max(0.001, sphere.radius);
1158
+ const duration = (_a = options.duration) !== null && _a !== void 0 ? _a : 700;
1159
+ const padding = (_b = options.padding) !== null && _b !== void 0 ? _b : 1.0;
1160
+ const minDistance = options.minDistance;
1161
+ const maxDistance = options.maxDistance;
1162
+ const controls = (_c = options.controls) !== null && _c !== void 0 ? _c : null;
1163
+ const azimuth = (_d = options.azimuth) !== null && _d !== void 0 ? _d : Math.PI / 4;
1164
+ const elevation = (_e = options.elevation) !== null && _e !== void 0 ? _e : Math.PI / 4;
1165
+ const easing = (_f = options.easing) !== null && _f !== void 0 ? _f : 'easeOut';
1166
+ const onProgress = options.onProgress;
1167
+ // ✨ 获取缓动函数
1168
+ const easingFn = EASING_FUNCTIONS[easing] || EASING_FUNCTIONS.easeOut;
1169
+ let distance = 10;
1170
+ if (camera.isPerspectiveCamera) {
1171
+ const cam = camera;
1172
+ const halfV = THREE__namespace.MathUtils.degToRad(cam.fov * 0.5);
1173
+ const halfH = Math.atan(Math.tan(halfV) * cam.aspect);
1174
+ const halfMin = Math.min(halfV, halfH);
1175
+ distance = (radiusBase * padding) / Math.sin(halfMin);
1176
+ if (minDistance != null)
1177
+ distance = Math.max(distance, minDistance);
1178
+ if (maxDistance != null)
1179
+ distance = Math.min(distance, maxDistance);
1180
+ }
1181
+ else if (camera.isOrthographicCamera) {
1182
+ distance = camera.position.distanceTo(center);
1183
+ }
1184
+ else {
1185
+ distance = camera.position.distanceTo(center);
1186
+ }
1187
+ // 根据 azimuth / elevation 计算方向
1188
+ const hx = Math.sin(azimuth);
1189
+ const hz = Math.cos(azimuth);
1190
+ const dir = new THREE__namespace.Vector3(hx * Math.cos(elevation), Math.sin(elevation), hz * Math.cos(elevation)).normalize();
1191
+ const desiredPos = center.clone().add(dir.multiplyScalar(distance));
1192
+ const startPos = camera.position.clone();
1193
+ const startTarget = controls && controls.target
1194
+ ? controls.target.clone()
1195
+ : camera.position.clone().add(camera.getWorldDirection(new THREE__namespace.Vector3()).multiplyScalar(1));
1196
+ const endTarget = center.clone();
1197
+ const startTime = performance.now();
1198
+ return new Promise((resolve) => {
1199
+ const step = (now) => {
1200
+ var _a;
1201
+ const elapsed = now - startTime;
1202
+ const t = Math.min(1, duration > 0 ? elapsed / duration : 1);
1203
+ const k = easingFn(t);
1204
+ camera.position.lerpVectors(startPos, desiredPos, k);
1205
+ if (controls && controls.target) {
1206
+ const newTarget = new THREE__namespace.Vector3().lerpVectors(startTarget, endTarget, k);
1207
+ controls.target.copy(newTarget);
1208
+ if (typeof controls.update === 'function')
1209
+ controls.update();
1210
+ }
1211
+ else {
1212
+ camera.lookAt(endTarget);
1213
+ }
1214
+ (_a = camera.updateProjectionMatrix) === null || _a === void 0 ? void 0 : _a.call(camera);
1215
+ // ✨ 调用进度回调
1216
+ if (onProgress) {
1217
+ try {
1218
+ onProgress(t);
1219
+ }
1220
+ catch (error) {
1221
+ console.error('followModels: 进度回调错误', error);
1222
+ }
1223
+ }
1224
+ if (t < 1) {
1225
+ const rafId = requestAnimationFrame(step);
1226
+ _animationMap.set(camera, rafId);
1227
+ }
1228
+ else {
1229
+ _animationMap.delete(camera);
1230
+ camera.position.copy(desiredPos);
1231
+ if (controls && controls.target) {
1232
+ controls.target.copy(endTarget);
1233
+ if (typeof controls.update === 'function')
1234
+ controls.update();
1235
+ }
1236
+ else {
1237
+ camera.lookAt(endTarget);
1238
+ }
1239
+ resolve();
1240
+ }
1241
+ };
1242
+ const rafId = requestAnimationFrame(step);
1243
+ _animationMap.set(camera, rafId);
1244
+ });
1245
+ }
1246
+ catch (error) {
1247
+ console.error('followModels: 执行失败', error);
1248
+ return Promise.reject(error);
1249
+ }
1250
+ }
1251
+ /**
1252
+ * ✨ 取消相机的跟随动画
1253
+ */
1254
+ function cancelFollow(camera) {
1255
+ const rafId = _animationMap.get(camera);
1256
+ if (rafId !== undefined) {
1257
+ cancelAnimationFrame(rafId);
1258
+ _animationMap.delete(camera);
1259
+ }
1260
+ }
1261
+
1262
+ // src/utils/setView.ts - 优化版
1263
+ /**
1264
+ * 平滑切换相机到模型的最佳视角 - 优化版
1265
+ *
1266
+ * ✨ 优化内容:
1267
+ * - 复用 followModels 逻辑,避免代码重复
1268
+ * - 支持更多视角
1269
+ * - 配置选项增强
1270
+ * - 返回 Promise 支持链式调用
1271
+ * - 支持取消动画
1272
+ *
1273
+ * @param camera THREE.PerspectiveCamera 相机实例
1274
+ * @param controls OrbitControls 控制器实例
1275
+ * @param targetObj THREE.Object3D 模型对象
1276
+ * @param position 视角位置
1277
+ * @param options 配置选项
1278
+ * @returns Promise<void>
1279
+ */
1280
+ function setView(camera, controls, targetObj, position = 'front', options = {}) {
1281
+ const { distanceFactor = 0.8, duration = 1000, easing = 'easeInOut', onProgress } = options;
1282
+ // ✨ 边界检查
1283
+ if (!targetObj) {
1284
+ console.warn('setView: 目标对象为空');
1285
+ return Promise.reject(new Error('Target object is required'));
1286
+ }
1287
+ try {
1288
+ // 计算包围盒
1289
+ const box = new THREE__namespace.Box3().setFromObject(targetObj);
1290
+ if (!isFinite(box.min.x)) {
1291
+ console.warn('setView: 包围盒计算失败');
1292
+ return Promise.reject(new Error('Invalid bounding box'));
1293
+ }
1294
+ const center = box.getCenter(new THREE__namespace.Vector3());
1295
+ const size = box.getSize(new THREE__namespace.Vector3());
1296
+ const maxSize = Math.max(size.x, size.y, size.z);
1297
+ // ✨ 使用映射表简化视角计算
1298
+ const viewAngles = {
1299
+ 'front': { azimuth: 0, elevation: 0 },
1300
+ 'back': { azimuth: Math.PI, elevation: 0 },
1301
+ 'left': { azimuth: -Math.PI / 2, elevation: 0 },
1302
+ 'right': { azimuth: Math.PI / 2, elevation: 0 },
1303
+ 'top': { azimuth: 0, elevation: Math.PI / 2 },
1304
+ 'bottom': { azimuth: 0, elevation: -Math.PI / 2 },
1305
+ 'iso': { azimuth: Math.PI / 4, elevation: Math.PI / 4 }
1306
+ };
1307
+ const angle = viewAngles[position] || viewAngles.front;
1308
+ // ✨ 复用 followModels,避免代码重复
1309
+ return followModels(camera, targetObj, {
1310
+ duration,
1311
+ padding: distanceFactor,
1312
+ controls,
1313
+ azimuth: angle.azimuth,
1314
+ elevation: angle.elevation,
1315
+ easing,
1316
+ onProgress
1317
+ });
1318
+ }
1319
+ catch (error) {
1320
+ console.error('setView: 执行失败', error);
1321
+ return Promise.reject(error);
1322
+ }
1323
+ }
1324
+ /**
1325
+ * ✨ 取消视角切换动画
1326
+ */
1327
+ function cancelSetView(camera) {
1328
+ cancelFollow(camera);
1329
+ }
1330
+ /**
1331
+ * ✨ 预设视角快捷方法
1332
+ */
1333
+ const ViewPresets = {
1334
+ /**
1335
+ * 前视图
1336
+ */
1337
+ front: (camera, controls, target, options) => setView(camera, controls, target, 'front', options),
1338
+ /**
1339
+ * 等距视图
1340
+ */
1341
+ isometric: (camera, controls, target, options) => setView(camera, controls, target, 'iso', options),
1342
+ /**
1343
+ * 顶视图
1344
+ */
1345
+ top: (camera, controls, target, options) => setView(camera, controls, target, 'top', options)
1346
+ };
1347
+
1348
+ /******************************************************************************
1349
+ Copyright (c) Microsoft Corporation.
1350
+
1351
+ Permission to use, copy, modify, and/or distribute this software for any
1352
+ purpose with or without fee is hereby granted.
1353
+
1354
+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
1355
+ REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
1356
+ AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
1357
+ INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
1358
+ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1359
+ OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1360
+ PERFORMANCE OF THIS SOFTWARE.
1361
+ ***************************************************************************** */
1362
+ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */
1363
+
1364
+
1365
+ function __awaiter(thisArg, _arguments, P, generator) {
1366
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
1367
+ return new (P || (P = Promise))(function (resolve, reject) {
1368
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
1369
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
1370
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
1371
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
1372
+ });
1373
+ }
1374
+
1375
+ typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
1376
+ var e = new Error(message);
1377
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
1378
+ };
1379
+
1380
+ const DEFAULT_OPTIONS$1 = {
1381
+ useKTX2: false,
1382
+ mergeGeometries: false,
1383
+ maxTextureSize: null,
1384
+ useSimpleMaterials: false,
1385
+ skipSkinned: true,
1386
+ };
1387
+ /** 自动根据扩展名决定启用哪些选项(智能判断) */
1388
+ function normalizeOptions(url, opts) {
1389
+ const ext = (url.split('.').pop() || '').toLowerCase();
1390
+ const merged = Object.assign(Object.assign({}, DEFAULT_OPTIONS$1), opts);
1391
+ if (ext === 'gltf' || ext === 'glb') {
1392
+ // gltf/glb 默认尝试 draco/ktx2,如果用户没填
1393
+ if (merged.dracoDecoderPath === undefined)
1394
+ merged.dracoDecoderPath = '/draco/';
1395
+ if (merged.useKTX2 === undefined)
1396
+ merged.useKTX2 = true;
1397
+ if (merged.ktx2TranscoderPath === undefined)
1398
+ merged.ktx2TranscoderPath = '/basis/';
1399
+ }
1400
+ else {
1401
+ // fbx/obj/ply/stl 等不需要 draco/ktx2
1402
+ merged.dracoDecoderPath = null;
1403
+ merged.ktx2TranscoderPath = null;
1404
+ merged.useKTX2 = false;
1405
+ }
1406
+ return merged;
1407
+ }
1408
+ function loadModelByUrl(url_1) {
1409
+ return __awaiter(this, arguments, void 0, function* (url, options = {}) {
1410
+ var _a, _b;
1411
+ if (!url)
1412
+ throw new Error('url required');
1413
+ const ext = (url.split('.').pop() || '').toLowerCase();
1414
+ const opts = normalizeOptions(url, options);
1415
+ const manager = (_a = opts.manager) !== null && _a !== void 0 ? _a : new THREE__namespace.LoadingManager();
1416
+ let loader;
1417
+ if (ext === 'gltf' || ext === 'glb') {
1418
+ const { GLTFLoader } = yield import('three/examples/jsm/loaders/GLTFLoader.js');
1419
+ const gltfLoader = new GLTFLoader(manager);
1420
+ if (opts.dracoDecoderPath) {
1421
+ const { DRACOLoader } = yield import('three/examples/jsm/loaders/DRACOLoader.js');
1422
+ const draco = new DRACOLoader();
1423
+ draco.setDecoderPath(opts.dracoDecoderPath);
1424
+ gltfLoader.setDRACOLoader(draco);
1425
+ }
1426
+ if (opts.useKTX2 && opts.ktx2TranscoderPath) {
1427
+ const { KTX2Loader } = yield import('three/examples/jsm/loaders/KTX2Loader.js');
1428
+ const ktx2Loader = new KTX2Loader().setTranscoderPath(opts.ktx2TranscoderPath);
1429
+ gltfLoader.__ktx2Loader = ktx2Loader;
1430
+ }
1431
+ loader = gltfLoader;
1432
+ }
1433
+ else if (ext === 'fbx') {
1434
+ const { FBXLoader } = yield import('three/examples/jsm/loaders/FBXLoader.js');
1435
+ loader = new FBXLoader(manager);
1436
+ }
1437
+ else if (ext === 'obj') {
1438
+ const { OBJLoader } = yield import('three/examples/jsm/loaders/OBJLoader.js');
1439
+ loader = new OBJLoader(manager);
1440
+ }
1441
+ else if (ext === 'ply') {
1442
+ const { PLYLoader } = yield import('three/examples/jsm/loaders/PLYLoader.js');
1443
+ loader = new PLYLoader(manager);
1444
+ }
1445
+ else if (ext === 'stl') {
1446
+ const { STLLoader } = yield import('three/examples/jsm/loaders/STLLoader.js');
1447
+ loader = new STLLoader(manager);
1448
+ }
1449
+ else {
1450
+ throw new Error(`Unsupported model extension: .${ext}`);
1451
+ }
1452
+ const object = yield new Promise((resolve, reject) => {
1453
+ loader.load(url, (res) => {
1454
+ var _a;
1455
+ if (ext === 'gltf' || ext === 'glb') {
1456
+ const sceneObj = res.scene || res;
1457
+ // --- 关键:把 animations 暴露到 scene.userData(或 scene.animations)上 ---
1458
+ // 这样调用方只要拿到 sceneObj,就能通过 sceneObj.userData.animations 读取到 clips
1459
+ sceneObj.userData = (sceneObj === null || sceneObj === void 0 ? void 0 : sceneObj.userData) || {};
1460
+ sceneObj.userData.animations = (_a = res.animations) !== null && _a !== void 0 ? _a : [];
1461
+ resolve(sceneObj);
1462
+ }
1463
+ else {
1464
+ resolve(res);
1465
+ }
1466
+ }, undefined, (err) => reject(err));
1467
+ });
1468
+ // 优化
1469
+ object.traverse((child) => {
1470
+ var _a, _b, _c;
1471
+ const mesh = child;
1472
+ if (mesh.isMesh && mesh.geometry && !mesh.geometry.isBufferGeometry) {
1473
+ try {
1474
+ mesh.geometry = (_c = (_b = (_a = new THREE__namespace.BufferGeometry()).fromGeometry) === null || _b === void 0 ? void 0 : _b.call(_a, mesh.geometry)) !== null && _c !== void 0 ? _c : mesh.geometry;
1475
+ }
1476
+ catch (_d) { }
1477
+ }
1478
+ });
1479
+ if (opts.maxTextureSize && opts.maxTextureSize > 0)
1480
+ downscaleTexturesInObject(object, opts.maxTextureSize);
1481
+ if (opts.useSimpleMaterials) {
1482
+ object.traverse((child) => {
1483
+ const m = child.material;
1484
+ if (!m)
1485
+ return;
1486
+ if (Array.isArray(m))
1487
+ child.material = m.map((mat) => toSimpleMaterial(mat));
1488
+ else
1489
+ child.material = toSimpleMaterial(m);
1490
+ });
1491
+ }
1492
+ if (opts.mergeGeometries) {
1493
+ try {
1494
+ yield tryMergeGeometries(object, { skipSkinned: (_b = opts.skipSkinned) !== null && _b !== void 0 ? _b : true });
1495
+ }
1496
+ catch (e) {
1497
+ console.warn('mergeGeometries failed', e);
1498
+ }
1499
+ }
1500
+ return object;
1501
+ });
1502
+ }
1503
+ /** 运行时下采样网格中的贴图到 maxSize(canvas drawImage)以节省 GPU 内存 */
1504
+ function downscaleTexturesInObject(obj, maxSize) {
1505
+ obj.traverse((ch) => {
1506
+ if (!ch.isMesh)
1507
+ return;
1508
+ const mesh = ch;
1509
+ const mat = mesh.material;
1510
+ if (!mat)
1511
+ return;
1512
+ const props = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'alphaMap'];
1513
+ props.forEach((p) => {
1514
+ const tex = mat[p];
1515
+ if (!tex || !tex.image)
1516
+ return;
1517
+ const image = tex.image;
1518
+ if (!image.width || !image.height)
1519
+ return;
1520
+ const max = maxSize;
1521
+ if (image.width <= max && image.height <= max)
1522
+ return;
1523
+ // downscale using canvas (sync, may be heavy for many textures)
1524
+ try {
1525
+ const scale = Math.min(max / image.width, max / image.height);
1526
+ const canvas = document.createElement('canvas');
1527
+ canvas.width = Math.floor(image.width * scale);
1528
+ canvas.height = Math.floor(image.height * scale);
1529
+ const ctx = canvas.getContext('2d');
1530
+ if (ctx) {
1531
+ ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
1532
+ const newTex = new THREE__namespace.Texture(canvas);
1533
+ newTex.needsUpdate = true;
1534
+ // copy common settings (encoding etc)
1535
+ newTex.encoding = tex.encoding;
1536
+ mat[p] = newTex;
1537
+ }
1538
+ }
1539
+ catch (e) {
1540
+ console.warn('downscale texture failed', e);
1541
+ }
1542
+ });
1543
+ });
1544
+ }
1545
+ /**
1546
+ * 尝试合并 object 中的几何体(只合并:非透明、非 SkinnedMesh、attribute 集合兼容的 BufferGeometry)
1547
+ * - 合并前会把每个 mesh 的几何体应用 world matrix(so merged geometry in world space)
1548
+ * - 合并会按材质 UUID 分组(不同材质不能合并)
1549
+ * - 合并函数会兼容 BufferGeometryUtils 的常见导出名
1550
+ */
1551
+ function tryMergeGeometries(root, opts) {
1552
+ return __awaiter(this, void 0, void 0, function* () {
1553
+ // collect meshes by material uuid
1554
+ const groups = new Map();
1555
+ root.traverse((ch) => {
1556
+ var _a;
1557
+ if (!ch.isMesh)
1558
+ return;
1559
+ const mesh = ch;
1560
+ if (opts.skipSkinned && mesh.isSkinnedMesh)
1561
+ return;
1562
+ const mat = mesh.material;
1563
+ // don't merge transparent or morph-enabled or skinned meshes
1564
+ if (!mesh.geometry || mesh.visible === false)
1565
+ return;
1566
+ if (mat && mat.transparent)
1567
+ return;
1568
+ const geom = mesh.geometry.clone();
1569
+ mesh.updateWorldMatrix(true, false);
1570
+ geom.applyMatrix4(mesh.matrixWorld);
1571
+ // ensure attributes compatible? we'll rely on merge function to return null if incompatible
1572
+ const key = (mat && mat.uuid) || 'default';
1573
+ const bucket = (_a = groups.get(key)) !== null && _a !== void 0 ? _a : { material: mat !== null && mat !== void 0 ? mat : new THREE__namespace.MeshStandardMaterial(), geoms: [] };
1574
+ bucket.geoms.push(geom);
1575
+ groups.set(key, bucket);
1576
+ // mark for removal (we'll remove meshes after)
1577
+ mesh.userData.__toRemoveForMerge = true;
1578
+ });
1579
+ if (groups.size === 0)
1580
+ return;
1581
+ // dynamic import BufferGeometryUtils and find merge function name
1582
+ const bufUtilsMod = yield import('three/examples/jsm/utils/BufferGeometryUtils.js');
1583
+ // use || chain (avoid mixing ?? with || without parentheses)
1584
+ const mergeFn = bufUtilsMod.mergeBufferGeometries ||
1585
+ bufUtilsMod.mergeGeometries ||
1586
+ bufUtilsMod.mergeBufferGeometries || // defensive duplicate
1587
+ bufUtilsMod.mergeGeometries;
1588
+ if (!mergeFn)
1589
+ throw new Error('No merge function found in BufferGeometryUtils');
1590
+ // for each group, try merge
1591
+ for (const [key, { material, geoms }] of groups) {
1592
+ if (geoms.length <= 1) {
1593
+ // nothing to merge
1594
+ continue;
1595
+ }
1596
+ // call merge function - signature typically mergeBufferGeometries(array, useGroups)
1597
+ const merged = mergeFn(geoms, false);
1598
+ if (!merged) {
1599
+ console.warn('merge returned null for group', key);
1600
+ continue;
1601
+ }
1602
+ // create merged mesh at root (world-space geometry already applied)
1603
+ const mergedMesh = new THREE__namespace.Mesh(merged, material);
1604
+ root.add(mergedMesh);
1605
+ }
1606
+ // now remove original meshes flagged for removal
1607
+ const toRemove = [];
1608
+ root.traverse((ch) => {
1609
+ var _a;
1610
+ if ((_a = ch.userData) === null || _a === void 0 ? void 0 : _a.__toRemoveForMerge)
1611
+ toRemove.push(ch);
1612
+ });
1613
+ toRemove.forEach((m) => {
1614
+ if (m.parent)
1615
+ m.parent.remove(m);
1616
+ // free original resources (geometries already cloned/applied), but careful with shared materials
1617
+ if (m.isMesh) {
1618
+ const mm = m;
1619
+ try {
1620
+ mm.geometry.dispose();
1621
+ }
1622
+ catch (_a) { }
1623
+ // we do NOT dispose material because it may be reused by mergedMesh
1624
+ }
1625
+ });
1626
+ });
1627
+ }
1628
+ /* ---------------------
1629
+ 释放工具
1630
+ --------------------- */
1631
+ /** 彻底释放对象:几何体,材质和其贴图(危险:共享资源会被释放) */
1632
+ function disposeObject(obj) {
1633
+ if (!obj)
1634
+ return;
1635
+ obj.traverse((ch) => {
1636
+ if (ch.isMesh) {
1637
+ const m = ch;
1638
+ if (m.geometry) {
1639
+ try {
1640
+ m.geometry.dispose();
1641
+ }
1642
+ catch (_a) { }
1643
+ }
1644
+ const mat = m.material;
1645
+ if (mat) {
1646
+ if (Array.isArray(mat))
1647
+ mat.forEach((x) => disposeMaterial(x));
1648
+ else
1649
+ disposeMaterial(mat);
1650
+ }
1651
+ }
1652
+ });
1653
+ }
1654
+ /** 释放材质及其贴图 */
1655
+ function disposeMaterial(mat) {
1656
+ if (!mat)
1657
+ return;
1658
+ const texNames = ['map', 'alphaMap', 'aoMap', 'emissiveMap', 'envMap', 'metalnessMap', 'roughnessMap', 'normalMap', 'bumpMap', 'displacementMap', 'lightMap'];
1659
+ texNames.forEach((k) => {
1660
+ if (mat[k] && typeof mat[k].dispose === 'function') {
1661
+ try {
1662
+ mat[k].dispose();
1663
+ }
1664
+ catch (_a) { }
1665
+ }
1666
+ });
1667
+ try {
1668
+ if (typeof mat.dispose === 'function')
1669
+ mat.dispose();
1670
+ }
1671
+ catch (_a) { }
1672
+ }
1673
+
1674
+ /** 默认值 */
1675
+ const DEFAULT_OPTIONS = {
1676
+ setAsBackground: true,
1677
+ setAsEnvironment: true,
1678
+ useSRGBEncoding: true,
1679
+ cache: true
1680
+ };
1681
+ /** 内部缓存:key -> { handle, refCount } */
1682
+ const cubeCache = new Map();
1683
+ const equirectCache = new Map();
1684
+ /* -------------------------------------------
1685
+ 公共函数:加载 skybox(自动选 cube 或 equirect)
1686
+ ------------------------------------------- */
1687
+ /**
1688
+ * 加载立方体贴图(6张)
1689
+ * @param renderer THREE.WebGLRenderer - 用于 PMREM 生成环境贴图
1690
+ * @param scene THREE.Scene
1691
+ * @param paths string[] 6 张图片地址,顺序:[px, nx, py, ny, pz, nz]
1692
+ * @param opts SkyboxOptions
1693
+ */
1694
+ function loadCubeSkybox(renderer_1, scene_1, paths_1) {
1695
+ return __awaiter(this, arguments, void 0, function* (renderer, scene, paths, opts = {}) {
1696
+ var _a, _b;
1697
+ const options = Object.assign(Object.assign({}, DEFAULT_OPTIONS), opts);
1698
+ if (!Array.isArray(paths) || paths.length !== 6)
1699
+ throw new Error('cube skybox requires 6 image paths');
1700
+ const key = paths.join('|');
1701
+ // 缓存处理
1702
+ if (options.cache && cubeCache.has(key)) {
1703
+ const rec = cubeCache.get(key);
1704
+ rec.refCount += 1;
1705
+ // reapply to scene (in case it was removed)
1706
+ if (options.setAsBackground)
1707
+ scene.background = rec.handle.backgroundTexture;
1708
+ if (options.setAsEnvironment && rec.handle.envRenderTarget)
1709
+ scene.environment = rec.handle.envRenderTarget.texture;
1710
+ return rec.handle;
1711
+ }
1712
+ // 加载立方体贴图
1713
+ const loader = new THREE__namespace.CubeTextureLoader();
1714
+ const texture = yield new Promise((resolve, reject) => {
1715
+ loader.load(paths, (tex) => resolve(tex), undefined, (err) => reject(err));
1716
+ });
1717
+ // 设置编码与映射
1718
+ if (options.useSRGBEncoding)
1719
+ texture.encoding = THREE__namespace.sRGBEncoding;
1720
+ texture.mapping = THREE__namespace.CubeReflectionMapping;
1721
+ // apply as background if required
1722
+ if (options.setAsBackground)
1723
+ scene.background = texture;
1724
+ // environment: use PMREM to produce a proper prefiltered env map for PBR
1725
+ let pmremGenerator = (_a = options.pmremGenerator) !== null && _a !== void 0 ? _a : new THREE__namespace.PMREMGenerator(renderer);
1726
+ (_b = pmremGenerator.compileCubemapShader) === null || _b === void 0 ? void 0 : _b.call(pmremGenerator);
1727
+ // fromCubemap might be available in your three.js; fallback to fromEquirectangular approach if not
1728
+ let envRenderTarget = null;
1729
+ if (pmremGenerator.fromCubemap) {
1730
+ envRenderTarget = pmremGenerator.fromCubemap(texture);
1731
+ }
1732
+ else {
1733
+ // Fallback: render cube to env map by using generator.fromEquirectangular with a converted equirect if needed.
1734
+ // Simpler fallback: use the cube texture directly as environment (less correct for reflections).
1735
+ envRenderTarget = null;
1736
+ }
1737
+ if (options.setAsEnvironment) {
1738
+ if (envRenderTarget) {
1739
+ scene.environment = envRenderTarget.texture;
1740
+ }
1741
+ else {
1742
+ // fallback: use cube texture directly (works but not prefiltered)
1743
+ scene.environment = texture;
1744
+ }
1745
+ }
1746
+ const handle = {
1747
+ key,
1748
+ backgroundTexture: options.setAsBackground ? texture : null,
1749
+ envRenderTarget: envRenderTarget,
1750
+ pmremGenerator: options.pmremGenerator ? null : pmremGenerator, // only dispose if we created it
1751
+ setAsBackground: !!options.setAsBackground,
1752
+ setAsEnvironment: !!options.setAsEnvironment,
1753
+ dispose() {
1754
+ // remove from scene
1755
+ if (options.setAsBackground && scene.background === texture)
1756
+ scene.background = null;
1757
+ if (options.setAsEnvironment && scene.environment) {
1758
+ // only clear if it's the same texture we set
1759
+ if (envRenderTarget && scene.environment === envRenderTarget.texture)
1760
+ scene.environment = null;
1761
+ else if (scene.environment === texture)
1762
+ scene.environment = null;
1763
+ }
1764
+ // dispose resources only if not cached/shared
1765
+ if (envRenderTarget) {
1766
+ try {
1767
+ envRenderTarget.dispose();
1768
+ }
1769
+ catch (_a) { }
1770
+ }
1771
+ try {
1772
+ texture.dispose();
1773
+ }
1774
+ catch (_b) { }
1775
+ // dispose pmremGenerator we created
1776
+ if (!options.pmremGenerator && pmremGenerator) {
1777
+ try {
1778
+ pmremGenerator.dispose();
1779
+ }
1780
+ catch (_c) { }
1781
+ }
1782
+ }
1783
+ };
1784
+ if (options.cache)
1785
+ cubeCache.set(key, { handle, refCount: 1 });
1786
+ return handle;
1787
+ });
1788
+ }
1789
+ /**
1790
+ * 加载等距/单图(支持 HDR via RGBELoader)
1791
+ * @param renderer THREE.WebGLRenderer
1792
+ * @param scene THREE.Scene
1793
+ * @param url string - *.hdr, *.exr, *.jpg, *.png
1794
+ * @param opts SkyboxOptions
1795
+ */
1796
+ function loadEquirectSkybox(renderer_1, scene_1, url_1) {
1797
+ return __awaiter(this, arguments, void 0, function* (renderer, scene, url, opts = {}) {
1798
+ var _a, _b;
1799
+ const options = Object.assign(Object.assign({}, DEFAULT_OPTIONS), opts);
1800
+ const key = url;
1801
+ if (options.cache && equirectCache.has(key)) {
1802
+ const rec = equirectCache.get(key);
1803
+ rec.refCount += 1;
1804
+ if (options.setAsBackground)
1805
+ scene.background = rec.handle.backgroundTexture;
1806
+ if (options.setAsEnvironment && rec.handle.envRenderTarget)
1807
+ scene.environment = rec.handle.envRenderTarget.texture;
1808
+ return rec.handle;
1809
+ }
1810
+ // 动态导入 RGBELoader(用于 .hdr/.exr),如果加载的是普通 jpg/png 可直接用 TextureLoader
1811
+ const isHDR = /\.hdr$|\.exr$/i.test(url);
1812
+ let hdrTexture;
1813
+ if (isHDR) {
1814
+ const { RGBELoader } = yield import('three/examples/jsm/loaders/RGBELoader.js');
1815
+ hdrTexture = yield new Promise((resolve, reject) => {
1816
+ new RGBELoader().load(url, (tex) => resolve(tex), undefined, (err) => reject(err));
1817
+ });
1818
+ // RGBE textures typically use LinearEncoding
1819
+ hdrTexture.encoding = THREE__namespace.LinearEncoding;
1820
+ }
1821
+ else {
1822
+ // ordinary image - use TextureLoader
1823
+ const loader = new THREE__namespace.TextureLoader();
1824
+ hdrTexture = yield new Promise((resolve, reject) => {
1825
+ loader.load(url, (t) => resolve(t), undefined, (err) => reject(err));
1826
+ });
1827
+ if (options.useSRGBEncoding)
1828
+ hdrTexture.encoding = THREE__namespace.sRGBEncoding;
1829
+ }
1830
+ // PMREMGenerator to convert equirectangular to prefiltered cubemap (good for PBR)
1831
+ const pmremGenerator = (_a = options.pmremGenerator) !== null && _a !== void 0 ? _a : new THREE__namespace.PMREMGenerator(renderer);
1832
+ (_b = pmremGenerator.compileEquirectangularShader) === null || _b === void 0 ? void 0 : _b.call(pmremGenerator);
1833
+ const envRenderTarget = pmremGenerator.fromEquirectangular(hdrTexture);
1834
+ // envTexture to use for scene.environment
1835
+ const envTexture = envRenderTarget.texture;
1836
+ // set background and/or environment
1837
+ if (options.setAsBackground) {
1838
+ // for background it's ok to use the equirect texture directly or the envTexture
1839
+ // envTexture is cubemap-like and usually better for reflections; using it as background creates cube-projected look
1840
+ scene.background = envTexture;
1841
+ }
1842
+ if (options.setAsEnvironment) {
1843
+ scene.environment = envTexture;
1844
+ }
1845
+ // We can dispose the original hdrTexture (the PMREM target contains the needed data)
1846
+ try {
1847
+ hdrTexture.dispose();
1848
+ }
1849
+ catch (_c) { }
1850
+ const handle = {
1851
+ key,
1852
+ backgroundTexture: options.setAsBackground ? envTexture : null,
1853
+ envRenderTarget,
1854
+ pmremGenerator: options.pmremGenerator ? null : pmremGenerator,
1855
+ setAsBackground: !!options.setAsBackground,
1856
+ setAsEnvironment: !!options.setAsEnvironment,
1857
+ dispose() {
1858
+ if (options.setAsBackground && scene.background === envTexture)
1859
+ scene.background = null;
1860
+ if (options.setAsEnvironment && scene.environment === envTexture)
1861
+ scene.environment = null;
1862
+ try {
1863
+ envRenderTarget.dispose();
1864
+ }
1865
+ catch (_a) { }
1866
+ if (!options.pmremGenerator && pmremGenerator) {
1867
+ try {
1868
+ pmremGenerator.dispose();
1869
+ }
1870
+ catch (_b) { }
1871
+ }
1872
+ }
1873
+ };
1874
+ if (options.cache)
1875
+ equirectCache.set(key, { handle, refCount: 1 });
1876
+ return handle;
1877
+ });
1878
+ }
1879
+ function loadSkybox(renderer_1, scene_1, params_1) {
1880
+ return __awaiter(this, arguments, void 0, function* (renderer, scene, params, opts = {}) {
1881
+ if (params.type === 'cube')
1882
+ return loadCubeSkybox(renderer, scene, params.paths, opts);
1883
+ return loadEquirectSkybox(renderer, scene, params.url, opts);
1884
+ });
1885
+ }
1886
+ /* -------------------------
1887
+ 缓存/引用计数 辅助方法
1888
+ ------------------------- */
1889
+ /** 释放一个缓存的 skybox(会减少 refCount,refCount=0 时才真正 dispose) */
1890
+ function releaseSkybox(handle) {
1891
+ // check cube cache
1892
+ if (cubeCache.has(handle.key)) {
1893
+ const rec = cubeCache.get(handle.key);
1894
+ rec.refCount -= 1;
1895
+ if (rec.refCount <= 0) {
1896
+ rec.handle.dispose();
1897
+ cubeCache.delete(handle.key);
1898
+ }
1899
+ return;
1900
+ }
1901
+ if (equirectCache.has(handle.key)) {
1902
+ const rec = equirectCache.get(handle.key);
1903
+ rec.refCount -= 1;
1904
+ if (rec.refCount <= 0) {
1905
+ rec.handle.dispose();
1906
+ equirectCache.delete(handle.key);
1907
+ }
1908
+ return;
1909
+ }
1910
+ // if not cached, just dispose
1911
+ // handle.dispose()
1912
+ }
1913
+
1914
+ // utils/BlueSkyManager.ts - 优化版
1915
+ /**
1916
+ * BlueSkyManager - 优化版
1917
+ * ---------------------------------------------------------
1918
+ * 一个全局单例管理器,用于加载和管理基于 HDR/EXR 的蓝天白云环境贴图。
1919
+ *
1920
+ * ✨ 优化内容:
1921
+ * - 添加加载进度回调
1922
+ * - 支持加载取消
1923
+ * - 完善错误处理
1924
+ * - 返回 Promise 支持异步
1925
+ * - 添加加载状态管理
1926
+ */
1927
+ class BlueSkyManager {
1928
+ constructor() {
1929
+ /** 当前环境贴图的 RenderTarget,用于后续释放 */
1930
+ this.skyRT = null;
1931
+ /** 是否已经初始化 */
1932
+ this.isInitialized = false;
1933
+ /** ✨ 当前加载器,用于取消加载 */
1934
+ this.currentLoader = null;
1935
+ /** ✨ 加载状态 */
1936
+ this.loadingState = 'idle';
1937
+ }
1938
+ /**
1939
+ * 初始化
1940
+ * ---------------------------------------------------------
1941
+ * 必须在使用 BlueSkyManager 之前调用一次。
1942
+ * @param renderer WebGLRenderer 实例
1943
+ * @param scene Three.js 场景
1944
+ * @param exposure 曝光度 (默认 1.0)
1945
+ */
1946
+ init(renderer, scene, exposure = 1.0) {
1947
+ if (this.isInitialized) {
1948
+ console.warn('BlueSkyManager: 已经初始化,跳过重复初始化');
1949
+ return;
1950
+ }
1951
+ this.renderer = renderer;
1952
+ this.scene = scene;
1953
+ // 使用 ACESFilmicToneMapping,效果更接近真实
1954
+ this.renderer.toneMapping = THREE__namespace.ACESFilmicToneMapping;
1955
+ this.renderer.toneMappingExposure = exposure;
1956
+ // 初始化 PMREM 生成器(全局只需一个)
1957
+ this.pmremGen = new THREE__namespace.PMREMGenerator(renderer);
1958
+ this.pmremGen.compileEquirectangularShader();
1959
+ this.isInitialized = true;
1960
+ }
1961
+ /**
1962
+ * ✨ 加载蓝天 HDR/EXR 贴图并应用到场景(Promise 版本)
1963
+ * ---------------------------------------------------------
1964
+ * @param exrPath HDR/EXR 文件路径
1965
+ * @param options 加载选项
1966
+ * @returns Promise<void>
1967
+ */
1968
+ loadAsync(exrPath, options = {}) {
1969
+ if (!this.isInitialized) {
1970
+ return Promise.reject(new Error('BlueSkyManager not initialized!'));
1971
+ }
1972
+ // ✨ 取消之前的加载
1973
+ this.cancelLoad();
1974
+ const { background = true, onProgress, onComplete, onError } = options;
1975
+ this.loadingState = 'loading';
1976
+ this.currentLoader = new EXRLoader_js.EXRLoader();
1977
+ return new Promise((resolve, reject) => {
1978
+ this.currentLoader.load(exrPath,
1979
+ // 成功回调
1980
+ (texture) => {
1981
+ try {
1982
+ // 设置贴图为球面反射映射
1983
+ texture.mapping = THREE__namespace.EquirectangularReflectionMapping;
1984
+ // 清理旧的环境贴图
1985
+ this.dispose();
1986
+ // 用 PMREM 生成高效的环境贴图
1987
+ this.skyRT = this.pmremGen.fromEquirectangular(texture);
1988
+ // 应用到场景:环境光照 & 背景
1989
+ this.scene.environment = this.skyRT.texture;
1990
+ if (background)
1991
+ this.scene.background = this.skyRT.texture;
1992
+ // 原始 HDR/EXR 贴图用完即销毁,节省内存
1993
+ texture.dispose();
1994
+ this.loadingState = 'loaded';
1995
+ this.currentLoader = null;
1996
+ console.log('✅ Blue sky EXR loaded:', exrPath);
1997
+ if (onComplete)
1998
+ onComplete();
1999
+ resolve();
2000
+ }
2001
+ catch (error) {
2002
+ this.loadingState = 'error';
2003
+ this.currentLoader = null;
2004
+ console.error('❌ Processing EXR sky failed:', error);
2005
+ if (onError)
2006
+ onError(error);
2007
+ reject(error);
2008
+ }
2009
+ },
2010
+ // 进度回调
2011
+ (xhr) => {
2012
+ if (onProgress && xhr.lengthComputable) {
2013
+ const progress = xhr.loaded / xhr.total;
2014
+ onProgress(progress);
2015
+ }
2016
+ },
2017
+ // 错误回调
2018
+ (err) => {
2019
+ this.loadingState = 'error';
2020
+ this.currentLoader = null;
2021
+ console.error('❌ Failed to load EXR sky:', err);
2022
+ if (onError)
2023
+ onError(err);
2024
+ reject(err);
2025
+ });
2026
+ });
2027
+ }
2028
+ /**
2029
+ * 加载蓝天 HDR/EXR 贴图并应用到场景(同步 API,保持向后兼容)
2030
+ * ---------------------------------------------------------
2031
+ * @param exrPath HDR/EXR 文件路径
2032
+ * @param background 是否应用为场景背景 (默认 true)
2033
+ */
2034
+ load(exrPath, background = true) {
2035
+ this.loadAsync(exrPath, { background }).catch((error) => {
2036
+ console.error('BlueSkyManager load error:', error);
2037
+ });
2038
+ }
2039
+ /**
2040
+ * ✨ 取消当前加载
2041
+ */
2042
+ cancelLoad() {
2043
+ if (this.currentLoader) {
2044
+ // EXRLoader 本身没有 abort 方法,但我们可以清空引用
2045
+ this.currentLoader = null;
2046
+ this.loadingState = 'idle';
2047
+ }
2048
+ }
2049
+ /**
2050
+ * ✨ 获取加载状态
2051
+ */
2052
+ getLoadingState() {
2053
+ return this.loadingState;
2054
+ }
2055
+ /**
2056
+ * ✨ 是否正在加载
2057
+ */
2058
+ isLoading() {
2059
+ return this.loadingState === 'loading';
2060
+ }
2061
+ /**
2062
+ * 释放当前的天空贴图资源
2063
+ * ---------------------------------------------------------
2064
+ * 仅清理 skyRT,不销毁 PMREM
2065
+ * 适用于切换 HDR/EXR 文件时调用
2066
+ */
2067
+ dispose() {
2068
+ if (this.skyRT) {
2069
+ this.skyRT.texture.dispose();
2070
+ this.skyRT.dispose();
2071
+ this.skyRT = null;
2072
+ }
2073
+ if (this.scene && this.scene.background)
2074
+ this.scene.background = null;
2075
+ if (this.scene && this.scene.environment)
2076
+ this.scene.environment = null;
2077
+ }
2078
+ /**
2079
+ * 完全销毁 BlueSkyManager
2080
+ * ---------------------------------------------------------
2081
+ * 包括 PMREMGenerator 的销毁
2082
+ * 通常在场景彻底销毁或应用退出时调用
2083
+ */
2084
+ destroy() {
2085
+ var _a;
2086
+ this.cancelLoad();
2087
+ this.dispose();
2088
+ (_a = this.pmremGen) === null || _a === void 0 ? void 0 : _a.dispose();
2089
+ this.isInitialized = false;
2090
+ this.loadingState = 'idle';
2091
+ }
2092
+ }
2093
+ /**
2094
+ * 🌐 全局单例
2095
+ * ---------------------------------------------------------
2096
+ * 直接导出一个全局唯一的 BlueSkyManager 实例,
2097
+ * 保证整个应用中只用一个 PMREMGenerator,性能最佳。
2098
+ */
2099
+ const BlueSky = new BlueSkyManager();
2100
+
2101
+ /**
2102
+ * 创建模型标签(带连线和脉冲圆点)- 优化版
2103
+ *
2104
+ * ✨ 优化内容:
2105
+ * - 支持暂停/恢复更新
2106
+ * - 可配置更新间隔
2107
+ * - 淡入淡出效果
2108
+ * - 缓存包围盒计算
2109
+ * - RAF 管理优化
2110
+ */
2111
+ function createModelsLabel(camera, renderer, parentModel, modelLabelsMap, options) {
2112
+ var _a, _b, _c, _d, _e, _f;
2113
+ const cfg = {
2114
+ fontSize: (options === null || options === void 0 ? void 0 : options.fontSize) || '12px',
2115
+ color: (options === null || options === void 0 ? void 0 : options.color) || '#ffffff',
2116
+ background: (options === null || options === void 0 ? void 0 : options.background) || '#1890ff',
2117
+ padding: (options === null || options === void 0 ? void 0 : options.padding) || '6px 10px',
2118
+ borderRadius: (options === null || options === void 0 ? void 0 : options.borderRadius) || '6px',
2119
+ lift: (_a = options === null || options === void 0 ? void 0 : options.lift) !== null && _a !== void 0 ? _a : 100,
2120
+ dotSize: (_b = options === null || options === void 0 ? void 0 : options.dotSize) !== null && _b !== void 0 ? _b : 6,
2121
+ dotSpacing: (_c = options === null || options === void 0 ? void 0 : options.dotSpacing) !== null && _c !== void 0 ? _c : 2,
2122
+ lineColor: (options === null || options === void 0 ? void 0 : options.lineColor) || 'rgba(200,200,200,0.7)',
2123
+ lineWidth: (_d = options === null || options === void 0 ? void 0 : options.lineWidth) !== null && _d !== void 0 ? _d : 1,
2124
+ updateInterval: (_e = options === null || options === void 0 ? void 0 : options.updateInterval) !== null && _e !== void 0 ? _e : 0, // ✨ 默认每帧更新
2125
+ fadeInDuration: (_f = options === null || options === void 0 ? void 0 : options.fadeInDuration) !== null && _f !== void 0 ? _f : 300, // ✨ 淡入时长
2126
+ };
2127
+ const container = document.createElement('div');
2128
+ container.style.position = 'absolute';
2129
+ container.style.top = '0';
2130
+ container.style.left = '0';
2131
+ container.style.width = '100%';
2132
+ container.style.height = '100%';
2133
+ container.style.pointerEvents = 'none';
2134
+ container.style.overflow = 'visible';
2135
+ document.body.appendChild(container);
2136
+ const svgNS = 'http://www.w3.org/2000/svg';
2137
+ const svg = document.createElementNS(svgNS, 'svg');
2138
+ svg.setAttribute('width', '100%');
2139
+ svg.setAttribute('height', '100%');
2140
+ svg.style.position = 'absolute';
2141
+ svg.style.top = '0';
2142
+ svg.style.left = '0';
2143
+ svg.style.overflow = 'visible';
2144
+ svg.style.pointerEvents = 'none';
2145
+ svg.style.zIndex = '1';
2146
+ container.appendChild(svg);
2147
+ let currentModel = parentModel;
2148
+ let currentLabelsMap = Object.assign({}, modelLabelsMap);
2149
+ let labels = [];
2150
+ let isActive = true;
2151
+ let isPaused = false; // ✨ 暂停状态
2152
+ let rafId = null; // ✨ RAF ID
2153
+ let lastUpdateTime = 0; // ✨ 上次更新时间
2154
+ // ✨ 注入样式(带淡入动画)
2155
+ const styleId = 'three-model-label-styles';
2156
+ if (!document.getElementById(styleId)) {
2157
+ const style = document.createElement('style');
2158
+ style.id = styleId;
2159
+ style.innerHTML = `
2160
+ @keyframes pulse-dot {
2161
+ 0% { transform: scale(1); opacity: 1; }
2162
+ 50% { transform: scale(1.6); opacity: 0.45; }
2163
+ 100% { transform: scale(1); opacity: 1; }
2164
+ }
2165
+ @keyframes fade-in-label {
2166
+ from { opacity: 0; transform: translateY(10px); }
2167
+ to { opacity: 1; transform: translateY(0); }
2168
+ }
2169
+ .tm-label {
2170
+ pointer-events: none;
2171
+ display: inline-block;
2172
+ line-height: 1;
2173
+ will-change: transform, opacity;
2174
+ transition: opacity 150ms ease;
2175
+ }
2176
+ .tm-label-wrapper {
2177
+ display: inline-flex;
2178
+ align-items: center;
2179
+ gap: 8px;
2180
+ animation: fade-in-label ${cfg.fadeInDuration}ms ease-out;
2181
+ }
2182
+ .tm-label-dot {
2183
+ border-radius: 50%;
2184
+ will-change: transform, opacity;
2185
+ animation: pulse-dot 1.2s infinite ease-in-out;
2186
+ }
2187
+ .tm-label-text {
2188
+ white-space: nowrap;
2189
+ }
2190
+ `;
2191
+ document.head.appendChild(style);
2192
+ }
2193
+ // ✨ 获取或更新缓存的顶部位置
2194
+ const getObjectTopPosition = (labelData) => {
2195
+ const obj = labelData.object;
2196
+ // 如果有缓存且对象没有变换,直接返回
2197
+ if (labelData.cachedTopPos && !obj.matrixWorldNeedsUpdate) {
2198
+ return labelData.cachedTopPos.clone();
2199
+ }
2200
+ // 重新计算
2201
+ const box = new THREE__namespace.Box3().setFromObject(obj);
2202
+ labelData.cachedBox = box;
2203
+ if (!box.isEmpty()) {
2204
+ const center = new THREE__namespace.Vector3();
2205
+ box.getCenter(center);
2206
+ const topPos = new THREE__namespace.Vector3(center.x, box.max.y, center.z);
2207
+ labelData.cachedTopPos = topPos;
2208
+ return topPos.clone();
2209
+ }
2210
+ const p = new THREE__namespace.Vector3();
2211
+ obj.getWorldPosition(p);
2212
+ labelData.cachedTopPos = p;
2213
+ return p.clone();
2214
+ };
2215
+ const clearLabels = () => {
2216
+ labels.forEach(({ el, line, wrapper }) => {
2217
+ el.remove();
2218
+ wrapper.remove();
2219
+ if (line && line.parentNode)
2220
+ line.parentNode.removeChild(line);
2221
+ });
2222
+ labels = [];
2223
+ };
2224
+ const rebuildLabels = () => {
2225
+ clearLabels();
2226
+ if (!currentModel)
2227
+ return;
2228
+ currentModel.traverse((child) => {
2229
+ var _a;
2230
+ if (child.isMesh || child.type === 'Group') {
2231
+ const labelText = (_a = Object.entries(currentLabelsMap).find(([key]) => child.name.includes(key))) === null || _a === void 0 ? void 0 : _a[1];
2232
+ if (!labelText)
2233
+ return;
2234
+ const wrapper = document.createElement('div');
2235
+ wrapper.className = 'tm-label-wrapper';
2236
+ wrapper.style.position = 'absolute';
2237
+ wrapper.style.pointerEvents = 'none';
2238
+ wrapper.style.transform = 'translate(-50%, -100%)';
2239
+ wrapper.style.zIndex = '1';
2240
+ const el = document.createElement('div');
2241
+ el.className = 'tm-label';
2242
+ el.style.background = cfg.background;
2243
+ el.style.color = cfg.color;
2244
+ el.style.padding = cfg.padding;
2245
+ el.style.borderRadius = cfg.borderRadius;
2246
+ el.style.fontSize = cfg.fontSize;
2247
+ el.style.boxShadow = '0 6px 18px rgba(0,0,0,0.35)';
2248
+ el.style.backdropFilter = 'blur(4px)';
2249
+ el.style.border = '1px solid rgba(255,255,255,0.03)';
2250
+ el.style.display = 'inline-block';
2251
+ const txt = document.createElement('div');
2252
+ txt.className = 'tm-label-text';
2253
+ txt.innerText = labelText;
2254
+ el.appendChild(txt);
2255
+ const dot = document.createElement('div');
2256
+ dot.className = 'tm-label-dot';
2257
+ dot.style.width = `${cfg.dotSize}px`;
2258
+ dot.style.height = `${cfg.dotSize}px`;
2259
+ dot.style.background = 'radial-gradient(circle at 30% 30%, #fff, rgba(255,255,255,0.85) 20%, rgba(255,204,0,0.9) 60%, rgba(255,170,0,0.9) 100%)';
2260
+ dot.style.boxShadow = '0 0 8px rgba(255,170,0,0.9)';
2261
+ dot.style.flex = '0 0 auto';
2262
+ dot.style.marginRight = `${cfg.dotSpacing}px`;
2263
+ wrapper.appendChild(dot);
2264
+ wrapper.appendChild(el);
2265
+ container.appendChild(wrapper);
2266
+ const line = document.createElementNS(svgNS, 'line');
2267
+ line.setAttribute('stroke', cfg.lineColor);
2268
+ line.setAttribute('stroke-width', `${cfg.lineWidth}`);
2269
+ line.setAttribute('stroke-linecap', 'round');
2270
+ line.setAttribute('opacity', '0.85');
2271
+ svg.appendChild(line);
2272
+ labels.push({
2273
+ object: child,
2274
+ el,
2275
+ wrapper,
2276
+ dot,
2277
+ line,
2278
+ cachedBox: null, // ✨ 初始化缓存
2279
+ cachedTopPos: null
2280
+ });
2281
+ }
2282
+ });
2283
+ };
2284
+ rebuildLabels();
2285
+ // ✨ 优化的更新函数
2286
+ const updateLabels = (timestamp) => {
2287
+ if (!isActive || isPaused) {
2288
+ rafId = null;
2289
+ return;
2290
+ }
2291
+ // ✨ 节流处理
2292
+ if (cfg.updateInterval > 0 && timestamp - lastUpdateTime < cfg.updateInterval) {
2293
+ rafId = requestAnimationFrame(updateLabels);
2294
+ return;
2295
+ }
2296
+ lastUpdateTime = timestamp;
2297
+ const rect = renderer.domElement.getBoundingClientRect();
2298
+ const width = rect.width;
2299
+ const height = rect.height;
2300
+ svg.setAttribute('width', `${width}`);
2301
+ svg.setAttribute('height', `${height}`);
2302
+ labels.forEach((labelData) => {
2303
+ const { el, wrapper, dot, line } = labelData;
2304
+ const topWorld = getObjectTopPosition(labelData); // ✨ 使用缓存
2305
+ const topNDC = topWorld.clone().project(camera);
2306
+ const modelX = (topNDC.x * 0.5 + 0.5) * width + rect.left;
2307
+ const modelY = (-(topNDC.y * 0.5) + 0.5) * height + rect.top;
2308
+ const labelX = modelX;
2309
+ const labelY = modelY - cfg.lift;
2310
+ wrapper.style.left = `${labelX}px`;
2311
+ wrapper.style.top = `${labelY}px`;
2312
+ const svgModelX = modelX - rect.left;
2313
+ const svgModelY = modelY - rect.top;
2314
+ const svgLabelX = labelX - rect.left;
2315
+ const svgLabelY = labelY - rect.top + (el.getBoundingClientRect().height * 0.5);
2316
+ line.setAttribute('x1', `${svgModelX}`);
2317
+ line.setAttribute('y1', `${svgModelY}`);
2318
+ line.setAttribute('x2', `${svgLabelX}`);
2319
+ line.setAttribute('y2', `${svgLabelY}`);
2320
+ const visible = topNDC.z < 1;
2321
+ wrapper.style.display = visible ? 'flex' : 'none';
2322
+ line.setAttribute('visibility', visible ? 'visible' : 'hidden');
2323
+ dot.style.opacity = visible ? '1' : '0';
2324
+ });
2325
+ rafId = requestAnimationFrame(updateLabels);
2326
+ };
2327
+ rafId = requestAnimationFrame(updateLabels);
2328
+ return {
2329
+ updateModel(newModel) {
2330
+ if (!newModel || newModel === currentModel)
2331
+ return;
2332
+ currentModel = newModel;
2333
+ rebuildLabels();
2334
+ },
2335
+ updateLabelsMap(newMap) {
2336
+ currentLabelsMap = Object.assign({}, newMap);
2337
+ rebuildLabels();
2338
+ },
2339
+ // ✨ 暂停更新
2340
+ pause() {
2341
+ isPaused = true;
2342
+ if (rafId !== null) {
2343
+ cancelAnimationFrame(rafId);
2344
+ rafId = null;
2345
+ }
2346
+ },
2347
+ // ✨ 恢复更新
2348
+ resume() {
2349
+ if (!isPaused)
2350
+ return;
2351
+ isPaused = false;
2352
+ rafId = requestAnimationFrame(updateLabels);
2353
+ },
2354
+ dispose() {
2355
+ isActive = false;
2356
+ isPaused = true;
2357
+ if (rafId !== null) {
2358
+ cancelAnimationFrame(rafId);
2359
+ rafId = null;
2360
+ }
2361
+ clearLabels();
2362
+ svg.remove();
2363
+ container.remove();
2364
+ },
2365
+ };
2366
+ }
2367
+
2368
+ function easeInOutQuad(t) {
2369
+ return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
2370
+ }
2371
+ class GroupExploder {
2372
+ constructor(scene, camera, controls) {
2373
+ // sets and snapshots
2374
+ this.currentSet = null;
2375
+ this.stateMap = new Map();
2376
+ // prevSet preserved to allow async restore
2377
+ this.prevSet = null;
2378
+ this.prevStateMap = new Map();
2379
+ // material context map: material -> Set<contextId>, and snapshot store material->snap
2380
+ this.materialContexts = new Map();
2381
+ this.materialSnaps = new Map();
2382
+ this.contextMaterials = new Map();
2383
+ this.animId = null;
2384
+ this.cameraAnimId = null;
2385
+ this.isExploded = false;
2386
+ this.isInitialized = false;
2387
+ this.scene = scene;
2388
+ this.camera = camera;
2389
+ this.controls = controls;
2390
+ }
2391
+ log(msg) {
2392
+ console.log('[GroupExploderDebug]', msg);
2393
+ if (this.onLog)
2394
+ this.onLog(msg);
2395
+ }
2396
+ init() {
2397
+ if (this.isInitialized)
2398
+ return;
2399
+ this.isInitialized = true;
2400
+ this.log('init() called');
2401
+ }
2402
+ /**
2403
+ * setMeshes(newSet):
2404
+ * - Detects content-level changes even if same Set reference is used.
2405
+ * - Preserves prevSet/stateMap to allow async restore when needed.
2406
+ * - Ensures stateMap contains snapshots for *all meshes in the new set*.
2407
+ */
2408
+ setMeshes(newSet, options) {
2409
+ return __awaiter(this, void 0, void 0, function* () {
2410
+ var _a, _b;
2411
+ const autoRestorePrev = (_a = options === null || options === void 0 ? void 0 : options.autoRestorePrev) !== null && _a !== void 0 ? _a : true;
2412
+ const restoreDuration = (_b = options === null || options === void 0 ? void 0 : options.restoreDuration) !== null && _b !== void 0 ? _b : 300;
2413
+ this.log(`setMeshes called. newSetSize=${newSet ? newSet.size : 0}, autoRestorePrev=${autoRestorePrev}`);
2414
+ // If the newSet is null and currentSet is null -> nothing
2415
+ if (!newSet && !this.currentSet) {
2416
+ this.log('setMeshes: both newSet and currentSet are null, nothing to do');
2417
+ return;
2418
+ }
2419
+ // If both exist and are the same reference, we still must detect content changes.
2420
+ const sameReference = this.currentSet === newSet;
2421
+ // Prepare prevSet snapshot (we copy current to prev)
2422
+ if (this.currentSet) {
2423
+ this.prevSet = this.currentSet;
2424
+ this.prevStateMap = new Map(this.stateMap);
2425
+ this.log(`setMeshes: backed up current->prev prevSetSize=${this.prevSet.size}`);
2426
+ }
2427
+ else {
2428
+ this.prevSet = null;
2429
+ this.prevStateMap = new Map();
2430
+ }
2431
+ // If we used to be exploded and need to restore prevSet, do that first (await)
2432
+ if (this.prevSet && autoRestorePrev && this.isExploded) {
2433
+ this.log('setMeshes: need to restore prevSet before applying newSet');
2434
+ yield this.restoreSet(this.prevSet, this.prevStateMap, restoreDuration, { debug: true });
2435
+ this.log('setMeshes: prevSet restore done');
2436
+ this.prevStateMap.clear();
2437
+ this.prevSet = null;
2438
+ }
2439
+ // Now register newSet: we clear and rebuild stateMap carefully.
2440
+ // But we must handle the case where caller reuses same Set object and just mutated elements.
2441
+ // We will compute additions and removals.
2442
+ const oldSet = this.currentSet;
2443
+ this.currentSet = newSet;
2444
+ // If newSet is null -> simply clear stateMap
2445
+ if (!this.currentSet) {
2446
+ this.stateMap.clear();
2447
+ this.log('setMeshes: newSet is null -> cleared stateMap');
2448
+ this.isExploded = false;
2449
+ return;
2450
+ }
2451
+ // If we have oldSet (could be same reference) then compute diffs
2452
+ if (oldSet) {
2453
+ // If same reference but size or content differs -> handle diffs
2454
+ const wasSameRef = sameReference;
2455
+ let added = [];
2456
+ let removed = [];
2457
+ // Build maps of membership
2458
+ const oldMembers = new Set(Array.from(oldSet));
2459
+ const newMembers = new Set(Array.from(this.currentSet));
2460
+ // find removals
2461
+ oldMembers.forEach((m) => {
2462
+ if (!newMembers.has(m))
2463
+ removed.push(m);
2464
+ });
2465
+ // find additions
2466
+ newMembers.forEach((m) => {
2467
+ if (!oldMembers.has(m))
2468
+ added.push(m);
2469
+ });
2470
+ if (wasSameRef && added.length === 0 && removed.length === 0) {
2471
+ // truly identical (no content changes)
2472
+ this.log('setMeshes: same reference and identical contents -> nothing to update');
2473
+ return;
2474
+ }
2475
+ this.log(`setMeshes: diff detected -> added=${added.length}, removed=${removed.length}`);
2476
+ // Remove snapshots for removed meshes
2477
+ removed.forEach((m) => {
2478
+ if (this.stateMap.has(m)) {
2479
+ this.stateMap.delete(m);
2480
+ }
2481
+ });
2482
+ // Ensure snapshots exist for current set members (create for newly added meshes)
2483
+ yield this.ensureSnapshotsForSet(this.currentSet);
2484
+ this.log(`setMeshes: after diff handling, stateMap size=${this.stateMap.size}`);
2485
+ this.isExploded = false;
2486
+ return;
2487
+ }
2488
+ else {
2489
+ // no oldSet -> brand new registration
2490
+ this.stateMap.clear();
2491
+ yield this.ensureSnapshotsForSet(this.currentSet);
2492
+ this.log(`setMeshes: recorded stateMap entries for newSet size=${this.stateMap.size}`);
2493
+ this.isExploded = false;
2494
+ return;
2495
+ }
2496
+ });
2497
+ }
2498
+ /**
2499
+ * ensureSnapshotsForSet: for each mesh in set, ensure stateMap has an entry.
2500
+ * If missing, record current matrixWorld as originalMatrixWorld (best-effort).
2501
+ */
2502
+ ensureSnapshotsForSet(set) {
2503
+ return __awaiter(this, void 0, void 0, function* () {
2504
+ set.forEach((m) => {
2505
+ try {
2506
+ m.updateMatrixWorld(true);
2507
+ }
2508
+ catch (_a) { }
2509
+ if (!this.stateMap.has(m)) {
2510
+ try {
2511
+ this.stateMap.set(m, {
2512
+ originalParent: m.parent || null,
2513
+ originalMatrixWorld: (m.matrixWorld && m.matrixWorld.clone()) || new THREE__namespace.Matrix4().copy(m.matrix),
2514
+ });
2515
+ // Also store in userData for extra resilience
2516
+ m.userData.__originalMatrixWorld = this.stateMap.get(m).originalMatrixWorld.clone();
2517
+ }
2518
+ catch (e) {
2519
+ this.log(`ensureSnapshotsForSet: failed to snapshot mesh ${m.name || m.id}: ${e.message}`);
2520
+ }
2521
+ }
2522
+ });
2523
+ });
2524
+ }
2525
+ /**
2526
+ * explode: compute targets first, compute targetBound using targets + mesh radii,
2527
+ * animate camera to that targetBound, then animate meshes to targets.
2528
+ */
2529
+ explode(opts) {
2530
+ return __awaiter(this, void 0, void 0, function* () {
2531
+ var _a;
2532
+ if (!this.currentSet || this.currentSet.size === 0) {
2533
+ this.log('explode: empty currentSet, nothing to do');
2534
+ return;
2535
+ }
2536
+ const { spacing = 2, duration = 1000, lift = 0.5, cameraPadding = 1.5, mode = 'spiral', dimOthers = { enabled: true, opacity: 0.25 }, debug = false, } = opts || {};
2537
+ this.log(`explode called. setSize=${this.currentSet.size}, mode=${mode}, spacing=${spacing}, duration=${duration}, lift=${lift}, dim=${dimOthers.enabled}`);
2538
+ this.cancelAnimations();
2539
+ const meshes = Array.from(this.currentSet);
2540
+ // ensure snapshots exist for any meshes that may have been added after initial registration
2541
+ yield this.ensureSnapshotsForSet(this.currentSet);
2542
+ // compute center/radius from current meshes (fallback)
2543
+ const initial = this.computeBoundingSphereForMeshes(meshes);
2544
+ const center = initial.center;
2545
+ const baseRadius = Math.max(1, initial.radius);
2546
+ this.log(`explode: initial center=${center.toArray().map((n) => n.toFixed(3))}, baseRadius=${baseRadius.toFixed(3)}`);
2547
+ // compute targets (pure calculation)
2548
+ const targets = this.computeTargetsByMode(meshes, center, baseRadius + spacing, { lift, mode });
2549
+ this.log(`explode: computed ${targets.length} target positions`);
2550
+ // compute target-based bounding sphere (targets + per-mesh radius)
2551
+ const targetBound = this.computeBoundingSphereForPositionsAndMeshes(targets, meshes);
2552
+ this.log(`explode: targetBound center=${targetBound.center.toArray().map((n) => n.toFixed(3))}, radius=${targetBound.radius.toFixed(3)}`);
2553
+ yield this.animateCameraToFit(targetBound.center, targetBound.radius, { duration: Math.min(600, duration), padding: cameraPadding });
2554
+ this.log('explode: camera animation to target bound completed');
2555
+ // apply dim if needed with context id
2556
+ const contextId = (dimOthers === null || dimOthers === void 0 ? void 0 : dimOthers.enabled) ? this.applyDimToOthers(meshes, (_a = dimOthers.opacity) !== null && _a !== void 0 ? _a : 0.25, { debug }) : null;
2557
+ if (contextId)
2558
+ this.log(`explode: applied dim for context ${contextId}`);
2559
+ // capture starts after camera move
2560
+ const starts = meshes.map((m) => {
2561
+ const v = new THREE__namespace.Vector3();
2562
+ try {
2563
+ m.getWorldPosition(v);
2564
+ }
2565
+ catch (_a) {
2566
+ // fallback to originalMatrixWorld if available
2567
+ const st = this.stateMap.get(m);
2568
+ if (st)
2569
+ v.setFromMatrixPosition(st.originalMatrixWorld);
2570
+ }
2571
+ return v;
2572
+ });
2573
+ const startTime = performance.now();
2574
+ const total = Math.max(1, duration);
2575
+ const tick = (now) => {
2576
+ const t = Math.min(1, (now - startTime) / total);
2577
+ const eased = easeInOutQuad(t);
2578
+ for (let i = 0; i < meshes.length; i++) {
2579
+ const m = meshes[i];
2580
+ const s = starts[i];
2581
+ const tar = targets[i];
2582
+ const cur = s.clone().lerp(tar, eased);
2583
+ if (m.parent) {
2584
+ const local = cur.clone();
2585
+ m.parent.worldToLocal(local);
2586
+ m.position.copy(local);
2587
+ }
2588
+ else {
2589
+ m.position.copy(cur);
2590
+ }
2591
+ m.updateMatrix();
2592
+ }
2593
+ if (this.controls && typeof this.controls.update === 'function')
2594
+ this.controls.update();
2595
+ if (t < 1) {
2596
+ this.animId = requestAnimationFrame(tick);
2597
+ }
2598
+ else {
2599
+ this.animId = null;
2600
+ this.isExploded = true;
2601
+ this.log(`explode: completed. contextId=${contextId !== null && contextId !== void 0 ? contextId : 'none'}`);
2602
+ }
2603
+ };
2604
+ this.animId = requestAnimationFrame(tick);
2605
+ return;
2606
+ });
2607
+ }
2608
+ restore(duration = 400) {
2609
+ if (!this.currentSet || this.currentSet.size === 0) {
2610
+ this.log('restore: no currentSet to restore');
2611
+ return Promise.resolve();
2612
+ }
2613
+ this.log(`restore called for currentSet size=${this.currentSet.size}`);
2614
+ return this.restoreSet(this.currentSet, this.stateMap, duration, { debug: true });
2615
+ }
2616
+ /**
2617
+ * restoreSet: reparent and restore transforms using provided stateMap.
2618
+ * If missing stateMap entry for a mesh, use fallbacks:
2619
+ * 1) mesh.userData.__originalMatrixWorld (if present)
2620
+ * 2) mesh.matrixWorld (current) -> smooth lerp to itself (no-op visually)
2621
+ */
2622
+ restoreSet(set, stateMap, duration = 400, opts) {
2623
+ if (!set || set.size === 0) {
2624
+ if (opts === null || opts === void 0 ? void 0 : opts.debug)
2625
+ this.log('restoreSet: empty set, nothing to restore');
2626
+ return Promise.resolve();
2627
+ }
2628
+ this.cancelAnimations();
2629
+ const meshes = Array.from(set);
2630
+ this.log(`restoreSet: starting restore for ${meshes.length} meshes (duration=${duration})`);
2631
+ const starts = [];
2632
+ const targets = [];
2633
+ for (const m of meshes) {
2634
+ try {
2635
+ m.updateMatrixWorld(true);
2636
+ }
2637
+ catch (_a) { }
2638
+ const s = new THREE__namespace.Vector3();
2639
+ try {
2640
+ m.getWorldPosition(s);
2641
+ }
2642
+ catch (_b) {
2643
+ s.set(0, 0, 0);
2644
+ }
2645
+ starts.push(s);
2646
+ const st = stateMap.get(m);
2647
+ if (st) {
2648
+ const tar = new THREE__namespace.Vector3();
2649
+ tar.setFromMatrixPosition(st.originalMatrixWorld);
2650
+ targets.push(tar);
2651
+ }
2652
+ else {
2653
+ // fallback attempts
2654
+ const ud = m.userData.__originalMatrixWorld;
2655
+ if (ud instanceof THREE__namespace.Matrix4) {
2656
+ const tar = new THREE__namespace.Vector3();
2657
+ tar.setFromMatrixPosition(ud);
2658
+ targets.push(tar);
2659
+ this.log(`restoreSet: used userData.__originalMatrixWorld for mesh ${m.name || m.id}`);
2660
+ }
2661
+ else {
2662
+ // fallback to current position to avoid NaN; will be effectively a no-op but avoids error
2663
+ const tar = s.clone();
2664
+ targets.push(tar);
2665
+ this.log(`restoreSet: missing stateMap entry for mesh ${m.name || m.id} -> fallback to current pos`);
2666
+ }
2667
+ }
2668
+ }
2669
+ const startTime = performance.now();
2670
+ const total = Math.max(1, duration);
2671
+ return new Promise((resolve) => {
2672
+ const tick = (now) => {
2673
+ const t = Math.min(1, (now - startTime) / total);
2674
+ const eased = easeInOutQuad(t);
2675
+ for (let i = 0; i < meshes.length; i++) {
2676
+ const m = meshes[i];
2677
+ const s = starts[i];
2678
+ const tar = targets[i];
2679
+ const cur = s.clone().lerp(tar, eased);
2680
+ // final step: ensure reparent to original parent if possible
2681
+ const st = stateMap.get(m);
2682
+ if (st && t >= 0.999) {
2683
+ try {
2684
+ const origParent = st.originalParent || this.scene;
2685
+ origParent.updateMatrixWorld(true);
2686
+ const parentInv = new THREE__namespace.Matrix4().copy(origParent.matrixWorld).invert();
2687
+ const localMat = new THREE__namespace.Matrix4().multiplyMatrices(parentInv, st.originalMatrixWorld);
2688
+ // reparent (if needed)
2689
+ if (m.parent !== origParent) {
2690
+ origParent.add(m);
2691
+ }
2692
+ m.matrix.copy(localMat);
2693
+ m.matrix.decompose(m.position, m.quaternion, m.scale);
2694
+ m.updateMatrixWorld(true);
2695
+ }
2696
+ catch (e) {
2697
+ // fallback: set world position
2698
+ if (m.parent) {
2699
+ const local = tar.clone();
2700
+ m.parent.worldToLocal(local);
2701
+ m.position.copy(local);
2702
+ }
2703
+ else {
2704
+ m.position.copy(tar);
2705
+ }
2706
+ m.updateMatrixWorld(true);
2707
+ this.log(`restoreSet: error finalizing reparent for ${m.name || m.id}: ${e.message}`);
2708
+ }
2709
+ }
2710
+ else {
2711
+ // intermediate frames: lerp world -> convert to local relative to current parent
2712
+ if (m.parent) {
2713
+ const local = cur.clone();
2714
+ m.parent.worldToLocal(local);
2715
+ m.position.copy(local);
2716
+ }
2717
+ else {
2718
+ m.position.copy(cur);
2719
+ }
2720
+ m.updateMatrix();
2721
+ }
2722
+ }
2723
+ if (this.controls && typeof this.controls.update === 'function')
2724
+ this.controls.update();
2725
+ if (t < 1) {
2726
+ this.animId = requestAnimationFrame(tick);
2727
+ }
2728
+ else {
2729
+ this.animId = null;
2730
+ this.cleanContextsForMeshes(meshes);
2731
+ this.isExploded = false;
2732
+ this.log('restoreSet: completed and cleaned contexts for restored meshes');
2733
+ resolve();
2734
+ }
2735
+ };
2736
+ this.animId = requestAnimationFrame(tick);
2737
+ });
2738
+ }
2739
+ // material dim with context id
2740
+ applyDimToOthers(explodingMeshes, opacity = 0.25, opts) {
2741
+ const contextId = `ctx_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
2742
+ const explodingSet = new Set(explodingMeshes);
2743
+ const touched = new Set();
2744
+ this.scene.traverse((obj) => {
2745
+ if (!obj.isMesh)
2746
+ return;
2747
+ const mesh = obj;
2748
+ if (explodingSet.has(mesh))
2749
+ return;
2750
+ const applyMat = (mat) => {
2751
+ var _a;
2752
+ if (!this.materialSnaps.has(mat)) {
2753
+ this.materialSnaps.set(mat, {
2754
+ transparent: !!mat.transparent,
2755
+ opacity: (_a = mat.opacity) !== null && _a !== void 0 ? _a : 1,
2756
+ depthWrite: mat.depthWrite,
2757
+ });
2758
+ }
2759
+ let s = this.materialContexts.get(mat);
2760
+ if (!s) {
2761
+ s = new Set();
2762
+ this.materialContexts.set(mat, s);
2763
+ }
2764
+ s.add(contextId);
2765
+ touched.add(mat);
2766
+ mat.transparent = true;
2767
+ mat.opacity = opacity;
2768
+ mat.needsUpdate = true;
2769
+ };
2770
+ if (Array.isArray(mesh.material)) {
2771
+ mesh.material.forEach((m) => applyMat(m));
2772
+ }
2773
+ else if (mesh.material) {
2774
+ applyMat(mesh.material);
2775
+ }
2776
+ });
2777
+ this.contextMaterials.set(contextId, touched);
2778
+ this.log(`applyDimToOthers: context=${contextId}, touchedMaterials=${touched.size}`);
2779
+ return contextId;
2780
+ }
2781
+ // clean contexts for meshes (restore materials whose contexts are removed)
2782
+ cleanContextsForMeshes(meshes) {
2783
+ // conservative strategy: for each context we created, delete it and restore materials accordingly
2784
+ for (const [contextId, mats] of Array.from(this.contextMaterials.entries())) {
2785
+ mats.forEach((mat) => {
2786
+ const ctxSet = this.materialContexts.get(mat);
2787
+ if (ctxSet) {
2788
+ ctxSet.delete(contextId);
2789
+ if (ctxSet.size === 0) {
2790
+ const snap = this.materialSnaps.get(mat);
2791
+ if (snap) {
2792
+ mat.transparent = snap.transparent;
2793
+ mat.opacity = snap.opacity;
2794
+ if (typeof snap.depthWrite !== 'undefined')
2795
+ mat.depthWrite = snap.depthWrite;
2796
+ }
2797
+ mat.needsUpdate = true;
2798
+ this.materialContexts.delete(mat);
2799
+ this.materialSnaps.delete(mat);
2800
+ }
2801
+ else {
2802
+ this.materialContexts.set(mat, ctxSet);
2803
+ }
2804
+ }
2805
+ });
2806
+ this.contextMaterials.delete(contextId);
2807
+ this.log(`cleanContextsForMeshes: removed context ${contextId}`);
2808
+ }
2809
+ }
2810
+ // robust bounding sphere computation; if mesh world pos invalid, try stateMap's originalMatrixWorld as backoff
2811
+ computeBoundingSphereForMeshes(meshes) {
2812
+ const box = new THREE__namespace.Box3();
2813
+ meshes.forEach((m) => {
2814
+ try {
2815
+ m.updateMatrixWorld(true);
2816
+ const pos = new THREE__namespace.Vector3();
2817
+ m.getWorldPosition(pos);
2818
+ if (!isFinite(pos.x) || !isFinite(pos.y) || !isFinite(pos.z)) {
2819
+ // fallback to stateMap's originalMatrixWorld if available
2820
+ const st = this.stateMap.get(m);
2821
+ if (st) {
2822
+ const fallback = new THREE__namespace.Vector3();
2823
+ fallback.setFromMatrixPosition(st.originalMatrixWorld);
2824
+ if (isFinite(fallback.x) && isFinite(fallback.y) && isFinite(fallback.z)) {
2825
+ this.log(`computeBoundingSphereForMeshes: using stateMap originalMatrixWorld for mesh ${m.name || m.id}`);
2826
+ pos.copy(fallback);
2827
+ }
2828
+ else {
2829
+ this.log(`computeBoundingSphereForMeshes: skipping mesh ${m.name || m.id} due to invalid positions`);
2830
+ return;
2831
+ }
2832
+ }
2833
+ else {
2834
+ this.log(`computeBoundingSphereForMeshes: skipping mesh ${m.name || m.id} due to invalid world pos and no snapshot`);
2835
+ return;
2836
+ }
2837
+ }
2838
+ let radius = 0;
2839
+ const geom = m.geometry || null;
2840
+ if (geom) {
2841
+ if (!geom.boundingSphere)
2842
+ geom.computeBoundingSphere();
2843
+ if (geom.boundingSphere) {
2844
+ radius = geom.boundingSphere.radius;
2845
+ const ws = new THREE__namespace.Vector3();
2846
+ m.getWorldScale(ws);
2847
+ radius = radius * Math.max(ws.x, ws.y, ws.z, 1e-6);
2848
+ }
2849
+ }
2850
+ if (!isFinite(radius) || radius < 0 || radius > 1e8)
2851
+ radius = 0;
2852
+ const min = pos.clone().addScalar(-radius);
2853
+ const max = pos.clone().addScalar(radius);
2854
+ box.expandByPoint(min);
2855
+ box.expandByPoint(max);
2856
+ }
2857
+ catch (e) {
2858
+ this.log(`computeBoundingSphereForMeshes: error for mesh ${m.name || m.id}: ${e.message}`);
2859
+ }
2860
+ });
2861
+ const center = new THREE__namespace.Vector3();
2862
+ if (box.isEmpty()) {
2863
+ center.set(0, 0, 0);
2864
+ box.expandByPoint(center);
2865
+ }
2866
+ box.getCenter(center);
2867
+ const sphere = new THREE__namespace.Sphere();
2868
+ box.getBoundingSphere(sphere);
2869
+ const radius = sphere.radius || Math.max(box.getSize(new THREE__namespace.Vector3()).length() * 0.5, 1.0);
2870
+ return { center, radius };
2871
+ }
2872
+ // compute bounding sphere for positions + mesh radii
2873
+ computeBoundingSphereForPositionsAndMeshes(positions, meshes) {
2874
+ const box = new THREE__namespace.Box3();
2875
+ for (let i = 0; i < positions.length; i++) {
2876
+ const p = positions[i];
2877
+ if (!isFinite(p.x) || !isFinite(p.y) || !isFinite(p.z)) {
2878
+ this.log(`computeBoundingSphereForPositionsAndMeshes: skipping invalid target pos idx=${i}`);
2879
+ continue;
2880
+ }
2881
+ let radius = 0;
2882
+ const m = meshes[i];
2883
+ try {
2884
+ const geom = m.geometry || null;
2885
+ if (geom) {
2886
+ if (!geom.boundingSphere)
2887
+ geom.computeBoundingSphere();
2888
+ if (geom.boundingSphere) {
2889
+ radius = geom.boundingSphere.radius;
2890
+ const ws = new THREE__namespace.Vector3();
2891
+ m.getWorldScale(ws);
2892
+ radius = radius * Math.max(ws.x, ws.y, ws.z, 1e-6);
2893
+ }
2894
+ }
2895
+ }
2896
+ catch (_a) {
2897
+ radius = 0;
2898
+ }
2899
+ if (!isFinite(radius) || radius < 0 || radius > 1e8)
2900
+ radius = 0;
2901
+ const min = p.clone().addScalar(-radius);
2902
+ const max = p.clone().addScalar(radius);
2903
+ box.expandByPoint(min);
2904
+ box.expandByPoint(max);
2905
+ }
2906
+ const center = new THREE__namespace.Vector3();
2907
+ if (box.isEmpty()) {
2908
+ center.set(0, 0, 0);
2909
+ box.expandByPoint(center);
2910
+ }
2911
+ box.getCenter(center);
2912
+ const tmp = new THREE__namespace.Sphere();
2913
+ box.getBoundingSphere(tmp);
2914
+ const radius = tmp.radius || Math.max(box.getSize(new THREE__namespace.Vector3()).length() * 0.5, 1.0);
2915
+ return { center, radius };
2916
+ }
2917
+ // computeTargetsByMode (unchanged logic but pure function)
2918
+ computeTargetsByMode(meshes, center, baseRadius, opts) {
2919
+ var _a, _b;
2920
+ const n = meshes.length;
2921
+ const lift = (_a = opts.lift) !== null && _a !== void 0 ? _a : 0.5;
2922
+ const mode = (_b = opts.mode) !== null && _b !== void 0 ? _b : 'ring';
2923
+ const targets = [];
2924
+ if (mode === 'ring') {
2925
+ for (let i = 0; i < n; i++) {
2926
+ const angle = (i / n) * Math.PI * 2;
2927
+ targets.push(new THREE__namespace.Vector3(center.x + Math.cos(angle) * baseRadius, center.y + lift, center.z + Math.sin(angle) * baseRadius));
2928
+ }
2929
+ return targets;
2930
+ }
2931
+ if (mode === 'spiral') {
2932
+ const turns = Math.max(1, Math.ceil(n / 6));
2933
+ for (let i = 0; i < n; i++) {
2934
+ const t = i / (n - 1 || 1);
2935
+ const angle = t * Math.PI * 2 * turns;
2936
+ const radius = baseRadius * (0.3 + 0.7 * t);
2937
+ targets.push(new THREE__namespace.Vector3(center.x + Math.cos(angle) * radius, center.y + lift + t * 0.5, center.z + Math.sin(angle) * radius));
2938
+ }
2939
+ return targets;
2940
+ }
2941
+ if (mode === 'grid') {
2942
+ const cols = Math.ceil(Math.sqrt(n));
2943
+ const rows = Math.ceil(n / cols);
2944
+ const spacing = Math.max(0.5, baseRadius / Math.max(cols, rows));
2945
+ const startX = center.x - ((cols - 1) * spacing) / 2;
2946
+ const startZ = center.z - ((rows - 1) * spacing) / 2;
2947
+ for (let i = 0; i < n; i++) {
2948
+ const r = Math.floor(i / cols);
2949
+ const c = i % cols;
2950
+ targets.push(new THREE__namespace.Vector3(startX + c * spacing, center.y + lift, startZ + r * spacing));
2951
+ }
2952
+ return targets;
2953
+ }
2954
+ // radial
2955
+ for (let i = 0; i < n; i++) {
2956
+ const t = i / n;
2957
+ const angle = t * Math.PI * 2;
2958
+ const r = baseRadius * (1 + (i % 3) * 0.4 + Math.floor(i / 3) * 0.6);
2959
+ targets.push(new THREE__namespace.Vector3(center.x + Math.cos(angle) * r, center.y + lift + Math.floor(i / 3) * 0.2, center.z + Math.sin(angle) * r));
2960
+ }
2961
+ return targets;
2962
+ }
2963
+ animateCameraToFit(targetCenter, targetRadius, opts) {
2964
+ var _a, _b, _c;
2965
+ const duration = (_a = opts === null || opts === void 0 ? void 0 : opts.duration) !== null && _a !== void 0 ? _a : 600;
2966
+ const padding = (_b = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _b !== void 0 ? _b : 1.5;
2967
+ if (!(this.camera instanceof THREE__namespace.PerspectiveCamera)) {
2968
+ if (this.controls && this.controls.target) {
2969
+ this.controls.target.copy(targetCenter);
2970
+ if (typeof this.controls.update === 'function')
2971
+ this.controls.update();
2972
+ }
2973
+ return Promise.resolve();
2974
+ }
2975
+ const cam = this.camera;
2976
+ const fov = (cam.fov * Math.PI) / 180;
2977
+ const safeRadius = isFinite(targetRadius) && targetRadius > 0 ? targetRadius : 1;
2978
+ const desiredDistance = Math.min(1e6, (safeRadius * ((_c = opts === null || opts === void 0 ? void 0 : opts.padding) !== null && _c !== void 0 ? _c : padding)) / Math.sin(fov / 2));
2979
+ const camPos = cam.position.clone();
2980
+ const dir = camPos.clone().sub(targetCenter);
2981
+ if (dir.length() === 0)
2982
+ dir.set(0, 0, 1);
2983
+ else
2984
+ dir.normalize();
2985
+ const newCamPos = targetCenter.clone().add(dir.multiplyScalar(desiredDistance));
2986
+ const startPos = cam.position.clone();
2987
+ const startTarget = (this.controls && this.controls.target) ? (this.controls.target.clone()) : this.getCameraLookAtPoint();
2988
+ const endTarget = targetCenter.clone();
2989
+ const startTime = performance.now();
2990
+ return new Promise((resolve) => {
2991
+ const tick = (now) => {
2992
+ const t = Math.min(1, (now - startTime) / Math.max(1, duration));
2993
+ const eased = easeInOutQuad(t);
2994
+ cam.position.lerpVectors(startPos, newCamPos, eased);
2995
+ if (this.controls && this.controls.target)
2996
+ this.controls.target.lerpVectors(startTarget, endTarget, eased);
2997
+ cam.updateProjectionMatrix();
2998
+ if (this.controls && typeof this.controls.update === 'function')
2999
+ this.controls.update();
3000
+ if (t < 1)
3001
+ this.cameraAnimId = requestAnimationFrame(tick);
3002
+ else {
3003
+ this.cameraAnimId = null;
3004
+ this.log(`animateCameraToFit: done. center=${targetCenter.toArray().map((n) => n.toFixed(2))}, radius=${targetRadius.toFixed(2)}`);
3005
+ resolve();
3006
+ }
3007
+ };
3008
+ this.cameraAnimId = requestAnimationFrame(tick);
3009
+ });
3010
+ }
3011
+ getCameraLookAtPoint() {
3012
+ const dir = new THREE__namespace.Vector3();
3013
+ this.camera.getWorldDirection(dir);
3014
+ return this.camera.position.clone().add(dir.multiplyScalar(10));
3015
+ }
3016
+ cancelAnimations() {
3017
+ if (this.animId) {
3018
+ cancelAnimationFrame(this.animId);
3019
+ this.animId = null;
3020
+ }
3021
+ if (this.cameraAnimId) {
3022
+ cancelAnimationFrame(this.cameraAnimId);
3023
+ this.cameraAnimId = null;
3024
+ }
3025
+ }
3026
+ dispose() {
3027
+ return __awaiter(this, arguments, void 0, function* (restoreBefore = true) {
3028
+ this.cancelAnimations();
3029
+ if (restoreBefore && this.isExploded) {
3030
+ try {
3031
+ yield this.restore(200);
3032
+ }
3033
+ catch (_a) { }
3034
+ }
3035
+ // force restore of materials
3036
+ for (const [mat, ctxs] of Array.from(this.materialContexts.entries())) {
3037
+ const snap = this.materialSnaps.get(mat);
3038
+ if (snap) {
3039
+ mat.transparent = snap.transparent;
3040
+ mat.opacity = snap.opacity;
3041
+ if (typeof snap.depthWrite !== 'undefined')
3042
+ mat.depthWrite = snap.depthWrite;
3043
+ mat.needsUpdate = true;
3044
+ }
3045
+ this.materialContexts.delete(mat);
3046
+ this.materialSnaps.delete(mat);
3047
+ }
3048
+ this.contextMaterials.clear();
3049
+ this.stateMap.clear();
3050
+ this.prevStateMap.clear();
3051
+ this.currentSet = null;
3052
+ this.prevSet = null;
3053
+ this.isInitialized = false;
3054
+ this.isExploded = false;
3055
+ this.log('dispose: cleaned up');
3056
+ });
3057
+ }
3058
+ }
3059
+
3060
+ /**
3061
+ * 自动设置相机与基础灯光 - 优化版
3062
+ *
3063
+ * ✨ 优化内容:
3064
+ * - 添加灯光强度调整方法
3065
+ * - 完善错误处理
3066
+ * - 优化dispose逻辑
3067
+ *
3068
+ * - camera: THREE.PerspectiveCamera(会被移动并指向模型中心)
3069
+ * - scene: THREE.Scene(会把新创建的 light group 加入 scene)
3070
+ * - model: THREE.Object3D 已加载的模型(任意 transform/坐标)
3071
+ * - options: 可选配置(见 AutoSetupOptions)
3072
+ *
3073
+ * 返回 AutoSetupHandle,调用方在组件卸载/切换时请调用 handle.dispose()
3074
+ */
3075
+ function autoSetupCameraAndLight(camera, scene, model, options = {}) {
3076
+ var _a, _b, _c, _d, _e, _f, _g;
3077
+ // ✨ 边界检查
3078
+ if (!camera || !scene || !model) {
3079
+ throw new Error('autoSetupCameraAndLight: camera, scene, model are required');
3080
+ }
3081
+ const opts = {
3082
+ padding: (_a = options.padding) !== null && _a !== void 0 ? _a : 1.2,
3083
+ elevation: (_b = options.elevation) !== null && _b !== void 0 ? _b : 0.2,
3084
+ enableShadows: (_c = options.enableShadows) !== null && _c !== void 0 ? _c : false,
3085
+ shadowMapSize: (_d = options.shadowMapSize) !== null && _d !== void 0 ? _d : 1024,
3086
+ directionalCount: (_e = options.directionalCount) !== null && _e !== void 0 ? _e : 4,
3087
+ setMeshShadowProps: (_f = options.setMeshShadowProps) !== null && _f !== void 0 ? _f : true,
3088
+ renderer: (_g = options.renderer) !== null && _g !== void 0 ? _g : null,
3089
+ };
3090
+ try {
3091
+ // --- 1) 计算包围数据
3092
+ const box = new THREE__namespace.Box3().setFromObject(model);
3093
+ // ✨ 检查包围盒有效性
3094
+ if (!isFinite(box.min.x)) {
3095
+ throw new Error('autoSetupCameraAndLight: Invalid bounding box');
3096
+ }
3097
+ const sphere = new THREE__namespace.Sphere();
3098
+ box.getBoundingSphere(sphere);
3099
+ const center = sphere.center.clone();
3100
+ const radius = Math.max(0.001, sphere.radius);
3101
+ // --- 2) 计算相机位置
3102
+ const fov = (camera.fov * Math.PI) / 180;
3103
+ const halfFov = fov / 2;
3104
+ const sinHalfFov = Math.max(Math.sin(halfFov), 0.001);
3105
+ const distance = (radius * opts.padding) / sinHalfFov;
3106
+ const dir = new THREE__namespace.Vector3(0, Math.sin(opts.elevation), Math.cos(opts.elevation)).normalize();
3107
+ const desiredPos = center.clone().add(dir.multiplyScalar(distance));
3108
+ camera.position.copy(desiredPos);
3109
+ camera.lookAt(center);
3110
+ camera.near = Math.max(0.001, radius / 1000);
3111
+ camera.far = Math.max(1000, radius * 50);
3112
+ camera.updateProjectionMatrix();
3113
+ // --- 3) 启用阴影
3114
+ if (opts.renderer && opts.enableShadows) {
3115
+ opts.renderer.shadowMap.enabled = true;
3116
+ opts.renderer.shadowMap.type = THREE__namespace.PCFSoftShadowMap;
3117
+ }
3118
+ // --- 4) 创建灯光组
3119
+ const lightsGroup = new THREE__namespace.Group();
3120
+ lightsGroup.name = 'autoSetupLightsGroup';
3121
+ lightsGroup.position.copy(center);
3122
+ scene.add(lightsGroup);
3123
+ // 4.1 基础光
3124
+ const hemi = new THREE__namespace.HemisphereLight(0xffffff, 0x444444, 0.6);
3125
+ hemi.name = 'auto_hemi';
3126
+ hemi.position.set(0, radius * 2.0, 0);
3127
+ lightsGroup.add(hemi);
3128
+ const ambient = new THREE__namespace.AmbientLight(0xffffff, 0.25);
3129
+ ambient.name = 'auto_ambient';
3130
+ lightsGroup.add(ambient);
3131
+ // 4.2 方向光
3132
+ const dirCount = Math.max(1, Math.floor(opts.directionalCount));
3133
+ const directionalLights = [];
3134
+ const dirs = [];
3135
+ dirs.push(new THREE__namespace.Vector3(0, 1, 0));
3136
+ for (let i = 0; i < Math.max(1, dirCount); i++) {
3137
+ const angle = (i / Math.max(1, dirCount)) * Math.PI * 2;
3138
+ const v = new THREE__namespace.Vector3(Math.cos(angle), 0.3, Math.sin(angle)).normalize();
3139
+ dirs.push(v);
3140
+ }
3141
+ const shadowCamSize = Math.max(1, radius * 1.5);
3142
+ for (let i = 0; i < dirs.length; i++) {
3143
+ const d = dirs[i];
3144
+ const light = new THREE__namespace.DirectionalLight(0xffffff, i === 0 ? 1.5 : 1.2);
3145
+ light.position.copy(d.clone().multiplyScalar(radius * 2.5));
3146
+ light.target.position.copy(center);
3147
+ light.name = `auto_dir_${i}`;
3148
+ lightsGroup.add(light);
3149
+ lightsGroup.add(light.target);
3150
+ if (opts.enableShadows) {
3151
+ light.castShadow = true;
3152
+ light.shadow.mapSize.width = opts.shadowMapSize;
3153
+ light.shadow.mapSize.height = opts.shadowMapSize;
3154
+ const cam = light.shadow.camera;
3155
+ const s = shadowCamSize;
3156
+ cam.left = -s;
3157
+ cam.right = s;
3158
+ cam.top = s;
3159
+ cam.bottom = -s;
3160
+ cam.near = 0.1;
3161
+ cam.far = radius * 10 + 50;
3162
+ light.shadow.bias = -0.0005;
3163
+ }
3164
+ directionalLights.push(light);
3165
+ }
3166
+ // 4.3 点光补光
3167
+ const fill1 = new THREE__namespace.PointLight(0xffffff, 0.5, radius * 4);
3168
+ fill1.position.copy(center).add(new THREE__namespace.Vector3(radius * 0.5, 0.2 * radius, 0));
3169
+ fill1.name = 'auto_fill1';
3170
+ lightsGroup.add(fill1);
3171
+ const fill2 = new THREE__namespace.PointLight(0xffffff, 0.3, radius * 3);
3172
+ fill2.position.copy(center).add(new THREE__namespace.Vector3(-radius * 0.5, -0.2 * radius, 0));
3173
+ fill2.name = 'auto_fill2';
3174
+ lightsGroup.add(fill2);
3175
+ // --- 5) 设置 Mesh 阴影属性
3176
+ if (opts.setMeshShadowProps) {
3177
+ model.traverse((ch) => {
3178
+ if (ch.isMesh) {
3179
+ const mesh = ch;
3180
+ const isSkinned = mesh.isSkinnedMesh;
3181
+ mesh.castShadow = opts.enableShadows && !isSkinned ? true : mesh.castShadow;
3182
+ mesh.receiveShadow = opts.enableShadows ? true : mesh.receiveShadow;
3183
+ }
3184
+ });
3185
+ }
3186
+ // --- 6) 返回 handle ---
3187
+ const handle = {
3188
+ lightsGroup,
3189
+ center,
3190
+ radius,
3191
+ // ✨ 新增灯光强度调整
3192
+ updateLightIntensity(factor) {
3193
+ lightsGroup.traverse((node) => {
3194
+ if (node.isLight) {
3195
+ const light = node;
3196
+ const originalIntensity = parseFloat(light.name.split('_').pop() || '1');
3197
+ light.intensity = originalIntensity * Math.max(0, factor);
3198
+ }
3199
+ });
3200
+ },
3201
+ dispose: () => {
3202
+ try {
3203
+ // 移除灯光组
3204
+ if (lightsGroup.parent)
3205
+ lightsGroup.parent.remove(lightsGroup);
3206
+ // 清理阴影资源
3207
+ lightsGroup.traverse((node) => {
3208
+ if (node.isLight) {
3209
+ const l = node;
3210
+ if (l.shadow && l.shadow.map) {
3211
+ try {
3212
+ l.shadow.map.dispose();
3213
+ }
3214
+ catch (err) {
3215
+ console.warn('Failed to dispose shadow map:', err);
3216
+ }
3217
+ }
3218
+ }
3219
+ });
3220
+ }
3221
+ catch (error) {
3222
+ console.error('autoSetupCameraAndLight: dispose failed', error);
3223
+ }
3224
+ }
3225
+ };
3226
+ return handle;
3227
+ }
3228
+ catch (error) {
3229
+ console.error('autoSetupCameraAndLight: setup failed', error);
3230
+ throw error;
3231
+ }
3232
+ }
3233
+
3234
+ /**
3235
+ * three-model-render
3236
+ * Professional Three.js Model Visualization and Interaction Toolkit
3237
+ *
3238
+ * @packageDocumentation
3239
+ */
3240
+ // Core utilities
3241
+ // Version
3242
+ const VERSION = '1.0.0';
3243
+
3244
+ exports.ArrowGuide = ArrowGuide;
3245
+ exports.BlueSky = BlueSky;
3246
+ exports.FOLLOW_ANGLES = FOLLOW_ANGLES;
3247
+ exports.GroupExploder = GroupExploder;
3248
+ exports.LiquidFillerGroup = LiquidFillerGroup;
3249
+ exports.VERSION = VERSION;
3250
+ exports.ViewPresets = ViewPresets;
3251
+ exports.addChildModelLabels = addChildModelLabels;
3252
+ exports.autoSetupCameraAndLight = autoSetupCameraAndLight;
3253
+ exports.cancelFollow = cancelFollow;
3254
+ exports.cancelSetView = cancelSetView;
3255
+ exports.createModelClickHandler = createModelClickHandler;
3256
+ exports.createModelsLabel = createModelsLabel;
3257
+ exports.disposeMaterial = disposeMaterial;
3258
+ exports.disposeObject = disposeObject;
3259
+ exports.enableHoverBreath = enableHoverBreath;
3260
+ exports.followModels = followModels;
3261
+ exports.initPostProcessing = initPostProcessing;
3262
+ exports.loadCubeSkybox = loadCubeSkybox;
3263
+ exports.loadEquirectSkybox = loadEquirectSkybox;
3264
+ exports.loadModelByUrl = loadModelByUrl;
3265
+ exports.loadSkybox = loadSkybox;
3266
+ exports.releaseSkybox = releaseSkybox;
3267
+ exports.setView = setView;
3268
+ //# sourceMappingURL=index.js.map