1191 lines
42 KiB
TypeScript
1191 lines
42 KiB
TypeScript
"use client";
|
|
|
|
import { Canvas, useThree } from "@react-three/fiber";
|
|
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
|
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
|
import * as THREE from "three";
|
|
|
|
interface YardPlacement {
|
|
id: number;
|
|
yard_layout_id?: number;
|
|
material_code?: string | null;
|
|
material_name?: string | null;
|
|
name?: string | null; // 객체 이름 (야드 이름 등)
|
|
quantity?: number | null;
|
|
unit?: string | null;
|
|
position_x: number;
|
|
position_y: number;
|
|
position_z: number;
|
|
size_x: number;
|
|
size_y: number;
|
|
size_z: number;
|
|
color: string;
|
|
data_source_type?: string | null;
|
|
data_source_config?: any;
|
|
data_binding?: any;
|
|
material_count?: number; // Location의 자재 개수
|
|
material_preview_height?: number; // 자재 스택 높이 (시각적)
|
|
}
|
|
|
|
interface Yard3DCanvasProps {
|
|
placements: YardPlacement[];
|
|
selectedPlacementId: number | null;
|
|
onPlacementClick: (placement: YardPlacement | null) => void;
|
|
onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void;
|
|
gridSize?: number; // 그리드 크기 (기본값: 5)
|
|
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
|
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
|
}
|
|
|
|
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
|
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
|
|
function snapToGrid(value: number, gridSize: number): number {
|
|
// 가장 가까운 그리드 교차점으로 스냅 (오프셋 없음)
|
|
// DigitalTwinEditor에서 오프셋 처리하므로 여기서는 순수 스냅만
|
|
return Math.round(value / gridSize) * gridSize;
|
|
}
|
|
|
|
// 자재 박스 컴포넌트 (드래그 가능)
|
|
function MaterialBox({
|
|
placement,
|
|
isSelected,
|
|
onClick,
|
|
onDrag,
|
|
onDragStart,
|
|
onDragEnd,
|
|
gridSize = 5,
|
|
allPlacements = [],
|
|
}: {
|
|
placement: YardPlacement;
|
|
isSelected: boolean;
|
|
onClick: () => void;
|
|
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 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 dragOffset = useRef<{ x: number; z: number }>({ x: 0, z: 0 }); // 마우스와 객체 중심 간 오프셋
|
|
const { camera, gl } = useThree();
|
|
const [glowIntensity, setGlowIntensity] = useState(1);
|
|
|
|
// 선택 시 빛나는 애니메이션
|
|
useEffect(() => {
|
|
if (!isSelected) {
|
|
setGlowIntensity(1);
|
|
return;
|
|
}
|
|
|
|
let animationId: number;
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const intensity = 1 + Math.sin(elapsed * 0.003) * 0.5; // 0.5 ~ 1.5 사이 진동
|
|
setGlowIntensity(intensity);
|
|
animationId = requestAnimationFrame(animate);
|
|
};
|
|
|
|
animate();
|
|
|
|
return () => {
|
|
if (animationId) {
|
|
cancelAnimationFrame(animationId);
|
|
}
|
|
};
|
|
}, [isSelected]);
|
|
|
|
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
|
|
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
|
|
if (!allPlacements || allPlacements.length === 0) {
|
|
// 다른 객체가 없으면 기본 높이
|
|
const objectType = placement.data_source_type as string | null;
|
|
const defaultY = objectType === "area" ? 0.05 : (placement.size_y || gridSize) / 2;
|
|
return {
|
|
hasCollision: false,
|
|
adjustedY: defaultY,
|
|
};
|
|
}
|
|
|
|
// 내 크기 정보
|
|
const mySizeX = placement.size_x || gridSize;
|
|
const mySizeZ = placement.size_z || gridSize;
|
|
const mySizeY = placement.size_y || gridSize;
|
|
|
|
// 내 바운딩 박스 (좌측 하단 모서리 기준)
|
|
const myMinX = x - mySizeX / 2;
|
|
const myMaxX = x + mySizeX / 2;
|
|
const myMinZ = z - mySizeZ / 2;
|
|
const myMaxZ = z + mySizeZ / 2;
|
|
|
|
const objectType = placement.data_source_type as string | null;
|
|
const defaultY = objectType === "area" ? 0.05 : mySizeY / 2;
|
|
let maxYBelow = defaultY;
|
|
|
|
// Area는 스택되지 않음 (항상 바닥에 배치)
|
|
if (objectType === "area") {
|
|
return {
|
|
hasCollision: false,
|
|
adjustedY: defaultY,
|
|
};
|
|
}
|
|
|
|
for (const p of allPlacements) {
|
|
// 자기 자신은 제외
|
|
if (Number(p.id) === Number(placement.id)) {
|
|
continue;
|
|
}
|
|
|
|
// 상대방 크기 정보
|
|
const pSizeX = p.size_x || gridSize;
|
|
const pSizeZ = p.size_z || gridSize;
|
|
const pSizeY = p.size_y || gridSize;
|
|
|
|
// 상대방 바운딩 박스
|
|
const pMinX = p.position_x - pSizeX / 2;
|
|
const pMaxX = p.position_x + pSizeX / 2;
|
|
const pMinZ = p.position_z - pSizeZ / 2;
|
|
const pMaxZ = p.position_z + pSizeZ / 2;
|
|
|
|
// AABB 충돌 감지 (2D 평면에서)
|
|
const isOverlapping = myMinX < pMaxX && myMaxX > pMinX && myMinZ < pMaxZ && myMaxZ > pMinZ;
|
|
|
|
if (isOverlapping) {
|
|
// 겹침: 상대방 위에 배치
|
|
const topOfOtherElement = p.position_y + pSizeY / 2;
|
|
const myYOnTop = topOfOtherElement + mySizeY / 2;
|
|
|
|
if (myYOnTop > maxYBelow) {
|
|
maxYBelow = myYOnTop;
|
|
}
|
|
}
|
|
}
|
|
|
|
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
|
|
|
|
return {
|
|
hasCollision: needsAdjustment,
|
|
adjustedY: maxYBelow,
|
|
};
|
|
};
|
|
|
|
// 드래그 중이 아닐 때만 위치 동기화
|
|
useEffect(() => {
|
|
if (!isDragging && meshRef.current) {
|
|
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]);
|
|
|
|
// 전역 이벤트 리스너 등록
|
|
useEffect(() => {
|
|
const handleGlobalMouseMove = (e: MouseEvent) => {
|
|
if (isDragging && onDrag && meshRef.current) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
// 마우스 좌표를 정규화 (-1 ~ 1)
|
|
const rect = gl.domElement.getBoundingClientRect();
|
|
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
// Raycaster로 바닥 평면과의 교차점 계산
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera);
|
|
|
|
// 바닥 평면 (y = 0)
|
|
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
const intersectPoint = new THREE.Vector3();
|
|
const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint);
|
|
|
|
if (!hasIntersection) {
|
|
return;
|
|
}
|
|
|
|
// 마우스 위치에 드래그 시작 시 저장한 오프셋 적용
|
|
const finalX = intersectPoint.x + dragOffset.current.x;
|
|
const finalZ = intersectPoint.z + dragOffset.current.z;
|
|
|
|
// NaN 검증
|
|
if (isNaN(finalX) || isNaN(finalZ)) {
|
|
return;
|
|
}
|
|
|
|
// 객체의 좌측 하단 모서리 좌표 계산 (크기 / 2를 빼서)
|
|
const sizeX = placement.size_x || 5;
|
|
const sizeZ = placement.size_z || 5;
|
|
|
|
const cornerX = finalX - sizeX / 2;
|
|
const cornerZ = finalZ - sizeZ / 2;
|
|
|
|
// 좌측 하단 모서리를 그리드에 스냅
|
|
const snappedCornerX = snapToGrid(cornerX, gridSize);
|
|
const snappedCornerZ = snapToGrid(cornerZ, gridSize);
|
|
|
|
// 스냅된 모서리로부터 중심 위치 계산
|
|
const finalSnappedX = snappedCornerX + sizeX / 2;
|
|
const finalSnappedZ = snappedCornerZ + sizeZ / 2;
|
|
|
|
console.log("🐛 드래그 중:", {
|
|
마우스_화면: { x: e.clientX, y: e.clientY },
|
|
정규화_마우스: { x: mouseX, y: mouseY },
|
|
교차점: { x: finalX, z: finalZ },
|
|
스냅후: { x: finalSnappedX, z: finalSnappedZ },
|
|
});
|
|
|
|
// 충돌 체크 및 Y 위치 조정
|
|
const { adjustedY } = checkCollisionAndAdjustY(finalSnappedX, dragStartPos.current.y, finalSnappedZ);
|
|
|
|
// 즉시 mesh 위치 업데이트 (스냅된 위치로)
|
|
meshRef.current.position.set(finalSnappedX, adjustedY, finalSnappedZ);
|
|
|
|
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
|
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
|
}
|
|
};
|
|
|
|
const handleGlobalMouseUp = () => {
|
|
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) {
|
|
// 실제로 드래그한 경우: 이미 handleGlobalMouseMove에서 스냅됨
|
|
// currentPos는 이미 스냅+오프셋이 적용된 값이므로 그대로 사용
|
|
const finalX = currentPos.x;
|
|
const finalY = currentPos.y;
|
|
const finalZ = currentPos.z;
|
|
|
|
// ✅ 항상 배치 가능 (위로 올라가므로)
|
|
console.log("✅ 배치 완료! 저장:", { x: finalX, y: finalY, z: finalZ });
|
|
|
|
// 최종 위치 저장
|
|
if (onDrag) {
|
|
onDrag({
|
|
x: finalX,
|
|
y: finalY,
|
|
z: finalZ,
|
|
});
|
|
}
|
|
} 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) {
|
|
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();
|
|
|
|
// 뷰어 모드(onDrag 없음)에서는 클릭만 처리
|
|
if (!onDrag) {
|
|
return;
|
|
}
|
|
|
|
// 편집 모드에서 선택되었고 드래그 가능한 경우
|
|
if (isSelected && meshRef.current) {
|
|
// 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치)
|
|
const currentPos = meshRef.current.position;
|
|
dragStartPos.current = {
|
|
x: currentPos.x,
|
|
y: currentPos.y,
|
|
z: currentPos.z,
|
|
};
|
|
|
|
// 마우스 시작 위치 저장
|
|
mouseStartPos.current = {
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
};
|
|
|
|
// 마우스 클릭 위치를 3D 좌표로 변환
|
|
const rect = gl.domElement.getBoundingClientRect();
|
|
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
|
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
|
|
|
const raycaster = new THREE.Raycaster();
|
|
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera);
|
|
|
|
// 바닥 평면과의 교차점 계산
|
|
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
|
const intersectPoint = new THREE.Vector3();
|
|
const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint);
|
|
|
|
if (hasIntersection) {
|
|
// 마우스 클릭 위치와 객체 중심 간의 오프셋 저장
|
|
dragOffset.current = {
|
|
x: currentPos.x - intersectPoint.x,
|
|
z: currentPos.z - intersectPoint.z,
|
|
};
|
|
} else {
|
|
dragOffset.current = { x: 0, z: 0 };
|
|
}
|
|
|
|
setIsDragging(true);
|
|
gl.domElement.style.cursor = "grabbing";
|
|
if (onDragStart) {
|
|
onDragStart();
|
|
}
|
|
}
|
|
};
|
|
|
|
// 요소가 설정되었는지 확인
|
|
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;
|
|
|
|
// 객체 타입 (data_source_type에 저장됨)
|
|
const objectType = placement.data_source_type as string | null;
|
|
|
|
// 타입별 렌더링
|
|
const renderObjectByType = () => {
|
|
switch (objectType) {
|
|
case "area":
|
|
// Area: 투명한 바닥 + 두꺼운 외곽선 (mesh) + 이름 텍스트
|
|
const borderThickness = 0.3; // 외곽선 두께
|
|
return (
|
|
<>
|
|
{/* 투명한 메쉬 (클릭 영역) */}
|
|
<mesh>
|
|
<boxGeometry args={[boxWidth, 0.1, boxDepth]} />
|
|
<meshBasicMaterial transparent opacity={0} />
|
|
</mesh>
|
|
|
|
{/* 두꺼운 외곽선 - 4개의 막대로 구현 */}
|
|
{/* 상단 */}
|
|
<mesh position={[0, 0.05, -boxDepth / 2]}>
|
|
<boxGeometry args={[boxWidth + borderThickness, 0.2, borderThickness]} />
|
|
<meshBasicMaterial color={isSelected ? "#ffffff" : placement.color} transparent opacity={0.9} />
|
|
</mesh>
|
|
{/* 하단 */}
|
|
<mesh position={[0, 0.05, boxDepth / 2]}>
|
|
<boxGeometry args={[boxWidth + borderThickness, 0.2, borderThickness]} />
|
|
<meshBasicMaterial color={isSelected ? "#ffffff" : placement.color} transparent opacity={0.9} />
|
|
</mesh>
|
|
{/* 좌측 */}
|
|
<mesh position={[-boxWidth / 2, 0.05, 0]}>
|
|
<boxGeometry args={[borderThickness, 0.2, boxDepth - borderThickness]} />
|
|
<meshBasicMaterial color={isSelected ? "#ffffff" : placement.color} transparent opacity={0.9} />
|
|
</mesh>
|
|
{/* 우측 */}
|
|
<mesh position={[boxWidth / 2, 0.05, 0]}>
|
|
<boxGeometry args={[borderThickness, 0.2, boxDepth - borderThickness]} />
|
|
<meshBasicMaterial color={isSelected ? "#ffffff" : placement.color} transparent opacity={0.9} />
|
|
</mesh>
|
|
|
|
{/* 선택 시 빛나는 효과 */}
|
|
{isSelected && (
|
|
<>
|
|
<mesh position={[0, 0.08, -boxDepth / 2]}>
|
|
<boxGeometry args={[boxWidth + borderThickness * 2, 0.25, borderThickness * 1.5]} />
|
|
<meshBasicMaterial color={placement.color} transparent opacity={0.5} />
|
|
</mesh>
|
|
<mesh position={[0, 0.08, boxDepth / 2]}>
|
|
<boxGeometry args={[boxWidth + borderThickness * 2, 0.25, borderThickness * 1.5]} />
|
|
<meshBasicMaterial color={placement.color} transparent opacity={0.5} />
|
|
</mesh>
|
|
<mesh position={[-boxWidth / 2, 0.08, 0]}>
|
|
<boxGeometry args={[borderThickness * 1.5, 0.25, boxDepth]} />
|
|
<meshBasicMaterial color={placement.color} transparent opacity={0.5} />
|
|
</mesh>
|
|
<mesh position={[boxWidth / 2, 0.08, 0]}>
|
|
<boxGeometry args={[borderThickness * 1.5, 0.25, boxDepth]} />
|
|
<meshBasicMaterial color={placement.color} transparent opacity={0.5} />
|
|
</mesh>
|
|
</>
|
|
)}
|
|
|
|
{/* Area 이름 텍스트 - 위쪽 (바닥) */}
|
|
{placement.name && (
|
|
<>
|
|
<Text
|
|
position={[0, 0.15, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxDepth) * 0.2}
|
|
color={placement.color}
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.05}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
|
|
{/* 4면에 텍스트 표시 */}
|
|
{/* 앞면 (+Z) */}
|
|
<Text
|
|
position={[0, boxHeight / 2, boxDepth / 2 + 0.01]}
|
|
rotation={[0, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.08}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
|
|
{/* 뒷면 (-Z) */}
|
|
<Text
|
|
position={[0, boxHeight / 2, -boxDepth / 2 - 0.01]}
|
|
rotation={[0, Math.PI, 0]}
|
|
fontSize={Math.min(boxWidth, boxHeight) * 0.3}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.08}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
|
|
{/* 왼쪽면 (-X) */}
|
|
<Text
|
|
position={[-boxWidth / 2 - 0.01, boxHeight / 2, 0]}
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.08}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
|
|
{/* 오른쪽면 (+X) */}
|
|
<Text
|
|
position={[boxWidth / 2 + 0.01, boxHeight / 2, 0]}
|
|
rotation={[0, Math.PI / 2, 0]}
|
|
fontSize={Math.min(boxDepth, boxHeight) * 0.3}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.08}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
case "location-bed":
|
|
case "location-temp":
|
|
case "location-dest":
|
|
// 베드 타입 Location: 초록색 상자
|
|
return (
|
|
<>
|
|
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
roughness={0.5}
|
|
metalness={0.3}
|
|
emissive={isSelected ? placement.color : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
|
{placement.material_count !== undefined &&
|
|
placement.material_count > 0 &&
|
|
placement.material_preview_height && (
|
|
<Box
|
|
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
|
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#ef4444"
|
|
roughness={0.6}
|
|
metalness={0.2}
|
|
emissive={isSelected ? "#ef4444" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
|
transparent
|
|
opacity={0.7}
|
|
/>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Location 이름 */}
|
|
{placement.name && (
|
|
<Text
|
|
position={[0, boxHeight / 2 + 0.3, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.03}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
)}
|
|
|
|
{/* 자재 개수 */}
|
|
{placement.material_count !== undefined && placement.material_count > 0 && (
|
|
<Text
|
|
position={[0, boxHeight / 2 + 0.6, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
|
color="#fbbf24"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.03}
|
|
outlineColor="#000000"
|
|
>
|
|
{`자재: ${placement.material_count}개`}
|
|
</Text>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
case "location-stp":
|
|
// 정차포인트(STP): 주황색 낮은 플랫폼
|
|
return (
|
|
<>
|
|
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
roughness={0.6}
|
|
metalness={0.2}
|
|
emissive={isSelected ? placement.color : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Location 이름 */}
|
|
{placement.name && (
|
|
<Text
|
|
position={[0, boxHeight / 2 + 0.3, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
|
color="#ffffff"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.03}
|
|
outlineColor="#000000"
|
|
>
|
|
{placement.name}
|
|
</Text>
|
|
)}
|
|
|
|
{/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */}
|
|
{placement.material_count !== undefined && placement.material_count > 0 && (
|
|
<Text
|
|
position={[0, boxHeight / 2 + 0.6, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
|
color="#fbbf24"
|
|
anchorX="center"
|
|
anchorY="middle"
|
|
outlineWidth={0.03}
|
|
outlineColor="#000000"
|
|
>
|
|
{`자재: ${placement.material_count}개`}
|
|
</Text>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
// case "gantry-crane":
|
|
// // 겐트리 크레인: 기둥 2개 + 상단 빔
|
|
// return (
|
|
// <group>
|
|
// {/* 왼쪽 기둥 */}
|
|
// <Box args={[boxWidth * 0.1, boxHeight, boxDepth * 0.1]} position={[-boxWidth * 0.4, 0, 0]}>
|
|
// <meshStandardMaterial
|
|
// color={placement.color}
|
|
// roughness={0.3}
|
|
// metalness={0.7}
|
|
// emissive={isSelected ? placement.color : "#000000"}
|
|
// emissiveIntensity={isSelected ? glowIntensity : 0}
|
|
// />
|
|
// </Box>
|
|
// {/* 오른쪽 기둥 */}
|
|
// <Box args={[boxWidth * 0.1, boxHeight, boxDepth * 0.1]} position={[boxWidth * 0.4, 0, 0]}>
|
|
// <meshStandardMaterial
|
|
// color={placement.color}
|
|
// roughness={0.3}
|
|
// metalness={0.7}
|
|
// emissive={isSelected ? placement.color : "#000000"}
|
|
// emissiveIntensity={isSelected ? glowIntensity : 0}
|
|
// />
|
|
// </Box>
|
|
// {/* 상단 빔 */}
|
|
// <Box args={[boxWidth, boxHeight * 0.15, boxDepth * 0.15]} position={[0, boxHeight * 0.42, 0]}>
|
|
// <meshStandardMaterial
|
|
// color={placement.color}
|
|
// roughness={0.3}
|
|
// metalness={0.7}
|
|
// emissive={isSelected ? placement.color : "#000000"}
|
|
// emissiveIntensity={isSelected ? glowIntensity : 0}
|
|
// />
|
|
// </Box>
|
|
// {/* 호이스트 (크레인 훅) */}
|
|
// <Box args={[boxWidth * 0.08, boxHeight * 0.3, boxDepth * 0.08]} position={[0, boxHeight * 0.1, 0]}>
|
|
// <meshStandardMaterial
|
|
// color="#fbbf24"
|
|
// roughness={0.4}
|
|
// metalness={0.6}
|
|
// emissive={isSelected ? "#fbbf24" : "#000000"}
|
|
// emissiveIntensity={isSelected ? glowIntensity * 0.6 : 0}
|
|
// />
|
|
// </Box>
|
|
// </group>
|
|
// );
|
|
|
|
case "crane-mobile":
|
|
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
|
return (
|
|
<group>
|
|
{/* 하부 - 크롤러 트랙 (좌측) */}
|
|
<Box
|
|
args={[boxWidth * 0.3, boxHeight * 0.15, boxDepth * 0.95]}
|
|
position={[-boxWidth * 0.3, -boxHeight * 0.42, 0]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#1f2937"
|
|
roughness={0.8}
|
|
metalness={0.3}
|
|
emissive={isSelected ? "#1f2937" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
|
/>
|
|
</Box>
|
|
{/* 하부 - 크롤러 트랙 (우측) */}
|
|
<Box
|
|
args={[boxWidth * 0.3, boxHeight * 0.15, boxDepth * 0.95]}
|
|
position={[boxWidth * 0.3, -boxHeight * 0.42, 0]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#1f2937"
|
|
roughness={0.8}
|
|
metalness={0.3}
|
|
emissive={isSelected ? "#1f2937" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 회전 플랫폼 */}
|
|
<Box args={[boxWidth * 0.85, boxHeight * 0.12, boxDepth * 0.85]} position={[0, -boxHeight * 0.3, 0]}>
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
roughness={0.3}
|
|
metalness={0.7}
|
|
emissive={isSelected ? placement.color : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 엔진룸 (뒤쪽) */}
|
|
<Box
|
|
args={[boxWidth * 0.6, boxHeight * 0.25, boxDepth * 0.3]}
|
|
position={[0, -boxHeight * 0.15, boxDepth * 0.25]}
|
|
>
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
roughness={0.4}
|
|
metalness={0.6}
|
|
emissive={isSelected ? placement.color : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 캐빈 (운전실) - 앞쪽 */}
|
|
<Box
|
|
args={[boxWidth * 0.35, boxHeight * 0.3, boxDepth * 0.35]}
|
|
position={[0, -boxHeight * 0.1, -boxDepth * 0.2]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#374151"
|
|
roughness={0.2}
|
|
metalness={0.8}
|
|
emissive={isSelected ? "#60a5fa" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.5 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 붐대 베이스 (회전 지점) */}
|
|
<Box args={[boxWidth * 0.2, boxHeight * 0.2, boxDepth * 0.2]} position={[0, -boxHeight * 0.05, 0]}>
|
|
<meshStandardMaterial
|
|
color="#4b5563"
|
|
roughness={0.3}
|
|
metalness={0.8}
|
|
emissive={isSelected ? "#4b5563" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.4 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 메인 붐대 (하단 섹션) */}
|
|
<Box
|
|
args={[boxWidth * 0.15, boxHeight * 0.5, boxDepth * 0.15]}
|
|
position={[0, boxHeight * 0.1, -boxDepth * 0.15]}
|
|
rotation={[Math.PI / 4.5, 0, 0]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#fbbf24"
|
|
roughness={0.3}
|
|
metalness={0.7}
|
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.7 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 메인 붐대 (상단 섹션 - 연장) */}
|
|
<Box
|
|
args={[boxWidth * 0.12, boxHeight * 0.4, boxDepth * 0.12]}
|
|
position={[0, boxHeight * 0.3, -boxDepth * 0.35]}
|
|
rotation={[Math.PI / 5, 0, 0]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#fbbf24"
|
|
roughness={0.3}
|
|
metalness={0.7}
|
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.7 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 카운터웨이트 (뒤쪽 균형추) */}
|
|
<Box
|
|
args={[boxWidth * 0.5, boxHeight * 0.2, boxDepth * 0.25]}
|
|
position={[0, -boxHeight * 0.05, boxDepth * 0.3]}
|
|
>
|
|
<meshStandardMaterial
|
|
color="#6b7280"
|
|
roughness={0.6}
|
|
metalness={0.4}
|
|
emissive={isSelected ? "#6b7280" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 후크 케이블 */}
|
|
<Box
|
|
args={[boxWidth * 0.02, boxHeight * 0.3, boxDepth * 0.02]}
|
|
position={[0, boxHeight * 0.15, -boxDepth * 0.4]}
|
|
>
|
|
<meshStandardMaterial color="#1f2937" roughness={0.4} metalness={0.6} />
|
|
</Box>
|
|
|
|
{/* 후크 */}
|
|
<Box args={[boxWidth * 0.08, boxHeight * 0.08, boxDepth * 0.08]} position={[0, 0, -boxDepth * 0.4]}>
|
|
<meshStandardMaterial
|
|
color="#ef4444"
|
|
roughness={0.3}
|
|
metalness={0.8}
|
|
emissive={isSelected ? "#ef4444" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
|
/>
|
|
</Box>
|
|
|
|
{/* 지브 와이어 (지지 케이블) */}
|
|
<Box
|
|
args={[boxWidth * 0.015, boxHeight * 0.35, boxDepth * 0.015]}
|
|
position={[0, boxHeight * 0.25, -boxDepth * 0.05]}
|
|
rotation={[Math.PI / 6, 0, 0]}
|
|
>
|
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.7} />
|
|
</Box>
|
|
</group>
|
|
);
|
|
|
|
case "rack":
|
|
// 랙: 프레임 구조
|
|
return (
|
|
<group>
|
|
{/* 4개 기둥 */}
|
|
{[
|
|
[-boxWidth * 0.4, -boxDepth * 0.4],
|
|
[boxWidth * 0.4, -boxDepth * 0.4],
|
|
[-boxWidth * 0.4, boxDepth * 0.4],
|
|
[boxWidth * 0.4, boxDepth * 0.4],
|
|
].map(([x, z], idx) => (
|
|
<Box key={`pillar-${idx}`} args={[boxWidth * 0.08, boxHeight, boxDepth * 0.08]} position={[x, 0, z]}>
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
roughness={0.3}
|
|
metalness={0.7}
|
|
emissive={isSelected ? placement.color : "#000000"}
|
|
emissiveIntensity={isSelected ? 0.5 : 0}
|
|
/>
|
|
</Box>
|
|
))}
|
|
{/* 선반 (3단) */}
|
|
{[-boxHeight * 0.3, 0, boxHeight * 0.3].map((y, idx) => (
|
|
<Box key={`shelf-${idx}`} args={[boxWidth * 0.9, boxHeight * 0.05, boxDepth * 0.9]} position={[0, y, 0]}>
|
|
<meshStandardMaterial
|
|
color="#6b7280"
|
|
roughness={0.5}
|
|
metalness={0.5}
|
|
emissive={isSelected ? "#6b7280" : "#000000"}
|
|
emissiveIntensity={isSelected ? glowIntensity * 0.6 : 0}
|
|
/>
|
|
</Box>
|
|
))}
|
|
</group>
|
|
);
|
|
|
|
case "plate-stack":
|
|
default:
|
|
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
|
|
return (
|
|
<>
|
|
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
|
<group position={[0, palletYOffset, 0]}>
|
|
{/* 상단 가로 판자들 (5개) */}
|
|
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
|
<Box
|
|
key={`top-${idx}`}
|
|
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
|
position={[0, palletHeight * 0.35, zOffset]}
|
|
>
|
|
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
|
<lineSegments>
|
|
<edgesGeometry
|
|
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
|
|
/>
|
|
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
|
</lineSegments>
|
|
</Box>
|
|
))}
|
|
|
|
{/* 중간 세로 받침대 (3개) */}
|
|
{[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => (
|
|
<Box
|
|
key={`middle-${idx}`}
|
|
args={[boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2]}
|
|
position={[xOffset, 0, 0]}
|
|
>
|
|
<meshStandardMaterial color="#654321" roughness={0.98} metalness={0.0} />
|
|
<lineSegments>
|
|
<edgesGeometry
|
|
args={[new THREE.BoxGeometry(boxWidth * 0.12, palletHeight * 0.4, boxDepth * 0.2)]}
|
|
/>
|
|
<lineBasicMaterial color="#000000" opacity={0.4} transparent />
|
|
</lineSegments>
|
|
</Box>
|
|
))}
|
|
|
|
{/* 하단 가로 판자들 (3개) */}
|
|
{[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => (
|
|
<Box
|
|
key={`bottom-${idx}`}
|
|
args={[boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18]}
|
|
position={[0, -palletHeight * 0.35, zOffset]}
|
|
>
|
|
<meshStandardMaterial color="#6B4423" roughness={0.97} metalness={0.0} />
|
|
<lineSegments>
|
|
<edgesGeometry
|
|
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.25, boxDepth * 0.18)]}
|
|
/>
|
|
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
|
</lineSegments>
|
|
</Box>
|
|
))}
|
|
</group>
|
|
|
|
{/* 메인 박스 */}
|
|
<Box args={[boxWidth, boxHeight, boxDepth]} position={[0, 0, 0]}>
|
|
{/* 메인 재질 - 골판지 느낌 */}
|
|
<meshStandardMaterial
|
|
color={placement.color}
|
|
opacity={isConfigured ? (isSelected ? 1 : 0.8) : 0.5}
|
|
transparent
|
|
emissive={isSelected ? "#ffffff" : "#000000"}
|
|
emissiveIntensity={isSelected ? 0.2 : 0}
|
|
wireframe={!isConfigured}
|
|
roughness={0.95}
|
|
metalness={0.05}
|
|
/>
|
|
|
|
{/* 외곽선 - 더 진하게 */}
|
|
<lineSegments>
|
|
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, boxHeight, boxDepth)]} />
|
|
<lineBasicMaterial color="#000000" opacity={0.6} transparent linewidth={1.5} />
|
|
</lineSegments>
|
|
</Box>
|
|
</>
|
|
);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<group
|
|
ref={meshRef}
|
|
position={[placement.position_x, placement.position_y, placement.position_z]}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
e.nativeEvent?.stopPropagation();
|
|
e.nativeEvent?.stopImmediatePropagation();
|
|
onClick();
|
|
}}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerOver={() => {
|
|
if (onDrag) {
|
|
gl.domElement.style.cursor = isSelected ? "grab" : "pointer";
|
|
} else {
|
|
gl.domElement.style.cursor = "pointer";
|
|
}
|
|
}}
|
|
onPointerOut={() => {
|
|
if (!isDragging) {
|
|
gl.domElement.style.cursor = "default";
|
|
}
|
|
}}
|
|
>
|
|
{renderObjectByType()}
|
|
</group>
|
|
);
|
|
}
|
|
|
|
// 3D 씬 컴포넌트
|
|
// 카메라 포커스 컨트롤러
|
|
function CameraFocusController({
|
|
focusOnPlacementId,
|
|
placements,
|
|
orbitControlsRef,
|
|
}: {
|
|
focusOnPlacementId?: number | null;
|
|
placements: YardPlacement[];
|
|
orbitControlsRef: React.RefObject<any>;
|
|
}) {
|
|
const { camera } = useThree();
|
|
|
|
useEffect(() => {
|
|
console.log("🎥 CameraFocusController triggered");
|
|
console.log(" - focusOnPlacementId:", focusOnPlacementId);
|
|
console.log(" - orbitControlsRef.current:", orbitControlsRef.current);
|
|
console.log(" - placements count:", placements.length);
|
|
|
|
if (focusOnPlacementId && orbitControlsRef.current) {
|
|
const targetPlacement = placements.find((p) => p.id === focusOnPlacementId);
|
|
console.log(" - targetPlacement:", targetPlacement);
|
|
|
|
if (targetPlacement) {
|
|
console.log("✅ Starting camera animation to:", targetPlacement.material_name || targetPlacement.id);
|
|
|
|
const controls = orbitControlsRef.current;
|
|
const targetPosition = new THREE.Vector3(
|
|
targetPlacement.position_x,
|
|
targetPlacement.position_y,
|
|
targetPlacement.position_z,
|
|
);
|
|
|
|
// 카메라 위치 계산 (요소 위에서 약간 비스듬히)
|
|
const cameraOffset = new THREE.Vector3(15, 15, 15);
|
|
const newCameraPosition = targetPosition.clone().add(cameraOffset);
|
|
|
|
// 부드러운 애니메이션으로 카메라 이동
|
|
const startPos = camera.position.clone();
|
|
const startTarget = controls.target.clone();
|
|
const duration = 1000; // 1초
|
|
const startTime = Date.now();
|
|
|
|
const animate = () => {
|
|
const elapsed = Date.now() - startTime;
|
|
const progress = Math.min(elapsed / duration, 1);
|
|
|
|
// easeInOutCubic 이징 함수
|
|
const eased = progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2;
|
|
|
|
// 카메라 위치 보간
|
|
camera.position.lerpVectors(startPos, newCameraPosition, eased);
|
|
|
|
// 카메라 타겟 보간
|
|
controls.target.lerpVectors(startTarget, targetPosition, eased);
|
|
controls.update();
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(animate);
|
|
}
|
|
};
|
|
|
|
animate();
|
|
}
|
|
}
|
|
}, [focusOnPlacementId, placements, camera, orbitControlsRef]);
|
|
|
|
return null;
|
|
}
|
|
|
|
function Scene({
|
|
placements,
|
|
selectedPlacementId,
|
|
onPlacementClick,
|
|
onPlacementDrag,
|
|
gridSize = 5,
|
|
onCollisionDetected,
|
|
focusOnPlacementId,
|
|
}: Yard3DCanvasProps) {
|
|
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
|
const orbitControlsRef = useRef<any>(null);
|
|
|
|
return (
|
|
<>
|
|
{/* 카메라 포커스 컨트롤러 */}
|
|
<CameraFocusController
|
|
focusOnPlacementId={focusOnPlacementId}
|
|
placements={placements}
|
|
orbitControlsRef={orbitControlsRef}
|
|
/>
|
|
|
|
{/* 조명 */}
|
|
<ambientLight intensity={0.5} />
|
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
|
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
|
|
|
{/* 배경색 */}
|
|
<color attach="background" args={["#f3f4f6"]} />
|
|
|
|
{/* 바닥 그리드 (타일을 4등분) */}
|
|
<Grid
|
|
args={[100, 100]}
|
|
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
|
cellThickness={0.6}
|
|
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
|
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
|
sectionThickness={1.5}
|
|
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
|
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;
|
|
}
|
|
}}
|
|
gridSize={gridSize}
|
|
allPlacements={placements}
|
|
onCollisionDetected={onCollisionDetected}
|
|
/>
|
|
))}
|
|
|
|
{/* 카메라 컨트롤 */}
|
|
<OrbitControls
|
|
ref={orbitControlsRef}
|
|
enablePan={true}
|
|
enableZoom={true}
|
|
enableRotate={true}
|
|
minDistance={8}
|
|
maxDistance={200}
|
|
maxPolarAngle={Math.PI / 2}
|
|
enabled={!isDraggingAny}
|
|
screenSpacePanning={true} // 화면 공간 패닝
|
|
panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림)
|
|
rotateSpeed={0.5} // 회전 속도
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Yard3DCanvas({
|
|
placements,
|
|
selectedPlacementId,
|
|
onPlacementClick,
|
|
onPlacementDrag,
|
|
gridSize = 5,
|
|
onCollisionDetected,
|
|
focusOnPlacementId,
|
|
}: 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-100" onClick={handleCanvasClick}>
|
|
<Canvas
|
|
camera={{
|
|
position: [50, 30, 50],
|
|
fov: 50,
|
|
}}
|
|
shadows
|
|
gl={{ preserveDrawingBuffer: true }}
|
|
>
|
|
<Suspense fallback={null}>
|
|
<Scene
|
|
placements={placements}
|
|
selectedPlacementId={selectedPlacementId}
|
|
onPlacementClick={onPlacementClick}
|
|
onPlacementDrag={onPlacementDrag}
|
|
gridSize={gridSize}
|
|
onCollisionDetected={onCollisionDetected}
|
|
focusOnPlacementId={focusOnPlacementId}
|
|
/>
|
|
</Suspense>
|
|
</Canvas>
|
|
</div>
|
|
);
|
|
}
|