Merge pull request '3d 야드 수정' (#249) from lhj into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/249
This commit is contained in:
commit
6da1590430
|
|
@ -648,7 +648,14 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
description: `${loadedObjects.length}개의 객체를 불러왔습니다.`,
|
||||
});
|
||||
|
||||
// Location 객체들의 자재 개수 로드
|
||||
// Location 객체들의 자재 개수 로드 (직접 dbConnectionId와 materialTableName 전달)
|
||||
const dbConnectionId = layout.external_db_connection_id;
|
||||
const hierarchyConfigParsed =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
const materialTableName = hierarchyConfigParsed?.material?.tableName;
|
||||
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" ||
|
||||
|
|
@ -657,10 +664,10 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
obj.type === "location-dest") &&
|
||||
obj.locaKey,
|
||||
);
|
||||
if (locationObjects.length > 0) {
|
||||
if (locationObjects.length > 0 && dbConnectionId && materialTableName) {
|
||||
const locaKeys = locationObjects.map((obj) => obj.locaKey!);
|
||||
setTimeout(() => {
|
||||
loadMaterialCountsForLocations(locaKeys);
|
||||
loadMaterialCountsForLocations(locaKeys, dbConnectionId, materialTableName);
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1045,11 +1052,15 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
};
|
||||
|
||||
// Location별 자재 개수 로드 (locaKeys를 직접 받음)
|
||||
const loadMaterialCountsForLocations = async (locaKeys: string[]) => {
|
||||
if (!selectedDbConnection || locaKeys.length === 0) return;
|
||||
const loadMaterialCountsForLocations = async (locaKeys: string[], dbConnectionId?: number, materialTableName?: string) => {
|
||||
const connectionId = dbConnectionId || selectedDbConnection;
|
||||
const tableName = materialTableName || selectedTables.material;
|
||||
if (!connectionId || locaKeys.length === 0) return;
|
||||
|
||||
try {
|
||||
const response = await getMaterialCounts(selectedDbConnection, selectedTables.material, locaKeys);
|
||||
const response = await getMaterialCounts(connectionId, tableName, locaKeys);
|
||||
console.log("📊 자재 개수 API 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 각 Location 객체에 자재 개수 업데이트 (STP는 자재 미적재이므로 제외)
|
||||
setPlacedObjects((prev) =>
|
||||
|
|
@ -1060,13 +1071,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
|
|||
) {
|
||||
return obj;
|
||||
}
|
||||
const materialCount = response.data?.find((mc) => mc.LOCAKEY === obj.locaKey);
|
||||
// 백엔드 응답 필드명: location_key, count (대소문자 모두 체크)
|
||||
const materialCount = response.data?.find(
|
||||
(mc: any) =>
|
||||
mc.LOCAKEY === obj.locaKey ||
|
||||
mc.location_key === obj.locaKey ||
|
||||
mc.locakey === obj.locaKey
|
||||
);
|
||||
if (materialCount) {
|
||||
// count 또는 material_count 필드 사용
|
||||
const count = materialCount.count || materialCount.material_count || 0;
|
||||
const maxLayer = materialCount.max_layer || count;
|
||||
console.log(`📊 ${obj.locaKey}: 자재 ${count}개`);
|
||||
return {
|
||||
...obj,
|
||||
materialCount: materialCount.material_count,
|
||||
materialCount: Number(count),
|
||||
materialPreview: {
|
||||
height: materialCount.max_layer * 1.5, // 층당 1.5 높이 (시각적)
|
||||
height: maxLayer * 1.5, // 층당 1.5 높이 (시각적)
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,15 +54,17 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
|
||||
// 레이아웃 정보 저장 (snake_case와 camelCase 모두 지원)
|
||||
setLayoutName(layout.layout_name || layout.layoutName);
|
||||
setExternalDbConnectionId(layout.external_db_connection_id || layout.externalDbConnectionId);
|
||||
const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId;
|
||||
setExternalDbConnectionId(dbConnectionId);
|
||||
|
||||
// hierarchy_config 저장
|
||||
let hierarchyConfigData: any = null;
|
||||
if (layout.hierarchy_config) {
|
||||
const config =
|
||||
hierarchyConfigData =
|
||||
typeof layout.hierarchy_config === "string"
|
||||
? JSON.parse(layout.hierarchy_config)
|
||||
: layout.hierarchy_config;
|
||||
setHierarchyConfig(config);
|
||||
setHierarchyConfig(hierarchyConfigData);
|
||||
}
|
||||
|
||||
// 객체 데이터 변환
|
||||
|
|
@ -103,6 +105,47 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
|
|||
});
|
||||
|
||||
setPlacedObjects(loadedObjects);
|
||||
|
||||
// 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회
|
||||
if (dbConnectionId && hierarchyConfigData?.material) {
|
||||
const locationObjects = loadedObjects.filter(
|
||||
(obj) =>
|
||||
(obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") &&
|
||||
obj.locaKey
|
||||
);
|
||||
|
||||
// 각 Location에 대해 자재 개수 조회 (병렬 처리)
|
||||
const materialCountPromises = locationObjects.map(async (obj) => {
|
||||
try {
|
||||
const matResponse = await getMaterials(dbConnectionId, {
|
||||
tableName: hierarchyConfigData.material.tableName,
|
||||
keyColumn: hierarchyConfigData.material.keyColumn,
|
||||
locationKeyColumn: hierarchyConfigData.material.locationKeyColumn,
|
||||
layerColumn: hierarchyConfigData.material.layerColumn,
|
||||
locaKey: obj.locaKey!,
|
||||
});
|
||||
if (matResponse.success && matResponse.data) {
|
||||
return { id: obj.id, count: matResponse.data.length };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`자재 개수 조회 실패 (${obj.locaKey}):`, e);
|
||||
}
|
||||
return { id: obj.id, count: 0 };
|
||||
});
|
||||
|
||||
const materialCounts = await Promise.all(materialCountPromises);
|
||||
|
||||
// materialCount 업데이트
|
||||
setPlacedObjects((prev) =>
|
||||
prev.map((obj) => {
|
||||
const countData = materialCounts.find((m) => m.id === obj.id);
|
||||
if (countData && countData.count > 0) {
|
||||
return { ...obj, materialCount: countData.count };
|
||||
}
|
||||
return obj;
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || "레이아웃 조회 실패");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { Canvas, useThree } from "@react-three/fiber";
|
||||
import { OrbitControls, Grid, Box, Text } from "@react-three/drei";
|
||||
import { OrbitControls, Box, Text } from "@react-three/drei";
|
||||
import { Suspense, useRef, useState, useEffect, useMemo } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
|
|
@ -525,68 +525,77 @@ function MaterialBox({
|
|||
case "location-bed":
|
||||
case "location-temp":
|
||||
case "location-dest":
|
||||
// 베드 타입 Location: 초록색 상자
|
||||
// 베드 타입 Location: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||
const locPlateCount = placement.material_count || placement.quantity || 5; // 데이터 개수
|
||||
const locVisiblePlateCount = locPlateCount; // 데이터 개수만큼 모두 렌더링
|
||||
const locPlateThickness = 0.15; // 각 철판 두께
|
||||
const locPlateGap = 0.03; // 철판 사이 미세한 간격
|
||||
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||
const locVisibleStackHeight = locVisiblePlateCount * (locPlateThickness + locPlateGap);
|
||||
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||
const locYOffset = -placement.position_y;
|
||||
const locPlateBaseY = locYOffset + locPlateThickness / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box args={[boxWidth, boxHeight, boxDepth]}>
|
||||
<meshStandardMaterial
|
||||
color={placement.color}
|
||||
roughness={0.5}
|
||||
metalness={0.3}
|
||||
emissive={isSelected ? placement.color : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.8 : 0}
|
||||
/>
|
||||
</Box>
|
||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||
{Array.from({ length: locVisiblePlateCount }).map((_, idx) => {
|
||||
const yPos = locPlateBaseY + idx * (locPlateThickness + locPlateGap);
|
||||
// 약간의 랜덤 오프셋으로 자연스러움 추가
|
||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||
|
||||
{/* 대표 자재 스택 (자재가 있을 때만) */}
|
||||
{placement.material_count !== undefined &&
|
||||
placement.material_count > 0 &&
|
||||
placement.material_preview_height && (
|
||||
return (
|
||||
<Box
|
||||
args={[boxWidth * 0.7, placement.material_preview_height, boxDepth * 0.7]}
|
||||
position={[0, boxHeight / 2 + placement.material_preview_height / 2, 0]}
|
||||
key={`loc-plate-${idx}`}
|
||||
args={[boxWidth, locPlateThickness, boxDepth]}
|
||||
position={[xOffset, yPos, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial
|
||||
color="#ef4444"
|
||||
roughness={0.6}
|
||||
metalness={0.2}
|
||||
emissive={isSelected ? "#ef4444" : "#000000"}
|
||||
color="#6b7280" // 회색 (고정)
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||
transparent
|
||||
opacity={0.7}
|
||||
/>
|
||||
{/* 각 철판 외곽선 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, locPlateThickness, boxDepth)]} />
|
||||
<lineBasicMaterial color="#374151" opacity={0.8} transparent />
|
||||
</lineSegments>
|
||||
</Box>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Location 이름 */}
|
||||
{/* Location 이름 - 실제 폴리곤 높이 기준, 뒤쪽(+Z)에 배치 */}
|
||||
{placement.name && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.3, 0]}
|
||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#ffffff"
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||
color="#374151"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{placement.name}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 자재 개수 */}
|
||||
{placement.material_count !== undefined && placement.material_count > 0 && (
|
||||
{/* 수량 표시 텍스트 - 실제 폴리곤 높이 기준, 앞쪽(-Z)에 배치 */}
|
||||
{locPlateCount > 0 && (
|
||||
<Text
|
||||
position={[0, boxHeight / 2 + 0.6, 0]}
|
||||
position={[0, locYOffset + locVisibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.12}
|
||||
color="#fbbf24"
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#1f2937"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#000000"
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{`자재: ${placement.material_count}개`}
|
||||
{`${locPlateCount}장`}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
|
|
@ -886,83 +895,79 @@ function MaterialBox({
|
|||
|
||||
case "plate-stack":
|
||||
default:
|
||||
// 후판 스택: 팔레트 + 박스 (기존 렌더링)
|
||||
// 후판 스택: 회색 철판들이 데이터 개수만큼 쌓이는 형태
|
||||
const plateCount = placement.material_count || placement.quantity || 5; // 데이터 개수 (기본 5장)
|
||||
const visiblePlateCount = plateCount; // 데이터 개수만큼 모두 렌더링
|
||||
const plateThickness = 0.15; // 각 철판 두께
|
||||
const plateGap = 0.03; // 철판 사이 미세한 간격
|
||||
// 실제 렌더링되는 폴리곤 기준으로 높이 계산
|
||||
const visibleStackHeight = visiblePlateCount * (plateThickness + plateGap);
|
||||
// 그룹의 position_y를 상쇄해서 바닥(y=0)부터 시작하도록
|
||||
const yOffset = -placement.position_y;
|
||||
const plateBaseY = yOffset + plateThickness / 2;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 팔레트 그룹 - 박스 하단에 붙어있도록 */}
|
||||
<group position={[0, palletYOffset, 0]}>
|
||||
{/* 상단 가로 판자들 (5개) */}
|
||||
{[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => (
|
||||
{/* 철판 스택 - 데이터 개수만큼 회색 판 쌓기 (최대 20개) */}
|
||||
{Array.from({ length: visiblePlateCount }).map((_, idx) => {
|
||||
const yPos = plateBaseY + idx * (plateThickness + plateGap);
|
||||
// 약간의 랜덤 오프셋으로 자연스러움 추가 (실제 철판처럼)
|
||||
const xOffset = (Math.sin(idx * 0.5) * 0.02);
|
||||
const zOffset = (Math.cos(idx * 0.7) * 0.02);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={`top-${idx}`}
|
||||
args={[boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15]}
|
||||
position={[0, palletHeight * 0.35, zOffset]}
|
||||
key={`plate-${idx}`}
|
||||
args={[boxWidth, plateThickness, boxDepth]}
|
||||
position={[xOffset, yPos, zOffset]}
|
||||
>
|
||||
<meshStandardMaterial color="#8B4513" roughness={0.95} metalness={0.0} />
|
||||
<meshStandardMaterial
|
||||
color="#6b7280" // 회색 (고정)
|
||||
roughness={0.4}
|
||||
metalness={0.7}
|
||||
emissive={isSelected ? "#9ca3af" : "#000000"}
|
||||
emissiveIntensity={isSelected ? glowIntensity * 0.3 : 0}
|
||||
/>
|
||||
{/* 각 철판 외곽선 */}
|
||||
<lineSegments>
|
||||
<edgesGeometry
|
||||
args={[new THREE.BoxGeometry(boxWidth * 0.95, palletHeight * 0.3, boxDepth * 0.15)]}
|
||||
/>
|
||||
<lineBasicMaterial color="#000000" opacity={0.3} transparent />
|
||||
<edgesGeometry args={[new THREE.BoxGeometry(boxWidth, plateThickness, boxDepth)]} />
|
||||
<lineBasicMaterial color="#374151" opacity={0.8} 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>
|
||||
))}
|
||||
{/* 수량 표시 텍스트 (상단) - 앞쪽(-Z)에 배치 */}
|
||||
{plateCount > 0 && (
|
||||
<Text
|
||||
position={[0, yOffset + visibleStackHeight + 0.3, -boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.18}
|
||||
color="#374151"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.03}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{`${plateCount}장`}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 하단 가로 판자들 (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>
|
||||
{/* 자재명 표시 (있는 경우) - 뒤쪽(+Z)에 배치 */}
|
||||
{placement.material_name && (
|
||||
<Text
|
||||
position={[0, yOffset + visibleStackHeight + 0.3, boxDepth * 0.3]}
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
fontSize={Math.min(boxWidth, boxDepth) * 0.15}
|
||||
color="#1f2937"
|
||||
anchorX="center"
|
||||
anchorY="middle"
|
||||
outlineWidth={0.02}
|
||||
outlineColor="#ffffff"
|
||||
>
|
||||
{placement.material_name}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1114,20 +1119,11 @@ function Scene({
|
|||
{/* 배경색 */}
|
||||
<color attach="background" args={["#f3f4f6"]} />
|
||||
|
||||
{/* 바닥 그리드 (타일을 4등분) */}
|
||||
<Grid
|
||||
args={[100, 100]}
|
||||
cellSize={gridSize / 2} // 타일을 2x2로 나눔 (2.5칸)
|
||||
cellThickness={0.6}
|
||||
cellColor="#d1d5db" // 얇은 선 (서브 그리드) - 밝은 회색
|
||||
sectionSize={gridSize} // 타일 경계선 (5칸마다)
|
||||
sectionThickness={1.5}
|
||||
sectionColor="#6b7280" // 타일 경계는 조금 어둡게
|
||||
fadeDistance={200}
|
||||
fadeStrength={1}
|
||||
followCamera={false}
|
||||
infiniteGrid={true}
|
||||
/>
|
||||
{/* 바닥 - 단색 평면 (그리드 제거) */}
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.01, 0]}>
|
||||
<planeGeometry args={[200, 200]} />
|
||||
<meshStandardMaterial color="#e5e7eb" roughness={0.9} metalness={0.1} />
|
||||
</mesh>
|
||||
|
||||
{/* 자재 박스들 */}
|
||||
{placements.map((placement) => (
|
||||
|
|
|
|||
Loading…
Reference in New Issue