s3m-three 0.4.25 → 0.4.27

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 (109) hide show
  1. package/dist/browser/assets/{s3mWorker-jc9Oly5K.js → s3mWorker-BfSBNvF5.js} +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +119 -36
  3. package/dist/browser/s3m-three.iife.js +119 -36
  4. package/dist/core/BakeMercator.d.ts +54 -0
  5. package/dist/core/BakeMercator.d.ts.map +1 -0
  6. package/dist/core/BakeMercator.js +65 -0
  7. package/dist/core/BakeMercator.js.map +1 -0
  8. package/dist/core/CustomAttributes.d.ts +7 -0
  9. package/dist/core/CustomAttributes.d.ts.map +1 -0
  10. package/dist/core/CustomAttributes.js +48 -0
  11. package/dist/core/CustomAttributes.js.map +1 -0
  12. package/dist/core/Draco.d.ts.map +1 -1
  13. package/dist/core/Draco.js +149 -2
  14. package/dist/core/Draco.js.map +1 -1
  15. package/dist/core/MeshOpt.d.ts.map +1 -1
  16. package/dist/core/MeshOpt.js +2 -0
  17. package/dist/core/MeshOpt.js.map +1 -1
  18. package/dist/core/S3M1Parser.d.ts.map +1 -1
  19. package/dist/core/S3M1Parser.js +114 -27
  20. package/dist/core/S3M1Parser.js.map +1 -1
  21. package/dist/core/S3MBParser.d.ts +5 -2
  22. package/dist/core/S3MBParser.d.ts.map +1 -1
  23. package/dist/core/S3MBParser.js +27 -12
  24. package/dist/core/S3MBParser.js.map +1 -1
  25. package/dist/core/TileGeometry.d.ts +8 -0
  26. package/dist/core/TileGeometry.d.ts.map +1 -1
  27. package/dist/core/TileGeometry.js +45 -7
  28. package/dist/core/TileGeometry.js.map +1 -1
  29. package/dist/core/types.d.ts +1 -0
  30. package/dist/core/types.d.ts.map +1 -1
  31. package/dist/index.d.ts +8 -5
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +4 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/render/DatasetManager.d.ts +27 -0
  36. package/dist/render/DatasetManager.d.ts.map +1 -1
  37. package/dist/render/DatasetManager.js +18 -4
  38. package/dist/render/DatasetManager.js.map +1 -1
  39. package/dist/render/GeoAdapter.d.ts +49 -24
  40. package/dist/render/GeoAdapter.d.ts.map +1 -1
  41. package/dist/render/GeoAdapter.js +96 -8
  42. package/dist/render/GeoAdapter.js.map +1 -1
  43. package/dist/render/GeometryBuilder.d.ts +17 -0
  44. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  45. package/dist/render/GeometryBuilder.js +67 -4
  46. package/dist/render/GeometryBuilder.js.map +1 -1
  47. package/dist/render/MotionBlurPass.d.ts +130 -0
  48. package/dist/render/MotionBlurPass.d.ts.map +1 -0
  49. package/dist/render/MotionBlurPass.js +394 -0
  50. package/dist/render/MotionBlurPass.js.map +1 -0
  51. package/dist/render/S3MViewer.d.ts +99 -2
  52. package/dist/render/S3MViewer.d.ts.map +1 -1
  53. package/dist/render/S3MViewer.js +345 -13
  54. package/dist/render/S3MViewer.js.map +1 -1
  55. package/dist/render/StyleMaterial.d.ts +5 -0
  56. package/dist/render/StyleMaterial.d.ts.map +1 -1
  57. package/dist/render/StyleMaterial.js +21 -0
  58. package/dist/render/StyleMaterial.js.map +1 -1
  59. package/dist/render/tilePlacement.d.ts +84 -0
  60. package/dist/render/tilePlacement.d.ts.map +1 -0
  61. package/dist/render/tilePlacement.js +71 -0
  62. package/dist/render/tilePlacement.js.map +1 -0
  63. package/dist/render/viewer/EdgeDecorator.d.ts +1 -0
  64. package/dist/render/viewer/EdgeDecorator.d.ts.map +1 -1
  65. package/dist/render/viewer/EdgeDecorator.js +3 -0
  66. package/dist/render/viewer/EdgeDecorator.js.map +1 -1
  67. package/dist/render/viewer/PickService.d.ts +13 -0
  68. package/dist/render/viewer/PickService.d.ts.map +1 -1
  69. package/dist/render/viewer/PickService.js +24 -15
  70. package/dist/render/viewer/PickService.js.map +1 -1
  71. package/dist/render/viewer/types.d.ts +75 -0
  72. package/dist/render/viewer/types.d.ts.map +1 -1
  73. package/dist/render/viewer/types.js.map +1 -1
  74. package/dist/tileset/S3MTileset.d.ts +62 -1
  75. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  76. package/dist/tileset/S3MTileset.js +273 -6
  77. package/dist/tileset/S3MTileset.js.map +1 -1
  78. package/dist/worker/S3MWorkerClient.d.ts +1 -1
  79. package/dist/worker/S3MWorkerClient.d.ts.map +1 -1
  80. package/dist/worker/S3MWorkerClient.js +2 -2
  81. package/dist/worker/S3MWorkerClient.js.map +1 -1
  82. package/dist/worker/s3mWorker.js +844 -635
  83. package/dist/worker/s3mWorker.js.map +1 -1
  84. package/docs/API.md +36 -5
  85. package/docs/FORMAT-NOTES.md +10 -0
  86. package/package.json +1 -1
  87. package/src/core/BakeMercator.ts +96 -0
  88. package/src/core/CustomAttributes.ts +53 -0
  89. package/src/core/Draco.ts +168 -6
  90. package/src/core/MeshOpt.ts +2 -0
  91. package/src/core/S3M1Parser.ts +102 -22
  92. package/src/core/S3MBParser.ts +33 -14
  93. package/src/core/TileGeometry.ts +60 -7
  94. package/src/core/types.ts +1 -0
  95. package/src/index.ts +15 -3
  96. package/src/render/DatasetManager.ts +43 -5
  97. package/src/render/GeoAdapter.ts +139 -11
  98. package/src/render/GeometryBuilder.ts +90 -4
  99. package/src/render/MotionBlurPass.ts +453 -0
  100. package/src/render/S3MViewer.ts +384 -17
  101. package/src/render/StyleMaterial.ts +26 -0
  102. package/src/render/tilePlacement.ts +121 -0
  103. package/src/render/viewer/EdgeDecorator.ts +3 -0
  104. package/src/render/viewer/PickService.ts +44 -16
  105. package/src/render/viewer/types.ts +73 -0
  106. package/src/tileset/S3MTileset.ts +300 -6
  107. package/src/vendor/draco_decoder.module.d.ts +15 -1
  108. package/src/worker/S3MWorkerClient.ts +7 -2
  109. package/src/worker/s3mWorker.ts +6 -2
