3dtiles-inspector 0.1.4 → 0.1.6

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,23 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.6] - 2026-04-27
10
+
11
+ ### Changed
12
+
13
+ - Changed `Layer Multiplier` to scale each tile's geometric-error difference from its leaf baseline instead of applying a depth-based multiplier.
14
+ - Expanded the `Layer Multiplier` range to `1/8x` through `8x`.
15
+
16
+ ## [0.1.5] - 2026-04-27
17
+
18
+ ### Added
19
+
20
+ - Added an LOD `Layer Multiplier` slider to scale geometric errors progressively by distance from leaf tiles.
21
+
22
+ ### Changed
23
+
24
+ - Tightened inspector toolbar spacing and LOD slider value labels.
25
+
9
26
  ## [0.1.4] - 2026-04-25
10
27
 
11
28
  ### 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/8x` to `8x` for each tile's geometric-error difference from its leaf baseline
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,21 @@ 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_MIN_EXPONENT = -3;
65500
+ var GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT = 3;
65501
+ var GEOMETRIC_ERROR_LAYER_SCALE_STEP = 0.1;
65493
65502
  var DEFAULT_ERROR_TARGET = 16;
65494
65503
  var DEFAULT_TERRAIN_ERROR_TARGET = 16;
65495
65504
  var RUNTIME_STATS_UPDATE_INTERVAL_MS = 250;
@@ -65801,12 +65810,16 @@ var savedRootInverseMatrix = new Matrix4();
65801
65810
  var pointerCoords = new Vector2();
65802
65811
  var pickRaycaster = new Raycaster();
65803
65812
  var pickTargets = [];
65813
+ var originalTileGeometricErrors = /* @__PURE__ */ new WeakMap();
65804
65814
  var tiles = null;
65805
65815
  var toolbarVisible = true;
65806
65816
  var activeTransformMode = null;
65807
65817
  var geometricErrorScaleExponent = 0;
65808
65818
  var geometricErrorScale = 1;
65809
65819
  var lastSavedGeometricErrorScale = 1;
65820
+ var geometricErrorLayerScaleExponent = 0;
65821
+ var geometricErrorLayerScale = 1;
65822
+ var lastSavedGeometricErrorLayerScale = 1;
65810
65823
  var lastSavedMatrix = new Matrix4();
65811
65824
  var savedRootMatrix = new Matrix4();
65812
65825
  var savedRootMatrixPromise = Promise.resolve();
