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