# -*- coding: utf-8 -*- """K2(제2전시장 H6~H10) 트렌치 실측 시드 생성기 — V66 (PLANNING R4 실측 대체, K1 V61의 K2판). 소스: 원본-1층 평면도.dwg → LibreDWG dwg2dxf 변환본(예: scratchpad k2_plan1f.dxf). 홀 프레임(FRAMES)을 실측한 파일과 동일 도면이라 좌표계가 자동 정합된다. ※ "2전시장 E3-59~69 지상1층 전력간선 평면도(분할).dwg"는 교차검증용으로만 사용 — 그 안의 '전시장 유틸라인' xref는 x=509.2m에서 잘린 크롭본 + 채널 위상이 ~3.5m 어긋나 시드 소스로 부적합(docs/analysis/cad-extraction.md §K2 트렌치 참조). 방식: 111MB DXF를 ezdxf 없이 스트리밍 태그 파싱(4GB RAM 노트북 안전) — BLOCKS 내부까지 트렌치 레이어(LINE/LWPOLYLINE) 수집, INSERT 체인 변환으로 월드 좌표 복원, 홀 프레임(±11~12° 회전) 로컬로 변환·클리핑 후 가로 채널 행을 병합(쌍선 스냅 1.5m), 채널을 따라 6m 간격 공급 포인트 생성(K1과 동일 샘플링 규약). 사용: python tools/cad/extract_trench_seed_k2.py 좌표 규약: CAD mm → 홀 로컬 m. 홀 로컬 y=0은 도면 상단(v=v1), y = v1 - v (상하 반전). """ import io import math import sys from collections import defaultdict MM = 1000.0 MIN_RUN_M = 30.0 # 채널 인정 최소 연장(짧은 스퍼·피더 배제 — K1 동일) POINT_STEP_M = 6.0 # 공급 포인트 샘플링 간격(K1 동일 규약) ROW_SNAP_M = 1.5 # 쌍선(채널 양변) 병합 허용(K1 동일) HORIZ_TOL_M = 1.0 # 프레임 로컬 수평 판정 허용 dv # 홀 프레임(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), } TRENCH_KEYS = [b"TRENCH", "트렌치".encode("cp949"), "트랜치".encode("cp949")] def is_trench_layer(layer_bytes): u = layer_bytes.upper() return any(k in u for k in TRENCH_KEYS) # ---------- 1) DXF 스트리밍 파싱 (BLOCKS + ENTITIES) ---------- def parse(src): segs_by_container = defaultdict(list) # container(bytes) -> [(x1,y1,x2,y2)] mm inserts_by_container = defaultdict(list) # container -> [(name,x,y,sx,sy,rot)] section = None container = None ent = None def flush(ent): if ent is None: return c = ent["container"] if c is None or ent.get("paper"): return t = ent["type"] if t == b"INSERT" and ent.get("name"): inserts_by_container[c].append( (ent["name"], ent.get("x", 0.0), ent.get("y", 0.0), ent.get("sx", 1.0), ent.get("sy", 1.0), ent.get("rot", 0.0))) return if not is_trench_layer(ent.get("layer", b"")): return if t == b"LINE": if all(k in ent for k in ("x", "y", "x2", "y2")): segs_by_container[c].append((ent["x"], ent["y"], ent["x2"], ent["y2"])) elif t == b"LWPOLYLINE": pts = ent.get("pts", []) for a, b in zip(pts, pts[1:]): segs_by_container[c].append((a[0], a[1], b[0], b[1])) f = open(src, "rb", buffering=1024 * 1024 * 8) expect_section_name = False while True: code_raw = f.readline() if not code_raw: break val = f.readline() if not val: break try: code = int(code_raw) except ValueError: continue v = val.strip() if code == 0: flush(ent) ent = None if v == b"SECTION": expect_section_name = True elif v == b"ENDSEC": section = None container = None elif v == b"BLOCK" and section == b"BLOCKS": ent = {"type": b"BLOCK", "container": None} elif v == b"ENDBLK": container = None elif section in (b"BLOCKS", b"ENTITIES") and v in (b"LINE", b"LWPOLYLINE", b"INSERT"): ent = {"type": v, "container": container} continue if expect_section_name and code == 2: section = v expect_section_name = False if section == b"ENTITIES": container = b"*MS" continue if ent is None: continue if ent["type"] == b"BLOCK": if code == 2: container = v ent = None continue if code == 8: ent["layer"] = v elif code == 67: ent["paper"] = v == b"1" elif code == 2 and ent["type"] == b"INSERT": ent["name"] = v elif code == 10: x = float(v) if ent["type"] == b"LWPOLYLINE": ent.setdefault("pts", []).append([x, 0.0]) else: ent["x"] = x elif code == 20: y = float(v) if ent["type"] == b"LWPOLYLINE": pts = ent.get("pts") if pts: pts[-1][1] = y else: ent["y"] = y elif code == 11: ent["x2"] = float(v) elif code == 21: ent["y2"] = float(v) elif code == 41: ent["sx"] = float(v) elif code == 42: ent["sy"] = float(v) elif code == 50 and ent["type"] == b"INSERT": ent["rot"] = float(v) flush(ent) f.close() return segs_by_container, inserts_by_container # ---------- 2) INSERT 체인 해석 → 월드 세그(m) ---------- def _transform(px, py, ins): x0, y0, sx, sy, rot = ins px, py = px * sx, py * sy if rot: t = math.radians(rot) px, py = px * math.cos(t) - py * math.sin(t), px * math.sin(t) + py * math.cos(t) return px + x0, py + y0 def world_segs(segs_by_container, inserts_by_container): cache = {} def placements(cname, depth=0): if cname == b"*MS": return [lambda p: p] if depth > 6: return [] if cname in cache: return cache[cname] out = [] for parent, inss in inserts_by_container.items(): for ins in inss: if ins[0] != cname: continue geo = ins[1:] for up in placements(parent, depth + 1): out.append(lambda p, geo=geo, up=up: up(_transform(p[0], p[1], geo))) cache[cname] = out return out ws = [] for c, ss in segs_by_container.items(): for pl in placements(c): for (x1, y1, x2, y2) in ss: wx1, wy1 = pl((x1, y1)) wx2, wy2 = pl((x2, y2)) ws.append((wx1 / MM, wy1 / MM, wx2 / MM, wy2 / MM)) return ws # ---------- 3) 홀 프레임 변환·클리핑·채널 병합 ---------- def _rot_frame(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 _clip(ua, va, ub, vb, u0, u1, v0, v1): """Liang-Barsky 세그 사각 클리핑.""" du, dv = ub - ua, vb - va t0, t1 = 0.0, 1.0 for p, q in ((-du, ua - u0), (du, u1 - ua), (-dv, va - v0), (dv, v1 - va)): if p == 0: if q < 0: return None continue r = q / p if p < 0: if r > t1: return None t0 = max(t0, r) else: if r < t0: return None t1 = min(t1, r) if t0 > t1: return None return (ua + t0 * du, ua + t1 * du, (va + vb) / 2 + 0 * t0) # (cu0, cu1, vm) — 수평 전제 def channels_of(ws): """홀별 채널 [(center_y, x0, x1)] — 로컬 y=0 도면 상단(상하 반전).""" out = {} for hall, (deg, c, u0, u1, v0, v1) in FRAMES.items(): rows = defaultdict(list) for (x1, y1, x2, y2) in ws: ua, va = _rot_frame(x1, y1, deg, c) ub, vb = _rot_frame(x2, y2, deg, c) if abs(vb - va) > HORIZ_TOL_M: continue cl = _clip(ua, va, ub, vb, u0, u1, v0, v1) if cl is None: continue cu0, cu1, vm = cl lx0, lx1 = min(cu0, cu1) - u0, max(cu0, cu1) - u0 if lx1 - lx0 < MIN_RUN_M: continue rows[round((v1 - vm) * 2) / 2].append((lx0, lx1)) merged = [] # (center_y, x0, x1) for y, spans in sorted(rows.items()): x0 = min(s[0] for s in spans) x1 = max(s[1] for s in spans) if merged and y - merged[-1][0] <= ROW_SNAP_M: py, px0, px1 = merged[-1] merged[-1] = ((py + y) / 2, min(px0, x0), max(px1, x1)) else: merged.append((y, x0, x1)) out[hall] = merged return out # ---------- 4) SQL 산출 ---------- def main(): src, out_sql = sys.argv[1], sys.argv[2] segs_by_container, inserts_by_container = parse(src) ws = world_segs(segs_by_container, inserts_by_container) chans = channels_of(ws) sql = io.StringIO() sql.write( "-- V66: K2 홀6~10 트렌치 실측 시드 — CAD(원본-1층 평면도.dwg) TRENCH 레이어 추출(V61의 K2판).\n" "-- 생성기: tools/cad/extract_trench_seed_k2.py (재생성 가능·결정적). 근거: docs/analysis/cad-extraction.md §K2 트렌치.\n" "-- 실측: 홀 프레임(±11~12° 회전) 로컬 가로 채널(쌍선 병합 중심선)·연장 실좌표. 포인트 6m 간격은 샘플링 규약.\n" "-- 교차검증: 전력간선 평면도(E3-59~69)의 채널 구조(9m 피치·쌍선·홀 배치각)와 정합 —\n" "-- 단, 그 도면의 유틸라인 xref는 크롭본(x<=509.2m)이라 시드 소스는 홀 프레임과 동일 원점인 원본 평면도 사용.\n" "-- 정책: 실측 확보 홀(H6~H10)의 가정(is_assumed) 포인트를 실측 포인트로 대체(K1 V61과 동일).\n" "-- 공급 플래그는 V5 정책 유지(공동구 — 전 서비스 true·gas는 홀 보유 여부), 위치만 실측 대체.\n\n" ) report = [] total = 0 for hall, merged in chans.items(): report.append(f"{hall}: 채널 {len(merged)}개 " + ", ".join(f"y={m[0]:.1f} x[{m[1]:.1f}..{m[2]:.1f}]" for m in merged)) if not merged: continue total += len(merged) sql.write(f"-- {hall}: 실측 채널 {len(merged)}개\n") sql.write(f"DELETE FROM trench WHERE hall_id = '{hall}' AND is_assumed;\n") values = [] for ri, (cy, x0, x1) in enumerate(merged, 1): k = 0 x = x0 while x <= x1 + 1e-6: k += 1 values.append( f"('{hall}-RT-{ri}-{k}', '{hall}', ST_SetSRID(ST_MakePoint({x:.2f}, {cy:.2f}), 0), " f"true, true, true, true, (SELECT has_gas FROM hall WHERE id='{hall}'), false)" ) x += POINT_STEP_M sql.write( "INSERT INTO trench (id, hall_id, geom, supply_power, supply_water, supply_air, supply_network, supply_gas, is_assumed)\nVALUES\n " + ",\n ".join(values) + "\nON CONFLICT (id) DO UPDATE SET geom = EXCLUDED.geom, is_assumed = EXCLUDED.is_assumed;\n\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} (채널 {total}개)") if __name__ == "__main__": main()