3d요소에 그리드 스냅 시스템 적용

This commit is contained in:
dohyeons 2025-10-27 11:16:54 +09:00
parent 1116bb2b73
commit f0bb349c8c
2 changed files with 228 additions and 34 deletions

View File

@ -29,6 +29,19 @@ interface Yard3DCanvasProps {
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;
}
// 자재 박스 컴포넌트 (드래그 가능)
@ -39,6 +52,9 @@ function MaterialBox({
onDrag,
onDragStart,
onDragEnd,
gridSize = 5,
allPlacements = [],
onCollisionDetected,
}: {
placement: YardPlacement;
isSelected: boolean;
@ -46,17 +62,70 @@ function MaterialBox({
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 [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();
// 드래그 중이 아닐 때 위치 업데이트
// 특정 좌표에 다른 요소가 있는지 확인 (AABB 충돌 감지)
const checkCollision = (x: number, z: number): boolean => {
const mySize = placement.size_x || gridSize; // 내 크기 (5)
const myHalfSize = mySize / 2; // 2.5
return allPlacements.some((p) => {
// 자기 자신은 제외 (엄격한 비교)
if (Number(p.id) === Number(placement.id)) {
return false;
}
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
const pHalfSize = pSize / 2; // 2.5
// AABB (Axis-Aligned Bounding Box) 충돌 감지
// 두 박스가 겹치는지 확인
const isOverlapping =
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),
},
});
}
return isOverlapping;
});
};
// 드래그 중이 아닐 때만 위치 동기화
useEffect(() => {
if (!isDragging && meshRef.current) {
meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z);
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]);
@ -90,28 +159,76 @@ function MaterialBox({
.multiplyScalar(deltaY * scaleFactor);
// 최종 위치 계산
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
let finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
let finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
// NaN 검증
if (isNaN(finalX) || isNaN(finalZ)) {
return;
}
// 즉시 mesh 위치 업데이트 (부드러운 드래그)
// 그리드에 스냅
const snappedX = snapToGrid(finalX, gridSize);
const snappedZ = snapToGrid(finalZ, gridSize);
// 충돌 체크 (시각 피드백용 - 실제 차단은 마우스 업 시)
const hasCollision = checkCollision(snappedX, snappedZ);
setIsValidPosition(!hasCollision);
// 즉시 mesh 위치 업데이트 (부드러운 드래그 - 스냅되기 전 위치)
meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ);
// 상태 업데이트 (저장용)
onDrag({
x: finalX,
y: dragStartPos.current.y,
z: finalZ,
});
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
// 실제 저장은 handleGlobalMouseUp에서만 수행
}
};
const handleGlobalMouseUp = () => {
if (isDragging) {
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);
// 충돌 체크: 최종 위치에서만 체크 (AABB 방식)
const hasCollision = checkCollision(snappedX, 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,
});
}
}
} 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) {
@ -141,11 +258,12 @@ function MaterialBox({
// 편집 모드에서 선택되었고 드래그 가능한 경우
if (isSelected && meshRef.current) {
// 드래그 시작 시점의 자재 위치 저장 (숫자로 변환)
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
const currentPos = meshRef.current.position;
dragStartPos.current = {
x: Number(placement.position_x),
y: Number(placement.position_y),
z: Number(placement.position_z),
x: currentPos.x,
y: currentPos.y,
z: currentPos.z,
};
// 마우스 시작 위치 저장
@ -192,11 +310,11 @@ function MaterialBox({
}}
>
<meshStandardMaterial
color={placement.color}
color={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : placement.color}
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
transparent
emissive={isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isSelected ? 0.2 : 0}
emissive={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : isSelected ? "#ffffff" : "#000000"}
emissiveIntensity={isDragging ? 0.5 : isSelected ? 0.2 : 0}
wireframe={!isConfigured}
/>
</Box>
@ -204,7 +322,14 @@ function MaterialBox({
}
// 3D 씬 컴포넌트
function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) {
function Scene({
placements,
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
}: Yard3DCanvasProps) {
const [isDraggingAny, setIsDraggingAny] = useState(false);
const orbitControlsRef = useRef<any>(null);
@ -215,15 +340,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
<directionalLight position={[10, 10, 5]} intensity={1} />
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
{/* 바닥 그리드 */}
{/* 바닥 그리드 (타일을 4등분) */}
<Grid
args={[100, 100]}
cellSize={5}
cellThickness={0.5}
cellColor="#6b7280"
sectionSize={10}
sectionThickness={1}
sectionColor="#374151"
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}
@ -250,6 +375,9 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD
orbitControlsRef.current.enabled = true;
}
}}
gridSize={gridSize}
allPlacements={placements}
onCollisionDetected={onCollisionDetected}
/>
))}
@ -273,6 +401,8 @@ export default function Yard3DCanvas({
selectedPlacementId,
onPlacementClick,
onPlacementDrag,
gridSize = 5,
onCollisionDetected,
}: Yard3DCanvasProps) {
const handleCanvasClick = (e: any) => {
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
@ -297,6 +427,8 @@ export default function Yard3DCanvas({
selectedPlacementId={selectedPlacementId}
onPlacementClick={onPlacementClick}
onPlacementDrag={onPlacementDrag}
gridSize={gridSize}
onCollisionDetected={onCollisionDetected}
/>
</Suspense>
</Canvas>

View File

@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
import dynamic from "next/dynamic";
import { YardLayout, YardPlacement } from "./types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { AlertCircle, CheckCircle } from "lucide-react";
import { AlertCircle, CheckCircle, XCircle } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
ssr: false,
@ -33,6 +34,7 @@ interface YardEditorProps {
}
export default function YardEditor({ layout, onBack }: YardEditorProps) {
const { toast } = useToast();
const [placements, setPlacements] = useState<YardPlacement[]>([]);
const [originalPlacements, setOriginalPlacements] = useState<YardPlacement[]>([]); // 원본 데이터 보관
const [selectedPlacement, setSelectedPlacement] = useState<YardPlacement | null>(null);
@ -78,8 +80,60 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
loadPlacements();
}, [layout.id]);
// 빈 공간 찾기 (그리드 기반)
const findEmptyGridPosition = (gridSize = 5) => {
// 이미 사용 중인 좌표 Set
const occupiedPositions = new Set(
placements.map((p) => {
const x = Math.round(p.position_x / gridSize) * gridSize;
const z = Math.round(p.position_z / gridSize) * gridSize;
return `${x},${z}`;
}),
);
// 나선형으로 빈 공간 찾기
let x = 0;
let z = 0;
let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상
let steps = 1;
let stepsTaken = 0;
let stepsInDirection = 0;
for (let i = 0; i < 1000; i++) {
const key = `${x},${z}`;
if (!occupiedPositions.has(key)) {
return { x, z };
}
// 다음 위치로 이동
stepsInDirection++;
if (direction === 0)
x += gridSize; // 우
else if (direction === 1)
z += gridSize; // 하
else if (direction === 2)
x -= gridSize; // 좌
else z -= gridSize; // 상
if (stepsInDirection >= steps) {
stepsInDirection = 0;
direction = (direction + 1) % 4;
stepsTaken++;
if (stepsTaken === 2) {
stepsTaken = 0;
steps++;
}
}
}
return { x: 0, z: 0 };
};
// 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영)
const handleAddElement = () => {
const gridSize = 5;
const emptyPos = findEmptyGridPosition(gridSize);
const newPlacement: YardPlacement = {
id: nextPlacementId, // 임시 음수 ID
yard_layout_id: layout.id,
@ -87,12 +141,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
material_name: null,
quantity: null,
unit: null,
position_x: 0,
position_y: 2.5,
position_z: 0,
size_x: 5,
size_y: 5,
size_z: 5,
// 그리드 칸의 중심에 배치 (Three.js Box position은 중심점)
position_x: emptyPos.x + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
position_y: gridSize / 2, // 요소 높이의 절반 (바닥에서 시작)
position_z: emptyPos.z + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5...
size_x: gridSize,
size_y: gridSize,
size_z: gridSize,
color: "#9ca3af",
data_source_type: null,
data_source_config: null,
@ -358,6 +413,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
selectedPlacementId={selectedPlacement?.id || null}
onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)}
onPlacementDrag={handlePlacementDrag}
onCollisionDetected={() => {
toast({
title: "배치 불가",
description: "해당 위치에 이미 다른 요소가 있습니다.",
variant: "destructive",
});
}}
/>
)}
</div>