feat(viewer3d): hybrid process visualization (green solid + grey ghost frame), 70% synced WebGL ViewCube, dark grid fine-tuning, and industrial grey wireframe

This commit is contained in:
2026-09-21 01:42:58 +00:00
parent b43d5314fb
commit 94d41c0e85
3 changed files with 396 additions and 255 deletions
+184 -162
View File
@@ -1,174 +1,196 @@
import React, { useState } from 'react';
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>;
}
export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView }) => {
const [hoveredFace, setHoveredFace] = useState<string | null>(null);
export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef }) => {
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 raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
const mouseRef = useRef<THREE.Vector2>(new THREE.Vector2());
useEffect(() => {
if (!containerRef.current) return;
const width = 84;
const height = 84;
// 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
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 (SteelXR Style)
const createFaceTexture = (text: string) => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
if (ctx) {
// Background: Deep Dark Navy Blue (#0f172a / #1e293b)
ctx.fillStyle = '#0f172a';
ctx.fillRect(0, 0, 256, 256);
// Neon Cyan Border (#22d3ee)
ctx.strokeStyle = '#22d3ee';
ctx.lineWidth = 14;
ctx.strokeRect(7, 7, 242, 242);
// Inner soft cyan glow
ctx.strokeStyle = 'rgba(34, 211, 238, 0.4)';
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 = '#06b6d4';
ctx.shadowBlur = 12;
ctx.fillText(text, 128, 128);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
};
// Faces Order in Three.js BoxGeometry:
// 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') }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('ESQ') }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('TOPO') }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('BASE') }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('FRENTE') }),
new THREE.MeshBasicMaterial({ map: createFaceTexture('ATRÁS') }),
];
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: 0x22d3ee, linewidth: 2 });
const wireframe = new THREE.LineSegments(edges, lineMaterial);
cube.add(wireframe);
// 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);
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 Principal translúcido idêntico ao SteelXR */}
<div className="relative w-32 h-32 p-2 rounded-2xl bg-slate-950/85 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 flex items-center justify-center overflow-hidden">
{/* Glow de Fundo */}
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/10 via-transparent to-blue-500/10 pointer-events-none" />
{/* Container Reduzido (~70% do anterior) com estilo SteelXR */}
<div className="relative w-24 h-24 p-1 rounded-2xl bg-slate-950/85 backdrop-blur-xl border border-cyan-500/40 shadow-xl shadow-cyan-950/60 flex items-center justify-center cursor-pointer group">
{/* Glow sutil */}
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/10 via-transparent to-blue-500/10 rounded-2xl pointer-events-none" />
{/* Viewport 3D Isométrica do Cubo */}
<div
className="relative w-20 h-20"
style={{
perspective: '600px',
perspectiveOrigin: '50% 50%',
}}
>
{/* Cubo 3D Rotacionado em Vista Isométrica Natural */}
<div
className="w-full h-full relative transition-transform duration-300 ease-out"
style={{
transformStyle: 'preserve-3d',
transform: 'rotateX(-28deg) rotateY(42deg)',
}}
>
{/* FACE TOPO */}
<button
onClick={() => onSelectView('top')}
onMouseEnter={() => setHoveredFace('TOPO')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'rotateX(90deg) translateZ(40px)',
backgroundColor: hoveredFace === 'TOPO' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'TOPO' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'TOPO'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Superior (Planta)"
>
TOPO
</button>
{/* Canvas WebGL 3D do Cubo Sincronizado */}
<div ref={containerRef} className="w-[84px] h-[84px] flex items-center justify-center" />
{/* FACE FRENTE */}
<button
onClick={() => onSelectView('front')}
onMouseEnter={() => setHoveredFace('FRENTE')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'translateZ(40px)',
backgroundColor: hoveredFace === 'FRENTE' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'FRENTE' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'FRENTE'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Frontal"
>
FRENTE
</button>
{/* FACE DIREITA (DIR) */}
<button
onClick={() => onSelectView('right')}
onMouseEnter={() => setHoveredFace('DIR')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'rotateY(90deg) translateZ(40px)',
backgroundColor: hoveredFace === 'DIR' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'DIR' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'DIR'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Direita"
>
DIR
</button>
{/* FACE ESQUERDA (ESQ) */}
<button
onClick={() => onSelectView('left')}
onMouseEnter={() => setHoveredFace('ESQ')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'rotateY(-90deg) translateZ(40px)',
backgroundColor: hoveredFace === 'ESQ' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'ESQ' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'ESQ'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Esquerda"
>
ESQ
</button>
{/* FACE ATRÁS */}
<button
onClick={() => onSelectView('back')}
onMouseEnter={() => setHoveredFace('ATRÁS')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'rotateY(180deg) translateZ(40px)',
backgroundColor: hoveredFace === 'ATRÁS' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'ATRÁS' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'ATRÁS'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Posterior (Atrás)"
>
ATRÁS
</button>
{/* FACE BASE (INFERIOR) */}
<button
onClick={() => onSelectView('bottom')}
onMouseEnter={() => setHoveredFace('BASE')}
onMouseLeave={() => setHoveredFace(null)}
className="absolute inset-0 flex items-center justify-center font-extrabold text-xs tracking-wider transition-all duration-200 cursor-pointer"
style={{
transform: 'rotateX(-90deg) translateZ(40px)',
backgroundColor: hoveredFace === 'BASE' ? '#0891b2' : '#0f172a',
color: hoveredFace === 'BASE' ? '#ffffff' : '#a5f3fc',
border: '2px solid #22d3ee',
boxShadow:
hoveredFace === 'BASE'
? 'inset 0 0 15px rgba(34, 211, 238, 0.9), 0 0 15px #06b6d4'
: 'inset 0 0 10px rgba(34, 211, 238, 0.35)',
textShadow: '0 0 8px #22d3ee',
}}
title="Vista Inferior (Base)"
>
BASE
</button>
</div>
</div>
{/* Botão de Vista Isométrica Padrão (ISO) */}
{/* Botão ISO no canto inferior */}
<button
onClick={() => onSelectView('iso')}
className="absolute bottom-1.5 right-1.5 p-1 rounded-lg bg-slate-900/90 hover:bg-cyan-600 text-cyan-300 hover:text-white border border-cyan-500/30 transition-all text-[10px] font-mono font-bold flex items-center gap-1 shadow cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onSelectView('iso');
}}
className="absolute bottom-1 right-1 px-1 py-0.5 rounded bg-slate-900/90 hover:bg-cyan-600 text-cyan-300 hover:text-white border border-cyan-500/30 transition-all text-[9px] font-mono font-bold flex items-center gap-0.5 shadow cursor-pointer"
title="Restaurar Vista Isométrica 3D"
>
<RotateCcw className="w-2.5 h-2.5" />
@@ -176,9 +198,9 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView }) => {
</button>
</div>
{/* Rótulo Inferior Elegante */}
<span className="text-[9px] font-mono font-bold tracking-widest uppercase text-cyan-700 dark:text-cyan-400 mt-1">
Cubo de Orientação
{/* Rótulo inferior */}
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-700 dark:text-cyan-400 mt-0.5">
Cubo 3D
</span>
</div>
);