s3m-three 0.4.26 → 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 (71) hide show
  1. package/dist/browser/assets/{s3mWorker-DHOVIrAU.js → s3mWorker-BfSBNvF5.js} +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +52 -40
  3. package/dist/browser/s3m-three.iife.js +52 -40
  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/TileGeometry.d.ts +7 -0
  9. package/dist/core/TileGeometry.d.ts.map +1 -1
  10. package/dist/core/TileGeometry.js +20 -0
  11. package/dist/core/TileGeometry.js.map +1 -1
  12. package/dist/index.d.ts +4 -3
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/render/DatasetManager.d.ts +27 -0
  17. package/dist/render/DatasetManager.d.ts.map +1 -1
  18. package/dist/render/DatasetManager.js +18 -4
  19. package/dist/render/DatasetManager.js.map +1 -1
  20. package/dist/render/GeoAdapter.d.ts +49 -24
  21. package/dist/render/GeoAdapter.d.ts.map +1 -1
  22. package/dist/render/GeoAdapter.js +96 -8
  23. package/dist/render/GeoAdapter.js.map +1 -1
  24. package/dist/render/GeometryBuilder.d.ts +17 -0
  25. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  26. package/dist/render/GeometryBuilder.js +31 -2
  27. package/dist/render/GeometryBuilder.js.map +1 -1
  28. package/dist/render/S3MViewer.d.ts +72 -2
  29. package/dist/render/S3MViewer.d.ts.map +1 -1
  30. package/dist/render/S3MViewer.js +269 -9
  31. package/dist/render/S3MViewer.js.map +1 -1
  32. package/dist/render/StyleMaterial.d.ts +5 -0
  33. package/dist/render/StyleMaterial.d.ts.map +1 -1
  34. package/dist/render/StyleMaterial.js +21 -0
  35. package/dist/render/StyleMaterial.js.map +1 -1
  36. package/dist/render/tilePlacement.d.ts +84 -0
  37. package/dist/render/tilePlacement.d.ts.map +1 -0
  38. package/dist/render/tilePlacement.js +71 -0
  39. package/dist/render/tilePlacement.js.map +1 -0
  40. package/dist/render/viewer/EdgeDecorator.d.ts +1 -0
  41. package/dist/render/viewer/EdgeDecorator.d.ts.map +1 -1
  42. package/dist/render/viewer/EdgeDecorator.js +3 -0
  43. package/dist/render/viewer/EdgeDecorator.js.map +1 -1
  44. package/dist/render/viewer/PickService.d.ts +13 -0
  45. package/dist/render/viewer/PickService.d.ts.map +1 -1
  46. package/dist/render/viewer/PickService.js +24 -15
  47. package/dist/render/viewer/PickService.js.map +1 -1
  48. package/dist/render/viewer/types.d.ts +53 -0
  49. package/dist/render/viewer/types.d.ts.map +1 -1
  50. package/dist/render/viewer/types.js.map +1 -1
  51. package/dist/tileset/S3MTileset.d.ts +43 -1
  52. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  53. package/dist/tileset/S3MTileset.js +202 -2
  54. package/dist/tileset/S3MTileset.js.map +1 -1
  55. package/dist/worker/s3mWorker.js +115 -62
  56. package/dist/worker/s3mWorker.js.map +1 -1
  57. package/docs/API.md +18 -0
  58. package/package.json +1 -1
  59. package/src/core/BakeMercator.ts +96 -0
  60. package/src/core/TileGeometry.ts +26 -0
  61. package/src/index.ts +11 -1
  62. package/src/render/DatasetManager.ts +43 -5
  63. package/src/render/GeoAdapter.ts +139 -11
  64. package/src/render/GeometryBuilder.ts +53 -2
  65. package/src/render/S3MViewer.ts +306 -13
  66. package/src/render/StyleMaterial.ts +26 -0
  67. package/src/render/tilePlacement.ts +121 -0
  68. package/src/render/viewer/EdgeDecorator.ts +3 -0
  69. package/src/render/viewer/PickService.ts +44 -16
  70. package/src/render/viewer/types.ts +51 -0
  71. package/src/tileset/S3MTileset.ts +226 -3
