🚀 Auto-deploy: melhoria no snap e medição AR em 24/05/2026 01:33:36
This commit is contained in:
@@ -1,869 +0,0 @@
|
||||
import { useEffect, useRef, useMemo, useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Canvas, useFrame, useThree } from '@react-three/fiber';
|
||||
import { useGLTF, Grid, Html, Line, OrbitControls, Text } from '@react-three/drei';
|
||||
import { XR, createXRStore, useXR, XROrigin } from '@react-three/xr';
|
||||
import * as THREE from 'three';
|
||||
import { useModelStore } from '@/stores/useModelStore';
|
||||
import { toast } from 'sonner';
|
||||
import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generateMarkerDownloadURL } from '@/lib/trackingMarker';
|
||||
import { XRHud } from '@/components/XRHud';
|
||||
import { ShareButton } from '@/components/ShareButton';
|
||||
import { XRHudInWorld } from '@/components/three/XRHudInWorld';
|
||||
import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror';
|
||||
import { findNearestVertex } from '@/components/three/SmartMeasure';
|
||||
import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement';
|
||||
import { XRGrabbable } from '@/components/three/XRGrabbable';
|
||||
import { XRControllerMeasure } from '@/components/three/XRControllerMeasure';
|
||||
import { ControllerLocomotion } from '@/components/three/ControllerLocomotion';
|
||||
// DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit
|
||||
import { useDevKit } from '@/devkit/useDevKit';
|
||||
import { DevPanel } from '@/devkit/DevPanel';
|
||||
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||
import { VisibilityApplier } from '@/components/three/ModelViewer';
|
||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
// --- Diagnóstico XR ---
|
||||
console.log('[XR] Inicializando store...');
|
||||
if (navigator.xr) {
|
||||
navigator.xr.isSessionSupported('immersive-ar').then((supported) => {
|
||||
console.log('[XR] immersive-ar suportado:', supported);
|
||||
});
|
||||
}
|
||||
|
||||
// Lê flags de feature WebXR do localStorage (default: todas ON).
|
||||
// O usuário liga/desliga pelo HUD AR (aba WebXR) para diagnosticar qual
|
||||
// feature ativa o grid de segurança do Quest. Mudanças exigem sair e re-entrar do AR.
|
||||
function loadXRFeatures() {
|
||||
const defaults = {
|
||||
handTracking: true, planeDetection: true, hitTest: true, domOverlay: true,
|
||||
anchors: true, meshDetection: true, depthSensing: true, layers: true,
|
||||
bodyTracking: true, lightEstimation: true,
|
||||
};
|
||||
try {
|
||||
const raw = localStorage.getItem('xrFeatures');
|
||||
if (raw) return { ...defaults, ...JSON.parse(raw) };
|
||||
} catch {}
|
||||
return defaults;
|
||||
}
|
||||
const _xrf = loadXRFeatures();
|
||||
console.log('[XR] Features:', _xrf);
|
||||
|
||||
const store = createXRStore({
|
||||
hand: { left: true, right: true },
|
||||
controller: { left: true, right: true },
|
||||
handTracking: _xrf.handTracking,
|
||||
planeDetection: _xrf.planeDetection,
|
||||
hitTest: _xrf.hitTest,
|
||||
domOverlay: _xrf.domOverlay,
|
||||
anchors: _xrf.anchors,
|
||||
meshDetection: _xrf.meshDetection,
|
||||
depthSensing: _xrf.depthSensing,
|
||||
layers: _xrf.layers,
|
||||
bodyTracking: _xrf.bodyTracking,
|
||||
});
|
||||
|
||||
// ─── XRModel ───────────────────────────────────────────
|
||||
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
|
||||
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
||||
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const isIfc = sceneModel.fileName.toLowerCase().endsWith('.ifc');
|
||||
if (isIfc) {
|
||||
fetch(sceneModel.url)
|
||||
.then((res) => res.arrayBuffer())
|
||||
.then((buf) => parseIFCtoThree(buf))
|
||||
.then((threeScene) => {
|
||||
if (active) setRawScene(threeScene);
|
||||
})
|
||||
.catch((err) => console.error('[XRModel] IFC parsing error', err));
|
||||
} else {
|
||||
const loader = new GLTFLoader();
|
||||
loader.load(
|
||||
sceneModel.url,
|
||||
(gltf) => {
|
||||
if (active) setRawScene(gltf.scene);
|
||||
},
|
||||
undefined,
|
||||
(err) => console.error('[XRModel] GLTF loading error', err)
|
||||
);
|
||||
}
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [sceneModel.url, sceneModel.fileName]);
|
||||
|
||||
const ref = useRef<THREE.Group>(null);
|
||||
useEffect(() => {
|
||||
const g = ref.current;
|
||||
if (!g) return;
|
||||
registerModelLocalGroup(sceneModel.id, g);
|
||||
return () => unregisterModelLocalGroup(sceneModel.id, g);
|
||||
}, [sceneModel.id]);
|
||||
|
||||
const fineTuning = sceneModel.fineTuning;
|
||||
const opacity = useModelStore((s) => s.opacity);
|
||||
const renderMode = useModelStore((s) => s.renderMode);
|
||||
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
||||
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
||||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||||
const checklist = useModelStore((s) => s.checklist);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
|
||||
const modelInfo = useMemo(() => {
|
||||
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
||||
const box = new THREE.Box3().setFromObject(scene);
|
||||
const size = new THREE.Vector3();
|
||||
const center = new THREE.Vector3();
|
||||
box.getSize(size);
|
||||
box.getCenter(center);
|
||||
return { size, center };
|
||||
}, [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!scene) return;
|
||||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||
const allApproved = checklist.every(i => i.status === 'approved');
|
||||
|
||||
scene.traverse((child) => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
if (Array.isArray(child.material)) {
|
||||
child.material = child.material.map(m => m.clone());
|
||||
} else {
|
||||
child.material = child.material.clone();
|
||||
}
|
||||
|
||||
const toRemove: THREE.Object3D[] = [];
|
||||
child.children.forEach(c => {
|
||||
if (c.userData.__edgeLine) toRemove.push(c);
|
||||
});
|
||||
toRemove.forEach(c => {
|
||||
if (c instanceof THREE.LineSegments) {
|
||||
c.geometry.dispose();
|
||||
(c.material as THREE.Material).dispose();
|
||||
}
|
||||
child.remove(c);
|
||||
});
|
||||
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach((mat: THREE.Material) => {
|
||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
|
||||
mat.visible = true;
|
||||
const targetOpacity = isEdgesOrMeasure ? 0.25 : opacity;
|
||||
mat.transparent = targetOpacity < 1;
|
||||
mat.opacity = targetOpacity;
|
||||
mat.wireframe = false;
|
||||
if (hasRejected) {
|
||||
mat.color.setHSL(0, 0.7, 0.5);
|
||||
} else if (allApproved) {
|
||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||
} else {
|
||||
mat.color.set(sceneModel.color);
|
||||
}
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
});
|
||||
|
||||
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||
const lineMat = new THREE.LineBasicMaterial({
|
||||
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
|
||||
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
|
||||
});
|
||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||||
lineSegments.userData.__edgeLine = true;
|
||||
child.add(lineSegments);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
||||
|
||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
|
||||
const s = fineTuning.scale ?? 1;
|
||||
const scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||
const renderFactor = scaleRatio?.factor ?? 1;
|
||||
|
||||
if (!sceneModel.visible) return null;
|
||||
if (!scene) return null;
|
||||
|
||||
return (
|
||||
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
||||
<group
|
||||
ref={ref}
|
||||
userData={{ modelId: sceneModel.id }}
|
||||
position={[
|
||||
-modelInfo.center.x + fineTuning.posX,
|
||||
-modelInfo.center.y + fineTuning.posY,
|
||||
-modelInfo.center.z + fineTuning.posZ,
|
||||
]}
|
||||
rotation={[rotXRad, rotYRad, rotZRad]}
|
||||
scale={[s, s, s]}
|
||||
>
|
||||
<primitive object={scene} />
|
||||
<XRLocalModelMeasurements modelId={sceneModel.id} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders all NON-active models as static background (placement + grab apply only to active). */
|
||||
function XRBackgroundModels() {
|
||||
const models = useModelStore((s) => s.models);
|
||||
const activeId = useModelStore((s) => s.activeModelId);
|
||||
return (
|
||||
<>
|
||||
{models.filter(m => m.id !== activeId).map((m) => (
|
||||
<XRModel key={m.id} sceneModel={m} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders the currently-active model (the one wrapped by grab/placement). */
|
||||
function XRActiveModel() {
|
||||
const models = useModelStore((s) => s.models);
|
||||
const activeId = useModelStore((s) => s.activeModelId);
|
||||
const active = models.find(m => m.id === activeId);
|
||||
if (!active) return null;
|
||||
return <XRModel sceneModel={active} />;
|
||||
}
|
||||
|
||||
// ─── ControllerFineTuning ──────────────────────────────
|
||||
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
||||
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
||||
// strong = fast. Standard VR mapping: left=lateral/forward, right=rotY/height.
|
||||
function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||
const { setFineTuning } = useModelStore();
|
||||
const session = useXR((s) => s.session);
|
||||
const isActiveLocked = useModelStore((s) => {
|
||||
const a = s.models.find(m => m.id === s.activeModelId);
|
||||
return !!a?.locked;
|
||||
});
|
||||
|
||||
useFrame(() => {
|
||||
if (!session || isActiveLocked) return;
|
||||
const inputSources = session.inputSources;
|
||||
if (!inputSources) return;
|
||||
|
||||
let leftAxes: number[] | null = null;
|
||||
let rightAxes: number[] | null = null;
|
||||
let leftTrig = 0;
|
||||
let rightTrig = 0;
|
||||
let gripHeld = false;
|
||||
let anyGripHeld = false;
|
||||
|
||||
for (const source of inputSources) {
|
||||
const gp = source.gamepad;
|
||||
if (!gp) continue;
|
||||
const gripBtn = gp.buttons[2];
|
||||
const trigBtn = gp.buttons[1];
|
||||
const gripVal = gripBtn ? (gripBtn.value || (gripBtn.pressed ? 1 : 0)) : 0;
|
||||
if (gripBtn?.pressed) gripHeld = true;
|
||||
if (gripVal > 0.3) anyGripHeld = true; // matches grab hysteresis OFF
|
||||
const trigVal = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||
if (source.handedness === 'left' && gp.axes.length >= 4) {
|
||||
leftAxes = [gp.axes[2], gp.axes[3]];
|
||||
leftTrig = trigVal;
|
||||
}
|
||||
if (source.handedness === 'right' && gp.axes.length >= 4) {
|
||||
rightAxes = [gp.axes[2], gp.axes[3]];
|
||||
rightTrig = trigVal;
|
||||
}
|
||||
}
|
||||
|
||||
// Grab takes priority — joystick is disabled while grabbing
|
||||
if (anyGripHeld) return;
|
||||
// In freeMove mode, joystick always moves. Otherwise requires grip — but
|
||||
// grip is reserved for grab now, so freeMove=false essentially disables.
|
||||
if (!freeMove && !gripHeld) return;
|
||||
|
||||
const baseStep = 0.001;
|
||||
const baseRot = 0.1;
|
||||
const deadzone = 0.15;
|
||||
// Trigger modulation: 0 → 0.3×, 1 → 3×
|
||||
const speedL = 0.3 + leftTrig * 2.7;
|
||||
const speedR = 0.3 + rightTrig * 2.7;
|
||||
// Quadratic acceleration on thumbstick value
|
||||
const curve = (v: number) => Math.sign(v) * v * v;
|
||||
|
||||
const modelStore = useModelStore.getState();
|
||||
const ft = { ...modelStore.fineTuning };
|
||||
|
||||
if (leftAxes) {
|
||||
// Left: X (horizontal) / Z forward-back (vertical, up = forward = -Z)
|
||||
if (Math.abs(leftAxes[0]) > deadzone) ft.posX += curve(leftAxes[0]) * baseStep * speedL;
|
||||
if (Math.abs(leftAxes[1]) > deadzone) ft.posZ += curve(leftAxes[1]) * baseStep * speedL;
|
||||
}
|
||||
if (rightAxes) {
|
||||
// Right: rotY (horizontal) / Y height (vertical, up = up)
|
||||
if (Math.abs(rightAxes[0]) > deadzone) ft.rotY += curve(rightAxes[0]) * baseRot * speedR;
|
||||
if (Math.abs(rightAxes[1]) > deadzone) ft.posY -= curve(rightAxes[1]) * baseStep * speedR;
|
||||
}
|
||||
|
||||
setFineTuning(ft);
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Componente de renderização de texto compatível e seguro para WebXR
|
||||
function XRMeasurementText({ text, color }: { text: string; color: string }) {
|
||||
const texture = useMemo(() => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 256;
|
||||
canvas.height = 64;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Limpa e desenha fundo semi-transparente
|
||||
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
||||
ctx.beginPath();
|
||||
// Desenha retângulo arredondado manualmente
|
||||
const x = 0, y = 0, width = 256, height = 64, radius = 12;
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + width - radius, y);
|
||||
ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
|
||||
ctx.lineTo(x + width, y + height - radius);
|
||||
ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
|
||||
ctx.lineTo(x + radius, y + height);
|
||||
ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
// Desenha borda sutil
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.stroke();
|
||||
|
||||
// Texto
|
||||
ctx.fillStyle = color;
|
||||
ctx.font = 'bold 24px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, 128, 32);
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.colorSpace = THREE.SRGBColorSpace;
|
||||
return tex;
|
||||
}, [text, color]);
|
||||
|
||||
return (
|
||||
<mesh renderOrder={999}>
|
||||
<planeGeometry args={[0.08, 0.02]} />
|
||||
<meshBasicMaterial
|
||||
map={texture}
|
||||
transparent
|
||||
depthTest={false}
|
||||
side={THREE.DoubleSide}
|
||||
/>
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
|
||||
// Componente de linha nativa seguro para WebXR
|
||||
export function XRSafeLine({ a, b }: { a: [number, number, number]; b: [number, number, number] }) {
|
||||
const points = useMemo(() => [
|
||||
new THREE.Vector3(a[0], a[1], a[2]),
|
||||
new THREE.Vector3(b[0], b[1], b[2])
|
||||
], [a, b]);
|
||||
|
||||
const geometry = useMemo(() => {
|
||||
return new THREE.BufferGeometry().setFromPoints(points);
|
||||
}, [points]);
|
||||
|
||||
return (
|
||||
<line geometry={geometry}>
|
||||
<lineBasicMaterial color="#22c55e" linewidth={2} />
|
||||
</line>
|
||||
);
|
||||
}
|
||||
|
||||
// Componente para renderizar medições que pertencem a um modelo específico
|
||||
export function XRLocalModelMeasurements({ modelId }: { modelId: string }) {
|
||||
const measurements = useModelStore((s) => s.measurements);
|
||||
const modelMeasurements = useMemo(() => {
|
||||
return measurements.filter(m => m.modelId === modelId);
|
||||
}, [measurements, modelId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modelMeasurements.map((m) => {
|
||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
||||
const isHole = m.kind === 'hole';
|
||||
const color = isHole ? '#f59e0b' : '#3b82f6';
|
||||
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
||||
|
||||
return (
|
||||
<group key={m.id}>
|
||||
{!isHole && (
|
||||
<>
|
||||
<mesh position={a}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
<mesh position={b}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
<XRSafeLine a={a} b={b} />
|
||||
</>
|
||||
)}
|
||||
<group position={mid}>
|
||||
<XRMeasurementText text={text} color={color} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Measurement overlay (reused from ModelViewer) ─────
|
||||
function XRMeasurementOverlay() {
|
||||
const measurements = useModelStore((s) => s.measurements);
|
||||
const measurePoints = useModelStore((s) => s.measurePoints);
|
||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
|
||||
const globalMeasurements = useMemo(() => {
|
||||
return measurements.filter(m => !m.modelId);
|
||||
}, [measurements]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{measureMode && snapPoint && (
|
||||
<mesh position={[snapPoint.x, snapPoint.y, snapPoint.z]} rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<ringGeometry args={[0.003, 0.005, 24]} />
|
||||
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
)}
|
||||
{measurePoints.length === 1 && (
|
||||
<mesh position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#e8a838" />
|
||||
</mesh>
|
||||
)}
|
||||
{globalMeasurements.map((m) => {
|
||||
const a: [number, number, number] = [m.pointA.x, m.pointA.y, m.pointA.z];
|
||||
const b: [number, number, number] = [m.pointB.x, m.pointB.y, m.pointB.z];
|
||||
const mid: [number, number, number] = [(a[0]+b[0])/2, (a[1]+b[1])/2, (a[2]+b[2])/2];
|
||||
const isHole = m.kind === 'hole';
|
||||
const color = isHole ? '#f59e0b' : '#3b82f6';
|
||||
const text = m.label ?? `${m.distanceMM.toFixed(1)} mm`;
|
||||
|
||||
return (
|
||||
<group key={m.id}>
|
||||
{!isHole && (
|
||||
<>
|
||||
<mesh position={a}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
<mesh position={b}>
|
||||
<sphereGeometry args={[0.003, 16, 16]} />
|
||||
<meshBasicMaterial color="#22c55e" />
|
||||
</mesh>
|
||||
<XRSafeLine a={a} b={b} />
|
||||
</>
|
||||
)}
|
||||
<group position={mid}>
|
||||
<XRMeasurementText text={text} color={color} />
|
||||
</group>
|
||||
</group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Snap handler for XR raycasting ────────────────────
|
||||
function XRSnapHandler() {
|
||||
const { camera, scene, gl } = useThree();
|
||||
const measureMode = useModelStore((s) => s.measureMode);
|
||||
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
|
||||
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
|
||||
const snapPoint = useModelStore((s) => s.snapPoint);
|
||||
const raycaster = useMemo(() => new THREE.Raycaster(), []);
|
||||
const mouse = useMemo(() => new THREE.Vector2(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!measureMode) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const rect = gl.domElement.getBoundingClientRect();
|
||||
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
};
|
||||
gl.domElement.addEventListener('pointermove', onMove);
|
||||
return () => gl.domElement.removeEventListener('pointermove', onMove);
|
||||
}, [gl, mouse, measureMode]);
|
||||
|
||||
useFrame(() => {
|
||||
// Em AR imersivo, a medição é feita pelo XRControllerMeasure (gatilho do controle).
|
||||
// Evita raycast pesado por frame que travava o Quest ao ativar "Medir".
|
||||
if (gl.xr.isPresenting) { setSnapPoint(null); return; }
|
||||
if (!measureMode) { setSnapPoint(null); return; }
|
||||
raycaster.setFromCamera(mouse, camera);
|
||||
const intersects = raycaster.intersectObjects(scene.children, true);
|
||||
const hit = intersects.find(i => {
|
||||
const obj = i.object;
|
||||
if (obj instanceof THREE.GridHelper) return false;
|
||||
if (obj instanceof THREE.Mesh && (obj.geometry instanceof THREE.SphereGeometry || obj.geometry instanceof THREE.RingGeometry)) return false;
|
||||
if (obj.userData.__edgeLine) return false;
|
||||
return obj instanceof THREE.Mesh;
|
||||
});
|
||||
if (hit && hit.object instanceof THREE.Mesh) {
|
||||
const canvas = gl.domElement;
|
||||
const snap = findNearestVertex(hit.object, hit.point, camera, { width: canvas.clientWidth, height: canvas.clientHeight }, 10);
|
||||
setSnapPoint(snap ? { x: snap.x, y: snap.y, z: snap.z } : null);
|
||||
} else {
|
||||
setSnapPoint(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Click to measure
|
||||
useEffect(() => {
|
||||
if (!measureMode) return;
|
||||
if (gl.xr.isPresenting) return; // Ignora cliques simulados do DOM quando o WebXR está ativo
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (snapPoint) {
|
||||
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
|
||||
return;
|
||||
}
|
||||
const rect = gl.domElement.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 => i.object instanceof THREE.Mesh && !i.object.userData.__edgeLine);
|
||||
if (hit) addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
|
||||
};
|
||||
gl.domElement.addEventListener('click', onClick);
|
||||
return () => gl.domElement.removeEventListener('click', onClick);
|
||||
}, [measureMode, snapPoint, gl, camera, scene, raycaster, mouse, addMeasurePoint]);
|
||||
|
||||
useEffect(() => {
|
||||
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
|
||||
return () => { gl.domElement.style.cursor = 'grab'; };
|
||||
}, [measureMode, gl]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── XR Grid ───────────────────────────────────────────
|
||||
function XRGrid() {
|
||||
const showGrid = useModelStore((s) => s.showGrid);
|
||||
const gridY = useModelStore((s) => s.gridY);
|
||||
if (!showGrid) return null;
|
||||
return (
|
||||
<Grid
|
||||
position={[0, gridY, 0]}
|
||||
infiniteGrid
|
||||
cellSize={0.01}
|
||||
sectionSize={0.1}
|
||||
cellThickness={0.5}
|
||||
sectionThickness={1}
|
||||
cellColor="#334155"
|
||||
sectionColor="#475569"
|
||||
fadeDistance={5}
|
||||
fadeStrength={1}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Auto-follow grid to model bottom when enabled. */
|
||||
function XRGridAutoFollower() {
|
||||
const models = useModelStore((s) => s.models);
|
||||
const { scene } = useThree();
|
||||
useEffect(() => {
|
||||
if (!useModelStore.getState().gridAutoFollow) return;
|
||||
const id = setTimeout(() => {
|
||||
if (!useModelStore.getState().gridAutoFollow) return;
|
||||
const box = new THREE.Box3();
|
||||
let has = false;
|
||||
scene.traverse((obj) => {
|
||||
if (obj instanceof THREE.Mesh && obj.geometry) {
|
||||
if (obj.geometry instanceof THREE.SphereGeometry) return;
|
||||
if (obj.geometry instanceof THREE.RingGeometry) return;
|
||||
if (obj.userData.__edgeLine) return;
|
||||
obj.updateWorldMatrix(true, false);
|
||||
const b = new THREE.Box3().setFromObject(obj);
|
||||
if (isFinite(b.min.y)) {
|
||||
if (!has) { box.copy(b); has = true; }
|
||||
else box.union(b);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (has) useModelStore.setState({ gridY: box.min.y - 0.005 });
|
||||
}, 150);
|
||||
return () => clearTimeout(id);
|
||||
}, [models, scene]);
|
||||
return null;
|
||||
}
|
||||
|
||||
// ImageTrackingAnchor removed — replaced by XRHitTestPlacement
|
||||
|
||||
// ─── XRSession Page ────────────────────────────────────
|
||||
const XRSession = () => {
|
||||
const navigate = useNavigate();
|
||||
const { model, anchorMode, setAnchorMode } = useModelStore();
|
||||
const isActiveLocked = useModelStore((s) => {
|
||||
const a = s.models.find(m => m.id === s.activeModelId);
|
||||
return !!a?.locked;
|
||||
});
|
||||
const [inXR, setInXR] = useState(false);
|
||||
const [freeMove, setFreeMove] = useState(true);
|
||||
const [placementMode, setPlacementMode] = useState(true); // start in placement mode
|
||||
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||
// Zoom por grip duplo — toggle real, padrão ligado.
|
||||
const [allowScale, setAllowScale] = useState(true);
|
||||
const [liveCode, setLiveCode] = useState<string | null>(null);
|
||||
const [liveViewers, setLiveViewers] = useState(0);
|
||||
// Rig que envolve <XROrigin/> — locomoção por joystick escreve aqui.
|
||||
const rigRef = useRef<THREE.Group>(null);
|
||||
// DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session
|
||||
const devkit = useDevKit();
|
||||
const [simXR, setSimXR] = useState(false);
|
||||
const effectiveInXR = inXR || simXR;
|
||||
|
||||
useEffect(() => {
|
||||
if (!model) navigate('/');
|
||||
}, [model, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.subscribe((state) => {
|
||||
const session = state.session;
|
||||
if (session && !inXR) {
|
||||
console.log('[XR] ✅ Sessão AR ativa!');
|
||||
setInXR(true);
|
||||
setAnchorMode('manual');
|
||||
toast.success('Sessão AR iniciada!');
|
||||
session.addEventListener('end', () => {
|
||||
console.log('[XR] ❌ Sessão AR encerrada');
|
||||
setInXR(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [inXR, setAnchorMode]);
|
||||
|
||||
const handleDownloadMarker = useCallback(async () => {
|
||||
const url = await generateMarkerDownloadURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'TrackSteelXR_Marker.png';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success('Marcador baixado — Imprima em 15×15cm');
|
||||
}, []);
|
||||
|
||||
if (!model) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3 z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/viewer')} title="Voltar ao Viewer">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => navigate('/')} title="Tela inicial">
|
||||
<Home className="h-5 w-5" />
|
||||
</Button>
|
||||
<h1 className="font-mono text-sm font-semibold text-foreground">
|
||||
Modo <span className="text-primary">XR Imersivo</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!inXR && (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="gap-1.5" onClick={handleDownloadMarker}>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-xs">Marcador</span>
|
||||
</Button>
|
||||
<Button className="gap-2 glow-primary" onClick={() => {
|
||||
console.log('[XR] Botão AR clicado');
|
||||
store.enterAR().catch((e) => console.error('[XR] enterAR rejeitado:', e));
|
||||
}}>
|
||||
Iniciar Sessão AR
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{inXR && (
|
||||
<span className="font-mono text-xs text-primary flex items-center gap-1.5">
|
||||
<span className="h-2 w-2 rounded-full bg-primary animate-pulse" />
|
||||
XR Ativo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* 3D Canvas */}
|
||||
<div className="flex-1 relative">
|
||||
<Canvas
|
||||
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
||||
camera={{ position: [1.5, 1.2, 1.5], fov: 50, near: 0.01, far: 100 }}
|
||||
className="!bg-transparent"
|
||||
onCreated={({ gl }) => {
|
||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||
gl.setClearColor(0x000000, 0);
|
||||
}}
|
||||
>
|
||||
<XR store={store}>
|
||||
<ambientLight intensity={0.8} />
|
||||
<directionalLight position={[3, 5, 3]} intensity={1.2} />
|
||||
<directionalLight position={[-3, 3, -3]} intensity={0.4} />
|
||||
|
||||
<group ref={rigRef}>
|
||||
<XROrigin />
|
||||
</group>
|
||||
|
||||
{effectiveInXR ? (
|
||||
<>
|
||||
{/* In XR (or SimXR): all models share the same placement origin so
|
||||
switching active doesn't make others appear to "disappear".
|
||||
Only the ACTIVE model receives grab transforms. */}
|
||||
{simXR ? (
|
||||
<group position={[0, 1.0, -1.2]}>
|
||||
<XRBackgroundModels />
|
||||
<XRGrabbable
|
||||
allowScale={allowScale}
|
||||
lockedActive={isActiveLocked}
|
||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||
>
|
||||
<XRActiveModel />
|
||||
</XRGrabbable>
|
||||
</group>
|
||||
) : (
|
||||
<XRHitTestPlacement
|
||||
placementMode={placementMode}
|
||||
snapToPlanes={snapToPlanes}
|
||||
onPlace={() => {
|
||||
setPlacementMode(false);
|
||||
toast.success('Modelo posicionado na superfície!');
|
||||
}}
|
||||
>
|
||||
<XRBackgroundModels />
|
||||
<XRGrabbable
|
||||
allowScale={allowScale}
|
||||
lockedActive={isActiveLocked}
|
||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||
>
|
||||
<XRActiveModel />
|
||||
</XRGrabbable>
|
||||
</XRHitTestPlacement>
|
||||
)}
|
||||
|
||||
<XRMeasurementOverlay />
|
||||
<XRControllerMeasure />
|
||||
<ControllerLocomotion rigRef={rigRef} />
|
||||
<VisibilityApplier />
|
||||
|
||||
|
||||
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
|
||||
<XRHudInWorld
|
||||
freeMove={freeMove}
|
||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||
placementMode={placementMode}
|
||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||
snapToPlanes={snapToPlanes}
|
||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||
allowScale={allowScale}
|
||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||
liveCode={liveCode}
|
||||
liveViewers={liveViewers}
|
||||
onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()}
|
||||
onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()}
|
||||
/>
|
||||
|
||||
{/* Opaque mirror canvas used as the WebRTC source while in AR */}
|
||||
<XRBroadcastMirror enabled />
|
||||
|
||||
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
|
||||
{devkit && simXR && <FakeControllers />}
|
||||
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
|
||||
{simXR && (
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.1}
|
||||
minDistance={0.05}
|
||||
maxDistance={50}
|
||||
target={[0, 1.0, -1.2]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop preview (no headset): show all models with OrbitControls */}
|
||||
<group position={[0, 0, 0]}>
|
||||
<XRBackgroundModels />
|
||||
<XRActiveModel />
|
||||
</group>
|
||||
<XRMeasurementOverlay />
|
||||
<OrbitControls
|
||||
makeDefault
|
||||
enableDamping
|
||||
dampingFactor={0.1}
|
||||
minDistance={0.05}
|
||||
maxDistance={50}
|
||||
target={[0, 0, 0]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<XRSnapHandler />
|
||||
<XRGrid />
|
||||
<XRGridAutoFollower />
|
||||
</XR>
|
||||
</Canvas>
|
||||
|
||||
{/* Floating DOM HUD overlay — only visible OUTSIDE passthrough.
|
||||
Inside AR, the DOM is occluded by the headset compositor; the
|
||||
in-world XRHudInWorld replaces it. */}
|
||||
<div style={{ display: inXR ? 'none' : 'contents' }}>
|
||||
<XRHud
|
||||
freeMove={freeMove}
|
||||
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||
placementMode={placementMode}
|
||||
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||
snapToPlanes={snapToPlanes}
|
||||
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||
allowScale={allowScale}
|
||||
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hidden ShareButton instance — registers window.__trackSteelStartLive
|
||||
so the in-world Share tab can trigger broadcasts. Also keeps live
|
||||
state in sync with the in-XR HUD. */}
|
||||
<div style={{ display: 'none' }}>
|
||||
<ShareButton
|
||||
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
||||
onViewerCountChange={setLiveViewers}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
|
||||
{devkit && (
|
||||
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default XRSession;
|
||||
Reference in New Issue
Block a user