s3m-three 0.4.25 → 0.4.26

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 (73) hide show
  1. package/dist/browser/assets/{s3mWorker-jc9Oly5K.js → s3mWorker-DHOVIrAU.js} +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +107 -36
  3. package/dist/browser/s3m-three.iife.js +107 -36
  4. package/dist/core/CustomAttributes.d.ts +7 -0
  5. package/dist/core/CustomAttributes.d.ts.map +1 -0
  6. package/dist/core/CustomAttributes.js +48 -0
  7. package/dist/core/CustomAttributes.js.map +1 -0
  8. package/dist/core/Draco.d.ts.map +1 -1
  9. package/dist/core/Draco.js +149 -2
  10. package/dist/core/Draco.js.map +1 -1
  11. package/dist/core/MeshOpt.d.ts.map +1 -1
  12. package/dist/core/MeshOpt.js +2 -0
  13. package/dist/core/MeshOpt.js.map +1 -1
  14. package/dist/core/S3M1Parser.d.ts.map +1 -1
  15. package/dist/core/S3M1Parser.js +114 -27
  16. package/dist/core/S3M1Parser.js.map +1 -1
  17. package/dist/core/S3MBParser.d.ts +5 -2
  18. package/dist/core/S3MBParser.d.ts.map +1 -1
  19. package/dist/core/S3MBParser.js +27 -12
  20. package/dist/core/S3MBParser.js.map +1 -1
  21. package/dist/core/TileGeometry.d.ts +1 -0
  22. package/dist/core/TileGeometry.d.ts.map +1 -1
  23. package/dist/core/TileGeometry.js +25 -7
  24. package/dist/core/TileGeometry.js.map +1 -1
  25. package/dist/core/types.d.ts +1 -0
  26. package/dist/core/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +4 -2
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +2 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  32. package/dist/render/GeometryBuilder.js +36 -2
  33. package/dist/render/GeometryBuilder.js.map +1 -1
  34. package/dist/render/MotionBlurPass.d.ts +130 -0
  35. package/dist/render/MotionBlurPass.d.ts.map +1 -0
  36. package/dist/render/MotionBlurPass.js +394 -0
  37. package/dist/render/MotionBlurPass.js.map +1 -0
  38. package/dist/render/S3MViewer.d.ts +27 -0
  39. package/dist/render/S3MViewer.d.ts.map +1 -1
  40. package/dist/render/S3MViewer.js +76 -4
  41. package/dist/render/S3MViewer.js.map +1 -1
  42. package/dist/render/viewer/types.d.ts +22 -0
  43. package/dist/render/viewer/types.d.ts.map +1 -1
  44. package/dist/render/viewer/types.js.map +1 -1
  45. package/dist/tileset/S3MTileset.d.ts +19 -0
  46. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  47. package/dist/tileset/S3MTileset.js +71 -4
  48. package/dist/tileset/S3MTileset.js.map +1 -1
  49. package/dist/worker/S3MWorkerClient.d.ts +1 -1
  50. package/dist/worker/S3MWorkerClient.d.ts.map +1 -1
  51. package/dist/worker/S3MWorkerClient.js +2 -2
  52. package/dist/worker/S3MWorkerClient.js.map +1 -1
  53. package/dist/worker/s3mWorker.js +791 -635
  54. package/dist/worker/s3mWorker.js.map +1 -1
  55. package/docs/API.md +18 -5
  56. package/docs/FORMAT-NOTES.md +10 -0
  57. package/package.json +1 -1
  58. package/src/core/CustomAttributes.ts +53 -0
  59. package/src/core/Draco.ts +168 -6
  60. package/src/core/MeshOpt.ts +2 -0
  61. package/src/core/S3M1Parser.ts +102 -22
  62. package/src/core/S3MBParser.ts +33 -14
  63. package/src/core/TileGeometry.ts +34 -7
  64. package/src/core/types.ts +1 -0
  65. package/src/index.ts +4 -2
  66. package/src/render/GeometryBuilder.ts +37 -2
  67. package/src/render/MotionBlurPass.ts +453 -0
  68. package/src/render/S3MViewer.ts +78 -4
  69. package/src/render/viewer/types.ts +22 -0
  70. package/src/tileset/S3MTileset.ts +74 -3
  71. package/src/vendor/draco_decoder.module.d.ts +15 -1
  72. package/src/worker/S3MWorkerClient.ts +7 -2
  73. package/src/worker/s3mWorker.ts +6 -2
