kintex/tools/cad/extract_trench_seed_k2.py
zio 0702b414bf feat(m2): V66 K2 hall 6-10 trench measured seed from CAD (65 channels, 941 points)
- Extract trench channels from original 1F site plan DWG (same origin as
  hall frames) via streaming DXF tag parser that walks xref BLOCK contents
  and resolves INSERT-chain transforms (missed by top-level iteration,
  which caused the earlier 'no trench layer in K2' misjudgment).
- Power trunk plan (E3-59~69) used as cross-validation only: its util-line
  xref is a cropped copy (x<=509.2m) with ~3.5m phase offset; angle
  clusters (0/+-11/-12 deg) confirm frame rotation alignment.
- Generator tools/cad/extract_trench_seed_k2.py: frame rotation, horizontal
  clip (Liang-Barsky), >=30m runs, 1.5m twin-line merge, 6m point sampling
  (V61 convention). H6 9 / H7 H8 H9 H10 14 channels each, is_assumed=false.
- All halls H1-H10 now have measured trench (PLANNING R4 resolved).
- Docs: cad-extraction.md V66 section, OWNER_FEEDBACK log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 00:49:45 +09:00

315 lines
12 KiB
Python

# -*- 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 <k2_plan1f.dxf> <out.sql>
좌표 규약: 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()