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 ## 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 | ## O que vai ser construído
|---|---|---|
| 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 |
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). - Atualmente `SmartSnapHandler` (vértices) e `HoverDetector` (furo/aresta) só funcionam pelo mouse. Vamos:
2. Calcular `THREE.Box3().setFromObject(group)` em coordenadas mundo → obter `size` e `center`. - Manter o snap de vértice via `findNearestVertex` (já existe em `SmartMeasure.ts`).
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. - 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.
4. Transformar esse eixo local para mundo aplicando a `group.matrixWorld` (apenas a parte rotacional via `Vector3.transformDirection`). - Expor uma função utilitária `resolveSnap(hitPoint, hitObject, camera, canvasSize)` reutilizada pelo mouse e pelo AR.
5. Guardar em refs: `axisWorld: Vector3`, `centerWorld: Vector3` (centro da bbox mundo).
## Aplicação da rotação no `pointermove` ### C) AR (joysticks)
- Sensibilidade: `0.5° por pixel` de `dx` (movimento horizontal natural para "rolar"). Reescrever `XRControllerMeasure.tsx` para:
- 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.
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`. ### D) Marcadores e labels proporcionais
- Depois: `newOffset = qDelta.applyVector(offset)`.
- `deltaPos = offset - newOffset` (em mundo) → converter para local (dividindo por `scaleRatio.factor`) e somar a `posX/posY/posZ`.
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`** - **Desktop (`ViewerControls`)**: adicionar botão "Desfazer ponto" ao lado do "Limpar medições" quando `measurePoints.length === 1`.
- Marcar o grupo de cada `GLBModel` com `userData.modelId = model.id` (se ainda não estiver). - **AR HUD (`XRHudInWorld`)**: já existe "✕ Medidas". Adicionar legenda visual abaixo: "Gatilho R: marcar · A: desfazer · B: limpar · Gatilho L: snap".
- 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).
- **`src/components/ViewerControls.tsx`** ## Arquivos afetados
- 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`.
## 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. ## Critérios de aceite
- 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)`. - 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).
- 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). - Snap a vértice mostra anel amarelo (`SnapRing`) e clique usa esse ponto.
- Persistência: `setFineTuning` já chama `savePlacement`, então a posição/rotação ficam salvas como já acontece. - 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 { Slider } from '@/components/ui/slider';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
@@ -20,7 +20,7 @@ const WIRE_COLORS = [
export function ViewerControls() { export function ViewerControls() {
const { const {
opacity, setOpacity, renderMode, setRenderMode, opacity, setOpacity, renderMode, setRenderMode,
measureMode, setMeasureMode, measurements, clearMeasurements, measureMode, setMeasureMode, measurements, clearMeasurements, undoLastMeasurement,
measurePoints, addScreenshot, measurePoints, addScreenshot,
showGrid, setShowGrid, showGrid, setShowGrid,
wireframeColor, setWireframeColor, wireframeColor, setWireframeColor,
@@ -170,6 +170,7 @@ export function ViewerControls() {
size="sm" size="sm"
className="gap-2 h-9" className="gap-2 h-9"
onClick={() => setMeasureMode(!measureMode)} 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" /> <Ruler className="h-3.5 w-3.5" />
<span className="font-mono text-xs"> <span className="font-mono text-xs">
@@ -181,6 +182,18 @@ export function ViewerControls() {
</span> </span>
</Button> </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 && ( {measurements.length > 0 && (
<Button <Button
variant="ghost" variant="ghost"
@@ -194,6 +207,7 @@ export function ViewerControls() {
)} )}
</div> </div>
{/* Screenshot */} {/* Screenshot */}
<Button <Button
variant="outline" 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 { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react'; 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 }) { 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 ( return (
<mesh position={position}> <mesh ref={ref} position={position}>
<sphereGeometry args={[0.003, 16, 16]} /> <sphereGeometry args={[1, 16, 16]} />
<meshBasicMaterial color={color} /> <meshBasicMaterial color={color} depthTest={false} transparent opacity={0.95} />
</mesh> </mesh>
); );
} }
/** Snap ring indicator */ /** Snap ring indicator — scaled per-frame */
function SnapRing({ position }: { position: [number, number, number] }) { 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 ( return (
<mesh position={position} rotation={[-Math.PI / 2, 0, 0]}> <mesh ref={ref} position={position}>
<ringGeometry args={[0.003, 0.005, 24]} /> <ringGeometry args={[0.7, 1, 28]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} /> <meshBasicMaterial color={color} side={THREE.DoubleSide} depthTest={false} transparent opacity={0.9} />
</mesh> </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 */ /** Renders all measurements, snap point, and hover info */
function MeasurementOverlay() { function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements); const measurements = useModelStore((s) => s.measurements);
@@ -194,6 +250,8 @@ function MeasurementOverlay() {
const snapPoint = useModelStore((s) => s.snapPoint); const snapPoint = useModelStore((s) => s.snapPoint);
const hoverInfo = useModelStore((s) => s.hoverInfo); const hoverInfo = useModelStore((s) => s.hoverInfo);
const measureMode = useModelStore((s) => s.measureMode); const measureMode = useModelStore((s) => s.measureMode);
const scaleRatio = useModelStore((s) => s.scaleRatio);
const factor = scaleRatio?.factor ?? 1;
return ( return (
<> <>
@@ -204,14 +262,11 @@ function MeasurementOverlay() {
{/* Hover auto-detect tooltip */} {/* Hover auto-detect tooltip */}
{hoverInfo && ( {hoverInfo && (
<Html position={hoverInfo.position} center distanceFactor={1} style={{ pointerEvents: 'none' }}> <MeasurementLabel
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm"> position={hoverInfo.position}
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap"> text={`${hoverInfo.type === 'hole' ? '⌀ ' : ''}${hoverInfo.value.toFixed(1)} mm`}
{hoverInfo.type === 'hole' ? '⌀ ' : ''} variant="primary"
{hoverInfo.value.toFixed(1)} mm />
</span>
</div>
</Html>
)} )}
{/* Pending first point */} {/* Pending first point */}
@@ -237,14 +292,9 @@ function MeasurementOverlay() {
points={[a, b]} points={[a, b]}
color="#22c55e" color="#22c55e"
lineWidth={2} lineWidth={2}
depthTest={false}
/> />
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}> <MeasurementLabel position={mid} text={`${m.distanceMM.toFixed(1)} mm`} variant="success" />
<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>
</group> </group>
); );
})} })}
@@ -252,6 +302,7 @@ function MeasurementOverlay() {
); );
} }
/** Smart vertex snap on pointer move */ /** Smart vertex snap on pointer move */
function SmartSnapHandler() { function SmartSnapHandler() {
const { camera, scene, gl } = useThree(); const { camera, scene, gl } = useThree();
@@ -408,61 +459,93 @@ function HoverDetector() {
return null; 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() { function MeasureClickHandler() {
const { camera, scene, gl } = useThree(); const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode); 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 raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []); const mouse = useMemo(() => new THREE.Vector2(), []);
const handleClick = useCallback((event: MouseEvent) => { useEffect(() => {
if (!measureMode) return; if (!measureMode) {
gl.domElement.style.cursor = 'grab';
// Use snap point if available
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
return; return;
} }
gl.domElement.style.cursor = 'crosshair';
const rect = gl.domElement.getBoundingClientRect(); const canvas = gl.domElement;
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1; let downX = 0, downY = 0, downBtn = 0;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1; let armed = false;
raycaster.setFromCamera(mouse, camera); const onDown = (e: PointerEvent) => {
const intersects = raycaster.intersectObjects(scene.children, true); if (e.button !== 0) { armed = false; return; }
downX = e.clientX;
downY = e.clientY;
downBtn = e.button;
armed = true;
};
const hit = intersects.find(i => { const onUp = (e: PointerEvent) => {
const obj = i.object; if (!armed || e.button !== downBtn) return;
if (obj instanceof THREE.GridHelper) return false; armed = false;
if (obj instanceof THREE.Mesh) { 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.SphereGeometry) return false;
if (obj.geometry instanceof THREE.RingGeometry) 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) { const onKey = (e: KeyboardEvent) => {
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z }); const st = useModelStore.getState();
} if (!st.measureMode) return;
}, [measureMode, addMeasurePoint, snapPoint, camera, scene, gl, raycaster, mouse]); 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(() => { canvas.addEventListener('pointerdown', onDown);
const canvas = gl.domElement; canvas.addEventListener('pointerup', onUp);
canvas.addEventListener('click', handleClick); window.addEventListener('keydown', onKey);
return () => canvas.removeEventListener('click', handleClick); return () => {
}, [gl, handleClick]); canvas.removeEventListener('pointerdown', onDown);
canvas.removeEventListener('pointerup', onUp);
// Change cursor when in measure mode window.removeEventListener('keydown', onKey);
useEffect(() => { gl.domElement.style.cursor = 'grab';
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab'; };
return () => { gl.domElement.style.cursor = 'grab'; }; }, [measureMode, camera, scene, gl, raycaster, mouse]);
}, [measureMode, gl]);
return null; return null;
} }
function GridLayer() { function GridLayer() {
const showGrid = useModelStore((s) => s.showGrid); const showGrid = useModelStore((s) => s.showGrid);
const gridY = useModelStore((s) => s.gridY); 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; const closestY = ay + t * dy;
return Math.sqrt((px - closestX) ** 2 + (py - closestY) ** 2); 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 { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three'; import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore'; import { useModelStore } from '@/stores/useModelStore';
import { resolveSnap } from './SmartMeasure';
const TRIG_ON = 0.7; const TRIG_ON = 0.7;
const TRIG_OFF = 0.3; const TRIG_OFF = 0.3;
const BTN_ON = 0.6;
const MAX_RAY = 10; // meters const MAX_RAY = 10; // meters
/** /**
* Right-controller trigger driven measurement for AR. * Right-controller trigger driven measurement for AR.
* *
* When `measureMode` is on and the user pulls the right trigger, casts a ray * Mapping (right hand):
* forward from the controller (target-ray space). The first mesh hit becomes * - Trigger → add measurement point (with snap)
* a measurement point. Two pulls = one measurement (handled by the store). * - 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() { export function XRControllerMeasure() {
const { scene, gl } = useThree(); 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 raycaster = useRef(new THREE.Raycaster());
const tmpOrigin = useRef(new THREE.Vector3()); const tmpOrigin = useRef(new THREE.Vector3());
const tmpDir = useRef(new THREE.Vector3()); const tmpDir = useRef(new THREE.Vector3());
const tmpQuat = useRef(new THREE.Quaternion()); 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) => { useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode; const measureMode = useModelStore.getState().measureMode;
if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false;
if (!measureMode || !frame) return; if (!measureMode || !frame) return;
const session = frame.session; const session = frame.session;
const refSpace = gl.xr.getReferenceSpace(); const refSpace = gl.xr.getReferenceSpace();
if (!session || !refSpace) return; if (!session || !refSpace) return;
// Find right controller
let right: XRInputSource | null = null; let right: XRInputSource | null = null;
let left: XRInputSource | null = null;
for (const src of session.inputSources) { 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; if (!right) return;
const gp = right.gamepad; const gp = right.gamepad;
const trigBtn = gp?.buttons?.[0];
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
// Rising edge with hysteresis // ── Compute right-controller ray ──────────────────────────────────
if (!triggered.current && trigVal > TRIG_ON) { const raySpace = right.targetRaySpace ?? right.gripSpace;
triggered.current = true; if (!raySpace) return;
const pose = frame.getPose(raySpace, refSpace);
if (!pose) return;
const raySpace = right.targetRaySpace ?? right.gripSpace; const m = new THREE.Matrix4().fromArray(pose.transform.matrix);
if (!raySpace) return; tmpOrigin.current.setFromMatrixPosition(m);
const pose = frame.getPose(raySpace, refSpace); tmpQuat.current.setFromRotationMatrix(m);
if (!pose) return; tmpDir.current.set(0, 0, -1).applyQuaternion(tmpQuat.current).normalize();
const m = new THREE.Matrix4().fromArray(pose.transform.matrix); raycaster.current.set(tmpOrigin.current, tmpDir.current);
tmpOrigin.current.setFromMatrixPosition(m); raycaster.current.far = MAX_RAY;
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); const hits = raycaster.current.intersectObjects(scene.children, true);
raycaster.current.far = MAX_RAY; 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); let snappedPoint: THREE.Vector3 | null = null;
const hit = hits.find((h) => { let snapKind: 'vertex' | 'edge' | 'surface' = 'surface';
const o = h.object;
if (!(o instanceof THREE.Mesh)) return false; if (hit && hit.object instanceof THREE.Mesh) {
if (o.userData.__edgeLine) return false; if (snapEnabled) {
if (o.geometry instanceof THREE.SphereGeometry) return false; // Use a virtual canvas size based on session — fall back to current renderer size
if (o.geometry instanceof THREE.RingGeometry) return false; const size = gl.getSize(new THREE.Vector2());
if (o.geometry instanceof THREE.PlaneGeometry) return false; const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
return true; fakeCam.position.copy(tmpOrigin.current);
}); fakeCam.quaternion.copy(tmpQuat.current);
if (hit) { 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({ 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) { } else if (trigState.current && trigVal < TRIG_OFF) {
triggered.current = false; 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" label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} /> 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 */} {/* Grid floor controls */}
<GridFloorRow /> <GridFloorRow />
</group> </group>
+13
View File
@@ -216,9 +216,12 @@ interface ModelStore {
setPositionMode: (on: boolean) => void; setPositionMode: (on: boolean) => void;
measurePoints: MeasurePoint[]; measurePoints: MeasurePoint[];
addMeasurePoint: (p: MeasurePoint) => void; addMeasurePoint: (p: MeasurePoint) => void;
undoLastMeasurePoint: () => void;
measurements: Measurement[]; measurements: Measurement[];
clearMeasurements: () => void; clearMeasurements: () => void;
removeMeasurement: (id: string) => void; removeMeasurement: (id: string) => void;
undoLastMeasurement: () => void;
compareMode: boolean; compareMode: boolean;
setCompareMode: (on: boolean) => void; setCompareMode: (on: boolean) => void;
@@ -473,6 +476,16 @@ export const useModelStore = create<ModelStore>((set, get) => ({
removeMeasurement: (id) => set((state) => ({ removeMeasurement: (id) => set((state) => ({
measurements: state.measurements.filter(m => m.id !== id), 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, compareMode: false,
setCompareMode: (compareMode) => set({ compareMode }), setCompareMode: (compareMode) => set({ compareMode }),