Files
tracksteel_app/src/components/viewer3d/ViewCube.tsx
T

250 lines
9.2 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import * as THREE from 'three';
import { RotateCcw } from 'lucide-react';
interface ViewCubeProps {
onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void;
mainCameraRef?: React.MutableRefObject<THREE.Camera | null>;
size?: number;
compact?: boolean;
}
export const ViewCube: React.FC<ViewCubeProps> = ({
onSelectView,
mainCameraRef,
size = 84,
compact = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.OrthographicCamera | null>(null);
const cubeRef = useRef<THREE.Mesh | null>(null);
const wireframeRef = useRef<THREE.LineSegments | null>(null);
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
const mouseRef = useRef<THREE.Vector2>(new THREE.Vector2());
useEffect(() => {
if (!containerRef.current) return;
const width = size;
const height = size;
// 1. Scene & Camera
const scene = new THREE.Scene();
sceneRef.current = scene;
const frustum = 55;
const camera = new THREE.OrthographicCamera(
frustum / -2,
frustum / 2,
frustum / 2,
frustum / -2,
1,
500
);
camera.position.set(0, 0, 100);
camera.lookAt(0, 0, 0);
cameraRef.current = camera;
// 2. Renderer Transparente
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
rendererRef.current = renderer;
containerRef.current.innerHTML = '';
containerRef.current.appendChild(renderer.domElement);
// 3. Helper to create high-res Canvas Texture for each face
const createFaceTexture = (text: string, isDark: boolean) => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (ctx) {
// Face Background: Azul petróleo equilibrado no claro (#1e293b) e escuro (#0f172a)
ctx.fillStyle = isDark ? '#0f172a' : '#1e293b';
ctx.fillRect(0, 0, 256, 256);
// Neon Cyan Border (#0891b2 no claro / #22d3ee no escuro)
ctx.strokeStyle = isDark ? '#22d3ee' : '#06b6d4';
ctx.lineWidth = 14;
ctx.strokeRect(7, 7, 242, 242);
// Inner soft cyan glow
ctx.strokeStyle = isDark ? 'rgba(34, 211, 238, 0.4)' : 'rgba(6, 182, 212, 0.3)';
ctx.lineWidth = 6;
ctx.strokeRect(18, 18, 220, 220);
// Text: Bold Sans-Serif (#ffffff with Cyan Glow)
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 54px system-ui, -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.shadowColor = isDark ? '#06b6d4' : '#0891b2';
ctx.shadowBlur = isDark ? 12 : 8;
ctx.fillText(text, 128, 128);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
};
const isDarkMode = document.documentElement.classList.contains('dark');
// Faces: 0: Right (DIR), 1: Left (ESQ), 2: Top (TOPO), 3: Bottom (BASE), 4: Front (FRENTE), 5: Back (ATRÁS)
const materials = [
new THREE.MeshBasicMaterial({ map: createFaceTexture('DIR', isDarkMode) }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('ESQ', isDarkMode) }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('TOPO', isDarkMode) }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('BASE', isDarkMode) }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('FRENTE', isDarkMode) }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('ATRÁS', isDarkMode) }),
];
const geometry = new THREE.BoxGeometry(32, 32, 32);
const cube = new THREE.Mesh(geometry, materials);
scene.add(cube);
cubeRef.current = cube;
// Glowing Neon Edges on the Cube
const edges = new THREE.EdgesGeometry(geometry);
const lineMaterial = new THREE.LineBasicMaterial({
color: isDarkMode ? 0x22d3ee : 0x06b6d4,
linewidth: 2,
});
const wireframe = new THREE.LineSegments(edges, lineMaterial);
cube.add(wireframe);
wireframeRef.current = wireframe;
// Função de atualização quando o tema mudar
const updateThemeMaterials = () => {
const dark = document.documentElement.classList.contains('dark');
const labels = ['DIR', 'ESQ', 'TOPO', 'BASE', 'FRENTE', 'ATRÁS'];
materials.forEach((mat, idx) => {
if (mat.map) mat.map.dispose();
mat.map = createFaceTexture(labels[idx], dark);
mat.needsUpdate = true;
});
if (wireframeRef.current && wireframeRef.current.material) {
(wireframeRef.current.material as THREE.LineBasicMaterial).color.set(dark ? 0x22d3ee : 0x06b6d4);
}
};
const themeObserver = new MutationObserver(() => {
updateThemeMaterials();
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
// 4. Animation Loop: Sincroniza com a câmera principal em tempo real
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
if (mainCameraRef && mainCameraRef.current && cubeRef.current) {
// Copia a orientação da câmera principal invertida para o cubo navegar sincronizado
cubeRef.current.quaternion.copy(mainCameraRef.current.quaternion).invert();
}
if (rendererRef.current && sceneRef.current && cameraRef.current) {
rendererRef.current.render(sceneRef.current, cameraRef.current);
}
};
animate();
// 5. Interactivity: Click face to switch view
const handleCanvasClick = (event: MouseEvent) => {
if (!renderer.domElement || !cubeRef.current) return;
const rect = renderer.domElement.getBoundingClientRect();
mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycasterRef.current.setFromCamera(mouseRef.current, camera);
const intersects = raycasterRef.current.intersectObject(cubeRef.current);
if (intersects.length > 0) {
const faceIndex = intersects[0].face?.materialIndex;
switch (faceIndex) {
case 0:
onSelectView('right');
break;
case 1:
onSelectView('left');
break;
case 2:
onSelectView('top');
break;
case 3:
onSelectView('bottom');
break;
case 4:
onSelectView('front');
break;
case 5:
onSelectView('back');
break;
default:
onSelectView('iso');
}
}
};
const dom = renderer.domElement;
dom.addEventListener('click', handleCanvasClick);
return () => {
cancelAnimationFrame(animId);
dom.removeEventListener('click', handleCanvasClick);
themeObserver.disconnect();
materials.forEach((m) => {
if (m.map) m.map.dispose();
m.dispose();
});
geometry.dispose();
renderer.dispose();
};
}, [onSelectView, mainCameraRef]);
return (
<div className="relative select-none flex flex-col items-center">
{/* Container Reduzido Harmonizado para Modo Claro e Escuro */}
<div
className={`relative p-1 rounded-2xl bg-white/90 dark:bg-slate-950/85 backdrop-blur-xl border border-slate-200 dark:border-cyan-500/40 shadow-lg dark:shadow-xl dark:shadow-cyan-950/60 flex items-center justify-center cursor-pointer group transition-all`}
style={{ width: `${size + 12}px`, height: `${size + 12}px` }}
>
{/* Glow sutil harmonizado */}
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/5 dark:from-cyan-500/10 via-transparent to-blue-500/5 dark:to-blue-500/10 rounded-2xl pointer-events-none" />
{/* Canvas WebGL 3D do Cubo Sincronizado */}
<div
ref={containerRef}
style={{ width: `${size}px`, height: `${size}px` }}
className="flex items-center justify-center"
/>
{/* Botão ISO no canto inferior harmonizado com o tema */}
<button
onClick={(e) => {
e.stopPropagation();
onSelectView('iso');
}}
className="absolute bottom-0.5 right-0.5 px-1 py-0.5 rounded bg-slate-100 hover:bg-cyan-600 text-slate-700 hover:text-white border border-slate-300 hover:border-cyan-500 dark:bg-slate-900/90 dark:text-cyan-300 dark:border-cyan-500/30 transition-all text-[8px] font-mono font-bold flex items-center gap-0.5 shadow-sm cursor-pointer"
title="Restaurar Vista Isométrica 3D"
>
<RotateCcw className="w-2 h-2" />
<span>ISO</span>
</button>
</div>
{/* Rótulo inferior opcional */}
{!compact && (
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-800 dark:text-cyan-400 mt-0.5">
Cubo 3D
</span>
)}
</div>
);
};