13
13
* limitations under the License.
14
14
*/
15
15
16
- import { Box3 , BufferGeometry , Event as ThreeEvent , EventDispatcher , Line , Matrix4 , PerspectiveCamera , Quaternion , Vector3 , WebGLRenderer , XRControllerEventType , XRTargetRaySpace , Object3D } from 'three' ;
16
+ import { Box3 , BufferGeometry , Camera , Event as ThreeEvent , EventDispatcher , Line , Matrix4 , PerspectiveCamera , Quaternion , Vector3 , WebGLRenderer , XRControllerEventType , XRTargetRaySpace , Object3D } from 'three' ;
17
17
import { XREstimatedLight } from 'three/examples/jsm/webxr/XREstimatedLight.js' ;
18
18
19
19
import { CameraChangeDetails , ControlsInterface } from '../features/controls.js' ;
@@ -55,6 +55,19 @@ const AXIS_Y = new Vector3(0, 1, 0);
55
55
// Webxr rotation sensitivity
56
56
const ROTATION_SENSIVITY = 0.3 ;
57
57
58
+ // World-space AR automatic positioning constants (similar to FrameModel approach in SVXR)
59
+ const MIN_WORLD_SPACE_DISTANCE = 1.5 ; // Minimum distance from camera (meters)
60
+ const VIEW_DROP_DEGREES = 15 ; // Angle down from camera center for optimal viewing
61
+ const VIEW_RATIO = 0.3 ; // Ratio of viewport to use for model sizing
62
+ const MIN_MODEL_SIZE = 0.01 ; // Minimum model size to prevent division by zero
63
+ const PLACEMENT_BOX_EXTRA_PADDING = 0.15 ; // Extra padding for model-viewer
64
+
65
+ // SVXR-like constants for scale limits
66
+ const MAX_MODEL_SIZE = 70.0 ;
67
+ const MODEL_SIZE_EPSILON = 0.001 ;
68
+
69
+ const FOOTPRINT__INTERSECT_THRESHOLD = 0.2 ;
70
+
58
71
export type ARStatus =
59
72
'not-presenting' | 'session-started' | 'object-placed' | 'failed' ;
60
73
@@ -159,8 +172,23 @@ export class ARRenderer extends EventDispatcher<
159
172
private scaleDamper = new Damper ( ) ;
160
173
private wasTwoFingering = false ;
161
174
175
+ // Track if initial automatic placement has been done for world-space mode
176
+ private worldSpaceInitialPlacementDone = false ;
177
+
178
+ // Scale toggle state for world-space mode
179
+ private initialModelScale = 1.0 ;
180
+ private minScale = 1.0 ;
181
+ private maxScale = 1.0 ;
182
+
162
183
private onExitWebXRButtonContainerClick = ( ) => this . stopPresenting ( ) ;
163
184
185
+ /**
186
+ * Check if world-space mode is active and initial placement is complete
187
+ */
188
+ private isWorldSpaceReady ( ) : boolean {
189
+ return this . xrMode === XRMode . WORLD_SPACE && this . worldSpaceInitialPlacementDone ;
190
+ }
191
+
164
192
constructor ( private renderer : Renderer ) {
165
193
super ( ) ;
166
194
this . threeRenderer = renderer . threeRenderer ;
@@ -279,6 +307,7 @@ export class ARRenderer extends EventDispatcher<
279
307
280
308
scene . element . addEventListener ( 'load' , this . onUpdateScene ) ;
281
309
310
+ // Create hit test source for all modes (will be used differently based on mode and state)
282
311
const radians = HIT_ANGLE_DEG * Math . PI / 180 ;
283
312
const ray = this . placeOnWall === true ?
284
313
undefined :
@@ -380,6 +409,20 @@ export class ARRenderer extends EventDispatcher<
380
409
return ;
381
410
}
382
411
412
+ const scaleModeIntersect = this . menuPanel ! . scaleModeButtonControllerIntersection ( scene , controller ) ;
413
+ if ( scaleModeIntersect != null ) {
414
+ const goalScale = this . menuPanel ! . handleScaleToggle (
415
+ this . worldSpaceInitialPlacementDone ,
416
+ this . initialModelScale ,
417
+ this . minScale ,
418
+ this . maxScale
419
+ ) ;
420
+ if ( goalScale !== null ) {
421
+ this . goalScale = goalScale ;
422
+ }
423
+ return ;
424
+ }
425
+
383
426
if ( menuPanel ) {
384
427
menuPanel ! . show = false ;
385
428
}
@@ -388,14 +431,14 @@ export class ARRenderer extends EventDispatcher<
388
431
controller ) ;
389
432
if ( intersection != null ) {
390
433
const bbox = new Box3 ( ) . setFromObject ( scene . pivot ) ;
391
- const footprintY = bbox . min . y + 0.2 ; // Small threshold above base
434
+ const footprintY = bbox . min . y + FOOTPRINT__INTERSECT_THRESHOLD ; // Small threshold above base
392
435
393
436
// Check if the ray intersection is near the footprint
394
437
const isFootprint = intersection . point . y <= footprintY ;
395
438
if ( isFootprint ) {
396
439
if ( this . selectedXRController != null ) {
397
440
this . selectedXRController . userData . line . visible = false ;
398
- if ( scene . canScale ) {
441
+ if ( scene . canScale && this . isWorldSpaceReady ( ) ) {
399
442
this . isTwoHandInteraction = true ;
400
443
this . firstRatio = this . controllerSeparation ( ) / scene . pivot . scale . x ;
401
444
this . scaleLine . visible = true ;
@@ -413,7 +456,7 @@ export class ARRenderer extends EventDispatcher<
413
456
}
414
457
415
458
if ( this . xrController1 ?. userData . isSelected && this . xrController2 ?. userData . isSelected ) {
416
- if ( scene . canScale ) {
459
+ if ( scene . canScale && this . isWorldSpaceReady ( ) ) {
417
460
this . isTwoHandInteraction = true ;
418
461
this . firstRatio = this . controllerSeparation ( ) / scene . pivot . scale . x ;
419
462
this . scaleLine . visible = true ;
@@ -455,6 +498,11 @@ export class ARRenderer extends EventDispatcher<
455
498
scene . pivot . matrix . elements [ 8 ] , scene . pivot . matrix . elements [ 10 ] ) ;
456
499
this . goalPosition . x = scene . pivot . position . x ;
457
500
this . goalPosition . z = scene . pivot . position . z ;
501
+
502
+ // For world-space mode after initial placement, preserve Y position
503
+ if ( this . isWorldSpaceReady ( ) ) {
504
+ this . goalPosition . y = scene . pivot . position . y ;
505
+ }
458
506
459
507
const menuPanel = this . menuPanel ;
460
508
menuPanel ! . show = true ;
@@ -642,6 +690,7 @@ export class ARRenderer extends EventDispatcher<
642
690
this . frame = null ;
643
691
this . inputSource = null ;
644
692
this . overlay = null ;
693
+ this . worldSpaceInitialPlacementDone = false ;
645
694
646
695
if ( this . resolveCleanup != null ) {
647
696
this . resolveCleanup ! ( ) ;
@@ -698,15 +747,42 @@ export class ARRenderer extends EventDispatcher<
698
747
scene . yaw = Math . atan2 ( - cameraDirection . x , - cameraDirection . z ) - theta ;
699
748
this . goalYaw = scene . yaw ;
700
749
701
- const radius = Math . max ( 1 , 2 * scene . boundingSphere . radius ) ;
702
- position . copy ( xrCamera . position )
703
- . add ( cameraDirection . multiplyScalar ( radius ) ) ;
704
-
705
- this . updateTarget ( ) ;
706
- const target = scene . getTarget ( ) ;
707
- position . add ( target ) . sub ( this . oldTarget ) ;
750
+ // Use different placement logic for world-space vs screen-space
751
+ if ( this . xrMode === XRMode . WORLD_SPACE && ! this . worldSpaceInitialPlacementDone ) {
752
+ // Use automatic optimal placement for world-space AR only on first session
753
+ const { position : optimalPosition , scale : optimalScale } =
754
+ this . calculateWorldSpaceOptimalPlacement ( scene , xrCamera ) ;
755
+
756
+ this . goalPosition . copy ( optimalPosition ) ;
757
+ this . goalScale = optimalScale ;
758
+
759
+ // Store the initial scale for toggle functionality
760
+ this . initialModelScale = optimalScale ;
761
+
762
+ // Set initial position and scale immediately for world-space
763
+ position . copy ( optimalPosition ) ;
764
+ pivot . scale . set ( optimalScale , optimalScale , optimalScale ) ;
765
+
766
+ // Mark that initial placement is done
767
+ this . worldSpaceInitialPlacementDone = true ;
768
+
769
+ // Calculate scale limits for world-space mode (SVXR logic)
770
+ this . calculateWorldSpaceScaleLimits ( scene ) ;
771
+
772
+ // Enable user interaction after initial placement
773
+ this . enableWorldSpaceUserInteraction ( ) ;
774
+ } else if ( this . xrMode === XRMode . SCREEN_SPACE ) {
775
+ // Use original placement logic for screen-space AR
776
+ const radius = Math . max ( 1 , 2 * scene . boundingSphere . radius ) ;
777
+ position . copy ( xrCamera . position )
778
+ . add ( cameraDirection . multiplyScalar ( radius ) ) ;
779
+
780
+ this . updateTarget ( ) ;
781
+ const target = scene . getTarget ( ) ;
782
+ position . add ( target ) . sub ( this . oldTarget ) ;
708
783
709
- this . goalPosition . copy ( position ) ;
784
+ this . goalPosition . copy ( position ) ;
785
+ }
710
786
711
787
scene . setHotspotsVisibility ( true ) ;
712
788
@@ -755,6 +831,13 @@ export class ARRenderer extends EventDispatcher<
755
831
}
756
832
757
833
public moveToFloor ( frame : XRFrame ) {
834
+ // Skip hit testing for world-space mode only during initial placement
835
+ if ( this . xrMode === XRMode . WORLD_SPACE && ! this . worldSpaceInitialPlacementDone ) {
836
+ this . placementBox ! . show = false ;
837
+ this . dispatchEvent ( { type : 'status' , status : ARStatus . OBJECT_PLACED } ) ;
838
+ return ;
839
+ }
840
+
758
841
const hitSource = this . initialHitSource ;
759
842
if ( hitSource == null ) {
760
843
return ;
@@ -857,8 +940,13 @@ export class ARRenderer extends EventDispatcher<
857
940
}
858
941
859
942
private setScale ( separation : number ) {
860
- const scale = separation / this . firstRatio ;
861
- this . goalScale = ( Math . abs ( scale - 1 ) < SCALE_SNAP ) ? 1 : scale ;
943
+ let scale = separation / this . firstRatio ;
944
+ scale = ( Math . abs ( scale - 1 ) < SCALE_SNAP ) ? 1 : scale ;
945
+ // Clamp to min/max for world-space mode after initial placement
946
+ if ( this . isWorldSpaceReady ( ) ) {
947
+ scale = Math . max ( this . minScale , Math . min ( this . maxScale , scale ) ) ;
948
+ }
949
+ this . goalScale = scale ;
862
950
}
863
951
864
952
private processInput ( frame : XRFrame ) {
@@ -928,7 +1016,13 @@ export class ARRenderer extends EventDispatcher<
928
1016
929
1017
this . goalPosition . sub ( this . lastDragPosition ) ;
930
1018
931
- if ( this . placeOnWall === false ) {
1019
+ // For world-space mode after initial placement, allow full Y-axis control
1020
+ if ( this . isWorldSpaceReady ( ) ) {
1021
+ // Use the hit point directly without floor constraints
1022
+ console . log ( '[processInput] Setting goalPosition.y to hit.y:' , hit . y ) ;
1023
+ this . goalPosition . add ( hit ) ;
1024
+ } else if ( this . placeOnWall === false ) {
1025
+ // Original logic for screen-space or initial world-space placement
932
1026
const offset = hit . y - this . lastDragPosition . y ;
933
1027
// When a lower floor is found, keep the model at the same height, but
934
1028
// drop the placement box to the floor. The model falls on select end.
@@ -941,9 +1035,11 @@ export class ARRenderer extends EventDispatcher<
941
1035
cameraPosition . multiplyScalar ( alpha ) ;
942
1036
hit . multiplyScalar ( 1 - alpha ) . add ( cameraPosition ) ;
943
1037
}
1038
+ this . goalPosition . add ( hit ) ;
1039
+ } else {
1040
+ this . goalPosition . add ( hit ) ;
944
1041
}
945
1042
946
- this . goalPosition . add ( hit ) ;
947
1043
this . lastDragPosition . copy ( hit ) ;
948
1044
} ) ;
949
1045
}
@@ -959,6 +1055,11 @@ export class ARRenderer extends EventDispatcher<
959
1055
}
960
1056
961
1057
private handleScalingInXR ( scene : ModelScene , delta : number ) {
1058
+ // Allow manual scaling for world-space mode after initial placement
1059
+ if ( this . xrMode === XRMode . WORLD_SPACE && ! this . worldSpaceInitialPlacementDone ) {
1060
+ return ;
1061
+ }
1062
+
962
1063
if ( this . xrController1 && this . xrController2 && this . isTwoHandInteraction ) {
963
1064
const dist = this . controllerSeparation ( ) ;
964
1065
this . setScale ( dist ) ;
@@ -1002,7 +1103,20 @@ export class ARRenderer extends EventDispatcher<
1002
1103
if ( this . xrMode !== XRMode . SCREEN_SPACE && goal . equals ( position ) ) {
1003
1104
scene . setShadowIntensity ( AR_SHADOW_INTENSITY ) ;
1004
1105
}
1106
+
1107
+ // For world-space mode after initial placement, don't constrain Y position
1108
+ if ( this . isWorldSpaceReady ( ) ) {
1109
+ // Allow full Y-axis movement without floor constraints
1110
+ scene . setShadowIntensity ( AR_SHADOW_INTENSITY ) ;
1111
+ }
1112
+ }
1113
+
1114
+ // Handle automatic scaling for world-space mode only during initial placement
1115
+ if ( this . xrMode === XRMode . WORLD_SPACE && ! this . worldSpaceInitialPlacementDone && this . goalScale !== pivot . scale . x ) {
1116
+ const newScale = this . scaleDamper . update ( pivot . scale . x , this . goalScale , delta , 1 ) ;
1117
+ pivot . scale . set ( newScale , newScale , newScale ) ;
1005
1118
}
1119
+
1006
1120
scene . updateTarget ( delta ) ;
1007
1121
1008
1122
// Return the source so the caller can use it for camera-change events
@@ -1072,8 +1186,28 @@ export class ARRenderer extends EventDispatcher<
1072
1186
this . placementBox ! . show = ( over1 || over2 ) && ! this . isTwoHandInteraction ;
1073
1187
}
1074
1188
1189
+
1190
+
1191
+ /**
1192
+ * Enable user interaction for world-space mode after initial automatic placement
1193
+ */
1194
+ private enableWorldSpaceUserInteraction ( ) {
1195
+ // Show placement box to indicate model can be moved
1196
+ if ( this . placementBox ) {
1197
+ this . placementBox . show = true ;
1198
+ }
1199
+
1200
+ // Enable shadow to show model is placed
1201
+ if ( this . presentedScene ) {
1202
+ this . presentedScene . setShadowIntensity ( AR_SHADOW_INTENSITY ) ;
1203
+ }
1204
+ }
1205
+
1075
1206
private handleFirstView ( frame : XRFrame , time : number ) {
1076
- this . moveToFloor ( frame ) ;
1207
+ // Skip moveToFloor for world-space mode after initial placement to prevent overriding
1208
+ if ( this . xrMode !== XRMode . WORLD_SPACE || ! this . worldSpaceInitialPlacementDone ) {
1209
+ this . moveToFloor ( frame ) ;
1210
+ }
1077
1211
this . processInput ( frame ) ;
1078
1212
1079
1213
const delta = time - this . lastTick ! ;
@@ -1138,4 +1272,83 @@ export class ARRenderer extends EventDispatcher<
1138
1272
this . threeRenderer . render ( scene , scene . getCamera ( ) ) ;
1139
1273
}
1140
1274
}
1275
+
1276
+ /**
1277
+ * Calculate optimal scale and position for world-space AR presentation
1278
+ * Similar to the SVXR:FrameModel approach for consistent model presentation
1279
+ *
1280
+ * This method implements automatic model framing for world-space AR sessions:
1281
+ * 1. Calculates optimal viewing distance based on model size and minimum distance
1282
+ * 2. Positions model at a drop angle below camera center for natural viewing
1283
+ * 3. Automatically scales model to fit within viewport constraints
1284
+ * 4. Ensures consistent presentation across different model sizes
1285
+ *
1286
+ * Note: This automatic placement only happens on the first session start.
1287
+ * After initial placement, users have full control over model position, rotation, and scale.
1288
+ */
1289
+ private calculateWorldSpaceOptimalPlacement ( scene : ModelScene , camera : Camera ) : { position : Vector3 , scale : number } {
1290
+ // Get model bounding box half extents
1291
+ const boundingBox = scene . boundingBox ;
1292
+ const halfExtent = {
1293
+ x : ( boundingBox . max . x - boundingBox . min . x ) / 2 ,
1294
+ y : ( boundingBox . max . y - boundingBox . min . y ) / 2 ,
1295
+ z : ( boundingBox . max . z - boundingBox . min . z ) / 2
1296
+ } ;
1297
+
1298
+ // Compute view distance (with extra padding for model-viewer)
1299
+ const placementBoxPadding = PLACEMENT_BOX_EXTRA_PADDING ;
1300
+ const viewDistance = Math . max (
1301
+ MIN_WORLD_SPACE_DISTANCE + placementBoxPadding ,
1302
+ 2 * Math . max ( halfExtent . x , halfExtent . z ) + placementBoxPadding
1303
+ ) ;
1304
+
1305
+ // Compute ideal view position (drop angle below camera center)
1306
+ const dropAngleRad = VIEW_DROP_DEGREES * Math . PI / 180 ;
1307
+ const idealViewPosition = new Vector3 (
1308
+ 0 ,
1309
+ - viewDistance * Math . sin ( dropAngleRad ) ,
1310
+ - viewDistance * Math . cos ( dropAngleRad )
1311
+ ) ;
1312
+
1313
+ // Transform ideal view position to world space
1314
+ const worldFromCamera = camera . matrixWorld ;
1315
+ const idealWorldPosition = idealViewPosition . clone ( ) . applyMatrix4 ( worldFromCamera ) ;
1316
+
1317
+ // Compute turntable and vertical radii
1318
+ const turntableRadius = Math . max ( halfExtent . x , halfExtent . z ) + placementBoxPadding ;
1319
+ const verticalRadius = halfExtent . y ;
1320
+ const turntableRadiusLimit = viewDistance * VIEW_RATIO ;
1321
+ const verticalRadiusLimit = viewDistance * VIEW_RATIO ;
1322
+
1323
+ // Compute optimal scale
1324
+ const verticalScale = verticalRadiusLimit / Math . max ( verticalRadius , MIN_MODEL_SIZE ) ;
1325
+ const turntableScale = turntableRadiusLimit / Math . max ( turntableRadius , MIN_MODEL_SIZE ) ;
1326
+ const optimalScale = Math . min ( verticalScale , turntableScale ) ;
1327
+
1328
+ // Offset so the model's base sits at the ideal world position
1329
+ // (subtract scaled half height in Y)
1330
+
1331
+ const finalPosition = idealWorldPosition . clone ( ) . sub (
1332
+ new Vector3 ( 0 , optimalScale * halfExtent . y , 0 )
1333
+ ) ;
1334
+
1335
+ return {
1336
+ position : finalPosition ,
1337
+ scale : optimalScale
1338
+ } ;
1339
+ }
1340
+
1341
+ /**
1342
+ * Calculate min/max scale for world-space AR, SVXR-style
1343
+ */
1344
+ private calculateWorldSpaceScaleLimits ( scene : ModelScene ) {
1345
+ const size = scene . size ;
1346
+ const largestDimension = Math . max ( size . x , size . y , size . z ) ;
1347
+ const smallestDimension = Math . max ( Math . min ( size . x , size . y , size . z ) , MODEL_SIZE_EPSILON ) ;
1348
+ const scaleMin = MIN_MODEL_SIZE / Math . max ( largestDimension , MODEL_SIZE_EPSILON ) ;
1349
+ const scaleMax = MAX_MODEL_SIZE / Math . max ( smallestDimension , MODEL_SIZE_EPSILON ) ;
1350
+ // Clamp to initial scale if needed
1351
+ this . minScale = Math . min ( scaleMin , scaleMax , this . goalScale ) ;
1352
+ this . maxScale = Math . max ( scaleMin , scaleMax , this . goalScale ) ;
1353
+ }
1141
1354
}
0 commit comments