@fengyizhi/scene-lib 1.0.3 → 1.0.4

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.
@@ -168,6 +168,7 @@ class SceneStore {
168
168
  this.doc.environment = doc.environment;
169
169
  this.doc.flyPaths = doc.flyPaths;
170
170
  this.doc.dashboard = doc.dashboard;
171
+ this.doc.instanceGroups = doc.instanceGroups;
171
172
  this._selection = [];
172
173
  // 加载文档时清空撤销/重做历史
173
174
  this.history.clear();
@@ -204,6 +205,44 @@ class AddNodeCommand {
204
205
  }
205
206
  }
206
207
  }
208
+ /** 批量添加节点(用于复制操作,一次撤销全部) */
209
+ class BatchAddCommand {
210
+ label;
211
+ nodes;
212
+ parentIds;
213
+ constructor(items) {
214
+ this.nodes = items.map(i => i.node);
215
+ this.parentIds = items.map(i => i.parentId);
216
+ this.label = `批量添加 (${items.length} 个)`;
217
+ }
218
+ do(store) {
219
+ for (let i = 0; i < this.nodes.length; i++) {
220
+ const node = this.nodes[i];
221
+ const parentId = this.parentIds[i];
222
+ if (!store.node(node.id)) {
223
+ store.doc.nodes.push(clone(node));
224
+ }
225
+ if (parentId) {
226
+ const p = store.node(parentId);
227
+ if (p && !p.children.includes(node.id))
228
+ p.children.push(node.id);
229
+ }
230
+ }
231
+ }
232
+ undo(store) {
233
+ const ids = new Set(this.nodes.map(n => n.id));
234
+ store.doc.nodes = store.doc.nodes.filter(n => !ids.has(n.id));
235
+ for (let i = 0; i < this.parentIds.length; i++) {
236
+ const parentId = this.parentIds[i];
237
+ const node = this.nodes[i];
238
+ if (parentId) {
239
+ const p = store.node(parentId);
240
+ if (p)
241
+ p.children = p.children.filter(c => c !== node.id);
242
+ }
243
+ }
244
+ }
245
+ }
207
246
  class RemoveNodeCommand {
208
247
  id;
209
248
  label = '删除节点';
@@ -265,6 +304,7 @@ class GroupCommand {
265
304
  originalParents = new Map();
266
305
  originalPositions = new Map();
267
306
  removedEmptyGroups = [];
307
+ commonParentId = null;
268
308
  constructor(selectedIds) {
269
309
  this.selectedIds = selectedIds;
270
310
  this.groupId = nextId('grp');
@@ -330,9 +370,27 @@ class GroupCommand {
330
370
  ];
331
371
  }
332
372
  }
373
+ // 找到共同父节点:如果所有选中节点都在同一个父节点下,组也应该在该父节点下
374
+ const parentIds = [...this.originalParents.values()];
375
+ const allSameParent = parentIds.length > 0 && parentIds.every(p => p === parentIds[0]);
376
+ this.commonParentId = allSameParent ? parentIds[0] : null;
377
+ if (this.commonParentId) {
378
+ // 添加到共同父节点下
379
+ const parentNode = store.node(this.commonParentId);
380
+ if (parentNode) {
381
+ parentNode.children.push(group.id);
382
+ }
383
+ }
333
384
  store.doc.nodes.push(clone(group));
334
385
  }
335
386
  undo(store) {
387
+ // 从共同父节点中移除 group(如果有的话)
388
+ if (this.commonParentId) {
389
+ const parentNode = store.node(this.commonParentId);
390
+ if (parentNode) {
391
+ parentNode.children = parentNode.children.filter(c => c !== this.groupId);
392
+ }
393
+ }
336
394
  // 删除新创建的 group
337
395
  store.doc.nodes = store.doc.nodes.filter(n => n.id !== this.groupId);
338
396
  // 恢复被删除的空组
@@ -355,6 +413,23 @@ class GroupCommand {
355
413
  }
356
414
  }
357
415
  }
416
+ /** 计算节点的世界位置(只考虑平移,忽略旋转缩放) */
417
+ function computeWorldPosition(store, nodeId) {
418
+ const node = store.node(nodeId);
419
+ if (!node)
420
+ return [0, 0, 0];
421
+ const parent = store.parentOf(nodeId);
422
+ if (!parent) {
423
+ // 根节点,局部坐标就是世界坐标
424
+ return [...node.position];
425
+ }
426
+ const parentWorld = computeWorldPosition(store, parent.id);
427
+ return [
428
+ parentWorld[0] + node.position[0],
429
+ parentWorld[1] + node.position[1],
430
+ parentWorld[2] + node.position[2],
431
+ ];
432
+ }
358
433
  /** 解除合并:将 group 的子节点提升到 group 的父节点下,删除 group */
359
434
  class UngroupCommand {
360
435
  label = '解除合并';
@@ -362,6 +437,10 @@ class UngroupCommand {
362
437
  children = [];
363
438
  groupParentId = null;
364
439
  groupName = '';
440
+ /** 记录 group 的变换(用于 undo 恢复) */
441
+ groupTransform = null;
442
+ /** 记录子节点在 group 内的原始位置(用于 undo 恢复) */
443
+ originalChildPositions = new Map();
365
444
  constructor(groupId) {
366
445
  this.groupId = groupId;
367
446
  }
@@ -372,6 +451,39 @@ class UngroupCommand {
372
451
  this.groupParentId = store.parentOf(this.groupId)?.id ?? null;
373
452
  this.children = [...group.children];
374
453
  this.groupName = group.name;
454
+ this.originalChildPositions.clear();
455
+ // 记录 group 的变换状态
456
+ this.groupTransform = {
457
+ position: [...group.position],
458
+ rotation: [...group.rotation],
459
+ scale: [...group.scale],
460
+ };
461
+ // 计算 group 的世界位置
462
+ const groupWorldPos = computeWorldPosition(store, this.groupId);
463
+ // 计算目标父节点的世界位置
464
+ const parentWorldPos = this.groupParentId
465
+ ? computeWorldPosition(store, this.groupParentId)
466
+ : [0, 0, 0];
467
+ // 记录原始位置,并转换子节点坐标
468
+ for (const childId of this.children) {
469
+ const child = store.node(childId);
470
+ if (!child)
471
+ continue;
472
+ // 记录原始位置(group 的局部坐标)
473
+ this.originalChildPositions.set(childId, [...child.position]);
474
+ // 计算子节点的世界位置
475
+ const childWorldPos = [
476
+ groupWorldPos[0] + child.position[0],
477
+ groupWorldPos[1] + child.position[1],
478
+ groupWorldPos[2] + child.position[2],
479
+ ];
480
+ // 转换为父节点的局部坐标
481
+ child.position = [
482
+ childWorldPos[0] - parentWorldPos[0],
483
+ childWorldPos[1] - parentWorldPos[1],
484
+ childWorldPos[2] - parentWorldPos[2],
485
+ ];
486
+ }
375
487
  // 将子节点移到 group 的父节点下
376
488
  if (this.groupParentId) {
377
489
  const parent = store.node(this.groupParentId);
@@ -393,6 +505,12 @@ class UngroupCommand {
393
505
  const group = createNode('group', this.groupName || '组');
394
506
  group.id = this.groupId;
395
507
  group.children = [...this.children];
508
+ // 恢复 group 的变换状态
509
+ if (this.groupTransform) {
510
+ group.position = [...this.groupTransform.position];
511
+ group.rotation = [...this.groupTransform.rotation];
512
+ group.scale = [...this.groupTransform.scale];
513
+ }
396
514
  store.doc.nodes.push(clone(group));
397
515
  // 从当前父节点移除子节点
398
516
  for (const id of this.children) {
@@ -411,6 +529,13 @@ class UngroupCommand {
411
529
  }
412
530
  }
413
531
  }
532
+ // 恢复子节点的原始位置(group 的局部坐标)
533
+ for (const [id, pos] of this.originalChildPositions) {
534
+ const child = store.node(id);
535
+ if (child) {
536
+ child.position = [...pos];
537
+ }
538
+ }
414
539
  }
415
540
  }
416
541
  /** 材质覆盖命令:记录 before/after,支持 undo/redo */
@@ -535,13 +660,17 @@ class SetAlarmCommand {
535
660
  }
536
661
  }
537
662
  }
538
- /** 拖动节点改变父节点 */
663
+ /** 拖动节点改变父节点(保持世界坐标不变) */
539
664
  class ReparentCommand {
540
665
  nodeId;
541
666
  newParentId;
542
667
  label = '移动节点';
543
668
  oldParentId = null;
544
669
  oldIndex = 0;
670
+ /** 记录旧位置(用于 undo 恢复) */
671
+ oldPosition = null;
672
+ oldRotation = null;
673
+ oldScale = null;
545
674
  constructor(nodeId, newParentId) {
546
675
  this.nodeId = nodeId;
547
676
  this.newParentId = newParentId;
@@ -558,6 +687,12 @@ class ReparentCommand {
558
687
  this.oldIndex = oldParent.children.indexOf(this.nodeId);
559
688
  }
560
689
  }
690
+ // 记录原始变换(用于 undo 恢复)
691
+ this.oldPosition = [...node.position];
692
+ this.oldRotation = [...node.rotation];
693
+ this.oldScale = [...node.scale];
694
+ // 计算世界坐标(只考虑平移)
695
+ const worldPos = computeWorldPosition(store, this.nodeId);
561
696
  // 从旧父节点移除
562
697
  if (this.oldParentId) {
563
698
  const oldParent = store.node(this.oldParentId);
@@ -572,8 +707,22 @@ class ReparentCommand {
572
707
  newParent.children.push(this.nodeId);
573
708
  }
574
709
  }
710
+ // 转换坐标:世界坐标 → 新父节点的局部坐标
711
+ if (this.newParentId) {
712
+ const newParentWorld = computeWorldPosition(store, this.newParentId);
713
+ node.position = [
714
+ worldPos[0] - newParentWorld[0],
715
+ worldPos[1] - newParentWorld[1],
716
+ worldPos[2] - newParentWorld[2],
717
+ ];
718
+ }
719
+ else {
720
+ // 移动到根级,直接使用世界坐标
721
+ node.position = worldPos;
722
+ }
575
723
  }
