s3m-three 0.3.1 → 0.3.2

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.
@@ -288,6 +288,7 @@ export interface BuildTileResult {
288
288
  group: THREE.Group;
289
289
  stats: TileBuildStats;
290
290
  objects: THREE.Object3D[];
291
+ textureKeys?: string[];
291
292
  }
292
293
 
293
294
  interface CachedGeometry {
@@ -308,6 +309,7 @@ export async function buildTileObjects(
308
309
  const textureByCode = options.textureCache ?? new Map<string, THREE.Texture>();
309
310
  const group = new THREE.Group();
310
311
  const objects: THREE.Object3D[] = [];
312
+ const usedTextureKeys: string[] = [];
311
313
  const stats: TileBuildStats = {
312
314
  meshes: 0,
313
315
  vertices: 0,
@@ -352,6 +354,7 @@ export async function buildTileObjects(
352
354
  const { texture, decoded } = await createTexture(tex);
353
355
  if (texture) {
354
356
  textureByCode.set(key, texture);
357
+ usedTextureKeys.push(key);
355
358
  stats.textures++;
356
359
  } else {
357
360
  stats.skipped.push(`texture ${code} → ${decoded}`);
@@ -1028,5 +1031,6 @@ export async function buildTileObjects(
1028
1031
 
1029
1032
  if (!group.children.length) stats.skipped.push('tile 无可用几何');
1030
1033
 
1031
- return { group, stats, objects };
1034
+ group.userData.textureKeys = usedTextureKeys;
1035
+ return { group, stats, objects, textureKeys: usedTextureKeys };
1032
1036
  }
@@ -144,7 +144,42 @@ export interface PickResult extends FeatureAttributes {
144
144
  mesh: THREE.Mesh;
145
145
  }
146
146
 
147
+ export interface LightOptions {
148
+ /** 环境光强度,默认 0.4 */
149
+ ambientIntensity?: number;
150
+ /** 环境光颜色,十六进制或 CSS 颜色字符串,默认 0xffffff */
151
+ ambientColor?: number | string;
152
+ /** 半球光天空强度,默认 0.8 */
153
+ hemisphereIntensity?: number;
154
+ /** 半球光天空颜色,默认 0xffffff */
155
+ hemisphereColor?: number | string;
156
+ /** 半球光地面颜色,默认 0xb6c0cb */
157
+ hemisphereGroundColor?: number | string;
158
+ /** 主平行光强度,默认 1.0 */
159
+ keyLightIntensity?: number;
160
+ /** 主平行光颜色,默认 0xffffff */
161
+ keyLightColor?: number | string;
162
+ /** 补光强度,默认 0.35 */
163
+ fillLightIntensity?: number;
164
+ /** 补光颜色,默认 0xffffff */
165
+ fillLightColor?: number | string;
166
+ /**
167
+ * 光照朝向模式:
168
+ * - 'headlamp': 默认头灯模式(随相机 bearing 旋转,受光面始终面向镜头)
169
+ * - 'fixed': 固定世界坐标方位角(如太阳光模拟)
170
+ */
171
+ mode?: 'headlamp' | 'fixed';
172
+ /** 方位角(度,0~360),固定模式下以正北为 0 顺时针,头灯模式下为相机相对偏角,默认 150 */
173
+ azimuth?: number;
174
+ /** 太阳/光源仰角(度,0~90),默认 50 */
175
+ altitude?: number;
176
+ }
177
+
147
178
  export interface S3MViewerOptions {
179
+ /** 光照参数配置(强度、颜色、朝向模式、太阳方位角/仰角等) */
180
+ light?: LightOptions;
181
+ /** 全局总驻留瓦片上限(多数据集多图层共享预算),在所有可见数据集之间动态均衡分配,彻底防止爆显存 */
182
+ totalMaxTiles?: number;
148
183
  /** 单数据集简写 */
149
184
  configUrl?: string;
150
185
  /** 多数据集 */
@@ -294,8 +329,24 @@ export class S3MViewer {
294
329
  private _needsRepaint = true;
295
330
  private _frame = 0;
296
331
  private readonly _fps = { value: 0, last: 0 };
297
- private _keyLight: THREE.DirectionalLight | null = null;
298
- private _fillLight: THREE.DirectionalLight | null = null;
332
+ private _ambientLight!: THREE.AmbientLight;
333
+ private _hemiLight!: THREE.HemisphereLight;
334
+ private _keyLight!: THREE.DirectionalLight;
335
+ private _fillLight!: THREE.DirectionalLight;
336
+ private readonly _lightOptions: Required<LightOptions> = {
337
+ ambientIntensity: 0.4,
338
+ ambientColor: 0xffffff,
339
+ hemisphereIntensity: 0.8,
340
+ hemisphereColor: 0xffffff,
341
+ hemisphereGroundColor: 0xb6c0cb,
342
+ keyLightIntensity: 1.0,
343
+ keyLightColor: 0xffffff,
344
+ fillLightIntensity: 0.35,
345
+ fillLightColor: 0xffffff,
346
+ mode: 'headlamp',
347
+ azimuth: 150,
348
+ altitude: 50,
349
+ };
299
350
 
300
351
  private readonly _datasetQueue: S3MDatasetOptions[];
301
352
 
@@ -309,10 +360,20 @@ export class S3MViewer {
309
360
  this._onRequestRepaint();
310
361
  }
311
362
 
363
+ triggerRepaint(): void {
364
+ this._requestRepaint();
365
+ }
366
+
312
367
  constructor(options: S3MViewerOptions = {}) {
313
368
  this.options = options;
314
369
  this.pickable = options.pickable !== false;
315
370
  this._lodRangeScale = options.lodRangeScale ?? 1.0;
371
+ if (options.light) {
372
+ Object.assign(this._lightOptions, options.light);
373
+ }
374
+ if (options.totalMaxTiles) {
375
+ this._datasetManager.totalMaxTiles = options.totalMaxTiles;
376
+ }
316
377
  this._onRequestRepaint = options.onRequestRepaint ?? (() => {});
317
378
  this._getBearing = options.getBearing;
318
379
  this.onStats = options.onStats ?? (() => {});
@@ -321,6 +382,11 @@ export class S3MViewer {
321
382
  this.onError = options.onError ?? ((e) => console.warn('[s3m]', e));
322
383
 
323
384
  this.groundManager = new GroundManager(options.ground);
385
+ if (!this.scene) {
386
+ this.scene = new THREE.Scene();
387
+ this.scene.add(this.groundManager.group);
388
+ this._addLights();
389
+ }
324
390
  this._featureStyleManager = new FeatureStyleManager(
325
391
  () => this.style.floorHeight,
326
392
  () => this._requestRepaint(),
@@ -412,24 +478,70 @@ export class S3MViewer {
412
478
  this.canvas = null;
413
479
  }
414
480
 
481
+ private _createLights(): void {
482
+ this._ambientLight = new THREE.AmbientLight(this._lightOptions.ambientColor as any, this._lightOptions.ambientIntensity);
483
+ this._hemiLight = new THREE.HemisphereLight(
484
+ this._lightOptions.hemisphereColor as any,
485
+ this._lightOptions.hemisphereGroundColor as any,
486
+ this._lightOptions.hemisphereIntensity
487
+ );
488
+ this._keyLight = new THREE.DirectionalLight(this._lightOptions.keyLightColor as any, this._lightOptions.keyLightIntensity);
489
+ this._fillLight = new THREE.DirectionalLight(this._lightOptions.fillLightColor as any, this._lightOptions.fillLightIntensity);
490
+ this._updateLights();
491
+ }
492
+
415
493
  private _addLights(): void {
416
494
  if (!this.scene) return;
417
- // 环境光与天光打底,保证背光面和折叠处不死黑
418
- this.scene.add(new THREE.AmbientLight(0xffffff, 0.4));
419
- this.scene.add(new THREE.HemisphereLight(0xffffff, 0xb6c0cb, 0.8));
420
- // 主光 / 补光的方向每帧按地图朝向重算(见 _updateLights),
421
- // 这样旋转视角时白模始终是亮的,不会转到背光面就一片死灰。
422
- this._keyLight = new THREE.DirectionalLight(0xffffff, 1.0);
423
- this._fillLight = new THREE.DirectionalLight(0xffffff, 0.35);
424
- this.scene.add(this._keyLight, this._fillLight);
495
+ if (!this._ambientLight) this._createLights();
496
+ this.scene.add(this._ambientLight, this._hemiLight, this._keyLight, this._fillLight);
497
+ }
498
+
499
+ private _applyLightOptions(): void {
500
+ if (this._ambientLight) {
501
+ this._ambientLight.intensity = this._lightOptions.ambientIntensity;
502
+ this._ambientLight.color.set(this._lightOptions.ambientColor as any);
503
+ }
504
+ if (this._hemiLight) {
505
+ this._hemiLight.intensity = this._lightOptions.hemisphereIntensity;
506
+ this._hemiLight.color.set(this._lightOptions.hemisphereColor as any);
507
+ this._hemiLight.groundColor.set(this._lightOptions.hemisphereGroundColor as any);
508
+ }
509
+ if (this._keyLight) {
510
+ this._keyLight.intensity = this._lightOptions.keyLightIntensity;
511
+ this._keyLight.color.set(this._lightOptions.keyLightColor as any);
512
+ }
513
+ if (this._fillLight) {
514
+ this._fillLight.intensity = this._lightOptions.fillLightIntensity;
515
+ this._fillLight.color.set(this._lightOptions.fillLightColor as any);
516
+ }
425
517
  }
426
518
 
427
519
  /**
428
- * 让光照跟着地图朝向走:主光在镜头后上方偏左,补光在后上方偏右。
429
- * 相当于一盏头灯,看到的立面永远是受光面。
520
+ * 动态设置光照配置
521
+ */
522
+ setLight(options: Partial<LightOptions>): void {
523
+ Object.assign(this._lightOptions, options);
524
+ this._applyLightOptions();
525
+ this._updateLights();
526
+ this._needsRepaint = true;
527
+ this.triggerRepaint();
528
+ }
529
+
530
+ /**
531
+ * 获取当前光照配置快照
532
+ */
533
+ getLight(): LightOptions {
534
+ return { ...this._lightOptions };
535
+ }
536
+
537
+ /**
538
+ * 更新光照位置与方向:
539
+ * - 'headlamp': 随地图方位角 bearing 旋转,主光始终在镜头上方,受光面面向镜头;
540
+ * - 'fixed': 固定世界方位角(如模拟恒定日光)。
430
541
  */
431
542
  private _updateLights(): void {
432
- const bearing = ((this._getBearing?.() ?? 0) * Math.PI) / 180;
543
+ if (!this._keyLight || !this._fillLight) return;
544
+ const bearing = this._lightOptions.mode === 'fixed' ? 0 : (((this._getBearing?.() ?? 0) * Math.PI) / 180);
433
545
  const place = (light: THREE.DirectionalLight | null, azOffsetDeg: number, elevDeg: number): void => {
434
546
  if (!light) return;
435
547
  const az = bearing + (azOffsetDeg * Math.PI) / 180;
@@ -437,8 +549,8 @@ export class S3MViewer {
437
549
  const c = Math.cos(el);
438
550
  light.position.set(Math.sin(az) * c, Math.cos(az) * c, Math.sin(el)).multiplyScalar(1000);
439
551
  };
440
- place(this._keyLight, 150, 50);
441
- place(this._fillLight, 215, 30);
552
+ place(this._keyLight, this._lightOptions.azimuth, this._lightOptions.altitude);
553
+ place(this._fillLight, this._lightOptions.azimuth + 65, Math.max(10, this._lightOptions.altitude - 20));
442
554
  }
443
555
 
444
556
  /* ---------------------------------------------------------------- 数据集 */
@@ -877,6 +989,27 @@ export class S3MViewer {
877
989
  * 而 LOD 细化、瓦片重载、淘汰回补都会换掉 mesh —— 不重放的话,用户点中的那栋楼
878
990
  * 一细化就失去高亮(状态还在 _selection.smid 里,只是没人往新 mesh 上刷)。
879
991
  */
992
+ /**
993
+ * 判断一块无 SMID 单体化信息的网格(如倾斜摄影或白模整块)是否空间覆盖当前选中命中点
994
+ */
995
+ private _isMeshCoveringSelection(mesh: THREE.Mesh, sel: PickResult): boolean {
996
+ if (!sel.point) return false;
997
+ const geom = mesh.geometry;
998
+ if (!geom) return false;
999
+ if (!geom.boundingBox) geom.computeBoundingBox();
1000
+ if (!geom.boundingBox) return false;
1001
+
1002
+ // 将选中的三维场景点转入网格局部坐标系进行包围盒检测
1003
+ const pt = new THREE.Vector3(sel.point[0], sel.point[1], sel.point[2]);
1004
+ mesh.updateWorldMatrix(true, false);
1005
+ const inv = mesh.matrixWorld.clone().invert();
1006
+ const localPt = pt.applyMatrix4(inv);
1007
+
1008
+ // 留出 0.5 米容差,避免刚好处于三角网格边缘缝隙时漏判
1009
+ const box = geom.boundingBox.clone().expandByScalar(0.5);
1010
+ return box.containsPoint(localPt);
1011
+ }
1012
+
880
1013
  private _replaySelectionOnto(mesh: THREE.Mesh): void {
881
1014
  const sel = this._selection;
882
1015
  if (!sel) return;
@@ -893,7 +1026,13 @@ export class S3MViewer {
893
1026
  // 拾取命中的那块 mesh 用原始 batchId;同一 smid 散在别的几何里时按各自的区间算
894
1027
  if (mesh === sel.mesh) batchId = sel.batchId;
895
1028
  // 这块几何里根本没有这个单体 → 不动它(否则会把无关瓦片一起点亮)
896
- if (batchId < 0 && mesh !== sel.mesh) return;
1029
+ if (batchId < 0 && mesh !== sel.mesh) {
1030
+ if (sel.smid == null && ud.datasetId === sel.datasetId && this._isMeshCoveringSelection(mesh, sel)) {
1031
+ batchId = -1;
1032
+ } else {
1033
+ return;
1034
+ }
1035
+ }
897
1036
 
898
1037
  mat.userData.uSelBatch.value = batchId;
899
1038
  mat.userData.uSelWhole.value = batchId < 0 ? 1 : 0;
@@ -903,44 +1042,56 @@ export class S3MViewer {
903
1042
  this.uniforms.uDim.value = this.style.dimOthers ? 1 : 0;
904
1043
  }
905
1044
 
906
- /** 瓦片被淘汰:从拾取表和场景里摘掉,并释放它自己的 GPU 资源 */
1045
+ /** 瓦片被淘汰:从拾取表和场景里摘掉,并深度彻底释放所有几何体与材质显存 */
907
1046
  private _unregisterTile(node: TileNode, entry: S3MDatasetEntry): void {
908
- for (const child of [...node.group.children]) {
909
- const mesh = child as THREE.Mesh;
910
- if (!mesh.isMesh) continue;
911
- const ud = mesh.userData as MeshUserData;
912
- // 「这块 mesh 没了」≠「用户不再选中」:LOD 细化 / 淘汰回补都会换掉 mesh。
913
- // 以前这里一律 select(null),于是点中的那栋楼一拉近,选中状态就整个被清掉。
914
- const wasSelected = this._selectedMeshes.includes(mesh);
915
- if (wasSelected) {
916
- this._selectedMeshes = this._selectedMeshes.filter((m) => m !== mesh);
1047
+ node.group.traverse((child) => {
1048
+ const obj = child as any;
1049
+ if (obj.userData?.pickId != null) {
1050
+ this._pickables.delete(obj.userData.pickId);
1051
+ this._releasePickId(obj.userData.pickId);
1052
+ obj.userData.pickId = undefined;
917
1053
  }
918
- if (this._selection?.mesh === mesh) {
919
- const smid = this._selection.smid;
920
- const carrier =
921
- smid != null ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== mesh && m.parent) : undefined;
922
- if (carrier) {
923
- // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
924
- this._selection = { ...this._selection, mesh: carrier };
925
- this._replaySelectionOnto(carrier);
926
- } else {
927
- this.select(null);
1054
+ if (obj.isMesh) {
1055
+ const wasSelected = this._selectedMeshes.includes(obj);
1056
+ if (wasSelected) {
1057
+ this._selectedMeshes = this._selectedMeshes.filter((m) => m !== obj);
928
1058
  }
1059
+ const currentSel = this._selection;
1060
+ if (currentSel && currentSel.mesh === obj) {
1061
+ const smid = currentSel.smid;
1062
+ const carrier = (
1063
+ smid != null
1064
+ ? [...(this._smidMeshes.get(smid) ?? [])].find((m) => m !== obj && m.parent)
1065
+ : this._selectedMeshes.find((m) => m !== obj && m.parent && (m.parent as any).visible) ??
1066
+ this._selectedMeshes.find((m) => m !== obj && m.parent)
1067
+ ) as THREE.Mesh | undefined;
1068
+ if (carrier) {
1069
+ // 选中身份交给承载同一 smid 的另一块 mesh(通常是接替显示的子瓦)
1070
+ this._selection = { ...currentSel, mesh: carrier };
1071
+ this._replaySelectionOnto(carrier);
1072
+ } else {
1073
+ this.select(null);
1074
+ }
1075
+ }
1076
+ this._unindexMeshSmids(obj);
1077
+ this._removeMeshHeight(obj);
1078
+ this._featureStyleManager.disposeMesh(obj);
1079
+ const styled = styledMaterialOf(obj);
1080
+ styled?.dispose?.();
929
1081
  }
930
- if (ud.pickId != null) {
931
- this._pickables.delete(ud.pickId);
932
- this._releasePickId(ud.pickId);
933
- ud.pickId = undefined;
1082
+ // 深度释放材质(点云、普通材质、材质数组)
1083
+ if (isOwnedPointCloudMaterial(obj.material)) {
1084
+ (obj.material as THREE.Material).dispose();
1085
+ } else if (obj.material) {
1086
+ if (Array.isArray(obj.material)) {
1087
+ obj.material.forEach((m: any) => m?.dispose?.());
1088
+ } else {
1089
+ obj.material.dispose?.();
1090
+ }
934
1091
  }
935
- this._unindexMeshSmids(mesh);
936
- this._removeMeshHeight(mesh);
937
- this._featureStyleManager.disposeMesh(mesh);
938
- const styled = styledMaterialOf(mesh);
939
- styled?.dispose?.();
940
- // 点云材质是本模块按模式建的,不走 styled 那条链
941
- if (isOwnedPointCloudMaterial(mesh.material)) (mesh.material as THREE.Material).dispose();
942
- mesh.geometry?.dispose?.();
943
- }
1092
+ // 释放几何体显存缓冲
1093
+ obj.geometry?.dispose?.();
1094
+ });
944
1095
  entry.root.remove(node.group);
945
1096
  }
946
1097
 
@@ -216,6 +216,10 @@ export interface TileNode {
216
216
  parentUrl?: string;
217
217
  /** 当前帧是否满足细化条件 */
218
218
  needRefine?: boolean;
219
+ /** 该瓦片引用的纹理 key 清单,用于引用计数与显存释放 */
220
+ textureKeys?: string[];
221
+ /** 瓦片构建产生的统计信息,用于淘汰时准确扣减 stats */
222
+ stats?: { meshes: number; vertices: number; triangles: number };
219
223
  }
220
224
 
221
225
  /** 瓦片构建器可注入,用于测试 / 服务端渲染(此时不需要 three) */
@@ -228,6 +232,8 @@ export interface S3MTilesetOptions {
228
232
  configUrl: string;
229
233
  maxTiles?: number;
230
234
  maxConcurrent?: number;
235
+ /** 原始图像/子图集缓存上限(LRU),默认 128 */
236
+ maxImageCache?: number;
231
237
  buildTile?: BuildTileFn | null;
232
238
  uniforms?: StyleUniforms | null;
233
239
  onTile?: (node: TileNode) => void;
@@ -266,6 +272,9 @@ export class S3MTileset {
266
272
  readonly nodes = new Map<string, TileNode>();
267
273
  readonly failed = new Set<string>();
268
274
  readonly textureCache = new Map<string, Texture>();
275
+ maxImageCache: number;
276
+ private readonly _textureRefs = new Map<string, number>();
277
+ private readonly _tileTextureKeys = new Map<string, string[]>();
269
278
  readonly pending = new Set<string>();
270
279
  /** 是否请求 `.texblock` 子图集,默认关,见 S3MTilesetOptions.atlasLod */
271
280
  atlasLod: boolean;
@@ -316,7 +325,7 @@ export class S3MTileset {
316
325
  /** 格式类永久错误只向上报一次,避免整棵瓦片树刷同样的日志 */
317
326
  private _permanentReported = false;
318
327
  /** 请求量的硬上限(软上限 maxTiles 的超额余量),见构造函数注释 */
319
- private hardCap = 96;
328
+ hardCap = 96;
320
329
  /** 本数据集是 S3M 1.x(SuperMapCache,.s3m):子瓦地址规则与 3.x 不同 */
321
330
  private isS3MV1 = false;
322
331
  private _buildTile: BuildTileFn | null;
@@ -341,6 +350,7 @@ export class S3MTileset {
341
350
  // 并发与总量限制:iServer 公开服务上别把人家打爆
342
351
  this.maxConcurrent = options.maxConcurrent ?? 4;
343
352
  this.maxTiles = options.maxTiles ?? 64;
353
+ this.maxImageCache = options.maxImageCache ?? 128;
344
354
  // 硬上限 = 软上限 + 余量。淘汰只在「新瓦片已经落地并接管显示」之后做(make-before-break),
345
355
  // 所以中间态允许临时超出 maxTiles;硬上限只用来挡住失控的请求量,不用于挤掉正在显示的内容。
346
356
  this.hardCap = this.maxTiles + Math.max(8, Math.ceil(this.maxTiles * 0.5));
@@ -419,19 +429,91 @@ export class S3MTileset {
419
429
  * 两轮都按「最久没进有效范围」排,同帧再比距离。
420
430
  * 淘汰最细一级时,如果上一级也不在内存里,顺手把上一级排回队列补洞。
421
431
  */
432
+ /** 记录瓦片引用的纹理 key,并递增其引用计数 */
433
+ registerTileTextures(url: string, textureKeys: string[]): void {
434
+ this._tileTextureKeys.set(url, textureKeys);
435
+ const node = this.nodes.get(url);
436
+ if (node) {
437
+ node.textureKeys = textureKeys;
438
+ }
439
+ for (const key of textureKeys) {
440
+ this._textureRefs.set(key, (this._textureRefs.get(key) ?? 0) + 1);
441
+ }
442
+ }
443
+
444
+ /** 淘汰单块瓦片节点并释放专属纹理 */
445
+ _evictTileNode(victim: TileNode): void {
446
+ this.nodes.delete(victim.url);
447
+ victim.group?.parent?.remove(victim.group);
448
+ this._onTileRemoved(victim);
449
+ this._parentOf.delete(victim.url);
450
+
451
+ this.stats.tiles = Math.max(0, this.stats.tiles - 1);
452
+ if (victim.stats) {
453
+ this.stats.meshes = Math.max(0, this.stats.meshes - (victim.stats.meshes ?? 0));
454
+ this.stats.vertices = Math.max(0, this.stats.vertices - (victim.stats.vertices ?? 0));
455
+ this.stats.triangles = Math.max(0, this.stats.triangles - (victim.stats.triangles ?? 0));
456
+ }
457
+
458
+ // 释放此瓦片引用的纹理计数;引用归零时从 GPU / 缓存彻底 dispose
459
+ const keys = victim.textureKeys ?? this._tileTextureKeys.get(victim.url) ?? (victim.group?.userData?.textureKeys as string[] | undefined);
460
+ this._tileTextureKeys.delete(victim.url);
461
+ if (keys?.length) {
462
+ for (const key of keys) {
463
+ const ref = (this._textureRefs.get(key) ?? 1) - 1;
464
+ if (ref <= 0) {
465
+ this._textureRefs.delete(key);
466
+ const tex = this.textureCache.get(key);
467
+ if (tex) {
468
+ tex.dispose?.();
469
+ this.textureCache.delete(key);
470
+ }
471
+ } else {
472
+ this._textureRefs.set(key, ref);
473
+ }
474
+ }
475
+ }
476
+ }
477
+
478
+ /** 存入外部图集(带 LRU 容量上限) */
479
+ putExternalAtlas(id: string, img: S3MBlockAtlasImage | null): void {
480
+ if (this.externalAtlasCache.size >= this.maxImageCache && !this.externalAtlasCache.has(id)) {
481
+ const oldestKey = this.externalAtlasCache.keys().next().value;
482
+ if (oldestKey !== undefined) this.externalAtlasCache.delete(oldestKey);
483
+ }
484
+ Map.prototype.set.call(this.externalAtlasCache, id, img);
485
+ }
486
+
487
+ /** 读取外部图集 */
488
+ getExternalAtlas(id: string): S3MBlockAtlasImage | null | undefined {
489
+ return this.externalAtlasCache.get(id);
490
+ }
491
+
492
+ /** 存入 leaf 记录段(带 LRU 容量上限) */
493
+ putLeafRecords(name: string, recs: S3MBlockAtlasImage[] | null): void {
494
+ if (this.leafRequestCache.size >= this.maxImageCache && !this.leafRequestCache.has(name)) {
495
+ const oldestKey = this.leafRequestCache.keys().next().value;
496
+ if (oldestKey !== undefined) this.leafRequestCache.delete(oldestKey);
497
+ }
498
+ Map.prototype.set.call(this.leafRequestCache, name, recs);
499
+ }
500
+
501
+ /** 读取 leaf 记录段 */
502
+ getLeafRecords(name: string): S3MBlockAtlasImage[] | null | undefined {
503
+ return this.leafRequestCache.get(name);
504
+ }
505
+
422
506
  private _evictOne(allowUncovered = false): boolean {
507
+
423
508
  // 只挑安全候选:叶子 → 已被子瓦接手的父瓦。allowUncovered 仅在超出硬上限时开启(内存兜底)
424
- const victim = this._pickVictim(false, allowUncovered) ?? this._pickVictim(true, allowUncovered);
509
+ const victim = this._pickVictim(true, allowUncovered) ?? this._pickVictim(false, allowUncovered);
425
510
  if (!victim) return false;
426
511
 
427
512
  const parentUrl = victim.parentUrl ?? this._parentOf.get(victim.url);
428
513
  const childUrls = victim.childUrls ?? (victim.childUrl ? [victim.childUrl] : []);
429
514
  const isLeaf = !childUrls.some((u) => u !== victim.url && this.nodes.has(u));
430
515
 
431
- this.nodes.delete(victim.url);
432
- victim.group.parent?.remove(victim.group);
433
- this._onTileRemoved(victim);
434
- this._parentOf.delete(victim.url);
516
+ this._evictTileNode(victim);
435
517
 
436
518
  // 淘汰的是中间层时,把已加载的子孙改挂到它的父级上:
437
519
  // 不然它们瞬间"无父",父级那边还留着指向 victim 的 childUrls,
@@ -459,8 +541,8 @@ export class S3MTileset {
459
541
  }
460
542
 
461
543
  // 删的是最细一级、而上一级又不在内存里 → 把上一级排回去,别让那块地空着
462
- if (isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
463
- this._enqueue(parentUrl);
544
+ if (false as boolean && isLeaf && parentUrl && !this.nodes.has(parentUrl) && !this.pending.has(parentUrl)) {
545
+ // 淘汰阶段不再重新入队父瓦,防止预算满载时引发父子死循环震荡(thrashing loop)
464
546
  }
465
547
  return true;
466
548
  }
@@ -773,7 +855,7 @@ export class S3MTileset {
773
855
  break;
774
856
  }
775
857
  }
776
- this.externalAtlasCache.set(id, candidate);
858
+ this.putExternalAtlas(id, candidate);
777
859
  }
778
860
  if (candidate) {
779
861
  if (accept(candidate)) apply(candidate);
@@ -818,10 +900,10 @@ export class S3MTileset {
818
900
  if (segments.length !== names.length) {
819
901
  throw new Error(`pack 段数 ${segments.length} ≠ 请求名 ${names.length}`);
820
902
  }
821
- names.forEach((n, i) => this.leafRequestCache.set(n, segments[i]!));
903
+ names.forEach((n, i) => this.putLeafRecords(n, segments[i]!));
822
904
  } catch (e) {
823
905
  console.warn('[s3m] leaf pack 请求失败:', u, e);
824
- for (const n of names) if (!this.leafRequestCache.has(n)) this.leafRequestCache.set(n, null);
906
+ for (const n of names) if (!this.leafRequestCache.has(n)) this.putLeafRecords(n, null);
825
907
  }
826
908
  }
827
909
 
@@ -833,10 +915,10 @@ export class S3MTileset {
833
915
  const res = await fetch(u);
834
916
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
835
917
  const records = (await parseTexblockSegments(await res.arrayBuffer())).flat();
836
- this.leafRequestCache.set(stem, records);
918
+ this.putLeafRecords(stem, records);
837
919
  } catch (e) {
838
920
  console.warn('[s3m] leaf texblock 请求失败:', u, e);
839
- if (!this.leafRequestCache.has(stem)) this.leafRequestCache.set(stem, null);
921
+ if (!this.leafRequestCache.has(stem)) this.putLeafRecords(stem, null);
840
922
  }
841
923
  }
842
924
 
@@ -898,7 +980,10 @@ export class S3MTileset {
898
980
  }
899
981
 
900
982
  const build = await this._builder();
901
- const { group, stats } = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
983
+ const buildRes = await build(tile, { textureCache: this.textureCache, uniforms: this._uniforms });
984
+ const { group, stats } = buildRes;
985
+ const texKeys = (buildRes as any).textureKeys ?? (group.userData?.textureKeys as string[] | undefined) ?? Object.keys(tile.textures ?? {});
986
+ this.registerTileTextures(url, texKeys);
902
987
  const lod = this._pickLod(tile);
903
988
 
904
989
  const childUrls: string[] = [];
@@ -932,6 +1017,11 @@ export class S3MTileset {
932
1017
  childUrls,
933
1018
  parentUrl: this._parentOf.get(url),
934
1019
  state: 'ready',
1020
+ stats: {
1021
+ meshes: stats.meshes ?? 0,
1022
+ vertices: stats.vertices ?? 0,
1023
+ triangles: stats.triangles ?? 0,
1024
+ },
935
1025
  };
936
1026
 
937
1027
  this.nodes.set(url, node);