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

300 lines
8.7 KiB
TypeScript

"use client";
import { Canvas, useThree } from "@react-three/fiber";
import { OrbitControls, Grid, Box } from "@react-three/drei";
import { Suspense, useRef, useState, useEffect } from "react";
import * as THREE from "three";
interface YardPlacement {
id: number;
external_material_id: string;
material_code: string;
material_name: string;
quantity: number;
unit: string;
position_x: number;
position_y: number;
position_z: number;
size_x: number;
size_y: number;
size_z: number;
color: string;
}
interface Yard3DCanvasProps {
placements: YardPlacement[];
selectedPlacementId: number | null;
onPlacementClick: (placement: YardPlacement) => void;
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
}
// 자재 박스 컴포넌트 (드래그 가능)
function MaterialBox({
placement,
isSelected,
onClick,
onDrag,
onDragStart,
onDragEnd,
}: {
placement: YardPlacement;
isSelected: boolean;
onClick: () => void;
onDrag?: (position: { x: number; y: number; z: number }) => void;
onDragStart?: () => void;
onDragEnd?: () => 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();
// 드래그 중이 아닐 때 위치 업데이트
useEffect(() => {
if (!isDragging && meshRef.current) {
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
}
}, [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;
}
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
// 상태 업데이트 (저장용)
onDrag({
x: finalX,
y: dragStartPos.current.y,
z: finalZ,
});
}
};
const handleGlobalMouseUp = () => {
if (isDragging) {
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) {
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
dragStartPos.current = {
x: Number(placement.position_x),
y: Number(placement.position_y),
z: Number(placement.position_z),
};
// 마우스 시작 위치 저장
mouseStartPos.current = {
x: e.clientX,
y: e.clientY,
};
setIsDragging(true);
gl.domElement.style.cursor = "grabbing";
if (onDragStart) {
onDragStart();
}
}
};
return (
<Box
ref={meshRef}
position={[placement.position_x, placement.position_y, placement.position_z]}
args={[placement.size_x, placement.size_y, placement.size_z]}
onClick={(e) => {
e.stopPropagation();
e.nativeEvent?.stopPropagation();
e.nativeEvent?.stopImmediatePropagation();
console.log("3D Box clicked:", placement.material_name);
onClick();
}}
onPointerDown={handlePointerDown}
onPointerOver={() => {
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
if (onDrag) {
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
} else {
gl.domElement.style.cursor = "pointer";
}
}}
onPointerOut={() => {
if (!isDragging) {
gl.domElement.style.cursor = "default";
}
}}
>
<meshStandardMaterial
color={placement.color}
opacity={isSelected ? 1 : 0.8}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
/>
</Box>
);
}
// 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
return (
<>
{/* 조명 */}
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */}
<Grid
args={[100, 100]}
cellSize={5}
cellThickness={0.5}
cellColor="#6b7280"
sectionSize={10}
sectionThickness={1}
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;
}
}}
/>
))}
{/* 카메라 컨트롤 */}
<OrbitControls
ref={orbitControlsRef}
enablePan={true}
enableZoom={true}
enableRotate={true}
minDistance={10}
maxDistance={200}
maxPolarAngle={Math.PI / 2}
enabled={!isDraggingAny}
/>
</>
);
}
export default function Yard3DCanvas({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
}: 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}
/>
</Suspense>
</Canvas>
</div>
);
}