s3m-three 0.3.0 → 0.3.1

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/README.md +1 -1
  2. package/dist/browser/s3m-three.iife.ext-three.js +163 -15
  3. package/dist/browser/s3m-three.iife.js +190 -42
  4. package/dist/core/AtlasAlpha.d.ts +31 -0
  5. package/dist/core/AtlasAlpha.d.ts.map +1 -0
  6. package/dist/core/AtlasAlpha.js +82 -0
  7. package/dist/core/AtlasAlpha.js.map +1 -0
  8. package/dist/core/AtlasPack.d.ts +57 -0
  9. package/dist/core/AtlasPack.d.ts.map +1 -0
  10. package/dist/core/AtlasPack.js +81 -0
  11. package/dist/core/AtlasPack.js.map +1 -0
  12. package/dist/core/AtlasUpgrade.d.ts +68 -0
  13. package/dist/core/AtlasUpgrade.d.ts.map +1 -0
  14. package/dist/core/AtlasUpgrade.js +154 -0
  15. package/dist/core/AtlasUpgrade.js.map +1 -0
  16. package/dist/core/DXTTextureDecode.d.ts.map +1 -1
  17. package/dist/core/DXTTextureDecode.js +28 -6
  18. package/dist/core/DXTTextureDecode.js.map +1 -1
  19. package/dist/core/Draco.d.ts +52 -0
  20. package/dist/core/Draco.d.ts.map +1 -0
  21. package/dist/core/Draco.js +323 -0
  22. package/dist/core/Draco.js.map +1 -0
  23. package/dist/core/MeshOpt.d.ts +14 -3
  24. package/dist/core/MeshOpt.d.ts.map +1 -1
  25. package/dist/core/MeshOpt.js +101 -18
  26. package/dist/core/MeshOpt.js.map +1 -1
  27. package/dist/core/S3M1Parser.d.ts +16 -0
  28. package/dist/core/S3M1Parser.d.ts.map +1 -0
  29. package/dist/core/S3M1Parser.js +367 -0
  30. package/dist/core/S3M1Parser.js.map +1 -0
  31. package/dist/core/S3MBParser.d.ts +43 -1
  32. package/dist/core/S3MBParser.d.ts.map +1 -1
  33. package/dist/core/S3MBParser.js +101 -23
  34. package/dist/core/S3MBParser.js.map +1 -1
  35. package/dist/core/S3MBlockParser.d.ts +209 -0
  36. package/dist/core/S3MBlockParser.d.ts.map +1 -0
  37. package/dist/core/S3MBlockParser.js +789 -0
  38. package/dist/core/S3MBlockParser.js.map +1 -0
  39. package/dist/core/TextureFormat.d.ts +0 -1
  40. package/dist/core/TextureFormat.d.ts.map +1 -1
  41. package/dist/core/TextureFormat.js +19 -2
  42. package/dist/core/TextureFormat.js.map +1 -1
  43. package/dist/core/types.d.ts +65 -0
  44. package/dist/core/types.d.ts.map +1 -1
  45. package/dist/index.d.ts +2 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +2 -1
  48. package/dist/index.js.map +1 -1
  49. package/dist/render/DatasetManager.d.ts.map +1 -1
  50. package/dist/render/DatasetManager.js +5 -3
  51. package/dist/render/DatasetManager.js.map +1 -1
  52. package/dist/render/GeometryBuilder.d.ts +2 -0
  53. package/dist/render/GeometryBuilder.d.ts.map +1 -1
  54. package/dist/render/GeometryBuilder.js +185 -16
  55. package/dist/render/GeometryBuilder.js.map +1 -1
  56. package/dist/render/GroundManager.d.ts +57 -1
  57. package/dist/render/GroundManager.d.ts.map +1 -1
  58. package/dist/render/GroundManager.js +149 -21
  59. package/dist/render/GroundManager.js.map +1 -1
  60. package/dist/render/Picker.d.ts.map +1 -1
  61. package/dist/render/Picker.js +12 -4
  62. package/dist/render/Picker.js.map +1 -1
  63. package/dist/render/PointCloudMaterial.d.ts +36 -0
  64. package/dist/render/PointCloudMaterial.d.ts.map +1 -0
  65. package/dist/render/PointCloudMaterial.js +159 -0
  66. package/dist/render/PointCloudMaterial.js.map +1 -0
  67. package/dist/render/S3MViewer.d.ts +19 -0
  68. package/dist/render/S3MViewer.d.ts.map +1 -1
  69. package/dist/render/S3MViewer.js +143 -22
  70. package/dist/render/S3MViewer.js.map +1 -1
  71. package/dist/render/StyleMaterial.d.ts +6 -0
  72. package/dist/render/StyleMaterial.d.ts.map +1 -1
  73. package/dist/render/StyleMaterial.js +125 -2
  74. package/dist/render/StyleMaterial.js.map +1 -1
  75. package/dist/render/TextureFactory.d.ts.map +1 -1
  76. package/dist/render/TextureFactory.js +64 -10
  77. package/dist/render/TextureFactory.js.map +1 -1
  78. package/dist/tileset/S3MTileset.d.ts +152 -1
  79. package/dist/tileset/S3MTileset.d.ts.map +1 -1
  80. package/dist/tileset/S3MTileset.js +669 -53
  81. package/dist/tileset/S3MTileset.js.map +1 -1
  82. package/dist/vendor/crunch_decoder.module.js +12 -18
  83. package/dist/vendor/draco_decoder.module.js +3178 -0
  84. package/docs/API.md +110 -9
  85. package/package.json +1 -1
  86. package/src/core/AtlasAlpha.ts +82 -0
  87. package/src/core/AtlasPack.ts +127 -0
  88. package/src/core/AtlasUpgrade.ts +182 -0
  89. package/src/core/DXTTextureDecode.ts +28 -6
  90. package/src/core/Draco.ts +409 -0
  91. package/src/core/MeshOpt.ts +124 -18
  92. package/src/core/S3M1Parser.ts +430 -0
  93. package/src/core/S3MBParser.ts +112 -21
  94. package/src/core/S3MBlockParser.ts +953 -0
  95. package/src/core/TextureFormat.ts +20 -2
  96. package/src/core/types.ts +63 -0
  97. package/src/index.ts +2 -1
  98. package/src/render/DatasetManager.ts +5 -5
  99. package/src/render/GeometryBuilder.ts +207 -15
  100. package/src/render/GroundManager.ts +174 -20
  101. package/src/render/Picker.ts +13 -4
  102. package/src/render/PointCloudMaterial.ts +174 -0
  103. package/src/render/S3MViewer.ts +140 -24
  104. package/src/render/StyleMaterial.ts +135 -2
  105. package/src/render/TextureFactory.ts +67 -11
  106. package/src/tileset/S3MTileset.ts +701 -58
  107. package/src/vendor/crunch_decoder.module.js +12 -18
  108. package/src/vendor/draco_decoder.module.d.ts +128 -0
  109. package/src/vendor/draco_decoder.module.js +3178 -0
@@ -7,6 +7,24 @@
7
7
  * 命中阈值就请求 childTile,实现渐进式细化。
8
8
  */
9
9
  import { parseS3MB } from '../core/S3MBParser.js';
10
+ import {
11
+ composeLeafAtlas,
12
+ expandTexblockRequestNames,
13
+ parseS3MBlockGroups,
14
+ parseTexblockSegments,
15
+ rebuildBlockTextureState,
16
+ unpackBlockTextures,
17
+ } from '../core/S3MBlockParser.js';
18
+ import type { S3MBlockTile } from '../core/S3MBlockParser.js';
19
+ import { evaluateAtlasUpgrade } from '../core/AtlasUpgrade.js';
20
+ import type { AtlasPixels } from '../core/AtlasUpgrade.js';
21
+ import type {
22
+ LeafSprite,
23
+ S3MBlockAtlasImage,
24
+ S3MBlockGroupSet,
25
+ S3MBlockTexture,
26
+ TexblockRequestPlan,
27
+ } from '../core/S3MBlockParser.js';
10
28
  import type { Anchor, ParsedTile, Stats, StyleUniforms } from '../core/types.js';
11
29
  import type { Group, Texture } from 'three';
12
30
 
