diff --git a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java
index b9cfa38..dd727d1 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/module/m2/FloorplanServiceImpl.java
@@ -353,7 +353,12 @@ public class FloorplanServiceImpl implements FloorplanService {
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
}
- /** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */
+ /**
+ * 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율에 더해 요청 조건
+ * (주출입구·무대·라운지 수)을 예약 영역으로 반영해 목표 수까지 배치한다.
+ * 예약 영역(중앙 교차 통로·출입구 전면 클리어존·무대·라운지)과 겹치는 셀은 건너뛰어
+ * 배치안이 실제 전시 평면도처럼 홀 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
+ */
private List packBooths(double boothW, double boothD, double aisle,
double hallW, double hallD, AutoLayoutRequest req, char tag) {
List booths = new ArrayList<>();
@@ -364,9 +369,14 @@ public class FloorplanServiceImpl implements FloorplanService {
double usableW = hallW - 2 * PERIMETER_MARGIN_M;
double usableD = hallD - 2 * PERIMETER_MARGIN_M;
+ List reserved = reservedZones(hallW, hallD, aisle, req);
+
int index = 0;
for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) {
for (double x = PERIMETER_MARGIN_M; x + boothW <= PERIMETER_MARGIN_M + usableW && index < target; x += stepX) {
+ if (intersectsAny(reserved, x, y, x + boothW, y + boothD)) {
+ continue;
+ }
index++;
boolean premium = index <= premiumTarget;
String boothNo = String.format("%c-%03d", tag, index);
@@ -379,6 +389,59 @@ public class FloorplanServiceImpl implements FloorplanService {
return booths;
}
+ /**
+ * 요청 조건 기반 예약 영역 [x0,y0,x1,y1] 목록(홀 로컬 m) — 결정적 산출.
+ *
+ * - 중앙 교차 주통로: 홀 폭 40m 이상이면 세로, 깊이 40m 이상이면 가로(폭 max(1.5×통로, 4.5m))
+ * - 주출입구 클리어존: 전면(y=0) 변에 균등 분포, 개소당 9m 폭 × 6m 깊이
+ * - 무대: 후면(y=hallD) 중앙부에 12×9m + 사방 3m 버퍼
+ * - 라운지: 홀 중앙부에 9×9m + 사방 3m 버퍼
+ *
+ */
+ private List reservedZones(double hallW, double hallD, double aisle, AutoLayoutRequest req) {
+ List zones = new ArrayList<>();
+ double mainAisle = Math.max(aisle * 1.5, 4.5);
+
+ if (hallW >= 40) {
+ double cx = hallW / 2;
+ zones.add(new double[]{cx - mainAisle / 2, 0, cx + mainAisle / 2, hallD});
+ }
+ if (hallD >= 40) {
+ double cy = hallD / 2;
+ zones.add(new double[]{0, cy - mainAisle / 2, hallW, cy + mainAisle / 2});
+ }
+
+ int entrances = Math.max(0, req.mainEntranceCount());
+ for (int i = 1; i <= entrances; i++) {
+ double ex = hallW * i / (entrances + 1.0);
+ zones.add(new double[]{ex - 4.5, 0, ex + 4.5, PERIMETER_MARGIN_M + 6.0});
+ }
+
+ int stages = Math.max(0, req.stageCount());
+ for (int i = 1; i <= stages; i++) {
+ double sx = hallW * i / (stages + 1.0);
+ double sy = hallD - PERIMETER_MARGIN_M - 9.0;
+ zones.add(new double[]{sx - 6.0 - 3.0, sy - 3.0, sx + 6.0 + 3.0, hallD});
+ }
+
+ int lounges = Math.max(0, req.loungeCount());
+ for (int i = 1; i <= lounges; i++) {
+ double lx = hallW * i / (lounges + 1.0);
+ double ly = hallD / 2;
+ zones.add(new double[]{lx - 4.5 - 3.0, ly - 4.5 - 3.0, lx + 4.5 + 3.0, ly + 4.5 + 3.0});
+ }
+ return zones;
+ }
+
+ private static boolean intersectsAny(List zones, double x0, double y0, double x1, double y1) {
+ for (double[] z : zones) {
+ if (x0 < z[2] && x1 > z[0] && y0 < z[3] && y1 > z[1]) {
+ return true;
+ }
+ }
+ return false;
+ }
+
/** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
try {
diff --git a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx
index 08f4d28..b7cd504 100644
--- a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx
+++ b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx
@@ -9,7 +9,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
import { AutoLayoutDialog } from './AutoLayoutDialog';
import { ValidationPanel } from './ValidationPanel';
import { sampleLayout, emptyLayout } from './sampleLayout';
-import { hallFloorplanUrl } from './hallFloorplan';
+import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan';
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
import './editor.css';
@@ -173,7 +173,11 @@ export function BoothLayoutEditorPage() {
) : (
layout && (
= 2
+ ? [layout.hall.dimsM[0], layout.hall.dimsM[1]]
+ : [126, 90]
+ }
booths={layout.booths}
layers={layers}
selectedBoothId={selectedBoothId}
@@ -181,6 +185,7 @@ export function BoothLayoutEditorPage() {
zoom={zoom}
onZoomChange={setZoom}
floorplanUrl={hallFloorplanUrl(hallId)}
+ floorRegion={hallFloorRegion(hallId)}
/>
)
)}
diff --git a/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx b/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx
index fd2e446..925f100 100644
--- a/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx
+++ b/src/frontend/src/screens/floorplan/FloorplanCanvas.tsx
@@ -29,6 +29,12 @@ interface FloorplanCanvasProps {
zoom: number;
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
floorplanUrl?: string | null;
+ /**
+ * 도면 이미지 내 전시 바닥면 분율 영역 [x0,y0,x1,y1] (hallFloorplan.ts 캘리브레이션).
+ * 지정 시 이 영역이 홀 사각형(0,0~hw,hh)에 정렬되도록 이미지를 확대 배치 —
+ * 부스 좌표(홀 로컬 m)가 도면의 채색 바닥면 "안"에 놓인다. 미지정 시 전체 스트레치(구 동작).
+ */
+ floorRegion?: [number, number, number, number] | null;
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
onZoomChange?: (zoom: number) => void;
}
@@ -55,9 +61,26 @@ export function FloorplanCanvas({
onSelectBooth,
zoom,
floorplanUrl,
+ floorRegion,
onZoomChange,
}: FloorplanCanvasProps) {
const [hw, hh] = hallDims;
+
+ // 도면 배치: 캘리브레이션 영역이 있으면 바닥면(fx0..fx1, fy0..fy1)이 홀 사각형에 오도록 역산.
+ const drawing = useMemo(() => {
+ if (!floorRegion) {
+ return { x: 0, y: 0, w: hw, h: hh, clipped: false };
+ }
+ const [fx0, fy0, fx1, fy1] = floorRegion;
+ const rw = fx1 - fx0;
+ const rh = fy1 - fy0;
+ if (rw <= 0 || rh <= 0) {
+ return { x: 0, y: 0, w: hw, h: hh, clipped: false };
+ }
+ const w = hw / rw;
+ const h = hh / rh;
+ return { x: -fx0 * w, y: -fy0 * h, w, h, clipped: true };
+ }, [floorRegion, hw, hh]);
const [drawingOk, setDrawingOk] = useState(true);
useEffect(() => setDrawingOk(true), [floorplanUrl]);
@@ -164,10 +187,10 @@ export function FloorplanCanvas({
{floorplanUrl && drawingOk && (
setDrawingOk(false)}
diff --git a/src/frontend/src/screens/floorplan/canvas.css b/src/frontend/src/screens/floorplan/canvas.css
index 8a43915..fb6dda7 100644
--- a/src/frontend/src/screens/floorplan/canvas.css
+++ b/src/frontend/src/screens/floorplan/canvas.css
@@ -25,8 +25,8 @@
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
.kx-canvas__drawing {
- opacity: 0.3;
- filter: saturate(0.35);
+ opacity: 0.55;
+ filter: saturate(0.55);
pointer-events: none;
}
diff --git a/src/frontend/src/screens/floorplan/hallFloorplan.ts b/src/frontend/src/screens/floorplan/hallFloorplan.ts
index ae52199..1dd59d7 100644
--- a/src/frontend/src/screens/floorplan/hallFloorplan.ts
+++ b/src/frontend/src/screens/floorplan/hallFloorplan.ts
@@ -1,13 +1,41 @@
/*
- * 홀 ID → 실측 홀 도면(크롤 JPG) URL 매핑.
+ * 홀 ID → 실측 홀 도면(크롤 JPG) URL + 전시 바닥면 캘리브레이션.
* 에셋은 docs/assets/floorplans 크롤본을 public/media/floorplans 로 빌드 내장(V46 포스터 패턴).
* 매핑 불가한 홀은 null — 캔버스는 기존 다크 서피스만 렌더(FloorplanCanvas onError 폴백과 동일).
+ *
+ * FLOOR_REGIONS: 도면 이미지 안에서 전시 바닥면(1전시장 연노랑·2전시장 라임그린 채색 영역)이
+ * 차지하는 사각 영역의 이미지 분율 [x0, y0, x1, y1]. 도면 JPG는 여백·범례·미니맵을 포함하므로
+ * 이 영역을 홀 좌표계(0,0~W,H m)에 정렬해야 부스가 "평면도 안"에 배치돼 보인다(소유자 지시 2026-07-14).
+ * 값은 색상 블롭 자동 측정(최대 연결 성분) 산출 — 도면 교체 시 재측정 필요.
*/
-const AVAILABLE_HALLS = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
+export type FloorRegion = [number, number, number, number];
+
+const FLOOR_REGIONS: Record = {
+ H1: [0.1583, 0.4015, 0.9167, 0.7956],
+ H2: [0.0667, 0.3723, 0.8944, 0.8029],
+ H3: [0.0667, 0.3759, 0.8972, 0.8102],
+ H4: [0.0667, 0.3759, 0.8972, 0.8066],
+ H5: [0.0528, 0.4416, 0.9028, 0.8686],
+ H6: [0.3222, 0.2162, 0.6917, 0.8176],
+ H7: [0.2583, 0.2027, 0.7306, 0.8547],
+ H8: [0.275, 0.5034, 0.7194, 0.8446],
+ H9: [0.2833, 0.2061, 0.7111, 0.8581],
+ H10: [0.2778, 0.5068, 0.7194, 0.8581],
+};
+
+function hallKey(hallId: string | null | undefined): string | null {
+ const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
+ return m ? `H${Number(m[1])}` : null;
+}
export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
- const m = /^H(\d+)$/i.exec((hallId ?? '').trim());
- if (!m) return null;
- const n = Number(m[1]);
- return AVAILABLE_HALLS.has(n) ? `/media/floorplans/hall${n}.jpg` : null;
+ const key = hallKey(hallId);
+ if (!key || !(key in FLOOR_REGIONS)) return null;
+ return `/media/floorplans/hall${key.slice(1)}.jpg`;
+}
+
+/** 도면 이미지 내 전시 바닥면 분율 영역 — 캔버스가 이 영역을 홀 사각형에 정렬한다. */
+export function hallFloorRegion(hallId: string | null | undefined): FloorRegion | null {
+ const key = hallKey(hallId);
+ return key ? FLOOR_REGIONS[key] ?? null : null;
}