diff --git a/.lovable/plan.md b/.lovable/plan.md index d318339..0d23d97 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,68 +1,71 @@ -# Giro da peça em torno do próprio eixo (botão do meio) - ## Objetivo -No modo **Posicionar**, permitir rotação da peça ativa em torno do seu **eixo longitudinal próprio** (a linha que liga os dois extremos da peça), usando o **botão do meio (roda) do mouse** enquanto se arrasta. +Tornar a ferramenta **Medir** confiável tanto no desktop (mouse) quanto no modo AR (controles Touch Plus), com snap de pontos a vértices/arestas, marcadores visíveis em qualquer escala e labels com texto sempre legível. -A rotação atual (botão direito) gira em torno do pivô do grupo (canto da peça). A nova precisa girar em torno do **centro geométrico da peça**, no eixo do seu maior comprimento — como rolar um perfil de aço em torno do seu próprio comprimento. +## Problemas identificados -## Mudança de mapeamento dos botões +1. **Mouse não cria pontos**: o `MeasureClickHandler` usa o evento DOM `click` no canvas, que conflita com o `onClick` interno do R3F no `` do `GLBModel` (`setActive`). O `stopPropagation` do R3F não pára o evento DOM, mas o caminho que chega lá ignora se o mouse arrastou, e o `pointerdown` global de `PositionDragHandler` (que está sempre montado) chama `setPointerCapture` quando `positionMode` está ligado — em modo Medir não captura, mas o handler `click` puro é frágil. Vamos trocar por `pointerdown/up` próprios com gate de "não foi arrasto". +2. **Sem feedback de "ponto A pendente"** claro no AR — o ponto fica pequeno demais quando escala é 1:50. +3. **Tamanho dos marcadores e do texto é fixo em unidades de mundo** (`sphereGeometry [0.003]`, `fontSize 0.008`), então some quando a peça está em 1:1 grande ou fica enorme em 1:100. +4. **AR só tem gatilho** — sem desfazer último ponto, sem limpar, sem indicação visual de "armando 2º ponto". +5. **Snap de vértice só funciona em desktop** (`SmartSnapHandler` lê mouse DOM). No AR não há snap. -| Botão | Antes | Depois | -|---|---|---| -| Esquerdo | Translada plano câmera | igual | -| Shift + Esquerdo | Profundidade (Z câmera) | igual | -| **Meio (roda)** | Profundidade (Z câmera) | **Giro axial em torno do eixo próprio** | -| Direito | rotY/rotX (pivô atual) | igual | -| Shift + Direito | rotZ | igual | +## O que vai ser construído -A profundidade continua acessível via Shift+Esquerdo (sem perda de função). +### A) Mouse (desktop) -## Como calcular o "eixo próprio" +- Substituir `MeasureClickHandler` por handler `pointerdown` → `pointerup` no canvas: + - Marca posição em `pointerdown`; se em `pointerup` o mouse moveu < 4 px e `measureMode` está on → adiciona ponto. + - Usa `snapPoint` se houver; senão raycast. + - Ignora botão direito (reservado para OrbitControls eventual no futuro). +- Cursor `crosshair` apenas quando `measureMode`. +- Adicionar **tecla Esc** para cancelar 1º ponto pendente, **tecla Z** (ou Ctrl+Z) para desfazer última medição. -No `PositionDragHandler` (`src/components/three/ModelViewer.tsx`), quando o `pointerdown` ocorrer com `button === 1`: +### B) Snap inteligente compartilhado -1. Localizar o `THREE.Object3D` raiz da peça ativa na cena via `scene.getObjectByName(...)` ou um `userData.modelId`. Já existe um `GLBModel` montado; usar `scene.traverse` filtrando por `userData.modelId === activeId` (ajustar `GLBModel` para marcar `userData.modelId` se ainda não estiver). -2. Calcular `THREE.Box3().setFromObject(group)` em coordenadas mundo → obter `size` e `center`. -3. O eixo próprio = direção do maior lado da bbox local. Para isso, calcular a bbox em **espaço local** do grupo (`new THREE.Box3().setFromObject(group)` aplicado depois de zerar matriz, ou iterar geometrias). Mais simples: comparar `size.x`, `size.y`, `size.z` da bbox local → eixo local = (1,0,0), (0,1,0) ou (0,0,1) correspondente ao maior. -4. Transformar esse eixo local para mundo aplicando a `group.matrixWorld` (apenas a parte rotacional via `Vector3.transformDirection`). -5. Guardar em refs: `axisWorld: Vector3`, `centerWorld: Vector3` (centro da bbox mundo). +- Atualmente `SmartSnapHandler` (vértices) e `HoverDetector` (furo/aresta) só funcionam pelo mouse. Vamos: + - Manter o snap de vértice via `findNearestVertex` (já existe em `SmartMeasure.ts`). + - Adicionar **snap a aresta mais próxima** (já há `findNearestEdgeSegment`) como fallback quando não há vértice perto — prioridade: vértice > aresta > ponto bruto do raycast. + - Expor uma função utilitária `resolveSnap(hitPoint, hitObject, camera, canvasSize)` reutilizada pelo mouse e pelo AR. -## Aplicação da rotação no `pointermove` +### C) AR (joysticks) -- Sensibilidade: `0.5° por pixel` de `dx` (movimento horizontal natural para "rolar"). -- Converter para radianos: `angle = dx * sens * Math.PI/180`. -- Construir quaternion: `qDelta = new Quaternion().setFromAxisAngle(axisWorld, angle)`. -- Precisamos atualizar `fineTuning.rotX/Y/Z` (graus, ordem Euler atual usada pelo grupo). Estratégia: - 1. Ler quaternion atual do grupo: `qCurrent = group.quaternion.clone()`. - 2. Aplicar `qNew = qDelta * qCurrent` (rotação em mundo aplica-se à esquerda). - 3. Mas `fineTuning` é aplicado **em cima** de uma orientação base do modelo. Para preservar isso, calcular `qBase = qCurrent.clone().premultiply(qFTInverse)` onde `qFT` é a rotação atual do fineTuning como quaternion. Então o novo fineTuning quaternion = `qBase.inverse() * qNew`. - 4. Converter para Euler na mesma ordem usada hoje (provavelmente `'XYZ'`) e gravar `rotX/Y/Z` em graus. +Reescrever `XRControllerMeasure.tsx` para: -Para evitar complexidade da composição com `qBase`: alternativa mais simples e suficiente — **rotacionar diretamente o grupo via quaternion e também transladar para compensar a diferença entre pivô do grupo e centro da bbox**. Como `fineTuning.pos{X,Y,Z}` é a única fonte de translação, calcular: +- **Gatilho direito (trigger)**: adiciona ponto (com snap). +- **Botão A (gamepad button 4) direito**: desfaz último ponto / última medição. +- **Botão B (gamepad button 5) direito**: limpa todas as medições. +- **Gatilho esquerdo**: alterna *snap to vertex* ON/OFF (com hysteresis para evitar toggling) — útil em peças orgânicas. +- **Mira laser visível**: render de uma linha fina saindo do controle direito até o primeiro hit, com uma esfera no ponto que será marcado (verde se snap, amarelo se ponto bruto). Tamanho proporcional à distância do controle ao alvo. +- Hysteresis já existe para o gatilho; aplicar o mesmo para A/B. -- Antes da rotação: `offset = centerWorld - group.position`. -- Depois: `newOffset = qDelta.applyVector(offset)`. -- `deltaPos = offset - newOffset` (em mundo) → converter para local (dividindo por `scaleRatio.factor`) e somar a `posX/posY/posZ`. +### D) Marcadores e labels proporcionais -Combinada com a atualização Euler descrita acima, isso garante que o **centro geométrico fique fixo na tela** enquanto a peça gira em torno do próprio eixo. +- `PointMarker`: raio passa a ser função da distância da câmera ao ponto, calculado em `useFrame` via `setScalar` no mesh — alvo: ~6 px de raio na tela em qualquer escala/zoom. +- Label ``: substituir o `distanceFactor={1}` fixo por um valor calculado a partir da distância câmera↔ponto (mantém o texto com ~12 px na tela). +- Linha verde da medição: usar `Line` do drei com `lineWidth` constante em pixels (já é o comportamento padrão). +- No AR usar `` do `@react-three/drei` (já importado em `XRHudInWorld`) em vez de `` para os labels — render correto em XR. -## Resumo dos arquivos +### E) Botões de UI -- **`src/components/three/ModelViewer.tsx`** - - Marcar o grupo de cada `GLBModel` com `userData.modelId = model.id` (se ainda não estiver). - - Em `PositionDragHandler`: - - No `pointerdown` com `button === 1`: localizar grupo da peça ativa, calcular bbox local, determinar eixo próprio, salvar `axisWorld` e `centerWorld` em refs. - - No `pointermove` com `button === 1`: aplicar rotação axial + correção de translação como descrito, gravar via `setFineTuning({ rotX, rotY, rotZ, posX, posY, posZ })`. - - Remover o uso de "Meio = profundidade" (mantém apenas Shift+Esquerdo). +- **Desktop (`ViewerControls`)**: adicionar botão "Desfazer ponto" ao lado do "Limpar medições" quando `measurePoints.length === 1`. +- **AR HUD (`XRHudInWorld`)**: já existe "✕ Medidas". Adicionar legenda visual abaixo: "Gatilho R: marcar · A: desfazer · B: limpar · Gatilho L: snap". -- **`src/components/ViewerControls.tsx`** - - Atualizar o `title` do botão Posicionar para refletir o novo mapeamento: `Esquerdo: mover · Shift+Esq: profundidade · Meio: girar no próprio eixo · Direito: rotação livre · Shift+Dir: rotZ`. +## Arquivos afetados -## Detalhes técnicos +| Arquivo | Mudança | +|---|---| +| `src/components/three/ModelViewer.tsx` | Reescrever `MeasureClickHandler` (pointerdown/up + gate de arrasto); `PointMarker` e `MeasurementOverlay` com tamanho/distanceFactor dinâmicos por frame | +| `src/components/three/SmartMeasure.ts` | Adicionar helper `resolveSnap()` que combina vértice + aresta | +| `src/components/three/XRControllerMeasure.tsx` | Adicionar handlers A/B + gatilho esquerdo, render de laser+esfera, snap via raycast no frame XR | +| `src/components/three/XRHudInWorld.tsx` | Legenda de atalhos dos controles na aba Tools | +| `src/components/ViewerControls.tsx` | Botão "Desfazer ponto"; atalhos Esc/Z | +| `src/stores/useModelStore.ts` | Action `undoLastMeasurePoint()` (e `undoLastMeasurement()` se nenhum ponto pendente) | -- Usar `THREE.Box3.setFromObject` cuidando para chamar `updateMatrixWorld(true)` antes. -- Para a bbox local: clonar o grupo ou aplicar inverso da matriz mundo nas posições, ou simplesmente iterar `child.geometry.boundingBox` se for um único mesh. Para robustez com vários meshes filhos, somar via `Box3.expandByObject` após copiar `matrixWorld` e remover translação. -- `Quaternion.applyVector` não existe diretamente — usar `vector.clone().applyQuaternion(qDelta)`. -- Conversão Euler→graus deve usar a mesma ordem em que `fineTuning` é aplicado hoje (verificar no JSX do `GLBModel`; provavelmente `rotation={[degToRad(rotX), degToRad(rotY), degToRad(rotZ)]}` com ordem padrão XYZ). -- Persistência: `setFineTuning` já chama `savePlacement`, então a posição/rotação ficam salvas como já acontece. +## Critérios de aceite + +- Clicar em uma peça com Medir ON cria marcador amarelo; segundo clique fecha e gera label verde em mm reais (já compensado por escala — lógica do store mantida). +- Snap a vértice mostra anel amarelo (`SnapRing`) e clique usa esse ponto. +- Marcadores e labels permanecem do mesmo tamanho aparente em qualquer escala (1:1, 1:10, 1:100). +- No AR, gatilho direito marca ponto sob o laser; A desfaz; B limpa; gatilho esquerdo alterna snap. +- Esc cancela 1º ponto pendente; Z desfaz última medição (desktop). \ No newline at end of file diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 058c7b6..644b04b 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 } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2 } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -20,7 +20,7 @@ const WIRE_COLORS = [ export function ViewerControls() { const { opacity, setOpacity, renderMode, setRenderMode, - measureMode, setMeasureMode, measurements, clearMeasurements, + measureMode, setMeasureMode, measurements, clearMeasurements, undoLastMeasurement, measurePoints, addScreenshot, showGrid, setShowGrid, wireframeColor, setWireframeColor, @@ -170,6 +170,7 @@ export function ViewerControls() { size="sm" className="gap-2 h-9" onClick={() => setMeasureMode(!measureMode)} + title="Clique para marcar pontos · Esc desfaz ponto pendente · Z desfaz última medição" > @@ -181,6 +182,18 @@ export function ViewerControls() { + {(measurePoints.length > 0 || measurements.length > 0) && ( + + )} + {measurements.length > 0 && (