feat(m2): place auto-generated booths inside the hall floorplan

Owner directive 2026-07-14: booths must be auto-placed inside the floorplan.

- hallFloorplan.ts: per-hall floor-region calibration [x0,y0,x1,y1] (auto-measured
  largest color blob of the exhibition floor in each crawled JPG, halls 1-10)
- FloorplanCanvas: align calibrated floor region to hall rect (0,0-W,H m) so booth
  coordinates land inside the drawn floor; raise underlay visibility (0.3 -> 0.55)
- BoothLayoutEditorPage: use real hall dims from LayoutDto.hall (was hardcoded 126x90),
  pass floorRegion; types.ts HallInfoDto added in previous commit
- FloorplanServiceImpl.packBooths: honor request conditions as reserved zones -
  central cross aisles (>=40m spans), entrance clear zones, stage/lounge blocks
  (previously mainEntranceCount/stageCount/loungeCount were ignored)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-14 22:59:31 +09:00
parent c686365021
commit 0cc7f26c56
5 changed files with 134 additions and 15 deletions

View File

@ -353,7 +353,12 @@ public class FloorplanServiceImpl implements FloorplanService {
+ " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n"; + " 예: {\"opt-A\":\"...\",\"opt-B\":\"...\"}\n";
} }
/** 제약 기반 그리드 패킹 — 외곽 주통로·통로 폭·프리미엄 비율을 반영해 목표 수까지 배치. */ /**
* 제약 기반 그리드 패킹 외곽 주통로·통로 ·프리미엄 비율에 더해 요청 조건
* (주출입구·무대·라운지 ) <b>예약 영역</b>으로 반영해 목표 수까지 배치한다.
* 예약 영역(중앙 교차 통로·출입구 전면 클리어존·무대·라운지) 겹치는 셀은 건너뛰어
* 배치안이 실제 전시 평면도처럼 바닥면 안에서 구획된다(소유자 지시 2026-07-14).
*/
private List<BoothDto> packBooths(double boothW, double boothD, double aisle, private List<BoothDto> packBooths(double boothW, double boothD, double aisle,
double hallW, double hallD, AutoLayoutRequest req, char tag) { double hallW, double hallD, AutoLayoutRequest req, char tag) {
List<BoothDto> booths = new ArrayList<>(); List<BoothDto> booths = new ArrayList<>();
@ -364,9 +369,14 @@ public class FloorplanServiceImpl implements FloorplanService {
double usableW = hallW - 2 * PERIMETER_MARGIN_M; double usableW = hallW - 2 * PERIMETER_MARGIN_M;
double usableD = hallD - 2 * PERIMETER_MARGIN_M; double usableD = hallD - 2 * PERIMETER_MARGIN_M;
List<double[]> reserved = reservedZones(hallW, hallD, aisle, req);
int index = 0; int index = 0;
for (double y = PERIMETER_MARGIN_M; y + boothD <= PERIMETER_MARGIN_M + usableD && index < target; y += stepY) { 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) { 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++; index++;
boolean premium = index <= premiumTarget; boolean premium = index <= premiumTarget;
String boothNo = String.format("%c-%03d", tag, index); String boothNo = String.format("%c-%03d", tag, index);
@ -379,6 +389,59 @@ public class FloorplanServiceImpl implements FloorplanService {
return booths; return booths;
} }
/**
* 요청 조건 기반 예약 영역 [x0,y0,x1,y1] 목록( 로컬 m) 결정적 산출.
* <ul>
* <li>중앙 교차 주통로: 40m 이상이면 세로, 깊이 40m 이상이면 가로( max(1.5×통로, 4.5m))</li>
* <li>주출입구 클리어존: 전면(y=0) 변에 균등 분포, 개소당 9m × 6m 깊이</li>
* <li>무대: 후면(y=hallD) 중앙부에 12×9m + 사방 3m 버퍼</li>
* <li>라운지: 중앙부에 9×9m + 사방 3m 버퍼</li>
* </ul>
*/
private List<double[]> reservedZones(double hallW, double hallD, double aisle, AutoLayoutRequest req) {
List<double[]> 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<double[]> 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) 허용. */ /** S7 홀 전경(조감) 프리뷰 발행 — 렌더 인프라 장애 시에도 자동배치가 성립하도록 degraded(null) 허용. */
private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) { private String publishS7Preview(String eventId, String hallId, HallInfo hall, char tag) {
try { try {

View File

@ -9,7 +9,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas';
import { AutoLayoutDialog } from './AutoLayoutDialog'; import { AutoLayoutDialog } from './AutoLayoutDialog';
import { ValidationPanel } from './ValidationPanel'; import { ValidationPanel } from './ValidationPanel';
import { sampleLayout, emptyLayout } from './sampleLayout'; import { sampleLayout, emptyLayout } from './sampleLayout';
import { hallFloorplanUrl } from './hallFloorplan'; import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan';
import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types'; import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types';
import './editor.css'; import './editor.css';
@ -173,7 +173,11 @@ export function BoothLayoutEditorPage() {
) : ( ) : (
layout && ( layout && (
<FloorplanCanvas <FloorplanCanvas
hallDims={[126, 90]} hallDims={
layout.hall?.dimsM && layout.hall.dimsM.length >= 2
? [layout.hall.dimsM[0], layout.hall.dimsM[1]]
: [126, 90]
}
booths={layout.booths} booths={layout.booths}
layers={layers} layers={layers}
selectedBoothId={selectedBoothId} selectedBoothId={selectedBoothId}
@ -181,6 +185,7 @@ export function BoothLayoutEditorPage() {
zoom={zoom} zoom={zoom}
onZoomChange={setZoom} onZoomChange={setZoom}
floorplanUrl={hallFloorplanUrl(hallId)} floorplanUrl={hallFloorplanUrl(hallId)}
floorRegion={hallFloorRegion(hallId)}
/> />
) )
)} )}

View File

@ -29,6 +29,12 @@ interface FloorplanCanvasProps {
zoom: number; zoom: number;
/** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */ /** 실측 홀 도면(크롤 JPG) 언더레이 URL — 없거나 로드 실패 시 기존 다크 서피스만. */
floorplanUrl?: string | null; floorplanUrl?: string | null;
/**
* [x0,y0,x1,y1] (hallFloorplan.ts ).
* (0,0~hw,hh)
* ( m) "안" . ( ).
*/
floorRegion?: [number, number, number, number] | null;
/** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */ /** 마우스휠 확대/축소 시 페이지 zoom 상태 동기화(소유자 지시 2026-07-14). 미지정 시 휠 줌 비활성. */
onZoomChange?: (zoom: number) => void; onZoomChange?: (zoom: number) => void;
} }
@ -55,9 +61,26 @@ export function FloorplanCanvas({
onSelectBooth, onSelectBooth,
zoom, zoom,
floorplanUrl, floorplanUrl,
floorRegion,
onZoomChange, onZoomChange,
}: FloorplanCanvasProps) { }: FloorplanCanvasProps) {
const [hw, hh] = hallDims; 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); const [drawingOk, setDrawingOk] = useState(true);
useEffect(() => setDrawingOk(true), [floorplanUrl]); useEffect(() => setDrawingOk(true), [floorplanUrl]);
@ -164,10 +187,10 @@ export function FloorplanCanvas({
{floorplanUrl && drawingOk && ( {floorplanUrl && drawingOk && (
<image <image
href={floorplanUrl} href={floorplanUrl}
x={0} x={drawing.x}
y={0} y={drawing.y}
width={hw} width={drawing.w}
height={hh} height={drawing.h}
preserveAspectRatio="none" preserveAspectRatio="none"
className="kx-canvas__drawing" className="kx-canvas__drawing"
onError={() => setDrawingOk(false)} onError={() => setDrawingOk(false)}

View File

@ -25,8 +25,8 @@
/* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */ /* 실측 홀 도면(JPG) 언더레이 — 다크 서피스 위 블루프린트 톤, 상호작용 통과 */
.kx-canvas__drawing { .kx-canvas__drawing {
opacity: 0.3; opacity: 0.55;
filter: saturate(0.35); filter: saturate(0.55);
pointer-events: none; pointer-events: none;
} }

View File

@ -1,13 +1,41 @@
/* /*
* ID ( JPG) URL . * ID ( JPG) URL + .
* docs/assets/floorplans public/media/floorplans (V46 ). * docs/assets/floorplans public/media/floorplans (V46 ).
* null (FloorplanCanvas onError ). * 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<string, FloorRegion> = {
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 { export function hallFloorplanUrl(hallId: string | null | undefined): string | null {
const m = /^H(\d+)$/i.exec((hallId ?? '').trim()); const key = hallKey(hallId);
if (!m) return null; if (!key || !(key in FLOOR_REGIONS)) return null;
const n = Number(m[1]); return `/media/floorplans/hall${key.slice(1)}.jpg`;
return AVAILABLE_HALLS.has(n) ? `/media/floorplans/hall${n}.jpg` : null; }
/** 도면 이미지 내 전시 바닥면 분율 영역 — 캔버스가 이 영역을 홀 사각형에 정렬한다. */
export function hallFloorRegion(hallId: string | null | undefined): FloorRegion | null {
const key = hallKey(hallId);
return key ? FLOOR_REGIONS[key] ?? null : null;
} }