extens model-viewer so it exposes the models anchor to allow moving the model in AR Context

allowCeilingPlacement
MrPlatnum 2025-09-09 09:35:39 +02:00
parent 5ce6d73279
commit c6f789a7c9
6 changed files with 304 additions and 6 deletions

View File

@ -18154,6 +18154,7 @@ class ARRenderer extends EventDispatcher {
this.renderer = renderer;
this.currentSession = null;
this.placementMode = 'floor';
this.anchorOffset = null;
this.placementBox = null;
this.menuPanel = null;
this.lastTick = null;
@ -18172,6 +18173,7 @@ class ARRenderer extends EventDispatcher {
this.xrController1 = null;
this.xrController2 = null;
this.selectedXRController = null;
this.parsedAnchorOffset = null;
this.tracking = true;
this.frames = 0;
this.initialized = false;
@ -18384,6 +18386,35 @@ class ARRenderer extends EventDispatcher {
get presentedScene() {
return this._presentedScene;
}
get currentGoalPosition() {
return this.goalPosition;
}
get isObjectPlaced() {
return this.placementComplete;
}
updateAnchor(newAnchor) {
var _a;
if (newAnchor) {
const parts = newAnchor.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
const newPosition = new Vector3(parts[0], parts[1], parts[2]);
this.goalPosition.copy(newPosition);
this.parsedAnchorOffset = newPosition;
if (!this.placementComplete) {
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
if (this.placementBox) {
this.placementBox.show = false;
}
(_a = this.presentedScene) === null || _a === void 0 ? void 0 : _a.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
}
}
else {
console.warn(`Invalid dynamic ar-anchor value: "${newAnchor}"`);
}
}
}
/**
* Resolves to true if the renderer has detected all the necessary qualities
* to support presentation in AR.
@ -18407,6 +18438,16 @@ class ARRenderer extends EventDispatcher {
if (this.isPresenting) {
console.warn('Cannot present while a model is already presenting');
}
this.parsedAnchorOffset = null;
if (this.anchorOffset) {
const parts = this.anchorOffset.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
this.parsedAnchorOffset = new Vector3(parts[0], parts[1], parts[2]);
}
else {
console.warn(`Invalid ar-anchor value: "${this.anchorOffset}"`);
}
}
let waitForAnimationFrame = new Promise((resolve, _reject) => {
requestAnimationFrame(() => resolve());
});
@ -18680,6 +18721,8 @@ class ARRenderer extends EventDispatcher {
this.inputSource = null;
this.overlay = null;
this.worldSpaceInitialPlacementDone = false;
this.anchorOffset = null;
this.parsedAnchorOffset = null;
if (this.resolveCleanup != null) {
this.resolveCleanup();
}
@ -18714,6 +18757,40 @@ class ARRenderer extends EventDispatcher {
const { pivot, element } = scene;
const { position } = pivot;
const xrCamera = scene.getCamera();
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
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.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; });
}
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.
}
const { width, height } = this.overlay.getBoundingClientRect();
scene.setSize(width, height);
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
@ -18866,6 +18943,9 @@ class ARRenderer extends EventDispatcher {
* until a ceiling hit arrives (no premature floor placement).
*/
moveToAnchor(frame) {
if (this.parsedAnchorOffset != null) {
return;
}
// Handle deferred initial placement for ceiling mode
if (this.placeOnCeiling &&
this.xrMode === XRMode.WORLD_SPACE &&
@ -28491,6 +28571,7 @@ const ARMixin = (ModelViewerElement) => {
this.arScale = 'auto';
this.arUsdzMaxTextureSize = 'auto';
this.arPlacement = 'floor';
this.arAnchor = null;
this.arModes = DEFAULT_AR_MODES;
this.iosSrc = null;
this.xrEnvironment = false;
@ -28554,6 +28635,9 @@ const ARMixin = (ModelViewerElement) => {
this[$scene].updateShadow();
this[$needsRender]();
}
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
this[$renderer].arRenderer.updateAnchor(this.arAnchor);
}
if (changedProperties.has('arModes')) {
this[$arModes] = deserializeARModes(this.arModes);
}
@ -28563,6 +28647,14 @@ const ARMixin = (ModelViewerElement) => {
this[$selectARMode]();
}
}
getAnchor() {
const arRenderer = this[$renderer].arRenderer;
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
const position = arRenderer.currentGoalPosition;
return `${position.x} ${position.y} ${position.z}`;
}
return 'Model not placed in AR yet.';
}
/**
* Activates AR. Note that for any mode that is not WebXR-based, this
* method most likely has to be called synchronous from a user
@ -28649,6 +28741,7 @@ configuration or device capabilities');
else {
arRenderer.placementMode = 'floor';
}
arRenderer.anchorOffset = this.arAnchor;
await arRenderer.present(this[$scene], this.xrEnvironment);
}
catch (error) {
@ -28822,6 +28915,9 @@ configuration or device capabilities');
__decorate$2([
property({ type: String, attribute: 'ar-placement' })
], ARModelViewerElement.prototype, "arPlacement", void 0);
__decorate$2([
property({ type: String, attribute: 'ar-anchor' })
], ARModelViewerElement.prototype, "arAnchor", void 0);
__decorate$2([
property({ type: String, attribute: 'ar-modes' })
], ARModelViewerElement.prototype, "arModes", void 0);

File diff suppressed because one or more lines are too long

View File

@ -69779,6 +69779,7 @@ class ARRenderer extends EventDispatcher {
this.renderer = renderer;
this.currentSession = null;
this.placementMode = 'floor';
this.anchorOffset = null;
this.placementBox = null;
this.menuPanel = null;
this.lastTick = null;
@ -69797,6 +69798,7 @@ class ARRenderer extends EventDispatcher {
this.xrController1 = null;
this.xrController2 = null;
this.selectedXRController = null;
this.parsedAnchorOffset = null;
this.tracking = true;
this.frames = 0;
this.initialized = false;
@ -70009,6 +70011,35 @@ class ARRenderer extends EventDispatcher {
get presentedScene() {
return this._presentedScene;
}
get currentGoalPosition() {
return this.goalPosition;
}
get isObjectPlaced() {
return this.placementComplete;
}
updateAnchor(newAnchor) {
var _a;
if (newAnchor) {
const parts = newAnchor.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
const newPosition = new Vector3(parts[0], parts[1], parts[2]);
this.goalPosition.copy(newPosition);
this.parsedAnchorOffset = newPosition;
if (!this.placementComplete) {
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
if (this.placementBox) {
this.placementBox.show = false;
}
(_a = this.presentedScene) === null || _a === void 0 ? void 0 : _a.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({ type: 'status', status: ARStatus.OBJECT_PLACED });
}
}
else {
console.warn(`Invalid dynamic ar-anchor value: "${newAnchor}"`);
}
}
}
/**
* Resolves to true if the renderer has detected all the necessary qualities
* to support presentation in AR.
@ -70032,6 +70063,16 @@ class ARRenderer extends EventDispatcher {
if (this.isPresenting) {
console.warn('Cannot present while a model is already presenting');
}
this.parsedAnchorOffset = null;
if (this.anchorOffset) {
const parts = this.anchorOffset.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
this.parsedAnchorOffset = new Vector3(parts[0], parts[1], parts[2]);
}
else {
console.warn(`Invalid ar-anchor value: "${this.anchorOffset}"`);
}
}
let waitForAnimationFrame = new Promise((resolve, _reject) => {
requestAnimationFrame(() => resolve());
});
@ -70305,6 +70346,8 @@ class ARRenderer extends EventDispatcher {
this.inputSource = null;
this.overlay = null;
this.worldSpaceInitialPlacementDone = false;
this.anchorOffset = null;
this.parsedAnchorOffset = null;
if (this.resolveCleanup != null) {
this.resolveCleanup();
}
@ -70339,6 +70382,40 @@ class ARRenderer extends EventDispatcher {
const { pivot, element } = scene;
const { position } = pivot;
const xrCamera = scene.getCamera();
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
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.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; });
}
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.
}
const { width, height } = this.overlay.getBoundingClientRect();
scene.setSize(width, height);
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
@ -70491,6 +70568,9 @@ class ARRenderer extends EventDispatcher {
* until a ceiling hit arrives (no premature floor placement).
*/
moveToAnchor(frame) {
if (this.parsedAnchorOffset != null) {
return;
}
// Handle deferred initial placement for ceiling mode
if (this.placeOnCeiling &&
this.xrMode === XRMode.WORLD_SPACE &&
@ -80116,6 +80196,7 @@ const ARMixin = (ModelViewerElement) => {
this.arScale = 'auto';
this.arUsdzMaxTextureSize = 'auto';
this.arPlacement = 'floor';
this.arAnchor = null;
this.arModes = DEFAULT_AR_MODES;
this.iosSrc = null;
this.xrEnvironment = false;
@ -80179,6 +80260,9 @@ const ARMixin = (ModelViewerElement) => {
this[$scene].updateShadow();
this[$needsRender]();
}
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
this[$renderer].arRenderer.updateAnchor(this.arAnchor);
}
if (changedProperties.has('arModes')) {
this[$arModes] = deserializeARModes(this.arModes);
}
@ -80188,6 +80272,14 @@ const ARMixin = (ModelViewerElement) => {
this[$selectARMode]();
}
}
getAnchor() {
const arRenderer = this[$renderer].arRenderer;
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
const position = arRenderer.currentGoalPosition;
return `${position.x} ${position.y} ${position.z}`;
}
return 'Model not placed in AR yet.';
}
/**
* Activates AR. Note that for any mode that is not WebXR-based, this
* method most likely has to be called synchronous from a user
@ -80274,6 +80366,7 @@ configuration or device capabilities');
else {
arRenderer.placementMode = 'floor';
}
arRenderer.anchorOffset = this.arAnchor;
await arRenderer.present(this[$scene], this.xrEnvironment);
}
catch (error) {
@ -80447,6 +80540,9 @@ configuration or device capabilities');
__decorate$2([
property({ type: String, attribute: 'ar-placement' })
], ARModelViewerElement.prototype, "arPlacement", void 0);
__decorate$2([
property({ type: String, attribute: 'ar-anchor' })
], ARModelViewerElement.prototype, "arAnchor", void 0);
__decorate$2([
property({ type: String, attribute: 'ar-modes' })
], ARModelViewerElement.prototype, "arModes", void 0);

File diff suppressed because one or more lines are too long

View File

@ -70,11 +70,13 @@ export declare interface ARInterface {
arModes: string;
arScale: string;
arPlacement: 'floor'|'wall'|'ceiling';
arAnchor: string|null;
iosSrc: string|null;
xrEnvironment: boolean;
arUsdzMaxTextureSize: string;
readonly canActivateAR: boolean;
activateAR(): Promise<void>;
getAnchor(): string;
}
export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
@ -90,6 +92,9 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
@property({type: String, attribute: 'ar-placement'})
arPlacement: 'floor'|'wall'|'ceiling' = 'floor';
@property({type: String, attribute: 'ar-anchor'})
arAnchor: string|null = null;
@property({type: String, attribute: 'ar-modes'})
arModes: string = DEFAULT_AR_MODES;
@ -181,6 +186,10 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
this[$needsRender]();
}
if (changedProperties.has('arAnchor') && this[$renderer].arRenderer.isPresenting) {
this[$renderer].arRenderer.updateAnchor(this.arAnchor);
}
if (changedProperties.has('arModes')) {
this[$arModes] = deserializeARModes(this.arModes);
}
@ -192,6 +201,15 @@ export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
}
}
public getAnchor(): string {
const arRenderer = this[$renderer].arRenderer;
if (arRenderer.isPresenting && arRenderer.isObjectPlaced) {
const position = arRenderer.currentGoalPosition;
return `${position.x} ${position.y} ${position.z}`;
}
return 'Model not placed in AR yet.';
}
/**
* Activates AR. Note that for any mode that is not WebXR-based, this
* method most likely has to be called synchronous from a user
@ -286,6 +304,7 @@ configuration or device capabilities');
} else {
arRenderer.placementMode = 'floor';
}
arRenderer.anchorOffset = this.arAnchor;
await arRenderer.present(this[$scene], this.xrEnvironment);
} catch (error) {
console.warn('Error while trying to present in AR with WebXR');

View File

@ -130,6 +130,7 @@ export class ARRenderer extends EventDispatcher<
public threeRenderer: WebGLRenderer;
public currentSession: XRSession|null = null;
public placementMode:'floor'|'wall'|'ceiling' = 'floor';
public anchorOffset: string|null = null;
private placementBox: PlacementBox|null = null;
private menuPanel: XRMenuPanel|null = null;
@ -149,6 +150,7 @@ export class ARRenderer extends EventDispatcher<
private xrController1: XRController|null = null;
private xrController2: XRController|null = null;
private selectedXRController: XRController|null = null;
private parsedAnchorOffset: Vector3|null = null;
private tracking = true;
private frames = 0;
@ -224,6 +226,37 @@ export class ARRenderer extends EventDispatcher<
return this._presentedScene;
}
public get currentGoalPosition(): Vector3 {
return this.goalPosition;
}
public get isObjectPlaced(): boolean {
return this.placementComplete;
}
public updateAnchor(newAnchor: string|null) {
if (newAnchor) {
const parts = newAnchor.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
const newPosition = new Vector3(parts[0], parts[1], parts[2]);
this.goalPosition.copy(newPosition);
this.parsedAnchorOffset = newPosition;
if (!this.placementComplete) {
this.placementComplete = true;
this.worldSpaceInitialPlacementDone = true;
if (this.placementBox) {
this.placementBox.show = false;
}
this.presentedScene?.setShadowIntensity(AR_SHADOW_INTENSITY);
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
}
} else {
console.warn(`Invalid dynamic ar-anchor value: "${newAnchor}"`);
}
}
}
/**
* Resolves to true if the renderer has detected all the necessary qualities
* to support presentation in AR.
@ -249,6 +282,16 @@ export class ARRenderer extends EventDispatcher<
console.warn('Cannot present while a model is already presenting');
}
this.parsedAnchorOffset = null;
if (this.anchorOffset) {
const parts = this.anchorOffset.split(' ').map(Number);
if (parts.length === 3 && parts.every(p => !isNaN(p))) {
this.parsedAnchorOffset = new Vector3(parts[0], parts[1], parts[2]);
} else {
console.warn(`Invalid ar-anchor value: "${this.anchorOffset}"`);
}
}
let waitForAnimationFrame = new Promise<void>((resolve, _reject) => {
requestAnimationFrame(() => resolve());
});
@ -322,7 +365,7 @@ export class ARRenderer extends EventDispatcher<
const r = CEILING_HIT_ANGLE_DEG*Math.PI/180;
ray = new XRRay(new DOMPoint(0,0,0), {x:0, y: Math.sin(r), z:-Math.cos(r)});
}
currentSession
.requestHitTestSource!
({space: viewerRefSpace, offsetRay: ray})!.then(hitTestSource => {
@ -711,6 +754,8 @@ export class ARRenderer extends EventDispatcher<
this.inputSource = null;
this.overlay = null;
this.worldSpaceInitialPlacementDone = false;
this.anchorOffset = null;
this.parsedAnchorOffset = null;
if (this.resolveCleanup != null) {
this.resolveCleanup!();
@ -753,6 +798,45 @@ export class ARRenderer extends EventDispatcher<
const {pivot, element} = scene;
const {position} = pivot;
const xrCamera = scene.getCamera();
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
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.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; });
} 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.
}
const {width, height} = this.overlay!.getBoundingClientRect();
scene.setSize(width, height);
@ -830,7 +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
if (!this.placeOnCeiling || !this.presentedScene || this.presentedScene.visible) return;
@ -931,6 +1015,9 @@ 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 &&
@ -1362,7 +1449,7 @@ export class ARRenderer extends EventDispatcher<
this.presentedScene!.renderShadow(this.threeRenderer);
}
/**
* Only public to make it testable.
*/
@ -1370,7 +1457,7 @@ export class ARRenderer extends EventDispatcher<
if (this.xrMode !== XRMode.SCREEN_SPACE) {
this.updateXRControllerHover();
}
this.frame = frame;
// increamenets a counter tracking how many frames have been processed sinces the session started
++this.frames;