This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 00:55:33 +00:00
parent f6023e3cb3
commit 96e316aa8e
5 changed files with 308 additions and 24 deletions
+136
View File
@@ -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>
);
}
+43
View File
@@ -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>
);
}
+51 -5
View File
@@ -1,8 +1,9 @@
import { Suspense, useRef, useMemo } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Environment, Html } from '@react-three/drei';
import { Suspense, useRef, useMemo, useEffect } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
interface ModelViewerProps {
url: string;
@@ -22,6 +23,9 @@ function LoadingFallback() {
function GLBModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
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 box = new THREE.Box3().setFromObject(scene);
@@ -32,6 +36,41 @@ function GLBModel({ url }: { url: string }) {
return { size, center };
}, [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 (
<group ref={ref} position={[-modelInfo.center.x, -modelInfo.center.y, -modelInfo.center.z]}>
<primitive object={scene} />
@@ -43,8 +82,16 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
return (
<Canvas
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"
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} />
<directionalLight position={[5, 10, 5]} intensity={1} castShadow />
@@ -76,4 +123,3 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
</Canvas>
);
}