s3m-three 0.1.0 → 0.2.0

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 (44) hide show
  1. package/LICENSE +202 -202
  2. package/dist/render/DatasetManager.d.ts +10 -3
  3. package/dist/render/DatasetManager.d.ts.map +1 -1
  4. package/dist/render/DatasetManager.js +14 -5
  5. package/dist/render/DatasetManager.js.map +1 -1
  6. package/dist/render/EdgeLines.js +46 -46
  7. package/dist/render/GroundManager.d.ts +9 -0
  8. package/dist/render/GroundManager.d.ts.map +1 -1
  9. package/dist/render/GroundManager.js +13 -1
  10. package/dist/render/GroundManager.js.map +1 -1
  11. package/dist/render/Picker.js +66 -66
  12. package/dist/render/S3MViewer.d.ts +11 -3
  13. package/dist/render/S3MViewer.d.ts.map +1 -1
  14. package/dist/render/S3MViewer.js +18 -3
  15. package/dist/render/S3MViewer.js.map +1 -1
  16. package/dist/render/StyleMaterial.js +265 -265
  17. package/dist/vendor/meshopt_decoder.module.js +118 -118
  18. package/docs/API.md +20 -0
  19. package/package.json +1 -1
  20. package/src/core/BinaryReader.ts +116 -116
  21. package/src/core/DXTTextureDecode.ts +260 -260
  22. package/src/core/FeatureInfo.ts +150 -150
  23. package/src/core/MeshOpt.ts +401 -401
  24. package/src/core/S3MBParser.ts +590 -590
  25. package/src/core/types.ts +222 -222
  26. package/src/render/DatasetManager.ts +17 -6
  27. package/src/render/EdgeLines.ts +343 -343
  28. package/src/render/GeometryBuilder.ts +840 -840
  29. package/src/render/GroundManager.ts +14 -1
  30. package/src/render/Picker.ts +293 -293
  31. package/src/render/S3MViewer.ts +18 -3
  32. package/src/render/StyleMaterial.ts +602 -602
  33. package/src/render/TextureFactory.ts +134 -134
  34. package/src/tileset/S3MTileset.ts +552 -552
  35. package/src/vendor/meshopt_decoder.module.d.ts +31 -31
  36. package/src/vendor/meshopt_decoder.module.js +118 -118
  37. package/dist/render/CoordinateHelper.d.ts +0 -90
  38. package/dist/render/CoordinateHelper.d.ts.map +0 -1
  39. package/dist/render/CoordinateHelper.js +0 -85
  40. package/dist/render/CoordinateHelper.js.map +0 -1
  41. package/dist/render/S3MLayer.d.ts +0 -457
  42. package/dist/render/S3MLayer.d.ts.map +0 -1
  43. package/dist/render/S3MLayer.js +0 -1860
  44. package/dist/render/S3MLayer.js.map +0 -1
