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