@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
286 lines • 13.2 kB
JavaScript
import { CanvasTexture, LinearFilter, Mesh, MeshBasicMaterial, Object3D, PlaneGeometry, Shape, ShapeGeometry, Vector3 } from 'three';
import { Damper } from './Damper.js';
// SVG strings for the icons are defined here to avoid io and better performance
// for xr.
const CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
<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"/>
</svg>`;
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">
<path d="M7,17V9H5V7H9V17H7ZM11,17V15H13V17H11ZM16,17V9H14V7H18V17H16ZM11,13V11H13V13H11Z"/>
</svg>`;
const REPLAY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#E1E2E8">
<defs>
<clipPath id="clip0">
<path d="M0,0h24v24h-24z"/>
</clipPath>
</defs>
<g clip-path="url(#clip0)">
<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"/>
</g>
</svg>`;
// Panel configuration
const PANEL_CONFIG = {
width: 0.16,
height: 0.07,
cornerRadius: 0.03,
opacity: 1,
color: 0x000000,
// Distance-based scaling configuration
minDistance: 0.5, // Minimum distance for scaling (meters)
maxDistance: 10.0, // Maximum distance for scaling (meters)
baseScale: 1.0, // Base scale factor
distanceScaleFactor: 0.3 // How much to scale per meter of distance
};
// Button configuration
const BUTTON_CONFIG = {
size: 0.05, // Fixed size for all buttons
zOffset: 0.01, // Distance from panel surface
spacing: 0.07 // Space between button centers
};
// Icon configuration
const ICON_CONFIG = {
canvasSize: 128,
filter: LinearFilter
};
export class XRMenuPanel extends Object3D {
constructor() {
super();
this.isActualSize = false; // Start with normalized size
// Pre-render all icons
this.preRenderIcons();
this.createPanel();
this.createButtons();
this.opacityDamper = new Damper();
this.goalOpacity = PANEL_CONFIG.opacity;
}
createPanel() {
const panelShape = this.createPanelShape();
const geometry = new ShapeGeometry(panelShape);
const material = new MeshBasicMaterial({
color: PANEL_CONFIG.color,
opacity: PANEL_CONFIG.opacity,
transparent: true
});
this.panelMesh = new Mesh(geometry, material);
this.panelMesh.name = 'MenuPanel';
this.add(this.panelMesh);
}
createButtons() {
// Create exit button
this.exitButton = this.createButton('close');
this.exitButton.name = 'ExitButton';
this.exitButton.position.set(BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
this.add(this.exitButton);
// Create toggle button
this.toggleButton = this.createButton('view-real-size');
this.toggleButton.name = 'ToggleButton';
this.toggleButton.position.set(-BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
this.add(this.toggleButton);
}
createPanelShape() {
const shape = new Shape();
const { width: w, height: h, cornerRadius: r } = PANEL_CONFIG;
// Create rounded rectangle path
shape.moveTo(-w / 2 + r, -h / 2);
shape.lineTo(w / 2 - r, -h / 2);
shape.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
shape.lineTo(w / 2, h / 2 - r);
shape.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
shape.lineTo(-w / 2 + r, h / 2);
shape.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
shape.lineTo(-w / 2, -h / 2 + r);
shape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
return shape;
}
preRenderIcons() {
const iconSvgs = [
{ key: 'close', svg: CLOSE_ICON_SVG },
{ key: 'view-real-size', svg: VIEW_REAL_SIZE_ICON_SVG },
{ key: 'replay', svg: REPLAY_ICON_SVG }
];
iconSvgs.forEach(({ key, svg }) => {
if (!XRMenuPanel.iconTextures.has(key)) {
this.createTextureFromSvg(svg, key);
}
});
}
createTextureFromSvg(svgContent, key) {
const canvas = document.createElement('canvas');
canvas.width = ICON_CONFIG.canvasSize;
canvas.height = ICON_CONFIG.canvasSize;
const ctx = canvas.getContext('2d');
// Create an image from SVG content
const img = new Image();
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, 0, 0, ICON_CONFIG.canvasSize, ICON_CONFIG.canvasSize);
const texture = new CanvasTexture(canvas);
texture.needsUpdate = true;
texture.minFilter = ICON_CONFIG.filter;
XRMenuPanel.iconTextures.set(key, texture);
URL.revokeObjectURL(url);
};
img.src = url;
}
createButton(iconKey) {
// Create a placeholder mesh
const material = new MeshBasicMaterial({ transparent: true });
const geometry = new PlaneGeometry(BUTTON_CONFIG.size, BUTTON_CONFIG.size);
const mesh = new Mesh(geometry, material);
// Try to get cached texture, or create a fallback
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
if (cachedTexture) {
mesh.material.map = cachedTexture;
mesh.material.needsUpdate = true;
}
else {
// RACE CONDITION FIX: Texture creation is async (img.onload), but button
// creation is sync This fallback handles the case where buttons are
// created before textures finish loading
this.createTextureFromSvg(iconKey === 'close' ? CLOSE_ICON_SVG :
iconKey === 'view-real-size' ? VIEW_REAL_SIZE_ICON_SVG :
REPLAY_ICON_SVG, iconKey);
// Polling mechanism: Wait for async texture creation to complete
// This prevents white squares from appearing on first load
const checkTexture = () => {
const texture = XRMenuPanel.iconTextures.get(iconKey);
if (texture) {
// Texture is ready - apply it to the mesh
mesh.material.map = texture;
mesh.material.needsUpdate = true;
}
else {
// Texture not ready yet - check again in 10ms
setTimeout(checkTexture, 10);
}
};
checkTexture();
}
return mesh;
}
exitButtonControllerIntersection(scene, controller) {
const hitResult = scene.hitFromController(controller, this.exitButton);
return hitResult;
}
scaleModeButtonControllerIntersection(scene, controller) {
const hitResult = scene.hitFromController(controller, this.toggleButton);
return hitResult;
}
handleScaleToggle(worldSpaceInitialPlacementDone, initialModelScale, minScale, maxScale) {
if (!worldSpaceInitialPlacementDone) {
return null;
}
this.isActualSize = !this.isActualSize;
// Toggle between view real size icon and replay icon
// When isActualSize is true, show replay icon (to reset)
// When isActualSize is false, show view real size icon (to go to actual
// size)
const iconKey = this.isActualSize ? 'replay' : 'view-real-size';
this.updateScaleModeButtonLabel(iconKey);
const targetScale = this.isActualSize ? 1.0 : initialModelScale;
const goalScale = Math.max(minScale, Math.min(maxScale, targetScale));
return goalScale;
}
updateScaleModeButtonLabel(iconKey) {
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
if (cachedTexture) {
this.toggleButton.material.map = cachedTexture;
this.toggleButton.material.needsUpdate = true;
}
}
updatePosition(camera, placementBox) {
if (!placementBox) {
return;
}
// Get the world position of the placement box
const placementBoxWorldPos = new Vector3();
placementBox.getWorldPosition(placementBoxWorldPos);
// Get the placement box size to calculate dynamic offsets
const placementBoxSize = placementBox.getSize();
const placementBoxMinDimension = Math.min(placementBoxSize.x, placementBoxSize.z);
// Calculate dynamic offsets based on placement box size
// Base offsets with placement box size scaling
const baseOffsetUp = -0.2;
const baseOffsetForward = 0.9;
const sizeScaleFactor = Math.max(0.5, Math.min(2.0, placementBoxMinDimension / 1.0)); // Scale between 0.5x and 2x
const offsetUp = baseOffsetUp * sizeScaleFactor;
const offsetForward = baseOffsetForward * sizeScaleFactor;
// Get direction from placement box to camera (horizontal only)
const directionToCamera = new Vector3().copy(camera.position).sub(placementBoxWorldPos);
directionToCamera.y = 0; // Zero out vertical component
directionToCamera.normalize();
// Calculate the final position
const panelPosition = new Vector3()
.copy(placementBoxWorldPos)
.add(new Vector3(0, offsetUp, 0)) // Move up
.add(directionToCamera.multiplyScalar(offsetForward)); // Move forward
this.position.copy(panelPosition);
// Calculate distance-based scaling
const distanceToCamera = camera.position.distanceTo(panelPosition);
const clampedDistance = Math.max(PANEL_CONFIG.minDistance, Math.min(PANEL_CONFIG.maxDistance, distanceToCamera));
const scaleFactor = PANEL_CONFIG.baseScale +
(clampedDistance - PANEL_CONFIG.minDistance) *
PANEL_CONFIG.distanceScaleFactor;
// Apply scaling to the entire panel (including buttons)
this.scale.set(scaleFactor, scaleFactor, scaleFactor);
// Make the menu panel face the camera
this.lookAt(camera.position);
}
/**
* Set the box's visibility; it will fade in and out.
*/
set show(visible) {
this.goalOpacity = visible ? PANEL_CONFIG.opacity : 0;
}
/**
* Call on each frame with the frame delta to fade the box.
*/
updateOpacity(delta) {
const material = this.panelMesh.material;
const currentOpacity = material.opacity;
const newOpacity = this.opacityDamper.update(currentOpacity, this.goalOpacity, delta, 1);
this.traverse((child) => {
if (child instanceof Mesh) {
const mat = child.material;
if (mat.transparent)
mat.opacity = newOpacity;
}
});
this.visible = newOpacity > 0;
}
dispose() {
var _a;
this.children.forEach(child => {
if (child instanceof Mesh) {
// Dispose geometry first
if (child.geometry) {
child.geometry.dispose();
}
// Handle material(s)
// Material can be a single Material or an array of Materials
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach(material => {
if (material) { // Ensure material exists before proceeding
// Dispose texture if it exists and is a CanvasTexture
// We specifically created CanvasTextures for buttons, so check for
// that type.
if ('map' in material &&
material.map instanceof
CanvasTexture) { // Check if 'map' property exists and is a
// CanvasTexture
material.map.dispose();
}
// Dispose material itself
material.dispose();
}
});
}
});
// Remove the panel itself from its parent in the scene graph
(_a = this.parent) === null || _a === void 0 ? void 0 : _a.remove(this);
}
}
// Cache for pre-rendered textures
XRMenuPanel.iconTextures = new Map();
//# sourceMappingURL=XRMenuPanel.js.map