@@ -41,6 +41,7 @@ import type {
41
41
  PickResult,
42
42
  PickableRef,
43
43
  PathFollowOptions,
44
+ DepthMode,
44
45
  MeshUserData,
45
46
  } from './viewer/types.js';
46
47
  import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
@@ -69,6 +70,8 @@ import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMat
69
70
  import type { DegradationKind, UnsupportedPolicy } from '../core/degradations.js';
70
71
  import { Picker } from './Picker.js';
71
72
  import { GpuEdgePass } from './GpuEdgePass.js';
73
+ import { MotionBlurPass, normalizeMotionBlur, autoMotionBlurDamp } from './MotionBlurPass.js';
74
+ import type { MotionBlurOptions, MotionBlurConfig } from './MotionBlurPass.js';
72
75
  import { Animator } from '../anim/Animator.js';
73
76
  import {
74
77
  buildClipPlanes,
@@ -112,6 +115,7 @@ import type {
112
115
  } from '../core/types.js';
113
116
 
114
117
  import { lngLatFromWorld, worldFromLngLat, type GeoAdapter } from './GeoAdapter.js';
118
+ import { computeTilePlacement } from './tilePlacement.js';
115
119
 
116
120
  import {
117
121
  DatasetManager,
@@ -174,7 +178,16 @@ export type {
174
178
  S3MViewerOptions,
175
179
  PickableRef,
176
180
  PathFollowOptions,
181
+ DepthMode,
177
182
  } from './viewer/types.js';
183
+ export type { MotionBlurOptions, MotionBlurConfig } from './MotionBlurPass.js';
184
+
185
+ /** 稳健归一化 depthMode 入参,默认 'scene' */
186
+ export function normalizeDepthMode(mode?: unknown): DepthMode {
187
+ if (mode === 'clear') return 'clear';
188
+ if (mode === 'auto') return 'auto';
189
+ return 'scene';
190
+ }
178
191
 
179
192
  function styledMaterialOf(mesh: THREE.Mesh): StyledMaterial | null {
180
193
  const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
@@ -202,6 +215,12 @@ function clampOpacity(v: number | null | undefined): number {
202
215
  /** 低于这个透明度就当作完全不可见:跳过绘制,避免全透明物体写深度 */
203
216
  const OPACITY_EPS = 1e-3;
204
217
 
218
+ /**
219
+ * 拾取反解瓦片级放置时的临时矩阵。命中点→规范 ENU 是一次独立查询(鼠标事件),
220
+ * 复用一个实例避免每次分配;不可重入(`_toCanonicalScene` 内部不递归调用)。
221
+ */
222
+ const _tilePlacementInverse = new THREE.Matrix4();
223
+
205
224
  /* ------------------------------------------------------------------ 可动画的样式键 */
206
225
 
207
226
  /**
@@ -294,6 +313,8 @@ export class S3MViewer {
294
313
  * 传了值就只认自己,多图层同页各用各的,互不覆盖。
295
314
  */
296
315
  private _materialQuality: MaterialQuality | undefined;
316
+ private _lodTransition: 'binary' | 'incremental';
317
+ private _fallbackOffset?: { factor?: number; units?: number };
297
318
  /**
298
319
  * 本图层的颜色管线。
299
320
  *
@@ -319,11 +340,32 @@ export class S3MViewer {
319
340
  }
320
341
 
321
342
  /** 场景主锚点(ENU 原点对应的世界坐标) */
322
- get sceneAnchor(): { x: number; y: number; z: number; meterInMercator: number; altitude?: number } | null {
343
+ get sceneAnchor(): {
344
+ x: number;
345
+ y: number;
346
+ z: number;
347
+ meterInMercatorEast?: number;
348
+ meterInMercatorNorth?: number;
349
+ meterInMercatorUp?: number;
350
+ meterInMercator: number;
351
+ altitude?: number;
352
+ } | null {
323
353
  return this._datasetManager.sceneAnchor;
324
354
  }
325
- set sceneAnchor(val: { x: number; y: number; z: number; meterInMercator: number; altitude?: number } | null) {
355
+ set sceneAnchor(
356
+ val: {
357
+ x: number;
358
+ y: number;
359
+ z: number;
360
+ meterInMercatorEast?: number;
361
+ meterInMercatorNorth?: number;
362
+ meterInMercatorUp?: number;
363
+ meterInMercator: number;
364
+ altitude?: number;
365
+ } | null
366
+ ) {
326
367
  this._datasetManager.sceneAnchor = val;
368
+ this._reapplyTilePlacements();
327
369
  }
328
370
 
329
371
  /** 拾取表 / pickId 池 / 射线求交(见 viewer/PickService.ts) */
@@ -393,6 +435,19 @@ export class S3MViewer {
393
435
  private readonly _unsupported: UnsupportedPolicy;
394
436
  /** LOD 判定屏幕高度的 DPR 上限。Infinity = 不截断(默认,行为同旧版) */
395
437
  private _lodPixelRatioCap = Infinity;
438
+ /**
439
+ * 运动模糊配置(默认 null = 关闭)。归一化后的形状见 `normalizeMotionBlur`;
440
+ * pass 延迟到首帧才建(构造期 renderer 都还没有)。
441
+ */
442
+ private _motionBlurCfg: MotionBlurConfig | null = null;
443
+ private _motionBlur: MotionBlurPass | null = null;
444
+ /**
445
+ * 瓦片级放置(默认关)。关时 `_registerTile` 完全不碰瓦片矩阵,代码路径与改动前
446
+ * 逐字一致;开时每块瓦片用自己的中心纬度算尺度,见 `tilePlacement.ts`。
447
+ */
448
+ private readonly _perTilePlacement: boolean;
449
+ /** 本层绘制前深度缓冲的处理策略:'scene' | 'clear' | 'auto',默认 'scene' */
450
+ private _depthMode: DepthMode = 'scene';
396
451
 
397
452
  /** 宿主请求重画(MapLibre 传 triggerRepaint,纯 three 宿主传自己的渲染循环) */
398
453
  private _requestRepaint(): void {
@@ -409,12 +464,23 @@ export class S3MViewer {
409
464
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
410
465
  this._unsupported = options.unsupported ?? 'warn';
411
466
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
467
+ // 瓦片级放置默认开启:旧行为是拿单一常量切平面拟合随纬度变化的墨卡托比例,属于 bug 修复,
468
+ // 所以默认值跟着改(见 AGENTS.md 不变式 ①)。显式传 false 仍可回退。
469
+ this._perTilePlacement = options.perTilePlacement !== false;
470
+ this._depthMode = normalizeDepthMode(options.depthMode);
412
471
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
472
+ // 运动模糊:默认关闭;开了也只在 renderFrame 里按需接管「场景→屏幕」这一步
473
+ this._motionBlurCfg = normalizeMotionBlur(options.motionBlur);
413
474
  // 只记本层的档位,**不写模块级全局**:以前这里 setMaterialQuality(...) 会把全局
414
475
  // 改掉,于是同页后创建的图层顺手把先创建的图层也打回去了(多层互相覆盖的根因)。
415
476
  this._materialQuality = normalizeMaterialQuality(options.materialQuality);
416
477
  // 之后 addDataset 的数据集按这个默认建材质(数据集自己指定则用自己的)
417
478
  this._datasetManager.defaultMaterialQuality = this._materialQuality;
479
+ this._lodTransition = options.lodTransition ?? 'binary';
480
+ this._fallbackOffset = options.fallbackOffset;
481
+ this._datasetManager.defaultLodTransition = this._lodTransition;
482
+ this._datasetManager.defaultFallbackOffset = this._fallbackOffset;
483
+ this._datasetManager.defaultBakeMercator = options.bakeMercator;
418
484
  if (options.totalMaxTiles) {
419
485
  this._datasetManager.totalMaxTiles = options.totalMaxTiles;
420
486
  }
@@ -496,6 +562,7 @@ export class S3MViewer {
496
562
  return self._smidMeshes;
497
563
  },
498
564
  lngLatFromWorld: (x, y) => self._lngLatFromWorld(x, y),
565
+ toCanonicalScene: (p, mesh) => self._toCanonicalScene(p, mesh),
499
566
  gpuPathPoseAt: (smid) => self._gpuPathPoseAt(smid),
500
567
  isFeatureHidden: (smid, batchId) => self._isFeatureHidden(smid, batchId),
501
568
  });
@@ -518,9 +585,18 @@ export class S3MViewer {
518
585
  styleTarget: this._styleTarget,
519
586
  animator: this.animator,
520
587
  pickables: this._picks.pickables,
521
- scene: this.scene,
522
- renderer: this.renderer,
523
- camera: this.camera,
588
+ // mount() / restoreContext() 都会换掉 scene / renderer / camera,这里必须用
589
+ // getter 拿「当下」的实例 —— 拆分 EdgeDecorator 时曾误写成值快照,mount 之后
590
+ // host.renderer 恒为 null,GPU 描边(和 loadCreases 挂线)静默失效
591
+ get scene() {
592
+ return self.scene;
593
+ },
594
+ get renderer() {
595
+ return self.renderer;
596
+ },
597
+ get camera() {
598
+ return self.camera;
599
+ },
524
600
  authenticate: this._authenticate,
525
601
  requestRepaint: () => this.triggerRepaint(),
526
602
  });
@@ -656,6 +732,8 @@ export class S3MViewer {
656
732
  this._lights.dispose();
657
733
  this.outlineMaterial.dispose();
658
734
  this._edges.disposeGpuEdge();
735
+ this._motionBlur?.dispose();
736
+ this._motionBlur = null;
659
737
  this.edgeMaterial.dispose();
660
738
  this.selectionEdgeMaterial.dispose();
661
739
  this.scene?.clear();
@@ -740,7 +818,15 @@ export class S3MViewer {
740
818
  .filter((d) => !!d?.configUrl)
741
819
  .map((d) => ({ ...d, polygonOffset: d.polygonOffset ?? options.polygonOffset }));
742
820
  if (options.configUrl)
743
- return [{ id: 'default', name: '默认数据集', configUrl: options.configUrl, useWorker: options.useWorker, polygonOffset: options.polygonOffset }];
821
+ return [{
822
+ id: 'default',
823
+ name: '默认数据集',
824
+ configUrl: options.configUrl,
825
+ useWorker: options.useWorker,
826
+ polygonOffset: options.polygonOffset,
827
+ lodTransition: options.lodTransition,
828
+ fallbackOffset: options.fallbackOffset,
829
+ }];
744
830
  return [];
745
831
  }
746
832
 
@@ -748,7 +834,12 @@ export class S3MViewer {
748
834
  async addDataset(ds: S3MDatasetOptions): Promise<S3MDatasetEntry> {
749
835
  if (!this.scene) throw new Error('[s3m] 图层尚未 addLayer,不能添加数据集');
750
836
  // 图层级默认值:数据集自己指定了就用自己的,见 `S3MViewerOptions.unsupported`
751
- const merged: S3MDatasetOptions = { ...ds, unsupported: ds.unsupported ?? this._unsupported };
837
+ const merged: S3MDatasetOptions = {
838
+ ...ds,
839
+ unsupported: ds.unsupported ?? this._unsupported,
840
+ lodTransition: ds.lodTransition ?? this._lodTransition,
841
+ fallbackOffset: ds.fallbackOffset ?? this._fallbackOffset,
842
+ };
752
843
  const entry = await this._datasetManager.addDataset(
753
844
  merged,
754
845
  this.scene,
@@ -806,6 +897,8 @@ export class S3MViewer {
806
897
  /** 当地图 CRS 投影切换或宿主 map 挂载后,重新计算场景锚点与各数据集 offset */
807
898
  recomputeAnchors(): void {
808
899
  this._datasetManager.recomputeAnchors(this.geo);
900
+ // 锚点变了 → 每块瓦片的 k 与 position 都要重算(关这项功能时是空转,一次遍历都不做)
901
+ this._reapplyTilePlacements();
809
902
  this._heights.refreshAll();
810
903
  this._requestRepaint();
811
904
  }
@@ -1643,6 +1736,19 @@ export class S3MViewer {
1643
1736
  /* ---------------------------------------------------------------- 瓦片注册 */
1644
1737
 
1645
1738
  private _registerTile(node: TileNode, entry: S3MDatasetEntry): void {
1739
+ // 必须在 add / updateMatrixWorld 之前设置:后者会拿当前 matrix 算 matrixWorld 并缓存
1740
+ if (this._perTilePlacement && node.sphere) {
1741
+ this._applyTilePlacement(node.group, node.sphere.center, entry);
1742
+ } else {
1743
+ const sceneAnchor = this.sceneAnchor;
1744
+ if (sceneAnchor) {
1745
+ const sEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1746
+ const sNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1747
+ const sUp = sceneAnchor.meterInMercatorUp ?? sEast;
1748
+ const center = node.sphere ? ([node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] as const) : ([0, 0, 0] as const);
1749
+ this._updateTileUniforms(node.group, center, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1750
+ }
1751
+ }
1646
1752
  entry.root.add(node.group);
1647
1753
  node.group.updateMatrixWorld(true);
1648
1754
 
@@ -1714,6 +1820,136 @@ export class S3MViewer {
1714
1820
  }
1715
1821
  }
1716
1822
 
1823
+ /**
1824
+ * 把一块瓦片摆到「用它自己中心纬度算出的尺度」上(`perTilePlacement` 开启时才走)。
1825
+ *
1826
+ * 矩阵是 `T(position) · S(k) · T(-pivot)`,展开后即 `v ↦ position + k·(v - pivot)`。
1827
+ * 直接用 `makeScale + setPosition` 一次写好,省掉两个临时矩阵:
1828
+ * `T(t)·S(k)·T(-c)` 的元素其实就是对角 `k` 加平移 `t - k·c`。
1829
+ *
1830
+ * 只写 `matrix`,不碰 `rotation / position / scale` —— three 在
1831
+ * `matrixAutoUpdate = false` 时直接使用 `matrix`,那些字段留着无害,
1832
+ * 但别让 `updateMatrix()` 有机会覆盖回来。
1833
+ */
1834
+ private _applyTilePlacement(
1835
+ group: THREE.Object3D,
1836
+ center: { x: number; y: number; z: number },
1837
+ entry: S3MDatasetEntry,
1838
+ ): void {
1839
+ const sceneAnchor = this.sceneAnchor;
1840
+ if (!sceneAnchor || !entry.anchor) return;
1841
+
1842
+ const p = computeTilePlacement({
1843
+ center,
1844
+ datasetAnchor: entry.anchor,
1845
+ sceneAnchor,
1846
+ datasetOffset: entry.offset,
1847
+ geo: this.geo,
1848
+ });
1849
+ if (!p) return;
1850
+
1851
+ const kEast = p.scaleEast ?? p.scale;
1852
+ const kNorth = p.scaleNorth ?? p.scale;
1853
+ const kUp = kEast;
1854
+ group.matrixAutoUpdate = false;
1855
+ group.matrix.makeScale(kEast, kNorth, kUp);
1856
+ group.matrix.setPosition(
1857
+ p.position[0] - kEast * p.pivot[0],
1858
+ p.position[1] - kNorth * p.pivot[1],
1859
+ p.position[2] - kUp * p.pivot[2],
1860
+ );
1861
+ group.matrixWorldNeedsUpdate = true;
1862
+ // 重锚点后要能重算:中心只属于注册那一刻,存到 group 上,不依赖 tileset 内部结构
1863
+ (group.userData as { tileCenter?: { x: number; y: number; z: number } }).tileCenter = {
1864
+ x: center.x,
1865
+ y: center.y,
1866
+ z: center.z,
1867
+ };
1868
+ // 逆矩阵缓存:_toCanonicalScene 常态路径无需每次拾取反复求逆,失效点仅在瓦片放置或重锚点
1869
+ const inv = ((group.userData as { placementInverse?: THREE.Matrix4 }).placementInverse ??= new THREE.Matrix4());
1870
+ inv.copy(group.matrix).invert();
1871
+
1872
+ const sSceneEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1873
+ const sSceneNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1874
+ const sSceneUp = sceneAnchor.meterInMercatorUp ?? sSceneEast;
1875
+ const invScale = p.invTileScale ?? [1.0 / (sSceneEast * kEast), 1.0 / (sSceneNorth * kNorth), 1.0 / (sSceneUp * kUp)];
1876
+ this._updateTileUniforms(group, p.pivot, invScale);
1877
+ }
1878
+
1879
+ private _updateTileUniforms(
1880
+ group: THREE.Object3D,
1881
+ pivot: readonly [number, number, number],
1882
+ invTileScale: readonly [number, number, number]
1883
+ ): void {
1884
+ group.traverse((obj) => {
1885
+ const mesh = obj as THREE.Mesh;
1886
+ if (((obj as any).isMesh || (obj as any).isPoints) && (obj as any).material) {
1887
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
1888
+ for (const m of mats) {
1889
+ const uPivot = (m as any).userData?.uTilePivot;
1890
+ const uScale = (m as any).userData?.uInvTileScale;
1891
+ if (uPivot?.value) {
1892
+ uPivot.value.set(pivot[0], pivot[1], pivot[2]);
1893
+ }
1894
+ if (uScale?.value) {
1895
+ uScale.value.set(invTileScale[0], invTileScale[1], invTileScale[2]);
1896
+ }
1897
+ }
1898
+ }
1899
+ });
1900
+ }
1901
+
1902
+ /**
1903
+ * 世界坐标 → **规范**场景 ENU:把瓦片级放置的缩放反解掉。
1904
+ *
1905
+ * `perTilePlacement` 开启后,瓦片几何是「绕自身中心缩放 k 倍、再摆到精确位置」的,
1906
+ * 所以命中点的世界坐标不再等于数据本身的 ENU。拾取要报的是**坐标本身**——
1907
+ * 一个屋顶的经纬度不该因为我们怎么缩放它而改变,否则同一个点在 20 km 外会比
1908
+ * 1 km 内偏出几米,量算、贴地、外部标注全都跟着漂。
1909
+ *
1910
+ * 反解只需要一次矩阵求逆:父级 `entry.root` 只有平移(`layoutDataset` 只写
1911
+ * `position`),所以世界坐标 `p` 对应的瓦片局部坐标是
1912
+ *
1913
+ * ```
1914
+ * v = group.matrix⁻¹ · (p − root.position)
1915
+ * canonical = root.position + v
1916
+ * ```
1917
+ *
1918
+ * 关掉本功能、或网格不在已放置的瓦片里时**原样返回同一个对象**,
1919
+ * 默认路径一次矩阵运算都不做。
1920
+ */
1921
+ private _toCanonicalScene(p: THREE.Vector3, mesh?: THREE.Object3D | null): THREE.Vector3 {
1922
+ if (!this._perTilePlacement || !mesh) return p;
1923
+ // 网格直接挂在瓦片 group 下(`_registerTile` 就是遍历 node.group.children);
1924
+ // 往上找到第一个带 tileCenter 的祖先,避免依赖层级深度
1925
+ let group: THREE.Object3D | null = mesh.parent;
1926
+ while (group && !(group.userData as { tileCenter?: unknown }).tileCenter) group = group.parent;
1927
+ if (!group) return p;
1928
+ const root = group.parent;
1929
+ if (!root) return p;
1930
+
1931
+ return p
1932
+ .clone()
1933
+ .sub(root.position)
1934
+ .applyMatrix4((group.userData as { placementInverse?: THREE.Matrix4 }).placementInverse ?? _tilePlacementInverse.copy(group.matrix).invert())
1935
+ .add(root.position);
1936
+ }
1937
+
1938
+ /**
1939
+ * 场景锚点变了(`recomputeAnchors`)之后,所有已注册瓦片的放置都要重算 ——
1940
+ * `k` 与 `position` 都依赖场景锚点。没存 `tileCenter` 的(未开启本功能时注册的)
1941
+ * 一律跳过,保证默认路径不产生任何副作用。
1942
+ */
1943
+ private _reapplyTilePlacements(): void {
1944
+ for (const entry of this._datasetManager.values()) {
1945
+ if (!entry.anchor) continue;
1946
+ for (const child of entry.root.children) {
1947
+ const c = (child.userData as { tileCenter?: { x: number; y: number; z: number } }).tileCenter;
1948
+ if (c) this._applyTilePlacement(child, c, entry);
1949
+ }
1950
+ }
1951
+ }
1952
+
1717
1953
  /**
1718
1954
  * 新瓦片淡入。
1719
1955
  *
@@ -2178,10 +2414,91 @@ export class S3MViewer {
2178
2414
  }
2179
2415
 
2180
2416
  /** 当前材质质感档位(本图层指定了就用本图层的,否则是模块级默认) */
2417
+ get lodTransition(): 'binary' | 'incremental' {
2418
+ return this._lodTransition;
2419
+ }
2420
+
2421
+ setLodTransition(mode: 'binary' | 'incremental'): void {
2422
+ if (this._lodTransition === mode) return;
2423
+ this._lodTransition = mode;
2424
+ this._datasetManager.setDefaultLodTransition(mode);
2425
+ this.triggerRepaint();
2426
+ }
2427
+
2181
2428
  get materialQuality(): MaterialQuality {
2182
2429
  return this._materialQuality ?? getMaterialQuality();
2183
2430
  }
2184
2431
 
2432
+ /**
2433
+ * 运行时开关运动模糊后处理;`null` / 非法入参 = 关闭并立即释放 RT 显存。
2434
+ *
2435
+ * 这是纯视觉补偿(见 `motionBlur` 选项),对渲染管线唯一的结构性影响是开启期间
2436
+ * 「场景 → 屏幕」改走 RT 中转(合成 quad 会补回色调映射 / sRGB / 深度)。
2437
+ * WebGL1 上开了也自动回退直渲。`damp: 'auto'` 每帧按实时 FPS 重新解析。
2438
+ */
2439
+ setMotionBlur(cfg: MotionBlurOptions | null | undefined): void {
2440
+ this._motionBlurCfg = normalizeMotionBlur(cfg);
2441
+ if (!this._motionBlurCfg) {
2442
+ this._motionBlur?.dispose();
2443
+ this._motionBlur = null;
2444
+ }
2445
+ // 走公共 triggerRepaint:宿主 / 单测会覆盖前者来观察重绘(同其它 setter 的观测点)
2446
+ this.triggerRepaint();
2447
+ }
2448
+
2449
+ /** 当前运动模糊配置(归一化后的副本);null = 关闭 */
2450
+ /**
2451
+ * 运行时修改深度处理策略('scene' | 'clear' | 'auto')。
2452
+ */
2453
+ setDepthMode(mode: DepthMode): void {
2454
+ const next = normalizeDepthMode(mode);
2455
+ if (this._depthMode !== next) {
2456
+ this._depthMode = next;
2457
+ this.triggerRepaint();
2458
+ }
2459
+ }
2460
+
2461
+ /** 当前深度策略('scene' | 'clear' | 'auto',默认 'scene') */
2462
+ get depthMode(): DepthMode {
2463
+ return this._depthMode;
2464
+ }
2465
+
2466
+ /**
2467
+ * 当前帧实际生效的深度处理模式('scene' | 'clear')。
2468
+ * - 'clear': 显式指定 clear,或 auto 模式下命中地面覆盖/倾斜摄影/DEM等地面类数据
2469
+ * - 'scene': 显式指定 scene,或 auto 模式下无地面类数据
2470
+ */
2471
+ resolveDepthMode(): 'scene' | 'clear' {
2472
+ if (this._depthMode === 'clear') return 'clear';
2473
+ if (this._depthMode === 'scene') return 'scene';
2474
+
2475
+ // 'auto': 按数据集与地面覆盖特征推断
2476
+ if (this.groundManager && this.groundManager.mode !== 'none') {
2477
+ return 'clear';
2478
+ }
2479
+
2480
+ for (const entry of this._datasetManager.datasets.values()) {
2481
+ if (!entry.visible) continue;
2482
+ if (entry.options.depthMode === 'clear') return 'clear';
2483
+ if (entry.options.depthMode === 'scene') continue;
2484
+ if (entry.tileset?.isS3MV1) return 'clear';
2485
+ const cfg = entry.tileset?.config as Record<string, unknown> | null | undefined;
2486
+ if (cfg) {
2487
+ const typeStr = `${cfg.fileType ?? ''} ${cfg.dataType ?? ''} ${cfg.datasetType ?? ''} ${cfg.cacheType ?? ''} ${cfg.fileExtentName ?? ''}`;
2488
+ if (/osgb|oblique|ground|dem|tin|terrain/i.test(typeStr)) return 'clear';
2489
+ }
2490
+ if (entry.options.configUrl && /osgb|oblique/i.test(entry.options.configUrl)) {
2491
+ return 'clear';
2492
+ }
2493
+ }
2494
+
2495
+ return 'scene';
2496
+ }
2497
+
2498
+ get motionBlur(): MotionBlurOptions | null {
2499
+ return this._motionBlurCfg ? { ...this._motionBlurCfg } : null;
2500
+ }
2501
+
2185
2502
  /**
2186
2503
  * 运行时切换**本图层**的颜色管线(`'linear'` | `'direct'`)。
2187
2504
  *
@@ -2486,10 +2803,11 @@ export class S3MViewer {
2486
2803
  private _sceneFromLngLat(p: LngLatAlt): Vec3 {
2487
2804
  const anchor = this.sceneAnchor;
2488
2805
  if (!anchor) return [0, 0, 0];
2489
- const s = anchor.meterInMercator;
2806
+ const sEast = anchor.meterInMercatorEast ?? anchor.meterInMercator;
2807
+ const sNorth = anchor.meterInMercatorNorth ?? anchor.meterInMercator;
2490
2808
  const w = worldFromLngLat(this.geo, p.lng, p.lat, p.altitude);
2491
2809
  // 注意 y 取反:three 的 y 向上,墨卡托 y 向下(与 PickService.pickPosition 互逆)
2492
- return [(w.x - anchor.x) / s, -(w.y - anchor.y) / s, p.altitude - (anchor.altitude ?? 0)];
2810
+ return [(w.x - anchor.x) / sEast, -(w.y - anchor.y) / sNorth, p.altitude - (anchor.altitude ?? 0)];
2493
2811
  }
2494
2812
 
2495
2813
  /**
@@ -2512,8 +2830,9 @@ export class S3MViewer {
2512
2830
  private _lngLatFromScene(v: Vec3): LngLatAlt {
2513
2831
  const anchor = this.sceneAnchor;
2514
2832
  if (!anchor) return { lng: 0, lat: 0, altitude: v[2] };
2515
- const s = anchor.meterInMercator;
2516
- const [lng, lat] = lngLatFromWorld(this.geo, v[0] * s + anchor.x, -v[1] * s + anchor.y);
2833
+ const sEast = anchor.meterInMercatorEast ?? anchor.meterInMercator;
2834
+ const sNorth = anchor.meterInMercatorNorth ?? anchor.meterInMercator;
2835
+ const [lng, lat] = lngLatFromWorld(this.geo, v[0] * sEast + anchor.x, -v[1] * sNorth + anchor.y);
2517
2836
  return { lng, lat, altitude: v[2] + (anchor.altitude ?? 0) };
2518
2837
  }
2519
2838
 
@@ -2952,6 +3271,32 @@ export class S3MViewer {
2952
3271
  this._splats.sort(this.camera, this._pointClouds);
2953
3272
  }
2954
3273
 
3274
+ /**
3275
+ * 运动模糊路径(`_motionBlurCfg` 非空时接管「场景 → 屏幕」)。
3276
+ *
3277
+ * 返回 false = 不能用(WebGL1 / 尺寸非法),调用方回退直渲 —— 配置保留不丢,
3278
+ * 上下文恢复成 WebGL2 后下一帧自动生效。
3279
+ * `damp: 'auto'` 在这里按实时 FPS 解析(fps 为 0 的启动阶段按 60fps 保守取值),
3280
+ * `maxOffset` 已在 `normalizeMotionBlur` 里归一化(默认 0.05,屏宽百分比)。
3281
+ */
3282
+ private _renderMotionBlur(width: number, height: number): boolean {
3283
+ const renderer = this.renderer;
3284
+ const scene = this.scene;
3285
+ const camera = this.camera;
3286
+ const cfg = this._motionBlurCfg;
3287
+ if (!renderer || !scene || !camera || !cfg) return false;
3288
+ this._motionBlur ||= new MotionBlurPass();
3289
+ if (!this._motionBlur.supported(renderer)) return false;
3290
+ this._motionBlur.render(renderer, scene, camera, width, height, {
3291
+ mode: cfg.mode,
3292
+ damp: cfg.damp === 'auto' ? autoMotionBlurDamp(this.fps) : cfg.damp,
3293
+ maxOffset: cfg.maxOffset,
3294
+ mvp: this._frameMvp,
3295
+ mvpInverse: camera.projectionMatrixInverse,
3296
+ });
3297
+ return true;
3298
+ }
3299
+
2955
3300
  /**
2956
3301
  * 渲染一帧。所有宿主相关量都由调用方喂进来,viewer 不碰任何地图 API:
2957
3302
  *
@@ -2983,13 +3328,15 @@ export class S3MViewer {
2983
3328
  this.flushOverrides();
2984
3329
 
2985
3330
  const canvas = this.canvas;
2986
- const s = this.sceneAnchor.meterInMercator;
3331
+ const sEast = this.sceneAnchor.meterInMercatorEast ?? this.sceneAnchor.meterInMercator;
3332
+ const sNorth = this.sceneAnchor.meterInMercatorNorth ?? this.sceneAnchor.meterInMercator;
3333
+ const sUp = this.sceneAnchor.meterInMercatorUp ?? sEast;
2987
3334
 
2988
3335
  // ENU → 世界坐标:x 向东,y 向北(墨卡托 y 朝南,所以取负),z 向上
2989
3336
  this._enuMatrix.set(
2990
- s, 0, 0, this.sceneAnchor.x,
2991
- 0, -s, 0, this.sceneAnchor.y,
2992
- 0, 0, s, this.sceneAnchor.z,
3337
+ sEast, 0, 0, this.sceneAnchor.x,
3338
+ 0, -sNorth, 0, this.sceneAnchor.y,
3339
+ 0, 0, sUp, this.sceneAnchor.z,
2993
3340
  0, 0, 0, 1
2994
3341
  );
2995
3342
 
@@ -3047,15 +3394,35 @@ export class S3MViewer {
3047
3394
  setSplatViewport(canvas.width, canvas.height);
3048
3395
 
3049
3396
  this.renderer.setViewport(0, 0, canvas.width, canvas.height);
3397
+ const effectiveDepthMode = (this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS
3398
+ ? this.resolveDepthMode()
3399
+ : 'scene';
3400
+ if (effectiveDepthMode === 'clear' && gl) {
3401
+ // gl.clear 受 depthMask 约束。上一帧若以 depthWrite=false 的材质收尾(棱线、半透明
3402
+ // 地表覆盖、半透明点云、泼溅),GL 的 depthMask 会停在 false —— three 帧末不恢复它,
3403
+ // 而 resetState 排在 clear 之后,救不了这一步。不自己打开就是一次静默的空操作:
3404
+ // 画面上的 z-fighting 原样保留,且没有任何报错可查。
3405
+ gl.depthMask(true);
3406
+ // 只清深度,不清颜色 —— 颜色是 MapLibre 已经画好的底图
3407
+ gl.clear(gl.DEPTH_BUFFER_BIT);
3408
+ }
3409
+ // 清完之后必须让 three 知道状态被外部改过,否则它的状态缓存会认为自己仍然正确
3050
3410
  this.renderer.resetState();
3051
3411
  // 完全透明时什么都不画:透明物体照样写深度,画了会在底图上挖出一个洞。
3052
3412
  // 这里跳过的是「本层的绘制」,宿主自己的清屏 / 底图照旧。
3053
3413
  if ((this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS) {
3054
3414
  this._sortSplats();
3055
- this.renderer.render(this.scene, this.camera);
3415
+ if (this._motionBlurCfg && this._renderMotionBlur(canvas.width, canvas.height)) {
3416
+ // 运动模糊接管了「场景 → 屏幕」:场景进 RT,合成 quad 补回色调映射 / sRGB / 深度
3417
+ } else {
3418
+ this.renderer.render(this.scene, this.camera);
3419
+ }
3056
3420
  // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
3057
3421
  // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
3058
- this._edges.renderGpuEdges(canvas);
3422
+ // clear 模式下宿主深度已清除,描边会穿透地形产生伪影,故只在非 clear 时画。
3423
+ if (effectiveDepthMode !== 'clear') {
3424
+ this._edges.renderGpuEdges(canvas);
3425
+ }
3059
3426
  }
3060
3427
 
3061
3428
  this._frame++;
@@ -446,6 +446,11 @@ export function createSharedUniforms(): StyleUniforms {
446
446
  };
447
447
  }
448
448
  const VERT_PARS = /* glsl */ `
449
+ #ifdef S3M_BAKED_MERCATOR
450
+ attribute vec3 aMercatorOffset;
451
+ uniform vec3 uTilePivot;
452
+ uniform vec3 uInvTileScale;
453
+ #endif
449
454
  attribute float aBatchId;
450
455
  // vec4:alpha 是「材质不透明、顶点半透明」这类数据的唯一透明度来源(官方 baseColorWithAlpha = vColor)
451
456
  attribute vec4 aColor;
@@ -624,6 +629,13 @@ const VERT_SETUP = /* glsl */ `
624
629
  `;
625
630
 
626
631
  const VERT_ELEVATION = /* glsl */ `
632
+ #ifdef S3M_BAKED_MERCATOR
633
+ transformed = uTilePivot + vec3(
634
+ aMercatorOffset.x * uInvTileScale.x,
635
+ -aMercatorOffset.y * uInvTileScale.y,
636
+ aMercatorOffset.z * uInvTileScale.z
637
+ );
638
+ #endif
627
639
  float s3mBatchElev = s3mBatchRaw();
628
640
  if ( uBatchTexActive > 0 && s3mBatchElev >= 0.0 ) {
629
641
  float s3mU = ( s3mBatchElev + 0.5 ) / uBatchTexWidth;
@@ -1142,6 +1154,11 @@ export interface StyledMaterialOptions {
1142
1154
  * 与 `materialQuality` 同一套:图层把自己的 profile 透传进来,同页互不污染。
1143
1155
  */
1144
1156
  colorPipeline?: ColorPipeline;
1157
+ bakeMercator?: boolean;
1158
+ /** 瓦片变换中心在 object space 的位置,供烘焙着色器恢复绝对位置 */
1159
+ tilePivot?: THREE.Vector3;
1160
+ /** 逐瓦片逆尺度 (1 / sTileEast, 1 / sTileNorth, 1 / sTileUp),供烘焙着色器抵消各向异性缩放 */
1161
+ invTileScale?: THREE.Vector3;
1145
1162
  }
1146
1163
 
1147
1164
  /**
@@ -1246,12 +1263,19 @@ export function createStyledMaterial(opts: StyledMaterialOptions): StyledMateria
1246
1263
  // supermap 完整版:官方 sRGB 直乘管线(配合 setSrgbTextureDecode(false))
1247
1264
  const direct = (opts.colorPipeline ?? colorPipeline) === 'direct';
1248
1265
  if (direct) mat.defines = { ...mat.defines, S3M_DIRECT_COLOR: '' };
1266
+ const bakeMercator = !!opts.bakeMercator;
1267
+ if (bakeMercator) mat.defines = { ...(mat.defines as Record<string, string>), S3M_BAKED_MERCATOR: '' };
1249
1268
  // 官方 uTextureDim = (base宽, base高, bake宽, bake高),取实际绑定纹理的像素尺寸
1250
1269
  const uAtlasDim = { value: new THREE.Vector2(opts.map?.image?.width || 1, opts.map?.image?.height || 1) };
1251
1270
  const uAtlasDim1 = { value: new THREE.Vector2(secMap?.image?.width || 1, secMap?.image?.height || 1) };
1252
1271
 
1272
+ const uTilePivot = { value: opts.tilePivot ? opts.tilePivot.clone() : new THREE.Vector3(0, 0, 0) };
1273
+ const uInvTileScale = { value: opts.invTileScale ? opts.invTileScale.clone() : new THREE.Vector3(1, 1, 1) };
1274
+
1253
1275
  mat.onBeforeCompile = (shader) => {
1254
1276
  Object.assign(shader.uniforms, uniforms, {
1277
+ uTilePivot,
1278
+ uInvTileScale,
1255
1279
  uSelBatch,
1256
1280
  uSelWhole,
1257
1281
  uFlatShading,
@@ -1349,6 +1373,8 @@ export function createStyledMaterial(opts: StyledMaterialOptions): StyledMateria
1349
1373
  mat.userData.uTileFade = uTileFade;
1350
1374
  // 平面 / 平滑着色开关:S3MViewer._applyShading 翻它,不触发重编译
1351
1375
  mat.userData.uFlatShading = uFlatShading;
1376
+ mat.userData.uTilePivot = uTilePivot;
1377
+ mat.userData.uInvTileScale = uInvTileScale;
1352
1378
  // 记录创建参数:materialQuality 运行时切换时按原参数重建材质(换类不换外观)
1353
1379
  mat.userData.s3mOpts = opts;
1354
1380
  // 记录实际生效的档位:重建 / 排查时不用再猜当时读的是哪一层设置