3dtiles-inspector 0.1.6 → 0.1.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,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.8] - 2026-05-03
10
+
11
+ ### Added
12
+
13
+ - Added a camera pivot indicator for rotate, pan, and zoom interactions.
14
+ - Added bottom-right tile runtime stats for downloading, parsing, loaded, and visible tile counts.
15
+
16
+ ### Changed
17
+
18
+ - Moved the `Canvas` toolbar section above `Transform`.
19
+
20
+ ## [0.1.7] - 2026-04-27
21
+
22
+ ### Changed
23
+
24
+ - Changed `Layer Multiplier` to use the tileset-wide minimum leaf geometric error as the baseline, so non-minimum leaf tiles are scaled too.
25
+
9
26
  ## [0.1.6] - 2026-04-27
10
27
 
11
28
  ### Changed
package/README.md CHANGED
@@ -14,6 +14,13 @@
14
14
 
15
15
  Requires Node.js 18 or newer.
16
16
 
17
+ ## Built On
18
+
19
+ This project is based on and integrates work from:
20
+
21
+ - [WilliamLiu-1997/3D-Tiles-RendererJS-3DGS-Plugin](https://github.com/WilliamLiu-1997/3D-Tiles-RendererJS-3DGS-Plugin)
22
+ - [NASA-AMMOS/3DTilesRendererJS](https://github.com/NASA-AMMOS/3DTilesRendererJS)
23
+
17
24
  ## Install
18
25
 
19
26
  ```bash
@@ -76,13 +83,15 @@ const {
76
83
 
77
84
  ## Inspector Features
78
85
 
86
+ <img src="https://raw.githubusercontent.com/WilliamLiu-1997/3DTiles-Inspector/main/screenshot.png" alt="screenshot" width="960" />
87
+
79
88
  - `Translate`, `Rotate`, and `Reset` for root transform edits
80
89
  - `Move Camera` to a WGS84 latitude / longitude / height
81
90
  - `Move Tiles` to relocate the tileset root with an ENU-aligned transform
82
91
  - `Set Position` to click the globe, terrain, or loaded tiles and place the tileset there
83
92
  - `Terrain` to toggle Cesium World Terrain while keeping satellite imagery
84
93
  - `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
94
+ - `Layer Multiplier` scaling from `1/8x` to `8x` for each tile's geometric-error difference from the tileset's global leaf baseline
86
95
  - `Save` to persist the updated root transform and geometric-error scale back to disk
87
96
 
88
97
  If `build_summary.json` exists next to the root tileset, `Save` also updates:
@@ -64485,6 +64485,88 @@ var PointerTracker = class {
64485
64485
  return Boolean(this.buttons & 4);
64486
64486
  }
64487
64487
  };
64488
+ var PivotPointMesh = class extends Mesh {
64489
+ constructor(size = 15, thickness = 3) {
64490
+ super(new PlaneGeometry(0, 0), new PivotMaterial(size, thickness));
64491
+ this.renderOrder = Infinity;
64492
+ }
64493
+ set focus(value) {
64494
+ this.material.uniforms.opacity.value = value ? 1 : 0.5;
64495
+ }
64496
+ onBeforeRender(renderer2) {
64497
+ renderer2.getSize(this.material.uniforms.resolution.value);
64498
+ }
64499
+ updateMatrixWorld() {
64500
+ this.matrixWorld.makeTranslation(this.position);
64501
+ }
64502
+ dispose() {
64503
+ this.geometry.dispose();
64504
+ this.material.dispose();
64505
+ }
64506
+ };
64507
+ var PivotMaterial = class extends ShaderMaterial {
64508
+ constructor(size, thickness) {
64509
+ const coreD = size + thickness;
64510
+ const planeD = coreD + 3 * thickness;
64511
+ const normThk = thickness / coreD;
64512
+ const ringR = (coreD - 0.4 * thickness - 4) / coreD;
64513
+ const hw = 0.4 * normThk;
64514
+ super({
64515
+ depthWrite: false,
64516
+ depthTest: false,
64517
+ transparent: true,
64518
+ uniforms: {
64519
+ resolution: { value: new Vector2() },
64520
+ opacity: { value: 1 },
64521
+ planeD: { value: planeD },
64522
+ hw: { value: hw },
64523
+ ringR: { value: ringR },
64524
+ shadowW: { value: hw * 5 },
64525
+ uvScale: { value: planeD / coreD }
64526
+ },
64527
+ vertexShader: `
64528
+ uniform float planeD;
64529
+ uniform vec2 resolution;
64530
+ varying vec2 vUv;
64531
+
64532
+ void main() {
64533
+ vUv = uv;
64534
+ float aspect = resolution.x / resolution.y;
64535
+ vec2 offset = uv * 2.0 - vec2(1.0);
64536
+ offset.y *= aspect;
64537
+ vec4 screenPoint = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
64538
+ screenPoint.xy += offset * planeD * screenPoint.w / resolution.x;
64539
+ gl_Position = screenPoint;
64540
+ }
64541
+ `,
64542
+ fragmentShader: `
64543
+ uniform float hw;
64544
+ uniform float ringR;
64545
+ uniform float shadowW;
64546
+ uniform float opacity;
64547
+ uniform float uvScale;
64548
+ varying vec2 vUv;
64549
+
64550
+ void main() {
64551
+ vec2 uv = (vUv * 2.0 - 1.0) * uvScale;
64552
+ float len = length(uv);
64553
+ float fw = fwidth(len) * 0.5;
64554
+ float d = abs(len - ringR);
64555
+
64556
+ float ring = 1.0 - smoothstep(hw - fw, hw + fw, d);
64557
+
64558
+ float shadow = (1.0 - smoothstep(hw, shadowW, d)) * (1.0 - smoothstep(ringR - fw, ringR + fw, len)) * 0.5;
64559
+
64560
+ float white = ring;
64561
+ float black = shadow * (1.0 - white);
64562
+ float alpha = (white + black) * opacity;
64563
+ if (alpha < 0.001) discard;
64564
+ gl_FragColor = vec4(vec3(white / max(alpha / opacity, 0.001)), alpha);
64565
+ }
64566
+ `
64567
+ });
64568
+ }
64569
+ };
64488
64570
  var _matrix2 = new Matrix4();
64489
64571
  function setRaycasterFromCamera(raycaster, coords, camera2) {
64490
64572
  const ray = raycaster instanceof Ray ? raycaster : raycaster.ray;
@@ -64522,6 +64604,8 @@ var UPDATE_EVENT = { type: "update" };
64522
64604
  var FINISH_EVENT = { type: "finish" };
64523
64605
  var THRESHOLD = 1e-3;
64524
64606
  var MAX = 1e8;
64607
+ var PIVOT_SIZE = 22;
64608
+ var PIVOT_THICKNESS = 2.5;
64525
64609
  var VIRTUAL_HIT_DISTANCE = 50;
64526
64610
  var ZOOM_OUT_TRANSITION_COS_THRESHOLD = Math.cos(105 * Math.PI / 180);
64527
64611
  var ZOOM_OUT_TRANSITION_COS_MAX_THRESHOLD = Math.cos(95 * Math.PI / 180);
@@ -64550,7 +64634,7 @@ var _quaternion2 = new Quaternion();
64550
64634
  var _plane = new Plane();
64551
64635
  var _ray2 = new Ray();
64552
64636
  var _zoomOutMetrics = { distanceScale: 1, transitionWeight: 0 };
64553
- var _pointerTracker, _domElement, _camera3, _scene2, _raycaster2, _zoomDelta, _zoomInertia, _rotateInertia, _dragInertia, _dragAnchorPoint, _dragStartPosition, _dragStartQuaternion, _dragPlaneNormal, _inertiaValue, _enabled, _ellipsoid, _ellipsoidMaxRadius, _lastTime, _hit, _contextMenuEvent, _pointerDownEvent, _pointerMoveEvent, _pointerUpEvent, _wheelEvent, _pointerEnterEvent, _zoomTimeout, _CameraController_instances, setState_fn, setZooming_fn, resetState_fn, bindEvents_fn, _contextMenu, _pointerDown, _pointerMove, _pointerUp, _wheel, _pointerEnter, finalizeCamera_fn, alignCameraRightToXYPlane_fn, rotateNearAnchor_fn, clampVerticalRotateAngle_fn, rotate_fn, initializeDragAnchor_fn, restoreDragStartCamera_fn, intersectDragPlane_fn, modifiedDrag_fn, keepCameraUpAtFixedPoint_fn, getZoomOutMetrics_fn, getScaledZoomTarget_fn, getZoomOutTransitionTarget_fn, getZoomPosition_fn, applyZoom_fn, reachCameraMaxDistance_fn, isCameraCenterMode_fn, limitCameraDistance_fn, shouldDragModified_fn, keepCameraUp_fn, normalRaycastClosest_fn, raycast_fn;
64637
+ var _pointerTracker, _domElement, _camera3, _scene2, _raycaster2, _pivotMesh, _zoomDelta, _zoomInertia, _rotateInertia, _dragInertia, _dragAnchorPoint, _dragStartPosition, _dragStartQuaternion, _dragPlaneNormal, _inertiaValue, _enabled, _ellipsoid, _ellipsoidMaxRadius, _lastTime, _hit, _contextMenuEvent, _pointerDownEvent, _pointerMoveEvent, _pointerUpEvent, _wheelEvent, _pointerEnterEvent, _zoomTimeout, _CameraController_instances, setState_fn, setZooming_fn, resetState_fn, bindEvents_fn, _contextMenu, updateIndicatorFromHit_fn, _pointerDown, _pointerMove, _pointerUp, _wheel, _pointerEnter, finalizeCamera_fn, alignCameraRightToXYPlane_fn, rotateNearAnchor_fn, clampVerticalRotateAngle_fn, rotate_fn, initializeDragAnchor_fn, restoreDragStartCamera_fn, intersectDragPlane_fn, modifiedDrag_fn, keepCameraUpAtFixedPoint_fn, getZoomOutMetrics_fn, getScaledZoomTarget_fn, getZoomOutTransitionTarget_fn, getZoomPosition_fn, applyZoom_fn, reachCameraMaxDistance_fn, isCameraCenterMode_fn, limitCameraDistance_fn, shouldDragModified_fn, keepCameraUp_fn, normalRaycastClosest_fn, raycast_fn;
64554
64638
  var CameraController = class extends EventDispatcher {
64555
64639
  constructor(renderer2, scene2, camera2, options = {}) {
64556
64640
  super();
@@ -64567,6 +64651,7 @@ var CameraController = class extends EventDispatcher {
64567
64651
  __privateAdd(this, _camera3);
64568
64652
  __privateAdd(this, _scene2);
64569
64653
  __privateAdd(this, _raycaster2);
64654
+ __privateAdd(this, _pivotMesh);
64570
64655
  __privateAdd(this, _zoomDelta);
64571
64656
  __privateAdd(this, _zoomInertia);
64572
64657
  __privateAdd(this, _rotateInertia);
@@ -64624,6 +64709,7 @@ var CameraController = class extends EventDispatcher {
64624
64709
  mouseToCoords(_pointer1.x, _pointer1.y, __privateGet(this, _domElement), _pointer1);
64625
64710
  setRaycasterFromCamera(__privateGet(this, _raycaster2), _pointer1, __privateGet(this, _camera3));
64626
64711
  __privateSet(this, _hit, __privateMethod(this, _CameraController_instances, raycast_fn).call(this, __privateGet(this, _raycaster2)));
64712
+ __privateMethod(this, _CameraController_instances, updateIndicatorFromHit_fn).call(this);
64627
64713
  if (this.state === DRAG && __privateGet(this, _hit).distance > 0) {
64628
64714
  __privateMethod(this, _CameraController_instances, initializeDragAnchor_fn).call(this);
64629
64715
  }
@@ -64660,7 +64746,7 @@ var CameraController = class extends EventDispatcher {
64660
64746
  if (!__privateGet(this, _enabled)) {
64661
64747
  return;
64662
64748
  }
64663
- const tooClose = __privateGet(this, _hit) ? __privateGet(this, _hit).point.distanceTo(__privateGet(this, _camera3).position) <= __privateGet(this, _camera3).near : false;
64749
+ const tooClose = __privateGet(this, _pivotMesh).position.distanceTo(__privateGet(this, _camera3).position) <= __privateGet(this, _camera3).near;
64664
64750
  if (!this.zooming || tooClose) {
64665
64751
  __privateGet(this, _rotateInertia).set(0, 0);
64666
64752
  __privateGet(this, _dragInertia).set(0, 0, 0);
@@ -64674,6 +64760,7 @@ var CameraController = class extends EventDispatcher {
64674
64760
  setRaycasterFromCamera(__privateGet(this, _raycaster2), _pointer1, __privateGet(this, _camera3));
64675
64761
  if (!this.zooming && this.state === NONE || tooClose) {
64676
64762
  __privateSet(this, _hit, __privateMethod(this, _CameraController_instances, raycast_fn).call(this, __privateGet(this, _raycaster2)));
64763
+ __privateMethod(this, _CameraController_instances, updateIndicatorFromHit_fn).call(this);
64677
64764
  }
64678
64765
  let delta = 0;
64679
64766
  switch (e.deltaMode) {
@@ -64721,6 +64808,8 @@ var CameraController = class extends EventDispatcher {
64721
64808
  __privateSet(this, _pointerTracker, new PointerTracker());
64722
64809
  __privateSet(this, _raycaster2, new Raycaster());
64723
64810
  __privateGet(this, _raycaster2).params.Points.threshold = 0.1;
64811
+ __privateSet(this, _pivotMesh, new PivotPointMesh(PIVOT_SIZE, PIVOT_THICKNESS));
64812
+ __privateGet(this, _pivotMesh).visible = false;
64724
64813
  __privateSet(this, _zoomDelta, 0);
64725
64814
  __privateSet(this, _zoomInertia, 0);
64726
64815
  __privateSet(this, _rotateInertia, new Vector2());
@@ -64759,6 +64848,9 @@ var CameraController = class extends EventDispatcher {
64759
64848
  get camera() {
64760
64849
  return __privateGet(this, _camera3);
64761
64850
  }
64851
+ get indicator() {
64852
+ return __privateGet(this, _pivotMesh);
64853
+ }
64762
64854
  setCamera(camera2) {
64763
64855
  __privateSet(this, _camera3, camera2);
64764
64856
  __privateMethod(this, _CameraController_instances, resetState_fn).call(this);
@@ -64772,6 +64864,9 @@ var CameraController = class extends EventDispatcher {
64772
64864
  }
64773
64865
  init() {
64774
64866
  __privateGet(this, _domElement).style.touchAction = "none";
64867
+ __privateGet(this, _pivotMesh).raycast = () => {
64868
+ };
64869
+ __privateGet(this, _scene2).add(__privateGet(this, _pivotMesh));
64775
64870
  __privateSet(this, _contextMenuEvent, __privateGet(this, _contextMenu).bind(this));
64776
64871
  __privateSet(this, _pointerDownEvent, __privateGet(this, _pointerDown).bind(this));
64777
64872
  __privateSet(this, _pointerMoveEvent, __privateGet(this, _pointerMove).bind(this));
@@ -64945,6 +65040,8 @@ var CameraController = class extends EventDispatcher {
64945
65040
  "pointerenter",
64946
65041
  __privateGet(this, _pointerEnterEvent)
64947
65042
  );
65043
+ __privateGet(this, _pivotMesh).removeFromParent();
65044
+ __privateGet(this, _pivotMesh).dispose();
64948
65045
  __privateGet(this, _domElement).style.touchAction = "";
64949
65046
  __privateSet(this, _enabled, false);
64950
65047
  __privateSet(this, _ellipsoid, null);
@@ -64955,6 +65052,7 @@ _domElement = new WeakMap();
64955
65052
  _camera3 = new WeakMap();
64956
65053
  _scene2 = new WeakMap();
64957
65054
  _raycaster2 = new WeakMap();
65055
+ _pivotMesh = new WeakMap();
64958
65056
  _zoomDelta = new WeakMap();
64959
65057
  _zoomInertia = new WeakMap();
64960
65058
  _rotateInertia = new WeakMap();
@@ -64982,8 +65080,14 @@ setState_fn = function(state = this.state) {
64982
65080
  return;
64983
65081
  }
64984
65082
  this.state = state;
65083
+ if (state !== NONE) {
65084
+ __privateGet(this, _pivotMesh).visible = true;
65085
+ }
64985
65086
  };
64986
65087
  setZooming_fn = function(zooming, touchZooming = false) {
65088
+ if (!this.zooming && this.state === NONE && zooming) {
65089
+ __privateGet(this, _pivotMesh).visible = true;
65090
+ }
64987
65091
  this.zooming = zooming;
64988
65092
  this.touchZooming = touchZooming;
64989
65093
  };
@@ -65000,6 +65104,7 @@ resetState_fn = function() {
65000
65104
  __privateGet(this, _dragPlaneNormal).set(0, 0, 0);
65001
65105
  __privateSet(this, _zoomInertia, 0);
65002
65106
  __privateSet(this, _hit, null);
65107
+ __privateGet(this, _pivotMesh).visible = false;
65003
65108
  };
65004
65109
  bindEvents_fn = function() {
65005
65110
  __privateGet(this, _domElement).addEventListener("contextmenu", __privateGet(this, _contextMenuEvent));
@@ -65010,6 +65115,15 @@ bindEvents_fn = function() {
65010
65115
  __privateGet(this, _domElement).addEventListener("pointerenter", __privateGet(this, _pointerEnterEvent));
65011
65116
  };
65012
65117
  _contextMenu = new WeakMap();
65118
+ updateIndicatorFromHit_fn = function() {
65119
+ if (__privateGet(this, _hit) && __privateGet(this, _hit).distance > 0) {
65120
+ __privateGet(this, _pivotMesh).visible = true;
65121
+ __privateGet(this, _pivotMesh).position.copy(__privateGet(this, _hit).point);
65122
+ __privateGet(this, _pivotMesh).focus = !__privateGet(this, _hit).onGlobe;
65123
+ } else {
65124
+ __privateGet(this, _pivotMesh).visible = false;
65125
+ }
65126
+ };
65013
65127
  _pointerDown = new WeakMap();
65014
65128
  _pointerMove = new WeakMap();
65015
65129
  _pointerUp = new WeakMap();
@@ -65291,6 +65405,7 @@ applyZoom_fn = function(zoomAmount) {
65291
65405
  _forward.subVectors(hit.point, __privateGet(this, _camera3).position).normalize();
65292
65406
  hit.point.copy(__privateGet(this, _camera3).position).addScaledVector(_forward, VIRTUAL_HIT_DISTANCE);
65293
65407
  hit.distance = VIRTUAL_HIT_DISTANCE;
65408
+ __privateGet(this, _pivotMesh).position.copy(hit.point);
65294
65409
  }
65295
65410
  let zoomFactor = Math.exp(-zoomAmount * 1e-3);
65296
65411
  if (this.minDistance > 0 && zoomFactor < 1) {
@@ -65463,6 +65578,12 @@ var MOVE_TO_COORDINATE_RADIUS = 10;
65463
65578
  var statusEl = document.getElementById("status");
65464
65579
  var cacheBytesValueEl = document.getElementById("cache-bytes-value");
65465
65580
  var splatsCountValueEl = document.getElementById("splats-count-value");
65581
+ var tilesDownloadingValueEl = document.getElementById(
65582
+ "tiles-downloading-value"
65583
+ );
65584
+ var tilesParsingValueEl = document.getElementById("tiles-parsing-value");
65585
+ var tilesLoadedValueEl = document.getElementById("tiles-loaded-value");
65586
+ var tilesVisibleValueEl = document.getElementById("tiles-visible-value");
65466
65587
  var toolbarEl = document.getElementById("toolbar");
65467
65588
  var toolbarDockEl = toolbarEl.parentElement;
65468
65589
  var toolbarToggleButton = document.getElementById("toolbar-toggle");
@@ -65888,9 +66009,19 @@ function getKnownTileLeafGeometricError(tile, visited = /* @__PURE__ */ new Set(
65888
66009
  visited.delete(tile);
65889
66010
  return leafGeometricError === null ? originalGeometricError : leafGeometricError;
65890
66011
  }
65891
- function applyGeometricErrorLayerScaleToTile(tile) {
66012
+ function getGlobalTileLeafGeometricError(tile) {
66013
+ const rootLeafGeometricError = tiles?.root ? getKnownTileLeafGeometricError(tiles.root) : null;
66014
+ const tileLeafGeometricError = getKnownTileLeafGeometricError(tile);
66015
+ if (rootLeafGeometricError === null) {
66016
+ return tileLeafGeometricError;
66017
+ }
66018
+ if (tileLeafGeometricError === null) {
66019
+ return rootLeafGeometricError;
66020
+ }
66021
+ return Math.min(rootLeafGeometricError, tileLeafGeometricError);
66022
+ }
66023
+ function applyGeometricErrorLayerScaleToTile(tile, leafGeometricError = getGlobalTileLeafGeometricError(tile)) {
65892
66024
  const originalGeometricError = getOriginalTileGeometricError(tile);
65893
- const leafGeometricError = getKnownTileLeafGeometricError(tile);
65894
66025
  if (originalGeometricError === null || leafGeometricError === null) {
65895
66026
  return;
65896
66027
  }
@@ -65900,9 +66031,10 @@ function applyGeometricErrorLayerScaleToTileset() {
65900
66031
  if (!tiles) {
65901
66032
  return;
65902
66033
  }
66034
+ const leafGeometricError = getGlobalTileLeafGeometricError(tiles.root);
65903
66035
  tiles.traverse(
65904
66036
  (tile) => {
65905
- applyGeometricErrorLayerScaleToTile(tile);
66037
+ applyGeometricErrorLayerScaleToTile(tile, leafGeometricError);
65906
66038
  return false;
65907
66039
  },
65908
66040
  null,
@@ -65952,7 +66084,7 @@ function getActiveSparkSplatsCount() {
65952
66084
  return count;
65953
66085
  }
65954
66086
  function updateRuntimeStats(force = false) {
65955
- if (!cacheBytesValueEl || !splatsCountValueEl) {
66087
+ if (!cacheBytesValueEl || !splatsCountValueEl || !tilesDownloadingValueEl || !tilesParsingValueEl || !tilesLoadedValueEl || !tilesVisibleValueEl) {
65956
66088
  return;
65957
66089
  }
65958
66090
  const now = performance.now();
@@ -65961,10 +66093,19 @@ function updateRuntimeStats(force = false) {
65961
66093
  }
65962
66094
  lastRuntimeStatsUpdateTime = now;
65963
66095
  const cacheBytes = tiles?.lruCache?.cachedBytes ?? 0;
66096
+ const tilesStats = tiles?.stats;
66097
+ const downloadingTiles = tilesStats?.downloading ?? 0;
66098
+ const parsingTiles = tilesStats?.parsing ?? 0;
66099
+ const loadedTiles = tilesStats?.loaded ?? 0;
66100
+ const visibleTiles = tiles?.visibleTiles?.size ?? tilesStats?.visible ?? 0;
65964
66101
  const activeSparkSplats = getActiveSparkSplatsCount();
65965
66102
  const splatCount = activeSparkSplats !== null ? activeSparkSplats : getLoadedGaussianSplatCount();
65966
66103
  cacheBytesValueEl.textContent = formatBytes(cacheBytes);
65967
66104
  splatsCountValueEl.textContent = formatInteger(splatCount);
66105
+ tilesDownloadingValueEl.textContent = formatInteger(downloadingTiles);
66106
+ tilesParsingValueEl.textContent = formatInteger(parsingTiles);
66107
+ tilesLoadedValueEl.textContent = formatInteger(loadedTiles);
66108
+ tilesVisibleValueEl.textContent = formatInteger(visibleTiles);
65968
66109
  }
65969
66110
  function setGeometricErrorScaleExponent(exponent) {
65970
66111
  geometricErrorScaleExponent = clamp2(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3dtiles-inspector",
3
- "version": "0.1.6",
3
+ "version": "0.1.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",
package/src/viewer/app.js CHANGED
@@ -79,6 +79,12 @@ const MOVE_TO_COORDINATE_RADIUS = 10;
79
79
  const statusEl = document.getElementById('status');
80
80
  const cacheBytesValueEl = document.getElementById('cache-bytes-value');
81
81
  const splatsCountValueEl = document.getElementById('splats-count-value');
82
+ const tilesDownloadingValueEl = document.getElementById(
83
+ 'tiles-downloading-value',
84
+ );
85
+ const tilesParsingValueEl = document.getElementById('tiles-parsing-value');
86
+ const tilesLoadedValueEl = document.getElementById('tiles-loaded-value');
87
+ const tilesVisibleValueEl = document.getElementById('tiles-visible-value');
82
88
  const toolbarEl = document.getElementById('toolbar');
83
89
  const toolbarDockEl = toolbarEl.parentElement;
84
90
  const toolbarToggleButton = document.getElementById('toolbar-toggle');
@@ -580,9 +586,28 @@ function getKnownTileLeafGeometricError(tile, visited = new Set()) {
580
586
  : leafGeometricError;
581
587
  }
582
588
 
583
- function applyGeometricErrorLayerScaleToTile(tile) {
589
+ function getGlobalTileLeafGeometricError(tile) {
590
+ const rootLeafGeometricError = tiles?.root
591
+ ? getKnownTileLeafGeometricError(tiles.root)
592
+ : null;
593
+ const tileLeafGeometricError = getKnownTileLeafGeometricError(tile);
594
+
595
+ if (rootLeafGeometricError === null) {
596
+ return tileLeafGeometricError;
597
+ }
598
+
599
+ if (tileLeafGeometricError === null) {
600
+ return rootLeafGeometricError;
601
+ }
602
+
603
+ return Math.min(rootLeafGeometricError, tileLeafGeometricError);
604
+ }
605
+
606
+ function applyGeometricErrorLayerScaleToTile(
607
+ tile,
608
+ leafGeometricError = getGlobalTileLeafGeometricError(tile),
609
+ ) {
584
610
  const originalGeometricError = getOriginalTileGeometricError(tile);
585
- const leafGeometricError = getKnownTileLeafGeometricError(tile);
586
611
  if (originalGeometricError === null || leafGeometricError === null) {
587
612
  return;
588
613
  }
@@ -598,9 +623,10 @@ function applyGeometricErrorLayerScaleToTileset() {
598
623
  return;
599
624
  }
600
625
 
626
+ const leafGeometricError = getGlobalTileLeafGeometricError(tiles.root);
601
627
  tiles.traverse(
602
628
  (tile) => {
603
- applyGeometricErrorLayerScaleToTile(tile);
629
+ applyGeometricErrorLayerScaleToTile(tile, leafGeometricError);
604
630
  return false;
605
631
  },
606
632
  null,
@@ -672,7 +698,14 @@ function getActiveSparkSplatsCount() {
672
698
  }
673
699
 
674
700
  function updateRuntimeStats(force = false) {
675
- if (!cacheBytesValueEl || !splatsCountValueEl) {
701
+ if (
702
+ !cacheBytesValueEl ||
703
+ !splatsCountValueEl ||
704
+ !tilesDownloadingValueEl ||
705
+ !tilesParsingValueEl ||
706
+ !tilesLoadedValueEl ||
707
+ !tilesVisibleValueEl
708
+ ) {
676
709
  return;
677
710
  }
678
711
 
@@ -687,6 +720,11 @@ function updateRuntimeStats(force = false) {
687
720
  lastRuntimeStatsUpdateTime = now;
688
721
 
689
722
  const cacheBytes = tiles?.lruCache?.cachedBytes ?? 0;
723
+ const tilesStats = tiles?.stats;
724
+ const downloadingTiles = tilesStats?.downloading ?? 0;
725
+ const parsingTiles = tilesStats?.parsing ?? 0;
726
+ const loadedTiles = tilesStats?.loaded ?? 0;
727
+ const visibleTiles = tiles?.visibleTiles?.size ?? tilesStats?.visible ?? 0;
690
728
  const activeSparkSplats = getActiveSparkSplatsCount();
691
729
  const splatCount =
692
730
  activeSparkSplats !== null
@@ -695,6 +733,10 @@ function updateRuntimeStats(force = false) {
695
733
 
696
734
  cacheBytesValueEl.textContent = formatBytes(cacheBytes);
697
735
  splatsCountValueEl.textContent = formatInteger(splatCount);
736
+ tilesDownloadingValueEl.textContent = formatInteger(downloadingTiles);
737
+ tilesParsingValueEl.textContent = formatInteger(parsingTiles);
738
+ tilesLoadedValueEl.textContent = formatInteger(loadedTiles);
739
+ tilesVisibleValueEl.textContent = formatInteger(visibleTiles);
698
740
  }
699
741
 
700
742
  function setGeometricErrorScaleExponent(exponent) {
@@ -1,10 +1,13 @@
1
1
  import {
2
2
  EventDispatcher,
3
3
  Matrix4,
4
+ Mesh,
4
5
  Plane,
6
+ PlaneGeometry,
5
7
  Quaternion,
6
8
  Ray,
7
9
  Raycaster,
10
+ ShaderMaterial,
8
11
  Vector2,
9
12
  Vector3,
10
13
  } from 'three';
@@ -191,6 +194,98 @@ class PointerTracker {
191
194
  return Boolean(this.buttons & 4);
192
195
  }
193
196
  }
197
+
198
+ class PivotPointMesh extends Mesh {
199
+ constructor(size = 15, thickness = 3) {
200
+ super(new PlaneGeometry(0, 0), new PivotMaterial(size, thickness));
201
+ this.renderOrder = Infinity;
202
+ }
203
+
204
+ set focus(value) {
205
+ this.material.uniforms.opacity.value = value ? 1 : 0.5;
206
+ }
207
+
208
+ onBeforeRender(renderer) {
209
+ renderer.getSize(this.material.uniforms.resolution.value);
210
+ }
211
+
212
+ updateMatrixWorld() {
213
+ this.matrixWorld.makeTranslation(this.position);
214
+ }
215
+
216
+ dispose() {
217
+ this.geometry.dispose();
218
+ this.material.dispose();
219
+ }
220
+ }
221
+
222
+ class PivotMaterial extends ShaderMaterial {
223
+ constructor(size, thickness) {
224
+ const coreD = size + thickness;
225
+ const planeD = coreD + 3 * thickness;
226
+ const normThk = thickness / coreD;
227
+ const ringR = (coreD - 0.4 * thickness - 4.0) / coreD;
228
+ const hw = 0.4 * normThk;
229
+
230
+ super({
231
+ depthWrite: false,
232
+ depthTest: false,
233
+ transparent: true,
234
+
235
+ uniforms: {
236
+ resolution: { value: new Vector2() },
237
+ opacity: { value: 1 },
238
+ planeD: { value: planeD },
239
+ hw: { value: hw },
240
+ ringR: { value: ringR },
241
+ shadowW: { value: hw * 5.0 },
242
+ uvScale: { value: planeD / coreD },
243
+ },
244
+
245
+ vertexShader: `
246
+ uniform float planeD;
247
+ uniform vec2 resolution;
248
+ varying vec2 vUv;
249
+
250
+ void main() {
251
+ vUv = uv;
252
+ float aspect = resolution.x / resolution.y;
253
+ vec2 offset = uv * 2.0 - vec2(1.0);
254
+ offset.y *= aspect;
255
+ vec4 screenPoint = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
256
+ screenPoint.xy += offset * planeD * screenPoint.w / resolution.x;
257
+ gl_Position = screenPoint;
258
+ }
259
+ `,
260
+ fragmentShader: `
261
+ uniform float hw;
262
+ uniform float ringR;
263
+ uniform float shadowW;
264
+ uniform float opacity;
265
+ uniform float uvScale;
266
+ varying vec2 vUv;
267
+
268
+ void main() {
269
+ vec2 uv = (vUv * 2.0 - 1.0) * uvScale;
270
+ float len = length(uv);
271
+ float fw = fwidth(len) * 0.5;
272
+ float d = abs(len - ringR);
273
+
274
+ float ring = 1.0 - smoothstep(hw - fw, hw + fw, d);
275
+
276
+ float shadow = (1.0 - smoothstep(hw, shadowW, d)) * (1.0 - smoothstep(ringR - fw, ringR + fw, len)) * 0.5;
277
+
278
+ float white = ring;
279
+ float black = shadow * (1.0 - white);
280
+ float alpha = (white + black) * opacity;
281
+ if (alpha < 0.001) discard;
282
+ gl_FragColor = vec4(vec3(white / max(alpha / opacity, 0.001)), alpha);
283
+ }
284
+ `,
285
+ });
286
+ }
287
+ }
288
+
194
289
  const _matrix = new Matrix4();
195
290
  // custom version of set raycaster from camera that relies on the underlying matrices
196
291
  // so the ray origin is position at the camera near clip.
@@ -240,6 +335,8 @@ const UPDATE_EVENT = { type: 'update' };
240
335
  const FINISH_EVENT = { type: 'finish' };
241
336
  const THRESHOLD = 1e-3;
242
337
  const MAX = 1e8;
338
+ const PIVOT_SIZE = 22;
339
+ const PIVOT_THICKNESS = 2.5;
243
340
  const VIRTUAL_HIT_DISTANCE = 50;
244
341
  const ZOOM_OUT_TRANSITION_COS_THRESHOLD = Math.cos((105 * Math.PI) / 180);
245
342
  const ZOOM_OUT_TRANSITION_COS_MAX_THRESHOLD = Math.cos((95 * Math.PI) / 180);
@@ -281,6 +378,7 @@ class CameraController extends EventDispatcher {
281
378
  #camera;
282
379
  #scene;
283
380
  #raycaster;
381
+ #pivotMesh;
284
382
  #zoomDelta;
285
383
  #zoomInertia;
286
384
  #rotateInertia;
@@ -330,6 +428,8 @@ class CameraController extends EventDispatcher {
330
428
  this.#pointerTracker = new PointerTracker();
331
429
  this.#raycaster = new Raycaster();
332
430
  this.#raycaster.params.Points.threshold = 0.1;
431
+ this.#pivotMesh = new PivotPointMesh(PIVOT_SIZE, PIVOT_THICKNESS);
432
+ this.#pivotMesh.visible = false;
333
433
  this.#zoomDelta = 0;
334
434
  this.#zoomInertia = 0;
335
435
  this.#rotateInertia = new Vector2();
@@ -368,13 +468,22 @@ class CameraController extends EventDispatcher {
368
468
  get camera() {
369
469
  return this.#camera;
370
470
  }
471
+ get indicator() {
472
+ return this.#pivotMesh;
473
+ }
371
474
  #setState(state = this.state) {
372
475
  if (this.state === state) {
373
476
  return;
374
477
  }
375
478
  this.state = state;
479
+ if (state !== NONE) {
480
+ this.#pivotMesh.visible = true;
481
+ }
376
482
  }
377
483
  #setZooming(zooming, touchZooming = false) {
484
+ if (!this.zooming && this.state === NONE && zooming) {
485
+ this.#pivotMesh.visible = true;
486
+ }
378
487
  this.zooming = zooming;
379
488
  this.touchZooming = touchZooming;
380
489
  }
@@ -391,6 +500,7 @@ class CameraController extends EventDispatcher {
391
500
  this.#dragPlaneNormal.set(0, 0, 0);
392
501
  this.#zoomInertia = 0;
393
502
  this.#hit = null;
503
+ this.#pivotMesh.visible = false;
394
504
  }
395
505
  setCamera(camera) {
396
506
  this.#camera = camera;
@@ -405,6 +515,8 @@ class CameraController extends EventDispatcher {
405
515
  }
406
516
  init() {
407
517
  this.#domElement.style.touchAction = 'none';
518
+ this.#pivotMesh.raycast = () => {};
519
+ this.#scene.add(this.#pivotMesh);
408
520
  this.#contextMenuEvent = this.#contextMenu.bind(this);
409
521
  this.#pointerDownEvent = this.#pointerDown.bind(this);
410
522
  this.#pointerMoveEvent = this.#pointerMove.bind(this);
@@ -600,6 +712,8 @@ class CameraController extends EventDispatcher {
600
712
  'pointerenter',
601
713
  this.#pointerEnterEvent,
602
714
  );
715
+ this.#pivotMesh.removeFromParent();
716
+ this.#pivotMesh.dispose();
603
717
  this.#domElement.style.touchAction = '';
604
718
  this.#enabled = false;
605
719
  this.#ellipsoid = null;
@@ -615,6 +729,15 @@ class CameraController extends EventDispatcher {
615
729
  #contextMenu = (e) => {
616
730
  e.preventDefault();
617
731
  };
732
+ #updateIndicatorFromHit() {
733
+ if (this.#hit && this.#hit.distance > 0) {
734
+ this.#pivotMesh.visible = true;
735
+ this.#pivotMesh.position.copy(this.#hit.point);
736
+ this.#pivotMesh.focus = !this.#hit.onGlobe;
737
+ } else {
738
+ this.#pivotMesh.visible = false;
739
+ }
740
+ }
618
741
  #pointerDown = (e) => {
619
742
  if (!this.#enabled) {
620
743
  return;
@@ -653,6 +776,7 @@ class CameraController extends EventDispatcher {
653
776
  mouseToCoords(_pointer1.x, _pointer1.y, this.#domElement, _pointer1);
654
777
  setRaycasterFromCamera(this.#raycaster, _pointer1, this.#camera);
655
778
  this.#hit = this.#raycast(this.#raycaster);
779
+ this.#updateIndicatorFromHit();
656
780
  if (this.state === DRAG && this.#hit.distance > 0) {
657
781
  this.#initializeDragAnchor();
658
782
  }
@@ -689,9 +813,9 @@ class CameraController extends EventDispatcher {
689
813
  if (!this.#enabled) {
690
814
  return;
691
815
  }
692
- const tooClose = this.#hit
693
- ? this.#hit.point.distanceTo(this.#camera.position) <= this.#camera.near
694
- : false;
816
+ const tooClose =
817
+ this.#pivotMesh.position.distanceTo(this.#camera.position) <=
818
+ this.#camera.near;
695
819
  if (!this.zooming || tooClose) {
696
820
  this.#rotateInertia.set(0, 0);
697
821
  this.#dragInertia.set(0, 0, 0);
@@ -705,6 +829,7 @@ class CameraController extends EventDispatcher {
705
829
  setRaycasterFromCamera(this.#raycaster, _pointer1, this.#camera);
706
830
  if ((!this.zooming && this.state === NONE) || tooClose) {
707
831
  this.#hit = this.#raycast(this.#raycaster);
832
+ this.#updateIndicatorFromHit();
708
833
  }
709
834
  let delta = 0;
710
835
  switch (e.deltaMode) {
@@ -1056,6 +1181,7 @@ class CameraController extends EventDispatcher {
1056
1181
  .copy(this.#camera.position)
1057
1182
  .addScaledVector(_forward, VIRTUAL_HIT_DISTANCE);
1058
1183
  hit.distance = VIRTUAL_HIT_DISTANCE;
1184
+ this.#pivotMesh.position.copy(hit.point);
1059
1185
  }
1060
1186
  let zoomFactor = Math.exp(-zoomAmount * 0.001);
1061
1187
  if (this.minDistance > 0 && zoomFactor < 1) {
@@ -344,9 +344,7 @@ function scaleTilesetGeometricErrors(
344
344
  tile,
345
345
  geometricErrorScale,
346
346
  geometricErrorLayerScale,
347
- baseDir,
348
- rootDir,
349
- leafGeometricErrorCache,
347
+ leafGeometricError,
350
348
  pathLabel = 'tileset.root',
351
349
  ) {
352
350
  if (!tile || typeof tile !== 'object') {
@@ -358,13 +356,7 @@ function scaleTilesetGeometricErrors(
358
356
  'geometricError',
359
357
  geometricErrorScale,
360
358
  geometricErrorLayerScale,
361
- getTileLeafGeometricError(
362
- tile,
363
- baseDir,
364
- rootDir,
365
- leafGeometricErrorCache,
366
- new Set(),
367
- ),
359
+ leafGeometricError,
368
360
  `${pathLabel}.geometricError`,
369
361
  );
370
362
 
@@ -377,9 +369,7 @@ function scaleTilesetGeometricErrors(
377
369
  child,
378
370
  geometricErrorScale,
379
371
  geometricErrorLayerScale,
380
- baseDir,
381
- rootDir,
382
- leafGeometricErrorCache,
372
+ leafGeometricError,
383
373
  `${pathLabel}.children[${index}]`,
384
374
  );
385
375
  });
@@ -428,6 +418,7 @@ function updateTilesetJsonFile(
428
418
  rootDir,
429
419
  rootTransform = null,
430
420
  leafGeometricErrorCache = new Map(),
421
+ globalLeafGeometricError = null,
431
422
  },
432
423
  visited = new Set(),
433
424
  ) {
@@ -455,27 +446,30 @@ function updateTilesetJsonFile(
455
446
  }
456
447
 
457
448
  const tilesetDir = path.dirname(resolvedPath);
449
+ const effectiveLeafGeometricError =
450
+ globalLeafGeometricError == null
451
+ ? getTileLeafGeometricError(
452
+ tileset.root,
453
+ tilesetDir,
454
+ rootDir,
455
+ leafGeometricErrorCache,
456
+ new Set(),
457
+ )
458
+ : globalLeafGeometricError;
459
+
458
460
  scaleGeometricErrorValue(
459
461
  tileset,
460
462
  'geometricError',
461
463
  geometricErrorScale,
462
464
  geometricErrorLayerScale,
463
- getTileLeafGeometricError(
464
- tileset.root,
465
- tilesetDir,
466
- rootDir,
467
- leafGeometricErrorCache,
468
- new Set(),
469
- ),
465
+ effectiveLeafGeometricError,
470
466
  `${resolvedPath}.geometricError`,
471
467
  );
472
468
  scaleTilesetGeometricErrors(
473
469
  tileset.root,
474
470
  geometricErrorScale,
475
471
  geometricErrorLayerScale,
476
- tilesetDir,
477
- rootDir,
478
- leafGeometricErrorCache,
472
+ effectiveLeafGeometricError,
479
473
  `${resolvedPath}.root`,
480
474
  );
481
475
  writeJsonAtomic(resolvedPath, tileset);
@@ -489,6 +483,7 @@ function updateTilesetJsonFile(
489
483
  geometricErrorLayerScale,
490
484
  geometricErrorScale,
491
485
  leafGeometricErrorCache,
486
+ globalLeafGeometricError: effectiveLeafGeometricError,
492
487
  rootDir,
493
488
  },
494
489
  visited,
@@ -905,6 +900,24 @@ function buildViewerHtml(viewerConfig) {
905
900
  pointer-events: none;
906
901
  }
907
902
 
903
+ .tile-runtime-stats {
904
+ position: fixed;
905
+ right: 16px;
906
+ bottom: 16px;
907
+ display: flex;
908
+ flex-wrap: nowrap;
909
+ justify-content: flex-end;
910
+ gap: 14px;
911
+ box-sizing: border-box;
912
+ max-width: calc(100vw - 32px);
913
+ padding: 4px 8px;
914
+ border-radius: 6px;
915
+ background: rgba(255, 255, 255, 0.15);
916
+ backdrop-filter: blur(6px);
917
+ z-index: 12;
918
+ pointer-events: none;
919
+ }
920
+
908
921
  .runtime-stat {
909
922
  display: grid;
910
923
  gap: 4px;
@@ -917,6 +930,19 @@ function buildViewerHtml(viewerConfig) {
917
930
  backdrop-filter: blur(14px);
918
931
  }
919
932
 
933
+ .tile-runtime-stats .runtime-stat {
934
+ display: inline-flex;
935
+ align-items: center;
936
+ gap: 0;
937
+ min-width: 0;
938
+ padding: 0;
939
+ border: 0;
940
+ border-radius: 0;
941
+ background: transparent;
942
+ box-shadow: none;
943
+ backdrop-filter: none;
944
+ }
945
+
920
946
  .runtime-stat-label {
921
947
  margin: 0;
922
948
  font-size: 10px;
@@ -926,6 +952,18 @@ function buildViewerHtml(viewerConfig) {
926
952
  color: #5d738b;
927
953
  }
928
954
 
955
+ .tile-runtime-stats .runtime-stat-label,
956
+ .tile-runtime-stats .runtime-stat-value {
957
+ display: inline-flex;
958
+ align-items: center;
959
+ font-size: 12px;
960
+ font-weight: 400;
961
+ letter-spacing: 0;
962
+ line-height: 14px;
963
+ text-transform: none;
964
+ color: #24292f;
965
+ }
966
+
929
967
  .runtime-stat-value {
930
968
  margin: 0;
931
969
  font-size: 15px;
@@ -934,6 +972,10 @@ function buildViewerHtml(viewerConfig) {
934
972
  color: #16324f;
935
973
  }
936
974
 
975
+ .tile-runtime-stats .runtime-stat-value {
976
+ font-variant-numeric: tabular-nums;
977
+ }
978
+
937
979
  canvas {
938
980
  display: block;
939
981
  }
@@ -1221,6 +1263,16 @@ function buildViewerHtml(viewerConfig) {
1221
1263
  top: 16px;
1222
1264
  right: 16px;
1223
1265
  left: 16px;
1266
+ flex-wrap: wrap;
1267
+ justify-content: stretch;
1268
+ max-width: none;
1269
+ }
1270
+
1271
+ .tile-runtime-stats {
1272
+ right: 16px;
1273
+ bottom: 16px;
1274
+ left: 16px;
1275
+ flex-wrap: wrap;
1224
1276
  justify-content: stretch;
1225
1277
  max-width: none;
1226
1278
  }
@@ -1230,6 +1282,11 @@ function buildViewerHtml(viewerConfig) {
1230
1282
  min-width: 0;
1231
1283
  }
1232
1284
 
1285
+ .tile-runtime-stats .runtime-stat {
1286
+ flex: 0 0 auto;
1287
+ min-width: 0;
1288
+ }
1289
+
1233
1290
  .toolbar-dock {
1234
1291
  top: auto;
1235
1292
  bottom: 16px;
@@ -1262,6 +1319,24 @@ function buildViewerHtml(viewerConfig) {
1262
1319
  <p id="splats-count-value" class="runtime-stat-value">0</p>
1263
1320
  </div>
1264
1321
  </div>
1322
+ <div class="tile-runtime-stats" aria-live="polite">
1323
+ <div class="runtime-stat">
1324
+ <p class="runtime-stat-label">Downloading:&nbsp;</p>
1325
+ <p id="tiles-downloading-value" class="runtime-stat-value">0</p>
1326
+ </div>
1327
+ <div class="runtime-stat">
1328
+ <p class="runtime-stat-label">Parsing:&nbsp;</p>
1329
+ <p id="tiles-parsing-value" class="runtime-stat-value">0</p>
1330
+ </div>
1331
+ <div class="runtime-stat">
1332
+ <p class="runtime-stat-label">Loaded:&nbsp;</p>
1333
+ <p id="tiles-loaded-value" class="runtime-stat-value">0</p>
1334
+ </div>
1335
+ <div class="runtime-stat">
1336
+ <p class="runtime-stat-label">Visible:&nbsp;</p>
1337
+ <p id="tiles-visible-value" class="runtime-stat-value">0</p>
1338
+ </div>
1339
+ </div>
1265
1340
  <div class="toolbar-dock expanded">
1266
1341
  <button
1267
1342
  id="toolbar-toggle"
@@ -1274,16 +1349,6 @@ function buildViewerHtml(viewerConfig) {
1274
1349
  Hide Sidebar
1275
1350
  </button>
1276
1351
  <div id="toolbar" class="toolbar">
1277
- <div class="toolbar-section">
1278
- <div class="toolbar-section-header">
1279
- <p class="toolbar-section-title">Transform</p>
1280
- </div>
1281
- <div class="transform-actions">
1282
- <button id="translate" type="button">Translate</button>
1283
- <button id="rotate" type="button">Rotate</button>
1284
- <button id="set-position" class="full-span" type="button">Set Position</button>
1285
- </div>
1286
- </div>
1287
1352
  <div class="toolbar-section">
1288
1353
  <div class="toolbar-section-header">
1289
1354
  <p class="toolbar-section-title">Canvas</p>
@@ -1294,6 +1359,16 @@ function buildViewerHtml(viewerConfig) {
1294
1359
  <button id="move-to-tiles" type="button">Move To Tiles</button>
1295
1360
  </div>
1296
1361
  </div>
1362
+ <div class="toolbar-section">
1363
+ <div class="toolbar-section-header">
1364
+ <p class="toolbar-section-title">Transform</p>
1365
+ </div>
1366
+ <div class="transform-actions">
1367
+ <button id="translate" type="button">Translate</button>
1368
+ <button id="rotate" type="button">Rotate</button>
1369
+ <button id="set-position" class="full-span" type="button">Set Position</button>
1370
+ </div>
1371
+ </div>
1297
1372
  <div class="toolbar-section">
1298
1373
  <div class="toolbar-section-header">
1299
1374
  <p class="toolbar-section-title">Coordinate</p>
@@ -1304,8 +1379,8 @@ function buildViewerHtml(viewerConfig) {
1304
1379
  <label><span>Height</span><input id="height" type="number" step="any" value="0" /></label>
1305
1380
  </div>
1306
1381
  <div class="coordinate-actions">
1307
- <button id="move-tiles-to-coordinate" class="wide" type="button">Move Tiles</button>
1308
1382
  <button id="move-camera-to-coordinate" class="wide" type="button">Move Camera</button>
1383
+ <button id="move-tiles-to-coordinate" class="wide" type="button">Move Tiles</button>
1309
1384
  </div>
1310
1385
  </div>
1311
1386
  <div class="toolbar-section">