@chocozhang/three-model-render 1.0.3 → 1.0.5

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