Files
CMPlanning/scripts/extract_alignment_plan.py
T
Claude f144d53f79 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
2026-10-20 23:46:44 +00:00

82 lines
3.1 KiB
Python

#!/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)