s3m-three 0.3.6 → 0.3.8

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 (92) 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/assets/{s3mWorker-hZY5KqWF.js → s3mWorker-D7AV6O30.js} +1 -1
  18. package/dist/browser/s3m-three.iife.ext-three.js +60 -22
  19. package/dist/browser/s3m-three.iife.js +60 -22
  20. package/dist/core/TextureDecode.d.ts +22 -1
  21. package/dist/core/TextureDecode.d.ts.map +1 -1
  22. package/dist/core/TextureDecode.js +47 -3
  23. package/dist/core/TextureDecode.js.map +1 -1
  24. package/dist/core/TileGeometry.d.ts +92 -0
  25. package/dist/core/TileGeometry.d.ts.map +1 -0
  26. package/dist/core/TileGeometry.js +607 -0
  27. package/dist/core/TileGeometry.js.map +1 -0
  28. package/dist/core/types.d.ts +16 -0
  29. package/dist/core/types.d.ts.map +1 -1
  30. package/dist/index.d.ts +3 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/render/EdgeLines.d.ts +62 -14
  35. package/dist/render/EdgeLines.d.ts.map +1 -1
  36. package/dist/render/EdgeLines.js +97 -8
  37. package/dist/render/EdgeLines.js.map +1 -1
  38. package/dist/render/FeatureStyleManager.d.ts +17 -1
  39. package/dist/render/FeatureStyleManager.d.ts.map +1 -1
  40. package/dist/render/FeatureStyleManager.js +14 -4
  41. package/dist/render/FeatureStyleManager.js.map +1 -1
  42. package/dist/render/GeometryBuilder.d.ts +8 -10
  43. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  44. package/dist/render/GeometryBuilder.js +77 -555
  45. package/dist/render/GeometryBuilder.js.map +1 -1
  46. package/dist/render/MergeVertices.d.ts +7 -0
  47. package/dist/render/MergeVertices.d.ts.map +1 -1
  48. package/dist/render/MergeVertices.js +10 -1
  49. package/dist/render/MergeVertices.js.map +1 -1
  50. package/dist/render/S3MViewer.d.ts +218 -9
  51. package/dist/render/S3MViewer.d.ts.map +1 -1
  52. package/dist/render/S3MViewer.js +772 -68
  53. package/dist/render/S3MViewer.js.map +1 -1
  54. package/dist/render/StyleMaterial.d.ts +19 -0
  55. package/dist/render/StyleMaterial.d.ts.map +1 -1
  56. package/dist/render/StyleMaterial.js +37 -0
  57. package/dist/render/StyleMaterial.js.map +1 -1
  58. package/dist/render/TextureFactory.d.ts +2 -1
  59. package/dist/render/TextureFactory.d.ts.map +1 -1
  60. package/dist/render/TextureFactory.js +30 -6
  61. package/dist/render/TextureFactory.js.map +1 -1
  62. package/dist/tileset/S3MTileset.d.ts +6 -2
  63. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  64. package/dist/tileset/S3MTileset.js +17 -7
  65. package/dist/tileset/S3MTileset.js.map +1 -1
  66. package/dist/worker/S3MWorkerClient.d.ts +12 -4
  67. package/dist/worker/S3MWorkerClient.d.ts.map +1 -1
  68. package/dist/worker/S3MWorkerClient.js +12 -8
  69. package/dist/worker/S3MWorkerClient.js.map +1 -1
  70. package/dist/worker/s3mWorker.js +11 -3
  71. package/dist/worker/s3mWorker.js.map +1 -1
  72. package/docs/ANIMATION.md +534 -0
  73. package/docs/API.md +4 -0
  74. package/package.json +1 -1
  75. package/src/anim/Animator.ts +460 -0
  76. package/src/anim/Path.ts +107 -0
  77. package/src/anim/easing.ts +195 -0
  78. package/src/anim/index.ts +10 -0
  79. package/src/core/TextureDecode.ts +69 -3
  80. package/src/core/TileGeometry.ts +745 -0
  81. package/src/core/types.ts +8 -0
  82. package/src/index.ts +10 -0
  83. package/src/render/EdgeLines.ts +131 -15
  84. package/src/render/FeatureStyleManager.ts +12 -4
  85. package/src/render/GeometryBuilder.ts +91 -617
  86. package/src/render/MergeVertices.ts +11 -1
  87. package/src/render/S3MViewer.ts +866 -67
  88. package/src/render/StyleMaterial.ts +54 -1
  89. package/src/render/TextureFactory.ts +42 -8
  90. package/src/tileset/S3MTileset.ts +25 -9
  91. package/src/worker/S3MWorkerClient.ts +18 -9
  92. package/src/worker/s3mWorker.ts +24 -6
@@ -23,7 +23,7 @@
23
23
  import * as THREE from 'three';
24
24
  import { S3MTileset, usableGeoBounds } from '../tileset/S3MTileset.js';
25
25
  import type { BuildTileFn, TileNode } from '../tileset/S3MTileset.js';
