Add plane detection snap
Enable automatic snapping of placed models to detected WebXR planes, + visualization and HUD controls for plane-based alignment. Added XRPlaneOverlay, enhanced XRHitTestPlacement with snapToPlanes support, integrated plane overlays in placement UI, and wired snap toggle in HUD. Also wired new snapToPlanes state and related callbacks in XRSession. X-Lovable-Edit-ID: edt-e3ef299f-6898-4442-844a-e6363dcfd236
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 }}
|
||||
>
|
||||
<group ref={reticleRef as any} visible={false}>
|
||||
<mesh onClick={handleSelect} pointerEventsType={{ deny: 'grab' as any }}>
|
||||
<ringGeometry args={[0.08, 0.1, 32]} />
|
||||
<meshBasicMaterial color="#22c55e" side={THREE.DoubleSide} transparent opacity={0.8} />
|
||||
<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