chore(rebar): Pier_T_rebar 작업 폴더 초기 구성 및 외부 의존 파일 통합

- Code\rebar_pipeline, review_pipeline, pier-program을 정션에서 실제 폴더로 전환
- Code\rhino_mcp 및 CLAUDE_rhino배경.md 추가, .mcp.json은 PYTHONPATH로 새 위치 사용
- pier-program 내부 .git 제거(이력은 backup\pier-program_20261028.bundle에 보관)
- worktree 가이드 반영(CLAUDE.local.md, HANDOFF.md)
- D:\Rhino-MCP 경로 참조를 새 위치로 치환

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

+460
View File
@@ -0,0 +1,460 @@
"""Drawing reader (semi-automatic): DWG -> DXF conversion, text index, text search and table candidates.
Values are never written into params/expected here; the reader only proposes candidates with their source
(drawing, layer, position) and the user confirms them in the web form.
Validated on one drawing set (Ungok P1 D1-D3): dimensions there are TEXT on dimension layers, not DIMENSION.
"""
import hashlib
import json
import re
import shutil
import subprocess
import tempfile
from collections import defaultdict
from pathlib import Path
import ezdxf
ODA = Path(r"C:\Program Files\ODA\ODAFileConverter 27.1.0\ODAFileConverter.exe")
EL_RE = re.compile(r"EL\.?\s*([0-9]{1,3}\.[0-9]{2,3})\b")
def sha256(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def convert(dwg_paths, out_dir):
"""Convert DWG files to DXF in out_dir (sources are copied first and never touched)."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
audit = []
with tempfile.TemporaryDirectory() as tmp:
tin, tout = Path(tmp, "in"), Path(tmp, "out")
tin.mkdir(), tout.mkdir()
for p in dwg_paths:
shutil.copy2(p, tin / Path(p).name)
subprocess.run([str(ODA), str(tin), str(tout), "ACAD2018", "DXF", "0", "1", "*.DWG"], check=True, timeout=600)
for p in dwg_paths:
produced = tout / (Path(p).stem + ".dxf")
if not produced.is_file():
raise RuntimeError(f"conversion failed: {Path(p).name}")
target = out_dir / produced.name
shutil.copy2(produced, target)
audit.append({"source": Path(p).name, "source_sha256": sha256(p), "dxf": target.name,
"dxf_sha256": sha256(target), "engine": "ODA File Converter 27.1.0"})
return audit
def _text(e):
return (e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text).strip()
def index_dxf(path, label):
"""All TEXT/MTEXT (incl. those inside block inserts) of one drawing with layer and position."""
doc = ezdxf.readfile(path)
msp = doc.modelspace()
out = []
for e in msp.query("TEXT MTEXT"):
p = e.dxf.insert
h = e.dxf.char_height if e.dxftype() == "MTEXT" else e.dxf.height
out.append({"dwg": label, "layer": e.dxf.layer, "x": round(p.x, 1), "y": round(p.y, 1),
"h": round(h or 0, 1), "text": _text(e), "handle": e.dxf.handle})
for ins in msp.query("INSERT"):
try:
for v in ins.virtual_entities():
if v.dxftype() in ("TEXT", "MTEXT"):
p = v.dxf.insert
out.append({"dwg": label, "layer": v.dxf.layer, "x": round(p.x, 1), "y": round(p.y, 1),
"h": 0, "text": _text(v), "handle": ins.dxf.handle, "block": ins.dxf.name})
except Exception: # unresolvable block (proxy etc.): its texts are simply not indexed
pass
return [t for t in out if t["text"]]
def search(texts, query, regex=False, limit=200):
if regex:
rx = re.compile(query, re.I)
hit = [t for t in texts if rx.search(t["text"])]
else:
q = query.lower()
hit = [t for t in texts if q in t["text"].lower()]
return hit[:limit]
def el_candidates(texts):
"""'EL. 26.208' style texts -> value in mm (Z = EL x 1000)."""
out = []
for t in texts:
m = EL_RE.search(t["text"].replace(" ", ""))
if m:
out.append(dict(t, value_mm=round(float(m.group(1)) * 1000, 1)))
return out
def table_below(texts, anchor, align="center", width=25000.0, depth=20000.0, stops=(), row_tol=0.6, stops_below=()):
# window scales with the title text height (25,000 x 20,000 at the Ungok title height 420; Ulsan 1:100 sheet
# has 700 and wider tables, 2026-10-13)
k = max(1.0, (anchor["h"] or 0) / 420.0)
width, depth = width * k, depth * k
"""Texts in a window below an anchor (table title) of the same drawing, grouped into rows/columns.
Window: `width` wide, centred on the title (align="center") or starting at it (align="left"), cut at the
nearest other table title on the right (`stops`), `depth` deep. Columns are then limited to the x range of
the header row (first row with >= 4 cells) plus one column gap, which drops neighbouring drawing texts.
Returns [{"y": .., "cells": [{"x", "text", "layer"}...]}], top row first.
"""
h = anchor["h"] or 150
x0, x1 = ((anchor["x"] - width / 2, anchor["x"] + width / 2) if align == "center"
else (anchor["x"] - 2 * h, anchor["x"] + width))
for s in stops:
if s["dwg"] == anchor["dwg"] and s is not anchor and abs(s["y"] - anchor["y"]) < 2 * h and anchor["x"] < s["x"] < x1:
x1 = s["x"] - 2 * h
y1, y0 = anchor["y"], anchor["y"] - depth
for s in list(stops) + list(stops_below): # another table title below in the same column ends this table (Ulsan SD400
if (s["dwg"] == anchor["dwg"] and s is not anchor and y0 < s["y"] < y1 - 2 * h # above SD500, 2026-10-13)
and abs(s["x"] - anchor["x"]) < 2 * h):
y0 = s["y"] + h
cells = [t for t in texts if t["dwg"] == anchor["dwg"] and x0 <= t["x"] <= x1 and y0 <= t["y"] < y1
and t is not anchor]
if not cells:
return []
tol = row_tol * (sorted(t["h"] for t in cells)[len(cells) // 2] or 150)
rows = []
for t in sorted(cells, key=lambda t: -t["y"]):
if rows and abs(rows[-1]["y"] - t["y"]) <= tol:
rows[-1]["cells"].append(t)
else:
rows.append({"y": t["y"], "cells": [t]})
for r in rows:
r["cells"] = sorted(r["cells"], key=lambda c: c["x"])
# header = first row with >= 4 cells whose first following row with a plain number cell (text-only rows =
# further header lines, Ulsan three-line header, are skipped) has at least half as many cells. On Ramp-A P1
# the labels of the detail drawing between the title and the table (C1 C3 C2 S1 S2, then '2,200' alone) were
# taken as the header (2026-10-13)
def numeric(r):
return any(re.fullmatch(r"-?[\d.,]+", c["text"].strip()) for c in r["cells"])
def next_data_ok(i):
# rows lying wholly outside the header's x span belong to a neighbouring table and are skipped (Okseong P1:
# ELEVATION TABLE rows interleave in the 솔플레이트 TABLE window, 2026-10-14)
hx = [c["x"] for c in rows[i]["cells"]]
pad = max([b - a for a, b in zip(sorted(hx), sorted(hx)[1:])] or [0])
inside = lambda r: any(min(hx) - pad <= c["x"] <= max(hx) + pad for c in r["cells"])
nxt = next((r for r in rows[i + 1:] if numeric(r) and inside(r)), None)
return nxt is not None and 2 * len(nxt["cells"]) >= len(rows[i]["cells"])
cand = [i for i, r in enumerate(rows) if len(r["cells"]) >= 4]
good = [i for i in cand if next_data_ok(i)]
head = rows[(good or cand)[0]] if cand else None
if head:
# multi-line header (Ulsan schedule, 2026-10-13: '길이'/'총길이' one line above '번호 형식 직경 개수 A..J',
# units '(M)' one line below): merge the text-only lines within 2 text heights below the first header line
# into it; unit lines (all cells in parentheses) and cells on top of an existing header cell are dropped
hh = sorted(t["h"] for t in cells)[len(cells) // 2] or 150
i = rows.index(head)
while i + 1 < len(rows) and head["y"] - rows[i + 1]["y"] <= 2 * hh:
nxt = rows[i + 1]
if any(re.fullmatch(r"[\d.,~%()°˚-]+", c["text"].strip()) for c in nxt["cells"]):
break
if not all(c["text"].strip().startswith("(") for c in nxt["cells"]):
for c in nxt["cells"]:
if all(abs(c["x"] - o["x"]) > hh for o in head["cells"]):
head["cells"].append(c)
head["cells"].sort(key=lambda c: c["x"])
rows.pop(i + 1)
xs = [c["x"] for c in head["cells"]]
# the header row may also hold the header of a neighbouring table on the same line (Ulsan: ELEVATION and
# 솔플레이트 TABLE side by side, 2026-10-13): split it at gaps > 3 x the median gap and keep the cluster
# nearest to the title
gaps = sorted(b - a for a, b in zip(xs, xs[1:]))
med = gaps[len(gaps) // 2] if gaps else 0
clusters, cur = [], [xs[0]]
for a_, b_ in zip(xs, xs[1:]):
if med and b_ - a_ > 3 * med:
clusters.append(cur)
cur = []
cur.append(b_)
clusters.append(cur)
xs = min(clusters, key=lambda cl: 0 if cl[0] <= anchor["x"] <= cl[-1] else min(abs(cl[0] - anchor["x"]), abs(cl[-1] - anchor["x"])))
gap = max([b - a for a, b in zip(xs, xs[1:])] or [med or 1000])
lo, hi = xs[0] - gap, xs[-1] + gap
rows = rows[rows.index(head):]
for r in rows:
r["cells"] = [c for c in r["cells"] if lo <= c["x"] <= hi]
rows = [r for r in rows if r["cells"]]
return [{"y": r["y"], "cells": [{"x": c["x"], "text": c["text"], "layer": c["layer"]} for c in r["cells"]]}
for r in rows]
TABLE_ANCHORS = [ # (key, title pattern, title alignment over the table)
("elevation", r"ELEVATION\s*TABLE", "center"),
("sole_plate", r"솔플레이트.*TABLE", "center"),
("schedule_SD400", r"^\(SD[0-9]00\)$", "left"),
]
def table_candidates(texts):
out = []
titles = {key: search(texts, pat, regex=True) for key, pat, _ in TABLE_ANCHORS}
for key, pat, align in TABLE_ANCHORS:
anchors = titles[key]
# a table title of another kind below in the same column also ends the window (Ramp-A P1: the ELEVATION
# TABLE window ran into the 솔플레이트 TABLE below it, 2026-10-13); on the right only titles of the same kind
below = [t for k_, ts in titles.items() if k_ != key for t in ts]
for a in anchors:
k = f"schedule_{a['text'].strip('()')}" if key.startswith("schedule") else key
out.append({"key": k, "anchor": a, "rows": table_below(texts, a, align, stops=anchors, stops_below=below)})
return out
def build_index(dxf_paths, labels, out_json):
texts = []
for p, lab in zip(dxf_paths, labels):
texts += index_dxf(p, lab)
Path(out_json).write_text(json.dumps(texts, ensure_ascii=False), encoding="utf-8")
return texts
def table_records(rows, key_pred):
"""Map cells of data rows to header columns (nearest header x within 0.6 x the gap to the neighbouring header
on the cell's side; blank cells stay absent). Data rows = rows whose first cell satisfies key_pred; that cell
is stored as "_key". Returns [(row y, {header: text})].
The tolerance is per column side, not the smallest gap of the table: a long range text such as '12.228~3.151'
in a wide column (Ungok P2 C12, 562 from the 'A' header, smallest gap 900) was dropped (OPEN #19 A3)."""
head = [(c["x"], re.sub(r"\s+", "", c["text"])) for c in rows[0]["cells"]]
xs = [x for x, _ in head]
gaps = [b - a for a, b in zip(xs, xs[1:])]
out = []
for r in rows[1:]:
if not r["cells"] or not key_pred(r["cells"][0]["text"]):
continue
rec = {"_key": r["cells"][0]["text"]}
for c in r["cells"][1:]:
i = min(range(len(head)), key=lambda j: abs(head[j][0] - c["x"]))
x, name = head[i]
side = gaps[i - 1] if c["x"] < x and i > 0 else gaps[min(i, len(gaps) - 1)] if gaps else 0
if abs(x - c["x"]) <= 0.6 * side and name not in rec:
rec[name] = c["text"]
out.append((r["y"], rec))
return out
def _num(s):
return float(s.replace(",", ""))
def _int(s):
"""Table integer; thousands separators allowed ('1,000' in the Ramp-A sole plate table, 2026-10-13)."""
return int(round(_num(s)))
def _m_to_mm(s):
v = _num(s)
return round(v * 1000, 1) if "." in s and v < 100 else v
MARK_RE = re.compile(r"^[A-Z]{1,2}\d{1,2}$")
EL_X = "EL.'X'"
def _get(d, path):
for k in path:
if isinstance(d, list) and isinstance(k, int) and 0 <= k < len(d): # list index (params table rows)
d = d[k]
continue
if not isinstance(d, dict) or k not in d:
return None
d = d[k]
return d
def proposals(cands, params, expected):
"""Candidate vs current value per field. Schedule rows are routed to the expected/params entries that
already list that mark (G2 P*, G3 B*, G4 C*/S*, params rebar_marks). Returns rows for the review table:
{"file": "expected"|"params", "path": [...], "current", "candidate", "src", "same"}."""
docs = {"expected": expected, "params": params}
rows = []
def row(file, path, cand, src):
cur = _get(docs[file], path)
if cur is None and file == "expected" and path[0] in ("G1", "G2", "G3", "G4", "G5"):
return # field not used by this pier's checks
if cur is None and file == "params":
return # only fields this pier's params already have
same = cur == cand or (isinstance(cur, (int, float)) and isinstance(cand, (int, float))
and abs(cur - cand) < 1e-6)
rows.append({"file": file, "path": path, "current": cur, "candidate": cand, "src": src, "same": same})
def params_rows(t, c):
"""The same bearing / sole plate table value as a separate params row (OPEN #19 A4): the user selects
it on its own, so a misread value does not change the model and its check value in one click."""
no = c.get("no") if c.get("no") is not None else (t[-1] if t[-1].isdigit() else None)
if no is None:
return
def index(path):
lst = _get(params, path)
return next((i for i, e in enumerate(lst) if isinstance(e, dict) and str(e.get("no")) == str(no)),
None) if isinstance(lst, list) else None
bi, si = index(["bearing_seats", "bearings"]), index(["sole_plates", "table"])
bp, sp = ["bearing_seats", "bearings", bi], ["sole_plates", "table", si]
if t[1:3] == ["G5", "el_x"] and bi is not None:
row("params", bp + ["el_x"], c["value"], c["src"])
elif t[1:4] == ["G1", "seats", "table"] and bi is not None:
for k, v in zip(("h1", "h2", "A", "B"), c["value"][1:]):
row("params", bp + [k], v, c["src"])
elif t[1] == "G5" and t[2] in ("S", "W", "L", "T", "h3") and si is not None:
if t[2] == "h3" and "h3" not in params["sole_plates"]["table"][si]:
if not any(r["file"] == "params" and r["path"] == ["sole_plates", "h3"]
and r["candidate"] == c["value"] for r in rows): # common h3 (Ungok): once per value
row("params", ["sole_plates", "h3"], c["value"], c["src"])
else:
row("params", sp + [t[2]], c["value"], c["src"])
for c in cands:
t = c["target"].split(".")
if t[0] == "expected":
path = t[1:]
if c.get("no") is not None and isinstance(_get(expected, path), dict):
path = path + [str(c["no"])] # per-bearing values (Ulsan G5 h3 / L / T, 2026-10-13)
row("expected", path, c["value"], c["src"])
params_rows(t, c)
elif t[0] == "schedule":
m, v = t[1], c["value"]
if m in (_get(expected, ["G2", "schedule_L"]) or {}):
row("expected", ["G2", "schedule_L", m], v["L"], c["src"])
row("expected", ["G2", "schedule_n", m], v["n"], c["src"])
if m in (_get(expected, ["G3", "schedule"]) or {}):
row("expected", ["G3", "schedule", m], [v["L"], v["n"]], c["src"])
if m in (_get(expected, ["G4", "count"]) or {}):
row("expected", ["G4", "count", m], v["n"], c["src"])
row("expected", ["G4", "L", m], v["L"], c["src"])
if m in (params.get("rebar_marks") or {}):
# a schedule without a shape column (Ramp-A D4, 2026-10-13) proposes the diameter only
shape = v["shape"] if v["shape"] is not None else (params["rebar_marks"][m] or {}).get("shape")
row("params", ["rebar_marks", m], {"dia": v["dia"], "shape": shape}, c["src"])
return rows
def apply_proposals(rows, params, expected):
"""Write the candidate value of each given proposal row into params / expected (in place)."""
docs = {"expected": expected, "params": params}
for r in rows:
d = docs[r["file"]]
for k in r["path"][:-1]:
d = d[k]
d[r["path"][-1]] = r["candidate"]
def field_candidates(tables):
"""Candidate values for params / expected fields from the recognised tables.
Returns [{"target": "expected.G5.el_x.1", "value": .., "src": "D2 ELEVATION TABLE NO.1 EL.'X' 43.625", "no": ..}].
`no` (bearing number) routes h3 / L / T to the per-bearing entry when expected holds them per bearing.
Only the tables validated on the Ungok P1 drawings are mapped; everything else stays manual.
"""
out = []
def add(target, value, src, no=None):
out.append({"target": target, "value": value, "src": src, "no": no})
for t in tables:
dwg = t["anchor"]["dwg"]
if not t["rows"]:
continue
if t["key"] == "elevation":
for _, r in table_records(t["rows"], lambda s: s.isdigit()):
no = r["_key"]
src = f"{dwg} ELEVATION TABLE NO.{no}"
if EL_X in r:
elx = round(_num(r[EL_X]) * 1000)
add(f"expected.G5.el_x.{no}", elx, f"{src} EL.'X' {r[EL_X]}")
if all(k in r for k in ("h1", "h2", "A", "B")):
add(f"expected.G1.seats.table.{no}", [elx, _int(r["h1"]), _int(r["h2"]), _int(r["A"]), _int(r["B"])],
f"{src} EL.'X', h1, h2, A, B")
if "h3" in r:
add("expected.G5.h3", _int(r["h3"]), f"{src} h3", no)
elif t["key"] == "sole_plate":
for _, r in table_records(t["rows"], lambda s: s.isdigit()):
no = r["_key"]
src = f"{dwg} 솔플레이트 상세 TABLE NO.{no}"
if "S(%)" in r:
add(f"expected.G5.S.{no}", _num(r["S(%)"]), f"{src} S(%)")
if "W" in r:
add(f"expected.G5.W.{no}", _int(r["W"]), f"{src} W")
for k in ("L", "T"):
if k in r:
add(f"expected.G5.{k}", _int(r[k]), f"{src} {k}", no)
elif t["key"].startswith("schedule"):
last_dia = None
for _, r in table_records(t["rows"], lambda s: bool(MARK_RE.match(s))):
m = r["_key"]
dia = r.get("직경", "")
dia = last_dia if dia in ('"', "") else dia
last_dia = dia
src = f"{dwg} 철근재료표 {t['anchor']['text']} {m}"
try:
L = _m_to_mm(r["길이"]) if "길이" in r else None
n = int(r["개수"]) if r.get("개수", "").isdigit() else None
if n is None and L and "총길이" in r:
n = round(_num(r["총길이"]) * 1000 / L) # quantity blank -> total / L (derived)
src += " (개수 = 총길이/길이 [역산])"
except ValueError:
# cells landed in the wrong column (e.g. a two-row header, Ulsan 2026-10-13): no candidate
continue
add(f"schedule.{m}", {"shape": r.get("형식"), "dia": dia, "n": n, "L": L}, src)
return out
def _pt(p):
return [round(p[0], 1), round(p[1], 1)]
def entity_info(dxf_path, handle):
"""One drawing object by handle (OPEN #19 A6: D1 outline values were read by handle with ad-hoc scripts).
Returns {"type", "layer", "handle", "values": {name: value}} or None. Coordinates are drawing (DXF) units."""
doc = ezdxf.readfile(dxf_path)
e = doc.entitydb.get(handle.strip().upper())
if e is None or not hasattr(e, "dxf"):
return None
t, d = e.dxftype(), e.dxf
v = {}
if t == "LINE":
v = {"start": _pt(d.start), "end": _pt(d.end), "length": round((d.end - d.start).magnitude, 1),
"dx": round(d.end.x - d.start.x, 1), "dy": round(d.end.y - d.start.y, 1)}
elif t in ("LWPOLYLINE", "POLYLINE"):
pts = [_pt(p) for p in (e.get_points("xy") if t == "LWPOLYLINE" else e.points())]
segs = [((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5 for a, b in zip(pts, pts[1:] + pts[:1] if e.is_closed else pts[1:])]
v = {"closed": e.is_closed, "vertices": len(pts), "points": pts[:50], "segment_lengths": [round(s, 1) for s in segs[:50]],
"length (straight segments)": round(sum(segs), 1)}
elif t in ("ARC", "CIRCLE"):
v = {"centre": _pt(d.center), "radius": round(d.radius, 1)}
if t == "ARC":
sweep = (d.end_angle - d.start_angle) % 360
v.update(start_angle=round(d.start_angle, 3), end_angle=round(d.end_angle, 3),
arc_length=round(d.radius * sweep * 3.141592653589793 / 180, 1),
start_point=_pt(e.start_point), end_point=_pt(e.end_point))
elif t in ("TEXT", "MTEXT"):
v = {"insert": _pt(d.insert), "text": _text(e)}
elif t == "INSERT":
v = {"block": d.name, "insert": _pt(d.insert), "rotation": d.get("rotation", 0),
"scale": [d.get("xscale", 1), d.get("yscale", 1)]}
elif t == "DIMENSION":
v = {"measurement": round(e.get_measurement(), 1) if hasattr(e, "get_measurement") else None,
"text": d.get("text", "")}
try:
from ezdxf import bbox
b = bbox.extents([e])
if b.has_data:
v["bbox"] = [_pt(b.extmin), _pt(b.extmax)]
v["bbox_size"] = [round(b.size.x, 1), round(b.size.y, 1)]
except Exception: # objects without geometry (e.g. an empty block) have no extents
pass
return {"type": t, "layer": d.get("layer", ""), "handle": d.handle, "values": v}
def el_list(texts):
"""EL texts for manual reference (no automatic field mapping: position decides the meaning)."""
return [{"dwg": t["dwg"], "layer": t["layer"], "x": t["x"], "y": t["y"], "text": t["text"],
"value_mm": t["value_mm"]} for t in el_candidates(texts)]