diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 4dd93136..b5af2630 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import { Canvas, useThree } from "@react-three/fiber"; -import { OrbitControls, Grid, Box } from "@react-three/drei"; +import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; import { Suspense, useRef, useState, useEffect } from "react"; import * as THREE from "three"; @@ -68,16 +68,19 @@ function MaterialBox({ }) { const meshRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [isValidPosition, setIsValidPosition] = useState(true); // 배치 가능 여부 (시각 피드백용) 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 mySizeY = placement.size_y || gridSize; // 박스 높이 (5) + const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이 let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) @@ -89,24 +92,33 @@ function MaterialBox({ const pSize = p.size_x || gridSize; // 상대방 크기 (5) const pHalfSize = pSize / 2; // 2.5 - const pSizeY = p.size_y || gridSize; // 상대방 높이 (5) + const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5) + const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이 - // XZ 평면에서 겹치는지 확인 - const isXZOverlapping = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침 + // 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 (isXZOverlapping) { - // 같은 XZ 위치에 요소가 있음 - // 그 요소의 윗면 높이 계산 (중심 + 높이/2) - const topOfOtherElement = p.position_y + pSizeY / 2; - // 내가 올라갈 Y 위치는 윗면 + 내 높이/2 - const myYOnTop = topOfOtherElement + mySizeY / 2; + if (isNearby) { + // 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정) + const isActuallyOverlapping = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침 - // 가장 높은 위치 기록 - if (myYOnTop > maxYBelow) { - maxYBelow = myYOnTop; + if (isActuallyOverlapping) { + // 실제로 겹침: 위에 배치 + // 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산 + const topOfOtherElement = p.position_y + pTotalHeight / 2; + // 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산 + const myYOnTop = topOfOtherElement + myTotalHeight / 2; + + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; + } } + // 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지) } } @@ -182,12 +194,9 @@ function MaterialBox({ const snappedX = snapToGrid(finalX, gridSize); const snappedZ = snapToGrid(finalZ, gridSize); - // 충돌 체크 및 Y 위치 조정 (시각 피드백용) + // 충돌 체크 및 Y 위치 조정 const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); - // 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문) - setIsValidPosition(true); - // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) meshRef.current.position.set(finalX, adjustedY, finalZ); @@ -285,11 +294,19 @@ function MaterialBox({ // 요소가 설정되었는지 확인 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(); @@ -298,7 +315,6 @@ function MaterialBox({ }} onPointerDown={handlePointerDown} onPointerOver={() => { - // 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서 if (onDrag) { gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; } else { @@ -311,15 +327,142 @@ function MaterialBox({ } }} > - - + {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} + + {/* 상단 가로 판자들 (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 + + + + )} + ); }