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