@@ -65831,9 +65844,77 @@ function updateGeometricErrorScaleDisplay() {
65831
65844
  geometricErrorScale
65832
65845
  )}`;
65833
65846
  }
65847
+ function updateGeometricErrorLayerScaleDisplay() {
65848
+ geometricErrorLayerValueEl.textContent = `x${formatGeometricErrorScale(
65849
+ geometricErrorLayerScale
65850
+ )}`;
65851
+ }
65834
65852
  function getEffectiveGeometricErrorScale() {
65835
65853
  return lastSavedGeometricErrorScale * geometricErrorScale;
65836
65854
  }
65855
+ function getEffectiveGeometricErrorLayerScale() {
65856
+ return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
65857
+ }
65858
+ function getOriginalTileGeometricError(tile) {
65859
+ if (!tile || typeof tile !== "object") {
65860
+ return null;
65861
+ }
65862
+ if (!originalTileGeometricErrors.has(tile)) {
65863
+ const number = Number(tile.geometricError);
65864
+ if (!Number.isFinite(number)) {
65865
+ return null;
65866
+ }
65867
+ originalTileGeometricErrors.set(tile, number);
65868
+ }
65869
+ return originalTileGeometricErrors.get(tile);
65870
+ }
65871
+ function getKnownTileLeafGeometricError(tile, visited = /* @__PURE__ */ new Set()) {
65872
+ const originalGeometricError = getOriginalTileGeometricError(tile);
65873
+ if (originalGeometricError === null || !tile || typeof tile !== "object" || visited.has(tile)) {
65874
+ return originalGeometricError;
65875
+ }
65876
+ visited.add(tile);
65877
+ let leafGeometricError = null;
65878
+ const children = Array.isArray(tile.children) ? tile.children : [];
65879
+ for (const child of children) {
65880
+ const childLeafGeometricError = getKnownTileLeafGeometricError(
65881
+ child,
65882
+ visited
65883
+ );
65884
+ if (childLeafGeometricError !== null) {
65885
+ leafGeometricError = leafGeometricError === null ? childLeafGeometricError : Math.min(leafGeometricError, childLeafGeometricError);
65886
+ }
65887
+ }
65888
+ visited.delete(tile);
65889
+ return leafGeometricError === null ? originalGeometricError : leafGeometricError;
65890
+ }
65891
+ function applyGeometricErrorLayerScaleToTile(tile) {
65892
+ const originalGeometricError = getOriginalTileGeometricError(tile);
65893
+ const leafGeometricError = getKnownTileLeafGeometricError(tile);
65894
+ if (originalGeometricError === null || leafGeometricError === null) {
65895
+ return;
65896
+ }
65897
+ tile.geometricError = leafGeometricError + (originalGeometricError - leafGeometricError) * getEffectiveGeometricErrorLayerScale();
65898
+ }
65899
+ function applyGeometricErrorLayerScaleToTileset() {
65900
+ if (!tiles) {
65901
+ return;
65902
+ }
65903
+ tiles.traverse(
65904
+ (tile) => {
65905
+ applyGeometricErrorLayerScaleToTile(tile);
65906
+ return false;
65907
+ },
65908
+ null,
65909
+ false
65910
+ );
65911
+ }
65912
+ var geometricErrorLayerScalePlugin = {
65913
+ name: "GeometricErrorLayerScalePlugin",
65914
+ preprocessNode(tile) {
65915
+ applyGeometricErrorLayerScaleToTile(tile);
65916
+ }
65917
+ };
65837
65918
  function getGaussianMeshSplatCount(mesh) {
65838
65919
  if (!mesh || typeof mesh !== "object") {
65839
65920
  return 0;
@@ -65898,6 +65979,19 @@ function setGeometricErrorScaleExponent(exponent) {
65898
65979
  updateGeometricErrorScaleDisplay();
65899
65980
  updateTilesetErrorTarget();
65900
65981
  }
65982
+ function setGeometricErrorLayerScaleExponent(exponent) {
65983
+ geometricErrorLayerScaleExponent = clamp2(
65984
+ Number(exponent),
65985
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
65986
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT
65987
+ );
65988
+ geometricErrorLayerScale = exponentToGeometricErrorScale(
65989
+ geometricErrorLayerScaleExponent
65990
+ );
65991
+ geometricErrorLayerScaleInput.value = geometricErrorLayerScaleExponent.toFixed(1);
65992
+ updateGeometricErrorLayerScaleDisplay();
65993
+ applyGeometricErrorLayerScaleToTileset();
65994
+ }
65901
65995
  function syncTerrainButton() {
65902
65996
  terrainButton.classList.toggle("active", terrainEnabled);
65903
65997
  terrainLight.visible = terrainEnabled;
@@ -65979,7 +66073,15 @@ function toggleTransformMode(mode) {
65979
66073
  geometricErrorScaleInput.min = String(GEOMETRIC_ERROR_SCALE_MIN_EXPONENT);
65980
66074
  geometricErrorScaleInput.max = String(GEOMETRIC_ERROR_SCALE_MAX_EXPONENT);
65981
66075
  geometricErrorScaleInput.step = String(GEOMETRIC_ERROR_SCALE_STEP);
66076
+ geometricErrorLayerScaleInput.min = String(
66077
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT
66078
+ );
66079
+ geometricErrorLayerScaleInput.max = String(
66080
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT
66081
+ );
66082
+ geometricErrorLayerScaleInput.step = String(GEOMETRIC_ERROR_LAYER_SCALE_STEP);
65982
66083
  setGeometricErrorScaleExponent(geometricErrorScaleExponent);
66084
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleExponent);
65983
66085
  setTerrainEnabled(terrainEnabled);
65984
66086
  setTransformMode(activeTransformMode);
65985
66087
  syncToolbarVisibility();
@@ -66473,7 +66575,9 @@ function loadTileset(url) {
66473
66575
  updateRuntimeStats(true);
66474
66576
  resetEditableGroup();
66475
66577
  lastSavedGeometricErrorScale = 1;
66578
+ lastSavedGeometricErrorLayerScale = 1;
66476
66579
  setGeometricErrorScaleExponent(0);
66580
+ setGeometricErrorLayerScaleExponent(0);
66477
66581
  savedRootMatrix.identity();
66478
66582
  savedRootMatrixLoadError = null;
66479
66583
  savedRootMatrixPromise = refreshSavedRootMatrix(url).then(
@@ -66496,7 +66600,16 @@ function loadTileset(url) {
66496
66600
  next.registerPlugin(new go());
66497
66601
  next.registerPlugin(new To());
66498
66602
  next.registerPlugin(new w2());
66499
- next.registerPlugin(new GaussianSplatPlugin({ renderer, scene }));
66603
+ next.registerPlugin(geometricErrorLayerScalePlugin);
66604
+ next.registerPlugin(
66605
+ new GaussianSplatPlugin({
66606
+ renderer,
66607
+ scene,
66608
+ sparkRendererOptions: {
66609
+ accumExtSplats: true
66610
+ }
66611
+ })
66612
+ );
66500
66613
  debugTilesPlugin = new Oo({
66501
66614
  displayBoxBounds: showBoundingVolume,
66502
66615
  displaySphereBounds: showBoundingVolume,
@@ -66543,6 +66656,7 @@ function loadTileset(url) {
66543
66656
  setStatus("Tileset ready.");
66544
66657
  }
66545
66658
  };
66659
+ next.addEventListener("load-tileset", applyGeometricErrorLayerScaleToTileset);
66546
66660
  next.addEventListener("load-tile-set", tryFrame);
66547
66661
  next.addEventListener("load-tileset", tryFrame);
66548
66662
  }
@@ -66554,6 +66668,8 @@ async function saveTransform() {
66554
66668
  const incrementalMatrix = currentMatrix.clone().multiply(lastSavedMatrix.clone().invert());
66555
66669
  const incrementalGeometricErrorScale = geometricErrorScale;
66556
66670
  const savedGeometricErrorScale = getEffectiveGeometricErrorScale();
66671
+ const incrementalGeometricErrorLayerScale = geometricErrorLayerScale;
66672
+ const savedGeometricErrorLayerScale = getEffectiveGeometricErrorLayerScale();
66557
66673
  try {
66558
66674
  const response = await fetch(SAVE_URL, {
66559
66675
  method: "POST",
@@ -66561,6 +66677,7 @@ async function saveTransform() {
66561
66677
  "Content-Type": "application/json"
66562
66678
  },
66563
66679
  body: JSON.stringify({
66680
+ geometricErrorLayerScale: incrementalGeometricErrorLayerScale,
66564
66681
  geometricErrorScale: incrementalGeometricErrorScale,
66565
66682
  transform: incrementalMatrix.toArray()
66566
66683
  })
@@ -66591,13 +66708,17 @@ async function saveTransform() {
66591
66708
  }
66592
66709
  }
66593
66710
  lastSavedGeometricErrorScale = savedGeometricErrorScale;
66711
+ lastSavedGeometricErrorLayerScale = savedGeometricErrorLayerScale;
66594
66712
  lastSavedMatrix.copy(currentMatrix);
66595
66713
  setGeometricErrorScaleExponent(0);
66714
+ setGeometricErrorLayerScaleExponent(0);
66596
66715
  syncTransformHandleFromTilesTransform();
66597
66716
  syncCoordinateInputsFromTilesTransform();
66598
66717
  setStatus(
66599
- `Saved transform and geometric-error scale x${formatGeometricErrorScale(
66718
+ `Saved transform, geometric-error scale x${formatGeometricErrorScale(
66600
66719
  savedGeometricErrorScale
66720
+ )}, and layer multiplier x${formatGeometricErrorScale(
66721
+ savedGeometricErrorLayerScale
66601
66722
  )} to ${ROOT_TILESET_LABEL} and build_summary.json.`
66602
66723
  );
66603
66724
  } catch (err2) {
@@ -66638,6 +66759,16 @@ geometricErrorScaleInput.addEventListener("change", () => {
66638
66759
  )}.`
66639
66760
  );
66640
66761
  });
66762
+ geometricErrorLayerScaleInput.addEventListener("input", () => {
66763
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleInput.value);
66764
+ });
66765
+ geometricErrorLayerScaleInput.addEventListener("change", () => {
66766
+ setStatus(
66767
+ `Geometric-error layer multiplier set to x${formatGeometricErrorScale(
66768
+ geometricErrorLayerScale
66769
+ )}.`
66770
+ );
66771
+ });
66641
66772
  moveToTilesButton.addEventListener("click", moveCameraToTiles);
66642
66773
  moveCameraToCoordinateButton.addEventListener("click", moveCameraToCoordinate);
66643
66774
  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.6",
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,21 @@ 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_MIN_EXPONENT = -3;
116
+ const GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT = 3;
117
+ const GEOMETRIC_ERROR_LAYER_SCALE_STEP = 0.1;
109
118
  const DEFAULT_ERROR_TARGET = 16;
110
119
  const DEFAULT_TERRAIN_ERROR_TARGET = 16;
111
120
  const RUNTIME_STATS_UPDATE_INTERVAL_MS = 250;
@@ -470,12 +479,16 @@ const savedRootInverseMatrix = new Matrix4();
470
479
  const pointerCoords = new Vector2();
471
480
  const pickRaycaster = new Raycaster();
472
481
  const pickTargets = [];
482
+ const originalTileGeometricErrors = new WeakMap();
473
483
  let tiles = null;
474
484
  let toolbarVisible = true;
475
485
  let activeTransformMode = null;
476
486
  let geometricErrorScaleExponent = 0;
477
487
  let geometricErrorScale = 1;
478
488
  let lastSavedGeometricErrorScale = 1;
489
+ let geometricErrorLayerScaleExponent = 0;
490
+ let geometricErrorLayerScale = 1;
491
+ let lastSavedGeometricErrorLayerScale = 1;
479
492
  let lastSavedMatrix = new Matrix4();
480
493
  const savedRootMatrix = new Matrix4();
481
494
  let savedRootMatrixPromise = Promise.resolve();
@@ -505,10 +518,103 @@ function updateGeometricErrorScaleDisplay() {
505
518
  )}`;
506
519
  }
