"""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)]