From cffa66c6df70e3c22e17dfde75d33f355ad21cb7 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:48:16 +0000 Subject: [PATCH 1/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- .lovable/plan.md | 71 ++++++++++++++++++++++++------------------------ 1 file changed, 35 insertions(+), 36 deletions(-) diff --git a/.lovable/plan.md b/.lovable/plan.md index b311a67..2410f05 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,44 +1,43 @@ -# Plano: Mini-menu de Captura (Foto + Vídeo) no Modo AR -## Objetivo -Adicionar dentro do HUD flutuante do modo AR um pequeno menu de captura com: -- **📸 Foto** — screenshot do canvas (já existe lógica, expor no AR) -- **● Gravar / ⏸ Pausar / ▶ Retomar / ■ Parar** — gravação de vídeo do canvas WebXR +## Modo Posicionar (desktop) -## O que será implementado +Permite que, antes de entrar no AR, o usuário mova e gire a peça **ativa** com o mouse — exatamente como faria com o controle no AR. As outras peças permanecem travadas. Ao desativar o botão, todas ficam fixas nas posições atuais e o estado persiste (já temos `savePlacement` por `fileName`). -### 1. Novo hook `useCanvasRecorder` (`src/hooks/useCanvasRecorder.ts`) -- Usa `canvas.captureStream(30)` + `MediaRecorder` (codec `video/webm;codecs=vp9` com fallback p/ vp8). -- Estados expostos: `status` (`idle | recording | paused`), `elapsedMs`. -- Métodos: `start()`, `pause()`, `resume()`, `stop()`. -- Ao parar: monta `Blob` webm, gera `URL.createObjectURL`, dispara download automático (`tracksteel-AR-.webm`) e também salva uma referência opcional para galeria futura. -- Captura screenshot via `canvas.toDataURL('image/png')` reutilizando o store (`addScreenshot`) — mesma lógica já presente em `XRHud.tsx`. +### Comportamento -### 2. Nova aba "Captura" no HUD 3D (`src/components/three/XRHudInWorld.tsx`) -- Adicionar tab `capture` (ícone 🎥) na barra de tabs (junto com Cena, Ferram., Inspeção, Compart., WebXR). -- Componente `CaptureTab` com botões 3D (`XR3DButton`): - - **📸 Foto** — chama screenshot, mostra toast "Foto capturada". - - **● REC** (vermelho quando gravando) — inicia/para gravação. - - **⏸ Pause / ▶ Resume** — alterna pausa (desabilitado quando idle). - - **■ Stop** — finaliza e baixa o arquivo. - - Texto de status: `● 00:12` em vermelho enquanto grava, amarelo quando pausado. -- Indicador visual flutuante (pequeno círculo vermelho pulsante) visível mesmo com painel fechado quando estiver gravando — anexado ao `headLockRef`. +- Botão **Posicionar** (ícone Move3D) na barra `ViewerControls`, ao lado de Medir. +- Quando ativo: + - `OrbitControls` é desabilitado (não conflitar com o arrasto da peça). + - Cursor vira `move`. + - Apenas a peça com `activeModelId` responde ao mouse. Clicar em outra peça troca a ativa e passa a manipulá-la (as outras voltam a ficar travadas). + - **Arrastar com botão esquerdo** → translada no plano da câmera (X/Y da tela projetados para o mundo). + - **Shift + arrastar esquerdo** ou **arrastar com botão do meio** → translada no eixo Z (profundidade) usando o movimento vertical do mouse. + - **Arrastar com botão direito** → rotaciona: movimento horizontal = rotY (yaw), movimento vertical = rotX (pitch). **Shift + direito** = rotZ (roll). + - Cada movimento atualiza `fineTuning` via `setFineTuning` da peça ativa, o que já dispara `savePlacement` (persistência entre sessões/entrar-sair do AR — mesma chave usada hoje). +- Quando desativado: tudo volta ao normal (Orbit ativo, peças travadas onde foram deixadas). Modo Medir e Posicionar são mutuamente exclusivos. -### 3. Integração -- O hook recebe o `` via `document.querySelector('canvas')` (mesmo padrão usado em `XRHud.tsx`/`handleScreenshot`). -- Nenhuma mudança no store global é necessária — o estado de gravação vive no hook, instanciado no nível do `XRSession.tsx` e passado por props ao `XRHudInWorld`, OU mantido dentro do próprio `CaptureTab` via singleton simples. +### Arquivos a alterar -## Detalhes técnicos -- `MediaRecorder` com `canvas.captureStream(30)` funciona dentro de sessão WebXR no Quest 3 (Chromium-based browser do Quest suporta). -- Áudio do microfone **não** será incluído (pode ser adicionado depois se solicitado). -- Vídeo gerado é `.webm`; o usuário pode converter para `.mp4` fora do app se quiser. -- O download é feito ao parar a gravação — o arquivo aparece no armazenamento do Quest (pasta Downloads do browser). +1. **`src/stores/useModelStore.ts`** + - Adicionar `positionMode: boolean` + `setPositionMode(on)`. + - Em `setPositionMode(true)` desligar `measureMode`; e vice-versa em `setMeasureMode`. -## Arquivos afetados -- `src/hooks/useCanvasRecorder.ts` (novo) -- `src/components/three/XRHudInWorld.tsx` (nova tab + CaptureTab + indicador REC) +2. **`src/components/three/ModelViewer.tsx`** + - Novo componente `PositionDragHandler` (dentro do Canvas) que: + - Escuta `pointerdown/move/up` no `gl.domElement` quando `positionMode` está on e existe `activeModelId`. + - Converte delta de pixels em delta de mundo usando a distância câmera↔peça e o FOV (`2 * dist * tan(fov/2) / canvasHeight`), depois divide pelo `scaleRatio.factor` para gravar em coordenadas locais coerentes com `fineTuning`. + - Aplica via `setFineTuning({ posX: ..., posY: ..., posZ: ..., rotX: ..., rotY: ..., rotZ: ... })`. + - Em `OrbitControls`, definir `enabled={!positionMode && !measureMode}` (medir já não rotaciona hoje só por cursor, mas reforça). + - Cursor: quando `positionMode`, setar `gl.domElement.style.cursor = 'move'`. -## Fora do escopo -- Galeria de vídeos dentro do app (apenas download direto por enquanto). -- Áudio/microfone na gravação. -- Streaming/upload para nuvem. +3. **`src/components/ViewerControls.tsx`** + - Importar ícone `Move3D` do lucide. + - Novo botão **Posicionar** com `variant={positionMode ? 'default' : 'outline'}` chamando `setPositionMode(!positionMode)`. Label dinâmica: `Posicionar` / `Posicionando — [nome da peça ativa]`. + +### Detalhes técnicos + +- A translação é gravada em `fineTuning.pos{X,Y,Z}` (espaço local da peça, antes do `renderFactor` da escala). Para que 1 pixel ↔ 1 mm visual fique coerente, o cálculo divide o delta-mundo por `scaleRatio.factor` (ex: em 1:50, mover 100 px arrasta o objeto 100 px de tela tanto na visualização quanto na coordenada interna). +- Rotação em graus (já é o formato de `fineTuning.rotX/Y/Z`), sensibilidade ~`0.4°/px`. +- O bloqueio de outras peças é automático: o handler só atualiza a `activeModelId`. Clicar em outra peça (handler `onClick` já existente em `GLBModel`) muda a ativa. +- Persistência: `setFineTuning` já chama `savePlacement(fileName, fineTuning)`, então sair/voltar do AR mantém posições. +- Sem mudanças no AR; apenas no viewer desktop. From 56d1ca422274a4ec41e9bacfb567589adcf649a8 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:48:27 +0000 Subject: [PATCH 2/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/ModelViewer.tsx | 3 +++ src/stores/useModelStore.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index f1363ab..b49b923 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -536,12 +536,15 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) { + + s.positionMode) && !useModelStore((s) => s.measureMode)} /> ); diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 8fb4c1a..60c16a0 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -212,6 +212,8 @@ interface ModelStore { measureMode: boolean; setMeasureMode: (on: boolean) => void; + positionMode: boolean; + setPositionMode: (on: boolean) => void; measurePoints: MeasurePoint[]; addMeasurePoint: (p: MeasurePoint) => void; measurements: Measurement[]; @@ -442,7 +444,9 @@ export const useModelStore = create((set, get) => ({ resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }), measureMode: false, - setMeasureMode: (measureMode) => set({ measureMode, measurePoints: [] }), + setMeasureMode: (measureMode) => set((s) => ({ measureMode, measurePoints: [], positionMode: measureMode ? false : s.positionMode })), + positionMode: false, + setPositionMode: (positionMode) => set((s) => ({ positionMode, measureMode: positionMode ? false : s.measureMode })), measurePoints: [], addMeasurePoint: (p) => set((state) => { const points = [...state.measurePoints, p]; From a93db3db7a762fdfdbf228eaa98a9f083cfca745 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:48:49 +0000 Subject: [PATCH 3/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/ModelViewer.tsx | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index b49b923..e851f05 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -504,9 +504,138 @@ function GridAutoFollower() { }, 100); return () => clearTimeout(id); }, [models, scene]); +/** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */ +function PositionDragHandler() { + const { camera, gl, scene } = useThree(); + const positionMode = useModelStore((s) => s.positionMode); + const activeId = useModelStore((s) => s.activeModelId); + + useEffect(() => { + if (!positionMode) { + gl.domElement.style.cursor = 'grab'; + return; + } + gl.domElement.style.cursor = 'move'; + + const canvas = gl.domElement; + let dragging = false; + let button = 0; + let shiftKey = false; + let lastX = 0; + let lastY = 0; + let pixelsPerWorldUnit = 1; + + const computePixelScale = () => { + const st = useModelStore.getState(); + const active = st.models.find((m) => m.id === st.activeModelId); + if (!active) return; + // Find the model's group position (approx world): use renderFactor + fineTuning pos + const factor = st.scaleRatio?.factor ?? 1; + const wp = new THREE.Vector3( + active.fineTuning.posX * factor, + active.fineTuning.posY * factor, + active.fineTuning.posZ * factor, + ); + const dist = camera.position.distanceTo(wp); + const perspCam = camera as THREE.PerspectiveCamera; + const fov = (perspCam.fov ?? 50) * (Math.PI / 180); + const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / canvas.clientHeight; + pixelsPerWorldUnit = worldPerPixel; // world units per pixel + }; + + const onDown = (e: PointerEvent) => { + const st = useModelStore.getState(); + if (!st.positionMode || !st.activeModelId) return; + dragging = true; + button = e.button; + shiftKey = e.shiftKey; + lastX = e.clientX; + lastY = e.clientY; + computePixelScale(); + (e.target as Element).setPointerCapture?.(e.pointerId); + e.preventDefault(); + }; + + const onMove = (e: PointerEvent) => { + if (!dragging) return; + const st = useModelStore.getState(); + const active = st.models.find((m) => m.id === st.activeModelId); + if (!active) return; + + const dx = e.clientX - lastX; + const dy = e.clientY - lastY; + lastX = e.clientX; + lastY = e.clientY; + shiftKey = e.shiftKey; + + const ft = active.fineTuning; + const factor = st.scaleRatio?.factor ?? 1; + + if (button === 2) { + // Right button: rotate + const sens = 0.4; // deg per pixel + if (shiftKey) { + useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens }); + } else { + useModelStore.getState().setFineTuning({ + rotY: ft.rotY + dx * sens, + rotX: ft.rotX + dy * sens, + }); + } + } else if (button === 1 || (button === 0 && shiftKey)) { + // Middle or Shift+left: depth (Z in camera space) + const worldDelta = dy * pixelsPerWorldUnit; + const camDir = new THREE.Vector3(); + camera.getWorldDirection(camDir); + const move = camDir.multiplyScalar(worldDelta).divideScalar(factor); + useModelStore.getState().setFineTuning({ + posX: ft.posX + move.x, + posY: ft.posY + move.y, + posZ: ft.posZ + move.z, + }); + } else { + // Left: translate in camera plane + const right = new THREE.Vector3(); + const up = new THREE.Vector3(); + camera.matrixWorld.extractBasis(right, up, new THREE.Vector3()); + const worldDX = dx * pixelsPerWorldUnit; + const worldDY = -dy * pixelsPerWorldUnit; + const move = right.multiplyScalar(worldDX).add(up.multiplyScalar(worldDY)).divideScalar(factor); + useModelStore.getState().setFineTuning({ + posX: ft.posX + move.x, + posY: ft.posY + move.y, + posZ: ft.posZ + move.z, + }); + } + }; + + const onUp = (e: PointerEvent) => { + dragging = false; + (e.target as Element).releasePointerCapture?.(e.pointerId); + }; + + const onContext = (e: MouseEvent) => e.preventDefault(); + + canvas.addEventListener('pointerdown', onDown); + canvas.addEventListener('pointermove', onMove); + canvas.addEventListener('pointerup', onUp); + canvas.addEventListener('pointercancel', onUp); + canvas.addEventListener('contextmenu', onContext); + return () => { + canvas.removeEventListener('pointerdown', onDown); + canvas.removeEventListener('pointermove', onMove); + canvas.removeEventListener('pointerup', onUp); + canvas.removeEventListener('pointercancel', onUp); + canvas.removeEventListener('contextmenu', onContext); + gl.domElement.style.cursor = 'grab'; + }; + }, [positionMode, activeId, camera, gl, scene]); + return null; } + + export function ModelViewerCanvas({ url }: ModelViewerProps) { return ( Date: Wed, 20 May 2026 18:49:08 +0000 Subject: [PATCH 4/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/ModelViewer.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index e851f05..ba0effc 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -504,6 +504,9 @@ function GridAutoFollower() { }, 100); return () => clearTimeout(id); }, [models, scene]); + return null; +} + /** Mouse drag handler for desktop "Posicionar" mode: translates/rotates the active model. */ function PositionDragHandler() { const { camera, gl, scene } = useThree(); From 249251fa3186d35dc359399b8ed2b5e693328eb1 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:49:17 +0000 Subject: [PATCH 5/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/ViewerControls.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 725a415..eb0138d 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,4 +1,4 @@ -import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -26,7 +26,10 @@ export function ViewerControls() { wireframeColor, setWireframeColor, wireframeThickness, setWireframeThickness, edgeThresholdAngle, setEdgeThresholdAngle, + positionMode, setPositionMode, + activeModelId, models, } = useModelStore(); + const activeModel = models.find(m => m.id === activeModelId); const handleScreenshot = useCallback(() => { const canvas = document.querySelector('canvas'); From 9b15f2425917eb0957a65bba8c69a7256648b1bc Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 18:49:30 +0000 Subject: [PATCH 6/7] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/ViewerControls.tsx | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index eb0138d..6bd27f6 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -146,6 +146,23 @@ export function ViewerControls() { )} + {/* Posicionar (manual mouse placement) */} + + {/* Measure tool */}