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
package/docs/API.md CHANGED
@@ -170,13 +170,19 @@ new S3MViewer(options?: S3MViewerOptions)
170
170
  | (随 `useWorker`)纹理解码 | `boolean` | `true` | DXT / CRN / WEBP 纹理解码与 `.s3mb` 解析共用同一个 Worker(`core/TextureDecode.ts` 三方共用同一份实现,WEBP 走可 transfer 的 ImageBitmap);失败自动回主线程,产物逐字节一致 |
171
171
  | (随 `useWorker`)几何组装 | `boolean` | `true` | 顶点解交错 / 法线计算 / 同材质合批合并(`core/TileGeometry.ts`)随解析在 Worker 完成,主线程只做 three 对象包装;Worker 不可用时主线程跑同一份实现,产物一致 |
172
172
  | (随 `useWorker`)DXT 压缩直传 | 自动 | 自动 | 宿主 GL 同时支持 `WEBGL_compressed_texture_s3tc`(+srgb) 时,dxt1/dxt5(含 CRN 转码产物)不解码直接 `CompressedTexture` 上传,显存与上传带宽降为 1/4~1/8;块长与 4×4 对齐公式不符、flipY 图集、移动端无扩展时自动回退 RGBA 解码 |
173
+ | `perTilePlacement` | `boolean` | `true` | 瓦片级切平面放置与拾取反解(**默认开启**)。每块瓦片用自身中心纬度计算墨卡托比例与反解拾取,消除「全场景共用一个常量切平面」在大范围(距场景锚点 20 km 处 0.26% 误差,约 26 m;200 km 处 2.8 km)下的累积尺度误差与拾取偏移。逆矩阵自动缓存在瓦片 group 上避免拾取性能损耗。可显式传 `false` 回退旧版单一全场景切平面(下游若缓存了旧坐标或与旧数据对齐时使用) |
174
+ | `bakeMercator` | `boolean` | `undefined`(自动) | 全球大尺度下逐顶点烘焙精确 Web 墨卡托偏移(Issue #15)。消除大范围单瓦片内因墨卡托纬度非线性缩放导致的视差游动与呼吸效应。默认 `undefined`:当瓦片集根节点包围球半径 > 25 km 时自动启用;<= 25 km 时保持关闭(顶点 buffer 逐字节与旧版相同)。传 `true` / `false` 强制开关 |
173
175
  | `lodRangeScale` | `number` | `1.0` | LOD 切换距离缩放系数,> 1 更倾向保留粗模,< 1 提前细化 |
176
+ | `lodTransition` | `'binary' \| 'incremental'` | `'binary'` | LOD 细化过渡策略。`'binary'`(默认):整节点二值切换,所有子瓦片全部就绪或失败后整块替换;`'incremental'`:增量细化(primary / fallback 双列表 + LEQUAL 深度推移),子瓦片就绪一个显示一个,未就绪区域由最近父瓦片(fallback)垫底填充,消除整块跳变与慢 child 阻塞,极端角度接缝无缝衔接且不出洞 |
177
+ | `fallbackOffset` | `{ factor?: number; units?: number }` | `{ factor: 1, units: 2 }` | 增量细化模式下 fallback 兜底瓦片的材质深度偏移系数,向远处推移深度配合 gl.LEQUAL 消除共面 z-fighting |
174
178
  | `lodPixelRatioCap` | `number` | `Infinity` | LOD 判定屏幕高度的 **DPR 上限**(不截断)。默认按 `canvasHeight` 物理像素算,与官方 `calcPixFromCam` 同口径;3x DPR 手机上由此会多细化、多拉瓦片,传 `2` 即按「CSS 高度 × 2」截断 |
175
179
  | `anisotropyCap` | `number` | `Infinity` | 各向异性过滤上限(不封顶 = 取 GPU 最大值,通常 16)。移动端封到 4 / 8 肉眼通常无差,采样成本明显下降。**图层级**:运行时 `setAnisotropyCap()` 会立刻应用到已上传纹理(改采样参数 + 重传,不重解码、不重载瓦片),图集纹理恒为 1 |
176
180
  | `maxTextureSize` | `number` | `Infinity` | 纹理长边像素上限(不限制)。非图集的 RGBA / WebP 产物按 2× 盒式滤波减半到上限内;DXT 压缩直传与图集不受影响。**图层级**,但只影响之后解码的纹理(已解码的不重解) |
177
181
  | `maxImageCache` | `number` | `128` | 纹理缓存条数上限。一条 = 一张已解码的瓦片纹理,1024² RGBA 约 4MB,128 条即约 512MB 显存;低端设备 32~48。LRU,降下来下次淘汰时释放 |
178
182
  | `materialQuality` | `'pbr' \| 'lambert'` | `'pbr'` | 光照模型。`lambert` 用 `MeshLambertMaterial` 替换 PBR,去掉 GGX 与环境球谐,片元约省一半;白模 / 纯色 / 按高度着色几乎无差。**图层级**:同页多个图层各用各的,互不覆盖;不传则跟随模块级默认(`setMaterialQuality`)。只影响之后创建的材质 |
179
183
  | `colorPipeline` | `'linear' \| 'direct'` | `'linear'` | 颜色管线。`direct` = 官方 iClient3D 的 sRGB 直乘(同时关掉纹理解码),`lightingPreset: 'supermap'` 会选它。**图层级**:同页多图层各用各的;已加载瓦片可运行时 `setColorPipeline(q, { duration })` 走 fade-through 平滑切过去(重建材质 + 重传纹理,不重下瓦片) |
184
+ | `motionBlur` | `{ mode?: 'afterimage' \| 'velocity'; damp?: number \| 'auto'; maxOffset?: number }` | 不传 = 关闭 | 可选的运动模糊后处理(**默认关闭**,不传时渲染路径与旧版逐字一致)。低端 / 移动设备低帧率下,用帧间残影在感知层面填补卡顿与边缘闪烁。`afterimage`(缺省)= 当前帧 + 上一帧全屏混合,几乎零边际 GPU 成本;`velocity` = 按上一帧 ↔ 当前帧相机矩阵逐像素反推屏幕速度、沿速度方向 8 tap 采样(方向感知,只覆盖相机运动)。`damp` 为拖尾长度(仅 afterimage),`'auto'`(缺省)按实时 FPS 收敛(`0.55^(fps/60)` 封顶 0.92),数值钳 [0, 0.98];`maxOffset` 为拖影上限(仅 velocity),速度向量的最大屏幕偏移按绘图缓冲宽度的百分比计,缺省 `0.05`,数值钳 [0, 1]。运行时 `setMotionBlur(null)` 关闭并释放 RT。仅 WebGL2(WebGL1 静默回退直渲);已知限制:拖影只在当前帧几何覆盖范围内(合成 quad 依赖本帧深度保住遮挡正确性);RT 用 HalfFloatType 存线性值(高光不被 clamp,代价是三张全屏纹理显存 ×2) |
185
+ | `depthMode` | `'scene' \| 'clear' \| 'auto'` | `'scene'` | 本层绘制前深度缓冲处理策略。`'scene'`(默认):保留宿主深度,建筑/管线等离散对象受地形正常遮挡;`'clear'`:先清深度再画,贴地倾斜摄影/开挖地表/大范围 DEM 化模型消除共面 z-fighting;`'auto'`:按 ground 覆盖或数据集类型自动推断 |
180
186
  | `buildTile` | `BuildTileFn \| null` | — | 自定义瓦片构建器(测试 / 服务端渲染时注入) |
181
187
  | `attributes` | `AttributeQueryOptions` | — | 可选的 iServer 数据服务配置,配了才能用 `colorByField()` 等便利方法;不配就自己取数 + `setFeaturesStyle()`,见[业务字段](#业务字段锚点在库内对应关系在外部) |
182
188
  | `onRequestRepaint` | `() => void` | — | 宿主请求重画的信号:纯 three 宿主接自己的渲染循环 |
@@ -207,6 +213,15 @@ prewarmShading(): Promise<void> // 手动预热另一种 shading 变体
207
213
 
208
214
  `geo` 是可写的 `GeoAdapter | null`:`null` 用内置的墨卡托兜底(`worldFromLngLat` / `lngLatFromWorld`),地图宿主注入适配器以支持任意 CRS / 自定义投影。
209
215
 
216
+ 内置兜底使用高精度 **WGS84 椭球模型**($a=6378137, f=1/298.257223563$):
217
+ - `worldFromLngLat` 返回带有各向异性尺度的对象:`{ x, y, z, meterInMercatorEast, meterInMercatorNorth, meterInMercatorUp, meterInMercator, altitude }`
218
+ - 东西向尺度对应卯酉圈半径 $N(\varphi) = a / \sqrt{1 - e^2 \sin^2\varphi}$:$1 / (2\pi N \cos\varphi)$
219
+ - 南北向尺度对应子午圈半径 $M(\varphi) = a(1 - e^2) / (1 - e^2 \sin^2\varphi)^{3/2}$:$1 / (2\pi M \cos\varphi)$
220
+ - 竖直向高程尺度 `meterInMercatorUp` 取东向尺度(局部正圆保角)
221
+ - `meterInMercator` 保留作为向前兼容属性,取值从旧版球面近似 $1/(2\pi R\cos\varphi)$ 升级为真实的 WGS84 椭球东向尺度(属于精度修正,默认生效且更精准)
222
+ - 彻底消除传统单标量模型在南北/东西方向上 0.4%~0.67% 的各向异性拉伸与米级系统残差。
223
+ - 包导出了高精度大地测量工具:`primeVerticalRadius`, `meridianRadius`, `enuToLngLat`, `lngLatToEnu` 及 `WGS84_A`, `WGS84_E2` 常量。
224
+
210
225
  ### 数据集管理
211
226
 
212
227
  ```ts
@@ -515,6 +530,8 @@ type ClipOptions =
515
530
  ```ts
516
531
  setLodRangeScale(scale: number, datasetId?: string): void
517
532
  readonly lodRangeScale: number
533
+ readonly lodTransition: 'binary' | 'incremental'
534
+ setLodTransition(mode: 'binary' | 'incremental'): void
518
535
  setLodPixelRatioCap(cap: number): void // Infinity = 不截断(默认)
519
536
  readonly lodPixelRatioCap: number
520
537
  setMaxTriangles(n: number, datasetId?: string): void // Infinity = 不限制(默认)
@@ -526,6 +543,10 @@ readonly anisotropyCap: number
526
543
  setMaxTextureSize(n: number): void // Infinity = 不限制(默认)
527
544
  readonly maxTextureSize: number
528
545
  readonly maxImageCache: number
546
+ setDepthMode(mode: DepthMode): void // 运行时调整深度处理策略('scene' | 'clear' | 'auto')
547
+ readonly depthMode: DepthMode // 当前深度策略
548
+ setMotionBlur(cfg: MotionBlurOptions | null): void // 运行时开关运动模糊;null = 关闭并释放 RT 显存
549
+ readonly motionBlur: MotionBlurOptions | null // 当前配置(归一化后的副本)
529
550
  readonly stats: Stats
530
551
  readonly fps: number
531
552
  getBounds(): [[number, number], [number, number]] | null // 合并经纬度范围
@@ -1366,9 +1387,9 @@ bun run test:meshopt
1366
1387
 
1367
1388
  **这两条 GS 流的量化格式还没标定**(4 字节 / 点,既不像 3 个 float,也不像明显四元数),所以渲染层目前只是把它们带到顶点属性上备用,没有接进真正的 splat 着色器。
1368
1389
 
1369
- ### Draco 点云几何
1390
+ ### Draco 几何(点云与三角网格)
1370
1391
 
1371
- v3.01 的点云数据还有一条 Draco 压缩分支(骨架 tag = `DRACO`)。接法与 MeshOpt 相同:内联 wasm 解码器(约 266KB)只在真的遇到时动态 `import`,解码后并入同一套 attribute 组装,点云按 `primitiveType = 1` 交给 `THREE.Points`,分类码 / 强度 / 权重三种自定义属性一并解出来。
1392
+ v3.01 支持 Draco 压缩分支(骨架 tag = `DRACO`),涵盖点云与三角网格(`TRIANGULAR_MESH`)。接法与 MeshOpt 相同:内联 wasm 解码器(约 266KB)只在遇到时动态 `import`,解码后并入同一套 attribute 组装:点云按 `primitiveType = 1` 交给 `THREE.Points`(分类码/强度/权重一并解出);三角网格按面解出顶点索引与法线,按 `primitiveType = 4` 交给 `THREE.Mesh`。遇到未知几何类型自动 warn 降级并跳过,避免整层崩溃。
1372
1393
 
1373
1394
  **这个分支的字段顺序与规范描述对不上,是从真实瓦片逐字节标定的**(偏移以骨架 tag 字段之后为 0):
1374
1395
 
@@ -1404,16 +1425,25 @@ ts.update([0, 0, 500], 0.64, 900); // 相机在局部 ENU 的位置
1404
1425
  https://host/iserver/services/3D-xxx/rest/realspace/datas/<数据源名>/config
1405
1426
  ```
1406
1427
 
1407
- 数据源名可以是中文(如 `寿宁阁`),`new URL()` 会自动百分号编码;后缀只能写 `config`,写 `config.xml` 会 406。只给到 `…/realspace/datas` 时拿到的是一个资源清单数组,`JSON.parse` 不报错但没有 `position` / `rootTiles`,表现为「视角跳到 (0,0) + 一句缺少 root tile」——先用 `…/datas.json` 列出数据源,再逐个拼 `/config`。
1428
+ 数据源名可以是中文(如 `寿宁阁`),`new URL()` 会自动百分号编码;后缀只能写 `config`,写 `config.xml` 会 406。只给到 `…/realspace/datas` 时拿到的是一个资源清单数组,`JSON.parse` 不报错但没有 `position` / `rootTiles`,表现为「视角跳到 (0,0) + 一句缺少 root tile」——先用 `…/datas.json` 列出数据源,再逐个拼 `/config`。这类报错会带上 config 的类型标记(`FileExtentName` / `Type` / `fileType` / `dataType`),便于区分「清单当配置」和「桌面导出漏瓦片清单」两种病因。
1429
+
1430
+ 混合服务(模型 + 地形 + 影像共存)里还有第三种触发面:地形/影像数据集的 config 也是 XML,但**没有瓦片清单**。`init` 会按类型标记提前拒绝并说明病因(见 [FORMAT-NOTES 第二章](./FORMAT-NOTES.md#二先判断是哪一种数据)),不再落到「缺少 root tile」:
1431
+
1432
+ ```
1433
+ Error: 数据集 beijingdem 是地形缓存(FileExtentName=Terrainz),本层仅支持模型类 S3M 数据集
1434
+ ```
1408
1435
 
1409
- 配套导出的三个纯函数,方便自建数据集列表与做体检:
1436
+ 配套导出的五个纯函数,方便自建数据集列表与做体检:
1410
1437
 
1411
1438
  ```ts
1412
- parseConfigText(text) // JSON 与 SuperMapCache XML 两种配置统一成同一种形状(含 fileType)
1439
+ parseConfigText(text) // JSON 与 SuperMapCache XML 两种配置统一成同一种形状(含 fileType / fileExtentName / cacheType)
1440
+ classifyDatasetKind(config) // → 'model' | 'terrain' | 'imagery';只有认出确凿的非模型标记才判 terrain / imagery
1413
1441
  usableGeoBounds(bounds) // 判断 geoBounds 是否真的可用;不可用返回 null(见「锚点」)
1414
1442
  fixPathEncoding(url) // 补路径里没编码但服务端会改写的字符:+ → %2B、空格 → %20
1415
1443
  ```
1416
1444
 
1445
+ `classifyDatasetKind` 的判别字段来自实测:地形 = `FileExtentName=Terrainz`;影像 = `Type=Image`(影像的 `FileExtentName` 是 `jpg_png` 这类扩展名样式,不可靠)。认不出标记的一律返回 `'model'`,保证行为与旧版一致;`init` 对非模型数据集抛出的错误上带 `datasetKind` 属性,数据集列表 UI 可以据此标注或跳过。
1446
+
1417
1447
  `fixPathEncoding` 这条是硬需求:OSGB 转出来的瓦片名就是 `Tile_+002_+001`,而 iServer 按 `URLDecoder` 解路径,未编码的 `+` 会变成空格 → 404「瓦片数据不存在」。`new URL()` 认为 `+` 在 path 里合法,不会替我们编码,所以拼完 URL 必须补这一刀(query 里的 `+` 不动,那本来就是空格约定)。
1418
1448
 
1419
1449
  失败处理分两类:**网络类**错误退避重试(`maxRetries`,默认 2 次);**格式类**错误(`e.permanent === true`,例如 S3M 1.x 容器)直接判失败不重试,并且整棵瓦片树里同类错误只回调一次 `onError`,避免几十块瓦片刷同样的日志。
@@ -1441,6 +1471,7 @@ buildAttributes({ smid, box, triangles, floorHeight }) // → S3MFeatureAttribu
1441
1471
  | `maxTextureSize` | 纹理长边像素上限,默认不限制。手机上 1024 即可,显存降到 1/4 |
1442
1472
  | `maxImageCache` | 纹理缓存条数,默认 128。低端机 32~48,和 `maxTextureSize` 一起管显存 |
1443
1473
  | `materialQuality: 'lambert'` | 换成 Lambert 光照,片元约省一半。白模 / 按高度配色看不出差别,贴图模式会略平。图层级设置,同页其它图层不受影响 |
1474
+ | `motionBlur: { mode: 'afterimage' }` | 运动模糊后处理(默认关闭)。低端机低帧率下用帧间残影填卡顿:`afterimage` 每帧只多两次全屏混合,`damp: 'auto'` 按实时 FPS 自适应拖尾长度。建议与上面的预算开关搭配用。注意 RT 为 HalfFloat(半精度浮点,显存 / 带宽 ×2),显存极度吃紧时权衡是否开启 |
1444
1475
  | `maxImageCache` | 原始图集与 leaf 段图像记录的 LRU 缓存容量上限(默认 128),超限自动淘汰旧缓存,防止漫游时内存不断累积 |
1445
1476
  | 纹理引用计数 | 瓦片淘汰时按引用计数释放未被其他瓦片复用的 `Texture` 并调用 `dispose()`,真正归还 GPU 显存 |
1446
1477
  | 几何/材质深度释放 | 瓦片注销时深度遍历对象树,彻底释放 `Mesh`、`Points`(点云/高斯泼溅)以及 `LineSegments`(棱线包)的 `BufferGeometry` 与材质 |
@@ -33,6 +33,16 @@ C 级的处置原则:**只在新样本进来时提高级别,不靠推理提
33
33
  | SuperMapCache / S3M 1.x | **XML**(`<SuperMapCache>`,`sml:FileType = OSGBFile`) | `.s3m`(整棵 LOD 树同一种容器) | `S3M1Parser` |
34
34
  | 高斯泼溅 | JSON,`dataType = "3DGaussianSplatting"` | `.s3mb` v3.01 + MeshOpt,**无索引包** | `S3MBParser` + `MeshOpt` |
35
35
 
36
+ 还有第四种撞法(issue #7):**容器形态识别对了(XML),但内容不是模型**。混合服务(模型 + 地形 + 影像共存,iServer / supermapol 上很常见)里地形/影像数据集的 config 也是 SuperMapCache XML,只是按 `Level0Width/Level0Height + Levels` 的金字塔寻址、**没有瓦片清单**。实测判别字段(3D-OlympicGreen_Plan,2026-10-30):
37
+
38
+ | 数据集 | config 根节点 | 判别字段 |
39
+ |:---|:---|:---|
40
+ | `beijingdem`(地形) | `<SuperMapCache>` | `sml:FileExtentName = Terrainz` |
41
+ | `beijingimg`(影像) | `<SuperMapCache_Unicode>` | `sml:Type = Image`(它的 FileExtentName 是 `jpg_png` 这种扩展名样式,**不可靠**) |
42
+ | `Building` 等(模型) | `<SuperMapCache>` | `sml:FileType = OSGBFile / OSGBCacheFile`,无 FileExtentName / Type |
43
+
44
+ `classifyDatasetKind(config)` 按上表识别,认出地形/影像时 `S3MTileset.init` 会在解析锚点之前明确报错(错误带 `datasetKind` 属性),不再落到误导性的「配置中缺少 root tile」。
45
+
36
46
  **S3M 1.x 必须靠魔数分流,不能靠配置。** 头 4 字节 `73 33 6D 00`(`"s3m\0"`)紧接 `78 da` 的 zlib。不分流的话,头 4 字节会被 float32 读成 1e-38 的「版本号」,再把 zlib 头当长度,报出来的是 `RangeError: Invalid typed array length` —— 一个彻底误导人的错。
37
47
 
38
48
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "s3m-three",
3
- "version": "0.4.25",
3
+ "version": "0.4.27",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * 全球大尺度精确 Web 墨卡托烘焙(issue #15)。
3
+ *
4
+ * ## 背景与原理
5
+ *
6
+ * S3M 场景通常只有一个切平面:`_enuMatrix` 用场景锚点纬度的常量 `meterInMercator`
7
+ * 把整个场景缩放进 Web 墨卡托。但在跨度超过几十公里的全球或大区域场景中,
8
+ * 墨卡托投影比例随纬度变化(k = 1 / cos(φ))。
9
+ * 即使有 `perTilePlacement`(issue #12)按瓦片重置了原点与缩放,瓦片内部仍然存在
10
+ * 线性切平面与真实墨卡托投影之间的微小残差。
11
+ *
12
+ * 烘焙方案:
13
+ * 1. 预先或在组装瓦片几何时,逐顶点计算其精确的 Web 墨卡托坐标;
14
+ * 2. 减去瓦片中心的精确墨卡托坐标,得到相对于瓦片中心的相对偏移量 `aMercatorOffset`(float32);
15
+ * 3. 为什么是相对偏移:
16
+ * Web 墨卡托全局坐标在 0..1 之间,若直接用 float32(24 位尾数)存储绝对坐标,
17
+ * 分辨率约为 40000000 / 2^24 ≈ 2.38 米;
18
+ * 若改为相对瓦片中心(瓦片半径通常 <= 100 km),float32 尾数精度可达:
19
+ * 100000 / 2^24 ≈ 0.0059 米(小于 6 毫米),常规 2 km 瓦片下更是达到 0.1 毫米以内。
20
+ * 4. 着色器重构:
21
+ * 顶点着色器在局部对象空间中还原出抵消瓦片尺度与中心平移后的坐标:
22
+ * `transformed = uTilePivot + vec3(aMercatorOffset.x, -aMercatorOffset.y, aMercatorOffset.z) * uInvTileScale;`
23
+ * 其中 `uInvTileScale = 1 / (sScene * k) = 1 / sTile`。
24
+ * 经过瓦片矩阵变换与 `_enuMatrix` 缩放平移后,刚好恒等于真实的世界墨卡托坐标,误差 < 1 cm。
25
+ */
26
+
27
+ import {
28
+ EARTH_RADIUS,
29
+ enuToLngLat as geoEnuToLngLat,
30
+ mercatorFromLngLat,
31
+ } from '../render/GeoAdapter.js';
32
+
33
+ /** 超过此半径(米)的瓦片在未指定参数时自动开启墨卡托烘焙(25 km) */
34
+ export const AUTO_BAKE_RADIUS_THRESHOLD = 25000;
35
+
36
+ export interface TileAnchorLike {
37
+ lon: number;
38
+ lat: number;
39
+ height?: number;
40
+ }
41
+
42
+ export interface TileCenterLike {
43
+ x: number;
44
+ y: number;
45
+ z?: number;
46
+ }
47
+
48
+ /**
49
+ * 局部 ENU 偏移(东, 北 米)→ 经纬度(对接 GeoAdapter 的 WGS84 高精度模型)。
50
+ */
51
+ export function enuToLngLat(anchor: TileAnchorLike, east: number, north: number): [number, number] {
52
+ return geoEnuToLngLat(anchor.lon, anchor.lat, east, north);
53
+ }
54
+
55
+ /**
56
+ * 逐顶点计算 Web 墨卡托相对于瓦片中心的偏移量。
57
+ *
58
+ * @param positions 顶点位置数组(x, y, z...)
59
+ * @param vertexCount 顶点数
60
+ * @param tileAnchor 数据集/瓦片地理锚点
61
+ * @param tileCenter 瓦片中心在数据集局部 ENU 中的位置(米)
62
+ * @param stride 每个顶点的分量数(默认为 3)
63
+ * @param target 可选的复用 Float32Array
64
+ * @returns 长度为 vertexCount * 3 的 Float32Array,存 [dx, dy, dz, ...]
65
+ */
66
+ export function bakeMercatorOffsets(
67
+ positions: ArrayLike<number>,
68
+ vertexCount: number,
69
+ tileAnchor: TileAnchorLike,
70
+ tileCenter: TileCenterLike,
71
+ stride = 3,
72
+ target?: Float32Array
73
+ ): Float32Array {
74
+ const out = target && target.length >= vertexCount * 3 ? target : new Float32Array(vertexCount * 3);
75
+
76
+ const centerAlt = (tileAnchor.height ?? 0) + (tileCenter.z ?? 0);
77
+ const [centerLon, centerLat] = enuToLngLat(tileAnchor, tileCenter.x, tileCenter.y);
78
+ const cMerc = mercatorFromLngLat(centerLon, centerLat, centerAlt);
79
+
80
+ for (let i = 0; i < vertexCount; i++) {
81
+ const idx = i * stride;
82
+ const vx = positions[idx];
83
+ const vy = positions[idx + 1];
84
+ const vz = positions[idx + 2] ?? 0;
85
+
86
+ const vAlt = (tileAnchor.height ?? 0) + vz;
87
+ const [vLon, vLat] = enuToLngLat(tileAnchor, vx, vy);
88
+ const vMerc = mercatorFromLngLat(vLon, vLat, vAlt);
89
+
90
+ out[i * 3 + 0] = vMerc.x - cMerc.x;
91
+ out[i * 3 + 1] = vMerc.y - cMerc.y;
92
+ out[i * 3 + 2] = vMerc.z - cMerc.z;
93
+ }
94
+
95
+ return out;
96
+ }
@@ -0,0 +1,53 @@
1
+ import type { VertexAttribute, VertexPackage } from './types.js';
2
+
3
+ /**
4
+ * 根据 descript(来自瓦片内嵌或 config.json 的 vertexAttributeDescript)
5
+ * 将 custom 扩展属性槽(如 custom0, custom1...)重命名为实际的语义名(如 VertexWeight, TextureCoordMatrix)。
6
+ */
7
+ export function applyCustomAttributeSemantics(
8
+ vp: VertexPackage,
9
+ extAttrs?: VertexAttribute[],
10
+ configDescript?: unknown
11
+ ): void {
12
+ const attrsToRemap = extAttrs ?? vp.vertexAttributes.filter((a) => a.semantic.startsWith('custom'));
13
+ if (attrsToRemap.length === 0) return;
14
+
15
+ const descript = vp.customVertexAttribute ?? configDescript;
16
+
17
+ for (let i = 0; i < attrsToRemap.length; i++) {
18
+ const attr = attrsToRemap[i]!;
19
+ if (!attr.semantic.startsWith('custom')) continue;
20
+ const slotMatch = attr.semantic.match(/^custom(\d+)$/);
21
+ const slot = slotMatch ? parseInt(slotMatch[1]!, 10) : i;
22
+
23
+ let category: string | undefined;
24
+
25
+ const resolveCategory = (source: unknown): string | undefined => {
26
+ if (!source || typeof source !== 'object') return undefined;
27
+ if (Array.isArray(source)) {
28
+ const item = source[slot];
29
+ if (typeof item === 'string') return item;
30
+ if (item && typeof item === 'object' && typeof item.category === 'string') return item.category;
31
+ return undefined;
32
+ }
33
+ for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
34
+ if (v === slot) return k;
35
+ }
36
+ return undefined;
37
+ };
38
+
39
+ category = resolveCategory(descript);
40
+ if (!category && configDescript && configDescript !== descript) {
41
+ category = resolveCategory(configDescript);
42
+ }
43
+
44
+ if (category) {
45
+ delete vp.attrLocation[attr.semantic];
46
+ delete vp.attrLocation[`a${attr.semantic}`];
47
+ attr.semantic = category;
48
+ const idx = vp.vertexAttributes.indexOf(attr);
49
+ vp.attrLocation[category] = idx;
50
+ vp.attrLocation[`a${category}`] = idx;
51
+ }
52
+ }
53
+ }
package/src/core/Draco.ts CHANGED
@@ -1,4 +1,4 @@
1
- /**
1
+ /**
2
2
  * S3M v3 Draco 几何压缩(Skeleton 的 Draco 分支)
3
3
  *
4
4
  * 与 MeshOpt 一样,这里的布局不是照规范写的 —— 规范里的 Draco 分支描述和实际数据
@@ -33,7 +33,7 @@
33
33
  */
34
34
  import type { BinaryReader } from './BinaryReader.js';
35
35
  import type { AttrDType, IndexPackage, VertexPackage } from './types.js';
36
- import type { DracoModule, DracoPointAttribute, DracoPointCloud } from '../vendor/draco_decoder.module.js';
36
+ import type { DracoMesh, DracoModule, DracoPointAttribute, DracoPointCloud } from '../vendor/draco_decoder.module.js';
37
37
 
38
38
  /* ------------------------------------------------------------ draco 枚举(本地副本) */
39
39
 
@@ -57,6 +57,9 @@ const DRACO_MAGIC = 'DRACO';
57
57
  /** primitiveType = 1:点云包(渲染层据此走 THREE.Points) */
58
58
  const POINT_PRIMITIVE = 1;
59
59
 
60
+ /** primitiveType = 4:三角网格包(渲染层据此走 THREE.Mesh,对应 gl.TRIANGLES) */
61
+ const TRIANGLE_PRIMITIVE = 4;
62
+
60
63
  /** 单个 skeleton 的点数上限,防头读错时开出天文数字的缓冲 */
61
64
  const MAX_POINTS = 64 * 1024 * 1024;
62
65
 
@@ -243,13 +246,27 @@ export async function parseDracoGeometry(r: BinaryReader, vp: VertexPackage): Pr
243
246
  /* ---- 2. 解码 ---- */
244
247
 
245
248
  const decoder = new draco.Decoder();
249
+ const geomType = decoder.GetEncodedGeometryType(data);
250
+
251
+ if (geomType === GEOM_TRIANGULAR_MESH) {
252
+ // 三角网格分支:draco mesh 内部自带面索引,不需要容器里的 IndexPackage
253
+ // 官方 S3ModelParser 对 Draco 分支的索引包解析阶段是空操作(see Issue #5)
254
+ const mesh = new draco.Mesh();
255
+ try {
256
+ return decodeDracoMesh(draco, decoder, mesh, data, vp, posUniqueId, normalUniqueId, colorUniqueId);
257
+ } finally {
258
+ try { draco.destroy(decoder); draco.destroy(mesh); } catch { /* ignore */ }
259
+ }
260
+ }
261
+
262
+ // 点云分支(GEOM_POINT_CLOUD)或其他未知类型
246
263
  const geometry = new draco.PointCloud();
247
264
  try {
248
- const geomType = decoder.GetEncodedGeometryType(data);
249
265
  if (geomType !== GEOM_POINT_CLOUD) {
250
- throw new Error(
251
- `[s3m] Draco ${geomType === GEOM_TRIANGULAR_MESH ? '三角网格' : '未知'} 几何暂未实现(仅支持点云)`
252
- );
266
+ // 未知几何类型:warn 并降级为空包,避免整层崩溃
267
+ console.warn(`[s3m] Draco 未知几何类型 ${geomType},跳过该骨架`);
268
+ vp.verticesCount = 0;
269
+ return [pointPack()];
253
270
  }
254
271
 
255
272
  const status = decoder.DecodeArrayToPointCloud(data, data.length, geometry);
@@ -373,6 +390,151 @@ function pointPack(): IndexPackage {
373
390
  };
374
391
  }
375
392
 
393
+ /**
394
+ * 解码 Draco TRIANGULAR_MESH 几何体,填充 VertexPackage 并返回带索引的 IndexPackage。
395
+ *
396
+ * 按官方 S3MDracoDecode.js 逻辑(Issue #5 调研):
397
+ * - DecodeArrayToMesh → num_faces → 逐面 GetFaceFromMesh → 填入索引缓冲
398
+ * - primitiveType = TRIANGLE_PRIMITIVE(4),让渲染层走 THREE.Mesh
399
+ * - 容器头(posUniqueId/normalUniqueId/colorUniqueId)与点云共用,直接复用属性装配代码
400
+ *
401
+ * 调用方负责 destroy(decoder) 和 destroy(mesh)。
402
+ */
403
+ function decodeDracoMesh(
404
+ draco: DracoModule,
405
+ decoder: InstanceType<DracoModule['Decoder']>,
406
+ mesh: DracoMesh,
407
+ data: Uint8Array,
408
+ vp: VertexPackage,
409
+ posUniqueId: number,
410
+ normalUniqueId: number,
411
+ colorUniqueId: number,
412
+ ): IndexPackage[] {
413
+ const status = decoder.DecodeArrayToMesh(data, data.length, mesh);
414
+ if (!status.ok()) throw new Error(`[s3m] Draco mesh 解码失败:${status.error_msg()}`);
415
+
416
+ const numPoints = mesh.num_points();
417
+ if (!Number.isInteger(numPoints) || numPoints < 0 || numPoints > MAX_POINTS) {
418
+ throw new Error(`[s3m] Draco mesh 顶点数异常:${numPoints}`);
419
+ }
420
+ const numFaces = mesh.num_faces();
421
+
422
+ if (numPoints === 0 || numFaces === 0) {
423
+ vp.verticesCount = 0;
424
+ return [meshPack(new Uint8Array(0), 0, 0)];
425
+ }
426
+
427
+ /* ---- 属性装配(与点云分支共用逻辑,但三角网格有法线) ---- */
428
+
429
+ for (let i = 0; i < mesh.num_attributes(); i++) {
430
+ const attr = decoder.GetAttribute(mesh, i);
431
+ const uid = attr.unique_id();
432
+ const comps = Math.max(1, attr.num_components());
433
+
434
+ if (uid === posUniqueId || attr.attribute_type() === ATTR_POSITION) {
435
+ if (attr.data_type() !== DT_FLOAT32) {
436
+ throw new Error(`[s3m] Draco mesh 位置属性不是 float32(dt=${attr.data_type()})`);
437
+ }
438
+ const raw = readAttribute(draco, decoder, mesh, attr) as Float32Array | null;
439
+ if (!raw) throw new Error('[s3m] Draco mesh 位置属性读取失败');
440
+ const out = new Float32Array(numPoints * 3);
441
+ for (let p = 0; p < numPoints; p++) {
442
+ out[p * 3] = raw[p * comps] ?? 0;
443
+ out[p * 3 + 1] = raw[p * comps + 1] ?? 0;
444
+ out[p * 3 + 2] = raw[p * comps + 2] ?? 0;
445
+ }
446
+ pushAttr(vp, 'position', new Uint8Array(out.buffer), 3, 12, 0, undefined, 'f32');
447
+ continue;
448
+ }
449
+
450
+ if (uid === normalUniqueId || attr.attribute_type() === ATTR_NORMAL) {
451
+ if (attr.data_type() === DT_FLOAT32) {
452
+ const raw = readAttribute(draco, decoder, mesh, attr) as Float32Array | null;
453
+ if (raw) {
454
+ // 法线量化由 draco 解码时自动还原为 float32
455
+ const out = new Float32Array(numPoints * 3);
456
+ for (let p = 0; p < numPoints; p++) {
457
+ out[p * 3] = raw[p * comps] ?? 0;
458
+ out[p * 3 + 1] = raw[p * comps + 1] ?? 0;
459
+ out[p * 3 + 2] = raw[p * comps + 2] ?? 0;
460
+ }
461
+ pushAttr(vp, 'normal', new Uint8Array(out.buffer), 3, 12, 0, undefined, 'f32');
462
+ }
463
+ }
464
+ continue;
465
+ }
466
+
467
+ if (uid === colorUniqueId || attr.attribute_type() === ATTR_COLOR) {
468
+ const raw = readAttribute(draco, decoder, mesh, attr);
469
+ if (raw) {
470
+ const out = new Uint8Array(numPoints * 4);
471
+ const isFloat = raw instanceof Float32Array;
472
+ for (let p = 0; p < numPoints; p++) {
473
+ const o = p * comps;
474
+ if (isFloat) {
475
+ out[p * 4] = clamp255(raw[o] ?? 1);
476
+ out[p * 4 + 1] = clamp255(raw[o + 1] ?? 1);
477
+ out[p * 4 + 2] = clamp255(raw[o + 2] ?? 1);
478
+ } else {
479
+ out[p * 4] = raw[o] ?? 255;
480
+ out[p * 4 + 1] = raw[o + 1] ?? 255;
481
+ out[p * 4 + 2] = raw[o + 2] ?? 255;
482
+ }
483
+ out[p * 4 + 3] = comps >= 4 && !isFloat ? (raw[o + 3] ?? 255) : 255;
484
+ }
485
+ pushAttr(vp, 'color', out, 4, 4, 0, undefined, 'u8');
486
+ }
487
+ continue;
488
+ }
489
+ // 其余属性(texCoord 等):暂不处理,等真实瓦片来后按需补齐
490
+ }
491
+
492
+ if (vp.attrLocation.position === undefined) {
493
+ throw new Error('[s3m] Draco mesh 缺少 position 属性');
494
+ }
495
+
496
+ vp.verticesCount = numPoints;
497
+
498
+ /* ---- 三角面索引构建(按官方逻辑:numPoints <= 65535 用 U16,否则 U32) ---- */
499
+ const useU16 = numPoints <= 65535;
500
+ const indices = useU16
501
+ ? new Uint16Array(numFaces * 3)
502
+ : new Uint32Array(numFaces * 3);
503
+
504
+ const face = new draco.DracoInt32Array();
505
+ try {
506
+ let n = 0;
507
+ for (let f = 0; f < numFaces; f++) {
508
+ decoder.GetFaceFromMesh(mesh, f, face);
509
+ indices[n++] = face.GetValue(0);
510
+ indices[n++] = face.GetValue(1);
511
+ indices[n++] = face.GetValue(2);
512
+ }
513
+ } finally {
514
+ draco.destroy(face);
515
+ }
516
+
517
+ return [meshPack(new Uint8Array(indices.buffer), numFaces * 3, useU16 ? 0 : 1)];
518
+ }
519
+
520
+ /**
521
+ * 三角网格索引包。
522
+ *
523
+ * @param indicesBytes - Uint16Array 或 Uint32Array 的字节视图
524
+ * @param indexCount - 索引总数(numFaces × 3)
525
+ * @param indexType - 0 = Uint16, 1 = Uint32(与 IndexPackage.indexType 约定一致)
526
+ */
527
+ function meshPack(indicesBytes: Uint8Array, indexCount: number, indexType: number): IndexPackage {
528
+ return {
529
+ indexCount,
530
+ indexType,
531
+ useIndex: 1,
532
+ primitiveType: TRIANGLE_PRIMITIVE,
533
+ indices: indexCount > 0 ? indicesBytes : null,
534
+ passNames: [],
535
+ };
536
+ }
537
+
376
538
  function pushAttr(
377
539
  vp: VertexPackage,
378
540
  semantic: string,
@@ -18,6 +18,7 @@
18
18
  import type { BinaryReader } from './BinaryReader.js';
19
19
  import type { IndexPackage, VertexAttribute, VertexPackage } from './types.js';
20
20
  import type { MeshoptDecoder } from '../vendor/meshopt_decoder.module.js';
21
+ import { applyCustomAttributeSemantics } from './CustomAttributes.js';
21
22
 
22
23
  /** 顶点语义是位标记,不是枚举序号 */
23
24
  export const MeshOptSemantic = {
@@ -451,6 +452,7 @@ export async function parseMeshOptGeometry(
451
452
  for (let i = 0; i < vp.vertexAttributes.length; i++) {
452
453
  vp.attrLocation[`a${vp.vertexAttributes[i].semantic}`] = i;
453
454
  }
455
+ applyCustomAttributeSemantics(vp);
454
456
 
455
457
  return { info, indexPackages };
456
458
  }