This commit is contained in:
gpt-engineer-app[bot]
2026-02-27 01:13:10 +00:00
parent 1709a8e9e4
commit 379f3f96e6
5 changed files with 235 additions and 14 deletions
+37
View File
@@ -0,0 +1,37 @@
import { Ruler, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
export function MeasurementsList() {
const { measurements, removeMeasurement } = useModelStore();
if (measurements.length === 0) return null;
return (
<div className="space-y-2">
<h3 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Medições
</h3>
<div className="space-y-1.5">
{measurements.map((m, i) => (
<div key={m.id} className="flex items-center justify-between rounded-lg border bg-muted/30 px-3 py-2">
<div className="flex items-center gap-2">
<Ruler className="h-3.5 w-3.5 text-success" />
<span className="font-mono text-xs text-foreground">
M{i + 1}: <span className="text-success font-bold">{m.distanceMM.toFixed(1)} mm</span>
</span>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={() => removeMeasurement(m.id)}
>
<X className="h-3 w-3 text-muted-foreground" />
</Button>
</div>
))}
</div>
</div>
);
}
+33 -2
View File
@@ -1,10 +1,10 @@
import { Eye, Grid3X3 } from 'lucide-react';
import { Eye, Grid3X3, Ruler, Trash2 } 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();
const { opacity, setOpacity, renderMode, setRenderMode, measureMode, setMeasureMode, measurements, clearMeasurements, measurePoints } = useModelStore();
return (
<div className="absolute bottom-4 left-4 z-10 flex items-end gap-3">
@@ -38,6 +38,37 @@ export function ViewerControls() {
{renderMode === 'wireframe' ? 'Wireframe' : 'Sólido'}
</span>
</Button>
{/* Measure tool */}
<div className="flex items-end gap-1.5">
<Button
variant={measureMode ? 'default' : 'outline'}
size="sm"
className="gap-2 h-9"
onClick={() => setMeasureMode(!measureMode)}
>
<Ruler className="h-3.5 w-3.5" />
<span className="font-mono text-xs">
{measureMode
? measurePoints.length === 0
? 'Ponto A…'
: 'Ponto B…'
: 'Medir'}
</span>
</Button>
{measurements.length > 0 && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9"
onClick={clearMeasurements}
title="Limpar medições"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
)}
</div>
</div>
);
}
+112 -12
View File
@@ -1,6 +1,6 @@
import { Suspense, useRef, useMemo, useEffect } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html } from '@react-three/drei';
import { Suspense, useRef, useMemo, useEffect, useCallback } from 'react';
import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
@@ -37,15 +37,12 @@ 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 {
@@ -58,13 +55,12 @@ function GLBModel({ url }: { url: string }) {
mat.opacity = opacity;
mat.wireframe = renderMode === 'wireframe';
mat.needsUpdate = true;
// Color feedback
if (hasRejected) {
mat.color.setHSL(0, 0.7, 0.5); // red
mat.color.setHSL(0, 0.7, 0.5);
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45); // green
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
mat.color.set(0x8899aa); // default steel
mat.color.set(0x8899aa);
}
}
});
@@ -89,6 +85,109 @@ function GLBModel({ url }: { url: string }) {
);
}
/** Sphere marker at a 3D point */
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
return (
<mesh position={position}>
<sphereGeometry args={[0.003, 16, 16]} />
<meshBasicMaterial color={color} />
</mesh>
);
}
/** Renders all measurements and pending points */
function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
return (
<>
{/* Pending first point */}
{measurePoints.length === 1 && (
<PointMarker position={[measurePoints[0].x, measurePoints[0].y, measurePoints[0].z]} color="#e8a838" />
)}
{/* Completed measurements */}
{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,
];
return (
<group key={m.id}>
<PointMarker position={a} color="#22c55e" />
<PointMarker position={b} color="#22c55e" />
<Line
points={[a, b]}
color="#22c55e"
lineWidth={2}
/>
<Html position={mid} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-success/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-success whitespace-nowrap">
{m.distanceMM.toFixed(1)} mm
</span>
</div>
</Html>
</group>
);
})}
</>
);
}
/** Raycasting click handler for measurement mode */
function MeasureClickHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const handleClick = useCallback((event: MouseEvent) => {
if (!measureMode) return;
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
// Filter out measurement markers and grid
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh) {
if (obj.geometry instanceof THREE.SphereGeometry) return false;
}
return true;
});
if (hit) {
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
}, [measureMode, addMeasurePoint, camera, scene, gl, raycaster, mouse]);
useEffect(() => {
const canvas = gl.domElement;
canvas.addEventListener('click', handleClick);
return () => canvas.removeEventListener('click', handleClick);
}, [gl, handleClick]);
// Change cursor when in measure mode
useEffect(() => {
gl.domElement.style.cursor = measureMode ? 'crosshair' : 'grab';
return () => { gl.domElement.style.cursor = 'grab'; };
}, [measureMode, gl]);
return null;
}
export function ModelViewerCanvas({ url }: ModelViewerProps) {
return (
<Canvas
@@ -97,9 +196,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
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();
}}
@@ -112,6 +209,9 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
<GLBModel url={url} />
</Suspense>
<MeasurementOverlay />
<MeasureClickHandler />
<Grid
infiniteGrid
cellSize={0.01}