@@ -49,11 +49,30 @@ export interface PickHost {
49
49
  readonly scene: THREE.Scene | null;
50
50
  readonly canvas: HTMLCanvasElement | null;
51
51
  readonly style: { floorHeight: number };
52
- readonly sceneAnchor: { x: number; y: number; z: number; meterInMercator: number; altitude?: number } | null;
52
+ readonly sceneAnchor: {
53
+ x: number;
54
+ y: number;
55
+ z: number;
56
+ meterInMercatorEast?: number;
57
+ meterInMercatorNorth?: number;
58
+ meterInMercator: number;
59
+ altitude?: number;
60
+ } | null;
53
61
  readonly datasets: Map<string, { name: string }>;
54
62
  readonly featureStyleManager: FeatureStyleManager;
55
63
  readonly smidMeshes: Map<number, Set<THREE.Mesh>>;
56
64
  lngLatFromWorld(x: number, y: number): [number, number];
65
+ /**
66
+ * 世界坐标 → **规范**场景 ENU(把瓦片级放置的缩放反解掉)。
67
+ *
68
+ * `perTilePlacement` 开启时瓦片几何是绕自身中心缩放 `k` 倍后摆到正确位置的,
69
+ * 命中点的世界坐标因此不再等于「数据本身的 ENU」。拾取要报的是坐标本身,
70
+ * 不能带渲染相对底图的缩放,否则同一个屋顶在 20 km 外会比 1 km 内偏出几米,
71
+ * 量算、贴地、外部标注全都跟着错。
72
+ *
73
+ * 未开启本功能、或网格不在已放置的瓦片里时**原样返回**(默认路径零副作用)。
74
+ */
75
+ toCanonicalScene(p: THREE.Vector3, mesh?: THREE.Object3D | null): THREE.Vector3;
57
76
  gpuPathPoseAt(smid: number): { pos: [number, number, number]; yaw: number } | null;
58
77
  isFeatureHidden(smid: number | null, batchId: number): boolean;
59
78
  }