507
520
 
521
+ function updateGeometricErrorLayerScaleDisplay() {
522
+ geometricErrorLayerValueEl.textContent = `x${formatGeometricErrorScale(
523
+ geometricErrorLayerScale,
524
+ )}`;
525
+ }
526
+
508
527
  function getEffectiveGeometricErrorScale() {
509
528
  return lastSavedGeometricErrorScale * geometricErrorScale;
510
529
  }
511
530
 
531
+ function getEffectiveGeometricErrorLayerScale() {
532
+ return lastSavedGeometricErrorLayerScale * geometricErrorLayerScale;
533
+ }
534
+
535
+ function getOriginalTileGeometricError(tile) {
536
+ if (!tile || typeof tile !== 'object') {
537
+ return null;
538
+ }
539
+
540
+ if (!originalTileGeometricErrors.has(tile)) {
541
+ const number = Number(tile.geometricError);
542
+ if (!Number.isFinite(number)) {
543
+ return null;
544
+ }
545
+ originalTileGeometricErrors.set(tile, number);
546
+ }
547
+
548
+ return originalTileGeometricErrors.get(tile);
549
+ }
550
+
551
+ function getKnownTileLeafGeometricError(tile, visited = new Set()) {
552
+ const originalGeometricError = getOriginalTileGeometricError(tile);
553
+ if (
554
+ originalGeometricError === null ||
555
+ !tile ||
556
+ typeof tile !== 'object' ||
557
+ visited.has(tile)
558
+ ) {
559
+ return originalGeometricError;
560
+ }
561
+
562
+ visited.add(tile);
563
+ let leafGeometricError = null;
564
+ const children = Array.isArray(tile.children) ? tile.children : [];
565
+ for (const child of children) {
566
+ const childLeafGeometricError = getKnownTileLeafGeometricError(
567
+ child,
568
+ visited,
569
+ );
570
+ if (childLeafGeometricError !== null) {
571
+ leafGeometricError =
572
+ leafGeometricError === null
573
+ ? childLeafGeometricError
574
+ : Math.min(leafGeometricError, childLeafGeometricError);
575
+ }
576
+ }
577
+ visited.delete(tile);
578
+ return leafGeometricError === null
579
+ ? originalGeometricError
580
+ : leafGeometricError;
581
+ }
582
+
583
+ function applyGeometricErrorLayerScaleToTile(tile) {
584
+ const originalGeometricError = getOriginalTileGeometricError(tile);
585
+ const leafGeometricError = getKnownTileLeafGeometricError(tile);
586
+ if (originalGeometricError === null || leafGeometricError === null) {
587
+ return;
588
+ }
589
+
590
+ tile.geometricError =
591
+ leafGeometricError +
592
+ (originalGeometricError - leafGeometricError) *
593
+ getEffectiveGeometricErrorLayerScale();
594
+ }
595
+
596
+ function applyGeometricErrorLayerScaleToTileset() {
597
+ if (!tiles) {
598
+ return;
599
+ }
600
+
601
+ tiles.traverse(
602
+ (tile) => {
603
+ applyGeometricErrorLayerScaleToTile(tile);
604
+ return false;
605
+ },
606
+ null,
607
+ false,
608
+ );
609
+ }
610
+
611
+ const geometricErrorLayerScalePlugin = {
612
+ name: 'GeometricErrorLayerScalePlugin',
613
+ preprocessNode(tile) {
614
+ applyGeometricErrorLayerScaleToTile(tile);
615
+ },
616
+ };
617
+
512
618
  function getGaussianMeshSplatCount(mesh) {
513
619
  if (!mesh || typeof mesh !== 'object') {
514
620
  return 0;
@@ -605,6 +711,21 @@ function setGeometricErrorScaleExponent(exponent) {
605
711
  updateTilesetErrorTarget();
606
712
  }
607
713
 
714
+ function setGeometricErrorLayerScaleExponent(exponent) {
715
+ geometricErrorLayerScaleExponent = clamp(
716
+ Number(exponent),
717
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
718
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT,
719
+ );
720
+ geometricErrorLayerScale = exponentToGeometricErrorScale(
721
+ geometricErrorLayerScaleExponent,
722
+ );
723
+ geometricErrorLayerScaleInput.value =
724
+ geometricErrorLayerScaleExponent.toFixed(1);
725
+ updateGeometricErrorLayerScaleDisplay();
726
+ applyGeometricErrorLayerScaleToTileset();
727
+ }
728
+
608
729
  function syncTerrainButton() {
609
730
  terrainButton.classList.toggle('active', terrainEnabled);
610
731
  terrainLight.visible = terrainEnabled;
@@ -702,7 +823,15 @@ function toggleTransformMode(mode) {
702
823
  geometricErrorScaleInput.min = String(GEOMETRIC_ERROR_SCALE_MIN_EXPONENT);
703
824
  geometricErrorScaleInput.max = String(GEOMETRIC_ERROR_SCALE_MAX_EXPONENT);
704
825
  geometricErrorScaleInput.step = String(GEOMETRIC_ERROR_SCALE_STEP);
826
+ geometricErrorLayerScaleInput.min = String(
827
+ GEOMETRIC_ERROR_LAYER_SCALE_MIN_EXPONENT,
828
+ );
829
+ geometricErrorLayerScaleInput.max = String(
830
+ GEOMETRIC_ERROR_LAYER_SCALE_MAX_EXPONENT,
831
+ );
832
+ geometricErrorLayerScaleInput.step = String(GEOMETRIC_ERROR_LAYER_SCALE_STEP);
705
833
  setGeometricErrorScaleExponent(geometricErrorScaleExponent);
834
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleExponent);
706
835
  setTerrainEnabled(terrainEnabled);
707
836
  setTransformMode(activeTransformMode);
708
837
  syncToolbarVisibility();
@@ -1324,7 +1453,9 @@ function loadTileset(url) {
1324
1453
 
1325
1454
  resetEditableGroup();
1326
1455
  lastSavedGeometricErrorScale = 1;
1456
+ lastSavedGeometricErrorLayerScale = 1;
1327
1457
  setGeometricErrorScaleExponent(0);
1458
+ setGeometricErrorLayerScaleExponent(0);
1328
1459
  savedRootMatrix.identity();
1329
1460
  savedRootMatrixLoadError = null;
1330
1461
  savedRootMatrixPromise = refreshSavedRootMatrix(url).then(
@@ -1348,7 +1479,16 @@ function loadTileset(url) {
1348
1479
  next.registerPlugin(new TileCompressionPlugin());
1349
1480
  next.registerPlugin(new UnloadTilesPlugin());
1350
1481
  next.registerPlugin(new ImplicitTilingPlugin());
1351
- next.registerPlugin(new GaussianSplatPlugin({ renderer, scene }));
1482
+ next.registerPlugin(geometricErrorLayerScalePlugin);
1483
+ next.registerPlugin(
1484
+ new GaussianSplatPlugin({
1485
+ renderer,
1486
+ scene,
1487
+ sparkRendererOptions: {
1488
+ accumExtSplats: true,
1489
+ },
1490
+ }),
1491
+ );
1352
1492
  debugTilesPlugin = new DebugTilesPlugin({
1353
1493
  displayBoxBounds: showBoundingVolume,
1354
1494
  displaySphereBounds: showBoundingVolume,
@@ -1399,6 +1539,7 @@ function loadTileset(url) {
1399
1539
  }
1400
1540
  };
1401
1541
 
1542
+ next.addEventListener('load-tileset', applyGeometricErrorLayerScaleToTileset);
1402
1543
  next.addEventListener('load-tile-set', tryFrame);
1403
1544
  next.addEventListener('load-tileset', tryFrame);
1404
1545
  }
@@ -1414,6 +1555,8 @@ async function saveTransform() {
1414
1555
  .multiply(lastSavedMatrix.clone().invert());
1415
1556
  const incrementalGeometricErrorScale = geometricErrorScale;
1416
1557
  const savedGeometricErrorScale = getEffectiveGeometricErrorScale();
1558
+ const incrementalGeometricErrorLayerScale = geometricErrorLayerScale;
1559
+ const savedGeometricErrorLayerScale = getEffectiveGeometricErrorLayerScale();
1417
1560
 
1418
1561
  try {
1419
1562
  const response = await fetch(SAVE_URL, {
@@ -1422,6 +1565,7 @@ async function saveTransform() {
1422
1565
  'Content-Type': 'application/json',
1423
1566
  },
1424
1567
  body: JSON.stringify({
1568
+ geometricErrorLayerScale: incrementalGeometricErrorLayerScale,
1425
1569
  geometricErrorScale: incrementalGeometricErrorScale,
1426
1570
  transform: incrementalMatrix.toArray(),
1427
1571
  }),
@@ -1452,13 +1596,17 @@ async function saveTransform() {
1452
1596
  }
1453
1597
  }
1454
1598
  lastSavedGeometricErrorScale = savedGeometricErrorScale;
1599
+ lastSavedGeometricErrorLayerScale = savedGeometricErrorLayerScale;
1455
1600
  lastSavedMatrix.copy(currentMatrix);
1456
1601
  setGeometricErrorScaleExponent(0);
1602
+ setGeometricErrorLayerScaleExponent(0);
1457
1603
  syncTransformHandleFromTilesTransform();
1458
1604
  syncCoordinateInputsFromTilesTransform();
1459
1605
  setStatus(
1460
- `Saved transform and geometric-error scale x${formatGeometricErrorScale(
1606
+ `Saved transform, geometric-error scale x${formatGeometricErrorScale(
1461
1607
  savedGeometricErrorScale,
1608
+ )}, and layer multiplier x${formatGeometricErrorScale(
1609
+ savedGeometricErrorLayerScale,
1462
1610
  )} to ${ROOT_TILESET_LABEL} and build_summary.json.`,
1463
1611
  );
1464
1612
  } catch (err) {
@@ -1506,6 +1654,16 @@ geometricErrorScaleInput.addEventListener('change', () => {
1506
1654
  )}.`,
1507
1655
  );
1508
1656
  });
1657
+ geometricErrorLayerScaleInput.addEventListener('input', () => {
1658
+ setGeometricErrorLayerScaleExponent(geometricErrorLayerScaleInput.value);
1659
+ });
1660
+ geometricErrorLayerScaleInput.addEventListener('change', () => {
1661
+ setStatus(
1662
+ `Geometric-error layer multiplier set to x${formatGeometricErrorScale(
1663
+ geometricErrorLayerScale,
1664
+ )}.`,
1665
+ );
1666
+ });
1509
1667
  moveToTilesButton.addEventListener('click', moveCameraToTiles);
1510
1668
  moveCameraToCoordinateButton.addEventListener('click', moveCameraToCoordinate);
1511
1669
  moveTilesToCoordinateButton.addEventListener('click', moveTilesToCoordinate);
@@ -174,7 +174,14 @@ function normalizePositiveFinite(value, name) {
174
174
  return number;
175
175
  }
176
176
 
177
- function scaleGeometricErrorValue(target, key, scale, label) {
177
+ function scaleGeometricErrorValue(
178
+ target,
179
+ key,
180
+ geometricErrorScale,
181
+ geometricErrorLayerScale,
182
+ leafGeometricError,
183
+ label,
184
+ ) {
178
185
  if (target[key] == null) {
179
186
  return;
180
187
  }
@@ -184,10 +191,164 @@ function scaleGeometricErrorValue(target, key, scale, label) {
184
191
  throw new InspectorError(`${label} must be a finite number.`);
185
192
  }
186
193
 
187
- target[key] = number * scale;
194
+ if (!Number.isFinite(leafGeometricError)) {
195
+ throw new InspectorError(`${label} leaf geometricError must be finite.`);
196
+ }
197
+
198
+ const adjusted =
199
+ leafGeometricError +
200
+ (number - leafGeometricError) * geometricErrorLayerScale;
201
+ const next = adjusted * geometricErrorScale;
202
+ if (!Number.isFinite(next)) {
203
+ throw new InspectorError(`${label} scaled value must be finite.`);
204
+ }
205
+
206
+ target[key] = next;
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 getTilesetRootLeafGeometricError(
254
+ tilesetPath,
255
+ rootDir,
256
+ leafGeometricErrorCache,
257
+ stack,
258
+ ) {
259
+ const resolvedPath = path.resolve(tilesetPath);
260
+ if (leafGeometricErrorCache.has(resolvedPath)) {
261
+ return leafGeometricErrorCache.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 leafGeometricError = getTileLeafGeometricError(
283
+ tileset.root,
284
+ path.dirname(resolvedPath),
285
+ rootDir,
286
+ leafGeometricErrorCache,
287
+ stack,
288
+ );
289
+ stack.delete(resolvedPath);
290
+ leafGeometricErrorCache.set(resolvedPath, leafGeometricError);
291
+ return leafGeometricError;
292
+ }
293
+
294
+ function getTileLeafGeometricError(
295
+ tile,
296
+ baseDir,
297
+ rootDir,
298
+ leafGeometricErrorCache,
299
+ stack,
300
+ ) {
301
+ if (!tile || typeof tile !== 'object') {
302
+ return 0;
303
+ }
304
+
305
+ const ownGeometricError = Number(tile.geometricError);
306
+ if (!Number.isFinite(ownGeometricError)) {
307
+ return 0;
308
+ }
309
+
310
+ let leafGeometricError = null;
311
+ if (Array.isArray(tile.children)) {
312
+ tile.children.forEach((child) => {
313
+ const childLeafGeometricError = getTileLeafGeometricError(
314
+ child,
315
+ baseDir,
316
+ rootDir,
317
+ leafGeometricErrorCache,
318
+ stack,
319
+ );
320
+ leafGeometricError =
321
+ leafGeometricError === null
322
+ ? childLeafGeometricError
323
+ : Math.min(leafGeometricError, childLeafGeometricError);
324
+ });
325
+ }
326
+
327
+ getLocalExternalTilesetPaths(tile, baseDir).forEach((childTilesetPath) => {
328
+ const childLeafGeometricError = getTilesetRootLeafGeometricError(
329
+ childTilesetPath,
330
+ rootDir,
331
+ leafGeometricErrorCache,
332
+ stack,
333
+ );
334
+ leafGeometricError =
335
+ leafGeometricError === null
336
+ ? childLeafGeometricError
337
+ : Math.min(leafGeometricError, childLeafGeometricError);
338
+ });
339
+
340
+ return leafGeometricError === null ? ownGeometricError : leafGeometricError;
188
341
  }
189
342
 
190
- function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
343
+ function scaleTilesetGeometricErrors(
344
+ tile,
345
+ geometricErrorScale,
346
+ geometricErrorLayerScale,
347
+ baseDir,
348
+ rootDir,
349
+ leafGeometricErrorCache,
350
+ pathLabel = 'tileset.root',
351
+ ) {
191
352
  if (!tile || typeof tile !== 'object') {
192
353
  return;
193
354
  }
@@ -195,7 +356,15 @@ function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
195
356
  scaleGeometricErrorValue(
196
357
  tile,
197
358
  'geometricError',
198
- scale,
359
+ geometricErrorScale,
360
+ geometricErrorLayerScale,
361
+ getTileLeafGeometricError(
362
+ tile,
363
+ baseDir,
364
+ rootDir,
365
+ leafGeometricErrorCache,
366
+ new Set(),
367
+ ),
199
368
  `${pathLabel}.geometricError`,
200
369
  );
201
370
 
@@ -206,7 +375,11 @@ function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
206
375
  tile.children.forEach((child, index) => {
207
376
  scaleTilesetGeometricErrors(
208
377
  child,
209
- scale,
378
+ geometricErrorScale,
379
+ geometricErrorLayerScale,
380
+ baseDir,
381
+ rootDir,
382
+ leafGeometricErrorCache,
210
383
  `${pathLabel}.children[${index}]`,
211
384
  );
212
385
  });
@@ -234,27 +407,8 @@ function collectExternalTilesetPaths(tile, baseDir, results) {
234
407
  return;
235
408
  }
236
409
 
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
- }
410
+ getLocalExternalTilesetPaths(tile, baseDir).forEach((filePath) => {
411
+ results.add(filePath);
258
412
  });
259
413
 
260
414
  if (!Array.isArray(tile.children)) {
@@ -268,7 +422,13 @@ function collectExternalTilesetPaths(tile, baseDir, results) {
268
422
 
269
423
  function updateTilesetJsonFile(
270
424
  tilesetPath,
271
- { geometricErrorScale, rootDir, rootTransform = null },
425
+ {
426
+ geometricErrorLayerScale,
427
+ geometricErrorScale,
428
+ rootDir,
429
+ rootTransform = null,
430
+ leafGeometricErrorCache = new Map(),
431
+ },
272
432
  visited = new Set(),
273
433
  ) {
274
434
  const resolvedPath = path.resolve(tilesetPath);
@@ -277,14 +437,7 @@ function updateTilesetJsonFile(
277
437
  }
278
438
  visited.add(resolvedPath);
279
439
 
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
- }
440
+ assertTilesetPathInsideRoot(resolvedPath, rootDir);
288
441
 
289
442
  if (!fs.existsSync(resolvedPath)) {
290
443
  throw new InspectorError(
@@ -301,30 +454,41 @@ function updateTilesetJsonFile(
301
454
  tileset.root.transform = rootTransform.slice();
302
455
  }
303
456
 
457
+ const tilesetDir = path.dirname(resolvedPath);
304
458
  scaleGeometricErrorValue(
305
459
  tileset,
306
460
  'geometricError',
307
461
  geometricErrorScale,
462
+ geometricErrorLayerScale,
463
+ getTileLeafGeometricError(
464
+ tileset.root,
465
+ tilesetDir,
466
+ rootDir,
467
+ leafGeometricErrorCache,
468
+ new Set(),
469
+ ),
308
470
  `${resolvedPath}.geometricError`,
309
471
  );
310
472
  scaleTilesetGeometricErrors(
311
473
  tileset.root,
312
474
  geometricErrorScale,
475
+ geometricErrorLayerScale,
476
+ tilesetDir,
477
+ rootDir,
478
+ leafGeometricErrorCache,
313
479
  `${resolvedPath}.root`,
314
480
  );
315
481
  writeJsonAtomic(resolvedPath, tileset);
316
482
 
317
483
  const nestedTilesets = new Set();
318
- collectExternalTilesetPaths(
319
- tileset.root,
320
- path.dirname(resolvedPath),
321
- nestedTilesets,
322
- );
484
+ collectExternalTilesetPaths(tileset.root, tilesetDir, nestedTilesets);
323
485
  nestedTilesets.forEach((childTilesetPath) => {
324
486
  updateTilesetJsonFile(
325
487
  childTilesetPath,
326
488
  {
489
+ geometricErrorLayerScale,
327
490
  geometricErrorScale,
491
+ leafGeometricErrorCache,
328
492
  rootDir,
329
493
  },
330
494
  visited,
@@ -337,13 +501,17 @@ function updateTilesetJsonFile(
337
501
  function saveViewerTransform(
338
502
  rootTilesetPath,
339
503
  editMatrix,
340
- { geometricErrorScale = 1 } = {},
504
+ { geometricErrorLayerScale = 1, geometricErrorScale = 1 } = {},
341
505
  ) {
342
506
  const normalizedEdit = normalizeMatrix4Array(editMatrix, 'transform');
343
507
  const normalizedGeometricErrorScale = normalizePositiveFinite(
344
508
  geometricErrorScale,
345
509
  'geometricErrorScale',
346
510
  );
511
+ const normalizedGeometricErrorLayerScale = normalizePositiveFinite(
512
+ geometricErrorLayerScale,
513
+ 'geometricErrorLayerScale',
514
+ );
347
515
  const tilesetPath = path.resolve(rootTilesetPath);
348
516
  const rootDir = path.dirname(tilesetPath);
349
517
 
@@ -366,6 +534,7 @@ function saveViewerTransform(
366
534
  const nextRoot = multiplyMatrix4(normalizedEdit, currentRoot);
367
535
 
368
536
  updateTilesetJsonFile(tilesetPath, {
537
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
369
538
  geometricErrorScale: normalizedGeometricErrorScale,
370
539
  rootDir,
371
540
  rootTransform: nextRoot,
@@ -386,6 +555,15 @@ function saveViewerTransform(
386
555
  summary.root_coordinate = null;
387
556
  summary.viewer_geometric_error_scale =
388
557
  previousGeometricErrorScale * normalizedGeometricErrorScale;
558
+ const previousGeometricErrorLayerScale =
559
+ summary.viewer_geometric_error_layer_scale == null
560
+ ? 1
561
+ : normalizePositiveFinite(
562
+ summary.viewer_geometric_error_layer_scale,
563
+ 'build_summary.viewer_geometric_error_layer_scale',
564
+ );
565
+ summary.viewer_geometric_error_layer_scale =
566
+ previousGeometricErrorLayerScale * normalizedGeometricErrorLayerScale;
389
567
  writeJsonAtomic(summaryPath, summary);
390
568
  }
391
569
 
@@ -529,9 +707,28 @@ async function handleSaveTransformRequest(rootTilesetPath, req, res) {
529
707
  return;
530
708
  }
531
709
 
710
+ let normalizedGeometricErrorLayerScale;
711
+ try {
712
+ normalizedGeometricErrorLayerScale = normalizePositiveFinite(
713
+ payload.geometricErrorLayerScale == null
714
+ ? 1
715
+ : payload.geometricErrorLayerScale,
716
+ 'geometricErrorLayerScale',
717
+ );
718
+ } catch (err) {
719
+ sendJson(res, 400, {
720
+ error:
721
+ err instanceof Error && err.message
722
+ ? err.message
723
+ : 'geometricErrorLayerScale must be a finite number greater than 0.',
724
+ });
725
+ return;
726
+ }
727
+
532
728
  let nextRoot;
533
729
  try {
534
730
  nextRoot = saveViewerTransform(rootTilesetPath, normalizedEdit, {
731
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
535
732
  geometricErrorScale: normalizedGeometricErrorScale,
536
733
  });
537
734
  } catch (err) {
@@ -547,6 +744,7 @@ async function handleSaveTransformRequest(rootTilesetPath, req, res) {
547
744
  sendJson(res, 200, {
548
745
  ok: true,
549
746
  transform: nextRoot,
747
+ geometricErrorLayerScale: normalizedGeometricErrorLayerScale,
550
748
  geometricErrorScale: normalizedGeometricErrorScale,
551
749
  });
552
750
  }
@@ -756,8 +954,8 @@ function buildViewerHtml(viewerConfig) {
756
954
  .toolbar {
757
955
  display: grid;
758
956
  align-content: start;
759
- gap: 12px;
760
- padding: 14px;
957
+ gap: 8px;
958
+ padding: 10px 14px;
761
959
  border: 1px solid rgba(22, 50, 79, 0.12);
762
960
  border-top: 0;
763
961
  border-radius: 0 0 20px 20px;
@@ -782,7 +980,7 @@ function buildViewerHtml(viewerConfig) {
782
980
  justify-content: center;
783
981
  width: 100%;
784
982
  min-height: 32px;
785
- padding: 10px 12px;
983
+ padding: 8px 12px;
786
984
  border: 1px solid rgba(22, 50, 79, 0.08);
787
985
  border-radius: 20px 20px 0 0;
788
986
  font: inherit;
@@ -804,7 +1002,7 @@ function buildViewerHtml(viewerConfig) {
804
1002
  justify-self: start;
805
1003
  width: auto;
806
1004
  min-height: 36px;
807
- padding: 7px 12px 8px;
1005
+ padding: 6px 12px 7px;
808
1006
  border-radius: 999px;
809
1007
  color: #506377;
810
1008
  background: rgba(255, 255, 255, 0.94);
@@ -830,7 +1028,7 @@ function buildViewerHtml(viewerConfig) {
830
1028
  .toolbar-section {
831
1029
  display: grid;
832
1030
  gap: 10px;
833
- padding: 12px;
1031
+ padding: 10px 12px;
834
1032
  border: 1px solid rgba(22, 50, 79, 0.08);
835
1033
  border-radius: 14px;
836
1034
  background:
@@ -839,7 +1037,7 @@ function buildViewerHtml(viewerConfig) {
839
1037
 
840
1038
  .toolbar-section-header {
841
1039
  display: flex;
842
- align-items: center;
1040
+ align-items: flex-start;
843
1041
  justify-content: space-between;
844
1042
  gap: 10px;
845
1043
  }
@@ -857,7 +1055,7 @@ function buildViewerHtml(viewerConfig) {
857
1055
  margin: 0;
858
1056
  font-size: 12px;
859
1057
  font-weight: 700;
860
- color: #16324f;
1058
+ color: #5d738b;
861
1059
  }
862
1060
 
863
1061
  .button-row {
@@ -886,7 +1084,7 @@ function buildViewerHtml(viewerConfig) {
886
1084
  justify-content: center;
887
1085
  border: 0;
888
1086
  border-radius: 999px;
889
- padding: 9px 14px;
1087
+ padding: 7px 14px;
890
1088
  font: inherit;
891
1089
  font-size: 14px;
892
1090
  font-weight: 600;
@@ -922,6 +1120,14 @@ function buildViewerHtml(viewerConfig) {
922
1120
 
923
1121
  .range-field {
924
1122
  display: grid;
1123
+ gap: 4px;
1124
+ min-width: 0;
1125
+ }
1126
+
1127
+ .range-field-header {
1128
+ display: flex;
1129
+ align-items: center;
1130
+ justify-content: space-between;
925
1131
  gap: 8px;
926
1132
  min-width: 0;
927
1133
  }
@@ -1105,10 +1311,12 @@ function buildViewerHtml(viewerConfig) {
1105
1311
  <div class="toolbar-section">
1106
1312
  <div class="toolbar-section-header">
1107
1313
  <p class="toolbar-section-title">LOD</p>
1108
- <p id="geometric-error-value" class="toolbar-value">x1.00</p>
1109
1314
  </div>
1110
1315
  <label class="range-field">
1111
- <span>Geometric Error</span>
1316
+ <div class="range-field-header">
1317
+ <span>Geometric Error</span>
1318
+ <p id="geometric-error-value" class="toolbar-value">x1.00</p>
1319
+ </div>
1112
1320
  <input
1113
1321
  id="geometric-error-scale"
1114
1322
  type="range"
@@ -1118,6 +1326,20 @@ function buildViewerHtml(viewerConfig) {
1118
1326
  value="0"
1119
1327
  />
1120
1328
  </label>
1329
+ <label class="range-field">
1330
+ <div class="range-field-header">
1331
+ <span>Layer Multiplier</span>
1332
+ <p id="geometric-error-layer-value" class="toolbar-value">x1.00</p>
1333
+ </div>
1334
+ <input
1335
+ id="geometric-error-layer-scale"
1336
+ type="range"
1337
+ min="-3"
1338
+ max="3"
1339
+ step="0.1"
1340
+ value="0"
1341
+ />
1342
+ </label>
1121
1343
  </div>
1122
1344
  <div class="toolbar-section status-panel">
1123
1345
  <div class="status-actions">