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
@@ -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
 
@@ -195,6 +196,10 @@ export function parseConfigText(text: string): Record<string, unknown> {
195
196
 
196
197
  return {
197
198
  fileType: tagVal('FileType') ?? undefined,
199
+ // 地形/影像缓存的判别字段(见 classifyDatasetKind):FileExtentName=Terrainz 是地形,
200
+ // <sml:Type>Image</sml:Type> 是影像(影像的 FileExtentName 是 jpg_png 这种扩展名样式,不可靠)
201
+ fileExtentName: tagVal('FileExtentName') ?? undefined,
202
+ cacheType: tagVal('Type') ?? undefined,
198
203
  position: { x: posX, y: posY, z: posZ, units: 'Degree' },
199
204
  geoBounds: { left, right, top, bottom },
200
205
  heightRange: Number.isFinite(minH) || Number.isFinite(maxH) ? { min: minH, max: maxH } : undefined,
@@ -205,6 +210,53 @@ export function parseConfigText(text: string): Record<string, unknown> {
205
210
  return JSON.parse(trimmed) as Record<string, unknown>;
206
211
  }
207
212
 
213
+ /** config 能认出的数据集类别(判别规则见 classifyDatasetKind) */
214
+ export type DatasetKind = 'model' | 'terrain' | 'imagery';
215
+
216
+ /**
217
+ * 按 config 的类型标记识别数据集类别。
218
+ *
219
+ * 同一个 `…/datas/<名>/config` URL 底下可能是模型、地形、影像三种完全不同的缓存
220
+ * (FORMAT-NOTES 第二章)。地形/影像是按 Level0 金字塔寻址的栅格缓存,**没有瓦片清单**,
221
+ * 当模型加载只会一路走到「配置中缺少 root tile」,把「类型不支持」伪装成「配置缺字段」
222
+ * (issue #7:混合服务第一个数据集 beijingdem 是 Terrainz 地形缓存)。
223
+ *
224
+ * 判别字段取自实测(supermapol 3D-OlympicGreen_Plan,2026-10-30):
225
+ * 地形 XML: FileExtentName = Terrainz
226
+ * 影像 XML: FileExtentName = jpg_png(扩展名样式,不可靠)+ Type = Image
227
+ * 模型 XML: FileType = OSGBFile / OSGBCacheFile,无 FileExtentName / Type
228
+ *
229
+ * 保守原则:只有认出确凿的非模型标记才判 terrain / imagery,其余(含 JSON 配置、
230
+ * 无标记的 XML)一律 'model' —— 保证「认不出」时行为与旧版一致(不变式 ①)。
231
+ */
232
+ export function classifyDatasetKind(config: Record<string, unknown> | null | undefined): DatasetKind {
233
+ if (!config || typeof config !== 'object') return 'model';
234
+ if (String(config.fileExtentName ?? '').trim().toLowerCase() === 'terrainz') return 'terrain';
235
+ if (String(config.cacheType ?? '').trim().toLowerCase() === 'image') return 'imagery';
236
+ return 'model';
237
+ }
238
+
239
+ /** 拼一句能区分 config 形态的上下文(FileExtentName / Type / fileType / dataType),供报错使用 */
240
+ function configTypeHint(config: Record<string, unknown>): string {
241
+ const parts: string[] = [];
242
+ if (config.fileExtentName) parts.push(`FileExtentName=${config.fileExtentName}`);
243
+ if (config.cacheType) parts.push(`Type=${config.cacheType}`);
244
+ if (config.fileType) parts.push(`fileType=${config.fileType}`);
245
+ if (config.dataType) parts.push(`dataType=${config.dataType}`);
246
+ return parts.length ? parts.join(', ') : '无类型标记';
247
+ }
248
+
249
+ /** 从 configUrl 里抠出数据集名(…/datas/<名>/config);抠不出就原样返回,报错时能对上服务里的数据集 */
250
+ function datasetNameOf(configUrl: string): string {
251
+ const m = /\/datas\/([^/?#]+)/i.exec(configUrl);
252
+ if (!m) return configUrl;
253
+ try {
254
+ return decodeURIComponent(m[1]!);
255
+ } catch {
256
+ return m[1]!;
257
+ }
258
+ }
259
+
208
260
  export interface TileNode {
209
261
  url: string;
210
262
  tile: ParsedTile;
@@ -253,10 +305,14 @@ export type BuildTileFn = (
253
305
  colorPipeline?: 'linear' | 'direct';
254
306
  /** 纹理创建参数(透传给 buildTileObjects → createTexture) */
255
307
  textureOptions?: TextureCreateOptions;
308
+ bakeMercator?: boolean;
309
+ tileAnchor?: { lon: number; lat: number; height?: number };
310
+ tileCenter?: { x: number; y: number; z?: number };
256
311
  }
257
312
  ) => Promise<{ group: Group; stats: Partial<Stats> }>;
258
313
 
259
314
  export interface S3MTilesetOptions {
315
+ bakeMercator?: boolean;
260
316
  configUrl: string;
261
317
  maxTiles?: number;
262
318
  maxConcurrent?: number;
@@ -337,6 +393,18 @@ export interface S3MTilesetOptions {
337
393
  * 'silent' —— 只记录到 `stats.degradations`,由 UI 自己决定怎么提示。
338
394
  */
339
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 };
340
408
  }
341
409
 
342
410
  /** 估算单个纹理占用的显存字节数 */
@@ -454,6 +522,10 @@ export class S3MTileset {
454
522
  */
455
523
  maxMemory: number;
456
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>();
457
529
 
458
530
  /** 数据集配置声明的高度范围(米),若配置未声明则返回 null */
459
531
  get heightRange(): { min: number; max: number } | null {
@@ -484,8 +556,16 @@ export class S3MTileset {
484
556
  /** 请求量的硬上限(软上限 maxTiles 的超额余量),见构造函数注释 */
485
557
  hardCap = 96;
486
558
  /** 本数据集是 S3M 1.x(SuperMapCache,.s3m):子瓦地址规则与 3.x 不同 */
487
- private isS3MV1 = false;
559
+ isS3MV1 = false;
488
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
+ }
489
569
  private readonly _polygonOffset: { factor: number; units: number } | undefined;
490
570
  private _materialQuality: 'pbr' | 'lambert' | undefined;
491
571
  private _colorPipeline: 'linear' | 'direct' | undefined;
@@ -499,6 +579,11 @@ export class S3MTileset {
499
579
  constructor(options: S3MTilesetOptions) {
500
580
  this.configUrl = options.configUrl;
501
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
+ };
502
587
  this._onTile = options.onTile ?? (() => {});
503
588
  this._onTileRemoved = options.onTileRemoved ?? (() => {});
504
589
  this._onError = options.onError ?? ((e) => console.warn(e));
@@ -509,6 +594,7 @@ export class S3MTileset {
509
594
 
510
595
  // 渲染层可注入:Node 环境下不需要 three,也能跑 URL / 解析链路
511
596
  this._buildTile = options.buildTile ?? null;
597
+ this._bakeMercator = options.bakeMercator;
512
598
  this._polygonOffset = options.polygonOffset;
513
599
  this._materialQuality = options.materialQuality;
514
600
  this._colorPipeline = options.colorPipeline;
@@ -539,11 +625,31 @@ export class S3MTileset {
539
625
  const text = await res.text();
540
626
  const config = parseConfigText(text);
541
627
  this.config = config;
628
+
629
+ // 地形/影像缓存(金字塔栅格,无瓦片清单)不能当模型加载:走到下面「缺少 root tile」
630
+ // 那步只会得到一句把「类型不支持」伪装成「配置缺字段」的报错(issue #7),
631
+ // 所以在锚点解析之前先按类型标记拒掉,把病因说清楚。
632
+ const kind = classifyDatasetKind(config);
633
+ if (kind !== 'model') {
634
+ const err = new Error(
635
+ `数据集 ${datasetNameOf(this.configUrl)} 是${kind === 'terrain' ? '地形' : '影像'}缓存(${configTypeHint(config)}),本层仅支持模型类 S3M 数据集`
636
+ ) as Error & { datasetKind: DatasetKind };
637
+ // 程序化判别用:调用方(如数据集列表 UI)可以据此标注/跳过该数据集
638
+ err.datasetKind = kind;
639
+ throw err;
640
+ }
641
+
542
642
  this.anchor = this._resolveAnchor(config);
543
643
 
544
644
  // iServer 的 realspace 配置用 rootTiles(可多棵树),桌面导出的用 tiles
545
645
  const roots = (config.rootTiles ?? config.tiles ?? []) as Array<{ url: string }>;
546
- if (!roots.length) throw new Error('配置中缺少 root tile');
646
+ if (!roots.length) {
647
+ // 带上 config 的类型标记:地形/影像缓存(本应在前一步被拒)与「configUrl 误指向
648
+ // …/datas 清单」「桌面导出漏瓦片清单」是三种不同的病因,文案必须能区分
649
+ throw new Error(
650
+ `配置中缺少 root tile(${configTypeHint(config)})——若是地形/影像数据集,或 configUrl 误指向 …/datas 数据集清单,本层仅支持模型类 S3M 数据集`
651
+ );
652
+ }
547
653
 
548
654
  this.rootUrls.length = 0;
549
655
  for (const r of roots) {
@@ -832,6 +938,9 @@ export class S3MTileset {
832
938
  if (refined !== allowRefined) continue;
833
939
  // 正在显示且没有可见子瓦接手 → 踢了就是洞
834
940
  if (!allowUncovered && node.group.visible) {
941
+ if (this.lodTransition === 'incremental') {
942
+ continue;
943
+ }
835
944
  const covered = childUrls.some((u) => {
836
945
  const c = this.nodes.get(u);
837
946
  return !!c && c.group.visible;
@@ -941,7 +1050,7 @@ export class S3MTileset {
941
1050
  }
942
1051
  if (this._workerClient) {
943
1052
  try {
944
- return await this._workerClient.parseTile(buffer, true);
1053
+ return await this._workerClient.parseTile(buffer, true, true, this.config?.vertexAttributeDescript);
945
1054
  } catch (e) {
946
1055
  if (e instanceof WorkerDeadError) {
947
1056
  // 脚本加载失败 / 崩溃属于环境问题,永久退回主线程;瓦片本身的问题照常抛
@@ -951,7 +1060,7 @@ export class S3MTileset {
951
1060
  }
952
1061
  }
953
1062
  }
954
- return { tile: await parseS3MB(buffer) };
1063
+ return { tile: await parseS3MB(buffer, { vertexAttributeDescript: this.config?.vertexAttributeDescript }) };
955
1064
  }
956
1065
 
957
1066
  /**
@@ -1279,6 +1388,11 @@ export class S3MTileset {
1279
1388
  // 解析成功但能力不完整的(目前只有 3DGS),在花力气建几何之前先决定处置
1280
1389
  this._noteDegradations(tile);
1281
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);
1282
1396
  const build = await this._builder();
1283
1397
  const buildRes = await build(tile, {
1284
1398
  textureCache: this.textureCache,
@@ -1288,6 +1402,9 @@ export class S3MTileset {
1288
1402
  materialQuality: this._materialQuality,
1289
1403
  colorPipeline: this._colorPipeline,
1290
1404
  textureOptions: this._textureOptions,
1405
+ bakeMercator: shouldBake,
1406
+ tileAnchor: this.anchor,
1407
+ tileCenter: lodForBake?.boundingSphere?.center,
1291
1408
  });
1292
1409
  const { group, stats } = buildRes;
1293
1410
  const texKeys = (buildRes as any).textureKeys ?? (group.userData?.textureKeys as string[] | undefined) ?? Object.keys(tile.textures ?? {});
@@ -1499,10 +1616,21 @@ export class S3MTileset {
1499
1616
  if (!p || !this.nodes.has(p)) roots.add(url);
1500
1617
  }
1501
1618
 
1502
- for (const rootUrl of roots) {
1503
- if (this._updateNodeVisibility(rootUrl, true, visited)) {
1619
+ if (this.lodTransition === 'incremental') {
1620
+ if (this._updateVisibilityIncremental(roots, visited)) {
1504
1621
  hasChanges = true;
1505
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();
1506
1634
  }
1507
1635
 
1508
1636
  // 防御:若有任何未被 root 树访问到的游离瓦片,全部隐藏
@@ -1512,6 +1640,9 @@ export class S3MTileset {
1512
1640
  node.group.visible = false;
1513
1641
  hasChanges = true;
1514
1642
  }
1643
+ if (this.lodTransition === 'incremental') {
1644
+ this._restoreNodePolygonOffset(node);
1645
+ }
1515
1646
  }
1516
1647
  }
1517
1648
 
@@ -1603,6 +1734,169 @@ export class S3MTileset {
1603
1734
  return changed;
1604
1735
  }
1605
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
+ }
1606
1900
  dispose(): void {
1607
1901
  this._workerClient?.dispose();
1608
1902
  for (const node of this.nodes.values()) {
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * Draco 几何解码器(google/draco,wasm 内联)的类型声明
3
3
  *
4
4
  * 只声明 S3M 用到的部分。两个坑:
@@ -41,6 +41,11 @@ export interface DracoPointCloud {
41
41
  __destroy__(): void;
42
42
  }
43
43
 
44
+
45
+ /** draco::Mesh - inherits from PointCloud, additionally provides face info (used for TRIANGULAR_MESH branch) */
46
+ export interface DracoMesh extends DracoPointCloud {
47
+ num_faces(): number;
48
+ }
44
49
  export interface DracoStatus {
45
50
  ok(): boolean;
46
51
  error_msg(): string;
@@ -54,6 +59,14 @@ export interface DracoDecoder {
54
59
  byteLength: number,
55
60
  out: DracoPointCloud
56
61
  ): DracoStatus;
62
+ /** 解码三角网格(TRIANGULAR_MESH 分支) */
63
+ DecodeArrayToMesh(
64
+ data: Uint8Array,
65
+ byteLength: number,
66
+ out: DracoMesh
67
+ ): DracoStatus;
68
+ /** 从已解码的 Mesh 取第 faceIndex 个面的 3 个顶点索引到 DracoInt32Array */
69
+ GetFaceFromMesh(mesh: DracoMesh, faceIndex: number, out: DracoInt32Array): boolean;
57
70
  /** 按属性在几何里的序号取 */
58
71
  GetAttribute(pc: DracoPointCloud, index: number): DracoPointAttribute;
59
72
  /** 按 unique_id 取;不存在时返回 null */
@@ -113,6 +126,7 @@ export interface DracoUInt32Array {
113
126
  export interface DracoModule {
114
127
  Decoder: new () => DracoDecoder;
115
128
  PointCloud: new () => DracoPointCloud;
129
+ Mesh: new () => DracoMesh;
116
130
  DracoFloat32Array: new () => DracoFloat32Array;
117
131
  DracoInt8Array: new () => DracoInt8Array;
118
132
  DracoUInt8Array: new () => DracoUInt8Array;
@@ -88,12 +88,17 @@ export class S3MWorkerClient {
88
88
  * 解析一块瓦片。buffer 会被 structured clone(不 transfer),主线程保留原 buffer 以便崩溃后降级重解。
89
89
  * assemble = true 时顺带在 Worker 里做纯数据几何组装,返回 { tile, geometry } 一次带回。
90
90
  */
91
- parseTile(buffer: ArrayBuffer, assemble = false, batching = true): Promise<{ tile: ParsedTile; geometry?: TileGeometryData }> {
91
+ parseTile(
92
+ buffer: ArrayBuffer,
93
+ assemble = false,
94
+ batching = true,
95
+ vertexAttributeDescript?: unknown
96
+ ): Promise<{ tile: ParsedTile; geometry?: TileGeometryData }> {
92
97
  if (this.dead) return Promise.reject(new WorkerDeadError('s3m 解析 Worker 已终止'));
93
98
  const id = ++this._seq;
94
99
  return new Promise<{ tile: ParsedTile; geometry?: TileGeometryData }>((resolve, reject) => {
95
100
  this._pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
96
- this._worker.postMessage({ type: 'parse', id, buffer, assemble, batching });
101
+ this._worker.postMessage({ type: 'parse', id, buffer, assemble, batching, vertexAttributeDescript });
97
102
  });
98
103
  }
99
104
 
@@ -28,6 +28,7 @@ type ParseRequest = {
28
28
  /** 解析后顺带做纯数据几何组装(解交错 / 法线 / 合批),一次往返带回 */
29
29
  assemble?: boolean;
30
30
  batching?: boolean;
31
+ vertexAttributeDescript?: unknown;
31
32
  };
32
33
  type TexRequest = { type: 'tex'; id: number; tex: S3MTexture; compressed?: boolean; maxSize?: number };
33
34
  type Request = ParseRequest | TexRequest;
@@ -52,10 +53,13 @@ ctx.onmessage = async (e: MessageEvent<Request>) => {
52
53
  const { id } = msg;
53
54
  try {
54
55
  if (msg.type === 'parse') {
55
- const tile = await parseS3MB(msg.buffer);
56
+ const tile = await parseS3MB(msg.buffer, { vertexAttributeDescript: msg.vertexAttributeDescript });
56
57
  if (msg.assemble) {
57
58
  // 解析 + 组装一起做,避免 tile 回主线程后再整块克隆回来一次
58
- const geometry = assembleTileGeometry(tile, { batching: msg.batching });
59
+ const geometry = assembleTileGeometry(tile, {
60
+ batching: msg.batching,
61
+ vertexAttributeDescript: msg.vertexAttributeDescript,
62
+ });
59
63
  ctx.postMessage(
60
64
  { id, ok: true, tile, geometry } satisfies ParseResponse,
61
65
  [...collectTransferables(tile), ...collectTransferables(geometry)]