From 79b95ce3c86418821be089bc94fb48f9d767bc42 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Sun, 24 May 2026 18:56:17 +0000 Subject: [PATCH] Reverted to commit c93e56640d82a4706cd84a573d7dfd2c16b51b59 --- package.json | 2 +- src/App.tsx | 2 + src/components/ViewCube.tsx | 191 ---- src/components/ViewerControls.tsx | 92 +- src/components/XRHud.tsx | 8 +- .../three/DesktopFirstPersonControls.tsx | 167 ---- src/components/three/ModelViewer.tsx | 485 ++-------- src/components/three/XRControllerMeasure.tsx | 40 - src/components/three/XRGrabbable.tsx | 85 +- src/components/three/XRHudInWorld.tsx | 269 ++---- src/components/three/XRSceneComponents.tsx | 606 ------------ src/index.css | 35 - src/lib/xrStore.ts | 37 - src/pages/Index.tsx | 4 +- src/pages/Viewer.tsx | 15 +- src/pages/XRSession.tsx | 875 ++++++++++++++++++ src/stores/useModelStore.ts | 34 +- 17 files changed, 1069 insertions(+), 1878 deletions(-) delete mode 100644 src/components/ViewCube.tsx delete mode 100644 src/components/three/DesktopFirstPersonControls.tsx delete mode 100644 src/components/three/XRSceneComponents.tsx delete mode 100644 src/lib/xrStore.ts create mode 100644 src/pages/XRSession.tsx diff --git a/package.json b/package.json index 4e02585..6e247ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vite_react_shadcn_ts", "private": true, - "version": "1.0.6", + "version": "1.0.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/App.tsx b/src/App.tsx index 3798cd4..42fbbb6 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route } from "react-router-dom"; import Index from "./pages/Index"; import Viewer from "./pages/Viewer"; +import XRSession from "./pages/XRSession"; import Watch from "./pages/Watch"; import NotFound from "./pages/NotFound"; import "@/lib/remoteLogger"; @@ -20,6 +21,7 @@ const App = () => ( } /> } /> + } /> } /> } /> diff --git a/src/components/ViewCube.tsx b/src/components/ViewCube.tsx deleted file mode 100644 index 9a05eac..0000000 --- a/src/components/ViewCube.tsx +++ /dev/null @@ -1,191 +0,0 @@ -import { useState, useEffect } from 'react'; -import { useModelStore } from '@/stores/useModelStore'; -import { Button } from '@/components/ui/button'; -import { RotateCw, RefreshCw, Move, HelpCircle } from 'lucide-react'; -import { toast } from 'sonner'; - -export function ViewCube() { - const { activeModelId, models, setFineTuning } = useModelStore(); - const activeModel = models.find((m) => m.id === activeModelId); - - // Estados locais para controlar uma rotação interativa ou visualização do cubo - const [hoveredFace, setHoveredFace] = useState(null); - - if (!activeModelId || !activeModel) return null; - - const alignToFace = (face: string) => { - let rotX = 0; - let rotY = 0; - let rotZ = 0; - - switch (face) { - case 'TOPO': - rotX = 0; - rotY = 0; - rotZ = 0; - break; - case 'FRENTE': - rotX = 90; - rotY = 0; - rotZ = 0; - break; - case 'DIREITA': - rotX = 90; - rotY = 0; - rotZ = -90; - break; - case 'ESQUERDA': - rotX = 90; - rotY = 0; - rotZ = 90; - break; - case 'ATRÁS': - rotX = -90; - rotY = 180; - rotZ = 0; - break; - case 'BASE': - rotX = 180; - rotY = 0; - rotZ = 0; - break; - } - - setFineTuning({ rotX, rotY, rotZ }); - toast.success(`Alinhado para vista ${face}`); - }; - - // Ajusta a rotação para o múltiplo de 90 graus mais próximo - const snapToNearest90 = () => { - const ft = activeModel.fineTuning; - const rotX = Math.round(ft.rotX / 90) * 90; - const rotY = Math.round(ft.rotY / 90) * 90; - const rotZ = Math.round(ft.rotZ / 90) * 90; - setFineTuning({ rotX, rotY, rotZ }); - toast.success(`Snap Ortogonal (90°): [${rotX}°, ${rotY}°, ${rotZ}°]`); - }; - - const resetAllTransforms = () => { - setFineTuning({ posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }); - toast.success('Posição e rotação restauradas para a origem'); - }; - - return ( -
- {/* Título do Widget */} - - Alinhamento 3D - - - {/* Cubo 3D Visual */} -
- {/* Container do Cubo Isométrico */} -
- {/* TOPO (Z+) */} -
alignToFace('TOPO')} - onMouseEnter={() => setHoveredFace('TOPO')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-top ${ - hoveredFace === 'TOPO' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-800/80 text-zinc-300' - }`} - > - TOPO -
- - {/* FRENTE (Y-) */} -
alignToFace('FRENTE')} - onMouseEnter={() => setHoveredFace('FRENTE')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-front ${ - hoveredFace === 'FRENTE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-700/80 text-zinc-300' - }`} - > - FRENTE -
- - {/* DIREITA (X+) */} -
alignToFace('DIREITA')} - onMouseEnter={() => setHoveredFace('DIREITA')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-right ${ - hoveredFace === 'DIREITA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300' - }`} - > - DIR -
- - {/* ESQUERDA (X-) */} -
alignToFace('ESQUERDA')} - onMouseEnter={() => setHoveredFace('ESQUERDA')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-left ${ - hoveredFace === 'ESQUERDA' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-850/80 text-zinc-300' - }`} - > - ESQ -
- - {/* ATRÁS (Y+) */} -
alignToFace('ATRÁS')} - onMouseEnter={() => setHoveredFace('ATRÁS')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-back ${ - hoveredFace === 'ATRÁS' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-750/80 text-zinc-300' - }`} - > - ATRÁS -
- - {/* BASE (Z-) */} -
alignToFace('BASE')} - onMouseEnter={() => setHoveredFace('BASE')} - onMouseLeave={() => setHoveredFace(null)} - className={`absolute h-14 w-14 border border-white/20 flex items-center justify-center font-mono text-[9px] font-bold cursor-pointer transition-all duration-200 viewcube-face-bottom ${ - hoveredFace === 'BASE' ? 'bg-primary/40 border-primary text-white shadow-[0_0_10px_rgba(59,130,246,0.5)]' : 'bg-zinc-900/80 text-zinc-300' - }`} - > - BASE -
-
-
- - {/* Botões Rápidos de Alinhamento */} -
- - - -
- - {/* Info compacta */} -
- X: {Math.round(activeModel.fineTuning.rotX)}° - Y: {Math.round(activeModel.fineTuning.rotY)}° - Z: {Math.round(activeModel.fineTuning.rotZ)}° -
-
- ); -} diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 80d9c32..b08a0ef 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,4 +1,4 @@ -import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -20,16 +20,6 @@ const WIRE_COLORS = [ { label: 'Branco', value: '#e2e8f0' }, ]; -const colorClassMap: Record = { - '#8899aa': 'bg-[#8899aa]', - '#ef4444': 'bg-red-500', - '#22c55e': 'bg-green-500', - '#3b82f6': 'bg-blue-500', - '#eab308': 'bg-yellow-500', - '#f97316': 'bg-orange-500', - '#e2e8f0': 'bg-slate-200', -}; - export function ViewerControls() { const { opacity, setOpacity, renderMode, setRenderMode, @@ -44,9 +34,6 @@ export function ViewerControls() { selectionMode, setSelectionMode, selectedElementKeys, hiddenElementKeys, isolatedElementKeys, hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection, - desktopCameraMode, setDesktopCameraMode, - xrEnvironment, setXrEnvironment, - xrScaleMode, setXrScaleMode, } = useModelStore(); const activeModel = models.find(m => m.id === activeModelId); const hasSelection = selectedElementKeys.size > 0; @@ -125,13 +112,13 @@ export function ViewerControls() { size="sm" className="gap-2 h-9" onClick={() => { - const next = renderMode === 'solid' ? 'edges' : 'solid'; + const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid'; setRenderMode(next); }} > {renderMode === 'edges' ? : } - {renderMode === 'solid' ? 'Sólido' : 'Bordas'} + {renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wireframe' : 'Bordas'} @@ -141,7 +128,7 @@ export function ViewerControls() { @@ -151,7 +138,8 @@ export function ViewerControls() { {WIRE_COLORS.map((c) => ( - - {/* Seletor de Cenário 2D */} - - - - - -
- - Cenário de Fundo - - - - -
-
-
{/* Screenshot */} @@ -143,7 +139,6 @@ const Viewer = () => {
-
{/* Side panel */} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx new file mode 100644 index 0000000..cc59e3d --- /dev/null +++ b/src/pages/XRSession.tsx @@ -0,0 +1,875 @@ +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 * as THREE from 'three'; +import { useModelStore } from '@/stores/useModelStore'; +import { toast } from 'sonner'; +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 { 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 ─────────────────────────────────────────── +function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) { + const [rawScene, setRawScene] = useState(null); + const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]); + + useEffect(() => { + let active = true; + const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc'); + if (isIfc) { + fetch(sceneModel.url) + .then((res) => res.arrayBuffer()) + .then((buf) => parseIFCtoThree(buf)) + .then((threeScene) => { + if (active) setRawScene(threeScene); + }) + .catch((err) => console.error('[XRModel] IFC parsing error', err)); + } else { + const loader = new GLTFLoader(); + loader.load( + sceneModel.url, + (gltf) => { + if (active) setRawScene(gltf.scene); + }, + undefined, + (err) => console.error('[XRModel] GLTF loading error', err) + ); + } + return () => { + active = false; + }; + }, [sceneModel.url, sceneModel.fileName]); + + const ref = useRef(null); + useEffect(() => { + const g = ref.current; + if (!g) return; + registerModelLocalGroup(sceneModel.id, g); + return () => unregisterModelLocalGroup(sceneModel.id, g); + }, [sceneModel.id]); + + const fineTuning = sceneModel.fineTuning; + const opacity = useModelStore((s) => s.opacity); + const renderMode = useModelStore((s) => s.renderMode); + const wireframeColor = useModelStore((s) => s.wireframeColor); + const wireframeThickness = useModelStore((s) => s.wireframeThickness); + const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle); + const checklist = useModelStore((s) => s.checklist); + const measureMode = useModelStore((s) => s.measureMode); + + const modelInfo = useMemo(() => { + if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() }; + const box = new THREE.Box3().setFromObject(scene); + const size = new THREE.Vector3(); + const center = new THREE.Vector3(); + box.getSize(size); + box.getCenter(center); + return { size, center }; + }, [scene]); + + useEffect(() => { + if (!scene) return; + const hasRejected = checklist.some(i => i.status === 'rejected'); + const allApproved = checklist.every(i => i.status === 'approved'); + + scene.traverse((child) => { + if (child instanceof THREE.Mesh) { + if (Array.isArray(child.material)) { + child.material = child.material.map(m => m.clone()); + } else { + child.material = child.material.clone(); + } + + const toRemove: THREE.Object3D[] = []; + child.children.forEach(c => { + if (c.userData.__edgeLine) toRemove.push(c); + }); + toRemove.forEach(c => { + if (c instanceof THREE.LineSegments) { + c.geometry.dispose(); + (c.material as THREE.Material).dispose(); + } + child.remove(c); + }); + + const materials = Array.isArray(child.material) ? child.material : [child.material]; + materials.forEach((mat: THREE.Material) => { + if (mat instanceof THREE.MeshStandardMaterial) { + if (renderMode === 'edges' && !measureMode) { + mat.visible = false; + } else { + mat.visible = true; + const targetOpacity = measureMode ? 0.25 : opacity; + mat.transparent = targetOpacity < 1; + mat.opacity = targetOpacity; + mat.wireframe = renderMode === 'wireframe'; + if (renderMode === 'wireframe') { + mat.wireframeLinewidth = wireframeThickness; + mat.color.set(wireframeColor); + } else if (hasRejected) { + mat.color.setHSL(0, 0.7, 0.5); + } else if (allApproved) { + mat.color.setHSL(0.38, 0.7, 0.45); + } else { + mat.color.set(sceneModel.color); + } + } + mat.needsUpdate = true; + } + }); + + if ((renderMode === 'edges' || measureMode) && child.geometry) { + const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle); + const lineMat = new THREE.LineBasicMaterial({ + color: measureMode ? '#00f3ff' : wireframeColor, + linewidth: measureMode ? 2 : wireframeThickness, + }); + const lineSegments = new THREE.LineSegments(edgesGeo, lineMat); + lineSegments.userData.__edgeLine = true; + child.add(lineSegments); + } + } + }); + }, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]); + + const rotXRad = (fineTuning.rotX * Math.PI) / 180; + const rotYRad = (fineTuning.rotY * Math.PI) / 180; + const rotZRad = (fineTuning.rotZ * Math.PI) / 180; + const s = fineTuning.scale ?? 1; + const scaleRatio = useModelStore((st) => st.scaleRatio); + const renderFactor = scaleRatio?.factor ?? 1; + + if (!sceneModel.visible) return null; + if (!scene) return null; + + return ( + + + + + + + ); +} + +/** Renders all NON-active models as static background (placement + grab apply only to active). */ +function XRBackgroundModels() { + const models = useModelStore((s) => s.models); + const activeId = useModelStore((s) => s.activeModelId); + return ( + <> + {models.filter(m => m.id !== activeId).map((m) => ( + + ))} + + ); +} + +/** Renders the currently-active model (the one wrapped by grab/placement). */ +function XRActiveModel() { + const models = useModelStore((s) => s.models); + const activeId = useModelStore((s) => s.activeModelId); + const active = models.find(m => m.id === activeId); + if (!active) return null; + return ; +} + +// ─── ControllerFineTuning ────────────────────────────── +// Joystick for slow numeric adjustments. Disabled while any grip is held +// (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 session = useXR((s) => s.session); + const isActiveLocked = useModelStore((s) => { + const a = s.models.find(m => m.id === s.activeModelId); + return !!a?.locked; + }); + + useFrame(() => { + if (!session || isActiveLocked) return; + const inputSources = session.inputSources; + if (!inputSources) return; + + let leftAxes: number[] | null = null; + let rightAxes: number[] | null = null; + let leftTrig = 0; + let rightTrig = 0; + let gripHeld = false; + let anyGripHeld = false; + + for (const source of inputSources) { + const gp = source.gamepad; + if (!gp) continue; + const gripBtn = gp.buttons[2]; + const trigBtn = gp.buttons[1]; + const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0; + if (gripBtn?.pressed) gripHeld = true; + if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF + const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; + if (source.handedness === 'left' && gp.axes.length >= 4) { + leftAxes = [gp.axes[2], gp.axes[3]]; + leftTrig = trigVal; + } + if (source.handedness === 'right' && gp.axes.length >= 4) { + rightAxes = [gp.axes[2], gp.axes[3]]; + rightTrig = trigVal; + } + } + + // Grab takes priority — joystick is disabled while grabbing + 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; + + const baseStep = 0.001; + const baseRot = 0.1; + const deadzone = 0.15; + // Trigger modulation: 0 → 0.3×, 1 → 3× + const speedL = 0.3 + leftTrig * 2.7; + const speedR = 0.3 + rightTrig * 2.7; + // Quadratic acceleration on thumbstick value + const curve = (v: number) => Math.sign(v) * v * v; + + const modelStore = useModelStore.getState(); + const ft = { ...modelStore.fineTuning }; + + 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[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL; + } + 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[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR; + } + + setFineTuning(ft); + }); + + return null; +} + +// Componente de renderização de texto compatível e seguro para WebXR +function XRMeasurementText({ text, color }: { text: string; color: string }) { + const texture = useMemo(() => { + const canvas = document.createElement('canvas'); + canvas.width = 256; + canvas.height = 64; + const ctx = canvas.getContext('2d'); + if (ctx) { + // Limpa e desenha fundo semi-transparente + ctx.fillStyle = 'rgba(11, 18, 32, 0.85)'; + ctx.beginPath(); + // Desenha retângulo arredondado manualmente + const x = 0, y = 0, width = 256, height = 64, radius = 12; + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); + ctx.fill(); + + // Desenha borda sutil + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.stroke(); + + // Texto + ctx.fillStyle = color; + ctx.font = 'bold 24px monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, 128, 32); + } + const tex = new THREE.CanvasTexture(canvas); + tex.colorSpace = THREE.SRGBColorSpace; + return tex; + }, [text, color]); + + return ( + + + + + ); +} + +// Componente de linha nativa seguro para WebXR +export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) { + const points = useMemo(() => [ + new THREE.Vector3(a[0], a[1], a[2]), + new THREE.Vector3(b[0], b[1], b[2]) + ], [a, b]); + + const geometry = useMemo(() => { + return new THREE.BufferGeometry().setFromPoints(points); + }, [points]); + + return ( + + + + ); +} + +// Componente para renderizar medições que pertencem a um modelo específico +export function XRLocalModelMeasurements({ modelId }: { modelId: string }) { + const measurements = useModelStore((s) => s.measurements); + const modelMeasurements = useMemo(() => { + return measurements.filter(m => m.modelId === modelId); + }, [measurements, modelId]); + + return ( + <> + {modelMeasurements.map((m) => { + const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z]; + const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z]; + const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2]; + const isHole = m.kind === 'hole'; + const color = isHole ? '#f59e0b' : '#3b82f6'; + const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`; + + return ( + + {!isHole && ( + <> + + + + + + + + + + + )} + + + + + ); + })} + + ); +} + +// ─── Measurement overlay (reused from ModelViewer) ───── +function XRMeasurementOverlay() { + const measurements = useModelStore((s) => s.measurements); + const measurePoints = useModelStore((s) => s.measurePoints); + const snapPoint = useModelStore((s) => s.snapPoint); + const measureMode = useModelStore((s) => s.measureMode); + + const globalMeasurements = useMemo(() => { + return measurements.filter(m => !m.modelId); + }, [measurements]); + + return ( + <> + {measureMode && snapPoint && ( + + + + + )} + {measurePoints.length === 1 && ( + + + + + )} + {globalMeasurements.map((m) => { + const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z]; + const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z]; + const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2]; + const isHole = m.kind === 'hole'; + const color = isHole ? '#f59e0b' : '#3b82f6'; + const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`; + + return ( + + {!isHole && ( + <> + + + + + + + + + + + )} + + + + + ); + })} + + ); +} + +// ─── Snap handler for XR raycasting ──────────────────── +function XRSnapHandler() { + const { camera, scene, gl } = useThree(); + const measureMode = useModelStore((s) => s.measureMode); + const setSnapPoint = useModelStore((s) => s.setSnapPoint); + const addMeasurePoint = useModelStore((s) => s.addMeasurePoint); + const snapPoint = useModelStore((s) => s.snapPoint); + const raycaster = useMemo(() => new THREE.Raycaster(), []); + const mouse = useMemo(() => new THREE.Vector2(), []); + + useEffect(() => { + if (!measureMode) return; + const onMove = (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; + }; + gl.domElement.addEventListener('pointermove', onMove); + return () => gl.domElement.removeEventListener('pointermove', onMove); + }, [gl, mouse, measureMode]); + + 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 (!measureMode) { setSnapPoint(null); return; } + raycaster.setFromCamera(mouse, camera); + const intersects = raycaster.intersectObjects(scene.children, true); + const hit = intersects.find(i => { + const obj = i.object; + if (obj instanceof THREE.GridHelper) return false; + if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false; + if (obj.userData.__edgeLine) return false; + return obj instanceof THREE.Mesh; + }); + if (hit && hit.object instanceof THREE.Mesh) { + const canvas = gl.domElement; + const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10); + setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null); + } else { + setSnapPoint(null); + } + }); + + + // Click to measure + useEffect(() => { + if (!measureMode) return; + if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo + const onClick = (e: MouseEvent) => { + if (snapPoint) { + addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z }); + return; + } + 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 hit = intersects.find(i => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine); + if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); + }; + gl.domElement.addEventListener('click', onClick); + return () => gl.domElement.removeEventListener('click', onClick); + }, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]); + + useEffect(() => { + gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab'; + return () => { gl.domElement.style.cursor = 'grab'; }; + }, [measureMode, gl]); + + return null; +} + +// ─── XR Grid ─────────────────────────────────────────── +function XRGrid() { + const showGrid = useModelStore((s) => s.showGrid); + const gridY = useModelStore((s) => s.gridY); + if (!showGrid) return null; + return ( + + ); +} + +/** Auto-follow grid to model bottom when enabled. */ +function XRGridAutoFollower() { + const models = useModelStore((s) => s.models); + const { scene } = useThree(); + useEffect(() => { + if (!useModelStore.getState().gridAutoFollow) return; + const id = setTimeout(() => { + if (!useModelStore.getState().gridAutoFollow) return; + const box = new THREE.Box3(); + let has = false; + scene.traverse((obj) => { + if (obj instanceof THREE.Mesh && obj.geometry) { + if (obj.geometry instanceof THREE.SphereGeometry) return; + if (obj.geometry instanceof THREE.RingGeometry) return; + if (obj.userData.__edgeLine) return; + obj.updateWorldMatrix(true, false); + const b = new THREE.Box3().setFromObject(obj); + if (isFinite(b.min.y)) { + if (!has) { box.copy(b); has = true; } + else box.union(b); + } + } + }); + if (has) useModelStore.setState({ gridY: box.min.y - 0.005 }); + }, 150); + return () => clearTimeout(id); + }, [models, scene]); + 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(null); + const [liveViewers, setLiveViewers] = useState(0); + // Rig que envolve — locomoção por joystick escreve aqui. + const rigRef = useRef(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 ( +
+ {/* Header */} +
+
+ + +

+ Modo XR Imersivo +

+
+ +
+ {!inXR && ( + <> + + + + )} + {inXR && ( + + + XR Ativo + + )} +
+
+ + {/* 3D Canvas */} +
+ { + gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)); + gl.setClearColor(0x000000, 0); + }} + > + + + + + + + + + + {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 ? ( + + + { if (placementMode) setPlacementMode(false); }} + > + + + + ) : ( + { + setPlacementMode(false); + toast.success('Modelo posicionado na superfície!'); + }} + > + + { if (placementMode) setPlacementMode(false); }} + > + + + + )} + + + + + + + + {/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */} + 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 */} + + + {/* DEVKIT: fake controllers visible in scene + keyboard driver */} + {devkit && simXR && } + {/* DEVKIT: orbit camera in SimXR so you can navigate around the model */} + {simXR && ( + + )} + + ) : ( + <> + {/* Desktop preview (no headset): show all models with OrbitControls */} + + + + + + + + )} + + + + + + + + {/* Floating DOM HUD overlay — only visible OUTSIDE passthrough. + Inside AR, the DOM is occluded by the headset compositor; the + in-world XRHudInWorld replaces it. */} +
+ setFreeMove(!freeMove)} + placementMode={placementMode} + onTogglePlacement={() => setPlacementMode(!placementMode)} + snapToPlanes={snapToPlanes} + onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} + allowScale={allowScale} + onToggleAllowScale={() => setAllowScale(!allowScale)} + /> +
+ + {/* 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. */} +
+ setLiveCode(h?.code ?? null)} + onViewerCountChange={setLiveViewers} + /> +
+ + {/* DEVKIT: floating diagnostic panel + SimXR toggle */} + {devkit && ( + setSimXR((v) => !v)} /> + )} +
+
+ ); +}; + +export default XRSession; diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 437a677..e07045f 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -127,13 +127,12 @@ const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899']; type AnchorMode = 'tracking' | 'manual' | 'fine-tuning'; type InspectionResult = 'pending' | 'approved' | 'rejected'; -type RenderMode = 'solid' | 'edges'; +type RenderMode = 'solid' | 'wireframe' | 'edges'; export interface MeasurePoint { x: number; y: number; z: number; - modelId?: string; } export interface Measurement { @@ -156,7 +155,6 @@ export interface HoverInfo { value: number; // mm (diameter or length) /** For edges: the two endpoints in world coords. */ endpoints?: { a: MeasurePoint; b: MeasurePoint }; - modelId?: string; } export interface ChecklistItem { @@ -299,20 +297,6 @@ interface ModelStore { xrFeatures: XRFeatureFlags; setXRFeature: (key: keyof XRFeatureFlags, value: boolean) => void; resetXRFeatures: () => void; - - // ── WebXR HUD states persistent cache ──── - xrHudOpen: boolean; - setXrHudOpen: (open: boolean) => void; - xrHudTab: string; - setXrHudTab: (tab: string) => void; - - // ── WebXR Cenários Imersivos e Escalonamento ──── - xrEnvironment: 'passthrough' | 'office' | 'field'; - setXrEnvironment: (env: 'passthrough' | 'office' | 'field') => void; - xrScaleMode: 'tabletop' | 'realscale'; - setXrScaleMode: (mode: 'tabletop' | 'realscale') => void; - desktopCameraMode: 'orbit' | 'walk'; - setDesktopCameraMode: (mode: 'orbit' | 'walk') => void; } /** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */ @@ -580,7 +564,7 @@ export const useModelStore = create((set, get) => ({ const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz); const factor = state.scaleRatio?.factor ?? 1; const distanceMM = distanceWorldMM / factor; - const modelId = a.modelId ?? b.modelId ?? state.activeModelId ?? undefined; + const modelId = state.activeModelId ?? undefined; const aConv = modelId ? worldToModelLocal(modelId, a) : null; const bConv = modelId ? worldToModelLocal(modelId, b) : null; const attached = !!(aConv && bConv); @@ -618,7 +602,7 @@ export const useModelStore = create((set, get) => ({ return { measurements: state.measurements.slice(0, -1) }; }), registerHoverMeasurement: (info) => set((state) => { - const modelId = info.modelId ?? state.activeModelId ?? undefined; + const modelId = state.activeModelId ?? undefined; const toLocal = (p: MeasurePoint): { point: MeasurePoint; attached: boolean } => { const conv = modelId ? worldToModelLocal(modelId, p) : null; return conv ? { point: conv, attached: true } : { point: p, attached: false }; @@ -687,16 +671,4 @@ export const useModelStore = create((set, get) => ({ saveXRFeatures(next); set({ xrFeatures: next }); }, - - xrHudOpen: true, - setXrHudOpen: (open) => set({ xrHudOpen: open }), - xrHudTab: 'scene', - setXrHudTab: (tab) => set({ xrHudTab: tab }), - - xrEnvironment: 'passthrough', - setXrEnvironment: (env) => set({ xrEnvironment: env }), - xrScaleMode: 'realscale', - setXrScaleMode: (mode) => set({ xrScaleMode: mode }), - desktopCameraMode: 'orbit', - setDesktopCameraMode: (mode) => set({ desktopCameraMode: mode }), }));