From f0bb349c8c5f757d318e3ab32f9e43650c4a5cc6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 11:16:54 +0900 Subject: [PATCH 1/6] =?UTF-8?q?3d=EC=9A=94=EC=86=8C=EC=97=90=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=EC=8A=A4=EB=83=85=20=EC=8B=9C=EC=8A=A4?= =?UTF-8?q?=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/Yard3DCanvas.tsx | 186 +++++++++++++++--- .../dashboard/widgets/yard-3d/YardEditor.tsx | 76 ++++++- 2 files changed, 228 insertions(+), 34 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 29c15ca9..1e57ce85 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -29,6 +29,19 @@ interface Yard3DCanvasProps { selectedPlacementId: number | null; onPlacementClick: (placement: YardPlacement | null) => void; onPlacementDrag?: (id: number, position: { x: number; y: number; z: number }) => void; + gridSize?: number; // 그리드 크기 (기본값: 5) + onCollisionDetected?: () => void; // 충돌 감지 시 콜백 +} + +// 좌표를 그리드 칸의 중심에 스냅 (마인크래프트 스타일) +// Three.js Box의 position은 중심점이므로, 그리드 칸의 중심에 배치해야 칸에 딱 맞음 +function snapToGrid(value: number, gridSize: number): number { + // 가장 가까운 그리드 칸 찾기 + const gridIndex = Math.round(value / gridSize); + // 그리드 칸의 중심점 반환 + // gridSize=5일 때: ..., -7.5, -2.5, 2.5, 7.5, 12.5, 17.5... + // 이렇게 하면 Box가 칸 안에 정확히 들어감 + return gridIndex * gridSize + gridSize / 2; } // 자재 박스 컴포넌트 (드래그 가능) @@ -39,6 +52,9 @@ function MaterialBox({ onDrag, onDragStart, onDragEnd, + gridSize = 5, + allPlacements = [], + onCollisionDetected, }: { placement: YardPlacement; isSelected: boolean; @@ -46,17 +62,70 @@ function MaterialBox({ onDrag?: (position: { x: number; y: number; z: number }) => void; onDragStart?: () => void; onDragEnd?: () => void; + gridSize?: number; + allPlacements?: YardPlacement[]; + onCollisionDetected?: () => void; }) { const meshRef = useRef(null); const [isDragging, setIsDragging] = useState(false); + const [isValidPosition, setIsValidPosition] = useState(true); // 배치 가능 여부 (시각 피드백용) 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 { camera, gl } = useThree(); - // 드래그 중이 아닐 때 위치 업데이트 + // 특정 좌표에 다른 요소가 있는지 확인 (AABB 충돌 감지) + const checkCollision = (x: number, z: number): boolean => { + const mySize = placement.size_x || gridSize; // 내 크기 (5) + const myHalfSize = mySize / 2; // 2.5 + + return allPlacements.some((p) => { + // 자기 자신은 제외 (엄격한 비교) + if (Number(p.id) === Number(placement.id)) { + return false; + } + + const pSize = p.size_x || gridSize; // 상대방 크기 (5) + const pHalfSize = pSize / 2; // 2.5 + + // AABB (Axis-Aligned Bounding Box) 충돌 감지 + // 두 박스가 겹치는지 확인 + const isOverlapping = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침 + + if (isOverlapping) { + console.log("🔴 충돌 감지! (AABB)", { + current: `${placement.id} at (${x}, ${z}) size=${mySize}`, + blocking: `${p.id} at (${p.position_x}, ${p.position_z}) size=${pSize}`, + distance: { + x: Math.abs(x - p.position_x), + z: Math.abs(z - p.position_z), + }, + }); + } + + return isOverlapping; + }); + }; + + // 드래그 중이 아닐 때만 위치 동기화 useEffect(() => { if (!isDragging && meshRef.current) { - meshRef.current.position.set(placement.position_x, placement.position_y, placement.position_z); + const currentPos = meshRef.current.position; + const targetX = placement.position_x; + const targetY = placement.position_y; + const targetZ = placement.position_z; + + // 현재 위치와 목표 위치가 다를 때만 업데이트 (0.01 이상 차이) + const threshold = 0.01; + const needsUpdate = + Math.abs(currentPos.x - targetX) > threshold || + Math.abs(currentPos.y - targetY) > threshold || + Math.abs(currentPos.z - targetZ) > threshold; + + if (needsUpdate) { + meshRef.current.position.set(targetX, targetY, targetZ); + } } }, [placement.position_x, placement.position_y, placement.position_z, isDragging]); @@ -90,28 +159,76 @@ function MaterialBox({ .multiplyScalar(deltaY * scaleFactor); // 최종 위치 계산 - const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + let finalX = dragStartPos.current.x + moveRight.x + moveForward.x; + let finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; // NaN 검증 if (isNaN(finalX) || isNaN(finalZ)) { return; } - // 즉시 mesh 위치 업데이트 (부드러운 드래그) + // 그리드에 스냅 + const snappedX = snapToGrid(finalX, gridSize); + const snappedZ = snapToGrid(finalZ, gridSize); + + // 충돌 체크 (시각 피드백용 - 실제 차단은 마우스 업 시) + const hasCollision = checkCollision(snappedX, snappedZ); + setIsValidPosition(!hasCollision); + + // 즉시 mesh 위치 업데이트 (부드러운 드래그 - 스냅되기 전 위치) meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ); - // 상태 업데이트 (저장용) - onDrag({ - x: finalX, - y: dragStartPos.current.y, - z: finalZ, - }); + // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만) + // 실제 저장은 handleGlobalMouseUp에서만 수행 } }; const handleGlobalMouseUp = () => { - if (isDragging) { + if (isDragging && meshRef.current) { + const currentPos = meshRef.current.position; + + // 실제로 이동했는지 확인 (최소 이동 거리: 0.1) + const minMovement = 0.1; + const deltaX = Math.abs(currentPos.x - dragStartPos.current.x); + const deltaZ = Math.abs(currentPos.z - dragStartPos.current.z); + const hasMoved = deltaX > minMovement || deltaZ > minMovement; + + if (hasMoved) { + // 실제로 드래그한 경우: 그리드에 스냅 + const snappedX = snapToGrid(currentPos.x, gridSize); + const snappedZ = snapToGrid(currentPos.z, gridSize); + + // 충돌 체크: 최종 위치에서만 체크 (AABB 방식) + const hasCollision = checkCollision(snappedX, snappedZ); + + if (hasCollision) { + // ⛔ 충돌 시: 원래 위치로 되돌리고 저장 안 함 + console.log("⛔ 충돌! 원래 위치로 복원:", dragStartPos.current); + meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z); + setIsValidPosition(true); + // 충돌 감지 콜백 호출 (Toast 알림) + if (onCollisionDetected) { + onCollisionDetected(); + } + // ⚠️ 중요: onDrag 호출하지 않음 (상태 업데이트 안 함) + } else { + // ✅ 충돌 없음: 스냅된 위치로 최종 설정하고 저장 + console.log("✅ 충돌 없음! 저장:", { x: snappedX, y: dragStartPos.current.y, z: snappedZ }); + meshRef.current.position.set(snappedX, dragStartPos.current.y, snappedZ); + // 최종 위치 저장 (이것만 실제 상태 업데이트) + if (onDrag) { + onDrag({ + x: snappedX, + y: dragStartPos.current.y, + z: snappedZ, + }); + } + } + } else { + // 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함) + meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z); + } + setIsDragging(false); gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; if (onDragEnd) { @@ -141,11 +258,12 @@ function MaterialBox({ // 편집 모드에서 선택되었고 드래그 가능한 경우 if (isSelected && meshRef.current) { - // 드래그 시작 시점의 자재 위치 저장 (숫자로 변환) + // 드래그 시작 시점의 mesh 실제 위치 저장 (현재 렌더링된 위치) + const currentPos = meshRef.current.position; dragStartPos.current = { - x: Number(placement.position_x), - y: Number(placement.position_y), - z: Number(placement.position_z), + x: currentPos.x, + y: currentPos.y, + z: currentPos.z, }; // 마우스 시작 위치 저장 @@ -192,11 +310,11 @@ function MaterialBox({ }} > @@ -204,7 +322,14 @@ function MaterialBox({ } // 3D 씬 컴포넌트 -function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementDrag }: Yard3DCanvasProps) { +function Scene({ + placements, + selectedPlacementId, + onPlacementClick, + onPlacementDrag, + gridSize = 5, + onCollisionDetected, +}: Yard3DCanvasProps) { const [isDraggingAny, setIsDraggingAny] = useState(false); const orbitControlsRef = useRef(null); @@ -215,15 +340,15 @@ function Scene({ placements, selectedPlacementId, onPlacementClick, onPlacementD - {/* 바닥 그리드 */} + {/* 바닥 그리드 (타일을 4등분) */} ))} @@ -273,6 +401,8 @@ export default function Yard3DCanvas({ selectedPlacementId, onPlacementClick, onPlacementDrag, + gridSize = 5, + onCollisionDetected, }: Yard3DCanvasProps) { const handleCanvasClick = (e: any) => { // Canvas의 빈 공간을 클릭했을 때만 선택 해제 @@ -297,6 +427,8 @@ export default function Yard3DCanvas({ selectedPlacementId={selectedPlacementId} onPlacementClick={onPlacementClick} onPlacementDrag={onPlacementDrag} + gridSize={gridSize} + onCollisionDetected={onCollisionDetected} /> diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 41c68af5..973a151e 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -7,10 +7,11 @@ import { yardLayoutApi } from "@/lib/api/yardLayoutApi"; import dynamic from "next/dynamic"; import { YardLayout, YardPlacement } from "./types"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { AlertCircle, CheckCircle } from "lucide-react"; +import { AlertCircle, CheckCircle, XCircle } from "lucide-react"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -33,6 +34,7 @@ interface YardEditorProps { } export default function YardEditor({ layout, onBack }: YardEditorProps) { + const { toast } = useToast(); const [placements, setPlacements] = useState([]); const [originalPlacements, setOriginalPlacements] = useState([]); // 원본 데이터 보관 const [selectedPlacement, setSelectedPlacement] = useState(null); @@ -78,8 +80,60 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { loadPlacements(); }, [layout.id]); + // 빈 공간 찾기 (그리드 기반) + const findEmptyGridPosition = (gridSize = 5) => { + // 이미 사용 중인 좌표 Set + const occupiedPositions = new Set( + placements.map((p) => { + const x = Math.round(p.position_x / gridSize) * gridSize; + const z = Math.round(p.position_z / gridSize) * gridSize; + return `${x},${z}`; + }), + ); + + // 나선형으로 빈 공간 찾기 + let x = 0; + let z = 0; + let direction = 0; // 0: 우, 1: 하, 2: 좌, 3: 상 + let steps = 1; + let stepsTaken = 0; + let stepsInDirection = 0; + + for (let i = 0; i < 1000; i++) { + const key = `${x},${z}`; + if (!occupiedPositions.has(key)) { + return { x, z }; + } + + // 다음 위치로 이동 + stepsInDirection++; + if (direction === 0) + x += gridSize; // 우 + else if (direction === 1) + z += gridSize; // 하 + else if (direction === 2) + x -= gridSize; // 좌 + else z -= gridSize; // 상 + + if (stepsInDirection >= steps) { + stepsInDirection = 0; + direction = (direction + 1) % 4; + stepsTaken++; + if (stepsTaken === 2) { + stepsTaken = 0; + steps++; + } + } + } + + return { x: 0, z: 0 }; + }; + // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) const handleAddElement = () => { + const gridSize = 5; + const emptyPos = findEmptyGridPosition(gridSize); + const newPlacement: YardPlacement = { id: nextPlacementId, // 임시 음수 ID yard_layout_id: layout.id, @@ -87,12 +141,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { material_name: null, quantity: null, unit: null, - position_x: 0, - position_y: 2.5, - position_z: 0, - size_x: 5, - size_y: 5, - size_z: 5, + // 그리드 칸의 중심에 배치 (Three.js Box position은 중심점) + position_x: emptyPos.x + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... + position_y: gridSize / 2, // 요소 높이의 절반 (바닥에서 시작) + position_z: emptyPos.z + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... + size_x: gridSize, + size_y: gridSize, + size_z: gridSize, color: "#9ca3af", data_source_type: null, data_source_config: null, @@ -358,6 +413,13 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { selectedPlacementId={selectedPlacement?.id || null} onPlacementClick={(placement) => handleSelectPlacement(placement as YardPlacement)} onPlacementDrag={handlePlacementDrag} + onCollisionDetected={() => { + toast({ + title: "배치 불가", + description: "해당 위치에 이미 다른 요소가 있습니다.", + variant: "destructive", + }); + }} /> )} From 3b5f0b638f32a9c8f6bca7409528763e09005b04 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 11:40:11 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EC=A4=91=EB=A0=A5=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9A=94=EC=86=8C=20=EC=8C=93=EA=B8=B0=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/Yard3DCanvas.tsx | 110 +++++++++--------- .../dashboard/widgets/yard-3d/YardEditor.tsx | 52 ++++++++- 2 files changed, 109 insertions(+), 53 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 1e57ce85..bcbded34 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -73,39 +73,50 @@ function MaterialBox({ const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const { camera, gl } = useThree(); - // 특정 좌표에 다른 요소가 있는지 확인 (AABB 충돌 감지) - const checkCollision = (x: number, z: number): boolean => { + // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정 + const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { const mySize = placement.size_x || gridSize; // 내 크기 (5) const myHalfSize = mySize / 2; // 2.5 + const mySizeY = placement.size_y || gridSize; // 내 높이 (5) - return allPlacements.some((p) => { - // 자기 자신은 제외 (엄격한 비교) + let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) + + for (const p of allPlacements) { + // 자기 자신은 제외 if (Number(p.id) === Number(placement.id)) { - return false; + continue; } const pSize = p.size_x || gridSize; // 상대방 크기 (5) const pHalfSize = pSize / 2; // 2.5 + const pSizeY = p.size_y || gridSize; // 상대방 높이 (5) - // AABB (Axis-Aligned Bounding Box) 충돌 감지 - // 두 박스가 겹치는지 확인 - const isOverlapping = + // XZ 평면에서 겹치는지 확인 + const isXZOverlapping = Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침 Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침 - if (isOverlapping) { - console.log("🔴 충돌 감지! (AABB)", { - current: `${placement.id} at (${x}, ${z}) size=${mySize}`, - blocking: `${p.id} at (${p.position_x}, ${p.position_z}) size=${pSize}`, - distance: { - x: Math.abs(x - p.position_x), - z: Math.abs(z - p.position_z), - }, - }); - } + if (isXZOverlapping) { + // 같은 XZ 위치에 요소가 있음 + // 그 요소의 윗면 높이 계산 (중심 + 높이/2) + const topOfOtherElement = p.position_y + pSizeY / 2; + // 내가 올라갈 Y 위치는 윗면 + 내 높이/2 + const myYOnTop = topOfOtherElement + mySizeY / 2; - return isOverlapping; - }); + // 가장 높은 위치 기록 + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; + } + } + } + + // 요청한 Y와 조정된 Y가 다르면 충돌로 간주 (위로 올려야 함) + const needsAdjustment = Math.abs(y - maxYBelow) > 0.1; + + return { + hasCollision: needsAdjustment, + adjustedY: maxYBelow, + }; }; // 드래그 중이 아닐 때만 위치 동기화 @@ -159,8 +170,8 @@ function MaterialBox({ .multiplyScalar(deltaY * scaleFactor); // 최종 위치 계산 - let finalX = dragStartPos.current.x + moveRight.x + moveForward.x; - let finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; + const finalX = dragStartPos.current.x + moveRight.x + moveForward.x; + const finalZ = dragStartPos.current.z + moveRight.z + moveForward.z; // NaN 검증 if (isNaN(finalX) || isNaN(finalZ)) { @@ -171,12 +182,14 @@ function MaterialBox({ const snappedX = snapToGrid(finalX, gridSize); const snappedZ = snapToGrid(finalZ, gridSize); - // 충돌 체크 (시각 피드백용 - 실제 차단은 마우스 업 시) - const hasCollision = checkCollision(snappedX, snappedZ); - setIsValidPosition(!hasCollision); + // 충돌 체크 및 Y 위치 조정 (시각 피드백용) + const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); - // 즉시 mesh 위치 업데이트 (부드러운 드래그 - 스냅되기 전 위치) - meshRef.current.position.set(finalX, dragStartPos.current.y, finalZ); + // 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문) + setIsValidPosition(true); + + // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) + meshRef.current.position.set(finalX, adjustedY, finalZ); // ⚠️ 드래그 중에는 상태 업데이트 안 함 (미리보기만) // 실제 저장은 handleGlobalMouseUp에서만 수행 @@ -198,31 +211,20 @@ function MaterialBox({ const snappedX = snapToGrid(currentPos.x, gridSize); const snappedZ = snapToGrid(currentPos.z, gridSize); - // 충돌 체크: 최종 위치에서만 체크 (AABB 방식) - const hasCollision = checkCollision(snappedX, snappedZ); + // Y 위치 조정 (마인크래프트처럼 쌓기) + const { adjustedY } = checkCollisionAndAdjustY(snappedX, currentPos.y, snappedZ); - if (hasCollision) { - // ⛔ 충돌 시: 원래 위치로 되돌리고 저장 안 함 - console.log("⛔ 충돌! 원래 위치로 복원:", dragStartPos.current); - meshRef.current.position.set(dragStartPos.current.x, dragStartPos.current.y, dragStartPos.current.z); - setIsValidPosition(true); - // 충돌 감지 콜백 호출 (Toast 알림) - if (onCollisionDetected) { - onCollisionDetected(); - } - // ⚠️ 중요: onDrag 호출하지 않음 (상태 업데이트 안 함) - } else { - // ✅ 충돌 없음: 스냅된 위치로 최종 설정하고 저장 - console.log("✅ 충돌 없음! 저장:", { x: snappedX, y: dragStartPos.current.y, z: snappedZ }); - meshRef.current.position.set(snappedX, dragStartPos.current.y, snappedZ); - // 최종 위치 저장 (이것만 실제 상태 업데이트) - if (onDrag) { - onDrag({ - x: snappedX, - y: dragStartPos.current.y, - z: snappedZ, - }); - } + // ✅ 항상 배치 가능 (위로 올라가므로) + console.log("✅ 배치 완료! 저장:", { x: snappedX, y: adjustedY, z: snappedZ }); + meshRef.current.position.set(snappedX, adjustedY, snappedZ); + + // 최종 위치 저장 (조정된 Y 위치로) + if (onDrag) { + onDrag({ + x: snappedX, + y: adjustedY, + z: snappedZ, + }); } } else { // 클릭만 한 경우: 원래 위치 유지 (아무것도 안 함) @@ -387,10 +389,14 @@ function Scene({ enablePan={true} enableZoom={true} enableRotate={true} - minDistance={10} + minDistance={8} maxDistance={200} maxPolarAngle={Math.PI / 2} enabled={!isDraggingAny} + reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동) + screenSpacePanning={true} // 화면 공간 패닝 + panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림) + rotateSpeed={0.5} // 회전 속도 /> ); diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 973a151e..21435a16 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -180,12 +180,62 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setDeleteConfirmDialog({ open: true, placementId }); }; + // 중력 적용: 삭제된 요소 위에 있던 요소들을 아래로 내림 + const applyGravity = (deletedPlacement: YardPlacement, remainingPlacements: YardPlacement[]) => { + const gridSize = 5; + const halfSize = gridSize / 2; + + return remainingPlacements.map((p) => { + // 삭제된 요소와 XZ가 겹치는지 확인 + const isXZOverlapping = + Math.abs(p.position_x - deletedPlacement.position_x) < gridSize && + Math.abs(p.position_z - deletedPlacement.position_z) < gridSize; + + // 삭제된 요소보다 위에 있는지 확인 + const isAbove = p.position_y > deletedPlacement.position_y; + + if (isXZOverlapping && isAbove) { + // 아래로 내림: 삭제된 요소의 크기만큼 + const fallDistance = deletedPlacement.size_y || gridSize; + const newY = Math.max(halfSize, p.position_y - fallDistance); // 바닥(2.5) 아래로는 안 내려감 + + return { + ...p, + position_y: newY, + }; + } + + return p; + }); + }; + // 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영) const confirmDeletePlacement = () => { const { placementId } = deleteConfirmDialog; if (placementId === null) return; - setPlacements((prev) => prev.filter((p) => p.id !== placementId)); + setPlacements((prev) => { + const deletedPlacement = prev.find((p) => p.id === placementId); + if (!deletedPlacement) return prev; + + // 삭제 후 남은 요소들 + const remaining = prev.filter((p) => p.id !== placementId); + + // 중력 적용 (재귀적으로 계속 적용) + let result = remaining; + let hasChanges = true; + + // 모든 요소가 안정될 때까지 반복 + while (hasChanges) { + const before = JSON.stringify(result.map((p) => p.position_y)); + result = applyGravity(deletedPlacement, result); + const after = JSON.stringify(result.map((p) => p.position_y)); + hasChanges = before !== after; + } + + return result; + }); + if (selectedPlacement?.id === placementId) { setSelectedPlacement(null); setShowConfigPanel(false); From 189f0e03a05daaf64b96351fa4da9b37c2633387 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 12:02:15 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=83=88=EC=9A=94=EC=86=8C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EC=8B=9C=EC=97=90=EB=8F=84=20=EC=9C=84=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AC=EB=A6=AC=EA=B8=B0=20=EC=B2=B4=ED=81=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/yard-3d/YardEditor.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 21435a16..87319916 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -129,10 +129,39 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { return { x: 0, z: 0 }; }; + // 특정 XZ 위치에 배치할 때 적절한 Y 위치 계산 (마인크래프트 쌓기) + const calculateYPosition = (x: number, z: number, existingPlacements: YardPlacement[]) => { + const gridSize = 5; + const halfSize = gridSize / 2; + let maxY = halfSize; // 기본 바닥 높이 (2.5) + + for (const p of existingPlacements) { + // XZ가 겹치는지 확인 + const isXZOverlapping = Math.abs(x - p.position_x) < gridSize && Math.abs(z - p.position_z) < gridSize; + + if (isXZOverlapping) { + // 이 요소의 윗면 높이 + const topY = p.position_y + (p.size_y || gridSize) / 2; + // 새 요소의 Y 위치 (윗면 + 새 요소 높이/2) + const newY = topY + gridSize / 2; + if (newY > maxY) { + maxY = newY; + } + } + } + + return maxY; + }; + // 빈 요소 추가 (로컬 상태에만 추가, 저장 시 서버에 반영) const handleAddElement = () => { const gridSize = 5; const emptyPos = findEmptyGridPosition(gridSize); + const centerX = emptyPos.x + gridSize / 2; + const centerZ = emptyPos.z + gridSize / 2; + + // 해당 위치에 적절한 Y 계산 (쌓기) + const appropriateY = calculateYPosition(centerX, centerZ, placements); const newPlacement: YardPlacement = { id: nextPlacementId, // 임시 음수 ID @@ -142,9 +171,9 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { quantity: null, unit: null, // 그리드 칸의 중심에 배치 (Three.js Box position은 중심점) - position_x: emptyPos.x + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... - position_y: gridSize / 2, // 요소 높이의 절반 (바닥에서 시작) - position_z: emptyPos.z + gridSize / 2, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... + position_x: centerX, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... + position_y: appropriateY, // 쌓기 고려한 Y 위치 + position_z: centerZ, // 칸 중심: 0→2.5, 5→7.5, 10→12.5... size_x: gridSize, size_y: gridSize, size_z: gridSize, From bc36c007121c52076d2c1d20e61ff69fe6a50a1f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 13:20:31 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=A0=9C=EB=AA=A9=20=ED=95=9C=20=EA=B0=9C=EB=A7=8C?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/ListWidget.tsx | 5 ----- .../dashboard/widgets/CustomMetricWidget.tsx | 15 ++++++++------- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 378d8825..252831c5 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -217,11 +217,6 @@ export function ListWidget({ element }: ListWidgetProps) { return (
- {/* 제목 - 항상 표시 */} -
-

{element.customTitle || element.title}

-
- {/* 테이블 뷰 */} {config.viewMode === "table" && (
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index d97ec05f..3b6e5d92 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -351,7 +351,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps)
  • • 선택한 컬럼의 데이터로 지표를 계산합니다
  • • COUNT, SUM, AVG, MIN, MAX 등 집계 함수 지원
  • • 사용자 정의 단위 설정 가능
  • -
  • 그룹별 카드 생성 모드로 간편하게 사용 가능
  • +
  • + • 그룹별 카드 생성 모드로 간편하게 사용 가능 +
  • @@ -361,11 +363,7 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) ? "SQL 쿼리를 입력하고 실행하세요 (지표 추가 불필요)" : "SQL 쿼리를 입력하고 지표를 추가하세요"}

    - {isGroupByMode && ( -

    - 💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값 -

    - )} + {isGroupByMode &&

    💡 첫 번째 컬럼: 카드 제목, 두 번째 컬럼: 카드 값

    }
    @@ -386,7 +384,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) const colors = colorMap[colorKey]; return ( -
    +
    {card.label}
    {card.value.toLocaleString()}
    From 4bbe29e18e8cdba4d5f61bc1072954ce9cac734f Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 13:20:49 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=96=B4=EB=93=9C=EB=AF=BC=20=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=8B=9D=EB=B3=84=20=EB=B0=A9=EB=B2=95=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/layout/AppLayout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 903a91bb..cb38ed3c 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -416,7 +416,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { } flex h-[calc(100vh-3.5rem)] w-72 max-w-72 min-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`} > {/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */} - {(user as ExtendedUserInfo)?.userType === "admin" && ( + {(user as ExtendedUserInfo)?.userType?.toLowerCase().includes("admin") && (