🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 01:33:36
This commit is contained in:
@@ -5,7 +5,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
import XRSession from "./pages/XRSession";
|
|
||||||
import Watch from "./pages/Watch";
|
import Watch from "./pages/Watch";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
import "@/lib/remoteLogger";
|
import "@/lib/remoteLogger";
|
||||||
@@ -21,7 +20,6 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
<Route path="/xr" element={<XRSession />} />
|
|
||||||
<Route path="/watch/:code" element={<Watch />} />
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -9,6 +9,30 @@ import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelT
|
|||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
|
// WebXR imersivo
|
||||||
|
import { XR, useXR, XROrigin } from '@react-three/xr';
|
||||||
|
import { xrStore } from '@/lib/xrStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
XRBackgroundModels,
|
||||||
|
XRActiveModel,
|
||||||
|
XRMeasurementOverlay,
|
||||||
|
XRSnapHandler,
|
||||||
|
XRGrid,
|
||||||
|
XRGridAutoFollower,
|
||||||
|
} from './XRSceneComponents';
|
||||||
|
import { XRGrabbable } from './XRGrabbable';
|
||||||
|
import { XRHitTestPlacement } from './XRHitTestPlacement';
|
||||||
|
import { XRControllerMeasure } from './XRControllerMeasure';
|
||||||
|
import { ControllerLocomotion } from './ControllerLocomotion';
|
||||||
|
import { XRHudInWorld } from './XRHudInWorld';
|
||||||
|
import { XRBroadcastMirror } from './XRBroadcastMirror';
|
||||||
|
import { XRHud } from '@/components/XRHud';
|
||||||
|
import { ShareButton } from '@/components/ShareButton';
|
||||||
|
import { useDevKit } from '@/devkit/useDevKit';
|
||||||
|
import { DevPanel } from '@/devkit/DevPanel';
|
||||||
|
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
url?: string; // legacy, ignored — uses store.models
|
||||||
}
|
}
|
||||||
@@ -1006,8 +1030,8 @@ function PositionDragHandler() {
|
|||||||
const ft = active.fineTuning;
|
const ft = active.fineTuning;
|
||||||
const factor = st.scaleRatio?.factor ?? 1;
|
const factor = st.scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
if (button === 2) {
|
if (button === 0) {
|
||||||
// Right button: rotate
|
// Left button (Trigger in Quest emulated mouse): rotate (unifies with AR / VR standard)
|
||||||
const sens = 0.4; // deg per pixel
|
const sens = 0.4; // deg per pixel
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
||||||
@@ -1034,8 +1058,8 @@ function PositionDragHandler() {
|
|||||||
} else {
|
} else {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
||||||
}
|
}
|
||||||
} else if (button === 0 && shiftKey) {
|
} else if (button === 2 && shiftKey) {
|
||||||
// Shift+left: depth (Z in camera space)
|
// Shift+Right button: depth (Z in camera space)
|
||||||
const worldDelta = dy * pixelsPerWorldUnit;
|
const worldDelta = dy * pixelsPerWorldUnit;
|
||||||
const camDir = new THREE.Vector3();
|
const camDir = new THREE.Vector3();
|
||||||
camera.getWorldDirection(camDir);
|
camera.getWorldDirection(camDir);
|
||||||
@@ -1046,7 +1070,7 @@ function PositionDragHandler() {
|
|||||||
posZ: ft.posZ + move.z,
|
posZ: ft.posZ + move.z,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Left: translate in camera plane
|
// Right button (Grip in Quest emulated mouse): translate in camera plane (matches AR pan-only grip)
|
||||||
const right = new THREE.Vector3();
|
const right = new THREE.Vector3();
|
||||||
const up = new THREE.Vector3();
|
const up = new THREE.Vector3();
|
||||||
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
||||||
@@ -1088,10 +1112,193 @@ function PositionDragHandler() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
function SceneInsideXR() {
|
||||||
|
const isPresenting = useXR((state) => state.isPresenting);
|
||||||
const positionMode = useModelStore((s) => s.positionMode);
|
const positionMode = useModelStore((s) => s.positionMode);
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
const models = useModelStore((s) => s.models);
|
||||||
|
const activeModel = models.find(m => m.id === activeId);
|
||||||
|
const isActiveLocked = !!activeModel?.locked;
|
||||||
|
|
||||||
|
// Estados locais para a sessão de XR
|
||||||
|
const [freeMove, setFreeMove] = useState(true);
|
||||||
|
const [placementMode, setPlacementMode] = useState(true);
|
||||||
|
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||||
|
const [allowScale, setAllowScale] = useState(true);
|
||||||
|
const [liveCode, setLiveCode] = useState<string | null>(null);
|
||||||
|
const [liveViewers, setLiveViewers] = useState(0);
|
||||||
|
|
||||||
|
const rigRef = useRef<THREE.Group>(null);
|
||||||
|
|
||||||
|
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
||||||
|
const devkit = useDevKit();
|
||||||
|
const [simXR, setSimXR] = useState(false);
|
||||||
|
const effectiveInXR = isPresenting || simXR;
|
||||||
|
|
||||||
|
// Monitorar início e fim da sessão
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPresenting) {
|
||||||
|
toast.success('Sessão AR iniciada!');
|
||||||
|
}
|
||||||
|
}, [isPresenting]);
|
||||||
|
|
||||||
|
if (!effectiveInXR) {
|
||||||
|
// Modo 2D normal (não imersivo)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.6} />
|
||||||
|
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
||||||
|
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<SceneModels />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<MeasurementOverlay />
|
||||||
|
<MeasureClickHandler />
|
||||||
|
<SmartSnapHandler />
|
||||||
|
<HoverDetector />
|
||||||
|
|
||||||
|
<GridLayer />
|
||||||
|
<GridAutoFollower />
|
||||||
|
|
||||||
|
<PositionDragHandler />
|
||||||
|
<SelectionHandler />
|
||||||
|
<VisibilityApplier />
|
||||||
|
<SceneRefCapture />
|
||||||
|
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
enabled={!positionMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo AR imersivo (ou SimXR)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.8} />
|
||||||
|
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||||||
|
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||||||
|
|
||||||
|
<group ref={rigRef}>
|
||||||
|
<XROrigin />
|
||||||
|
</group>
|
||||||
|
|
||||||
|
{simXR ? (
|
||||||
|
<group position={[0, 1.0, -1.2]}>
|
||||||
|
<XRBackgroundModels />
|
||||||
|
<XRGrabbable
|
||||||
|
allowScale={allowScale}
|
||||||
|
lockedActive={isActiveLocked}
|
||||||
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
|
>
|
||||||
|
<XRActiveModel />
|
||||||
|
</XRGrabbable>
|
||||||
|
</group>
|
||||||
|
) : (
|
||||||
|
<XRHitTestPlacement
|
||||||
|
placementMode={placementMode}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onPlace={() => {
|
||||||
|
setPlacementMode(false);
|
||||||
|
toast.success('Modelo posicionado na superfície!');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<XRBackgroundModels />
|
||||||
|
<XRGrabbable
|
||||||
|
allowScale={allowScale}
|
||||||
|
lockedActive={isActiveLocked}
|
||||||
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
|
>
|
||||||
|
<XRActiveModel />
|
||||||
|
</XRGrabbable>
|
||||||
|
</XRHitTestPlacement>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<XRMeasurementOverlay />
|
||||||
|
<XRControllerMeasure />
|
||||||
|
<ControllerLocomotion rigRef={rigRef} />
|
||||||
|
<VisibilityApplier />
|
||||||
|
<SceneRefCapture />
|
||||||
|
|
||||||
|
<XRHudInWorld
|
||||||
|
freeMove={freeMove}
|
||||||
|
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||||
|
placementMode={placementMode}
|
||||||
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
|
liveCode={liveCode}
|
||||||
|
liveViewers={liveViewers}
|
||||||
|
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
|
||||||
|
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<XRBroadcastMirror enabled />
|
||||||
|
|
||||||
|
{devkit && simXR && <FakeControllers />}
|
||||||
|
{simXR && (
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
target={[0, 1.0, -1.2]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<XRSnapHandler />
|
||||||
|
<XRGrid />
|
||||||
|
<XRGridAutoFollower />
|
||||||
|
|
||||||
|
{/* Floating HUD overlay - visível apenas fora do headset passthrough */}
|
||||||
|
<Html fullscreen style={{ pointerEvents: 'none', display: isPresenting ? 'none' : 'contents' }}>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>
|
||||||
|
<XRHud
|
||||||
|
freeMove={freeMove}
|
||||||
|
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||||
|
placementMode={placementMode}
|
||||||
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* Compartilhar botões ocultos para WebRTC e HUD de fora */}
|
||||||
|
<Html style={{ display: 'none' }}>
|
||||||
|
<div>
|
||||||
|
<ShareButton
|
||||||
|
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
||||||
|
onViewerCountChange={setLiveViewers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* DevPanel se devkit habilitado */}
|
||||||
|
{devkit && (
|
||||||
|
<Html fullscreen style={{ pointerEvents: 'none' }}>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>
|
||||||
|
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||||
@@ -1109,36 +1316,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<XR store={xrStore}>
|
||||||
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
<SceneInsideXR />
|
||||||
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
</XR>
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
|
||||||
<SceneModels />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<MeasurementOverlay />
|
|
||||||
<MeasureClickHandler />
|
|
||||||
<SmartSnapHandler />
|
|
||||||
<HoverDetector />
|
|
||||||
|
|
||||||
<GridLayer />
|
|
||||||
<GridAutoFollower />
|
|
||||||
|
|
||||||
<PositionDragHandler />
|
|
||||||
<SelectionHandler />
|
|
||||||
<VisibilityApplier />
|
|
||||||
<SceneRefCapture />
|
|
||||||
|
|
||||||
<OrbitControls
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={0.05}
|
|
||||||
maxDistance={50}
|
|
||||||
enabled={!positionMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,74 +1,15 @@
|
|||||||
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
import { Grid } from '@react-three/drei';
|
||||||
import { useGLTF, Grid, Html, Line, OrbitControls, Text } from '@react-three/drei';
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { findNearestVertex } from './SmartMeasure';
|
||||||
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
|
||||||
import { XRHud } from '@/components/XRHud';
|
|
||||||
import { ShareButton } from '@/components/ShareButton';
|
|
||||||
import { XRHudInWorld } from '@/components/three/XRHudInWorld';
|
|
||||||
import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror';
|
|
||||||
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
|
||||||
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
|
||||||
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
|
||||||
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
|
|
||||||
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
|
|
||||||
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
|
|
||||||
import { useDevKit } from '@/devkit/useDevKit';
|
|
||||||
import { DevPanel } from '@/devkit/DevPanel';
|
|
||||||
import { FakeControllers } from '@/devkit/FakeControllers';
|
|
||||||
import { VisibilityApplier } from '@/components/three/ModelViewer';
|
|
||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
|
||||||
|
|
||||||
// --- Diagnóstico XR ---
|
|
||||||
console.log('[XR] Inicializando store...');
|
|
||||||
if (navigator.xr) {
|
|
||||||
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
|
||||||
console.log('[XR] immersive-ar suportado:', supported);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lê flags de feature WebXR do localStorage (default: todas ON).
|
|
||||||
// O usuário liga/desliga pelo HUD AR (aba WebXR) para diagnosticar qual
|
|
||||||
// feature ativa o grid de segurança do Quest. Mudanças exigem sair e re-entrar do AR.
|
|
||||||
function loadXRFeatures() {
|
|
||||||
const defaults = {
|
|
||||||
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
|
|
||||||
anchors: true, meshDetection: true, depthSensing: true, layers: true,
|
|
||||||
bodyTracking: true, lightEstimation: true,
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('xrFeatures');
|
|
||||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
|
||||||
} catch {}
|
|
||||||
return defaults;
|
|
||||||
}
|
|
||||||
const _xrf = loadXRFeatures();
|
|
||||||
console.log('[XR] Features:', _xrf);
|
|
||||||
|
|
||||||
const store = createXRStore({
|
|
||||||
hand: { left: true, right: true },
|
|
||||||
controller: { left: true, right: true },
|
|
||||||
handTracking: _xrf.handTracking,
|
|
||||||
planeDetection: _xrf.planeDetection,
|
|
||||||
hitTest: _xrf.hitTest,
|
|
||||||
domOverlay: _xrf.domOverlay,
|
|
||||||
anchors: _xrf.anchors,
|
|
||||||
meshDetection: _xrf.meshDetection,
|
|
||||||
depthSensing: _xrf.depthSensing,
|
|
||||||
layers: _xrf.layers,
|
|
||||||
bodyTracking: _xrf.bodyTracking,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── XRModel ───────────────────────────────────────────
|
// ─── XRModel ───────────────────────────────────────────
|
||||||
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
|
export function XRModel({ sceneModel }: { sceneModel: SceneModel }) {
|
||||||
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
||||||
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
||||||
|
|
||||||
@@ -215,8 +156,7 @@ function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders all NON-active models as static background (placement + grab apply only to active). */
|
export function XRBackgroundModels() {
|
||||||
function XRBackgroundModels() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
return (
|
return (
|
||||||
@@ -228,8 +168,7 @@ function XRBackgroundModels() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the currently-active model (the one wrapped by grab/placement). */
|
export function XRActiveModel() {
|
||||||
function XRActiveModel() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
const activeId = useModelStore((s) => s.activeModelId);
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
const active = models.find(m => m.id === activeId);
|
const active = models.find(m => m.id === activeId);
|
||||||
@@ -238,12 +177,9 @@ function XRActiveModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── ControllerFineTuning ──────────────────────────────
|
// ─── ControllerFineTuning ──────────────────────────────
|
||||||
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
export function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||||
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
|
||||||
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
|
|
||||||
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|
||||||
const { setFineTuning } = useModelStore();
|
const { setFineTuning } = useModelStore();
|
||||||
const session = useXR((s) => s.session);
|
const session = useThree((s) => s.gl.xr.getSession());
|
||||||
const isActiveLocked = useModelStore((s) => {
|
const isActiveLocked = useModelStore((s) => {
|
||||||
const a = s.models.find(m => m.id === s.activeModelId);
|
const a = s.models.find(m => m.id === s.activeModelId);
|
||||||
return !!a?.locked;
|
return !!a?.locked;
|
||||||
@@ -268,7 +204,7 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|||||||
const trigBtn = gp.buttons[1];
|
const trigBtn = gp.buttons[1];
|
||||||
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||||
if (gripBtn?.pressed) gripHeld = true;
|
if (gripBtn?.pressed) gripHeld = true;
|
||||||
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
|
if (gripVal > 0.3) anyGripHeld = true;
|
||||||
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||||
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
||||||
leftAxes = [gp.axes[2], gp.axes[3]];
|
leftAxes = [gp.axes[2], gp.axes[3]];
|
||||||
@@ -280,31 +216,24 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grab takes priority — joystick is disabled while grabbing
|
|
||||||
if (anyGripHeld) return;
|
if (anyGripHeld) return;
|
||||||
// In freeMove mode, joystick always moves. Otherwise requires grip — but
|
|
||||||
// grip is reserved for grab now, so freeMove=false essentially disables.
|
|
||||||
if (!freeMove && !gripHeld) return;
|
if (!freeMove && !gripHeld) return;
|
||||||
|
|
||||||
const baseStep = 0.001;
|
const baseStep = 0.001;
|
||||||
const baseRot = 0.1;
|
const baseRot = 0.1;
|
||||||
const deadzone = 0.15;
|
const deadzone = 0.15;
|
||||||
// Trigger modulation: 0 → 0.3×, 1 → 3×
|
|
||||||
const speedL = 0.3 + leftTrig * 2.7;
|
const speedL = 0.3 + leftTrig * 2.7;
|
||||||
const speedR = 0.3 + rightTrig * 2.7;
|
const speedR = 0.3 + rightTrig * 2.7;
|
||||||
// Quadratic acceleration on thumbstick value
|
|
||||||
const curve = (v: number) => Math.sign(v) * v * v;
|
const curve = (v: number) => Math.sign(v) * v * v;
|
||||||
|
|
||||||
const modelStore = useModelStore.getState();
|
const modelStore = useModelStore.getState();
|
||||||
const ft = { ...modelStore.fineTuning };
|
const ft = { ...modelStore.fineTuning };
|
||||||
|
|
||||||
if (leftAxes) {
|
if (leftAxes) {
|
||||||
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
|
|
||||||
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
||||||
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
||||||
}
|
}
|
||||||
if (rightAxes) {
|
if (rightAxes) {
|
||||||
// Right: rotY (horizontal) / Y height (vertical, up = up)
|
|
||||||
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
||||||
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
||||||
}
|
}
|
||||||
@@ -315,7 +244,6 @@ function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Componente de renderização de texto compatível e seguro para WebXR
|
|
||||||
function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
||||||
const texture = useMemo(() => {
|
const texture = useMemo(() => {
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -323,10 +251,8 @@ function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
|||||||
canvas.height = 64;
|
canvas.height = 64;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
// Limpa e desenha fundo semi-transparente
|
|
||||||
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
// Desenha retângulo arredondado manualmente
|
|
||||||
const x = 0, y = 0, width = 256, height = 64, radius = 12;
|
const x = 0, y = 0, width = 256, height = 64, radius = 12;
|
||||||
ctx.moveTo(x + radius, y);
|
ctx.moveTo(x + radius, y);
|
||||||
ctx.lineTo(x + width - radius, y);
|
ctx.lineTo(x + width - radius, y);
|
||||||
@@ -340,12 +266,10 @@ function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
|||||||
ctx.closePath();
|
ctx.closePath();
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
|
|
||||||
// Desenha borda sutil
|
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color;
|
||||||
ctx.lineWidth = 3;
|
ctx.lineWidth = 3;
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Texto
|
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.font = 'bold 24px monospace';
|
ctx.font = 'bold 24px monospace';
|
||||||
ctx.textAlign = 'center';
|
ctx.textAlign = 'center';
|
||||||
@@ -370,7 +294,6 @@ function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Componente de linha nativa seguro para WebXR
|
|
||||||
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
|
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
|
||||||
const points = useMemo(() => [
|
const points = useMemo(() => [
|
||||||
new THREE.Vector3(a[0], a[1], a[2]),
|
new THREE.Vector3(a[0], a[1], a[2]),
|
||||||
@@ -388,7 +311,6 @@ export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number,
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Componente para renderizar medições que pertencem a um modelo específico
|
|
||||||
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
||||||
const measurements = useModelStore((s) => s.measurements);
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
const modelMeasurements = useMemo(() => {
|
const modelMeasurements = useMemo(() => {
|
||||||
@@ -430,8 +352,7 @@ export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Measurement overlay (reused from ModelViewer) ─────
|
export function XRMeasurementOverlay() {
|
||||||
function XRMeasurementOverlay() {
|
|
||||||
const measurements = useModelStore((s) => s.measurements);
|
const measurements = useModelStore((s) => s.measurements);
|
||||||
const measurePoints = useModelStore((s) => s.measurePoints);
|
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||||
@@ -488,8 +409,7 @@ function XRMeasurementOverlay() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Snap handler for XR raycasting ────────────────────
|
export function XRSnapHandler() {
|
||||||
function XRSnapHandler() {
|
|
||||||
const { camera, scene, gl } = useThree();
|
const { camera, scene, gl } = useThree();
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
||||||
@@ -510,8 +430,6 @@ function XRSnapHandler() {
|
|||||||
}, [gl, mouse, measureMode]);
|
}, [gl, mouse, measureMode]);
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
// Em AR imersivo, a medição é feita pelo XRControllerMeasure (gatilho do controle).
|
|
||||||
// Evita raycast pesado por frame que travava o Quest ao ativar "Medir".
|
|
||||||
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
|
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
|
||||||
if (!measureMode) { setSnapPoint(null); return; }
|
if (!measureMode) { setSnapPoint(null); return; }
|
||||||
raycaster.setFromCamera(mouse, camera);
|
raycaster.setFromCamera(mouse, camera);
|
||||||
@@ -532,11 +450,9 @@ function XRSnapHandler() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Click to measure
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!measureMode) return;
|
if (!measureMode) return;
|
||||||
if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo
|
if (gl.xr.isPresenting) return;
|
||||||
const onClick = (e: MouseEvent) => {
|
const onClick = (e: MouseEvent) => {
|
||||||
if (snapPoint) {
|
if (snapPoint) {
|
||||||
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
||||||
@@ -562,8 +478,7 @@ function XRSnapHandler() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── XR Grid ───────────────────────────────────────────
|
export function XRGrid() {
|
||||||
function XRGrid() {
|
|
||||||
const showGrid = useModelStore((s) => s.showGrid);
|
const showGrid = useModelStore((s) => s.showGrid);
|
||||||
const gridY = useModelStore((s) => s.gridY);
|
const gridY = useModelStore((s) => s.gridY);
|
||||||
if (!showGrid) return null;
|
if (!showGrid) return null;
|
||||||
@@ -583,8 +498,7 @@ function XRGrid() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-follow grid to model bottom when enabled. */
|
export function XRGridAutoFollower() {
|
||||||
function XRGridAutoFollower() {
|
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
const { scene } = useThree();
|
const { scene } = useThree();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -612,258 +526,3 @@ function XRGridAutoFollower() {
|
|||||||
}, [models, scene]);
|
}, [models, scene]);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
|
|
||||||
|
|
||||||
// ─── XRSession Page ────────────────────────────────────
|
|
||||||
const XRSession = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { model, anchorMode, setAnchorMode } = useModelStore();
|
|
||||||
const isActiveLocked = useModelStore((s) => {
|
|
||||||
const a = s.models.find(m => m.id === s.activeModelId);
|
|
||||||
return !!a?.locked;
|
|
||||||
});
|
|
||||||
const [inXR, setInXR] = useState(false);
|
|
||||||
const [freeMove, setFreeMove] = useState(true);
|
|
||||||
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
|
||||||
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
|
||||||
// Zoom por grip duplo — toggle real, padrão ligado.
|
|
||||||
const [allowScale, setAllowScale] = useState(true);
|
|
||||||
const [liveCode, setLiveCode] = useState<string | null>(null);
|
|
||||||
const [liveViewers, setLiveViewers] = useState(0);
|
|
||||||
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
|
|
||||||
const rigRef = useRef<THREE.Group>(null);
|
|
||||||
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
|
||||||
const devkit = useDevKit();
|
|
||||||
const [simXR, setSimXR] = useState(false);
|
|
||||||
const effectiveInXR = inXR || simXR;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!model) navigate('/');
|
|
||||||
}, [model, navigate]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = store.subscribe((state) => {
|
|
||||||
const session = state.session;
|
|
||||||
if (session && !inXR) {
|
|
||||||
console.log('[XR] ✅ Sessão AR ativa!');
|
|
||||||
setInXR(true);
|
|
||||||
setAnchorMode('manual');
|
|
||||||
toast.success('Sessão AR iniciada!');
|
|
||||||
session.addEventListener('end', () => {
|
|
||||||
console.log('[XR] ❌ Sessão AR encerrada');
|
|
||||||
setInXR(false);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return unsubscribe;
|
|
||||||
}, [inXR, setAnchorMode]);
|
|
||||||
|
|
||||||
const handleDownloadMarker = useCallback(async () => {
|
|
||||||
const url = await generateMarkerDownloadURL();
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = 'TrackSteelXR_Marker.png';
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!model) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-screen flex-col bg-background">
|
|
||||||
{/* Header */}
|
|
||||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
|
|
||||||
<ArrowLeft className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
|
|
||||||
<Home className="h-5 w-5" />
|
|
||||||
</Button>
|
|
||||||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
|
||||||
Modo <span className="text-primary">XR Imersivo</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{!inXR && (
|
|
||||||
<>
|
|
||||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
|
||||||
<Download className="h-3.5 w-3.5" />
|
|
||||||
<span className="font-mono text-xs">Marcador</span>
|
|
||||||
</Button>
|
|
||||||
<Button className="gap-2 glow-primary" onClick={() => {
|
|
||||||
console.log('[XR] Botão AR clicado');
|
|
||||||
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
|
||||||
}}>
|
|
||||||
Iniciar Sessão AR
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{inXR && (
|
|
||||||
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
|
||||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
|
||||||
XR Ativo
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* 3D Canvas */}
|
|
||||||
<div className="flex-1 relative">
|
|
||||||
<Canvas
|
|
||||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
|
||||||
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
|
|
||||||
className="!bg-transparent"
|
|
||||||
onCreated={({ gl }) => {
|
|
||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
||||||
gl.setClearColor(0x000000, 0);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XR store={store}>
|
|
||||||
<ambientLight intensity={0.8} />
|
|
||||||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
|
||||||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
|
||||||
|
|
||||||
<group ref={rigRef}>
|
|
||||||
<XROrigin />
|
|
||||||
</group>
|
|
||||||
|
|
||||||
{effectiveInXR ? (
|
|
||||||
<>
|
|
||||||
{/* In XR (or SimXR): all models share the same placement origin so
|
|
||||||
switching active doesn't make others appear to "disappear".
|
|
||||||
Only the ACTIVE model receives grab transforms. */}
|
|
||||||
{simXR ? (
|
|
||||||
<group position={[0, 1.0, -1.2]}>
|
|
||||||
<XRBackgroundModels />
|
|
||||||
<XRGrabbable
|
|
||||||
allowScale={allowScale}
|
|
||||||
lockedActive={isActiveLocked}
|
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
|
||||||
>
|
|
||||||
<XRActiveModel />
|
|
||||||
</XRGrabbable>
|
|
||||||
</group>
|
|
||||||
) : (
|
|
||||||
<XRHitTestPlacement
|
|
||||||
placementMode={placementMode}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onPlace={() => {
|
|
||||||
setPlacementMode(false);
|
|
||||||
toast.success('Modelo posicionado na superfície!');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<XRBackgroundModels />
|
|
||||||
<XRGrabbable
|
|
||||||
allowScale={allowScale}
|
|
||||||
lockedActive={isActiveLocked}
|
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
|
||||||
>
|
|
||||||
<XRActiveModel />
|
|
||||||
</XRGrabbable>
|
|
||||||
</XRHitTestPlacement>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XRMeasurementOverlay />
|
|
||||||
<XRControllerMeasure />
|
|
||||||
<ControllerLocomotion rigRef={rigRef} />
|
|
||||||
<VisibilityApplier />
|
|
||||||
|
|
||||||
|
|
||||||
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
|
|
||||||
<XRHudInWorld
|
|
||||||
freeMove={freeMove}
|
|
||||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
|
||||||
placementMode={placementMode}
|
|
||||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
|
||||||
allowScale={allowScale}
|
|
||||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
|
||||||
liveCode={liveCode}
|
|
||||||
liveViewers={liveViewers}
|
|
||||||
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
|
|
||||||
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Opaque mirror canvas used as the WebRTC source while in AR */}
|
|
||||||
<XRBroadcastMirror enabled />
|
|
||||||
|
|
||||||
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
|
|
||||||
{devkit && simXR && <FakeControllers />}
|
|
||||||
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
|
|
||||||
{simXR && (
|
|
||||||
<OrbitControls
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={0.05}
|
|
||||||
maxDistance={50}
|
|
||||||
target={[0, 1.0, -1.2]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Desktop preview (no headset): show all models with OrbitControls */}
|
|
||||||
<group position={[0, 0, 0]}>
|
|
||||||
<XRBackgroundModels />
|
|
||||||
<XRActiveModel />
|
|
||||||
</group>
|
|
||||||
<XRMeasurementOverlay />
|
|
||||||
<OrbitControls
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={0.05}
|
|
||||||
maxDistance={50}
|
|
||||||
target={[0, 0, 0]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<XRSnapHandler />
|
|
||||||
<XRGrid />
|
|
||||||
<XRGridAutoFollower />
|
|
||||||
</XR>
|
|
||||||
</Canvas>
|
|
||||||
|
|
||||||
{/* Floating DOM HUD overlay — only visible OUTSIDE passthrough.
|
|
||||||
Inside AR, the DOM is occluded by the headset compositor; the
|
|
||||||
in-world XRHudInWorld replaces it. */}
|
|
||||||
<div style={{ display: inXR ? 'none' : 'contents' }}>
|
|
||||||
<XRHud
|
|
||||||
freeMove={freeMove}
|
|
||||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
|
||||||
placementMode={placementMode}
|
|
||||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
|
||||||
snapToPlanes={snapToPlanes}
|
|
||||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
|
||||||
allowScale={allowScale}
|
|
||||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hidden ShareButton instance — registers window.__trackSteelStartLive
|
|
||||||
so the in-world Share tab can trigger broadcasts. Also keeps live
|
|
||||||
state in sync with the in-XR HUD. */}
|
|
||||||
<div style={{ display: 'none' }}>
|
|
||||||
<ShareButton
|
|
||||||
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
|
||||||
onViewerCountChange={setLiveViewers}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
|
|
||||||
{devkit && (
|
|
||||||
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default XRSession;
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createXRStore } from '@react-three/xr';
|
||||||
|
|
||||||
|
function loadXRFeatures() {
|
||||||
|
const defaults = {
|
||||||
|
handTracking: true,
|
||||||
|
planeDetection: true,
|
||||||
|
hitTest: true,
|
||||||
|
domOverlay: true,
|
||||||
|
anchors: true,
|
||||||
|
meshDetection: true,
|
||||||
|
depthSensing: true,
|
||||||
|
layers: true,
|
||||||
|
bodyTracking: true,
|
||||||
|
lightEstimation: true,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem('xrFeatures');
|
||||||
|
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||||
|
} catch {}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _xrf = loadXRFeatures();
|
||||||
|
|
||||||
|
export const xrStore = createXRStore({
|
||||||
|
hand: { left: true, right: true },
|
||||||
|
controller: { left: true, right: true },
|
||||||
|
handTracking: _xrf.handTracking,
|
||||||
|
planeDetection: _xrf.planeDetection,
|
||||||
|
hitTest: _xrf.hitTest,
|
||||||
|
domOverlay: _xrf.domOverlay,
|
||||||
|
anchors: _xrf.anchors,
|
||||||
|
meshDetection: _xrf.meshDetection,
|
||||||
|
depthSensing: _xrf.depthSensing,
|
||||||
|
layers: _xrf.layers,
|
||||||
|
bodyTracking: _xrf.bodyTracking,
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@ import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucid
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useModelStore } from "@/stores/useModelStore";
|
import { useModelStore } from "@/stores/useModelStore";
|
||||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||||
|
import { xrStore } from "@/lib/xrStore";
|
||||||
import { ViewerControls } from "@/components/ViewerControls";
|
import { ViewerControls } from "@/components/ViewerControls";
|
||||||
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
@@ -92,11 +93,12 @@ const Viewer = () => {
|
|||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
const handleEnterXR = async () => {
|
const handleEnterXR = async () => {
|
||||||
if (!xrSupported) {
|
try {
|
||||||
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
|
await xrStore.enterAR();
|
||||||
return;
|
} catch (e) {
|
||||||
|
console.error('[XR] Falha ao entrar no AR:', e);
|
||||||
|
toast.error("Não foi possível iniciar a sessão AR. Certifique-se de que está usando um dispositivo compatível (como Meta Quest 3).");
|
||||||
}
|
}
|
||||||
navigate("/xr");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canAddMore = models.length < maxModels;
|
const canAddMore = models.length < maxModels;
|
||||||
@@ -129,7 +131,7 @@ const Viewer = () => {
|
|||||||
<ShareButton />
|
<ShareButton />
|
||||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
||||||
<Glasses className="h-4 w-4" />
|
<Glasses className="h-4 w-4" />
|
||||||
Entrar em Modo XR
|
Iniciar AR
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
Reference in New Issue
Block a user