"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(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 ( <> {/* 투명한 메쉬 (클릭 영역) */} {/* 두꺼운 외곽선 - 4개의 막대로 구현 */} {/* 상단 */} {/* 하단 */} {/* 좌측 */} {/* 우측 */} {/* 선택 시 빛나는 효과 */} {isSelected && ( <> )} {/* Area 이름 텍스트 */} {placement.name && ( {placement.name} )} ); case "location-bed": case "location-temp": case "location-dest": // 베드 타입 Location: 초록색 상자 return ( <> {/* 대표 자재 스택 (자재가 있을 때만) */} {placement.material_count !== undefined && placement.material_count > 0 && placement.material_preview_height && ( )} {/* Location 이름 */} {placement.name && ( {placement.name} )} {/* 자재 개수 */} {placement.material_count !== undefined && placement.material_count > 0 && ( {`자재: ${placement.material_count}개`} )} ); case "location-stp": // 정차포인트(STP): 주황색 낮은 플랫폼 return ( <> {/* Location 이름 */} {placement.name && ( {placement.name} )} {/* 자재 개수 (STP는 정차포인트라 자재가 없을 수 있음) */} {placement.material_count !== undefined && placement.material_count > 0 && ( {`자재: ${placement.material_count}개`} )} ); // case "gantry-crane": // // 겐트리 크레인: 기둥 2개 + 상단 빔 // return ( // // {/* 왼쪽 기둥 */} // // // // {/* 오른쪽 기둥 */} // // // // {/* 상단 빔 */} // // // // {/* 호이스트 (크레인 훅) */} // // // // // ); case "crane-mobile": // 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크 return ( {/* 하부 - 크롤러 트랙 (좌측) */} {/* 하부 - 크롤러 트랙 (우측) */} {/* 회전 플랫폼 */} {/* 엔진룸 (뒤쪽) */} {/* 캐빈 (운전실) - 앞쪽 */} {/* 붐대 베이스 (회전 지점) */} {/* 메인 붐대 (하단 섹션) */} {/* 메인 붐대 (상단 섹션 - 연장) */} {/* 카운터웨이트 (뒤쪽 균형추) */} {/* 후크 케이블 */} {/* 후크 */} {/* 지브 와이어 (지지 케이블) */} ); case "rack": // 랙: 프레임 구조 return ( {/* 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) => ( ))} {/* 선반 (3단) */} {[-boxHeight * 0.3, 0, boxHeight * 0.3].map((y, idx) => ( ))} ); case "plate-stack": default: // 후판 스택: 팔레트 + 박스 (기존 렌더링) return ( <> {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} {/* 상단 가로 판자들 (5개) */} {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( ))} {/* 중간 세로 받침대 (3개) */} {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( ))} {/* 하단 가로 판자들 (3개) */} {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( ))} {/* 메인 박스 */} {/* 메인 재질 - 골판지 느낌 */} {/* 외곽선 - 더 진하게 */} ); } }; return ( { 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()} ); } // 3D 씬 컴포넌트 // 카메라 포커스 컨트롤러 function CameraFocusController({ focusOnPlacementId, placements, orbitControlsRef, }: { focusOnPlacementId?: number | null; placements: YardPlacement[]; orbitControlsRef: React.RefObject; }) { 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(null); return ( <> {/* 카메라 포커스 컨트롤러 */} {/* 조명 */} {/* 배경색 */} {/* 바닥 그리드 (타일을 4등분) */} {/* 자재 박스들 */} {placements.map((placement) => ( 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} /> ))} {/* 카메라 컨트롤 */} ); } 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 (
); }