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,1244 @@
1
+ const fs = require('fs');
2
+ const http = require('http');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawn } = require('child_process');
6
+
7
+ const { InspectorError } = require('../errors');
8
+ const { resolveAndValidateTilesetPath } = require('../tileset-path');
9
+
10
+ const VIEWER_HTML_NAME = 'viewer.html';
11
+ const VIEWER_DIR_NAME = 'viewer';
12
+ const BUILT_VIEWER_ASSETS_DIR = path.resolve(
13
+ __dirname,
14
+ '..',
15
+ '..',
16
+ 'dist',
17
+ 'inspector-assets',
18
+ );
19
+ const SAVE_ENDPOINT_PATH = '/__inspector/save-transform';
20
+ const SHUTDOWN_ENDPOINT_PATH = '/__inspector/shutdown';
21
+ const MAX_SAVE_BODY_BYTES = 64 * 1024;
22
+ const SHUTDOWN_DELAY_MS = 1000;
23
+ const IDENTITY_MATRIX4 = Object.freeze([
24
+ 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0,
25
+ 1.0,
26
+ ]);
27
+
28
+ const MIME_TYPES = {
29
+ '.css': 'text/css; charset=utf-8',
30
+ '.glb': 'model/gltf-binary',
31
+ '.html': 'text/html; charset=utf-8',
32
+ '.jpeg': 'image/jpeg',
33
+ '.jpg': 'image/jpeg',
34
+ '.js': 'text/javascript; charset=utf-8',
35
+ '.json': 'application/json; charset=utf-8',
36
+ '.png': 'image/png',
37
+ '.subtree': 'application/octet-stream',
38
+ '.svg': 'image/svg+xml; charset=utf-8',
39
+ '.txt': 'text/plain; charset=utf-8',
40
+ '.wasm': 'application/wasm',
41
+ };
42
+
43
+ function encodePathSegment(segment) {
44
+ return encodeURIComponent(segment).replace(
45
+ /[!'()*]/g,
46
+ (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`,
47
+ );
48
+ }
49
+
50
+ function getBrowserRelativePath(fromDir, targetPath) {
51
+ const relativePath = path.relative(fromDir, targetPath);
52
+ if (
53
+ relativePath.length === 0 ||
54
+ relativePath === '.' ||
55
+ relativePath.startsWith('..') ||
56
+ path.isAbsolute(relativePath)
57
+ ) {
58
+ throw new InspectorError(
59
+ `Tileset path must stay within the viewer root: ${targetPath}`,
60
+ );
61
+ }
62
+
63
+ return relativePath.split(path.sep).map(encodePathSegment).join('/');
64
+ }
65
+
66
+ function stringifyInlineScriptValue(value) {
67
+ return JSON.stringify(value)
68
+ .replace(/</g, '\\u003C')
69
+ .replace(/>/g, '\\u003E')
70
+ .replace(/&/g, '\\u0026');
71
+ }
72
+
73
+ function cloneIdentityMatrix4() {
74
+ return IDENTITY_MATRIX4.slice();
75
+ }
76
+
77
+ function normalizeMatrix4Array(value, name = 'transform') {
78
+ if (!Array.isArray(value) || value.length !== 16) {
79
+ throw new InspectorError(`${name} must be a 16-number matrix.`);
80
+ }
81
+
82
+ return value.map((entry, index) => {
83
+ const number = Number(entry);
84
+ if (!Number.isFinite(number)) {
85
+ throw new InspectorError(`${name}[${index}] must be a finite number.`);
86
+ }
87
+ return number;
88
+ });
89
+ }
90
+
91
+ function multiplyMatrix4(left, right) {
92
+ const a = normalizeMatrix4Array(left, 'left');
93
+ const b = normalizeMatrix4Array(right, 'right');
94
+ const out = new Array(16);
95
+
96
+ for (let column = 0; column < 4; column++) {
97
+ for (let row = 0; row < 4; row++) {
98
+ let sum = 0.0;
99
+ for (let i = 0; i < 4; i++) {
100
+ sum += a[i * 4 + row] * b[column * 4 + i];
101
+ }
102
+ out[column * 4 + row] = sum;
103
+ }
104
+ }
105
+
106
+ return out;
107
+ }
108
+
109
+ function readJsonFile(filePath) {
110
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
111
+ }
112
+
113
+ function writeJsonAtomic(filePath, value) {
114
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
115
+ fs.writeFileSync(tempPath, JSON.stringify(value), 'utf8');
116
+ fs.renameSync(tempPath, filePath);
117
+ }
118
+
119
+ function copyDirectoryRecursive(sourceDir, targetDir) {
120
+ fs.mkdirSync(targetDir, { recursive: true });
121
+ for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
122
+ const sourcePath = path.join(sourceDir, entry.name);
123
+ const targetPath = path.join(targetDir, entry.name);
124
+ if (entry.isDirectory()) {
125
+ copyDirectoryRecursive(sourcePath, targetPath);
126
+ } else if (entry.isFile()) {
127
+ fs.copyFileSync(sourcePath, targetPath);
128
+ }
129
+ }
130
+ }
131
+
132
+ function resolveBuiltViewerSubdir() {
133
+ const builtViewerSubdir = path.join(BUILT_VIEWER_ASSETS_DIR, VIEWER_DIR_NAME);
134
+ if (!fs.existsSync(builtViewerSubdir)) {
135
+ throw new InspectorError(
136
+ 'Missing built inspector assets. Run `npm run build:viewer` first.',
137
+ );
138
+ }
139
+ if (!fs.statSync(builtViewerSubdir).isDirectory()) {
140
+ throw new InspectorError(
141
+ `Built viewer assets path must be a directory: ${builtViewerSubdir}`,
142
+ );
143
+ }
144
+ return builtViewerSubdir;
145
+ }
146
+
147
+ function createViewerAssetsDir(viewerConfig) {
148
+ const assetsDir = fs.mkdtempSync(
149
+ path.join(os.tmpdir(), '3dtiles-inspector-'),
150
+ );
151
+ const viewerSubdir = path.join(assetsDir, VIEWER_DIR_NAME);
152
+ fs.mkdirSync(viewerSubdir, { recursive: true });
153
+ fs.writeFileSync(
154
+ path.join(assetsDir, VIEWER_HTML_NAME),
155
+ buildViewerHtml(viewerConfig),
156
+ 'utf8',
157
+ );
158
+ copyDirectoryRecursive(resolveBuiltViewerSubdir(), viewerSubdir);
159
+ return assetsDir;
160
+ }
161
+
162
+ function removeViewerAssetsDir(assetsDir) {
163
+ if (!assetsDir) {
164
+ return;
165
+ }
166
+ fs.rmSync(assetsDir, { recursive: true, force: true });
167
+ }
168
+
169
+ function normalizePositiveFinite(value, name) {
170
+ const number = Number(value);
171
+ if (!Number.isFinite(number) || number <= 0) {
172
+ throw new InspectorError(`${name} must be a finite number greater than 0.`);
173
+ }
174
+ return number;
175
+ }
176
+
177
+ function scaleGeometricErrorValue(target, key, scale, label) {
178
+ if (target[key] == null) {
179
+ return;
180
+ }
181
+
182
+ const number = Number(target[key]);
183
+ if (!Number.isFinite(number)) {
184
+ throw new InspectorError(`${label} must be a finite number.`);
185
+ }
186
+
187
+ target[key] = number * scale;
188
+ }
189
+
190
+ function scaleTilesetGeometricErrors(tile, scale, pathLabel = 'tileset.root') {
191
+ if (!tile || typeof tile !== 'object') {
192
+ return;
193
+ }
194
+
195
+ scaleGeometricErrorValue(
196
+ tile,
197
+ 'geometricError',
198
+ scale,
199
+ `${pathLabel}.geometricError`,
200
+ );
201
+
202
+ if (!Array.isArray(tile.children)) {
203
+ return;
204
+ }
205
+
206
+ tile.children.forEach((child, index) => {
207
+ scaleTilesetGeometricErrors(
208
+ child,
209
+ scale,
210
+ `${pathLabel}.children[${index}]`,
211
+ );
212
+ });
213
+ }
214
+
215
+ function getLocalJsonReferencePath(baseDir, uri) {
216
+ if (typeof uri !== 'string' || uri.length === 0) {
217
+ return null;
218
+ }
219
+
220
+ if (/^[a-z][a-z\d+.-]*:/i.test(uri) || uri.startsWith('//')) {
221
+ return null;
222
+ }
223
+
224
+ const normalized = uri.split('#', 1)[0].split('?', 1)[0];
225
+ if (!/\.json$/i.test(normalized)) {
226
+ return null;
227
+ }
228
+
229
+ return path.resolve(baseDir, normalized.replace(/\//g, path.sep));
230
+ }
231
+
232
+ function collectExternalTilesetPaths(tile, baseDir, results) {
233
+ if (!tile || typeof tile !== 'object') {
234
+ return;
235
+ }
236
+
237
+ const maybePaths = [];
238
+ if (tile.content && typeof tile.content === 'object') {
239
+ maybePaths.push(
240
+ getLocalJsonReferencePath(baseDir, tile.content.uri || tile.content.url),
241
+ );
242
+ }
243
+
244
+ if (Array.isArray(tile.contents)) {
245
+ tile.contents.forEach((entry) => {
246
+ if (entry && typeof entry === 'object') {
247
+ maybePaths.push(
248
+ getLocalJsonReferencePath(baseDir, entry.uri || entry.url),
249
+ );
250
+ }
251
+ });
252
+ }
253
+
254
+ maybePaths.forEach((filePath) => {
255
+ if (filePath) {
256
+ results.add(filePath);
257
+ }
258
+ });
259
+
260
+ if (!Array.isArray(tile.children)) {
261
+ return;
262
+ }
263
+
264
+ tile.children.forEach((child) => {
265
+ collectExternalTilesetPaths(child, baseDir, results);
266
+ });
267
+ }
268
+
269
+ function updateTilesetJsonFile(
270
+ tilesetPath,
271
+ { geometricErrorScale, rootDir, rootTransform = null },
272
+ visited = new Set(),
273
+ ) {
274
+ const resolvedPath = path.resolve(tilesetPath);
275
+ if (visited.has(resolvedPath)) {
276
+ return null;
277
+ }
278
+ visited.add(resolvedPath);
279
+
280
+ if (
281
+ resolvedPath !== rootDir &&
282
+ !resolvedPath.startsWith(`${rootDir}${path.sep}`)
283
+ ) {
284
+ throw new InspectorError(
285
+ `Nested tileset path escapes the viewer root: ${resolvedPath}`,
286
+ );
287
+ }
288
+
289
+ if (!fs.existsSync(resolvedPath)) {
290
+ throw new InspectorError(
291
+ `Referenced nested tileset does not exist: ${resolvedPath}`,
292
+ );
293
+ }
294
+
295
+ const tileset = readJsonFile(resolvedPath);
296
+ if (!tileset || typeof tileset !== 'object' || !tileset.root) {
297
+ throw new InspectorError(`${resolvedPath} must contain a root object.`);
298
+ }
299
+
300
+ if (rootTransform) {
301
+ tileset.root.transform = rootTransform.slice();
302
+ }
303
+
304
+ scaleGeometricErrorValue(
305
+ tileset,
306
+ 'geometricError',
307
+ geometricErrorScale,
308
+ `${resolvedPath}.geometricError`,
309
+ );
310
+ scaleTilesetGeometricErrors(
311
+ tileset.root,
312
+ geometricErrorScale,
313
+ `${resolvedPath}.root`,
314
+ );
315
+ writeJsonAtomic(resolvedPath, tileset);
316
+
317
+ const nestedTilesets = new Set();
318
+ collectExternalTilesetPaths(
319
+ tileset.root,
320
+ path.dirname(resolvedPath),
321
+ nestedTilesets,
322
+ );
323
+ nestedTilesets.forEach((childTilesetPath) => {
324
+ updateTilesetJsonFile(
325
+ childTilesetPath,
326
+ {
327
+ geometricErrorScale,
328
+ rootDir,
329
+ },
330
+ visited,
331
+ );
332
+ });
333
+
334
+ return tileset;
335
+ }
336
+
337
+ function saveViewerTransform(
338
+ rootTilesetPath,
339
+ editMatrix,
340
+ { geometricErrorScale = 1 } = {},
341
+ ) {
342
+ const normalizedEdit = normalizeMatrix4Array(editMatrix, 'transform');
343
+ const normalizedGeometricErrorScale = normalizePositiveFinite(
344
+ geometricErrorScale,
345
+ 'geometricErrorScale',
346
+ );
347
+ const tilesetPath = path.resolve(rootTilesetPath);
348
+ const rootDir = path.dirname(tilesetPath);
349
+
350
+ if (!fs.existsSync(tilesetPath)) {
351
+ throw new InspectorError(
352
+ `Cannot save viewer transform because ${tilesetPath} does not exist.`,
353
+ );
354
+ }
355
+
356
+ const tileset = readJsonFile(tilesetPath);
357
+ if (!tileset || typeof tileset !== 'object' || !tileset.root) {
358
+ throw new InspectorError(
359
+ `Root tileset JSON must contain a root object: ${tilesetPath}`,
360
+ );
361
+ }
362
+
363
+ const currentRoot = Array.isArray(tileset.root.transform)
364
+ ? normalizeMatrix4Array(tileset.root.transform, 'tileset.root.transform')
365
+ : cloneIdentityMatrix4();
366
+ const nextRoot = multiplyMatrix4(normalizedEdit, currentRoot);
367
+
368
+ updateTilesetJsonFile(tilesetPath, {
369
+ geometricErrorScale: normalizedGeometricErrorScale,
370
+ rootDir,
371
+ rootTransform: nextRoot,
372
+ });
373
+
374
+ const summaryPath = path.join(rootDir, 'build_summary.json');
375
+ if (fs.existsSync(summaryPath)) {
376
+ const summary = readJsonFile(summaryPath);
377
+ const previousGeometricErrorScale =
378
+ summary.viewer_geometric_error_scale == null
379
+ ? 1
380
+ : normalizePositiveFinite(
381
+ summary.viewer_geometric_error_scale,
382
+ 'build_summary.viewer_geometric_error_scale',
383
+ );
384
+ summary.root_transform = nextRoot.slice();
385
+ summary.root_transform_source = 'transform';
386
+ summary.root_coordinate = null;
387
+ summary.viewer_geometric_error_scale =
388
+ previousGeometricErrorScale * normalizedGeometricErrorScale;
389
+ writeJsonAtomic(summaryPath, summary);
390
+ }
391
+
392
+ return nextRoot;
393
+ }
394
+
395
+ function resolveStaticFilePath(tilesDir, viewerAssetsDir, pathname) {
396
+ let decodedPathname;
397
+ try {
398
+ decodedPathname = decodeURIComponent(pathname || '/');
399
+ } catch (err) {
400
+ throw new InspectorError('Request path is not valid URL encoding.');
401
+ }
402
+
403
+ if (decodedPathname.includes('\0')) {
404
+ throw new InspectorError('Request path contains invalid characters.');
405
+ }
406
+
407
+ const requested =
408
+ decodedPathname === '/' || decodedPathname === ''
409
+ ? VIEWER_HTML_NAME
410
+ : decodedPathname.replace(/^[/\\]+/, '');
411
+
412
+ const normalized = requested.replace(/\\/g, '/');
413
+ const isViewerAsset =
414
+ normalized === VIEWER_HTML_NAME ||
415
+ normalized === VIEWER_DIR_NAME ||
416
+ normalized.startsWith(`${VIEWER_DIR_NAME}/`);
417
+
418
+ const resolvedRoot = path.resolve(isViewerAsset ? viewerAssetsDir : tilesDir);
419
+ const resolvedPath = path.resolve(resolvedRoot, requested);
420
+
421
+ if (
422
+ resolvedPath !== resolvedRoot &&
423
+ !resolvedPath.startsWith(`${resolvedRoot}${path.sep}`)
424
+ ) {
425
+ throw new InspectorError('Request path escapes the viewer root.');
426
+ }
427
+
428
+ return resolvedPath;
429
+ }
430
+
431
+ function sendJson(res, statusCode, payload) {
432
+ const body = JSON.stringify(payload);
433
+ res.writeHead(statusCode, {
434
+ 'Content-Length': Buffer.byteLength(body),
435
+ 'Content-Type': 'application/json; charset=utf-8',
436
+ 'Cache-Control': 'no-store',
437
+ });
438
+ res.end(body);
439
+ }
440
+
441
+ function sendText(res, statusCode, message, headers = {}) {
442
+ const body = `${message}\n`;
443
+ res.writeHead(statusCode, {
444
+ 'Content-Length': Buffer.byteLength(body),
445
+ 'Content-Type': 'text/plain; charset=utf-8',
446
+ 'Cache-Control': 'no-store',
447
+ ...headers,
448
+ });
449
+ res.end(body);
450
+ }
451
+
452
+ function readRequestBody(req) {
453
+ return new Promise((resolve, reject) => {
454
+ const chunks = [];
455
+ let size = 0;
456
+
457
+ req.on('data', (chunk) => {
458
+ size += chunk.length;
459
+ if (size > MAX_SAVE_BODY_BYTES) {
460
+ reject(new InspectorError('Request body is too large.'));
461
+ req.destroy();
462
+ return;
463
+ }
464
+ chunks.push(chunk);
465
+ });
466
+ req.on('end', () => resolve(Buffer.concat(chunks)));
467
+ req.on('error', reject);
468
+ });
469
+ }
470
+
471
+ function normalizeRequestTarget(rawTarget) {
472
+ if (typeof rawTarget !== 'string' || rawTarget.length === 0) {
473
+ return '/';
474
+ }
475
+
476
+ if (rawTarget.startsWith('//')) {
477
+ return `/${rawTarget.replace(/^\/+/, '')}`;
478
+ }
479
+
480
+ return rawTarget;
481
+ }
482
+
483
+ async function handleSaveTransformRequest(rootTilesetPath, req, res) {
484
+ let payload;
485
+ try {
486
+ const body = await readRequestBody(req);
487
+ payload = body.length > 0 ? JSON.parse(body.toString('utf8')) : {};
488
+ } catch (err) {
489
+ sendJson(res, 400, {
490
+ error:
491
+ err instanceof Error && err.message
492
+ ? err.message
493
+ : 'Invalid JSON payload.',
494
+ });
495
+ return;
496
+ }
497
+
498
+ if (!payload || typeof payload !== 'object') {
499
+ sendJson(res, 400, { error: 'Request payload must be a JSON object.' });
500
+ return;
501
+ }
502
+
503
+ let normalizedEdit;
504
+ try {
505
+ normalizedEdit = normalizeMatrix4Array(payload.transform, 'transform');
506
+ } catch (err) {
507
+ sendJson(res, 400, {
508
+ error:
509
+ err instanceof Error && err.message
510
+ ? err.message
511
+ : 'transform must be a 16-number matrix.',
512
+ });
513
+ return;
514
+ }
515
+
516
+ let normalizedGeometricErrorScale;
517
+ try {
518
+ normalizedGeometricErrorScale = normalizePositiveFinite(
519
+ payload.geometricErrorScale == null ? 1 : payload.geometricErrorScale,
520
+ 'geometricErrorScale',
521
+ );
522
+ } catch (err) {
523
+ sendJson(res, 400, {
524
+ error:
525
+ err instanceof Error && err.message
526
+ ? err.message
527
+ : 'geometricErrorScale must be a finite number greater than 0.',
528
+ });
529
+ return;
530
+ }
531
+
532
+ let nextRoot;
533
+ try {
534
+ nextRoot = saveViewerTransform(rootTilesetPath, normalizedEdit, {
535
+ geometricErrorScale: normalizedGeometricErrorScale,
536
+ });
537
+ } catch (err) {
538
+ sendJson(res, 500, {
539
+ error:
540
+ err instanceof Error && err.message
541
+ ? err.message
542
+ : 'Failed to save transform.',
543
+ });
544
+ return;
545
+ }
546
+
547
+ sendJson(res, 200, {
548
+ ok: true,
549
+ transform: nextRoot,
550
+ geometricErrorScale: normalizedGeometricErrorScale,
551
+ });
552
+ }
553
+
554
+ async function handleViewerRequest(
555
+ tilesDir,
556
+ rootTilesetPath,
557
+ viewerAssetsDir,
558
+ req,
559
+ res,
560
+ requestUrl = null,
561
+ ) {
562
+ const normalizedRequestUrl =
563
+ requestUrl || new URL(normalizeRequestTarget(req.url), 'http://127.0.0.1');
564
+
565
+ if (normalizedRequestUrl.pathname === SAVE_ENDPOINT_PATH) {
566
+ if (req.method !== 'POST') {
567
+ sendText(res, 405, 'Method Not Allowed', { Allow: 'POST' });
568
+ return;
569
+ }
570
+ await handleSaveTransformRequest(rootTilesetPath, req, res);
571
+ return;
572
+ }
573
+
574
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
575
+ sendText(res, 405, 'Method Not Allowed', { Allow: 'GET, HEAD' });
576
+ return;
577
+ }
578
+
579
+ let filePath;
580
+ try {
581
+ filePath = resolveStaticFilePath(
582
+ tilesDir,
583
+ viewerAssetsDir,
584
+ normalizedRequestUrl.pathname,
585
+ );
586
+ } catch (err) {
587
+ sendText(res, 403, err.message || 'Forbidden');
588
+ return;
589
+ }
590
+
591
+ let stats;
592
+ try {
593
+ stats = fs.statSync(filePath);
594
+ } catch (err) {
595
+ sendText(res, 404, 'Not Found');
596
+ return;
597
+ }
598
+
599
+ if (!stats.isFile()) {
600
+ sendText(res, 404, 'Not Found');
601
+ return;
602
+ }
603
+
604
+ const extension = path.extname(filePath).toLowerCase();
605
+ const contentType = MIME_TYPES[extension] || 'application/octet-stream';
606
+ res.writeHead(200, {
607
+ 'Content-Length': stats.size,
608
+ 'Content-Type': contentType,
609
+ 'Cache-Control': 'no-store',
610
+ });
611
+
612
+ if (req.method === 'HEAD') {
613
+ res.end();
614
+ return;
615
+ }
616
+
617
+ fs.createReadStream(filePath).pipe(res);
618
+ }
619
+
620
+ function escapeForSingleQuotedPowerShell(value) {
621
+ return String(value).replace(/'/g, "''");
622
+ }
623
+
624
+ function openBrowser(url) {
625
+ return new Promise((resolve, reject) => {
626
+ let child;
627
+
628
+ if (process.platform === 'win32') {
629
+ child = spawn(
630
+ 'powershell',
631
+ [
632
+ '-NoProfile',
633
+ '-Command',
634
+ `Start-Process '${escapeForSingleQuotedPowerShell(url)}'`,
635
+ ],
636
+ {
637
+ detached: true,
638
+ stdio: 'ignore',
639
+ windowsHide: true,
640
+ },
641
+ );
642
+ } else if (process.platform === 'darwin') {
643
+ child = spawn('open', [url], {
644
+ detached: true,
645
+ stdio: 'ignore',
646
+ });
647
+ } else {
648
+ child = spawn('xdg-open', [url], {
649
+ detached: true,
650
+ stdio: 'ignore',
651
+ });
652
+ }
653
+
654
+ child.once('error', reject);
655
+ child.once('spawn', () => {
656
+ child.unref();
657
+ resolve();
658
+ });
659
+ });
660
+ }
661
+
662
+ function buildViewerHtml(viewerConfig) {
663
+ const serializedViewerConfig = stringifyInlineScriptValue(viewerConfig);
664
+ return `<!doctype html>
665
+ <html lang="en">
666
+ <head>
667
+ <meta charset="utf-8" />
668
+ <meta
669
+ name="viewport"
670
+ content="width=device-width, initial-scale=1.0, viewport-fit=cover"
671
+ />
672
+ <title>3D Tiles Inspector</title>
673
+ <style>
674
+ :root {
675
+ color-scheme: light;
676
+ font-family: "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif;
677
+ }
678
+
679
+ * {
680
+ box-sizing: border-box;
681
+ }
682
+
683
+ body {
684
+ margin: 0;
685
+ overflow: hidden;
686
+ background:
687
+ radial-gradient(circle at top, rgba(255, 255, 255, 0.95), rgba(236, 240, 245, 0.9)),
688
+ linear-gradient(180deg, #eef3f8 0%, #dfe7ef 100%);
689
+ color: #16324f;
690
+ }
691
+
692
+ #app {
693
+ position: fixed;
694
+ inset: 0;
695
+ }
696
+
697
+ canvas {
698
+ display: block;
699
+ }
700
+
701
+ .toolbar-dock {
702
+ position: fixed;
703
+ top: 16px;
704
+ bottom: 16px;
705
+ left: 16px;
706
+ display: grid;
707
+ grid-template-rows: auto minmax(0, 1fr);
708
+ align-items: stretch;
709
+ gap: 0;
710
+ width: min(280px, calc(100vw - 32px));
711
+ z-index: 10;
712
+ }
713
+
714
+ .toolbar {
715
+ display: grid;
716
+ align-content: start;
717
+ gap: 12px;
718
+ padding: 14px;
719
+ border: 1px solid rgba(22, 50, 79, 0.12);
720
+ border-top: 0;
721
+ border-radius: 0 0 20px 20px;
722
+ background: rgba(255, 255, 255, 0.9);
723
+ box-shadow: 0 18px 44px rgba(33, 52, 73, 0.16);
724
+ backdrop-filter: blur(14px);
725
+ min-height: 0;
726
+ overflow-y: auto;
727
+ overscroll-behavior: contain;
728
+ transition:
729
+ opacity 160ms ease,
730
+ transform 160ms ease;
731
+ }
732
+
733
+ .toolbar.hidden {
734
+ display: none;
735
+ }
736
+
737
+ .toolbar-toggle {
738
+ display: inline-flex;
739
+ align-items: center;
740
+ justify-content: center;
741
+ width: 100%;
742
+ min-height: 32px;
743
+ padding: 10px 12px;
744
+ border: 1px solid rgba(22, 50, 79, 0.08);
745
+ border-radius: 20px 20px 0 0;
746
+ font: inherit;
747
+ font-size: 13px;
748
+ font-weight: 600;
749
+ letter-spacing: 0.02em;
750
+ color: #16324f;
751
+ background: rgba(255, 255, 255, 0.9);
752
+ box-shadow: 0 18px 44px rgba(33, 52, 73, 0.16);
753
+ cursor: pointer;
754
+ backdrop-filter: blur(14px);
755
+ transition:
756
+ background-color 120ms ease,
757
+ color 120ms ease,
758
+ box-shadow 120ms ease;
759
+ }
760
+
761
+ .toolbar-dock.collapsed .toolbar-toggle {
762
+ justify-self: start;
763
+ width: auto;
764
+ min-height: 36px;
765
+ padding: 7px 12px 8px;
766
+ border-radius: 999px;
767
+ color: #506377;
768
+ background: rgba(255, 255, 255, 0.94);
769
+ box-shadow: 0 12px 28px rgba(33, 52, 73, 0.12);
770
+ }
771
+
772
+ .toolbar-toggle:hover {
773
+ color: #16324f;
774
+ background: rgba(225, 226, 229, 0.98);
775
+ box-shadow: 0 18px 40px rgba(33, 52, 73, 0.18);
776
+ }
777
+
778
+ .toolbar-dock.collapsed .toolbar-toggle:hover {
779
+ background: rgba(239, 241, 243, 0.98);
780
+ box-shadow: 0 10px 22px rgba(33, 52, 73, 0.1);
781
+ }
782
+
783
+ .toolbar-toggle:focus-visible {
784
+ outline: 2px solid rgba(13, 111, 131, 0.35);
785
+ outline-offset: 2px;
786
+ }
787
+
788
+ .toolbar-section {
789
+ display: grid;
790
+ gap: 10px;
791
+ padding: 12px;
792
+ border: 1px solid rgba(22, 50, 79, 0.08);
793
+ border-radius: 14px;
794
+ background:
795
+ linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(243, 247, 251, 0.9));
796
+ }
797
+
798
+ .toolbar-section-header {
799
+ display: flex;
800
+ align-items: center;
801
+ justify-content: space-between;
802
+ gap: 10px;
803
+ }
804
+
805
+ .toolbar-section-title {
806
+ margin: 0;
807
+ font-size: 11px;
808
+ font-weight: 700;
809
+ letter-spacing: 0.08em;
810
+ text-transform: uppercase;
811
+ color: #5d738b;
812
+ }
813
+
814
+ .toolbar-value {
815
+ margin: 0;
816
+ font-size: 12px;
817
+ font-weight: 700;
818
+ color: #16324f;
819
+ }
820
+
821
+ .button-row {
822
+ display: flex;
823
+ flex-wrap: wrap;
824
+ gap: 8px;
825
+ }
826
+
827
+ .transform-actions {
828
+ display: grid;
829
+ grid-template-columns: repeat(2, minmax(0, 1fr));
830
+ gap: 8px;
831
+ }
832
+
833
+ .transform-actions button {
834
+ width: 100%;
835
+ }
836
+
837
+ .transform-actions .full-span {
838
+ grid-column: 1 / -1;
839
+ }
840
+
841
+ .toolbar button {
842
+ display: inline-flex;
843
+ align-items: center;
844
+ justify-content: center;
845
+ border: 0;
846
+ border-radius: 999px;
847
+ padding: 9px 14px;
848
+ font: inherit;
849
+ font-size: 14px;
850
+ font-weight: 600;
851
+ color: #16324f;
852
+ background: #dde7f2;
853
+ cursor: pointer;
854
+ transition:
855
+ transform 120ms ease,
856
+ background-color 120ms ease,
857
+ color 120ms ease;
858
+ }
859
+
860
+ .toolbar button:hover {
861
+ transform: translateY(-1px);
862
+ background: #d0deeb;
863
+ }
864
+
865
+ .toolbar button.active {
866
+ color: #fff;
867
+ background: #0d6f83;
868
+ }
869
+
870
+ .toolbar button.save {
871
+ color: #fff;
872
+ background: #19765b;
873
+ }
874
+
875
+ .toolbar button:disabled {
876
+ transform: none;
877
+ opacity: 0.7;
878
+ cursor: wait;
879
+ }
880
+
881
+ .range-field {
882
+ display: grid;
883
+ gap: 8px;
884
+ min-width: 0;
885
+ }
886
+
887
+ .range-field span {
888
+ font-size: 11px;
889
+ font-weight: 600;
890
+ letter-spacing: 0.04em;
891
+ text-transform: uppercase;
892
+ color: #5d738b;
893
+ }
894
+
895
+ .range-field input[type='range'] {
896
+ width: 100%;
897
+ margin: 0;
898
+ }
899
+
900
+ .coordinate-grid {
901
+ display: grid;
902
+ grid-template-columns: 1fr;
903
+ gap: 10px;
904
+ }
905
+
906
+ .coordinate-grid label {
907
+ display: grid;
908
+ gap: 6px;
909
+ min-width: 0;
910
+ font-size: 11px;
911
+ font-weight: 600;
912
+ letter-spacing: 0.04em;
913
+ text-transform: uppercase;
914
+ color: #5d738b;
915
+ }
916
+
917
+ .coordinate-grid input {
918
+ width: 100%;
919
+ padding: 8px 10px;
920
+ border: 1px solid rgba(22, 50, 79, 0.16);
921
+ border-radius: 10px;
922
+ font: inherit;
923
+ font-size: 13px;
924
+ font-weight: 600;
925
+ color: #16324f;
926
+ background: rgba(255, 255, 255, 0.92);
927
+ }
928
+
929
+ .coordinate-actions {
930
+ display: grid;
931
+ grid-template-columns: 1fr;
932
+ gap: 8px;
933
+ }
934
+
935
+ .toolbar button.wide {
936
+ width: 100%;
937
+ justify-content: center;
938
+ }
939
+
940
+ .status {
941
+ min-width: 0;
942
+ font-size: 13px;
943
+ line-height: 1.4;
944
+ color: #38516c;
945
+ }
946
+
947
+ .status.error {
948
+ color: #a33f2f;
949
+ }
950
+
951
+ .status-panel {
952
+ display: grid;
953
+ gap: 10px;
954
+ }
955
+
956
+ .status-actions {
957
+ display: grid;
958
+ grid-template-columns: repeat(2, minmax(0, 1fr));
959
+ gap: 8px;
960
+ }
961
+
962
+ .status-actions button {
963
+ width: 100%;
964
+ }
965
+
966
+ @media (max-width: 720px) {
967
+ .toolbar-dock {
968
+ top: auto;
969
+ bottom: 16px;
970
+ right: 16px;
971
+ left: 16px;
972
+ width: auto;
973
+ max-height: min(78vh, 640px);
974
+ }
975
+
976
+ .toolbar {
977
+ max-height: min(calc(78vh - 44px), 596px);
978
+ }
979
+
980
+ .coordinate-actions button,
981
+ .status-actions button {
982
+ width: 100%;
983
+ }
984
+ }
985
+ </style>
986
+ </head>
987
+ <body>
988
+ <div id="app"></div>
989
+ <div class="toolbar-dock expanded">
990
+ <button
991
+ id="toolbar-toggle"
992
+ class="toolbar-toggle"
993
+ type="button"
994
+ aria-controls="toolbar"
995
+ aria-label="Hide Sidebar"
996
+ aria-expanded="true"
997
+ >
998
+ Hide Sidebar
999
+ </button>
1000
+ <div id="toolbar" class="toolbar">
1001
+ <div class="toolbar-section">
1002
+ <div class="toolbar-section-header">
1003
+ <p class="toolbar-section-title">Transform</p>
1004
+ </div>
1005
+ <div class="transform-actions">
1006
+ <button id="translate" type="button">Translate</button>
1007
+ <button id="rotate" type="button">Rotate</button>
1008
+ <button id="set-position" class="full-span" type="button">Set Position</button>
1009
+ </div>
1010
+ </div>
1011
+ <div class="toolbar-section">
1012
+ <div class="toolbar-section-header">
1013
+ <p class="toolbar-section-title">Canvas</p>
1014
+ </div>
1015
+ <div class="coordinate-actions">
1016
+ <button id="terrain" class="wide" type="button">Terrain</button>
1017
+ <button id="move-to-tiles" type="button">Move To Tiles</button>
1018
+ </div>
1019
+ </div>
1020
+ <div class="toolbar-section">
1021
+ <div class="toolbar-section-header">
1022
+ <p class="toolbar-section-title">Coordinate</p>
1023
+ </div>
1024
+ <div class="coordinate-grid">
1025
+ <label>Lat <input id="latitude" type="number" step="any" value="0" /></label>
1026
+ <label>Lon <input id="longitude" type="number" step="any" value="0" /></label>
1027
+ <label>Height <input id="height" type="number" step="any" value="0" /></label>
1028
+ </div>
1029
+ <div class="coordinate-actions">
1030
+ <button id="move-tiles-to-coordinate" class="wide" type="button">Move Tiles</button>
1031
+ <button id="move-camera-to-coordinate" class="wide" type="button">Move Camera</button>
1032
+ </div>
1033
+ </div>
1034
+ <div class="toolbar-section">
1035
+ <div class="toolbar-section-header">
1036
+ <p class="toolbar-section-title">LOD</p>
1037
+ <p id="geometric-error-value" class="toolbar-value">x1.00</p>
1038
+ </div>
1039
+ <label class="range-field">
1040
+ <span>Geometric Error</span>
1041
+ <input
1042
+ id="geometric-error-scale"
1043
+ type="range"
1044
+ min="-4"
1045
+ max="4"
1046
+ step="0.1"
1047
+ value="0"
1048
+ />
1049
+ </label>
1050
+ </div>
1051
+ <div class="toolbar-section status-panel">
1052
+ <div class="status-actions">
1053
+ <button id="reset" type="button">Reset</button>
1054
+ <button id="save" class="save" type="button">Save</button>
1055
+ </div>
1056
+ <div id="status" class="status">Loading tileset...</div>
1057
+ </div>
1058
+ </div>
1059
+ </div>
1060
+ <script>
1061
+ globalThis.__TILES_INSPECTOR_CONFIG__ = ${serializedViewerConfig};
1062
+ </script>
1063
+ <script type="module" src="./viewer/app.js"></script>
1064
+ </body>
1065
+ </html>
1066
+ `;
1067
+ }
1068
+
1069
+ async function startInspectorSession(
1070
+ rawTilesetPath,
1071
+ { openBrowser: shouldOpenBrowser = true, handleSignals = true } = {},
1072
+ ) {
1073
+ const tilesetPath = resolveAndValidateTilesetPath(rawTilesetPath);
1074
+ const rootDir = path.dirname(tilesetPath);
1075
+ const viewerAssetsDir = createViewerAssetsDir({
1076
+ tilesetLabel: path.basename(tilesetPath),
1077
+ tilesetUrl: `./${getBrowserRelativePath(rootDir, tilesetPath)}`,
1078
+ });
1079
+ let sessionOrigin = null;
1080
+ let closingPromise = null;
1081
+ let shutdownTimer = null;
1082
+ let cleanedUp = false;
1083
+ const signalHandlers = [];
1084
+
1085
+ const removeSignalHandlers = () => {
1086
+ while (signalHandlers.length > 0) {
1087
+ const { event, handler } = signalHandlers.pop();
1088
+ process.off(event, handler);
1089
+ }
1090
+ };
1091
+
1092
+ const cancelScheduledShutdown = () => {
1093
+ if (shutdownTimer) {
1094
+ clearTimeout(shutdownTimer);
1095
+ shutdownTimer = null;
1096
+ }
1097
+ };
1098
+
1099
+ let closeResolve;
1100
+ const closed = new Promise((resolve) => {
1101
+ closeResolve = resolve;
1102
+ });
1103
+
1104
+ const close = () => {
1105
+ if (closingPromise) {
1106
+ return closingPromise;
1107
+ }
1108
+
1109
+ closingPromise = new Promise((resolve, reject) => {
1110
+ cancelScheduledShutdown();
1111
+ removeSignalHandlers();
1112
+ server.close((err) => {
1113
+ try {
1114
+ if (err) {
1115
+ reject(err);
1116
+ return;
1117
+ }
1118
+ if (!cleanedUp) {
1119
+ removeViewerAssetsDir(viewerAssetsDir);
1120
+ cleanedUp = true;
1121
+ }
1122
+ resolve();
1123
+ } catch (cleanupErr) {
1124
+ reject(cleanupErr);
1125
+ } finally {
1126
+ closeResolve();
1127
+ }
1128
+ });
1129
+ });
1130
+
1131
+ return closingPromise;
1132
+ };
1133
+
1134
+ const scheduleShutdown = () => {
1135
+ cancelScheduledShutdown();
1136
+ shutdownTimer = setTimeout(() => {
1137
+ shutdownTimer = null;
1138
+ close().catch((err) => {
1139
+ console.error(
1140
+ `[warn] failed to close inspector server cleanly: ${err.message || err}`,
1141
+ );
1142
+ });
1143
+ }, SHUTDOWN_DELAY_MS);
1144
+ if (typeof shutdownTimer.unref === 'function') {
1145
+ shutdownTimer.unref();
1146
+ }
1147
+ };
1148
+
1149
+ const server = http.createServer((req, res) => {
1150
+ const requestUrl = new URL(
1151
+ normalizeRequestTarget(req.url),
1152
+ 'http://127.0.0.1',
1153
+ );
1154
+
1155
+ if (
1156
+ req.method === 'POST' &&
1157
+ requestUrl.pathname.startsWith('/__inspector/') &&
1158
+ req.headers.origin !== sessionOrigin
1159
+ ) {
1160
+ sendText(res, 403, 'Forbidden');
1161
+ return;
1162
+ }
1163
+
1164
+ if (requestUrl.pathname === SHUTDOWN_ENDPOINT_PATH) {
1165
+ if (req.method !== 'POST') {
1166
+ sendText(res, 405, 'Method Not Allowed', { Allow: 'POST' });
1167
+ return;
1168
+ }
1169
+ sendJson(res, 200, { ok: true });
1170
+ scheduleShutdown();
1171
+ return;
1172
+ }
1173
+
1174
+ cancelScheduledShutdown();
1175
+ handleViewerRequest(
1176
+ rootDir,
1177
+ tilesetPath,
1178
+ viewerAssetsDir,
1179
+ req,
1180
+ res,
1181
+ requestUrl,
1182
+ ).catch((err) => {
1183
+ sendJson(res, 500, {
1184
+ error:
1185
+ err instanceof Error && err.message
1186
+ ? err.message
1187
+ : 'Unexpected inspector server error.',
1188
+ });
1189
+ });
1190
+ });
1191
+
1192
+ if (handleSignals) {
1193
+ for (const event of ['SIGINT', 'SIGTERM']) {
1194
+ const handler = () => {
1195
+ close().catch((err) => {
1196
+ console.error(
1197
+ `[warn] failed to close inspector server cleanly: ${err.message || err}`,
1198
+ );
1199
+ });
1200
+ };
1201
+ signalHandlers.push({ event, handler });
1202
+ process.on(event, handler);
1203
+ }
1204
+ }
1205
+
1206
+ await new Promise((resolve, reject) => {
1207
+ server.once('error', reject);
1208
+ server.listen(0, '127.0.0.1', () => {
1209
+ server.off('error', reject);
1210
+ resolve();
1211
+ });
1212
+ });
1213
+
1214
+ const address = server.address();
1215
+ if (!address || typeof address === 'string') {
1216
+ await close();
1217
+ throw new InspectorError('Inspector server failed to bind to a TCP port.');
1218
+ }
1219
+
1220
+ sessionOrigin = `http://127.0.0.1:${address.port}`;
1221
+ const url = `${sessionOrigin}/${VIEWER_HTML_NAME}`;
1222
+ if (shouldOpenBrowser) {
1223
+ try {
1224
+ await openBrowser(url);
1225
+ } catch (err) {
1226
+ console.warn(
1227
+ `[warn] failed to open the browser automatically: ${err.message || err}`,
1228
+ );
1229
+ }
1230
+ }
1231
+
1232
+ return {
1233
+ close,
1234
+ port: address.port,
1235
+ url,
1236
+ waitUntilClosed() {
1237
+ return closed;
1238
+ },
1239
+ };
1240
+ }
1241
+
1242
+ module.exports = {
1243
+ startInspectorSession,
1244
+ };