3dtiles-inspector 0.2.9 → 0.2.10

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,12 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.10] - 2026-05-22
10
+
11
+ ### Added
12
+
13
+ - Added an infinite `Scale` drag track and editable value input for root transform edits.
14
+
9
15
  ## [0.2.9] - 2026-05-21
10
16
 
11
17
  ### Changed
package/README.md CHANGED
@@ -85,7 +85,7 @@ const {
85
85
 
86
86
  <img src="https://raw.githubusercontent.com/WilliamLiu-1997/3DTiles-Inspector/main/screenshot.png" alt="screenshot" />
87
87
 
88
- - `Translate`, `Rotate`, and `Reset` for root transform edits
88
+ - `Translate`, `Rotate`, infinite `Scale` track/value input, and `Reset` for root transform edits
89
89
  - `Move Camera` to a WGS84 latitude / longitude / height
90
90
  - `Move Tiles` to relocate the tileset root with an ENU-aligned transform
91
91
  - `Set Position` to click the globe, terrain, or loaded tiles and place the tileset there
@@ -45581,6 +45581,156 @@ var init_geometricError = __esm({
45581
45581
  }
45582
45582
  });
45583
45583
 
45584
+ // src/viewer/transform/uniformScale.js
45585
+ function exponentToUniformScale(exponent) {
45586
+ return 2 ** exponent;
45587
+ }
45588
+ function scaleToExponent(scale) {
45589
+ return Math.log2(scale);
45590
+ }
45591
+ function getFinitePositiveScale(value) {
45592
+ const number = Number(value);
45593
+ return Number.isFinite(number) && number > 0 ? number : null;
45594
+ }
45595
+ function normalizeUniformScale(value) {
45596
+ const scale = getFinitePositiveScale(value);
45597
+ if (scale === null) {
45598
+ return null;
45599
+ }
45600
+ return scale;
45601
+ }
45602
+ function formatUniformScale(scale) {
45603
+ const value = Number(scale);
45604
+ if (!Number.isFinite(value)) {
45605
+ return "1";
45606
+ }
45607
+ const absValue = Math.abs(value);
45608
+ if (absValue > 0 && (absValue < 1e-5 || absValue >= 1e6)) {
45609
+ return value.toExponential(3).replace(/\.?0+e/, "e");
45610
+ }
45611
+ const formatted = absValue < 0.01 ? value.toFixed(5) : absValue < 0.1 ? value.toFixed(4) : absValue < 10 ? value.toFixed(3) : absValue < 100 ? value.toFixed(2) : value.toFixed(1);
45612
+ return formatted.replace(/\.?0+$/, "");
45613
+ }
45614
+ function createUniformScaleController({
45615
+ applyScale,
45616
+ uniformScaleTrackEl,
45617
+ uniformScaleValueInput
45618
+ }) {
45619
+ let uniformScale = 1;
45620
+ let syncingInputs = false;
45621
+ let trackDragStartClientX = 0;
45622
+ let trackDragStartExponent = 0;
45623
+ function getScaleExponent() {
45624
+ return scaleToExponent(uniformScale);
45625
+ }
45626
+ function updateInputs() {
45627
+ syncingInputs = true;
45628
+ try {
45629
+ const formattedScale = formatUniformScale(uniformScale);
45630
+ uniformScaleTrackEl.setAttribute(
45631
+ "aria-label",
45632
+ `Scale x${formattedScale}`
45633
+ );
45634
+ uniformScaleTrackEl.title = `Scale x${formattedScale}`;
45635
+ uniformScaleValueInput.value = formatUniformScale(uniformScale);
45636
+ } finally {
45637
+ syncingInputs = false;
45638
+ }
45639
+ }
45640
+ function applyUniformScale(nextScale) {
45641
+ uniformScale = nextScale;
45642
+ applyScale(nextScale);
45643
+ updateInputs();
45644
+ }
45645
+ function setScale(scale) {
45646
+ const nextScale = normalizeUniformScale(scale);
45647
+ if (nextScale === null) {
45648
+ return false;
45649
+ }
45650
+ applyUniformScale(nextScale);
45651
+ return true;
45652
+ }
45653
+ function setScaleExponent(exponent) {
45654
+ const exponentNumber = Number(exponent);
45655
+ if (!Number.isFinite(exponentNumber)) {
45656
+ updateInputs();
45657
+ return false;
45658
+ }
45659
+ const nextScale = exponentToUniformScale(exponentNumber);
45660
+ if (!Number.isFinite(nextScale) || nextScale <= 0) {
45661
+ updateInputs();
45662
+ return false;
45663
+ }
45664
+ applyUniformScale(nextScale);
45665
+ return true;
45666
+ }
45667
+ function beginTrackDrag(clientX) {
45668
+ trackDragStartClientX = clientX;
45669
+ trackDragStartExponent = getScaleExponent();
45670
+ uniformScaleTrackEl.style.setProperty("--scale-track-offset", "0px");
45671
+ }
45672
+ function setScaleFromTrackClientX(clientX) {
45673
+ const deltaX = clientX - trackDragStartClientX;
45674
+ uniformScaleTrackEl.style.setProperty(
45675
+ "--scale-track-offset",
45676
+ `${deltaX}px`
45677
+ );
45678
+ return setScaleExponent(
45679
+ trackDragStartExponent + deltaX / TRACK_PIXELS_PER_SCALE_EXPONENT
45680
+ );
45681
+ }
45682
+ function nudgeScaleExponent(delta) {
45683
+ return setScaleExponent(getScaleExponent() + delta);
45684
+ }
45685
+ function setScaleValue(value, { commit = false } = {}) {
45686
+ if (syncingInputs) {
45687
+ return true;
45688
+ }
45689
+ const rawValue = typeof value === "string" ? value.trim() : value;
45690
+ if (rawValue === "" && !commit) {
45691
+ return false;
45692
+ }
45693
+ const scale = normalizeUniformScale(rawValue);
45694
+ if (scale === null) {
45695
+ if (commit) {
45696
+ updateInputs();
45697
+ }
45698
+ return false;
45699
+ }
45700
+ applyUniformScale(scale);
45701
+ return true;
45702
+ }
45703
+ function initializeInputs() {
45704
+ uniformScaleValueInput.removeAttribute("max");
45705
+ uniformScaleValueInput.removeAttribute("min");
45706
+ uniformScaleValueInput.step = "any";
45707
+ updateInputs();
45708
+ }
45709
+ function syncFromRootScale(scale) {
45710
+ const nextScale = normalizeUniformScale(scale);
45711
+ uniformScale = nextScale === null ? 1 : nextScale;
45712
+ updateInputs();
45713
+ }
45714
+ return {
45715
+ beginTrackDrag,
45716
+ formatScale: formatUniformScale,
45717
+ getScale: () => uniformScale,
45718
+ initializeInputs,
45719
+ nudgeScaleExponent,
45720
+ setScale,
45721
+ setScaleExponent,
45722
+ setScaleFromTrackClientX,
45723
+ setScaleValue,
45724
+ syncFromRootScale
45725
+ };
45726
+ }
45727
+ var TRACK_PIXELS_PER_SCALE_EXPONENT;
45728
+ var init_uniformScale = __esm({
45729
+ "src/viewer/transform/uniformScale.js"() {
45730
+ TRACK_PIXELS_PER_SCALE_EXPONENT = 90;
45731
+ }
45732
+ });
45733
+
45584
45734
  // node_modules/@cesium/engine/Source/Core/defined.js
