290 lines
9.4 KiB
TypeScript
290 lines
9.4 KiB
TypeScript
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';
|
|
|
|
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 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 fineTuning = useModelStore((s) => s.fineTuning);
|
|
|
|
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]);
|
|
|
|
// Store original colors so we can restore them
|
|
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
|
|
|
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();
|
|
}
|
|
|
|
// Clean up previous edge lines
|
|
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) {
|
|
// Store original color on first encounter
|
|
if (!originalColors.current.has(mat)) {
|
|
originalColors.current.set(mat, mat.color.clone());
|
|
}
|
|
|
|
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(0x8899aa);
|
|
}
|
|
}
|
|
mat.needsUpdate = true;
|
|
}
|
|
});
|
|
|
|
// Create edge lines for edges mode
|
|
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]);
|
|
|
|
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
|
|
|
|
return (
|
|
<group
|
|
ref={ref}
|
|
position={[
|
|
-modelInfo.center.x + fineTuning.posX,
|
|
-modelInfo.center.y + fineTuning.posY,
|
|
-modelInfo.center.z + fineTuning.posZ,
|
|
]}
|
|
rotation={[0, rotYRad, 0]}
|
|
>
|
|
<primitive object={scene} />
|
|
</group>
|
|
);
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
|
|
function GridLayer() {
|
|
const showGrid = useModelStore((s) => s.showGrid);
|
|
if (!showGrid) return null;
|
|
return (
|
|
<Grid
|
|
infiniteGrid
|
|
cellSize={0.01}
|
|
sectionSize={0.1}
|
|
cellThickness={0.5}
|
|
sectionThickness={1}
|
|
cellColor="#334155"
|
|
sectionColor="#475569"
|
|
fadeDistance={5}
|
|
fadeStrength={1}
|
|
/>
|
|
);
|
|
}
|
|
|
|
export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|
return (
|
|
<Canvas
|
|
camera={{ position: [2, 2, 2], fov: 50, near: 0.001, far: 1000 }}
|
|
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }}
|
|
frameloop="demand"
|
|
className="!bg-background"
|
|
onCreated={({ gl, invalidate }) => {
|
|
gl.setPixelRatio(Math.min(window.devicePixelRatio, 1.5));
|
|
const loop = () => { invalidate(); requestAnimationFrame(loop); };
|
|
loop();
|
|
}}
|
|
>
|
|
<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>
|
|
|
|
<MeasurementOverlay />
|
|
<MeasureClickHandler />
|
|
|
|
<GridLayer />
|
|
|
|
<OrbitControls
|
|
makeDefault
|
|
enableDamping
|
|
dampingFactor={0.1}
|
|
minDistance={0.05}
|
|
maxDistance={50}
|
|
/>
|
|
</Canvas>
|
|
);
|
|
}
|