3dtiles-inspector 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ const { assertPathInsideRoot } = require('./gltfResource');
6
6
  const { getRootUpRotationMatrix } = require('./gaussianPrimitives');
7
7
  const {
8
8
  collectCandidateSplatResources,
9
+ deleteOrphanedSplatResources,
9
10
  readTilesetJson,
10
11
  traverseTileset,
11
12
  } = require('./traversal');
@@ -35,6 +36,8 @@ async function deleteSplatsInNormalizedSelections(
35
36
  if (normalizedScreenSelections.length === 0) {
36
37
  return {
37
38
  deletedSplats: 0,
39
+ deletedSplatFiles: 0,
40
+ failedSplatFileDeletes: 0,
38
41
  processedSplatResources: 0,
39
42
  };
40
43
  }
@@ -45,11 +48,12 @@ async function deleteSplatsInNormalizedSelections(
45
48
 
46
49
  const { THREE } = await getSplatCropModules();
47
50
  const rootTileset = readTilesetJson(tilesetPath);
48
- const totalResources = collectCandidateSplatResources({
51
+ const initialResourcePaths = collectCandidateSplatResources({
49
52
  rootDir,
50
53
  tileset: rootTileset,
51
54
  tilesetPath,
52
- }).size;
55
+ });
56
+ const totalResources = initialResourcePaths.size;
53
57
  if (typeof onProgress === 'function') {
54
58
  const readStreamHint = tileReadStreamsClosed
55
59
  ? ' Tile read streams closed.'
@@ -77,7 +81,7 @@ async function deleteSplatsInNormalizedSelections(
77
81
  const workerPool = new SplatCropWorkerPool(SPLAT_CROP_WORKER_COUNT);
78
82
 
79
83
  try {
80
- return await traverseTileset({
84
+ const traversalResult = await traverseTileset({
81
85
  THREE,
82
86
  tilesetPath,
83
87
  tileset: rootTileset,
@@ -102,6 +106,20 @@ async function deleteSplatsInNormalizedSelections(
102
106
  resourceLocks: new Map(),
103
107
  workerPool,
104
108
  });
109
+ const remainingResourcePaths = collectCandidateSplatResources({
110
+ rootDir,
111
+ tilesetPath,
112
+ });
113
+ const cleanupResult = await deleteOrphanedSplatResources({
114
+ initialResourcePaths,
115
+ remainingResourcePaths,
116
+ rootDir,
117
+ });
118
+ return {
119
+ ...traversalResult,
120
+ deletedSplatFiles: cleanupResult.deletedFiles.length,
121
+ failedSplatFileDeletes: cleanupResult.failedFiles.length,
122
+ };
105
123
  } finally {
106
124
  await workerPool.close();
107
125
  }
@@ -20,7 +20,73 @@ const {
20
20
  hasNonGaussianScenePrimitives,
21
21
  hasScenePrimitives,
22
22
  removeMeshPrimitives,
23
+ updateGaussianPrimitiveAccessorCounts,
23
24
  } = require('./gaussianPrimitives');
25
+ const { SPLAT_CROP_WORKER_COUNT } = require('./workerPool');
26
+
27
+ class AsyncLimiter {
28
+ constructor(limit) {
29
+ this.active = 0;
30
+ this.limit = normalizeConcurrency(limit);
31
+ this.queue = [];
32
+ }
33
+
34
+ run(task) {
35
+ return new Promise((resolve, reject) => {
36
+ this.queue.push({ reject, resolve, task });
37
+ this.dispatch();
38
+ });
39
+ }
40
+
41
+ dispatch() {
42
+ while (this.active < this.limit && this.queue.length > 0) {
43
+ const job = this.queue.shift();
44
+ this.active += 1;
45
+ Promise.resolve()
46
+ .then(job.task)
47
+ .then(job.resolve, job.reject)
48
+ .finally(() => {
49
+ this.active -= 1;
50
+ this.dispatch();
51
+ });
52
+ }
53
+ }
54
+ }
55
+
56
+ function normalizeConcurrency(value) {
57
+ const number = Number(value);
58
+ return Number.isFinite(number) && number > 0
59
+ ? Math.max(1, Math.floor(number))
60
+ : 1;
61
+ }
62
+
63
+ function createAsyncLimiter(limit = SPLAT_CROP_WORKER_COUNT) {
64
+ return new AsyncLimiter(limit);
65
+ }
66
+
67
+ async function mapWithConcurrency(items, concurrency, mapper) {
68
+ if (!Array.isArray(items) || items.length === 0) {
69
+ return [];
70
+ }
71
+
72
+ const limit = normalizeConcurrency(concurrency);
73
+ const results = new Array(items.length);
74
+ let nextIndex = 0;
75
+
76
+ async function worker() {
77
+ while (nextIndex < items.length) {
78
+ const index = nextIndex;
79
+ nextIndex += 1;
80
+ results[index] = await mapper(items[index], index);
81
+ }
82
+ }
83
+
84
+ const workerCount = Math.min(limit, items.length);
85
+ await Promise.all(
86
+ Array.from({ length: workerCount }, () => worker()),
87
+ );
88
+ return results;
89
+ }
24
90
 
25
91
  function getContentSlots(tile) {
26
92
  const slots = [];
@@ -399,6 +465,126 @@ function collectCandidateSplatResources({
399
465
  return resourcePaths;
400
466
  }
401
467
 
468
+ function collectLocalGltfBufferPaths(filePath, rootDir) {
469
+ const bufferPaths = new Set();
470
+ if (path.extname(filePath).toLowerCase() !== '.gltf') {
471
+ return bufferPaths;
472
+ }
473
+ if (!fs.existsSync(filePath)) {
474
+ return bufferPaths;
475
+ }
476
+
477
+ const json = JSON.parse(fs.readFileSync(filePath, 'utf8'));
478
+ const buffers = Array.isArray(json.buffers) ? json.buffers : [];
479
+ const resourceDir = path.dirname(filePath);
480
+ buffers.forEach((buffer, index) => {
481
+ const uri = buffer?.uri;
482
+ if (
483
+ typeof uri !== 'string' ||
484
+ uri.length === 0 ||
485
+ /^data:/i.test(uri) ||
486
+ isRemoteOrProtocolUri(uri)
487
+ ) {
488
+ return;
489
+ }
490
+
491
+ bufferPaths.add(
492
+ resolveLocalUri(
493
+ resourceDir,
494
+ rootDir,
495
+ uri,
496
+ `${filePath}.buffers[${index}].uri`,
497
+ ),
498
+ );
499
+ });
500
+ return bufferPaths;
501
+ }
502
+
503
+ function collectReferencedGltfBufferPaths(resourcePaths, rootDir) {
504
+ const bufferPaths = new Set();
505
+ resourcePaths.forEach((resourcePath) => {
506
+ collectLocalGltfBufferPaths(resourcePath, rootDir).forEach((bufferPath) => {
507
+ bufferPaths.add(bufferPath);
508
+ });
509
+ });
510
+ return bufferPaths;
511
+ }
512
+
513
+ async function deleteFileIfSafe(filePath, rootDir) {
514
+ const resolvedPath = assertPathInsideRoot(
515
+ filePath,
516
+ rootDir,
517
+ 'Orphaned splat resource path',
518
+ );
519
+ let stats;
520
+ try {
521
+ stats = await fs.promises.stat(resolvedPath);
522
+ } catch (err) {
523
+ if (err && err.code === 'ENOENT') {
524
+ return { deleted: false };
525
+ }
526
+ return {
527
+ deleted: false,
528
+ error: err && err.message ? err.message : String(err),
529
+ };
530
+ }
531
+
532
+ if (!stats.isFile()) {
533
+ return { deleted: false };
534
+ }
535
+
536
+ try {
537
+ await fs.promises.unlink(resolvedPath);
538
+ return { deleted: true };
539
+ } catch (err) {
540
+ return {
541
+ deleted: false,
542
+ error: err && err.message ? err.message : String(err),
543
+ };
544
+ }
545
+ }
546
+
547
+ async function deleteOrphanedSplatResources({
548
+ initialResourcePaths,
549
+ remainingResourcePaths,
550
+ rootDir,
551
+ }) {
552
+ const remainingBuffers = collectReferencedGltfBufferPaths(
553
+ remainingResourcePaths,
554
+ rootDir,
555
+ );
556
+ const deletePaths = new Set();
557
+
558
+ initialResourcePaths.forEach((resourcePath) => {
559
+ if (remainingResourcePaths.has(resourcePath)) {
560
+ return;
561
+ }
562
+
563
+ deletePaths.add(resourcePath);
564
+ collectLocalGltfBufferPaths(resourcePath, rootDir).forEach((bufferPath) => {
565
+ if (
566
+ !remainingResourcePaths.has(bufferPath) &&
567
+ !remainingBuffers.has(bufferPath)
568
+ ) {
569
+ deletePaths.add(bufferPath);
570
+ }
571
+ });
572
+ });
573
+
574
+ const deletedFiles = [];
575
+ const failedFiles = [];
576
+ for (const filePath of deletePaths) {
577
+ const result = await deleteFileIfSafe(filePath, rootDir);
578
+ if (result.deleted) {
579
+ deletedFiles.push(filePath);
580
+ } else if (result.error) {
581
+ failedFiles.push({ error: result.error, filePath });
582
+ }
583
+ }
584
+
585
+ return { deletedFiles, failedFiles };
586
+ }
587
+
402
588
  function markSplatResourceProgress(context, resourcePath) {
403
589
  const progress = context.progress;
404
590
  if (
@@ -472,36 +658,17 @@ async function processGltfResource({
472
658
  const resourceBounds = createBounds();
473
659
  let hasResourceBounds = false;
474
660
  let deletedSplats = 0;
475
- const rewriteTasks = [];
661
+ let modified = false;
476
662
 
477
663
  for (const [bufferViewIndex, viewDescriptors] of byBufferView) {
478
664
  const slice = getBufferViewSlice(resource, bufferViewIndex);
479
- rewriteTasks.push({
480
- bufferViewIndex,
481
- slice,
482
- viewDescriptors,
483
- promise: rewriteSpzBytesInWorker({
484
- bytes: slice.bytes,
485
- descriptors: viewDescriptors,
486
- screenSelections,
487
- workerPool,
488
- }),
665
+ const rewrite = await rewriteSpzBytesInWorker({
666
+ bytes: slice.bytes,
667
+ descriptors: viewDescriptors,
668
+ screenSelections,
669
+ workerPool,
489
670
  });
490
- }
491
-
492
- const rewriteResults = await Promise.all(
493
- rewriteTasks.map(async (task) => ({
494
- ...task,
495
- rewrite: await task.promise,
496
- })),
497
- );
498
671
 
499
- for (const {
500
- bufferViewIndex,
501
- rewrite,
502
- slice,
503
- viewDescriptors,
504
- } of rewriteResults) {
505
672
  deletedSplats += rewrite.deleted;
506
673
  if (rewrite.bounds) {
507
674
  hasResourceBounds =
@@ -511,6 +678,14 @@ async function processGltfResource({
511
678
  emptyDescriptors.push(...viewDescriptors);
512
679
  continue;
513
680
  }
681
+ if (Number.isInteger(rewrite.survivorCount)) {
682
+ modified =
683
+ updateGaussianPrimitiveAccessorCounts(
684
+ resource,
685
+ viewDescriptors,
686
+ rewrite.survivorCount,
687
+ ) > 0 || modified;
688
+ }
514
689
  if (!rewrite.bytes) {
515
690
  continue;
516
691
  }
@@ -526,7 +701,6 @@ async function processGltfResource({
526
701
  });
527
702
  }
528
703
 
529
- let modified = false;
530
704
  if (emptyDescriptors.length > 0) {
531
705
  modified = removeMeshPrimitives(resource, emptyDescriptors) > 0 || modified;
532
706
  }
@@ -637,11 +811,13 @@ async function processGltfContentSlot(
637
811
  context.resourceLocks,
638
812
  resourcePath,
639
813
  () =>
640
- processLockedSplatResource(
641
- context,
642
- resourcePath,
643
- tileSceneMatrix,
644
- tileProjectionMatrix,
814
+ context.resourceLimiter.run(() =>
815
+ processLockedSplatResource(
816
+ context,
817
+ resourcePath,
818
+ tileSceneMatrix,
819
+ tileProjectionMatrix,
820
+ ),
645
821
  ),
646
822
  );
647
823
 
@@ -680,6 +856,8 @@ async function processNestedTilesetContentSlot(
680
856
  emptySplatResources: context.emptySplatResources,
681
857
  progress: context.progress,
682
858
  resourceLocks: context.resourceLocks,
859
+ resourceLimiter: context.resourceLimiter,
860
+ traversalConcurrency: context.traversalConcurrency,
683
861
  workerPool: context.workerPool,
684
862
  });
685
863
 
@@ -733,6 +911,14 @@ async function processContentSlot(context, slot, tileTransform, tileProjection)
733
911
  return createContentSlotResult(slot, { boundsKnown: false });
734
912
  }
735
913
 
914
+ async function processContentSlots(context, tile, tileTransform, tileProjection) {
915
+ return mapWithConcurrency(
916
+ getContentSlots(tile),
917
+ context.traversalConcurrency,
918
+ (slot) => processContentSlot(context, slot, tileTransform, tileProjection),
919
+ );
920
+ }
921
+
736
922
  function applyContentResultsToTile(context, tile, contentResults) {
737
923
  const contentBounds = [];
738
924
  let boundsKnown = true;
@@ -764,16 +950,18 @@ async function pruneEmptyChildren(context, tile, tileTransform, tileProjection)
764
950
  };
765
951
  }
766
952
 
767
- const children = tile.children.slice();
768
- const childResults = await Promise.all(
769
- children.map(async (child) => ({
770
- child,
771
- result: await visitTilesetTile(context, child, tileTransform, false),
772
- })),
773
- );
774
953
  const childBounds = [];
775
954
  let boundsKnown = true;
776
955
  const keptChildren = [];
956
+ const childResults = await mapWithConcurrency(
957
+ tile.children,
958
+ context.traversalConcurrency,
959
+ async (child) => ({
960
+ child,
961
+ result: await visitTilesetTile(context, child, tileTransform, false),
962
+ }),
963
+ );
964
+
777
965
  childResults.forEach(({ child, result }) => {
778
966
  if (result.empty) {
779
967
  context.tilesetModified = true;
@@ -831,10 +1019,11 @@ async function visitTilesetTile(context, tile, inheritedTransform, isRootTile) {
831
1019
  isRootTile,
832
1020
  );
833
1021
  const tileProjection = getTileProjection(context.THREE, tile);
834
- const contentResults = await Promise.all(
835
- getContentSlots(tile).map((slot) =>
836
- processContentSlot(context, slot, worldTransform, tileProjection),
837
- ),
1022
+ const contentResults = await processContentSlots(
1023
+ context,
1024
+ tile,
1025
+ worldTransform,
1026
+ tileProjection,
838
1027
  );
839
1028
 
840
1029
  const contentSummary = applyContentResultsToTile(context, tile, contentResults);
@@ -887,6 +1076,8 @@ async function traverseTileset({
887
1076
  emptySplatResources,
888
1077
  progress,
889
1078
  resourceLocks,
1079
+ resourceLimiter = null,
1080
+ traversalConcurrency = SPLAT_CROP_WORKER_COUNT,
890
1081
  workerPool,
891
1082
  }) {
892
1083
  const resolvedTilesetPath = assertPathInsideRoot(
@@ -910,6 +1101,8 @@ async function traverseTileset({
910
1101
  throw new InspectorError(`${resolvedTilesetPath} must contain a root object.`);
911
1102
  }
912
1103
 
1104
+ const normalizedTraversalConcurrency =
1105
+ normalizeConcurrency(traversalConcurrency);
913
1106
  const context = {
914
1107
  THREE,
915
1108
  deletedSplats: 0,
@@ -918,11 +1111,14 @@ async function traverseTileset({
918
1111
  processedResources,
919
1112
  processedSplatResources: 0,
920
1113
  resourceLocks,
1114
+ resourceLimiter:
1115
+ resourceLimiter || createAsyncLimiter(normalizedTraversalConcurrency),
921
1116
  rootDir,
922
1117
  rootTransform,
923
1118
  screenSelections,
924
1119
  tilesetDir: path.dirname(resolvedTilesetPath),
925
1120
  tilesetModified: false,
1121
+ traversalConcurrency: normalizedTraversalConcurrency,
926
1122
  upRotationMatrix,
927
1123
  visitedTilesets,
928
1124
  workerPool,
@@ -948,6 +1144,7 @@ async function traverseTileset({
948
1144
 
949
1145
  module.exports = {
950
1146
  collectCandidateSplatResources,
1147
+ deleteOrphanedSplatResources,
951
1148
  readTilesetJson,
952
1149
  traverseTileset,
953
1150
  };
@@ -344,10 +344,24 @@ async function rewriteSpzBytes({
344
344
  descriptors,
345
345
  );
346
346
  if (survivors.length === 0) {
347
- return { bounds, bytes: null, deleted, empty: true };
347
+ return {
348
+ bounds,
349
+ bytes: null,
350
+ deleted,
351
+ empty: true,
352
+ splatCount: spz.numSplats,
353
+ survivorCount: 0,
354
+ };
348
355
  }
349
356
  if (deleted === 0) {
350
- return { bounds, bytes: null, deleted: 0, empty: false };
357
+ return {
358
+ bounds,
359
+ bytes: null,
360
+ deleted: 0,
361
+ empty: false,
362
+ splatCount: spz.numSplats,
363
+ survivorCount: survivors.length,
364
+ };
351
365
  }
352
366
 
353
367
  return {
@@ -355,6 +369,8 @@ async function rewriteSpzBytes({
355
369
  bytes: await writeSurvivingSpzBytes(SpzWriter, spz, splatData, survivors),
356
370
  deleted,
357
371
  empty: false,
372
+ splatCount: spz.numSplats,
373
+ survivorCount: survivors.length,
358
374
  };
359
375
  }
360
376
 
@@ -380,6 +396,8 @@ parentPort.on('message', async (message) => {
380
396
  bytes,
381
397
  deleted: result.deleted,
382
398
  empty: result.empty,
399
+ splatCount: result.splatCount,
400
+ survivorCount: result.survivorCount,
383
401
  },
384
402
  },
385
403
  transferList,
@@ -1,11 +1,37 @@
1
1
  const path = require('path');
2
+ const os = require('os');
2
3
  const { Worker } = require('worker_threads');
3
4
 
4
5
  const { InspectorError } = require('../../errors');
5
6
 
6
- const SPLAT_CROP_WORKER_COUNT = 4;
7
+ const MAX_SPLAT_CROP_WORKER_COUNT = 8;
7
8
  const SPLAT_CROP_WORKER_PATH = path.join(__dirname, 'worker.js');
8
9
 
10
+ function getAvailableParallelism() {
11
+ if (typeof os.availableParallelism === 'function') {
12
+ try {
13
+ const parallelism = os.availableParallelism();
14
+ if (Number.isFinite(parallelism) && parallelism > 0) {
15
+ return parallelism;
16
+ }
17
+ } catch (err) {
18
+ // Fall back to os.cpus() below.
19
+ }
20
+ }
21
+
22
+ const cpus = os.cpus();
23
+ return Array.isArray(cpus) && cpus.length > 0 ? cpus.length : 1;
24
+ }
25
+
26
+ function getDefaultSplatCropWorkerCount() {
27
+ return Math.min(
28
+ MAX_SPLAT_CROP_WORKER_COUNT,
29
+ Math.max(1, getAvailableParallelism() - 1),
30
+ );
31
+ }
32
+
33
+ const SPLAT_CROP_WORKER_COUNT = getDefaultSplatCropWorkerCount();
34
+
9
35
  function deserializeWorkerError(error) {
10
36
  const message =
11
37
  error && typeof error.message === 'string'
@@ -74,9 +100,17 @@ class SplatCropWorkerPool {
74
100
  const result = message.result || {};
75
101
  job.resolve({
76
102
  bounds: result.bounds || null,
77
- bytes: result.bytes ? Buffer.from(result.bytes) : null,
103
+ bytes: result.bytes
104
+ ? Buffer.from(
105
+ result.bytes.buffer,
106
+ result.bytes.byteOffset,
107
+ result.bytes.byteLength,
108
+ )
109
+ : null,
78
110
  deleted: Number(result.deleted || 0),
79
111
  empty: !!result.empty,
112
+ splatCount: Number(result.splatCount || 0),
113
+ survivorCount: Number(result.survivorCount || 0),
80
114
  });
81
115
  }
82
116
 
@@ -177,6 +211,7 @@ class SplatCropWorkerPool {
177
211
  }
178
212
 
179
213
  module.exports = {
214
+ getDefaultSplatCropWorkerCount,
180
215
  SPLAT_CROP_WORKER_COUNT,
181
216
  SplatCropWorkerPool,
182
217
  };
package/src/viewer/app.js CHANGED
@@ -550,10 +550,11 @@ async function saveTransform() {
550
550
  const processedSplatResources = Number(
551
551
  payload.processedSplatResources || 0,
552
552
  );
553
+ const deletedSplatFiles = Number(payload.deletedSplatFiles || 0);
553
554
  cropController.clearAll();
554
555
  loadTileset(TILESET_URL, { frameOnLoad: false });
555
556
  setStatus(
556
- `Saved transform and deleted ${deletedSplats} cropped splats from ${processedSplatResources} splat resource${processedSplatResources === 1 ? '' : 's'}. Reloading tileset.`,
557
+ `Saved transform and deleted ${deletedSplats} cropped splats from ${processedSplatResources} splat resource${processedSplatResources === 1 ? '' : 's'}${deletedSplatFiles > 0 ? `, removing ${deletedSplatFiles} orphaned file${deletedSplatFiles === 1 ? '' : 's'}` : ''}. Reloading tileset.`,
557
558
  );
558
559
  } else {
559
560
  setStatus(
@@ -100,8 +100,8 @@ export function createTerrainGlobeTiles(options) {
100
100
  function createGeometricErrorLayerScalePlugin(preprocessNode) {
101
101
  return {
102
102
  name: 'GeometricErrorLayerScalePlugin',
103
- preprocessNode(tile) {
104
- preprocessNode(tile);
103
+ preprocessNode(tile, tilesetDir, parentTile) {
104
+ preprocessNode(tile, null, parentTile);
105
105
  },
106
106
  };
107
107
  }