package/docs/API.md CHANGED
@@ -177,6 +177,7 @@ new S3MViewer(options?: S3MViewerOptions)
177
177
  | `maxImageCache` | `number` | `128` | 纹理缓存条数上限。一条 = 一张已解码的瓦片纹理,1024² RGBA 约 4MB,128 条即约 512MB 显存;低端设备 32~48。LRU,降下来下次淘汰时释放 |
178
178
  | `materialQuality` | `'pbr' \| 'lambert'` | `'pbr'` | 光照模型。`lambert` 用 `MeshLambertMaterial` 替换 PBR,去掉 GGX 与环境球谐,片元约省一半;白模 / 纯色 / 按高度着色几乎无差。**图层级**:同页多个图层各用各的,互不覆盖;不传则跟随模块级默认(`setMaterialQuality`)。只影响之后创建的材质 |
179
179
  | `colorPipeline` | `'linear' \| 'direct'` | `'linear'` | 颜色管线。`direct` = 官方 iClient3D 的 sRGB 直乘(同时关掉纹理解码),`lightingPreset: 'supermap'` 会选它。**图层级**:同页多图层各用各的;已加载瓦片可运行时 `setColorPipeline(q, { duration })` 走 fade-through 平滑切过去(重建材质 + 重传纹理,不重下瓦片) |
180
+ | `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) |
180
181
  | `buildTile` | `BuildTileFn \| null` | — | 自定义瓦片构建器(测试 / 服务端渲染时注入) |
181
182
  | `attributes` | `AttributeQueryOptions` | — | 可选的 iServer 数据服务配置,配了才能用 `colorByField()` 等便利方法;不配就自己取数 + `setFeaturesStyle()`,见[业务字段](#业务字段锚点在库内对应关系在外部) |
182
183
  | `onRequestRepaint` | `() => void` | — | 宿主请求重画的信号:纯 three 宿主接自己的渲染循环 |
@@ -526,6 +527,8 @@ readonly anisotropyCap: number
526
527
  setMaxTextureSize(n: number): void // Infinity = 不限制(默认)
527
528
  readonly maxTextureSize: number
528
529
  readonly maxImageCache: number
530
+ setMotionBlur(cfg: MotionBlurOptions | null): void // 运行时开关运动模糊;null = 关闭并释放 RT 显存
531
+ readonly motionBlur: MotionBlurOptions | null // 当前配置(归一化后的副本)
529
532
  readonly stats: Stats
530
533
  readonly fps: number
531
534
  getBounds(): [[number, number], [number, number]] | null // 合并经纬度范围
@@ -1366,9 +1369,9 @@ bun run test:meshopt
1366
1369
 
1367
1370
  **这两条 GS 流的量化格式还没标定**(4 字节 / 点,既不像 3 个 float,也不像明显四元数),所以渲染层目前只是把它们带到顶点属性上备用,没有接进真正的 splat 着色器。
1368
1371
 
1369
- ### Draco 点云几何
1372
+ ### Draco 几何(点云与三角网格)
1370
1373
 
1371
- v3.01 的点云数据还有一条 Draco 压缩分支(骨架 tag = `DRACO`)。接法与 MeshOpt 相同:内联 wasm 解码器(约 266KB)只在真的遇到时动态 `import`,解码后并入同一套 attribute 组装,点云按 `primitiveType = 1` 交给 `THREE.Points`,分类码 / 强度 / 权重三种自定义属性一并解出来。
1374
+ v3.01 支持 Draco 压缩分支(骨架 tag = `DRACO`),涵盖点云与三角网格(`TRIANGULAR_MESH`)。接法与 MeshOpt 相同:内联 wasm 解码器(约 266KB)只在遇到时动态 `import`,解码后并入同一套 attribute 组装:点云按 `primitiveType = 1` 交给 `THREE.Points`(分类码/强度/权重一并解出);三角网格按面解出顶点索引与法线,按 `primitiveType = 4` 交给 `THREE.Mesh`。遇到未知几何类型自动 warn 降级并跳过,避免整层崩溃。
1372
1375
 
1373
1376
  **这个分支的字段顺序与规范描述对不上,是从真实瓦片逐字节标定的**(偏移以骨架 tag 字段之后为 0):
1374
1377
 
@@ -1404,16 +1407,25 @@ ts.update([0, 0, 500], 0.64, 900); // 相机在局部 ENU 的位置
1404
1407
  https://host/iserver/services/3D-xxx/rest/realspace/datas/<数据源名>/config
1405
1408
  ```
