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
@@ -35,6 +35,7 @@ import { decodeTextureWithWorker } from '../worker/S3MWorkerClient.js';
35
35
  import { STYLE, STYLE_LABELS, SELECT_MODE, DEFAULT_STYLE, createSharedUniforms, createStyledMaterial, createOutlineMaterial, restoreBaseBlendState, } from './StyleMaterial.js';
36
36
  import { applyPointCloudStyle, isOwnedPointCloudMaterial } from './PointCloudMaterial.js';
37
37
  import { Picker } from './Picker.js';
38
+ import { MotionBlurPass, normalizeMotionBlur, autoMotionBlurDamp } from './MotionBlurPass.js';
38
39
  import { Animator } from '../anim/Animator.js';
39
40
  import { buildClipPlanes, lineOfSight as computeLineOfSight, measureSegment, polygonArea, polygonAreaHorizontal, polygonPerimeter, polylineLength, polylineLengthHorizontal, } from '../analysis/index.js';
40
41
  import { skylineFromDepth } from '../analysis/skyline.js';
@@ -44,10 +45,19 @@ import { Path } from '../anim/Path.js';
44
45
  import { GPUPathManager } from './GPUPathManager.js';
45
46
  import { AttributeQuery } from '../core/AttributeQuery.js';
46
47
  import { lngLatFromWorld, worldFromLngLat } from './GeoAdapter.js';
48
+ import { computeTilePlacement } from './tilePlacement.js';
47
49
  import { DatasetManager, } from './DatasetManager.js';
48
50
  import { FeatureStyleManager, extractEulerAngles, } from './FeatureStyleManager.js';
49
51
  import { GroundManager, } from './GroundManager.js';
50
52
  export { GroundManager, DatasetManager, FeatureStyleManager };
