Skip to content

Commit 2a6b88c

Browse files
feat: Implement Scene Viewer XR-style AR placement and scale toggle for world-space mode (#5068)
* Add scale mode button with no OP * feat: Add SVXR-style world-space AR placement and scale toggle * reduced code duplicate and addressed some minor bugs
1 parent 64167d4 commit 2a6b88c

File tree

2 files changed

+295
-23
lines changed

2 files changed

+295
-23
lines changed

packages/model-viewer/src/three-components/ARRenderer.ts

Lines changed: 230 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
* limitations under the License.
1414
*/
1515

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';
1717
import {XREstimatedLight} from 'three/examples/jsm/webxr/XREstimatedLight.js';
1818

1919
import {CameraChangeDetails, ControlsInterface} from '../features/controls.js';
@@ -55,6 +55,19 @@ const AXIS_Y = new Vector3(0, 1, 0);
5555
// Webxr rotation sensitivity
5656
const ROTATION_SENSIVITY = 0.3;
5757

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+
5871
export type ARStatus =
5972
'not-presenting'|'session-started'|'object-placed'|'failed';
6073

@@ -159,8 +172,23 @@ export class ARRenderer extends EventDispatcher<
159172
private scaleDamper = new Damper();
160173
private wasTwoFingering = false;
161174

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+
162183
private onExitWebXRButtonContainerClick = () => this.stopPresenting();
163184

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+
164192
constructor(private renderer: Renderer) {
165193
super();
166194
this.threeRenderer = renderer.threeRenderer;
@@ -279,6 +307,7 @@ export class ARRenderer extends EventDispatcher<
279307

280308
scene.element.addEventListener('load', this.onUpdateScene);
281309

310+
// Create hit test source for all modes (will be used differently based on mode and state)
282311
const radians = HIT_ANGLE_DEG * Math.PI / 180;
283312
const ray = this.placeOnWall === true ?
284313
undefined :
@@ -380,6 +409,20 @@ export class ARRenderer extends EventDispatcher<
380409
return;
381410
}
382411

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+
383426
if (menuPanel) {
384427
menuPanel!.show = false;
385428
}
@@ -388,14 +431,14 @@ export class ARRenderer extends EventDispatcher<
388431
controller);
389432
if (intersection!=null){
390433
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
392435

393436
// Check if the ray intersection is near the footprint
394437
const isFootprint = intersection.point.y <= footprintY;
395438
if (isFootprint) {
396439
if (this.selectedXRController != null) {
397440
this.selectedXRController.userData.line.visible = false;
398-
if (scene.canScale) {
441+
if (scene.canScale && this.isWorldSpaceReady()) {
399442
this.isTwoHandInteraction = true;
400443
this.firstRatio = this.controllerSeparation() / scene.pivot.scale.x;
401444
this.scaleLine.visible = true;
@@ -413,7 +456,7 @@ export class ARRenderer extends EventDispatcher<
413456
}
414457

415458
if (this.xrController1?.userData.isSelected && this.xrController2?.userData.isSelected) {
416-
if (scene.canScale) {
459+
if (scene.canScale && this.isWorldSpaceReady()) {
417460
this.isTwoHandInteraction = true;
418461
this.firstRatio = this.controllerSeparation() / scene.pivot.scale.x;
419462
this.scaleLine.visible = true;
@@ -455,6 +498,11 @@ export class ARRenderer extends EventDispatcher<
455498
scene.pivot.matrix.elements[8], scene.pivot.matrix.elements[10]);
456499
this.goalPosition.x = scene.pivot.position.x;
457500
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+
}
458506

459507
const menuPanel = this.menuPanel;
460508
menuPanel!.show = true;
@@ -642,6 +690,7 @@ export class ARRenderer extends EventDispatcher<
642690
this.frame = null;
643691
this.inputSource = null;
644692
this.overlay = null;
693+
this.worldSpaceInitialPlacementDone = false;
645694

646695
if (this.resolveCleanup != null) {
647696
this.resolveCleanup!();
@@ -698,15 +747,42 @@ export class ARRenderer extends EventDispatcher<
698747
scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta;
699748
this.goalYaw = scene.yaw;
700749

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);
708783

709-
this.goalPosition.copy(position);
784+
this.goalPosition.copy(position);
785+
}
710786

711787
scene.setHotspotsVisibility(true);
712788

@@ -755,6 +831,13 @@ export class ARRenderer extends EventDispatcher<
755831
}
756832

757833
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+
758841
const hitSource = this.initialHitSource;
759842
if (hitSource == null) {
760843
return;
@@ -857,8 +940,13 @@ export class ARRenderer extends EventDispatcher<
857940
}
858941

859942
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;
862950
}
863951

864952
private processInput(frame: XRFrame) {
@@ -928,7 +1016,13 @@ export class ARRenderer extends EventDispatcher<
9281016

9291017
this.goalPosition.sub(this.lastDragPosition);
9301018

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
9321026
const offset = hit.y - this.lastDragPosition.y;
9331027
// When a lower floor is found, keep the model at the same height, but
9341028
// drop the placement box to the floor. The model falls on select end.
@@ -941,9 +1035,11 @@ export class ARRenderer extends EventDispatcher<
9411035
cameraPosition.multiplyScalar(alpha);
9421036
hit.multiplyScalar(1 - alpha).add(cameraPosition);
9431037
}
1038+
this.goalPosition.add(hit);
1039+
} else {
1040+
this.goalPosition.add(hit);
9441041
}
9451042

946-
this.goalPosition.add(hit);
9471043
this.lastDragPosition.copy(hit);
9481044
});
9491045
}
@@ -959,6 +1055,11 @@ export class ARRenderer extends EventDispatcher<
9591055
}
9601056

9611057
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+
9621063
if (this.xrController1 && this.xrController2 && this.isTwoHandInteraction) {
9631064
const dist = this.controllerSeparation();
9641065
this.setScale(dist);
@@ -1002,7 +1103,20 @@ export class ARRenderer extends EventDispatcher<
10021103
if (this.xrMode !== XRMode.SCREEN_SPACE && goal.equals(position)) {
10031104
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
10041105
}
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);
10051118
}
1119+
10061120
scene.updateTarget(delta);
10071121

10081122
// Return the source so the caller can use it for camera-change events
@@ -1072,8 +1186,28 @@ export class ARRenderer extends EventDispatcher<
10721186
this.placementBox!.show = (over1 || over2) && !this.isTwoHandInteraction;
10731187
}
10741188

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+
10751206
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+
}
10771211
this.processInput(frame);
10781212

10791213
const delta = time - this.lastTick!;
@@ -1138,4 +1272,83 @@ export class ARRenderer extends EventDispatcher<
11381272
this.threeRenderer.render(scene, scene.getCamera());
11391273
}
11401274
}
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+
}
11411354
}

0 commit comments

Comments
 (0)