3dtiles-inspector 0.2.7 → 0.2.8

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/CHANGELOG.md CHANGED
@@ -6,6 +6,13 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.8] - 2026-05-13
10
+
11
+ ### Fixed
12
+
13
+ - Fixed save-time crop memory spikes on large tilesets by limiting concurrent Gaussian Splat resource processing based on CPU parallelism, capped at 8 workers.
14
+ - Fixed viewer stalls when many tiles are loaded by avoiding repeated tileset-wide leaf geometric-error scans during tile preprocessing.
15
+
9
16
  ## [0.2.7] - 2026-05-10
10
17
 
11
18
  ### Changed
@@ -45288,6 +45288,9 @@ function createGeometricErrorController({
45288
45288
  getTiles
45289
45289
  }) {
45290
45290
  const originalTileGeometricErrors = /* @__PURE__ */ new WeakMap();
45291
+ let knownTileChildCounts = /* @__PURE__ */ new WeakMap();
45292
+ let cachedGlobalLeafGeometricError = null;
45293
+ let cachedGlobalLeafGeometricErrorRoot = null;
45291
45294
  let geometricErrorScaleExponent = 0;
45292
45295
  let geometricErrorScale = 1;
45293
45296
  let lastSavedGeometricErrorScale = 1;
@@ -45300,6 +45303,39 @@ function createGeometricErrorController({
45300
45303
  function getEffectiveGeometricErrorLayerScale() {
45301
45304
  return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
45302
45305
  }
45306
+ function getTilesRoot() {
45307
+ return getTiles()?.root || null;
45308
+ }
45309
+ function getCachedGlobalLeafGeometricError() {
45310
+ const root = getTilesRoot();
45311
+ if (cachedGlobalLeafGeometricErrorRoot !== root) {
45312
+ cachedGlobalLeafGeometricErrorRoot = root;
45313
+ cachedGlobalLeafGeometricError = null;
45314
+ knownTileChildCounts = /* @__PURE__ */ new WeakMap();
45315
+ }
45316
+ return cachedGlobalLeafGeometricError;
45317
+ }
45318
+ function setCachedGlobalLeafGeometricError(leafGeometricError) {
45319
+ cachedGlobalLeafGeometricErrorRoot = getTilesRoot();
45320
+ cachedGlobalLeafGeometricError = leafGeometricError;
45321
+ }
45322
+ function clearCachedGlobalLeafGeometricError() {
45323
+ cachedGlobalLeafGeometricError = null;
45324
+ }
45325
+ function trackTileChildCount(tile, children) {
45326
+ knownTileChildCounts.set(tile, children.length);
45327
+ }
45328
+ function invalidateLeafCacheIfParentChildrenChanged(parentTile) {
45329
+ if (!parentTile || typeof parentTile !== "object") {
45330
+ return;
45331
+ }
45332
+ const children = Array.isArray(parentTile.children) ? parentTile.children : [];
45333
+ const knownChildCount = knownTileChildCounts.get(parentTile);
45334
+ if (cachedGlobalLeafGeometricError !== null && knownChildCount !== children.length) {
45335
+ clearCachedGlobalLeafGeometricError();
45336
+ }
45337
+ trackTileChildCount(parentTile, children);
45338
+ }
45303
45339
  function updateTilesetErrorTarget() {
45304
45340
  const tiles = getTiles();
45305
45341
  if (!tiles) {
@@ -45338,6 +45374,7 @@ function createGeometricErrorController({
45338
45374
  visited.add(tile);
45339
45375
  let leafGeometricError = null;
45340
45376
  const children = Array.isArray(tile.children) ? tile.children : [];
45377
+ trackTileChildCount(tile, children);
45341
45378
  for (const child of children) {
45342
45379
  const childLeafGeometricError = getKnownTileLeafGeometricError(
45343
45380
  child,
@@ -45350,31 +45387,58 @@ function createGeometricErrorController({
45350
45387
  visited.delete(tile);
45351
45388
  return leafGeometricError === null ? originalGeometricError : leafGeometricError;
45352
45389
  }
45353
- function getGlobalTileLeafGeometricError(tile) {
45390
+ function getGlobalTileLeafGeometricError(tile, { forceRefresh = false } = {}) {
45391
+ if (!forceRefresh) {
45392
+ const cachedLeafGeometricError = getCachedGlobalLeafGeometricError();
45393
+ if (cachedLeafGeometricError !== null) {
45394
+ return cachedLeafGeometricError;
45395
+ }
45396
+ }
45354
45397
  const tiles = getTiles();
45355
45398
  const rootLeafGeometricError = tiles?.root ? getKnownTileLeafGeometricError(tiles.root) : null;
45399
+ if (tiles?.root && tile === tiles.root) {
45400
+ setCachedGlobalLeafGeometricError(rootLeafGeometricError);
45401
+ return rootLeafGeometricError;
45402
+ }
45356
45403
  const tileLeafGeometricError = getKnownTileLeafGeometricError(tile);
45404
+ let leafGeometricError = null;
45357
45405
  if (rootLeafGeometricError === null) {
45358
- return tileLeafGeometricError;
45359
- }
45360
- if (tileLeafGeometricError === null) {
45361
- return rootLeafGeometricError;
45406
+ leafGeometricError = tileLeafGeometricError;
45407
+ } else if (tileLeafGeometricError === null) {
45408
+ leafGeometricError = rootLeafGeometricError;
45409
+ } else {
45410
+ leafGeometricError = Math.min(
45411
+ rootLeafGeometricError,
45412
+ tileLeafGeometricError
45413
+ );
45362
45414
  }
45363
- return Math.min(rootLeafGeometricError, tileLeafGeometricError);
45415
+ setCachedGlobalLeafGeometricError(leafGeometricError);
45416
+ return leafGeometricError;
45364
45417
  }
45365
- function applyLayerScaleToTile(tile, leafGeometricError = getGlobalTileLeafGeometricError(tile)) {
45418
+ function applyLayerScaleToTile(tile, leafGeometricError = null, parentTile = null) {
45419
+ invalidateLeafCacheIfParentChildrenChanged(parentTile);
45366
45420
  const originalGeometricError = getOriginalTileGeometricError(tile);
45367
- if (originalGeometricError === null || leafGeometricError === null) {
45421
+ if (originalGeometricError === null) {
45422
+ return;
45423
+ }
45424
+ const layerScale = getEffectiveGeometricErrorLayerScale();
45425
+ if (layerScale === 1) {
45426
+ tile.geometricError = originalGeometricError;
45427
+ return;
45428
+ }
45429
+ const effectiveLeafGeometricError = leafGeometricError ?? getGlobalTileLeafGeometricError(tile);
45430
+ if (effectiveLeafGeometricError === null) {
45368
45431
  return;
45369
45432
  }
45370
- tile.geometricError = leafGeometricError + (originalGeometricError - leafGeometricError) * getEffectiveGeometricErrorLayerScale();
45433
+ tile.geometricError = effectiveLeafGeometricError + (originalGeometricError - effectiveLeafGeometricError) * layerScale;
45371
45434
  }
45372
45435
  function applyLayerScaleToTileset() {
45373
45436
  const tiles = getTiles();
45374
45437
  if (!tiles) {
45375
45438
  return;
45376
45439
  }
45377
- const leafGeometricError = getGlobalTileLeafGeometricError(tiles.root);
45440
+ const layerScale = getEffectiveGeometricErrorLayerScale();
45441
+ const leafGeometricError = layerScale === 1 ? null : getGlobalTileLeafGeometricError(tiles.root, { forceRefresh: true });
45378
45442
  tiles.traverse(
45379
45443
  (tile) => {
45380
45444
  applyLayerScaleToTile(tile, leafGeometricError);
@@ -63249,8 +63313,8 @@ function createTerrainGlobeTiles(options) {
63249
63313
  function createGeometricErrorLayerScalePlugin(preprocessNode) {
63250
63314
  return {
63251
63315
  name: "GeometricErrorLayerScalePlugin",
63252
- preprocessNode(tile) {
63253
- preprocessNode(tile);
63316
+ preprocessNode(tile, tilesetDir, parentTile) {
63317
+ preprocessNode(tile, null, parentTile);
63254
63318
  }
63255
63319
  };
63256
63320
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3dtiles-inspector",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "Inspect, align, and save local 3D Tiles root transforms in an interactive browser session.",
5
5
  "author": "William Liu <lyz15972107087@gmail.com>",
6
6
  "license": "Apache-2.0",
@@ -70,7 +70,7 @@ function parseGlb(filePath) {
70
70
  if (chunkType === GLB_JSON_CHUNK_TYPE) {
71
71
  json = JSON.parse(chunk.toString('utf8').replace(/\0+$/g, '').trimEnd());
72
72
  } else if (chunkType === GLB_BIN_CHUNK_TYPE && !bin) {
73
- bin = Buffer.from(chunk);
73
+ bin = chunk;
74
74
  }
75
75
 
76
76
  offset = chunkEnd;
@@ -190,7 +190,7 @@ function loadResourceBuffer(resource, bufferIndex) {
190
190
  `${resource.filePath}.buffers[${bufferIndex}].uri`,
191
191
  );
192
192
  resource.dataUriMetadata.set(bufferIndex, metadata);
193
- record = { bytes: Buffer.from(bytes), dataUri: true };
193
+ record = { bytes, dataUri: true };
194
194
  } else {
195
195
  const bufferPath = resolveLocalUri(
196
196
  path.dirname(resource.filePath),
@@ -205,7 +205,7 @@ function loadResourceBuffer(resource, bufferIndex) {
205
205
  }
206
206
  } else if (resource.type === 'glb' && bufferIndex === 0) {
207
207
  record = {
208
- bytes: Buffer.from(resource.embeddedBin || Buffer.alloc(0)),
208
+ bytes: resource.embeddedBin || Buffer.alloc(0),
209
209
  embedded: true,
210
210
  };
211
211
  } else {
@@ -322,7 +322,7 @@ function applyBufferReplacements(resource, bufferIndex, replacements) {
322
322
 
323
323
  replacements.forEach((replacement) => {
324
324
  parts.push(record.bytes.subarray(cursor, replacement.start));
325
- parts.push(Buffer.from(replacement.bytes));
325
+ parts.push(replacement.bytes);
326
326
  cursor = replacement.end;
327
327
  });
328
328
 
@@ -22,6 +22,71 @@ const {
22
22
  removeMeshPrimitives,
23
23
  updateGaussianPrimitiveAccessorCounts,
24
24
  } = require('./gaussianPrimitives');
25
+ const { SPLAT_CROP_WORKER_COUNT } = require('./workerPool');
26
+
27
+ class AsyncLimiter {
28
+ constructor(limit) {
29
+ this.active = 0;
30
+ this.limit = normalizeConcurrency(limit);
31
+ this.queue = [];
32
+ }
33
+
34
+ run(task) {
35
+ return new Promise((resolve, reject) => {
36
+ this.queue.push({ reject, resolve, task });
37
+ this.dispatch();
38
+ });
39
+ }
40
+
41
+ dispatch() {
42
+ while (this.active < this.limit && this.queue.length > 0) {
43
+ const job = this.queue.shift();
44
+ this.active += 1;
45
+ Promise.resolve()
46
+ .then(job.task)
47
+ .then(job.resolve, job.reject)
48
+ .finally(() => {
49
+ this.active -= 1;
50
+ this.dispatch();
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ function normalizeConcurrency(value) {
57
+ const number = Number(value);
58
+ return Number.isFinite(number) && number > 0
59
+ ? Math.max(1, Math.floor(number))
60
+ : 1;
61
+ }
62
+
63
+ function createAsyncLimiter(limit = SPLAT_CROP_WORKER_COUNT) {
64
+ return new AsyncLimiter(limit);
65
+ }
66
+
67
+ async function mapWithConcurrency(items, concurrency, mapper) {
68
+ if (!Array.isArray(items) || items.length === 0) {
69
+ return [];
70
+ }
71
+
72
+ const limit = normalizeConcurrency(concurrency);
73
+ const results = new Array(items.length);
74
+ let nextIndex = 0;
75
+
76
+ async function worker() {
77
+ while (nextIndex < items.length) {
78
+ const index = nextIndex;
79
+ nextIndex += 1;
80
+ results[index] = await mapper(items[index], index);
81
+ }
82
+ }
83
+
84
+ const workerCount = Math.min(limit, items.length);
85
+ await Promise.all(
86
+ Array.from({ length: workerCount }, () => worker()),
87
+ );
88
+ return results;
89
+ }
25
90
 
26
91
  function getContentSlots(tile) {
27
92
  const slots = [];
@@ -594,36 +659,16 @@ async function processGltfResource({
594
659
  let hasResourceBounds = false;
595
660
  let deletedSplats = 0;
596
661
  let modified = false;
597
- const rewriteTasks = [];
598
662
 
599
663
  for (const [bufferViewIndex, viewDescriptors] of byBufferView) {
600
664
  const slice = getBufferViewSlice(resource, bufferViewIndex);
601
- rewriteTasks.push({
602
- bufferViewIndex,
603
- slice,
604
- viewDescriptors,
605
- promise: rewriteSpzBytesInWorker({
606
- bytes: slice.bytes,
607
- descriptors: viewDescriptors,
608
- screenSelections,
609
- workerPool,
610
- }),
665
+ const rewrite = await rewriteSpzBytesInWorker({
666
+ bytes: slice.bytes,
667
+ descriptors: viewDescriptors,
668
+ screenSelections,
669
+ workerPool,
611
670
  });
612
- }
613
-
614
- const rewriteResults = await Promise.all(
615
- rewriteTasks.map(async (task) => ({
616
- ...task,
617
- rewrite: await task.promise,
618
- })),
619
- );
620
671
 
621
- for (const {
622
- bufferViewIndex,
623
- rewrite,
624
- slice,
625
- viewDescriptors,
626
- } of rewriteResults) {
627
672
  deletedSplats += rewrite.deleted;
628
673
  if (rewrite.bounds) {
629
674
  hasResourceBounds =
@@ -766,11 +811,13 @@ async function processGltfContentSlot(
766
811
  context.resourceLocks,
767
812
  resourcePath,
768
813
  () =>
769
- processLockedSplatResource(
770
- context,
771
- resourcePath,
772
- tileSceneMatrix,
773
- tileProjectionMatrix,
814
+ context.resourceLimiter.run(() =>
815
+ processLockedSplatResource(
816
+ context,
817
+ resourcePath,
818
+ tileSceneMatrix,
819
+ tileProjectionMatrix,
820
+ ),
774
821
  ),
775
822
  );
776
823
 
@@ -809,6 +856,8 @@ async function processNestedTilesetContentSlot(
809
856
  emptySplatResources: context.emptySplatResources,
810
857
  progress: context.progress,
811
858
  resourceLocks: context.resourceLocks,
859
+ resourceLimiter: context.resourceLimiter,
860
+ traversalConcurrency: context.traversalConcurrency,
812
861
  workerPool: context.workerPool,
813
862
  });
814
863
 
@@ -862,6 +911,14 @@ async function processContentSlot(context, slot, tileTransform, tileProjection)
862
911
  return createContentSlotResult(slot, { boundsKnown: false });
863
912
  }
864
913
 
914
+ async function processContentSlots(context, tile, tileTransform, tileProjection) {
915
+ return mapWithConcurrency(
916
+ getContentSlots(tile),
917
+ context.traversalConcurrency,
918
+ (slot) => processContentSlot(context, slot, tileTransform, tileProjection),
919
+ );
920
+ }
921
+
865
922
  function applyContentResultsToTile(context, tile, contentResults) {
866
923
  const contentBounds = [];
867
924
  let boundsKnown = true;
@@ -893,16 +950,18 @@ async function pruneEmptyChildren(context, tile, tileTransform, tileProjection)
893
950
  };
894
951
  }
895
952
 
896
- const children = tile.children.slice();
897
- const childResults = await Promise.all(
898
- children.map(async (child) => ({
899
- child,
900
- result: await visitTilesetTile(context, child, tileTransform, false),
901
- })),
902
- );
903
953
  const childBounds = [];
904
954
  let boundsKnown = true;
905
955
  const keptChildren = [];
956
+ const childResults = await mapWithConcurrency(
957
+ tile.children,
958
+ context.traversalConcurrency,
959
+ async (child) => ({
960
+ child,
961
+ result: await visitTilesetTile(context, child, tileTransform, false),
962
+ }),
963
+ );
964
+
906
965
  childResults.forEach(({ child, result }) => {
907
966
  if (result.empty) {
908
967
  context.tilesetModified = true;
@@ -960,10 +1019,11 @@ async function visitTilesetTile(context, tile, inheritedTransform, isRootTile) {
960
1019
  isRootTile,
961
1020
  );
962
1021
  const tileProjection = getTileProjection(context.THREE, tile);
963
- const contentResults = await Promise.all(
964
- getContentSlots(tile).map((slot) =>
965
- processContentSlot(context, slot, worldTransform, tileProjection),
966
- ),
1022
+ const contentResults = await processContentSlots(
1023
+ context,
1024
+ tile,
1025
+ worldTransform,
1026
+ tileProjection,
967
1027
  );
968
1028
 
969
1029
  const contentSummary = applyContentResultsToTile(context, tile, contentResults);
@@ -1016,6 +1076,8 @@ async function traverseTileset({
1016
1076
  emptySplatResources,
1017
1077
  progress,
1018
1078
  resourceLocks,
1079
+ resourceLimiter = null,
1080
+ traversalConcurrency = SPLAT_CROP_WORKER_COUNT,
1019
1081
  workerPool,
1020
1082
  }) {
1021
1083
  const resolvedTilesetPath = assertPathInsideRoot(
@@ -1039,6 +1101,8 @@ async function traverseTileset({
1039
1101
  throw new InspectorError(`${resolvedTilesetPath} must contain a root object.`);
1040
1102
  }
1041
1103
 
1104
+ const normalizedTraversalConcurrency =
1105
+ normalizeConcurrency(traversalConcurrency);
1042
1106
  const context = {
1043
1107
  THREE,
1044
1108
  deletedSplats: 0,
@@ -1047,11 +1111,14 @@ async function traverseTileset({
1047
1111
  processedResources,
1048
1112
  processedSplatResources: 0,
1049
1113
  resourceLocks,
1114
+ resourceLimiter:
1115
+ resourceLimiter || createAsyncLimiter(normalizedTraversalConcurrency),
1050
1116
  rootDir,
1051
1117
  rootTransform,
1052
1118
  screenSelections,
1053
1119
  tilesetDir: path.dirname(resolvedTilesetPath),
1054
1120
  tilesetModified: false,
1121
+ traversalConcurrency: normalizedTraversalConcurrency,
1055
1122
  upRotationMatrix,
1056
1123
  visitedTilesets,
1057
1124
  workerPool,
@@ -1,11 +1,37 @@
1
1
  const path = require('path');
2
+ const os = require('os');
2
3
  const { Worker } = require('worker_threads');
3
4
 
4
5
  const { InspectorError } = require('../../errors');
5
6
 
6
- const SPLAT_CROP_WORKER_COUNT = 4;
7
+ const MAX_SPLAT_CROP_WORKER_COUNT = 8;
7
8
  const SPLAT_CROP_WORKER_PATH = path.join(__dirname, 'worker.js');
8
9
 
10
+ function getAvailableParallelism() {
11
+ if (typeof os.availableParallelism === 'function') {
12
+ try {
13
+ const parallelism = os.availableParallelism();
14
+ if (Number.isFinite(parallelism) && parallelism > 0) {
15
+ return parallelism;
16
+ }
17
+ } catch (err) {
18
+ // Fall back to os.cpus() below.
19
+ }
20
+ }
21
+
22
+ const cpus = os.cpus();
23
+ return Array.isArray(cpus) && cpus.length > 0 ? cpus.length : 1;
24
+ }
25
+
26
+ function getDefaultSplatCropWorkerCount() {
27
+ return Math.min(
28
+ MAX_SPLAT_CROP_WORKER_COUNT,
29
+ Math.max(1, getAvailableParallelism() - 1),
30
+ );
31
+ }
32
+
33
+ const SPLAT_CROP_WORKER_COUNT = getDefaultSplatCropWorkerCount();
34
+
9
35
  function deserializeWorkerError(error) {
10
36
  const message =
11
37
  error && typeof error.message === 'string'
@@ -74,7 +100,13 @@ class SplatCropWorkerPool {
74
100
  const result = message.result || {};
75
101
  job.resolve({
76
102
  bounds: result.bounds || null,
77
- bytes: result.bytes ? Buffer.from(result.bytes) : null,
103
+ bytes: result.bytes
104
+ ? Buffer.from(
105
+ result.bytes.buffer,
106
+ result.bytes.byteOffset,
107
+ result.bytes.byteLength,
108
+ )
109
+ : null,
78
110
  deleted: Number(result.deleted || 0),
79
111
  empty: !!result.empty,
80
112
  splatCount: Number(result.splatCount || 0),
@@ -179,6 +211,7 @@ class SplatCropWorkerPool {
179
211
  }
180
212
 
181
213
  module.exports = {
214
+ getDefaultSplatCropWorkerCount,
182
215
  SPLAT_CROP_WORKER_COUNT,
183
216
  SplatCropWorkerPool,
184
217
  };
@@ -100,8 +100,8 @@ export function createTerrainGlobeTiles(options) {
100
100
  function createGeometricErrorLayerScalePlugin(preprocessNode) {
101
101
  return {
102
102
  name: 'GeometricErrorLayerScalePlugin',
103
- preprocessNode(tile) {
104
- preprocessNode(tile);
103
+ preprocessNode(tile, tilesetDir, parentTile) {
104
+ preprocessNode(tile, null, parentTile);
105
105
  },
106
106
  };
107
107
  }
@@ -21,6 +21,9 @@ export function createGeometricErrorController({
21
21
  getTiles,
22
22
  }) {
23
23
  const originalTileGeometricErrors = new WeakMap();
24
+ let knownTileChildCounts = new WeakMap();
25
+ let cachedGlobalLeafGeometricError = null;
26
+ let cachedGlobalLeafGeometricErrorRoot = null;
24
27
  let geometricErrorScaleExponent = 0;
25
28
  let geometricErrorScale = 1;
26
29
  let lastSavedGeometricErrorScale = 1;
@@ -36,6 +39,51 @@ export function createGeometricErrorController({
36
39
  return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
37
40
  }
38
41
 
42
+ function getTilesRoot() {
43
+ return getTiles()?.root || null;
44
+ }
45
+
46
+ function getCachedGlobalLeafGeometricError() {
47
+ const root = getTilesRoot();
48
+ if (cachedGlobalLeafGeometricErrorRoot !== root) {
49
+ cachedGlobalLeafGeometricErrorRoot = root;
50
+ cachedGlobalLeafGeometricError = null;
51
+ knownTileChildCounts = new WeakMap();
52
+ }
53
+ return cachedGlobalLeafGeometricError;
54
+ }
55
+
56
+ function setCachedGlobalLeafGeometricError(leafGeometricError) {
57
+ cachedGlobalLeafGeometricErrorRoot = getTilesRoot();
58
+ cachedGlobalLeafGeometricError = leafGeometricError;
59
+ }
60
+
61
+ function clearCachedGlobalLeafGeometricError() {
62
+ cachedGlobalLeafGeometricError = null;
63
+ }
64
+
65
+ function trackTileChildCount(tile, children) {
66
+ knownTileChildCounts.set(tile, children.length);
67
+ }
68
+
69
+ function invalidateLeafCacheIfParentChildrenChanged(parentTile) {
70
+ if (!parentTile || typeof parentTile !== 'object') {
71
+ return;
72
+ }
73
+
74
+ const children = Array.isArray(parentTile.children)
75
+ ? parentTile.children
76
+ : [];
77
+ const knownChildCount = knownTileChildCounts.get(parentTile);
78
+ if (
79
+ cachedGlobalLeafGeometricError !== null &&
80
+ knownChildCount !== children.length
81
+ ) {
82
+ clearCachedGlobalLeafGeometricError();
83
+ }
84
+ trackTileChildCount(parentTile, children);
85
+ }
86
+
39
87
  function updateTilesetErrorTarget() {
40
88
  const tiles = getTiles();
41
89
  if (!tiles) {
@@ -87,6 +135,7 @@ export function createGeometricErrorController({
87
135
  visited.add(tile);
88
136
  let leafGeometricError = null;
89
137
  const children = Array.isArray(tile.children) ? tile.children : [];
138
+ trackTileChildCount(tile, children);
90
139
  for (const child of children) {
91
140
  const childLeafGeometricError = getKnownTileLeafGeometricError(
92
141
  child,
@@ -105,37 +154,69 @@ export function createGeometricErrorController({
105
154
  : leafGeometricError;
106
155
  }
107
156
 
108
- function getGlobalTileLeafGeometricError(tile) {
157
+ function getGlobalTileLeafGeometricError(tile, { forceRefresh = false } = {}) {
158
+ if (!forceRefresh) {
159
+ const cachedLeafGeometricError = getCachedGlobalLeafGeometricError();
160
+ if (cachedLeafGeometricError !== null) {
161
+ return cachedLeafGeometricError;
162
+ }
163
+ }
164
+
109
165
  const tiles = getTiles();
110
166
  const rootLeafGeometricError = tiles?.root
111
167
  ? getKnownTileLeafGeometricError(tiles.root)
112
168
  : null;
113
- const tileLeafGeometricError = getKnownTileLeafGeometricError(tile);
114
169
 
115
- if (rootLeafGeometricError === null) {
116
- return tileLeafGeometricError;
170
+ if (tiles?.root && tile === tiles.root) {
171
+ setCachedGlobalLeafGeometricError(rootLeafGeometricError);
172
+ return rootLeafGeometricError;
117
173
  }
118
174
 
119
- if (tileLeafGeometricError === null) {
120
- return rootLeafGeometricError;
175
+ const tileLeafGeometricError = getKnownTileLeafGeometricError(tile);
176
+ let leafGeometricError = null;
177
+
178
+ if (rootLeafGeometricError === null) {
179
+ leafGeometricError = tileLeafGeometricError;
180
+ } else if (tileLeafGeometricError === null) {
181
+ leafGeometricError = rootLeafGeometricError;
182
+ } else {
183
+ leafGeometricError = Math.min(
184
+ rootLeafGeometricError,
185
+ tileLeafGeometricError,
186
+ );
121
187
  }
122
188
 
123
- return Math.min(rootLeafGeometricError, tileLeafGeometricError);
189
+ setCachedGlobalLeafGeometricError(leafGeometricError);
190
+ return leafGeometricError;
124
191
  }
125
192
 
126
193
  function applyLayerScaleToTile(
127
194
  tile,
128
- leafGeometricError = getGlobalTileLeafGeometricError(tile),
195
+ leafGeometricError = null,
196
+ parentTile = null,
129
197
  ) {
198
+ invalidateLeafCacheIfParentChildrenChanged(parentTile);
199
+
130
200
  const originalGeometricError = getOriginalTileGeometricError(tile);
131
- if (originalGeometricError === null || leafGeometricError === null) {
201
+ if (originalGeometricError === null) {
202
+ return;
203
+ }
204
+
205
+ const layerScale = getEffectiveGeometricErrorLayerScale();
206
+ if (layerScale === 1) {
207
+ tile.geometricError = originalGeometricError;
208
+ return;
209
+ }
210
+
211
+ const effectiveLeafGeometricError =
212
+ leafGeometricError ?? getGlobalTileLeafGeometricError(tile);
213
+ if (effectiveLeafGeometricError === null) {
132
214
  return;
133
215
  }
134
216
 
135
217
  tile.geometricError =
136
- leafGeometricError +
137
- (originalGeometricError - leafGeometricError) *
138
- getEffectiveGeometricErrorLayerScale();
218
+ effectiveLeafGeometricError +
219
+ (originalGeometricError - effectiveLeafGeometricError) * layerScale;
139
220
  }
140
221
 
141
222
  function applyLayerScaleToTileset() {
@@ -144,7 +225,11 @@ export function createGeometricErrorController({
144
225
  return;
145
226
  }
146
227
 
147
- const leafGeometricError = getGlobalTileLeafGeometricError(tiles.root);
228
+ const layerScale = getEffectiveGeometricErrorLayerScale();
229
+ const leafGeometricError =
230
+ layerScale === 1
231
+ ? null
232
+ : getGlobalTileLeafGeometricError(tiles.root, { forceRefresh: true });
148
233
  tiles.traverse(
149
234
  (tile) => {
150
235
  applyLayerScaleToTile(tile, leafGeometricError);