diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 1e57ce85..bcbded34 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -73,39 +73,50 @@ function MaterialBox({ const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const { camera, gl } = useThree(); - // 특정 좌표에 다른 요소가 있는지 확인 (AABB 충돌 감지) - const checkCollision = (x: number, z: number): boolean => { + // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정 + const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { const mySize = placement.size_x || gridSize; // 내 크기 (5) const myHalfSize = mySize / 2; // 2.5 + const mySizeY = placement.size_y || gridSize; // 내 높이 (5) - return allPlacements.some((p) => { - // 자기 자신은 제외 (엄격한 비교) + let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) + + for (const p of allPlacements) { + // 자기 자신은 제외 if (Number(p.id) === Number(placement.id)) { - return false; + continue; } const pSize = p.size_x || gridSize; // 상대방 크기 (5) const pHalfSize = pSize / 2; // 2.5 + const pSizeY = p.size_y || gridSize; // 상대방 높이 (5) - // AABB (Axis-Aligned Bounding Box) 충돌 감지 - // 두 박스가 겹치는지 확인 - const isOverlapping = + // XZ 평면에서 겹치는지 확인 + const isXZOverlapping = Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침 Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침 - if (isOverlapping) { - console.log("🔴 충돌 감지! (AABB)", { - current: `${placement.id} at (${x}, ${z}) size=${mySize}`, - blocking: `${p.id} at (${p.position_x}, ${p.position_z}) size=${pSize}`, - distance: { - x: Math.abs(x - p.position_x), - z: Math.abs(z - p.position_z), - }, - }); - } + if (isXZOverlapping) { + // 같은 XZ 위치에 요소가 있음 + // 그 요소의 윗면 높이 계산 (중심 + 높이/2) + const topOfOtherElement = p.position_y + pSizeY / 2; + // 내가 올라갈 Y 위치는 윗면 + 내 높이/2 + const myYOnTop = topOfOtherElement + mySizeY / 2; - return isOverlapping; - }); + // 가장 높은 위치 기록 + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; + } + } + } + + // 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함) + const needsAdjustment = Math.abs(y - maxYBelow) > 0.1; + + return { + hasCollision: needsAdjustment, + adjustedY: maxYBelow, + }; }; // 드래그 중이 아닐 때만 위치 동기화 @@ -159,8 +170,8 @@ function MaterialBox({ .multiplyScalar(deltaY * scaleFactor); // 최종 위치 계산 - let finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - let finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; + const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; // NaN 검증 if (isNaN(finalX) || isNaN(finalZ)) { @@ -171,12 +182,14 @@ function MaterialBox({ const snappedX = snapToGrid(finalX, gridSize); const snappedZ = snapToGrid(finalZ, gridSize); - // 충돌 체크 (시각 피드백용 - 실제 차단은 마우스 업 시) - const hasCollision = checkCollision(snappedX, snappedZ); - setIsValidPosition(!hasCollision); + // 충돌 체크 및 Y 위치 조정 (시각 피드백용) + const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); - // 즉시 mesh 위치 업데이트 (부드러운 드래그 - 스냅되기 전 위치) - meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ); + // 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문) + setIsValidPosition(true); + + // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) + meshRef.current.position.set(finalX, adjustedY, finalZ); // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만) // 실제 저장은 handleGlobalMouseUp에서만 수행 @@ -198,31 +211,20 @@ function MaterialBox({ const snappedX = snapToGrid(currentPos.x, gridSize); const snappedZ = snapToGrid(currentPos.z, gridSize); - // 충돌 체크: 최종 위치에서만 체크 (AABB 방식) - const hasCollision = checkCollision(snappedX, snappedZ); + // Y 위치 조정 (마인크래프트처럼 쌓기) + const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ); - if (hasCollision) { - // ⛔ 충돌 시: 원래 위치로 되돌리고 저장 안 함 - console.log("⛔ 충돌! 원래 위치로 복원:", dragStartPos.current); - meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z); - setIsValidPosition(true); - // 충돌 감지 콜백 호출 (Toast 알림) - if (onCollisionDetected) { - onCollisionDetected(); - } - // ⚠️ 중요: onDrag 호출하지 않음 (상태 업데이트 안 함) - } else { - // ✅ 충돌 없음: 스냅된 위치로 최종 설정하고 저장 - console.log("✅ 충돌 없음! 저장:", { x: snappedX, y: dragStartPos.current.y, z: snappedZ }); - meshRef.current.position.set(snappedX, dragStartPos.current.y, snappedZ); - // 최종 위치 저장 (이것만 실제 상태 업데이트) - if (onDrag) { - onDrag({ - x: snappedX, - y: dragStartPos.current.y, - z: 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 { // 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함) @@ -387,10 +389,14 @@ function Scene({ enablePan={true} enableZoom={true} enableRotate={true} - minDistance={10} + minDistance={8} maxDistance={200} maxPolarAngle={Math.PI / 2} enabled={!isDraggingAny} + reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동) + screenSpacePanning={true} // 화면 공간 패닝 + panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림) + rotateSpeed={0.5} // 회전 속도 /> ); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 973a151e..21435a16 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -180,12 +180,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setDeleteConfirmDialog({ open: true, placementId }); }; + // 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림 + const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => { + const gridSize = 5; + const halfSize = gridSize / 2; + + return remainingPlacements.map((p) => { + // 삭제된 요소와 XZ가 겹치는지 확인 + const isXZOverlapping = + Math.abs(p.position_x - deletedPlacement.position_x) < gridSize && + Math.abs(p.position_z - deletedPlacement.position_z) < gridSize; + + // 삭제된 요소보다 위에 있는지 확인 + const isAbove = p.position_y > deletedPlacement.position_y; + + if (isXZOverlapping && isAbove) { + // 아래로 내림: 삭제된 요소의 크기만큼 + const fallDistance = deletedPlacement.size_y || gridSize; + const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감 + + return { + ...p, + position_y: newY, + }; + } + + return p; + }); + }; + // 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영) const confirmDeletePlacement = () => { const { placementId } = deleteConfirmDialog; if (placementId === null) return; - setPlacements((prev) => prev.filter((p) => p.id !== placementId)); + setPlacements((prev) => { + const deletedPlacement = prev.find((p) => p.id === placementId); + if (!deletedPlacement) return prev; + + // 삭제 후 남은 요소들 + const remaining = prev.filter((p) => p.id !== placementId); + + // 중력 적용 (재귀적으로 계속 적용) + let result = remaining; + let hasChanges = true; + + // 모든 요소가 안정될 때까지 반복 + while (hasChanges) { + const before = JSON.stringify(result.map((p) => p.position_y)); + result = applyGravity(deletedPlacement, result); + const after = JSON.stringify(result.map((p) => p.position_y)); + hasChanges = before !== after; + } + + return result; + }); + if (selectedPlacement?.id === placementId) { setSelectedPlacement(null); setShowConfigPanel(false);