This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 00:41:06 +00:00
parent dc6b44fef9
commit 1cc84fb1f1
10 changed files with 3183 additions and 112 deletions
+2613 -17
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -41,7 +41,11 @@
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@react-three/xr": "^6.6.29",
"@tanstack/react-query": "^5.83.0",
"@types/three": "^0.160.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -60,14 +64,16 @@
"sonner": "^1.7.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"three": "^0.160.1",
"vaul": "^0.9.9",
"zod": "^3.25.76"
"zod": "^3.25.76",
"zustand": "^4.5.7"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@tailwindcss/typography": "^0.5.16",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.23",
"@types/react-dom": "^18.3.7",
+4 -1
View File
@@ -4,6 +4,8 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Index from "./pages/Index";
import Viewer from "./pages/Viewer";
import XRSession from "./pages/XRSession";
import NotFound from "./pages/NotFound";
const queryClient = new QueryClient();
@@ -16,7 +18,8 @@ const App = () => (
<BrowserRouter>
<Routes>
<Route path="/" element={<Index />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
<Route path="/viewer" element={<Viewer />} />
<Route path="/xr" element={<XRSession />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
+79
View File
@@ -0,0 +1,79 @@
import { Suspense, useRef, useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Environment, Html } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
interface ModelViewerProps {
url: string;
}
function LoadingFallback() {
return (
<Html center>
<div className="flex items-center gap-2 rounded-lg bg-card px-4 py-2 text-foreground shadow-lg">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="font-mono text-sm">Carregando modelo</span>
</div>
</Html>
);
}
function GLBModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
const ref = useRef<THREE.Group>(null);
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]);
return (
<group ref={ref} position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
<primitive object={scene} />
</group>
);
}
export function ModelViewerCanvas({ url }: ModelViewerProps) {
return (
<Canvas
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
gl={{ antialias: true, alpha: true }}
className="!bg-background"
>
<ambientLight intensity={0.6} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
<Suspense fallback={<LoadingFallback />}>
<GLBModel url={url} />
</Suspense>
<Grid
infiniteGrid
cellSize={0.01}
sectionSize={0.1}
cellThickness={0.5}
sectionThickness={1}
cellColor="#334155"
sectionColor="#475569"
fadeDistance={5}
fadeStrength={1}
/>
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
/>
</Canvas>
);
}
+65 -75
View File
@@ -1,96 +1,52 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Inter:wght@300;400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--background: 220 20% 7%;
--foreground: 210 20% 92%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--card: 220 18% 10%;
--card-foreground: 210 20% 92%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--popover: 220 18% 10%;
--popover-foreground: 210 20% 92%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--primary: 38 92% 55%;
--primary-foreground: 220 20% 7%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--secondary: 220 16% 16%;
--secondary-foreground: 210 20% 85%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--muted: 220 14% 14%;
--muted-foreground: 215 12% 55%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--accent: 200 80% 50%;
--accent-foreground: 220 20% 7%;
--destructive: 0 84.2% 60.2%;
--destructive: 0 72% 51%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--success: 142 72% 42%;
--success-foreground: 220 20% 7%;
--border: 220 14% 18%;
--input: 220 14% 18%;
--ring: 38 92% 55%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
--sidebar-background: 240 5.9% 10%;
--sidebar-foreground: 240 4.8% 95.9%;
--sidebar-primary: 224.3 76.3% 48%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 240 3.7% 15.9%;
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--sidebar-background: 220 18% 9%;
--sidebar-foreground: 210 20% 85%;
--sidebar-primary: 38 92% 55%;
--sidebar-primary-foreground: 220 20% 7%;
--sidebar-accent: 220 14% 14%;
--sidebar-accent-foreground: 210 20% 85%;
--sidebar-border: 220 14% 18%;
--sidebar-ring: 38 92% 55%;
}
}
@@ -101,5 +57,39 @@ All colors MUST be HSL.
body {
@apply bg-background text-foreground;
font-family: 'Inter', system-ui, sans-serif;
}
h1, h2, h3, h4, h5, h6 {
font-family: 'JetBrains Mono', monospace;
}
code, pre, .font-mono {
font-family: 'JetBrains Mono', monospace;
}
}
@layer utilities {
.glow-primary {
box-shadow: 0 0 20px hsl(38 92% 55% / 0.3), 0 0 60px hsl(38 92% 55% / 0.1);
}
.glow-accent {
box-shadow: 0 0 20px hsl(200 80% 50% / 0.3), 0 0 60px hsl(200 80% 50% / 0.1);
}
.glow-success {
box-shadow: 0 0 20px hsl(142 72% 42% / 0.3);
}
.glow-destructive {
box-shadow: 0 0 20px hsl(0 72% 51% / 0.3);
}
.grid-industrial {
background-image:
linear-gradient(hsl(220 14% 18% / 0.5) 1px, transparent 1px),
linear-gradient(90deg, hsl(220 14% 18% / 0.5) 1px, transparent 1px);
background-size: 20px 20px;
}
}
+125 -5
View File
@@ -1,12 +1,132 @@
// Update this page (the content is just a fallback if you fail to update the page)
import { useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Upload, Glasses, CheckCircle, XCircle, Loader2, Box } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
const Index = () => {
const navigate = useNavigate();
const fileInputRef = useRef<HTMLInputElement>(null);
const { model, setModel, xrSupported, setXrSupported } = useModelStore();
useEffect(() => {
if (navigator.xr) {
navigator.xr.isSessionSupported('immersive-ar').then(setXrSupported).catch(() => setXrSupported(false));
} else {
setXrSupported(false);
}
}, [setXrSupported]);
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.glb')) {
toast.error('Formato inválido. Por favor, selecione um arquivo .GLB');
return;
}
const url = URL.createObjectURL(file);
setModel({ fileName: file.name, fileSize: file.size, url });
toast.success(`Modelo "${file.name}" carregado com sucesso!`);
}, [setModel]);
const handleEnterViewer = () => {
if (!model) {
toast.error('Importe um modelo GLB primeiro');
return;
}
navigate('/viewer');
};
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="text-center">
<h1 className="mb-4 text-4xl font-bold">Welcome to Your Blank App</h1>
<p className="text-xl text-muted-foreground">Start building your amazing project here!</p>
<div className="flex min-h-screen flex-col items-center justify-center bg-background grid-industrial p-6">
<input
ref={fileInputRef}
type="file"
accept=".glb"
className="hidden"
onChange={handleFileUpload}
/>
{/* Logo area */}
<div className="mb-12 text-center">
<div className="mb-4 flex items-center justify-center gap-3">
<Box className="h-10 w-10 text-primary" />
<h1 className="text-3xl font-bold tracking-tight text-foreground md:text-4xl">
TrackkSteel<span className="text-primary">XR</span>
</h1>
</div>
<p className="font-mono text-sm uppercase tracking-widest text-muted-foreground">
Inspeção de Qualidade Industrial
</p>
</div>
{/* Main card */}
<div className="w-full max-w-md space-y-4">
{/* Import button */}
<Button
variant="outline"
className="h-28 w-full flex-col gap-3 border-dashed border-2 text-muted-foreground hover:border-primary hover:text-primary transition-all"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="h-8 w-8" />
<div className="text-center">
<p className="text-sm font-semibold">Importar Modelo GLB</p>
<p className="text-xs text-muted-foreground">Escala 1:1 Unidades em mm</p>
</div>
</Button>
{/* Model info */}
{model && (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 glow-primary">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-primary" />
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm font-medium text-foreground">{model.fileName}</p>
<p className="text-xs text-muted-foreground">
{(model.fileSize / (1024 * 1024)).toFixed(2)} MB
</p>
</div>
<CheckCircle className="h-5 w-5 text-success shrink-0" />
</div>
</div>
)}
{/* Enter viewer */}
<Button
className="h-14 w-full gap-3 text-base font-semibold glow-primary"
disabled={!model}
onClick={handleEnterViewer}
>
<Glasses className="h-5 w-5" />
Visualizar Modelo 3D
</Button>
{/* XR Status */}
<div className="flex items-center justify-center gap-2 pt-2">
{xrSupported === null ? (
<>
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">Verificando suporte WebXR</span>
</>
) : xrSupported ? (
<>
<CheckCircle className="h-4 w-4 text-success" />
<span className="text-xs text-success">WebXR Compatível Passthrough disponível</span>
</>
) : (
<>
<XCircle className="h-4 w-4 text-destructive" />
<span className="text-xs text-destructive">WebXR não disponível neste navegador</span>
</>
)}
</div>
</div>
{/* Footer */}
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
TrackkSteelXR v1.0 Advance Steel Inspection
</p>
</div>
);
};
+100
View File
@@ -0,0 +1,100 @@
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, Glasses, Box, Ruler, FileText } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { ModelViewerCanvas } from '@/components/three/ModelViewer';
import { useEffect } from 'react';
import { toast } from 'sonner';
const Viewer = () => {
const navigate = useNavigate();
const { model, xrSupported } = useModelStore();
useEffect(() => {
if (!model) {
navigate('/');
}
}, [model, navigate]);
if (!model) return null;
const handleEnterXR = async () => {
if (!xrSupported) {
toast.error('WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3.');
return;
}
navigate('/xr');
};
return (
<div className="flex h-screen flex-col bg-background">
{/* Top bar */}
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={() => navigate('/')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<Box className="h-5 w-5 text-primary" />
<h1 className="font-mono text-sm font-semibold text-foreground">
TrackkSteel<span className="text-primary">XR</span>
</h1>
</div>
</div>
<Button
className="gap-2 glow-primary"
disabled={!xrSupported}
onClick={handleEnterXR}
>
<Glasses className="h-4 w-4" />
Entrar em Modo XR
</Button>
</header>
<div className="flex flex-1 overflow-hidden">
{/* 3D Canvas */}
<div className="flex-1">
<ModelViewerCanvas url={model.url} />
</div>
{/* Side panel */}
<aside className="w-72 shrink-0 overflow-y-auto border-l bg-card p-4">
<h2 className="mb-4 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Informações do Modelo
</h2>
<div className="space-y-4">
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
<InfoItem
icon={Box}
label="Tamanho"
value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`}
/>
<InfoItem icon={Ruler} label="Escala" value="1:1 (mm)" />
<div className="rounded-lg border bg-muted/50 p-3">
<p className="font-mono text-xs text-muted-foreground">
Use o mouse para orbitar, scroll para zoom. No Quest 3, use o botão acima para entrar em modo imersivo com passthrough.
</p>
</div>
</div>
</aside>
</div>
</div>
);
};
function InfoItem({ icon: Icon, label, value }: { icon: any; label: string; value: string }) {
return (
<div className="flex items-start gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate font-mono text-sm text-foreground">{value}</p>
</div>
</div>
);
}
export default Viewer;
+117
View File
@@ -0,0 +1,117 @@
import { useEffect, useCallback, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
/**
* XR Immersive Session Page
* Uses native WebXR API for passthrough AR on Quest 3.
* - Loads GLB model
* - Attempts image tracking for QR anchor
* - Falls back to manual placement
* - Fine tuning with grip + joysticks
*/
const XRSession = () => {
const navigate = useNavigate();
const { model, xrSupported } = useModelStore();
const [sessionActive, setSessionActive] = useState(false);
const [statusMessage, setStatusMessage] = useState('Preparando sessão XR…');
useEffect(() => {
if (!model) {
navigate('/');
return;
}
if (!xrSupported) {
toast.error('WebXR não disponível');
navigate('/viewer');
return;
}
}, [model, xrSupported, navigate]);
const startXRSession = useCallback(async () => {
if (!navigator.xr) return;
try {
setStatusMessage('Iniciando sessão imersiva…');
// Check for image tracking support
const features: string[] = ['local-floor', 'hand-tracking'];
const optionalFeatures: string[] = ['image-tracking', 'anchors', 'plane-detection'];
const session = await navigator.xr.requestSession('immersive-ar', {
requiredFeatures: features,
optionalFeatures: optionalFeatures,
domOverlay: { root: document.getElementById('xr-overlay')! },
});
setSessionActive(true);
setStatusMessage('Sessão XR ativa — Modo Passthrough');
session.addEventListener('end', () => {
setSessionActive(false);
setStatusMessage('Sessão XR encerrada');
toast.info('Sessão XR encerrada');
});
// The actual 3D rendering in XR would require a full WebXR render loop
// with Three.js. For now we show the overlay UI.
toast.success('Sessão XR iniciada com passthrough!');
} catch (err: any) {
console.error('XR Session error:', err);
setStatusMessage('Erro ao iniciar XR');
toast.error(`Falha ao iniciar XR: ${err.message}`);
}
}, []);
if (!model) return null;
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background p-6">
<div id="xr-overlay" className="fixed inset-0 z-50 pointer-events-none" />
<div className="w-full max-w-sm space-y-6 text-center">
<h1 className="font-mono text-xl font-bold text-foreground">
Modo <span className="text-primary">XR Imersivo</span>
</h1>
<p className="font-mono text-sm text-muted-foreground">{statusMessage}</p>
<div className="rounded-lg border bg-card p-4 text-left">
<p className="font-mono text-xs text-muted-foreground mb-2">Modelo:</p>
<p className="font-mono text-sm text-foreground truncate">{model.fileName}</p>
</div>
{!sessionActive && (
<button
onClick={startXRSession}
className="w-full rounded-lg bg-primary px-6 py-4 font-mono text-sm font-bold text-primary-foreground transition-all hover:opacity-90 glow-primary"
>
Iniciar Sessão XR
</button>
)}
{sessionActive && (
<div className="space-y-3">
<div className="rounded-lg border border-success/30 bg-success/10 p-3">
<p className="font-mono text-xs text-success"> Sessão XR Ativa</p>
</div>
<p className="font-mono text-xs text-muted-foreground">
Coloque o Quest 3 para visualizar o modelo em passthrough.
Use Grip + Joysticks para ajuste fino.
</p>
</div>
)}
<button
onClick={() => navigate('/viewer')}
className="font-mono text-xs text-muted-foreground underline hover:text-foreground transition-colors"
>
Voltar ao Visualizador
</button>
</div>
</div>
);
};
export default XRSession;
+55
View File
@@ -0,0 +1,55 @@
import { create } from 'zustand';
interface ModelInfo {
fileName: string;
fileSize: number;
url: string;
}
interface FineTuning {
posX: number;
posY: number;
posZ: number;
rotY: number;
}
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
type InspectionResult = 'pending' | 'approved' | 'rejected';
interface ModelStore {
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
fineTuning: FineTuning;
setFineTuning: (ft: Partial<FineTuning>) => void;
resetFineTuning: () => void;
anchorMode: AnchorMode;
setAnchorMode: (mode: AnchorMode) => void;
inspectionResult: InspectionResult;
setInspectionResult: (result: InspectionResult) => void;
xrSupported: boolean | null;
setXrSupported: (supported: boolean | null) => void;
}
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 };
export const useModelStore = create<ModelStore>((set) => ({
model: null,
setModel: (model) => set({ model }),
fineTuning: { ...defaultFineTuning },
setFineTuning: (ft) => set((state) => ({ fineTuning: { ...state.fineTuning, ...ft } })),
resetFineTuning: () => set({ fineTuning: { ...defaultFineTuning } }),
anchorMode: 'manual',
setAnchorMode: (anchorMode) => set({ anchorMode }),
inspectionResult: 'pending',
setInspectionResult: (inspectionResult) => set({ inspectionResult }),
xrSupported: null,
setXrSupported: (xrSupported) => set({ xrSupported }),
}));
+16 -11
View File
@@ -13,6 +13,10 @@ export default {
},
},
extend: {
fontFamily: {
mono: ["JetBrains Mono", "monospace"],
sans: ["Inter", "system-ui", "sans-serif"],
},
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
@@ -31,6 +35,10 @@ export default {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
success: {
DEFAULT: "hsl(var(--success))",
foreground: "hsl(var(--success-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
@@ -65,25 +73,22 @@ export default {
},
keyframes: {
"accordion-down": {
from: {
height: "0",
},
to: {
height: "var(--radix-accordion-content-height)",
},
from: { height: "0" },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: {
height: "var(--radix-accordion-content-height)",
},
to: {
height: "0",
from: { height: "var(--radix-accordion-content-height)" },
to: { height: "0" },
},
"pulse-glow": {
"0%, 100%": { opacity: "1" },
"50%": { opacity: "0.5" },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
"pulse-glow": "pulse-glow 2s ease-in-out infinite",
},
},
},