Files
SteelXR2/src/components/three/ModelViewer.tsx
T
gpt-engineer-app[bot] 2ad24c76a8 Changes
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
2026-05-14 11:28:35 +00:00

494 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
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 rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
const rotZRad = (fineTuning.rotZ * Math.PI) / 180;
const s = fineTuning.scale ?? 1;
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
ref={ref}
position={[
-modelInfo.center.x + fineTuning.posX,
-modelInfo.center.y + fineTuning.posY,
-modelInfo.center.z + fineTuning.posZ,
]}
rotation={[rotXRad, rotYRad, rotZRad]}
scale={[s, s, s]}
>
<primitive object={scene} />
</group>
</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>
);
}
/** Snap ring indicator */
function SnapRing({ position }: { position: [number, number, number] }) {
return (
<mesh position={position} rotation={[-Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.003, 0.005, 24]} />
<meshBasicMaterial color="#eab308" side={THREE.DoubleSide} />
</mesh>
);
}
/** Renders all measurements, snap point, and hover info */
function MeasurementOverlay() {
const measurements = useModelStore((s) => s.measurements);
const measurePoints = useModelStore((s) => s.measurePoints);
const snapPoint = useModelStore((s) => s.snapPoint);
const hoverInfo = useModelStore((s) => s.hoverInfo);
const measureMode = useModelStore((s) => s.measureMode);
return (
<>
{/* Snap indicator */}
{measureMode && snapPoint && (
<SnapRing position={[snapPoint.x, snapPoint.y, snapPoint.z]} />
)}
{/* Hover auto-detect tooltip */}
{hoverInfo && (
<Html position={hoverInfo.position} center distanceFactor={1} style={{ pointerEvents: 'none' }}>
<div className="rounded bg-card/95 border border-primary/50 px-2 py-0.5 shadow-lg backdrop-blur-sm">
<span className="font-mono text-[11px] font-bold text-primary whitespace-nowrap">
{hoverInfo.type === 'hole' ? '⌀ ' : ''}
{hoverInfo.value.toFixed(1)} mm
</span>
</div>
</Html>
)}
{/* 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>
);
})}
</>
);
}
/** Smart vertex snap on pointer move */
function SmartSnapHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setSnapPoint = useModelStore((s) => s.setSnapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const mouseRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouseRef.current.x = e.clientX - rect.left;
mouseRef.current.y = e.clientY - rect.top;
mouse.x = (mouseRef.current.x / rect.width) * 2 - 1;
mouse.y = -(mouseRef.current.y / rect.height) * 2 + 1;
};
gl.domElement.addEventListener('pointermove', onMove);
return () => gl.domElement.removeEventListener('pointermove', onMove);
}, [gl, mouse]);
useFrame(() => {
if (!measureMode) {
setSnapPoint(null);
return;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
if (obj.userData.__edgeLine) return false;
return obj instanceof THREE.Mesh;
});
if (hit && hit.object instanceof THREE.Mesh) {
const canvas = gl.domElement;
const snap = findNearestVertex(
hit.object, hit.point, camera,
{ width: canvas.clientWidth, height: canvas.clientHeight },
10
);
if (snap) {
setSnapPoint({ x: snap.x, y: snap.y, z: snap.z });
} else {
setSnapPoint(null);
}
} else {
setSnapPoint(null);
}
});
return null;
}
/** Hover detector for auto-detect hole diameter and edge length */
function HoverDetector() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const setHoverInfo = useModelStore((s) => s.setHoverInfo);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastHitKey = useRef('');
useEffect(() => {
if (measureMode) {
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
return;
}
const onMove = (e: MouseEvent) => {
const rect = gl.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
const hit = intersects.find(i => {
const obj = i.object;
if (obj instanceof THREE.GridHelper) return false;
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.SphereGeometry) return false;
if (obj instanceof THREE.Mesh && obj.geometry instanceof THREE.RingGeometry) return false;
if (obj.userData.__edgeLine) return false;
return obj instanceof THREE.Mesh;
});
if (!hit || !(hit.object instanceof THREE.Mesh)) {
lastHitKey.current = '';
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
return;
}
// Stability check same approximate position for debounce
const key = `${hit.point.x.toFixed(3)},${hit.point.y.toFixed(3)},${hit.point.z.toFixed(3)}`;
if (key === lastHitKey.current) return; // timer already running
lastHitKey.current = key;
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
hoverTimer.current = setTimeout(() => {
if (!(hit.object instanceof THREE.Mesh)) return;
const canvas = gl.domElement;
const canvasSize = { width: canvas.clientWidth, height: canvas.clientHeight };
// Try hole detection first
if (hit.faceIndex !== undefined) {
const hole = detectHoleAtFace(hit.object, hit.faceIndex);
if (hole) {
setHoverInfo({
type: 'hole',
position: [hole.center.x, hole.center.y, hole.center.z],
value: hole.diameterMM,
});
return;
}
}
// Try edge detection
const edge = findNearestEdgeSegment(hit.object, hit.point, camera, canvasSize, 15);
if (edge) {
setHoverInfo({
type: 'edge',
position: [edge.midpoint.x, edge.midpoint.y, edge.midpoint.z],
value: edge.lengthMM,
});
return;
}
setHoverInfo(null);
}, 1000);
};
const onLeave = () => {
lastHitKey.current = '';
setHoverInfo(null);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
};
gl.domElement.addEventListener('pointermove', onMove);
gl.domElement.addEventListener('pointerleave', onLeave);
return () => {
gl.domElement.removeEventListener('pointermove', onMove);
gl.domElement.removeEventListener('pointerleave', onLeave);
if (hoverTimer.current) clearTimeout(hoverTimer.current);
};
}, [measureMode, camera, scene, gl, raycaster, mouse, setHoverInfo]);
return null;
}
/** Raycasting click handler for measurement mode uses snap point when available */
function MeasureClickHandler() {
const { camera, scene, gl } = useThree();
const measureMode = useModelStore((s) => s.measureMode);
const addMeasurePoint = useModelStore((s) => s.addMeasurePoint);
const snapPoint = useModelStore((s) => s.snapPoint);
const raycaster = useMemo(() => new THREE.Raycaster(), []);
const mouse = useMemo(() => new THREE.Vector2(), []);
const handleClick = useCallback((event: MouseEvent) => {
if (!measureMode) return;
// Use snap point if available
if (snapPoint) {
addMeasurePoint({ x: snapPoint.x, y: snapPoint.y, z: snapPoint.z });
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);
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;
if (obj.geometry instanceof THREE.RingGeometry) return false;
}
return true;
});
if (hit) {
addMeasurePoint({ x: hit.point.x, y: hit.point.y, z: hit.point.z });
}
}, [measureMode, addMeasurePoint, snapPoint, 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 />
<SmartSnapHandler />
<HoverDetector />
<GridLayer />
<OrbitControls
makeDefault
enableDamping
dampingFactor={0.1}
minDistance={0.05}
maxDistance={50}
/>
</Canvas>
);
}