ERP-node/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx

666 lines
23 KiB
TypeScript

"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react";
import * as THREE from "three";
interface YardPlacement {
id: number;
yard_layout_id?: number;
material_code?: string | null;
material_name?: string | null;
quantity?: number | null;
unit?: string | null;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
data_source_type?: string | null;
data_source_config?: any;
data_binding?: any;
}
interface Yard3DCanvasProps {
placements: YardPlacement[];
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement | null) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
gridSize?: number; // 그리드 크기 (기본값: 5)
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
}
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
function snapToGrid(value: number, gridSize: number): number {
// 가장 가까운 그리드 칸 찾기
const gridIndex = Math.round(value / gridSize);
// 그리드 칸의 중심점 반환
// gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5...
// 이렇게 하면 Box가 칸 안에 정확히 들어감
return gridIndex * gridSize + gridSize / 2;
}
// 자재 박스 컴포넌트 (드래그 가능)
function MaterialBox({
placement,
isSelected,
onClick,
onDrag,
onDragStart,
onDragEnd,
gridSize = 5,
allPlacements = [],
onCollisionDetected,
}: {
placement: YardPlacement;
isSelected: boolean;
onClick: () => void;
onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void;
onDragEnd?: () => void;
gridSize?: number;
allPlacements?: YardPlacement[];
onCollisionDetected?: () => void;
}) {
const meshRef = useRef<THREE.Mesh>(null);
const [isDragging, setIsDragging] = useState(false);
const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 });
const { camera, gl } = useThree();
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
const palletHeight = 0.3; // 팔레트 높이
const palletGap = 0.05; // 팔레트와 박스 사이 간격
const mySize = placement.size_x || gridSize; // 내 크기 (5)
const myHalfSize = mySize / 2; // 2.5
const mySizeY = placement.size_y || gridSize; // 박스 높이 (5)
const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
for (const p of allPlacements) {
// 자기 자신은 제외
if (Number(p.id) === Number(placement.id)) {
continue;
}
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
const pHalfSize = pSize / 2; // 2.5
const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5)
const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이
// 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지)
const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛)
const isNearby =
Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접
Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접
if (isNearby) {
// 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정)
const isActuallyOverlapping =
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침
if (isActuallyOverlapping) {
// 실제로 겹침: 위에 배치
// 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산
const topOfOtherElement = p.position_y + pTotalHeight / 2;
// 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산
const myYOnTop = topOfOtherElement + myTotalHeight / 2;
if (myYOnTop > maxYBelow) {
maxYBelow = myYOnTop;
}
}
// 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지)
}
}
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
return {
hasCollision: needsAdjustment,
adjustedY: maxYBelow,
};
};
// 드래그 중이 아닐 때만 위치 동기화
useEffect(() => {
if (!isDragging && meshRef.current) {
const currentPos = meshRef.current.position;
const targetX = placement.position_x;
const targetY = placement.position_y;
const targetZ = placement.position_z;
// 현재 위치와 목표 위치가 다를 때만 업데이트 (0.01 이상 차이)
const threshold = 0.01;
const needsUpdate =
Math.abs(currentPos.x - targetX) > threshold ||
Math.abs(currentPos.y - targetY) > threshold ||
Math.abs(currentPos.z - targetZ) > threshold;
if (needsUpdate) {
meshRef.current.position.set(targetX, targetY, targetZ);
}
}
}, [placement.position_x, placement.position_y, placement.position_z, isDragging]);
// 전역 이벤트 리스너 등록
useEffect(() => {
const handleGlobalMouseMove = (e: MouseEvent) => {
if (isDragging && onDrag && meshRef.current) {
e.preventDefault();
e.stopPropagation();
// 마우스 이동 거리 계산 (픽셀)
const deltaX = e.clientX - mouseStartPos.current.x;
const deltaY = e.clientY - mouseStartPos.current.y;
// 카메라 거리를 고려한 스케일 팩터
const distance = camera.position.distanceTo(meshRef.current.position);
const scaleFactor = distance / 500; // 조정 가능한 값
// 카메라 방향 벡터
const cameraDirection = new THREE.Vector3();
camera.getWorldDirection(cameraDirection);
// 카메라의 우측 벡터 (X축 이동용)
const right = new THREE.Vector3();
right.crossVectors(camera.up, cameraDirection).normalize();
// 실제 3D 공간에서의 이동량 계산
const moveRight = right.multiplyScalar(-deltaX * scaleFactor);
const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z)
.normalize()
.multiplyScalar(deltaY * scaleFactor);
// 최종 위치 계산
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
// NaN 검증
if (isNaN(finalX) || isNaN(finalZ)) {
return;
}
// 그리드에 스냅
const snappedX = snapToGrid(finalX, gridSize);
const snappedZ = snapToGrid(finalZ, gridSize);
// 충돌 체크 및 Y 위치 조정
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
meshRef.current.position.set(finalX, adjustedY, finalZ);
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
// 실제 저장은 handleGlobalMouseUp에서만 수행
}
};
const handleGlobalMouseUp = () => {
if (isDragging && meshRef.current) {
const currentPos = meshRef.current.position;
// 실제로 이동했는지 확인 (최소 이동 거리: 0.1)
const minMovement = 0.1;
const deltaX = Math.abs(currentPos.x - dragStartPos.current.x);
const deltaZ = Math.abs(currentPos.z - dragStartPos.current.z);
const hasMoved = deltaX > minMovement || deltaZ > minMovement;
if (hasMoved) {
// 실제로 드래그한 경우: 그리드에 스냅
const snappedX = snapToGrid(currentPos.x, gridSize);
const snappedZ = snapToGrid(currentPos.z, gridSize);
// Y 위치 조정 (마인크래프트처럼 쌓기)
const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
// ✅ 항상 배치 가능 (위로 올라가므로)
console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
meshRef.current.position.set(snappedX, adjustedY, snappedZ);
// 최종 위치 저장 (조정된 Y 위치로)
if (onDrag) {
onDrag({
x: snappedX,
y: adjustedY,
z: snappedZ,
});
}
} else {
// 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
}
setIsDragging(false);
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
if (onDragEnd) {
onDragEnd();
}
}
};
if (isDragging) {
window.addEventListener("mousemove", handleGlobalMouseMove);
window.addEventListener("mouseup", handleGlobalMouseUp);
return () => {
window.removeEventListener("mousemove", handleGlobalMouseMove);
window.removeEventListener("mouseup", handleGlobalMouseUp);
};
}
}, [isDragging, onDrag, onDragEnd, camera, isSelected, gl.domElement]);
const handlePointerDown = (e: any) => {
e.stopPropagation();
// 뷰어 모드(onDrag 없음)에서는 클릭만 처리
if (!onDrag) {
return;
}
// 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) {
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
const currentPos = meshRef.current.position;
dragStartPos.current = {
x: currentPos.x,
y: currentPos.y,
z: currentPos.z,
};
// 마우스 시작 위치 저장
mouseStartPos.current = {
x: e.clientX,
y: e.clientY,
};
setIsDragging(true);
gl.domElement.style.cursor = "grabbing";
if (onDragStart) {
onDragStart();
}
}
};
// 요소가 설정되었는지 확인
const isConfigured = !!(placement.material_name && placement.quantity && placement.unit);
const boxHeight = placement.size_y || gridSize;
const boxWidth = placement.size_x || gridSize;
const boxDepth = placement.size_z || gridSize;
const palletHeight = 0.3; // 팔레트 높이
const palletGap = 0.05; // 팔레트와 박스 사이 간격 (매우 작게)
// 팔레트 위치 계산: 박스 하단부터 시작
const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap;
return (
<group
ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]}
onClick={(e) => {
e.stopPropagation();
e.nativeEvent?.stopPropagation();
e.nativeEvent?.stopImmediatePropagation();
onClick();
}}
onPointerDown={handlePointerDown}
onPointerOver={() => {
if (onDrag) {
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
} else {
gl.domElement.style.cursor = "pointer";
}
}}
onPointerOut={() => {
if (!isDragging) {
gl.domElement.style.cursor = "default";
}
}}
>
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
<group position={[0, palletYOffset, 0]}>
{/* 상단 가로 판자들 (5개) */}
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
<Box
key={`top-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
position={[0, palletHeight * 0.35, zOffset]}
>
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]} />
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
{/* 중간 세로 받침대 (3개) */}
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
<Box
key={`middle-${idx}`}
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
position={[xOffset, 0, 0]}
>
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]} />
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
</lineSegments>
</Box>
))}
{/* 하단 가로 판자들 (3개) */}
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
<Box
key={`bottom-${idx}`}
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
position={[0, -palletHeight * 0.35, zOffset]}
>
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]} />
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
</lineSegments>
</Box>
))}
</group>
{/* 메인 박스 */}
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
{/* 메인 재질 - 골판지 느낌 */}
<meshStandardMaterial
color={placement.color}
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
wireframe={!isConfigured}
roughness={0.95}
metalness={0.05}
/>
{/* 외곽선 - 더 진하게 */}
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
<lineBasicMaterial color="#1a1a1a" opacity={0.8} transparent />
</lineSegments>
</Box>
{/* 포장 테이프 (가로) - 윗면 */}
{isConfigured && (
<>
{/* 테이프 세로 */}
<Box args={[boxWidth * 0.12, 0.02, boxDepth * 0.95]} position={[0, boxHeight / 2 + 0.01, 0]}>
<meshStandardMaterial color="#d4a574" opacity={0.7} transparent roughness={0.3} metalness={0.3} />
</Box>
</>
)}
{/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */}
{isConfigured && placement.material_name && (
<group position={[0, boxHeight * 0.1, boxDepth / 2 + 0.02]}>
{/* 라벨 배경 (흰색 스티커) */}
<Box args={[boxWidth * 0.7, boxHeight * 0.25, 0.01]}>
<meshStandardMaterial color="#ffffff" roughness={0.4} metalness={0.1} />
<lineSegments>
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.7, boxHeight * 0.25, 0.01)]} />
<lineBasicMaterial color="#cccccc" opacity={0.8} transparent />
</lineSegments>
</Box>
{/* 라벨 텍스트 */}
<Text
position={[0, 0, 0.02]}
fontSize={0.3}
color="#000000"
anchorX="center"
anchorY="middle"
fontWeight="bold"
>
{placement.material_name}
</Text>
</group>
)}
{/* 수량 라벨 (윗면) - 큰 글씨 */}
{isConfigured && placement.quantity && (
<Text
position={[0, boxHeight / 2 + 0.03, 0]}
rotation={[-Math.PI / 2, 0, 0]}
fontSize={0.6}
color="#000000"
anchorX="center"
anchorY="middle"
outlineWidth={0.1}
outlineColor="#ffffff"
fontWeight="bold"
>
{placement.quantity} {placement.unit || ""}
</Text>
)}
{/* 디테일 표시 */}
{isConfigured && (
<>
{/* 화살표 표시 (이 쪽이 위) */}
<group position={[0, boxHeight * 0.35, boxDepth / 2 + 0.01]}>
<Text fontSize={0.6} color="#000000" anchorX="center" anchorY="middle">
</Text>
<Text position={[0, -0.4, 0]} fontSize={0.3} color="#666666" anchorX="center" anchorY="middle">
UP
</Text>
</group>
</>
)}
</group>
);
}
// 3D 씬 컴포넌트
// 카메라 포커스 컨트롤러
function CameraFocusController({
focusOnPlacementId,
placements,
orbitControlsRef,
}: {
focusOnPlacementId?: number | null;
placements: YardPlacement[];
orbitControlsRef: React.RefObject<any>;
}) {
const { camera } = useThree();
useEffect(() => {
console.log("🎥 CameraFocusController triggered");
console.log(" - focusOnPlacementId:", focusOnPlacementId);
console.log(" - orbitControlsRef.current:", orbitControlsRef.current);
console.log(" - placements count:", placements.length);
if (focusOnPlacementId && orbitControlsRef.current) {
const targetPlacement = placements.find((p) => p.id === focusOnPlacementId);
console.log(" - targetPlacement:", targetPlacement);
if (targetPlacement) {
console.log("✅ Starting camera animation to:", targetPlacement.material_name || targetPlacement.id);
const controls = orbitControlsRef.current;
const targetPosition = new THREE.Vector3(
targetPlacement.position_x,
targetPlacement.position_y,
targetPlacement.position_z,
);
// 카메라 위치 계산 (요소 위에서 약간 비스듬히)
const cameraOffset = new THREE.Vector3(15, 15, 15);
const newCameraPosition = targetPosition.clone().add(cameraOffset);
// 부드러운 애니메이션으로 카메라 이동
const startPos = camera.position.clone();
const startTarget = controls.target.clone();
const duration = 1000; // 1초
const startTime = Date.now();
const animate = () => {
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / duration, 1);
// easeInOutCubic 이징 함수
const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
// 카메라 위치 보간
camera.position.lerpVectors(startPos, newCameraPosition, eased);
// 카메라 타겟 보간
controls.target.lerpVectors(startTarget, targetPosition, eased);
controls.update();
if (progress < 1) {
requestAnimationFrame(animate);
}
};
animate();
}
}
}, [focusOnPlacementId, placements, camera, orbitControlsRef]);
return null;
}
function Scene({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
focusOnPlacementId,
}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 카메라 포커스 컨트롤러 */}
<CameraFocusController
focusOnPlacementId={focusOnPlacementId}
placements={placements}
orbitControlsRef={orbitControlsRef}
/>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 (타일을 4등분) */}
<Grid
args={[100, 100]}
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
cellThickness={0.6}
cellColor="#1f2937" // 얇은 (서브 그리드) - 매우 어두운 회색
sectionSize={gridSize} // 타일 경계선 (5칸마다)
sectionThickness={1.5}
sectionColor="#374151" // 타일 경계는 조금 밝게
fadeDistance={200}
fadeStrength={1}
followCamera={false}
infiniteGrid={true}
/>
{/* 자재 박스들 */}
{placements.map((placement) => (
<MaterialBox
key={placement.id}
placement={placement}
isSelected={selectedPlacementId === placement.id}
onClick={() => onPlacementClick(placement)}
onDrag={onPlacementDrag ? (position) => onPlacementDrag(placement.id, position) : undefined}
onDragStart={() => {
setIsDraggingAny(true);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = false;
}
}}
onDragEnd={() => {
setIsDraggingAny(false);
if (orbitControlsRef.current) {
orbitControlsRef.current.enabled = true;
}
}}
gridSize={gridSize}
allPlacements={placements}
onCollisionDetected={onCollisionDetected}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls
ref={orbitControlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={8}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
screenSpacePanning={true} // 화면 공간 패닝
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
rotateSpeed={0.5} // 회전 속도
/>
</>
);
}
export default function Yard3DCanvas({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
focusOnPlacementId,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
// e.target이 canvas 엘리먼트인 경우
if (e.target.tagName === "CANVAS") {
onPlacementClick(null as any);
}
};
return (
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
<Canvas
camera={{
position: [50, 30, 50],
fov: 50,
}}
shadows
>
<Suspense fallback={null}>
<Scene
placements={placements}
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
gridSize={gridSize}
onCollisionDetected={onCollisionDetected}
focusOnPlacementId={focusOnPlacementId}
/>
</Suspense>
</Canvas>
</div>
);
}