feat(m2): DWG/JPG floorplan underlay tabs + CAD extraction pipeline

Owner directives 2026-07-14~15: run the DWG pipeline; split floorplan into DWG and JPG tabs.

- tools/cad/dwg_extract.py: DWG(LibreDWG dwg2dxf) -> DXF -> layer inventory /
  keyword extraction / grid-label anchor detection (ezdxf, recover fallback)
- tools/cad/render_hall_png.py: render hall-band crops from the K1 combined
  plan+trench drawing (x0=293.5m, width 171m, Y bands 80.1m + 63m pitch,
  single load -> multi-crop). Real trench layout confirmed: 7 horizontal runs
  at ~9m pitch + central feeder (differs from assumed 6m grid seed - V61 later)
- public/media/floorplans/dwg/hall1..5.png: K1 hall underlays (3m margin crop,
  deterministic calibration from render params)
- Editor left panel new tool group with JPG/DWG tabs; DWG disabled for
  halls without CAD render yet (K2 pending). Canvas switches url+region.
- docs/analysis/cad-extraction.md: pipeline findings, limits (A3002 LibreDWG
  crash, band-to-hall numbering direction to confirm), next steps

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-15 00:35:12 +09:00
parent d4bd4fb214
commit d8ef5c53df
11 changed files with 361 additions and 3 deletions

View File

@ -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` 변환 검증 후 확장.

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

View File

@ -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<string | null>(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<RightTab>('validation');
const [autoOpen, setAutoOpen] = useState(false);
const [report, setReport] = useState<ComplianceReport | null>(null);
@ -170,6 +174,29 @@ export function BoothLayoutEditorPage() {
<button></button>
</div>
</section>
<section className="kx-tool-group">
<h3></h3>
<div className="kx-drawing-tabs" role="tablist" aria-label="평면도 종류">
<button
role="tab"
aria-selected={drawingTab === 'jpg'}
className={drawingTab === 'jpg' ? 'is-active' : ''}
onClick={() => setDrawingSrc('jpg')}
>
JPG
</button>
<button
role="tab"
aria-selected={drawingTab === 'dwg'}
className={drawingTab === 'dwg' ? 'is-active' : ''}
disabled={!dwgAvailable}
title={dwgAvailable ? 'CAD(DWG) 실측 렌더 도면' : '이 홀은 CAD 렌더 준비 중'}
onClick={() => setDrawingSrc('dwg')}
>
DWG
</button>
</div>
</section>
<section className="kx-tool-group">
<h3></h3>
<LayerToggle
@ -225,8 +252,8 @@ export function BoothLayoutEditorPage() {
onSelectBooth={setSelectedBoothId}
zoom={zoom}
onZoomChange={setZoom}
floorplanUrl={hallFloorplanUrl(hallId)}
floorRegion={hallFloorRegion(hallId)}
floorplanUrl={drawingTab === 'dwg' ? hallDwgUrl(hallId) : hallFloorplanUrl(hallId)}
floorRegion={drawingTab === 'dwg' ? hallDwgRegion(hallId) : hallFloorRegion(hallId)}
/>
)
)}

View File

@ -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;

View File

@ -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<string, [number, number]> = {
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)];
}

135
tools/cad/dwg_extract.py Normal file
View File

@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
"""KINTEX CAD(DWG) 실측 추출 파이프라인 — PLANNING R4(트렌치·기둥·비상구 실측 대체).
흐름:
DWG --(LibreDWG dwg2dxf)--> DXF --(ezdxf)--> 레이어 인벤토리 / 키워드 추출 / 그리드 앵커 탐지
사용:
python tools/cad/dwg_extract.py inventory <dxf> [out.txt]
python tools/cad/dwg_extract.py extract <dxf> <레이어키워드> [out.txt] # 예: 트렌치, COL
python tools/cad/dwg_extract.py anchors <dxf> [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()

View File

@ -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 <dxf> <hall_no 1~5 | 1-5> <x0_m> <width_m> <out.png|outdir> [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()