diff --git a/packages/model-viewer/src/three-components/ARRenderer.ts b/packages/model-viewer/src/three-components/ARRenderer.ts index affcd2cc82..05ae9b8cae 100644 --- a/packages/model-viewer/src/three-components/ARRenderer.ts +++ b/packages/model-viewer/src/three-components/ARRenderer.ts @@ -13,7 +13,7 @@ * limitations under the License. */ -import {Box3, BufferGeometry, Event as ThreeEvent, EventDispatcher, Line, Matrix4, PerspectiveCamera, Quaternion, Vector3, WebGLRenderer, XRControllerEventType, XRTargetRaySpace, Object3D} from 'three'; +import {Box3, BufferGeometry, Camera, Event as ThreeEvent, EventDispatcher, Line, Matrix4, PerspectiveCamera, Quaternion, Vector3, WebGLRenderer, XRControllerEventType, XRTargetRaySpace, Object3D} from 'three'; import {XREstimatedLight} from 'three/examples/jsm/webxr/XREstimatedLight.js'; import {CameraChangeDetails, ControlsInterface} from '../features/controls.js'; @@ -55,6 +55,19 @@ const AXIS_Y = new Vector3(0, 1, 0); // Webxr rotation sensitivity const ROTATION_SENSIVITY = 0.3; +// World-space AR automatic positioning constants (similar to FrameModel approach in SVXR) +const MIN_WORLD_SPACE_DISTANCE = 1.5; // Minimum distance from camera (meters) +const VIEW_DROP_DEGREES = 15; // Angle down from camera center for optimal viewing +const VIEW_RATIO = 0.3; // Ratio of viewport to use for model sizing +const MIN_MODEL_SIZE = 0.01; // Minimum model size to prevent division by zero +const PLACEMENT_BOX_EXTRA_PADDING = 0.15; // Extra padding for model-viewer + +// SVXR-like constants for scale limits +const MAX_MODEL_SIZE = 70.0; +const MODEL_SIZE_EPSILON = 0.001; + +const FOOTPRINT__INTERSECT_THRESHOLD = 0.2; + export type ARStatus = 'not-presenting'|'session-started'|'object-placed'|'failed'; @@ -159,8 +172,23 @@ export class ARRenderer extends EventDispatcher< private scaleDamper = new Damper(); private wasTwoFingering = false; + // Track if initial automatic placement has been done for world-space mode + private worldSpaceInitialPlacementDone = false; + + // Scale toggle state for world-space mode + private initialModelScale = 1.0; + private minScale = 1.0; + private maxScale = 1.0; + private onExitWebXRButtonContainerClick = () => this.stopPresenting(); + /** + * Check if world-space mode is active and initial placement is complete + */ + private isWorldSpaceReady(): boolean { + return this.xrMode === XRMode.WORLD_SPACE && this.worldSpaceInitialPlacementDone; + } + constructor(private renderer: Renderer) { super(); this.threeRenderer = renderer.threeRenderer; @@ -279,6 +307,7 @@ export class ARRenderer extends EventDispatcher< scene.element.addEventListener('load', this.onUpdateScene); + // Create hit test source for all modes (will be used differently based on mode and state) const radians = HIT_ANGLE_DEG * Math.PI / 180; const ray = this.placeOnWall === true ? undefined : @@ -380,6 +409,20 @@ export class ARRenderer extends EventDispatcher< return; } + const scaleModeIntersect = this.menuPanel!.scaleModeButtonControllerIntersection(scene, controller); + if (scaleModeIntersect != null) { + const goalScale = this.menuPanel!.handleScaleToggle( + this.worldSpaceInitialPlacementDone, + this.initialModelScale, + this.minScale, + this.maxScale + ); + if (goalScale !== null) { + this.goalScale = goalScale; + } + return; + } + if (menuPanel) { menuPanel!.show = false; } @@ -388,14 +431,14 @@ export class ARRenderer extends EventDispatcher< controller); if (intersection!=null){ const bbox = new Box3().setFromObject(scene.pivot); - const footprintY = bbox.min.y + 0.2; // Small threshold above base + const footprintY = bbox.min.y + FOOTPRINT__INTERSECT_THRESHOLD; // Small threshold above base // Check if the ray intersection is near the footprint const isFootprint = intersection.point.y <= footprintY; if (isFootprint) { if (this.selectedXRController != null) { this.selectedXRController.userData.line.visible = false; - if (scene.canScale) { + if (scene.canScale && this.isWorldSpaceReady()) { this.isTwoHandInteraction = true; this.firstRatio = this.controllerSeparation() / scene.pivot.scale.x; this.scaleLine.visible = true; @@ -413,7 +456,7 @@ export class ARRenderer extends EventDispatcher< } if (this.xrController1?.userData.isSelected && this.xrController2?.userData.isSelected) { - if (scene.canScale) { + if (scene.canScale && this.isWorldSpaceReady()) { this.isTwoHandInteraction = true; this.firstRatio = this.controllerSeparation() / scene.pivot.scale.x; this.scaleLine.visible = true; @@ -455,6 +498,11 @@ export class ARRenderer extends EventDispatcher< scene.pivot.matrix.elements[8], scene.pivot.matrix.elements[10]); this.goalPosition.x = scene.pivot.position.x; this.goalPosition.z = scene.pivot.position.z; + + // For world-space mode after initial placement, preserve Y position + if (this.isWorldSpaceReady()) { + this.goalPosition.y = scene.pivot.position.y; + } const menuPanel = this.menuPanel; menuPanel!.show = true; @@ -642,6 +690,7 @@ export class ARRenderer extends EventDispatcher< this.frame = null; this.inputSource = null; this.overlay = null; + this.worldSpaceInitialPlacementDone = false; if (this.resolveCleanup != null) { this.resolveCleanup!(); @@ -698,15 +747,42 @@ export class ARRenderer extends EventDispatcher< scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta; this.goalYaw = scene.yaw; - const radius = Math.max(1, 2 * scene.boundingSphere.radius); - position.copy(xrCamera.position) - .add(cameraDirection.multiplyScalar(radius)); - - this.updateTarget(); - const target = scene.getTarget(); - position.add(target).sub(this.oldTarget); + // Use different placement logic for world-space vs screen-space + if (this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone) { + // Use automatic optimal placement for world-space AR only on first session + const {position: optimalPosition, scale: optimalScale} = + this.calculateWorldSpaceOptimalPlacement(scene, xrCamera); + + this.goalPosition.copy(optimalPosition); + this.goalScale = optimalScale; + + // Store the initial scale for toggle functionality + this.initialModelScale = optimalScale; + + // Set initial position and scale immediately for world-space + position.copy(optimalPosition); + pivot.scale.set(optimalScale, optimalScale, optimalScale); + + // Mark that initial placement is done + this.worldSpaceInitialPlacementDone = true; + + // Calculate scale limits for world-space mode (SVXR logic) + this.calculateWorldSpaceScaleLimits(scene); + + // Enable user interaction after initial placement + this.enableWorldSpaceUserInteraction(); + } else if (this.xrMode === XRMode.SCREEN_SPACE) { + // Use original placement logic for screen-space AR + const radius = Math.max(1, 2 * scene.boundingSphere.radius); + position.copy(xrCamera.position) + .add(cameraDirection.multiplyScalar(radius)); + + this.updateTarget(); + const target = scene.getTarget(); + position.add(target).sub(this.oldTarget); - this.goalPosition.copy(position); + this.goalPosition.copy(position); + } scene.setHotspotsVisibility(true); @@ -755,6 +831,13 @@ export class ARRenderer extends EventDispatcher< } public moveToFloor(frame: XRFrame) { + // Skip hit testing for world-space mode only during initial placement + if (this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone) { + this.placementBox!.show = false; + this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED}); + return; + } + const hitSource = this.initialHitSource; if (hitSource == null) { return; @@ -857,8 +940,13 @@ export class ARRenderer extends EventDispatcher< } private setScale(separation: number) { - const scale = separation / this.firstRatio; - this.goalScale = (Math.abs(scale - 1) < SCALE_SNAP) ? 1 : scale; + let scale = separation / this.firstRatio; + scale = (Math.abs(scale - 1) < SCALE_SNAP) ? 1 : scale; + // Clamp to min/max for world-space mode after initial placement + if (this.isWorldSpaceReady()) { + scale = Math.max(this.minScale, Math.min(this.maxScale, scale)); + } + this.goalScale = scale; } private processInput(frame: XRFrame) { @@ -928,7 +1016,13 @@ export class ARRenderer extends EventDispatcher< this.goalPosition.sub(this.lastDragPosition); - if (this.placeOnWall === false) { + // For world-space mode after initial placement, allow full Y-axis control + if (this.isWorldSpaceReady()) { + // Use the hit point directly without floor constraints + console.log('[processInput] Setting goalPosition.y to hit.y:', hit.y); + this.goalPosition.add(hit); + } else if (this.placeOnWall === false) { + // Original logic for screen-space or initial world-space placement const offset = hit.y - this.lastDragPosition.y; // When a lower floor is found, keep the model at the same height, but // drop the placement box to the floor. The model falls on select end. @@ -941,9 +1035,11 @@ export class ARRenderer extends EventDispatcher< cameraPosition.multiplyScalar(alpha); hit.multiplyScalar(1 - alpha).add(cameraPosition); } + this.goalPosition.add(hit); + } else { + this.goalPosition.add(hit); } - this.goalPosition.add(hit); this.lastDragPosition.copy(hit); }); } @@ -959,6 +1055,11 @@ export class ARRenderer extends EventDispatcher< } private handleScalingInXR(scene: ModelScene, delta: number) { + // Allow manual scaling for world-space mode after initial placement + if (this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone) { + return; + } + if (this.xrController1 && this.xrController2 && this.isTwoHandInteraction) { const dist = this.controllerSeparation(); this.setScale(dist); @@ -1002,7 +1103,20 @@ export class ARRenderer extends EventDispatcher< if (this.xrMode !== XRMode.SCREEN_SPACE && goal.equals(position)) { scene.setShadowIntensity(AR_SHADOW_INTENSITY); } + + // For world-space mode after initial placement, don't constrain Y position + if (this.isWorldSpaceReady()) { + // Allow full Y-axis movement without floor constraints + scene.setShadowIntensity(AR_SHADOW_INTENSITY); + } + } + + // Handle automatic scaling for world-space mode only during initial placement + if (this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone && this.goalScale !== pivot.scale.x) { + const newScale = this.scaleDamper.update(pivot.scale.x, this.goalScale, delta, 1); + pivot.scale.set(newScale, newScale, newScale); } + scene.updateTarget(delta); // Return the source so the caller can use it for camera-change events @@ -1072,8 +1186,28 @@ export class ARRenderer extends EventDispatcher< this.placementBox!.show = (over1 || over2) && !this.isTwoHandInteraction; } + + + /** + * Enable user interaction for world-space mode after initial automatic placement + */ + private enableWorldSpaceUserInteraction() { + // Show placement box to indicate model can be moved + if (this.placementBox) { + this.placementBox.show = true; + } + + // Enable shadow to show model is placed + if (this.presentedScene) { + this.presentedScene.setShadowIntensity(AR_SHADOW_INTENSITY); + } + } + private handleFirstView(frame: XRFrame, time: number) { - this.moveToFloor(frame); + // Skip moveToFloor for world-space mode after initial placement to prevent overriding + if (this.xrMode !== XRMode.WORLD_SPACE || !this.worldSpaceInitialPlacementDone) { + this.moveToFloor(frame); + } this.processInput(frame); const delta = time - this.lastTick!; @@ -1138,4 +1272,83 @@ export class ARRenderer extends EventDispatcher< this.threeRenderer.render(scene, scene.getCamera()); } } + + /** + * Calculate optimal scale and position for world-space AR presentation + * Similar to the SVXR:FrameModel approach for consistent model presentation + * + * This method implements automatic model framing for world-space AR sessions: + * 1. Calculates optimal viewing distance based on model size and minimum distance + * 2. Positions model at a drop angle below camera center for natural viewing + * 3. Automatically scales model to fit within viewport constraints + * 4. Ensures consistent presentation across different model sizes + * + * Note: This automatic placement only happens on the first session start. + * After initial placement, users have full control over model position, rotation, and scale. + */ + private calculateWorldSpaceOptimalPlacement(scene: ModelScene, camera: Camera): {position: Vector3, scale: number} { + // Get model bounding box half extents + const boundingBox = scene.boundingBox; + const halfExtent = { + x: (boundingBox.max.x - boundingBox.min.x) / 2, + y: (boundingBox.max.y - boundingBox.min.y) / 2, + z: (boundingBox.max.z - boundingBox.min.z) / 2 + }; + + // Compute view distance (with extra padding for model-viewer) + const placementBoxPadding = PLACEMENT_BOX_EXTRA_PADDING; + const viewDistance = Math.max( + MIN_WORLD_SPACE_DISTANCE + placementBoxPadding, + 2 * Math.max(halfExtent.x, halfExtent.z) + placementBoxPadding + ); + + // Compute ideal view position (drop angle below camera center) + const dropAngleRad = VIEW_DROP_DEGREES * Math.PI / 180; + const idealViewPosition = new Vector3( + 0, + -viewDistance * Math.sin(dropAngleRad), + -viewDistance * Math.cos(dropAngleRad) + ); + + // Transform ideal view position to world space + const worldFromCamera = camera.matrixWorld; + const idealWorldPosition = idealViewPosition.clone().applyMatrix4(worldFromCamera); + + // Compute turntable and vertical radii + const turntableRadius = Math.max(halfExtent.x, halfExtent.z)+ placementBoxPadding; + const verticalRadius = halfExtent.y; + const turntableRadiusLimit = viewDistance * VIEW_RATIO; + const verticalRadiusLimit = viewDistance * VIEW_RATIO; + + // Compute optimal scale + const verticalScale = verticalRadiusLimit / Math.max(verticalRadius, MIN_MODEL_SIZE); + const turntableScale = turntableRadiusLimit / Math.max(turntableRadius, MIN_MODEL_SIZE); + const optimalScale = Math.min(verticalScale, turntableScale); + + // Offset so the model's base sits at the ideal world position + // (subtract scaled half height in Y) + + const finalPosition = idealWorldPosition.clone().sub( + new Vector3(0, optimalScale * halfExtent.y, 0) + ); + + return { + position: finalPosition, + scale: optimalScale + }; + } + + /** + * Calculate min/max scale for world-space AR, SVXR-style + */ + private calculateWorldSpaceScaleLimits(scene: ModelScene) { + const size = scene.size; + const largestDimension = Math.max(size.x, size.y, size.z); + const smallestDimension = Math.max(Math.min(size.x, size.y, size.z), MODEL_SIZE_EPSILON); + const scaleMin = MIN_MODEL_SIZE / Math.max(largestDimension, MODEL_SIZE_EPSILON); + const scaleMax = MAX_MODEL_SIZE / Math.max(smallestDimension, MODEL_SIZE_EPSILON); + // Clamp to initial scale if needed + this.minScale = Math.min(scaleMin, scaleMax, this.goalScale); + this.maxScale = Math.max(scaleMin, scaleMax, this.goalScale); + } } diff --git a/packages/model-viewer/src/three-components/XRMenuPanel.ts b/packages/model-viewer/src/three-components/XRMenuPanel.ts index 8065d7fda1..938436fb6d 100644 --- a/packages/model-viewer/src/three-components/XRMenuPanel.ts +++ b/packages/model-viewer/src/three-components/XRMenuPanel.ts @@ -1,19 +1,20 @@ - import {Camera, CanvasTexture, Mesh,Object3D, Shape,ShapeGeometry, LinearFilter, MeshBasicMaterial, PlaneGeometry, XRTargetRaySpace, Vector3} from 'three'; import {Damper} from './Damper.js'; import {ModelScene} from './ModelScene.js'; import { PlacementBox } from './PlacementBox.js'; const MAX_OPACITY = 1; -const PANEL_WIDTH = 0.1; -const PANEL_HEIGHT = 0.1; +const PANEL_WIDTH = 0.15; +const PANEL_HEIGHT = 0.08; const PANEL_CORNER_RADIUS = 0.02; export class XRMenuPanel extends Object3D { private panelMesh: Mesh; private exitButton: Mesh; + private toggleButton: Mesh; private goalOpacity: number; private opacityDamper: Damper; + private isActualSize: boolean = false; // Start with normalized size constructor() { super(); @@ -41,10 +42,22 @@ export class XRMenuPanel extends Object3D { this.panelMesh = new Mesh(geometry, material); this.panelMesh.name = 'MenuPanel'; this.add(this.panelMesh); - this.exitButton = this.createButton('x'); + + // Create exit button + this.exitButton = this.createButton('X'); this.exitButton.name = 'ExitButton'; - this.exitButton.position.set(0, 0, 0.01); - this.add(this.exitButton) + this.exitButton.position.set(0.035, 0, 0.01); // Keep X button position + this.add(this.exitButton); + + // Create toggle button with adjusted position for larger text + this.toggleButton = this.createButton('1:1', { + width: 0.06, // Slightly wider for the longer text + height: 0.05 // Keep same height as X button + }); + this.toggleButton.name = 'ToggleButton'; + this.toggleButton.position.set(-0.03, 0, 0.01); // Move slightly more to the left to account for wider button + this.add(this.toggleButton); + this.opacityDamper = new Damper(); this.goalOpacity = MAX_OPACITY; } @@ -98,6 +111,52 @@ export class XRMenuPanel extends Object3D { const hitResult = scene.hitFromController(controller, this.exitButton); return hitResult; } + + scaleModeButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) { + const hitResult = scene.hitFromController(controller, this.toggleButton); + return hitResult; + } + + handleScaleToggle( + worldSpaceInitialPlacementDone: boolean, + initialModelScale: number, + minScale: number, + maxScale: number + ): number | null { + if (!worldSpaceInitialPlacementDone) { + return null; + } + + this.isActualSize = !this.isActualSize; + const newLabel = this.isActualSize ? '@' : '1:1'; + this.updateScaleModeButtonLabel(newLabel); + + const targetScale = this.isActualSize ? 1.0 : initialModelScale; + const goalScale = Math.max(minScale, Math.min(maxScale, targetScale)); + + return goalScale; + } + + private updateScaleModeButtonLabel(label: string) { + const canvasSize = 128; + const canvas = document.createElement('canvas'); + canvas.width = canvasSize; + canvas.height = canvasSize; + const ctx = canvas.getContext('2d')!; + + ctx.fillStyle = '#cccccc'; + ctx.font = 'bold 80px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, canvasSize / 2, canvasSize / 2); + + const texture = new CanvasTexture(canvas); + texture.needsUpdate = true; + texture.minFilter = LinearFilter; + + (this.toggleButton.material as MeshBasicMaterial).map = texture; + (this.toggleButton.material as MeshBasicMaterial).needsUpdate = true; + } updatePosition(camera: Camera, placementBox: PlacementBox) { if (!placementBox) {