576
724
  undo(store) {
725
+ const node = store.node(this.nodeId);
577
726
  // 从当前父节点移除
578
727
  const currentParent = store.parentOf(this.nodeId);
579
728
  if (currentParent) {
@@ -586,6 +735,14 @@ class ReparentCommand {
586
735
  oldParent.children.splice(this.oldIndex, 0, this.nodeId);
587
736
  }
588
737
  }
738
+ // 恢复原始变换
739
+ if (node && this.oldPosition) {
740
+ node.position = [...this.oldPosition];
741
+ if (this.oldRotation)
742
+ node.rotation = [...this.oldRotation];
743
+ if (this.oldScale)
744
+ node.scale = [...this.oldScale];
745
+ }
589
746
  }
590
747
  }
591
748
  /** 环境配置命令:背景色、半球光、平行光、HDRI、场景空间 */
@@ -598,15 +755,9 @@ class SetEnvironmentCommand {
598
755
  this.after = after;
599
756
  }
600
757
  do(store) {
601
- const floorColor = this.after.room?.floorColor || 'none';
602
- const floorTexture = this.after.room?.floorTexture?.substring(0, 30) || 'no texture';
603
- console.log('[SetEnvironmentCommand.do] applying - floorColor:', floorColor, 'floorTexture:', floorTexture);
604
758
  store.doc.environment = clone(this.after);
605
759
  }
606
760
  undo(store) {
607
- const floorColor = this.before?.room?.floorColor || 'none';
608
- const floorTexture = this.before?.room?.floorTexture?.substring(0, 30) || 'no texture';
609
- console.log('[SetEnvironmentCommand.undo] restoring - floorColor:', floorColor, 'floorTexture:', floorTexture);
610
761
  if (this.before) {
611
762
  store.doc.environment = clone(this.before);
612
763
  }
@@ -1239,6 +1390,142 @@ class MovePathPointToEndCommand {
1239
1390
  }
1240
1391
  }
1241
1392
  }
1393
+ /** 对齐命令:将多个节点对齐到指定轴向的指定位置 */
1394
+ class AlignCommand {
1395
+ nodeIds;
1396
+ axis;
1397
+ position;
1398
+ targetPositions;
1399
+ label;
1400
+ beforePositions = new Map();
1401
+ constructor(nodeIds, axis, position, targetPositions) {
1402
+ this.nodeIds = nodeIds;
1403
+ this.axis = axis;
1404
+ this.position = position;
1405
+ this.targetPositions = targetPositions;
1406
+ const axisNames = { x: '红轴(X)', y: '绿轴(Y)', z: '蓝轴(Z)' };
1407
+ const positionNames = { min: '负方向', center: '居中', max: '正方向' };
1408
+ this.label = `对齐 ${axisNames[axis]} ${positionNames[position]}`;
1409
+ }
1410
+ do(store) {
1411
+ // 记录原始位置(用于 undo)
1412
+ this.beforePositions.clear();
1413
+ for (const id of this.nodeIds) {
1414
+ const node = store.node(id);
1415
+ if (node) {
1416
+ this.beforePositions.set(id, [...node.position]);
1417
+ }
1418
+ }
1419
+ // 应用新位置
1420
+ for (const [id, pos] of this.targetPositions) {
1421
+ const node = store.node(id);
1422
+ if (node) {
1423
+ node.position = clone(pos);
1424
+ }
1425
+ }
1426
+ }
1427
+ undo(store) {
1428
+ for (const [id, pos] of this.beforePositions) {
1429
+ const node = store.node(id);
1430
+ if (node) {
1431
+ node.position = clone(pos);
1432
+ }
1433
+ }
1434
+ }
1435
+ }
1436
+ /** 启用实例化渲染 */
1437
+ class EnableInstancingCommand {
1438
+ nodeIds;
1439
+ label = '启用实例化渲染';
1440
+ groupId;
1441
+ constructor(nodeIds) {
1442
+ this.nodeIds = nodeIds;
1443
+ this.groupId = nextId('inst');
1444
+ }
1445
+ do(store) {
1446
+ if (!store.doc.instanceGroups)
1447
+ store.doc.instanceGroups = [];
1448
+ // 获取 assetId(从第一个节点)
1449
+ const firstNode = store.node(this.nodeIds[0]);
1450
+ if (!firstNode?.assetId)
1451
+ return;
1452
+ // 检查是否已存在同 assetId 的实例组
1453
+ const existing = store.doc.instanceGroups.find(g => g.assetId === firstNode.assetId);
1454
+ if (existing) {
1455
+ // 添加到现有组
1456
+ for (const id of this.nodeIds) {
1457
+ if (!existing.nodeIds.includes(id))
1458
+ existing.nodeIds.push(id);
1459
+ }
1460
+ this.groupId = existing.id;
1461
+ }
1462
+ else {
1463
+ // 创建新组
1464
+ store.doc.instanceGroups.push({
1465
+ id: this.groupId,
1466
+ assetId: firstNode.assetId,
1467
+ nodeIds: [...this.nodeIds],
1468
+ });
1469
+ }
1470
+ }
1471
+ undo(store) {
1472
+ if (!store.doc.instanceGroups)
1473
+ return;
1474
+ const group = store.doc.instanceGroups.find(g => g.id === this.groupId);
1475
+ if (group) {
1476
+ // 移除添加的节点
1477
+ group.nodeIds = group.nodeIds.filter(id => !this.nodeIds.includes(id));
1478
+ // 如果组为空,删除组
1479
+ if (group.nodeIds.length === 0) {
1480
+ store.doc.instanceGroups = store.doc.instanceGroups.filter(g => g.id !== this.groupId);
1481
+ }
1482
+ }
1483
+ }
1484
+ }
1485
+ /** 禁用实例化渲染 */
1486
+ class DisableInstancingCommand {
1487
+ nodeIds;
1488
+ label = '禁用实例化渲染';
1489
+ removedGroup = null;
1490
+ constructor(nodeIds) {
1491
+ this.nodeIds = nodeIds;
1492
+ }
1493
+ do(store) {
1494
+ if (!store.doc.instanceGroups)
1495
+ return;
1496
+ // 找到包含这些节点的实例组
1497
+ const group = store.doc.instanceGroups.find(g => this.nodeIds.some(id => g.nodeIds.includes(id)));
1498
+ if (group) {
1499
+ // 保存用于 undo
1500
+ this.removedGroup = { id: group.id, assetId: group.assetId, nodeIds: [...group.nodeIds] };
1501
+ // 移除这些节点
1502
+ group.nodeIds = group.nodeIds.filter(id => !this.nodeIds.includes(id));
1503
+ // 如果组为空,删除组
1504
+ if (group.nodeIds.length === 0) {
1505
+ store.doc.instanceGroups = store.doc.instanceGroups.filter(g => g.id !== group.id);
1506
+ }
1507
+ }
1508
+ }
1509
+ undo(store) {
1510
+ if (!this.removedGroup)
1511
+ return;
1512
+ if (!store.doc.instanceGroups)
1513
+ store.doc.instanceGroups = [];
1514
+ // 恢复组
1515
+ const existing = store.doc.instanceGroups.find(g => g.id === this.removedGroup.id);
1516
+ if (existing) {
1517
+ // 恢复节点
1518
+ for (const id of this.removedGroup.nodeIds) {
1519
+ if (!existing.nodeIds.includes(id))
1520
+ existing.nodeIds.push(id);
1521
+ }
1522
+ }
1523
+ else {
1524
+ // 恢复整个组
1525
+ store.doc.instanceGroups.push({ ...this.removedGroup });
1526
+ }
1527
+ }
1528
+ }
1242
1529
 
1243
1530
  /**
1244
1531
  * 粒子系统运行时
@@ -2964,6 +3251,12 @@ class SceneViewer {
2964
3251
  pendingAssetNodes = new Map();
2965
3252
  /** 动画基础变换(nodeId → 数据模型原始变换) */
2966
3253
  animBases = new Map();
3254
+ /** FPS 统计 */
3255
+ fpsFrames = 0;
3256
+ fpsTime = 0;
3257
+ fpsCurrent = 0;
3258
+ /** 场景统计缓存 */
3259
+ statsCache = { vertices: 0, faces: 0, drawCalls: 0, dirty: true };
2967
3260
  /** 缩略图渲染器(离屏) */
2968
3261
  thumbRenderer;
2969
3262
  thumbScene = new THREE.Scene();
