3dtiles-inspector 0.1.4 → 0.1.5

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,16 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.5] - 2026-04-27
10
+
11
+ ### Added
12
+
13
+ - Added an LOD `Layer Multiplier` slider to scale geometric errors progressively by distance from leaf tiles.
14
+
15
+ ### Changed
16
+
17
+ - Tightened inspector toolbar spacing and LOD slider value labels.
18
+
9
19
  ## [0.1.4] - 2026-04-25
10
20
 
11
21
  ### Changed
package/README.md CHANGED
@@ -82,6 +82,7 @@ const {
82
82
  - `Set Position` to click the globe, terrain, or loaded tiles and place the tileset there
83
83
  - `Terrain` to toggle Cesium World Terrain while keeping satellite imagery
84
84
  - `Geometric Error` scaling from `1/16x` to `16x`
85
+ - `Layer Multiplier` scaling from `1/1.5x` to `1.5x` for leaf-based geometric error changes between tile depths
85
86
  - `Save` to persist the updated root transform and geometric-error scale back to disk
86
87
 
87
88
  If `build_summary.json` exists next to the root tileset, `Save` also updates:
@@ -90,6 +91,7 @@ If `build_summary.json` exists next to the root tileset, `Save` also updates:
90
91
  - `root_transform_source`
91
92
  - `root_coordinate`
92
93
  - `viewer_geometric_error_scale`
94
+ - `viewer_geometric_error_layer_scale`
93
95
 
94
96
  ## Package Surface
95
97
 
@@ -65484,12 +65484,26 @@ var geometricErrorScaleInput = document.getElementById(
65484
65484
  "geometric-error-scale"
65485
65485
  );
65486
65486
  var geometricErrorValueEl = document.getElementById("geometric-error-value");
65487
+ var geometricErrorLayerScaleInput = document.getElementById(
65488
+ "geometric-error-layer-scale"
65489
+ );
65490
+ var geometricErrorLayerValueEl = document.getElementById(
65491
+ "geometric-error-layer-value"
65492
+ );
65487
65493
  var setPositionButton = document.getElementById("set-position");
65488
65494
  var resetButton = document.getElementById("reset");
65489
65495
  var saveButton = document.getElementById("save");
65490
65496
  var GEOMETRIC_ERROR_SCALE_MIN_EXPONENT = -4;
65491
65497
  var GEOMETRIC_ERROR_SCALE_MAX_EXPONENT = 4;
65492
65498
  var GEOMETRIC_ERROR_SCALE_STEP = 0.1;
65499
+ var GEOMETRIC_ERROR_LAYER_SCALE_MAX = 1.5;
65500
+ var GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT = -Math.log2(
65501
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX
65502
+ );
65503
+ var GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT = Math.log2(
65504
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX
65505
+ );
65506
+ var GEOMETRIC_ERROR_LAYER_SCALE_STEP = "any";
65493
65507
  var DEFAULT_ERROR_TARGET = 16;
65494
65508
  var DEFAULT_TERRAIN_ERROR_TARGET = 16;
65495
65509
  var RUNTIME_STATS_UPDATE_INTERVAL_MS = 250;
@@ -65801,12 +65815,16 @@ var savedRootInverseMatrix = new Matrix4();
65801
65815
  var pointerCoords = new Vector2();
65802
65816
  var pickRaycaster = new Raycaster();
65803
65817
  var pickTargets = [];
65818
+ var originalTileGeometricErrors = /* @__PURE__ */ new WeakMap();
65804
65819
  var tiles = null;
65805
65820
  var toolbarVisible = true;
65806
65821
  var activeTransformMode = null;
65807
65822
  var geometricErrorScaleExponent = 0;
65808
65823
  var geometricErrorScale = 1;
65809
65824
  var lastSavedGeometricErrorScale = 1;
65825
+ var geometricErrorLayerScaleExponent = 0;
65826
+ var geometricErrorLayerScale = 1;
65827
+ var lastSavedGeometricErrorLayerScale = 1;
65810
65828
  var lastSavedMatrix = new Matrix4();
65811
65829
  var savedRootMatrix = new Matrix4();
65812
65830
  var savedRootMatrixPromise = Promise.resolve();
@@ -65831,9 +65849,73 @@ function updateGeometricErrorScaleDisplay() {
65831
65849
  geometricErrorScale
65832
65850
  )}`;
65833
65851
  }
65852
+ function updateGeometricErrorLayerScaleDisplay() {
65853
+ geometricErrorLayerValueEl.textContent = `x${formatGeometricErrorScale(
65854
+ geometricErrorLayerScale
65855
+ )}`;
65856
+ }
65834
65857
  function getEffectiveGeometricErrorScale() {
65835
65858
  return lastSavedGeometricErrorScale * geometricErrorScale;
65836
65859
  }
65860
+ function getEffectiveGeometricErrorLayerScale() {
65861
+ return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
65862
+ }
65863
+ function getKnownTileLeafDistance(tile, visited = /* @__PURE__ */ new Set()) {
65864
+ if (!tile || typeof tile !== "object" || visited.has(tile)) {
65865
+ return 0;
65866
+ }
65867
+ visited.add(tile);
65868
+ let maxDistance = 0;
65869
+ const children = Array.isArray(tile.children) ? tile.children : [];
65870
+ for (const child of children) {
65871
+ maxDistance = Math.max(
65872
+ maxDistance,
65873
+ getKnownTileLeafDistance(child, visited) + 1
65874
+ );
65875
+ }
65876
+ visited.delete(tile);
65877
+ return maxDistance;
65878
+ }
65879
+ function getOriginalTileGeometricError(tile) {
65880
+ if (!tile || typeof tile !== "object") {
65881
+ return null;
65882
+ }
65883
+ if (!originalTileGeometricErrors.has(tile)) {
65884
+ const number = Number(tile.geometricError);
65885
+ if (!Number.isFinite(number)) {
65886
+ return null;
65887
+ }
65888
+ originalTileGeometricErrors.set(tile, number);
65889
+ }
65890
+ return originalTileGeometricErrors.get(tile);
65891
+ }
65892
+ function applyGeometricErrorLayerScaleToTile(tile) {
65893
+ const originalGeometricError = getOriginalTileGeometricError(tile);
65894
+ if (originalGeometricError === null) {
65895
+ return;
65896
+ }
65897
+ const leafDistance = getKnownTileLeafDistance(tile);
65898
+ tile.geometricError = originalGeometricError * getEffectiveGeometricErrorLayerScale() ** leafDistance;
65899
+ }
65900
+ function applyGeometricErrorLayerScaleToTileset() {
65901
+ if (!tiles) {
65902
+ return;
65903
+ }
65904
+ tiles.traverse(
65905
+ (tile) => {
65906
+ applyGeometricErrorLayerScaleToTile(tile);
65907
+ return false;
65908
+ },
65909
+ null,
65910
+ false
65911
+ );
65912
+ }
65913
+ var geometricErrorLayerScalePlugin = {
65914
+ name: "GeometricErrorLayerScalePlugin",
65915
+ preprocessNode(tile) {
65916
+ applyGeometricErrorLayerScaleToTile(tile);
65917
+ }
65918
+ };
65837
65919
  function getGaussianMeshSplatCount(mesh) {
65838
65920
  if (!mesh || typeof mesh !== "object") {
65839
65921
  return 0;
@@ -65898,6 +65980,19 @@ function setGeometricErrorScaleExponent(exponent) {
65898
65980
  updateGeometricErrorScaleDisplay();
65899
65981
  updateTilesetErrorTarget();
65900
65982
  }
65983
+ function setGeometricErrorLayerScaleExponent(exponent) {
65984
+ geometricErrorLayerScaleExponent = clamp2(
65985
+ Number(exponent),
65986
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
65987
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT
65988
+ );
65989
+ geometricErrorLayerScale = exponentToGeometricErrorScale(
65990
+ geometricErrorLayerScaleExponent
65991
+ );
65992
+ geometricErrorLayerScaleInput.value = geometricErrorLayerScaleExponent.toFixed(1);
65993
+ updateGeometricErrorLayerScaleDisplay();
65994
+ applyGeometricErrorLayerScaleToTileset();
65995
+ }
65901
65996
  function syncTerrainButton() {
65902
65997
  terrainButton.classList.toggle("active", terrainEnabled);
65903
65998
  terrainLight.visible = terrainEnabled;
@@ -65979,7 +66074,15 @@ function toggleTransformMode(mode) {
65979
66074
  geometricErrorScaleInput.min = String(GEOMETRIC_ERROR_SCALE_MIN_EXPONENT);
65980
66075
  geometricErrorScaleInput.max = String(GEOMETRIC_ERROR_SCALE_MAX_EXPONENT);
65981
66076
  geometricErrorScaleInput.step = String(GEOMETRIC_ERROR_SCALE_STEP);
66077
+ geometricErrorLayerScaleInput.min = String(
66078
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT
66079
+ );
66080
+ geometricErrorLayerScaleInput.max = String(
66081
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT
66082
+ );
66083
+ geometricErrorLayerScaleInput.step = String(GEOMETRIC_ERROR_LAYER_SCALE_STEP);
65982
66084
  setGeometricErrorScaleExponent(geometricErrorScaleExponent);
66085
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleExponent);
65983
66086
  setTerrainEnabled(terrainEnabled);
65984
66087
  setTransformMode(activeTransformMode);
65985
66088
  syncToolbarVisibility();
@@ -66473,7 +66576,9 @@ function loadTileset(url) {
66473
66576
  updateRuntimeStats(true);
66474
66577
  resetEditableGroup();
66475
66578
  lastSavedGeometricErrorScale = 1;
66579
+ lastSavedGeometricErrorLayerScale = 1;
66476
66580
  setGeometricErrorScaleExponent(0);
66581
+ setGeometricErrorLayerScaleExponent(0);
66477
66582
  savedRootMatrix.identity();
66478
66583
  savedRootMatrixLoadError = null;
66479
66584
  savedRootMatrixPromise = refreshSavedRootMatrix(url).then(
@@ -66496,7 +66601,16 @@ function loadTileset(url) {
66496
66601
  next.registerPlugin(new go());
66497
66602
  next.registerPlugin(new To());
66498
66603
  next.registerPlugin(new w2());
66499
- next.registerPlugin(new GaussianSplatPlugin({ renderer, scene }));
66604
+ next.registerPlugin(geometricErrorLayerScalePlugin);
66605
+ next.registerPlugin(
66606
+ new GaussianSplatPlugin({
66607
+ renderer,
66608
+ scene,
66609
+ sparkRendererOptions: {
66610
+ accumExtSplats: true
66611
+ }
66612
+ })
66613
+ );
66500
66614
  debugTilesPlugin = new Oo({
66501
66615
  displayBoxBounds: showBoundingVolume,
66502
66616
  displaySphereBounds: showBoundingVolume,
@@ -66543,6 +66657,7 @@ function loadTileset(url) {
66543
66657
  setStatus("Tileset ready.");
66544
66658
  }
66545
66659
  };
66660
+ next.addEventListener("load-tileset", applyGeometricErrorLayerScaleToTileset);
66546
66661
  next.addEventListener("load-tile-set", tryFrame);
66547
66662
  next.addEventListener("load-tileset", tryFrame);
66548
66663
  }
@@ -66554,6 +66669,8 @@ async function saveTransform() {
66554
66669
  const incrementalMatrix = currentMatrix.clone().multiply(lastSavedMatrix.clone().invert());
66555
66670
  const incrementalGeometricErrorScale = geometricErrorScale;
66556
66671
  const savedGeometricErrorScale = getEffectiveGeometricErrorScale();
66672
+ const incrementalGeometricErrorLayerScale = geometricErrorLayerScale;
66673
+ const savedGeometricErrorLayerScale = getEffectiveGeometricErrorLayerScale();
66557
66674
  try {
66558
66675
  const response = await fetch(SAVE_URL, {
66559
66676
  method: "POST",
@@ -66561,6 +66678,7 @@ async function saveTransform() {
66561
66678
  "Content-Type": "application/json"
66562
66679
  },
66563
66680
  body: JSON.stringify({
66681
+ geometricErrorLayerScale: incrementalGeometricErrorLayerScale,
66564
66682
  geometricErrorScale: incrementalGeometricErrorScale,
66565
66683
  transform: incrementalMatrix.toArray()
66566
66684
  })
@@ -66591,13 +66709,17 @@ async function saveTransform() {
66591
66709
  }
66592
66710
  }
66593
66711
  lastSavedGeometricErrorScale = savedGeometricErrorScale;
66712
+ lastSavedGeometricErrorLayerScale = savedGeometricErrorLayerScale;
66594
66713
  lastSavedMatrix.copy(currentMatrix);
66595
66714
  setGeometricErrorScaleExponent(0);
66715
+ setGeometricErrorLayerScaleExponent(0);
66596
66716
  syncTransformHandleFromTilesTransform();
66597
66717
  syncCoordinateInputsFromTilesTransform();
66598
66718
  setStatus(
66599
- `Saved transform and geometric-error scale x${formatGeometricErrorScale(
66719
+ `Saved transform, geometric-error scale x${formatGeometricErrorScale(
66600
66720
  savedGeometricErrorScale
66721
+ )}, and layer multiplier x${formatGeometricErrorScale(
66722
+ savedGeometricErrorLayerScale
66601
66723
  )} to ${ROOT_TILESET_LABEL} and build_summary.json.`
66602
66724
  );
66603
66725
  } catch (err2) {
@@ -66638,6 +66760,16 @@ geometricErrorScaleInput.addEventListener("change", () => {
66638
66760
  )}.`
66639
66761
  );
66640
66762
  });
66763
+ geometricErrorLayerScaleInput.addEventListener("input", () => {
66764
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleInput.value);
66765
+ });
66766
+ geometricErrorLayerScaleInput.addEventListener("change", () => {
66767
+ setStatus(
66768
+ `Geometric-error layer multiplier set to x${formatGeometricErrorScale(
66769
+ geometricErrorLayerScale
66770
+ )}.`
66771
+ );
66772
+ });
66641
66773
  moveToTilesButton.addEventListener("click", moveCameraToTiles);
66642
66774
  moveCameraToCoordinateButton.addEventListener("click", moveCameraToCoordinate);
66643
66775
  moveTilesToCoordinateButton.addEventListener("click", moveTilesToCoordinate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3dtiles-inspector",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
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",
package/src/viewer/app.js CHANGED
@@ -100,12 +100,26 @@ const geometricErrorScaleInput = document.getElementById(
100
100
  'geometric-error-scale',
101
101
  );
102
102
  const geometricErrorValueEl = document.getElementById('geometric-error-value');
103
+ const geometricErrorLayerScaleInput = document.getElementById(
104
+ 'geometric-error-layer-scale',
105
+ );
106
+ const geometricErrorLayerValueEl = document.getElementById(
107
+ 'geometric-error-layer-value',
108
+ );
103
109
  const setPositionButton = document.getElementById('set-position');
104
110
  const resetButton = document.getElementById('reset');
105
111
  const saveButton = document.getElementById('save');
106
112
  const GEOMETRIC_ERROR_SCALE_MIN_EXPONENT = -4;
107
113
  const GEOMETRIC_ERROR_SCALE_MAX_EXPONENT = 4;
108
114
  const GEOMETRIC_ERROR_SCALE_STEP = 0.1;
115
+ const GEOMETRIC_ERROR_LAYER_SCALE_MAX = 1.5;
116
+ const GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT = -Math.log2(
117
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX,
118
+ );
119
+ const GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT = Math.log2(
120
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX,
121
+ );
122
+ const GEOMETRIC_ERROR_LAYER_SCALE_STEP = 'any';
109
123
  const DEFAULT_ERROR_TARGET = 16;
110
124
  const DEFAULT_TERRAIN_ERROR_TARGET = 16;
111
125
  const RUNTIME_STATS_UPDATE_INTERVAL_MS = 250;
@@ -470,12 +484,16 @@ const savedRootInverseMatrix = new Matrix4();
470
484
  const pointerCoords = new Vector2();
471
485
  const pickRaycaster = new Raycaster();
472
486
  const pickTargets = [];
487
+ const originalTileGeometricErrors = new WeakMap();
473
488
  let tiles = null;
474
489
  let toolbarVisible = true;
475
490
  let activeTransformMode = null;
476
491
  let geometricErrorScaleExponent = 0;
477
492
  let geometricErrorScale = 1;
478
493
  let lastSavedGeometricErrorScale = 1;
494
+ let geometricErrorLayerScaleExponent = 0;
495
+ let geometricErrorLayerScale = 1;
496
+ let lastSavedGeometricErrorLayerScale = 1;
479
497
  let lastSavedMatrix = new Matrix4();
480
498
  const savedRootMatrix = new Matrix4();
481
499
  let savedRootMatrixPromise = Promise.resolve();
@@ -505,10 +523,88 @@ function updateGeometricErrorScaleDisplay() {
505
523
  )}`;
506
524
  }
