@google/model-viewer
Version:
Easily display interactive 3D models on the web and in AR!
429 lines • 17.8 kB
JavaScript
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { AdditiveBlending, BoxGeometry, BufferGeometry, Color, DoubleSide, Float32BufferAttribute, Mesh, MeshBasicMaterial, NormalBlending, PlaneGeometry, Vector2, Vector3 } from 'three';
import { Damper } from './Damper.js';
// Enhanced configuration for dynamic sizing and visual design
const CONFIG = {
// Dynamic sizing - slightly bigger
MIN_TOUCH_AREA: 0.05, // minimum touch area
BASE_RADIUS: 0.15, // base radius
LINE_WIDTH: 0.02, // line width
SEGMENTS: 16, // segments for smoother curves
DELTA_PHI: Math.PI / (2 * 16),
// Enhanced visual design with more vibrant colors
COLORS: {
EDGE_FALLOFF: new Color(0.98, 0.98, 0.98), // Brighter light gray
EDGE_CUTOFF: new Color(0.8, 0.8, 0.8), // Brighter medium gray
FILL_FALLOFF: new Color(0.4, 0.4, 0.4), // Brighter dark gray
FILL_CUTOFF: new Color(0.4, 0.4, 0.4), // Brighter dark gray
ACTIVE_EDGE: new Color(1.0, 1.0, 1.0), // Pure white when active
ACTIVE_FILL: new Color(0.6, 0.6, 0.6), // Brighter fill when active
},
// Opacity settings - now configurable
MAX_OPACITY: 0.75,
ACTIVE_OPACITY: 0.9,
FILL_OPACITY_MULTIPLIER: 0.5, // Fill opacity relative to edge opacity
INTERACTIVE_OPACITY_MULTIPLIER: 1.2, // Edge opacity multiplier when interactive
// Distance-based scaling (similar to Footprint)
MIN_DISTANCE: 0.5,
MAX_DISTANCE: 10.0,
BASE_SCALE: 1.0,
DISTANCE_SCALE_FACTOR: 0.3,
// Animation timing - optimized for performance
FADE_IN_DURATION: 0.12,
FADE_OUT_DURATION: 0.12,
SIZE_UPDATE_DURATION: 0.05,
COLOR_LERP_FACTOR: 0.15, // Color transition speed
// Screen space scaling - now configurable
SCREEN_SPACE_SCALE: 1.2, // Scale factor for screen space mode
// Performance optimization thresholds
SIZE_UPDATE_THRESHOLD: 0.001, // Minimum size change to trigger geometry update
GEOMETRY_UPDATE_DEBOUNCE: 100, // ms to debounce geometry updates
};
const vector2 = new Vector2();
/**
* Adds a quarter-annulus of vertices to the array, centered on cornerX,
* cornerY.
*/
const addCorner = (vertices, cornerX, cornerY, radius, lineWidth) => {
let phi = cornerX > 0 ? (cornerY > 0 ? 0 : -Math.PI / 2) :
(cornerY > 0 ? Math.PI / 2 : Math.PI);
for (let i = 0; i <= CONFIG.SEGMENTS; ++i) {
vertices.push(cornerX + (radius - lineWidth) * Math.cos(phi), cornerY + (radius - lineWidth) * Math.sin(phi), 0, cornerX + radius * Math.cos(phi), cornerY + radius * Math.sin(phi), 0);
phi += CONFIG.DELTA_PHI;
}
};
/**
* Enhanced PlacementBox that dynamically updates based on model size changes
* and features improved visual design inspired by Footprint.
*/
export class PlacementBox extends Mesh {
constructor(scene, side) {
const geometry = new BufferGeometry();
super(geometry);
this.shadowHeight = 0;
// Visual state
this.isActive = false;
this.isHovered = false;
// Performance optimization
this.lastGeometryUpdateTime = 0;
this.needsGeometryUpdate = false;
this.scene = scene;
this.side = side;
this.currentSize = new Vector3();
this.goalSize = new Vector3();
this.sizeDamper = new Damper();
// Initialize with current scene size
this.updateSizeFromScene();
// Create enhanced materials with better visual properties
this.edgeMaterial = new MeshBasicMaterial({
color: CONFIG.COLORS.EDGE_FALLOFF,
transparent: true,
opacity: 0,
side: DoubleSide,
depthWrite: false, // Better transparency handling
blending: AdditiveBlending // Subtle glow effect
});
this.fillMaterial = new MeshBasicMaterial({
color: CONFIG.COLORS.FILL_FALLOFF,
transparent: true,
opacity: 0,
side: DoubleSide,
depthWrite: false, // Better transparency handling
blending: NormalBlending
});
this.material = this.edgeMaterial;
this.goalOpacity = 0;
this.opacityDamper = new Damper();
// Create hit testing meshes
this.createHitMeshes();
// Position based on scene
this.updatePositionFromScene();
// Add to scene
scene.target.add(this);
scene.target.add(this.hitBox);
this.offsetHeight = 0;
}
updateSizeFromScene() {
const { size } = this.scene;
this.goalSize.copy(size);
// Apply proportional minimum size constraints
// For small models, use a smaller minimum size
const modelDiagonal = Math.sqrt(size.x * size.x + size.z * size.z);
const proportionalMinSize = Math.max(CONFIG.MIN_TOUCH_AREA, modelDiagonal * 0.4); // Increased from 0.3 to 0.4
// Only apply minimum size if the model is very small
if (this.goalSize.x < proportionalMinSize) {
this.goalSize.x = proportionalMinSize;
}
if (this.goalSize.z < proportionalMinSize) {
this.goalSize.z = proportionalMinSize;
}
// Update geometry with new size
this.updateGeometry();
}
updateGeometry() {
const geometry = this.geometry;
const triangles = [];
const vertices = [];
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
// Use dynamic radius based on size - slightly bigger for better visibility
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2); // Increased multipliers
const lineWidth = Math.max(CONFIG.LINE_WIDTH * 0.7, modelSize * 0.025); // Increased line width
addCorner(vertices, x, y, radius, lineWidth);
addCorner(vertices, -x, y, radius, lineWidth);
addCorner(vertices, -x, -y, radius, lineWidth);
addCorner(vertices, x, -y, radius, lineWidth);
const numVertices = vertices.length / 3;
for (let i = 0; i < numVertices - 2; i += 2) {
triangles.push(i, i + 1, i + 3, i, i + 3, i + 2);
}
const i = numVertices - 2;
triangles.push(i, i + 1, 1, i, 1, 0);
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
geometry.setIndex(triangles);
geometry.computeBoundingSphere();
}
createHitMeshes() {
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2);
this.hitPlane =
new Mesh(new PlaneGeometry(2 * (x + radius), 2 * (y + radius)));
this.hitPlane.visible = false;
this.hitPlane.material.side = DoubleSide;
this.add(this.hitPlane);
this.hitBox = new Mesh(new BoxGeometry(this.goalSize.x + 2 * radius, this.goalSize.y + radius, this.goalSize.z + 2 * radius));
this.hitBox.visible = false;
this.hitBox.material.side = DoubleSide;
this.add(this.hitBox);
}
updatePositionFromScene() {
const { boundingBox } = this.scene;
boundingBox.getCenter(this.position);
// Reset rotation to ensure proper orientation
this.rotation.set(0, 0, 0);
switch (this.side) {
case 'bottom':
// Ensure the placement box is horizontal for floor placement
this.rotateX(-Math.PI / 2);
this.shadowHeight = boundingBox.min.y;
this.position.y = this.shadowHeight;
break;
case 'back':
// For wall placement, keep it vertical but ensure proper orientation
this.shadowHeight = boundingBox.min.z;
this.position.z = this.shadowHeight;
break;
}
// Update hit box position with proper offset
if (this.hitBox) {
const offset = this.side === 'back' ?
(this.goalSize.y + CONFIG.BASE_RADIUS) / 2 :
(this.goalSize.y + CONFIG.BASE_RADIUS) / 2;
this.hitBox.position.y = offset + boundingBox.min.y;
}
}
/**
* Update the placement box when model size changes
* Optimized to batch updates and reduce performance impact
*/
updateFromModelChanges() {
this.updateSizeFromScene();
this.updatePositionFromScene();
// Force immediate geometry update for model changes
this.updateGeometry();
this.updateHitMeshes();
this.ensureProperOrientation();
// Reset performance tracking
this.needsGeometryUpdate = false;
this.lastGeometryUpdateTime = performance.now();
}
/**
* Ensure the placement box is properly oriented for the current mode
*/
ensureProperOrientation() {
// Force proper orientation based on side
if (this.side === 'bottom') {
// For floor placement, ensure it's horizontal
this.rotation.x = -Math.PI / 2;
this.rotation.y = 0;
this.rotation.z = 0;
}
else if (this.side === 'back') {
// For wall placement, ensure it's vertical
this.rotation.x = 0;
this.rotation.y = 0;
this.rotation.z = 0;
}
}
/**
* Set screen space mode to adjust positioning for mobile AR
*/
setScreenSpaceMode(isScreenSpace) {
if (isScreenSpace) {
// In screen space mode, ensure the placement box is more visible
// and properly positioned for touch interaction
this.scale.set(CONFIG.SCREEN_SPACE_SCALE, CONFIG.SCREEN_SPACE_SCALE, CONFIG.SCREEN_SPACE_SCALE);
}
else {
// Reset scale for world space mode
this.scale.set(1.0, 1.0, 1.0);
}
}
updateHitMeshes() {
if (this.hitPlane && this.hitBox) {
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2);
// Update hit plane geometry
const hitPlaneGeometry = new PlaneGeometry(2 * (x + radius), 2 * (y + radius));
this.hitPlane.geometry.dispose();
this.hitPlane.geometry = hitPlaneGeometry;
// Update hit box geometry
const hitBoxGeometry = new BoxGeometry(this.goalSize.x + 2 * radius, this.goalSize.y + radius, this.goalSize.z + 2 * radius);
this.hitBox.geometry.dispose();
this.hitBox.geometry = hitBoxGeometry;
}
}
/**
* Set interaction state for visual feedback
*/
setInteractionState(isActive, isHovered = false) {
this.isActive = isActive;
this.isHovered = isHovered;
this.updateVisualState();
}
updateVisualState() {
let targetColor;
let targetFillColor;
if (this.isActive) {
targetColor = CONFIG.COLORS.ACTIVE_EDGE;
targetFillColor = CONFIG.COLORS.ACTIVE_FILL;
}
else if (this.isHovered) {
targetColor = CONFIG.COLORS.EDGE_FALLOFF;
targetFillColor = CONFIG.COLORS.FILL_FALLOFF;
}
else {
targetColor = CONFIG.COLORS.EDGE_CUTOFF;
targetFillColor = CONFIG.COLORS.FILL_CUTOFF;
}
// Smoothly transition colors with configurable response speed
this.edgeMaterial.color.lerp(targetColor, CONFIG.COLOR_LERP_FACTOR);
this.fillMaterial.color.lerp(targetFillColor, CONFIG.COLOR_LERP_FACTOR);
}
/**
* Apply distance-based scaling
*/
applyDistanceScaling(cameraPosition) {
const distanceToCamera = cameraPosition.distanceTo(this.position);
const clampedDistance = Math.max(CONFIG.MIN_DISTANCE, Math.min(CONFIG.MAX_DISTANCE, distanceToCamera));
const scaleFactor = CONFIG.BASE_SCALE +
(clampedDistance - CONFIG.MIN_DISTANCE) * CONFIG.DISTANCE_SCALE_FACTOR;
this.scale.set(scaleFactor, scaleFactor, scaleFactor);
}
/**
* Get the world hit position if the touch coordinates hit the box, and null
* otherwise. Pass the scene in to get access to its raycaster.
*/
getHit(scene, screenX, screenY) {
vector2.set(screenX, -screenY);
this.hitPlane.visible = true;
const hitResult = scene.positionAndNormalFromPoint(vector2, this.hitPlane);
this.hitPlane.visible = false;
return hitResult == null ? null : hitResult.position;
}
getExpandedHit(scene, screenX, screenY) {
this.hitPlane.scale.set(1000, 1000, 1000);
this.hitPlane.updateMatrixWorld();
const hitResult = this.getHit(scene, screenX, screenY);
this.hitPlane.scale.set(1, 1, 1);
return hitResult;
}
controllerIntersection(scene, controller) {
this.hitBox.visible = true;
const hitResult = scene.hitFromController(controller, this.hitBox);
this.hitBox.visible = false;
return hitResult;
}
/**
* Offset the height of the box relative to the bottom of the scene. Positive
* is up, so generally only negative values are used.
*/
set offsetHeight(offset) {
offset -= 0.001; // push 1 mm below shadow to avoid z-fighting
if (this.side === 'back') {
this.position.z = this.shadowHeight + offset;
}
else {
this.position.y = this.shadowHeight + offset;
}
}
get offsetHeight() {
if (this.side === 'back') {
return this.position.z - this.shadowHeight;
}
else {
return this.position.y - this.shadowHeight;
}
}
/**
* Set the box's visibility; it will fade in and out.
*/
set show(visible) {
this.goalOpacity = visible ? CONFIG.MAX_OPACITY : 0;
}
/**
* Call on each frame with the frame delta to fade the box.
*/
updateOpacity(delta) {
const material = this.material;
const newOpacity = this.opacityDamper.update(material.opacity, this.goalOpacity, delta, 1);
// Update both edge and fill materials with configurable visibility
this.edgeMaterial.opacity = newOpacity;
this.fillMaterial.opacity = newOpacity * CONFIG.FILL_OPACITY_MULTIPLIER;
// Add subtle glow effect when active or hovered
if (this.isActive || this.isHovered) {
this.edgeMaterial.opacity =
newOpacity * CONFIG.INTERACTIVE_OPACITY_MULTIPLIER;
}
this.visible = newOpacity > 0;
}
/**
* Update method to be called each frame for smooth transitions
* Optimized to reduce frequent geometry updates for better performance
*/
update(delta, cameraPosition) {
// Update opacity
this.updateOpacity(delta);
// Update size transitions with performance optimization
if (!this.currentSize.equals(this.goalSize)) {
const newSize = new Vector3();
newSize.x =
this.sizeDamper.update(this.currentSize.x, this.goalSize.x, delta, 1);
newSize.y =
this.sizeDamper.update(this.currentSize.y, this.goalSize.y, delta, 1);
newSize.z =
this.sizeDamper.update(this.currentSize.z, this.goalSize.z, delta, 1);
// Check if size change is significant enough to warrant geometry update
const sizeChange = newSize.distanceTo(this.currentSize);
if (sizeChange > CONFIG.SIZE_UPDATE_THRESHOLD) {
this.currentSize.copy(newSize);
this.needsGeometryUpdate = true;
}
}
// Debounce geometry updates to prevent excessive updates
const now = performance.now();
if (this.needsGeometryUpdate &&
(now - this.lastGeometryUpdateTime) > CONFIG.GEOMETRY_UPDATE_DEBOUNCE) {
this.updateGeometry();
this.updateHitMeshes();
this.needsGeometryUpdate = false;
this.lastGeometryUpdateTime = now;
}
// Apply distance scaling if camera position is provided
if (cameraPosition) {
this.applyDistanceScaling(cameraPosition);
}
// Update visual state
this.updateVisualState();
}
/**
* Get the current size of the placement box
*/
getSize() {
return this.goalSize.clone();
}
/**
* Call this to clean up Three's cache when you remove the box.
*/
dispose() {
const { geometry, material } = this.hitPlane;
geometry.dispose();
material.dispose();
this.hitBox.geometry.dispose();
this.hitBox.material.dispose();
this.geometry.dispose();
this.edgeMaterial.dispose();
this.fillMaterial.dispose();
this.hitBox.removeFromParent();
this.removeFromParent();
}
}
//# sourceMappingURL=PlacementBox.js.map