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