3dtiles-inspector 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "3dtiles-inspector",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "Inspect, align, and save local 3D Tiles root transforms in an interactive browser session.",
5
5
  "author": "William Liu <lyz15972107087@gmail.com>",
6
6
  "license": "Apache-2.0",
@@ -55,8 +55,8 @@
55
55
  },
56
56
  "devDependencies": {
57
57
  "@sparkjsdev/spark": "2.1.0",
58
- "3d-tiles-renderer": "0.4.27",
59
- "3d-tiles-rendererjs-3dgs-plugin": "0.1.11",
58
+ "3d-tiles-renderer": "0.4.28",
59
+ "3d-tiles-rendererjs-3dgs-plugin": "0.1.14",
60
60
  "esbuild": "^0.25.11"
61
61
  },
62
62
  "dependencies": {
@@ -2,6 +2,23 @@ const { InspectorError } = require('../../errors');
2
2
  const { normalizeMatrix4Array } = require('./normalize');
3
3
  const { getNodeLocalMatrix } = require('./gltfResource');
4
4
 
5
+ const KHR_GAUSSIAN_SPLATTING_EXTENSION = 'KHR_gaussian_splatting';
6
+ const KHR_GAUSSIAN_SPLATTING_SPZ_EXTENSION =
7
+ 'KHR_gaussian_splatting_compression_spz_2';
8
+ const EXT_SPLAT_OPACITY_EXTENSION = 'EXT_splat_opacity';
9
+
10
+ function getGaussianSplattingExtensions(primitive) {
11
+ return primitive?.extensions?.[KHR_GAUSSIAN_SPLATTING_EXTENSION]?.extensions;
12
+ }
13
+
14
+ function getGaussianSplatOpacityExtension(primitive) {
15
+ const extensions = getGaussianSplattingExtensions(primitive);
16
+ return (
17
+ extensions?.[EXT_SPLAT_OPACITY_EXTENSION] ??
18
+ primitive?.extensions?.[EXT_SPLAT_OPACITY_EXTENSION]
19
+ );
20
+ }
21
+
5
22
  function getTileLocalTransform(THREE, tile) {
6
23
  const matrix = new THREE.Matrix4();
7
24
  if (Array.isArray(tile?.transform)) {
@@ -37,10 +54,22 @@ function getRootUpRotationMatrix(THREE, tileset) {
37
54
  }
38
55
 
39
56
  function primitiveHasGaussianSpzExtension(primitive) {
40
- return (
41
- primitive?.extensions?.KHR_gaussian_splatting?.extensions
42
- ?.KHR_gaussian_splatting_compression_spz_2 != null
43
- );
57
+ const extensions = getGaussianSplattingExtensions(primitive);
58
+ return extensions?.[KHR_GAUSSIAN_SPLATTING_SPZ_EXTENSION] != null;
59
+ }
60
+
61
+ function getGaussianSplatOpacityAccessorIndex(primitive) {
62
+ const opacityAccessor = getGaussianSplatOpacityExtension(primitive)
63
+ ?.opacityAccessor;
64
+ if (opacityAccessor == null) {
65
+ return null;
66
+ }
67
+ if (!Number.isInteger(opacityAccessor) || opacityAccessor < 0) {
68
+ throw new InspectorError(
69
+ 'EXT_splat_opacity opacityAccessor must be a non-negative integer.',
70
+ );
71
+ }
72
+ return opacityAccessor;
44
73
  }
45
74
 
46
75
  function collectGaussianPrimitiveDescriptors(
@@ -87,12 +116,12 @@ function collectGaussianPrimitiveDescriptors(
87
116
  const mesh = meshes[node.mesh];
88
117
  if (mesh && Array.isArray(mesh.primitives)) {
89
118
  mesh.primitives.forEach((primitive, primitiveIndex) => {
119
+ const extensions = getGaussianSplattingExtensions(primitive);
90
120
  const gaussianExtension =
91
- primitive?.extensions?.KHR_gaussian_splatting?.extensions
92
- ?.KHR_gaussian_splatting_compression_spz_2;
121
+ extensions?.[KHR_GAUSSIAN_SPLATTING_SPZ_EXTENSION];
93
122
  const bufferView = gaussianExtension?.bufferView;
94
123
  if (bufferView == null) {
95
- if (primitive?.extensions?.KHR_gaussian_splatting) {
124
+ if (primitive?.extensions?.[KHR_GAUSSIAN_SPLATTING_EXTENSION]) {
96
125
  throw new InspectorError(
97
126
  'Only KHR_gaussian_splatting_compression_spz_2 Gaussian primitives are supported for crop deletion.',
98
127
  );
@@ -288,6 +317,7 @@ function updateGaussianPrimitiveAccessorCounts(resource, descriptors, count) {
288
317
 
289
318
  module.exports = {
290
319
  collectGaussianPrimitiveDescriptors,
320
+ getGaussianSplatOpacityAccessorIndex,
291
321
  getRootUpRotationMatrix,
292
322
  getTileLocalTransform,
293
323
  getTileWorldTransform,
@@ -75,8 +75,16 @@ async function deleteSplatsInNormalizedSelections(
75
75
  planeMatrices: selection.planeMatrices
76
76
  ? selection.planeMatrices.map((matrix) => matrix.slice())
77
77
  : null,
78
- rect: { ...selection.rect },
79
- viewProjectionMatrix: selection.viewProjectionMatrix.slice(),
78
+ rect: selection.rect ? { ...selection.rect } : null,
79
+ sphere: selection.sphere
80
+ ? {
81
+ center: selection.sphere.center.slice(),
82
+ radius: selection.sphere.radius,
83
+ }
84
+ : null,
85
+ viewProjectionMatrix: selection.viewProjectionMatrix
86
+ ? selection.viewProjectionMatrix.slice()
87
+ : null,
80
88
  }));
81
89
  const workerPool = new SplatCropWorkerPool(SPLAT_CROP_WORKER_COUNT);
82
90
 
@@ -2,6 +2,7 @@ const { InspectorError } = require('../../errors');
2
2
 
3
3
  const MAX_SCREEN_SELECTIONS = 256;
4
4
  const SCREEN_SELECTION_ACTION_EXCLUDE = 'exclude';
5
+ const SCREEN_SELECTION_ACTION_INCLUDE = 'include';
5
6
 
6
7
  function normalizeMatrix4Array(value, name = 'matrix') {
7
8
  if (!Array.isArray(value) || value.length !== 16) {
@@ -56,12 +57,35 @@ function normalizeScreenSelectionAction(value, name) {
56
57
  if (value == null) {
57
58
  return SCREEN_SELECTION_ACTION_EXCLUDE;
58
59
  }
59
- if (value !== SCREEN_SELECTION_ACTION_EXCLUDE) {
60
- throw new InspectorError(`${name} must be "exclude".`);
60
+ if (
61
+ value !== SCREEN_SELECTION_ACTION_EXCLUDE &&
62
+ value !== SCREEN_SELECTION_ACTION_INCLUDE
63
+ ) {
64
+ throw new InspectorError(`${name} must be "exclude" or "include".`);
61
65
  }
62
66
  return value;
63
67
  }
64
68
 
69
+ function normalizeSphereSelection(value, name) {
70
+ if (value == null) {
71
+ return null;
72
+ }
73
+ if (!value || typeof value !== 'object') {
74
+ throw new InspectorError(`${name} must be an object.`);
75
+ }
76
+ if (!Array.isArray(value.center) || value.center.length !== 3) {
77
+ throw new InspectorError(`${name}.center must be a 3-number array.`);
78
+ }
79
+ const center = value.center.map((entry, index) =>
80
+ normalizeFiniteNumber(entry, `${name}.center[${index}]`),
81
+ );
82
+ const radius = normalizeFiniteNumber(value.radius, `${name}.radius`);
83
+ if (radius <= 0) {
84
+ throw new InspectorError(`${name}.radius must be greater than 0.`);
85
+ }
86
+ return { center, radius };
87
+ }
88
+
65
89
  function normalizeScreenSelectionPlaneMatrices(value, name) {
66
90
  if (value == null) {
67
91
  return null;
@@ -96,11 +120,23 @@ function normalizeSplatScreenSelections(value) {
96
120
  );
97
121
  }
98
122
 
123
+ const action = normalizeScreenSelectionAction(
124
+ entry.action,
125
+ `splatScreenSelections[${index}].action`,
126
+ );
127
+ const sphere = normalizeSphereSelection(
128
+ entry.sphere,
129
+ `splatScreenSelections[${index}].sphere`,
130
+ );
131
+ if (sphere) {
132
+ return {
133
+ action,
134
+ sphere,
135
+ };
136
+ }
137
+
99
138
  return {
100
- action: normalizeScreenSelectionAction(
101
- entry.action,
102
- `splatScreenSelections[${index}].action`,
103
- ),
139
+ action,
104
140
  rect: normalizeScreenSelectionRect(
105
141
  entry.rect,
106
142
  `splatScreenSelections[${index}].rect`,
@@ -119,6 +155,7 @@ function normalizeSplatScreenSelections(value) {
119
155
 
120
156
  module.exports = {
121
157
  SCREEN_SELECTION_ACTION_EXCLUDE,
158
+ SCREEN_SELECTION_ACTION_INCLUDE,
122
159
  normalizeMatrix4Array,
123
160
  normalizeSplatScreenSelections,
124
161
  };
@@ -16,6 +16,7 @@ const {
16
16
  } = require('./gltfResource');
17
17
  const {
18
18
  collectGaussianPrimitiveDescriptors,
19
+ getGaussianSplatOpacityAccessorIndex,
19
20
  getTileLocalTransform,
20
21
  hasNonGaussianScenePrimitives,
21
22
  hasScenePrimitives,
@@ -24,6 +25,10 @@ const {
24
25
  } = require('./gaussianPrimitives');
25
26
  const { SPLAT_CROP_WORKER_COUNT } = require('./workerPool');
26
27
 
28
+ const ACCESSOR_COMPONENT_TYPE_FLOAT = 5126;
29
+ const ACCESSOR_TYPE_SCALAR = 'SCALAR';
30
+ const FLOAT32_BYTE_LENGTH = 4;
31
+
27
32
  class AsyncLimiter {
28
33
  constructor(limit) {
29
34
  this.active = 0;
@@ -618,6 +623,154 @@ function markSplatResourceProgress(context, resourcePath) {
618
623
  });
619
624
  }
620
625
 
626
+ function getPrimitiveForDescriptor(resource, descriptor) {
627
+ if (
628
+ !Number.isInteger(descriptor.meshIndex) ||
629
+ !Number.isInteger(descriptor.primitiveIndex)
630
+ ) {
631
+ return null;
632
+ }
633
+ return resource.json.meshes?.[descriptor.meshIndex]?.primitives?.[
634
+ descriptor.primitiveIndex
635
+ ];
636
+ }
637
+
638
+ function getAccessorDefinition(resource, accessorIndex, label) {
639
+ const accessor = resource.json.accessors?.[accessorIndex];
640
+ if (!accessor || typeof accessor !== 'object') {
641
+ throw new InspectorError(`${label} references missing accessor ${accessorIndex}.`);
642
+ }
643
+ if (accessor.componentType !== ACCESSOR_COMPONENT_TYPE_FLOAT) {
644
+ throw new InspectorError(`${label} must use FLOAT componentType.`);
645
+ }
646
+ if (accessor.type !== ACCESSOR_TYPE_SCALAR) {
647
+ throw new InspectorError(`${label} must use SCALAR type.`);
648
+ }
649
+ if (accessor.sparse) {
650
+ throw new InspectorError(`${label} sparse accessors are not supported.`);
651
+ }
652
+ if (!Number.isInteger(accessor.bufferView)) {
653
+ throw new InspectorError(`${label} must reference a bufferView.`);
654
+ }
655
+ if (!Number.isInteger(accessor.count) || accessor.count < 0) {
656
+ throw new InspectorError(`${label} count must be a non-negative integer.`);
657
+ }
658
+
659
+ const byteOffset = Number(accessor.byteOffset ?? 0);
660
+ if (!Number.isInteger(byteOffset) || byteOffset < 0) {
661
+ throw new InspectorError(`${label} byteOffset must be a non-negative integer.`);
662
+ }
663
+ const bufferView = resource.json.bufferViews?.[accessor.bufferView];
664
+ if (!bufferView || typeof bufferView !== 'object') {
665
+ throw new InspectorError(`${label} references missing bufferView.`);
666
+ }
667
+ const byteStride = Number(bufferView.byteStride ?? FLOAT32_BYTE_LENGTH);
668
+ if (!Number.isInteger(byteStride) || byteStride < FLOAT32_BYTE_LENGTH) {
669
+ throw new InspectorError(
670
+ `${label} byteStride must be at least ${FLOAT32_BYTE_LENGTH}.`,
671
+ );
672
+ }
673
+
674
+ return { accessor, bufferView, byteOffset, byteStride };
675
+ }
676
+
677
+ function makeFloat32ScalarAccessorSubsetReplacement(
678
+ resource,
679
+ accessorIndex,
680
+ survivors,
681
+ ) {
682
+ const label = `EXT_splat_opacity accessor ${accessorIndex}`;
683
+ const { accessor, bufferView, byteOffset, byteStride } = getAccessorDefinition(
684
+ resource,
685
+ accessorIndex,
686
+ label,
687
+ );
688
+ const slice = getBufferViewSlice(resource, accessor.bufferView);
689
+ const sourceLength =
690
+ accessor.count === 0
691
+ ? 0
692
+ : (accessor.count - 1) * byteStride + FLOAT32_BYTE_LENGTH;
693
+ const sourceEnd = byteOffset + sourceLength;
694
+
695
+ if (sourceEnd > slice.bytes.length) {
696
+ throw new InspectorError(`${label} exceeds its bufferView.`);
697
+ }
698
+
699
+ const out = Buffer.allocUnsafe(survivors.length * FLOAT32_BYTE_LENGTH);
700
+ let min = Infinity;
701
+ let max = -Infinity;
702
+
703
+ for (let index = 0; index < survivors.length; index++) {
704
+ const sourceIndex = survivors[index];
705
+ if (
706
+ !Number.isInteger(sourceIndex) ||
707
+ sourceIndex < 0 ||
708
+ sourceIndex >= accessor.count
709
+ ) {
710
+ throw new InspectorError(`${label} survivor index is out of range.`);
711
+ }
712
+
713
+ const value = slice.bytes.readFloatLE(
714
+ byteOffset + sourceIndex * byteStride,
715
+ );
716
+ out.writeFloatLE(value, index * FLOAT32_BYTE_LENGTH);
717
+ if (value < min) min = value;
718
+ if (value > max) max = value;
719
+ }
720
+
721
+ accessor.count = survivors.length;
722
+ delete accessor.byteOffset;
723
+ delete bufferView.byteStride;
724
+ if (survivors.length > 0) {
725
+ accessor.min = [min];
726
+ accessor.max = [max];
727
+ }
728
+
729
+ return {
730
+ bufferIndex: slice.bufferIndex,
731
+ bufferViewIndex: accessor.bufferView,
732
+ bytes: out,
733
+ end: slice.end,
734
+ start: slice.start,
735
+ };
736
+ }
737
+
738
+ function addGaussianSplatOpacityAccessorReplacements(
739
+ resource,
740
+ descriptors,
741
+ survivors,
742
+ replacementsByBuffer,
743
+ ) {
744
+ if (!(survivors instanceof Uint32Array) || survivors.length === 0) {
745
+ return 0;
746
+ }
747
+
748
+ const opacityAccessors = new Set();
749
+ descriptors.forEach((descriptor) => {
750
+ const primitive = getPrimitiveForDescriptor(resource, descriptor);
751
+ const accessorIndex = getGaussianSplatOpacityAccessorIndex(primitive);
752
+ if (accessorIndex !== null) {
753
+ opacityAccessors.add(accessorIndex);
754
+ }
755
+ });
756
+
757
+ let replacementCount = 0;
758
+ opacityAccessors.forEach((accessorIndex) => {
759
+ const replacement = makeFloat32ScalarAccessorSubsetReplacement(
760
+ resource,
761
+ accessorIndex,
762
+ survivors,
763
+ );
764
+ if (!replacementsByBuffer.has(replacement.bufferIndex)) {
765
+ replacementsByBuffer.set(replacement.bufferIndex, []);
766
+ }
767
+ addReplacement(replacementsByBuffer.get(replacement.bufferIndex), replacement);
768
+ replacementCount += 1;
769
+ });
770
+
771
+ return replacementCount;
772
+ }
773
+
621
774
  async function processGltfResource({
622
775
  THREE,
623
776
  filePath,
@@ -679,6 +832,15 @@ async function processGltfResource({
679
832
  continue;
680
833
  }
681
834
  if (Number.isInteger(rewrite.survivorCount)) {
835
+ if (rewrite.deleted > 0) {
836
+ modified =
837
+ addGaussianSplatOpacityAccessorReplacements(
838
+ resource,
839
+ viewDescriptors,
840
+ rewrite.survivors,
841
+ replacementsByBuffer,
842
+ ) > 0 || modified;
843
+ }
682
844
  modified =
683
845
  updateGaussianPrimitiveAccessorCounts(
684
846
  resource,
@@ -5,6 +5,7 @@ const {
5
5
  } = require('./spzSubsetWriter');
6
6
 
7
7
  const SPZ_POSITION_STRIDE = 9;
8
+ const SCREEN_SELECTION_ACTION_INCLUDE = 'include';
8
9
 
9
10
  function multiplyMatrix4(left, right) {
10
11
  const out = new Float64Array(16);
@@ -81,8 +82,18 @@ function buildSourceToScreenSelections(screenSelections, descriptors) {
81
82
  for (let j = 0; j < descriptors.length; j++) {
82
83
  const descriptor = descriptors[j];
83
84
  const sourceToWorldMatrix = descriptor.sourceToWorldMatrix;
85
+ if (selection.sphere) {
86
+ sourceToScreenSelections.push({
87
+ action: selection.action,
88
+ radius: selection.sphere.radius,
89
+ sphereCenter: selection.sphere.center,
90
+ sourceToWorldMatrix,
91
+ });
92
+ continue;
93
+ }
84
94
  if (selection.planeMatrices) {
85
95
  sourceToScreenSelections.push({
96
+ action: selection.action,
86
97
  planes: buildSourceSpacePlaneValues(
87
98
  selection.planeMatrices,
88
99
  sourceToWorldMatrix,
@@ -91,6 +102,7 @@ function buildSourceToScreenSelections(screenSelections, descriptors) {
91
102
  } else {
92
103
  const rect = selection.rect;
93
104
  sourceToScreenSelections.push({
105
+ action: selection.action,
94
106
  matrix: multiplyMatrix4(
95
107
  selection.viewProjectionMatrix,
96
108
  sourceToWorldMatrix,
@@ -119,7 +131,25 @@ function buildSourceSpacePlaneValues(planeMatrices, sourceToWorldMatrix) {
119
131
  return values;
120
132
  }
121
133
 
134
+ function transformPoint(matrix, x, y, z) {
135
+ const w = matrix[3] * x + matrix[7] * y + matrix[11] * z + matrix[15];
136
+ const inverseW = Number.isFinite(w) && w !== 0 ? 1 / w : 1;
137
+ return {
138
+ x: (matrix[0] * x + matrix[4] * y + matrix[8] * z + matrix[12]) * inverseW,
139
+ y: (matrix[1] * x + matrix[5] * y + matrix[9] * z + matrix[13]) * inverseW,
140
+ z: (matrix[2] * x + matrix[6] * y + matrix[10] * z + matrix[14]) * inverseW,
141
+ };
142
+ }
143
+
122
144
  function centerIsInsideScreenSelection(x, y, z, selection) {
145
+ if (selection.sphereCenter) {
146
+ const point = transformPoint(selection.sourceToWorldMatrix, x, y, z);
147
+ const dx = point.x - selection.sphereCenter[0];
148
+ const dy = point.y - selection.sphereCenter[1];
149
+ const dz = point.z - selection.sphereCenter[2];
150
+ return dx * dx + dy * dy + dz * dz <= selection.radius * selection.radius;
151
+ }
152
+
123
153
  if (selection.planes) {
124
154
  const planes = selection.planes;
125
155
  for (let i = 0; i < planes.length; i += 4) {
@@ -166,14 +196,12 @@ function centerIsSelectedForDeletion(
166
196
  sourceToScreenSelections,
167
197
  ) {
168
198
  for (let i = 0; i < sourceToScreenSelections.length; i++) {
169
- if (
170
- centerIsInsideScreenSelection(
171
- x,
172
- y,
173
- z,
174
- sourceToScreenSelections[i],
175
- )
176
- ) {
199
+ const selection = sourceToScreenSelections[i];
200
+ const inside = centerIsInsideScreenSelection(x, y, z, selection);
201
+ if (selection.action === SCREEN_SELECTION_ACTION_INCLUDE && !inside) {
202
+ return true;
203
+ }
204
+ if (selection.action !== SCREEN_SELECTION_ACTION_INCLUDE && inside) {
177
205
  return true;
178
206
  }
179
207
  }
@@ -345,6 +373,7 @@ function rewriteSpzBytes({
345
373
  deleted,
346
374
  empty: false,
347
375
  splatCount: parsed.sourceCount,
376
+ survivors,
348
377
  survivorCount: survivors.length,
349
378
  };
350
379
  }
@@ -373,6 +402,11 @@ parentPort.on('message', async (message) => {
373
402
  bytes = getTransferableBytes(result.bytes);
374
403
  transferList.push(bytes.buffer);
375
404
  }
405
+ let survivors = null;
406
+ if (result.survivors) {
407
+ survivors = result.survivors;
408
+ transferList.push(survivors.buffer);
409
+ }
376
410
 
377
411
  parentPort.postMessage(
378
412
  {
@@ -383,6 +417,7 @@ parentPort.on('message', async (message) => {
383
417
  deleted: result.deleted,
384
418
  empty: result.empty,
385
419
  splatCount: result.splatCount,
420
+ survivors,
386
421
  survivorCount: result.survivorCount,
387
422
  },
388
423
  },
@@ -110,6 +110,8 @@ class SplatCropWorkerPool {
110
110
  deleted: Number(result.deleted || 0),
111
111
  empty: !!result.empty,
112
112
  splatCount: Number(result.splatCount || 0),
113
+ survivors:
114
+ result.survivors instanceof Uint32Array ? result.survivors : null,
113
115
  survivorCount: Number(result.survivorCount || 0),
114
116
  });
115
117
  }
@@ -612,6 +612,11 @@ function buildViewerHtml(viewerConfig) {
612
612
  inset 0 -1px 0 rgba(73, 80, 87, 0.08);
613
613
  }
614
614
 
615
+ .scale-track.disabled {
616
+ cursor: default;
617
+ opacity: 0.48;
618
+ }
619
+
615
620
  .scale-track:focus {
616
621
  outline: none;
617
622
  }
@@ -700,6 +705,18 @@ function buildViewerHtml(viewerConfig) {
700
705
  min-height: 0;
701
706
  }
702
707
 
708
+ .crop-subsection {
709
+ display: grid;
710
+ gap: 8px;
711
+ min-width: 0;
712
+ }
713
+
714
+ .crop-subsection + .crop-subsection {
715
+ margin-top: 6px;
716
+ padding-top: 10px;
717
+ border-top: 1px solid rgba(22, 50, 79, 0.1);
718
+ }
719
+
703
720
  .crop-list:empty {
704
721
  display: none;
705
722
  }
@@ -1024,18 +1041,45 @@ function buildViewerHtml(viewerConfig) {
1024
1041
  </label>
1025
1042
  </div>
1026
1043
  <div id="crop-section" class="toolbar-section" hidden>
1027
- <div class="toolbar-section-header">
1028
- <p class="toolbar-section-title">Crop Regions</p>
1029
- <p id="crop-count-value" class="toolbar-value">0</p>
1030
- </div>
1031
- <div class="coordinate-actions">
1032
- <button id="crop-screen-select" class="wide" type="button">Draw Region</button>
1044
+ <div class="crop-subsection">
1045
+ <div class="toolbar-section-header">
1046
+ <p class="toolbar-section-title">Crop Sphere</p>
1047
+ <p id="keep-sphere-radius-value" class="toolbar-value">None</p>
1048
+ </div>
1049
+ <div class="coordinate-actions">
1050
+ <button id="keep-sphere-create" class="wide" type="button">Create Sphere</button>
1051
+ </div>
1052
+ <div class="range-field">
1053
+ <div
1054
+ id="keep-sphere-radius"
1055
+ class="scale-track disabled"
1056
+ tabindex="0"
1057
+ aria-label="Crop sphere radius"
1058
+ aria-disabled="true"
1059
+ >
1060
+ <span class="scale-track-center" aria-hidden="true"></span>
1061
+ </div>
1062
+ </div>
1063
+ <div class="status-actions">
1064
+ <button id="keep-sphere-confirm" type="button">Confirm</button>
1065
+ <button id="keep-sphere-cancel" type="button">Cancel</button>
1066
+ </div>
1067
+ <div id="keep-sphere-list" class="crop-list"></div>
1033
1068
  </div>
1034
- <div class="status-actions">
1035
- <button id="crop-screen-confirm" type="button">Confirm</button>
1036
- <button id="crop-screen-cancel" type="button">Cancel</button>
1069
+ <div class="crop-subsection">
1070
+ <div class="toolbar-section-header">
1071
+ <p class="toolbar-section-title">Crop Regions</p>
1072
+ <p id="crop-count-value" class="toolbar-value">0</p>
1073
+ </div>
1074
+ <div class="coordinate-actions">
1075
+ <button id="crop-screen-select" class="wide" type="button">Draw Region</button>
1076
+ </div>
1077
+ <div class="status-actions">
1078
+ <button id="crop-screen-confirm" type="button">Confirm</button>
1079
+ <button id="crop-screen-cancel" type="button">Cancel</button>
1080
+ </div>
1081
+ <div id="crop-list" class="crop-list"></div>
1037
1082
  </div>
1038
- <div id="crop-list" class="crop-list"></div>
1039
1083
  </div>
1040
1084
  </div>
1041
1085
  <div class="toolbar-section status-panel">
package/src/viewer/app.js CHANGED
@@ -331,8 +331,13 @@ cropController = createCropController({
331
331
  cancelOtherPositionPickModes: () => setPositionController.cancelMode(),
332
332
  getCurrentRootTransformArray: () =>
333
333
  rootTransform.getCurrentRootTransformArray(),
334
+ getLocalFrameQuaternion: (referencePoint, target) =>
335
+ geoCamera.getLocalFrameQuaternion(referencePoint, target),
334
336
  getTilesetBoundingSphere,
335
337
  });
338
+ cameraController.setRaycastHitFilter((intersection) =>
339
+ cropController?.isRaycastHitVisible(intersection) ?? true,
340
+ );
336
341
 
337
342
  transformModeController = createTransformModeController({
338
343
  cropController,
@@ -516,7 +521,7 @@ async function saveTransform() {
516
521
  cancelPositionPickModes();
517
522
  if (cropController.hasPendingSelections()) {
518
523
  setStatus(
519
- 'Confirm or cancel pending screen selections before saving.',
524
+ 'Confirm or cancel pending crop selections before saving.',
520
525
  true,
521
526
  );
522
527
  return;
@@ -601,8 +606,15 @@ bindViewerEvents({
601
606
  getTiles: () => tiles,
602
607
  handlers: {
603
608
  cancelCropScreenSelection: cropController.cancel,
609
+ beginKeepSphereRadiusTrackDrag:
610
+ cropController.beginKeepSphereRadiusTrackDrag,
611
+ cancelKeepSphere: cropController.cancelKeepSphere,
604
612
  cancelPositionPickModes,
605
613
  confirmCropScreenSelection: cropController.confirm,
614
+ confirmKeepSphere: cropController.confirmKeepSphere,
615
+ createKeepSphere: cropController.createKeepSphere,
616
+ endKeepSphereRadiusTrackDrag:
617
+ cropController.endKeepSphereRadiusTrackDrag,
606
618
  handleScreenSelectionPointerCancel: cropController.handlePointerCancel,
607
619
  handleScreenSelectionPointerDown: cropController.handlePointerDown,
608
620
  handleScreenSelectionPointerMove: cropController.handlePointerMove,
@@ -617,6 +629,9 @@ bindViewerEvents({
617
629
  requestViewerShutdown,
618
630
  resetToSaved,
619
631
  saveTransform,
632
+ nudgeKeepSphereRadiusExponent: cropController.nudgeKeepSphereRadiusExponent,
633
+ setKeepSphereRadiusFromTrackClientX:
634
+ cropController.setKeepSphereRadiusFromTrackClientX,
620
635
  setTerrainEnabled: viewerToggles.setTerrainEnabled,
621
636
  toggleBoundingVolume: viewerToggles.toggleBoundingVolume,
622
637
  toggleCropScreenSelectionMode: cropController.toggle,