diff --git a/packages/model-viewer/src/three-components/ARRenderer.ts b/packages/model-viewer/src/three-components/ARRenderer.ts index 5b5778d..ebd066f 100644 --- a/packages/model-viewer/src/three-components/ARRenderer.ts +++ b/packages/model-viewer/src/three-components/ARRenderer.ts @@ -43,6 +43,7 @@ const HIT_ANGLE_DEG = 20; const SCALE_SNAP = 0.2; // upward-oriented ray for ceiling detection const CEILING_HIT_ANGLE_DEG = 20; const CEILING_ORIENTATION_THRESHOLD = 15; // degrees // For automatic dynamic viewport scaling, don't let the scale drop below this // limit. const MIN_VIEWPORT_SCALE = 0.25; @@ -752,20 +753,35 @@ export class ARRenderer extends EventDispatcher< const {pivot, element} = scene; const {position} = pivot; const xrCamera = scene.getCamera(); const {width, height} = this.overlay!.getBoundingClientRect(); scene.setSize(width, height); xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert(); const {theta} = (element as ModelViewerElementBase & ControlsInterface) .getCameraOrbit(); // Orient model to match the 3D camera view const cameraDirection = xrCamera.getWorldDirection(vector3); scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta; this.goalYaw = scene.yaw; if (this.placeOnCeiling && !this.isViewPointingUp()) { scene.visible = false; // Hide until properly oriented scene.setHotspotsVisibility(true); // Still show UI // Set up touch interaction for screen-space mode if (this.xrMode === XRMode.SCREEN_SPACE) { const {session} = this.frame!; session.addEventListener('selectstart', this.onSelectStart); session.addEventListener('selectend', this.onSelectEnd); session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})! .then(hitTestSource => { this.transientHitTestSource = hitTestSource; }); } return; // Exit early - don't place yet } // 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 @@ -795,28 +811,74 @@ export class ARRenderer extends EventDispatcher< 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); } scene.setHotspotsVisibility(true); scene.visible = true; // Model is properly oriented, show it if (this.xrMode === XRMode.SCREEN_SPACE) { const {session} = this.frame!; session.addEventListener('selectstart', this.onSelectStart); session.addEventListener('selectend', this.onSelectEnd); session .requestHitTestSourceForTransientInput! ({profile: 'generic-touchscreen'})!.then(hitTestSourcesession.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})! .then(hitTestSource => { this.transientHitTestSource = hitTestSource; }); } } private checkForDeferredCeilingPlacement(): void { // Check on every frame—both XR modes, only when ceiling is the target and the model is hidden if (!this.placeOnCeiling || !this.presentedScene || this.presentedScene.visible) return; const isWorldSpaceDeferred = this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone; const isScreenSpaceDeferred = this.xrMode === XRMode.SCREEN_SPACE; if (isWorldSpaceDeferred || isScreenSpaceDeferred) { if (this.isViewPointingUp()) { this.performDeferredPlacement(); } } } private performDeferredPlacement(): void { const scene = this.presentedScene!; if (this.xrMode === XRMode.WORLD_SPACE) { const xrCamera = scene.getCamera(); const {position, scale} = this.calculateWorldSpaceOptimalPlacement(scene, xrCamera); this.goalPosition.copy(position); this.goalScale = scale; this.initialModelScale = scale; scene.pivot.position.copy(position); scene.pivot.scale.set(scale, scale, scale); this.worldSpaceInitialPlacementDone = true; this.calculateWorldSpaceScaleLimits(scene); this.enableWorldSpaceUserInteraction(); } else { // SCREEN_SPACE const xrCamera = scene.getCamera(); const cameraDirection = xrCamera.getWorldDirection(new Vector3()); const radius = Math.max(1, 2 * scene.boundingSphere.radius); scene.pivot.position.copy(xrCamera.position).add(cameraDirection.multiplyScalar(radius)); this.updateTarget(); const target = scene.getTarget(); scene.pivot.position.add(target).sub(this.oldTarget); this.goalPosition.copy(scene.pivot.position); // Setup touch interaction if needed const {session} = this.frame!; session.addEventListener('selectstart', this.onSelectStart); session.addEventListener('selectend', this.onSelectEnd); session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})! .then(hitTestSource => { this.transientHitTestSource = hitTestSource; }); } scene.visible = true; scene.setHotspotsVisibility(true); this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED}); } private getTouchLocation(): Vector3|null { const {axes} = this.inputSource!.gamepad!; let location = this.placementBox!.getExpandedHit( @@ -869,42 +931,71 @@ export class ARRenderer extends EventDispatcher< * until a ceiling hit arrives (no premature floor placement). */ public moveToAnchor(frame: XRFrame) { // Handle deferred initial placement for ceiling mode if (this.xrMode(this.placeOnCeiling && this.xrMode === XRMode.WORLD_SPACE && !this.worldSpaceInitialPlacementDone)!this.worldSpaceInitialPlacementDone && !this.presentedScene!.visible) { // Check if orientation is now sufficient if (!this.isViewPointingUp()) { this.placementBox!.showconsole.log('[ARR/moveToAnchor] Still waiting for proper ceiling orientation'); return; } // Orientation is good - complete the deferred world-space placement const scene = this.presentedScene!; const xrCamera = scene.getCamera(); const {position: optimalPosition, scale: optimalScale} = this.calculateWorldSpaceOptimalPlacement(scene, xrCamera); this.goalPosition.copy(optimalPosition); this.goalScale = optimalScale; this.initialModelScale = optimalScale; scene.pivot.position.copy(optimalPosition); scene.pivot.scale.set(optimalScale, optimalScale, optimalScale); this.worldSpaceInitialPlacementDone = true; this.calculateWorldSpaceScaleLimits(scene); this.enableWorldSpaceUserInteraction(); scene.visible = false;true; this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED}); return; } const hitSource = this.initialHitSource; if (!hitSource) return; const hits = frame.getHitTestResults(hitSource);// Skip for world-space mode after initial placement (unless ceiling was deferred) if (hits.length(this.xrMode === 0) return; const hitPointXRMode.WORLD_SPACE && this.worldSpaceInitialPlacementDone) { this.placementBox!.show = this.getHitPoint(hits[0]); // applies normal filtering if (!hitPoint)false; this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED}); return; } } this.placementBox!.showprivate isViewPointingUp(thresholdDeg: number = true; this.presentedScene!.visibleCEILING_ORIENTATION_THRESHOLD): boolean { const cam = !(this.placeOnCeilingthis.presentedScene!.getCamera(); // Handle ArrayCamera (common in XR) const realCam: any = (cam as any).isArrayCamera && !this.placementComplete);Array.isArray((cam as any).cameras) ? (cam as any).cameras[0] // Use first sub-camera : cam; if (!this.isTranslating) { if (this.placeOnWall) { this.goalPosition.copy(hitPoint); // wall → full XYZ } else if (this.placeOnCeiling) { this.goalPosition.copy(hitPoint); } else { this.goalPosition.y = hitPoint.y; // floor → drop only Y }(!realCam || typeof realCam.updateMatrixWorld !== 'function') { return false; } hitSource.cancel(); this.initialHitSource = null; this.placementComplete// Update camera matrix to get current world orientation realCam.updateMatrixWorld(true); const elements = true; this.presentedScene!.visiblerealCam.matrixWorld.elements; // Get forward direction from camera matrix (-Z column) const forwardY = true;-elements[9]; // reveal after hit this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});Y component of forward vector const minY = Math.sin(thresholdDeg * Math.PI / 180); return forwardY >= minY; } private onSelectStart = (event: Event) => { const hitSource = this.transientHitTestSource; @@ -1281,7 +1372,6 @@ export class ARRenderer extends EventDispatcher< } this.frame = frame; this.ensureCeilingHitTestSource(frame); // increamenets a counter tracking how many frames have been processed sinces the session started ++this.frames; // refSpace and pose are used to get the user's current position and orientation in the XR session. @@ -1320,6 +1410,7 @@ export class ARRenderer extends EventDispatcher< this.updateView(view); if (isFirstView) { this.checkForDeferredCeilingPlacement(); this.handleFirstView(frame, time); isFirstView = false; } @@ -1328,43 +1419,6 @@ export class ARRenderer extends EventDispatcher< } } // ToDo check if this method is really necessary. // Compiler wont let the code compile without this function... private ensureCeilingHitTestSource(frame: XRFrame) { if (!this.placeOnCeiling || this.initialHitSource) return; // Guard frame and session // ToDo is this necessary? const session = frame?.session; if (!session) return; const hasRequestHitTestSource = 'requestHitTestSource' in session && typeof (session as any).requestHitTestSource === 'function'; if (!hasRequestHitTestSource) { return; } // Use viewer reference space for the directional ray session.requestReferenceSpace('viewer') .then(viewerSpace => { const r = CEILING_HIT_ANGLE_DEG * Math.PI / 180; return (session as any).requestHitTestSource({ space: viewerSpace, offsetRay: new XRRay( new DOMPoint(0, 0, 0), { x: 0, y: Math.sin(r), z: -Math.cos(r) } ), }); }) .then((src: XRHitTestSource) => { this.initialHitSource = src; }) .catch(() => { // Not ready yet (e.g., early frames); silently retry next frame }); } /** * Calculate optimal scale and position for world-space AR presentation