Skip to content

Commit 6384d7b

Browse files
Update XRMenuPanel icones to use svg and improved code readability (#5077)
1 parent cae8383 commit 6384d7b

File tree

2 files changed

+168
-97
lines changed

2 files changed

+168
-97
lines changed

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

Lines changed: 167 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -2,109 +2,190 @@ import {Camera, CanvasTexture, Mesh,Object3D, Shape,ShapeGeometry, LinearFilter,
22
import {Damper} from './Damper.js';
33
import {ModelScene} from './ModelScene.js';
44
import { PlacementBox } from './PlacementBox.js';
5+
// SVG strings for the icons are defined here to avoid io and better performance for xr.
6+
const CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
7+
<path d="M6.4,19L5,17.6L10.6,12L5,6.4L6.4,5L12,10.6L17.6,5L19,6.4L13.4,12L19,17.6L17.6,19L12,13.4L6.4,19Z"/>
8+
</svg>`;
59

6-
const MAX_OPACITY = 1;
7-
const PANEL_WIDTH = 0.15;
8-
const PANEL_HEIGHT = 0.08;
9-
const PANEL_CORNER_RADIUS = 0.02;
10+
const VIEW_REAL_SIZE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
11+
<path d="M7,17V9H5V7H9V17H7ZM11,17V15H13V17H11ZM16,17V9H14V7H18V17H16ZM11,13V11H13V13H11Z"/>
12+
</svg>`;
13+
14+
const REPLAY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#E1E2E8">
15+
<defs>
16+
<clipPath id="clip0">
17+
<path d="M0,0h24v24h-24z"/>
18+
</clipPath>
19+
</defs>
20+
<g clip-path="url(#clip0)">
21+
<path d="M12,22C10.75,22 9.575,21.767 8.475,21.3C7.392,20.817 6.442,20.175 5.625,19.375C4.825,18.558 4.183,17.608 3.7,16.525C3.233,15.425 3,14.25 3,13H5C5,14.95 5.675,16.608 7.025,17.975C8.392,19.325 10.05,20 12,20C13.95,20 15.6,19.325 16.95,17.975C18.317,16.608 19,14.95 19,13C19,11.05 18.317,9.4 16.95,8.05C15.6,6.683 13.95,6 12,6H11.85L13.4,7.55L12,9L8,5L12,1L13.4,2.45L11.85,4H12C13.25,4 14.417,4.242 15.5,4.725C16.6,5.192 17.55,5.833 18.35,6.65C19.167,7.45 19.808,8.4 20.275,9.5C20.758,10.583 21,11.75 21,13C21,14.25 20.758,15.425 20.275,16.525C19.808,17.608 19.167,18.558 18.35,19.375C17.55,20.175 16.6,20.817 15.5,21.3C14.417,21.767 13.25,22 12,22Z"/>
22+
</g>
23+
</svg>`;
24+
25+
// Panel configuration
26+
const PANEL_CONFIG = {
27+
width: 0.16,
28+
height: 0.07,
29+
cornerRadius: 0.03,
30+
opacity: 1,
31+
color: 0x000000
32+
} as const;
33+
34+
// Button configuration
35+
const BUTTON_CONFIG = {
36+
size: 0.05, // Fixed size for all buttons
37+
zOffset: 0.01, // Distance from panel surface
38+
spacing: 0.07 // Space between button centers
39+
} as const;
40+
41+
// Icon configuration
42+
const ICON_CONFIG = {
43+
canvasSize: 128,
44+
filter: LinearFilter
45+
} as const;
1046

1147
export class XRMenuPanel extends Object3D {
12-
private panelMesh: Mesh;
13-
private exitButton: Mesh;
14-
private toggleButton: Mesh;
48+
private panelMesh!: Mesh;
49+
private exitButton!: Mesh;
50+
private toggleButton!: Mesh;
1551
private goalOpacity: number;
1652
private opacityDamper: Damper;
1753
private isActualSize: boolean = false; // Start with normalized size
1854

55+
// Cache for pre-rendered textures
56+
private static readonly iconTextures = new Map<string, CanvasTexture>();
57+
1958
constructor() {
2059
super();
21-
const panelShape = new Shape();
22-
const w = PANEL_WIDTH, h = PANEL_HEIGHT, r = PANEL_CORNER_RADIUS;
23-
// straight horizontal bottom edge and a rounded bottom-right corner with a radius of r
24-
panelShape.moveTo(-w / 2 + r, -h / 2);
25-
panelShape.lineTo(w / 2 - r, -h / 2);
26-
panelShape.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
27-
// the right most line and the rounded up-right
28-
panelShape.lineTo(w / 2, h / 2 - r);
29-
panelShape.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
30-
// the horizontal top edge and rounded up-left
31-
panelShape.lineTo(-w / 2 + r, h / 2);
32-
panelShape.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
33-
// the left line and bottom left corner
34-
panelShape.lineTo(-w / 2, -h / 2 + r);
35-
panelShape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
60+
61+
// Pre-render all icons
62+
this.preRenderIcons();
63+
64+
this.createPanel();
65+
this.createButtons();
66+
67+
this.opacityDamper = new Damper();
68+
this.goalOpacity = PANEL_CONFIG.opacity;
69+
}
70+
71+
private createPanel(): void {
72+
const panelShape = this.createPanelShape();
3673
const geometry = new ShapeGeometry(panelShape);
3774
const material = new MeshBasicMaterial({
38-
color: 0x000000,
39-
opacity: MAX_OPACITY,
75+
color: PANEL_CONFIG.color,
76+
opacity: PANEL_CONFIG.opacity,
4077
transparent: true
4178
});
4279
this.panelMesh = new Mesh(geometry, material);
4380
this.panelMesh.name = 'MenuPanel';
4481
this.add(this.panelMesh);
82+
}
4583

84+
private createButtons(): void {
4685
// Create exit button
47-
this.exitButton = this.createButton('X');
86+
this.exitButton = this.createButton('close');
4887
this.exitButton.name = 'ExitButton';
49-
this.exitButton.position.set(0.035, 0, 0.01); // Keep X button position
88+
this.exitButton.position.set(BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
5089
this.add(this.exitButton);
5190

52-
// Create toggle button with adjusted position for larger text
53-
this.toggleButton = this.createButton('1:1', {
54-
width: 0.06, // Slightly wider for the longer text
55-
height: 0.05 // Keep same height as X button
56-
});
91+
// Create toggle button
92+
this.toggleButton = this.createButton('view-real-size');
5793
this.toggleButton.name = 'ToggleButton';
58-
this.toggleButton.position.set(-0.03, 0, 0.01); // Move slightly more to the left to account for wider button
94+
this.toggleButton.position.set(-BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
5995
this.add(this.toggleButton);
60-
61-
this.opacityDamper = new Damper();
62-
this.goalOpacity = MAX_OPACITY;
63-
}
96+
}
6497

65-
createButton(label: string, options?: {
66-
width?: number;
67-
height?: number;
68-
fontSize?: number;
69-
textColor?: string;
70-
backgroundColor?: string;
71-
fontFamily?: string;
72-
}): Mesh {
73-
const {
74-
width = 0.05,
75-
height = 0.05,
76-
fontSize = 80,
77-
textColor = '#cccccc',
78-
backgroundColor = 'transparent',
79-
fontFamily = 'sans-serif'
80-
} = options || {};
98+
private createPanelShape(): Shape {
99+
const shape = new Shape();
100+
const { width: w, height: h, cornerRadius: r } = PANEL_CONFIG;
101+
102+
// Create rounded rectangle path
103+
shape.moveTo(-w / 2 + r, -h / 2);
104+
shape.lineTo(w / 2 - r, -h / 2);
105+
shape.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
106+
shape.lineTo(w / 2, h / 2 - r);
107+
shape.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
108+
shape.lineTo(-w / 2 + r, h / 2);
109+
shape.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
110+
shape.lineTo(-w / 2, -h / 2 + r);
111+
shape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
81112

82-
const canvasSize = 128;
113+
return shape;
114+
}
115+
116+
private preRenderIcons(): void {
117+
const iconSvgs = [
118+
{ key: 'close', svg: CLOSE_ICON_SVG },
119+
{ key: 'view-real-size', svg: VIEW_REAL_SIZE_ICON_SVG },
120+
{ key: 'replay', svg: REPLAY_ICON_SVG }
121+
];
122+
123+
iconSvgs.forEach(({ key, svg }) => {
124+
if (!XRMenuPanel.iconTextures.has(key)) {
125+
this.createTextureFromSvg(svg, key);
126+
}
127+
});
128+
}
129+
130+
private createTextureFromSvg(svgContent: string, key: string): void {
83131
const canvas = document.createElement('canvas');
84-
canvas.width = canvasSize;
85-
canvas.height = canvasSize;
132+
canvas.width = ICON_CONFIG.canvasSize;
133+
canvas.height = ICON_CONFIG.canvasSize;
86134
const ctx = canvas.getContext('2d')!;
87135

88-
// Background
89-
if (backgroundColor !== 'transparent') {
90-
ctx.fillStyle = backgroundColor;
91-
ctx.fillRect(0, 0, canvasSize, canvasSize);
136+
// Create an image from SVG content
137+
const img = new Image();
138+
const svgBlob = new Blob([svgContent], {type: 'image/svg+xml'});
139+
const url = URL.createObjectURL(svgBlob);
140+
141+
img.onload = () => {
142+
ctx.drawImage(img, 0, 0, ICON_CONFIG.canvasSize, ICON_CONFIG.canvasSize);
143+
144+
const texture = new CanvasTexture(canvas);
145+
texture.needsUpdate = true;
146+
texture.minFilter = ICON_CONFIG.filter;
147+
148+
XRMenuPanel.iconTextures.set(key, texture);
149+
URL.revokeObjectURL(url);
150+
};
151+
img.src = url;
152+
}
153+
154+
createButton(iconKey: string): Mesh {
155+
// Create a placeholder mesh
156+
const material = new MeshBasicMaterial({ transparent: true });
157+
const geometry = new PlaneGeometry(BUTTON_CONFIG.size, BUTTON_CONFIG.size);
158+
const mesh = new Mesh(geometry, material);
159+
160+
// Try to get cached texture, or create a fallback
161+
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
162+
if (cachedTexture) {
163+
(mesh.material as MeshBasicMaterial).map = cachedTexture;
164+
(mesh.material as MeshBasicMaterial).needsUpdate = true;
165+
} else {
166+
// RACE CONDITION FIX: Texture creation is async (img.onload), but button creation is sync
167+
// This fallback handles the case where buttons are created before textures finish loading
168+
this.createTextureFromSvg(iconKey === 'close' ? CLOSE_ICON_SVG :
169+
iconKey === 'view-real-size' ? VIEW_REAL_SIZE_ICON_SVG :
170+
REPLAY_ICON_SVG, iconKey);
171+
172+
// Polling mechanism: Wait for async texture creation to complete
173+
// This prevents white squares from appearing on first load
174+
const checkTexture = () => {
175+
const texture = XRMenuPanel.iconTextures.get(iconKey);
176+
if (texture) {
177+
// Texture is ready - apply it to the mesh
178+
(mesh.material as MeshBasicMaterial).map = texture;
179+
(mesh.material as MeshBasicMaterial).needsUpdate = true;
180+
} else {
181+
// Texture not ready yet - check again in 10ms
182+
setTimeout(checkTexture, 10);
183+
}
184+
};
185+
checkTexture();
92186
}
93-
94-
// Text
95-
ctx.fillStyle = textColor;
96-
ctx.font = `bold ${fontSize}px ${fontFamily}`;
97-
ctx.textAlign = 'center';
98-
ctx.textBaseline = 'middle';
99-
ctx.fillText(label, canvasSize / 2, canvasSize / 2);
100-
101-
const texture = new CanvasTexture(canvas);
102-
texture.needsUpdate = true;
103-
texture.minFilter = LinearFilter;
104-
105-
const material = new MeshBasicMaterial({ map: texture, transparent: true });
106-
const geometry = new PlaneGeometry(width, height);
107-
return new Mesh(geometry, material);
187+
188+
return mesh;
108189
}
109190

110191
exitButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) {
@@ -128,34 +209,24 @@ export class XRMenuPanel extends Object3D {
128209
}
129210

130211
this.isActualSize = !this.isActualSize;
131-
const newLabel = this.isActualSize ? '@' : '1:1';
132-
this.updateScaleModeButtonLabel(newLabel);
212+
// Toggle between view real size icon and replay icon
213+
// When isActualSize is true, show replay icon (to reset)
214+
// When isActualSize is false, show view real size icon (to go to actual size)
215+
const iconKey = this.isActualSize ? 'replay' : 'view-real-size';
216+
this.updateScaleModeButtonLabel(iconKey);
133217

134218
const targetScale = this.isActualSize ? 1.0 : initialModelScale;
135219
const goalScale = Math.max(minScale, Math.min(maxScale, targetScale));
136220

137221
return goalScale;
138222
}
139223

140-
private updateScaleModeButtonLabel(label: string) {
141-
const canvasSize = 128;
142-
const canvas = document.createElement('canvas');
143-
canvas.width = canvasSize;
144-
canvas.height = canvasSize;
145-
const ctx = canvas.getContext('2d')!;
146-
147-
ctx.fillStyle = '#cccccc';
148-
ctx.font = 'bold 80px sans-serif';
149-
ctx.textAlign = 'center';
150-
ctx.textBaseline = 'middle';
151-
ctx.fillText(label, canvasSize / 2, canvasSize / 2);
152-
153-
const texture = new CanvasTexture(canvas);
154-
texture.needsUpdate = true;
155-
texture.minFilter = LinearFilter;
156-
157-
(this.toggleButton.material as MeshBasicMaterial).map = texture;
158-
(this.toggleButton.material as MeshBasicMaterial).needsUpdate = true;
224+
private updateScaleModeButtonLabel(iconKey: string) {
225+
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
226+
if (cachedTexture) {
227+
(this.toggleButton.material as MeshBasicMaterial).map = cachedTexture;
228+
(this.toggleButton.material as MeshBasicMaterial).needsUpdate = true;
229+
}
159230
}
160231

161232
updatePosition(camera: Camera, placementBox: PlacementBox) {
@@ -188,7 +259,7 @@ export class XRMenuPanel extends Object3D {
188259
* Set the box's visibility; it will fade in and out.
189260
*/
190261
set show(visible: boolean) {
191-
this.goalOpacity = visible ? MAX_OPACITY : 0;
262+
this.goalOpacity = visible ? PANEL_CONFIG.opacity : 0;
192263
}
193264

194265
/**

packages/render-fidelity-tools/src/artifact-creator.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -382,7 +382,7 @@ export class ArtifactCreator {
382382
}
383383

384384
const screenshot =
385-
await page.screenshot({path: outputPath, omitBackground: true});
385+
await page.screenshot({path: outputPath as `${string}.png`, omitBackground: true});
386386

387387
page.close();
388388

0 commit comments

Comments
 (0)