Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c839653a97 | |||
| b7cd422068 | |||
| c93e56640d | |||
| c85dc85936 | |||
| 63ad53edfb | |||
| 8610ec67f7 | |||
| d8ca2b2e6c | |||
| d64d77dba6 | |||
| 4c43d4c289 | |||
| 5f24e5c85b |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "vite_react_shadcn_ts",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "1.0.4",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+1
-2
@@ -5,9 +5,9 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import Index from "./pages/Index";
|
import Index from "./pages/Index";
|
||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
import XRSession from "./pages/XRSession";
|
|
||||||
import Watch from "./pages/Watch";
|
import Watch from "./pages/Watch";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
|
import "@/lib/remoteLogger";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -20,7 +20,6 @@ const App = () => (
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
<Route path="/xr" element={<XRSession />} />
|
|
||||||
<Route path="/watch/:code" element={<Watch />} />
|
<Route path="/watch/:code" element={<Watch />} />
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -112,13 +112,13 @@ export function ViewerControls() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="gap-2 h-9"
|
className="gap-2 h-9"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
||||||
setRenderMode(next);
|
setRenderMode(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||||
<span className="font-mono text-xs">
|
<span className="font-mono text-xs">
|
||||||
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wireframe' : 'Bordas'}
|
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ const helpItems = [
|
|||||||
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Sólido / Wire / Bordas',
|
title: 'Sólido / Bordas',
|
||||||
icon: '🔲',
|
icon: '🔲',
|
||||||
desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).',
|
desc: 'Alterna modos de renderização: Sólido (preenchido) e Bordas (destaque visual de arestas com transparência).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Medir',
|
title: 'Medir',
|
||||||
@@ -345,12 +345,12 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
variant={renderMode !== 'solid' ? '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={() => {
|
onClick={() => {
|
||||||
const next = renderMode === 'solid' ? 'wireframe' : renderMode === 'wireframe' ? 'edges' : 'solid';
|
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
||||||
setRenderMode(next);
|
setRenderMode(next);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
||||||
{renderMode === 'solid' ? 'Sólido' : renderMode === 'wireframe' ? 'Wire' : 'Bordas'}
|
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Suspense, useRef, useMemo, useEffect, type ReactNode } from 'react';
|
import { Suspense, useRef, useMemo, useEffect, type ReactNode, useState } from 'react';
|
||||||
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
@@ -6,6 +6,32 @@ import * as THREE from 'three';
|
|||||||
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment, detectCircularEdgeAtPoint } from './SmartMeasure';
|
||||||
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
|
||||||
|
// WebXR imersivo
|
||||||
|
import { XR, useXR, XROrigin } from '@react-three/xr';
|
||||||
|
import { xrStore } from '@/lib/xrStore';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
XRBackgroundModels,
|
||||||
|
XRActiveModel,
|
||||||
|
XRMeasurementOverlay,
|
||||||
|
XRSnapHandler,
|
||||||
|
XRGrid,
|
||||||
|
XRGridAutoFollower,
|
||||||
|
} from './XRSceneComponents';
|
||||||
|
import { XRGrabbable } from './XRGrabbable';
|
||||||
|
import { XRHitTestPlacement } from './XRHitTestPlacement';
|
||||||
|
import { XRControllerMeasure } from './XRControllerMeasure';
|
||||||
|
import { ControllerLocomotion } from './ControllerLocomotion';
|
||||||
|
import { XRHudInWorld } from './XRHudInWorld';
|
||||||
|
import { XRBroadcastMirror } from './XRBroadcastMirror';
|
||||||
|
import { XRHud } from '@/components/XRHud';
|
||||||
|
import { ShareButton } from '@/components/ShareButton';
|
||||||
|
import { useDevKit } from '@/devkit/useDevKit';
|
||||||
|
import { DevPanel } from '@/devkit/DevPanel';
|
||||||
|
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url?: string; // legacy, ignored — uses store.models
|
url?: string; // legacy, ignored — uses store.models
|
||||||
@@ -73,9 +99,36 @@ function LoadingFallback() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
|
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
|
||||||
const { scene: rawScene } = useGLTF(sceneModel.url);
|
const [rawScene, setRawScene] = useState<THREE.Object3D | null>(null);
|
||||||
// Clone scene per instance so multiple GLBModels with same url don't share materials/transforms
|
const scene = useMemo(() => rawScene ? rawScene.clone(true) : null, [rawScene]);
|
||||||
const scene = useMemo(() => rawScene.clone(true), [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('[GLBModel] IFC parsing error', err));
|
||||||
|
} else {
|
||||||
|
const loader = new GLTFLoader();
|
||||||
|
loader.load(
|
||||||
|
sceneModel.url,
|
||||||
|
(gltf) => {
|
||||||
|
if (active) setRawScene(gltf.scene);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(err) => console.error('[GLBModel] GLTF loading error', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [sceneModel.url, sceneModel.fileName]);
|
||||||
|
|
||||||
const ref = useRef<THREE.Group>(null);
|
const ref = useRef<THREE.Group>(null);
|
||||||
const opacity = useModelStore((s) => s.opacity);
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
const renderMode = useModelStore((s) => s.renderMode);
|
const renderMode = useModelStore((s) => s.renderMode);
|
||||||
@@ -85,9 +138,11 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
const checklist = useModelStore((s) => s.checklist);
|
const checklist = useModelStore((s) => s.checklist);
|
||||||
const setActive = useModelStore((s) => s.setActiveModel);
|
const setActive = useModelStore((s) => s.setActiveModel);
|
||||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
const selectionMode = useModelStore((s) => s.selectionMode);
|
||||||
|
const measureMode = useModelStore((s) => s.measureMode);
|
||||||
const fineTuning = sceneModel.fineTuning;
|
const fineTuning = sceneModel.fineTuning;
|
||||||
|
|
||||||
const modelInfo = useMemo(() => {
|
const modelInfo = useMemo(() => {
|
||||||
|
if (!scene) return { size: new THREE.Vector3(), center: new THREE.Vector3() };
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
const size = new THREE.Vector3();
|
const size = new THREE.Vector3();
|
||||||
const center = new THREE.Vector3();
|
const center = new THREE.Vector3();
|
||||||
@@ -99,6 +154,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!scene) return;
|
||||||
const hasRejected = checklist.some(i => i.status === 'rejected');
|
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||||
const allApproved = checklist.every(i => i.status === 'approved');
|
const allApproved = checklist.every(i => i.status === 'approved');
|
||||||
|
|
||||||
@@ -129,34 +185,29 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
originalColors.current.set(mat, mat.color.clone());
|
originalColors.current.set(mat, mat.color.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (renderMode === 'edges') {
|
const isEdgesOrMeasure = renderMode === 'edges' || measureMode;
|
||||||
mat.visible = false;
|
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 {
|
} else {
|
||||||
mat.visible = true;
|
// Tint with the per-model color (subtle)
|
||||||
mat.transparent = opacity < 1;
|
mat.color.set(sceneModel.color);
|
||||||
mat.opacity = opacity;
|
|
||||||
mat.wireframe = renderMode === 'wireframe';
|
|
||||||
if (renderMode === 'wireframe') {
|
|
||||||
mat.wireframeLinewidth = wireframeThickness;
|
|
||||||
mat.color.set(wireframeColor);
|
|
||||||
} else if (hasRejected) {
|
|
||||||
mat.color.setHSL(0, 0.7, 0.5);
|
|
||||||
} else if (allApproved) {
|
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
|
||||||
} else {
|
|
||||||
// Tint with the per-model color (subtle)
|
|
||||||
mat.color.set(sceneModel.color);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
mat.needsUpdate = true;
|
mat.needsUpdate = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (renderMode === 'edges' && child.geometry) {
|
if ((renderMode === 'edges' || measureMode) && child.geometry) {
|
||||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||||
const lineMat = new THREE.LineBasicMaterial({
|
const lineMat = new THREE.LineBasicMaterial({
|
||||||
color: wireframeColor,
|
color: (renderMode === 'edges' || measureMode) ? '#00f3ff' : wireframeColor,
|
||||||
linewidth: wireframeThickness,
|
linewidth: (renderMode === 'edges' || measureMode) ? 2 : wireframeThickness,
|
||||||
});
|
});
|
||||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
||||||
lineSegments.userData.__edgeLine = true;
|
lineSegments.userData.__edgeLine = true;
|
||||||
@@ -164,7 +215,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
|
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color, measureMode]);
|
||||||
|
|
||||||
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
||||||
@@ -174,6 +225,7 @@ function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive:
|
|||||||
const renderFactor = scaleRatio?.factor ?? 1;
|
const renderFactor = scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
if (!sceneModel.visible) return null;
|
if (!sceneModel.visible) return null;
|
||||||
|
if (!scene) return null;
|
||||||
|
|
||||||
// Determine dominant local axis of the model (longest bbox side) for axial roll
|
// Determine dominant local axis of the model (longest bbox side) for axial roll
|
||||||
const dominantAxis: 'x' | 'y' | 'z' =
|
const dominantAxis: 'x' | 'y' | 'z' =
|
||||||
@@ -802,7 +854,7 @@ function SelectionHandler() {
|
|||||||
|
|
||||||
/** Walks the scene each time visibility/selection state changes and applies
|
/** Walks the scene each time visibility/selection state changes and applies
|
||||||
* per-element visibility + emissive highlight for selected elements. */
|
* per-element visibility + emissive highlight for selected elements. */
|
||||||
function VisibilityApplier() {
|
export function VisibilityApplier() {
|
||||||
const { scene } = useThree();
|
const { scene } = useThree();
|
||||||
const nonce = useModelStore((s) => s.visibilityNonce);
|
const nonce = useModelStore((s) => s.visibilityNonce);
|
||||||
const models = useModelStore((s) => s.models);
|
const models = useModelStore((s) => s.models);
|
||||||
@@ -978,8 +1030,8 @@ function PositionDragHandler() {
|
|||||||
const ft = active.fineTuning;
|
const ft = active.fineTuning;
|
||||||
const factor = st.scaleRatio?.factor ?? 1;
|
const factor = st.scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
if (button === 2) {
|
if (button === 0) {
|
||||||
// Right button: rotate
|
// Left button (Trigger in Quest emulated mouse): rotate (unifies with AR / VR standard)
|
||||||
const sens = 0.4; // deg per pixel
|
const sens = 0.4; // deg per pixel
|
||||||
if (shiftKey) {
|
if (shiftKey) {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + dx * sens });
|
||||||
@@ -1006,8 +1058,8 @@ function PositionDragHandler() {
|
|||||||
} else {
|
} else {
|
||||||
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
useModelStore.getState().setFineTuning({ rotZ: ft.rotZ + delta });
|
||||||
}
|
}
|
||||||
} else if (button === 0 && shiftKey) {
|
} else if (button === 2 && shiftKey) {
|
||||||
// Shift+left: depth (Z in camera space)
|
// Shift+Right button: depth (Z in camera space)
|
||||||
const worldDelta = dy * pixelsPerWorldUnit;
|
const worldDelta = dy * pixelsPerWorldUnit;
|
||||||
const camDir = new THREE.Vector3();
|
const camDir = new THREE.Vector3();
|
||||||
camera.getWorldDirection(camDir);
|
camera.getWorldDirection(camDir);
|
||||||
@@ -1018,7 +1070,7 @@ function PositionDragHandler() {
|
|||||||
posZ: ft.posZ + move.z,
|
posZ: ft.posZ + move.z,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Left: translate in camera plane
|
// Right button (Grip in Quest emulated mouse): translate in camera plane (matches AR pan-only grip)
|
||||||
const right = new THREE.Vector3();
|
const right = new THREE.Vector3();
|
||||||
const up = new THREE.Vector3();
|
const up = new THREE.Vector3();
|
||||||
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
camera.matrixWorld.extractBasis(right, up, new THREE.Vector3());
|
||||||
@@ -1060,10 +1112,193 @@ function PositionDragHandler() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
function SceneInsideXR() {
|
||||||
|
const isPresenting = useXR((state) => state.isPresenting);
|
||||||
const positionMode = useModelStore((s) => s.positionMode);
|
const positionMode = useModelStore((s) => s.positionMode);
|
||||||
const measureMode = useModelStore((s) => s.measureMode);
|
const activeId = useModelStore((s) => s.activeModelId);
|
||||||
const selectionMode = useModelStore((s) => s.selectionMode);
|
const models = useModelStore((s) => s.models);
|
||||||
|
const activeModel = models.find(m => m.id === activeId);
|
||||||
|
const isActiveLocked = !!activeModel?.locked;
|
||||||
|
|
||||||
|
// Estados locais para a sessão de XR
|
||||||
|
const [freeMove, setFreeMove] = useState(true);
|
||||||
|
const [placementMode, setPlacementMode] = useState(true);
|
||||||
|
const [snapToPlanes, setSnapToPlanes] = useState(true);
|
||||||
|
const [allowScale, setAllowScale] = useState(true);
|
||||||
|
const [liveCode, setLiveCode] = useState<string | null>(null);
|
||||||
|
const [liveViewers, setLiveViewers] = useState(0);
|
||||||
|
|
||||||
|
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 = isPresenting || simXR;
|
||||||
|
|
||||||
|
// Monitorar início e fim da sessão
|
||||||
|
useEffect(() => {
|
||||||
|
if (isPresenting) {
|
||||||
|
toast.success('Sessão AR iniciada!');
|
||||||
|
}
|
||||||
|
}, [isPresenting]);
|
||||||
|
|
||||||
|
if (!effectiveInXR) {
|
||||||
|
// Modo 2D normal (não imersivo)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ambientLight intensity={0.6} />
|
||||||
|
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
||||||
|
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
||||||
|
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<SceneModels />
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<MeasurementOverlay />
|
||||||
|
<MeasureClickHandler />
|
||||||
|
<SmartSnapHandler />
|
||||||
|
<HoverDetector />
|
||||||
|
|
||||||
|
<GridLayer />
|
||||||
|
<GridAutoFollower />
|
||||||
|
|
||||||
|
<PositionDragHandler />
|
||||||
|
<SelectionHandler />
|
||||||
|
<VisibilityApplier />
|
||||||
|
<SceneRefCapture />
|
||||||
|
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
enabled={!positionMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modo AR imersivo (ou SimXR)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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 />
|
||||||
|
<SceneRefCapture />
|
||||||
|
|
||||||
|
<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?.()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<XRBroadcastMirror enabled />
|
||||||
|
|
||||||
|
{devkit && simXR && <FakeControllers />}
|
||||||
|
{simXR && (
|
||||||
|
<OrbitControls
|
||||||
|
makeDefault
|
||||||
|
enableDamping
|
||||||
|
dampingFactor={0.1}
|
||||||
|
minDistance={0.05}
|
||||||
|
maxDistance={50}
|
||||||
|
target={[0, 1.0, -1.2]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<XRSnapHandler />
|
||||||
|
<XRGrid />
|
||||||
|
<XRGridAutoFollower />
|
||||||
|
|
||||||
|
{/* Floating HUD overlay - visível apenas fora do headset passthrough */}
|
||||||
|
<Html fullscreen style={{ pointerEvents: 'none', display: isPresenting ? 'none' : 'contents' }}>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>
|
||||||
|
<XRHud
|
||||||
|
freeMove={freeMove}
|
||||||
|
onToggleFreeMove={() => setFreeMove(!freeMove)}
|
||||||
|
placementMode={placementMode}
|
||||||
|
onTogglePlacement={() => setPlacementMode(!placementMode)}
|
||||||
|
snapToPlanes={snapToPlanes}
|
||||||
|
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
|
||||||
|
allowScale={allowScale}
|
||||||
|
onToggleAllowScale={() => setAllowScale(!allowScale)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* Compartilhar botões ocultos para WebRTC e HUD de fora */}
|
||||||
|
<Html style={{ display: 'none' }}>
|
||||||
|
<div>
|
||||||
|
<ShareButton
|
||||||
|
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
|
||||||
|
onViewerCountChange={setLiveViewers}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
|
||||||
|
{/* DevPanel se devkit habilitado */}
|
||||||
|
{devkit && (
|
||||||
|
<Html fullscreen style={{ pointerEvents: 'none' }}>
|
||||||
|
<div style={{ pointerEvents: 'auto' }}>
|
||||||
|
<DevPanel simXR={simXR} onToggleSimXR={() => setSimXR((v) => !v)} />
|
||||||
|
</div>
|
||||||
|
</Html>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
||||||
return (
|
return (
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
||||||
@@ -1081,36 +1316,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, maxRatio));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<XR store={xrStore}>
|
||||||
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
<SceneInsideXR />
|
||||||
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
</XR>
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
|
||||||
<SceneModels />
|
|
||||||
</Suspense>
|
|
||||||
|
|
||||||
<MeasurementOverlay />
|
|
||||||
<MeasureClickHandler />
|
|
||||||
<SmartSnapHandler />
|
|
||||||
<HoverDetector />
|
|
||||||
|
|
||||||
<GridLayer />
|
|
||||||
<GridAutoFollower />
|
|
||||||
|
|
||||||
<PositionDragHandler />
|
|
||||||
<SelectionHandler />
|
|
||||||
<VisibilityApplier />
|
|
||||||
<SceneRefCapture />
|
|
||||||
|
|
||||||
<OrbitControls
|
|
||||||
makeDefault
|
|
||||||
enableDamping
|
|
||||||
dampingFactor={0.1}
|
|
||||||
minDistance={0.05}
|
|
||||||
maxDistance={50}
|
|
||||||
enabled={!positionMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,8 +252,16 @@ export function findNearestEdgeSegment(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// If no edge lines, generate from EdgesGeometry
|
// If no edge lines, fetch from cache or generate and store in cache
|
||||||
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
let geo: THREE.BufferGeometry;
|
||||||
|
if (edgeLines) {
|
||||||
|
geo = edgeLines.geometry;
|
||||||
|
} else {
|
||||||
|
if (!mesh.userData.__cachedEdgesGeometry) {
|
||||||
|
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
}
|
||||||
|
geo = mesh.userData.__cachedEdgesGeometry;
|
||||||
|
}
|
||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
if (!posAttr) return null;
|
||||||
|
|
||||||
@@ -298,8 +306,6 @@ export function findNearestEdgeSegment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!edgeLines) geo.dispose();
|
|
||||||
|
|
||||||
if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) {
|
if (bestDist <= thresholdPx && bestMid && bestA && bestB && bestLen > 0.5) {
|
||||||
return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB };
|
return { midpoint: bestMid, lengthMM: bestLen, a: bestA, b: bestB };
|
||||||
}
|
}
|
||||||
@@ -321,7 +327,16 @@ export function detectCircularEdgeAtPoint(
|
|||||||
mesh.children.forEach(c => {
|
mesh.children.forEach(c => {
|
||||||
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
|
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
|
||||||
});
|
});
|
||||||
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
|
||||||
|
let geo: THREE.BufferGeometry;
|
||||||
|
if (edgeLines) {
|
||||||
|
geo = edgeLines.geometry;
|
||||||
|
} else {
|
||||||
|
if (!mesh.userData.__cachedEdgesGeometry) {
|
||||||
|
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
}
|
||||||
|
geo = mesh.userData.__cachedEdgesGeometry;
|
||||||
|
}
|
||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
if (!posAttr) return null;
|
||||||
|
|
||||||
@@ -347,7 +362,6 @@ export function detectCircularEdgeAtPoint(
|
|||||||
seen.add(key);
|
seen.add(key);
|
||||||
verts.push(tmp.clone());
|
verts.push(tmp.clone());
|
||||||
}
|
}
|
||||||
if (!edgeLines) geo.dispose();
|
|
||||||
|
|
||||||
if (verts.length < 6) return null;
|
if (verts.length < 6) return null;
|
||||||
|
|
||||||
@@ -497,7 +511,15 @@ export function findNearestEdgeSegment3D(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
let geo: THREE.BufferGeometry;
|
||||||
|
if (edgeLines) {
|
||||||
|
geo = edgeLines.geometry;
|
||||||
|
} else {
|
||||||
|
if (!mesh.userData.__cachedEdgesGeometry) {
|
||||||
|
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
}
|
||||||
|
geo = mesh.userData.__cachedEdgesGeometry;
|
||||||
|
}
|
||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
if (!posAttr) return null;
|
||||||
|
|
||||||
@@ -525,8 +547,6 @@ export function findNearestEdgeSegment3D(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!edgeLines) geo.dispose();
|
|
||||||
|
|
||||||
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
|
if (bestIndex !== -1 && bestDistSq <= thresholdSq) {
|
||||||
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
|
const a = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2).applyMatrix4(matrix);
|
||||||
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
|
const b = new THREE.Vector3().fromBufferAttribute(posAttr, bestIndex * 2 + 1).applyMatrix4(matrix);
|
||||||
@@ -546,7 +566,16 @@ export function detectCircularEdgeAtPoint3D(
|
|||||||
mesh.children.forEach(c => {
|
mesh.children.forEach(c => {
|
||||||
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
|
if (c.userData.__edgeLine && c instanceof THREE.LineSegments) edgeLines = c;
|
||||||
});
|
});
|
||||||
const geo = edgeLines?.geometry ?? new THREE.EdgesGeometry(mesh.geometry, 15);
|
|
||||||
|
let geo: THREE.BufferGeometry;
|
||||||
|
if (edgeLines) {
|
||||||
|
geo = edgeLines.geometry;
|
||||||
|
} else {
|
||||||
|
if (!mesh.userData.__cachedEdgesGeometry) {
|
||||||
|
mesh.userData.__cachedEdgesGeometry = new THREE.EdgesGeometry(mesh.geometry, 15);
|
||||||
|
}
|
||||||
|
geo = mesh.userData.__cachedEdgesGeometry;
|
||||||
|
}
|
||||||
const posAttr = geo.attributes.position;
|
const posAttr = geo.attributes.position;
|
||||||
if (!posAttr) return null;
|
if (!posAttr) return null;
|
||||||
|
|
||||||
@@ -573,7 +602,6 @@ export function detectCircularEdgeAtPoint3D(
|
|||||||
seen.add(key);
|
seen.add(key);
|
||||||
verts.push(tmp.clone().applyMatrix4(matrix));
|
verts.push(tmp.clone().applyMatrix4(matrix));
|
||||||
}
|
}
|
||||||
if (!edgeLines) geo.dispose();
|
|
||||||
|
|
||||||
if (verts.length < 6) return null;
|
if (verts.length < 6) return null;
|
||||||
|
|
||||||
@@ -618,8 +646,8 @@ export function detectCircularEdgeAtPoint3D(
|
|||||||
export function resolveSnap3D(
|
export function resolveSnap3D(
|
||||||
mesh: THREE.Mesh,
|
mesh: THREE.Mesh,
|
||||||
hitPoint: THREE.Vector3,
|
hitPoint: THREE.Vector3,
|
||||||
vertexThresholdMeters: number = 0.035,
|
vertexThresholdMeters: number = 0.07,
|
||||||
edgeThresholdMeters: number = 0.05
|
edgeThresholdMeters: number = 0.09
|
||||||
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
|
): { point: THREE.Vector3; type: 'vertex' | 'edge' | 'surface' } {
|
||||||
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
|
const v = findNearestVertex3D(mesh, hitPoint, vertexThresholdMeters);
|
||||||
if (v) return { point: v, type: 'vertex' };
|
if (v) return { point: v, type: 'vertex' };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useRef, useState } from 'react';
|
|||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
import { sendRemoteLog } from '@/lib/remoteLogger';
|
||||||
import {
|
import {
|
||||||
resolveSnap,
|
resolveSnap,
|
||||||
detectCircularEdgeAtPoint,
|
detectCircularEdgeAtPoint,
|
||||||
@@ -16,6 +17,15 @@ const TRIG_OFF = 0.3;
|
|||||||
const BTN_ON = 0.6;
|
const BTN_ON = 0.6;
|
||||||
const MAX_RAY = 10; // meters
|
const MAX_RAY = 10; // meters
|
||||||
|
|
||||||
|
function isPickableModelMesh(o: THREE.Object3D): boolean {
|
||||||
|
let cur: THREE.Object3D | null = o;
|
||||||
|
while (cur) {
|
||||||
|
if (cur.userData?.modelId) return true;
|
||||||
|
cur = cur.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-controller trigger driven measurement for AR.
|
* Right-controller trigger driven measurement for AR.
|
||||||
*
|
*
|
||||||
@@ -60,17 +70,12 @@ export function XRControllerMeasure() {
|
|||||||
// running them every frame at 72fps can stall the Quest right after
|
// running them every frame at 72fps can stall the Quest right after
|
||||||
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
|
// toggling "Medir" from the Ferramentas tab. We cap heavy work to ~24Hz.
|
||||||
// Trigger/A/B/L-trigger polling continues every frame for responsiveness.
|
// 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();
|
||||||
@@ -115,70 +120,61 @@ export function XRControllerMeasure() {
|
|||||||
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 (doHeavy) {
|
const hits = raycaster.current.intersectObjects(scene.children, true);
|
||||||
const hits = raycaster.current.intersectObjects(scene.children, true);
|
const hit = hits.find((h) => {
|
||||||
const hit = hits.find((h) => {
|
const o = h.object;
|
||||||
const o = h.object;
|
if (!isPickableModelMesh(o)) return false;
|
||||||
if (!(o instanceof THREE.Mesh)) return false;
|
if (o.userData.__edgeLine) return false;
|
||||||
if (o.userData.__edgeLine) return false;
|
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
||||||
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
if (o.geometry instanceof THREE.RingGeometry) return false;
|
||||||
if (o.geometry instanceof THREE.RingGeometry) return false;
|
if (o.geometry instanceof THREE.PlaneGeometry) return false;
|
||||||
if (o.geometry instanceof THREE.PlaneGeometry) return false;
|
return true;
|
||||||
return true;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
if (hit && hit.object instanceof THREE.Mesh) {
|
if (hit && hit.object instanceof THREE.Mesh) {
|
||||||
// Snap to existing registered hole centers first (within ~4cm)
|
const dist = tmpOrigin.current.distanceTo(hit.point);
|
||||||
const existing = useModelStore.getState().measurements;
|
const vertexThreshold = Math.max(0.06, dist * 0.08);
|
||||||
let bestHoleCenter: THREE.Vector3 | null = null;
|
const edgeThreshold = Math.max(0.08, dist * 0.10);
|
||||||
let bestHoleDist = Infinity;
|
|
||||||
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 d = hit.point.distanceTo(c);
|
|
||||||
if (d < 0.04 && d < bestHoleDist) {
|
|
||||||
bestHoleDist = d;
|
|
||||||
bestHoleCenter = c;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (snapEnabled && bestHoleCenter) {
|
// Snap to existing registered hole centers first
|
||||||
snappedPoint = bestHoleCenter;
|
const existing = useModelStore.getState().measurements;
|
||||||
snapKind = 'hole';
|
let bestHoleCenter: THREE.Vector3 | null = null;
|
||||||
} else if (snapEnabled) {
|
let bestHoleDist = Infinity;
|
||||||
// Try detecting a circular edge (hole) at hit (8cm radius)
|
for (const m of existing) {
|
||||||
const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, 0.08);
|
if (m.kind !== 'hole') continue;
|
||||||
if (circle) {
|
const c = new THREE.Vector3(m.pointA.x, m.pointA.y, m.pointA.z);
|
||||||
snappedPoint = circle.center;
|
const d = hit.point.distanceTo(c);
|
||||||
snapKind = 'hole';
|
if (d < 0.05 && d < bestHoleDist) {
|
||||||
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
|
bestHoleDist = d;
|
||||||
} else {
|
bestHoleCenter = c;
|
||||||
// Snap 3D físico puro (3.5cm para vértices, 5cm para arestas)
|
|
||||||
const snap = resolveSnap3D(hit.object, hit.point, 0.035, 0.05);
|
|
||||||
snappedPoint = snap.point;
|
|
||||||
snapKind = snap.type;
|
|
||||||
if (snap.type === 'edge') {
|
|
||||||
const seg = findNearestEdgeSegment3D(hit.object, hit.point, 0.05);
|
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Cache for non-heavy frames so the laser + trigger still have a target.
|
|
||||||
// Sticky: se conseguimos um snap "forte" (vertex/edge/hole), preservamos
|
if (snapEnabled && bestHoleCenter) {
|
||||||
// ele entre frames leves para evitar cintilação.
|
snappedPoint = bestHoleCenter;
|
||||||
cachedSnappedPoint.current = snappedPoint ? snappedPoint.clone() : null;
|
snapKind = 'hole';
|
||||||
cachedSnapKind.current = snapKind;
|
} else if (snapEnabled) {
|
||||||
} else {
|
const circle = detectCircularEdgeAtPoint3D(hit.object, hit.point, Math.max(0.08, dist * 0.12));
|
||||||
// Light frame: reuse the last computed snap so visuals stay smooth
|
if (circle) {
|
||||||
snappedPoint = cachedSnappedPoint.current ? cachedSnappedPoint.current.clone() : null;
|
snappedPoint = circle.center;
|
||||||
snapKind = cachedSnapKind.current;
|
snapKind = 'hole';
|
||||||
|
hoverDetected = { kind: 'hole', value: circle.diameterMM, position: circle.center };
|
||||||
|
} else {
|
||||||
|
const snap = resolveSnap3D(hit.object, hit.point, vertexThreshold, edgeThreshold);
|
||||||
|
snappedPoint = snap.point;
|
||||||
|
snapKind = snap.type;
|
||||||
|
if (snap.type === 'edge') {
|
||||||
|
const seg = findNearestEdgeSegment3D(hit.object, hit.point, edgeThreshold);
|
||||||
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
|
// ── Dwell detection (1 s) to auto-register hovered hole/edge ─────
|
||||||
@@ -235,6 +231,8 @@ export function XRControllerMeasure() {
|
|||||||
const factor = strongSnap ? 0.030 : 0.012;
|
const factor = strongSnap ? 0.030 : 0.012;
|
||||||
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
|
const s = Math.max(strongSnap ? 0.010 : 0.004, dist * factor);
|
||||||
tipRef.current.scale.setScalar(s);
|
tipRef.current.scale.setScalar(s);
|
||||||
|
} else {
|
||||||
|
tipRef.current.visible = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,7 +246,7 @@ export function XRControllerMeasure() {
|
|||||||
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
|
const triggerHits = raycaster.current.intersectObjects(scene.children, true);
|
||||||
const triggerHit = triggerHits.find((h) => {
|
const triggerHit = triggerHits.find((h) => {
|
||||||
const o = h.object;
|
const o = h.object;
|
||||||
if (!(o instanceof THREE.Mesh)) return false;
|
if (!isPickableModelMesh(o)) return false;
|
||||||
if (o.userData.__edgeLine) return false;
|
if (o.userData.__edgeLine) return false;
|
||||||
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
if (o.geometry instanceof THREE.SphereGeometry) return false;
|
||||||
if (o.geometry instanceof THREE.RingGeometry) return false;
|
if (o.geometry instanceof THREE.RingGeometry) return false;
|
||||||
@@ -272,6 +270,7 @@ export function XRControllerMeasure() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (snappedPoint) {
|
} else if (snappedPoint) {
|
||||||
|
sendRemoteLog('info', 'Trigger físico pressionado no AR com snapPoint ativo', snappedPoint);
|
||||||
st.addMeasurePoint({
|
st.addMeasurePoint({
|
||||||
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
x: snappedPoint.x, y: snappedPoint.y, z: snappedPoint.z,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -347,9 +347,7 @@ function ToolsTab(p: XRHudInWorldProps) {
|
|||||||
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
|
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
|
||||||
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
|
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
|
||||||
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
|
||||||
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Wire"
|
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
|
||||||
active={renderMode === 'wireframe'} onClick={() => setRenderMode('wireframe')} />
|
|
||||||
<XR3DButton position={[0.0, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
|
|
||||||
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
|
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
|
||||||
|
|
||||||
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
|
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
|
||||||
|
|||||||
@@ -0,0 +1,528 @@
|
|||||||
|
import { useEffect, useRef, useMemo, useState, useCallback } from 'react';
|
||||||
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
|
import { Grid } from '@react-three/drei';
|
||||||
|
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||||
|
import * as THREE from 'three';
|
||||||
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
|
import { findNearestVertex } from './SmartMeasure';
|
||||||
|
import { registerModelLocalGroup, unregisterModelLocalGroup } from '@/lib/modelTransforms';
|
||||||
|
import { parseIFCtoThree } from '@/lib/convertIFC';
|
||||||
|
|
||||||
|
// ─── XRModel ───────────────────────────────────────────
|
||||||
|
export function XRModel({ sceneModel }: { sceneModel: 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 ──────────────────────────────
|
||||||
|
export function ControllerFineTuning({ freeMove }: { freeMove: boolean }) {
|
||||||
|
const { setFineTuning } = useModelStore();
|
||||||
|
const session = useThree((s) => s.gl.xr.getSession());
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyGripHeld) return;
|
||||||
|
if (!freeMove && !gripHeld) return;
|
||||||
|
|
||||||
|
const baseStep = 0.001;
|
||||||
|
const baseRot = 0.1;
|
||||||
|
const deadzone = 0.15;
|
||||||
|
const speedL = 0.3 + leftTrig * 2.7;
|
||||||
|
const speedR = 0.3 + rightTrig * 2.7;
|
||||||
|
const curve = (v: number) => Math.sign(v) * v * v;
|
||||||
|
|
||||||
|
const modelStore = useModelStore.getState();
|
||||||
|
const ft = { ...modelStore.fineTuning };
|
||||||
|
|
||||||
|
if (leftAxes) {
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
ctx.fillStyle = 'rgba(11, 18, 32, 0.85)';
|
||||||
|
ctx.beginPath();
|
||||||
|
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();
|
||||||
|
|
||||||
|
ctx.strokeStyle = color;
|
||||||
|
ctx.lineWidth = 3;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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(() => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!measureMode) return;
|
||||||
|
if (gl.xr.isPresenting) return;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
@@ -7,10 +7,7 @@ const WASM_PATH = 'https://unpkg.com/web-ifc@0.0.57/';
|
|||||||
/**
|
/**
|
||||||
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
|
* Convert an IFC file (ArrayBuffer) into a GLB Blob using web-ifc + GLTFExporter.
|
||||||
*/
|
*/
|
||||||
export async function convertIFCtoGLB(
|
export async function parseIFCtoThree(buffer: ArrayBuffer): Promise<THREE.Scene> {
|
||||||
buffer: ArrayBuffer,
|
|
||||||
fileName: string
|
|
||||||
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
|
|
||||||
const ifcApi = new WebIFC.IfcAPI();
|
const ifcApi = new WebIFC.IfcAPI();
|
||||||
ifcApi.SetWasmPath(WASM_PATH, true);
|
ifcApi.SetWasmPath(WASM_PATH, true);
|
||||||
await ifcApi.Init();
|
await ifcApi.Init();
|
||||||
@@ -92,6 +89,14 @@ export async function convertIFCtoGLB(
|
|||||||
});
|
});
|
||||||
|
|
||||||
ifcApi.CloseModel(modelID);
|
ifcApi.CloseModel(modelID);
|
||||||
|
return scene;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function convertIFCtoGLB(
|
||||||
|
buffer: ArrayBuffer,
|
||||||
|
fileName: string
|
||||||
|
): Promise<{ blob: Blob; fileName: string; fileSize: number }> {
|
||||||
|
const scene = await parseIFCtoThree(buffer);
|
||||||
|
|
||||||
// Export to GLB
|
// Export to GLB
|
||||||
const exporter = new GLTFExporter();
|
const exporter = new GLTFExporter();
|
||||||
@@ -108,7 +113,6 @@ export async function convertIFCtoGLB(
|
|||||||
const blob = new Blob([glb], { type: 'model/gltf-binary' });
|
const blob = new Blob([glb], { type: 'model/gltf-binary' });
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
materials.forEach((m) => m.dispose());
|
|
||||||
scene.traverse((child) => {
|
scene.traverse((child) => {
|
||||||
if (child instanceof THREE.Mesh) {
|
if (child instanceof THREE.Mesh) {
|
||||||
child.geometry.dispose();
|
child.geometry.dispose();
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
|
|
||||||
|
const logBuffer: any[] = [];
|
||||||
|
let channelSubscribed = false;
|
||||||
|
|
||||||
|
const logChannel = supabase.channel('global-app-logs', {
|
||||||
|
config: { broadcast: { self: true, ack: false } },
|
||||||
|
});
|
||||||
|
|
||||||
|
logChannel.subscribe((status) => {
|
||||||
|
console.log(`[RemoteLogger] Status do canal de log: ${status}`);
|
||||||
|
if (status === 'SUBSCRIBED') {
|
||||||
|
channelSubscribed = true;
|
||||||
|
console.log(`[RemoteLogger] Inscrito com sucesso. Enviando ${logBuffer.length} logs acumulados...`);
|
||||||
|
// Envia o buffer acumulado
|
||||||
|
while (logBuffer.length > 0) {
|
||||||
|
const payload = logBuffer.shift();
|
||||||
|
logChannel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'log',
|
||||||
|
payload,
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[RemoteLogger] Erro ao enviar log em buffer:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export function sendRemoteLog(level: 'info' | 'warn' | 'error', message: string, data?: any) {
|
||||||
|
const consoleFn = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
|
||||||
|
consoleFn(`[RemoteLog] [${level}] ${message}`, data || '');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
data: data ? JSON.parse(JSON.stringify(data)) : null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : 'NodeJS',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (channelSubscribed) {
|
||||||
|
logChannel.send({
|
||||||
|
type: 'broadcast',
|
||||||
|
event: 'log',
|
||||||
|
payload,
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('[RemoteLogger] Falha ao enviar log remoto, enfileirando...', err);
|
||||||
|
logBuffer.push(payload);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logBuffer.push(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Captura global de erros em tempo de execução
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('error', (event) => {
|
||||||
|
sendRemoteLog('error', `Global error: ${event.message} at ${event.filename}:${event.lineno}`, {
|
||||||
|
colno: event.colno,
|
||||||
|
stack: event.error?.stack,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
sendRemoteLog('error', `Unhandled Promise rejection: ${event.reason}`, {
|
||||||
|
reason: event.reason?.stack || event.reason,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { createXRStore } from '@react-three/xr';
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
export const xrStore = 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,
|
||||||
|
});
|
||||||
+124
-8
@@ -4,6 +4,7 @@ import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box, Package } from 'lu
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { supabase } from '@/integrations/supabase/client';
|
||||||
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
||||||
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
||||||
import { validateModelFile } from '@/lib/validateModelFile';
|
import { validateModelFile } from '@/lib/validateModelFile';
|
||||||
@@ -11,6 +12,8 @@ import { CloudLoader } from '@/components/CloudLoader';
|
|||||||
import { SceneModelList } from '@/components/SceneModelList';
|
import { SceneModelList } from '@/components/SceneModelList';
|
||||||
import { RecentFilesList } from '@/components/RecentFilesList';
|
import { RecentFilesList } from '@/components/RecentFilesList';
|
||||||
import { addRecentFile } from '@/lib/recentFiles';
|
import { addRecentFile } from '@/lib/recentFiles';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -21,6 +24,32 @@ const Index = () => {
|
|||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
||||||
|
|
||||||
|
// --- Painel de Logs Remotos em Tempo Real ---
|
||||||
|
const [debugLogs, setDebugLogs] = useState<{ level: 'info' | 'warn' | 'error'; message: string; timestamp: string; data?: any }[]>([]);
|
||||||
|
const [showLogs, setShowLogs] = useState(() => new URLSearchParams(window.location.search).get('viewlogs') === 'true');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showLogs) return;
|
||||||
|
const logChannel = supabase.channel('global-app-logs-viewer');
|
||||||
|
logChannel.on('broadcast', { event: 'log' }, ({ payload }) => {
|
||||||
|
setDebugLogs(prev => [payload, ...prev].slice(0, 100));
|
||||||
|
}).subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
logChannel.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [showLogs]);
|
||||||
|
|
||||||
|
const [ifcConvert, setIfcConvert] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('tsxr_ifc_convert');
|
||||||
|
return saved !== 'false';
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleIfcConvert = (checked: boolean) => {
|
||||||
|
setIfcConvert(checked);
|
||||||
|
localStorage.setItem('tsxr_ifc_convert', checked ? 'true' : 'false');
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigator.xr) {
|
if (navigator.xr) {
|
||||||
navigator.xr.isSessionSupported('immersive-ar').then(setXrSupported).catch(() => setXrSupported(false));
|
navigator.xr.isSessionSupported('immersive-ar').then(setXrSupported).catch(() => setXrSupported(false));
|
||||||
@@ -74,8 +103,19 @@ const Index = () => {
|
|||||||
if (!sigCheck.ok) {
|
if (!sigCheck.ok) {
|
||||||
toast.error(sigCheck.reason!);
|
toast.error(sigCheck.reason!);
|
||||||
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
|
if (sigCheck.detail) console.warn('[validateModelFile]', sigCheck.detail);
|
||||||
|
setConverting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ext === 'ifc' && !ifcConvert) {
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
addModel({ fileName: file.name, fileSize: file.size, url });
|
||||||
|
addRecentFile(file.name, file).catch(() => {});
|
||||||
|
toast.success(`Modelo "${file.name}" carregado de forma nativa!`);
|
||||||
|
setConverting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let result;
|
let result;
|
||||||
if (ext === 'ifc') {
|
if (ext === 'ifc') {
|
||||||
result = await convertIFCtoGLB(buffer, file.name);
|
result = await convertIFCtoGLB(buffer, file.name);
|
||||||
@@ -93,7 +133,7 @@ const Index = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setConverting(false);
|
setConverting(false);
|
||||||
}
|
}
|
||||||
}, [addModel]);
|
}, [addModel, ifcConvert]);
|
||||||
|
|
||||||
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
|
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
|
||||||
|
|
||||||
@@ -102,6 +142,14 @@ const Index = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/models/demo250hp.ifc');
|
const response = await fetch('/models/demo250hp.ifc');
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (!ifcConvert) {
|
||||||
|
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
addModel({ fileName: 'demo250hp.ifc', fileSize: blob.size, url });
|
||||||
|
addRecentFile('demo250hp.ifc', blob).catch(() => {});
|
||||||
|
toast.success('Modelo demo "250 HP" (IFC nativo) carregado!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
const url = URL.createObjectURL(result.blob);
|
||||||
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
||||||
@@ -109,17 +157,25 @@ const Index = () => {
|
|||||||
toast.success('Modelo demo "250 HP" (IFC) carregado!');
|
toast.success('Modelo demo "250 HP" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error('Falha ao converter modelo demo IFC');
|
toast.error('Falha ao processar modelo demo IFC');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDemoIFC(false);
|
setLoadingDemoIFC(false);
|
||||||
}
|
}
|
||||||
}, [addModel]);
|
}, [addModel, ifcConvert]);
|
||||||
|
|
||||||
const handleLoadDemoCotovelo = useCallback(async () => {
|
const handleLoadDemoCotovelo = useCallback(async () => {
|
||||||
setLoadingDemoCotovelo(true);
|
setLoadingDemoCotovelo(true);
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/models/B126-COTOVELO.ifc');
|
const response = await fetch('/models/B126-COTOVELO.ifc');
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
|
if (!ifcConvert) {
|
||||||
|
const blob = new Blob([buffer], { type: 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
addModel({ fileName: 'B126-COTOVELO.ifc', fileSize: blob.size, url });
|
||||||
|
addRecentFile('B126-COTOVELO.ifc', blob).catch(() => {});
|
||||||
|
toast.success('Modelo demo "B126 Cotovelo" (IFC nativo) carregado!');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
const url = URL.createObjectURL(result.blob);
|
||||||
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
|
||||||
@@ -127,15 +183,15 @@ const Index = () => {
|
|||||||
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
|
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
toast.error('Falha ao converter modelo demo Cotovelo');
|
toast.error('Falha ao processar modelo demo Cotovelo');
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDemoCotovelo(false);
|
setLoadingDemoCotovelo(false);
|
||||||
}
|
}
|
||||||
}, [addModel]);
|
}, [addModel, ifcConvert]);
|
||||||
|
|
||||||
const handleEnterViewer = () => {
|
const handleEnterViewer = () => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
toast.error('Importe um modelo GLB primeiro');
|
toast.error('Importe um modelo 3D primeiro');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navigate('/viewer');
|
navigate('/viewer');
|
||||||
@@ -175,11 +231,28 @@ const Index = () => {
|
|||||||
|
|
||||||
{converting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Upload className="h-8 w-8" />}
|
{converting ? <Loader2 className="h-8 w-8 animate-spin" /> : <Upload className="h-8 w-8" />}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm font-semibold">{converting ? 'Convertendo modelo…' : 'Importar Modelo 3D'}</p>
|
<p className="text-sm font-semibold">{converting ? 'Carregando modelo…' : 'Importar Modelo 3D'}</p>
|
||||||
<p className="text-xs text-muted-foreground">GLB · OBJ · STL · IFC — Escala 1:1</p>
|
<p className="text-xs text-muted-foreground">GLB · OBJ · STL · IFC — Escala 1:1</p>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Toggle IFC Conversion */}
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-border bg-card/50 p-3.5">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<Label htmlFor="ifc-convert-toggle" className="text-xs font-semibold cursor-pointer text-foreground">
|
||||||
|
Conversão IFC ➔ GLB
|
||||||
|
</Label>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{ifcConvert ? 'Otimizado para AR (conversão automática)' : 'Modo nativo (carregamento bruto IFC)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="ifc-convert-toggle"
|
||||||
|
checked={ifcConvert}
|
||||||
|
onCheckedChange={handleToggleIfcConvert}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Demo buttons */}
|
{/* Demo buttons */}
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<CloudLoader />
|
<CloudLoader />
|
||||||
@@ -254,8 +327,51 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
||||||
TrackSteelXR v1.01 — Q.C. Inspection
|
TrackSteelXR v1.05 — Q.C. Inspection
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{showLogs && (
|
||||||
|
<div className="fixed bottom-4 right-4 z-50 flex h-[400px] w-[500px] flex-col rounded-lg border border-border bg-slate-950/95 p-4 shadow-2xl backdrop-blur font-mono text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between border-b border-border pb-2 mb-2">
|
||||||
|
<span className="font-bold text-primary flex items-center gap-2">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-500 animate-ping" />
|
||||||
|
Console de Logs Remotos (Quest 3)
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px]" onClick={() => setDebugLogs([])}>
|
||||||
|
Limpar
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="h-6 px-2 text-[10px] text-destructive" onClick={() => setShowLogs(false)}>
|
||||||
|
Fechar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
||||||
|
{debugLogs.length === 0 ? (
|
||||||
|
<p className="text-slate-500 text-center mt-12">Aguardando logs do headset Quest 3...</p>
|
||||||
|
) : (
|
||||||
|
debugLogs.map((log, i) => (
|
||||||
|
<div key={i} className={`p-2 rounded border ${
|
||||||
|
log.level === 'error' ? 'bg-red-950/40 border-red-500/30 text-red-300' :
|
||||||
|
log.level === 'warn' ? 'bg-amber-950/40 border-amber-500/30 text-amber-300' :
|
||||||
|
'bg-slate-900/60 border-slate-700/30 text-slate-300'
|
||||||
|
}`}>
|
||||||
|
<div className="flex justify-between text-[9px] text-slate-400 mb-1">
|
||||||
|
<span>{new Date(log.timestamp).toLocaleTimeString()}</span>
|
||||||
|
<span className="uppercase font-bold">{log.level}</span>
|
||||||
|
</div>
|
||||||
|
<p className="break-all whitespace-pre-wrap">{log.message}</p>
|
||||||
|
{log.data && (
|
||||||
|
<pre className="mt-1 text-[10px] bg-black/40 p-1 rounded overflow-x-auto max-h-24 text-slate-400">
|
||||||
|
{JSON.stringify(log.data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucid
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useModelStore } from "@/stores/useModelStore";
|
import { useModelStore } from "@/stores/useModelStore";
|
||||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||||
|
import { xrStore } from "@/lib/xrStore";
|
||||||
import { ViewerControls } from "@/components/ViewerControls";
|
import { ViewerControls } from "@/components/ViewerControls";
|
||||||
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
import { InspectionChecklist } from "@/components/InspectionChecklist";
|
||||||
import { FineTuningControls } from "@/components/FineTuningControls";
|
import { FineTuningControls } from "@/components/FineTuningControls";
|
||||||
@@ -92,11 +93,12 @@ const Viewer = () => {
|
|||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
|
|
||||||
const handleEnterXR = async () => {
|
const handleEnterXR = async () => {
|
||||||
if (!xrSupported) {
|
try {
|
||||||
toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.");
|
await xrStore.enterAR();
|
||||||
return;
|
} catch (e) {
|
||||||
|
console.error('[XR] Falha ao entrar no AR:', e);
|
||||||
|
toast.error("Não foi possível iniciar a sessão AR. Certifique-se de que está usando um dispositivo compatível (como Meta Quest 3).");
|
||||||
}
|
}
|
||||||
navigate("/xr");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const canAddMore = models.length < maxModels;
|
const canAddMore = models.length < maxModels;
|
||||||
@@ -129,7 +131,7 @@ const Viewer = () => {
|
|||||||
<ShareButton />
|
<ShareButton />
|
||||||
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
<Button className="gap-2 glow-primary" disabled={!xrSupported} onClick={handleEnterXR}>
|
||||||
<Glasses className="h-4 w-4" />
|
<Glasses className="h-4 w-4" />
|
||||||
Entrar em Modo XR
|
Iniciar AR
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,713 +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';
|
|
||||||
|
|
||||||
// --- 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 { scene: rawScene } = useGLTF(sceneModel.url);
|
|
||||||
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
|
|
||||||
const ref = useRef<THREE.Group>(null);
|
|
||||||
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 modelInfo = useMemo(() => {
|
|
||||||
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(() => {
|
|
||||||
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) {
|
|
||||||
if (renderMode === 'edges') {
|
|
||||||
mat.visible = false;
|
|
||||||
} else {
|
|
||||||
mat.visible = true;
|
|
||||||
mat.transparent = opacity < 1;
|
|
||||||
mat.opacity = opacity;
|
|
||||||
mat.wireframe = renderMode === 'wireframe';
|
|
||||||
if (renderMode === 'wireframe') {
|
|
||||||
mat.wireframeLinewidth = wireframeThickness;
|
|
||||||
mat.color.set(wireframeColor);
|
|
||||||
} else 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' && child.geometry) {
|
|
||||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
|
||||||
const lineMat = new THREE.LineBasicMaterial({ color: wireframeColor, linewidth: wireframeThickness });
|
|
||||||
const lineSegments = new THREE.LineSegments(edgesGeo, lineMat);
|
|
||||||
lineSegments.userData.__edgeLine = true;
|
|
||||||
child.add(lineSegments);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
|
||||||
<group
|
|
||||||
ref={ref}
|
|
||||||
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} />
|
|
||||||
</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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── 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);
|
|
||||||
|
|
||||||
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>
|
|
||||||
)}
|
|
||||||
{measurements.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>
|
|
||||||
<Line points={[a, b]} color="#22c55e" lineWidth={2} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<group position={mid}>
|
|
||||||
<mesh position={[0, 0, 0]} renderOrder={998}>
|
|
||||||
<planeGeometry args={[0.09, 0.024]} />
|
|
||||||
<meshBasicMaterial color="#0b1220" transparent opacity={0.85} depthTest={false} />
|
|
||||||
</mesh>
|
|
||||||
<Text
|
|
||||||
position={[0, 0, 0.001]}
|
|
||||||
fontSize={0.012}
|
|
||||||
color={color}
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
renderOrder={999}
|
|
||||||
material-depthTest={false}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</Text>
|
|
||||||
</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;
|
|
||||||
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} />
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* 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;
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { worldToModelLocal } from '@/lib/modelTransforms';
|
import { worldToModelLocal } from '@/lib/modelTransforms';
|
||||||
|
import { sendRemoteLog } from '@/lib/remoteLogger';
|
||||||
|
|
||||||
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
// ── Persistência de placement (fineTuning + escala) e flags WebXR ─────
|
||||||
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
const PLACEMENT_KEY = 'tsxr_placements_v1';
|
||||||
@@ -126,7 +127,7 @@ const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899'];
|
|||||||
|
|
||||||
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||||
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
||||||
type RenderMode = 'solid' | 'wireframe' | 'edges';
|
type RenderMode = 'solid' | 'edges';
|
||||||
|
|
||||||
export interface MeasurePoint {
|
export interface MeasurePoint {
|
||||||
x: number;
|
x: number;
|
||||||
@@ -553,6 +554,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
measurePoints: [],
|
measurePoints: [],
|
||||||
addMeasurePoint: (p) => set((state) => {
|
addMeasurePoint: (p) => set((state) => {
|
||||||
|
sendRemoteLog('info', `addMeasurePoint acionado: ponto atual ${state.measurePoints.length + 1}`, p);
|
||||||
const points = [...state.measurePoints, p];
|
const points = [...state.measurePoints, p];
|
||||||
if (points.length === 2) {
|
if (points.length === 2) {
|
||||||
const [a, b] = points;
|
const [a, b] = points;
|
||||||
@@ -562,11 +564,17 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
||||||
const factor = state.scaleRatio?.factor ?? 1;
|
const factor = state.scaleRatio?.factor ?? 1;
|
||||||
const distanceMM = distanceWorldMM / factor;
|
const distanceMM = distanceWorldMM / factor;
|
||||||
// Convert world points to active model's local frame so they follow the model.
|
|
||||||
const modelId = state.activeModelId ?? undefined;
|
const modelId = state.activeModelId ?? undefined;
|
||||||
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
const aConv = modelId ? worldToModelLocal(modelId, a) : null;
|
||||||
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
const bConv = modelId ? worldToModelLocal(modelId, b) : null;
|
||||||
const attached = !!(aConv && bConv);
|
const attached = !!(aConv && bConv);
|
||||||
|
sendRemoteLog('info', `addMeasurePoint: calculando distância`, {
|
||||||
|
distanceWorldMM,
|
||||||
|
distanceMM,
|
||||||
|
factor,
|
||||||
|
modelId,
|
||||||
|
attached
|
||||||
|
});
|
||||||
const measurement: Measurement = {
|
const measurement: Measurement = {
|
||||||
id: `m_${Date.now()}`,
|
id: `m_${Date.now()}`,
|
||||||
pointA: attached ? aConv! : a,
|
pointA: attached ? aConv! : a,
|
||||||
@@ -574,6 +582,7 @@ export const useModelStore = create<ModelStore>((set, get) => ({
|
|||||||
distanceMM,
|
distanceMM,
|
||||||
modelId: attached ? modelId : undefined,
|
modelId: attached ? modelId : undefined,
|
||||||
};
|
};
|
||||||
|
sendRemoteLog('info', `addMeasurePoint: medição gerada com sucesso`, measurement);
|
||||||
return { measurePoints: [], measurements: [...state.measurements, measurement] };
|
return { measurePoints: [], measurements: [...state.measurements, measurement] };
|
||||||
}
|
}
|
||||||
return { measurePoints: points };
|
return { measurePoints: points };
|
||||||
|
|||||||
Reference in New Issue
Block a user