@@ -219,8 +238,10 @@ export class PickService {
219
238
  if (box && this.host.sceneAnchor) {
220
239
  const v = new THREE.Vector3((box.minX + box.maxX) / 2, (box.minY + box.maxY) / 2, (box.minZ + box.maxZ) / 2);
221
240
  v.applyMatrix4(ref.mesh.matrixWorld);
222
- const s = this.host.sceneAnchor.meterInMercator;
223
- centerLngLat = this.host.lngLatFromWorld(v.x * s + this.host.sceneAnchor.x, -v.y * s + this.host.sceneAnchor.y);
241
+ const c = this.host.toCanonicalScene(v, ref.mesh);
242
+ const sEast = this.host.sceneAnchor.meterInMercatorEast ?? this.host.sceneAnchor.meterInMercator;
243
+ const sNorth = this.host.sceneAnchor.meterInMercatorNorth ?? this.host.sceneAnchor.meterInMercator;
244
+ centerLngLat = this.host.lngLatFromWorld(c.x * sEast + this.host.sceneAnchor.x, -c.y * sNorth + this.host.sceneAnchor.y);
224
245
  }
225
246
 
226
247
  // 精确命中点求交计算
@@ -237,7 +258,8 @@ export class PickService {
237
258
  const off = smid != null ? this.host.featureStyleManager.getFeatureEffectiveOffset(smid) : {};
238
259
  const trans = off.translation;
239
260
  const elev = off.elevation;
240
- const finalPos = hitPoint.clone();
261
+ // 先反解瓦片级放置,再叠单体位移 —— 位移是「米」,必须落在规范 ENU 里
262
+ const finalPos = this.host.toCanonicalScene(hitPoint, ref.mesh).clone();
241
263
 
242
264
  if (off.rotation) {
243
265
  const rotMat = resolveRotationMatrix(off.rotation);
@@ -256,8 +278,9 @@ export class PickService {
256
278
  else if (elev) finalPos.z += elev;
257
279
 
258
280
  point = [finalPos.x, finalPos.y, finalPos.z];
259
- const s = this.host.sceneAnchor.meterInMercator;
260
- lngLat = this.host.lngLatFromWorld(finalPos.x * s + this.host.sceneAnchor.x, -finalPos.y * s + this.host.sceneAnchor.y);
281
+ const sEast = this.host.sceneAnchor.meterInMercatorEast ?? this.host.sceneAnchor.meterInMercator;
282
+ const sNorth = this.host.sceneAnchor.meterInMercatorNorth ?? this.host.sceneAnchor.meterInMercator;
283
+ lngLat = this.host.lngLatFromWorld(finalPos.x * sEast + this.host.sceneAnchor.x, -finalPos.y * sNorth + this.host.sceneAnchor.y);
261
284
  altitude = finalPos.z + (this.host.sceneAnchor.altitude ?? 0);
262
285
  } else if (centerLngLat && box && this.host.sceneAnchor) {
263
286
  lngLat = centerLngLat;
@@ -321,16 +344,18 @@ export class PickService {
321
344
  floorHeight: this.host.style.floorHeight,
322
345
  });
323
346
 
324
- const s = anchor.meterInMercator;
347
+ const sEast = anchor.meterInMercatorEast ?? anchor.meterInMercator;
348
+ const sNorth = anchor.meterInMercatorNorth ?? anchor.meterInMercator;
325
349
  let point: [number, number, number] | null = null;
326
350
  let lngLat: [number, number] | null = null;
327
351
  let altitude: number | null = null;
328
352
  let centerLngLat: [number, number] | null = null;
329
353
 
330
354
  if (hitPoint) {
331
- point = [hitPoint.x, hitPoint.y, hitPoint.z];
332
- lngLat = this.host.lngLatFromWorld(hitPoint.x * s + anchor.x, -hitPoint.y * s + anchor.y);
333
- altitude = hitPoint.z + (anchor.altitude ?? 0);
355
+ const c = this.host.toCanonicalScene(hitPoint, mesh);
356
+ point = [c.x, c.y, c.z];
357
+ lngLat = this.host.lngLatFromWorld(c.x * sEast + anchor.x, -c.y * sNorth + anchor.y);
358
+ altitude = c.z + (anchor.altitude ?? 0);
334
359
  // 泼溅命中的就是一个高斯,没有「单体中心」可言,用命中点当中心
335
360
  centerLngLat = lngLat;
336
361
  } else if (box) {
@@ -339,8 +364,9 @@ export class PickService {
339
364
  (box.minY + box.maxY) / 2,
340
365
  (box.minZ + box.maxZ) / 2
341
366
  ).applyMatrix4(mesh.matrixWorld);
342
- centerLngLat = this.host.lngLatFromWorld(v.x * s + anchor.x, -v.y * s + anchor.y);
343
- altitude = v.z + (anchor.altitude ?? 0);
367
+ const c = this.host.toCanonicalScene(v, mesh);
368
+ centerLngLat = this.host.lngLatFromWorld(c.x * sEast + anchor.x, -c.y * sNorth + anchor.y);
369
+ altitude = c.z + (anchor.altitude ?? 0);
344
370
  }
345
371
 
346
372
  return {
@@ -395,8 +421,9 @@ export class PickService {
395
421
  const target = new THREE.Vector3();
396
422
  const intersect = ray.intersectPlane(groundPlane, target);
397
423
  if (intersect) {
398
- const s = this.host.sceneAnchor.meterInMercator;
399
- const lngLat = this.host.lngLatFromWorld(intersect.x * s + this.host.sceneAnchor.x, -intersect.y * s + this.host.sceneAnchor.y);
424
+ const sEast = this.host.sceneAnchor.meterInMercatorEast ?? this.host.sceneAnchor.meterInMercator;
425
+ const sNorth = this.host.sceneAnchor.meterInMercatorNorth ?? this.host.sceneAnchor.meterInMercator;
426
+ const lngLat = this.host.lngLatFromWorld(intersect.x * sEast + this.host.sceneAnchor.x, -intersect.y * sNorth + this.host.sceneAnchor.y);
400
427
  const altitude = intersect.z + (this.host.sceneAnchor.altitude ?? 0);
401
428
  return {
402
429
  lngLat,
@@ -643,8 +670,9 @@ export class PickService {
643
670
  if (gpuPose) c.add(new THREE.Vector3(gpuPose.pos[0], gpuPose.pos[1], gpuPose.pos[2]));
644
671
 
645
672
  c.applyMatrix4(mesh.matrixWorld);
646
- const s = this.host.sceneAnchor.meterInMercator;
647
- const [lng, lat] = this.host.lngLatFromWorld(c.x * s + this.host.sceneAnchor.x, -c.y * s + this.host.sceneAnchor.y);
673
+ const sEast = this.host.sceneAnchor.meterInMercatorEast ?? this.host.sceneAnchor.meterInMercator;
674
+ const sNorth = this.host.sceneAnchor.meterInMercatorNorth ?? this.host.sceneAnchor.meterInMercator;
675
+ const [lng, lat] = this.host.lngLatFromWorld(c.x * sEast + this.host.sceneAnchor.x, -c.y * sNorth + this.host.sceneAnchor.y);
648
676
  if (lng < minLng) minLng = lng;
649
677
  if (lng > maxLng) maxLng = lng;
650
678
  if (lat < minLat) minLat = lat;
@@ -77,7 +77,23 @@ export interface LightOptions {
77
77
  altitude?: number;
78
78
  }
79
79
 
80
+ /**
81
+ * 本层绘制前如何处理宿主已有的深度缓冲。
82
+ * - `'scene'` 保留(默认)。地形能遮挡本层几何,适用于建筑、管线等离散对象。
83
+ * - `'clear'` 先清深度再画,本层只与自己做深度测试。适用于贴地地面类数据(倾斜摄影、大范围 DEM 化模型等),消除共面 z-fighting。
84
+ * - `'auto'` 按数据集类型推断:有 ground 覆盖 / OSGB 倾斜摄影 / 地面类网格 → `'clear'`,否则 `'scene'`。
85
+ */
86
+ export type DepthMode = 'scene' | 'clear' | 'auto';
87
+
80
88
  export interface S3MViewerOptions {
89
+ /**
90
+ * 本层绘制前如何处理宿主已有的深度缓冲。
91
+ * - `'scene'` 保留(默认)。地形能遮挡本层几何,适用于建筑、管线等离散对象。
92
+ * - `'clear'` 先清深度再画,本层只与自己做深度测试。适用于贴地地面类数据。
93
+ * - `'auto'` 按数据集类型推断:有 ground 覆盖 / OSGB 倾斜摄影 / 地面类网格 → `'clear'`,否则 `'scene'`。
94
+ * 默认 `'scene'`,与改动前行为逐字一致。
95
+ */
96
+ depthMode?: DepthMode;
81
97
  /**
82
98
  * 光照预设:
83
99
  * - `'default'`(默认):现有 PBR 打光,亮部线性总量 ≈ 2.55;
@@ -134,6 +150,34 @@ export interface S3MViewerOptions {
134
150
  * 所以默认不启用,由接入方按设备档位自行决定。运行时可用 `setLodPixelRatioCap()` 调整。
135
151
  */
136
152
  lodPixelRatioCap?: number;
153
+ /**
154
+ * 瓦片级放置(默认 `true`)。
155
+ *
156
+ * 开启后每块瓦片用自己的中心纬度算 `meterInMercator`,几何绕自身中心缩放,
157
+ * 修正「全场景共用一个切平面」在大范围下的尺度误差。
158
+ *
159
+ * 误差量级(北纬 40°,距场景锚点的距离 → 相对尺度的偏差):
160
+ * `5 km → 0.065%`、`20 km → 0.26%(约 26 m)`、`200 km → 2.8%(约 2.8 km)`。
161
+ * 城市级(几公里)随手开无妨;多数据集相距几十公里、或单个数据集跨 20 km 以上时
162
+ * 必须开,否则远处的模型会整体偏大/偏小。
163
+ *
164
+ * 代价与遗留:
165
+ * - 每块瓦片的 `Object3D` 走 `matrixAutoUpdate = false`,多一次矩阵乘;
166
+ * - 场景 ENU 仍是**一个平面**,所以只是把误差按瓦片重新分摊(瓦片跨 2 km 时
167
+ * 剩余约 0.5 m),彻底消除要逐顶点烘焙墨卡托;
168
+ * - 拾取 / 量算的 `lngLat ↔ 场景 ENU` 换算仍用场景锚点的常量 s,在本模式下
169
+ * 保持修复前的误差量级(几何与反解不再自洽,但都不比之前差)。
170
+ *
171
+ * 见 `render/tilePlacement.ts` 的文件头。
172
+ */
173
+ perTilePlacement?: boolean;
174
+ /**
175
+ * 是否对瓦片逐顶点烘焙精确 Web 墨卡托偏移(消除全球大尺度下的视差游动/呼吸效应)。
176
+ * - undefined(默认):自动判定。当瓦片集根节点包围球半径 > 25 km 时自动启用,<= 25 km 时保持关闭(逐字节保持原有 ENU 顶点缓冲不变)。
177
+ * - true:强制开启。
178
+ * - false:强制关闭。
179
+ */
180
+ bakeMercator?: boolean;
137
181
  /**
138
182
  * 每个数据集驻留三角形总数的软上限(默认不限制)。
139
183
  * 见 `S3MTilesetOptions.maxTriangles`:瓦片数管不住的场景(精模、大面片)用这条刹车。
@@ -237,6 +281,13 @@ export interface S3MViewerOptions {
237
281
  * 编译着色器、卡一帧。预热在首帧之后异步做,期间跳一两帧渲染,肉眼不可见。
238
282
  */
239
283
  prewarmShading?: boolean;
284
+ /**
285
+ * LOD 细化过渡策略:
286
+ * - 'binary'(默认):整节点二值切换
287
+ * - 'incremental':增量细化(primary / fallback 双列表 + LEQUAL 深度推移)
288
+ */
289
+ lodTransition?: 'binary' | 'incremental';
290
+ fallbackOffset?: { factor?: number; units?: number };
240
291
  }
241
292
 
242
293
  /* ------------------------------------------------------------------ 内部类型 */
@@ -32,6 +32,7 @@ import type {
32
32
  } from '../core/S3MBlockParser.js';
33
33
  import type { Anchor, ParsedTile, Stats, StyleUniforms } from '../core/types.js';
34
34
  import type { TileGeometryData } from '../core/TileGeometry.js';
35
+ import { AUTO_BAKE_RADIUS_THRESHOLD } from '../core/BakeMercator.js';
35
36
  import type { TextureCreateOptions } from '../render/TextureFactory.js';
36
37
  import type { Group, Texture } from 'three';
37
38
 
@@ -304,10 +305,14 @@ export type BuildTileFn = (
304
305
  colorPipeline?: 'linear' | 'direct';
305
306
  /** 纹理创建参数(透传给 buildTileObjects → createTexture) */
306
307
  textureOptions?: TextureCreateOptions;
308
+ bakeMercator?: boolean;
309
+ tileAnchor?: { lon: number; lat: number; height?: number };
310
+ tileCenter?: { x: number; y: number; z?: number };
307
311
  }
308
312
  ) => Promise<{ group: Group; stats: Partial<Stats> }>;
309
313
 
310
314
  export interface S3MTilesetOptions {
315
+ bakeMercator?: boolean;
311
316
  configUrl: string;
312
317
  maxTiles?: number;
313
318
  maxConcurrent?: number;
@@ -388,6 +393,18 @@ export interface S3MTilesetOptions {
388
393
  * 'silent' —— 只记录到 `stats.degradations`,由 UI 自己决定怎么提示。
389
394
  */
390
395
  unsupported?: UnsupportedPolicy;
396
+ /**
397
+ * LOD 细化过渡策略:
398
+ * - 'binary'(默认):整节点二值切换。所有子瓦片全部就绪或失败后,父瓦片整块隐藏、子瓦片整块接替。
399
+ * - 'incremental':增量细化(primary / fallback 双列表 + LEQUAL 深度推移)。
400
+ * 子瓦片就绪一个显示一个,未就绪区域由父级(fallback)兜底填充,消除整块跳变与慢 child 阻塞。
401
+ */
402
+ lodTransition?: 'binary' | 'incremental';
403
+ /**
404
+ * 增量细化模式下 fallback 兜底瓦片的材质深度偏移系数。
405
+ * 默认 { factor: 1, units: 2 },通过向远处推移深度配合 gl.LEQUAL 消除共面 z-fighting。
406
+ */
407
+ fallbackOffset?: { factor?: number; units?: number };
391
408
  }
392
409
 
393
410
  /** 估算单个纹理占用的显存字节数 */
@@ -505,6 +522,10 @@ export class S3MTileset {
505
522
  */
506
523
  maxMemory: number;
507
524
  lodRangeScale: number;
525
+ lodTransition: 'binary' | 'incremental';
526
+ fallbackOffset: { factor: number; units: number };
527
+ readonly primaryNodes = new Set<string>();
528
+ readonly fallbackNodes = new Set<string>();
508
529
 
509
530
  /** 数据集配置声明的高度范围(米),若配置未声明则返回 null */
510
531
  get heightRange(): { min: number; max: number } | null {
@@ -535,8 +556,16 @@ export class S3MTileset {
535
556
  /** 请求量的硬上限(软上限 maxTiles 的超额余量),见构造函数注释 */
536
557
  hardCap = 96;
537
558
  /** 本数据集是 S3M 1.x(SuperMapCache,.s3m):子瓦地址规则与 3.x 不同 */
538
- private isS3MV1 = false;
559
+ isS3MV1 = false;
539
560
  private _buildTile: BuildTileFn | null;
561
+ private _bakeMercator?: boolean;
562
+ rootRadius?: number;
563
+
564
+ shouldBakeMercator(radius?: number): boolean {
565
+ if (this._bakeMercator !== undefined) return this._bakeMercator;
566
+ const r = radius ?? this.rootRadius ?? 0;
567
+ return r > AUTO_BAKE_RADIUS_THRESHOLD;
568
+ }
540
569
  private readonly _polygonOffset: { factor: number; units: number } | undefined;
541
570
  private _materialQuality: 'pbr' | 'lambert' | undefined;
542
571
  private _colorPipeline: 'linear' | 'direct' | undefined;
@@ -550,6 +579,11 @@ export class S3MTileset {
550
579
  constructor(options: S3MTilesetOptions) {
551
580
  this.configUrl = options.configUrl;
552
581
  this.lodRangeScale = options.lodRangeScale ?? 3.0;
582
+ this.lodTransition = options.lodTransition ?? 'binary';
583
+ this.fallbackOffset = {
584
+ factor: options.fallbackOffset?.factor ?? 1,
585
+ units: options.fallbackOffset?.units ?? 2,
586
+ };
553
587
  this._onTile = options.onTile ?? (() => {});
554
588
  this._onTileRemoved = options.onTileRemoved ?? (() => {});
555
589
  this._onError = options.onError ?? ((e) => console.warn(e));
@@ -560,6 +594,7 @@ export class S3MTileset {
560
594
 
561
595
  // 渲染层可注入:Node 环境下不需要 three,也能跑 URL / 解析链路
562
596
  this._buildTile = options.buildTile ?? null;
597
+ this._bakeMercator = options.bakeMercator;
563
598
  this._polygonOffset = options.polygonOffset;
564
599
  this._materialQuality = options.materialQuality;
565
600
  this._colorPipeline = options.colorPipeline;
@@ -903,6 +938,9 @@ export class S3MTileset {
903
938
  if (refined !== allowRefined) continue;
904
939
  // 正在显示且没有可见子瓦接手 → 踢了就是洞
905
940
  if (!allowUncovered && node.group.visible) {
941
+ if (this.lodTransition === 'incremental') {
942
+ continue;
943
+ }
906
944
  const covered = childUrls.some((u) => {
907
945
  const c = this.nodes.get(u);
908
946
  return !!c && c.group.visible;
@@ -1350,6 +1388,11 @@ export class S3MTileset {
1350
1388
  // 解析成功但能力不完整的(目前只有 3DGS),在花力气建几何之前先决定处置
1351
1389
  this._noteDegradations(tile);
1352
1390
 
1391
+ const lodForBake = this._pickLod(tile);
1392
+ if (this.rootRadius === undefined && lodForBake?.boundingSphere?.radius) {
1393
+ this.rootRadius = lodForBake.boundingSphere.radius;
1394
+ }
1395
+ const shouldBake = this.shouldBakeMercator(lodForBake?.boundingSphere?.radius);
1353
1396
  const build = await this._builder();
1354
1397
  const buildRes = await build(tile, {
1355
1398
  textureCache: this.textureCache,
@@ -1359,6 +1402,9 @@ export class S3MTileset {
1359
1402
  materialQuality: this._materialQuality,
1360
1403
  colorPipeline: this._colorPipeline,
1361
1404
  textureOptions: this._textureOptions,
1405
+ bakeMercator: shouldBake,
1406
+ tileAnchor: this.anchor,
1407
+ tileCenter: lodForBake?.boundingSphere?.center,
1362
1408
  });
1363
1409
  const { group, stats } = buildRes;
1364
1410
  const texKeys = (buildRes as any).textureKeys ?? (group.userData?.textureKeys as string[] | undefined) ?? Object.keys(tile.textures ?? {});
@@ -1570,10 +1616,21 @@ export class S3MTileset {
1570
1616
  if (!p || !this.nodes.has(p)) roots.add(url);
1571
1617
  }
1572
1618
 
1573
- for (const rootUrl of roots) {
1574
- if (this._updateNodeVisibility(rootUrl, true, visited)) {
1619
+ if (this.lodTransition === 'incremental') {
1620
+ if (this._updateVisibilityIncremental(roots, visited)) {
1575
1621
  hasChanges = true;
1576
1622
  }
1623
+ } else {
1624
+ for (const rootUrl of roots) {
1625
+ if (this._updateNodeVisibility(rootUrl, true, visited)) {
1626
+ hasChanges = true;
1627
+ }
1628
+ }
1629
+ this.primaryNodes.clear();
1630
+ for (const [url, node] of this.nodes) {
1631
+ if (node.group.visible) this.primaryNodes.add(url);
1632
+ }
1633
+ this.fallbackNodes.clear();
1577
1634
  }
1578
1635
 
1579
1636
  // 防御:若有任何未被 root 树访问到的游离瓦片,全部隐藏
@@ -1583,6 +1640,9 @@ export class S3MTileset {
1583
1640
  node.group.visible = false;
1584
1641
  hasChanges = true;
1585
1642
  }
1643
+ if (this.lodTransition === 'incremental') {
1644
+ this._restoreNodePolygonOffset(node);
1645
+ }
1586
1646
  }
1587
1647
  }
1588
1648
 
@@ -1674,6 +1734,169 @@ export class S3MTileset {
1674
1734
  return changed;
1675
1735
  }
1676
1736
 
1737
+
1738
+ getLodTransition(): 'binary' | 'incremental' {
1739
+ return this.lodTransition;
1740
+ }
1741
+
1742
+ setLodTransition(mode: 'binary' | 'incremental'): void {
1743
+ if (this.lodTransition === mode) return;
1744
+ this.lodTransition = mode;
1745
+ if (mode === 'binary') {
1746
+ for (const node of this.nodes.values()) {
1747
+ this._restoreNodePolygonOffset(node);
1748
+ }
1749
+ this.fallbackNodes.clear();
1750
+ }
1751
+ }
1752
+
1753
+ private _applyNodeFallbackPolygonOffset(node: TileNode, depth: number): boolean {
1754
+ const factor = this.fallbackOffset.factor;
1755
+ const units = Math.max(2, (10 - Math.min(depth, 8)) * this.fallbackOffset.units);
1756
+ let changed = false;
1757
+
1758
+ node.group.traverse((obj) => {
1759
+ const target = obj as { isMesh?: boolean; isPoints?: boolean; isLine?: boolean; material?: any };
1760
+ if ((target.isMesh || target.isPoints || target.isLine) && target.material) {
1761
+ const materials = Array.isArray(target.material) ? target.material : [target.material];
1762
+ for (const mat of materials) {
1763
+ if (!mat) continue;
1764
+ if (!mat.userData) mat.userData = {};
1765
+ if (!mat.userData._basePolygonOffset) {
1766
+ mat.userData._basePolygonOffset = {
1767
+ polygonOffset: mat.polygonOffset === true,
1768
+ factor: mat.polygonOffsetFactor ?? 0,
1769
+ units: mat.polygonOffsetUnits ?? 0,
1770
+ };
1771
+ }
1772
+ const base = mat.userData._basePolygonOffset;
1773
+ const targetFactor = base.factor + factor;
1774
+ const targetUnits = base.units + units;
1775
+ if (
1776
+ mat.polygonOffset !== true ||
1777
+ mat.polygonOffsetFactor !== targetFactor ||
1778
+ mat.polygonOffsetUnits !== targetUnits
1779
+ ) {
1780
+ mat.polygonOffset = true;
1781
+ mat.polygonOffsetFactor = targetFactor;
1782
+ mat.polygonOffsetUnits = targetUnits;
1783
+ changed = true;
1784
+ }
1785
+ }
1786
+ }
1787
+ });
1788
+ return changed;
1789
+ }
1790
+
1791
+ private _restoreNodePolygonOffset(node: TileNode): boolean {
1792
+ let changed = false;
1793
+ node.group.traverse((obj) => {
1794
+ const target = obj as { isMesh?: boolean; isPoints?: boolean; isLine?: boolean; material?: any };
1795
+ if ((target.isMesh || target.isPoints || target.isLine) && target.material) {
1796
+ const materials = Array.isArray(target.material) ? target.material : [target.material];
1797
+ for (const mat of materials) {
1798
+ if (mat?.userData?._basePolygonOffset) {
1799
+ const base = mat.userData._basePolygonOffset;
1800
+ if (
1801
+ mat.polygonOffset !== base.polygonOffset ||
1802
+ mat.polygonOffsetFactor !== base.factor ||
1803
+ mat.polygonOffsetUnits !== base.units
1804
+ ) {
1805
+ mat.polygonOffset = base.polygonOffset;
1806
+ mat.polygonOffsetFactor = base.factor;
1807
+ mat.polygonOffsetUnits = base.units;
1808
+ changed = true;
1809
+ }
1810
+ }
1811
+ }
1812
+ }
1813
+ });
1814
+ return changed;
1815
+ }
1816
+
1817
+ private _updateVisibilityIncremental(roots: Iterable<string>, visited: Set<string>): boolean {
1818
+ const nextPrimary = new Set<string>();
1819
+ const nextFallback = new Set<string>();
1820
+ const nodeDepths = new Map<string, number>();
1821
+
1822
+ const traverse = (url: string, depth: number) => {
1823
+ if (visited.has(url)) return;
1824
+ visited.add(url);
1825
+ nodeDepths.set(url, depth);
1826
+
1827
+ const node = this.nodes.get(url);
1828
+ if (!node) return;
1829
+
1830
+ const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
1831
+ const hasChildren = childUrls.length > 0;
1832
+ const readyChildren = childUrls.filter((cUrl) => this.nodes.has(cUrl));
1833
+ const missingChildren = childUrls.filter(
1834
+ (cUrl) => !this.nodes.has(cUrl) && !this.failed.has(cUrl)
1835
+ );
1836
+
1837
+ const canRefine = !!node.needRefine && hasChildren && readyChildren.length > 0;
1838
+
1839
+ if (canRefine) {
1840
+ if (missingChildren.length > 0) {
1841
+ nextFallback.add(url);
1842
+ }
1843
+ for (const cUrl of readyChildren) {
1844
+ traverse(cUrl, depth + 1);
1845
+ }
1846
+ } else {
1847
+ nextPrimary.add(url);
1848
+ }
1849
+ };
1850
+
1851
+ for (const rootUrl of roots) {
1852
+ traverse(rootUrl, 0);
1853
+ }
1854
+
1855
+ let changed = false;
1856
+
1857
+ for (const [url, node] of this.nodes) {
1858
+ if (!visited.has(url)) continue;
1859
+
1860
+ if (nextPrimary.has(url)) {
1861
+ if (!node.group.visible) {
1862
+ node.group.visible = true;
1863
+ changed = true;
1864
+ }
1865
+ const depth = nodeDepths.get(url) ?? 0;
1866
+ const targetOrder = 100 + depth;
1867
+ if (node.group.renderOrder !== targetOrder) {
1868
+ node.group.renderOrder = targetOrder;
1869
+ changed = true;
1870
+ }
1871
+ if (this._restoreNodePolygonOffset(node)) changed = true;
1872
+ } else if (nextFallback.has(url)) {
1873
+ if (!node.group.visible) {
1874
+ node.group.visible = true;
1875
+ changed = true;
1876
+ }
1877
+ const depth = nodeDepths.get(url) ?? 0;
1878
+ const targetOrder = depth;
1879
+ if (node.group.renderOrder !== targetOrder) {
1880
+ node.group.renderOrder = targetOrder;
1881
+ changed = true;
1882
+ }
1883
+ if (this._applyNodeFallbackPolygonOffset(node, depth)) changed = true;
1884
+ } else {
1885
+ if (node.group.visible) {
1886
+ node.group.visible = false;
1887
+ changed = true;
1888
+ }
1889
+ if (this._restoreNodePolygonOffset(node)) changed = true;
1890
+ }
1891
+ }
1892
+
1893
+ this.primaryNodes.clear();
1894
+ for (const u of nextPrimary) this.primaryNodes.add(u);
1895
+ this.fallbackNodes.clear();
1896
+ for (const u of nextFallback) this.fallbackNodes.add(u);
1897
+
1898
+ return changed;
1899
+ }
1677
1900
  dispose(): void {
1678
1901
  this._workerClient?.dispose();
1679
1902
  for (const node of this.nodes.values()) {