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.