s3m-three 0.3.7 → 0.3.9

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 (67) hide show
  1. package/dist/anim/Animator.d.ts +111 -0
  2. package/dist/anim/Animator.d.ts.map +1 -0
  3. package/dist/anim/Animator.js +382 -0
  4. package/dist/anim/Animator.js.map +1 -0
  5. package/dist/anim/Path.d.ts +30 -0
  6. package/dist/anim/Path.d.ts.map +1 -0
  7. package/dist/anim/Path.js +100 -0
  8. package/dist/anim/Path.js.map +1 -0
  9. package/dist/anim/easing.d.ts +70 -0
  10. package/dist/anim/easing.d.ts.map +1 -0
  11. package/dist/anim/easing.js +170 -0
  12. package/dist/anim/easing.js.map +1 -0
  13. package/dist/anim/index.d.ts +6 -0
  14. package/dist/anim/index.d.ts.map +1 -0
  15. package/dist/anim/index.js +4 -0
  16. package/dist/anim/index.js.map +1 -0
  17. package/dist/browser/s3m-three.iife.ext-three.js +218 -39
  18. package/dist/browser/s3m-three.iife.js +218 -39
  19. package/dist/core/types.d.ts +20 -0
  20. package/dist/core/types.d.ts.map +1 -1
  21. package/dist/index.d.ts +6 -2
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +3 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/render/EdgeLines.d.ts +68 -14
  26. package/dist/render/EdgeLines.d.ts.map +1 -1
  27. package/dist/render/EdgeLines.js +125 -8
  28. package/dist/render/EdgeLines.js.map +1 -1
  29. package/dist/render/FeatureStyleManager.d.ts +62 -1
  30. package/dist/render/FeatureStyleManager.d.ts.map +1 -1
  31. package/dist/render/FeatureStyleManager.js +331 -60
  32. package/dist/render/FeatureStyleManager.js.map +1 -1
  33. package/dist/render/MergeVertices.d.ts +7 -0
  34. package/dist/render/MergeVertices.d.ts.map +1 -1
  35. package/dist/render/MergeVertices.js +10 -1
  36. package/dist/render/MergeVertices.js.map +1 -1
  37. package/dist/render/OrthoProjection.d.ts +6 -0
  38. package/dist/render/OrthoProjection.d.ts.map +1 -1
  39. package/dist/render/OrthoProjection.js +48 -0
  40. package/dist/render/OrthoProjection.js.map +1 -1
  41. package/dist/render/S3MViewer.d.ts +235 -9
  42. package/dist/render/S3MViewer.d.ts.map +1 -1
  43. package/dist/render/S3MViewer.js +880 -68
  44. package/dist/render/S3MViewer.js.map +1 -1
  45. package/dist/render/StyleMaterial.d.ts +28 -0
  46. package/dist/render/StyleMaterial.d.ts.map +1 -1
  47. package/dist/render/StyleMaterial.js +190 -21
  48. package/dist/render/StyleMaterial.js.map +1 -1
  49. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  50. package/dist/tileset/S3MTileset.js +3 -1
  51. package/dist/tileset/S3MTileset.js.map +1 -1
  52. package/docs/ANIMATION.md +534 -0
  53. package/docs/API.md +2 -0
  54. package/package.json +1 -1
  55. package/src/anim/Animator.ts +460 -0
  56. package/src/anim/Path.ts +107 -0
  57. package/src/anim/easing.ts +195 -0
  58. package/src/anim/index.ts +10 -0
  59. package/src/core/types.ts +10 -0
  60. package/src/index.ts +16 -0
  61. package/src/render/EdgeLines.ts +161 -15
  62. package/src/render/FeatureStyleManager.ts +414 -65
  63. package/src/render/MergeVertices.ts +11 -1
  64. package/src/render/OrthoProjection.ts +52 -0
  65. package/src/render/S3MViewer.ts +1012 -64
  66. package/src/render/StyleMaterial.ts +211 -22
  67. package/src/tileset/S3MTileset.ts +3 -1
