- Add hardware error banner with priority system (motion > i/o > emergency) - Add DIO status to HW status display with backend integration - Remove status text from HW status, keep only LED indicators - Add VisionDataPanel showing real-time recognized data for L/C/R ports - Add GetVisionData API in MachineBridge with batch field support - Add BroadcastVisionData function (250ms interval) - Replace 3D model with detailed reel handler equipment - Use OrthographicCamera with front view for distortion-free display - Fix ProcessedDataPanel layout to avoid right sidebar overlap - Show log viewer filename in error message when file not found 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
564 lines
25 KiB
TypeScript
564 lines
25 KiB
TypeScript
import React, { useRef } from 'react';
|
|
import { Canvas, useFrame } from '@react-three/fiber';
|
|
import { OrbitControls, Grid, OrthographicCamera, Text, Box, Environment, RoundedBox, Cylinder, Plane } from '@react-three/drei';
|
|
import * as THREE from 'three';
|
|
import { RobotTarget, IOPoint } from '../types';
|
|
|
|
interface Machine3DProps {
|
|
target: RobotTarget;
|
|
ioState: IOPoint[];
|
|
doorStates: {
|
|
front: boolean;
|
|
right: boolean;
|
|
left: boolean;
|
|
back: boolean;
|
|
};
|
|
}
|
|
|
|
// Reusable Aluminum Profile Component
|
|
const Profile = ({ position, size, rotation = [0, 0, 0] }: { position: [number, number, number], size: [number, number, number], rotation?: [number, number, number] }) => (
|
|
<RoundedBox position={position} args={size} radius={0.05} smoothness={4} rotation={rotation as any}>
|
|
<meshStandardMaterial color="#b0b5be" roughness={0.3} metalness={0.8} />
|
|
</RoundedBox>
|
|
);
|
|
|
|
// Industrial Linear Actuator Rail (Black body + Silver guides)
|
|
const RobotRail = ({
|
|
length,
|
|
orientation = 'horizontal',
|
|
profile = [0.25, 0.25]
|
|
}: {
|
|
length: number,
|
|
orientation?: 'horizontal' | 'vertical',
|
|
profile?: [number, number]
|
|
}) => {
|
|
const isHoriz = orientation === 'horizontal';
|
|
const [dim1, dim2] = profile;
|
|
const size = isHoriz ? [length, dim1, dim2] : [dim1, length, dim2];
|
|
const railW = isHoriz ? length : dim1 * 0.4;
|
|
const railH = isHoriz ? dim1 * 0.4 : length;
|
|
const railD = 0.02;
|
|
const guideZ = dim2 / 2 + 0.005;
|
|
|
|
return (
|
|
<group>
|
|
<RoundedBox args={size as [number, number, number]} radius={0.02}>
|
|
<meshStandardMaterial color="#1e293b" roughness={0.4} metalness={0.5} />
|
|
</RoundedBox>
|
|
<group position={[0, 0, guideZ]}>
|
|
<Box args={[railW, railH, railD]} position={isHoriz ? [0, dim1 * 0.25, 0] : [dim1 * 0.25, 0, 0]}>
|
|
<meshStandardMaterial color="#e2e8f0" metalness={0.8} roughness={0.2} />
|
|
</Box>
|
|
<Box args={[railW, railH, railD]} position={isHoriz ? [0, -dim1 * 0.25, 0] : [-dim1 * 0.25, 0, 0]}>
|
|
<meshStandardMaterial color="#e2e8f0" metalness={0.8} roughness={0.2} />
|
|
</Box>
|
|
</group>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Detailed Industrial Camera Component
|
|
const IndustrialCamera = ({ position, rotation = [0, 0, 0], isActive = false }: { position: [number, number, number], rotation?: [number, number, number], isActive?: boolean }) => {
|
|
return (
|
|
<group position={position} rotation={rotation as any}>
|
|
<group rotation={[0, 0, 0]}>
|
|
<Cylinder args={[0.14, 0.14, 0.1, 32]} position={[0, 0.45, 0]}>
|
|
<meshStandardMaterial color="#111" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.135, 0.135, 0.15, 32]} position={[0, 0.32, 0]}>
|
|
<meshStandardMaterial color="#111" roughness={0.4} />
|
|
</Cylinder>
|
|
<Cylinder args={[0.13, 0.13, 0.05, 32]} position={[0, 0.20, 0]}>
|
|
<meshStandardMaterial color="#e2e8f0" metalness={0.9} roughness={0.1} />
|
|
</Cylinder>
|
|
<Cylinder args={[0.125, 0.125, 0.02, 32]} position={[0, 0.15, 0]}>
|
|
<meshStandardMaterial color="#f97316" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.13, 0.13, 0.05, 32]} position={[0, 0.10, 0]}>
|
|
<meshStandardMaterial color="#e2e8f0" metalness={0.9} roughness={0.1} />
|
|
</Cylinder>
|
|
<Cylinder args={[0.15, 0.15, 0.4, 32]} position={[0, -0.15, 0]}>
|
|
<meshStandardMaterial color="#0f172a" metalness={0.5} roughness={0.5} />
|
|
</Cylinder>
|
|
<Box args={[0.32, 0.4, 0.32]} position={[0, -0.45, 0]}>
|
|
<meshStandardMaterial color="#111" />
|
|
</Box>
|
|
<Plane args={[0.25, 0.3]} position={[0, -0.45, 0.161]} rotation={[0, 0, Math.PI]}>
|
|
<meshStandardMaterial color="#f1f5f9" />
|
|
</Plane>
|
|
<Text position={[0, -0.4, 0.162]} rotation={[0, 0, Math.PI]} fontSize={0.05} color="black">MA-D200B</Text>
|
|
<Text position={[0, -0.5, 0.162]} rotation={[0, 0, Math.PI]} fontSize={0.03} color="black">DC 12V</Text>
|
|
<Cylinder args={[0.08, 0.08, 0.1, 16]} position={[0, -0.7, 0]}>
|
|
<meshStandardMaterial color="#94a3b8" metalness={0.8} />
|
|
</Cylinder>
|
|
</group>
|
|
{isActive && (
|
|
<Cylinder args={[0.05, 0.6, 2.5, 16]} position={[0, 1.7, 0]} rotation={[0, 0, 0]}>
|
|
<meshBasicMaterial color="#22c55e" opacity={0.15} transparent depthWrite={false} />
|
|
</Cylinder>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// SATO CL4NX Plus Printer Component
|
|
const SatoPrinter = ({ position, rotation = [0, 0, 0] }: { position: [number, number, number], rotation?: [number, number, number] }) => {
|
|
return (
|
|
<group position={position} rotation={rotation as any}>
|
|
<RoundedBox args={[0.8, 1.0, 1.5]} position={[0.2, 0, 0]} radius={0.05}>
|
|
<meshStandardMaterial color="#c0c0c0" roughness={0.4} metalness={0.6} />
|
|
</RoundedBox>
|
|
<RoundedBox args={[0.05, 0.5, 0.8]} position={[0.6, 0.1, 0]} radius={0.02}>
|
|
<meshStandardMaterial color="#111" roughness={0.2} metalness={0.8} opacity={0.8} transparent />
|
|
</RoundedBox>
|
|
<RoundedBox args={[0.4, 1.0, 1.5]} position={[-0.4, 0, 0]} radius={0.05}>
|
|
<meshStandardMaterial color="#1e1e1e" roughness={0.6} />
|
|
</RoundedBox>
|
|
<group position={[-0.4, 0, 0.76]}>
|
|
<Plane args={[0.25, 0.2]} position={[0, 0.2, 0]} rotation={[0, 0, 0]}>
|
|
<meshBasicMaterial color="#0ea5e9" />
|
|
</Plane>
|
|
<Plane args={[0.25, 0.3]} position={[0, -0.15, 0]} rotation={[0, 0, 0]}>
|
|
<meshStandardMaterial color="#333" />
|
|
</Plane>
|
|
<Box args={[0.3, 0.02, 0.01]} position={[0, 0.35, 0]}>
|
|
<meshStandardMaterial color="#0044ff" emissive="#0044ff" emissiveIntensity={4} toneMapped={false} />
|
|
</Box>
|
|
<Text position={[0, 0.42, 0]} fontSize={0.04} color="white">SATO</Text>
|
|
<Text position={[0, 0.39, 0]} fontSize={0.03} color="white">CL4NX Plus</Text>
|
|
</group>
|
|
<Box args={[0.01, 1.0, 1.51]} position={[-0.2, 0, 0]}>
|
|
<meshStandardMaterial color="#000" />
|
|
</Box>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Industrial Cart Component
|
|
const Cart = ({ position, label }: { position: [number, number, number], label?: string }) => {
|
|
return (
|
|
<group position={position}>
|
|
<group position={[0, 0.15, 0]}>
|
|
<Cylinder position={[-0.7, 0, -0.7]} rotation={[0,0,Math.PI/2]} args={[0.15, 0.15, 0.1]}><meshStandardMaterial color="#111" /></Cylinder>
|
|
<Cylinder position={[0.7, 0, -0.7]} rotation={[0,0,Math.PI/2]} args={[0.15, 0.15, 0.1]}><meshStandardMaterial color="#111" /></Cylinder>
|
|
<Cylinder position={[-0.7, 0, 0.7]} rotation={[0,0,Math.PI/2]} args={[0.15, 0.15, 0.1]}><meshStandardMaterial color="#111" /></Cylinder>
|
|
<Cylinder position={[0.7, 0, 0.7]} rotation={[0,0,Math.PI/2]} args={[0.15, 0.15, 0.1]}><meshStandardMaterial color="#111" /></Cylinder>
|
|
</group>
|
|
<RoundedBox position={[0, 0.4, 0]} args={[1.8, 0.1, 1.8]} radius={0.05}>
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.5} />
|
|
</RoundedBox>
|
|
<Profile position={[-0.85, 1.4, -0.85]} size={[0.05, 2.0, 0.05]} />
|
|
<Profile position={[0.85, 1.4, -0.85]} size={[0.05, 2.0, 0.05]} />
|
|
<Profile position={[-0.85, 1.4, 0.85]} size={[0.05, 2.0, 0.05]} />
|
|
<Profile position={[0.85, 1.4, 0.85]} size={[0.05, 2.0, 0.05]} />
|
|
<Box position={[0, 2.4, 0]} args={[1.8, 0.05, 1.8]}>
|
|
<meshStandardMaterial color="#94a3b8" transparent opacity={0.2} />
|
|
</Box>
|
|
<group position={[0, 0.45, 0]}>
|
|
{Array.from({ length: 15 }).map((_, i) => (
|
|
<group key={i} position={[0, 0.1 + (i * 0.12), 0]}>
|
|
<Cylinder args={[0.6, 0.6, 0.1, 32]}>
|
|
<meshStandardMaterial color="#1e293b" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.61, 0.61, 0.02, 32]}>
|
|
<meshStandardMaterial color="#f8fafc" />
|
|
</Cylinder>
|
|
</group>
|
|
))}
|
|
{[0, 120, 240].map((angle, idx) => {
|
|
const rad = 0.65;
|
|
const x = Math.cos(angle * Math.PI / 180) * rad;
|
|
const z = Math.sin(angle * Math.PI / 180) * rad;
|
|
return (
|
|
<Cylinder key={idx} args={[0.02, 0.02, 2.2, 8]} position={[x, 1.1, z]}>
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.6} roughness={0.3} />
|
|
</Cylinder>
|
|
);
|
|
})}
|
|
</group>
|
|
{label && (
|
|
<Text position={[0, 2.6, 0]} fontSize={0.2} color="white" anchorY="bottom">
|
|
{label}
|
|
</Text>
|
|
)}
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Glass Door Component
|
|
const Door = ({ position, size, isOpen, hingeDirection = 1 }: { position: [number, number, number], size: [number, number, number], isOpen: boolean, hingeDirection?: number }) => {
|
|
const meshRef = useRef<THREE.Group>(null);
|
|
|
|
useFrame(() => {
|
|
if (meshRef.current) {
|
|
const targetRotation = isOpen ? Math.PI / 2 * hingeDirection : 0;
|
|
meshRef.current.rotation.y = THREE.MathUtils.lerp(meshRef.current.rotation.y, targetRotation, 0.1);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<group ref={meshRef} position={[position[0] - (size[0]/2 * hingeDirection), position[1], position[2]]}>
|
|
<group position={[(size[0]/2 * hingeDirection), 0, 0]}>
|
|
<Box args={[size[0], size[1], 0.05]} position={[0, 0, 0]}>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
<Box args={[size[0] - 0.1, size[1] - 0.1, 0.04]} position={[0, 0, 0]}>
|
|
<meshPhysicalMaterial
|
|
color="#ffffff"
|
|
transmission={0.99}
|
|
opacity={0.1}
|
|
transparent={true}
|
|
roughness={0}
|
|
metalness={0.1}
|
|
clearcoat={1}
|
|
side={THREE.DoubleSide}
|
|
depthWrite={false}
|
|
/>
|
|
</Box>
|
|
<Box args={[0.05, 0.4, 0.1]} position={[-size[0]/2 * hingeDirection * 0.8, 0, 0.05]}>
|
|
<meshStandardMaterial color="black" />
|
|
</Box>
|
|
</group>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Reusable Labeler Unit Component
|
|
const LabelerUnit = ({ side, posY, posZ }: { side: 'left' | 'right', posY: number, posZ: number }) => {
|
|
const isLeft = side === 'left';
|
|
const wallOffsetX = isLeft ? -1.4 : 1.4;
|
|
const printerOffset = isLeft ? 0.3 : -0.3;
|
|
|
|
return (
|
|
<group>
|
|
<Box args={[1.0, 0.1, 1.6]} position={[printerOffset, 0.05, -1.2]}>
|
|
<meshStandardMaterial color="#1e293b" />
|
|
</Box>
|
|
<SatoPrinter position={[printerOffset, 0.6, -1.2]} rotation={[0, 0, 0]} />
|
|
<group position={[wallOffsetX, 0, 0]}>
|
|
<Profile position={[0, 0.75, -1.45]} size={[0.1, 1.5, 0.1]} />
|
|
<Profile position={[0, 0.75, 1.45]} size={[0.1, 1.5, 0.1]} />
|
|
<Profile position={[0, 1.5, 0]} size={[0.1, 0.1, 3.0]} />
|
|
<group position={[0, 1.4, 0]} rotation={[0, Math.PI/2, 0]}>
|
|
<RobotRail length={3.0} orientation="horizontal" profile={[0.2, 0.2]} />
|
|
</group>
|
|
<group position={[0, 1.3, -1.0 + posY]}>
|
|
<Box args={[0.3, 0.15, 0.3]} position={[0, 0, 0]}>
|
|
<meshStandardMaterial color="#f97316" />
|
|
</Box>
|
|
<Box args={[Math.abs(wallOffsetX), 0.1, 0.15]} position={[-wallOffsetX / 2, -0.05, 0]}>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
<group position={[-wallOffsetX, -0.1, 0]}>
|
|
<Box args={[0.15, 0.3, 0.15]} position={[0, 0, 0]}><meshStandardMaterial color="#111" /></Box>
|
|
<Cylinder args={[0.04, 0.04, posZ, 8]} position={[0, -posZ/2, 0]}>
|
|
<meshStandardMaterial color="#ccc" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.12, 0.12, 0.05, 16]} position={[0, -posZ, 0]}>
|
|
<meshStandardMaterial color="black" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.15, 0.15, 0.02, 16]} position={[0, -posZ - 0.02, 0]}>
|
|
<meshStandardMaterial color="yellow" transparent opacity={0.8} />
|
|
</Cylinder>
|
|
</group>
|
|
</group>
|
|
</group>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Tower Lamp Component
|
|
const TowerLamp = ({ ioState }: { ioState: IOPoint[] }) => {
|
|
const redOn = ioState.find(io => io.id === 0 && io.type === 'output')?.state;
|
|
const yellowOn = ioState.find(io => io.id === 1 && io.type === 'output')?.state;
|
|
const greenOn = ioState.find(io => io.id === 2 && io.type === 'output')?.state;
|
|
|
|
const redMat = useRef<THREE.MeshStandardMaterial>(null);
|
|
const yelMat = useRef<THREE.MeshStandardMaterial>(null);
|
|
const grnMat = useRef<THREE.MeshStandardMaterial>(null);
|
|
|
|
useFrame((state) => {
|
|
const time = state.clock.elapsedTime;
|
|
const pulse = (Math.sin(time * 6) * 0.5 + 0.5);
|
|
const intensity = 0.5 + (pulse * 3.0);
|
|
|
|
if (redMat.current) {
|
|
redMat.current.emissiveIntensity = redOn ? intensity : 0;
|
|
redMat.current.opacity = redOn ? 1.0 : 0.3;
|
|
redMat.current.color.setHex(redOn ? 0xff0000 : 0x550000);
|
|
}
|
|
if (yelMat.current) {
|
|
yelMat.current.emissiveIntensity = yellowOn ? intensity : 0;
|
|
yelMat.current.opacity = yellowOn ? 1.0 : 0.3;
|
|
yelMat.current.color.setHex(yellowOn ? 0xffff00 : 0x555500);
|
|
}
|
|
if (grnMat.current) {
|
|
grnMat.current.emissiveIntensity = greenOn ? intensity : 0;
|
|
grnMat.current.opacity = greenOn ? 1.0 : 0.3;
|
|
grnMat.current.color.setHex(greenOn ? 0x00ff00 : 0x005500);
|
|
}
|
|
});
|
|
|
|
return (
|
|
<group position={[5.2, 6.8, -1.3]}>
|
|
<mesh position={[0, 0, 0]}>
|
|
<cylinderGeometry args={[0.05, 0.05, 0.5]} />
|
|
<meshStandardMaterial color="#333" roughness={0.5} />
|
|
</mesh>
|
|
<mesh position={[0, 0.3, 0]}>
|
|
<cylinderGeometry args={[0.08, 0.08, 0.15]} />
|
|
<meshStandardMaterial ref={grnMat} color="#00ff00" emissive="#00ff00" transparent toneMapped={false} />
|
|
</mesh>
|
|
<mesh position={[0, 0.46, 0]}>
|
|
<cylinderGeometry args={[0.08, 0.08, 0.15]} />
|
|
<meshStandardMaterial ref={yelMat} color="#ffff00" emissive="#ffff00" transparent toneMapped={false} />
|
|
</mesh>
|
|
<mesh position={[0, 0.62, 0]}>
|
|
<cylinderGeometry args={[0.08, 0.08, 0.15]} />
|
|
<meshStandardMaterial ref={redMat} color="#ff0000" emissive="#ff0000" transparent toneMapped={false} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// Main Machine Model
|
|
const MachineModel = ({ target, doorStates }: { target: RobotTarget, doorStates: Machine3DProps['doorStates'] }) => {
|
|
const FRAME_WIDTH = 10;
|
|
const TOTAL_HEIGHT = 6.5;
|
|
const DOCK_HEIGHT = 2.5;
|
|
const OPERATIONAL_HEIGHT = TOTAL_HEIGHT - DOCK_HEIGHT;
|
|
const FRAME_DEPTH = 3.0;
|
|
const CONVEYOR_Y = DOCK_HEIGHT + 0.2;
|
|
const LABELER_BASE_Y = CONVEYOR_Y;
|
|
const PICKER_Y = 4.8;
|
|
|
|
const minZLength = 0.6;
|
|
const zRailLength = Math.max(minZLength, target.z + 0.4);
|
|
|
|
const Z_XRAIL = -0.6;
|
|
const Z_CARRIAGE = -0.45;
|
|
const Z_ZRAIL = -0.3;
|
|
const Z_HEAD = 0;
|
|
|
|
// Convert target coordinates
|
|
const pickerX = target.x;
|
|
const pickerZ = target.z;
|
|
const leftLabelY = target.y;
|
|
const leftLabelZ = 0.5;
|
|
const rightLabelY = target.y;
|
|
const rightLabelZ = 0.5;
|
|
|
|
return (
|
|
<group>
|
|
{/* --- STRUCTURAL FRAME --- */}
|
|
<group>
|
|
<Profile position={[-FRAME_WIDTH/2, TOTAL_HEIGHT/2, -FRAME_DEPTH/2]} size={[0.2, TOTAL_HEIGHT, 0.2]} />
|
|
<Profile position={[FRAME_WIDTH/2, TOTAL_HEIGHT/2, -FRAME_DEPTH/2]} size={[0.2, TOTAL_HEIGHT, 0.2]} />
|
|
<Profile position={[-FRAME_WIDTH/2, TOTAL_HEIGHT/2, FRAME_DEPTH/2]} size={[0.2, TOTAL_HEIGHT, 0.2]} />
|
|
<Profile position={[FRAME_WIDTH/2, TOTAL_HEIGHT/2, FRAME_DEPTH/2]} size={[0.2, TOTAL_HEIGHT, 0.2]} />
|
|
|
|
<Profile position={[0, DOCK_HEIGHT, -FRAME_DEPTH/2]} size={[FRAME_WIDTH, 0.2, 0.2]} />
|
|
<Profile position={[0, DOCK_HEIGHT, FRAME_DEPTH/2]} size={[FRAME_WIDTH, 0.2, 0.2]} />
|
|
<Profile position={[-FRAME_WIDTH/2, DOCK_HEIGHT, 0]} size={[0.2, 0.2, FRAME_DEPTH]} />
|
|
<Profile position={[FRAME_WIDTH/2, DOCK_HEIGHT, 0]} size={[0.2, 0.2, FRAME_DEPTH]} />
|
|
|
|
<Profile position={[0, TOTAL_HEIGHT, -FRAME_DEPTH/2]} size={[FRAME_WIDTH, 0.2, 0.2]} />
|
|
<Profile position={[0, TOTAL_HEIGHT, FRAME_DEPTH/2]} size={[FRAME_WIDTH, 0.2, 0.2]} />
|
|
<Profile position={[-FRAME_WIDTH/2, TOTAL_HEIGHT, 0]} size={[0.2, 0.2, FRAME_DEPTH]} />
|
|
<Profile position={[FRAME_WIDTH/2, TOTAL_HEIGHT, 0]} size={[0.2, 0.2, FRAME_DEPTH]} />
|
|
|
|
<group position={[0, PICKER_Y + 0.4, Z_XRAIL]}>
|
|
<RobotRail length={FRAME_WIDTH - 0.4} orientation="horizontal" profile={[0.6, 0.2]} />
|
|
</group>
|
|
</group>
|
|
|
|
{/* --- DOCKING AREA (LOWER LEVEL) --- */}
|
|
<group position={[0, 0, 0]}>
|
|
<Profile position={[-1.75, DOCK_HEIGHT/2, 0]} size={[0.1, DOCK_HEIGHT, FRAME_DEPTH]} />
|
|
<Profile position={[1.75, DOCK_HEIGHT/2, 0]} size={[0.1, DOCK_HEIGHT, FRAME_DEPTH]} />
|
|
|
|
{[-3.5, 0, 3.5].map((xPos, idx) => (
|
|
<group key={idx} position={[xPos, 0.02, 0]}>
|
|
<Plane args={[2.2, FRAME_DEPTH - 0.5]} rotation={[-Math.PI/2, 0, 0]} position={[0, 0, 0]}>
|
|
<meshStandardMaterial color="#0f172a" />
|
|
</Plane>
|
|
<Box args={[0.1, 0.05, FRAME_DEPTH - 0.5]} position={[-1, 0, 0]}><meshStandardMaterial color="yellow" /></Box>
|
|
<Box args={[0.1, 0.05, FRAME_DEPTH - 0.5]} position={[1, 0, 0]}><meshStandardMaterial color="yellow" /></Box>
|
|
<Text position={[0, 0.1, FRAME_DEPTH/2 - 0.5]} fontSize={0.3} rotation={[-Math.PI/2, 0, 0]} color="white">
|
|
{idx === 0 ? "SPARE L" : idx === 1 ? "LOADING" : "SPARE R"}
|
|
</Text>
|
|
</group>
|
|
))}
|
|
|
|
<Cart position={[-3.5, 0, 0]} label="Cart A" />
|
|
<Cart position={[0, 0, 0]} label="Cart B" />
|
|
<Cart position={[3.5, 0, 0]} label="Cart C" />
|
|
</group>
|
|
|
|
{/* --- DOORS (UPPER LEVEL ONLY) --- */}
|
|
<group position={[0, DOCK_HEIGHT + OPERATIONAL_HEIGHT/2, FRAME_DEPTH/2]}>
|
|
<Door position={[-3.3, 0, 0]} size={[3.3, OPERATIONAL_HEIGHT, 0.1]} isOpen={doorStates.front || doorStates.left} hingeDirection={-1} />
|
|
<Door position={[0, 0, 0]} size={[3.3, OPERATIONAL_HEIGHT, 0.1]} isOpen={doorStates.front} hingeDirection={1} />
|
|
<Door position={[3.3, 0, 0]} size={[3.3, OPERATIONAL_HEIGHT, 0.1]} isOpen={doorStates.front || doorStates.right} hingeDirection={1} />
|
|
</group>
|
|
|
|
{/* --- TOP COVER (ROOF) --- */}
|
|
<group position={[0, TOTAL_HEIGHT, 0]}>
|
|
<Box args={[FRAME_WIDTH, 0.1, FRAME_DEPTH]}>
|
|
<meshPhysicalMaterial
|
|
color="#ffffff"
|
|
transmission={0.99}
|
|
opacity={0.1}
|
|
transparent={true}
|
|
roughness={0}
|
|
metalness={0.1}
|
|
clearcoat={1}
|
|
thickness={0.02}
|
|
side={THREE.DoubleSide}
|
|
depthWrite={false}
|
|
/>
|
|
</Box>
|
|
<Box args={[FRAME_WIDTH, 0.12, 0.1]} position={[0, 0, FRAME_DEPTH/2]}><meshStandardMaterial color="#334155" /></Box>
|
|
<Box args={[FRAME_WIDTH, 0.12, 0.1]} position={[0, 0, -FRAME_DEPTH/2]}><meshStandardMaterial color="#334155" /></Box>
|
|
<Box args={[0.1, 0.12, FRAME_DEPTH]} position={[FRAME_WIDTH/2, 0, 0]}><meshStandardMaterial color="#334155" /></Box>
|
|
<Box args={[0.1, 0.12, FRAME_DEPTH]} position={[-FRAME_WIDTH/2, 0, 0]}><meshStandardMaterial color="#334155" /></Box>
|
|
</group>
|
|
|
|
{/* --- CONVEYORS (Left & Right - UPPER LEVEL) --- */}
|
|
<group position={[-3.5, CONVEYOR_Y, 0]}>
|
|
<Box args={[2.5, 0.2, 3.0]} receiveShadow>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
<Box args={[2.3, 0.05, 3.0]} position={[0, 0.11, 0]}>
|
|
<meshStandardMaterial color="#020617" roughness={0.8} />
|
|
</Box>
|
|
<Text position={[0, 0.5, 1.5]} fontSize={0.3} color="white" rotation={[-Math.PI/4, 0, 0]}>Left Conveyor</Text>
|
|
<Cylinder args={[0.6, 0.6, 0.1, 32]} position={[0, 0.16, 0]}>
|
|
<meshStandardMaterial color="#38bdf8" transparent opacity={0.3} />
|
|
</Cylinder>
|
|
<Text position={[0, 0.3, 0]} fontSize={0.2} color="#38bdf8" rotation={[-Math.PI/2, 0, 0]}>PLACE REEL</Text>
|
|
</group>
|
|
|
|
<group position={[3.5, CONVEYOR_Y, 0]}>
|
|
<Box args={[2.5, 0.2, 3.0]} receiveShadow>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
<Box args={[2.3, 0.05, 3.0]} position={[0, 0.11, 0]}>
|
|
<meshStandardMaterial color="#020617" roughness={0.8} />
|
|
</Box>
|
|
<Text position={[0, 0.5, 1.5]} fontSize={0.3} color="white" rotation={[-Math.PI/4, 0, 0]}>Right Conveyor</Text>
|
|
<Cylinder args={[0.6, 0.6, 0.1, 32]} position={[0, 0.16, 0]}>
|
|
<meshStandardMaterial color="#38bdf8" transparent opacity={0.3} />
|
|
</Cylinder>
|
|
<Text position={[0, 0.3, 0]} fontSize={0.2} color="#38bdf8" rotation={[-Math.PI/2, 0, 0]}>PLACE REEL</Text>
|
|
</group>
|
|
|
|
{/* --- MAIN PICKER (Robot Design) --- */}
|
|
<group position={[pickerX, PICKER_Y, 0]}>
|
|
<group position={[0, 0.4, Z_CARRIAGE]}>
|
|
<RoundedBox args={[0.4, 0.7, 0.1]} radius={0.02}>
|
|
<meshStandardMaterial color="#ef4444" />
|
|
</RoundedBox>
|
|
<Box args={[0.3, 0.15, 0.2]} position={[0, 0.4, 0.1]}>
|
|
<meshStandardMaterial color="#1e1e1e" />
|
|
</Box>
|
|
</group>
|
|
|
|
<group position={[0, -zRailLength/2 + 0.5, Z_ZRAIL]}>
|
|
<RobotRail length={zRailLength} orientation="vertical" profile={[0.2, 0.15]} />
|
|
</group>
|
|
|
|
<group position={[0, -pickerZ, Z_HEAD]}>
|
|
<Box args={[0.2, 0.15, 0.3]} position={[0, 0, -0.15]}>
|
|
<meshStandardMaterial color="#333" />
|
|
</Box>
|
|
<RoundedBox args={[0.5, 0.2, 0.4]} radius={0.02} position={[0, -0.1, 0]}>
|
|
<meshStandardMaterial color="#ef4444" />
|
|
</RoundedBox>
|
|
<group rotation={[0, 0, 0]} position={[0, -0.2, 0]}>
|
|
<Cylinder args={[0.25, 0.25, 0.2, 32]}>
|
|
<meshStandardMaterial color="#111" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.5, 0.5, 0.05, 32]} position={[0, -0.12, 0]}>
|
|
<meshStandardMaterial color="#cbd5e1" metalness={0.6} roughness={0.2} />
|
|
</Cylinder>
|
|
{Array.from({ length: 8 }).map((_, i) => {
|
|
const angle = (i / 8) * Math.PI * 2;
|
|
const radius = 0.4;
|
|
const x = Math.cos(angle) * radius;
|
|
const z = Math.sin(angle) * radius;
|
|
return (
|
|
<group key={i} position={[x, -0.15, z]}>
|
|
<Cylinder args={[0.02, 0.02, 0.1, 8]} position={[0, 0.03, 0]}>
|
|
<meshStandardMaterial color="#64748b" />
|
|
</Cylinder>
|
|
<Cylinder args={[0.06, 0.04, 0.04, 16]} position={[0, -0.04, 0]}>
|
|
<meshStandardMaterial color="#fca5a5" transparent opacity={0.9} />
|
|
</Cylinder>
|
|
</group>
|
|
);
|
|
})}
|
|
</group>
|
|
</group>
|
|
</group>
|
|
|
|
{/* --- LABELER UNITS --- */}
|
|
<group position={[-3.5, LABELER_BASE_Y, 0]}>
|
|
<LabelerUnit side="left" posY={leftLabelY} posZ={leftLabelZ} />
|
|
</group>
|
|
<group position={[3.5, LABELER_BASE_Y, 0]}>
|
|
<LabelerUnit side="right" posY={rightLabelY} posZ={rightLabelZ} />
|
|
</group>
|
|
|
|
{/* --- CAMERAS (Roof Mounted) --- */}
|
|
<Profile position={[-3.5, TOTAL_HEIGHT - 0.2, 0]} size={[0.2, 0.4, 0.2]} />
|
|
<Profile position={[3.5, TOTAL_HEIGHT - 0.2, 0]} size={[0.2, 0.4, 0.2]} />
|
|
<IndustrialCamera position={[-3.5, 5.5, 0]} rotation={[Math.PI, 0, 0]} isActive={false} />
|
|
<IndustrialCamera position={[3.5, 5.5, 0]} rotation={[Math.PI, 0, 0]} isActive={false} />
|
|
|
|
{/* --- REAR DISCHARGE CHUTES --- */}
|
|
<Box position={[-3.5, CONVEYOR_Y, -FRAME_DEPTH/2 - 0.5]} args={[2.5, 0.2, 1]}>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
<Box position={[3.5, CONVEYOR_Y, -FRAME_DEPTH/2 - 0.5]} args={[2.5, 0.2, 1]}>
|
|
<meshStandardMaterial color="#334155" />
|
|
</Box>
|
|
|
|
{/* Grid Floor */}
|
|
<Grid infiniteGrid fadeDistance={25} sectionColor="#475569" cellColor="#1e293b" />
|
|
</group>
|
|
);
|
|
};
|
|
|
|
export const Machine3D: React.FC<Machine3DProps> = ({ target, ioState, doorStates }) => {
|
|
return (
|
|
<Canvas shadows className="w-full h-full bg-slate-900" orthographic>
|
|
{/* 정면에서 바라보는 직교 카메라 (왜곡 없음) */}
|
|
<OrthographicCamera
|
|
makeDefault
|
|
position={[0, 3.5, 20]} // 정면에서 바라봄
|
|
zoom={80} // 줌 레벨 (클수록 가까이)
|
|
near={0.1}
|
|
far={100}
|
|
/>
|
|
<OrbitControls
|
|
maxPolarAngle={Math.PI}
|
|
minPolarAngle={0}
|
|
minZoom={30}
|
|
maxZoom={200}
|
|
target={[0, 3.5, 0]}
|
|
enableRotate={true}
|
|
/>
|
|
<Environment preset="city" />
|
|
|
|
<ambientLight intensity={0.5} />
|
|
<directionalLight position={[10, 15, 10]} intensity={1} castShadow />
|
|
<directionalLight position={[-10, 10, -10]} intensity={0.3} />
|
|
<pointLight position={[0, 5, 5]} intensity={0.3} color="#bae6fd" distance={15} />
|
|
|
|
<MachineModel target={target} doorStates={doorStates} />
|
|
<TowerLamp ioState={ioState} />
|
|
</Canvas>
|
|
);
|
|
};
|