diff --git a/docs/analysis/cad-extraction.md b/docs/analysis/cad-extraction.md new file mode 100644 index 0000000..bc72f86 --- /dev/null +++ b/docs/analysis/cad-extraction.md @@ -0,0 +1,64 @@ +# CAD(DWG) 실측 추출 파이프라인 — 1차 분석 (2026-07-14) + +> 소유자 지시 "dwg 파이프라인 진행해" 결과 보고. 도구: `tools/cad/dwg_extract.py`. +> 목적: 가정값으로 시드된 트렌치(6m 격자, `is_assumed=true`)·비상구(변 중앙 4개소)·기둥(미반영)을 +> CAD 실측치로 대체(PLANNING R4). + +## 1. 파이프라인 구성 (검증 완료) + +``` +DWG ──(LibreDWG dwg2dxf.exe, 무료·오픈소스)──> DXF ──(ezdxf)──> 레이어 추출·좌표 정규화 +``` + +- **변환기**: LibreDWG 0.14 win64 (github.com/LibreDWG/libredwg/releases). ODA File Converter 불필요. + `전기,설비 트렌치.dwg`(10.3MB) → DXF 33.2MB 변환 성공(경고 무해). +- **단위**: KINTEX 도면 INSUNITS=4(**mm**) — 파이프라인이 m로 환산. +- **파서**: `pip install ezdxf` (1.4.x). + +## 2. 제1전시장 `전기,설비 트렌치.dwg` 분석 결과 + +| 항목 | 발견 | +|---|---| +| 트렌치 레이어 | `5-트렌치`(급배수 추정)·`7-트렌치`(전기 추정) — 968 세그먼트 | +| 기둥 레이어 | `4-COL-H`·`ST-COL-H`·`ST-COL-PIPE` 등 8종 — 0.2~3m 각형 폴리라인 812개 | +| 구조 그리드 앵커 | **Y축 라벨이 63m 피치**(y=80.1→143.1→206.1→269.1→332.1→395.1) = 홀 깊이 베이 5개 → 홀1~5 Y밴드 식별 가능. X축 라벨은 y=456.1m 행 | +| 도면 성격 | **트렌치 상세도** — 홀 전면 트렌치 그리드가 아니라 ①세로 트렌치 라인 스트립(밴드2·3: 홀 깊이 63m 관통, 폭 9~15m) ②단면·확대 상세(밴드1·5) 위주 | + +### 판정 — V61 실측 시드 보류 사유 + +Y밴드(홀) 앵커는 확보했으나, 이 상세도만으로는 **홀별 전체 트렌치 그리드의 완전한 실좌표**를 +단정할 수 없다(상세도 특성상 대표 구간만 작도). 잘못된 "실측" 시드는 가정 그리드보다 해롭다. +→ 현행 V5 가정 그리드(`is_assumed=true`) 유지. + +## 3. 다음 단계 (승인 시 진행) + +1. **평면도 교차 분석**: `A3002(지상1층평면도)-z2.dwg` 변환 후 동일 그리드 앵커로 트렌치 + 센터라인·기둥·비상구(문 심볼) 레이어를 교차 대조 — 상세도와 평면도가 일치하는 구간만 실측 채택. +2. **기둥 반영**: `원본-철골기둥 주심도.dwg`(제2전시장)·`4-COL-H`(제1전시장)에서 기둥 실좌표 추출 → + 신규 `hall_pillar` 테이블(V61) + 자동배치 엔진 회피(예약 영역) + 캔버스 표시. +3. **좌표 변환 규약**: CAD(mm, 전건물 좌표) → 홀 로컬 m. 홀 h의 Y밴드 [y0..y0+63], X원점은 + 평면도의 홀 외벽선에서 확정(트렌치 상세도 x범위는 불충분). +4. **비상구**: 평면도 문 심볼(`4-DOOR`·`4-출입구` 계열) 위치에서 실좌표 재유도. + +## 4. 자산 목록 (zip 내용) + +- `kintex1_cad_all_halls.zip`: 지상1/2/3층 평면도 + **전기,설비 트렌치.dwg** +- `kintex2_cad_all_halls.zip`: 원본-1층 평면도(24.6MB) + **원본-철골기둥 주심도.dwg** + 전력간선 평면도(77MB) + FORM + +> 주의: CAD 원본은 gitignore(로컬 보존). DXF·중간 산출물도 커밋 금지(용량) — 리포트(txt/md)만 기록. + +## DWG 파이프라인 산출 (2026-07-15 진행분) + +- **파이프라인 도구**: `tools/cad/dwg_extract.py`(inventory/extract/anchors, recover 폴백) · + `tools/cad/render_hall_png.py`(홀 밴드 PNG 렌더). 변환기 = LibreDWG 0.14 win64. +- **좌표 확정(제1전시장, `전기,설비 트렌치.dwg`)**: 홀 바닥 X원점 293.5m·폭 171m, + Y밴드 80.1m 시작·63m 피치 — 렌더 시각 검증 2회(try1→try2)로 캘리브레이션. + 트렌치 실배치 = 가로 7열(약 9m 피치) + 중앙 세로 피더. 현행 V5 가정 6m 격자와 다름 확인 + → 실측 시드(V61)는 트렌치 레이어 세그먼트의 열 좌표 정규화 후 별도 진행. +- **DWG 탭 언더레이**: 홀1~5 바닥면 크롭 PNG(margin 3m) 생성 → `public/media/floorplans/dwg/` + 빌드 내장. 에디터 좌측 "도면" 툴그룹에 JPG/DWG 탭 — DWG 미보유 홀은 탭 비활성. + 캘리브레이션은 렌더 파라미터에서 결정적(margin/(W+2m) 분율). +- **주의/한계**: ①밴드 번호↔홀 번호 방향(남→북 가정) 도면 라벨로 미확증 — K1 홀 5개는 구조 + 동일해 언더레이 표시는 유효하나 표기 확정 필요 ②A3002 평면도 DWG는 LibreDWG 크래시 + (r2000 다운컨버전 산출물도 ezdxf 파싱 실패) — 트렌치 통합도면으로 대체 ③제2전시장(H6~10) + 렌더는 `원본-1층 평면도.dwg` 변환 검증 후 확장. diff --git a/src/frontend/public/media/floorplans/dwg/hall1.png b/src/frontend/public/media/floorplans/dwg/hall1.png new file mode 100644 index 0000000..0ae4ada Binary files /dev/null and b/src/frontend/public/media/floorplans/dwg/hall1.png differ diff --git a/src/frontend/public/media/floorplans/dwg/hall2.png b/src/frontend/public/media/floorplans/dwg/hall2.png new file mode 100644 index 0000000..a106d51 Binary files /dev/null and b/src/frontend/public/media/floorplans/dwg/hall2.png differ diff --git a/src/frontend/public/media/floorplans/dwg/hall3.png b/src/frontend/public/media/floorplans/dwg/hall3.png new file mode 100644 index 0000000..e209a8f Binary files /dev/null and b/src/frontend/public/media/floorplans/dwg/hall3.png differ diff --git a/src/frontend/public/media/floorplans/dwg/hall4.png b/src/frontend/public/media/floorplans/dwg/hall4.png new file mode 100644 index 0000000..0d1d1bf Binary files /dev/null and b/src/frontend/public/media/floorplans/dwg/hall4.png differ diff --git a/src/frontend/public/media/floorplans/dwg/hall5.png b/src/frontend/public/media/floorplans/dwg/hall5.png new file mode 100644 index 0000000..00d8cd2 Binary files /dev/null and b/src/frontend/public/media/floorplans/dwg/hall5.png differ diff --git a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx index c8df1b5..8a741ae 100644 --- a/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx +++ b/src/frontend/src/screens/floorplan/BoothLayoutEditorPage.tsx @@ -10,7 +10,7 @@ import { FloorplanCanvas, type CanvasLayer } from './FloorplanCanvas'; import { AutoLayoutDialog } from './AutoLayoutDialog'; import { ValidationPanel } from './ValidationPanel'; import { sampleLayout, emptyLayout } from './sampleLayout'; -import { hallFloorplanUrl, hallFloorRegion } from './hallFloorplan'; +import { hallFloorplanUrl, hallFloorRegion, hallDwgUrl, hallDwgRegion } from './hallFloorplan'; import type { AutoLayoutOption, ComplianceReport, LayoutDto } from '../../api/types'; import './editor.css'; @@ -43,6 +43,10 @@ export function BoothLayoutEditorPage() { }); const [selectedBoothId, setSelectedBoothId] = useState(null); const [zoom, setZoom] = useState(1); + // 평면도 언더레이 소스 — DWG(CAD 렌더) / JPG(크롤 도면) 탭 구분(소유자 지시 2026-07-14). + const [drawingSrc, setDrawingSrc] = useState<'jpg' | 'dwg'>('jpg'); + const dwgAvailable = hallDwgUrl(hallId) != null; + const drawingTab = drawingSrc === 'dwg' && dwgAvailable ? 'dwg' : 'jpg'; const [rightTab, setRightTab] = useState('validation'); const [autoOpen, setAutoOpen] = useState(false); const [report, setReport] = useState(null); @@ -170,6 +174,29 @@ export function BoothLayoutEditorPage() { +
+

