This commit is contained in:
gpt-engineer-app[bot]
2026-02-28 01:27:17 +00:00
parent 4638432d6e
commit 414317889c
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"
+150 -14
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 }}
>
<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;
}
+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>