import { useRef, useState, useCallback, useEffect } from 'react'; import { useFrame } from '@react-three/fiber'; import { useXRHitTest, useXRRequestHitTest, useXRPlanes } from '@react-three/xr'; import { XRSpace } from '@react-three/xr'; import { XRPlaneModel } from '@react-three/xr'; import * as THREE from 'three'; const matrixHelper = new THREE.Matrix4(); const positionHelper = new THREE.Vector3(); const quaternionHelper = new THREE.Quaternion(); interface XRHitTestPlacementProps { placementMode: boolean; onPlace: (position: THREE.Vector3, quaternion: THREE.Quaternion) => void; children: React.ReactNode; snapToPlanes?: boolean; } /** * Uses @react-three/xr useXRHitTest to show a reticle on real-world surfaces. * When snapToPlanes is true, snaps to detected planes (floor, table, wall). * Tap (select) to place the model at the reticle position. */ export function XRHitTestPlacement({ placementMode, onPlace, children, snapToPlanes = true }: XRHitTestPlacementProps) { const reticleRef = useRef(null); const [reticlePos, setReticlePos] = useState(null); const [reticleQuat, setReticleQuat] = useState(null); const [placed, setPlaced] = useState(false); const [snappedPlaneLabel, setSnappedPlaneLabel] = useState(null); const prevPlacementMode = useRef(placementMode); const placedPosition = useRef(new THREE.Vector3(0, 0, -1.5)); const placedQuaternion = useRef(new THREE.Quaternion()); // Get detected planes for snap const allPlanes = useXRPlanes(); // Continuous hit-test from the viewer (camera center) useXRHitTest( placementMode && !placed ? (results, getWorldMatrix) => { if (results.length === 0) { setReticlePos(null); setSnappedPlaneLabel(null); return; } getWorldMatrix(matrixHelper, results[0]); positionHelper.setFromMatrixPosition(matrixHelper); quaternionHelper.setFromRotationMatrix(matrixHelper); // Snap to nearest detected plane if enabled if (snapToPlanes && allPlanes.length > 0) { const snapResult = findSnapPlane(allPlanes, positionHelper, 0.3); if (snapResult) { // Project hit point onto detected plane const projected = projectOntoPlane(positionHelper, snapResult); setReticlePos(projected); // Align quaternion with plane normal const alignedQuat = getPlaneAlignedQuaternion(snapResult); setReticleQuat(alignedQuat); setSnappedPlaneLabel(snapResult.semanticLabel ?? snapResult.orientation ?? 'surface'); return; } } setReticlePos(positionHelper.clone()); setReticleQuat(quaternionHelper.clone()); setSnappedPlaneLabel(null); } : undefined, 'viewer' ); // Request-based hit test for tap-to-place const requestHitTest = useXRRequestHitTest(); const handleSelect = useCallback(async () => { if (!placementMode) return; if (reticlePos) { placedPosition.current.copy(reticlePos); if (reticleQuat) placedQuaternion.current.copy(reticleQuat); setPlaced(true); onPlace(reticlePos.clone(), reticleQuat?.clone() ?? new THREE.Quaternion()); const label = snappedPlaneLabel ? ` (${snappedPlaneLabel})` : ''; console.log(`[HitTest] 📍 Modelo posicionado${label}:`, reticlePos.toArray()); return; } // Fallback: on-demand hit test if (!requestHitTest) return; const result = await requestHitTest('viewer', ['plane', 'mesh']); if (!result || result.results.length === 0) return; result.getWorldMatrix(matrixHelper, result.results[0]); const pos = new THREE.Vector3().setFromMatrixPosition(matrixHelper); const quat = new THREE.Quaternion().setFromRotationMatrix(matrixHelper); placedPosition.current.copy(pos); placedQuaternion.current.copy(quat); setPlaced(true); onPlace(pos, quat); console.log('[HitTest] 📍 Modelo posicionado via requestHitTest:', pos.toArray()); }, [placementMode, reticlePos, reticleQuat, onPlace, requestHitTest, snappedPlaneLabel]); // Update reticle mesh position each frame useFrame(() => { if (reticleRef.current) { if (reticlePos && placementMode && !placed) { reticleRef.current.visible = true; reticleRef.current.position.copy(reticlePos); if (reticleQuat) reticleRef.current.quaternion.copy(reticleQuat); } else { reticleRef.current.visible = false; } } }); // Reset placement when user re-enters placement mode useEffect(() => { if (placementMode && !prevPlacementMode.current) { setPlaced(false); } prevPlacementMode.current = placementMode; }, [placementMode]); // Reticle color changes when snapped const reticleColor = snappedPlaneLabel ? '#3b82f6' : '#22c55e'; return ( <> {/* Reticle on detected surface */} {/* Inner crosshair */} {/* Snap indicator text - shows plane type */} {snappedPlaneLabel && placementMode && !placed && ( )} {/* Invisible tap plane to catch select events */} {placementMode && !placed && ( )} {/* Show detected planes as semi-transparent overlays when in placement mode */} {placementMode && !placed && snapToPlanes && allPlanes.map((plane, i) => { const label = plane.semanticLabel ?? ''; const isVertical = plane.orientation === 'vertical'; let color = '#64748b'; if (label === 'floor' || label === 'table' || (!isVertical && label !== 'ceiling')) { color = '#22c55e'; } else if (label === 'wall' || isVertical) { color = '#3b82f6'; } else if (label === 'ceiling') { color = '#a855f7'; } return ( ); })} {/* Placed model or default position */} {children} ); } // ─── Helpers ──────────────────────────────────────────── /** Find the nearest plane to a point within maxDistance */ function findSnapPlane(planes: readonly XRPlane[], point: THREE.Vector3, maxDistance: number): XRPlane | null { let nearest: XRPlane | null = null; let minDist = maxDistance; for (const plane of planes) { const polygon = plane.polygon; if (!polygon || polygon.length === 0) continue; // Compute plane center from polygon let cx = 0, cy = 0, cz = 0; for (const pt of polygon) { cx += pt.x; cy += pt.y; cz += pt.z; } const len = polygon.length; const center = new THREE.Vector3(cx / len, cy / len, cz / len); const dist = center.distanceTo(point); if (dist < minDist) { minDist = dist; nearest = plane; } } return nearest; } /** Project a point onto a plane defined by its polygon */ function projectOntoPlane(point: THREE.Vector3, plane: XRPlane): THREE.Vector3 { const polygon = plane.polygon; if (!polygon || polygon.length < 3) return point.clone(); // Get plane normal from first 3 vertices const p0 = new THREE.Vector3(polygon[0].x, polygon[0].y, polygon[0].z); const p1 = new THREE.Vector3(polygon[1].x, polygon[1].y, polygon[1].z); const p2 = new THREE.Vector3(polygon[2].x, polygon[2].y, polygon[2].z); const normal = new THREE.Vector3().crossVectors( new THREE.Vector3().subVectors(p1, p0), new THREE.Vector3().subVectors(p2, p0) ).normalize(); // Project point onto plane const toPoint = new THREE.Vector3().subVectors(point, p0); const dist = toPoint.dot(normal); return point.clone().sub(normal.clone().multiplyScalar(dist)); } /** Get a quaternion that aligns with the plane's normal direction */ function getPlaneAlignedQuaternion(plane: XRPlane): THREE.Quaternion { const polygon = plane.polygon; if (!polygon || polygon.length < 3) return new THREE.Quaternion(); const p0 = new THREE.Vector3(polygon[0].x, polygon[0].y, polygon[0].z); const p1 = new THREE.Vector3(polygon[1].x, polygon[1].y, polygon[1].z); const p2 = new THREE.Vector3(polygon[2].x, polygon[2].y, polygon[2].z); const normal = new THREE.Vector3().crossVectors( new THREE.Vector3().subVectors(p1, p0), new THREE.Vector3().subVectors(p2, p0) ).normalize(); // Create rotation from up vector to plane normal const quat = new THREE.Quaternion(); quat.setFromUnitVectors(new THREE.Vector3(0, 1, 0), normal); return quat; }