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>
This commit is contained in:
ahnyongjooandClaude Sonnet 5.5 committed 2026-10-28 11:20:18 +09:00
commit 964a841de1
3978 files changed
+58013356

No files matched your search

@@ -0,0 +1,67 @@
"""Sizing study for W4 (stale template values): how many fields of a new pier still equal the template, how many of them vary between verified piers,
and how many of those were really wrong in the P4 12:11 state (restored P4 = truth).
usage: python -X utf8 analysis_w4_residual_sizing.py (run from Code/교각모델링앱/data; needs the P4 backup before_restore_decisions_20261020)
"""
import json
def L(p): return json.load(open(p,encoding="utf-8-sig"))
def flat(o,p=()):
if isinstance(o,dict):
for k,v in o.items(): yield from flat(v,p+(str(k),))
elif isinstance(o,list):
for i,v in enumerate(o): yield from flat(v,p+(str(i),))
else: yield p,o
def num(v): return isinstance(v,(int,float)) and not isinstance(v,bool)
def load(d):
out={}
for f in("params","expected"):
for p,v in flat(L(f"{d}/{f}.json")): out[(f,)+p]=v
return out
T=load("_템플릿/운곡교P1형")
ref={n:load(f"운곡교{n}") for n in("P1","P2","P3")}
truth=load("운곡교P4") # restored, verified-equivalent
cur=load("운곡교P4/backup/before_restore_decisions_20261020") # 12:11 state
# variability from P1..P3 only (leave-P4-out)
def varies(k):
vals=[ref[n].get(k) for n in ref]
return len(set(map(repr,vals)))>1
nums=[k for k,v in T.items() if num(v)]
print("템플릿 숫자 필드:",len(nums))
var=[k for k in nums if varies(k)]
print("P1~P3에서 교각마다 달라진 숫자 필드:",len(var))
wrong=[k for k in set(cur)|set(truth) if cur.get(k)!=truth.get(k)]
print("12:11 판과 정답(복원판)이 다른 필드:",len(wrong))
resid=[k for k in nums if cur.get(k)==T.get(k)] # still template value in 12:11 state
flag_old=[k for k in resid if k in var] # variable + still template
tp=[k for k in flag_old if truth.get(k)!=cur.get(k)]
fp=[k for k in flag_old if truth.get(k)==cur.get(k)]
print("12:11 판에서 템플릿 값 그대로인 숫자 필드:",len(resid))
print(" 그중 '교각마다 달라지는' 필드(새 기준 후보):",len(flag_old),"→ 실제 틀림(TP)",len(tp),"/ 맞게 같은 값(FP)",len(fp))
missed=[k for k in wrong if k not in flag_old]
print("정답과 달랐으나 새 기준이 못 잡은 필드:",len(missed))
from collections import Counter
def grp(k): return ".".join(k[:3])
print("TP 분포:",Counter(grp(k) for k in tp).most_common(12))
print("FP 분포:",Counter(grp(k) for k in fp).most_common(12))
print("missed 분포:",Counter(grp(k) for k in missed).most_common(12))
# compare to current C3b height-only rule
import sys; sys.path.insert(0,"../")
from engine.verify import height_like, EL_MIN, EL_MAX
c3b=[k for k in resid if EL_MIN<=abs(cur[k])<=EL_MAX and height_like(k[2:])]
print("현행 C3b(높이형) 기준 후보:",len(c3b),"→ TP",sum(1 for k in c3b if truth.get(k)!=cur.get(k)))
print("\n=== 정밀도 개선 시험")
def distinct(k): return len(set(repr(ref[n].get(k)) for n in ref))
# bundle changed vs template (section = file + first key)
def sec(k): return k[:2]
changed_secs={sec(k) for k in set(cur)|set(T) if cur.get(k)!=T.get(k)}
def stat(name,flag):
tp=[k for k in flag if truth.get(k)!=cur.get(k)]
print(f"{name}: 후보 {len(flag)} / 실제 틀림 {len(tp)} / 정밀도 {len(tp)/max(1,len(flag)):.0%}", [".".join(k[1:4]) for k in tp])
stat("A 교각마다 달라짐(2종 이상)", flag_old)
stat("B A + 바뀐 묶음 안", [k for k in flag_old if sec(k) in changed_secs])
stat("C B + 3교각 모두 서로 다름", [k for k in flag_old if sec(k) in changed_secs and distinct(k)==3])
# C with the 3 mostly-structural groups removed
skip=("translation_checks","layers","table","drawing_datums")
stat("D C + 구조성 그룹 제외", [k for k in flag_old if sec(k) in changed_secs and distinct(k)==3 and not any(s in k for s in skip)])
print("C 후보 분포:",Counter(grp(k) for k in flag_old if sec(k) in changed_secs and distinct(k)==3).most_common(10))
@@ -0,0 +1,61 @@
"""Apply the grade-A anchor candidates of a pier, one file at a time (2026-10-19), through the app's own route so that the P7 rule, the
anchor log and the re-confirmation rule apply: first every differing A candidate of expected, then the differing A candidates of params whose
drawing text was NOT used for expected (the same text may not feed both files). params / expected are backed up first.
usage: python tools/apply_anchor_a.py <pier> [--dry]"""
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
import store # noqa: E402
from engine import verify # noqa: E402
name = sys.argv[1]
dry = "--dry" in sys.argv
p = store.Pier(name)
c = webapp.app.test_client()
def a_rows():
rows, _m, _meta = p.anchor_rows()
return [r for r in rows if r["grade"] == "A" and not r["same"] and not r["self_source"]]
def count():
a = p.run_autocheck(force=True)
req = {f"{f}:{k}" for f, k in webapp.required(p)}
return verify.counts(a["sections"], req), a
before, a0 = count()
print(name, "before:", {verify.ST_NAME[k]: v for k, v in before.items()})
ex = [r for r in a_rows() if r["file"] == "expected"]
used = {(r["text"], r["x"], r["y"]) for r in ex}
print(f"expected side: {len(ex)} A candidates;", end=" ")
pa = [r for r in a_rows() if r["file"] == "params" and (r["text"], r["x"], r["y"]) not in used]
print(f"params side (texts not used for expected): {len(pa)} of {len([r for r in a_rows() if r['file'] == 'params'])}")
if dry:
sys.exit(0)
bk = p.dir / "backup" / f"before_anchor_A_{time.strftime('%Y%m%d_%H%M%S')}"
bk.mkdir(parents=True)
for f in ("params.json", "expected.json", "confirm.json"):
if (p.dir / f).exists():
shutil.copy2(p.dir / f, bk / f)
for label, rows in (("expected", ex), ("params", pa)):
if not rows:
continue
r = c.post(f"/pier/{name}/anchors/apply", data={"field": [x["field"] for x in rows]}, follow_redirects=True)
txt = r.get_data(as_text=True)
msg = "반영했습니다" in txt
print(f" apply {label}: {'ok' if msg else 'REFUSED'} ({len(rows)} fields)")
if not msg:
sys.exit(1)
after, a1 = count()
print(name, "after: ", {verify.ST_NAME[k]: v for k, v in after.items()})
bad = sorted(b for b, r in a1["sections"].items() if r["st"] == "b")
print(" still 불일치:", bad)
print(" backup:", bk)
@@ -0,0 +1,122 @@
"""One-off (2026-10-19): write the values read from the Ungok P3 drawing (see prompt log Task_도면도형직접판독시험) into data/운곡교P3/params.json.
params.json only; expected.json is left alone (independent evidence, P7). Backs up params/expected/confirm first. Prints what changed and what was NOT read.
usage: python tools/apply_p3_read_values.py [--dry]"""
import copy
import json
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import store # noqa: E402
from tools import infer_footing_rebar as inf # noqa: E402
p3 = store.Pier("운곡교P3")
P = p3.load("params")
Q = copy.deepcopy(P)
P1 = json.loads((APP / "data" / "운곡교P1" / "params.json").read_text(encoding="utf-8"))
SH = -1909 # coping heights P1 -> P3 (D1 EL labels)
changes = []
def put(obj, path, val, why):
keys = path.split(".")
x = obj
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[last]
if old != val:
x[last] = val
changes.append((path, old if not isinstance(old, (list, dict)) else f"{type(old).__name__}[{len(old)}]",
val if not isinstance(val, (list, dict)) else f"{type(val).__name__}[{len(val)}]", why))
# ---- footing, piles, column (D1 dimension texts, cross-checked by section-view line lengths and the pile layout sums)
put(Q, "footing.size_y", 8800.0, "D1 8.800, 800+4@1800+800")
put(Q, "footing.z_bottom", 25175, "D1 EL. 25.175")
put(Q, "lean_concrete.size_y", 9000.0, "footing + 200 (section lines 9000)")
put(Q, "lean_concrete.z_bottom", 25075, "footing bottom - 100")
put(Q, "lean_concrete.z_top", 25175, "= footing bottom")
put(Q, "piles.pitch_y", 1800.0, "D1 4@1.800=7.200")
put(Q, "piles.length", 25300, "D1 L=25.3M")
put(Q, "column.height", 11127, "D1 11.127 = 38502 - (25175 + 2200)")
# ---- coping (geometry of D1 front view, tools/extract_coping_outline.py)
steps = [(-6000, -3764, 41502), (-3764, -1263, 41576), (-1263, 1237, 41650), (1237, 3738, 41724), (3738, 6000, 41798)]
for i, (a, b, z) in enumerate(steps):
put(Q, f"coping.top_steps.{i}.x0", float(a), "D1 coping top outline")
put(Q, f"coping.top_steps.{i}.x1", float(b), "D1 coping top outline")
put(Q, f"coping.top_steps.{i}.z", z, "D1 coping top outline + EL. 41.798")
put(Q, "coping.z_bottom_flat", 38502, "D1 coping soffit flat")
# ---- coping rebar (D1 front view rebar lines)
for t, z in (("1", 41402), ("2", 41302), ("3", 41202)):
put(Q, f"coping_rebar.top.tiers.{t}.z", z, "D1 rebar lines")
put(Q, "coping_rebar.bottom.z_flat", 38602, "soffit flat + 100 (as P1)")
put(Q, "coping_rebar.loops.levels", [v + SH for v in P1["coping_rebar"]["loops"]["levels"]], "P1 levels shifted by the coping drop; lines at 40102..40852 seen")
put(Q, "coping_rebar.c15.z", 41698, "D1 rebar line")
put(Q, "coping_rebar.c15.x0", 3838, "D1 rebar line; schedule C15 a 2.062 = 5900 - 3838")
put(Q, "coping_rebar.c16.z", 41550, "D1 rebar line")
put(Q, "coping_rebar.c16.x0", -1163, "schedule C16 a 5.251 = 4088 + 1163")
put(Q, "coping_rebar.c16.x1", 4088, "D1 rebar line")
# ---- bearings (D1 plan view LWPOLYLINEs, datum = coping plan centre as in P1)
bs = Q["bearing_seats"]
pitch = 2500.5
put(Q, "bearing_seats.pitch_x", pitch, "plan pedestals 2487.4 -> 4987.9; text 3@2.501=7.502")
put(Q, "bearing_seats.pedestal_girder5.front_half", [[4369.3, -1280.7], [4408.7, -31.3], [5608.1, -69.1], [5568.7, -1318.5]], "D1 plan outline")
put(Q, "bearing_seats.pedestal_girder5.rear_half", [[4408.7, -31.3], [4448.0, 1218.1], [5647.4, 1180.3], [5608.1, -69.1]], "D1 plan outline")
put(Q, "bearing_seats.pedestal_girder5.rotation_deg", 1.81, "outline angle 178.19")
put(Q, "bearing_seats.pad_rows.front.centre_rel_girder5", [4987.9, -699.9], "D1 plan pad polyline")
put(Q, "bearing_seats.pad_rows.front.rotation_deg", 1.81, "pad polyline angle")
put(Q, "bearing_seats.pad_rows.rear.centre_rel_girder5", [5014.1, 599.6], "D1 plan pad polyline")
put(Q, "bearing_seats.pad_rows.rear.rotation_deg", 0.5, "pad polyline angle")
for i, b in enumerate(bs["bearings"]):
g = b["girder_line"]
front = b["row"] == "front"
x = (4987.9 if front else 5014.1) - (5 - g) * pitch
put(Q, f"bearing_seats.bearings.{i}.centre", [round(x, 1), -699.9 if front else 599.6], "g4/g5 drawn, g1-g3 by pitch")
put(Q, f"bearing_seats.bearings.{i}.rotation_deg", 1.81 if front else 0.5, "pad angle")
# ---- footing rebar (rules of tools/infer_footing_rebar.py + D1 rebar points)
r = inf.infer(10350, 8800)
put(Q, "footing_rebar.a_x", r["a_x"], "footing - 200")
put(Q, "footing_rebar.a_y", r["a_y"], "footing - 200")
for k in ("bottom_Y1", "bottom_X2", "bottom_Y3", "bottom_X4", "top_Y", "top_X"):
put(Q, f"footing_rebar.layers.{k}.pos", [float(v) for v in r[k]], "layer rule; counts equal the schedule")
mid_x = {"550.0": [-2750, -1750, -750, 750, 1750, 2750], "950.0": [-3750, -3250, -2250, -1250, -250, 250, 1250, 2250, 3250, 3750]}
mid_x["1350.0"], mid_x["1750.0"] = mid_x["550.0"], mid_x["950.0"]
mid_y = {"550.0": [-3875, -2875, -1875, 1875, 2875, 3875], "950.0": [-4375, -3375, -2375, -1375, -375, 375, 1375, 2375, 3375, 4375]}
mid_y["1350.0"], mid_y["1750.0"] = mid_y["550.0"], mid_y["950.0"]
put(Q, "footing_rebar.layers.mid_X.levels", {k: [float(v) for v in vs] for k, vs in mid_x.items()}, "D1 section dots (B11), 32 bars")
put(Q, "footing_rebar.layers.mid_Y.levels", {k: [float(v) for v in vs] for k, vs in mid_y.items()}, "D1 front dots (B12), 32 bars (schedule 38, as P1: drawn 28 / schedule 34)")
put(Q, "footing_rebar.b9.grid_x", [-4625 + 250 * i for i in range(38)], "38 x 33 grid, checkerboard = 627 = schedule")
put(Q, "footing_rebar.b9.grid_y", [-4000 + 250 * i for i in range(33)], "38 x 33 grid")
put(Q, "footing_rebar.b9.count", 627, "schedule 627 = 1351.185 / 2.155")
put(Q, "footing_rebar.b10.size_y", 8644.0, "schedule B10 b 8.644")
# ---- column rebar (schedule + D1 hoop dots)
put(Q, "column_rebar.main_bars.p1.hook_leg_z", 25475, "footing bottom + 300 (as P1)")
put(Q, "column_rebar.main_bars.p1.straight_from", 25815, "+ bend R340")
put(Q, "column_rebar.main_bars.p1.straight_len", 15337, "schedule P1 A 15.337")
put(Q, "column_rebar.main_bars.p2.z0", 25475, "as above")
put(Q, "column_rebar.main_bars.p2.length", 15677, "schedule P2 15.677")
levels = [26355 + 120 * i for i in range(31)]
levels += [levels[-1] + 300 * (i + 1) for i in range(33)]
put(Q, "column_rebar.hoops.levels", levels, "first level 26355 seen in D1 dots; 31 @120 + 33 @300 (schedule 9+22+33)")
put(Q, "column_rebar.hoops.marks", ["P4"] * 9 + ["P5"] * 22 + ["P3"] * 33, "schedule counts")
put(Q, "column_rebar.hoops.radius", 902, "schedule P4/P5 R 902 (2*pi*902 = 5667)")
put(Q, "column_rebar.ties.levels", [26355 + 1800 * i for i in range(8)], "ASSUMED start; chain 7@1.800 = 8 levels (schedule P6 32 = 8 x 4)")
print(f"{len(changes)} changes")
for c in changes:
print(f" {c[0]:58s} {str(c[1])[:22]:>22} -> {str(c[2])[:28]:<28} | {c[3]}")
print("NOT read / not changed: coping_rebar c14 points, stirrups, bottom curve, top_right, x_c9..x_c11; schedule tables; column ties start/end (assumed); main-bar bottom z (assumed)")
if "--dry" in sys.argv:
sys.exit(0)
bk = p3.dir / "backup" / f"before_read_values_{time.strftime('%Y%m%d_%H%M%S')}"
bk.mkdir(parents=True)
for f in ("params.json", "expected.json", "confirm.json"):
if (p3.dir / f).exists():
shutil.copy2(p3.dir / f, bk / f)
p3.save("params", Q)
print("saved; backup:", bk)
@@ -0,0 +1,113 @@
"""Second pass for the Ungok P3 values (2026-10-19): the items read after the first gate run (see prompt log Task_도면도형직접판독시험)
and the stale P2 values the first run left in expected.json / params.json. Backs up first, prints every change.
usage: python tools/apply_p3_stage2.py [--dry]"""
import copy
import json
import re
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import store # noqa: E402
from engine import reader # noqa: E402
p3 = store.Pier("운곡교P3")
P, E = p3.load("params"), p3.load("expected")
P1 = json.loads((APP / "data" / "운곡교P1" / "params.json").read_text(encoding="utf-8"))
SH = -1909
changes = []
def put(doc, tag, 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[last]
if old != val:
x[last] = val
brief = lambda v: v if not isinstance(v, (list, dict)) else f"{type(v).__name__}[{len(v)}]" # noqa: E731
changes.append((f"{tag}:{path}", brief(old), brief(val), why))
# ---------------- params
cr = P["coping_rebar"]
put(P, "params", "coping_rebar.soffit_arc_centre", [6951.9, 28025.6], "24 stirrup bottoms fit z = cz + sqrt(R^2 - dx^2) within 0.2 mm (P1: 0.5 mm)")
put(P, "params", "coping_rebar.stirrups.top_right", [[0, 41550], [3900, 41698]], "D1 stirrup tops = C16 / C15 levels")
put(P, "params", "coping_rebar.stirrups.top_left", [[0, 41550], [1263, 41476], [3764, 41402]], "step top - 100 (P1 rule, inferred)")
put(P, "params", "coping_rebar.stirrups.bottom", "z=38,602 for |X|<=1,073.9 else circle R 12,100 about (+-6,951.9, 28,025.6)", "text of the soffit rule")
put(P, "params", "coping_rebar.c14.points", [[x, round(z + SH, 1)] for x, z in P1["coping_rebar"]["c14"]["points"]], "P1 points - 1909 (23 right-half drawn circles match)")
put(P, "params", "column_rebar.hoops.radius_by_mark", {"P3": 897}, "schedule P3 R897 (P4/P5 R902 = hoops.radius)")
# schedule tables of the pier (own drawing) -> params copies
texts = p3.texts()
rows = {}
for c in reader.table_candidates(texts):
if c["key"].startswith("schedule"):
for r in c["rows"]:
tx = [x["text"] for x in sorted(r["cells"], key=lambda z: z["x"])]
if tx and re.fullmatch(r"[BP]\d+", tx[0].strip()):
rows[tx[0].strip()] = tx
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"}
for m in ("P1", "P2", "P3", "P4", "P5", "P6"):
tx = rows[m]
shape = tx[1]
vals = tx[4:4 + len(ORDER[shape].split())]
new = dict(P["column_rebar"]["schedule"][m])
new["n"] = tx[3]
for f, v in zip(ORDER[shape].split(), vals):
new[f] = v
if shape == "XVI":
new["L"] = vals[0]
put(P, "params", f"column_rebar.schedule.{m}", new, "own schedule row")
for m in [f"B{i}" for i in range(1, 13)]:
old = P["footing_rebar"]["schedule"][m]
new = rows[m]
put(P, "params", f"footing_rebar.schedule.{m}", new, "own schedule row")
# ---------------- expected
g1, g2, g3, g4 = E["G1"], E["G2"], E["G3"], E["G4"]
put(E, "expected", "G1.coping_outline", [[-1100, 38502], [1100, 38502], [6200, 40002], [6000, 41798], [3738, 41798], [3738, 41724], [1237, 41724], [1237, 41650],
[-1263, 41650], [-1263, 41576], [-3764, 41576], [-3764, 41502], [-6000, 41502], [-6200, 40002]], "coping steps + soffit flat 38502")
put(E, "expected", "G1.dxf_arc_centres.datum_el", 38502, "coping soffit flat")
put(E, "expected", "G1.piles.pitch_y", 1800, "D1 4@1.800")
put(E, "expected", "G1.piles.length", 25300, "D1 L=25.3M")
put(E, "expected", "G1.seats.step_top", {"1": 41502, "2": 41576, "3": 41650, "4": 41724, "5": 41798}, "coping steps")
put(E, "expected", "G1.seats.step_x", {"1": [-6000, -3764], "2": [-3764, -1263], "3": [-1263, 1237], "4": [1237, 3738], "5": [3738, 6000]}, "coping steps")
put(E, "expected", "G1.seats.row_y", {"front": -700, "rear": 600}, "plan pad polylines -699.9 / 599.6")
put(E, "expected", "G1.seats.pitch_chain", [3, 7502], "D1 3@2.501=7.502")
put(E, "expected", "G2.hoop_chain", {"start": [150, 1030], "groups": [[8, 120], [22, 120], [33, 300]]}, "D1 chain 33@300=9.900")
put(E, "expected", "G2.hoop_marks_by_zone", [["P4", 9], ["P5", 22], ["P3", 33]], "schedule 9 / 22 / 33")
put(E, "expected", "G2.tie_chain", {"start": [150, 1030], "groups": [[7, 1800]]}, "D1 chain 7@1.800=12.600")
put(E, "expected", "G2.r_hoop", 902, "schedule P4/P5 R902")
put(E, "expected", "G3.rows", {"Y_BOT": {"ends": [-5075, 5075], "start": -4875, "pitch": 125, "n": 79}, "Y_250": {"ends": [-5075, 5075], "start": -4875, "pitch": 250, "n": 40},
"X_BOT": {"ends": [-4300, 4300], "start": -4125, "pitch": 125, "n": 67}, "X_250": {"ends": [-4300, 4300], "start": -4125, "pitch": 250, "n": 34}},
"footing - 200 / 2, first gap 200 (Y) / 175 (X)")
put(E, "expected", "G3.b9_grid", {"x": {"start": -4625, "pitch": 250, "n": 38}, "y": {"start": -4000, "pitch": 250, "n": 33}}, "38 x 33 grid = 627 (schedule)")
put(E, "expected", "G4.range.C9", ["b_sched", 2960, 1376], "schedule C9 2.960~1.376")
put(E, "expected", "G4.range.C11", ["b_sched", 2960, 1672], "schedule C11 2.960~1.672")
put(E, "expected", "G4.range.C13", ["a_sched", 12167, 12045], "schedule C13 12.167~12.045")
put(E, "expected", "G4.tier_z", {"C1": 41402, "C4": 41402, "C2": 41302, "C5": 41302, "C3": 41202, "C6": 41202}, "D1 rebar lines")
put(E, "expected", "G4.c10_bottom", 38602, "soffit flat + 100")
put(E, "expected", "G4.loop_z", {"C12": {"start": 38852, "pitch": 250, "n": 6}, "C13": [40352, 40602, 40852]}, "D1 loop lines 40102..40852")
put(E, "expected", "G4.c15_top", 41698, "D1 rebar line")
put(E, "expected", "G4.c16_top", 41550, "D1 rebar line")
put(E, "expected", "G4.c14_levels", [[41179.5, 11], [41279.5, 11], [39102, 3], [40102, 5], [39602, 4], [40602, 6], [41533.4, 1]], "P1 levels - 1909")
put(E, "expected", "G4.step_top", {"1": 41502, "2": 41576, "3": 41650, "4": 41724, "5": 41798}, "coping steps")
print(f"{len(changes)} changes")
for c in changes:
print(f" {c[0]:44s} {str(c[1])[:24]:>24} -> {str(c[2])[:34]:<34} | {c[3]}")
if "--dry" in sys.argv:
sys.exit(0)
bk = p3.dir / "backup" / f"before_stage2_{time.strftime('%Y%m%d_%H%M%S')}"
bk.mkdir(parents=True)
for f in ("params.json", "expected.json", "confirm.json"):
shutil.copy2(p3.dir / f, bk / f)
p3.save("params", P)
p3.save("expected", E)
print("saved; backup:", bk)
@@ -0,0 +1,37 @@
"""Command-line twin of the web screen "도면에서 읽기" (engine/geomread.py): print / write the values read from an Ungok-type pier drawing into
data/<pier>/params.json and expected.json (2026-10-19, rewritten on top of engine/geomread 2026-10-20).
python tools/apply_pier_read.py 운곡교P1 --dry (a finished pier: only rounding changes; see tools/test_geomread.py)
usage: python tools/apply_pier_read.py <pier> [--dry] backs up params / expected / confirm first; table + anchor candidates are applied afterwards (candidates screen)."""
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import store # noqa: E402
from engine import geomread # noqa: E402
name = sys.argv[1]
pier = store.Pier(name)
P, E = pier.load("params"), pier.load("expected")
ref = store.rjson(store.template_dir("운곡교P1형") / "params.json")
audit = store.rjson(pier.drawings / "audit.json", [])
P2, E2, changes, notes = geomread.read(P, E, ref, pier.texts(), geomread.dxf_of(pier.dir, audit), name)
print(f"{name}: {len(changes)} changes")
for c in changes:
brief = lambda v: v if not isinstance(v, (list, dict)) else f"{type(v).__name__}[{len(v)}]" # noqa: E731
print(f" {c['file']}:{c['path']:58s} {str(brief(c['old']))[:22]:>22} -> {str(brief(c['new']))[:30]:<30} | {c['why']}")
for n in notes:
print("NOTE:", n)
if "--dry" in sys.argv:
sys.exit(0)
bk = pier.dir / "backup" / f"before_apply_pier_read_{time.strftime('%Y%m%d_%H%M%S')}"
bk.mkdir(parents=True)
for f in ("params.json", "expected.json", "confirm.json"):
if (pier.dir / f).exists():
shutil.copy2(pier.dir / f, bk / f)
pier.save("params", P2)
pier.save("expected", E2)
print("saved; backup:", bk)
@@ -0,0 +1,30 @@
"""Build a T-pier .3dm from app params in a headless Rhino run.
usage: python build_model.py <params.json> <out.3dm> [--upto G1..G5]
"""
import argparse
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine.pipeline import TOLERANCE_MM, build_steps # noqa: E402
from runner import run_job # noqa: E402
def build(params, out, upto="G5"):
return run_job(build_steps(params, upto), save=out, units_mm_tolerance=TOLERANCE_MM)
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("params")
ap.add_argument("out")
ap.add_argument("--upto", default="G5")
a = ap.parse_args()
r = build(a.params, a.out, a.upto)
print("wall", r["wall_secs"], "s; objects", r["objects"], "; saved", r.get("saved_to"))
for st in r["steps"]:
print(f" {st['name']:<28} {st['secs']:>7}s {st['stdout'].strip()[:160]}")
(Path(a.out).with_suffix(".build.json")).write_text(json.dumps(r, ensure_ascii=False, indent=1), encoding="utf-8")
@@ -0,0 +1,21 @@
"""Run the app gate checkers on a saved .3dm (opened headless).
usage: python check_model.py <model.3dm> <expected.json> <out dir> [G1 G2 ...]
Writes <out dir>/<gate>.json per checker.
"""
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine.pipeline import check_steps # noqa: E402
from runner import run_job # noqa: E402
if __name__ == "__main__":
model, expected, out = (Path(a).resolve() for a in sys.argv[1:4])
gates = sys.argv[4:] or None
out.mkdir(parents=True, exist_ok=True)
r = run_job(check_steps(expected, out, gates), open_path=model)
print("wall", r["wall_secs"], "s")
for st in r["steps"]:
print(f"{st['name']:<26} {st['secs']:>7}s {st['stdout'].splitlines()[0] if st['stdout'] else ''}")
@@ -0,0 +1,48 @@
"""Item-by-item comparison of two checker result sets (same order expected).
usage: python compare_checks.py <dir A> <dir B> [mapping]
mapping default: legacy_verify_*.json in A <-> G1..G5.json in B. Compares check id, result, expected, actual
and detail; item text differences are listed separately (wording may differ where values became parameters).
"""
import json
import sys
from pathlib import Path
PAIRS = [("legacy_verify_rhino.json", "G1.json"), ("legacy_verify_rebar.json", "G2.json"),
("legacy_verify_footing_rebar.json", "G3.json"), ("legacy_verify_coping_rebar.json", "G4.json"),
("legacy_verify_bearing_plates.json", "G5.json")]
DETAIL_FREE = ("object counts",) # detail wording changed (json.dumps -> str), value compared via result
def compare(a, b):
ra, rb = a["results"], b["results"]
value_diffs, name_diffs = [], []
for i, (x, y) in enumerate(zip(ra, rb)):
for k in ("check", "result", "expected", "actual", "tol"):
if k in x or k in y:
vx, vy = x.get(k), y.get(k)
if isinstance(vx, (int, float)) and isinstance(vy, (int, float)):
if abs(vx - vy) > 1e-9 + (1e-6 * abs(vx) if k == "tol" else 0):
value_diffs.append((i, x["item"], k, vx, vy))
elif vx != vy:
value_diffs.append((i, x["item"], k, vx, vy))
if x.get("detail") != y.get("detail") and not x["item"].startswith(DETAIL_FREE):
value_diffs.append((i, x["item"], "detail", x.get("detail"), y.get("detail")))
if x["item"] != y["item"]:
name_diffs.append((i, x["item"], y["item"]))
return {"n": [len(ra), len(rb)], "value_diffs": value_diffs, "name_diffs": name_diffs,
"same": len(ra) == len(rb) and not value_diffs}
if __name__ == "__main__":
A, B = Path(sys.argv[1]), Path(sys.argv[2])
ok = True
for fa, fb in PAIRS:
r = compare(json.loads((A / fa).read_text(encoding="utf-8")), json.loads((B / fb).read_text(encoding="utf-8")))
ok &= r["same"]
print(f"{fb}: n={r['n']} same={r['same']} value_diffs={len(r['value_diffs'])} name_diffs={len(r['name_diffs'])}")
for d in r["value_diffs"][:8]:
print(" VALUE", d)
for d in r["name_diffs"]:
print(" NAME ", d)
sys.exit(0 if ok else 1)
@@ -0,0 +1,74 @@
"""Regression check: compare two model fingerprints (fingerprint.py output) object by object.
usage: python compare_fingerprints.py <baseline.json> <candidate.json> [<report.json>]
Tolerances: bbox 0.011 mm (values rounded to 0.01), curve length 0.002 mm, volume 1e-6 relative.
Everything else (layer, type, visibility, colour, face count, solidity, user strings) must be equal.
Exit code 0 = identical within tolerance.
"""
import json
import sys
from collections import Counter
from pathlib import Path
def diff_obj(a, b):
out = []
for k in sorted(set(a) | set(b)):
va, vb = a.get(k), b.get(k)
if k == "bbox" and va and vb:
if max(abs(x - y) for x, y in zip(va, vb)) > 0.011:
out.append((k, va, vb))
elif k == "length" and va is not None and vb is not None:
if abs(va - vb) > 0.002:
out.append((k, va, vb))
elif k == "volume" and isinstance(va, float) and isinstance(vb, float):
if abs(va - vb) > 1e-6 * max(abs(va), 1.0):
out.append((k, va, vb))
elif k == "user":
for uk in sorted(set(va or {}) | set(vb or {})):
if (va or {}).get(uk) != (vb or {}).get(uk):
out.append((f"user.{uk}", (va or {}).get(uk), (vb or {}).get(uk)))
elif va != vb:
out.append((k, va, vb))
return out
def is_fast(fp):
return any(o.get("volume") == "skipped" for o in fp["objects"].values())
def compare(base, cand):
# A fast fingerprint skips pipe volumes, and Rhino then returns pipe bounding boxes without the pipe radius
# (2026-10-12: B9 top 28350.87 fast vs 28358.87 full on the same file) -> only compare like with like.
if is_fast(base) != is_fast(cand):
raise ValueError("fingerprint modes differ (FAST vs full): compare fingerprints made with the same mode")
A, B = base["objects"], cand["objects"]
only_a, only_b = sorted(set(A) - set(B)), sorted(set(B) - set(A))
diffs = {oid: d for oid in sorted(set(A) & set(B)) if (d := diff_obj(A[oid], B[oid]))}
fields = Counter(f for d in diffs.values() for f, _, _ in d)
layer_diff = {k: (base["layers"].get(k), cand["layers"].get(k))
for k in set(base["layers"]) | set(cand["layers"]) if base["layers"].get(k) != cand["layers"].get(k)}
rep = {"baseline": base["model"], "candidate": cand["model"],
"count": [base["count"], cand["count"]], "units": [base["units"], cand["units"]],
"tolerance": [base["tolerance"], cand["tolerance"]],
"only_in_baseline": only_a, "only_in_candidate": only_b,
"objects_with_diffs": len(diffs), "diff_fields": dict(fields), "layer_diffs": layer_diff,
"samples": {oid: d[:6] for oid, d in list(diffs.items())[:25]}}
rep["identical"] = (not only_a and not only_b and not diffs and not layer_diff
and rep["units"][0] == rep["units"][1])
return rep
if __name__ == "__main__":
base = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
cand = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8"))
rep = compare(base, cand)
if len(sys.argv) > 3:
Path(sys.argv[3]).write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
brief = {k: rep[k] for k in ("count", "units", "tolerance", "objects_with_diffs", "diff_fields", "identical")}
brief["only_in_baseline"], brief["only_in_candidate"] = rep["only_in_baseline"][:10], rep["only_in_candidate"][:10]
brief["layer_diffs"] = rep["layer_diffs"]
print(json.dumps(brief, ensure_ascii=False, indent=1))
for oid, d in list(rep["samples"].items())[:8]:
print(oid, d)
sys.exit(0 if rep["identical"] else 1)
@@ -0,0 +1,74 @@
"""Read the coping top-step outline of D1 (front view) straight from the DXF geometry (2026-10-19, trial of geometry reading).
Finds the two end-batter tip lines on layer CS-CONC-MAJR (|dx|~200, |dy| 1300..1900), chains the horizontal / vertical lines between their top
ends, converts x to the pier axis (centre of the two tips) and the heights to EL with the 'EL. xx.xxx' label next to the highest step.
Reads only. usage: python tools/extract_coping_outline.py <D1.dxf> <texts.json> [params.json] (params.json: compare with coping.top_steps)"""
import json
import re
import sys
import ezdxf
dxf, texts = sys.argv[1], sys.argv[2]
doc = ezdxf.readfile(dxf)
L = [(e.dxf.start.x, e.dxf.start.y, e.dxf.end.x, e.dxf.end.y) for e in doc.modelspace() if e.dxftype() == "LINE" and e.dxf.layer == "CS-CONC-MAJR"]
tips = [l for l in L if 150 < abs(l[2] - l[0]) < 250 and 1300 < abs(l[3] - l[1]) < 1900 and l[1] < 25000 and l[1] > 0]
tips = sorted({(round(l[0], 1), round(l[1], 1), round(l[2], 1), round(l[3], 1)) for l in tips})
if len(tips) != 2:
sys.exit(f"expected 2 tip lines, found {len(tips)}: {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) < 0.05 or abs(b - d) < 0.05)]
steps, used = [], set()
while abs(cur[0] - end[0]) > 0.5 or abs(cur[1] - end[1]) > 0.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]) < 0.5 and abs(p[1] - cur[1]) < 0.5 and q[0] >= cur[0] - 0.5 and (q[1] >= cur[1] - 0.5 or abs(q[0] - cur[0]) > 0.5):
nxt = (s, q)
break
if nxt:
break
if not nxt:
sys.exit(f"chain broken at {cur}")
used.add(nxt[0])
if abs(nxt[1][1] - cur[1]) < 0.05:
steps.append([cur[0], nxt[1][0], cur[1]])
cur = nxt[1]
y_hi = max(s[2] for s in steps)
els = [(t["text"], t["x"], t["y"]) for t in json.load(open(texts, encoding="utf-8")) if t["dwg"] == "D1" and t["text"].startswith("EL.")
and abs(t["x"] - end[0]) < 1500 and 0 < t["y"] - y_hi < 1200]
if len(els) != 1:
sys.exit(f"need exactly one EL label next to the highest step, found {els}")
el_hi = float(re.search(r"\d+\.\d+", els[0][0]).group()) * 1000
res = [{"x0": round(a - cx), "x1": round(b - cx), "z": round(el_hi - (y_hi - y))} for a, b, y in steps]
print("axis x =", round(cx, 1), "| EL label", els[0][0])
for r in res:
print(r)
if len(sys.argv) > 3:
ref = json.load(open(sys.argv[3], encoding="utf-8"))["coping"]["top_steps"]
d = max(max(abs(a[k] - b[k]) for k in ("x0", "x1", "z")) for a, b in zip(ref, res)) if len(ref) == len(res) else None
print("max difference from params coping.top_steps (mm):", d)
# ---- other coping dimensions from the same geometry
other = {"length_bottom_tip_to_tip": round(bot(right)[0] - bot(left)[0]),
"tip_bottom_rise": round(top(left)[1] - bot(left)[1]),
"end_batter_top_inset": round(abs(top(left)[0] - bot(left)[0]))}
arcs = {round(e.dxf.radius) for e in doc.modelspace() if e.dxftype() == "ARC" and e.dxf.layer == "CS-CONC-MAJR" and e.dxf.radius > 3000}
other["soffit_radius"] = sorted(arcs)
flat = [(a, b, c_, d) for a, b, c_, d in hv if abs(b - d) < 0.05 and 1800 < abs(c_ - a) < 2600 and abs((a + c_) / 2 - cx) < 600 and bot(left)[1] - 1700 < b < y_hi]
flat = sorted({(round(f[0], 1), round(f[1], 1), round(f[2], 1)) for f in flat if f[1] < bot(left)[1] + 100})
if flat:
f = flat[0]
other["flat_bottom_half_width"] = round(abs(f[2] - f[0]) / 2)
other["z_bottom_flat"] = round(el_hi - (y_hi - f[1]))
other["_flat_candidates"] = len(flat)
sect = sorted({round(abs(c_ - a), 1) for a, b, c_, d in hv if abs(b - d) < 0.05 and 3000 <= abs(c_ - a) <= 3200 and a > 20000})
other["width_y_candidates"] = sect
print("other:", other)
if len(sys.argv) > 3:
print("params:", {k: ref_ for k, ref_ in json.load(open(sys.argv[3], encoding="utf-8"))["coping"].items() if k in other})
@@ -0,0 +1,18 @@
"""Fingerprint a .3dm in a headless Rhino run. usage: python fingerprint_model.py <model.3dm> <out.json>"""
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from runner import run_job # noqa: E402
def fingerprint(model, out):
r = run_job([{"name": "fingerprint", "script": APP / "engine" / "rhino" / "fingerprint.py",
"globals": {"MODEL_PATH": str(Path(model).resolve()), "OUT_PATH": str(Path(out).resolve())}}])
return r
if __name__ == "__main__":
r = fingerprint(sys.argv[1], sys.argv[2])
print("wall", r["wall_secs"], "s; step", r["steps"][0]["secs"], "s;", r["steps"][0]["stdout"].strip())
@@ -0,0 +1,23 @@
"""Infer the footing rebar layers B1-B10 from the footing size (2026-10-19, read-only trial). The rules were read off the template pier (운곡교 P1):
bars run (footing - 200) long; the dense layers (B2/B4, B6/B8) have the two end bars, one bar 200 (X-position axis) / 175 (Y) inside, then a 125 grid;
the sparse layers (B3, B7, B1, B5) the same with a 250 grid; B9 ties sit on a 250 grid (checkerboard, half of the points); B10 hoops grow with the footing.
The result is checked against (a) the template params (P1: must reproduce) and (b) the bar counts of the pier's own rebar schedule (independent evidence).
usage: python tools/infer_footing_rebar.py <size_x> <size_y> -> prints the layers; with --check <params.json> compares with that file's footing_rebar"""
import json
import sys
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from engine.footing_rule import dense, infer, sparse # noqa: E402,F401
if __name__ == "__main__":
sx, sy = float(sys.argv[1]), float(sys.argv[2])
r = infer(sx, sy)
print({k: (len(v) if isinstance(v, list) else v) for k, v in r.items()})
if "--check" in sys.argv:
ref = json.load(open(sys.argv[sys.argv.index("--check") + 1], encoding="utf-8"))["footing_rebar"]
for k in ("bottom_Y1", "bottom_X2", "bottom_Y3", "bottom_X4", "top_Y", "top_X"):
print(k, "equal" if [round(x, 1) for x in r[k]] == [round(x, 1) for x in ref["layers"][k]["pos"]] else "DIFF")
@@ -0,0 +1,30 @@
"""P1 acceptance baseline: run the legacy verify scripts (Code/교각모델링, unchanged) on a saved model.
usage: python legacy_baseline.py <model.3dm> <out dir>
Writes <out dir>/legacy_<script>.json (one per checker) and prints each summary.
"""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
LEGACY = APP.parent / "교각모델링"
sys.path.insert(0, str(APP))
from runner import run_job # noqa: E402
SCRIPTS = ["verify_rhino.py", "verify_rebar.py", "verify_footing_rebar.py", "verify_coping_rebar.py",
"verify_bearing_plates.py"]
if __name__ == "__main__":
model, out = Path(sys.argv[1]).resolve(), Path(sys.argv[2]).resolve()
out.mkdir(parents=True, exist_ok=True)
steps = [{"name": s, "script": LEGACY / s, "globals": {"RESULT_PATH": str(out / f"legacy_{Path(s).stem}.json")}}
for s in SCRIPTS]
r = run_job(steps, open_path=model)
print("wall", r["wall_secs"], "s")
for st in r["steps"]:
print(f"{st['name']:<28} {st['secs']:>7}s {st['stdout'].splitlines()[0] if st['stdout'] else ''}")
for s in SCRIPTS:
d = json.loads((out / f"legacy_{Path(s).stem}.json").read_text(encoding="utf-8"))
fails = [x["item"] for x in d["results"] if x["result"] == "FAIL"]
print(s, d["summary"].get("total"), "checks,", len(fails), "FAIL:", fails[:12])
@@ -0,0 +1,28 @@
"""Learn anchors (engine/anchors.py) for the protected templates from their source pier's drawing and write data/_템플릿/<t>/anchors.json
(2026-10-19). The template's own params / expected give the values, the source pier's drawing texts give the places.
usage: python tools/make_anchors.py [--force]"""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine import anchors # noqa: E402
SOURCE = {"운곡교P1형": "운곡교P1", "산외교울산P1형": "산외교울산P1", "산외교울산P2형": "산외교울산P2", "새재1교P1형": "새재1교P1",
"옥성교P1형": "옥성교P1", "Ramp-A교P1형": "Ramp-A교P1"}
force = "--force" in sys.argv
for t, pier in SOURCE.items():
td = APP / "data" / "_템플릿" / t
out = td / "anchors.json"
if out.exists() and not force:
print("keep", t)
continue
P = json.loads((td / "params.json").read_text(encoding="utf-8"))
E = json.loads((td / "expected.json").read_text(encoding="utf-8"))
d = APP / "data" / pier / "drawings"
texts = json.loads((d / "texts.json").read_text(encoding="utf-8"))
shas = [a["source_sha256"] for a in json.loads((d / "audit.json").read_text(encoding="utf-8")) if a.get("source_sha256")]
a = anchors.learn(P, E, texts, shas)
out.write_text(json.dumps(a, ensure_ascii=False), encoding="utf-8")
print("wrote", t, "fields", len(a["fields"]), "landmarks", len(a["landmarks"]), f"{out.stat().st_size // 1024} KB")
@@ -0,0 +1,18 @@
"""Write data/<pier>/origin_values.json (creation snapshot used by C3b) for a pier made before the app wrote it, from an early backup
(2026-10-20). The snapshot is marked reconstructed. usage: python tools/make_origin_values.py <pier> <backup folder name>"""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP / "web"))
import store # noqa: E402
name, bk = sys.argv[1], sys.argv[2]
d = APP / "data" / name
b = d / "backup" / bk
P, E = (json.loads((b / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json"))
info = json.loads((d / "pier.json").read_text(encoding="utf-8"))
store.wjson(d / "origin_values.json", {"label": info.get("template", "?"), "at": store.now(), "reconstructed": True,
"source": f"backup/{bk}", "params": P, "expected": E})
print("written", d / "origin_values.json", "from", b)
@@ -0,0 +1,45 @@
"""Build web/static/prism.css from the design mockup's pier.css (2026-10-16): the Prism tokens (:root) plus the component rules
(card, strip, banner, badge, button, field, table, key-value, list, scroll, chip ...) with every selector scoped under `.prism`, so the
new stage-B screens use Prism while the old screens keep their look until stage D.
Skipped from the mockup: global resets (body, a, headings), the app shell (.app/.top/.rail/.main/...), the modal scrim.
usage: python tools/make_prism_css.py"""
import re
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
SRC = APP.parents[1] / "DOC" / "01_기획" / "웹앱디자인시안" / "시안" / "pier.css"
OUT = APP / "web" / "static" / "prism.css"
START = "/* scope, card, strip, banner */"
SKIP_PREFIX = (".scrim", ".modal") # app-shell overlays are not used in the web app
def scope(selector):
parts = [p.strip() for p in selector.split(",")]
return ", ".join(f".prism {p}" if not p.startswith("@") else p for p in parts)
css = SRC.read_text(encoding="utf-8")
root = css[:css.index("* { box-sizing")]
comp = css[css.index(".eyebrow {"):css.index(START)] + css[css.index(START):] # layout helpers (.row .muted .grid3 ...) + components
comp = comp.replace("@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }", "")
rules = re.findall(r"([^{}/]+)\{([^{}]*)\}", re.sub(r"/\*.*?\*/", "", comp, flags=re.S))
out = ["/* generated by tools/make_prism_css.py from DOC/01_기획/웹앱디자인시안/시안/pier.css — do not edit by hand */",
'@font-face { font-family: "Pretendard"; src: url("fonts/PretendardVariable.woff2") format("woff2-variations"); font-weight: 45 920; font-display: swap; }',
root.strip(),
".prism { font-family: var(--prism-font); font-size: var(--prism-type-body-sm); line-height: var(--prism-lh-body); color: var(--prism-text-body);"
" font-variant-numeric: tabular-nums; margin: var(--prism-space-200) 0; }", # the page itself is the canvas (prism-app.css)
".prism * { box-sizing: border-box; }",
".prism a { color: var(--prism-text-heading); }",
".prism h1, .prism h2, .prism h3, .prism p, .prism dl, .prism dd, .prism ol, .prism ul { margin: 0; padding: 0; }",
".prism h2 { font-size: var(--prism-type-title-sm); line-height: var(--prism-lh-title); font-weight: 500; color: var(--prism-text-heading); }",
".prism h3 { font-size: var(--prism-type-body-lg); font-weight: 700; color: var(--prism-text-heading); }",
".prism textarea { height: auto; min-height: 5rem; width: 100%; font-family: var(--prism-font); }", # base.html sets a 480 px textarea for the JSON editor
".prism :focus-visible { outline: var(--prism-focus-ring) solid var(--prism-comp-line-strong); outline-offset: var(--prism-focus-offset); }",
]
for sel, body in rules:
sel = sel.strip()
if not sel or sel.startswith(SKIP_PREFIX) or sel.startswith("@"):
continue
out.append(f"{scope(sel)} {{{body.strip()}}}")
OUT.write_text("\n".join(out) + "\n", encoding="utf-8")
print("written", OUT, len(rules), "rules read")
@@ -0,0 +1,31 @@
"""CLI: write the review report draft (.md + .pdf + report_check.json) for a pier run folder.
usage: python make_report.py <pier data dir> [<run dir>] [--author 이름]
run dir defaults to <pier data dir>/runs/current. Gates whose recorded inputs hash differs from the current
params/expected are reported as void (same rule as the web app).
"""
import argparse
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "web"))
from engine import report_files # noqa: E402
from engine.pipeline import gate_ids # noqa: E402
import store # noqa: E402
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("pier")
ap.add_argument("run", nargs="?")
ap.add_argument("--author", default="")
a = ap.parse_args()
pier = Path(a.pier).resolve()
run = Path(a.run).resolve() if a.run else pier / "runs" / "current"
cur = store.sha([store.rjson(pier / "params.json"), store.rjson(pier / "expected.json")])
stale = {g for g in gate_ids()
if (rep := store.rjson(run / f"{g}_gate.json")) and rep.get("inputs_sha") not in (None, cur)}
out = report_files.write(pier, run, a.author, stale)
print(out["md"], "\n", out["pdf"], "\n", out["check"])
sys.exit(0 if out["check"]["ok"] else 1)
@@ -0,0 +1,41 @@
"""Save a pier's params / expected as a protected template in data/_템플릿/<template> (2026-10-13).
usage:
python tools/make_template.py <pier | folder> <template name> "<form description>"
python tools/make_template.py --defaults # the two reference piers (운곡교P1형, 산외교울산P1형)
Templates are not editable in the web app; a re-save keeps the old snapshot in data/_템플릿/_이전/.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "web"))
import store # noqa: E402
DEFAULTS = [
("운곡교P1", "운곡교P1형",
"말뚝기초 · 원형 기둥 Ø2.0 · 하면 곡선(R12,000) 코핑, 상면 5단 · 받침 10개 · 철근 G2~G4 운곡교 형식"),
# the Ulsan P1 pier was deleted in the app on 2026-10-13 13:11; its intact copy (G1~G5 reading) is in the trash
(str(store.DATA / store.TRASH / "산외교울산P1_20261013_131140"), "산외교울산P1형",
"직접기초(말뚝 없음) · 원형 기둥 Ø2.5 · 직선 경사 코핑(끝면 300 경사, 상면 130 단차, 평면 R500) · 받침 4개 · "
"철근 G2~G4 울산 형식(코핑 철근은 철근별 중심선)"),
]
def main(argv):
jobs = DEFAULTS if argv == ["--defaults"] else [tuple(argv)] if len(argv) == 3 else None
if not jobs:
print(__doc__)
return 2
for pier, name, desc in jobs:
if argv == ["--defaults"] and not (Path(pier).is_dir() or (store.DATA / pier / "params.json").exists()):
print(f"skip {name}: source {pier} not found (the existing template is kept)")
continue
d = store.save_template(pier, name, desc)
print(f"saved {pier} -> {d}")
for t in store.templates():
print(t["name"], "|", t["source"], "|", ", ".join(t["scope"]))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
@@ -0,0 +1,26 @@
"""Build the throw-away data root 검증/ui_check_data for UI screenshots (2026-10-19): a new Ungok pier from the template 운곡교P1형 whose drawing index is
the one of Ungok P2 (same series), so the out-of-table candidates screen has differing A / B candidates. Used with tools/serve_ui_check.py."""
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "ui_check_data"
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import store # noqa: E402
for n in list(TD.iterdir()):
if n.name != "_템플릿":
shutil.rmtree(n, ignore_errors=True)
p = store.create_pier("운곡신규", "T형", "tpl:운곡교P1형")
sys.path.insert(0, str(APP / "tools"))
from testlib import pier_dir # noqa: E402
shutil.copytree(pier_dir("운곡교p2") / "drawings", p.drawings, dirs_exist_ok=True)
aud = store.rjson(p.drawings / "audit.json", [])
for a in aud:
a["source_sha256"] = "x" + a["source_sha256"][1:]
store.wjson(p.drawings / "audit.json", aud)
print("ready", p.dir)
@@ -0,0 +1,26 @@
"""Write variability.json (draft classes of every bundle, engine/variability.py) into each protected template that lacks one,
or into all with --force (2026-10-16). usage: python tools/make_variability.py [--force]"""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine import variability # noqa: E402
force = "--force" in sys.argv
root = APP / "data" / "_템플릿"
for d in sorted(root.iterdir()):
if d.name.startswith("_") or not (d / "params.json").exists():
continue
out = d / "variability.json"
if out.exists() and not force:
print("keep", d.name)
continue
P = json.loads((d / "params.json").read_text(encoding="utf-8"))
E = json.loads((d / "expected.json").read_text(encoding="utf-8"))
v = variability.build(P, E)
out.write_text(json.dumps(v, ensure_ascii=False, indent=1), encoding="utf-8")
from collections import Counter
c = Counter(s["grade"] for s in v["sections"].values() if not s["info"])
print("wrote", d.name, len(v["sections"]), "bundles", dict(c), "info", sum(s["info"] for s in v["sections"].values()))
@@ -0,0 +1,83 @@
"""Convert the Ungok P1 working params.json into the app schema `pier-T/1`.
The legacy build scripts kept some values as code constants. They are moved into params here, each with
its source, so that the app builders contain no pier-specific numbers:
rebar_marks <- MARK / MARK_DIA tables of the legacy build scripts (D3 schedule reading)
footing_rebar.a_y/a_x <- D3 B1 a 7.600 / B5 a 9.150
coping_rebar.soffit_arc_centre <- rounded right soffit-arc centre used by the legacy coping build
coping_rebar.stirrups.top_right/top_left <- band thresholds that were encoded in key names
sole_plates.h3 <- D2 ELEVATION TABLE h3
display <- layer colours, diameter palette, hidden objects (user decisions)
usage: python migrate_legacy_params.py <legacy params.json> <out params.json>
"""
import copy
import json
import sys
from pathlib import Path
MARKS = {
# column (D3, 설계서 §5)
"P1": ("S32", "XIV"), "P2": ("S32", "XVI"), "P3": ("H19", "VI"), "P4": ("H19", "V"), "P5": ("H19", "IX"),
"P6": ("H16", "III"),
# footing (D3, 설계서 §6)
"B1": ("S25", "III"), "B2": ("S25", "XII"), "B3": ("S19", "III"), "B4": ("S25", "I"), "B5": ("S29", "XII"),
"B6": ("S29", "XII"), "B7": ("S19", "XII"), "B8": ("S29", "XVI"), "B9": ("H16", "XI"), "B10": ("S19", "XIII"),
"B11": ("S16", "XII"), "B12": ("S16", "III"),
# coping (D3, 설계서 §7)
"C1": ("H29", "VIII"), "C2": ("H29", "VIII"), "C3": ("H29", "VIII"), "C4": ("H29", "VIII"),
"C5": ("H29", "VIII"), "C6": ("H29", "VIII"), "C7": ("H29", "VII"), "C8": ("H29", "VII"),
"C9": ("H25", "IV"), "C10": ("H25", "IV"), "C11": ("H25", "IV"), "C12": ("H25", "X"), "C13": ("H25", "X"),
"C14": ("H19", "III"), "C15": ("H19", "III"), "C16": ("H19", "II"), "S1": ("H19", "III"), "S2": ("H19", "III"),
}
def migrate(P):
A = copy.deepcopy(P)
A["_schema"] = "pier-T/1"
A["pier_id"] = "P1"
A["rebar_marks"] = {m: {"dia": d, "shape": s} for m, (d, s) in MARKS.items()}
A["rebar_marks_src"] = "D3 철근재료표 판독 (설계서 v0.1.10 §5~7; 동일 기호(\") 해석 반영)"
fr = A["footing_rebar"]
fr["a_y"], fr["a_x"] = 7600.0, 9150.0
fr["a_src"] = "D3 B1 a '7.600' (Y bars B1-B4, B12) / B5 a '9.150' (X bars B5-B8, B11)"
cr = A["coping_rebar"]
cr["soffit_arc_centre"] = [6951.9, 29934.6]
cr["soffit_arc_centre_src"] = "right soffit arc centre, rounded to 0.1 mm (legacy build constant); left mirrored"
st = cr["stirrups"]
st["top_right"] = [[0, st["top_right"]["X<3900"]], [3900, st["top_right"]["X>=3900"]]]
t = st.pop("top_left_inferred")
st["top_left"] = [[0, t["|X|<1296"]], [1296, t["1296<=|X|<3801"]], [3801, t["|X|>=3801"]]]
st["top_bands_note"] = "[[|X| from, Z]]: right applies to X>=0, left to X<0 [추론]; last band with |X|>=from wins"
A["materials"] = {"fck": {"lean": "15MPa", "footing": "27MPa", "column": "40MPa", "coping": "40MPa",
"seat": "40MPa"},
"src": "D1 주기 콘크리트 강도 (legacy build constants)"}
A["piles"]["kind"] = "composite pile"
bs = A["bearing_seats"]
bs["reference_girder"] = 5
bs["pad_margin_bottom"], bs["pad_margin_top"] = 200, 100
bs["pad_margin_src"] = "D1 평면도 pad (A+200)x(B+200) bottom / (A+100)x(B+100) top (pad_size_rule)"
A["sole_plates"]["h3"] = 181
A["display"] = {
"concrete_layers": {"lean": ["버림콘크리트", [160, 160, 160]], "footing": ["기초", [120, 150, 200]],
"column": ["기둥", [200, 140, 90]], "coping": ["코핑", [210, 180, 80]],
"pile": ["말뚝", [110, 110, 110]], "seat": ["받침대좌", [190, 90, 90]],
"pad": ["받침패드", [90, 160, 90]]},
"centreline_colour": [60, 60, 60],
"column_mark_colours": {"P1": [200, 40, 40], "P2": [230, 120, 40], "P3": [40, 120, 200],
"P4": [40, 170, 170], "P5": [90, 90, 220], "P6": [140, 60, 160]},
"footing_layer_colour": [150, 110, 60],
"coping_layer_colour": [120, 120, 160],
"rebar_palette": {"32": [214, 39, 40], "29": [255, 127, 14], "25": [188, 60, 160], "19": [44, 160, 44],
"16": [31, 119, 180]},
"hidden_ids": ["P1-FT", "P1-CP"],
"src": "디자인지침_경로.md R8 팔레트(2026-10-07), 숨김 = 사용자 결정 '숨김 유지'(P1-FT, P1-CP)",
}
return A
if __name__ == "__main__":
src, dst = Path(sys.argv[1]), Path(sys.argv[2])
A = migrate(json.loads(src.read_text(encoding="utf-8")))
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(A, ensure_ascii=False, indent=1), encoding="utf-8")
print(dst, len(A["rebar_marks"]), "marks")
@@ -0,0 +1,54 @@
"""OPEN #19 regression snapshot: table records and expected-side proposals of the reference piers.
usage: python tools/open19_snapshot.py <tag> -> 검증/open19_snapshot_<tag>.json
Run before and after the OPEN #19 changes and compare (tools/test_open19.py reads both).
Sources are read only: Ungok P1 / P2 data folders, Ulsan texts from the recycle-bin copy + protected template.
"""
import json
import re
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from engine import reader # noqa: E402
from testlib import pier_dir # noqa: E402
PIERS = {
"운곡교P1": (APP / "data/운곡교P1/drawings/texts.json", APP / "data/운곡교P1"),
"운곡교p2": (pier_dir("운곡교p2") / "drawings/texts.json", pier_dir("운곡교p2")),
"산외교울산P1": (APP / "data/_휴지통/산외교울산P1_20261013_131140/drawings/texts.json",
APP / "data/_템플릿/산외교울산P1형"),
}
def snapshot():
out = {}
for name, (tx, d) in PIERS.items():
texts = json.loads(tx.read_text(encoding="utf-8"))
P = json.loads((d / "params.json").read_text(encoding="utf-8"))
X = json.loads((d / "expected.json").read_text(encoding="utf-8"))
tables = reader.table_candidates(texts)
recs = {}
for t in tables:
if not t["rows"]:
continue
pred = (lambda s: s.isdigit()) if not t["key"].startswith("schedule") else (lambda s: bool(reader.MARK_RE.match(s)))
recs[f"{t['anchor']['dwg']}|{t['key']}|{t['anchor']['y']}"] = [r for _, r in reader.table_records(t["rows"], pred)]
rows = reader.proposals(reader.field_candidates(tables), P, X)
out[name] = {"records": recs,
"proposals": [{k: r[k] for k in ("file", "path", "current", "candidate", "same")} for r in rows]}
return out
if __name__ == "__main__":
tag = sys.argv[1]
p = APP / "검증" / f"open19_snapshot_{tag}.json"
p.write_text(json.dumps(snapshot(), ensure_ascii=False, indent=1), encoding="utf-8")
s = json.loads(p.read_text(encoding="utf-8"))
for k, v in s.items():
props = v["proposals"]
print(k, "tables", len(v["records"]), "records", sum(len(x) for x in v["records"].values()),
"proposals", len(props), "differ", sum(not r["same"] for r in props),
"params rows", sum(r["file"] == "params" for r in props))
@@ -0,0 +1,189 @@
"""Patch (2026-10-12, user request): batch confirmation of required sections and batch run + auto-approval of
gates G1..G5. Approvals are recorded under the name typed by the user, note "일괄 실행 자동 승인"; only PASS
gates are approved and the batch stops at the first FAIL / error / blocker."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/app.py", [
("""ACTIVE = {} # job id -> thread of this server process""",
"""ACTIVE = {} # job id -> thread of this server process
BATCH = {} # pier name -> batch thread of this server process
AUTO_NOTE = "일괄 실행 자동 승인\""""),
(""" return redirect(request.form.get("next") or url_for("pier", name=name))
# ---------------- gates""",
""" return redirect(request.form.get("next") or url_for("pier", name=name))
def unconfirmed(p):
\"\"\"Required sections (all gates) that exist and are not confirmed, without duplicates.\"\"\"
out = []
for g in gate_ids():
for r in p.gate_requirements(g):
if r["exists"] and not r["confirmed"] and (r["file"], r["key"]) not in out:
out.append((r["file"], r["key"]))
return out
@app.post("/pier/<name>/confirm_all")
def confirm_all(name):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("확정자 이름을 입력하세요.", "fail")
return redirect(url_for("pier", name=name))
need = unconfirmed(p)
for f, k in need:
p.confirm(f, k, by)
flash(f"필요한 묶음 {len(need)}개를 일괄 확정했습니다 ({by}).", "ok")
return redirect(url_for("pier", name=name))
# ---------------- gates"""),
("""@app.post("/pier/<name>/gate/<gate>/run")
def gate_run(name, gate):
p = pier_or_404(name)
if gate not in gate_ids():
abort(404)""",
"""def batch_state(p):
\"\"\"State of the batch run of this pier (실행 중 only while its thread lives in this server process).\"\"\"
b = store.rjson(p.run_dir / "batch.json")
if b and b["state"] == "실행 중":
t = BATCH.get(p.name)
if b.get("pid") != os.getpid() or not (t and t.is_alive()):
b = dict(b, state="중단됨")
return b
def _batch(p, by, gates_todo):
bp = p.run_dir / "batch.json"
log = []
def put(state):
store.wjson(bp, {"state": state, "by": by, "pid": os.getpid(), "at": store.now(), "log": log})
put("실행 중")
for g in gates_todo:
b = gate_blockers(p, g)
if b:
log.append(f"{g} 실행 불가: " + ", ".join(b))
return put("중단")
jid = uuid.uuid4().hex[:12]
store.wjson(p.run_dir / f"{g}_job.json", {"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []})
ACTIVE[jid] = threading.current_thread()
log.append(f"{g} 실행 시작")
put("실행 중")
_gate_job(p, g, jid)
st = p.gate_status(g)
rep, job = st["report"] or {}, st["job"] or {}
if job.get("state") != "완료" or rep.get("job_id") != jid or rep.get("verdict") != "PASS":
log.append(f"{g} {rep.get('verdict') or job.get('state')} — 승인하지 않고 멈춤 (결과를 확인하세요)")
return put("중단")
p.set_approval(g, "승인", by, AUTO_NOTE)
s = rep["summary"]
log.append(f"{g} PASS {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) → 승인")
put("실행 중")
put("완료")
def anything_running(p):
if (batch_state(p) or {}).get("state") == "실행 중":
return True
return any(job_state(p.gate_status(g)["job"]) in ("대기", "실행 중") for g in gate_ids())
@app.post("/pier/<name>/gates/run_all")
def gates_run_all(name):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("판정자 이름을 입력하세요.", "fail")
return redirect(url_for("gates", name=name))
if anything_running(p):
flash("이미 실행 중인 작업이 있습니다.", "fail")
return redirect(url_for("gates", name=name))
todo = [g for g in gate_ids() if not p.gate_status(g)["approved"]]
if not todo:
flash("모든 게이트가 이미 승인되어 있습니다.", "ok")
return redirect(url_for("gates", name=name))
b = gate_blockers(p, todo[0])
if b:
flash(f"{todo[0]} 실행 불가: " + ", ".join(b), "fail")
return redirect(url_for("gates", name=name))
store.wjson(p.run_dir / "batch.json", {"state": "실행 중", "by": by, "pid": os.getpid(), "at": store.now(),
"log": ["일괄 실행 대기"]})
t = threading.Thread(target=_batch, args=(p, by, todo), daemon=True)
BATCH[p.name] = t
t.start()
flash(f"{todo[0]}~{todo[-1]} 연속 실행을 시작했습니다. PASS인 게이트만 '{by}' 이름으로 자동 승인합니다.", "ok")
return redirect(url_for("gates", name=name))
@app.post("/pier/<name>/gate/<gate>/run")
def gate_run(name, gate):
p = pier_or_404(name)
if gate not in gate_ids():
abort(404)
if (batch_state(p) or {}).get("state") == "실행 중":
flash("일괄 실행 중에는 개별 실행을 할 수 없습니다.", "fail")
return redirect(url_for("gates", name=name))"""),
(""" for r in rows:
r["state"] = job_state(r["job"])
running = any(r["state"] in ("대기", "실행 중") for r in rows)""",
""" for r in rows:
r["state"] = job_state(r["job"])
batch = batch_state(p)
running = any(r["state"] in ("대기", "실행 중") for r in rows) or (batch or {}).get("state") == "실행 중\""""),
])
patch("web/app.py", [
(""" return render_template("gates.html", p=p, rows=rows, running=running)""",
""" return render_template("gates.html", p=p, rows=rows, running=running, batch=batch,
todo=[g for g in gate_ids() if not p.gate_status(g)["approved"]])"""),
(""" return render_template("pier.html", p=p, sections=secs, gates=gates,""",
""" return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p),"""),
])
patch("web/templates/pier.html", [
("""<h2>3. 게이트</h2>""",
"""<form method="post" action="{{ url_for('confirm_all', name=p.name) }}"
onsubmit="return confirm('게이트에 필요한 미확정 묶음 {{ need|length }}개를 모두 확정합니다. 값을 도면·판독서와 대조하셨습니까?');">
{% if need %}확정자 <input type="text" name="by" required>
<button>필요한 묶음 일괄 확정 ({{ need|length }}개)</button>
<span class="muted">게이트 G1~G5에 필요한 묶음 중 미확정인 것만 확정합니다.</span>
{% else %}<span class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</span>{% endif %}
</form>
<h2>3. 게이트</h2>"""),
])
patch("web/templates/gates.html", [
("""<h2>검토보고서 (초안)</h2>""",
"""<h2>연속 실행·자동 승인</h2>
{% if batch %}<p>최근 일괄 실행: <b class="{{ 'ok' if batch.state == '완료' else ('warn' if batch.state in ('실행 중', '중단됨') else 'fail') }}">{{ batch.state }}</b>
· 판정자 {{ batch.by }} · {{ batch.at }}</p>
<ul>{% for m in batch.log %}<li>{{ m }}</li>{% endfor %}</ul>{% endif %}
{% if todo %}
<form method="post" action="{{ url_for('gates_run_all', name=p.name) }}"
onsubmit="return confirm('{{ todo[0] }}~{{ todo[-1] }}를 차례로 실행하고 PASS인 게이트만 입력한 이름으로 자동 승인합니다. 진행할까요?');">
판정자 <input type="text" name="by" required>
<button {% if running %}disabled{% endif %}>{{ todo[0] }}~{{ todo[-1] }} 연속 실행·자동 승인</button>
</form>
<p class="muted">PASS인 게이트만 승인합니다. FAIL·오류·실행 조건 미충족이면 그 게이트에서 멈추고 승인하지 않습니다.
승인 기록의 의견란에 '일괄 실행 자동 승인'이 남습니다. 결과는 아래 게이트별 표에서 확인하세요.</p>
{% else %}<p class="ok">모든 게이트가 승인되어 있습니다.</p>{% endif %}
<h2>검토보고서 (초안)</h2>"""),
])
print("patched")
@@ -0,0 +1,145 @@
"""Patch (2026-10-12, user request): batch run G1..G5 in ONE Rhino session with gate checks only — no per-gate
save, no reload; negative tests kept (moved and restored inside the session); the final G5 model is saved once.
Reload / unchanged are reported as skipped for batch-run gates. Individual gate runs keep save + reload; when the
previous gate has no fingerprint (it was batch-run), the unchanged check is skipped instead of raising."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("engine/gates.py", [
# 1. shared judgement of checks + negative tests
("""def run_gate(gate, params, expected, run_dir, log=print):""",
"""def _negative_steps(gate, pid, expected, run_dir):
\"\"\"Rhino steps: checker, then horizontal / vertical move -> checker -> undo (same document).\"\"\"
neg_h, neg_v = NEGATIVE[gate]
pert = RHINO_DIR / "perturb.py"
steps = check_steps(expected, run_dir, [gate])
for tag, mv in (("_negH", neg_h), ("_negV", neg_v)):
glb = {"PIER_ID": pid, "MOVES": [mv]}
steps.append({"name": f"{gate}:perturb{tag}", "script": pert, "globals": glb})
steps += check_steps(expected, run_dir, [gate], tag)
steps.append({"name": f"{gate}:undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)})
return steps
def _judge(gate, X, run_dir):
\"\"\"Read checker + negative-test results: (base, unplanned, planned_seen, neg).\"\"\"
base = _load(run_dir / f"{gate}.json")
fails = _fails(base)
unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])]
planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]}
for f in fails if planned(X, gate, f["item"])]
neg = {}
for tag, mv in zip(("negH", "negV"), NEGATIVE[gate]):
r = _load(run_dir / f"{gate}_{tag}.json")
new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}]
neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)}
return base, unplanned, planned_seen, neg
def run_batch_session(gates, params, expected, run_dir, log=print):
\"\"\"Batch mode: build G1..G5 in ONE Rhino session; after each gate in `gates` is built, run its checker and
negative tests in the same document. No per-gate save and no reload; the final model is saved once as G5.3dm.
Writes <gate>_gate.json for each gate in `gates` (reload / unchanged = skipped) and returns {gate: report}.\"\"\"
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
X = _load(expected)
pid = X["pier_id"]
ids = gate_ids()
for g in gates: # results / models of these gates are replaced: drop the old ones
for suffix in ("_fp.json",) + ((".3dm",) if g != "G5" else ()):
(run_dir / f"{g}{suffix}").unlink(missing_ok=True)
glb = {"PARAMS_PATH": str(Path(params).resolve())}
steps = []
for g, _, script in GATES:
steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})
if g in REBAR_GATES:
steps.append({"name": f"{g}:rebar_colours.py", "script": RHINO_DIR / "rebar_colours.py", "globals": glb})
if g in gates:
steps += _negative_steps(g, pid, expected, run_dir)
steps.append({"name": "display.py", "script": RHINO_DIR / "display.py", "globals": glb})
t0 = time.time()
log(f"[batch] one Rhino session: build G1..G5, check {', '.join(gates)} (no per-gate save / reload)")
res = run_job(steps, save=run_dir / "G5.3dm", units_mm_tolerance=TOLERANCE_MM)
secs = {}
for st in res["steps"]:
g = st["name"].split(":")[0]
secs[g] = secs.get(g, 0) + st["secs"]
out = {}
for g in gates:
base, unplanned, planned_seen, neg = _judge(g, X, run_dir)
verdict = "PASS" if not unplanned and all(v["detected"] for v in neg.values()) else "FAIL"
rep = {"gate": g, "checker": CHECKERS[g], "verdict": verdict, "summary": base["summary"],
"unplanned_fails": unplanned, "planned_fails": planned_seen, "negative": neg,
"reload": {"ok": None, "skipped": "일괄 실행(단일 세션) — 저장·재로드 안 함"},
"unchanged": {"ok": None, "compared": 0, "diffs": [], "missing": [], "against": None,
"skipped": "일괄 실행(단일 세션) — 앞 게이트 객체를 다시 만들지 않음"},
"model": str(run_dir / "G5.3dm") if g == "G5" else None, "mode": "batch",
"secs": round(secs.get(g, 0), 1), "session_secs": res["wall_secs"]}
(run_dir / f"{g}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
log(f"[{g}] {verdict} checks {base['summary']['passed']}/{base['summary']['total']} "
f"(planned FAIL {len(planned_seen)}, unplanned {len(unplanned)}) negative H/V "
f"{neg['negH']['detected']}/{neg['negV']['detected']} (batch)")
out[g] = rep
log(f"[batch] done in {round(time.time() - t0, 1)}s, objects {res['objects']}, model {run_dir / 'G5.3dm'}")
return out
def run_gate(gate, params, expected, run_dir, log=print):"""),
# 2. run_gate uses the shared helpers
(""" neg_h, neg_v = NEGATIVE[gate]
pert = RHINO_DIR / "perturb.py"
steps = check_steps(expected, run_dir, [gate])
for tag, mv in (("_negH", neg_h), ("_negV", neg_v)):
glb = {"PIER_ID": pid, "MOVES": [mv]}
steps.append({"name": f"perturb{tag}", "script": pert, "globals": glb})
steps += check_steps(expected, run_dir, [gate], tag)
steps.append({"name": f"undo{tag}", "script": pert, "globals": dict(glb, UNDO=True)})
steps.append""",
""" steps = _negative_steps(gate, pid, expected, run_dir)
steps.append"""),
(""" base = _load(run_dir / f"{gate}.json")
fails = _fails(base)
unplanned = [f["item"] for f in fails if not planned(X, gate, f["item"])]
planned_seen = [{"item": f["item"], "reason": planned(X, gate, f["item"])["reason"]}
for f in fails if planned(X, gate, f["item"])]
neg = {}
for tag, mv in (("negH", neg_h), ("negV", neg_v)):
r = _load(run_dir / f"{gate}_{tag}.json")
new = [f["item"] for f in _fails(r) if f["item"] not in {x["item"] for x in fails}]
neg[tag] = {"move": mv, "new_fails": new[:10], "detected": len(_fails(r)) > len(fails)}
""",
""" base, unplanned, planned_seen, neg = _judge(gate, X, run_dir)
"""),
# 3. previous gate without fingerprint (batch-run) -> unchanged skipped
(""" prev_fp = run_dir / f"{ids[k - 1]}_fp.json"
if not prev_fp.exists():
raise FileNotFoundError(f"run {ids[k - 1]} first (needs {prev_fp.name})")
prev = _load(prev_fp)""",
""" prev_fp = run_dir / f"{ids[k - 1]}_fp.json"
if k > 0 and not prev_fp.exists():
unchanged.update(ok=None, skipped=f"{ids[k - 1]} 지문 없음(일괄 실행 결과) — 비교 생략")
elif k > 0:
prev = _load(prev_fp)"""),
(""" verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok
and unchanged["ok"] else "FAIL")""",
""" verdict = ("PASS" if not unplanned and all(v["detected"] for v in neg.values()) and reload_ok
and unchanged["ok"] is not False else "FAIL")"""),
(""" "unchanged": unchanged, "model": str(model), "secs": round(time.time() - t0, 1),""",
""" "unchanged": unchanged, "model": str(model), "mode": "single", "secs": round(time.time() - t0, 1),"""),
("""from engine.pipeline import (CHECKERS, GATE_PREFIXES, RHINO_DIR, TOLERANCE_MM, build_steps, check_steps,
gate_ids)""",
"""from engine.pipeline import (CHECKERS, GATE_PREFIXES, GATES, REBAR_GATES, RHINO_DIR, TOLERANCE_MM, build_steps,
check_steps, gate_ids)"""),
])
print("engine patched")
@@ -0,0 +1,172 @@
from pathlib import Path
APP = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱")
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:60])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/app.py", [
("""import json
import sys
import threading
import traceback""", """import json
import os
import sys
import threading
import traceback
import uuid"""),
("""RHINO_LOCK = threading.Lock() # one Rhino job at a time""",
"""RHINO_LOCK = threading.Lock() # one Rhino job at a time
ACTIVE = {} # job id -> thread of this server process"""),
("""def _gate_job(p, gate):
jp = p.run_dir / f"{gate}_job.json"
inputs = p.inputs_sha()
log = []
def note(m):
log.append(m)
store.wjson(jp, {"state": "실행 중", "at": store.now(), "log": log})
with RHINO_LOCK:
try:
note("Rhino 작업 시작")
rep = run_gate(gate, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
rep["inputs_sha"] = inputs
store.wjson(p.run_dir / f"{gate}_gate.json", rep)
store.wjson(jp, {"state": "완료", "at": store.now(), "log": log})
except Exception:
store.wjson(jp, {"state": "오류", "at": store.now(), "log": log, "error": traceback.format_exc()})""",
"""def job_state(job):
\"\"\"A queued/running job counts only while its thread lives in this server process; a job left behind by a
stopped server (or test process) is shown as 중단됨 and may be run again.\"\"\"
if job and job["state"] in ("대기", "실행 중"):
t = ACTIVE.get(job.get("id"))
if job.get("pid") != os.getpid() or not (t and t.is_alive()):
return "중단됨"
return job and job["state"]
def _gate_job(p, gate, jid):
jp = p.run_dir / f"{gate}_job.json"
inputs = p.inputs_sha()
log = []
def put(state, **kw):
store.wjson(jp, dict({"id": jid, "pid": os.getpid(), "state": state, "at": store.now(), "log": log}, **kw))
def note(m):
log.append(m)
put("실행 중")
with RHINO_LOCK:
try:
note("Rhino 작업 시작")
rep = run_gate(gate, p.dir / "params.json", p.dir / "expected.json", p.run_dir, log=note)
rep["inputs_sha"], rep["job_id"] = inputs, jid
store.wjson(p.run_dir / f"{gate}_gate.json", rep)
put("완료")
except Exception:
put("오류", error=traceback.format_exc())"""),
(""" job = p.gate_status(gate)["job"]
if job and job["state"] in ("대기", "실행 중"):
flash(f"{gate}는 이미 실행 중입니다.", "fail")
return redirect(url_for("gates", name=name))
# Mark the job as queued before the thread starts, so no request sees the previous run's "완료" state.
store.wjson(p.run_dir / f"{gate}_job.json", {"state": "대기", "at": store.now(), "log": []})
threading.Thread(target=_gate_job, args=(p, gate), daemon=True).start()""",
""" if job_state(p.gate_status(gate)["job"]) in ("대기", "실행 중"):
flash(f"{gate}는 이미 실행 중입니다.", "fail")
return redirect(url_for("gates", name=name))
# Mark the job as queued (with its id) before the thread starts, so no request sees the previous run's state.
jid = uuid.uuid4().hex[:12]
store.wjson(p.run_dir / f"{gate}_job.json",
{"id": jid, "pid": os.getpid(), "state": "대기", "at": store.now(), "log": []})
t = threading.Thread(target=_gate_job, args=(p, gate, jid), daemon=True)
ACTIVE[jid] = t
t.start()"""),
(""" rows = [dict(id=g, name=GATE_NAMES[g], blockers=gate_blockers(p, g), req=p.gate_requirements(g), **p.gate_status(g))
for g in gate_ids()]
running = any(r["job"] and r["job"]["state"] in ("대기", "실행 중") for r in rows)""",
""" rows = [dict(id=g, name=GATE_NAMES[g], blockers=gate_blockers(p, g), req=p.gate_requirements(g), **p.gate_status(g))
for g in gate_ids()]
for r in rows:
r["state"] = job_state(r["job"])
running = any(r["state"] in ("대기", "실행 중") for r in rows)"""),
])
patch("web/templates/gates.html", [
("""{% if g.job and g.job.state in ('대기', '실행 중') %}<p class="warn">{{ g.job.state }}: {{ g.job.log[-1] if g.job.log else '' }}</p>
{% elif g.job and g.job.state == '오류' %}<p class="fail">오류</p><pre>{{ g.job.error }}</pre>{% endif %}""",
"""{% if g.state in ('대기', '실행 중') %}<p class="warn">{{ g.state }}: {{ g.job.log[-1] if g.job.log else '' }}</p>
{% elif g.state == '중단됨' %}<p class="warn">중단됨: 이전 실행이 끝나지 않은 채 서버가 멈췄습니다. 다시 실행하세요.</p>
{% elif g.state == '오류' %}<p class="fail">오류</p><pre>{{ g.job.error }}</pre>{% endif %}"""),
])
patch("tools/test_web.py", [
("""def html(r):
return r.get_data(as_text=True)""",
"""def html(r):
return r.get_data(as_text=True)
def run_and_wait(c, P, g, limit=900):
\"\"\"POST run, then wait until the job with the id written at start-up is finished; returns (job, trace).\"\"\"
c.post(f"/pier/{P}/gate/{g}/run")
jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id")
t0, trace = time.time(), []
while time.time() - t0 < limit:
job = store.Pier(P).gate_status(g)["job"] or {}
trace.append((round(time.time() - t0), job.get("id") == jid, job.get("state")))
if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"):
return job, trace
time.sleep(3)
return None, trace"""),
(""" r = c.post(f"/pier/{P}/gate/{g}/run", follow_redirects=True)
t0 = time.time()
while True:
job = store.Pier(P).gate_status(g)["job"]
if job and job["state"] in ("완료", "오류"):
break
if time.time() - t0 > 900:
break
time.sleep(3)
st = store.Pier(P).gate_status(g)
rep = st["report"] or {}
check(f"{g} run via app -> PASS", job and job["state"] == "완료" and rep.get("verdict") == "PASS",
f"{rep.get('summary')} {time.time() - t0:.0f}s {job.get('error', '')[-200:] if job else ''}")""",
""" job, trace = run_and_wait(c, P, g)
rep = store.Pier(P).gate_status(g)["report"] or {}
check(f"{g} run via app -> PASS", bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS"
and rep.get("job_id") == job["id"], f"{rep.get('summary')} {trace[-1][0]}s {(job or {}).get('error', '')[-200:]}")"""),
(""" c.post(f"/pier/{P}/gate/G1/run")
t0 = time.time()
while time.time() - t0 < 600:
job = store.Pier(P).gate_status("G1")["job"]
if job and job["state"] in ("완료", "오류"):
break
time.sleep(3)
rep = store.Pier(P).gate_status("G1")["report"]
check("pile length +5 mm -> G1 FAIL (pile length)", rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"],
rep["unplanned_fails"])""",
""" job, trace = run_and_wait(c, P, "G1")
rep = store.Pier(P).gate_status("G1")["report"]
check("pile length +5 mm -> G1 FAIL (pile length)", bool(job) and rep.get("job_id") == job["id"]
and rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"],
f"{rep['unplanned_fails']} trace {trace[:2]}..{trace[-2:]}")"""),
(""" check("report: other modelled marks match", md.count("| 일치 |") == 35, md.count("| 일치 |"))""",
""" sec6 = md.split("## 6.")[1].split("## 7.")[0]
check("report: other modelled marks match (35)", sec6.count("| 일치 |") == 35, sec6.count("| 일치 |"))"""),
(""" # 8 a wrong input gives a FAIL gate""",
""" # 7b a job left "실행 중" by a stopped process must not block the gate forever
jp = TD / P / "runs" / "current" / "G1_job.json"
store.wjson(jp, {"id": "orphan", "pid": -1, "state": "실행 중", "at": store.now(), "log": ["x"]})
r = c.get(f"/pier/{P}/gates")
check("orphaned running job shown as 중단됨", "중단됨: 이전 실행" in html(r))
# 8 a wrong input gives a FAIL gate"""),
])
print("patched")
@@ -0,0 +1,94 @@
from pathlib import Path
p = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱\runner.py")
s = p.read_text(encoding="utf-8")
reps = [
# result written atomically, so the runner can act as soon as it appears
(""" json.dump(res, open(SPEC["result"], "w", encoding="utf-8"), ensure_ascii=False, indent=1)
if opened is not None:""",
""" tmp = SPEC["result"] + ".tmp"
json.dump(res, open(tmp, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
os.replace(tmp, SPEC["result"])
if opened is not None:"""),
("""import io, json, sys, time, traceback""", """import io, json, os, sys, time, traceback"""),
("""def run_job(steps, save=None, units_mm_tolerance=None, timeout=1800, open_path=None):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
\"\"\"""",
"""def _window_title(pid):
\"\"\"Main window title of a process (diagnosis of a stalled Rhino, e.g. a modal dialog).\"\"\"
try:
out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, text=True, timeout=20)
return out.stdout.strip()
except Exception as e:
return f"? ({e})"
def _launch_and_wait(job, timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists. A Rhino that does not exit within `exit_grace`
seconds after writing the result is killed (2026-10-12: Rhino sometimes stalled on exit). Raises
RhinoJobError if no result appears within `timeout` (Rhino killed; its window title is reported).\"\"\"
rp = job / "result.json"
# Pass a raw command line: Rhino only honours /runscript="..." (quote after '='), while a list argument
# would be quoted as a whole ("/runscript=...") and silently ignored.
proc = subprocess.Popen(f'"{RHINO_EXE}" /nosplash /notemplate '
f'/runscript="_-RunPythonScript {job / "job.py"} _-Exit"')
t0 = time.time()
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if time.time() - t0 > timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino gave no result within {timeout}s (window: '{title}'): {job}")
time.sleep(0.5)
try:
proc.wait(timeout=exit_grace)
return "exited"
except subprocess.TimeoutExpired:
title = _window_title(proc.pid)
proc.kill()
return f"killed after result (did not exit in {exit_grace}s, window: '{title}')"
def run_job(steps, save=None, units_mm_tolerance=None, timeout=600, open_path=None, retries=1, exit_grace=20):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
timeout: seconds to wait for the result; a stalled launch is killed and retried `retries` times.
\"\"\""""),
(""" t0 = time.time()
# Pass a raw command line: Rhino only honours /runscript="..." (quote after '='), while a list argument
# would be quoted as a whole ("/runscript=...") and silently ignored.
proc = subprocess.Popen(f'"{RHINO_EXE}" /nosplash /notemplate '
f'/runscript="_-RunPythonScript {job / "job.py"} _-Exit"')
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
proc.kill()
raise RhinoJobError(f"Rhino job timed out after {timeout}s: {job}")
rp = job / "result.json"
if not rp.exists():
raise RhinoJobError(f"Rhino exited without a result: {job}")
res = json.loads(rp.read_text(encoding="utf-8"))
res["job_dir"], res["wall_secs"] = str(job), round(time.time() - t0, 1)""",
""" t0, attempts = time.time(), []
for attempt in range(retries + 1):
try:
exit_note = _launch_and_wait(job, timeout, exit_grace)
break
except RhinoJobError as e:
attempts.append(str(e))
if attempt == retries:
raise RhinoJobError(" | ".join(attempts))
res = json.loads((job / "result.json").read_text(encoding="utf-8"))
res["job_dir"], res["wall_secs"] = str(job), round(time.time() - t0, 1)
res["rhino_exit"], res["failed_attempts"] = exit_note, attempts"""),
]
for a, b in reps:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,107 @@
"""Runner patch 2 (2026-10-12): separate start stall from long jobs; fix window-title decoding."""
from pathlib import Path
APP = Path(r"C:\Users\User\Desktop\바이브코딩\pier_T\Code\교각모델링앱")
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:60])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("runner.py", [
("""SPEC = json.load(open(r"%(spec)s", encoding="utf-8"))
doc = Rhino.RhinoDoc.ActiveDoc""",
"""SPEC = json.load(open(r"%(spec)s", encoding="utf-8"))
open(SPEC["started"], "w").write(time.strftime("%%H:%%M:%%S"))
doc = Rhino.RhinoDoc.ActiveDoc"""),
("""class RhinoJobError(RuntimeError):
pass
""",
"""class RhinoJobError(RuntimeError):
pass
class RhinoStartError(RhinoJobError):
\"\"\"Rhino did not start the job script in time (retryable).\"\"\"
"""),
(""" out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, text=True, timeout=20)
return out.stdout.strip()""",
""" out = subprocess.run(["powershell", "-NoProfile", "-Command", f"(Get-Process -Id {pid}).MainWindowTitle"],
capture_output=True, timeout=20)
return out.stdout.decode("mbcs", errors="replace").strip() # console code page (cp949 here)"""),
("""def _launch_and_wait(job, timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists. A Rhino that does not exit within `exit_grace`
seconds after writing the result is killed (2026-10-12: Rhino sometimes stalled on exit). Raises
RhinoJobError if no result appears within `timeout` (Rhino killed; its window title is reported).\"\"\"
rp = job / "result.json\"""",
"""def _launch_and_wait(job, start_timeout, run_timeout, exit_grace):
\"\"\"Start Rhino on job.py; return when result.json exists.
start_timeout: seconds until job.py writes its `started` marker (2026-10-12: a launch once stalled 23 min);
exceeded -> Rhino killed, RhinoStartError (retryable). run_timeout: seconds from start to result (long jobs
such as a full fingerprint take ~12 min); exceeded -> Rhino killed, RhinoJobError. A Rhino that does not
exit within `exit_grace` seconds after writing the result is killed (the result is already on disk).
The main window title is reported on every kill to help find the cause (e.g. a modal dialog).\"\"\"
rp, sp = job / "result.json", job / "started"
sp.unlink(missing_ok=True)"""),
(""" t0 = time.time()
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if time.time() - t0 > timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino gave no result within {timeout}s (window: '{title}'): {job}")
time.sleep(0.5)""",
""" t0, t_start = time.time(), None
while not rp.exists():
if proc.poll() is not None:
raise RhinoJobError(f"Rhino exited (code {proc.returncode}) without a result: {job}")
if t_start is None and sp.exists():
t_start = time.time()
if t_start is None and time.time() - t0 > start_timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoStartError(f"Rhino did not start the job within {start_timeout}s (window: '{title}'): {job}")
if t_start is not None and time.time() - t_start > run_timeout:
title = _window_title(proc.pid)
proc.kill()
raise RhinoJobError(f"Rhino job ran over {run_timeout}s (window: '{title}'): {job}")
time.sleep(0.5)"""),
("""def run_job(steps, save=None, units_mm_tolerance=None, timeout=600, open_path=None, retries=1, exit_grace=30):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
timeout: seconds to wait for the result; a stalled launch is killed and retried `retries` times.
\"\"\"""",
"""def run_job(steps, save=None, units_mm_tolerance=None, open_path=None, start_timeout=600, run_timeout=3600,
retries=1, exit_grace=30):
\"\"\"Run `steps` in a fresh Rhino; return the result dict (raises RhinoJobError on failure).
open_path: run the steps on that .3dm opened headless (reload test) instead of the empty active doc.
A launch that does not start the script within start_timeout is killed and retried `retries` times.
\"\"\""""),
(""" "result": str(job / "result.json")}""",
""" "result": str(job / "result.json"), "started": str(job / "started")}"""),
(""" exit_note = _launch_and_wait(job, timeout, exit_grace)
break
except RhinoJobError as e:""",
""" exit_note = _launch_and_wait(job, start_timeout, run_timeout, exit_grace)
break
except RhinoStartError as e:"""),
])
patch("tools/test_runner_stall.py", [
(""" run_job([{"name": "noop", "script": step}], timeout=3, retries=1)""",
""" run_job([{"name": "noop", "script": step}], start_timeout=3, retries=1)"""),
(""" n = str(e).count("gave no result within 3s")""",
""" n = str(e).count("did not start the job within 3s")"""),
("""1. timeout=3 s with 1 retry:""", """1. start_timeout=3 s with 1 retry:"""),
])
print("patched")
@@ -0,0 +1,105 @@
"""Patch (2026-10-13, Ulsan P1): per-pier gate scope in the web app and the report.
Gates outside params["scope"] are not listed / run / required; the previous gate of a gate is the previous gate
in scope; optional sections (params.piles for a pier without piles) are not required for confirmation."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(rel, reps):
p = APP / rel
s = p.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (rel, a[:70])
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
patch("web/store.py", [
(""" def gate_requirements(self, gate):
need = COMMON + GATE_SECTIONS[gate]
return [dict(file=f, key=k, **self.section_state(f, k)) for f, k in need]""",
""" def gate_requirements(self, gate):
need = COMMON + GATE_SECTIONS[gate]
P = self.load("params")
need = [(f, k) for f, k in need if not ((f, k) in OPTIONAL and k not in P)]
return [dict(file=f, key=k, **self.section_state(f, k)) for f, k in need]
def scope(self):
\"\"\"Gates modelled for this pier (params["scope"], default all five).\"\"\"
sc = self.load("params").get("scope")
return [g for g in ("G1", "G2", "G3", "G4", "G5") if not sc or g in sc]"""),
("""GATE_SECTIONS = {""", """OPTIONAL = {("params", "piles")} # a pier on a spread footing has no piles (Ulsan P1)
GATE_SECTIONS = {"""),
])
patch("web/app.py", [
(""" gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in gate_ids()]""",
""" gates = [dict(id=g, name=GATE_NAMES[g], **p.gate_status(g)) for g in p.scope()]"""),
(""" out = []
for g in gate_ids():
for r in p.gate_requirements(g):""",
""" out = []
for g in p.scope():
for r in p.gate_requirements(g):"""),
(""" k = gate_ids().index(gate)
if k > 0 and not p.gate_status(gate_ids()[k - 1])["approved"]:
out.append(f"{gate_ids()[k - 1]} 미승인")""",
""" ids = p.scope()
if gate not in ids:
return [f"{gate}는 이 교각의 범위 밖"]
k = ids.index(gate)
if k > 0 and not p.gate_status(ids[k - 1])["approved"]:
out.append(f"{ids[k - 1]} 미승인")"""),
(""" todo = [g for g in gate_ids() if not p.gate_status(g)["approved"]]
if not todo:""",
""" todo = [g for g in p.scope() if not p.gate_status(g)["approved"]]
if not todo:"""),
(""" for g in gate_ids()]
for r in rows:""",
""" for g in p.scope()]
for r in rows:"""),
(""" todo=[g for g in gate_ids() if not p.gate_status(g)["approved"]])""",
""" todo=[g for g in p.scope() if not p.gate_status(g)["approved"]],
out_of_scope=[g for g in gate_ids() if g not in p.scope()])"""),
(""" if not any(p.gate_status(g)["report"] for g in gate_ids()):""",
""" if not any(p.gate_status(g)["report"] for g in p.scope()):"""),
(""" stale = {g for g in gate_ids() if p.gate_status(g)["stale"]}
out = report_files.write(p.dir, p.run_dir, request.form.get("by", "").strip(), stale)""",
""" stale = {g for g in p.scope() if p.gate_status(g)["stale"]}
out = report_files.write(p.dir, p.run_dir, request.form.get("by", "").strip(), stale,
[g for g in gate_ids() if g not in p.scope()])"""),
(""" todo=[g["id"] for g in gates if not g["approved"]], running=anything_running(p),""",
""" todo=[g["id"] for g in gates if not g["approved"]], running=anything_running(p),
out_of_scope=[g for g in gate_ids() if g not in p.scope()],"""),
])
patch("web/templates/gates.html", [
("""<h2>일괄 승인 (필요하면 실행)</h2>""",
"""{% if out_of_scope %}<p class="muted">이 교각의 범위 밖 게이트: {{ out_of_scope|join(', ') }} (실행·승인 대상 아님)</p>{% endif %}
<h2>일괄 승인 (필요하면 실행)</h2>"""),
])
patch("web/templates/pier.html", [
("""<h2>3. 게이트</h2>""",
"""<h2>3. 게이트</h2>
{% if out_of_scope %}<p class="muted">범위 밖: {{ out_of_scope|join(', ') }} — 이 교각에서는 모델링하지 않습니다.</p>{% endif %}"""),
])
patch("engine/report_files.py", [
("""def write(pier_dir, run_dir, author="", stale=()):""", """def write(pier_dir, run_dir, author="", stale=(), out_of_scope=()):"""),
(""" md, d = report.build(pier_dir, run_dir, author, today, stale)""",
""" md, d = report.build(pier_dir, run_dir, author, today, stale, out_of_scope)"""),
])
patch("engine/report.py", [
("""def build(pier_dir, run_dir, author="", today=None, stale=()):""",
"""def build(pier_dir, run_dir, author="", today=None, stale=(), out_of_scope=()):"""),
(""" all_ok = all(gates[g] and gates[g]["verdict"] == "PASS" and appr.get(g, {}).get("decision") == "승인"
for g in gate_ids())""",
""" all_ok = all(gates[g] and gates[g]["verdict"] == "PASS" and appr.get(g, {}).get("decision") == "승인"
for g in gate_ids() if g not in out_of_scope)"""),
(""" rows.append([g, gname, "입력 변경 — 무효(재실행 필요)" if g in stale else "미실행", "-", "-", "-"])""",
""" rows.append([g, gname, "범위 밖" if g in out_of_scope else
("입력 변경 — 무효(재실행 필요)" if g in stale else "미실행"), "-", "-", "-"])"""),
])
print("patched")
@@ -0,0 +1,32 @@
"""Patch (2026-10-14, Okseong P1 G3): builders receive the global GATE (gate id), so the generic centreline builder
rebar_polylines.py can serve G3 (params footing_rebar) as well as G4 (params coping_rebar).
Backups: backup/2026-10-14_okseong_g3/. Other builders ignore the extra global."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, reps):
s = path.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "pipeline.py", [(
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})''',
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})''')])
patch(APP / "engine" / "gates.py", [(
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": glb})''',
''' steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})''')])
patch(APP / "engine" / "rhino" / "rebar_polylines.py", [
('''Ids: <pier>-R-<mark>-<occ> (pipe) and <pier>-RC-<mark>-<occ> (centreline).
"""''',
'''Ids: <pier>-R-<mark>-<occ> (pipe) and <pier>-RC-<mark>-<occ> (centreline).
Section by gate (2026-10-14, Okseong P1): GATE "G3" -> params footing_rebar, otherwise coping_rebar.
"""'''),
('''SEC = P["coping_rebar"]''',
'''SEC = P["footing_rebar" if globals().get("GATE") == "G3" else "coping_rebar"]'''),
])
print("patched")
@@ -0,0 +1,113 @@
"""Patch (2026-10-14, Okseong P1 G2): optional inner ring / per-zone hoop radius / 90-135 ties in
engine/rhino/column_rebar_ring.py and the matching optional checks in engine/rhino/checks/g2_ring.py.
Absent options -> the Ulsan code paths are unchanged. Backups: backup/2026-10-14_okseong_g2/."""
import shutil
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
BK = APP / "backup" / "2026-10-14_okseong_g2"
BK.mkdir(parents=True, exist_ok=True)
B = APP / "engine" / "rhino" / "column_rebar_ring.py"
C = APP / "engine" / "rhino" / "checks" / "g2_ring.py"
for f in (B, C):
if not (BK / f.name).exists():
shutil.copy2(f, BK / f.name)
def patch(path, reps):
s = path.read_text(encoding="utf-8")
for a, b in reps:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(B, [
('''includes the coupler).
"""''',
'''includes the coupler).
Optional (Okseong P1, 2026-10-14; absent -> unchanged): main.inner {mark, radius} second ring at the same angles
with the hook bent inwards; zone.radius per hoop zone; ties.shape "90-135" {leg, tail, y_lift, design_length}:
straight on the X / Y diameters, 90-deg leg down at one end, 135-deg hook at the other, ends swapped per level,
the Y tie y_lift above the X tie.
"""'''),
(''' f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; 배근도(1) 정면도 갈고리 R{R:.0f}·{m['hook_leg']:.0f}; 단면 DIVISION {m['count']}")
n += 1''',
''' f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; 배근도(1) 정면도 갈고리 R{R:.0f}·{m['hook_leg']:.0f}; 단면 DIVISION {m['count']}")
n += 1
if "inner" in m: # second ring, hook bent inwards (Okseong P1)
ri = m["inner"]["radius"]
z0 = m["hook_leg_z"]
leg = rg.LineCurve(pt(ri - R - m["hook_leg"], th, z0), pt(ri - R, th, z0))
bend = rg.ArcCurve(rg.Arc(pt(ri - R, th, z0),
pt(ri - R + R * math.sin(math.radians(45)), th, z0 + R - R * math.cos(math.radians(45))),
pt(ri, th, z0 + R)))
vert = rg.LineCurve(pt(ri, th, z0 + R), pt(ri, th, top))
c = rg.Curve.JoinCurves([leg, bend, vert], TOL)[0]
add_bar(c, m["inner"]["mark"], f"{i + 1:02d}", m["straight_len"] + math.pi / 2 * R + m["hook_leg"],
f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; inner ring r {ri:.0f}, hook inwards")
n += 1'''),
(''' mark, typ = zone["mark"], zone["type"]
for j, z in enumerate(zone["levels"]):''',
''' mark, typ = zone["mark"], zone["type"]
r = zone.get("radius", hp["radius"]) # per zone (Okseong P3 1,150 / P4 1,147)
for j, z in enumerate(zone["levels"]):'''),
(''' tie = cr["ties"]
half = tie["straight"] / 2''',
''' tie = cr["ties"]
if tie.get("shape") == "90-135": # Okseong P1 (2026-10-14), as column_rebar_straight.py
rb = min_bend_radius(MARK[tie["mark"]][0])
for q, z in enumerate(tie["levels"]):
for axis, name in ((0, "X"), (1, "Y")):
zt = z + (tie["y_lift"] if axis else 0)
u = rg.Vector3d(1, 0, 0) if axis == 0 else rg.Vector3d(0, 1, 0)
s = -1 if q % 2 else 1
a = rg.Point3d(0, 0, zt) - u * (s * tie["straight"] / 2)
b = rg.Point3d(0, 0, zt) + u * (s * tie["straight"] / 2)
d = -u * s * math.cos(math.radians(45)) + rg.Vector3d(0, 0, -1) * math.sin(math.radians(45))
pl = rg.PolylineCurve([a + rg.Vector3d(0, 0, -tie["leg"]), a, b, b + d * tie["tail"]])
c = rg.Curve.CreateFilletCornersCurve(pl, rb, TOL, math.radians(1)) or pl
add_bar(c, tie["mark"], f"L{q + 1:02d}{name}", tie["design_length"], hosts(zt),
f"EL {zt / 1000:.3f}; {name} (Y +{tie['y_lift']}, 90/135 교번 [추론])")
n += 1
doc.Views.Redraw()
print(json.dumps({"bars": n}))
return
half = tie["straight"] / 2'''),
])
patch(C, [
(''' check(f"{m} radius (mid point)", G["r_hoop"], math.hypot(p.X, p.Y), tol=0.5)''',
''' check(f"{m} radius (mid point)", G.get("r_hoop_by_mark", {}).get(m, G["r_hoop"]), math.hypot(p.X, p.Y), tol=0.5)
if "inner" in M: # Okseong P1: inner ring (2026-10-14)
im = M["inner"]
tops = [max((o.Geometry.PointAtStart, o.Geometry.PointAtEnd), key=lambda q: q.Z) for o in cl[im["mark"]]]
lows = [min((o.Geometry.PointAtStart, o.Geometry.PointAtEnd), key=lambda q: q.Z) for o in cl[im["mark"]]]
check(f"{im['mark']} inner ring radius (worst)", 0, max(abs(math.hypot(q.X, q.Y) - im["radius"]) for q in tops) if tops else 1e9, tol=0.5)
flag(f"{im['mark']} hooks bent inwards", bool(lows) and all(math.hypot(q.X, q.Y) < im["radius"] for q in lows))'''),
(''' tz = {}
for o in cl[tie_mark]:
tz.setdefault(round(o.Geometry.GetBoundingBox(True).Max.Z, 1), []).append(o)
lv = sorted(tz)
flag(f"tie levels {len(TIE_LEVELS)} x {TIE['per_level']}", len(lv) == len(TIE_LEVELS)
and all(len(v) == TIE["per_level"] for v in tz.values()), f"{len(lv)} levels")
check(f"tie levels vs chain + {TIE['offset']} (worst)", 0,
max(abs(a - (b + TIE["offset"])) for a, b in zip(lv, TIE_LEVELS)) if lv else 1e9)''',
''' if TIE.get("y_lift"): # Okseong P1: X tie at the level, Y tie lifted
occ_ = lambda o: o.Attributes.GetUserString("occurrence")
lx = sorted(round(o.Geometry.GetBoundingBox(True).Max.Z, 1) for o in cl[tie_mark] if occ_(o).endswith("X"))
ly = sorted(round(o.Geometry.GetBoundingBox(True).Max.Z, 1) for o in cl[tie_mark] if occ_(o).endswith("Y"))
flag(f"tie X / Y levels {len(TIE_LEVELS)} each", len(lx) == len(ly) == len(TIE_LEVELS), f"X {len(lx)} Y {len(ly)}")
check("tie X levels vs chain (worst)", 0, max(abs(a - b - TIE["offset"]) for a, b in zip(lx, TIE_LEVELS)) if lx else 1e9)
check(f"tie Y = X + {TIE['y_lift']} (worst)", 0, max(abs(b - a - TIE["y_lift"]) for a, b in zip(lx, ly)) if ly else 1e9)
else:
tz = {}
for o in cl[tie_mark]:
tz.setdefault(round(o.Geometry.GetBoundingBox(True).Max.Z, 1), []).append(o)
lv = sorted(tz)
flag(f"tie levels {len(TIE_LEVELS)} x {TIE['per_level']}", len(lv) == len(TIE_LEVELS)
and all(len(v) == TIE["per_level"] for v in tz.values()), f"{len(lv)} levels")
check(f"tie levels vs chain + {TIE['offset']} (worst)", 0,
max(abs(a - (b + TIE["offset"])) for a, b in zip(lv, TIE_LEVELS)) if lv else 1e9)'''),
])
print("patched")
@@ -0,0 +1,128 @@
"""Ulsan P2 (2026-10-15): footing rebar generator / checker extensions for a rotated foundation, rectangular hoops and explicit vertical
bar positions. Backups are made by the caller (backup/2026-10-15_울산P2전/). Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
GEN = APP / "engine" / "rhino" / "footing_rebar_mats.py"
CHK = APP / "engine" / "rhino" / "checks" / "g3_mats.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "Ulsan P2" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
GEN_PAIRS = [
(''' legs of length `leg` towards +`leg_axis` ("X"|"Y") at both ends (shape I with b, SD400 catalogue)
"""''', ''' legs of length `leg` towards +`leg_axis` ("X"|"Y") at both ends (shape I with b, SD400 catalogue)
Optional (Ulsan P2, 2026-10-15; absent -> unchanged):
footing.rot_deg all bars are built in the foundation's own axes and turned counter-clockwise about the pier centre
hoops.half_x / half_y rectangular hoop (default: square `half`)
verticals.points [[x, y], ...] explicit positions instead of the checkerboard; leg_bottom / leg_top leg lengths per end
(default `leg`); design length = a + leg_bottom + leg_top
"""'''),
('''def min_r(dia):''', '''ROT = math.radians(P["footing"].get("rot_deg", 0.0))
def turn(pts):
"""Foundation axes -> pier axes (counter-clockwise ROT about the origin)."""
if not ROT:
return pts
c, s = math.cos(ROT), math.sin(ROT)
return [(x * c - y * s, x * s + y * c, z) for x, y, z in pts]
def min_r(dia):'''),
(''' add_bar(poly(pts, MARK[g["mark"]][0]), g["mark"], f"{g['axis'].lower()}{v:+.0f}",''',
''' add_bar(poly(turn(pts), MARK[g["mark"]][0]), g["mark"], f"{g['axis'].lower()}{v:+.0f}",'''),
(''' s = hp["half"]
for zz in hp["levels"]:
z = FB + zz
pts = [(-s, 0, z), (-s, -s, z), (s, -s, z), (s, s, z), (-s, s, z), (-s, 0.0, z)]
add_bar(poly(pts, MARK[hp["mark"]][0]),''', ''' sx, sy = hp.get("half_x", hp.get("half")), hp.get("half_y", hp.get("half"))
for zz in hp["levels"]:
z = FB + zz
pts = [(-sx, 0, z), (-sx, -sy, z), (sx, -sy, z), (sx, sy, z), (-sx, sy, z), (-sx, 0.0, z)]
add_bar(poly(turn(pts), MARK[hp["mark"]][0]),'''),
(''' for i, x in enumerate(vt["grid_x"]):
for j, y in enumerate(vt["grid_y"]):
if (i + j) % 2 != vt["parity"]:
continue
z0, z1 = FB + vt["z0"], FB + vt["z0"] + vt["a"]
lx, ly = (vt["leg"], 0) if vt.get("leg_axis", "X") == "X" else (0, vt["leg"])
pts = [(x + lx, y + ly, z0), (x, y, z0), (x, y, z1), (x + lx, y + ly, z1)]
add_bar(poly(pts, MARK[vt["mark"]][0]), vt["mark"], f"x{x:+.0f}y{y:+.0f}", vt["a"] + 2 * vt["leg"], vt.get("src", ""))
n += 1''', ''' if "points" in vt:
where = [tuple(q) for q in vt["points"]]
else:
where = [(x, y) for i, x in enumerate(vt["grid_x"]) for j, y in enumerate(vt["grid_y"]) if (i + j) % 2 == vt["parity"]]
lb, lt = vt.get("leg_bottom", vt.get("leg", 0)), vt.get("leg_top", vt.get("leg", 0))
ux, uy = (1, 0) if vt.get("leg_axis", "X") == "X" else (0, 1)
for x, y in where:
z0, z1 = FB + vt["z0"], FB + vt["z0"] + vt["a"]
pts = [(x + ux * lb, y + uy * lb, z0), (x, y, z0), (x, y, z1), (x + ux * lt, y + uy * lt, z1)]
add_bar(poly(turn(pts), MARK[vt["mark"]][0]), vt["mark"], f"x{x:+.0f}y{y:+.0f}", vt["a"] + lb + lt, vt.get("src", ""))
n += 1'''),
]
CHK_PAIRS = [
('''Checks (C9):''', '''Optional (Ulsan P2, 2026-10-15; absent -> unchanged): G1.footing.rot_deg (bars are checked after turning their centrelines back by the
foundation rotation), hoops.half_x / half_y, verticals {exclude {x, y} (grid points with |x| <= x and |y| <= y are absent), legs {bottom, top}}.
Checks (C9):'''),
('''results = []
def check(item''', '''ROT = X["G1"]["footing"].get("rot_deg", 0.0)
results = []
def bbx(o):
"""Bounding box of the bar centreline in the foundation's own axes."""
g = o.Geometry
if ROT:
g = g.DuplicateCurve()
g.Transform(rg.Transform.Rotation(-math.radians(ROT), rg.Vector3d.ZAxis, rg.Point3d.Origin))
return g.GetBoundingBox(True)
def check(item'''),
(''' bb = o.Geometry.GetBoundingBox(True)
leg_down''', ''' bb = bbx(o)
leg_down'''),
(''' hz = sorted(round(o.Geometry.GetBoundingBox(True).Min.Z - FB, 1) for o in cl[hp["mark"]])''',
''' hz = sorted(round(bbx(o).Min.Z - FB, 1) for o in cl[hp["mark"]])'''),
(''' bb = cl[hp["mark"]][0].Geometry.GetBoundingBox(True)
check(f"{hp['mark']} centreline size X [derived]", 2 * hp["half"], bb.Max.X - bb.Min.X)
check(f"{hp['mark']} centreline size Y [derived]", 2 * hp["half"], bb.Max.Y - bb.Min.Y)''',
''' bb = bbx(cl[hp["mark"]][0])
check(f"{hp['mark']} centreline size X [derived]", 2 * hp.get("half_x", hp.get("half")), bb.Max.X - bb.Min.X)
check(f"{hp['mark']} centreline size Y [derived]", 2 * hp.get("half_y", hp.get("half")), bb.Max.Y - bb.Min.Y)'''),
(''' pts = {(round(o.Geometry.GetBoundingBox(True).Min.X), round(o.Geometry.GetBoundingBox(True).Min.Y)) for o in cl[vt["mark"]]}
exp = {(round(x), round(y)) for i, x in enumerate(gx) for j, y in enumerate(gy) if (i + j) % 2 == vt["parity"]}
flag(f"{vt['mark']} positions = checkerboard of {len(gx)}x{len(gy)} grid [추론]", pts == exp, f"{len(pts)} / {len(exp)}")''',
''' pts = {(round(bbx(o).Min.X), round(bbx(o).Min.Y)) for o in cl[vt["mark"]]}
if "exclude" in vt: # Ulsan P2: grid minus the central rectangle
ex = vt["exclude"]
exp = {(round(x), round(y)) for x in gx for y in gy if not (abs(x) <= ex["x"] and abs(y) <= ex["y"])}
flag(f"{vt['mark']} positions = {len(gx)}x{len(gy)} grid minus |x|<={ex['x']} & |y|<={ex['y']} [추론]", pts == exp, f"{len(pts)} / {len(exp)}")
else:
exp = {(round(x), round(y)) for i, x in enumerate(gx) for j, y in enumerate(gy) if (i + j) % 2 == vt["parity"]}
flag(f"{vt['mark']} positions = checkerboard of {len(gx)}x{len(gy)} grid [추론]", pts == exp, f"{len(pts)} / {len(exp)}")'''),
(''' max(abs(o.Geometry.GetBoundingBox(True).Min.Z - FB - vt["z0"]) for o in cl[vt["mark"]]))
bb = cl[vt["mark"]][0].Geometry.GetBoundingBox(True)
check(f"{vt['mark']} vertical extent a", vt["a"], bb.Max.Z - bb.Min.Z)''',
''' max(abs(bbx(o).Min.Z - FB - vt["z0"]) for o in cl[vt["mark"]]))
bb = bbx(cl[vt["mark"]][0])
check(f"{vt['mark']} vertical extent a", vt["a"], bb.Max.Z - bb.Min.Z)
if "legs" in vt: # both legs run towards +Y from the same point: Y extent = the longer leg
check(f"{vt['mark']} leg extent along Y (longer leg)", max(vt["legs"].values()), bb.Max.Y - bb.Min.Y)'''),
]
if __name__ == "__main__":
patch(GEN, GEN_PAIRS)
patch(CHK, CHK_PAIRS)
print("patched", GEN.name, CHK.name)
@@ -0,0 +1,55 @@
"""Stage B (2026-10-16): web/store.py gets the app-check storage (autocheck.json) and the fixed-field snapshot in the origin record.
Backup of the stage-A state is made by the caller. Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "def run_autocheck" not in s, "already patched"
pairs = [
('from engine import variability # noqa: E402', 'from engine import variability, verify # noqa: E402'),
(''' origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()}}''',
''' origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()},
"fixed": {f"{w}:{k}": variability.fixed_view(v, (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields", []))
for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()
if (var["sections"].get(f"{w}:{k}") or {}).get("fixed_fields")}}'''),
(''' def section_state(self, which, key):''', ''' # ---- app check (stage B): autocheck.json is separate from confirm.json; the app never writes confirmations
def autocheck(self):
return rjson(self.dir / "autocheck.json", {})
def _autocheck_basis(self):
return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts())])
def autocheck_current(self):
a = self.autocheck()
return bool(a) and a.get("basis") == self._autocheck_basis()
def run_autocheck(self, force=False):
"""Run the app check of every bundle (no gate, no Rhino) and store the result; skipped when nothing changed."""
if not force and self.autocheck_current():
return self.autocheck()
P0, E0 = self.load("params"), self.load("expected")
meta = {f"{w}:{k}": self.section_meta(w, k) for w, d in (("params", P0), ("expected", E0)) for k in d}
cur_fixed = {b: variability.fixed_view((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]], m["fixed_fields"])
for b, m in meta.items() if m["fixed_fields"]}
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed)
sections = {b: dict(r, sha=sha((P0 if b.startswith("params:") else E0)[b.split(":", 1)[1]])) for b, r in res.items()}
out = {"version": verify.VERSION, "at": now(), "basis": self._autocheck_basis(), "sections": sections}
wjson(self.dir / "autocheck.json", out)
return out
def section_check(self, which, key):
"""Stored app-check result of a bundle: {"st", "checks", "summary", "stale"}; st "?" when never checked."""
r = (self.autocheck().get("sections") or {}).get(f"{which}:{key}")
if not r:
return {"st": "?", "checks": [], "summary": "앱 확인 전", "stale": False}
d = self.load(which)
return dict(r, stale=key not in d or r.get("sha") != sha(d[key]))
def section_state(self, which, key):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched store.py")
@@ -0,0 +1,84 @@
"""Stage B (2026-10-16): pier page section 2 in Prism (summary strip, app-check column, evidence link, 일치 묶음 일괄 확정), prism.css link,
reason field on the section confirm form. The old 필요한 묶음 일괄 확정 form stays, folded away as legacy (stage D decides)."""
from pathlib import Path
T = Path(__file__).resolve().parents[2] / "web" / "templates"
# ---- base.html
p = T / "base.html"
s = p.read_text(encoding="utf-8")
assert "prism.css" not in s
a = "<title>{% block title %}T형 교각 모델링{% endblock %}</title>\n"
assert s.count(a) == 1
s = s.replace(a, a + '<link rel="stylesheet" href="{{ url_for(\'static\', filename=\'prism.css\') }}">\n')
p.write_text(s, encoding="utf-8")
# ---- pier.html: replace everything between the stage-A marker and the gates heading
p = T / "pier.html"
s = p.read_text(encoding="utf-8")
i = s.index("<!-- stage A 2026-10-16 -->")
j = s.index("<h2>3. 게이트</h2>")
new = '''<!-- stage A 2026-10-16 / stage B 2026-10-16 -->
{% from "_prism.html" import st_badge, human_badge %}
<div class="prism" id="bundles">
<div class="stack">
<section class="strip" aria-label="요약">
<p class="concl">필요 {{ nreq }}개 중 앱 확인 일치 {{ cnt.g }} · 주의 {{ cnt.w }} · 불일치 {{ cnt.b }} · 근거 없음 {{ cnt.n }} · 해당 없음 {{ cnt.x }} · 사람 확정 {{ human }}</p>
<div class="row">
<form method="post" action="{{ url_for('autocheck_run', name=p.name) }}" style="margin:0"><button class="btn" type="submit">전체 확인</button></form>
{% if nmatch %}<a class="btn primary" href="{{ url_for('confirm_matched', name=p.name) }}">일치 묶음 일괄 확정 ({{ nmatch }}개)</a>
{% else %}<span class="cap muted">확정할 '일치' 묶음이 없습니다.</span>{% endif %}
</div>
<p class="cap">앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과이며 <b>앱은 확정하지 않습니다</b>. '일치'는 "독립 근거와 값이 같다"는 뜻이지 "도면이 맞다"는 뜻이 아닙니다.
<b>변수 등급</b> V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요).</p>
</section>
{% if origin_same %}
<div class="banner" role="alert"><span class="m" aria-hidden="true">▲</span><div>템플릿 원본 그대로인 필수 묶음 {{ origin_same|length }}개 — 템플릿을 복사한 뒤 한 번도 바뀌지 않았습니다. 새 도면과 대조(후보 반영·값 수정)하기 전에 확정하면 원본 교각의 모델이 PASS로 나올 수 있습니다:
{% for s in origin_same %}<a href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</div></div>
{% elif not has_origin %}<p class="cap muted">이 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로' 여부를 표시하지 않습니다.</p>{% endif %}
<div class="tw"><table class="t">
<caption>입력 묶음 {{ sections|length }}개 — 앱 확인이 나쁜 것부터</caption>
<thead><tr><th scope="col">묶음</th><th scope="col">변수 등급</th><th scope="col">값 출처</th><th scope="col">앱 확인</th><th scope="col">사람 확정</th></tr></thead>
<tbody>
{% for s in sections %}
<tr>
<td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not s.required %} <span class="cap muted">(필수 아님)</span>{% endif %}</td>
<td>{% if s.info %}<span class="muted">참고</span>{% else %}<span title="{{ s.meta.note }}">{{ s.meta.grade }} {{ grades[s.meta.grade] }}</span>{% endif %}</td>
<td>{% if s.meta.origin and s.required %}<span class="b warn"><span class="m" aria-hidden="true">▲</span>템플릿 원본 그대로</span>{% elif s.meta.origin %}<span class="muted">템플릿 원본 그대로</span>{% else %}{{ s.meta.source|join(' · ') }}{% endif %}</td>
<td>{{ st_badge(s.chk.st) }} <span class="cap">{{ s.chk.summary }}</span> <a class="cap" href="{{ url_for('evidence', name=p.name, which=s.file, key=s.key) }}">근거 보기</a></td>
<td>{{ human_badge(s) }}{% if s.by %} <span class="cap muted">{{ s.by }} · {{ s.at }}</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table></div>
</div>
</div>
{% if need %}
<details>
<summary class="muted">앱 확인과 상관없이 필요한 묶음을 모두 확정 (이전 방식 · 권장하지 않음)</summary>
<form method="post" action="{{ url_for('confirm_all', name=p.name) }}"
onsubmit="return confirm('게이트에 필요한 미확정 묶음 {{ need|length }}개를 모두 확정합니다. 앱 확인 결과와 상관없이 확정 기록이 찍힙니다. 값을 도면·판독서와 대조하셨습니까?');">
확정자 <input type="text" name="by" required>
<button>필요한 묶음 일괄 확정 ({{ need|length }}개)</button>
<span class="muted">게이트 G1~G5에 필요한 묶음 중 미확정인 것을 앱 확인 없이 모두 확정합니다(도장). 위의 '일치 묶음 일괄 확정'을 쓰세요.</span>
</form>
</details>
{% else %}<p class="ok">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}
'''
s = s[:i] + new + s[j:]
p.write_text(s, encoding="utf-8")
# ---- section.html: link to the evidence page and the reason field of the confirm form
p = T / "section.html"
s = p.read_text(encoding="utf-8")
a = ''' <input type="hidden" name="next" value="{{ url_for('section', name=p.name, which=which, key=key) }}">
<button>저장된 값으로 확정</button>'''
assert s.count(a) == 1
b = ''' <input type="hidden" name="next" value="{{ url_for('section', name=p.name, which=which, key=key) }}">
사유 <input type="text" name="why" size="50" placeholder="앱 확인이 '일치'가 아니면 필수">
<button>저장된 값으로 확정</button> <a href="{{ url_for('evidence', name=p.name, which=which, key=key) }}">근거 보기</a>'''
s = s.replace(a, b)
p.write_text(s, encoding="utf-8")
print("patched templates")
@@ -0,0 +1,162 @@
"""Stage B (2026-10-16): confirmation records carry the reason and the app-check status, routes for the app check, the evidence page and the
"일치 묶음 일괄 확정" (D4), the report gets the app-check summary. The old 필요한 묶음 일괄 확정 route stays (legacy, stage D decides).
Backups of the stage-A state are made by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "# stage B 2026-10-16" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
STORE = [
(''' return {"exists": True, "confirmed": ok, "by": c and c["by"], "at": c and c["at"],
"stale": bool(c) and not ok, "info": is_info(key)}''',
''' return {"exists": True, "confirmed": ok, "by": c and c["by"], "at": c and c["at"], "why": c and c.get("why"),
"app_st": c and c.get("app_st"), "stale": bool(c) and not ok, "info": is_info(key)}'''),
(''' def confirm(self, which, key, by):
d = self.load(which)
conf = self.confirmations()
conf[f"{which}:{key}"] = {"sha": sha(d[key]), "by": by, "at": now()}''',
''' def confirm(self, which, key, by, why="", app_st=""):
"""Human confirmation record (stage B 2026-10-16: + the reason `why` and the app-check status at that time `app_st`)."""
d = self.load(which)
conf = self.confirmations()
rec = {"sha": sha(d[key]), "by": by, "at": now()}
if why:
rec["why"] = why
if app_st:
rec["app_st"] = app_st
conf[f"{which}:{key}"] = rec'''),
]
APPPY = [
("from engine import recommend, reader, report_files, variability # noqa: E402",
"from engine import recommend, reader, report_files, variability, verify # noqa: E402"),
('''@app.post("/pier/<name>/section/<which>/<key>/confirm")
def section_confirm(name, which, key):
p = pier_or_404(name)
by = request.form.get("by", "").strip()
if not by:
flash("확정자 이름을 입력하세요.", "fail")
else:
p.confirm(which, key, by)
flash(f"{which}.{key} 확정 ({by})", "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))''',
'''@app.post("/pier/<name>/section/<which>/<key>/confirm")
def section_confirm(name, which, key):
"""Confirm one bundle. A bundle the app check does not report as 일치 (주의 / 불일치 / 근거 없음) needs a reason (D3, stage B 2026-10-16)."""
p = pier_or_404(name)
by = request.form.get("by", "").strip()
why = request.form.get("why", "").strip()
p.run_autocheck()
st = p.section_check(which, key)["st"]
if not by:
flash("확정자 이름을 입력하세요.", "fail")
elif st in ("w", "b", "n") and not why:
flash(f"{which}.{key}는 앱 확인이 '{verify.ST_NAME[st]}'입니다. 사유를 입력해야 확정할 수 있습니다.", "fail")
else:
p.confirm(which, key, by, why, st)
flash(f"{which}.{key} 확정 ({by})" + (f" — 앱 확인 {verify.ST_NAME.get(st, st)}, 사유 기록" if why else ""), "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))
# ---------------- app check (stage B 2026-10-16)
@app.post("/pier/<name>/autocheck")
def autocheck_run(name):
p = pier_or_404(name)
a = p.run_autocheck(force=True)
flash(f"앱 확인을 실행했습니다 ({len(a['sections'])}개 묶음). 앱은 확정하지 않습니다.", "ok")
return redirect(request.form.get("next") or url_for("pier", name=name))
@app.route("/pier/<name>/evidence/<which>/<key>")
def evidence(name, which, key):
p = pier_or_404(name)
if which not in ("params", "expected") or key not in p.load(which):
abort(404)
p.run_autocheck()
return render_template("evidence.html", p=p, which=which, key=key, chk=p.section_check(which, key), st=p.section_state(which, key),
meta=p.section_meta(which, key), grades=variability.GRADES, ST=verify.ST_NAME)
def matched_targets(p):
"""Required bundles the app check reports as 일치 and nobody has confirmed (current): the D4 bulk-confirm targets."""
p.run_autocheck()
out = []
for f, k in sorted(required(p)):
c, s = p.section_check(f, k), p.section_state(f, k)
if c["st"] == "g" and not c["stale"] and not s["confirmed"]:
out.append((f, k))
return out
def _targets_sha(p, targets):
return store.sha([[f, k, p.section_check(f, k)["sha"], p.section_check(f, k)["st"]] for f, k in targets])
@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])
def confirm_matched(name):
"""D4: bulk confirmation of the bundles the app check reports as 일치. The evidence table must have been opened (a marker holds the
hash of the target list), a name is required; anything the app does not report as 일치 is not touched."""
p = pier_or_404(name)
targets = matched_targets(p)
if request.method == "POST":
by = request.form.get("by", "").strip()
mark = store.rjson(p.dir / "confirm_view.json", {})
if not targets:
flash("확정할 일치 묶음이 없습니다.", "fail")
elif mark.get("sha") != _targets_sha(p, targets):
flash("근거표를 열어 끝까지 읽은 뒤에 확정할 수 있습니다(대상 목록이 바뀌었으면 다시 여세요).", "fail")
elif not by:
flash("확정자 이름을 입력하세요.", "fail")
else:
for f, k in targets:
p.confirm(f, k, by, "", "g")
flash(f"앱 확인 '일치' 묶음 {len(targets)}개를 {by}이(가) 확정했습니다. 확정 기록에는 입력한 이름만 남습니다.", "ok")
return redirect(url_for("pier", name=name))
return redirect(url_for("confirm_matched", name=name))
store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
rest = []
for f, k in sorted(required(p)):
if (f, k) in targets:
continue
s = p.section_state(f, k)
if not s["confirmed"]:
rest.append(dict(file=f, key=k, chk=p.section_check(f, k)))
return render_template("confirm_matched.html", p=p, targets=[dict(file=f, key=k, chk=p.section_check(f, k)) for f, k in targets],
rest=rest, ST=verify.ST_NAME)'''),
(''' secs = []
req = required(p)
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req))''',
''' secs = []
req = required(p)
p.run_autocheck() # stage B: skipped when nothing changed
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req, chk=p.section_check(s["file"], s["key"])))'''),
(''' origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16''',
''' origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16
cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16
for s in secs:
if s["required"]:
cnt[s["chk"]["st"] if s["chk"]["st"] in cnt else "n"] += 1
human = sum(1 for s in secs if s["required"] and s["confirmed"])
nmatch = len(matched_targets(p))'''),
(''' has_origin=bool(p.info.get("origin")), grades=variability.GRADES,''',
''' has_origin=bool(p.info.get("origin")), grades=variability.GRADES, cnt=cnt, human=human, nreq=len(req),
nmatch=nmatch, ST=verify.ST_NAME,'''),
]
if __name__ == "__main__":
patch(APP / "web" / "store.py", STORE)
patch(APP / "web" / "app.py", APPPY)
print("patched store.py, app.py")
@@ -0,0 +1,26 @@
"""Stage B (2026-10-16), second part: the "근거표를 끝까지 읽었다" marker is written by a separate POST (sent by the page when the evidence
table has been scrolled to its end), not by merely opening the page (D4)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "app.py"
s = P.read_text(encoding="utf-8")
a = ''' store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
rest = []'''
b = ''' rest = []'''
assert s.count(a) == 1
s = s.replace(a, b)
a2 = '''@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])'''
b2 = '''@app.post("/pier/<name>/confirm_matched/read")
def confirm_matched_read(name):
"""Marker: the evidence table of the current target list was read to its end (the page posts this when the scroll box is at the bottom)."""
p = pier_or_404(name)
targets = matched_targets(p)
store.wjson(p.dir / "confirm_view.json", {"sha": _targets_sha(p, targets), "at": store.now(), "n": len(targets)})
return {"ok": True, "n": len(targets)}
@app.route("/pier/<name>/confirm_matched", methods=["GET", "POST"])'''
assert s.count(a2) == 1
s = s.replace(a2, b2)
P.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,35 @@
"""Stage B (2026-10-16): report chapter 8 gets "8.1 입력 묶음 앱 확인 결과 요약" (app-check status of every required bundle next to the
human confirmation, so the basis of each confirmation is on record). Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "engine" / "report.py"
s = P.read_text(encoding="utf-8")
assert "8.1 입력 묶음" not in s
pairs = [
(''' "gates": {}, "results": {}}
for g in gate_ids():''', ''' "gates": {}, "results": {}, "autocheck": _rj(pier_dir / "autocheck.json", {}), "confirm": _rj(pier_dir / "confirm.json", {})}
for g in gate_ids():'''),
(''' ""] + _table(["게이트", "재로드", "음성 시험", "앞 게이트 객체"], rows) + [""]
''', ''' ""] + _table(["게이트", "재로드", "음성 시험", "앞 게이트 객체"], rows) + [""]
ac = (d.get("autocheck") or {}).get("sections") or {}
if ac:
names = {"g": "일치", "w": "주의", "b": "불일치", "n": "근거 없음", "x": "해당 없음"}
rows = []
for b, r in sorted(ac.items(), key=lambda kv: ("gwbnx".index(kv[1]["st"]) if kv[1]["st"] in "gwbnx" else 9, kv[0])):
if r["st"] == "x" and b.split(":")[1].startswith("_"):
continue
c = d["confirm"].get(b) or {}
human = f"{c['by']} {c['at']}" + (f" (앱 확인 {names.get(c.get('app_st'), '-')}, 사유: {c['why']})" if c.get("why") else "") if c else "미확정"
rows.append([b, names.get(r["st"], r["st"]), r["summary"][:90], human])
n = {k: sum(1 for r in ac.values() if r["st"] == k) for k in "gwbnx"}
L += ["### 8.1 입력 묶음 앱 확인 결과 요약", "",
f"- 앱 확인(검사 버전 {d['autocheck'].get('version', '-')}, {d['autocheck'].get('at', '-')}): 일치 {n['g']} · 주의 {n['w']} · 불일치 {n['b']} · 근거 없음 {n['n']} · 해당 없음 {n['x']} (전체 묶음 {len(ac)}개).",
"- 앱 확인은 도면 표·재료표 수량·고정 항목을 앱이 대조한 결과입니다. '일치'는 독립 근거와 값이 같다는 뜻이며 도면이 맞다는 보증이 아닙니다. 확정은 사람이 했고, 앱 확인이 일치가 아닌 묶음은 사유가 기록되어 있습니다.",
""] + _table(["묶음", "앱 확인", "근거", "사람 확정"], rows) + [""]
'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched report.py")
@@ -0,0 +1,46 @@
"""Stage A of the 입력 묶음 확인·확정 개편 (2026-10-16): web/store.py gets the variability classes, the template-origin record and the
bundle metadata. Backup (backup/2026-10-16_묶음개편A전/) is made by the caller. Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "from engine import variability" not in s, "already patched"
pairs = [
("import hashlib\nimport json\nimport os\nimport re\nimport shutil\nimport threading\nimport time\nfrom pathlib import Path\n",
"import hashlib\nimport json\nimport os\nimport re\nimport shutil\nimport sys\nimport threading\nimport time\nfrom pathlib import Path\n"),
('APP = Path(__file__).resolve().parents[1]\nDATA =',
'APP = Path(__file__).resolve().parents[1]\nif str(APP) not in sys.path:\n sys.path.insert(0, str(APP))\nfrom engine import variability # noqa: E402\n\nDATA ='),
(' wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label})\n return Pier(name)',
''' P0, E0 = rjson(d / "params.json"), rjson(d / "expected.json")
var = rjson(src_dir / "variability.json") or variability.build(P0, E0)
wjson(d / "variability.json", var)
# origin record: hashes of every bundle as copied, so a bundle that is still identical later is "템플릿 원본 그대로"
origin = {"label": label, "at": now(), "sha": {f"{w}:{k}": sha(v) for w, dd in (("params", P0), ("expected", E0)) for k, v in dd.items()}}
wjson(d / "pier.json", {"name": name, "type": ptype, "created": now(), "template": label, "origin": origin})
return Pier(name)'''),
(' def section_state(self, which, key):',
''' def variability(self):
"""Variability classes of this pier's bundles (own variability.json, else classified from the current values)."""
v = rjson(self.dir / "variability.json")
if v and v.get("sections"):
return v
return variability.build(self.load("params"), self.load("expected"))
def section_meta(self, which, key):
"""Grade / source / fixed fields of a bundle and whether it is still the template's original value.
origin is None for piers created before the origin record existed (unknown, not claimed either way)."""
d = self.load(which)
m = dict(self.variability()["sections"].get(f"{which}:{key}") or variability.classify(which, key, d.get(key)))
if which == "params" and key in ("coping_rebar", "footing_rebar") and key in d:
m = variability.classify(which, key, d[key]) # V2 / V3 follows the current content (explicit bars list)
o = (self.info.get("origin") or {}).get("sha", {}).get(f"{which}:{key}")
m["origin"] = None if o is None or key not in d else (o == sha(d[key]))
return m
def section_state(self, which, key):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched", P)
@@ -0,0 +1,49 @@
"""Stage A (2026-10-16): pier page columns (변수 등급 / 값 출처) and origin notice, section page grade + reason field."""
from pathlib import Path
T = Path(__file__).resolve().parents[2] / "web" / "templates"
pier, sec = T / "pier.html", T / "section.html"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "stage A" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(pier, [
('''<p class="muted">각 묶음을 열어 값을 도면과 대조·수정한 뒤 확정하세요. 확정 뒤 값이 바뀌면 자동으로 미확정이 됩니다.</p>
<div class="wrap"><table>
<tr><th>파일</th><th>묶음</th><th>상태</th><th>확정자</th><th>확정 시각</th></tr>''',
'''<!-- stage A 2026-10-16 -->
<p class="muted">각 묶음을 열어 값을 도면과 대조·수정한 뒤 확정하세요. 확정 뒤 값이 바뀌면 자동으로 미확정이 됩니다.
<b>변수 등급</b>: V1 값 · V2 목록 · V3 생성(화면 편집 차단) · F 고정(바꾸면 사유 필요). <b>값 출처</b>는 값이 보통 어디서 오는지입니다.</p>
{% if origin_same %}
<p class="warn">▲ 템플릿 원본 그대로인 필수 묶음 {{ origin_same|length }}개 — 템플릿을 복사한 뒤 한 번도 바뀌지 않았습니다. 새 도면과 대조(후보 반영·값 수정)하기 전에 확정하면 원본 교각의 모델이 PASS로 나올 수 있습니다:
{% for s in origin_same %}<a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.file }}.{{ s.key }}</a>{% if not loop.last %}, {% endif %}{% endfor %}</p>
{% elif not has_origin %}<p class="muted">이 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로' 여부를 표시하지 않습니다.</p>{% endif %}
<div class="wrap"><table>
<tr><th>파일</th><th>묶음</th><th>변수 등급</th><th>값 출처</th><th>상태</th><th>확정자</th><th>확정 시각</th></tr>'''),
(''' <td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.key }}</a></td>
''', ''' <td><a href="{{ url_for('section', name=p.name, which=s.file, key=s.key) }}">{{ s.key }}</a></td>
<td>{% if s.info %}<span class="muted">참고</span>{% else %}<span title="{{ s.meta.note }}">{{ s.meta.grade }} {{ grades[s.meta.grade] }}</span>{% endif %}</td>
<td>{% if s.meta.origin and s.required %}<span class="warn">▲ 템플릿 원본 그대로</span>{% elif s.meta.origin %}<span class="muted">템플릿 원본 그대로</span>{% else %}{{ s.meta.source|join(' · ') }}{% endif %}</td>
'''),
])
patch(sec, [
('''<p class="muted">{% if which == 'expected' %}''', '''<!-- stage A 2026-10-16 -->
<p><b>{{ meta.grade }} {{ grades[meta.grade] }}</b> · 값 출처 {{ meta.source|join(' · ') }}{% if meta.origin %} · <span class="warn">▲ 템플릿 원본 그대로</span>{% endif %}
<span class="muted"> — {{ meta.note }}{% if meta.fixed_fields %} (고정 항목: {{ meta.fixed_fields|join(', ') }}){% endif %}</span></p>
{% if meta.grade == 'V3' %}<p class="fail">■ 생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요. 저장 단추는 거부됩니다.</p>
{% elif meta.grade == 'F' or meta.fixed_fields %}<p class="warn">▲ 이 묶음에는 고정(F) 항목이 있습니다. 바꾸면 같은 템플릿이 아닐 수 있으므로 <b>바꾸는 사유</b>를 입력해야 저장되고, 사유는 기록됩니다.</p>{% endif %}
<p class="muted">{% if which == 'expected' %}'''),
(''' <textarea name="value" spellcheck="false">{{ text }}</textarea>
<p><button>저장</button></p>''', ''' <textarea name="value" spellcheck="false">{{ text }}</textarea>
{% if meta.grade == 'F' or meta.fixed_fields %}<p>고정 항목을 바꾸는 사유 <input type="text" name="reason" size="60"></p>{% endif %}
<p><button>저장</button></p>'''),
])
print("patched templates")
@@ -0,0 +1,81 @@
"""Stage A (2026-10-16): web/app.py pier page metadata, F-field edit rule (D3) and V3 edit block; web/store.py edit log.
Backup in backup/2026-10-16_묶음개편A전/. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
A, S = APP / "web" / "app.py", APP / "web" / "store.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "# stage A 2026-10-16" not in s, f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:60])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(S, [(
' def set_section(self, which, key, value):',
''' def log_fixed_edit(self, which, key, paths, reason):
"""Record of an edit of fixed (F) fields with its reason (D3) # stage A 2026-10-16"""
log = rjson(self.dir / "edit_log.json", [])
log.append({"at": now(), "section": f"{which}:{key}", "fixed_fields": paths, "reason": reason})
wjson(self.dir / "edit_log.json", log)
def set_section(self, which, key, value):''')])
patch(A, [
("from engine import recommend, reader, report_files # noqa: E402", "from engine import recommend, reader, report_files, variability # noqa: E402"),
(''' secs = []
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"])))
''', ''' secs = []
req = required(p)
for s in p.sections():
secs.append(dict(s, **p.section_state(s["file"], s["key"]), meta=p.section_meta(s["file"], s["key"]),
required=(s["file"], s["key"]) in req))
origin_same = [s for s in secs if s["required"] and s["meta"]["origin"]] # stage A 2026-10-16
'''),
(''' return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p),''',
''' return render_template("pier.html", p=p, sections=secs, gates=gates, need=unconfirmed(p), origin_same=origin_same,
has_origin=bool(p.info.get("origin")), grades=variability.GRADES,'''),
(''' text = json.dumps(d[key], ensure_ascii=False, indent=1)
if request.method == "POST":
text = request.form["value"]
try:
p.set_section(which, key, json.loads(text))
flash("저장했습니다. 확정하려면 '확정'을 누르세요.", "ok")
return redirect(url_for("section", name=name, which=which, key=key))
except (ValueError, json.JSONDecodeError) as e:
flash(f"저장 안 됨: {e}", "fail")
return render_template("section.html", p=p, which=which, key=key, text=text, st=p.section_state(which, key))''',
''' text = json.dumps(d[key], ensure_ascii=False, indent=1)
meta = p.section_meta(which, key)
if request.method == "POST":
text = request.form["value"]
reason = request.form.get("reason", "").strip()
try:
new = store.expand_chains(json.loads(text))
if meta["grade"] == "V3":
raise ValueError("생성 변수(V3)는 화면에서 직접 고칠 수 없습니다. 판독 스크립트를 다시 실행해 목록을 만든 뒤 반영하세요")
fixed = ["(묶음 전체)"] if meta["grade"] == "F" and new != d[key] else variability.changed_fixed(d[key], new, meta["fixed_fields"])
if fixed and not reason:
raise ValueError(f"고정(F) 항목({', '.join(fixed)})을 바꾸면 같은 템플릿이 아닐 수 있습니다. 바꾸는 사유를 입력하세요")
p.set_section(which, key, new)
if fixed:
p.log_fixed_edit(which, key, fixed, reason)
flash("저장했습니다. 확정하려면 '확정'을 누르세요." + (" 고정 항목 변경 사유를 기록했습니다." if fixed else ""), "ok")
return redirect(url_for("section", name=name, which=which, key=key))
except (ValueError, json.JSONDecodeError) as e:
flash(f"저장 안 됨: {e}", "fail")
return render_template("section.html", p=p, which=which, key=key, text=text, st=p.section_state(which, key), meta=meta,
grades=variability.GRADES)'''),
('''def unconfirmed(p):''', '''def required(p):
"""Bundles any gate of the pier's scope needs (existing ones)."""
return {(r["file"], r["key"]) for g in p.scope() for r in p.gate_requirements(g) if r["exists"]}
def unconfirmed(p):'''),
])
print("patched app.py, store.py")
@@ -0,0 +1,84 @@
"""Stage B fixes after the first run on the real piers (2026-10-16): C6 false positives on directional values (pitch < 0, translation checks),
C7 for fixed bundles of piers without an origin record (compare with the protected templates), status summaries."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
V, S = APP / "engine" / "verify.py", APP / "web" / "store.py"
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(V, [
('''SIZE_KEYS = re.compile(r"^(size_[xy]|thickness|diameter|d|height|length|width_y|plan_length|top_length|dx|dy|half|count_[xy]|nx|ny|pitch.*|dia|"
r"half_.*|bottom_flat_half|tip_half|top_half|soffit_rise|margin)$")''',
'''SIZE_KEYS = re.compile(r"^(size_[xy]|thickness|diameter|height|length|width_y|plan_length|top_length|half|count_[xy]|nx|ny|dia|"
r"half_.*|bottom_flat_half|tip_half|top_half|soffit_rise)$")
SIZE_IN = {"d": ("column", "piles"), "dx": ("footing", "lean"), "dy": ("footing", "lean")} # short names only where they are sizes
NOT_SIZE = ("translation_checks", "pitch", "negative", "chain") # directional / check values may be negative or 0'''),
(''' elif isinstance(v, (int, float)) and not isinstance(v, bool):
if SIZE_KEYS.match(name) and v <= 0:''',
''' elif isinstance(v, (int, float)) and not isinstance(v, bool):
is_size = (SIZE_KEYS.match(name) or (name in SIZE_IN and len(path) > 1 and path[-2] in SIZE_IN[name])) \
and not any(p.startswith(NOT_SIZE) for p in path)
if is_size and v <= 0:'''),
('''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None):''',
'''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None):'''),
(''' origin_fixed = origin_fixed or {}
edit_log = edit_log or []''', ''' origin_fixed = origin_fixed or {}
edit_log = edit_log or []
template_values = template_values or {}'''),
(''' if m["grade"] == "F":
if m.get("origin") is None:
checks.append(chk("C7", "변수 등급 위반", "n", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다"))''',
''' if m["grade"] == "F":
if m.get("origin") is None:
tv = template_values.get(b) or {}
now = json.dumps(value, sort_keys=True, ensure_ascii=False)
same = sorted(t for t, v in tv.items() if v == now)
if key == "pier_id":
checks.append(chk("C7", "변수 등급 위반", "x", "교각 식별자라 비교하지 않는다"))
elif same:
checks.append(chk("C7", "변수 등급 위반", "g", f"템플릿 복사 기록은 없지만 보호 템플릿 {len(same)}종({', '.join(same[:3])})과 같은 값이다", strength="F"))
elif tv:
checks.append(chk("C7", "변수 등급 위반", "w", "템플릿 복사 기록이 없고 어느 보호 템플릿과도 같지 않다 — 새 형식인지 확인"))
else:
checks.append(chk("C7", "변수 등급 위반", "n", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다"))'''),
('''def summarize(st, checks):
if st == "x":
return "사람 결정 기록 또는 참고 정보 — 앱이 도면과 대조하지 않는다"
key = [c for c in checks if c["st"] in ("b", "w", "n") and c["id"] != "C1"] or [c for c in checks if c["st"] == "g"]
return (key[0]["id"] + " " + key[0]["text"]) if key else ""''',
'''def summarize(st, checks):
"""One sentence for the table: the check that decides the status."""
if st == "x":
return "사람 결정 기록 또는 참고 정보 — 앱이 도면과 대조하지 않는다"
pick = {"b": [c for c in checks if c["st"] == "b"],
"w": [c for c in checks if c["st"] == "w"] or [c for c in checks if c["strength"] == "C" and c["st"] == "g"],
"g": [c for c in checks if c["strength"] in ("S", "F") and c["st"] == "g"],
"n": [c for c in checks if c["id"] in ("C3", "C7", "C2") and c["st"] == "n"]}[st]
if pick:
c = pick[0]
return f"{c['id']} {c['text']}" + (" — 수량만 확인, 위치·좌표는 도면과 직접 대조" if st == "w" and c["strength"] == "C" and c["st"] == "g" else "")
return "앱이 독립적으로 대조할 근거가 없다 — 도면과 직접 대조한 뒤 사유와 함께 확정"'''),
])
patch(S, [
(''' res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed)''',
''' tvals = {} # fixed bundles: value in every protected template (for piers without origin record)
for t in templates():
td = template_dir(t["name"])
for w in ("params", "expected"):
tdoc = rjson(td / f"{w}.json", {})
for k, v in tdoc.items():
if meta.get(f"{w}:{k}", {}).get("grade") == "F":
tvals.setdefault(f"{w}:{k}", {})[t["name"]] = json.dumps(v, sort_keys=True, ensure_ascii=False)
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals)'''),
])
print("patched")
@@ -0,0 +1,67 @@
"""Stage C part 2 (2026-10-19): table cross-check of the anchor candidates. A field that has a table value (ELEVATION / 솔플레이트 / 철근재료표) is
confirmed by it: the anchor candidate equal to the table value becomes grade A ("표와 도면 문자 일치"), one that differs is lowered to grade C and
carries the table value. Measured on the real piers Ungok P3 / P4 (frame of the template): grade B was right in 68 % of the fields that have a table
value, mostly the schedule lengths of marks that share a value. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "anchors.py", [
('''def chains(texts):''', '''def table_values(proposal_rows):
"""{field: value} of the table candidates (engine.reader.proposals rows); list candidates are expanded per index (schedule [L, n])."""
out = {}
for r in proposal_rows:
field = f"{r['file']}:" + ".".join(str(x) for x in r["path"])
c = r["candidate"]
if isinstance(c, (list, tuple)):
for i, v in enumerate(c):
if isinstance(v, (int, float)) and not isinstance(v, bool):
out[f"{field}.{i}"] = v
elif isinstance(c, (int, float)) and not isinstance(c, bool):
out[field] = c
return out
def cross_check(cand, table):
"""Confirm / correct anchor candidates with table values of the same field: equal -> grade A (table_confirmed), different -> grade C with
`table` = the table value. Fields without a table value keep their grade. Works on the dict returned by follow()."""
for f, c in cand.items():
t = table.get(f)
if t is None:
continue
c["table"] = t
if abs(abs(t) - abs(c["value"])) <= 1.5:
c["grade"], c["table_confirmed"] = "A", True
else:
c["grade"], c["table_confirmed"] = "C", False
return cand
def chains(texts):'''),
])
patch(APP / "web" / "store.py", [
(''' cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}''',
''' cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}
tabs = reader.table_candidates(texts)
anchors.cross_check(cand, anchors.table_values(reader.proposals(reader.field_candidates(tabs), docs["params"], docs["expected"])) if tabs else {})'''),
('from engine import anchors, variability, verify # noqa: E402', 'from engine import anchors, reader, variability, verify # noqa: E402'),
])
patch(APP / "web" / "templates" / "candidates.html", [
(''' <td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}</td>''',
''' <td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}
{% if r.table_confirmed %}<br><b>표 값과도 일치</b>{% elif r.table is defined %}<br><b>표 값 {{ r.table }}와 다름 — 표를 따르세요</b>{% endif %}</td>'''),
])
print("patched")
@@ -0,0 +1,40 @@
"""Stage C part 2 (2026-10-19): selection helpers on the candidate screen — tick every differing grade-A candidate of one file (params OR expected,
never both: the same drawing text may not go into both, P7)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "templates" / "candidates.html"
s = P.read_text(encoding="utf-8")
a = ''' <div class="field"><label for="why">반영 사유'''
assert s.count(a) == 1 and "sel-a" not in s
b = ''' <div class="row"><span class="cap">빠른 선택(A등급 중 현재 값과 다른 것):</span>
<button class="btn sm" type="button" data-sel-a="expected">expected만</button>
<button class="btn sm" type="button" data-sel-a="params">params만</button>
<button class="btn sm quiet" type="button" data-sel-a="">선택 해제</button>
<span class="cap muted">params와 expected에 같은 도면 문자를 함께 반영할 수 없어 한쪽씩 고릅니다.</span></div>
''' + a
s = s.replace(a, b)
c = ''' {% endif %}
{% endif %}
{% if chains %}'''
assert s.count(c) == 1
d = ''' <script>
(function () {
/* sel-a */
var rows = document.querySelectorAll('#out-of-table tbody tr');
document.querySelectorAll('[data-sel-a]').forEach(function (btn) {
btn.addEventListener('click', function () {
var file = btn.getAttribute('data-sel-a');
rows.forEach(function (tr) {
var cb = tr.querySelector('input[type=checkbox][name=field]');
if (!cb) return;
var isA = tr.textContent.indexOf('등급 A') >= 0;
cb.checked = !!file && isA && cb.value.indexOf(file + ':') === 0;
});
});
});
})();
</script>
''' + c
s = s.replace(c, d)
P.write_text(s, encoding="utf-8")
print("patched candidates.html")
@@ -0,0 +1,88 @@
"""Stage C (2026-10-19), part 1: web/store.py learns / copies anchors, follows them in a pier's drawing and offers field get / set helpers.
Backup by the caller (backup/2026-10-19_C단계전/). Refuses to run twice."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "store.py"
s = P.read_text(encoding="utf-8")
assert "def anchor_rows" not in s, "already patched"
pairs = [
('from engine import variability, verify # noqa: E402', 'from engine import anchors, variability, verify # noqa: E402'),
# save_template: learn anchors when the source pier has an indexed drawing
(''' wjson(d / "variability.json", variability.build(rjson(sdir / "params.json"), rjson(sdir / "expected.json")))
return d''', ''' wjson(d / "variability.json", variability.build(rjson(sdir / "params.json"), rjson(sdir / "expected.json")))
texts = rjson(sdir / "drawings" / "texts.json", [])
if texts:
shas = [a.get("source_sha256") for a in rjson(sdir / "drawings" / "audit.json", []) if a.get("source_sha256")]
wjson(d / "anchors.json", anchors.learn(rjson(sdir / "params.json"), rjson(sdir / "expected.json"), texts, shas))
return d'''),
# create_pier: carry the anchors (template) or learn them from the copied pier's own drawing
(''' wjson(d / "variability.json", var)
''', ''' wjson(d / "variability.json", var)
anc = rjson(src_dir / "anchors.json")
if not anc and (src_dir / "drawings" / "texts.json").exists():
shas = [a.get("source_sha256") for a in rjson(src_dir / "drawings" / "audit.json", []) if a.get("source_sha256")]
anc = anchors.learn(P0, E0, rjson(src_dir / "drawings" / "texts.json", []), shas)
if anc:
wjson(d / "anchors.json", anc)
'''),
(''' # ---- app check (stage B)''', ''' # ---- anchors (stage C, 2026-10-19): out-of-table dimension candidates
def anchor_rows(self):
"""Candidates followed from the pier's anchors in its drawing: ({rows}, missing, meta). rows: one per anchored field that was found again;
missing: anchored fields without a candidate. Nothing is written."""
anc, texts = rjson(self.dir / "anchors.json"), self.texts()
if not anc or not texts:
return [], [], {"anchors": bool(anc), "texts": bool(texts), "coverage": None, "self_source": False}
own = [a.get("source_sha256") for a in rjson(self.drawings / "audit.json", []) if a.get("source_sha256")]
cand = anchors.follow(anc, texts, own)
docs = {"params": self.load("params"), "expected": self.load("expected")}
rows, missing = [], []
for field in anc["fields"]:
cur = get_field(docs, field)
if cur is None:
continue
c = cand.get(field)
if not c:
missing.append(field)
continue
which, path = field.split(":", 1)
rows.append(dict(c, field=field, file=which, path=path, current=cur, same=abs(abs(cur) - abs(c["value"])) < 1.5 and (cur >= 0) == (c["value"] >= 0)))
cov = rows[0]["coverage"] if rows else None
return rows, missing, {"anchors": True, "texts": True, "coverage": cov, "self_source": bool(rows and rows[0]["self_source"])}
# ---- app check (stage B)'''),
('''def sha(obj):''', '''def get_field(docs, field):
"""Numeric value at "params:coping.profile_xz.0.1" (list indices allowed), else None."""
which, path = field.split(":", 1)
x = docs[which]
for p in path.split("."):
if isinstance(x, list):
x = x[int(p)] if p.isdigit() and int(p) < len(x) else None
elif isinstance(x, dict):
x = x.get(p)
else:
return None
if x is None:
return None
return x if isinstance(x, (int, float)) and not isinstance(x, bool) else None
def set_field(docs, field, value):
which, path = field.split(":", 1)
x, parts = docs[which], path.split(".")
for p in parts[:-1]:
x = x[int(p)] if isinstance(x, list) else x[p]
last = parts[-1]
value = int(value) if float(value).is_integer() and isinstance(x[int(last) if isinstance(x, list) else last], int) else float(value)
if isinstance(x, list):
x[int(last)] = value
else:
x[last] = value
def sha(obj):'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched store.py")
@@ -0,0 +1,78 @@
"""Stage C (2026-10-19), part 2: the app check gets C2b "표 밖 치수 재대조" from the followed anchors (engine/verify.py) and store.run_autocheck
passes the rows. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
assert "C2b" not in s.replace("# C2b-patched", "") or path.name == "store.py", f"{path.name} already patched"
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "engine" / "verify.py", [
(''' C3 템플릿 원본 잔존''', ''' C2b 표 밖 치수 재대조 anchors followed into the drawing (engine/anchors.py): A candidates equal / differ, B only warn; own-drawing anchors are no evidence [strong]
C3 템플릿 원본 잔존'''),
('''def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None):''',
'''def anchor_check(rows, missing, key_prefix):
"""C2b of one bundle from the anchor rows of its fields (rows of engine.anchors.follow joined with the current values)."""
mine = [r for r in rows if r["field"].startswith(key_prefix) and not r["self_source"]]
miss = [f for f in missing if f.startswith(key_prefix)]
if not mine:
return None
a_diff = [r for r in mine if r["grade"] == "A" and not r["same"]]
b_diff = [r for r in mine if r["grade"] == "B" and not r["same"]]
a_same = [r for r in mine if r["grade"] == "A" and r["same"]]
b_same = [r for r in mine if r["grade"] == "B" and r["same"]]
anchored = len(mine) + len(miss)
cover = (len(a_same) + len(b_same)) / anchored if anchored else 0
if a_diff:
r = a_diff[0]
d = "; ".join(f"{x['field'].split(':', 1)[1]}: 현재 {x['current']} ≠ 도면 {x['value']} ('{x['text']}' {x['dwg']})" for x in a_diff[:3])
return chk("C2b", "표 밖 치수 재대조", "b", f"A등급 후보 {len(a_diff)}건이 현재 값과 다르다 — {d}", pos=f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})", strength="S")
if b_diff:
r = b_diff[0]
d = "; ".join(f"{x['field'].split(':', 1)[1]}: 현재 {x['current']} ≠ 도면 {x['value']}" for x in b_diff[:3])
return chk("C2b", "표 밖 치수 재대조", "w", f"B등급 후보 {len(b_diff)}건이 현재 값과 다르다(후보가 틀렸을 수도 있다) — {d}", pos=f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})")
if a_same and cover >= 0.6:
r = a_same[0]
return chk("C2b", "표 밖 치수 재대조", "g", f"A등급 {len(a_same)}건·B등급 {len(b_same)}건이 현재 값과 같다 (앵커가 있는 {anchored}개 필드의 {cover:.0%})",
pos=f"{r['dwg']} '{r['text']}' ({r['x']:.0f}, {r['y']:.0f})", strength="S")
if a_same or b_same:
return chk("C2b", "표 밖 치수 재대조", "w", f"앵커 필드 {anchored}개 중 {len(a_same) + len(b_same)}개만 같다 (A {len(a_same)} · B {len(b_same)}) — 대조 범위 {cover:.0%}")
return None
def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None, anchor_rows=None):'''),
(''' template_values = template_values or {}''', ''' template_values = template_values or {}
arows, amiss = (anchor_rows or ([], []))[:2]'''),
(''' # C4
gate = next(''', ''' # C2b
ac = anchor_check(arows, amiss, f"{which}:{key}.") if arows else None
if ac:
checks.append(ac)
# C4
gate = next('''),
(''' order = ("C1", "C2", "C3", "C4", "C5", "C6", "C7", "C8")''', ''' order = ("C1", "C2", "C2b", "C3", "C4", "C5", "C6", "C7", "C8")'''),
(''' strong = any(c["id"] == "C2" and c["st"] == "g" for c in checks)''', ''' strong = any(c["id"] in ("C2", "C2b") and c["st"] == "g" for c in checks)'''),
(''' checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n",
"원본과 같지만 도면 표와 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다"))''',
''' checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n",
"원본과 같지만 도면(표·표 밖 치수)과 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다"))'''),
])
patch(APP / "web" / "store.py", [
(''' res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals)''',
''' arows, amiss, _ameta = self.anchor_rows()
res = verify.verify(P0, E0, self.texts(), meta, (self.info.get("origin") or {}).get("fixed"),
rjson(self.dir / "edit_log.json", []), cur_fixed, tvals, (arows, amiss))'''),
(''' return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts())])''',
''' return sha([self.inputs_sha(), rjson(self.drawings / "audit.json", []), verify.VERSION, len(self.texts()), anchors.VERSION,
sha(rjson(self.dir / "anchors.json", {}).get("fields", {}))])'''),
])
print("patched verify.py, store.py")
@@ -0,0 +1,187 @@
"""Stage C (2026-10-19), part 3: candidate screen for out-of-table dimensions (anchors followed into the drawing), apply route, anchor loader for
piers without anchors, region image route, drawing chain check notice. Backup by the caller. Refuses to run twice."""
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
def patch(path, pairs):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
assert s.count(a) == 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
patch(APP / "web" / "app.py", [
("from engine import recommend, reader, report_files, variability, verify # noqa: E402",
"from engine import anchors, recommend, reader, render, report_files, variability, verify # noqa: E402"),
(''' return render_template("candidates.html", p=p, tables=tables, rows=rows, els=reader.el_list(texts),
dumps=lambda v: json.dumps(v, ensure_ascii=False))''',
''' arows, amiss, ameta = p.anchor_rows()
rank = {"A": 0, "B": 1, "C": 2}
arows.sort(key=lambda r: (r["same"], rank[r["grade"]], r["field"])) # differing candidates first, best grade first
chains = anchors.chains(texts)
return render_template("candidates.html", p=p, tables=tables, rows=rows, els=reader.el_list(texts),
dumps=lambda v: json.dumps(v, ensure_ascii=False), arows=arows, amiss=amiss, ameta=ameta,
chains=chains, chain_bad=[c for c in chains if not c["ok"]],
tpl_anchors=[t["name"] for t in store.templates() if (store.template_dir(t["name"]) / "anchors.json").exists()])
@app.post("/pier/<name>/anchors/apply")
def anchors_apply(name):
"""Apply selected out-of-table candidates. Rules: the same candidate must not go into params and expected together (P7), a grade C candidate
needs a reason, only differing rows are applied; the change is logged and the changed bundles need a new confirmation."""
p = pier_or_404(name)
rows, _m, _meta = p.anchor_rows()
chosen = set(request.form.getlist("field"))
sel = [r for r in rows if r["field"] in chosen and not r["same"] and not r["self_source"]]
why = request.form.get("why", "").strip()
key = lambda r: (r["text"], r["x"], r["y"], r["value"])
both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
if not sel:
flash("반영할 후보를 고르세요(이미 같은 값은 반영하지 않습니다).", "fail")
elif both:
flash("같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다. 두 값이 같은 근거에서 나오면 검사가 무의미해집니다. 한쪽만 고르세요.", "fail")
elif any(r["grade"] == "C" for r in sel) and not why:
flash("C등급 후보는 반영 사유를 입력해야 합니다.", "fail")
else:
docs = {"params": p.load("params"), "expected": p.load("expected")}
for r in sel:
store.set_field(docs, r["field"], r["value"])
p.save("params", docs["params"])
p.save("expected", docs["expected"])
log = store.rjson(p.dir / "anchor_log.json", [])
log.append({"at": store.now(), "fields": [{"field": r["field"], "from": r["current"], "to": r["value"], "grade": r["grade"], "text": r["text"],
"pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"} for r in sel], "why": why})
store.wjson(p.dir / "anchor_log.json", log)
flash(f"표 밖 치수 후보 {len(sel)}건을 반영했습니다. 바뀐 묶음은 다시 확정해야 합니다.", "ok")
return redirect(url_for("candidates", name=name))
@app.post("/pier/<name>/anchors/load")
def anchors_load(name):
"""Copy the anchors of a protected template into a pier that has none (the person says which series the drawing belongs to)."""
p = pier_or_404(name)
t = request.form.get("template", "")
try:
src = store.template_dir(t) / "anchors.json"
if not src.exists():
raise FileNotFoundError(t)
except (FileNotFoundError, ValueError):
flash("앵커가 있는 템플릿을 고르세요.", "fail")
return redirect(url_for("candidates", name=name))
if (p.dir / "anchors.json").exists():
flash("이 교각에는 이미 앵커가 있습니다.", "fail")
else:
shutil.copy2(src, p.dir / "anchors.json")
flash(f"템플릿 '{t}'의 앵커를 불러왔습니다. 앱 확인이 새 도면 기준으로 다시 실행됩니다.", "ok")
return redirect(url_for("candidates", name=name))
@app.route("/pier/<name>/view/<dwg>")
def view_drawing(name, dwg):
"""PNG of the drawing region around (x, y) so a candidate can be checked by eye."""
p = pier_or_404(name)
try:
x, y = float(request.args["x"]), float(request.args["y"])
half = min(max(float(request.args.get("half", 3000)), 500.0), 20000.0)
except (KeyError, ValueError):
abort(400)
ent = next((a for a in store.rjson(p.drawings / "audit.json", []) if a.get("label") == dwg), None)
dxf = p.drawings / "dxf" / ent["dxf"] if ent else None
if not dxf or not dxf.exists():
abort(404)
cache = p.drawings / "view_cache"
cache.mkdir(exist_ok=True)
out = cache / f"{dwg}_{x:.0f}_{y:.0f}_{half:.0f}.png"
if not out.exists():
render.render(str(dxf), x, y, half, str(out))
return send_file(out, mimetype="image/png")'''),
(''' cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16''',
''' chain_bad = [c for c in anchors.chains(p.texts()) if not c["ok"]] # stage C 2026-10-19: R2 check of the drawing's own chains
cnt = {k: 0 for k in "gwbnx"} # stage B 2026-10-16'''),
(''' nmatch=nmatch, ST=verify.ST_NAME,''', ''' nmatch=nmatch, ST=verify.ST_NAME, chain_bad=chain_bad,'''),
])
# ---- templates
patch(APP / "web" / "templates" / "pier.html", [
(''' {% if origin_same %}
<div class="banner" role="alert">''', ''' {% if chain_bad %}
<div class="banner dont" role="alert"><span class="m" aria-hidden="true">■</span><div>도면 치수 사슬 {{ chain_bad|length }}건이 검산에 맞지 않습니다(개수 × 간격 ≠ 합계) — 도면 자체의 오류일 수 있습니다:
{% for c in chain_bad %}<span class="chip">{{ c.dwg }} {{ c.text }} (계산 {{ c.n * c.pitch }} mm)</span> {% endfor %}
<a href="{{ url_for('candidates', name=p.name) }}">표 후보값 화면에서 보기</a></div></div>
{% endif %}
{% if origin_same %}
<div class="banner" role="alert">'''),
])
patch(APP / "web" / "templates" / "candidates.html", [
('''<h2>후보값 비교 ({{ rows|length }}건 · 다름 {{ rows|rejectattr('same')|list|length }}건)</h2>''',
'''{% from "_prism.html" import grade_badge %}
<!-- stage C 2026-10-19 -->
<div class="prism" id="out-of-table">
<div class="stack">
<h2>표 밖 치수 후보 (앵커 따라가기)</h2>
{% if not ameta.anchors %}
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>이 교각에는 앵커가 없습니다. 앵커는 같은 형식의 도면을 이미 읽은 템플릿에서 "각 값을 도면 어디서 읽었는가"를 기억한 것입니다.
같은 도면 틀(예: 운곡교 P1~P4)에 속한다면 템플릿을 골라 불러오세요.</div></div>
{% if tpl_anchors %}<form method="post" action="{{ url_for('anchors_load', name=p.name) }}" class="row" style="margin:0">
<label for="tpl">템플릿 앵커 불러오기</label>
<select id="tpl" name="template">{% for t in tpl_anchors %}<option>{{ t }}</option>{% endfor %}</select>
<button class="btn" type="submit">불러오기</button></form>{% endif %}
{% elif not ameta.texts %}<p class="cap muted">도면 문자 색인이 없습니다. 도면을 먼저 올리세요.</p>
{% else %}
{% set na = arows|selectattr('grade', 'equalto', 'A')|list|length %}{% set nb = arows|selectattr('grade', 'equalto', 'B')|list|length %}{% set nc = arows|selectattr('grade', 'equalto', 'C')|list|length %}
{% set nd = arows|rejectattr('same')|list|length %}
<section class="strip">
<p class="concl">후보 {{ arows|length }}건 — 현재 값과 다름 {{ nd }}건 · A {{ na }} · B {{ nb }} · C {{ nc }} · 후보를 못 찾음 {{ amiss|length }}건</p>
<p class="cap">도면 틀 일치율 {{ '%d' % ((ameta.coverage or 0) * 100) }} % (앵커를 만든 도면과 이 도면에 공통인 문자의 비율). 70 % 미만이면 틀이 다른 도면으로 보고 모든 후보를 C등급으로 낮춥니다.
A = 같은 치수가 여러 곳에서 같게 읽힘 · B = 한 곳 또는 일부만 · C = 근거 약함(목록만, 반영 시 사유 필요). 시험에서 A등급은 틀린 후보가 없었고 B등급은 약 15 %가 틀렸습니다.</p>
</section>
{% if ameta.self_source %}<div class="banner"><span class="m" aria-hidden="true">▲</span><div>이 앵커는 이 교각 자신의 도면에서 만들어졌습니다. 같은 도면을 다시 읽은 것이라 앱 확인의 근거로 쓰지 않습니다.</div></div>{% endif %}
{% if arows %}
<form method="post" action="{{ url_for('anchors_apply', name=p.name) }}" class="stack">
<div class="tw"><table class="t">
<caption>표 밖 치수 후보 — 다른 것부터, 등급 좋은 것부터</caption>
<thead><tr><th scope="col">반영</th><th scope="col">대상</th><th class="num" scope="col">현재</th><th class="num" scope="col">후보</th><th scope="col">등급</th><th scope="col">근거</th><th scope="col">도면 위치</th></tr></thead>
<tbody>
{% for r in arows %}
<tr{% if not r.same %} class="sel"{% endif %}>
<td>{% if not r.same and not r.self_source %}<input type="checkbox" name="field" value="{{ r.field }}" aria-label="{{ r.field }} 반영" style="width:1.25rem;height:1.25rem">{% endif %}</td>
<td><b>{{ r.field }}</b></td><td class="num">{{ r.current }}</td><td class="num">{{ r.value }}</td>
<td>{{ grade_badge(r.grade) }}{% if r.same %} <span class="cap muted">같음</span>{% endif %}</td>
<td class="cap">{{ r.followed }}곳 중 {{ r.agree }}곳 일치{% if r.shared > 1 %} · 같은 값의 필드 {{ r.shared }}종{% endif %}</td>
<td><span class="chip">{{ r.dwg }} '{{ r.text }}' ({{ '%.0f' % r.x }}, {{ '%.0f' % r.y }})</span>
<a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=r.dwg, x=r.x, y=r.y) }}" target="_blank">도면 보기</a></td>
</tr>
{% endfor %}
</tbody>
</table></div>
<div class="field"><label for="why">반영 사유<span class="req">C등급 후보 반영에는 필수</span></label><input id="why" type="text" name="why" style="max-width:36rem"></div>
<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>같은 도면 문자에서 나온 후보를 params와 expected에 함께 반영할 수 없습니다(검사의 독립성). 한쪽만 고르고, 나머지는 도면에서 따로 확인하세요.</div></div>
<div class="row"><button class="btn primary" type="submit">선택한 후보 반영</button></div>
</form>
{% endif %}
{% endif %}
{% if chains %}
<h3>도면 치수 사슬 검산 (R2)</h3>
<p class="cap">{{ chains|length }}개 중 검산(개수 × 간격 = 합계)에 맞지 않는 것 {{ chain_bad|length }}개{% if chain_bad %}:{% endif %}
{% for c in chain_bad %}<span class="b bad"><span class="m" aria-hidden="true">■</span>{{ c.dwg }} {{ c.text }} → 계산 {{ c.n * c.pitch }} mm</span> <a class="cap" href="{{ url_for('view_drawing', name=p.name, dwg=c.dwg, x=c.x, y=c.y) }}" target="_blank">도면 보기</a> {% endfor %}</p>
{% endif %}
</div>
</div>
<h2>후보값 비교 ({{ rows|length }}건 · 다름 {{ rows|rejectattr('same')|list|length }}건)</h2>'''),
])
patch(APP / "web" / "templates" / "_prism.html", [
('''{% macro human_badge(s) -%}''', '''{% macro grade_badge(g) -%}
{%- set M = {'A': ['good', '●', '등급 A'], 'B': ['warn', '▲', '등급 B'], 'C': ['dash', '◇', '등급 C']} -%}
{%- set m = M.get(g, M['C']) -%}
<span class="b {{ m[0] }}"><span class="m" aria-hidden="true">{{ m[1] }}</span>{{ m[2] }}</span>
{%- endmacro %}
{% macro human_badge(s) -%}'''),
])
print("patched app.py, pier.html, candidates.html, _prism.html")
@@ -0,0 +1,24 @@
"""Stage C (2026-10-19), part 3b: the independence rule (P7) also holds across separate applications: a candidate read from a drawing text that
was already applied to params may not be applied to expected later (and the other way round)."""
from pathlib import Path
P = Path(__file__).resolve().parents[2] / "web" / "app.py"
s = P.read_text(encoding="utf-8")
pairs = [
(''' both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
if not sel:''', ''' both = {key(r) for r in sel if r["file"] == "params"} & {key(r) for r in sel if r["file"] == "expected"}
done = store.rjson(p.dir / "anchor_log.json", []) # applied earlier: (file, text, x, y)
prior = {(f["file"], f["text"], f["x"], f["y"]) for e in done for f in e["fields"] if "file" in f}
both |= {key(r) for r in sel if ("expected" if r["file"] == "params" else "params", r["text"], r["x"], r["y"]) in prior}
if not sel:'''),
(''' log.append({"at": store.now(), "fields": [{"field": r["field"], "from": r["current"], "to": r["value"], "grade": r["grade"], "text": r["text"],
"pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"} for r in sel], "why": why})''',
''' log.append({"at": store.now(), "fields": [{"field": r["field"], "file": r["file"], "from": r["current"], "to": r["value"], "grade": r["grade"],
"text": r["text"], "x": r["x"], "y": r["y"], "pos": f"{r['dwg']} ({r['x']:.0f}, {r['y']:.0f})"}
for r in sel], "why": why})'''),
]
for a, b in pairs:
assert s.count(a) == 1, a[:60]
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched")
@@ -0,0 +1,38 @@
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
plan_old = R / "DOC/01_기획/기획_입력묶음확인확정개편_2026-10-19_v0.1.06.md"
plan_new = R / "DOC/01_기획/기획_입력묶음확인확정개편_2026-10-19_v0.1.07.md"
shutil.copy2(plan_old, R / "DOC/01_기획/backup" / plan_old.name)
s = plan_old.read_text(encoding="utf-8")
row = "| v0.1.06 | 2026-10-19 |"
i = s.index(row); j = s.index("\n", i)
s = s[:j+1] + "| v0.1.07 | 2026-10-19 | 심영표 | D단계(전 화면 Prism 교체, 사용설명서 v0.2.12, 전체 회귀, 운곡교 P3·P4 A등급 반영) 결과 6.5절 추가 | 사용자 \"D단계 착수, 운곡교 P3·P4 후보 반영\" |\n" + s[j+1:]
sec = """### 6.5 D단계 결과 (2026-10-19)
**① 전 화면 Prism 교체**: `web/templates/base.html`을 Prism 틀(위쪽 띠·본문)로 바꾸고 `web/static/prism-app.css`를 추가해, 옛 화면의 표·버튼·입력칸·안내 문구가 Prism 토큰(색·간격·Pretendard)을 따르게 했습니다(템플릿별 개별 수정 없이 공통 틀로 교체). 상태 표시는 색과 함께 글자·기호를 씁니다. 형식 불일치 경고는 계속 닫을 수 없습니다. 어두운 테마는 시험하지 않았습니다.
**② 사용설명서 v0.2.12**: 앱 확인·근거 보기·일치 묶음 일괄 확정·변수 등급·표 밖 치수 후보·도면 먼저 추천·6번째 템플릿을 반영하고, 이전 방식 일괄 확정 설명을 삭제했습니다.
**③ 운곡교 P3·P4 후보 반영(A등급, 파일 하나씩)**: `tools/apply_anchor_a.py`로 앱의 후보 반영 경로를 거쳐 expected 쪽 A등급 후보를 반영했습니다(P3 29필드, P4 34필드, 반영 전 params·expected·confirm.json은 `backup/before_anchor_A_*`에 보관, 기록은 `anchor_log.json`). params 쪽 A등급 후보는 같은 도면 문자를 expected에 이미 썼기 때문에 P7 규칙으로 막혀 반영하지 못했습니다. 그 결과 묶음 단위 앱 확인 불일치는 P3 10·P4 11로 그대로이고, 필드 단위 expected 차이 줄은 69→52(P3), 75→63(P4)로 줄었습니다. 남은 불일치는 A등급 후보가 아닌 값(표 후보·params 독립 판독이 필요한 값)입니다.
**④ 회귀**: fast 15/15 PASS, Rhino 그룹 결과는 OPEN #26에 기록합니다.
**남은 결정**: 표 후보(expected 쪽) 검토 후 반영 여부, params 독립 판독, 운곡교 P4 도면 판(OPEN #33).
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
plan_new.write_text(s, encoding="utf-8")
plan_old.unlink()
op = R / "context-migration/OPEN_미해결목록.md"
shutil.copy2(op, R / "context-migration/backup" / "OPEN_미해결목록_2026-10-19_D단계전.md")
t = op.read_text(encoding="utf-8")
a = "**남은 것**: D단계(기존 화면 Prism 교체, 사용설명서 v0.2.12, 전체 회귀). 운곡교 P3·P4의 후보 반영과 나머지 판독은 별도 Task"
assert t.count(a) == 1
t = t.replace(a, "**[갱신 2026-10-19] D단계 완료**: 전 화면 Prism 교체(`base.html`+`prism-app.css`), 사용설명서 v0.2.12, 전체 회귀, 운곡교 P3·P4 expected 쪽 A등급 반영(P3 29·P4 34필드, params 쪽은 P7로 막힘, 묶음 불일치 10/11 그대로, 필드 차이 69→52·75→63). **남은 것**: 표 후보 검토·반영과 params 독립 판독(별도 Task), 운곡교 P4 도면 판(#33)")
op.write_text(t, encoding="utf-8")
print("ok")
@@ -0,0 +1,168 @@
"""Manual v0.2.11 -> v0.2.12 (2026-10-19): the 입력 묶음 확인·확정 개편 (stage A-D). The v0.2.11 file is copied to backup/ by the caller first;
this script writes the new file next to it. Refuses to run when a replacement does not match exactly (the manual changed under it)."""
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-15_v0.2.11.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-19_v0.2.12.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:80]
s = s.replace(a, b)
rep("| v0.2.11 | 2026-10-15 | 심영표 | 새재1교 P1 코핑·받침좌 철근(G4) 반영: 1.1 형식 표, 7.1 항목 수, 9 한계 | 사용자 지시 \"진행해줘\" |",
"| v0.2.11 | 2026-10-15 | 심영표 | 새재1교 P1 코핑·받침좌 철근(G4) 반영: 1.1 형식 표, 7.1 항목 수, 9 한계 | 사용자 지시 \"진행해줘\" |\n"
"| v0.2.12 | 2026-10-19 | 심영표 | 입력 묶음 확인·확정 개편(A~D단계) 반영: 1.1·1.2(6번째 템플릿 `산외교울산P2형`, 앱 확인·표 밖 치수 후보), 4 화면 구성, 5.1(도면 먼저 추천)·5.3·5.4(확정 방법 전면 개정, 이전 방식 일괄 확정 삭제), 6.1·6.5 파일, 8, 9, 부록 A·B. 화면 디자인 Prism 적용 | 사용자 지시 \"D단계 착수해줘\" |")
# ---- 1.1
rep("| 3 | 입력 묶음 확인·확정 (\"이 값이 도면과 맞다\"는 기록) | 사용자 |",
"| 3 | 입력 묶음 **앱 확인**(표·수량·고정 항목 대조) → 사람이 근거를 보고 **확정** (\"이 값이 도면과 맞다\"는 기록) | 앱이 확인, 사용자가 확정 |")
rep("| 2 | 도면 표에서 읽은 값과 입력값 비교, 표 밖 치수는 문자 검색으로 찾기 | 사용자 + 앱 |",
"| 2 | 도면 표에서 읽은 값과 입력값 비교, 표 밖 치수는 후보(같은 도면 틀일 때)·문자 검색으로 찾기 | 사용자 + 앱 |")
rep("- 지원하는 T형 교각 형식은 다섯 가지입니다.", "- 지원하는 T형 교각 형식은 여섯 가지입니다. 도면을 먼저 올리면 가장 비슷한 형식을 추천해 줍니다(5.1절).")
rep("| `새재1교P1형` | 직접기초(말뚝 없음)", "| `산외교울산P2형` | 강관말뚝 Ø508 6×5 기초(**평면 37° 회전**) · 원형 기둥 Ø2.5 · 직선 경사 코핑(끝면 300 경사, 상면 130 단차, 평면 모서리 R500) · 받침 4개 · 철근 G2 링(주철근 68) · G3 기초(B7 320본은 추정) · G4 코핑·받침(철근별 중심선 372본) | 산외교 울산 P2 |\n| `새재1교P1형` | 직접기초(말뚝 없음)")
rep("- 도면 판독은 **반자동**입니다. 표 3종(받침 ELEVATION TABLE, 솔플레이트 TABLE, 철근재료표)만 자동으로 읽고, 나머지 치수는 사용자가 확인·입력합니다.",
"- 도면 판독은 **반자동**입니다. 표 3종(받침 ELEVATION TABLE, 솔플레이트 TABLE, 철근재료표)은 자동으로 읽습니다. 표 밖 치수는 같은 도면 틀(예: 운곡교 P1~P4)이면 **후보**로 찾아 주지만(5.3절), 철근 위치 목록·코핑 외곽 같은 값은 앱이 읽지 못하므로 사용자가 확인·입력합니다.\n"
"- **앱은 확정하지 않습니다.** 앱 확인 '일치'는 \"독립 근거(도면 표·재료표 수량 등)와 값이 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다.")
# ---- 4 화면 구성
i = s.index("## 4. 화면 구성")
j = s.index("## 5. 따라 하기")
s = s[:i] + """## 4. 화면 구성
화면은 Prism 디자인(Pretendard 글꼴)으로 통일되어 있습니다. 위쪽 띠에 앱 이름과, 교각을 연 뒤에는 그 교각의 메뉴(문자 검색·표 후보값·게이트)가 있습니다.
| 화면 | 들어가는 법 | 주요 버튼·기능 |
| :--- | :--- | :--- |
| 교각 목록 | 첫 화면 | 교각 선택, **도면으로 템플릿 추천**(도면을 먼저 올려 순위·근거 확인), **새 교각 만들기**, **삭제**(이름 입력 후), 아래쪽 **보호 템플릿** 표 |
| 도면으로 템플릿 추천 | 교각 목록 | 도면 올리기 → 템플릿 순위·추정 근거 → 6문항·각도 값 확인 → 판정 → 교각 만들기 |
| 교각 | 목록에서 교각 이름 클릭 | 1. 도면 **올리기·변환** / 2. **요약 띠**(앱 확인 결과 개수), **[전체 확인]**, **[일치 묶음 일괄 확정]**, 입력 묶음 표(변수 등급·값 출처·앱 확인·사람 확정) / 3. 게이트 현황, **일괄 승인** |
| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 8종 검사별 결과·비교한 내용·도면 위치, 사유와 함께 **확정** |
| 일치 묶음 일괄 확정 | 교각 화면의 버튼 | 대상 묶음의 근거표(끝까지 내려야 활성화), 제외된 묶음과 사유, 확정자 입력 |
| 도면 문자 검색 | 위쪽 메뉴 | 글자·숫자 검색, 정규식 검색. 도면(D1~D3)·레이어·좌표 표시. 아래쪽 **핸들 조회** |
| 표 후보값 | 위쪽 메뉴 | **표 밖 치수 후보**(앵커, 등급 A/B/C, **도면 보기**), 도면 **치수 사슬 검산**, 도면 표에서 읽은 값과 현재 값 비교, **선택한 후보값 반영** |
| 게이트 | 위쪽 메뉴 | **일괄 승인**, 게이트별 **실행**·**승인**·**반려**, **모델 내려받기**, **보고서 초안 생성** |
| 묶음 편집 | 교각 화면의 묶음 이름 클릭 | 변수 등급·값 출처 표시, 값(JSON) **저장**(고정(F) 항목을 바꾸면 사유 필요, 생성(V3) 묶음은 저장 불가), **저장된 값으로 확정**(사유 칸 포함) |
판정 표시는 색과 글자·기호를 함께 씁니다. ● 일치·PASS(초록), ▲ 주의·계획 FAIL(황), ■ 불일치·FAIL(빨강), ◇ 근거 없음·미확정(점선 테두리), — 해당 없음(회색).
---
""" + s[j:]
# ---- 5.1
rep("1. 교각 목록 아래 **새 교각**에서 이름(예: `운곡교p2`)을 입력합니다.",
"**도면을 먼저 올려 추천받기(권장)**: 교각 목록의 **도면으로 템플릿 추천**에서 DWG를 올리고 [도면 읽고 추천 받기]를 누르면, 도면 문자에서 기초 형식(말뚝 유무)·코핑 하면 형상·받침 개수를 추정해 보호 템플릿을 일치 문항 수 순으로 보여 줍니다(추정 근거와 도면 위치 표시, 맞는 템플릿이 없으면 '판단 불가'). 아래 6문항(철근 형상·생성 방식은 사람이 답함)과 각도 값(코핑 skew·받침 회전·기초 회전)을 확인하면 판정이 나오고, 이름을 넣고 [교각 만들기]를 누르면 교각 폴더가 만들어지며 올린 도면이 그 안으로 옮겨집니다. 답이 템플릿과 다르면 [그래도 만들기]로 진행할 수 있고, 그 교각은 모든 화면 위에 닫을 수 없는 '형식 불일치 경고'가 표시됩니다.\n\n"
"**도면 없이 만들기**\n\n1. 교각 목록 아래 **새 교각**에서 이름(예: `운곡교p2`)을 입력합니다.")
rep("말뚝 없는 직접기초에 대좌 5개를 앞뒤 받침이 함께 쓰는 형식이면 `새재1교P1형`.", "말뚝 없는 직접기초에 대좌 5개를 앞뒤 받침이 함께 쓰는 형식이면 `새재1교P1형`, 강관말뚝 기초가 37° 회전한 직선 경사 코핑 형식이면 `산외교울산P2형`.")
rep("4. **[만들기]**를 누릅니다. 템플릿의 params·expected가 복사되어 출발점이 되고, 교각 목록의 '템플릿' 칸에 \"템플릿: 산외교울산P1형\"처럼 표시됩니다.",
"4. **[만들기]**를 누릅니다. 템플릿의 params·expected가 복사되어 출발점이 되고, 교각 목록의 '템플릿' 칸에 \"템플릿: 산외교울산P1형\"처럼 표시됩니다. 이때 변수 등급 파일(`variability.json`), 표 밖 치수 앵커(`anchors.json`), 묶음별 원본 해시(`pier.json`의 `origin`)도 함께 복사·기록됩니다.")
rep("> 주의: 템플릿 값은 **원본 교각의 도면 값**입니다. 복사한 값 그대로 게이트를 실행하면 원본 교각의 모델이 만들어질 뿐입니다. 반드시 5.3절대로 새 도면 값으로 바꾼 뒤 확정하세요.",
"> 주의: 템플릿 값은 **원본 교각의 도면 값**입니다. 복사한 값 그대로 게이트를 실행하면 원본 교각의 모델이 만들어질 뿐입니다. 앱은 복사한 뒤 한 번도 바뀌지 않은 묶음을 \"▲ 템플릿 원본 그대로\"로 표시하고 앱 확인을 '근거 없음'으로 두며, 이런 묶음은 일괄 확정 대상에서 빠집니다. 반드시 5.3절대로 새 도면 값으로 바꾼 뒤 확정하세요.")
# ---- 5.3
rep("1. **표 후보값**: 위쪽 메뉴 [표 후보값]에서 '다름'인 줄을 도면과 대조합니다.",
"0. **앱 확인 보기**: 교각 화면 2절 맨 위 요약 띠와 묶음 표의 「앱 확인」 열에서 어느 묶음이 일치·불일치·근거 없음인지 봅니다. 불일치 묶음의 [근거 보기]에서 무엇이 어떻게 다른지(예: \"현재 42686 ≠ 표 41747\") 알 수 있습니다.\n"
"1. **표 후보값**: 위쪽 메뉴 [표 후보값]에서 '다름'인 줄을 도면과 대조합니다.")
rep("2. **문자 검색**: 표 밖의 치수는 [도면 문자 검색]에서 찾습니다.",
" - **표 밖 치수 후보**: 같은 도면 틀(예: 운곡교 P1~P4)이면 [표 후보값] 맨 위 「표 밖 치수 후보」에 템플릿 원본 교각이 값을 읽은 문자 위치를 새 도면에서 따라간 후보가 나옵니다. 등급 **A**(같은 치수가 여러 곳에서 같게 읽힘, 표 값과도 일치하면 표시) · **B**(한 곳 또는 일부만, 약 15 %는 틀림) · **C**(근거 약함, 반영 시 사유 필요). [도면 보기]로 그 위치를 확대 이미지로 확인합니다. 도면 틀 일치율이 70 % 미만이면 모든 후보가 C로 내려갑니다. 앵커가 없는 교각은 같은 화면에서 템플릿을 골라 앵커를 불러옵니다. **[선택한 후보 반영]**은 선택한 줄에만 적용되고, 같은 도면 문자에서 나온 후보를 params와 expected에 함께(따로 해도) 반영할 수 없습니다. 「빠른 선택」으로 A등급 후보를 expected만/params만 한 번에 고를 수 있습니다.\n"
" - **치수 사슬 검산**: 도면의 `n@간격=합계` 표기를 검산해(개수 × 간격 = 합계) 맞지 않는 것을 교각 화면과 [표 후보값]에 경고합니다(도면 자체의 오류일 수 있음).\n"
"2. **문자 검색**: 표 밖의 치수는 [도면 문자 검색]에서 찾습니다.")
# ---- 5.4
i = s.index("### 5.4 확정")
j = s.index("### 5.5 게이트 실행과 승인")
s = s[:i] + """### 5.4 확정
확정은 **"내가 이 값을 도면과 대조했다"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.
**앱 확인 결과** — 교각 화면 2절 맨 위 요약 띠: "필요 26개 중 앱 확인 일치 9 · 주의 4 · 불일치 0 · 근거 없음 10 · 해당 없음 3 · 사람 확정 0". [전체 확인]을 누르면 앱이 다시 확인합니다(값·도면이 바뀌면 자동으로도 다시 확인).
| 앱 확인 | 뜻 | 확정 방법 |
| :--- | :--- | :--- |
| ● 일치 | 독립 근거(도면 표·재료표 수량·표 밖 치수 A등급·템플릿 고정 값)와 값이 같다 | [일치 묶음 일괄 확정] 가능 |
| ▲ 주의 | 수량만 확인했거나, 계획된 예외·B등급 후보와 다름 등 | 근거 보기에서 **사유와 함께** 개별 확정 |
| ■ 불일치 | 값이 도면 표·후보와 다르다 | 값을 고치거나, 사유와 함께 개별 확정 |
| ◇ 근거 없음 | 템플릿 원본 그대로이거나 앱이 읽지 못하는 값(철근 위치·코핑 외곽·표 밖 치수) | 도면과 직접 대조한 뒤 **사유와 함께** 개별 확정 |
| — 해당 없음 | 참고 정보·사람 결정 기록(계획 FAIL, 철근 색 등) | 참고 정보는 확정 불필요, 결정 기록은 개별 확정 |
'일치'는 "독립 근거와 같다"는 뜻이지 "도면이 맞다"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.
**일치 묶음 일괄 확정**
1. 교각 화면의 **[일치 묶음 일괄 확정 (○개)]**를 누릅니다(대상이 없으면 버튼 대신 안내만 나옵니다).
2. 대상 묶음의 **근거표를 끝까지 내려** 읽습니다(내리기 전에는 확정자 입력·확정 단추가 막혀 있습니다). 제외된 묶음은 접힌 칸에서 사유를 볼 수 있습니다.
3. **확정자** 이름을 넣고 확정합니다. 앱 확인이 '일치'인 묶음만 확정되며, 확정 기록에는 입력한 이름과 당시 앱 확인 상태가 남습니다. 근거표를 읽은 뒤 대상 목록이 바뀌면(값 수정 등) 다시 읽어야 합니다.
**개별 확정(근거 보기)**: 묶음 이름 옆 **[근거 보기]**에서 앱 확인 8종(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성)의 결과와 비교 내용·도면 위치를 보고, 확정자와 **사유**를 넣어 확정합니다. 앱 확인이 일치가 아니면 사유가 없을 때 확정되지 않습니다. 묶음 편집 화면 아래의 [저장된 값으로 확정]도 같은 규칙입니다.
**변수 등급과 편집 규칙** (묶음 표의 「변수 등급」 열)
- **V1 값 변수**·**V2 목록 변수**: 도면마다 달라지는 값. 자유롭게 고칩니다.
- **V3 생성 변수**: 판독 스크립트가 만든 긴 목록(철근별 중심선 등). 화면에서 저장할 수 없습니다. 판독 스크립트를 다시 실행해 값을 만드세요.
- **F 고정**: 형식을 정의하는 값(`scope`, `builders`, `column.shape`, `coping.form` 등). 바꾸면 같은 템플릿이 아닐 수 있어 **사유를 입력해야** 저장되고, 사유는 `edit_log.json`에 기록됩니다.
**확정 대상**: 치수·철근·기대값 묶음과 **실행 설정 묶음**(`scope`·`builders`·`checkers`·`negative`)입니다. 결과를 바꾸므로 확정해야 게이트를 실행할 수 있습니다. 이 묶음이 없는 교각(운곡교 형식)은 기본값을 쓰며 확정이 필요 없습니다.
**확정 불필요(참고)**: `_schema`, `_meta`, `_note`, `○○_src` 같은 형식·출처 메모 묶음은 게이트가 쓰지 않습니다. 확정하지 않아도 됩니다.
- 확정 뒤 값을 고치면 그 묶음은 자동으로 '변경됨 · 재확정 필요'가 되고, 승인된 게이트도 '재실행 필요'가 됩니다.
- **이전 방식의 "필요한 묶음 일괄 확정"(앱 확인 없이 전부 확정)은 2026-10-19에 없어졌습니다.** 확정은 위의 두 방법뿐입니다.
""" + s[j:]
# ---- 6.1 / 6.5
rep("| `confirm.json` | 묶음별 확정 기록 (확정자, 시각) |",
"| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |\n"
"| `autocheck.json` | 앱 확인 결과 (묶음별 일치·주의·불일치 등과 검사 근거). 확정 기록과 별개이며 앱은 확정하지 않음 |\n"
"| `variability.json` | 묶음별 변수 등급(V1·V2·V3·F)과 값 출처 (템플릿에서 복사, 담당자 확정 전 초안) |\n"
"| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |\n"
"| `edit_log.json`, `anchor_log.json` | 고정 항목 변경 사유 기록, 표 밖 치수 후보 반영 기록 |\n"
"| `confirm_view.json` | 일치 묶음 일괄 확정에서 근거표를 끝까지 읽었다는 표식 |\n"
"| `drawings\\view_cache\\` | [도면 보기] 이미지 캐시 |")
rep("| `pier.json` | 교각 이름, 형식, 만든 날, 템플릿 |", "| `pier.json` | 교각 이름, 형식, 만든 날, 템플릿, 템플릿 복사 기록(`origin`), 템플릿 적합성 답(`fit_answers`·`fit_mismatch`) |")
rep("| `template.json` | 형식 설명, 원본 교각, 저장일, 입력 해시 |", "| `template.json` | 형식 설명, 원본 교각, 저장일, 입력 해시 |\n| `variability.json`, `anchors.json` | 묶음별 변수 등급, 표 밖 치수 앵커(원본 교각의 도면에서 학습) |")
# ---- 8
rep("| \"실행 불가: ○○ 미확정\" | 필요한 묶음이 미확정 | 교각 화면에서 일괄 확정 또는 해당 묶음 확정 |",
"| \"실행 불가: ○○ 미확정\" | 필요한 묶음이 미확정 | 교각 화면에서 [일치 묶음 일괄 확정] 또는 근거 보기에서 해당 묶음 확정(5.4절) |\n"
"| [일치 묶음 일괄 확정]이 없고 \"확정할 '일치' 묶음이 없습니다\" | 앱 확인이 '일치'인 미확정 묶음이 없음 | 남은 묶음은 근거 보기에서 사유와 함께 개별 확정 |\n"
"| \"사유를 입력해야 확정할 수 있습니다\" | 앱 확인이 일치가 아닌 묶음 | 도면과 대조한 내용을 사유에 적어 확정 |\n"
"| \"고정(F) 항목을 바꾸면 … 사유를 입력하세요\" | 고정 항목 변경 | 정말 형식이 같은지 확인하고 사유를 적음. 형식이 다르면 다른 템플릿으로 시작 |\n"
"| \"생성 변수(V3)는 화면에서 직접 고칠 수 없습니다\" | 철근별 중심선 목록 등 | 판독 스크립트를 다시 실행해 값을 만듦 |\n"
"| \"근거표를 열어 끝까지 읽은 뒤에 확정할 수 있습니다\" | 일괄 확정 근거표를 끝까지 내리지 않음, 또는 읽은 뒤 대상이 바뀜 | 일괄 확정 화면에서 근거표를 끝까지 내려 다시 확정 |\n"
"| 후보 화면에 \"이 교각에는 앵커가 없습니다\" | 템플릿 없이 만든 교각 | 같은 도면 틀의 템플릿을 골라 앵커 불러오기 |\n"
"| 후보가 전부 C등급 | 도면 틀 일치율 70 % 미만(다른 도면 틀) | 후보를 근거로 쓰지 말고 표·문자 검색으로 직접 확인 |\n"
"| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |")
# ---- 9
rep("- **확정·승인의 의미**: 확정과 승인은 사람의 검토 기록입니다. 일괄 기능은 버튼을 누른 사람의 이름으로 기록합니다.",
"- **확정·승인의 의미**: 확정과 승인은 사람의 검토 기록입니다. 일괄 기능(일치 묶음 일괄 확정, 일괄 승인)은 버튼을 누른 사람의 이름으로 기록합니다.\n"
"- **앱 확인의 한계**: '일치'는 독립 근거와 같다는 뜻입니다. 철근 위치 목록·코핑 외곽·표 밖 치수는 아직 대조하지 못해 '근거 없음'으로 남고, 철근 목록은 수량만 확인해 '주의'가 됩니다. C8(재생성 일치)은 스크립트가 연결되어 있지 않아 '해당 없음'입니다. 기존 교각은 템플릿 복사 기록이 없어 '템플릿 원본 그대로'를 판단하지 않습니다.\n"
"- **표 밖 치수 후보의 한계**: 같은 도면 틀에서만 작동합니다(시험: 같은 틀 A등급 45건 중 오답 0, B등급 약 14 % 오답, 다른 틀은 A·B 후보 없음). 1 m 미만 값과 계산해서 얻는 값은 앵커가 없고, 철근 점·선과 콘크리트 외곽 점열은 시험 결과 채택하지 않았습니다. 자기 도면에서 만든 앵커는 근거로 쓰지 않습니다.")
rep("- 남은 개선 과제: 미해결 목록 #18(Rhino 실행 정체), #19(일반화), #22(울산 추정 사항), #23(Ramp-A 추정 사항), #24(옥성교) — `context-migration\\OPEN_미해결목록.md`",
"- **울산 P2 형식의 추정 사항**: 기초 회전 방향(반시계)·말뚝(속찬 원기둥)·솔플레이트(두꺼운 쪽 +Y)·대좌 fck는 사용자 확인 하에 추정을 적용했고, 기초 철근 B7 320본 배치와 B8 띠철근 길이 불일치는 설계자 확인 대상입니다(미해결 목록 #32, 판독서 `분석_산외교울산P2교각도면판독_2026-10-15_v0.1.02.md`).\n"
"- 남은 개선 과제: 미해결 목록 #18(Rhino 실행 정체), #19(일반화), #22(울산 추정 사항), #23(Ramp-A 추정 사항), #24(옥성교), #26(입력 묶음 개편 마무리), #32(울산 P2), #33(운곡교 P4 도면 사슬) — `context-migration\\OPEN_미해결목록.md`")
# ---- appendix
rep("| 보호 템플릿 저장·갱신 (교각 이름 대신 폴더 경로도 가능) | `python tools\\make_template.py <교각 이름> <템플릿 이름> \"<형식 설명>\"` |",
"| 보호 템플릿 저장·갱신 (교각 이름 대신 폴더 경로도 가능) | `python tools\\make_template.py <교각 이름> <템플릿 이름> \"<형식 설명>\"` |\n"
"| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |\n"
"| 템플릿 표 밖 치수 앵커 만들기 | `python tools\\make_anchors.py [--force]` |\n"
"| 실제 교각의 앱 확인 분포 보기 (data를 건드리지 않음) | `python tools\\report_autocheck.py [교각 ...]` |\n"
"| 앵커 후보를 표 값으로 교차 검증 | `python tools\\report_anchor_pier.py <교각> <템플릿> [--detail]` |\n"
"| 시험 일괄 실행 (Rhino 없는 시험 `fast`, 실제 Rhino 시험 `rhino`) | `python tools\\run_all_tests.py <fast\\|rhino\\|all>` |")
rep("| 확정 | 묶음 값을 도면과 대조했다는 기록 |",
"| 확정 | 묶음 값을 도면과 대조했다는 사람의 기록(앱은 확정하지 않음) |\n"
"| 앱 확인 | 앱이 묶음 값을 도면 표·수량·고정 항목과 대조한 결과(일치·주의·불일치·근거 없음·해당 없음) |\n"
"| 근거 보기 | 앱 확인 8종 검사의 결과와 비교 내용을 보는 화면 |\n"
"| 변수 등급 | V1 값 · V2 목록 · V3 생성(화면 편집 불가) · F 고정(바꾸면 사유 필요) |\n"
"| 템플릿 원본 그대로 | 템플릿을 복사한 뒤 한 번도 바뀌지 않은 묶음(새 도면 근거가 없는 상태) |\n"
"| 표 밖 치수 | 표 3종에 없는 치수·EL. 같은 도면 틀이면 앵커로 후보를 찾음 |\n"
"| 앵커 | 원본 교각이 값을 읽은 도면 문자의 위치(레이어·좌표) 기록 |\n"
"| 후보 등급 A·B·C | A 여러 곳이 같게 읽힘 · B 한 곳 또는 일부 · C 근거 약함 |")
DST.write_text(s, encoding="utf-8")
print("written", DST.name, len(s.splitlines()), "lines")
@@ -0,0 +1,86 @@
"""2026-10-19: the old "필요한 묶음 일괄 확정" (confirm_all: confirms every required bundle without looking at the app check) is removed by user
decision. Bundles are confirmed by 일치 묶음 일괄 확정 (D4) or one by one with a reason. The tests that used confirm_all now call
tools/testlib.confirm_required (one confirmation per required bundle, with a reason). Backups by the caller. Refuses to run twice."""
import re
from pathlib import Path
APP = Path(__file__).resolve().parents[2]
T = APP / "tools"
def sub(path, pairs, must=True):
s = path.read_text(encoding="utf-8")
for a, b in pairs:
if must:
assert s.count(a) >= 1, (path.name, a[:70])
s = s.replace(a, b)
path.write_text(s, encoding="utf-8")
# ---- app.py: remove the route
p = APP / "web" / "app.py"
s = p.read_text(encoding="utf-8")
assert '@app.post("/pier/<name>/confirm_all")' in s, "already removed"
i = s.index('@app.post("/pier/<name>/confirm_all")')
j = s.index("# ---------------- gates", i)
s = s[:i] + s[j:]
p.write_text(s, encoding="utf-8")
# ---- pier.html: remove the folded legacy form, update the hints
p = APP / "web" / "templates" / "pier.html"
s = p.read_text(encoding="utf-8")
i = s.index("{% if need %}\n<details>")
j = s.index("{% else %}<p class=\"ok\">게이트에 필요한 묶음이 모두 확정되어 있습니다.</p>{% endif %}")
s = s[:i] + "{% if need %}<p class=\"muted\">게이트에 필요한 묶음 {{ need|length }}개가 아직 확정되지 않았습니다. 앱 확인이 '일치'인 묶음은 위의 [일치 묶음 일괄 확정]으로, 나머지는 '근거 보기'에서 사유와 함께 하나씩 확정합니다.</p>\n" + s[j:]
s = s.replace("먼저 위의 '필요한 묶음 일괄 확정'을 하세요. ", "먼저 위의 묶음을 확정하세요. ")
p.write_text(s, encoding="utf-8")
# ---- tests
imp = "from testlib import confirm_required # noqa: E402"
for name in ("test_web_batch", "test_web_ulsan", "test_web_ulsan_full", "test_ulsan_e2e", "test_okseong", "test_rampa_web", "test_confirm_required",
"test_pier_batch_button", "test_variability"):
path = T / f"{name}.py"
s = path.read_text(encoding="utf-8")
assert "confirm_required" not in s, name
# helper import after the last top-level import line of the header (before the first `ok = True` / def / check usage): put it after sys.path setup
m = re.search(r"^import app as webapp.*$", s, re.M) or re.search(r"^from app import.*$", s, re.M)
assert m, name
s = s[:m.end()] + "\nsys.path.insert(0, str(Path(__file__).resolve().parent))\n" + imp + s[m.end():]
path.write_text(s, encoding="utf-8")
print("imports added")
sub(T / "test_web_batch.py", [
(''' r = c.post(f"/pier/{P}/confirm_all", data={"by": ""}, follow_redirects=True)
check("bulk confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0)
r = c.post(f"/pier/{P}/confirm_all", data={"by": BY}, follow_redirects=True)
''', ''' r = c.post(f"/pier/{P}/section/{need0[0][0]}/{need0[0][1]}/confirm", data={"by": "", "why": "시험"}, follow_redirects=True)
check("confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0)
confirm_required(c, webapp, store, P, BY)
'''),
('check("bulk confirm: all required sections confirmed"', 'check("confirm: all required sections confirmed"'),
('check("bulk confirm: sections not required stay unconfirmed"', 'check("confirm: sections not required stay unconfirmed"'),
(' c.post(f"/pier/{P}/confirm_all", data={"by": BY})\n', ' confirm_required(c, webapp, store, P, BY)\n'),
])
for name, pat, rep in (
("test_web_ulsan", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_web_ulsan_full", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_ulsan_e2e", 'c.post(f"/pier/{P}/confirm_all", data={"by": BY})', "confirm_required(c, webapp, store, P, BY)"),
("test_okseong", 'c.post("/pier/옥성/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "옥성", "시험")'),
("test_rampa_web", 'c.post("/pier/램프/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "램프", "시험")'),
("test_confirm_required", 'c.post("/pier/울산/confirm_all", data={"by": "시험"})', 'confirm_required(c, webapp, store, "울산", "시험")'),
("test_pier_batch_button", 'c.post("/pier/버튼P/confirm_all", data={"by": "시험자"})', 'confirm_required(c, webapp, store, "버튼P", "시험자")'),
("test_variability", 'r = c.post("/pier/원본시험/confirm_all", data={"by": "시험자"})', 'confirm_required(c, webapp, store, "원본시험", "시험자")'),
):
sub(T / f"{name}.py", [(pat, rep)])
for name in ("test_okseong", "test_rampa_web", "test_confirm_required", "test_variability"):
sub(T / f"{name}.py", [("confirm_all clears", "confirming every required bundle clears"), ("after confirm_all", "after confirming every required bundle"),
("confirm_all still confirms required bundles", "confirming every required bundle one by one works")], must=False)
sub(T / "test_autocheck.py", [(
'''r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"})
check("legacy confirm_all still confirms the required bundles", not webapp.unconfirmed(store.Pier("신규시험")))''',
'''r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"})
check("the old 필요한 묶음 일괄 확정 route is gone (2026-10-19)", r.status_code in (404, 405), r.status_code)
confirm_required(c, webapp, store, "신규시험", "시험자")
check("every required bundle can still be confirmed one by one with a reason", not webapp.unconfirmed(store.Pier("신규시험")))'''),
("from engine import verify # noqa: E402", "from engine import verify # noqa: E402\nsys.path.insert(0, str(Path(__file__).resolve().parent))\nfrom testlib import confirm_required # noqa: E402")])
print("patched")
@@ -0,0 +1,45 @@
"""2026-10-20: record the implementation of improvements #34 (2) (3) (4) (6): OPEN #34 status, planning document v0.1.09 section 6.7."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md"
NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.09.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_개선구현전.md")
t = OPEN.read_text(encoding="utf-8")
a = "기획서 6.6절 참조 | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "기획서 6.6절 참조. **[갱신 2026-10-20] ②③④⑥ 구현 완료**(앱 확인 B3): ② C9 params↔expected 일관성(`engine/consistency.py`, 규칙 103~106건), ③ C3b 필드 단위 원본 잔존(교각 생성 시 `origin_values.json` 기록, 높이로 보이는 값만 비교), "
"④ 재료표 칸 하나에서만 따라간 앵커 후보를 C등급으로(`anchors.TABLE_RULE=\"single\"`, 확인값 기준 B등급 오답 10/65 → 2/39, A등급 영향 없음; 부작용: 운곡교 P3의 expected:G2·params:column_rebar 가 일치 → 주의/근거 없음으로 내려감), "
"⑥ 점 좌표 목록(`hooks_up` ⊂ `c14.points`) 검사는 C9에 포함. 시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. 남은 것: ①⑤⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |")
OPEN.write_text(t, encoding="utf-8")
shutil.copy2(OLD, R / "DOC" / "01_기획" / "backup" / OLD.name)
s = OLD.read_text(encoding="utf-8")
row = "| v0.1.08 | 2026-10-20 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + "| v0.1.09 | 2026-10-20 | 심영표 | 개선 ②③④⑥ 구현 결과 6.7절 추가 | 사용자 \"#34의 2·3·4·6번 순으로 진행\" |\n" + s[j + 1:]
sec = """### 6.7 개선 ②③④⑥ 구현 결과 (2026-10-20)
| 개선 | 구현 | 시험 결과 |
| :--- | :--- | :--- |
| ② params↔expected 일관성(C9) | `engine/consistency.py`: 같은 양을 나타내는 두 파일 값의 규칙 103~106건(기초·말뚝·기둥, 코핑 윗면 단·하면, 받침 피치·열 위치, 코핑 철근 단·C15·C16·루프·스터럽 하단, 후프·결속 사슬, 기초 철근 층·B9·B10, 재료표 사본). 다르면 해당 묶음이 불일치(b). 규칙의 경로가 없는 교각(새재1교 등)은 건너뜀 | 완성된 P1·P3·P4: 불일치 0. P3 최초 백업 상태(`before_stage2`): 54건 모두 검출, expected G1~G4 전부 불일치. P4 `before_table_apply`: 16건 검출. 다른 형식 4종: 거짓 불일치 0 |
| ③ 필드 단위 원본 잔존(C3b) | 교각 생성 시 `origin_values.json` 기록(이전 교각은 `tools/make_origin_values.py`로 초기 백업에서 재구성). 바뀐 묶음 안에서 높이로 보이는 값(10~60 m, z·단 높이·점 좌표의 z)이 기록값 그대로이면 주의(w) | P3 `before_hooks_up` 상태에서 `hooks_up` 좌표 4개를 정확히 지목. 완성된 상태: 경고 0(처음에는 길이 12400·12000 등을 높이로 오인해 경고가 많았으나 높이 키로 제한) |
| ④ 거짓 앵커 | `engine/anchors.py` `TABLE_RULE="single"`: 후보를 따라간 근거가 재료표 칸 **하나**뿐이면 C등급(`table_only`). 두 방식(`none`: 표 칸을 아예 제외)과 비교 | P3·P4 확인값 기준 B등급 오답: 끔 10/65 · single 2/39 · none 3/29. A등급 오답 0 유지. 부작용: P3에서 expected:G2(일치→주의)·params:column_rebar(일치→근거 없음) 2개 묶음의 근거가 줄어듦(표 칸 하나로 맞던 B등급 후보) |
| ⑥ 점 좌표 목록 | C9 규칙: `c14.hooks_up`의 각 점이 `c14.points`에 있어야 함 | 최초 오류(P3 `before_hooks_up`)의 4점 모두 검출, 점 하나를 옮기면 해당 항목만 깨짐 |
**검사 환경 정리**: 실제 사용 중 `운곡교p2`(휴지통으로 이동)에 의존하던 시험 5종(`test_open19`·`test_rampa`·`test_saejae`·`test_okseong`·`test_ulsan_p2`)과 `test_anchors`를 `tools/testlib.pier_dir`(휴지통 최신 복사본, 대소문자 구분)로 바꿈. `test_autocheck`는 P3가 완성 값을 갖게 되어 최초 백업(다른 교각 값) 상태로 시험하도록 바꿈. 회귀 fast 16/16 PASS(`test_consistency` 추가).
**남은 개선**: ① 읽기 도구 통합, ⑤ 일괄 실행 중단 표시, ⑦ 확정 사유 품질, ⑧ 임시 등록 관리, ⑨ 독립 근거 구분 표시, ⑩~⑫.
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
NEW.write_text(s, encoding="utf-8")
OLD.unlink()
print("ok")
@@ -0,0 +1,47 @@
"""2026-10-20: record improvements #34 (1) (5): OPEN #34 status, planning document v0.1.10 section 6.8."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.09.md"
NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.10.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_개선구현2전.md")
t = OPEN.read_text(encoding="utf-8")
a = "시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. 남은 것: ①⑤⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "시험 `tools/test_consistency.py`(P3·P4 최초 백업의 실제 오류 상태를 모두 잡음, 완성된 P1·P3·P4 거짓 불일치 0), 회귀 fast 16/16. "
"**[갱신 2026-10-20] ①⑤ 구현 완료**: ① `engine/geomread.py`(읽기 규칙 통합, 완성된 P1·P3·P4 에서 읽은 값 = 현재 값, 초기 P4 백업 → 144건 제안이 검증된 값과 같음)와 화면 「도면에서 읽기」(고른 줄만 반영, 백업·`read_log.json`, 다른 도면 틀 거절, 추론 줄은 기본 미선택) 및 `tools/apply_pier_read.py` 가 같은 읽기를 씀, `tools/test_geomread.py`. "
"⑤ 일괄 실행이 끝까지 가지 못했을 때 게이트 화면이 이유(error/fail/blocked/killed)·이전 실행 결과 표시·중간 결과·무효 승인을 보여 줌(`batch.json` 에 `started`·`reason`), `tools/test_batch_notice.py`. 회귀 fast 18/18. 남은 것: ⑦~⑫ | 2026-10-20 | 2026-10-20 | 미해소 |")
OPEN.write_text(t, encoding="utf-8")
shutil.copy2(OLD, R / "DOC" / "01_기획" / "backup" / OLD.name)
s = OLD.read_text(encoding="utf-8")
row = "| v0.1.09 | 2026-10-20 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + "| v0.1.10 | 2026-10-20 | 심영표 | 개선 ①⑤ 구현 결과 6.8절 추가 | 사용자 \"①(읽기 도구 통합), ⑤(일괄 실행 중단 표시)를 진행해줘\" |\n" + s[j + 1:]
sec = """### 6.8 개선 ①⑤ 구현 결과 (2026-10-20)
| 개선 | 구현 | 시험 결과 |
| :--- | :--- | :--- |
| ① 읽기 도구 통합 | `engine/geomread.py`: P3·P4 모델링 때 따로 만든 읽기 도구(코핑 외곽·철근 선·원, 기초·말뚝·기둥 글자, 받침 폴리라인, 재료표, 기초 철근 규칙)를 하나로 합친 순수 함수 `read()`(부작용 없음, 사본과 변경 목록 반환)와 `apply_selected()`. `engine/footing_rule.py`(기초 철근 규칙). 화면 「도면에서 읽기」(`/pier/<이름>/geomread`): 묶음별 현재/읽은 값/근거 표, 체크한 줄만 반영, 반영 전 `backup/before_geomread_*`, `read_log.json`, 추론 줄 기본 미선택, 도면 틀 일치율 70 % 미만 또는 필요한 선·글자가 없으면 이유를 알리고 읽지 않음. `tools/apply_pier_read.py` 는 같은 읽기의 명령줄 판 | `tools/test_geomread.py` PASS: 운곡교 P1·P3 에서 읽은 값 = 현재 값(변경 0), P4 는 추론 줄 1건만, 초기 P4 백업(P1 복사)에서 144건 제안이 검증된 P4 값과 모두 같음(표시 선택 `hidden_ids` 제외), 다른 도면 틀 2종 거절, 화면에서 고른 줄만 기록·백업·로그·확정 무효화 |
| ⑤ 일괄 실행 중단 표시 | `batch.json` 에 `started`·`reason`(error/fail/blocked, 서버 중단은 killed)·`error`·`gate`, 게이트 결과에 `at`. 게이트 화면: 중단 이유 안내, 결과 시각과 '이전 실행 결과' 표시, 중단되기 전에 계산된 검사기 원본(`<게이트>.json` 이 기록(`_gate.json`)보다 새로울 때)을 '중단된 실행의 임시 결과'로 표시(승인·보고서에 쓰이지 않음), 입력이 바뀐 뒤의 승인은 '승인 기록은 이전 입력 것(무효)' | `tools/test_batch_notice.py` PASS: 오류·FAIL·조건 미충족·서버 중단 안내, 이전 실행 결과 표시, 임시 결과, 무효 승인 표시, 완료된 실행에는 안내 없음, 실제 작업자(`_batch`)에 가짜 Rhino 를 연결해 세 중단 경로의 기록 확인 |
**선택과 한계**
- 읽기 결과를 params 와 expected 에 함께 채우는 것은 두 파일의 독립성을 떨어뜨림 → 화면 문구와 앱 확인 C9 설명으로 알림, 표(재료표) 값은 표 후보값 화면에서 따로 대조.
- 표시 선택 `display.hidden_ids` 는 교각 이름이 바뀌면 템플릿의 숨김을 새 이름으로 옮겨 제안(P4 는 사용자가 비움으로 정함).
- 읽기 규칙의 범위: 운곡교 도면 틀 1종, 교각 4개. 좌측 스터럽 윗면(추론)·c14 좌측 규칙·결속철근 끝 높이는 P1 규칙 가정.
- 환경: 일괄 실행 오류 시 중간 결과가 `<게이트>.json` 으로 남는 것을 이용(별도 저장 없음).
**남은 개선**: ⑦ 확정 사유 품질, ⑧ 임시 등록 관리, ⑨ 독립 근거 구분 표시, ⑩ P1 규칙 가정 단계 읽기, ⑪ 도면 해시·개정 번호, ⑫ 다른 도면 틀 일반성.
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
NEW.write_text(s, encoding="utf-8")
OLD.unlink()
print("ok")
@@ -0,0 +1,82 @@
"""2026-10-20: record the Ungok P3 / P4 real-use results and the app improvement list (OPEN #34 / #35, #26 / #33 update, planning document v0.1.08 section 6.6)."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
PLAN_OLD = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-19_v0.1.07.md"
PLAN_NEW = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md"
# ---------------- OPEN
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_P3P4개선전.md")
t = OPEN.read_text(encoding="utf-8")
a = "**남은 것**: 표 후보 검토·반영과 params 독립 판독(별도 Task), 운곡교 P4 도면 판(#33)"
assert t.count(a) == 1
t = t.replace(a, "**[갱신 2026-10-20] 운곡교 P3·P4 실사용 완료**: 도면에서 읽은 값으로 params·expected 반영 후 G1~G5 모두 통과·승인(P3 578/585·P4 577/585, 계획 외 FAIL 0, 계획된 FAIL P3 7·P4 8), 도구 `tools/verify_pier_geometry.py`·`apply_pier_read.py` 추가 — 개선 목록은 #34, 설계자 확인 대상은 #35. **남은 것**: 앱 개선(#34), 설계자 확인(#35)")
# #33 update
b = "확인: 사용자가 보는 파일을 앱에 다시 올려(sha 비교) 오류가 개정본에서 고쳐졌는지 확인"
assert t.count(b) == 1
t = t.replace(b, b + ". **[갱신 2026-10-20]** 사용자가 P4 도면을 다시 올렸으나 해시가 같음(D2 `2ea47f8d81b1…`) → 같은 판. 오류는 그대로. 기초 철근 개수(재료표 65·34본)는 이 사슬과 무관하게 규칙 계산과 일치하므로 모델 값 선택에는 영향 없음(P4 G1~G5 통과)")
row34 = ("| 34 | 앱 개선 목록(운곡교 P3·P4 실사용에서 도출, 효과 큰 순): ① 도면 읽기 도구(`verify_pier_geometry.py`·`apply_pier_read.py`)를 앱 후보 화면에 통합, 템플릿 재현 검증 포함(새 교각당 수작업 100곳 이상 감소) "
"② params↔expected 일관성 검사(Rhino 실행 전, 게이트 1회 약 150초) ③ 묶음 안 필드 단위 '템플릿 원본 그대로 남음' 검사(P3: 앱 확인 불일치 0인데 게이트 40여 건 실패) "
"④ 표 칸에서 학습한 앵커의 거짓 후보 제거(재료표 행이 밀리면 '3.800' 같은 길이 칸을 사슬 끝으로 오인) ⑤ 일괄 실행이 마지막 단계(`display.py`)에서 중단될 때 중간 결과와 이전 승인 요약을 구분해 표시 "
"⑥ 점 좌표를 키로 쓰는 목록(`hooks_up` 등) 정합 검사(P3 G4 코핑 밖 점 56개의 원인) ⑦ 확정 사유 최소 품질(사유 'asf'·'12' 방지) ⑧ 임시 등록(계획된 FAIL) '설계자 확인 대기' 상태 관리와 더는 해당 없는 항목 자동 탐지(P2의 G3 B9 551/493 항목이 P3·P4에 남음) "
"⑨ 보고서에 독립 근거 구분 표시(PASS = 내부 일관성이며 도면 보증이 아님) ⑩ 후프 반경 마크별(표에서 읽음), 스터럽 좌측 윗면·c14 좌측 규칙 같은 P1 규칙 가정 단계의 도면 읽기 ⑪ 도면 파일 해시·개정 번호 기록과 비교 ⑫ 다른 도면 틀·형식(울산·새재1교 등)에서의 읽기 도구 일반성 확인. 기획서 6.6절 참조 | 2026-10-20 | 2026-10-20 | 미해소 | "
"[ref: DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.08.md §6.6] |")
row35 = ("| 35 | 운곡교 P3·P4 설계자 확인 대상(모두 **임시 등록**, 사용자 승인 문구 '설계자 확인 전'): P3 — 재료표 C13 L 28.966(총중량 260.694÷9본과 일치)이 규칙 (max+min)/2 = 29.346과 380 mm 다름. "
"P4 — ① 재료표 B11 30본 대 도면 점 32본(우측 반 작도, 좌우 대칭 가정; P1 28/28·P3 32/32는 일치) ② B12 재료표 38본 대 작도 32본(P1 34/28·P3 38/32와 같은 F2 규칙) "
"③ C9 좌측 스터럽 윗면은 도면에 없어 재료표 C9 b 최대 2.812·최소 1.376 에서 역산한 40447(= 철근 단 1 높이)로 둠(P1·P3의 '소단 − 100' 규칙과 다름) ④ C13 L 28.966(P3와 동일, 개정판 공통 표기 가능성) ⑤ D2 사슬 `62@125=7.250`·`29@250=7.750`(#33). "
"P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목 | 2026-10-20 | 2026-10-20 | 미해소 | "
"[ref: Code/교각모델링앱/data/운곡교P3·P4/expected.json planned_fail, prompt/심영표/2026-10-19/Task_도면도형직접판독시험_2026-10-19_v0.1.00.md] |")
t = t.rstrip("\n") + "\n" + row34 + "\n" + row35 + "\n"
OPEN.write_text(t, encoding="utf-8")
# ---------------- planning document v0.1.08
shutil.copy2(PLAN_OLD, R / "DOC" / "01_기획" / "backup" / PLAN_OLD.name)
s = PLAN_OLD.read_text(encoding="utf-8")
row = "| v0.1.07 | 2026-10-19 |"
i = s.index(row)
j = s.index("\n", i)
s = s[:j + 1] + ("| v0.1.08 | 2026-10-20 | 심영표 | 운곡교 P3·P4 실사용 결과와 앱 개선점 6.6절 추가(OPEN #34·#35) | 사용자 \"앱의 개선점을 알려줘\" → \"진행해줘\" |\n") + s[j + 1:]
sec = """### 6.6 운곡교 P3·P4 실사용 결과와 앱 개선점 (2026-10-20)
**결과 요약**
| 교각 | 입력 | 게이트 결과 | 검사 | 계획된 FAIL | 계획 외 FAIL |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 운곡교 P3 | 도면에서 읽은 값으로 params·expected 갱신(77곳 + 표 후보 105건 + 2차 48곳) | G1~G5 모두 PASS·승인 | 578/585 | 7 (B12, C9~C13 원래 길이 5건, C13 규칙) | 0 |
| 운곡교 P4 | `apply_pier_read.py` 로 144곳 + 표 후보 118건 | G1~G5 모두 PASS·승인 | 577/585 | 8 (B11, B12, C9~C13 원래 길이 5건, C13 규칙) | 0 |
- 첫 실행 → 최종 실행의 계획 외 FAIL: P3 G1 34·G2 7·G3 5·G4 28 → 0, P4 G1 18·G2 10·G3 22·G5 10(허위 G4 PASS) → 0.
- 새 도구: `tools/verify_pier_geometry.py`(도면 값 55항목을 params 와 비교; P1·P3 55/55 로 재현 확인), `tools/apply_pier_read.py`(읽은 값을 params·expected 에 반영; P1·P3 `--dry` 로 재현 확인), `tools/infer_footing_rebar.py`(기초 철근 층 규칙; P1 재현), `tools/extract_coping_outline.py`.
- 읽을 수 있는 값: 표 3종(받침·솔플레이트·재료표), 치수 글자, 코핑 외곽·철근 단·C15·C16, 받침 위치·회전, 기초·말뚝·기둥, 기초 철근 층(규칙 + 재료표 개수 일치), 기둥 철근(후프·결속). 읽을 수 없거나 가정: 스터럽 좌측 윗면, c14 좌측 규칙, 결속철근 끝 높이, 설계 의도.
**앱 개선점 (OPEN #34, 효과 큰 순)**
| # | 개선 | 근거(실제 사례) |
| :--- | :--- | :--- |
| 1 | 도면 읽기 도구를 후보 화면에 통합, 템플릿 재현 검증 포함 | P3 수작업 77곳, P4 144곳 |
| 2 | params↔expected 일관성 검사(Rhino 실행 전) | 게이트 1회 약 150초, P3 실패의 대부분이 옛 값 혼재 |
| 3 | 필드 단위 '템플릿 원본 그대로 남음' 검사 | P3: 앱 확인 불일치 0인데 게이트 40여 건 실패 |
| 4 | 표 칸에서 학습한 앵커의 거짓 후보 제거 | P3·P4: 재료표 S1 길이 칸 '3.800' 을 G3 사슬 끝으로 오인 |
| 5 | 일괄 실행 중단 시 중간 결과·이전 승인 요약 구분 표시 | P4: `display.py` 오류 후 승인 요약이 이전 실행 것으로 남음 |
| 6 | 점 좌표를 키로 쓰는 목록 정합 검사 | P3: `hooks_up` 옛 좌표로 G4 코핑 밖 점 56개 |
| 7 | 확정 사유 최소 품질 | 사유 'asf', 'afs', '12' |
| 8 | 임시 등록 '설계자 확인 대기' 상태 관리, 해당 없음 항목 자동 탐지 | P2 의 G3 B9 551/493 항목이 P3·P4 에 남음 |
| 9 | 보고서에 독립 근거 구분 표시 | PASS = 내부 일관성(params·expected 가 같은 읽기에서 나온 값 다수) |
| 10 | P1 규칙 가정 단계(스터럽 좌측 윗면, c14 좌측 규칙)의 도면 읽기 | P4: 규칙이 틀려 재료표 역산값으로 교체 |
| 11 | 도면 파일 해시·개정 번호 기록·비교 | P4 D2 사슬 오류: 사용자 CAD 판과 올린 파일 불일치 의심 |
| 12 | 다른 도면 틀·형식(울산·새재1교)에서의 일반성 확인 | 교각 2개·템플릿 1개로만 확인 |
**설계자 확인 대상**(OPEN #35): P3 C13, P4 B11·B12·C9·C13, D2 사슬(#33). 모두 임시 등록이며 보고서에 '설계자 확인 전'으로 표시해야 한다.
**이번 작업에서 생긴 오류(재발 방지)**: `apply_pier_read.py` 가 pier_id 를 바꾸면서 `display.hidden_ids` 를 바꾸지 않아 P4 일괄 실행이 마지막 단계에서 중단됨 → 스크립트 수정(개선 3·6 과 같은 종류: 파생 값의 동시 갱신 누락).
---
"""
k = s.index("## 7. 리스크·결정 사항")
s = s[:k] + sec + s[k:]
PLAN_NEW.write_text(s, encoding="utf-8")
PLAN_OLD.unlink()
print("ok")
@@ -0,0 +1,49 @@
"""Manual v0.2.12 -> v0.2.13 (2026-10-20): app improvements #34-2/3/4/6 (C9 params<->expected consistency, C3b field-level 'still the creation value',
point-list rule, table-cell anchors). The v0.2.12 file moves to backup/ after this writes v0.2.13."""
import shutil
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-19_v0.2.12.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.13.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.12 | 2026-10-19 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.13 | 2026-10-20 | 심영표 | 앱 확인 검사 2종 추가(C9 params↔expected 일관성, C3b 필드 단위 원본 잔존)와 점 좌표 목록 검사, 표 칸 하나에서만 따라간 표 밖 치수 후보를 C등급으로 내림: 4 화면 구성, 5.3, 5.4, 6.1, 8, 9, 부록 B | OPEN #34 ②③④⑥ |\n" + s[j + 1:]
rep("| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 8종 검사별 결과·비교한 내용·도면 위치, 사유와 함께 **확정** |",
"| 근거 보기 | 묶음 표의 [근거 보기] | 앱 확인 검사별 결과(C1~C9, C3b)·비교한 내용·도면 위치, 사유와 함께 **확정** |")
rep("앱 확인 8종(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성)",
"앱 확인 검사(C1 출처·C2 표 대조·C2b 표 밖 치수·C3 템플릿 원본·C3b 필드 단위 원본 잔존·C4 수량·C5 params 독립·C6 범위·C7 고정 항목·C8 재생성·C9 params↔expected 일관성)")
rep("| 근거 보기 | 앱 확인 8종 검사의 결과와 비교 내용을 보는 화면 |", "| 근거 보기 | 앱 확인 검사(C1~C9, C3b)의 결과와 비교 내용을 보는 화면 |\n| C9 일관성 | 같은 양을 나타내는 params 값과 expected 값이 서로 같은지 보는 검사. 게이트를 돌리기 전에 \"한쪽만 바뀌고 다른 쪽은 옛 값\"을 찾는다. 같은 도면 읽기에서 나온 값이면 서로 같은 것이 당연하므로 독립 근거는 아니다 |\n| C3b 필드 단위 원본 잔존 | 교각을 만들 때의 값 기록(origin_values.json)과 비교해, 바뀐 묶음 안에서 높이로 보이는 값 몇 개가 옛 값 그대로 남았는지 알려 주는 검사(주의) |")
# 5.4 after the check table note
rep("'일치'는 \"독립 근거와 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.",
"'일치'는 \"독립 근거와 같다\"는 뜻이지 \"도면이 맞다\"는 보증이 아닙니다. 철근 위치·좌표와 표 밖 치수는 아직 앱이 대조하지 못하는 부분이 많습니다.\n\n"
"**검사 2종 추가 (2026-10-20)**\n"
"- **C9 params↔expected 일관성**: 새 도면 값을 넣다가 params만 고치고 expected는 옛 값으로 남기는 실수를 게이트 실행 전에 찾습니다(예: 코핑 윗면 단 높이, 받침 피치, 말뚝 피치·길이, 후프 사슬, 기초 철근 개수와 격자). 다르면 해당 묶음이 **불일치**가 되고 근거 보기에 \"params … = ○ ≠ expected … = ○\"가 나옵니다. 점 좌표로 대상을 지정하는 목록(`coping_rebar.c14.hooks_up`)이 실제 점(`c14.points`)과 맞는지도 같은 검사에서 봅니다. 새재1교·옥성교 같은 다른 형식은 해당하는 규칙만 적용됩니다.\n"
"- **C3b 필드 단위 원본 잔존**: 교각을 만들 때 기록한 값(`origin_values.json`)과 비교해, 이미 바뀐 묶음 안에서 높이로 보이는 값 중 옛 값 그대로 남은 것이 있으면 **주의**로 알려 줍니다(예: 새 도면의 높이로 바꾼 뒤에도 옛 좌표가 남은 점 목록). 이 기록이 없는 교각(앱이 기록을 만들기 전에 만든 교각)은 해당 없음이고, 필요하면 `python tools\\make_origin_values.py <교각> <백업 폴더>`로 초기 백업에서 만들 수 있습니다.")
# 5.3 anchors
rep("**[선택한 후보 반영]**은 선택한 줄에만 적용되고",
"재료표 칸 **하나**에서만 따라간 후보는 재료표 행이 밀려 거짓 후보가 되기 쉬워서(운곡교 P3·P4에서 재료표의 길이 칸 \"3.800\"을 철근 사슬 끝으로 오인) 자동으로 C등급으로 내립니다(운곡교 P3·P4 확인값 기준 B등급 오답 10/65 → 2/39). **[선택한 후보 반영]**은 선택한 줄에만 적용되고")
# 6.1 files
rep("| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |",
"| `anchors.json` | 표 밖 치수 앵커(원본 교각이 값을 읽은 도면 문자 위치). 템플릿에서 복사 |\n| `origin_values.json` | 교각을 만들 때의 params·expected 값 기록(C3b). 교각을 만들 때 앱이 자동으로 쓰며, 이전 교각은 `tools\\make_origin_values.py`로 초기 백업에서 만든다(\"reconstructed\"로 표시) |")
# 8 troubleshooting
rep("| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |",
"| 도면 치수 사슬 경고 | 도면의 `개수@간격=합계`가 맞지 않음 | 도면 자체의 오류일 수 있으니 [도면 보기]로 확인하고 설계자에게 문의 |\n"
"| 근거 보기에 \"C9 … params … ≠ expected …\" | 같은 양을 나타내는 두 파일 값이 다름 | 어느 쪽이 도면 값인지 확인하고 다른 쪽을 고침(묶음 편집). 고친 묶음은 다시 확정 |\n"
"| \"C3b … 생성 시점 값 그대로\" 주의 | 바뀐 묶음 안에서 높이 값 몇 개가 옛 값 그대로 | 해당 값이 새 도면에서도 같은 값이 맞는지 도면과 대조. 맞다면 사유를 적고 확정 |")
# 9 limits
rep("- **표 밖 치수 후보의 한계**:", "- **C9의 한계**: 두 파일의 값이 같다는 것만 확인합니다. 같은 도면 읽기에서 두 파일을 채웠다면 틀린 값도 서로 같을 수 있습니다.\n- **표 밖 치수 후보의 한계**:")
DST.write_text(s, encoding="utf-8")
(DOC / "backup").mkdir(exist_ok=True)
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
print("written", DST.name)
@@ -0,0 +1,48 @@
"""Manual v0.2.13 -> v0.2.14 (2026-10-20): improvements #34-1 (screen 도면에서 읽기) and #34-5 (batch run that did not finish). v0.2.13 moves to backup/."""
import shutil
from pathlib import Path
DOC = Path(__file__).resolve().parents[4] / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.13.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.14.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.13 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.14 | 2026-10-20 | 심영표 | 화면 「도면에서 읽기」 추가(운곡교 도면 틀의 코핑·기초·말뚝·기둥·받침·철근 값을 도면에서 읽어 비교·반영), 일괄 실행이 끝까지 가지 못했을 때의 안내(중단 이유, 이전 실행 결과 표시, 중간 결과, 무효 승인 표시): 4 화면 구성, 5.3, 5.5, 6.1, 8, 9, 부록 A | OPEN #34 ①⑤ |\n" + s[j + 1:]
rep("| 표 후보값 | 위쪽 메뉴 |", "| 도면에서 읽기 | 위쪽 메뉴 | 운곡교 P1 도면 틀(P1~P4)에서 코핑·기초·말뚝·기둥·받침·철근 값을 **도면에서 읽어** 현재 값과 비교하고, **고른 줄만 반영**(반영 전 백업, 반영 기록 `read_log.json`) |\n| 표 후보값 | 위쪽 메뉴 |")
rep("### 5.3 값 확인\n\n입력 값은 두 종류입니다.",
"### 5.3 값 확인\n\n"
"**도면에서 읽기 (운곡교 도면 틀)**: 위쪽 메뉴 [도면에서 읽기]는 운곡교 P1 도면 틀(P1~P4)의 첫 번째 도면(D1)과 철근재료표에서 코핑 윗면 계단·철근 단 높이·C14 점, 기초·말뚝·기둥 값, 받침 위치·회전, 기초 철근 층(규칙 + 재료표 개수), 기둥 철근(후프·결속)을 읽어 현재 값과 비교합니다. "
"묶음별로 \"현재 / 읽은 값 / 근거\" 표가 나오고, **체크한 줄만** [선택한 줄 반영]으로 들어갑니다. 반영 전에 `params.json`·`expected.json`·`confirm.json`을 `backup\\before_geomread_…`에 보관하고 반영한 줄을 `read_log.json`에 남기며, 바뀐 묶음은 다시 확정해야 합니다.\n"
"- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.\n"
"- 읽기 규칙은 모두 완성된 교각(P1)에서 같은 값이 재현되는지 먼저 확인한 것입니다. 이미 완성된 교각(P1·P3·P4)에서는 \"읽은 값이 모두 현재 값과 같습니다\"가 나옵니다.\n"
"- **추론 줄**(좌측 스터럽 윗면 등 도면에 그려져 있지 않은 값)은 기본으로 선택되어 있지 않습니다. 재료표와 대조한 뒤 반영 여부를 정하세요.\n"
"- params와 expected를 같은 도면 읽기로 채우면 둘이 서로 같은 것은 독립 근거가 아닙니다(앱 확인 C9). 표(재료표) 값은 [표 후보값]에서 따로 대조하세요.\n"
"- \"확인이 필요한 점\"에는 도면 점 개수와 재료표 개수가 다른 경우(예: B11·B12), 단순 이동값과 도면 원이 다른 점 등이 나옵니다. 재료표와 작도가 다를 때 작도를 채택하려면 계획된 FAIL 등록이 필요합니다.\n\n"
"입력 값은 두 종류입니다.")
rep("**결과 표 읽는 법**",
"**일괄 실행이 끝까지 가지 못했을 때 (2026-10-20)**: 게이트 화면 위에 빨간 안내가 나옵니다.\n"
"- **이유**: 오류(원인 한 줄 표시), 어느 게이트의 FAIL, 실행 조건 미충족, 서버가 멈춤 중 하나로 알려 줍니다.\n"
"- **이전 실행 결과**: 이번 일괄 실행에서 갱신되지 않은 게이트는 \"이전 실행 결과\"로 표시하고 결과 시각을 함께 보여 줍니다. 그 결과와 승인을 이번 입력의 결과로 읽지 마세요.\n"
"- **임시 결과**: 중단되기 전에 계산된 게이트가 있으면 해당 게이트 표 위에 \"중단된 실행의 임시 결과\"(검사 PASS 수, FAIL 항목)를 따로 보여 줍니다. 계획된 예외를 구분하지 않은 검사기 원본이며 승인·보고서에는 쓰이지 않습니다.\n"
"- 입력이 바뀐 뒤의 옛 승인은 \"승인 기록은 이전 입력 것(무효)\"로 표시합니다.\n\n"
"**결과 표 읽는 법**")
rep("| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |",
"| `confirm.json` | 묶음별 확정 기록 (확정자, 시각, 사유, 확정 당시 앱 확인 상태) |\n| `read_log.json` | 「도면에서 읽기」로 반영한 기록(시각, 반영자, 건수, 백업 폴더, 줄 목록) |")
rep("| \"C3b … 생성 시점 값 그대로\" 주의 |",
"| 게이트 화면 \"이 일괄 실행은 끝까지 가지 못했습니다\" | 오류·FAIL·조건 미충족·서버 중단 | 안내에 적힌 이유를 해결하고 일괄 승인을 다시 실행. 표의 \"이전 실행 결과\"는 옛 결과 |\n| 「도면에서 읽기」가 \"읽지 않습니다\" | 도면 틀이 다르거나 D1에서 필요한 선·글자를 찾지 못함 | 운곡교 P1~P4 도면 틀이 아니면 값을 직접 입력하거나 [표 후보값]을 사용 |\n| \"C3b … 생성 시점 값 그대로\" 주의 |")
rep("- **C9의 한계**:", "- **도면에서 읽기의 한계**: 운곡교 도면 틀 한 종류와 교각 4개(P1·P3·P4와 템플릿)에서만 확인했습니다. 좌측 스터럽 윗면·c14 좌측 규칙·결속철근 끝 높이는 P1 규칙을 가정한 추론이고, 설계 의도(기초 회전 방향 등)는 읽지 못합니다.\n- **C9의 한계**:")
rep("| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |",
"| 템플릿 변수 등급 파일 만들기 | `python tools\\make_variability.py [--force]` |\n| 도면에서 읽은 값 보기·반영(화면 「도면에서 읽기」와 같은 읽기) | `python tools\\apply_pier_read.py <교각> [--dry]` |\n| 도면 값 55항목을 params와 비교 | `python tools\\verify_pier_geometry.py <교각>` |\n| 교각 생성 시점 값 기록을 초기 백업에서 만들기(C3b) | `python tools\\make_origin_values.py <교각> <백업 폴더 이름>` |")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
print("written", DST.name)
@@ -0,0 +1,87 @@
"""2026-10-20: manual v0.2.14 -> v0.2.15 (검토 요약 확정), planning document v0.1.10 -> v0.1.11 (6.9), OPEN #36. Old files move to backup/."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
DOC = R / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.14.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.15.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.14 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.15 | 2026-10-20 | 심영표 | 「검토 요약 확정」 추가(한 화면에서 검토하고 한 번에 확정, 사유 자동 기록): 4 화면 구성, 5.4, 6.1, 8, 9 | 사용자 \"확정 판정에 시간이 너무 걸리고 사용자가 확인하기 어렵다\" → 권고안 진행 |\n" + s[j + 1:]
rep("| 일치 묶음 일괄 확정 | 교각 화면의 버튼 |",
"| 검토 요약 확정 | 교각 화면의 [검토 요약으로 확정] | 확정이 필요한 묶음을 **한 화면**에 모아 \"바뀐 것 · 앱이 확인한 것 · 앱이 확인하지 못한 것 · 값의 출처\"를 보여 주고, 사용자가 **도면과 대조한 묶음에 체크**한 뒤 이름을 넣어 **한 번에 확정**. 불일치 묶음은 확정 불가, 사유는 앱이 자동 기록 |\n| 일치 묶음 일괄 확정 | 교각 화면의 버튼 |")
rep("### 5.4 확정\n\n확정은 **\"내가 이 값을 도면과 대조했다\"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.\n",
"### 5.4 확정\n\n확정은 **\"내가 이 값을 도면과 대조했다\"는 사람의 기록**입니다. 앱은 확정하지 않고, 확정에 쓸 근거를 모아 줍니다.\n\n"
"**권장: 검토 요약으로 확정 (2026-10-20)** — 교각 화면 2절의 [검토 요약으로 확정]을 누르면 확정이 필요한 묶음이 한 화면에 모입니다.\n"
"1. **불일치**(빨간 구역): 확정할 수 없습니다. 값을 고치거나 원인을 해결한 뒤 다시 확인하세요.\n"
"2. **주의·근거 없음**: 묶음마다 \"바뀐 것\"(교각을 만들 때 대비 몇 개 값이 어떻게 바뀌었는지), \"앱이 확인하지 못한 것\", \"값의 출처\"(도면에서 읽기·표 밖 치수 후보 반영 이력)가 나옵니다. **도면과 직접 대조한 묶음에만 체크**하세요. 체크하지 않은 묶음은 미확정으로 남습니다.\n"
"3. **사람 결정 기록**(예: 계획된 FAIL 목록): 내용을 읽고 체크합니다. 설계자 확인 전 임시 등록은 사유에 그렇게 적혀 있는지 보세요.\n"
"4. **앱 확인 일치**: 근거를 훑어보고 맨 아래 \"일치 ○개의 근거를 읽었습니다\"에 한 번 체크합니다.\n"
"5. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.\n"
"- **사유는 앱이 자동으로 기록합니다**: \"[검토 요약 일괄 확정] 사용자가 도면과 대조했다고 표시함. 앱 확인 근거 없음 — … | 변경: … | 출처: … | 의견: …\". 확정 기록에는 그 시점의 앱 확인 상태가 함께 남아 보고서 8.1장에 올라갑니다.\n"
"- 목록이 바뀌었으면(값을 고침) \"끝까지 읽은 뒤에 확정할 수 있습니다\"가 나오므로 화면을 다시 여세요.\n"
"- 체크는 사용자의 선언입니다. 앱은 실제로 대조했는지 확인할 수 없으므로, 체크한 묶음은 도면과 직접 대조한 것이어야 합니다.\n\n"
"아래는 같은 확정을 다른 방법으로 하는 경우입니다.\n")
rep("| `confirm_view.json` | 일치 묶음 일괄 확정에서 근거표를 끝까지 읽었다는 표식 |",
"| `confirm_view.json`, `review_view.json` | 일치 묶음 일괄 확정, 검토 요약 확정에서 목록을 끝까지 읽었다는 표식(그때 목록의 해시) |")
rep("| \"C3b … 생성 시점 값 그대로\" 주의 |",
"| 검토 요약에서 \"끝까지 읽은 뒤에 확정할 수 있습니다\" | 목록을 끝까지 내리지 않았거나 읽은 뒤 값이 바뀌어 목록이 달라짐 | 화면을 다시 열어 끝까지 내림 |\n| 검토 요약에서 \"확정한 묶음이 없습니다\" | 체크한 묶음이 없음(일치 묶음은 맨 아래 \"읽었습니다\" 체크) | 도면과 대조한 묶음에 체크 |\n| \"C3b … 생성 시점 값 그대로\" 주의 |")
rep("- **C9의 한계**:", "- **검토 요약 확정의 한계**: 체크는 사용자의 선언이라 앱이 실제 대조 여부를 확인하지 못합니다. 사유는 앱이 아는 사실로 자동 작성되지만 도면과 무엇을 어떻게 대조했는지는 공통 의견에 직접 적어야 남습니다.\n- **C9의 한계**:")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
# ---- planning document + OPEN
PO = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.10.md"
PN = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md"
shutil.copy2(PO, R / "DOC" / "01_기획" / "backup" / PO.name)
t = PO.read_text(encoding="utf-8")
row = "| v0.1.10 | 2026-10-20 |"
i = t.index(row)
j = t.index("\n", i)
t = t[:j + 1] + "| v0.1.11 | 2026-10-20 | 심영표 | 검토 요약 확정(한 화면 검토 + 한 번 확정) 6.9절 추가 | 사용자 \"확정 판정이 오래 걸리고 확인하기 어렵다 → 앱이 일괄 처리, 사용자는 일괄 검토\" 제안 → 권고안 진행 |\n" + t[j + 1:]
sec = """### 6.9 검토 요약 확정 (2026-10-20)
**문제**: 묶음 20~23개를 하나씩 확정하는 데 시간이 오래 걸리고, 사유가 비거나('12', 'asf') 무엇이 바뀌었는지 보기 어려웠다. 사용자 제안: 앱이 일괄 확정하고 사용자는 일괄 검토만 한다.
**결정(권고안)**: "앱이 확정"은 확정의 의미(사람이 도면과 대조했다는 기록)를 없애므로 채택하지 않고, **앱이 검토를 준비하고 사람이 한 화면에서 검토·체크해 한 번에 확정**한다(D1 유지). 근거 없음 묶음은 **항목별 체크**(a)를 요구, 기존 개별 확정 화면은 예외 처리용으로 유지.
| 요소 | 구현 |
| :--- | :--- |
| 화면 | `/pier/<이름>/review` (교각 화면 [검토 요약으로 확정]). 확정이 필요한 묶음을 네 구역으로: 불일치(확정 불가), 주의·근거 없음(항목별 체크), 사람 결정 기록(체크), 일치(근거 훑어보기 + 구역 체크 1회) |
| 앱이 준비하는 것 | `engine/review.py`: 바뀐 것(교각 생성 시점 값 기록 `origin_values.json` 대비 변경 값 수와 예), 앱이 확인하지 못한 것(근거 없음·주의 검사 항목), 값의 출처(`read_log.json`·`anchor_log.json` 이력), 사유 자동 작성(`auto_reason`) |
| 사람이 하는 것 | 도면과 대조한 묶음에 체크, 목록 끝까지 읽기(표식 `review_view.json` = 그때 목록의 해시), 확정자 이름, 공통 의견(선택) |
| 기록 | 묶음별 확정 기록에 사유(앱 확인·변경·출처·의견 포함), 확정 당시 앱 확인 상태, 이름. 앱은 체크가 없으면 아무것도 확정하지 않음 |
| 안전장치 | 이름·끝까지 읽기·체크 없으면 거절, 값이 바뀌면 표식 무효, 불일치는 요청에 id 가 들어와도 확정하지 않음 |
**시험** `tools/test_review.py`(PASS): 화면 조회만으로는 확정 0, 표식·이름 없으면 거절, 체크 없으면 0, 일치 구역은 구역 체크로만, B·D 는 체크한 것만, 불일치는 거절, 기록에 사유·앱 확인 상태·이름, 값이 바뀌면 표식 무효, 불일치를 고치면 모든 필수 묶음이 이 화면으로 확정되어 게이트가 열림. 회귀 fast 19/19.
**한계**: 체크는 사용자의 선언이라 실제 대조 여부는 앱이 확인할 수 없다. 사유의 "무엇을 어떻게 대조했는지"는 공통 의견에 적어야 남는다. 앞으로 앱 확인 'C10 도구 읽기 이력'을 근거(출처, 독립 검증 아님)로 연결하면 근거 없음 항목이 줄 수 있다(미구현).
---
"""
k = t.index("## 7. 리스크·결정 사항")
t = t[:k] + sec + t[k:]
PN.write_text(t, encoding="utf-8")
PO.unlink()
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
shutil.copy2(OPEN, R / "context-migration" / "backup" / "OPEN_미해결목록_2026-10-20_검토요약전.md")
o = OPEN.read_text(encoding="utf-8")
row36 = ("| 36 | 입력 묶음 확정에 시간이 오래 걸리고 확인이 어려움(사유 '12'·'asf') — **[해소 2026-10-20]** 「검토 요약 확정」 구현: 앱이 바뀐 것·확인하지 못한 것·값의 출처를 한 화면에 모으고 사람이 도면과 대조한 묶음에 체크해 한 번에 확정, 사유는 앱이 자동 기록(`engine/review.py`, `/pier/<이름>/review`, `tools/test_review.py`). "
"'앱이 확정' 방식은 확정의 의미를 없애므로 채택하지 않음. **남은 것**: 체크는 사용자의 선언이라 실제 대조 여부를 앱이 확인하지 못함, 'C10 도구 읽기 이력'(출처 근거) 연결 미구현, 기존 묶음별 개별 확정 화면은 예외용으로 유지 | 2026-10-20 | 2026-10-20 | 해소 | "
"[ref: DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md §6.9] |")
o = o.rstrip("\n") + "\n" + row36 + "\n"
OPEN.write_text(o, encoding="utf-8")
print("ok", DST.name, PN.name)
@@ -0,0 +1,46 @@
"""2026-10-20: manual v0.2.15 -> v0.2.16 (도면에서 읽기 now includes the table values; 검토 요약: bulk check buttons + flags), planning v0.1.12, OPEN #34/#36 notes."""
import shutil
from pathlib import Path
R = Path(__file__).resolve().parents[4]
DOC = R / "DOC" / "04_실행"
SRC = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.15.md"
DST = DOC / "실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.16.md"
s = SRC.read_text(encoding="utf-8")
def rep(a, b):
global s
assert s.count(a) == 1, a[:70]
s = s.replace(a, b)
i = s.index("| v0.2.15 | 2026-10-20 |")
j = s.index("\n", i)
s = s[:j + 1] + "| v0.2.16 | 2026-10-20 | 심영표 | 「도면에서 읽기」가 받침 표·솔플레이트 표·재료표 값까지 한 화면에서 반영(템플릿 원본 값을 한 번에 도면 값으로), 「검토 요약 확정」에 모두 체크·해제 버튼과 주의 표시: 4 화면 구성, 5.3, 5.4 | 사용자 \"템플릿 원본 그대로도 모두 도면에서 읽기로 바꾸고, 미확정도 일괄 확정\" |\n" + s[j + 1:]
rep("- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.",
"- **표 값도 같은 화면에서 반영합니다(2026-10-20).** 읽은 값 아래 \"표 값(받침·솔플레이트·재료표)\" 구역에 받침 표(EL·h1·h2·A·B), 솔플레이트 표, 철근재료표에서 읽은 값 중 현재 값과 다른 줄이 함께 나옵니다. 이 화면 한 번으로 템플릿 원본 값이 도면 값으로 바뀌므로 [표 후보값]을 따로 돌릴 필요가 없습니다(같은 값을 [표 후보값]에서 골라 반영할 수도 있음). 받침마다 값이 다른 솔플레이트 길이(앞열 700, 뒤열 800 등)는 expected에 `{번호: 값}`으로 자동 기록합니다.\n"
"- 도면 틀이 다르면(공통 문자 비율 70 % 미만, 예: 새재1교·옥성교) 읽지 않고 이유를 알려 줍니다.")
rep("5. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.",
"5. **[주의·근거 없음·결정 기록 모두 도면과 대조함으로 체크]** 버튼으로 한 번에 모두 체크할 수 있고([모두 해제]로 되돌림), 템플릿 원본 그대로이거나 설계자 확인 전 임시 등록이 들어 있는 묶음에는 **주의 표시**가 붙습니다. 주의 표시가 붙은 묶음이 있으면 모두 체크할 때 \"○개가 포함됩니다. 모두 도면과 대조했습니까?\" 확인창이 나옵니다. 주의 표시는 확정 기록의 사유(\"주의 표시: …\")에도 남습니다.\n"
"6. 목록을 **끝까지 내리면** 확정자 이름과 공통 의견(선택)을 넣을 수 있고, [선택한 묶음 확정]을 누르면 체크한 묶음이 한 번에 확정됩니다.")
DST.write_text(s, encoding="utf-8")
shutil.copy2(SRC, DOC / "backup" / SRC.name)
SRC.unlink()
PO = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.11.md"
PN = R / "DOC" / "01_기획" / "기획_입력묶음확인확정개편_2026-10-20_v0.1.12.md"
shutil.copy2(PO, R / "DOC" / "01_기획" / "backup" / PO.name)
t = PO.read_text(encoding="utf-8")
row = "| v0.1.11 | 2026-10-20 |"
i = t.index(row)
j = t.index("\n", i)
t = t[:j + 1] + "| v0.1.12 | 2026-10-20 | 심영표 | 6.9절에 후속 변경 추가(도면에서 읽기에 표 값 통합, 검토 요약 모두 체크·주의 표시) | 사용자 \"템플릿 원본 그대로도 모두 도면에서 읽기로 바꾸고, 미확정도 일괄 확정\" |\n" + t[j + 1:]
add = """**후속 변경 (같은 날)**: ① 「도면에서 읽기」가 표 값(받침·솔플레이트·재료표 후보)을 같은 화면에 \"표 값\" 구역으로 포함(`geomread.table_changes`), 반영은 `reader.apply_proposals` 와 같은 규칙, 받침마다 다른 솔플레이트 길이는 `{번호: 값}` 으로 정규화 — 새 교각에서 템플릿 원본 값이 한 화면으로 모두 도면 값이 됨(시험: 초기 P4 백업 + 읽기 + 표 행 = 검증된 P4 의 표 관련 필드, 알려진 예외는 앵커 값·`tie_offset` 상수·낡은 직경 기록). ② 「검토 요약 확정」에 [모두 체크]·[모두 해제] 버튼, 주의 표시(템플릿 원본 그대로·설계자 확인 전 임시 등록 포함)를 칩과 확정 사유에 기록, 주의 표시가 있으면 모두 체크 시 확인창. 불일치 묶음은 여전히 확정 불가, 이름·끝까지 읽기·체크 없이는 확정되지 않음. 앞서 제안한 '낮은 위험/높은 위험 두 단계' 대신 사용자 요청에 따라 일괄 체크 + 주의 표시로 구현(위험도별 분류는 미구현).
"""
k = t.index("**한계**: 체크는 사용자의 선언이라")
t = t[:k] + add + t[k:]
PN.write_text(t, encoding="utf-8")
PO.unlink()
print("ok")
@@ -0,0 +1,157 @@
"""2026-10-20 Step 5: split the working log of the Ungok P3/P4 geometry-reading work into one log per task and day (F8) and write the user inputs verbatim
(INPUT, taken from the session transcript with their time). Reads prompt/심영표/2026-10-19/Task_도면도형판독시험...md (the running notes) and the inputs file."""
import datetime as dt
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[4]
P19 = ROOT / "prompt" / "심영표" / "2026-10-19"
P20 = ROOT / "prompt" / "심영표" / "2026-10-20"
OLD = P19 / "Task_도면도형직접판독시험_2026-10-19_v0.1.00.md"
rows = json.loads((ROOT / "Code" / "교각모델링앱" / "검증" / "_user_inputs.json").read_text(encoding="utf-8"))
def kst(ts):
return dt.datetime.fromisoformat(ts) + dt.timedelta(hours=9)
turns = []
for ts, t in rows:
if t.startswith(("<ide_selection>", "<ide_opened_file>", "[Request interrupted")):
continue
turns.append((kst(ts), t))
def inputs(lo, hi):
out, n = [], 0
for k, t in turns:
if lo <= k < hi:
n += 1
note = "[주석] 이미지 첨부는 텍스트로 옮기지 않음(캡처 화면)" if "[첨부: 이미지]" in t else ""
body = t.replace("[첨부: 이미지]", "[첨부: 이미지(캡처)]")
out.append(f"### [{n}] {k:%Y-%m-%d %H:%M} KST\n```\n[원문]\n{body}\n```\n" + (note + "\n" if note else ""))
return "".join(out), n
text = OLD.read_text(encoding="utf-8").split("\n")
# section boundaries of the running notes
idx = {l: i for i, l in enumerate(text) if l.startswith("## ")}
heads = [l for l in text if l.startswith("## ")]
def find(prefix):
return next(i for i, l in enumerate(text) if l.startswith(prefix))
i_p4_0820 = find("## 운곡교 P4 게이트 결과 검토 (10-20 08:38")
i_impr = find("## 앱 개선 #34 ②③④⑥ 구현")
part_a = text[:i_p4_0820] # 10-19: reading work P3 + first P4 pass
part_b = text[i_p4_0820:i_impr] # 10-20 morning: P4 gate review and decisions
part_c = text[i_impr:] # 10-20: app improvements, review confirmation, P2 / P4 finishing
MODEL = "Claude Sonnet 5.5 (claude-sonnet-5-5) — Task 중 모델 전환 없음"
def header(title, day, task_id, prev, inputs_md, process, decisions, output, n_turns, status, note):
return f"""# {task_id}
| 항목 | 내용 |
|------|------|
| 담당자 | 심영표 |
| 일자 | {day} |
| Task 식별자 | {task_id} |
| 선행 Task | {prev} |
| 경로·모드 | COMPLEX / GATED |
| MODEL | {MODEL} |
| EFFORT | 턴 {n_turns} / 도구호출 미집계(실행환경이 제공하지 않음) |
| STATUS | {status} |
| 비고 | {note} |
## INPUT
{inputs_md}
## PROCESS
{process}
## DECISIONS
{decisions}
## OUTPUT
{output}
"""
# ---------------- log A (10-19)
inp_a, n_a = inputs(dt.datetime(2026, 10, 19, 14, 0), dt.datetime(2026, 10, 20, 0, 0))
body_a = "\n".join(part_a[part_a.index("## PROCESS / OUTPUT"):]) if False else "\n".join(l for l in part_a[9:])
dec_a = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| 값을 어디서 읽나 | 글자·표는 앱이 읽는 그대로, 선·원·폴리라인은 템플릿(P1)에서 재현 확인한 규칙으로 읽음 | 사람이 판독서를 보고 값마다 입력 — 새 교각마다 수작업 100곳 이상 |
| 읽은 값으로 expected 까지 채울 때의 독립성 | 같은 읽기에서 나온 값은 서로 같은 것이 독립 근거가 아님을 명시하고 재료표 개수·P1 재현·앵커 B 일치로 보완 | 읽은 값을 그대로 PASS 근거로 삼음 — 내부 일관성일 뿐 도면 보증이 아님 |
| G4 FAIL 승인 지시("G4를 승인하고 보고서 초안 생성") | 앱 규칙상 FAIL 승인 불가 → 원인(hooks_up 좌표, C13 재료표 불일치) 정리 후 계획된 FAIL 로 등록 | 승인 기록 파일을 직접 고쳐 승인 — 앱 검증 절차 우회 |
| 읽을 수 없는 값(철근 위치·코핑 외곽) | 선·원 좌표 직접 읽기(P1 재현 검증) + 재료표 산술 검산 | "읽을 수 없음"으로 두고 P1 값 유지 — 새 도면에서 틀린 모델 |
| 허위 PASS(P4 첫 게이트 G4) | P1 값끼리 일치한 PASS 로 판정, 새 값으로 재실행 | 승인 — 도면과 무관한 일치 |"""
out_a = """| 산출물 | 경로 | 비고 |
| :--- | :--- | :--- |
| 코핑 외곽 추출 | Code/교각모델링앱/tools/extract_coping_outline.py | 신규 |
| 기초 철근 규칙 | Code/교각모델링앱/tools/infer_footing_rebar.py (→ engine/footing_rule.py) | 신규 |
| 도면 값 비교 | Code/교각모델링앱/tools/verify_pier_geometry.py | 신규(55항목, P1·P3 55/55) |
| P3 반영 | tools/apply_p3_read_values.py, apply_p3_stage2.py | 77곳 + 표 105건 + 48곳, 백업 data/운곡교P3/backup |
| P3 게이트 | data/운곡교P3/runs/current | G1~G5 PASS 승인(578/585, 계획 7), 보고서 초안 2026-10-19 |
| P4 반영 | tools/apply_pier_read.py | 144곳 + 표 118건, 게이트 G1~G5 PASS 후 승인 |
| 사용설명서·기획서 | DOC/04_실행 v0.2.12 → v0.2.13, DOC/01_기획 v0.1.07 → v0.1.08 | |
| 개선 목록 | context-migration/OPEN_미해결목록.md #34·#35 | |"""
(P19 / "Task_도면도형직접판독시험_2026-10-19_v0.1.00.md").write_text(
header("도면도형직접판독시험", "2026-10-19", "Task_도면도형직접판독시험_2026-10-19_v0.1.00", "prompt/심영표/2026-10-19/Task_입력묶음개편D단계_2026-10-19_v0.1.00.md", inp_a, body_a, dec_a, out_a, n_a,
"PROCESS COMPLETE", "운곡교 P3 게이트 통과·승인, P4 반영 완료. 하루를 넘긴 후속 작업(P4 게이트 검토 이후)은 2026-10-20 로그로 분리(F8). 도구호출 횟수는 집계되지 않음."),
encoding="utf-8")
# ---------------- log B (10-20 morning)
P20.mkdir(exist_ok=True)
inp_b, n_b = inputs(dt.datetime(2026, 10, 20, 8, 0), dt.datetime(2026, 10, 20, 9, 13))
body_b = "\n".join(part_b)
dec_b = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| P4 B11(표 30본 대 작도 32본) | 작도 채택 + 계획된 FAIL 등록(설계자 확인 대기) | 표 30본으로 값을 고침 — 그려진 32개 점과 모델이 어긋남 |
| P4 C9 좌측 스터럽 윗면 | 재료표 C9 b 범위(2.812·1.376)를 역산한 40447 로 둠 | P1 규칙(소단 − 100) 유지 — 재료표와 150 mm 어긋남 |
| P4 C13 재료표 L 28.966 | P3 처럼 계획된 FAIL 임시 등록, 설계자 확인 | 규칙값 29.346 으로 모델 변경 — 재료표 자체와 불일치 |
| P4 일괄 실행 오류(display) | pier_id 변경 시 hidden_ids 접두어도 갱신(스크립트 결함 수정) | 오류를 무시하고 중간 결과만 사용 |"""
out_b = """| 산출물 | 경로 | 비고 |
| :--- | :--- | :--- |
| P4 게이트 결과 | data/운곡교P4/runs/current (이후 사용자가 템플릿에서 다시 만듦, 승인된 복사본은 data/_휴지통/운곡교P4_20261020_112015) | G1~G5 PASS·승인 09:08 |
| P4 결정 반영 | data/운곡교P4/expected.json planned_fail, params.json stirrups.top_left | 백업 backup/before_b11_c9_c13_* |
| 읽기 보정 | engine/geomread.py(이후 통합), tools/apply_pier_read.py | hidden_ids 갱신 |
| 이월 설명 | (대화) B11·B12·C9·C13 설명 | OPEN #35 |"""
(P20 / "Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00.md").write_text(
header("운곡교P4게이트검토와결정", "2026-10-20", "Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00", "prompt/심영표/2026-10-19/Task_도면도형직접판독시험_2026-10-19_v0.1.00.md", inp_b, body_b, dec_b, out_b, n_b,
"PROCESS COMPLETE", "P4 G1~G5 계획 외 FAIL 0 으로 통과·승인. 이후 사용자가 P4 를 삭제하고 템플릿에서 다시 만들어 같은 흐름을 재시험함(다음 로그)."),
encoding="utf-8")
# ---------------- log C (10-20 improvements and P2 / P4 finishing)
inp_c, n_c = inputs(dt.datetime(2026, 10, 20, 9, 13), dt.datetime(2026, 10, 21, 0, 0))
body_c = "\n".join(part_c)
dec_c = """| 결정 지점 | 채택안 | 기각안 / 사유 |
| :--- | :--- | :--- |
| 확정 방식 개선 요청("앱이 일괄 확인, 사용자가 일괄 확정") | 앱이 검토를 준비(바뀐 것·확인하지 못한 것·출처·자동 사유), 사람이 한 화면에서 체크·한 번 확정(검토 요약 확정). 이후 요청에 따라 [모두 체크] 버튼과 주의 표시 | 앱이 확정 대행 — 확정의 의미(사람의 대조 기록)가 사라지고 앱이 대조하지 못한 묶음까지 확정됨 |
| 위험도 두 단계(낮은 위험 일괄/높은 위험 개별) | 사용자 요청에 따라 일괄 체크 + 주의 표시로 대체(위험도 분류는 미구현, 기획서 6.9에 기록) | 위험도 분류를 먼저 구현 — 사용자가 단순한 일괄 방식을 요청함 |
| 거짓 앵커(재료표 칸 하나) | 단독 표 칸 후보만 C등급 | 표 칸 앵커를 아예 제외 — B 오답은 3/29 로 큰 차이 없이 A 후보 2건 손실 |
| 읽기 도구 변경 비교 허용오차 | 글자·재료표 값은 정확 비교, CAD 측정값만 1.5 mm | 모든 값에 1.5 mm — 코핑 상단 철근 a 1 mm 차이와 개수 36/37 을 같다고 봄(P2·P4 에서 발견) |
| P2·P4 새 교각 흐름 | 읽기(도면 + 표) → 결정 가져오기 → 검토 요약 확정 → 게이트 | 개별 도구 실행·묶음별 개별 확정 — P2 는 약 20분에 끝남 |
| 시험 데이터 의존 | 게이트가 모두 승인된 복사본(`verified_pier`)과 휴지통의 옛 pier 로 시험 | 실제 사용 중인 pier 를 시험 기준으로 사용 — 삭제·재작성으로 시험이 깨짐 |"""
out_c = """| 산출물 | 경로 | 버전/비고 |
| :--- | :--- | :--- |
| 일관성 검사 C9 | Code/교각모델링앱/engine/consistency.py, tools/test_consistency.py | 규칙 103~106건 + hooks_up 규칙 |
| 필드 단위 원본 잔존 C3b | engine/verify.py(B3), web/store.py(origin_values.json), tools/make_origin_values.py | |
| 거짓 앵커 | engine/anchors.py(VERSION 2, TABLE_RULE=single), tools/test_anchors.py | |
| 도면에서 읽기 | engine/geomread.py, engine/footing_rule.py, web/templates/geomread.html, tools/test_geomread.py | 표 값 통합, 정확 비교 |
| 일괄 실행 중단 표시 | web/app.py(batch.json reason/started), web/templates/gates.html, tools/test_batch_notice.py | |
| 검토 요약 확정 | engine/review.py, web/templates/review.html, tools/test_review.py | 모두 체크·주의 표시 |
| 사용설명서 | DOC/04_실행/실행_T형교각모델링앱사용설명서_2026-10-20_v0.2.16.md | v0.2.12→v0.2.16 |
| 기획서 | DOC/01_기획/기획_입력묶음확인확정개편_2026-10-20_v0.1.12.md | v0.1.07→v0.1.12 |
| 운곡교 P2 | data/운곡교P2 | G1~G5 PASS·승인(578/585, 계획 7), 보고서 초안 미생성 |
| 운곡교 P4(재작성) | data/운곡교P4 | 11:59 G1~G3 승인, G4 계획 외 C1·C4(코핑 상단 철근 a)를 수정, 재확정·재실행 대기 |
| 개선 목록 | context-migration/OPEN_미해결목록.md #34·#36 | |"""
(P20 / "Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00.md").write_text(
header("앱개선_검토요약확정과P2P4마무리", "2026-10-20", "Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00", "prompt/심영표/2026-10-20/Task_운곡교P4게이트검토와결정_2026-10-20_v0.1.00.md", inp_c, body_c, dec_c, out_c, n_c,
"PROCESS PARTIAL (운곡교P4 재확정·G4 재실행 대기, 앱 개선 ⑦~⑫ 미착수, P2 보고서 초안 미생성)",
"Rhino 회귀 4종(test_web_batch 외)은 ⑤ 변경 뒤 통과, 울산 3종은 미실행. 이 로그는 하루 안에 20턴을 넘어 다음 변경부터 새 로그로 분리 권장(F8). 도구호출 횟수는 집계되지 않음."),
encoding="utf-8")
print("written", n_a, n_b, n_c)
@@ -0,0 +1,38 @@
"""2026-10-20 Step 5: context-migration update (OPEN list: #35 / #36 notes + new #37 #38; technical note: reading-tool rules and traps)."""
from pathlib import Path
R = Path(__file__).resolve().parents[4]
OPEN = R / "context-migration" / "OPEN_미해결목록.md"
NOTE = R / "context-migration" / "기술노트_DWG판독_Rhino모델링환경.md"
t = OPEN.read_text(encoding="utf-8")
a = "P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목 | 2026-10-20 | 2026-10-20 | 미해소 |"
assert t.count(a) == 1
t = t.replace(a, "P3·P4 승인 기록은 일괄 실행 자동 승인이며 보고서 3~5장은 담당자 작성 항목. **[갱신 2026-10-20]** 재료표 C13 L 28.966(규칙 29.346과 380 mm 차이)은 **P3·P4 도면에만** 있고 운곡교 P2 도면의 C13 은 29.345 로 규칙과 일치(P2 는 G4 에 계획된 FAIL 없음) → 설계자 문의는 'P3·P4 도면의 재료표 C13'으로 한정. P2 는 옛 P2(2026-10-12)에서 사용자가 확정한 B9(551 대 493)·B12(32 대 38) 계획된 FAIL 만 있음 | 2026-10-20 | 2026-10-20 | 미해소 |")
rows = [
("| 37 | **운곡교 P4(2026-10-20 11:22 템플릿에서 재작성본) 마무리 대기** — 검증·승인된 첫 P4(G1~G5 PASS 09:08)는 사용자가 11:20 삭제해 `data/_휴지통/운곡교P4_20261020_112015` 에 있음(승인·결과 보존). 재작성본: 읽기(도면 145+1+4건·표 130건)·결정(좌측 스터럽 윗면 40447, tie_offset 22.5, 계획된 FAIL B11·C13, 앵커 rear_first_from_tip 1198)까지 반영, 11:59 게이트에서 G1~G3 승인·G4 계획 외 C1·C4(원래 길이 2 mm = 코핑 상단 철근 a 가 템플릿 값 11855) → 읽기 도구 비교 허용오차 수정(글자·재료표 값은 정확 비교) 후 `coping_rebar.tiers.a` 를 11856/11881/11905 로 반영. **남은 것**: `params:coping_rebar` 재확정(검토 요약) → 일괄 승인 재실행 → G4·G5 승인 → 보고서 초안 | 2026-10-20 | 2026-10-20 | 미해소 | [ref: Code/교각모델링앱/data/운곡교P4, prompt/심영표/2026-10-20/Task_앱개선_검토요약확정과P2P4마무리_2026-10-20_v0.1.00.md] |"),
("| 38 | 운곡교 P2(2026-10-20 10:53 템플릿에서 새로 만듦) — 읽기(126+4+6건)·표 94건·앵커 1건·planned_fail(B9·B12) 반영 후 G1~G5 PASS·승인(11:13, 578/585, 계획 7, 계획 외 0). 새 도구로 새 교각이 약 20분에 끝남. **남은 것**: 보고서 초안 미생성, 확정 기록의 공통 의견 칸이 비어 있음(앱이 자동 사유는 남김), 보고서 3~5장 담당자 작성 | 2026-10-20 | 2026-10-20 | 미해소 | [ref: Code/교각모델링앱/data/운곡교P2] |"),
]
t = t.rstrip("\n") + "\n" + "\n".join(rows) + "\n"
OPEN.write_text(t, encoding="utf-8")
n = NOTE.read_text(encoding="utf-8")
n = n.replace("최근확인일: 2026-10-07", "최근확인일: 2026-10-20", 1)
n = n.rstrip("\n") + """
## 도면 읽기 도구(`engine/geomread.py`)의 규칙과 함정 (2026-10-20)
- **틀**: 운곡교 P1 도면 틀(P1~P4)만. 첫 도면(D1) 좌표가 P1 도면과 같음: 코핑 평면 중심 (12162.4, 26412.3), 정면도 중심 x 12159.4, 단면도 기초 중심 x 30133.6, 기초 하면 y 8115.6 = 기초 EL, 코핑 EL = y + (최고 단 옆 'EL.' 글자 − 그 단 y). 틀 일치율은 템플릿 앵커의 랜드마크 글자 비율(70 % 미만이면 거절). 새재1교·옥성교는 0 %.
- **재현 검증이 먼저**: 읽기 규칙은 완성된 교각(P1)에서 같은 값이 나오는지 확인한 뒤에만 믿음 — `tools/test_geomread.py`(P1·P3·P2·P4, 초기 P4 백업 144~150건 = 검증값).
- **비교 허용오차는 값의 출처에 따라**: 글자·재료표에서 온 값(기초·말뚝·기둥·철근 개수·길이·반경·재료표 셀·코핑 상단 철근 a)은 **정확 비교**, CAD 선·원·폴리라인에서 잰 값만 1.5 mm(각도 0.05). **동일 방식 재시도 금지**: 모든 숫자에 1.5 mm 를 주면 개수 36 대 37, 재료표 17.247 대 17.377, 상단 철근 a 11855 대 11856 이 '같다'로 가려진다(P2 첫 반영에서 재료표 사본·후프 개수가 빠졌고 P4 G4 C1·C4 2 mm 실패의 원인).
- **C14 점은 템플릿 점 좌표에 맞춰 찾지 않는다**: x 오프셋이 교각마다 다름(P1·P3·P4: 13/913…, P2 상단 두 줄: 0/900…). 도면의 철근 원(R27, CS-RBAR-MAJR)을 읽고, 템플릿 C14 높이(+ 높이 이동) 5 mm 안의 원만 취함(기둥 후프 점은 x≈±902 이고 한 점은 C14 높이에서 23 mm 떨어짐, P2 의 C14 x=900 과는 2 mm). 오른쪽 반만 그려져 있어 좌우 대칭, 가운데(|x|≤20)는 한 번, 템플릿에서 왼쪽 짝이 없던 점(P1 x 2913 근처)은 오른쪽 전용.
- **재료표의 직경 칸 '"' 는 표 순서상 바로 윗 줄**이다(마크 번호 순서가 아님) — P1~P6 을 번호 순으로 이어받으면 틀림.
- **재료표 B9 개수는 격자(체스판)로 계산**: 38×29/2 = 551 = 모델이 놓는 개수, 재료표 493 과 다르면 계획된 FAIL 로 등록(P2·옛 P2). P3·P4 는 격자와 재료표가 같음.
- **작도 한쪽만 그려진 값(C14, 기초 중간 층 B11·B12, 좌측 스터럽 윗면)은 좌우 대칭 가정** — 재료표 개수와 어긋나면(P4 B11 30 대 32) 작도를 채택하고 계획된 FAIL 등록 후 설계자 확인.
- **`hooks_up` 처럼 점 좌표로 대상을 지정하는 목록은 점이 바뀌면 조용히 깨진다**(P3 G4 코핑 밖 점 56개) — 앱 확인 C9 가 `hooks_up ⊂ c14.points` 를 검사함.
- **교각 이름을 바꾸면 모델 객체 이름 접두어(`P1-FT`)가 바뀐다** — `params.display.hidden_ids` 도 같이 바꿔야 함(안 바꾸면 일괄 실행이 마지막 단계에서 오류로 중단).
- **고정(F) 항목(pier_id·display)을 도면에서 읽기로 바꾸면 앱 확인 C7 이 사유 기록을 요구** — 반영 화면이 `edit_log.json` 에 자동 기록하도록 수정함(이전 서버 프로세스는 재시작해야 반영).
- **실제 사용 데이터에 의존하는 시험은 깨진다**: 사용자가 `운곡교p2`·`운곡교P4` 를 삭제·재작성하면서 시험 6종이 깨짐 → 시험은 G5 까지 승인된 복사본(`testlib.verified_pier`)·휴지통 복사본(`testlib.pier_dir`)을 쓴다. Windows 의 글로빙은 대소문자를 구분하지 않아 `운곡교p2_*` 가 `운곡교P2_*` 를 집는다 — 이름 접두어를 대소문자 구분으로 다시 거른다.
- **Flask 는 디버그 꺼짐이면 템플릿을 캐시한다** — 화면·라우트를 고친 뒤에는 사용자가 앱을 껐다 켜야 새 메뉴·버튼이 보인다(2026-10-20 '도면에서 읽기가 없다'의 원인).
"""
NOTE.write_text(n, encoding="utf-8")
print("ok")
@@ -0,0 +1,23 @@
"""Insert the S02 view (s02_mockup_view.js) into the design mockup DOC/01_기획/웹앱디자인시안/시안/index.html (2026-10-15).
The original is copied to ../backup first by the caller; the patch is applied once (refuses if already applied)."""
from pathlib import Path
HERE = Path(__file__).parent
P = Path(__file__).resolve().parents[4] / "DOC" / "01_기획" / "웹앱디자인시안" / "시안" / "index.html"
s = P.read_text(encoding="utf-8")
assert "viewNew" not in s, "already applied"
js = (HERE / "s02_mockup_view.js").read_text(encoding="utf-8")
pairs = [
(" /* ---------- router ---------- */", js + "\n /* ---------- router ---------- */"),
(" else if (h.indexOf('#/candidates') === 0) viewCandidates();", " else if (h.indexOf('#/candidates') === 0) viewCandidates();\n else if (h.indexOf('#/new') === 0) viewNew();"),
(" $('#nav-cand').style.fontWeight = h.indexOf('#/candidates') === 0 ? '700' : '500';",
" $('#nav-cand').style.fontWeight = h.indexOf('#/candidates') === 0 ? '700' : '500';\n $('#nav-new').style.fontWeight = h.indexOf('#/new') === 0 ? '700' : '500';"),
(' <div class="sub"><a href="#/candidates" id="nav-cand">표 후보값</a></div>',
' <div class="sub"><a href="#/candidates" id="nav-cand">표 후보값</a></div>\n <div class="sub"><a href="#/new" id="nav-new">새 교각 (S02)</a></div>'),
("<title>PierProof 시안 — 입력 묶음 확인·확정</title>", "<title>PierProof 시안 — 입력 묶음·새 교각 판별</title>"),
]
for a, b in pairs:
assert s.count(a) == 1, a
s = s.replace(a, b)
P.write_text(s, encoding="utf-8")
print("patched", P)
@@ -0,0 +1,28 @@
// Probe for the S02 mockup (run in headless Edge via --dump-dom on a copy that has this script appended): drives the view and writes the results into <pre id="res">.
window.addEventListener('load', function () {
var out = [];
function rec(k, v) { out.push(k + '=' + v); }
function strip() { return document.querySelector('.strip[role=status]').textContent; }
try {
location.hash = '#/new';
setTimeout(function () {
try {
rec('upload_button', !!document.getElementById('up'));
document.getElementById('up').click();
rec('rank1', document.querySelector('tbody tr td:nth-child(2)').textContent);
rec('verdict_ok', strip().indexOf('이 템플릿으로 만들 수 있음') >= 0);
var tp = document.getElementById('tp'); tp.value = '옥성교P1형'; tp.dispatchEvent(new Event('change'));
rec('verdict_bad', strip().indexOf('같은 템플릿으로 만들 수 없음') >= 0 && strip().indexOf('코핑 하면 형상') >= 0);
rec('button', document.getElementById('mk').textContent);
document.getElementById('mk').click();
rec('name_error', (document.querySelector('.field.bad .err') || {}).textContent);
var nm = document.getElementById('nm'); nm.value = 'TEST'; nm.dispatchEvent(new Event('input'));
document.getElementById('mk').click();
rec('toast', document.getElementById('toast').textContent);
var rs = document.getElementById('rs'); rs.value = '아니오'; rs.dispatchEvent(new Event('change'));
rec('rebar_bad', strip().indexOf('철근 형상 종류') >= 0);
} catch (e) { rec('ERR', e.message); }
var pre = document.createElement('pre'); pre.id = 'res'; pre.textContent = out.join(' | '); document.body.appendChild(pre);
}, 200);
} catch (e) { rec('ERR0', e.message); }
});
@@ -0,0 +1,78 @@
/* ---------- S02 new pier fit (sample: Ungok P1 drawings uploaded) ---------- */
var QS = [['foundation', '기초 형식(말뚝 유무)'], ['soffit', '코핑 하면 형상'], ['bearings', '받침 개수']];
var OPTS = { foundation: ['기초판+말뚝', '기초판만', '말뚝만'], soffit: ['곡선', '직선 경사', '나팔형'], bearings: ['2', '4', '10'] };
var TPLS = [
{ n: '운곡교P1형', p: { foundation: '기초판+말뚝', soffit: '곡선', bearings: '10' } },
{ n: '옥성교P1형', p: { foundation: '기초판+말뚝', soffit: '직선 경사', bearings: '10' } },
{ n: '새재1교P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '10' } },
{ n: 'Ramp-A교P1형', p: { foundation: '말뚝만', soffit: '나팔형', bearings: '2' } },
{ n: '산외교울산P1형', p: { foundation: '기초판만', soffit: '직선 경사', bearings: '4' } }
];
var EST = {
foundation: ['기초판+말뚝', "'말뚝' 문자 7개: D1 CS-TEXT (복합말뚝)", '보통'],
soffit: ['곡선', "R 12000 mm: D1 CS-DIMT (8257, 16513) 'R=12.000'", '보통'],
bearings: ['10', 'ELEVATION TABLE 행 10개: D2 (26179, 9342)', '보통']
};
var N = { up: false, ans: null, tpl: '운곡교P1형', name: '', rs: '미확인', rm: '미확인', skew: '', brot: '', frot: '', err: '' };
function nMatch(t) { return QS.filter(function (q) { return t.p[q[0]] === EST[q[0]][0]; }).length; }
function nBad() {
var t = TPLS.filter(function (x) { return x.n === N.tpl; })[0], bad = [];
QS.forEach(function (q) { if (t.p[q[0]] !== N.ans[q[0]]) bad.push(q[1]); });
if (N.rs === '아니오') bad.push('철근 형상 종류');
if (N.rm === '아니오') bad.push('철근 생성 방식');
return bad;
}
function viewNew() {
setWhere('S02 · 새 교각 적합성 판별');
var html = '<div class="head"><span class="eyebrow">새 교각</span><h1>도면으로 템플릿 추천</h1><p class="lead">도면을 먼저 올리면 앱이 가장 비슷한 템플릿을 추천한다. 추천은 제안이고, 6문항은 사람이 확인한다.</p></div>';
if (!N.up) {
html += '<section class="card" style="max-width:38em"><div class="field"><label for="dw">교각 도면(DWG, 여러 장)</label><input id="dw" type="file" accept=".dwg" multiple><span class="help">시안에서는 운곡교 P1 도면 3장을 올린 것으로 본다</span></div><div class="row"><button class="btn primary" type="button" id="up">도면 읽고 추천 받기</button></div></section>';
main.innerHTML = html;
$('#up').onclick = function () { N.up = true; N.ans = {}; QS.forEach(function (q) { N.ans[q[0]] = EST[q[0]][0]; }); viewNew(); };
return;
}
var ranked = TPLS.slice().sort(function (a, b) { return nMatch(b) - nMatch(a); });
html += '<section class="strip"><p class="concl">도면 3장, 문자 2,366개를 읽었다 — 1위 ' + ranked[0].n + ' (3 / 3 일치)</p></section>';
html += '<h2>1 추천 순위</h2><div class="tw"><table class="t"><caption>추정한 3문항과 일치한 수</caption><thead><tr><th scope="col">순위</th><th scope="col">템플릿</th><th class="num" scope="col">일치</th>' + QS.map(function (q) { return '<th scope="col">' + q[1] + '</th>'; }).join('') + '</tr></thead><tbody>';
ranked.forEach(function (t, i) {
html += '<tr><td class="num">' + (i + 1) + '</td><td><b>' + t.n + '</b></td><td class="num">' + nMatch(t) + ' / 3</td>' + QS.map(function (q) {
var ok = t.p[q[0]] === EST[q[0]][0];
return '<td>' + esc(t.p[q[0]]) + ' ' + (ok ? badge('good', '●', '일치') : badge('bad', '■', '다름')) + '</td>';
}).join('') + '</tr>';
});
html += '</tbody></table></div>';
html += '<h2>2 추정 근거</h2><div class="tw"><table class="t"><thead><tr><th scope="col">문항</th><th scope="col">추정</th><th scope="col">근거(도면 위치)</th><th scope="col">신뢰</th></tr></thead><tbody>';
QS.forEach(function (q) { var e = EST[q[0]]; html += '<tr><td>' + q[1] + '</td><td><b>' + e[0] + '</b></td><td><span class="chip">' + esc(e[1]) + '</span></td><td>' + e[2] + '</td></tr>'; });
html += '<tr><td>기둥 단면·철근 형상·철근 생성 방식</td><td>' + badge('dash', '◇', '판단 불가') + '</td><td>도면 문자로 판별할 근거가 없다. 사람이 답한다</td><td>-</td></tr></tbody></table></div>';
html += '<div class="banner tip"><span class="m" aria-hidden="true">◆</span><div>코핑 하면 형상은 정면도의 큰 R 문자(10 m 이상)만 본다. 평면 모서리의 작은 R은 제외한다. 평면의 사각·직각과 각도는 문항이 아니라 아래 값으로 입력한다.</div></div>';
html += '<h2>3 6문항 확인 후 교각 만들기</h2><div style="max-width:38em;display:flex;flex-direction:column;gap:var(--prism-space-200)">';
QS.forEach(function (q) {
html += '<div class="field"><label for="a_' + q[0] + '">' + q[1] + '</label><select id="a_' + q[0] + '" data-a="' + q[0] + '">' + OPTS[q[0]].map(function (o) { return '<option' + (N.ans[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
[['rs', '철근 형상 종류가 템플릿의 재료표 형식 열과 같은가'], ['rm', '철근 생성 방식(규칙 값 / 철근별 중심선)이 같은가']].forEach(function (q) {
html += '<div class="field"><label for="' + q[0] + '">' + q[1] + '</label><select id="' + q[0] + '" data-r="' + q[0] + '">' + ['미확인', '예', '아니오'].map(function (o) { return '<option' + (N[q[0]] === o ? ' selected' : '') + '>' + o + '</option>'; }).join('') + '</select></div>';
});
html += '<fieldset class="card sunken"><legend class="lbl">각도 값 확인 <span class="muted">템플릿을 가르지 않는 값. 비워도 된다</span></legend><div class="grid3">' +
[['skew', '코핑 평면 skew (°)'], ['brot', '받침 회전 (°)'], ['frot', '기초 회전 (°)']].map(function (q) { return '<div class="field"><label for="g_' + q[0] + '">' + q[1] + '</label><input id="g_' + q[0] + '" type="text" inputmode="decimal" data-g="' + q[0] + '" value="' + esc(N[q[0]]) + '"></div>'; }).join('') + '</div></fieldset>';
var bad = nBad();
html += '<section class="strip" role="status" aria-live="polite"><h3 style="margin:0 0 .25rem">결과 판정</h3><p class="concl">' + (bad.length ? badge('bad', '■', '같은 템플릿으로 만들 수 없음') + ' 다른 문항: ' + esc(bad.join(', ')) : badge('good', '●', '이 템플릿으로 만들 수 있음')) + '</p>' +
(bad.length ? '<p class="muted" style="margin:.25rem 0 0">가장 가까운 템플릿에 생성기·검사기를 추가해야 한다. 그래도 진행하면 교각 이름 옆과 모든 화면 상단에 형식 불일치 경고가 계속 표시된다(닫을 수 없다).</p>' : '') + '</section>';
html += '<div class="field' + (N.err ? ' bad' : '') + '"><label for="nm">교각 이름<span class="req">필수</span></label><input id="nm" type="text" value="' + esc(N.name) + '" style="max-width:20rem">' + (N.err ? '<span class="err"><span aria-hidden="true">■</span>' + esc(N.err) + '</span>' : '') + '</div>';
html += '<div class="field"><label for="tp">템플릿</label><select id="tp" style="max-width:20rem">' + ranked.map(function (t, i) { return '<option value="' + t.n + '"' + (N.tpl === t.n ? ' selected' : '') + '>' + (i + 1) + '위 ' + t.n + '</option>'; }).join('') + '</select></div>';
html += '<div class="row"><button class="btn primary" type="button" id="mk">' + (bad.length ? '그래도 만들기' : '교각 만들기') + '</button></div></div>';
main.innerHTML = html;
var y = window.scrollY;
main.querySelectorAll('[data-a]').forEach(function (e) { e.onchange = function () { N.ans[e.getAttribute('data-a')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-r]').forEach(function (e) { e.onchange = function () { N[e.getAttribute('data-r')] = e.value; viewNew(); window.scrollTo(0, y); $('#' + e.id).focus(); }; });
main.querySelectorAll('[data-g]').forEach(function (e) { e.oninput = function () { N[e.getAttribute('data-g')] = e.value; }; });
$('#nm').oninput = function () { N.name = this.value; };
$('#tp').onchange = function () { N.tpl = this.value; viewNew(); window.scrollTo(0, y); $('#tp').focus(); };
$('#mk').onclick = function () {
N.name = $('#nm').value.trim();
if (!N.name) { N.err = '교각 이름이 비어 있다. 폴더를 만들 이름이 필요하다. 이름을 입력한다'; viewNew(); $('#nm').focus(); return; }
N.err = '';
toast("교각 '" + N.name + "'을(를) 만들었다. 올린 도면이 교각 폴더로 옮겨졌고 6문항 답과 각도 값이 저장되었다." + (bad.length ? ' 형식 불일치 경고가 표시된다.' : ''));
viewNew();
};
}
@@ -0,0 +1,68 @@
"""Real-use check of the anchors on an unmodelled pier (2026-10-19): follow the anchors of a protected template into the pier's drawing and
cross-check every candidate that also has a table value (ELEVATION / 솔플레이트 / 철근재료표 candidates of engine/reader) — the table is the
independent truth for those fields. Reads only; writes nothing under data/.
usage: python tools/report_anchor_pier.py <pier> <template> [--detail]"""
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine import anchors, reader # noqa: E402
pier, tpl = sys.argv[1], sys.argv[2]
detail = "--detail" in sys.argv
D = APP / "data"
P, E = (json.loads((D / pier / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json"))
texts = json.loads((D / pier / "drawings" / "texts.json").read_text(encoding="utf-8"))
anc = json.loads((D / "_템플릿" / tpl / "anchors.json").read_text(encoding="utf-8"))
own = [a["source_sha256"] for a in json.loads((D / pier / "drawings" / "audit.json").read_text(encoding="utf-8")) if a.get("source_sha256")]
cand = anchors.follow(anc, texts, own)
docs = {"params": P, "expected": E}
def get(field):
which, path = field.split(":", 1)
x = docs[which]
for p in path.split("."):
x = x[int(p)] if isinstance(x, list) else x.get(p)
if x is None:
return None
return x
cand = {f: c for f, c in cand.items() if isinstance(get(f), (int, float))} # fields this pier's params really have (as the app does)
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), P, E)
table = {f"{r['file']}:" + ".".join(str(x) for x in r["path"]): r["candidate"] for r in rows}
print(f"== {pier} <- anchors of {tpl}: candidates {len(cand)}, frame coverage {next(iter(cand.values()))['coverage'] if cand else '-'}")
by = Counter(c["grade"] for c in cand.values())
print("grades", dict(by), "| differing from the current (template) value:", Counter(c["grade"] for f, c in cand.items() if abs(abs(get(f)) - abs(c["value"])) > 1.5))
agree = defaultdict(lambda: [0, 0])
bad = []
for f, c in cand.items():
t = table.get(f)
if t is None or isinstance(t, (list, dict)):
continue
ok = abs(abs(t) - abs(c["value"])) <= 1.5
agree[c["grade"]][0] += 1
agree[c["grade"]][1] += ok
if not ok:
bad.append((c["grade"], f, c["value"], t))
import copy # noqa: E402
after = anchors.cross_check(copy.deepcopy(cand), anchors.table_values(rows))
print("after the table cross-check (engine.anchors.cross_check):", dict(Counter(c["grade"] for c in after.values())),
"| table-confirmed A:", sum(1 for c in after.values() if c.get("table_confirmed")), "| lowered to C by a different table value:",
sum(1 for c in after.values() if c.get("table_confirmed") is False))
print("cross-check against the table value of the same field (independent truth):")
for g in "ABC":
n, k = agree[g]
print(f" grade {g}: {k}/{n} equal" + (f" ({k / n:.0%})" if n else ""))
for b in bad[:12 if detail else 5]:
print(" differs:", b)
if detail:
for g in "AB":
print(f"-- grade {g} candidates that differ from the current value")
for f, c in sorted(cand.items()):
if c["grade"] == g and abs(abs(get(f)) - abs(c["value"])) > 1.5:
print(f" {f:46s} {get(f)!s:>8} -> {c['value']!s:>8} ({c['followed']} places, {c['text']!r} {c['dwg']})")
@@ -0,0 +1,35 @@
"""Print the app-check distribution of the real piers (stage B, 2026-10-16) without touching data/: the piers are copied (without runs,
backup and the DWG / DXF files) into 검증/autocheck_report_data and checked there.
usage: python tools/report_autocheck.py [pier ...] -> per pier: counts of required bundles per status and the non-g bundles."""
import json
import os
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "autocheck_report_data"
if TD.exists():
shutil.rmtree(TD)
names = sys.argv[1:] or [d.name for d in sorted((APP / "data").iterdir()) if (d / "params.json").exists() and not d.name.startswith("_")]
for n in names:
shutil.copytree(APP / "data" / n, TD / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf"))
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
import store # noqa: E402
from engine import verify # noqa: E402
for n in names:
p = store.Pier(n)
t0 = time.time()
a = p.run_autocheck(force=True)
req = {f"{f}:{k}" for f, k in webapp.required(p)}
c = verify.counts(a["sections"], req)
print(f"== {n}: required {len(req)} -> " + " ".join(f"{verify.ST_NAME[k]} {v}" for k, v in c.items()) + f" ({time.time() - t0:.1f}s)")
for b, r in sorted(a["sections"].items()):
if b in req and r["st"] != "g":
print(f" {verify.ST_NAME[r['st']]:5s} {b:28s} {r['summary'][:110]}")
@@ -0,0 +1,48 @@
"""Run the app's test scripts one after another and summarise (2026-10-16).
usage: python run_all_tests.py <fast|rhino|all> [name ...]
fast = tests without Rhino; rhino = tests with real Rhino runs (one at a time, each can take several minutes).
Each test's output goes to 검증/_rerun_<name>_<date>.log; the summary (verdict, exit code, seconds, FAIL lines) to
검증/_rerun_summary_<group>_<date>.json. A test passes when its exit code is 0 and no line starts with 'FAIL'.
"""
import json
import subprocess
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
FAST = ["test_consistency", "test_geomread", "test_batch_notice", "test_review", "test_open19", "test_rampa", "test_saejae", "test_okseong", "test_confirm_required", "test_templates", "test_recommend",
"test_recommend_web", "test_ulsan_p2", "test_variability", "test_autocheck", "test_anchors", "test_delete_pier", "test_store_concurrency", "test_rampa_web"]
RHINO = ["test_web_batch", "test_pier_batch_button", "test_individual_after_batch", "test_web", "test_web_ulsan",
"test_web_ulsan_full", "test_ulsan_e2e"]
TIMEOUT = {"test_web": 2400, "test_web_ulsan": 1500, "test_web_ulsan_full": 2400, "test_ulsan_e2e": 3000, "test_web_batch": 2400}
def run(name, stamp):
log = APP / "검증" / f"_rerun_{name}_{stamp}.log"
t0 = time.time()
try:
r = subprocess.run([sys.executable, "-u", str(APP / "tools" / f"{name}.py")], cwd=str(APP), capture_output=True,
timeout=TIMEOUT.get(name, 900), env={**__import__("os").environ, "PYTHONIOENCODING": "utf-8"})
out, code = r.stdout.decode("utf-8", "replace") + r.stderr.decode("utf-8", "replace"), r.returncode
except subprocess.TimeoutExpired as e:
out, code = (e.stdout or b"").decode("utf-8", "replace") + "\nTIMEOUT", -9
log.write_text(out, encoding="utf-8")
fails = [ln[:240] for ln in out.splitlines() if ln.startswith("FAIL")]
return {"test": name, "ok": code == 0 and not fails, "exit": code, "secs": round(time.time() - t0), "fails": fails[:8],
"tail": out.strip().splitlines()[-1][:200] if out.strip() else ""}
if __name__ == "__main__":
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
group = sys.argv[1]
names = sys.argv[2:] or (FAST if group == "fast" else RHINO if group == "rhino" else FAST + RHINO)
stamp = time.strftime("%Y%m%d")
res = []
for n in names:
r = run(n, stamp)
res.append(r)
print(("PASS" if r["ok"] else "FAIL"), n, r["secs"], "s", r["tail"], flush=True)
(APP / "검증" / f"_rerun_summary_{group}_{stamp}.json").write_text(json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8")
print("DONE", sum(r["ok"] for r in res), "/", len(res))
@@ -0,0 +1,108 @@
"""Example run of the Ungok P1 pier through the running web app (HTTP, so progress shows in the browser).
Steps: upload the 3 DWG (copies of the RAG originals) -> check table candidates -> confirm the required
sections -> run and approve G1..G5 -> make the review report draft.
Confirmations and approvals are recorded under the name "예제실행(Agent)" so they are not mistaken for a
person's review. Standard library only (urllib).
usage: python run_example.py [pier name] [--base http://127.0.0.1:5000]
"""
import argparse
import json
import re
import sys
import time
import urllib.parse
import urllib.request
import uuid
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
ROOT = APP.parents[1]
sys.path.insert(0, str(APP / "web"))
import store # noqa: E402 (read-only use: section lists, job files)
BY = "예제실행(Agent)"
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *a, **k):
return None
OPENER = urllib.request.build_opener(NoRedirect)
def call(base, path, data=None, files=None):
url = base + urllib.parse.quote(path)
if files:
b = uuid.uuid4().hex
body = b""
for name, (fname, content) in files:
body += (f"--{b}\r\nContent-Disposition: form-data; name=\"{name}\"; filename=\"{fname}\"\r\n"
f"Content-Type: application/octet-stream\r\n\r\n").encode("utf-8") + content + b"\r\n"
body += f"--{b}--\r\n".encode()
req = urllib.request.Request(url, body, {"Content-Type": f"multipart/form-data; boundary={b}"})
elif data is not None:
req = urllib.request.Request(url, urllib.parse.urlencode(data, doseq=True).encode("utf-8"))
else:
req = urllib.request.Request(url)
try:
with OPENER.open(req, timeout=600) as r:
return r.status, r.read().decode("utf-8")
except urllib.error.HTTPError as e: # 302 redirects after POST are expected
return e.code, ""
def wait_job(pier, gate, limit=1800):
jid = (pier.gate_status(gate)["job"] or {}).get("id")
t0 = time.time()
while time.time() - t0 < limit:
job = pier.gate_status(gate)["job"] or {}
if job.get("id") == jid and job.get("state") in ("완료", "오류"):
return job
time.sleep(5)
return None
def main(name, base):
pier = store.Pier(name)
print(f"[교각] {name} ({base}/pier/{name})", flush=True)
dwgs = sorted((ROOT / "RAG").glob("C0050205-02*-운곡교 교각도*.dwg"))
st, _ = call(base, f"/pier/{name}/upload", files=[("dwg", (p.name, p.read_bytes())) for p in dwgs])
print(f"[도면] {len(dwgs)}장 업로드 → 문자 {len(pier.texts())}개 색인 (HTTP {st})", flush=True)
_, html = call(base, f"/pier/{name}/candidates")
m = re.search(r"후보값 비교 \((\d+)건 · 다름 (\d+)건\)", html)
print(f"[표 후보값] 비교 {m.group(1)}건, 다름 {m.group(2)}건" if m else "[표 후보값] 표를 찾지 못함", flush=True)
need = sorted({(r["file"], r["key"]) for g in store.GATE_SECTIONS for r in pier.gate_requirements(g)})
for f, k in need:
call(base, f"/pier/{name}/section/{f}/{k}/confirm", {"by": BY})
ok = all(r["confirmed"] for g in store.GATE_SECTIONS for r in pier.gate_requirements(g))
print(f"[확정] 필요한 묶음 {len(need)}개 확정 ({'완료' if ok else '미완료'}, 확정자 {BY})", flush=True)
for g in ("G1", "G2", "G3", "G4", "G5"):
t0 = time.time()
call(base, f"/pier/{name}/gate/{g}/run", {})
job = wait_job(pier, g)
rep = pier.gate_status(g)["report"] or {}
if not job or job["state"] != "완료" or rep.get("verdict") != "PASS":
print(f"[{g}] 중단: {job and job['state']} {rep.get('verdict')} {(job or {}).get('error', '')[-300:]}")
return 1
s = rep["summary"]
call(base, f"/pier/{name}/gate/{g}/decide", {"by": BY, "decision": "승인", "note": "예제 실행"})
print(f"[{g}] PASS {s['passed']}/{s['total']} (계획 FAIL {len(rep['planned_fails'])}) · "
f"음성 {'/'.join('검출' if v['detected'] else '미검출' for v in rep['negative'].values())} · "
f"재로드 {'일치' if rep['reload']['ok'] else '불일치'} · 승인 {pier.gate_status(g)['approved']} · "
f"{time.time() - t0:.0f}초", flush=True)
call(base, f"/pier/{name}/report", {"by": BY})
chk = json.loads((pier.run_dir / "report_check.json").read_text(encoding="utf-8"))
files = sorted(p.name for p in pier.run_dir.glob("보고서_*"))
print(f"[보고서] {files} · 자체 점검 {'통과' if chk['ok'] else '미통과'}", flush=True)
print(f"[모델] {pier.run_dir / 'G5.3dm'}")
return 0
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("name", nargs="?", default="운곡교P1")
ap.add_argument("--base", default="http://127.0.0.1:5000")
a = ap.parse_args()
sys.exit(main(a.name, a.base))
@@ -0,0 +1,31 @@
"""CLI: run gates G1..G5 for a pier (stops at the first failing gate unless --keep-going).
usage: python run_gates.py <pier data dir> <run dir> [--gates G1 G2 ...] [--keep-going]
The pier data dir holds params.json (generator) and expected.json (independent drawing values).
"""
import argparse
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine.gates import run_gate # noqa: E402
from engine.pipeline import gate_ids # noqa: E402
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("data")
ap.add_argument("run")
ap.add_argument("--gates", nargs="*", default=gate_ids())
ap.add_argument("--keep-going", action="store_true")
a = ap.parse_args()
data, run = Path(a.data), Path(a.run)
out = []
for g in a.gates:
rep = run_gate(g, data / "params.json", data / "expected.json", run, log=lambda m: print(m, flush=True))
out.append({k: rep[k] for k in ("gate", "verdict", "summary", "unplanned_fails", "secs")})
if rep["verdict"] != "PASS" and not a.keep_going:
break
(run / "gates_summary.json").write_text(json.dumps(out, ensure_ascii=False, indent=1), encoding="utf-8")
sys.exit(0 if all(r["verdict"] == "PASS" for r in out) and len(out) == len(a.gates) else 1)
@@ -0,0 +1,12 @@
"""Serve the web app on 127.0.0.1:5057 with a throw-away data copy (검증/ui_check_data) for screenshots (stage B UI check, 2026-10-16)."""
import os
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
os.environ["PIERAPP_DATA"] = str(APP / "검증" / "ui_check_data")
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
webapp.app.run(host="127.0.0.1", port=5057, debug=False, use_reloader=False)
@@ -0,0 +1,227 @@
"""Stage C (part 1) test of the 입력 묶음 확인·확정 개편 (2026-10-19), no Rhino: anchors (engine/anchors.py) learned from one modelled pier and followed
into the drawing of another, the web screen, the apply rules and the effect on the app check. Own data root 검증/anchors_test_data (recreated).
Acceptance (기획서 4.2 / 6장): grade A has no wrong candidate in the same series, a drawing frame that differs gets no A / B candidate (only C), a
candidate is never invented where nothing is found, the R2 chain check finds the known drawing error, and the app never applies a candidate by itself.
"""
import itertools
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "anchors_test_data"
if TD.exists():
shutil.rmtree(TD)
D = APP / "data"
names = ["산외교울산P1", "산외교울산P2", "운곡교P1", "운곡교p2", "운곡교P3", "운곡교P4", "새재1교P1", "옥성교P1", "Ramp-A교P1"]
SERIES = {"산외교울산P1": "U", "산외교울산P2": "U", "운곡교P1": "G", "운곡교p2": "G", "운곡교P3": "G", "운곡교P4": "G", "새재1교P1": "S", "옥성교P1": "O", "Ramp-A교P1": "R"}
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "web"))
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD) # BEFORE the first import of store / app (store reads DATA at import time)
import app as webapp # noqa: E402
import store # noqa: E402
from engine import anchors # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
def src(n):
"""data/<n>, or the newest copy in data/_휴지통 when the pier was deleted in real use (운곡교p2 was, 2026-10-19); read only."""
d = D / n
if (d / "params.json").exists():
return d
trash = sorted(t for t in (D / "_휴지통").glob(f"{n}_*") if t.name.startswith(n + "_")) # exact case: Windows globbing is case-insensitive (운곡교p2 / 운곡교P2)
return trash[-1] if trash else d
def load(n):
return [json.loads((src(n) / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json", "drawings/texts.json")]
def get(docs, field):
return store.get_field(docs, field)
L = {n: load(n) for n in names}
# ---- 1. same series: grade A has no wrong candidate; B is mostly right; C is weak and labelled so
st = {g: [0, 0] for g in "ABC"}
recovered = 0
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
Pa, Ea, Ta = L[a]
Pb, Eb, Tb = L[b]
anc = anchors.learn(Pa, Ea, Ta)
cand = anchors.follow(anc, Tb)
for f, c in cand.items():
tv = get({"params": Pb, "expected": Eb}, f)
if tv is None:
continue
good = abs(abs(tv) - abs(c["value"])) < 1.5
st[c["grade"]][0] += 1
st[c["grade"]][1] += good
recovered += c["grade"] == "A" and good and abs(abs(c["source_value"]) - abs(tv)) > 1.5
check(f"{a} -> {b}: frame coverage >= 70 %", next(iter(cand.values()))["coverage"] >= 0.7, next(iter(cand.values()))["coverage"])
check(f"same series: grade A all right ({st['A'][1]}/{st['A'][0]}), at least 30 A candidates", st["A"][0] >= 30 and st["A"][1] == st["A"][0], st["A"])
check(f"same series: changed values recovered by A candidates ({recovered}) >= 10", recovered >= 10)
check(f"same series: grade B right in >= 80 % ({st['B'][1]}/{st['B'][0]})", st["B"][0] and st["B"][1] / st["B"][0] >= 0.8, st["B"])
print("INFO grade C right", st["C"], "(shown to the user as weak, needs a reason to apply)")
# ---- 1b. table cross-check: a field with a table value is confirmed (A) or corrected (C) by it
import copy # noqa: E402
from engine import reader # noqa: E402
st2 = {g: [0, 0] for g in "ABC"}
n_conf = n_low = 0
for a, b in [("산외교울산P1", "산외교울산P2"), ("산외교울산P2", "산외교울산P1"), ("운곡교P1", "운곡교p2"), ("운곡교p2", "운곡교P1")]:
Pa, Ea, Ta = L[a]
Pb, Eb, Tb = L[b]
cand = anchors.follow(anchors.learn(Pa, Ea, Ta), Tb)
tab = anchors.table_values(reader.proposals(reader.field_candidates(reader.table_candidates(Tb)), Pb, Eb))
after = anchors.cross_check(copy.deepcopy(cand), tab)
n_conf += sum(1 for c in after.values() if c.get("table_confirmed"))
n_low += sum(1 for c in after.values() if c.get("table_confirmed") is False)
for f, c in after.items():
tv = get({"params": Pb, "expected": Eb}, f)
if tv is None:
continue
st2[c["grade"]][0] += 1
st2[c["grade"]][1] += abs(abs(tv) - abs(c["value"])) < 1.5
check(f"table cross-check: grade A is still all right ({st2['A'][1]}/{st2['A'][0]}) and larger than before ({st['A'][0]})", st2["A"][0] > st["A"][0] and st2["A"][1] == st2["A"][0], st2["A"])
check(f"table cross-check: {n_conf} candidates confirmed by a table value, {n_low} lowered to C by a different one", n_conf >= 100)
# ---- 2. a different drawing frame: no A / B candidate, nothing invented
bad_frames = []
for a, b in itertools.permutations(names, 2):
if SERIES[a] == SERIES[b]:
continue
anc = anchors.learn(*L[a][:2], L[a][2])
cand = anchors.follow(anc, L[b][2])
n_ab = sum(1 for c in cand.values() if c["grade"] in ("A", "B"))
if n_ab:
bad_frames.append((a, b, n_ab))
check("different series (frame coverage < 70 %): no A or B candidate in any of the other-series pairs", not bad_frames, bad_frames[:3])
anc = anchors.learn(*L["Ramp-A교P1"][:2], L["Ramp-A교P1"][2])
check("a drawing with none of the anchors' texts gives no candidate at all", not anchors.follow(anc, L["운곡교P1"][2]) or all(c["grade"] == "C" for c in anchors.follow(anc, L["운곡교P1"][2]).values()))
assert Path(str(store.DATA)) == TD, store.DATA # the test never touches data/
check("anchors skip values below 1 m and fields of F / V3 bundles", all(abs(a["v"]) >= 1000 for a in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"].values())
and not any(f.startswith(("params:coping_rebar", "params:footing_rebar", "params:scope")) for f in anchors.learn(*L["옥성교P1"][:2], L["옥성교P1"][2])["fields"]))
# ---- 3. R2 chain check: the known error of the Ungok P4 drawing (62@125=7.250 and 29@250=7.750 are swapped totals), no false alarm elsewhere
bad = {n: [c["text"] for c in anchors.chains(L[n][2]) if not c["ok"]] for n in names}
check("R2: Ungok P4 drawing has the two inconsistent chains", sorted(bad["운곡교P4"]) == sorted(["62@125=7.250", "29@250=7.750"]), bad["운곡교P4"])
check("R2: no inconsistent chain in the other 8 drawings", all(not v for n, v in bad.items() if n != "운곡교P4"), {n: v for n, v in bad.items() if v and n != "운곡교P4"})
# ---- 4. web flow: a new Ungok pier (template 운곡교P1형) with the drawing index of Ungok P2 (a different pier of the same series)
for n in ("운곡교p2",):
shutil.copytree(src(n), TD / "_src" / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "confirm.json"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
c = webapp.app.test_client()
c.post("/pier/new", data={"name": "앵커시험", "type": "T형", "template": "tpl:운곡교P1형"})
p = store.Pier("앵커시험")
check("new pier carries the template's anchors", (p.dir / "anchors.json").exists() and not p.texts())
rows, miss, meta = p.anchor_rows()
check("no drawing yet -> no candidates (nothing invented)", rows == [] and meta["texts"] is False)
shutil.copytree(TD / "_src" / "운곡교p2" / "drawings", p.drawings, dirs_exist_ok=True) # "upload": index + converted DXF + audit of Ungok P2
aud = store.rjson(p.drawings / "audit.json", [])
for a in aud:
a["source_sha256"] = "x" + a.get("source_sha256", "")[1:] # a different file than the template's source drawing
store.wjson(p.drawings / "audit.json", aud)
rows, miss, meta = p.anchor_rows()
tr = store.Pier("운곡교p2") if False else None
P2 = json.loads((src("운곡교p2") / "params.json").read_text(encoding="utf-8"))
E2 = json.loads((src("운곡교p2") / "expected.json").read_text(encoding="utf-8"))
diff_a = [r for r in rows if r["grade"] == "A" and not r["same"]]
check(f"new drawing: coverage >= 70 %, {len(rows)} candidates, {len(diff_a)} A candidates differ from the template's values", meta["coverage"] >= 0.7 and diff_a, meta)
check("every differing A candidate equals the value of the modelled pier 운곡교p2 (no wrong A)", all(abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) < 1.5
for r in diff_a), [r["field"] for r in diff_a if abs(abs(store.get_field({"params": P2, "expected": E2}, r["field"]) or 0) - abs(r["value"])) >= 1.5])
html = c.get("/pier/앵커시험/candidates").get_data(as_text=True)
check("candidates page lists the out-of-table candidates with grades, positions and the view link", "표 밖 치수 후보" in html and "등급 A" in html and "도면 보기" in html and "도면 틀 일치율" in html)
check("candidate page offers the one-file quick selection of grade A rows and shows the table confirmation", 'data-sel-a="expected"' in html and 'data-sel-a="params"' in html and "표 값과도 일치" in html)
check("the app applied nothing by itself", p.load("params") == json.loads((D / "_템플릿" / "운곡교P1형" / "params.json").read_text(encoding="utf-8")))
# ---- 5. app check: the A candidates that differ make the bundle 불일치; applying them makes it agree
a0 = p.run_autocheck(force=True)["sections"]
bundles = sorted({r["field"].rsplit(".", 0)[0].split(".")[0] for r in diff_a})
hit = [b for b in bundles if a0[b]["st"] == "b" and any(x["id"] == "C2b" and x["st"] == "b" for x in a0[b]["checks"])]
check("bundles with a differing A candidate are 불일치 by C2b", set(hit) == set(bundles) and hit, {b: a0[b]["st"] for b in bundles})
# ---- 6. apply rules
pa = [r for r in diff_a if r["file"] == "params"]
ex = [r for r in diff_a if r["file"] == "expected"]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": []}, follow_redirects=True)
check("applying nothing is refused", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
same_rows = [r for r in rows if r["same"]]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [same_rows[0]["field"]]}, follow_redirects=True)
check("a candidate equal to the current value is not applied", "반영할 후보를 고르세요" in r0.get_data(as_text=True))
twin = next(((a, b) for a in pa for b in ex if (a["text"], a["x"], a["y"], a["value"]) == (b["text"], b["x"], b["y"], b["value"])), None)
if twin:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin[0]["field"], twin[1]["field"]]}, follow_redirects=True)
check("the same drawing text into params and expected together is refused (P7)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
weak = next((r for r in rows if r["grade"] in ("B", "C") and not r["same"] and r["grade"] == "C"), None)
if weak:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [weak["field"]]}, follow_redirects=True)
check("a grade C candidate needs a reason", "사유를 입력" in r0.get_data(as_text=True))
first = pa[0]
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [first["field"]]}, follow_redirects=True)
check("applying a differing A candidate writes the value and a log entry", abs(store.get_field({"params": p.load("params"), "expected": p.load("expected")}, first["field"]) - first["value"]) < 1e-6
and store.rjson(p.dir / "anchor_log.json", [])[-1]["fields"][0]["field"] == first["field"])
twin_b = next((b for b in ex if (b["text"], b["x"], b["y"]) == (first["text"], first["x"], first["y"])), None)
if twin_b:
r0 = c.post("/pier/앵커시험/anchors/apply", data={"field": [twin_b["field"]]}, follow_redirects=True)
check("the same text may not be applied to expected later either (P7 across requests)", "함께 반영할 수 없습니다" in r0.get_data(as_text=True))
check("the applied bundle needs a new confirmation", not p.section_state(first["field"].split(":")[0], first["field"].split(":")[1].split(".")[0])["confirmed"])
# ---- 7. a pier without anchors can load them from a template; own-drawing anchors are no evidence
q = store.Pier("앵커시험")
c.post("/pier/new", data={"name": "복사시험", "type": "T형", "template": "tpl:산외교울산P2형"})
q2 = store.Pier("복사시험")
os.remove(q2.dir / "anchors.json")
r0 = c.post("/pier/복사시험/anchors/load", data={"template": "산외교울산P1형"}, follow_redirects=True)
check("anchors can be loaded from a template into a pier without anchors", (q2.dir / "anchors.json").exists() and "앵커를 불러왔습니다" in r0.get_data(as_text=True))
shutil.copytree(D / "산외교울산P2" / "drawings", q2.drawings, dirs_exist_ok=True, ignore=shutil.ignore_patterns("dwg", "dxf"))
os.remove(q2.dir / "anchors.json")
shutil.copy2(D / "_템플릿" / "산외교울산P2형" / "anchors.json", q2.dir / "anchors.json") # anchors learned from this very drawing
aud = store.rjson(q2.drawings / "audit.json", [])
srcs = {a["source_sha256"] for a in aud}
anc2 = store.rjson(q2.dir / "anchors.json")
check("template anchors remember the source drawing", set(anc2["sources"]) == srcs, anc2["sources"][:1])
r2, _m, meta2 = q2.anchor_rows()
check("own-drawing anchors are flagged and give no evidence to the app check", meta2["self_source"] and not any(
x["id"] == "C2b" for r in q2.run_autocheck(force=True)["sections"].values() for x in r["checks"]))
# ---- 8. the region image
ent = aud[0]
shutil.copytree(D / "산외교울산P2" / "drawings" / "dxf", q2.drawings / "dxf", dirs_exist_ok=True)
r0 = c.get(f"/pier/복사시험/view/{ent['label']}?x=-85062&y=-14500&half=3000")
check("region image route returns a PNG", r0.status_code == 200 and r0.data[:4] == b"\x89PNG" and len(r0.data) > 5000, (r0.status_code, len(r0.data)))
check("bad view arguments are refused", c.get("/pier/복사시험/view/D1?x=abc&y=1").status_code == 400 and c.get("/pier/복사시험/view/D9?x=1&y=1").status_code == 404)
# ---- 9. pier page notice for the drawing's own chain errors
shutil.copytree(D / "운곡교P4", TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf"))
h4 = c.get("/pier/운곡교P4").get_data(as_text=True)
check("pier page warns about the inconsistent drawing chains", "치수 사슬 2건이 검산에 맞지 않습니다" in h4 and "62@125=7.250" in h4)
# ---- 10. improvement #34-4 (2026-10-20): a candidate whose only followed instance is ONE schedule cell is demoted to C (Ungok P3 / P4: the S1 length cell
# '3.800' was followed as the end of a rebar chain and graded B)
anc1 = anchors.learn(*L["운곡교P1"][:2], L["운곡교P1"][2])
for tgt in ("운곡교P3", "운곡교P4"):
cand = anchors.follow(anc1, L[tgt][2])
lone = {f: c for f, c in cand.items() if c["followed"] == 1 and "TABL" in c["layer"]}
check(f"{tgt}: {len(lone)} candidates rest on one table cell and none is graded A or B", lone and all(c["grade"] == "C" and c["table_only"] for c in lone.values()),
[(f, c["grade"]) for f, c in lone.items() if c["grade"] != "C"][:3])
check(f"{tgt}: the false chain-end candidate G3.rows.X_250.ends is not offered above C",
all(cand[f]["grade"] == "C" for f in cand if f.startswith("expected:G3.rows.X_250.ends")))
others = [c for c in cand.values() if "TABL" not in c["layer"] or c["followed"] > 1]
check(f"{tgt}: candidates with several instances or off-table instances keep their grade (A {sum(c['grade'] == 'A' for c in others)})", any(c["grade"] == "A" for c in others))
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,174 @@
"""Stage B test of the 입력 묶음 확인·확정 개편 (2026-10-16), no Rhino: the app check (engine/verify.py), the evidence page, the reason rule for
bundles that are not 일치, the D4 bulk confirmation, and the report chapter. Own data root 검증/autocheck_test_data (recreated); real piers are
not touched. Acceptance (기획서 6장): no false 일치 / no false 불일치 on the real piers, a changed value is caught, the app never writes a
confirmation, nothing is hidden.
"""
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "autocheck_test_data"
if TD.exists():
shutil.rmtree(TD)
def _src(n):
"""data/<n>, else the newest recycle-bin copy (a pier may be deleted in real use: 운곡교P3 on 2026-10-20)"""
d = APP / "data" / n
if d.exists():
return d
return sorted((APP / "data" / "_휴지통").glob(f"{n}_*"))[-1]
for n in ("새재1교P1", "산외교울산P2", "옥성교P1", "운곡교P1", "운곡교P3", "Ramp-A교P1"):
shutil.copytree(_src(n), TD / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf", "confirm.json", "autocheck.json",
"confirm_view.json", "edit_log.json"))
# 운곡교P3 holds the finished (gate-verified) values since 2026-10-19; this test wants the WRONG-input state (values of another pier, P3 drawing) -> the first backup
_bk = sorted((_src("운곡교P3") / "backup").glob("before_anchor_A_*"))
if _bk:
for _f in ("params.json", "expected.json"):
shutil.copy2(_bk[0] / _f, TD / "운곡교P3" / _f)
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
import store # noqa: E402
from engine import verify # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
def st_of(p, b, force=True):
a = p.run_autocheck(force=force)
return a["sections"][b]["st"]
# ---- 1. real piers: no false 불일치 (every bundle that is not a known data problem), P3 (an unmodelled copy of P1) is caught
for n in ("새재1교P1", "산외교울산P2", "옥성교P1", "운곡교P1", "Ramp-A교P1"):
p = store.Pier(n)
a = p.run_autocheck(force=True)
bad = [b for b, r in a["sections"].items() if r["st"] == "b"]
check(f"{n}: no 불일치 on its own data", not bad, bad)
req = {f"{f}:{k}" for f, k in webapp.required(p)}
miss = [b for b in req if b not in a["sections"]]
check(f"{n}: every required bundle has an app-check status", not miss, miss)
check(f"{n}: app check wrote no confirmation", not store.rjson(TD / n / "confirm.json", {}))
p3 = store.Pier("운곡교P3")
a3 = p3.run_autocheck(force=True)
bad3 = sorted(b for b, r in a3["sections"].items() if r["st"] == "b")
check("운곡교P3 (values of another pier, P3 drawing): the table bundles are 불일치", {"expected:G1", "expected:G5", "params:bearing_seats", "params:sole_plates"} <= set(bad3), bad3)
# ---- 2. a changed value is caught (no false 일치): 새재1교P1
p = store.Pier("새재1교P1")
base = p.run_autocheck(force=True)["sections"]
check("새재1교P1: bearing_seats / sole_plates / expected.G5 start as 일치", all(base[b]["st"] == "g" for b in ("params:bearing_seats", "params:sole_plates", "expected:G5")),
{b: base[b]["st"] for b in ("params:bearing_seats", "params:sole_plates", "expected:G5")})
P0 = p.load("params")
P0["bearing_seats"]["bearings"][0]["h1"] += 5
p.save("params", P0)
check("changed bearing h1 -> params:bearing_seats 불일치", st_of(p, "params:bearing_seats") == "b")
P0["bearing_seats"]["bearings"][0]["h1"] -= 5
p.save("params", P0)
check("restored -> 일치 again", st_of(p, "params:bearing_seats") == "g")
E0 = p.load("expected")
E0["G5"]["el_x"]["1"] += 1
p.save("expected", E0)
check("changed expected.G5.el_x -> 불일치", st_of(p, "expected:G5") == "b")
E0["G5"]["el_x"]["1"] -= 1
p.save("expected", E0)
P0 = p.load("params")
P0["coping_rebar"]["bars"] = [b for b in P0["coping_rebar"]["bars"] if not (b["mark"] == "C7" and b == next(x for x in P0["coping_rebar"]["bars"] if x["mark"] == "C7"))]
p.save("params", P0)
r = p.run_autocheck(force=True)["sections"]["params:coping_rebar"]
check("one coping bar C7 removed -> coping_rebar 불일치 (C4 count)", r["st"] == "b" and any(c["id"] == "C4" and c["st"] == "b" for c in r["checks"]), r["summary"])
# the count-only bundle is never 일치 (no false 일치): a polylines bundle with all counts right is 주의
p2 = store.Pier("옥성교P1")
r2 = p2.run_autocheck(force=True)["sections"]
check("옥성교P1: coping_rebar / footing_rebar (counts only) are 주의, not 일치", r2["params:coping_rebar"]["st"] == "w" and r2["params:footing_rebar"]["st"] == "w",
(r2["params:coping_rebar"]["st"], r2["params:footing_rebar"]["st"]))
# a range error
P0 = p.load("params")
P0["footing"]["thickness"] = -5
p.save("params", P0)
check("negative thickness -> footing 불일치 (C6)", st_of(p, "params:footing") == "b")
# ---- 3. new pier from a template: original values are 근거 없음 (C3), and a bulk confirmation does not touch them
c = webapp.app.test_client()
c.post("/pier/new", data={"name": "신규시험", "type": "T형", "template": "tpl:새재1교P1형"})
q = store.Pier("신규시험")
aq = q.run_autocheck(force=True)["sections"]
req = webapp.required(q)
orig = [f"{f}:{k}" for f, k in req if q.section_meta(f, k)["origin"] and q.section_meta(f, k)["grade"] != "F"] # F bundles are judged by C7 (same as the template = 일치)
check("new pier: every required non-F bundle that is still the template original is not 일치 unless a table supports it",
all(aq[b]["st"] != "g" or any(x["id"] == "C2" and x["st"] == "g" for x in aq[b]["checks"]) for b in orig), [b for b in orig if aq[b]["st"] == "g"])
check("new pier: original bundles without table support are 근거 없음 (C3)", all(aq[b]["st"] in ("n", "x") or any(x["id"] == "C2" and x["st"] == "g" for x in aq[b]["checks"])
for b in orig), {b: aq[b]["st"] for b in orig if aq[b]["st"] not in ("n", "x")})
# the evidence page
r = c.get("/pier/신규시험/evidence/params/footing")
html = r.get_data(as_text=True)
check("evidence page renders with the 8 checks and the status", r.status_code == 200 and all(f"C{i}" in html for i in range(1, 9)) and "근거 없음" in html)
check("pier page shows the summary strip, the app-check column and the Prism block", "앱 확인 일치" in c.get("/pier/신규시험").get_data(as_text=True)
and 'class="prism"' in c.get("/pier/신규시험").get_data(as_text=True))
# ---- 4. a bundle that is not 일치 needs a reason; with it the record keeps reason and status
r = c.post("/pier/신규시험/section/params/footing/confirm", data={"by": "시험자"}, follow_redirects=True)
check("confirm of a 근거 없음 bundle without reason refused", "사유를 입력" in r.get_data(as_text=True) and not q.section_state("params", "footing")["confirmed"])
c.post("/pier/신규시험/section/params/footing/confirm", data={"by": "시험자", "why": "도면 정면도 7.700과 직접 대조"})
s = q.section_state("params", "footing")
check("with reason: confirmed, reason and app status recorded", s["confirmed"] and s["why"] == "도면 정면도 7.700과 직접 대조" and s["app_st"] == "n", s)
# ---- 5. D4: 일치 묶음 일괄 확정 only touches 일치 bundles, needs the evidence table read, a name, and is refused when the list changed
p = store.Pier("새재1교P1")
p.save("params", json.loads(json.dumps(p.load("params")))) # keep file; reset the damage below
src = json.loads((APP / "data" / "새재1교P1" / "params.json").read_text(encoding="utf-8"))
p.save("params", src)
p.save("expected", json.loads((APP / "data" / "새재1교P1" / "expected.json").read_text(encoding="utf-8")))
tg = webapp.matched_targets(p)
check("matched targets are exactly the 일치 / unconfirmed required bundles", tg and all(p.section_check(f, k)["st"] == "g" for f, k in tg), len(tg))
r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True)
check("bulk confirm refused before the evidence table was read", "끝까지 읽은 뒤" in r.get_data(as_text=True) and not any(p.section_state(f, k)["confirmed"] for f, k in tg))
r = c.get("/pier/새재1교P1/confirm_matched")
check("evidence table page lists the targets with their basis", r.status_code == 200 and all(f"{f}.{k}" in r.get_data(as_text=True) for f, k in tg[:3]))
check("opening the page alone does not mark it as read", not store.rjson(TD / "새재1교P1" / "confirm_view.json", {}))
c.post("/pier/새재1교P1/confirm_matched/read")
r = c.post("/pier/새재1교P1/confirm_matched", data={"by": ""}, follow_redirects=True)
check("bulk confirm needs a name", "확정자 이름" in r.get_data(as_text=True) and not any(p.section_state(f, k)["confirmed"] for f, k in tg))
r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True)
conf = {f"{f}:{k}" for f, k in tg if p.section_state(f, k)["confirmed"]}
others = [s for s in p.sections() if not p.section_state(s["file"], s["key"])["confirmed"] and (s["file"], s["key"]) in webapp.required(p)]
check("bulk confirm confirmed exactly the 일치 bundles", conf == {f"{f}:{k}" for f, k in tg} and all(p.section_check(s["file"], s["key"])["st"] != "g" for s in others),
(len(conf), len(others)))
check("records keep the name and app status g", all(p.confirmations()[b].get("app_st") == "g" and p.confirmations()[b]["by"] == "시험자" for b in conf))
# the marker is bound to the target list: after a change the old marker is useless
P0 = p.load("params")
P0["bearing_seats"]["bearings"][1]["h1"] += 3
p.save("params", P0)
c.post("/pier/새재1교P1/confirm_matched/read")
P0["bearing_seats"]["bearings"][1]["h1"] -= 3
p.save("params", P0)
q2 = store.Pier("새재1교P1")
for f, k in tg[:2]: # re-open two targets by editing nothing: remove their confirmation
cf = q2.confirmations(); cf.pop(f"{f}:{k}", None); store.wjson(TD / "새재1교P1" / "confirm.json", cf)
r = c.post("/pier/새재1교P1/confirm_matched", data={"by": "시험자"}, follow_redirects=True)
check("a stale marker (target list changed since it was read) is refused", "끝까지 읽은 뒤" in r.get_data(as_text=True))
# ---- 6. the legacy bulk route still works (stage D decides) and the report carries chapter 8.1
r = c.post("/pier/신규시험/confirm_all", data={"by": "시험자"})
check("the old 필요한 묶음 일괄 확정 route is gone (2026-10-19)", r.status_code in (404, 405), r.status_code)
confirm_required(c, webapp, store, "신규시험", "시험자")
check("every required bundle can still be confirmed one by one with a reason", not webapp.unconfirmed(store.Pier("신규시험")))
from engine import report # noqa: E402
md, d = report.build(TD / "신규시험", TD / "신규시험" / "runs" / "current", "시험자", "2026-10-16")
check("report chapter 8.1 lists the app-check summary and the human confirmation", "### 8.1 입력 묶음 앱 확인 결과 요약" in md and "근거 없음" in md and "시험자" in md)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,154 @@
"""Test of improvement #34-5 (2026-10-20): a batch run that did not finish must not look like a finished one. No Rhino. Own data root
검증/batch_notice_test_data (recreated): a copy of Ungok P4 whose run folder is built from the json files of its real runs (copied) with chosen file times.
Acceptance: the gates page says WHY the run stopped (error / FAIL / blocked / killed), marks the results that are older than the run, shows the partial
results computed before it stopped (and only then), labels an approval of an earlier input as void, and shows nothing of this for a finished run."""
import json
import os
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
D = APP / "data"
TD = APP / "검증" / "batch_notice_test_data"
if TD.exists():
shutil.rmtree(TD)
sys.path.insert(0, str(APP / "tools"))
from testlib import verified_pier # noqa: E402
src = verified_pier("운곡교P4")
if src is None or not (src / "runs" / "current" / "G1_gate.json").exists():
print("SKIP no real run of 운곡교P4")
print("RESULT PASS")
sys.exit(0)
shutil.copytree(src, TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf", "confirm.json"))
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
import store # noqa: E402
assert Path(str(store.DATA)) == TD
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
p = store.Pier("운곡교P4")
run = p.run_dir
run.mkdir(parents=True, exist_ok=True)
def fresh():
for f in run.glob("*.json"):
f.unlink()
for f in (src / "runs" / "current").glob("*.json"):
if f.name.startswith("G") and not f.name.endswith(("_negH.json", "_negV.json")) or f.name == "approvals.json":
shutil.copy2(f, run / f.name)
def at(path, ago_s):
t = time.time() - ago_s
os.utime(path, (t, t))
def batch(state, **kw):
b = {"state": state, "by": "시험자", "pid": os.getpid(), "started": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 600)),
"at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - 60)), "log": ["G1~G5 단일 세션 실행", "[batch] one Rhino session"]}
b.update(kw)
store.wjson(run / "batch.json", b)
def page():
return webapp.app.test_client().get("/pier/운곡교P4/gates").get_data(as_text=True)
# ---- A. error: old recorded results, newer checker outputs
fresh()
for g in ("G1", "G2", "G3", "G4", "G5"):
at(run / f"{g}_gate.json", 20000) # recorded long ago (an earlier run)
at(run / f"{g}.json", 300) # checker output of the aborted run (newer)
batch("중단", reason="error", error="RuntimeError: display: hidden_ids not found ['P1-CP']")
h = page()
check("error: the page says the run did not finish and why (error text shown)", 'id="batch-aborted"' in h and "오류" in h and "hidden_ids not found" in h)
check("error: results older than the run are marked 이전 실행 결과", h.count("이전 실행 결과") >= 5)
check("error: partial results of the aborted run are shown per gate and labelled as not used for approval", 'id="partial-G1"' in h and "임시 결과" in h and "승인·보고서에는 쓰이지 않습니다" in h)
check("error: the partial block reports the checker's own pass / total", f"{json.loads((run / 'G1.json').read_text(encoding='utf-8'))['summary']['passed']} / " in h)
check("partial_result() returns the counts and the failed items", p.partial_result("G3") and p.partial_result("G3")["total"] and isinstance(p.partial_result("G3")["fails"], list))
# ---- B. the recorded result is NEWER than the checker output (a normal finished gate): no partial block, no 'older' mark
at(run / "G1_gate.json", 100)
at(run / "G1.json", 300)
check("a recorded result newer than the checker output is not a partial result", p.partial_result("G1") is None)
# ---- C. FAIL stop
batch("중단", reason="fail", gate="G3")
check("fail: names the gate that stopped the run", "G3</b>가 FAIL" in page())
# ---- D. blocked / killed
batch("중단", reason="blocked")
check("blocked: says the run could not start", "시작하지 못했습니다" in page())
batch("실행 중", pid=os.getpid() + 1) # a run of another (dead) server process
h = page()
check("killed: a 실행 중 record of another process is shown as 중단됨 with its reason", "서버가 멈추거나" in h and "중단됨" in h)
# ---- E. finished run: nothing of the abort notice
batch("완료")
h = page()
check("finished run: no abort banner, no partial block, no 'older' mark", 'id="batch-aborted"' not in h and 'id="partial-' not in h and "이전 실행 결과 —" not in h)
# ---- F. an approval of an earlier input is labelled void
ap = store.rjson(run / "approvals.json", {})
for g in ap:
ap[g]["inputs_sha"] = "0000000000000000"
store.wjson(run / "approvals.json", ap)
h = page()
st = p.gate_status("G1")
check("approval of an earlier input: approval_stale and the label 승인 기록은 이전 입력 것(무효)", st["approval_stale"] and not st["approved"] and "승인 기록은 이전 입력 것(무효)" in h)
check("the void approval is not shown as a plain 승인", "· 승인</h2>" not in h and 'class="ok">승인</span>' not in h)
# ---- G. the real worker (app._batch) with a fake Rhino session: it records started + a reason on every stop path
fresh()
need = ["G1", "G2", "G3", "G4", "G5"]
for f in run.glob("*_gate.json"):
f.unlink() # no current PASS result -> the worker has to start a session
for f in run.glob("approvals.json"):
f.unlink()
webapp.gate_blockers = lambda pp, g: []
def boom(*a, **k):
raise RuntimeError("fake Rhino failure")
webapp.run_batch_session = boom
webapp._batch(p, "시험자", need)
b = store.rjson(run / "batch.json")
check("worker: a failure inside the Rhino session -> 중단 with reason error, the last traceback line, and a start time",
b["state"] == "중단" and b["reason"] == "error" and "fake Rhino failure" in b["error"] and b.get("started"), {k: b.get(k) for k in ("state", "reason", "error", "started")})
def fake_session(gates, *a, **k):
rep = {"verdict": "PASS", "summary": {"passed": 3, "total": 3}, "planned_fails": [], "unplanned_fails": [], "negative": {}, "mode": "batch"}
return {g: dict(rep, verdict="FAIL" if g == "G2" else "PASS") for g in gates}
webapp.run_batch_session = fake_session
webapp._batch(p, "시험자", need)
b = store.rjson(run / "batch.json")
check("worker: a FAIL result -> 중단 with reason fail and the failing gate", b["state"] == "중단" and b.get("reason") == "fail" and b.get("gate") == "G2", {k: b.get(k) for k in ("state", "reason", "gate")})
g1 = store.rjson(run / "G1_gate.json")
check("worker: every recorded gate result carries its own time (at)", g1.get("at") and store.rjson(run / "G2_gate.json").get("at"))
check("worker: G1 (PASS before the failing gate) was approved, G2 was not", p.approvals().get("G1", {}).get("decision") == "승인" and "G2" not in p.approvals())
webapp.gate_blockers = lambda pp, g: ["G2: 입력 묶음 미확정"] if g == "G2" else []
webapp._batch(p, "시험자", need)
b = store.rjson(run / "batch.json")
check("worker: a blocked run -> 중단 with reason blocked", b["state"] == "중단" and b.get("reason") == "blocked", {k: b.get(k) for k in ("state", "reason")})
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,68 @@
"""Confirmation scope test (2026-10-13): run settings (scope / builders / checkers / negative) need confirmation,
reference-only sections are shown as "확정 불필요(참고)". No Rhino.
Uses its own data root 검증/confirm_test_data (recreated) with piers made from the two protected templates."""
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "confirm_test_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
ok = True
RUN = {("params", "scope"), ("params", "builders"), ("expected", "checkers"), ("expected", "negative")}
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:200], flush=True)
c = webapp.app.test_client()
for nm, tpl in (("울산", "산외교울산P1형"), ("운곡", "운곡교P1형")):
c.post("/pier/new", data={"name": nm, "type": "T형", "template": "tpl:" + tpl})
u, w = store.Pier("울산"), store.Pier("운곡")
need_u = set(webapp.unconfirmed(u))
check("Ulsan: run settings required", RUN <= need_u, sorted(RUN - need_u))
check("Ulsan: run settings required by every gate in scope",
all(RUN <= {(r["file"], r["key"]) for r in u.gate_requirements(g)} for g in u.scope()))
need_w = set(webapp.unconfirmed(w))
check("Ungok: absent run settings not required", not (RUN & need_w), sorted(RUN & need_w))
info = {(s["file"], s["key"]) for s in u.sections() if store.is_info(s["key"])}
check("reference sections not required", info and not (info & need_u), sorted(info & need_u))
check("reference keys", {k for _, k in info} == {"_schema", "_meta", "_note", "rebar_marks_src"}, sorted(info))
b = webapp.gate_blockers(u, "G1")
check("G1 blocked while run settings unconfirmed", "params.builders 미확정" in b and "expected.checkers 미확정" in b, b)
confirm_required(c, webapp, store, "울산", "시험")
check("confirming every required bundle clears all required", webapp.unconfirmed(u) == [], webapp.unconfirmed(u))
check("G1 not blocked after confirming every required bundle", webapp.gate_blockers(u, "G1") == [], webapp.gate_blockers(u, "G1"))
left = [s["key"] for s in u.sections() if not u.section_state(s["file"], s["key"])["confirmed"]]
check("only reference sections left unconfirmed", set(left) == {k for _, k in info}, left)
h = c.get("/pier/울산").get_data(as_text=True)
check("pier page shows reference label", h.count("확정 불필요(참고)") == len(info), h.count("확정 불필요(참고)"))
check("pier page shows no plain 미확정 row", '<span class="muted">미확정</span>' not in h)
h = c.get("/pier/울산/section/params/_meta").get_data(as_text=True)
check("section page shows reference label", "확정 불필요(참고)" in h)
X = u.load("expected")
X["negative"]["G1"][0]["d"] = [4, 0, 0]
u.save("expected", X)
check("edited run setting needs re-confirm", ("expected", "negative") in webapp.unconfirmed(u)
and u.section_state("expected", "negative")["stale"])
shutil.rmtree(TD)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,124 @@
"""Test of improvements #34-2 (params <-> expected consistency, C9), #34-3 (field-level 'still the creation value', C3b) and #34-6 (point-list rule),
2026-10-20. No Rhino, no writes under data/ (reads only). Real piers: the finished 운곡교 P1 / P3 / P4 must show no mismatch; the backups of P3 / P4 taken
during the first real-use runs (data/<pier>/backup/before_*) hold the exact stale states that cost two gate runs each -- they must be caught.
Skipped parts (data missing) are reported, not failed."""
import copy
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from testlib import verified_pier # noqa: E402
from engine import consistency, verify # noqa: E402
D = APP / "data"
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
def load(d):
return json.loads((d / "params.json").read_text(encoding="utf-8")), json.loads((d / "expected.json").read_text(encoding="utf-8"))
def origin(d):
f = d / "origin_values.json"
return json.loads(f.read_text(encoding="utf-8")) if f.exists() else None
def run(P, E, org=None):
return verify.verify(P, E, [], {}, origin_values=org)
def c_of(res, b, cid):
return next(c for c in res[b]["checks"] if c["id"] == cid)
def bad_c9(res):
return sorted(b for b, r in res.items() if any(c["id"] == "C9" and c["st"] == "b" for c in r["checks"]))
# ---- 1. finished piers: no false mismatch
for n in ("운곡교P1", "운곡교P3", "운곡교P4"):
d = D / n if n == "운곡교P1" else verified_pier(n) # P3 / P4: the gate-verified copy (a live pier may be half-updated while it is being made again)
if d is None or not d.exists():
print("SKIP", n)
continue
P, E = load(d)
r = consistency.check(P, E)
check(f"{n}: {len(r)} linked values, none differ", r and all(x["ok"] for x in r), [x["id"] for x in r if not x["ok"]][:5])
res = run(P, E, origin(d))
check(f"{n}: C9 never b / C3b never w on the finished state", not bad_c9(res) and not any(c["id"] == "C3b" and c["st"] == "w" for r_ in res.values() for c in r_["checks"]),
bad_c9(res))
# ---- 2. the stale states of the first real-use runs are caught
p3 = D / "운곡교P3"
if p3.exists() and list((p3 / "backup").glob("before_hooks_up_*")):
bk = sorted((p3 / "backup").glob("before_hooks_up_*"))[0]
P, E = load(bk)
r = [x for x in consistency.check(P, E) if not x["ok"]]
check("P3 before_hooks_up: the 4 hooks_up points that no longer exist in c14.points are found (#34-6)",
sorted(x["id"] for x in r) == [f"coping_rebar.c14.hooks_up.{i}" for i in range(4)], [x["id"] for x in r])
res = run(P, E, origin(p3))
check("P3 before_hooks_up: params:coping_rebar becomes 불일치 (C9)", c_of(res, "params:coping_rebar", "C9")["st"] == "b" and res["params:coping_rebar"]["st"] == "b")
c3b = c_of(res, "params:coping_rebar", "C3b")
check("P3 before_hooks_up: C3b names the hooks_up coordinates that were never changed (#34-3)", c3b["st"] == "w" and "hooks_up" in c3b["text"], c3b["text"])
else:
print("SKIP P3 before_hooks_up")
if p3.exists() and list((p3 / "backup").glob("before_stage2_*")):
bk = sorted((p3 / "backup").glob("before_stage2_*"))[0]
P, E = load(bk)
r = [x for x in consistency.check(P, E) if not x["ok"]]
ids = {x["id"] for x in r}
check("P3 before_stage2: >= 40 mismatches", len(r) >= 40, len(r))
for must in ("piles.pitch_y", "coping.step2.z", "coping.step2.x0", "bearing.pitch", "column_rebar.hoop.count", "footing_rebar.bottom_X2.count", "footing_rebar.b9.y"):
check(f"P3 before_stage2: rule {must} caught", must in ids)
res = run(P, E, origin(p3))
check("P3 before_stage2: every gate bundle of expected is 불일치 before any gate runs", all(res[f"expected:G{i}"]["st"] == "b" for i in (1, 2, 3, 4)), bad_c9(res))
else:
print("SKIP P3 before_stage2")
p4 = verified_pier("운곡교P4") or D / "운곡교P4"
if p4.exists() and list((p4 / "backup").glob("before_table_apply_*")):
bk = sorted((p4 / "backup").glob("before_table_apply_*"))[0]
P, E = load(bk)
r = [x for x in consistency.check(P, E) if not x["ok"]]
check("P4 before_table_apply: the schedule copies that differ between the files are caught", any(x["id"].startswith("schedule.") for x in r), len(r))
else:
print("SKIP P4 before_table_apply")
# ---- 3. synthetic edits on a finished pier
if p3.exists():
P, E = load(p3)
E2 = copy.deepcopy(E)
E2["G1"]["piles"]["pitch_y"] = 1550
res = run(P, E2, origin(p3))
check("pile pitch changed in expected only -> expected:G1 and params:piles 불일치", res["expected:G1"]["st"] == "b" and res["params:piles"]["st"] == "b", bad_c9(res))
P2 = copy.deepcopy(P)
h = P2["coping_rebar"]["c14"]["hooks_up"][0]
P2["coping_rebar"]["c14"]["points"] = [[x, z + 5 if [x, z] == h or (x, z) == tuple(h) else z] for x, z in P2["coping_rebar"]["c14"]["points"]]
r = [x for x in consistency.check(P2, E) if not x["ok"]]
check("moving the point a hooks_up entry names breaks that entry (#34-6)", [x["id"] for x in r] == ["coping_rebar.c14.hooks_up.0"], [x["id"] for x in r])
P3_ = copy.deepcopy(P)
del P3_["coping_rebar"]["c14"]["hooks_up"]
check("no hooks_up -> no point-list rule, nothing else breaks", not [x for x in consistency.check(P3_, E) if not x["ok"]])
res = run(P, E, None)
check("without origin_values.json C3b is 해당 없음 (older piers)", c_of(res, "params:coping_rebar", "C3b")["st"] == "x")
# ---- 4. other pier types are not touched
for n in ("새재1교P1", "옥성교P1", "산외교울산P2", "Ramp-A교P1"):
d = D / n
if not d.exists():
print("SKIP", n)
continue
P, E = load(d)
r = consistency.check(P, E)
check(f"{n}: no false C9 mismatch ({len(r)} linked values)", all(x["ok"] for x in r), [x["id"] for x in r if not x["ok"]][:5])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,68 @@
"""Pier delete test (2026-10-12): own data root (검증/web_delete_test_data, recreated); real piers are not touched.
Checks: wrong / empty confirmation name refused; refused while a batch runs; correct name moves the folder to
data/_휴지통/<name>_<time> with its files; the pier disappears from the list; the trash folder is not listed as
a pier; restoring the folder brings the pier back.
"""
import os
import shutil
import sys
import threading
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "web_delete_test_data"
if TD.exists():
shutil.rmtree(TD)
(TD / "원본").mkdir(parents=True)
for f in ("params.json", "expected.json"):
shutil.copy2(APP / "data" / "운곡교P1" / f, TD / "원본" / f)
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
import store # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, detail)
c = webapp.app.test_client()
c.post("/pier/new", data={"name": "삭제대상", "type": "T형", "template": "원본"})
check("pier created", "삭제대상" in store.piers())
h = c.get("/").get_data(as_text=True)
check("list shows a delete form per pier", h.count('name="confirm_name"') == 2)
r = c.post("/pier/삭제대상/delete", data={"confirm_name": "삭제"}, follow_redirects=True)
check("wrong name refused", "정확히 입력하세요" in r.get_data(as_text=True) and "삭제대상" in store.piers())
r = c.post("/pier/삭제대상/delete", data={"confirm_name": ""}, follow_redirects=True)
check("empty name refused", "삭제대상" in store.piers())
# a running batch blocks deletion
p = store.Pier("삭제대상")
stop = threading.Event()
t = threading.Thread(target=stop.wait, daemon=True)
t.start()
webapp.BATCH["삭제대상"] = t
store.wjson(p.run_dir / "batch.json", {"state": "실행 중", "by": "x", "pid": os.getpid(), "at": store.now(), "log": []})
r = c.post("/pier/삭제대상/delete", data={"confirm_name": "삭제대상"}, follow_redirects=True)
check("refused while a batch runs", "실행 중입니다" in r.get_data(as_text=True) and "삭제대상" in store.piers())
stop.set()
t.join()
store.wjson(p.run_dir / "batch.json", {"state": "완료", "by": "x", "pid": os.getpid(), "at": store.now(), "log": []})
r = c.post("/pier/삭제대상/delete", data={"confirm_name": "삭제대상"}, follow_redirects=True)
trash = sorted((TD / store.TRASH).glob("삭제대상_*"))
check("correct name: moved to trash", "휴지통으로 이동" in r.get_data(as_text=True) and "삭제대상" not in store.piers()
and len(trash) == 1 and (trash[0] / "params.json").exists() and (trash[0] / "runs" / "current" / "batch.json").exists())
check("trash folder not listed as a pier", store.TRASH not in store.piers() and store.piers() == ["원본"])
check("deleted pier page is 404", c.get("/pier/삭제대상").status_code == 404)
check("delete of a missing pier is 404", c.post("/pier/없는교각/delete", data={"confirm_name": "없는교각"}).status_code == 404)
shutil.move(str(trash[0]), str(TD / "삭제대상"))
check("restore by moving the folder back", "삭제대상" in store.piers() and c.get("/pier/삭제대상").status_code == 200)
shutil.rmtree(TD / store.TRASH) # also when no trash folder exists yet
r = c.post("/pier/new", data={"name": store.TRASH, "type": "T형", "template": "원본"}, follow_redirects=True)
check("a pier cannot be named like the trash folder", "휴지통 폴더 이름" in r.get_data(as_text=True)
and not (TD / store.TRASH).exists())
sys.exit(0 if ok else 1)
@@ -0,0 +1,273 @@
"""Test of improvement #34-1 (2026-10-20): the drawing readers (engine/geomread.py) and the web screen "도면에서 읽기". No Rhino. Own data root
검증/geomread_test_data (recreated); real piers are only read. Acceptance: the readers reproduce the finished template pier (P1) and the gate-verified
piers (P3, P4) within 1.5 mm; starting from the early P4 backup (a P1 copy) they come out equal to the verified P4 values; a drawing with another frame is
refused; the screen writes only the rows that were chosen, takes a backup, logs it, and the app check runs again. Skipped parts (data missing) are reported."""
import json
import os
import re
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
D = APP / "data"
TD = APP / "검증" / "geomread_test_data"
if TD.exists():
shutil.rmtree(TD)
TD.mkdir(parents=True)
sys.path.insert(0, str(APP / "tools"))
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "web"))
from testlib import verified_pier # noqa: E402
from engine import anchors, geomread # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
def jl(p):
return json.loads(Path(p).read_text(encoding="utf-8"))
ref = jl(D / "_템플릿" / "운곡교P1형" / "params.json")
anc = jl(D / "_템플릿" / "운곡교P1형" / "anchors.json")
def read(d, P=None, E=None, name=None):
d = Path(d)
texts = jl(d / "drawings" / "texts.json")
audit = jl(d / "drawings" / "audit.json")
return geomread.read(P or jl(d / "params.json"), E or jl(d / "expected.json"), ref, texts, geomread.dxf_of(d, audit), name or re.sub(r"_\d{8}_\d{6}$", "", d.name)), texts
def get(doc, path):
x = doc
for k in path.split("."):
x = x[int(k)] if isinstance(x, list) else x[k]
return x
def geomread_paths(doc, key, pre=None):
"""dotted paths of every leaf under doc[key] (key as first token)"""
out = []
def walk(x, path):
if isinstance(x, dict):
for k, v in x.items():
if k in ("src", "_note") or str(k).endswith("_src"):
continue
walk(v, path + [str(k)])
elif isinstance(x, list):
for i, v in enumerate(x):
walk(v, path + [str(i)])
else:
out.append(".".join(path))
walk(doc, [key])
return out
# ---- 1. finished piers: the readers reproduce them
for n in ("운곡교P1", "운곡교P3", "운곡교P4"):
vd = verified_pier(n) if n != "운곡교P1" else D / n # P1 is the template pier; P3 / P4: the gate-verified copy
if vd is None or not vd.exists():
print("SKIP", n)
continue
(P, E, ch, notes), texts = read(vd)
cov = geomread.frame_ok(texts, anc, anchors)
check(f"{n}: frame coverage {cov:.2f} >= {geomread.FRAME_MIN_COVERAGE}", cov >= geomread.FRAME_MIN_COVERAGE)
# diagnostics that were never updated while P3 / P4 were hand-modelled (gate-approved with them, so the data stays): bar-count notes and bar diameters
stale_diag = ("footing_rebar.counts_check", "G2.dia", "G3.dia", "G4.dia") if n != "운곡교P1" else ()
material = [c for c in ch if not c["inferred"] and c["path"] not in stale_diag]
check(f"{n}: the readers reproduce the verified values (0 material changes, {len(ch) - len(material)} inferred row(s) allowed)", not material,
[(c["file"], c["path"], c["old"], c["new"]) for c in material][:4])
# ---- 2. from the early P4 backup (a P1 copy) the readers reach the verified P4 values
p4 = verified_pier("운곡교P4") or D / "운곡교P4"
bks = sorted((p4 / "backup").glob("before_apply_pier_read_*")) if p4.exists() else []
FIX_P4 = APP / "검증" / "fixtures" / "p4_template_state" # P4 straight from the template (the early P1-copy state); kept because the pier data is recreated
if not bks and FIX_P4.exists():
bks = [FIX_P4]
if bks:
P0, E0 = jl(bks[0] / "params.json"), jl(bks[0] / "expected.json")
(P, E, ch, notes), texts = read(p4, P0, E0)
check(f"P4 from the P1-copy backup: {len(ch)} changes proposed (>= 100)", len(ch) >= 100, len(ch))
cur_p, cur_e = jl(p4 / "params.json"), jl(p4 / "expected.json")
bad = []
for c in ch:
if c["inferred"] or c["path"] in ("display.hidden_ids", "footing_rebar.counts_check", "G2.dia", "G3.dia", "G4.dia"): # a display choice: P4 shows the footing / coping (P3 does too), the reader keeps the template's hiding
continue
cur = get(cur_p if c["file"] == "params" else cur_e, c["path"])
if not geomread.same(cur, c["new"], 0.05 if "rotation" in c["path"] else 1.5) and not (c["path"].endswith(("c14.points", "c14_levels")) and
geomread.same(geomread._points_key(cur), geomread._points_key(c["new"]))):
bad.append((c["file"], c["path"], str(cur)[:30], str(c["new"])[:30]))
check("P4 from the P1-copy backup: every proposed value equals the gate-verified P4 value (inferred rows excluded)", not bad, bad[:4])
groups = {c["group"] for c in ch}
check("the proposal covers coping, footing / piles / column, bearings, footing rebar, column rebar", {"코핑", "코핑 철근", "기초·말뚝·기둥", "받침", "기초 철근", "기둥 철근"} <= groups, groups)
P2, E2 = geomread.apply_selected(P0, E0, ch, [i for i, c in enumerate(ch) if not c["inferred"]])
check("apply_selected leaves the unchosen (inferred) rows as they were", P2["coping_rebar"]["stirrups"]["top_left"] == P0["coping_rebar"]["stirrups"]["top_left"])
else:
print("SKIP P4 early backup")
# ---- 2b. a pier whose answer is known: 운곡교 P2 (the 10-12 pier with gate-verified values now in the recycle bin) -- the readers on the NEW P2 (made from the
# P1 template) reach the same values, incl. C14 points whose x offsets (0 / 900 ...) differ from P1 (13 / 913 ...)
newp2 = D / "운곡교P2"
oldp2 = sorted(t for t in (D / "_휴지통").glob("운곡교p2_*") if t.name.startswith("운곡교p2_") and (t / "params.json").exists()) if (D / "_휴지통").exists() else []
b2 = sorted((newp2 / "backup").glob("before_geomread_*")) if newp2.exists() else []
if oldp2 and b2:
old_p, old_e = jl(oldp2[-1] / "params.json"), jl(oldp2[-1] / "expected.json")
(P, E, ch, notes), texts = read(newp2, jl(b2[0] / "params.json"), jl(b2[0] / "expected.json"))
bad = []
for c in ch:
if c["path"] in ("pier_id", "display.hidden_ids") or c["path"] in ("footing_rebar.counts_check", "G2.dia", "G3.dia", "G4.dia"):
continue
ov = get(old_p if c["file"] == "params" else old_e, c["path"]) if True else None
tol = 0.05 if "rotation" in c["path"] else 1.5
if c["path"] == "G1.seats.row_y": # nominal text value in the old pier (-699 / 600) vs the pad polylines (-700 / 598)
continue
okv = geomread.same(ov, c["new"], tol) or (c["path"].endswith(("c14.points", "c14_levels", "hooks_up")) and geomread.same(geomread._points_key(ov), geomread._points_key(c["new"]), tol))
if not okv:
bad.append((c["file"], c["path"], str(ov)[:30], str(c["new"])[:30]))
check(f"P2 (new, from the P1 template): {len(ch)} readings, every one equals the gate-verified old P2 value (row_y nominal values aside)", not bad, bad[:4])
c14 = [c for c in ch if c["path"] == "coping_rebar.c14.points"]
check("P2: the C14 points are read from the drawn circles (the top rows sit at x 0 / 900 ..., the others at 13 / 913 ... as in P1)", c14 and any(p_[0] == 900 for p_ in c14[0]["new"]) and any(p_[0] == 913 for p_ in c14[0]["new"]))
else:
print("SKIP P2 (new pier with an early backup and the old verified P2 are both needed)")
# ---- 2c. the tables are part of the same screen: from the early P4 backup (a P1 copy) the readings + the table rows together bring the tables' fields to the
# gate-verified P4 values (expected G1..G5 tables, bearing / sole plate / bar-mark fields), incl. the per-bearing sole plate length L
if bks:
P0, E0 = jl(bks[0] / "params.json"), jl(bks[0] / "expected.json")
(P, E, ch, notes), texts = read(p4, P0, E0)
tch = geomread.table_changes(P, E, texts)
check(f"table rows are proposed on top of the readings ({len(tch)}), grouped as one", len(tch) >= 60 and {c["group"] for c in tch} == {geomread.TABLE_GROUP}, len(tch))
allc = ch + tch
P2, E2 = geomread.apply_selected(P0, E0, allc, [i for i, c in enumerate(allc) if not c["inferred"]])
vp, ve = jl(p4 / "params.json"), jl(p4 / "expected.json")
bad = []
for which, a, b in (("params", P2, vp), ("expected", E2, ve)):
for key in (("bearing_seats", "sole_plates", "rebar_marks") if which == "params" else ("G1", "G2", "G3", "G4", "G5")):
for path in geomread_paths(a[key], key):
x, y = get(a, path), get(b, path)
if y != "<missing>" and not geomread.same(x, y, 0.05 if "rotation" in path else 1.5):
bad.append((which, path, str(x)[:25], str(y)[:25]))
allowed = ("G1.seats.rear_first_from_tip", "G2.tie_offset", "G2.dia", "G3.dia", "G4.dia", "G1.seats.row_y", "G1.seats.pitch_chain") # anchor / builder constant / stale diagnostics of the old pier
unexpected = [b for b in bad if not b[1].startswith(allowed)]
check("bearing / sole plate / bar mark fields and the expected tables equal the gate-verified P4 after readings + table rows (known exceptions: anchor value, tie_offset constant, diameters)", not unexpected, unexpected[:6])
check("per-bearing sole plate length: expected G5.L became {no: value} where the table differs", isinstance(E2["G5"].get("L"), dict) and len(set(E2["G5"]["L"].values())) > 1, E2["G5"].get("L"))
# ---- 3. another frame is refused
for n in ("새재1교P1", "옥성교P1"):
if (D / n).exists():
cov = geomread.frame_ok(jl(D / n / "drawings" / "texts.json"), anc, anchors)
check(f"{n}: another drawing frame (coverage {cov:.2f} < 0.70) -> refused", cov < geomread.FRAME_MIN_COVERAGE)
# ---- 4. the web screen
if bks:
for n in ("운곡교P4", "새재1교P1"):
shutil.copytree(D / n, TD / n, ignore=shutil.ignore_patterns("runs", "backup", "dwg", "confirm.json", "autocheck.json"))
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
shutil.copy2(bks[0] / "params.json", TD / "운곡교P4" / "params.json")
shutil.copy2(bks[0] / "expected.json", TD / "운곡교P4" / "expected.json")
os.environ["PIERAPP_DATA"] = str(TD)
import app as webapp # noqa: E402
import store # noqa: E402
assert Path(str(store.DATA)) == TD
c = webapp.app.test_client()
h = c.get("/pier/운곡교P4/geomread").get_data(as_text=True)
check("page lists the proposal in groups with a checkbox per row", "코핑 철근" in h and 'name="row"' in h and "기초 철근" in h)
check("the inferred row is not pre-selected", "추론" in h and h.count("checked") < h.count('name="row"'))
check("page warns that params and expected from one reading are no independent evidence", "독립 근거가 아닙니다" in h)
h2 = c.get("/pier/새재1교P1/geomread").get_data(as_text=True)
check("another drawing frame: the page refuses", "읽지 않습니다" in h2 and 'name="row"' not in h2)
p = store.Pier("운곡교P4")
before = (p.load("params"), p.load("expected"))
r = c.post("/pier/운곡교P4/geomread/apply", data={"by": "시험자"}, follow_redirects=True)
check("applying nothing is refused and writes nothing", "반영할 줄을 고르세요" in r.get_data(as_text=True) and (p.load("params"), p.load("expected")) == before)
# rows 0 and the footing rows only
ch = geomread.read(*before, ref, p.texts(), geomread.dxf_of(p.dir, store.rjson(p.drawings / "audit.json", [])), "운곡교P4")[2]
pick = [i for i, x in enumerate(ch) if x["path"] in ("footing.size_x", "footing.size_y") and x["file"] == "params"]
p.confirm("params", "footing", "시험자", "시험", "n")
r = c.post("/pier/운곡교P4/geomread/apply", data={"by": "시험자", "row": [str(i) for i in pick]}, follow_redirects=True)
after = (p.load("params"), p.load("expected"))
check("only the chosen rows were written", after[0]["footing"]["size_x"] == 10350 and after[0]["footing"]["size_y"] == 8300 and after[0]["coping"] == before[0]["coping"] and after[1] == before[1])
bk = list((p.dir / "backup").glob("before_geomread_*"))
check("a backup of params / expected / confirm was taken first", len(bk) == 1 and (bk[0] / "params.json").exists() and jl(bk[0] / "params.json")["footing"]["size_x"] == 9350)
lg = store.rjson(p.dir / "read_log.json", [])
check("the reading is logged with who, how many and which fields", lg and lg[-1]["by"] == "시험자" and lg[-1]["count"] == len(pick) and "params:footing.size_x" in lg[-1]["fields"])
check("the changed bundle lost its confirmation (hash changed) and the app check ran again", p.section_state("params", "footing")["stale"] and p.autocheck()["sections"]["params:footing"])
check("the page now lists fewer differing rows", c.get("/pier/운곡교P4/geomread").get_data(as_text=True).count('name="row"') < h.count('name="row"'))
# the planned FAIL proposal: listed, not pre-selected; applying it records the person's choice in `approved`
st, _P, _E, chs, _n, _m = webapp._geomread(p)
pfi = [i for i, x in enumerate(chs) if x["path"] == "planned_fail"]
check("the planned FAIL proposal is on the page and is not pre-selected", len(pfi) == 1 and chs[pfi[0]]["inferred"] and any(x["approved"] == geomread.PENDING for x in chs[pfi[0]]["new"]))
c.post("/pier/운곡교P4/geomread/apply", data={"by": "시험자", "row": [str(pfi[0])]}, follow_redirects=True)
ap = [x["approved"] for x in p.load("expected")["planned_fail"] if x["gate"] == "G3" and x["item"].startswith(("B11", "B12"))]
check("applying the planned FAIL proposal stamps approved with who / when (no longer 'pending')", len(ap) == 2 and all(a.startswith("사용자 선택 ") and "시험자" in a for a in ap), ap)
# ---- 4. G2.tie_offset = (dia P4 + dia P6) / 2 (cross-tie height = hoop level + hoop radius + tie radius); the 12:11 P4 state (template value 17.5) gets 22.5
for n in ("_템플릿/운곡교P1형", "운곡교P1", "운곡교P2", "운곡교P3", "운곡교P4"):
vd = D / n if n.startswith("_템플릿") or n == "운곡교P1" else verified_pier(n) # a pier may be deleted / half-rebuilt: use its gate-approved copy
if vd is None or not (vd / "expected.json").exists():
print("SKIP tie_offset rule", n)
continue
dd = jl(vd / "expected.json")["G2"]
check(f"tie_offset rule holds for {n}", dd["tie_offset"] == (dd["dia"]["P4"] + dd["dia"]["P6"]) / 2, (dd["tie_offset"], dd["dia"]["P4"], dd["dia"]["P6"]))
bk0 = APP / "검증" / "fixtures" / "p4_template_state" # P4 as created from the template (before any reading), kept because the pier data is recreated
if bk0.exists():
(P, E, ch, notes), texts = read(D / "운곡교P4", jl(bk0 / "params.json"), jl(bk0 / "expected.json"))
got = [c for c in ch if c["path"] == "G2.tie_offset"]
check("reading the template-state P4 proposes tie_offset 17.5 -> 22.5", len(got) == 1 and got[0]["new"] == 22.5, [(c["old"], c["new"]) for c in got])
check("reading the template-state P4 also proposes the three coping top-tier a values", sorted(c["new"] for c in ch if re.fullmatch(r"coping_rebar\.top\.tiers\.\d\.a", c["path"])) == [11856, 11881, 11905])
else:
print("SKIP template-state P4 fixture missing")
# ---- 5. rules that used to be hand decisions (P4 rebuild 2026-10-20): top_left back-calculation, anchor candidate, planned FAIL proposals
tpl = [APP / "검증" / "fixtures" / "p4_template_state"]
if tpl[0].exists() and verified_pier("운곡교P4"):
vp = verified_pier("운곡교P4")
(P, E, ch, notes), texts = read(vp, jl(tpl[0] / "params.json"), jl(tpl[0] / "expected.json"))
ex = geomread.extra_changes(P, E, texts, jl(vp / "anchors.json"), [])
vpar, vexp = jl(vp / "params.json"), jl(vp / "expected.json")
check("template-state P4: top_left is back-calculated from the C9 b range = the verified decision", P["coping_rebar"]["stirrups"]["top_left"] == vpar["coping_rebar"]["stirrups"]["top_left"],
P["coping_rebar"]["stirrups"]["top_left"])
anc = [c for c in ex if c["path"] == "G1.seats.rear_first_from_tip"]
check("template-state P4: the anchor candidate proposes rear_first_from_tip 1232 -> 1198 (pre-selected, not inferred)", len(anc) == 1 and anc[0]["old"] == 1232 and anc[0]["new"] == 1198 and not anc[0]["inferred"], [(c["old"], c["new"]) for c in anc])
pf = [c for c in ex if c["path"] == "planned_fail"]
items = {(x["gate"], x.get("item")): x for c in pf for x in c["new"] if x.get("approved") == geomread.PENDING}
check("template-state P4: planned FAIL proposals B11, B12 (numbers of this pier), C13 convention; never pre-selected; approval left to the person",
set(items) == {("G3", "B11 bar count vs schedule"), ("G3", "B12 bar count vs schedule"), ("G4", "C13 schedule L reproduced by convention (max+min)/2")} and all(c["inferred"] for c in pf)
and "32본" in items[("G3", "B12 bar count vs schedule")]["reason"] and "38본" in items[("G3", "B12 bar count vs schedule")]["reason"], sorted(items))
else:
print("SKIP template-state P4 backup missing")
fx2 = APP / "검증" / "fixtures" / "p2_template_state"
vp2 = verified_pier("운곡교P2")
if fx2.exists() and vp2:
(P, E, ch, notes), texts = read(vp2, jl(fx2 / "params.json"), jl(fx2 / "expected.json"))
ex = geomread.extra_changes(P, E, texts, jl(vp2 / "anchors.json") if (vp2 / "anchors.json").exists() else None, [])
items = {(x["gate"], x.get("item")): x for c in ex if c["path"] == "planned_fail" for x in c["new"] if x.get("approved") == geomread.PENDING}
check("template-state P2: planned FAIL proposals B9 (551 vs 493) and B12 (32 vs 38); no C13 (P2's C13 follows the convention)",
set(items) == {("G3", "B9 bar count vs schedule"), ("G3", "B12 bar count vs schedule")} and "551본" in items[("G3", "B9 bar count vs schedule")]["reason"] and "493본" in items[("G3", "B9 bar count vs schedule")]["reason"], sorted(items))
else:
print("SKIP template-state P2 fixture / verified copy missing")
E_t = {"planned_fail": [{"gate": "G3", "item": "x", "approved": geomread.PENDING}, {"gate": "G3", "item": "y", "approved": "사용자 2026-10-07"}]}
check("stamp_pending changes only the pending entries", geomread.stamp_pending(E_t, "심영표", "2026-10-20") == 1 and E_t["planned_fail"][1]["approved"] == "사용자 2026-10-07" and "심영표" in E_t["planned_fail"][0]["approved"])
for n in ("운곡교P1", "운곡교P2", "운곡교P3", "운곡교P4"):
vd = verified_pier(n) if n != "운곡교P1" else D / n
if vd is None or not vd.exists() or not (vd / "anchors.json").exists():
print("SKIP", n)
continue
(P, E, ch, notes), texts = read(vd)
ex = geomread.extra_changes(P, E, texts, jl(vd / "anchors.json"), [])
anc = [c for c in ex if c["path"] == "G1.seats.rear_first_from_tip"]
pf_new = [x for c in ex if c["path"] == "planned_fail" for x in c["new"] if x.get("approved") == geomread.PENDING]
check(f"{n}: finished pier -> no anchor proposal, no new planned FAIL proposal (no false proposals)", not anc and not pf_new, [(c["old"], c["new"]) for c in anc] + [x.get("item") for x in pf_new])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,17 @@
"""Individual gate run after a batch run (2026-10-12): the previous gate has no fingerprint (batch mode saves
no per-gate model), so the unchanged check must be skipped instead of raising; save + reload still run.
Uses the run folder left by test_web_batch.py (검증/web_batch_test_data/배치P1).
"""
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine.gates import run_gate # noqa: E402
d = APP / "검증" / "web_batch_test_data" / "배치P1"
rep = run_gate("G3", d / "params.json", d / "expected.json", d / "runs" / "current", log=lambda m: None)
ok = rep["verdict"] == "PASS" and rep["reload"]["ok"] is True and rep["unchanged"]["ok"] is None
print("PASS" if ok else "FAIL", "individual G3 after batch:", rep["verdict"], "reload", rep["reload"]["ok"],
"unchanged", rep["unchanged"].get("skipped"))
sys.exit(0 if ok else 1)
@@ -0,0 +1,77 @@
"""Okseong P1 form test (2026-10-14), no Rhino: table reading fix, pier inputs, engine gate reports, app flow.
- 솔플레이트 TABLE read although ELEVATION TABLE rows interleave in its window (10 rows, all columns)
- reference piers unchanged (검증/open19_snapshot_after.json)
- table candidates all equal the hand-typed expected / params
- gate reports of data/옥성교P1 PASS with negatives detected
- app flow on a copy (검증/okseong_web_data): no gate blocked by an absent section, confirming every required bundle clears G1
"""
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "okseong_web_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "옥성교P1", TD / "옥성", ignore=shutil.ignore_patterns("runs", "backup", "confirm.json"))
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from engine import reader # noqa: E402
from open19_snapshot import snapshot # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
ref = json.loads((APP / "검증" / "open19_snapshot_after.json").read_text(encoding="utf-8"))
now = snapshot()
for k in ref:
check(f"{k}: table records / proposals unchanged", ref[k]["records"] == now[k]["records"]
and ref[k]["proposals"] == now[k]["proposals"])
D = APP / "data" / "옥성교P1"
texts = json.loads((D / "drawings" / "texts.json").read_text(encoding="utf-8"))
tables = reader.table_candidates(texts)
sp = [t for t in tables if t["key"] == "sole_plate"][0]
recs = [r for _, r in reader.table_records(sp["rows"], lambda s: s.isdigit())]
check("솔플레이트 TABLE 10 rows NO.1~10", [r["_key"] for r in recs] == [str(i) for i in range(1, 11)], [r["_key"] for r in recs])
check("솔플레이트 TABLE columns", recs and set(recs[0]) == {"_key", "S(%)", "L", "W", "T", "T1", "T2"}, recs[:1])
el = [t for t in tables if t["key"] == "elevation"][0]
erec = [r for _, r in reader.table_records(el["rows"], lambda s: s.isdigit())]
check("ELEVATION TABLE 10 rows", len(erec) == 10 and erec[0].get("EL.'X'") == "21.398", len(erec))
P = json.loads((D / "params.json").read_text(encoding="utf-8"))
X = json.loads((D / "expected.json").read_text(encoding="utf-8"))
rows = reader.proposals(reader.field_candidates(tables), P, X)
diff = [(r["file"], r["path"]) for r in rows if not r["same"]]
check(f"candidates all equal the typed inputs ({len(rows)} rows)", rows and not diff, diff[:5])
for g, n in (("G1", 147), ("G2", 39), ("G3", 75), ("G4", 251), ("G5", 183)): # G2 column, G3 footing, G4 coping + seats 2026-10-14
r = json.loads((D / "runs" / "current" / f"{g}_gate.json").read_text(encoding="utf-8"))
check(f"{g} gate PASS {n}/{n}, negatives detected, reload ok",
r["verdict"] == "PASS" and r["summary"]["passed"] == n == r["summary"]["total"]
and all(v["detected"] for v in r["negative"].values()) and (r["reload"]["ok"] or r.get("mode") == "batch"), r["summary"]) # batch mode (app 일괄 승인) skips save + reload on purpose (OPEN #25)
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
p = store.Pier("옥성")
missing = [b for g in p.scope() for b in webapp.gate_blockers(p, g) if b.endswith("없음")]
check("no gate blocked by an absent section", not missing, missing)
c = webapp.app.test_client()
confirm_required(c, webapp, store, "옥성", "시험")
p = store.Pier("옥성")
check("confirming every required bundle clears G1", webapp.gate_blockers(p, "G1") == [], webapp.gate_blockers(p, "G1"))
check("pier page renders", c.get("/pier/옥성").status_code == 200)
shutil.rmtree(TD)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,162 @@
"""OPEN #19 test (2026-10-13): A3 range text in schedules, A4 params rows for bearing / sole plate tables,
A5 dimension-chain input, A6 handle lookup, A7 default negative-test ids. No Rhino.
Needs 검증/open19_snapshot_before.json (tools/open19_snapshot.py before, taken before the changes).
Web part uses its own data root 검증/open19_test_data (recreated) with a copy of data/운곡교p2.
"""
import copy
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "open19_test_data"
if TD.exists():
shutil.rmtree(TD)
sys.path.insert(0, str(APP / "tools"))
from testlib import pier_dir # noqa: E402
shutil.copytree(pier_dir("운곡교p2"), TD / "p2", ignore=shutil.ignore_patterns("runs", "backup"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
sys.path.insert(0, str(APP / "web"))
from engine import reader # noqa: E402
from engine.gates import NEGATIVE, negative_moves # noqa: E402
import app as webapp # noqa: E402
import store # noqa: E402
from open19_snapshot import snapshot # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
before = json.loads((APP / "검증" / "open19_snapshot_before.json").read_text(encoding="utf-8"))
after = snapshot()
(APP / "검증" / "open19_snapshot_after.json").write_text(json.dumps(after, ensure_ascii=False, indent=1), encoding="utf-8")
# ---- A3: records only gain cells; Ungok P2 C12 / C13 range texts now land in column A
for pier in before:
added, changed = [], []
b_rec, a_rec = before[pier]["records"], after[pier]["records"]
for k in b_rec:
for rb, ra in zip(b_rec[k], a_rec.get(k, [])):
changed += [(k, rb["_key"], f) for f in rb if ra.get(f) != rb[f]]
added += [(rb["_key"], f, ra[f]) for f in ra if f not in rb]
check(f"A3 {pier}: same tables / rows", b_rec.keys() == a_rec.keys()
and all(len(b_rec[k]) == len(a_rec[k]) for k in b_rec))
check(f"A3 {pier}: no existing cell changed or lost", not changed, changed[:5])
print(" added cells:", added)
if pier == "운곡교p2":
got = {m: v for m, f, v in added if f == "A"}
check("A3 p2: C12 / C13 range text in column A",
got.get("C12") == "12.228~3.151" and got.get("C13") == "12.167~12.044", added)
check("A3 p2: nothing else added", len(added) == 2, added)
# ---- A4: expected-side rows identical to before; new params rows all equal current params
for pier in before:
old = before[pier]["proposals"]
new = after[pier]["proposals"]
new_old = [r for r in new if r["file"] == "expected" or r["path"][0] == "rebar_marks"]
check(f"A4 {pier}: expected / rebar_marks rows unchanged ({len(old)})", new_old == old)
pr = [r for r in new if r["file"] == "params" and r["path"][0] != "rebar_marks"]
diff = [r for r in pr if not r["same"]]
kinds = sorted({".".join(str(x) for x in r["path"] if not isinstance(x, int)) for r in pr})
check(f"A4 {pier}: params rows found, none differ ({len(pr)})", pr and not diff, diff[:3] or kinds)
texts = json.loads((pier_dir("운곡교p2") / "drawings/texts.json").read_text(encoding="utf-8"))
P0 = json.loads((pier_dir("운곡교p2") / "params.json").read_text(encoding="utf-8"))
X0 = json.loads((pier_dir("운곡교p2") / "expected.json").read_text(encoding="utf-8"))
cands = reader.field_candidates(reader.table_candidates(texts))
P = copy.deepcopy(P0)
P["sole_plates"]["table"][2]["W"] = 999 # NO.3 W (table 750)
P["bearing_seats"]["bearings"][6]["h1"] += 7 # 7th entry
rows = reader.proposals(cands, P, X0)
d = [r for r in rows if not r["same"]]
no7 = P["bearing_seats"]["bearings"][6]["no"]
check("A4 mutation: only the two changed params cells differ",
sorted(tuple(r["path"]) for r in d) == sorted([("sole_plates", "table", 2, "W"),
("bearing_seats", "bearings", 6, "h1")]), [r["path"] for r in d])
check("A4 mutation: expected untouched by the params rows", all(r["file"] == "params" for r in d))
reader.apply_proposals(d, P, X0)
check("A4 apply restores exactly the original params", P == P0)
check("A4 h3 common value proposed once (Ungok)",
sum(r["path"] == ["sole_plates", "h3"] for r in rows) == 1)
# ---- A5: dimension chain
pos = json.loads((APP / "data/운곡교P1/params.json").read_text(encoding="utf-8"))["footing_rebar"]["layers"]["bottom_Y1"]["pos"]
incs = [b - a for a, b in zip(pos, pos[1:])]
parts, i = [], 0
while i < len(incs):
j = i
while j < len(incs) and incs[j] == incs[i]:
j += 1
parts.append(f"{j - i}@{incs[i]:g}" if j - i > 1 else f"{incs[i]:g}")
i = j
text = ", ".join([f"{pos[0]:g}"] + parts)
print(" chain:", text)
check("A5 chain of P1 bottom_Y1.pos (73) expands to the same list", store.chain(text) == pos, len(store.chain(text)))
check("A5 drawing notation", store.chain("0, 8@120") == [0] + [120 * k for k in range(1, 9)])
check("A5 decimals kept", store.chain("-1.5, 2@0.25") == [-1.5, -1.25, -1.0])
for bad in ("", "a, 1", "2@100", "0, 0@5", "0, 3@x"):
try:
store.chain(bad)
check(f"A5 rejects {bad!r}", False)
except ValueError as e:
check(f"A5 rejects {bad!r}", True, e)
nested = {"a": {"chain": "0, 2@5"}, "b": [{"chain": "1, 1"}, 3], "c": {"chain": "0", "x": 1}}
check("A5 expand_chains nested / other dicts untouched",
store.expand_chains(nested) == {"a": [0, 5, 10], "b": [[1, 2], 3], "c": {"chain": "0", "x": 1}})
c = webapp.app.test_client()
fr = store.Pier("p2").load("params")["footing_rebar"]
fr2 = copy.deepcopy(fr)
fr2["b9"]["grid_x"] = {"chain": f"{fr['b9']['grid_x'][0]}, {len(fr['b9']['grid_x']) - 1}@250"}
r = c.post("/pier/p2/section/params/footing_rebar", data={"value": json.dumps(fr2, ensure_ascii=False)})
saved = store.Pier("p2").load("params")["footing_rebar"]
check("A5 web save expands the chain to the same list", r.status_code == 302 and saved == fr,
(r.status_code, saved["b9"]["grid_x"][:3]))
fr2["b9"]["grid_x"] = {"chain": "0, 3@abc"}
h = c.post("/pier/p2/section/params/footing_rebar", data={"value": json.dumps(fr2, ensure_ascii=False)},
follow_redirects=True).get_data(as_text=True)
check("A5 web rejects a bad chain, params unchanged",
"치수 사슬 형식 오류" in h and store.Pier("p2").load("params")["footing_rebar"] == fr)
check("A5 section page shows the chain hint", "치수 사슬" in c.get("/pier/p2/section/params/footing_rebar").get_data(as_text=True))
# ---- A6: handle lookup (values written in the P2 reading report / params src)
dxf = pier_dir("운곡교p2") / "drawings/dxf"
d1 = next(a["dxf"] for a in json.loads((pier_dir("운곡교p2") / "drawings/audit.json").read_text(encoding="utf-8"))
if a["label"] == "D1")
e = reader.entity_info(dxf / d1, "21A76")
print(" 21A76:", e)
check("A6 21A76 (footing, report: 10.350) found", e and abs(max(e["values"].get("bbox_size", [0])) - 10350) < 1, e)
e = reader.entity_info(dxf / d1, "20e89")
print(" 20E89:", e)
check("A6 20E89 (coping soffit arc, params R12,000) is an ARC R12000",
e and e["type"] == "ARC" and abs(e["values"]["radius"] - 12000) < 1, e)
check("A6 unknown handle -> None", reader.entity_info(dxf / d1, "FFFFFFF") is None)
h = c.get("/pier/p2/search?dwg=D1&h=20E89").get_data(as_text=True)
check("A6 web shows the object", "ARC" in h and "radius" in h and "12000" in h)
h = c.get("/pier/p2/search?dwg=D1&h=ZZZ").get_data(as_text=True)
check("A6 web reports a missing handle", "핸들 &#39;ZZZ&#39; 객체가 없습니다" in h)
# ---- A7: default negative ids follow the bearings of the pier
X10 = json.loads((APP / "data/운곡교P1/expected.json").read_text(encoding="utf-8"))
check("A7 10 bearings: defaults unchanged", all(negative_moves(X10, g) == NEGATIVE[g] for g in NEGATIVE))
X4 = copy.deepcopy(X10)
X4["G5"]["el_x"] = {k: v for k, v in X4["G5"]["el_x"].items() if int(k) <= 4}
m = {g: [mv.get("id") or mv.get("mark") for mv in negative_moves(X4, g)] for g in NEGATIVE}
check("A7 4 bearings: ids within 1..4", m["G1"] == ["BS04", "BP03"] and m["G5"] == ["BB04", "SP04"]
and m["G2"] == ["P2", "P3"], m)
X4["negative"] = {"G1": [{"id": "BS01", "d": [3, 0, 0]}, {"id": "BP01", "d": [0, 0, 2]}]}
check("A7 pier override still wins", [mv["id"] for mv in negative_moves(X4, "G1")] == ["BS01", "BP01"])
shutil.rmtree(TD)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,44 @@
"""Pier page '3. 게이트' batch button states (2026-10-12): disabled while sections are unconfirmed, enabled after
bulk confirmation, replaced by a notice when all gates are approved. Uses 검증/web_batch_test_data from
test_web_batch.py (pier 배치P1 fully approved) plus a fresh pier made from it; no Rhino run.
"""
import os
import re
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "web_batch_test_data"
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
import store # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, detail)
c = webapp.app.test_client()
shutil.rmtree(TD / "버튼P", ignore_errors=True)
c.post("/pier/new", data={"name": "버튼P", "type": "T형", "template": "배치P1"})
h = c.get("/pier/버튼P").get_data(as_text=True)
btn = re.search(r"<button[^>]*>G1~G5 일괄 승인</button>", h)
check("unconfirmed: button shown but disabled", btn and "disabled" in btn.group(0))
confirm_required(c, webapp, store, "버튼P", "시험자")
h = c.get("/pier/버튼P").get_data(as_text=True)
btn = re.search(r"<button[^>]*>G1~G5 일괄 승인</button>", h)
check("confirmed: button enabled", btn and "disabled" not in btn.group(0))
check("form posts to the batch route", 'action="/pier/%EB%B2%84%ED%8A%BCP/gates/run_all"' in h or "/gates/run_all" in h)
h = c.get("/pier/배치P1").get_data(as_text=True)
check("all approved: notice instead of button", "모든 게이트가 승인되어 있습니다" in h and "일괄 승인</button>" not in h)
check("all approved: last batch shown", "최근 일괄 실행: 완료" in h)
shutil.rmtree(TD / "버튼P", ignore_errors=True)
sys.exit(0 if ok else 1)
@@ -0,0 +1,75 @@
"""Ramp-A P1 form test (2026-10-13), no Rhino: table reading fixes and the pier inputs.
- ELEVATION TABLE header below detail labels, window cut at the 솔플레이트 TABLE title, '1,000' integers
- reference piers unchanged: table records / proposals equal 검증/open19_snapshot_after.json
- Ramp-A table candidates all equal the hand-typed expected / params (independent transcription check)
- gate reports of data/Ramp-A교P1 (engine run) PASS with negatives detected
"""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
sys.path.insert(0, str(APP / "web"))
from engine import reader # noqa: E402
import store # noqa: E402
from open19_snapshot import snapshot # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
ref = json.loads((APP / "검증" / "open19_snapshot_after.json").read_text(encoding="utf-8"))
now = snapshot()
for k in ref:
check(f"{k}: table records unchanged", ref[k]["records"] == now[k]["records"])
check(f"{k}: proposals unchanged", ref[k]["proposals"] == now[k]["proposals"])
p = store.Pier("Ramp-A교P1")
tables = reader.table_candidates(p.texts())
el = [t for t in tables if t["key"] == "elevation"]
recs = [r for _, r in reader.table_records(el[0]["rows"], lambda s: s.isdigit())] if el else []
check("ELEVATION TABLE: 2 rows NO.1 / NO.2", [r["_key"] for r in recs] == ["1", "2"], [r["_key"] for r in recs])
check("ELEVATION TABLE: all 15 columns read", recs and all(len(r) == 16 for r in recs), [len(r) for r in recs])
check("ELEVATION TABLE NO.2 EL.'X' 60.427, C3 1400", recs and recs[1].get("EL.'X'") == "60.427" and recs[1].get("C3") == "1400")
sp = [t for t in tables if t["key"] == "sole_plate"]
srec = [r for _, r in reader.table_records(sp[0]["rows"], lambda s: s.isdigit())] if sp else []
check("솔플레이트 TABLE: 2 rows, L '1,000'", [r["_key"] for r in srec] == ["1", "2"] and srec[0].get("L") == "1,000", srec)
check("thousands separator", reader._int("1,000") == 1000 and reader._int("50") == 50)
rows = reader.proposals(reader.field_candidates(tables), p.load("params"), p.load("expected"))
diff = [(r["file"], r["path"], r["current"], r["candidate"]) for r in rows if not r["same"]]
kinds = {r["file"] + "." + ".".join(str(x) for x in r["path"][:2]) for r in rows}
check(f"candidates all equal the typed inputs ({len(rows)} rows)", rows and not diff, diff)
check("candidates cover G1 seats, G5 el_x, params bearings and sole plates",
{"expected.G1.seats", "expected.G5.el_x", "params.bearing_seats.bearings", "params.sole_plates.table"} <= kinds, sorted(kinds))
for g, n in (("G1", 97), ("G2", 26), ("G3", 48), ("G4", 126), ("G5", 39)): # G2, G3 2026-10-13; G4 coping rebar 2026-10-14
r = json.loads((p.dir / "runs" / "current" / f"{g}_gate.json").read_text(encoding="utf-8"))
check(f"{g} gate PASS {n}/{n}, negatives detected, reload ok",
r["verdict"] == "PASS" and r["summary"]["passed"] == n == r["summary"]["total"]
and all(v["detected"] for v in r["negative"].values()) and (r["reload"]["ok"] or r.get("mode") == "batch"), r["summary"]) # batch mode (app 일괄 승인) skips save + reload on purpose (OPEN #25)
r2 = json.loads((p.dir / "runs" / "current" / "G2.json").read_text(encoding="utf-8"))
cov = r2["summary"]["min_cover"]
check("G2 min side cover P1 107.5 / P2 91 / P3 76 (R 1,000 - radial - bar radius)",
cov == {"P1": 107.5, "P2": 91.0, "P3": 76.0}, cov)
check("scope G1~G5", p.scope() == ["G1", "G2", "G3", "G4", "G5"], p.scope())
r4 = json.loads((p.dir / "runs" / "current" / "G4.json").read_text(encoding="utf-8"))
cov4 = r4["summary"]["cover"]
check("G4 min cover of coping bars >= 40 (corner clipping)", all(v >= 40 for k, v in cov4.items() if not k.startswith("S")), cov4)
check("G4 449 bars", sum(len([b for b in p.load("params")["coping_rebar"]["bars"] if b["mark"] == m]) for m in
{b["mark"] for b in p.load("params")["coping_rebar"]["bars"]}) == 449)
r3 = json.loads((p.dir / "runs" / "current" / "G3.json").read_text(encoding="utf-8"))
cov3 = r3["summary"]["min_cover"]
check("G3 min cover (side / bottom) as hand-computed",
cov3 == {"P1-1": 158.0, "P1-2": 164.5, "P2-1": 142.0, "P3-1": 98.5, "P3-2": 92.0}, cov3)
check("template Ramp-A교P1형 = pier inputs",
all((store.DATA / "_템플릿" / "Ramp-A교P1형" / f).read_bytes() == (p.dir / f).read_bytes()
for f in ("params.json", "expected.json")))
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,48 @@
"""Ramp-A P1 app flow without Rhino (2026-10-14): a copy of data/Ramp-A교P1 in 검증/rampa_web_data (recreated).
Checks that the drilled-shaft form is not blocked by sections it does not have (lean concrete, footing, footing
rebar), that its own sections (pile, shaft_rebar) need confirmation, and that confirming every required bundle clears G1."""
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "rampa_web_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "Ramp-A교P1", TD / "램프", ignore=shutil.ignore_patterns("runs", "backup", "confirm.json"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
p = store.Pier("램프")
check("scope G1~G5", p.scope() == ["G1", "G2", "G3", "G4", "G5"], p.scope())
missing = [b for g in p.scope() for b in webapp.gate_blockers(p, g) if b.endswith("없음")]
check("no gate blocked by an absent section", not missing, missing)
need = {k for _, k in webapp.unconfirmed(p)}
check("pile and shaft_rebar need confirmation", {"pile", "shaft_rebar"} <= need, sorted(need))
check("absent footing sections not required", not ({"lean_concrete", "footing", "footing_rebar", "piles"} & need))
c = webapp.app.test_client()
confirm_required(c, webapp, store, "램프", "시험")
p = store.Pier("램프")
check("confirming every required bundle clears all required", webapp.unconfirmed(p) == [], webapp.unconfirmed(p))
check("G1 not blocked after confirming every required bundle", webapp.gate_blockers(p, "G1") == [], webapp.gate_blockers(p, "G1"))
check("G2 waits only for G1 approval", webapp.gate_blockers(p, "G2") == ["G1 미승인"], webapp.gate_blockers(p, "G2"))
r = c.get("/pier/램프")
h = r.get_data(as_text=True)
check("pier page renders with the pile section", r.status_code == 200 and "params.pile" in h.replace("/", ".") or "pile" in h, r.status_code)
shutil.rmtree(TD)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,50 @@
"""S02 template recommendation test (2026-10-15), no Rhino / no DWG converter: for every pier in data/ that has indexed drawing
texts, estimate the fit answers from the texts and compare with the answers of the pier's own params.json (leave-in: the pier's
own template is among the candidates). Prints the ranking; PASS when the estimates equal the pier's own profile and the
top-ranked template set contains a template with the same profile."""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
from engine import reader, recommend # noqa: E402
DATA = APP / "data"
TPL = {d.name: json.loads((d / "params.json").read_text(encoding="utf-8")) for d in sorted((DATA / "_템플릿").iterdir())
if (d / "params.json").exists()}
profiles = {n: recommend.profile(p) for n, p in TPL.items()}
ok = True
KNOWN_MISMATCH = set() # Ulsan P2 was one (stale P1 copy without piles) until its 2026-10-15 modelling (OPEN #31 resolved)
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:400], flush=True)
for n, pr in profiles.items():
print("template", n, pr)
for d in sorted(DATA.iterdir()):
f = d / "drawings" / "texts.json"
if not (d / "params.json").exists() or not f.exists():
continue
texts = json.loads(f.read_text(encoding="utf-8"))
rows = next((t["rows"] for t in reader.table_candidates(texts) if t["key"] == "elevation"), [])
est = recommend.estimate(texts, rows)
own = recommend.profile(json.loads((d / "params.json").read_text(encoding="utf-8")))
got = {q: est[q][0] for q, _ in recommend.QUESTIONS}
if d.name in KNOWN_MISMATCH:
print("WARN", d.name, "known mismatch drawing vs model (OPEN #31)", f"est={got} own={own}")
continue
check(f"{d.name}: estimate == own profile", got == own, f"est={got} own={own}")
rk = recommend.rank(est, profiles)
best = [r["name"] for r in rk if r["matched"] == rk[0]["matched"]]
same = [n for n, pr in profiles.items() if pr == own]
if same:
check(f"{d.name}: top-ranked templates {best} contain a same-profile template {same}", set(best) & set(same) and rk[0]["matched"] == rk[0]["asked"])
else: # a form no protected template has (Ulsan P2: piles + straight soffit + 4 bearings) -> "판단 불가"
check(f"{d.name}: no template has this profile -> no full match ({rk[0]['matched']}/{rk[0]['asked']}, closest {best})", rk[0]["matched"] < rk[0]["asked"])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,64 @@
"""S02 web flow test (2026-10-15): upload the 3 Ungok P1 DWG, the page must rank 운곡교P1형 first with 3/3; create a pier from the
recommendation; the staged drawings move into the pier and pier.json keeps the six answers. Uses a temp data dir (no Rhino)."""
import json
import os
import re
import shutil
import sys
import tempfile
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = Path(tempfile.mkdtemp(prefix="recweb_"))
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
dwgs = sorted((APP.parents[1] / "RAG").glob("C0050205-02[567]-*.dwg"))
check("3 Ungok DWG found", len(dwgs) == 3, dwgs)
c = webapp.app.test_client()
r = c.post("/recommend", data={"dwg": [(open(p, "rb"), p.name) for p in dwgs]}, content_type="multipart/form-data")
html = r.get_data(as_text=True)
check("recommend page 200", r.status_code == 200, r.status_code)
rows = re.findall(r"<tr><td>(\d)</td><td>([^<]+)</td><td>(\d) / (\d)</td>", html)
check("rank 1 = 운곡교P1형, 3/3", rows and rows[0][1] == "운곡교P1형" and rows[0][2] == rows[0][3] == "3", rows[:2])
check("others rank lower", all(int(a) < 3 for _, _, a, _ in rows[1:]), rows)
sid = re.search(r"/recommend/(\w+)/create", html).group(1)
staged = TD / "_추천" / sid
check("staged drawings exist", (staged / "texts.json").exists())
r = c.post(f"/recommend/{sid}/create", data={"name": "시험교각", "type": "T형", "template": "tpl:운곡교P1형", "ans_foundation": "기초판+말뚝",
"ans_soffit": "곡선", "ans_bearings": "10", "ans_rebar_shape": "예", "ans_rebar_method": "미확인"})
check("create redirects to the pier", r.status_code == 302 and "/pier/" in r.headers["Location"], r.status_code)
P = TD / "시험교각"
check("drawings moved into the pier, staging removed", (P / "drawings" / "texts.json").exists() and (P / "drawings" / "dxf").is_dir() and not staged.exists())
info = json.loads((P / "pier.json").read_text(encoding="utf-8"))
check("six answers + three angle values saved in pier.json", info.get("fit_answers", {}).get("soffit") == "곡선" and len(info["fit_answers"]) == 8, info.get("fit_answers"))
check("pier keeps template label", "운곡교P1형" in info["template"], info["template"])
check("matching answers: no mismatch recorded, no warning on the pier page", info.get("fit_mismatch") == []
and "형식 불일치 경고" not in c.get("/pier/시험교각").get_data(as_text=True), info.get("fit_mismatch"))
check("result judgement area + angle inputs on the page", 'id="verdict"' in html and 'name="ans_skew_deg"' in html)
# second pier from the same staged recommendation with a different soffit answer -> warning, not closable
r = c.post("/recommend", data={"dwg": [(open(p, "rb"), p.name) for p in dwgs]}, content_type="multipart/form-data")
sid2 = re.search(r"/recommend/(\w+)/create", r.get_data(as_text=True)).group(1)
c.post(f"/recommend/{sid2}/create", data={"name": "불일치교각", "type": "T형", "template": "tpl:운곡교P1형", "ans_foundation": "기초판+말뚝",
"ans_soffit": "직선 경사", "ans_bearings": "10", "ans_rebar_shape": "아니오", "ans_rebar_method": "예", "ans_skew_deg": "18"})
info2 = json.loads((TD / "불일치교각" / "pier.json").read_text(encoding="utf-8"))
check("mismatch recorded: soffit + rebar shape", info2["fit_mismatch"] == ["코핑 하면 형상", "철근 형상 종류"] and info2["fit_answers"]["skew_deg"] == "18", info2["fit_mismatch"])
for page in ("/pier/불일치교각", "/pier/불일치교각/candidates", "/pier/불일치교각/gates"):
check(f"warning shown on {page}", "형식 불일치 경고" in c.get(page).get_data(as_text=True))
r = c.post("/recommend", data={"dwg": [(b"x", "a.txt")]}, content_type="multipart/form-data")
check("non-DWG refused", r.status_code == 302)
shutil.rmtree(TD, ignore_errors=True)
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,125 @@
"""Test of the one-screen confirmation "검토 요약 확정" (2026-10-20). No Rhino. Own data root 검증/review_test_data (recreated): a copy of Ungok P4 with
no confirmations. Acceptance: nothing is confirmed by looking at the page; the list must be read to its end (marker) and a name given; a bundle the app
check reports as 주의 / 근거 없음 / 결정 기록 is confirmed only when it is ticked; 일치 bundles only with the group tick; 불일치 bundles never; every
confirmation record carries an informative reason (app check, change, origin) plus the person's note and the app status at that time; a changed list
invalidates the marker."""
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
D = APP / "data"
TD = APP / "검증" / "review_test_data"
if TD.exists():
shutil.rmtree(TD)
sys.path.insert(0, str(APP / "tools"))
from testlib import verified_pier # noqa: E402
SRC = verified_pier("운곡교P4") # the gate-verified P4 (live, or the recycle-bin copy after a real-use re-creation)
if SRC is None:
print("SKIP no verified 운곡교P4")
print("RESULT PASS")
sys.exit(0)
shutil.copytree(SRC, TD / "운곡교P4", ignore=shutil.ignore_patterns("runs", "backup", "dwg", "dxf", "confirm.json", "autocheck.json", "review_view.json", "confirm_view.json"))
shutil.copytree(D / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
import store # noqa: E402
from engine import review as reviewlib # noqa: E402
assert Path(str(store.DATA)) == TD
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
p = store.Pier("운곡교P4")
# one real mismatch (a stale value in expected only) so that group C exists
E = p.load("expected")
E["G1"]["piles"]["pitch_y"] = 1550
p.save("expected", E)
c = webapp.app.test_client()
items = webapp.review_items(p)
by = {g: [i for i in items if i["group"] == g] for g in "ABCD"}
check("the list has all four groups (일치 / 주의·근거 없음 / 불일치 / 결정 기록)", all(by[g] for g in "ABCD"), {g: len(by[g]) for g in "ABCD"})
check("the stale expected value makes expected:G1 and params:piles 불일치 (group C)", {"expected:G1", "params:piles"} <= {i["id"] for i in by["C"]}, [i["id"] for i in by["C"]])
check("every item knows what changed since creation, what the app did not verify (B) and its origin", all(i["change"]["text"] for i in items) and all(i["unverified"] for i in by["B"]))
h = c.get("/pier/운곡교P4/review").get_data(as_text=True)
check("page: one tick per B and D bundle, one group tick for A, none for C", h.count('name="ack"') == len(by["B"]) + len(by["D"]) and 'name="ack_matched"' in h and all(f'value="{i["id"]}"' not in h for i in by["C"]))
check("looking at the page confirmed nothing", not store.rjson(p.dir / "confirm.json", {}))
# ---- the gates of the form
r = c.post("/pier/운곡교P4/review", data={"by": "시험자", "ack_matched": "1"}, follow_redirects=True)
check("without having read the list to its end: refused, nothing written", "끝까지 읽은 뒤" in r.get_data(as_text=True) and not store.rjson(p.dir / "confirm.json", {}))
c.post("/pier/운곡교P4/review/read")
r = c.post("/pier/운곡교P4/review", data={"by": "", "ack_matched": "1"}, follow_redirects=True)
check("without a name: refused", "이름을 입력" in r.get_data(as_text=True) and not store.rjson(p.dir / "confirm.json", {}))
r = c.post("/pier/운곡교P4/review", data={"by": "시험자"}, follow_redirects=True)
check("a name but no tick at all: nothing is confirmed", "확정한 묶음이 없습니다" in r.get_data(as_text=True) and not store.rjson(p.dir / "confirm.json", {}))
# ---- group A with its tick only
r = c.post("/pier/운곡교P4/review", data={"by": "시험자", "ack_matched": "1", "note": "시험 의견"}, follow_redirects=True)
conf = store.rjson(p.dir / "confirm.json", {})
check("일치 group tick: exactly the 일치 bundles are confirmed", {k for k in conf} == {i["id"] for i in by["A"]}, sorted(conf)[:3])
rec = conf[by["A"][0]["id"]]
check("the record has an informative reason (app check + change) and the note, the app status and the name",
rec["why"].startswith("[검토 요약 일괄 확정]") and "앱 확인 일치" in rec["why"] and "의견: 시험 의견" in rec["why"] and rec["app_st"] == "g" and rec["by"] == "시험자", rec["why"])
check("B / C / D bundles stayed unconfirmed", not any(p.section_state(i["file"], i["key"])["confirmed"] for g in "BCD" for i in by[g]))
# ---- B and D need their own tick; C is refused even when posted
ticks = [i["id"] for i in by["B"][:2]] + [i["id"] for i in by["D"]] + [by["C"][0]["id"]]
c.post("/pier/운곡교P4/review/read")
r = c.post("/pier/운곡교P4/review", data={"by": "시험자", "ack": ticks}, follow_redirects=True)
conf = store.rjson(p.dir / "confirm.json", {})
check("ticked B and D bundles are confirmed", all(t in conf for t in ticks[:-1]))
check("the 불일치 bundle is not confirmed even though its id was posted", by["C"][0]["id"] not in conf)
rb = conf[by["B"][0]["id"]]
check("a B record says the person compared with the drawing, names the app status and what was not verified",
"사용자가 도면과 대조했다고 표시함" in rb["why"] and rb["app_st"] in ("w", "n") and ("근거 없음" in rb["why"] or "주의" in rb["why"]), rb["why"])
rd = conf["expected:planned_fail"]
check("the decision record (planned FAIL) is confirmed only by its tick and keeps its status 해당 없음", rd["app_st"] == "x" and "[검토 요약 일괄 확정]" in rd["why"])
check("the unticked B bundles stayed unconfirmed", all(i["id"] not in conf for i in by["B"][2:]))
check("the page has the bulk buttons (check all / clear) for the B and D groups", 'id="all-on"' in h and 'id="all-off"' in h)
check("a provisional registration that waits for the designer is flagged, and the flag is written into the reason",
"설계자 확인 전 임시 등록 포함" in by["D"][0]["flags"] if by["D"][0]["id"] == "expected:planned_fail" and any("설계자 확인" in d for d in by["D"][0].get("detail", [])) else True)
check("the reason of a flagged bundle carries the 주의 표시", "주의 표시" in rd["why"] if reviewlib.flags(dict(change={"mode": "x"}, st="x", detail=["a 설계자 확인 전"])) else False)
check("flags(): template original / provisional registration", reviewlib.flags(dict(change={"mode": "same"}, st="n")) == ["템플릿 원본 그대로(새 도면 근거 없음)"])
# ---- a changed list invalidates the marker
c.post("/pier/운곡교P4/review/read")
P = p.load("params")
P["footing"]["thickness"] = 2300.0
p.save("params", P)
r = c.post("/pier/운곡교P4/review", data={"by": "시험자", "ack": [i["id"] for i in by["B"][2:]]}, follow_redirects=True)
check("after a value changed the marker no longer matches: refused", "끝까지 읽은 뒤" in r.get_data(as_text=True) and all(i["id"] not in store.rjson(p.dir / "confirm.json", {}) for i in by["B"][2:]))
P["footing"]["thickness"] = 2200.0
p.save("params", P)
# ---- fix the mismatch, confirm the rest, nothing is left
E = p.load("expected")
E["G1"]["piles"]["pitch_y"] = 1675
p.save("expected", E)
items = webapp.review_items(p)
c.post("/pier/운곡교P4/review/read")
r = c.post("/pier/운곡교P4/review", data={"by": "시험자", "ack_matched": "1", "ack": [i["id"] for i in items if i["group"] in "BD"]}, follow_redirects=True)
left = webapp.review_items(p)
check("after fixing the mismatch every required bundle can be confirmed through this one screen", not left, [i["id"] for i in left])
check("every confirmation record of this screen has a non-empty reason", all(v.get("why") for v in store.rjson(p.dir / "confirm.json", {}).values()))
check("the gates are not blocked by unconfirmed bundles any more", not [b for b in webapp.gate_blockers(p, "G1") if "미확정" in b])
# ---- pure helpers
ch = reviewlib.bundle_change({"a": 1, "b": [1, 2]}, {"a": 1, "b": [1, 3]})
check("bundle_change: counts the changed values and shows the first ones", ch["mode"] == "changed" and ch["n_changed"] == 1 and "b.1" in ch["text"], ch["text"])
check("bundle_change: no snapshot -> says it cannot compare", reviewlib.bundle_change({"a": 1}, None)["mode"] == "none")
check("provenance: reads the app tools' logs", reviewlib.provenance("params", "footing", [{"at": "2026-10-20 09:00", "by": "x", "fields": ["params:footing.size_x", "params:column.height"]}]) == ["도면에서 읽기 1건 반영 (2026-10-20 09:00 x)"])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,46 @@
"""Runner stall handling check (2026-10-12).
1. start_timeout=3 s with 1 retry: Rhino cannot start a script that fast -> both attempts are killed, RhinoJobError
lists 2 attempts with the window title, and no Rhino process from this test is left running.
2. normal run: result returned and no Rhino left behind (exit mode exited / killed after result is recorded).
"""
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from runner import RhinoJobError, run_job # noqa: E402
step = Path(tempfile.mkdtemp()) / "noop.py"
step.write_text("print('noop')\n", encoding="utf-8")
def rhino_count():
out = subprocess.run(["tasklist", "/FI", "IMAGENAME eq Rhino.exe", "/FO", "CSV", "/NH"], capture_output=True, text=True)
return out.stdout.count("Rhino.exe")
ok = True
before = rhino_count()
try:
run_job([{"name": "noop", "script": step}], start_timeout=3, retries=1)
print("FAIL stall: no error raised")
ok = False
except RhinoJobError as e:
n = str(e).count("did not start the job within 3s")
print("PASS" if n == 2 else "FAIL", "stall: attempts reported", n, "|", str(e)[:200])
ok &= n == 2
left = rhino_count() - before
print("PASS" if left == 0 else "FAIL", "stall: Rhino processes left", left)
ok &= left == 0
r = run_job([{"name": "noop", "script": step}])
good = r["ok"] and r["steps"][0]["stdout"].strip() == "noop"
print("PASS" if good else "FAIL", "normal run returns the result:", r["wall_secs"], "s")
# How Rhino ended is recorded, not judged: since 2026-10-12 13:00 Rhino on this PC often stays alive after
# _-Exit (cause not identified); the runner then kills it after the grace period, the result is already safe.
print("INFO rhino exit:", r["rhino_exit"])
left = rhino_count() - before
print("PASS" if left == 0 else "FAIL", "normal run: Rhino processes left", left)
ok &= good and left == 0
sys.exit(0 if ok else 1)
@@ -0,0 +1,37 @@
"""Saejae-1 P1 form test (2026-10-15), no Rhino: engine gate reports of data/새재1교P1 (G1 + G5) PASS with negatives detected,
the pier exists with its template, reference piers unchanged (검증/open19_snapshot_after.json)."""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from open19_snapshot import snapshot # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
ref = json.loads((APP / "검증" / "open19_snapshot_after.json").read_text(encoding="utf-8"))
now = snapshot()
for k in ref:
check(f"{k}: table records / proposals unchanged", ref[k]["records"] == now[k]["records"] and ref[k]["proposals"] == now[k]["proposals"])
D = APP / "data" / "새재1교P1"
P = json.loads((D / "params.json").read_text(encoding="utf-8"))
X = json.loads((D / "expected.json").read_text(encoding="utf-8"))
check("scope G1 + G2 + G3 + G4 + G5, checker g1_pedestal.py", P["scope"] == ["G1", "G2", "G3", "G4", "G5"] and X["checkers"]["G1"] == "g1_pedestal.py")
check("spread footing (no piles), 10 bearings on 5 shared pedestals", "piles" not in P and len(P["bearing_seats"]["bearings"]) == 10
and len(P["bearing_seats"]["pedestals"]) == 5)
check("saved template", (APP / "data" / "_템플릿" / "새재1교P1형" / "params.json").exists())
for g, n, tot in (("G1", 138, 138), ("G2", 41, 41), ("G3", 72, 76), ("G4", 189, 189), ("G5", 183, 183)): # G3: 4 planned FAIL (B5~B8 count 51 vs 53)
r = json.loads((D / "runs" / "current" / f"{g}_gate.json").read_text(encoding="utf-8"))
check(f"{g} gate PASS {n}/{tot}, negatives detected", r["verdict"] == "PASS" and r["summary"]["passed"] == n and r["summary"]["total"] == tot
and all(v["detected"] for v in r["negative"].values()), r["summary"])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,45 @@
"""Concurrency check of web/store.py wjson/rjson: one thread rewrites a job file while another reads it.
Reproduces the 2026-10-12 E2E failure (reader saw an empty file during write_text). Pass = no read errors
and no writer errors. usage: python test_store_concurrency.py [writes]
"""
import sys
import tempfile
import threading
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "web"))
import store # noqa: E402
N = int(sys.argv[1]) if len(sys.argv) > 1 else 500
p = Path(tempfile.mkdtemp()) / "G1_job.json"
store.wjson(p, {"state": "대기", "log": []})
stop = threading.Event()
read_err, reads = [], [0]
def reader():
while not stop.is_set():
try:
store.rjson(p)
reads[0] += 1
except Exception as e:
read_err.append(repr(e))
t = threading.Thread(target=reader, daemon=True)
t.start()
write_err, t0 = [], time.time()
try:
for i in range(N):
try:
store.wjson(p, {"state": "실행 중", "log": ["x" * 200] * (i % 50)})
except Exception as e:
write_err.append(repr(e))
finally:
stop.set()
t.join(5)
print(f"writes {N} reads {reads[0]} read_errors {len(read_err)} write_errors {len(write_err)} "
f"{time.time() - t0:.1f}s", read_err[:2], write_err[:2])
sys.exit(0 if not read_err and not write_err else 1)
@@ -0,0 +1,67 @@
"""Protected template test (2026-10-13): save / list / create from template / protection. No Rhino.
Uses its own data root 검증/template_test_data (recreated) with copies of the two real templates."""
import json
import os
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "template_test_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
shutil.copytree(APP / "data" / "운곡교P1", TD / "원본", ignore=shutil.ignore_patterns("runs", "backup", "drawings"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
import store # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:200], flush=True)
c = webapp.app.test_client()
names = [t["name"] for t in store.templates()]
# 2026-10-13: + Ramp-A교P1형 (third form)
check("templates listed", names == ["Ramp-A교P1형", "산외교울산P1형", "산외교울산P2형", "새재1교P1형", "옥성교P1형", "운곡교P1형"], names) # + 옥성교P1형 2026-10-14, + 산외교울산P2형 2026-10-15
check("templates are not piers", store.piers() == ["원본"], store.piers())
h = c.get("/").get_data(as_text=True)
check("index shows protected group and table", 'label="보호 템플릿"' in h and 'value="tpl:산외교울산P1형"' in h
and "직접기초(말뚝 없음)" in h and 'label="기존 교각 복사"' in h)
r = c.post("/pier/new", data={"name": "울산형새교각", "type": "T형", "template": "tpl:산외교울산P1형"}, follow_redirects=True)
d = TD / "울산형새교각"
same = all((d / f).read_bytes() == (TD / "_템플릿" / "산외교울산P1형" / f).read_bytes() for f in ("params.json", "expected.json"))
info = json.loads((d / "pier.json").read_text(encoding="utf-8"))
check("pier from protected template", r.status_code == 200 and same and info["template"] == "템플릿: 산외교울산P1형", info)
p = store.Pier("울산형새교각")
check("new pier has the Ulsan scope and needs confirmation", p.scope() == ["G1", "G2", "G3", "G4", "G5"]
and webapp.unconfirmed(p), len(webapp.unconfirmed(p)))
check("pier page renders", c.get("/pier/울산형새교각").status_code == 200)
# editing / deleting the new pier leaves the template unchanged
p.set_section("params", "pier_id", "변경")
before = (TD / "_템플릿" / "산외교울산P1형" / "params.json").read_bytes()
c.post("/pier/울산형새교각/delete", data={"confirm_name": "울산형새교각"})
check("template unchanged after edit + delete", (TD / "_템플릿" / "산외교울산P1형" / "params.json").read_bytes() == before
and json.loads(before)["pier_id"] == "P1")
# copy of an existing pier still works (plain name)
r = c.post("/pier/new", data={"name": "복사본", "type": "T형", "template": "원본"}, follow_redirects=True)
check("copy of an existing pier", (TD / "복사본" / "params.json").exists()
and json.loads((TD / "복사본" / "pier.json").read_text(encoding="utf-8"))["template"] == "원본")
# refusals
r = c.post("/pier/new", data={"name": "_템플릿", "type": "T형", "template": "원본"}, follow_redirects=True)
check("reserved name refused", "앱이 쓰는 폴더 이름" in r.get_data(as_text=True) and not (TD / "_템플릿" / "pier.json").exists())
r = c.post("/pier/new", data={"name": "없는템플릿", "type": "T형", "template": "tpl:없음"}, follow_redirects=True)
check("missing template refused", "템플릿이 없습니다" in r.get_data(as_text=True) and not (TD / "없는템플릿").exists())
check("template folder cannot be deleted as a pier", c.post("/pier/_템플릿/delete", data={"confirm_name": "_템플릿"}).status_code == 404)
# re-save keeps the old snapshot
store.save_template("원본", "운곡교P1형", "재저장 시험")
old = list((TD / "_템플릿" / "_이전").glob("운곡교P1형_*"))
check("re-save keeps old snapshot, _이전 not listed", len(old) == 1 and [t["name"] for t in store.templates()] == names,
[t["name"] for t in store.templates()])
sys.exit(0 if ok else 1)
@@ -0,0 +1,196 @@
"""End-to-end web test of the Ulsan form (2026-10-13), as a user would work: new pier from the protected template
산외교울산P1형, upload of a copy of RAG/교각배근도(울산P1).dwg, search and table candidates, confirmation, individual
G1~G5 runs with approvals (save / reload / unchanged checks), report, model downloads, a wrong input caught by the
checker, deletion. Own data root 검증/ulsan_e2e_data (recreated); real ODA conversion and Rhino runs.
Results: 검증/ulsan_e2e_test.json
"""
import json
import os
import shutil
import sys
import time
from io import BytesIO
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
ROOT = APP.parents[1]
TD = APP / "검증" / "ulsan_e2e_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
P, BY = "울산E2E", "시험자"
EXP = {"G1": 104, "G2": 35, "G3": 63, "G4": 129, "G5": 75}
results = []
def check(name, ok, detail=""):
results.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:400]})
print("PASS" if ok else "FAIL", name, str(detail)[:200], flush=True)
def info(name, detail):
results.append({"check": name, "result": "INFO", "detail": str(detail)[:2000]})
print("INFO", name, str(detail)[:300], flush=True)
def html(r):
return r.get_data(as_text=True)
def run_and_wait(c, g, limit=1500):
c.post(f"/pier/{P}/gate/{g}/run")
jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id")
t0 = time.time()
while time.time() - t0 < limit:
job = store.Pier(P).gate_status(g)["job"] or {}
if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"):
return job, round(time.time() - t0)
time.sleep(5)
return None, round(time.time() - t0)
def main():
c = webapp.app.test_client()
tpl = TD / "_템플릿" / "산외교울산P1형"
# 1 new pier from the protected template
r = c.post("/pier/new", data={"name": P, "type": "T형", "template": "tpl:산외교울산P1형"}, follow_redirects=True)
p = store.Pier(P)
check("1 pier from protected template 산외교울산P1형", r.status_code == 200 and p.scope() == list(EXP)
and p.info["template"] == "템플릿: 산외교울산P1형", p.info)
# 2 upload + conversion + index
dwg = ROOT / "RAG" / "교각배근도(울산P1).dwg"
sha_before = store.hashlib.sha256(dwg.read_bytes()).hexdigest()
t0 = time.time()
r = c.post(f"/pier/{P}/upload", data={"dwg": [(BytesIO(dwg.read_bytes()), dwg.name)]},
content_type="multipart/form-data", follow_redirects=True)
texts = store.Pier(P).texts()
audit = store.rjson(TD / P / "drawings" / "audit.json", [])
check("2 upload converts the Ulsan DWG and indexes texts", len(audit) == 1 and len(texts) > 1000
and "도면 1장 변환" in html(r), f"{len(texts)} texts, {time.time() - t0:.0f}s")
check("2 RAG original unchanged (read only)", store.hashlib.sha256(dwg.read_bytes()).hexdigest() == sha_before)
# 3 search: values the Ulsan reading used
for q, why in (("EL. 42.763", "footing bottom"), ("28@250=7.000", "footing top chain"),
("24@125=3.000", "coping top layers"), ("8@125", "seat reinforcement")):
r = c.get(f"/pier/{P}/search", query_string={"q": q})
n = sum(1 for t in texts if q in t["text"])
check(f"3 search '{q}' ({why})", r.status_code == 200 and n > 0 and f"{n}건" in html(r), f"{n}")
# 4 table candidates against the template values (same drawing -> expect no differences)
r = c.get(f"/pier/{P}/candidates")
h = html(r)
import re
m = re.search(r"후보값 비교 \((\d+)건 · 다름 (\d+)건\)", h)
check("4 candidates page renders", r.status_code == 200)
if m:
check("4 candidates: template values equal the drawing tables (다름 0)", m.group(2) == "0",
f"{m.group(1)} rows, {m.group(2)} different")
else:
info("4 candidates: no comparison table", re.sub(r"<[^>]+>", " ", h)[:600])
from engine import reader
# per-bearing value (G5 h3 is a dict per bearing in the Ulsan form): edit one bearing, detect, apply back
E = store.Pier(P).load("expected")
g5 = E["G5"]
good = dict(g5["h3"])
g5["h3"]["3"] = 999
c.post(f"/pier/{P}/section/expected/G5", data={"value": json.dumps(g5, ensure_ascii=False)})
pp = store.Pier(P)
rows = reader.proposals(reader.field_candidates(reader.table_candidates(pp.texts())), pp.load("params"), pp.load("expected"))
diff = [r for r in rows if not r["same"]]
check("4 edited bearing-3 h3 detected as the only difference", len(diff) == 1 and diff[0]["path"] == ["G5", "h3", "3"],
[(r["path"], r["current"], r["candidate"]) for r in diff])
r = c.post(f"/pier/{P}/candidates/apply", data={"row": [str(rows.index(d)) for d in diff]}, follow_redirects=True)
check("4 apply restores the drawing value and keeps the per-bearing form",
store.Pier(P).load("expected")["G5"]["h3"] == good and "다름 0건" in html(r), store.Pier(P).load("expected")["G5"]["h3"])
tabs = reader.table_candidates(texts)
info("4 recognised tables", [{k: (v if not isinstance(v, (list, dict)) else len(v)) for k, v in t.items()} for t in tabs])
# 5 confirmation: run blocked before, bulk confirm, then allowed
r = c.post(f"/pier/{P}/gate/G1/run", follow_redirects=True)
check("5 G1 blocked while sections unconfirmed", "G1 실행 불가" in html(r))
need = webapp.unconfirmed(store.Pier(P))
confirm_required(c, webapp, store, P, BY)
check("5 bulk confirm (all required sections)", not webapp.unconfirmed(store.Pier(P)), f"{len(need)} sections")
# 6 individual runs + approvals
for g, n in EXP.items():
job, sec = run_and_wait(c, g)
rep = store.Pier(P).gate_status(g)["report"] or {}
s = rep.get("summary", {})
check(f"6 {g} individual run -> PASS {n}/{n}, reload and unchanged verified",
bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS" and s.get("passed") == n
and rep.get("mode") != "batch" and (rep.get("reload") or {}).get("ok") is True
and (rep.get("unchanged") or {}).get("ok") in (True, None),
f"{s.get('passed')}/{s.get('total')} {sec}s reload={(rep.get('reload') or {}).get('ok')} "
f"unchanged={(rep.get('unchanged') or {}).get('ok')} {(job or {}).get('error', '')[-200:]}")
c.post(f"/pier/{P}/gate/{g}/decide", data={"by": BY, "decision": "승인"})
check(f"6 {g} approved", store.Pier(P).gate_status(g)["approved"])
# 7 downloads (individual runs save every gate model)
for g in EXP:
r = c.get(f"/pier/{P}/download/{g}")
check(f"7 download {g}.3dm", r.status_code == 200 and len(r.data) > 100000, f"{len(r.data)} bytes")
r.close()
# 8 report
r = c.post(f"/pier/{P}/report", data={"by": BY}, follow_redirects=True)
rc = store.rjson(TD / P / "runs" / "current" / "report_check.json", {})
md_files = sorted((TD / P / "runs" / "current").glob("보고서_*.md"))
md = md_files[-1].read_text(encoding="utf-8") if md_files else ""
check("8 report generated, all gates PASS and approved", bool(md) and "게이트 진행: 5/5 실행" in md
and "전 게이트 PASS·승인" in md, rc)
sec6 = md.split("## 6.")[1].split("## 7.")[0] if "## 6." in md and "## 7." in md else ""
rows6 = [l for l in sec6.splitlines() if l.startswith("| ") and not l.startswith("| :") and "마크" not in l]
check("8 report ch.6: 34 schedule marks, all 일치 with the model", len(rows6) == 34 and all("| 일치 |" in l for l in rows6),
f"{len(rows6)} rows; not 일치: {[l for l in rows6 if '| 일치 |' not in l][:3]}")
check("8 report self-check passed", rc.get("ok") is True, rc)
rp = c.get(f"/pier/{P}/report/pdf")
check("8 report PDF download", rp.status_code == 200 and rp.data[:4] == b"%PDF", len(rp.data))
rp.close()
# 9 wrong input caught: footing width 7,500 -> 7,505 in expected G1. Any input change voids every approval
# (inputs hash), so G3 is blocked until G1 is approved again; G1 runs FAIL and cannot be approved; restore
E = store.Pier(P).load("expected")
g1 = E["G1"]
g1["footing"]["dx"] = 7505
c.post(f"/pier/{P}/section/expected/G1", data={"value": json.dumps(g1, ensure_ascii=False)})
pp = store.Pier(P)
check("9 input change -> all gates stale, approvals void", all(pp.gate_status(g)["stale"] and not pp.gate_status(g)["approved"]
for g in EXP))
c.post(f"/pier/{P}/section/expected/G1/confirm", data={"by": BY, "why": "시험"}) # stage B: reason for a non-일치 bundle
r = c.post(f"/pier/{P}/gate/G3/run", follow_redirects=True)
check("9 G3 blocked until G1 is approved again", "G3 실행 불가" in html(r) and "미승인" in html(r))
job, sec = run_and_wait(c, "G1")
rep = store.Pier(P).gate_status("G1")["report"] or {}
check("9 footing dx 7,505 -> G1 FAIL (footing checks)", bool(job) and rep.get("job_id") == job["id"]
and rep.get("verdict") == "FAIL" and any("footing" in f for f in rep.get("unplanned_fails", [])),
rep.get("unplanned_fails"))
r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": BY, "decision": "승인"}, follow_redirects=True)
check("9 FAIL gate cannot be approved", "승인할 수 없습니다" in html(r) and not store.Pier(P).gate_status("G1")["approved"])
g1["footing"]["dx"] = 7500
c.post(f"/pier/{P}/section/expected/G1", data={"value": json.dumps(g1, ensure_ascii=False)})
c.post(f"/pier/{P}/section/expected/G1/confirm", data={"by": BY, "why": "시험"}) # stage B: reason for a non-일치 bundle
job, sec = run_and_wait(c, "G1")
c.post(f"/pier/{P}/gate/G1/decide", data={"by": BY, "decision": "승인"})
pp = store.Pier(P)
check("9 restored input -> G1 PASS and approved; G2~G5 approvals valid again (same inputs as approved)",
all(pp.gate_status(g)["approved"] for g in EXP), {g: pp.gate_status(g)["approved"] for g in EXP})
# 10 pages, deletion, template intact
for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/search?q=C7",
f"/pier/{P}/section/params/coping_rebar"):
check(f"10 page renders {page}", c.get(page).status_code == 200)
tsha = [(tpl / f).read_bytes() for f in ("params.json", "expected.json")]
c.post(f"/pier/{P}/delete", data={"confirm_name": P})
check("10 delete moves the pier to the trash; template unchanged", not (TD / P).exists()
and any((TD / "_휴지통").glob(f"{P}_*")) and tsha == [(tpl / f).read_bytes() for f in ("params.json", "expected.json")])
out = APP / "검증" / "ulsan_e2e_test.json"
n_ok = sum(x["result"] == "PASS" for x in results)
n = sum(x["result"] != "INFO" for x in results)
out.write_text(json.dumps({"results": results, "passed": n_ok, "total": n}, ensure_ascii=False, indent=1), encoding="utf-8")
print("TOTAL", n_ok, "/", n)
return 0 if n_ok == n else 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,48 @@
"""Ulsan P2 form test (2026-10-15), no Rhino: engine gate reports of data/산외교울산P2 (G1~G5; G3 with one planned FAIL) PASS with negatives detected, the
foundation rotation (37 deg, piles 6 x 5 on the foundation's axes) and the P2-specific values are in params / expected, and the reference
piers are unchanged (검증/open19_snapshot_after.json)."""
import json
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from open19_snapshot import snapshot # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
ref = json.loads((APP / "검증" / "open19_snapshot_after.json").read_text(encoding="utf-8"))
now = snapshot()
for k in ref:
check(f"{k}: table records / proposals unchanged", ref[k]["records"] == now[k]["records"] and ref[k]["proposals"] == now[k]["proposals"])
D = APP / "data" / "산외교울산P2"
P = json.loads((D / "params.json").read_text(encoding="utf-8"))
X = json.loads((D / "expected.json").read_text(encoding="utf-8"))
check("scope G1~G5, builders / checkers", P["scope"] == ["G1", "G2", "G3", "G4", "G5"] and X["checkers"] == {"G1": "g1_tapered.py", "G2": "g2_ring.py", "G3": "g3_mats.py", "G4": "g4_polylines.py"}
and P["builders"]["G3"] == "footing_rebar_mats.py")
F, G = P["footing"], X["G1"]
check("footing 7800 x 7500 x 2500 at EL 38.916, rotated 37 deg (params = expected)",
(F["size_x"], F["size_y"], F["thickness"], F["z_bottom"], F["rot_deg"]) == (7800, 7500, 2500, 38916, 37.0)
and (G["footing"]["dx"], G["footing"]["dy"], G["footing"]["z0"], G["footing"]["rot_deg"]) == (7800, 7500, 38916, 37.0))
check("piles 6 x 5 @1300/1550, D508 L7000, head +100", (P["piles"]["count_x"], P["piles"]["count_y"], P["piles"]["pitch_x"], P["piles"]["pitch_y"],
P["piles"]["diameter"], P["piles"]["length"], P["piles"]["head_embedment"]) == (6, 5, 1300.0, 1550.0, 508.0, 7000.0, 100.0)
and (G["piles"]["nx"], G["piles"]["ny"], G["piles"]["edge_x"], G["piles"]["edge_y"]) == (6, 5, 650, 650))
check("column 2500 x 27300; coping EL 72.046 / 71.916", P["column"]["height"] == 27300 and P["coping"]["z_top_left"] == 72046.0 and P["coping"]["z_top_right"] == 71916.0)
check("4 bearings, seat levels from the coping steps", len(P["bearing_seats"]["bearings"]) == 4
and all(abs(b["el_x"] - b["h1"] - b["h2"] - b["seat_z"]) <= 1 for b in P["bearing_seats"]["bearings"]))
check("rebar counts = schedule: column P1 68 / footing B7 320 / coping C7 51",
X["G2"]["schedule_n"]["P1"] == 68 and X["G3"]["schedule"]["B7"][1] == 320 and X["G4"]["schedule"]["C7"][1] == 51)
for g, n, tot in (("G1", 118, 118), ("G2", 35, 35), ("G3", 63, 64), ("G4", 129, 129), ("G5", 75, 75)): # G3: 1 planned FAIL (B8 length, OPEN #32)
r = json.loads((D / "runs" / "current" / f"{g}_gate.json").read_text(encoding="utf-8"))
check(f"{g} gate PASS {n}/{tot}, negatives detected", r["verdict"] == "PASS" and r["summary"]["passed"] == n and r["summary"]["total"] == tot
and not r["unplanned_fails"] and all(v["detected"] for v in r["negative"].values()), r["summary"])
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
@@ -0,0 +1,103 @@
"""Stage A test of the 입력 묶음 확인·확정 개편 (2026-10-16), no Rhino: variability classes, template-origin marking, F-field edit rule (D3),
V3 edit block. Own data root 검증/variability_test_data (recreated); real piers are not touched.
Checks: every bundle of the 6 protected templates has a class (100 %), grades follow 기획서 부록 A, a new pier from a template shows all
required bundles as "템플릿 원본 그대로" and an edited bundle drops out, an old pier without origin record claims nothing, F edits need a
reason and are logged, V3 edits are refused, saved templates get a variability.json."""
import json
import os
import re
import shutil
import sys
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "variability_test_data"
if TD.exists():
shutil.rmtree(TD)
shutil.copytree(APP / "data" / "_템플릿", TD / "_템플릿", ignore=shutil.ignore_patterns("_이전"))
shutil.copytree(APP / "data" / "옥성교P1", TD / "옥성교P1", ignore=shutil.ignore_patterns("runs", "backup", "drawings"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
sys.path.insert(0, str(APP))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
from engine import variability # noqa: E402
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
# ---- 1. classes of the protected templates
for t in store.templates():
d = TD / "_템플릿" / t["name"]
P, E = (json.loads((d / f).read_text(encoding="utf-8")) for f in ("params.json", "expected.json"))
v = json.loads((d / "variability.json").read_text(encoding="utf-8"))["sections"]
want = {f"params:{k}" for k in P} | {f"expected:{k}" for k in E}
check(f"{t['name']}: every bundle classified ({len(want)})", set(v) == want and all(s["grade"] in variability.GRADES for s in v.values()),
sorted(want ^ set(v))[:5])
v3 = sorted(k for k, s in v.items() if s["grade"] == "V3")
exp_v3 = {"운곡교P1형": [], "산외교울산P1형": ["params:coping_rebar"], "산외교울산P2형": ["params:coping_rebar"], "Ramp-A교P1형": ["params:coping_rebar"],
"옥성교P1형": ["params:coping_rebar", "params:footing_rebar"], "새재1교P1형": ["params:coping_rebar", "params:footing_rebar"]}[t["name"]]
check(f"{t['name']}: V3 = {exp_v3} (explicit bar lists, 부록 A)", v3 == exp_v3, v3)
check(f"{t['name']}: run settings / pier_id are F", all(v[k]["grade"] == "F" for k in ("params:pier_id", "expected:pier_id", "expected:checkers") if k in v))
# ---- 2. new pier from a template: all required bundles are "원본 그대로"
c = webapp.app.test_client()
r = c.post("/pier/new", data={"name": "원본시험", "type": "T형", "template": "tpl:새재1교P1형"})
check("new pier created", r.status_code == 302, r.status_code)
p = store.Pier("원본시험")
req = webapp.required(p)
same = {(s["file"], s["key"]) for s in p.sections() if p.section_meta(s["file"], s["key"])["origin"]}
check(f"all {len(req)} required bundles are still the template's original", req <= same and len(req) > 20, f"{len(req & same)}/{len(req)}")
html = c.get("/pier/원본시험").get_data(as_text=True)
check("pier page warns about original values and shows grade / source columns", f"템플릿 원본 그대로인 필수 묶음 {len(req)}개" in html
and "변수 등급" in html and "값 출처" in html and "V1 값 변수" in html)
check("pier folder holds its own variability.json and the origin record", (TD / "원본시험" / "variability.json").exists() and p.info.get("origin", {}).get("sha"))
# ---- 3. editing a V1 bundle drops it from the original list
col = json.loads(json.dumps(p.load("params")["column"]))
col["height"] = col["height"] + 100
r = c.post("/pier/원본시험/section/params/column", data={"value": json.dumps(col)})
check("V1 edit saved without reason", r.status_code == 302 and p.load("params")["column"]["height"] == col["height"])
check("edited bundle is no longer 'original'", p.section_meta("params", "column")["origin"] is False and p.section_meta("params", "footing")["origin"] is True)
# ---- 4. F fields need a reason (D3), the reason is logged
col2 = dict(col, shape="rectangular")
r = c.post("/pier/원본시험/section/params/column", data={"value": json.dumps(col2)})
check("F-field change without reason refused", "고정(F) 항목" in r.get_data(as_text=True) and p.load("params")["column"]["shape"] == "circular")
r = c.post("/pier/원본시험/section/params/column", data={"value": json.dumps(col2), "reason": "사각 기둥 시험"})
log = store.rjson(TD / "원본시험" / "edit_log.json", [])
check("F-field change with reason saved and logged", p.load("params")["column"]["shape"] == "rectangular" and log and log[-1]["reason"] == "사각 기둥 시험"
and log[-1]["fixed_fields"] == ["shape"], log[-1:] )
sc = p.load("params")["scope"]
r = c.post("/pier/원본시험/section/params/scope", data={"value": json.dumps(sc[:-1])})
check("F bundle (scope) change without reason refused", "고정(F) 항목" in r.get_data(as_text=True) and p.load("params")["scope"] == sc)
# ---- 5. V3 bundles cannot be edited by hand
before = p.load("params")["coping_rebar"]
r = c.post("/pier/원본시험/section/params/coping_rebar", data={"value": json.dumps(before)})
check("V3 bundle edit refused", "생성 변수(V3)" in r.get_data(as_text=True) and p.load("params")["coping_rebar"] == before)
check("V3 section page explains", "판독 스크립트를 다시 실행" in c.get("/pier/원본시험/section/params/coping_rebar").get_data(as_text=True))
# ---- 6. an old pier (no origin record) claims nothing
q = store.Pier("옥성교P1")
check("old pier: origin unknown (None), no 'original' claim", q.info.get("origin") is None and all(q.section_meta(s["file"], s["key"])["origin"] is None for s in q.sections()))
html = c.get("/pier/옥성교P1").get_data(as_text=True)
check("old pier page says the origin is not recorded", "템플릿 복사 기록이 없어" in html and "▲ 템플릿 원본 그대로" not in html)
# ---- 7. saving a template writes variability.json
store.save_template("원본시험", "시험형", "시험")
check("save_template writes variability.json", (TD / "_템플릿" / "시험형" / "variability.json").exists())
# ---- 8. confirmation still works and the confirm records are untouched by the new metadata
confirm_required(c, webapp, store, "원본시험", "시험자")
check("confirming every required bundle one by one works", not webapp.unconfirmed(store.Pier("원본시험")))
print("RESULT", "PASS" if ok else "FAIL")
sys.exit(0 if ok else 1)
+183
View File
@@ -0,0 +1,183 @@
"""P2 end-to-end test of the web app with the Flask test client (real ODA conversion and real Rhino runs).
Uses its own data root (검증/web_test_data, recreated each run) with the Ungok P1 pier as template, and uploads
copies of the Ungok P1 DWG files (RAG originals are only read).
usage: python test_web.py [--gates G1..G5] (default: all five gates with approvals)
"""
import argparse
import json
import os
import shutil
import sys
import time
from io import BytesIO
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
ROOT = APP.parents[1]
TD = APP / "검증" / "web_test_data"
if TD.exists():
shutil.rmtree(TD)
(TD / "운곡교P1").mkdir(parents=True)
for f in ("params.json", "expected.json"):
shutil.copy2(APP / "data" / "운곡교P1" / f, TD / "운곡교P1" / f)
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
import store # noqa: E402
results = []
def check(name, ok, detail=""):
results.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:300]})
print("PASS" if ok else "FAIL", name, str(detail)[:160], flush=True)
def html(r):
return r.get_data(as_text=True)
def run_and_wait(c, P, g, limit=900):
"""POST run, then wait until the job with the id written at start-up is finished; returns (job, trace)."""
c.post(f"/pier/{P}/gate/{g}/run")
jid = (store.Pier(P).gate_status(g)["job"] or {}).get("id")
t0, trace = time.time(), []
while time.time() - t0 < limit:
job = store.Pier(P).gate_status(g)["job"] or {}
trace.append((round(time.time() - t0), job.get("id") == jid, job.get("state")))
if jid and job.get("id") == jid and job.get("state") in ("완료", "오류"):
return job, trace
time.sleep(3)
return None, trace
def report_checks(c, P):
r = c.post(f"/pier/{P}/report", data={"by": "테스트"}, follow_redirects=True)
chk = json.loads((TD / P / "runs" / "current" / "report_check.json").read_text(encoding="utf-8"))
md = next((TD / P / "runs" / "current").glob("보고서_*.md")).read_text(encoding="utf-8")
check("report self-check ok (chapters, vocabulary, 37 schedule rows, 5 gate rows)", chk["ok"], chk)
check("report: all gates PASS and approved", "전 게이트 PASS·승인" in md)
check("report: 6 planned FAIL listed as 불일치", md.count("| 불일치 | G") == 6, md.count("| 불일치 | G"))
check("report: B12 schedule 34 vs model 28 -> 불일치", "| B12 | III | S16 | 34 | 7800 | 28 | 불일치 |" in md)
sec6 = md.split("## 6.")[1].split("## 7.")[0]
check("report: other modelled marks match (35)", sec6.count("| 일치 |") == 35, sec6.count("| 일치 |"))
check("report: UA1 outside model", "| UA1 | - | S16 | - | - | 모델 외 | - |" in md)
rp = c.get(f"/pier/{P}/report/pdf")
check("report PDF download", rp.status_code == 200 and rp.data[:4] == b"%PDF", len(rp.data))
rp.close()
def main(gates):
c = webapp.app.test_client()
P = "테스트P1"
# 1 pier creation: other types rejected, T형 accepted
r = c.post("/pier/new", data={"name": "기타형", "type": "기타", "template": "운곡교P1"}, follow_redirects=True)
check("non-T pier rejected", "대상 외 교각 형식" in html(r) and not (TD / "기타형").exists())
r = c.post("/pier/new", data={"name": P, "type": "T형", "template": "운곡교P1"}, follow_redirects=True)
check("T pier created from template", r.status_code == 200 and (TD / P / "params.json").exists())
# 2 upload + convert + index (copies of the RAG DWG)
dwgs = sorted((ROOT / "RAG").glob("C0050205-02*-운곡교 교각도*.dwg"))
data = {"dwg": [(BytesIO(p.read_bytes()), p.name) for p in dwgs]}
t0 = time.time()
r = c.post(f"/pier/{P}/upload", data=data, content_type="multipart/form-data", follow_redirects=True)
texts = store.Pier(P).texts()
check("upload converts 3 DWG and indexes texts", len(dwgs) == 3 and len(texts) > 2000,
f"{len(texts)} texts, {time.time() - t0:.0f}s")
r = c.post(f"/pier/{P}/upload", data={"dwg": [(BytesIO(b"x"), "a.txt")]}, content_type="multipart/form-data",
follow_redirects=True)
check("non-DWG upload refused", "DWG 파일(.dwg)만" in html(r))
# 3 search
r = c.get(f"/pier/{P}/search", query_string={"q": "EL. 26.208"})
check("search finds 'EL. 26.208'", "3건" in html(r), "")
r = c.get(f"/pier/{P}/search", query_string={"q": "([", "regex": "1"})
check("bad regex reported, no crash", r.status_code == 200 and "검색식 오류" in html(r))
# 4 candidates: all equal on the template values; a changed value shows as different and can be re-applied
r = c.get(f"/pier/{P}/candidates")
# 2026-10-13 OPEN #19 A4: + 91 params rows (bearing_seats / sole_plates) -> 257 (was 166)
check("candidates: 257 rows (166 + 91 params), 0 different", "후보값 비교 (257건 · 다름 0건)" in html(r))
E = store.Pier(P).load("expected")
g5 = E["G5"]
g5["el_x"]["1"] = 99999
r = c.post(f"/pier/{P}/section/expected/G5", data={"value": json.dumps(g5, ensure_ascii=False)}, follow_redirects=True)
r = c.get(f"/pier/{P}/candidates")
check("edited value detected as different", "다름 1건" in html(r))
from engine import reader
pp = store.Pier(P)
rows = reader.proposals(reader.field_candidates(reader.table_candidates(pp.texts())), pp.load("params"), pp.load("expected"))
idx = [str(i) for i, x in enumerate(rows) if not x["same"]]
r = c.post(f"/pier/{P}/candidates/apply", data={"row": idx}, follow_redirects=True)
check("apply candidate restores drawing value", store.Pier(P).load("expected")["G5"]["el_x"]["1"] == 43625
and "다름 0건" in html(r))
r = c.post(f"/pier/{P}/section/expected/G5", data={"value": "[1, 2]"}, follow_redirects=True)
check("type change refused", "형식이 바뀌었습니다" in html(r))
r = c.post(f"/pier/{P}/section/expected/G5", data={"value": "{bad json"}, follow_redirects=True)
check("invalid JSON refused", "저장 안 됨" in html(r))
# 5 gate preconditions
r = c.post(f"/pier/{P}/gate/G1/run", follow_redirects=True)
check("G1 blocked while sections unconfirmed", "G1 실행 불가" in html(r) and "미확정" in html(r))
pier = store.Pier(P)
need = {(x["file"], x["key"]) for g in store.GATE_SECTIONS for x in pier.gate_requirements(g)}
for f, k in sorted(need):
c.post(f"/pier/{P}/section/{f}/{k}/confirm", data={"by": "테스트", "why": "시험"}) # stage B: a bundle the app check does not report as 일치 needs a reason
check("all required sections confirmed",
all(x["confirmed"] for g in store.GATE_SECTIONS for x in store.Pier(P).gate_requirements(g)), len(need))
r = c.post(f"/pier/{P}/gate/G2/run", follow_redirects=True)
check("G2 blocked before G1 approval", "G1 미승인" in html(r))
# 6 gates with approvals
for g in gates:
job, trace = run_and_wait(c, P, g)
rep = store.Pier(P).gate_status(g)["report"] or {}
check(f"{g} run via app -> PASS", bool(job) and job["state"] == "완료" and rep.get("verdict") == "PASS"
and rep.get("job_id") == job["id"], f"{rep.get('summary')} {trace[-1][0]}s {(job or {}).get('error', '')[-200:]}")
r = c.post(f"/pier/{P}/gate/{g}/decide", data={"by": "", "decision": "승인"}, follow_redirects=True)
check(f"{g} approval needs a name", "판정자 이름" in html(r))
r = c.post(f"/pier/{P}/gate/{g}/decide", data={"by": "테스트", "decision": "승인"}, follow_redirects=True)
check(f"{g} approved", store.Pier(P).gate_status(g)["approved"])
last = gates[-1]
r = c.get(f"/pier/{P}/download/{last}")
check(f"download {last}.3dm", r.status_code == 200 and len(r.data) > 100000, f"{len(r.data)} bytes")
r.close()
# 7a review report draft with all gates PASS and approved (only meaningful when all five gates ran)
if len(gates) == 5:
report_checks(c, P)
# 7 an input change after approval makes gates stale and blocks the next run
pr = store.Pier(P).load("params")
pile = pr["piles"]
pile["length"] += 5 # > checker tolerance 1.0 mm
c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)})
st = store.Pier(P).gate_status("G1")
check("input change -> gate stale and approval void", st["stale"] and not st["approved"])
check("changed section needs re-confirmation", store.Pier(P).section_state("params", "piles")["stale"])
# 7b a job left "실행 중" by a stopped process must not block the gate forever
jp = TD / P / "runs" / "current" / "G1_job.json"
store.wjson(jp, {"id": "orphan", "pid": -1, "state": "실행 중", "at": store.now(), "log": ["x"]})
r = c.get(f"/pier/{P}/gates")
check("orphaned running job shown as 중단됨", "중단됨: 이전 실행" in html(r))
# 8 a wrong input gives a FAIL gate, which cannot be approved; rejection needs a reason
c.post(f"/pier/{P}/section/params/piles/confirm", data={"by": "테스트", "why": "시험"}) # stage B: a bundle the app check does not report as 일치 needs a reason
job, trace = run_and_wait(c, P, "G1")
rep = store.Pier(P).gate_status("G1")["report"]
check("pile length +5 mm -> G1 FAIL (pile length)", bool(job) and rep.get("job_id") == job["id"]
and rep["verdict"] == "FAIL" and "pile length" in rep["unplanned_fails"],
f"{rep['unplanned_fails']} trace {trace[:2]}..{trace[-2:]}")
r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "승인"}, follow_redirects=True)
check("FAIL gate cannot be approved", "승인할 수 없습니다" in html(r) and not store.Pier(P).gate_status("G1")["approved"])
r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "반려", "note": ""}, follow_redirects=True)
check("rejection needs a reason", "반려 사유" in html(r))
r = c.post(f"/pier/{P}/gate/G1/decide", data={"by": "테스트", "decision": "반려", "note": "말뚝 길이 입력 오류"},
follow_redirects=True)
check("rejection recorded", store.Pier(P).approvals()["G1"]["decision"] == "반려")
for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/section/params/footing"):
r = c.get(page)
check(f"page renders {page}", r.status_code == 200)
out = APP / "검증" / "P2_web_test.json"
out.write_text(json.dumps({"results": results, "passed": sum(x["result"] == "PASS" for x in results),
"total": len(results)}, ensure_ascii=False, indent=1), encoding="utf-8")
print("TOTAL", sum(x["result"] == "PASS" for x in results), "/", len(results))
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--gates", nargs="*", default=["G1", "G2", "G3", "G4", "G5"])
main(ap.parse_args().gates)
@@ -0,0 +1,138 @@
"""Test of the batch features (2026-10-12): bulk confirmation and G1..G5 run with auto-approval.
Own data root (검증/web_batch_test_data, recreated) with the Ungok P1 params/expected as a pier; real Rhino runs.
Checks: name required; bulk confirm confirms exactly the required unconfirmed sections; single run refused while
a batch runs; a wrong input stops the batch at G1 without approval; a correct input approves G1..G5 under the
given name with the auto note.
"""
import json
import os
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "web_batch_test_data"
if TD.exists():
shutil.rmtree(TD)
(TD / "배치P1").mkdir(parents=True)
for f in ("params.json", "expected.json"):
shutil.copy2(APP / "data" / "운곡교P1" / f, TD / "배치P1" / f)
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
P, BY = "배치P1", "시험자"
results = []
def check(name, ok, detail=""):
results.append({"check": name, "result": "PASS" if ok else "FAIL", "detail": str(detail)[:300]})
print("PASS" if ok else "FAIL", name, str(detail)[:200], flush=True)
def html(r):
return r.get_data(as_text=True)
def wait_batch(limit=1500):
t0 = time.time()
while time.time() - t0 < limit:
b = webapp.batch_state(store.Pier(P))
if b and b["state"] != "실행 중":
return b, round(time.time() - t0)
time.sleep(5)
return webapp.batch_state(store.Pier(P)), round(time.time() - t0)
def main():
c = webapp.app.test_client()
pier = store.Pier(P)
need0 = webapp.unconfirmed(pier)
r = c.post(f"/pier/{P}/section/{need0[0][0]}/{need0[0][1]}/confirm", data={"by": "", "why": "시험"}, follow_redirects=True)
check("confirm needs a name", "확정자 이름" in html(r) and webapp.unconfirmed(store.Pier(P)) == need0)
confirm_required(c, webapp, store, P, BY)
p = store.Pier(P)
conf = p.confirmations()
check("confirm: all required sections confirmed", not webapp.unconfirmed(p)
and len(need0) == 23 and all(conf[f"{f}:{k}"]["by"] == BY for f, k in need0), f"{len(need0)} sections")
check("confirm: sections not required stay unconfirmed", "params:_meta" not in conf)
r = c.get(f"/pier/{P}")
check("pier page shows all confirmed", "모두 확정되어 있습니다" in html(r))
r = c.post(f"/pier/{P}/gates/run_all", data={"by": ""}, follow_redirects=True)
check("batch run needs a name", "판정자 이름" in html(r))
# wrong input: pile length +5 mm -> G1 FAIL, batch stops, nothing approved
pr = p.load("params")
pile = dict(pr["piles"], length=pr["piles"]["length"] + 5)
c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)})
confirm_required(c, webapp, store, P, BY)
r = c.post(f"/pier/{P}/gates/run_all", data={"by": BY}, follow_redirects=True)
r2 = c.post(f"/pier/{P}/gate/G2/run", follow_redirects=True)
check("single run refused while the batch runs", "일괄 실행 중에는" in html(r2))
b, secs = wait_batch()
p = store.Pier(P)
check("wrong input: batch stops at G1 FAIL", b["state"] == "중단" and any("G1 FAIL" in m for m in b["log"]), b["log"])
check("wrong input: nothing approved", not p.approvals(), p.approvals())
# correct input: G1..G5 approved under the given name with the auto note
pile["length"] -= 5
c.post(f"/pier/{P}/section/params/piles", data={"value": json.dumps(pile)})
confirm_required(c, webapp, store, P, BY)
c.post(f"/pier/{P}/gates/run_all", data={"by": BY})
b, secs = wait_batch()
p = store.Pier(P)
ap = p.approvals()
check("batch completes", b["state"] == "완료", f"{b['log']} {secs}s")
check("G1..G5 approved by the given name with the auto note",
all(p.gate_status(g)["approved"] and ap[g]["by"] == BY and ap[g]["note"] == webapp.AUTO_NOTE
for g in ("G1", "G2", "G3", "G4", "G5")), ap)
reps = {g: p.gate_status(g)["report"] for g in ("G1", "G2", "G3", "G4", "G5")}
check("batch: one session, gate checks only (mode batch, reload/unchanged skipped)",
all(r["mode"] == "batch" and r["reload"]["ok"] is None and r["unchanged"]["ok"] is None for r in reps.values())
and len({r["job_id"] for r in reps.values()}) == 1, {g: r["mode"] for g, r in reps.items()})
run = TD / P / "runs" / "current"
check("batch: only G5 model saved", (run / "G5.3dm").exists() and not any((run / f"G{i}.3dm").exists() for i in range(1, 5)))
check("batch: G1 download refused, G5 download served",
c.get(f"/pier/{P}/download/G1").status_code == 404 and c.get(f"/pier/{P}/download/G5").status_code == 200)
check("batch: session time", True, f"{reps['G5']['session_secs']}s for G1..G5 (individual runs ~330s)")
# the single-session model equals the model of individual gate runs (same FAST fingerprint mode)
sys.path.insert(0, str(APP)); sys.path.insert(0, str(APP / "tools"))
from runner import run_job
from compare_fingerprints import compare
fp = run / "G5_session_fp.json"
run_job([{"name": "fp", "script": APP / "engine" / "rhino" / "fingerprint.py",
"globals": {"MODEL_PATH": str(run / "G5.3dm"), "OUT_PATH": str(fp), "FAST": True}}])
ref = APP / "출력" / "P1회귀_엔진수정" / "G5_fp.json"
cmp = compare(json.loads(ref.read_text(encoding="utf-8")), json.loads(fp.read_text(encoding="utf-8")))
check("batch G5 model == individual-run G5 model (3190 objects, FAST fingerprints)", cmp["identical"],
{k: cmp[k] for k in ("count", "objects_with_diffs", "diff_fields")})
check("planned FAIL gates still listed", len(p.gate_status("G3")["report"]["planned_fails"]) == 1
and len(p.gate_status("G4")["report"]["planned_fails"]) == 5)
r = c.post(f"/pier/{P}/gates/run_all", data={"by": BY}, follow_redirects=True)
check("nothing left to run", "이미 승인되어 있습니다" in html(r))
r = c.get(f"/pier/{P}/gates")
check("gates page renders batch log", r.status_code == 200 and "일괄 승인 (필요하면 실행)" in html(r) and "G5 PASS" in html(r))
# an existing current PASS result is approved without running the gate again
jid5 = p.gate_status("G5")["job"]["id"]
c.post(f"/pier/{P}/gate/G5/decide", data={"by": BY, "decision": "반려", "note": "시험: 승인 취소"})
r = c.get(f"/pier/{P}")
check("pier page offers 'G5 일괄 승인' for one unapproved gate", ">G5 일괄 승인</button>" in html(r))
c.post(f"/pier/{P}/gates/run_all", data={"by": BY})
b2, secs2 = wait_batch(120)
p = store.Pier(P)
check("existing PASS approved without re-run", b2["state"] == "완료" and p.gate_status("G5")["approved"]
and p.gate_status("G5")["job"]["id"] == jid5 and any("재실행 없음" in m for m in b2["log"]),
f"{b2['log']} {secs2}s")
out = APP / "검증" / "batch_web_test.json"
out.write_text(json.dumps({"results": results, "passed": sum(x["result"] == "PASS" for x in results),
"total": len(results)}, ensure_ascii=False, indent=1), encoding="utf-8")
print("TOTAL", sum(x["result"] == "PASS" for x in results), "/", len(results))
if __name__ == "__main__":
main()
@@ -0,0 +1,71 @@
"""Web flow test for a pier with a reduced gate scope (Ulsan P1 copy limited to G1, G5), 2026-10-13. Uses a copy
of data/산외교울산P1 in 검증/web_ulsan_test_data (recreated) whose params scope is set to G1, G5 (the real pier
has G1~G5 since the rebar stage); real Rhino runs (one batch session). Full scope: test_web_ulsan_full.py."""
import json
import os
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "web_ulsan_test_data"
if TD.exists():
shutil.rmtree(TD)
# source: the protected template (the pier data/산외교울산P1 itself was deleted in the app on 2026-10-13)
shutil.copytree(APP / "data" / "_템플릿" / "산외교울산P1형", TD / "울산시험", ignore=shutil.ignore_patterns("template.json"))
_pp = TD / "울산시험" / "params.json"
_pj = json.loads(_pp.read_text(encoding="utf-8"))
_pj["scope"] = ["G1", "G5"]
_pp.write_text(json.dumps(_pj, ensure_ascii=False, indent=1), encoding="utf-8")
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
P, BY = "울산시험", "시험자"
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:200], flush=True)
c = webapp.app.test_client()
p = store.Pier(P)
check("scope = G1, G5", p.scope() == ["G1", "G5"], p.scope())
need = webapp.unconfirmed(p)
check("required sections exclude params.piles and rebar sections",
("params", "piles") not in need and not any(k in ("column_rebar", "footing_rebar", "coping_rebar") for _, k in need),
need)
h = c.get(f"/pier/{P}").get_data(as_text=True)
check("pier page lists out-of-scope gates", "범위 밖: G2, G3, G4" in h and ">G1~G5 일괄 승인<" in h)
confirm_required(c, webapp, store, P, BY)
check("bulk confirm", not webapp.unconfirmed(store.Pier(P)), len(need))
r = c.post(f"/pier/{P}/gate/G3/run", follow_redirects=True)
check("out-of-scope gate cannot run", "범위 밖" in r.get_data(as_text=True))
c.post(f"/pier/{P}/gates/run_all", data={"by": BY})
t0 = time.time()
while time.time() - t0 < 900:
b = webapp.batch_state(store.Pier(P))
if b and b["state"] != "실행 중":
break
time.sleep(5)
p = store.Pier(P)
check("batch completes", b["state"] == "완료", b["log"])
check("G1 and G5 approved", p.gate_status("G1")["approved"] and p.gate_status("G5")["approved"])
check("G1 PASS 104/104, G5 PASS 75/75",
p.gate_status("G1")["report"]["summary"]["passed"] == 104 and p.gate_status("G5")["report"]["summary"]["passed"] == 75,
{g: p.gate_status(g)["report"]["summary"] for g in ("G1", "G5")})
r = c.post(f"/pier/{P}/report", data={"by": BY}, follow_redirects=True)
md = next(p.run_dir.glob("보고서_*.md")).read_text(encoding="utf-8")
check("report: 2/2 gates, out-of-scope rows", "게이트 진행: 2/2 실행 (범위 밖: G2, G3, G4)" in md and md.count("| 범위 밖 |") == 3
and "전 게이트 PASS·승인" in md)
check("G5 model download", c.get(f"/pier/{P}/download/G5").status_code == 200)
for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/search?q=EL"):
check(f"page renders {page}", c.get(page).status_code == 200)
sys.exit(0 if ok else 1)
@@ -0,0 +1,58 @@
"""Web flow test for Ulsan P1 with the full scope G1~G5 (rebar stage, 2026-10-13). Uses a copy of
the protected template data/_템플릿/산외교울산P1형 in 검증/web_ulsan_full_test_data (recreated); real Rhino runs (one batch session)."""
import os
import shutil
import sys
import time
from pathlib import Path
APP = Path(__file__).resolve().parents[1]
TD = APP / "검증" / "web_ulsan_full_test_data"
if TD.exists():
shutil.rmtree(TD)
# source: the protected template (the pier data/산외교울산P1 itself was deleted in the app on 2026-10-13)
shutil.copytree(APP / "data" / "_템플릿" / "산외교울산P1형", TD / "울산전체", ignore=shutil.ignore_patterns("template.json"))
os.environ["PIERAPP_DATA"] = str(TD)
sys.path.insert(0, str(APP / "web"))
import app as webapp # noqa: E402
sys.path.insert(0, str(Path(__file__).resolve().parent))
from testlib import confirm_required # noqa: E402
import store # noqa: E402
P, BY = "울산전체", "시험자"
EXP = {"G1": 104, "G2": 35, "G3": 63, "G4": 129, "G5": 75}
ok = True
def check(name, cond, detail=""):
global ok
ok &= bool(cond)
print("PASS" if cond else "FAIL", name, str(detail)[:300], flush=True)
c = webapp.app.test_client()
p = store.Pier(P)
check("scope = G1~G5", p.scope() == ["G1", "G2", "G3", "G4", "G5"], p.scope())
h = c.get(f"/pier/{P}").get_data(as_text=True)
check("pier page has no out-of-scope note", "범위 밖" not in h)
confirm_required(c, webapp, store, P, BY)
check("bulk confirm", not webapp.unconfirmed(store.Pier(P)))
c.post(f"/pier/{P}/gates/run_all", data={"by": BY})
t0 = time.time()
while time.time() - t0 < 3000:
b = webapp.batch_state(store.Pier(P))
if b and b["state"] != "실행 중":
break
time.sleep(10)
p = store.Pier(P)
check("batch completes", b["state"] == "완료", b["log"])
for g, n in EXP.items():
st = p.gate_status(g)
check(f"{g} approved, PASS {n}/{n}", st["approved"] and st["report"]["summary"]["passed"] == n, st["report"]["summary"])
c.post(f"/pier/{P}/report", data={"by": BY}, follow_redirects=True)
md = next(p.run_dir.glob("보고서_*.md")).read_text(encoding="utf-8")
check("report: 5/5 gates, all PASS", "게이트 진행: 5/5 실행" in md and "전 게이트 PASS·승인" in md and "범위 밖" not in md)
check("G5 model download", c.get(f"/pier/{P}/download/G5").status_code == 200)
for page in ("/", f"/pier/{P}", f"/pier/{P}/gates", f"/pier/{P}/candidates", f"/pier/{P}/search?q=EL"):
check(f"page renders {page}", c.get(page).status_code == 200)
sys.exit(0 if ok else 1)
+41
View File
@@ -0,0 +1,41 @@
"""Helpers shared by the web tests (2026-10-19)."""
from pathlib import Path
_APP = Path(__file__).resolve().parents[1]
def pier_dir(name):
"""data/<name>, or the newest recycle-bin copy of a pier that was deleted in real use (운곡교p2 on 2026-10-19). Read only; exact case
(Windows globbing is case-insensitive: 운곡교p2 must not pick 운곡교P2_*)."""
d = _APP / "data" / name
if (d / "params.json").exists():
return d
trash = sorted(t for t in (_APP / "data" / "_휴지통").glob(f"{name}_*") if t.name.startswith(name + "_") and (t / "params.json").exists())
return trash[-1] if trash else d
def confirm_required(client, webapp, store, pier, by, why="시험"):
"""Confirm every required, unconfirmed bundle of a pier one by one through the section confirm route.
The old one-click bulk route is gone; a bundle the app check does not report as 일치 needs a reason, so one is always sent."""
for f, k in webapp.unconfirmed(store.Pier(pier)):
client.post(f"/pier/{pier}/section/{f}/{k}/confirm", data={"by": by, "why": why})
def _approved(d):
"""the pier folder has a recorded G5 result AND an approval of G5 (the whole gate chain was approved)"""
import json
try:
ap = json.loads((d / "runs" / "current" / "approvals.json").read_text(encoding="utf-8"))
except (OSError, ValueError):
return False
return (d / "runs" / "current" / "G5_gate.json").exists() and ap.get("G5", {}).get("decision") == "승인"
def verified_pier(name):
"""The copy of a pier whose gates G1..G5 were all approved (data/<name>, else the newest recycle-bin copy): a pier may be deleted and made again from
the template in real use (운곡교P4 on 2026-10-20) and is half-updated while it is being modelled. None when there is none."""
d = _APP / "data" / name
if _approved(d):
return d
trash = sorted(t for t in (_APP / "data" / "_휴지통").glob(f"{name}_*") if t.name.startswith(name + "_") and _approved(t))
return trash[-1] if trash else None
@@ -0,0 +1,200 @@
"""Read the main dimensions of a Ungok-type pier straight from its drawings and compare them with the pier's params.json (2026-10-19, read-only).
Same frame as the Ungok P1 drawing (D1 front view / plan at the same sheet coordinates). Every reader was first checked against P1 (see prompt log
Task_도면도형직접판독시험): coping outline + rebar lines (CS-CONC-MAJR / CS-RBAR-MAJR lines), plan pad polylines, hoop dots, schedule tables, footing rule.
usage: python tools/verify_pier_geometry.py <pier> (exit 0 always; prints a table 'item | drawing | params | OK/DIFF')"""
import glob
import json
import math
import re
import subprocess
import sys
from pathlib import Path
import ezdxf
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from engine import reader # noqa: E402
import infer_footing_rebar as inf # noqa: E402
name = sys.argv[1]
D = APP / "data" / name
P = json.loads((D / "params.json").read_text(encoding="utf-8"))
texts = json.loads((D / "drawings" / "texts.json").read_text(encoding="utf-8"))
dxf1 = sorted(glob.glob(str(D / "drawings" / "dxf" / "*(1)*.dxf")))[0]
doc = ezdxf.readfile(dxf1)
MS = list(doc.modelspace())
rows = []
def cmp(item, drawing, par, tol=1.0):
try:
ok = abs(float(drawing) - float(par)) <= tol
except (TypeError, ValueError):
ok = drawing == par
rows.append((item, drawing, par, "OK" if ok else "DIFF"))
def num(s):
return float(re.search(r"\d+\.\d+|\d+", s).group())
T = lambda t: [x for x in texts if x["dwg"] == "D1" and x["text"] == t] # noqa: E731
# ---- coping top outline + EL datum (extract_coping_outline logic)
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})
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:
sys.exit(f"coping outline chain broken at {cur}")
used.add(nxt[0])
if abs(nxt[1][1] - cur[1]) < .05:
steps.append([cur[0], nxt[1][0], cur[1]])
cur = nxt[1]
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]
el_hi = num(els[0]["text"]) * 1000
OFF = el_hi - y_hi # EL = y + OFF in the front view
ts = P["coping"]["top_steps"]
for i, (a, b, y) in enumerate(steps):
for k, v in (("x0", a - cx), ("x1", b - cx), ("z", y + OFF)):
cmp(f"coping step {i + 1} {k}", round(v), ts[i][k], tol=1.5)
flat = 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})
z_flat = round(flat[0] + OFF)
cmp("coping z_bottom_flat", z_flat, P["coping"]["z_bottom_flat"])
cmp("coping length", round(bot(right)[0] - bot(left)[0]), P["coping"]["length_bottom_tip_to_tip"])
# ---- stirrups (soffit arc centre) and coping rebar lines
cz_arc = None
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))
if V:
xs = sorted(V)
z0, z1 = xs[0][1], xs[0][1]
# centre z from the stirrup at x = 2900 (R12100 arc about x = 6951.9)
pick = [v for v in V if v[0] == 2900]
if pick:
cz_arc = round(pick[0][1] - math.sqrt(12100 ** 2 - (6951.9 - 2900) ** 2), 1)
cmp("coping_rebar soffit_arc_centre z", cz_arc, P["coping_rebar"]["soffit_arc_centre"][1], tol=1.5)
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 17000 < 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), reverse=True)
if len(tiers) >= 3:
for t, z in zip(("1", "2", "3"), tiers[:3]):
cmp(f"coping_rebar top tier {t} z", z, P["coping_rebar"]["top"]["tiers"][t]["z"], tol=1.5)
c15 = [(z, a, b) for z, (a, b) in H.items() if a > 3000]
if c15:
cmp("coping_rebar C15 z", c15[0][0], P["coping_rebar"]["c15"]["z"], tol=2)
cmp("coping_rebar C15 x0", c15[0][1], P["coping_rebar"]["c15"]["x0"], tol=2)
# ---- footing / piles / column from the D1 dimension texts at the template's text positions
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]
if el_f:
cmp("footing z_bottom (EL text)", round(el_f[0]), P["footing"]["z_bottom"])
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"])]
if fx:
cmp("footing size_x", round(fx[0]), P["footing"]["size_x"])
if fy:
cmp("footing size_y", round(fy[0]), P["footing"]["size_y"])
pl = [t["text"] for t in texts if t["dwg"] == "D1" and t["text"].startswith("L=") and t["x"] < 10000 and t["y"] < 7000]
if pl:
cmp("pile length", round(num(pl[0]) * 1000), P["piles"]["length"])
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 chx:
cmp("pile pitch x", round(num(chx[0].split("@")[1]) * 1000), P["piles"].get("pitch_x", P["piles"].get("pitch")))
cmp("pile count x", int(chx[0].split("@")[0]) + 1, P["piles"]["count_x"], tol=0)
if chy:
cmp("pile pitch y", round(num(chy[0].split("@")[1]) * 1000), P["piles"].get("pitch_y", P["piles"].get("pitch")))
cmp("pile count y", int(chy[0].split("@")[0]) + 1, P["piles"]["count_y"], tol=0)
ch = [t for t in texts if t["dwg"] == "D1" and re.fullmatch(r"\d+\.\d{3}", t["text"]) and 22000 < t["x"] < 24000 and 12500 < t["y"] < 15500]
col_h = None
if z_flat and el_f:
col_h = z_flat - (el_f[0] * 1000 if el_f[0] < 1000 else el_f[0]) - P["footing"]["thickness"]
cmp("column height (soffit flat - footing top)", round(col_h), P["column"]["height"])
# ---- bearings (plan pad polylines, datum = coping plan centre)
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 - 12162.4, sum(v[1] for v in q) / 4 - 26412.3, math.degrees(math.atan2(q[1][1] - q[0][1], q[1][0] - q[0][0])) % 180))
_seen, _u = set(), []
for a, b, c in sorted(pads):
k = (round(a / 3), round(b / 3)) # nested outlines of one pad share a centre
if k not in _seen:
_seen.add(k)
_u.append((round(a, 1), round(b, 1), round(90 - c, 2)))
pads = _u
fr = [p for p in pads if p[1] < 0]
rr = [p for p in pads if p[1] > 0]
if fr and rr:
g5f, g5r = max(fr), max(rr)
c = P["bearing_seats"]["pad_rows"]
cmp("pad front g5 x", g5f[0], c["front"]["centre_rel_girder5"][0], tol=1.5)
cmp("pad front g5 y", g5f[1], c["front"]["centre_rel_girder5"][1], tol=1.5)
cmp("pad rear g5 x", g5r[0], c["rear"]["centre_rel_girder5"][0], tol=1.5)
cmp("pad rear g5 y", g5r[1], c["rear"]["centre_rel_girder5"][1], tol=1.5)
cmp("pad front rotation", g5f[2], c["front"]["rotation_deg"], tol=0.05)
cmp("pad rear rotation", g5r[2], c["rear"]["rotation_deg"], tol=0.05)
if len(fr) >= 2:
cmp("bearing pitch_x", round((g5f[0] - sorted(fr)[-2][0]), 1), P["bearing_seats"]["pitch_x"], tol=1.5)
# ---- schedule counts vs footing rule and params
rows_s = {}
for c in reader.table_candidates(texts):
if c["key"].startswith("schedule"):
for r in c["rows"]:
tx = [x["text"] for x in sorted(r["cells"], key=lambda z: z["x"])]
if tx and re.fullmatch(r"[BPC]\d+|S\d+", tx[0].strip()):
rows_s[tx[0].strip()] = tx
fs = P["footing"]
rule = inf.infer(fs["size_x"], fs["size_y"])
for lay, marks in (("bottom_Y1", ("B2", "B4")), ("bottom_X2", ("B6", "B8")), ("bottom_Y3", ("B3",)), ("bottom_X4", ("B7",)), ("top_Y", ("B1",)), ("top_X", ("B5",))):
try:
cmp(f"footing rebar {lay} count (schedule {'+'.join(marks)} vs params layer)", sum(int(rows_s[m][3]) for m in marks), len(P["footing_rebar"]["layers"][lay]["pos"]), tol=0)
cmp(f"footing rebar {lay} count (schedule vs footing rule)", sum(int(rows_s[m][3]) for m in marks), len(rule[lay]), tol=0)
except KeyError:
pass
try:
cmp("column rebar P3 hoop count (schedule vs params levels/marks)", int(rows_s["P3"][3]), P["column_rebar"]["hoops"]["marks"].count("P3"), tol=0)
cmp("column rebar P4 hoop count", int(rows_s["P4"][3]), P["column_rebar"]["hoops"]["marks"].count("P4"), tol=0)
cmp("column rebar P5 hoop count", int(rows_s["P5"][3]), P["column_rebar"]["hoops"]["marks"].count("P5"), tol=0)
cmp("column rebar P2 length (schedule vs params)", num(rows_s["P2"][4]) * 1000, P["column_rebar"]["main_bars"]["p2"]["length"], tol=1.5)
except KeyError:
pass
w = max(len(r[0]) for r in rows)
bad = [r for r in rows if r[3] == "DIFF"]
for r in rows:
print(f"{r[0]:{w}s} | drawing {str(r[1]):>16} | params {str(r[2]):>16} | {r[3]}")
print(f"== {name}: {len(rows)} items compared, {len(bad)} differ")