@@ -1,552 +1,552 @@
1
- /**
2
- * S3M 瓦片集:配置解析 + 按需加载 + LOD 判定
3
- *
4
- * LOD 规则(对应规范里的 RangeMode):
5
- * Pixel_Size_OnScreen(1) —— 用包围球投影到屏幕的像素半径与 rangeValue 比较
6
- * Distance_From_EyePoint(0) —— 直接用相机到包围球中心的距离
7
- * 命中阈值就请求 childTile,实现渐进式细化。
8
- */
9
- import { parseS3MB } from '../core/S3MBParser.js';
10
- import type { Anchor, ParsedTile, Stats, StyleUniforms } from '../core/types.js';
11
- import type { Group, Texture } from 'three';
12
-
13
- function dirOf(url: string): string {
14
- return url.slice(0, url.lastIndexOf('/') + 1);
15
- }
16
-
17
- /**
18
- * 把任意 URL 转成绝对地址。
19
- * 关键:configUrl 常常是相对路径(如 '../../data/cbd.scp'),
20
- * 而 new URL(rel, 相对基准) 会抛异常,导致后续 childTile 全部解析错误。
21
- * 所以进 init 前必须先锚定到 document.baseURI。
22
- */
23
- function toAbsolute(url: string): string {
24
- if (!url) return url;
25
- try {
26
- const base = typeof document !== 'undefined' ? document.baseURI : undefined;
27
- return base ? new URL(url, base).href : new URL(url).href;
28
- } catch {
29
- return url;
30
- }
31
- }
32
-
33
- function resolveUrl(base: string, rel: string | null | undefined): string | null {
34
- if (!rel) return null;
35
- try {
36
- return new URL(rel, base).href;
37
- } catch {
38
- // base 不是合法绝对地址时,退化为手工拼接,避免静默拿到错误路径
39
- return `${dirOf(base)}${rel}`;
40
- }
41
- }
42
-
43
- /** 兼容解析 JSON 与 XML 格式的 SuperMap .scp / config 配置文件 */
44
- function parseConfigText(text: string): Record<string, unknown> {
45
- const trimmed = text.trim();
46
- if (trimmed.startsWith('<')) {
47
- const tagVal = (tag: string): string | null => {
48
- const m = new RegExp(`<(?:[a-zA-Z0-9_-]+:)?${tag}[^>]*>([^<]+)<\\/`, 'i').exec(trimmed);
49
- return m ? m[1].trim() : null;
50
- };
51
- const posX = parseFloat(tagVal('X') ?? '0');
52
- const posY = parseFloat(tagVal('Y') ?? '0');
53
- const posZ = parseFloat(tagVal('Z') ?? '0');
54
-
55
- const left = parseFloat(tagVal('Left') ?? '0');
56
- const right = parseFloat(tagVal('Right') ?? '0');
57
- const top = parseFloat(tagVal('Top') ?? '0');
58
- const bottom = parseFloat(tagVal('Bottom') ?? '0');
59
-
60
- const fileMatches = trimmed.matchAll(/<(?:[a-zA-Z0-9_-]+:)?FileName[^>]*>([^<]+)<\//gi);
61
- const tiles: Array<{ url: string }> = [];
62
- for (const m of fileMatches) {
63
- tiles.push({ url: m[1].trim() });
64
- }
65
-
66
- return {
67
- position: { x: posX, y: posY, z: posZ, units: 'Degree' },
68
- geoBounds: { left, right, top, bottom },
69
- rootTiles: tiles,
70
- tiles,
71
- };
72
- }
73
- return JSON.parse(trimmed) as Record<string, unknown>;
74
- }
75
-
76
- export interface TileNode {
77
- url: string;
78
- tile: ParsedTile;
79
- group: Group;
80
- sphere: { center: { x: number; y: number; z: number }; radius: number } | null;
81
- rangeValue: number;
82
- rangeMode: number;
83
- childUrl: string | null;
84
- childUrls?: string[];
85
- state: string;
86
- /** 最近一次 update() 到相机的距离(米),淘汰时按它排序 */
87
- distance?: number;
88
- /** 最近一次「在有效范围内」的帧号,越小越该被淘汰 */
89
- lastSeen?: number;
90
- /** 上一级瓦片的 URL(淘汰到最细一级时,要把上一级找回来补洞) */
91
- parentUrl?: string;
92
- /** 当前帧是否满足细化条件 */
93
- needRefine?: boolean;
94
- }
95
-
96
- /** 瓦片构建器可注入,用于测试 / 服务端渲染(此时不需要 three) */
97
- export type BuildTileFn = (
98
- tile: ParsedTile,
99
- options: { textureCache: Map<string, Texture>; uniforms: StyleUniforms | null }
100
- ) => Promise<{ group: Group; stats: Partial<Stats> }>;
101
-
102
- export interface S3MTilesetOptions {
103
- configUrl: string;
104
- maxTiles?: number;
105
- maxConcurrent?: number;
106
- buildTile?: BuildTileFn | null;
107
- uniforms?: StyleUniforms | null;
108
- onTile?: (node: TileNode) => void;
109
- /** 超过 maxTiles 被淘汰时回调,调用方要自己把场景里的对象和拾取表清掉 */
110
- onTileRemoved?: (node: TileNode) => void;
111
- onProgress?: (stats: Stats) => void;
112
- onError?: (error: unknown) => void;
113
- /** 单块瓦片加载失败后的重试次数,默认 2(退避 0.8s / 1.6s) */
114
- maxRetries?: number;
115
- /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
116
- lodRangeScale?: number;
117
- }
118
-
119
- export class S3MTileset {
120
- configUrl: string;
121
- config: Record<string, unknown> | null = null;
122
- anchor: Anchor = { lon: 0, lat: 0, height: 0 };
123
-
124
- readonly rootUrls: string[] = [];
125
- readonly nodes = new Map<string, TileNode>();
126
- readonly failed = new Set<string>();
127
- readonly textureCache = new Map<string, Texture>();
128
- readonly pending = new Set<string>();
129
- readonly stats: Stats = { tiles: 0, loading: 0, failed: 0, meshes: 0, vertices: 0, triangles: 0, datasets: 1 };
130
-
131
- maxConcurrent: number;
132
- maxTiles: number;
133
- lodRangeScale: number;
134
-
135
- /** 数据集配置声明的高度范围(米),若配置未声明则返回 null */
136
- get heightRange(): { min: number; max: number } | null {
137
- const hr = this.config?.heightRange as { min?: number; max?: number } | undefined;
138
- if (hr && typeof hr.min === 'number' && typeof hr.max === 'number') {
139
- return { min: hr.min, max: hr.max };
140
- }
141
- return null;
142
- }
143
-
144
- private readonly _onTile: (node: TileNode) => void;
145
- private readonly _onTileRemoved: (node: TileNode) => void;
146
- private readonly _onError: (error: unknown) => void;
147
- private readonly _onProgress: (stats: Stats) => void;
148
- private readonly _maxRetries: number;
149
- /** 失败重试计数与下次可用时间,退避期间 _enqueue 会直接跳过 */
150
- private readonly _retryCount = new Map<string, number>();
151
- private readonly _retryAt = new Map<string, number>();
152
- /** 子瓦片 URL → 父瓦片 URL,用于淘汰后补洞 */
153
- private readonly _parentOf = new Map<string, string>();
154
- private _frame = 0;
155
- private _buildTile: BuildTileFn | null;
156
- private readonly _uniforms: StyleUniforms | null;
157
- private _queue: string[] = [];
158
- private _active = 0;
159
-
160
- constructor(options: S3MTilesetOptions) {
161
- this.configUrl = options.configUrl;
162
- this.lodRangeScale = options.lodRangeScale ?? 1.0;
163
- this._onTile = options.onTile ?? (() => {});
164
- this._onTileRemoved = options.onTileRemoved ?? (() => {});
165
- this._onError = options.onError ?? ((e) => console.warn(e));
166
- this._onProgress = options.onProgress ?? (() => {});
167
- this._maxRetries = Math.max(0, options.maxRetries ?? 2);
168
-
169
- // 渲染层可注入:Node 环境下不需要 three,也能跑 URL / 解析链路
170
- this._buildTile = options.buildTile ?? null;
171
- // 着色器共享 uniform,由 S3MLayer 持有并传进来
172
- this._uniforms = options.uniforms ?? null;
173
-
174
- // 并发与总量限制:iServer 公开服务上别把人家打爆
175
- this.maxConcurrent = options.maxConcurrent ?? 4;
176
- this.maxTiles = options.maxTiles ?? 64;
177
- }
178
-
179
- async init(): Promise<this> {
180
- this.configUrl = toAbsolute(this.configUrl);
181
- const res = await fetch(this.configUrl);
182
- if (!res.ok) throw new Error(`配置读取失败 ${res.status} ${this.configUrl}`);
183
- const text = await res.text();
184
- const config = parseConfigText(text);
185
- this.config = config;
186
- this.anchor = this._resolveAnchor(config);
187
-
188
- // iServer 的 realspace 配置用 rootTiles(可多棵树),桌面导出的用 tiles
189
- const roots = (config.rootTiles ?? config.tiles ?? []) as Array<{ url: string }>;
190
- if (!roots.length) throw new Error('配置中缺少 root tile');
191
-
192
- this.rootUrls.length = 0;
193
- for (const r of roots) {
194
- const u = resolveUrl(this.configUrl, r.url);
195
- if (u) this.rootUrls.push(u);
196
- }
197
-
198
- // 首棵树同步加载,保证 init 返回时已有内容可渲染
199
- await this._load(this.rootUrls[0]);
200
- for (const r of this.rootUrls.slice(1)) this._enqueue(r);
201
-
202
- return this;
203
- }
204
-
205
- private _enqueue(url: string | null, parentUrl?: string): void {
206
- if (!url) return;
207
- if (parentUrl) this._parentOf.set(url, parentUrl);
208
- if (this.nodes.has(url) || this.pending.has(url) || this.failed.has(url)) return;
209
- if (this._queue.includes(url)) return;
210
- // 失败退避期间不重入队,等 update() 下一轮再来
211
- const retryAt = this._retryAt.get(url);
212
- if (retryAt !== undefined && performance.now() < retryAt) return;
213
- // 满了就淘汰最久没被用到的,而不是干等着永远不再出图
214
- while (this.nodes.size + this.pending.size >= this.maxTiles) {
215
- if (!this._evictOne()) return;
216
- }
217
- this._queue.push(url);
218
- this._pump();
219
- }
220
-
221
- /**
222
- * 淘汰一块瓦片给新瓦片腾位置。
223
- *
224
- * 分两轮挑:
225
- * 1. 先挑「没有已加载子瓦片」的叶子 —— 删父瓦片容易露洞;
226
- * 2. 但如果树的形状是「所有节点都已细化」,一棵叶子都没有,第 1 轮会挑空,
227
- * 于是永远拒绝加载、相机移到哪都卡在旧画面。这时退一步挑「已经不带
228
- * 渲染职责」的父瓦片:它的子节点完全覆盖同一片区域,删掉视觉上无损。
229
- *
230
- * 两轮都按「最久没进有效范围」排,同帧再比距离。
231
- * 淘汰最细一级时,如果上一级也不在内存里,顺手把上一级排回队列补洞。
232
- */
233
- private _evictOne(): boolean {
234
- const victim = this._pickVictim(false) ?? this._pickVictim(true);
235
- if (!victim) return false;
236
-
237
- const parentUrl = victim.parentUrl ?? this._parentOf.get(victim.url);
238
- const childUrls = victim.childUrls ?? (victim.childUrl ? [victim.childUrl] : []);
239
- const isLeaf = !childUrls.some((u) => u !== victim.url && this.nodes.has(u));
240
-
241
- this.nodes.delete(victim.url);
242
- victim.group.parent?.remove(victim.group);
243
- this._onTileRemoved(victim);
244
- this._parentOf.delete(victim.url);
245
-
246
- // 删的是最细一级、而上一级又不在内存里 → 把上一级排回去,别让那块地空着
247
- if (isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
248
- this._enqueue(parentUrl);
249
- }
250
- return true;
251
- }
252
-
253
- /**
254
- * 挑淘汰候选。
255
- * @param allowRefined false = 只挑叶子;true = 只挑「子节点已全部加载」的父瓦片
256
- */
257
- private _pickVictim(allowRefined: boolean): TileNode | null {
258
- let victim: TileNode | null = null;
259
- for (const node of this.nodes.values()) {
260
- const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
261
- const refined = childUrls.length > 0 && childUrls.some((u) => u !== node.url && this.nodes.has(u));
262
- if (refined !== allowRefined) continue;
263
- if (!victim) {
264
- victim = node;
265
- continue;
266
- }
267
- const a = node.lastSeen ?? -1;
268
- const b = victim.lastSeen ?? -1;
269
- if (a < b || (a === b && (node.distance ?? 0) > (victim.distance ?? 0))) victim = node;
270
- }
271
- return victim;
272
- }
273
-
274
- /** 把之前彻底失败的瓦片全部放回队列(网络恢复后手动调一次) */
275
- retryFailed(): number {
276
- const count = this.failed.size;
277
- this.failed.clear();
278
- this._retryCount.clear();
279
- this._retryAt.clear();
280
- return count;
281
- }
282
-
283
- private _pump(): void {
284
- while (this._active < this.maxConcurrent && this._queue.length) {
285
- const url = this._queue.shift()!;
286
- this._active++;
287
- void this._load(url).finally(() => {
288
- this._active--;
289
- this._pump();
290
- });
291
- }
292
- }
293
-
294
- /**
295
- * 定位锚点。规范里 position 是局部 ENU 坐标系原点,
296
- * 但实际数据里 position 偶尔是生产时的占位值(与 geoBounds 不符),
297
- * 所以这里做一次一致性检查,不一致就退回 geoBounds 中心。
298
- */
299
- private _resolveAnchor(config: Record<string, unknown>): Anchor {
300
- const raw = config.position as { point3D?: { x: number; y: number; z?: number }; x?: number; y?: number; z?: number } | undefined;
301
- const p = raw?.point3D ?? (raw as { x: number; y: number; z?: number } | undefined);
302
- const b = config.geoBounds as { left: number; bottom: number; right: number; top: number } | undefined;
303
- const centerLon = b ? (b.left + b.right) / 2 : undefined;
304
- const centerLat = b ? (b.bottom + b.top) / 2 : undefined;
305
- // ENU 原点在椭球面上,position.z 就是它的高度;用 heightRange.min 会引入垂直偏移
306
- const z = p?.z ?? 0;
307
-
308
- if (!p) return { lon: centerLon ?? 0, lat: centerLat ?? 0, height: 0 };
309
-
310
- const units = (config.position as { units?: string } | undefined)?.units ?? 'Degree';
311
- if (units !== 'Degree') return { lon: p.x!, lat: p.y!, height: z };
312
-
313
- const offLon = centerLon === undefined ? 0 : Math.abs(p.x! - centerLon);
314
- const offLat = centerLat === undefined ? 0 : Math.abs(p.y! - centerLat);
315
- if (offLon > 0.5 || offLat > 0.5) {
316
- console.warn('[s3m] position 与 geoBounds 不一致,改用 geoBounds 中心作为锚点', {
317
- position: p,
318
- geoBounds: b,
319
- });
320
- return { lon: centerLon!, lat: centerLat!, height: z };
321
- }
322
- return { lon: p.x!, lat: p.y!, height: z };
323
- }
324
-
325
- private _pickLod(tile: ParsedTile) {
326
- const lods = tile.groupNode?.pageLods ?? [];
327
- if (!lods.length) return null;
328
- // 优先取带 childTile 且 rangeValue > 0 的包,否则退回第一个
329
- return lods.find((l) => l.childTile && l.rangeValue > 0) ?? lods[0];
330
- }
331
-
332
- private async _builder(): Promise<BuildTileFn> {
333
- if (!this._buildTile) {
334
- const mod = await import('../render/GeometryBuilder.js');
335
- this._buildTile = mod.buildTileObjects as unknown as BuildTileFn;
336
- }
337
- return this._buildTile;
338
- }
339
-
340
- private async _load(url: string): Promise<void> {
341
- if (this.nodes.has(url) || this.pending.has(url) || this.failed.has(url)) return;
342
- this.pending.add(url);
343
- this.stats.loading++;
344
-
345
- try {
346
- const res = await fetch(url);
347
- if (!res.ok) {
348
- throw new Error(`HTTP ${res.status} 加载瓦片失败: ${url}`);
349
- }
350
- const ctype = res.headers.get('content-type') || '';
351
- if (ctype.includes('text/html')) {
352
- throw new Error(`瓦片请求返回了 HTML 页面(通常为 404/500 服务端拦截): ${url}`);
353
- }
354
- const buffer = await res.arrayBuffer();
355
- const tile = await parseS3MB(buffer);
356
-
357
- const build = await this._builder();
358
- const { group, stats } = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
359
- const lod = this._pickLod(tile);
360
-
361
- const childUrls: string[] = [];
362
- for (const l of tile.groupNode?.pageLods ?? []) {
363
- const u = resolveUrl(dirOf(url), l.childTile);
364
- if (u && u !== url && !childUrls.includes(u)) {
365
- childUrls.push(u);
366
- }
367
- }
368
-
369
- const node: TileNode = {
370
- url,
371
- tile,
372
- group,
373
- sphere: lod?.boundingSphere ?? null,
374
- rangeValue: lod?.rangeValue ?? 0,
375
- rangeMode: lod?.rangeMode ?? 1,
376
- childUrl: childUrls[0] ?? resolveUrl(dirOf(url), lod?.childTile),
377
- childUrls,
378
- parentUrl: this._parentOf.get(url),
379
- state: 'ready',
380
- };
381
-
382
- this.nodes.set(url, node);
383
- this.stats.tiles++;
384
- this.stats.meshes += stats.meshes ?? 0;
385
- this.stats.vertices += stats.vertices ?? 0;
386
- this.stats.triangles += stats.triangles ?? 0;
387
-
388
- this._onTile(node);
389
- } catch (e) {
390
- this.stats.failed++;
391
- const attempts = (this._retryCount.get(url) ?? 0) + 1;
392
- this._retryCount.set(url, attempts);
393
- if (attempts <= this._maxRetries) {
394
- // 退避重试:不进 failed,下一帧 update() 会重新入队,避免网络抖一下就留个永久空洞
395
- this._retryAt.set(url, performance.now() + 800 * attempts);
396
- } else {
397
- this.failed.add(url);
398
- this._retryAt.delete(url);
399
- }
400
- this._onError(e);
401
- } finally {
402
- this.pending.delete(url);
403
- this.stats.loading--;
404
- this._onProgress(this.stats);
405
- }
406
- }
407
-
408
- /**
409
- * @param camEnu 相机在该数据集局部 ENU 坐标系中的位置(米)
410
- * @param fovRad 垂直视场角(弧度)
411
- * @param screenHeight 视口像素高度
412
- * @returns 瓦片加载队列或可见性是否发生变化
413
- */
414
- update(camEnu: [number, number, number], fovRad: number, screenHeight: number): boolean {
415
- this._frame++;
416
- let hasChanges = false;
417
-
418
- for (const node of this.nodes.values()) {
419
- if (!node.sphere) {
420
- node.needRefine = false;
421
- continue;
422
- }
423
-
424
- const c = node.sphere.center;
425
- const dx = c.x - camEnu[0];
426
- const dy = c.y - camEnu[1];
427
- const dz = c.z - camEnu[2];
428
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
429
- node.distance = dist;
430
-
431
- let metric: number;
432
- if (node.rangeMode === 0) {
433
- metric = dist; // Distance_From_EyePoint:距离越小越该细化
434
- } else {
435
- // 屏幕像素半径
436
- metric = ((node.sphere.radius / dist) / Math.tan(fovRad / 2)) * screenHeight * 0.5;
437
- }
438
-
439
- // 在有效范围内(留一倍余量)的算「正在用」,淘汰时按这个帧号排优先级
440
- const scale = this.lodRangeScale > 0 ? this.lodRangeScale : 1.0;
441
- const threshold = node.rangeValue * scale;
442
- const inRange = node.rangeMode === 0 ? dist < threshold * 2 : metric > threshold * 0.5;
443
- if (inRange) node.lastSeen = this._frame;
444
-
445
- const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
446
- const needRefine = childUrls.length > 0 && (node.rangeMode === 0 ? metric < threshold : metric > threshold);
447
- node.needRefine = needRefine;
448
-
449
- if (needRefine) {
450
- for (const childUrl of childUrls) {
451
- if (!this.failed.has(childUrl) && !this.nodes.has(childUrl) && !this.pending.has(childUrl)) {
452
- this._enqueue(childUrl, node.url);
453
- hasChanges = true;
454
- }
455
- }
456
- }
457
- }
458
-
459
- // 树状递归更新所有瓦片的可见性(确保每个位置只渲染一层最适合的 LOD,避免父子瓦片重叠 Z-fighting)
460
- const visited = new Set<string>();
461
- const roots = this.rootUrls.length > 0
462
- ? this.rootUrls
463
- : [...this.nodes.keys()].filter((u) => {
464
- const p = this.nodes.get(u)?.parentUrl;
465
- return !p || !this.nodes.has(p);
466
- });
467
-
468
- for (const rootUrl of roots) {
469
- if (this._updateNodeVisibility(rootUrl, true, visited)) {
470
- hasChanges = true;
471
- }
472
- }
473
-
474
- // 防御:若有任何未被 root 树访问到的游离瓦片,全部隐藏
475
- for (const [url, node] of this.nodes) {
476
- if (!visited.has(url)) {
477
- if (node.group.visible) {
478
- node.group.visible = false;
479
- hasChanges = true;
480
- }
481
- }
482
- }
483
-
484
- return hasChanges;
485
- }
486
-
487
- private _updateNodeVisibility(url: string, parentInBranch: boolean, visited: Set<string>): boolean {
488
- const node = this.nodes.get(url);
489
- if (!node) return false;
490
- visited.add(url);
491
-
492
- let changed = false;
493
- const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
494
-
495
- if (!parentInBranch) {
496
- if (node.group.visible) {
497
- node.group.visible = false;
498
- changed = true;
499
- }
500
- for (const cUrl of childUrls) {
501
- if (this._updateNodeVisibility(cUrl, false, visited)) changed = true;
502
- }
503
- return changed;
504
- }
505
-
506
- const hasChildren = childUrls.length > 0;
507
- const allChildrenReady = hasChildren && childUrls.every((cUrl) => this.nodes.has(cUrl));
508
- const anyChildFailed = childUrls.some((cUrl) => this.failed.has(cUrl));
509
-
510
- // 细化前提:需要细化、拥有子瓦片、子瓦片全部加载就绪且没有任何一个子瓦片失败
511
- const shouldRefine = !!node.needRefine && allChildrenReady && !anyChildFailed;
512
-
513
- if (shouldRefine) {
514
- // 子节点接替当前节点渲染,当前父节点隐藏
515
- if (node.group.visible) {
516
- node.group.visible = false;
517
- changed = true;
518
- }
519
- for (const cUrl of childUrls) {
520
- if (this._updateNodeVisibility(cUrl, true, visited)) changed = true;
521
- }
522
- } else {
523
- // 当前节点即为最终展示的 LOD:当前节点显示,下层所有已加载子代瓦片隐藏
524
- if (!node.group.visible) {
525
- node.group.visible = true;
526
- changed = true;
527
- }
528
- for (const cUrl of childUrls) {
529
- if (this._updateNodeVisibility(cUrl, false, visited)) changed = true;
530
- }
531
- }
532
-
533
- return changed;
534
- }
535
-
536
- dispose(): void {
537
- for (const node of this.nodes.values()) {
538
- node.group.traverse((o) => {
539
- const mesh = o as {
540
- geometry?: { dispose?: () => void };
541
- material?: { dispose?: () => void } | Array<{ dispose?: () => void }>;
542
- };
543
- mesh.geometry?.dispose?.();
544
- if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose?.());
545
- else mesh.material?.dispose?.();
546
- });
547
- }
548
- for (const tex of this.textureCache.values()) tex.dispose?.();
549
- this.textureCache.clear();
550
- this.nodes.clear();
551
- }
552
- }
1
+ /**
2
+ * S3M 瓦片集:配置解析 + 按需加载 + LOD 判定
3
+ *
4
+ * LOD 规则(对应规范里的 RangeMode):
5
+ * Pixel_Size_OnScreen(1) —— 用包围球投影到屏幕的像素半径与 rangeValue 比较
6
+ * Distance_From_EyePoint(0) —— 直接用相机到包围球中心的距离
7
+ * 命中阈值就请求 childTile,实现渐进式细化。
8
+ */
9
+ import { parseS3MB } from '../core/S3MBParser.js';
10
+ import type { Anchor, ParsedTile, Stats, StyleUniforms } from '../core/types.js';
11
+ import type { Group, Texture } from 'three';
12
+
13
+ function dirOf(url: string): string {
14
+ return url.slice(0, url.lastIndexOf('/') + 1);
15
+ }
16
+
17
+ /**
18
+ * 把任意 URL 转成绝对地址。
19
+ * 关键:configUrl 常常是相对路径(如 '../../data/cbd.scp'),
20
+ * 而 new URL(rel, 相对基准) 会抛异常,导致后续 childTile 全部解析错误。
21
+ * 所以进 init 前必须先锚定到 document.baseURI。
22
+ */
23
+ function toAbsolute(url: string): string {
24
+ if (!url) return url;
25
+ try {
26
+ const base = typeof document !== 'undefined' ? document.baseURI : undefined;
27
+ return base ? new URL(url, base).href : new URL(url).href;
28
+ } catch {
29
+ return url;
30
+ }
31
+ }
32
+
33
+ function resolveUrl(base: string, rel: string | null | undefined): string | null {
34
+ if (!rel) return null;
35
+ try {
36
+ return new URL(rel, base).href;
37
+ } catch {
38
+ // base 不是合法绝对地址时,退化为手工拼接,避免静默拿到错误路径
39
+ return `${dirOf(base)}${rel}`;
40
+ }
41
+ }
42
+
43
+ /** 兼容解析 JSON 与 XML 格式的 SuperMap .scp / config 配置文件 */
44
+ function parseConfigText(text: string): Record<string, unknown> {
45
+ const trimmed = text.trim();
46
+ if (trimmed.startsWith('<')) {
47
+ const tagVal = (tag: string): string | null => {
48
+ const m = new RegExp(`<(?:[a-zA-Z0-9_-]+:)?${tag}[^>]*>([^<]+)<\\/`, 'i').exec(trimmed);
49
+ return m ? m[1].trim() : null;
50
+ };
51
+ const posX = parseFloat(tagVal('X') ?? '0');
52
+ const posY = parseFloat(tagVal('Y') ?? '0');
53
+ const posZ = parseFloat(tagVal('Z') ?? '0');
54
+
55
+ const left = parseFloat(tagVal('Left') ?? '0');
56
+ const right = parseFloat(tagVal('Right') ?? '0');
57
+ const top = parseFloat(tagVal('Top') ?? '0');
58
+ const bottom = parseFloat(tagVal('Bottom') ?? '0');
59
+
60
+ const fileMatches = trimmed.matchAll(/<(?:[a-zA-Z0-9_-]+:)?FileName[^>]*>([^<]+)<\//gi);
61
+ const tiles: Array<{ url: string }> = [];
62
+ for (const m of fileMatches) {
63
+ tiles.push({ url: m[1].trim() });
64
+ }
65
+
66
+ return {
67
+ position: { x: posX, y: posY, z: posZ, units: 'Degree' },
68
+ geoBounds: { left, right, top, bottom },
69
+ rootTiles: tiles,
70
+ tiles,
71
+ };
72
+ }
73
+ return JSON.parse(trimmed) as Record<string, unknown>;
74
+ }
75
+
76
+ export interface TileNode {
77
+ url: string;
78
+ tile: ParsedTile;
79
+ group: Group;
80
+ sphere: { center: { x: number; y: number; z: number }; radius: number } | null;
81
+ rangeValue: number;
82
+ rangeMode: number;
83
+ childUrl: string | null;
84
+ childUrls?: string[];
85
+ state: string;
86
+ /** 最近一次 update() 到相机的距离(米),淘汰时按它排序 */
87
+ distance?: number;
88
+ /** 最近一次「在有效范围内」的帧号,越小越该被淘汰 */
89
+ lastSeen?: number;
90
+ /** 上一级瓦片的 URL(淘汰到最细一级时,要把上一级找回来补洞) */
91
+ parentUrl?: string;
92
+ /** 当前帧是否满足细化条件 */
93
+ needRefine?: boolean;
94
+ }
95
+
96
+ /** 瓦片构建器可注入,用于测试 / 服务端渲染(此时不需要 three) */
97
+ export type BuildTileFn = (
98
+ tile: ParsedTile,
99
+ options: { textureCache: Map<string, Texture>; uniforms: StyleUniforms | null }
100
+ ) => Promise<{ group: Group; stats: Partial<Stats> }>;
101
+
102
+ export interface S3MTilesetOptions {
103
+ configUrl: string;
104
+ maxTiles?: number;
105
+ maxConcurrent?: number;
106
+ buildTile?: BuildTileFn | null;
107
+ uniforms?: StyleUniforms | null;
108
+ onTile?: (node: TileNode) => void;
109
+ /** 超过 maxTiles 被淘汰时回调,调用方要自己把场景里的对象和拾取表清掉 */
110
+ onTileRemoved?: (node: TileNode) => void;
111
+ onProgress?: (stats: Stats) => void;
112
+ onError?: (error: unknown) => void;
113
+ /** 单块瓦片加载失败后的重试次数,默认 2(退避 0.8s / 1.6s) */
114
+ maxRetries?: number;
115
+ /** LOD 切换范围缩放系数(默认 1.0),> 1 更倾向保留粗模,< 1 提前换细模 */
116
+ lodRangeScale?: number;
117
+ }
118
+
119
+ export class S3MTileset {
120
+ configUrl: string;
121
+ config: Record<string, unknown> | null = null;
122
+ anchor: Anchor = { lon: 0, lat: 0, height: 0 };
123
+
124
+ readonly rootUrls: string[] = [];
125
+ readonly nodes = new Map<string, TileNode>();
126
+ readonly failed = new Set<string>();
127
+ readonly textureCache = new Map<string, Texture>();
128
+ readonly pending = new Set<string>();
129
+ readonly stats: Stats = { tiles: 0, loading: 0, failed: 0, meshes: 0, vertices: 0, triangles: 0, datasets: 1 };
130
+
131
+ maxConcurrent: number;
132
+ maxTiles: number;
133
+ lodRangeScale: number;
134
+
135
+ /** 数据集配置声明的高度范围(米),若配置未声明则返回 null */
136
+ get heightRange(): { min: number; max: number } | null {
137
+ const hr = this.config?.heightRange as { min?: number; max?: number } | undefined;
138
+ if (hr && typeof hr.min === 'number' && typeof hr.max === 'number') {
139
+ return { min: hr.min, max: hr.max };
140
+ }
141
+ return null;
142
+ }
143
+
144
+ private readonly _onTile: (node: TileNode) => void;
145
+ private readonly _onTileRemoved: (node: TileNode) => void;
146
+ private readonly _onError: (error: unknown) => void;
147
+ private readonly _onProgress: (stats: Stats) => void;
148
+ private readonly _maxRetries: number;
149
+ /** 失败重试计数与下次可用时间,退避期间 _enqueue 会直接跳过 */
150
+ private readonly _retryCount = new Map<string, number>();
151
+ private readonly _retryAt = new Map<string, number>();
152
+ /** 子瓦片 URL → 父瓦片 URL,用于淘汰后补洞 */
153
+ private readonly _parentOf = new Map<string, string>();
154
+ private _frame = 0;
155
+ private _buildTile: BuildTileFn | null;
156
+ private readonly _uniforms: StyleUniforms | null;
157
+ private _queue: string[] = [];
158
+ private _active = 0;
159
+
160
+ constructor(options: S3MTilesetOptions) {
161
+ this.configUrl = options.configUrl;
162
+ this.lodRangeScale = options.lodRangeScale ?? 1.0;
163
+ this._onTile = options.onTile ?? (() => {});
164
+ this._onTileRemoved = options.onTileRemoved ?? (() => {});
165
+ this._onError = options.onError ?? ((e) => console.warn(e));
166
+ this._onProgress = options.onProgress ?? (() => {});
167
+ this._maxRetries = Math.max(0, options.maxRetries ?? 2);
168
+
169
+ // 渲染层可注入:Node 环境下不需要 three,也能跑 URL / 解析链路
170
+ this._buildTile = options.buildTile ?? null;
171
+ // 着色器共享 uniform,由 S3MLayer 持有并传进来
172
+ this._uniforms = options.uniforms ?? null;
173
+
174
+ // 并发与总量限制:iServer 公开服务上别把人家打爆
175
+ this.maxConcurrent = options.maxConcurrent ?? 4;
176
+ this.maxTiles = options.maxTiles ?? 64;
177
+ }
178
+
179
+ async init(): Promise<this> {
180
+ this.configUrl = toAbsolute(this.configUrl);
181
+ const res = await fetch(this.configUrl);
182
+ if (!res.ok) throw new Error(`配置读取失败 ${res.status} ${this.configUrl}`);
183
+ const text = await res.text();
184
+ const config = parseConfigText(text);
185
+ this.config = config;
186
+ this.anchor = this._resolveAnchor(config);
187
+
188
+ // iServer 的 realspace 配置用 rootTiles(可多棵树),桌面导出的用 tiles
189
+ const roots = (config.rootTiles ?? config.tiles ?? []) as Array<{ url: string }>;
190
+ if (!roots.length) throw new Error('配置中缺少 root tile');
191
+
192
+ this.rootUrls.length = 0;
193
+ for (const r of roots) {
194
+ const u = resolveUrl(this.configUrl, r.url);
195
+ if (u) this.rootUrls.push(u);
196
+ }
197
+
198
+ // 首棵树同步加载,保证 init 返回时已有内容可渲染
199
+ await this._load(this.rootUrls[0]);
200
+ for (const r of this.rootUrls.slice(1)) this._enqueue(r);
201
+
202
+ return this;
203
+ }
204
+
205
+ private _enqueue(url: string | null, parentUrl?: string): void {
206
+ if (!url) return;
207
+ if (parentUrl) this._parentOf.set(url, parentUrl);
208
+ if (this.nodes.has(url) || this.pending.has(url) || this.failed.has(url)) return;
209
+ if (this._queue.includes(url)) return;
210
+ // 失败退避期间不重入队,等 update() 下一轮再来
211
+ const retryAt = this._retryAt.get(url);
212
+ if (retryAt !== undefined && performance.now() < retryAt) return;
213
+ // 满了就淘汰最久没被用到的,而不是干等着永远不再出图
214
+ while (this.nodes.size + this.pending.size >= this.maxTiles) {
215
+ if (!this._evictOne()) return;
216
+ }
217
+ this._queue.push(url);
218
+ this._pump();
219
+ }
220
+
221
+ /**
222
+ * 淘汰一块瓦片给新瓦片腾位置。
223
+ *
224
+ * 分两轮挑:
225
+ * 1. 先挑「没有已加载子瓦片」的叶子 —— 删父瓦片容易露洞;
226
+ * 2. 但如果树的形状是「所有节点都已细化」,一棵叶子都没有,第 1 轮会挑空,
227
+ * 于是永远拒绝加载、相机移到哪都卡在旧画面。这时退一步挑「已经不带
228
+ * 渲染职责」的父瓦片:它的子节点完全覆盖同一片区域,删掉视觉上无损。
229
+ *
230
+ * 两轮都按「最久没进有效范围」排,同帧再比距离。
231
+ * 淘汰最细一级时,如果上一级也不在内存里,顺手把上一级排回队列补洞。
232
+ */
233
+ private _evictOne(): boolean {
234
+ const victim = this._pickVictim(false) ?? this._pickVictim(true);
235
+ if (!victim) return false;
236
+
237
+ const parentUrl = victim.parentUrl ?? this._parentOf.get(victim.url);
238
+ const childUrls = victim.childUrls ?? (victim.childUrl ? [victim.childUrl] : []);
239
+ const isLeaf = !childUrls.some((u) => u !== victim.url && this.nodes.has(u));
240
+
241
+ this.nodes.delete(victim.url);
242
+ victim.group.parent?.remove(victim.group);
243
+ this._onTileRemoved(victim);
244
+ this._parentOf.delete(victim.url);
245
+
246
+ // 删的是最细一级、而上一级又不在内存里 → 把上一级排回去,别让那块地空着
247
+ if (isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
248
+ this._enqueue(parentUrl);
249
+ }
250
+ return true;
251
+ }
252
+
253
+ /**
254
+ * 挑淘汰候选。
255
+ * @param allowRefined false = 只挑叶子;true = 只挑「子节点已全部加载」的父瓦片
256
+ */
257
+ private _pickVictim(allowRefined: boolean): TileNode | null {
258
+ let victim: TileNode | null = null;
259
+ for (const node of this.nodes.values()) {
260
+ const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
261
+ const refined = childUrls.length > 0 && childUrls.some((u) => u !== node.url && this.nodes.has(u));
262
+ if (refined !== allowRefined) continue;
263
+ if (!victim) {
264
+ victim = node;
265
+ continue;
266
+ }
267
+ const a = node.lastSeen ?? -1;
268
+ const b = victim.lastSeen ?? -1;
269
+ if (a < b || (a === b && (node.distance ?? 0) > (victim.distance ?? 0))) victim = node;
270
+ }
271
+ return victim;
272
+ }
273
+
274
+ /** 把之前彻底失败的瓦片全部放回队列(网络恢复后手动调一次) */
275
+ retryFailed(): number {
276
+ const count = this.failed.size;
277
+ this.failed.clear();
278
+ this._retryCount.clear();
279
+ this._retryAt.clear();
280
+ return count;
281
+ }
282
+
283
+ private _pump(): void {
284
+ while (this._active < this.maxConcurrent && this._queue.length) {
285
+ const url = this._queue.shift()!;
286
+ this._active++;
287
+ void this._load(url).finally(() => {
288
+ this._active--;
289
+ this._pump();
290
+ });
291
+ }
292
+ }
293
+
294
+ /**
295
+ * 定位锚点。规范里 position 是局部 ENU 坐标系原点,
296
+ * 但实际数据里 position 偶尔是生产时的占位值(与 geoBounds 不符),
297
+ * 所以这里做一次一致性检查,不一致就退回 geoBounds 中心。
298
+ */
299
+ private _resolveAnchor(config: Record<string, unknown>): Anchor {
300
+ const raw = config.position as { point3D?: { x: number; y: number; z?: number }; x?: number; y?: number; z?: number } | undefined;
301
+ const p = raw?.point3D ?? (raw as { x: number; y: number; z?: number } | undefined);
302
+ const b = config.geoBounds as { left: number; bottom: number; right: number; top: number } | undefined;
303
+ const centerLon = b ? (b.left + b.right) / 2 : undefined;
304
+ const centerLat = b ? (b.bottom + b.top) / 2 : undefined;
305
+ // ENU 原点在椭球面上,position.z 就是它的高度;用 heightRange.min 会引入垂直偏移
306
+ const z = p?.z ?? 0;
307
+
308
+ if (!p) return { lon: centerLon ?? 0, lat: centerLat ?? 0, height: 0 };
309
+
310
+ const units = (config.position as { units?: string } | undefined)?.units ?? 'Degree';
311
+ if (units !== 'Degree') return { lon: p.x!, lat: p.y!, height: z };
312
+
313
+ const offLon = centerLon === undefined ? 0 : Math.abs(p.x! - centerLon);
314
+ const offLat = centerLat === undefined ? 0 : Math.abs(p.y! - centerLat);
315
+ if (offLon > 0.5 || offLat > 0.5) {
316
+ console.warn('[s3m] position 与 geoBounds 不一致,改用 geoBounds 中心作为锚点', {
317
+ position: p,
318
+ geoBounds: b,
319
+ });
320
+ return { lon: centerLon!, lat: centerLat!, height: z };
321
+ }
322
+ return { lon: p.x!, lat: p.y!, height: z };
323
+ }
324
+
325
+ private _pickLod(tile: ParsedTile) {
326
+ const lods = tile.groupNode?.pageLods ?? [];
327
+ if (!lods.length) return null;
328
+ // 优先取带 childTile 且 rangeValue > 0 的包,否则退回第一个
329
+ return lods.find((l) => l.childTile && l.rangeValue > 0) ?? lods[0];
330
+ }
331
+
332
+ private async _builder(): Promise<BuildTileFn> {
333
+ if (!this._buildTile) {
334
+ const mod = await import('../render/GeometryBuilder.js');
335
+ this._buildTile = mod.buildTileObjects as unknown as BuildTileFn;
336
+ }
337
+ return this._buildTile;
338
+ }
339
+
340
+ private async _load(url: string): Promise<void> {
341
+ if (this.nodes.has(url) || this.pending.has(url) || this.failed.has(url)) return;
342
+ this.pending.add(url);
343
+ this.stats.loading++;
344
+
345
+ try {
346
+ const res = await fetch(url);
347
+ if (!res.ok) {
348
+ throw new Error(`HTTP ${res.status} 加载瓦片失败: ${url}`);
349
+ }
350
+ const ctype = res.headers.get('content-type') || '';
351
+ if (ctype.includes('text/html')) {
352
+ throw new Error(`瓦片请求返回了 HTML 页面(通常为 404/500 服务端拦截): ${url}`);
353
+ }
354
+ const buffer = await res.arrayBuffer();
355
+ const tile = await parseS3MB(buffer);
356
+
357
+ const build = await this._builder();
358
+ const { group, stats } = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
359
+ const lod = this._pickLod(tile);
360
+
361
+ const childUrls: string[] = [];
362
+ for (const l of tile.groupNode?.pageLods ?? []) {
363
+ const u = resolveUrl(dirOf(url), l.childTile);
364
+ if (u && u !== url && !childUrls.includes(u)) {
365
+ childUrls.push(u);
366
+ }
367
+ }
368
+
369
+ const node: TileNode = {
370
+ url,
371
+ tile,
372
+ group,
373
+ sphere: lod?.boundingSphere ?? null,
374
+ rangeValue: lod?.rangeValue ?? 0,
375
+ rangeMode: lod?.rangeMode ?? 1,
376
+ childUrl: childUrls[0] ?? resolveUrl(dirOf(url), lod?.childTile),
377
+ childUrls,
378
+ parentUrl: this._parentOf.get(url),
379
+ state: 'ready',
380
+ };
381
+
382
+ this.nodes.set(url, node);
383
+ this.stats.tiles++;
384
+ this.stats.meshes += stats.meshes ?? 0;
385
+ this.stats.vertices += stats.vertices ?? 0;
386
+ this.stats.triangles += stats.triangles ?? 0;
387
+
388
+ this._onTile(node);
389
+ } catch (e) {
390
+ this.stats.failed++;
391
+ const attempts = (this._retryCount.get(url) ?? 0) + 1;
392
+ this._retryCount.set(url, attempts);
393
+ if (attempts <= this._maxRetries) {
394
+ // 退避重试:不进 failed,下一帧 update() 会重新入队,避免网络抖一下就留个永久空洞
395
+ this._retryAt.set(url, performance.now() + 800 * attempts);
396
+ } else {
397
+ this.failed.add(url);
398
+ this._retryAt.delete(url);
399
+ }
400
+ this._onError(e);
401
+ } finally {
402
+ this.pending.delete(url);
403
+ this.stats.loading--;
404
+ this._onProgress(this.stats);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * @param camEnu 相机在该数据集局部 ENU 坐标系中的位置(米)
410
+ * @param fovRad 垂直视场角(弧度)
411
+ * @param screenHeight 视口像素高度
412
+ * @returns 瓦片加载队列或可见性是否发生变化
413
+ */
414
+ update(camEnu: [number, number, number], fovRad: number, screenHeight: number): boolean {
415
+ this._frame++;
416
+ let hasChanges = false;
417
+
418
+ for (const node of this.nodes.values()) {
419
+ if (!node.sphere) {
420
+ node.needRefine = false;
421
+ continue;
422
+ }
423
+
424
+ const c = node.sphere.center;
425
+ const dx = c.x - camEnu[0];
426
+ const dy = c.y - camEnu[1];
427
+ const dz = c.z - camEnu[2];
428
+ const dist = Math.sqrt(dx * dx + dy * dy + dz * dz) || 1e-6;
429
+ node.distance = dist;
430
+
431
+ let metric: number;
432
+ if (node.rangeMode === 0) {
433
+ metric = dist; // Distance_From_EyePoint:距离越小越该细化
434
+ } else {
435
+ // 屏幕像素半径
436
+ metric = ((node.sphere.radius / dist) / Math.tan(fovRad / 2)) * screenHeight * 0.5;
437
+ }
438
+
439
+ // 在有效范围内(留一倍余量)的算「正在用」,淘汰时按这个帧号排优先级
440
+ const scale = this.lodRangeScale > 0 ? this.lodRangeScale : 1.0;
441
+ const threshold = node.rangeValue * scale;
442
+ const inRange = node.rangeMode === 0 ? dist < threshold * 2 : metric > threshold * 0.5;
443
+ if (inRange) node.lastSeen = this._frame;
444
+
445
+ const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
446
+ const needRefine = childUrls.length > 0 && (node.rangeMode === 0 ? metric < threshold : metric > threshold);
447
+ node.needRefine = needRefine;
448
+
449
+ if (needRefine) {
450
+ for (const childUrl of childUrls) {
451
+ if (!this.failed.has(childUrl) && !this.nodes.has(childUrl) && !this.pending.has(childUrl)) {
452
+ this._enqueue(childUrl, node.url);
453
+ hasChanges = true;
454
+ }
455
+ }
456
+ }
457
+ }
458
+
459
+ // 树状递归更新所有瓦片的可见性(确保每个位置只渲染一层最适合的 LOD,避免父子瓦片重叠 Z-fighting)
460
+ const visited = new Set<string>();
461
+ const roots = this.rootUrls.length > 0
462
+ ? this.rootUrls
463
+ : [...this.nodes.keys()].filter((u) => {
464
+ const p = this.nodes.get(u)?.parentUrl;
465
+ return !p || !this.nodes.has(p);
466
+ });
467
+
468
+ for (const rootUrl of roots) {
469
+ if (this._updateNodeVisibility(rootUrl, true, visited)) {
470
+ hasChanges = true;
471
+ }
472
+ }
473
+
474
+ // 防御:若有任何未被 root 树访问到的游离瓦片,全部隐藏
475
+ for (const [url, node] of this.nodes) {
476
+ if (!visited.has(url)) {
477
+ if (node.group.visible) {
478
+ node.group.visible = false;
479
+ hasChanges = true;
480
+ }
481
+ }
482
+ }
483
+
484
+ return hasChanges;
485
+ }
486
+
487
+ private _updateNodeVisibility(url: string, parentInBranch: boolean, visited: Set<string>): boolean {
488
+ const node = this.nodes.get(url);
489
+ if (!node) return false;
490
+ visited.add(url);
491
+
492
+ let changed = false;
493
+ const childUrls = node.childUrls ?? (node.childUrl ? [node.childUrl] : []);
494
+
495
+ if (!parentInBranch) {
496
+ if (node.group.visible) {
497
+ node.group.visible = false;
498
+ changed = true;
499
+ }
500
+ for (const cUrl of childUrls) {
501
+ if (this._updateNodeVisibility(cUrl, false, visited)) changed = true;
502
+ }
503
+ return changed;
504
+ }
505
+
506
+ const hasChildren = childUrls.length > 0;
507
+ const allChildrenReady = hasChildren && childUrls.every((cUrl) => this.nodes.has(cUrl));
508
+ const anyChildFailed = childUrls.some((cUrl) => this.failed.has(cUrl));
509
+
510
+ // 细化前提:需要细化、拥有子瓦片、子瓦片全部加载就绪且没有任何一个子瓦片失败
511
+ const shouldRefine = !!node.needRefine && allChildrenReady && !anyChildFailed;
512
+
513
+ if (shouldRefine) {
514
+ // 子节点接替当前节点渲染,当前父节点隐藏
515
+ if (node.group.visible) {
516
+ node.group.visible = false;
517
+ changed = true;
518
+ }
519
+ for (const cUrl of childUrls) {
520
+ if (this._updateNodeVisibility(cUrl, true, visited)) changed = true;
521
+ }
522
+ } else {
523
+ // 当前节点即为最终展示的 LOD:当前节点显示,下层所有已加载子代瓦片隐藏
524
+ if (!node.group.visible) {
525
+ node.group.visible = true;
526
+ changed = true;
527
+ }
528
+ for (const cUrl of childUrls) {
529
+ if (this._updateNodeVisibility(cUrl, false, visited)) changed = true;
530
+ }
531
+ }
532
+
533
+ return changed;
534
+ }
535
+
536
+ dispose(): void {
537
+ for (const node of this.nodes.values()) {
538
+ node.group.traverse((o) => {
539
+ const mesh = o as {
540
+ geometry?: { dispose?: () => void };
541
+ material?: { dispose?: () => void } | Array<{ dispose?: () => void }>;
542
+ };
543
+ mesh.geometry?.dispose?.();
544
+ if (Array.isArray(mesh.material)) mesh.material.forEach((m) => m.dispose?.());
545
+ else mesh.material?.dispose?.();
546
+ });
547
+ }
548
+ for (const tex of this.textureCache.values()) tex.dispose?.();
549
+ this.textureCache.clear();
550
+ this.nodes.clear();
551
+ }
552
+ }