Files
SteelXR2/src/components/three/XRHitTestPlacement.tsx
T
gpt-engineer-app[bot] 414317889c Changes
2026-02-28 01:27:17 +00:00

270 lines
9.9 KiB
TypeScript

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<THREE.Mesh>(null);
const [reticlePos, setReticlePos] = useState<THREE.Vector3 | null>(null);
const [reticleQuat, setReticleQuat] = useState<THREE.Quaternion | null>(null);
const [placed, setPlaced] = useState(false);
const [snappedPlaneLabel, setSnappedPlaneLabel] = useState<string | null>(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 */}
<group ref={reticleRef as any} visible={false}>
<mesh onClick={handleSelect} pointerEventsType={{ deny: 'grab' as any }}>
<ringGeometry args={[0.08, 0.1, 32]} />
<meshBasicMaterial color={reticleColor} side={THREE.DoubleSide} transparent opacity={0.8} />
</mesh>
{/* Inner crosshair */}
<mesh rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.005, 0.015, 16]} />
<meshBasicMaterial color={reticleColor} side={THREE.DoubleSide} transparent opacity={0.6} />
</mesh>
{/* Snap indicator text - shows plane type */}
{snappedPlaneLabel && placementMode && !placed && (
<mesh position={[0, 0.02, 0]}>
<planeGeometry args={[0.12, 0.03]} />
<meshBasicMaterial color={reticleColor} transparent opacity={0.6} />
</mesh>
)}
</group>
{/* Invisible tap plane to catch select events */}
{placementMode && !placed && (
<mesh
position={[0, 0, -2]}
onClick={handleSelect}
visible={false}
pointerEventsType={{ deny: 'grab' as any }}
>
<planeGeometry args={[10, 10]} />
<meshBasicMaterial transparent opacity={0} />
</mesh>
)}
{/* 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 (
<XRSpace key={i} space={plane.planeSpace}>
<XRPlaneModel plane={plane}>
<meshBasicMaterial
color={color}
transparent
opacity={0.12}
side={THREE.DoubleSide}
depthWrite={false}
/>
</XRPlaneModel>
</XRSpace>
);
})}
{/* Placed model or default position */}
<group
position={placed ? placedPosition.current : [0, 0, -1.5]}
quaternion={placed ? placedQuaternion.current : undefined}
>
{children}
</group>
</>
);
}
// ─── 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;
}