improves placement logic and implements a feature to disable touch interaction in ar mode

allowCeilingPlacement
MrPlatnum 2025-09-18 09:29:41 +02:00
parent c6f789a7c9
commit 8b07a88309
3 changed files with 1224 additions and 127 deletions

File diff suppressed because one or more lines are too long

View File

@ -68,6 +68,7 @@ const $triggerLoad = Symbol('triggerLoad');
export declare interface ARInterface {
ar: boolean;
arModes: string;
arInteraction: boolean;
arScale: string;
arPlacement: 'floor'|'wall'|'ceiling';
arAnchor: string|null;
@ -84,6 +85,9 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
class ARModelViewerElement extends ModelViewerElement {
@property({type: Boolean, attribute: 'ar'}) ar: boolean = false;
@property({type: Boolean, attribute: 'ar-interaction'})
arInteraction: boolean = true;
@property({type: String, attribute: 'ar-scale'}) arScale: string = 'auto';
@property({type: String, attribute: 'ar-usdz-max-texture-size'})
@ -181,6 +185,10 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
this[$scene].canScale = this.arScale !== 'fixed';
}
if (changedProperties.has('arInteraction')) {
this[$renderer].arRenderer.isInteractionEnabled = this.arInteraction;
}
if (changedProperties.has('arPlacement')) {
this[$scene].updateShadow();
this[$needsRender]();
@ -194,6 +202,17 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
this[$arModes] = deserializeARModes(this.arModes);
}
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
const arRenderer = this[$renderer].arRenderer;
const isDeferredCeiling = this.arPlacement === 'ceiling' &&
!arRenderer.isObjectPlaced &&
!arRenderer.presentedScene?.visible;
if (!isDeferredCeiling) {
arRenderer.updateAnchor(this.arAnchor);
}
}
if (changedProperties.has('ar') || changedProperties.has('arModes') ||
changedProperties.has('src') || changedProperties.has('iosSrc') ||
changedProperties.has('arUsdzMaxTextureSize')) {
@ -204,7 +223,7 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
public getAnchor(): string {
const arRenderer = this[$renderer].arRenderer;
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
const position = arRenderer.currentGoalPosition;
const position = arRenderer.currentPosition;
return `${position.x} ${position.y} ${position.z}`;
}
return 'Model not placed in AR yet.';
@ -297,6 +316,7 @@ configuration or device capabilities');
this[$arButtonContainer].removeEventListener(
'click', this[$onARButtonContainerClick]);
const {arRenderer} = this[$renderer];
arRenderer.isInteractionEnabled = this.arInteraction;
if (this.arPlacement === 'wall') {
arRenderer.placementMode = 'wall';
} else if (this.arPlacement === 'ceiling') {

View File

@ -131,6 +131,7 @@ export class ARRenderer extends EventDispatcher<
public currentSession: XRSession|null = null;
public placementMode:'floor'|'wall'|'ceiling' = 'floor';
public anchorOffset: string|null = null;
public isInteractionEnabled = true;
private placementBox: PlacementBox|null = null;
private menuPanel: XRMenuPanel|null = null;
@ -230,6 +231,13 @@ export class ARRenderer extends EventDispatcher<
return this.goalPosition;
}
public get currentPosition(): Vector3 {
if (this.presentedScene) {
return this.presentedScene.pivot.position;
}
return this.goalPosition;
}
public get isObjectPlaced(): boolean {
return this.placementComplete;
}
@ -417,9 +425,11 @@ export class ARRenderer extends EventDispatcher<
private setupController(controller: XRController) {
this.setupXRControllerLine(controller);
if (this.isInteractionEnabled) {
controller.addEventListener('selectstart', this.onControllerSelectStart);
controller.addEventListener('selectend', this.onControllerSelectEnd);
}
}
private setupXRControllers() {
this.xrController1 = this.threeRenderer.xr.getController(0) as XRController;
this.xrController2 = this.threeRenderer.xr.getController(1) as XRController;
@ -801,27 +811,22 @@ export class ARRenderer extends EventDispatcher<
if (this.parsedAnchorOffset != null) {
// Set position directly from the provided anchor offset.
// This position is relative to the initial XR reference space origin.
position.copy(this.parsedAnchorOffset);
this.goalPosition.copy(this.parsedAnchorOffset);
// Set up the scene for presentation
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
scene.setHotspotsVisibility(true);
scene.visible = true;
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
// Mark placement as complete to bypass further automatic placement.
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
// Hide the placement box as it's not needed.
if (this.placementBox) {
this.placementBox.show = false;
}
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
// Enable user interaction controls for the appropriate mode.
if (this.isInteractionEnabled) {
if (this.xrMode === XRMode.SCREEN_SPACE) {
const { session } = this.frame!;
session.addEventListener('selectstart', this.onSelectStart);
@ -830,12 +835,9 @@ export class ARRenderer extends EventDispatcher<
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
} else { // WORLD_SPACE
this.enableWorldSpaceUserInteraction();
// Hide the placement box again as it's not needed for manual anchoring.
if (this.placementBox) {
this.placementBox.show = false;
}
}
return; // Skip the rest of the automatic placement logic.
return;
}
const {width, height} = this.overlay!.getBoundingClientRect();
@ -852,10 +854,9 @@ export class ARRenderer extends EventDispatcher<
this.goalYaw = scene.yaw;
if (this.placeOnCeiling && !this.isViewPointingUp()) {
scene.visible = false; // Hide until properly oriented
scene.setHotspotsVisibility(true); // Still show UI
scene.visible = false;
scene.setHotspotsVisibility(true);
// Set up touch interaction for screen-space mode
if (this.xrMode === XRMode.SCREEN_SPACE) {
const {session} = this.frame!;
session.addEventListener('selectstart', this.onSelectStart);
@ -863,35 +864,25 @@ export class ARRenderer extends EventDispatcher<
session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})!
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
}
return; // Exit early - don't place yet
return;
}
// 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
if (this.xrMode === XRMode.WORLD_SPACE) {
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
} else { // SCREEN_SPACE
const radius = Math.max(1, 2 * scene.boundingSphere.radius);
position.copy(xrCamera.position)
.add(cameraDirection.multiplyScalar(radius));
@ -903,9 +894,18 @@ export class ARRenderer extends EventDispatcher<
this.goalPosition.copy(position);
}
this.placementComplete = true;
scene.visible = true;
scene.setHotspotsVisibility(true);
scene.visible = true; // Model is properly oriented, show it
if (this.placementBox) {
this.placementBox.show = false;
}
this.presentedScene?.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
if (this.isInteractionEnabled) {
if (this.xrMode === XRMode.SCREEN_SPACE) {
const { session } = this.frame!;
session.addEventListener('selectstart', this.onSelectStart);
@ -914,6 +914,7 @@ export class ARRenderer extends EventDispatcher<
.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
@ -952,17 +953,23 @@ export class ARRenderer extends EventDispatcher<
scene.pivot.position.add(target).sub(this.oldTarget);
this.goalPosition.copy(scene.pivot.position);
// Setup touch interaction if needed
if (this.isInteractionEnabled) {
const { session } = this.frame!;
session.addEventListener('selectstart', this.onSelectStart);
session.addEventListener('selectend', this.onSelectEnd);
session.requestHitTestSourceForTransientInput!({ profile: 'generic-touchscreen' })!
.then(hitTestSource => { this.transientHitTestSource = hitTestSource; });
}
}
this.placementComplete = true;
scene.visible = true;
scene.setHotspotsVisibility(true);
scene.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
}
private getTouchLocation(): Vector3|null {
const {axes} = this.inputSource!.gamepad!;
let location = this.placementBox!.getExpandedHit(
@ -1015,48 +1022,32 @@ export class ARRenderer extends EventDispatcher<
* until a ceiling hit arrives (no premature floor placement).
*/
public moveToAnchor(frame: XRFrame) {
if (this.parsedAnchorOffset != null) {
return;
}
// Handle deferred initial placement for ceiling mode
if (this.placeOnCeiling &&
this.xrMode === XRMode.WORLD_SPACE &&
!this.worldSpaceInitialPlacementDone &&
!this.presentedScene!.visible) {
// Check if orientation is now sufficient
if (!this.isViewPointingUp()) {
console.log('[ARR/moveToAnchor] Still waiting for proper ceiling orientation');
if (this.parsedAnchorOffset != null || this.placementComplete) {
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);
const hitSource = this.initialHitSource;
if (hitSource == null) {
return;
}
this.goalPosition.copy(optimalPosition);
this.goalScale = optimalScale;
this.initialModelScale = optimalScale;
const hitResults = frame.getHitTestResults(hitSource);
if (hitResults.length === 0) {
return;
}
scene.pivot.position.copy(optimalPosition);
scene.pivot.scale.set(optimalScale, optimalScale, optimalScale);
const hitResult = hitResults[0];
const hitPosition = this.getHitPoint(hitResult);
this.worldSpaceInitialPlacementDone = true;
this.calculateWorldSpaceScaleLimits(scene);
this.enableWorldSpaceUserInteraction();
if (hitPosition != null) {
this.goalPosition.copy(hitPosition);
scene.visible = true;
this.placementComplete = true;
if (this.placementBox) {
this.placementBox.show = false;
}
this.presentedScene!.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
return;
}
// Skip for world-space mode after initial placement (unless ceiling was deferred)
if (this.xrMode === XRMode.WORLD_SPACE && this.worldSpaceInitialPlacementDone) {
this.placementBox!.show = false;
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
return;
}
}
@ -1085,6 +1076,9 @@ export class ARRenderer extends EventDispatcher<
private onSelectStart = (event: Event) => {
if (!this.isInteractionEnabled) {
return;
}
const hitSource = this.transientHitTestSource;
if (hitSource == null) {
return;