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.
- package/README.md +1 -1
- package/dist/browser/s3m-three.iife.ext-three.js +163 -15
- package/dist/browser/s3m-three.iife.js +190 -42
- package/dist/core/AtlasAlpha.d.ts +31 -0
- package/dist/core/AtlasAlpha.d.ts.map +1 -0
- package/dist/core/AtlasAlpha.js +82 -0
- package/dist/core/AtlasAlpha.js.map +1 -0
- package/dist/core/AtlasPack.d.ts +57 -0
- package/dist/core/AtlasPack.d.ts.map +1 -0
- package/dist/core/AtlasPack.js +81 -0
- package/dist/core/AtlasPack.js.map +1 -0
- package/dist/core/AtlasUpgrade.d.ts +68 -0
- package/dist/core/AtlasUpgrade.d.ts.map +1 -0
- package/dist/core/AtlasUpgrade.js +154 -0
- package/dist/core/AtlasUpgrade.js.map +1 -0
- package/dist/core/DXTTextureDecode.d.ts.map +1 -1
- package/dist/core/DXTTextureDecode.js +28 -6
- package/dist/core/DXTTextureDecode.js.map +1 -1
- package/dist/core/Draco.d.ts +52 -0
- package/dist/core/Draco.d.ts.map +1 -0
- package/dist/core/Draco.js +323 -0
- package/dist/core/Draco.js.map +1 -0
- package/dist/core/MeshOpt.d.ts +14 -3
- package/dist/core/MeshOpt.d.ts.map +1 -1
- package/dist/core/MeshOpt.js +101 -18
- package/dist/core/MeshOpt.js.map +1 -1
- package/dist/core/S3M1Parser.d.ts +16 -0
- package/dist/core/S3M1Parser.d.ts.map +1 -0
- package/dist/core/S3M1Parser.js +367 -0
- package/dist/core/S3M1Parser.js.map +1 -0
- package/dist/core/S3MBParser.d.ts +43 -1
- package/dist/core/S3MBParser.d.ts.map +1 -1
- package/dist/core/S3MBParser.js +101 -23
- package/dist/core/S3MBParser.js.map +1 -1
- package/dist/core/S3MBlockParser.d.ts +209 -0
- package/dist/core/S3MBlockParser.d.ts.map +1 -0
- package/dist/core/S3MBlockParser.js +789 -0
- package/dist/core/S3MBlockParser.js.map +1 -0
- package/dist/core/TextureFormat.d.ts +0 -1
- package/dist/core/TextureFormat.d.ts.map +1 -1
- package/dist/core/TextureFormat.js +19 -2
- package/dist/core/TextureFormat.js.map +1 -1
- package/dist/core/types.d.ts +65 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/render/DatasetManager.d.ts.map +1 -1
- package/dist/render/DatasetManager.js +5 -3
- package/dist/render/DatasetManager.js.map +1 -1
- package/dist/render/GeometryBuilder.d.ts +2 -0
- package/dist/render/GeometryBuilder.d.ts.map +1 -1
- package/dist/render/GeometryBuilder.js +185 -16
- package/dist/render/GeometryBuilder.js.map +1 -1
- package/dist/render/GroundManager.d.ts +57 -1
- package/dist/render/GroundManager.d.ts.map +1 -1
- package/dist/render/GroundManager.js +149 -21
- package/dist/render/GroundManager.js.map +1 -1
- package/dist/render/Picker.d.ts.map +1 -1
- package/dist/render/Picker.js +12 -4
- package/dist/render/Picker.js.map +1 -1
- package/dist/render/PointCloudMaterial.d.ts +36 -0
- package/dist/render/PointCloudMaterial.d.ts.map +1 -0
- package/dist/render/PointCloudMaterial.js +159 -0
- package/dist/render/PointCloudMaterial.js.map +1 -0
- package/dist/render/S3MViewer.d.ts +19 -0
- package/dist/render/S3MViewer.d.ts.map +1 -1
- package/dist/render/S3MViewer.js +143 -22
- package/dist/render/S3MViewer.js.map +1 -1
- package/dist/render/StyleMaterial.d.ts +6 -0
- package/dist/render/StyleMaterial.d.ts.map +1 -1
- package/dist/render/StyleMaterial.js +125 -2
- package/dist/render/StyleMaterial.js.map +1 -1
- package/dist/render/TextureFactory.d.ts.map +1 -1
- package/dist/render/TextureFactory.js +64 -10
- package/dist/render/TextureFactory.js.map +1 -1
- package/dist/tileset/S3MTileset.d.ts +152 -1
- package/dist/tileset/S3MTileset.d.ts.map +1 -1
- package/dist/tileset/S3MTileset.js +669 -53
- package/dist/tileset/S3MTileset.js.map +1 -1
- package/dist/vendor/crunch_decoder.module.js +12 -18
- package/dist/vendor/draco_decoder.module.js +3178 -0
- package/docs/API.md +110 -9
- package/package.json +1 -1
- package/src/core/AtlasAlpha.ts +82 -0
- package/src/core/AtlasPack.ts +127 -0
- package/src/core/AtlasUpgrade.ts +182 -0
- package/src/core/DXTTextureDecode.ts +28 -6
- package/src/core/Draco.ts +409 -0
- package/src/core/MeshOpt.ts +124 -18
- package/src/core/S3M1Parser.ts +430 -0
- package/src/core/S3MBParser.ts +112 -21
- package/src/core/S3MBlockParser.ts +953 -0
- package/src/core/TextureFormat.ts +20 -2
- package/src/core/types.ts +63 -0
- package/src/index.ts +2 -1
- package/src/render/DatasetManager.ts +5 -5
- package/src/render/GeometryBuilder.ts +207 -15
- package/src/render/GroundManager.ts +174 -20
- package/src/render/Picker.ts +13 -4
- package/src/render/PointCloudMaterial.ts +174 -0
- package/src/render/S3MViewer.ts +140 -24
- package/src/render/StyleMaterial.ts +135 -2
- package/src/render/TextureFactory.ts +67 -11
- package/src/tileset/S3MTileset.ts +701 -58
- package/src/vendor/crunch_decoder.module.js +12 -18
- package/src/vendor/draco_decoder.module.d.ts +128 -0
- 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
|
-
/**
|
|
44
|
-
|
|
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
|
|
52
|
-
const
|
|
53
|
-
const
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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 ??
|
|
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
|
-
|
|
215
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
565
|
+
if (!okP) return { lon: centerLon ?? 0, lat: centerLat ?? 0, height: 0 };
|
|
309
566
|
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
314
|
-
const offLat = centerLat === undefined ? 0 : Math.abs(p
|
|
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
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 && (
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
510
|
-
const
|
|
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
|
-
|
|
1154
|
+
// 细化前提:需要细化、至少有一个子瓦片就绪、且没有还在下载途中的子瓦片
|
|
1155
|
+
// (不等「全部就绪」是因为个别 childTile 在服务器上就是 404,等它等于永远不细化)
|
|
1156
|
+
const shouldRefine = !!node.needRefine && hasChildren && readyChildren.length > 0 && allResolved;
|
|
514
1157
|
|
|
515
1158
|
if (shouldRefine) {
|
|
516
1159
|
// 子节点接替当前节点渲染,当前父节点隐藏
|