26
- import { setMaxAnisotropy, setTextureOffThreadDecode } from './TextureFactory.js';
26
+ import { setMaxAnisotropy, setCompressedTexturesSupported, setTextureOffThreadDecode } from './TextureFactory.js';
27
27
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
28
28
  import {
29
29
  STYLE,
@@ -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,
@@ -159,8 +165,12 @@ export interface LightOptions {
159
165
  hemisphereGroundColor?: number | string;
160
166
  /** 主平行光强度,默认 1.0 */
161
167
  keyLightIntensity?: number;
168
+ /** 主平行光强度别名(兼容写法) */
169
+ mainIntensity?: number;
162
170
  /** 主平行光颜色,默认 0xffffff */
163
171
  keyLightColor?: number | string;
172
+ /** 主平行光颜色别名(兼容写法) */
173
+ mainColor?: number | string;
164
174
  /** 补光强度,默认 0.35 */
165
175
  fillLightIntensity?: number;
166
176
  /** 补光颜色,默认 0xffffff */
@@ -180,6 +190,13 @@ export interface LightOptions {
180
190
  export interface S3MViewerOptions {
181
191
  /** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
182
192
  light?: LightOptions;
193
+ /**
194
+ * 色调映射模式(默认 THREE.NoToneMapping,避免 ACESFilmic 压暗中暗部及导致底图与建筑色彩失真;
195
+ * 可传入 THREE.ACESFilmicToneMapping、THREE.LinearToneMapping、THREE.ReinhardToneMapping 等)
196
+ */
197
+ toneMapping?: THREE.ToneMapping;
198
+ /** 色调映射曝光度,默认 1.0 */
199
+ toneMappingExposure?: number;
183
200
  /** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
184
201
  totalMaxTiles?: number;
185
202
  /** 单数据集简写 */
@@ -243,6 +260,8 @@ interface MeshUserData {
243
260
  isThickEdge?: boolean;
244
261
  /** 选中单体的棱线对象 */
245
262
  isSelectionEdge?: boolean;
263
+ /** 该网格正在淡入时占用的动画通道 key,瓦片淘汰时要回收 */
264
+ tileFadeKey?: string;
246
265
  pickMaterial?: THREE.Material;
247
266
  [key: string]: unknown;
248
267
  }
@@ -258,6 +277,54 @@ function isPointCloudObject(o: THREE.Object3D): o is THREE.Points {
258
277
  return (o as unknown as THREE.Points).isPoints === true;
259
278
  }
260
279
 
280
+ /* ------------------------------------------------------------------ 可动画的样式键 */
281
+
282
+ /**
283
+ * 能逐帧插值的标量样式键。
284
+ *
285
+ * 判据:写一次只是「改一个 uniform 或一个字段」,不重建几何、不换 program。
286
+ * `mode` / `shading` / `edges` 不在此列——它们是结构变更,只能编排不能插值。
287
+ */
288
+ const STYLE_SCALAR_KEYS = [
289
+ 'opacity',
290
+ 'floorHeight',
291
+ 'selectOpacity',
292
+ 'selectEmissive',
293
+ 'selectElevation',
294
+ 'dimFactor',
295
+ 'texMix',
296
+ 'roofThreshold',
297
+ 'ambientGain',
298
+ 'edgeWidth',
299
+ 'edgeOpacity',
300
+ 'outlineWidth',
301
+ ] as const;
302
+
303
+ /** 能逐帧插值的颜色键(在线性空间插值,与 shader 消费端一致) */
304
+ const STYLE_COLOR_KEYS = ['colorA', 'colorB', 'colorC', 'selectColor', 'edgeColor'] as const;
305
+
306
+ /** 可动画的单体属性 */
307
+ const FEATURE_PROPS = ['translation', 'elevation', 'opacity', 'color'] as const;
308
+ type FeatureProp = (typeof FEATURE_PROPS)[number];
309
+
310
+ /** 单体沿折线运动的参数 */
311
+ export interface PathFollowOptions {
312
+ /** 走完全程的时长(毫秒)。不传则按 speed 或默认 2000ms 推算 */
313
+ duration?: number;
314
+ /** 线速度(米/秒)。给了 duration 就以 duration 为准 */
315
+ speed?: number;
316
+ /** 是否循环 */
317
+ loop?: boolean;
318
+ /** 是否把折线首尾相连 */
319
+ closed?: boolean;
320
+ /** 缓动曲线:作用在**已走距离**上,默认 linear(恒速) */
321
+ easing?: EasingName | EasingFn;
322
+ /** 路径整体偏移(米),相当于路径原点 */
323
+ offset?: [number, number, number];
324
+ /** 单次走完(非循环)时回调 */
325
+ onComplete?: () => void;
326
+ }
327
+
261
328
  /* ------------------------------------------------------------------ 图层 */
262
329
 
263
330
  export class S3MViewer {
@@ -278,15 +345,22 @@ export class S3MViewer {
278
345
  picker: Picker | null = null;
279
346
 
280
347
  readonly uniforms: StyleUniforms;
348
+ /** 当前**显示**的样式(动画推进中会逐帧变) */
281
349
  style: S3MStyle;
350
+ /** 用户想要的样式(getStyle 返回它,动画目标以它为准) */
351
+ private _styleTarget: S3MStyle;
282
352
  readonly outlineMaterial: OutlineMaterial;
283
353
 
284
354
  /** 等宽线共享的分辨率 uniform(全局棱线 + 选中棱线共用) */
285
355
  readonly lineShared: LineSharedUniforms;
286
- /** 全局棱线材质 */
356
+ /** 全局棱线材质(外观 uniform 的持有者,逐网格棱线材质共享它的对象) */
287
357
  readonly edgeMaterial: ThickLineMaterial;
288
- /** 选中棱线材质 */
358
+ /** 选中棱线材质(同上,选中棱线的逐网格材质共享它的对象) */
289
359
  readonly selectionEdgeMaterial: ThickLineMaterial;
360
+ /** 全局棱线的外观 uniform:逐网格棱线材质直接复用这些对象 */
361
+ private readonly _edgeStyleUniforms: ThickLineStyleUniforms;
362
+ /** 选中棱线的外观 uniform */
363
+ private readonly _selEdgeStyleUniforms: ThickLineStyleUniforms;
290
364
 
291
365
  /** 3D 物理地表面 / 基坑开挖管理器 */
292
366
  readonly groundManager: GroundManager;
@@ -325,13 +399,37 @@ export class S3MViewer {
325
399
  private _selectionEdges: THREE.Mesh | null = null;
326
400
  private _selectionEdgeMeshes: THREE.Mesh[] = [];
327
401
  private _edgesEnabled = false;
402
+ /** 瓦片淡入通道的自增序号(不用 pickId:pickId 会被回收复用) */
403
+ private _tileFadeSeq = 0;
404
+ /** setMode 编排的代次,用来作废被新调用顶掉的旧编排 */
405
+ private _modeGen = 0;
406
+ /** 路径运动的规格表:通道 key → 路径 + 偏移 */
407
+ private readonly _pathSpecs = new Map<string, { path: Path; offset: [number, number, number] }>();
408
+ /** 路径采样临时对象,避免每帧 new */
409
+ private readonly _pathScratch = new Float32Array(3);
328
410
  /** 预制棱线缓存(Crease Cache):从外部 source-creases 载入的全局统一 LineSegments,性能最高、视觉最干净 */
329
411
  private _creaseLines: THREE.LineSegments | null = null;
412
+ /** 数据自带的棱线对象(LineSegments),改棱线颜色时只刷这些,不再全场景 traverse */
413
+ private readonly _nativeEdgeLines = new Set<THREE.LineSegments>();
414
+ /** 点云对象集合:透明度动画时只刷它们,避免每帧遍历全部 pickables */
415
+ private readonly _pointClouds = new Set<THREE.Points>();
416
+ /** 当前材质是否处于 transparent 状态,用来避免逐帧重复切换 program */
417
+ private _opacityTransparent = false;
418
+
419
+ /**
420
+ * 统一动画调度器。
421
+ *
422
+ * 所有「有过渡」的样式 / 单体 / 棱线变更都经由它推进;公开 API 只写目标值,
423
+ * 真正的逐帧写入由注册在这里的通道完成。
424
+ */
425
+ readonly animator = new Animator();
330
426
 
331
427
  /** 业务属性查询(可选,配了 `attributes` 才有) */
332
428
  attributeQuery: AttributeQuery | null = null;
333
429
 
334
430
  private readonly _enuMatrix = new THREE.Matrix4();
431
+ /** 颜色换算用的临时对象,避免每帧 new */
432
+ private readonly _colorScratch = new THREE.Color();
335
433
  private _needsRepaint = true;
336
434
  private _frame = 0;
337
435
  private readonly _fps = { value: 0, last: 0 };
@@ -339,7 +437,7 @@ export class S3MViewer {
339
437
  private _hemiLight!: THREE.HemisphereLight;
340
438
  private _keyLight!: THREE.DirectionalLight;
341
439
  private _fillLight!: THREE.DirectionalLight;
342
- private readonly _lightOptions: Required<LightOptions> = {
440
+ private readonly _lightOptions: Required<Omit<LightOptions, 'mainIntensity' | 'mainColor'>> = {
343
441
  ambientIntensity: 0.4,
344
442
  ambientColor: 0xffffff,
345
443
  hemisphereIntensity: 0.8,
@@ -400,26 +498,40 @@ export class S3MViewer {
400
498
  if (this._selection) this._syncSelectionStyle();
401
499
  },
402
500
  () => this.getAllMeshes(),
403
- (mesh) => this._applyOverridesToMesh(mesh)
501
+ (mesh) => this._applyOverridesToMesh(mesh),
502
+ // 图层自身是否半透明:全局透明度改走 uOpacity 后,材质 opacity 不再代表它
503
+ () => this._opacityTransparent
404
504
  );
405
505
 
406
506
  this.uniforms = createSharedUniforms();
407
507
  // 纹理解码优先派给共享解析 Worker(崩溃自动回主线程,见 decodeTextureWithWorker)
408
508
  setTextureOffThreadDecode(decodeTextureWithWorker);
409
509
  this.style = { ...DEFAULT_STYLE, ...(options.style ?? {}) };
510
+ this._styleTarget = { ...this.style };
511
+ this._opacityTransparent = (this.style.opacity ?? 1) < 0.999;
512
+ this._applyStyleUniforms();
513
+ this._registerAnimChannels();
514
+ // 动画启动必须把按需驱动的帧循环踢起来:MapLibre 空闲时不渲染,
515
+ // 不踢的话动画「卡住不动」,直到别的什么碰巧触发了重绘
516
+ this.animator.onActive = () => this._requestRepaint();
410
517
  this.outlineMaterial = createOutlineMaterial(this.uniforms);
411
518
 
412
519
  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
- });
520
+ // 棱线外观 uniform:全局棱线材质与逐网格棱线材质**共用同一批对象**,
521
+ // _syncEdgeStyle 改一次,所有棱线(含淡入乘子)同时生效,保持 O(1)。
522
+ this._edgeStyleUniforms = {
523
+ uColor: { value: new THREE.Color(this.style.edgeColor) },
524
+ uWidth: { value: Math.max(0.1, this.style.edgeWidth ?? 1.5) },
525
+ uOpacity: { value: 0.6 },
526
+ };
527
+ this.edgeMaterial = createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms);
528
+ this._selEdgeStyleUniforms = {
529
+ uColor: { value: new THREE.Color(this.style.outlineColor ?? this.style.selectColor) },
530
+ uWidth: { value: Math.max(0.1, this.style.outlineWidthPx ?? 3) },
531
+ uOpacity: { value: 1 },
532
+ uElevation: { value: this.style.selectElevation ?? 0 },
533
+ };
534
+ this.selectionEdgeMaterial = createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms);
423
535
 
424
536
  // 关键:style.edges 是个开关,但构造函数从没据此启用过棱线,导致
425
537
  // `style: { edges: true }` 形同虚设、_edgesEnabled 永远是 false。
@@ -438,25 +550,32 @@ export class S3MViewer {
438
550
  * MapLibre 宿主由 S3MLayer 在 onAdd 里调用;纯 three 宿主直接传自己的 canvas / gl。
439
551
  */
440
552
  /** 创建并配置一个绑定到给定 gl 上下文的 three renderer(mount 与上下文恢复共用) */
441
- private _createRenderer(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): THREE.WebGLRenderer {
442
- const r = new THREE.WebGLRenderer({
553
+ private _createRenderer(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): THREE.WebGLRenderer { const r = new THREE.WebGLRenderer({
443
554
  canvas,
444
555
  context: gl as WebGLRenderingContext,
445
556
  antialias: true,
446
557
  });
447
558
  r.autoClear = false;
448
559
  r.setPixelRatio(1);
449
- // 白模 albedo 接近 1,不做色调映射会过曝成一片纯白、丢失明暗关系
450
- r.toneMapping = THREE.ACESFilmicToneMapping;
451
- r.toneMappingExposure = 1.0;
560
+ // 允许外部传入色调映射模式,默认使用 THREE.NoToneMapping 还原真实材质与底图通透度,避免 ACESFilmic 压暗阴影与偏色
561
+ r.toneMapping = this.options.toneMapping !== undefined ? this.options.toneMapping : THREE.NoToneMapping;
562
+ r.toneMappingExposure = this.options.toneMappingExposure ?? 1.0;
452
563
  return r;
453
564
  }
454
565
 
566
+ /** 按 GL 能力开/关 DXT 压缩直传:两个 s3tc 扩展都要在(srgb 缺了贴图会发白失配) */
567
+ private _syncTextureCapabilities(gl: WebGLRenderingContext | WebGL2RenderingContext): void {
568
+ const s3tc = !!gl.getExtension('WEBGL_compressed_texture_s3tc');
569
+ const s3tcSrgb = !!gl.getExtension('WEBGL_compressed_texture_s3tc_srgb');
570
+ setCompressedTexturesSupported(s3tc && s3tcSrgb);
571
+ }
572
+
455
573
  mount(canvas: HTMLCanvasElement, gl: WebGLRenderingContext | WebGL2RenderingContext): void {
456
574
  this.canvas = canvas;
457
575
 
458
576
  this.renderer = this._createRenderer(canvas, gl);
459
577
  setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
578
+ this._syncTextureCapabilities(gl);
460
579
 
461
580
  this.scene = new THREE.Scene();
462
581
  this.scene.add(this.groundManager.group);
@@ -498,6 +617,7 @@ export class S3MViewer {
498
617
  }
499
618
  this.renderer = this._createRenderer(this.canvas, gl);
500
619
  setMaxAnisotropy(this.renderer.capabilities.getMaxAnisotropy());
620
+ this._syncTextureCapabilities(gl);
501
621
  this.picker?.dispose();
502
622
  this.picker = new Picker(this.renderer);
503
623
  // 共享 uniform / 材质引用不变,three 重新编译着色器时会自然接管;
@@ -523,6 +643,11 @@ export class S3MViewer {
523
643
  this._selectionEdges = null;
524
644
  this._smidMeshes.clear();
525
645
  this._meshHeight.clear();
646
+ // 场景已经清空,还挂着的动画通道(瓦片淡入 / 单体位移)不能再对着死对象写
647
+ this.animator.clear();
648
+ this.animator.unregisterPrefix('tile.');
649
+ this.animator.unregisterPrefix('feature.');
650
+ this._pathSpecs.clear();
526
651
  this._heightMin = Infinity;
527
652
  this._heightMax = -Infinity;
528
653
  this.renderer?.dispose();
@@ -572,7 +697,14 @@ export class S3MViewer {
572
697
  * 动态设置光照配置
573
698
  */
574
699
  setLight(options: Partial<LightOptions>): void {
575
- Object.assign(this._lightOptions, options);
700
+ const normalized: Partial<LightOptions> = { ...options };
701
+ if (normalized.mainColor !== undefined && normalized.keyLightColor === undefined) {
702
+ normalized.keyLightColor = normalized.mainColor;
703
+ }
704
+ if (normalized.mainIntensity !== undefined && normalized.keyLightIntensity === undefined) {
705
+ normalized.keyLightIntensity = normalized.mainIntensity;
706
+ }
707
+ Object.assign(this._lightOptions, normalized);
576
708
  this._applyLightOptions();
577
709
  this._updateLights();
578
710
  this._needsRepaint = true;
@@ -586,6 +718,30 @@ export class S3MViewer {
586
718
  return { ...this._lightOptions };
587
719
  }
588
720
 
721
+ /**
722
+ * 动态设置色调映射模式与曝光度
723
+ */
724
+ setToneMapping(toneMapping: THREE.ToneMapping, exposure = 1.0): void {
725
+ this.options.toneMapping = toneMapping;
726
+ this.options.toneMappingExposure = exposure;
727
+ if (this.renderer) {
728
+ this.renderer.toneMapping = toneMapping;
729
+ this.renderer.toneMappingExposure = exposure;
730
+ this._needsRepaint = true;
731
+ this.triggerRepaint();
732
+ }
733
+ }
734
+
735
+ /**
736
+ * 获取当前色调映射模式与曝光度
737
+ */
738
+ getToneMapping(): { toneMapping: THREE.ToneMapping; toneMappingExposure: number } {
739
+ return {
740
+ toneMapping: this.renderer ? this.renderer.toneMapping : (this.options.toneMapping ?? THREE.NoToneMapping),
741
+ toneMappingExposure: this.renderer ? this.renderer.toneMappingExposure : (this.options.toneMappingExposure ?? 1.0),
742
+ };
743
+ }
744
+
589
745
  /**
590
746
  * 更新光照位置与方向:
591
747
  * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
@@ -766,6 +922,276 @@ export class S3MViewer {
766
922
  this._featureStyleManager.setFeaturesStyle(smids, style, this._meshesOf(smids));
767
923
  }
768
924
 
925
+ /**
926
+ * 把单体平移到新位置(米,场景 ENU 坐标的增量)。
927
+ *
928
+ * 平移写进状态纹理第 2 行,所以拾取、选中描边、棱线都会自动跟随——
929
+ * 但**视锥剔除不会**:three 用的是几何原始包围球,移出包围球后整块瓦片会被剔掉。
930
+ * 所以这里顺手把「有位移」的网格关掉 frustumCulled,归零时再恢复。
931
+ */
932
+ moveFeature(
933
+ smid: number,
934
+ translation: [number, number, number],
935
+ transition?: TransitionOptions
936
+ ): void {
937
+ const key = this._ensureFeatureChannel(smid, 'translation');
938
+ this.animator.to(key, translation, transition);
939
+ }
940
+
941
+ /** 让单体沿某个轴移动的便捷写法(单位米) */
942
+ moveFeatureBy(
943
+ smid: number,
944
+ delta: [number, number, number],
945
+ transition?: TransitionOptions
946
+ ): void {
947
+ const cur = this._featureStyleManager.getFeatureStyle(smid)?.translation ?? [0, 0, 0];
948
+ this.moveFeature(smid, [cur[0] + delta[0], cur[1] + delta[1], cur[2] + delta[2]], transition);
949
+ }
950
+
951
+ /**
952
+ * 让单体沿折线运动。
953
+ *
954
+ * 路径先做**弧长参数化**(见 anim/Path.ts):缓动作用在「已走距离」上,而不是
955
+ * 顶点索引上,所以顶点疏密不均也不会忽快忽慢。
956
+ *
957
+ * 实现上没有引入新的 track 类型——把「进度 0→1」当成一条普通补间,通道的 write
958
+ * 把进度映射成弧长再查表得位置。这样打断、循环、缓动、预算全部复用现有调度器。
959
+ *
960
+ * 注意:不要和 `moveFeature` 同时驱动同一个单体,两者都写 `translation`。
961
+ */
962
+ followPath(
963
+ smid: number,
964
+ points: Path | ArrayLike<number> | Array<[number, number, number]>,
965
+ opts: PathFollowOptions = {}
966
+ ): void {
967
+ const path = points instanceof Path ? points : new Path(points, { closed: opts.closed });
968
+ if (path.length <= 0) return;
969
+
970
+ const offset = opts.offset ?? [0, 0, 0];
971
+ const duration =
972
+ opts.duration ??
973
+ (opts.speed && opts.speed > 0 ? (path.length / opts.speed) * 1000 : 2000);
974
+
975
+ const key = `feature.${smid}.path`;
976
+ this._pathSpecs.set(key, { path, offset });
977
+ if (!this.animator.has(key)) {
978
+ this.animator.register(key, {
979
+ dim: 1,
980
+ // 每次都从起点开始:进度通道没有「当前值」的概念,循环时正好重置
981
+ read: (out) => {
982
+ out[0] = 0;
983
+ },
984
+ write: (v) => {
985
+ const spec = this._pathSpecs.get(key);
986
+ if (!spec) return;
987
+ spec.path.sampleAt(v[0], this._pathScratch);
988
+ const meshes = this._meshesOf([smid]);
989
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
990
+ this._featureStyleManager.setFeatureStyle(
991
+ smid,
992
+ {
993
+ ...cur,
994
+ translation: [
995
+ spec.offset[0] + this._pathScratch[0],
996
+ spec.offset[1] + this._pathScratch[1],
997
+ spec.offset[2] + this._pathScratch[2],
998
+ ],
999
+ },
1000
+ meshes
1001
+ );
1002
+ // 轨迹可能跑到原始包围球外,关掉视锥剔除免得整块瓦片被剔掉
1003
+ for (const mesh of meshes) mesh.frustumCulled = false;
1004
+ },
1005
+ });
1006
+ }
1007
+
1008
+ const run = (): void => {
1009
+ this.animator.to(key, 1, {
1010
+ duration,
1011
+ easing: opts.easing ?? 'linear',
1012
+ onComplete: () => {
1013
+ if (opts.loop) run();
1014
+ else opts.onComplete?.();
1015
+ },
1016
+ });
1017
+ };
1018
+ run();
1019
+ }
1020
+
1021
+ /** 停下路径运动(停在当前位置,不回弹) */
1022
+ stopPath(smid: number): void {
1023
+ const key = `feature.${smid}.path`;
1024
+ this.animator.cancel(key);
1025
+ this.animator.unregister(key);
1026
+ this._pathSpecs.delete(key);
1027
+ }
1028
+ /**
1029
+ * 单体样式过渡:颜色 / 抬高 / 透明度 / 平移可以任意组合。
1030
+ *
1031
+ * 各属性各走一条通道,互不覆盖——同一个单体上「一边抬高一边变色」是两条独立的
1032
+ * track,写回时都是「读当前 → 改一个字段 → 写回」,不会互相冲掉。
1033
+ * `hidden` 不可插值,直接生效。
1034
+ */
1035
+ animateFeature(smid: number, style: Partial<FeatureStyle>, transition?: TransitionOptions): void {
1036
+ if (style.translation) this.moveFeature(smid, style.translation, transition);
1037
+ if (style.elevation != null) {
1038
+ this.animator.to(this._ensureFeatureChannel(smid, 'elevation'), style.elevation, transition);
1039
+ }
1040
+ if (style.opacity != null) {
1041
+ this.animator.to(this._ensureFeatureChannel(smid, 'opacity'), style.opacity, transition);
1042
+ }
1043
+ if (style.color) {
1044
+ this._colorScratch.set(style.color);
1045
+ this.animator.to(
1046
+ this._ensureFeatureChannel(smid, 'color'),
1047
+ [this._colorScratch.r, this._colorScratch.g, this._colorScratch.b],
1048
+ transition
1049
+ );
1050
+ }
1051
+ if (style.hidden != null) {
1052
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1053
+ this.setFeatureStyle(smid, { ...cur, hidden: style.hidden });
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * 批量单体样式过渡,支持 `stagger` 错峰。
1059
+ *
1060
+ * 错峰是这类系统性价比最高的选项:一万个单体错峰 8ms,总时长不变,但每帧只推进
1061
+ * 一小批,把峰值摊平——比任何「每帧上限」都自然。
1062
+ */
1063
+ animateFeatures(
1064
+ smids: number[],
1065
+ style: Partial<FeatureStyle>,
1066
+ transition?: TransitionOptions
1067
+ ): void {
1068
+ const stagger = transition?.stagger ?? 0;
1069
+ if (stagger <= 0) {
1070
+ for (const smid of smids) this.animateFeature(smid, style, transition);
1071
+ return;
1072
+ }
1073
+ const baseDelay = transition?.delay ?? 0;
1074
+ for (let i = 0; i < smids.length; i++) {
1075
+ this.animateFeature(smids[i], style, { ...transition, delay: baseDelay + i * stagger });
1076
+ }
1077
+ }
1078
+
1079
+ /**
1080
+ * 切换着色模式,用 fade-through 代替硬切。
1081
+ *
1082
+ * `uMode` 是 int,插不了值;能做的就是「压暗 → 换 → 亮回来」。
1083
+ * 编排走 onComplete 回调而不是 Promise 链,这样换模式发生在同一帧的回调里,
1084
+ * 不会多出一帧的空白。返回的 Promise 在整段编排结束后 resolve。
1085
+ */
1086
+ setMode(mode: StyleMode, opts: { duration?: number; dim?: number } = {}): Promise<void> {
1087
+ const d = Math.max(0, opts.duration ?? 300);
1088
+ const dim = Math.min(1, Math.max(0.05, opts.dim ?? 0.35));
1089
+ if (this.style.mode === mode) return Promise.resolve();
1090
+ if (d <= 0) {
1091
+ this.setStyle({ mode });
1092
+ return Promise.resolve();
1093
+ }
1094
+
1095
+ const gen = ++this._modeGen;
1096
+ return new Promise<void>((resolve) => {
1097
+ this.animator.to('style.fade', dim, {
1098
+ duration: d,
1099
+ easing: 'easeInOutQuad',
1100
+ onComplete: () => {
1101
+ // 中途又切了一次:这次编排作废,收尾交给新的那次
1102
+ if (gen !== this._modeGen) {
1103
+ resolve();
1104
+ return;
1105
+ }
1106
+ this.setStyle({ mode });
1107
+ this.animator.to('style.fade', 1, {
1108
+ duration: d,
1109
+ easing: 'easeInOutQuad',
1110
+ onComplete: resolve,
1111
+ });
1112
+ },
1113
+ });
1114
+ });
1115
+ }
1116
+
1117
+ /**
1118
+ * 按需注册「单体属性」通道。
1119
+ *
1120
+ * 通道按 smid 惰性创建:绝大多数场景只动几十个单体,没必要给全城几万个 smid
1121
+ * 预先建通道。`removeFeatureStyle` / `clearFeatureStyles` 会顺手回收。
1122
+ *
1123
+ * 写回统一走 `setFeatureStyle`:它内部已经按 `_meshesOf([smid])` 收敛脏网格,
1124
+ * 所以单个单体动画每帧只重烘「含这个单体」的那一两块,不会全场景重算。
1125
+ */
1126
+ private _ensureFeatureChannel(smid: number, prop: FeatureProp): string {
1127
+ const key = `feature.${smid}.${prop}`;
1128
+ if (this.animator.has(key)) return key;
1129
+
1130
+ const channel: Channel = prop === 'translation' || prop === 'color'
1131
+ ? {
1132
+ dim: 3,
1133
+ read: (out) => {
1134
+ if (prop === 'translation') {
1135
+ const t = this._featureStyleManager.getFeatureStyle(smid)?.translation;
1136
+ out[0] = t?.[0] ?? 0;
1137
+ out[1] = t?.[1] ?? 0;
1138
+ out[2] = t?.[2] ?? 0;
1139
+ } else {
1140
+ // 没有自定义色时以图层主色为起点,插值才连续
1141
+ const hex = this._featureStyleManager.getFeatureStyle(smid)?.color ?? this.style.colorA;
1142
+ this._colorScratch.set(hex);
1143
+ out[0] = this._colorScratch.r;
1144
+ out[1] = this._colorScratch.g;
1145
+ out[2] = this._colorScratch.b;
1146
+ }
1147
+ },
1148
+ write: (v) => {
1149
+ const meshes = this._meshesOf([smid]);
1150
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1151
+ if (prop === 'translation') {
1152
+ this._featureStyleManager.setFeatureStyle(
1153
+ smid,
1154
+ { ...cur, translation: [v[0], v[1], v[2]] },
1155
+ meshes
1156
+ );
1157
+ // 有位移就关掉视锥剔除,否则移出原始包围球后整块瓦片会凭空消失
1158
+ const moving = Math.abs(v[0]) > 1e-6 || Math.abs(v[1]) > 1e-6 || Math.abs(v[2]) > 1e-6;
1159
+ for (const mesh of meshes) mesh.frustumCulled = !moving;
1160
+ } else {
1161
+ const c = this._colorScratch.setRGB(v[0], v[1], v[2]);
1162
+ this._featureStyleManager.setFeatureStyle(
1163
+ smid,
1164
+ { ...cur, color: `#${c.getHexString()}` },
1165
+ meshes
1166
+ );
1167
+ }
1168
+ },
1169
+ }
1170
+ : {
1171
+ dim: 1,
1172
+ read: (out) => {
1173
+ const st = this._featureStyleManager.getFeatureStyle(smid);
1174
+ out[0] = (prop === 'elevation' ? st?.elevation : st?.opacity) ?? (prop === 'opacity' ? 1 : 0);
1175
+ },
1176
+ write: (v) => {
1177
+ const cur = this._featureStyleManager.getFeatureStyle(smid) ?? {};
1178
+ const next: FeatureStyle = prop === 'elevation' ? { ...cur, elevation: v[0] } : { ...cur, opacity: v[0] };
1179
+ this._featureStyleManager.setFeatureStyle(smid, next, this._meshesOf([smid]));
1180
+ },
1181
+ };
1182
+
1183
+ this.animator.register(key, channel);
1184
+ return key;
1185
+ }
1186
+
1187
+ /** 回收某个单体的动画通道(单体被删除 / 样式被清空时调用) */
1188
+ private _releaseFeatureChannels(smid: number): void {
1189
+ for (const prop of FEATURE_PROPS) this.animator.unregister(`feature.${smid}.${prop}`);
1190
+ const pathKey = `feature.${smid}.path`;
1191
+ this.animator.unregister(pathKey);
1192
+ this._pathSpecs.delete(pathKey);
1193
+ }
1194
+
769
1195
  /** 获取指定单体的自定义样式 */
770
1196
  getFeatureStyle(smid: number): FeatureStyle | null {
771
1197
  return this._featureStyleManager.getFeatureStyle(smid);
@@ -778,11 +1204,15 @@ export class S3MViewer {
778
1204
 
779
1205
  /** 移除指定单体的自定义样式,恢复为图层全局样式 */
780
1206
  removeFeatureStyle(smid: number): void {
1207
+ this._releaseFeatureChannels(smid);
781
1208
  this._featureStyleManager.removeFeatureStyle(smid, this._meshesOf([smid]));
782
1209
  }
783
1210
 
784
1211
  /** 清空所有单体的自定义样式 */
785
1212
  clearFeatureStyles(): void {
1213
+ for (const smid of this._featureStyleManager.getFeatureStyles().keys()) {
1214
+ this._releaseFeatureChannels(smid);
1215
+ }
786
1216
  this._featureStyleManager.clearFeatureStyles();
787
1217
  }
788
1218
 
@@ -990,6 +1420,7 @@ export class S3MViewer {
990
1420
  // 数据自带的棱线包:只跟着 edges 开关显示,不参与拾取、不参与着色
991
1421
  if ((child as THREE.LineSegments).isLineSegments && (child.userData as MeshUserData).isEdgeLines) {
992
1422
  child.visible = this._edgesEnabled;
1423
+ this._nativeEdgeLines.add(child as THREE.LineSegments);
993
1424
  this._styleEdgeLine(child as THREE.LineSegments);
994
1425
  continue;
995
1426
  }
@@ -998,6 +1429,7 @@ export class S3MViewer {
998
1429
  const isPoints = isPointCloudObject(child);
999
1430
  const mesh = child as THREE.Mesh;
1000
1431
  if (!mesh.isMesh && !isPoints) continue;
1432
+ if (isPoints) this._pointClouds.add(child as THREE.Points);
1001
1433
  const ud = mesh.userData as MeshUserData;
1002
1434
  ud.pickId = this._allocPickId();
1003
1435
  ud.datasetId = entry.id;
@@ -1024,12 +1456,13 @@ export class S3MViewer {
1024
1456
  const mat = styledMaterialOf(mesh);
1025
1457
  if (mat) {
1026
1458
  const o = Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
1027
- mat.opacity = o;
1459
+ // 不写 mat.opacity:全局透明度走共享 uOpacity,材质自身 opacity 保留模型自带 alpha
1028
1460
  mat.transparent = o < 0.999;
1029
1461
  mat.depthWrite = o >= 0.999;
1030
1462
  mat.flatShading = this.style.shading !== 'smooth';
1031
1463
  mat.needsUpdate = true;
1032
1464
  this._featureStyleManager.applyOverridesToMesh(mesh);
1465
+ this._startTileFade(mesh, mat);
1033
1466
  }
1034
1467
  }
1035
1468
 
@@ -1039,6 +1472,53 @@ export class S3MViewer {
1039
1472
  }
1040
1473
  }
1041
1474
 
1475
+ /**
1476
+ * 新瓦片淡入。
1477
+ *
1478
+ * 每块网格有自己的材质实例,所以给一个逐材质的 `uTileFade` 就够,不需要动共享
1479
+ * uniform、也不需要重建任何东西。淡入期间该网格进透明队列(混合 + 排序),
1480
+ * 淡完立刻把 `transparent` 还给图层状态,避免长期留在透明通道里。
1481
+ *
1482
+ * 通道 key 用自增序号而不是 pickId:pickId 会被回收复用,用它会串到新瓦片上。
1483
+ */
1484
+ private _startTileFade(mesh: THREE.Mesh, mat: StyledMaterial): void {
1485
+ const duration = this.style.fadeIn ?? 0;
1486
+ if (duration <= 0) return;
1487
+ const uFade = mat.userData.uTileFade;
1488
+ if (!uFade) return;
1489
+
1490
+ uFade.value = 0;
1491
+ mat.transparent = true;
1492
+ // 淡入期间保持深度写入:否则能透过正在淡入的楼看到后面的东西,像在「漏」
1493
+ mat.depthWrite = true;
1494
+
1495
+ const key = `tile.${this._tileFadeSeq++}.fade`;
1496
+ (mesh.userData as MeshUserData).tileFadeKey = key;
1497
+ this.animator.register(key, {
1498
+ dim: 1,
1499
+ read: (out) => {
1500
+ out[0] = uFade.value;
1501
+ },
1502
+ write: (v) => {
1503
+ uFade.value = v[0];
1504
+ },
1505
+ });
1506
+ this.animator.to(key, 1, {
1507
+ duration,
1508
+ easing: 'easeOutQuad',
1509
+ onComplete: () => {
1510
+ uFade.value = 1;
1511
+ (mesh.userData as MeshUserData).tileFadeKey = undefined;
1512
+ this.animator.unregister(key);
1513
+ // 淡入期间强制开了 transparent + depthWrite,结束时不能自己拍板关掉——
1514
+ // 图层可能本来就半透明、单体也可能还有淡化。交给淡化流程重新裁决一次。
1515
+ this._featureStyleManager.applyOverridesToMesh(mesh);
1516
+ if (!mat.transparent) mat.depthWrite = true;
1517
+ this._requestRepaint();
1518
+ },
1519
+ });
1520
+ }
1521
+
1042
1522
  /**
1043
1523
  * 把「当前选中」重放到一块新登记的 mesh 上。
1044
1524
  *
@@ -1103,6 +1583,15 @@ export class S3MViewer {
1103
1583
  private _unregisterTile(node: TileNode, entry: S3MDatasetEntry): void {
1104
1584
  node.group.traverse((child) => {
1105
1585
  const obj = child as any;
1586
+ if ((child as THREE.LineSegments).isLineSegments && obj.userData?.isEdgeLines) {
1587
+ this._nativeEdgeLines.delete(child as THREE.LineSegments);
1588
+ }
1589
+ if (isPointCloudObject(child)) this._pointClouds.delete(child);
1590
+ // 淡入还没结束就被淘汰:把通道收掉,否则会对着已释放的材质继续写
1591
+ if (obj.userData?.tileFadeKey) {
1592
+ this.animator.unregister(obj.userData.tileFadeKey);
1593
+ obj.userData.tileFadeKey = undefined;
1594
+ }
1106
1595
  if (obj.userData?.pickId != null) {
1107
1596
  this._pickables.delete(obj.userData.pickId);
1108
1597
  this._releasePickId(obj.userData.pickId);
@@ -1173,23 +1662,183 @@ export class S3MViewer {
1173
1662
  /* ---------------------------------------------------------------- 样式 */
1174
1663
 
1175
1664
  /** 动态设置图层整体透明度 (0.0 ~ 1.0) */
1176
- setOpacity(opacity: number): void {
1177
- this.setStyle({ opacity });
1665
+ setOpacity(opacity: number, transition?: TransitionOptions): void {
1666
+ this.setStyle({ opacity }, transition);
1178
1667
  }
1179
1668
 
1180
- /** 合并并应用样式。只改 uniform 的项不会触发几何/着色器重建。 */
1181
- setStyle(patch: Partial<S3MStyle> = {}): void {
1182
- this.style = { ...this.style, ...patch };
1669
+ /**
1670
+ * 合并并应用样式。
1671
+ *
1672
+ * 只改 uniform 的项不会触发几何 / 着色器重建。带 `transition` 时,能插值的项
1673
+ * 交给动画调度器逐帧推进;不能插值的项(`mode` / `shading` / `edges`)走各自的
1674
+ * 编排逻辑(`edges` 由 setEdges 做淡入淡出,`mode` 建议配 fade-through)。
1675
+ */
1676
+ setStyle(patch: Partial<S3MStyle> = {}, transition?: TransitionOptions): void {
1677
+ Object.assign(this._styleTarget, patch);
1678
+
1679
+ const duration = transition?.duration ?? 0;
1680
+ const wantAnim = duration > 0 || !!transition?.spring;
1681
+
1682
+ // 棱线开关是结构变更,不是插值:交给 setEdges 编排
1683
+ let rest = patch;
1684
+ if ('edges' in patch) {
1685
+ const { edges, ...others } = patch;
1686
+ rest = others;
1687
+ this.setEdges(!!edges, wantAnim ? transition : undefined);
1688
+ if (Object.keys(rest).length === 0) {
1689
+ this._requestRepaint();
1690
+ return;
1691
+ }
1692
+ }
1693
+
1694
+ const animated: string[] = [];
1695
+ const immediate: Record<string, unknown> = {};
1696
+ for (const key of Object.keys(rest) as (keyof S3MStyle)[]) {
1697
+ if (wantAnim && this.animator.has(`style.${String(key)}`)) animated.push(String(key));
1698
+ else immediate[key] = rest[key];
1699
+ }
1700
+
1701
+ if (Object.keys(immediate).length > 0) {
1702
+ Object.assign(this.style, immediate);
1703
+ this._applyStyleEffects(immediate as Partial<S3MStyle>);
1704
+ }
1705
+
1706
+ for (const key of animated) {
1707
+ this.animator.to(`style.${key}`, this._animTargetOf(key, rest), {
1708
+ ...transition,
1709
+ duration,
1710
+ });
1711
+ }
1712
+
1713
+ this._requestRepaint();
1714
+ }
1715
+
1716
+ getStyle(): S3MStyle & { modeLabel: string } {
1717
+ return { ...this._styleTarget, modeLabel: STYLE_LABELS[this._styleTarget.mode] };
1718
+ }
1719
+
1720
+ /** 把某个样式键的取值转成动画通道能吃的形状(标量 or 线性 RGB 三元组) */
1721
+ private _animTargetOf(key: string, patch: Partial<S3MStyle>): number | number[] {
1722
+ const value = (patch as Record<string, unknown>)[key];
1723
+ const dim = this._animDimOf(key);
1724
+ if (dim === 1) return Number(value);
1725
+ this._colorScratch.set(String(value));
1726
+ return [this._colorScratch.r, this._colorScratch.g, this._colorScratch.b];
1727
+ }
1728
+
1729
+ /** 该样式键的动画维度:3 = 颜色,1 = 标量 */
1730
+ private _animDimOf(key: string): 1 | 3 {
1731
+ return STYLE_COLOR_KEYS.includes(key as (typeof STYLE_COLOR_KEYS)[number]) ? 3 : 1;
1732
+ }
1733
+
1734
+ /**
1735
+ * 注册样式动画通道。
1736
+ *
1737
+ * 每个通道的 write 都是 O(1):只改 `this.style` 的一个字段 + 一次 uniform 赋值。
1738
+ * 这里不注册 `mode` / `shading` / `edges`——它们不可插值,见类文档。
1739
+ */
1740
+ private _registerAnimChannels(): void {
1741
+ for (const key of STYLE_SCALAR_KEYS) {
1742
+ this.animator.register(`style.${key}`, {
1743
+ dim: 1,
1744
+ read: (out) => {
1745
+ out[0] = Number((this.style as unknown as Record<string, number>)[key] ?? 0);
1746
+ },
1747
+ write: (v) => {
1748
+ (this.style as unknown as Record<string, number>)[key] = v[0];
1749
+ this._onStyleChannelWrote(key);
1750
+ },
1751
+ });
1752
+ }
1753
+
1754
+ for (const key of STYLE_COLOR_KEYS) {
1755
+ this.animator.register(`style.${key}`, {
1756
+ dim: 3,
1757
+ read: (out) => {
1758
+ const c = this._uniformColorOf(key);
1759
+ out[0] = c.r;
1760
+ out[1] = c.g;
1761
+ out[2] = c.b;
1762
+ },
1763
+ write: (v) => {
1764
+ const c = this._uniformColorOf(key);
1765
+ // 直接写线性分量:shader 消费的就是线性值,端点严格吻合
1766
+ c.setRGB(v[0], v[1], v[2]);
1767
+ (this.style as unknown as Record<string, string>)[key] = `#${c.getHexString()}`;
1768
+ this._onStyleChannelWrote(key);
1769
+ },
1770
+ });
1771
+ }
1772
+
1773
+ // 棱线淡入淡出乘子:不属于 S3MStyle,单独一条通道
1774
+ this.animator.register('style.edgeFade', {
1775
+ dim: 1,
1776
+ read: (out) => {
1777
+ out[0] = this.uniforms.uEdgeFade?.value ?? 1;
1778
+ },
1779
+ write: (v) => {
1780
+ if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v[0];
1781
+ this._syncEdgeStyle();
1782
+ },
1783
+ });
1784
+
1785
+ // 整体压暗乘子:setMode 的 fade-through 用它,也不是 S3MStyle 字段
1786
+ this.animator.register('style.fade', {
1787
+ dim: 1,
1788
+ read: (out) => {
1789
+ out[0] = this.uniforms.uFade?.value ?? 1;
1790
+ },
1791
+ write: (v) => {
1792
+ if (this.uniforms.uFade) this.uniforms.uFade.value = v[0];
1793
+ this._requestRepaint();
1794
+ },
1795
+ });
1796
+ }
1797
+
1798
+ /** 通道写完一个样式键后的最小副作用(全部 O(1)) */
1799
+ private _onStyleChannelWrote(key: string): void {
1800
+ if (key === 'opacity') {
1801
+ this._applyOpacity();
1802
+ return;
1803
+ }
1804
+ this._applyStyleUniforms();
1805
+ if (key === 'edgeColor' || key === 'edgeWidth' || key === 'edgeOpacity') {
1806
+ this._refreshEdgeLines();
1807
+ }
1808
+ }
1809
+
1810
+ /** 取某个颜色键对应的共享 uniform Color 对象 */
1811
+ private _uniformColorOf(key: string): THREE.Color {
1812
+ const u = this.uniforms;
1813
+ switch (key) {
1814
+ case 'colorA':
1815
+ return u.uColorA.value;
1816
+ case 'colorB':
1817
+ return u.uColorB.value;
1818
+ case 'colorC':
1819
+ return u.uColorC.value;
1820
+ case 'selectColor':
1821
+ return u.uSelColor.value;
1822
+ case 'edgeColor':
1823
+ return this.edgeMaterial.uniforms.uColor.value;
1824
+ default:
1825
+ return u.uColorA.value;
1826
+ }
1827
+ }
1828
+
1829
+ /** 应用一批样式键带来的副作用(uniform / 材质 / 棱线 / 选中态) */
1830
+ private _applyStyleEffects(patch: Partial<S3MStyle>): void {
1183
1831
  this._applyStyleUniforms();
1184
1832
  if ('opacity' in patch || 'depthWrite' in patch) this._applyOpacity();
1185
1833
  // 点云不走 StyleMaterial 的 uMode,模式切换要单独同步它的材质
1186
1834
  if ('mode' in patch) this._applyPointCloudStyles();
1187
1835
  if ('shading' in patch) this._applyShading();
1188
- // 临界角变了要重建 CPU 棱线(EdgesGeometry 参数),开/关走 setEdges 整体刷新
1836
+ // 临界角变了要重建 CPU 棱线(EdgesGeometry 的参数),开 / 关走 setEdges 整体刷新。
1837
+ // 必须先 _disposeEdges():setEdges 只挑「还没有棱线」的网格来建,
1838
+ // 不先拆掉的话已经建好的会被全部跳过,临界角改了等于没改。
1189
1839
  if ('edgeThreshold' in patch && this._edgesEnabled) {
1840
+ this._disposeEdges();
1190
1841
  this.setEdges(true);
1191
- } else if ('edges' in patch) {
1192
- this.setEdges(!!this.style.edges);
1193
1842
  } else if ('edgeColor' in patch || 'edgeWidth' in patch || 'opacity' in patch || 'edgeOpacity' in patch) {
1194
1843
  this._refreshEdgeLines();
1195
1844
  }
@@ -1198,11 +1847,6 @@ export class S3MViewer {
1198
1847
  if ('outline' in patch || 'outlineStyle' in patch) this.select(this._selection, { silent: true });
1199
1848
  else this._syncSelectionStyle();
1200
1849
  }
1201
- this._requestRepaint();
1202
- }
1203
-
1204
- getStyle(): S3MStyle & { modeLabel: string } {
1205
- return { ...this.style, modeLabel: STYLE_LABELS[this.style.mode] };
1206
1850
  }
1207
1851
 
1208
1852
  private _applyStyleUniforms(): void {
@@ -1237,6 +1881,9 @@ export class S3MViewer {
1237
1881
  if (u.uTexMix && s.texMix != null) {
1238
1882
  u.uTexMix.value = s.texMix;
1239
1883
  }
1884
+ if (u.uAmbientGain && s.ambientGain != null) {
1885
+ u.uAmbientGain.value = s.ambientGain;
1886
+ }
1240
1887
  if (this.outlineMaterial) {
1241
1888
  if (this.outlineMaterial.uniforms.uElevation && s.selectElevation != null) {
1242
1889
  this.outlineMaterial.uniforms.uElevation.value = s.selectElevation;
@@ -1250,23 +1897,40 @@ export class S3MViewer {
1250
1897
  }
1251
1898
  }
1252
1899
 
1253
- /** 透明度:只改材质状态,不触发着色器重编译,所以可以跟着滑块实时拖 */
1900
+ /**
1901
+ * 透明度:写共享 uniform,**不再逐 mesh 改材质**。
1902
+ *
1903
+ * 原来的写法每次都要遍历全部 pickables 改 `material.opacity` + `material.transparent`,
1904
+ * 是 O(meshes);而且 `opaque` 参与 three 的 program 缓存键,跨 0.999 会换一次 program。
1905
+ * 拖滑块时一秒钟几十次调用,几十万面的场景就是明显掉帧。
1906
+ *
1907
+ * 现在逐帧只写一个 float(`uOpacity`),混合开关只在**真正跨过阈值**时切一次。
1908
+ */
1254
1909
  private _applyOpacity(): void {
1255
1910
  const o = Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
1911
+ if (this.uniforms.uOpacity) this.uniforms.uOpacity.value = o;
1912
+
1256
1913
  const transparent = o < 0.999;
1257
- // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
1258
- const keepDepth = this.style.depthWrite !== false;
1259
- for (const p of this._pickables.values()) {
1260
- if (isPointCloudObject(p.mesh)) {
1261
- this._applyPointCloudStyle(p.mesh);
1262
- continue;
1914
+ if (transparent !== this._opacityTransparent) {
1915
+ this._opacityTransparent = transparent;
1916
+ // 关键:若 depthWrite 开启(默认开启),即使半透明也保持深度写入,完美遮挡背面棱线,防止背面线条透出
1917
+ const keepDepth = this.style.depthWrite !== false;
1918
+ for (const p of this._pickables.values()) {
1919
+ if (isPointCloudObject(p.mesh)) continue;
1920
+ const m = styledMaterialOf(p.mesh);
1921
+ if (!m) continue;
1922
+ // 有单体淡化 / 正在淡入的材质,transparent 由各自的流程管,这里不要抢
1923
+ if (m.userData.uBatchTexActive?.value) continue;
1924
+ if ((m.userData.uTileFade?.value ?? 1) < 1) continue;
1925
+ m.transparent = transparent;
1926
+ m.depthWrite = transparent ? keepDepth : true;
1263
1927
  }
1264
- const m = styledMaterialOf(p.mesh);
1265
- if (!m) continue;
1266
- m.opacity = o;
1267
- m.transparent = transparent;
1268
- m.depthWrite = transparent ? keepDepth : true;
1269
1928
  }
1929
+
1930
+ // 点云不走 StyleMaterial,自己有一套 uOpacity,逐帧刷的只有这几个对象
1931
+ for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
1932
+
1933
+ this._syncEdgeStyle();
1270
1934
  this._requestRepaint();
1271
1935
  }
1272
1936
 
@@ -1277,9 +1941,7 @@ export class S3MViewer {
1277
1941
 
1278
1942
  /** 全部点云刷新:着色模式切换时用(uniform 是共享引用,改色不用走这里) */
1279
1943
  private _applyPointCloudStyles(): void {
1280
- for (const p of this._pickables.values()) {
1281
- if (isPointCloudObject(p.mesh)) this._applyPointCloudStyle(p.mesh);
1282
- }
1944
+ for (const pc of this._pointClouds) this._applyPointCloudStyle(pc);
1283
1945
  this._requestRepaint();
1284
1946
  }
1285
1947
 
@@ -1392,10 +2054,16 @@ export class S3MViewer {
1392
2054
  *
1393
2055
  * 棱线本身用「屏幕空间等宽线」画(见 EdgeLines.ts):WebGL 的 lineWidth 基本无效,
1394
2056
  * 只有摊成面向屏幕的四边形,`edgeWidth` 才能真正调粗。
2057
+ *
2058
+ * 带 `transition` 时做「淡入 / 淡出」,**不对几何做插值**(几何没法插值):
2059
+ * 开:立刻置位 + 开始分片建线,同时 `uEdgeFade` 0→1 —— 建好一块亮一块,视觉上是渐显;
2060
+ * 关:`uEdgeFade` 1→0,淡完才 dispose,避免棱线「啪」一下消失。
1395
2061
  */
1396
- setEdges(enabled: boolean): void {
2062
+ setEdges(enabled: boolean, transition?: TransitionOptions): void {
2063
+ const wantAnim = !!transition && ((transition.duration ?? 0) > 0 || !!transition.spring);
1397
2064
  this._edgesEnabled = enabled;
1398
2065
  this.style.edges = enabled;
2066
+ this._styleTarget.edges = enabled;
1399
2067
 
1400
2068
  // 数据自带的棱线:直接切可见性
1401
2069
  for (const entry of this.datasets.values()) {
@@ -1410,7 +2078,19 @@ export class S3MViewer {
1410
2078
  this._syncEdgeStyle();
1411
2079
 
1412
2080
  if (!enabled) {
1413
- for (const p of this._pickables.values()) this._removeEdges(p.mesh);
2081
+ if (wantAnim) {
2082
+ this.animator.to('style.edgeFade', 0, {
2083
+ ...transition,
2084
+ onComplete: () => {
2085
+ this._disposeEdges();
2086
+ this._requestRepaint();
2087
+ transition?.onComplete?.();
2088
+ },
2089
+ });
2090
+ } else {
2091
+ this._setEdgeFade(1);
2092
+ this._disposeEdges();
2093
+ }
1414
2094
  this._requestRepaint();
1415
2095
  return;
1416
2096
  }
@@ -1430,6 +2110,25 @@ export class S3MViewer {
1430
2110
  else this._requestRepaint();
1431
2111
  };
1432
2112
  step();
2113
+
2114
+ if (wantAnim) {
2115
+ // 从 0 起淡入:新建出来的棱线自动继承当前进度,不会半路冒出一条满亮度的线
2116
+ this._setEdgeFade(0);
2117
+ this.animator.to('style.edgeFade', 1, transition);
2118
+ } else {
2119
+ this._setEdgeFade(1);
2120
+ }
2121
+ }
2122
+
2123
+ /** 直接写棱线淡入乘子(不走动画通道) */
2124
+ private _setEdgeFade(v: number): void {
2125
+ if (this.uniforms.uEdgeFade) this.uniforms.uEdgeFade.value = v;
2126
+ this._syncEdgeStyle();
2127
+ }
2128
+
2129
+ /** 释放所有现算棱线几何 */
2130
+ private _disposeEdges(): void {
2131
+ for (const p of this._pickables.values()) this._removeEdges(p.mesh);
1433
2132
  }
1434
2133
 
1435
2134
  /**
@@ -1478,19 +2177,28 @@ export class S3MViewer {
1478
2177
  this._requestRepaint();
1479
2178
  }
1480
2179
 
1481
- /** 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity */
2180
+ /**
2181
+ * 让全局棱线材质跟上 style.edgeColor / edgeWidth / edgeOpacity。
2182
+ *
2183
+ * 全程 O(1):只有一份共享 edgeMaterial(所有逐 mesh 棱线都用它)+ 一份预制缓存,
2184
+ * 所以棱线淡入淡出只要改一个 uniform,新建瓦片自动继承当前进度。
2185
+ */
1482
2186
  private _syncEdgeStyle(): void {
1483
2187
  const s = this.style;
2188
+ const fade = this.uniforms.uEdgeFade?.value ?? 1;
2189
+ const opacity =
2190
+ Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) *
2191
+ Math.min(1, Math.max(0.05, s.opacity ?? 1)) *
2192
+ fade;
1484
2193
  const u = this.edgeMaterial.uniforms;
1485
2194
  u.uColor.value.set(s.edgeColor);
1486
2195
  u.uWidth.value = Math.max(0.1, s.edgeWidth ?? 1.5);
1487
- u.uOpacity.value =
1488
- Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * Math.min(1, Math.max(0.05, s.opacity ?? 1));
2196
+ u.uOpacity.value = opacity;
1489
2197
  // 预制棱线缓存(LineBasicMaterial)同步颜色 / 不透明度
1490
2198
  if (this._creaseLines) {
1491
2199
  const m = this._creaseLines.material as THREE.LineBasicMaterial;
1492
2200
  m.color.set(s.edgeColor);
1493
- m.opacity = Math.min(1, Math.max(0, s.edgeOpacity ?? 0.6)) * Math.min(1, Math.max(0.05, s.opacity ?? 1));
2201
+ m.opacity = opacity;
1494
2202
  }
1495
2203
  }
1496
2204
 
@@ -1500,7 +2208,10 @@ export class S3MViewer {
1500
2208
  if (!mat || !mat.color) return;
1501
2209
  mat.color.set(this.style.edgeColor);
1502
2210
  const baseOpacity = this.style.edgeOpacity ?? 0.6;
1503
- mat.opacity = baseOpacity * Math.min(1, Math.max(0.05, this.style.opacity ?? 1));
2211
+ mat.opacity =
2212
+ baseOpacity *
2213
+ Math.min(1, Math.max(0.05, this.style.opacity ?? 1)) *
2214
+ (this.uniforms.uEdgeFade?.value ?? 1);
1504
2215
  mat.transparent = true;
1505
2216
  mat.polygonOffset = true;
1506
2217
  mat.polygonOffsetFactor = -1;
@@ -1508,12 +2219,17 @@ export class S3MViewer {
1508
2219
  mat.needsUpdate = true;
1509
2220
  }
1510
2221
 
2222
+ /**
2223
+ * 刷新所有棱线外观。
2224
+ *
2225
+ * 数据自带的棱线是逐对象的 LineBasicMaterial,改颜色只能一个个写;但原来每次都要
2226
+ * 遍历整个数据集树(几千个节点),颜色动画逐帧跑就是灾难。改成只刷注册时收集好的
2227
+ * 那批 LineSegments,复杂度降到 O(棱线条数)。
2228
+ */
1511
2229
  private _refreshEdgeLines(): void {
1512
2230
  this._syncEdgeStyle();
1513
- for (const entry of this.datasets.values()) {
1514
- entry.root.traverse((o) => {
1515
- if ((o.userData as MeshUserData).isEdgeLines) this._styleEdgeLine(o as THREE.LineSegments);
1516
- });
2231
+ for (const lines of this._nativeEdgeLines) {
2232
+ if (lines.parent) this._styleEdgeLine(lines);
1517
2233
  }
1518
2234
  }
1519
2235
 
@@ -1564,7 +2280,26 @@ export class S3MViewer {
1564
2280
  source.dispose();
1565
2281
  if (!segments || segments.length < 6) return;
1566
2282
 
1567
- const lines = new THREE.Mesh(buildThickLineGeometry(segments), this.edgeMaterial);
2283
+ // 3. 回填每段棱线所属的单体 batchId。
2284
+ // EdgesGeometry 只输出坐标不输出顶点号,只能拿端点坐标去查「位置 → batchId」表。
2285
+ // 没有这张表,单体一动棱线就留在原地——这正是「棱线跟随」的关键一环。
2286
+ const TOL = 0.005;
2287
+ const batchMap = buildPositionBatchMap(mesh.geometry, TOL);
2288
+ let batchIds: Float32Array | undefined;
2289
+ if (batchMap) {
2290
+ const segCount = segments.length / 6;
2291
+ batchIds = new Float32Array(segCount);
2292
+ for (let i = 0; i < segCount; i++) {
2293
+ const s = i * 6;
2294
+ batchIds[i] =
2295
+ batchMap.get(positionQuantKey(segments[s], segments[s + 1], segments[s + 2], 1 / TOL)) ?? -1;
2296
+ }
2297
+ }
2298
+
2299
+ const lines = new THREE.Mesh(
2300
+ buildThickLineGeometry(segments, batchIds),
2301
+ this._createEdgeMaterial(mesh)
2302
+ );
1568
2303
  lines.renderOrder = 2;
1569
2304
  // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
1570
2305
  lines.frustumCulled = false;
@@ -1574,12 +2309,52 @@ export class S3MViewer {
1574
2309
  mesh.add(lines); // 作为子节点,自动继承 mesh 的世界变换
1575
2310
  }
1576
2311
 
2312
+ /**
2313
+ * 给一块网格造专属的棱线材质。
2314
+ *
2315
+ * 棱线材质必须逐网格一份,因为它要绑定**这个网格**的单体状态纹理;但外观
2316
+ * (颜色 / 粗细 / 透明度)必须全局统一——所以外观 uniform 直接复用
2317
+ * `_edgeStyleUniforms` 里的对象,`_syncEdgeStyle()` 改一次全部生效,
2318
+ * program 也因为着色器相同而只编译一次。
2319
+ */
2320
+ private _createEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2321
+ const styled = styledMaterialOf(mesh);
2322
+ const batch = styled?.userData?.uBatchTex
2323
+ ? {
2324
+ uBatchTex: styled.userData.uBatchTex,
2325
+ uBatchTexWidth: styled.userData.uBatchTexWidth,
2326
+ uBatchTexActive: styled.userData.uBatchTexActive,
2327
+ }
2328
+ : undefined;
2329
+ return createThickLineMaterial(this.lineShared, {}, this._edgeStyleUniforms, batch);
2330
+ }
2331
+
2332
+ /** 选中棱线的逐网格材质,道理同上 */
2333
+ private _createSelectionEdgeMaterial(mesh: THREE.Mesh): ThickLineMaterial {
2334
+ const styled = styledMaterialOf(mesh);
2335
+ const batch = styled?.userData?.uBatchTex
2336
+ ? {
2337
+ uBatchTex: styled.userData.uBatchTex,
2338
+ uBatchTexWidth: styled.userData.uBatchTexWidth,
2339
+ uBatchTexActive: styled.userData.uBatchTexActive,
2340
+ }
2341
+ : undefined;
2342
+ return createThickLineMaterial(this.lineShared, {}, this._selEdgeStyleUniforms, batch);
2343
+ }
2344
+
1577
2345
  private _removeEdges(mesh: THREE.Mesh): void {
1578
2346
  const ud = mesh.userData as MeshUserData;
1579
2347
  const lines = ud.edges;
1580
2348
  if (!lines) return;
1581
2349
  mesh.remove(lines);
1582
2350
  (lines as THREE.Mesh).geometry?.dispose();
2351
+ // 棱线材质现在是逐网格一份(要绑各自的 batch 纹理),得跟着释放
2352
+ const mat = (lines as THREE.Mesh).material;
2353
+ if (Array.isArray(mat)) {
2354
+ mat.forEach((m) => (m as any)?.dispose?.());
2355
+ } else {
2356
+ (mat as any)?.dispose?.();
2357
+ }
1583
2358
  ud.edges = null;
1584
2359
  }
1585
2360
 
@@ -2104,23 +2879,36 @@ export class S3MViewer {
2104
2879
  const ud = mesh.userData as MeshUserData;
2105
2880
  const geoRef = ud.geoRef;
2106
2881
  let segments: PickSegment[] = [];
2882
+ // 这块网格上的棱线全部属于选中的那个单体,整批填同一个 batchId,
2883
+ // 顶点着色器才能从状态纹理里取到平移 / 拔高——选中跟随移动靠它。
2884
+ let batchId = -1;
2107
2885
  if (mesh === result.mesh && result.instanceIndex != null) {
2108
2886
  // 实例化几何没有逐单体的顶点区间,整个 base mesh 就是这一栋的外形
2109
2887
  segments = [{ vertexOffset: 0, vertexCount: mesh.geometry.getAttribute('position')?.count ?? 0 }];
2888
+ const pickInfo = geoRef?.pickInfo;
2889
+ const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid ?? ''] ??
2890
+ (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
2891
+ batchId = entry ? pickEntryId(entry as any) : -1;
2110
2892
  } else if (result.smid != null) {
2111
2893
  const pickInfo = geoRef?.pickInfo;
2112
2894
  const entry = (pickInfo as Record<string | number, unknown> | undefined)?.[result.smid] ??
2113
2895
  (pickInfo as Record<string | number, unknown> | undefined)?.[String(result.smid)];
2114
2896
  const bId = entry ? pickEntryId(entry as any) : -1;
2897
+ batchId = bId;
2115
2898
  segments = (entry as any)?.segments ?? (bId >= 0 ? resolveFeature(geoRef, bId).segments : []);
2116
2899
  } else if (mesh === result.mesh) {
2117
2900
  const localGeo = (ud.geoByBatchId as Map<number, GeoPackageEntry> | undefined)?.get(result.batchId) ?? geoRef;
2118
2901
  segments = resolveFeature(localGeo, result.batchId).segments;
2902
+ batchId = result.batchId;
2119
2903
  }
2120
2904
 
2121
2905
  const pairs = buildFeatureEdgeSegments(mesh.geometry, segments);
2122
2906
  if (pairs && pairs.length > 0) {
2123
- const lines = new THREE.Mesh(buildThickLineGeometry(pairs), this.selectionEdgeMaterial);
2907
+ const batchIds = batchId >= 0 ? new Float32Array(pairs.length / 6).fill(batchId) : undefined;
2908
+ const lines = new THREE.Mesh(
2909
+ buildThickLineGeometry(pairs, batchIds),
2910
+ this._createSelectionEdgeMaterial(mesh)
2911
+ );
2124
2912
  lines.renderOrder = 4;
2125
2913
  // 屏幕空间外扩后可能超出原包围球,关掉剔除更稳
2126
2914
  lines.frustumCulled = false;
@@ -2189,7 +2977,9 @@ export class S3MViewer {
2189
2977
  u.uColor.value.set(s.outlineColor ?? s.selectColor);
2190
2978
  u.uWidth.value = Math.max(0.1, s.outlineWidthPx ?? 3);
2191
2979
  u.uOpacity.value = Math.min(1, Math.max(0.05, s.opacity ?? 1));
2192
- u.uElevation.value = this._selection ? this._selectionElevation(this._selection) : 0;
2980
+ // 只加全局 selectElevation:单体自己的拔高已经写在状态纹理第 2 行 z 里,
2981
+ // 顶点着色器采样时带出来了,这里再加一次就会双重拔高
2982
+ if (u.uElevation) u.uElevation.value = s.selectElevation ?? 0;
2193
2983
 
2194
2984
  // 外扩描边是另一个材质实例,单体状态纹理的开关是建的时候抄过去的。
2195
2985
  // 先选中、再抬高(或反过来)时模板会停留在旧值,这里补一次同步。
@@ -2283,6 +3073,9 @@ export class S3MViewer {
2283
3073
  const gl = this.renderer.getContext() as WebGLRenderingContext | null;
2284
3074
  if (gl && typeof gl.isContextLost === 'function' && gl.isContextLost()) return false;
2285
3075
 
3076
+ // 先推进动画,再刷脏标记,最后渲染 —— 保证本帧画出来的是最新值
3077
+ const animating = this.animator.tick();
3078
+
2286
3079
  // 脏标记正常由微任务刷掉;这里再兜一次底,保证「本帧画出来的」一定是最新样式
2287
3080
  this.flushOverrides();
2288
3081
 
@@ -2351,8 +3144,14 @@ export class S3MViewer {
2351
3144
  this._fps.last = now;
2352
3145
  }
2353
3146
 
2354
- // 动态着色 / 瓦片加载中 / 瓦片层级或可见性改变,都需要宿主继续驱动下一帧
2355
- return this.style.mode === STYLE.DYNAMIC || this.stats.loading > 0 || tilesetChanged;
3147
+ // 动态着色 / 动画进行中 / 瓦片加载中 / 瓦片层级或可见性改变,都需要宿主继续驱动下一帧。
3148
+ // 动画结束那一帧必须精确回到 false,否则 MapLibre 会永远重绘(耗电、发热)。
3149
+ return (
3150
+ animating ||
3151
+ this.style.mode === STYLE.DYNAMIC ||
3152
+ this.stats.loading > 0 ||
3153
+ tilesetChanged
3154
+ );
2356
3155
  }
2357
3156
 
2358
3157
  /* ---------------------------------------------------------------- 只读属性 */