- 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>
461 lines
23 KiB
Python
461 lines
23 KiB
Python
"""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)]
|