3d요소에 그리드 스냅 시스템 적용
This commit is contained in:
parent
1116bb2b73
commit
f0bb349c8c
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue