diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx index 386072c..9d2c3fb 100644 --- a/src/components/XRHud.tsx +++ b/src/components/XRHud.tsx @@ -310,6 +310,19 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog )} + {/* Allow scale (two-hand zoom) toggle */} + {onToggleAllowScale && ( + + )} + {/* Scale presets */} diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx index 63b9af9..d5149e8 100644 --- a/src/components/three/XRControllerMeasure.tsx +++ b/src/components/three/XRControllerMeasure.tsx @@ -149,18 +149,20 @@ export function XRControllerMeasure() { snappedPoint = bestHoleCenter; snapKind = 'hole'; } else if (snapEnabled) { - // Try detecting a circular edge (hole) at hit - const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 60); + // Try detecting a circular edge (hole) at hit — radius mais generoso + const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 90); if (circle) { snappedPoint = circle.center; snapKind = 'hole'; hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center }; } else { - const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 14, 18); + // Snap mais "magnético": raios maiores de busca em pixels + // vértice: 32px (antes 14), aresta: 48px (antes 18) + const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 32, 48); snappedPoint = snap.point; snapKind = snap.type; if (snap.type === 'edge') { - const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 18); + const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 48); if (seg) { const lenMM = seg.a.distanceTo(seg.b) * 1000; hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } }; @@ -172,7 +174,9 @@ export function XRControllerMeasure() { snapKind = 'surface'; } } - // Cache for non-heavy frames so the laser + trigger still have a target + // Cache for non-heavy frames so the laser + trigger still have a target. + // Sticky: se conseguimos um snap "forte" (vertex/edge/hole), preservamos + // ele entre frames leves para evitar cintilação. cachedSnappedPoint.current = snappedPoint ? snappedPoint.clone() : null; cachedSnapKind.current = snapKind; } else { @@ -227,9 +231,13 @@ export function XRControllerMeasure() { if (snappedPoint) { tipRef.current.visible = true; tipRef.current.position.copy(snappedPoint); - // Scale tip with distance from controller (~12mm at 1m) + // Tip scales with distance from controller. Snap "forte" (vértice, + // aresta, furo) recebe um marcador ~3× maior do que uma superfície + // genérica, para que o ponto destacado fique bem visível. const dist = tmpOrigin.current.distanceTo(snappedPoint); - const s = Math.max(0.004, dist * 0.012); + const strongSnap = snapKind === 'vertex' || snapKind === 'edge' || snapKind === 'hole'; + const factor = strongSnap ? 0.030 : 0.012; + const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor); tipRef.current.scale.setScalar(s); } } diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 0cb8774..69acba7 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -6,8 +6,8 @@ import { useModelStore } from '@/stores/useModelStore'; interface XRGrabbableProps { /** - * @deprecated Mantido por compatibilidade — agora o zoom por duas mãos - * está SEMPRE ativo (faz parte do mapeamento padrão do grip). + * Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação. + * Quando false, o grip duplo só ORBITA — a escala da peça é preservada. */ allowScale?: boolean; /** When true, the active model is locked — grab is ignored entirely */ @@ -51,7 +51,7 @@ const _tmpQuat = new THREE.Quaternion(); * * Trigger não interfere aqui (reservado para seleção/medição). */ -export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) { +export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) { const groupRef = useRef(null); const grab = useControllerGrab(); @@ -121,8 +121,10 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG const d = dual.current; const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); - // ZOOM sempre ativo no grip duplo - const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10); + // ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita). + const scaleRatio = allowScale + ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) + : 1; const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z); const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z); diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx index 4f89c49..dd41d6e 100644 --- a/src/components/three/XRHudInWorld.tsx +++ b/src/components/three/XRHudInWorld.tsx @@ -328,6 +328,15 @@ function ToolsTab(p: XRHudInWorldProps) { const setShowGrid = useModelStore((s) => s.setShowGrid); const measureMode = useModelStore((s) => s.measureMode); const setMeasureMode = useModelStore((s) => s.setMeasureMode); + const selectionMode = useModelStore((s) => s.selectionMode); + const setSelectionMode = useModelStore((s) => s.setSelectionMode); + const selectedCount = useModelStore((s) => s.selectedElementKeys.size); + const hideSelectedElements = useModelStore((s) => s.hideSelectedElements); + const isolateSelectedElements = useModelStore((s) => s.isolateSelectedElements); + const showAllElements = useModelStore((s) => s.showAllElements); + const hasHidden = useModelStore((s) => + s.hiddenElementKeys.size > 0 || s.isolatedElementKeys !== null + ); const scaleRatio = useModelStore((s) => s.scaleRatio); const setScaleRatio = useModelStore((s) => s.setScaleRatio); const resetScale = useModelStore((s) => s.resetScale); @@ -353,52 +362,61 @@ function ToolsTab(p: XRHudInWorldProps) { + - + {/* Selecionar — paridade com a tela inicial */} + Selecionar + 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'} + active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} /> + + + + + Opacidade {Math.round(opacity * 100)}% - setOpacity(Math.max(0, opacity - 0.25))} /> - setOpacity(Math.max(0, opacity - 0.05))} /> - setOpacity(Math.min(1, opacity + 0.05))} /> - setOpacity(Math.min(1, opacity + 0.25))} /> - setOpacity(1)} /> - + Escala {scaleRatio.label} {SCALE_LABELS.map((lbl, i) => { const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!; return ( setScaleRatio(preset)} /> ); })} - {/* Reset Escala — devolve a peça ao tamanho 1:1 (cancela qualquer - escala manual feita com duas mãos). */} - - {/* Limpa todos os labels de medição. */} - - {/* Legenda dos controles para Medir */} {measureMode && ( - + Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap )} - - {/* Grid floor controls */} ); diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 9164e34..8bb52ae 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -471,9 +471,8 @@ const XRSession = () => { const [freeMove, setFreeMove] = useState(true); const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [snapToPlanes, setSnapToPlanes] = useState(true); - // Zoom por grip duplo é SEMPRE ligado (parte do mapeamento padrão). - const allowScale = true; - const setAllowScale = (_v?: boolean) => {}; + // Zoom por grip duplo — toggle real, padrão ligado. + const [allowScale, setAllowScale] = useState(true); const [liveCode, setLiveCode] = useState(null); const [liveViewers, setLiveViewers] = useState(0); // Rig que envolve — locomoção por joystick escreve aqui.