Skip to content

Commit 48cd1de

Browse files
feat(ar): Introduce Basic XR Menu Panel and Exit Functionality
1 parent 4efcf2e commit 48cd1de

File tree

2 files changed

+235
-1
lines changed

2 files changed

+235
-1
lines changed

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

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {ModelScene} from './ModelScene.js';
2626
import {PlacementBox} from './PlacementBox.js';
2727
import {Renderer} from './Renderer.js';
2828
import {ChangeSource} from './SmoothControls.js';
29+
import { XRMenuPanel } from './XRMenuPanel.js';
2930

3031
// number of initial null pose XRFrames allowed before we post not-tracking
3132
const INIT_FRAMES = 30;
@@ -109,6 +110,7 @@ export class ARRenderer extends EventDispatcher<
109110
public placeOnWall = false;
110111

111112
private placementBox: PlacementBox|null = null;
113+
private menuPanel: XRMenuPanel|null = null;
112114
private lastTick: number|null = null;
113115
private turntableRotation: number|null = null;
114116
private oldShadowIntensity: number|null = null;
@@ -297,10 +299,47 @@ export class ARRenderer extends EventDispatcher<
297299
new PlacementBox(scene, this.placeOnWall ? 'back' : 'bottom');
298300
this.placementComplete = false;
299301

302+
this.menuPanel = new XRMenuPanel();
303+
scene.add(this.menuPanel);
304+
this.updateMenuPanelPosition(scene.camera, this.placementBox!); // Position the menu panel
305+
306+
300307
this.lastTick = performance.now();
301308
this.dispatchEvent({type: 'status', status: ARStatus.SESSION_STARTED});
302309
}
303310

