diff --git a/packages/model-viewer/src/three-components/ARRenderer.ts b/packages/model-viewer/src/three-components/ARRenderer.ts index 3dacfb876b..4154298611 100644 --- a/packages/model-viewer/src/three-components/ARRenderer.ts +++ b/packages/model-viewer/src/three-components/ARRenderer.ts @@ -328,11 +328,18 @@ export class ARRenderer extends EventDispatcher< this.yawDamper.setDecayTime(DECAY); this.pitchDamper.setDecayTime(DECAY); this.rollDamper.setDecayTime(DECAY); + this.scaleDamper.setDecayTime(DECAY); } this.currentSession = currentSession; this.placementBox = new PlacementBox(scene, this.placeOnWall ? 'back' : 'bottom'); + + // Set screen space mode for proper positioning + if (this.placementBox) { + this.placementBox.setScreenSpaceMode(this.xrMode === XRMode.SCREEN_SPACE); + } + this.placementComplete = false; if (this.xrMode !== XRMode.SCREEN_SPACE) { @@ -561,9 +568,11 @@ export class ARRenderer extends EventDispatcher< onUpdateScene = () => { if (this.placementBox != null && this.isPresenting) { - this.placementBox!.dispose(); - this.placementBox = new PlacementBox( - this.presentedScene!, this.placeOnWall ? 'back' : 'bottom'); + // Update the existing placement box with new model dimensions instead of recreating + this.placementBox!.updateFromModelChanges(); + + // Ensure screen space mode is maintained + this.placementBox!.setScreenSpaceMode(this.xrMode === XRMode.SCREEN_SPACE); } if (this.xrMode !== XRMode.SCREEN_SPACE) { if (this.menuPanel) { @@ -1162,7 +1171,17 @@ export class ARRenderer extends EventDispatcher< } private updatePlacementBoxOpacity(box: PlacementBox, delta: number) { - box.updateOpacity(delta); + // Use the new enhanced update method that includes distance scaling and visual state + const camera = this.presentedScene!.getCamera(); + box.update(delta, camera.position); + + // Update interaction state based on hover + const over1 = this.hover(this.xrController1!); + const over2 = this.hover(this.xrController2!); + const isHovered = (over1 || over2) && !this.isTwoHandInteraction; + + // Set interaction state for visual feedback + box.setInteractionState(this.isTranslating || this.isRotating, isHovered); } private updateTwoHandInteractionState() { @@ -1186,7 +1205,13 @@ export class ARRenderer extends EventDispatcher< private updateXRControllerHover() { const over1 = this.hover(this.xrController1!); const over2 = this.hover(this.xrController2!); - this.placementBox!.show = (over1 || over2) && !this.isTwoHandInteraction; + const isHovered = (over1 || over2) && !this.isTwoHandInteraction; + + // Use the new interaction state system + if (this.placementBox) { + this.placementBox.setInteractionState(this.isTranslating || this.isRotating, isHovered); + this.placementBox.show = isHovered; + } } diff --git a/packages/model-viewer/src/three-components/PlacementBox.ts b/packages/model-viewer/src/three-components/PlacementBox.ts index d3a2c4cf25..81e9f6da5b 100644 --- a/packages/model-viewer/src/three-components/PlacementBox.ts +++ b/packages/model-viewer/src/three-components/PlacementBox.ts @@ -13,17 +13,56 @@ * limitations under the License. */ -import {BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, Material, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2, Vector3, XRTargetRaySpace} from 'three'; +import {BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, Material, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2, Vector3, XRTargetRaySpace, Color, AdditiveBlending, NormalBlending} from 'three'; import {Damper} from './Damper.js'; import {ModelScene} from './ModelScene.js'; import {Side} from './Shadow.js'; -const RADIUS = 0.2; -const LINE_WIDTH = 0.03; -const MAX_OPACITY = 0.75; -const SEGMENTS = 12; -const DELTA_PHI = Math.PI / (2 * SEGMENTS); +// 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 +} as const; const vector2 = new Vector2(); @@ -32,53 +71,136 @@ const vector2 = new Vector2(); * cornerY. */ const addCorner = - (vertices: Array, cornerX: number, cornerY: number) => { + (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 <= SEGMENTS; ++i) { + for (let i = 0; i <= CONFIG.SEGMENTS; ++i) { vertices.push( - cornerX + (RADIUS - LINE_WIDTH) * Math.cos(phi), - cornerY + (RADIUS - LINE_WIDTH) * Math.sin(phi), + cornerX + (radius - lineWidth) * Math.cos(phi), + cornerY + (radius - lineWidth) * Math.sin(phi), 0, - cornerX + RADIUS * Math.cos(phi), - cornerY + RADIUS * Math.sin(phi), + cornerX + radius * Math.cos(phi), + cornerY + radius * Math.sin(phi), 0); - phi += DELTA_PHI; + phi += CONFIG.DELTA_PHI; } }; /** - * This class is a set of two coincident planes. The first is just a cute box - * outline with rounded corners and damped opacity to indicate the floor extents - * of a scene. It is purposely larger than the scene's bounding box by RADIUS on - * all sides so that small scenes are still visible / selectable. Its center is - * actually carved out by vertices to ensure its fragment shader doesn't add - * much time. - * - * The child plane is a simple plane with the same extents for use in hit - * testing (translation is triggered when the touch hits the plane, rotation - * otherwise). + * 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; + 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; + + // Visual state + private isActive: boolean = false; + private isHovered: boolean = false; + private edgeMaterial!: MeshBasicMaterial; + private fillMaterial!: MeshBasicMaterial; + + // Performance optimization + 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 {size, boundingBox} = scene; - - const x = size.x / 2; - const y = (side === 'back' ? size.y : size.z) / 2; - addCorner(vertices, x, y); - addCorner(vertices, -x, y); - addCorner(vertices, -x, -y); - addCorner(vertices, x, -y); + + 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) { @@ -89,48 +211,174 @@ export class PlacementBox extends Mesh { geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3)); geometry.setIndex(triangles); + geometry.computeBoundingSphere(); + } - super(geometry); - - this.side = side; - const material = this.material as MeshBasicMaterial; - material.side = DoubleSide; - material.transparent = true; - material.opacity = 0; - this.goalOpacity = 0; - this.opacityDamper = new Damper(); - - this.hitPlane = - new Mesh(new PlaneGeometry(2 * (x + RADIUS), 2 * (y + RADIUS))); + 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); - // The box matches the dimensions of the plane (extra radius all around), - // but only the top is expanded by radius, not the bottom. this.hitBox = new Mesh(new BoxGeometry( - size.x + 2 * RADIUS, size.y + RADIUS, size.z + 2 * RADIUS)); + 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); - switch (side) { + // 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; + } + } - scene.target.add(this); - this.hitBox.position.y = (size.y + RADIUS) / 2 + boundingBox.min.y; - scene.target.add(this.hitBox); - this.offsetHeight = 0; + /** + * 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); } /** @@ -145,8 +393,7 @@ export class PlacementBox extends Mesh { return hitResult == null ? null : hitResult.position; } - getExpandedHit(scene: ModelScene, screenX: number, screenY: number): Vector3 - |null { + 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); @@ -186,7 +433,7 @@ export class PlacementBox extends Mesh { * Set the box's visibility; it will fade in and out. */ set show(visible: boolean) { - this.goalOpacity = visible ? MAX_OPACITY : 0; + this.goalOpacity = visible ? CONFIG.MAX_OPACITY : 0; } /** @@ -194,9 +441,65 @@ export class PlacementBox extends Mesh { */ updateOpacity(delta: number) { const material = this.material as MeshBasicMaterial; - material.opacity = - this.opacityDamper.update(material.opacity, this.goalOpacity, delta, 1); - this.visible = material.opacity > 0; + 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; + } + } + + // 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(); } /** @@ -209,7 +512,8 @@ export class PlacementBox extends Mesh { this.hitBox.geometry.dispose(); (this.hitBox.material as Material).dispose(); this.geometry.dispose(); - (this.material as Material).dispose(); + this.edgeMaterial.dispose(); + this.fillMaterial.dispose(); this.hitBox.removeFromParent(); this.removeFromParent(); }