Changes
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import {
|
||||
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
||||
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
||||
ClipboardCheck, X, Crosshair,
|
||||
ClipboardCheck, X, Crosshair, Magnet,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
@@ -19,9 +19,11 @@ interface XRHudProps {
|
||||
onToggleFreeMove: () => void;
|
||||
placementMode?: boolean;
|
||||
onTogglePlacement?: () => void;
|
||||
snapToPlanes?: boolean;
|
||||
onToggleSnap?: () => void;
|
||||
}
|
||||
|
||||
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement }: XRHudProps) {
|
||||
export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTogglePlacement, snapToPlanes = true, onToggleSnap }: XRHudProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [checklistOpen, setChecklistOpen] = useState(false);
|
||||
|
||||
@@ -161,6 +163,19 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Snap to planes toggle */}
|
||||
{onToggleSnap && (
|
||||
<Button
|
||||
variant={snapToPlanes ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
onClick={onToggleSnap}
|
||||
title="Snap automático em planos detectados"
|
||||
>
|
||||
<Magnet className="h-3.5 w-3.5" />
|
||||
{snapToPlanes ? 'Snap ON' : 'Snap'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant={freeMove ? 'default' : 'outline'}
|
||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import { useFrame } from '@react-three/fiber';
|
||||
import { useXRHitTest, useXRRequestHitTest } from '@react-three/xr';
|
||||
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();
|
||||
@@ -11,34 +13,58 @@ 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 }: XRHitTestPlacementProps) {
|
||||
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'
|
||||
@@ -50,13 +76,13 @@ export function XRHitTestPlacement({ placementMode, onPlace, children }: XRHitTe
|
||||
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());
|
||||
const label = snappedPlaneLabel ? ` (${snappedPlaneLabel})` : '';
|
||||
console.log(`[HitTest] 📍 Modelo posicionado${label}:`, reticlePos.toArray());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,7 +98,7 @@ export function XRHitTestPlacement({ placementMode, onPlace, children }: XRHitTe
|
||||
setPlaced(true);
|
||||
onPlace(pos, quat);
|
||||
console.log('[HitTest] 📍 Modelo posicionado via requestHitTest:', pos.toArray());
|
||||
}, [placementMode, reticlePos, reticleQuat, onPlace, requestHitTest]);
|
||||
}, [placementMode, reticlePos, reticleQuat, onPlace, requestHitTest, snappedPlaneLabel]);
|
||||
|
||||
// Update reticle mesh position each frame
|
||||
useFrame(() => {
|
||||
@@ -95,18 +121,30 @@ export function XRHitTestPlacement({ placementMode, onPlace, children }: XRHitTe
|
||||
prevPlacementMode.current = placementMode;
|
||||
}, [placementMode]);
|
||||
|
||||
// Reticle color changes when snapped
|
||||
const reticleColor = snappedPlaneLabel ? '#3b82f6' : '#22c55e';
|
||||
|
||||
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>
|
||||
<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 && (
|
||||
@@ -121,6 +159,34 @@ export function XRHitTestPlacement({ placementMode, onPlace, children }: XRHitTe
|
||||
</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]}
|
||||
@@ -131,3 +197,73 @@ export function XRHitTestPlacement({ placementMode, onPlace, children }: XRHitTe
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useXRPlanes, XRPlaneModel } from '@react-three/xr';
|
||||
import { XRSpace } from '@react-three/xr';
|
||||
import * as THREE from 'three';
|
||||
|
||||
interface XRPlaneOverlayProps {
|
||||
showPlanes: boolean;
|
||||
highlightNearest?: THREE.Vector3 | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders semi-transparent overlays on all detected WebXR planes.
|
||||
* Walls are tinted blue, floors/ceilings green, others grey.
|
||||
*/
|
||||
export function XRPlaneOverlay({ showPlanes, highlightNearest }: XRPlaneOverlayProps) {
|
||||
const allPlanes = useXRPlanes();
|
||||
|
||||
if (!showPlanes || allPlanes.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{allPlanes.map((plane, i) => {
|
||||
const label = plane.semanticLabel ?? '';
|
||||
const isHorizontal = plane.orientation === 'horizontal';
|
||||
const isVertical = plane.orientation === 'vertical';
|
||||
|
||||
// Color by type
|
||||
let color = '#64748b'; // default grey
|
||||
if (label === 'floor' || label === 'table' || (isHorizontal && label !== 'ceiling')) {
|
||||
color = '#22c55e'; // green for floor/table/horizontal
|
||||
} else if (label === 'wall' || isVertical) {
|
||||
color = '#3b82f6'; // blue for walls
|
||||
} else if (label === 'ceiling') {
|
||||
color = '#a855f7'; // purple for ceiling
|
||||
}
|
||||
|
||||
return (
|
||||
<XRSpace key={i} space={plane.planeSpace}>
|
||||
<XRPlaneModel plane={plane}>
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
transparent
|
||||
opacity={0.15}
|
||||
side={THREE.DoubleSide}
|
||||
depthWrite={false}
|
||||
/>
|
||||
</XRPlaneModel>
|
||||
</XRSpace>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to find the nearest detected plane to a given point.
|
||||
* Returns the plane's center position and orientation quaternion.
|
||||
*/
|
||||
export function useNearestPlane(
|
||||
targetPoint: THREE.Vector3 | null,
|
||||
maxDistance = 0.5
|
||||
): { position: THREE.Vector3; quaternion: THREE.Quaternion; plane: XRPlane } | null {
|
||||
const allPlanes = useXRPlanes();
|
||||
|
||||
return useMemo(() => {
|
||||
if (!targetPoint || allPlanes.length === 0) return null;
|
||||
|
||||
let nearest: { position: THREE.Vector3; quaternion: THREE.Quaternion; plane: XRPlane } | null = null;
|
||||
let minDist = maxDistance;
|
||||
|
||||
for (const plane of allPlanes) {
|
||||
// Use the plane's polygon center as approximation
|
||||
const polygon = plane.polygon;
|
||||
if (!polygon || polygon.length === 0) continue;
|
||||
|
||||
const center = new THREE.Vector3();
|
||||
for (const pt of polygon) {
|
||||
center.add(new THREE.Vector3(pt.x, pt.y, pt.z));
|
||||
}
|
||||
center.divideScalar(polygon.length);
|
||||
|
||||
const dist = center.distanceTo(targetPoint);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
nearest = {
|
||||
position: center.clone(),
|
||||
quaternion: new THREE.Quaternion(),
|
||||
plane,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return nearest;
|
||||
}, [targetPoint, allPlanes, maxDistance]);
|
||||
}
|
||||
@@ -317,6 +317,7 @@ const XRSession = () => {
|
||||
const [inXR, setInXR] = useState(false);
|
||||
const [freeMove, setFreeMove] = useState(true);
|
||||
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
||||
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) navigate('/');
|
||||
@@ -410,6 +411,7 @@ const XRSession = () => {
|
||||
|
||||
<XRHitTestPlacement
|
||||
placementMode={placementMode}
|
||||
snapToPlanes={snapToPlanes}
|
||||
onPlace={() => {
|
||||
setPlacementMode(false);
|
||||
toast.success('Modelo posicionado na superfície!');
|
||||
@@ -431,6 +433,8 @@ const XRSession = () => {
|
||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||
placementMode={placementMode}
|
||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||
snapToPlanes={snapToPlanes}
|
||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user