311+
private updateMenuPanelPosition(camera: PerspectiveCamera, placementBox: PlacementBox) {
312+
if (!this.menuPanel || !placementBox) {
313+
return;
314+
}
315+
316+
// Get the world position of the placement box
317+
const placementBoxWorldPos = new Vector3();
318+
placementBox.getWorldPosition(placementBoxWorldPos);
319+
320+
// Calculate a position slightly in front of the placement box
321+
const offsetUp = -0.2; // Offset upward from the placement box
322+
const offsetForward = 0.9; // Offset forward from the placement box
323+
324+
// Get direction from placement box to camera (horizontal only)
325+
const directionToCamera = new Vector3()
326+
.copy(camera.position)
327+
.sub(placementBoxWorldPos);
328+
directionToCamera.y = 0; // Zero out vertical component
329+
directionToCamera.normalize();
330+
331+
// Calculate the final position
332+
const panelPosition = new Vector3()
333+
.copy(placementBoxWorldPos)
334+
.add(new Vector3(0, offsetUp, 0)) // Move up
335+
.add(directionToCamera.multiplyScalar(offsetForward)); // Move forward
336+
337+
this.menuPanel.position.copy(panelPosition);
338+
339+
// Make the menu panel face the camera
340+
this.menuPanel.lookAt(camera.position);
341+
}
342+
304343
private setupControllers() {
305344
this.controller1 = this.threeRenderer.xr.getController(0) as Controller;
306345
this.controller1.addEventListener(
@@ -358,7 +397,19 @@ export class ARRenderer extends EventDispatcher<
358397
private onControllerSelectStart = (event: XRControllerEvent) => {
359398
const scene = this.presentedScene!;
360399
const controller = event.target;
400+
const menuPanel = this.menuPanel;
361401

402+
const exitIntersect = this.menuPanel!.exitButtonControllerIntersection(scene, controller);
403+
if (exitIntersect != null) {
404+
this.menuPanel?.dispose();
405+
this.stopPresenting();
406+
return;
407+
}
408+
409+
if (menuPanel) {
410+
menuPanel!.show = false;
411+
}
412+
362413
const intersection = this.placementBox!.controllerIntersection(scene,
363414
controller);
364415
if (intersection!=null){
@@ -430,6 +481,9 @@ export class ARRenderer extends EventDispatcher<
430481
scene.pivot.matrix.elements[8], scene.pivot.matrix.elements[10]);
431482
this.goalPosition.x = scene.pivot.position.x;
432483
this.goalPosition.z = scene.pivot.position.z;
484+
485+
const menuPanel = this.menuPanel;
486+
menuPanel!.show = true;
433487
};
434488

435489
/**
@@ -489,6 +543,14 @@ export class ARRenderer extends EventDispatcher<
489543
this.placementBox = new PlacementBox(
490544
this.presentedScene!, this.placeOnWall ? 'back' : 'bottom');
491545
}
546+
if (this.menuPanel) {
547+
this.menuPanel.dispose(); // Add a dispose method to XRMenuPanel if needed
548+
this.menuPanel = null;
549+
}
550+
this.menuPanel = new XRMenuPanel();
551+
this.presentedScene!.add(this.menuPanel);
552+
this.updateMenuPanelPosition(this.presentedScene!.camera, this.placementBox!);
553+
492554
};
493555

494556
private postSessionCleanup() {
@@ -510,6 +572,11 @@ export class ARRenderer extends EventDispatcher<
510572
this.xrLight = null;
511573
}
512574

575+
if (this.menuPanel != null) {
576+
this.menuPanel.dispose();
577+
this.menuPanel = null;
578+
}
579+
513580
scene.add(scene.pivot);
514581
scene.pivot.quaternion.set(0, 0, 0, 1);
515582
scene.pivot.position.set(0, 0, 0);
@@ -913,7 +980,6 @@ export class ARRenderer extends EventDispatcher<
913980
const box = this.placementBox!;
914981
box.updateOpacity(delta);
915982

916-
917983
const bothSelected = this.controller1?.userData.isSelected && this.controller2?.userData.isSelected;
918984
if (bothSelected) {
919985
this.isTwoFingering = true;
@@ -979,6 +1045,13 @@ export class ARRenderer extends EventDispatcher<
9791045
// screen, plus damping time.
9801046
scene.element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
9811047
'camera-change', {detail: {source}}));
1048+
1049+
const menuPanel = this.menuPanel;
1050+
if (menuPanel) {
1051+
menuPanel.updateOpacity(delta);
1052+
// Update menu panel position whenever the model moves
1053+
this.updateMenuPanelPosition(scene.camera, box);
1054+
}
9821055
}
9831056

9841057
/**
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
2+
import {CanvasTexture, Mesh,Object3D, Shape,ShapeGeometry, LinearFilter, MeshBasicMaterial, PlaneGeometry, XRTargetRaySpace} from 'three';
3+
import {Damper} from './Damper.js';
4+
import {ModelScene} from './ModelScene.js';
5+
6+
const MAX_OPACITY = 0.6;
7+
const PANEL_WIDTH = 0.1;
8+
const PANEL_HEIGHT = 0.1;
9+
const PANEL_CORNER_RADIUS = 0.02;
10+
11+
export class XRMenuPanel extends Object3D {
12+
private panelMesh: Mesh;
13+
private exitButton: Mesh;
14+
private goalOpacity: number;
15+
private opacityDamper: Damper;
16+
17+
constructor() {
18+
super();
19+
20+
const panelShape = new Shape();
21+
const w = PANEL_WIDTH, h = PANEL_HEIGHT, r = PANEL_CORNER_RADIUS;
22+
// straight horizontal bottom edge and a rounded bottom-right corner with a radius of r
23+
panelShape.moveTo(-w / 2 + r, -h / 2);
24+
panelShape.lineTo(w / 2 - r, -h / 2);
25+
panelShape.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
26+
// the right most line and the rounded up-right
27+
panelShape.lineTo(w / 2, h / 2 - r);
28+
panelShape.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
29+
// the horizontal top edge and rounded up-left
30+
panelShape.lineTo(-w / 2 + r, h / 2);
31+
panelShape.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
32+
// the left line and bottom left corner
33+
panelShape.lineTo(-w / 2, -h / 2 + r);
34+
panelShape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
35+
36+
const geometry = new ShapeGeometry(panelShape);
37+
const material = new MeshBasicMaterial({
38+
color: 0x000000,
39+
opacity: MAX_OPACITY,
40+
transparent: true
41+
});
42+
43+
this.panelMesh = new Mesh(geometry, material);
44+
this.panelMesh.name = 'MenuPanel';
45+
this.add(this.panelMesh);
46+
47+
48+
this.exitButton = this.createButton('x');
49+
this.exitButton.name = 'ExitButton';
50+
this.exitButton.position.set(0, 0, 0.01);
51+
this.add(this.exitButton);
52+
53+
this.opacityDamper = new Damper();
54+
this.goalOpacity = MAX_OPACITY;
55+
}
56+
57+
createButton(label: string, options?: {
58+
width?: number;
59+
height?: number;
60+
fontSize?: number;
61+
textColor?: string;
62+
backgroundColor?: string;
63+
fontFamily?: string;
64+
}): Mesh {
65+
const {
66+
width = 0.05,
67+
height = 0.05,
68+
fontSize = 80,
69+
textColor = 'white',
70+
backgroundColor = 'transparent',
71+
fontFamily = 'sans-serif'
72+
} = options || {};
73+
74+
const canvasSize = 128;
75+
const canvas = document.createElement('canvas');
76+
canvas.width = canvasSize;
77+
canvas.height = canvasSize;
78+
const ctx = canvas.getContext('2d')!;
79+
80+
// Background
81+
if (backgroundColor !== 'transparent') {
82+
ctx.fillStyle = backgroundColor;
83+
ctx.fillRect(0, 0, canvasSize, canvasSize);
84+
}
85+
86+
// Text
87+
ctx.fillStyle = textColor;
88+
ctx.font = `bold ${fontSize}px ${fontFamily}`;
89+
ctx.textAlign = 'center';
90+
ctx.textBaseline = 'middle';
91+
ctx.fillText(label, canvasSize / 2, canvasSize / 2);
92+
93+
const texture = new CanvasTexture(canvas);
94+
texture.needsUpdate = true;
95+
texture.minFilter = LinearFilter;
96+
97+
const material = new MeshBasicMaterial({ map: texture, transparent: true });
98+
const geometry = new PlaneGeometry(width, height);
99+
return new Mesh(geometry, material);
100+
}
101+
102+
103+
exitButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) {
104+
const hitResult = scene.hitFromController(controller, this.exitButton);
105+
return hitResult;
106+
}
107+
108+
/**
109+
* Set the box's visibility; it will fade in and out.
110+
*/
111+
set show(visible: boolean) {
112+
this.goalOpacity = visible ? MAX_OPACITY : 0;
113+
}
114+
115+
/**
116+
* Call on each frame with the frame delta to fade the box.
117+
*/
118+
updateOpacity(delta: number) {
119+
const material = this.panelMesh.material as MeshBasicMaterial;
120+
const currentOpacity = material.opacity;
121+
const newOpacity = this.opacityDamper.update(currentOpacity, this.goalOpacity, delta, 1);
122+
this.traverse((child) => {
123+
if (child instanceof Mesh) {
124+
const mat = child.material as MeshBasicMaterial;
125+
if (mat.transparent) mat.opacity = newOpacity;
126+
}
127+
});
128+
this.visible = newOpacity > 0;
129+
}
130+
131+
dispose() {
132+
this.children.forEach(child => {
133+
if (child instanceof Mesh) {
134+
// Dispose geometry first
135+
if (child.geometry) {
136+
child.geometry.dispose();
137+
}
138+
139+
// Handle material(s)
140+
// Material can be a single Material or an array of Materials
141+
const materials = Array.isArray(child.material) ? child.material : [child.material];
142+
143+
materials.forEach(material => {
144+
if (material) { // Ensure material exists before proceeding
145+
// Dispose texture if it exists and is a CanvasTexture
146+
// We specifically created CanvasTextures for buttons, so check for that type.
147+
if ('map' in material && material.map instanceof CanvasTexture) { // Check if 'map' property exists and is a CanvasTexture
148+
material.map.dispose();
149+
}
150+
// Dispose material itself
151+
material.dispose();
152+
}
153+
});
154+
}
155+
});
156+
157+
// Remove the panel itself from its parent in the scene graph
158+
this.parent?.remove(this);
159+
}
160+
}
161+

0 commit comments

Comments
 (0)