From 7f86104c7439acfa38c6e36407980df0b996bc20 Mon Sep 17 00:00:00 2001 From: zio Date: Wed, 15 Jul 2026 23:45:14 +0900 Subject: [PATCH] feat(m2): V64 K2 obstacle seeds - all 10 halls now obstacle-aware Owner directive 2026-07-15: extend obstacle recognition to K2 (halls 6-10). - tools/cad/extract_obstacle_seed_k2.py: same 1m-cell raster + row-strip method as K1, plus hall-frame rotation transform (+-11..12deg wings) - 31 measured strips: edge doors, service-room walls, and the full-width midline door strips on H9/H10 = the A/B divider shutter tracks - the 1.0m door buffer keeps the shutter travel line clear (operationally correct) - Layers: ELEVATOR, STAIR, DOOR, gypsum/lightweight-steel/cement-panel walls Co-Authored-By: Claude Fable 5 --- docs/OWNER_FEEDBACK.md | 4 + docs/analysis/cad-extraction.md | 7 + .../migration/V64__hall_obstacle_k2_seed.sql | 39 ++++ tools/cad/extract_obstacle_seed_k2.py | 206 ++++++++++++++++++ 4 files changed, 256 insertions(+) create mode 100644 src/backend/src/main/resources/db/migration/V64__hall_obstacle_k2_seed.sql create mode 100644 tools/cad/extract_obstacle_seed_k2.py diff --git a/docs/OWNER_FEEDBACK.md b/docs/OWNER_FEEDBACK.md index 5b9cb25..9ad5973 100644 --- a/docs/OWNER_FEEDBACK.md +++ b/docs/OWNER_FEEDBACK.md @@ -344,3 +344,7 @@ V62: hall_pillar 74개 시드 — K1은 CAD 4-COL-H 추출(공식 주석 54/117m ### 세션 5 추가 — V63 벽·계단·엘리베이터·문 인식 (2026-07-15, 소유자 지시 "V63 벽·계단 인식 진행해" + "엘리베이터,문,DRYWALL 까지 함께") hall_obstacle 60개 시드(K1 H1~H5) — 1m 셀 래스터·연결성분·행 스트립 분해로 벽/계단/엘리베이터/문 실측 추출. 자동배치가 문 1.0m·기타 0.3m 버퍼로 회피, 캔버스 구조 레이어 사각 표시. K2는 레이어 구조 상이로 보류(한계 기록). 게이트: gradle test·tsc·vite·XML 전부 통과 → push→배포. + +### 세션 5 추가 — V64 K2 장애물 (2026-07-15, 소유자 지시 "K2 장애물도 진행해") + +H6~H10 장애물 31개 스트립 시드(홀 프레임 회전 변환) — 에지 문·부속실 벽·midline 분리 셔터 트랙(door 1.0m 버퍼로 가동선 클리어). 전 홀(K1+K2) 장애물 인식 완성(총 91개). 게이트: gradle test 통과 → push→배포. diff --git a/docs/analysis/cad-extraction.md b/docs/analysis/cad-extraction.md index 216718e..5f8f491 100644 --- a/docs/analysis/cad-extraction.md +++ b/docs/analysis/cad-extraction.md @@ -117,3 +117,10 @@ H7/H8 블록 252m=126×2·폭 90m 정확 일치) + 회전 프로브 렌더 시 클리어)·기타 0.3m. 캔버스 구조 레이어에 사각 표시(문=녹색톤 구분). - **한계**: ①벽선 기반 — 밀폐 실내 채움 아님(단, 외곽 여유 5m+벽 스트립 차단으로 실배치 유입 방지) ②K2(H6~10)는 장애물 레이어 구조 상이(석고보드·경량철골 등 xref 평탄화)로 신뢰 추출 보류. + +## V64 K2 장애물 시드 (2026-07-15) + +- K1과 동일 방식 + 홀 프레임 회전 변환(±11~12°)으로 H6~H10에서 31개 스트립 추출. +- 특기: H9/H10 midline(y≈66)의 전폭 door 스트립 = **9A/9B·10A/10B 분리 셔터 트랙**(가동벽) — + door 1.0m 버퍼 회피로 셔터 가동선 클리어 확보(운영 정합). H7/H8은 서/동측 에지 문 + midline 포켓. +- 레이어: ELEVATOR·STAIR·DOOR·석고보드·경량철골·시멘트판넬·WALL 계열(k2_slim 구조 추출본). diff --git a/src/backend/src/main/resources/db/migration/V64__hall_obstacle_k2_seed.sql b/src/backend/src/main/resources/db/migration/V64__hall_obstacle_k2_seed.sql new file mode 100644 index 0000000..7741401 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V64__hall_obstacle_k2_seed.sql @@ -0,0 +1,39 @@ +-- V64: K2(H6~H10) 벽·계단·엘리베이터·문 장애물 실측 시드 — V63(K1)과 동일 방식 + 홀 프레임 회전 변환. +-- 생성기: tools/cad/extract_obstacle_seed_k2.py (결정적 재생성). 근거: docs/analysis/cad-extraction.md §K2. + +DELETE FROM hall_obstacle WHERE hall_id IN ('H6','H7','H8','H9','H10'); +INSERT INTO hall_obstacle (id, hall_id, kind, x0, y0, x1, y1) +VALUES + ('H6-OB-1', 'H6', 'door', 0.0, 2.0, 2.0, 23.0), + ('H6-OB-2', 'H6', 'door', 0.0, 24.0, 2.0, 25.0), + ('H6-OB-3', 'H6', 'door', 0.0, 26.0, 2.0, 27.0), + ('H6-OB-4', 'H6', 'door', 0.0, 28.0, 1.0, 30.0), + ('H6-OB-5', 'H6', 'door', 0.0, 42.0, 1.0, 43.0), + ('H6-OB-6', 'H6', 'door', 0.0, 51.0, 2.0, 52.0), + ('H6-OB-7', 'H6', 'door', 0.0, 67.0, 2.0, 68.0), + ('H6-OB-8', 'H6', 'door', 0.0, 69.0, 2.0, 70.0), + ('H6-OB-9', 'H6', 'wall', 0.0, 75.0, 1.0, 85.0), + ('H6-OB-10', 'H6', 'door', 1.0, 92.0, 2.0, 93.0), + ('H7-OB-1', 'H7', 'door', 0.0, 63.0, 5.0, 64.0), + ('H7-OB-2', 'H7', 'door', 89.0, 27.0, 90.0, 35.0), + ('H7-OB-3', 'H7', 'door', 89.0, 91.0, 90.0, 98.0), + ('H8-OB-1', 'H8', 'door', 0.0, 0.0, 1.0, 1.0), + ('H8-OB-2', 'H8', 'door', 0.0, 60.0, 1.0, 62.0), + ('H8-OB-3', 'H8', 'door', 0.0, 63.0, 5.0, 64.0), + ('H8-OB-4', 'H8', 'door', 89.0, 18.0, 90.0, 26.0), + ('H8-OB-5', 'H8', 'door', 89.0, 28.0, 90.0, 35.0), + ('H8-OB-6', 'H8', 'wall', 89.0, 50.0, 90.0, 61.0), + ('H8-OB-7', 'H8', 'door', 89.0, 91.0, 90.0, 98.0), + ('H9-OB-1', 'H9', 'door', 0.0, 64.0, 1.0, 65.0), + ('H9-OB-2', 'H9', 'door', 0.0, 65.0, 50.0, 66.0), + ('H9-OB-3', 'H9', 'door', 0.0, 66.0, 1.0, 67.0), + ('H9-OB-4', 'H9', 'door', 51.0, 65.0, 99.0, 66.0), + ('H9-OB-5', 'H9', 'door', 95.0, 64.0, 99.0, 65.0), + ('H10-OB-1', 'H10', 'door', 0.0, 65.0, 1.0, 66.0), + ('H10-OB-2', 'H10', 'door', 0.0, 66.0, 50.0, 67.0), + ('H10-OB-3', 'H10', 'door', 0.0, 102.0, 1.0, 103.0), + ('H10-OB-4', 'H10', 'door', 51.0, 66.0, 99.0, 67.0), + ('H10-OB-5', 'H10', 'door', 95.0, 65.0, 99.0, 66.0), + ('H10-OB-6', 'H10', 'door', 95.0, 67.0, 99.0, 68.0) +ON CONFLICT (id) DO UPDATE SET kind = EXCLUDED.kind, + x0 = EXCLUDED.x0, y0 = EXCLUDED.y0, x1 = EXCLUDED.x1, y1 = EXCLUDED.y1; diff --git a/tools/cad/extract_obstacle_seed_k2.py b/tools/cad/extract_obstacle_seed_k2.py new file mode 100644 index 0000000..caafc41 --- /dev/null +++ b/tools/cad/extract_obstacle_seed_k2.py @@ -0,0 +1,206 @@ +# -*- coding: utf-8 -*- +"""K2(제2전시장 H6~H10) 장애물 실측 시드 생성기 — V64 (hall_obstacle 확장). + +K1(extract_obstacle_seed.py)과 동일한 1m 셀 래스터·연결성분·행 스트립 분해 방식이되, +K2 홀 프레임(±11~12° 회전 배치)을 회전 변환해 홀 로컬 좌표로 정규화한다. +소스: 원본-1층 평면도.dwg → dwg2dxf → k2_slim.py 슬림 추출본(구조 레이어만). + +사용: + python tools/cad/extract_obstacle_seed_k2.py +""" +import io +import math +import sys +from collections import deque + +import ezdxf + +MM = 1000.0 +CELL = 1.0 +MIN_AREA = 3.0 +MIN_DIM_DOOR = 0.5 +DOOR_MIN_AREA = 0.5 + +# 홀 프레임(fork 확정, docs/analysis/cad-extraction.md §K2): (회전deg, 중심, u0,u1, v0,v1) +FRAMES = { + "H6": (-12, (530, 405), 482, 542, 350.5, 443.5), + "H7": (0, (0, 0), 439, 529, 215.5, 341.5), + "H8": (0, (0, 0), 439, 529, 89.5, 215.5), + "H9": (-11, (245, 155), 196, 295, 95.5, 227.5), + "H10": (11, (255, 330), 194.5, 293.5, 250, 382), +} + +KIND_LAYERS = { + "stair": ("STAIR", "계단"), + "elevator": ("ELEVATOR", "ELV", "승강"), + "door": ("DOOR", "출입구"), + "wall": ("WALL", "석고보드", "경량철골", "시멘트판넬", "판넬", "조적"), +} + + +def kind_of(layer): + u = layer.upper() + for kind, keys in KIND_LAYERS.items(): + if any(k.upper() in u for k in keys): + return kind + return None + + +def rot(x, y, deg, c): + if deg == 0: + return x, y + t = math.radians(-deg) + dx, dy = x - c[0], y - c[1] + return c[0] + dx * math.cos(t) - dy * math.sin(t), c[1] + dx * math.sin(t) + dy * math.cos(t) + + +def sample_points(e): + t = e.dxftype() + try: + if t == "LINE": + a, b = e.dxf.start, e.dxf.end + ln = math.hypot(b[0] - a[0], b[1] - a[1]) / MM + n = max(2, int(ln / 0.5)) + return [((a[0] + (b[0] - a[0]) * i / n) / MM, (a[1] + (b[1] - a[1]) * i / n) / MM) for i in range(n + 1)] + if t == "LWPOLYLINE": + pts = [(p[0] / MM, p[1] / MM) for p in e.get_points()] + out = [] + for a, b in zip(pts, pts[1:]): + ln = math.hypot(b[0] - a[0], b[1] - a[1]) + n = max(1, int(ln / 0.5)) + out += [(a[0] + (b[0] - a[0]) * i / n, a[1] + (b[1] - a[1]) * i / n) for i in range(n + 1)] + return out + if t in ("ARC", "CIRCLE"): + return [(p[0] / MM, p[1] / MM) for p in e.flattening(200)] + except Exception: + pass + return [] + + +def strips_of(cells): + by_row = {} + for ci, cj in cells: + by_row.setdefault(cj, []).append(ci) + strips = [] + for j in sorted(by_row): + cols = sorted(by_row[j]) + seg = [cols[0], cols[0]] + segs = [] + for c in cols[1:]: + if c == seg[1] + 1: + seg[1] = c + else: + segs.append(tuple(seg)) + seg = [c, c] + segs.append(tuple(seg)) + for i0, i1 in segs: + merged = False + for s in strips: + if s[1] == j - 1 and s[2] == i0 and s[3] == i1: + s[1] = j + merged = True + break + if not merged: + strips.append([j, j, i0, i1]) + return strips + + +def main(): + src, out_sql = sys.argv[1], sys.argv[2] + doc = ezdxf.readfile(src) + msp = doc.modelspace() + + grids = {} + dims = {} + for hall, (deg, c, u0, u1, v0, v1) in FRAMES.items(): + w, h = u1 - u0, v1 - v0 + dims[hall] = (w, h) + grids[hall] = [[None] * int(w / CELL) for _ in range(int(h / CELL))] + + for e in msp: + kind = kind_of(e.dxf.layer) + if not kind: + continue + for x, y in sample_points(e): + for hall, (deg, c, u0, u1, v0, v1) in FRAMES.items(): + u, v = rot(x, y, deg, c) + if not (u0 <= u < u1 and v0 <= v < v1): + continue + lx, ly = u - u0, v1 - v # y=0 도면 상단 + g = grids[hall] + ci, cj = int(lx / CELL), min(len(g) - 1, int(ly / CELL)) + cur = g[cj][ci] + if cur is None or (cur == "wall" and kind != "wall"): + g[cj][ci] = kind + + sql = io.StringIO() + sql.write( + "-- V64: K2(H6~H10) 벽·계단·엘리베이터·문 장애물 실측 시드 — V63(K1)과 동일 방식 + 홀 프레임 회전 변환.\n" + "-- 생성기: tools/cad/extract_obstacle_seed_k2.py (결정적 재생성). 근거: docs/analysis/cad-extraction.md §K2.\n\n" + "DELETE FROM hall_obstacle WHERE hall_id IN ('H6','H7','H8','H9','H10');\n" + ) + rows = [] + report = [] + for hall, (deg, c, u0, u1, v0, v1) in FRAMES.items(): + g = grids[hall] + W, H = len(g[0]), len(g) + hw, hd = dims[hall] + seen = [[False] * W for _ in range(H)] + comps = [] + for j in range(H): + for i in range(W): + if g[j][i] is None or seen[j][i]: + continue + q = deque([(i, j)]) + seen[j][i] = True + cells = [] + kinds = {} + while q: + ci, cj = q.popleft() + cells.append((ci, cj)) + kinds[g[cj][ci]] = kinds.get(g[cj][ci], 0) + 1 + for ni, nj in ((ci + 1, cj), (ci - 1, cj), (ci, cj + 1), (ci, cj - 1), + (ci + 1, cj + 1), (ci - 1, cj - 1), (ci + 1, cj - 1), (ci - 1, cj + 1)): + if 0 <= ni < W and 0 <= nj < H and g[nj][ni] is not None and not seen[nj][ni]: + seen[nj][ni] = True + q.append((ni, nj)) + area = len(cells) * CELL * CELL + kind = ("stair" if kinds.get("stair") + else "elevator" if kinds.get("elevator") + else "door" if kinds.get("door") + else "wall") + min_area = DOOR_MIN_AREA if kind == "door" else MIN_AREA + if area < min_area: + continue + for j0, j1, i0, i1 in strips_of(cells): + x0, x1 = i0 * CELL, (i1 + 1) * CELL + y0, y1 = j0 * CELL, (j1 + 1) * CELL + w, h = x1 - x0, y1 - y0 + if w * h < min_area and not (kind == "door" and w * h >= DOOR_MIN_AREA): + continue + if kind != "door" and min(w, h) < MIN_DIM_DOOR: + continue + if (w >= hw * 0.8 and h <= 2.5) or (h >= hd * 0.8 and w <= 2.5): + continue # 외곽 벽선 관통 박판 + comps.append((kind, x0, y0, x1, y1)) + comps.sort(key=lambda cmp: (cmp[1], cmp[2])) + report.append(f"{hall}: {len(comps)}개") + for i, (kind, x0, y0, x1, y1) in enumerate(comps, 1): + rows.append(f"('{hall}-OB-{i}', '{hall}', '{kind}', {x0:.1f}, {y0:.1f}, {x1:.1f}, {y1:.1f})") + + if rows: + sql.write( + "INSERT INTO hall_obstacle (id, hall_id, kind, x0, y0, x1, y1)\nVALUES\n " + + ",\n ".join(rows) + + "\nON CONFLICT (id) DO UPDATE SET kind = EXCLUDED.kind,\n" + " x0 = EXCLUDED.x0, y0 = EXCLUDED.y0, x1 = EXCLUDED.x1, y1 = EXCLUDED.y1;\n" + ) + with open(out_sql, "w", encoding="utf-8") as f: + f.write(sql.getvalue()) + for line in report: + print(line) + print(f"OK -> {out_sql} (스트립 {len(rows)}개)") + + +if __name__ == "__main__": + main()