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.
@@ -1,6 +1,17 @@
1
- import { Sphere, Vector3 } from 'three';
1
+ import {
2
+ BufferGeometry,
3
+ Float32BufferAttribute,
4
+ Group,
5
+ LineBasicMaterial,
6
+ LineSegments,
7
+ Matrix4,
8
+ Quaternion,
9
+ Sphere,
10
+ Vector3,
11
+ } from 'three';
2
12
  import {
3
13
  SCREEN_SELECTION_ACTION_EXCLUDE,
14
+ SCREEN_SELECTION_ACTION_INCLUDE,
4
15
  createScreenSelection,
5
16
  createScreenSelectionEdit,
6
17
  createScreenSelectionFarHandle,
@@ -13,6 +24,7 @@ import {
13
24
  setScreenSelectionFarDepth,
14
25
  updateScreenSelectionWorldState,
15
26
  } from './index.js';
27
+ import { applySphereSelectionSdf } from './sdf.js';
16
28
  import {
17
29
  clearOverlay,
18
30
  createSelectionData,
@@ -37,6 +49,122 @@ import { updateCropControls } from '../dom/cropUi.js';
37
49
  const CAMERA_POSITION_EPSILON_SQ = 1e-12;
38
50
  const CAMERA_QUATERNION_EPSILON = 1e-10;
39
51
  const CAMERA_PROJECTION_EPSILON = 1e-10;
52
+ const KEEP_SPHERE_RADIUS_TRACK_PIXELS_PER_EXPONENT = 90;
53
+ const KEEP_SPHERE_MIN_RADIUS = 1e-6;
54
+ const KEEP_SPHERE_WIREFRAME_COLOR = 0xffffff;
55
+ const KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS = 20;
56
+ const KEEP_SPHERE_WIREFRAME_LONGITUDE_SEGMENTS = 80;
57
+ const KEEP_SPHERE_WIREFRAME_MERIDIANS = 20;
58
+ const KEEP_SPHERE_WIREFRAME_OVERLAY_OPACITY = 0.35;
59
+ const KEEP_SPHERE_WIREFRAME_RENDER_ORDER = 1000001;
60
+ const RAYCAST_CROP_EPSILON = 1e-6;
61
+
62
+ function pushKeepSphereSegment(vertices, start, end) {
63
+ vertices.push(start[0], start[1], start[2], end[0], end[1], end[2]);
64
+ }
65
+
66
+ function createKeepSphereWireframeGeometry() {
67
+ const vertices = [];
68
+
69
+ for (
70
+ let latitudeIndex = 1;
71
+ latitudeIndex < KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS;
72
+ latitudeIndex++
73
+ ) {
74
+ const phi =
75
+ (Math.PI * latitudeIndex) / KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS;
76
+ const z = Math.cos(phi);
77
+ const ringRadius = Math.sin(phi);
78
+
79
+ for (
80
+ let longitudeIndex = 0;
81
+ longitudeIndex < KEEP_SPHERE_WIREFRAME_LONGITUDE_SEGMENTS;
82
+ longitudeIndex++
83
+ ) {
84
+ const theta0 =
85
+ (Math.PI * 2 * longitudeIndex) /
86
+ KEEP_SPHERE_WIREFRAME_LONGITUDE_SEGMENTS;
87
+ const theta1 =
88
+ (Math.PI * 2 * (longitudeIndex + 1)) /
89
+ KEEP_SPHERE_WIREFRAME_LONGITUDE_SEGMENTS;
90
+ pushKeepSphereSegment(
91
+ vertices,
92
+ [
93
+ Math.cos(theta0) * ringRadius,
94
+ Math.sin(theta0) * ringRadius,
95
+ z,
96
+ ],
97
+ [
98
+ Math.cos(theta1) * ringRadius,
99
+ Math.sin(theta1) * ringRadius,
100
+ z,
101
+ ],
102
+ );
103
+ }
104
+ }
105
+
106
+ for (
107
+ let meridianIndex = 0;
108
+ meridianIndex < KEEP_SPHERE_WIREFRAME_MERIDIANS;
109
+ meridianIndex++
110
+ ) {
111
+ const theta =
112
+ (Math.PI * 2 * meridianIndex) / KEEP_SPHERE_WIREFRAME_MERIDIANS;
113
+ const cosTheta = Math.cos(theta);
114
+ const sinTheta = Math.sin(theta);
115
+
116
+ for (
117
+ let latitudeIndex = 0;
118
+ latitudeIndex < KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS;
119
+ latitudeIndex++
120
+ ) {
121
+ const phi0 =
122
+ (Math.PI * latitudeIndex) / KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS;
123
+ const phi1 =
124
+ (Math.PI * (latitudeIndex + 1)) /
125
+ KEEP_SPHERE_WIREFRAME_LATITUDE_SEGMENTS;
126
+ pushKeepSphereSegment(
127
+ vertices,
128
+ [
129
+ cosTheta * Math.sin(phi0),
130
+ sinTheta * Math.sin(phi0),
131
+ Math.cos(phi0),
132
+ ],
133
+ [
134
+ cosTheta * Math.sin(phi1),
135
+ sinTheta * Math.sin(phi1),
136
+ Math.cos(phi1),
137
+ ],
138
+ );
139
+ }
140
+ }
141
+
142
+ const geometry = new BufferGeometry();
143
+ geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
144
+ geometry.computeBoundingSphere();
145
+ return geometry;
146
+ }
147
+
148
+ function createKeepSphereWireframeMaterial({ depthTest, opacity = 1 }) {
149
+ return new LineBasicMaterial({
150
+ color: KEEP_SPHERE_WIREFRAME_COLOR,
151
+ depthTest,
152
+ depthWrite: depthTest,
153
+ opacity,
154
+ transparent: opacity < 1,
155
+ });
156
+ }
157
+
158
+ function isGaussianSplatObject(object) {
159
+ let current = object;
160
+ while (current) {
161
+ if (current.userData?.gaussianSplat) {
162
+ return true;
163
+ }
164
+ current = current.parent;
165
+ }
166
+ return false;
167
+ }
40
168
 
41
169
  export function createCropController({
42
170
  camera,
@@ -53,6 +181,7 @@ export function createCropController({
53
181
  viewerElements,
54
182
  cancelOtherPositionPickModes,
55
183
  getCurrentRootTransformArray,
184
+ getLocalFrameQuaternion,
56
185
  getTilesetBoundingSphere,
57
186
  }) {
58
187
  let selections = [];
@@ -62,9 +191,22 @@ export function createCropController({
62
191
  let pendingMode = false;
63
192
  let pendingScreenEdit = null;
64
193
  let pendingEditDrag = null;
194
+ let keepSphere = null;
195
+ let keepSphereTrackDrag = null;
65
196
  let editCursor = '';
66
197
  let hasGaussianSplats = false;
67
198
  const sphere = new Sphere();
199
+ const rootMatrix = new Matrix4();
200
+ const rootInverseMatrix = new Matrix4();
201
+ const sphereWorldCenter = new Vector3();
202
+ const sphereLocalCenter = new Vector3();
203
+ const sphereWorldQuaternion = new Quaternion();
204
+ const raycastPlaneMatrix = new Matrix4();
205
+ const raycastPlanePosition = new Vector3();
206
+ const raycastPlaneQuaternion = new Quaternion();
207
+ const raycastPlaneScale = new Vector3();
208
+ const raycastPlaneNormal = new Vector3(0, 0, 1);
209
+ const raycastSphereCenter = new Vector3();
68
210
  const cameraForward = new Vector3();
69
211
  const screenEditOverlay = createScreenEditOverlay({ overlayEl, rectEl });
70
212
 
@@ -75,14 +217,182 @@ export function createCropController({
75
217
  return findSelection(activeSelectionId)?.selection || null;
76
218
  }
77
219
 
220
+ function isPointInsideScreenSelection(selection, point) {
221
+ if (!selection?.planeMatrices) {
222
+ return false;
223
+ }
224
+
225
+ for (const matrixArray of selection.planeMatrices) {
226
+ raycastPlaneMatrix.fromArray(matrixArray);
227
+ raycastPlaneMatrix.decompose(
228
+ raycastPlanePosition,
229
+ raycastPlaneQuaternion,
230
+ raycastPlaneScale,
231
+ );
232
+ raycastPlaneNormal
233
+ .set(0, 0, 1)
234
+ .applyQuaternion(raycastPlaneQuaternion)
235
+ .normalize();
236
+ const distance =
237
+ raycastPlaneNormal.dot(point) -
238
+ raycastPlaneNormal.dot(raycastPlanePosition);
239
+ if (distance > RAYCAST_CROP_EPSILON) {
240
+ return false;
241
+ }
242
+ }
243
+ return true;
244
+ }
245
+
246
+ function isPointInsideCropSphere(selection, point) {
247
+ const radius = Number(selection?.worldRadius);
248
+ if (
249
+ !Array.isArray(selection?.worldCenter) ||
250
+ !Number.isFinite(radius) ||
251
+ radius <= 0
252
+ ) {
253
+ return true;
254
+ }
255
+
256
+ raycastSphereCenter.fromArray(selection.worldCenter);
257
+ return (
258
+ point.distanceToSquared(raycastSphereCenter) <=
259
+ radius * radius + RAYCAST_CROP_EPSILON
260
+ );
261
+ }
262
+
263
+ function isSplatPointVisibleForRaycast(point) {
264
+ for (const selection of selections) {
265
+ if (selection.id === activeSelectionId) {
266
+ continue;
267
+ }
268
+ if (isPointInsideScreenSelection(selection, point)) {
269
+ return false;
270
+ }
271
+ }
272
+
273
+ if (
274
+ keepSphere?.confirmed &&
275
+ keepSphere.id !== activeSelectionId &&
276
+ !isPointInsideCropSphere(keepSphere, point)
277
+ ) {
278
+ return false;
279
+ }
280
+
281
+ return true;
282
+ }
283
+
284
+ function getCurrentRootMatrix(target = rootMatrix) {
285
+ return target.fromArray(getCurrentRootTransformArray());
286
+ }
287
+
288
+ function getCurrentRootScale() {
289
+ const scale = getCurrentRootMatrix(rootMatrix).getMaxScaleOnAxis();
290
+ return Number.isFinite(scale) && scale > 0 ? scale : 1;
291
+ }
292
+
293
+ function updateKeepSphereWorldState(selection = keepSphere) {
294
+ if (!selection) {
295
+ return;
296
+ }
297
+
298
+ getCurrentRootMatrix(rootMatrix);
299
+ sphereLocalCenter.fromArray(selection.localCenter);
300
+ sphereWorldCenter.copy(sphereLocalCenter).applyMatrix4(rootMatrix);
301
+ const rootScale = rootMatrix.getMaxScaleOnAxis();
302
+ const safeScale = Number.isFinite(rootScale) && rootScale > 0 ? rootScale : 1;
303
+ selection.worldCenter = sphereWorldCenter.toArray();
304
+ if (typeof getLocalFrameQuaternion === 'function') {
305
+ getLocalFrameQuaternion(sphereWorldCenter, sphereWorldQuaternion);
306
+ } else {
307
+ sphereWorldQuaternion.identity();
308
+ }
309
+ selection.worldQuaternion = sphereWorldQuaternion.toArray();
310
+ selection.worldRadius = Math.max(
311
+ KEEP_SPHERE_MIN_RADIUS,
312
+ selection.localRadius * safeScale,
313
+ );
314
+ applySphereSelectionSdf(selection);
315
+ updateKeepSphereWireframe(selection);
316
+ }
317
+
318
+ function createKeepSphereWireframe(selection) {
319
+ const group = new Group();
320
+ const geometry = createKeepSphereWireframeGeometry();
321
+ const visibleMaterial = createKeepSphereWireframeMaterial({
322
+ depthTest: true,
323
+ });
324
+ const overlayMaterial = createKeepSphereWireframeMaterial({
325
+ depthTest: false,
326
+ opacity: KEEP_SPHERE_WIREFRAME_OVERLAY_OPACITY,
327
+ });
328
+ const visibleWireframe = new LineSegments(geometry, visibleMaterial);
329
+ const overlayWireframe = new LineSegments(geometry, overlayMaterial);
330
+ visibleWireframe.renderOrder = KEEP_SPHERE_WIREFRAME_RENDER_ORDER;
331
+ overlayWireframe.renderOrder = KEEP_SPHERE_WIREFRAME_RENDER_ORDER + 1;
332
+ group.add(visibleWireframe);
333
+ group.add(overlayWireframe);
334
+ group.renderOrder = KEEP_SPHERE_WIREFRAME_RENDER_ORDER;
335
+ group.userData.keepSphereHandle = true;
336
+ group.userData.screenSelectionId = selection.id;
337
+ selection.wireframe = group;
338
+ selection.wireframeGeometry = geometry;
339
+ scene.add(group);
340
+ return group;
341
+ }
342
+
343
+ function updateKeepSphereWireframe(selection = keepSphere) {
344
+ if (!selection) {
345
+ return;
346
+ }
347
+ const wireframe = selection.wireframe || createKeepSphereWireframe(selection);
348
+ wireframe.position.fromArray(selection.worldCenter);
349
+ wireframe.quaternion.fromArray(selection.worldQuaternion);
350
+ wireframe.scale.setScalar(selection.worldRadius);
351
+ wireframe.visible =
352
+ !selection.confirmed || selection.id === activeSelectionId;
353
+ wireframe.updateMatrix();
354
+ wireframe.updateMatrixWorld(true);
355
+ }
356
+
357
+ function disposeKeepSphere(selection = keepSphere) {
358
+ if (!selection) {
359
+ return;
360
+ }
361
+ if (selection.edit) {
362
+ setScreenSelectionEditSelection(selection.edit, null, true);
363
+ selection.edit.removeFromParent();
364
+ selection.edit = null;
365
+ }
366
+ selection.sdfs?.forEach((sdf) => {
367
+ sdf.removeFromParent();
368
+ });
369
+ selection.sdfs = null;
370
+ if (selection.wireframe) {
371
+ selection.wireframe.traverse((object) => {
372
+ object.material?.dispose?.();
373
+ });
374
+ selection.wireframeGeometry?.dispose?.();
375
+ selection.wireframe.removeFromParent();
376
+ selection.wireframe = null;
377
+ selection.wireframeGeometry = null;
378
+ }
379
+ }
380
+
78
381
  function getEntries() {
79
- return [
382
+ const entries = [
80
383
  ...selections.map((selection) => ({ style: 'exclude', selection })),
81
384
  ...pendingSelections.map((selection) => ({
82
385
  style: 'preview',
83
386
  selection,
84
387
  })),
85
388
  ];
389
+ if (keepSphere) {
390
+ entries.push({
391
+ style: keepSphere.confirmed ? 'include' : 'preview',
392
+ selection: keepSphere,
393
+ });
394
+ }
395
+ return entries;
86
396
  }
87
397
 
88
398
  function setEditCursor(cursor) {
@@ -218,6 +528,10 @@ export function createCropController({
218
528
  function syncWorldState() {
219
529
  const transform = getCurrentRootTransformArray();
220
530
  getEntries().forEach(({ selection }) => {
531
+ if (selection.type === 'sphere') {
532
+ updateKeepSphereWorldState(selection);
533
+ return;
534
+ }
221
535
  updateScreenSelectionWorldState(
222
536
  selection,
223
537
  transform,
@@ -239,6 +553,15 @@ export function createCropController({
239
553
  selection,
240
554
  })),
241
555
  ];
556
+ if (keepSphere) {
557
+ styled.push({
558
+ style:
559
+ keepSphere.confirmed && keepSphere.id !== activeSelectionId
560
+ ? 'include'
561
+ : 'preview',
562
+ selection: keepSphere,
563
+ });
564
+ }
242
565
 
243
566
  styled.forEach(({ style, selection }) => {
244
567
  if (!selection.edit) {
@@ -255,10 +578,13 @@ export function createCropController({
255
578
  }
256
579
 
257
580
  function syncFarHandles() {
258
- const entries = getEntries();
581
+ const allEntries = getEntries();
582
+ const entries = allEntries.filter(
583
+ ({ selection }) => selection.type !== 'sphere',
584
+ );
259
585
  if (
260
586
  activeSelectionId != null &&
261
- !entries.some(({ selection }) => selection.id === activeSelectionId)
587
+ !allEntries.some(({ selection }) => selection.id === activeSelectionId)
262
588
  ) {
263
589
  activeSelectionId = null;
264
590
  }
@@ -280,6 +606,9 @@ export function createCropController({
280
606
  pendingScreenSelections: pendingSelections,
281
607
  onScreenSelectionRemove: handleSelectionRemove,
282
608
  onScreenSelectionSelect: handleSelectionSelect,
609
+ keepSphere,
610
+ onKeepSphereRemove: removeKeepSphere,
611
+ onKeepSphereSelect: selectKeepSphere,
283
612
  tilesetHasGaussianSplats: hasGaussianSplats,
284
613
  });
285
614
  }
@@ -568,6 +897,206 @@ export function createCropController({
568
897
  );
569
898
  }
570
899
 
900
+ function formatKeepSphereRadius(radius) {
901
+ const value = Number(radius);
902
+ if (!Number.isFinite(value)) {
903
+ return '0';
904
+ }
905
+ const abs = Math.abs(value);
906
+ if (abs > 0 && (abs < 0.001 || abs >= 1000000)) {
907
+ return value.toExponential(3).replace(/\.?0+e/, 'e');
908
+ }
909
+ return (abs < 1 ? value.toFixed(3) : value.toFixed(2)).replace(
910
+ /\.?0+$/,
911
+ '',
912
+ );
913
+ }
914
+
915
+ function createKeepSphere() {
916
+ if (!hasGaussianSplats) {
917
+ setStatus('Crop sphere is available for 3D Gaussian Splat tilesets only.', true);
918
+ return;
919
+ }
920
+ if (keepSphere) {
921
+ setStatus('Only one crop sphere can be active.', true);
922
+ return;
923
+ }
924
+ if (!getTilesetBoundingSphere(sphere)) {
925
+ setStatus('Tileset bounds are not ready yet.', true);
926
+ return;
927
+ }
928
+
929
+ cancelMode();
930
+ cancelOtherPositionPickModes();
931
+ setTransformMode(null);
932
+
933
+ getCurrentRootMatrix(rootMatrix);
934
+ rootInverseMatrix.copy(rootMatrix).invert();
935
+ const rootScale = getCurrentRootScale();
936
+ const localRadius = Math.max(
937
+ KEEP_SPHERE_MIN_RADIUS,
938
+ sphere.radius / rootScale,
939
+ );
940
+ sphereLocalCenter.copy(sphere.center).applyMatrix4(rootInverseMatrix);
941
+ keepSphere = {
942
+ action: SCREEN_SELECTION_ACTION_INCLUDE,
943
+ confirmed: false,
944
+ id: nextSelectionId++,
945
+ localBaseRadius: localRadius,
946
+ localCenter: sphereLocalCenter.toArray(),
947
+ localRadius,
948
+ radiusExponent: 0,
949
+ type: 'sphere',
950
+ worldCenter: sphere.center.toArray(),
951
+ worldRadius: sphere.radius,
952
+ };
953
+ activeSelectionId = keepSphere.id;
954
+ updateKeepSphereWorldState();
955
+ syncEditSdfs();
956
+ syncFarHandles();
957
+ syncTransformControlsState();
958
+ refreshUi();
959
+ setStatus('Created crop sphere at the tileset center. Adjust radius or move it, then Confirm or Cancel.');
960
+ }
961
+
962
+ function confirmKeepSphere() {
963
+ if (!keepSphere || keepSphere.confirmed) {
964
+ return;
965
+ }
966
+ keepSphere.confirmed = true;
967
+ activeSelectionId = null;
968
+ syncEditSdfs();
969
+ syncFarHandles();
970
+ syncTransformControlsState();
971
+ refreshUi();
972
+ setStatus('Confirmed crop sphere. Splats outside the sphere are hidden and will be removed on Save. Select its row to adjust it again.');
973
+ }
974
+
975
+ function cancelKeepSphere() {
976
+ if (!keepSphere || keepSphere.confirmed) {
977
+ return;
978
+ }
979
+ if (activeSelectionId === keepSphere.id) {
980
+ activeSelectionId = null;
981
+ }
982
+ disposeKeepSphere(keepSphere);
983
+ keepSphere = null;
984
+ keepSphereTrackDrag = null;
985
+ syncEditSdfs();
986
+ syncFarHandles();
987
+ syncTransformControlsState();
988
+ refreshUi();
989
+ setStatus('Crop sphere cancelled.');
990
+ }
991
+
992
+ function removeKeepSphere() {
993
+ if (!keepSphere) {
994
+ return;
995
+ }
996
+ if (activeSelectionId === keepSphere.id) {
997
+ activeSelectionId = null;
998
+ }
999
+ disposeKeepSphere(keepSphere);
1000
+ keepSphere = null;
1001
+ keepSphereTrackDrag = null;
1002
+ syncEditSdfs();
1003
+ syncFarHandles();
1004
+ syncTransformControlsState();
1005
+ refreshUi();
1006
+ setStatus('Removed crop sphere.');
1007
+ }
1008
+
1009
+ function selectKeepSphere() {
1010
+ if (!keepSphere) {
1011
+ return;
1012
+ }
1013
+ activeSelectionId = activeSelectionId === keepSphere.id ? null : keepSphere.id;
1014
+ setTransformMode(null);
1015
+ syncEditSdfs();
1016
+ syncFarHandles();
1017
+ syncTransformControlsState();
1018
+ refreshUi();
1019
+ setStatus(
1020
+ activeSelectionId === keepSphere?.id
1021
+ ? 'Drag the crop sphere transform controls or adjust radius.'
1022
+ : 'Crop sphere deactivated.',
1023
+ );
1024
+ }
1025
+
1026
+ function setKeepSphereRadiusExponent(exponent, { commit = false } = {}) {
1027
+ if (!keepSphere) {
1028
+ return false;
1029
+ }
1030
+ const nextExponent = Number(exponent);
1031
+ if (!Number.isFinite(nextExponent)) {
1032
+ return false;
1033
+ }
1034
+ keepSphere.radiusExponent = nextExponent;
1035
+ keepSphere.localRadius = Math.max(
1036
+ KEEP_SPHERE_MIN_RADIUS,
1037
+ keepSphere.localBaseRadius * 2 ** nextExponent,
1038
+ );
1039
+ updateKeepSphereWorldState();
1040
+ syncEditSdfs();
1041
+ refreshUi();
1042
+ if (commit) {
1043
+ setStatus(`Crop sphere radius set to ${formatKeepSphereRadius(keepSphere.worldRadius)}.`);
1044
+ }
1045
+ return true;
1046
+ }
1047
+
1048
+ function beginKeepSphereRadiusTrackDrag(clientX) {
1049
+ if (!keepSphere) {
1050
+ return false;
1051
+ }
1052
+ keepSphereTrackDrag = {
1053
+ startClientX: clientX,
1054
+ startExponent: keepSphere.radiusExponent,
1055
+ };
1056
+ viewerElements.keepSphereRadiusTrackEl?.style.setProperty(
1057
+ '--scale-track-offset',
1058
+ '0px',
1059
+ );
1060
+ return true;
1061
+ }
1062
+
1063
+ function setKeepSphereRadiusFromTrackClientX(clientX) {
1064
+ if (!keepSphere || !keepSphereTrackDrag) {
1065
+ return false;
1066
+ }
1067
+ const deltaX = clientX - keepSphereTrackDrag.startClientX;
1068
+ viewerElements.keepSphereRadiusTrackEl?.style.setProperty(
1069
+ '--scale-track-offset',
1070
+ `${deltaX}px`,
1071
+ );
1072
+ return setKeepSphereRadiusExponent(
1073
+ keepSphereTrackDrag.startExponent +
1074
+ deltaX / KEEP_SPHERE_RADIUS_TRACK_PIXELS_PER_EXPONENT,
1075
+ );
1076
+ }
1077
+
1078
+ function endKeepSphereRadiusTrackDrag({ commit = false } = {}) {
1079
+ if (!keepSphereTrackDrag) {
1080
+ return false;
1081
+ }
1082
+ keepSphereTrackDrag = null;
1083
+ viewerElements.keepSphereRadiusTrackEl?.style.setProperty(
1084
+ '--scale-track-offset',
1085
+ '0px',
1086
+ );
1087
+ if (commit && keepSphere) {
1088
+ setStatus(`Crop sphere radius set to ${formatKeepSphereRadius(keepSphere.worldRadius)}.`);
1089
+ }
1090
+ return true;
1091
+ }
1092
+
1093
+ function nudgeKeepSphereRadiusExponent(delta) {
1094
+ return setKeepSphereRadiusExponent(
1095
+ (keepSphere?.radiusExponent || 0) + delta,
1096
+ { commit: true },
1097
+ );
1098
+ }
1099
+
571
1100
  function toggle() {
572
1101
  if (!hasGaussianSplats) {
573
1102
  setStatus(
@@ -640,6 +1169,9 @@ export function createCropController({
640
1169
 
641
1170
  function findSelection(selectionId) {
642
1171
  const id = Number(selectionId);
1172
+ if (keepSphere?.id === id) {
1173
+ return { confirmed: keepSphere.confirmed, selection: keepSphere };
1174
+ }
643
1175
  let selection = selections.find((entry) => entry.id === id);
644
1176
  if (selection) {
645
1177
  return { confirmed: true, selection };
@@ -670,6 +1202,20 @@ export function createCropController({
670
1202
  }
671
1203
 
672
1204
  function handleTransformControlObjectChange(object) {
1205
+ if (object?.userData?.keepSphereHandle) {
1206
+ if (!keepSphere || object.userData.screenSelectionId !== keepSphere.id) {
1207
+ return true;
1208
+ }
1209
+ getCurrentRootMatrix(rootMatrix);
1210
+ rootInverseMatrix.copy(rootMatrix).invert();
1211
+ sphereLocalCenter.copy(object.position).applyMatrix4(rootInverseMatrix);
1212
+ keepSphere.localCenter = sphereLocalCenter.toArray();
1213
+ updateKeepSphereWorldState();
1214
+ syncEditSdfs();
1215
+ refreshUi();
1216
+ return true;
1217
+ }
1218
+
673
1219
  if (!object?.userData?.screenSelectionFarHandle) {
674
1220
  return false;
675
1221
  }
@@ -751,15 +1297,22 @@ export function createCropController({
751
1297
 
752
1298
  function getPayload() {
753
1299
  syncWorldState();
754
- return selections.map(getScreenSelectionPayload);
1300
+ const payload = selections.map(getScreenSelectionPayload);
1301
+ if (keepSphere?.confirmed) {
1302
+ payload.push(getScreenSelectionPayload(keepSphere));
1303
+ }
1304
+ return payload;
755
1305
  }
756
1306
 
757
1307
  function clearAll() {
758
1308
  cancelMode();
759
1309
  selections.forEach(disposeScreenSelection);
760
1310
  pendingSelections.forEach(disposeScreenSelection);
1311
+ disposeKeepSphere(keepSphere);
761
1312
  selections = [];
762
1313
  pendingSelections = [];
1314
+ keepSphere = null;
1315
+ keepSphereTrackDrag = null;
763
1316
  activeSelectionId = null;
764
1317
  clearPendingScreenEdit();
765
1318
  syncEditSdfs();
@@ -828,6 +1381,16 @@ export function createCropController({
828
1381
  return handlePendingEditPointerCancel(event);
829
1382
  }
830
1383
 
1384
+ function isRaycastHitVisible(intersection) {
1385
+ if (!hasGaussianSplats || !isGaussianSplatObject(intersection?.object)) {
1386
+ return true;
1387
+ }
1388
+ if (!intersection?.point) {
1389
+ return true;
1390
+ }
1391
+ return isSplatPointVisibleForRaycast(intersection.point);
1392
+ }
1393
+
831
1394
  cameraController.addEventListener(
832
1395
  'update',
833
1396
  freezePendingScreenEditIfCameraChanged,
@@ -853,8 +1416,17 @@ export function createCropController({
853
1416
  handleSelectionRemove,
854
1417
  handleSelectionSelect,
855
1418
  handleTransformControlObjectChange,
856
- hasPendingSelections: () => pendingSelections.length > 0,
1419
+ isRaycastHitVisible,
1420
+ hasPendingSelections: () =>
1421
+ pendingSelections.length > 0 || (keepSphere && !keepSphere.confirmed),
1422
+ beginKeepSphereRadiusTrackDrag,
1423
+ cancelKeepSphere,
1424
+ confirmKeepSphere,
1425
+ createKeepSphere,
1426
+ endKeepSphereRadiusTrackDrag,
1427
+ nudgeKeepSphereRadiusExponent,
857
1428
  notifyTransformModeChanged,
1429
+ setKeepSphereRadiusFromTrackClientX,
858
1430
  setHasGaussianSplats,
859
1431
  shouldCapturePointerDown,
860
1432
  syncWorldState,