@@ -36,13 +36,19 @@ import {
36
36
  import type { OutlineMaterial, StyledMaterial, StyleMode, SelectMode, S3MStyle } from './StyleMaterial.js';
37
37
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
38
38
  import { Picker } from './Picker.js';
39
+ import { Animator } from '../anim/Animator.js';
40
+ import type { Channel, TransitionOptions } from '../anim/Animator.js';
41
+ import { Path } from '../anim/Path.js';
42
+ import type { EasingFn, EasingName } from '../anim/easing.js';
43
+ import type { LineSharedUniforms, ThickLineMaterial, ThickLineStyleUniforms } from './EdgeLines.js';
39
44
  import {
40
45
  buildFeatureEdgeSegments,
46
+ buildPositionBatchMap,
41
47
  buildThickLineGeometry,
42
48
  createLineSharedUniforms,
43
49
  createThickLineMaterial,
50
+ positionQuantKey,
44
51
  } from './EdgeLines.js';
45
- import type { LineSharedUniforms, ThickLineMaterial } from './EdgeLines.js';
46
52
  import { mergeVerticesByPosition } from './MergeVertices.js';
47
53
  import {
48
54
  resolveFeature,
@@ -88,6 +94,8 @@ import {
88
94
  type FieldElevationOptions,
89
95
  type FieldElevationResult,
90
96
  type S3MFilter,
97
+ resolveRotationMatrix,
98
+ extractEulerAngles,
91
99
  } from './FeatureStyleManager.js';
92
100
 
93
101
  import {
@@ -159,8 +167,12 @@ export interface LightOptions {
159
167
  hemisphereGroundColor?: number | string;
160
168
  /** 主平行光强度,默认 1.0 */
161
169
  keyLightIntensity?: number;
170
+ /** 主平行光强度别名(兼容写法) */
171
+ mainIntensity?: number;
162
172
  /** 主平行光颜色,默认 0xffffff */
163
173
  keyLightColor?: number | string;
174
+ /** 主平行光颜色别名(兼容写法) */
175
+ mainColor?: number | string;
164
176
  /** 补光强度,默认 0.35 */
165
177
  fillLightIntensity?: number;
166
178
  /** 补光颜色,默认 0xffffff */
@@ -180,6 +192,13 @@ export interface LightOptions {
180
192
  export interface S3MViewerOptions {
181
193
  /** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
182
194
  light?: LightOptions;
195
+ /**
196
+ * 色调映射模式(默认 THREE.NoToneMapping,避免 ACESFilmic 压暗中暗部及导致底图与建筑色彩失真;
197
+ * 可传入 THREE.ACESFilmicToneMapping、THREE.LinearToneMapping、THREE.ReinhardToneMapping 等)
198
+ */
199
+ toneMapping?: THREE.ToneMapping;
200
+ /** 色调映射曝光度,默认 1.0 */
201
+ toneMappingExposure?: number;
183
202
  /** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
184
203
  totalMaxTiles?: number;
185
204
  /** 单数据集简写 */
@@ -243,6 +262,8 @@ interface MeshUserData {
243
262
  isThickEdge?: boolean;
244
263
  /** 选中单体的棱线对象 */
245
264
  isSelectionEdge?: boolean;
265
+ /** 该网格正在淡入时占用的动画通道 key,瓦片淘汰时要回收 */
266
+ tileFadeKey?: string;
246
267
  pickMaterial?: THREE.Material;
247
268
  [key: string]: unknown;
248
269
  }
@@ -258,6 +279,54 @@ function isPointCloudObject(o: THREE.Object3D): o is THREE.Points {
258
279
  return (o as unknown as THREE.Points).isPoints === true;
259
280
  }
260
281
 
282
+ /* ------------------------------------------------------------------ 可动画的样式键 */
283
+
284
+ /**
285
+ * 能逐帧插值的标量样式键。
286
+ *
287
+ * 判据:写一次只是「改一个 uniform 或一个字段」,不重建几何、不换 program。
288
+ * `mode` / `shading` / `edges` 不在此列——它们是结构变更,只能编排不能插值。
289
+ */
290
+ const STYLE_SCALAR_KEYS = [
291
+ 'opacity',
292
+ 'floorHeight',
293
+ 'selectOpacity',
294
+ 'selectEmissive',
295
+ 'selectElevation',
296
+ 'dimFactor',
297
+ 'texMix',
298
+ 'roofThreshold',
299
+ 'ambientGain',
300
+ 'edgeWidth',
301
+ 'edgeOpacity',
302
+ 'outlineWidth',
303
+ ] as const;
304
+
305
+ /** 能逐帧插值的颜色键(在线性空间插值,与 shader 消费端一致) */
306
+ const STYLE_COLOR_KEYS = ['colorA', 'colorB', 'colorC', 'selectColor', 'edgeColor'] as const;
307
+
308
+ /** 可动画的单体属性 */
309
+ const FEATURE_PROPS = ['translation', 'elevation', 'opacity', 'color', 'rotation'] as const;
310
+ type FeatureProp = (typeof FEATURE_PROPS)[number];
311
+
312
+ /** 单体沿折线运动的参数 */
313
+ export interface PathFollowOptions {
314
+ /** 走完全程的时长(毫秒)。不传则按 speed 或默认 2000ms 推算 */
315
+ duration?: number;
316
+ /** 线速度(米/秒)。给了 duration 就以 duration 为准 */
317
+ speed?: number;
318
+ /** 是否循环 */
319
+ loop?: boolean;
320
+ /** 是否把折线首尾相连 */
321
+ closed?: boolean;
322
+ /** 缓动曲线:作用在**已走距离**上,默认 linear(恒速) */
323
+ easing?: EasingName | EasingFn;
324
+ /** 路径整体偏移(米),相当于路径原点 */
325
+ offset?: [number, number, number];
326
+ /** 单次走完(非循环)时回调 */
327
+ onComplete?: () => void;
328
+ }
329
+
261
330
  /* ------------------------------------------------------------------ 图层 */
262
331
 
263
332
  export class S3MViewer {
@@ -278,15 +347,22 @@ export class S3MViewer {
278
347
  picker: Picker | null = null;
279
348
 
280
349
  readonly uniforms: StyleUniforms;
350
+ /** 当前**显示**的样式(动画推进中会逐帧变) */
281
351
  style: S3MStyle;
352
+ /** 用户想要的样式(getStyle 返回它,动画目标以它为准) */
353
+ private _styleTarget: S3MStyle;
282
354
  readonly outlineMaterial: OutlineMaterial;
283
355
 
284
356
  /** 等宽线共享的分辨率 uniform(全局棱线 + 选中棱线共用) */
285
357
  readonly lineShared: LineSharedUniforms;
286
- /** 全局棱线材质 */
358
+ /** 全局棱线材质(外观 uniform 的持有者,逐网格棱线材质共享它的对象) */
287
359
  readonly edgeMaterial: ThickLineMaterial;
288
- /** 选中棱线材质 */
360
+ /** 选中棱线材质(同上,选中棱线的逐网格材质共享它的对象) */
289
361
  readonly selectionEdgeMaterial: ThickLineMaterial;
362
+ /** 全局棱线的外观 uniform:逐网格棱线材质直接复用这些对象 */
363
+ private readonly _edgeStyleUniforms: ThickLineStyleUniforms;
364
+ /** 选中棱线的外观 uniform */
365
+ private readonly _selEdgeStyleUniforms: ThickLineStyleUniforms;
290
366
 
291
367
  /** 3D 物理地表面 / 基坑开挖管理器 */
292
368
  readonly groundManager: GroundManager;
@@ -325,13 +401,37 @@ export class S3MViewer {
325
401
  private _selectionEdges: THREE.Mesh | null = null;
326
402
  private _selectionEdgeMeshes: THREE.Mesh[] = [];
327
403
  private _edgesEnabled = false;
404
+ /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
405
+ private _tileFadeSeq = 0;
406
+ /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
407
+ private _modeGen = 0;
408
+ /** 路径运动的规格表:通道 key → 路径 + 偏移 */
409
+ private readonly _pathSpecs = new Map<string, { path: Path; offset: [number, number, number] }>();
410
+ /** 路径采样临时对象,避免每帧 new */
411
+ private readonly _pathScratch = new Float32Array(3);
328
412
  /** 预制棱线缓存(Crease Cache):从外部 source-creases 载入的全局统一 LineSegments,性能最高、视觉最干净 */
329
413
  private _creaseLines: THREE.LineSegments | null = null;
414
+ /** 数据自带的棱线对象(LineSegments),改棱线颜色时只刷这些,不再全场景 traverse */
415
+ private readonly _nativeEdgeLines = new Set<THREE.LineSegments>();
416
+ /** 点云对象集合:透明度动画时只刷它们,避免每帧遍历全部 pickables */
417
+ private readonly _pointClouds = new Set<THREE.Points>();
418
+ /** 当前材质是否处于 transparent 状态,用来避免逐帧重复切换 program */
419
+ private _opacityTransparent = false;
420
+
421
+ /**
422
+ * 统一动画调度器。
423
+ *
424
+ * 所有「有过渡」的样式 / 单体 / 棱线变更都经由它推进;公开 API 只写目标值,
425
+ * 真正的逐帧写入由注册在这里的通道完成。
426
+ */
427
+ readonly animator = new Animator();
330
428
 
331
429
  /** 业务属性查询(可选,配了 `attributes` 才有) */
332
430
  attributeQuery: AttributeQuery | null = null;
333
431
 
334
432
  private readonly _enuMatrix = new THREE.Matrix4();
433
+ /** 颜色换算用的临时对象,避免每帧 new */
434
+ private readonly _colorScratch = new THREE.Color();
335
435
  private _needsRepaint = true;
336
436
  private _frame = 0;
337
437
  private readonly _fps = { value: 0, last: 0 };
@@ -339,7 +439,7 @@ export class S3MViewer {
339
439
  private _hemiLight!: THREE.HemisphereLight;
340
440
  private _keyLight!: THREE.DirectionalLight;
341
441
  private _fillLight!: THREE.DirectionalLight;
342
- private readonly _lightOptions: Required<LightOptions> = {
442
+ private readonly _lightOptions: Required<Omit<LightOptions, 'mainIntensity' | 'mainColor'>> = {
343
443
  ambientIntensity: 0.4,
344
444
  ambientColor: 0xffffff,
345
445
  hemisphereIntensity: 0.8,
@@ -400,26 +500,40 @@ export class S3MViewer {
400
500
  if (this._selection) this._syncSelectionStyle();
401
501
  },
402
502
  () => this.getAllMeshes(),
403
- (mesh) => this._applyOverridesToMesh(mesh)
503
+ (mesh) => this._applyOverridesToMesh(mesh),
504
+ // 图层自身是否半透明:全局透明度改走 uOpacity 后,材质 opacity 不再代表它
505
+ () => this._opacityTransparent
404
506
  );
405
507
 
406
508
  this.uniforms = createSharedUniforms();
407
509
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
408
510
  setTextureOffThreadDecode(decodeTextureWithWorker);
409
511
  this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
512
+ this._styleTarget = { ...this.style };
513
+ this._opacityTransparent = (this.style.opacity ?? 1) < 0.999;
514
+ this._applyStyleUniforms();
515
+ this._registerAnimChannels();
516
+ // 动画启动必须把按需驱动的帧循环踢起来:MapLibre 空闲时不渲染,
517
+ // 不踢的话动画「卡住不动」,直到别的什么碰巧触发了重绘
518
+ this.animator.onActive = () => this._requestRepaint();
410
519
  this.outlineMaterial = createOutlineMaterial(this.uniforms);
411
520
 
412
521
  this.lineShared = createLineSharedUniforms();
413
- this.edgeMaterial = createThickLineMaterial(this.lineShared, {
414
- color: this.style.edgeColor,
415
- opacity: this.style.edgeOpacity ?? 0.45,
416
- width: this.style.edgeWidth ?? 1.2,
417
- });
418
- this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {
419
- color: this.style.outlineColor ?? this.style.selectColor,
420
- opacity: 1,
421
- width: this.style.outlineWidthPx ?? 3,
422
- });
522
+ // 棱线外观 uniform:全局棱线材质与逐网格棱线材质**共用同一批对象**,
523
+ // _syncEdgeStyle 改一次,所有棱线(含淡入乘子)同时生效,保持 O(1)。
524
+ this._edgeStyleUniforms = {
525
+ uColor: { value: new THREE.Color(this.style.edgeColor) },
526
+ uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
527
+ uOpacity: { value: 0.6 },
528
+ };
529
+ this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
530
+ this._selEdgeStyleUniforms = {
531
+ uColor: { value: new THREE.Color(this.style.outlineColor ?? this.style.selectColor) },
532
+ uWidth: { value: Math.max(0.1, this.style.outlineWidthPx ?? 3) },
533
+ uOpacity: { value: 1 },
534
+ uElevation: { value: this.style.selectElevation ?? 0 },
535
+ };
536
+ this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
423
537
 
424
538
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
425
539
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
@@ -445,9 +559,9 @@ export class S3MViewer {
445
559
  });
446
560
  r.autoClear = false;
447
561
  r.setPixelRatio(1);
448
- // 白模 albedo 接近 1,不做色调映射会过曝成一片纯白、丢失明暗关系
449
- r.toneMapping = THREE.ACESFilmicToneMapping;
450
- r.toneMappingExposure = 1.0;
562
+ // 允许外部传入色调映射模式,默认使用 THREE.NoToneMapping 还原真实材质与底图通透度,避免 ACESFilmic 压暗阴影与偏色
563
+ r.toneMapping = this.options.toneMapping !== undefined ? this.options.toneMapping : THREE.NoToneMapping;
564
+ r.toneMappingExposure = this.options.toneMappingExposure ?? 1.0;
451
565
  return r;
452
566
  }
453
567
 
@@ -531,6 +645,11 @@ export class S3MViewer {
531
645
  this._selectionEdges = null;
532
646
  this._smidMeshes.clear();
533
647
  this._meshHeight.clear();
648
+ // 场景已经清空,还挂着的动画通道(瓦片淡入 / 单体位移)不能再对着死对象写
649
+ this.animator.clear();
650
+ this.animator.unregisterPrefix('tile.');
651
+ this.animator.unregisterPrefix('feature.');
652
+ this._pathSpecs.clear();
534
653
  this._heightMin = Infinity;
535
654
  this._heightMax = -Infinity;
536
655
  this.renderer?.dispose();
@@ -580,7 +699,14 @@ export class S3MViewer {
580
699
  * 动态设置光照配置
581
700
  */
582
701
  setLight(options: Partial<LightOptions>): void {
583
- Object.assign(this._lightOptions, options);
702
+ const normalized: Partial<LightOptions> = { ...options };
703
+ if (normalized.mainColor !== undefined && normalized.keyLightColor === undefined) {
704
+ normalized.keyLightColor = normalized.mainColor;
705
+ }
706
+ if (normalized.mainIntensity !== undefined && normalized.keyLightIntensity === undefined) {
707
+ normalized.keyLightIntensity = normalized.mainIntensity;
708
+ }
709
+ Object.assign(this._lightOptions, normalized);
584
710
  this._applyLightOptions();
585
711
  this._updateLights();
586
712
  this._needsRepaint = true;
@@ -594,6 +720,30 @@ export class S3MViewer {
594
720
  return { ...this._lightOptions };
595
721
  }
596
722
 
723
+ /**
724
+ * 动态设置色调映射模式与曝光度
725
+ */
726
+ setToneMapping(toneMapping: THREE.ToneMapping, exposure = 1.0): void {
727
+ this.options.toneMapping = toneMapping;
728
+ this.options.toneMappingExposure = exposure;
729
+ if (this.renderer) {
730
+ this.renderer.toneMapping = toneMapping;
731
+ this.renderer.toneMappingExposure = exposure;
732
+ this._needsRepaint = true;
733
+ this.triggerRepaint();
734
+ }
735
+ }
736
+
737
+ /**
738
+ * 获取当前色调映射模式与曝光度
739
+ */
740
+ getToneMapping(): { toneMapping: THREE.ToneMapping; toneMappingExposure: number } {
741
+ return {
742
+ toneMapping: this.renderer ? this.renderer.toneMapping : (this.options.toneMapping ?? THREE.NoToneMapping),
743
+ toneMappingExposure: this.renderer ? this.renderer.toneMappingExposure : (this.options.toneMappingExposure ?? 1.0),
744
+ };
745
+ }
746
+
597
747
  /**
598
748
  * 更新光照位置与方向:
599
749
  * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
@@ -774,6 +924,400 @@ export class S3MViewer {
774
924
  this._featureStyleManager.setFeaturesStyle(smids, style, this._meshesOf(smids));
775
925
  }
776
926
 
927
+ /**
928
+ * 把单体平移到新位置(米,场景 ENU 坐标的增量)。
929
+ *
930
+ * 平移写进状态纹理第 2 行,所以拾取、选中描边、棱线都会自动跟随——
931
+ * 但**视锥剔除不会**:three 用的是几何原始包围球,移出包围球后整块瓦片会被剔掉。
932
+ * 所以这里顺手把「有位移」的网格关掉 frustumCulled,归零时再恢复。
933
+ */
934
+ moveFeature(
935
+ smid: number,
936
+ translation: [number, number, number],
937
+ transition?: TransitionOptions
938
+ ): void {
939
+ const key = this._ensureFeatureChannel(smid, 'translation');
940
+ this.animator.to(key, translation, transition);
941
+ }
942
+
943
+ /** 让单体沿某个轴移动的便捷写法(单位米) */
944
+ moveFeatureBy(
945
+ smid: number,
946
+ delta: [number, number, number],
947
+ transition?: TransitionOptions
948
+ ): void {
949
+ const cur = this._featureStyleManager.getFeatureStyle(smid)?.translation ?? [0, 0, 0];
950
+ this.moveFeature(smid, [cur[0] + delta[0], cur[1] + delta[1], cur[2] + delta[2]], transition);
951
+ }
952
+
953
+ /**
954
+ * 让单体进行刚体姿态旋转(在 GPU 顶点着色器中以 pivot 轴心进行 3x3 矩阵旋转变换)。
955
+ *
956
+ * @param smid 单体 SMID
957
+ * @param rotation 旋转弧度或姿态:数字(绕 Z 轴 yaw 弧度)| [rx, ry, rz] 欧拉角 | [x,y,z,w] 四元数 | Euler/Quaternion/Matrix
958
+ * @param pivot 旋转中心轴心点 [px, py, pz](局部坐标,默认 [0, 0, 0])
959
+ * @param transition 动画过渡参数
960
+ */
961
+ rotateFeature(
962
+ smid: number,
963
+ rotation:
964
+ | number
965
+ | [number, number, number]
966
+ | [number, number, number, number]
967
+ | THREE.Euler
968
+ | THREE.Quaternion
969
+ | THREE.Matrix3
970
+ | THREE.Matrix4,
971
+ pivot?: [number, number, number],
972
+ transition?: TransitionOptions
973
+ ): void {
974
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
975
+ if (pivot) {
976
+ this._featureStyleManager.setFeatureStyle(smid, { ...cur, pivot }, this._meshesOf([smid]));
977
+ }
978
+ const targetEuler = extractEulerAngles(rotation);
979
+ const key = this._ensureFeatureChannel(smid, 'rotation');
980
+ this.animator.to(key, targetEuler, transition);
981
+ }
982
+
983
+ /** 让单体增量旋转的便捷写法 */
984
+ rotateFeatureBy(
985
+ smid: number,
986
+ deltaRotation: number | [number, number, number],
987
+ pivot?: [number, number, number],
988
+ transition?: TransitionOptions
989
+ ): void {
990
+ const curEuler = extractEulerAngles(this._featureStyleManager.getFeatureStyle(smid)?.rotation);
991
+ const d = typeof deltaRotation === 'number' ? [0, 0, deltaRotation] : deltaRotation;
992
+ this.rotateFeature(
993
+ smid,
994
+ [curEuler[0] + d[0], curEuler[1] + d[1], curEuler[2] + d[2]],
995
+ pivot,
996
+ transition
997
+ );
998
+ }
999
+
1000
+ /**
1001
+ * 双纹理 GPU 交叉淡化(跨状态 / 专题图平滑过渡)。
1002
+ *
1003
+ * 采用双纹理 (uBatchTex + uBatchTex2 + uBatchMix) 硬件混合,
1004
+ * 即使几千上万栋建筑全量切换配色/高度/显隐/姿态,也无需在 CPU 逐单体创建通道,
1005
+ * 由 GPU 顶点与片元着色器以 60FPS 零成本硬件插值完成过渡。
1006
+ */
1007
+ crossFadeStyles(
1008
+ targetStyles: Map<number, FeatureStyle> | Record<string | number, FeatureStyle>,
1009
+ transition?: TransitionOptions
1010
+ ): void {
1011
+ const targetMap =
1012
+ targetStyles instanceof Map
1013
+ ? targetStyles
1014
+ : new Map(Object.entries(targetStyles).map(([k, v]) => [Number(k), v]));
1015
+
1016
+ const meshes = this.getAllMeshes();
1017
+ this._featureStyleManager.prepareCrossFade(targetMap, meshes);
1018
+
1019
+ const key = 'batch.mix';
1020
+ if (!this.animator.has(key)) {
1021
+ this.animator.register(key, {
1022
+ dim: 1,
1023
+ read: (out) => {
1024
+ out[0] = this.uniforms.uBatchMix?.value ?? 0;
1025
+ },
1026
+ write: (v) => {
1027
+ if (this.uniforms.uBatchMix) {
1028
+ this.uniforms.uBatchMix.value = v[0];
1029
+ }
1030
+ this.triggerRepaint();
1031
+ },
1032
+ });
1033
+ }
1034
+
1035
+ const duration = transition?.duration ?? 0;
1036
+ const wantAnim = duration > 0 || !!transition?.spring;
1037
+ if (!wantAnim) {
1038
+ if (this.animator.has(key)) this.animator.cancel(key);
1039
+ if (this.uniforms.uBatchMix) this.uniforms.uBatchMix.value = 0;
1040
+ this._featureStyleManager.commitCrossFade(targetMap, meshes);
1041
+ this.triggerRepaint();
1042
+ transition?.onComplete?.();
1043
+ return;
1044
+ }
1045
+
1046
+ if (this.uniforms.uBatchMix) this.uniforms.uBatchMix.value = 0;
1047
+ this.animator.to(key, 1, {
1048
+ ...transition,
1049
+ onComplete: () => {
1050
+ if (this.uniforms.uBatchMix) this.uniforms.uBatchMix.value = 0;
1051
+ this._featureStyleManager.commitCrossFade(targetMap, meshes);
1052
+ this.triggerRepaint();
1053
+ transition?.onComplete?.();
1054
+ },
1055
+ });
1056
+ }
1057
+
1058
+ /**
1059
+ * 让单体沿折线运动。
1060
+ *
1061
+ * 路径先做**弧长参数化**(见 anim/Path.ts):缓动作用在「已走距离」上,而不是
1062
+ * 顶点索引上,所以顶点疏密不均也不会忽快忽慢。
1063
+ *
1064
+ * 实现上没有引入新的 track 类型——把「进度 0→1」当成一条普通补间,通道的 write
1065
+ * 把进度映射成弧长再查表得位置。这样打断、循环、缓动、预算全部复用现有调度器。
1066
+ *
1067
+ * 注意:不要和 `moveFeature` 同时驱动同一个单体,两者都写 `translation`。
1068
+ */
1069
+ followPath(
1070
+ smid: number,
1071
+ points: Path | ArrayLike<number> | Array<[number, number, number]>,
1072
+ opts: PathFollowOptions = {}
1073
+ ): void {
1074
+ const path = points instanceof Path ? points : new Path(points, { closed: opts.closed });
1075
+ if (path.length <= 0) return;
1076
+
1077
+ const offset = opts.offset ?? [0, 0, 0];
1078
+ const duration =
1079
+ opts.duration ??
1080
+ (opts.speed && opts.speed > 0 ? (path.length / opts.speed) * 1000 : 2000);
1081
+
1082
+ const key = `feature.${smid}.path`;
1083
+ this._pathSpecs.set(key, { path, offset });
1084
+ if (!this.animator.has(key)) {
1085
+ this.animator.register(key, {
1086
+ dim: 1,
1087
+ // 每次都从起点开始:进度通道没有「当前值」的概念,循环时正好重置
1088
+ read: (out) => {
1089
+ out[0] = 0;
1090
+ },
1091
+ write: (v) => {
1092
+ const spec = this._pathSpecs.get(key);
1093
+ if (!spec) return;
1094
+ spec.path.sampleAt(v[0], this._pathScratch);
1095
+ const meshes = this._meshesOf([smid]);
1096
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1097
+ this._featureStyleManager.setFeatureStyle(
1098
+ smid,
1099
+ {
1100
+ ...cur,
1101
+ translation: [
1102
+ spec.offset[0] + this._pathScratch[0],
1103
+ spec.offset[1] + this._pathScratch[1],
1104
+ spec.offset[2] + this._pathScratch[2],
1105
+ ],
1106
+ },
1107
+ meshes
1108
+ );
1109
+ // 轨迹可能跑到原始包围球外,关掉视锥剔除免得整块瓦片被剔掉
1110
+ for (const mesh of meshes) mesh.frustumCulled = false;
1111
+ },
1112
+ });
1113
+ }
1114
+
1115
+ const run = (): void => {
1116
+ this.animator.to(key, 1, {
1117
+ duration,
1118
+ easing: opts.easing ?? 'linear',
1119
+ onComplete: () => {
1120
+ if (opts.loop) run();
1121
+ else opts.onComplete?.();
1122
+ },
1123
+ });
1124
+ };
1125
+ run();
1126
+ }
1127
+
1128
+ /** 停下路径运动(停在当前位置,不回弹) */
1129
+ stopPath(smid: number): void {
1130
+ const key = `feature.${smid}.path`;
1131
+ this.animator.cancel(key);
1132
+ this.animator.unregister(key);
1133
+ this._pathSpecs.delete(key);
1134
+ }
1135
+ /**
1136
+ * 单体样式过渡:颜色 / 抬高 / 透明度 / 平移可以任意组合。
1137
+ *
1138
+ * 各属性各走一条通道,互不覆盖——同一个单体上「一边抬高一边变色」是两条独立的
1139
+ * track,写回时都是「读当前 → 改一个字段 → 写回」,不会互相冲掉。
1140
+ * `hidden` 不可插值,直接生效。
1141
+ */
1142
+ animateFeature(smid: number, style: Partial<FeatureStyle>, transition?: TransitionOptions): void {
1143
+ if (style.translation) this.moveFeature(smid, style.translation, transition);
1144
+ if (style.rotation != null) this.rotateFeature(smid, style.rotation, style.pivot, transition);
1145
+ if (style.elevation != null) {
1146
+ this.animator.to(this._ensureFeatureChannel(smid, 'elevation'), style.elevation, transition);
1147
+ }
1148
+ if (style.opacity != null) {
1149
+ this.animator.to(this._ensureFeatureChannel(smid, 'opacity'), style.opacity, transition);
1150
+ }
1151
+ if (style.color) {
1152
+ this._colorScratch.set(style.color);
1153
+ this.animator.to(
1154
+ this._ensureFeatureChannel(smid, 'color'),
1155
+ [this._colorScratch.r, this._colorScratch.g, this._colorScratch.b],
1156
+ transition
1157
+ );
1158
+ }
1159
+ if (style.hidden != null) {
1160
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1161
+ this.setFeatureStyle(smid, { ...cur, hidden: style.hidden });
1162
+ }
1163
+ }
1164
+
1165
+ /**
1166
+ * 批量单体样式过渡,支持 `stagger` 错峰。
1167
+ *
1168
+ * 错峰是这类系统性价比最高的选项:一万个单体错峰 8ms,总时长不变,但每帧只推进
1169
+ * 一小批,把峰值摊平——比任何「每帧上限」都自然。
1170
+ */
1171
+ animateFeatures(
1172
+ smids: number[],
1173
+ style: Partial<FeatureStyle>,
1174
+ transition?: TransitionOptions
1175
+ ): void {
1176
+ const stagger = transition?.stagger ?? 0;
1177
+ if (stagger <= 0) {
1178
+ for (const smid of smids) this.animateFeature(smid, style, transition);
1179
+ return;
1180
+ }
1181
+ const baseDelay = transition?.delay ?? 0;
1182
+ for (let i = 0; i < smids.length; i++) {
1183
+ this.animateFeature(smids[i], style, { ...transition, delay: baseDelay + i * stagger });
1184
+ }
1185
+ }
1186
+
1187
+ /**
1188
+ * 切换着色模式,用 fade-through 代替硬切。
1189
+ *
1190
+ * `uMode` 是 int,插不了值;能做的就是「压暗 → 换 → 亮回来」。
1191
+ * 编排走 onComplete 回调而不是 Promise 链,这样换模式发生在同一帧的回调里,
1192
+ * 不会多出一帧的空白。返回的 Promise 在整段编排结束后 resolve。
1193
+ */
1194
+ setMode(mode: StyleMode, opts: { duration?: number; dim?: number } = {}): Promise<void> {
1195
+ const d = Math.max(0, opts.duration ?? 300);
1196
+ const dim = Math.min(1, Math.max(0.05, opts.dim ?? 0.35));
1197
+ if (this.style.mode === mode) return Promise.resolve();
1198
+ if (d <= 0) {
1199
+ this.setStyle({ mode });
1200
+ return Promise.resolve();
1201
+ }
1202
+
1203
+ const gen = ++this._modeGen;
1204
+ return new Promise<void>((resolve) => {
1205
+ this.animator.to('style.fade', dim, {
1206
+ duration: d,
1207
+ easing: 'easeInOutQuad',
1208
+ onComplete: () => {
1209
+ // 中途又切了一次:这次编排作废,收尾交给新的那次
1210
+ if (gen !== this._modeGen) {
1211
+ resolve();
1212
+ return;
1213
+ }
1214
+ this.setStyle({ mode });
1215
+ this.animator.to('style.fade', 1, {
1216
+ duration: d,
1217
+ easing: 'easeInOutQuad',
1218
+ onComplete: resolve,
1219
+ });
1220
+ },
1221
+ });
1222
+ });
1223
+ }
1224
+
1225
+ /**
1226
+ * 按需注册「单体属性」通道。
1227
+ *
1228
+ * 通道按 smid 惰性创建:绝大多数场景只动几十个单体,没必要给全城几万个 smid
1229
+ * 预先建通道。`removeFeatureStyle` / `clearFeatureStyles` 会顺手回收。
1230
+ *
1231
+ * 写回统一走 `setFeatureStyle`:它内部已经按 `_meshesOf([smid])` 收敛脏网格,
1232
+ * 所以单个单体动画每帧只重烘「含这个单体」的那一两块,不会全场景重算。
1233
+ */
1234
+ private _ensureFeatureChannel(smid: number, prop: FeatureProp): string {
1235
+ const key = `feature.${smid}.${prop}`;
1236
+ if (this.animator.has(key)) return key;
1237
+
1238
+ const channel: Channel = prop === 'translation' || prop === 'color' || prop === 'rotation'
1239
+ ? {
1240
+ dim: 3,
1241
+ read: (out) => {
1242
+ if (prop === 'translation') {
1243
+ const t = this._featureStyleManager.getFeatureStyle(smid)?.translation;
1244
+ out[0] = t?.[0] ?? 0;
1245
+ out[1] = t?.[1] ?? 0;
1246
+ out[2] = t?.[2] ?? 0;
1247
+ } else if (prop === 'rotation') {
1248
+ const r = this._featureStyleManager.getFeatureStyle(smid)?.rotation;
1249
+ const euler = extractEulerAngles(r);
1250
+ out[0] = euler[0];
1251
+ out[1] = euler[1];
1252
+ out[2] = euler[2];
1253
+ } else {
1254
+ // 没有自定义色时以图层主色为起点,插值才连续
1255
+ const hex = this._featureStyleManager.getFeatureStyle(smid)?.color ?? this.style.colorA;
1256
+ this._colorScratch.set(hex);
1257
+ out[0] = this._colorScratch.r;
1258
+ out[1] = this._colorScratch.g;
1259
+ out[2] = this._colorScratch.b;
1260
+ }
1261
+ },
1262
+ write: (v) => {
1263
+ const meshes = this._meshesOf([smid]);
1264
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1265
+ if (prop === 'translation') {
1266
+ this._featureStyleManager.setFeatureStyle(
1267
+ smid,
1268
+ { ...cur, translation: [v[0], v[1], v[2]] },
1269
+ meshes
1270
+ );
1271
+ // 有位移或旋转就关掉视锥剔除,否则移出原始包围球后整块瓦片会凭空消失
1272
+ const transMoving = Math.abs(v[0]) > 1e-6 || Math.abs(v[1]) > 1e-6 || Math.abs(v[2]) > 1e-6;
1273
+ const rotEuler = extractEulerAngles(cur.rotation);
1274
+ const rotMoving = Math.abs(rotEuler[0]) > 1e-6 || Math.abs(rotEuler[1]) > 1e-6 || Math.abs(rotEuler[2]) > 1e-6;
1275
+ for (const mesh of meshes) mesh.frustumCulled = !(transMoving || rotMoving);
1276
+ } else if (prop === 'rotation') {
1277
+ this._featureStyleManager.setFeatureStyle(
1278
+ smid,
1279
+ { ...cur, rotation: [v[0], v[1], v[2]] },
1280
+ meshes
1281
+ );
1282
+ const rotMoving = Math.abs(v[0]) > 1e-6 || Math.abs(v[1]) > 1e-6 || Math.abs(v[2]) > 1e-6;
1283
+ const t = cur.translation ?? [0, 0, 0];
1284
+ const transMoving = Math.abs(t[0]) > 1e-6 || Math.abs(t[1]) > 1e-6 || Math.abs(t[2]) > 1e-6;
1285
+ for (const mesh of meshes) mesh.frustumCulled = !(rotMoving || transMoving);
1286
+ } else {
1287
+ const c = this._colorScratch.setRGB(v[0], v[1], v[2]);
1288
+ this._featureStyleManager.setFeatureStyle(
1289
+ smid,
1290
+ { ...cur, color: `#${c.getHexString()}` },
1291
+ meshes
1292
+ );
1293
+ }
1294
+ },
1295
+ }
1296
+ : {
1297
+ dim: 1,
1298
+ read: (out) => {
1299
+ const st = this._featureStyleManager.getFeatureStyle(smid);
1300
+ out[0] = (prop === 'elevation' ? st?.elevation : st?.opacity) ?? (prop === 'opacity' ? 1 : 0);
1301
+ },
1302
+ write: (v) => {
1303
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1304
+ const next: FeatureStyle = prop === 'elevation' ? { ...cur, elevation: v[0] } : { ...cur, opacity: v[0] };
1305
+ this._featureStyleManager.setFeatureStyle(smid, next, this._meshesOf([smid]));
1306
+ },
1307
+ };
1308
+
1309
+ this.animator.register(key, channel);
1310
+ return key;
1311
+ }
1312
+
1313
+ /** 回收某个单体的动画通道(单体被删除 / 样式被清空时调用) */
1314
+ private _releaseFeatureChannels(smid: number): void {
1315
+ for (const prop of FEATURE_PROPS) this.animator.unregister(`feature.${smid}.${prop}`);
1316
+ const pathKey = `feature.${smid}.path`;
1317
+ this.animator.unregister(pathKey);
1318
+ this._pathSpecs.delete(pathKey);
1319
+ }
1320
+
777
1321
  /** 获取指定单体的自定义样式 */
778
1322
  getFeatureStyle(smid: number): FeatureStyle | null {
779
1323
  return this._featureStyleManager.getFeatureStyle(smid);
@@ -786,11 +1330,15 @@ export class S3MViewer {
786
1330
 
787
1331
  /** 移除指定单体的自定义样式,恢复为图层全局样式 */
788
1332
  removeFeatureStyle(smid: number): void {
1333
+ this._releaseFeatureChannels(smid);
789
1334
  this._featureStyleManager.removeFeatureStyle(smid, this._meshesOf([smid]));
790
1335
  }
791
1336
 
792
1337
  /** 清空所有单体的自定义样式 */
793
1338
  clearFeatureStyles(): void {
1339
+ for (const smid of this._featureStyleManager.getFeatureStyles().keys()) {
1340
+ this._releaseFeatureChannels(smid);
1341
+ }
794
1342
  this._featureStyleManager.clearFeatureStyles();
795
1343
  }
796
1344
 
@@ -998,6 +1546,7 @@ export class S3MViewer {
998
1546
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
999
1547
  if ((child as THREE.LineSegments).isLineSegments && (child.userData as MeshUserData).isEdgeLines) {
1000
1548
  child.visible = this._edgesEnabled;
1549
+ this._nativeEdgeLines.add(child as THREE.LineSegments);
1001
1550
  this._styleEdgeLine(child as THREE.LineSegments);
1002
1551
  continue;
1003
1552
  }
@@ -1006,6 +1555,7 @@ export class S3MViewer {
1006
1555
  const isPoints = isPointCloudObject(child);
1007
1556
  const mesh = child as THREE.Mesh;
1008
1557
  if (!mesh.isMesh && !isPoints) continue;
1558
+ if (isPoints) this._pointClouds.add(child as THREE.Points);
1009
1559
  const ud = mesh.userData as MeshUserData;
1010
1560
  ud.pickId = this._allocPickId();
1011
1561
  ud.datasetId = entry.id;
@@ -1032,12 +1582,13 @@ export class S3MViewer {
1032
1582
  const mat = styledMaterialOf(mesh);
1033
1583
  if (mat) {
1034
1584
  const o = Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
1035
- mat.opacity = o;
1585
+ // 不写 mat.opacity:全局透明度走共享 uOpacity,材质自身 opacity 保留模型自带 alpha
1036
1586
  mat.transparent = o < 0.999;
1037
1587
  mat.depthWrite = o >= 0.999;
1038
1588
  mat.flatShading = this.style.shading !== 'smooth';
1039
1589
  mat.needsUpdate = true;
1040
1590
  this._featureStyleManager.applyOverridesToMesh(mesh);
1591
+ this._startTileFade(mesh, mat);
1041
1592
  }
1042
1593
  }
1043
1594
 
@@ -1047,6 +1598,53 @@ export class S3MViewer {
1047
1598
  }
1048
1599
  }
1049
1600
 
1601
+ /**
1602
+ * 新瓦片淡入。
1603
+ *
1604
+ * 每块网格有自己的材质实例,所以给一个逐材质的 `uTileFade` 就够,不需要动共享
1605
+ * uniform、也不需要重建任何东西。淡入期间该网格进透明队列(混合 + 排序),
1606
+ * 淡完立刻把 `transparent` 还给图层状态,避免长期留在透明通道里。
1607
+ *
1608
+ * 通道 key 用自增序号而不是 pickId:pickId 会被回收复用,用它会串到新瓦片上。
1609
+ */
1610
+ private _startTileFade(mesh: THREE.Mesh, mat: StyledMaterial): void {
1611
+ const duration = this.style.fadeIn ?? 0;
1612
+ if (duration <= 0) return;
1613
+ const uFade = mat.userData.uTileFade;
1614
+ if (!uFade) return;
1615
+
1616
+ uFade.value = 0;
1617
+ mat.transparent = true;
1618
+ // 淡入期间保持深度写入:否则能透过正在淡入的楼看到后面的东西,像在「漏」
1619
+ mat.depthWrite = true;
1620
+
1621
+ const key = `tile.${this._tileFadeSeq++}.fade`;
1622
+ (mesh.userData as MeshUserData).tileFadeKey = key;
1623
+ this.animator.register(key, {
1624
+ dim: 1,
1625
+ read: (out) => {
1626
+ out[0] = uFade.value;
1627
+ },
1628
+ write: (v) => {
1629
+ uFade.value = v[0];
1630
+ },
1631
+ });
1632
+ this.animator.to(key, 1, {
1633
+ duration,
1634
+ easing: 'easeOutQuad',
1635
+ onComplete: () => {
1636
+ uFade.value = 1;
1637
+ (mesh.userData as MeshUserData).tileFadeKey = undefined;
1638
+ this.animator.unregister(key);
1639
+ // 淡入期间强制开了 transparent + depthWrite,结束时不能自己拍板关掉——
1640
+ // 图层可能本来就半透明、单体也可能还有淡化。交给淡化流程重新裁决一次。
1641
+ this._featureStyleManager.applyOverridesToMesh(mesh);
1642
+ if (!mat.transparent) mat.depthWrite = true;
1643
+ this._requestRepaint();
1644
+ },
1645
+ });
1646
+ }
1647
+
1050
1648
  /**
1051
1649
  * 把「当前选中」重放到一块新登记的 mesh 上。
1052
1650
  *
@@ -1111,6 +1709,15 @@ export class S3MViewer {
1111
1709
  private _unregisterTile(node: TileNode, entry: S3MDatasetEntry): void {
1112
1710
  node.group.traverse((child) => {
1113
1711
  const obj = child as any;
1712
+ if ((child as THREE.LineSegments).isLineSegments && obj.userData?.isEdgeLines) {
1713
+ this._nativeEdgeLines.delete(child as THREE.LineSegments);
1714
+ }
1715
+ if (isPointCloudObject(child)) this._pointClouds.delete(child);
1716
+ // 淡入还没结束就被淘汰:把通道收掉,否则会对着已释放的材质继续写
1717
+ if (obj.userData?.tileFadeKey) {
1718
+ this.animator.unregister(obj.userData.tileFadeKey);
1719
+ obj.userData.tileFadeKey = undefined;
1720
+ }
1114
1721
  if (obj.userData?.pickId != null) {
1115
1722
  this._pickables.delete(obj.userData.pickId);
1116
1723
  this._releasePickId(obj.userData.pickId);
@@ -1181,23 +1788,183 @@ export class S3MViewer {
1181
1788
  /* ---------------------------------------------------------------- 样式 */
1182
1789
 
1183
1790
  /** 动态设置图层整体透明度 (0.0 ~ 1.0) */
1184
- setOpacity(opacity: number): void {
1185
- this.setStyle({ opacity });
1791
+ setOpacity(opacity: number, transition?: TransitionOptions): void {
1792
+ this.setStyle({ opacity }, transition);
1186
1793
  }
1187
1794
 
1188
- /** 合并并应用样式。只改 uniform 的项不会触发几何/着色器重建。 */
1189
- setStyle(patch: Partial<S3MStyle> = {}): void {
1190
- this.style = { ...this.style, ...patch };
1795
+ /**
1796
+ * 合并并应用样式。
1797
+ *
1798
+ * 只改 uniform 的项不会触发几何 / 着色器重建。带 `transition` 时,能插值的项
1799
+ * 交给动画调度器逐帧推进;不能插值的项(`mode` / `shading` / `edges`)走各自的
1800
+ * 编排逻辑(`edges` 由 setEdges 做淡入淡出,`mode` 建议配 fade-through)。
1801
+ */
1802
+ setStyle(patch: Partial<S3MStyle> = {}, transition?: TransitionOptions): void {
1803
+ Object.assign(this._styleTarget, patch);
1804
+
1805
+ const duration = transition?.duration ?? 0;
1806
+ const wantAnim = duration > 0 || !!transition?.spring;
1807
+
1808
+ // 棱线开关是结构变更,不是插值:交给 setEdges 编排
1809
+ let rest = patch;
1810
+ if ('edges' in patch) {
1811
+ const { edges, ...others } = patch;
1812
+ rest = others;
1813
+ this.setEdges(!!edges, wantAnim ? transition : undefined);
1814
+ if (Object.keys(rest).length === 0) {
1815
+ this._requestRepaint();
1816
+ return;
1817
+ }
1818
+ }
1819
+
1820
+ const animated: string[] = [];
1821
+ const immediate: Record<string, unknown> = {};
1822
+ for (const key of Object.keys(rest) as (keyof S3MStyle)[]) {
1823
+ if (wantAnim && this.animator.has(`style.${String(key)}`)) animated.push(String(key));
1824
+ else immediate[key] = rest[key];
1825
+ }
1826
+
1827
+ if (Object.keys(immediate).length > 0) {
1828
+ Object.assign(this.style, immediate);
1829
+ this._applyStyleEffects(immediate as Partial<S3MStyle>);
1830
+ }
1831
+
1832
+ for (const key of animated) {
1833
+ this.animator.to(`style.${key}`, this._animTargetOf(key, rest), {
1834
+ ...transition,
1835
+ duration,
1836
+ });
1837
+ }
1838
+
1839
+ this._requestRepaint();
1840
+ }
1841
+
1842
+ getStyle(): S3MStyle & { modeLabel: string } {
1843
+ return { ...this._styleTarget, modeLabel: STYLE_LABELS[this._styleTarget.mode] };
1844
+ }
1845
+
1846
+ /** 把某个样式键的取值转成动画通道能吃的形状(标量 or 线性 RGB 三元组) */
1847
+ private _animTargetOf(key: string, patch: Partial<S3MStyle>): number | number[] {
1848
+ const value = (patch as Record<string, unknown>)[key];
1849
+ const dim = this._animDimOf(key);
1850
+ if (dim === 1) return Number(value);
1851
+ this._colorScratch.set(String(value));
1852
+ return [this._colorScratch.r, this._colorScratch.g, this._colorScratch.b];
1853
+ }
1854
+
1855
+ /** 该样式键的动画维度:3 = 颜色,1 = 标量 */
1856
+ private _animDimOf(key: string): 1 | 3 {
1857
+ return STYLE_COLOR_KEYS.includes(key as (typeof STYLE_COLOR_KEYS)[number]) ? 3 : 1;
1858
+ }
1859
+
1860
+ /**
1861
+ * 注册样式动画通道。
1862
+ *
1863
+ * 每个通道的 write 都是 O(1):只改 `this.style` 的一个字段 + 一次 uniform 赋值。
1864
+ * 这里不注册 `mode` / `shading` / `edges`——它们不可插值,见类文档。
1865
+ */
1866
+ private _registerAnimChannels(): void {
1867
+ for (const key of STYLE_SCALAR_KEYS) {
1868
+ this.animator.register(`style.${key}`, {
1869
+ dim: 1,
1870
+ read: (out) => {
1871
+ out[0] = Number((this.style as unknown as Record<string, number>)[key] ?? 0);
1872
+ },
1873
+ write: (v) => {
1874
+ (this.style as unknown as Record<string, number>)[key] = v[0];
1875
+ this._onStyleChannelWrote(key);
1876
+ },
1877
+ });
1878
+ }
1879
+
1880
+ for (const key of STYLE_COLOR_KEYS) {
1881
+ this.animator.register(`style.${key}`, {
1882
+ dim: 3,
1883
+ read: (out) => {
1884
+ const c = this._uniformColorOf(key);
1885
+ out[0] = c.r;
1886
+ out[1] = c.g;
1887
+ out[2] = c.b;
1888
+ },
1889
+ write: (v) => {
1890
+ const c = this._uniformColorOf(key);
1891
+ // 直接写线性分量:shader 消费的就是线性值,端点严格吻合
1892
+ c.setRGB(v[0], v[1], v[2]);
1893
+ (this.style as unknown as Record<string, string>)[key] = `#${c.getHexString()}`;
1894
+ this._onStyleChannelWrote(key);
1895
+ },
1896
+ });
1897
+ }
1898
+
1899
+ // 棱线淡入淡出乘子:不属于 S3MStyle,单独一条通道
1900
+ this.animator.register('style.edgeFade', {
1901
+ dim: 1,
1902
+ read: (out) => {
1903
+ out[0] = this.uniforms.uEdgeFade?.value ?? 1;
1904
+ },
1905
+ write: (v) => {
1906
+ if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v[0];
1907
+ this._syncEdgeStyle();
1908
+ },
1909
+ });
1910
+
1911
+ // 整体压暗乘子:setMode 的 fade-through 用它,也不是 S3MStyle 字段
1912
+ this.animator.register('style.fade', {
1913
+ dim: 1,
1914
+ read: (out) => {
1915
+ out[0] = this.uniforms.uFade?.value ?? 1;
1916
+ },
1917
+ write: (v) => {
1918
+ if (this.uniforms.uFade) this.uniforms.uFade.value = v[0];
1919
+ this._requestRepaint();
1920
+ },
1921
+ });
1922
+ }
1923
+
1924
+ /** 通道写完一个样式键后的最小副作用(全部 O(1)) */
1925
+ private _onStyleChannelWrote(key: string): void {
1926
+ if (key === 'opacity') {
1927
+ this._applyOpacity();
1928
+ return;
1929
+ }
1930
+ this._applyStyleUniforms();
1931
+ if (key === 'edgeColor' || key === 'edgeWidth' || key === 'edgeOpacity') {
1932
+ this._refreshEdgeLines();
1933
+ }
1934
+ }
1935
+
1936
+ /** 取某个颜色键对应的共享 uniform Color 对象 */
1937
+ private _uniformColorOf(key: string): THREE.Color {
1938
+ const u = this.uniforms;
1939
+ switch (key) {
1940
+ case 'colorA':
1941
+ return u.uColorA.value;
1942
+ case 'colorB':
1943
+ return u.uColorB.value;
1944
+ case 'colorC':
1945
+ return u.uColorC.value;
1946
+ case 'selectColor':
1947
+ return u.uSelColor.value;
1948
+ case 'edgeColor':
1949
+ return this.edgeMaterial.uniforms.uColor.value;
1950
+ default:
1951
+ return u.uColorA.value;
1952
+ }
1953
+ }
1954
+
1955
+ /** 应用一批样式键带来的副作用(uniform / 材质 / 棱线 / 选中态) */
1956
+ private _applyStyleEffects(patch: Partial<S3MStyle>): void {
1191
1957
  this._applyStyleUniforms();
1192
1958
  if ('opacity' in patch || 'depthWrite' in patch) this._applyOpacity();
1193
1959
  // 点云不走 StyleMaterial 的 uMode,模式切换要单独同步它的材质
1194
1960
  if ('mode' in patch) this._applyPointCloudStyles();
1195
1961
  if ('shading' in patch) this._applyShading();
1196
- // 临界角变了要重建 CPU 棱线(EdgesGeometry 参数),开/关走 setEdges 整体刷新
1962
+ // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
1963
+ // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
1964
+ // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
1197
1965
  if ('edgeThreshold' in patch && this._edgesEnabled) {
1966
+ this._disposeEdges();
1198
1967
  this.setEdges(true);
1199
- } else if ('edges' in patch) {
1200
- this.setEdges(!!this.style.edges);
1201
1968
  } else if ('edgeColor' in patch || 'edgeWidth' in patch || 'opacity' in patch || 'edgeOpacity' in patch) {
1202
1969
  this._refreshEdgeLines();
1203
1970
  }
@@ -1206,11 +1973,6 @@ export class S3MViewer {
1206
1973
  if ('outline' in patch || 'outlineStyle' in patch) this.select(this._selection, { silent: true });
1207
1974
  else this._syncSelectionStyle();
1208
1975
  }
1209
- this._requestRepaint();
1210
- }
1211
-
1212
- getStyle(): S3MStyle & { modeLabel: string } {
1213
- return { ...this.style, modeLabel: STYLE_LABELS[this.style.mode] };
1214
1976
  }
1215
1977
 
1216
1978
  private _applyStyleUniforms(): void {
@@ -1245,6 +2007,9 @@ export class S3MViewer {
1245
2007
  if (u.uTexMix && s.texMix != null) {
1246
2008
  u.uTexMix.value = s.texMix;
1247
2009
  }
2010
+ if (u.uAmbientGain && s.ambientGain != null) {
2011
+ u.uAmbientGain.value = s.ambientGain;
2012
+ }
1248
2013
  if (this.outlineMaterial) {
1249
2014
  if (this.outlineMaterial.uniforms.uElevation && s.selectElevation != null) {
1250
2015
  this.outlineMaterial.uniforms.uElevation.value = s.selectElevation;
@@ -1258,23 +2023,40 @@ export class S3MViewer {
1258
2023
  }
1259
2024
  }
1260
2025
 
1261
- /** 透明度:只改材质状态,不触发着色器重编译,所以可以跟着滑块实时拖 */
2026
+ /**
2027
+ * 透明度:写共享 uniform,**不再逐 mesh 改材质**。
2028
+ *
2029
+ * 原来的写法每次都要遍历全部 pickables 改 `material.opacity` + `material.transparent`,
2030
+ * 是 O(meshes);而且 `opaque` 参与 three 的 program 缓存键,跨 0.999 会换一次 program。
2031
+ * 拖滑块时一秒钟几十次调用,几十万面的场景就是明显掉帧。
2032
+ *
2033
+ * 现在逐帧只写一个 float(`uOpacity`),混合开关只在**真正跨过阈值**时切一次。
2034
+ */
1262
2035
  private _applyOpacity(): void {
1263
2036
  const o = Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
2037
+ if (this.uniforms.uOpacity) this.uniforms.uOpacity.value = o;
2038
+
1264
2039
  const transparent = o < 0.999;
1265
- // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
1266
- const keepDepth = this.style.depthWrite !== false;
1267
- for (const p of this._pickables.values()) {
1268
- if (isPointCloudObject(p.mesh)) {
1269
- this._applyPointCloudStyle(p.mesh);
1270
- continue;
2040
+ if (transparent !== this._opacityTransparent) {
2041
+ this._opacityTransparent = transparent;
2042
+ // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
2043
+ const keepDepth = this.style.depthWrite !== false;
2044
+ for (const p of this._pickables.values()) {
2045
+ if (isPointCloudObject(p.mesh)) continue;
2046
+ const m = styledMaterialOf(p.mesh);
2047
+ if (!m) continue;
2048
+ // 有单体淡化 / 正在淡入的材质,transparent 由各自的流程管,这里不要抢
2049
+ if (m.userData.uBatchTexActive?.value) continue;
2050
+ if ((m.userData.uTileFade?.value ?? 1) < 1) continue;
2051
+ m.transparent = transparent;
2052
+ m.depthWrite = transparent ? keepDepth : true;
1271
2053
  }
1272
- const m = styledMaterialOf(p.mesh);
1273
- if (!m) continue;
1274
- m.opacity = o;
1275
- m.transparent = transparent;
1276
- m.depthWrite = transparent ? keepDepth : true;
1277
2054
  }
2055
+
2056
+ // 点云不走 StyleMaterial,自己有一套 uOpacity,逐帧刷的只有这几个对象
2057
+ for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
2058
+
2059
+ this._syncEdgeStyle();
1278
2060
  this._requestRepaint();
1279
2061
  }
1280
2062
 
@@ -1285,9 +2067,7 @@ export class S3MViewer {
1285
2067
 
1286
2068
  /** 全部点云刷新:着色模式切换时用(uniform 是共享引用,改色不用走这里) */
1287
2069
  private _applyPointCloudStyles(): void {
1288
- for (const p of this._pickables.values()) {
1289
- if (isPointCloudObject(p.mesh)) this._applyPointCloudStyle(p.mesh);
1290
- }
2070
+ for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
1291
2071
  this._requestRepaint();
1292
2072
  }
1293
2073
 
@@ -1400,10 +2180,16 @@ export class S3MViewer {
1400
2180
  *
1401
2181
  * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
1402
2182
  * 只有摊成面向屏幕的四边形,`edgeWidth` 才能真正调粗。
2183
+ *
2184
+ * 带 `transition` 时做「淡入 / 淡出」,**不对几何做插值**(几何没法插值):
2185
+ * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2186
+ * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
1403
2187
  */
1404
- setEdges(enabled: boolean): void {
2188
+ setEdges(enabled: boolean, transition?: TransitionOptions): void {
2189
+ const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
1405
2190
  this._edgesEnabled = enabled;
1406
2191
  this.style.edges = enabled;
2192
+ this._styleTarget.edges = enabled;
1407
2193
 
1408
2194
  // 数据自带的棱线:直接切可见性
1409
2195
  for (const entry of this.datasets.values()) {
@@ -1418,7 +2204,19 @@ export class S3MViewer {
1418
2204
  this._syncEdgeStyle();
1419
2205
 
1420
2206
  if (!enabled) {
1421
- for (const p of this._pickables.values()) this._removeEdges(p.mesh);
2207
+ if (wantAnim) {
2208
+ this.animator.to('style.edgeFade', 0, {
2209
+ ...transition,
2210
+ onComplete: () => {
2211
+ this._disposeEdges();
2212
+ this._requestRepaint();
2213
+ transition?.onComplete?.();
2214
+ },
2215
+ });
2216
+ } else {
2217
+ this._setEdgeFade(1);
2218
+ this._disposeEdges();
2219
+ }
1422
2220
  this._requestRepaint();
1423
2221
  return;
1424
2222
  }
@@ -1438,6 +2236,25 @@ export class S3MViewer {
1438
2236
  else this._requestRepaint();
1439
2237
  };
1440
2238
  step();
2239
+
2240
+ if (wantAnim) {
2241
+ // 从 0 起淡入:新建出来的棱线自动继承当前进度,不会半路冒出一条满亮度的线
2242
+ this._setEdgeFade(0);
2243
+ this.animator.to('style.edgeFade', 1, transition);
2244
+ } else {
2245
+ this._setEdgeFade(1);
2246
+ }
2247
+ }
2248
+
2249
+ /** 直接写棱线淡入乘子(不走动画通道) */
2250
+ private _setEdgeFade(v: number): void {
2251
+ if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v;
2252
+ this._syncEdgeStyle();
2253
+ }
2254
+
2255
+ /** 释放所有现算棱线几何 */
2256
+ private _disposeEdges(): void {
2257
+ for (const p of this._pickables.values()) this._removeEdges(p.mesh);
1441
2258
  }
1442
2259
 
1443
2260
  /**
@@ -1486,19 +2303,28 @@ export class S3MViewer {
1486
2303
  this._requestRepaint();
1487
2304
  }
1488
2305
 
1489
- /** 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity */
2306
+ /**
2307
+ * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity。
2308
+ *
2309
+ * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存,
2310
+ * 所以棱线淡入淡出只要改一个 uniform,新建瓦片自动继承当前进度。
2311
+ */
1490
2312
  private _syncEdgeStyle(): void {
1491
2313
  const s = this.style;
2314
+ const fade = this.uniforms.uEdgeFade?.value ?? 1;
2315
+ const opacity =
2316
+ Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) *
2317
+ Math.min(1, Math.max(0.05, s.opacity ?? 1)) *
2318
+ fade;
1492
2319
  const u = this.edgeMaterial.uniforms;
1493
2320
  u.uColor.value.set(s.edgeColor);
1494
2321
  u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
1495
- u.uOpacity.value =
1496
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * Math.min(1, Math.max(0.05, s.opacity ?? 1));
2322
+ u.uOpacity.value = opacity;
1497
2323
  // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
1498
2324
  if (this._creaseLines) {
1499
2325
  const m = this._creaseLines.material as THREE.LineBasicMaterial;
1500
2326
  m.color.set(s.edgeColor);
1501
- m.opacity = Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * Math.min(1, Math.max(0.05, s.opacity ?? 1));
2327
+ m.opacity = opacity;
1502
2328
  }
1503
2329
  }
1504
2330
 
@@ -1508,7 +2334,10 @@ export class S3MViewer {
1508
2334
  if (!mat || !mat.color) return;
1509
2335
  mat.color.set(this.style.edgeColor);
1510
2336
  const baseOpacity = this.style.edgeOpacity ?? 0.6;
1511
- mat.opacity = baseOpacity * Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
2337
+ mat.opacity =
2338
+ baseOpacity *
2339
+ Math.min(1, Math.max(0.05, this.style.opacity ?? 1)) *
2340
+ (this.uniforms.uEdgeFade?.value ?? 1);
1512
2341
  mat.transparent = true;
1513
2342
  mat.polygonOffset = true;
1514
2343
  mat.polygonOffsetFactor = -1;
@@ -1516,12 +2345,17 @@ export class S3MViewer {
1516
2345
  mat.needsUpdate = true;
1517
2346
  }
1518
2347
 
2348
+ /**
2349
+ * 刷新所有棱线外观。
2350
+ *
2351
+ * 数据自带的棱线是逐对象的 LineBasicMaterial,改颜色只能一个个写;但原来每次都要
2352
+ * 遍历整个数据集树(几千个节点),颜色动画逐帧跑就是灾难。改成只刷注册时收集好的
2353
+ * 那批 LineSegments,复杂度降到 O(棱线条数)。
2354
+ */
1519
2355
  private _refreshEdgeLines(): void {
1520
2356
  this._syncEdgeStyle();
1521
- for (const entry of this.datasets.values()) {
1522
- entry.root.traverse((o) => {
1523
- if ((o.userData as MeshUserData).isEdgeLines) this._styleEdgeLine(o as THREE.LineSegments);
1524
- });
2357
+ for (const lines of this._nativeEdgeLines) {
2358
+ if (lines.parent) this._styleEdgeLine(lines);
1525
2359
  }
1526
2360
  }
1527
2361
 
@@ -1572,7 +2406,26 @@ export class S3MViewer {
1572
2406
  source.dispose();
1573
2407
  if (!segments || segments.length < 6) return;
1574
2408
 
1575
- const lines = new THREE.Mesh(buildThickLineGeometry(segments), this.edgeMaterial);
2409
+ // 3. 回填每段棱线所属的单体 batchId。
2410
+ // EdgesGeometry 只输出坐标不输出顶点号,只能拿端点坐标去查「位置 → batchId」表。
2411
+ // 没有这张表,单体一动棱线就留在原地——这正是「棱线跟随」的关键一环。
2412
+ const TOL = 0.005;
2413
+ const batchMap = buildPositionBatchMap(mesh.geometry, TOL);
2414
+ let batchIds: Float32Array | undefined;
2415
+ if (batchMap) {
2416
+ const segCount = segments.length / 6;
2417
+ batchIds = new Float32Array(segCount);
2418
+ for (let i = 0; i < segCount; i++) {
2419
+ const s = i * 6;
2420
+ batchIds[i] =
2421
+ batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
2422
+ }
2423
+ }
2424
+
2425
+ const lines = new THREE.Mesh(
2426
+ buildThickLineGeometry(segments, batchIds),
2427
+ this._createEdgeMaterial(mesh)
2428
+ );
1576
2429
  lines.renderOrder = 2;
1577
2430
  // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
1578
2431
  lines.frustumCulled = false;
@@ -1582,12 +2435,56 @@ export class S3MViewer {
1582
2435
  mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
1583
2436
  }
1584
2437
 
2438
+ /**
2439
+ * 给一块网格造专属的棱线材质。
2440
+ *
2441
+ * 棱线材质必须逐网格一份,因为它要绑定**这个网格**的单体状态纹理;但外观
2442
+ * (颜色 / 粗细 / 透明度)必须全局统一——所以外观 uniform 直接复用
2443
+ * `_edgeStyleUniforms` 里的对象,`_syncEdgeStyle()` 改一次全部生效,
2444
+ * program 也因为着色器相同而只编译一次。
2445
+ */
2446
+ private _createEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2447
+ const styled = styledMaterialOf(mesh);
2448
+ const batch = styled?.userData?.uBatchTex
2449
+ ? {
2450
+ uBatchTex: styled.userData.uBatchTex,
2451
+ uBatchTex2: styled.userData.uBatchTex2,
2452
+ uBatchMix: this.uniforms.uBatchMix,
2453
+ uBatchTexWidth: styled.userData.uBatchTexWidth,
2454
+ uBatchTexActive: styled.userData.uBatchTexActive,
2455
+ }
2456
+ : undefined;
2457
+ return createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms, batch);
2458
+ }
2459
+
2460
+ /** 选中棱线的逐网格材质,道理同上 */
2461
+ private _createSelectionEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2462
+ const styled = styledMaterialOf(mesh);
2463
+ const batch = styled?.userData?.uBatchTex
2464
+ ? {
2465
+ uBatchTex: styled.userData.uBatchTex,
2466
+ uBatchTex2: styled.userData.uBatchTex2,
2467
+ uBatchMix: this.uniforms.uBatchMix,
2468
+ uBatchTexWidth: styled.userData.uBatchTexWidth,
2469
+ uBatchTexActive: styled.userData.uBatchTexActive,
2470
+ }
2471
+ : undefined;
2472
+ return createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms, batch);
2473
+ }
2474
+
1585
2475
  private _removeEdges(mesh: THREE.Mesh): void {
1586
2476
  const ud = mesh.userData as MeshUserData;
1587
2477
  const lines = ud.edges;
1588
2478
  if (!lines) return;
1589
2479
  mesh.remove(lines);
1590
2480
  (lines as THREE.Mesh).geometry?.dispose();
2481
+ // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2482
+ const mat = (lines as THREE.Mesh).material;
2483
+ if (Array.isArray(mat)) {
2484
+ mat.forEach((m) => (m as any)?.dispose?.());
2485
+ } else {
2486
+ (mat as any)?.dispose?.();
2487
+ }
1591
2488
  ud.edges = null;
1592
2489
  }
1593
2490
 
@@ -1734,6 +2631,20 @@ export class S3MViewer {
1734
2631
  const trans = off.translation;
1735
2632
  const elev = off.elevation;
1736
2633
  const finalPos = hitPoint.clone();
2634
+
2635
+ if (off.rotation) {
2636
+ const rotMat = resolveRotationMatrix(off.rotation);
2637
+ if (rotMat) {
2638
+ const m3 = new THREE.Matrix3().set(
2639
+ rotMat[0], rotMat[1], rotMat[2],
2640
+ rotMat[3], rotMat[4], rotMat[5],
2641
+ rotMat[6], rotMat[7], rotMat[8]
2642
+ );
2643
+ const piv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
2644
+ finalPos.sub(piv).applyMatrix3(m3).add(piv);
2645
+ }
2646
+ }
2647
+
1737
2648
  if (trans) finalPos.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
1738
2649
  else if (elev) finalPos.z += elev;
1739
2650
 
@@ -1964,7 +2875,20 @@ export class S3MViewer {
1964
2875
  const trans = off.translation;
1965
2876
  const elev = off.elevation;
1966
2877
 
2878
+ const rotMat = off.rotation ? resolveRotationMatrix(off.rotation) : null;
2879
+ let rotM3: THREE.Matrix3 | null = null;
2880
+ let rotPiv: THREE.Vector3 | null = null;
2881
+ if (rotMat) {
2882
+ rotM3 = new THREE.Matrix3().set(
2883
+ rotMat[0], rotMat[1], rotMat[2],
2884
+ rotMat[3], rotMat[4], rotMat[5],
2885
+ rotMat[6], rotMat[7], rotMat[8]
2886
+ );
2887
+ rotPiv = off.pivot ? new THREE.Vector3(off.pivot[0], off.pivot[1], off.pivot[2]) : new THREE.Vector3(0, 0, 0);
2888
+ }
2889
+
1967
2890
  for (const c of corners) {
2891
+ if (rotM3 && rotPiv) c.sub(rotPiv).applyMatrix3(rotM3).add(rotPiv);
1968
2892
  if (trans) c.add(new THREE.Vector3(trans[0], trans[1], trans[2]));
1969
2893
  else if (elev) c.z += elev;
1970
2894
 
@@ -2112,23 +3036,36 @@ export class S3MViewer {
2112
3036
  const ud = mesh.userData as MeshUserData;
2113
3037
  const geoRef = ud.geoRef;
2114
3038
  let segments: PickSegment[] = [];
3039
+ // 这块网格上的棱线全部属于选中的那个单体,整批填同一个 batchId,
3040
+ // 顶点着色器才能从状态纹理里取到平移 / 拔高——选中跟随移动靠它。
3041
+ let batchId = -1;
2115
3042
  if (mesh === result.mesh && result.instanceIndex != null) {
2116
3043
  // 实例化几何没有逐单体的顶点区间,整个 base mesh 就是这一栋的外形
2117
3044
  segments = [{ vertexOffset: 0, vertexCount: mesh.geometry.getAttribute('position')?.count ?? 0 }];
3045
+ const pickInfo = geoRef?.pickInfo;
3046
+ const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid ?? ''] ??
3047
+ (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
3048
+ batchId = entry ? pickEntryId(entry as any) : -1;
2118
3049
  } else if (result.smid != null) {
2119
3050
  const pickInfo = geoRef?.pickInfo;
2120
3051
  const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
2121
3052
  (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
2122
3053
  const bId = entry ? pickEntryId(entry as any) : -1;
3054
+ batchId = bId;
2123
3055
  segments = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(geoRef, bId).segments : []);
2124
3056
  } else if (mesh === result.mesh) {
2125
3057
  const localGeo = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(result.batchId) ?? geoRef;
2126
3058
  segments = resolveFeature(localGeo, result.batchId).segments;
3059
+ batchId = result.batchId;
2127
3060
  }
2128
3061
 
2129
3062
  const pairs = buildFeatureEdgeSegments(mesh.geometry, segments);
2130
3063
  if (pairs && pairs.length > 0) {
2131
- const lines = new THREE.Mesh(buildThickLineGeometry(pairs), this.selectionEdgeMaterial);
3064
+ const batchIds = batchId >= 0 ? new Float32Array(pairs.length / 6).fill(batchId) : undefined;
3065
+ const lines = new THREE.Mesh(
3066
+ buildThickLineGeometry(pairs, batchIds),
3067
+ this._createSelectionEdgeMaterial(mesh)
3068
+ );
2132
3069
  lines.renderOrder = 4;
2133
3070
  // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
2134
3071
  lines.frustumCulled = false;
@@ -2197,7 +3134,9 @@ export class S3MViewer {
2197
3134
  u.uColor.value.set(s.outlineColor ?? s.selectColor);
2198
3135
  u.uWidth.value = Math.max(0.1, s.outlineWidthPx ?? 3);
2199
3136
  u.uOpacity.value = Math.min(1, Math.max(0.05, s.opacity ?? 1));
2200
- u.uElevation.value = this._selection ? this._selectionElevation(this._selection) : 0;
3137
+ // 只加全局 selectElevation:单体自己的拔高已经写在状态纹理第 2 行 z 里,
3138
+ // 顶点着色器采样时带出来了,这里再加一次就会双重拔高
3139
+ if (u.uElevation) u.uElevation.value = s.selectElevation ?? 0;
2201
3140
 
2202
3141
  // 外扩描边是另一个材质实例,单体状态纹理的开关是建的时候抄过去的。
2203
3142
  // 先选中、再抬高(或反过来)时模板会停留在旧值,这里补一次同步。
@@ -2291,6 +3230,9 @@ export class S3MViewer {
2291
3230
  const gl = this.renderer.getContext() as WebGLRenderingContext | null;
2292
3231
  if (gl && typeof gl.isContextLost === 'function' && gl.isContextLost()) return false;
2293
3232
 
3233
+ // 先推进动画,再刷脏标记,最后渲染 —— 保证本帧画出来的是最新值
3234
+ const animating = this.animator.tick();
3235
+
2294
3236
  // 脏标记正常由微任务刷掉;这里再兜一次底,保证「本帧画出来的」一定是最新样式
2295
3237
  this.flushOverrides();
2296
3238
 
@@ -2359,8 +3301,14 @@ export class S3MViewer {
2359
3301
  this._fps.last = now;
2360
3302
  }
2361
3303
 
2362
- // 动态着色 / 瓦片加载中 / 瓦片层级或可见性改变,都需要宿主继续驱动下一帧
2363
- return this.style.mode === STYLE.DYNAMIC || this.stats.loading > 0 || tilesetChanged;
3304
+ // 动态着色 / 动画进行中 / 瓦片加载中 / 瓦片层级或可见性改变,都需要宿主继续驱动下一帧。
3305
+ // 动画结束那一帧必须精确回到 false,否则 MapLibre 会永远重绘(耗电、发热)。
3306
+ return (
3307
+ animating ||
3308
+ this.style.mode === STYLE.DYNAMIC ||
3309
+ this.stats.loading > 0 ||
3310
+ tilesetChanged
3311
+ );
2364
3312
  }
2365
3313
 
2366
3314
  /* ---------------------------------------------------------------- 只读属性 */