2025-10-17 15:26:21 +09:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { Canvas, useThree } from "@react-three/fiber";
|
|
|
|
|
import { OrbitControls, Grid, Box } from "@react-three/drei";
|
|
|
|
|
import { Suspense, useRef, useState, useEffect } from "react";
|
|
|
|
|
import * as THREE from "three";
|
|
|
|
|
|
|
|
|
|
interface YardPlacement {
|
|
|
|
|
id: number;
|
2025-10-21 16:45:04 +09:00
|
|
|
yard_layout_id?: number;
|
2025-10-20 09:58:51 +09:00
|
|
|
material_code?: string | null;
|
|
|
|
|
material_name?: string | null;
|
|
|
|
|
quantity?: number | null;
|
|
|
|
|
unit?: string | null;
|
2025-10-17 15:26:21 +09:00
|
|
|
position_x: number;
|
|
|
|
|
position_y: number;
|
|
|
|
|
position_z: number;
|
|
|
|
|
size_x: number;
|
|
|
|
|
size_y: number;
|
|
|
|
|
size_z: number;
|
|
|
|
|
color: string;
|
2025-10-20 09:58:51 +09:00
|
|
|
data_source_type?: string | null;
|
|
|
|
|
data_source_config?: any;
|
|
|
|
|
data_binding?: any;
|
2025-10-17 15:26:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Yard3DCanvasProps {
|
|
|
|
|
placements: YardPlacement[];
|
|
|
|
|
selectedPlacementId: number | null;
|
2025-10-21 16:45:04 +09:00
|
|
|
onPlacementClick: (placement: YardPlacement | null) => void;
|
2025-10-17 15:26:21 +09:00
|
|
|
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
2025-10-27 11:16:54 +09:00
|
|
|
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;
|
2025-10-17 15:26:21 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 자재 박스 컴포넌트 (드래그 가능)
|
|
|
|
|
function MaterialBox({
|
|
|
|
|
placement,
|
|
|
|
|
isSelected,
|
|
|
|
|
onClick,
|
|
|
|
|
onDrag,
|
|
|
|
|
onDragStart,
|
|
|
|
|
onDragEnd,
|
2025-10-27 11:16:54 +09:00
|
|
|
gridSize = 5,
|
|
|
|
|
allPlacements = [],
|
|
|
|
|
onCollisionDetected,
|
2025-10-17 15:26:21 +09:00
|
|
|
}: {
|
|
|
|
|
placement: YardPlacement;
|
|
|
|
|
isSelected: boolean;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
onDrag?: (position: { x: number; y: number; z: number }) => void;
|
|
|
|
|
onDragStart?: () => void;
|
|
|
|
|
onDragEnd?: () => void;
|
2025-10-27 11:16:54 +09:00
|
|
|
gridSize?: number;
|
|
|
|
|
allPlacements?: YardPlacement[];
|
|
|
|
|
onCollisionDetected?: () => void;
|
2025-10-17 15:26:21 +09:00
|
|
|
}) {
|
|
|
|
|
const meshRef = useRef<THREE.Mesh>(null);
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
2025-10-27 11:16:54 +09:00
|
|
|
const [isValidPosition, setIsValidPosition] = useState(true); // 배치 가능 여부 (시각 피드백용)
|
2025-10-17 15:26:21 +09:00
|
|
|
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();
|
|
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
|
|
|
|
|
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
|
2025-10-27 11:16:54 +09:00
|
|
|
const mySize = placement.size_x || gridSize; // 내 크기 (5)
|
|
|
|
|
const myHalfSize = mySize / 2; // 2.5
|
2025-10-27 11:40:11 +09:00
|
|
|
const mySizeY = placement.size_y || gridSize; // 내 높이 (5)
|
2025-10-27 11:16:54 +09:00
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
|
|
|
|
|
|
|
|
|
|
for (const p of allPlacements) {
|
|
|
|
|
// 자기 자신은 제외
|
2025-10-27 11:16:54 +09:00
|
|
|
if (Number(p.id) === Number(placement.id)) {
|
2025-10-27 11:40:11 +09:00
|
|
|
continue;
|
2025-10-27 11:16:54 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
|
|
|
|
|
const pHalfSize = pSize / 2; // 2.5
|
2025-10-27 11:40:11 +09:00
|
|
|
const pSizeY = p.size_y || gridSize; // 상대방 높이 (5)
|
2025-10-27 11:16:54 +09:00
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
// XZ 평면에서 겹치는지 확인
|
|
|
|
|
const isXZOverlapping =
|
2025-10-27 11:16:54 +09:00
|
|
|
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침
|
|
|
|
|
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침
|
|
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
if (isXZOverlapping) {
|
|
|
|
|
// 같은 XZ 위치에 요소가 있음
|
|
|
|
|
// 그 요소의 윗면 높이 계산 (중심 + 높이/2)
|
|
|
|
|
const topOfOtherElement = p.position_y + pSizeY / 2;
|
|
|
|
|
// 내가 올라갈 Y 위치는 윗면 + 내 높이/2
|
|
|
|
|
const myYOnTop = topOfOtherElement + mySizeY / 2;
|
|
|
|
|
|
|
|
|
|
// 가장 높은 위치 기록
|
|
|
|
|
if (myYOnTop > maxYBelow) {
|
|
|
|
|
maxYBelow = myYOnTop;
|
|
|
|
|
}
|
2025-10-27 11:16:54 +09:00
|
|
|
}
|
2025-10-27 11:40:11 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
|
|
|
|
|
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
|
2025-10-27 11:16:54 +09:00
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
return {
|
|
|
|
|
hasCollision: needsAdjustment,
|
|
|
|
|
adjustedY: maxYBelow,
|
|
|
|
|
};
|
2025-10-27 11:16:54 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 드래그 중이 아닐 때만 위치 동기화
|
2025-10-17 15:26:21 +09:00
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isDragging && meshRef.current) {
|
2025-10-27 11:16:54 +09:00
|
|
|
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);
|
|
|
|
|
}
|
2025-10-17 15:26:21 +09:00
|
|
|
}
|
|
|
|
|
}, [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);
|
|
|
|
|
|
|
|
|
|
// 최종 위치 계산
|
2025-10-27 11:40:11 +09:00
|
|
|
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
|
|
|
|
|
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
|
2025-10-17 15:26:21 +09:00
|
|
|
|
|
|
|
|
// NaN 검증
|
|
|
|
|
if (isNaN(finalX) || isNaN(finalZ)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-27 11:16:54 +09:00
|
|
|
// 그리드에 스냅
|
|
|
|
|
const snappedX = snapToGrid(finalX, gridSize);
|
|
|
|
|
const snappedZ = snapToGrid(finalZ, gridSize);
|
|
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
// 충돌 체크 및 Y 위치 조정 (시각 피드백용)
|
|
|
|
|
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
|
2025-10-27 11:16:54 +09:00
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
// 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문)
|
|
|
|
|
setIsValidPosition(true);
|
|
|
|
|
|
|
|
|
|
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
|
|
|
|
|
meshRef.current.position.set(finalX, adjustedY, finalZ);
|
2025-10-17 15:26:21 +09:00
|
|
|
|
2025-10-27 11:16:54 +09:00
|
|
|
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
|
|
|
|
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
2025-10-17 15:26:21 +09:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleGlobalMouseUp = () => {
|
2025-10-27 11:16:54 +09:00
|
|
|
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);
|
|
|
|
|
|
2025-10-27 11:40:11 +09:00
|
|
|
// 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,
|
|
|
|
|
});
|
2025-10-27 11:16:54 +09:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함)
|
|
|
|
|
meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z);
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-17 15:26:21 +09:00
|
|
|
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();
|
2025-10-17 16:23:33 +09:00
|
|
|
|
|
|
|
|
// 뷰어 모드(onDrag 없음)에서는 클릭만 처리
|
|
|
|
|
if (!onDrag) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
|
|
|
|
if (isSelected && meshRef.current) {
|
2025-10-27 11:16:54 +09:00
|
|
|
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
|
|
|
|
|
const currentPos = meshRef.current.position;
|
2025-10-17 15:26:21 +09:00
|
|
|
dragStartPos.current = {
|
2025-10-27 11:16:54 +09:00
|
|
|
x: currentPos.x,
|
|
|
|
|
y: currentPos.y,
|
|
|
|
|
z: currentPos.z,
|
2025-10-17 15:26:21 +09:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 마우스 시작 위치 저장
|
|
|
|
|
mouseStartPos.current = {
|
|
|
|
|
x: e.clientX,
|
|
|
|
|
y: e.clientY,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setIsDragging(true);
|
|
|
|
|
gl.domElement.style.cursor = "grabbing";
|
|
|
|
|
if (onDragStart) {
|
|
|
|
|
onDragStart();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-20 09:58:51 +09:00
|
|
|
// 요소가 설정되었는지 확인
|
|
|
|
|
const isConfigured = !!(placement.material_name && placement.quantity && placement.unit);
|
|
|
|
|
|
2025-10-17 15:26:21 +09:00
|
|
|
return (
|
|
|
|
|
<Box
|
|
|
|
|
ref={meshRef}
|
|
|
|
|
position={[placement.position_x, placement.position_y, placement.position_z]}
|
|
|
|
|
args={[placement.size_x, placement.size_y, placement.size_z]}
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
e.nativeEvent?.stopPropagation();
|
|
|
|
|
e.nativeEvent?.stopImmediatePropagation();
|
|
|
|
|
onClick();
|
|
|
|
|
}}
|
|
|
|
|
onPointerDown={handlePointerDown}
|
|
|
|
|
onPointerOver={() => {
|
2025-10-17 16:23:33 +09:00
|
|
|
// 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서
|
|
|
|
|
if (onDrag) {
|
|
|
|
|
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
|
|
|
|
} else {
|
|
|
|
|
gl.domElement.style.cursor = "pointer";
|
|
|
|
|
}
|
2025-10-17 15:26:21 +09:00
|
|
|
}}
|
|
|
|
|
onPointerOut={() => {
|
|
|
|
|
if (!isDragging) {
|
|
|
|
|
gl.domElement.style.cursor = "default";
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<meshStandardMaterial
|
2025-10-27 11:16:54 +09:00
|
|
|
color={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : placement.color}
|
2025-10-20 09:58:51 +09:00
|
|
|
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
2025-10-17 15:26:21 +09:00
|
|
|
transparent
|
2025-10-27 11:16:54 +09:00
|
|
|
emissive={isDragging ? (isValidPosition ? "#22c55e" : "#ef4444") : isSelected ? "#ffffff" : "#000000"}
|
|
|
|
|
emissiveIntensity={isDragging ? 0.5 : isSelected ? 0.2 : 0}
|
2025-10-20 09:58:51 +09:00
|
|
|
wireframe={!isConfigured}
|
2025-10-17 15:26:21 +09:00
|
|
|
/>
|
|
|
|
|
</Box>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3D 씬 컴포넌트
|
2025-10-27 11:16:54 +09:00
|
|
|
function Scene({
|
|
|
|
|
placements,
|
|
|
|
|
selectedPlacementId,
|
|
|
|
|
onPlacementClick,
|
|
|
|
|
onPlacementDrag,
|
|
|
|
|
gridSize = 5,
|
|
|
|
|
onCollisionDetected,
|
|
|
|
|
}: Yard3DCanvasProps) {
|
2025-10-17 15:26:21 +09:00
|
|
|
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
|
|
|
|
const orbitControlsRef = useRef<any>(null);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* 조명 */}
|
|
|
|
|
<ambientLight intensity={0.5} />
|
|
|
|
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
|
|
|
|
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
|
|
|
|
|
2025-10-27 11:16:54 +09:00
|
|
|
{/* 바닥 그리드 (타일을 4등분) */}
|
2025-10-17 15:26:21 +09:00
|
|
|
<Grid
|
|
|
|
|
args={[100, 100]}
|
2025-10-27 11:16:54 +09:00
|
|
|
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
|
|
|
|
cellThickness={0.6}
|
|
|
|
|
cellColor="#1f2937" // 얇은 선 (서브 그리드) - 매우 어두운 회색
|
|
|
|
|
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
|
|
|
|
sectionThickness={1.5}
|
|
|
|
|
sectionColor="#374151" // 타일 경계는 조금 밝게
|
2025-10-17 15:26:21 +09:00
|
|
|
fadeDistance={200}
|
|
|
|
|
fadeStrength={1}
|
|
|
|
|
followCamera={false}
|
|
|
|
|
infiniteGrid={true}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{/* 자재 박스들 */}
|
|
|
|
|
{placements.map((placement) => (
|
|
|
|
|
<MaterialBox
|
|
|
|
|
key={placement.id}
|
|
|
|
|
placement={placement}
|
|
|
|
|
isSelected={selectedPlacementId === placement.id}
|
|
|
|
|
onClick={() => 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;
|
|
|
|
|
}
|
|
|
|
|
}}
|
2025-10-27 11:16:54 +09:00
|
|
|
gridSize={gridSize}
|
|
|
|
|
allPlacements={placements}
|
|
|
|
|
onCollisionDetected={onCollisionDetected}
|
2025-10-17 15:26:21 +09:00
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* 카메라 컨트롤 */}
|
|
|
|
|
<OrbitControls
|
|
|
|
|
ref={orbitControlsRef}
|
|
|
|
|
enablePan={true}
|
|
|
|
|
enableZoom={true}
|
|
|
|
|
enableRotate={true}
|
2025-10-27 11:40:11 +09:00
|
|
|
minDistance={8}
|
2025-10-17 15:26:21 +09:00
|
|
|
maxDistance={200}
|
|
|
|
|
maxPolarAngle={Math.PI / 2}
|
|
|
|
|
enabled={!isDraggingAny}
|
2025-10-27 11:40:11 +09:00
|
|
|
reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동)
|
|
|
|
|
screenSpacePanning={true} // 화면 공간 패닝
|
|
|
|
|
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
|
|
|
|
|
rotateSpeed={0.5} // 회전 속도
|
2025-10-17 15:26:21 +09:00
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Yard3DCanvas({
|
|
|
|
|
placements,
|
|
|
|
|
selectedPlacementId,
|
|
|
|
|
onPlacementClick,
|
|
|
|
|
onPlacementDrag,
|
2025-10-27 11:16:54 +09:00
|
|
|
gridSize = 5,
|
|
|
|
|
onCollisionDetected,
|
2025-10-17 15:26:21 +09:00
|
|
|
}: Yard3DCanvasProps) {
|
|
|
|
|
const handleCanvasClick = (e: any) => {
|
|
|
|
|
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
|
|
|
|
// e.target이 canvas 엘리먼트인 경우
|
|
|
|
|
if (e.target.tagName === "CANVAS") {
|
|
|
|
|
onPlacementClick(null as any);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full bg-gray-900" onClick={handleCanvasClick}>
|
|
|
|
|
<Canvas
|
|
|
|
|
camera={{
|
|
|
|
|
position: [50, 30, 50],
|
|
|
|
|
fov: 50,
|
|
|
|
|
}}
|
|
|
|
|
shadows
|
|
|
|
|
>
|
|
|
|
|
<Suspense fallback={null}>
|
|
|
|
|
<Scene
|
|
|
|
|
placements={placements}
|
|
|
|
|
selectedPlacementId={selectedPlacementId}
|
|
|
|
|
onPlacementClick={onPlacementClick}
|
|
|
|
|
onPlacementDrag={onPlacementDrag}
|
2025-10-27 11:16:54 +09:00
|
|
|
gridSize={gridSize}
|
|
|
|
|
onCollisionDetected={onCollisionDetected}
|
2025-10-17 15:26:21 +09:00
|
|
|
/>
|
|
|
|
|
</Suspense>
|
|
|
|
|
</Canvas>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|