diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx
index fa2afab..3e86107 100644
--- a/src/components/three/XRHudInWorld.tsx
+++ b/src/components/three/XRHudInWorld.tsx
@@ -617,12 +617,9 @@ function GridFloorRow() {
const nudgeGridY = useModelStore((s) => s.nudgeGridY);
const gridCalibMode = useModelStore((s) => s.gridCalibMode);
const setGridCalibMode = useModelStore((s) => s.setGridCalibMode);
+ const gridLandingMode = useModelStore((s) => s.gridLandingMode);
+ const setGridLandingMode = useModelStore((s) => s.setGridLandingMode);
- const placeAtFloor = () => {
- // Headset eye Y minus avg standing eye height (1.6 m)
- const floor = camera.position.y - 1.6;
- setGridY(floor);
- };
return (
@@ -636,8 +633,8 @@ function GridFloorRow() {
onClick={() => nudgeGridY(-0.01)} />
nudgeGridY(0.01)} />
-
+ setGridLandingMode(!gridLandingMode)} />
setGridCalibMode(!gridCalibMode)} />
diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx
index 6e3bf45..592d3a4 100644
--- a/src/pages/XRSession.tsx
+++ b/src/pages/XRSession.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import { useGLTF, Grid, Html, Line, OrbitControls, Text } from '@react-three/drei';
-import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
+import { XR, createXRStore, useXR, XROrigin, useXRHitTest } from '@react-three/xr';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
@@ -304,6 +304,130 @@ function XRBackgroundModels() {
);
}
+/** Component that renders a laser from the camera (headset) to the grid landing target. */
+function XRGridLandingLaser({ reticlePos }: { reticlePos: THREE.Vector3 | null }) {
+ const { camera } = useThree();
+ const geom = useMemo(() => new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)]), []);
+ const mat = useMemo(() => new THREE.LineBasicMaterial({ color: '#eab308', transparent: true, opacity: 0.6 }), []);
+ const lineRef = useRef(null);
+
+ useFrame(() => {
+ if (lineRef.current && reticlePos) {
+ const posAttr = geom.attributes.position as THREE.BufferAttribute;
+ // Laser origin slightly below eyes
+ posAttr.setXYZ(0, camera.position.x, camera.position.y - 0.05, camera.position.z);
+ posAttr.setXYZ(1, reticlePos.x, reticlePos.y, reticlePos.z);
+ posAttr.needsUpdate = true;
+ geom.computeBoundingSphere();
+ lineRef.current.visible = true;
+ } else if (lineRef.current) {
+ lineRef.current.visible = false;
+ }
+ });
+
+ return ;
+}
+
+/** Handles physics surface hit-testing using WebXR for grid and active model landing. */
+function XRGridLandingHandler() {
+ const gridLandingMode = useModelStore((s) => s.gridLandingMode);
+ const gridY = useModelStore((s) => s.gridY);
+
+ const reticleRef = useRef(null);
+ const [reticlePos, setReticlePos] = useState(null);
+
+ // Continuous hit-test from viewer center
+ useXRHitTest(
+ gridLandingMode
+ ? (results, getWorldMatrix) => {
+ if (results.length === 0) {
+ setReticlePos(null);
+ return;
+ }
+ const matrixHelper = new THREE.Matrix4();
+ getWorldMatrix(matrixHelper, results[0]);
+ const positionHelper = new THREE.Vector3().setFromMatrixPosition(matrixHelper);
+ setReticlePos(positionHelper.clone());
+ }
+ : undefined,
+ 'viewer'
+ );
+
+ // Trata a confirmação (clique/gatilho no óculos ou controle)
+ const handleSelect = useCallback(() => {
+ if (!gridLandingMode || !reticlePos) return;
+
+ const targetY = reticlePos.y;
+ const deltaY = targetY - gridY;
+
+ // Desloca a peça ativa na mesma diferença (deltaY) para que ela "pouse junto"
+ const state = useModelStore.getState();
+ const activeModel = state.models.find(m => m.id === state.activeModelId);
+ if (activeModel) {
+ const ft = activeModel.fineTuning;
+ state.setFineTuning({
+ posY: ft.posY + deltaY,
+ });
+ }
+
+ // Atualiza o grid Y no store
+ useModelStore.setState({
+ gridY: targetY,
+ gridAutoFollow: false,
+ gridLandingMode: false,
+ showGrid: true,
+ });
+
+ toast.success("Grid e peça posicionados na superfície real!");
+ }, [gridLandingMode, reticlePos, gridY]);
+
+ // Atualiza posição da retícula a cada frame
+ useFrame(() => {
+ if (reticleRef.current) {
+ if (reticlePos && gridLandingMode) {
+ reticleRef.current.visible = true;
+ reticleRef.current.position.copy(reticlePos);
+ } else {
+ reticleRef.current.visible = false;
+ }
+ }
+ });
+
+ if (!gridLandingMode) return null;
+
+ return (
+ <>
+
+
+ {/* Retícula dourada premium */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Plano invisível de click no fundo */}
+
+
+
+
+ >
+ );
+}
+
/** Renders the currently-active model (the one wrapped by grab/placement). */
function XRActiveModel() {
const models = useModelStore((s) => s.models);
@@ -845,6 +969,7 @@ const XRSession = () => {
+
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts
index 6216d0c..37d1bf3 100644
--- a/src/stores/useModelStore.ts
+++ b/src/stores/useModelStore.ts
@@ -260,6 +260,9 @@ interface ModelStore {
/** When true, the next click on a model face sets grid Y to that point. */
gridCalibMode: boolean;
setGridCalibMode: (b: boolean) => void;
+ /** When true, the VR controller/headset hit-tests real world surfaces to place grid & models. */
+ gridLandingMode: boolean;
+ setGridLandingMode: (b: boolean) => void;
/** Section/clipping cut tool (X/Y/Z axis-aligned planes in world space). */
sectionEnabled: { x: boolean; y: boolean; z: boolean };
@@ -555,6 +558,8 @@ export const useModelStore = create((set, get) => ({
nudgeGridY: (delta) => set((s) => ({ gridY: s.gridY + delta, gridAutoFollow: false })),
gridCalibMode: false,
setGridCalibMode: (gridCalibMode) => set({ gridCalibMode }),
+ gridLandingMode: false,
+ setGridLandingMode: (gridLandingMode) => set({ gridLandingMode }),
sectionEnabled: { x: false, y: false, z: false },
sectionInvert: { x: false, y: false, z: false },