@@ -33,29 +51,128 @@ function toAbsolute(url: string): string {
33
51
  function resolveUrl(base: string, rel: string | null | undefined): string | null {
34
52
  if (!rel) return null;
35
53
  try {
36
- return new URL(rel, base).href;
54
+ return fixPathEncoding(new URL(rel, base).href);
37
55
  } catch {
38
56
  // base 不是合法绝对地址时,退化为手工拼接,避免静默拿到错误路径
39
- return `${dirOf(base)}${rel}`;
57
+ return fixPathEncoding(`${dirOf(base)}${rel}`);
40
58
  }
41
59
  }
42
60
 
43
- /** 兼容解析 JSON 与 XML 格式的 SuperMap .scp / config 配置文件 */
44
- function parseConfigText(text: string): Record<string, unknown> {
61
+ /**
62
+ * 修补路径里没被编码、但会被服务端改写的字符。
63
+ *
64
+ * 最典型的是 `+`:OSGB 转 S3M 的缓存瓦片名就是 `Tile_+002_+001`,而 iServer 的 Java 侧
65
+ * 按 URLDecoder 解路径,未编码的 `+` 会变成空格 → 404「瓦片数据不存在」。
66
+ * new URL() 的序列化器认为 `+` 在 path 里是合法字符,所以不会替我们编码,必须自己补上。
67
+ * 只动 path,不动 query/hash:query 里的 `+` 本来就是空格的约定,改了反而换味。
68
+ */
69
+ export function fixPathEncoding(url: string): string {
70
+ if (!/[+ ]/.test(url)) return url;
71
+ const hashAt = url.indexOf('#');
72
+ const hash = hashAt < 0 ? '' : url.slice(hashAt);
73
+ const body = hashAt < 0 ? url : url.slice(0, hashAt);
74
+ const queryAt = body.indexOf('?');
75
+ const path = queryAt < 0 ? body : body.slice(0, queryAt);
76
+ const query = queryAt < 0 ? '' : body.slice(queryAt);
77
+ return `${path.replace(/\+/g, '%2B').replace(/ /g, '%20')}${query}${hash}`;
78
+ }
79
+
80
+ /** 每度对应的米数(等距圆柱近似),与 demo 里量测瓦片尺寸用的是同一套系数 */
81
+ const METERS_PER_DEG_LAT = 110540;
82
+ const METERS_PER_DEG_LON_EQUATOR = 111320;
83
+
84
+ /** 配置文件里的经纬度范围 */
85
+ export interface GeoBounds {
86
+ left: number;
87
+ bottom: number;
88
+ right: number;
89
+ top: number;
90
+ }
91
+
92
+ /**
93
+ * 判断 config.geoBounds 是不是一张「真的能用」的经纬度范围。
94
+ *
95
+ * 挡掉三种情况:字段缺失 / 非有限值;超出经纬度量级(OSGB 那类只给米制局部坐标的,
96
+ * 反算不出来就是这种);以及退化成一条线或一个点的全 0 框。
97
+ * 退化框尤其危险:拿它去 fitBounds 会把视角甩到几内亚湾 (0,0),
98
+ * 拿它去校验 position 又会把唯一正确的 position 判成异常。
99
+ */
100
+ export function usableGeoBounds(b: unknown): GeoBounds | null {
101
+ if (!b || typeof b !== 'object') return null;
102
+ const g = b as Partial<GeoBounds>;
103
+ const v = [g.left, g.bottom, g.right, g.top];
104
+ if (!v.every((n) => typeof n === 'number' && Number.isFinite(n))) return null;
105
+ const { left, bottom, right, top } = g as GeoBounds;
106
+ if (Math.abs(left) > 180 || Math.abs(right) > 180 || Math.abs(top) > 90 || Math.abs(bottom) > 90) return null;
107
+ if (right === left && top === bottom) return null;
108
+ return { left, bottom, right, top };
109
+ }
110
+
111
+ /**
112
+ * S3M 1.x(SuperMapCache)的子瓦地址。
113
+ *
114
+ * XML 的 RangeDataList 写的是切片时的原始文件名(`Tile_+002_+001_L16_0.osgb`),
115
+ * 而 iServer 实际发布的是**同名换 .s3m 后缀**、并且插在 `datas/<数据源>/data/path/` 下:
116
+ * .../datas/Config/Tile_+002_+001/Tile_+002_+001.s3m ← 根
117
+ * .../datas/Config/data/path/Tile_+002_+001/Tile_+002_+001_L16_0.s3m ← 子
118
+ * 实测 L16/L17/L18 都是 200,`?sceneName=` 不参与寻址。
119
+ */
120
+ export function s3m1ChildUrl(tileUrl: string, child: string | null | undefined): string | null {
121
+ if (!child) return null;
122
+ const name = child.replace(/\.(osgb|s3mb|s3m)$/i, '.s3m');
123
+ // 根瓦地址: <前缀>/datas/<数据源>/<瓦片目录>/<文件名>.s3m
124
+ // 子瓦地址: <前缀>/datas/<数据源>/data/path/<瓦片目录>/<子瓦名>.s3m
125
+ const parts = tileUrl.split('/');
126
+ const file = parts.pop() ?? '';
127
+ const dir = parts.pop() ?? '';
128
+ const datasIdx = parts.lastIndexOf('datas');
129
+ if (!file || !dir || datasIdx < 1) {
130
+ // 不像 datas/<ds>/<dir>/<file> 的结构,退回按父目录解析
131
+ return resolveUrl(dirOf(tileUrl), name);
132
+ }
133
+ // 父瓦自己就已经在 data/path 下面了(再往下细化),那只换文件名,别再插一层
134
+ const alreadyPath = parts[parts.length - 1] === 'path' && parts[parts.length - 2] === 'data';
135
+ const base = alreadyPath ? `${parts.join('/')}/${dir}` : `${parts.join('/')}/data/path/${dir}`;
136
+ return fixPathEncoding(`${base}/${name}`);
137
+ }
138
+
139
+ /** 兼容解析 JSON 与 XML 格式的 SuperMap .scp / config 配置文件(导出以便单测与自建数据集列表复用) */
140
+ export function parseConfigText(text: string): Record<string, unknown> {
45
141
  const trimmed = text.trim();
46
142
  if (trimmed.startsWith('<')) {
47
143
  const tagVal = (tag: string): string | null => {
48
144
  const m = new RegExp(`<(?:[a-zA-Z0-9_-]+:)?${tag}[^>]*>([^<]+)<\\/`, 'i').exec(trimmed);
49
145
  return m ? m[1].trim() : null;
50
146
  };
51
- const posX = parseFloat(tagVal('X') ?? '0');
52
- const posY = parseFloat(tagVal('Y') ?? '0');
53
- const posZ = parseFloat(tagVal('Z') ?? '0');
147
+ const num = (tag: string): number => parseFloat(tagVal(tag) ?? '');
148
+ const posX = num('X') || 0;
149
+ const posY = num('Y') || 0;
150
+ const posZ = num('Z') || 0;
151
+
152
+ let left = num('Left');
153
+ let right = num('Right');
154
+ let top = num('Top');
155
+ let bottom = num('Bottom');
54
156
 
55
- const left = parseFloat(tagVal('Left') ?? '0');
56
- const right = parseFloat(tagVal('Right') ?? '0');
57
- const top = parseFloat(tagVal('Top') ?? '0');
58
- const bottom = parseFloat(tagVal('Bottom') ?? '0');
157
+ // SuperMapCache(OSGB 转出来的那类 XML)压根不给经纬度范围,只给
158
+ // 「以 Position 为原点、局部 ENU 米制」的 BoundingBox。不反算的话 geoBounds 就是
159
+ // 全 0:getBounds() 会把视角甩到几内亚湾,_resolveAnchor 还会反过来把正确的
160
+ // position 判成「与 geoBounds 不一致」而退回 (0,0),位置错得彻彻底底。
161
+ if (![left, right, top, bottom].every(Number.isFinite)) {
162
+ const minX = num('MinX');
163
+ const maxX = num('MaxX');
164
+ const minY = num('MinY');
165
+ const maxY = num('MaxY');
166
+ if ([minX, maxX, minY, maxY].every(Number.isFinite) && posX && posY) {
167
+ const kx = METERS_PER_DEG_LON_EQUATOR * Math.cos((posY * Math.PI) / 180);
168
+ if (kx > 1e-6) {
169
+ left = posX + minX / kx;
170
+ right = posX + maxX / kx;
171
+ bottom = posY + minY / METERS_PER_DEG_LAT;
172
+ top = posY + maxY / METERS_PER_DEG_LAT;
173
+ }
174
+ }
175
+ }
59
176
 
60
177
  const fileMatches = trimmed.matchAll(/<(?:[a-zA-Z0-9_-]+:)?FileName[^>]*>([^<]+)<\//gi);
61
178
  const tiles: Array<{ url: string }> = [];
@@ -63,9 +180,17 @@ function parseConfigText(text: string): Record<string, unknown> {
63
180
  tiles.push({ url: m[1].trim() });
64
181
  }
65
182
 
183
+ // SuperMapCache 用 MinHeight/MaxHeight 声明成果的高程范围(米)。
184
+ // 不读出来的话 align: 'bottom' 无从计算 —— 这类 OSGB 数据的 z 是从 120m 起飞的,
185
+ // 不贴地就会整片悬在地图上方,斜视下看起来像散在别处的碎片。
186
+ const minH = num('MinHeight');
187
+ const maxH = num('MaxHeight');
188
+
66
189
  return {
190
+ fileType: tagVal('FileType') ?? undefined,
67
191
  position: { x: posX, y: posY, z: posZ, units: 'Degree' },
68
192
  geoBounds: { left, right, top, bottom },
193
+ heightRange: Number.isFinite(minH) || Number.isFinite(maxH) ? { min: minH, max: maxH } : undefined,
69
194
  rootTiles: tiles,
70
195
  tiles,
71
196
  };
@@ -114,11 +239,27 @@ export interface S3MTilesetOptions {
114
239
  maxRetries?: number;
115
240
  /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
116
241
  lodRangeScale?: number;
242
+ /**
243
+ * 是否请求 `.texblock` 子图集(S3MBLOCK 根图集的清晰版)。**默认关**。
244
+ *
245
+ * 不是没实现,而是「换图不换归一化 rect」这条规则只在部分链上成立:实测三对样本里
246
+ * 有一对的比例是 2.000×1.579(不成比例),打开后整场景建筑会碎成满屏空洞
247
+ * (错裁到 alpha=0 的区域被 alphaTest 丢弃)。要安全启用必须先做逐图集一致性自检
248
+ * (把子图集按归一化 rect 裁回来与根槽位比 alpha 轮廓 IoU + 色差),通过才换。
249
+ * 度量方式见 `_tmp/atlas/rule2.mts`。
250
+ */
251
+ atlasLod?: boolean;
117
252
  }
118
253
 
119
254
  export class S3MTileset {
120
255
  configUrl: string;
121
256
  config: Record<string, unknown> | null = null;
257
+ /**
258
+ * S3MBLOCK 数据集的完整瓦片清单(config.tiles 解析成绝对 URL)。
259
+ * 这类数据的 pageLOD 会引用服务端并不存在的子块(LOD 树占位),
260
+ * 按清单过滤可以避免成片的 404。经典 scp 不建这个索引,行为不变。
261
+ */
262
+ private _blockTileIndex: Set<string> | null = null;
122
263
  anchor: Anchor = { lon: 0, lat: 0, height: 0 };
123
264
 
124
265
  readonly rootUrls: string[] = [];
@@ -126,6 +267,25 @@ export class S3MTileset {
126
267
  readonly failed = new Set<string>();
127
268
  readonly textureCache = new Map<string, Texture>();
128
269
  readonly pending = new Set<string>();
270
+ /** 是否请求 `.texblock` 子图集,默认关,见 S3MTilesetOptions.atlasLod */
271
+ atlasLod: boolean;
272
+ /** 根图集 id → 已拉到的清晰版子图集;null = 正在请求或确认没有,避免重复发请求 */
273
+ readonly atlasLodCache = new Map<string, S3MBlockAtlasImage | null>();
274
+ /** 块 URL → 该文件里的全部 LOD 组(一个 .s3mblock 是一棵子树,不是一块瓦片) */
275
+ private readonly _groupCache = new Map<string, S3MBlockGroupSet>();
276
+ /** 独立 `.texblock` 图集缓存:id → 图像;null 表示正在请求或确认不可用 */
277
+ readonly externalAtlasCache = new Map<string, S3MBlockAtlasImage | null>();
278
+ /**
279
+ * leaf 子请求缓存:去扩展名的子请求名(`Tex_0_000` / `Tile_6_000000`)→ 响应里的记录;
280
+ * null = 已判定不可用。精灵按全局共享(同名同内容),跨瓦片复用。
281
+ */
282
+ readonly leafRequestCache = new Map<string, S3MBlockAtlasImage[] | null>();
283
+ /** 进行中的 leaf 请求(stem → Promise),跨瓦片并发时同名只发一次 */
284
+ private readonly _leafPending = new Map<string, Promise<void>>();
285
+ /** 真正被高清版顶替掉的图集 id(判定通过) */
286
+ readonly atlasUpgraded = new Set<string>();
287
+ /** 拿到高清版但判定不通过 → 原因。用于排查「为什么这块还是糊的」 */
288
+ readonly atlasUpgradeSkipped = new Map<string, string>();
129
289
  readonly stats: Stats = { tiles: 0, loading: 0, failed: 0, meshes: 0, vertices: 0, triangles: 0, datasets: 1 };
130
290
 
131
291
  maxConcurrent: number;
@@ -152,6 +312,13 @@ export class S3MTileset {
152
312
  /** 子瓦片 URL → 父瓦片 URL,用于淘汰后补洞 */
153
313
  private readonly _parentOf = new Map<string, string>();
154
314
  private _frame = 0;
315
+ private _lodDbgOnce = false;
316
+ /** 格式类永久错误只向上报一次,避免整棵瓦片树刷同样的日志 */
317
+ private _permanentReported = false;
318
+ /** 请求量的硬上限(软上限 maxTiles 的超额余量),见构造函数注释 */
319
+ private hardCap = 96;
320
+ /** 本数据集是 S3M 1.x(SuperMapCache,.s3m):子瓦地址规则与 3.x 不同 */
321
+ private isS3MV1 = false;
155
322
  private _buildTile: BuildTileFn | null;
156
323
  private readonly _uniforms: StyleUniforms | null;
157
324
  private _queue: string[] = [];
@@ -159,7 +326,7 @@ export class S3MTileset {
159
326
 
160
327
  constructor(options: S3MTilesetOptions) {
161
328
  this.configUrl = options.configUrl;
162
- this.lodRangeScale = options.lodRangeScale ?? 1.0;
329
+ this.lodRangeScale = options.lodRangeScale ?? 3.0;
163
330
  this._onTile = options.onTile ?? (() => {});
164
331
  this._onTileRemoved = options.onTileRemoved ?? (() => {});
165
332
  this._onError = options.onError ?? ((e) => console.warn(e));
@@ -174,6 +341,11 @@ export class S3MTileset {
174
341
  // 并发与总量限制:iServer 公开服务上别把人家打爆
175
342
  this.maxConcurrent = options.maxConcurrent ?? 4;
176
343
  this.maxTiles = options.maxTiles ?? 64;
344
+ // 硬上限 = 软上限 + 余量。淘汰只在「新瓦片已经落地并接管显示」之后做(make-before-break),
345
+ // 所以中间态允许临时超出 maxTiles;硬上限只用来挡住失控的请求量,不用于挤掉正在显示的内容。
346
+ this.hardCap = this.maxTiles + Math.max(8, Math.ceil(this.maxTiles * 0.5));
347
+ // 默认开启清晰版子图集:根图集是 level=0 的低清代理,拿它平铺就是一堆马赛克
348
+ this.atlasLod = options.atlasLod !== false;
177
349
  }
178
350
 
179
351
  async init(): Promise<this> {
@@ -195,6 +367,22 @@ export class S3MTileset {
195
367
  if (u) this.rootUrls.push(u);
196
368
  }
197
369
 
370
+ // S3M 1.x(SuperMapCache,.s3m)容器:早点说清楚,别等几十块瓦片全失败才看出来。
371
+ // 位置与范围是按配置反算出来的,所以视角会正确落到模型上,只是这种容器还没接解码。
372
+ this.isS3MV1 =
373
+ /OSGB/i.test(String(config.fileType ?? '')) ||
374
+ /OSGB/i.test(String(config.dataType ?? '')) ||
375
+ (this.rootUrls.length > 0 && this.rootUrls.every((u) => /\.s3m($|\?)/i.test(u)));
376
+
377
+ // S3MBLOCK:config.tiles 就是全部瓦片,子引用超出清单的一律不请求
378
+ if (this.rootUrls.every((u) => /\.s3mblock($|\?)/i.test(u))) {
379
+ this._blockTileIndex = new Set(this.rootUrls);
380
+ for (const r of roots) {
381
+ const u = resolveUrl(this.configUrl, r.url);
382
+ if (u) this._blockTileIndex.add(u);
383
+ }
384
+ }
385
+
198
386
  // 首棵树同步加载,保证 init 返回时已有内容可渲染
199
387
  await this._load(this.rootUrls[0]);
200
388
  for (const r of this.rootUrls.slice(1)) this._enqueue(r);
@@ -210,10 +398,11 @@ export class S3MTileset {
210
398
  // 失败退避期间不重入队,等 update() 下一轮再来
211
399
  const retryAt = this._retryAt.get(url);
212
400
  if (retryAt !== undefined && performance.now() < retryAt) return;
213
- // 满了就淘汰最久没被用到的,而不是干等着永远不再出图
214
- while (this.nodes.size + this.pending.size >= this.maxTiles) {
215
- if (!this._evictOne()) return;
216
- }
401
+ // 不在这里淘汰!「为了塞新的先删旧的」会立刻在画面上开一个洞 —— 旧的已经没了、
402
+ // 新的还没下载完,而洞那块地下一帧又要靠它回补,于是「删 → 补 → 再删」就是闪烁。
403
+ // 改成 make-before-break:新瓦片落地并接管显示之后,再由 update() 末尾的
404
+ // _trimBudget() 统一裁。这里只用硬上限挡住失控的请求量。
405
+ if (this.nodes.size + this.pending.size + this._queue.length >= this.hardCap) return;
217
406
  this._queue.push(url);
218
407
  this._pump();
219
408
  }
@@ -230,8 +419,9 @@ export class S3MTileset {
230
419
  * 两轮都按「最久没进有效范围」排,同帧再比距离。
231
420
  * 淘汰最细一级时,如果上一级也不在内存里,顺手把上一级排回队列补洞。
232
421
  */
233
- private _evictOne(): boolean {
234
- const victim = this._pickVictim(false) ?? this._pickVictim(true);
422
+ private _evictOne(allowUncovered = false): boolean {
423
+ // 只挑安全候选:叶子 → 已被子瓦接手的父瓦。allowUncovered 仅在超出硬上限时开启(内存兜底)
424
+ const victim = this._pickVictim(false, allowUncovered) ?? this._pickVictim(true, allowUncovered);
235
425
  if (!victim) return false;
236
426
 
237
427
  const parentUrl = victim.parentUrl ?? this._parentOf.get(victim.url);
@@ -243,6 +433,31 @@ export class S3MTileset {
243
433
  this._onTileRemoved(victim);
244
434
  this._parentOf.delete(victim.url);
245
435
 
436
+ // 淘汰的是中间层时,把已加载的子孙改挂到它的父级上:
437
+ // 不然它们瞬间"无父",父级那边还留着指向 victim 的 childUrls,
438
+ // 于是 allChildrenReady 永远为假 —— 既显示不对,也白占预算。
439
+ const kids = childUrls.filter((u) => u !== victim.url && this.nodes.has(u));
440
+ if (kids.length) {
441
+ for (const k of kids) {
442
+ const kn = this.nodes.get(k);
443
+ if (!kn) continue;
444
+ kn.parentUrl = parentUrl ?? undefined;
445
+ if (parentUrl) this._parentOf.set(k, parentUrl);
446
+ else this._parentOf.delete(k);
447
+ }
448
+ if (parentUrl) {
449
+ const pn = this.nodes.get(parentUrl);
450
+ if (pn) {
451
+ const merged = new Set<string>(pn.childUrls ?? []);
452
+ if (pn.childUrl) merged.add(pn.childUrl);
453
+ merged.delete(victim.url);
454
+ for (const k of kids) merged.add(k);
455
+ pn.childUrls = [...merged];
456
+ pn.childUrl = pn.childUrls[0] ?? null;
457
+ }
458
+ }
459
+ }
460
+
246
461
  // 删的是最细一级、而上一级又不在内存里 → 把上一级排回去,别让那块地空着
247
462
  if (isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
248
463
  this._enqueue(parentUrl);
@@ -250,16 +465,45 @@ export class S3MTileset {
250
465
  return true;
251
466
  }
252
467
 
468
+ /**
469
+ * 预算裁剪 —— 必须在可见性算完之后调用(make-before-break):
470
+ * 新瓦片已经落地、也已经决定由谁负责显示,这时候才把没人依赖的那块裁掉。
471
+ * 只踢「当前不显示」或「位置已被可见子瓦接手」的瓦片;挑不出安全候选时允许
472
+ * 暂时超出 maxTiles,等下一帧再裁 —— 宁可多占一会儿内存,也不在画面上开洞。
473
+ */
474
+ private _trimBudget(): boolean {
475
+ let removed = false;
476
+ let guard = 0;
477
+ // 在途的也算进"预期驻留数":不然一批瓦片同时落地时会先把画面撑爆再疯狂裁
478
+ while (this.nodes.size + this.pending.size > this.maxTiles && guard++ <= this.maxTiles + 8) {
479
+ if (!this._evictOne(this.nodes.size + this.pending.size > this.hardCap)) break;
480
+ removed = true;
481
+ }
482
+ return removed;
483
+ }
484
+
253
485
  /**
254
486
  * 挑淘汰候选。
255
- * @param allowRefined false = 只挑叶子;true = 只挑「子节点已全部加载」的父瓦片
487
+ * @param allowRefined false = 只挑叶子;true = 也允许挑「子节点已全部加载」的父瓦片
488
+ * @param allowUncovered true = 允许淘汰「正在显示、但位置还没被子瓦接手」的瓦片。
489
+ * 默认 false:这种瓦片一踢,画面上立刻是一个洞,下一帧调度又会把它请回来补洞,
490
+ * 于是「淘汰 → 开洞 → 回补 → 再淘汰」无限循环 = 屏幕不停闪烁。
491
+ * 只有在实在挑不出安全候选时才允许(否则预算满死锁、相机移到哪都不出图)。
256
492
  */
257
- private _pickVictim(allowRefined: boolean): TileNode | null {
493
+ private _pickVictim(allowRefined: boolean, allowUncovered = false): TileNode | null {
258
494
  let victim: TileNode | null = null;
259
495
  for (const node of this.nodes.values()) {
260
496
  const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
261
497
  const refined = childUrls.length > 0 && childUrls.some((u) => u !== node.url && this.nodes.has(u));
262
498
  if (refined !== allowRefined) continue;
499
+ // 正在显示且没有可见子瓦接手 → 踢了就是洞
500
+ if (!allowUncovered && node.group.visible) {
501
+ const covered = childUrls.some((u) => {
502
+ const c = this.nodes.get(u);
503
+ return !!c && c.group.visible;
504
+ });
505
+ if (!covered) continue;
506
+ }
263
507
  if (!victim) {
264
508
  victim = node;
265
509
  continue;
@@ -295,23 +539,37 @@ export class S3MTileset {
295
539
  * 定位锚点。规范里 position 是局部 ENU 坐标系原点,
296
540
  * 但实际数据里 position 偶尔是生产时的占位值(与 geoBounds 不符),
297
541
  * 所以这里做一次一致性检查,不一致就退回 geoBounds 中心。
542
+ *
543
+ * 前提是 geoBounds 真的可用:OSGB/SuperMapCache 这类配置只给米制局部包围盒,
544
+ * 全 0 的退化框不能反过来否定一个正常的 position —— 那等于把模型甩到 (0,0)。
298
545
  */
299
546
  private _resolveAnchor(config: Record<string, unknown>): Anchor {
300
- const raw = config.position as { point3D?: { x: number; y: number; z?: number }; x?: number; y?: number; z?: number } | undefined;
547
+ const raw = config.position as
548
+ | {
549
+ point3D?: { x: number; y: number; z?: number };
550
+ x?: number;
551
+ y?: number;
552
+ z?: number;
553
+ units?: string;
554
+ unit?: string;
555
+ }
556
+ | undefined;
301
557
  const p = raw?.point3D ?? (raw as { x: number; y: number; z?: number } | undefined);
302
- const b = config.geoBounds as { left: number; bottom: number; right: number; top: number } | undefined;
558
+ const okP = !!p && Number.isFinite(p.x) && Number.isFinite(p.y);
559
+ const b = usableGeoBounds(config.geoBounds);
303
560
  const centerLon = b ? (b.left + b.right) / 2 : undefined;
304
561
  const centerLat = b ? (b.bottom + b.top) / 2 : undefined;
305
562
  // ENU 原点在椭球面上,position.z 就是它的高度;用 heightRange.min 会引入垂直偏移
306
563
  const z = p?.z ?? 0;
307
564
 
308
- if (!p) return { lon: centerLon ?? 0, lat: centerLat ?? 0, height: 0 };
565
+ if (!okP) return { lon: centerLon ?? 0, lat: centerLat ?? 0, height: 0 };
309
566
 
310
- const units = (config.position as { units?: string } | undefined)?.units ?? 'Degree';
311
- if (units !== 'Degree') return { lon: p.x!, lat: p.y!, height: z };
567
+ // 两种写法都得认:老数据是 position.units,v3.01 起改成了 position.unit
568
+ const units = raw?.units ?? raw?.unit ?? 'Degree';
569
+ if (units !== 'Degree') return { lon: p!.x!, lat: p!.y!, height: z };
312
570
 
313
- const offLon = centerLon === undefined ? 0 : Math.abs(p.x! - centerLon);
314
- const offLat = centerLat === undefined ? 0 : Math.abs(p.y! - centerLat);
571
+ const offLon = centerLon === undefined ? 0 : Math.abs(p!.x! - centerLon);
572
+ const offLat = centerLat === undefined ? 0 : Math.abs(p!.y! - centerLat);
315
573
  if (offLon > 0.5 || offLat > 0.5) {
316
574
  console.warn('[s3m] position 与 geoBounds 不一致,改用 geoBounds 中心作为锚点', {
317
575
  position: p,
@@ -319,7 +577,7 @@ export class S3MTileset {
319
577
  });
320
578
  return { lon: centerLon!, lat: centerLat!, height: z };
321
579
  }
322
- return { lon: p.x!, lat: p.y!, height: z };
580
+ return { lon: p!.x!, lat: p!.y!, height: z };
323
581
  }
324
582
 
325
583
  private _pickLod(tile: ParsedTile) {
@@ -337,22 +595,307 @@ export class S3MTileset {
337
595
  return this._buildTile;
338
596
  }
339
597
 
598
+ /**
599
+ * 记住一个块文件里解析出来的全部 LOD 组。
600
+ *
601
+ * 一棵 LOD 子树的几层几何加图集动辄好几 MB,全数据集都留着会把内存吃光,
602
+ * 所以只保留最近 24 个块(LRU 足够:相机在附近移动时命中的永远是同几个块)。
603
+ */
604
+ private _rememberGroups(baseUrl: string, set: S3MBlockGroupSet): void {
605
+ this._groupCache.delete(baseUrl);
606
+ this._groupCache.set(baseUrl, set);
607
+ while (this._groupCache.size > 24) {
608
+ const oldest = this._groupCache.keys().next().value;
609
+ if (oldest === undefined) break;
610
+ this._groupCache.delete(oldest);
611
+ }
612
+ }
613
+
614
+ /**
615
+ * 补齐「图集像素不在块文件里」的那一半 —— 官方 `requestSubTextures` 的等价物。
616
+ *
617
+ * 根瓦片(如 6.s3mblock)的图集是内嵌的(isSaveData=1);但**子瓦片**(6_00 / 6_01)
618
+ * 的图集段只有元信息:`data = null` + `requestNames`,像素在同目录的 `.texblock` 里。
619
+ * 不补这一步的后果不是「糊一点」,而是子瓦片整块无贴图:`tile.textures` 里根本没有
620
+ * 这条纹理,材质拿不到 map,建筑变成纯白几何——精细 LOD 一旦真的加载进来就会显露。
621
+ *
622
+ * ★ 只认 requestNames。`childTexNames` 是**下一级 LOD 的图集指针**(官方 `_ancestorTexture`
623
+ * 用它继承 batchTable 的 rect),它有自己的 subTexInfos:同一个精灵在父级叫 `1460_3`
624
+ * 96x64、子级叫 `1460_2` 192x128,排布也不一样。拿父级的 rect 去套子级像素必然错位 ——
625
+ * 上一版就是这么把画面搞坏的。官方从不拿它换像素,那个 LOD 的瓦片自己会加载。
626
+ *
627
+ * requestNames 展开(见 `expandTexblockRequestNames`,逐字对应官方 `_subRequestNames`):
628
+ *
629
+ * 1. **整图**(`['-704919438.texblock']`,单条记录 = 整张图集)→ 用 `evaluateAtlasUpgrade`
630
+ * 验证后整体替换;根块自带缩略图必须验证过才能换,子瓦片(无基准)至少要求长宽比一致。
631
+ * 2. **leaf 多精灵**(`['Tex_0.texblock#000009…', 'Tile_6_000000.texblock']`,官方 isLeaf)→
632
+ * 图集本身没有像素,只有 `subTexInfos` 里每块一个精灵 rect。按 subName 把精灵贴回
633
+ * 各 rect(`composeLeafAtlas`),组合出整张自上而下 RGBA 画布(compressType=0 /
634
+ * pixelFormat=28 / flipY=false)。精灵自带「左 2/3 base + 右 1/3 内联 mip 金字塔」,
635
+ * 与 StyleMaterial 的 `s3mAtlasLod` 2/3 采样公式直接配套,rect 一个像素都不用改。
636
+ *
637
+ * 请求方式:pack 组按官方 `Uci` 合并成
638
+ * `GET Tex_0_<code>.texblock?extratiles=Tex_0_000;Tex_0_009;…`(extratiles **去扩展名**、
639
+ * 段序 = 请求序,kMi 取第 i 段);`Tile_*` 是普通多记录容器,整文件解析后按记录名分发。
640
+ *
641
+ * 不补/补失败时保持缩略图(或无贴图),由上层照常渲染几何,绝不清空已有纹理表。
642
+ */
643
+ private async _fillExternalAtlases(tile: ParsedTile, url: string): Promise<void> {
644
+ const blockTile = tile as ParsedTile & { texturePackage?: Record<string, S3MBlockTexture> };
645
+ const tp = blockTile.texturePackage;
646
+ if (!tp) return;
647
+
648
+ // 拼图块:先退回原始图集。拼图是把多张图集重排进一张大图,
649
+ // 换高清图必须在拼图**之前**做,否则 rect 和像素两套排布对不上。
650
+ const wasPacked = unpackBlockTextures(blockTile as S3MBlockTile);
651
+ /** 只要像素被动过,纹理表 / rect 就必须重算 —— 否则子瓦片会整块白膜 */
652
+ let changed = false;
653
+
654
+ // ① 展开 requestNames,跨图集汇总本块还缺哪些子请求(同名只拉一次)
655
+ const plans = new Map<string, { tex: S3MBlockTexture; plan: TexblockRequestPlan }>();
656
+ const packMiss = new Map<string, string[]>();
657
+ const plainMiss: string[] = [];
658
+ for (const [id, tex] of Object.entries(tp)) {
659
+ const plan = expandTexblockRequestNames(tex.requestNames);
660
+ if (!plan.names.length) continue; // 只挂了 childTexNames(根瓦片形态)→ 一个请求都不发
661
+ plans.set(id, { tex, plan });
662
+ for (const [base, group] of plan.packGroups) {
663
+ const miss = group.filter((n) => !this.leafRequestCache.has(n));
664
+ if (!miss.length) continue;
665
+ const acc = packMiss.get(base) ?? [];
666
+ for (const n of miss) if (!acc.includes(n)) acc.push(n);
667
+ packMiss.set(base, acc);
668
+ }
669
+ for (const n of plan.plain) {
670
+ if (!this.leafRequestCache.has(n) && !plainMiss.includes(n)) plainMiss.push(n);
671
+ }
672
+ }
673
+
674
+ // ② 拉取(先全部 await,再动图集状态;跨瓦片并发时同名请求共享同一个 Promise)
675
+ const pending: Promise<void>[] = [];
676
+ for (const [base, names] of packMiss) {
677
+ let groupFetch: Promise<void> | null = null;
678
+ const start = () => (groupFetch ??= this._fetchLeafPack(url, base, names));
679
+ for (const n of names) pending.push(this._ensureLeafFetch(n, start));
680
+ }
681
+ for (const n of plainMiss) pending.push(this._ensureLeafFetch(n, () => this._fetchLeafFile(url, n)));
682
+ if (pending.length) await Promise.all(pending);
683
+
684
+ // ③ 逐图集:leaf 组合优先;没匹配上任何精灵就退回「整图替换 + 验证」
685
+ for (const [id, { tex, plan }] of plans) {
686
+ const sprites = new Map<string, LeafSprite>();
687
+ for (const n of plan.names) {
688
+ const recs = this.leafRequestCache.get(n);
689
+ if (!recs) continue;
690
+ for (const r of recs) sprites.set(r.id, { source: n, image: r });
691
+ }
692
+
693
+ const composed = composeLeafAtlas(tex, sprites);
694
+ if (composed) {
695
+ const t = tex as S3MBlockTexture & { composed?: boolean; leafSig?: string };
696
+ t.composed = true;
697
+ t.leafSig = composed.sig;
698
+ t.flipY = false; // 画布自上而下(语义见 S3MBlockTexture.flipY 注释)
699
+ tex.data = composed.data;
700
+ tex.compressType = 0;
701
+ tex.pixelFormat = 28; // RGBA
702
+ tex.width = composed.width;
703
+ tex.height = composed.height;
704
+ tex.rectScale = 1; // 画布与 subTexInfos 同一像素空间,rect 原样、不缩放
705
+ this.atlasUpgraded.add(id);
706
+ changed = true;
707
+ if (composed.missing.length || composed.mismatched.length) {
708
+ // 官方对缺失精灵同样跳过(留透明);尺寸不一致照样贴。都只告警,不整块拒收。
709
+ console.warn('[s3m] leaf 图集部分精灵异常:', id, {
710
+ missing: composed.missing,
711
+ mismatched: composed.mismatched,
712
+ });
713
+ }
714
+ continue;
715
+ }
716
+
717
+ // —— 整图替换回退(官方 isLeaf=false 的路径)——
718
+ // 子瓦片的图集段只有元信息(data = null),没有可比的基准,只能直接采用;
719
+ // 根块自带缩略图,必须验证过才能换。
720
+ const hasBaseline = !!tex.data;
721
+ const accept = (img: S3MBlockAtlasImage): boolean => {
722
+ if (!hasBaseline) {
723
+ // 没有缩略图可比,但至少要挡住「根本不是同一张图集」的情况:
724
+ // 长宽比必须一致,否则 subTexInfos 的 rect 一贴就错位。
725
+ const loAsp = tex.width / tex.height;
726
+ const hiAsp = img.width / img.height;
727
+ if (!(Math.abs(hiAsp / loAsp - 1) <= 0.02)) {
728
+ this.atlasUpgradeSkipped.set(id, `aspect(no-baseline) ${tex.width}x${tex.height} vs ${img.width}x${img.height}`);
729
+ return false;
730
+ }
731
+ return true;
732
+ }
733
+ const v = evaluateAtlasUpgrade(tex as unknown as AtlasPixels, img);
734
+ if (v.ok) return true;
735
+ this.atlasUpgradeSkipped.set(
736
+ id,
737
+ `${v.reason} aspectΔ=${(v.aspectDelta * 100).toFixed(1)}% scale=${v.scale} err=${Number.isNaN(v.meanAbsError) ? 'n/a' : v.meanAbsError.toFixed(1)}`
738
+ );
739
+ return false;
740
+ };
741
+
742
+ // ★ 不能无条件换图。实测 3D-OlympicGreen/Building 两块共 8 张图集里,
743
+ // 只有 2 张的 child 是「同一套排布的等比放大」(长宽比 0.0%、下采样色差 8.5/11.2),
744
+ // 其余是**重新排版**过的另一张图(长宽比偏差 5.6%~200%,色差 43~86)。
745
+ // 直接套用 subTexInfos 会整张错位,比不换更糟 —— 所以换之前先验一遍。
746
+ const apply = (img: S3MBlockAtlasImage) => {
747
+ const t = tex as S3MBlockTexture & { composed?: boolean; leafSig?: string };
748
+ // 整图是 DXT 原图(底朝上),清掉 leaf 组合状态
749
+ t.composed = false;
750
+ t.leafSig = undefined;
751
+ t.flipY = true;
752
+ // 覆盖解析阶段按内嵌缩略图填好的那份;判定不通过时不会走到这里,原样保留
753
+ tex.data = img.data;
754
+ tex.compressType = img.compressType;
755
+ tex.pixelFormat = img.pixelFormat;
756
+ // rect 是归一化的,但 subTexInfos 的 offset/width 是像素,必须按倍数换算
757
+ tex.rectScale = tex.width > 0 ? img.width / tex.width : 1;
758
+ tex.width = img.width || tex.width;
759
+ tex.height = img.height || tex.height;
760
+ this.atlasUpgraded.add(id);
761
+ changed = true;
762
+ };
763
+
764
+ // 候选只可能是普通请求名的第一条记录;Tex pack 段是子精灵,不是整图(与旧实现一致)。
765
+ let candidate = this.externalAtlasCache.get(id);
766
+ if (candidate === null) continue; // 已判定不可用
767
+ if (candidate === undefined) {
768
+ candidate = null;
769
+ for (const n of plan.plain) {
770
+ const recs = this.leafRequestCache.get(n);
771
+ if (recs?.length) {
772
+ candidate = recs[0]!;
773
+ break;
774
+ }
775
+ }
776
+ this.externalAtlasCache.set(id, candidate);
777
+ }
778
+ if (candidate) {
779
+ if (accept(candidate)) apply(candidate);
780
+ } else if (!this.atlasUpgradeSkipped.has(id)) {
781
+ this.atlasUpgradeSkipped.set(id, 'no usable external image(请求失败或只拿到子精灵)');
782
+ }
783
+ }
784
+
785
+ // 像素定下来了,重新拼图并重算 rect / 纹理表(两者必须一起重算)。
786
+ //
787
+ // ★ 这里必须用 `wasPacked || changed`,不能只看 wasPacked:
788
+ // **子瓦片**的图集是 `data = null` + `requestNames`,根本没参与拼图(wasPacked=false),
789
+ // 像素是刚从 .texblock 拉回来的。只看 wasPacked 的后果是 `tile.textures` 永远是空的
790
+ // → 近看整片白膜;同时 atlasRects 也没按 rectScale 重算 → 远处马赛克噪点。
791
+ if (wasPacked || changed) rebuildBlockTextureState(blockTile as S3MBlockTile);
792
+ }
793
+
794
+ /** 同名子请求去重:命中缓存立即返回;否则共享进行中的 Promise(跨瓦片并发只发一次) */
795
+ private _ensureLeafFetch(stem: string, start: () => Promise<void>): Promise<void> {
796
+ if (this.leafRequestCache.has(stem)) return Promise.resolve();
797
+ const inflight = this._leafPending.get(stem);
798
+ if (inflight) return inflight;
799
+ const p = start().finally(() => this._leafPending.delete(stem));
800
+ this._leafPending.set(stem, p);
801
+ return p;
802
+ }
803
+
804
+ /**
805
+ * pack 组请求 —— 官方 `Uci`:URL = 组内第一个请求的 URL,追加
806
+ * `extratiles=<全组 quadKey,去扩展名,';' 连接>`;响应是 kMi pack,段序 = extratiles 序。
807
+ *
808
+ * 实测服务端两种写法都 200,但带 `.texblock` 的 extratiles 会解出 recs=4 的错内容,
809
+ * 所以必须用**去扩展名**的 quadKey。
810
+ */
811
+ private async _fetchLeafPack(baseUrl: string, base: string, names: string[]): Promise<void> {
812
+ const u = resolveUrl(dirOf(baseUrl), `${names[0]}.texblock?extratiles=${names.join(';')}`);
813
+ try {
814
+ if (!u) throw new Error('无法拼出 URL');
815
+ const res = await fetch(u);
816
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
817
+ const segments = await parseTexblockSegments(await res.arrayBuffer());
818
+ if (segments.length !== names.length) {
819
+ throw new Error(`pack 段数 ${segments.length} ≠ 请求名 ${names.length}`);
820
+ }
821
+ names.forEach((n, i) => this.leafRequestCache.set(n, segments[i]!));
822
+ } catch (e) {
823
+ console.warn('[s3m] leaf pack 请求失败:', u, e);
824
+ for (const n of names) if (!this.leafRequestCache.has(n)) this.leafRequestCache.set(n, null);
825
+ }
826
+ }
827
+
828
+ /** 普通 `.texblock`(整图或多记录容器):文件里的全部记录都归这个子请求名 */
829
+ private async _fetchLeafFile(baseUrl: string, stem: string): Promise<void> {
830
+ const u = resolveUrl(dirOf(baseUrl), `${stem}.texblock`);
831
+ try {
832
+ if (!u) throw new Error('无法拼出 URL');
833
+ const res = await fetch(u);
834
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
835
+ const records = (await parseTexblockSegments(await res.arrayBuffer())).flat();
836
+ this.leafRequestCache.set(stem, records);
837
+ } catch (e) {
838
+ console.warn('[s3m] leaf texblock 请求失败:', u, e);
839
+ if (!this.leafRequestCache.has(stem)) this.leafRequestCache.set(stem, null);
840
+ }
841
+ }
842
+
843
+ /**
844
+ * @deprecated 已由 `_fillExternalAtlases` 覆盖,保留仅为兼容 `atlasLod` 选项。
845
+ *
846
+ * 旧实现「像素数更大就整体换图」,正是被证伪的那个前提:实测 8 张图集里只有 2 张的
847
+ * child 是等比放大,其余是重新排版过的另一张图,硬换会整张错位。
848
+ * 现在换图统一走 `_fillExternalAtlases` + `evaluateAtlasUpgrade` 的验证流程。
849
+ */
850
+ private async _upgradeAtlasLod(_tile: ParsedTile, _url: string): Promise<void> {
851
+ return;
852
+ }
853
+
340
854
  private async _load(url: string): Promise<void> {
341
855
  if (this.nodes.has(url) || this.pending.has(url) || this.failed.has(url)) return;
342
856
  this.pending.add(url);
343
857
  this.stats.loading++;
344
858
 
345
859
  try {
346
- const res = await fetch(url);
347
- if (!res.ok) {
348
- throw new Error(`HTTP ${res.status} 加载瓦片失败: ${url}`);
860
+ // URL 里的 `#N` 表示「同一个 .s3mblock 文件里的第 N 个 LOD 组」。
861
+ // 一个块文件打包的是一整棵 LOD 子树(6_01 → 6_0100 → 6_010000 → …),
862
+ // 这些下层瓦片**不是独立文件**,用 `#N` 直接命中已缓存的组,不再发请求。
863
+ const hashAt = url.indexOf('#');
864
+ const baseUrl = hashAt >= 0 ? url.slice(0, hashAt) : url;
865
+ const groupIndex = hashAt >= 0 ? Number(url.slice(hashAt + 1)) || 0 : 0;
866
+ const isBlock = /\.s3mblock($|\?)/i.test(baseUrl);
867
+
868
+ let tile: ParsedTile;
869
+ let groupNames: string[] = [];
870
+ const cachedSet = this._groupCache.get(baseUrl);
871
+ if (cachedSet) {
872
+ groupNames = cachedSet.names;
873
+ tile = cachedSet.tiles[groupIndex] ?? cachedSet.tiles[0]!;
874
+ } else {
875
+ const res = await fetch(baseUrl);
876
+ if (!res.ok) {
877
+ throw new Error(`HTTP ${res.status} 加载瓦片失败: ${baseUrl}`);
878
+ }
879
+ const ctype = res.headers.get('content-type') || '';
880
+ if (ctype.includes('text/html')) {
881
+ throw new Error(`瓦片请求返回了 HTML 页面(通常为 404/500 服务端拦截): ${baseUrl}`);
882
+ }
883
+ const buffer = await res.arrayBuffer();
884
+ // S3MBLOCK(TextureSharing / 单文件存储)走专用解析:容器 + 内嵌 v2 瓦片体 + 图集纹理
885
+ if (isBlock) {
886
+ const set = await parseS3MBlockGroups(buffer);
887
+ this._rememberGroups(baseUrl, set);
888
+ groupNames = set.names;
889
+ tile = set.tiles[groupIndex] ?? set.tiles[0]!;
890
+ } else {
891
+ tile = await parseS3MB(buffer);
892
+ }
349
893
  }
350
- const ctype = res.headers.get('content-type') || '';
351
- if (ctype.includes('text/html')) {
352
- throw new Error(`瓦片请求返回了 HTML 页面(通常为 404/500 服务端拦截): ${url}`);
894
+ if (isBlock) {
895
+ // 子瓦片的图集像素在独立 .texblock 里,必须先补齐,否则整块无贴图
896
+ await this._fillExternalAtlases(tile, baseUrl);
897
+ await this._upgradeAtlasLod(tile, baseUrl);
353
898
  }
354
- const buffer = await res.arrayBuffer();
355
- const tile = await parseS3MB(buffer);
356
899
 
357
900
  const build = await this._builder();
358
901
  const { group, stats } = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
@@ -360,10 +903,22 @@ export class S3MTileset {
360
903
 
361
904
  const childUrls: string[] = [];
362
905
  for (const l of tile.groupNode?.pageLods ?? []) {
363
- const u = resolveUrl(dirOf(url), l.childTile);
364
- if (u && u !== url && !childUrls.includes(u)) {
365
- childUrls.push(u);
366
- }
906
+ // 下一层如果就在本文件里(实测 6_01 的 5 个组是连成一条链的),直接指向 `#index`,
907
+ // 不产生任何网络请求;只有真正的外部文件才走 URL 解析。
908
+ const childName = String(l.childTile ?? '').replace(/\.s3mblock$/i, '');
909
+ const sameFile = isBlock && childName ? groupNames.indexOf(childName) : -1;
910
+ const u =
911
+ sameFile >= 0
912
+ ? `${baseUrl}#${sameFile}`
913
+ : this.isS3MV1
914
+ ? s3m1ChildUrl(url, l.childTile)
915
+ : resolveUrl(dirOf(url), l.childTile);
916
+ // 注意:不能拿 rootTiles 清单去筛子瓦片。S3MBLOCK 的 config.tiles 只是**根**瓦片,
917
+ // 实测 6.s3mblock 的 pageLOD 指向 6_00/6_01.s3mblock(HTTP 200,37 KB / 549 KB),
918
+ // 都在清单之外。按清单筛的后果是 childUrls 恒为空 → update() 永远不细化 →
919
+ // 任何机位都只渲染最粗一级的根图集(一栋楼只剩十几像素,再按 uv 平铺几十遍),
920
+ // 表现为「整栋微缩建筑照片贴满立面」。是否可取由服务器 404 + failed 退避来兜。
921
+ if (u && u !== url && !childUrls.includes(u)) childUrls.push(u);
367
922
  }
368
923
 
369
924
  const node: TileNode = {
@@ -373,7 +928,7 @@ export class S3MTileset {
373
928
  sphere: lod?.boundingSphere ?? null,
374
929
  rangeValue: lod?.rangeValue ?? 0,
375
930
  rangeMode: lod?.rangeMode ?? 1,
376
- childUrl: childUrls[0] ?? resolveUrl(dirOf(url), lod?.childTile),
931
+ childUrl: childUrls[0] ?? (this.isS3MV1 ? s3m1ChildUrl(url, lod?.childTile) : resolveUrl(dirOf(url), lod?.childTile)),
377
932
  childUrls,
378
933
  parentUrl: this._parentOf.get(url),
379
934
  state: 'ready',
@@ -387,9 +942,12 @@ export class S3MTileset {
387
942
 
388
943
  this._onTile(node);
389
944
  } catch (e) {
945
+ // 格式层面的永久错误(例如 S3M 1.x 容器)重试一万次也不会变好,
946
+ // 直接判失败,免得反复重下一整块瓦片还拖慢首屏
947
+ const permanent = (e as { permanent?: boolean } | null)?.permanent === true;
390
948
  const attempts = (this._retryCount.get(url) ?? 0) + 1;
391
949
  this._retryCount.set(url, attempts);
392
- if (attempts <= this._maxRetries) {
950
+ if (!permanent && attempts <= this._maxRetries) {
393
951
  // 退避重试:不进 failed,下一帧 update() 会重新入队,避免网络抖一下就留个永久空洞
394
952
  this._retryAt.set(url, performance.now() + 800 * attempts);
395
953
  } else {
@@ -398,8 +956,26 @@ export class S3MTileset {
398
956
  this.stats.failed++;
399
957
  this.failed.add(url);
400
958
  this._retryAt.delete(url);
959
+ // 从父瓦片的子列表里摘掉:S3MBLOCK 里有些 pageLOD 的 childTile 在服务器上根本不存在
960
+ // (实测 64_0000 / 65_0200 全是 404)。留着会让父瓦片永远「子未全就绪」→ 永不细化,
961
+ // 症状就是贴着看也一直渲染最粗一级。
962
+ const pUrl = this._parentOf.get(url);
963
+ const pNode = pUrl ? this.nodes.get(pUrl) : undefined;
964
+ if (pNode?.childUrls) {
965
+ pNode.childUrls = pNode.childUrls.filter((u) => u !== url);
966
+ pNode.childUrl = pNode.childUrls[0] ?? null;
967
+ }
968
+ }
969
+ // 同一类永久错误只报一次:OSGB 服务一上来几十块瓦片都是同一个原因,
970
+ // 刷屏会把真正值得看的日志埋掉
971
+ if (permanent) {
972
+ if (!this._permanentReported) {
973
+ this._permanentReported = true;
974
+ this._onError(e);
975
+ }
976
+ } else {
977
+ this._onError(e);
401
978
  }
402
- this._onError(e);
403
979
  } finally {
404
980
  this.pending.delete(url);
405
981
  this.stats.loading--;
@@ -431,23 +1007,51 @@ export class S3MTileset {
431
1007
  node.distance = dist;
432
1008
 
433
1009
  let metric: number;
434
- if (node.rangeMode === 0) {
1010
+ // RangeMode 语义(规范只写了 0/1):
1011
+ // 0 Distance_From_EyePoint —— rangeValue 是米,离得越近越该细化
1012
+ // 1 Pixel_Size_OnScreen —— rangeValue 是屏幕像素,投影半径超过它就细化
1013
+ // 2 规范枚举里没有这一档(文档里记作 Geometric_Error)。实测高斯泼溅 / 点云服务用的是
1014
+ // 这一档,且 rangeValue ≈ 包围球半径的固定比例(根瓦片 0.664/10.455,子瓦片
1015
+ // 0.332/3.7,比例都约 0.064),像是「相对屏幕尺寸的几何误差」而非绝对像素。
1016
+ // 这里先按 1 处理:方向是对的(近处细化),只是阈值偏小会多下几层,靠 lodRangeScale 收着。
1017
+ const byDistance = node.rangeMode === 0;
1018
+ if (byDistance) {
435
1019
  metric = dist; // Distance_From_EyePoint:距离越小越该细化
436
1020
  } else {
437
- // 屏幕像素半径
438
- metric = ((node.sphere.radius / dist) / Math.tan(fovRad / 2)) * screenHeight * 0.5;
1021
+ // 屏幕像素尺寸。照抄官方 PageLod.calcPixFromCam(SourceRelease/_3437c37f.js):
1022
+ // pix = (drawingBufferHeight/2) / tan(PI_OVER_FOUR * 0.5) * radius / distance * 2
1023
+ // = drawingBufferHeight * 2.4142 * radius / distance
1024
+ // 两个要点:① 视场角是**写死的 45°**(tan(22.5°)),不取渲染器实际 fov;
1025
+ // ② 末尾那个 *2(即用直径量屏幕尺寸)。少这个 *2 会让 metric 只有官方的一半,
1026
+ // needRefine 的门槛就永远差一级 —— 表现就是「贴着看也一直在渲染最粗的根图集,
1027
+ // 一张整楼雪碧被 uv 平铺几十遍」。实测瓦片 6_01:radius 293.4、rangeValue 586.8,
1028
+ // distance ≈ 824 m、H = 854 → 官方算 734 > 586.8 该细化,我们旧公式只有 326。
1029
+ metric = ((screenHeight * 0.5) / Math.tan(Math.PI / 8)) * (node.sphere.radius / dist) * 2;
1030
+ void fovRad;
439
1031
  }
440
1032
 
441
1033
  // 在有效范围内(留一倍余量)的算「正在用」,淘汰时按这个帧号排优先级
442
1034
  const scale = this.lodRangeScale > 0 ? this.lodRangeScale : 1.0;
443
1035
  const threshold = node.rangeValue * scale;
444
- const inRange = node.rangeMode === 0 ? dist < threshold * 2 : metric > threshold * 0.5;
1036
+ const inRange = byDistance ? dist < threshold * 2 : metric > threshold * 0.5;
445
1037
  if (inRange) node.lastSeen = this._frame;
446
1038
 
447
1039
  const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
448
- const needRefine = childUrls.length > 0 && (node.rangeMode === 0 ? metric < threshold : metric > threshold);
1040
+ const needRefine = childUrls.length > 0 && (byDistance ? metric < threshold : metric > threshold);
449
1041
  node.needRefine = needRefine;
450
1042
 
1043
+ const dbgSeen = ((globalThis as { __S3M_LOD_SEEN?: Set<string> }).__S3M_LOD_SEEN ??= new Set<string>());
1044
+ if ((globalThis as { __S3M_LOD_DEBUG?: boolean }).__S3M_LOD_DEBUG && !dbgSeen.has(node.url) && dbgSeen.size < 260) {
1045
+ dbgSeen.add(node.url);
1046
+ // eslint-disable-next-line no-console
1047
+ console.log('[LOD]', JSON.stringify({
1048
+ url: node.url.split('/').pop(), mode: node.rangeMode, rangeValue: node.rangeValue,
1049
+ radius: node.sphere.radius, dist: +dist.toFixed(1), metric: +metric.toFixed(1),
1050
+ threshold: +threshold.toFixed(1), needRefine, childUrls: childUrls.map((u) => u.split('/').pop()),
1051
+ parent: node.parentUrl ? node.parentUrl.split('/').pop() : null,
1052
+ }));
1053
+ }
1054
+
451
1055
  if (needRefine) {
452
1056
  for (const childUrl of childUrls) {
453
1057
  if (!this.failed.has(childUrl) && !this.nodes.has(childUrl) && !this.pending.has(childUrl)) {
@@ -460,12 +1064,15 @@ export class S3MTileset {
460
1064
 
461
1065
  // 树状递归更新所有瓦片的可见性(确保每个位置只渲染一层最适合的 LOD,避免父子瓦片重叠 Z-fighting)
462
1066
  const visited = new Set<string>();
463
- const roots = this.rootUrls.length > 0
464
- ? this.rootUrls
465
- : [...this.nodes.keys()].filter((u) => {
466
- const p = this.nodes.get(u)?.parentUrl;
467
- return !p || !this.nodes.has(p);
468
- });
1067
+ // 根集合 = 配置里的入口瓦片 + 所有「父瓦已不在档」的瓦片。
1068
+ // 后者必须有:淘汰掉一块中间层瓦片后,它下面的子孙会瞬间变成"无父"状态,
1069
+ // 如果只按 rootUrls 走,它们全部不可达,会被下面那条防御规则一起隐藏 ——
1070
+ // 症状就是「进去先渲染出来,然后整片消失,偶尔闪一下」。
1071
+ const roots = new Set<string>(this.rootUrls);
1072
+ for (const [url, node] of this.nodes) {
1073
+ const p = node.parentUrl ?? this._parentOf.get(url);
1074
+ if (!p || !this.nodes.has(p)) roots.add(url);
1075
+ }
469
1076
 
470
1077
  for (const rootUrl of roots) {
471
1078
  if (this._updateNodeVisibility(rootUrl, true, visited)) {
@@ -483,12 +1090,46 @@ export class S3MTileset {
483
1090
  }
484
1091
  }
485
1092
 
1093
+ // 显示职责已经分配完毕,现在才裁预算(make-before-break):
1094
+ // 此时「谁在负责显示这块地」是确定的,只踢没人依赖的那块,不会开出洞来
1095
+ if (this._trimBudget()) hasChanges = true;
1096
+
1097
+ // 兜底:绝不允许空场。
1098
+ // 可见性递归是按「先访问先决定」处理的,而 LOD 图不一定是严格的树 —— 同一个子瓦可能
1099
+ // 被多个父瓦引用(不同层级指向同一份数据),它可能先在某条"隐藏分支"里被访问掉,
1100
+ // 之后就再也拿不到显示机会,最终出现「一块都不显示」的整片消失。
1101
+ // 这里做最后一次校正:凡是位置没有被任何可见子瓦接手的瓦片,一律自己显示。
1102
+ let anyVisible = false;
1103
+ for (const node of this.nodes.values()) {
1104
+ if (node.group.visible) {
1105
+ anyVisible = true;
1106
+ break;
1107
+ }
1108
+ }
1109
+ if (!anyVisible && this.nodes.size > 0) {
1110
+ for (const node of this.nodes.values()) {
1111
+ const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
1112
+ const covered = childUrls.some((u) => {
1113
+ const c = this.nodes.get(u);
1114
+ return !!c && c !== node && c.group.visible;
1115
+ });
1116
+ if (!covered && !node.group.visible) {
1117
+ node.group.visible = true;
1118
+ hasChanges = true;
1119
+ }
1120
+ }
1121
+ }
1122
+
486
1123
  return hasChanges;
487
1124
  }
488
1125
 
489
1126
  private _updateNodeVisibility(url: string, parentInBranch: boolean, visited: Set<string>): boolean {
490
1127
  const node = this.nodes.get(url);
491
1128
  if (!node) return false;
1129
+ // 必须挡住重复访问:可见性图不是严格的树 —— 同一个子瓦可能被多个父瓦引用(不同 LOD 源
1130
+ // 指向同一份数据)、淘汰后重挂父级也可能临时造出交叉引用。没有这道守卫,
1131
+ // 递归会指数级展开甚至成环,直接 RangeError: Maximum call stack size exceeded。
1132
+ if (visited.has(url)) return false;
492
1133
  visited.add(url);
493
1134
 
494
1135
  let changed = false;
@@ -506,11 +1147,13 @@ export class S3MTileset {
506
1147
  }
507
1148
 
508
1149
  const hasChildren = childUrls.length > 0;
509
- const allChildrenReady = hasChildren && childUrls.every((cUrl) => this.nodes.has(cUrl));
510
- const anyChildFailed = childUrls.some((cUrl) => this.failed.has(cUrl));
1150
+ // 「就绪」= 已加载;失败的已经从 childUrls 里摘掉,剩下的只要都到了就能接手显示
1151
+ const readyChildren = childUrls.filter((cUrl) => this.nodes.has(cUrl));
1152
+ const allResolved = childUrls.every((cUrl) => this.nodes.has(cUrl) || this.failed.has(cUrl));
511
1153
 
512
- // 细化前提:需要细化、拥有子瓦片、子瓦片全部加载就绪且没有任何一个子瓦片失败
513
- const shouldRefine = !!node.needRefine && allChildrenReady && !anyChildFailed;
1154
+ // 细化前提:需要细化、至少有一个子瓦片就绪、且没有还在下载途中的子瓦片
1155
+ // (不等「全部就绪」是因为个别 childTile 在服务器上就是 404,等它等于永远不细化)
1156
+ const shouldRefine = !!node.needRefine && hasChildren && readyChildren.length > 0 && allResolved;
514
1157
 
515
1158
  if (shouldRefine) {
516
1159
  // 子节点接替当前节点渲染,当前父节点隐藏