1406
1409
 
1407
- 数据源名可以是中文(如 `寿宁阁`),`new URL()` 会自动百分号编码;后缀只能写 `config`,写 `config.xml` 会 406。只给到 `…/realspace/datas` 时拿到的是一个资源清单数组,`JSON.parse` 不报错但没有 `position` / `rootTiles`,表现为「视角跳到 (0,0) + 一句缺少 root tile」——先用 `…/datas.json` 列出数据源,再逐个拼 `/config`。
1410
+ 数据源名可以是中文(如 `寿宁阁`),`new URL()` 会自动百分号编码;后缀只能写 `config`,写 `config.xml` 会 406。只给到 `…/realspace/datas` 时拿到的是一个资源清单数组,`JSON.parse` 不报错但没有 `position` / `rootTiles`,表现为「视角跳到 (0,0) + 一句缺少 root tile」——先用 `…/datas.json` 列出数据源,再逐个拼 `/config`。这类报错会带上 config 的类型标记(`FileExtentName` / `Type` / `fileType` / `dataType`),便于区分「清单当配置」和「桌面导出漏瓦片清单」两种病因。
1408
1411
 
1409
- 配套导出的三个纯函数,方便自建数据集列表与做体检:
1412
+ 混合服务(模型 + 地形 + 影像共存)里还有第三种触发面:地形/影像数据集的 config 也是 XML,但**没有瓦片清单**。`init` 会按类型标记提前拒绝并说明病因(见 [FORMAT-NOTES 第二章](./FORMAT-NOTES.md#二先判断是哪一种数据)),不再落到「缺少 root tile」:
1413
+
1414
+ ```
1415
+ Error: 数据集 beijingdem 是地形缓存(FileExtentName=Terrainz),本层仅支持模型类 S3M 数据集
1416
+ ```
1417
+
1418
+ 配套导出的五个纯函数,方便自建数据集列表与做体检:
1410
1419
 
1411
1420
  ```ts
1412
- parseConfigText(text) // JSON 与 SuperMapCache XML 两种配置统一成同一种形状(含 fileType)
1421
+ parseConfigText(text) // JSON 与 SuperMapCache XML 两种配置统一成同一种形状(含 fileType / fileExtentName / cacheType)
1422
+ classifyDatasetKind(config) // → 'model' | 'terrain' | 'imagery';只有认出确凿的非模型标记才判 terrain / imagery
1413
1423
  usableGeoBounds(bounds) // 判断 geoBounds 是否真的可用;不可用返回 null(见「锚点」)
1414
1424
  fixPathEncoding(url) // 补路径里没编码但服务端会改写的字符:+ → %2B、空格 → %20
1415
1425
  ```
1416
1426
 
1427
+ `classifyDatasetKind` 的判别字段来自实测:地形 = `FileExtentName=Terrainz`;影像 = `Type=Image`(影像的 `FileExtentName` 是 `jpg_png` 这类扩展名样式,不可靠)。认不出标记的一律返回 `'model'`,保证行为与旧版一致;`init` 对非模型数据集抛出的错误上带 `datasetKind` 属性,数据集列表 UI 可以据此标注或跳过。
1428
+
1417
1429
  `fixPathEncoding` 这条是硬需求:OSGB 转出来的瓦片名就是 `Tile_+002_+001`,而 iServer 按 `URLDecoder` 解路径,未编码的 `+` 会变成空格 → 404「瓦片数据不存在」。`new URL()` 认为 `+` 在 path 里合法,不会替我们编码,所以拼完 URL 必须补这一刀(query 里的 `+` 不动,那本来就是空格约定)。
1418
1430
 
1419
1431
  失败处理分两类:**网络类**错误退避重试(`maxRetries`,默认 2 次);**格式类**错误(`e.permanent === true`,例如 S3M 1.x 容器)直接判失败不重试,并且整棵瓦片树里同类错误只回调一次 `onError`,避免几十块瓦片刷同样的日志。
@@ -1441,6 +1453,7 @@ buildAttributes({ smid, box, triangles, floorHeight }) // → S3MFeatureAttribu
1441
1453
  | `maxTextureSize` | 纹理长边像素上限,默认不限制。手机上 1024 即可,显存降到 1/4 |
1442
1454
  | `maxImageCache` | 纹理缓存条数,默认 128。低端机 32~48,和 `maxTextureSize` 一起管显存 |
1443
1455
  | `materialQuality: 'lambert'` | 换成 Lambert 光照,片元约省一半。白模 / 按高度配色看不出差别,贴图模式会略平。图层级设置,同页其它图层不受影响 |
1456
+ | `motionBlur: { mode: 'afterimage' }` | 运动模糊后处理(默认关闭)。低端机低帧率下用帧间残影填卡顿:`afterimage` 每帧只多两次全屏混合,`damp: 'auto'` 按实时 FPS 自适应拖尾长度。建议与上面的预算开关搭配用。注意 RT 为 HalfFloat(半精度浮点,显存 / 带宽 ×2),显存极度吃紧时权衡是否开启 |
1444
1457
  | `maxImageCache` | 原始图集与 leaf 段图像记录的 LRU 缓存容量上限(默认 128),超限自动淘汰旧缓存,防止漫游时内存不断累积 |
1445
1458
  | 纹理引用计数 | 瓦片淘汰时按引用计数释放未被其他瓦片复用的 `Texture` 并调用 `dispose()`,真正归还 GPU 显存 |
1446
1459
  | 几何/材质深度释放 | 瓦片注销时深度遍历对象树,彻底释放 `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.26",
4
4
  "description": "SuperMap S3M 三维模型格式的 three.js 渲染内核:浏览器内直接解析 .s3mb 瓦片,无需转 3D Tiles,支持 LOD 调度、白膜/按高度/按单体/动态扫描着色、单体级 GPU 拾取。宿主无关,可单独用于任意 three 场景。",
5
5
  "type": "module",
6
6
  "repository": {
@@ -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
  }
@@ -225,14 +225,45 @@ interface MeshRecord {
225
225
  position: Float32Array;
226
226
  uv: Float32Array;
227
227
  indexCount: number;
228
- indices: Uint16Array;
228
+ indices: Uint16Array | Uint32Array;
229
+ primitiveType: number;
229
230
  materialId: string;
230
231
  /** 实例矩阵(每实例 16 float:3×4 行主序 + RGBA),没有实例化时为 undefined */
231
232
  instances?: Float32Array;
232
233
  }
233
234
 
234
- /** 索引头的第 3 个 dword,全版本固定 */
235
- const INDEX_HEADER_MARK = 0x00040100;
235
+ /**
236
+ * 校验 p 偏移处是否为合法的 S3M 1.x 索引头:
237
+ * dword[0] = 1 (idxKind)
238
+ * dword[1] = indexCount
239
+ * dword[2] = mark,按字节小端存储:
240
+ * byte 0: indexType (0=u16, 1/3=u32)
241
+ * byte 1: useIndex (恒 1)
242
+ * byte 2: primitiveType (1=POINTS, 2=LINES, 3=LINE_STRIP, 4=TRIANGLES, 5/6=STRIP/FAN, 13=EDGES)
243
+ * byte 3: reserved (恒 0)
244
+ * 校验紧随其后的索引数据(4 字节对齐)之后,记录尾标记恒为 1。
245
+ */
246
+ function isValidIndexHeader(view: DataView, p: number): boolean {
247
+ if (view.getUint32(p, true) !== 1) return false;
248
+ const count = view.getUint32(p + 4, true);
249
+ if (count === 0 || count > 10_000_000) return false;
250
+ const mark = view.getUint32(p + 8, true);
251
+ const indexType = mark & 0xff;
252
+ const useIndex = (mark >> 8) & 0xff;
253
+ const primitiveType = (mark >> 16) & 0xff;
254
+ const reserved = (mark >> 24) & 0xff;
255
+ if (useIndex !== 1 || reserved !== 0) return false;
256
+ if (indexType !== 0 && indexType !== 1 && indexType !== 3) return false;
257
+ if (![1, 2, 3, 4, 5, 6, 13].includes(primitiveType)) return false;
258
+ if (primitiveType === 4 && count % 3 !== 0) return false;
259
+ if (primitiveType === 2 && count % 2 !== 0) return false;
260
+
261
+ const wide = indexType === 1 || indexType === 3;
262
+ const indexBytes = count * (wide ? 4 : 2);
263
+ const tailOffset = (p + 12 + indexBytes + 3) & ~3;
264
+ if (tailOffset + 8 > view.byteLength) return false;
265
+ return view.getUint32(tailOffset, true) === 1;
266
+ }
236
267
 
237
268
  /**
238
269
  * v1 里每个实例记录固定 20 个 float(80 字节):
@@ -321,11 +352,7 @@ function readInstances(
321
352
  function findIndexHeader(view: DataView, from: number): number {
322
353
  const limit = view.byteLength - 12;
323
354
  for (let p = from; p <= limit; p += 4) {
324
- if (view.getUint32(p, true) !== 1) continue;
325
- const count = view.getUint32(p + 4, true);
326
- if (count === 0 || count % 3 !== 0) continue;
327
- if (view.getUint32(p + 8, true) !== INDEX_HEADER_MARK) continue;
328
- return p;
355
+ if (isValidIndexHeader(view, p)) return p;
329
356
  }
330
357
  return -1;
331
358
  }
@@ -389,6 +416,39 @@ function applyQuantBox(position: Float32Array, box: Float32Array): boolean {
389
416
  return true;
390
417
  }
391
418
 
419
+ /**
420
+ * 从版本 1 属性块中尝试提取 UV 坐标:
421
+ * 倾斜摄影(OSGB)等数据中,UV 块通常紧贴在索引头前:
422
+ * 头: [count: u32, dim: u32 = 2]
423
+ * 数据: V * 8 字节 float32 (u, v)
424
+ */
425
+ function findV1UV(view: DataView, from: number, to: number, V: number): Float32Array | null {
426
+ const head = to - V * 8 - 8;
427
+ if (head >= from && view.getUint32(head, true) === V && view.getUint32(head + 4, true) === 2) {
428
+ let uv = new Float32Array(V * 2);
429
+ for (let i = 0; i < V * 2; i++) {
430
+ uv[i] = view.getFloat32(head + 8 + i * 4, true);
431
+ }
432
+ return uv;
433
+ }
434
+ for (let p = to - V * 8 - 8; p >= from; p -= 4) {
435
+ if (view.getUint32(p, true) === V && view.getUint32(p + 4, true) === 2) {
436
+ let uv = new Float32Array(V * 2);
437
+ let ok = true;
438
+ for (let i = 0; i < V * 2; i++) {
439
+ const val = view.getFloat32(p + 8 + i * 4, true);
440
+ if (!Number.isFinite(val) || Math.abs(val) > 1e4) {
441
+ ok = false;
442
+ break;
443
+ }
444
+ uv[i] = val;
445
+ }
446
+ if (ok) return uv;
447
+ }
448
+ }
449
+ return null;
450
+ }
451
+
392
452
  function readGeometry(
393
453
  br: BinaryReader,
394
454
  geoSize: number
@@ -433,7 +493,7 @@ function readGeometry(
433
493
  position[i * 3 + 2] = z;
434
494
  }
435
495
 
436
- const uv = new Float32Array(V * 2);
496
+ let uv = new Float32Array(V * 2);
437
497
  let instances: Float32Array | undefined;
438
498
  if (v0UvAt16) {
439
499
  // 版本 0:UV 数组头 [0,0,0,kind=1,count=V,flag=2]
@@ -456,7 +516,12 @@ function readGeometry(
456
516
  throw new Error(`[s3m] S3M 1.x 属性段无法定位索引头:网格 ${geoName}(偏移 ${br.offset}/${geoSize})`);
457
517
  }
458
518
  if (at !== br.offset) {
459
- skippedAttrs++;
519
+ const foundUv = findV1UV(br.view, br.offset, at, V);
520
+ if (foundUv) {
521
+ uv = foundUv;
522
+ } else {
523
+ skippedAttrs++;
524
+ }
460
525
  // 优先认实例化:柱状图是真·实例化的(一份模板 + N 个变换矩阵),
461
526
  // 顶点本来就该保持单位盒,靠矩阵摆位/缩放/上色。
462
527
  const inst = readInstances(br.view, br.offset, at);
@@ -471,15 +536,29 @@ function readGeometry(
471
536
  }
472
537
  }
473
538
 
474
- // 索引头 [1, indexCount, 0x00040100]
539
+ // 索引头 [1, indexCount, mark]
475
540
  const idxKind = br.u32();
476
541
  const indexCount = br.u32();
477
- br.u32(); // 常量标记
478
- if (idxKind !== 1 || indexCount === 0 || indexCount % 3 !== 0) {
479
- throw new Error(`[s3m] S3M 1.x 索引头不符:网格 ${geoName} kind=${idxKind} count=${indexCount}`);
542
+ const mark = br.u32();
543
+ const indexType = mark & 0xff;
544
+ const useIndex = (mark >> 8) & 0xff;
545
+ const primitiveType = (mark >> 16) & 0xff;
546
+ if (idxKind !== 1 || indexCount === 0 || useIndex !== 1) {
547
+ throw new Error(`[s3m] S3M 1.x 索引头不符:网格 ${geoName} kind=${idxKind} count=${indexCount} mark=0x${mark.toString(16)}`);
548
+ }
549
+ if (primitiveType === 4 && indexCount % 3 !== 0) {
550
+ throw new Error(`[s3m] S3M 1.x 索引头不符:网格 ${geoName} 三角形索引数 ${indexCount} 非 3 的倍数`);
551
+ }
552
+ if (primitiveType === 2 && indexCount % 2 !== 0) {
553
+ throw new Error(`[s3m] S3M 1.x 索引头不符:网格 ${geoName} 线段索引数 ${indexCount} 非 2 的倍数`);
554
+ }
555
+ const wide = indexType === 1 || indexType === 3;
556
+ const indices = wide ? new Uint32Array(indexCount) : new Uint16Array(indexCount);
557
+ if (wide) {
558
+ for (let i = 0; i < indexCount; i++) indices[i] = br.u32();
559
+ } else {
560
+ for (let i = 0; i < indexCount; i++) indices[i] = br.u16();
480
561
  }
481
- const indices = new Uint16Array(indexCount);
482
- for (let i = 0; i < indexCount; i++) indices[i] = br.u16();
483
562
  if (br.offset > geoSize) throw new Error(`[s3m] S3M 1.x 索引越界:网格 ${geoName}`);
484
563
  br.align4();
485
564
 
@@ -495,6 +574,7 @@ function readGeometry(
495
574
  uv,
496
575
  indexCount,
497
576
  indices,
577
+ primitiveType,
498
578
  materialId,
499
579
  ...(instances ? { instances } : {}),
500
580
  });
@@ -511,10 +591,10 @@ function readGeometry(
511
591
  */
512
592
  export function pixelFormatOfBpp(bpp: number, rawFormat: number): number {
513
593
  if (!Number.isFinite(bpp) || bpp <= 0) return rawFormat;
514
- if (Math.abs(bpp - 0.5) < 0.02) return 17; // DXT1
515
- if (Math.abs(bpp - 1) < 0.02) return 21; // DXT5
516
- if (Math.abs(bpp - 4) < 0.02) return 14; // BGRA
517
- if (Math.abs(bpp - 3) < 0.02) return 10; // RGB
594
+ if (Math.abs(bpp - 0.5) < 0.05 || (bpp >= 0.6 && bpp <= 0.75)) return 17; // DXT1(含全 mip 链 ~0.667 B/px)
595
+ if (Math.abs(bpp - 1) < 0.05 || (bpp >= 1.25 && bpp <= 1.5)) return 21; // DXT5(含全 mip 链 ~1.333~1.44 B/px)
596
+ if (Math.abs(bpp - 4) < 0.05) return 14; // BGRA
597
+ if (Math.abs(bpp - 3) < 0.05) return 10; // RGB
518
598
  console.warn(`[s3m] S3M 1.x 纹理密度 ${bpp.toFixed(3)} B/px 不在已知档位,按原 format=${rawFormat} 交给上层判定`);
519
599
  return rawFormat;
520
600
  }
@@ -668,9 +748,9 @@ export async function parseS3M1(arrayBuffer: ArrayBuffer, options: ParseS3M1Opti
668
748
  }
669
749
  const pack: IndexPackage = {
670
750
  indexCount: mesh.indexCount,
671
- indexType: 0, // 0 = UNSIGNED_SHORT
751
+ indexType: mesh.indices instanceof Uint32Array ? 1 : 0,
672
752
  useIndex: 1,
673
- primitiveType: 4, // TRIANGLES
753
+ primitiveType: mesh.primitiveType ?? 4,
674
754
  indices: new Uint8Array(mesh.indices.buffer, mesh.indices.byteOffset, mesh.indices.byteLength),
675
755
  passNames: [mesh.materialId],
676
756
  };