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:
+29
-68
@@ -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?
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
Reference in New Issue
Block a user