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:
gpt-engineer-app[bot]
2026-02-28 01:27:18 +00:00
4 changed files with 266 additions and 16 deletions
+17 -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, 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"
+148 -12
View File
@@ -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;
}
+95
View File
@@ -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]);
}
+4
View File
@@ -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>