Added positioning controls
X-Lovable-Edit-ID: edt-ae1dd1aa-2777-44c1-9a71-ba5742464627 Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
+35
-36
@@ -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-<timestamp>.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 `<canvas>` 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.
|
||||
|
||||
@@ -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');
|
||||
@@ -143,6 +146,23 @@ export function ViewerControls() {
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
{/* Posicionar (manual mouse placement) */}
|
||||
<Button
|
||||
variant={positionMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="gap-2 h-9"
|
||||
onClick={() => setPositionMode(!positionMode)}
|
||||
disabled={!activeModelId}
|
||||
title="Arraste: mover · Shift+arraste ou meio: profundidade · Direito: girar · Shift+direito: rotZ"
|
||||
>
|
||||
<Move3D className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">
|
||||
{positionMode
|
||||
? `Posicionando${activeModel ? ` · ${activeModel.fileName.slice(0, 14)}` : ''}`
|
||||
: 'Posicionar'}
|
||||
</span>
|
||||
</Button>
|
||||
|
||||
{/* Measure tool */}
|
||||
<div className="flex items-end gap-1.5">
|
||||
<Button
|
||||
|
||||
@@ -507,7 +507,141 @@ function GridAutoFollower() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
const positionMode = useModelStore((s) => s.positionMode);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||
@@ -536,12 +670,15 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||
<GridLayer />
|
||||
<GridAutoFollower />
|
||||
|
||||
<PositionDragHandler />
|
||||
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.1}
|
||||
minDistance={0.05}
|
||||
maxDistance={50}
|
||||
enabled={!positionMode && !measureMode}
|
||||
/>
|
||||
</Canvas>
|
||||
);
|
||||
|
||||
@@ -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<ModelStore>((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];
|
||||
|
||||
Reference in New Issue
Block a user