507
525
 
526
+ function updateGeometricErrorLayerScaleDisplay() {
527
+ geometricErrorLayerValueEl.textContent = `x${formatGeometricErrorScale(
528
+ geometricErrorLayerScale,
529
+ )}`;
530
+ }
531
+
508
532
  function getEffectiveGeometricErrorScale() {
509
533
  return lastSavedGeometricErrorScale * geometricErrorScale;
510
534
  }
511
535
 
536
+ function getEffectiveGeometricErrorLayerScale() {
537
+ return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
538
+ }
539
+
540
+ function getKnownTileLeafDistance(tile, visited = new Set()) {
541
+ if (!tile || typeof tile !== 'object' || visited.has(tile)) {
542
+ return 0;
543
+ }
544
+
545
+ visited.add(tile);
546
+ let maxDistance = 0;
547
+ const children = Array.isArray(tile.children) ? tile.children : [];
548
+ for (const child of children) {
549
+ maxDistance = Math.max(
550
+ maxDistance,
551
+ getKnownTileLeafDistance(child, visited) + 1,
552
+ );
553
+ }
554
+ visited.delete(tile);
555
+ return maxDistance;
556
+ }
557
+
558
+ function getOriginalTileGeometricError(tile) {
559
+ if (!tile || typeof tile !== 'object') {
560
+ return null;
561
+ }
562
+
563
+ if (!originalTileGeometricErrors.has(tile)) {
564
+ const number = Number(tile.geometricError);
565
+ if (!Number.isFinite(number)) {
566
+ return null;
567
+ }
568
+ originalTileGeometricErrors.set(tile, number);
569
+ }
570
+
571
+ return originalTileGeometricErrors.get(tile);
572
+ }
573
+
574
+ function applyGeometricErrorLayerScaleToTile(tile) {
575
+ const originalGeometricError = getOriginalTileGeometricError(tile);
576
+ if (originalGeometricError === null) {
577
+ return;
578
+ }
579
+
580
+ const leafDistance = getKnownTileLeafDistance(tile);
581
+ tile.geometricError =
582
+ originalGeometricError *
583
+ getEffectiveGeometricErrorLayerScale() ** leafDistance;
584
+ }
585
+
586
+ function applyGeometricErrorLayerScaleToTileset() {
587
+ if (!tiles) {
588
+ return;
589
+ }
590
+
591
+ tiles.traverse(
592
+ (tile) => {
593
+ applyGeometricErrorLayerScaleToTile(tile);
594
+ return false;
595
+ },
596
+ null,
597
+ false,
598
+ );
599
+ }
600
+
601
+ const geometricErrorLayerScalePlugin = {
602
+ name: 'GeometricErrorLayerScalePlugin',
603
+ preprocessNode(tile) {
604
+ applyGeometricErrorLayerScaleToTile(tile);
605
+ },
606
+ };
607
+
512
608
  function getGaussianMeshSplatCount(mesh) {
513
609
  if (!mesh || typeof mesh !== 'object') {
514
610
  return 0;
@@ -605,6 +701,21 @@ function setGeometricErrorScaleExponent(exponent) {
605
701
  updateTilesetErrorTarget();
606
702
  }
607
703
 
704
+ function setGeometricErrorLayerScaleExponent(exponent) {
705
+ geometricErrorLayerScaleExponent = clamp(
706
+ Number(exponent),
707
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
708
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT,
709
+ );
710
+ geometricErrorLayerScale = exponentToGeometricErrorScale(
711
+ geometricErrorLayerScaleExponent,
712
+ );
713
+ geometricErrorLayerScaleInput.value =
714
+ geometricErrorLayerScaleExponent.toFixed(1);
715
+ updateGeometricErrorLayerScaleDisplay();
716
+ applyGeometricErrorLayerScaleToTileset();
717
+ }
718
+
608
719
  function syncTerrainButton() {
609
720
  terrainButton.classList.toggle('active', terrainEnabled);
610
721
  terrainLight.visible = terrainEnabled;
@@ -702,7 +813,15 @@ function toggleTransformMode(mode) {
702
813
  geometricErrorScaleInput.min = String(GEOMETRIC_ERROR_SCALE_MIN_EXPONENT);
703
814
  geometricErrorScaleInput.max = String(GEOMETRIC_ERROR_SCALE_MAX_EXPONENT);
704
815
  geometricErrorScaleInput.step = String(GEOMETRIC_ERROR_SCALE_STEP);
816
+ geometricErrorLayerScaleInput.min = String(
817
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
818
+ );
819
+ geometricErrorLayerScaleInput.max = String(
820
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT,
821
+ );
822
+ geometricErrorLayerScaleInput.step = String(GEOMETRIC_ERROR_LAYER_SCALE_STEP);
705
823
  setGeometricErrorScaleExponent(geometricErrorScaleExponent);
824
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleExponent);
706
825
  setTerrainEnabled(terrainEnabled);
707
826
  setTransformMode(activeTransformMode);
708
827
  syncToolbarVisibility();
@@ -1324,7 +1443,9 @@ function loadTileset(url) {
1324
1443
 
1325
1444
  resetEditableGroup();
1326
1445
  lastSavedGeometricErrorScale = 1;
1446
+ lastSavedGeometricErrorLayerScale = 1;
1327
1447
  setGeometricErrorScaleExponent(0);
1448
+ setGeometricErrorLayerScaleExponent(0);
1328
1449
  savedRootMatrix.identity();
1329
1450
  savedRootMatrixLoadError = null;
1330
1451
  savedRootMatrixPromise = refreshSavedRootMatrix(url).then(
@@ -1348,7 +1469,16 @@ function loadTileset(url) {
1348
1469
  next.registerPlugin(new TileCompressionPlugin());
1349
1470
  next.registerPlugin(new UnloadTilesPlugin());
1350
1471
  next.registerPlugin(new ImplicitTilingPlugin());
1351
- next.registerPlugin(new GaussianSplatPlugin({ renderer, scene }));
1472
+ next.registerPlugin(geometricErrorLayerScalePlugin);
1473
+ next.registerPlugin(
1474
+ new GaussianSplatPlugin({
1475
+ renderer,
1476
+ scene,
1477
+ sparkRendererOptions: {
1478
+ accumExtSplats: true,
1479
+ },
1480
+ }),
1481
+ );
1352
1482
  debugTilesPlugin = new DebugTilesPlugin({
1353
1483
  displayBoxBounds: showBoundingVolume,
1354
1484
  displaySphereBounds: showBoundingVolume,
@@ -1399,6 +1529,7 @@ function loadTileset(url) {
1399
1529
  }
1400
1530
  };
1401
1531
 
1532
+ next.addEventListener('load-tileset', applyGeometricErrorLayerScaleToTileset);
1402
1533
  next.addEventListener('load-tile-set', tryFrame);
1403
1534
  next.addEventListener('load-tileset', tryFrame);
1404
1535
  }
@@ -1414,6 +1545,8 @@ async function saveTransform() {
1414
1545
  .multiply(lastSavedMatrix.clone().invert());
1415
1546
  const incrementalGeometricErrorScale = geometricErrorScale;
1416
1547
  const savedGeometricErrorScale = getEffectiveGeometricErrorScale();
1548
+ const incrementalGeometricErrorLayerScale = geometricErrorLayerScale;
1549
+ const savedGeometricErrorLayerScale = getEffectiveGeometricErrorLayerScale();
1417
1550
 
1418
1551
  try {
1419
1552
  const response = await fetch(SAVE_URL, {
@@ -1422,6 +1555,7 @@ async function saveTransform() {
1422
1555
  'Content-Type': 'application/json',
1423
1556
  },
1424
1557
  body: JSON.stringify({
1558
+ geometricErrorLayerScale: incrementalGeometricErrorLayerScale,
1425
1559
  geometricErrorScale: incrementalGeometricErrorScale,
1426
1560
  transform: incrementalMatrix.toArray(),
1427
1561
  }),
@@ -1452,13 +1586,17 @@ async function saveTransform() {
1452
1586
  }
1453
1587
  }
1454
1588
  lastSavedGeometricErrorScale = savedGeometricErrorScale;
1589
+ lastSavedGeometricErrorLayerScale = savedGeometricErrorLayerScale;
1455
1590
  lastSavedMatrix.copy(currentMatrix);
1456
1591
  setGeometricErrorScaleExponent(0);
1592
+ setGeometricErrorLayerScaleExponent(0);
1457
1593
  syncTransformHandleFromTilesTransform();
1458
1594
  syncCoordinateInputsFromTilesTransform();
1459
1595
  setStatus(
1460
- `Saved transform and geometric-error scale x${formatGeometricErrorScale(
1596
+ `Saved transform, geometric-error scale x${formatGeometricErrorScale(
1461
1597
  savedGeometricErrorScale,
1598
+ )}, and layer multiplier x${formatGeometricErrorScale(
1599
+ savedGeometricErrorLayerScale,
1462
1600
  )} to ${ROOT_TILESET_LABEL} and build_summary.json.`,
1463
1601
  );
1464
1602
  } catch (err) {
@@ -1506,6 +1644,16 @@ geometricErrorScaleInput.addEventListener('change', () => {
1506
1644
  )}.`,
1507
1645
  );
1508
1646
  });
1647
+ geometricErrorLayerScaleInput.addEventListener('input', () => {
1648
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleInput.value);
1649
+ });
1650
+ geometricErrorLayerScaleInput.addEventListener('change', () => {
1651
+ setStatus(
1652
+ `Geometric-error layer multiplier set to x${formatGeometricErrorScale(
1653
+ geometricErrorLayerScale,
1654
+ )}.`,
1655
+ );
1656
+ });
1509
1657
  moveToTilesButton.addEventListener('click', moveCameraToTiles);
1510
1658
  moveCameraToCoordinateButton.addEventListener('click', moveCameraToCoordinate);
1511
1659
  moveTilesToCoordinateButton.addEventListener('click', moveTilesToCoordinate);
@@ -184,10 +184,153 @@ function scaleGeometricErrorValue(target, key, scale, label) {
184
184
  throw new InspectorError(`${label} must be a finite number.`);
185
185
  }
186
186
 
187
- target[key] = number * scale;
187
+ const next = number * scale;
188
+ if (!Number.isFinite(next)) {
189
+ throw new InspectorError(`${label} scaled value must be finite.`);
190
+ }
191
+
192
+ target[key] = next;
193
+ }
194
+
195
+ function getGeometricErrorScaleForLeafDistance(
196
+ geometricErrorScale,
197
+ geometricErrorLayerScale,
198
+ leafDistance,
199
+ label,
200
+ ) {
201
+ const scale =
202
+ geometricErrorScale * geometricErrorLayerScale ** Math.max(0, leafDistance);
203
+ if (!Number.isFinite(scale)) {
204
+ throw new InspectorError(`${label} scale must be a finite number.`);
205
+ }
206
+ return scale;
207
+ }
208
+
209
+ function assertTilesetPathInsideRoot(resolvedPath, rootDir) {
210
+ if (
211
+ resolvedPath !== rootDir &&
212
+ !resolvedPath.startsWith(`${rootDir}${path.sep}`)
213
+ ) {
214
+ throw new InspectorError(
215
+ `Nested tileset path escapes the viewer root: ${resolvedPath}`,
216
+ );
217
+ }
218
+ }
219
+
220
+ function getLocalExternalTilesetPaths(tile, baseDir) {
221
+ const paths = [];
222
+ if (!tile || typeof tile !== 'object') {
223
+ return paths;
224
+ }
225
+
226
+ if (tile.content && typeof tile.content === 'object') {
227
+ const filePath = getLocalJsonReferencePath(
228
+ baseDir,
229
+ tile.content.uri || tile.content.url,
230
+ );
231
+ if (filePath) {
232
+ paths.push(filePath);
233
+ }
234
+ }
235
+
236
+ if (Array.isArray(tile.contents)) {
237
+ tile.contents.forEach((entry) => {
238
+ if (entry && typeof entry === 'object') {
239
+ const filePath = getLocalJsonReferencePath(
240
+ baseDir,
241
+ entry.uri || entry.url,
242
+ );
243
+ if (filePath) {
244
+ paths.push(filePath);
245
+ }
246
+ }
247
+ });
248
+ }
249
+
250
+ return paths;
251
+ }
252
+
253
+ function getTilesetRootLeafDistance(
254
+ tilesetPath,
255
+ rootDir,
256
+ leafDistanceCache,
257
+ stack,
258
+ ) {
259
+ const resolvedPath = path.resolve(tilesetPath);
260
+ if (leafDistanceCache.has(resolvedPath)) {
261
+ return leafDistanceCache.get(resolvedPath);
262
+ }
263
+
264
+ if (stack.has(resolvedPath)) {
265
+ return 0;
266
+ }
267
+
268
+ assertTilesetPathInsideRoot(resolvedPath, rootDir);
269
+
270
+ if (!fs.existsSync(resolvedPath)) {
271
+ throw new InspectorError(
272
+ `Referenced nested tileset does not exist: ${resolvedPath}`,
273
+ );
274
+ }
275
+
276
+ const tileset = readJsonFile(resolvedPath);
277
+ if (!tileset || typeof tileset !== 'object' || !tileset.root) {
278
+ throw new InspectorError(`${resolvedPath} must contain a root object.`);
279
+ }
280
+
281
+ stack.add(resolvedPath);
282
+ const leafDistance = getTileLeafDistance(
283
+ tileset.root,
284
+ path.dirname(resolvedPath),
285
+ rootDir,
286
+ leafDistanceCache,
287
+ stack,
288
+ );
289
+ stack.delete(resolvedPath);
290
+ leafDistanceCache.set(resolvedPath, leafDistance);
291
+ return leafDistance;
292
+ }
293
+
294
+ function getTileLeafDistance(tile, baseDir, rootDir, leafDistanceCache, stack) {
295
+ if (!tile || typeof tile !== 'object') {
296
+ return 0;
297
+ }
298
+
299
+ let maxDistance = 0;
300
+ if (Array.isArray(tile.children)) {
301
+ tile.children.forEach((child) => {
302
+ maxDistance = Math.max(
303
+ maxDistance,
304
+ getTileLeafDistance(child, baseDir, rootDir, leafDistanceCache, stack) +
305
+ 1,
306
+ );
307
+ });
308
+ }
309
+
310
+ getLocalExternalTilesetPaths(tile, baseDir).forEach((childTilesetPath) => {
311
+ maxDistance = Math.max(
312
+ maxDistance,
313
+ getTilesetRootLeafDistance(
314
+ childTilesetPath,
315
+ rootDir,
316
+ leafDistanceCache,
317
+ stack,
318
+ ) + 1,
319
+ );
320
+ });
321
+
322
+ return maxDistance;
188
323
  }
189
324
 
190
- function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
325
+ function scaleTilesetGeometricErrors(
326
+ tile,
327
+ geometricErrorScale,
328
+ geometricErrorLayerScale,
329
+ baseDir,
330
+ rootDir,
331
+ leafDistanceCache,
332
+ pathLabel = 'tileset.root',
333
+ ) {
191
334
  if (!tile || typeof tile !== 'object') {
192
335
  return;
193
336
  }
@@ -195,7 +338,12 @@ function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
195
338
  scaleGeometricErrorValue(
196
339
  tile,
197
340
  'geometricError',
198
- scale,
341
+ getGeometricErrorScaleForLeafDistance(
342
+ geometricErrorScale,
343
+ geometricErrorLayerScale,
344
+ getTileLeafDistance(tile, baseDir, rootDir, leafDistanceCache, new Set()),
345
+ pathLabel,
346
+ ),
199
347
  `${pathLabel}.geometricError`,
200
348
  );
201
349
 
@@ -206,7 +354,11 @@ function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
206
354
  tile.children.forEach((child, index) => {
207
355
  scaleTilesetGeometricErrors(
208
356
  child,
209
- scale,
357
+ geometricErrorScale,
358
+ geometricErrorLayerScale,
359
+ baseDir,
360
+ rootDir,
361
+ leafDistanceCache,
210
362
  `${pathLabel}.children[${index}]`,
211
363
  );
212
364
  });
@@ -234,27 +386,8 @@ function collectExternalTilesetPaths(tile, baseDir, results) {
234
386
  return;
235
387
  }
236
388
 
237
- const maybePaths = [];
238
- if (tile.content && typeof tile.content === 'object') {
239
- maybePaths.push(
240
- getLocalJsonReferencePath(baseDir, tile.content.uri || tile.content.url),
241
- );
242
- }
243
-
244
- if (Array.isArray(tile.contents)) {
245
- tile.contents.forEach((entry) => {
246
- if (entry && typeof entry === 'object') {
247
- maybePaths.push(
248
- getLocalJsonReferencePath(baseDir, entry.uri || entry.url),
249
- );
250
- }
251
- });
252
- }
253
-
254
- maybePaths.forEach((filePath) => {
255
- if (filePath) {
256
- results.add(filePath);
257
- }
389
+ getLocalExternalTilesetPaths(tile, baseDir).forEach((filePath) => {
390
+ results.add(filePath);
258
391
  });
259
392
 
260
393
  if (!Array.isArray(tile.children)) {
@@ -268,7 +401,13 @@ function collectExternalTilesetPaths(tile, baseDir, results) {
268
401
 
269
402
  function updateTilesetJsonFile(
270
403
  tilesetPath,
271
- { geometricErrorScale, rootDir, rootTransform = null },
404
+ {
405
+ geometricErrorLayerScale,
406
+ geometricErrorScale,
407
+ rootDir,
408
+ rootTransform = null,
409
+ leafDistanceCache = new Map(),
410
+ },
272
411
  visited = new Set(),
273
412
  ) {
274
413
  const resolvedPath = path.resolve(tilesetPath);
@@ -277,14 +416,7 @@ function updateTilesetJsonFile(
277
416
  }
278
417
  visited.add(resolvedPath);
279
418
 
280
- if (
281
- resolvedPath !== rootDir &&
282
- !resolvedPath.startsWith(`${rootDir}${path.sep}`)
283
- ) {
284
- throw new InspectorError(
285
- `Nested tileset path escapes the viewer root: ${resolvedPath}`,
286
- );
287
- }
419
+ assertTilesetPathInsideRoot(resolvedPath, rootDir);
288
420
 
289
421
  if (!fs.existsSync(resolvedPath)) {
290
422
  throw new InspectorError(
@@ -301,30 +433,44 @@ function updateTilesetJsonFile(
301
433
  tileset.root.transform = rootTransform.slice();
302
434
  }
303
435
 
436
+ const tilesetDir = path.dirname(resolvedPath);
304
437
  scaleGeometricErrorValue(
305
438
  tileset,
306
439
  'geometricError',
307
- geometricErrorScale,
440
+ getGeometricErrorScaleForLeafDistance(
441
+ geometricErrorScale,
442
+ geometricErrorLayerScale,
443
+ getTileLeafDistance(
444
+ tileset.root,
445
+ tilesetDir,
446
+ rootDir,
447
+ leafDistanceCache,
448
+ new Set(),
449
+ ),
450
+ resolvedPath,
451
+ ),
308
452
  `${resolvedPath}.geometricError`,
309
453
  );
310
454
  scaleTilesetGeometricErrors(
311
455
  tileset.root,
312
456
  geometricErrorScale,
457
+ geometricErrorLayerScale,
458
+ tilesetDir,
459
+ rootDir,
460
+ leafDistanceCache,
313
461
  `${resolvedPath}.root`,
314
462
  );
315
463
  writeJsonAtomic(resolvedPath, tileset);
316
464
 
317
465
  const nestedTilesets = new Set();
318
- collectExternalTilesetPaths(
319
- tileset.root,
320
- path.dirname(resolvedPath),
321
- nestedTilesets,
322
- );
466
+ collectExternalTilesetPaths(tileset.root, tilesetDir, nestedTilesets);
323
467
  nestedTilesets.forEach((childTilesetPath) => {
324
468
  updateTilesetJsonFile(
325
469
  childTilesetPath,
326
470
  {
471
+ geometricErrorLayerScale,
327
472
  geometricErrorScale,
473
+ leafDistanceCache,
328
474
  rootDir,
329
475
  },
330
476
  visited,
@@ -337,13 +483,17 @@ function updateTilesetJsonFile(
337
483
  function saveViewerTransform(
338
484
  rootTilesetPath,
339
485
  editMatrix,
340
- { geometricErrorScale = 1 } = {},
486
+ { geometricErrorLayerScale = 1, geometricErrorScale = 1 } = {},
341
487
  ) {
342
488
  const normalizedEdit = normalizeMatrix4Array(editMatrix, 'transform');
343
489
  const normalizedGeometricErrorScale = normalizePositiveFinite(
344
490
  geometricErrorScale,
345
491
  'geometricErrorScale',
346
492
  );
493
+ const normalizedGeometricErrorLayerScale = normalizePositiveFinite(
494
+ geometricErrorLayerScale,
495
+ 'geometricErrorLayerScale',
496
+ );
347
497
  const tilesetPath = path.resolve(rootTilesetPath);
348
498
  const rootDir = path.dirname(tilesetPath);
349
499
 
@@ -366,6 +516,7 @@ function saveViewerTransform(
366
516
  const nextRoot = multiplyMatrix4(normalizedEdit, currentRoot);
367
517
 
368
518
  updateTilesetJsonFile(tilesetPath, {
519
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
369
520
  geometricErrorScale: normalizedGeometricErrorScale,
370
521
  rootDir,
371
522
  rootTransform: nextRoot,
@@ -386,6 +537,15 @@ function saveViewerTransform(
386
537
  summary.root_coordinate = null;
387
538
  summary.viewer_geometric_error_scale =
388
539
  previousGeometricErrorScale * normalizedGeometricErrorScale;
540
+ const previousGeometricErrorLayerScale =
541
+ summary.viewer_geometric_error_layer_scale == null
542
+ ? 1
543
+ : normalizePositiveFinite(
544
+ summary.viewer_geometric_error_layer_scale,
545
+ 'build_summary.viewer_geometric_error_layer_scale',
546
+ );
547
+ summary.viewer_geometric_error_layer_scale =
548
+ previousGeometricErrorLayerScale * normalizedGeometricErrorLayerScale;
389
549
  writeJsonAtomic(summaryPath, summary);
390
550
  }
391
551
 
@@ -529,9 +689,28 @@ async function handleSaveTransformRequest(rootTilesetPath, req, res) {
529
689
  return;
530
690
  }
531
691
 
692
+ let normalizedGeometricErrorLayerScale;
693
+ try {
694
+ normalizedGeometricErrorLayerScale = normalizePositiveFinite(
695
+ payload.geometricErrorLayerScale == null
696
+ ? 1
697
+ : payload.geometricErrorLayerScale,
698
+ 'geometricErrorLayerScale',
699
+ );
700
+ } catch (err) {
701
+ sendJson(res, 400, {
702
+ error:
703
+ err instanceof Error && err.message
704
+ ? err.message
705
+ : 'geometricErrorLayerScale must be a finite number greater than 0.',
706
+ });
707
+ return;
708
+ }
709
+
532
710
  let nextRoot;
533
711
  try {
534
712
  nextRoot = saveViewerTransform(rootTilesetPath, normalizedEdit, {
713
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
535
714
  geometricErrorScale: normalizedGeometricErrorScale,
536
715
  });
537
716
  } catch (err) {
@@ -547,6 +726,7 @@ async function handleSaveTransformRequest(rootTilesetPath, req, res) {
547
726
  sendJson(res, 200, {
548
727
  ok: true,
549
728
  transform: nextRoot,
729
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
550
730
  geometricErrorScale: normalizedGeometricErrorScale,
551
731
  });
552
732
  }
@@ -756,7 +936,7 @@ function buildViewerHtml(viewerConfig) {
756
936
  .toolbar {
757
937
  display: grid;
758
938
  align-content: start;
759
- gap: 12px;
939
+ gap: 10px;
760
940
  padding: 14px;
761
941
  border: 1px solid rgba(22, 50, 79, 0.12);
762
942
  border-top: 0;
@@ -782,7 +962,7 @@ function buildViewerHtml(viewerConfig) {
782
962
  justify-content: center;
783
963
  width: 100%;
784
964
  min-height: 32px;
785
- padding: 10px 12px;
965
+ padding: 8px 12px;
786
966
  border: 1px solid rgba(22, 50, 79, 0.08);
787
967
  border-radius: 20px 20px 0 0;
788
968
  font: inherit;
@@ -804,7 +984,7 @@ function buildViewerHtml(viewerConfig) {
804
984
  justify-self: start;
805
985
  width: auto;
806
986
  min-height: 36px;
807
- padding: 7px 12px 8px;
987
+ padding: 6px 12px 7px;
808
988
  border-radius: 999px;
809
989
  color: #506377;
810
990
  background: rgba(255, 255, 255, 0.94);
@@ -830,7 +1010,7 @@ function buildViewerHtml(viewerConfig) {
830
1010
  .toolbar-section {
831
1011
  display: grid;
832
1012
  gap: 10px;
833
- padding: 12px;
1013
+ padding: 10px 12px;
834
1014
  border: 1px solid rgba(22, 50, 79, 0.08);
835
1015
  border-radius: 14px;
836
1016
  background:
@@ -839,7 +1019,7 @@ function buildViewerHtml(viewerConfig) {
839
1019
 
840
1020
  .toolbar-section-header {
841
1021
  display: flex;
842
- align-items: center;
1022
+ align-items: flex-start;
843
1023
  justify-content: space-between;
844
1024
  gap: 10px;
845
1025
  }
@@ -857,7 +1037,7 @@ function buildViewerHtml(viewerConfig) {
857
1037
  margin: 0;
858
1038
  font-size: 12px;
859
1039
  font-weight: 700;
860
- color: #16324f;
1040
+ color: #5d738b;
861
1041
  }
862
1042
 
863
1043
  .button-row {
@@ -886,7 +1066,7 @@ function buildViewerHtml(viewerConfig) {
886
1066
  justify-content: center;
887
1067
  border: 0;
888
1068
  border-radius: 999px;
889
- padding: 9px 14px;
1069
+ padding: 7px 14px;
890
1070
  font: inherit;
891
1071
  font-size: 14px;
892
1072
  font-weight: 600;
@@ -926,6 +1106,14 @@ function buildViewerHtml(viewerConfig) {
926
1106
  min-width: 0;
927
1107
  }
928
1108
 
1109
+ .range-field-header {
1110
+ display: flex;
1111
+ align-items: center;
1112
+ justify-content: space-between;
1113
+ gap: 8px;
1114
+ min-width: 0;
1115
+ }
1116
+
929
1117
  .range-field span {
930
1118
  font-size: 11px;
931
1119
  font-weight: 600;
@@ -1105,10 +1293,12 @@ function buildViewerHtml(viewerConfig) {
1105
1293
  <div class="toolbar-section">
1106
1294
  <div class="toolbar-section-header">
1107
1295
  <p class="toolbar-section-title">LOD</p>
1108
- <p id="geometric-error-value" class="toolbar-value">x1.00</p>
1109
1296
  </div>
1110
1297
  <label class="range-field">
1111
- <span>Geometric Error</span>
1298
+ <div class="range-field-header">
1299
+ <span>Geometric Error</span>
1300
+ <p id="geometric-error-value" class="toolbar-value">x1.00</p>
1301
+ </div>
1112
1302
  <input
1113
1303
  id="geometric-error-scale"
1114
1304
  type="range"
@@ -1118,6 +1308,20 @@ function buildViewerHtml(viewerConfig) {
1118
1308
  value="0"
1119
1309
  />
1120
1310
  </label>
1311
+ <label class="range-field">
1312
+ <div class="range-field-header">
1313
+ <span>Layer Multiplier</span>
1314
+ <p id="geometric-error-layer-value" class="toolbar-value">x1.00</p>
1315
+ </div>
1316
+ <input
1317
+ id="geometric-error-layer-scale"
1318
+ type="range"
1319
+ min="-0.5849625007211562"
1320
+ max="0.5849625007211562"
1321
+ step="any"
1322
+ value="0"
1323
+ />
1324
+ </label>
1121
1325
  </div>
1122
1326
  <div class="toolbar-section status-panel">
1123
1327
  <div class="status-actions">