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:
parent
c686365021
commit
0cc7f26c56
@ -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 {
|
||||||
|
|||||||
@ -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)}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user