Added camera switcher to Canvas

X-Lovable-Edit-ID: edt-20061032-6ed9-4916-9fa5-f4792809dfa9
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-24 20:26:40 +00:00
6 changed files with 165 additions and 74 deletions
+29 -68
View File
@@ -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 `<PerspectiveCamera>` / `<OrthographicCamera>` (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 `<XRGrabbable>`.
- Substituir `<ControllerFineTuning />` por um novo `<ControllerLocomotion />` (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?
+10 -3
View File
@@ -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 = () => {
+21 -1
View File
@@ -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() {
</span>
</Button>
{/* Camera projection toggle (Ortho ⇄ Persp) */}
<Button
variant="outline"
size="sm"
className="gap-2 h-9"
onClick={() => setCameraMode(cameraMode === 'ortho' ? 'persp' : 'ortho')}
title={cameraMode === 'ortho'
? 'Vista ortogonal (CAD) — clique para perspectiva'
: 'Vista em perspectiva — clique para ortogonal'}
>
{cameraMode === 'ortho'
? <Square className="h-3.5 w-3.5" />
: <Box className="h-3.5 w-3.5" />}
<span className="font-mono text-xs">
{cameraMode === 'ortho' ? 'Ortogonal' : 'Perspectiva'}
</span>
</Button>
{/* Edges color/thickness/threshold popover */}
{renderMode === 'edges' && (
<Popover>
+59 -1
View File
@@ -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 (
<OrthographicCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
zoom={snap.zoom}
near={-1000}
far={1000}
/>
);
}
return (
<PerspectiveCamera
makeDefault
position={snap.pos.toArray()}
up={snap.up.toArray()}
fov={50}
near={0.001}
far={1000}
/>
);
}
function LoadingFallback() {
return (
@@ -1182,6 +1236,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
}}
>
<CameraSwitcher />
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
@@ -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;
}}
/>
</Canvas>
);
}
+27 -1
View File
@@ -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;
+19
View File
@@ -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<ModelStore>((set, get) => ({
renderMode: 'solid',
setRenderMode: (renderMode) => set({ renderMode }),
cameraMode: loadCameraMode(),
setCameraMode: (cameraMode) => { saveCameraMode(cameraMode); set({ cameraMode }); },
showGrid: false,
setShowGrid: (showGrid) => set({ showGrid }),