🚀 Auto-deploy: melhoria no snap e medição AR em 29/05/2026 20:32:41

This commit is contained in:
2026-05-29 20:32:41 +00:00
parent 1e4bd78128
commit 0f017d0e4b
4 changed files with 391 additions and 29 deletions
+88 -10
View File
@@ -11,6 +11,8 @@ import {
detectCircularEdgeAtPoint3D,
findNearestEdgeSegment3D
} from './SmartMeasure';
import { xrCalibration, pushXRRealPoint } from './xrCalibrationBus';
import { toast } from 'sonner';
const TRIG_ON = 0.7;
const TRIG_OFF = 0.3;
@@ -80,12 +82,31 @@ export function XRControllerMeasure() {
// running them every frame at 72fps can stall the Quest right after
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
const hitTestSourceRef = useRef<any>(null);
const hitTestRequestedRef = useRef<boolean>(false);
useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode;
const selectionMode = useModelStore.getState().selectionMode;
// Alinhamento Virt/Real ativo
const isAligning = xrCalibration.alignType === 'virt-real' &&
(xrCalibration.step === 'await-real-1' ||
xrCalibration.step === 'await-virtual-1' ||
xrCalibration.step === 'await-real-2' ||
xrCalibration.step === 'await-virtual-2' ||
xrCalibration.step === 'await-real-3' ||
xrCalibration.step === 'await-virtual-3');
if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false;
if ((!measureMode && !selectionMode) || !frame) return;
if ((!measureMode && !selectionMode && !isAligning) || !frame) {
if (hitTestRequestedRef.current) {
hitTestSourceRef.current = null;
hitTestRequestedRef.current = false;
}
return;
}
const session = frame.session;
const refSpace = gl.xr.getReferenceSpace();
@@ -126,6 +147,43 @@ export function XRControllerMeasure() {
raycaster.current.set(tmpOrigin.current, tmpDir.current);
raycaster.current.far = MAX_RAY;
// Hit test do mundo real para alinhamento
const isRealStep = xrCalibration.alignType === 'virt-real' &&
(xrCalibration.step === 'await-real-1' ||
xrCalibration.step === 'await-real-2' ||
xrCalibration.step === 'await-real-3');
if (session && right && !hitTestRequestedRef.current && isAligning) {
hitTestRequestedRef.current = true;
session.requestHitTestSource({ space: right.targetRaySpace })
.then((source) => {
hitTestSourceRef.current = source;
})
.catch((err) => {
console.error('[XR] Failed to request controller hit test source:', err);
hitTestRequestedRef.current = false;
});
}
let realHitPoint: THREE.Vector3 | null = null;
if (isRealStep) {
if (hitTestSourceRef.current && frame) {
const results = frame.getHitTestResults(hitTestSourceRef.current);
if (results.length > 0) {
const poseResult = results[0].getPose(refSpace);
if (poseResult) {
realHitPoint = new THREE.Vector3().setFromMatrixPosition(
new THREE.Matrix4().fromArray(poseResult.transform.matrix)
);
}
}
}
if (!realHitPoint) {
// Fallback: 1.5 metros na direção do controle
realHitPoint = tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(1.5));
}
}
let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; modelId?: string; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
@@ -240,27 +298,32 @@ export function XRControllerMeasure() {
// ── Update laser visual ───────────────────────────────────────────
if (laserRef.current && tipRef.current) {
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
const end = isRealStep
? realHitPoint
: (snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY)));
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
positions.setXYZ(1, end.x, end.y, end.z);
positions.needsUpdate = true;
laserGeom.current.computeBoundingSphere();
const color = selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308');
const color = isAligning
? '#eab308' // dourado para alinhamento Virt/Real
: (selectionMode ? '#a855f7' : (snapKind === 'hole' ? '#f59e0b' : snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308'));
tipColor.current.set(color);
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
laserRef.current.visible = true;
if (snappedPoint) {
const tipPos = isRealStep ? realHitPoint : snappedPoint;
if (tipPos) {
tipRef.current.visible = true;
tipRef.current.position.copy(snappedPoint);
// Tip scales with distance from controller. Snap "forte" (vértice,
// aresta, furo) recebe um marcador ~3× maior do que uma superfície
// genérica, para que o ponto destacado fique bem visível.
const dist = tmpOrigin.current.distanceTo(snappedPoint);
const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
tipRef.current.position.copy(tipPos);
const dist = tmpOrigin.current.distanceTo(tipPos);
const strongSnap = isAligning || snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole';
const factor = strongSnap ? 0.030 : 0.012;
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
tipRef.current.scale.setScalar(s);
@@ -274,6 +337,21 @@ export function XRControllerMeasure() {
if (!trigState.current && trigVal > TRIG_ON) {
trigState.current = true;
const st = useModelStore.getState();
if (isRealStep) {
if (realHitPoint) {
pushXRRealPoint(realHitPoint);
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
toast.success(`Ponto real ${stepNum} registrado!`);
}
return;
}
if (isAligning) {
// Ignora o trigger físico para medições quando no modo virtual pois o clique do modelo é capturado pelo R3F onClick.
return;
}
if (st.selectionMode) {
// Fresh raycast on the press itself (not every frame) for selection
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
+97 -17
View File
@@ -904,10 +904,16 @@ function CalibrationTab() {
const isCalibrating = xrCalibration.step !== 'idle' && xrCalibration.step !== 'done';
const isDone = xrCalibration.step === 'done';
const progress = isDone ? 1 : xrCalibration.progress;
const onClickCalibrar = () => {
if (isCalibrating || isDone) {
const isCubeMode = xrCalibration.alignType === 'cube';
const isVirtRealMode = xrCalibration.alignType === 'virt-real';
const onClickCube = () => {
if (isCalibrating && isCubeMode) {
cancelXRCalibration();
return;
}
if (isDone && isCubeMode) {
cancelXRCalibration();
return;
}
@@ -919,7 +925,27 @@ function CalibrationTab() {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id);
startXRCalibration(active.id, 'cube');
};
const onClickVirtReal = () => {
if (isCalibrating && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (isDone && isVirtRealMode) {
cancelXRCalibration();
return;
}
if (!active) {
toast.error('Selecione uma peça antes de calibrar');
return;
}
if (active.locked) {
toast.error('Peça travada — destranque a peça para calibrar');
return;
}
startXRCalibration(active.id, 'virt-real');
};
const onResetCalibration = () => {
@@ -935,10 +961,54 @@ function CalibrationTab() {
'await-model-2': '2/3 - Clique na face correspondente da peça',
'await-cube-3': '3/3 (Verificar) - Clique em uma 3ª face do Cubo (opcional)',
'await-model-3': '3/3 - Clique na face correspondente da peça',
'await-real-1': '1/3 Real - Clique na 1ª superfície da peça REAL',
'await-virtual-1': '1/3 Virtual - Clique na 1ª correspondente da peça VIRTUAL',
'await-real-2': '2/3 Real - Clique na 2ª superfície da peça REAL',
'await-virtual-2': '2/3 Virtual - Clique na 2ª correspondente da peça VIRTUAL',
'await-real-3': '3/3 Real - Clique na 3ª superfície da peça REAL',
'await-virtual-3': '3/3 Virtual - Clique na 3ª correspondente da peça VIRTUAL',
'done': 'Calibração concluída com sucesso!',
};
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Clique em "Iniciar Calibrar" para alinhar o modelo';
const currentHint = STEP_HINTS_AR[xrCalibration.step] ?? 'Selecione "Cubo" ou "Virt/Real" para calibrar o modelo';
// Configurações dinâmicas de estados de botão
let cubeLabel = 'Cubo';
let cubeActive = false;
let cubeColor = '#3b82f6';
let vrLabel = 'Virt/Real';
let vrActive = false;
let vrColor = '#3b82f6';
if (isCalibrating) {
if (isCubeMode) {
cubeLabel = 'Cancelar';
cubeActive = true;
cubeColor = '#d97706';
} else {
cubeColor = '#475569';
}
if (isVirtRealMode) {
vrLabel = 'Cancelar';
vrActive = true;
vrColor = '#d97706';
} else {
vrColor = '#475569';
}
} else if (isDone) {
if (isCubeMode) {
cubeLabel = 'Concluído';
cubeActive = true;
cubeColor = '#22c55e';
}
if (isVirtRealMode) {
vrLabel = 'Concluído';
vrActive = true;
vrColor = '#22c55e';
}
}
return (
<group>
@@ -948,7 +1018,7 @@ function CalibrationTab() {
</Text>
<Text position={[-0.24, 0.07, 0]} fontSize={0.008} color="#cbd5e1" anchorX="left" anchorY="top" maxWidth={0.28}>
Alinhe os eixos intrínsecos do modelo 3D com a estrutura física correspondente no ambiente.
Alinhe o modelo virtual 3D com a peça real usando o Cubo ou clique em 3 pontos de controle ("Virt/Real").
</Text>
{/* Caixa de status do passo atual */}
@@ -957,10 +1027,10 @@ function CalibrationTab() {
<planeGeometry args={[0.26, 0.05]} />
<meshBasicMaterial color="#111827" transparent opacity={0.65} />
</mesh>
<Text position={[0.01, 0, 0.001]} fontSize={0.0085} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
<Text position={[0.01, 0, 0.001]} fontSize={0.008} color={isDone ? '#22c55e' : isCalibrating ? '#f59e0b' : '#ffffff'} anchorX="left" anchorY="middle" maxWidth={0.24}>
{currentHint}
</Text>
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && (
{isDone && Number.isFinite(xrCalibration.verifyErrorDeg) && isCubeMode && (
<Text position={[0.01, -0.016, 0.001]} fontSize={0.007} color="#22c55e" anchorX="left" anchorY="middle">
Erro residual calculado: {xrCalibration.verifyErrorDeg.toFixed(1)}°
</Text>
@@ -970,20 +1040,30 @@ function CalibrationTab() {
{/* Botões na parte inferior esquerda */}
<group position={[-0.24, -0.06, 0]}>
<XR3DButton
position={[0.06, 0, 0]}
size={[0.11, 0.026]}
label={isDone ? 'Concluído' : isCalibrating ? 'Cancelar' : 'Iniciar Calibrar'}
active={isCalibrating || isDone}
color={isDone ? '#22c55e' : isCalibrating ? '#d97706' : '#3b82f6'}
onClick={onClickCalibrar}
position={[0.045, 0, 0]}
size={[0.08, 0.026]}
label={cubeLabel}
active={cubeActive}
color={cubeColor}
onClick={onClickCube}
fontSize={0.008}
/>
<XR3DButton
position={[0.135, 0, 0]}
size={[0.08, 0.026]}
label={vrLabel}
active={vrActive}
color={vrColor}
onClick={onClickVirtReal}
fontSize={0.008}
/>
{active?.calibrationQuat && !isCalibrating && (
<XR3DButton
position={[0.18, 0, 0]}
size={[0.09, 0.026]}
label="Remover Cal."
position={[0.225, 0, 0]}
size={[0.08, 0.026]}
label="Limpar"
color="#dc2626"
onClick={onResetCalibration}
fontSize={0.008}
+110 -2
View File
@@ -9,6 +9,12 @@ export type XRCalibrationStep =
| 'await-model-2'
| 'await-cube-3' // opcional verificação
| 'await-model-3'
| 'await-real-1'
| 'await-virtual-1'
| 'await-real-2'
| 'await-virtual-2'
| 'await-real-3'
| 'await-virtual-3'
| 'done';
interface XRPair {
@@ -19,7 +25,10 @@ interface XRPair {
interface XRCalState {
step: XRCalibrationStep;
modelId: string | null;
alignType: 'cube' | 'virt-real' | null;
pairs: XRPair[];
realPoints: THREE.Vector3[];
virtualPoints: THREE.Vector3[];
pendingCube: THREE.Vector3 | null;
progress: number;
verifyErrorDeg: number;
@@ -29,7 +38,10 @@ interface XRCalState {
export const xrCalibration: XRCalState = {
step: 'idle',
modelId: null,
alignType: null,
pairs: [],
realPoints: [],
virtualPoints: [],
pendingCube: null,
progress: 0,
verifyErrorDeg: NaN,
@@ -47,20 +59,31 @@ export function subscribeXRCalibration(fn: () => void): () => void {
};
}
export function startXRCalibration(modelId: string) {
xrCalibration.step = 'await-cube-1';
export function startXRCalibration(modelId: string, type: 'cube' | 'virt-real' = 'cube') {
xrCalibration.modelId = modelId;
xrCalibration.alignType = type;
xrCalibration.pairs = [];
xrCalibration.realPoints = [];
xrCalibration.virtualPoints = [];
xrCalibration.pendingCube = null;
xrCalibration.progress = 0;
xrCalibration.verifyErrorDeg = NaN;
if (type === 'cube') {
xrCalibration.step = 'await-cube-1';
} else {
xrCalibration.step = 'await-real-1';
}
notify();
}
export function cancelXRCalibration() {
xrCalibration.step = 'idle';
xrCalibration.modelId = null;
xrCalibration.alignType = null;
xrCalibration.pairs = [];
xrCalibration.realPoints = [];
xrCalibration.virtualPoints = [];
xrCalibration.pendingCube = null;
xrCalibration.progress = 0;
xrCalibration.verifyErrorDeg = NaN;
@@ -83,6 +106,44 @@ export function pushXRCubeFace(dirWorld: THREE.Vector3) {
notify();
}
export function pushXRRealPoint(pointWorld: THREE.Vector3) {
if (xrCalibration.alignType !== 'virt-real') return;
const p = pointWorld.clone();
if (xrCalibration.step === 'await-real-1') {
xrCalibration.realPoints[0] = p;
xrCalibration.step = 'await-virtual-1';
xrCalibration.progress = 0.16;
} else if (xrCalibration.step === 'await-real-2') {
xrCalibration.realPoints[1] = p;
xrCalibration.step = 'await-virtual-2';
xrCalibration.progress = 0.5;
} else if (xrCalibration.step === 'await-real-3') {
xrCalibration.realPoints[2] = p;
xrCalibration.step = 'await-virtual-3';
xrCalibration.progress = 0.83;
}
notify();
}
export function pushXRVirtualPoint(pointLocalPivot: THREE.Vector3) {
if (xrCalibration.alignType !== 'virt-real') return;
const p = pointLocalPivot.clone();
if (xrCalibration.step === 'await-virtual-1') {
xrCalibration.virtualPoints[0] = p;
xrCalibration.step = 'await-real-2';
xrCalibration.progress = 0.33;
} else if (xrCalibration.step === 'await-virtual-2') {
xrCalibration.virtualPoints[1] = p;
xrCalibration.step = 'await-real-3';
xrCalibration.progress = 0.66;
} else if (xrCalibration.step === 'await-virtual-3') {
xrCalibration.virtualPoints[2] = p;
xrCalibration.step = 'done';
xrCalibration.progress = 1.0;
}
notify();
}
function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 {
const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg));
const axes = [
@@ -162,3 +223,50 @@ export function computeXRVerifyError(pairs: XRPair[]): number {
const cos = THREE.MathUtils.clamp(predicted.dot(target), -1, 1);
return THREE.MathUtils.radToDeg(Math.acos(cos));
}
export function computeVirtRealTransform(
v: THREE.Vector3[],
r: THREE.Vector3[]
): { quaternion: THREE.Quaternion; position: THREE.Vector3 } | null {
if (v.length < 3 || r.length < 3) return null;
const v1 = v[0];
const v2 = v[1];
const v3 = v[2];
const r1 = r[0];
const r2 = r[1];
const r3 = r[2];
// 1. Basis local virtual
const xv = new THREE.Vector3().subVectors(v2, v1).normalize();
const v3_proj = new THREE.Vector3().subVectors(v3, v1);
const dot_v = v3_proj.dot(xv);
const yv = v3_proj.clone().sub(xv.clone().multiplyScalar(dot_v));
if (yv.lengthSq() < 1e-6) return null;
yv.normalize();
const zv = new THREE.Vector3().crossVectors(xv, yv).normalize();
// 2. Basis de mundo real
const xr = new THREE.Vector3().subVectors(r2, r1).normalize();
const r3_proj = new THREE.Vector3().subVectors(r3, r1);
const dot_r = r3_proj.dot(xr);
const yr = r3_proj.clone().sub(xr.clone().multiplyScalar(dot_r));
if (yr.lengthSq() < 1e-6) return null;
yr.normalize();
const zr = new THREE.Vector3().crossVectors(xr, yr).normalize();
// 3. Matriz de rotação R que mapeia virtual para real
const Mv = new THREE.Matrix4().makeBasis(xv, yv, zv);
const Mr = new THREE.Matrix4().makeBasis(xr, yr, zr);
const Mv_t = Mv.clone().transpose();
const R = Mr.clone().multiply(Mv_t);
const quaternion = new THREE.Quaternion().setFromRotationMatrix(R);
// 4. Translação T = r1 - R * v1
const rv1 = v1.clone().applyQuaternion(quaternion);
const position = new THREE.Vector3().subVectors(r1, rv1);
return { quaternion, position };
}
+96
View File
@@ -31,6 +31,9 @@ import {
pushXRModelFaceNormal,
computeXRCalibrationQuaternion,
subscribeXRCalibration,
pushXRRealPoint,
pushXRVirtualPoint,
computeVirtRealTransform,
} from '@/components/three/xrCalibrationBus';
// --- Diagnóstico XR ---
@@ -110,6 +113,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
const ref = useRef<THREE.Group>(null);
const calGroupRef = useRef<THREE.Group>(null);
const localFrameRef = useRef<THREE.Group>(null);
const topGroupRef = useRef<THREE.Group>(null);
// Escuta mudanças de calibração para re-renderizar no AR
const [, setCalTick] = useState(0);
@@ -229,6 +233,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
return (
<group
ref={topGroupRef}
scale={[renderFactor, renderFactor, renderFactor]}
onClick={(e) => {
e.stopPropagation();
@@ -248,6 +253,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
// Calibração XR da Peça: captura normal da face do modelo em espaço de mundo.
if (
xrCalibration.modelId === sceneModel.id &&
xrCalibration.alignType === 'cube' &&
(xrCalibration.step === 'await-model-1' || xrCalibration.step === 'await-model-2' || xrCalibration.step === 'await-model-3') &&
e.face && e.object
) {
@@ -267,6 +273,48 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
}
return;
}
// Calibração Virt/Real da Peça: captura os pontos virtuais.
if (
xrCalibration.modelId === sceneModel.id &&
xrCalibration.alignType === 'virt-real' &&
(xrCalibration.step === 'await-virtual-1' || xrCalibration.step === 'await-virtual-2' || xrCalibration.step === 'await-virtual-3') &&
localFrameRef.current
) {
const localPoint = localFrameRef.current.worldToLocal(e.point.clone());
const localPivotPoint = localPoint.clone().sub(modelInfo.center);
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
if (stepNum === 3 && xrCalibration.realPoints.length >= 3 && topGroupRef.current) {
const vPoints = [...xrCalibration.virtualPoints, localPivotPoint];
const realWorldPoints = xrCalibration.realPoints;
const parentPoints = realWorldPoints.map(rw =>
topGroupRef.current!.worldToLocal(rw.clone())
);
const res = computeVirtRealTransform(vPoints, parentPoints);
if (res) {
const { quaternion, position } = res;
useModelStore.getState().setCalibration(sceneModel.id, [quaternion.x, quaternion.y, quaternion.z, quaternion.w]);
useModelStore.getState().setFineTuning({
posX: position.x,
posY: position.y,
posZ: position.z,
rotX: 0,
rotY: 0,
rotZ: 0,
});
pushXRVirtualPoint(localPivotPoint);
toast.success("Peça virtual ajustada com sucesso na peça real!");
} else {
toast.error("Erro ao alinhar: pontos são colineares!");
}
} else {
pushXRVirtualPoint(localPivotPoint);
toast.success(`Ponto virtual ${stepNum} registrado!`);
}
return;
}
}}
>
{/* Translação */}
@@ -760,6 +808,53 @@ function XRSnapHandler() {
return null;
}
// ─── Desktop Aligner Fallback for Virt/Real Calibration ─
function XRVirtRealDesktopAligner() {
const { gl, camera, scene } = useThree();
useEffect(() => {
const isAligning = xrCalibration.alignType === 'virt-real' &&
(xrCalibration.step === 'await-real-1' ||
xrCalibration.step === 'await-real-2' ||
xrCalibration.step === 'await-real-3');
if (!isAligning) return;
if (gl.xr.isPresenting) return;
const mouse = new THREE.Vector2();
const raycaster = new THREE.Raycaster();
const onClick = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const activeModelId = useModelStore.getState().activeModelId;
const hit = intersects.find(i => {
if (i.object.userData?.__edgeLine) return false;
let cur: THREE.Object3D | null = i.object;
while (cur) {
if (cur.userData?.modelId === activeModelId) return false;
cur = cur.parent;
}
return true;
});
const point = hit ? hit.point.clone() : new THREE.Vector3(0, 0, 0);
pushXRRealPoint(point);
const stepNum = xrCalibration.step.endsWith('1') ? 1 : xrCalibration.step.endsWith('2') ? 2 : 3;
toast.success(`Ponto real ${stepNum} registrado (Simulado)!`);
};
gl.domElement.addEventListener('click', onClick);
return () => gl.domElement.removeEventListener('click', onClick);
}, [gl, camera, scene, xrCalibration.step, xrCalibration.alignType]);
return null;
}
// ─── XR Grid ───────────────────────────────────────────
function XRGrid() {
const showGrid = useModelStore((s) => s.showGrid);
@@ -1025,6 +1120,7 @@ const XRSession = () => {
)}
<XRSnapHandler />
<XRVirtRealDesktopAligner />
<XRGrid />
<XRGridAutoFollower />
</XR>