Changes
This commit is contained in:
@@ -0,0 +1,136 @@
|
|||||||
|
import { CheckCircle, XCircle, Clock, MessageSquare } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useModelStore, type ChecklistItem } from '@/stores/useModelStore';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
function ChecklistRow({ item }: { item: ChecklistItem }) {
|
||||||
|
const { setChecklistItemStatus, setChecklistItemNote } = useModelStore();
|
||||||
|
const [showNote, setShowNote] = useState(item.status === 'rejected');
|
||||||
|
|
||||||
|
const handleApprove = () => {
|
||||||
|
setChecklistItemStatus(item.id, 'approved');
|
||||||
|
setShowNote(false);
|
||||||
|
toast.success(`"${item.label}" aprovado`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
setChecklistItemStatus(item.id, 'rejected');
|
||||||
|
setShowNote(true);
|
||||||
|
toast.error(`"${item.label}" reprovado — adicione uma anotação`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColor =
|
||||||
|
item.status === 'approved' ? 'text-success' :
|
||||||
|
item.status === 'rejected' ? 'text-destructive' :
|
||||||
|
'text-muted-foreground';
|
||||||
|
|
||||||
|
const StatusIcon =
|
||||||
|
item.status === 'approved' ? CheckCircle :
|
||||||
|
item.status === 'rejected' ? XCircle :
|
||||||
|
Clock;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<StatusIcon className={`h-4 w-4 shrink-0 ${statusColor}`} />
|
||||||
|
<span className="font-mono text-xs text-foreground truncate">{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant={item.status === 'approved' ? 'default' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleApprove}
|
||||||
|
title="Aprovar"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={item.status === 'rejected' ? 'destructive' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7"
|
||||||
|
onClick={handleReject}
|
||||||
|
title="Reprovar"
|
||||||
|
>
|
||||||
|
<XCircle className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(showNote || item.status === 'rejected') && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<MessageSquare className="h-3 w-3 text-muted-foreground" />
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">Anotação</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
className="h-16 font-mono text-xs resize-none bg-background"
|
||||||
|
placeholder="Descreva o problema encontrado…"
|
||||||
|
value={item.note}
|
||||||
|
onChange={(e) => setChecklistItemNote(item.id, e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectionChecklist() {
|
||||||
|
const { checklist, resetChecklist } = useModelStore();
|
||||||
|
|
||||||
|
const approved = checklist.filter(i => i.status === 'approved').length;
|
||||||
|
const rejected = checklist.filter(i => i.status === 'rejected').length;
|
||||||
|
const total = checklist.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Checklist de Inspeção
|
||||||
|
</h3>
|
||||||
|
<Button variant="ghost" size="sm" className="h-6 text-[10px]" onClick={resetChecklist}>
|
||||||
|
Resetar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress summary */}
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
|
style={{ width: `${((approved + rejected) / total) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground shrink-0">
|
||||||
|
{approved + rejected}/{total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status badges */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{approved > 0 && (
|
||||||
|
<span className="font-mono text-[10px] text-success flex items-center gap-1">
|
||||||
|
<CheckCircle className="h-3 w-3" /> {approved}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{rejected > 0 && (
|
||||||
|
<span className="font-mono text-[10px] text-destructive flex items-center gap-1">
|
||||||
|
<XCircle className="h-3 w-3" /> {rejected}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{checklist.map(item => (
|
||||||
|
<ChecklistRow key={item.id} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Eye, Grid3X3 } from 'lucide-react';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
|
export function ViewerControls() {
|
||||||
|
const { opacity, setOpacity, renderMode, setRenderMode } = useModelStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 left-4 z-10 flex items-end gap-3">
|
||||||
|
{/* Opacity slider */}
|
||||||
|
<div className="rounded-lg border bg-card/90 backdrop-blur-sm p-3 shadow-lg w-56">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Eye className="h-3.5 w-3.5 text-primary" />
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">Opacidade</span>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-foreground">{Math.round(opacity * 100)}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[opacity * 100]}
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([v]) => setOpacity(v / 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Render mode toggle */}
|
||||||
|
<Button
|
||||||
|
variant={renderMode === 'wireframe' ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="gap-2 h-9"
|
||||||
|
onClick={() => setRenderMode(renderMode === 'solid' ? 'wireframe' : 'solid')}
|
||||||
|
>
|
||||||
|
<Grid3X3 className="h-3.5 w-3.5" />
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{renderMode === 'wireframe' ? 'Wireframe' : 'Sólido'}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Suspense, useRef, useMemo } from 'react';
|
import { Suspense, useRef, useMemo, useEffect } from 'react';
|
||||||
import { Canvas } from '@react-three/fiber';
|
import { Canvas, useFrame } from '@react-three/fiber';
|
||||||
import { OrbitControls, useGLTF, Grid, Environment, Html } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
|
import { useModelStore } from '@/stores/useModelStore';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -22,6 +23,9 @@ function LoadingFallback() {
|
|||||||
function GLBModel({ url }: { url: string }) {
|
function GLBModel({ url }: { url: string }) {
|
||||||
const { scene } = useGLTF(url);
|
const { scene } = useGLTF(url);
|
||||||
const ref = useRef<THREE.Group>(null);
|
const ref = useRef<THREE.Group>(null);
|
||||||
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
|
const renderMode = useModelStore((s) => s.renderMode);
|
||||||
|
const checklist = useModelStore((s) => s.checklist);
|
||||||
|
|
||||||
const modelInfo = useMemo(() => {
|
const modelInfo = useMemo(() => {
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
@@ -32,6 +36,41 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
return { size, center };
|
return { size, center };
|
||||||
}, [scene]);
|
}, [scene]);
|
||||||
|
|
||||||
|
// Apply opacity, wireframe, and checklist color feedback
|
||||||
|
useEffect(() => {
|
||||||
|
// Determine overall inspection color
|
||||||
|
const hasRejected = checklist.some(i => i.status === 'rejected');
|
||||||
|
const allApproved = checklist.every(i => i.status === 'approved');
|
||||||
|
|
||||||
|
scene.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
// Clone material to avoid shared mutations
|
||||||
|
if (Array.isArray(child.material)) {
|
||||||
|
child.material = child.material.map(m => m.clone());
|
||||||
|
} else {
|
||||||
|
child.material = child.material.clone();
|
||||||
|
}
|
||||||
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
|
materials.forEach((mat: THREE.Material) => {
|
||||||
|
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||||
|
mat.transparent = opacity < 1;
|
||||||
|
mat.opacity = opacity;
|
||||||
|
mat.wireframe = renderMode === 'wireframe';
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
// Color feedback
|
||||||
|
if (hasRejected) {
|
||||||
|
mat.color.setHSL(0, 0.7, 0.5); // red
|
||||||
|
} else if (allApproved) {
|
||||||
|
mat.color.setHSL(0.38, 0.7, 0.45); // green
|
||||||
|
} else {
|
||||||
|
mat.color.set(0x8899aa); // default steel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [scene, opacity, renderMode, checklist]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group ref={ref} position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
|
<group ref={ref} position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
|
||||||
<primitive object={scene} />
|
<primitive object={scene} />
|
||||||
@@ -43,8 +82,16 @@ 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 }}
|
||||||
gl={{ antialias: true, alpha: true }}
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
|
||||||
|
frameloop="demand"
|
||||||
className="!bg-background"
|
className="!bg-background"
|
||||||
|
onCreated={({ gl, invalidate }) => {
|
||||||
|
// Target 72fps for Quest 3 compatibility
|
||||||
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
||||||
|
// Continuous render for smooth interaction
|
||||||
|
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
||||||
|
loop();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ambientLight intensity={0.6} />
|
<ambientLight intensity={0.6} />
|
||||||
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
|
||||||
@@ -76,4 +123,3 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
</Canvas>
|
</Canvas>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+33
-19
@@ -3,8 +3,12 @@ import { ArrowLeft, Glasses, Box, Ruler, FileText } from 'lucide-react';
|
|||||||
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 { ViewerControls } from '@/components/ViewerControls';
|
||||||
|
import { InspectionChecklist } from '@/components/InspectionChecklist';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
|
||||||
const Viewer = () => {
|
const Viewer = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -53,32 +57,42 @@ const Viewer = () => {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 3D Canvas */}
|
{/* 3D Canvas with floating controls */}
|
||||||
<div className="flex-1">
|
<div className="relative flex-1">
|
||||||
<ModelViewerCanvas url={model.url} />
|
<ModelViewerCanvas url={model.url} />
|
||||||
|
<ViewerControls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Side panel */}
|
{/* Side panel */}
|
||||||
<aside className="w-72 shrink-0 overflow-y-auto border-l bg-card p-4">
|
<aside className="w-80 shrink-0 border-l bg-card">
|
||||||
<h2 className="mb-4 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<ScrollArea className="h-full">
|
||||||
Informações do Modelo
|
<div className="p-4 space-y-5">
|
||||||
</h2>
|
<div>
|
||||||
|
<h2 className="mb-3 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||||
|
Informações do Modelo
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<Separator />
|
||||||
<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">
|
<InspectionChecklist />
|
||||||
<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.
|
<div className="rounded-lg border bg-muted/50 p-3">
|
||||||
</p>
|
<p className="font-mono text-xs text-muted-foreground">
|
||||||
|
Use o mouse para orbitar, scroll para zoom. Controle a opacidade e o modo de renderização nos controles flutuantes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ interface FineTuning {
|
|||||||
|
|
||||||
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';
|
||||||
|
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
status: 'pending' | 'approved' | 'rejected';
|
||||||
|
note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultChecklist: ChecklistItem[] = [
|
||||||
|
{ id: 'dimensions', label: 'Dimensões Gerais', status: 'pending', note: '' },
|
||||||
|
{ id: 'plates', label: 'Posição das Placas', status: 'pending', note: '' },
|
||||||
|
{ id: 'holes', label: 'Diâmetro dos Furos', status: 'pending', note: '' },
|
||||||
|
{ id: 'weld', label: 'Especificação da Solda', status: 'pending', note: '' },
|
||||||
|
];
|
||||||
|
|
||||||
interface ModelStore {
|
interface ModelStore {
|
||||||
model: ModelInfo | null;
|
model: ModelInfo | null;
|
||||||
@@ -32,6 +47,17 @@ interface ModelStore {
|
|||||||
|
|
||||||
xrSupported: boolean | null;
|
xrSupported: boolean | null;
|
||||||
setXrSupported: (supported: boolean | null) => void;
|
setXrSupported: (supported: boolean | null) => void;
|
||||||
|
|
||||||
|
opacity: number;
|
||||||
|
setOpacity: (opacity: number) => void;
|
||||||
|
|
||||||
|
renderMode: RenderMode;
|
||||||
|
setRenderMode: (mode: RenderMode) => void;
|
||||||
|
|
||||||
|
checklist: ChecklistItem[];
|
||||||
|
setChecklistItemStatus: (id: string, status: 'approved' | 'rejected') => void;
|
||||||
|
setChecklistItemNote: (id: string, note: string) => void;
|
||||||
|
resetChecklist: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 };
|
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotY: 0 };
|
||||||
@@ -52,4 +78,23 @@ export const useModelStore = create<ModelStore>((set) => ({
|
|||||||
|
|
||||||
xrSupported: null,
|
xrSupported: null,
|
||||||
setXrSupported: (xrSupported) => set({ xrSupported }),
|
setXrSupported: (xrSupported) => set({ xrSupported }),
|
||||||
|
|
||||||
|
opacity: 1,
|
||||||
|
setOpacity: (opacity) => set({ opacity }),
|
||||||
|
|
||||||
|
renderMode: 'solid',
|
||||||
|
setRenderMode: (renderMode) => set({ renderMode }),
|
||||||
|
|
||||||
|
checklist: defaultChecklist.map(i => ({ ...i })),
|
||||||
|
setChecklistItemStatus: (id, status) => set((state) => ({
|
||||||
|
checklist: state.checklist.map(item =>
|
||||||
|
item.id === id ? { ...item, status } : item
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
setChecklistItemNote: (id, note) => set((state) => ({
|
||||||
|
checklist: state.checklist.map(item =>
|
||||||
|
item.id === id ? { ...item, note } : item
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
resetChecklist: () => set({ checklist: defaultChecklist.map(i => ({ ...i })) }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user