diff --git a/packages/model-viewer/src/three-components/PlacementBox.ts b/packages/model-viewer/src/three-components/PlacementBox.ts index 81e9f6da5b..cf78bb463f 100644 --- a/packages/model-viewer/src/three-components/PlacementBox.ts +++ b/packages/model-viewer/src/three-components/PlacementBox.ts @@ -1,17 +1,17 @@ /* @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. - */ +* 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 {BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, Material, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2, Vector3, XRTargetRaySpace, Color, AdditiveBlending, NormalBlending} from 'three'; @@ -67,454 +67,463 @@ const CONFIG = { const vector2 = new Vector2(); /** - * Adds a quarter-annulus of vertices to the array, centered on cornerX, - * cornerY. - */ +* Adds a quarter-annulus of vertices to the array, centered on cornerX, +* cornerY. +*/ const addCorner = - (vertices: Array, cornerX: number, cornerY: number, radius: number, lineWidth: number) => { - 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; - } - }; + (vertices: Array, cornerX: number, cornerY: number, radius: number, lineWidth: number) => { + 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. - */ +* Enhanced PlacementBox that dynamically updates based on model size changes +* and features improved visual design inspired by Footprint. +*/ export class PlacementBox extends Mesh { - private hitPlane!: Mesh; - private hitBox!: Mesh; - private shadowHeight: number = 0; - private side: Side; - private goalOpacity: number; - private opacityDamper: Damper; - + private hitPlane!: Mesh; + private hitBox!: Mesh; + private shadowHeight: number = 0; + private side: Side; + private goalOpacity: number; + private opacityDamper: Damper; + // Dynamic sizing properties - private currentSize: Vector3; - private goalSize: Vector3; - private sizeDamper: Damper; - private scene: ModelScene; - + private currentSize: Vector3; + private goalSize: Vector3; + private sizeDamper: Damper; + private scene: ModelScene; + // Visual state - private isActive: boolean = false; - private isHovered: boolean = false; - private edgeMaterial!: MeshBasicMaterial; - private fillMaterial!: MeshBasicMaterial; - + private isActive: boolean = false; + private isHovered: boolean = false; + private edgeMaterial!: MeshBasicMaterial; + private fillMaterial!: MeshBasicMaterial; + // Performance optimization - private lastGeometryUpdateTime: number = 0; - private needsGeometryUpdate: boolean = false; + private lastGeometryUpdateTime: number = 0; + private needsGeometryUpdate: boolean = false; - constructor(scene: ModelScene, side: Side) { - const geometry = new BufferGeometry(); - - super(geometry); - - 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; - } - - private updateSizeFromScene(): void { - 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(); - } - - private updateGeometry(): void { - const geometry = this.geometry as BufferGeometry; - const triangles: Array = []; - const vertices: Array = []; - - 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(); - } - - private createHitMeshes(): void { - 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 as 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 as Material).side = DoubleSide; - this.add(this.hitBox); - } - - private updatePositionFromScene(): void { - 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(): void { - 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 - */ - private ensureProperOrientation(): void { - // 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: boolean): void { - 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); - } - } - - private updateHitMeshes(): void { - 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: boolean, isHovered: boolean = false): void { - this.isActive = isActive; - this.isHovered = isHovered; - this.updateVisualState(); - } - - private updateVisualState(): void { - let targetColor: Color; - let targetFillColor: Color; - - 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: Vector3): void { - 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: ModelScene, screenX: number, screenY: number): Vector3|null { - 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: ModelScene, screenX: number, screenY: number): Vector3|null { - 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: ModelScene, controller: XRTargetRaySpace) { - 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: number) { - 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(): number { - 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: boolean) { - this.goalOpacity = visible ? CONFIG.MAX_OPACITY : 0; - } - - /** - * Call on each frame with the frame delta to fade the box. - */ - updateOpacity(delta: number) { - const material = this.material as MeshBasicMaterial; - 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: number, cameraPosition?: Vector3): void { - // 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; - } - } + constructor(scene: ModelScene, side: Side) { + const geometry = new BufferGeometry(); + + super(geometry); + + 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; + } + + private updateSizeFromScene(): void { + 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(); + } + + private updateGeometry(): void { + const geometry = this.geometry as BufferGeometry; + const triangles: Array = []; + const vertices: Array = []; + + 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(); + } + + private createHitMeshes(): void { + 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 as 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 as Material).side = DoubleSide; + this.add(this.hitBox); + } + + private updatePositionFromScene(): void { + 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(): void { + 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 + */ + private ensureProperOrientation(): void { + // 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: boolean): void { + 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); + } + } + + private updateHitMeshes(): void { + 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); - // 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; - } + // Update hit plane geometry + const hitPlaneGeometry = new PlaneGeometry(2 * (x + radius), 2 * (y + radius)); + this.hitPlane.geometry.dispose(); + this.hitPlane.geometry = hitPlaneGeometry; - // Apply distance scaling if camera position is provided - if (cameraPosition) { - this.applyDistanceScaling(cameraPosition); - } + // 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: boolean, isHovered: boolean = false): void { + this.isActive = isActive; + this.isHovered = isHovered; + this.updateVisualState(); + } + + private updateVisualState(): void { + let targetColor: Color; + let targetFillColor: Color; + + 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: Vector3): void { + 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: ModelScene, screenX: number, screenY: number): Vector3|null { + 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: ModelScene, screenX: number, screenY: number): Vector3|null { + 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: ModelScene, controller: XRTargetRaySpace) { + 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: number) { + 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(): number { + 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: boolean) { + this.goalOpacity = visible ? CONFIG.MAX_OPACITY : 0; + } + + /** + * Call on each frame with the frame delta to fade the box. + */ + updateOpacity(delta: number) { + const material = this.material as MeshBasicMaterial; + 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: number, cameraPosition?: Vector3): void { + // 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); - // Update visual state - this.updateVisualState(); - } - - /** - * Call this to clean up Three's cache when you remove the box. - */ - dispose() { - const {geometry, material} = this.hitPlane; - geometry.dispose(); - (material as Material).dispose(); - this.hitBox.geometry.dispose(); - (this.hitBox.material as Material).dispose(); - this.geometry.dispose(); - this.edgeMaterial.dispose(); - this.fillMaterial.dispose(); - this.hitBox.removeFromParent(); - this.removeFromParent(); - } + // 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(): Vector3 { + 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 as Material).dispose(); + this.hitBox.geometry.dispose(); + (this.hitBox.material as Material).dispose(); + this.geometry.dispose(); + this.edgeMaterial.dispose(); + this.fillMaterial.dispose(); + this.hitBox.removeFromParent(); + this.removeFromParent(); + } } \ No newline at end of file diff --git a/packages/model-viewer/src/three-components/XRMenuPanel.ts b/packages/model-viewer/src/three-components/XRMenuPanel.ts index 18d7f2bafd..40f4d02c92 100644 --- a/packages/model-viewer/src/three-components/XRMenuPanel.ts +++ b/packages/model-viewer/src/three-components/XRMenuPanel.ts @@ -4,22 +4,22 @@ import {ModelScene} from './ModelScene.js'; import { PlacementBox } from './PlacementBox.js'; // SVG strings for the icons are defined here to avoid io and better performance for xr. const CLOSE_ICON_SVG = ` - + `; const VIEW_REAL_SIZE_ICON_SVG = ` - + `; const REPLAY_ICON_SVG = ` - - - - - - - - + + + + + + + + `; // Panel configuration @@ -61,14 +61,14 @@ export class XRMenuPanel extends Object3D { private static readonly iconTextures = new Map(); constructor() { - super(); + super(); // Pre-render all icons this.preRenderIcons(); this.createPanel(); this.createButtons(); - + this.opacityDamper = new Damper(); this.goalOpacity = PANEL_CONFIG.opacity; } @@ -80,10 +80,10 @@ export class XRMenuPanel extends Object3D { color: PANEL_CONFIG.color, opacity: PANEL_CONFIG.opacity, transparent: true - }); + }); this.panelMesh = new Mesh(geometry, material); this.panelMesh.name = 'MenuPanel'; - this.add(this.panelMesh); + this.add(this.panelMesh); } private createButtons(): void { @@ -116,7 +116,7 @@ export class XRMenuPanel extends Object3D { shape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2); return shape; - } + } private preRenderIcons(): void { const iconSvgs = [ @@ -156,7 +156,7 @@ export class XRMenuPanel extends Object3D { img.src = url; } - createButton(iconKey: string): Mesh { + createButton(iconKey: string): Mesh { // Create a placeholder mesh const material = new MeshBasicMaterial({ transparent: true }); const geometry = new PlaneGeometry(BUTTON_CONFIG.size, BUTTON_CONFIG.size); @@ -170,37 +170,37 @@ export class XRMenuPanel extends Object3D { } 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 : + 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 as MeshBasicMaterial).map = texture; - (mesh.material as MeshBasicMaterial).needsUpdate = true; - } else { - // Texture not ready yet - check again in 10ms - setTimeout(checkTexture, 10); - } - }; - checkTexture(); - } - - return mesh; - } - + const texture = XRMenuPanel.iconTextures.get(iconKey); + if (texture) { + // Texture is ready - apply it to the mesh + (mesh.material as MeshBasicMaterial).map = texture; + (mesh.material as MeshBasicMaterial).needsUpdate = true; + } else { + // Texture not ready yet - check again in 10ms + setTimeout(checkTexture, 10); + } + }; + checkTexture(); + } + + return mesh; + } + exitButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) { - const hitResult = scene.hitFromController(controller, this.exitButton); - return hitResult; + const hitResult = scene.hitFromController(controller, this.exitButton); + return hitResult; } scaleModeButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) { - const hitResult = scene.hitFromController(controller, this.toggleButton); - return hitResult; + const hitResult = scene.hitFromController(controller, this.toggleButton); + return hitResult; } handleScaleToggle( @@ -233,29 +233,40 @@ export class XRMenuPanel extends Object3D { (this.toggleButton.material as MeshBasicMaterial).needsUpdate = true; } } - + updatePosition(camera: Camera, placementBox: PlacementBox) { if (!placementBox) { return; - } + } // Get the world position of the placement box const placementBoxWorldPos = new Vector3(); - placementBox.getWorldPosition(placementBoxWorldPos); - // Calculate a position slightly in front of the placement box - const offsetUp = -0.2; // Offset upward from the placement box - const offsetForward = 0.9; // Offset forward from the placement box + 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(); + 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); + .add(directionToCamera.multiplyScalar(offsetForward)); // Move forward + this.position.copy(panelPosition); // Calculate distance-based scaling const distanceToCamera = camera.position.distanceTo(panelPosition); @@ -317,9 +328,8 @@ export class XRMenuPanel extends Object3D { }); } }); - + // Remove the panel itself from its parent in the scene graph this.parent?.remove(this); } } - \ No newline at end of file