Adicionou snap e controles AR

X-Lovable-Edit-ID: edt-405b583a-2ea7-4f41-9a6f-a1f812753f07
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-21 12:57:47 +00:00
7 changed files with 415 additions and 151 deletions
+52 -49
View File
@@ -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 `<group>` 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 `<Html distanceFactor={...}>`: 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 `<Text>` do `@react-three/drei` (já importado em `XRHudInWorld`) em vez de `<Html>` 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).
+16 -2
View File
@@ -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"
>
<Ruler className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
@@ -181,6 +182,18 @@ export function ViewerControls() {
</span>
</Button>
{(measurePoints.length > 0 || measurements.length > 0) && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={undoLastMeasurement}
title="Desfazer (Z)"
>
<Undo2 className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
)}
{measurements.length > 0 && (
<Button
variant="ghost"
@@ -194,6 +207,7 @@ export function ViewerControls() {
)}
</div>
{/* Screenshot */}
<Button
variant="outline"
+143 -60
View File
@@ -1,4 +1,4 @@
import { Suspense, useRef, useMemo, useEffect, useCallback } from 'react';
import { Suspense, useRef, useMemo, useEffect } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
@@ -167,26 +167,82 @@ function SceneModels() {
);
}
/** Sphere marker at a 3D point */
/** Sphere marker at a 3D point — scaled per-frame to keep constant screen size */
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
useFrame(() => {
if (!ref.current) return;
const dist = camera.position.distanceTo(ref.current.position);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// Target: ~6 px radius (radius = 1 in base geometry → scale = 6*wpp)
const s = Math.max(0.0005, 6 * worldPerPixel);
ref.current.scale.setScalar(s);
});
return (
<mesh position={position}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color={color} />
<mesh ref={ref} position={position}>
<sphereGeometry args={[1, 16, 16]} />
<meshBasicMaterial color={color} depthTest={false} transparent opacity={0.95} />
</mesh>
);
}
/** Snap ring indicator */
function SnapRing({ position }: { position: [number, number, number] }) {
/** Snap ring indicator — scaled per-frame */
function SnapRing({ position, color = '#eab308' }: { position: [number, number, number]; color?: string }) {
const ref = useRef<THREE.Mesh>(null);
const { camera, size } = useThree();
useFrame(() => {
if (!ref.current) return;
ref.current.lookAt(camera.position);
const dist = camera.position.distanceTo(ref.current.position);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
const s = Math.max(0.0008, 10 * worldPerPixel);
ref.current.scale.setScalar(s);
});
return (
<mesh position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
<mesh ref={ref} position={position}>
<ringGeometry args={[0.7, 1, 28]} />
<meshBasicMaterial color={color} side={THREE.DoubleSide} depthTest={false} transparent opacity={0.9} />
</mesh>
);
}
/** Computes a distanceFactor that keeps an Html label roughly constant on screen */
function useLabelDistanceFactor(position: [number, number, number]): number {
const { camera, size } = useThree();
const v = useMemo(() => new THREE.Vector3(), []);
const ref = useRef(1);
useFrame(() => {
v.set(position[0], position[1], position[2]);
const dist = camera.position.distanceTo(v);
const perspCam = camera as THREE.PerspectiveCamera;
const fov = (perspCam.fov ?? 50) * (Math.PI / 180);
const worldPerPixel = (2 * dist * Math.tan(fov / 2)) / size.height;
// distanceFactor scales the html so it stays ~140 px tall at this distance
ref.current = Math.max(0.05, worldPerPixel * 140);
});
return ref.current;
}
function MeasurementLabel({ position, text, variant }: { position: [number, number, number]; text: string; variant: 'success' | 'primary' }) {
const df = useLabelDistanceFactor(position);
const borderClass = variant === 'success' ? 'border-success/60' : 'border-primary/60';
const textClass = variant === 'success' ? 'text-success' : 'text-primary';
return (
<Html position={position} center distanceFactor={df} zIndexRange={[100, 0]} style={{ pointerEvents: 'none' }}>
<div className={`rounded bg-card/95 border ${borderClass} px-2 py-0.5 shadow-lg backdrop-blur-sm`}>
<span className={`font-mono text-[11px] font-bold ${textClass} whitespace-nowrap`}>
{text}
</span>
</div>
</Html>
);
}
/** Renders all measurements, snap point, and hover info */
function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
@@ -194,6 +250,8 @@ function MeasurementOverlay() {
const snapPoint = useModelStore((s) => s.snapPoint);
const hoverInfo = useModelStore((s) => s.hoverInfo);
const measureMode = useModelStore((s) => s.measureMode);
const scaleRatio = useModelStore((s) => s.scaleRatio);
const factor = scaleRatio?.factor ?? 1;
return (
<>
@@ -204,14 +262,11 @@ function MeasurementOverlay() {
{/* Hover auto-detect tooltip */}
{hoverInfo && (
<Html position={hoverInfo.position} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
{hoverInfo.type === 'hole' ? '⌀ ' : ''}
{hoverInfo.value.toFixed(1)} mm
</span>
</div>
</Html>
<MeasurementLabel
position={hoverInfo.position}
text={`${hoverInfo.type === 'hole' ? '⌀ ' : ''}${hoverInfo.value.toFixed(1)} mm`}
variant="primary"
/>
)}
{/* Pending first point */}
@@ -237,14 +292,9 @@ function MeasurementOverlay() {
points={[a, b]}
color="#22c55e"
lineWidth={2}
depthTest={false}
/>
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-success/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-success whitespace-nowrap">
{m.distanceMM.toFixed(1)} mm
</span>
</div>
</Html>
<MeasurementLabel position={mid} text={`${m.distanceMM.toFixed(1)} mm`} variant="success" />
</group>
);
})}
@@ -252,6 +302,7 @@ function MeasurementOverlay() {
);
}
/** Smart vertex snap on pointer move */
function SmartSnapHandler() {
const { camera, scene, gl } = useThree();
@@ -408,61 +459,93 @@ function HoverDetector() {
return null;
}
/** Raycasting click handler for measurement mode uses snap point when available */
/** Pointer handler for measurement mode uses snap point when available.
* Uses pointerdown/up with a drag gate so accidental drags don't add points. */
function MeasureClickHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const handleClick = useCallback((event: MouseEvent) => {
if (!measureMode) return;
// Use snap point if available
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
useEffect(() => {
if (!measureMode) {
gl.domElement.style.cursor = 'grab';
return;
}
gl.domElement.style.cursor = 'crosshair';
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
const canvas = gl.domElement;
let downX = 0, downY = 0, downBtn = 0;
let armed = false;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const onDown = (e: PointerEvent) => {
if (e.button !== 0) { armed = false; return; }
downX = e.clientX;
downY = e.clientY;
downBtn = e.button;
armed = true;
};
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh) {
const onUp = (e: PointerEvent) => {
if (!armed || e.button !== downBtn) return;
armed = false;
const dx = e.clientX - downX;
const dy = e.clientY - downY;
if (Math.hypot(dx, dy) > 4) return; // it was a drag, not a click
const st = useModelStore.getState();
if (!st.measureMode) return;
// Prefer snap
if (st.snapPoint) {
st.addMeasurePoint({ x: st.snapPoint.x, y: st.snapPoint.y, z: st.snapPoint.z });
return;
}
const rect = canvas.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (!(obj instanceof THREE.Mesh)) return false;
if (obj.userData.__edgeLine) return false;
if (obj.geometry instanceof THREE.SphereGeometry) return false;
if (obj.geometry instanceof THREE.RingGeometry) return false;
if (obj.geometry instanceof THREE.PlaneGeometry) return false;
return true;
});
if (hit) {
st.addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
return true;
});
};
if (hit) {
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
}, [measureMode, addMeasurePoint, snapPoint, camera, scene, gl, raycaster, mouse]);
const onKey = (e: KeyboardEvent) => {
const st = useModelStore.getState();
if (!st.measureMode) return;
if (e.key === 'Escape') {
if (st.measurePoints.length > 0) st.undoLastMeasurePoint();
} else if ((e.key === 'z' || e.key === 'Z') && !e.ctrlKey && !e.metaKey) {
st.undoLastMeasurement();
}
};
useEffect(() => {
const canvas = gl.domElement;
canvas.addEventListener('click', handleClick);
return () => canvas.removeEventListener('click', handleClick);
}, [gl, handleClick]);
// Change cursor when in measure mode
useEffect(() => {
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
return () => { gl.domElement.style.cursor = 'grab'; };
}, [measureMode, gl]);
canvas.addEventListener('pointerdown', onDown);
canvas.addEventListener('pointerup', onUp);
window.addEventListener('keydown', onKey);
return () => {
canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointerup', onUp);
window.removeEventListener('keydown', onKey);
gl.domElement.style.cursor = 'grab';
};
}, [measureMode, camera, scene, gl, raycaster, mouse]);
return null;
}
function GridLayer() {
const showGrid = useModelStore((s) => s.showGrid);
const gridY = useModelStore((s) => s.gridY);
+19
View File
@@ -316,3 +316,22 @@ function pointToSegmentDist(px: number, py: number, ax: number, ay: number, bx:
const closestY = ay + t * dy;
return Math.sqrt((px - closestX) ** 2 + (py - closestY) ** 2);
}
/**
* Combined snap resolver: tries vertex first, then edge midpoint,
* falling back to the raw hit point. Returns { point, type }.
*/
export function resolveSnap(
mesh: THREE.Mesh,
hitPoint: THREE.Vector3,
camera: THREE.Camera,
canvasSize: { width: number; height: number },
vertexThresholdPx: number = 12,
edgeThresholdPx: number = 16
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
const v = findNearestVertex(mesh, hitPoint, camera, canvasSize, vertexThresholdPx);
if (v) return { point: v, type: 'vertex' };
const e = findNearestEdgeSegment(mesh, hitPoint, camera, canvasSize, edgeThresholdPx);
if (e) return { point: e.midpoint, type: 'edge' };
return { point: hitPoint.clone(), type: 'surface' };
}
+164 -40
View File
@@ -1,84 +1,208 @@
import { useRef } from 'react';
import { useRef, useState } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
import { resolveSnap } from './SmartMeasure';
const TRIG_ON = 0.7;
const TRIG_OFF = 0.3;
const BTN_ON = 0.6;
const MAX_RAY = 10; // meters
/**
* Right-controller trigger driven measurement for AR.
*
* When `measureMode` is on and the user pulls the right trigger, casts a ray
* forward from the controller (target-ray space). The first mesh hit becomes
* a measurement point. Two pulls = one measurement (handled by the store).
* Mapping (right hand):
* - Trigger → add measurement point (with snap)
* - Button A (gp.buttons[4]) → undo last point/measurement
* - Button B (gp.buttons[5]) → clear all measurements
* Left hand:
* - Trigger → toggle vertex snap ON/OFF
*
* Uses hysteresis so a single trigger pull adds exactly one point.
* Renders a thin laser from the right controller to the snap candidate
* (green if snapped to vertex/edge, amber if surface).
*/
export function XRControllerMeasure() {
const { scene, gl } = useThree();
const triggered = useRef(false);
const trigState = useRef(false);
const aState = useRef(false);
const bState = useRef(false);
const lTrigState = useRef(false);
const [snapEnabled, setSnapEnabled] = useState(true);
const raycaster = useRef(new THREE.Raycaster());
const tmpOrigin = useRef(new THREE.Vector3());
const tmpDir = useRef(new THREE.Vector3());
const tmpQuat = useRef(new THREE.Quaternion());
// Visual laser refs
const laserRef = useRef<THREE.Line>(null);
const tipRef = useRef<THREE.Mesh>(null);
const laserGeom = useRef<THREE.BufferGeometry>(
new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(), new THREE.Vector3(0, 0, -1)])
);
const laserMat = useRef(new THREE.LineBasicMaterial({ color: '#22c55e', transparent: true, opacity: 0.7, depthTest: false }));
const tipColor = useRef(new THREE.Color('#22c55e'));
useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode;
if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false;
if (!measureMode || !frame) return;
const session = frame.session;
const refSpace = gl.xr.getReferenceSpace();
if (!session || !refSpace) return;
// Find right controller
let right: XRInputSource | null = null;
let left: XRInputSource | null = null;
for (const src of session.inputSources) {
if (src.handedness === 'right') { right = src; break; }
if (src.handedness === 'right') right = src;
else if (src.handedness === 'left') left = src;
}
// ── Left trigger: toggle snap ─────────────────────────────────────
if (left) {
const v = left.gamepad?.buttons?.[0]?.value ?? (left.gamepad?.buttons?.[0]?.pressed ? 1 : 0);
if (!lTrigState.current && v > TRIG_ON) {
lTrigState.current = true;
setSnapEnabled((s) => !s);
} else if (lTrigState.current && v < TRIG_OFF) {
lTrigState.current = false;
}
}
if (!right) return;
const gp = right.gamepad;
const trigBtn = gp?.buttons?.[0];
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
// Rising edge with hysteresis
if (!triggered.current && trigVal > TRIG_ON) {
triggered.current = true;
// ── Compute right-controller ray ──────────────────────────────────
const raySpace = right.targetRaySpace ?? right.gripSpace;
if (!raySpace) return;
const pose = frame.getPose(raySpace, refSpace);
if (!pose) return;
const raySpace = right.targetRaySpace ?? right.gripSpace;
if (!raySpace) return;
const pose = frame.getPose(raySpace, refSpace);
if (!pose) return;
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
tmpOrigin.current.setFromMatrixPosition(m);
tmpQuat.current.setFromRotationMatrix(m);
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
tmpOrigin.current.setFromMatrixPosition(m);
tmpQuat.current.setFromRotationMatrix(m);
// Controller aims along -Z in WebXR target-ray convention
tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
raycaster.current.set(tmpOrigin.current, tmpDir.current);
raycaster.current.far = MAX_RAY;
raycaster.current.set(tmpOrigin.current, tmpDir.current);
raycaster.current.far = MAX_RAY;
const hits = raycaster.current.intersectObjects(scene.children, true);
const hit = hits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false;
if (o.geometry instanceof THREE.PlaneGeometry) return false;
return true;
});
const hits = raycaster.current.intersectObjects(scene.children, true);
const hit = hits.find((h) => {
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false;
if (o.userData.__edgeLine) return false;
if (o.geometry instanceof THREE.SphereGeometry) return false;
if (o.geometry instanceof THREE.RingGeometry) return false;
if (o.geometry instanceof THREE.PlaneGeometry) return false;
return true;
});
if (hit) {
let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' = 'surface';
if (hit && hit.object instanceof THREE.Mesh) {
if (snapEnabled) {
// Use a virtual canvas size based on session — fall back to current renderer size
const size = gl.getSize(new THREE.Vector2());
const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
fakeCam.position.copy(tmpOrigin.current);
fakeCam.quaternion.copy(tmpQuat.current);
fakeCam.updateMatrixWorld(true);
const snap = resolveSnap(
hit.object,
hit.point,
fakeCam,
{ width: size.x || 1024, height: size.y || 1024 },
14,
18
);
snappedPoint = snap.point;
snapKind = snap.type;
} else {
snappedPoint = hit.point.clone();
snapKind = 'surface';
}
}
// ── Update laser visual ───────────────────────────────────────────
if (laserRef.current && tipRef.current) {
const end = snappedPoint ?? tmpOrigin.current.clone().add(tmpDir.current.clone().multiplyScalar(MAX_RAY));
const positions = laserGeom.current.attributes.position as THREE.BufferAttribute;
positions.setXYZ(0, tmpOrigin.current.x, tmpOrigin.current.y, tmpOrigin.current.z);
positions.setXYZ(1, end.x, end.y, end.z);
positions.needsUpdate = true;
laserGeom.current.computeBoundingSphere();
const color = snapKind === 'vertex' ? '#22c55e' : snapKind === 'edge' ? '#3b82f6' : '#eab308';
tipColor.current.set(color);
(laserRef.current.material as THREE.LineBasicMaterial).color.set(color);
((tipRef.current.material as THREE.MeshBasicMaterial)).color.copy(tipColor.current);
laserRef.current.visible = true;
if (snappedPoint) {
tipRef.current.visible = true;
tipRef.current.position.copy(snappedPoint);
// Scale tip with distance from controller (~12mm at 1m)
const dist = tmpOrigin.current.distanceTo(snappedPoint);
const s = Math.max(0.004, dist * 0.012);
tipRef.current.scale.setScalar(s);
}
}
// ── Right trigger: add point ──────────────────────────────────────
const trigVal = gp?.buttons?.[0]?.value ?? (gp?.buttons?.[0]?.pressed ? 1 : 0);
if (!trigState.current && trigVal > TRIG_ON) {
trigState.current = true;
if (snappedPoint) {
useModelStore.getState().addMeasurePoint({
x: hit.point.x, y: hit.point.y, z: hit.point.z,
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
});
}
} else if (triggered.current && trigVal < TRIG_OFF) {
triggered.current = false;
} else if (trigState.current && trigVal < TRIG_OFF) {
trigState.current = false;
}
// ── Button A (undo) ──────────────────────────────────────────────
const aBtn = gp?.buttons?.[4];
const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0;
if (!aState.current && aVal > BTN_ON) {
aState.current = true;
useModelStore.getState().undoLastMeasurement();
} else if (aState.current && aVal < TRIG_OFF) {
aState.current = false;
}
// ── Button B (clear) ─────────────────────────────────────────────
const bBtn = gp?.buttons?.[5];
const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0;
if (!bState.current && bVal > BTN_ON) {
bState.current = true;
useModelStore.getState().clearMeasurements();
} else if (bState.current && bVal < TRIG_OFF) {
bState.current = false;
}
});
return null;
// Construct the laser Line object once so we can attach via <primitive>
const laserObject = useRef<THREE.Line>();
if (!laserObject.current) {
laserObject.current = new THREE.Line(laserGeom.current, laserMat.current);
laserObject.current.frustumCulled = false;
laserObject.current.renderOrder = 999;
(laserRef as React.MutableRefObject<THREE.Line | null>).current = laserObject.current;
}
return (
<>
<primitive object={laserObject.current} />
<mesh ref={tipRef} frustumCulled={false} renderOrder={999}>
<sphereGeometry args={[1, 12, 12]} />
<meshBasicMaterial color="#22c55e" depthTest={false} transparent opacity={0.9} />
</mesh>
</>
);
}
+8
View File
@@ -392,6 +392,14 @@ function ToolsTab(p: XRHudInWorldProps) {
label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} />
{/* Legenda dos controles para Medir */}
{measureMode && (
<Text position={[-0.24, -0.145, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
</Text>
)}
{/* Grid floor controls */}
<GridFloorRow />
</group>
+13
View File
@@ -216,9 +216,12 @@ interface ModelStore {
setPositionMode: (on: boolean) => void;
measurePoints: MeasurePoint[];
addMeasurePoint: (p: MeasurePoint) => void;
undoLastMeasurePoint: () => void;
measurements: Measurement[];
clearMeasurements: () => void;
removeMeasurement: (id: string) => void;
undoLastMeasurement: () => void;
compareMode: boolean;
setCompareMode: (on: boolean) => void;
@@ -473,6 +476,16 @@ export const useModelStore = create<ModelStore>((set, get) => ({
removeMeasurement: (id) => set((state) => ({
measurements: state.measurements.filter(m => m.id !== id),
})),
undoLastMeasurePoint: () => set((state) => ({
measurePoints: state.measurePoints.slice(0, -1),
})),
undoLastMeasurement: () => set((state) => {
if (state.measurePoints.length > 0) {
return { measurePoints: state.measurePoints.slice(0, -1) };
}
return { measurements: state.measurements.slice(0, -1) };
}),
compareMode: false,
setCompareMode: (compareMode) => set({ compareMode }),