Files
Pier_v1/Code/교각모델링앱/engine/geomread.py
T
ahnyongjooandClaude Sonnet 5.5 964a841de1 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>
2026-10-28 11:20:18 +09:00

692 lines
47 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Read the main dimensions of an Ungok-type pier straight from its drawings and propose them as params / expected values (2026-10-20, improvement #34-1).
Origin: the same readers were first written as separate tools (tools/extract_coping_outline.py, infer_footing_rebar.py, verify_pier_geometry.py,
apply_pier_read.py) while the Ungok P3 / P4 piers were modelled; they cost 77 + 144 hand-made value changes. Everything is read from the drawing
(D1 lines / circles / polylines / texts, the schedule tables) or derived by a rule that was reproduced on the finished template pier first:
coping outline + steps + soffit flat D1 front view lines (CS-CONC-MAJR), heights from the 'EL.' label next to the highest step
coping rebar tiers / C15 / C16 / loops D1 front view rebar lines (CS-RBAR-MAJR), C14 points = right-half circles mirrored
footing / lean / piles / column D1 dimension texts at the sheet positions of the template frame, pile chains `n@pitch=total`
bearings plan view pad polylines (datum = coping plan centre), pedestal outlines
footing rebar layer rule (engine/footing_rule) + the schedule counts + mid layers from the drawn dots
column rebar schedule rows + first hoop level = footing bottom + 1180
Frame: all sheet positions are those of the Ungok P1 drawing set (the same frame as P2 .. P4); `frame_ok()` tells whether a new drawing has it.
Validation: `python tools/test_geomread.py` runs the reader on P1 (must reproduce its params within 1.5 mm), on the finished P3 / P4 (no material change)
and on the early P4 backup (P1 copy) which must come out equal to the verified P4 values.
`read()` has no side effect: it returns changed COPIES of params / expected and the list of changes; the web screen / tools write them.
"""
import copy
import glob
import json
import math
import re
from pathlib import Path
import ezdxf
from engine import anchors, footing_rule, reader
FRAME_MIN_COVERAGE = 0.70
DATUM_X, DATUM_Y = 12162.4, 26412.3 # coping plan centre in the P1 plan view (all piers of the frame)
SEC_CENTRE_X = 30133.6 # footing centre in the section view of the P1 sheet
ARC_X = 6951.9 # x of the soffit arc centres (about the pier axis)
ORDER = {"XIV": "A B C R L", "XVI": "A", "VI": "A B R L", "V": "A B R L", "IX": "A B R L", "III": "A B L"}
GROUPS = (("coping.", "코핑"), ("coping_rebar.", "코핑 철근"), ("footing.", "기초·말뚝·기둥"), ("lean_concrete.", "기초·말뚝·기둥"), ("piles.", "기초·말뚝·기둥"),
("column.", "기초·말뚝·기둥"), ("bearing_seats.", "받침"), ("footing_rebar.", "기초 철근"), ("column_rebar.", "기둥 철근"), ("display.", "표시"),
("pier_id", "교각 식별자"))
class FrameError(Exception):
"""the drawing does not have the Ungok frame (or a needed element is missing) -- the message says what"""
def same(a, b, tol=1.5):
"""equal up to drawing rounding. Numbers: millimetre values (|v| >= 100) within `tol` (1.5 mm); SMALL numbers (counts, offsets, angles) must agree to
0.15 (0.05 for angles) -- a count of 36 is not 37. Texts that are numbers (schedule cells in metres: '17.247') must be equal; texts with words
(rule descriptions) compare by their numbers within 0.5. Lists / dicts element by element."""
if isinstance(a, bool) or isinstance(b, bool):
return a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return abs(a - b) <= (tol if max(abs(a), abs(b)) >= 100 else min(tol, 0.15))
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(same(x, y, tol) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(same(a[k], b[k], tol) for k in a)
if isinstance(a, str) and isinstance(b, str):
pure = lambda t: re.fullmatch(r"\s*-?\d[\d,]*\.?\d*\s*", t) is not None # noqa: E731
if pure(a) and pure(b):
return abs(float(a.replace(",", "")) - float(b.replace(",", ""))) <= 0.0005
na, nb = re.findall(r"-?\d[\d,]*\.?\d*", a), re.findall(r"-?\d[\d,]*\.?\d*", b)
if na and len(na) == len(nb) and re.sub(r"[\d,.\-+]+", "", a) == re.sub(r"[\d,.\-+]+", "", b):
return all(abs(float(x.replace(",", "")) - float(y.replace(",", ""))) <= 0.5 for x, y in zip(na, nb))
return a == b
def _points_key(v):
return sorted(v, key=lambda p: (p[0], p[1]) if isinstance(p, list) else p)
EXACT_PARAMS = ("footing.", "lean_concrete.", "column.", "piles.", "column_rebar.", "footing_rebar.a_", "footing_rebar.b9", "footing_rebar.b10", "footing_rebar.layers.bottom",
"footing_rebar.layers.top", "footing_rebar.schedule", "footing_rebar.counts", "pier_id", "display.")
EXACT_EXPECTED = ("G1.footing", "G1.column", "G1.piles", "G2.", "G3.", "pier_id")
def _tol(which, path):
"""Values taken from TEXTS (dimension texts, schedule cells) are exact numbers: a 1 mm difference is a real difference (the bar length checks have a
1 mm tolerance). Values measured on CAD GEOMETRY (lines, circles, polylines) carry drawing rounding: 1.5 mm; angles 0.05."""
if "rotation" in path:
return 0.05
if re.fullmatch(r"coping_rebar\.top\.tiers\.\d\.a", path) or path.startswith(EXACT_EXPECTED if which == "expected" else EXACT_PARAMS):
return 0.0005
return 1.5
def _top_left(P, rng, ST, c16z):
"""Top face of the left C9 stirrups (not drawn). P1 rule: step top - 100. When the schedule's C9 b range does not follow from that rule, the top is
back-calculated from the range (b = top - stirrup bottom + b_offset) at the inner and the outer stirrup; accepted only when both ends agree (P4, 2026-10-20).
-> (bands [[|x| from, z]], why)"""
rule = [[0, c16z], [abs(ST[1][1]), ST[1][2] - 100], [abs(ST[0][1]), ST[0][2] - 100]]
why = "소단 − 100 (P1 규칙, 추론: 좌측 스터럽은 도면에 그려져 있지 않음 — 재료표 C9 b 범위와 대조하세요)"
cr = P["coping_rebar"]
st, bt, xs = cr["stirrups"], cr["bottom"], cr["stirrups"].get("x_c9")
if not (rng and rng[0] == "b_sched" and xs and cr.get("soffit_arc_centre")):
return rule, why
ax, cz = cr["soffit_arc_centre"]
def bottom(x):
x = abs(x)
return bt["z_flat"] if x <= bt["half_flat"] else cz + math.sqrt(bt["R_offset"] ** 2 - (x - ax) ** 2)
def b_of(bands, x):
z = bands[0][1]
for frm, zz in bands:
if abs(x) >= frm:
z = zz
return z - bottom(x) + st["b_offset"]
hi, lo = max(rng[1:3]), min(rng[1:3])
xi, xo = min(xs, key=abs), max(xs, key=abs)
if abs(b_of(rule, xi) - hi) <= 1.5 and abs(b_of(rule, xo) - lo) <= 1.5:
return rule, why + " — 재료표 C9 b 범위와 일치"
zi, zo = hi - st["b_offset"] + bottom(xi), lo - st["b_offset"] + bottom(xo)
if abs(zi - zo) <= 1.5:
z = (zi + zo) / 2
t1 = cr["top"]["tiers"]["1"]["z"]
z = t1 if abs(z - t1) <= 1.5 else round(z)
return [[0, c16z], [abs(xi) - 50, z]], (f"재료표 C9 b 범위(최대 {hi:g}, 최소 {lo:g})로 역산한 윗면 {z:g} — 안쪽·바깥쪽 스터럽이 {abs(zi - zo):.1f} mm 안에서 일치"
" (추론: 소단 − 100 규칙은 재료표와 어긋남)")
return rule, why + " — 재료표 C9 b 범위와 어긋나고 역산값도 두 끝이 일치하지 않음, 눈으로 확인하세요"
def _num(s):
return float(re.search(r"\d+\.\d+|\d+", s).group())
def _group(path):
for pre, name in GROUPS:
if path.startswith(pre) or path == pre:
return name
return "기타"
def dxf_of(pier_dir, audit):
"""DXF file of the front-view sheet D1: the audit entry labelled D1, else the file named (1)."""
ent = next((a for a in audit if a.get("label") == "D1"), None)
if ent and (pier_dir / "drawings" / "dxf" / ent["dxf"]).exists():
return pier_dir / "drawings" / "dxf" / ent["dxf"]
f = sorted(glob.glob(str(pier_dir / "drawings" / "dxf" / "*(1)*.dxf")))
if not f:
raise FrameError("D1(교각도 1) DXF가 없습니다")
return Path(f[0])
def read(params, expected, ref, texts, dxf_path, pier_name):
"""-> (params', expected', changes, notes). changes: dicts {file, path, old, new, why, group}; notes: strings for the person."""
P, E = copy.deepcopy(params), copy.deepcopy(expected)
changes, notes = [], []
def put(doc, which, path, val, why):
keys = path.split(".")
x = doc
for k in keys[:-1]:
x = x[int(k)] if isinstance(x, list) else x[k]
last = int(keys[-1]) if isinstance(x, list) else keys[-1]
old = x.get(last) if isinstance(x, dict) else x[last]
cmp_old, cmp_val = old, val
if path.endswith(("c14.points", "c14_levels")) and isinstance(old, list) and isinstance(val, list):
cmp_old, cmp_val = _points_key(old), _points_key(val) # the order of a point list carries no meaning
if old is None and path.endswith(("piles.pitch_x", "piles.pitch_y")) and isinstance(x, dict) and same(x.get("pitch"), val):
return # older layout: one `pitch` for both directions
if old is None and path.endswith("radius_by_mark") and isinstance(x, dict) and val.get("P3") == x.get("radius"):
return
if not same(cmp_old, cmp_val, _tol(which, path)):
x[last] = val
changes.append(dict(file=which, path=path, old=old, new=val, why=why, inferred="추론" in why,
group=_group(path) if which == "params" else "기대값(expected) " + path.split(".")[0]))
MS = list(ezdxf.readfile(str(dxf_path)).modelspace())
# ================= coping outline / datum
L = [(e.dxf.start.x, e.dxf.start.y, e.dxf.end.x, e.dxf.end.y) for e in MS if e.dxftype() == "LINE" and e.dxf.layer == "CS-CONC-MAJR"]
tips = sorted({(round(a, 1), round(b, 1), round(c, 1), round(d, 1)) for a, b, c, d in L if 150 < abs(c - a) < 250 and 1300 < abs(d - b) < 1900 and 0 < b < 25000})
if len(tips) != 2:
raise FrameError(f"코핑 양 끝 경사선 2개를 찾지 못했습니다({len(tips)}개)")
top = lambda l: (l[0], l[1]) if l[1] > l[3] else (l[2], l[3]) # noqa: E731
bot = lambda l: (l[2], l[3]) if l[1] > l[3] else (l[0], l[1]) # noqa: E731
left, right = sorted(tips, key=lambda l: top(l)[0])
CX = (bot(left)[0] + bot(right)[0]) / 2
cur, end = top(left), top(right)
hv = [(round(a, 1), round(b, 1), round(c, 1), round(d, 1)) for a, b, c, d in L if abs(a - c) < .05 or abs(b - d) < .05]
steps, used = [], set()
while abs(cur[0] - end[0]) > .5 or abs(cur[1] - end[1]) > .5:
nxt = None
for s in hv:
if s in used:
continue
for p, q in (((s[0], s[1]), (s[2], s[3])), ((s[2], s[3]), (s[0], s[1]))):
if abs(p[0] - cur[0]) < .5 and abs(p[1] - cur[1]) < .5 and q[0] >= cur[0] - .5 and (q[1] >= cur[1] - .5 or abs(q[0] - cur[0]) > .5):
nxt = (s, q)
break
if nxt:
break
if not nxt:
raise FrameError(f"코핑 윗면 계단 선이 끊겼습니다 ({cur[0]:.0f}, {cur[1]:.0f})")
used.add(nxt[0])
if abs(nxt[1][1] - cur[1]) < .05:
steps.append([cur[0], nxt[1][0], cur[1]])
cur = nxt[1]
if len(steps) != 5:
raise FrameError(f"코핑 윗면 계단이 {len(steps)}단입니다 (5단 형식만 읽습니다)")
y_hi = max(s[2] for s in steps)
els = [t for t in texts if t["dwg"] == "D1" and t["text"].startswith("EL.") and abs(t["x"] - end[0]) < 1500 and 0 < t["y"] - y_hi < 1200]
if not els:
raise FrameError("코핑 윗면 최고 단 옆의 EL 글자를 찾지 못했습니다")
OFF = _num(els[0]["text"]) * 1000 - y_hi # EL = y + OFF (front view)
ST = [(round(a - CX), round(b - CX), round(y + OFF)) for a, b, y in steps]
flat_y = sorted({round(f[1], 1) for f in hv if abs(f[1] - f[3]) < .05 and 1800 < abs(f[2] - f[0]) < 2600 and abs((f[0] + f[2]) / 2 - CX) < 600 and bot(left)[1] - 1700 < f[1] < bot(left)[1] + 100})
if not flat_y:
raise FrameError("코핑 아랫면 평평부를 찾지 못했습니다")
Z_FLAT = round(flat_y[0] + OFF)
TIP_RISE = round(top(left)[1] - bot(left)[1])
for i, (a, b, z) in enumerate(ST):
put(P, "params", f"coping.top_steps.{i}.x0", float(a), "D1 코핑 윗면 외곽선")
put(P, "params", f"coping.top_steps.{i}.x1", float(b), "D1 코핑 윗면 외곽선")
put(P, "params", f"coping.top_steps.{i}.z", z, "D1 코핑 윗면 외곽선 + EL 글자")
put(P, "params", "coping.z_bottom_flat", Z_FLAT, "D1 코핑 아랫면 평평부")
tip_z = Z_FLAT + TIP_RISE
# ================= coping rebar: lines and circles of the front view
V = []
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.start.x - e.dxf.end.x) < .05 and CX - 10 < e.dxf.start.x < 18500:
y0, y1 = sorted((e.dxf.start.y, e.dxf.end.y))
if 15500 < y0 and y1 < 20300 and y1 - y0 > 400:
V.append((round(e.dxf.start.x - CX), y0 + OFF, y1 + OFF))
pick = [v for v in V if v[0] == 2900]
CZ_REL = round(ref["coping_rebar"]["soffit_arc_centre"][1] + (Z_FLAT - ref["coping"]["z_bottom_flat"]), 1)
if pick:
CZ = round(pick[0][1] - math.sqrt(12100 ** 2 - (ARC_X - 2900) ** 2), 1)
if abs(CZ_REL - CZ) > 1.5:
notes.append(f"아랫면 곡선 중심: 템플릿 관계 {CZ_REL}, 스터럽 하단으로 맞춘 값 {CZ} — 눈으로 확인하세요")
else:
CZ = CZ_REL
put(P, "params", "coping_rebar.soffit_arc_centre", [ARC_X, CZ_REL], "템플릿 중심 + 하면 높이 차(스터럽 하단이 1.5 mm 안에서 맞음)")
H = {}
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.start.y - e.dxf.end.y) < .05 and 5500 < min(e.dxf.start.x, e.dxf.end.x) and max(e.dxf.start.x, e.dxf.end.x) < 18700 \
and 15500 < e.dxf.start.y < 20300 and abs(e.dxf.end.x - e.dxf.start.x) > 1500:
H[round(e.dxf.start.y + OFF)] = (round(min(e.dxf.start.x, e.dxf.end.x) - CX), round(max(e.dxf.start.x, e.dxf.end.x) - CX))
tiers = sorted((z for z, (a, b) in H.items() if a == 0 and b > 5900 and ST[0][2] - 450 < z < ST[0][2] - 50), reverse=True)
c15s = [(z, a, b) for z, (a, b) in H.items() if a > 3000]
c16s = [(z, a, b) for z, (a, b) in H.items() if a == 0 and 3500 < b < 4500]
if len(tiers) < 3 or not c15s or not c16s:
raise FrameError("코핑 상단 철근 단·C15·C16 선을 찾지 못했습니다")
c15, c16 = c15s[0], max(c16s)
for t, z in zip(("1", "2", "3"), tiers[:3]):
put(P, "params", f"coping_rebar.top.tiers.{t}.z", z, "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.z", c15[0], "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.x0", c15[1], "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.x1", c15[2], "D1 철근 수평선")
put(P, "params", "coping_rebar.c16.z", c16[0], "D1 철근 수평선")
put(P, "params", "coping_rebar.c16.x1", c16[2], "D1 철근 수평선")
put(P, "params", "coping_rebar.bottom.z_flat", Z_FLAT + 100, "아랫면 평평부 + 100")
n_total = len(P["coping_rebar"]["loops"]["levels"])
cand = sorted((z for z, (a, b) in H.items() if a == 0 and z < tiers[-1] - 50 and z != c16[0]), reverse=True)
chain_ = [cand[0]] if cand else []
for z in cand[1:]:
if abs(chain_[-1] - z - 250) <= 1.5:
chain_.append(z)
loops = sorted(chain_)
lvs = None
if len(loops) >= 4:
lvs = [loops[0] - 250 * k for k in range(max(n_total - len(loops), 0), 0, -1)] + loops # lower levels lie below the plotted band: continue at 250
put(P, "params", "coping_rebar.loops.levels", lvs, "D1 루프 수평선 @250(아래쪽은 이어서 계산)")
else:
notes.append("코핑 루프 선을 4개 미만 찾아 그대로 두었습니다")
put(P, "params", "coping_rebar.stirrups.top_right", [[0, c16[0]], [3900, c15[0]]], "스터럽 윗면 = C16 / C15 높이")
put(P, "params", "coping_rebar.stirrups.bottom", f"z={Z_FLAT + 100:,} for |X|<=1,073.9 else circle R 12,100 about (+-6,951.9, {CZ_REL:,})", "아랫면 규칙 문구")
allc = set()
for e in MS:
if e.dxftype() == "CIRCLE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.radius - 27) < .1 and CX - 6000 < e.dxf.center.x < 18500 and 15500 < e.dxf.center.y < 20300:
allc.add((round(e.dxf.center.x - CX), round(e.dxf.center.y + OFF, 1)))
circ = None # filled below, once dz (the pier's height shift) is known
dz = ST[0][2] - ref["coping"]["top_steps"][0]["z"]
# C14 points are READ from the drawn circles (x as drawn: the x offsets differ per pier -- 13/913/... in P1, P3, P4 but 0/900/... in P2).
# Right-half circles are mirrored to the left; the centre point (|x| <= 20) is kept once; a circle that sits where the template has a point
# WITHOUT a mirror image (P1: the point near x 2913 that is missing on the left because the cover would be below 40 mm) stays right-only.
cpts = ref["coping_rebar"]["c14"]["points"]
tset = {(round(x), round(z, 1)) for x, z in cpts}
right_only = [(x, z + dz) for x, z in cpts if x > 20 and (-x, round(z, 1)) not in tset]
levels = {z + dz for x, z in cpts if (x, z + dz) not in right_only}
# keep the circles on the template's C14 heights (+ the pier's shift, 5 mm): the column hoop dots (x about +-902, every 120 / 300 -- one lies 23 mm from a
# C14 height) and other dots are left out; in P2 a C14 circle (x = 900) is only 2 mm from a hoop dot in x, so the height has to tell them apart.
# The right-only point of the template may sit up to 60 mm off its shifted height (P4: 18 mm).
circ = {(x, z) for x, z in allc if x >= -20 and (any(abs(z - lz) <= 5 for lz in levels) or any(abs(x - rx) <= 20 and abs(z - rz) <= 60 for rx, rz in right_only))}
pts = []
for x, z in sorted(circ):
if abs(x) <= 20 or any(abs(x - rx) <= 20 and abs(z - rz) <= 60 for rx, rz in right_only):
pts.append([x, z])
else:
pts += [[x, z], [-x, z]]
if len(pts) != len(cpts):
notes.append(f"C14 점: 도면에서 읽은 {len(pts)}개 ≠ 템플릿 {len(cpts)}개 — 눈으로 확인하세요")
put(P, "params", "coping_rebar.c14.points", pts, "도면 정면도의 철근 원(우측 반을 좌우 대칭, 가운데 점은 한 번)")
hup = []
for tx, tz in ref["coping_rebar"]["c14"]["hooks_up"]:
near = [p for p in pts if (p[0] > 0) == (tx > 0) and abs(abs(p[0]) - abs(tx)) <= 20 and abs(p[1] - (tz + dz)) <= 60]
if near:
hup.append(min(near, key=lambda p: abs(p[1] - (tz + dz))))
else:
notes.append(f"갈고리 위 지정 점 ({tx}, {tz})에 해당하는 C14 점을 찾지 못했습니다 — 눈으로 확인하세요")
put(P, "params", "coping_rebar.c14.hooks_up", [list(p) for p in hup], "하면 곡선을 뚫는 점만 갈고리 위로(점 좌표로 지정 — c14.points와 일치해야 함)")
# ================= footing / lean / piles / column
EL_F = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and t["text"].startswith("EL. 2") and 17000 < t["x"] < 18500 and 7000 < t["y"] < 8200]
fx = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and 11000 < t["x"] < 12500 and 3500 < t["y"] < 4000 and re.fullmatch(r"\d+\.\d{3}", t["text"])]
fy = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and 29000 < t["x"] < 30500 and 3500 < t["y"] < 4000 and re.fullmatch(r"\d+\.\d{3}", t["text"])]
pl = [t["text"] for t in texts if t["dwg"] == "D1" and t["text"].startswith("L=") and t["x"] < 10000 and t["y"] < 7000]
chx = [t["text"] for t in texts if t["dwg"] == "D1" and "@1." in t["text"] and 10000 < t["x"] < 13000 and 5500 < t["y"] < 6600]
chy = [t["text"] for t in texts if t["dwg"] == "D1" and "@1." in t["text"] and 28000 < t["x"] < 31000 and 5500 < t["y"] < 6600]
if not (EL_F and fx and fy and pl and chx and chy):
raise FrameError("기초 크기·하면 EL·말뚝 길이·말뚝 사슬 글자를 찾지 못했습니다")
chx, chy = chx[0], chy[0]
SX, SY, ZF = round(fx[0]), round(fy[0]), round(EL_F[0])
TH = P["footing"]["thickness"]
put(P, "params", "footing.size_x", float(SX), "D1 기초 치수")
put(P, "params", "footing.size_y", float(SY), "D1 기초 치수")
put(P, "params", "footing.z_bottom", ZF, "D1 EL 글자")
put(P, "params", "lean_concrete.size_x", float(SX + 200), "기초 + 200")
put(P, "params", "lean_concrete.size_y", float(SY + 200), "기초 + 200")
put(P, "params", "lean_concrete.z_bottom", ZF - 100, "기초 하면 − 100")
put(P, "params", "lean_concrete.z_top", ZF, "= 기초 하면")
COL_H = Z_FLAT - ZF - TH
put(P, "params", "column.height", COL_H, "코핑 하면 − (기초 하면 + 두께) (D1 높이 글자와 같음)")
put(P, "params", "piles.length", round(_num(pl[0]) * 1000), "D1 말뚝 길이 글자")
put(P, "params", "piles.count_x", int(chx.split("@")[0]) + 1, "D1 말뚝 사슬")
put(P, "params", "piles.pitch_x", float(round(_num(chx.split("@")[1]) * 1000)), "D1 말뚝 사슬")
put(P, "params", "piles.count_y", int(chy.split("@")[0]) + 1, "D1 말뚝 사슬")
put(P, "params", "piles.pitch_y", float(round(_num(chy.split("@")[1]) * 1000)), "D1 말뚝 사슬")
# ================= bearings
pads = []
for e in MS:
if e.dxftype() == "LWPOLYLINE" and e.dxf.layer == "CS-CONC-MAJR" and len(e) == 4:
q = [(v[0], v[1]) for v in e.get_points()]
if not (5000 < q[0][0] < 20000 and 23000 < q[0][1] < 29500):
continue
s = [math.dist(q[i], q[(i + 1) % 4]) for i in range(4)]
if abs(s[0] - s[2]) < 1 and abs(s[1] - s[3]) < 1 and 780 < s[0] < 920 and 700 < s[1] < 860:
pads.append((sum(v[0] for v in q) / 4 - DATUM_X, sum(v[1] for v in q) / 4 - DATUM_Y, math.degrees(math.atan2(q[1][1] - q[0][1], q[1][0] - q[0][0])) % 180))
seen, uniq = set(), []
for a, b, c in sorted(pads):
k = (round(a / 3), round(b / 3))
if k not in seen:
seen.add(k)
uniq.append((round(a, 1), round(b, 1), round(90 - c, 2)))
fr = sorted(p for p in uniq if p[1] < 0)
rr = sorted(p for p in uniq if p[1] > 0)
if len(fr) < 2 or not rr:
raise FrameError("평면도에서 받침 패드(앞·뒤 열)를 찾지 못했습니다")
g5f, g5r = fr[-1], rr[-1]
PITCH = round(g5f[0] - fr[-2][0], 1)
put(P, "params", "bearing_seats.pitch_x", PITCH, "평면도 패드 g4 → g5")
put(P, "params", "bearing_seats.pad_rows.front.centre_rel_girder5", [g5f[0], g5f[1]], "D1 평면도 패드 폴리라인")
put(P, "params", "bearing_seats.pad_rows.front.rotation_deg", g5f[2], "패드 폴리라인 각도")
put(P, "params", "bearing_seats.pad_rows.rear.centre_rel_girder5", [g5r[0], g5r[1]], "D1 평면도 패드 폴리라인")
put(P, "params", "bearing_seats.pad_rows.rear.rotation_deg", g5r[2], "패드 폴리라인 각도")
for i, b in enumerate(P["bearing_seats"]["bearings"]):
ref_ = g5f if b["row"] == "front" else g5r
put(P, "params", f"bearing_seats.bearings.{i}.centre", [round(ref_[0] - (5 - b["girder_line"]) * PITCH, 1), ref_[1]], "g4·g5는 도면, g1~g3은 피치로 계산")
put(P, "params", f"bearing_seats.bearings.{i}.rotation_deg", ref_[2], "패드 각도")
ped = []
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-CONC-MAJR":
s, t = e.dxf.start, e.dxf.end
ln = math.hypot(t.x - s.x, t.y - s.y)
if 5000 < min(s.x, t.x) and max(s.x, t.x) < 20000 and 23000 < min(s.y, t.y) and max(s.y, t.y) < 29500 and (abs(ln - 2500) < 5 or abs(ln - 1200) < 5):
ped.append(((s.x - DATUM_X, s.y - DATUM_Y), (t.x - DATUM_X, t.y - DATUM_Y), round(ln)))
ped5 = [p for p in ped if min(p[0][0], p[1][0]) > g5f[0] - 800 and max(p[0][0], p[1][0]) < g5f[0] + 800]
if len(ped5) >= 5:
longs = sorted([p for p in ped5 if p[2] == 2500], key=lambda p: p[0][0])
shorts = sorted([p for p in ped5 if p[2] == 1200], key=lambda p: (p[0][1] + p[1][1]))
def lo_hi(l):
a, b = l[0], l[1]
return (a, b) if a[1] < b[1] else (b, a)
(l_lo, l_hi), (r_lo, r_hi) = lo_hi(longs[0]), lo_hi(longs[-1])
m0, m1 = sorted([shorts[1][0], shorts[1][1]])
r1 = lambda v: [round(v[0], 1), round(v[1], 1)] # noqa: E731
put(P, "params", "bearing_seats.pedestal_girder5.front_half", [r1(l_lo), r1(m0), r1(m1), r1(r_lo)], "D1 평면도 대좌 외곽")
put(P, "params", "bearing_seats.pedestal_girder5.rear_half", [r1(m0), r1(l_hi), r1(r_hi), r1(m1)], "D1 평면도 대좌 외곽")
put(P, "params", "bearing_seats.pedestal_girder5.rotation_deg", g5f[2], "대좌 각도 = 패드 각도")
else:
notes.append("g5 대좌 외곽(2500 × 1200)을 찾지 못해 그대로 두었습니다")
# ================= schedule tables of the pier itself
rows, dia_of = {}, {}
for c in reader.table_candidates(texts):
if c["key"].startswith("schedule"):
last = None # '"' in the diameter column = the row above (table order, not mark order)
for r in c["rows"]:
tx = [x["text"] for x in sorted(r["cells"], key=lambda z: z["x"])]
if len(tx) > 2 and tx[2].strip() != '"' and re.fullmatch(r"[SHD]\d+", tx[2].strip()):
last = tx[2].strip()
if tx and re.fullmatch(r"[BPC]\d+|S\d+", tx[0].strip()):
rows[tx[0].strip()] = tx
if last:
dia_of[tx[0].strip()] = int(re.search(r"\d+", last).group())
need = [f"{p}{i}" for p, n in (("P", 6), ("B", 12)) for i in range(1, n + 1)] + ["C16"] + [f"C{i}" for i in range(1, 7)]
miss = [m for m in need if m not in rows]
if miss:
raise FrameError("철근 재료표에서 다음 마크를 읽지 못했습니다: " + ", ".join(miss))
for m in ("P1", "P2", "P3", "P4", "P5", "P6"):
tx = rows[m]
vals = tx[4:4 + len(ORDER[tx[1]].split())]
new = dict(P["column_rebar"]["schedule"][m])
new["n"] = tx[3]
for f, v in zip(ORDER[tx[1]].split(), vals):
new[f] = v
if tx[1] == "XVI":
new["L"] = vals[0]
put(P, "params", f"column_rebar.schedule.{m}", new, "이 교각의 재료표 행")
for m in [f"B{i}" for i in range(1, 13)]:
put(P, "params", f"footing_rebar.schedule.{m}", rows[m], "이 교각의 재료표 행")
n_hoop = {m: int(rows[m][3]) for m in ("P3", "P4", "P5")}
n_tie_levels = int(rows["P6"][3]) // 4
for t, ms in (("1", ("C1", "C4")), ("2", ("C2", "C5")), ("3", ("C3", "C6"))):
put(P, "params", f"coping_rebar.top.tiers.{t}.a", round(_num(rows[ms[0]][4]) * 1000), "재료표 a")
put(P, "params", "coping_rebar.c16.x0", c16[2] - round(_num(rows["C16"][4]) * 1000), "x1 − 재료표 C16 a")
# ================= footing rebar
rule = footing_rule.infer(SX, SY)
put(P, "params", "footing_rebar.a_x", rule["a_x"], "기초 − 200")
put(P, "params", "footing_rebar.a_y", rule["a_y"], "기초 − 200")
for k in ("bottom_Y1", "bottom_X2", "bottom_Y3", "bottom_X4", "top_Y", "top_X"):
put(P, "params", f"footing_rebar.layers.{k}.pos", [float(v) for v in rule[k]], "철근 층 규칙")
cnt = {"bottom_Y1": ("B2", "B4"), "bottom_X2": ("B6", "B8"), "bottom_Y3": ("B3",), "bottom_X4": ("B7",), "top_Y": ("B1",), "top_X": ("B5",)}
for k, ms in cnt.items():
sch = sum(int(rows[m][3]) for m in ms)
if sch != len(rule[k]):
notes.append(f"기초 철근 {k}: 규칙 계산 {len(rule[k])}본 ≠ 재료표 {sch}본")
gx = [-(SX - 1100) / 2 + 250 * i for i in range(int((SX - 1100) // 250) + 1)]
gy = [-(SY - 800) / 2 + 250 * i for i in range(int((SY - 800) // 250) + 1)]
put(P, "params", "footing_rebar.b9.grid_x", [int(v) for v in gx], "B9 격자 규칙(x 폭 = 기초 − 1100)")
put(P, "params", "footing_rebar.b9.grid_y", [int(v) for v in gy], "B9 격자 규칙(y 폭 = 기초 − 800)")
B9N = int(rows["B9"][3]) if len(rows["B9"]) >= 9 else round(_num(rows["B9"][-1]) / _num(rows["B9"][-2]))
B9G = (len(gx) * len(gy) + 1) // 2 # the model places a checkerboard of the grid
put(P, "params", "footing_rebar.b9.count", B9G, "B9 격자의 체스판 개수(모델이 놓는 개수)")
if B9G != B9N:
notes.append(f"B9: 격자 {len(gx)} × {len(gy)} ÷ 2 = {B9G}본 ≠ 재료표 {B9N}본 — 작도를 채택하려면 계획된 FAIL(G3 B9 개수) 등록이 필요합니다")
put(P, "params", "footing_rebar.b10.size_x", float(SX - 152), "B10 = 기초 − 152")
put(P, "params", "footing_rebar.b10.size_y", float(SY - 156), "B10 = 기초 − 156")
mid = {}
for tag, (xmin, xmax) in (("mid_Y", (CX - 5, 18500)), ("mid_X", (23000, 37000))):
lv = {}
for e in MS:
if e.dxftype() == "CIRCLE" and xmin < e.dxf.center.x < xmax:
for l in (550, 950, 1350, 1750):
if abs(e.dxf.center.y - (8115.6 + l)) < 1:
lv.setdefault(float(l), []).append(round(e.dxf.center.x, 1))
mid[tag] = lv
def sym(xs, centre, edge_hoop):
r = sorted({round(abs(x - centre)) for x in xs})
r = [v for v in r if (abs(v - edge_hoop) > 12 and v > 0) or v == 0]
return sorted([-v for v in r if v] + [v for v in r])
if all(len(mid[t]) == 4 for t in mid):
put(P, "params", "footing_rebar.layers.mid_Y.levels", {str(k): [float(v) for v in sym(xs, CX, round(SX - 152) / 2)] for k, xs in sorted(mid["mid_Y"].items())}, "D1 정면도 철근 점(B12, 우측 반 → 좌우 대칭)")
put(P, "params", "footing_rebar.layers.mid_X.levels", {str(k): [float(v) for v in sym(xs, SEC_CENTRE_X, round(SY - 156) / 2)] for k, xs in sorted(mid["mid_X"].items())}, "D1 단면도 철근 점(B11, 우측 반 → 좌우 대칭)")
for tag, mk in (("mid_Y", "B12"), ("mid_X", "B11")):
drawn = sum(len(v) for v in P["footing_rebar"]["layers"][tag]["levels"].values())
if drawn != int(rows[mk][3]):
notes.append(f"{mk}: 도면 점 {drawn}본 ≠ 재료표 {rows[mk][3]}본 (작도를 채택하려면 계획된 FAIL 등록이 필요합니다)")
else:
notes.append("기초 중간 층 B11·B12 점을 네 단 모두에서 찾지 못해 그대로 두었습니다")
cc = {"bottom_Y1": len(rule["bottom_Y1"]), "bottom_X2": len(rule["bottom_X2"]), "bottom_Y3": len(rule["bottom_Y3"]), "bottom_X4": len(rule["bottom_X4"]),
"top_Y": len(rule["top_Y"]), "top_X": len(rule["top_X"])}
for tag in ("mid_X", "mid_Y"):
cc[tag] = sum(len(v) for v in P["footing_rebar"]["layers"][tag]["levels"].values())
if isinstance(P["footing_rebar"].get("counts_check"), dict):
put(P, "params", "footing_rebar.counts_check", {k: cc.get(k, v) for k, v in P["footing_rebar"]["counts_check"].items()}, "층별 철근 개수(위 규칙·도면 점)")
# ================= column rebar
put(P, "params", "column_rebar.main_bars.p1.hook_leg_z", ZF + 300, "기초 하면 + 300")
put(P, "params", "column_rebar.main_bars.p1.straight_from", ZF + 640, "+ 굽힘 R340")
put(P, "params", "column_rebar.main_bars.p1.straight_len", round(_num(rows["P1"][4]) * 1000), "재료표 P1 A")
put(P, "params", "column_rebar.main_bars.p2.z0", ZF + 300, "기초 하면 + 300")
put(P, "params", "column_rebar.main_bars.p2.length", round(_num(rows["P2"][4]) * 1000), "재료표 P2")
lv = [ZF + 1180 + 120 * i for i in range(n_hoop["P4"] + n_hoop["P5"])]
lv += [lv[-1] + 300 * (i + 1) for i in range(n_hoop["P3"])]
put(P, "params", "column_rebar.hoops.levels", lv, "첫 단 = 기초 하면 + 1180, 120 / 300 사슬(재료표 개수)")
put(P, "params", "column_rebar.hoops.marks", ["P4"] * n_hoop["P4"] + ["P5"] * n_hoop["P5"] + ["P3"] * n_hoop["P3"], "재료표 개수")
put(P, "params", "column_rebar.hoops.radius", round(_num(rows["P4"][6])), "재료표 P4·P5 R")
put(P, "params", "column_rebar.hoops.radius_by_mark", {"P3": round(_num(rows["P3"][6]))}, "재료표 P3 R")
put(P, "params", "column_rebar.ties.levels", [ZF + 1180 + 1800 * i for i in range(n_tie_levels)], "사슬 시작 = 기초 하면 + 1180, 단 수 = 재료표 P6 ÷ 4")
# ================= expected
outline = [[-1100, Z_FLAT], [1100, Z_FLAT], [6200, tip_z], [6000, ST[4][2]], [ST[4][0], ST[4][2]], [ST[4][0], ST[3][2]], [ST[3][0], ST[3][2]], [ST[3][0], ST[2][2]],
[ST[2][0], ST[2][2]], [ST[2][0], ST[1][2]], [ST[1][0], ST[1][2]], [ST[1][0], ST[0][2]], [-6000, ST[0][2]], [-6200, tip_z]]
put(E, "expected", "G1.coping_outline", outline, "코핑 계단 + 아랫면 평평부")
put(E, "expected", "G1.dxf_arc_centres.datum_el", Z_FLAT, "코핑 아랫면 평평부")
put(E, "expected", "G1.footing.dx", SX, "D1")
put(E, "expected", "G1.footing.dy", SY, "D1")
put(E, "expected", "G1.footing.z0", ZF, "D1 EL")
put(E, "expected", "G1.column.height", COL_H, "D1")
put(E, "expected", "G1.coping.z_top_left", ST[0][2], "코핑 계단")
put(E, "expected", "G1.coping.z_top_center", ST[2][2], "코핑 계단")
put(E, "expected", "G1.coping.z_top_right", ST[4][2], "코핑 계단")
put(E, "expected", "G1.piles.nx", int(chx.split("@")[0]) + 1, "D1")
put(E, "expected", "G1.piles.ny", int(chy.split("@")[0]) + 1, "D1")
put(E, "expected", "G1.piles.pitch_x", round(_num(chx.split("@")[1]) * 1000), "D1")
put(E, "expected", "G1.piles.pitch_y", round(_num(chy.split("@")[1]) * 1000), "D1")
put(E, "expected", "G1.piles.length", round(_num(pl[0]) * 1000), "D1")
put(E, "expected", "G1.seats.step_top", {str(i + 1): ST[i][2] for i in range(5)}, "코핑 계단")
put(E, "expected", "G1.seats.step_x", {str(i + 1): [ST[i][0], ST[i][1]] for i in range(5)}, "코핑 계단")
put(E, "expected", "G1.seats.row_y", {"front": round(g5f[1]), "rear": round(g5r[1])}, "평면도 패드 폴리라인")
chain = [t["text"] for t in texts if t["dwg"] == "D1" and re.fullmatch(r"3@2\.\d+=\d\.\d+", t["text"])]
if chain:
put(E, "expected", "G1.seats.pitch_chain", [3, round(_num(chain[0].split("=")[1]) * 1000)], "D1 3@피치=합계")
put(E, "expected", "G2.hoop_chain", {"start": [150, 1030], "groups": [[n_hoop["P4"] - 1, 120], [n_hoop["P5"], 120], [n_hoop["P3"], 300]]}, "재료표 개수")
put(E, "expected", "G2.hoop_marks_by_zone", [["P4", n_hoop["P4"]], ["P5", n_hoop["P5"]], ["P3", n_hoop["P3"]]], "재료표 개수")
put(E, "expected", "G2.tie_chain", {"start": [150, 1030], "groups": [[n_tie_levels - 1, 1800]]}, "재료표 P6 ÷ 4 − 1 구간")
put(E, "expected", "G2.r_hoop", round(_num(rows["P4"][6])), "재료표 P4·P5 R")
put(E, "expected", "G2.p1_top_parts", [300, 340, round(_num(rows["P1"][4]) * 1000)], "300 + 340 + 재료표 P1 A")
hx, hy = (SX - 200) / 2, (SY - 200) / 2
put(E, "expected", "G3.rows", {"Y_BOT": {"ends": [-int(hx), int(hx)], "start": -int(hx - 200), "pitch": 125, "n": len([v for v in rule["bottom_Y1"] if abs(v) < hx - 200 + 1])},
"Y_250": {"ends": [-int(hx), int(hx)], "start": -int(hx - 200), "pitch": 250, "n": len([v for v in rule["bottom_Y3"] if abs(v) < hx - 1])},
"X_BOT": {"ends": [-int(hy), int(hy)], "start": -int(hy - 175), "pitch": 125, "n": len([v for v in rule["bottom_X2"] if abs(v) < hy - 1])},
"X_250": {"ends": [-int(hy), int(hy)], "start": -int(hy - 175), "pitch": 250, "n": len([v for v in rule["bottom_X4"] if abs(v) < hy - 1])}}, "기초 철근 규칙")
put(E, "expected", "G3.b9_grid", {"x": {"start": int(gx[0]), "pitch": 250, "n": len(gx)}, "y": {"start": int(gy[0]), "pitch": 250, "n": len(gy)}}, "B9 격자")
put(E, "expected", "G3.b10_size", [SX - 152, SY - 156], "B10")
put(E, "expected", "G4.tier_z", {"C1": tiers[0], "C4": tiers[0], "C2": tiers[1], "C5": tiers[1], "C3": tiers[2], "C6": tiers[2]}, "D1 철근 수평선")
put(E, "expected", "G4.c10_bottom", Z_FLAT + 100, "아랫면 평평부 + 100")
if lvs:
put(E, "expected", "G4.loop_z", {"C12": {"start": lvs[0], "pitch": 250, "n": 6}, "C13": lvs[6:]}, "D1 루프 수평선")
put(E, "expected", "G4.c15_top", c15[0], "D1 철근 수평선")
put(E, "expected", "G4.c16_top", c16[0], "D1 철근 수평선")
lev = {}
for x, z in P["coping_rebar"]["c14"]["points"]:
lev[z] = lev.get(z, 0) + 1
put(E, "expected", "G4.c14_levels", [[z, n] for z, n in sorted(lev.items(), key=lambda kv: -kv[0])], "c14 점을 높이별로")
put(E, "expected", "G4.step_top", {str(i + 1): ST[i][2] for i in range(5)}, "코핑 계단")
# bar diameters from the schedule ('"' = same as the row above, in the order of the marks), ranges of the stirrup / loop lengths
dia = dia_of
for gate, marks in (("G2", [f"P{i}" for i in range(1, 7)]), ("G3", [f"B{i}" for i in range(1, 13)]), ("G4", [f"C{i}" for i in range(1, 17)] + ["S1", "S2"])):
cur = E.get(gate, {}).get("dia")
if isinstance(cur, dict):
put(E, "expected", f"{gate}.dia", {m: dia.get(m, v) for m, v in cur.items()}, "이 교각의 재료표 직경")
d2 = E.get("G2", {}).get("dia")
if isinstance(d2, dict) and "P4" in d2 and "P6" in d2:
put(E, "expected", "G2.tie_offset", (d2["P4"] + d2["P6"]) / 2, "크로스타이 높이 = 띠철근 높이 + (P4 직경 + P6 직경) / 2 (P1~P4 5개 값과 일치)")
def rng(txt):
v = [round(_num(x) * 1000) for x in re.findall(r"\d+\.\d+", txt)]
return v if len(v) == 2 else (v + v if len(v) == 1 else None)
cur = E["G4"].get("range")
if isinstance(cur, dict):
new_r = dict(cur)
for m, col, kind in (("C9", 5, "b_sched"), ("C10", 5, "b_sched"), ("C11", 5, "b_sched"), ("C12", 4, "a_sched"), ("C13", 4, "a_sched")):
if m in rows and len(rows[m]) > col and rng(rows[m][col]):
new_r[m] = [kind] + rng(rows[m][col])
put(E, "expected", "G4.range", new_r, "재료표 C9~C13의 b·a 범위")
tl, tl_why = _top_left(P, (E["G4"].get("range") or {}).get("C9"), ST, c16[0])
put(P, "params", "coping_rebar.stirrups.top_left", tl, tl_why)
pid = pier_name.replace("운곡교", "")
old_pid = P.get("pier_id")
if old_pid and old_pid != pid:
put(P, "params", "display.hidden_ids", [h.replace(old_pid + "-", pid + "-", 1) for h in P["display"].get("hidden_ids", [])], "모델 객체 이름에 교각 식별자가 들어 있음")
put(E, "expected", "pier_id", pid, "교각 이름")
put(P, "params", "pier_id", pid, "교각 이름")
return P, E, changes, notes
TABLE_GROUP = "표 값(받침·솔플레이트·재료표)"
def table_changes(P, E, texts):
"""Changes that bring params / expected in line with the tables the app recognises (ELEVATION / 솔플레이트 / 철근재료표): one per proposal row that
differs (engine.reader.proposals). Computed on the already-read P / E so only what the reading left different is proposed."""
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), P, E) if texts else []
return [dict(file=r["file"], path=".".join(str(x) for x in r["path"]), old=r["current"], new=r["candidate"], why=r["src"], inferred=False, group=TABLE_GROUP, row=r)
for r in rows if not r["same"]]
ANCHOR_FIELDS = ("expected:G1.seats.rear_first_from_tip",) # per-pier dimensions taken from an anchor candidate (A / B grade) when the reading left the template's value
PENDING = "읽기 도구 제안 — 사용자 승인 필요"
def _get(doc, path):
x = doc
for k in path.split("."):
x = x[int(k)] if isinstance(x, list) else x.get(k) if isinstance(x, dict) else None
if x is None:
return None
return x
def extra_changes(P, E, texts, anchors_doc=None, own_sources=()):
"""Proposals made on top of read() + table_changes() (2026-10-20, from the P4 rebuild):
1. anchor candidates for the per-pier dimensions in ANCHOR_FIELDS (e.g. the rear pedestal's first distance from the tip) when they differ from the current value;
2. planned FAIL entries the person would have to add by hand: a drawn bar count that differs from the schedule (B11 / B12, the drawing is followed),
and a schedule length L that does not follow the (max+min)/2 convention (C9~C13). One change holding the whole list; it is never pre-selected
(the approval of a planned FAIL stays with the person) and its entries carry `approved` = PENDING."""
out = []
if anchors_doc and texts:
cand = anchors.follow(anchors_doc, texts, own_sources)
for f in ANCHOR_FIELDS:
c = cand.get(f)
which, path = f.split(":", 1)
cur = _get(E if which == "expected" else P, path)
if c and c["grade"] in ("A", "B") and not c["self_source"] and cur is not None and not same(cur, c["value"], 0.0005):
out.append(dict(file=which, path=path, old=cur, new=c["value"], inferred=False,
why=f"앵커 후보 {c['grade']}등급 — 도면 글자 '{c['text']}' ({c['dwg']} {c['x']:.0f}, {c['y']:.0f})",
group=f"기대값({which}) " + path.split(".")[0]))
P2, E2 = copy.deepcopy(P), copy.deepcopy(E)
if texts:
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), P2, E2)
reader.apply_proposals([r for r in rows if not r["same"]], P2, E2)
want = [] # (gate, item, reason, numbers the reason must contain)
layers = (P2.get("footing_rebar") or {}).get("layers") or {}
for tag, mark in (("mid_X", "B11"), ("mid_Y", "B12")):
lv, sch = (layers.get(tag) or {}).get("levels"), ((E2.get("G3") or {}).get("schedule") or {}).get(mark)
if isinstance(lv, dict) and isinstance(sch, list) and len(sch) == 2:
got, exp = sum(len(v) for v in lv.values()), sch[1]
if got != exp:
want.append(("G3", f"{mark} bar count vs schedule", f"작도 {got}본 채택(좌우 대칭) vs D3 {exp}본 — 설계자 확인 대기", (f"{got}본", f"{exp}본")))
b9, b9n = (P2.get("footing_rebar") or {}).get("b9") or {}, (((E2.get("G3") or {}).get("schedule") or {}).get("B9") or [None, None])[1]
if b9.get("count") is not None and b9n is not None and b9["count"] != b9n:
gx, gy = b9.get("grid_x") or [], b9.get("grid_y") or []
want.append(("G3", "B9 bar count vs schedule", f"작도 {b9['count']}본({len(gx)}×{len(gy)} 엇갈림) 채택 vs D3 {b9n}본 — 설계자 확인 대기", (f"{b9['count']}본", f"{b9n}본")))
G4 = E2.get("G4") or {}
conv, Ls = G4.get("range_convention"), G4.get("L") or {}
for m, v in (G4.get("range") or {}).items():
if conv and isinstance(Ls, dict) and Ls.get(m) is not None and v[0] in conv:
c = conv[v[0]]
avg = 2 * (c["a"] if v[0] == "b_sched" else c["b"]) + (v[1] + v[2]) + c["c_times"] * c["c"]
if abs(avg - Ls[m]) > 1.0:
want.append(("G4", f"{m} schedule L reproduced by convention (max+min)/2",
f"재료표 {m} L {Ls[m] / 1000:.3f}이 규칙 (max+min)/2 = {avg / 1000:.3f}과 {abs(avg - Ls[m]):.0f} mm 다름 — 재료표 자체 불일치, 설계자 확인 대기", ()))
pf, notes = copy.deepcopy(E.get("planned_fail", [])), []
for gate, item, reason, nums in want:
i = next((k for k, x in enumerate(pf) if x.get("gate") == gate and (x.get("item") == item or (x.get("item_prefix") and item.startswith(x["item_prefix"])))), None)
entry = dict(gate=gate, item=item, reason=reason, approved=PENDING)
if i is None:
pf.append(entry)
notes.append(item.split(" ")[0] + " 추가")
elif not all(n in pf[i].get("reason", "") for n in nums):
pf[i] = entry # an entry inherited from another pier: the numbers do not fit this pier
notes.append(item.split(" ")[0] + " 숫자 갱신")
if notes:
out.append(dict(file="expected", path="planned_fail", old=E.get("planned_fail"), new=pf, inferred=True, group="기대값(expected) planned_fail",
why="계획된 FAIL 제안(" + ", ".join(notes) + ") — 작도를 채택하고 재료표와 다른 항목을 등록합니다. 승인은 사람이 합니다"))
return out
def stamp_pending(E, by, day):
"""A planned FAIL entry written from the reading screen carries PENDING; once the person has chosen and applied it, the choice is recorded in `approved`
(the report prints this text). Returns how many entries were stamped."""
n = 0
for x in E.get("planned_fail", []):
if x.get("approved") == PENDING:
x["approved"] = f"사용자 선택 {day} ({by or '-'}) — 도면에서 읽기 화면"
n += 1
return n
def normalize_per_bearing(P, E):
"""A value that differs per bearing (e.g. the sole plate length L: front row 700, rear row 800) cannot stay one number in expected.G5 -- the checker
accepts {no: value} (Ulsan 2026-10-13). Converts G5.L / G5.T when the pier's own table shows different values."""
tab = (P.get("sole_plates") or {}).get("table")
g5 = E.get("G5")
if not isinstance(tab, list) or not isinstance(g5, dict):
return
for k in ("L", "T"):
vals = {str(r["no"]): r[k] for r in tab if isinstance(r, dict) and k in r}
if vals and not isinstance(g5.get(k), dict) and len(set(vals.values())) > 1:
g5[k] = vals
def apply_selected(params, expected, changes, selected):
"""-> (params', expected') with the selected changes (indices into `changes`) written; the others stay as they are."""
P, E = copy.deepcopy(params), copy.deepcopy(expected)
for i in selected:
c = changes[i]
if c.get("row"):
reader.apply_proposals([c["row"]], P, E)
continue
doc = P if c["file"] == "params" else E
x = doc
keys = c["path"].split(".")
for k in keys[:-1]:
x = x[int(k)] if isinstance(x, list) else x[k]
x[int(keys[-1]) if isinstance(x, list) else keys[-1]] = copy.deepcopy(c["new"])
normalize_per_bearing(P, E)
return P, E
def frame_ok(texts, anchors_template, anchors_mod):
"""coverage of the template anchors' landmark texts in the drawing (0..1); the Ungok frame needs >= FRAME_MIN_COVERAGE."""
if not anchors_template:
return None
cand = anchors_mod.follow(anchors_template, texts)
return next(iter(cand.values()))["coverage"] if cand else 0.0