@@ -2974,6 +3267,10 @@ class SceneViewer {
2974
3267
  lastFrameTime = 0;
2975
3268
  /** 拖拽中冻结动画 */
2976
3269
  animationFrozen = false;
3270
+ /** 性能模式:true 时使用 renderer(无后处理),false 时使用 composer(有后处理) */
3271
+ performanceMode = false;
3272
+ /** 顶层节点集合(没有父节点的节点,用于视锥剔除优化) */
3273
+ topLevelNodes = new Set();
2977
3274
  /** 相机是否已从文档恢复(只在首次 sync 时恢复一次) */
2978
3275
  cameraRestored = false;
2979
3276
  /** 保存原始材质参数(覆盖前),用于撤销覆盖时恢复 */
@@ -2984,6 +3281,8 @@ class SceneViewer {
2984
3281
  textureCache = new Map();
2985
3282
  /** 标签对象缓存(nodeId → label mesh) */
2986
3283
  labelObjects = new Map();
3284
+ /** 实例化网格缓存(groupId → InstancedMesh 数组) */
3285
+ instanceMeshes = new Map();
2987
3286
  textureLoader = new TextureLoader();
2988
3287
  /** HDRI 环境贴图缓存 */
2989
3288
  hdriTexture = null;
@@ -3061,6 +3360,8 @@ class SceneViewer {
3061
3360
  pathEditor;
3062
3361
  /** 是否为编辑器模式(编辑器模式下动画默认播放,不依赖数据) */
3063
3362
  isEditorMode;
3363
+ /** 相机视角变化回调 */
3364
+ onCameraChange;
3064
3365
  constructor(host, store, cb, dashboardOverlayEl, isEditorMode = false) {
3065
3366
  this.host = host;
3066
3367
  this.store = store;
@@ -3081,6 +3382,22 @@ class SceneViewer {
3081
3382
  this.scene.add(this.gridHelper);
3082
3383
  this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
3083
3384
  this.orbit.enableDamping = true;
3385
+ // 相机视角变化时,保存相机并触发草稿缓存
3386
+ let cameraSaveTimer = null;
3387
+ /** 恢复相机时置 true,阻止触发 cameraChange */
3388
+ let restoringCamera = false;
3389
+ this.orbit.addEventListener('change', () => {
3390
+ if (restoringCamera)
3391
+ return;
3392
+ if (cameraSaveTimer)
3393
+ clearTimeout(cameraSaveTimer);
3394
+ cameraSaveTimer = setTimeout(() => {
3395
+ this.saveCamera();
3396
+ this.onCameraChange?.();
3397
+ }, 500);
3398
+ });
3399
+ // 暴露给 restoreCamera 使用
3400
+ this._setRestoringCamera = (v) => { restoringCamera = v; };
3084
3401
  this.gizmo = new TransformControls(this.camera, this.renderer.domElement);
3085
3402
  this.gizmo.setSize(0.9);
3086
3403
  this.gizmo.addEventListener('dragging-changed', (e) => {
@@ -3165,6 +3482,12 @@ class SceneViewer {
3165
3482
  obj.traverse(c => { if (c instanceof THREE.Mesh)
3166
3483
  allMeshes.push(c); });
3167
3484
  }
3485
+ // 添加 InstancedMesh 到拾取列表
3486
+ for (const meshes of this.instanceMeshes.values()) {
3487
+ for (const im of meshes) {
3488
+ allMeshes.push(im);
3489
+ }
3490
+ }
3168
3491
  const rect = this.renderer.domElement.getBoundingClientRect();
3169
3492
  const ndc = new THREE.Vector2(((e.clientX - rect.left) / rect.width) * 2 - 1, -((e.clientY - rect.top) / rect.height) * 2 + 1);
3170
3493
  this.raycaster.setFromCamera(ndc, this.camera);
@@ -3250,6 +3573,9 @@ class SceneViewer {
3250
3573
  }
3251
3574
  return;
3252
3575
  }
3576
+ // 按住了 Ctrl/Meta 时不记录 WASD 移动键,避免 Ctrl+S 等快捷键触发后退
3577
+ if (e.ctrlKey || e.metaKey)
3578
+ return;
3253
3579
  this.keysPressed.add(e.key.toLowerCase());
3254
3580
  };
3255
3581
  this.onKeyUp = (e) => this.keysPressed.delete(e.key.toLowerCase());
@@ -3260,38 +3586,10 @@ class SceneViewer {
3260
3586
  // 初始化后处理
3261
3587
  this.initPostProcessing();
3262
3588
  this.renderer.setAnimationLoop(() => {
3263
- for (const h of this.helpers.values()) {
3264
- h.update();
3265
- // 组的包围盒已预计算,跳过逐帧放大
3266
- if (h.userData['isGroupHelper'])
3267
- continue;
3268
- // 给包围盒加 3% 空隙,让框与模型之间有视觉间距
3269
- const pos = h.geometry.attributes['position'].array;
3270
- let minX = Infinity, minY = Infinity, minZ = Infinity;
3271
- let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity;
3272
- for (let i = 0; i < pos.length; i += 3) {
3273
- if (pos[i] < minX)
3274
- minX = pos[i];
3275
- if (pos[i + 1] < minY)
3276
- minY = pos[i + 1];
3277
- if (pos[i + 2] < minZ)
3278
- minZ = pos[i + 2];
3279
- if (pos[i] > maxX)
3280
- maxX = pos[i];
3281
- if (pos[i + 1] > maxY)
3282
- maxY = pos[i + 1];
3283
- if (pos[i + 2] > maxZ)
3284
- maxZ = pos[i + 2];
3285
- }
3286
- const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2, cz = (minZ + maxZ) / 2;
3287
- const pad = 0.03;
3288
- for (let i = 0; i < pos.length; i += 3) {
3289
- pos[i] = cx + (pos[i] - cx) + (pos[i] - cx >= 0 ? pad : -pad);
3290
- pos[i + 1] = cy + (pos[i + 1] - cy) + (pos[i + 1] - cy >= 0 ? pad : -pad);
3291
- pos[i + 2] = cz + (pos[i + 2] - cz) + (pos[i + 2] - cz >= 0 ? pad : -pad);
3292
- }
3293
- h.geometry.attributes['position'].needsUpdate = true;
3294
- }
3589
+ // TODO: 包围盒按需更新,暂时注释测试性能
3590
+ // for (const h of this.helpers.values()) {
3591
+ // h.update();
3592
+ // }
3295
3593
  // 更新灯光助手
3296
3594
  for (const h of this.lightHelpers.values()) {
3297
3595
  if (h instanceof THREE.DirectionalLightHelper || h instanceof THREE.SpotLightHelper) {
@@ -3309,6 +3607,14 @@ class SceneViewer {
3309
3607
  const now = performance.now();
3310
3608
  const dt = this.lastFrameTime ? (now - this.lastFrameTime) / 1000 : 0;
3311
3609
  this.lastFrameTime = now;
3610
+ // FPS 统计
3611
+ this.fpsFrames++;
3612
+ this.fpsTime += dt;
3613
+ if (this.fpsTime >= 0.5) {
3614
+ this.fpsCurrent = Math.round(this.fpsFrames / this.fpsTime);
3615
+ this.fpsFrames = 0;
3616
+ this.fpsTime = 0;
3617
+ }
3312
3618
  // 更新动画
3313
3619
  this.updateAnimations(dt);
3314
3620
  // 更新视觉效果(UV 动画、粒子系统)
@@ -3325,7 +3631,15 @@ class SceneViewer {
3325
3631
  this.constrainCameraToRoom();
3326
3632
  // 更新看板 world 模式
3327
3633
  this.updateDashboard();
3328
- this.composer.render();
3634
+ // 视锥剔除:隐藏不在相机视锥内的顶层节点
3635
+ this.updateFrustumCulling();
3636
+ // 根据性能模式选择渲染方式
3637
+ if (this.performanceMode) {
3638
+ this.renderer.render(this.scene, this.camera);
3639
+ }
3640
+ else {
3641
+ this.composer.render();
3642
+ }
3329
3643
  });
3330
3644
  // 初始化路径编辑器
3331
3645
  const pathEditorCallbacks = {
@@ -3398,7 +3712,7 @@ class SceneViewer {
3398
3712
  setPathHandleSize(size) {
3399
3713
  this.pathEditor.setHandleSize(size);
3400
3714
  }
3401
- /** 将当前相机状态写入 SceneDoc(保存前调用) */
3715
+ /** 将当前相机状态写入 SceneDoc(保存前调用,不触发 notifyChanged) */
3402
3716
  saveCamera() {
3403
3717
  this.store.doc.camera = {
3404
3718
  position: [
@@ -3418,6 +3732,8 @@ class SceneViewer {
3418
3732
  const cam = this.store.doc.camera;
3419
3733
  if (!cam)
3420
3734
  return;
3735
+ // 设置标志,阻止 change 事件触发 cameraChange → saveDraft
3736
+ this._setRestoringCamera?.(true);
3421
3737
  this.camera.position.set(...cam.position);
3422
3738
  this.orbit.target.set(...cam.target);
3423
3739
  // 重置 damping 状态,防止残留速度导致位置漂移
@@ -3426,6 +3742,7 @@ class SceneViewer {
3426
3742
  this.orbit.enableDamping = true;
3427
3743
  // 强制相机朝向 target
3428
3744
  this.camera.lookAt(this.orbit.target);
3745
+ this._setRestoringCamera?.(false);
3429
3746
  }
3430
3747
  /** 导出场景为 GLB 文件 */
3431
3748
  exportScene() {
@@ -3472,6 +3789,10 @@ class SceneViewer {
3472
3789
  this.cameraRestored = false;
3473
3790
  // 新文档加载:清空资产加载状态,确保资产重新加载
3474
3791
  this.loadingAssetUrls.clear();
3792
+ // 清空原始材质缓存,避免旧场景的材质状态污染新场景
3793
+ this.originalMaterials.clear();
3794
+ // 重置房间配置缓存,确保 applyRoom 重新应用
3795
+ this.lastRoomKey = '';
3475
3796
  }
3476
3797
  }
3477
3798
  const seen = new Set();
@@ -3600,6 +3921,7 @@ class SceneViewer {
3600
3921
  for (const [id, obj] of [...this.objects]) {
3601
3922
  if (!seen.has(id)) {
3602
3923
  this.objects.delete(id);
3924
+ this.markStatsDirty();
3603
3925
  obj.parent?.remove(obj);
3604
3926
  if (obj instanceof THREE.Mesh) {
3605
3927
  obj.geometry.dispose();
@@ -3641,6 +3963,31 @@ class SceneViewer {
3641
3963
  }
3642
3964
  }
3643
3965
  }
3966
+ // 清理不再存在的实例组对应的 InstancedMesh
3967
+ const validGroupIds = new Set((this.store.doc.instanceGroups || []).map(g => g.id));
3968
+ for (const [groupId, meshes] of [...this.instanceMeshes]) {
3969
+ if (!validGroupIds.has(groupId)) {
3970
+ // 恢复原始节点的 visible 状态
3971
+ if (meshes.length > 0) {
3972
+ const nodeIds = meshes[0].userData['instanceNodeIds'];
3973
+ if (nodeIds) {
3974
+ for (const nodeId of nodeIds) {
3975
+ const obj = this.objects.get(nodeId);
3976
+ if (obj) {
3977
+ obj.visible = true;
3978
+ }
3979
+ }
3980
+ }
3981
+ }
3982
+ // 清理 InstancedMesh
3983
+ for (const mesh of meshes) {
3984
+ mesh.parent?.remove(mesh);
3985
+ mesh.geometry.dispose();
3986
+ mesh.material.dispose();
3987
+ }
3988
+ this.instanceMeshes.delete(groupId);
3989
+ }
3990
+ }
3644
3991
  // 层级关系
3645
3992
  for (const n of this.store.doc.nodes) {
3646
3993
  const obj = this.objects.get(n.id);
@@ -3676,6 +4023,10 @@ class SceneViewer {
3676
4023
  this.syncEffects();
3677
4024
  // 同步路径编辑器
3678
4025
  this.pathEditor.sync();
4026
+ // 同步实例化组
4027
+ this.syncInstanceGroups();
4028
+ // 更新顶层节点集合(用于视锥剔除优化)
4029
+ this.updateTopLevelNodes();
3679
4030
  // 应用环境配置
3680
4031
  this.applyEnvironment();
3681
4032
  // 只在首次加载时恢复相机(后续 doc 变更不重置视角)
@@ -3691,6 +4042,203 @@ class SceneViewer {
3691
4042
  this.restoreCamera();
3692
4043
  this.cameraRestored = true;
3693
4044
  }
4045
+ /** 同步实例化组:创建/更新 InstancedMesh */
4046
+ syncInstanceGroups() {
4047
+ const groups = this.store.doc.instanceGroups;
4048
+ if (!groups || groups.length === 0) {
4049
+ // 恢复所有被隐藏的原始节点
4050
+ for (const [groupId, meshes] of this.instanceMeshes) {
4051
+ if (meshes.length > 0) {
4052
+ const nodeIds = meshes[0].userData['instanceNodeIds'];
4053
+ if (nodeIds) {
4054
+ for (const nodeId of nodeIds) {
4055
+ const obj = this.objects.get(nodeId);
4056
+ if (obj) {
4057
+ obj.visible = true;
4058
+ obj.traverse(child => {
4059
+ if (child !== obj)
4060
+ child.visible = true;
4061
+ });
4062
+ }
4063
+ }
4064
+ }
4065
+ }
4066
+ // 清理 InstancedMesh
4067
+ for (const mesh of meshes) {
4068
+ mesh.parent?.remove(mesh);
4069
+ mesh.geometry.dispose();
4070
+ mesh.material.dispose();
4071
+ }
4072
+ }
4073
+ this.instanceMeshes.clear();
4074
+ this.markStatsDirty();
4075
+ return;
4076
+ }
4077
+ const seenGroups = new Set();
4078
+ for (const group of groups) {
4079
+ seenGroups.add(group.id);
4080
+ const nodeIds = group.nodeIds;
4081
+ if (nodeIds.length === 0)
4082
+ continue;
4083
+ // 获取第一个节点作为模板
4084
+ const templateNodeId = nodeIds[0];
4085
+ const templateObj = this.objects.get(templateNodeId);
4086
+ if (!templateObj)
4087
+ continue;
4088
+ // 收集模板对象中的所有 Mesh
4089
+ const templateMeshes = [];
4090
+ templateObj.traverse(child => {
4091
+ if (child instanceof THREE.Mesh) {
4092
+ templateMeshes.push(child);
4093
+ }
4094
+ });
4095
+ if (templateMeshes.length === 0)
4096
+ continue;
4097
+ // 获取或创建 InstancedMesh 数组
4098
+ let instancedMeshes = this.instanceMeshes.get(group.id);
4099
+ const needsRecreate = !instancedMeshes || instancedMeshes.length !== templateMeshes.length;
4100
+ if (needsRecreate) {
4101
+ // 清理旧的 InstancedMesh
4102
+ if (instancedMeshes) {
4103
+ for (const mesh of instancedMeshes) {
4104
+ mesh.parent?.remove(mesh);
4105
+ mesh.geometry.dispose();
4106
+ mesh.material.dispose();
4107
+ }
4108
+ }
4109
+ // 为每个模板 Mesh 创建 InstancedMesh
4110
+ instancedMeshes = [];
4111
+ for (let i = 0; i < templateMeshes.length; i++) {
4112
+ const templateMesh = templateMeshes[i];
4113
+ const geometry = templateMesh.geometry;
4114
+ const material = templateMesh.material.clone();
4115
+ const instancedMesh = new THREE.InstancedMesh(geometry, material, nodeIds.length);
4116
+ instancedMesh.userData['instanceGroupId'] = group.id;
4117
+ instancedMesh.userData['instanceNodeIds'] = nodeIds;
4118
+ instancedMesh.userData['meshIndex'] = i;
4119
+ this.scene.add(instancedMesh);
4120
+ instancedMeshes.push(instancedMesh);
4121
+ }
4122
+ this.instanceMeshes.set(group.id, instancedMeshes);
4123
+ // 隐藏参与实例化的节点及其子 Mesh
4124
+ for (const nodeId of nodeIds) {
4125
+ const obj = this.objects.get(nodeId);
4126
+ if (obj) {
4127
+ obj.visible = false;
4128
+ obj.traverse(child => {
4129
+ if (child !== obj)
4130
+ child.visible = false;
4131
+ });
4132
+ }
4133
+ }
4134
+ this.markStatsDirty();
4135
+ }
4136
+ // 此时 instancedMeshes 必定存在
4137
+ if (!instancedMeshes)
4138
+ continue;
4139
+ // 更新每个 InstancedMesh 的实例矩阵
4140
+ for (let meshIdx = 0; meshIdx < instancedMeshes.length; meshIdx++) {
4141
+ const instancedMesh = instancedMeshes[meshIdx];
4142
+ const matrix = new THREE.Matrix4();
4143
+ for (let i = 0; i < nodeIds.length; i++) {
4144
+ const nodeId = nodeIds[i];
4145
+ const node = this.store.node(nodeId);
4146
+ const obj = this.objects.get(nodeId);
4147
+ if (!node || !obj)
4148
+ continue;
4149
+ // 找到对应的 mesh
4150
+ const meshes = [];
4151
+ obj.traverse(child => {
4152
+ if (child instanceof THREE.Mesh) {
4153
+ meshes.push(child);
4154
+ }
4155
+ });
4156
+ if (meshIdx >= meshes.length)
4157
+ continue;
4158
+ const mesh = meshes[meshIdx];
4159
+ // 计算世界矩阵(确保父链更新)
4160
+ mesh.updateWorldMatrix(true, true);
4161
+ matrix.copy(mesh.matrixWorld);
4162
+ instancedMesh.setMatrixAt(i, matrix);
4163
+ }
4164
+ instancedMesh.instanceMatrix.needsUpdate = true;
4165
+ }
4166
+ }
4167
+ // 清理不再存在的实例组
4168
+ for (const [groupId, meshes] of this.instanceMeshes) {
4169
+ if (!seenGroups.has(groupId)) {
4170
+ for (const mesh of meshes) {
4171
+ mesh.parent?.remove(mesh);
4172
+ mesh.geometry.dispose();
4173
+ mesh.material.dispose();
4174
+ }
4175
+ this.instanceMeshes.delete(groupId);
4176
+ // 恢复原始 mesh 的可见性
4177
+ const nodeIds = meshes[0]?.userData['instanceNodeIds'];
4178
+ if (nodeIds) {
4179
+ for (const nodeId of nodeIds) {
4180
+ const obj = this.objects.get(nodeId);
4181
+ if (obj) {
4182
+ obj.visible = true;
4183
+ obj.traverse(child => {
4184
+ if (child !== obj)
4185
+ child.visible = true;
4186
+ });
4187
+ }
4188
+ }
4189
+ }
4190
+ }
4191
+ }
4192
+ }
4193
+ /** 设置性能模式 */
4194
+ setPerformanceMode(enabled) {
4195
+ if (this.performanceMode === enabled)
4196
+ return;
4197
+ this.performanceMode = enabled;
4198
+ // 性能模式下关闭阴影,否则保持原设置
4199
+ // 注意:shadowMap.enabled 需要在 renderer 创建后设置,不能动态切换
4200
+ // 所以这里只影响渲染通道,阴影由初始化时的环境设置决定
4201
+ }
4202
+ /** 更新顶层节点集合(没有父节点的节点) */
4203
+ updateTopLevelNodes() {
4204
+ this.topLevelNodes.clear();
4205
+ for (const obj of this.objects.values()) {
4206
+ if (obj.parent === this.scene || obj.parent === null) {
4207
+ this.topLevelNodes.add(obj);
4208
+ }
4209
+ }
4210
+ }
4211
+ /** 视锥剔除:隐藏不在相机视锥内的顶层节点,大幅减少 CPU 遍历开销 */
4212
+ updateFrustumCulling() {
4213
+ if (this.topLevelNodes.size === 0)
4214
+ return;
4215
+ // 创建相机视锥
4216
+ const frustum = new THREE.Frustum();
4217
+ const projScreenMatrix = new THREE.Matrix4();
4218
+ projScreenMatrix.multiplyMatrices(this.camera.projectionMatrix, this.camera.matrixWorldInverse);
4219
+ frustum.setFromProjectionMatrix(projScreenMatrix);
4220
+ let hiddenCount = 0;
4221
+ let visibleCount = 0;
4222
+ for (const obj of this.topLevelNodes) {
4223
+ // 计算包围盒(世界空间)
4224
+ obj.updateWorldMatrix(false, false);
4225
+ const bbox = new THREE.Box3().setFromObject(obj);
4226
+ // 判断是否与视锥相交
4227
+ const inFrustum = frustum.intersectsBox(bbox);
4228
+ // 只在可见性变化时才设置,避免不必要的状态更新
4229
+ if (obj.visible !== inFrustum) {
4230
+ obj.visible = inFrustum;
4231
+ }
4232
+ if (inFrustum)
4233
+ visibleCount++;
4234
+ else
4235
+ hiddenCount++;
4236
+ }
4237
+ // 每 60 帧输出一次调试信息
4238
+ if (this.fpsFrames % 60 === 0) {
4239
+ console.log(`[视锥剔除] 顶层节点:${this.topLevelNodes.size},可见:${visibleCount},隐藏:${hiddenCount}`);
4240
+ }
4241
+ }
3694
4242
  /** 创建标签 mesh */
3695
4243
  createLabel(text) {
3696
4244
  const canvas = document.createElement('canvas');
@@ -3896,7 +4444,7 @@ class SceneViewer {
3896
4444
  const d = room.depth ?? 30;
3897
4445
  const floorColor = (room.floorUseBaseColor ?? true) ? (room.floorColor ?? '#808080') : '#ffffff';
3898
4446
  const wallColor = (room.wallUseBaseColor ?? true) ? (room.wallColor ?? '#c8c0b0') : '#ffffff';
3899
- const ceilingColor = (room.ceilingUseBaseColor ?? true) ? (room.ceilingColor ?? '#a0a0a0') : '#ffffff';
4447
+ const ceilingColor = (room.ceilingUseBaseColor ?? true) ? (room.ceilingColor ?? '#c8c0b0') : '#ffffff';
3900
4448
  this.disposeRoom();
3901
4449
  this.scene.background = null;
3902
4450
  // 构建 6 面材质:[+X墙, -X墙, +Y天花板, -Y地板, +Z墙, -Z墙]
@@ -4051,13 +4599,14 @@ class SceneViewer {
4051
4599
  move.sub(right);
4052
4600
  if (keys.has('d'))
4053
4601
  move.add(right);
4054
- if (keys.has('q') || keys.has('shift'))
4602
+ if (keys.has('q'))
4055
4603
  move.y -= 1;
4056
4604
  if (keys.has('e') || keys.has(' '))
4057
4605
  move.y += 1;
4058
4606
  if (move.lengthSq() === 0)
4059
4607
  return;
4060
- move.normalize().multiplyScalar(this.moveSpeed * 0.016); // 假设 60fps,约 16ms/帧
4608
+ const speed = keys.has('shift') ? this.moveSpeed * 3 : this.moveSpeed; // Shift 加速 3 倍
4609
+ move.normalize().multiplyScalar(speed * 0.016); // 假设 60fps,约 16ms/帧
4061
4610
  // 同时移动 orbit target 和 camera
4062
4611
  this.orbit.target.add(move);
4063
4612
  this.camera.position.add(move);
@@ -4128,15 +4677,13 @@ class SceneViewer {
4128
4677
  this.composer.addPass(renderPass);
4129
4678
  // 2. SSAO(屏幕空间环境光遮蔽)—— 增强角落/缝隙的阴影深度
4130
4679
  const ssaoPass = new SSAOPass(this.scene, this.camera, w, h);
4131
- ssaoPass.kernelRadius = 16; // 采样半径
4132
- ssaoPass.minDistance = 0.005; // 最小距离阈值
4133
- ssaoPass.maxDistance = 0.1; // 最大距离阈值
4680
+ ssaoPass.kernelRadius = 16;
4681
+ ssaoPass.minDistance = 0.005;
4682
+ ssaoPass.maxDistance = 0.1;
4134
4683
  ssaoPass.output = SSAOPass.OUTPUT.Default;
4135
4684
  this.composer.addPass(ssaoPass);
4136
4685
  // 3. Bloom(泛光)—— 高亮区域发光效果
4137
- const bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), 0.3, // strength:泛光强度(低值,避免过曝)
4138
- 0.4, // radius:泛光扩散范围
4139
- 0.85);
4686
+ const bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), 0.3, 0.4, 0.85);
4140
4687
  this.composer.addPass(bloomPass);
4141
4688
  // 4. 输出通道(色调映射 + 色彩空间转换)
4142
4689
  const outputPass = new OutputPass();
@@ -4719,21 +5266,21 @@ class SceneViewer {
4719
5266
  const rows = widget.dataPoints.map((dp, i) => {
4720
5267
  const dpValue = this.dashboardValues.get(`${widget.id}_dp${i}`);
4721
5268
  const dpDisplay = this.formatValue(dpValue, { decimals: widget.decimals });
4722
- return `
4723
- <div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.05);">
4724
- <span style="font-size: 12px; opacity: 0.7;">${dp.label}</span>
4725
- <span style="font-size: 16px; font-weight: 500;">${dpDisplay}${dp.unit ? `<small style="font-size: 11px; opacity: 0.6; margin-left: 2px;">${dp.unit}</small>` : ''}</span>
4726
- </div>
5269
+ return `
5270
+ <div style="display: flex; justify-content: space-between; align-items: center; padding: 4px 0; border-bottom: 1px solid rgba(255,255,255,0.05);">
5271
+ <span style="font-size: 12px; opacity: 0.7;">${dp.label}</span>
5272
+ <span style="font-size: 16px; font-weight: 500;">${dpDisplay}${dp.unit ? `<small style="font-size: 11px; opacity: 0.6; margin-left: 2px;">${dp.unit}</small>` : ''}</span>
5273
+ </div>
4727
5274
  `;
4728
5275
  }).join('');
4729
5276
  el.innerHTML = rows;
4730
5277
  }
4731
5278
  else {
4732
5279
  // 兼容旧的单值模式
4733
- el.innerHTML = `
4734
- <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
4735
- <div style="font-size: 28px; font-weight: 600;">${displayValue}</div>
4736
- ${widget.unit ? `<div style="font-size: 12px; opacity: 0.6; margin-top: 2px;">${widget.unit}</div>` : ''}
5280
+ el.innerHTML = `
5281
+ <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
5282
+ <div style="font-size: 28px; font-weight: 600;">${displayValue}</div>
5283
+ ${widget.unit ? `<div style="font-size: 12px; opacity: 0.6; margin-top: 2px;">${widget.unit}</div>` : ''}
4737
5284
  `;
4738
5285
  }
4739
5286
  break;
@@ -4742,39 +5289,39 @@ class SceneViewer {
4742
5289
  break;
4743
5290
  case 'status':
4744
5291
  const statusColor = this.getStatusColor(value, widget);
4745
- el.innerHTML = `
4746
- <div style="display: flex; align-items: center; gap: 8px;">
4747
- <div style="width: 12px; height: 12px; border-radius: 50%; background: ${statusColor};"></div>
4748
- <div style="font-size: 14px;">${widget.label || ''}</div>
4749
- </div>
5292
+ el.innerHTML = `
5293
+ <div style="display: flex; align-items: center; gap: 8px;">
5294
+ <div style="width: 12px; height: 12px; border-radius: 50%; background: ${statusColor};"></div>
5295
+ <div style="font-size: 14px;">${widget.label || ''}</div>
5296
+ </div>
4750
5297
  `;
4751
5298
  break;
4752
5299
  case 'progress':
4753
5300
  const progress = this.calcProgress(value, widget);
4754
- el.innerHTML = `
4755
- <div style="font-size: 12px; opacity: 0.7; margin-bottom: 8px;">${widget.label || ''}</div>
4756
- <div style="height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden;">
4757
- <div style="height: 100%; width: ${progress}%; background: #4f83cc; transition: width 0.3s;"></div>
4758
- </div>
4759
- <div style="font-size: 12px; text-align: right; margin-top: 4px;">${displayValue}</div>
5301
+ el.innerHTML = `
5302
+ <div style="font-size: 12px; opacity: 0.7; margin-bottom: 8px;">${widget.label || ''}</div>
5303
+ <div style="height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden;">
5304
+ <div style="height: 100%; width: ${progress}%; background: #4f83cc; transition: width 0.3s;"></div>
5305
+ </div>
5306
+ <div style="font-size: 12px; text-align: right; margin-top: 4px;">${displayValue}</div>
4760
5307
  `;
4761
5308
  break;
4762
5309
  case 'gauge':
4763
5310
  // 简化版仪表:只显示数值
4764
5311
  const gaugeProgress = this.calcProgress(value, widget);
4765
- el.innerHTML = `
4766
- <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
4767
- <div style="font-size: 24px; font-weight: 600; text-align: center;">${displayValue}</div>
4768
- <div style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden;">
4769
- <div style="height: 100%; width: ${gaugeProgress}%; background: #4f83cc;"></div>
4770
- </div>
5312
+ el.innerHTML = `
5313
+ <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
5314
+ <div style="font-size: 24px; font-weight: 600; text-align: center;">${displayValue}</div>
5315
+ <div style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; margin-top: 8px; overflow: hidden;">
5316
+ <div style="height: 100%; width: ${gaugeProgress}%; background: #4f83cc;"></div>
5317
+ </div>
4771
5318
  `;
4772
5319
  break;
4773
5320
  case 'chart':
4774
5321
  // 简化版:显示当前值
4775
- el.innerHTML = `
4776
- <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
4777
- <div style="font-size: 20px; font-weight: 500;">${displayValue}</div>
5322
+ el.innerHTML = `
5323
+ <div style="font-size: 12px; opacity: 0.7; margin-bottom: 4px;">${widget.label || ''}</div>
5324
+ <div style="font-size: 20px; font-weight: 500;">${displayValue}</div>
4778
5325
  `;
4779
5326
  break;
4780
5327
  }
@@ -5433,6 +5980,59 @@ class SceneViewer {
5433
5980
  }
5434
5981
  this.lightHelpers.clear();
5435
5982
  this.lightTargets.clear();
5983
+ // 清理贴图缓存
5984
+ for (const tex of this.textureCache.values())
5985
+ tex.dispose();
5986
+ this.textureCache.clear();
5987
+ // 清理资产缓存(GLB 模型的几何体/材质/纹理)
5988
+ for (const group of this.assetCache.values()) {
5989
+ group.traverse(child => {
5990
+ if (child instanceof THREE.Mesh) {
5991
+ child.geometry.dispose();
5992
+ const mats = Array.isArray(child.material) ? child.material : [child.material];
5993
+ for (const m of mats) {
5994
+ if (m instanceof THREE.MeshStandardMaterial) {
5995
+ m.map?.dispose();
5996
+ m.normalMap?.dispose();
5997
+ m.roughnessMap?.dispose();
5998
+ m.metalnessMap?.dispose();
5999
+ m.emissiveMap?.dispose();
6000
+ }
6001
+ m.dispose();
6002
+ }
6003
+ }
6004
+ });
6005
+ }
6006
+ this.assetCache.clear();
6007
+ this.originalMaterials.clear();
6008
+ // 清理标签
6009
+ for (const label of this.labelObjects.values()) {
6010
+ label.geometry.dispose();
6011
+ label.material.map?.dispose();
6012
+ label.material.dispose();
6013
+ }
6014
+ this.labelObjects.clear();
6015
+ // 清理告警弹窗
6016
+ for (const popup of this.alarmPopups.values()) {
6017
+ popup.geometry.dispose();
6018
+ popup.material.map?.dispose();
6019
+ popup.material.dispose();
6020
+ }
6021
+ this.alarmPopups.clear();
6022
+ this.alarmCreationTime.clear();
6023
+ // 清理屏幕内容定时器
6024
+ for (const state of this.screenContentStates.values()) {
6025
+ clearInterval(state.intervalId);
6026
+ }
6027
+ this.screenContentStates.clear();
6028
+ // 清理灯光选择器
6029
+ for (const picker of this.lightPickers.values()) {
6030
+ picker.geometry.dispose();
6031
+ picker.material.dispose();
6032
+ }
6033
+ this.lightPickers.clear();
6034
+ // 清理缩略图渲染器
6035
+ this.thumbRenderer?.dispose();
5436
6036
  // 清理看板 DOM 和定时器
5437
6037
  for (const el of this.dashboardElements.values()) {
5438
6038
  el.remove();
@@ -5466,6 +6066,41 @@ class SceneViewer {
5466
6066
  mesh.receiveShadow = true;
5467
6067
  return mesh;
5468
6068
  }
6069
+ /**
6070
+ * 参考 JCISceneParser 的 GdeepCloneMaterials
6071
+ * 几何体共享(不 clone),材质独立克隆
6072
+ * - Object3D/Group:clone(false) 不递归,手动处理 children
6073
+ * - Mesh:共享 geometry,克隆 material
6074
+ * - Light:完整克隆
6075
+ */
6076
+ gdeepCloneMaterials(obj, nodeId) {
6077
+ let clone;
6078
+ if (obj instanceof THREE.Mesh) {
6079
+ // Mesh:几何体共享,材质克隆(关键:保留 geometry 引用)
6080
+ const material = obj.material ? obj.material.clone() : undefined;
6081
+ clone = new THREE.Mesh(obj.geometry, material);
6082
+ clone.castShadow = true;
6083
+ clone.receiveShadow = true;
6084
+ // 启用视锥剔除:不在相机视锥体内的 Mesh 不参与渲染
6085
+ clone.frustumCulled = true;
6086
+ }
6087
+ else if (obj instanceof THREE.Light) {
6088
+ // 灯光:完整克隆
6089
+ clone = obj.clone();
6090
+ }
6091
+ else {
6092
+ // Group 或其他 Object3D:clone(false) 不递归克隆 children
6093
+ clone = obj.clone(false);
6094
+ }
6095
+ // 复制通用属性
6096
+ clone.name = obj.name;
6097
+ clone.userData = { ...obj.userData, 'id': nodeId };
6098
+ // 递归处理 children
6099
+ for (const child of obj.children) {
6100
+ clone.add(this.gdeepCloneMaterials(child, nodeId));
6101
+ }
6102
+ return clone;
6103
+ }
5469
6104
  /** 判断对象是否为灯光 */
5470
6105
  isLight(obj) {
5471
6106
  return obj instanceof THREE.Light;
@@ -5767,21 +6402,8 @@ class SceneViewer {
5767
6402
  const node = this.store.node(nodeId);
5768
6403
  if (!node)
5769
6404
  return;
5770
- const clone = assetScene.clone(true);
5771
- clone.userData['id'] = nodeId;
5772
- // 彻底断开材质共享:clone(true) 不会 clone 材质(只是引用复制),
5773
- // 需要给每个 mesh 都分配独立材质副本,避免跨节点/跨 mesh 共享
5774
- clone.traverse(child => {
5775
- if (child instanceof THREE.Mesh) {
5776
- child.userData['id'] = nodeId;
5777
- child.castShadow = true;
5778
- child.receiveShadow = true;
5779
- const mat = child.material;
5780
- if (mat) {
5781
- child.material = mat.clone();
5782
- }
5783
- }
5784
- });
6405
+ // 几何体共享,材质独立克隆(参考 JCISceneParser 的 GdeepCloneMaterials)
6406
+ const clone = this.gdeepCloneMaterials(assetScene, nodeId);
5785
6407
  // 将 pivot 校正到几何中心:把子对象整体反向偏移 -center
5786
6408
  // 这样外层 clone 的 pivot 就落在几何中心,旋转/缩放/吸附都以此为基准
5787
6409
  const box = new THREE.Box3().setFromObject(clone);
@@ -5834,6 +6456,7 @@ class SceneViewer {
5834
6456
  clone.scale.set(...node.scale);
5835
6457
  clone.visible = node.visible;
5836
6458
  clone.userData['assetLoaded'] = true;
6459
+ this.markStatsDirty();
5837
6460
  // 层级关系
5838
6461
  const parent = this.store.parentOf(nodeId);
5839
6462
  const target = (parent && this.objects.get(parent.id)) || this.scene;
@@ -6088,13 +6711,11 @@ class SceneViewer {
6088
6711
  obj.traverse(child => {
6089
6712
  if (!(child instanceof THREE.Mesh))
6090
6713
  return;
6091
- // 给没有 name 的 mesh 设置默认名称(与 getMeshNames 保持一致)
6092
6714
  if (!child.name)
6093
6715
  child.name = `mesh_${unnamedIndex++}`;
6094
6716
  let mat = child.material;
6095
6717
  if (!mat)
6096
6718
  return;
6097
- // OBJ 加载的可能是其他材质类型,转换为 MeshStandardMaterial
6098
6719
  if (!(mat instanceof THREE.MeshStandardMaterial)) {
6099
6720
  const oldMat = mat;
6100
6721
  const newMat = new THREE.MeshStandardMaterial();
@@ -6111,7 +6732,6 @@ class SceneViewer {
6111
6732
  child.material = newMat;
6112
6733
  mat = newMat;
6113
6734
  }
6114
- // 如果材质被多个 mesh 共享,给当前 mesh 分配独立副本
6115
6735
  if (seenMaterials.has(mat)) {
6116
6736
  const independentMat = mat.clone();
6117
6737
  child.material = independentMat;
@@ -6127,14 +6747,13 @@ class SceneViewer {
6127
6747
  resetMaterialToOriginal(nodeId, meshName, mat) {
6128
6748
  const orig = this.originalMaterials.get(nodeId)?.[meshName];
6129
6749
  if (!orig)
6130
- return; // 没有保存的原始材质,说明从未被覆盖过
6750
+ return;
6131
6751
  mat.color.set(orig.color);
6132
6752
  mat.opacity = orig.opacity;
6133
6753
  mat.transparent = orig.transparent;
6134
6754
  mat.roughness = orig.roughness;
6135
6755
  mat.metalness = orig.metalness;
6136
6756
  mat.emissive.set(orig.emissive);
6137
- // 恢复原始贴图
6138
6757
  mat.map = orig.map ?? null;
6139
6758
  mat.normalMap = orig.normalMap ?? null;
6140
6759
  mat.roughnessMap = orig.roughnessMap ?? null;
@@ -6245,9 +6864,31 @@ class SceneViewer {
6245
6864
  if (obj && !this.helpers.has(id)) {
6246
6865
  if (node.primitive === 'asset' && !obj.userData['assetLoaded'])
6247
6866
  continue;
6248
- const h = new THREE.BoxHelper(obj, 0xfadb14);
6867
+ // 计算 obj 的局部空间包围盒(忽略自身 transform,只看子节点)
6868
+ obj.updateWorldMatrix(true, false);
6869
+ const invObjMatrix = obj.matrixWorld.clone().invert();
6870
+ const localBox = new THREE.Box3();
6871
+ obj.traverse((child) => {
6872
+ const mesh = child;
6873
+ if (!mesh.isMesh || !mesh.geometry?.attributes['position'])
6874
+ return;
6875
+ mesh.updateWorldMatrix(true, false);
6876
+ const pos = mesh.geometry.attributes['position'];
6877
+ const tempVec = new THREE.Vector3();
6878
+ // mesh 相对于 obj 的变换
6879
+ const relMatrix = mesh.matrixWorld.clone().premultiply(invObjMatrix);
6880
+ for (let i = 0; i < pos.count; i++) {
6881
+ tempVec.fromBufferAttribute(pos, i);
6882
+ tempVec.applyMatrix4(relMatrix);
6883
+ localBox.expandByPoint(tempVec);
6884
+ }
6885
+ });
6886
+ localBox.expandByScalar(0.03);
6887
+ const h = new THREE.BoxHelper(new THREE.Object3D(), 0xfadb14);
6888
+ h.geometry.dispose();
6889
+ h.geometry = this.createBoxGeometryFromMinMax(localBox.min, localBox.max);
6249
6890
  this.helpers.set(id, h);
6250
- this.scene.add(h);
6891
+ obj.add(h);
6251
6892
  }
6252
6893
  }
6253
6894
  }
@@ -6294,6 +6935,20 @@ class SceneViewer {
6294
6935
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
6295
6936
  return geometry;
6296
6937
  }
6938
+ /** 用局部坐标 min/max 创建包围盒几何体 */
6939
+ createBoxGeometryFromMinMax(min, max) {
6940
+ const positions = new Float32Array([
6941
+ min.x, min.y, max.z, max.x, min.y, max.z, max.x, min.y, max.z, max.x, max.y, max.z, max.x, max.y, max.z,
6942
+ min.x, max.y, max.z, min.x, max.y, max.z, min.x, min.y, max.z,
6943
+ min.x, min.y, min.z, max.x, min.y, min.z, max.x, min.y, min.z, max.x, max.y, min.z, max.x, max.y, min.z,
6944
+ min.x, max.y, min.z, min.x, max.y, min.z, min.x, min.y, min.z,
6945
+ min.x, min.y, min.z, min.x, min.y, max.z, max.x, min.y, min.z, max.x, min.y, max.z, max.x, max.y, min.z,
6946
+ max.x, max.y, max.z, min.x, max.y, min.z, min.x, max.y, max.z,
6947
+ ]);
6948
+ const geometry = new THREE.BufferGeometry();
6949
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
6950
+ return geometry;
6951
+ }
6297
6952
  /** 计算 group 的所有后代的包围盒(本地空间,相对于组的 Object3D) */
6298
6953
  computeGroupLocalBounds(groupId) {
6299
6954
  const box = new THREE.Box3();
@@ -6371,8 +7026,30 @@ class SceneViewer {
6371
7026
  });
6372
7027
  }
6373
7028
  }
7029
+ // 添加 InstancedMesh 到拾取列表
7030
+ for (const meshes of this.instanceMeshes.values()) {
7031
+ for (const im of meshes) {
7032
+ allMeshes.push(im);
7033
+ }
7034
+ }
6374
7035
  const hits = this.raycaster.intersectObjects(allMeshes, false);
6375
- const id = hits[0]?.object.userData['id'] ?? null;
7036
+ if (hits.length === 0) {
7037
+ this.cb.onSelect({ id: null, additive: e.ctrlKey || e.metaKey });
7038
+ return;
7039
+ }
7040
+ const hit = hits[0];
7041
+ const obj = hit.object;
7042
+ // 检查是否是 InstancedMesh
7043
+ if (obj instanceof THREE.InstancedMesh && hit.instanceId !== undefined) {
7044
+ const nodeIds = obj.userData['instanceNodeIds'];
7045
+ if (nodeIds && hit.instanceId < nodeIds.length) {
7046
+ const id = nodeIds[hit.instanceId];
7047
+ this.cb.onSelect({ id, additive: e.ctrlKey || e.metaKey });
7048
+ return;
7049
+ }
7050
+ }
7051
+ // 普通 Mesh
7052
+ const id = obj.userData['id'] ?? null;
6376
7053
  this.cb.onSelect({ id, additive: e.ctrlKey || e.metaKey });
6377
7054
  }
6378
7055
  // ── 框选方法 ──
@@ -6428,6 +7105,9 @@ class SceneViewer {
6428
7105
  const node = this.store.node(id);
6429
7106
  if (!node)
6430
7107
  continue;
7108
+ // 跳过子节点(只选顶层对象)
7109
+ if (this.store.parentOf(id))
7110
+ continue;
6431
7111
  const center = new THREE.Vector3();
6432
7112
  if (obj instanceof THREE.Mesh) {
6433
7113
  obj.getWorldPosition(center);
@@ -6561,6 +7241,192 @@ class SceneViewer {
6561
7241
  Math.round(hit.z * 100) / 100,
6562
7242
  ];
6563
7243
  }
7244
+ /** 获取当前 FPS */
7245
+ getFps() {
7246
+ return this.fpsCurrent;
7247
+ }
7248
+ /** 标记场景统计过期(模型加载/删除后调用) */
7249
+ markStatsDirty() {
7250
+ this.statsCache.dirty = true;
7251
+ }
7252
+ /** 获取场景顶点数和三角面数(带缓存,只统计根节点避免重复) */
7253
+ getSceneStats() {
7254
+ if (!this.statsCache.dirty)
7255
+ return { vertices: this.statsCache.vertices, faces: this.statsCache.faces, drawCalls: this.statsCache.drawCalls };
7256
+ console.log('[getSceneStats] 开始统计场景数据');
7257
+ let vertices = 0;
7258
+ let faces = 0;
7259
+ let drawCalls = 0;
7260
+ // 收集所有被实例化的节点 ID
7261
+ const instancedNodeIds = new Set();
7262
+ for (const group of this.store.doc.instanceGroups || []) {
7263
+ for (const nodeId of group.nodeIds) {
7264
+ instancedNodeIds.add(nodeId);
7265
+ }
7266
+ }
7267
+ console.log('[getSceneStats] 被实例化的节点数:', instancedNodeIds.size);
7268
+ // 只遍历逻辑根节点(无父节点的),traverse 自然覆盖所有后代,避免子节点被重复计数
7269
+ for (const [id, obj] of this.objects) {
7270
+ if (this.store.parentOf(id))
7271
+ continue; // 有父节点,跳过(会被父 traverse 覆盖)
7272
+ // 如果这个节点被实例化了,跳过(由 InstancedMesh 统计)
7273
+ if (instancedNodeIds.has(id)) {
7274
+ console.log('[getSceneStats] 跳过被实例化的节点:', id);
7275
+ continue;
7276
+ }
7277
+ obj.traverse(child => {
7278
+ // 跳过不可见的对象(包括被实例化隐藏的)
7279
+ // 需要检查对象本身及其所有祖先的 visible 状态
7280
+ let current = child;
7281
+ const visibleChain = [];
7282
+ while (current) {
7283
+ visibleChain.push(`${current.name || 'unnamed'}(visible=${current.visible})`);
7284
+ if (!current.visible) {
7285
+ console.log('[getSceneStats] 跳过不可见对象:', child.name || 'unnamed', '祖先链:', visibleChain.reverse().join(' -> '));
7286
+ return;
7287
+ }
7288
+ current = current.parent;
7289
+ }
7290
+ if (!child.isMesh)
7291
+ return;
7292
+ const mesh = child;
7293
+ // 跳过 InstancedMesh(后面统一统计 instanceMeshes)
7294
+ if (mesh instanceof THREE.InstancedMesh) {
7295
+ console.log('[getSceneStats] 跳过 InstancedMesh(在 traverse 中):', mesh.name || 'unnamed');
7296
+ return;
7297
+ }
7298
+ console.log('[getSceneStats] 统计 mesh(在 traverse 中):', mesh.name || 'unnamed', '几何体顶点数:', mesh.geometry.attributes['position']?.count || 0, '祖先链:', visibleChain.reverse().join(' -> '));
7299
+ drawCalls += 1;
7300
+ const geo = mesh.geometry;
7301
+ if (!geo.index) {
7302
+ const pos = geo.attributes['position'];
7303
+ if (pos) {
7304
+ vertices += pos.count;
7305
+ faces += pos.count / 3;
7306
+ }
7307
+ }
7308
+ else {
7309
+ const pos = geo.attributes['position'];
7310
+ if (pos)
7311
+ vertices += pos.count;
7312
+ faces += geo.index.count / 3;
7313
+ }
7314
+ });
7315
+ }
7316
+ // 统计 InstancedMesh
7317
+ console.log('[getSceneStats] 统计独立的 InstancedMesh,组数:', this.instanceMeshes.size);
7318
+ for (const [groupId, instancedMeshArray] of this.instanceMeshes) {
7319
+ for (const instancedMesh of instancedMeshArray) {
7320
+ drawCalls += 1;
7321
+ const geo = instancedMesh.geometry;
7322
+ if (!geo.index) {
7323
+ const pos = geo.attributes['position'];
7324
+ if (pos) {
7325
+ vertices += pos.count;
7326
+ faces += pos.count / 3;
7327
+ }
7328
+ }
7329
+ else {
7330
+ const pos = geo.attributes['position'];
7331
+ if (pos)
7332
+ vertices += pos.count;
7333
+ faces += geo.index.count / 3;
7334
+ }
7335
+ }
7336
+ }
7337
+ console.log('[getSceneStats] 最终统计结果 - 顶点:', vertices, '面:', faces, 'DrawCalls:', drawCalls);
7338
+ this.statsCache.vertices = vertices;
7339
+ this.statsCache.faces = faces;
7340
+ this.statsCache.drawCalls = drawCalls;
7341
+ this.statsCache.dirty = false;
7342
+ return { vertices, faces, drawCalls };
7343
+ }
7344
+ /** 获取指定节点的顶点数、面数、DrawCalls */
7345
+ getNodeStats(nodeIds) {
7346
+ let vertices = 0;
7347
+ let faces = 0;
7348
+ let drawCalls = 0;
7349
+ // 收集所有被实例化的节点 ID
7350
+ const instancedNodeIds = new Set();
7351
+ for (const group of this.store.doc.instanceGroups || []) {
7352
+ for (const nodeId of group.nodeIds) {
7353
+ instancedNodeIds.add(nodeId);
7354
+ }
7355
+ }
7356
+ // 统计被选中的节点中有多少被实例化了
7357
+ const selectedInstancedCount = nodeIds.filter(id => instancedNodeIds.has(id)).length;
7358
+ // 如果有被实例化的节点,统计 InstancedMesh
7359
+ if (selectedInstancedCount > 0) {
7360
+ // 找到对应的实例组
7361
+ for (const group of this.store.doc.instanceGroups || []) {
7362
+ // 检查这个组是否包含选中的节点
7363
+ const hasSelectedNode = group.nodeIds.some(id => nodeIds.includes(id));
7364
+ if (!hasSelectedNode)
7365
+ continue;
7366
+ // 统计这个组的 InstancedMesh
7367
+ const instancedMeshArray = this.instanceMeshes.get(group.id);
7368
+ if (instancedMeshArray) {
7369
+ for (const instancedMesh of instancedMeshArray) {
7370
+ drawCalls += 1;
7371
+ const geo = instancedMesh.geometry;
7372
+ if (!geo.index) {
7373
+ const pos = geo.attributes['position'];
7374
+ if (pos) {
7375
+ vertices += pos.count;
7376
+ faces += pos.count / 3;
7377
+ }
7378
+ }
7379
+ else {
7380
+ const pos = geo.attributes['position'];
7381
+ if (pos)
7382
+ vertices += pos.count;
7383
+ faces += geo.index.count / 3;
7384
+ }
7385
+ }
7386
+ }
7387
+ }
7388
+ }
7389
+ // 统计未被实例化的选中节点
7390
+ for (const id of nodeIds) {
7391
+ if (instancedNodeIds.has(id))
7392
+ continue; // 跳过被实例化的节点
7393
+ const obj = this.objects.get(id);
7394
+ if (!obj)
7395
+ continue;
7396
+ obj.traverse(child => {
7397
+ if (!child.isMesh)
7398
+ return;
7399
+ drawCalls++;
7400
+ const mesh = child;
7401
+ const geo = mesh.geometry;
7402
+ if (!geo.index) {
7403
+ const pos = geo.attributes['position'];
7404
+ if (pos) {
7405
+ vertices += pos.count;
7406
+ faces += pos.count / 3;
7407
+ }
7408
+ }
7409
+ else {
7410
+ const pos = geo.attributes['position'];
7411
+ if (pos)
7412
+ vertices += pos.count;
7413
+ faces += geo.index.count / 3;
7414
+ }
7415
+ });
7416
+ }
7417
+ return { vertices, faces, drawCalls };
7418
+ }
7419
+ /** 获取节点的世界空间包围盒 */
7420
+ getObjectBoundingBox(nodeId) {
7421
+ const obj = this.objects.get(nodeId);
7422
+ if (!obj)
7423
+ return null;
7424
+ const box = new THREE.Box3().setFromObject(obj);
7425
+ return {
7426
+ min: [box.min.x, box.min.y, box.min.z],
7427
+ max: [box.max.x, box.max.y, box.max.z],
7428
+ };
7429
+ }
6564
7430
  }
6565
7431
 
6566
7432
  /**
@@ -6578,6 +7444,7 @@ class SceneViewComponent {
6578
7444
  transformEnd = new EventEmitter();
6579
7445
  dropAsset = new EventEmitter();
6580
7446
  thumbnailGenerated = new EventEmitter();
7447
+ cameraChange = new EventEmitter();
6581
7448
  pathPointMoved = new EventEmitter();
6582
7449
  addPathPoint = new EventEmitter();
6583
7450
  deletePathPoint = new EventEmitter();
@@ -6604,7 +7471,11 @@ class SceneViewComponent {
6604
7471
  onSegmentRightClick: (nodeId, effectIndex, segmentIndex, position, event) => this.zone.run(() => this.segmentRightClick.emit({ nodeId, effectIndex, segmentIndex, position, event })),
6605
7472
  onModelSurfaceClick: (nodeId, effectIndex, position, event) => this.zone.run(() => this.modelSurfaceClick.emit({ nodeId, effectIndex, position, event })),
6606
7473
  onMovePathPointToEnd: (nodeId, effectIndex, pointIndex, toStart) => this.zone.run(() => this.movePathPointToEnd.emit({ nodeId, effectIndex, pointIndex, toStart })),
6607
- }, this.dashboardOverlay.nativeElement, this.isEditorMode);
7474
+ });
7475
+ // 相机视角变化回调
7476
+ this.viewer.onCameraChange = () => {
7477
+ this.zone.run(() => this.cameraChange.emit());
7478
+ };
6608
7479
  this.viewer.setMode(this.mode);
6609
7480
  });
6610
7481
  }
@@ -6747,6 +7618,10 @@ class SceneViewComponent {
6747
7618
  hideAlarm(nodeId) {
6748
7619
  this.viewer?.hideAlarm(nodeId);
6749
7620
  }
7621
+ /** 设置性能模式 */
7622
+ setPerformanceMode(enabled) {
7623
+ this.viewer?.setPerformanceMode(enabled);
7624
+ }
6750
7625
  /** 拖拽悬停:允许放置 */
6751
7626
  onDragOver(e) {
6752
7627
  e.preventDefault();
@@ -6773,17 +7648,29 @@ class SceneViewComponent {
6773
7648
  getViewer() {
6774
7649
  return this.viewer;
6775
7650
  }
7651
+ getFps() {
7652
+ return this.viewer?.getFps() ?? 0;
7653
+ }
7654
+ getSceneStats() {
7655
+ return this.viewer?.getSceneStats() ?? { vertices: 0, faces: 0, drawCalls: 0 };
7656
+ }
7657
+ getNodeStats(nodeIds) {
7658
+ return this.viewer?.getNodeStats(nodeIds) ?? { vertices: 0, faces: 0, drawCalls: 0 };
7659
+ }
7660
+ getObjectBoundingBox(nodeId) {
7661
+ return this.viewer?.getObjectBoundingBox(nodeId) ?? null;
7662
+ }
6776
7663
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.34", ngImport: i0, type: SceneViewComponent, deps: [{ token: i0.NgZone }], target: i0.ɵɵFactoryTarget.Component });
6777
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.34", type: SceneViewComponent, isStandalone: true, selector: "twin-scene-view", inputs: { store: "store", mode: "mode", isEditorMode: "isEditorMode" }, outputs: { select: "select", transformEnd: "transformEnd", dropAsset: "dropAsset", thumbnailGenerated: "thumbnailGenerated", pathPointMoved: "pathPointMoved", addPathPoint: "addPathPoint", deletePathPoint: "deletePathPoint", pathEditingChanged: "pathEditingChanged", pathPointRightClick: "pathPointRightClick", segmentRightClick: "segmentRightClick", modelSurfaceClick: "modelSurfaceClick", movePathPointToEnd: "movePathPointToEnd" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, static: true }, { propertyName: "dashboardOverlay", first: true, predicate: ["dashboardOverlay"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `
6778
- <div #host class="scene-host" (dragover)="onDragOver($event)" (drop)="onDrop($event)"></div>
6779
- <div #dashboardOverlay class="dashboard-overlay"></div>
7664
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.3.34", type: SceneViewComponent, isStandalone: true, selector: "twin-scene-view", inputs: { store: "store", mode: "mode", isEditorMode: "isEditorMode" }, outputs: { select: "select", transformEnd: "transformEnd", dropAsset: "dropAsset", thumbnailGenerated: "thumbnailGenerated", cameraChange: "cameraChange", pathPointMoved: "pathPointMoved", addPathPoint: "addPathPoint", deletePathPoint: "deletePathPoint", pathEditingChanged: "pathEditingChanged", pathPointRightClick: "pathPointRightClick", segmentRightClick: "segmentRightClick", modelSurfaceClick: "modelSurfaceClick", movePathPointToEnd: "movePathPointToEnd" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, static: true }, { propertyName: "dashboardOverlay", first: true, predicate: ["dashboardOverlay"], descendants: true, static: true }], usesOnChanges: true, ngImport: i0, template: `
7665
+ <div #host class="scene-host" (dragover)="onDragOver($event)" (drop)="onDrop($event)"></div>
7666
+ <div #dashboardOverlay class="dashboard-overlay"></div>
6780
7667
  `, isInline: true, styles: [":host{display:block;width:100%;height:100%;position:relative}.scene-host{width:100%;height:100%;overflow:hidden}.scene-host canvas{display:block}.dashboard-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden}.dashboard-overlay .dashboard-widget{pointer-events:auto}\n"] });
6781
7668
  }
6782
7669
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImport: i0, type: SceneViewComponent, decorators: [{
6783
7670
  type: Component,
6784
- args: [{ selector: 'twin-scene-view', standalone: true, template: `
6785
- <div #host class="scene-host" (dragover)="onDragOver($event)" (drop)="onDrop($event)"></div>
6786
- <div #dashboardOverlay class="dashboard-overlay"></div>
7671
+ args: [{ selector: 'twin-scene-view', standalone: true, template: `
7672
+ <div #host class="scene-host" (dragover)="onDragOver($event)" (drop)="onDrop($event)"></div>
7673
+ <div #dashboardOverlay class="dashboard-overlay"></div>
6787
7674
  `, styles: [":host{display:block;width:100%;height:100%;position:relative}.scene-host{width:100%;height:100%;overflow:hidden}.scene-host canvas{display:block}.dashboard-overlay{position:absolute;top:0;left:0;width:100%;height:100%;pointer-events:none;overflow:hidden}.dashboard-overlay .dashboard-widget{pointer-events:auto}\n"] }]
6788
7675
  }], ctorParameters: () => [{ type: i0.NgZone }], propDecorators: { host: [{
6789
7676
  type: ViewChild,
@@ -6805,6 +7692,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.34", ngImpo
6805
7692
  type: Output
6806
7693
  }], thumbnailGenerated: [{
6807
7694
  type: Output
7695
+ }], cameraChange: [{
7696
+ type: Output
6808
7697
  }], pathPointMoved: [{
6809
7698
  type: Output
6810
7699
  }], addPathPoint: [{
@@ -6834,5 +7723,5 @@ const DRACO_ENCODER_PATH = 'assets/draco/encoder/';
6834
7723
  * Generated bundle index. Do not edit.
6835
7724
  */
6836
7725
 
6837
- export { AddAnimationCommand, AddEffectCommand, AddNodeCommand, BatchRemoveCommand, DRACO_DECODER_PATH, DRACO_ENCODER_PATH, FlyPathCommand, GroupCommand, History, InsertPathPointCommand, KeyframeCommand, MaterialOverrideCommand, MovePathPointToEndCommand, ParticleRuntime, PathEditor, PathParticleRuntime, RemoveAnimationCommand, RemoveEffectCommand, RemoveNodeCommand, RemovePathPointCommand, ReparentCommand, SceneStore, SceneViewComponent, SceneViewer, SegmentedPath, SetAlarmCommand, SetDashboardCommand, SetEnvironmentCommand, SetLightPropertyCommand, SetNodeNameCommand, SetNodeTextCommand, SyncMaterialCommand, ToggleSegmentTypeCommand, TransformCommand, UngroupCommand, UpdateAnimationCommand, UpdateEffectCommand, UpdatePathPointsCommand, clone, createNode, createSceneDoc, nextId };
7726
+ export { AddAnimationCommand, AddEffectCommand, AddNodeCommand, AlignCommand, BatchAddCommand, BatchRemoveCommand, DRACO_DECODER_PATH, DRACO_ENCODER_PATH, DisableInstancingCommand, EnableInstancingCommand, FlyPathCommand, GroupCommand, History, InsertPathPointCommand, KeyframeCommand, MaterialOverrideCommand, MovePathPointToEndCommand, ParticleRuntime, PathEditor, PathParticleRuntime, RemoveAnimationCommand, RemoveEffectCommand, RemoveNodeCommand, RemovePathPointCommand, ReparentCommand, SceneStore, SceneViewComponent, SceneViewer, SegmentedPath, SetAlarmCommand, SetDashboardCommand, SetEnvironmentCommand, SetLightPropertyCommand, SetNodeNameCommand, SetNodeTextCommand, SyncMaterialCommand, ToggleSegmentTypeCommand, TransformCommand, UngroupCommand, UpdateAnimationCommand, UpdateEffectCommand, UpdatePathPointsCommand, clone, createNode, createSceneDoc, nextId };
6838
7727
  //# sourceMappingURL=fengyizhi-scene-lib.mjs.map