feat: draft main-line centreline stations from plan sheets (Task 3, HITL)

extract_alignment_plan.py takes the midpoint of each station's two side
ticks (CA-ALGN-STSM) as the centreline point. Sheet 001 gives 4+700..5+500
with consecutive residual <= 0.02 m. Sheet 002 gives identical output, so
the duplicate question is left to HITL. Profile EL is not yet extracted.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5Cun3RHQKna9qxE6ybZ4A
This commit is contained in:
Claude committed 2026-10-20 23:46:44 +00:00
1 parent 14a4c4a37a
commit f144d53f79
5 files changed
+193

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env python3
"""Task 3 exploration: layer usage and long polylines / text clusters in a
plan-profile DXF, to locate the tunnel centreline (본선 축선) candidates."""
import collections
import json
import sys
import ezdxf
path = sys.argv[1]
doc = ezdxf.readfile(path)
msp = doc.modelspace()
by_layer = collections.Counter(e.dxf.layer for e in msp)
print("layers used:", len(by_layer))
for name, n in by_layer.most_common(25):
print(f" {n:6d} {name}")
# long polylines = candidate alignment / outline strings
polys = []
for e in msp:
if e.dxftype() == "LWPOLYLINE":
pts = list(e.get_points("xy"))
if len(pts) >= 20:
xs = [p[0] for p in pts]; ys = [p[1] for p in pts]
polys.append({"layer": e.dxf.layer, "n": len(pts),
"x": [round(min(xs), 1), round(max(xs), 1)],
"y": [round(min(ys), 1), round(max(ys), 1)]})
polys.sort(key=lambda d: -d["n"])
print("long LWPOLYLINE (>=20 vertices):", len(polys))
for p in polys[:12]:
print(" ", p)
# text that looks like station (STA) or level (EL) labels
import re
pat = re.compile(r"(STA|No\.?|EL|E\.L|\d+\+\d+|\+\d{2,3}\.\d{3}|\d{2,3}\.\d{2,3})", re.I)
hits = []
for e in msp:
if e.dxftype() in ("TEXT", "MTEXT"):
s = e.dxf.text if e.dxftype() == "TEXT" else e.plain_text()
if s and pat.search(s):
loc = e.dxf.insert if e.dxftype() == "TEXT" else e.dxf.insert
hits.append((round(loc.x, 1), round(loc.y, 1), s.strip()[:40]))
print("station/level-like texts:", len(hits))
for h in sorted(hits)[:25]:
print(" ", h)
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Task 3 (HITL draft): main-line centreline station table from a plan sheet.
Method (layers CA-ALGN-TEXT labels and CA-ALGN-STSM station ticks):
1. Station labels: 'N+mmm' is explicit; bare numbers take the km of the next
explicit label when they precede it (700 before 5+000 -> 4+700), otherwise
the km of the previous explicit label.
2. Each station has two labels (one per side of the road). Each label is
paired with its nearest tick; the centreline point is the midpoint of the
two tick points.
3. Checks reported (not forced): side offset per station, and consecutive
centreline distance vs station difference (residual).
DRAFT for HITL review. No value is adjusted to fit."""
import csv
import math
import re
import sys
from collections import defaultdict
import ezdxf
SRC, OUT = sys.argv[1], sys.argv[2]
msp = ezdxf.readfile(SRC).modelspace()
explicit_re = re.compile(r"^(\d+)\+(\d{3})$")
labels = [(e.dxf.insert.x, e.dxf.insert.y, e.dxf.text.strip())
for e in msp.query("TEXT") if e.dxf.layer == "CA-ALGN-TEXT"]
ticks = []
for e in msp.query("LINE"):
if e.dxf.layer == "CA-ALGN-STSM":
a, b = e.dxf.start, e.dxf.end
ticks.append(((a.x + b.x) / 2, (a.y + b.y) / 2))
labels = [l for l in labels if explicit_re.match(l[2]) or l[2].isdigit()]
labels.sort(key=lambda t: -t[1]) # along the sheet, NNE -> SSW
explicit_idx = [i for i, l in enumerate(labels) if explicit_re.match(l[2])]
station_of = {}
for i, (x, y, s) in enumerate(labels):
if explicit_re.match(s):
m = explicit_re.match(s)
station_of[i] = int(m.group(1)) * 1000 + int(m.group(2))
continue
v = int(s)
nxt = next((j for j in explicit_idx if j > i), None)
prv = next((j for j in reversed(explicit_idx) if j < i), None)
if prv is not None:
km = int(explicit_re.match(labels[prv][2]).group(1))
else:
km = int(explicit_re.match(labels[nxt][2]).group(1)) - 1
station_of[i] = km * 1000 + v
groups = defaultdict(list)
for i, (x, y, s) in enumerate(labels):
groups[station_of[i]].append((x, y))
def nearest_tick(pt):
return min(ticks, key=lambda t: math.hypot(t[0] - pt[0], t[1] - pt[1]))
centre = []
for sta in sorted(groups):
pts = groups[sta]
if len(pts) != 2:
continue
t1, t2 = nearest_tick(pts[0]), nearest_tick(pts[1])
side_gap = math.dist(t1, t2)
mid = ((t1[0] + t2[0]) / 2, (t1[1] + t2[1]) / 2)
centre.append((sta, mid[0], mid[1], side_gap))
with open(OUT, "w", newline="", encoding="utf-8") as fh:
w = csv.writer(fh)
w.writerow(["station_m", "centre_x", "centre_y", "side_gap_m"])
for sta, x, y, g in centre:
w.writerow([sta, round(x, 3), round(y, 3), round(g, 3)])
print(f"stations={len(centre)} (labels={len(labels)}, ticks={len(ticks)})")
prev = None
for sta, x, y, g in centre:
step = "" if prev is None else f" d={math.dist(prev[1:3], (x, y)):8.3f} dSTA={sta - prev[0]:5d} res={math.dist(prev[1:3], (x, y)) - (sta - prev[0]):+.3f}"
print(f" STA {sta:>5} ({x:12.3f}, {y:12.3f}) side_gap={g:6.2f}{step}")
prev = (sta, x, y)
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
"""Render the Task 3 draft centreline report (DOC/03_설계) from the centreline
CSVs written by extract_alignment_plan.py. Numbers are read, not typed."""
import csv
import math
import sys
CSV_001, CSV_002, OUT_MD = sys.argv[1], sys.argv[2], sys.argv[3]
def load(path):
with open(path, encoding="utf-8") as fh:
return [(int(r["station_m"]), float(r["centre_x"]), float(r["centre_y"]), float(r["side_gap_m"]))
for r in csv.DictReader(fh)]
a, b = load(CSV_001), load(CSV_002)
same = a == b
lines = ["# 본선 중심선 측점 좌표 (초안)\n",
"| 버전 | 일자 | 변경 내용 |", "|---|---|---|",
"| v0.1.00 | 2026-10-21 | 최초 작성. 평면도 02-001 기준 측점 4+700~5+500 중심선 초안 (HITL 검토 대상) |\n",
"## 1. 추출 방법",
"- 소스: 레이어 `CA-ALGN-TEXT`(측점 라벨), `CA-ALGN-STSM`(측점 눈금) [출처: C0060302-001]",
"- 측점 라벨은 측점당 2개(좌우 측). 각 라벨의 최근접 눈금 2점의 **중점**을 중심선 점으로 채택 (정의 HITL 확인 대상)",
"- 좌표는 도면 좌표계 그대로 (단위: 도면 표기 따름, 사용자 승인 2026-10-21)\n",
"## 2. 측점별 중심선 좌표 (02-001)\n",
"| 측점(m) | 표기 | X | Y | 좌우 간격(m) | 인접 거리(m) | 측점차(m) | 잔차(m) |",
"|---|---|---|---|---|---|---|---|"]
prev = None
for sta, x, y, g in a:
label = f"{sta // 1000}+{sta % 1000:03d}"
if prev is None:
lines.append(f"| {sta} | {label} | {x:,.3f} | {y:,.3f} | {g:.2f} | — | — | — |")
else:
d = math.dist(prev[1:3], (x, y))
lines.append(f"| {sta} | {label} | {x:,.3f} | {y:,.3f} | {g:.2f} | {d:.3f} | {sta - prev[0]} | {d - (sta - prev[0]):+.3f} |")
prev = (sta, x, y)
resid = [math.dist(a[i][1:3], a[i + 1][1:3]) - (a[i + 1][0] - a[i][0]) for i in range(len(a) - 1)]
lines += ["",
f"- 검증: 인접 측점 거리와 측점차의 잔차 최대 {max(abs(r) for r in resid):.3f} m (측점 9개, 8구간). 잔차는 보정하지 않고 그대로 기재",
"- 범위: 4+700 ~ 5+500 (800m). **이 시트 밖의 연장은 확보된 도면이 없음**\n",
"## 3. 02-002 시트 대조",
f"- 02-002 추출 결과 {'02-001과 측점 좌표가 동일' if same else '02-001과 차이 있음'} ({'중복 시트 가능성' if same else '차이 확인 필요'}). 원본 파일 md5는 상이함 [출처: C0060302-002]",
"- 02-002가 종단(프로파일) 시트인지 중복 시트인지는 사용자 확인 필요\n",
"## 4. 미완 항목 (OPEN 등재 대상)",
"- 종단 EL 추출 미실시 (측점별 계획고·지반고). `CR-TABL-TEX2`의 740.00~980.00 라벨은 20m 간격으로 보이나 측점 체계와 대응 미확인 [추론]",
"- 허용오차 ±50mm 판정은 EL 추출 후 실시",
"- 측점 4+700 이전·5+500 이후 구간 평면 도면 미확보\n"]
open(OUT_MD, "w", encoding="utf-8").write("\n".join(lines) + "\n")
print("written", OUT_MD, "same_as_002=", same, "max_resid=", round(max(abs(r) for r in resid), 3))