Merge remote-tracking branch 'origin/main' into ksh
This commit is contained in:
commit
d04330283a
|
|
@ -73,6 +73,7 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
const [draggedTool, setDraggedTool] = useState<ToolType | null>(null);
|
||||
const [draggedAreaData, setDraggedAreaData] = useState<Area | null>(null); // 드래그 중인 Area 정보
|
||||
const [draggedLocationData, setDraggedLocationData] = useState<Location | null>(null); // 드래그 중인 Location 정보
|
||||
const [previewPosition, setPreviewPosition] = useState<{ x: number; z: number } | null>(null); // 드래그 프리뷰 위치
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
|
@ -656,13 +657,13 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// 캔버스에 드롭
|
||||
const handleCanvasDrop = (x: number, z: number) => {
|
||||
const handleCanvasDrop = async (x: number, z: number) => {
|
||||
if (!draggedTool) return;
|
||||
|
||||
const defaults = getToolDefaults(draggedTool);
|
||||
|
||||
// Area는 바닥(y=0.05)에, 다른 객체는 중앙 정렬
|
||||
const yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
let yPosition = draggedTool === "area" ? 0.05 : (defaults.size?.y || 1) / 2;
|
||||
|
||||
// 외부 DB 데이터에서 드래그한 경우 해당 정보 사용
|
||||
let objectName = defaults.name || "새 객체";
|
||||
|
|
@ -696,12 +697,51 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
externalKey = draggedLocationData.LOCAKEY;
|
||||
}
|
||||
|
||||
// 기본 크기 설정
|
||||
let objectSize = defaults.size || { x: 5, y: 5, z: 5 };
|
||||
|
||||
// Location 배치 시 자재 개수에 따라 높이 자동 설정
|
||||
if (
|
||||
(draggedTool === "location-bed" ||
|
||||
draggedTool === "location-stp" ||
|
||||
draggedTool === "location-temp" ||
|
||||
draggedTool === "location-dest") &&
|
||||
locaKey &&
|
||||
selectedDbConnection &&
|
||||
hierarchyConfig?.material
|
||||
) {
|
||||
try {
|
||||
// 해당 Location의 자재 개수 조회
|
||||
const countsResponse = await getMaterialCounts(selectedDbConnection, hierarchyConfig.material.tableName, [
|
||||
locaKey,
|
||||
]);
|
||||
|
||||
if (countsResponse.success && countsResponse.data && countsResponse.data.length > 0) {
|
||||
const materialCount = countsResponse.data[0].count;
|
||||
|
||||
// 자재 개수에 비례해서 높이(Y축) 설정 (최소 5, 최대 30)
|
||||
// 자재 1개 = 높이 5, 자재 10개 = 높이 15, 자재 50개 = 높이 30
|
||||
const calculatedHeight = Math.min(30, Math.max(5, 5 + materialCount * 0.5));
|
||||
|
||||
objectSize = {
|
||||
...objectSize,
|
||||
y: calculatedHeight, // Y축이 높이!
|
||||
};
|
||||
|
||||
// 높이가 높아진 만큼 Y 위치도 올려서 바닥을 뚫지 않게 조정
|
||||
yPosition = calculatedHeight / 2;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("자재 개수 조회 실패, 기본 높이 사용:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const newObject: PlacedObject = {
|
||||
id: nextObjectId,
|
||||
type: draggedTool,
|
||||
name: objectName,
|
||||
position: { x, y: yPosition, z },
|
||||
size: defaults.size || { x: 5, y: 5, z: 5 },
|
||||
size: objectSize,
|
||||
color: defaults.color || "#9ca3af",
|
||||
areaKey,
|
||||
locaKey,
|
||||
|
|
@ -739,9 +779,32 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return;
|
||||
}
|
||||
|
||||
// 부모 ID 설정
|
||||
// 부모 ID 설정 및 논리적 유효성 검사
|
||||
if (validation.parent) {
|
||||
// 1. 부모 객체 찾기
|
||||
const parentObj = placedObjects.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// 2. 논리적 키 검사 (DB에서 가져온 데이터인 경우)
|
||||
if (parentObj && parentObj.externalKey && newObject.parentKey) {
|
||||
if (parentObj.externalKey !== newObject.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${parentObj.externalKey})`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
newObject.parentId = validation.parent.id;
|
||||
} else if (newObject.parentKey) {
|
||||
// DB 데이터인데 부모 영역 위에 놓이지 않은 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "배치 오류",
|
||||
description: `이 Location은 '${newObject.parentKey}' Area 내부에 배치해야 합니다.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -770,7 +833,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
|
||||
// Location의 자재 목록 로드
|
||||
const loadMaterialsForLocation = async (locaKey: string) => {
|
||||
console.log("🔍 자재 조회 시작:", { locaKey, selectedDbConnection, material: hierarchyConfig?.material });
|
||||
|
||||
if (!selectedDbConnection || !hierarchyConfig?.material) {
|
||||
console.error("❌ 설정 누락:", { selectedDbConnection, material: hierarchyConfig?.material });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "자재 조회 실패",
|
||||
|
|
@ -782,10 +848,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
try {
|
||||
setLoadingMaterials(true);
|
||||
setShowMaterialPanel(true);
|
||||
const response = await getMaterials(selectedDbConnection, {
|
||||
|
||||
const materialConfig = {
|
||||
...hierarchyConfig.material,
|
||||
locaKey: locaKey,
|
||||
});
|
||||
};
|
||||
console.log("📡 API 호출:", { externalDbConnectionId: selectedDbConnection, materialConfig });
|
||||
|
||||
const response = await getMaterials(selectedDbConnection, materialConfig);
|
||||
console.log("📦 API 응답:", response);
|
||||
if (response.success && response.data) {
|
||||
// layerColumn이 있으면 정렬
|
||||
const sortedMaterials = hierarchyConfig.material.layerColumn
|
||||
|
|
@ -925,7 +996,59 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
return obj;
|
||||
});
|
||||
|
||||
// 2. 그룹 이동: 자식 객체들도 함께 이동
|
||||
// 2. 하위 계층 객체 이동 시 논리적 키 검증
|
||||
if (hierarchyConfig && targetObj.hierarchyLevel && targetObj.hierarchyLevel > 1) {
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
size: obj.size,
|
||||
hierarchyLevel: obj.hierarchyLevel || 1,
|
||||
parentId: obj.parentId,
|
||||
}));
|
||||
|
||||
const targetSpatialObj = spatialObjects.find((obj) => obj.id === objectId);
|
||||
if (targetSpatialObj) {
|
||||
const validation = validateSpatialContainment(
|
||||
targetSpatialObj,
|
||||
spatialObjects.filter((obj) => obj.id !== objectId),
|
||||
);
|
||||
|
||||
// 새로운 부모 영역 찾기
|
||||
if (validation.parent) {
|
||||
const newParentObj = prev.find((obj) => obj.id === validation.parent!.id);
|
||||
|
||||
// DB에서 가져온 데이터인 경우 논리적 키 검증
|
||||
if (newParentObj && newParentObj.externalKey && targetObj.parentKey) {
|
||||
if (newParentObj.externalKey !== targetObj.parentKey) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area에만 배치할 수 있습니다. (현재 선택된 Area: ${newParentObj.externalKey})`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
|
||||
// 부모 ID 업데이트
|
||||
updatedObjects = updatedObjects.map((obj) => {
|
||||
if (obj.id === objectId) {
|
||||
return { ...obj, parentId: validation.parent!.id };
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
} else if (targetObj.parentKey) {
|
||||
// DB 데이터인데 부모 영역 밖으로 이동하려는 경우
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "이동 불가",
|
||||
description: `이 Location은 '${targetObj.parentKey}' Area 내부에 있어야 합니다.`,
|
||||
});
|
||||
return prev; // 이동 취소
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 그룹 이동: 자식 객체들도 함께 이동
|
||||
const spatialObjects = updatedObjects.map((obj) => ({
|
||||
id: obj.id,
|
||||
position: obj.position,
|
||||
|
|
@ -1452,79 +1575,191 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 배치된 객체 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{/* 배치된 객체 목록 (계층 구조) */}
|
||||
<div className="flex-1 overflow-y-auto border-t 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>
|
||||
{obj.areaKey && <p className="text-muted-foreground mt-1 text-xs">Area: {obj.areaKey}</p>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Accordion type="multiple" className="w-full">
|
||||
{/* Area별로 그룹핑 */}
|
||||
{(() => {
|
||||
// Area 객체들
|
||||
const areaObjects = placedObjects.filter((obj) => obj.type === "area");
|
||||
|
||||
// Area가 없으면 기존 방식으로 표시
|
||||
if (areaObjects.length === 0) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// Area별로 Location들을 그룹핑
|
||||
return areaObjects.map((areaObj) => {
|
||||
// 이 Area의 자식 Location들 찾기
|
||||
const childLocations = placedObjects.filter(
|
||||
(obj) =>
|
||||
obj.type !== "area" &&
|
||||
obj.areaKey === areaObj.areaKey &&
|
||||
(obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey),
|
||||
);
|
||||
|
||||
return (
|
||||
<AccordionItem key={areaObj.id} value={`area-${areaObj.id}`} className="border-b">
|
||||
<AccordionTrigger className="px-2 py-3 hover:no-underline">
|
||||
<div
|
||||
className={`flex w-full items-center justify-between pr-2 ${
|
||||
selectedObject?.id === areaObj.id ? "text-primary font-semibold" : ""
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleObjectClick(areaObj.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Grid3x3 className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{areaObj.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">({childLocations.length})</span>
|
||||
<div className="h-3 w-3 rounded-full" style={{ backgroundColor: areaObj.color }} />
|
||||
</div>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-2 pb-3">
|
||||
{childLocations.length === 0 ? (
|
||||
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||
Location이 없습니다
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{childLocations.map((locationObj) => (
|
||||
<div
|
||||
key={locationObj.id}
|
||||
onClick={() => handleObjectClick(locationObj.id)}
|
||||
className={`cursor-pointer rounded-lg border p-2 transition-all ${
|
||||
selectedObject?.id === locationObj.id
|
||||
? "border-primary bg-primary/10"
|
||||
: "hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-3 w-3" />
|
||||
<span className="text-xs font-medium">{locationObj.name}</span>
|
||||
</div>
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: locationObj.color }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)})
|
||||
</p>
|
||||
{locationObj.locaKey && (
|
||||
<p className="text-muted-foreground mt-0.5 text-[10px]">
|
||||
Key: {locationObj.locaKey}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</Accordion>
|
||||
)}
|
||||
</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;
|
||||
{/* 중앙: 3D 캔버스 */}
|
||||
<div className="relative h-full flex-1 bg-gray-100">
|
||||
{isLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Yard3DCanvas
|
||||
placements={placements}
|
||||
selectedPlacementId={selectedObject?.id || null}
|
||||
onPlacementClick={(placement) => handleObjectClick(placement?.id || null)}
|
||||
onPlacementDrag={(id, position) => handleObjectMove(id, position.x, position.z, position.y)}
|
||||
focusOnPlacementId={null}
|
||||
onCollisionDetected={() => {}}
|
||||
previewTool={draggedTool}
|
||||
previewPosition={previewPosition}
|
||||
onPreviewPositionUpdate={setPreviewPosition}
|
||||
/>
|
||||
{/* 드래그 중일 때 Canvas 위에 투명한 오버레이 (프리뷰 및 드롭 이벤트 캐치용) */}
|
||||
{draggedTool && (
|
||||
<div
|
||||
className="pointer-events-auto absolute inset-0"
|
||||
style={{ zIndex: 10 }}
|
||||
onDragOver={(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;
|
||||
// 그리드 크기 (5 단위)
|
||||
const gridSize = 5;
|
||||
|
||||
// 그리드에 스냅
|
||||
// Area(20x20)는 그리드 교차점에, 다른 객체(5x5)는 타일 중앙에
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
// 그리드에 스냅
|
||||
let snappedX = Math.round(rawX / gridSize) * gridSize;
|
||||
let snappedZ = Math.round(rawZ / gridSize) * gridSize;
|
||||
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
snappedX += gridSize / 2;
|
||||
snappedZ += gridSize / 2;
|
||||
}
|
||||
// 5x5 객체는 타일 중앙으로 오프셋 (Area는 제외)
|
||||
if (draggedTool !== "area") {
|
||||
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={placements}
|
||||
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>
|
||||
setPreviewPosition({ x: snappedX, z: snappedZ });
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
setPreviewPosition(null);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (previewPosition) {
|
||||
handleCanvasDrop(previewPosition.x, previewPosition.z);
|
||||
setPreviewPosition(null);
|
||||
}
|
||||
setDraggedTool(null);
|
||||
setDraggedAreaData(null);
|
||||
setDraggedLocationData(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 객체 속성 편집 or 자재 목록 */}
|
||||
<div className="h-full w-[25%] overflow-y-auto border-l">
|
||||
|
|
|
|||
|
|
@ -67,12 +67,51 @@ export default function HierarchyConfigPanel({
|
|||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [columnsCache, setColumnsCache] = useState<{ [tableName: string]: string[] }>({});
|
||||
|
||||
// 외부에서 변경된 경우 동기화
|
||||
// 외부에서 변경된 경우 동기화 및 컬럼 자동 로드
|
||||
useEffect(() => {
|
||||
if (hierarchyConfig) {
|
||||
setLocalConfig(hierarchyConfig);
|
||||
|
||||
// 저장된 설정의 테이블들에 대한 컬럼 자동 로드
|
||||
const loadSavedColumns = async () => {
|
||||
const tablesToLoad: string[] = [];
|
||||
|
||||
// 창고 테이블
|
||||
if (hierarchyConfig.warehouse?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.warehouse.tableName);
|
||||
}
|
||||
|
||||
// 계층 레벨 테이블들
|
||||
hierarchyConfig.levels?.forEach((level) => {
|
||||
if (level.tableName) {
|
||||
tablesToLoad.push(level.tableName);
|
||||
}
|
||||
});
|
||||
|
||||
// 자재 테이블
|
||||
if (hierarchyConfig.material?.tableName) {
|
||||
tablesToLoad.push(hierarchyConfig.material.tableName);
|
||||
}
|
||||
|
||||
// 중복 제거 후 로드
|
||||
const uniqueTables = [...new Set(tablesToLoad)];
|
||||
for (const tableName of uniqueTables) {
|
||||
if (!columnsCache[tableName]) {
|
||||
try {
|
||||
const columns = await onLoadColumns(tableName);
|
||||
setColumnsCache((prev) => ({ ...prev, [tableName]: columns }));
|
||||
} catch (error) {
|
||||
console.error(`컬럼 로드 실패 (${tableName}):`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (externalDbConnectionId) {
|
||||
loadSavedColumns();
|
||||
}
|
||||
}
|
||||
}, [hierarchyConfig]);
|
||||
}, [hierarchyConfig, externalDbConnectionId]);
|
||||
|
||||
// 테이블 선택 시 컬럼 로드
|
||||
const handleTableChange = async (tableName: string, type: "warehouse" | "material" | number) => {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ interface Yard3DCanvasProps {
|
|||
gridSize?: number; // 그리드 크기 (기본값: 5)
|
||||
onCollisionDetected?: () => void; // 충돌 감지 시 콜백
|
||||
focusOnPlacementId?: number | null; // 카메라가 포커스할 요소 ID
|
||||
previewTool?: string | null; // 드래그 중인 도구 타입
|
||||
previewPosition?: { x: number; z: number } | null; // 프리뷰 위치
|
||||
onPreviewPositionUpdate?: (position: { x: number; z: number } | null) => void;
|
||||
}
|
||||
|
||||
// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일)
|
||||
|
|
@ -1007,10 +1010,26 @@ function Scene({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
}: Yard3DCanvasProps) {
|
||||
const [isDraggingAny, setIsDraggingAny] = useState(false);
|
||||
const orbitControlsRef = useRef<any>(null);
|
||||
|
||||
// 프리뷰 박스 크기 계산
|
||||
const getPreviewSize = (tool: string) => {
|
||||
if (tool === "area") return { x: 20, y: 0.1, z: 20 };
|
||||
return { x: 5, y: 5, z: 5 };
|
||||
};
|
||||
|
||||
// 프리뷰 박스 색상
|
||||
const getPreviewColor = (tool: string) => {
|
||||
if (tool === "area") return "#3b82f6";
|
||||
if (tool === "location-bed") return "#10b981";
|
||||
if (tool === "location-stp") return "#f59e0b";
|
||||
return "#9ca3af";
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 카메라 포커스 컨트롤러 */}
|
||||
|
|
@ -1069,6 +1088,30 @@ function Scene({
|
|||
/>
|
||||
))}
|
||||
|
||||
{/* 드래그 프리뷰 박스 */}
|
||||
{previewTool && previewPosition && (
|
||||
<Box
|
||||
args={[
|
||||
getPreviewSize(previewTool).x,
|
||||
getPreviewSize(previewTool).y,
|
||||
getPreviewSize(previewTool).z,
|
||||
]}
|
||||
position={[
|
||||
previewPosition.x,
|
||||
previewTool === "area" ? 0.05 : getPreviewSize(previewTool).y / 2,
|
||||
previewPosition.z,
|
||||
]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color={getPreviewColor(previewTool)}
|
||||
transparent
|
||||
opacity={0.4}
|
||||
roughness={0.5}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 카메라 컨트롤 */}
|
||||
<OrbitControls
|
||||
ref={orbitControlsRef}
|
||||
|
|
@ -1095,6 +1138,9 @@ export default function Yard3DCanvas({
|
|||
gridSize = 5,
|
||||
onCollisionDetected,
|
||||
focusOnPlacementId,
|
||||
previewTool,
|
||||
previewPosition,
|
||||
onPreviewPositionUpdate,
|
||||
}: Yard3DCanvasProps) {
|
||||
const handleCanvasClick = (e: any) => {
|
||||
// Canvas의 빈 공간을 클릭했을 때만 선택 해제
|
||||
|
|
@ -1123,6 +1169,8 @@ export default function Yard3DCanvas({
|
|||
gridSize={gridSize}
|
||||
onCollisionDetected={onCollisionDetected}
|
||||
focusOnPlacementId={focusOnPlacementId}
|
||||
previewTool={previewTool}
|
||||
previewPosition={previewPosition}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
|
|
|
|||
Loading…
Reference in New Issue