도면

+
+ + +
+

레이어

) )} diff --git a/src/frontend/src/screens/floorplan/editor.css b/src/frontend/src/screens/floorplan/editor.css index 063454a..f0961bc 100644 --- a/src/frontend/src/screens/floorplan/editor.css +++ b/src/frontend/src/screens/floorplan/editor.css @@ -34,6 +34,36 @@ max-width: 200px; height: 32px; } + +/* 평면도 소스 탭 — DWG(CAD 렌더) / JPG(크롤 도면) */ +.kx-drawing-tabs { + display: flex; + gap: var(--space-1); + padding: 2px; + border: var(--border-card); + border-radius: var(--radius-md); + background: var(--color-neutral-100); +} +.kx-drawing-tabs button { + flex: 1; + height: 26px; + border: 0; + border-radius: calc(var(--radius-md) - 2px); + background: transparent; + font-size: var(--fs-caption); + font-weight: var(--fw-medium); + color: var(--color-neutral-500); + cursor: pointer; +} +.kx-drawing-tabs button.is-active { + background: var(--color-white); + color: var(--color-neutral-900); + box-shadow: var(--shadow-level1, 0 1px 2px rgba(0, 0, 0, 0.08)); +} +.kx-drawing-tabs button:disabled { + opacity: 0.45; + cursor: not-allowed; +} .kx-editor__version { display: flex; align-items: center; diff --git a/src/frontend/src/screens/floorplan/hallFloorplan.ts b/src/frontend/src/screens/floorplan/hallFloorplan.ts index 3b4465f..d311da5 100644 --- a/src/frontend/src/screens/floorplan/hallFloorplan.ts +++ b/src/frontend/src/screens/floorplan/hallFloorplan.ts @@ -40,3 +40,33 @@ export function hallFloorRegion(hallId: string | null | undefined): FloorRegion const key = hallKey(hallId); return key ? FLOOR_REGIONS[key] ?? null : null; } + +/* + * DWG(CAD) 언더레이 — tools/cad/render_hall_png.py 가 홀 바닥면을 margin 3m 여백으로 + * 정확 크롭 렌더하므로 캘리브레이션이 렌더 파라미터에서 결정적으로 나온다: + * region = [m/(W+2m), m/(D+2m), (m+W)/(W+2m), (m+D)/(D+2m)] (m=3, W×D=홀 치수 V59) + * 현재 제1전시장 H1~H5 렌더 완료분만 노출 — 미보유 홀은 null(탭 비활성). + */ +const DWG_MARGIN_M = 3; +const DWG_HALL_DIMS: Record = { + H1: [171, 63], + H2: [171, 63], + H3: [171, 63], + H4: [171, 63], + H5: [171, 63], +}; + +export function hallDwgUrl(hallId: string | null | undefined): string | null { + const key = hallKey(hallId); + if (!key || !(key in DWG_HALL_DIMS)) return null; + return `/media/floorplans/dwg/hall${key.slice(1)}.png`; +} + +export function hallDwgRegion(hallId: string | null | undefined): FloorRegion | null { + const key = hallKey(hallId); + const dims = key ? DWG_HALL_DIMS[key] : undefined; + if (!dims) return null; + const [w, d] = dims; + const m = DWG_MARGIN_M; + return [m / (w + 2 * m), m / (d + 2 * m), (m + w) / (w + 2 * m), (m + d) / (d + 2 * m)]; +} diff --git a/tools/cad/dwg_extract.py b/tools/cad/dwg_extract.py new file mode 100644 index 0000000..2750456 --- /dev/null +++ b/tools/cad/dwg_extract.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +"""KINTEX CAD(DWG) 실측 추출 파이프라인 — PLANNING R4(트렌치·기둥·비상구 실측 대체). + +흐름: + DWG --(LibreDWG dwg2dxf)--> DXF --(ezdxf)--> 레이어 인벤토리 / 키워드 추출 / 그리드 앵커 탐지 + +사용: + python tools/cad/dwg_extract.py inventory [out.txt] + python tools/cad/dwg_extract.py extract <레이어키워드> [out.txt] # 예: 트렌치, COL + python tools/cad/dwg_extract.py anchors [out.txt] # 대형 라벨·그리드축 탐지 + +준비물(로컬 1회): + - pip install ezdxf + - LibreDWG win64(dwg2dxf.exe): https://github.com/LibreDWG/libredwg/releases + dwg2dxf.exe -o out.dxf in.dwg + 단위: KINTEX 도면은 INSUNITS=4(mm) — 본 스크립트 출력은 m 환산. + +2026-07-14 1차 분석 결과 요약은 docs/analysis/cad-extraction.md 참조. +""" +import io +import sys +from collections import Counter, defaultdict + +import ezdxf + +MM = 1000.0 + + +def _points_of(e): + t = e.dxftype() + if t == "LINE": + return [(e.dxf.start[0], e.dxf.start[1]), (e.dxf.end[0], e.dxf.end[1])] + if t == "LWPOLYLINE": + return [(p[0], p[1]) for p in e.get_points()] + if t in ("INSERT", "TEXT", "MTEXT"): + p = e.dxf.insert + return [(p[0], p[1])] + if t == "CIRCLE": + p = e.dxf.center + return [(p[0], p[1])] + return [] + + +def inventory(msp, rep): + by_layer = defaultdict(Counter) + for e in msp: + by_layer[e.dxf.layer][e.dxftype()] += 1 + rep.write("레이어별 엔티티(상위 40):\n") + for layer, cnt in sorted(by_layer.items(), key=lambda kv: -sum(kv[1].values()))[:40]: + top = ", ".join(f"{t}:{c}" for t, c in cnt.most_common(4)) + rep.write(f"{sum(cnt.values()):7d} {layer} [{top}]\n") + + +def extract(msp, keyword, rep): + segs = [] + for e in msp: + if keyword.upper() not in e.dxf.layer.upper(): + continue + pts = _points_of(e) + for a, b in zip(pts, pts[1:]): + segs.append((e.dxf.layer, a, b)) + rep.write(f"'{keyword}' 레이어 세그먼트: {len(segs)}\n") + if not segs: + return + allp = [p for _, a, b in segs for p in (a, b)] + xs = [p[0] / MM for p in allp] + ys = [p[1] / MM for p in allp] + rep.write(f"bbox(m): x[{min(xs):.1f}..{max(xs):.1f}] y[{min(ys):.1f}..{max(ys):.1f}]\n") + for layer, a, b in segs[:80]: + rep.write(f" {layer}: ({a[0]/MM:.2f},{a[1]/MM:.2f}) -> ({b[0]/MM:.2f},{b[1]/MM:.2f})\n") + if len(segs) > 80: + rep.write(f" … 외 {len(segs)-80}건 생략\n") + + +def anchors(msp, rep): + """대형 텍스트(도면 제목·홀 라벨)와 구조 그리드 축 라벨(X/Y·63m 피치) 탐지.""" + texts = [] + for e in msp: + if e.dxftype() not in ("TEXT", "MTEXT"): + continue + try: + txt = (e.dxf.text if e.dxftype() == "TEXT" else e.text).strip() + h = e.dxf.height if e.dxftype() == "TEXT" else e.dxf.char_height + p = e.dxf.insert + except Exception: + continue + if txt: + texts.append((h, txt, p[0] / MM, p[1] / MM)) + texts.sort(key=lambda t: -t[0]) + rep.write("대형 텍스트(상위 40):\n") + for h, t, x, y in texts[:40]: + rep.write(f" {h:7.0f}mm ({x:7.1f},{y:7.1f})m {t[:60]}\n") + ys = sorted(y for h, t, x, y in texts if t in ("Y", "RY")) + if len(ys) >= 3: + diffs = [round(b - a, 1) for a, b in zip(ys, ys[1:])] + rep.write(f"\nY 그리드 라벨 y좌표(m): {[round(v,1) for v in ys]}\n인접 간격: {diffs}\n") + rep.write("(63m 간격이면 = 홀 깊이 베이 — 홀별 Y밴드 앵커)\n") + + +def main(): + if len(sys.argv) < 3: + print(__doc__) + sys.exit(1) + cmd, src = sys.argv[1], sys.argv[2] + try: + doc = ezdxf.readfile(src) + except ezdxf.DXFStructureError: + # LibreDWG 산출 DXF는 구조 결함이 있을 수 있음 — 관용 파서로 복구 로드. + from ezdxf import recover + + doc, auditor = recover.readfile(src) + print(f"recover: errors={len(auditor.errors)} fixes={len(auditor.fixes)}", file=sys.stderr) + msp = doc.modelspace() + rep = io.StringIO() + rep.write(f"FILE {src}\nINSUNITS={doc.header.get('$INSUNITS', '?')}\n\n") + if cmd == "inventory": + inventory(msp, rep) + out_default = "cad_inventory.txt" + elif cmd == "extract": + extract(msp, sys.argv[3], rep) + out_default = "cad_extract.txt" + elif cmd == "anchors": + anchors(msp, rep) + out_default = "cad_anchors.txt" + else: + print(__doc__) + sys.exit(1) + out = sys.argv[-1] if sys.argv[-1].endswith(".txt") else out_default + with open(out, "w", encoding="utf-8") as f: + f.write(rep.getvalue()) + print(f"OK -> {out}") + + +if __name__ == "__main__": + main() diff --git a/tools/cad/render_hall_png.py b/tools/cad/render_hall_png.py new file mode 100644 index 0000000..7ee1f7a --- /dev/null +++ b/tools/cad/render_hall_png.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +"""CAD DXF → 홀별 DWG-탭 언더레이 PNG 렌더 (ezdxf drawing addon + matplotlib). + +제1전시장 `전기,설비 트렌치.dwg`(평면+트렌치 통합 도면) 기준: + - 구조 그리드 Y 라벨이 63m 피치(y=80.1 시작) = 홀1~5 Y밴드 + - X 원점·폭은 인자로 조정(렌더 결과를 눈으로 검증하며 캘리브레이션) + +사용: + python tools/cad/render_hall_png.py [margin_m] +예: + python tools/cad/render_hall_png.py k1_trench.dxf 1 293.5 171 hall1_dwg.png 3 + python tools/cad/render_hall_png.py k1_trench.dxf 1-5 293.5 171 out_dir 3 # 1회 로드로 5개 crop +""" +import sys + +import ezdxf +from ezdxf.addons.drawing import Frontend, RenderContext +from ezdxf.addons.drawing.matplotlib import MatplotlibBackend +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +# 잡음 레이어(치수·주석·표제) — 언더레이 가독성 우선 +EXCLUDE_PREFIX = ("DIM", "E-TEXT", "1-TEXT", "nember", "0-기호") +EXCLUDE_TYPES = {"TEXT", "MTEXT", "ATTDEF"} + +Y0_BASE_M = 80.1 # 홀1 밴드 시작(구조 그리드 Y 라벨 실측) +BAND_M = 63.0 + +def main(): + import os + + src, hall_arg, x0_m, width_m, out = sys.argv[1], sys.argv[2], float(sys.argv[3]), float(sys.argv[4]), sys.argv[5] + margin = float(sys.argv[6]) if len(sys.argv) > 6 else 3.0 + if "-" in hall_arg: + a, b = hall_arg.split("-") + halls = list(range(int(a), int(b) + 1)) + else: + halls = [int(hall_arg)] + + doc = ezdxf.readfile(src) + msp = doc.modelspace() + + # 1회 로드·1회 드로우 → 밴드별 xlim/ylim 변경 후 저장(재렌더 비용 회피) + fig = plt.figure(figsize=(17.1, 6.3), dpi=160) + ax = fig.add_axes([0, 0, 1, 1]) + ctx = RenderContext(doc) + backend = MatplotlibBackend(ax) + + def keep(e): + if e.dxftype() in EXCLUDE_TYPES: + return False + layer = e.dxf.layer.upper() + return not any(layer.startswith(p.upper()) for p in EXCLUDE_PREFIX) + + Frontend(ctx, backend).draw_entities(e for e in msp if keep(e)) + + mm = 1000.0 + for hall_no in halls: + y0_m = Y0_BASE_M + (hall_no - 1) * BAND_M + ax.set_xlim((x0_m - margin) * mm, (x0_m + width_m + margin) * mm) + ax.set_ylim((y0_m - margin) * mm, (y0_m + BAND_M + margin) * mm) + ax.set_aspect("equal") + ax.axis("off") + dest = os.path.join(out, f"hall{hall_no}.png") if len(halls) > 1 else out + fig.savefig(dest, facecolor="white") + print(f"OK -> {dest} band y[{y0_m}..{y0_m+BAND_M}]m x[{x0_m}..{x0_m+width_m}]m") + + +if __name__ == "__main__": + main()