45585
45735
  function defined(value) {
45586
45736
  return value !== void 0 && value !== null;
@@ -70869,7 +71019,8 @@ function bindViewerEvents({
70869
71019
  handlers,
70870
71020
  ktx2Loader,
70871
71021
  renderer,
70872
- setStatus
71022
+ setStatus,
71023
+ uniformScale
70873
71024
  }) {
70874
71025
  const {
70875
71026
  boundingVolumeButton,
@@ -70887,8 +71038,20 @@ function bindViewerEvents({
70887
71038
  setPositionButton,
70888
71039
  terrainButton,
70889
71040
  toolbarToggleButton,
70890
- translateButton
71041
+ translateButton,
71042
+ uniformScaleTrackEl,
71043
+ uniformScaleValueInput
70891
71044
  } = elements;
71045
+ let uniformScaleTrackPointerId = null;
71046
+ function setUniformScaleStatus() {
71047
+ setStatus(
71048
+ `Scale set to x${uniformScale.formatScale(uniformScale.getScale())}.`
71049
+ );
71050
+ }
71051
+ function updateUniformScaleFromTrackPointer(event) {
71052
+ handlers.cancelPositionPickModes();
71053
+ uniformScale.setScaleFromTrackClientX(event.clientX);
71054
+ }
70892
71055
  translateButton.addEventListener("click", () => {
70893
71056
  handlers.cancelPositionPickModes();
70894
71057
  handlers.toggleTransformMode("translate");
@@ -70903,6 +71066,70 @@ function bindViewerEvents({
70903
71066
  getActiveTransformMode() === "rotate" ? "Rotate mode enabled." : "Rotate mode disabled."
70904
71067
  );
70905
71068
  });
71069
+ uniformScaleTrackEl.addEventListener("pointerdown", (event) => {
71070
+ if (event.button !== 0) {
71071
+ return;
71072
+ }
71073
+ event.preventDefault();
71074
+ uniformScaleTrackPointerId = event.pointerId;
71075
+ uniformScaleTrackEl.focus();
71076
+ uniformScaleTrackEl.classList.add("dragging");
71077
+ uniformScaleTrackEl.setPointerCapture(event.pointerId);
71078
+ uniformScale.beginTrackDrag(event.clientX);
71079
+ });
71080
+ uniformScaleTrackEl.addEventListener("pointermove", (event) => {
71081
+ if (event.pointerId !== uniformScaleTrackPointerId) {
71082
+ return;
71083
+ }
71084
+ updateUniformScaleFromTrackPointer(event);
71085
+ });
71086
+ uniformScaleTrackEl.addEventListener("pointerup", (event) => {
71087
+ if (event.pointerId !== uniformScaleTrackPointerId) {
71088
+ return;
71089
+ }
71090
+ uniformScaleTrackPointerId = null;
71091
+ uniformScaleTrackEl.classList.remove("dragging");
71092
+ uniformScaleTrackEl.releasePointerCapture(event.pointerId);
71093
+ setUniformScaleStatus();
71094
+ });
71095
+ uniformScaleTrackEl.addEventListener("pointercancel", (event) => {
71096
+ if (event.pointerId !== uniformScaleTrackPointerId) {
71097
+ return;
71098
+ }
71099
+ uniformScaleTrackPointerId = null;
71100
+ uniformScaleTrackEl.classList.remove("dragging");
71101
+ uniformScaleTrackEl.releasePointerCapture(event.pointerId);
71102
+ });
71103
+ uniformScaleTrackEl.addEventListener("keydown", (event) => {
71104
+ let handled = true;
71105
+ const step = event.shiftKey ? 1 : 0.1;
71106
+ if (event.key === "ArrowLeft" || event.key === "ArrowDown") {
71107
+ uniformScale.nudgeScaleExponent(-step);
71108
+ } else if (event.key === "ArrowRight" || event.key === "ArrowUp") {
71109
+ uniformScale.nudgeScaleExponent(step);
71110
+ } else {
71111
+ handled = false;
71112
+ }
71113
+ if (!handled) {
71114
+ return;
71115
+ }
71116
+ event.preventDefault();
71117
+ handlers.cancelPositionPickModes();
71118
+ setUniformScaleStatus();
71119
+ });
71120
+ uniformScaleValueInput.addEventListener("input", () => {
71121
+ handlers.cancelPositionPickModes();
71122
+ uniformScale.setScaleValue(uniformScaleValueInput.value);
71123
+ });
71124
+ uniformScaleValueInput.addEventListener("change", () => {
71125
+ if (!uniformScale.setScaleValue(uniformScaleValueInput.value, {
71126
+ commit: true
71127
+ })) {
71128
+ setStatus("Scale must be greater than 0.", true);
71129
+ return;
71130
+ }
71131
+ setUniformScaleStatus();
71132
+ });
70906
71133
  cropScreenSelectButton.addEventListener(
70907
71134
  "click",
70908
71135
  handlers.toggleCropScreenSelectionMode
@@ -70915,7 +71142,10 @@ function bindViewerEvents({
70915
71142
  "click",
70916
71143
  handlers.cancelCropScreenSelection
70917
71144
  );
70918
- toolbarToggleButton.addEventListener("click", handlers.toggleToolbarVisibility);
71145
+ toolbarToggleButton.addEventListener(
71146
+ "click",
71147
+ handlers.toggleToolbarVisibility
71148
+ );
70919
71149
  terrainButton.addEventListener("click", () => {
70920
71150
  handlers.setTerrainEnabled(!getTerrainEnabled());
70921
71151
  setStatus(
@@ -70955,42 +71185,30 @@ function bindViewerEvents({
70955
71185
  setPositionButton.addEventListener("click", handlers.toggleSetPositionMode);
70956
71186
  resetButton.addEventListener("click", handlers.resetToSaved);
70957
71187
  saveButton.addEventListener("click", handlers.saveTransform);
70958
- renderer.domElement.addEventListener(
70959
- "pointerdown",
70960
- (event) => {
70961
- if (handlers.handleScreenSelectionPointerDown(event)) {
70962
- return;
70963
- }
70964
- handlers.handleSetPositionPointerDown(event);
71188
+ renderer.domElement.addEventListener("pointerdown", (event) => {
71189
+ if (handlers.handleScreenSelectionPointerDown(event)) {
71190
+ return;
70965
71191
  }
70966
- );
70967
- renderer.domElement.addEventListener(
70968
- "pointermove",
70969
- (event) => {
70970
- if (handlers.handleScreenSelectionPointerMove(event)) {
70971
- return;
70972
- }
70973
- handlers.handleSetPositionPointerMove(event);
71192
+ handlers.handleSetPositionPointerDown(event);
71193
+ });
71194
+ renderer.domElement.addEventListener("pointermove", (event) => {
71195
+ if (handlers.handleScreenSelectionPointerMove(event)) {
71196
+ return;
70974
71197
  }
70975
- );
70976
- renderer.domElement.addEventListener(
70977
- "pointerup",
70978
- (event) => {
70979
- if (handlers.handleScreenSelectionPointerUp(event)) {
70980
- return;
70981
- }
70982
- handlers.handleSetPositionPointerUp(event);
71198
+ handlers.handleSetPositionPointerMove(event);
71199
+ });
71200
+ renderer.domElement.addEventListener("pointerup", (event) => {
71201
+ if (handlers.handleScreenSelectionPointerUp(event)) {
71202
+ return;
70983
71203
  }
70984
- );
70985
- renderer.domElement.addEventListener(
70986
- "pointercancel",
70987
- (event) => {
70988
- if (handlers.handleScreenSelectionPointerCancel(event)) {
70989
- return;
70990
- }
70991
- handlers.handleSetPositionPointerCancel(event);
71204
+ handlers.handleSetPositionPointerUp(event);
71205
+ });
71206
+ renderer.domElement.addEventListener("pointercancel", (event) => {
71207
+ if (handlers.handleScreenSelectionPointerCancel(event)) {
71208
+ return;
70992
71209
  }
70993
- );
71210
+ handlers.handleSetPositionPointerCancel(event);
71211
+ });
70994
71212
  window.addEventListener("resize", () => {
70995
71213
  renderer.setSize(window.innerWidth, window.innerHeight);
70996
71214
  camera.aspect = window.innerWidth / window.innerHeight;
@@ -74391,7 +74609,8 @@ function createRootTransformController({
74391
74609
  transformControlsHelper,
74392
74610
  transformHandle,
74393
74611
  onTransformsInvalidated,
74394
- onCoordinateChanged
74612
+ onCoordinateChanged,
74613
+ onUniformScaleChanged
74395
74614
  }) {
74396
74615
  const coordinateWorldPosition = new Vector3();
74397
74616
  const coordinateTransformMatrix = new Matrix4();
@@ -74416,6 +74635,17 @@ function createRootTransformController({
74416
74635
  function getCurrentRootTransformArray() {
74417
74636
  return getCurrentRootTransform(currentRootTransformMatrix).toArray();
74418
74637
  }
74638
+ function getUniformScale() {
74639
+ const scale = transformHandle.scale;
74640
+ const scaleValues = [scale.x, scale.y, scale.z].map((value) => Math.abs(value)).filter((value) => Number.isFinite(value));
74641
+ if (scaleValues.length === 0) {
74642
+ return 1;
74643
+ }
74644
+ return scaleValues.reduce((total, value) => total + value, 0) / scaleValues.length;
74645
+ }
74646
+ function notifyUniformScaleChanged() {
74647
+ onUniformScaleChanged?.(getUniformScale());
74648
+ }
74419
74649
  function getCurrentMatrix() {
74420
74650
  return getObjectMatrix(editableGroup);
74421
74651
  }
@@ -74456,6 +74686,7 @@ function createRootTransformController({
74456
74686
  } finally {
74457
74687
  syncingTransformHandle = false;
74458
74688
  }
74689
+ notifyUniformScaleChanged();
74459
74690
  }
74460
74691
  function applySaved(matrix) {
74461
74692
  applySavedObjectMatrix(editableGroup, matrix);
@@ -74473,6 +74704,19 @@ function createRootTransformController({
74473
74704
  target: coordinateEditMatrix
74474
74705
  });
74475
74706
  invalidate();
74707
+ notifyUniformScaleChanged();
74708
+ }
74709
+ function applyUniformScale(scale) {
74710
+ const nextScale = Number(scale);
74711
+ if (!Number.isFinite(nextScale) || nextScale <= 0) {
74712
+ return false;
74713
+ }
74714
+ transformHandle.scale.set(nextScale, nextScale, nextScale);
74715
+ transformHandle.updateMatrix();
74716
+ transformHandle.updateMatrixWorld(true);
74717
+ applyFromRootTransform(transformHandle.matrix);
74718
+ syncCoordinateInputs();
74719
+ return true;
74476
74720
  }
74477
74721
  async function applyFromCoordinate(latitude, longitude, height) {
74478
74722
  await savedRootMatrixPromise;
@@ -74502,6 +74746,7 @@ function createRootTransformController({
74502
74746
  lastSavedMatrix.identity();
74503
74747
  resetEditableObjectTransform(transformHandle);
74504
74748
  tilesTransformDirty = true;
74749
+ notifyUniformScaleChanged();
74505
74750
  }
74506
74751
  function refresh(url) {
74507
74752
  savedRootMatrix.identity();
@@ -74572,6 +74817,7 @@ function createRootTransformController({
74572
74817
  applyFromCoordinate,
74573
74818
  applyFromRootTransform,
74574
74819
  applySaved,
74820
+ applyUniformScale,
74575
74821
  flush,
74576
74822
  getCurrentMatrix,
74577
74823
  getCurrentRootTransform,
@@ -74579,6 +74825,7 @@ function createRootTransformController({
74579
74825
  getIncrementalSinceSaved,
74580
74826
  getLastSaved: () => lastSavedMatrix,
74581
74827
  getLoadError: () => savedRootMatrixLoadError,
74828
+ getUniformScale,
74582
74829
  isSyncingHandle: () => syncingTransformHandle,
74583
74830
  markDirty() {
74584
74831
  tilesTransformDirty = true;
@@ -74722,7 +74969,9 @@ function getViewerElements() {
74722
74969
  toolbarDockEl: toolbarEl.parentElement,
74723
74970
  toolbarEl,
74724
74971
  toolbarToggleButton: document.getElementById("toolbar-toggle"),
74725
- translateButton: document.getElementById("translate")
74972
+ translateButton: document.getElementById("translate"),
74973
+ uniformScaleTrackEl: document.getElementById("uniform-scale"),
74974
+ uniformScaleValueInput: document.getElementById("uniform-scale-value")
74726
74975
  };
74727
74976
  }
74728
74977
  var init_elements = __esm({
@@ -74742,6 +74991,7 @@ var require_app = __commonJS({
74742
74991
  init_viewerToggles();
74743
74992
  init_geoCamera();
74744
74993
  init_geometricError();
74994
+ init_uniformScale();
74745
74995
  init_globeController();
74746
74996
  init_sceneSetup();
74747
74997
  init_transformControls();
@@ -74785,7 +75035,9 @@ var require_app = __commonJS({
74785
75035
  toolbarDockEl,
74786
75036
  toolbarEl,
74787
75037
  toolbarToggleButton,
74788
- translateButton
75038
+ translateButton,
75039
+ uniformScaleTrackEl,
75040
+ uniformScaleValueInput
74789
75041
  } = viewerElements;
74790
75042
  var MOVE_TO_TILES_POSE = {
74791
75043
  heading: MOVE_TO_TILES_HEADING,
@@ -74932,6 +75184,11 @@ var require_app = __commonJS({
74932
75184
  geometricErrorValueEl,
74933
75185
  getTiles: () => tiles
74934
75186
  });
75187
+ var uniformScale = createUniformScaleController({
75188
+ applyScale: (scale) => rootTransform?.applyUniformScale(scale),
75189
+ uniformScaleTrackEl,
75190
+ uniformScaleValueInput
75191
+ });
74935
75192
  function getTilesetBoundingSphere(target) {
74936
75193
  if (!tiles || !tiles.getBoundingSphere(target)) {
74937
75194
  return false;
@@ -75020,7 +75277,8 @@ var require_app = __commonJS({
75020
75277
  transformControlsHelper,
75021
75278
  transformHandle,
75022
75279
  onCoordinateChanged: updateCoordinateInputs,
75023
- onTransformsInvalidated: () => cropController.syncWorldState()
75280
+ onTransformsInvalidated: () => cropController.syncWorldState(),
75281
+ onUniformScaleChanged: uniformScale.syncFromRootScale
75024
75282
  });
75025
75283
  function setGaussianSplatUiVisible(visible) {
75026
75284
  if (splatsCountStatEl) {
@@ -75056,6 +75314,7 @@ var require_app = __commonJS({
75056
75314
  transformModeController.setMode(null);
75057
75315
  }
75058
75316
  geometricError.initializeInputs();
75317
+ uniformScale.initializeInputs();
75059
75318
  transformModeController.setMode(null);
75060
75319
  function moveCameraToTiles() {
75061
75320
  cancelPositionPickModes();
@@ -75264,7 +75523,8 @@ var require_app = __commonJS({
75264
75523
  },
75265
75524
  ktx2Loader,
75266
75525
  renderer,
75267
- setStatus
75526
+ setStatus,
75527
+ uniformScale
75268
75528
  });
75269
75529
  window.addEventListener("popstate", () => {
75270
75530
  if (cameraUrlPose.applyFromUrl({ showStatus: true })) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3dtiles-inspector",
3
- "version": "0.2.9",
3
+ "version": "0.2.10",
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",
@@ -279,7 +279,7 @@ function buildViewerHtml(viewerConfig) {
279
279
  grid-template-rows: minmax(0, 1fr) auto;
280
280
  gap: 0;
281
281
  padding: 10px 14px;
282
- border: 1px solid rgba(22, 50, 79, 0.12);
282
+ border: 1px solid rgba(73, 80, 87, 0.16);
283
283
  border-top: 0;
284
284
  border-radius: 0 0 20px 20px;
285
285
  background: rgba(255, 255, 255, 0.9);
@@ -502,6 +502,124 @@ function buildViewerHtml(viewerConfig) {
502
502
  margin: 0;
503
503
  }
504
504
 
505
+ .scale-value-input {
506
+ width: 82px;
507
+ min-width: 0;
508
+ padding: 4px 7px;
509
+ border: 1px solid rgba(22, 50, 79, 0.16);
510
+ border-radius: 8px;
511
+ font: inherit;
512
+ font-size: 12px;
513
+ font-weight: 700;
514
+ text-align: right;
515
+ color: #16324f;
516
+ background: rgba(255, 255, 255, 0.92);
517
+ }
518
+
519
+ .scale-track {
520
+ --scale-track-offset: 0px;
521
+ position: relative;
522
+ width: 100%;
523
+ height: 22px;
524
+ overflow: hidden;
525
+ border: 1px solid rgba(22, 50, 79, 0.12);
526
+ border-radius: 9px;
527
+ padding: 0;
528
+ background:
529
+ linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(231, 235, 240, 0.92)),
530
+ #edf0f4;
531
+ box-shadow:
532
+ inset 0 1px 0 rgba(255, 255, 255, 0.78),
533
+ inset 0 -1px 0 rgba(73, 80, 87, 0.08);
534
+ cursor: ew-resize;
535
+ touch-action: none;
536
+ }
537
+
538
+ .scale-track::before {
539
+ content: "";
540
+ position: absolute;
541
+ top: 50%;
542
+ right: 8px;
543
+ left: 8px;
544
+ height: 4px;
545
+ border-radius: 999px;
546
+ background:
547
+ linear-gradient(
548
+ 90deg,
549
+ rgba(73, 80, 87, 0.08),
550
+ rgba(73, 80, 87, 0.42) 50%,
551
+ rgba(73, 80, 87, 0.08)
552
+ );
553
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.56);
554
+ transform: translateY(-50%);
555
+ }
556
+
557
+ .scale-track::after {
558
+ content: "";
559
+ position: absolute;
560
+ inset: 5px 8px 5px;
561
+ background-image:
562
+ linear-gradient(
563
+ 90deg,
564
+ rgba(73, 80, 87, 0.46) 0 1px,
565
+ transparent 1px 100%
566
+ ),
567
+ linear-gradient(
568
+ 90deg,
569
+ rgba(73, 80, 87, 0.26) 0 1px,
570
+ transparent 1px 100%
571
+ );
572
+ background-repeat: repeat-x;
573
+ background-size:
574
+ 20px 12px,
575
+ 20px 7px;
576
+ background-position:
577
+ calc(50% + var(--scale-track-offset) - 1px) -1px,
578
+ calc(50% + var(--scale-track-offset) + 10px) 2px;
579
+ opacity: 0.72;
580
+ pointer-events: none;
581
+ }
582
+
583
+ .scale-track-center {
584
+ position: absolute;
585
+ top: 3px;
586
+ bottom: 3px;
587
+ left: 50%;
588
+ width: 2px;
589
+ border-radius: 999px;
590
+ background: rgba(73, 80, 87, 0.72);
591
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.72);
592
+ transform: translateX(-50%);
593
+ pointer-events: none;
594
+ }
595
+
596
+ .scale-track:hover::before,
597
+ .scale-track:focus-visible::before,
598
+ .scale-track.dragging::before {
599
+ background:
600
+ linear-gradient(
601
+ 90deg,
602
+ rgba(73, 80, 87, 0.12),
603
+ rgba(73, 80, 87, 0.58) 50%,
604
+ rgba(73, 80, 87, 0.12)
605
+ );
606
+ }
607
+
608
+ .scale-track.dragging {
609
+ border-color: rgba(73, 80, 87, 0.32);
610
+ box-shadow:
611
+ inset 0 1px 0 rgba(255, 255, 255, 0.82),
612
+ inset 0 -1px 0 rgba(73, 80, 87, 0.08);
613
+ }
614
+
615
+ .scale-track:focus {
616
+ outline: none;
617
+ }
618
+
619
+ .scale-track:focus-visible {
620
+ outline: none;
621
+ }
622
+
505
623
  .coordinate-grid {
506
624
  display: grid;
507
625
  grid-template-columns: 1fr;
@@ -795,8 +913,28 @@ function buildViewerHtml(viewerConfig) {
795
913
  <div class="transform-actions">
796
914
  <button id="translate" type="button">Translate</button>
797
915
  <button id="rotate" type="button">Rotate</button>
798
- <button id="set-position" type="button">Set Position</button>
799
- <button id="reset" type="button">Reset</button>
916
+ <div class="range-field full-span">
917
+ <div class="range-field-header">
918
+ <span>Scale</span>
919
+ <input
920
+ id="uniform-scale-value"
921
+ class="scale-value-input"
922
+ type="number"
923
+ step="any"
924
+ value="1"
925
+ />
926
+ </div>
927
+ <div
928
+ id="uniform-scale"
929
+ class="scale-track"
930
+ tabindex="0"
931
+ aria-label="Scale x1"
932
+ >
933
+ <span class="scale-track-center" aria-hidden="true"></span>
934
+ </div>
935
+ </div>
936
+ <button id="set-position" class="full-span" type="button">Set Position</button>
937
+ <button id="reset" class="full-span" type="button">Reset</button>
800
938
  </div>
801
939
  </div>
802
940
  <div class="toolbar-section">
package/src/viewer/app.js CHANGED
@@ -10,6 +10,7 @@ import { createStatusPanel } from './dom/statusPanel.js';
10
10
  import { createViewerToggles } from './dom/viewerToggles.js';
11
11
  import { createGeoCameraController } from './transform/geoCamera.js';
12
12
  import { createGeometricErrorController } from './transform/geometricError.js';
13
+ import { createUniformScaleController } from './transform/uniformScale.js';
13
14
  import { createGlobeController } from './scene/globeController.js';
14
15
  import { createViewerScene } from './scene/sceneSetup.js';
15
16
  import { createViewerTransformControls } from './scene/transformControls.js';
@@ -71,6 +72,8 @@ const {
71
72
  toolbarEl,
72
73
  toolbarToggleButton,
73
74
  translateButton,
75
+ uniformScaleTrackEl,
76
+ uniformScaleValueInput,
74
77
  } = viewerElements;
75
78
 
76
79
  const MOVE_TO_TILES_POSE = {
@@ -244,6 +247,12 @@ const geometricError = createGeometricErrorController({
244
247
  getTiles: () => tiles,
245
248
  });
246
249
 
250
+ const uniformScale = createUniformScaleController({
251
+ applyScale: (scale) => rootTransform?.applyUniformScale(scale),
252
+ uniformScaleTrackEl,
253
+ uniformScaleValueInput,
254
+ });
255
+
247
256
  function getTilesetBoundingSphere(target) {
248
257
  if (!tiles || !tiles.getBoundingSphere(target)) {
249
258
  return false;
@@ -342,6 +351,7 @@ rootTransform = createRootTransformController({
342
351
  transformHandle,
343
352
  onCoordinateChanged: updateCoordinateInputs,
344
353
  onTransformsInvalidated: () => cropController.syncWorldState(),
354
+ onUniformScaleChanged: uniformScale.syncFromRootScale,
345
355
  });
346
356
 
347
357
  function setGaussianSplatUiVisible(visible) {
@@ -384,6 +394,7 @@ function exitSaveInteractionModes() {
384
394
  }
385
395
 
386
396
  geometricError.initializeInputs();
397
+ uniformScale.initializeInputs();
387
398
  transformModeController.setMode(null);
388
399
 
389
400
  function moveCameraToTiles() {
@@ -614,6 +625,7 @@ bindViewerEvents({
614
625
  ktx2Loader,
615
626
  renderer,
616
627
  setStatus,
628
+ uniformScale,
617
629
  });
618
630
 
619
631
  window.addEventListener('popstate', () => {
@@ -49,5 +49,7 @@ export function getViewerElements() {
49
49
  toolbarEl,
50
50
  toolbarToggleButton: document.getElementById('toolbar-toggle'),
51
51
  translateButton: document.getElementById('translate'),
52
+ uniformScaleTrackEl: document.getElementById('uniform-scale'),
53
+ uniformScaleValueInput: document.getElementById('uniform-scale-value'),
52
54
  };
53
55
  }
@@ -12,6 +12,7 @@ export function bindViewerEvents({
12
12
  ktx2Loader,
13
13
  renderer,
14
14
  setStatus,
15
+ uniformScale,
15
16
  }) {
16
17
  const {
17
18
  boundingVolumeButton,
@@ -30,8 +31,23 @@ export function bindViewerEvents({
30
31
  terrainButton,
31
32
  toolbarToggleButton,
32
33
  translateButton,
34
+ uniformScaleTrackEl,
35
+ uniformScaleValueInput,
33
36
  } = elements;
34
37
 
38
+ let uniformScaleTrackPointerId = null;
39
+
40
+ function setUniformScaleStatus() {
41
+ setStatus(
42
+ `Scale set to x${uniformScale.formatScale(uniformScale.getScale())}.`,
43
+ );
44
+ }
45
+
46
+ function updateUniformScaleFromTrackPointer(event) {
47
+ handlers.cancelPositionPickModes();
48
+ uniformScale.setScaleFromTrackClientX(event.clientX);
49
+ }
50
+
35
51
  translateButton.addEventListener('click', () => {
36
52
  handlers.cancelPositionPickModes();
37
53
  handlers.toggleTransformMode('translate');
@@ -50,6 +66,79 @@ export function bindViewerEvents({
50
66
  : 'Rotate mode disabled.',
51
67
  );
52
68
  });
69
+ uniformScaleTrackEl.addEventListener('pointerdown', (event) => {
70
+ if (event.button !== 0) {
71
+ return;
72
+ }
73
+
74
+ event.preventDefault();
75
+ uniformScaleTrackPointerId = event.pointerId;
76
+ uniformScaleTrackEl.focus();
77
+ uniformScaleTrackEl.classList.add('dragging');
78
+ uniformScaleTrackEl.setPointerCapture(event.pointerId);
79
+ uniformScale.beginTrackDrag(event.clientX);
80
+ });
81
+ uniformScaleTrackEl.addEventListener('pointermove', (event) => {
82
+ if (event.pointerId !== uniformScaleTrackPointerId) {
83
+ return;
84
+ }
85
+
86
+ updateUniformScaleFromTrackPointer(event);
87
+ });
88
+ uniformScaleTrackEl.addEventListener('pointerup', (event) => {
89
+ if (event.pointerId !== uniformScaleTrackPointerId) {
90
+ return;
91
+ }
92
+
93
+ uniformScaleTrackPointerId = null;
94
+ uniformScaleTrackEl.classList.remove('dragging');
95
+ uniformScaleTrackEl.releasePointerCapture(event.pointerId);
96
+ setUniformScaleStatus();
97
+ });
98
+ uniformScaleTrackEl.addEventListener('pointercancel', (event) => {
99
+ if (event.pointerId !== uniformScaleTrackPointerId) {
100
+ return;
101
+ }
102
+
103
+ uniformScaleTrackPointerId = null;
104
+ uniformScaleTrackEl.classList.remove('dragging');
105
+ uniformScaleTrackEl.releasePointerCapture(event.pointerId);
106
+ });
107
+ uniformScaleTrackEl.addEventListener('keydown', (event) => {
108
+ let handled = true;
109
+ const step = event.shiftKey ? 1 : 0.1;
110
+
111
+ if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
112
+ uniformScale.nudgeScaleExponent(-step);
113
+ } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
114
+ uniformScale.nudgeScaleExponent(step);
115
+ } else {
116
+ handled = false;
117
+ }
118
+
119
+ if (!handled) {
120
+ return;
121
+ }
122
+
123
+ event.preventDefault();
124
+ handlers.cancelPositionPickModes();
125
+ setUniformScaleStatus();
126
+ });
127
+ uniformScaleValueInput.addEventListener('input', () => {
128
+ handlers.cancelPositionPickModes();
129
+ uniformScale.setScaleValue(uniformScaleValueInput.value);
130
+ });
131
+ uniformScaleValueInput.addEventListener('change', () => {
132
+ if (
133
+ !uniformScale.setScaleValue(uniformScaleValueInput.value, {
134
+ commit: true,
135
+ })
136
+ ) {
137
+ setStatus('Scale must be greater than 0.', true);
138
+ return;
139
+ }
140
+ setUniformScaleStatus();
141
+ });
53
142
  cropScreenSelectButton.addEventListener(
54
143
  'click',
55
144
  handlers.toggleCropScreenSelectionMode,
@@ -62,7 +151,10 @@ export function bindViewerEvents({
62
151
  'click',
63
152
  handlers.cancelCropScreenSelection,
64
153
  );
65
- toolbarToggleButton.addEventListener('click', handlers.toggleToolbarVisibility);
154
+ toolbarToggleButton.addEventListener(
155
+ 'click',
156
+ handlers.toggleToolbarVisibility,
157
+ );
66
158
  terrainButton.addEventListener('click', () => {
67
159
  handlers.setTerrainEnabled(!getTerrainEnabled());
68
160
  setStatus(
@@ -104,42 +196,30 @@ export function bindViewerEvents({
104
196
  setPositionButton.addEventListener('click', handlers.toggleSetPositionMode);
105
197
  resetButton.addEventListener('click', handlers.resetToSaved);
106
198
  saveButton.addEventListener('click', handlers.saveTransform);
107
- renderer.domElement.addEventListener(
108
- 'pointerdown',
109
- (event) => {
110
- if (handlers.handleScreenSelectionPointerDown(event)) {
111
- return;
112
- }
113
- handlers.handleSetPositionPointerDown(event);
114
- },
115
- );
116
- renderer.domElement.addEventListener(
117
- 'pointermove',
118
- (event) => {
119
- if (handlers.handleScreenSelectionPointerMove(event)) {
120
- return;
121
- }
122
- handlers.handleSetPositionPointerMove(event);
123
- },
124
- );
125
- renderer.domElement.addEventListener(
126
- 'pointerup',
127
- (event) => {
128
- if (handlers.handleScreenSelectionPointerUp(event)) {
129
- return;
130
- }
131
- handlers.handleSetPositionPointerUp(event);
132
- },
133
- );
134
- renderer.domElement.addEventListener(
135
- 'pointercancel',
136
- (event) => {
137
- if (handlers.handleScreenSelectionPointerCancel(event)) {
138
- return;
139
- }
140
- handlers.handleSetPositionPointerCancel(event);
141
- },
142
- );
199
+ renderer.domElement.addEventListener('pointerdown', (event) => {
200
+ if (handlers.handleScreenSelectionPointerDown(event)) {
201
+ return;
202
+ }
203
+ handlers.handleSetPositionPointerDown(event);
204
+ });
205
+ renderer.domElement.addEventListener('pointermove', (event) => {
206
+ if (handlers.handleScreenSelectionPointerMove(event)) {
207
+ return;
208
+ }
209
+ handlers.handleSetPositionPointerMove(event);
210
+ });
211
+ renderer.domElement.addEventListener('pointerup', (event) => {
212
+ if (handlers.handleScreenSelectionPointerUp(event)) {
213
+ return;
214
+ }
215
+ handlers.handleSetPositionPointerUp(event);
216
+ });
217
+ renderer.domElement.addEventListener('pointercancel', (event) => {
218
+ if (handlers.handleScreenSelectionPointerCancel(event)) {
219
+ return;
220
+ }
221
+ handlers.handleSetPositionPointerCancel(event);
222
+ });
143
223
 
144
224
  window.addEventListener('resize', () => {
145
225
  renderer.setSize(window.innerWidth, window.innerHeight);
@@ -21,6 +21,7 @@ export function createRootTransformController({
21
21
  transformHandle,
22
22
  onTransformsInvalidated,
23
23
  onCoordinateChanged,
24
+ onUniformScaleChanged,
24
25
  }) {
25
26
  const coordinateWorldPosition = new Vector3();
26
27
  const coordinateTransformMatrix = new Matrix4();
@@ -48,6 +49,24 @@ export function createRootTransformController({
48
49
  return getCurrentRootTransform(currentRootTransformMatrix).toArray();
49
50
  }
50
51
 
52
+ function getUniformScale() {
53
+ const scale = transformHandle.scale;
54
+ const scaleValues = [scale.x, scale.y, scale.z]
55
+ .map((value) => Math.abs(value))
56
+ .filter((value) => Number.isFinite(value));
57
+ if (scaleValues.length === 0) {
58
+ return 1;
59
+ }
60
+ return (
61
+ scaleValues.reduce((total, value) => total + value, 0) /
62
+ scaleValues.length
63
+ );
64
+ }
65
+
66
+ function notifyUniformScaleChanged() {
67
+ onUniformScaleChanged?.(getUniformScale());
68
+ }
69
+
51
70
  function getCurrentMatrix() {
52
71
  return getObjectMatrix(editableGroup);
53
72
  }
@@ -94,6 +113,7 @@ export function createRootTransformController({
94
113
  } finally {
95
114
  syncingTransformHandle = false;
96
115
  }
116
+ notifyUniformScaleChanged();
97
117
  }
98
118
 
99
119
  function applySaved(matrix) {
@@ -113,6 +133,21 @@ export function createRootTransformController({
113
133
  target: coordinateEditMatrix,
114
134
  });
115
135
  invalidate();
136
+ notifyUniformScaleChanged();
137
+ }
138
+
139
+ function applyUniformScale(scale) {
140
+ const nextScale = Number(scale);
141
+ if (!Number.isFinite(nextScale) || nextScale <= 0) {
142
+ return false;
143
+ }
144
+
145
+ transformHandle.scale.set(nextScale, nextScale, nextScale);
146
+ transformHandle.updateMatrix();
147
+ transformHandle.updateMatrixWorld(true);
148
+ applyFromRootTransform(transformHandle.matrix);
149
+ syncCoordinateInputs();
150
+ return true;
116
151
  }
117
152
 
118
153
  async function applyFromCoordinate(latitude, longitude, height) {
@@ -145,6 +180,7 @@ export function createRootTransformController({
145
180
  lastSavedMatrix.identity();
146
181
  resetEditableObjectTransform(transformHandle);
147
182
  tilesTransformDirty = true;
183
+ notifyUniformScaleChanged();
148
184
  }
149
185
 
150
186
  function refresh(url) {
@@ -222,6 +258,7 @@ export function createRootTransformController({
222
258
  applyFromCoordinate,
223
259
  applyFromRootTransform,
224
260
  applySaved,
261
+ applyUniformScale,
225
262
  flush,
226
263
  getCurrentMatrix,
227
264
  getCurrentRootTransform,
@@ -229,6 +266,7 @@ export function createRootTransformController({
229
266
  getIncrementalSinceSaved,
230
267
  getLastSaved: () => lastSavedMatrix,
231
268
  getLoadError: () => savedRootMatrixLoadError,
269
+ getUniformScale,
232
270
  isSyncingHandle: () => syncingTransformHandle,
233
271
  markDirty() {
234
272
  tilesTransformDirty = true;
@@ -0,0 +1,179 @@
1
+ const TRACK_PIXELS_PER_SCALE_EXPONENT = 90;
2
+
3
+ function exponentToUniformScale(exponent) {
4
+ return 2 ** exponent;
5
+ }
6
+
7
+ function scaleToExponent(scale) {
8
+ return Math.log2(scale);
9
+ }
10
+
11
+ function getFinitePositiveScale(value) {
12
+ const number = Number(value);
13
+ return Number.isFinite(number) && number > 0 ? number : null;
14
+ }
15
+
16
+ function normalizeUniformScale(value) {
17
+ const scale = getFinitePositiveScale(value);
18
+ if (scale === null) {
19
+ return null;
20
+ }
21
+ return scale;
22
+ }
23
+
24
+ export function formatUniformScale(scale) {
25
+ const value = Number(scale);
26
+ if (!Number.isFinite(value)) {
27
+ return '1';
28
+ }
29
+
30
+ const absValue = Math.abs(value);
31
+ if (absValue > 0 && (absValue < 0.00001 || absValue >= 1000000)) {
32
+ return value.toExponential(3).replace(/\.?0+e/, 'e');
33
+ }
34
+
35
+ const formatted =
36
+ absValue < 0.01
37
+ ? value.toFixed(5)
38
+ : absValue < 0.1
39
+ ? value.toFixed(4)
40
+ : absValue < 10
41
+ ? value.toFixed(3)
42
+ : absValue < 100
43
+ ? value.toFixed(2)
44
+ : value.toFixed(1);
45
+
46
+ return formatted.replace(/\.?0+$/, '');
47
+ }
48
+
49
+ export function createUniformScaleController({
50
+ applyScale,
51
+ uniformScaleTrackEl,
52
+ uniformScaleValueInput,
53
+ }) {
54
+ let uniformScale = 1;
55
+ let syncingInputs = false;
56
+ let trackDragStartClientX = 0;
57
+ let trackDragStartExponent = 0;
58
+
59
+ function getScaleExponent() {
60
+ return scaleToExponent(uniformScale);
61
+ }
62
+
63
+ function updateInputs() {
64
+ syncingInputs = true;
65
+ try {
66
+ const formattedScale = formatUniformScale(uniformScale);
67
+ uniformScaleTrackEl.setAttribute(
68
+ 'aria-label',
69
+ `Scale x${formattedScale}`,
70
+ );
71
+ uniformScaleTrackEl.title = `Scale x${formattedScale}`;
72
+ uniformScaleValueInput.value = formatUniformScale(uniformScale);
73
+ } finally {
74
+ syncingInputs = false;
75
+ }
76
+ }
77
+
78
+ function applyUniformScale(nextScale) {
79
+ uniformScale = nextScale;
80
+ applyScale(nextScale);
81
+ updateInputs();
82
+ }
83
+
84
+ function setScale(scale) {
85
+ const nextScale = normalizeUniformScale(scale);
86
+ if (nextScale === null) {
87
+ return false;
88
+ }
89
+
90
+ applyUniformScale(nextScale);
91
+ return true;
92
+ }
93
+
94
+ function setScaleExponent(exponent) {
95
+ const exponentNumber = Number(exponent);
96
+ if (!Number.isFinite(exponentNumber)) {
97
+ updateInputs();
98
+ return false;
99
+ }
100
+
101
+ const nextScale = exponentToUniformScale(exponentNumber);
102
+ if (!Number.isFinite(nextScale) || nextScale <= 0) {
103
+ updateInputs();
104
+ return false;
105
+ }
106
+
107
+ applyUniformScale(nextScale);
108
+ return true;
109
+ }
110
+
111
+ function beginTrackDrag(clientX) {
112
+ trackDragStartClientX = clientX;
113
+ trackDragStartExponent = getScaleExponent();
114
+ uniformScaleTrackEl.style.setProperty('--scale-track-offset', '0px');
115
+ }
116
+
117
+ function setScaleFromTrackClientX(clientX) {
118
+ const deltaX = clientX - trackDragStartClientX;
119
+ uniformScaleTrackEl.style.setProperty(
120
+ '--scale-track-offset',
121
+ `${deltaX}px`,
122
+ );
123
+ return setScaleExponent(
124
+ trackDragStartExponent + deltaX / TRACK_PIXELS_PER_SCALE_EXPONENT,
125
+ );
126
+ }
127
+
128
+ function nudgeScaleExponent(delta) {
129
+ return setScaleExponent(getScaleExponent() + delta);
130
+ }
131
+
132
+ function setScaleValue(value, { commit = false } = {}) {
133
+ if (syncingInputs) {
134
+ return true;
135
+ }
136
+
137
+ const rawValue = typeof value === 'string' ? value.trim() : value;
138
+ if (rawValue === '' && !commit) {
139
+ return false;
140
+ }
141
+
142
+ const scale = normalizeUniformScale(rawValue);
143
+ if (scale === null) {
144
+ if (commit) {
145
+ updateInputs();
146
+ }
147
+ return false;
148
+ }
149
+
150
+ applyUniformScale(scale);
151
+ return true;
152
+ }
153
+
154
+ function initializeInputs() {
155
+ uniformScaleValueInput.removeAttribute('max');
156
+ uniformScaleValueInput.removeAttribute('min');
157
+ uniformScaleValueInput.step = 'any';
158
+ updateInputs();
159
+ }
160
+
161
+ function syncFromRootScale(scale) {
162
+ const nextScale = normalizeUniformScale(scale);
163
+ uniformScale = nextScale === null ? 1 : nextScale;
164
+ updateInputs();
165
+ }
166
+
167
+ return {
168
+ beginTrackDrag,
169
+ formatScale: formatUniformScale,
170
+ getScale: () => uniformScale,
171
+ initializeInputs,
172
+ nudgeScaleExponent,
173
+ setScale,
174
+ setScaleExponent,
175
+ setScaleFromTrackClientX,
176
+ setScaleValue,
177
+ syncFromRootScale,
178
+ };
179
+ }