Compare commits

...

16 Commits

Author SHA1 Message Date
gpt-engineer-app[bot] bd9d0028f1 Melhorou snap e botões AR
X-Lovable-Edit-ID: edt-fe7c72e3-ce24-4acd-be0e-85f2e7f6eca3
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:40:00 +00:00
gpt-engineer-app[bot] 45a7bdbd1b Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:49 +00:00
gpt-engineer-app[bot] 6c022c514f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:39 +00:00
gpt-engineer-app[bot] 33b32fb268 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:39:01 +00:00
gpt-engineer-app[bot] de11c4e119 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:42 +00:00
gpt-engineer-app[bot] da9399305e Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:25 +00:00
gpt-engineer-app[bot] 073a3f8dc8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:18 +00:00
gpt-engineer-app[bot] 71b1e9ae3f Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:11 +00:00
gpt-engineer-app[bot] 360daac843 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:38:05 +00:00
gpt-engineer-app[bot] 639854c765 Corrigiu botões e throttle AR
X-Lovable-Edit-ID: edt-087c5038-cfa1-4ba9-93fe-3ad396e4c693
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:26:14 +00:00
gpt-engineer-app[bot] def95857ac Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:59 +00:00
gpt-engineer-app[bot] 0cce9f94b8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:45 +00:00
gpt-engineer-app[bot] a8cfe0d3b9 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:35 +00:00
gpt-engineer-app[bot] 085184ba41 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:21 +00:00
gpt-engineer-app[bot] 16c1e7b755 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:25:04 +00:00
gpt-engineer-app[bot] 22e3ede63c Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-21 21:24:35 +00:00
5 changed files with 161 additions and 115 deletions
+6 -15
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera, Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip, Move, RotateCw, RefreshCw, ChevronUp, ChevronDown,
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2, ClipboardCheck, X, Crosshair, Magnet, HelpCircle,
MousePointerClick, EyeOff, Focus, MousePointerClick, EyeOff, Focus,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -310,16 +310,16 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
</Button> </Button>
)} )}
{/* Two-hand scale toggle */} {/* Allow scale (two-hand zoom) toggle */}
{onToggleAllowScale && ( {onToggleAllowScale && (
<Button <Button
variant={allowScale ? 'default' : 'outline'} variant={allowScale ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono" size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleAllowScale} onClick={onToggleAllowScale}
title="Permite escalar a peça com gesto de duas mãos (afastando os controles)" title="Zoom por grip duplo (afastar/aproximar as mãos)"
> >
<Maximize2 className="h-3.5 w-3.5" /> <RefreshCw className="h-3.5 w-3.5" />
{allowScale ? 'Escala ON' : 'Escala'} {allowScale ? 'Zoom ON' : 'Zoom'}
</Button> </Button>
)} )}
@@ -332,15 +332,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
{/* Share live screen */} {/* Share live screen */}
<ShareButton variant="compact" /> <ShareButton variant="compact" />
<Button
variant={freeMove ? 'default' : 'outline'}
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
onClick={onToggleFreeMove}
>
<Grip className="h-3.5 w-3.5" />
{freeMove ? 'Mover ON' : 'Mover'}
</Button>
<Button <Button
variant={showGrid ? 'default' : 'outline'} variant={showGrid ? 'default' : 'outline'}
size="icon" className="h-8 w-8" size="icon" className="h-8 w-8"
+110 -72
View File
@@ -49,12 +49,21 @@ export function XRControllerMeasure() {
const dwellStart = useRef(0); const dwellStart = useRef(0);
const dwellFired = useRef(false); const dwellFired = useRef(false);
// Throttling: raycast + snap analysis are heavy on dense IFC models and
// running them every frame at 72fps can stall the Quest right after
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
const heavyFrame = useRef(0);
const cachedSnappedPoint = useRef<THREE.Vector3 | null>(null);
const cachedSnapKind = useRef<'vertex' | 'edge' | 'surface' | 'hole'>('surface');
useFrame((_state, _dt, frame: XRFrame | undefined) => { useFrame((_state, _dt, frame: XRFrame | undefined) => {
const measureMode = useModelStore.getState().measureMode; const measureMode = useModelStore.getState().measureMode;
const selectionMode = useModelStore.getState().selectionMode; const selectionMode = useModelStore.getState().selectionMode;
if (laserRef.current) laserRef.current.visible = false; if (laserRef.current) laserRef.current.visible = false;
if (tipRef.current) tipRef.current.visible = false; if (tipRef.current) tipRef.current.visible = false;
if ((!measureMode && !selectionMode) || !frame) return; if ((!measureMode && !selectionMode) || !frame) return;
const doHeavy = (heavyFrame.current++ % 3) === 0;
const session = frame.session; const session = frame.session;
const refSpace = gl.xr.getReferenceSpace(); const refSpace = gl.xr.getReferenceSpace();
@@ -95,72 +104,85 @@ export function XRControllerMeasure() {
raycaster.current.set(tmpOrigin.current, tmpDir.current); raycaster.current.set(tmpOrigin.current, tmpDir.current);
raycaster.current.far = MAX_RAY; 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;
});
let snappedPoint: THREE.Vector3 | null = null; let snappedPoint: THREE.Vector3 | null = null;
let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface'; let snapKind: 'vertex' | 'edge' | 'surface' | 'hole' = 'surface';
let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null; let hoverDetected: { kind: 'hole' | 'edge'; value: number; position: THREE.Vector3; endpoints?: { a: THREE.Vector3; b: THREE.Vector3 } } | null = null;
if (hit && hit.object instanceof THREE.Mesh) { if (doHeavy) {
const size = gl.getSize(new THREE.Vector2()); const hits = raycaster.current.intersectObjects(scene.children, true);
const canvasSize = { width: size.x || 1024, height: size.y || 1024 }; const hit = hits.find((h) => {
const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100); const o = h.object;
fakeCam.position.copy(tmpOrigin.current); if (!(o instanceof THREE.Mesh)) return false;
fakeCam.quaternion.copy(tmpQuat.current); if (o.userData.__edgeLine) return false;
fakeCam.updateMatrixWorld(true); 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;
});
// Snap to existing registered hole centers first (within ~30px screen) if (hit && hit.object instanceof THREE.Mesh) {
const existing = useModelStore.getState().measurements; const size = gl.getSize(new THREE.Vector2());
let bestHoleCenter: THREE.Vector3 | null = null; const canvasSize = { width: size.x || 1024, height: size.y || 1024 };
let bestHolePx = Infinity; const fakeCam = new THREE.PerspectiveCamera(60, 1, 0.01, 100);
const hitProj = hit.point.clone().project(fakeCam); fakeCam.position.copy(tmpOrigin.current);
const hx = (hitProj.x * 0.5 + 0.5) * canvasSize.width; fakeCam.quaternion.copy(tmpQuat.current);
const hy = (-hitProj.y * 0.5 + 0.5) * canvasSize.height; fakeCam.updateMatrixWorld(true);
for (const m of existing) {
if (m.kind !== 'hole') continue;
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
const p = c.clone().project(fakeCam);
const sx = (p.x * 0.5 + 0.5) * canvasSize.width;
const sy = (-p.y * 0.5 + 0.5) * canvasSize.height;
const d = Math.hypot(sx - hx, sy - hy);
if (d < 30 && d < bestHolePx) { bestHolePx = d; bestHoleCenter = c; }
}
if (snapEnabled && bestHoleCenter) { // Snap to existing registered hole centers first (within ~30px screen)
snappedPoint = bestHoleCenter; const existing = useModelStore.getState().measurements;
snapKind = 'hole'; let bestHoleCenter: THREE.Vector3 | null = null;
} else if (snapEnabled) { let bestHolePx = Infinity;
// Try detecting a circular edge (hole) at hit const hitProj = hit.point.clone().project(fakeCam);
const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 60); const hx = (hitProj.x * 0.5 + 0.5) * canvasSize.width;
if (circle) { const hy = (-hitProj.y * 0.5 + 0.5) * canvasSize.height;
snappedPoint = circle.center; for (const m of existing) {
if (m.kind !== 'hole') continue;
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
const p = c.clone().project(fakeCam);
const sx = (p.x * 0.5 + 0.5) * canvasSize.width;
const sy = (-p.y * 0.5 + 0.5) * canvasSize.height;
const d = Math.hypot(sx - hx, sy - hy);
if (d < 30 && d < bestHolePx) { bestHolePx = d; bestHoleCenter = c; }
}
if (snapEnabled && bestHoleCenter) {
snappedPoint = bestHoleCenter;
snapKind = 'hole'; snapKind = 'hole';
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center }; } else if (snapEnabled) {
} else { // Try detecting a circular edge (hole) at hit — radius mais generoso
const snap = resolveSnap(hit.object, hit.point, fakeCam, canvasSize, 14, 18); const circle = detectCircularEdgeAtPoint(hit.object, hit.point, fakeCam, canvasSize, 90);
snappedPoint = snap.point; if (circle) {
snapKind = snap.type; snappedPoint = circle.center;
if (snap.type === 'edge') { snapKind = 'hole';
const seg = findNearestEdgeSegment(hit.object, hit.point, fakeCam, canvasSize, 18); hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
if (seg) { } else {
const lenMM = seg.a.distanceTo(seg.b) * 1000; // Snap mais "magnético": raios maiores de busca em pixels
hoverDetected = { kind: 'edge', value: lenMM, position: seg.midpoint, endpoints: { a: seg.a, b: seg.b } }; // 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, 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 } };
}
} }
} }
} else {
snappedPoint = hit.point.clone();
snapKind = 'surface';
} }
} else {
snappedPoint = hit.point.clone();
snapKind = 'surface';
} }
// 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 {
// Light frame: reuse the last computed snap so visuals stay smooth
snappedPoint = cachedSnappedPoint.current ? cachedSnappedPoint.current.clone() : null;
snapKind = cachedSnapKind.current;
} }
// ── Dwell detection (1 s) to auto-register hovered hole/edge ───── // ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
@@ -209,9 +231,13 @@ export function XRControllerMeasure() {
if (snappedPoint) { if (snappedPoint) {
tipRef.current.visible = true; tipRef.current.visible = true;
tipRef.current.position.copy(snappedPoint); 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 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); tipRef.current.scale.setScalar(s);
} }
} }
@@ -221,21 +247,33 @@ export function XRControllerMeasure() {
if (!trigState.current && trigVal > TRIG_ON) { if (!trigState.current && trigVal > TRIG_ON) {
trigState.current = true; trigState.current = true;
const st = useModelStore.getState(); const st = useModelStore.getState();
if (st.selectionMode && hit && hit.object instanceof THREE.Mesh) { if (st.selectionMode) {
// Walk up to find ifcElement + modelId // Fresh raycast on the press itself (not every frame) for selection
let cur: THREE.Object3D | null = hit.object; const triggerHits = raycaster.current.intersectObjects(scene.children, true);
let element: THREE.Object3D | null = null; const triggerHit = triggerHits.find((h) => {
let modelId: string | null = null; const o = h.object;
while (cur) { if (!(o instanceof THREE.Mesh)) return false;
if (!element && cur.userData?.ifcElement) element = cur; if (o.userData.__edgeLine) return false;
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string; if (o.geometry instanceof THREE.SphereGeometry) return false;
cur = cur.parent; if (o.geometry instanceof THREE.RingGeometry) return false;
} if (o.geometry instanceof THREE.PlaneGeometry) return false;
if (!element) element = hit.object; return true;
if (!modelId) modelId = st.activeModelId; });
if (modelId && element) { if (triggerHit && triggerHit.object instanceof THREE.Mesh) {
const id = element.userData?.ifcId ?? element.name ?? element.uuid; let cur: THREE.Object3D | null = triggerHit.object;
st.toggleElementSelection(`${modelId}:${id}`); let element: THREE.Object3D | null = null;
let modelId: string | null = null;
while (cur) {
if (!element && cur.userData?.ifcElement) element = cur;
if (!modelId && cur.userData?.modelId) modelId = cur.userData.modelId as string;
cur = cur.parent;
}
if (!element) element = triggerHit.object;
if (!modelId) modelId = st.activeModelId;
if (modelId && element) {
const id = element.userData?.ifcId ?? element.name ?? element.uuid;
st.toggleElementSelection(`${modelId}:${id}`);
}
} }
} else if (snappedPoint) { } else if (snappedPoint) {
st.addMeasurePoint({ st.addMeasurePoint({
+7 -5
View File
@@ -6,8 +6,8 @@ import { useModelStore } from '@/stores/useModelStore';
interface XRGrabbableProps { interface XRGrabbableProps {
/** /**
* @deprecated Mantido por compatibilidade — agora o zoom por duas mãos * Quando true (padrão), o grip duplo aplica ZOOM uniforme além da rotação.
* está SEMPRE ativo (faz parte do mapeamento padrão do grip). * Quando false, o grip duplo só ORBITA — a escala da peça é preservada.
*/ */
allowScale?: boolean; allowScale?: boolean;
/** When true, the active model is locked — grab is ignored entirely */ /** 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). * 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<THREE.Group>(null); const groupRef = useRef<THREE.Group>(null);
const grab = useControllerGrab(); const grab = useControllerGrab();
@@ -121,8 +121,10 @@ export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRG
const d = dual.current; const d = dual.current;
const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow);
// ZOOM sempre ativo no grip duplo // ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita).
const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10); 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 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); const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z);
+36 -20
View File
@@ -328,6 +328,15 @@ function ToolsTab(p: XRHudInWorldProps) {
const setShowGrid = useModelStore((s) => s.setShowGrid); const setShowGrid = useModelStore((s) => s.setShowGrid);
const measureMode = useModelStore((s) => s.measureMode); const measureMode = useModelStore((s) => s.measureMode);
const setMeasureMode = useModelStore((s) => s.setMeasureMode); 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 scaleRatio = useModelStore((s) => s.scaleRatio);
const setScaleRatio = useModelStore((s) => s.setScaleRatio); const setScaleRatio = useModelStore((s) => s.setScaleRatio);
const resetScale = useModelStore((s) => s.resetScale); const resetScale = useModelStore((s) => s.resetScale);
@@ -350,57 +359,64 @@ function ToolsTab(p: XRHudInWorldProps) {
active={measureMode} onClick={() => setMeasureMode(!measureMode)} /> active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`} <XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
active={p.snapToPlanes} onClick={p.onToggleSnap} /> active={p.snapToPlanes} onClick={p.onToggleSnap} />
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Escala ${p.allowScale ? 'ON' : 'OFF'}`} <XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={p.placementMode ? 'Posic…' : 'Reposic.'} label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} /> active={p.placementMode} onClick={p.onTogglePlacement} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={`Zoom ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<Text position={[-0.24, -0.02, 0]} fontSize={0.008} color="#94a3b8" anchorX="left"> {/* Selecionar — paridade com a tela inicial */}
<Text position={[-0.24, -0.005, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Selecionar</Text>
<XR3DButton position={[-0.16, -0.03, 0]} size={[0.075, 0.022]}
label={selectionMode ? (selectedCount > 0 ? `${selectedCount} sel.` : 'Selec…') : 'Selec'}
active={selectionMode} onClick={() => setSelectionMode(!selectionMode)} />
<XR3DButton position={[-0.08, -0.03, 0]} size={[0.075, 0.022]} label="Esconder"
onClick={hideSelectedElements} />
<XR3DButton position={[0.0, -0.03, 0]} size={[0.075, 0.022]} label="Isolar"
onClick={isolateSelectedElements} />
<XR3DButton position={[0.08, -0.03, 0]} size={[0.075, 0.022]} label="Mostrar"
active={hasHidden} onClick={showAllElements} />
<Text position={[-0.24, -0.06, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}% Opacidade {Math.round(opacity * 100)}%
</Text> </Text>
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="25%" <XR3DButton position={[-0.16, -0.085, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} /> onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="5%" <XR3DButton position={[-0.105, -0.085, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} /> onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%" <XR3DButton position={[-0.05, -0.085, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} /> onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%" <XR3DButton position={[0.005, -0.085, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} /> onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%" <XR3DButton position={[0.06, -0.085, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} /> onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.085, 0]} fontSize={0.008} color="#94a3b8" anchorX="left"> <Text position={[-0.24, -0.115, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label} Escala {scaleRatio.label}
</Text> </Text>
{SCALE_LABELS.map((lbl, i) => { {SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!; const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
return ( return (
<XR3DButton key={lbl} <XR3DButton key={lbl}
position={[-0.16 + i * 0.06, -0.11, 0]} size={[0.055, 0.022]} label={lbl} position={[-0.16 + i * 0.06, -0.14, 0]} size={[0.055, 0.022]} label={lbl}
active={scaleRatio.label === lbl} active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} /> onClick={() => setScaleRatio(preset)} />
); );
})} })}
{/* Reset Escala — devolve a peça ao tamanho 1:1 (cancela qualquer <XR3DButton position={[0.10, -0.14, 0]} size={[0.062, 0.022]}
escala manual feita com duas mãos). */}
<XR3DButton position={[0.10, -0.11, 0]} size={[0.062, 0.022]}
label="↺ Reset" color="#dc2626" label="↺ Reset" color="#dc2626"
onClick={resetScale} fontSize={0.0085} /> onClick={resetScale} fontSize={0.0085} />
{/* Limpa todos os labels de medição. */} <XR3DButton position={[0.17, -0.14, 0]} size={[0.062, 0.022]}
<XR3DButton position={[0.17, -0.11, 0]} size={[0.062, 0.022]}
label="✕ Medidas" color="#dc2626" label="✕ Medidas" color="#dc2626"
onClick={clearMeasurements} fontSize={0.0085} /> onClick={clearMeasurements} fontSize={0.0085} />
{/* Legenda dos controles para Medir */}
{measureMode && ( {measureMode && (
<Text position={[-0.24, -0.145, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}> <Text position={[-0.24, -0.17, 0]} fontSize={0.0065} color="#a3e635" anchorX="left" maxWidth={0.5}>
Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap
</Text> </Text>
)} )}
{/* Grid floor controls */}
<GridFloorRow /> <GridFloorRow />
</group> </group>
); );
+2 -3
View File
@@ -471,9 +471,8 @@ const XRSession = () => {
const [freeMove, setFreeMove] = useState(true); const [freeMove, setFreeMove] = useState(true);
const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [placementMode, setPlacementMode] = useState(true); // start in placement mode
const [snapToPlanes, setSnapToPlanes] = useState(true); const [snapToPlanes, setSnapToPlanes] = useState(true);
// Zoom por grip duplo é SEMPRE ligado (parte do mapeamento padrão). // Zoom por grip duplo — toggle real, padrão ligado.
const allowScale = true; const [allowScale, setAllowScale] = useState(true);
const setAllowScale = (_v?: boolean) => {};
const [liveCode, setLiveCode] = useState<string | null>(null); const [liveCode, setLiveCode] = useState<string | null>(null);
const [liveViewers, setLiveViewers] = useState(0); const [liveViewers, setLiveViewers] = useState(0);
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui. // Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.