Adicionado hit-test placement

Integrate XR hit-test for surface placement by adding XRHitTestPlacement component, replacing image-tracking anchor with hit-test in XR session, and wiring placement toggle in HUD to position models on real-world surfaces.

X-Lovable-Edit-ID: edt-45a570ae-a207-48dc-83cd-57fdd02386de
This commit is contained in:
gpt-engineer-app[bot]
2026-02-28 00:49:39 +00:00
3 changed files with 167 additions and 16 deletions
+16 -2
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import {
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
ClipboardCheck, X,
ClipboardCheck, X, Crosshair,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Slider } from '@/components/ui/slider';
@@ -17,9 +17,11 @@ const ROTATION_STEP = 0.1;
interface XRHudProps {
freeMove: boolean;
onToggleFreeMove: () => void;
placementMode?: boolean;
onTogglePlacement?: () => void;
}
export function XRHud({ freeMove, onToggleFreeMove }: XRHudProps) {
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement }: XRHudProps) {
const [expanded, setExpanded] = useState(false);
const [checklistOpen, setChecklistOpen] = useState(false);
@@ -147,6 +149,18 @@ export function XRHud({ freeMove, onToggleFreeMove }: XRHudProps) {
{expanded ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</Button>
{/* Hit-test placement toggle */}
{onTogglePlacement && (
<Button
variant={placementMode ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onTogglePlacement}
>
<Crosshair className="h-3.5 w-3.5" />
{placementMode ? 'Posicionar…' : 'Reposicionar'}
</Button>
)}
<Button
variant={freeMove ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
+133
View File
@@ -0,0 +1,133 @@
import { useRef, useState, useCallback, useEffect } from 'react';
import { useFrame } from '@react-three/fiber';
import { useXRHitTest, useXRRequestHitTest } 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;
}
/**
* Uses @react-three/xr useXRHitTest to show a reticle on real-world surfaces.
* Tap (select) to place the model at the reticle position.
*/
export function XRHitTestPlacement({ placementMode, onPlace, children }: 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 prevPlacementMode = useRef(placementMode);
const placedPosition = useRef(new THREE.Vector3(0, 0, -1.5));
const placedQuaternion = useRef(new THREE.Quaternion());
// Continuous hit-test from the viewer (camera center)
useXRHitTest(
placementMode && !placed
? (results, getWorldMatrix) => {
if (results.length === 0) {
setReticlePos(null);
return;
}
getWorldMatrix(matrixHelper, results[0]);
positionHelper.setFromMatrixPosition(matrixHelper);
quaternionHelper.setFromRotationMatrix(matrixHelper);
setReticlePos(positionHelper.clone());
setReticleQuat(quaternionHelper.clone());
}
: undefined,
'viewer'
);
// Request-based hit test for tap-to-place
const requestHitTest = useXRRequestHitTest();
const handleSelect = useCallback(async () => {
if (!placementMode) return;
// Use current reticle position if available
if (reticlePos) {
placedPosition.current.copy(reticlePos);
if (reticleQuat) placedQuaternion.current.copy(reticleQuat);
setPlaced(true);
onPlace(reticlePos.clone(), reticleQuat?.clone() ?? new THREE.Quaternion());
console.log('[HitTest] 📍 Modelo posicionado via reticle:', 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]);
// 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]);
return (
<>
{/* Reticle on detected surface */}
<mesh
ref={reticleRef}
visible={false}
onClick={handleSelect}
pointerEventsType={{ deny: 'grab' as any }}
>
<ringGeometry args={[0.08, 0.1, 32]} />
<meshBasicMaterial color="#22c55e" side={THREE.DoubleSide} transparent opacity={0.8} />
</mesh>
{/* 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>
)}
{/* Placed model or default position */}
<group
position={placed ? placedPosition.current : [0, 0, -1.5]}
quaternion={placed ? placedQuaternion.current : undefined}
>
{children}
</group>
</>
);
}
+18 -14
View File
@@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button';
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
import { XRHud } from '@/components/XRHud';
import { findNearestVertex } from '@/components/three/SmartMeasure';
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
// --- Diagnóstico XR ---
console.log('[XR] Inicializando store...');
@@ -307,23 +308,15 @@ function XRGrid() {
);
}
// ─── ImageTrackingAnchor ───────────────────────────────
function ImageTrackingAnchor({ children }: { children: React.ReactNode }) {
const groupRef = useRef<THREE.Group>(null);
useFrame(() => {
if (!groupRef.current) return;
groupRef.current.position.set(0, 0, -1.5);
groupRef.current.quaternion.identity();
});
return <group ref={groupRef}>{children}</group>;
}
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
// ─── XRSession Page ────────────────────────────────────
const XRSession = () => {
const navigate = useNavigate();
const { model, anchorMode, setAnchorMode } = useModelStore();
const [inXR, setInXR] = useState(false);
const [freeMove, setFreeMove] = useState(true); // default ON for easier positioning
const [freeMove, setFreeMove] = useState(true);
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
useEffect(() => {
if (!model) navigate('/');
@@ -415,9 +408,15 @@ const XRSession = () => {
<XROrigin />
<ImageTrackingAnchor>
<XRHitTestPlacement
placementMode={placementMode}
onPlace={() => {
setPlacementMode(false);
toast.success('Modelo posicionado na superfície!');
}}
>
<XRModel url={model.url} />
</ImageTrackingAnchor>
</XRHitTestPlacement>
<XRMeasurementOverlay />
<XRSnapHandler />
@@ -427,7 +426,12 @@ const XRSession = () => {
</Canvas>
{/* Floating HUD overlay with all controls */}
<XRHud freeMove={freeMove} onToggleFreeMove={() => setFreeMove(!freeMove)} />
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
/>
</div>
</div>
);