3dtiles-inspector 0.1.0

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.
@@ -0,0 +1,1389 @@
1
+ import {
2
+ AmbientLight,
3
+ Color,
4
+ MathUtils,
5
+ Group,
6
+ Matrix4,
7
+ PerspectiveCamera,
8
+ Quaternion,
9
+ Raycaster,
10
+ Scene,
11
+ Sphere,
12
+ Vector2,
13
+ Vector3,
14
+ WebGLRenderer,
15
+ } from 'three';
16
+ import { TilesRenderer } from '3d-tiles-renderer';
17
+ import {
18
+ GLTFExtensionsPlugin,
19
+ ImplicitTilingPlugin,
20
+ TileCompressionPlugin,
21
+ TilesFadePlugin,
22
+ UnloadTilesPlugin,
23
+ XYZTilesPlugin,
24
+ } from '3d-tiles-renderer/plugins';
25
+ import {
26
+ CesiumIonAuthPlugin,
27
+ ImageOverlayPlugin,
28
+ QuantizedMeshPlugin,
29
+ XYZTilesOverlay,
30
+ } from '3d-tiles-renderer/three/plugins';
31
+ import { GaussianSplatPlugin } from '3d-tiles-rendererjs-3dgs-plugin';
32
+ import { Ion } from 'cesium';
33
+ import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
34
+ import { KTX2Loader } from 'three/addons/loaders/KTX2Loader.js';
35
+ import { MeshoptDecoder } from 'three/addons/libs/meshopt_decoder.module.js';
36
+ import { TransformControls } from 'three/addons/controls/TransformControls.js';
37
+ import { CameraController } from './cameraController.js';
38
+
39
+ const SAVE_URL = new URL('../__inspector/save-transform', import.meta.url).href;
40
+ const SHUTDOWN_URL = new URL('../__inspector/shutdown', import.meta.url).href;
41
+ const VIEWER_CONFIG =
42
+ globalThis.__TILES_INSPECTOR_CONFIG__ &&
43
+ typeof globalThis.__TILES_INSPECTOR_CONFIG__ === 'object'
44
+ ? globalThis.__TILES_INSPECTOR_CONFIG__
45
+ : {};
46
+ const ROOT_TILESET_LABEL =
47
+ typeof VIEWER_CONFIG.tilesetLabel === 'string' &&
48
+ VIEWER_CONFIG.tilesetLabel.length > 0
49
+ ? VIEWER_CONFIG.tilesetLabel
50
+ : 'tileset.json';
51
+ const TILESET_URL = normalizeLocalResourceUrl(
52
+ VIEWER_CONFIG.tilesetUrl || new URL('../tileset.json', import.meta.url).href,
53
+ );
54
+ const THREE_EXAMPLES_BASE_URL = new URL(
55
+ './vendor/three/examples/jsm/',
56
+ import.meta.url,
57
+ ).href;
58
+ const DRACO_DECODER_PATH = `${THREE_EXAMPLES_BASE_URL}libs/draco/gltf/`;
59
+ const BASIS_TRANSCODER_PATH = `${THREE_EXAMPLES_BASE_URL}libs/basis/`;
60
+ const SATELLITE_IMAGERY = {
61
+ url: 'https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
62
+ levels: 18,
63
+ };
64
+ const CESIUM_ION_TERRAIN = {
65
+ apiToken: Ion.defaultAccessToken,
66
+ assetId: 1,
67
+ };
68
+ const CAMERA_CENTER_MODE_DISTANCE = 3000000;
69
+ const CAMERA_CENTER_MODE_DISTANCE_SQ = CAMERA_CENTER_MODE_DISTANCE ** 2;
70
+ const MOVE_TO_TILES_HEADING = 0;
71
+ const MOVE_TO_TILES_PITCH = MathUtils.degToRad(-30);
72
+ const MOVE_TO_TILES_ROLL = 0;
73
+ const MOVE_TO_COORDINATE_RADIUS = 10;
74
+
75
+ const statusEl = document.getElementById('status');
76
+ const toolbarEl = document.getElementById('toolbar');
77
+ const toolbarDockEl = toolbarEl.parentElement;
78
+ const toolbarToggleButton = document.getElementById('toolbar-toggle');
79
+ const translateButton = document.getElementById('translate');
80
+ const rotateButton = document.getElementById('rotate');
81
+ const moveToTilesButton = document.getElementById('move-to-tiles');
82
+ const terrainButton = document.getElementById('terrain');
83
+ const latitudeInput = document.getElementById('latitude');
84
+ const longitudeInput = document.getElementById('longitude');
85
+ const heightInput = document.getElementById('height');
86
+ const moveCameraToCoordinateButton = document.getElementById(
87
+ 'move-camera-to-coordinate',
88
+ );
89
+ const moveTilesToCoordinateButton = document.getElementById(
90
+ 'move-tiles-to-coordinate',
91
+ );
92
+ const geometricErrorScaleInput = document.getElementById(
93
+ 'geometric-error-scale',
94
+ );
95
+ const geometricErrorValueEl = document.getElementById('geometric-error-value');
96
+ const setPositionButton = document.getElementById('set-position');
97
+ const resetButton = document.getElementById('reset');
98
+ const saveButton = document.getElementById('save');
99
+ const GEOMETRIC_ERROR_SCALE_MIN_EXPONENT = -4;
100
+ const GEOMETRIC_ERROR_SCALE_MAX_EXPONENT = 4;
101
+ const GEOMETRIC_ERROR_SCALE_STEP = 0.1;
102
+ const DEFAULT_ERROR_TARGET = 6;
103
+ const DEFAULT_TERRAIN_ERROR_TARGET = 2;
104
+
105
+ function normalizeLocalResourceUrl(value) {
106
+ if (typeof value !== 'string' || value.length === 0) {
107
+ return value;
108
+ }
109
+
110
+ if (value.startsWith('//')) {
111
+ return `/${value.replace(/^\/+/, '')}`;
112
+ }
113
+
114
+ if (value.startsWith('/')) {
115
+ return value.replace(/\/{2,}/g, '/');
116
+ }
117
+
118
+ if (/^[a-z][a-z\d+.-]*:/i.test(value)) {
119
+ try {
120
+ const parsed = new URL(value);
121
+ if (parsed.origin === window.location.origin) {
122
+ parsed.pathname = parsed.pathname.replace(/\/{2,}/g, '/');
123
+ return parsed.toString();
124
+ }
125
+ } catch (err) {
126
+ return value;
127
+ }
128
+ }
129
+
130
+ return value;
131
+ }
132
+
133
+ function forceOpaqueMaterial(material) {
134
+ if (!material) {
135
+ return;
136
+ }
137
+ if (Array.isArray(material)) {
138
+ material.forEach(forceOpaqueMaterial);
139
+ return;
140
+ }
141
+ material.transparent = false;
142
+ }
143
+
144
+ function forceOpaqueScene(root) {
145
+ root.traverse((child) => {
146
+ if (child.material) {
147
+ forceOpaqueMaterial(child.material);
148
+ }
149
+ });
150
+ }
151
+
152
+ function setStatus(message, isError = false) {
153
+ statusEl.textContent = message;
154
+ statusEl.classList.toggle('error', !!isError);
155
+ }
156
+
157
+ let shutdownRequested = false;
158
+
159
+ function requestViewerShutdown() {
160
+ if (shutdownRequested) {
161
+ return;
162
+ }
163
+ shutdownRequested = true;
164
+
165
+ let sent = false;
166
+ try {
167
+ if (navigator.sendBeacon) {
168
+ sent = navigator.sendBeacon(SHUTDOWN_URL, '');
169
+ }
170
+ } catch (err) {
171
+ sent = false;
172
+ }
173
+
174
+ if (!sent) {
175
+ fetch(SHUTDOWN_URL, {
176
+ method: 'POST',
177
+ body: '',
178
+ keepalive: true,
179
+ }).catch(() => {});
180
+ }
181
+ }
182
+
183
+ function getFiniteMatrix4Array(value, name = 'matrix') {
184
+ if (!Array.isArray(value) || value.length !== 16) {
185
+ throw new Error(`${name} must be a 16-number matrix.`);
186
+ }
187
+
188
+ return value.map((entry, index) => {
189
+ const number = Number(entry);
190
+ if (!Number.isFinite(number)) {
191
+ throw new Error(`${name}[${index}] must be a finite number.`);
192
+ }
193
+ return number;
194
+ });
195
+ }
196
+
197
+ function parseCoordinateInputs() {
198
+ const latitude = Number(latitudeInput.value);
199
+ const longitude = Number(longitudeInput.value);
200
+ const height = Number(heightInput.value);
201
+
202
+ if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
203
+ setStatus('Latitude and longitude must be valid numbers.', true);
204
+ return null;
205
+ }
206
+
207
+ if (!Number.isFinite(height)) {
208
+ setStatus('Height must be a valid number.', true);
209
+ return null;
210
+ }
211
+
212
+ if (latitude < -90 || latitude > 90) {
213
+ setStatus('Latitude must be in [-90, 90].', true);
214
+ return null;
215
+ }
216
+
217
+ if (longitude < -180 || longitude > 180) {
218
+ setStatus('Longitude must be in [-180, 180].', true);
219
+ return null;
220
+ }
221
+
222
+ return {
223
+ height,
224
+ latitude,
225
+ longitude,
226
+ };
227
+ }
228
+
229
+ function updateModeButtons(mode) {
230
+ translateButton.classList.toggle('active', mode === 'translate');
231
+ rotateButton.classList.toggle('active', mode === 'rotate');
232
+ }
233
+
234
+ function composeMatrix(target, matrix) {
235
+ matrix.decompose(target.position, target.quaternion, target.scale);
236
+ target.updateMatrix();
237
+ target.updateMatrixWorld(true);
238
+ }
239
+
240
+ function formatCoordinateInputValue(value, digits) {
241
+ return Number.isFinite(value) ? value.toFixed(digits) : '';
242
+ }
243
+
244
+ function clamp(value, min, max) {
245
+ return Math.min(Math.max(value, min), max);
246
+ }
247
+
248
+ function exponentToGeometricErrorScale(exponent) {
249
+ return 2 ** exponent;
250
+ }
251
+
252
+ function formatGeometricErrorScale(value) {
253
+ if (value < 0.1) {
254
+ return value.toFixed(3);
255
+ }
256
+
257
+ return value.toFixed(2);
258
+ }
259
+
260
+ function setRaycasterFromCamera(raycaster, coords, camera) {
261
+ const { origin, direction } = raycaster.ray;
262
+ const nearZ = camera.reversedDepth ? 1 : -1;
263
+ const farZ = camera.reversedDepth ? 0 : 1;
264
+
265
+ origin.set(coords.x, coords.y, nearZ).unproject(camera);
266
+ direction.set(coords.x, coords.y, farZ).unproject(camera).sub(origin);
267
+ raycaster.near = 0;
268
+ raycaster.far = direction.length();
269
+ raycaster.camera = camera;
270
+ direction.normalize();
271
+ }
272
+
273
+ function mouseToCoords(clientX, clientY, element, target) {
274
+ const rect = element.getBoundingClientRect();
275
+ target.x = ((clientX - rect.left) / rect.width) * 2 - 1;
276
+ target.y = -((clientY - rect.top) / rect.height) * 2 + 1;
277
+ }
278
+
279
+ const renderer = new WebGLRenderer({
280
+ antialias: false,
281
+ alpha: true,
282
+ premultipliedAlpha: true,
283
+ reversedDepthBuffer: true,
284
+ });
285
+ renderer.setPixelRatio(window.devicePixelRatio);
286
+ renderer.setSize(window.innerWidth, window.innerHeight);
287
+ document.getElementById('app').appendChild(renderer.domElement);
288
+
289
+ const dracoLoader = new DRACOLoader();
290
+ dracoLoader.setDecoderPath(DRACO_DECODER_PATH);
291
+
292
+ const ktx2Loader = new KTX2Loader();
293
+ ktx2Loader.setTranscoderPath(BASIS_TRANSCODER_PATH);
294
+ ktx2Loader.detectSupport(renderer);
295
+
296
+ const scene = new Scene();
297
+ scene.background = new Color(0xffffff);
298
+
299
+ const terrainLight = new AmbientLight(0xffffff, Math.PI);
300
+ terrainLight.visible = false;
301
+ scene.add(terrainLight);
302
+
303
+ const camera = new PerspectiveCamera(
304
+ 60,
305
+ window.innerWidth / window.innerHeight,
306
+ 1,
307
+ 2e7,
308
+ );
309
+ camera.position.set(0, 0, 1.75e7);
310
+ camera.updateMatrixWorld(true);
311
+
312
+ const contentGroup = new Group();
313
+ scene.add(contentGroup);
314
+
315
+ const globeGroup = new Group();
316
+ contentGroup.add(globeGroup);
317
+
318
+ const editableGroup = new Group();
319
+ contentGroup.add(editableGroup);
320
+ const transformHandle = new Group();
321
+ scene.add(transformHandle);
322
+
323
+ const cameraController = new CameraController(renderer, contentGroup, camera);
324
+ let globeTiles = null;
325
+ let terrainEnabled = true;
326
+
327
+ function configureGlobeTiles(next) {
328
+ next.registerPlugin(new TilesFadePlugin());
329
+ next.registerPlugin(new TileCompressionPlugin());
330
+ next.registerPlugin(new UnloadTilesPlugin());
331
+ next.preprocessURL = normalizeLocalResourceUrl;
332
+ next.setCamera(camera);
333
+ next.setResolutionFromRenderer(camera, renderer);
334
+ next.addEventListener('load-model', ({ scene: modelScene }) => {
335
+ forceOpaqueScene(modelScene);
336
+ });
337
+ return next;
338
+ }
339
+
340
+ function createImageryGlobeTiles() {
341
+ const next = new TilesRenderer();
342
+ next.downloadQueue.maxJobs = 8;
343
+ next.parseQueue.maxJobs = 2;
344
+ next.registerPlugin(
345
+ new XYZTilesPlugin({
346
+ shape: 'ellipsoid',
347
+ center: true,
348
+ levels: SATELLITE_IMAGERY.levels,
349
+ url: SATELLITE_IMAGERY.url,
350
+ }),
351
+ );
352
+ configureGlobeTiles(next);
353
+ next.errorTarget = DEFAULT_ERROR_TARGET;
354
+ return next;
355
+ }
356
+
357
+ function createTerrainGlobeTiles() {
358
+ const next = new TilesRenderer();
359
+ next.downloadQueue.maxJobs = 8;
360
+ next.parseQueue.maxJobs = 2;
361
+ next.registerPlugin(
362
+ new CesiumIonAuthPlugin({
363
+ apiToken: CESIUM_ION_TERRAIN.apiToken,
364
+ assetId: String(CESIUM_ION_TERRAIN.assetId),
365
+ autoRefreshToken: true,
366
+ assetTypeHandler: (type, tilesRenderer) => {
367
+ if (type === 'TERRAIN') {
368
+ tilesRenderer.registerPlugin(new QuantizedMeshPlugin({}));
369
+ }
370
+ },
371
+ }),
372
+ );
373
+ next.registerPlugin(
374
+ new ImageOverlayPlugin({
375
+ renderer,
376
+ overlays: [
377
+ new XYZTilesOverlay({
378
+ url: SATELLITE_IMAGERY.url,
379
+ levels: SATELLITE_IMAGERY.levels,
380
+ tileDimension: 256,
381
+ projection: 'EPSG:3857',
382
+ color: 0xffffff,
383
+ opacity: 1,
384
+ }),
385
+ ],
386
+ }),
387
+ );
388
+ configureGlobeTiles(next);
389
+ next.errorTarget = DEFAULT_TERRAIN_ERROR_TARGET;
390
+ return next;
391
+ }
392
+
393
+ const transformControls = new TransformControls(camera, renderer.domElement);
394
+ const transformControlsHelper =
395
+ typeof transformControls.getHelper === 'function'
396
+ ? transformControls.getHelper()
397
+ : null;
398
+ transformControls.setMode('translate');
399
+ transformControls.setSpace('local');
400
+ transformControls.size = 0.95;
401
+ transformControls.addEventListener('dragging-changed', ({ value }) => {
402
+ cameraController.enabled = !value;
403
+ });
404
+ transformControls.addEventListener('objectChange', () => {
405
+ if (syncingTransformHandle) {
406
+ return;
407
+ }
408
+
409
+ transformHandle.updateMatrix();
410
+ transformHandle.updateMatrixWorld(true);
411
+ applyEditableGroupMatrixFromRootTransform(transformHandle.matrix);
412
+ syncCoordinateInputsFromTilesTransform();
413
+ });
414
+ if (transformControlsHelper) {
415
+ scene.add(transformControlsHelper);
416
+ }
417
+
418
+ const sphere = new Sphere();
419
+ const worldRight = new Vector3(1, 0, 0);
420
+ const centerNorth = new Vector3(0, 1, 0);
421
+ const cartographicTarget = {
422
+ height: 0,
423
+ lat: 0,
424
+ lon: 0,
425
+ };
426
+ const moveToTilesBasis = new Matrix4();
427
+ const moveToTilesPosition = new Vector3();
428
+ const moveToTilesEast = new Vector3();
429
+ const moveToTilesNorth = new Vector3();
430
+ const moveToTilesUp = new Vector3();
431
+ const moveToTilesForward = new Vector3();
432
+ const moveToTilesRight = new Vector3();
433
+ const moveToTilesBackward = new Vector3();
434
+ const moveToTilesQuaternion = new Quaternion();
435
+ const coordinateWorldPosition = new Vector3();
436
+ const coordinateTransformMatrix = new Matrix4();
437
+ const coordinateEditMatrix = new Matrix4();
438
+ const currentRootTransformMatrix = new Matrix4();
439
+ const savedRootInverseMatrix = new Matrix4();
440
+ const pointerCoords = new Vector2();
441
+ const pickRaycaster = new Raycaster();
442
+ const pickTargets = [];
443
+ let tiles = null;
444
+ let toolbarVisible = true;
445
+ let activeTransformMode = null;
446
+ let geometricErrorScaleExponent = 0;
447
+ let geometricErrorScale = 1;
448
+ let lastSavedGeometricErrorScale = 1;
449
+ let lastSavedMatrix = new Matrix4();
450
+ const savedRootMatrix = new Matrix4();
451
+ let savedRootMatrixPromise = Promise.resolve();
452
+ let savedRootMatrixLoadError = null;
453
+ let pendingSetPosition = false;
454
+ let syncingTransformHandle = false;
455
+ let tilesTransformDirty = false;
456
+
457
+ function getActiveEllipsoid() {
458
+ return tiles?.ellipsoid || globeTiles?.ellipsoid || null;
459
+ }
460
+
461
+ function updateTilesetErrorTarget() {
462
+ if (!tiles) {
463
+ return;
464
+ }
465
+
466
+ tiles.errorTarget = DEFAULT_ERROR_TARGET / geometricErrorScale;
467
+ }
468
+
469
+ function updateGeometricErrorScaleDisplay() {
470
+ geometricErrorValueEl.textContent = `x${formatGeometricErrorScale(
471
+ geometricErrorScale,
472
+ )}`;
473
+ }
474
+
475
+ function setGeometricErrorScaleExponent(exponent) {
476
+ geometricErrorScaleExponent = clamp(
477
+ Number(exponent),
478
+ GEOMETRIC_ERROR_SCALE_MIN_EXPONENT,
479
+ GEOMETRIC_ERROR_SCALE_MAX_EXPONENT,
480
+ );
481
+ geometricErrorScale = exponentToGeometricErrorScale(
482
+ geometricErrorScaleExponent,
483
+ );
484
+ geometricErrorScaleInput.value = geometricErrorScaleExponent.toFixed(1);
485
+ updateGeometricErrorScaleDisplay();
486
+ updateTilesetErrorTarget();
487
+ }
488
+
489
+ function syncTerrainButton() {
490
+ terrainButton.classList.toggle('active', terrainEnabled);
491
+ terrainLight.visible = terrainEnabled;
492
+ }
493
+
494
+ function syncToolbarVisibility() {
495
+ const sidebarLabel = toolbarVisible ? 'Hide Sidebar' : 'Show Sidebar';
496
+ toolbarDockEl.classList.toggle('expanded', toolbarVisible);
497
+ toolbarDockEl.classList.toggle('collapsed', !toolbarVisible);
498
+ toolbarEl.classList.toggle('hidden', !toolbarVisible);
499
+ toolbarToggleButton.textContent = sidebarLabel;
500
+ toolbarToggleButton.setAttribute('aria-label', sidebarLabel);
501
+ toolbarToggleButton.setAttribute('aria-expanded', String(toolbarVisible));
502
+ }
503
+
504
+ function toggleToolbarVisibility() {
505
+ toolbarVisible = !toolbarVisible;
506
+ syncToolbarVisibility();
507
+ }
508
+
509
+ function setTerrainEnabled(enabled) {
510
+ const next = enabled ? createTerrainGlobeTiles() : createImageryGlobeTiles();
511
+
512
+ if (globeTiles) {
513
+ globeGroup.remove(globeTiles.group);
514
+ globeTiles.dispose();
515
+ }
516
+
517
+ terrainEnabled = enabled;
518
+ globeTiles = next;
519
+ globeGroup.add(next.group);
520
+ cameraController.setEllipsoid(getActiveEllipsoid());
521
+ syncTerrainButton();
522
+ }
523
+
524
+ function syncTransformControlsState() {
525
+ const controlsVisible = activeTransformMode !== null && !pendingSetPosition;
526
+ if (controlsVisible) {
527
+ if (transformControls.object !== transformHandle) {
528
+ transformControls.attach(transformHandle);
529
+ }
530
+ } else if (transformControls.object) {
531
+ transformControls.detach();
532
+ }
533
+ transformControls.enabled = controlsVisible;
534
+ if (transformControlsHelper) {
535
+ transformControlsHelper.visible = controlsVisible;
536
+ transformControlsHelper.updateMatrixWorld(true);
537
+ }
538
+ }
539
+
540
+ function setTransformMode(mode) {
541
+ activeTransformMode = mode;
542
+ if (mode !== null) {
543
+ transformControls.setMode(mode);
544
+ }
545
+ updateModeButtons(mode);
546
+ syncTransformControlsState();
547
+ }
548
+
549
+ function toggleTransformMode(mode) {
550
+ setTransformMode(activeTransformMode === mode ? null : mode);
551
+ }
552
+
553
+ geometricErrorScaleInput.min = String(GEOMETRIC_ERROR_SCALE_MIN_EXPONENT);
554
+ geometricErrorScaleInput.max = String(GEOMETRIC_ERROR_SCALE_MAX_EXPONENT);
555
+ geometricErrorScaleInput.step = String(GEOMETRIC_ERROR_SCALE_STEP);
556
+ setGeometricErrorScaleExponent(geometricErrorScaleExponent);
557
+ setTerrainEnabled(terrainEnabled);
558
+ setTransformMode(activeTransformMode);
559
+ syncToolbarVisibility();
560
+
561
+ function applySavedMatrix(matrix) {
562
+ composeMatrix(editableGroup, matrix);
563
+ invalidateTilesetTransforms();
564
+ syncTransformHandleFromTilesTransform();
565
+ syncCoordinateInputsFromTilesTransform();
566
+ }
567
+
568
+ function getCurrentMatrix() {
569
+ editableGroup.updateMatrix();
570
+ editableGroup.updateMatrixWorld(true);
571
+ return editableGroup.matrix.clone();
572
+ }
573
+
574
+ function getCurrentRootTransform(target) {
575
+ editableGroup.updateMatrix();
576
+ editableGroup.updateMatrixWorld(true);
577
+ return target
578
+ .copy(editableGroup.matrix)
579
+ .multiply(savedRootInverseMatrix.copy(lastSavedMatrix).invert())
580
+ .multiply(savedRootMatrix);
581
+ }
582
+
583
+ function updateTilesRendererGroupMatrices(tilesRenderer) {
584
+ const group = tilesRenderer?.group;
585
+ if (!group) {
586
+ return;
587
+ }
588
+
589
+ group.updateMatrixWorld(true);
590
+
591
+ if (
592
+ group.matrixWorldInverse &&
593
+ typeof group.matrixWorldInverse.copy === 'function'
594
+ ) {
595
+ group.matrixWorldInverse.copy(group.matrixWorld).invert();
596
+ }
597
+ }
598
+
599
+ function refreshLoadedTileSceneMatrices(tilesRenderer) {
600
+ if (
601
+ !tilesRenderer ||
602
+ typeof tilesRenderer.forEachLoadedModel !== 'function'
603
+ ) {
604
+ return;
605
+ }
606
+
607
+ tilesRenderer.forEachLoadedModel((loadedScene) => {
608
+ if (typeof loadedScene.updateWorldMatrix === 'function') {
609
+ loadedScene.updateWorldMatrix(false, true);
610
+ } else {
611
+ loadedScene.updateMatrixWorld(true);
612
+ }
613
+ });
614
+ }
615
+
616
+ function invalidateTilesetTransforms() {
617
+ tilesTransformDirty = true;
618
+ editableGroup.updateMatrixWorld(true);
619
+ updateTilesRendererGroupMatrices(tiles);
620
+ refreshLoadedTileSceneMatrices(tiles);
621
+ transformControlsHelper?.updateMatrixWorld(true);
622
+ }
623
+
624
+ function applyEditableGroupMatrixFromRootTransform(rootTransform) {
625
+ coordinateEditMatrix
626
+ .copy(rootTransform)
627
+ .multiply(savedRootInverseMatrix.copy(savedRootMatrix).invert())
628
+ .multiply(lastSavedMatrix);
629
+ composeMatrix(editableGroup, coordinateEditMatrix);
630
+ invalidateTilesetTransforms();
631
+ }
632
+
633
+ function syncTransformHandleFromTilesTransform() {
634
+ syncingTransformHandle = true;
635
+ try {
636
+ composeMatrix(
637
+ transformHandle,
638
+ getCurrentRootTransform(currentRootTransformMatrix),
639
+ );
640
+ transformHandle.updateMatrixWorld(true);
641
+ transformControlsHelper?.updateMatrixWorld(true);
642
+ } finally {
643
+ syncingTransformHandle = false;
644
+ }
645
+ }
646
+
647
+ function resetEditableGroup() {
648
+ editableGroup.position.set(0, 0, 0);
649
+ editableGroup.quaternion.identity();
650
+ editableGroup.scale.set(1, 1, 1);
651
+ editableGroup.updateMatrix();
652
+ editableGroup.updateMatrixWorld(true);
653
+ lastSavedMatrix.identity();
654
+ transformHandle.position.set(0, 0, 0);
655
+ transformHandle.quaternion.identity();
656
+ transformHandle.scale.set(1, 1, 1);
657
+ transformHandle.updateMatrix();
658
+ transformHandle.updateMatrixWorld(true);
659
+ syncTransformControlsState();
660
+ tilesTransformDirty = true;
661
+ }
662
+
663
+ function getTilesetWorldBoundingSphere() {
664
+ if (!tiles || !tiles.getBoundingSphere(sphere)) {
665
+ return false;
666
+ }
667
+
668
+ editableGroup.updateMatrixWorld(true);
669
+ sphere.center.applyMatrix4(editableGroup.matrixWorld);
670
+ sphere.radius *= editableGroup.matrixWorld.getMaxScaleOnAxis();
671
+ return true;
672
+ }
673
+
674
+ function getCameraDistanceForBoundingSphere(radius) {
675
+ const verticalHalfFov = MathUtils.degToRad(camera.fov) * 0.5;
676
+ const horizontalHalfFov = Math.atan(
677
+ Math.tan(verticalHalfFov) * camera.aspect,
678
+ );
679
+ const limitingHalfFov = Math.max(
680
+ Math.min(verticalHalfFov, horizontalHalfFov),
681
+ 1e-3,
682
+ );
683
+
684
+ return Math.max(radius / Math.sin(limitingHalfFov), 1);
685
+ }
686
+
687
+ function isCenterModePosition(position) {
688
+ return position.lengthSq() <= CAMERA_CENTER_MODE_DISTANCE_SQ;
689
+ }
690
+
691
+ function getLocalFrame(referencePoint) {
692
+ const ellipsoid = getActiveEllipsoid();
693
+ ellipsoid.getPositionToCartographic(referencePoint, cartographicTarget);
694
+ ellipsoid.getEastNorthUpFrame(
695
+ cartographicTarget.lat,
696
+ cartographicTarget.lon,
697
+ cartographicTarget.height,
698
+ moveToTilesBasis,
699
+ );
700
+ moveToTilesEast.setFromMatrixColumn(moveToTilesBasis, 0).normalize();
701
+ moveToTilesNorth.setFromMatrixColumn(moveToTilesBasis, 1).normalize();
702
+ moveToTilesUp.setFromMatrixColumn(moveToTilesBasis, 2).normalize();
703
+ }
704
+
705
+ function getCoordinateWorldPosition(latitude, longitude, height, target) {
706
+ const ellipsoid = getActiveEllipsoid();
707
+ return ellipsoid.getCartographicToPosition(
708
+ MathUtils.degToRad(latitude),
709
+ MathUtils.degToRad(longitude),
710
+ height,
711
+ target,
712
+ );
713
+ }
714
+
715
+ function getCoordinateTransform(latitude, longitude, height, target) {
716
+ const ellipsoid = getActiveEllipsoid();
717
+ return ellipsoid.getEastNorthUpFrame(
718
+ MathUtils.degToRad(latitude),
719
+ MathUtils.degToRad(longitude),
720
+ height,
721
+ target,
722
+ );
723
+ }
724
+
725
+ async function refreshSavedRootMatrix(url) {
726
+ const response = await fetch(url, { cache: 'no-store' });
727
+ if (!response.ok) {
728
+ throw new Error(
729
+ `Failed to load ${ROOT_TILESET_LABEL} metadata for coordinate placement (${response.status}).`,
730
+ );
731
+ }
732
+
733
+ const payload = await response.json();
734
+ savedRootMatrix.identity();
735
+
736
+ const rootTransform = payload?.root?.transform;
737
+ if (rootTransform != null) {
738
+ savedRootMatrix.fromArray(
739
+ getFiniteMatrix4Array(rootTransform, 'tileset.root.transform'),
740
+ );
741
+ }
742
+
743
+ return savedRootMatrix;
744
+ }
745
+
746
+ function syncCoordinateInputsFromTilesTransform() {
747
+ if (savedRootMatrixLoadError) {
748
+ return;
749
+ }
750
+
751
+ const ellipsoid = getActiveEllipsoid();
752
+ getCurrentRootTransform(currentRootTransformMatrix);
753
+ coordinateWorldPosition.setFromMatrixPosition(currentRootTransformMatrix);
754
+ ellipsoid.getPositionToCartographic(
755
+ coordinateWorldPosition,
756
+ cartographicTarget,
757
+ );
758
+
759
+ latitudeInput.value = formatCoordinateInputValue(
760
+ MathUtils.radToDeg(cartographicTarget.lat),
761
+ 8,
762
+ );
763
+ longitudeInput.value = formatCoordinateInputValue(
764
+ MathUtils.radToDeg(cartographicTarget.lon),
765
+ 8,
766
+ );
767
+ heightInput.value = formatCoordinateInputValue(cartographicTarget.height, 3);
768
+ }
769
+
770
+ function updateCoordinateInputs(latitude, longitude, height) {
771
+ latitudeInput.value = formatCoordinateInputValue(latitude, 8);
772
+ longitudeInput.value = formatCoordinateInputValue(longitude, 8);
773
+ heightInput.value = formatCoordinateInputValue(height, 3);
774
+ }
775
+
776
+ function raycastPickWorldPosition(target) {
777
+ pickTargets.length = 0;
778
+
779
+ if (tiles?.group) {
780
+ pickTargets.push(tiles.group);
781
+ }
782
+
783
+ if (globeTiles?.group) {
784
+ pickTargets.push(globeTiles.group);
785
+ }
786
+
787
+ if (pickTargets.length === 0) {
788
+ return false;
789
+ }
790
+
791
+ for (const root of pickTargets) {
792
+ root.updateMatrixWorld(true);
793
+ }
794
+
795
+ const [hit] = pickRaycaster.intersectObjects(pickTargets, true);
796
+ if (!hit) {
797
+ return false;
798
+ }
799
+
800
+ target.copy(hit.point);
801
+ return true;
802
+ }
803
+
804
+ function setSetPositionMode(active) {
805
+ pendingSetPosition = active;
806
+ setPositionButton.classList.toggle('active', active);
807
+ cameraController.enabled = !active;
808
+ syncTransformControlsState();
809
+ }
810
+
811
+ function cancelSetPositionMode() {
812
+ if (!pendingSetPosition) {
813
+ return;
814
+ }
815
+
816
+ setSetPositionMode(false);
817
+ }
818
+
819
+ async function applyTilesPlacementFromCoordinate(latitude, longitude, height) {
820
+ await savedRootMatrixPromise;
821
+ if (savedRootMatrixLoadError) {
822
+ throw savedRootMatrixLoadError;
823
+ }
824
+
825
+ getCoordinateTransform(
826
+ latitude,
827
+ longitude,
828
+ height,
829
+ coordinateTransformMatrix,
830
+ );
831
+ coordinateEditMatrix
832
+ .copy(coordinateTransformMatrix)
833
+ .multiply(savedRootInverseMatrix.copy(savedRootMatrix).invert())
834
+ .multiply(lastSavedMatrix);
835
+ composeMatrix(editableGroup, coordinateEditMatrix);
836
+ invalidateTilesetTransforms();
837
+ syncTransformHandleFromTilesTransform();
838
+ syncCoordinateInputsFromTilesTransform();
839
+ }
840
+
841
+ function pickCoordinateFromPointerEvent(event) {
842
+ const ellipsoid = getActiveEllipsoid();
843
+ if (!ellipsoid) {
844
+ return null;
845
+ }
846
+
847
+ mouseToCoords(
848
+ event.clientX,
849
+ event.clientY,
850
+ renderer.domElement,
851
+ pointerCoords,
852
+ );
853
+ setRaycasterFromCamera(pickRaycaster, pointerCoords, camera);
854
+
855
+ if (
856
+ !raycastPickWorldPosition(coordinateWorldPosition) &&
857
+ !ellipsoid.intersectRay(pickRaycaster.ray, coordinateWorldPosition)
858
+ ) {
859
+ return null;
860
+ }
861
+
862
+ ellipsoid.getPositionToCartographic(
863
+ coordinateWorldPosition,
864
+ cartographicTarget,
865
+ );
866
+ return {
867
+ height: cartographicTarget.height,
868
+ latitude: MathUtils.radToDeg(cartographicTarget.lat),
869
+ longitude: MathUtils.radToDeg(cartographicTarget.lon),
870
+ };
871
+ }
872
+
873
+ async function handleSetPositionPointerDown(event) {
874
+ if (!pendingSetPosition) {
875
+ return;
876
+ }
877
+
878
+ if (event.pointerType === 'mouse' && event.button !== 0) {
879
+ return;
880
+ }
881
+
882
+ event.preventDefault();
883
+ event.stopPropagation();
884
+
885
+ const coordinate = pickCoordinateFromPointerEvent(event);
886
+ if (!coordinate) {
887
+ setStatus(
888
+ 'No globe, terrain, or tiles hit under cursor. Click the globe, terrain, or tiles to place the tileset root.',
889
+ true,
890
+ );
891
+ return;
892
+ }
893
+
894
+ updateCoordinateInputs(
895
+ coordinate.latitude,
896
+ coordinate.longitude,
897
+ coordinate.height,
898
+ );
899
+
900
+ try {
901
+ await applyTilesPlacementFromCoordinate(
902
+ coordinate.latitude,
903
+ coordinate.longitude,
904
+ coordinate.height,
905
+ );
906
+ setStatus(
907
+ 'Moved tileset root to the clicked position using ENU orientation. Click Save to persist.',
908
+ );
909
+ setSetPositionMode(false);
910
+ } catch (err) {
911
+ setStatus(err && err.message ? err.message : String(err), true);
912
+ }
913
+ }
914
+
915
+ function getCenterModeHeadingPitchRollForward(heading, pitch) {
916
+ const cosPitch = Math.cos(pitch);
917
+ const sinPitch = Math.sin(pitch);
918
+ const cosHeading = Math.cos(heading);
919
+ const sinHeading = Math.sin(heading);
920
+
921
+ moveToTilesForward.set(
922
+ sinHeading * cosPitch,
923
+ cosHeading * cosPitch,
924
+ sinPitch,
925
+ );
926
+
927
+ return moveToTilesForward.normalize();
928
+ }
929
+
930
+ function getHeadingPitchRollForward(referencePoint, heading, pitch) {
931
+ if (isCenterModePosition(referencePoint)) {
932
+ return getCenterModeHeadingPitchRollForward(heading, pitch);
933
+ }
934
+
935
+ if (referencePoint.lengthSq() < 1e-6) {
936
+ return moveToTilesForward.set(0, 0, -1);
937
+ }
938
+
939
+ getLocalFrame(referencePoint);
940
+
941
+ const cosPitch = Math.cos(pitch);
942
+ const sinPitch = Math.sin(pitch);
943
+ const cosHeading = Math.cos(heading);
944
+ const sinHeading = Math.sin(heading);
945
+
946
+ moveToTilesForward
947
+ .copy(moveToTilesNorth)
948
+ .multiplyScalar(cosHeading * cosPitch)
949
+ .addScaledVector(moveToTilesEast, sinHeading * cosPitch)
950
+ .addScaledVector(moveToTilesUp, sinPitch)
951
+ .normalize();
952
+
953
+ return moveToTilesForward;
954
+ }
955
+
956
+ function getCenterModeHeadingPitchRollBasis(heading, pitch, roll) {
957
+ getCenterModeHeadingPitchRollForward(heading, pitch);
958
+
959
+ moveToTilesRight
960
+ .copy(worldRight)
961
+ .multiplyScalar(Math.cos(heading))
962
+ .addScaledVector(centerNorth, -Math.sin(heading))
963
+ .normalize();
964
+ moveToTilesUp.crossVectors(moveToTilesRight, moveToTilesForward).normalize();
965
+
966
+ if (roll !== 0) {
967
+ moveToTilesRight.applyAxisAngle(moveToTilesForward, roll).normalize();
968
+ moveToTilesUp.applyAxisAngle(moveToTilesForward, roll).normalize();
969
+ }
970
+
971
+ moveToTilesBackward.copy(moveToTilesForward).negate();
972
+ }
973
+
974
+ function getHeadingPitchRollQuaternion(referencePoint, heading, pitch, roll) {
975
+ if (isCenterModePosition(referencePoint)) {
976
+ getCenterModeHeadingPitchRollBasis(heading, pitch, roll);
977
+ } else if (referencePoint.lengthSq() < 1e-6) {
978
+ moveToTilesQuaternion.identity();
979
+ return moveToTilesQuaternion;
980
+ } else {
981
+ getHeadingPitchRollForward(referencePoint, heading, pitch);
982
+ moveToTilesRight
983
+ .copy(moveToTilesEast)
984
+ .multiplyScalar(Math.cos(heading))
985
+ .addScaledVector(moveToTilesNorth, -Math.sin(heading))
986
+ .normalize();
987
+ moveToTilesUp
988
+ .crossVectors(moveToTilesRight, moveToTilesForward)
989
+ .normalize();
990
+
991
+ if (roll !== 0) {
992
+ moveToTilesRight.applyAxisAngle(moveToTilesForward, roll).normalize();
993
+ moveToTilesUp.applyAxisAngle(moveToTilesForward, roll).normalize();
994
+ }
995
+
996
+ moveToTilesBackward.copy(moveToTilesForward).negate();
997
+ }
998
+
999
+ moveToTilesBasis.makeBasis(
1000
+ moveToTilesRight,
1001
+ moveToTilesUp,
1002
+ moveToTilesBackward,
1003
+ );
1004
+ return moveToTilesQuaternion.setFromRotationMatrix(moveToTilesBasis);
1005
+ }
1006
+
1007
+ function getBoundingSphereFlyToPosition(target, range, options) {
1008
+ const { heading, pitch } = options;
1009
+ if (heading === undefined && pitch === undefined) {
1010
+ const direction =
1011
+ target.lengthSq() > 1e-6
1012
+ ? moveToTilesPosition.copy(target).normalize()
1013
+ : camera.position.lengthSq() > 1e-6
1014
+ ? moveToTilesPosition.copy(camera.position).normalize()
1015
+ : moveToTilesPosition.set(0, -1, 0);
1016
+ return direction.multiplyScalar(range).add(target);
1017
+ }
1018
+
1019
+ const resolvedHeading = heading ?? 0;
1020
+ const resolvedPitch = pitch ?? -Math.PI / 2;
1021
+ const centerForward = getCenterModeHeadingPitchRollForward(
1022
+ resolvedHeading,
1023
+ resolvedPitch,
1024
+ );
1025
+ const centerPosition = moveToTilesPosition
1026
+ .copy(target)
1027
+ .addScaledVector(centerForward, -range);
1028
+ if (isCenterModePosition(centerPosition)) {
1029
+ return centerPosition;
1030
+ }
1031
+
1032
+ const forward = getHeadingPitchRollForward(
1033
+ target,
1034
+ resolvedHeading,
1035
+ resolvedPitch,
1036
+ );
1037
+ return moveToTilesPosition.copy(target).addScaledVector(forward, -range);
1038
+ }
1039
+
1040
+ function getFlyToPoseFromBoundingSphere(target, radius, options) {
1041
+ const safeRadius = Math.max(radius, 1);
1042
+ let offsetDistance = safeRadius;
1043
+
1044
+ if (camera instanceof PerspectiveCamera) {
1045
+ const verticalFov = MathUtils.degToRad(camera.fov);
1046
+ const horizontalFov =
1047
+ 2 * Math.atan(Math.tan(verticalFov / 2) * camera.aspect);
1048
+ const minHalfFov = Math.max(0.1, Math.min(verticalFov, horizontalFov) / 2);
1049
+ offsetDistance = safeRadius / Math.sin(minHalfFov) + safeRadius * 0.75;
1050
+ } else {
1051
+ offsetDistance = getCameraDistanceForBoundingSphere(safeRadius);
1052
+ }
1053
+
1054
+ const position = getBoundingSphereFlyToPosition(
1055
+ target,
1056
+ offsetDistance,
1057
+ options,
1058
+ );
1059
+ const quaternion = getHeadingPitchRollQuaternion(
1060
+ isCenterModePosition(position) ? position : target,
1061
+ options.heading ?? 0,
1062
+ options.pitch ?? -Math.PI / 2,
1063
+ options.roll ?? 0,
1064
+ );
1065
+
1066
+ return {
1067
+ position,
1068
+ quaternion,
1069
+ };
1070
+ }
1071
+
1072
+ function frameTileset() {
1073
+ if (!getTilesetWorldBoundingSphere()) {
1074
+ return false;
1075
+ }
1076
+
1077
+ const pose = getFlyToPoseFromBoundingSphere(sphere.center, sphere.radius, {
1078
+ heading: MOVE_TO_TILES_HEADING,
1079
+ pitch: MOVE_TO_TILES_PITCH,
1080
+ roll: MOVE_TO_TILES_ROLL,
1081
+ });
1082
+ camera.position.copy(pose.position);
1083
+ camera.quaternion.copy(pose.quaternion);
1084
+ camera.updateMatrixWorld(true);
1085
+ cameraController.setCamera(camera);
1086
+ return true;
1087
+ }
1088
+
1089
+ function moveCameraToTiles() {
1090
+ cancelSetPositionMode();
1091
+ if (frameTileset()) {
1092
+ setStatus('Moved camera to the tileset.');
1093
+ } else {
1094
+ setStatus('Tileset is not ready to frame yet.', true);
1095
+ }
1096
+ }
1097
+
1098
+ function toggleSetPositionMode() {
1099
+ if (pendingSetPosition) {
1100
+ setSetPositionMode(false);
1101
+ setStatus('Set Position cancelled.');
1102
+ return;
1103
+ }
1104
+
1105
+ setSetPositionMode(true);
1106
+ setStatus('Click the globe, terrain, or tiles to place the tileset root.');
1107
+ }
1108
+
1109
+ function moveCameraToCoordinate() {
1110
+ cancelSetPositionMode();
1111
+ const coordinate = parseCoordinateInputs();
1112
+ if (!coordinate) {
1113
+ return;
1114
+ }
1115
+
1116
+ getCoordinateWorldPosition(
1117
+ coordinate.latitude,
1118
+ coordinate.longitude,
1119
+ coordinate.height,
1120
+ coordinateWorldPosition,
1121
+ );
1122
+ const pose = getFlyToPoseFromBoundingSphere(
1123
+ coordinateWorldPosition,
1124
+ MOVE_TO_COORDINATE_RADIUS,
1125
+ {
1126
+ heading: MOVE_TO_TILES_HEADING,
1127
+ pitch: MOVE_TO_TILES_PITCH,
1128
+ roll: MOVE_TO_TILES_ROLL,
1129
+ },
1130
+ );
1131
+ camera.position.copy(pose.position);
1132
+ camera.quaternion.copy(pose.quaternion);
1133
+ camera.updateMatrixWorld(true);
1134
+ cameraController.setCamera(camera);
1135
+ setStatus('Moved camera to the specified coordinate.');
1136
+ }
1137
+
1138
+ async function moveTilesToCoordinate() {
1139
+ cancelSetPositionMode();
1140
+ const coordinate = parseCoordinateInputs();
1141
+ if (!coordinate) {
1142
+ return;
1143
+ }
1144
+
1145
+ try {
1146
+ await applyTilesPlacementFromCoordinate(
1147
+ coordinate.latitude,
1148
+ coordinate.longitude,
1149
+ coordinate.height,
1150
+ );
1151
+ setStatus(
1152
+ 'Moved tileset root to the specified coordinate using ENU orientation. Click Save to persist.',
1153
+ );
1154
+ } catch (err) {
1155
+ setStatus(err && err.message ? err.message : String(err), true);
1156
+ }
1157
+ }
1158
+
1159
+ function resetToSaved() {
1160
+ cancelSetPositionMode();
1161
+ applySavedMatrix(lastSavedMatrix);
1162
+ setStatus('Reset to the last saved transform.');
1163
+ }
1164
+
1165
+ function loadTileset(url) {
1166
+ if (tiles) {
1167
+ editableGroup.remove(tiles.group);
1168
+ tiles.dispose();
1169
+ tiles = null;
1170
+ }
1171
+
1172
+ resetEditableGroup();
1173
+ lastSavedGeometricErrorScale = 1;
1174
+ savedRootMatrix.identity();
1175
+ savedRootMatrixLoadError = null;
1176
+ savedRootMatrixPromise = refreshSavedRootMatrix(url).then(
1177
+ () => {
1178
+ savedRootMatrixLoadError = null;
1179
+ syncTransformHandleFromTilesTransform();
1180
+ syncCoordinateInputsFromTilesTransform();
1181
+ },
1182
+ (err) => {
1183
+ savedRootMatrixLoadError = err;
1184
+ savedRootMatrix.identity();
1185
+ syncTransformHandleFromTilesTransform();
1186
+ syncCoordinateInputsFromTilesTransform();
1187
+ },
1188
+ );
1189
+
1190
+ const next = new TilesRenderer(url);
1191
+ next.downloadQueue.maxJobs = 8;
1192
+ next.parseQueue.maxJobs = 4;
1193
+ next.registerPlugin(new TilesFadePlugin());
1194
+ next.registerPlugin(new TileCompressionPlugin());
1195
+ next.registerPlugin(new UnloadTilesPlugin());
1196
+ next.registerPlugin(new ImplicitTilingPlugin());
1197
+ next.registerPlugin(new GaussianSplatPlugin({ renderer, scene }));
1198
+ next.registerPlugin(
1199
+ new GLTFExtensionsPlugin({
1200
+ metadata: true,
1201
+ rtc: true,
1202
+ dracoLoader,
1203
+ ktxLoader: ktx2Loader,
1204
+ meshoptDecoder: MeshoptDecoder,
1205
+ autoDispose: false,
1206
+ }),
1207
+ );
1208
+ next.preprocessURL = normalizeLocalResourceUrl;
1209
+ next.setCamera(camera);
1210
+ next.setResolutionFromRenderer(camera, renderer);
1211
+ tiles = next;
1212
+ updateTilesetErrorTarget();
1213
+ next.addEventListener('load-model', ({ scene: modelScene }) => {
1214
+ forceOpaqueScene(modelScene);
1215
+ tilesTransformDirty = true;
1216
+ });
1217
+ next.addEventListener('tile-visibility-change', () => {
1218
+ tilesTransformDirty = true;
1219
+ });
1220
+
1221
+ const lruCache = next.lruCache;
1222
+ lruCache.minSize = 256;
1223
+ lruCache.maxSize = 4096;
1224
+ lruCache.minBytesSize = 0.2 * 2 ** 30;
1225
+ lruCache.maxBytesSize = 2 * 2 ** 30;
1226
+ lruCache.unloadPercent = 0.1;
1227
+
1228
+ editableGroup.add(next.group);
1229
+
1230
+ let framed = false;
1231
+ const tryFrame = () => {
1232
+ if (framed) {
1233
+ return;
1234
+ }
1235
+ if (frameTileset()) {
1236
+ framed = true;
1237
+ setStatus('Tileset ready.');
1238
+ }
1239
+ };
1240
+
1241
+ next.addEventListener('load-tile-set', tryFrame);
1242
+ next.addEventListener('load-tileset', tryFrame);
1243
+ }
1244
+
1245
+ async function saveTransform() {
1246
+ cancelSetPositionMode();
1247
+ saveButton.disabled = true;
1248
+ setStatus('Saving transform...');
1249
+
1250
+ const currentMatrix = getCurrentMatrix();
1251
+ const incrementalMatrix = currentMatrix
1252
+ .clone()
1253
+ .multiply(lastSavedMatrix.clone().invert());
1254
+ const incrementalGeometricErrorScale =
1255
+ geometricErrorScale / lastSavedGeometricErrorScale;
1256
+
1257
+ try {
1258
+ const response = await fetch(SAVE_URL, {
1259
+ method: 'POST',
1260
+ headers: {
1261
+ 'Content-Type': 'application/json',
1262
+ },
1263
+ body: JSON.stringify({
1264
+ geometricErrorScale: incrementalGeometricErrorScale,
1265
+ transform: incrementalMatrix.toArray(),
1266
+ }),
1267
+ });
1268
+ const payload = await response.json().catch(() => ({}));
1269
+ if (!response.ok) {
1270
+ throw new Error(payload.error || 'Save failed.');
1271
+ }
1272
+ if (payload && payload.transform != null) {
1273
+ savedRootMatrix.fromArray(
1274
+ getFiniteMatrix4Array(payload.transform, 'transform'),
1275
+ );
1276
+ savedRootMatrixLoadError = null;
1277
+ savedRootMatrixPromise = Promise.resolve(savedRootMatrix);
1278
+ } else {
1279
+ savedRootMatrixPromise = refreshSavedRootMatrix(TILESET_URL).then(
1280
+ () => {
1281
+ savedRootMatrixLoadError = null;
1282
+ },
1283
+ (err) => {
1284
+ savedRootMatrixLoadError = err;
1285
+ savedRootMatrix.identity();
1286
+ },
1287
+ );
1288
+ await savedRootMatrixPromise;
1289
+ if (savedRootMatrixLoadError) {
1290
+ throw savedRootMatrixLoadError;
1291
+ }
1292
+ }
1293
+ lastSavedGeometricErrorScale = geometricErrorScale;
1294
+ lastSavedMatrix.copy(currentMatrix);
1295
+ syncTransformHandleFromTilesTransform();
1296
+ syncCoordinateInputsFromTilesTransform();
1297
+ setStatus(
1298
+ `Saved transform and geometric-error scale x${formatGeometricErrorScale(
1299
+ geometricErrorScale,
1300
+ )} to ${ROOT_TILESET_LABEL} and build_summary.json.`,
1301
+ );
1302
+ } catch (err) {
1303
+ setStatus(err && err.message ? err.message : String(err), true);
1304
+ } finally {
1305
+ saveButton.disabled = false;
1306
+ }
1307
+ }
1308
+
1309
+ translateButton.addEventListener('click', () => {
1310
+ cancelSetPositionMode();
1311
+ toggleTransformMode('translate');
1312
+ setStatus(
1313
+ activeTransformMode === 'translate'
1314
+ ? 'Translate mode enabled.'
1315
+ : 'Translate mode disabled.',
1316
+ );
1317
+ });
1318
+ rotateButton.addEventListener('click', () => {
1319
+ cancelSetPositionMode();
1320
+ toggleTransformMode('rotate');
1321
+ setStatus(
1322
+ activeTransformMode === 'rotate'
1323
+ ? 'Rotate mode enabled.'
1324
+ : 'Rotate mode disabled.',
1325
+ );
1326
+ });
1327
+ toolbarToggleButton.addEventListener('click', toggleToolbarVisibility);
1328
+ terrainButton.addEventListener('click', () => {
1329
+ setTerrainEnabled(!terrainEnabled);
1330
+ setStatus(
1331
+ terrainEnabled
1332
+ ? 'Terrain enabled with Cesium World Terrain.'
1333
+ : 'Terrain disabled. Using ellipsoid imagery globe.',
1334
+ );
1335
+ });
1336
+ geometricErrorScaleInput.addEventListener('input', () => {
1337
+ setGeometricErrorScaleExponent(geometricErrorScaleInput.value);
1338
+ });
1339
+ geometricErrorScaleInput.addEventListener('change', () => {
1340
+ setStatus(
1341
+ `Geometric-error scale set to x${formatGeometricErrorScale(
1342
+ geometricErrorScale,
1343
+ )}.`,
1344
+ );
1345
+ });
1346
+ moveToTilesButton.addEventListener('click', moveCameraToTiles);
1347
+ moveCameraToCoordinateButton.addEventListener('click', moveCameraToCoordinate);
1348
+ moveTilesToCoordinateButton.addEventListener('click', moveTilesToCoordinate);
1349
+ setPositionButton.addEventListener('click', toggleSetPositionMode);
1350
+ resetButton.addEventListener('click', resetToSaved);
1351
+ saveButton.addEventListener('click', saveTransform);
1352
+ renderer.domElement.addEventListener(
1353
+ 'pointerdown',
1354
+ handleSetPositionPointerDown,
1355
+ );
1356
+
1357
+ window.addEventListener('resize', () => {
1358
+ renderer.setSize(window.innerWidth, window.innerHeight);
1359
+ camera.aspect = window.innerWidth / window.innerHeight;
1360
+ camera.updateProjectionMatrix();
1361
+ tiles?.setResolutionFromRenderer(camera, renderer);
1362
+ globeTiles?.setResolutionFromRenderer(camera, renderer);
1363
+ });
1364
+
1365
+ window.addEventListener('pagehide', requestViewerShutdown);
1366
+ window.addEventListener('beforeunload', () => {
1367
+ requestViewerShutdown();
1368
+ cameraController.dispose();
1369
+ dracoLoader.dispose();
1370
+ ktx2Loader.dispose();
1371
+ });
1372
+
1373
+ loadTileset(TILESET_URL);
1374
+
1375
+ function frame() {
1376
+ cameraController.update();
1377
+ if (tilesTransformDirty) {
1378
+ editableGroup.updateMatrixWorld(true);
1379
+ updateTilesRendererGroupMatrices(tiles);
1380
+ refreshLoadedTileSceneMatrices(tiles);
1381
+ tilesTransformDirty = false;
1382
+ }
1383
+ globeTiles?.update();
1384
+ tiles?.update();
1385
+ renderer.render(scene, camera);
1386
+ requestAnimationFrame(frame);
1387
+ }
1388
+
1389
+ frame();