53
+ /** 稳健归一化 depthMode 入参,默认 'scene' */
54
+ export function normalizeDepthMode(mode) {
55
+ if (mode === 'clear')
56
+ return 'clear';
57
+ if (mode === 'auto')
58
+ return 'auto';
59
+ return 'scene';
60
+ }
51
61
  function styledMaterialOf(mesh) {
52
62
  const m = Array.isArray(mesh.material) ? mesh.material[0] : mesh.material;
53
63
  const styled = m;
@@ -70,6 +80,11 @@ function clampOpacity(v) {
70
80
  }
71
81
  /** 低于这个透明度就当作完全不可见:跳过绘制,避免全透明物体写深度 */
72
82
  const OPACITY_EPS = 1e-3;
83
+ /**
84
+ * 拾取反解瓦片级放置时的临时矩阵。命中点→规范 ENU 是一次独立查询(鼠标事件),
85
+ * 复用一个实例避免每次分配;不可重入(`_toCanonicalScene` 内部不递归调用)。
86
+ */
87
+ const _tilePlacementInverse = new THREE.Matrix4();
73
88
  /* ------------------------------------------------------------------ 可动画的样式键 */
74
89
  /**
75
90
  * 能逐帧插值的标量样式键。
@@ -152,6 +167,8 @@ export class S3MViewer {
152
167
  * 传了值就只认自己,多图层同页各用各的,互不覆盖。
153
168
  */
154
169
  _materialQuality;
170
+ _lodTransition;
171
+ _fallbackOffset;
155
172
  /**
156
173
  * 本图层的颜色管线。
157
174
  *
@@ -180,6 +197,7 @@ export class S3MViewer {
180
197
  }
181
198
  set sceneAnchor(val) {
182
199
  this._datasetManager.sceneAnchor = val;
200
+ this._reapplyTilePlacements();
183
201
  }
184
202
  /** 拾取表 / pickId 池 / 射线求交(见 viewer/PickService.ts) */
185
203
  _picks;
@@ -241,6 +259,19 @@ export class S3MViewer {
241
259
  _unsupported;
242
260
  /** LOD 判定屏幕高度的 DPR 上限。Infinity = 不截断(默认,行为同旧版) */
243
261
  _lodPixelRatioCap = Infinity;
262
+ /**
263
+ * 运动模糊配置(默认 null = 关闭)。归一化后的形状见 `normalizeMotionBlur`;
264
+ * pass 延迟到首帧才建(构造期 renderer 都还没有)。
265
+ */
266
+ _motionBlurCfg = null;
267
+ _motionBlur = null;
268
+ /**
269
+ * 瓦片级放置(默认关)。关时 `_registerTile` 完全不碰瓦片矩阵,代码路径与改动前
270
+ * 逐字一致;开时每块瓦片用自己的中心纬度算尺度,见 `tilePlacement.ts`。
271
+ */
272
+ _perTilePlacement;
273
+ /** 本层绘制前深度缓冲的处理策略:'scene' | 'clear' | 'auto',默认 'scene' */
274
+ _depthMode = 'scene';
244
275
  /** 宿主请求重画(MapLibre 传 triggerRepaint,纯 three 宿主传自己的渲染循环) */
245
276
  _requestRepaint() {
246
277
  this._onRequestRepaint();
@@ -254,12 +285,23 @@ export class S3MViewer {
254
285
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
255
286
  this._unsupported = options.unsupported ?? 'warn';
256
287
  this._lodPixelRatioCap = options.lodPixelRatioCap ?? Infinity;
288
+ // 瓦片级放置默认开启:旧行为是拿单一常量切平面拟合随纬度变化的墨卡托比例,属于 bug 修复,
289
+ // 所以默认值跟着改(见 AGENTS.md 不变式 ①)。显式传 false 仍可回退。
290
+ this._perTilePlacement = options.perTilePlacement !== false;
291
+ this._depthMode = normalizeDepthMode(options.depthMode);
257
292
  setTextureSizeCap(options.maxTextureSize ?? Infinity);
293
+ // 运动模糊:默认关闭;开了也只在 renderFrame 里按需接管「场景→屏幕」这一步
294
+ this._motionBlurCfg = normalizeMotionBlur(options.motionBlur);
258
295
  // 只记本层的档位,**不写模块级全局**:以前这里 setMaterialQuality(...) 会把全局
259
296
  // 改掉,于是同页后创建的图层顺手把先创建的图层也打回去了(多层互相覆盖的根因)。
260
297
  this._materialQuality = normalizeMaterialQuality(options.materialQuality);
261
298
  // 之后 addDataset 的数据集按这个默认建材质(数据集自己指定则用自己的)
262
299
  this._datasetManager.defaultMaterialQuality = this._materialQuality;
300
+ this._lodTransition = options.lodTransition ?? 'binary';
301
+ this._fallbackOffset = options.fallbackOffset;
302
+ this._datasetManager.defaultLodTransition = this._lodTransition;
303
+ this._datasetManager.defaultFallbackOffset = this._fallbackOffset;
304
+ this._datasetManager.defaultBakeMercator = options.bakeMercator;
263
305
  if (options.totalMaxTiles) {
264
306
  this._datasetManager.totalMaxTiles = options.totalMaxTiles;
265
307
  }
@@ -334,6 +376,7 @@ export class S3MViewer {
334
376
  return self._smidMeshes;
335
377
  },
336
378
  lngLatFromWorld: (x, y) => self._lngLatFromWorld(x, y),
379
+ toCanonicalScene: (p, mesh) => self._toCanonicalScene(p, mesh),
337
380
  gpuPathPoseAt: (smid) => self._gpuPathPoseAt(smid),
338
381
  isFeatureHidden: (smid, batchId) => self._isFeatureHidden(smid, batchId),
339
382
  });
@@ -355,9 +398,18 @@ export class S3MViewer {
355
398
  styleTarget: this._styleTarget,
356
399
  animator: this.animator,
357
400
  pickables: this._picks.pickables,
358
- scene: this.scene,
359
- renderer: this.renderer,
360
- camera: this.camera,
401
+ // mount() / restoreContext() 都会换掉 scene / renderer / camera,这里必须用
402
+ // getter 拿「当下」的实例 —— 拆分 EdgeDecorator 时曾误写成值快照,mount 之后
403
+ // host.renderer 恒为 null,GPU 描边(和 loadCreases 挂线)静默失效
404
+ get scene() {
405
+ return self.scene;
406
+ },
407
+ get renderer() {
408
+ return self.renderer;
409
+ },
410
+ get camera() {
411
+ return self.camera;
412
+ },
361
413
  authenticate: this._authenticate,
362
414
  requestRepaint: () => this.triggerRepaint(),
363
415
  });
@@ -482,6 +534,8 @@ export class S3MViewer {
482
534
  this._lights.dispose();
483
535
  this.outlineMaterial.dispose();
484
536
  this._edges.disposeGpuEdge();
537
+ this._motionBlur?.dispose();
538
+ this._motionBlur = null;
485
539
  this.edgeMaterial.dispose();
486
540
  this.selectionEdgeMaterial.dispose();
487
541
  this.scene?.clear();
@@ -553,7 +607,15 @@ export class S3MViewer {
553
607
  .filter((d) => !!d?.configUrl)
554
608
  .map((d) => ({ ...d, polygonOffset: d.polygonOffset ?? options.polygonOffset }));
555
609
  if (options.configUrl)
556
- return [{ id: 'default', name: '默认数据集', configUrl: options.configUrl, useWorker: options.useWorker, polygonOffset: options.polygonOffset }];
610
+ return [{
611
+ id: 'default',
612
+ name: '默认数据集',
613
+ configUrl: options.configUrl,
614
+ useWorker: options.useWorker,
615
+ polygonOffset: options.polygonOffset,
616
+ lodTransition: options.lodTransition,
617
+ fallbackOffset: options.fallbackOffset,
618
+ }];
557
619
  return [];
558
620
  }
559
621
  /** 追加一个数据集。图层 addLayer 之前调用会抛错。 */
@@ -561,7 +623,12 @@ export class S3MViewer {
561
623
  if (!this.scene)
562
624
  throw new Error('[s3m] 图层尚未 addLayer,不能添加数据集');
563
625
  // 图层级默认值:数据集自己指定了就用自己的,见 `S3MViewerOptions.unsupported`
564
- const merged = { ...ds, unsupported: ds.unsupported ?? this._unsupported };
626
+ const merged = {
627
+ ...ds,
628
+ unsupported: ds.unsupported ?? this._unsupported,
629
+ lodTransition: ds.lodTransition ?? this._lodTransition,
630
+ fallbackOffset: ds.fallbackOffset ?? this._fallbackOffset,
631
+ };
565
632
  const entry = await this._datasetManager.addDataset(merged, this.scene, this.geo, this.uniforms, this._lodRangeScale, this.options.buildTile, this.options.maxTiles ?? 64, this.options.maxConcurrent ?? 4, {
566
633
  onTile: (node, e) => {
567
634
  this._registerTile(node, e);
@@ -603,6 +670,8 @@ export class S3MViewer {
603
670
  /** 当地图 CRS 投影切换或宿主 map 挂载后,重新计算场景锚点与各数据集 offset */
604
671
  recomputeAnchors() {
605
672
  this._datasetManager.recomputeAnchors(this.geo);
673
+ // 锚点变了 → 每块瓦片的 k 与 position 都要重算(关这项功能时是空转,一次遍历都不做)
674
+ this._reapplyTilePlacements();
606
675
  this._heights.refreshAll();
607
676
  this._requestRepaint();
608
677
  }
@@ -1338,6 +1407,20 @@ export class S3MViewer {
1338
1407
  }
1339
1408
  /* ---------------------------------------------------------------- 瓦片注册 */
1340
1409
  _registerTile(node, entry) {
1410
+ // 必须在 add / updateMatrixWorld 之前设置:后者会拿当前 matrix 算 matrixWorld 并缓存
1411
+ if (this._perTilePlacement && node.sphere) {
1412
+ this._applyTilePlacement(node.group, node.sphere.center, entry);
1413
+ }
1414
+ else {
1415
+ const sceneAnchor = this.sceneAnchor;
1416
+ if (sceneAnchor) {
1417
+ const sEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1418
+ const sNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1419
+ const sUp = sceneAnchor.meterInMercatorUp ?? sEast;
1420
+ const center = node.sphere ? [node.sphere.center.x, node.sphere.center.y, node.sphere.center.z] : [0, 0, 0];
1421
+ this._updateTileUniforms(node.group, center, [1.0 / sEast, 1.0 / sNorth, 1.0 / sUp]);
1422
+ }
1423
+ }
1341
1424
  entry.root.add(node.group);
1342
1425
  node.group.updateMatrixWorld(true);
1343
1426
  const hasNativeEdges = node.group.userData.hasNativeEdges === true;
@@ -1407,6 +1490,124 @@ export class S3MViewer {
1407
1490
  this._sel.replayOnto(mesh);
1408
1491
  }
1409
1492
  }
1493
+ /**
1494
+ * 把一块瓦片摆到「用它自己中心纬度算出的尺度」上(`perTilePlacement` 开启时才走)。
1495
+ *
1496
+ * 矩阵是 `T(position) · S(k) · T(-pivot)`,展开后即 `v ↦ position + k·(v - pivot)`。
1497
+ * 直接用 `makeScale + setPosition` 一次写好,省掉两个临时矩阵:
1498
+ * `T(t)·S(k)·T(-c)` 的元素其实就是对角 `k` 加平移 `t - k·c`。
1499
+ *
1500
+ * 只写 `matrix`,不碰 `rotation / position / scale` —— three 在
1501
+ * `matrixAutoUpdate = false` 时直接使用 `matrix`,那些字段留着无害,
1502
+ * 但别让 `updateMatrix()` 有机会覆盖回来。
1503
+ */
1504
+ _applyTilePlacement(group, center, entry) {
1505
+ const sceneAnchor = this.sceneAnchor;
1506
+ if (!sceneAnchor || !entry.anchor)
1507
+ return;
1508
+ const p = computeTilePlacement({
1509
+ center,
1510
+ datasetAnchor: entry.anchor,
1511
+ sceneAnchor,
1512
+ datasetOffset: entry.offset,
1513
+ geo: this.geo,
1514
+ });
1515
+ if (!p)
1516
+ return;
1517
+ const kEast = p.scaleEast ?? p.scale;
1518
+ const kNorth = p.scaleNorth ?? p.scale;
1519
+ const kUp = kEast;
1520
+ group.matrixAutoUpdate = false;
1521
+ group.matrix.makeScale(kEast, kNorth, kUp);
1522
+ group.matrix.setPosition(p.position[0] - kEast * p.pivot[0], p.position[1] - kNorth * p.pivot[1], p.position[2] - kUp * p.pivot[2]);
1523
+ group.matrixWorldNeedsUpdate = true;
1524
+ // 重锚点后要能重算:中心只属于注册那一刻,存到 group 上,不依赖 tileset 内部结构
1525
+ group.userData.tileCenter = {
1526
+ x: center.x,
1527
+ y: center.y,
1528
+ z: center.z,
1529
+ };
1530
+ // 逆矩阵缓存:_toCanonicalScene 常态路径无需每次拾取反复求逆,失效点仅在瓦片放置或重锚点
1531
+ const inv = (group.userData.placementInverse ??= new THREE.Matrix4());
1532
+ inv.copy(group.matrix).invert();
1533
+ const sSceneEast = sceneAnchor.meterInMercatorEast ?? sceneAnchor.meterInMercator;
1534
+ const sSceneNorth = sceneAnchor.meterInMercatorNorth ?? sceneAnchor.meterInMercator;
1535
+ const sSceneUp = sceneAnchor.meterInMercatorUp ?? sSceneEast;
1536
+ const invScale = p.invTileScale ?? [1.0 / (sSceneEast * kEast), 1.0 / (sSceneNorth * kNorth), 1.0 / (sSceneUp * kUp)];
1537
+ this._updateTileUniforms(group, p.pivot, invScale);
1538
+ }
1539
+ _updateTileUniforms(group, pivot, invTileScale) {
1540
+ group.traverse((obj) => {
1541
+ const mesh = obj;
1542
+ if ((obj.isMesh || obj.isPoints) && obj.material) {
1543
+ const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
1544
+ for (const m of mats) {
1545
+ const uPivot = m.userData?.uTilePivot;
1546
+ const uScale = m.userData?.uInvTileScale;
1547
+ if (uPivot?.value) {
1548
+ uPivot.value.set(pivot[0], pivot[1], pivot[2]);
1549
+ }
1550
+ if (uScale?.value) {
1551
+ uScale.value.set(invTileScale[0], invTileScale[1], invTileScale[2]);
1552
+ }
1553
+ }
1554
+ }
1555
+ });
1556
+ }
1557
+ /**
1558
+ * 世界坐标 → **规范**场景 ENU:把瓦片级放置的缩放反解掉。
1559
+ *
1560
+ * `perTilePlacement` 开启后,瓦片几何是「绕自身中心缩放 k 倍、再摆到精确位置」的,
1561
+ * 所以命中点的世界坐标不再等于数据本身的 ENU。拾取要报的是**坐标本身**——
1562
+ * 一个屋顶的经纬度不该因为我们怎么缩放它而改变,否则同一个点在 20 km 外会比
1563
+ * 1 km 内偏出几米,量算、贴地、外部标注全都跟着漂。
1564
+ *
1565
+ * 反解只需要一次矩阵求逆:父级 `entry.root` 只有平移(`layoutDataset` 只写
1566
+ * `position`),所以世界坐标 `p` 对应的瓦片局部坐标是
1567
+ *
1568
+ * ```
1569
+ * v = group.matrix⁻¹ · (p − root.position)
1570
+ * canonical = root.position + v
1571
+ * ```
1572
+ *
1573
+ * 关掉本功能、或网格不在已放置的瓦片里时**原样返回同一个对象**,
1574
+ * 默认路径一次矩阵运算都不做。
1575
+ */
1576
+ _toCanonicalScene(p, mesh) {
1577
+ if (!this._perTilePlacement || !mesh)
1578
+ return p;
1579
+ // 网格直接挂在瓦片 group 下(`_registerTile` 就是遍历 node.group.children);
1580
+ // 往上找到第一个带 tileCenter 的祖先,避免依赖层级深度
1581
+ let group = mesh.parent;
1582
+ while (group && !group.userData.tileCenter)
1583
+ group = group.parent;
1584
+ if (!group)
1585
+ return p;
1586
+ const root = group.parent;
1587
+ if (!root)
1588
+ return p;
1589
+ return p
1590
+ .clone()
1591
+ .sub(root.position)
1592
+ .applyMatrix4(group.userData.placementInverse ?? _tilePlacementInverse.copy(group.matrix).invert())
1593
+ .add(root.position);
1594
+ }
1595
+ /**
1596
+ * 场景锚点变了(`recomputeAnchors`)之后,所有已注册瓦片的放置都要重算 ——
1597
+ * `k` 与 `position` 都依赖场景锚点。没存 `tileCenter` 的(未开启本功能时注册的)
1598
+ * 一律跳过,保证默认路径不产生任何副作用。
1599
+ */
1600
+ _reapplyTilePlacements() {
1601
+ for (const entry of this._datasetManager.values()) {
1602
+ if (!entry.anchor)
1603
+ continue;
1604
+ for (const child of entry.root.children) {
1605
+ const c = child.userData.tileCenter;
1606
+ if (c)
1607
+ this._applyTilePlacement(child, c, entry);
1608
+ }
1609
+ }
1610
+ }
1410
1611
  /**
1411
1612
  * 新瓦片淡入。
1412
1613
  *
@@ -1861,9 +2062,88 @@ export class S3MViewer {
1861
2062
  this._requestRepaint();
1862
2063
  }
1863
2064
  /** 当前材质质感档位(本图层指定了就用本图层的,否则是模块级默认) */
2065
+ get lodTransition() {
2066
+ return this._lodTransition;
2067
+ }
2068
+ setLodTransition(mode) {
2069
+ if (this._lodTransition === mode)
2070
+ return;
2071
+ this._lodTransition = mode;
2072
+ this._datasetManager.setDefaultLodTransition(mode);
2073
+ this.triggerRepaint();
2074
+ }
1864
2075
  get materialQuality() {
1865
2076
  return this._materialQuality ?? getMaterialQuality();
1866
2077
  }
2078
+ /**
2079
+ * 运行时开关运动模糊后处理;`null` / 非法入参 = 关闭并立即释放 RT 显存。
2080
+ *
2081
+ * 这是纯视觉补偿(见 `motionBlur` 选项),对渲染管线唯一的结构性影响是开启期间
2082
+ * 「场景 → 屏幕」改走 RT 中转(合成 quad 会补回色调映射 / sRGB / 深度)。
2083
+ * WebGL1 上开了也自动回退直渲。`damp: 'auto'` 每帧按实时 FPS 重新解析。
2084
+ */
2085
+ setMotionBlur(cfg) {
2086
+ this._motionBlurCfg = normalizeMotionBlur(cfg);
2087
+ if (!this._motionBlurCfg) {
2088
+ this._motionBlur?.dispose();
2089
+ this._motionBlur = null;
2090
+ }
2091
+ // 走公共 triggerRepaint:宿主 / 单测会覆盖前者来观察重绘(同其它 setter 的观测点)
2092
+ this.triggerRepaint();
2093
+ }
2094
+ /** 当前运动模糊配置(归一化后的副本);null = 关闭 */
2095
+ /**
2096
+ * 运行时修改深度处理策略('scene' | 'clear' | 'auto')。
2097
+ */
2098
+ setDepthMode(mode) {
2099
+ const next = normalizeDepthMode(mode);
2100
+ if (this._depthMode !== next) {
2101
+ this._depthMode = next;
2102
+ this.triggerRepaint();
2103
+ }
2104
+ }
2105
+ /** 当前深度策略('scene' | 'clear' | 'auto',默认 'scene') */
2106
+ get depthMode() {
2107
+ return this._depthMode;
2108
+ }
2109
+ /**
2110
+ * 当前帧实际生效的深度处理模式('scene' | 'clear')。
2111
+ * - 'clear': 显式指定 clear,或 auto 模式下命中地面覆盖/倾斜摄影/DEM等地面类数据
2112
+ * - 'scene': 显式指定 scene,或 auto 模式下无地面类数据
2113
+ */
2114
+ resolveDepthMode() {
2115
+ if (this._depthMode === 'clear')
2116
+ return 'clear';
2117
+ if (this._depthMode === 'scene')
2118
+ return 'scene';
2119
+ // 'auto': 按数据集与地面覆盖特征推断
2120
+ if (this.groundManager && this.groundManager.mode !== 'none') {
2121
+ return 'clear';
2122
+ }
2123
+ for (const entry of this._datasetManager.datasets.values()) {
2124
+ if (!entry.visible)
2125
+ continue;
2126
+ if (entry.options.depthMode === 'clear')
2127
+ return 'clear';
2128
+ if (entry.options.depthMode === 'scene')
2129
+ continue;
2130
+ if (entry.tileset?.isS3MV1)
2131
+ return 'clear';
2132
+ const cfg = entry.tileset?.config;
2133
+ if (cfg) {
2134
+ const typeStr = `${cfg.fileType ?? ''} ${cfg.dataType ?? ''} ${cfg.datasetType ?? ''} ${cfg.cacheType ?? ''} ${cfg.fileExtentName ?? ''}`;
2135
+ if (/osgb|oblique|ground|dem|tin|terrain/i.test(typeStr))
2136
+ return 'clear';
2137
+ }
2138
+ if (entry.options.configUrl && /osgb|oblique/i.test(entry.options.configUrl)) {
2139
+ return 'clear';
2140
+ }
2141
+ }
2142
+ return 'scene';
2143
+ }
2144
+ get motionBlur() {
2145
+ return this._motionBlurCfg ? { ...this._motionBlurCfg } : null;
2146
+ }
1867
2147
  /**
1868
2148
  * 运行时切换**本图层**的颜色管线(`'linear'` | `'direct'`)。
1869
2149
  *
@@ -2154,10 +2434,11 @@ export class S3MViewer {
2154
2434
  const anchor = this.sceneAnchor;
2155
2435
  if (!anchor)
2156
2436
  return [0, 0, 0];
2157
- const s = anchor.meterInMercator;
2437
+ const sEast = anchor.meterInMercatorEast ?? anchor.meterInMercator;
2438
+ const sNorth = anchor.meterInMercatorNorth ?? anchor.meterInMercator;
2158
2439
  const w = worldFromLngLat(this.geo, p.lng, p.lat, p.altitude);
2159
2440
  // 注意 y 取反:three 的 y 向上,墨卡托 y 向下(与 PickService.pickPosition 互逆)
2160
- return [(w.x - anchor.x) / s, -(w.y - anchor.y) / s, p.altitude - (anchor.altitude ?? 0)];
2441
+ return [(w.x - anchor.x) / sEast, -(w.y - anchor.y) / sNorth, p.altitude - (anchor.altitude ?? 0)];
2161
2442
  }
2162
2443
  /**
2163
2444
  * 经纬度 + 绝对高程 → 场景 ENU 米制坐标。
@@ -2178,8 +2459,9 @@ export class S3MViewer {
2178
2459
  const anchor = this.sceneAnchor;
2179
2460
  if (!anchor)
2180
2461
  return { lng: 0, lat: 0, altitude: v[2] };
2181
- const s = anchor.meterInMercator;
2182
- const [lng, lat] = lngLatFromWorld(this.geo, v[0] * s + anchor.x, -v[1] * s + anchor.y);
2462
+ const sEast = anchor.meterInMercatorEast ?? anchor.meterInMercator;
2463
+ const sNorth = anchor.meterInMercatorNorth ?? anchor.meterInMercator;
2464
+ const [lng, lat] = lngLatFromWorld(this.geo, v[0] * sEast + anchor.x, -v[1] * sNorth + anchor.y);
2183
2465
  return { lng, lat, altitude: v[2] + (anchor.altitude ?? 0) };
2184
2466
  }
2185
2467
  /**
@@ -2553,6 +2835,33 @@ export class S3MViewer {
2553
2835
  _sortSplats() {
2554
2836
  this._splats.sort(this.camera, this._pointClouds);
2555
2837
  }
2838
+ /**
2839
+ * 运动模糊路径(`_motionBlurCfg` 非空时接管「场景 → 屏幕」)。
2840
+ *
2841
+ * 返回 false = 不能用(WebGL1 / 尺寸非法),调用方回退直渲 —— 配置保留不丢,
2842
+ * 上下文恢复成 WebGL2 后下一帧自动生效。
2843
+ * `damp: 'auto'` 在这里按实时 FPS 解析(fps 为 0 的启动阶段按 60fps 保守取值),
2844
+ * `maxOffset` 已在 `normalizeMotionBlur` 里归一化(默认 0.05,屏宽百分比)。
2845
+ */
2846
+ _renderMotionBlur(width, height) {
2847
+ const renderer = this.renderer;
2848
+ const scene = this.scene;
2849
+ const camera = this.camera;
2850
+ const cfg = this._motionBlurCfg;
2851
+ if (!renderer || !scene || !camera || !cfg)
2852
+ return false;
2853
+ this._motionBlur ||= new MotionBlurPass();
2854
+ if (!this._motionBlur.supported(renderer))
2855
+ return false;
2856
+ this._motionBlur.render(renderer, scene, camera, width, height, {
2857
+ mode: cfg.mode,
2858
+ damp: cfg.damp === 'auto' ? autoMotionBlurDamp(this.fps) : cfg.damp,
2859
+ maxOffset: cfg.maxOffset,
2860
+ mvp: this._frameMvp,
2861
+ mvpInverse: camera.projectionMatrixInverse,
2862
+ });
2863
+ return true;
2864
+ }
2556
2865
  /**
2557
2866
  * 渲染一帧。所有宿主相关量都由调用方喂进来,viewer 不碰任何地图 API:
2558
2867
  *
@@ -2578,9 +2887,11 @@ export class S3MViewer {
2578
2887
  // 脏标记正常由微任务刷掉;这里再兜一次底,保证「本帧画出来的」一定是最新样式
2579
2888
  this.flushOverrides();
2580
2889
  const canvas = this.canvas;
2581
- const s = this.sceneAnchor.meterInMercator;
2890
+ const sEast = this.sceneAnchor.meterInMercatorEast ?? this.sceneAnchor.meterInMercator;
2891
+ const sNorth = this.sceneAnchor.meterInMercatorNorth ?? this.sceneAnchor.meterInMercator;
2892
+ const sUp = this.sceneAnchor.meterInMercatorUp ?? sEast;
2582
2893
  // ENU → 世界坐标:x 向东,y 向北(墨卡托 y 朝南,所以取负),z 向上
2583
- this._enuMatrix.set(s, 0, 0, this.sceneAnchor.x, 0, -s, 0, this.sceneAnchor.y, 0, 0, s, this.sceneAnchor.z, 0, 0, 0, 1);
2894
+ this._enuMatrix.set(sEast, 0, 0, this.sceneAnchor.x, 0, -sNorth, 0, this.sceneAnchor.y, 0, 0, sUp, this.sceneAnchor.z, 0, 0, 0, 1);
2584
2895
  // 正交模式由宿主在传入 mvp 前自行替换(见 s3m-maplibre 的 S3MLayer._applyOrtho),
2585
2896
  // 视图矩阵是从宿主输入反推的(P⁻¹×MVP),不会和宿主的 pan/zoom/pitch 错位。
2586
2897
  const mvp = this._frameMvp.fromArray(opts.mvp);
@@ -2627,15 +2938,36 @@ export class S3MViewer {
2627
2938
  // 泼溅的最小像素半径 / 抗锯齿低通同样要把 ndc 换算成像素
2628
2939
  setSplatViewport(canvas.width, canvas.height);
2629
2940
  this.renderer.setViewport(0, 0, canvas.width, canvas.height);
2941
+ const effectiveDepthMode = (this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS
2942
+ ? this.resolveDepthMode()
2943
+ : 'scene';
2944
+ if (effectiveDepthMode === 'clear' && gl) {
2945
+ // gl.clear 受 depthMask 约束。上一帧若以 depthWrite=false 的材质收尾(棱线、半透明
2946
+ // 地表覆盖、半透明点云、泼溅),GL 的 depthMask 会停在 false —— three 帧末不恢复它,
2947
+ // 而 resetState 排在 clear 之后,救不了这一步。不自己打开就是一次静默的空操作:
2948
+ // 画面上的 z-fighting 原样保留,且没有任何报错可查。
2949
+ gl.depthMask(true);
2950
+ // 只清深度,不清颜色 —— 颜色是 MapLibre 已经画好的底图
2951
+ gl.clear(gl.DEPTH_BUFFER_BIT);
2952
+ }
2953
+ // 清完之后必须让 three 知道状态被外部改过,否则它的状态缓存会认为自己仍然正确
2630
2954
  this.renderer.resetState();
2631
2955
  // 完全透明时什么都不画:透明物体照样写深度,画了会在底图上挖出一个洞。
2632
2956
  // 这里跳过的是「本层的绘制」,宿主自己的清屏 / 底图照旧。
2633
2957
  if ((this.uniforms.uOpacity?.value ?? 1) > OPACITY_EPS) {
2634
2958
  this._sortSplats();
2635
- this.renderer.render(this.scene, this.camera);
2959
+ if (this._motionBlurCfg && this._renderMotionBlur(canvas.width, canvas.height)) {
2960
+ // 运动模糊接管了「场景 → 屏幕」:场景进 RT,合成 quad 补回色调映射 / sRGB / 深度
2961
+ }
2962
+ else {
2963
+ this.renderer.render(this.scene, this.camera);
2964
+ }
2636
2965
  // GPU 描边必须画在主场景之后:它的全屏 quad 参与宿主深度测试,
2637
2966
  // 依赖本帧已经写好的深度(被地形挡住的地方不该描边)
2638
- this._edges.renderGpuEdges(canvas);
2967
+ // clear 模式下宿主深度已清除,描边会穿透地形产生伪影,故只在非 clear 时画。
2968
+ if (effectiveDepthMode !== 'clear') {
2969
+ this._edges.renderGpuEdges(canvas);
2970
+ }
2639
2971
  }
2640
2972
  this._frame++;
2641
2973
  const now = performance.now();