3d - 배치 구현
This commit is contained in:
parent
cec631d0f7
commit
eeed671436
|
|
@ -277,7 +277,7 @@ export function CanvasElement({
|
||||||
const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향
|
const autoScrollDirectionRef = useRef<"up" | "down" | null>(null); // 🔥 자동 스크롤 방향
|
||||||
const autoScrollFrameRef = useRef<number | null>(null); // 🔥 requestAnimationFrame ID
|
const autoScrollFrameRef = useRef<number | null>(null); // 🔥 requestAnimationFrame ID
|
||||||
const lastMouseYRef = useRef<number>(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간)
|
const lastMouseYRef = useRef<number>(window.innerHeight / 2); // 🔥 마지막 마우스 Y 위치 (초기값: 화면 중간)
|
||||||
const [resizeStart, setResizeStart] = useState({
|
const [resizeStart, setResizeStart] = useState({
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 0,
|
y: 0,
|
||||||
width: 0,
|
width: 0,
|
||||||
|
|
@ -302,26 +302,26 @@ export function CanvasElement({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||||
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
|
// 위젯 내부 (헤더 제외) 클릭 시 드래그 무시 - 인터랙티브 사용 가능
|
||||||
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
|
if ((e.target as HTMLElement).closest(".widget-interactive-area")) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택되지 않은 경우에만 선택 처리
|
// 선택되지 않은 경우에만 선택 처리
|
||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
onSelect(element.id);
|
onSelect(element.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
const startPos = {
|
const startPos = {
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
elementX: element.position.x,
|
elementX: element.position.x,
|
||||||
elementY: element.position.y,
|
elementY: element.position.y,
|
||||||
initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치
|
initialScrollY: window.pageYOffset, // 🔥 드래그 시작 시점의 스크롤 위치
|
||||||
};
|
};
|
||||||
|
|
@ -348,7 +348,7 @@ export function CanvasElement({
|
||||||
onMultiDragStart(element.id, offsets);
|
onMultiDragStart(element.id, offsets);
|
||||||
}
|
}
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
element.id,
|
element.id,
|
||||||
|
|
@ -370,17 +370,17 @@ export function CanvasElement({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsResizing(true);
|
setIsResizing(true);
|
||||||
setResizeStart({
|
setResizeStart({
|
||||||
x: e.clientX,
|
x: e.clientX,
|
||||||
y: e.clientY,
|
y: e.clientY,
|
||||||
width: element.size.width,
|
width: element.size.width,
|
||||||
height: element.size.height,
|
height: element.size.height,
|
||||||
elementX: element.position.x,
|
elementX: element.position.x,
|
||||||
elementY: element.position.y,
|
elementY: element.position.y,
|
||||||
handle,
|
handle,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[element.size.width, element.size.height, element.position.x, element.position.y],
|
[element.size.width, element.size.height, element.position.x, element.position.y],
|
||||||
);
|
);
|
||||||
|
|
@ -388,7 +388,7 @@ export function CanvasElement({
|
||||||
// 마우스 이동 처리 (그리드 스냅 적용)
|
// 마우스 이동 처리 (그리드 스냅 적용)
|
||||||
const handleMouseMove = useCallback(
|
const handleMouseMove = useCallback(
|
||||||
(e: MouseEvent) => {
|
(e: MouseEvent) => {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
// 🔥 자동 스크롤: 다중 선택 시 첫 번째 위젯에서만 처리
|
||||||
const isFirstSelectedElement =
|
const isFirstSelectedElement =
|
||||||
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
!selectedElements || selectedElements.length === 0 || selectedElements[0] === element.id;
|
||||||
|
|
@ -425,14 +425,14 @@ export function CanvasElement({
|
||||||
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
|
||||||
onMultiDragMove(element, { x: rawX, y: rawY });
|
onMultiDragMove(element, { x: rawX, y: rawY });
|
||||||
}
|
}
|
||||||
} else if (isResizing) {
|
} else if (isResizing) {
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
const deltaX = e.clientX - resizeStart.x;
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
const deltaY = e.clientY - resizeStart.y;
|
||||||
|
|
||||||
let newWidth = resizeStart.width;
|
let newWidth = resizeStart.width;
|
||||||
let newHeight = resizeStart.height;
|
let newHeight = resizeStart.height;
|
||||||
let newX = resizeStart.elementX;
|
let newX = resizeStart.elementX;
|
||||||
let newY = resizeStart.elementY;
|
let newY = resizeStart.elementY;
|
||||||
|
|
||||||
// 최소 크기 설정: 모든 위젯 1x1
|
// 최소 크기 설정: 모든 위젯 1x1
|
||||||
const minWidthCells = 1;
|
const minWidthCells = 1;
|
||||||
|
|
@ -440,28 +440,28 @@ export function CanvasElement({
|
||||||
const minWidth = cellSize * minWidthCells;
|
const minWidth = cellSize * minWidthCells;
|
||||||
const minHeight = cellSize * minHeightCells;
|
const minHeight = cellSize * minHeightCells;
|
||||||
|
|
||||||
switch (resizeStart.handle) {
|
switch (resizeStart.handle) {
|
||||||
case "se": // 오른쪽 아래
|
case "se": // 오른쪽 아래
|
||||||
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
||||||
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
||||||
break;
|
break;
|
||||||
case "sw": // 왼쪽 아래
|
case "sw": // 왼쪽 아래
|
||||||
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
||||||
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
newHeight = Math.max(minHeight, resizeStart.height + deltaY);
|
||||||
newX = resizeStart.elementX + deltaX;
|
newX = resizeStart.elementX + deltaX;
|
||||||
break;
|
break;
|
||||||
case "ne": // 오른쪽 위
|
case "ne": // 오른쪽 위
|
||||||
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
newWidth = Math.max(minWidth, resizeStart.width + deltaX);
|
||||||
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
||||||
newY = resizeStart.elementY + deltaY;
|
newY = resizeStart.elementY + deltaY;
|
||||||
break;
|
break;
|
||||||
case "nw": // 왼쪽 위
|
case "nw": // 왼쪽 위
|
||||||
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
newWidth = Math.max(minWidth, resizeStart.width - deltaX);
|
||||||
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
newHeight = Math.max(minHeight, resizeStart.height - deltaY);
|
||||||
newX = resizeStart.elementX + deltaX;
|
newX = resizeStart.elementX + deltaX;
|
||||||
newY = resizeStart.elementY + deltaY;
|
newY = resizeStart.elementY + deltaY;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 가로 너비가 캔버스를 벗어나지 않도록 제한
|
// 가로 너비가 캔버스를 벗어나지 않도록 제한
|
||||||
const maxWidth = canvasWidth - newX;
|
const maxWidth = canvasWidth - newX;
|
||||||
|
|
@ -664,7 +664,7 @@ export function CanvasElement({
|
||||||
if (isDragging || isResizing) {
|
if (isDragging || isResizing) {
|
||||||
document.addEventListener("mousemove", handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener("mouseup", handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousemove", handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener("mouseup", handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
|
|
@ -685,7 +685,7 @@ export function CanvasElement({
|
||||||
// 필터 적용 (날짜 필터 등)
|
// 필터 적용 (날짜 필터 등)
|
||||||
const { applyQueryFilters } = await import("./utils/queryHelpers");
|
const { applyQueryFilters } = await import("./utils/queryHelpers");
|
||||||
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
const filteredQuery = applyQueryFilters(element.dataSource.query, element.chartConfig);
|
||||||
|
|
||||||
// 외부 DB vs 현재 DB 분기
|
// 외부 DB vs 현재 DB 분기
|
||||||
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) {
|
||||||
// 외부 DB
|
// 외부 DB
|
||||||
|
|
@ -709,13 +709,13 @@ export function CanvasElement({
|
||||||
// 현재 DB
|
// 현재 DB
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
result = await dashboardApi.executeQuery(filteredQuery);
|
result = await dashboardApi.executeQuery(filteredQuery);
|
||||||
|
|
||||||
setChartData({
|
setChartData({
|
||||||
columns: result.columns || [],
|
columns: result.columns || [],
|
||||||
rows: result.rows || [],
|
rows: result.rows || [],
|
||||||
totalRows: result.rowCount || 0,
|
totalRows: result.rowCount || 0,
|
||||||
executionTime: 0,
|
executionTime: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error("Chart data loading error:", error);
|
// console.error("Chart data loading error:", error);
|
||||||
|
|
@ -859,7 +859,7 @@ export function CanvasElement({
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
|
{/* 삭제 버튼 - 항상 표시 (우측 상단 절대 위치) */}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Plus, Check, Trash2 } from "lucide-react";
|
import { Plus, Check, Trash2 } from "lucide-react";
|
||||||
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
import YardLayoutCreateModal from "./yard-3d/YardLayoutCreateModal";
|
||||||
import YardEditor from "./yard-3d/YardEditor";
|
import DigitalTwinEditor from "./yard-3d/DigitalTwinEditor";
|
||||||
import Yard3DViewer from "./yard-3d/Yard3DViewer";
|
import DigitalTwinViewer from "./yard-3d/DigitalTwinViewer";
|
||||||
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
||||||
import type { YardManagementConfig } from "../types";
|
import type { YardManagementConfig } from "../types";
|
||||||
|
|
||||||
|
|
@ -125,11 +125,15 @@ export default function YardManagement3DWidget({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 편집 모드: 편집 중인 경우 YardEditor 표시
|
// 편집 모드: 편집 중인 경우 DigitalTwinEditor 표시
|
||||||
if (isEditMode && editingLayout) {
|
if (isEditMode && editingLayout) {
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<YardEditor layout={editingLayout} onBack={handleEditComplete} />
|
<DigitalTwinEditor
|
||||||
|
layoutId={editingLayout.id}
|
||||||
|
layoutName={editingLayout.name}
|
||||||
|
onBack={handleEditComplete}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,10 +273,10 @@ export default function YardManagement3DWidget({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 선택된 레이아웃의 3D 뷰어 표시
|
// 선택된 레이아웃의 디지털 트윈 뷰어 표시
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full">
|
<div className="h-full w-full">
|
||||||
<Yard3DViewer layoutId={config.layoutId} />
|
<DigitalTwinViewer layoutId={config.layoutId} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,641 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ArrowLeft, Save, Loader2, Grid3x3, Combine, Move, Box, Package } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="bg-muted flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DigitalTwinEditorProps {
|
||||||
|
layoutId: number;
|
||||||
|
layoutName: string;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ToolType = "yard" | "gantry-crane" | "mobile-crane" | "rack" | "plate-stack";
|
||||||
|
|
||||||
|
interface PlacedObject {
|
||||||
|
id: number;
|
||||||
|
type: ToolType;
|
||||||
|
name: string;
|
||||||
|
position: { x: number; y: number; z: number };
|
||||||
|
size: { x: number; y: number; z: number };
|
||||||
|
color: string;
|
||||||
|
// 데이터 바인딩 정보
|
||||||
|
externalDbConnectionId?: number;
|
||||||
|
dataBindingConfig?: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: DigitalTwinEditorProps) {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [placedObjects, setPlacedObjects] = useState<PlacedObject[]>([]);
|
||||||
|
const [selectedObject, setSelectedObject] = useState<PlacedObject | null>(null);
|
||||||
|
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
|
const [externalDbConnections, setExternalDbConnections] = useState<any[]>([]);
|
||||||
|
const [selectedDbConnection, setSelectedDbConnection] = useState<number | null>(null);
|
||||||
|
const [nextObjectId, setNextObjectId] = useState(-1);
|
||||||
|
|
||||||
|
// 외부 DB 연결 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadExternalDbConnections = async () => {
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출
|
||||||
|
// const response = await externalDbConnectionApi.getConnections({ is_active: 'Y' });
|
||||||
|
|
||||||
|
// 임시 데이터
|
||||||
|
setExternalDbConnections([
|
||||||
|
{ id: 1, name: "DO_DY (동연 야드)", db_type: "mariadb" },
|
||||||
|
{ id: 2, name: "GY_YARD (광양 야드)", db_type: "mariadb" },
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("외부 DB 연결 목록 조회 실패:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadExternalDbConnections();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 레이아웃 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLayout = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// TODO: 실제 API 호출
|
||||||
|
// const response = await digitalTwinApi.getLayout(layoutId);
|
||||||
|
|
||||||
|
// 임시 데이터
|
||||||
|
setPlacedObjects([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLayout();
|
||||||
|
}, [layoutId]);
|
||||||
|
|
||||||
|
// 도구 타입별 기본 설정
|
||||||
|
const getToolDefaults = (type: ToolType): Partial<PlacedObject> => {
|
||||||
|
switch (type) {
|
||||||
|
case "yard":
|
||||||
|
return {
|
||||||
|
name: "영역",
|
||||||
|
size: { x: 20, y: 0.1, z: 20 }, // 4x4 칸
|
||||||
|
color: "#3b82f6", // 파란색
|
||||||
|
};
|
||||||
|
case "gantry-crane":
|
||||||
|
return {
|
||||||
|
name: "겐트리 크레인",
|
||||||
|
size: { x: 5, y: 8, z: 5 }, // 1x1 칸
|
||||||
|
color: "#22c55e", // 녹색
|
||||||
|
};
|
||||||
|
case "mobile-crane":
|
||||||
|
return {
|
||||||
|
name: "크레인",
|
||||||
|
size: { x: 5, y: 6, z: 5 }, // 1x1 칸
|
||||||
|
color: "#eab308", // 노란색
|
||||||
|
};
|
||||||
|
case "rack":
|
||||||
|
return {
|
||||||
|
name: "랙",
|
||||||
|
size: { x: 5, y: 3, z: 5 }, // 1x1 칸
|
||||||
|
color: "#a855f7", // 보라색
|
||||||
|
};
|
||||||
|
case "plate-stack":
|
||||||
|
return {
|
||||||
|
name: "후판 스택",
|
||||||
|
size: { x: 5, y: 2, z: 5 }, // 1x1 칸
|
||||||
|
color: "#ef4444", // 빨간색
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 도구 드래그 시작
|
||||||
|
const handleToolDragStart = (toolType: ToolType) => {
|
||||||
|
setDraggedTool(toolType);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 캔버스에 드롭
|
||||||
|
const handleCanvasDrop = (x: number, z: number) => {
|
||||||
|
if (!draggedTool) return;
|
||||||
|
|
||||||
|
const defaults = getToolDefaults(draggedTool);
|
||||||
|
|
||||||
|
// 야드는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
||||||
|
const yPosition = draggedTool === "yard" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||||
|
|
||||||
|
const newObject: PlacedObject = {
|
||||||
|
id: nextObjectId,
|
||||||
|
type: draggedTool,
|
||||||
|
name: defaults.name || "새 객체",
|
||||||
|
position: { x, y: yPosition, z },
|
||||||
|
size: defaults.size || { x: 5, y: 5, z: 5 },
|
||||||
|
color: defaults.color || "#9ca3af",
|
||||||
|
};
|
||||||
|
|
||||||
|
setPlacedObjects((prev) => [...prev, newObject]);
|
||||||
|
setSelectedObject(newObject);
|
||||||
|
setNextObjectId((prev) => prev - 1);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
setDraggedTool(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 객체 클릭
|
||||||
|
const handleObjectClick = (objectId: number | null) => {
|
||||||
|
if (objectId === null) {
|
||||||
|
setSelectedObject(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obj = placedObjects.find((o) => o.id === objectId);
|
||||||
|
setSelectedObject(obj || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 객체 이동
|
||||||
|
const handleObjectMove = (objectId: number, newX: number, newZ: number, newY?: number) => {
|
||||||
|
// Yard3DCanvas에서 이미 스냅+오프셋이 완료된 좌표를 받음
|
||||||
|
// 그대로 저장하면 됨
|
||||||
|
setPlacedObjects((prev) =>
|
||||||
|
prev.map((obj) => {
|
||||||
|
if (obj.id === objectId) {
|
||||||
|
const newPosition = { ...obj.position, x: newX, z: newZ };
|
||||||
|
if (newY !== undefined) {
|
||||||
|
newPosition.y = newY;
|
||||||
|
}
|
||||||
|
return { ...obj, position: newPosition };
|
||||||
|
}
|
||||||
|
return obj;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selectedObject?.id === objectId) {
|
||||||
|
setSelectedObject((prev) => {
|
||||||
|
if (!prev) return null;
|
||||||
|
const newPosition = { ...prev.position, x: newX, z: newZ };
|
||||||
|
if (newY !== undefined) {
|
||||||
|
newPosition.y = newY;
|
||||||
|
}
|
||||||
|
return { ...prev, position: newPosition };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 객체 속성 업데이트
|
||||||
|
const handleObjectUpdate = (updates: Partial<PlacedObject>) => {
|
||||||
|
if (!selectedObject) return;
|
||||||
|
|
||||||
|
let finalUpdates = { ...updates };
|
||||||
|
|
||||||
|
// 크기 변경 시에만 5 단위로 스냅하고 위치 조정 (position 변경은 제외)
|
||||||
|
if (updates.size && !updates.position) {
|
||||||
|
// placedObjects 배열에서 실제 저장된 객체를 가져옴 (selectedObject 상태가 아닌)
|
||||||
|
const actualObject = placedObjects.find((obj) => obj.id === selectedObject.id);
|
||||||
|
if (!actualObject) return;
|
||||||
|
|
||||||
|
const oldSize = actualObject.size;
|
||||||
|
const newSize = { ...oldSize, ...updates.size };
|
||||||
|
|
||||||
|
// W, D를 5 단위로 스냅
|
||||||
|
newSize.x = Math.max(5, Math.round(newSize.x / 5) * 5);
|
||||||
|
newSize.z = Math.max(5, Math.round(newSize.z / 5) * 5);
|
||||||
|
|
||||||
|
// H는 자유롭게 (야드 제외)
|
||||||
|
if (actualObject.type !== "yard") {
|
||||||
|
newSize.y = Math.max(0.1, newSize.y);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 크기 차이 계산
|
||||||
|
const deltaX = newSize.x - oldSize.x;
|
||||||
|
const deltaZ = newSize.z - oldSize.z;
|
||||||
|
const deltaY = newSize.y - oldSize.y;
|
||||||
|
|
||||||
|
// 위치 조정: 왼쪽/뒤쪽/바닥 모서리 고정, 오른쪽/앞쪽/위쪽으로만 늘어남
|
||||||
|
// Three.js는 중심점 기준이므로 크기 차이의 절반만큼 위치 이동
|
||||||
|
// actualObject.position (실제 배열의 position)을 기준으로 계산
|
||||||
|
const newPosition = {
|
||||||
|
...actualObject.position,
|
||||||
|
x: actualObject.position.x + deltaX / 2, // 오른쪽으로 늘어남
|
||||||
|
y: actualObject.position.y + deltaY / 2, // 위쪽으로 늘어남 (바닥 고정)
|
||||||
|
z: actualObject.position.z + deltaZ / 2, // 앞쪽으로 늘어남
|
||||||
|
};
|
||||||
|
|
||||||
|
finalUpdates = {
|
||||||
|
...finalUpdates,
|
||||||
|
size: newSize,
|
||||||
|
position: newPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
setPlacedObjects((prev) => prev.map((obj) => (obj.id === selectedObject.id ? { ...obj, ...finalUpdates } : obj)));
|
||||||
|
|
||||||
|
setSelectedObject((prev) => (prev ? { ...prev, ...finalUpdates } : null));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 객체 삭제
|
||||||
|
const handleObjectDelete = () => {
|
||||||
|
if (!selectedObject) return;
|
||||||
|
|
||||||
|
setPlacedObjects((prev) => prev.filter((obj) => obj.id !== selectedObject.id));
|
||||||
|
setSelectedObject(null);
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!selectedDbConnection) {
|
||||||
|
toast({
|
||||||
|
title: "외부 DB 선택 필요",
|
||||||
|
description: "외부 데이터베이스 연결을 선택하세요.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
// TODO: 실제 API 호출
|
||||||
|
// await digitalTwinApi.saveLayout(layoutId, {
|
||||||
|
// externalDbConnectionId: selectedDbConnection,
|
||||||
|
// objects: placedObjects,
|
||||||
|
// });
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "저장 완료",
|
||||||
|
description: "레이아웃이 성공적으로 저장되었습니다.",
|
||||||
|
});
|
||||||
|
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("저장 실패:", error);
|
||||||
|
toast({
|
||||||
|
title: "저장 실패",
|
||||||
|
description: "레이아웃 저장에 실패했습니다.",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-background flex h-full flex-col">
|
||||||
|
{/* 상단 툴바 */}
|
||||||
|
<div className="flex items-center justify-between border-b p-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||||
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
|
목록으로
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{layoutName}</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">디지털 트윈 야드 편집</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{hasUnsavedChanges && <span className="text-warning text-sm font-medium">미저장 변경사항 있음</span>}
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={isSaving || !hasUnsavedChanges}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="mr-2 h-4 w-4" />
|
||||||
|
저장
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 도구 팔레트 */}
|
||||||
|
<div className="bg-muted flex items-center justify-center gap-2 border-b p-4">
|
||||||
|
<span className="text-muted-foreground text-sm font-medium">도구:</span>
|
||||||
|
{[
|
||||||
|
{ type: "yard" as ToolType, label: "영역", icon: Grid3x3, color: "text-blue-500" },
|
||||||
|
// { type: "gantry-crane" as ToolType, label: "겐트리", icon: Combine, color: "text-green-500" },
|
||||||
|
{ type: "mobile-crane" as ToolType, label: "크레인", icon: Move, color: "text-yellow-500" },
|
||||||
|
{ type: "rack" as ToolType, label: "랙", icon: Box, color: "text-purple-500" },
|
||||||
|
{ type: "plate-stack" as ToolType, label: "후판", icon: Package, color: "text-red-500" },
|
||||||
|
].map((tool) => {
|
||||||
|
const Icon = tool.icon;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tool.type}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => handleToolDragStart(tool.type)}
|
||||||
|
className="bg-background hover:bg-accent flex cursor-grab items-center gap-1 rounded-md border px-3 py-2 transition-colors active:cursor-grabbing"
|
||||||
|
title={`${tool.label} 드래그하여 배치`}
|
||||||
|
>
|
||||||
|
<Icon className={`h-4 w-4 ${tool.color}`} />
|
||||||
|
<span className="text-xs">{tool.label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메인 영역 */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* 좌측: 외부 DB 선택 + 객체 목록 */}
|
||||||
|
<div className="flex h-full w-[20%] flex-col border-r">
|
||||||
|
{/* 외부 DB 선택 */}
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<Label className="mb-2 block text-sm font-semibold">외부 데이터베이스</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDbConnection?.toString() || ""}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedDbConnection(parseInt(value));
|
||||||
|
setHasUnsavedChanges(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-10 text-sm">
|
||||||
|
<SelectValue placeholder="DB 선택..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{externalDbConnections.map((conn) => (
|
||||||
|
<SelectItem key={conn.id} value={conn.id.toString()} className="text-sm">
|
||||||
|
{conn.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-muted-foreground mt-1.5 text-xs">실시간 데이터를 가져올 데이터베이스를 선택하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 배치된 객체 목록 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<h3 className="mb-3 text-sm font-semibold">배치된 객체 ({placedObjects.length})</h3>
|
||||||
|
|
||||||
|
{placedObjects.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground text-center text-sm">상단 도구를 드래그하여 배치하세요</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{placedObjects.map((obj) => (
|
||||||
|
<div
|
||||||
|
key={obj.id}
|
||||||
|
onClick={() => handleObjectClick(obj.id)}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||||
|
selectedObject?.id === obj.id ? "border-primary bg-primary/10" : "hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{obj.name}</span>
|
||||||
|
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: obj.color }} />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
위치: ({obj.position.x.toFixed(1)}, {obj.position.z.toFixed(1)})
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 3D 캔버스 */}
|
||||||
|
<div
|
||||||
|
className="h-full flex-1 bg-gray-100"
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
|
const rawX = ((e.clientX - rect.left) / rect.width - 0.5) * 100;
|
||||||
|
const rawZ = ((e.clientY - rect.top) / rect.height - 0.5) * 100;
|
||||||
|
|
||||||
|
// 그리드 크기 (5 단위)
|
||||||
|
const gridSize = 5;
|
||||||
|
|
||||||
|
// 그리드에 스냅
|
||||||
|
// 야드(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
||||||
|
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||||
|
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||||
|
|
||||||
|
// 5x5 객체는 타일 중앙으로 오프셋 (야드는 제외)
|
||||||
|
if (draggedTool !== "yard") {
|
||||||
|
snappedX += gridSize / 2;
|
||||||
|
snappedZ += gridSize / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCanvasDrop(snappedX, snappedZ);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Yard3DCanvas
|
||||||
|
placements={placedObjects.map((obj) => ({
|
||||||
|
id: obj.id,
|
||||||
|
yard_layout_id: layoutId,
|
||||||
|
material_code: null,
|
||||||
|
material_name: obj.name,
|
||||||
|
name: obj.name, // 객체 이름 (야드 이름 표시용)
|
||||||
|
quantity: null,
|
||||||
|
unit: null,
|
||||||
|
position_x: obj.position.x,
|
||||||
|
position_y: obj.position.y,
|
||||||
|
position_z: obj.position.z,
|
||||||
|
size_x: obj.size.x,
|
||||||
|
size_y: obj.size.y,
|
||||||
|
size_z: obj.size.z,
|
||||||
|
color: obj.color,
|
||||||
|
data_source_type: obj.type,
|
||||||
|
data_source_config: null,
|
||||||
|
data_binding: null,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}))}
|
||||||
|
selectedPlacementId={selectedObject?.id || null}
|
||||||
|
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||||
|
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||||
|
focusOnPlacementId={null}
|
||||||
|
onCollisionDetected={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 객체 속성 편집 */}
|
||||||
|
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||||
|
{selectedObject ? (
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">객체 속성</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 이름 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="object-name" className="text-sm">
|
||||||
|
이름
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="object-name"
|
||||||
|
value={selectedObject.name}
|
||||||
|
onChange={(e) => handleObjectUpdate({ name: e.target.value })}
|
||||||
|
className="mt-1.5 h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 위치 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">위치</Label>
|
||||||
|
<div className="mt-1.5 grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pos-x" className="text-muted-foreground text-xs">
|
||||||
|
X
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pos-x"
|
||||||
|
type="number"
|
||||||
|
value={selectedObject.position.x.toFixed(1)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleObjectUpdate({
|
||||||
|
position: {
|
||||||
|
...selectedObject.position,
|
||||||
|
x: parseFloat(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="pos-z" className="text-muted-foreground text-xs">
|
||||||
|
Z
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="pos-z"
|
||||||
|
type="number"
|
||||||
|
value={selectedObject.position.z.toFixed(1)}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleObjectUpdate({
|
||||||
|
position: {
|
||||||
|
...selectedObject.position,
|
||||||
|
z: parseFloat(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 크기 */}
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm">크기</Label>
|
||||||
|
<div className="mt-1.5 grid grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="size-x" className="text-muted-foreground text-xs">
|
||||||
|
W (5 단위)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="size-x"
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
min="5"
|
||||||
|
value={selectedObject.size.x}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleObjectUpdate({
|
||||||
|
size: {
|
||||||
|
...selectedObject.size,
|
||||||
|
x: parseFloat(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="size-y" className="text-muted-foreground text-xs">
|
||||||
|
H
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="size-y"
|
||||||
|
type="number"
|
||||||
|
value={selectedObject.size.y}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleObjectUpdate({
|
||||||
|
size: {
|
||||||
|
...selectedObject.size,
|
||||||
|
y: parseFloat(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="size-z" className="text-muted-foreground text-xs">
|
||||||
|
D (5 단위)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="size-z"
|
||||||
|
type="number"
|
||||||
|
step="5"
|
||||||
|
min="5"
|
||||||
|
value={selectedObject.size.z}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleObjectUpdate({
|
||||||
|
size: {
|
||||||
|
...selectedObject.size,
|
||||||
|
z: parseFloat(e.target.value),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 */}
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="object-color" className="text-sm">
|
||||||
|
색상
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="object-color"
|
||||||
|
type="color"
|
||||||
|
value={selectedObject.color}
|
||||||
|
onChange={(e) => handleObjectUpdate({ color: e.target.value })}
|
||||||
|
className="mt-1.5 h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 삭제 버튼 */}
|
||||||
|
<Button variant="destructive" size="sm" onClick={handleObjectDelete} className="w-full">
|
||||||
|
객체 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center p-4 text-center">
|
||||||
|
<p className="text-muted-foreground text-sm">객체를 선택하면 속성을 편집할 수 있습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useMemo } from "react";
|
||||||
|
import { Search } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center bg-muted">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
interface DigitalTwinViewerProps {
|
||||||
|
layoutId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 타입 정의
|
||||||
|
interface Material {
|
||||||
|
id: number;
|
||||||
|
plate_no: string; // 후판번호
|
||||||
|
steel_grade: string; // 강종
|
||||||
|
thickness: number; // 두께
|
||||||
|
width: number; // 폭
|
||||||
|
length: number; // 길이
|
||||||
|
weight: number; // 중량
|
||||||
|
location: string; // 위치
|
||||||
|
status: string; // 상태
|
||||||
|
arrival_date: string; // 입고일자
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [selectedYard, setSelectedYard] = useState<string>("all");
|
||||||
|
const [selectedStatus, setSelectedStatus] = useState<string>("all");
|
||||||
|
const [dateRange, setDateRange] = useState({ from: "", to: "" });
|
||||||
|
const [selectedMaterial, setSelectedMaterial] = useState<Material | null>(null);
|
||||||
|
const [materials, setMaterials] = useState<Material[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
// 레이아웃 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
// TODO: 실제 API 호출
|
||||||
|
// const response = await digitalTwinApi.getLayoutData(layoutId);
|
||||||
|
|
||||||
|
// 임시 데이터
|
||||||
|
setMaterials([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
plate_no: "P-2024-001",
|
||||||
|
steel_grade: "SM490A",
|
||||||
|
thickness: 25,
|
||||||
|
width: 2000,
|
||||||
|
length: 6000,
|
||||||
|
weight: 2355,
|
||||||
|
location: "A동-101",
|
||||||
|
status: "입고",
|
||||||
|
arrival_date: "2024-11-15",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
plate_no: "P-2024-002",
|
||||||
|
steel_grade: "SS400",
|
||||||
|
thickness: 30,
|
||||||
|
width: 2500,
|
||||||
|
length: 8000,
|
||||||
|
weight: 4710,
|
||||||
|
location: "B동-205",
|
||||||
|
status: "가공중",
|
||||||
|
arrival_date: "2024-11-16",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("디지털 트윈 데이터 로드 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, [layoutId]);
|
||||||
|
|
||||||
|
// 필터링된 자재 목록
|
||||||
|
const filteredMaterials = useMemo(() => {
|
||||||
|
return materials.filter((material) => {
|
||||||
|
// 검색어 필터
|
||||||
|
if (searchTerm) {
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
const matchSearch =
|
||||||
|
material.plate_no.toLowerCase().includes(searchLower) ||
|
||||||
|
material.steel_grade.toLowerCase().includes(searchLower) ||
|
||||||
|
material.location.toLowerCase().includes(searchLower);
|
||||||
|
if (!matchSearch) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 야드 필터
|
||||||
|
if (selectedYard !== "all" && !material.location.startsWith(selectedYard)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 상태 필터
|
||||||
|
if (selectedStatus !== "all" && material.status !== selectedStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 필터
|
||||||
|
if (dateRange.from && material.arrival_date < dateRange.from) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (dateRange.to && material.arrival_date > dateRange.to) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [materials, searchTerm, selectedYard, selectedStatus, dateRange]);
|
||||||
|
|
||||||
|
// 3D 객체 클릭 핸들러
|
||||||
|
const handleObjectClick = (objectId: number) => {
|
||||||
|
const material = materials.find((m) => m.id === objectId);
|
||||||
|
setSelectedMaterial(material || null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full overflow-hidden">
|
||||||
|
{/* 좌측: 필터 패널 */}
|
||||||
|
<div className="flex h-full w-[20%] flex-col border-r">
|
||||||
|
{/* 검색바 */}
|
||||||
|
<div className="border-b p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
placeholder="후판번호, 강종, 위치 검색..."
|
||||||
|
className="h-10 pl-10 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 옵션 */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 야드 선택 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">야드</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{["all", "A동", "B동", "C동", "겐트리"].map((yard) => (
|
||||||
|
<button
|
||||||
|
key={yard}
|
||||||
|
onClick={() => setSelectedYard(yard)}
|
||||||
|
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedYard === yard
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{yard === "all" ? "전체" : yard}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 필터 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">상태</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{["all", "입고", "가공중", "출고대기", "출고완료"].map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
onClick={() => setSelectedStatus(status)}
|
||||||
|
className={`block w-full rounded px-3 py-2 text-left text-sm transition-colors ${
|
||||||
|
selectedStatus === status
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{status === "all" ? "전체" : status}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 기간 필터 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-2 text-sm font-semibold">입고 기간</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.from}
|
||||||
|
onChange={(e) => setDateRange((prev) => ({ ...prev, from: e.target.value }))}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
placeholder="시작일"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={dateRange.to}
|
||||||
|
onChange={(e) => setDateRange((prev) => ({ ...prev, to: e.target.value }))}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
placeholder="종료일"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 중앙: 3D 캔버스 */}
|
||||||
|
<div className="h-full flex-1 bg-gray-100">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Yard3DCanvas
|
||||||
|
placements={[]} // TODO: 실제 배치 데이터
|
||||||
|
selectedPlacementId={selectedMaterial?.id || null}
|
||||||
|
onPlacementClick={(placement) => {
|
||||||
|
if (placement) {
|
||||||
|
handleObjectClick(placement.id);
|
||||||
|
} else {
|
||||||
|
setSelectedMaterial(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onPlacementDrag={() => {}} // 뷰어 모드에서는 드래그 비활성화
|
||||||
|
focusOnPlacementId={null}
|
||||||
|
onCollisionDetected={() => {}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측: 상세정보 패널 (후판 목록 테이블) */}
|
||||||
|
<div className="h-full w-[30%] overflow-y-auto border-l">
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="mb-4 text-lg font-semibold">후판 목록</h3>
|
||||||
|
|
||||||
|
{filteredMaterials.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<p className="text-sm text-muted-foreground">조건에 맞는 후판이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{filteredMaterials.map((material) => (
|
||||||
|
<div
|
||||||
|
key={material.id}
|
||||||
|
onClick={() => setSelectedMaterial(material)}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-all ${
|
||||||
|
selectedMaterial?.id === material.id
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:border-primary/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-semibold">{material.plate_no}</span>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
material.status === "입고"
|
||||||
|
? "bg-blue-100 text-blue-700"
|
||||||
|
: material.status === "가공중"
|
||||||
|
? "bg-yellow-100 text-yellow-700"
|
||||||
|
: material.status === "출고대기"
|
||||||
|
? "bg-orange-100 text-orange-700"
|
||||||
|
: "bg-green-100 text-green-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{material.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-xs text-muted-foreground">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>강종:</span>
|
||||||
|
<span className="font-medium text-foreground">{material.steel_grade}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>규격:</span>
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{material.thickness}×{material.width}×{material.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>중량:</span>
|
||||||
|
<span className="font-medium text-foreground">{material.weight.toLocaleString()} kg</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>위치:</span>
|
||||||
|
<span className="font-medium text-foreground">{material.location}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>입고일:</span>
|
||||||
|
<span className="font-medium text-foreground">{material.arrival_date}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface YardPlacement {
|
||||||
yard_layout_id?: number;
|
yard_layout_id?: number;
|
||||||
material_code?: string | null;
|
material_code?: string | null;
|
||||||
material_name?: string | null;
|
material_name?: string | null;
|
||||||
|
name?: string | null; // 객체 이름 (야드 이름 등)
|
||||||
quantity?: number | null;
|
quantity?: number | null;
|
||||||
unit?: string | null;
|
unit?: string | null;
|
||||||
position_x: number;
|
position_x: number;
|
||||||
|
|
@ -37,12 +38,9 @@ interface Yard3DCanvasProps {
|
||||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||||
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
|
// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음
|
||||||
function snapToGrid(value: number, gridSize: number): number {
|
function snapToGrid(value: number, gridSize: number): number {
|
||||||
// 가장 가까운 그리드 칸 찾기
|
// 가장 가까운 그리드 교차점으로 스냅 (오프셋 없음)
|
||||||
const gridIndex = Math.round(value / gridSize);
|
// DigitalTwinEditor에서 오프셋 처리하므로 여기서는 순수 스냅만
|
||||||
// 그리드 칸의 중심점 반환
|
return Math.round(value / gridSize) * gridSize;
|
||||||
// gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5...
|
|
||||||
// 이렇게 하면 Box가 칸 안에 정확히 들어감
|
|
||||||
return gridIndex * gridSize + gridSize / 2;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 자재 박스 컴포넌트 (드래그 가능)
|
// 자재 박스 컴포넌트 (드래그 가능)
|
||||||
|
|
@ -55,7 +53,6 @@ function MaterialBox({
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
gridSize = 5,
|
gridSize = 5,
|
||||||
allPlacements = [],
|
allPlacements = [],
|
||||||
onCollisionDetected,
|
|
||||||
}: {
|
}: {
|
||||||
placement: YardPlacement;
|
placement: YardPlacement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
|
@ -71,19 +68,70 @@ function MaterialBox({
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 });
|
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 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 { 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 위치를 조정
|
// 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정
|
||||||
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
|
const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => {
|
||||||
const palletHeight = 0.3; // 팔레트 높이
|
if (!allPlacements || allPlacements.length === 0) {
|
||||||
const palletGap = 0.05; // 팔레트와 박스 사이 간격
|
// 다른 객체가 없으면 기본 높이
|
||||||
|
const objectType = placement.data_source_type as string | null;
|
||||||
|
const defaultY = objectType === "yard" ? 0.05 : (placement.size_y || gridSize) / 2;
|
||||||
|
return {
|
||||||
|
hasCollision: false,
|
||||||
|
adjustedY: defaultY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const mySize = placement.size_x || gridSize; // 내 크기 (5)
|
// 내 크기 정보
|
||||||
const myHalfSize = mySize / 2; // 2.5
|
const mySizeX = placement.size_x || gridSize;
|
||||||
const mySizeY = placement.size_y || gridSize; // 박스 높이 (5)
|
const mySizeZ = placement.size_z || gridSize;
|
||||||
const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이
|
const mySizeY = placement.size_y || gridSize;
|
||||||
|
|
||||||
let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5)
|
// 내 바운딩 박스 (좌측 하단 모서리 기준)
|
||||||
|
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 === "yard" ? 0.05 : mySizeY / 2;
|
||||||
|
let maxYBelow = defaultY;
|
||||||
|
|
||||||
|
// 야드는 스택되지 않음 (항상 바닥에 배치)
|
||||||
|
if (objectType === "yard") {
|
||||||
|
return {
|
||||||
|
hasCollision: false,
|
||||||
|
adjustedY: defaultY,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
for (const p of allPlacements) {
|
for (const p of allPlacements) {
|
||||||
// 자기 자신은 제외
|
// 자기 자신은 제외
|
||||||
|
|
@ -91,39 +139,31 @@ function MaterialBox({
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pSize = p.size_x || gridSize; // 상대방 크기 (5)
|
// 상대방 크기 정보
|
||||||
const pHalfSize = pSize / 2; // 2.5
|
const pSizeX = p.size_x || gridSize;
|
||||||
const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5)
|
const pSizeZ = p.size_z || gridSize;
|
||||||
const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이
|
const pSizeY = p.size_y || gridSize;
|
||||||
|
|
||||||
// 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지)
|
// 상대방 바운딩 박스
|
||||||
const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛)
|
const pMinX = p.position_x - pSizeX / 2;
|
||||||
const isNearby =
|
const pMaxX = p.position_x + pSizeX / 2;
|
||||||
Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접
|
const pMinZ = p.position_z - pSizeZ / 2;
|
||||||
Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접
|
const pMaxZ = p.position_z + pSizeZ / 2;
|
||||||
|
|
||||||
if (isNearby) {
|
// AABB 충돌 감지 (2D 평면에서)
|
||||||
// 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정)
|
const isOverlapping = myMinX < pMaxX && myMaxX > pMinX && myMinZ < pMaxZ && myMaxZ > pMinZ;
|
||||||
const isActuallyOverlapping =
|
|
||||||
Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침
|
|
||||||
Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침
|
|
||||||
|
|
||||||
if (isActuallyOverlapping) {
|
if (isOverlapping) {
|
||||||
// 실제로 겹침: 위에 배치
|
// 겹침: 상대방 위에 배치
|
||||||
// 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산
|
const topOfOtherElement = p.position_y + pSizeY / 2;
|
||||||
const topOfOtherElement = p.position_y + pTotalHeight / 2;
|
const myYOnTop = topOfOtherElement + mySizeY / 2;
|
||||||
// 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산
|
|
||||||
const myYOnTop = topOfOtherElement + myTotalHeight / 2;
|
|
||||||
|
|
||||||
if (myYOnTop > maxYBelow) {
|
if (myYOnTop > maxYBelow) {
|
||||||
maxYBelow = myYOnTop;
|
maxYBelow = myYOnTop;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함)
|
|
||||||
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
|
const needsAdjustment = Math.abs(y - maxYBelow) > 0.1;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -160,46 +200,60 @@ function MaterialBox({
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
// 마우스 이동 거리 계산 (픽셀)
|
// 마우스 좌표를 정규화 (-1 ~ 1)
|
||||||
const deltaX = e.clientX - mouseStartPos.current.x;
|
const rect = gl.domElement.getBoundingClientRect();
|
||||||
const deltaY = e.clientY - mouseStartPos.current.y;
|
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||||
|
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||||
|
|
||||||
// 카메라 거리를 고려한 스케일 팩터
|
// Raycaster로 바닥 평면과의 교차점 계산
|
||||||
const distance = camera.position.distanceTo(meshRef.current.position);
|
const raycaster = new THREE.Raycaster();
|
||||||
const scaleFactor = distance / 500; // 조정 가능한 값
|
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), camera);
|
||||||
|
|
||||||
// 카메라 방향 벡터
|
// 바닥 평면 (y = 0)
|
||||||
const cameraDirection = new THREE.Vector3();
|
const plane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
|
||||||
camera.getWorldDirection(cameraDirection);
|
const intersectPoint = new THREE.Vector3();
|
||||||
|
const hasIntersection = raycaster.ray.intersectPlane(plane, intersectPoint);
|
||||||
|
|
||||||
// 카메라의 우측 벡터 (X축 이동용)
|
if (!hasIntersection) {
|
||||||
const right = new THREE.Vector3();
|
return;
|
||||||
right.crossVectors(camera.up, cameraDirection).normalize();
|
}
|
||||||
|
|
||||||
// 실제 3D 공간에서의 이동량 계산
|
// 마우스 위치에 드래그 시작 시 저장한 오프셋 적용
|
||||||
const moveRight = right.multiplyScalar(-deltaX * scaleFactor);
|
const finalX = intersectPoint.x + dragOffset.current.x;
|
||||||
const moveForward = new THREE.Vector3(-cameraDirection.x, 0, -cameraDirection.z)
|
const finalZ = intersectPoint.z + dragOffset.current.z;
|
||||||
.normalize()
|
|
||||||
.multiplyScalar(deltaY * scaleFactor);
|
|
||||||
|
|
||||||
// 최종 위치 계산
|
|
||||||
const finalX = dragStartPos.current.x + moveRight.x + moveForward.x;
|
|
||||||
const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z;
|
|
||||||
|
|
||||||
// NaN 검증
|
// NaN 검증
|
||||||
if (isNaN(finalX) || isNaN(finalZ)) {
|
if (isNaN(finalX) || isNaN(finalZ)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 그리드에 스냅
|
// 객체의 좌측 하단 모서리 좌표 계산 (크기 / 2를 빼서)
|
||||||
const snappedX = snapToGrid(finalX, gridSize);
|
const sizeX = placement.size_x || 5;
|
||||||
const snappedZ = snapToGrid(finalZ, gridSize);
|
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 위치 조정
|
// 충돌 체크 및 Y 위치 조정
|
||||||
const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ);
|
const { adjustedY } = checkCollisionAndAdjustY(finalSnappedX, dragStartPos.current.y, finalSnappedZ);
|
||||||
|
|
||||||
// 즉시 mesh 위치 업데이트 (조정된 Y 위치로)
|
// 즉시 mesh 위치 업데이트 (스냅된 위치로)
|
||||||
meshRef.current.position.set(finalX, adjustedY, finalZ);
|
meshRef.current.position.set(finalSnappedX, adjustedY, finalSnappedZ);
|
||||||
|
|
||||||
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
// ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만)
|
||||||
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
// 실제 저장은 handleGlobalMouseUp에서만 수행
|
||||||
|
|
@ -217,23 +271,21 @@ function MaterialBox({
|
||||||
const hasMoved = deltaX > minMovement || deltaZ > minMovement;
|
const hasMoved = deltaX > minMovement || deltaZ > minMovement;
|
||||||
|
|
||||||
if (hasMoved) {
|
if (hasMoved) {
|
||||||
// 실제로 드래그한 경우: 그리드에 스냅
|
// 실제로 드래그한 경우: 이미 handleGlobalMouseMove에서 스냅됨
|
||||||
const snappedX = snapToGrid(currentPos.x, gridSize);
|
// currentPos는 이미 스냅+오프셋이 적용된 값이므로 그대로 사용
|
||||||
const snappedZ = snapToGrid(currentPos.z, gridSize);
|
const finalX = currentPos.x;
|
||||||
|
const finalY = currentPos.y;
|
||||||
// Y 위치 조정 (마인크래프트처럼 쌓기)
|
const finalZ = currentPos.z;
|
||||||
const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ);
|
|
||||||
|
|
||||||
// ✅ 항상 배치 가능 (위로 올라가므로)
|
// ✅ 항상 배치 가능 (위로 올라가므로)
|
||||||
console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ });
|
console.log("✅ 배치 완료! 저장:", { x: finalX, y: finalY, z: finalZ });
|
||||||
meshRef.current.position.set(snappedX, adjustedY, snappedZ);
|
|
||||||
|
|
||||||
// 최종 위치 저장 (조정된 Y 위치로)
|
// 최종 위치 저장
|
||||||
if (onDrag) {
|
if (onDrag) {
|
||||||
onDrag({
|
onDrag({
|
||||||
x: snappedX,
|
x: finalX,
|
||||||
y: adjustedY,
|
y: finalY,
|
||||||
z: snappedZ,
|
z: finalZ,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -284,6 +336,29 @@ function MaterialBox({
|
||||||
y: e.clientY,
|
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);
|
setIsDragging(true);
|
||||||
gl.domElement.style.cursor = "grabbing";
|
gl.domElement.style.cursor = "grabbing";
|
||||||
if (onDragStart) {
|
if (onDragStart) {
|
||||||
|
|
@ -304,6 +379,407 @@ function MaterialBox({
|
||||||
// 팔레트 위치 계산: 박스 하단부터 시작
|
// 팔레트 위치 계산: 박스 하단부터 시작
|
||||||
const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap;
|
const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap;
|
||||||
|
|
||||||
|
// 객체 타입 (data_source_type에 저장됨)
|
||||||
|
const objectType = placement.data_source_type as string | null;
|
||||||
|
|
||||||
|
// 타입별 렌더링
|
||||||
|
const renderObjectByType = () => {
|
||||||
|
switch (objectType) {
|
||||||
|
case "yard":
|
||||||
|
// 야드: 투명한 바닥 + 두꺼운 외곽선 (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>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 야드 이름 텍스트 */}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 "mobile-crane":
|
||||||
|
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
||||||
|
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 (
|
return (
|
||||||
<group
|
<group
|
||||||
ref={meshRef}
|
ref={meshRef}
|
||||||
|
|
@ -328,141 +804,7 @@ function MaterialBox({
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
{renderObjectByType()}
|
||||||
<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="#1a1a1a" opacity={0.8} transparent />
|
|
||||||
</lineSegments>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* 포장 테이프 (가로) - 윗면 */}
|
|
||||||
{isConfigured && (
|
|
||||||
<>
|
|
||||||
{/* 테이프 세로 */}
|
|
||||||
<Box args={[boxWidth * 0.12, 0.02, boxDepth * 0.95]} position={[0, boxHeight / 2 + 0.01, 0]}>
|
|
||||||
<meshStandardMaterial color="#d4a574" opacity={0.7} transparent roughness={0.3} metalness={0.3} />
|
|
||||||
</Box>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */}
|
|
||||||
{isConfigured && placement.material_name && (
|
|
||||||
<group position={[0, boxHeight * 0.1, boxDepth / 2 + 0.02]}>
|
|
||||||
{/* 라벨 배경 (흰색 스티커) */}
|
|
||||||
<Box args={[boxWidth * 0.7, boxHeight * 0.25, 0.01]}>
|
|
||||||
<meshStandardMaterial color="#ffffff" roughness={0.4} metalness={0.1} />
|
|
||||||
<lineSegments>
|
|
||||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth * 0.7, boxHeight * 0.25, 0.01)]} />
|
|
||||||
<lineBasicMaterial color="#cccccc" opacity={0.8} transparent />
|
|
||||||
</lineSegments>
|
|
||||||
</Box>
|
|
||||||
{/* 라벨 텍스트 */}
|
|
||||||
<Text
|
|
||||||
position={[0, 0, 0.02]}
|
|
||||||
fontSize={0.3}
|
|
||||||
color="#000000"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{placement.material_name}
|
|
||||||
</Text>
|
|
||||||
</group>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 수량 라벨 (윗면) - 큰 글씨 */}
|
|
||||||
{isConfigured && placement.quantity && (
|
|
||||||
<Text
|
|
||||||
position={[0, boxHeight / 2 + 0.03, 0]}
|
|
||||||
rotation={[-Math.PI / 2, 0, 0]}
|
|
||||||
fontSize={0.6}
|
|
||||||
color="#000000"
|
|
||||||
anchorX="center"
|
|
||||||
anchorY="middle"
|
|
||||||
outlineWidth={0.1}
|
|
||||||
outlineColor="#ffffff"
|
|
||||||
fontWeight="bold"
|
|
||||||
>
|
|
||||||
{placement.quantity} {placement.unit || ""}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 디테일 표시 */}
|
|
||||||
{isConfigured && (
|
|
||||||
<>
|
|
||||||
{/* 화살표 표시 (이 쪽이 위) */}
|
|
||||||
<group position={[0, boxHeight * 0.35, boxDepth / 2 + 0.01]}>
|
|
||||||
<Text fontSize={0.6} color="#000000" anchorX="center" anchorY="middle">
|
|
||||||
▲
|
|
||||||
</Text>
|
|
||||||
<Text position={[0, -0.4, 0]} fontSize={0.3} color="#666666" anchorX="center" anchorY="middle">
|
|
||||||
UP
|
|
||||||
</Text>
|
|
||||||
</group>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -563,15 +905,18 @@ function Scene({
|
||||||
<directionalLight position={[10, 10, 5]} intensity={1} />
|
<directionalLight position={[10, 10, 5]} intensity={1} />
|
||||||
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
<directionalLight position={[-10, -10, -5]} intensity={0.3} />
|
||||||
|
|
||||||
|
{/* 배경색 */}
|
||||||
|
<color attach="background" args={["#f3f4f6"]} />
|
||||||
|
|
||||||
{/* 바닥 그리드 (타일을 4등분) */}
|
{/* 바닥 그리드 (타일을 4등분) */}
|
||||||
<Grid
|
<Grid
|
||||||
args={[100, 100]}
|
args={[100, 100]}
|
||||||
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
||||||
cellThickness={0.6}
|
cellThickness={0.6}
|
||||||
cellColor="#1f2937" // 얇은 선 (서브 그리드) - 매우 어두운 회색
|
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
||||||
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
||||||
sectionThickness={1.5}
|
sectionThickness={1.5}
|
||||||
sectionColor="#374151" // 타일 경계는 조금 밝게
|
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
||||||
fadeDistance={200}
|
fadeDistance={200}
|
||||||
fadeStrength={1}
|
fadeStrength={1}
|
||||||
followCamera={false}
|
followCamera={false}
|
||||||
|
|
@ -640,7 +985,7 @@ export default function Yard3DCanvas({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full bg-foreground" onClick={handleCanvasClick}>
|
<div className="h-full w-full bg-gray-100" onClick={handleCanvasClick}>
|
||||||
<Canvas
|
<Canvas
|
||||||
camera={{
|
camera={{
|
||||||
position: [50, 30, 50],
|
position: [50, 30, 50],
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,290 @@
|
||||||
|
// (이전 import 및 interface 동일, mobile-crane case만 교체)
|
||||||
|
// 실제로는 기존 파일에서 mobile-crane 케이스만 교체
|
||||||
|
|
||||||
|
// mobile-crane 케이스를 다음으로 교체:
|
||||||
|
|
||||||
|
case "mobile-crane":
|
||||||
|
// 이동식 크레인: 하부(트랙) + 회전대 + 캐빈 + 붐대 + 카운터웨이트 + 후크
|
||||||
|
return (
|
||||||
|
<group>
|
||||||
|
{/* ========== 하부 크롤러 트랙 시스템 ========== */}
|
||||||
|
{/* 좌측 트랙 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[-boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#1a1a1a"
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 좌측 트랙 상부 롤러 */}
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[-boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 우측 트랙 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.28, boxHeight * 0.18, boxDepth * 0.98]} position={[boxWidth * 0.31, -boxHeight * 0.41, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#1a1a1a"
|
||||||
|
roughness={0.9}
|
||||||
|
metalness={0.2}
|
||||||
|
emissive={isSelected ? "#1a1a1a" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.2 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 우측 트랙 상부 롤러 */}
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, -boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.32, boxHeight * 0.08, boxDepth * 0.12]} position={[boxWidth * 0.31, -boxHeight * 0.28, boxDepth * 0.42]}>
|
||||||
|
<meshStandardMaterial color="#2d3748" roughness={0.7} metalness={0.5} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 트랙 연결 프레임 */}
|
||||||
|
<Box args={[boxWidth * 0.12, boxHeight * 0.06, boxDepth * 0.98]} position={[0, -boxHeight * 0.39, 0]}>
|
||||||
|
<meshStandardMaterial color="#374151" roughness={0.5} metalness={0.6} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 회전 상부 구조 ========== */}
|
||||||
|
{/* 메인 회전 플랫폼 */}
|
||||||
|
<Box args={[boxWidth * 0.88, boxHeight * 0.15, boxDepth * 0.88]} position={[0, -boxHeight * 0.28, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 회전 베어링 하우징 */}
|
||||||
|
<Box args={[boxWidth * 0.45, boxHeight * 0.08, boxDepth * 0.45]} position={[0, -boxHeight * 0.35, 0]}>
|
||||||
|
<meshStandardMaterial color="#4b5563" roughness={0.4} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 엔진 및 유압 시스템 ========== */}
|
||||||
|
{/* 엔진룸 메인 */}
|
||||||
|
<Box args={[boxWidth * 0.65, boxHeight * 0.32, boxDepth * 0.35]} position={[0, -boxHeight * 0.12, boxDepth * 0.24]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color={placement.color}
|
||||||
|
roughness={0.35}
|
||||||
|
metalness={0.65}
|
||||||
|
emissive={isSelected ? placement.color : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 유압 펌프 하우징 */}
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[-boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.18, boxDepth * 0.25]} position={[boxWidth * 0.25, -boxHeight * 0.15, boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
{/* 배기 파이프 */}
|
||||||
|
<Box args={[boxWidth * 0.08, boxHeight * 0.35, boxDepth * 0.08]} position={[boxWidth * 0.2, -boxHeight * 0.02, boxDepth * 0.35]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 운전실 (캐빈) ========== */}
|
||||||
|
{/* 캐빈 메인 바디 */}
|
||||||
|
<Box args={[boxWidth * 0.38, boxHeight * 0.35, boxDepth * 0.38]} position={[0, -boxHeight * 0.08, -boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#2d3748"
|
||||||
|
roughness={0.15}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#3b82f6" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.6 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 캐빈 창문 */}
|
||||||
|
<Box args={[boxWidth * 0.35, boxHeight * 0.25, boxDepth * 0.02]} position={[0, -boxHeight * 0.05, -boxDepth * 0.37]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#60a5fa"
|
||||||
|
roughness={0.05}
|
||||||
|
metalness={0.95}
|
||||||
|
transparent
|
||||||
|
opacity={0.6}
|
||||||
|
emissive={isSelected ? "#60a5fa" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 캐빈 지붕 */}
|
||||||
|
<Box args={[boxWidth * 0.4, boxHeight * 0.05, boxDepth * 0.4]} position={[0, boxHeight * 0.07, -boxDepth * 0.18]}>
|
||||||
|
<meshStandardMaterial color="#374151" roughness={0.3} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 붐대 시스템 ========== */}
|
||||||
|
{/* 붐대 마운트 베이스 */}
|
||||||
|
<Box args={[boxWidth * 0.25, boxHeight * 0.25, boxDepth * 0.25]} position={[0, -boxHeight * 0.02, 0]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#4b5563"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#4b5563" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.5 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 힌지 실린더 (유압) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.12, boxHeight * 0.28, boxDepth * 0.12]}
|
||||||
|
position={[0, boxHeight * 0.02, boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 3, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#dc2626" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 메인 붐대 하단 섹션 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.18, boxHeight * 0.55, boxDepth * 0.18]}
|
||||||
|
position={[0, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||||
|
rotation={[Math.PI / 4.2, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#f59e0b"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#f59e0b" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 상단 섹션 (텔레스코픽) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.14, boxHeight * 0.45, boxDepth * 0.14]}
|
||||||
|
position={[0, boxHeight * 0.32, -boxDepth * 0.32]}
|
||||||
|
rotation={[Math.PI / 4.8, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fbbf24"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 붐대 최상단 섹션 */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.1, boxHeight * 0.32, boxDepth * 0.1]}
|
||||||
|
position={[0, boxHeight * 0.45, -boxDepth * 0.44]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fbbf24"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.75}
|
||||||
|
emissive={isSelected ? "#fbbf24" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.75 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 붐대 트러스 구조 (디테일) */}
|
||||||
|
{[-0.15, -0.05, 0.05, 0.15].map((offset, idx) => (
|
||||||
|
<Box
|
||||||
|
key={`truss-${idx}`}
|
||||||
|
args={[boxWidth * 0.02, boxHeight * 0.5, boxDepth * 0.02]}
|
||||||
|
position={[offset, boxHeight * 0.12, -boxDepth * 0.13]}
|
||||||
|
rotation={[Math.PI / 4.2, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.4} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* ========== 카운터웨이트 시스템 ========== */}
|
||||||
|
{/* 카운터웨이트 메인 블록 */}
|
||||||
|
<Box args={[boxWidth * 0.55, boxHeight * 0.25, boxDepth * 0.28]} position={[0, -boxHeight * 0.03, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#52525b"
|
||||||
|
roughness={0.7}
|
||||||
|
metalness={0.4}
|
||||||
|
emissive={isSelected ? "#52525b" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 카운터웨이트 추가 블록 (상단) */}
|
||||||
|
<Box args={[boxWidth * 0.48, boxHeight * 0.18, boxDepth * 0.22]} position={[0, boxHeight * 0.08, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial color="#3f3f46" roughness={0.7} metalness={0.4} />
|
||||||
|
</Box>
|
||||||
|
{/* 카운터웨이트 프레임 */}
|
||||||
|
<Box args={[boxWidth * 0.58, boxHeight * 0.08, boxDepth * 0.32]} position={[0, -boxHeight * 0.16, boxDepth * 0.32]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.5} metalness={0.7} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 후크 및 케이블 시스템 ========== */}
|
||||||
|
{/* 붐대 끝단 풀리 */}
|
||||||
|
<Box args={[boxWidth * 0.08, boxHeight * 0.08, boxDepth * 0.08]} position={[0, boxHeight * 0.52, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 메인 호이스트 케이블 */}
|
||||||
|
<Box args={[boxWidth * 0.025, boxHeight * 0.42, boxDepth * 0.025]} position={[0, boxHeight * 0.28, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#0f172a"
|
||||||
|
roughness={0.3}
|
||||||
|
metalness={0.7}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 후크 블록 상단 */}
|
||||||
|
<Box args={[boxWidth * 0.12, boxHeight * 0.08, boxDepth * 0.12]} position={[0, boxHeight * 0.04, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial color="#1f2937" roughness={0.3} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
{/* 후크 메인 (빨간색 안전색) */}
|
||||||
|
<Box args={[boxWidth * 0.1, boxHeight * 0.12, boxDepth * 0.1]} position={[0, -boxHeight * 0.02, -boxDepth * 0.52]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#dc2626"
|
||||||
|
roughness={0.25}
|
||||||
|
metalness={0.85}
|
||||||
|
emissive={isSelected ? "#dc2626" : "#000000"}
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 0.9 : 0}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* 지브 지지 케이블 (좌측) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||||
|
position={[-boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
{/* 지브 지지 케이블 (우측) */}
|
||||||
|
<Box
|
||||||
|
args={[boxWidth * 0.018, boxHeight * 0.38, boxDepth * 0.018]}
|
||||||
|
position={[boxWidth * 0.06, boxHeight * 0.28, -boxDepth * 0.08]}
|
||||||
|
rotation={[Math.PI / 5.5, 0, 0]}
|
||||||
|
>
|
||||||
|
<meshStandardMaterial color="#0f172a" roughness={0.2} metalness={0.8} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* ========== 조명 및 안전 장치 ========== */}
|
||||||
|
{/* 작업등 (전방) */}
|
||||||
|
<Box args={[boxWidth * 0.06, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.09, -boxDepth * 0.4]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#fef08a"
|
||||||
|
roughness={0.1}
|
||||||
|
metalness={0.9}
|
||||||
|
emissive="#fef08a"
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 1.2 : 0.3}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{/* 경고등 (붐대 상단) */}
|
||||||
|
<Box args={[boxWidth * 0.04, boxHeight * 0.04, boxDepth * 0.04]} position={[0, boxHeight * 0.54, -boxDepth * 0.5]}>
|
||||||
|
<meshStandardMaterial
|
||||||
|
color="#ef4444"
|
||||||
|
roughness={0.1}
|
||||||
|
metalness={0.9}
|
||||||
|
emissive="#ef4444"
|
||||||
|
emissiveIntensity={isSelected ? glowIntensity * 1.5 : 0.4}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</group>
|
||||||
|
);
|
||||||
|
|
||||||
Loading…
Reference in New Issue