"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; // 충돌 감지 시 콜백 } // 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) // 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(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 ( { 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"; } }} > {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} {/* 상단 가로 판자들 (5개) */} {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( ))} {/* 중간 세로 받침대 (3개) */} {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( ))} {/* 하단 가로 판자들 (3개) */} {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( ))} {/* 메인 박스 */} {/* 메인 재질 - 골판지 느낌 */} {/* 외곽선 - 더 진하게 */} {/* 포장 테이프 (가로) - 윗면 */} {isConfigured && ( <> {/* 테이프 세로 */} )} {/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */} {isConfigured && placement.material_name && ( {/* 라벨 배경 (흰색 스티커) */} {/* 라벨 텍스트 */} {placement.material_name} )} {/* 수량 라벨 (윗면) - 큰 글씨 */} {isConfigured && placement.quantity && ( {placement.quantity} {placement.unit || ""} )} {/* 디테일 표시 */} {isConfigured && ( <> {/* 화살표 표시 (이 쪽이 위) */} UP )} ); } // 3D 씬 컴포넌트 function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag, gridSize = 5, onCollisionDetected, }: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); return ( <> {/* 조명 */} {/* 바닥 그리드 (타일을 4등분) */} {/* 자재 박스들 */} {placements.map((placement) => ( 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} /> ))} {/* 카메라 컨트롤 */} ); } export default function Yard3DCanvas({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag, gridSize = 5, onCollisionDetected, }: Yard3DCanvasProps) { const handleCanvasClick = (e: any) => { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 // e.target이 canvas 엘리먼트인 경우 if (e.target.tagName === "CANVAS") { onPlacementClick(null as any); } }; return (
); }