diff --git a/.lovable/plan.md b/.lovable/plan.md index 15f5a1c..46d731b 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,81 +1,42 @@ + ## Objetivo -Alinhar o comportamento dos controles Touch Plus (Meta Quest 3) ao padrão pedido: +Adicionar alternância entre câmera **Perspectiva** e **Ortográfica** no viewer principal, com **Ortográfica como padrão**, e tornar a calibração mais confiável usando a vista ortogonal (sem distorção de perspectiva, normais alinhadas com os eixos da tela). -- **Grip** = único responsável por mover a peça (pan, zoom, orbit). -- **Trigger** = apenas seleção, medição e ações pontuais (nunca move/rotaciona a peça). -- **Thumbstick** = navegação do usuário (teletransporte/recuar/girar a câmera), nunca mais ajuste fino do modelo. -- **Botão Menu** = mantém comportamento nativo (não tocar). +## Por que isso resolve a calibração -## Mapeamento alvo +Em perspectiva, ao clicar numa face "de frente", a normal capturada pelo raycaster é a normal real do triângulo, mas o usuário tende a clicar em faces inclinadas achando que estão alinhadas — o que gera pares de vetores ruins. Em **ortográfica + vista alinhada ao cubo (FRENTE/TOPO/DIR)**, a face que aparece "de frente" é geometricamente perpendicular à câmera, então a chance de capturar a normal certa é muito maior. Também damos uma dica visual (snap angular) para validar o clique. -```text -Mão Botão Ação -───────────────────────────────────────────────────────── -Esquerda/Direita Grip (1 mão) Pan da peça (translação 6DOF, sem rotação) -Ambas as mãos Grip + afastar Zoom uniforme (escala) -Ambas as mãos Grip + giro oposto Orbitar/rotacionar a peça -Direita Trigger Selecionar componente / adicionar ponto de medida -Esquerda Trigger Alternar snap de vértice (já existe) -Direita Botão A Desfazer última medida (já existe) -Direita Botão B Limpar medidas (já existe) -Thumbstick Frente + soltar Teletransporte para o ponto apontado -Thumbstick Lados Snap-rotate da câmera (±30°) -Thumbstick Trás Recuar o usuário (passo) -``` +## Mudanças -## Mudanças no código +### 1. Toggle de câmera no viewer (`ModelViewer.tsx` + `ViewerControls.tsx`) +- Estado `cameraMode: 'ortho' | 'persp'` no `useModelStore` (default `'ortho'`, persistido em localStorage junto com as demais prefs visuais). +- Em `ModelViewer.tsx`, condicionar `` / `` (drei) como `makeDefault`, compartilhando posição/target com o `OrbitControls`. Ao trocar de modo, copiar `position`, `target` e calcular `zoom` ortográfico a partir da distância atual para evitar "pulo" visual. +- `OrbitControls` permanece, apenas com `enableRotate` igual; em ortho mantemos rotação livre (estilo CAD). Pan/zoom continuam funcionando. +- Botão de alternância adicionado em `ViewerControls.tsx` ao lado dos controles de render (Sólido / Bordas), com ícones (ex.: `Box` para perspectiva, `Square` para ortho) e tooltip. Atalho de teclado opcional `O`/`P`. -### 1. `src/components/three/XRGrabbable.tsx` +### 2. ViewCube já existente +- Continua funcionando igual, mas agora ao clicar numa face em modo ortográfico o resultado é uma vista verdadeiramente axial (sem foreshortening), o que é o comportamento esperado de CAD. -- **One-hand grip**: passar a aplicar **apenas translação** (pan). Hoje aplica matriz completa do grip → leva rotação junto. Vou capturar o offset de posição e atualizar só `group.position`, mantendo `quaternion` e `scale`. -- **Two-hand grip**: manter rotação pelo eixo entre as mãos + **forçar `allowScale = true` sempre** (zoom passa a vir do grip, não depende mais do toggle do HUD). -- Remover a prop `allowScale` da assinatura (ou ignorar e tratar como `true`). +### 3. Calibração refinada (`ViewCube.tsx` + `viewCubeBus.ts`) +- Ao iniciar a calibração, **forçar modo ortográfico** automaticamente (salvando o modo anterior para restaurar ao concluir/cancelar). +- Em cada passo "await-cube-N", após o usuário clicar a face do cubo, **animar a câmera principal** para essa vista (já temos `requestView`) — assim a próxima clique na peça é feito olhando a face de frente. +- Adicionar **snap de normal**: ao capturar a normal da face da peça, se ela estiver a <10° de um eixo principal (X/Y/Z), arredondar para esse eixo. Isso elimina ruído de faces ligeiramente chanfradas. +- Mostrar no hint: "olhando para FRENTE — clique na face frontal da peça" (texto dinâmico do passo). +- Manter o fluxo de 4 cliques + verificação opcional já aprovado. -### 2. `src/pages/XRSession.tsx` +### 4. Persistência +- `cameraMode` salvo globalmente (preferência do app, não por peça). +- `calibrationQuat` por peça continua como já está. -- Remover o estado `allowScale` e os toggles correspondentes nas duas instâncias de ``. -- Substituir `` por um novo `` (ver item 4) — o joystick não deve mais editar `fineTuning` do modelo. -- Garantir que `XRHitTestPlacement` só consome `onSelect` (trigger) quando `placementMode === true`; fora disso o trigger é roteado integralmente para `XRControllerMeasure`. +## Arquivos afetados -### 3. `src/components/XRHud.tsx` e `src/components/three/XRHudInWorld.tsx` +- `src/stores/useModelStore.ts` — campo `cameraMode` + setter. +- `src/components/three/ModelViewer.tsx` — alternância de câmera, sincronização de posição/zoom. +- `src/components/ViewerControls.tsx` — botão de alternância Persp/Ortho. +- `src/components/ViewCube.tsx` — força ortho durante calibração, anima vista após cada cube-click. +- `src/components/three/viewCubeBus.ts` — snap angular da normal capturada. -- Remover botão/toggle “Permitir escala” (`allowScale`) — agora é sempre via grip duplo. -- Remover/relabelar o toggle “Movimento livre” (`freeMove`) já que o joystick deixa de ajustar o modelo; manter apenas se ainda fizer sentido para outra função (caso não, remover prop também de `XRSession.tsx`). +## Pergunta -### 4. Novo `src/components/three/ControllerLocomotion.tsx` - -Componente que substitui `ControllerFineTuning`. Lê `useXR().session` e implementa: - -- **Joystick esquerdo (ou ambos, configurável)**: - - `axes[1] < -0.7` mantido por ~200 ms → dispara teleport: raycast a partir do controle direito contra `scene.children` no plano horizontal mais próximo; aplica `xrOrigin.position` para reposicionar. - - `axes[1] > 0.7` → recua o `XROrigin` em passos de 0.3 m por trigger. - - `axes[0]` cruzando ±0.7 (com histerese) → snap-rotate de ±30° no `XROrigin.rotation.y`. -- Usar a API `XROrigin` já montada em `XRSession.tsx` (referência via `useRef` ou store) para escrever pose. -- Desligar tudo se `lockedActive` (modelo travado) — locomoção do usuário ainda funciona, só não move o modelo. - -### 5. `src/components/three/XRControllerMeasure.tsx` - -- Sem mudanças funcionais — já usa apenas trigger e botões A/B. Apenas confirmar que o early-return `(!measureMode && !selectionMode)` continua: quando nenhum dos modos está ativo, o trigger não dispara nada relacionado à medição (e não interfere em `XRHitTestPlacement`, que respeita `placementMode`). - -### 6. `src/components/three/XRHitTestPlacement.tsx` - -- Garantir que o handler `onSelect` (trigger) verifica `placementMode` antes de colocar o modelo. Hoje o tap-plane invisível pode estar capturando o select mesmo fora do placement — adicionar guarda explícita e `visible={placementMode}`. - -## Validação - -1. **Desktop SimXR** (devkit): pressionar Q/W (grip simulado) move a peça; pressionar A/S (trigger simulado) não muda pose nenhuma. -2. **Quest 3 real (passthrough)**: - - Grip único arrasta a peça sem rotacionar. - - Dois grips afastando = zoom; dois grips em rotação oposta = orbit. - - Trigger direito fora dos modos medir/selecionar/colocar = nada acontece. - - Trigger direito em modo Selecionar = seleciona componente IFC. - - Trigger direito em modo Medir = adiciona ponto com snap. - - Joystick esquerdo para frente = teletransporte; lados = snap-rotate; trás = recua. -3. Logs `[XR][grab]` continuam mostrando início/fim de one-hand e two-hand. - -## Fora do escopo - -- Não mexer no botão Menu (Universal Menu do Quest é nativo). -- Não alterar handlers DOM/mouse do modo desktop (Viewer). -- Não tocar em RLS, store de dados ou backend. +Te parece bom seguir com **ortográfica como default** e **forçar ortho durante calibração** com snap automático nas normais? Ou prefere deixar o usuário trocar manualmente e a calibração apenas "sugerir" o modo ortho via toast? diff --git a/src/components/ViewCube.tsx b/src/components/ViewCube.tsx index 0e79926..ab44476 100644 --- a/src/components/ViewCube.tsx +++ b/src/components/ViewCube.tsx @@ -162,14 +162,18 @@ export function ViewCube() { const progress = isDone ? 1 : calibration.progress; const onClickCalibrar = () => { + const store = useModelStore.getState(); if (isCalibrating) { - // Cancel mid-flow + // Cancel mid-flow — restore previous camera mode if we forced ortho. + const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined; + if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; } cancelCalibration(); toast.info('Calibração cancelada'); return; } if (isDone) { - // Reset/finish state + const prev = (window as any).__tsxr_prevCamMode as 'ortho' | 'persp' | undefined; + if (prev) { store.setCameraMode(prev); (window as any).__tsxr_prevCamMode = undefined; } cancelCalibration(); return; } @@ -181,8 +185,11 @@ export function ViewCube() { toast.error('Peça travada — destranque o cadeado para calibrar'); return; } + // Force orthographic view for accurate face picking. + (window as any).__tsxr_prevCamMode = store.cameraMode; + if (store.cameraMode !== 'ortho') store.setCameraMode('ortho'); startCalibration(active.id); - toast('Calibração iniciada — clique numa face do cubo, depois na face correspondente da peça', { duration: 4000 }); + toast('Calibração em vista ortogonal — clique numa face do cubo, depois na face correspondente da peça', { duration: 4000 }); }; const onResetCalibration = () => { diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index cee2c2d..b7eb4cf 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 } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Square } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -35,6 +35,7 @@ export function ViewerControls() { selectionMode, setSelectionMode, selectedElementKeys, hiddenElementKeys, isolatedElementKeys, hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection, + cameraMode, setCameraMode, } = useModelStore(); const activeModel = models.find(m => m.id === activeModelId); const hasSelection = selectedElementKeys.size > 0; @@ -120,6 +121,25 @@ export function ViewerControls() { + {/* Camera projection toggle (Ortho ⇄ Persp) */} + + + {/* Edges color/thickness/threshold popover */} {renderMode === 'edges' && ( diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index dd655f4..0b631b3 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -1,6 +1,6 @@ import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react'; import { Canvas, useThree, useFrame } from '@react-three/fiber'; -import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; +import { OrbitControls, useGLTF, Grid, Html, Line, PerspectiveCamera, OrthographicCamera } from '@react-three/drei'; import { Loader2 } from 'lucide-react'; import * as THREE from 'three'; import { useModelStore, type SceneModel } from '@/stores/useModelStore'; @@ -88,6 +88,60 @@ function ViewCubeAnimator() { return null; } +/** Switches between perspective and orthographic projection while keeping + * the visual framing (position, target, on-screen size of the scene). */ +function CameraSwitcher() { + const mode = useModelStore((s) => s.cameraMode); + const { size } = useThree(); + // Snapshot current view from whichever camera is active right now, before swap. + const snap = useMemo(() => { + const c = mainCameraRef.current; + const ctrl = mainControlsRef.current; + const target = (ctrl?.target ?? new THREE.Vector3()).clone(); + const pos = c ? c.position.clone() : new THREE.Vector3(2, 2, 2); + const up = c ? c.up.clone() : new THREE.Vector3(0, 1, 0); + const dist = pos.distanceTo(target); + // Compute ortho zoom that matches current perspective framing. + let zoom = 100; + if (c && (c as THREE.PerspectiveCamera).isPerspectiveCamera) { + const fov = ((c as THREE.PerspectiveCamera).fov ?? 50) * (Math.PI / 180); + const worldHeight = 2 * Math.max(dist, 0.001) * Math.tan(fov / 2); + zoom = Math.max(1, size.height / Math.max(worldHeight, 1e-6)); + } else if (c && (c as THREE.OrthographicCamera).isOrthographicCamera) { + zoom = (c as THREE.OrthographicCamera).zoom; + } + return { target, pos, up, zoom }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mode]); + // Sync OrbitControls target after camera swap. + useEffect(() => { + const ctrl = mainControlsRef.current; + if (ctrl?.target) ctrl.target.copy(snap.target); + ctrl?.update?.(); + }, [snap]); + if (mode === 'ortho') { + return ( + + ); + } + return ( + + ); +} function LoadingFallback() { return ( @@ -1182,6 +1236,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) { gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio)); }} > + @@ -1210,12 +1265,15 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) { dampingFactor={0.1} minDistance={0.05} maxDistance={50} + minZoom={5} + maxZoom={5000} enabled={!positionMode} ref={(c: any) => { mainControlsRef.current = c; }} /> + ); } diff --git a/src/components/three/viewCubeBus.ts b/src/components/three/viewCubeBus.ts index 643247f..85bbadc 100644 --- a/src/components/three/viewCubeBus.ts +++ b/src/components/three/viewCubeBus.ts @@ -128,9 +128,31 @@ export function pushCubeFace(dirWorld: THREE.Vector3) { } else { return; } + // Auto-rotate the main camera to look straight at the picked cube face + // so the user sees the corresponding model face head-on (especially helpful + // in orthographic mode where this guarantees a perpendicular click). + try { requestView(dirWorld.clone()); } catch {} notify(); } +/** Snap a unit vector to the nearest principal axis (±X/±Y/±Z) when within + * `maxDeg` degrees of it. Returns the snapped vector or the input unchanged. */ +function snapToPrincipalAxis(v: THREE.Vector3, maxDeg = 12): THREE.Vector3 { + const cosT = Math.cos(THREE.MathUtils.degToRad(maxDeg)); + const axes = [ + new THREE.Vector3(1, 0, 0), new THREE.Vector3(-1, 0, 0), + new THREE.Vector3(0, 1, 0), new THREE.Vector3(0, -1, 0), + new THREE.Vector3(0, 0, 1), new THREE.Vector3(0, 0,-1), + ]; + let best = v; + let bestDot = cosT; + for (const a of axes) { + const d = v.dot(a); + if (d > bestDot) { bestDot = d; best = a.clone(); } + } + return best; +} + /** * Called when user clicks a model face during calibration. * @param normalWorld world-space face normal of the clicked face @@ -142,7 +164,11 @@ export function pushModelFaceNormal(normalWorld: THREE.Vector3, calGroupWorldQua const cubeWorld = calibration.pendingCube; const inv = calGroupWorldQuat.clone().invert(); const cubeLocal = cubeWorld.clone().applyQuaternion(inv).normalize(); - const modelLocal = normalWorld.clone().applyQuaternion(inv).normalize(); + const modelLocalRaw = normalWorld.clone().applyQuaternion(inv).normalize(); + // Snap near-axial picks (chamfered or slightly off faces) to the closest + // principal axis. This dramatically improves robustness when picking faces + // from an orthographic head-on view. + const modelLocal = snapToPrincipalAxis(modelLocalRaw, 12); calibration.pairs.push({ cube: cubeLocal, model: modelLocal }); calibration.pendingCube = null; diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index cd1821b..82680f3 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -5,6 +5,18 @@ import { sendRemoteLog } from '@/lib/remoteLogger'; // ── Persistência de placement (fineTuning + escala) e flags WebXR ───── const PLACEMENT_KEY = 'tsxr_placements_v1'; const XRFEAT_KEY = 'xrFeatures'; +const CAMERA_MODE_KEY = 'tsxr_camera_mode_v1'; + +function loadCameraMode(): 'ortho' | 'persp' { + try { + const raw = localStorage.getItem(CAMERA_MODE_KEY); + if (raw === 'persp' || raw === 'ortho') return raw; + } catch {} + return 'ortho'; +} +function saveCameraMode(m: 'ortho' | 'persp') { + try { localStorage.setItem(CAMERA_MODE_KEY, m); } catch {} +} export interface XRFeatureFlags { handTracking: boolean; @@ -230,6 +242,10 @@ interface ModelStore { renderMode: RenderMode; setRenderMode: (mode: RenderMode) => void; + /** Camera projection used in the viewer. Ortho is the default for CAD-style work. */ + cameraMode: 'ortho' | 'persp'; + setCameraMode: (m: 'ortho' | 'persp') => void; + showGrid: boolean; setShowGrid: (show: boolean) => void; @@ -508,6 +524,9 @@ export const useModelStore = create((set, get) => ({ renderMode: 'solid', setRenderMode: (renderMode) => set({ renderMode }), + cameraMode: loadCameraMode(), + setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); }, + showGrid: false, setShowGrid: (showGrid) => set({ showGrid }),