- 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>
692 lines
47 KiB
Python
692 lines
47 KiB
Python
"""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
|