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

+226
View File
@@ -0,0 +1,226 @@
"""Out-of-table dimension finding (stage C of the 입력 묶음 확인·확정 개편, first part, 2026-10-19).
Anchors: for a modelled pier (params / expected known) the app records where each numeric field was read from the drawing — every dimension / EL
text (layer, sheet, position) whose value equals the field value. For a NEW drawing of the same series the anchors are followed: the shift between
the two drawings is estimated per view from the texts both drawings share (R5), the candidate is the dimension text of the same layer and kind
nearest to the predicted position (R1 EL texts, scalar dimension texts). Several instances of one dimension (front / side / plan) vote.
Trust grades (기획서 4.2): A >= 2 instances followed and all agree, small shift spread -> candidate with evidence, may be applied
B agreement of >= 2 instances, or a single instance -> candidate with a warning
C instances disagree -> list only, applying needs a reason
none: nothing found -> "근거 없음", no candidate is invented.
R2: dimension chains `n@pitch=total` are parsed and checked (n x pitch = total).
Validation on the real series (tools/test_anchors.py): grade A had no wrong candidate; B and C are measured and shown as such.
"""
import re
import statistics
from collections import defaultdict
from engine import variability
VERSION = "2"
MIN_MM = 1000 # values below one metre are too common as texts to be anchored
MAX_HITS = 6 # a value found in more places is not a specific dimension
RADIUS = 1200.0 # search radius around the predicted position (drawing units, mm)
K_LANDMARKS = 5
MIN_COVERAGE = 0.70 # share of the anchors' landmark texts found again in the new drawing
A_SPREAD = 400.0
TABLE_RULE = "single" # "single": a candidate whose only followed instance is ONE table cell is demoted to C (2026-10-20); "none": table cells never grade; "off": old behaviour.
# Measured on the verified Ungok P3 / P4 values: grade B wrong 10/65 (off) -> 2/39 (single) vs 3/29 (none); grade A unchanged (0 wrong)
MARK = re.compile(r"^[A-Z]{1,2}\d{1,2}$")
NUM = re.compile(r"^-?\d[\d,]*\.?\d*$")
CHAIN = re.compile(r"^(\d+)\s*@\s*(\d[\d,]*(?:\.\d+)?)\s*=\s*(\d[\d,]*(?:\.\d+)?)\s*(?:\(.*\))?$")
SKIP = {("expected", "planned_fail"), ("expected", "rebar_palette"), ("expected", "tol_mm")}
def text_val(t):
"""(value mm, kind) of a dimension / EL text: 'EL. 38.916' -> (38916, EL), '7.800' -> (7800, m), '7800' -> (7800, mm); else None."""
s = t.strip().replace(" ", "")
m = re.match(r"^EL\.?(\d+\.\d{3})$", s)
if m:
return round(float(m.group(1)) * 1000, 3), "EL"
if NUM.match(s):
x = s.replace(",", "")
try:
f = float(x)
except ValueError:
return None
if re.match(r"^\d+\.\d{3}$", x):
return round(f * 1000, 3), "m"
if re.match(r"^\d+$", x):
return f, "mm"
return None
def leaves(params, expected, max_list=40):
"""(field, value) of every anchorable numeric leaf: bundles of grade V1 / V2 only, no sources / notes, no human decision records."""
for which, doc in (("params", params), ("expected", expected)):
for k, v in doc.items():
m = variability.classify(which, k, v)
if m["info"] or m["grade"] in ("F", "V3") or (which, k) in SKIP:
continue
stack = [((k,), v)]
while stack:
path, x = stack.pop()
if isinstance(x, dict):
for a, b in x.items():
if a in ("src", "_note") or a.endswith("_src"):
continue
stack.append((path + (str(a),), b))
elif isinstance(x, list):
if len(x) <= max_list:
stack.extend((path + (str(i),), b) for i, b in enumerate(x))
elif isinstance(x, (int, float)) and not isinstance(x, bool):
yield f"{which}:" + ".".join(path), x
def _unique_labels(texts):
out = defaultdict(list)
for t in texts:
s = t["text"].strip()
if len(s) > 1 and not NUM.match(s.replace(" ", "")) and not CHAIN.match(s):
out[(t["dwg"], s)].append(t)
return {k: v[0] for k, v in out.items() if len(v) == 1}
def learn(params, expected, texts, sources=()):
"""Anchors of a modelled pier. `sources` = sha256 of its DWG files (to recognise a pier that follows its own drawing)."""
by = defaultdict(list)
for t in texts:
v = text_val(t["text"])
if v:
by[round(v[0])].append({"text": t["text"].strip(), "x": t["x"], "y": t["y"], "layer": t["layer"], "dwg": t["dwg"], "kind": v[1]})
fields = {}
owners = defaultdict(set) # same value, same file, different bundle / leaf name -> which field the text belongs to is unknown
cand = []
for field, v in leaves(params, expected):
key = round(abs(v))
if key >= MIN_MM and 1 <= len(by.get(key, [])) <= MAX_HITS:
which, path = field.split(":", 1)
parts = [q for q in path.split(".") if not q.isdigit() and not MARK.match(q)] # mark rows (P3, C1 ...) are instances of one dimension kind
owners[(which, key)].add((path.split(".")[0], re.sub(r"_(x|y|z|left|right|front|rear|top|bottom)$", "", parts[-1])))
cand.append((field, v, key))
for field, v, key in cand:
which = field.split(":", 1)[0]
fields[field] = {"v": v, "hits": by[key], "shared": len(owners[(which, key)])}
lm = [{"text": s, "x": t["x"], "y": t["y"], "dwg": d} for (d, s), t in _unique_labels(texts).items()]
return {"version": VERSION, "sources": list(sources), "fields": fields, "landmarks": lm}
def _shift(anchor, common, dwg, x, y):
near = sorted((c for c in common if c[4] == dwg), key=lambda c: (c[0] - x) ** 2 + (c[1] - y) ** 2)[:K_LANDMARKS]
if not near:
return None
dx, dy = statistics.median(c[2] for c in near), statistics.median(c[3] for c in near)
return dx, dy, max(abs(c[2] - dx) for c in near) + max(abs(c[3] - dy) for c in near)
def follow(anchors, texts, own_sources=()):
"""Candidates for a new drawing: {field: {value, grade, followed, agree, spread, text, x, y, dwg, layer, all, self_source}}."""
new = _unique_labels(texts)
common = []
for lm in anchors.get("landmarks", []):
t = new.get((lm["dwg"], lm["text"]))
if t:
common.append((lm["x"], lm["y"], t["x"] - lm["x"], t["y"] - lm["y"], lm["dwg"]))
by_layer = defaultdict(list)
for t in texts:
v = text_val(t["text"])
if v:
by_layer[(t["dwg"], t["layer"], v[1])].append((v[0], t))
self_source = bool(set(anchors.get("sources", [])) & set(own_sources))
coverage = len(common) / len(anchors["landmarks"]) if anchors.get("landmarks") else 0.0
out = {}
for field, a in anchors.get("fields", {}).items():
res = []
for h in a["hits"]:
sh = _shift(a, common, h["dwg"], h["x"], h["y"])
if not sh:
continue
px, py = h["x"] + sh[0], h["y"] + sh[1]
best = None
for val, t in by_layer.get((h["dwg"], h["layer"], h["kind"]), []):
d = ((t["x"] - px) ** 2 + (t["y"] - py) ** 2) ** 0.5
if d <= RADIUS and (best is None or d < best[0]):
best = (d, val, t)
if best:
res.append({"value": round(best[1]), "dist": round(best[0], 1), "spread": round(sh[2], 1), "text": best[2]["text"].strip(),
"x": best[2]["x"], "y": best[2]["y"], "dwg": best[2]["dwg"], "layer": best[2]["layer"],
"table_cell": "TABL" in best[2]["layer"]})
if not res:
continue
# improvement #34-4 (2026-10-20): a text that sits in a schedule / table is found again by POSITION, but the rows of a table move when the pier's
# bar list differs (Ungok P3 / P4: the length cell '3.800' of S1 was followed as the end of a rebar chain, grade B). Table cells are the job of the
# table candidates (engine/reader, check C2). A candidate whose only followed instance is one table cell is therefore demoted to C.
free = [r for r in res if not r["table_cell"]]
if TABLE_RULE == "none":
table_only = not free
if free:
res = free
elif TABLE_RULE == "single":
table_only = not free and len(res) == 1
else:
table_only = False
vals = [r["value"] for r in res]
cons = max(set(vals), key=lambda v: (vals.count(v), -abs(v)))
agree = vals.count(cons)
spread = max(r["spread"] for r in res)
if table_only:
grade = "C"
elif len(res) >= 2 and agree == len(res) and spread < A_SPREAD and a.get("shared", 1) == 1:
grade = "A"
elif agree >= 2 or len(res) == 1:
grade = "B"
else:
grade = "C"
if coverage < MIN_COVERAGE and grade != "C":
grade = "C" # the drawing frame differs from the one the anchors come from
pos = min((r for r in res if r["value"] == cons), key=lambda r: r["dist"])
out[field] = {"value": cons if a["v"] >= 0 else -cons, "grade": grade, "followed": len(res), "agree": agree, "spread": spread,
"text": pos["text"], "x": pos["x"], "y": pos["y"], "dwg": pos["dwg"], "layer": pos["layer"],
"all": sorted(set(vals)), "source_value": a["v"], "self_source": self_source, "coverage": round(coverage, 2),
"shared": a.get("shared", 1), "table_only": table_only}
return out
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):
"""R2: dimension chains `n@pitch=total` with the check n x pitch = total: [{text, dwg, x, y, n, pitch, total, ok}]."""
def mm(s):
s = s.replace(",", "")
return round(float(s) * 1000) if re.fullmatch(r"\d+\.\d{3}", s) else round(float(s))
out = []
for t in texts:
m = CHAIN.match(t["text"].strip())
if m:
n, p, tot = int(m.group(1)), mm(m.group(2)), mm(m.group(3))
out.append({"text": t["text"].strip(), "dwg": t["dwg"], "x": t["x"], "y": t["y"], "n": n, "pitch": p, "total": tot, "ok": abs(n * p - tot) <= 1})
return out
@@ -0,0 +1,153 @@
"""params <-> expected consistency (C9, 2026-10-20): values that describe the SAME physical quantity in params.json (what the model is built from) and
expected.json (what the checkers compare against) must agree. The Rhino gates find every mismatch too, but only after ~150 s; this check finds the
stale ones (a value that changed in one file but not in the other) before any gate is run. Origin: Ungok P3 / P4 -- the first gate runs failed almost
entirely on values that were updated in params but not in expected (coping steps, pile pitch, hoop chain, B9 grid, tier EL ...) or the other way round.
Not an independent check: when both files were written from the same drawing reading they agree trivially. It only says "the two files tell one story".
Rules apply to the Ungok layout (coping steps, footing rebar rows ...); a rule whose paths are missing in either file is skipped, so other pier types
are not affected. Also contains the point-list rule of improvement #6: `coping_rebar.c14.hooks_up` must name existing `c14.points` (a point list keyed by
coordinates breaks silently when the points move).
"""
import re
TOL = 1.5 # mm (drawing values are rounded to 1 mm, CAD vertices to 0.1)
def _get(doc, path):
x = doc
for p in path.split("."):
if isinstance(x, list):
if not re.fullmatch(r"-?\d+", p) or int(p) >= len(x):
return None
x = x[int(p)]
elif isinstance(x, dict):
if p not in x:
return None
x = x[p]
else:
return None
return x
def _num(v):
"""number of a value that may be a string like '11.856' (schedule cells are metres) -> None when not numeric."""
if isinstance(v, bool) or v is None:
return None
if isinstance(v, (int, float)):
return float(v)
try:
return float(str(v).replace(",", ""))
except ValueError:
return None
def _rule(rid, text, p, e, tol=TOL, f=None, g=None):
"""one pairwise rule: params path p, expected path e; f / g transform the values before comparing."""
return dict(id=rid, text=text, p=p, e=e, tol=tol, f=f, g=g)
def _steps(n=5):
for i in range(n):
yield i
def rules(params, expected):
R = []
A = R.append
# footing, lean, column, piles
for a, b, t in (("size_x", "dx", "기초 X"), ("size_y", "dy", "기초 Y"), ("z_bottom", "z0", "기초 하면 EL"), ("thickness", "thickness", "기초 두께")):
A(_rule("footing." + a, t, "footing." + a, "G1.footing." + b))
A(_rule("column.height", "기둥 높이", "column.height", "G1.column.height"))
A(_rule("column.d", "기둥 지름", "column.diameter", "G1.column.d"))
for a, b, t in (("count_x", "nx", "말뚝 X 개수"), ("count_y", "ny", "말뚝 Y 개수"), ("length", "length", "말뚝 길이"), ("diameter", "d", "말뚝 지름"), ("edge", "edge", "말뚝 연단")):
A(_rule("piles." + a, t, "piles." + a, "G1.piles." + b, tol=0 if a.startswith("count") else TOL))
pp = (params.get("piles") or {})
for ax in ("x", "y"):
pit = f"piles.pitch_{ax}" if f"pitch_{ax}" in pp else "piles.pitch"
A(_rule(f"piles.pitch_{ax}", f"말뚝 {ax.upper()} 피치", pit, f"G1.piles.pitch_{ax}"))
# coping outline: steps, soffit flat
for i in _steps():
A(_rule(f"coping.step{i + 1}.z", f"코핑 윗면 {i + 1}단 높이", f"coping.top_steps.{i}.z", f"G1.seats.step_top.{i + 1}"))
A(_rule(f"coping.step{i + 1}.z/G4", f"코핑 윗면 {i + 1}단 높이(G4)", f"coping.top_steps.{i}.z", f"G4.step_top.{i + 1}"))
A(_rule(f"coping.step{i + 1}.x0", f"코핑 {i + 1}단 시작 x", f"coping.top_steps.{i}.x0", f"G1.seats.step_x.{i + 1}.0"))
A(_rule(f"coping.step{i + 1}.x1", f"코핑 {i + 1}단 끝 x", f"coping.top_steps.{i}.x1", f"G1.seats.step_x.{i + 1}.1"))
for nm, i in (("left", 0), ("center", 2), ("right", 4)):
A(_rule(f"coping.z_top_{nm}", f"코핑 윗면 {nm} 높이", f"coping.top_steps.{i}.z", f"G1.coping.z_top_{nm}"))
A(_rule("coping.dx", "코핑 길이", "coping.length_bottom_tip_to_tip", "G1.coping.dx"))
A(_rule("coping.dy", "코핑 폭", "coping.width_y", "G1.coping.dy"))
A(_rule("coping.flat", "코핑 하면 평평부 높이", "coping.z_bottom_flat", "G1.dxf_arc_centres.datum_el"))
A(_rule("coping.outline.flat", "코핑 외곽 하면 높이", "coping.z_bottom_flat", "G1.coping_outline.0.1"))
# bearings
A(_rule("bearing.pitch", "받침 피치", "bearing_seats.pitch_x", "G1.seats.pitch_chain", tol=1.0, g=lambda v: v[1] / v[0] if isinstance(v, list) and len(v) == 2 and v[0] else None))
A(_rule("bearing.row_y.front", "받침 앞열 y", "bearing_seats.pad_rows.front.centre_rel_girder5.1", "G1.seats.row_y.front", tol=1.5))
A(_rule("bearing.row_y.rear", "받침 뒤열 y", "bearing_seats.pad_rows.rear.centre_rel_girder5.1", "G1.seats.row_y.rear", tol=1.5))
# coping rebar
for t, ms in (("1", ("C1", "C4")), ("2", ("C2", "C5")), ("3", ("C3", "C6"))):
for m in ms:
A(_rule(f"coping_rebar.tier{t}.{m}", f"코핑 상단 철근 {m} 높이", f"coping_rebar.top.tiers.{t}.z", f"G4.tier_z.{m}"))
A(_rule("coping_rebar.c15", "C15 높이", "coping_rebar.c15.z", "G4.c15_top"))
A(_rule("coping_rebar.c16", "C16 높이", "coping_rebar.c16.z", "G4.c16_top"))
A(_rule("coping_rebar.c10_bottom", "C10 스터럽 하단 높이", "coping_rebar.bottom.z_flat", "G4.c10_bottom"))
lv = (params.get("coping_rebar") or {}).get("loops", {}).get("levels")
if lv:
A(_rule("coping_rebar.loop.first", "루프 C12 시작 높이", "coping_rebar.loops.levels.0", "G4.loop_z.C12.start"))
for k in range(3):
A(_rule(f"coping_rebar.loop.c13.{k}", f"루프 C13 {k + 1}번째 높이", f"coping_rebar.loops.levels.{6 + k}", f"G4.loop_z.C13.{k}"))
# column rebar chains
A(_rule("column_rebar.hoop.radius", "후프 반경", "column_rebar.hoops.radius", "G2.r_hoop"))
A(_rule("column_rebar.hoop.count", "후프 개수", "column_rebar.hoops.levels", "G2.hoop_chain.groups",
tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda gs: 1 + sum(int(n) for n, _ in gs) if isinstance(gs, list) else None))
A(_rule("column_rebar.tie.count", "결속철근 단 수", "column_rebar.ties.levels", "G2.tie_chain.groups",
tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda gs: 1 + sum(int(n) for n, _ in gs) if isinstance(gs, list) else None))
A(_rule("column_rebar.p1.top", "주철근 P1 직선 길이", "column_rebar.main_bars.p1.straight_len", "G2.p1_top_parts.2"))
# footing rebar rows (positions include the two end bars)
for lay, row in (("bottom_Y1", "Y_BOT"), ("bottom_X2", "X_BOT"), ("bottom_Y3", "Y_250"), ("bottom_X4", "X_250"), ("top_Y", "Y_250"), ("top_X", "X_250")):
A(_rule(f"footing_rebar.{lay}.count", f"기초 철근 {lay} 개수", f"footing_rebar.layers.{lay}.pos", f"G3.rows.{row}.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None, g=lambda n: n + 2 if isinstance(n, (int, float)) else None))
A(_rule(f"footing_rebar.{lay}.end", f"기초 철근 {lay} 끝", f"footing_rebar.layers.{lay}.pos.-1", f"G3.rows.{row}.ends.1"))
A(_rule("footing_rebar.b9.x", "B9 격자 x 개수", "footing_rebar.b9.grid_x", "G3.b9_grid.x.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None))
A(_rule("footing_rebar.b9.y", "B9 격자 y 개수", "footing_rebar.b9.grid_y", "G3.b9_grid.y.n", tol=0, f=lambda v: len(v) if isinstance(v, list) else None))
A(_rule("footing_rebar.b9.x0", "B9 격자 x 시작", "footing_rebar.b9.grid_x.0", "G3.b9_grid.x.start"))
A(_rule("footing_rebar.b9.y0", "B9 격자 y 시작", "footing_rebar.b9.grid_y.0", "G3.b9_grid.y.start"))
A(_rule("footing_rebar.b10.x", "B10 x", "footing_rebar.b10.size_x", "G3.b10_size.0"))
A(_rule("footing_rebar.b10.y", "B10 y", "footing_rebar.b10.size_y", "G3.b10_size.1"))
# schedule copies
for m in ("P1", "P2", "P3", "P4", "P5", "P6"):
A(_rule(f"schedule.{m}.n", f"재료표 {m} 개수", f"column_rebar.schedule.{m}.n", f"G2.schedule_n.{m}", tol=0))
A(_rule(f"schedule.{m}.L", f"재료표 {m} 길이", f"column_rebar.schedule.{m}.L", f"G2.schedule_L.{m}", f=lambda v: None if _num(v) is None else _num(v) * 1000))
for i in range(1, 13):
m = f"B{i}"
sch = (params.get("footing_rebar") or {}).get("schedule", {}).get(m)
if sch is None:
continue
# raw schedule rows: B9 has an extra count column in newer drawings; counts are compared where the row has one
A(_rule(f"schedule.{m}.n", f"재료표 {m} 개수", f"footing_rebar.schedule.{m}.3", f"G3.schedule.{m}.1", tol=0, f=lambda v: _num(v) if _num(v) is not None and float(v) == int(float(v)) else None))
return R
def check(params, expected):
"""-> list of {id, text, p, e, pv, ev, ok} for every rule whose two values exist, plus the point-list rule."""
out = []
for r in rules(params, expected):
pv, ev = _get(params, r["p"]), _get(expected, r["e"])
if r["f"]:
pv = r["f"](pv)
if r["g"]:
ev = r["g"](ev)
a, b = _num(pv), _num(ev)
if a is None or b is None:
continue
out.append(dict(id=r["id"], text=r["text"], p=r["p"], e=r["e"], pv=a, ev=b, ok=abs(a - b) <= r["tol"]))
# improvement #6: a list of points addressed by coordinates must name existing points
c14 = _get(params, "coping_rebar.c14")
if isinstance(c14, dict) and isinstance(c14.get("hooks_up"), list) and isinstance(c14.get("points"), list):
pts = {(round(x, 1), round(z, 1)) for x, z in c14["points"]}
for i, h in enumerate(c14["hooks_up"]):
ok = (round(h[0], 1), round(h[1], 1)) in pts
out.append(dict(id=f"coping_rebar.c14.hooks_up.{i}", text="c14 갈고리 위 지정 점이 c14 점 목록에 있다", p=f"coping_rebar.c14.hooks_up.{i}",
e="coping_rebar.c14.points", pv=f"({h[0]}, {h[1]})", ev="목록에 없음" if not ok else "있음", ok=ok))
return out
def bundles_of(path):
"""'footing.size_x' -> 'footing' (the section key of a dotted path)."""
return path.split(".", 1)[0]
@@ -0,0 +1,23 @@
"""Footing rebar layer rule of the Ungok frame (2026-10-20; was tools/infer_footing_rebar.py). The rules were read off the template pier (운곡교 P1) and
reproduced there: 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. The result equals the bar counts of the pier's own rebar schedule (P3, P4)."""
def dense(half, first_gap):
inner = half - first_gap
k = int((inner - 125) // 125)
g = [i * 125.0 for i in range(-k, k + 1)]
return sorted({-half, half, -inner, inner, *g})
def sparse(half, first_gap):
inner = half - first_gap
n = int(round(2 * inner / 250))
return sorted({-half, half, *[-inner + 250.0 * i for i in range(n + 1)]})
def infer(size_x, size_y):
lx, ly = size_x - 200, size_y - 200
hx, hy = lx / 2, ly / 2
return {"bottom_Y1": dense(hx, 200), "bottom_X2": dense(hy, 175), "bottom_Y3": sparse(hx, 200), "bottom_X4": sparse(hy, 175),
"top_Y": sparse(hx, 200), "top_X": sparse(hy, 175), "a_y": ly, "a_x": lx}
+198
View File
@@ -0,0 +1,198 @@
"""Gate runner: build a gate model, re-open it, check it, run negative tests and judge the gate.
A gate Gk passes when all of:
checks every FAIL of checker Gk is an approved planned exception (expected.json `planned_fail`)
negative a horizontal and a vertical move of one gate object each raise the FAIL count
reload the re-opened file has the same object count as the built document
unchanged objects of earlier gates are identical to the previous gate model (fast fingerprint)
Files go to <run_dir>: Gk.3dm, Gk.json, Gk_negH.json, Gk_negV.json, Gk_fp.json, Gk_gate.json.
"""
import json
import sys
import time
from pathlib import Path
from engine.pipeline import (CHECKERS, GATE_PREFIXES, GATES, REBAR_GATES, RHINO_DIR, TOLERANCE_MM, build_steps,
builder_of, check_steps, checker_of, gate_ids, scope_of)
APP = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(APP))
sys.path.insert(0, str(APP / "tools"))
from compare_fingerprints import diff_obj # noqa: E402
from runner import run_job # noqa: E402
# Negative tests per gate: one horizontal and one vertical move of a gate object (mm).
NEGATIVE = {
"G1": ({"id": "BS08", "d": [3, 0, 0]}, {"id": "BP03", "d": [0, 0, 2]}),
"G2": ({"mark": "P2", "d": [5, 0, 0]}, {"mark": "P3", "d": [0, 0, 5]}),
"G3": ({"mark": "B4", "d": [5, 0, 0]}, {"mark": "B9", "d": [0, 0, 5]}),
"G4": ({"mark": "C11", "d": [5, 0, 0]}, {"mark": "S1", "d": [0, 0, 5]}),
"G5": ({"id": "BB05", "d": [5, 0, 0]}, {"id": "SP08", "d": [0, 0, 5]}),
}
def _load(p):
return json.loads(Path(p).read_text(encoding="utf-8"))
def planned(expected, gate, item):
for pf in expected.get("planned_fail", []):
if pf["gate"] != gate:
continue
if pf.get("item") == item or (pf.get("item_prefix") and item.startswith(pf["item_prefix"])):
return pf
return None
def _fails(res):
return [r for r in res["results"] if r["result"] == "FAIL"]
def negative_moves(X, gate):
"""Negative-test moves of a gate: expected["negative"][gate] (pier specific) or the Ungok default.
A default bearing id beyond this pier's bearings (BS08 / SP08 with 4 bearings) is replaced by the last bearing
number of expected G5 el_x (OPEN #19 A7); with 10 bearings the default is unchanged."""
ov = X.get("negative", {}).get(gate)
if ov:
return tuple(ov)
nos = sorted(int(k) for k in (X.get("G5", {}).get("el_x") or {}) if str(k).isdigit())
return tuple(dict(mv, id=f"{mv['id'][:2]}{nos[-1]:02d}") if "id" in mv and nos and int(mv["id"][2:]) not in nos
else mv for mv in NEGATIVE[gate])
def _negative_steps(gate, pid, expected, run_dir):
"""Rhino steps: checker, then horizontal / vertical move -> checker -> undo (same document)."""
neg_h, neg_v = negative_moves(_load(expected), 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_moves(X, 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 = []
scope = scope_of(params)
for g, _, _ in GATES:
if g not in scope:
continue
script = builder_of(params, g)
steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})
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": checker_of(expected, 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):
run_dir = Path(run_dir)
run_dir.mkdir(parents=True, exist_ok=True)
X = _load(expected)
pid = X["pier_id"]
t0 = time.time()
model = run_dir / f"{gate}.3dm"
log(f"[{gate}] build G1..{gate}")
built = run_job(build_steps(params, gate), save=model, units_mm_tolerance=TOLERANCE_MM)
steps = _negative_steps(gate, pid, expected, run_dir)
steps.append({"name": "fingerprint", "script": RHINO_DIR / "fingerprint.py",
"globals": {"MODEL_PATH": str(model), "OUT_PATH": str(run_dir / f"{gate}_fp.json"), "FAST": True}})
log(f"[{gate}] re-open, check, negative tests, fingerprint")
checked = run_job(steps, open_path=model)
base, unplanned, planned_seen, neg = _judge(gate, X, run_dir)
fp = _load(run_dir / f"{gate}_fp.json")
unchanged = {"compared": 0, "diffs": [], "missing": [], "ok": True, "against": None}
ids = scope_of(params) # previous gate = previous gate in this pier's scope
k = ids.index(gate)
if k > 0:
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)
earlier = tuple(f"{pid}-{p}" for g in ids[:k] for p in GATE_PREFIXES[g])
keys = [oid for oid in prev["objects"] if oid.startswith(earlier)]
unchanged["against"] = prev_fp.name
unchanged["compared"] = len(keys)
unchanged["missing"] = [oid for oid in keys if oid not in fp["objects"]][:10]
for oid in keys:
if oid in fp["objects"]:
d = diff_obj(prev["objects"][oid], fp["objects"][oid])
if d:
unchanged["diffs"].append([oid, d[:3]])
unchanged["diffs"] = unchanged["diffs"][:10]
unchanged["ok"] = not unchanged["missing"] and not unchanged["diffs"]
reload_ok = built["objects"] == checked["objects"] == fp["count"]
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")
rep = {"gate": gate, "checker": checker_of(expected, gate), "verdict": verdict, "summary": base["summary"],
"unplanned_fails": unplanned, "planned_fails": planned_seen,
"negative": neg, "reload": {"built": built["objects"], "reopened": checked["objects"],
"fingerprint": fp["count"], "ok": reload_ok},
"unchanged": unchanged, "model": str(model), "mode": "single", "secs": round(time.time() - t0, 1),
"build_steps": [{k_: s[k_] for k_ in ("name", "secs")} for s in built["steps"]]}
(run_dir / f"{gate}_gate.json").write_text(json.dumps(rep, ensure_ascii=False, indent=1), encoding="utf-8")
log(f"[{gate}] {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']} reload {reload_ok} "
f"unchanged {unchanged['ok']} ({unchanged['compared']}) {rep['secs']}s")
return rep
+691
View File
@@ -0,0 +1,691 @@
"""Read the main dimensions of an Ungok-type pier straight from its drawings and propose them as params / expected values (2026-10-20, improvement #34-1).
Origin: the same readers were first written as separate tools (tools/extract_coping_outline.py, infer_footing_rebar.py, verify_pier_geometry.py,
apply_pier_read.py) while the Ungok P3 / P4 piers were modelled; they cost 77 + 144 hand-made value changes. Everything is read from the drawing
(D1 lines / circles / polylines / texts, the schedule tables) or derived by a rule that was reproduced on the finished template pier first:
coping outline + steps + soffit flat D1 front view lines (CS-CONC-MAJR), heights from the 'EL.' label next to the highest step
coping rebar tiers / C15 / C16 / loops D1 front view rebar lines (CS-RBAR-MAJR), C14 points = right-half circles mirrored
footing / lean / piles / column D1 dimension texts at the sheet positions of the template frame, pile chains `n@pitch=total`
bearings plan view pad polylines (datum = coping plan centre), pedestal outlines
footing rebar layer rule (engine/footing_rule) + the schedule counts + mid layers from the drawn dots
column rebar schedule rows + first hoop level = footing bottom + 1180
Frame: all sheet positions are those of the Ungok P1 drawing set (the same frame as P2 .. P4); `frame_ok()` tells whether a new drawing has it.
Validation: `python tools/test_geomread.py` runs the reader on P1 (must reproduce its params within 1.5 mm), on the finished P3 / P4 (no material change)
and on the early P4 backup (P1 copy) which must come out equal to the verified P4 values.
`read()` has no side effect: it returns changed COPIES of params / expected and the list of changes; the web screen / tools write them.
"""
import copy
import glob
import json
import math
import re
from pathlib import Path
import ezdxf
from engine import anchors, footing_rule, reader
FRAME_MIN_COVERAGE = 0.70
DATUM_X, DATUM_Y = 12162.4, 26412.3 # coping plan centre in the P1 plan view (all piers of the frame)
SEC_CENTRE_X = 30133.6 # footing centre in the section view of the P1 sheet
ARC_X = 6951.9 # x of the soffit arc centres (about the pier axis)
ORDER = {"XIV": "A B C R L", "XVI": "A", "VI": "A B R L", "V": "A B R L", "IX": "A B R L", "III": "A B L"}
GROUPS = (("coping.", "코핑"), ("coping_rebar.", "코핑 철근"), ("footing.", "기초·말뚝·기둥"), ("lean_concrete.", "기초·말뚝·기둥"), ("piles.", "기초·말뚝·기둥"),
("column.", "기초·말뚝·기둥"), ("bearing_seats.", "받침"), ("footing_rebar.", "기초 철근"), ("column_rebar.", "기둥 철근"), ("display.", "표시"),
("pier_id", "교각 식별자"))
class FrameError(Exception):
"""the drawing does not have the Ungok frame (or a needed element is missing) -- the message says what"""
def same(a, b, tol=1.5):
"""equal up to drawing rounding. Numbers: millimetre values (|v| >= 100) within `tol` (1.5 mm); SMALL numbers (counts, offsets, angles) must agree to
0.15 (0.05 for angles) -- a count of 36 is not 37. Texts that are numbers (schedule cells in metres: '17.247') must be equal; texts with words
(rule descriptions) compare by their numbers within 0.5. Lists / dicts element by element."""
if isinstance(a, bool) or isinstance(b, bool):
return a == b
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
return abs(a - b) <= (tol if max(abs(a), abs(b)) >= 100 else min(tol, 0.15))
if isinstance(a, list) and isinstance(b, list):
return len(a) == len(b) and all(same(x, y, tol) for x, y in zip(a, b))
if isinstance(a, dict) and isinstance(b, dict):
return a.keys() == b.keys() and all(same(a[k], b[k], tol) for k in a)
if isinstance(a, str) and isinstance(b, str):
pure = lambda t: re.fullmatch(r"\s*-?\d[\d,]*\.?\d*\s*", t) is not None # noqa: E731
if pure(a) and pure(b):
return abs(float(a.replace(",", "")) - float(b.replace(",", ""))) <= 0.0005
na, nb = re.findall(r"-?\d[\d,]*\.?\d*", a), re.findall(r"-?\d[\d,]*\.?\d*", b)
if na and len(na) == len(nb) and re.sub(r"[\d,.\-+]+", "", a) == re.sub(r"[\d,.\-+]+", "", b):
return all(abs(float(x.replace(",", "")) - float(y.replace(",", ""))) <= 0.5 for x, y in zip(na, nb))
return a == b
def _points_key(v):
return sorted(v, key=lambda p: (p[0], p[1]) if isinstance(p, list) else p)
EXACT_PARAMS = ("footing.", "lean_concrete.", "column.", "piles.", "column_rebar.", "footing_rebar.a_", "footing_rebar.b9", "footing_rebar.b10", "footing_rebar.layers.bottom",
"footing_rebar.layers.top", "footing_rebar.schedule", "footing_rebar.counts", "pier_id", "display.")
EXACT_EXPECTED = ("G1.footing", "G1.column", "G1.piles", "G2.", "G3.", "pier_id")
def _tol(which, path):
"""Values taken from TEXTS (dimension texts, schedule cells) are exact numbers: a 1 mm difference is a real difference (the bar length checks have a
1 mm tolerance). Values measured on CAD GEOMETRY (lines, circles, polylines) carry drawing rounding: 1.5 mm; angles 0.05."""
if "rotation" in path:
return 0.05
if re.fullmatch(r"coping_rebar\.top\.tiers\.\d\.a", path) or path.startswith(EXACT_EXPECTED if which == "expected" else EXACT_PARAMS):
return 0.0005
return 1.5
def _top_left(P, rng, ST, c16z):
"""Top face of the left C9 stirrups (not drawn). P1 rule: step top - 100. When the schedule's C9 b range does not follow from that rule, the top is
back-calculated from the range (b = top - stirrup bottom + b_offset) at the inner and the outer stirrup; accepted only when both ends agree (P4, 2026-10-20).
-> (bands [[|x| from, z]], why)"""
rule = [[0, c16z], [abs(ST[1][1]), ST[1][2] - 100], [abs(ST[0][1]), ST[0][2] - 100]]
why = "소단 − 100 (P1 규칙, 추론: 좌측 스터럽은 도면에 그려져 있지 않음 — 재료표 C9 b 범위와 대조하세요)"
cr = P["coping_rebar"]
st, bt, xs = cr["stirrups"], cr["bottom"], cr["stirrups"].get("x_c9")
if not (rng and rng[0] == "b_sched" and xs and cr.get("soffit_arc_centre")):
return rule, why
ax, cz = cr["soffit_arc_centre"]
def bottom(x):
x = abs(x)
return bt["z_flat"] if x <= bt["half_flat"] else cz + math.sqrt(bt["R_offset"] ** 2 - (x - ax) ** 2)
def b_of(bands, x):
z = bands[0][1]
for frm, zz in bands:
if abs(x) >= frm:
z = zz
return z - bottom(x) + st["b_offset"]
hi, lo = max(rng[1:3]), min(rng[1:3])
xi, xo = min(xs, key=abs), max(xs, key=abs)
if abs(b_of(rule, xi) - hi) <= 1.5 and abs(b_of(rule, xo) - lo) <= 1.5:
return rule, why + " — 재료표 C9 b 범위와 일치"
zi, zo = hi - st["b_offset"] + bottom(xi), lo - st["b_offset"] + bottom(xo)
if abs(zi - zo) <= 1.5:
z = (zi + zo) / 2
t1 = cr["top"]["tiers"]["1"]["z"]
z = t1 if abs(z - t1) <= 1.5 else round(z)
return [[0, c16z], [abs(xi) - 50, z]], (f"재료표 C9 b 범위(최대 {hi:g}, 최소 {lo:g})로 역산한 윗면 {z:g} — 안쪽·바깥쪽 스터럽이 {abs(zi - zo):.1f} mm 안에서 일치"
" (추론: 소단 − 100 규칙은 재료표와 어긋남)")
return rule, why + " — 재료표 C9 b 범위와 어긋나고 역산값도 두 끝이 일치하지 않음, 눈으로 확인하세요"
def _num(s):
return float(re.search(r"\d+\.\d+|\d+", s).group())
def _group(path):
for pre, name in GROUPS:
if path.startswith(pre) or path == pre:
return name
return "기타"
def dxf_of(pier_dir, audit):
"""DXF file of the front-view sheet D1: the audit entry labelled D1, else the file named (1)."""
ent = next((a for a in audit if a.get("label") == "D1"), None)
if ent and (pier_dir / "drawings" / "dxf" / ent["dxf"]).exists():
return pier_dir / "drawings" / "dxf" / ent["dxf"]
f = sorted(glob.glob(str(pier_dir / "drawings" / "dxf" / "*(1)*.dxf")))
if not f:
raise FrameError("D1(교각도 1) DXF가 없습니다")
return Path(f[0])
def read(params, expected, ref, texts, dxf_path, pier_name):
"""-> (params', expected', changes, notes). changes: dicts {file, path, old, new, why, group}; notes: strings for the person."""
P, E = copy.deepcopy(params), copy.deepcopy(expected)
changes, notes = [], []
def put(doc, which, path, val, why):
keys = path.split(".")
x = doc
for k in keys[:-1]:
x = x[int(k)] if isinstance(x, list) else x[k]
last = int(keys[-1]) if isinstance(x, list) else keys[-1]
old = x.get(last) if isinstance(x, dict) else x[last]
cmp_old, cmp_val = old, val
if path.endswith(("c14.points", "c14_levels")) and isinstance(old, list) and isinstance(val, list):
cmp_old, cmp_val = _points_key(old), _points_key(val) # the order of a point list carries no meaning
if old is None and path.endswith(("piles.pitch_x", "piles.pitch_y")) and isinstance(x, dict) and same(x.get("pitch"), val):
return # older layout: one `pitch` for both directions
if old is None and path.endswith("radius_by_mark") and isinstance(x, dict) and val.get("P3") == x.get("radius"):
return
if not same(cmp_old, cmp_val, _tol(which, path)):
x[last] = val
changes.append(dict(file=which, path=path, old=old, new=val, why=why, inferred="추론" in why,
group=_group(path) if which == "params" else "기대값(expected) " + path.split(".")[0]))
MS = list(ezdxf.readfile(str(dxf_path)).modelspace())
# ================= coping outline / datum
L = [(e.dxf.start.x, e.dxf.start.y, e.dxf.end.x, e.dxf.end.y) for e in MS if e.dxftype() == "LINE" and e.dxf.layer == "CS-CONC-MAJR"]
tips = sorted({(round(a, 1), round(b, 1), round(c, 1), round(d, 1)) for a, b, c, d in L if 150 < abs(c - a) < 250 and 1300 < abs(d - b) < 1900 and 0 < b < 25000})
if len(tips) != 2:
raise FrameError(f"코핑 양 끝 경사선 2개를 찾지 못했습니다({len(tips)}개)")
top = lambda l: (l[0], l[1]) if l[1] > l[3] else (l[2], l[3]) # noqa: E731
bot = lambda l: (l[2], l[3]) if l[1] > l[3] else (l[0], l[1]) # noqa: E731
left, right = sorted(tips, key=lambda l: top(l)[0])
CX = (bot(left)[0] + bot(right)[0]) / 2
cur, end = top(left), top(right)
hv = [(round(a, 1), round(b, 1), round(c, 1), round(d, 1)) for a, b, c, d in L if abs(a - c) < .05 or abs(b - d) < .05]
steps, used = [], set()
while abs(cur[0] - end[0]) > .5 or abs(cur[1] - end[1]) > .5:
nxt = None
for s in hv:
if s in used:
continue
for p, q in (((s[0], s[1]), (s[2], s[3])), ((s[2], s[3]), (s[0], s[1]))):
if abs(p[0] - cur[0]) < .5 and abs(p[1] - cur[1]) < .5 and q[0] >= cur[0] - .5 and (q[1] >= cur[1] - .5 or abs(q[0] - cur[0]) > .5):
nxt = (s, q)
break
if nxt:
break
if not nxt:
raise FrameError(f"코핑 윗면 계단 선이 끊겼습니다 ({cur[0]:.0f}, {cur[1]:.0f})")
used.add(nxt[0])
if abs(nxt[1][1] - cur[1]) < .05:
steps.append([cur[0], nxt[1][0], cur[1]])
cur = nxt[1]
if len(steps) != 5:
raise FrameError(f"코핑 윗면 계단이 {len(steps)}단입니다 (5단 형식만 읽습니다)")
y_hi = max(s[2] for s in steps)
els = [t for t in texts if t["dwg"] == "D1" and t["text"].startswith("EL.") and abs(t["x"] - end[0]) < 1500 and 0 < t["y"] - y_hi < 1200]
if not els:
raise FrameError("코핑 윗면 최고 단 옆의 EL 글자를 찾지 못했습니다")
OFF = _num(els[0]["text"]) * 1000 - y_hi # EL = y + OFF (front view)
ST = [(round(a - CX), round(b - CX), round(y + OFF)) for a, b, y in steps]
flat_y = sorted({round(f[1], 1) for f in hv if abs(f[1] - f[3]) < .05 and 1800 < abs(f[2] - f[0]) < 2600 and abs((f[0] + f[2]) / 2 - CX) < 600 and bot(left)[1] - 1700 < f[1] < bot(left)[1] + 100})
if not flat_y:
raise FrameError("코핑 아랫면 평평부를 찾지 못했습니다")
Z_FLAT = round(flat_y[0] + OFF)
TIP_RISE = round(top(left)[1] - bot(left)[1])
for i, (a, b, z) in enumerate(ST):
put(P, "params", f"coping.top_steps.{i}.x0", float(a), "D1 코핑 윗면 외곽선")
put(P, "params", f"coping.top_steps.{i}.x1", float(b), "D1 코핑 윗면 외곽선")
put(P, "params", f"coping.top_steps.{i}.z", z, "D1 코핑 윗면 외곽선 + EL 글자")
put(P, "params", "coping.z_bottom_flat", Z_FLAT, "D1 코핑 아랫면 평평부")
tip_z = Z_FLAT + TIP_RISE
# ================= coping rebar: lines and circles of the front view
V = []
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.start.x - e.dxf.end.x) < .05 and CX - 10 < e.dxf.start.x < 18500:
y0, y1 = sorted((e.dxf.start.y, e.dxf.end.y))
if 15500 < y0 and y1 < 20300 and y1 - y0 > 400:
V.append((round(e.dxf.start.x - CX), y0 + OFF, y1 + OFF))
pick = [v for v in V if v[0] == 2900]
CZ_REL = round(ref["coping_rebar"]["soffit_arc_centre"][1] + (Z_FLAT - ref["coping"]["z_bottom_flat"]), 1)
if pick:
CZ = round(pick[0][1] - math.sqrt(12100 ** 2 - (ARC_X - 2900) ** 2), 1)
if abs(CZ_REL - CZ) > 1.5:
notes.append(f"아랫면 곡선 중심: 템플릿 관계 {CZ_REL}, 스터럽 하단으로 맞춘 값 {CZ} — 눈으로 확인하세요")
else:
CZ = CZ_REL
put(P, "params", "coping_rebar.soffit_arc_centre", [ARC_X, CZ_REL], "템플릿 중심 + 하면 높이 차(스터럽 하단이 1.5 mm 안에서 맞음)")
H = {}
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.start.y - e.dxf.end.y) < .05 and 5500 < min(e.dxf.start.x, e.dxf.end.x) and max(e.dxf.start.x, e.dxf.end.x) < 18700 \
and 15500 < e.dxf.start.y < 20300 and abs(e.dxf.end.x - e.dxf.start.x) > 1500:
H[round(e.dxf.start.y + OFF)] = (round(min(e.dxf.start.x, e.dxf.end.x) - CX), round(max(e.dxf.start.x, e.dxf.end.x) - CX))
tiers = sorted((z for z, (a, b) in H.items() if a == 0 and b > 5900 and ST[0][2] - 450 < z < ST[0][2] - 50), reverse=True)
c15s = [(z, a, b) for z, (a, b) in H.items() if a > 3000]
c16s = [(z, a, b) for z, (a, b) in H.items() if a == 0 and 3500 < b < 4500]
if len(tiers) < 3 or not c15s or not c16s:
raise FrameError("코핑 상단 철근 단·C15·C16 선을 찾지 못했습니다")
c15, c16 = c15s[0], max(c16s)
for t, z in zip(("1", "2", "3"), tiers[:3]):
put(P, "params", f"coping_rebar.top.tiers.{t}.z", z, "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.z", c15[0], "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.x0", c15[1], "D1 철근 수평선")
put(P, "params", "coping_rebar.c15.x1", c15[2], "D1 철근 수평선")
put(P, "params", "coping_rebar.c16.z", c16[0], "D1 철근 수평선")
put(P, "params", "coping_rebar.c16.x1", c16[2], "D1 철근 수평선")
put(P, "params", "coping_rebar.bottom.z_flat", Z_FLAT + 100, "아랫면 평평부 + 100")
n_total = len(P["coping_rebar"]["loops"]["levels"])
cand = sorted((z for z, (a, b) in H.items() if a == 0 and z < tiers[-1] - 50 and z != c16[0]), reverse=True)
chain_ = [cand[0]] if cand else []
for z in cand[1:]:
if abs(chain_[-1] - z - 250) <= 1.5:
chain_.append(z)
loops = sorted(chain_)
lvs = None
if len(loops) >= 4:
lvs = [loops[0] - 250 * k for k in range(max(n_total - len(loops), 0), 0, -1)] + loops # lower levels lie below the plotted band: continue at 250
put(P, "params", "coping_rebar.loops.levels", lvs, "D1 루프 수평선 @250(아래쪽은 이어서 계산)")
else:
notes.append("코핑 루프 선을 4개 미만 찾아 그대로 두었습니다")
put(P, "params", "coping_rebar.stirrups.top_right", [[0, c16[0]], [3900, c15[0]]], "스터럽 윗면 = C16 / C15 높이")
put(P, "params", "coping_rebar.stirrups.bottom", f"z={Z_FLAT + 100:,} for |X|<=1,073.9 else circle R 12,100 about (+-6,951.9, {CZ_REL:,})", "아랫면 규칙 문구")
allc = set()
for e in MS:
if e.dxftype() == "CIRCLE" and e.dxf.layer == "CS-RBAR-MAJR" and abs(e.dxf.radius - 27) < .1 and CX - 6000 < e.dxf.center.x < 18500 and 15500 < e.dxf.center.y < 20300:
allc.add((round(e.dxf.center.x - CX), round(e.dxf.center.y + OFF, 1)))
circ = None # filled below, once dz (the pier's height shift) is known
dz = ST[0][2] - ref["coping"]["top_steps"][0]["z"]
# C14 points are READ from the drawn circles (x as drawn: the x offsets differ per pier -- 13/913/... in P1, P3, P4 but 0/900/... in P2).
# Right-half circles are mirrored to the left; the centre point (|x| <= 20) is kept once; a circle that sits where the template has a point
# WITHOUT a mirror image (P1: the point near x 2913 that is missing on the left because the cover would be below 40 mm) stays right-only.
cpts = ref["coping_rebar"]["c14"]["points"]
tset = {(round(x), round(z, 1)) for x, z in cpts}
right_only = [(x, z + dz) for x, z in cpts if x > 20 and (-x, round(z, 1)) not in tset]
levels = {z + dz for x, z in cpts if (x, z + dz) not in right_only}
# keep the circles on the template's C14 heights (+ the pier's shift, 5 mm): the column hoop dots (x about +-902, every 120 / 300 -- one lies 23 mm from a
# C14 height) and other dots are left out; in P2 a C14 circle (x = 900) is only 2 mm from a hoop dot in x, so the height has to tell them apart.
# The right-only point of the template may sit up to 60 mm off its shifted height (P4: 18 mm).
circ = {(x, z) for x, z in allc if x >= -20 and (any(abs(z - lz) <= 5 for lz in levels) or any(abs(x - rx) <= 20 and abs(z - rz) <= 60 for rx, rz in right_only))}
pts = []
for x, z in sorted(circ):
if abs(x) <= 20 or any(abs(x - rx) <= 20 and abs(z - rz) <= 60 for rx, rz in right_only):
pts.append([x, z])
else:
pts += [[x, z], [-x, z]]
if len(pts) != len(cpts):
notes.append(f"C14 점: 도면에서 읽은 {len(pts)}개 ≠ 템플릿 {len(cpts)}개 — 눈으로 확인하세요")
put(P, "params", "coping_rebar.c14.points", pts, "도면 정면도의 철근 원(우측 반을 좌우 대칭, 가운데 점은 한 번)")
hup = []
for tx, tz in ref["coping_rebar"]["c14"]["hooks_up"]:
near = [p for p in pts if (p[0] > 0) == (tx > 0) and abs(abs(p[0]) - abs(tx)) <= 20 and abs(p[1] - (tz + dz)) <= 60]
if near:
hup.append(min(near, key=lambda p: abs(p[1] - (tz + dz))))
else:
notes.append(f"갈고리 위 지정 점 ({tx}, {tz})에 해당하는 C14 점을 찾지 못했습니다 — 눈으로 확인하세요")
put(P, "params", "coping_rebar.c14.hooks_up", [list(p) for p in hup], "하면 곡선을 뚫는 점만 갈고리 위로(점 좌표로 지정 — c14.points와 일치해야 함)")
# ================= footing / lean / piles / column
EL_F = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and t["text"].startswith("EL. 2") and 17000 < t["x"] < 18500 and 7000 < t["y"] < 8200]
fx = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and 11000 < t["x"] < 12500 and 3500 < t["y"] < 4000 and re.fullmatch(r"\d+\.\d{3}", t["text"])]
fy = [_num(t["text"]) * 1000 for t in texts if t["dwg"] == "D1" and 29000 < t["x"] < 30500 and 3500 < t["y"] < 4000 and re.fullmatch(r"\d+\.\d{3}", t["text"])]
pl = [t["text"] for t in texts if t["dwg"] == "D1" and t["text"].startswith("L=") and t["x"] < 10000 and t["y"] < 7000]
chx = [t["text"] for t in texts if t["dwg"] == "D1" and "@1." in t["text"] and 10000 < t["x"] < 13000 and 5500 < t["y"] < 6600]
chy = [t["text"] for t in texts if t["dwg"] == "D1" and "@1." in t["text"] and 28000 < t["x"] < 31000 and 5500 < t["y"] < 6600]
if not (EL_F and fx and fy and pl and chx and chy):
raise FrameError("기초 크기·하면 EL·말뚝 길이·말뚝 사슬 글자를 찾지 못했습니다")
chx, chy = chx[0], chy[0]
SX, SY, ZF = round(fx[0]), round(fy[0]), round(EL_F[0])
TH = P["footing"]["thickness"]
put(P, "params", "footing.size_x", float(SX), "D1 기초 치수")
put(P, "params", "footing.size_y", float(SY), "D1 기초 치수")
put(P, "params", "footing.z_bottom", ZF, "D1 EL 글자")
put(P, "params", "lean_concrete.size_x", float(SX + 200), "기초 + 200")
put(P, "params", "lean_concrete.size_y", float(SY + 200), "기초 + 200")
put(P, "params", "lean_concrete.z_bottom", ZF - 100, "기초 하면 − 100")
put(P, "params", "lean_concrete.z_top", ZF, "= 기초 하면")
COL_H = Z_FLAT - ZF - TH
put(P, "params", "column.height", COL_H, "코핑 하면 − (기초 하면 + 두께) (D1 높이 글자와 같음)")
put(P, "params", "piles.length", round(_num(pl[0]) * 1000), "D1 말뚝 길이 글자")
put(P, "params", "piles.count_x", int(chx.split("@")[0]) + 1, "D1 말뚝 사슬")
put(P, "params", "piles.pitch_x", float(round(_num(chx.split("@")[1]) * 1000)), "D1 말뚝 사슬")
put(P, "params", "piles.count_y", int(chy.split("@")[0]) + 1, "D1 말뚝 사슬")
put(P, "params", "piles.pitch_y", float(round(_num(chy.split("@")[1]) * 1000)), "D1 말뚝 사슬")
# ================= bearings
pads = []
for e in MS:
if e.dxftype() == "LWPOLYLINE" and e.dxf.layer == "CS-CONC-MAJR" and len(e) == 4:
q = [(v[0], v[1]) for v in e.get_points()]
if not (5000 < q[0][0] < 20000 and 23000 < q[0][1] < 29500):
continue
s = [math.dist(q[i], q[(i + 1) % 4]) for i in range(4)]
if abs(s[0] - s[2]) < 1 and abs(s[1] - s[3]) < 1 and 780 < s[0] < 920 and 700 < s[1] < 860:
pads.append((sum(v[0] for v in q) / 4 - DATUM_X, sum(v[1] for v in q) / 4 - DATUM_Y, math.degrees(math.atan2(q[1][1] - q[0][1], q[1][0] - q[0][0])) % 180))
seen, uniq = set(), []
for a, b, c in sorted(pads):
k = (round(a / 3), round(b / 3))
if k not in seen:
seen.add(k)
uniq.append((round(a, 1), round(b, 1), round(90 - c, 2)))
fr = sorted(p for p in uniq if p[1] < 0)
rr = sorted(p for p in uniq if p[1] > 0)
if len(fr) < 2 or not rr:
raise FrameError("평면도에서 받침 패드(앞·뒤 열)를 찾지 못했습니다")
g5f, g5r = fr[-1], rr[-1]
PITCH = round(g5f[0] - fr[-2][0], 1)
put(P, "params", "bearing_seats.pitch_x", PITCH, "평면도 패드 g4 → g5")
put(P, "params", "bearing_seats.pad_rows.front.centre_rel_girder5", [g5f[0], g5f[1]], "D1 평면도 패드 폴리라인")
put(P, "params", "bearing_seats.pad_rows.front.rotation_deg", g5f[2], "패드 폴리라인 각도")
put(P, "params", "bearing_seats.pad_rows.rear.centre_rel_girder5", [g5r[0], g5r[1]], "D1 평면도 패드 폴리라인")
put(P, "params", "bearing_seats.pad_rows.rear.rotation_deg", g5r[2], "패드 폴리라인 각도")
for i, b in enumerate(P["bearing_seats"]["bearings"]):
ref_ = g5f if b["row"] == "front" else g5r
put(P, "params", f"bearing_seats.bearings.{i}.centre", [round(ref_[0] - (5 - b["girder_line"]) * PITCH, 1), ref_[1]], "g4·g5는 도면, g1~g3은 피치로 계산")
put(P, "params", f"bearing_seats.bearings.{i}.rotation_deg", ref_[2], "패드 각도")
ped = []
for e in MS:
if e.dxftype() == "LINE" and e.dxf.layer == "CS-CONC-MAJR":
s, t = e.dxf.start, e.dxf.end
ln = math.hypot(t.x - s.x, t.y - s.y)
if 5000 < min(s.x, t.x) and max(s.x, t.x) < 20000 and 23000 < min(s.y, t.y) and max(s.y, t.y) < 29500 and (abs(ln - 2500) < 5 or abs(ln - 1200) < 5):
ped.append(((s.x - DATUM_X, s.y - DATUM_Y), (t.x - DATUM_X, t.y - DATUM_Y), round(ln)))
ped5 = [p for p in ped if min(p[0][0], p[1][0]) > g5f[0] - 800 and max(p[0][0], p[1][0]) < g5f[0] + 800]
if len(ped5) >= 5:
longs = sorted([p for p in ped5 if p[2] == 2500], key=lambda p: p[0][0])
shorts = sorted([p for p in ped5 if p[2] == 1200], key=lambda p: (p[0][1] + p[1][1]))
def lo_hi(l):
a, b = l[0], l[1]
return (a, b) if a[1] < b[1] else (b, a)
(l_lo, l_hi), (r_lo, r_hi) = lo_hi(longs[0]), lo_hi(longs[-1])
m0, m1 = sorted([shorts[1][0], shorts[1][1]])
r1 = lambda v: [round(v[0], 1), round(v[1], 1)] # noqa: E731
put(P, "params", "bearing_seats.pedestal_girder5.front_half", [r1(l_lo), r1(m0), r1(m1), r1(r_lo)], "D1 평면도 대좌 외곽")
put(P, "params", "bearing_seats.pedestal_girder5.rear_half", [r1(m0), r1(l_hi), r1(r_hi), r1(m1)], "D1 평면도 대좌 외곽")
put(P, "params", "bearing_seats.pedestal_girder5.rotation_deg", g5f[2], "대좌 각도 = 패드 각도")
else:
notes.append("g5 대좌 외곽(2500 × 1200)을 찾지 못해 그대로 두었습니다")
# ================= schedule tables of the pier itself
rows, dia_of = {}, {}
for c in reader.table_candidates(texts):
if c["key"].startswith("schedule"):
last = None # '"' in the diameter column = the row above (table order, not mark order)
for r in c["rows"]:
tx = [x["text"] for x in sorted(r["cells"], key=lambda z: z["x"])]
if len(tx) > 2 and tx[2].strip() != '"' and re.fullmatch(r"[SHD]\d+", tx[2].strip()):
last = tx[2].strip()
if tx and re.fullmatch(r"[BPC]\d+|S\d+", tx[0].strip()):
rows[tx[0].strip()] = tx
if last:
dia_of[tx[0].strip()] = int(re.search(r"\d+", last).group())
need = [f"{p}{i}" for p, n in (("P", 6), ("B", 12)) for i in range(1, n + 1)] + ["C16"] + [f"C{i}" for i in range(1, 7)]
miss = [m for m in need if m not in rows]
if miss:
raise FrameError("철근 재료표에서 다음 마크를 읽지 못했습니다: " + ", ".join(miss))
for m in ("P1", "P2", "P3", "P4", "P5", "P6"):
tx = rows[m]
vals = tx[4:4 + len(ORDER[tx[1]].split())]
new = dict(P["column_rebar"]["schedule"][m])
new["n"] = tx[3]
for f, v in zip(ORDER[tx[1]].split(), vals):
new[f] = v
if tx[1] == "XVI":
new["L"] = vals[0]
put(P, "params", f"column_rebar.schedule.{m}", new, "이 교각의 재료표 행")
for m in [f"B{i}" for i in range(1, 13)]:
put(P, "params", f"footing_rebar.schedule.{m}", rows[m], "이 교각의 재료표 행")
n_hoop = {m: int(rows[m][3]) for m in ("P3", "P4", "P5")}
n_tie_levels = int(rows["P6"][3]) // 4
for t, ms in (("1", ("C1", "C4")), ("2", ("C2", "C5")), ("3", ("C3", "C6"))):
put(P, "params", f"coping_rebar.top.tiers.{t}.a", round(_num(rows[ms[0]][4]) * 1000), "재료표 a")
put(P, "params", "coping_rebar.c16.x0", c16[2] - round(_num(rows["C16"][4]) * 1000), "x1 − 재료표 C16 a")
# ================= footing rebar
rule = footing_rule.infer(SX, SY)
put(P, "params", "footing_rebar.a_x", rule["a_x"], "기초 − 200")
put(P, "params", "footing_rebar.a_y", rule["a_y"], "기초 − 200")
for k in ("bottom_Y1", "bottom_X2", "bottom_Y3", "bottom_X4", "top_Y", "top_X"):
put(P, "params", f"footing_rebar.layers.{k}.pos", [float(v) for v in rule[k]], "철근 층 규칙")
cnt = {"bottom_Y1": ("B2", "B4"), "bottom_X2": ("B6", "B8"), "bottom_Y3": ("B3",), "bottom_X4": ("B7",), "top_Y": ("B1",), "top_X": ("B5",)}
for k, ms in cnt.items():
sch = sum(int(rows[m][3]) for m in ms)
if sch != len(rule[k]):
notes.append(f"기초 철근 {k}: 규칙 계산 {len(rule[k])}본 ≠ 재료표 {sch}본")
gx = [-(SX - 1100) / 2 + 250 * i for i in range(int((SX - 1100) // 250) + 1)]
gy = [-(SY - 800) / 2 + 250 * i for i in range(int((SY - 800) // 250) + 1)]
put(P, "params", "footing_rebar.b9.grid_x", [int(v) for v in gx], "B9 격자 규칙(x 폭 = 기초 − 1100)")
put(P, "params", "footing_rebar.b9.grid_y", [int(v) for v in gy], "B9 격자 규칙(y 폭 = 기초 − 800)")
B9N = int(rows["B9"][3]) if len(rows["B9"]) >= 9 else round(_num(rows["B9"][-1]) / _num(rows["B9"][-2]))
B9G = (len(gx) * len(gy) + 1) // 2 # the model places a checkerboard of the grid
put(P, "params", "footing_rebar.b9.count", B9G, "B9 격자의 체스판 개수(모델이 놓는 개수)")
if B9G != B9N:
notes.append(f"B9: 격자 {len(gx)} × {len(gy)} ÷ 2 = {B9G}본 ≠ 재료표 {B9N}본 — 작도를 채택하려면 계획된 FAIL(G3 B9 개수) 등록이 필요합니다")
put(P, "params", "footing_rebar.b10.size_x", float(SX - 152), "B10 = 기초 − 152")
put(P, "params", "footing_rebar.b10.size_y", float(SY - 156), "B10 = 기초 − 156")
mid = {}
for tag, (xmin, xmax) in (("mid_Y", (CX - 5, 18500)), ("mid_X", (23000, 37000))):
lv = {}
for e in MS:
if e.dxftype() == "CIRCLE" and xmin < e.dxf.center.x < xmax:
for l in (550, 950, 1350, 1750):
if abs(e.dxf.center.y - (8115.6 + l)) < 1:
lv.setdefault(float(l), []).append(round(e.dxf.center.x, 1))
mid[tag] = lv
def sym(xs, centre, edge_hoop):
r = sorted({round(abs(x - centre)) for x in xs})
r = [v for v in r if (abs(v - edge_hoop) > 12 and v > 0) or v == 0]
return sorted([-v for v in r if v] + [v for v in r])
if all(len(mid[t]) == 4 for t in mid):
put(P, "params", "footing_rebar.layers.mid_Y.levels", {str(k): [float(v) for v in sym(xs, CX, round(SX - 152) / 2)] for k, xs in sorted(mid["mid_Y"].items())}, "D1 정면도 철근 점(B12, 우측 반 → 좌우 대칭)")
put(P, "params", "footing_rebar.layers.mid_X.levels", {str(k): [float(v) for v in sym(xs, SEC_CENTRE_X, round(SY - 156) / 2)] for k, xs in sorted(mid["mid_X"].items())}, "D1 단면도 철근 점(B11, 우측 반 → 좌우 대칭)")
for tag, mk in (("mid_Y", "B12"), ("mid_X", "B11")):
drawn = sum(len(v) for v in P["footing_rebar"]["layers"][tag]["levels"].values())
if drawn != int(rows[mk][3]):
notes.append(f"{mk}: 도면 점 {drawn}본 ≠ 재료표 {rows[mk][3]}본 (작도를 채택하려면 계획된 FAIL 등록이 필요합니다)")
else:
notes.append("기초 중간 층 B11·B12 점을 네 단 모두에서 찾지 못해 그대로 두었습니다")
cc = {"bottom_Y1": len(rule["bottom_Y1"]), "bottom_X2": len(rule["bottom_X2"]), "bottom_Y3": len(rule["bottom_Y3"]), "bottom_X4": len(rule["bottom_X4"]),
"top_Y": len(rule["top_Y"]), "top_X": len(rule["top_X"])}
for tag in ("mid_X", "mid_Y"):
cc[tag] = sum(len(v) for v in P["footing_rebar"]["layers"][tag]["levels"].values())
if isinstance(P["footing_rebar"].get("counts_check"), dict):
put(P, "params", "footing_rebar.counts_check", {k: cc.get(k, v) for k, v in P["footing_rebar"]["counts_check"].items()}, "층별 철근 개수(위 규칙·도면 점)")
# ================= column rebar
put(P, "params", "column_rebar.main_bars.p1.hook_leg_z", ZF + 300, "기초 하면 + 300")
put(P, "params", "column_rebar.main_bars.p1.straight_from", ZF + 640, "+ 굽힘 R340")
put(P, "params", "column_rebar.main_bars.p1.straight_len", round(_num(rows["P1"][4]) * 1000), "재료표 P1 A")
put(P, "params", "column_rebar.main_bars.p2.z0", ZF + 300, "기초 하면 + 300")
put(P, "params", "column_rebar.main_bars.p2.length", round(_num(rows["P2"][4]) * 1000), "재료표 P2")
lv = [ZF + 1180 + 120 * i for i in range(n_hoop["P4"] + n_hoop["P5"])]
lv += [lv[-1] + 300 * (i + 1) for i in range(n_hoop["P3"])]
put(P, "params", "column_rebar.hoops.levels", lv, "첫 단 = 기초 하면 + 1180, 120 / 300 사슬(재료표 개수)")
put(P, "params", "column_rebar.hoops.marks", ["P4"] * n_hoop["P4"] + ["P5"] * n_hoop["P5"] + ["P3"] * n_hoop["P3"], "재료표 개수")
put(P, "params", "column_rebar.hoops.radius", round(_num(rows["P4"][6])), "재료표 P4·P5 R")
put(P, "params", "column_rebar.hoops.radius_by_mark", {"P3": round(_num(rows["P3"][6]))}, "재료표 P3 R")
put(P, "params", "column_rebar.ties.levels", [ZF + 1180 + 1800 * i for i in range(n_tie_levels)], "사슬 시작 = 기초 하면 + 1180, 단 수 = 재료표 P6 ÷ 4")
# ================= expected
outline = [[-1100, Z_FLAT], [1100, Z_FLAT], [6200, tip_z], [6000, ST[4][2]], [ST[4][0], ST[4][2]], [ST[4][0], ST[3][2]], [ST[3][0], ST[3][2]], [ST[3][0], ST[2][2]],
[ST[2][0], ST[2][2]], [ST[2][0], ST[1][2]], [ST[1][0], ST[1][2]], [ST[1][0], ST[0][2]], [-6000, ST[0][2]], [-6200, tip_z]]
put(E, "expected", "G1.coping_outline", outline, "코핑 계단 + 아랫면 평평부")
put(E, "expected", "G1.dxf_arc_centres.datum_el", Z_FLAT, "코핑 아랫면 평평부")
put(E, "expected", "G1.footing.dx", SX, "D1")
put(E, "expected", "G1.footing.dy", SY, "D1")
put(E, "expected", "G1.footing.z0", ZF, "D1 EL")
put(E, "expected", "G1.column.height", COL_H, "D1")
put(E, "expected", "G1.coping.z_top_left", ST[0][2], "코핑 계단")
put(E, "expected", "G1.coping.z_top_center", ST[2][2], "코핑 계단")
put(E, "expected", "G1.coping.z_top_right", ST[4][2], "코핑 계단")
put(E, "expected", "G1.piles.nx", int(chx.split("@")[0]) + 1, "D1")
put(E, "expected", "G1.piles.ny", int(chy.split("@")[0]) + 1, "D1")
put(E, "expected", "G1.piles.pitch_x", round(_num(chx.split("@")[1]) * 1000), "D1")
put(E, "expected", "G1.piles.pitch_y", round(_num(chy.split("@")[1]) * 1000), "D1")
put(E, "expected", "G1.piles.length", round(_num(pl[0]) * 1000), "D1")
put(E, "expected", "G1.seats.step_top", {str(i + 1): ST[i][2] for i in range(5)}, "코핑 계단")
put(E, "expected", "G1.seats.step_x", {str(i + 1): [ST[i][0], ST[i][1]] for i in range(5)}, "코핑 계단")
put(E, "expected", "G1.seats.row_y", {"front": round(g5f[1]), "rear": round(g5r[1])}, "평면도 패드 폴리라인")
chain = [t["text"] for t in texts if t["dwg"] == "D1" and re.fullmatch(r"3@2\.\d+=\d\.\d+", t["text"])]
if chain:
put(E, "expected", "G1.seats.pitch_chain", [3, round(_num(chain[0].split("=")[1]) * 1000)], "D1 3@피치=합계")
put(E, "expected", "G2.hoop_chain", {"start": [150, 1030], "groups": [[n_hoop["P4"] - 1, 120], [n_hoop["P5"], 120], [n_hoop["P3"], 300]]}, "재료표 개수")
put(E, "expected", "G2.hoop_marks_by_zone", [["P4", n_hoop["P4"]], ["P5", n_hoop["P5"]], ["P3", n_hoop["P3"]]], "재료표 개수")
put(E, "expected", "G2.tie_chain", {"start": [150, 1030], "groups": [[n_tie_levels - 1, 1800]]}, "재료표 P6 ÷ 4 − 1 구간")
put(E, "expected", "G2.r_hoop", round(_num(rows["P4"][6])), "재료표 P4·P5 R")
put(E, "expected", "G2.p1_top_parts", [300, 340, round(_num(rows["P1"][4]) * 1000)], "300 + 340 + 재료표 P1 A")
hx, hy = (SX - 200) / 2, (SY - 200) / 2
put(E, "expected", "G3.rows", {"Y_BOT": {"ends": [-int(hx), int(hx)], "start": -int(hx - 200), "pitch": 125, "n": len([v for v in rule["bottom_Y1"] if abs(v) < hx - 200 + 1])},
"Y_250": {"ends": [-int(hx), int(hx)], "start": -int(hx - 200), "pitch": 250, "n": len([v for v in rule["bottom_Y3"] if abs(v) < hx - 1])},
"X_BOT": {"ends": [-int(hy), int(hy)], "start": -int(hy - 175), "pitch": 125, "n": len([v for v in rule["bottom_X2"] if abs(v) < hy - 1])},
"X_250": {"ends": [-int(hy), int(hy)], "start": -int(hy - 175), "pitch": 250, "n": len([v for v in rule["bottom_X4"] if abs(v) < hy - 1])}}, "기초 철근 규칙")
put(E, "expected", "G3.b9_grid", {"x": {"start": int(gx[0]), "pitch": 250, "n": len(gx)}, "y": {"start": int(gy[0]), "pitch": 250, "n": len(gy)}}, "B9 격자")
put(E, "expected", "G3.b10_size", [SX - 152, SY - 156], "B10")
put(E, "expected", "G4.tier_z", {"C1": tiers[0], "C4": tiers[0], "C2": tiers[1], "C5": tiers[1], "C3": tiers[2], "C6": tiers[2]}, "D1 철근 수평선")
put(E, "expected", "G4.c10_bottom", Z_FLAT + 100, "아랫면 평평부 + 100")
if lvs:
put(E, "expected", "G4.loop_z", {"C12": {"start": lvs[0], "pitch": 250, "n": 6}, "C13": lvs[6:]}, "D1 루프 수평선")
put(E, "expected", "G4.c15_top", c15[0], "D1 철근 수평선")
put(E, "expected", "G4.c16_top", c16[0], "D1 철근 수평선")
lev = {}
for x, z in P["coping_rebar"]["c14"]["points"]:
lev[z] = lev.get(z, 0) + 1
put(E, "expected", "G4.c14_levels", [[z, n] for z, n in sorted(lev.items(), key=lambda kv: -kv[0])], "c14 점을 높이별로")
put(E, "expected", "G4.step_top", {str(i + 1): ST[i][2] for i in range(5)}, "코핑 계단")
# bar diameters from the schedule ('"' = same as the row above, in the order of the marks), ranges of the stirrup / loop lengths
dia = dia_of
for gate, marks in (("G2", [f"P{i}" for i in range(1, 7)]), ("G3", [f"B{i}" for i in range(1, 13)]), ("G4", [f"C{i}" for i in range(1, 17)] + ["S1", "S2"])):
cur = E.get(gate, {}).get("dia")
if isinstance(cur, dict):
put(E, "expected", f"{gate}.dia", {m: dia.get(m, v) for m, v in cur.items()}, "이 교각의 재료표 직경")
d2 = E.get("G2", {}).get("dia")
if isinstance(d2, dict) and "P4" in d2 and "P6" in d2:
put(E, "expected", "G2.tie_offset", (d2["P4"] + d2["P6"]) / 2, "크로스타이 높이 = 띠철근 높이 + (P4 직경 + P6 직경) / 2 (P1~P4 5개 값과 일치)")
def rng(txt):
v = [round(_num(x) * 1000) for x in re.findall(r"\d+\.\d+", txt)]
return v if len(v) == 2 else (v + v if len(v) == 1 else None)
cur = E["G4"].get("range")
if isinstance(cur, dict):
new_r = dict(cur)
for m, col, kind in (("C9", 5, "b_sched"), ("C10", 5, "b_sched"), ("C11", 5, "b_sched"), ("C12", 4, "a_sched"), ("C13", 4, "a_sched")):
if m in rows and len(rows[m]) > col and rng(rows[m][col]):
new_r[m] = [kind] + rng(rows[m][col])
put(E, "expected", "G4.range", new_r, "재료표 C9~C13의 b·a 범위")
tl, tl_why = _top_left(P, (E["G4"].get("range") or {}).get("C9"), ST, c16[0])
put(P, "params", "coping_rebar.stirrups.top_left", tl, tl_why)
pid = pier_name.replace("운곡교", "")
old_pid = P.get("pier_id")
if old_pid and old_pid != pid:
put(P, "params", "display.hidden_ids", [h.replace(old_pid + "-", pid + "-", 1) for h in P["display"].get("hidden_ids", [])], "모델 객체 이름에 교각 식별자가 들어 있음")
put(E, "expected", "pier_id", pid, "교각 이름")
put(P, "params", "pier_id", pid, "교각 이름")
return P, E, changes, notes
TABLE_GROUP = "표 값(받침·솔플레이트·재료표)"
def table_changes(P, E, texts):
"""Changes that bring params / expected in line with the tables the app recognises (ELEVATION / 솔플레이트 / 철근재료표): one per proposal row that
differs (engine.reader.proposals). Computed on the already-read P / E so only what the reading left different is proposed."""
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), P, E) if texts else []
return [dict(file=r["file"], path=".".join(str(x) for x in r["path"]), old=r["current"], new=r["candidate"], why=r["src"], inferred=False, group=TABLE_GROUP, row=r)
for r in rows if not r["same"]]
ANCHOR_FIELDS = ("expected:G1.seats.rear_first_from_tip",) # per-pier dimensions taken from an anchor candidate (A / B grade) when the reading left the template's value
PENDING = "읽기 도구 제안 — 사용자 승인 필요"
def _get(doc, path):
x = doc
for k in path.split("."):
x = x[int(k)] if isinstance(x, list) else x.get(k) if isinstance(x, dict) else None
if x is None:
return None
return x
def extra_changes(P, E, texts, anchors_doc=None, own_sources=()):
"""Proposals made on top of read() + table_changes() (2026-10-20, from the P4 rebuild):
1. anchor candidates for the per-pier dimensions in ANCHOR_FIELDS (e.g. the rear pedestal's first distance from the tip) when they differ from the current value;
2. planned FAIL entries the person would have to add by hand: a drawn bar count that differs from the schedule (B11 / B12, the drawing is followed),
and a schedule length L that does not follow the (max+min)/2 convention (C9~C13). One change holding the whole list; it is never pre-selected
(the approval of a planned FAIL stays with the person) and its entries carry `approved` = PENDING."""
out = []
if anchors_doc and texts:
cand = anchors.follow(anchors_doc, texts, own_sources)
for f in ANCHOR_FIELDS:
c = cand.get(f)
which, path = f.split(":", 1)
cur = _get(E if which == "expected" else P, path)
if c and c["grade"] in ("A", "B") and not c["self_source"] and cur is not None and not same(cur, c["value"], 0.0005):
out.append(dict(file=which, path=path, old=cur, new=c["value"], inferred=False,
why=f"앵커 후보 {c['grade']}등급 — 도면 글자 '{c['text']}' ({c['dwg']} {c['x']:.0f}, {c['y']:.0f})",
group=f"기대값({which}) " + path.split(".")[0]))
P2, E2 = copy.deepcopy(P), copy.deepcopy(E)
if texts:
rows = reader.proposals(reader.field_candidates(reader.table_candidates(texts)), P2, E2)
reader.apply_proposals([r for r in rows if not r["same"]], P2, E2)
want = [] # (gate, item, reason, numbers the reason must contain)
layers = (P2.get("footing_rebar") or {}).get("layers") or {}
for tag, mark in (("mid_X", "B11"), ("mid_Y", "B12")):
lv, sch = (layers.get(tag) or {}).get("levels"), ((E2.get("G3") or {}).get("schedule") or {}).get(mark)
if isinstance(lv, dict) and isinstance(sch, list) and len(sch) == 2:
got, exp = sum(len(v) for v in lv.values()), sch[1]
if got != exp:
want.append(("G3", f"{mark} bar count vs schedule", f"작도 {got}본 채택(좌우 대칭) vs D3 {exp}본 — 설계자 확인 대기", (f"{got}본", f"{exp}본")))
b9, b9n = (P2.get("footing_rebar") or {}).get("b9") or {}, (((E2.get("G3") or {}).get("schedule") or {}).get("B9") or [None, None])[1]
if b9.get("count") is not None and b9n is not None and b9["count"] != b9n:
gx, gy = b9.get("grid_x") or [], b9.get("grid_y") or []
want.append(("G3", "B9 bar count vs schedule", f"작도 {b9['count']}본({len(gx)}×{len(gy)} 엇갈림) 채택 vs D3 {b9n}본 — 설계자 확인 대기", (f"{b9['count']}본", f"{b9n}본")))
G4 = E2.get("G4") or {}
conv, Ls = G4.get("range_convention"), G4.get("L") or {}
for m, v in (G4.get("range") or {}).items():
if conv and isinstance(Ls, dict) and Ls.get(m) is not None and v[0] in conv:
c = conv[v[0]]
avg = 2 * (c["a"] if v[0] == "b_sched" else c["b"]) + (v[1] + v[2]) + c["c_times"] * c["c"]
if abs(avg - Ls[m]) > 1.0:
want.append(("G4", f"{m} schedule L reproduced by convention (max+min)/2",
f"재료표 {m} L {Ls[m] / 1000:.3f}이 규칙 (max+min)/2 = {avg / 1000:.3f}과 {abs(avg - Ls[m]):.0f} mm 다름 — 재료표 자체 불일치, 설계자 확인 대기", ()))
pf, notes = copy.deepcopy(E.get("planned_fail", [])), []
for gate, item, reason, nums in want:
i = next((k for k, x in enumerate(pf) if x.get("gate") == gate and (x.get("item") == item or (x.get("item_prefix") and item.startswith(x["item_prefix"])))), None)
entry = dict(gate=gate, item=item, reason=reason, approved=PENDING)
if i is None:
pf.append(entry)
notes.append(item.split(" ")[0] + " 추가")
elif not all(n in pf[i].get("reason", "") for n in nums):
pf[i] = entry # an entry inherited from another pier: the numbers do not fit this pier
notes.append(item.split(" ")[0] + " 숫자 갱신")
if notes:
out.append(dict(file="expected", path="planned_fail", old=E.get("planned_fail"), new=pf, inferred=True, group="기대값(expected) planned_fail",
why="계획된 FAIL 제안(" + ", ".join(notes) + ") — 작도를 채택하고 재료표와 다른 항목을 등록합니다. 승인은 사람이 합니다"))
return out
def stamp_pending(E, by, day):
"""A planned FAIL entry written from the reading screen carries PENDING; once the person has chosen and applied it, the choice is recorded in `approved`
(the report prints this text). Returns how many entries were stamped."""
n = 0
for x in E.get("planned_fail", []):
if x.get("approved") == PENDING:
x["approved"] = f"사용자 선택 {day} ({by or '-'}) — 도면에서 읽기 화면"
n += 1
return n
def normalize_per_bearing(P, E):
"""A value that differs per bearing (e.g. the sole plate length L: front row 700, rear row 800) cannot stay one number in expected.G5 -- the checker
accepts {no: value} (Ulsan 2026-10-13). Converts G5.L / G5.T when the pier's own table shows different values."""
tab = (P.get("sole_plates") or {}).get("table")
g5 = E.get("G5")
if not isinstance(tab, list) or not isinstance(g5, dict):
return
for k in ("L", "T"):
vals = {str(r["no"]): r[k] for r in tab if isinstance(r, dict) and k in r}
if vals and not isinstance(g5.get(k), dict) and len(set(vals.values())) > 1:
g5[k] = vals
def apply_selected(params, expected, changes, selected):
"""-> (params', expected') with the selected changes (indices into `changes`) written; the others stay as they are."""
P, E = copy.deepcopy(params), copy.deepcopy(expected)
for i in selected:
c = changes[i]
if c.get("row"):
reader.apply_proposals([c["row"]], P, E)
continue
doc = P if c["file"] == "params" else E
x = doc
keys = c["path"].split(".")
for k in keys[:-1]:
x = x[int(k)] if isinstance(x, list) else x[k]
x[int(keys[-1]) if isinstance(x, list) else keys[-1]] = copy.deepcopy(c["new"])
normalize_per_bearing(P, E)
return P, E
def frame_ok(texts, anchors_template, anchors_mod):
"""coverage of the template anchors' landmark texts in the drawing (0..1); the Ungok frame needs >= FRAME_MIN_COVERAGE."""
if not anchors_template:
return None
cand = anchors_mod.follow(anchors_template, texts)
return next(iter(cand.values()))["coverage"] if cand else 0.0
@@ -0,0 +1,85 @@
"""Stage order of the T-pier app and the Rhino step list for a build up to a given gate.
Gates follow the required work order: G1 shape -> G2 column rebar -> G3 footing rebar -> G4 coping rebar
-> G5 sole plates & bearings. A build "up to Gk" always rebuilds G1..Gk in a fresh document, so every
gate model is reproducible from params alone.
Per-pier options (2026-10-13, Ulsan P1 = second T-pier form):
params["scope"] gates modelled for this pier (default all); others are skipped ("범위 밖")
params["builders"] {gate: script} replacing the default builder (e.g. G1 -> concrete_tapered.py)
expected["checkers"] {gate: script} replacing the default checker (e.g. G1 -> g1_tapered.py)
"""
import json
from pathlib import Path
RHINO_DIR = Path(__file__).resolve().parent / "rhino"
GATES = [
("G1", "형상(콘크리트·말뚝·받침대좌·패드)", "concrete.py"),
("G2", "기둥 철근", "column_rebar.py"),
("G3", "확대기초 철근", "footing_rebar.py"),
("G4", "코핑 철근", "coping_rebar.py"),
("G5", "교량받침·솔플레이트", "bearings.py"),
]
CHECKERS = {"G1": "g1_shape.py", "G2": "g2_column_rebar.py", "G3": "g3_footing_rebar.py",
"G4": "g4_coping_rebar.py", "G5": "g5_bearings.py"}
# Object id prefixes created by each gate (after the pier id), used for negative tests and the
# "earlier gates unchanged" fingerprint comparison.
GATE_PREFIXES = {"G1": ["LC", "FT", "CL", "CP", "PL", "BS", "BP"], "G2": ["R-P", "RC-P"], "G3": ["R-B", "RC-B", "PT"],
"G4": ["R-C", "RC-C", "R-S", "RC-S"], "G5": ["BB", "SP"]}
# "PT": shaft cage plate (Ramp-A P1 G3, 2026-10-13). Ramp-A shaft cage bars carry "R-P1-1" etc. (marks P1-1 ...),
# so they fall under the G2 prefixes in the unchanged check; that is harmless (all earlier gates are compared)
REBAR_GATES = {"G2", "G3", "G4"}
TOLERANCE_MM = 0.001
def gate_ids():
return [g for g, _, _ in GATES]
def _json(path):
return json.loads(Path(path).read_text(encoding="utf-8")) if path and Path(path).exists() else {}
def scope_of(params_path):
"""Gates modelled for this pier (params["scope"], default all), in gate order."""
sc = _json(params_path).get("scope")
return [g for g in gate_ids() if not sc or g in sc]
def builder_of(params_path, gate):
default = {g: s for g, _, s in GATES}[gate]
return _json(params_path).get("builders", {}).get(gate, default)
def checker_of(expected_path, gate):
return _json(expected_path).get("checkers", {}).get(gate, CHECKERS[gate])
def build_steps(params_path, upto="G5"):
"""Rhino steps for G1..`upto`: builders, diameter colours after the last rebar stage, display state."""
ids = gate_ids()
if upto not in ids:
raise ValueError(f"unknown gate {upto}")
scope = scope_of(params_path)
chosen = [(g, n, builder_of(params_path, g)) for g, n, _ in GATES[: ids.index(upto) + 1] if g in scope]
glb = {"PARAMS_PATH": str(Path(params_path).resolve())}
steps = []
last_rebar = max((i for i, (g, _, _) in enumerate(chosen) if g in REBAR_GATES), default=None)
for i, (g, _, script) in enumerate(chosen):
steps.append({"name": f"{g}:{script}", "script": RHINO_DIR / script, "globals": dict(glb, GATE=g)})
if i == last_rebar:
steps.append({"name": f"{g}:rebar_colours.py", "script": RHINO_DIR / "rebar_colours.py", "globals": glb})
steps.append({"name": "display.py", "script": RHINO_DIR / "display.py", "globals": glb})
return steps
def check_steps(expected_path, out_dir, gates=None, tag=""):
"""Rhino steps running the gate checkers; each writes <out_dir>/<gate><tag>.json."""
steps = []
for g in gates or gate_ids():
chk = checker_of(expected_path, g)
steps.append({"name": f"{g}:{chk}", "script": RHINO_DIR / "checks" / chk,
"globals": {"EXPECTED_PATH": str(Path(expected_path).resolve()),
"RESULT_PATH": str(Path(out_dir).resolve() / f"{g}{tag}.json")}})
return steps
+460
View File
@@ -0,0 +1,460 @@
"""Drawing reader (semi-automatic): DWG -> DXF conversion, text index, text search and table candidates.
Values are never written into params/expected here; the reader only proposes candidates with their source
(drawing, layer, position) and the user confirms them in the web form.
Validated on one drawing set (Ungok P1 D1-D3): dimensions there are TEXT on dimension layers, not DIMENSION.
"""
import hashlib
import json
import re
import shutil
import subprocess
import tempfile
from collections import defaultdict
from pathlib import Path
import ezdxf
ODA = Path(r"C:\Program Files\ODA\ODAFileConverter 27.1.0\ODAFileConverter.exe")
EL_RE = re.compile(r"EL\.?\s*([0-9]{1,3}\.[0-9]{2,3})\b")
def sha256(path):
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
def convert(dwg_paths, out_dir):
"""Convert DWG files to DXF in out_dir (sources are copied first and never touched)."""
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
audit = []
with tempfile.TemporaryDirectory() as tmp:
tin, tout = Path(tmp, "in"), Path(tmp, "out")
tin.mkdir(), tout.mkdir()
for p in dwg_paths:
shutil.copy2(p, tin / Path(p).name)
subprocess.run([str(ODA), str(tin), str(tout), "ACAD2018", "DXF", "0", "1", "*.DWG"], check=True, timeout=600)
for p in dwg_paths:
produced = tout / (Path(p).stem + ".dxf")
if not produced.is_file():
raise RuntimeError(f"conversion failed: {Path(p).name}")
target = out_dir / produced.name
shutil.copy2(produced, target)
audit.append({"source": Path(p).name, "source_sha256": sha256(p), "dxf": target.name,
"dxf_sha256": sha256(target), "engine": "ODA File Converter 27.1.0"})
return audit
def _text(e):
return (e.plain_text() if e.dxftype() == "MTEXT" else e.dxf.text).strip()
def index_dxf(path, label):
"""All TEXT/MTEXT (incl. those inside block inserts) of one drawing with layer and position."""
doc = ezdxf.readfile(path)
msp = doc.modelspace()
out = []
for e in msp.query("TEXT MTEXT"):
p = e.dxf.insert
h = e.dxf.char_height if e.dxftype() == "MTEXT" else e.dxf.height
out.append({"dwg": label, "layer": e.dxf.layer, "x": round(p.x, 1), "y": round(p.y, 1),
"h": round(h or 0, 1), "text": _text(e), "handle": e.dxf.handle})
for ins in msp.query("INSERT"):
try:
for v in ins.virtual_entities():
if v.dxftype() in ("TEXT", "MTEXT"):
p = v.dxf.insert
out.append({"dwg": label, "layer": v.dxf.layer, "x": round(p.x, 1), "y": round(p.y, 1),
"h": 0, "text": _text(v), "handle": ins.dxf.handle, "block": ins.dxf.name})
except Exception: # unresolvable block (proxy etc.): its texts are simply not indexed
pass
return [t for t in out if t["text"]]
def search(texts, query, regex=False, limit=200):
if regex:
rx = re.compile(query, re.I)
hit = [t for t in texts if rx.search(t["text"])]
else:
q = query.lower()
hit = [t for t in texts if q in t["text"].lower()]
return hit[:limit]
def el_candidates(texts):
"""'EL. 26.208' style texts -> value in mm (Z = EL x 1000)."""
out = []
for t in texts:
m = EL_RE.search(t["text"].replace(" ", ""))
if m:
out.append(dict(t, value_mm=round(float(m.group(1)) * 1000, 1)))
return out
def table_below(texts, anchor, align="center", width=25000.0, depth=20000.0, stops=(), row_tol=0.6, stops_below=()):
# window scales with the title text height (25,000 x 20,000 at the Ungok title height 420; Ulsan 1:100 sheet
# has 700 and wider tables, 2026-10-13)
k = max(1.0, (anchor["h"] or 0) / 420.0)
width, depth = width * k, depth * k
"""Texts in a window below an anchor (table title) of the same drawing, grouped into rows/columns.
Window: `width` wide, centred on the title (align="center") or starting at it (align="left"), cut at the
nearest other table title on the right (`stops`), `depth` deep. Columns are then limited to the x range of
the header row (first row with >= 4 cells) plus one column gap, which drops neighbouring drawing texts.
Returns [{"y": .., "cells": [{"x", "text", "layer"}...]}], top row first.
"""
h = anchor["h"] or 150
x0, x1 = ((anchor["x"] - width / 2, anchor["x"] + width / 2) if align == "center"
else (anchor["x"] - 2 * h, anchor["x"] + width))
for s in stops:
if s["dwg"] == anchor["dwg"] and s is not anchor and abs(s["y"] - anchor["y"]) < 2 * h and anchor["x"] < s["x"] < x1:
x1 = s["x"] - 2 * h
y1, y0 = anchor["y"], anchor["y"] - depth
for s in list(stops) + list(stops_below): # another table title below in the same column ends this table (Ulsan SD400
if (s["dwg"] == anchor["dwg"] and s is not anchor and y0 < s["y"] < y1 - 2 * h # above SD500, 2026-10-13)
and abs(s["x"] - anchor["x"]) < 2 * h):
y0 = s["y"] + h
cells = [t for t in texts if t["dwg"] == anchor["dwg"] and x0 <= t["x"] <= x1 and y0 <= t["y"] < y1
and t is not anchor]
if not cells:
return []
tol = row_tol * (sorted(t["h"] for t in cells)[len(cells) // 2] or 150)
rows = []
for t in sorted(cells, key=lambda t: -t["y"]):
if rows and abs(rows[-1]["y"] - t["y"]) <= tol:
rows[-1]["cells"].append(t)
else:
rows.append({"y": t["y"], "cells": [t]})
for r in rows:
r["cells"] = sorted(r["cells"], key=lambda c: c["x"])
# header = first row with >= 4 cells whose first following row with a plain number cell (text-only rows =
# further header lines, Ulsan three-line header, are skipped) has at least half as many cells. On Ramp-A P1
# the labels of the detail drawing between the title and the table (C1 C3 C2 S1 S2, then '2,200' alone) were
# taken as the header (2026-10-13)
def numeric(r):
return any(re.fullmatch(r"-?[\d.,]+", c["text"].strip()) for c in r["cells"])
def next_data_ok(i):
# rows lying wholly outside the header's x span belong to a neighbouring table and are skipped (Okseong P1:
# ELEVATION TABLE rows interleave in the 솔플레이트 TABLE window, 2026-10-14)
hx = [c["x"] for c in rows[i]["cells"]]
pad = max([b - a for a, b in zip(sorted(hx), sorted(hx)[1:])] or [0])
inside = lambda r: any(min(hx) - pad <= c["x"] <= max(hx) + pad for c in r["cells"])
nxt = next((r for r in rows[i + 1:] if numeric(r) and inside(r)), None)
return nxt is not None and 2 * len(nxt["cells"]) >= len(rows[i]["cells"])
cand = [i for i, r in enumerate(rows) if len(r["cells"]) >= 4]
good = [i for i in cand if next_data_ok(i)]
head = rows[(good or cand)[0]] if cand else None
if head:
# multi-line header (Ulsan schedule, 2026-10-13: '길이'/'총길이' one line above '번호 형식 직경 개수 A..J',
# units '(M)' one line below): merge the text-only lines within 2 text heights below the first header line
# into it; unit lines (all cells in parentheses) and cells on top of an existing header cell are dropped
hh = sorted(t["h"] for t in cells)[len(cells) // 2] or 150
i = rows.index(head)
while i + 1 < len(rows) and head["y"] - rows[i + 1]["y"] <= 2 * hh:
nxt = rows[i + 1]
if any(re.fullmatch(r"[\d.,~%()°˚-]+", c["text"].strip()) for c in nxt["cells"]):
break
if not all(c["text"].strip().startswith("(") for c in nxt["cells"]):
for c in nxt["cells"]:
if all(abs(c["x"] - o["x"]) > hh for o in head["cells"]):
head["cells"].append(c)
head["cells"].sort(key=lambda c: c["x"])
rows.pop(i + 1)
xs = [c["x"] for c in head["cells"]]
# the header row may also hold the header of a neighbouring table on the same line (Ulsan: ELEVATION and
# 솔플레이트 TABLE side by side, 2026-10-13): split it at gaps > 3 x the median gap and keep the cluster
# nearest to the title
gaps = sorted(b - a for a, b in zip(xs, xs[1:]))
med = gaps[len(gaps) // 2] if gaps else 0
clusters, cur = [], [xs[0]]
for a_, b_ in zip(xs, xs[1:]):
if med and b_ - a_ > 3 * med:
clusters.append(cur)
cur = []
cur.append(b_)
clusters.append(cur)
xs = min(clusters, key=lambda cl: 0 if cl[0] <= anchor["x"] <= cl[-1] else min(abs(cl[0] - anchor["x"]), abs(cl[-1] - anchor["x"])))
gap = max([b - a for a, b in zip(xs, xs[1:])] or [med or 1000])
lo, hi = xs[0] - gap, xs[-1] + gap
rows = rows[rows.index(head):]
for r in rows:
r["cells"] = [c for c in r["cells"] if lo <= c["x"] <= hi]
rows = [r for r in rows if r["cells"]]
return [{"y": r["y"], "cells": [{"x": c["x"], "text": c["text"], "layer": c["layer"]} for c in r["cells"]]}
for r in rows]
TABLE_ANCHORS = [ # (key, title pattern, title alignment over the table)
("elevation", r"ELEVATION\s*TABLE", "center"),
("sole_plate", r"솔플레이트.*TABLE", "center"),
("schedule_SD400", r"^\(SD[0-9]00\)$", "left"),
]
def table_candidates(texts):
out = []
titles = {key: search(texts, pat, regex=True) for key, pat, _ in TABLE_ANCHORS}
for key, pat, align in TABLE_ANCHORS:
anchors = titles[key]
# a table title of another kind below in the same column also ends the window (Ramp-A P1: the ELEVATION
# TABLE window ran into the 솔플레이트 TABLE below it, 2026-10-13); on the right only titles of the same kind
below = [t for k_, ts in titles.items() if k_ != key for t in ts]
for a in anchors:
k = f"schedule_{a['text'].strip('()')}" if key.startswith("schedule") else key
out.append({"key": k, "anchor": a, "rows": table_below(texts, a, align, stops=anchors, stops_below=below)})
return out
def build_index(dxf_paths, labels, out_json):
texts = []
for p, lab in zip(dxf_paths, labels):
texts += index_dxf(p, lab)
Path(out_json).write_text(json.dumps(texts, ensure_ascii=False), encoding="utf-8")
return texts
def table_records(rows, key_pred):
"""Map cells of data rows to header columns (nearest header x within 0.6 x the gap to the neighbouring header
on the cell's side; blank cells stay absent). Data rows = rows whose first cell satisfies key_pred; that cell
is stored as "_key". Returns [(row y, {header: text})].
The tolerance is per column side, not the smallest gap of the table: a long range text such as '12.228~3.151'
in a wide column (Ungok P2 C12, 562 from the 'A' header, smallest gap 900) was dropped (OPEN #19 A3)."""
head = [(c["x"], re.sub(r"\s+", "", c["text"])) for c in rows[0]["cells"]]
xs = [x for x, _ in head]
gaps = [b - a for a, b in zip(xs, xs[1:])]
out = []
for r in rows[1:]:
if not r["cells"] or not key_pred(r["cells"][0]["text"]):
continue
rec = {"_key": r["cells"][0]["text"]}
for c in r["cells"][1:]:
i = min(range(len(head)), key=lambda j: abs(head[j][0] - c["x"]))
x, name = head[i]
side = gaps[i - 1] if c["x"] < x and i > 0 else gaps[min(i, len(gaps) - 1)] if gaps else 0
if abs(x - c["x"]) <= 0.6 * side and name not in rec:
rec[name] = c["text"]
out.append((r["y"], rec))
return out
def _num(s):
return float(s.replace(",", ""))
def _int(s):
"""Table integer; thousands separators allowed ('1,000' in the Ramp-A sole plate table, 2026-10-13)."""
return int(round(_num(s)))
def _m_to_mm(s):
v = _num(s)
return round(v * 1000, 1) if "." in s and v < 100 else v
MARK_RE = re.compile(r"^[A-Z]{1,2}\d{1,2}$")
EL_X = "EL.'X'"
def _get(d, path):
for k in path:
if isinstance(d, list) and isinstance(k, int) and 0 <= k < len(d): # list index (params table rows)
d = d[k]
continue
if not isinstance(d, dict) or k not in d:
return None
d = d[k]
return d
def proposals(cands, params, expected):
"""Candidate vs current value per field. Schedule rows are routed to the expected/params entries that
already list that mark (G2 P*, G3 B*, G4 C*/S*, params rebar_marks). Returns rows for the review table:
{"file": "expected"|"params", "path": [...], "current", "candidate", "src", "same"}."""
docs = {"expected": expected, "params": params}
rows = []
def row(file, path, cand, src):
cur = _get(docs[file], path)
if cur is None and file == "expected" and path[0] in ("G1", "G2", "G3", "G4", "G5"):
return # field not used by this pier's checks
if cur is None and file == "params":
return # only fields this pier's params already have
same = cur == cand or (isinstance(cur, (int, float)) and isinstance(cand, (int, float))
and abs(cur - cand) < 1e-6)
rows.append({"file": file, "path": path, "current": cur, "candidate": cand, "src": src, "same": same})
def params_rows(t, c):
"""The same bearing / sole plate table value as a separate params row (OPEN #19 A4): the user selects
it on its own, so a misread value does not change the model and its check value in one click."""
no = c.get("no") if c.get("no") is not None else (t[-1] if t[-1].isdigit() else None)
if no is None:
return
def index(path):
lst = _get(params, path)
return next((i for i, e in enumerate(lst) if isinstance(e, dict) and str(e.get("no")) == str(no)),
None) if isinstance(lst, list) else None
bi, si = index(["bearing_seats", "bearings"]), index(["sole_plates", "table"])
bp, sp = ["bearing_seats", "bearings", bi], ["sole_plates", "table", si]
if t[1:3] == ["G5", "el_x"] and bi is not None:
row("params", bp + ["el_x"], c["value"], c["src"])
elif t[1:4] == ["G1", "seats", "table"] and bi is not None:
for k, v in zip(("h1", "h2", "A", "B"), c["value"][1:]):
row("params", bp + [k], v, c["src"])
elif t[1] == "G5" and t[2] in ("S", "W", "L", "T", "h3") and si is not None:
if t[2] == "h3" and "h3" not in params["sole_plates"]["table"][si]:
if not any(r["file"] == "params" and r["path"] == ["sole_plates", "h3"]
and r["candidate"] == c["value"] for r in rows): # common h3 (Ungok): once per value
row("params", ["sole_plates", "h3"], c["value"], c["src"])
else:
row("params", sp + [t[2]], c["value"], c["src"])
for c in cands:
t = c["target"].split(".")
if t[0] == "expected":
path = t[1:]
if c.get("no") is not None and isinstance(_get(expected, path), dict):
path = path + [str(c["no"])] # per-bearing values (Ulsan G5 h3 / L / T, 2026-10-13)
row("expected", path, c["value"], c["src"])
params_rows(t, c)
elif t[0] == "schedule":
m, v = t[1], c["value"]
if m in (_get(expected, ["G2", "schedule_L"]) or {}):
row("expected", ["G2", "schedule_L", m], v["L"], c["src"])
row("expected", ["G2", "schedule_n", m], v["n"], c["src"])
if m in (_get(expected, ["G3", "schedule"]) or {}):
row("expected", ["G3", "schedule", m], [v["L"], v["n"]], c["src"])
if m in (_get(expected, ["G4", "count"]) or {}):
row("expected", ["G4", "count", m], v["n"], c["src"])
row("expected", ["G4", "L", m], v["L"], c["src"])
if m in (params.get("rebar_marks") or {}):
# a schedule without a shape column (Ramp-A D4, 2026-10-13) proposes the diameter only
shape = v["shape"] if v["shape"] is not None else (params["rebar_marks"][m] or {}).get("shape")
row("params", ["rebar_marks", m], {"dia": v["dia"], "shape": shape}, c["src"])
return rows
def apply_proposals(rows, params, expected):
"""Write the candidate value of each given proposal row into params / expected (in place)."""
docs = {"expected": expected, "params": params}
for r in rows:
d = docs[r["file"]]
for k in r["path"][:-1]:
d = d[k]
d[r["path"][-1]] = r["candidate"]
def field_candidates(tables):
"""Candidate values for params / expected fields from the recognised tables.
Returns [{"target": "expected.G5.el_x.1", "value": .., "src": "D2 ELEVATION TABLE NO.1 EL.'X' 43.625", "no": ..}].
`no` (bearing number) routes h3 / L / T to the per-bearing entry when expected holds them per bearing.
Only the tables validated on the Ungok P1 drawings are mapped; everything else stays manual.
"""
out = []
def add(target, value, src, no=None):
out.append({"target": target, "value": value, "src": src, "no": no})
for t in tables:
dwg = t["anchor"]["dwg"]
if not t["rows"]:
continue
if t["key"] == "elevation":
for _, r in table_records(t["rows"], lambda s: s.isdigit()):
no = r["_key"]
src = f"{dwg} ELEVATION TABLE NO.{no}"
if EL_X in r:
elx = round(_num(r[EL_X]) * 1000)
add(f"expected.G5.el_x.{no}", elx, f"{src} EL.'X' {r[EL_X]}")
if all(k in r for k in ("h1", "h2", "A", "B")):
add(f"expected.G1.seats.table.{no}", [elx, _int(r["h1"]), _int(r["h2"]), _int(r["A"]), _int(r["B"])],
f"{src} EL.'X', h1, h2, A, B")
if "h3" in r:
add("expected.G5.h3", _int(r["h3"]), f"{src} h3", no)
elif t["key"] == "sole_plate":
for _, r in table_records(t["rows"], lambda s: s.isdigit()):
no = r["_key"]
src = f"{dwg} 솔플레이트 상세 TABLE NO.{no}"
if "S(%)" in r:
add(f"expected.G5.S.{no}", _num(r["S(%)"]), f"{src} S(%)")
if "W" in r:
add(f"expected.G5.W.{no}", _int(r["W"]), f"{src} W")
for k in ("L", "T"):
if k in r:
add(f"expected.G5.{k}", _int(r[k]), f"{src} {k}", no)
elif t["key"].startswith("schedule"):
last_dia = None
for _, r in table_records(t["rows"], lambda s: bool(MARK_RE.match(s))):
m = r["_key"]
dia = r.get("직경", "")
dia = last_dia if dia in ('"', "") else dia
last_dia = dia
src = f"{dwg} 철근재료표 {t['anchor']['text']} {m}"
try:
L = _m_to_mm(r["길이"]) if "길이" in r else None
n = int(r["개수"]) if r.get("개수", "").isdigit() else None
if n is None and L and "총길이" in r:
n = round(_num(r["총길이"]) * 1000 / L) # quantity blank -> total / L (derived)
src += " (개수 = 총길이/길이 [역산])"
except ValueError:
# cells landed in the wrong column (e.g. a two-row header, Ulsan 2026-10-13): no candidate
continue
add(f"schedule.{m}", {"shape": r.get("형식"), "dia": dia, "n": n, "L": L}, src)
return out
def _pt(p):
return [round(p[0], 1), round(p[1], 1)]
def entity_info(dxf_path, handle):
"""One drawing object by handle (OPEN #19 A6: D1 outline values were read by handle with ad-hoc scripts).
Returns {"type", "layer", "handle", "values": {name: value}} or None. Coordinates are drawing (DXF) units."""
doc = ezdxf.readfile(dxf_path)
e = doc.entitydb.get(handle.strip().upper())
if e is None or not hasattr(e, "dxf"):
return None
t, d = e.dxftype(), e.dxf
v = {}
if t == "LINE":
v = {"start": _pt(d.start), "end": _pt(d.end), "length": round((d.end - d.start).magnitude, 1),
"dx": round(d.end.x - d.start.x, 1), "dy": round(d.end.y - d.start.y, 1)}
elif t in ("LWPOLYLINE", "POLYLINE"):
pts = [_pt(p) for p in (e.get_points("xy") if t == "LWPOLYLINE" else e.points())]
segs = [((b[0] - a[0]) ** 2 + (b[1] - a[1]) ** 2) ** 0.5 for a, b in zip(pts, pts[1:] + pts[:1] if e.is_closed else pts[1:])]
v = {"closed": e.is_closed, "vertices": len(pts), "points": pts[:50], "segment_lengths": [round(s, 1) for s in segs[:50]],
"length (straight segments)": round(sum(segs), 1)}
elif t in ("ARC", "CIRCLE"):
v = {"centre": _pt(d.center), "radius": round(d.radius, 1)}
if t == "ARC":
sweep = (d.end_angle - d.start_angle) % 360
v.update(start_angle=round(d.start_angle, 3), end_angle=round(d.end_angle, 3),
arc_length=round(d.radius * sweep * 3.141592653589793 / 180, 1),
start_point=_pt(e.start_point), end_point=_pt(e.end_point))
elif t in ("TEXT", "MTEXT"):
v = {"insert": _pt(d.insert), "text": _text(e)}
elif t == "INSERT":
v = {"block": d.name, "insert": _pt(d.insert), "rotation": d.get("rotation", 0),
"scale": [d.get("xscale", 1), d.get("yscale", 1)]}
elif t == "DIMENSION":
v = {"measurement": round(e.get_measurement(), 1) if hasattr(e, "get_measurement") else None,
"text": d.get("text", "")}
try:
from ezdxf import bbox
b = bbox.extents([e])
if b.has_data:
v["bbox"] = [_pt(b.extmin), _pt(b.extmax)]
v["bbox_size"] = [round(b.size.x, 1), round(b.size.y, 1)]
except Exception: # objects without geometry (e.g. an empty block) have no extents
pass
return {"type": t, "layer": d.get("layer", ""), "handle": d.handle, "values": v}
def el_list(texts):
"""EL texts for manual reference (no automatic field mapping: position decides the meaning)."""
return [{"dwg": t["dwg"], "layer": t["layer"], "x": t["x"], "y": t["y"], "text": t["text"],
"value_mm": t["value_mm"]} for t in el_candidates(texts)]
@@ -0,0 +1,96 @@
"""Template recommendation from a drawing set (S02, 2026-10-15): estimate the fit questions from the drawing texts and
rank the protected templates by the number of matching answers.
Only questions the texts can answer are estimated; the rest stay None ("판단 불가") and are answered by the user.
Estimates are proposals with their evidence, never confirmations. Rules were checked on 9 drawing sets only
(Ungok P1-P4, Ulsan P1-P2, Ramp-A P1, Okseong P1, Saejae-1 P1); weak ones are marked.
"""
import re
Q_PILE, Q_SOFFIT, Q_BEARING = "foundation", "soffit", "bearings"
QUESTIONS = [
(Q_PILE, "기초 형식(말뚝 유무)"),
(Q_SOFFIT, "코핑 하면 형상"),
(Q_BEARING, "받침 개수"),
]
R_RE = re.compile(r"(?:^|[\s(])R\s*=?\s*(\d[\d,]*(?:\.\d+)?)\s*$")
def profile(params):
"""Answers a template's params.json stands for."""
piles = "piles" in params or "pile" in params
cp = params.get("coping", {})
if "soffit_radius" in cp:
soffit = "곡선"
elif cp.get("form") == "y_shaft":
soffit = "나팔형"
else:
soffit = "직선 경사"
found = "말뚝만" if ("footing" not in params and piles) else ("기초판+말뚝" if piles else "기초판만")
return {Q_PILE: found, Q_SOFFIT: soffit, Q_BEARING: len(params.get("bearing_seats", {}).get("bearings", []))}
def _r_mm(text):
m = R_RE.search(text.strip())
if not m:
return None
s = m.group(1)
return round(float(s) * 1000) if re.fullmatch(r"\d+\.\d{3}", s) else round(float(s.replace(",", "")))
def _where(t):
return f"{t.get('dwg', '?')} {t.get('layer', '')} ({t['x']:.0f}, {t['y']:.0f}) '{t['text'][:40]}'"
def count_bearings(rows):
"""Largest run 1..n of integer first cells under the ELEVATION TABLE title."""
n = 0
for r in rows:
c = r["cells"][0]["text"].strip() if r.get("cells") else ""
if c.isdigit() and int(c) == n + 1:
n += 1
return n
def estimate(texts, table_rows=None):
"""{question: (answer or None, evidence str, confidence)}; table_rows = rows of the ELEVATION TABLE (reader.table_candidates)."""
out = {}
piles = [t for t in texts if "말뚝" in t["text"]]
cast = [t for t in piles if "현장타설말뚝" in t["text"]]
if cast:
out[Q_PILE] = ("말뚝만", "현장타설말뚝 문자: " + _where(cast[0]), "약함")
elif piles:
out[Q_PILE] = ("기초판+말뚝", f"'말뚝' 문자 {len(piles)}개: " + _where(piles[0]), "보통")
else:
out[Q_PILE] = ("기초판만", "'말뚝' 문자 없음", "보통")
rs = [(v, t) for t in texts if len(t["text"]) < 30 and (v := _r_mm(t["text"])) is not None]
big = [(v, t) for v, t in rs if v >= 10000]
mid = [(v, t) for v, t in rs if 1000 < v < 10000]
if big:
out[Q_SOFFIT] = ("곡선", f"R {big[0][0]} mm: " + _where(big[0][1]), "보통")
elif mid:
out[Q_SOFFIT] = ("나팔형", f"R {mid[0][0]} mm: " + _where(mid[0][1]), "약함")
else:
small = f"(R {rs[0][0]} mm는 평면 모서리 등으로 보고 제외)" if rs else ""
out[Q_SOFFIT] = ("직선 경사", "정면도 R 문자 없음 " + small, "약함")
n = count_bearings(table_rows) if table_rows else 0
out[Q_BEARING] = (n, f"ELEVATION TABLE 행 {n}개", "보통") if n else (None, "ELEVATION TABLE을 찾지 못함", "")
return out
def rank(est, profiles):
"""Templates ordered by the number of estimated answers they match: [{name, matched, asked, hits{q: bool}}]."""
asked = [q for q, _ in QUESTIONS if est.get(q, (None,))[0] is not None]
rows = []
for name, prof in profiles.items():
hits = {q: prof[q] == est[q][0] for q in asked}
rows.append({"name": name, "matched": sum(hits.values()), "asked": len(asked), "hits": hits, "profile": prof})
rows.sort(key=lambda r: -r["matched"])
return rows
def mismatches(answers, prof):
"""Questions whose answer differs from the template (strings compared; 'rebar_*' = '아니오' counts); empty = same form."""
out = [label for q, label in QUESTIONS if str(prof[q]) != str(answers.get(q, "")).strip()]
out += [label for key, label in (("rebar_shape", "철근 형상 종류"), ("rebar_method", "철근 생성 방식")) if answers.get(key) == "아니오"]
return out
+41
View File
@@ -0,0 +1,41 @@
"""Region image of a converted drawing (DXF) for the "도면 보기" links of the candidate tables (stage C, 2026-10-19).
The same drawing code as the reading aid Code/울산P1판독/dxftools.render, parameterised by a DXF path. Entities that touch the region are drawn
black on white; Korean text shows as boxes (no font mapping), numbers are readable.
"""
import ezdxf
from ezdxf import bbox
def _in_region(e, x0, y0, x1, y1):
try:
b = bbox.extents([e], fast=True)
except Exception:
return False
return b.has_data and b.extmax.x >= x0 and b.extmin.x <= x1 and b.extmax.y >= y0 and b.extmin.y <= y1
def render(dxf_path, cx, cy, half, out_png, px=1400, mark=True):
"""PNG of the square [cx-half, cx+half] x [cy-half, cy+half]; a ring marks the point (cx, cy)."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from ezdxf.addons.drawing import Frontend, RenderContext
from ezdxf.addons.drawing.config import BackgroundPolicy, ColorPolicy, Configuration
from ezdxf.addons.drawing.matplotlib import MatplotlibBackend
doc = ezdxf.readfile(dxf_path)
x0, x1, y0, y1 = cx - half, cx + half, cy - half, cy + half
keep = [e for e in doc.modelspace() if _in_region(e, x0, y0, x1, y1)]
fig = plt.figure(figsize=(px / 100, px / 100), dpi=100)
ax = fig.add_axes([0, 0, 1, 1])
cfg = Configuration(background_policy=BackgroundPolicy.WHITE, color_policy=ColorPolicy.BLACK)
Frontend(RenderContext(doc), MatplotlibBackend(ax), config=cfg).draw_entities(keep)
if mark:
ax.add_patch(plt.Circle((cx, cy), half * 0.04, fill=False, color="red", linewidth=2))
ax.set_xlim(x0, x1)
ax.set_ylim(y0, y1)
ax.set_aspect("equal")
ax.axis("off")
fig.savefig(out_png, facecolor="white")
plt.close(fig)
return out_png
+217
View File
@@ -0,0 +1,217 @@
"""Review report draft (Korean Markdown) from gate results, drawing tables and approval records.
Chapters follow the requirements review (기획 v0.1.01 §3.7): 개요, 오류·불일치, 개선방안, 추가 사항, 누락,
철근재료표(원본 vs 모델), 철근상세, plus 검증 방법. Verdict vocabulary is fixed (오류·불일치·누락·확인 필요);
automatic entries use only 불일치 (approved planned FAIL) and 확인 필요 (everything else). Final verdicts are
confirmed by a person, so the report is always issued as a draft.
"""
import json
import re
import time
from pathlib import Path
from engine import reader
from engine.pipeline import GATES, gate_ids
VOCAB = ("오류", "불일치", "누락", "확인 필요")
REBAR_GATES = {"G2": "기둥", "G3": "확대기초", "G4": "코핑"}
def _rj(p, default=None):
p = Path(p)
return json.loads(p.read_text(encoding="utf-8")) if p.exists() else default
def _cell(v):
return str(v).replace("|", "/").replace("\n", " ")
def _table(head, rows):
out = ["| " + " | ".join(head) + " |", "|" + "|".join(" :--- " for _ in head) + "|"]
out += ["| " + " | ".join(_cell(c) for c in r) + " |" for r in rows]
return out
def collect(pier_dir, run_dir):
pier_dir, run_dir = Path(pier_dir), Path(run_dir)
d = {"info": _rj(pier_dir / "pier.json", {"name": pier_dir.name, "type": "T형"}),
"audit": _rj(pier_dir / "drawings" / "audit.json", []),
"approvals": _rj(run_dir / "approvals.json", {}),
"expected": _rj(pier_dir / "expected.json", {}), "params": _rj(pier_dir / "params.json", {}),
"gates": {}, "results": {}, "autocheck": _rj(pier_dir / "autocheck.json", {}), "confirm": _rj(pier_dir / "confirm.json", {})}
for g in gate_ids():
d["gates"][g] = _rj(run_dir / f"{g}_gate.json")
d["results"][g] = _rj(run_dir / f"{g}.json")
texts = _rj(pier_dir / "drawings" / "texts.json", [])
tables = reader.table_candidates(texts) if texts else []
cands = reader.field_candidates(tables)
d["schedule"] = [c for c in cands if c["target"].startswith("schedule.")]
d["diffs"] = [r for r in reader.proposals(cands, d["params"], d["expected"]) if not r["same"]] if cands else []
return d
def _n(v):
"""Number for a table cell: '-' when missing, no trailing .0 for integral values."""
if v is None:
return "-"
return int(v) if isinstance(v, float) and v.is_integer() else v
def _bar_counts(results):
"""Model bar count per mark from the checker results (items '<mark> bar count...')."""
out = {}
for g in REBAR_GATES:
for r in (results.get(g) or {}).get("results", []):
m = re.match(r"^(\S+) bar count", r["item"])
if m and "actual" in r:
out[m.group(1)] = (int(r["actual"]), int(r["expected"]), r["result"], g)
return out
def build(pier_dir, run_dir, author="", today=None, stale=(), out_of_scope=()):
"""stale: gates whose result predates the current inputs (shown as void, not counted as done)."""
d = collect(pier_dir, run_dir)
for g in stale:
d["gates"][g] = None
d["results"][g] = None
today = today or time.strftime("%Y-%m-%d")
name, gates, appr = d["info"]["name"], d["gates"], d["approvals"]
done = [g for g in gate_ids() if gates[g]]
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)
L = [f"# {name} 교각 모델링 검토보고서 (초안)", "",
f"> 앱이 게이트 결과·도면 표·승인 기록으로 자동 작성한 **초안**입니다. '오류'·'불일치' 판정과 3~5장의 서술은 "
f"담당자가 확인·확정해야 합니다. 작성일 {today}{' · 작성 ' + author if author else ''}.", ""]
# 1 overview
L += ["## 1. 개요", "", f"- 교각: {name} ({d['info'].get('type', 'T형')}) · 좌표: 로컬(교각 중심 원점, X 교축직각, Y 교축, "
f"Z = EL×1000, mm) · 산출: .3dm", f"- 게이트 진행: {len(done)}/{5 - len(out_of_scope)} 실행{(' (범위 밖: ' + ', '.join(out_of_scope) + ')') if out_of_scope else ''}, "
+ ("**전 게이트 PASS·승인**" if all_ok else "**미완료 — 실행·승인되지 않은 게이트가 있음**"), ""]
if d["audit"]:
L += ["### 1.1 입력 도면", ""] + _table(["구분", "DWG", "SHA-256(앞 16자)"],
[[a.get("label", ""), a["source"], a["source_sha256"][:16]] for a in d["audit"]]) + [""]
rows = []
for g, gname, _ in GATES:
r, a = gates[g], appr.get(g, {})
if not r:
rows.append([g, gname, "범위 밖" if g in out_of_scope else
("입력 변경 — 무효(재실행 필요)" if g in stale else "미실행"), "-", "-", "-"])
continue
s = r["summary"]
rows.append([g, gname, r["verdict"], f"{s['passed']}/{s['total']}",
f"{a.get('decision', '-')} {a.get('by', '')} {a.get('at', '')}".strip(), a.get("note", "") or "-"])
L += ["### 1.2 게이트 결과", ""] + _table(["게이트", "내용", "판정", "검사 PASS", "승인", "의견"], rows) + [""]
# 2 errors / mismatches
finds = []
for g in done:
for pf in gates[g]["planned_fails"]:
ex = next((x for x in d["expected"].get("planned_fail", []) if x["gate"] == g and
(x.get("item") == pf["item"] or (x.get("item_prefix") and pf["item"].startswith(x["item_prefix"])))), {})
finds.append(["불일치", g, pf["item"], pf["reason"], f"예외 승인: {ex.get('approved', '-')}"])
for item in gates[g]["unplanned_fails"]:
finds.append(["확인 필요", g, item, "계획되지 않은 FAIL", "게이트 미승인 사유"])
for r in d["diffs"]:
finds.append(["확인 필요", "-", ".".join([r["file"]] + r["path"]),
f"도면 표 값 {json.dumps(r['candidate'], ensure_ascii=False)} ≠ 입력값 {json.dumps(r['current'], ensure_ascii=False)}",
r["src"]])
L += ["## 2. 도면 오류·불일치", "",
"자동 등재 기준: 승인된 계획 FAIL → '불일치', 계획되지 않은 FAIL과 도면 표 값·입력값 차이 → '확인 필요'.", ""]
L += (_table(["판정", "게이트", "항목", "내용", "근거"], finds) if finds else ["자동으로 찾은 항목이 없습니다."]) + [""]
# 3-5 human chapters with automatic hints
L += ["## 3. 개선방안", "", "[담당자 작성] 2장 항목별로 도면 수정·설계자 확인 방안을 적습니다.", "",
"## 4. 추가되어야 할 사항", "", "[담당자 작성] 도면에 없어 추정·가정으로 모델링한 사항(입력 묶음의 `[추론]` 표기)을 정리합니다.", ""]
inferred = sorted({k for k, v in d["params"].items() if "[추론]" in json.dumps(v, ensure_ascii=False)})
if inferred:
L += [f"- 자동 확인: `[추론]` 표기가 있는 입력 묶음 — {', '.join(inferred)}", ""]
L += ["## 5. 누락사항", "", "[담당자 작성] 도면에서 빠진 치수·표·상세를 적습니다.", ""]
nomodel = [c["target"][9:] for c in d["schedule"] if c["target"][9:] not in d["params"].get("rebar_marks", {})]
if nomodel:
L += [f"- 자동 확인: 재료표에 있으나 모델 대상이 아닌 마크 — {', '.join(sorted(set(nomodel)))} (범위 결정 확인)", ""]
# 6 schedule vs model
bc = _bar_counts(d["results"])
rows, seen = [], set()
for c in d["schedule"]:
m, v = c["target"][9:], c["value"]
if m in seen:
continue
seen.add(m)
mc = bc.get(m)
if m not in d["params"].get("rebar_marks", {}):
model, judge = "모델 외", "-"
elif not mc:
model, judge = "미검사", "-" # its rebar gate has not run (or is void)
else:
model, judge = mc[0], ("일치" if mc[0] == v["n"] else "불일치")
rows.append([m, v["shape"] or "-", v["dia"] or "-", _n(v["n"]), _n(v["L"]), model, judge, c["src"]])
L += ["## 6. 철근재료표 (원본 vs 모델)", "",
"원본 = 도면 재료표 판독값(동일 기호 \" 해석 포함), 모델 = 게이트 검사의 본수 실측값.", ""]
L += (_table(["마크", "형식", "직경", "재료표 수량", "재료표 L(mm)", "모델 본수", "대조", "근거"], rows)
if rows else ["도면 재료표를 인식하지 못했습니다(도면 미등록 또는 표 형식 상이)."]) + [""]
# 7 rebar detail (records)
L += ["## 7. 철근상세 (기록)", "", "원길이·곡선길이·피복은 판정이 아닌 기록 값입니다(요구사항 §6.2).", ""]
for g, part in REBAR_GATES.items():
res = d["results"].get(g)
if not res:
continue
recs = [[r["item"], r.get("detail", "")] for r in res["results"]
if r["result"] == "INFO" and ("length" in r["item"] or "cover" in r["item"])]
L += [f"### 7.{list(REBAR_GATES).index(g) + 1} {part} ({g})", ""] + _table(["항목", "값"], recs) + [""]
# 8 verification method
rows = []
for g in done:
r = gates[g]
n = r["negative"]
ok = lambda v, yes, no: "생략(일괄 실행)" if v is None else (yes if v else no)
rows.append([g, ok(r["reload"]["ok"], "일치", "불일치"),
" / ".join(f"{'수평' if k == 'negH' else '수직'} {'검출' if v['detected'] else '미검출'}" for k, v in n.items()),
ok(r["unchanged"]["ok"], f"불변 ({r['unchanged']['compared']})", f"변경 ({r['unchanged']['compared']})")])
L += ["## 8. 검증 방법", "",
"- 검사기는 생성 입력(params)이 아니라 도면 문자·표에서 옮긴 독립 기대값(expected)만 읽습니다.",
"- 게이트마다 G1부터 새로 생성 → 저장 파일을 다시 열어 검사(재로드) → 객체를 수평·수직으로 옮겨 FAIL이 나는지 확인(음성 시험) → 앞 게이트 객체 지문 비교.",
"- 일괄 실행(단일 Rhino 세션)으로 검사한 게이트는 중간 저장·재로드와 앞 게이트 비교를 하지 않으며, 표에 '생략(일괄 실행)'으로 적습니다. 모델은 마지막 G5만 저장합니다.",
""] + _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) + [""]
L += ["## 변경 이력", "", "| 버전 | 일자 | 내용 |", "| :--- | :--- | :--- |", f"| v0.1.00 | {today} | 앱 자동 생성 초안 |", ""]
return "\n".join(L), d
def check(md, d):
"""Report self-check (pattern of check_report.py): chapters, vocabulary, schedule rows, gate table."""
need = ["## 1. 개요", "## 2. 도면 오류·불일치", "## 3. 개선방안", "## 4. 추가되어야 할 사항", "## 5. 누락사항",
"## 6. 철근재료표", "## 7. 철근상세", "## 8. 검증 방법"]
res = {"chapters_missing": [h for h in need if h not in md]}
sec2 = md.split("## 2.")[1].split("## 3.")[0]
words = re.findall(r"^\| ([^|]+?) \| (?:G\d|-) \|", sec2, re.M)
res["vocab_bad"] = [w for w in words if w not in VOCAB]
res["finding_rows"] = len(words)
res["finding_rows_without_source"] = len([l for l in sec2.splitlines() if re.match(r"^\| (오류|불일치|누락|확인 필요) \|", l)
and l.rstrip().endswith("| |")])
marks = {c["target"][9:] for c in d["schedule"]}
sec6 = md.split("## 6.")[1].split("## 7.")[0]
res["schedule_rows"] = len([l for l in sec6.splitlines() if re.match(r"^\| [A-Z]{1,2}\d", l)])
res["schedule_marks"] = len(marks)
res["gate_rows"] = len(re.findall(r"^\| G\d \|", md.split("### 1.2")[1].split("## 2.")[0], re.M)) if "### 1.2" in md else 0
# a pier with rebar marks must have its schedule read (Ulsan 2026-10-13: 0 of 0 rows passed silently)
res["schedule_unread"] = bool(d["params"].get("rebar_marks")) and not marks
res["ok"] = (not res["chapters_missing"] and not res["vocab_bad"] and res["schedule_rows"] == res["schedule_marks"]
and not res["schedule_unread"] and res["gate_rows"] == 5 and res["finding_rows_without_source"] == 0)
return res
@@ -0,0 +1,28 @@
"""Write the review report draft (.md canonical + .pdf rendition + self-check json) into a run folder.
The PDF renderer is the one used for the Ungok P1 review report (Code/교각모델링/md_to_pdf.py: Malgun Gothic,
U+2212 replaced, column min width = longest token); it is imported, not copied.
"""
import json
import sys
import time
from pathlib import Path
from engine import report
LEGACY = Path(__file__).resolve().parents[2] / "교각모델링"
def write(pier_dir, run_dir, author="", stale=(), out_of_scope=()):
sys.path.insert(0, str(LEGACY))
import md_to_pdf # noqa: E402 (legacy renderer)
today = time.strftime("%Y-%m-%d")
md, d = report.build(pier_dir, run_dir, author, today, stale, out_of_scope)
stem = f"보고서_{d['info']['name']}교각모델링검토_{today}_v0.1.00"
run_dir = Path(run_dir)
md_path, pdf_path = run_dir / f"{stem}.md", run_dir / f"{stem}.pdf"
md_path.write_text(md, encoding="utf-8")
md_to_pdf.build(md, pdf_path, f"{d['info']['name']} 교각 모델링 검토보고서 (초안)")
chk = report.check(md, d)
(run_dir / "report_check.json").write_text(json.dumps(chk, ensure_ascii=False, indent=1), encoding="utf-8")
return {"md": md_path, "pdf": pdf_path, "check": chk}
+93
View File
@@ -0,0 +1,93 @@
"""Review summary for the one-screen confirmation (2026-10-20): what changed in a bundle, where its values came from, and the reason text the app
writes into the confirmation record. Pure functions (no I/O); the web layer (web/app.py `review`) supplies the data.
Why: confirming 20+ bundles one by one took too long, the reasons written under time pressure were empty ('12', 'asf'), and it was hard to see what
changed. The confirmation stays a HUMAN act (nothing is confirmed without the person's ticks and name); the app only prepares what the person looks at
and writes an informative reason from facts it knows (기획서 4.3 / 6.9).
"""
import json
from engine import verify
MAX_EXAMPLES = 3
def _short(v):
s = json.dumps(v, ensure_ascii=False) if not isinstance(v, (int, float, str)) else str(v)
return s if len(s) <= 24 else s[:21] + "…"
def bundle_change(value, origin_value):
"""Change of a bundle against the pier's creation snapshot: {mode, n_changed, n_total, examples:[(path, old, new)], text}.
mode 'none' when there is no snapshot (older piers), 'same' when nothing changed."""
if origin_value is None:
return {"mode": "none", "n_changed": None, "n_total": None, "examples": [], "text": "생성 시점 값 기록이 없어 무엇이 바뀌었는지 비교하지 못함"}
old, new = dict(verify._walk(origin_value)), dict(verify._walk(value))
paths = sorted(set(old) | set(new))
diff = [p for p in paths if old.get(p) != new.get(p)]
if not diff:
return {"mode": "same", "n_changed": 0, "n_total": len(paths), "examples": [], "text": f"생성 시점 값 그대로({len(paths)}개 값)"}
ex = [(".".join(p), old.get(p), new.get(p)) for p in diff[:MAX_EXAMPLES]]
txt = f"생성 시점 대비 {len(diff)}/{len(paths)}개 값이 바뀜 — " + "; ".join(f"{p}: {_short(o)} → {_short(n)}" for p, o, n in ex) + (" …" if len(diff) > MAX_EXAMPLES else "")
return {"mode": "changed", "n_changed": len(diff), "n_total": len(paths), "examples": ex, "text": txt}
def provenance(which, key, read_log=None, anchor_log=None, candidates_log=None):
"""Facts about where values of the bundle were written from (tools of the app) -> list of short sentences."""
out = []
pre = f"{which}:{key}."
for e in read_log or []:
n = sum(1 for f in e.get("fields", []) if f.startswith(pre))
if n:
out.append(f"도면에서 읽기 {n}건 반영 ({e.get('at', '')[:16]} {e.get('by') or '-'})")
for e in anchor_log or []:
n = sum(1 for f in e.get("fields", []) if isinstance(f, dict) and f.get("field", "").startswith(pre))
if n:
out.append(f"표 밖 치수 후보 {n}건 반영 ({e.get('at', '')[:16]})")
return out
def not_checked(checks, st=None):
"""What the app did NOT verify, one sentence each: the checks that are 근거 없음 / 주의 / 불일치 (id + text); when none of the checks says so
(a status that comes from 'counts only' or from the lack of any evidence) the general statement."""
out = [f"{c['id']} {c['text']}" for c in checks if c["st"] in ("n", "w", "b")]
if not out and st == "w":
out = ["수량만 확인했고 위치·좌표·치수는 도면과 직접 대조하지 못함"]
if not out and st == "n":
out = ["앱이 독립적으로 대조할 근거가 없음 — 도면과 직접 대조"]
return out
def flags(item):
"""Things the person should know before ticking a bundle in bulk (shown as chips, written into the reason): the bundle is still the template's
original, or it carries a provisional registration that waits for the designer."""
out = []
if item["change"]["mode"] == "same" and item["st"] in ("n", "w"):
out.append("템플릿 원본 그대로(새 도면 근거 없음)")
if any("설계자 확인" in d for d in item.get("detail") or []):
out.append("설계자 확인 전 임시 등록 포함")
return out
def group_of(st):
"""review group: A 일치, B 주의·근거 없음, D 사람 결정 기록 / 참고, C 불일치 (cannot be confirmed)"""
return {"g": "A", "w": "B", "n": "B", "x": "D", "b": "C"}.get(st, "B")
def auto_reason(item, note=""):
"""The reason text written into the confirmation record: facts the app knows + the person's note. Never empty."""
st = item["st"]
head = "[검토 요약 일괄 확정] "
if st == "g":
why = head + f"앱 확인 일치 — {item['summary']}"
else:
why = head + f"사용자가 도면과 대조했다고 표시함. 앱 확인 {verify.ST_NAME.get(st, st)} — {item['summary']}"
if item["change"]["mode"] in ("changed", "same"):
why += f" | 변경: {item['change']['text']}"
if item["prov"]:
why += " | 출처: " + "; ".join(item["prov"])
if item.get("flags"):
why += " | 주의 표시: " + "; ".join(item["flags"])
if note.strip():
why += f" | 의견: {note.strip()}"
return why[:900]
@@ -0,0 +1,110 @@
"""G5 stage: bridge bearings (black boxes) and sole plates (wedges) on each h2 pad.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after concrete.py.
Ported from Code/교각모델링/build_bearing_plates_rhino.py (Ungok P1, model v0.1.09). Rules: 설계서 v0.1.10 §8. Reads params.json `bearing_seats`,
`sole_plates`, `coping`. Bottom of the bearing = modelled pad top (= coping step + h1 + h2 = EL.'X' within 1 mm).
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
import System
from Rhino.DocObjects import ObjectAttributes, ObjectColorSource
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
H3 = P["sole_plates"].get("h3") # D2 ELEVATION TABLE h3 (common value; a table row may override)
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def corners(cx, cy, along, across, rot_deg):
"""Plan corners, `along` on +Y rotated clockwise by rot_deg; order: (-a,-c), (+a,-c), (+a,+c), (-a,+c)."""
t = math.radians(rot_deg)
ua, uc = (math.sin(t), math.cos(t)), (math.cos(t), -math.sin(t))
out = []
for sa, sc in ((-1, -1), (1, -1), (1, 1), (-1, 1)):
out.append((cx + sa * ua[0] * along / 2 + sc * uc[0] * across / 2,
cy + sa * ua[1] * along / 2 + sc * uc[1] * across / 2, sa * along / 2))
return out
def hexa(bottom, top):
pts = [rg.Point3d(*p) for p in bottom + top]
b = rg.Brep.CreateFromBox(pts)
if b is None or not b.IsSolid:
raise RuntimeError("hexahedron failed")
if b.SolidOrientation == rg.BrepSolidOrientation.Inward:
b.Flip()
return b
def add(brep, key, oid, color, props):
attr = ObjectAttributes()
attr.LayerIndex = layer(key, color)
attr.Name = oid
attr.ObjectColor = Color.FromArgb(*color)
attr.ColorSource = ObjectColorSource.ColorFromObject
attr.SetUserString("id", oid)
for k, v in props.items():
attr.SetUserString(k, str(v))
if doc.Objects.AddBrep(brep, attr) == System.Guid.Empty:
raise RuntimeError(f"{oid}: AddBrep failed")
def main():
for o in list(doc.Objects):
if (o.Attributes.GetUserString("id") or "").startswith((f"{PID}-BB", f"{PID}-SP")):
doc.Objects.Delete(o, True)
sp = P["sole_plates"]
if not sp["table"]: # 2026-10-13: an empty table silently produced no bearings
raise RuntimeError("sole_plates.table is empty")
steps = P["coping"].get("top_steps")
bear = {b["no"]: b for b in P["bearing_seats"]["bearings"]}
for r in sp["table"]:
b = bear[r["no"]]
cx, cy = b["centre"]
rot = b["rotation_deg"]
seat = b["seat_z"] if "seat_z" in b else steps[b["girder_line"] - 1]["z"] # seat_z: tapered form (Ulsan)
z0 = seat + b["h1"] + b["h2"] # modelled pad top
h3 = r.get("h3", H3) # per bearing when tabled
if sp.get("bearing_size") == "AB": # bearing body A (along) x B (across) (Ulsan detail drawing)
along, across, rule = b["A"], b["B"], "A x B (교량받침 상세)"
else: # (L-100) x (W-100) per user 2026-10-08 (Ungok)
along, across, rule = r["L"] - sp["bearing_shrink"], r["W"] - sp["bearing_shrink"], None
cb = corners(cx, cy, along, across, rot)
add(hexa([(x, y, z0) for x, y, _ in cb], [(x, y, z0 + h3) for x, y, _ in cb]),
f"{PID}::교량받침", f"{PID}-BB{r['no']:02d}", tuple(sp["colours"]["bearing"]),
dict(role="bridge bearing", no=r["no"], size=f"{along}x{across}x{h3}", el_x=b["el_x"],
src=f"ELEVATION TABLE h3, {rule}" if rule else "D2 ELEVATION TABLE h3, (L-100)x(W-100) per user 2026-10-08"))
# sole plate: bottom flat, top sloped S% along the pad long axis, centre thickness T (sign of S sets thick end)
zb = z0 + h3
cs = corners(cx, cy, r["L"], r["W"], rot)
top = [(x, y, zb + r["T"] + r["S"] / 100 * s) for x, y, s in cs]
add(hexa([(x, y, zb) for x, y, _ in cs], top), f"{PID}::솔플레이트", f"{PID}-SP{r['no']:02d}",
tuple(sp["colours"]["sole_plate"]),
dict(role="sole plate", no=r["no"], S=r["S"], L=r["L"], W=r["W"], T=r["T"],
end_thick=f"{r['T'] - r['S'] / 100 * r['L'] / 2:.2f}/{r['T'] + r['S'] / 100 * r['L'] / 2:.2f}",
src=sp.get("sole_plate_src", "D2 솔플레이트 상세 TABLE; slope along pad axis, thick end -Y [HITL]")))
doc.Views.Redraw()
print(json.dumps({"bearings": len(sp["table"]), "sole_plates": len(sp["table"])}))
main()
@@ -0,0 +1,62 @@
"""Shared helpers for the gate checkers (imported inside Rhino 8).
Checkers read only EXPECTED_PATH (independent drawing values) and never the generator params.
"""
import json
from Rhino.DocObjects import ObjectEnumeratorSettings
def load_expected(path):
return json.load(open(path, encoding="utf-8"))
def all_objects(doc):
"""All live objects incl. hidden/locked (doc.Objects alone skips hidden ones)."""
s = ObjectEnumeratorSettings()
s.HiddenObjects = True
s.LockedObjects = True
return list(doc.Objects.GetObjectList(s))
def chain(spec, base=0.0):
"""Expand a dimension chain: start offsets (cumulative) then groups [[n, pitch], ...] from the last point."""
pts, z = [], base
for s in spec["start"]:
z += s
pts.append(z)
for n, p in spec["groups"]:
for _ in range(n):
z += p
pts.append(z)
return pts
def series(spec):
"""Expand {start, pitch, n} (or a literal list) into a list of values."""
if isinstance(spec, list):
return list(spec)
return [spec["start"] + spec["pitch"] * i for i in range(spec["n"])]
def row(spec):
"""Bar positions: two end bars plus a regular series."""
return sorted(list(spec["ends"]) + series(spec))
def ychain(spec, half):
"""Section chain measured from the -Y face: listed first bars (absolute), then n x pitch from the last of
them, then the last bar at `end`; returned relative to the section centre (minus `half`)."""
pts = list(spec["start"])
pts += [pts[-1] + spec["pitch"] * k for k in range(1, spec["n"] + 1)] + [spec["end"]]
return [y - half for y in sorted(pts)]
def write_result(path, results, extra=None):
summ = dict(total=len([r for r in results if r["result"] != "INFO"]),
passed=sum(r["result"] == "PASS" for r in results),
failed=sum(r["result"] == "FAIL" for r in results))
summ.update(extra or {})
json.dump(dict(summary=summ, results=results), open(path, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(json.dumps(summ, ensure_ascii=False))
return summ
@@ -0,0 +1,217 @@
"""G1 gate checker, form "tapered T with shared pedestals": piles and plan skew optional (unified 2026-10-15 from g1_skew.py,
Okseong P1, and this file, Saejae-1 P1): lean, footing, column, coping (straight soffit, stepped top, side taper), shared pedestals
with a sloped top, pads. G1 expected "piles" absent = spread footing (no pile checks); coping.skew_deg 0 = rectangular plan.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G1) come from dimension / EL texts and
the bearing tables; the skew angle and the pedestal outlines are drawn values ([작도]). Generator params are not read.
Checks: C5 population, C2 closed solids, C1 extents (lean, footing, column, piles), C4 coping (front section at Y=0,
side width at X=0, skewed end faces), C3 joints / overlaps, C6 volumes, C7 pedestals / pads.
"""
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G1"]
F, LN, CO, C, PI = G["footing"], G["lean"], G["column"], G["coping"], G.get("piles")
FT_TOP = F["z0"] + F["thickness"]
ZB = FT_TOP + CO["height"] # column top = coping bottom
ZT = ZB + C["soffit_rise"]
FLAT, TIP, W, WB = C["bottom_flat_half"], C["tip_half"], C["width_y"] / 2, C["side_bottom_width"] / 2
K = math.tan(math.radians(C["skew_deg"])) # [작도]
EDGES = [-TIP] + C["step_bounds"] + [TIP] # top step boundaries at Y = 0 (front dimension chain)
STEPS = C["step_z"] # step levels (EL.'X' - h1 - h2 of the bearing rows)
results = []
def objs(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def brep(o):
return o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry)
def vol(b):
return rg.VolumeMassProperties.Compute(b).Volume
def bbox(b):
return b.GetBoundingBox(True)
def check(cid, name, expected, actual, tol=TOL):
results.append(dict(check=cid, item=name, expected=round(expected, 3), actual=round(actual, 3),
diff=round(actual - expected, 3), tol=tol, result="PASS" if abs(actual - expected) <= tol else "FAIL"))
def flag(cid, name, ok, detail=""):
results.append(dict(check=cid, item=name, detail=detail, result="PASS" if ok else "FAIL"))
def top_z(x):
for a, b, z in zip(EDGES, EDGES[1:], STEPS):
if a <= x <= b:
return z
return None
def bottom_z(x):
ax = abs(x)
return ZB if ax <= FLAT else ZB + C["soffit_rise"] * (ax - FLAT) / (TIP - FLAT)
def coping_volume_expected():
"""Shear keeps the volume: integrate the unsheared plan area (front x extent x side width) over z."""
total, n = 0.0, 20000
z1 = max(STEPS)
dz = (z1 - ZB) / n
for i in range(n):
z = ZB + (i + 0.5) * dz
if z < ZT:
xh = FLAT + (TIP - FLAT) * (z - ZB) / (ZT - ZB)
yh = WB + (W - WB) * (z - ZB) / (ZT - ZB)
total += 2 * xh * 2 * yh * dz
else:
length = sum(b - a for a, b, s in zip(EDGES, EDGES[1:], STEPS) if s >= z)
total += length * 2 * W * dz
return total
def inter_vol(a, b):
r = rg.Brep.CreateBooleanIntersection(a, b, 0.01)
return sum(vol(x) for x in r) if r else 0.0
def main():
ids = {k: f"{PID}-{k}" for k in ("LC", "FT", "CL", "CP", "PL")}
counts = {k: len(objs(v)) for k, v in ids.items()}
flag("C5", "object counts", counts == {"LC": 1, "FT": 1, "CL": 1, "CP": 1, "PL": PI["nx"] * PI["ny"] if PI else 0}, str(counts))
lc, ft, cl, cp = (brep(objs(ids[k])[0]) for k in ("LC", "FT", "CL", "CP"))
piles = [brep(o) for o in objs(ids["PL"])]
allb = [lc, ft, cl, cp] + piles
flag("C2", f"IsSolid all {len(allb)}", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{len(allb)}")
# C1 extents
for key, b, dx, dy, z0, z1 in (("footing", ft, F["dx"], F["dy"], F["z0"], FT_TOP),
("lean", lc, LN["dx"], LN["dy"], F["z0"] - LN["thickness"], F["z0"])):
bb = bbox(b)
check("C1", f"{key} X", dx, bb.Max.X - bb.Min.X)
check("C1", f"{key} Y", dy, bb.Max.Y - bb.Min.Y)
check("C1", f"{key} Zbot", z0, bb.Min.Z)
check("C1", f"{key} Ztop", z1, bb.Max.Z)
check("C1", f"{key} centre X", 0, (bb.Max.X + bb.Min.X) / 2)
check("C1", f"{key} centre Y", 0, (bb.Max.Y + bb.Min.Y) / 2)
bb = bbox(cl)
check("C1", "column diameter X", CO["d"], bb.Max.X - bb.Min.X)
check("C1", "column diameter Y", CO["d"], bb.Max.Y - bb.Min.Y)
check("C1", "column Zbot", FT_TOP, bb.Min.Z)
check("C1", "column Ztop", ZB, bb.Max.Z)
xs = sorted({round((bbox(b).Min.X + bbox(b).Max.X) / 2, 1) for b in piles})
ys = sorted({round((bbox(b).Min.Y + bbox(b).Max.Y) / 2, 1) for b in piles})
if PI:
flag("C1", f"pile grid {PI['nx']} x {PI['ny']}", len(xs) == PI["nx"] and len(ys) == PI["ny"], f"x={xs} y={ys}")
if PI and xs and ys:
check("C1", "pile edge X", PI["edge"], xs[0] + F["dx"] / 2)
check("C1", "pile edge Y", PI["edge"], ys[0] + F["dy"] / 2)
check("C1", "pile pitch X (worst)", 0, max(abs(b - a - PI["pitch"]) for a, b in zip(xs, xs[1:])))
check("C1", "pile pitch Y (worst)", 0, max(abs(b - a - PI["pitch"]) for a, b in zip(ys, ys[1:])))
pb = bbox(piles[0])
check("C1", "pile diameter", PI["d"], pb.Max.X - pb.Min.X)
check("C1", "pile length", PI["length"], pb.Max.Z - pb.Min.Z)
check("C1", "pile head embedment", PI["head_embedment"], pb.Max.Z - F["z0"])
# C4 coping: front section at Y = 0
ok, curves, pts = rg.Intersect.Intersection.BrepPlane(cp, rg.Plane(rg.Point3d(0, 0, 0), rg.Vector3d.YAxis), 0.01)
sec = rg.Curve.JoinCurves(curves, 0.1)
flag("C4", "front section (Y=0) closed single loop", len(sec) == 1 and sec[0].IsClosed, f"{len(sec)} loop(s)")
def z_at(x, upper):
line = rg.LineCurve(rg.Point3d(x, 0, 0), rg.Point3d(x, 0, 200000))
zs = sorted(ev.PointA.Z for ev in rg.Intersect.Intersection.CurveCurve(sec[0], line, 0.01, 0.01))
return zs[-1] if upper else zs[0]
for x in (0, FLAT - 1, -(FLAT - 1), 3500, -3500, TIP - 5, -(TIP - 5)):
check("C4", f"bottom at X={x:+.0f}", bottom_z(x), z_at(x, False))
for a, b in zip(EDGES, EDGES[1:]):
x = (a + b) / 2
check("C4", f"top step at X={x:+.0f}", top_z(x), z_at(x, True))
bb = bbox(cp)
check("C4", "coping Zbot", ZB, bb.Min.Z)
check("C4", "coping Ztop (highest step)", max(STEPS), bb.Max.Z)
# side width at X = 0 (side taper)
ok, curves, pts = rg.Intersect.Intersection.BrepPlane(cp, rg.Plane(rg.Point3d(0, 0, 0), rg.Vector3d.XAxis), 0.01)
ss = rg.Curve.JoinCurves(curves, 0.1)
for zz in (ZB + 1, (ZB + ZT) / 2, ZT - 1, ZT + 500):
hits = sorted(ev.PointA.Y for c in ss for ev in rg.Intersect.Intersection.CurveCurve(
c, rg.LineCurve(rg.Point3d(0, -9000, zz), rg.Point3d(0, 9000, zz)), 0.01, 0.01))
w = WB + (W - WB) * min(zz - ZB, ZT - ZB) / (ZT - ZB)
check("C4", f"side width at Z={zz:.0f}", 2 * w, (hits[-1] - hits[0]) if hits else 0)
# skewed end faces: points 3 mm inside / outside the sheared ends at y = +-(W - 50), z mid of the end face
for sx in (-1, 1):
for y in (-(W - 50), W - 50):
zz = (ZT + top_z(sx * (TIP - 10))) / 2
xe = sx * TIP - K * y
ins = cp.IsPointInside(rg.Point3d(xe - sx * 3, y, zz), 0.01, False)
out = not cp.IsPointInside(rg.Point3d(xe + sx * 3, y, zz), 0.01, False)
flag("C4", f"skewed end {'left' if sx < 0 else 'right'} at y={y:+.0f} [작도 skew {C['skew_deg']}]", ins and out,
f"x_end {xe:.1f}")
# C3 joints / overlaps
check("C3", "LC top = FT bottom", bbox(lc).Max.Z, bbox(ft).Min.Z, tol=0.01)
check("C3", "FT top = CL bottom", bbox(ft).Max.Z, bbox(cl).Min.Z, tol=0.01)
check("C3", "CL top = CP bottom", bbox(cl).Max.Z, bbox(cp).Min.Z, tol=0.01)
check("C3", "overlap FT-CL (mm3)", 0, inter_vol(ft, cl), tol=1.0)
check("C3", "overlap CL-CP (mm3)", 0, inter_vol(cl, cp), tol=1.0)
check("C3", "overlap LC-piles (mm3)", 0, sum(inter_vol(lc, b) for b in piles), tol=1.0)
if PI:
emb = len(piles) * math.pi * (PI["d"] / 2) ** 2 * PI["head_embedment"]
check("C3", "intended overlap FT-piles (mm3)", emb, sum(inter_vol(ft, b) for b in piles), tol=emb * 0.001)
# C6 volumes
fv = F["dx"] * F["dy"] * F["thickness"] / 1e9
check("C6", "footing volume m3", fv, vol(ft) / 1e9, tol=fv * 0.001)
lv = (LN["dx"] * LN["dy"] - (len(piles) * math.pi * (PI["d"] / 2) ** 2 if PI else 0)) * LN["thickness"] / 1e9
check("C6", "lean volume m3 (minus pile holes)", lv, vol(lc) / 1e9, tol=lv * 0.001)
cv_ = math.pi * (CO["d"] / 2000) ** 2 * CO["height"] / 1000
check("C6", "column volume m3", cv_, vol(cl) / 1e9, tol=cv_ * 0.001)
cpv = coping_volume_expected() / 1e9
check("C6", "coping volume m3 (dimension outline, shear keeps volume)", cpv, vol(cp) / 1e9, tol=cpv * 0.001)
# C7 shared pedestals and pads
S = G["seats"]
n0 = len(PID) + 3
seats = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BS")}
pads = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BP")}
flag("C7", "pedestal / pad counts", len(seats) == S["n_pedestals"] and sorted(pads) == sorted(int(k) for k in S["table"]),
f"pedestals={len(seats)} pads={len(pads)}")
flag("C7", "IsSolid pedestals+pads", all(b.IsSolid for b in list(seats.values()) + list(pads.values())))
for no_s, row in S["table"].items():
no = int(no_s)
elx, h1, h2, a, b = row
x, y = S["pos"][no_s]
p = pads[no]
top = top_z(x + K * y) # back to the Y = 0 step of this bearing line
check("C7", f"NO.{no} table EL.'X' - h1 - h2 = coping step", top, elx - h1 - h2)
check("C7", f"NO.{no} pad top = EL.'X'", elx, bbox(p).Max.Z)
pb = bbox(p) # plan centre: the sloped pad bottom shifts the volume centroid
check("C7", f"NO.{no} pad centre X (bbox)", x, (pb.Min.X + pb.Max.X) / 2)
check("C7", f"NO.{no} pad centre Y (bbox)", y, (pb.Min.Y + pb.Max.Y) / 2)
ped = [s for s in seats.values() if s.IsPointInside(rg.Point3d(x, y, top + 1), 0.01, False)]
flag("C7", f"NO.{no} on one pedestal", len(ped) == 1, f"{len(ped)}")
if ped:
line = rg.LineCurve(rg.Point3d(x, y, top - 10), rg.Point3d(x, y, top + 1000))
_ok, _crv, hits = rg.Intersect.Intersection.CurveBrep(line, ped[0], 0.01)
zs = [pt.Z for pt in hits]
check("C7", f"NO.{no} pedestal top at the bearing = step + h1", top + h1, max(zs) if zs else 0)
check("C7", f"NO.{no} pedestal bottom at the bearing = step", top, min(zs) if zs else 0)
check("C7", f"NO.{no} overlap pad-pedestal (mm3)", 0, inter_vol(p, ped[0]), tol=1.0)
for no, s in seats.items():
check("C7", f"pedestal {no} overlap with coping (mm3)", 0, inter_vol(s, cp), tol=1.0)
write_result(RESULT_PATH, results, dict(volumes_m3={"lean": round(vol(lc) / 1e9, 3), "footing": round(vol(ft) / 1e9, 3),
"column": round(vol(cl) / 1e9, 3), "coping": round(vol(cp) / 1e9, 3),
"pile_each": round(vol(piles[0]) / 1e9, 3) if piles else 0}))
main()
@@ -0,0 +1,264 @@
"""G1 gate checker for the "single shaft" form (Ramp-A P1, 2026-10-13): drilled shaft, column, Y coping,
pedestals, pads.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G1 of EXPECTED_PATH) come from
drawing dimension texts / EL texts / tables; generator params are not read. Two inputs have no dimension text
and are drawn values (marked "[작도]" in the item names): the plan end arc corner and the pedestal rotations.
Checks: C5 population, C2 closed solids, C1 extents, C4 coping sections (front Y=0, side X=0, plan end),
C3 joints and overlaps, C6 volumes (coping integrated from the dimension outline), C7 pedestals / pads.
"""
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G1"]
PI, CO, C = G["pile"], G["column"], G["coping"]
results = []
# expected outline from the dimensions
ZB = CO["z0"] + CO["length"] # column top = coping bottom
ZT = ZB + C["soffit_rise"] # tip level (end face bottom)
ZTL, ZTR = C["z_top_left"], C["z_top_right"]
HB, HT = C["column_face_half"], C["bottom_length"] / 2
TOPH, R = C["top_length"] / 2, C["soffit_radius"]
WT, WB = C["width_y"] / 2, C["side_bottom_width"] / 2
PCORNER = C["plan_end_corner_x"] # [작도]
def soffit_centre():
"""Centre of the R arc through (-HB, ZB) and (-HT, ZT), on the outer (lower-left) side."""
ax, az, bx, bz = -HB, ZB, -HT, ZT
mx, mz = (ax + bx) / 2, (az + bz) / 2
d = math.hypot(bx - ax, bz - az)
h = math.sqrt(R * R - d * d / 4)
ux, uz = (bz - az) / d, -(bx - ax) / d # unit normal of the chord
c1, c2 = (mx + h * ux, mz + h * uz), (mx - h * ux, mz - h * uz)
return min(c1, c2, key=lambda c: c[1]) # the centre below the chord
CX, CZ = soffit_centre()
def soffit_x(z):
"""|x| of the soffit at height z (ZB..ZT)."""
return abs(CX + math.sqrt(R * R - (z - CZ) ** 2))
def plan_arc(y):
sag = HT - PCORNER
rr = (WT * WT + sag * sag) / (2 * sag)
return HT - (rr - math.sqrt(rr * rr - y * y))
def end_x(y, z, zt):
"""|x| of the ruled end face at (y, z): plan arc at ZT, straight top edge TOPH at zt."""
t = (z - ZT) / (zt - ZT)
return plan_arc(y) + t * (TOPH - plan_arc(y))
def objs(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def brep(o):
return o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry)
def vol(b):
return rg.VolumeMassProperties.Compute(b).Volume
def bbox(b):
return b.GetBoundingBox(True)
def check(cid, name, expected, actual, tol=TOL):
results.append(dict(check=cid, item=name, expected=round(expected, 3), actual=round(actual, 3),
diff=round(actual - expected, 3), tol=tol, result="PASS" if abs(actual - expected) <= tol else "FAIL"))
def flag(cid, name, ok, detail=""):
results.append(dict(check=cid, item=name, detail=detail, result="PASS" if ok else "FAIL"))
def coping_volume_expected():
"""Sum over z slices and y strips of the x extent: soffit (below ZT) limited by the end face continued
below ZT, end face above ZT; left half up to ZTL, right half up to ZTR."""
total, nz, ny = 0.0, 3000, 200
z1 = max(ZTL, ZTR)
dz = (z1 - ZB) / nz
for i in range(nz):
z = ZB + (i + 0.5) * dz
w = WB + (WT - WB) * (z - ZB) / (ZT - ZB) if z < ZT else WT
dy = 2 * w / ny
area = 0.0
for zt in (ZTL, ZTR):
if z > zt:
continue
for j in range(ny):
y = -w + (j + 0.5) * dy
xe = end_x(y, z, zt)
area += (min(soffit_x(z), xe) if z < ZT else xe) * dy
total += area * dz
return total
def main():
pl = sorted(objs(f"{PID}-PL"), key=lambda o: o.Attributes.GetUserString("id"))
counts = {k: len(objs(f"{PID}-{k}")) for k in ("LC", "FT", "CL", "CP", "PL")}
flag("C5", "object counts", counts == {"LC": 0, "FT": 0, "CL": 1, "CP": 1, "PL": len(PI["segments"])}, str(counts))
cl, cp = brep(objs(f"{PID}-CL")[0]), brep(objs(f"{PID}-CP")[0])
pls = [brep(o) for o in pl]
allb = pls + [cl, cp]
flag("C2", f"IsSolid all {len(allb)}", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{len(allb)}")
# C1 pile segments (bottom to top), column, coping extents
z = PI["z_bottom"]
for k, (s, b) in enumerate(zip(PI["segments"], pls), 1):
bb = bbox(b)
check("C1", f"pile segment {k} diameter X", s["d"], bb.Max.X - bb.Min.X)
check("C1", f"pile segment {k} diameter Y", s["d"], bb.Max.Y - bb.Min.Y)
check("C1", f"pile segment {k} Zbot", z, bb.Min.Z)
check("C1", f"pile segment {k} Ztop", z + s["length"], bb.Max.Z)
z += s["length"]
check("C1", "pile top = EL text", CO["z0"], z)
bb = bbox(cl)
check("C1", "column diameter X", CO["d"], bb.Max.X - bb.Min.X)
check("C1", "column diameter Y", CO["d"], bb.Max.Y - bb.Min.Y)
check("C1", "column Zbot", CO["z0"], bb.Min.Z)
check("C1", "column Ztop", ZB, bb.Max.Z)
bb = bbox(cp)
check("C1", "coping length X (bottom tips)", 2 * HT, bb.Max.X - bb.Min.X)
check("C1", "coping width Y", 2 * WT, bb.Max.Y - bb.Min.Y)
check("C1", "coping Zbot", ZB, bb.Min.Z)
check("C1", "coping Ztop", max(ZTL, ZTR), bb.Max.Z)
check("C1", "coping centre X", 0, (bb.Max.X + bb.Min.X) / 2)
check("C1", "total height (pile bottom - coping top left)", G["total_height"], ZTL - PI["z_bottom"])
# C4 front section at Y = 0
def section(plane):
ok, curves, pts = rg.Intersect.Intersection.BrepPlane(cp, plane, 0.01)
return rg.Curve.JoinCurves(curves, 0.1)
sec = section(rg.Plane(rg.Point3d(0, 0, 0), rg.Vector3d.YAxis))
flag("C4", "front section closed single loop", len(sec) == 1 and sec[0].IsClosed, f"{len(sec)} loop(s)")
def hits(crv, a, b):
return sorted(ev.PointA for ev in rg.Intersect.Intersection.CurveCurve(crv, rg.LineCurve(a, b), 0.01, 0.01))
def z_at(x, upper):
zs = sorted(p.Z for p in hits(sec[0], rg.Point3d(x, 0, 0), rg.Point3d(x, 0, 200000)))
return zs[-1] if upper else zs[0]
for x in (0, HB - 1, -(HB - 1)):
check("C4", f"bottom at X={x:+.0f} (column top)", ZB, z_at(x, False))
for x in (1500, -1500, 2500, -2500, 3500, -3500, HT - 5, -(HT - 5)):
check("C4", f"soffit R{R:.0f} at X={x:+.0f}", CZ + math.sqrt(R * R - (abs(x) + CX) ** 2), z_at(x, False))
for x in (-3000, -1000, 1000, 3000):
check("C4", f"top at X={x:+.0f}", ZTL if x < 0 else ZTR, z_at(x, True))
for x in (-(TOPH + HT) / 2, (TOPH + HT) / 2):
zt = ZTL if x < 0 else ZTR
check("C4", f"end face at X={x:+.0f} (top {TOPH:.0f} -> bottom {HT:.0f})",
ZT + (zt - ZT) * (HT - abs(x)) / (HT - TOPH), z_at(x, True))
check("C4", "top step at the pier centre (right - left)", C["top_step"], z_at(1, True) - z_at(-1, True))
# C4 side section at X = 1 (X = 0 lies on the top step face, 2026-10-13): width 2,000 at the column top ->
# 2,200 at the tip level, constant above
sec_s = section(rg.Plane(rg.Point3d(1, 0, 0), rg.Vector3d.XAxis))
flag("C4", "side section closed single loop", len(sec_s) == 1 and sec_s[0].IsClosed, f"{len(sec_s)} loop(s)")
for zz in (ZB + 1, ZB + 750, ZT - 1, ZT + 500):
ys = [p.Y for p in hits(sec_s[0], rg.Point3d(1, -5000, zz), rg.Point3d(1, 5000, zz))]
w = WB + (WT - WB) * min(zz - ZB, ZT - ZB) / (ZT - ZB)
check("C4", f"side width at Z={zz:.0f}", 2 * w, max(ys) - min(ys) if ys else 0)
# C4 plan end (ruled face: convex arc at the tip level, straight top edge)
for sx in (-1, 1):
zt = ZTL if sx < 0 else ZTR
for zz, y in ((ZT + 5, 0), (ZT + 5, WT - 10), ((ZT + zt) / 2, 0), ((ZT + zt) / 2, WT - 10), (zt - 5, WT - 10)):
xe = end_x(y, zz, zt)
ins = cp.IsPointInside(rg.Point3d(sx * (xe - 3), y, zz), 0.01, False)
out = not cp.IsPointInside(rg.Point3d(sx * (xe + 3), y, zz), 0.01, False)
flag("C4", f"end face {'left' if sx < 0 else 'right'} at y={y:.0f} z={zz:.0f} [작도 corner {PCORNER:.0f}]",
ins and out, f"x_end {sx * xe:.1f}: inside 3 mm in {ins}, outside 3 mm out {out}")
# C3 joints and overlaps
def inter_vol(a, b):
r = rg.Brep.CreateBooleanIntersection(a, b, 0.01)
return sum(vol(x) for x in r) if r else 0.0
chain = pls + [cl, cp]
names = [f"PL{k:02d}" for k in range(1, len(pls) + 1)] + ["CL", "CP"]
for (na, a), (nb, b) in zip(zip(names, chain), zip(names[1:], chain[1:])):
check("C3", f"{na} top = {nb} bottom", bbox(a).Max.Z, bbox(b).Min.Z, tol=0.01)
check("C3", f"overlap {na}-{nb} (mm3)", 0, inter_vol(a, b), tol=1.0)
# C6 volumes
for k, (s, b) in enumerate(zip(PI["segments"], pls), 1):
v = math.pi * (s["d"] / 2000) ** 2 * s["length"] / 1000
check("C6", f"pile segment {k} volume m3", v, vol(b) / 1e9, tol=v * 0.001)
cv_ = math.pi * (CO["d"] / 2000) ** 2 * CO["length"] / 1000
check("C6", "column volume m3", cv_, vol(cl) / 1e9, tol=cv_ * 0.001)
cpv = coping_volume_expected() / 1e9
check("C6", "coping volume m3 (dimension outline)", cpv, vol(cp) / 1e9, tol=cpv * 0.001)
# C7 pedestals and pads
S = G["seats"]
nos = sorted(int(k) for k in S["table"])
n0 = len(PID) + 3
seats = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BS")}
pads = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BP")}
flag("C7", "pedestal / pad counts", sorted(seats) == nos and sorted(pads) == nos, f"seats={len(seats)} pads={len(pads)}")
allb = list(seats.values()) + list(pads.values())
flag("C7", "IsSolid pedestals+pads", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{len(allb)}")
p_along, p_across = S["pedestal_along"], S["pedestal_across"]
m0, m1 = S["pad_margin"]
def bottom_edges(b):
zmin = min(v.Location.Z for v in b.Vertices)
return [e.PointAtEnd - e.PointAtStart for e in b.Edges
if abs(e.PointAtStart.Z - zmin) < 0.01 and abs(e.PointAtEnd.Z - zmin) < 0.01
and e.PointAtStart.DistanceTo(e.PointAtEnd) > 1]
for no in nos:
elx, h1, h2, a, b = S["table"][str(no)]
x, y = S["pos"][str(no)]
top = ZTL if x < 0 else ZTR
s, p = seats[no], pads[no]
sb, pb = bbox(s), bbox(p)
check("C7", f"NO.{no} table EL.'X' - h1 - h2 = coping top ({'left' if x < 0 else 'right'})", top, elx - h1 - h2)
check("C7", f"NO.{no} pedestal bottom = coping top", top, sb.Min.Z)
check("C7", f"NO.{no} pedestal top = top + h1", top + h1, sb.Max.Z)
check("C7", f"NO.{no} pad top = EL.'X'", elx, pb.Max.Z)
ed = bottom_edges(s)
al = [v for v in ed if abs(v.Y) > abs(v.X)]
ac = [v for v in ed if abs(v.Y) <= abs(v.X)]
check("C7", f"NO.{no} pedestal along (C3)", p_along, al[0].Length if al else 0)
check("C7", f"NO.{no} pedestal across (C6)", p_across, ac[0].Length if ac else 0)
if al:
v = al[0] if al[0].Y > 0 else -al[0]
check("C7", f"NO.{no} pedestal rotation deg [작도]", S["rotation_deg"][str(no)],
math.degrees(math.atan2(v.X, v.Y)), tol=0.05)
check("C7", f"NO.{no} pedestal centre X", x, (sb.Max.X + sb.Min.X) / 2)
check("C7", f"NO.{no} pedestal centre Y", y, (sb.Max.Y + sb.Min.Y) / 2)
c = rg.VolumeMassProperties.Compute(p).Centroid
check("C7", f"NO.{no} pad centre X", x, c.X)
check("C7", f"NO.{no} pad centre Y", y, c.Y)
mm_ = (m0 + m1) / 2
pv = h2 / 6 * ((a + m0) * (b + m0) + (a + m1) * (b + m1) + 4 * (a + mm_) * (b + mm_))
check("C7", f"NO.{no} pad volume (prismatoid A,B)", pv, vol(p), tol=pv * 0.001)
check("C7", f"NO.{no} overlap pedestal-coping (mm3)", 0, inter_vol(s, cp), tol=1.0)
check("C7", f"NO.{no} overlap pad-pedestal (mm3)", 0, inter_vol(p, s), tol=1.0)
corners = [rg.Point3d(q.X, q.Y, sb.Min.Z - 1) for q in [v.Location for v in s.Vertices] if abs(q.Z - sb.Min.Z) < 0.01]
flag("C7", f"NO.{no} pedestal footprint on the coping top", all(cp.IsPointInside(q, 0.01, False) for q in corners),
f"{len(corners)} corners tested 1 mm below")
write_result(RESULT_PATH, results, dict(volumes_m3={**{f"pile{k}": round(vol(b) / 1e9, 3) for k, b in enumerate(pls, 1)},
"column": round(vol(cl) / 1e9, 3), "coping": round(vol(cp) / 1e9, 3)}))
main()
@@ -0,0 +1,242 @@
"""G1 gate checker: concrete, piles, pedestals and pads (checks C1-C7) inside Rhino 8.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Independent expected values from EXPECTED_PATH
section G1 (drawing texts / DXF); generator params are not read.
Ported from Code/교각모델링/verify_rhino.py (same checks and order; fingerprint part moved to the gate runner).
"""
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL_MM, G = X["pier_id"], X["tol_mm"], X["G1"]
results = []
f, c_, cl_, pl_ = G["footing"], G["coping"], G["column"], G["piles"]
FT_TOP = f["z0"] + f["thickness"]
EXPECTED = {
"footing": dict(dx=f["dx"], dy=f["dy"], z0=f["z0"], z1=FT_TOP),
"lean": dict(dx=f["dx"] + 2 * G["lean"]["margin"], dy=f["dy"] + 2 * G["lean"]["margin"],
z0=f["z0"] - G["lean"]["thickness"], z1=f["z0"]),
"column": dict(d=cl_["d"], z0=FT_TOP, z1=FT_TOP + cl_["height"]),
"coping": dict(dx=c_["dx"], dy=c_["dy"], z_bottom=c_["z_top_left"] - c_["rise_upper"] - c_["rise_lower"],
z_tip_bottom=c_["z_top_left"] - c_["rise_upper"], z_top_left=c_["z_top_left"],
z_top_right=c_["z_top_right"], z_top_center=c_["z_top_center"],
flat_half=c_["dx"] / 2 - c_["flat_from_tip"], top_half=c_["dx"] / 2 - c_["end_inset"],
radius=c_["radius"]),
"piles": dict(nx=pl_["nx"], ny=pl_["ny"], pitch_x=pl_.get("pitch_x", pl_.get("pitch")),
pitch_y=pl_.get("pitch_y", pl_.get("pitch")), edge=pl_["edge"], d=pl_["d"], length=pl_["length"]),
}
_a = G["dxf_arc_centres"]
DXF_LEFT_ARC_CENTER = (_a["left"][0] - _a["datum_x"], _a["datum_el"] + (_a["left"][1] - _a["datum_y"]))
DXF_RIGHT_ARC_CENTER = (_a["right"][0] - _a["datum_x"], _a["datum_el"] + (_a["right"][1] - _a["datum_y"]))
S = G["seats"]
TABLE = {int(k): tuple(v) for k, v in S["table"].items()}
NG = S["girders"]
STEP_TOP = {int(k): v for k, v in S["step_top"].items()}
STEP_X = {int(k): tuple(v) for k, v in S["step_x"].items()}
ROW_Y = S["row_y"]
PITCH = S["pitch_chain"][1] / S["pitch_chain"][0]
def objs(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def brep(o):
return o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry)
def vol(b):
return rg.VolumeMassProperties.Compute(b).Volume
def bbox(b):
return b.GetBoundingBox(True)
def check(cid, name, expected, actual, tol=TOL_MM):
ok = abs(expected - actual) <= tol
results.append(dict(check=cid, item=name, expected=round(expected, 3), actual=round(actual, 3),
diff=round(actual - expected, 3), tol=tol, result="PASS" if ok else "FAIL"))
def flag(cid, name, ok, detail):
results.append(dict(check=cid, item=name, detail=detail, result="PASS" if ok else "FAIL"))
def check_seats(cp):
n0 = len(PID) + 3
seats = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BS")}
pads = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BP")}
nos = sorted(TABLE)
flag("C7", "pedestal / pad counts", sorted(seats) == nos and sorted(pads) == nos,
f"seats={len(seats)} pads={len(pads)}")
allb = list(seats.values()) + list(pads.values())
flag("C7", "IsSolid pedestals+pads", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{2 * len(nos)}")
m0, m1 = S["pad_margin"]
pa, pc = S["pedestal_plan"]
half_w = c_["dy"] / 2
pad_x = {}
for no, (elx, h1, h2, a, b) in TABLE.items():
g = (no - 1) % NG + 1
row = "front" if no <= NG else "rear"
s, p = seats[no], pads[no]
sb, pb = bbox(s), bbox(p)
check("C7", f"NO.{no} pedestal bottom = step {g}", STEP_TOP[g], sb.Min.Z)
check("C7", f"NO.{no} pedestal top = step + h1", STEP_TOP[g] + h1, sb.Max.Z)
check("C7", f"NO.{no} pad top = EL.'X'", elx, pb.Max.Z)
flag("C7", f"NO.{no} pedestal within step {g} / coping width",
STEP_X[g][0] - TOL_MM <= sb.Min.X and sb.Max.X <= STEP_X[g][1] + TOL_MM
and -half_w - TOL_MM <= sb.Min.Y and sb.Max.Y <= half_w + TOL_MM,
f"x {sb.Min.X:.1f}..{sb.Max.X:.1f} y {sb.Min.Y:.1f}..{sb.Max.Y:.1f}")
area = pa / 1000 * pc / 1000
check("C7", f"NO.{no} pedestal half area (m2)", area, vol(s) / h1 / 1e6, tol=area * 0.002)
mm_ = (m0 + m1) / 2
pv = h2 / 6 * ((a + m0) * (b + m0) + (a + m1) * (b + m1) + 4 * (a + mm_) * (b + mm_))
check("C7", f"NO.{no} pad volume (prismatoid A,B)", pv, vol(p), tol=pv * 0.001)
c = rg.VolumeMassProperties.Compute(p).Centroid
check("C7", f"NO.{no} pad centre Y (S1)", ROW_Y[row], c.Y, tol=2.0)
pad_x.setdefault(row, {})[g] = c.X
check("C7", f"NO.{no} overlap pedestal-coping (mm3)", 0,
sum(vol(x) for x in (rg.Brep.CreateBooleanIntersection(s, cp, 0.01) or [])), tol=1.0)
check("C7", f"NO.{no} overlap pad-pedestal (mm3)", 0,
sum(vol(x) for x in (rg.Brep.CreateBooleanIntersection(p, s, 0.01) or [])), tol=1.0)
for row, xs in pad_x.items():
for g in range(1, NG):
check("C7", f"{row} pad pitch girder {g}-{g + 1}", PITCH, xs[g + 1] - xs[g], tol=1.0)
first = S["rear_first_from_tip"]
for g in range(1, NG + 1):
check("C7", f"rear pad centre X girder {g} (dim {first / 1000:.3f}+{PITCH / 1000:.3f}n)",
-c_["dx"] / 2 + first + (g - 1) * PITCH, pad_x["rear"][g], tol=2.0)
for row, rng in (("front", range(1, NG + 1)), ("rear", range(NG + 1, 2 * NG + 1))):
cxs = [rg.VolumeMassProperties.Compute(seats[n]).Centroid.X for n in rng]
for g in range(NG - 1):
check("C7", f"{row} pedestal pitch girder {g + 1}-{g + 2}", PITCH, cxs[g + 1] - cxs[g], tol=1.0)
def main():
ids = {k: f"{PID}-{k}" for k in ("LC", "FT", "CL", "CP", "PL")}
lc, ft, cl, cp = (brep(objs(ids[k])[0]) for k in ("LC", "FT", "CL", "CP"))
piles = [brep(o) for o in objs(ids["PL"])]
e = EXPECTED
p = e["piles"]
counts = {ids[k]: len(objs(ids[k])) for k in ("LC", "FT", "CL", "CP", "PL")}
exp_counts = {ids["LC"]: 1, ids["FT"]: 1, ids["CL"]: 1, ids["CP"]: 1, ids["PL"]: p["nx"] * p["ny"]}
flag("C5", "object counts", counts == exp_counts, str(counts))
all_b = [lc, ft, cl, cp] + piles
flag("C2", f"IsSolid all {len(all_b)}", all(b.IsSolid for b in all_b), f"{sum(b.IsSolid for b in all_b)}/{len(all_b)}")
for key, b in (("footing", ft), ("lean", lc)):
bb = bbox(b)
check("C1", f"{key} X", e[key]["dx"], bb.Max.X - bb.Min.X)
check("C1", f"{key} Y", e[key]["dy"], bb.Max.Y - bb.Min.Y)
check("C1", f"{key} Zbot", e[key]["z0"], bb.Min.Z)
check("C1", f"{key} Ztop", e[key]["z1"], bb.Max.Z)
check("C1", f"{key} centre X", 0, (bb.Max.X + bb.Min.X) / 2)
check("C1", f"{key} centre Y", 0, (bb.Max.Y + bb.Min.Y) / 2)
bb = bbox(cl)
check("C1", "column diameter X", e["column"]["d"], bb.Max.X - bb.Min.X)
check("C1", "column diameter Y", e["column"]["d"], bb.Max.Y - bb.Min.Y)
check("C1", "column Zbot", e["column"]["z0"], bb.Min.Z)
check("C1", "column Ztop", e["column"]["z1"], bb.Max.Z)
bb = bbox(cp)
check("C1", "coping length X", e["coping"]["dx"], bb.Max.X - bb.Min.X)
check("C1", "coping width Y", e["coping"]["dy"], bb.Max.Y - bb.Min.Y)
check("C1", "coping Zbot", e["coping"]["z_bottom"], bb.Min.Z)
check("C1", "coping Ztop (right end)", e["coping"]["z_top_right"], bb.Max.Z)
xs = sorted({round((bbox(b).Min.X + bbox(b).Max.X) / 2, 1) for b in piles})
ys = sorted({round((bbox(b).Min.Y + bbox(b).Max.Y) / 2, 1) for b in piles})
flag("C1", "pile grid", len(xs) == p["nx"] and len(ys) == p["ny"], f"x={xs} y={ys}")
check("C1", "pile edge X", p["edge"], xs[0] - (-e["footing"]["dx"] / 2))
check("C1", "pile edge Y", p["edge"], ys[0] - (-e["footing"]["dy"] / 2))
for a, b2 in zip(xs, xs[1:]):
check("C1", "pile pitch X", p["pitch_x"], b2 - a)
for a, b2 in zip(ys, ys[1:]):
check("C1", "pile pitch Y", p["pitch_y"], b2 - a)
pb = bbox(piles[0])
check("C1", "pile diameter", p["d"], pb.Max.X - pb.Min.X)
check("C1", "pile length", p["length"], pb.Max.Z - pb.Min.Z)
check("C1", "pile head embedment", pl_["head_embedment"], pb.Max.Z - e["footing"]["z0"])
# C4 coping front section at Y=0 (re-projection to drawing)
plane = rg.Plane(rg.Point3d(0, 0, 0), rg.Vector3d.YAxis)
ok, curves, pts = rg.Intersect.Intersection.BrepPlane(cp, plane, 0.01)
sec = rg.Curve.JoinCurves(curves, 0.1)
flag("C4", "section closed single loop", len(sec) == 1 and sec[0].IsClosed, f"{len(sec)} loop(s)")
c = e["coping"]
def z_at(x, upper):
line = rg.LineCurve(rg.Point3d(x, 0, 0), rg.Point3d(x, 0, 60000))
zs = sorted(ev.PointA.Z for ev in rg.Intersect.Intersection.CurveCurve(sec[0], line, 0.01, 0.01))
return zs[-1] if upper else zs[0]
sx = {k: (v[0] + v[1]) / 2 for k, v in STEP_X.items()} # sample each top step at its middle
check("C4", "bottom flat at X=0", c["z_bottom"], z_at(0, False))
check("C4", f"bottom flat at X=+{c['flat_half'] - 1:.0f}", c["z_bottom"], z_at(c["flat_half"] - 1, False))
check("C4", f"bottom at X=-{c['top_half'] + 199:.0f} (tip)", c["z_tip_bottom"], z_at(-c["top_half"] - 199, False), tol=2.0)
check("C4", f"top left step (X={sx[1]:.0f})", c["z_top_left"], z_at(sx[1], True))
check("C4", "top centre step (X=0)", c["z_top_center"], z_at(0, True))
check("C4", f"top right step (X={sx[NG]:.0f})", c["z_top_right"], z_at(sx[NG], True))
worst = 0.0
step = (c["dx"] / 2 - c["flat_half"] - 200) / 50
for side, (cx, cz) in ((1, DXF_RIGHT_ARC_CENTER), (-1, DXF_LEFT_ARC_CENTER)):
for k in range(1, 50):
x = side * (c["flat_half"] + 100 + k * step)
zb = z_at(x, False)
worst = max(worst, abs(math.hypot(x - cx, zb - cz) - c["radius"]))
check("C4", "soffit radius max deviation vs DXF ARC centre (98 samples)", 0, worst)
# C3 joints and overlaps
def inter_vol(a, b):
r = rg.Brep.CreateBooleanIntersection(a, b, 0.01)
return sum(vol(x) for x in r) if r else 0.0
check("C3", "LC top = FT bottom", bbox(lc).Max.Z, bbox(ft).Min.Z, tol=0.01)
check("C3", "FT top = CL bottom", bbox(ft).Max.Z, bbox(cl).Min.Z, tol=0.01)
check("C3", "CL top = CP bottom", bbox(cl).Max.Z, bbox(cp).Min.Z, tol=0.01)
check("C3", "overlap LC-FT (mm3)", 0, inter_vol(lc, ft), tol=1.0)
check("C3", "overlap FT-CL (mm3)", 0, inter_vol(ft, cl), tol=1.0)
check("C3", "overlap CL-CP (mm3)", 0, inter_vol(cl, cp), tol=1.0)
check("C3", "overlap LC-piles (mm3)", 0, sum(inter_vol(lc, b) for b in piles), tol=1.0)
emb = len(piles) * math.pi * (p["d"] / 2) ** 2 * pl_["head_embedment"]
check("C3", "intended overlap FT-piles (mm3)", emb, sum(inter_vol(ft, b) for b in piles), tol=emb * 0.001)
flag("C3", "column within coping flat zone", e["column"]["d"] / 2 <= c["flat_half"]
and e["column"]["d"] / 2 <= c["dy"] / 2,
f"r={e['column']['d'] / 2:.0f} <= {c['flat_half']:.0f} (X), {c['dy'] / 2:.0f} (Y)")
# C6 volumes vs analytic formulas
fv = f["dx"] / 1000 * f["dy"] / 1000 * f["thickness"] / 1000
check("C6", "footing volume m3", fv, vol(ft) / 1e9, tol=fv * 0.001)
cv_ = math.pi * (cl_["d"] / 2000) ** 2 * cl_["height"] / 1000
check("C6", "column volume m3", cv_, vol(cl) / 1e9, tol=round(cv_, 2) * 0.001)
lv = (e["lean"]["dx"] / 1000 * e["lean"]["dy"] / 1000 * G["lean"]["thickness"] / 1000
- len(piles) * math.pi * (p["d"] / 2000) ** 2 * G["lean"]["thickness"] / 1000)
check("C6", "lean volume m3", lv, vol(lc) / 1e9, tol=lv * 0.001)
poly = [tuple(q) for q in G["coping_outline"]]
area = 0.5 * abs(sum(x1 * y2 - x2 * y1 for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1])))
(x1, z1), (x2, z2) = poly[1], poly[2]
chord = math.hypot(x2 - x1, z2 - z1)
theta = 2 * math.asin(chord / 2 / c["radius"])
seg = c["radius"] ** 2 / 2 * (theta - math.sin(theta))
cv = (area - 2 * seg) * c["dy"] / 1e9
check("C6", "coping volume m3", cv, vol(cp) / 1e9, tol=cv * 0.001)
if objs(f"{PID}-BS") or objs(f"{PID}-BP"):
check_seats(cp)
write_result(RESULT_PATH, results, dict(
volumes_m3={k: round(vol(b) / 1e9, 3) for k, b in (("lean", lc), ("footing", ft), ("column", cl), ("coping", cp))},
pile_volume_each_m3=round(vol(piles[0]) / 1e9, 3)))
main()
@@ -0,0 +1,257 @@
"""G1 gate checker for the "tapered T" form (Ulsan P1, 2026-10-13): footing, lean, column, coping, pedestals, pads.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G1 of EXPECTED_PATH) come from
drawing dimension texts / EL texts / tables; generator params are not read.
Optional (Ulsan P2, 2026-10-15; absent -> the checks are unchanged): G1.footing.rot_deg (foundation drawn in its own axes, rotated
counter-clockwise about the pier centre; lean / footing / piles are checked after rotating them back and their rotated plan extents are
compared with the closed form), G1.piles {nx, ny, pitch_x, pitch_y, edge_x, edge_y, d, length, head_embedment}.
Checks: C5 population, C2 closed solids, C1 extents, C4 coping section at Y=0 and plan corner rounding,
C3 joints and overlaps, C6 volumes (coping: plan section areas integrated over Z from the
dimensions, R corner fillets in closed form), C7 pedestals / pads per bearing.
"""
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G1"]
F, LN, CO, C, PI = G["footing"], G["lean"], G["column"], G["coping"], G.get("piles")
ROT = F.get("rot_deg", 0.0)
FT_TOP = F["z0"] + F["thickness"]
Z_CB = FT_TOP + CO["height"] # column top = coping bottom
results = []
def objs(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def brep(o):
return o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry)
def vol(b):
return rg.VolumeMassProperties.Compute(b).Volume
def bbox(b):
return b.GetBoundingBox(True)
def unrot(b):
"""Copy of b rotated back by the foundation rotation (identity when the foundation is not rotated)."""
b = b.DuplicateBrep()
if ROT:
b.Transform(rg.Transform.Rotation(-math.radians(ROT), rg.Vector3d.ZAxis, rg.Point3d.Origin))
return b
def check(cid, name, expected, actual, tol=TOL):
results.append(dict(check=cid, item=name, expected=round(expected, 3), actual=round(actual, 3),
diff=round(actual - expected, 3), tol=tol, result="PASS" if abs(actual - expected) <= tol else "FAIL"))
def flag(cid, name, ok, detail=""):
results.append(dict(check=cid, item=name, detail=detail, result="PASS" if ok else "FAIL"))
# expected coping outline from the dimensions (front view, Y = 0)
FLAT, TIP, TOPH, RISE = C["bottom_flat_half"], C["tip_half"], C["top_half"], C["soffit_rise"]
ZTL, ZTR = C["z_top_left"], C["z_top_right"]
Z_TIP = Z_CB + RISE
def bottom_z(x):
ax = abs(x)
return Z_CB if ax <= FLAT else Z_CB + RISE * (ax - FLAT) / (TIP - FLAT)
def top_z(x):
"""Upper outline: top level of the side, then the battered end face from top_half down to the tip."""
zt = ZTL if x < 0 else ZTR
ax = abs(x)
if ax <= TOPH:
return zt
return zt - (zt - Z_TIP) * (ax - TOPH) / (TIP - TOPH)
def coping_volume_expected():
"""Integrate the plan section area over Z. At height z each side reaches x_end(z): the soffit line below the
tip level (plan outline: tip length, corner radius R), the battered end face above it (rounded outline
shrinking from the tip length to the top length). Corner cuts use the closed form of the R fillet area."""
W, R = C["width_y"], C["plan_corner_radius"]
def cut_area(e): # area cut by one R fillet over 0..e beyond its start
e = min(max(e, 0.0), R)
return R * e - (e / 2 * math.sqrt(R * R - e * e) + R * R / 2 * math.asin(e / R))
total, n = 0.0, 40000
z0, z1 = Z_CB, max(ZTL, ZTR)
dz = (z1 - z0) / n
for i in range(n):
z = z0 + (i + 0.5) * dz
area = 0.0
for zt in (ZTL, ZTR):
if z > zt:
continue
if z < Z_TIP:
x_end = FLAT + (TIP - FLAT) * (z - Z_CB) / RISE # soffit line
rect_end = TIP
else:
x_end = TIP - (TIP - TOPH) * (z - Z_TIP) / (zt - Z_TIP)
rect_end = x_end
area += W * x_end - 2 * cut_area(x_end - (rect_end - R))
total += area * dz
return total
def main():
ids = {k: f"{PID}-{k}" for k in ("LC", "FT", "CL", "CP", "PL")}
S = G["seats"]
nos = sorted(int(k) for k in S["table"])
counts = {k: len(objs(v)) for k, v in ids.items()}
flag("C5", "object counts", counts == {"LC": 1, "FT": 1, "CL": 1, "CP": 1, "PL": PI["nx"] * PI["ny"] if PI else 0}, str(counts))
lc, ft, cl, cp = (brep(objs(ids[k])[0]) for k in ("LC", "FT", "CL", "CP"))
piles = [brep(o) for o in objs(ids["PL"])]
allb = [lc, ft, cl, cp] + piles
flag("C2", f"IsSolid all {len(allb)}", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{len(allb)}")
for key, b, dx, dy, z0, z1 in (("footing", ft, F["dx"], F["dy"], F["z0"], FT_TOP),
("lean", lc, F["dx"] + 2 * LN["margin"], F["dy"] + 2 * LN["margin"],
F["z0"] - LN["thickness"], F["z0"])):
if ROT: # raw plan extent of the rotated rectangle (closed form)
t = math.radians(abs(ROT))
rb = bbox(b)
check("C1", f"{key} rotated extent X", dx * math.cos(t) + dy * math.sin(t), rb.Max.X - rb.Min.X)
check("C1", f"{key} rotated extent Y", dx * math.sin(t) + dy * math.cos(t), rb.Max.Y - rb.Min.Y)
bb = bbox(unrot(b))
check("C1", f"{key} X", dx, bb.Max.X - bb.Min.X)
check("C1", f"{key} Y", dy, bb.Max.Y - bb.Min.Y)
check("C1", f"{key} Zbot", z0, bb.Min.Z)
check("C1", f"{key} Ztop", z1, bb.Max.Z)
check("C1", f"{key} centre X", 0, (bb.Max.X + bb.Min.X) / 2)
check("C1", f"{key} centre Y", 0, (bb.Max.Y + bb.Min.Y) / 2)
bb = bbox(cl)
check("C1", "column diameter X", CO["d"], bb.Max.X - bb.Min.X)
check("C1", "column diameter Y", CO["d"], bb.Max.Y - bb.Min.Y)
check("C1", "column Zbot", FT_TOP, bb.Min.Z)
check("C1", "column Ztop", Z_CB, bb.Max.Z)
if PI and piles:
pr = [bbox(unrot(b)) for b in piles]
xs = sorted({round((q.Min.X + q.Max.X) / 2, 1) for q in pr})
ys = sorted({round((q.Min.Y + q.Max.Y) / 2, 1) for q in pr})
flag("C1", f"pile grid {PI['nx']} x {PI['ny']}", len(xs) == PI["nx"] and len(ys) == PI["ny"], f"x={xs} y={ys}")
if len(xs) == PI["nx"] and len(ys) == PI["ny"]:
check("C1", "pile edge X", PI["edge_x"], xs[0] + F["dx"] / 2)
check("C1", "pile edge Y", PI["edge_y"], ys[0] + F["dy"] / 2)
check("C1", "pile pitch X (worst)", 0, max(abs(b - a - PI["pitch_x"]) for a, b in zip(xs, xs[1:])))
check("C1", "pile pitch Y (worst)", 0, max(abs(b - a - PI["pitch_y"]) for a, b in zip(ys, ys[1:])))
check("C1", "pile diameter", PI["d"], pr[0].Max.X - pr[0].Min.X)
check("C1", "pile length", PI["length"], pr[0].Max.Z - pr[0].Min.Z)
check("C1", "pile head embedment", PI["head_embedment"], pr[0].Max.Z - F["z0"])
bb = bbox(cp)
check("C1", "coping length X (tip to tip)", 2 * TIP, bb.Max.X - bb.Min.X)
check("C1", "coping width Y", C["width_y"], bb.Max.Y - bb.Min.Y)
check("C1", "coping Zbot", Z_CB, bb.Min.Z)
check("C1", "coping Ztop", max(ZTL, ZTR), bb.Max.Z)
check("C1", "coping centre X", 0, (bb.Max.X + bb.Min.X) / 2)
# C4 section at Y = 0 against the dimension outline
ok, curves, pts = rg.Intersect.Intersection.BrepPlane(cp, rg.Plane(rg.Point3d(0, 0, 0), rg.Vector3d.YAxis), 0.01)
sec = rg.Curve.JoinCurves(curves, 0.1)
flag("C4", "section closed single loop", len(sec) == 1 and sec[0].IsClosed, f"{len(sec)} loop(s)")
def z_at(x, upper):
line = rg.LineCurve(rg.Point3d(x, 0, 0), rg.Point3d(x, 0, 200000))
zs = sorted(ev.PointA.Z for ev in rg.Intersect.Intersection.CurveCurve(sec[0], line, 0.01, 0.01))
return zs[-1] if upper else zs[0]
for x in (0, FLAT - 1, -(FLAT - 1), 3000, -3000, TIP - 5, -(TIP - 5)):
check("C4", f"bottom at X={x:+.0f}", bottom_z(x), z_at(x, False))
for x in (-2000, 2000, -(TOPH - 10), TOPH - 10, -(TOPH + TIP) / 2, (TOPH + TIP) / 2):
check("C4", f"top at X={x:+.0f}", top_z(x), z_at(x, True))
check("C4", "top step at the pier centre (left - right)", ZTL - ZTR, z_at(-1, True) - z_at(1, True))
# plan corner rounding (R) just above the tips
R, W = C["plan_corner_radius"], C["width_y"]
zc = Z_TIP + 50
inside = lambda x, y: cp.IsPointInside(rg.Point3d(x, y, zc), 0.01, False)
# test point on the 45 deg diagonal from the fillet centre, half way between the arc (R) and the sharp corner
d = (R + R * math.sqrt(2)) / 2 / math.sqrt(2)
corner_x, corner_y = TIP - R + d, W / 2 - R + d
flag("C4", f"plan corners rounded R{R} (4 corners outside, edges inside)",
all(not inside(sx * corner_x, sy * corner_y) for sx in (1, -1) for sy in (1, -1))
and all(inside(sx * (TIP - R - 50), sy * (W / 2 - 10)) for sx in (1, -1) for sy in (1, -1)),
f"z {zc}, test point ({corner_x:.0f}, {corner_y:.0f})")
# C3 joints and overlaps
def inter_vol(a, b):
r = rg.Brep.CreateBooleanIntersection(a, b, 0.01)
return sum(vol(x) for x in r) if r else 0.0
check("C3", "LC top = FT bottom", bbox(lc).Max.Z, bbox(ft).Min.Z, tol=0.01)
check("C3", "FT top = CL bottom", bbox(ft).Max.Z, bbox(cl).Min.Z, tol=0.01)
check("C3", "CL top = CP bottom", bbox(cl).Max.Z, bbox(cp).Min.Z, tol=0.01)
check("C3", "overlap LC-FT (mm3)", 0, inter_vol(lc, ft), tol=1.0)
check("C3", "overlap FT-CL (mm3)", 0, inter_vol(ft, cl), tol=1.0)
check("C3", "overlap CL-CP (mm3)", 0, inter_vol(cl, cp), tol=1.0)
if PI:
check("C3", "overlap LC-piles (mm3)", 0, sum(inter_vol(lc, b) for b in piles), tol=1.0)
emb = len(piles) * math.pi * (PI["d"] / 2) ** 2 * PI["head_embedment"]
check("C3", "intended overlap FT-piles (mm3)", emb, sum(inter_vol(ft, b) for b in piles), tol=emb * 0.001)
flag("C3", "column within coping bottom flat zone", CO["d"] / 2 <= FLAT and CO["d"] / 2 <= W / 2,
f"r={CO['d'] / 2:.0f} <= {FLAT:.0f} (X), {W / 2:.0f} (Y)")
# C6 volumes
fv = F["dx"] * F["dy"] * F["thickness"] / 1e9
check("C6", "footing volume m3", fv, vol(ft) / 1e9, tol=fv * 0.001)
cv_ = math.pi * (CO["d"] / 2000) ** 2 * CO["height"] / 1000
check("C6", "column volume m3", cv_, vol(cl) / 1e9, tol=cv_ * 0.001)
holes = len(piles) * math.pi * (PI["d"] / 2) ** 2 if PI else 0
lv = ((F["dx"] + 2 * LN["margin"]) * (F["dy"] + 2 * LN["margin"]) - holes) * LN["thickness"] / 1e9
check("C6", "lean volume m3 (minus pile holes)", lv, vol(lc) / 1e9, tol=lv * 0.001)
cpv = coping_volume_expected() / 1e9
check("C6", "coping volume m3 (dimension outline, R corners)", cpv, vol(cp) / 1e9, tol=cpv * 0.001)
# C7 pedestals and pads
n0 = len(PID) + 3
seats = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BS")}
pads = {int(o.Attributes.GetUserString("id")[n0:]): brep(o) for o in objs(f"{PID}-BP")}
flag("C7", "pedestal / pad counts", sorted(seats) == nos and sorted(pads) == nos, f"seats={len(seats)} pads={len(pads)}")
allb = list(seats.values()) + list(pads.values())
flag("C7", "IsSolid pedestals+pads", all(b.IsSolid for b in allb), f"{sum(b.IsSolid for b in allb)}/{len(allb)}")
px, py = S["pedestal"]
m0, m1 = S["pad_margin"]
for no in nos:
elx, h1, h2, a, b = S["table"][str(no)]
x, y = S["pos"][str(no)]
top = ZTL if x < 0 else ZTR
s, p = seats[no], pads[no]
sb, pb = bbox(s), bbox(p)
check("C7", f"NO.{no} table EL.'X' - h1 - h2 = coping top ({'left' if x < 0 else 'right'})", top, elx - h1 - h2)
check("C7", f"NO.{no} pedestal bottom = coping top", top, sb.Min.Z)
check("C7", f"NO.{no} pedestal top = top + h1", top + h1, sb.Max.Z)
check("C7", f"NO.{no} pad top = EL.'X'", elx, pb.Max.Z)
check("C7", f"NO.{no} pedestal size X", px, sb.Max.X - sb.Min.X)
check("C7", f"NO.{no} pedestal size Y", py, sb.Max.Y - sb.Min.Y)
check("C7", f"NO.{no} pedestal centre X", x, (sb.Max.X + sb.Min.X) / 2)
check("C7", f"NO.{no} pedestal centre Y", y, (sb.Max.Y + sb.Min.Y) / 2)
c = rg.VolumeMassProperties.Compute(p).Centroid
check("C7", f"NO.{no} pad centre X", x, c.X)
check("C7", f"NO.{no} pad centre Y", y, c.Y)
mm_ = (m0 + m1) / 2
pv = h2 / 6 * ((a + m0) * (b + m0) + (a + m1) * (b + m1) + 4 * (a + mm_) * (b + mm_))
check("C7", f"NO.{no} pad volume (prismatoid A,B)", pv, vol(p), tol=pv * 0.001)
check("C7", f"NO.{no} overlap pedestal-coping (mm3)", 0,
sum(vol(q) for q in (rg.Brep.CreateBooleanIntersection(s, cp, 0.01) or [])), tol=1.0)
check("C7", f"NO.{no} overlap pad-pedestal (mm3)", 0,
sum(vol(q) for q in (rg.Brep.CreateBooleanIntersection(p, s, 0.01) or [])), tol=1.0)
write_result(RESULT_PATH, results, dict(volumes_m3={k: round(vol(b) / 1e9, 3) for k, b in
(("lean", lc), ("footing", ft), ("column", cl), ("coping", cp))}))
main()
@@ -0,0 +1,143 @@
"""G2 gate checker: column rebar P1-P6 (check C8) inside Rhino 8.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values from EXPECTED_PATH sections G1 (footing
bottom) and G2 (D3 schedule L / counts, D1 dimension chains, section radii); generator params are not read.
Ported from Code/교각모델링/verify_rebar.py (same checks and order).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, chain, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G2"]
SCHED_L, SCHED_N, DIA = G["schedule_L"], G["schedule_n"], G["dia"]
FB = X["G1"]["footing"]["z0"]
LEVELS = chain(G["hoop_chain"], FB)
TIE_LEVELS = chain(G["tie_chain"], FB)
R_OUT, R_IN, R_HOOP = G["r_outer"], G["r_inner"], G["r_hoop"]
TOP = FB + sum(G["p1_top_parts"])
NDIV = G["main_divisions"]
results = []
def check(item, exp, act, tol=TOL):
results.append(dict(check="C8", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail):
results.append(dict(check="C8", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def ids(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def concrete():
out = []
for oid in (f"{PID}-CL", f"{PID}-FT", f"{PID}-CP"):
o = [x for x in all_objects(doc) if x.Attributes.GetUserString("id") == oid][0]
out.append(o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry))
return out
def main():
cl = {m: [o for o in ids(f"{PID}-RC-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
pipes = {m: [o for o in ids(f"{PID}-R-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
for m in SCHED_L:
occ = {o.Attributes.GetUserString("occurrence").rstrip("ab") for o in cl[m]}
check(f"{m} bar count", SCHED_N[m], len(occ), tol=0)
flag(f"{m} pipes closed & paired with centrelines",
len(pipes[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pipes[m]),
f"pipes {len(pipes[m])} / centrelines {len(cl[m])}")
for m in SCHED_L:
per = {}
for o in cl[m]:
k = o.Attributes.GetUserString("occurrence").rstrip("ab")
per[k] = per.get(k, 0) + float(o.Attributes.GetUserString("design_length")) + float(
o.Attributes.GetUserString("coupler_length") or 0) # schedule L includes coupler b (D3 V, IX)
worst = max(abs(v - SCHED_L[m]) for v in per.values())
check(f"{m} original length (+coupler b) vs schedule L (worst of {len(per)})", 0, worst)
curve = {}
for o in cl[m]:
k = o.Attributes.GetUserString("occurrence").rstrip("ab")
curve[k] = curve.get(k, 0) + o.Geometry.GetLength() + float(
o.Attributes.GetUserString("coupler_length") or 0)
dev = [v - SCHED_L[m] for v in curve.values()]
results.append(dict(check="C8", item=f"{m} curve length - L (record, fillets shorten hooks)",
detail=f"min {min(dev):.1f} / max {max(dev):.1f} mm", result="INFO"))
if m in G["no_fillet_marks"]:
check(f"{m} curve length (+coupler b) vs L (no fillet)", 0, max(abs(d) for d in dev))
for m, r in (("P1", R_OUT), ("P2", R_IN)):
angs, worst_r = [], 0
for o in cl[m]:
c = o.Geometry
p = c.PointAtEnd if c.PointAtEnd.Z > c.PointAtStart.Z else c.PointAtStart
angs.append(math.degrees(math.atan2(p.Y, p.X)) % 360)
worst_r = max(worst_r, abs(math.hypot(p.X, p.Y) - r))
check(f"{m} top EL", TOP, p.Z) if o is cl[m][0] else None
angs.sort()
worst_a = max(abs(a - i * 360 / NDIV) for i, a in enumerate(angs))
check(f"{m} radius (worst)", 0, worst_r, tol=0.5)
check(f"{m} angle vs i*360/{NDIV} (worst, deg)", 0, worst_a, tol=0.01)
p1b = min(min(o.Geometry.PointAtStart.Z, o.Geometry.PointAtEnd.Z) for o in cl["P1"])
p2b = min(min(o.Geometry.PointAtStart.Z, o.Geometry.PointAtEnd.Z) for o in cl["P2"])
dz = G["bottom_above_footing"]
check(f"P1 hook leg EL (footing bottom + {dz})", FB + dz, p1b)
check("P2 bottom EL", FB + dz, p2b)
hz = {}
for m in ("P3", "P4", "P5"):
for o in cl[m]:
bb = o.Geometry.GetBoundingBox(True)
hz.setdefault(round(bb.Max.Z, 1), set()).add(m)
if m != "P3":
p = o.Geometry.PointAtStart
check(f"{m} radius", R_HOOP, math.hypot(p.X, p.Y), tol=0.5) if o is cl[m][0] else None
zs = sorted(hz)
flag(f"hoop level count {len(LEVELS)}", len(zs) == len(LEVELS), f"{len(zs)}")
check("hoop levels vs dim chain (worst)", 0, max(abs(a - b) for a, b in zip(zs, LEVELS)))
marks_by_level = [list(hz[z])[0] for z in zs]
zones = G["hoop_marks_by_zone"]
flag("hoop marks by zone (" + ", ".join(f"{m} x{n}" for m, n in zones) + ")",
marks_by_level == [m for m, n in zones for _ in range(n)], "")
tz = sorted({round(o.Geometry.GetBoundingBox(True).Max.Z, 1) for o in cl["P6"]})
off = G["tie_offset"]
check(f"tie levels vs chain + {off} (worst)", 0, max(abs(a - (b + off)) for a, b in zip(tz, TIE_LEVELS)))
solids = concrete()
union = rg.Brep.CreateBooleanUnion(solids, 0.01)
shell = union[0] if union and len(union) == 1 else None # outer surface only (no internal joint faces)
results.append(dict(check="C8", item="concrete union for cover", detail=f"{len(union) if union else 0} brep",
result="INFO"))
min_cover = {}
for m in SCHED_L:
bad, cov = 0, 1e9
for o in cl[m]:
c = o.Geometry
n = max(4, int(c.GetLength() / 100))
for t in c.DivideByCount(n, True):
p = c.PointAt(t)
radial = rg.Vector3d(p.X, p.Y, 0)
radial.Unitize() if radial.Length > 1e-6 else None
for q in (p, p + radial * (DIA[m] / 2), p - radial * (DIA[m] / 2),
p + rg.Vector3d(0, 0, DIA[m] / 2), p - rg.Vector3d(0, 0, DIA[m] / 2)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
d = p.DistanceTo(shell.ClosestPoint(p)) if shell and shell.IsPointInside(p, 0.01, False) else -1
cov = min(cov, d - DIA[m] / 2)
min_cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
results.append(dict(check="C8", item="min cover to concrete surface per mark (record)",
detail=json.dumps(min_cover), result="INFO"))
results.append(dict(check="C8", item="clearance P1 outer surface vs hoop inner surface (record)",
detail=f"{R_HOOP - DIA['P4'] / 2 - (R_OUT + DIA['P1'] / 2):.1f} mm", result="INFO"))
write_result(RESULT_PATH, results, dict(min_cover=min_cover))
main()
@@ -0,0 +1,156 @@
"""G2 gate checker, form "ring" (Ulsan P1, 2026-10-13): column rebar with one main ring, hoop zones, cross ties.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G2 + G1 footing / column) come
from the schedule (L, counts, R) and the dimension chains of the rebar drawing; generator params are not read.
Checks (C8): counts, original length (+coupler) vs schedule L, curve length for bars without fillets, main bar
radius / division / leg and top level, hoop level chain / zones / radius, tie levels / count per level,
containment in concrete and minimum cover (record).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, chain, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G2"]
SCHED_L, SCHED_N, DIA = G["schedule_L"], G["schedule_n"], G["dia"]
FB = X["G1"]["footing"]["z0"]
M = G["main"]
LEVELS = chain(G["hoop_chain"], FB)
TIE = G["tie"]
TIE_LEVELS = chain(TIE["chain"], FB)
results = []
def check(item, exp, act, tol=TOL):
results.append(dict(check="C8", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C8", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C8", item=item, detail=detail, result="INFO"))
def ids(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def main():
cl = {m: [o for o in ids(f"{PID}-RC-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
pipes = {m: [o for o in ids(f"{PID}-R-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
occ = lambda o: o.Attributes.GetUserString("occurrence").rstrip("ab")
tie_mark = TIE["mark"]
for m in SCHED_L:
n_occ = len(cl[m]) if m == tie_mark else len({occ(o) for o in cl[m]})
check(f"{m} bar count", SCHED_N[m], n_occ, tol=0)
flag(f"{m} pipes closed & paired with centrelines",
len(pipes[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pipes[m]), f"{len(pipes[m])}/{len(cl[m])}")
for m in SCHED_L:
per, curve = {}, {}
for o in cl[m]:
k = o.Attributes.GetUserString("occurrence") if m == tie_mark else occ(o)
cp = float(o.Attributes.GetUserString("coupler_length") or 0)
per[k] = per.get(k, 0) + float(o.Attributes.GetUserString("design_length")) + cp
curve[k] = curve.get(k, 0) + o.Geometry.GetLength() + cp
if not per:
continue
check(f"{m} original length (+coupler) vs schedule L (worst of {len(per)})", 0,
max(abs(v - SCHED_L[m]) for v in per.values()))
dev = [v - SCHED_L[m] for v in curve.values()]
info(f"{m} curve length - L (record)", f"min {min(dev):.1f} / max {max(dev):.1f} mm")
if m in G["no_fillet_marks"]:
check(f"{m} curve length (+coupler) vs L (no fillet)", 0, max(abs(d) for d in dev))
# main bars
mm_, R0, ND = M["mark"], M["radius"], M["divisions"]
angs, worst_r, tops, legs = [], 0, [], []
for o in cl[mm_]:
c = o.Geometry
p = c.PointAtEnd if c.PointAtEnd.Z > c.PointAtStart.Z else c.PointAtStart
angs.append(math.degrees(math.atan2(p.Y, p.X)) % 360)
worst_r = max(worst_r, abs(math.hypot(p.X, p.Y) - R0))
tops.append(p.Z)
legs.append(min(c.PointAtStart.Z, c.PointAtEnd.Z))
angs.sort()
check(f"{mm_} radius (worst)", 0, worst_r, tol=0.5)
check(f"{mm_} angle vs start + i*360/{ND} (worst, deg)", 0,
max(abs(a - (M.get("start_deg", 0) + i * 360 / ND)) for i, a in enumerate(angs)), tol=0.01)
check(f"{mm_} top EL (worst)", 0, max(abs(t - (FB + sum(M["top_parts"]))) for t in tops))
check(f"{mm_} hook leg EL (footing bottom + {M['bottom_above_footing']}) (worst)", 0,
max(abs(z - (FB + M["bottom_above_footing"])) for z in legs))
# hoops
hz = {}
for m, _ in G["hoop_zones"]:
for o in cl[m]:
hz.setdefault(round(o.Geometry.GetBoundingBox(True).Max.Z, 1), set()).add(m)
zs = sorted(hz)
flag(f"hoop level count {len(LEVELS)}", len(zs) == len(LEVELS), f"{len(zs)}")
check("hoop levels vs dim chain (worst)", 0, max(abs(a - b) for a, b in zip(zs, LEVELS)) if zs else 1e9)
flag("hoop marks by zone (" + ", ".join(f"{m} x{n}" for m, n in G["hoop_zones"]) + ")",
[list(hz[z])[0] for z in zs] == [m for m, n in G["hoop_zones"] for _ in range(n)])
for m, _ in G["hoop_zones"]:
o = cl[m][0]
p = o.Geometry.PointAt(o.Geometry.Domain.Mid)
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))
# ties
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)
# containment and cover
solids = []
for oid in (f"{PID}-CL", f"{PID}-FT", f"{PID}-CP"):
o = [x for x in all_objects(doc) if x.Attributes.GetUserString("id") == oid][0]
solids.append(o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry))
union = rg.Brep.CreateBooleanUnion(solids, 0.01)
shell = union[0] if union and len(union) == 1 else None
info("concrete union for cover", f"{len(union) if union else 0} brep")
min_cover = {}
for m in SCHED_L:
bad, cov = 0, 1e9
r = DIA[m] / 2
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 150)), True):
p = c.PointAt(t)
radial = rg.Vector3d(p.X, p.Y, 0)
radial.Unitize() if radial.Length > 1e-6 else None
for q in (p, p + radial * r, p - radial * r, p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
d = p.DistanceTo(shell.ClosestPoint(p)) if shell and shell.IsPointInside(p, 0.01, False) else -1
cov = min(cov, d - r)
min_cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to concrete surface per mark (record)", json.dumps(min_cover))
write_result(RESULT_PATH, results, dict(min_cover=min_cover))
main()
@@ -0,0 +1,163 @@
"""G2 gate checker, form "straight" (Ramp-A P1, 2026-10-13): straight main ring with a bottom coupler, lapped
half-ring hoops, X / Y cross ties; column on a drilled shaft (no footing).
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G2) come from the schedule (L, N, r)
and the D2 dimension chains (anchor EL 45.492: '2,000' below, 14@150 / 78@150 hoops, 7@300 / 39@300 ties) with
the user decisions K1-K3; generator params are not read.
Checks (C8): counts, original length (+coupler) vs schedule L, main radius / division / bottom / top, hoop and tie
level grids and radius, X / Y ties apart, containment in concrete (pile segments, column, coping), min cover (record).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G2"]
SCHED_L, SCHED_N, DIA = G["schedule_L"], G["schedule_n"], G["dia"]
M, HP, TI = G["main"], G["hoops"], G["ties"]
A = G["chain_anchor"]
def grid(g):
return [A - g["below"] * g["pitch"] + k * g["pitch"] for k in range(SCHED_N[g["mark"]] // g.get("per_level", 1))]
HOOP_LEVELS, TIE_LEVELS = grid(HP), grid(TI)
CO = X["G1"]["column"]
R_COL, Z_CL0, Z_CL1 = CO["d"] / 2, CO["z0"], CO["z0"] + CO["length"]
results = []
def check(item, exp, act, tol=TOL):
results.append(dict(check="C8", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C8", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C8", item=item, detail=detail, result="INFO"))
def ids(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def main():
cl = {m: [o for o in ids(f"{PID}-RC-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
pipes = {m: [o for o in ids(f"{PID}-R-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
occ = lambda o: o.Attributes.GetUserString("occurrence").rstrip("ab")
tie = TI["mark"]
for m in SCHED_L:
n_occ = len(cl[m]) if m == tie else len({occ(o) for o in cl[m]})
check(f"{m} bar count", SCHED_N[m], n_occ, tol=0)
flag(f"{m} pipes closed & paired with centrelines",
len(pipes[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pipes[m]), f"{len(pipes[m])}/{len(cl[m])}")
for m in SCHED_L:
per, curve = {}, {}
for o in cl[m]:
k = o.Attributes.GetUserString("occurrence") if m == tie else occ(o)
cp = float(o.Attributes.GetUserString("coupler_length") or 0)
per[k] = per.get(k, 0) + float(o.Attributes.GetUserString("design_length")) + cp
curve[k] = curve.get(k, 0) + o.Geometry.GetLength() + cp
if per:
check(f"{m} original length (+coupler) vs schedule L (worst of {len(per)})", 0,
max(abs(v - SCHED_L[m]) for v in per.values()))
dev = [v - SCHED_L[m] for v in curve.values()]
info(f"{m} curve length (+coupler) - L (record)", f"min {min(dev):.1f} / max {max(dev):.1f} mm")
# main bars: straight, chain bottom (anchor - 2,000), top = bottom + L - coupler (K1)
mm_ = M["mark"]
bottom = A - M["below_anchor"]
top = bottom + SCHED_L[mm_] - M["coupler"]
angs, worst_r, zb, zt = [], 0, [], []
for o in cl[mm_]:
c = o.Geometry
lo, hi = sorted((c.PointAtStart, c.PointAtEnd), key=lambda p: p.Z)
angs.append(math.degrees(math.atan2(hi.Y, hi.X)) % 360)
worst_r = max(worst_r, abs(math.hypot(lo.X, lo.Y) - M["radius"]), abs(math.hypot(hi.X, hi.Y) - M["radius"]))
zb.append(lo.Z)
zt.append(hi.Z)
angs.sort()
check(f"{mm_} radius (worst)", 0, worst_r, tol=0.5)
check(f"{mm_} angle vs {M['start_deg']} + i*360/{M['divisions']} (worst, deg)", 0,
max(abs(a - (M["start_deg"] + i * 360 / M["divisions"])) for i, a in enumerate(angs)), tol=0.01)
check(f"{mm_} bottom EL = chain {A / 1000:.3f} - {M['below_anchor']} (worst)", 0, max(abs(z - bottom) for z in zb))
check(f"{mm_} top EL = bottom + L - coupler {M['coupler']} (worst)", 0, max(abs(z - top) for z in zt))
# hoops: levels on the chain grid, two pieces per level, radius
hz = {}
for o in cl[HP["mark"]]:
bb = o.Geometry.GetBoundingBox(True)
hz.setdefault(round((bb.Max.Z + bb.Min.Z) / 2, 1), []).append(o)
zs = sorted(hz)
flag(f"{HP['mark']} levels {len(HOOP_LEVELS)} x 2 pieces", len(zs) == len(HOOP_LEVELS)
and all(len(v) == 2 for v in hz.values()), f"{len(zs)} levels")
check(f"{HP['mark']} levels vs chain grid {HP['pitch']} (worst)", 0,
max(abs(a - b) for a, b in zip(zs, HOOP_LEVELS)) if len(zs) == len(HOOP_LEVELS) else 1e9)
for o in cl[HP["mark"]][:4]:
c = o.Geometry
p = c.PointAt(c.Domain.Mid)
check(f"{HP['mark']} radius at mid point ({o.Attributes.GetUserString('occurrence')})", HP["radius"],
math.hypot(p.X, p.Y), tol=0.5)
# ties: X at the grid level, Y lifted (K3); levels = top of the straight part
by = lambda s: [o.Geometry for o in cl[tie] if o.Attributes.GetUserString("occurrence").endswith(s)]
xs, ys = by("X"), by("Y")
txm = sorted(round(g.GetBoundingBox(True).Max.Z, 1) for g in xs)
tym = sorted(round(g.GetBoundingBox(True).Max.Z, 1) for g in ys)
flag(f"{tie} X / Y per level, {len(TIE_LEVELS)} levels", len(txm) == len(tym) == len(TIE_LEVELS), f"X {len(txm)} Y {len(tym)}")
if len(txm) == len(TIE_LEVELS):
check(f"{tie} X levels vs chain grid {TI['pitch']} (worst)", 0, max(abs(a - b) for a, b in zip(txm, TIE_LEVELS)))
if len(tym) == len(TIE_LEVELS):
check(f"{tie} Y levels = X + {TI['y_lift']} (worst)", 0, max(abs(a - b - TI["y_lift"]) for a, b in zip(tym, TIE_LEVELS)))
xs.sort(key=lambda g: g.GetBoundingBox(True).Max.Z)
ys.sort(key=lambda g: g.GetBoundingBox(True).Max.Z)
gaps = []
for a, b in zip(xs, ys):
ok, pa, pb = a.ClosestPoints(b)
gaps.append(pa.DistanceTo(pb) if ok else 0.0)
gap = min(gaps) if gaps else 0.0
check(f"{tie} X / Y centrelines apart >= bar diameter {DIA[tie]} (min, capped)", DIA[tie], min(gap, DIA[tie]), tol=0.01)
info(f"{tie} X / Y centreline distance (min, record)", f"{gap:.2f} mm")
# containment and cover
solids = []
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
if oid in (f"{PID}-CL", f"{PID}-CP") or oid.startswith(f"{PID}-PL"):
solids.append(o.Geometry if isinstance(o.Geometry, rg.Brep) else rg.Brep.TryConvertBrep(o.Geometry))
union = rg.Brep.CreateBooleanUnion(solids, 0.01)
shell = union[0] if union and len(union) == 1 else None
info("concrete union for cover", f"{len(union) if union else 0} brep from {len(solids)} solids"
+ ("" if shell else " -> min cover = column side cover in the column zone (R - radial distance - bar radius)"))
min_cover = {}
for m in SCHED_L:
bad, cov = 0, 1e9
r = DIA[m] / 2
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 150)), True):
p = c.PointAt(t)
radial = rg.Vector3d(p.X, p.Y, 0)
radial.Unitize() if radial.Length > 1e-6 else None
for q in (p, p + radial * r, p - radial * r, p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
if shell:
d = p.DistanceTo(shell.ClosestPoint(p)) if shell.IsPointInside(p, 0.01, False) else -1
cov = min(cov, d - r)
elif Z_CL0 <= p.Z <= Z_CL1: # no union (coplanar contact faces): side cover in the column zone
cov = min(cov, R_COL - math.hypot(p.X, p.Y) - r)
min_cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to concrete surface per mark (record)", json.dumps(min_cover))
write_result(RESULT_PATH, results, dict(min_cover=min_cover))
main()
@@ -0,0 +1,146 @@
"""G3 gate checker: footing rebar B1-B12 (check C9) and rebar colours inside Rhino 8.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values from EXPECTED_PATH sections G1 (footing
bottom) and G3 (D3 schedule, D1/D2 dimension chains, F1 layer heights); mid-layer positions are drawn dots
(derived, not independent). Generator params are not read.
Ported from Code/교각모델링/verify_footing_rebar.py (same checks and order).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectColorSource
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, row, series, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G3"]
FB = X["G1"]["footing"]["z0"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
DIA = G["dia"]
Y_BOT, Y_250, X_BOT, X_250 = (row(G["rows"][k]) for k in ("Y_BOT", "Y_250", "X_BOT", "X_250"))
Z = G["layer_z"]
B10_Z = series(G["b10_levels"])
B9_X, B9_Y = series(G["b9_grid"]["x"]), series(G["b9_grid"]["y"])
PALETTE = {int(k): tuple(v) for k, v in X["rebar_palette"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C9", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C9", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C9", item=item, detail=detail, result="INFO"))
def objs(prefix, mark):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)
and o.Attributes.GetUserString("mark") == mark]
def main():
cl = {m: objs(f"{PID}-RC-", m) for m in SCHED}
pp = {m: objs(f"{PID}-R-", m) for m in SCHED}
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]),
f"{len(pp[m])}/{len(cl[m])}")
if cl[m]:
worst = max(abs(float(o.Attributes.GetUserString("design_length")) - L) for o in cl[m])
check(f"{m} original length vs L (worst)", 0, worst)
dev = [o.Geometry.GetLength() - L for o in cl[m]]
info(f"{m} curve length - L (bend fillets)", f"min {min(dev):.1f} / max {max(dev):.1f}")
def mids(m):
return [o.Geometry.PointAt(o.Geometry.Domain.Mid) for o in cl[m]]
for m in ("B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"):
ps = mids(m)
check(f"{m} layer z (worst)", 0, max(abs(p.Z - (FB + Z[m])) for p in ps))
def pos_check(marks, axis, expected, name):
got = sorted(round(getattr(p, axis), 1) for m in marks for p in mids(m))
ok = len(got) == len(expected)
worst = max(abs(a - b) for a, b in zip(got, expected)) if ok else 1e9
check(f"{name} positions vs chain (worst, n={len(got)}/{len(expected)})", 0, worst)
pos_check(("B2", "B4"), "X", Y_BOT, "bottom Y-bars B2+B4")
pos_check(("B6", "B8"), "Y", X_BOT, "bottom X-bars B6+B8")
pos_check(("B3",), "X", Y_250, "B3")
pos_check(("B1",), "X", Y_250, "B1")
pos_check(("B7",), "Y", X_250, "B7")
pos_check(("B5",), "Y", X_250, "B5")
ye, xe = G["rows"]["Y_BOT"], G["rows"]["X_BOT"]
b2x = sorted(round(p.X) for p in mids("B2"))
flag(f"B2 at ends and alternate from {ye['start']}", b2x == sorted(list(ye["ends"]) + Y_BOT[1:-1][0::2]), "")
b6y = sorted(round(p.Y) for p in mids("B6"))
flag(f"B6 at ends and alternate from {xe['start']}", b6y == sorted(list(xe["ends"]) + X_BOT[1:-1][0::2]), "")
mid = G["mid_levels"]
for m in ("B11", "B12"):
zs = sorted({round(p.Z - FB) for p in mids(m)})
flag(f"{m} mid levels {'/'.join(str(v) for v in mid)} [derived]", zs == mid, str(zs))
zs = sorted(round(o.Geometry.GetBoundingBox(True).Min.Z - FB, 1) for o in cl["B10"])
check("B10 levels vs chain (worst)", 0, max(abs(a - b) for a, b in zip(zs, B10_Z)) if len(zs) == len(B10_Z) else 1e9)
bb = cl["B10"][0].Geometry.GetBoundingBox(True)
check("B10 centreline size X", G["b10_size"][0], bb.Max.X - bb.Min.X)
check("B10 centreline size Y", G["b10_size"][1], bb.Max.Y - bb.Min.Y)
# B9: vertical leg x = bbox min X (hooks point +X); y constant
pts = {(round(o.Geometry.GetBoundingBox(True).Min.X), round(o.Geometry.GetBoundingBox(True).Center.Y))
for o in cl["B9"]}
exp = {(x, y) for i, x in enumerate(B9_X) for j, y in enumerate(B9_Y) if (i + j) % 2 == 0}
flag(f"B9 positions = checkerboard of {len(B9_X)}x{len(B9_Y)} grid", pts == exp, f"{len(pts)} / {len(exp)}")
zr = [o.Geometry.GetBoundingBox(True) for o in cl["B9"]]
e0, e1 = G["b9_erec"]
a9, c9 = G["b9_a"], G["b9_c"]
check("B9 bottom z (worst of all)", 0, max(abs(b.Min.Z - (FB + (e0 + e1) / 2 - a9 / 2)) for b in zr))
check("B9 vertical extent a (bottom leg z to top of straight)", a9,
max(b.Max.Z for b in zr[:1]) - min(b.Min.Z for b in zr[:1]) - c9 / math.pi, tol=1.0)
solids = []
for oid in (f"{PID}-FT", f"{PID}-CL", f"{PID}-CP"):
o = [x for x in all_objects(doc) if x.Attributes.GetUserString("id") == oid][0]
solids.append(o.Geometry)
ft = solids[0]
shell = None
for order in ([solids[1], solids[0], solids[2]], solids): # union is order-sensitive in Rhino 8
u = rg.Brep.CreateBooleanUnion(order, 0.01)
if u and len(u) == 1:
shell = u[0]
break
if shell is None:
raise RuntimeError("concrete union failed")
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 200)), True):
p = c.PointAt(t)
r = DIA[m] / 2
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not ft.IsPointInside(q, 0.01, False):
bad += 1
cov = min(cov, p.DistanceTo(shell.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside footing", 0, bad, tol=0)
info("min cover to concrete exterior per mark", json.dumps(cover))
wrong = 0
for o in all_objects(doc):
if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-R-"):
d = int(o.Attributes.GetUserString("dia")[1:])
c = o.Attributes.ObjectColor
if o.Attributes.ColorSource != ObjectColorSource.ColorFromObject or (c.R, c.G, c.B) != PALETTE[d]:
wrong += 1
check("rebar pipe colour by diameter mismatches (all rebar)", 0, wrong, tol=0)
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,158 @@
"""G3 gate checker, form "mats" (Ulsan P1, 2026-10-13): footing rebar groups, perimeter hoops, verticals.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G3) from the schedule and the
position chains of the rebar drawing (layer heights are drawn values, marked [derived]); params are not read.
expected.G3: schedule {mark: [L, n]}, dia {mark: d}, rows {name: {first, start, pitch, n, last}},
layers {mark: {axis, z, row | pos, subset}} (subset "on"/"off": positions on / off the `of` row)
hoops {mark, half, chain {start, groups}}, verticals {mark, grid_x row, grid_y row, parity, z0, a}
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): counts, original length, layer z, positions, hoop levels / size, vertical grid, containment in the
footing, minimum cover (record), rebar colours by diameter.
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectColorSource
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, chain, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G3"]
FB = X["G1"]["footing"]["z0"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
DIA = G["dia"]
PALETTE = {int(k): tuple(v) for k, v in X["rebar_palette"].items()}
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, exp, act, tol=1.0):
results.append(dict(check="C9", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C9", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C9", item=item, detail=detail, result="INFO"))
def row(spec):
return sorted(list(spec.get("first", [])) + [spec["start"] + spec["pitch"] * i for i in range(spec["n"])]
+ list(spec.get("last", [])))
def positions(lay):
if "pos" in lay:
return sorted(lay["pos"])
base = row(G["rows"][lay["row"]])
if "subset" in lay:
ref = set(row(G["rows"][lay["of"]]))
base = [v for v in base if (v in ref) == (lay["subset"] == "on")]
return base
def objs(prefix, mark):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)
and o.Attributes.GetUserString("mark") == mark]
def main():
cl = {m: objs(f"{PID}-RC-", m) for m in SCHED}
pp = {m: objs(f"{PID}-R-", m) for m in SCHED}
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]),
f"{len(pp[m])}/{len(cl[m])}")
if cl[m]:
check(f"{m} original length vs L (worst)", 0,
max(abs(float(o.Attributes.GetUserString("design_length")) - L) for o in cl[m]))
dev = [o.Geometry.GetLength() - L for o in cl[m]]
info(f"{m} curve length - L (bend fillets / laps)", f"min {min(dev):.1f} / max {max(dev):.1f}")
for m, lay in G["layers"].items():
if not cl[m]:
continue
# horizontal part: the lowest (legs up / none) or highest (legs down) straight segment -> bbox mid level
zs, ps = [], []
for o in cl[m]:
bb = bbx(o)
leg_down = lay.get("leg", 0) < 0
zs.append((bb.Max.Z if leg_down else bb.Min.Z) - FB)
ps.append(round((bb.Min.X + bb.Max.X) / 2 if lay["axis"] == "Y" else (bb.Min.Y + bb.Max.Y) / 2, 1))
check(f"{m} layer z (worst) [derived]", 0, max(abs(z - lay["z"]) for z in zs))
exp = positions(lay)
got = sorted(ps)
ok = len(got) == len(exp)
check(f"{m} positions vs chain (worst, n={len(got)}/{len(exp)})", 0,
max(abs(a - b) for a, b in zip(got, exp)) if ok else 1e9)
hp = G["hoops"]
lv = chain(hp["chain"], 0)
hz = sorted(round(bbx(o).Min.Z - FB, 1) for o in cl[hp["mark"]])
check(f"{hp['mark']} levels vs chain (worst)", 0, max(abs(a - b) for a, b in zip(hz, lv)) if len(hz) == len(lv) else 1e9)
if cl[hp["mark"]]:
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)
vt = G["verticals"]
gx, gy = row(G["rows"][vt["grid_x"]]), row(G["rows"][vt["grid_y"]])
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)}")
if cl[vt["mark"]]:
check(f"{vt['mark']} bottom z (worst)", 0,
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)
ft = [o for o in all_objects(doc) if o.Attributes.GetUserString("id") == f"{PID}-FT"][0].Geometry
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = DIA[m] / 2
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 200)), True):
p = c.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not ft.IsPointInside(q, 0.01, False):
bad += 1
cov = min(cov, p.DistanceTo(ft.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside footing", 0, bad, tol=0)
info("min cover to footing surface per mark (record; top face under the column counted)", json.dumps(cover))
wrong = 0
for o in all_objects(doc):
if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-R-"):
d = int(o.Attributes.GetUserString("dia")[1:])
c = o.Attributes.ObjectColor
if o.Attributes.ColorSource != ObjectColorSource.ColorFromObject or (c.R, c.G, c.B) != PALETTE.get(d):
wrong += 1
check("rebar pipe colour by diameter mismatches (all rebar)", 0, wrong, tol=0)
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,115 @@
"""G3 gate checker, footing rebar as centreline polylines (Okseong P1, 2026-10-14): mats B1~B8, mid layers B11 / B12,
perimeter loops B10, vertical ties B9.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G3) come from the schedule (L, N), the
front / side dimension chains (bar positions), the leg lengths b of the schedule, the drawn mat / loop levels and
the B9 grid rule with the user decisions F1-F4; generator params are not read.
Checks (C9): counts, original length vs L, per mat: positions vs chain, straight level, span, leg length and
direction; mid layers; B10 levels / size; B9 grid / height; containment in the footing; min cover (record).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, chain, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G3"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C9", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C9", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C9", item=item, detail=detail, result="INFO"))
def bb(o):
return o.Geometry.GetBoundingBox(True)
def worst(a, b):
return max(abs(x - y) for x, y in zip(a, b)) if len(a) == len(b) and a else 1e9
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]), f"{len(pp[m])}/{len(cl[m])}")
if cl[m]:
check(f"{m} original length vs L (worst)", 0, max(abs(float(o.Attributes.GetUserString("design_length")) - L) for o in cl[m]))
half = G["half_a"]
for name, mt in G["mats"].items():
objs = [o for m in mt["marks"] for o in cl[m]]
axis = mt["axis"]
pos = sorted(round((bb(o).Min.X + bb(o).Max.X) / 2 if axis == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in objs)
want = chain(mt["chain"])
check(f"{name} positions vs chain ({len(pos)}/{len(want)}, worst)", 0, worst(pos, want))
leg = mt["leg"]
straight = [bb(o).Min.Z if leg > 0 else bb(o).Max.Z for o in objs]
check(f"{name} straight part level {mt['z']} (worst)", 0, max(abs(z - mt["z"]) for z in straight) if straight else 1e9)
span = [(bb(o).Max.Y - bb(o).Min.Y) if axis == "Y" else (bb(o).Max.X - bb(o).Min.X) for o in objs]
check(f"{name} span 2 x {half} (worst)", 0, max(abs(s - 2 * half) for s in span) if span else 1e9)
legs = [(bb(o).Max.Z - bb(o).Min.Z) for o in objs]
check(f"{name} leg {abs(leg)} {'up' if leg > 0 else 'down'} (worst)", 0, max(abs(h - abs(leg)) for h in legs) if legs else 1e9)
a, b = mt["marks"]
ia = sorted(round((bb(o).Min.X + bb(o).Max.X) / 2 if axis == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in cl[a])
flag(f"{name} {a}/{b} alternate from the edge (F3)", ia == want[0::2], f"{a} {len(ia)}")
for m, md in G["mid"].items():
got = sorted((round(bb(o).Max.Z, 1), round((bb(o).Min.X + bb(o).Max.X) / 2 if md["axis"] == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1)) for o in cl[m])
want = sorted((md["z_hi"] if round(p) % 1000 == 0 else md["z_lo"], round(p, 1)) for p in chain(md["chain"]))
flag(f"{m} levels / positions ({len(want)})", len(got) == len(want) and all(abs(g[0] - w[0]) <= 1 and abs(g[1] - w[1]) <= 1 for g, w in zip(got, want)),
f"{got[:3]} vs {want[:3]}")
b10 = G["b10"]
zs = sorted(round(bb(o).Min.Z, 1) for o in cl["B10"])
check("B10 levels (worst)", 0, worst(zs, sorted(b10["levels"])))
check("B10 loop size X (worst)", 0, max(abs(bb(o).Max.X - bb(o).Min.X - b10["size"]) for o in cl["B10"]) if cl["B10"] else 1e9)
b9 = G["b9"]
cols = sorted({round((bb(o).Min.X + bb(o).Max.X) / 2, 1) for o in cl["B9"]})
flag(f"B9 columns {b9['n_cols']} @{b9['pitch_x']}", len(cols) == b9["n_cols"] and all(abs(b - a - b9["pitch_x"]) < 1 for a, b in zip(cols, cols[1:])), f"{len(cols)}")
check("B9 height top-bottom (worst)", b9["height"], max((bb(o).Max.Z - bb(o).Min.Z for o in cl["B9"]), key=lambda h: abs(h - b9["height"])) if cl["B9"] else 0)
flag("B9 hooks the top and bottom Y bars", all(bb(o).Max.Z > G["mats"]["y_top"]["z"] and bb(o).Min.Z < G["mats"]["y_bottom"]["z"] for o in cl["B9"]))
# containment and cover
ft = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-FT"][0]
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 300)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not ft.IsPointInside(q, 0.01, False):
bad += 1
cov = min(cov, p.DistanceTo(ft.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside the footing", 0, bad, tol=0)
info("min cover to the footing surface per mark (record)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,181 @@
"""G3 gate checker, form "shaft cage" (Ramp-A P1, 2026-10-13): drilled-shaft cage bars and the anti-uplift plate.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G3) come from the D4 schedule (L, N),
the 구조물 치수표 chains measured from the shaft top EL (D1), the section radii and the user decisions S1-S6;
generator params are not read.
Checks (C9): counts, original length (+coupler) vs schedule L, main radius / division / piece levels, hoop and
tie level grids, hoop radius, X / Y ties apart, U bars (count, bottom level, width, planes), plate (size,
thickness, centre, cover), containment in the pile segments, min side cover (record).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, TOL, G = X["pier_id"], X["tol_mm"], X["G3"]
SCHED_L, SCHED_N, DIA = G["schedule_L"], G["schedule_n"], G["dia"]
M, HP, TI, U, PL = G["main"], G["hoops"], G["ties"], G["u_bars"], G["plate"]
TOP = G["shaft_top"]
PILE = X["G1"]["pile"]
Z_BOT = PILE["z_bottom"]
SEG = [] # (z0, z1, radius) bottom to top
z = Z_BOT
for s in PILE["segments"]:
SEG.append((z, z + s["length"], s["d"] / 2))
z += s["length"]
def chain(c):
"""Levels from the shaft top: first offset, then (count, pitch) groups downwards; returned bottom to top."""
out = [TOP - c["first"]]
for n, p in c["groups"]:
out += [out[-1] - p * k for k in range(1, n + 1)]
return sorted(out)
HOOP_LEVELS, TIE_LEVELS = chain(HP["chain"]), chain(TI["chain"])
results = []
def check(item, exp, act, tol=TOL):
results.append(dict(check="C9", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C9", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C9", item=item, detail=detail, result="INFO"))
def ids(prefix):
return [o for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(prefix)]
def main():
cl = {m: [o for o in ids(f"{PID}-RC-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
pipes = {m: [o for o in ids(f"{PID}-R-") if o.Attributes.GetUserString("mark") == m] for m in SCHED_L}
occ = lambda o: o.Attributes.GetUserString("occurrence").rstrip("ab")
hm, tm = HP["mark"], TI["mark"]
for m in SCHED_L:
n_occ = len({occ(o) for o in cl[m]}) if m == hm else len(cl[m])
check(f"{m} bar count", SCHED_N[m], n_occ, tol=0)
flag(f"{m} pipes closed & paired with centrelines",
len(pipes[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pipes[m]), f"{len(pipes[m])}/{len(cl[m])}")
for m in SCHED_L:
per, curve = {}, {}
for o in cl[m]:
k = occ(o) if m == hm else o.Attributes.GetUserString("occurrence")
cp = float(o.Attributes.GetUserString("coupler_length") or 0)
per[k] = per.get(k, 0) + float(o.Attributes.GetUserString("design_length")) + cp
curve[k] = curve.get(k, 0) + o.Geometry.GetLength() + cp
if per:
check(f"{m} original length (+coupler) vs schedule L (worst of {len(per)})", 0,
max(abs(v - SCHED_L[m]) for v in per.values()))
dev = [v - SCHED_L[m] for v in curve.values()]
info(f"{m} curve length (+coupler) - L (record)", f"min {min(dev):.1f} / max {max(dev):.1f} mm")
# main bars: pieces stacked between the first and the last hoop level (S6: coupler length 0)
zb, zt = HOOP_LEVELS[0], HOOP_LEVELS[-1]
check("main span (last - first hoop level) = sum of piece L", zt - zb, sum(SCHED_L[mk] for mk in M["pieces"]))
z = zb
for mk in M["pieces"]: # bottom to top
lo = [min(o.Geometry.PointAtStart.Z, o.Geometry.PointAtEnd.Z) for o in cl[mk]]
hi = [max(o.Geometry.PointAtStart.Z, o.Geometry.PointAtEnd.Z) for o in cl[mk]]
check(f"{mk} bottom EL (worst)", 0, max(abs(v - z) for v in lo))
check(f"{mk} top EL = bottom + L (worst)", 0, max(abs(v - (z + SCHED_L[mk])) for v in hi))
z += SCHED_L[mk]
rr = [math.hypot(o.Geometry.PointAtStart.X, o.Geometry.PointAtStart.Y) for o in cl[mk]]
check(f"{mk} radius (worst)", 0, max(abs(r - M["radius"]) for r in rr), tol=0.5)
a = sorted(math.degrees(math.atan2(o.Geometry.PointAtStart.Y, o.Geometry.PointAtStart.X)) % 360 for o in cl[mk])
check(f"{mk} angle vs {M['start_deg']} + i*360/{M['divisions']} (worst, deg)", 0,
max(abs(x - (M["start_deg"] + i * 360 / M["divisions"])) for i, x in enumerate(a)), tol=0.01)
# hoops
hz = {}
for o in cl[hm]:
bb = o.Geometry.GetBoundingBox(True)
hz.setdefault(round((bb.Max.Z + bb.Min.Z) / 2, 1), []).append(o)
zs = sorted(hz)
flag(f"{hm} levels {len(HOOP_LEVELS)} x 2 pieces", len(zs) == len(HOOP_LEVELS) and all(len(v) == 2 for v in hz.values()),
f"{len(zs)} levels")
check(f"{hm} levels vs 구조물 치수표 chain (worst)", 0,
max(abs(a - b) for a, b in zip(zs, HOOP_LEVELS)) if len(zs) == len(HOOP_LEVELS) else 1e9)
for o in cl[hm][:4]:
c = o.Geometry
p = c.PointAt(c.Domain.Mid)
check(f"{hm} radius at mid point ({o.Attributes.GetUserString('occurrence')})", HP["radius"], math.hypot(p.X, p.Y), tol=0.5)
# ties
by = lambda s: sorted((o.Geometry for o in cl[tm] if o.Attributes.GetUserString("occurrence").endswith(s)),
key=lambda g: g.GetBoundingBox(True).Max.Z)
xs, ys = by("X"), by("Y")
txm = [g.GetBoundingBox(True).Max.Z for g in xs]
tym = [g.GetBoundingBox(True).Max.Z for g in ys]
flag(f"{tm} X / Y per level, {len(TIE_LEVELS)} levels", len(txm) == len(tym) == len(TIE_LEVELS), f"X {len(txm)} Y {len(tym)}")
if len(txm) == len(TIE_LEVELS) == len(tym):
check(f"{tm} X levels vs chain (worst)", 0, max(abs(a - b) for a, b in zip(txm, TIE_LEVELS)))
check(f"{tm} Y levels = X + {TI['y_lift']} (worst)", 0, max(abs(a - b - TI["y_lift"]) for a, b in zip(tym, TIE_LEVELS)))
gaps = []
for a, b in zip(xs, ys):
ok, pa, pb = a.ClosestPoints(b)
gaps.append(pa.DistanceTo(pb) if ok else 0.0)
gap = min(gaps) if gaps else 0.0
check(f"{tm} X / Y centrelines apart >= bar diameter {DIA[tm]} (min, capped)", DIA[tm], min(gap, DIA[tm]), tol=0.01)
# U bars: bottom level, overall width (vertical legs on the hoop circle), one plane each through the centre
um = U["mark"]
bots = [o.Geometry.GetBoundingBox(True).Min.Z for o in cl[um]]
check(f"{um} bottom EL = shaft bottom + {U['bottom_above']} (worst, centreline)", 0,
max(abs(b - (Z_BOT + U["bottom_above"])) for b in bots) if bots else 1e9)
w, planar = [], 0
for o in cl[um]:
c = o.Geometry
a, b = c.PointAtStart, c.PointAtEnd
w.append(math.hypot(a.X - b.X, a.Y - b.Y))
ok, pl = c.TryGetPlane(0.01)
if ok and abs(pl.Normal.Z) < 1e-6 and abs(pl.DistanceTo(rg.Point3d(0, 0, pl.Origin.Z))) < 0.5:
planar += 1
check(f"{um} leg spacing (worst) = 2 x hoop radius {2 * HP['radius']:.0f}", 0, max(abs(x - 2 * HP["radius"]) for x in w) if w else 1e9)
check(f"{um} vertical planes through the centre", len(cl[um]), planar, tol=0)
# plate
pts = ids(f"{PID}-PT")
flag("plate count 1", len(pts) == 1, str(len(pts)))
if pts:
bb = pts[0].Geometry.GetBoundingBox(True)
check("plate size X", PL["size"], bb.Max.X - bb.Min.X)
check("plate size Y", PL["size"], bb.Max.Y - bb.Min.Y)
check("plate thickness", PL["thickness"], bb.Max.Z - bb.Min.Z, tol=0.01)
check("plate centre X / Y offset", 0, math.hypot((bb.Max.X + bb.Min.X) / 2, (bb.Max.Y + bb.Min.Y) / 2))
check(f"plate top = U bottom - {DIA[um] / 2:.0f} (S4)", Z_BOT + U["bottom_above"] - DIA[um] / 2, bb.Max.Z, tol=0.01)
info("plate bottom cover (record)", f"{bb.Min.Z - Z_BOT:.1f} mm")
# containment and side cover in the pile segments
solids = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-PL")]
min_cover = {}
for m in SCHED_L:
bad, cov = 0, 1e9
r = DIA[m] / 2
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 150)), True):
p = c.PointAt(t)
radial = rg.Vector3d(p.X, p.Y, 0)
radial.Unitize() if radial.Length > 1e-6 else None
for q in (p, p + radial * r, p - radial * r, p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
R = next((rad for z0, z1, rad in SEG if z0 <= p.Z <= z1), None)
if R is not None:
cov = min(cov, R - math.hypot(p.X, p.Y) - r, p.Z - Z_BOT - r)
min_cover[m] = round(cov, 1)
check(f"{m} sample points outside the pile concrete", 0, bad, tol=0)
info("min cover per mark (side or bottom, record)", json.dumps(min_cover))
write_result(RESULT_PATH, results, dict(min_cover=min_cover))
main()
@@ -0,0 +1,124 @@
"""G3 gate checker, footing rebar as centreline polylines with drawn position lists (Saejae-1 P1, 2026-10-15; adapted from g3_poly.py,
Okseong P1): mats B1~B8, mid layers B11 / B12, perimeter loops B10, vertical ties B9 (6 rows, 8 / 7 per row alternating).
Positions are explicit lists (expected G3 "pos" / "levels" / "b9") typed from the drawn points, not dimension chains.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G3) come from the schedule (L, N), the
front / side dimension chains (bar positions), the leg lengths b of the schedule, the drawn mat / loop levels and
the B9 grid rule with the user decisions F1-F4; generator params are not read.
Checks (C9): counts, original length vs L, per mat: positions vs chain, straight level, span, leg length and
direction; mid layers; B10 levels / size; B9 grid / height; containment in the footing; min cover (record).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G3"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C9", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C9", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C9", item=item, detail=detail, result="INFO"))
def bb(o):
return o.Geometry.GetBoundingBox(True)
def mid_(o):
return (bb(o).Min.X + bb(o).Max.X) / 2, (bb(o).Min.Y + bb(o).Max.Y) / 2
def worst(a, b):
return max(abs(x - y) for x, y in zip(a, b)) if len(a) == len(b) and a else 1e9
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]), f"{len(pp[m])}/{len(cl[m])}")
if cl[m]:
check(f"{m} original length vs L (worst)", 0, max(abs(float(o.Attributes.GetUserString("design_length")) - L) for o in cl[m]))
half = G["half_a"]
for name, mt in G["mats"].items():
objs = [o for m in mt["marks"] for o in cl[m]]
axis = mt["axis"]
pos = sorted(round((bb(o).Min.X + bb(o).Max.X) / 2 if axis == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in objs)
want = sorted(mt["pos"])
check(f"{name} positions vs chain ({len(pos)}/{len(want)}, worst)", 0, worst(pos, want))
leg = mt["leg"]
straight = [bb(o).Min.Z if leg > 0 else bb(o).Max.Z for o in objs]
check(f"{name} straight part level {mt['z']} (worst)", 0, max(abs(z - mt["z"]) for z in straight) if straight else 1e9)
span = [(bb(o).Max.Y - bb(o).Min.Y) if axis == "Y" else (bb(o).Max.X - bb(o).Min.X) for o in objs]
check(f"{name} span 2 x {half} (worst)", 0, max(abs(s - 2 * half) for s in span) if span else 1e9)
legs = [(bb(o).Max.Z - bb(o).Min.Z) for o in objs]
check(f"{name} leg {abs(leg)} {'up' if leg > 0 else 'down'} (worst)", 0, max(abs(h - abs(leg)) for h in legs) if legs else 1e9)
a, b = mt["marks"]
ia = sorted(round((bb(o).Min.X + bb(o).Max.X) / 2 if axis == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in cl[a])
flag(f"{name} {a}/{b} alternate from the edge (F3)", ia == want[0::2], f"{a} {len(ia)}")
for m, md in G["mid"].items():
lift = md.get("lift", 0)
got = sorted((round(bb(o).Max.Z, 1), round((bb(o).Min.X + bb(o).Max.X) / 2 if md["axis"] == "Y" else (bb(o).Min.Y + bb(o).Max.Y) / 2, 1)) for o in cl[m])
want = sorted((float(z) + lift, p) for z, ps in md["levels"].items() for p in ps)
flag(f"{m} levels / positions ({len(want)})", len(got) == len(want) and all(abs(g[0] - w[0]) <= 1 and abs(g[1] - w[1]) <= 1 for g, w in zip(got, want)),
f"{got[:3]} vs {want[:3]}")
b10 = G["b10"]
zs = sorted(round(bb(o).Min.Z, 1) for o in cl["B10"])
check("B10 levels (worst)", 0, worst(zs, sorted(b10["levels"])))
check("B10 loop size X (worst)", 0, max(abs(bb(o).Max.X - bb(o).Min.X - b10["size"]) for o in cl["B10"]) if cl["B10"] else 1e9)
b9 = G["b9"]
got9 = sorted((round(mid_(o)[0], 1), round(mid_(o)[1], 1)) for o in cl["B9"])
want9 = sorted((x, y) for k, y in enumerate(b9["rows"]) for x in (b9["cols_even"] if k % 2 == 0 else b9["cols_odd"]))
flag(f"B9 grid {len(want9)} (rows {len(b9['rows'])}, 8 / 7 per row)", len(got9) == len(want9) and all(abs(g[0] - w[0]) <= 1 and abs(g[1] - w[1]) <= 1 for g, w in zip(got9, want9)),
f"{len(got9)}")
check("B9 height top-bottom (worst)", b9["height"], max((bb(o).Max.Z - bb(o).Min.Z for o in cl["B9"]), key=lambda h: abs(h - b9["height"])) if cl["B9"] else 0)
check("B9 span along Y a (worst)", b9["a"], max((bb(o).Max.Y - bb(o).Min.Y for o in cl["B9"]), key=lambda h: abs(h - b9["a"])) if cl["B9"] else 0)
flag("B9 hooks the top and bottom Y bars", all(bb(o).Max.Z > G["mats"]["y_top"]["z"] and bb(o).Min.Z < G["mats"]["y_bottom"]["z"] for o in cl["B9"]))
# containment and cover
ft = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-FT"][0]
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 300)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not ft.IsPointInside(q, 0.01, False):
bad += 1
cov = min(cov, p.DistanceTo(ft.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside the footing", 0, bad, tol=0)
info("min cover to the footing surface per mark (record)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,138 @@
"""G4 gate checker: coping rebar C1-C16, S1, S2 (check C10) inside Rhino 8.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values from EXPECTED_PATH section G4 (D3 counts, L,
a/b ranges; D1/D2 dimension chains). Width decisions (K1) make the original-length checks of the range marks
fail by design; they are reported as FAIL and listed in `planned_fail`. Generator params are not read.
Ported from Code/교각모델링/verify_coping_rebar.py (same checks and order).
"""
import json
import os
import sys
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectColorSource
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, series, write_result, ychain # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G4"]
COUNT, L, DIA = G["count"], G["L"], G["dia"]
RANGE = {m: tuple(v) for m, v in G["range"].items()}
Y24 = ychain(G["y24_chain"], G["half_width"])
Y13 = ychain(G["y13_chain"], G["half_width"])
TIER = G["tier_z"]
STIR_X = {m: sorted(series(v)) for m, v in G["stirrup_x"].items()}
LOOP_Z = {m: series(v) for m, v in G["loop_z"].items()}
PALETTE = {int(k): tuple(v) for k, v in X["rebar_palette"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C10", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C10", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C10", item=item, detail=detail, result="INFO"))
def main():
objs = all_objects(doc)
cl = {m: [o for o in objs if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-RC-")
and o.Attributes.GetUserString("mark") == m] for m in COUNT}
pp = {m: [o for o in objs if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-R-")
and o.Attributes.GetUserString("mark") == m] for m in COUNT}
for m in COUNT:
check(f"{m} bar count", COUNT[m], len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]))
if not cl[m]:
continue
lens = [float(o.Attributes.GetUserString("design_length")) for o in cl[m]]
if m in RANGE:
key, hi, lo = RANGE[m]
vals = [float(o.Attributes.GetUserString(key)) for o in cl[m]]
check(f"{m} {key} max vs schedule", hi, max(vals))
check(f"{m} {key} min vs schedule", lo, min(vals))
cv = G["range_convention"][key]
fixed = cv["a"] if key == "b_sched" else cv["b"]
avg_conv = 2 * fixed + (hi + lo) + cv["c_times"] * cv["c"] # schedule convention: (max+min)/2
check(f"{m} schedule L reproduced by convention (max+min)/2", L[m], avg_conv)
ws = sorted({o.Attributes.GetUserString("w_model") for o in cl[m]})
results.append(dict(check="C10", item=f"{m} original length vs L (drawn widths {ws}, K1 decision)",
detail=f"model mean {sum(lens) / len(lens):.1f} vs L {L[m]}", result="FAIL"))
else:
check(f"{m} original length vs L (worst)", 0, max(abs(v - L[m]) for v in lens))
dev = [o.Geometry.GetLength() - float(o.Attributes.GetUserString("design_length")) for o in cl[m]]
info(f"{m} curve - original length", f"{min(dev):.1f} / {max(dev):.1f}")
for m, z in TIER.items():
zs = [o.Geometry.PointAt(o.Geometry.Domain.Mid).Z for o in cl[m]]
check(f"{m} tier EL (worst)", 0, max(abs(v - z) for v in zs))
for pair in G["pairs"]:
ys = sorted((round(o.Geometry.PointAt(o.Geometry.Domain.Mid).Y), m) for m in pair for o in cl[m])
flag(f"{'/'.join(pair)} Y = A-A chain, alternate from {pair[0]} at {Y24[0]:.0f}",
[y for y, _ in ys] == Y24 and [m for _, m in ys] == [pair[i % 2] for i in range(len(Y24))])
for m, xs in STIR_X.items():
got = sorted({round(o.Geometry.GetBoundingBox(True).Center.X) for o in cl[m]})
flag(f"{m} stirrup X positions", got == xs, f"{len(got)} positions")
per = {}
for o in cl[m]:
k = round(o.Geometry.GetBoundingBox(True).Center.X)
per[k] = per.get(k, 0) + 1
flag(f"{m} {G['stirrups_per_x']} stirrups per position", set(per.values()) == {G["stirrups_per_x"]})
zb = [round(o.Geometry.GetBoundingBox(True).Min.Z) for o in cl["C10"]]
check(f"C10 stirrup bottom EL {G['c10_bottom'] / 1000:.3f} (worst)", 0, max(abs(v - G["c10_bottom"]) for v in zb))
for m, zs in LOOP_Z.items():
got = sorted({round(o.Geometry.GetBoundingBox(True).Center.Z) for o in cl[m]})
flag(f"{m} loop levels", got == zs, str(got))
for m in ("C15", "C16"):
got = sorted(round(o.Geometry.GetBoundingBox(True).Center.Y) for o in cl[m])
flag(f"{m} Y = B-B chain", got == Y13, f"{len(got)}")
check(f"C15 EL {G['c15_top'] / 1000:.3f}", G["c15_top"], max(o.Geometry.GetBoundingBox(True).Max.Z for o in cl["C15"]))
check(f"C16 EL {G['c16_top'] / 1000:.3f}", G["c16_top"], max(o.Geometry.GetBoundingBox(True).Max.Z for o in cl["C16"]))
# horizontal-segment height (bbox max picks up upward hooks)
c14z = sorted(round(o.Geometry.PointAt(o.Geometry.Domain.Mid).Z, 1) for o in cl["C14"])
exp14 = sorted(z for z, n in G["c14_levels"] for _ in range(n))
flag("C14 levels/count per level", c14z == exp14, "")
step = {int(k): v for k, v in G["step_top"].items()}
for m, (n_per, dz) in G["s_per_girder"].items():
by = {}
for o in cl[m]:
g = int(o.Attributes.GetUserString("occurrence")[1])
by.setdefault(g, []).append(o.Geometry.GetBoundingBox(True).Max.Z)
flag(f"{m} per girder line = {n_per}", sorted(len(v) for v in by.values()) == [n_per] * len(step))
check(f"{m} EL = step top - {dz} (worst)", 0, max(abs(z - (step[g] - dz)) for g, v in by.items() for z in v))
cp = [o for o in objs if o.Attributes.GetUserString("id") == f"{PID}-CP"][0].Geometry
cover = {}
for m in COUNT:
bad, cov = 0, 1e9
r = DIA[m] / 2
for o in cl[m]:
c = o.Geometry
for t in c.DivideByCount(max(4, int(c.GetLength() / 200)), True):
p = c.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not cp.IsPointInside(q, 0.01, False):
bad += 1
cov = min(cov, p.DistanceTo(cp.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside coping", 0, bad, tol=0)
info("min cover to coping surface per mark (top face under pedestals counted)", json.dumps(cover))
wrong = 0
for o in objs:
if (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-R-"):
d = int(o.Attributes.GetUserString("dia")[1:])
c = o.Attributes.ObjectColor
if o.Attributes.ColorSource != ObjectColorSource.ColorFromObject or (c.R, c.G, c.B) != PALETTE[d]:
wrong += 1
check("rebar colour mismatches (all rebar)", 0, wrong, tol=0)
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,222 @@
"""G4 gate checker, Okseong P1 coping + bearing-seat rebar (2026-10-14): C1~C20 and S1~S8 as centreline polylines (plan skew 18 deg).
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G4, plus the G1 pedestal table) come from the schedule
(L, N, polyline length = L - lap), the drawn levels / positions of the rebar drawing, the section dimension chains and the user
decisions H1-H6; generator params are not read. Coping bars are sheared like the coping: x_model = x - tan(skew) * y, so the
section x of a bar is bbox mid X + tan * bbox mid Y. Seat bars are checked in the pedestal frame.
Checks (C10): counts / pipes / polyline length, top layers (rows, ends, legs), C19 / C20, C7, stirrups (x, top, bottom, y), side loops
and ties, C18, seat bars per pedestal, containment in the coping + pedestals, minimum cover (record).
"""
import json
import math
import os
import statistics
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G4"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
T = math.tan(math.radians(G["skew_deg"]))
COS = math.cos(math.radians(G["skew_deg"]))
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C10", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C10", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C10", item=item, detail=detail, result="INFO"))
def bb(o):
return o.Geometry.GetBoundingBox(True)
def mid(o):
b = bb(o)
return (b.Min.X + b.Max.X) / 2, (b.Min.Y + b.Max.Y) / 2
def xs(o):
"""Section x of a coping bar (shear removed)."""
mx, my = mid(o)
return mx + T * my
def worst(got, want):
got, want = sorted(got), sorted(want)
return max((abs(a - b) for a, b in zip(got, want)), default=1e9) if len(got) == len(want) else 1e9
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]), f"{len(pp[m])}/{len(cl[m])}")
dl = [float(o.Attributes.GetUserString("design_length")) for o in cl[m]]
if m in G["fixed_length"]:
check(f"{m} polyline length vs L - lap (worst)", 0, max(abs(v - G["fixed_length"][m]) for v in dl) if dl else 1e9)
elif dl:
info(f"{m} mean polyline length vs L (variable mark, record)",
f"mean {statistics.mean(dl):.1f} / L {L} (diff {statistics.mean(dl) - L:+.1f}); min {min(dl):.0f} max {max(dl):.0f}")
# top layers C1~C6
tp, y25 = G["top"], G["y25"]
for m in ("C1", "C2", "C3", "C4", "C5", "C6"):
s = tp[m]
want = [y for i, y in enumerate(y25) if i % 2 == s["parity"]]
got = [mid(o)[1] for o in cl[m]]
check(f"{m} y rows vs chain (worst, n={len(got)}/{len(want)})", 0, worst(got, want))
check(f"{m} level of the straight part (worst)", 0, max(abs(bb(o).Max.Z - s["z"]) for o in cl[m]))
check(f"{m} leg {s['leg']} (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - s["leg"]) for o in cl[m]))
check(f"{m} x ends +-{tp['x_end']} sheared (worst)", 0,
max(max(abs(bb(o).Min.X + T * mid(o)[1] + tp["x_end"]), abs(bb(o).Max.X + T * mid(o)[1] - tp["x_end"])) for o in cl[m]))
# C19, C20
c19 = G["c19"]
check("C19 y rows vs chain (worst)", 0, worst([mid(o)[1] for o in cl["C19"]], y25))
check("C19 level (worst)", 0, max(abs(bb(o).Max.Z - c19["z"]) for o in cl["C19"]))
check("C19 x ends (worst)", 0, max(max(abs(bb(o).Min.X + T * mid(o)[1] - c19["x"][0]), abs(bb(o).Max.X + T * mid(o)[1] - c19["x"][1])) for o in cl["C19"]))
check("C19 leg 370 (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - G["c20_leg"]) for o in cl["C19"]))
for k, pc in enumerate(G["c20"]):
grp = [o for o in cl["C20"] if abs(bb(o).Max.Z - pc["z"]) < 0.5 and abs(bb(o).Min.X + T * mid(o)[1] - pc["x"][0]) < 3]
flag(f"C20 piece {k + 1}: 25 bars at EL {pc['z']}, x {pc['x']}", len(grp) == 25, f"{len(grp)}")
if grp:
check(f"C20 piece {k + 1} x ends (worst)", 0, max(max(abs(bb(o).Min.X + T * mid(o)[1] - pc["x"][0]), abs(bb(o).Max.X + T * mid(o)[1] - pc["x"][1])) for o in grp))
check(f"C20 piece {k + 1} leg 370 (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - G["c20_leg"]) for o in grp))
check(f"C20 piece {k + 1} y rows (worst)", 0, worst([mid(o)[1] for o in grp], y25))
# C7
c7 = G["c7"]
check("C7 lowest level per bar vs drawn (worst)", 0, worst([bb(o).Min.Z for o in cl["C7"]], c7["flat_z"]))
normal = [o for o in cl["C7"] if abs(mid(o)[1]) < 1400]
check("C7 top end EL 20.3672 (bars not on the side face, worst)", 0,
max(abs(bb(o).Max.Z - (c7["top_end_z"] + (0 if abs(mid(o)[1]) <= 1000 else 365.2))) for o in normal))
check("C7 x ends +-5875 (worst)", 0, max(max(abs(bb(o).Min.X + T * mid(o)[1] + c7["x_end"]), abs(bb(o).Max.X + T * mid(o)[1] - c7["x_end"])) for o in normal))
edge = [o for o in cl["C7"] if abs(mid(o)[1]) >= 1400]
check("C7 side-face bars end where the slope meets the coping top (x end)", 0,
max(max(abs(bb(o).Min.X + T * mid(o)[1] + c7["capped"]["x_end"]), abs(bb(o).Max.X + T * mid(o)[1] - c7["capped"]["x_end"])) for o in edge) if edge else 1e9, tol=8)
got = sorted(round(mid(o)[1]) for o in cl["C7"])
flag("C7 y rows: 11 @250 + 2 on the side face", got == sorted([-1250 + 250 * k for k in range(11)] + [-1525, 1525]), f"{got}")
# stirrups
st = G["stirrups"]
def top_of(x):
return next(z for a, b, z in st["top"] if a <= x < b)
def bottom_of(x):
s = st["bottom"]
return s["flat_z"] if abs(x) <= s["flat_half"] else s["flat_z"] + s["slope"] * (abs(x) - s["flat_half"])
for zone, (rect, pent) in st["marks"].items():
want = st["x"][zone]
got_r = [xs(o) for o in cl[rect]]
check(f"{rect} positions = drawn x ({len(want)}, worst)", 0, worst(got_r, want))
got_p = [xs(o) - st["pent_dx"] for o in cl[pent]]
check(f"{pent} positions = drawn x + {st['pent_dx']}, 2 per x (worst)", 0, worst(got_p, [x for x in want for _ in (0, 1)]))
dt = max(abs(bb(o).Max.Z - top_of(xs(o))) for o in cl[rect] + cl[pent]) if cl[rect] else 1e9
db = max(abs(bb(o).Min.Z - bottom_of(xs(o) - (st["pent_dx"] if o in cl[pent] else 0))) for o in cl[rect] + cl[pent]) if cl[rect] else 1e9
check(f"{rect}/{pent} top = coping top - 97.5 (drawn zones, worst)", 0, dt)
check(f"{rect}/{pent} bottom = soffit + 120 - 22.5 (worst)", 0, db, tol=2.5)
check(f"{rect} legs y +-{st['rect_y']} (worst)", 0, max(max(abs(bb(o).Max.Y - st["rect_y"] - 0), abs(bb(o).Min.Y + st["rect_y"])) for o in cl[rect]) if cl[rect] else 1e9, tol=2)
lo, hi = st["pent_y"]
okp = all((abs(bb(o).Min.Y - lo) < 2 and abs(bb(o).Max.Y - hi) < 2) or (abs(bb(o).Max.Y + lo) < 2 and abs(bb(o).Min.Y + hi) < 2) for o in cl[pent])
flag(f"{pent} y between +-{lo} and +-{hi} (A-A)", okp)
# side loops and ties
lp = G["loops"]
for key in ("upper", "lower"):
sec = lp[key]
mloop, mtie = sec["marks"]
gl = sorted((round(bb(o).Min.Z, 1), round(bb(o).Max.X - bb(o).Min.X - 2 * T * (bb(o).Max.Y - bb(o).Min.Y) / 2, 1), round((bb(o).Max.Y - bb(o).Min.Y) / 2, 1)) for o in cl[mloop])
wl = sorted((z, round(2 * x, 1), y) for z, x, y in zip(sec["z"], sec["x"], sec["y"]))
flag(f"{mloop} loops: level / x length (corner fillets <= 50) / half width ({len(wl)})", len(gl) == len(wl) and all(abs(a[0] - b[0]) <= 1 and abs(a[1] - b[1]) <= 50 and abs(a[2] - b[2]) <= 1 for a, b in zip(gl, wl)), f"{gl[:2]}")
flat = max((bb(o).Max.Z - bb(o).Min.Z) for o in cl[mloop] + cl[mtie])
check(f"{mloop}/{mtie} horizontal (worst dz)", 0, flat, tol=0.01)
gt = sorted((round(bb(o).Min.Z, 1), round(mid(o)[1], 1), round(bb(o).Max.X - bb(o).Min.X, 1)) for o in cl[mtie])
wt = sorted((z, y, round(2 * x, 1)) for z, x in zip(sec["z"], sec["x"]) for y in lp["tie_y"])
flag(f"{mtie} ties: level / y / x length ({len(wt)})", len(gt) == len(wt) and all(abs(a[0] - b[0]) <= 1 and abs(a[1] - b[1]) <= 1 and abs(a[2] - b[2]) <= 1 for a, b in zip(gt, wt)), f"{len(gt)}")
# C18
c18 = G["c18"]
got = {}
for o in cl["C18"]: # lower rows have the legs UP (the straight part is the lowest level)
z = bb(o).Max.Z if bb(o).Max.Z - bb(o).Min.Z < 1 or abs((bb(o).Max.Z - bb(o).Min.Z) - 370) < 1 and bb(o).Min.Z > 20000 else bb(o).Min.Z
got.setdefault(f"{z:.1f}", []).append(o)
flag("C18 levels and counts", {k: len(v) for k, v in got.items()} == c18["levels"], f"{ {k: len(v) for k, v in got.items()} }")
check("C18 widths per level (worst; fillets on the slanted legs <= 30)", 0, max(abs((bb(o).Max.Y - bb(o).Min.Y) - c18["width"][k]) for k, v in got.items() if k in c18["width"] for o in v), tol=30)
# seats
se = G["seats"]
ped = {}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith(f"{PID}-BS"):
ped[int(oid[len(PID) + 3:])] = o.Geometry.GetBoundingBox(True)
flag("5 pedestals found", sorted(ped) == [1, 2, 3, 4, 5], f"{sorted(ped)}")
for k, pb in sorted(ped.items()):
cx, cy = (pb.Min.X + pb.Max.X) / 2, (pb.Min.Y + pb.Max.Y) / 2
yf, yr = se["y"]["front"], se["y"]["rear"]
zp = pb.Min.Z + se["h1"]["front"] + (se["h1"]["rear"] - se["h1"]["front"]) * (cy - yf) / (yr - yf)
ml, ms, mll, mss = se["marks_heavy"] if k == se["heavy"] else se["marks_light"]
dl_, ds_ = G["dia"][ml], G["dia"][ms]
grp = lambda m: [o for o in cl[m] if o.Attributes.GetUserString("occurrence").startswith(f"P{k}-")]
gl, gs, gll, gss = grp(ml), grp(ms), grp(mll), grp(mss)
flag(f"pedestal {k}: {ml} {ms} {mll} {mss} counts 11 / 21 / 22 / 42", (len(gl), len(gs), len(gll), len(gss)) == (11, 21, 22, 42), f"{len(gl)} {len(gs)} {len(gll)} {len(gss)}")
check(f"pedestal {k} {ml} level {se['top_below']} below the pedestal top (worst)", 0, max(abs(bb(o).Max.Z - (zp - se["top_below"])) for o in gl), tol=1.5)
check(f"pedestal {k} {ml} legs {se['leg']} (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - se["leg"]) for o in gl + gs))
check(f"pedestal {k} {ml} lateral positions (worst)", 0, worst([(mid(o)[0] - cx) * COS for o in gl], se["long_t"]), tol=1.5)
check(f"pedestal {k} {ml} length along the end face (worst)", 0, max(abs((bb(o).Max.Y - bb(o).Min.Y) - se["long_len"] * COS) for o in gl), tol=1.5)
check(f"pedestal {k} {ms} level {ds_ if False else dl_} under {ml} (worst)", 0, max(abs(bb(o).Max.Z - (zp - se["top_below"] - dl_)) for o in gs), tol=1.5)
check(f"pedestal {k} {ms} y positions (worst)", 0, worst([mid(o)[1] - cy for o in gs], se["short_o"]), tol=1.5)
check(f"pedestal {k} {ms} x length (worst)", 0, max(abs((bb(o).Max.X - bb(o).Min.X) - se["short_len"]) for o in gs), tol=1.5)
for layer, dz in enumerate(se["lower_below"]):
l1 = [o for o in gll if abs(bb(o).Min.Z - (zp - dz)) < 1.5]
l2 = [o for o in gss if abs(bb(o).Min.Z - (zp - dz + (G["dia"][mll] + G["dia"][mss]) / 2)) < 1.5]
flag(f"pedestal {k} layer {layer + 1} EL pedestal top - {dz}: {mll} 11 / {mss} 21", (len(l1), len(l2)) == (11, 21), f"{len(l1)} {len(l2)}")
if l1:
check(f"pedestal {k} {mll} layer {layer + 1} positions (worst)", 0, worst([(mid(o)[0] - cx) * COS for o in l1], se["low_long_t"]), tol=1.5)
check(f"pedestal {k} {mll} layer {layer + 1} length (worst)", 0, max(abs((bb(o).Max.Y - bb(o).Min.Y) - se["low_long_len"] * COS) for o in l1), tol=1.5)
if l2:
check(f"pedestal {k} {mss} layer {layer + 1} y positions (worst)", 0, worst([mid(o)[1] - cy for o in l2], se["low_short_o"]), tol=1.5)
# containment and cover
solids = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"
or (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-BS")]
solids = [s if isinstance(s, rg.Brep) else rg.Brep.TryConvertBrep(s) for s in solids]
cp = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"][0]
info("concrete solids for containment", f"{len(solids)} (coping + pedestals)")
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 200)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
if cp.IsPointInside(p, 0.01, False):
cov = min(cov, p.DistanceTo(cp.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to the coping surface per mark (record; seat bars measured to the coping only)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,207 @@
"""G4 gate checker, form "polylines" (Ulsan P1, 2026-10-13): coping rebar C1~C15 and bearing-seat S1~S4.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G4) are the schedule (L, n, ranges)
and drawn coordinates of the rebar drawing (front / side views, sections, seat details); params are not read.
Checks (C10): counts, fixed-length marks vs L, variable dimension ranges vs schedule (record mean length),
top / bottom layer level, y rows and x extents, stirrup positions / pairs / heights, side-bar and hoop levels
and ends, C14 positions, seat bars, containment in the coping + pedestals, minimum cover (record).
"""
import json
import os
import statistics
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G4"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C10", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C10", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C10", item=item, detail=detail, result="INFO"))
def row(spec):
return [spec["start"] + spec["pitch"] * i for i in range(spec["n"])]
def bb(o):
return o.Geometry.GetBoundingBox(True)
def worst(pairs):
return max((abs(a - b) for a, b in pairs), default=1e9)
def match(got, exp, tol=1.0):
"""Every expected tuple has one got tuple within tol (and same count)."""
got = list(got)
if len(got) != len(exp):
return False
for e in exp:
k = next((i for i, g in enumerate(got) if all(abs(a - b) <= tol for a, b in zip(g, e))), None)
if k is None:
return False
got.pop(k)
return True
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
dl = {m: [float(o.Attributes.GetUserString("design_length")) for o in cl[m]] for m in SCHED}
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]),
f"{len(pp[m])}/{len(cl[m])}")
if not dl[m]:
continue
if m in G["fixed_length"]:
check(f"{m} original length vs L (worst)", 0, max(abs(v - L) for v in dl[m]))
else:
info(f"{m} mean original length vs L (variable mark, record)",
f"mean {statistics.mean(dl[m]):.1f} / L {L} (diff {statistics.mean(dl[m]) - L:+.1f}); min {min(dl[m]):.0f} max {max(dl[m]):.0f}")
# top / bottom layers
rows = {k: row(v) for k, v in G["rows"].items()}
cy_abs = G["corner"]["y_abs"]
clipped_x = set(G["corner"]["x_clipped"])
for m, lay in G["layers"].items():
if not cl[m]:
continue
ys = rows[lay["y"]]
if lay["parity"] is not None:
ys = [y for i, y in enumerate(ys) if i % 2 == lay["parity"]]
got = sorted(round((bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in cl[m])
check(f"{m} y rows vs chain (worst, n={len(got)}/{len(ys)})", 0, worst(zip(got, ys)) if len(got) == len(ys) else 1e9)
if "z" in lay:
check(f"{m} level (top straight, worst)", 0, max(abs(bb(o).Max.Z - lay["z"]) for o in cl[m]))
else:
check(f"{m} bottom level (worst)", 0, max(abs(bb(o).Min.Z - lay["z_min"]) for o in cl[m]))
if m in G["x_extent"]:
x0, x1 = G["x_extent"][m]
inner = [o for o in cl[m] if abs((bb(o).Min.Y + bb(o).Max.Y) / 2) <= cy_abs]
check(f"{m} drawn x ends, |y| <= {cy_abs} (worst; corner fillets)", 0,
max(max(abs(bb(o).Min.X - x0), abs(bb(o).Max.X - x1)) for o in inner), tol=12)
info(f"{m} bars shortened at the R500 plan corners [추론]",
f"{sum(1 for o in cl[m] if o not in inner and bb(o).Max.X - bb(o).Min.X < x1 - x0 - 30)}"
f" of {len(cl[m]) - len(inner)} outer bars")
# stirrups
st = G["stirrups"]
for m, xs in st["x"].items():
got = {}
for o in cl[m]:
b = bb(o)
got.setdefault(round((b.Min.X + b.Max.X) / 2), []).append(o)
flag(f"{m} stirrup positions = drawn x ({len(xs)}) x 3", sorted(got) == sorted(round(x) for x in xs)
and all(len(v) == 3 for v in got.values()), f"{len(got)} positions")
pairs_ok, hw = True, 0
for x, os_ in got.items():
if x in clipped_x:
continue
pairs_ok &= match([(bb(o).Min.Y, bb(o).Max.Y) for o in os_], [tuple(p) for p in st["y_pairs"]])
exp_h = st["height"].get(f"{x:.0f}")
hw = max([hw] + [abs((bb(o).Max.Z - bb(o).Min.Z) - exp_h) for o in os_]) if exp_h else 1e9
flag(f"{m} legs at y pairs {st['y_pairs']} (A-A; corner positions {sorted(clipped_x)} excluded)", pairs_ok)
check(f"{m} centreline height vs drawn + 54 (worst)", 0, hw)
# variable ranges (schedule)
for m, (dim, lo, hi) in G["ranges"].items():
if not cl[m]:
continue
if dim == "B":
vals = [bb(o).Max.Z - bb(o).Min.Z for o in cl[m]]
check(f"{m} B min vs schedule '{lo}~{hi}'", lo, min(vals), tol=2)
check(f"{m} B max vs schedule '{lo}~{hi}'", hi, max(vals), tol=2)
else:
vals = []
for o in cl[m]:
b = bb(o)
a = b.Max.X - b.Min.X + (44 if m in ("C11", "C13") else (54 if m == "C10" else 44 - 790))
vals.append(a)
rng = f"model {min(vals):.0f}~{max(vals):.0f} / schedule {lo}~{hi}"
ok = abs(min(vals) - lo) <= 2 and abs(max(vals) - hi) <= 2
results.append(dict(check="C10", item=f"{m} A range vs schedule", detail=rng,
result="PASS" if ok else "INFO"))
# side bars and hoops per level
lv = G["levels"]
for key, yset in (("side", G["side"]["y"]), ("hoops", None)):
k0 = 0
for m, nlev in G[key]["marks"]:
exp = lv[k0:k0 + nlev]
k0 += nlev
got = []
for o in cl[m]:
b = bb(o)
c = o.Geometry.PointAt(o.Geometry.Domain.Mid)
got.append((round(b.Min.Z, 1), b.Min.X, b.Max.X, c.Y if yset else b.Max.Y - b.Min.Y))
want = [(zz, xl, xr, y) for zz, xl, xr in exp for y in (yset or [2 * G["hoops"]["y"]])]
flag(f"{m} levels / drawn ends / y ({len(want)})", match(got, want), f"{len(got)} bars")
# C14
c = G["c14"]
got = [((bb(o).Min.X + bb(o).Max.X) / 2, bb(o).Min.Z, bb(o).Min.Y, bb(o).Max.Y) for o in cl["C14"]]
n_corner = sum(1 for p in c["pts"] if round(abs(p[0]) + 19) in clipped_x)
full = [g for g in got if abs(g[2] - c["y"][0]) <= 1 and abs(g[3] - c["y"][1]) <= 1]
flag("C14 levels (drawn section marks)", match([(g[1],) for g in got], [(p[1],) for p in c["pts"]]), f"{len(got)}")
flag(f"C14 y span {c['y']} except {n_corner} corner bars", len(full) == len(got) - n_corner
and all(g[2] >= c["y"][0] - 1 and g[3] <= c["y"][1] + 1 for g in got), f"{len(full)} full")
# seat bars
se = G["seats"]
for no, s in se["bearings"].items():
cx, cy = s["centre"]
my, mx = s["marks"]
oy = [o for o in cl[my] if o.Attributes.GetUserString("occurrence").startswith(f"B{no}")]
ox = [o for o in cl[mx] if o.Attributes.GetUserString("occurrence").startswith(f"B{no}")]
flag(f"bearing {no} {my} x positions / y span", match([((bb(o).Min.X + bb(o).Max.X) / 2, bb(o).Min.Y, bb(o).Max.Y) for o in oy],
[(cx + d, cy - se["half"], cy + se["half"]) for d in se["offsets"]]), f"{len(oy)}")
flag(f"bearing {no} {mx} y positions / x span", match([((bb(o).Min.Y + bb(o).Max.Y) / 2, bb(o).Min.X, bb(o).Max.X) for o in ox],
[(cy + d, cx - se["half"], cx + se["half"]) for d in se["offsets"]]), f"{len(ox)}")
check(f"bearing {no} {my} level 50 below pedestal top (worst)", 0, max(abs(bb(o).Max.Z - s["z_top"]) for o in oy))
check(f"bearing {no} legs {se['leg']} (worst)", se["leg"], min(bb(o).Max.Z - bb(o).Min.Z for o in oy + ox))
# containment and cover
solids = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"
or (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-BS")]
solids = [s if isinstance(s, rg.Brep) else rg.Brep.TryConvertBrep(s) for s in solids]
cp = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"][0]
info("concrete solids for containment", f"{len(solids)} (coping + pedestals)")
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 150)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
if cp.IsPointInside(p, 0.01, False):
cov = min(cov, p.DistanceTo(cp.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to coping surface per mark (record; seat bars measured to the coping only)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,185 @@
"""G4 gate checker, Ramp-A P1 coping rebar (2026-10-14): C1~C16 and bearing-seat S1~S4 as centreline polylines.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G4, plus the G1 coping dimensions) come
from the D3 schedule (L, N), the D2 / D3 drawn positions and section dimensions, and the user decisions T1-T6;
generator params are not read.
Checks (C10): counts, fixed-length marks vs L (variable marks recorded), top layers (level, y rows, x ends),
stirrup positions per zone, stirrup top / bottom cover along the soffit, inner stirrup offset, side loops per level,
C6 bottom level, C15 / C16, seat bars per bearing, containment in the coping + pedestals, min cover (record).
"""
import json
import math
import os
import statistics
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G, C, CO = X["pier_id"], X["G4"], X["G1"]["coping"], X["G1"]["column"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
ZB = CO["z0"] + CO["length"] # column top = coping bottom
ZT = ZB + C["soffit_rise"]
ZTL, ZTR = C["z_top_left"], C["z_top_right"]
R, HB, HT = C["soffit_radius"], C["column_face_half"], C["bottom_length"] / 2
results = []
def soffit_centre():
ax, az, bx, bz = -HB, ZB, -HT, ZT # R arc through (-HB, ZB) and (-HT, ZT), centre below the chord
mx, mz = (ax + bx) / 2, (az + bz) / 2
d = math.hypot(bx - ax, bz - az)
h = math.sqrt(R * R - d * d / 4)
ux, uz = (bz - az) / d, -(bx - ax) / d
return min(((mx + h * ux, mz + h * uz), (mx - h * ux, mz - h * uz)), key=lambda c: c[1])
SCX, SCZ = soffit_centre()
SCX = abs(SCX)
def soffit_z(x):
return ZB if abs(x) <= HB else SCZ + math.sqrt(R * R - (SCX - abs(x)) ** 2)
def check(item, exp, act, tol=1.0):
results.append(dict(check="C10", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C10", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C10", item=item, detail=detail, result="INFO"))
def row(s):
return [s["start"] + s["pitch"] * i for i in range(s["n"])]
def bb(o):
return o.Geometry.GetBoundingBox(True)
def worst(pairs):
pairs = list(pairs)
return max((abs(a - b) for a, b in pairs), default=1e9)
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]),
f"{len(pp[m])}/{len(cl[m])}")
dl = [float(o.Attributes.GetUserString("design_length")) for o in cl[m]]
if not dl:
continue
if m in G["fixed_length"]:
check(f"{m} original length vs L (worst)", 0, max(abs(v - L) for v in dl))
else:
info(f"{m} mean original length vs L (variable mark, record)",
f"mean {statistics.mean(dl):.1f} / L {L} (diff {statistics.mean(dl) - L:+.1f}); min {min(dl):.0f} max {max(dl):.0f}")
# top layers (straight part = top of the bar)
for m, s in G["layers"].items():
got = sorted(round((bb(o).Min.Y + bb(o).Max.Y) / 2, 1) for o in cl[m])
want = row(s["y"])
check(f"{m} y rows (worst, {len(got)}/{len(want)})", 0, worst(zip(got, want)) if len(got) == len(want) else 1e9)
check(f"{m} level of the straight part (worst)", 0, max(abs(bb(o).Max.Z - s["z"]) for o in cl[m]))
if "x_top" in s:
x0, x1 = s["x_top"]
ends = []
for o in cl[m]:
c = o.Geometry
top_pts = [p for p in (c.PointAt(t) for t in c.DivideByLength(5, True)) if abs(p.Z - s["z"]) < 0.5]
ends.append(max(abs(min(p.X for p in top_pts) - x0), abs(max(p.X for p in top_pts) - x1)))
check(f"{m} straight part x ends vs drawn (worst; fillet tangent points)", 0, max(ends), tol=s.get("x_tol", 70))
# stirrups
st = G["stirrups"]
xs = row(st["x"])
for zone, (m_out, m_in, x0, x1) in st["zones"].items():
want = [x for x in xs if x0 <= x <= x1]
got_o = sorted(round(bb(o).Min.X, 1) for o in cl[m_out])
got_i = sorted(round(bb(o).Min.X, 1) for o in cl[m_in])
check(f"{m_out} positions = drawn x ({len(want)})", 0, worst(zip(got_o, want)) if len(got_o) == len(want) else 1e9)
check(f"{m_in} positions = drawn x + {st['inner_dx']} (2 per x)", 0,
worst(zip(got_i, sorted(x + st["inner_dx"] for x in want for _ in (0, 1)))) if len(got_i) == 2 * len(want) else 1e9)
for m in (m_out, m_in):
dt, dbm = [], []
for o in cl[m]:
b = bb(o)
x = b.Min.X - (st["inner_dx"] if m == m_in else 0)
t = ZTL if x <= 0 else ZTR
dt.append(abs((t - b.Max.Z) - st["top_cover"]))
dbm.append(abs((b.Min.Z - soffit_z(x)) - st["bottom_cover"] * (1 if abs(x) < HB else
# on the arc the 77.5 cover is radial: vertical gap = R' - R projected
(SCZ + math.sqrt((R + st["bottom_cover"]) ** 2 - (SCX - abs(x)) ** 2) - soffit_z(x)) / st["bottom_cover"])))
check(f"{m} top cover {st['top_cover']} (worst)", 0, max(dt))
check(f"{m} bottom cover {st['bottom_cover']} to the soffit (worst)", 0, max(dbm))
ys = [(bb(o).Min.Y, bb(o).Max.Y) for o in cl[m_out]]
check(f"{m_out} legs y {st['outer_y']} (worst)", 0, worst([(a, st["outer_y"][0]) for a, _ in ys] + [(b, st["outer_y"][1]) for _, b in ys]))
# side loops
lp = G["loops"]
for m, levels in lp["levels"].items():
zs = {}
for o in cl[m]:
zs.setdefault(round(bb(o).Min.Z, 1), []).append(o)
flag(f"{m} levels {levels} x {lp['per_level']}", sorted(zs) == sorted(levels)
and all(len(v) == lp["per_level"] for v in zs.values()), f"{sorted(zs)}")
flat = [abs(bb(o).Max.Z - bb(o).Min.Z) for o in cl[m]]
check(f"{m} loops horizontal (worst dz)", 0, max(flat) if flat else 1e9, tol=0.01)
# C6, C15, C16
c6 = G["c6"]
check("C6 bottom straight level (worst)", 0, max(abs(bb(o).Min.Z - c6["z_bottom"]) for o in cl["C6"]) if cl["C6"] else 1e9)
got = sorted(round(o.Geometry.PointAt(o.Geometry.Domain.Mid).Y, 1) for o in cl["C6"])
check("C6 y rows at mid-span (worst)", 0, worst(zip(got, c6["y"])) if len(got) == len(c6["y"]) else 1e9)
for m in ("C15",):
w = [bb(o).Max.Y - bb(o).Min.Y for o in cl[m]]
check(f"{m} width a (worst)", G["c15"]["a"], max(w, key=lambda v: abs(v - G["c15"]["a"])) if w else 0)
# seats
for no, want in G["seats"].items():
for m, n in want.items():
k = sum(1 for o in cl[m] if o.Attributes.GetUserString("occurrence").startswith(f"B{no}-"))
check(f"bearing {no} {m} count", n, k, tol=0)
# containment and cover
solids = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"
or (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-BS")]
solids = [s if isinstance(s, rg.Brep) else rg.Brep.TryConvertBrep(s) for s in solids]
cp = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"][0]
col = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CL"]
info("concrete solids for containment", f"{len(solids)} (coping + pedestals); bars below the coping bottom checked in the column")
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 150)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids + col):
bad += 1
if cp.IsPointInside(p, 0.01, False):
cov = min(cov, p.DistanceTo(cp.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to the coping surface per mark (record; seat bars measured to the coping only)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,207 @@
"""G4 gate checker, Saejae-1 P1 coping + bearing-seat rebar (2026-10-15): C1~C20 and S1~S2 as centreline polylines (rectangular plan, no skew).
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values (section G4) come from the schedule (L, N, polyline length = a + legs),
the drawn levels / positions of the rebar drawing, the section dimension chains and the Okseong rules H1-H6 applied again; generator params
are not read. Checks (C10): counts / pipes / polyline length, top layers, C19 / C20, C7, stirrups (x, top, bottom, y), side loops and ties,
C18, seat bars per pedestal, containment in the coping + pedestals, minimum cover (record).
"""
import json
import os
import statistics
import sys
import Rhino.Geometry as rg
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected, write_result # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G4"]
SCHED = {m: tuple(v) for m, v in G["schedule"].items()}
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C10", item=item, expected=round(exp, 3), actual=round(act, 3),
diff=round(act - exp, 3), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C10", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def info(item, detail):
results.append(dict(check="C10", item=item, detail=detail, result="INFO"))
def bb(o):
return o.Geometry.GetBoundingBox(True)
def mid(o):
b = bb(o)
return (b.Min.X + b.Max.X) / 2, (b.Min.Y + b.Max.Y) / 2
def worst(got, want):
got, want = sorted(got), sorted(want)
return max((abs(a - b) for a, b in zip(got, want)), default=1e9) if len(got) == len(want) else 1e9
def occ(o):
return o.Attributes.GetUserString("occurrence") or ""
def main():
cl = {m: [] for m in SCHED}
pp = {m: [] for m in SCHED}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
m = o.Attributes.GetUserString("mark")
if m in SCHED and oid.startswith(f"{PID}-RC-"):
cl[m].append(o)
elif m in SCHED and oid.startswith(f"{PID}-R-"):
pp[m].append(o)
for m, (L, n) in SCHED.items():
check(f"{m} bar count vs schedule", n, len(cl[m]), tol=0)
flag(f"{m} pipes closed & paired", len(pp[m]) == len(cl[m]) and all(o.Geometry.IsSolid for o in pp[m]), f"{len(pp[m])}/{len(cl[m])}")
dl = [float(o.Attributes.GetUserString("design_length")) for o in cl[m]]
if m in G["fixed_length"]:
check(f"{m} polyline length vs a + legs (worst)", 0, max(abs(v - G["fixed_length"][m]) for v in dl) if dl else 1e9)
elif dl:
info(f"{m} mean polyline length vs L (variable mark, record)",
f"mean {statistics.mean(dl):.1f} / L {L} (diff {statistics.mean(dl) - L:+.1f}); min {min(dl):.0f} max {max(dl):.0f}")
# top layers C1~C6
tp, y23 = G["top"], G["y23"]
for m in ("C1", "C2", "C3", "C4", "C5", "C6"):
s = tp[m]
want = [y for i, y in enumerate(y23) if i % 2 == s["parity"]]
check(f"{m} y rows vs chain (worst, n={len(cl[m])}/{len(want)})", 0, worst([mid(o)[1] for o in cl[m]], want))
check(f"{m} level of the straight part (worst)", 0, max(abs(bb(o).Max.Z - s["z"]) for o in cl[m]))
check(f"{m} leg {s['leg']} (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - s["leg"]) for o in cl[m]))
check(f"{m} x ends +-{tp['x_end']} (worst)", 0, max(max(abs(bb(o).Min.X + tp["x_end"]), abs(bb(o).Max.X - tp["x_end"])) for o in cl[m]))
# C19, C20
c19 = G["c19"]
check("C19 y rows vs chain (worst)", 0, worst([mid(o)[1] for o in cl["C19"]], y23))
check("C19 level (worst)", 0, max(abs(bb(o).Max.Z - c19["z"]) for o in cl["C19"]))
check("C19 x ends (worst)", 0, max(max(abs(bb(o).Min.X - c19["x"][0]), abs(bb(o).Max.X - c19["x"][1])) for o in cl["C19"]))
check("C19 legs 370 (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - G["c20_leg"]) for o in cl["C19"]))
for k, pc in enumerate(G["c20"]):
grp = [o for o in cl["C20"] if abs(bb(o).Max.Z - pc["z"]) < 0.5 and abs(bb(o).Min.X - pc["x"][0]) < 3]
flag(f"C20 piece {k + 1}: 23 bars at EL {pc['z']}, x {pc['x']}", len(grp) == 23, f"{len(grp)}")
if grp:
check(f"C20 piece {k + 1} x ends (worst)", 0, max(max(abs(bb(o).Min.X - pc["x"][0]), abs(bb(o).Max.X - pc["x"][1])) for o in grp))
check(f"C20 piece {k + 1} leg 370 (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - G["c20_leg"]) for o in grp))
check(f"C20 piece {k + 1} y rows (worst)", 0, worst([mid(o)[1] for o in grp], y23))
# C7
c7 = G["c7"]
check("C7 lowest level per bar vs drawn (worst)", 0, worst([bb(o).Min.Z for o in cl["C7"]], c7["flat_z"]))
flat = [o for o in cl["C7"] if abs(mid(o)[1]) <= 875]
check("C7 top end EL 31.8453 (flat bars, worst)", 0, max(abs(bb(o).Max.Z - c7["top_end_z"]) for o in flat))
check("C7 x ends +-5875 (flat bars, worst)", 0, max(max(abs(bb(o).Min.X + c7["x_end"]), abs(bb(o).Max.X - c7["x_end"])) for o in flat))
raised = [o for o in cl["C7"] if 875 < abs(mid(o)[1]) <= 1125]
check("C7 raised bars (y +-1125) top end (worst)", 0, max(abs(bb(o).Max.Z - c7["raised_top_z"]) for o in raised))
edge = [o for o in cl["C7"] if abs(mid(o)[1]) > 1125]
check("C7 side-face bars capped at the cap level (worst)", 0, max(abs(bb(o).Max.Z - c7["cap_z"]) for o in edge), tol=1.5)
flag("C7 y rows: 12 @250", sorted(round(mid(o)[1]) for o in cl["C7"]) == [-1375 + 250 * k for k in range(12)])
# stirrups
st = G["stirrups"]
def zone(x):
return "left" if x < st["zone_cut"][0] else ("centre" if x <= st["zone_cut"][1] else "right")
def top_of(x):
return st["step_z"][sum(1 for b in st["step_b"] if x >= b)] - st["top_below"]
def bottom_of(x):
if abs(x) <= st["flat_half"]:
return st["flat_bottom"]
return st["flat_bottom"] - st["cover_n"] + st["soffit_slope"] * (abs(x) - st["flat_half"]) + st["cover_n"] * (1 + st["soffit_slope"] ** 2) ** 0.5
for zn, (rect, pent) in st["marks"].items():
sx = [x for x in st["x"] if zone(x) == zn]
want_r = [x + o for x in sx for o in st["off"]["R"]]
want_p = [x + o for x in sx for o in st["off"]["P"]]
check(f"{rect} positions = stations + x offset ({len(want_r)}, worst)", 0, worst([mid(o)[0] for o in cl[rect]], want_r), tol=0.5)
check(f"{pent} positions = stations + x offset ({len(want_p)}, worst)", 0, worst([mid(o)[0] for o in cl[pent]], want_p), tol=0.5)
dt = db = 0.0
for o in cl[rect] + cl[pent]:
xb = mid(o)[0]
dt = max(dt, abs(bb(o).Max.Z - top_of(xb)))
db = max(db, abs(bb(o).Min.Z - bottom_of(xb)))
check(f"{rect}/{pent} top = step top - {st['top_below']:.0f} (worst)", 0, dt, tol=1.5)
check(f"{rect}/{pent} bottom = soffit + cover along the normal (worst, sloped part <= 4)", 0, db, tol=4.0)
okr = all(any(abs(bb(o).Min.Y - a) < 2 and abs(bb(o).Max.Y - b) < 2 for a, b in st["rect_y"]) for o in cl[rect])
flag(f"{rect} y legs {st['rect_y']} (B-B)", okr)
pin, pout = st["pent_in"], st["pent_out"]
okp = all((abs(bb(o).Min.Y + pin) < 2 and abs(bb(o).Max.Y - pout) < 2) or (abs(bb(o).Max.Y - pin) < 2 and abs(bb(o).Min.Y + pout) < 2) for o in cl[pent])
flag(f"{pent} y between {pin:.0f} and {pout:.0f} (B-B)", okp)
# side loops and ties
lp = G["loops"]
for key in ("upper", "lower"):
sec = lp[key]
mloop, mtie = sec["marks"]
gl = sorted((round(bb(o).Min.Z, 1), round(bb(o).Max.X - bb(o).Min.X, 1), round((bb(o).Max.Y - bb(o).Min.Y) / 2, 1)) for o in cl[mloop])
wl = sorted((z, round(2 * x, 1), y) for z, x, y in zip(sec["z"], sec["x"], sec["y"]))
flag(f"{mloop} loops: level / x length / half width ({len(wl)})", len(gl) == len(wl) and all(abs(a[0] - b[0]) <= 1 and abs(a[1] - b[1]) <= 1 and abs(a[2] - b[2]) <= 1 for a, b in zip(gl, wl)), f"{gl[:2]}")
check(f"{mloop}/{mtie} horizontal (worst dz)", 0, max((bb(o).Max.Z - bb(o).Min.Z) for o in cl[mloop] + cl[mtie]), tol=0.01)
gt = sorted((round(bb(o).Min.Z, 1), round(mid(o)[1], 1), round(bb(o).Max.X - bb(o).Min.X, 1)) for o in cl[mtie])
wt = sorted((z, y, round(2 * x, 1)) for z, x in zip(sec["z"], sec["x"]) for y in lp["tie_y"])
flag(f"{mtie} ties: level / y / x length ({len(wt)})", len(gt) == len(wt) and all(abs(a[0] - b[0]) <= 1 and abs(a[1] - b[1]) <= 1 and abs(a[2] - b[2]) <= 1 for a, b in zip(gt, wt)), f"{len(gt)}")
# C18
c18 = G["c18"]
got = {}
for o in cl["C18"]: # upper rows: legs DOWN (straight part highest); lower rows: legs UP (lowest)
z = bb(o).Max.Z if bb(o).Max.Z > 32100 else bb(o).Min.Z
got.setdefault(f"{z:.1f}", []).append(o)
flag("C18 levels and counts", {k: len(v) for k, v in got.items()} == c18["levels"], f"{ {k: len(v) for k, v in got.items()} }")
check("C18 widths per level (worst; fillets on the slanted legs <= 30)", 0, max(abs((bb(o).Max.Y - bb(o).Min.Y) - c18["width"][k]) for k, v in got.items() if k in c18["width"] for o in v), tol=30)
# seats
se = G["seats"]
ped = {}
for o in all_objects(doc):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith(f"{PID}-BS"):
ped[int(oid[len(PID) + 3:])] = o.Geometry.GetBoundingBox(True)
flag("5 pedestals found", sorted(ped) == [1, 2, 3, 4, 5], f"{sorted(ped)}")
for k, pb in sorted(ped.items()):
cx, cy = (pb.Min.X + pb.Max.X) / 2, (pb.Min.Y + pb.Max.Y) / 2
g1 = [o for o in cl["S1"] if occ(o).startswith(f"P{k}-")]
g2 = [o for o in cl["S2"] if occ(o).startswith(f"P{k}-")]
flag(f"pedestal {k}: S1 11 / S2 20", (len(g1), len(g2)) == (11, 20), f"{len(g1)} {len(g2)}")
zt = pb.Max.Z - se["top_below"]
check(f"pedestal {k} S1 level {se['top_below']} below the pedestal top (worst, plane slope <= 20)", 0, max(abs(bb(o).Max.Z - zt) for o in g1), tol=20)
check(f"pedestal {k} S1 legs {se['leg']} (worst)", 0, max(abs((bb(o).Max.Z - bb(o).Min.Z) - se["leg"]) for o in g1 + g2))
check(f"pedestal {k} S2 level 13 under S1 (worst)", 0, max(abs(bb(o).Max.Z - (min(bb(p).Max.Z for p in g1) - 13)) for o in g2), tol=1.5)
check(f"pedestal {k} S1 x positions (worst)", 0, worst([mid(o)[0] - cx for o in g1], se["s1_t"]), tol=1.5)
check(f"pedestal {k} S1 length along Y (worst)", 0, max(abs((bb(o).Max.Y - bb(o).Min.Y) - se["s1_len"]) for o in g1), tol=1.5)
check(f"pedestal {k} S2 y positions (worst)", 0, worst([mid(o)[1] - cy for o in g2], se["s2_o"]), tol=1.5)
check(f"pedestal {k} S2 length along X (worst)", 0, max(abs((bb(o).Max.X - bb(o).Min.X) - se["s2_len"]) for o in g2), tol=1.5)
# containment and cover
solids = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"
or (o.Attributes.GetUserString("id") or "").startswith(f"{PID}-BS")]
solids = [s if isinstance(s, rg.Brep) else rg.Brep.TryConvertBrep(s) for s in solids]
cp = [o.Geometry for o in all_objects(doc) if (o.Attributes.GetUserString("id") or "") == f"{PID}-CP"][0]
info("concrete solids for containment", f"{len(solids)} (coping + pedestals)")
cover = {}
for m in SCHED:
bad, cov = 0, 1e9
r = G["dia"][m] / 2
for o in cl[m]:
cu = o.Geometry
for t in cu.DivideByCount(max(6, int(cu.GetLength() / 200)), True):
p = cu.PointAt(t)
for q in (p, p + rg.Vector3d(r, 0, 0), p - rg.Vector3d(r, 0, 0), p + rg.Vector3d(0, r, 0),
p - rg.Vector3d(0, r, 0), p + rg.Vector3d(0, 0, r), p - rg.Vector3d(0, 0, r)):
if not any(s.IsPointInside(q, 0.01, False) for s in solids):
bad += 1
if cp.IsPointInside(p, 0.01, False):
cov = min(cov, p.DistanceTo(cp.ClosestPoint(p)) - r)
cover[m] = round(cov, 1)
check(f"{m} sample points outside concrete", 0, bad, tol=0)
info("min cover to the coping surface per mark (record; seat bars measured to the coping only)", json.dumps(cover))
write_result(RESULT_PATH, results, dict(cover=cover))
main()
@@ -0,0 +1,135 @@
"""G5 gate checker: bridge bearings (BB) and sole plates (SP), check C11, inside Rhino 8.
Globals: __rhino_doc__, EXPECTED_PATH, RESULT_PATH. Expected values from EXPECTED_PATH section G5 (D2 ELEVATION
TABLE, 솔플레이트 상세 TABLE, user rules); orientation is compared with the modelled h2 pads (BP).
Generator params are not read. Ported from Code/교각모델링/verify_bearing_plates.py (same checks and order).
"""
import json
import math
import os
import sys
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectColorSource
sys.path.insert(0, os.path.dirname(__file__))
from _common import all_objects, load_expected # noqa: E402
doc = __rhino_doc__
X = load_expected(EXPECTED_PATH)
PID, G = X["pier_id"], X["G5"]
ELX = {int(k): v for k, v in G["el_x"].items()}
S = {int(k): v for k, v in G["S"].items()}
W = {int(k): v for k, v in G["W"].items()}
L, T, H3, SHRINK = G["L"], G["T"], G["h3"], G.get("bearing_shrink")
# per-bearing values (Ulsan 2026-10-13): L / T / h3 may be {no: value}; bearing_size {no: [along, across]} replaces
# the (L-shrink) x (W-shrink) rule; thick_end "-Y" (Ungok, default) or "+Y"
per = lambda v, no: v[str(no)] if isinstance(v, dict) else v
BSIZE = {int(k): v for k, v in G.get("bearing_size", {}).items()}
THICK = G.get("thick_end", "-Y")
COL_B, COL_S = tuple(G["colours"]["bearing"]), tuple(G["colours"]["sole_plate"])
results = []
def check(item, exp, act, tol=1.0):
results.append(dict(check="C11", item=item, expected=round(exp, 4), actual=round(act, 4),
diff=round(act - exp, 4), tol=tol, result="PASS" if abs(act - exp) <= tol else "FAIL"))
def flag(item, ok, detail=""):
results.append(dict(check="C11", item=item, detail=detail, result="PASS" if ok else "FAIL"))
def edges_xy(brep, top=False):
"""Return horizontal edge vectors of the lowest (or highest) face (4 edges). Pads use the top face: their bottom
may follow a sloped pedestal top (Okseong P1, 2026-10-14)."""
zref = (max if top else min)(v.Location.Z for v in brep.Vertices)
out = []
for e in brep.Edges:
a, b = e.PointAtStart, e.PointAtEnd
if abs(a.Z - zref) < 0.01 and abs(b.Z - zref) < 0.01 and a.DistanceTo(b) > 1:
out.append(b - a)
return out
def split_axes(vecs):
"""Bridge-axis (along) edge = within 45 deg of +Y; the other is across. Not by length (W > L occurs)."""
along = [v for v in vecs if abs(v.Y) > abs(v.X)]
across = [v for v in vecs if abs(v.Y) <= abs(v.X)]
return along[0], across[0]
def axis_deg(vec):
"""Angle of a plan vector from +Y, clockwise positive, folded to [-90, 90)."""
a = math.degrees(math.atan2(vec.X, vec.Y))
while a >= 90:
a -= 180
while a < -90:
a += 180
return a
def main():
objs = all_objects(doc)
by = lambda p: {int(o.Attributes.GetUserString("id")[len(p):]): o for o in objs
if (o.Attributes.GetUserString("id") or "").startswith(p)}
bb, sp, pad = by(f"{PID}-BB"), by(f"{PID}-SP"), by(f"{PID}-BP")
n = len(ELX)
check("bearing count", n, len(bb), tol=0)
check("sole plate count", n, len(sp), tol=0)
flag(f"all {2 * n} closed solids", all(o.Geometry.IsSolid for o in list(bb.values()) + list(sp.values())))
for no in sorted(ELX):
gb, gs, gp = bb[no].Geometry, sp[no].Geometry, pad[no].Geometry
bx, sx = gb.GetBoundingBox(True), gs.GetBoundingBox(True)
check(f"NO.{no} bearing bottom = EL.'X'", ELX[no], bx.Min.Z)
check(f"NO.{no} bearing height h3", per(H3, no), bx.Max.Z - bx.Min.Z, tol=0.01)
ab, cb_ = split_axes(edges_xy(gb))
if no in BSIZE:
check(f"NO.{no} bearing across (B)", BSIZE[no][1], cb_.Length, tol=0.5)
check(f"NO.{no} bearing along (A)", BSIZE[no][0], ab.Length, tol=0.5)
else:
check(f"NO.{no} bearing across (W-{SHRINK})", W[no] - SHRINK, cb_.Length, tol=0.5)
check(f"NO.{no} bearing along (L-{SHRINK})", per(L, no) - SHRINK, ab.Length, tol=0.5)
as_, cs_ = split_axes(edges_xy(gs))
check(f"NO.{no} sole plate across W", W[no], cs_.Length, tol=0.5)
check(f"NO.{no} sole plate along L", per(L, no), as_.Length, tol=0.5)
check(f"NO.{no} sole plate bottom = bearing top", bx.Max.Z, sx.Min.Z, tol=0.01)
long_b, long_s, long_p = ab, as_, split_axes(edges_xy(gp, top=True))[0]
pc = gp.GetBoundingBox(True).Center
for nm, g in (("bearing", gb), ("sole plate", gs)):
c = g.GetBoundingBox(True).Center
check(f"NO.{no} {nm} plan centre offset from pad (mm)", 0, math.hypot(c.X - pc.X, c.Y - pc.Y), tol=1.0)
check(f"NO.{no} bearing axis vs pad (deg)", axis_deg(long_p), axis_deg(long_b), tol=0.01)
check(f"NO.{no} sole plate axis vs pad (deg)", axis_deg(long_p), axis_deg(long_s), tol=0.01)
top = [v.Location for v in gs.Vertices if v.Location.Z > sx.Min.Z + 1]
ua = rg.Vector3d(long_s.X, long_s.Y, 0)
ua.Unitize()
if ua.Y < 0:
ua = -ua # along points to +Y
c = rg.Point3d(sum(p.X for p in top) / 4, sum(p.Y for p in top) / 4, 0)
proj = [((p - c) * ua, p.Z) for p in top]
lo = [z for s_, z in proj if s_ < 0]
hi = [z for s_, z in proj if s_ > 0]
slope = (sum(hi) / len(hi) - sum(lo) / len(lo)) / per(L, no) * 100
check(f"NO.{no} sole plate slope S (%) along +Y-ward axis", S[no], slope, tol=0.01)
check(f"NO.{no} sole plate centre thickness T", per(T, no), sum(z for _, z in proj) / 4 - sx.Min.Z, tol=0.01)
flag(f"NO.{no} thick end at {THICK}", sum(lo) > sum(hi) if THICK == "-Y" else sum(hi) > sum(lo))
pairs = sorted(proj)
check(f"NO.{no} no transverse slope", 0, max(abs(pairs[0][1] - pairs[1][1]), abs(pairs[2][1] - pairs[3][1])),
tol=0.01)
for a, b2, name in ((gb, gp, "bearing-pad"), (gb, gs, "bearing-sole plate")):
r = rg.Brep.CreateBooleanIntersection(a, b2, 0.01)
v = sum(rg.VolumeMassProperties.Compute(x).Volume for x in r) if r else 0.0
check(f"NO.{no} overlap {name} (mm3)", 0, v, tol=1.0)
cb, cs = bb[no].Attributes, sp[no].Attributes
rgb = lambda a: (a.ObjectColor.R, a.ObjectColor.G, a.ObjectColor.B)
flag(f"NO.{no} colours (bearing {COL_B}, sole plate {COL_S})",
cb.ColorSource == ObjectColorSource.ColorFromObject and rgb(cb) == COL_B
and cs.ColorSource == ObjectColorSource.ColorFromObject and rgb(cs) == COL_S)
summ = dict(total=len(results), passed=sum(r["result"] == "PASS" for r in results),
failed=sum(r["result"] == "FAIL" for r in results))
json.dump(dict(summary=summ, results=results), open(RESULT_PATH, "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(json.dumps(summ))
main()
@@ -0,0 +1,180 @@
"""G2 stage: column rebar P1-P6 (centreline + pipe solid) from params `column_rebar`.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after concrete.py.
Ported from Code/교각모델링/build_rebar_rhino.py (Ungok P1, model v0.1.09). Rules: 설계서 v0.1.04 §5 (HITL H1-H3 approved 2026-10-07). Units mm, Z = EL x 1000.
Each centreline carries `design_length` (schedule-style original length: straight + arc lengths
before hook fillets) and the actual curve length is measured separately (box rule: 원길이 vs 곡선길이).
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
MARK_DIA = {m: v["dia"] for m, v in P["rebar_marks"].items()}
DIA = {d: float(d[1:]) for d in MARK_DIA.values()} # 'S32' -> 32.0
COLORS = {m: tuple(c) for m, c in P["display"]["column_mark_colours"].items()}
CL_RGB = tuple(P["display"]["centreline_colour"])
Z_FT_TOP = P["footing"]["z_bottom"] + P["footing"]["thickness"] # ties/hoops below -> anchored in footing
Z_CP_BOT = P["coping"]["z_bottom_flat"] # above -> anchored in coping
def layer(path, color):
idx = doc.Layers.FindByFullPath(path, -1)
if idx >= 0:
return idx
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
sub = "::".join(parts[:i])
idx = doc.Layers.FindByFullPath(sub, -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_bend_radius(dia_name):
d = DIA[dia_name]
inner = 2 * d if d <= 16 else 3.5 * d # box project rule (REBAR_BENDS_20260914)
return (inner + d) / 2
def add_bar(curve, mark, occ, design_len, hosts, src, coupler=0.0):
d = DIA[MARK_DIA[mark]]
# fitRail=False: fitRail=True fails on line-arc-line rails and crashed a headless Rhino (2026-10-07)
pipes = rg.Brep.CreatePipe(curve, d / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=MARK_DIA[mark], shape=P["column_rebar"]["schedule"][mark]["shape"],
occurrence=occ, host=f"{PID}-CL", anchor_hosts=hosts, design_length=f"{design_len:.3f}",
coupler_length=f"{coupler:.3f}",
src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::기둥::{mark}", COLORS[mark]), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
if isinstance(geo, rg.Brep):
doc.Objects.AddBrep(geo, attr)
else:
doc.Objects.AddCurve(geo, attr)
def pt(r, deg, z):
t = math.radians(deg)
return rg.Point3d(r * math.cos(t), r * math.sin(t), z)
def arc_xy(r, a0, a1, z):
"""Horizontal arc at height z from angle a0 to a1 (deg, counter-clockwise)."""
am = (a0 + a1) / 2
return rg.ArcCurve(rg.Arc(pt(r, a0, z), pt(r, am, z), pt(r, a1, z)))
def hooked_half_ring(r, a0, z, hook, bend_r):
"""Semicircle a0 -> a0+180 with inward 135-degree hooks at both ends (P3, shape VI)."""
arc = arc_xy(r, a0, a0 + 180, z)
pieces = []
for at_start in (True, False):
p = arc.PointAtStart if at_start else arc.PointAtEnd
t = arc.TangentAtStart if at_start else arc.TangentAtEnd
if at_start:
t = -t # travel direction away from the arc
n = rg.Vector3d(-p.X, -p.Y, 0)
n.Unitize()
d = t * math.cos(math.radians(135)) + n * math.sin(math.radians(135)) # back + inward
d.Unitize()
pieces.append(rg.LineCurve(p, p + d * hook))
joined = rg.Curve.JoinCurves([pieces[0], arc, pieces[1]], TOL)[0]
fil = rg.Curve.CreateFilletCornersCurve(joined, bend_r, TOL, math.radians(1))
return fil or joined
def main():
for o in list(doc.Objects):
if (o.Attributes.GetUserString("id") or "").startswith((f"{PID}-R-", f"{PID}-RC-")):
doc.Objects.Delete(o, True)
cr = P["column_rebar"]
mb, hp, tie = cr["main_bars"], cr["hoops"], cr["ties"]
sch = cr["schedule"]
count = 0
# Main bars P1 (outer, hooked) / P2 (inner, straight)
p1, p2 = mb["p1"], mb["p2"]
for i in range(mb["divisions"]):
th = mb["start_deg"] * 0 + i * mb["pitch_deg"] # start angle 0 (CAD 0.001 deg)
ro, ri = mb["r_outer"], mb["r_inner"]
z_leg, R = p1["hook_leg_z"], p1["bend_r"]
top = p1["straight_from"] + p1["straight_len"]
leg = rg.LineCurve(pt(ro + R + p1["hook_leg"], th, z_leg), pt(ro + R, th, z_leg))
bend = rg.ArcCurve(rg.Arc(pt(ro + R, th, z_leg),
pt(ro + R - R * math.sin(math.radians(45)), th,
z_leg + R - R * math.cos(math.radians(45))),
pt(ro, th, z_leg + R)))
vert = rg.LineCurve(pt(ro, th, z_leg + R), pt(ro, th, top))
c1 = rg.Curve.JoinCurves([leg, bend, vert], TOL)[0]
add_bar(c1, "P1", f"{i + 1:02d}", p1["straight_len"] + math.pi / 2 * R + p1["hook_leg"],
f"{PID}-FT,{PID}-CP", f"angle {th:.4f} deg; D1 단면 C-C / 정면도; D3 P1")
c2 = rg.LineCurve(pt(ri, th, p2["z0"]), pt(ri, th, p2["z0"] + p2["length"]))
add_bar(c2, "P2", f"{i + 1:02d}", p2["length"], f"{PID}-FT,{PID}-CP",
f"angle {th:.4f} deg; D1 단면 C-C / 정면도; D3 P2")
count += 2
# Hoops: P4 (V: 2 half arcs + 2 coupler gaps), P5 (IX: ring + 1 gap), P3 (VI: 2 hooked halves)
r = hp["radius"]
n3 = 0
for k, (z, mark) in enumerate(zip(hp["levels"], hp["marks"])):
r = hp.get("radius_by_mark", {}).get(mark, hp["radius"]) # Ungok P2: P3 R899, P4/P5 R904
s = sch[mark]
a = float(s["A"]) * 1000 if float(s["A"]) < 100 else float(s["A"])
if mark in ("P4", "P5"):
gap = float(s["B"])
gap_deg = math.degrees(gap / r)
arcs = ([(90 + gap_deg / 2, 270 - gap_deg / 2), (270 + gap_deg / 2, 450 - gap_deg / 2)]
if mark == "P4" else [(90 + gap_deg / 2, 450 - gap_deg / 2)])
curves = [arc_xy(r, a0, a1, z) for a0, a1 in arcs]
for j, c in enumerate(curves):
add_bar(c, mark, f"L{k + 1:02d}{'ab'[j] if len(curves) > 1 else ''}", a, "",
f"EL {z / 1000:.3f}; coupler gap {gap:.0f} at {'90/270' if mark == 'P4' else '90'} deg",
coupler=gap) # one coupler (b) per piece: P4 2 pieces/2 gaps, P5 1/1
else:
n3 += 1
a0 = 90 if n3 % 2 else 0 # joins staggered by 90 deg
hook = float(s["B"])
for j, start in enumerate((a0, a0 + 180)):
c = hooked_half_ring(r, start, z, hook, min_bend_radius(MARK_DIA["P3"]))
add_bar(c, mark, f"L{k + 1:02d}{'ab'[j]}", a + 2 * hook,
f"{PID}-CP" if z > Z_CP_BOT else "", f"EL {z / 1000:.3f}; joins at {a0}/{a0 + 180} deg")
count += 1
# Cross ties P6 (III): straight 1836 on diameters, 150 hooks down at r=918, sitting on hoop
half = tie["straight"] / 2
zoff = DIA[MARK_DIA["P4"]] / 2 + DIA[MARK_DIA["P6"]] / 2
for k, z in enumerate(tie["levels"]):
for ang in tie["angles_deg"]:
zt = z + zoff
pts = [pt(half, ang + 180, zt - tie["hook"]), pt(half, ang + 180, zt), pt(half, ang, zt),
pt(half, ang, zt - tie["hook"])]
pl = rg.PolylineCurve(pts)
c = rg.Curve.CreateFilletCornersCurve(pl, min_bend_radius(MARK_DIA["P6"]), TOL, math.radians(1)) or pl
add_bar(c, "P6", f"L{k + 1}-{ang:03d}", tie["straight"] + 2 * tie["hook"],
f"{PID}-FT" if z < Z_FT_TOP else (f"{PID}-CP" if z > Z_CP_BOT else ""),
f"EL {z / 1000:.3f} (+{zoff} on hoop); angle {ang} deg")
count += 1
doc.Views.Redraw()
print(json.dumps({"bars": count}))
main()
@@ -0,0 +1,205 @@
"""G2 stage, form "ring" (Ulsan P1, 2026-10-13): circular column rebar with one main-bar ring, hoop zones of
different hoop types and cross ties, from params `column_rebar` (centreline + pipe solid per bar).
Run inside Rhino 8 via runner (globals PARAMS_PATH) after the G1 builder. Units mm, Z = EL x 1000.
params column_rebar:
main {mark, count, start_deg, radius, hook_leg_z, bend_r, hook_leg, straight_from, straight_len}
bar = horizontal leg pointing outwards at hook_leg_z, R bend, vertical straight part (shape VI)
hoops {radius, zones: [{mark, type, levels, a, gap | lap+hook}]}
type "coupler_half": two half arcs, coupler gaps `gap` at 90 / 270 deg (shape V)
"coupler_ring": one ring, one coupler gap at 90 deg (shape X)
"lap_half": two half rings, each extended by lap/2 at both ends, 135-deg hooks (shape VI);
joins alternate between 90/270 and 0/180 deg
ties {mark, levels, angles_deg, straight, hook, z_offset}: straight bars on diameters, hooks down
`design_length` is the schedule-style original length; `coupler_length` the gap per piece (schedule L
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.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items()}
DIA = {d: float(d[1:]) for d, _ in MARK.values()}
CL_RGB = tuple(P["display"]["centreline_colour"])
COLORS = {m: tuple(c) for m, c in P["display"].get("column_mark_colours", {}).items()}
Z_FT_TOP = P["footing"]["z_bottom"] + P["footing"]["thickness"]
Z_CP_BOT = Z_FT_TOP + P["column"]["height"]
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_bend_radius(dia_name):
d = DIA[dia_name]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2
def hosts(z):
return f"{PID}-FT" if z < Z_FT_TOP else (f"{PID}-CP" if z > Z_CP_BOT else "")
def add_bar(curve, mark, occ, design_len, anchor, src, coupler=0.0):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-CL", anchor_hosts=anchor,
design_length=f"{design_len:.3f}", coupler_length=f"{coupler:.3f}", src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::기둥::{mark}", COLORS.get(mark, (200, 140, 90))), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(geo, attr) if isinstance(geo, rg.Brep) else doc.Objects.AddCurve(geo, attr)
if gid == __import__("System").Guid.Empty:
raise RuntimeError(f"{oid}: add failed")
def pt(r, deg, z):
t = math.radians(deg)
return rg.Point3d(r * math.cos(t), r * math.sin(t), z)
def arc_xy(r, a0, a1, z):
return rg.ArcCurve(rg.Arc(pt(r, a0, z), pt(r, (a0 + a1) / 2, z), pt(r, a1, z)))
def hooked_arc(r, a0, a1, z, hook, bend_r):
"""Arc a0 -> a1 (deg, ccw) with inward 135-deg hooks at both ends, fillet bend_r."""
arc = arc_xy(r, a0, a1, z)
pieces = []
for at_start in (True, False):
p = arc.PointAtStart if at_start else arc.PointAtEnd
t = -arc.TangentAtStart if at_start else arc.TangentAtEnd
n = rg.Vector3d(-p.X, -p.Y, 0)
n.Unitize()
d = t * math.cos(math.radians(135)) + n * math.sin(math.radians(135))
d.Unitize()
pieces.append(rg.LineCurve(p, p + d * hook))
joined = rg.Curve.JoinCurves([pieces[0], arc, pieces[1]], TOL)[0]
return rg.Curve.CreateFilletCornersCurve(joined, bend_r, TOL, math.radians(1)) or joined
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-R-P", f"{PID}-RC-P")):
doc.Objects.Delete(o, True)
cr = P["column_rebar"]
m = cr["main"]
n = 0
ro, R = m["radius"], m["bend_r"]
top = m["straight_from"] + m["straight_len"]
for i in range(m["count"]):
th = m["start_deg"] + i * 360.0 / m["count"]
leg = rg.LineCurve(pt(ro + R + m["hook_leg"], th, m["hook_leg_z"]), pt(ro + R, th, m["hook_leg_z"]))
bend = rg.ArcCurve(rg.Arc(pt(ro + R, th, m["hook_leg_z"]),
pt(ro + R - R * math.sin(math.radians(45)), th, m["hook_leg_z"] + R - R * math.cos(math.radians(45))),
pt(ro, th, m["hook_leg_z"] + R)))
vert = rg.LineCurve(pt(ro, th, m["hook_leg_z"] + R), pt(ro, th, top))
c = rg.Curve.JoinCurves([leg, bend, vert], TOL)[0]
add_bar(c, m["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; 배근도(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
hp = cr["hoops"]
r = hp["radius"]
k = 0
for zone in hp["zones"]:
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"]):
k += 1
if typ == "coupler_half":
gd = math.degrees(zone["gap"] / r)
arcs = [(90 + gd / 2, 270 - gd / 2), (270 + gd / 2, 450 - gd / 2)]
for q, (a0, a1) in enumerate(arcs):
add_bar(arc_xy(r, a0, a1, z), mark, f"L{k:03d}{'ab'[q]}", zone["a"], hosts(z),
f"EL {z / 1000:.3f}; coupler gaps {zone['gap']:.0f} at 90/270 deg [추론]", coupler=zone["gap"])
elif typ == "coupler_ring":
gd = math.degrees(zone["gap"] / r)
add_bar(arc_xy(r, 90 + gd / 2, 450 - gd / 2, z), mark, f"L{k:03d}", zone["a"], hosts(z),
f"EL {z / 1000:.3f}; coupler gap {zone['gap']:.0f} at 90 deg [추론]", coupler=zone["gap"])
elif typ == "lap_half":
ext = math.degrees(zone["lap"] / 2 / r)
a0 = 90 if j % 2 == 0 else 0 # joins alternate by 90 deg [추론]
for q, s in enumerate((a0, a0 + 180)):
c = hooked_arc(r, s - ext, s + 180 + ext, z, zone["hook"], min_bend_radius(MARK[mark][0]))
add_bar(c, mark, f"L{k:03d}{'ab'[q]}", zone["a"] + zone["lap"] + 2 * zone["hook"], hosts(z),
f"EL {z / 1000:.3f}; laps {zone['lap']:.0f} at {a0}/{a0 + 180} deg [추론]")
else:
raise RuntimeError(f"unknown hoop type {typ}")
n += 1
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
for q, z in enumerate(tie["levels"]):
zt = z + tie["z_offset"]
for ang in tie["angles_deg"]:
pts = [pt(half, ang + 180, zt - tie["hook"]), pt(half, ang + 180, zt), pt(half, ang, zt), pt(half, ang, zt - tie["hook"])]
pl = rg.PolylineCurve(pts)
c = rg.Curve.CreateFilletCornersCurve(pl, min_bend_radius(MARK[tie["mark"]][0]), TOL, math.radians(1)) or pl
add_bar(c, tie["mark"], f"L{q + 1:02d}-{ang:05.1f}", tie["straight"] + 2 * tie["hook"], hosts(z),
f"EL {z / 1000:.3f} (+{tie['z_offset']} on hoop); angle {ang:.2f} deg")
n += 1
doc.Views.Redraw()
print(json.dumps({"bars": n}))
main()
@@ -0,0 +1,150 @@
"""G2 stage, form "straight" (Ramp-A P1, 2026-10-13): column on a drilled shaft, no footing. Straight main bars
in one ring (bottom coupler to the shaft cage, not modelled as a solid), lapped half-ring hoops with 135-deg hooks,
cross ties in X and Y (90-deg leg at one end, 135-deg hook at the other), from params `column_rebar`.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after the G1 builder. Units mm, Z = EL x 1000.
params column_rebar:
main {mark, count, start_deg, radius, z0, z1, coupler_length}
hoops {mark, radius, levels, a, lap, hook, join_deg [first, second]} joins alternate per level (K4)
ties {mark, levels, straight, leg, hook_tail, y_lift} X tie at the level, Y tie y_lift above (K3);
the 90-deg leg (down) and the 135-deg hook swap ends per level (K4)
design lengths {mark: schedule-style original length per piece}
Hosts for cover / anchorage: below the column bottom = pile top segment, above the column top = coping.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items()}
DIA = {d: float(d[1:]) for d, _ in MARK.values()}
CL_RGB = tuple(P["display"]["centreline_colour"])
COLORS = {m: tuple(c) for m, c in P["display"].get("column_mark_colours", {}).items()}
Z_CL0, Z_CL1 = P["column"]["z0"], P["column"]["z1"]
PILE_TOP = f"{PID}-PL{len(P['pile']['segments']):02d}"
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_bend_radius(dia_name):
d = DIA[dia_name]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2
def hosts(z):
return PILE_TOP if z < Z_CL0 else (f"{PID}-CP" if z > Z_CL1 else "")
def add_bar(curve, mark, occ, design_len, anchor, src, coupler=0.0):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-CL", anchor_hosts=anchor,
design_length=f"{design_len:.3f}", coupler_length=f"{coupler:.3f}", src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::기둥::{mark}", COLORS.get(mark, (200, 140, 90))), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(geo, attr) if isinstance(geo, rg.Brep) else doc.Objects.AddCurve(geo, attr)
if gid == __import__("System").Guid.Empty:
raise RuntimeError(f"{oid}: add failed")
def pt(r, deg, z):
t = math.radians(deg)
return rg.Point3d(r * math.cos(t), r * math.sin(t), z)
def hooked_arc(r, a0, a1, z, hook, bend_r):
"""Arc a0 -> a1 (deg, ccw) with inward 135-deg hooks at both ends, fillet bend_r (as column_rebar_ring.py)."""
arc = rg.ArcCurve(rg.Arc(pt(r, a0, z), pt(r, (a0 + a1) / 2, z), pt(r, a1, z)))
pieces = []
for at_start in (True, False):
p = arc.PointAtStart if at_start else arc.PointAtEnd
t = -arc.TangentAtStart if at_start else arc.TangentAtEnd
n = rg.Vector3d(-p.X, -p.Y, 0)
n.Unitize()
d = t * math.cos(math.radians(135)) + n * math.sin(math.radians(135))
d.Unitize()
pieces.append(rg.LineCurve(p, p + d * hook))
joined = rg.Curve.JoinCurves([pieces[0], arc, pieces[1]], TOL)[0]
return rg.Curve.CreateFilletCornersCurve(joined, bend_r, TOL, math.radians(1)) or joined
def tie_curve(axis, z, half, leg, tail, flip):
"""Straight part on the X (axis 0) or Y (axis 1) diameter at z; 90-deg leg `leg` down at one end, 135-deg hook
(tail pointing down and back inwards) at the other; `flip` swaps the ends."""
u = rg.Vector3d(1, 0, 0) if axis == 0 else rg.Vector3d(0, 1, 0)
s = -1 if flip else 1
a, b = rg.Point3d(0, 0, z) - u * (s * half), rg.Point3d(0, 0, z) + u * (s * half)
d = -u * s * math.cos(math.radians(45)) + rg.Vector3d(0, 0, -1) * math.sin(math.radians(45))
return rg.PolylineCurve([a + rg.Vector3d(0, 0, -leg), a, b, b + d * tail])
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-R-P", f"{PID}-RC-P")):
doc.Objects.Delete(o, True)
cr = P["column_rebar"]
DL = cr["design_lengths"]
m = cr["main"]
n = 0
for i in range(m["count"]):
th = m["start_deg"] + i * 360.0 / m["count"]
c = rg.LineCurve(pt(m["radius"], th, m["z0"]), pt(m["radius"], th, m["z1"]))
add_bar(c, m["mark"], f"{i + 1:02d}", DL[m["mark"]], f"{PILE_TOP},{PID}-CP",
f"angle {th:.4f} deg; D2 단면 C-C DIVISION {m['count']} r {m['radius']:.0f}; 정면 EL {m['z0'] / 1000:.3f}"
f"~{m['z1'] / 1000:.3f}; 하단 커플러 {m['coupler_length']:.0f} (K1)", coupler=m["coupler_length"])
n += 1
h = cr["hoops"]
r, ext = h["radius"], math.degrees(h["lap"] / 2 / h["radius"])
for j, z in enumerate(h["levels"]):
a0 = h["join_deg"][j % 2]
for q, s in enumerate((a0, a0 + 180)):
c = hooked_arc(r, s - ext, s + 180 + ext, z, h["hook"], min_bend_radius(MARK[h["mark"]][0]))
add_bar(c, h["mark"], f"L{j + 1:03d}{'ab'[q]}", DL[h["mark"]], hosts(z),
f"EL {z / 1000:.3f}; 겹이음 {h['lap']:.0f} at {a0}/{a0 + 180} deg (K4 [추론])")
n += 1
t = cr["ties"]
rb = min_bend_radius(MARK[t["mark"]][0])
for j, z in enumerate(t["levels"]):
for axis, name in ((0, "X"), (1, "Y")):
zt = z + (t["y_lift"] if axis == 1 else 0)
pl = tie_curve(axis, zt, t["straight"] / 2, t["leg"], t["hook_tail"], flip=(j % 2 == 1))
c = rg.Curve.CreateFilletCornersCurve(pl, rb, TOL, math.radians(1)) or pl
add_bar(c, t["mark"], f"L{j + 1:02d}{name}", DL[t["mark"]], hosts(zt),
f"EL {zt / 1000:.3f}; {name} 방향{' +' + str(t['y_lift']) + ' (K3 [추론])' if axis else ''}; "
f"90° 다리·135° 갈고리 {'좌우 교번' if j % 2 else '기본'} (K4 [추론])")
n += 1
doc.Views.Redraw()
print(json.dumps({"bars": n}))
main()
@@ -0,0 +1,190 @@
"""G1 stage: T-pier concrete (lean, footing, circular column, coping, piles, pedestals, pads).
Run inside Rhino 8 via runner with globals PARAMS_PATH (app schema pier-T/1).
Only params are read; no drawing-name branching. Units: mm, Z = EL x 1000.
Ported from Code/교각모델링/build_rhino.py (Ungok P1, model v0.1.09).
"""
import json
import math
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
FCK = P["materials"]["fck"]
LAYERS = {k: (f"{PID}::{name}", Color.FromArgb(*rgb)) for k, (name, rgb) in P["display"]["concrete_layers"].items()}
def layer_index(full_path, color):
idx = doc.Layers.FindByFullPath(full_path, -1)
if idx >= 0:
return idx
parent_name, child = full_path.split("::")
parent = doc.Layers.FindByFullPath(parent_name, -1)
if parent < 0:
parent = doc.Layers.Add(parent_name, Color.Black)
layer = __import__("Rhino").DocObjects.Layer()
layer.Name, layer.Color = child, color
layer.ParentLayerId = doc.Layers[parent].Id
return doc.Layers.Add(layer)
def add(brep, key, oid, role, fck, views):
if brep is None or not brep.IsSolid:
raise RuntimeError(f"{oid}: not a closed solid")
attr = ObjectAttributes()
attr.LayerIndex = layer_index(*LAYERS[key])
attr.Name = oid
for k, v in (("id", oid), ("role", role), ("fck", fck), ("source_views", views)):
attr.SetUserString(k, v)
return doc.Objects.AddBrep(brep, attr)
def box(x0, x1, y0, y1, z0, z1):
return rg.Box(rg.BoundingBox(x0, y0, z0, x1, y1, z1)).ToBrep()
def cylinder(x, y, z0, z1, r):
circle = rg.Circle(rg.Plane(rg.Point3d(x, y, z0), rg.Vector3d.ZAxis), r)
return rg.Cylinder(circle, z1 - z0).ToBrep(True, True)
def arc_through(p_start, p_end, radius, y):
"""Arc of given radius between two XZ points; center on the lower side (soffit)."""
(x1, z1), (x2, z2) = p_start, p_end
mx, mz = (x1 + x2) / 2, (z1 + z2) / 2
dx, dz = x2 - x1, z2 - z1
chord = math.hypot(dx, dz)
h = math.sqrt(radius ** 2 - (chord / 2) ** 2)
nx, nz = -dz / chord, dx / chord
centers = [(mx + nx * h, mz + nz * h), (mx - nx * h, mz - nz * h)]
cx, cz = min(centers, key=lambda c: c[1])
a1, a2 = math.atan2(z1 - cz, x1 - cx), math.atan2(z2 - cz, x2 - cx)
am = (a1 + a2) / 2
mid = rg.Point3d(cx + radius * math.cos(am), y, cz + radius * math.sin(am))
arc = rg.Arc(rg.Point3d(x1, y, z1), mid, rg.Point3d(x2, y, z2))
return rg.ArcCurve(arc), (cx, cz)
def coping_brep(c):
y0 = -c["width_y"] / 2
half = c["length_bottom_tip_to_tip"] / 2
zb, rise, r = c["z_bottom_flat"], c["tip_bottom_rise"], c["soffit_radius"]
flat, inset = c["flat_bottom_half_width"], c["end_batter_top_inset"]
steps = c["top_steps"]
right_arc, rc = arc_through((flat, zb), (half, zb + rise), r, y0)
left_arc, lc = arc_through((-half, zb + rise), (-flat, zb), r, y0)
pts = [(half, zb + rise), (half - inset, steps[-1]["z"])]
for s_prev, s in zip(reversed(steps[1:]), reversed(steps[:-1])):
pts += [(s_prev["x0"], s_prev["z"]), (s["x1"], s["z"])]
pts += [(-half + inset, steps[0]["z"]), (-half, zb + rise)]
top = rg.PolylineCurve([rg.Point3d(x, y0, z) for x, z in pts])
bottom = rg.LineCurve(rg.Point3d(-flat, y0, zb), rg.Point3d(flat, y0, zb))
joined = rg.Curve.JoinCurves([bottom, right_arc, top, left_arc], TOL)
if len(joined) != 1 or not joined[0].IsClosed:
raise RuntimeError("coping profile not closed")
srf = rg.Surface.CreateExtrusion(joined[0], rg.Vector3d(0, c["width_y"], 0))
brep = srf.ToBrep().CapPlanarHoles(TOL)
if brep.SolidOrientation == rg.BrepSolidOrientation.Inward:
brep.Flip()
return brep, {"right_arc_center": rc, "left_arc_center": lc}
def prism(pts_xy, z0, z1):
poly = rg.PolylineCurve([rg.Point3d(x, y, z0) for x, y in pts_xy + pts_xy[:1]])
srf = rg.Surface.CreateExtrusion(poly, rg.Vector3d(0, 0, z1 - z0))
b = srf.ToBrep().CapPlanarHoles(TOL)
if b is not None and b.SolidOrientation == rg.BrepSolidOrientation.Inward:
b.Flip()
return b
def rotated_rect(cx, cy, along, across, rot_deg, z):
"""Rectangle with `along` side on +Y rotated clockwise by rot_deg (plan view)."""
t = math.radians(rot_deg)
ua, uc = (math.sin(t), math.cos(t)), (math.cos(t), -math.sin(t))
pts = [(cx + sa * ua[0] * along / 2 + sc * uc[0] * across / 2,
cy + sa * ua[1] * along / 2 + sc * uc[1] * across / 2)
for sa, sc in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
return rg.PolylineCurve([rg.Point3d(x, y, z) for x, y in pts + pts[:1]])
def frustum(bottom, top):
lofts = rg.Brep.CreateFromLoft([bottom, top], rg.Point3d.Unset, rg.Point3d.Unset,
rg.LoftType.Straight, False)
b = lofts[0].CapPlanarHoles(TOL)
if b is not None and b.SolidOrientation == rg.BrepSolidOrientation.Inward:
b.Flip()
return b
def add_bearing_seats(bs, steps):
"""Pedestals (h1) per girder line split front/rear, and h2 pads (D2 TABLE A, B, h2)."""
ped = bs["pedestal_girder5"]
ref = bs["reference_girder"]
mb, mt = bs["pad_margin_bottom"], bs["pad_margin_top"]
by_no = {b["no"]: b for b in bs["bearings"]}
for b in bs["bearings"]:
shift = (b["girder_line"] - ref) * bs["pitch_x"]
half = ped["front_half"] if b["row"] == "front" else ped["rear_half"]
z0 = steps[b["girder_line"] - 1]["z"]
z1 = z0 + b["h1"]
tag = " [추론: 평행 이동]" if b["inferred"] else ""
add(prism([(x + shift, y) for x, y in half], z0, z1), "seat", f"{PID}-BS{b['no']:02d}",
f"bearing pedestal h1 NO.{b['no']} girder {b['girder_line']} {b['row']}{tag}", FCK["seat"],
"D1 평면도, D1 단면도, D2 ELEVATION TABLE")
cx, cy = b["centre"]
bottom = rotated_rect(cx, cy, b["A"] + mb, b["B"] + mb, b["rotation_deg"], z1)
top = rotated_rect(cx, cy, b["A"] + mt, b["B"] + mt, b["rotation_deg"], z1 + b["h2"])
add(frustum(bottom, top), "pad", f"{PID}-BP{b['no']:02d}",
f"bearing pad h2 NO.{b['no']} {b['type']}{tag}", "-",
"D1 평면도, D1 단면도, D2 교량받침 상세·ELEVATION TABLE")
return len(by_no) * 2
def main():
# Rebuild from scratch: remove previous P1 objects only.
for o in list(doc.Objects):
if o.Attributes.GetUserString("id"):
doc.Objects.Delete(o, True)
lc, ft, cl, cp, pl = (P[k] for k in ("lean_concrete", "footing", "column", "coping", "piles"))
ft_top = ft["z_bottom"] + ft["thickness"]
col_top = ft_top + cl["height"]
if abs(col_top - cp["z_bottom_flat"]) > TOL:
raise RuntimeError(f"column top {col_top} != coping bottom {cp['z_bottom_flat']}")
pile_top = ft["z_bottom"] + pl["head_embedment"]
px, py = pl.get("pitch_x", pl.get("pitch")), pl.get("pitch_y", pl.get("pitch")) # Ungok P2: X 1750 / Y 1550
pile_xy = [(-((pl["count_x"] - 1) * px) / 2 + i * px,
-((pl["count_y"] - 1) * py) / 2 + j * py)
for j in range(pl["count_y"]) for i in range(pl["count_x"])]
piles = [cylinder(x, y, pile_top - pl["length"], pile_top, pl["diameter"] / 2) for x, y in pile_xy]
lean = box(-lc["size_x"] / 2, lc["size_x"] / 2, -lc["size_y"] / 2, lc["size_y"] / 2,
lc["z_bottom"], lc["z_top"])
cut = rg.Brep.CreateBooleanDifference([lean], piles, TOL)
if not cut or len(cut) != 1:
raise RuntimeError("lean concrete boolean failed")
add(cut[0], "lean", f"{PID}-LC", "lean concrete", FCK["lean"], "D1 정면도, D1 단면도")
add(box(-ft["size_x"] / 2, ft["size_x"] / 2, -ft["size_y"] / 2, ft["size_y"] / 2,
ft["z_bottom"], ft_top), "footing", f"{PID}-FT", "footing", FCK["footing"],
"D1 정면도, D1 단면도, D2 기초평면도")
add(cylinder(0, 0, ft_top, col_top, cl["diameter"] / 2), "column", f"{PID}-CL", "column", FCK["column"],
"D1 정면도, D1 단면도, D1 단면 C-C/D-D/E-E, D2 기초평면도")
cop, centers = coping_brep(cp)
add(cop, "coping", f"{PID}-CP", "coping", FCK["coping"], "D1 평면도, D1 정면도, D1 단면도, D1 단면 A-A/B-B")
pile_role = f"{pl['kind']} D{pl['diameter']:.0f} L{pl['length'] / 1000:g}m"
for n, b in enumerate(piles, 1):
add(b, "pile", f"{PID}-PL{n:02d}", pile_role, "-", "D1 정면도, D1 단면도")
seats = add_bearing_seats(P["bearing_seats"], cp["top_steps"]) if "bearing_seats" in P else 0
doc.Views.Redraw()
print(json.dumps({"objects": 4 + len(piles) + seats, "coping_arc_centers": centers}))
main()
@@ -0,0 +1,205 @@
"""G1 stage, form "single shaft" (Ramp-A P1, 2026-10-13): drilled shaft in two diameters (no footing, no lean
concrete), circular column, Y-shaped coping (R soffit arcs flaring from the column, battered ends that are convex
in plan, a step in the top surface), one pedestal (box) and one h2 pad (frustum) per bearing.
Run inside Rhino 8 via runner with globals PARAMS_PATH. Units mm, Z = EL x 1000, origin = pier centre.
params:
pile {segments [{d, z0, z1}, ...] bottom to top}; column {shape "circular", diameter, z0, z1}
coping {form "y_shaft", z_bottom (= column top), z_tip, half_bottom (column face), soffit {centre_x, centre_z,
radius} (left arc; the right one is mirrored), z_top_left, z_top_right, top_half (straight top edge),
plan_end {corner_x, tip_x, half_width} (bottom edge arc through (corner_x, +-half_width) and (tip_x, 0)),
side {half_width_top, half_width_bottom} (2,200 above z_tip, straight taper to 2,000 at z_bottom)}
bearing_seats {pedestal {size_x, size_y}, pad_margin_bottom, pad_margin_top,
bearings [{no, centre [x, y], rotation_deg, seat_z, h1, h2, A, B, type}]}
Coping = (front outline extruded along Y) n (side outline extruded along X) - two end cutters. The end face is
ruled: straight top edge x = +-top_half at the top level, convex plan arc at z_tip. Each extrusion reaches past
the faces the other one defines (no coincident or tangent faces in the booleans, see concrete_tapered.py).
Not modelled (user decision 2026-10-13): the 30 mm decorative grooves (D1 detail "B").
"""
import json
import math
import Rhino.Geometry as rg
import System
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
FCK = P["materials"]["fck"]
LAYERS = {k: (f"{PID}::{name}", Color.FromArgb(*rgb)) for k, (name, rgb) in P["display"]["concrete_layers"].items()}
def layer_index(full_path, color):
idx = doc.Layers.FindByFullPath(full_path, -1)
if idx >= 0:
return idx
parent_name, child = full_path.split("::")
parent = doc.Layers.FindByFullPath(parent_name, -1)
if parent < 0:
parent = doc.Layers.Add(parent_name, Color.Black)
layer = __import__("Rhino").DocObjects.Layer()
layer.Name, layer.Color = child, color
layer.ParentLayerId = doc.Layers[parent].Id
return doc.Layers.Add(layer)
def add(brep, key, oid, role, fck, views):
if brep is None or not brep.IsSolid:
raise RuntimeError(f"{oid}: not a closed solid")
if not brep.IsValid: # AddBrep silently refuses invalid breps
ok, log = brep.IsValidWithLog()
raise RuntimeError(f"{oid}: invalid brep: {log[:400]}")
attr = ObjectAttributes()
attr.LayerIndex = layer_index(*LAYERS[key])
attr.Name = oid
for k, v in (("id", oid), ("role", role), ("fck", fck), ("source_views", views)):
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(brep, attr)
if gid == System.Guid.Empty:
raise RuntimeError(f"{oid}: AddBrep failed")
return gid
def solid(b):
if b is not None and b.SolidOrientation == rg.BrepSolidOrientation.Inward:
b.Flip()
return b
def cylinder(z0, z1, r):
return rg.Cylinder(rg.Circle(rg.Plane(rg.Point3d(0, 0, z0), rg.Vector3d.ZAxis), r), z1 - z0).ToBrep(True, True)
def extrude_closed(curve, vec):
return solid(rg.Surface.CreateExtrusion(curve, vec).ToBrep().CapPlanarHoles(TOL))
def rotated_rect(cx, cy, along, across, rot_deg, z):
"""Rectangle with `along` side on +Y rotated clockwise by rot_deg (plan view)."""
t = math.radians(rot_deg)
ua, uc = (math.sin(t), math.cos(t)), (math.cos(t), -math.sin(t))
pts = [(cx + sa * ua[0] * along / 2 + sc * uc[0] * across / 2, cy + sa * ua[1] * along / 2 + sc * uc[1] * across / 2)
for sa, sc in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
return rg.PolylineCurve([rg.Point3d(x, y, z) for x, y in pts + pts[:1]])
def frustum(bottom, top):
lofts = rg.Brep.CreateFromLoft([bottom, top], rg.Point3d.Unset, rg.Point3d.Unset, rg.LoftType.Straight, False)
return solid(lofts[0].CapPlanarHoles(TOL))
def boolean(kind, a, b):
f = rg.Brep.CreateBooleanIntersection if kind == "and" else rg.Brep.CreateBooleanDifference
out = f(a, b, TOL)
if not out or len(out) != 1:
raise RuntimeError(f"coping boolean {kind} failed ({len(out) if out else 0} pieces)")
return solid(out[0])
def front_extrusion(c):
"""Front outline (XZ): soffit arcs from the column face, continued past the end (cut later), vertical outer
ends at +-ext, top levels with the step at x = 0. Extruded over y = +-(half_width_top + 100)."""
s, zb = c["soffit"], c["z_bottom"]
cx, cz, r = s["centre_x"], s["centre_z"], s["radius"]
a0 = math.atan2(zb - cz, -c["half_bottom"] - cx) # left arc start (column face)
ext = c["plan_end"]["tip_x"] + 400 # past the tip (abs)
a1 = math.acos((-ext - cx) / r) if abs((-ext - cx) / r) <= 1 else None
if a1 is None:
raise RuntimeError("soffit arc does not reach the extension")
am = (a0 + a1) / 2 # left arc through start, mid, end
pl = [(cx + r * math.cos(a), cz + r * math.sin(a)) for a in (a0, am, a1)] # (x, z)
w = c["side"]["half_width_top"] + 100
to_xyz = lambda x, z: rg.Point3d(x, -w, z)
arc_l = rg.ArcCurve(rg.Arc(*[to_xyz(x, z) for x, z in pl]))
arc_r = rg.ArcCurve(rg.Arc(*[to_xyz(-x, z) for x, z in reversed(pl)]))
zl, zr = c["z_top_left"], c["z_top_right"]
xe, ze = pl[2] # left extension end (xe < 0)
poly_top = rg.PolylineCurve([to_xyz(xe, ze), to_xyz(xe, zl), to_xyz(0, zl), to_xyz(0, zr), to_xyz(-xe, zr),
to_xyz(-xe, ze)])
bottom = rg.LineCurve(to_xyz(c["half_bottom"], zb), to_xyz(-c["half_bottom"], zb))
j = rg.Curve.JoinCurves([arc_l, poly_top, arc_r, bottom], 0.01)
if len(j) != 1 or not j[0].IsClosed:
raise RuntimeError(f"front outline not closed ({len(j)} pieces)")
return extrude_closed(j[0], rg.Vector3d(0, 2 * w, 0))
def side_extrusion(c):
"""Side outline (YZ): straight taper from the column width at z_bottom to the full width at z_tip, continued
100 below z_bottom (no face on the coping bottom plane), vertical above, up past the top. Extruded over X."""
sd, zb, zt = c["side"], c["z_bottom"], c["z_tip"]
hb, ht = sd["half_width_bottom"], sd["half_width_top"]
k = (ht - hb) / (zt - zb)
y_low, z_low = hb - k * 100, zb - 100
ztop = max(c["z_top_left"], c["z_top_right"]) + 200
x0 = c["plan_end"]["tip_x"] + 600
pts = [(-y_low, z_low), (y_low, z_low), (ht, zt), (ht, ztop), (-ht, ztop), (-ht, zt)]
crv = rg.PolylineCurve([rg.Point3d(-x0, y, z) for y, z in pts + pts[:1]])
return extrude_closed(crv, rg.Vector3d(2 * x0, 0, 0))
def end_cutter(c, sx):
"""Waste beyond the ruled end face of one end (sx = -1 left, +1 right). Two straight-lofted sections, below the
tip level and above the top: on each, x(y) = arc(y) + t * (top_edge - arc(y)), t = (z - z_tip) / (z_top - z_tip),
so the loft lines are the rulings of the end face. Plan arc sampled every 25 mm (chord error < 0.1 mm)."""
pe, zt = c["plan_end"], c["z_tip"]
ztop = c["z_top_left"] if sx < 0 else c["z_top_right"]
hw, corner, tip, top = pe["half_width"], pe["corner_x"], pe["tip_x"], c["top_half"]
sag = tip - corner
R = (hw * hw + sag * sag) / (2 * sag)
ys = [-hw - 200 + 25 * i for i in range(int((2 * hw + 400) / 25) + 1)]
arc = lambda y: tip - (R - math.sqrt(R * R - y * y)) # abs x of the bottom edge
secs = []
for z in (zt - 700, ztop + 150):
t = (z - zt) / (ztop - zt)
pts = [(arc(y) + t * (top - arc(y)), y) for y in ys]
pts += [(tip + 1500, ys[-1]), (tip + 1500, ys[0])]
secs.append(rg.PolylineCurve([rg.Point3d(sx * x, y, z) for x, y in pts + pts[:1]]))
lofts = rg.Brep.CreateFromLoft(secs, rg.Point3d.Unset, rg.Point3d.Unset, rg.LoftType.Straight, False)
b = solid(lofts[0].CapPlanarHoles(TOL))
if b is None or not b.IsSolid:
raise RuntimeError("end cutter is not a closed solid")
return b
def coping_brep(c):
body = boolean("and", [front_extrusion(c)], [side_extrusion(c)])
return boolean("diff", [body], [end_cutter(c, -1), end_cutter(c, 1)])
def main():
for o in list(doc.Objects):
if o.Attributes.GetUserString("id"):
doc.Objects.Delete(o, True)
pl, cl, cp, bs = (P[k] for k in ("pile", "column", "coping", "bearing_seats"))
segs = pl["segments"]
for a, b in zip(segs, segs[1:]):
if abs(a["z1"] - b["z0"]) > TOL:
raise RuntimeError("pile segments not continuous")
if abs(segs[-1]["z1"] - cl["z0"]) > TOL or abs(cl["z1"] - cp["z_bottom"]) > TOL:
raise RuntimeError("pile top / column / coping bottom levels do not meet")
for i, s in enumerate(segs, 1):
add(cylinder(s["z0"], s["z1"], s["d"] / 2), "pile", f"{PID}-PL{i:02d}",
f"drilled shaft segment {i} D{s['d']:.0f}", FCK["pile"], "일반도 정면도·측면도")
add(cylinder(cl["z0"], cl["z1"], cl["diameter"] / 2), "column", f"{PID}-CL", "column", FCK["column"],
"일반도 정면도·측면도·기둥 단면도")
add(coping_brep(cp), "coping", f"{PID}-CP", "coping (Y-shaped, single shaft)", FCK["coping"],
"일반도 정면도·측면도·평면도")
ped, mb, mt = bs["pedestal"], bs["pad_margin_bottom"], bs["pad_margin_top"]
for b in bs["bearings"]:
cx, cy = b["centre"]
z0, z1 = b["seat_z"], b["seat_z"] + b["h1"]
base = rotated_rect(cx, cy, ped["size_y"], ped["size_x"], b["rotation_deg"], z0)
add(extrude_closed(base, rg.Vector3d(0, 0, b["h1"])), "seat", f"{PID}-BS{b['no']:02d}",
f"bearing pedestal h1 NO.{b['no']}", FCK["seat"], "일반도 평면도·교량받침 상세도·ELEVATION TABLE")
bottom = rotated_rect(cx, cy, b["A"] + mb, b["B"] + mb, b["rotation_deg"], z1)
top = rotated_rect(cx, cy, b["A"] + mt, b["B"] + mt, b["rotation_deg"], z1 + b["h2"])
add(frustum(bottom, top), "pad", f"{PID}-BP{b['no']:02d}", f"bearing pad h2 NO.{b['no']} {b['type']}", "-",
"일반도 평면도·교량받침 상세도·ELEVATION TABLE")
doc.Views.Redraw()
print(json.dumps({"objects": len(segs) + 2 + 2 * len(bs["bearings"])}))
main()
@@ -0,0 +1,267 @@
"""G1 stage, form "tapered T" (Ulsan P1, 2026-10-13): lean, spread footing (no piles), circular column, coping
with a straight sloped soffit, battered ends, a step in the top surface and rounded plan corners,
one pedestal (box) and one h2 pad (frustum) per bearing.
Run inside Rhino 8 via runner with globals PARAMS_PATH. Units mm, Z = EL x 1000, origin = pier centre.
params:
lean_concrete {size_x, size_y, z_bottom, z_top}; footing {size_x, size_y, z_bottom, thickness}
column {shape "circular", diameter, height}
coping {form "tapered", profile_xz [[x, z], ...] (front-view outline, closed implicitly), width_y,
plan_length (at the tips), top_length, z_tip, z_top_left, z_top_right, plan_corner_radius}
bearing_seats {pedestal {size_x, size_y}, pad_margin_bottom, pad_margin_top,
bearings [{no, centre [x, y], rotation_deg, seat_z, h1, h2, A, B, type}]}
Coping = profile extrusion minus the four plan-corner wastes outside R (faceted 16-segment arcs, +0.5 mm).
Optional (Okseong P1, 2026-10-14; absent -> the code paths above are unchanged):
piles {count_x, count_y, pitch_x, pitch_y, diameter, length, head_embedment, kind}: pile grid under the footing,
the lean concrete is cut by the piles (as concrete.py)
coping.side_taper {half_width_top, z_taper_top, half_width_bottom}: body intersected with the side outline
(full width above z_taper_top, straight taper to the bottom width at the coping bottom)
coping.skew_deg: plan skew; the coping is sheared x' = x - tan(skew) * y (end faces / top-step lines at 90 + skew)
footing.rot_deg (Ulsan P2, 2026-10-15; absent = 0): lean, footing and piles are drawn in the foundation's own axes and then
rotated counter-clockwise (plan view, X right / Y up) about the pier centre; column, coping and seats stay in the pier axes
bearing_seats.pedestals [{no, corners [[x, y] x4], z_bottom, top {y0, z0, y1, z1}}]: explicit pedestal solids (plan
quadrilateral, top plane linear in y); bearings[].pedestal = its index; pads then sit on that plane
"""
import json
import math
import Rhino.Geometry as rg
import System
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
FCK = P["materials"]["fck"]
LAYERS = {k: (f"{PID}::{name}", Color.FromArgb(*rgb)) for k, (name, rgb) in P["display"]["concrete_layers"].items()}
def layer_index(full_path, color):
idx = doc.Layers.FindByFullPath(full_path, -1)
if idx >= 0:
return idx
parent_name, child = full_path.split("::")
parent = doc.Layers.FindByFullPath(parent_name, -1)
if parent < 0:
parent = doc.Layers.Add(parent_name, Color.Black)
layer = __import__("Rhino").DocObjects.Layer()
layer.Name, layer.Color = child, color
layer.ParentLayerId = doc.Layers[parent].Id
return doc.Layers.Add(layer)
def add(brep, key, oid, role, fck, views):
if brep is None or not brep.IsSolid:
raise RuntimeError(f"{oid}: not a closed solid")
attr = ObjectAttributes()
attr.LayerIndex = layer_index(*LAYERS[key])
attr.Name = oid
for k, v in (("id", oid), ("role", role), ("fck", fck), ("source_views", views)):
attr.SetUserString(k, v)
if not brep.IsValid: # AddBrep silently refuses invalid breps (2026-10-13: coping lost)
ok, log = brep.IsValidWithLog()
raise RuntimeError(f"{oid}: invalid brep: {log[:400]}")
gid = doc.Objects.AddBrep(brep, attr)
if gid == System.Guid.Empty:
raise RuntimeError(f"{oid}: AddBrep failed")
return gid
def solid(b):
if b is not None and b.SolidOrientation == rg.BrepSolidOrientation.Inward:
b.Flip()
return b
def box(x0, x1, y0, y1, z0, z1):
return rg.Box(rg.BoundingBox(x0, y0, z0, x1, y1, z1)).ToBrep()
def cylinder(x, y, z0, z1, r):
return rg.Cylinder(rg.Circle(rg.Plane(rg.Point3d(x, y, z0), rg.Vector3d.ZAxis), r), z1 - z0).ToBrep(True, True)
def extrude_closed(curve, vec):
return solid(rg.Surface.CreateExtrusion(curve, vec).ToBrep().CapPlanarHoles(TOL))
def rotated_rect(cx, cy, along, across, rot_deg, z):
"""Rectangle with `along` side on +Y rotated clockwise by rot_deg (plan view)."""
t = math.radians(rot_deg)
ua, uc = (math.sin(t), math.cos(t)), (math.cos(t), -math.sin(t))
pts = [(cx + sa * ua[0] * along / 2 + sc * uc[0] * across / 2, cy + sa * ua[1] * along / 2 + sc * uc[1] * across / 2)
for sa, sc in ((-1, -1), (1, -1), (1, 1), (-1, 1))]
return rg.PolylineCurve([rg.Point3d(x, y, z) for x, y in pts + pts[:1]])
def frustum(bottom, top):
lofts = rg.Brep.CreateFromLoft([bottom, top], rg.Point3d.Unset, rg.Point3d.Unset, rg.LoftType.Straight, False)
return solid(lofts[0].CapPlanarHoles(TOL))
def rrect(length, width, r, z):
"""Closed rounded rectangle centred on the axis at height z (corners filleted with radius r)."""
rect = rg.Rectangle3d(rg.Plane(rg.Point3d(0, 0, z), rg.Vector3d.ZAxis), rg.Interval(-length / 2, length / 2),
rg.Interval(-width / 2, width / 2)).ToNurbsCurve()
return rg.Curve.CreateFilletCornersCurve(rect, r, TOL, math.radians(1)) if r > 0 else rect
def corner_waste(sx, sy, c, z_top):
"""Region outside the R fillet of one plan corner (x sign sx, y sign sy), as a straight loft through three
equal sections: vertical below the tip level (fillet centre at tip - R), then following the end batter up to
z_top + 100 (centre moves with the end face). The arc is a 16-segment polyline offset 0.5 mm outwards
(chord error < 0.6 mm), so no cutter face is tangent to or coincident with a coping face / knife edge."""
w, R = c["width_y"], c["plan_corner_radius"]
tip, top, z_tip = c["plan_length"] / 2, c["top_length"] / 2, c["z_tip"]
batter = (tip - top) / (z_top - z_tip) # x shift per mm of height above the tip
zs = [z_tip - 300, z_tip, z_top + 100]
cxs = [tip - R, tip - R, tip - R - batter * (z_top + 100 - z_tip)]
cy, r = w / 2 - R, R + 0.5
secs = []
for z, cx in zip(zs, cxs):
pts = [(cx + r * math.cos(math.radians(a)), cy + r * math.sin(math.radians(a))) for a in
[90 * i / 16 for i in range(17)]]
pts += [(cx, cy + R + 100), (cx + R + 100, cy + R + 100), (cx + R + 100, cy)]
secs.append(rg.PolylineCurve([rg.Point3d(sx * x, sy * y, z) for x, y in pts + pts[:1]]))
lofts = rg.Brep.CreateFromLoft(secs, rg.Point3d.Unset, rg.Point3d.Unset, rg.LoftType.Straight, False)
return solid(lofts[0].CapPlanarHoles(TOL))
def side_cut(c, body):
"""Intersect with the side outline extruded along X (Ramp-A style taper); reaches past the X ends."""
st = c["side_taper"]
zb = min(z for _, z in c["profile_xz"])
zt, ht, hb = st["z_taper_top"], st["half_width_top"], st["half_width_bottom"]
k = (ht - hb) / (zt - zb)
y_low, z_low = hb - k * 100, zb - 100
ztop = max(c["z_top_left"], c["z_top_right"], max(z for _, z in c["profile_xz"])) + 200
x0 = c["plan_length"] / 2 + 3000
pts = [(-y_low, z_low), (y_low, z_low), (ht, zt), (ht, ztop), (-ht, ztop), (-ht, zt)]
side = extrude_closed(rg.PolylineCurve([rg.Point3d(-x0, y, z) for y, z in pts + pts[:1]]), rg.Vector3d(2 * x0, 0, 0))
out = rg.Brep.CreateBooleanIntersection(body, side, TOL)
if not out or len(out) != 1:
raise RuntimeError(f"coping side taper failed ({len(out) if out else 0})")
return solid(out[0])
def skew_xf(deg):
xf = rg.Transform.Identity
xf.M01 = -math.tan(math.radians(deg)) # x' = x - tan(skew) * y
return xf
def coping_brep(c):
"""Front profile (sloped soffit, battered ends, top step) extruded over the width as one solid, minus the four
plan-corner wastes outside the R fillets (see corner_waste). Splitting the coping into parts and uniting them,
or filleting the corner edges, failed on coplanar faces / the knife-edge tips (2026-10-13)."""
w, R = c["width_y"], c["plan_corner_radius"]
if "side_taper" in c or "skew_deg" in c: # Okseong form (2026-10-14): extrusion wider than the taper
w2 = w + 400
prof = rg.PolylineCurve([rg.Point3d(x, -w2 / 2, z) for x, z in c["profile_xz"] + c["profile_xz"][:1]])
a = extrude_closed(prof, rg.Vector3d(0, w2, 0))
if "side_taper" in c:
a = side_cut(c, a)
if c.get("skew_deg"):
a.Transform(skew_xf(c["skew_deg"]))
return a
prof = rg.PolylineCurve([rg.Point3d(x, -w / 2, z) for x, z in c["profile_xz"] + c["profile_xz"][:1]])
a = extrude_closed(prof, rg.Vector3d(0, w, 0))
if R <= 0:
return a
cutters = [corner_waste(sx, sy, c, c["z_top_left"] if sx < 0 else c["z_top_right"]) for sx in (1, -1) for sy in (1, -1)]
for q in cutters:
if q is None or not q.IsSolid:
raise RuntimeError("corner cutter is not a closed solid")
out = rg.Brep.CreateBooleanDifference([a], cutters, TOL)
if not out or len(out) != 1:
raise RuntimeError(f"coping corner cut failed ({len(out) if out else 0})")
return solid(out[0])
def plane_z(top, y):
return top["z0"] + (y - top["y0"]) * (top["z1"] - top["z0"]) / (top["y1"] - top["y0"])
def add_shared_seats(bs):
"""Pedestal = plan quadrilateral extruded from z_bottom to its sloped top plane; pad = frustum whose bottom lies
on that plane and whose top is flat at (pedestal top at the bearing centre) + h2 (Okseong P1, O2 2026-10-14)."""
mb, mt = bs["pad_margin_bottom"], bs["pad_margin_top"]
for pd in bs["pedestals"]:
cs = pd["corners"]
bot = [rg.Point3d(x, y, pd["z_bottom"]) for x, y in cs]
top = [rg.Point3d(x, y, plane_z(pd["top"], y)) for x, y in cs]
b = rg.Brep.CreateFromBox(bot + top)
if b is None or not b.IsSolid:
raise RuntimeError(f"pedestal {pd['no']} failed")
add(solid(b), "seat", f"{PID}-BS{pd['no']:02d}", f"bearing pedestal (shared) NO.{pd['no']}", FCK["seat"],
"일반도 평면도·교량받침 상세·ELEVATION TABLE")
for b in bs["bearings"]:
top = bs["pedestals"][b["pedestal"]]["top"]
cx, cy = b["centre"]
bottom = rotated_rect(cx, cy, b["A"] + mb, b["B"] + mb, b["rotation_deg"], 0)
bottom = rg.PolylineCurve([rg.Point3d(p.X, p.Y, plane_z(top, p.Y)) for p in bottom.ToPolyline()])
zt = plane_z(top, cy) + b["h2"]
add(frustum(bottom, rotated_rect(cx, cy, b["A"] + mt, b["B"] + mt, b["rotation_deg"], zt)), "pad",
f"{PID}-BP{b['no']:02d}", f"bearing pad h2 NO.{b['no']} {b['type']}", "-", "일반도 교량받침 상세·ELEVATION TABLE")
def main():
for o in list(doc.Objects):
if o.Attributes.GetUserString("id"):
doc.Objects.Delete(o, True)
lc, ft, cl, cp, bs = (P[k] for k in ("lean_concrete", "footing", "column", "coping", "bearing_seats"))
ft_top = ft["z_bottom"] + ft["thickness"]
col_top = ft_top + cl["height"]
zmin = min(z for _, z in cp["profile_xz"])
if abs(col_top - zmin) > TOL:
raise RuntimeError(f"column top {col_top} != coping bottom {zmin}")
rot = rg.Transform.Rotation(math.radians(ft.get("rot_deg", 0.0)), rg.Vector3d.ZAxis, rg.Point3d.Origin)
lean = box(-lc["size_x"] / 2, lc["size_x"] / 2, -lc["size_y"] / 2, lc["size_y"] / 2, lc["z_bottom"], lc["z_top"])
piles = []
if "piles" in P: # Okseong P1 (2026-10-14): pile grid, lean cut by the piles
pl = P["piles"]
top = ft["z_bottom"] + pl["head_embedment"]
piles = [cylinder(-(pl["count_x"] - 1) * pl["pitch_x"] / 2 + i * pl["pitch_x"],
-(pl["count_y"] - 1) * pl["pitch_y"] / 2 + j * pl["pitch_y"], top - pl["length"], top,
pl["diameter"] / 2) for j in range(pl["count_y"]) for i in range(pl["count_x"])]
cut = rg.Brep.CreateBooleanDifference([lean], piles, TOL)
if not cut or len(cut) != 1:
raise RuntimeError("lean concrete boolean failed")
lean = solid(cut[0])
footing = box(-ft["size_x"] / 2, ft["size_x"] / 2, -ft["size_y"] / 2, ft["size_y"] / 2, ft["z_bottom"], ft_top)
if ft.get("rot_deg"):
for b in [lean, footing] + piles:
b.Transform(rot)
add(lean, "lean", f"{PID}-LC", "lean concrete", FCK["lean"], "일반도 정면도·단면도")
add(footing, "footing", f"{PID}-FT", "footing on piles" if piles else "spread footing (no piles)", FCK["footing"],
"일반도 정면도·단면도·기초 평면도")
add(cylinder(0, 0, ft_top, col_top, cl["diameter"] / 2), "column", f"{PID}-CL", "column", FCK["column"],
"일반도 정면도·단면도·단면 A-A")
add(coping_brep(cp), "coping", f"{PID}-CP", "coping (tapered T)", FCK["coping"], "일반도 평면도·정면도·단면도")
for n, b in enumerate(piles, 1):
pl = P["piles"]
add(b, "pile", f"{PID}-PL{n:02d}", f"{pl['kind']} D{pl['diameter']:.0f} L{pl['length'] / 1000:g}m", "-",
"일반도 정면도·단면도·기초 평면도")
if "pedestals" in bs: # Okseong P1: shared pedestals with a sloped top
add_shared_seats(bs)
doc.Views.Redraw()
print(json.dumps({"objects": 4 + len(piles) + len(bs["pedestals"]) + len(bs["bearings"])}))
return
ped, mb, mt = bs["pedestal"], bs["pad_margin_bottom"], bs["pad_margin_top"]
for b in bs["bearings"]:
cx, cy = b["centre"]
z0, z1 = b["seat_z"], b["seat_z"] + b["h1"]
base = rotated_rect(cx, cy, ped["size_y"], ped["size_x"], b["rotation_deg"], z0)
add(extrude_closed(base, rg.Vector3d(0, 0, b["h1"])), "seat", f"{PID}-BS{b['no']:02d}",
f"bearing pedestal h1 NO.{b['no']}", FCK["seat"], "일반도 교량받침 상세·ELEVATION TABLE")
bottom = rotated_rect(cx, cy, b["A"] + mb, b["B"] + mb, b["rotation_deg"], z1)
top = rotated_rect(cx, cy, b["A"] + mt, b["B"] + mt, b["rotation_deg"], z1 + b["h2"])
add(frustum(bottom, top), "pad", f"{PID}-BP{b['no']:02d}", f"bearing pad h2 NO.{b['no']} {b['type']}", "-",
"일반도 교량받침 상세·ELEVATION TABLE")
doc.Views.Redraw()
print(json.dumps({"objects": 4 + 2 * len(bs["bearings"])}))
main()
@@ -0,0 +1,241 @@
"""G4 stage: coping rebar C1-C16, S1, S2 (centreline + pipe) from params `coping_rebar`.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after concrete.py, then rebar_colours.py.
Ported from Code/교각모델링/build_coping_rebar_rhino.py (Ungok P1, model v0.1.09). Rules: 설계서 v0.1.07 §7. Units mm, X transverse, Y longitudinal, Z = EL x 1000.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items() if m[0] in "CS"}
DIA = {d: float(d[1:]) for d, _ in MARK.values()} # 'H29' -> 29.0
CL_RGB = tuple(P["display"]["centreline_colour"])
CP_RGB = tuple(P["display"]["coping_layer_colour"])
ARC_C = tuple(P["coping_rebar"]["soffit_arc_centre"]) # soffit arc centre (right); left is mirrored
# coping end faces (concrete) from the coping outline: bottom tip (+-half, zb+rise) -> top (+-(half-inset), end step z)
_C = P["coping"]
HALF, INSET = _C["length_bottom_tip_to_tip"] / 2, _C["end_batter_top_inset"]
Z_TIP = _C["z_bottom_flat"] + _C["tip_bottom_rise"]
ZL, ZR = _C["top_steps"][0]["z"], _C["top_steps"][-1]["z"]
def xl(z):
return -(HALF - INSET) - INSET * (ZL - z) / (ZL - Z_TIP)
def xr(z):
return (HALF - INSET) + INSET * (ZR - z) / (ZR - Z_TIP)
def unit(dx, dz):
n = math.hypot(dx, dz)
return dx / n, dz / n
U_LEFT_DOWN, U_RIGHT_DOWN = unit(-INSET, -(ZL - Z_TIP)), unit(INSET, -(ZR - Z_TIP))
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_r(dia):
d = DIA[dia]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2 # box rule (REBAR_BENDS_20260914)
def fillet(curve, dia):
f = rg.Curve.CreateFilletCornersCurve(curve, min_r(dia), TOL, math.radians(1))
return f or curve
def add_bar(curve, mark, occ, design_len, src, extra=None):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-CP", anchor_hosts="",
design_length=f"{design_len:.3f}", coupler_length="0.000", src=src)
common.update(extra or {})
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::코핑::{mark}", CP_RGB), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
(doc.Objects.AddBrep if isinstance(geo, rg.Brep) else doc.Objects.AddCurve)(geo, attr)
def soffit_offset_z(x, cp):
st = cp["stirrups"]
if abs(x) <= cp["bottom"]["half_flat"]:
return cp["bottom"]["z_flat"]
cx = ARC_C[0] if x > 0 else -ARC_C[0]
R = cp["bottom"]["R_offset"]
return ARC_C[1] + math.sqrt(R * R - (x - cx) ** 2)
def stirrup_top(x, cp):
st = cp["stirrups"]
bands = st["top_right"] if x >= 0 else st["top_left"]
return [z for x0, z in bands if abs(x) >= x0][-1]
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith(tuple(f"{PID}-{k}-{m}" for k in ("R", "RC") for m in "CS")):
doc.Objects.Delete(o, True)
cp = P["coping_rebar"]
ys = cp["y_positions"]
n = 0
# top bars (VIII)
for tier, t in cp["top"]["tiers"].items():
z, a, (bl, br) = t["z"], t["a"], t["legs"]
d = (xr(z) - xl(z) - a) / 2
x0, x1 = xl(z) + d, xr(z) - d
for i, y in enumerate(ys):
mark = t["marks"][i % 2]
pts = [rg.Point3d(x0 + U_LEFT_DOWN[0] * bl, y, z + U_LEFT_DOWN[1] * bl), rg.Point3d(x0, y, z),
rg.Point3d(x1, y, z), rg.Point3d(x1 + U_RIGHT_DOWN[0] * br, y, z + U_RIGHT_DOWN[1] * br)]
add_bar(fillet(rg.PolylineCurve(pts), MARK[mark][0]), mark, f"t{tier}y{y:+.0f}", a + bl + br,
f"tier {tier} EL {z / 1000:.3f}; end inset {d:.1f}; legs parallel to end faces",
{"a_model": f"{x1 - x0:.3f}"})
n += 1
# bottom bars (VII)
b = cp["bottom"]
R, zf = b["R_offset"], b["z_flat"]
hf = ARC_C[0] - math.sqrt(R * R - (zf - ARC_C[1]) ** 2) # exact flat/arc junction (params 1,073.9 is rounded)
dth = b["arc_len"] / R
for i, y in enumerate(ys):
mark = b["marks"][i % 2]
pieces = [rg.LineCurve(rg.Point3d(-hf, y, zf), rg.Point3d(hf, y, zf))]
for side in (1, -1):
cx = side * ARC_C[0]
th0 = math.atan2(zf - ARC_C[1], side * hf - cx)
th1 = th0 - side * dth
thm = (th0 + th1) / 2
p = lambda th: rg.Point3d(cx + R * math.cos(th), y, ARC_C[1] + R * math.sin(th))
arc = rg.ArcCurve(rg.Arc(p(th0), p(thm), p(th1)))
up = (-U_RIGHT_DOWN[0], -U_RIGHT_DOWN[1]) if side > 0 else (-U_LEFT_DOWN[0], -U_LEFT_DOWN[1])
e = p(th1)
leg = rg.LineCurve(e, rg.Point3d(e.X + up[0] * b["leg"], y, e.Z + up[1] * b["leg"]))
pieces += [arc, leg]
joined = rg.Curve.JoinCurves(pieces, TOL)
if len(joined) != 1:
raise RuntimeError(f"{mark} y{y}: bottom bar did not join ({len(joined)} pieces)")
c = joined[0]
add_bar(c, mark, f"y{y:+.0f}", 2 * hf + 2 * b["arc_len"] + 2 * b["leg"],
"flat EL 40.511 +-1,073.9; arcs R 12,100 concentric with soffit, length 5,273; legs 650 // end faces")
n += 1
# stirrups (IV as closed rectangles, widths as drawn in section A-A)
st = cp["stirrups"]
for mark, xs in (("C10", st["x_c10"]), ("C11", st["x_c11"]), ("C9", st["x_c9"])):
for x in xs:
zb, zt = soffit_offset_z(x, cp), stirrup_top(x, cp)
for k, (y0, y1) in enumerate(st["loops_y"]):
ym = (y0 + y1) / 2
pts = [rg.Point3d(x, ym, zb), rg.Point3d(x, y1, zb), rg.Point3d(x, y1, zt), rg.Point3d(x, y0, zt),
rg.Point3d(x, y0, zb), rg.Point3d(x, ym, zb)]
c = fillet(rg.PolylineCurve(pts), MARK[mark][0])
w, h = y1 - y0, zt - zb
b_s = h + st["b_offset"]
add_bar(c, mark, f"x{x:+.0f}k{k + 1}", 2 * w + 2 * b_s + 2 * st["c_sched"],
f"X {x}; loop {k + 1} Y {y0}..{y1}; laps 2x750 not drawn (length only)",
{"b_sched": f"{b_s:.1f}", "w_model": f"{w:.1f}"})
n += 1
# horizontal closed loops C12 (lower 6 levels) / C13 (upper 3 levels), one inside each stirrup
lp = cp.get("loops")
if lp:
R, inset = cp["bottom"]["R_offset"], lp["end_inset_face"]
for z, mark in zip(lp["levels"], lp["marks_by_level"]):
disc = R * R - (z - ARC_C[1]) ** 2
xs = ARC_C[0] - math.sqrt(disc) if disc > 0 else 1e9 # soffit-offset limit (symmetric)
x0, x1 = max(-xs, xl(z) + inset), min(xs, xr(z) - inset)
for k, (y0, y1) in enumerate(lp["loops_y"]):
xm = (x0 + x1) / 2
pts = [rg.Point3d(xm, y0, z), rg.Point3d(x1, y0, z), rg.Point3d(x1, y1, z), rg.Point3d(x0, y1, z),
rg.Point3d(x0, y0, z), rg.Point3d(xm, y0, z)]
c = fillet(rg.PolylineCurve(pts), MARK[mark][0])
a_m, w = x1 - x0, y1 - y0
add_bar(c, mark, f"z{z}k{k + 1}", 2 * (a_m + lp["a_offset"]) + 2 * w + 4 * lp["c_sched"],
f"EL {z / 1000:.3f}; X {x0:.1f}..{x1:.1f}; loop {k + 1} Y {y0}..{y1}; laps 4x580 length only",
{"a_sched": f"{a_m + lp['a_offset']:.1f}", "w_model": f"{w:.1f}"})
n += 1
# C14 transverse (Y) bars with hooks down; C15 / C16 longitudinal (X) bars in the raised step zones
if "c14" in cp:
c14 = cp["c14"]
h, hk = c14["a"] / 2, c14["hook"]
up = [tuple(v) for v in c14.get("hooks_up", [])]
for x, z in c14["points"]:
s = 1 if (x, z) in up else -1 # hooks up only where a down hook would leave the soffit
pts = [rg.Point3d(x, -h, z + s * hk), rg.Point3d(x, -h, z), rg.Point3d(x, h, z), rg.Point3d(x, h, z + s * hk)]
add_bar(fillet(rg.PolylineCurve(pts), MARK["C14"][0]), "C14", f"x{x:+.0f}z{z:.0f}", c14["a"] + 2 * hk,
"D1 정면도 점; hooks " + ("up (HITL fix)" if s > 0 else "down [추론]"))
n += 1
c15, c16 = cp["c15"], cp["c16"]
for y in c15["y_positions"]:
z, (h0, h1) = c15["z"], c15["hooks"]
pts = [rg.Point3d(c15["x0"], y, z - h0), rg.Point3d(c15["x0"], y, z), rg.Point3d(c15["x1"], y, z),
rg.Point3d(c15["x1"], y, z - h1)]
add_bar(fillet(rg.PolylineCurve(pts), MARK["C15"][0]), "C15", f"y{y:+.0f}", c15["x1"] - c15["x0"] + h0 + h1,
"D1 정면도 43,598.4 / 3,816~5,900")
z = c16["z"]
pts = [rg.Point3d(c16["x0"], y, z - c16["hook_left"]), rg.Point3d(c16["x0"], y, z),
rg.Point3d(c16["x1"], y, z)]
add_bar(fillet(rg.PolylineCurve(pts), MARK["C16"][0]), "C16", f"y{y:+.0f}", c16["x1"] - c16["x0"] + c16["hook_left"],
"D1 정면도 43,454.7 / -1,195~4,066; hook at left end [추론]")
n += 2
# S1 / S2 bearing-seat reinforcement under each pedestal (rotated with the pedestal)
if "s1s2" in cp and "bearing_seats" in P:
bs, ss = P["bearing_seats"], cp["s1s2"]
corners = bs["pedestal_girder5"]["front_half"] + bs["pedestal_girder5"]["rear_half"]
c5 = (sum(p[0] for p in corners) / len(corners), sum(p[1] for p in corners) / len(corners))
t = math.radians(bs["pedestal_girder5"]["rotation_deg"])
ua, uc = (math.sin(t), math.cos(t)), (math.cos(t), -math.sin(t)) # along (2,500) / across (1,200)
steps = P["coping"]["top_steps"]
for g in range(1, len(steps) + 1):
cx_, cy_ = c5[0] + (g - bs["reference_girder"]) * bs["pitch_x"], c5[1]
top = steps[g - 1]["z"]
pt = lambda s_al, s_ac, z: rg.Point3d(cx_ + ua[0] * s_al + uc[0] * s_ac, cy_ + ua[1] * s_al + uc[1] * s_ac, z)
s1 = ss["s1"]
for off in s1["offsets_across"]:
z, h = top - s1["z_below_top"], s1["a"] / 2
pts = [pt(-h, off, z - s1["leg"]), pt(-h, off, z), pt(h, off, z), pt(h, off, z - s1["leg"])]
add_bar(fillet(rg.PolylineCurve(pts), MARK["S1"][0]), "S1", f"g{g}c{off:+.0f}", s1["a"] + 2 * s1["leg"],
f"girder {g} pedestal; upper layer [추론]")
n += 1
s2 = ss["s2"]
for off in s2["offsets_along"]:
z, h = top - s2["z_below_top"], s2["a"] / 2
pts = [pt(off, -h, z - s2["leg"]), pt(off, -h, z), pt(off, h, z), pt(off, h, z - s2["leg"])]
add_bar(fillet(rg.PolylineCurve(pts), MARK["S2"][0]), "S2", f"g{g}a{off:+.0f}", s2["a"] + 2 * s2["leg"],
f"girder {g} pedestal; lower layer")
n += 1
doc.Views.Redraw()
print(json.dumps({"coping_bars": n}))
main()
@@ -0,0 +1,23 @@
"""Final stage: apply the display state recorded in params `display` (hidden objects).
Run inside Rhino 8 via runner (globals PARAMS_PATH) after all build stages.
"""
import json
from Rhino.DocObjects import ObjectEnumeratorSettings
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
hide = set(P["display"].get("hidden_ids", []))
s = ObjectEnumeratorSettings()
s.HiddenObjects = True
done = []
for o in doc.Objects.GetObjectList(s):
oid = o.Attributes.GetUserString("id") or ""
if oid in hide and not o.IsHidden:
doc.Objects.Hide(o, True)
done.append(oid)
missing = sorted(hide - {o.Attributes.GetUserString("id") for o in doc.Objects.GetObjectList(s)})
print(json.dumps({"hidden": done, "missing": missing}))
if missing:
raise RuntimeError(f"display: hidden_ids not found {missing}")
@@ -0,0 +1,52 @@
"""Fingerprint a .3dm (run inside Rhino 8 via runner). Globals: MODEL_PATH, OUT_PATH, optional FAST.
Opens MODEL_PATH headless (the active doc is untouched) and writes, per object id (user string `id`,
falling back to the object name): layer path, geometry type, visibility, colour, bbox (0.01 mm),
brep face count / solidity / volume (mm3, 1 decimal), curve length (0.001 mm) and all user strings.
Hidden and locked objects are included.
"""
import json
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectEnumeratorSettings
FAST = globals().get("FAST", False)
d = Rhino.RhinoDoc.OpenHeadless(MODEL_PATH)
if d is None:
raise RuntimeError(f"cannot open {MODEL_PATH}")
try:
s = ObjectEnumeratorSettings()
s.HiddenObjects = True
s.LockedObjects = True
objs, layers = {}, {}
for o in d.Objects.GetObjectList(s):
a = o.Attributes
oid = a.GetUserString("id") or a.Name or str(o.Id)
lay = d.Layers[a.LayerIndex]
layers[lay.FullPath] = [lay.Color.R, lay.Color.G, lay.Color.B, lay.IsVisible]
g = o.Geometry
bb = g.GetBoundingBox(True)
rec = {"layer": lay.FullPath, "type": type(g).__name__, "hidden": o.IsHidden, "locked": o.IsLocked,
"colour_source": int(a.ColorSource), "colour": [a.ObjectColor.R, a.ObjectColor.G, a.ObjectColor.B],
"bbox": [round(v, 2) for v in (bb.Min.X, bb.Min.Y, bb.Min.Z, bb.Max.X, bb.Max.Y, bb.Max.Z)],
"user": {k: a.GetUserString(k) for k in sorted(a.GetUserStrings().AllKeys)}}
b = g if isinstance(g, rg.Brep) else (rg.Brep.TryConvertBrep(g) if isinstance(g, rg.Extrusion) else None)
if b is not None:
rec["faces"], rec["solid"] = b.Faces.Count, b.IsSolid
if FAST and "-R-" in oid: # rebar pipe: bbox + face count only (volume dominates run time)
rec["volume"] = "skipped"
else:
mp = rg.VolumeMassProperties.Compute(b) if b.IsSolid else None
rec["volume"] = round(mp.Volume, 1) if mp else None
elif isinstance(g, rg.Curve):
rec["length"] = round(g.GetLength(), 3)
if oid in objs:
oid = f"{oid}#dup{sum(k.startswith(oid) for k in objs)}"
objs[oid] = rec
out = {"model": MODEL_PATH, "units": str(d.ModelUnitSystem), "tolerance": d.ModelAbsoluteTolerance,
"count": len(objs), "layers": layers, "objects": objs}
json.dump(out, open(OUT_PATH, "w", encoding="utf-8"), ensure_ascii=False)
print(json.dumps({"count": len(objs), "units": out["units"], "tolerance": out["tolerance"]}))
finally:
d.Dispose()
@@ -0,0 +1,186 @@
"""G3 stage: footing rebar B1-B12 (centreline + pipe solid) from params `footing_rebar`.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after concrete.py.
Ported from Code/교각모델링/build_footing_rebar_rhino.py (Ungok P1, model v0.1.09).
Rules: 설계서 v0.1.06 §6 (HITL F1-F3 approved 2026-10-07). z in params is above the footing bottom.
`design_length` = schedule original length (a, a+2b, 2(a+b), a+b+c); curve length is measured separately.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
FB = P["footing"]["z_bottom"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items() if m.startswith("B")}
DIA = {d: float(d[1:]) for d, _ in MARK.values()} # 'S25' -> 25.0
CL_RGB = tuple(P["display"]["centreline_colour"])
FT_RGB = tuple(P["display"]["footing_layer_colour"])
LEGS = P["footing_rebar"]["legs"]
def leg(mark):
"""Signed end-leg length of a footing bar mark (+ up / - down) from params `legs`."""
v = LEGS[mark]
n = v.get("leg", v.get("hook"))
return n if v["dir"].startswith("up") else -n
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
sub = "::".join(parts[:i])
idx = doc.Layers.FindByFullPath(sub, -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_r(dia):
d = DIA[dia]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2 # box rule (REBAR_BENDS_20260914)
def poly(pts, dia):
pl = rg.PolylineCurve([rg.Point3d(*p) for p in pts])
if len(pts) < 3:
return pl
f = rg.Curve.CreateFilletCornersCurve(pl, min_r(dia), TOL, math.radians(1))
return f or pl
def add_bar(curve, mark, occ, design_len, src):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-FT", anchor_hosts="",
design_length=f"{design_len:.3f}", coupler_length="0.000", src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::기초::{mark}", FT_RGB), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
(doc.Objects.AddBrep if isinstance(geo, rg.Brep) else doc.Objects.AddCurve)(geo, attr)
def bar_y(x, z, a, end, dz):
"""Bar along Y (length a) at x, height z; end legs of length dz (+up / -down) or none."""
h = a / 2
if not end:
return [(x, -h, z), (x, h, z)]
return [(x, -h, z + dz), (x, -h, z), (x, h, z), (x, h, z + dz)]
def bar_x(y, z, a, end, dz):
h = a / 2
if not end:
return [(-h, y, z), (h, y, z)]
return [(-h, y, z + dz), (-h, y, z), (h, y, z), (h, y, z + dz)]
def sched(m):
row = P["footing_rebar"]["schedule"][m]
nums = [float(t) for t in row[3:] if t.replace(".", "", 1).isdigit()]
return row, nums
def mm(v):
return v * 1000 if v < 100 else v
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-R-B", f"{PID}-RC-B")):
doc.Objects.Delete(o, True)
fr = P["footing_rebar"]
L = fr["layers"]
n = 0
a_y, a_x = fr["a_y"], fr["a_x"] # D3 a of Y-bars (B1-B4, B12) / X-bars (B5-B8, B11)
# bottom layer 1 (Y): B2 U (legs up) at the outermost positions and alternate from the next; B4 straight
pos = L["bottom_Y1"]["pos"]
edge = max(abs(x) for x in pos)
inner = [x for x in pos if abs(x) != edge]
for i, x in enumerate(pos):
mark = "B2" if abs(x) == edge or inner.index(x) % 2 == 0 else "B4"
z = FB + L["bottom_Y1"]["z"]
if mark == "B2":
c, dl = poly(bar_y(x, z, a_y, True, leg("B2")), MARK["B2"][0]), a_y + 2 * abs(leg("B2"))
else:
c, dl = poly(bar_y(x, z, a_y, False, 0), MARK["B4"][0]), a_y
add_bar(c, mark, f"x{x:+.0f}", dl, "D1 정면도 하면 점 z122 / D1·D2 '70@125=8.750'")
n += 1
pos = L["bottom_X2"]["pos"]
edge = max(abs(y) for y in pos)
inner = [y for y in pos if abs(y) != edge]
for y in pos:
mark = "B6" if abs(y) == edge or inner.index(y) % 2 == 0 else "B8"
z = FB + L["bottom_X2"]["z"]
if mark == "B6":
c, dl = poly(bar_x(y, z, a_x, True, leg("B6")), MARK["B6"][0]), a_x + 2 * abs(leg("B6"))
else:
c, dl = poly(bar_x(y, z, a_x, False, 0), MARK["B8"][0]), a_x
add_bar(c, mark, f"y{y:+.0f}", dl, "D1 단면도 하면 점 z178 / D1 '58@125=7.250'")
n += 1
for key, mark, axis, a in (("bottom_Y3", "B3", "Y", a_y), ("bottom_X4", "B7", "X", a_x),
("top_Y", "B1", "Y", a_y), ("top_X", "B5", "X", a_x)):
z, dz, dia = FB + L[key]["z"], leg(mark), MARK[mark][0]
for v in L[key]["pos"]:
pts = bar_y(v, z, a, True, dz) if axis == "Y" else bar_x(v, z, a, True, dz)
add_bar(poly(pts, dia), mark, f"{axis.lower()}{v:+.0f}", a + 2 * abs(dz),
f"D1 {'정면도' if axis == 'Y' else '단면도'} 점 z{L[key]['z']}")
n += 1
for key, mark, axis, a in (("mid_X", "B11", "X", a_x), ("mid_Y", "B12", "Y", a_y)):
for zs, vs in L[key]["levels"].items():
z = FB + float(zs)
dz = leg(mark)
for v in vs:
pts = bar_y(v, z, a, True, dz) if axis == "Y" else bar_x(v, z, a, True, dz)
add_bar(poly(pts, MARK[mark][0]), mark, f"z{float(zs):.0f}{axis.lower()}{v:+.0f}", a + 2 * abs(dz),
f"D1 {'정면도' if axis == 'Y' else '단면도'} 중앙 점 z{zs}")
n += 1
b10 = fr["b10"]
hx, hy = b10["size_x"] / 2, b10["size_y"] / 2
for k, zs in enumerate(b10["levels"]):
z = FB + zs
pts = [(-hx, 0, z), (-hx, -hy, z), (hx, -hy, z), (hx, hy, z), (-hx, hy, z), (-hx, 0.0, z)]
c = poly(pts, MARK["B10"][0])
add_bar(c, "B10", f"z{zs}", 2 * (b10["size_x"] + b10["size_y"]),
"D1 정면도 '150','9@200'; D3 B10 9.198x7.644 (closed, start/end at -X mid side)")
n += 1
b9 = fr["b9"]
rc = b9["c"] / math.pi # 180-degree hook developed length = c
for i, x in enumerate(b9["grid_x"]):
for j, y in enumerate(b9["grid_y"]):
if (i + j) % 2 != b9["checker_parity"]:
continue
z0, z1 = FB + b9["z0"], FB + b9["z0"] + b9["a"]
base = poly([(x + b9["b"], y, z0), (x, y, z0), (x, y, z1)], MARK["B9"][0])
top = rg.ArcCurve(rg.Arc(rg.Point3d(x, y, z1), rg.Point3d(x + rc, y, z1 + rc),
rg.Point3d(x + 2 * rc, y, z1)))
c = rg.Curve.JoinCurves([base, top], TOL)[0]
add_bar(c, "B9", f"x{x:+.0f}y{y:+.0f}", b9["a"] + b9["b"] + b9["c"],
"D1 EREC grid @250 checker (i+j even) [추론]; count 493 [역산]")
n += 1
doc.Views.Redraw()
print(json.dumps({"footing_bars": n}))
main()
@@ -0,0 +1,138 @@
"""G3 stage, form "mats" (Ulsan P1, 2026-10-13): footing rebar as explicit bar groups, perimeter hoops and
vertical bars, from params `footing_rebar` (centreline + pipe solid per bar).
Run inside Rhino 8 via runner (globals PARAMS_PATH) after the G1 builder. z values are above the footing bottom.
params footing_rebar:
groups [{mark, axis "X"|"Y", z, pos [...], a, leg (signed: + up / - down / 0), lap, src}]
a bar along `axis` of length a centred on the footing, at `pos` across, with end legs
design length = a + 2|leg| + lap (lap only adds length)
hoops {mark, half, levels [...], design_length}: closed square hoops of centreline half-width `half`
verticals {mark, grid_x [...], grid_y [...], parity, z0, a, leg}: vertical bars on a checkerboard of the grid,
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
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
import System
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
FB = P["footing"]["z_bottom"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items()}
DIA = {d: float(d[1:]) for d, _ in MARK.values()}
CL_RGB = tuple(P["display"]["centreline_colour"])
FT_RGB = tuple(P["display"]["footing_layer_colour"])
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
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):
d = DIA[dia]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2
def poly(pts, dia):
pl = rg.PolylineCurve([rg.Point3d(*p) for p in pts])
if len(pts) < 3:
return pl
return rg.Curve.CreateFilletCornersCurve(pl, min_r(dia), TOL, math.radians(1)) or pl
def add_bar(curve, mark, occ, design_len, src):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-FT", anchor_hosts="",
design_length=f"{design_len:.3f}", coupler_length="0.000", src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::기초::{mark}", FT_RGB), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(geo, attr) if isinstance(geo, rg.Brep) else doc.Objects.AddCurve(geo, attr)
if gid == System.Guid.Empty:
raise RuntimeError(f"{oid}: add failed")
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-R-B", f"{PID}-RC-B")):
doc.Objects.Delete(o, True)
fr = P["footing_rebar"]
n = 0
for g in fr["groups"]:
h, z, leg = g["a"] / 2, FB + g["z"], g.get("leg", 0)
for v in g["pos"]:
if g["axis"] == "Y":
ends = [(v, -h, z), (v, h, z)]
else:
ends = [(-h, v, z), (h, v, z)]
pts = ends if not leg else [(ends[0][0], ends[0][1], z + leg), ends[0], ends[1], (ends[1][0], ends[1][1], z + leg)]
add_bar(poly(turn(pts), MARK[g["mark"]][0]), g["mark"], f"{g['axis'].lower()}{v:+.0f}",
g["a"] + 2 * abs(leg) + g.get("lap", 0), g.get("src", ""))
n += 1
hp = fr["hoops"]
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]), hp["mark"], f"z{zz:.0f}", hp["design_length"], hp.get("src", ""))
n += 1
vt = fr["verticals"]
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
doc.Views.Redraw()
print(json.dumps({"footing_bars": n}))
main()
@@ -0,0 +1,34 @@
"""Negative test helper: move selected objects of the open doc (inside Rhino 8, via runner).
Globals: __rhino_doc__, PIER_ID, MOVES = [{"id": "BS08"} | {"mark": "P2"}, "d": [dx, dy, dz]}, ...],
optional UNDO (move back by -d). A `mark` selector moves the first occurrence (sorted) of that mark,
centreline and pipe together. Prints what was moved; raises if a selector matches nothing.
"""
import json
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectEnumeratorSettings
doc = __rhino_doc__
s = ObjectEnumeratorSettings()
s.HiddenObjects = True
s.LockedObjects = True
objs = list(doc.Objects.GetObjectList(s))
sign = -1 if globals().get("UNDO") else 1
moved = []
for mv in MOVES:
if "id" in mv:
sel = [o for o in objs if o.Attributes.GetUserString("id") == f"{PIER_ID}-{mv['id']}"]
else:
cand = [o for o in objs if o.Attributes.GetUserString("mark") == mv["mark"]
and (o.Attributes.GetUserString("id") or "").startswith(f"{PIER_ID}-R")]
first = sorted({o.Attributes.GetUserString("occurrence") for o in cand})[0] if cand else None
sel = [o for o in cand if o.Attributes.GetUserString("occurrence") == first]
if not sel:
raise RuntimeError(f"perturb: nothing matches {mv}")
dx, dy, dz = (sign * v for v in mv["d"])
xf = rg.Transform.Translation(dx, dy, dz)
for o in sel:
doc.Objects.Transform(o.Id, xf, True)
moved.append(o.Attributes.GetUserString("id"))
print(json.dumps({"moved": moved, "undo": sign < 0}))
@@ -0,0 +1,53 @@
"""Colour rebar pipe solids by nominal diameter (run inside Rhino 8 via runner after the rebar stages).
Globals: PARAMS_PATH. Ported from Code/교각모델링/apply_rebar_colors.py (Ungok P1, model v0.1.09).
Rule (context-migration/디자인지침_경로.md, R8 criteria 2026-10-07): same diameter -> same colour
regardless of steel grade (S = SD500, H = SD400). Centrelines keep their layer colour.
Prints a check: per-diameter object count and colour, and any pipe whose colour does not match.
"""
import json
from collections import Counter
from Rhino.DocObjects import ObjectColorSource
from System.Drawing import Color
doc = __rhino_doc__
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
PID = P["pier_id"]
PALETTE = {int(k): tuple(v) for k, v in P["display"]["rebar_palette"].items()}
def main():
done, bad = Counter(), []
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if not oid.startswith(f"{PID}-R-"):
continue
dia = int(o.Attributes.GetUserString("dia")[1:]) # 'S32' / 'H19' -> 32 / 19
if dia not in PALETTE:
bad.append((oid, "no palette entry"))
continue
attr = o.Attributes.Duplicate()
attr.ObjectColor = Color.FromArgb(*PALETTE[dia])
attr.ColorSource = ObjectColorSource.ColorFromObject
doc.Objects.ModifyAttributes(o, attr, True)
done[dia] += 1
# check (re-read objects)
seen = {}
for o in doc.Objects:
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith(f"{PID}-R-"):
dia = int(o.Attributes.GetUserString("dia")[1:])
c = o.Attributes.ObjectColor
ok = o.Attributes.ColorSource == ObjectColorSource.ColorFromObject and (c.R, c.G, c.B) == PALETTE[dia]
if not ok:
bad.append((oid, "colour mismatch"))
seen.setdefault(dia, set()).add((c.R, c.G, c.B))
doc.Views.Redraw()
print(json.dumps(dict(coloured=dict(done), colours_per_dia={k: list(v) for k, v in seen.items()},
distinct_colours=len({tuple(PALETTE[k]) for k in seen}), mismatches=bad[:10],
mismatch_count=len(bad))))
main()
@@ -0,0 +1,91 @@
"""Generic rebar stage (Ulsan P1 coping, 2026-10-13): bars given as explicit centreline polylines in params.
Run inside Rhino 8 via runner (globals PARAMS_PATH). Used as the G4 builder (params["coping_rebar"]).
params coping_rebar:
host object id suffix of the host concrete (e.g. "CP")
group layer group under 철근:: (e.g. "코핑")
bars [{mark, occ, pts [[x, y, z], ...], r (corner fillet radius), design_length, src}]
a closed bar repeats its first point at the end (start it mid-side so no corner sits at the seam)
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.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
import System
from Rhino.DocObjects import ObjectAttributes
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
SEC = P["footing_rebar" if globals().get("GATE") == "G3" else "coping_rebar"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items()}
CL_RGB = tuple(P["display"]["centreline_colour"])
LAY_RGB = tuple(P["display"]["coping_layer_colour"])
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def centreline(pts, r):
pl = rg.PolylineCurve([rg.Point3d(*p) for p in pts])
if len(pts) < 3 or r <= 0:
return pl
c = rg.Curve.CreateFilletCornersCurve(pl, r, TOL, math.radians(1))
if c is None:
raise RuntimeError(f"fillet failed (r {r})")
return c
def add_bar(b):
mark, occ = b["mark"], b["occ"]
dia, shape = MARK[mark]
curve = centreline(b["pts"], b.get("r", 0))
pipes = rg.Brep.CreatePipe(curve, float(dia[1:]) / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=f"{PID}-{SEC['host']}", anchor_hosts="",
design_length=f"{b['design_length']:.3f}", coupler_length="0.000", src=b.get("src", ""))
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::{SEC['group']}::{mark}", LAY_RGB), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(geo, attr) if isinstance(geo, rg.Brep) else doc.Objects.AddCurve(geo, attr)
if gid == System.Guid.Empty:
raise RuntimeError(f"{oid}: add failed")
def main():
marks = {b["mark"] for b in SEC["bars"]}
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-R-", f"{PID}-RC-")) and o.Attributes.GetUserString("mark") in marks:
doc.Objects.Delete(o, True)
for b in SEC["bars"]:
add_bar(b)
doc.Views.Redraw()
print(json.dumps({"bars": len(SEC["bars"]), "marks": len(marks)}))
main()
@@ -0,0 +1,180 @@
"""G3 stage, form "shaft cage" (Ramp-A P1, 2026-10-13): drilled-shaft reinforcement cage (no footing). Straight main
bars in two pieces joined by couplers (upper / lower mark), lapped half-ring hoops with 135-deg hooks, X / Y cross
ties, bottom U bars in vertical planes through the centre, and the anti-uplift plate under them.
Run inside Rhino 8 via runner (globals PARAMS_PATH) after the G1 / G2 builders. Units mm, Z = EL x 1000.
params shaft_rebar:
main {count, start_deg, radius, pieces: [{mark, z0, z1}, ...] bottom to top, coupler_length}
hoops {mark, radius, levels, a, lap, hook, join_deg [first, second]} joins alternate per level (S5)
ties {mark, levels, straight, leg, hook_tail, y_lift} as column_rebar_straight.py (S5)
u_bars {mark, count, z_bottom, half_bottom, half_top, leg_top_z, top_z, bend_r} one U per plane at main-bar angle
i (i < count): bottom +-half_bottom at z_bottom, sloped legs to +-half_top at leg_top_z, vertical to top_z (S3)
plate {size, thickness, z_top, colour} square, centred (S4)
design_lengths {mark: schedule-style original length per piece}
Bars below the pile segment boundary are hosted by the lower pile segment, above it by the upper one.
"""
import json
import math
import Rhino
import Rhino.Geometry as rg
from Rhino.DocObjects import ObjectAttributes, ObjectColorSource
from System.Drawing import Color
P = json.load(open(PARAMS_PATH, encoding="utf-8"))
doc = __rhino_doc__
TOL = 0.01
PID = P["pier_id"]
MARK = {m: (v["dia"], v["shape"]) for m, v in P["rebar_marks"].items()}
DIA = {d: float(d[1:]) for d, _ in MARK.values()}
CL_RGB = tuple(P["display"]["centreline_colour"])
SEGS = P["pile"]["segments"]
def layer(path, color):
parent = -1
parts = path.split("::")
for i in range(1, len(parts) + 1):
idx = doc.Layers.FindByFullPath("::".join(parts[:i]), -1)
if idx < 0:
lay = Rhino.DocObjects.Layer()
lay.Name, lay.Color = parts[i - 1], Color.FromArgb(*color)
if parent >= 0:
lay.ParentLayerId = doc.Layers[parent].Id
idx = doc.Layers.Add(lay)
parent = idx
return parent
def min_bend_radius(dia_name):
d = DIA[dia_name]
return ((2 * d if d <= 16 else 3.5 * d) + d) / 2
def host(z):
for i, s in enumerate(SEGS, 1):
if s["z0"] <= z <= s["z1"]:
return f"{PID}-PL{i:02d}"
return ""
def add_bar(curve, mark, occ, design_len, src, coupler=0.0):
dia, shape = MARK[mark]
pipes = rg.Brep.CreatePipe(curve, DIA[dia] / 2, False, rg.PipeCapMode.Flat, False, TOL, math.radians(1))
if not pipes or not pipes[0].IsSolid:
raise RuntimeError(f"{mark} {occ}: pipe failed")
bb = curve.GetBoundingBox(True)
common = dict(mark=mark, dia=dia, shape=shape, occurrence=occ, host=host((bb.Min.Z + bb.Max.Z) / 2),
anchor_hosts="", design_length=f"{design_len:.3f}", coupler_length=f"{coupler:.3f}", src=src)
for geo, lay, prefix in ((curve, layer(f"{PID}::철근::중심선", CL_RGB), f"{PID}-RC"),
(pipes[0], layer(f"{PID}::철근::말뚝::{mark}", (110, 110, 110)), f"{PID}-R")):
attr = ObjectAttributes()
attr.LayerIndex = lay
oid = f"{prefix}-{mark}-{occ}"
attr.Name = oid
attr.SetUserString("id", oid)
for k, v in common.items():
attr.SetUserString(k, v)
gid = doc.Objects.AddBrep(geo, attr) if isinstance(geo, rg.Brep) else doc.Objects.AddCurve(geo, attr)
if gid == __import__("System").Guid.Empty:
raise RuntimeError(f"{oid}: add failed")
def pt(r, deg, z):
t = math.radians(deg)
return rg.Point3d(r * math.cos(t), r * math.sin(t), z)
def hooked_arc(r, a0, a1, z, hook, bend_r):
"""Arc a0 -> a1 (deg, ccw) with inward 135-deg hooks at both ends, fillet bend_r (as column_rebar_ring.py)."""
arc = rg.ArcCurve(rg.Arc(pt(r, a0, z), pt(r, (a0 + a1) / 2, z), pt(r, a1, z)))
pieces = []
for at_start in (True, False):
p = arc.PointAtStart if at_start else arc.PointAtEnd
t = -arc.TangentAtStart if at_start else arc.TangentAtEnd
n = rg.Vector3d(-p.X, -p.Y, 0)
n.Unitize()
d = t * math.cos(math.radians(135)) + n * math.sin(math.radians(135))
d.Unitize()
pieces.append(rg.LineCurve(p, p + d * hook))
joined = rg.Curve.JoinCurves([pieces[0], arc, pieces[1]], TOL)[0]
return rg.Curve.CreateFilletCornersCurve(joined, bend_r, TOL, math.radians(1)) or joined
def tie_curve(axis, z, half, leg, tail, flip):
"""As column_rebar_straight.py: straight part on the X / Y diameter, 90-deg leg down at one end, 135-deg hook."""
u = rg.Vector3d(1, 0, 0) if axis == 0 else rg.Vector3d(0, 1, 0)
s = -1 if flip else 1
a, b = rg.Point3d(0, 0, z) - u * (s * half), rg.Point3d(0, 0, z) + u * (s * half)
d = -u * s * math.cos(math.radians(45)) + rg.Vector3d(0, 0, -1) * math.sin(math.radians(45))
return rg.PolylineCurve([a + rg.Vector3d(0, 0, -leg), a, b, b + d * tail])
def main():
for o in list(doc.Objects):
oid = o.Attributes.GetUserString("id") or ""
if oid.startswith((f"{PID}-PT",)) or (oid.startswith((f"{PID}-R-", f"{PID}-RC-"))
and (o.Attributes.GetUserString("mark") or "") in SR_MARKS):
doc.Objects.Delete(o, True)
DL = SR["design_lengths"]
m, n = SR["main"], 0
for i in range(m["count"]):
th = m["start_deg"] + i * 360.0 / m["count"]
for pc in m["pieces"]:
c = rg.LineCurve(pt(m["radius"], th, pc["z0"]), pt(m["radius"], th, pc["z1"]))
add_bar(c, pc["mark"], f"{i + 1:02d}", DL[pc["mark"]],
f"angle {th:.4f} deg; D4 단면 G-G·J-J 61개; r {m['radius']} (S2); EL {pc['z0'] / 1000:.3f}~{pc['z1'] / 1000:.3f}; "
f"커플러 이음 (S6)", coupler=m["coupler_length"])
n += 1
h = SR["hoops"]
r, ext = h["radius"], math.degrees(h["lap"] / 2 / h["radius"])
for j, z in enumerate(h["levels"]):
a0 = h["join_deg"][j % 2]
for q, s in enumerate((a0, a0 + 180)):
c = hooked_arc(r, s - ext, s + 180 + ext, z, h["hook"], min_bend_radius(MARK[h["mark"]][0]))
add_bar(c, h["mark"], f"L{j + 1:03d}{'ab'[q]}", DL[h["mark"]],
f"EL {z / 1000:.3f}; r {r} (S1); 겹이음 {h['lap']:.0f} at {a0}/{a0 + 180} deg (S5 [추론])")
n += 1
t = SR["ties"]
rb = min_bend_radius(MARK[t["mark"]][0])
for j, z in enumerate(t["levels"]):
for axis, name in ((0, "X"), (1, "Y")):
zt = z + (t["y_lift"] if axis == 1 else 0)
pl = tie_curve(axis, zt, t["straight"] / 2, t["leg"], t["hook_tail"], flip=(j % 2 == 1))
c = rg.Curve.CreateFilletCornersCurve(pl, rb, TOL, math.radians(1)) or pl
add_bar(c, t["mark"], f"L{j + 1:02d}{name}", DL[t["mark"]],
f"EL {zt / 1000:.3f}; {name} 방향 (S5 [추론])")
n += 1
u = SR["u_bars"]
for i in range(u["count"]):
th = m["start_deg"] + i * 360.0 / m["count"]
e = rg.Vector3d(math.cos(math.radians(th)), math.sin(math.radians(th)), 0)
P0 = lambda s, z: rg.Point3d(0, 0, z) + e * s
pl = rg.PolylineCurve([P0(-u["half_top"], u["top_z"]), P0(-u["half_top"], u["leg_top_z"]),
P0(-u["half_bottom"], u["z_bottom"]), P0(u["half_bottom"], u["z_bottom"]),
P0(u["half_top"], u["leg_top_z"]), P0(u["half_top"], u["top_z"])])
c = rg.Curve.CreateFilletCornersCurve(pl, u["bend_r"], TOL, math.radians(1)) or pl
add_bar(c, u["mark"], f"{i + 1:02d}", DL[u["mark"]],
f"plane {th:.4f} deg through the centre (S3 [추론]); bottom EL {u['z_bottom'] / 1000:.3f}; c {u['top_z'] - u['leg_top_z']:.0f} welded to P1-2")
n += 1
pt_ = SR["plate"]
a = pt_["size"] / 2
box = rg.Box(rg.BoundingBox(-a, -a, pt_["z_top"] - pt_["thickness"], a, a, pt_["z_top"])).ToBrep()
attr = ObjectAttributes()
attr.LayerIndex = layer(f"{PID}::철근::말뚝::PLATE", tuple(pt_["colour"]))
attr.ObjectColor = Color.FromArgb(*pt_["colour"])
attr.ColorSource = ObjectColorSource.ColorFromObject
oid = f"{PID}-PT01"
attr.Name = oid
for k, v in (("id", oid), ("role", "anti-uplift plate"), ("host", host(pt_["z_top"])),
("src", f"D4 떠오름 방지 PLATE {pt_['size']:.0f}x{pt_['size']:.0f}x{pt_['thickness']:.0f}; U형 바닥 아래 (S4 [추론])")):
attr.SetUserString(k, v)
if doc.Objects.AddBrep(box, attr) == __import__("System").Guid.Empty:
raise RuntimeError("plate: add failed")
doc.Views.Redraw()
print(json.dumps({"bars": n, "plate": 1}))
SR = P["shaft_rebar"]
SR_MARKS = {pc["mark"] for pc in SR["main"]["pieces"]} | {SR[k]["mark"] for k in ("hoops", "ties", "u_bars")}
main()
@@ -0,0 +1,107 @@
"""Variability classes of the input bundles (stage A of the 입력 묶음 확인·확정 개편, 2026-10-16).
Every top-level item of params.json / expected.json is a bundle. Grades (기획서 4.4):
V1 value variable numbers / text that change from drawing to drawing within the same form
V2 list variable position lists, dimension chains, outline points
V3 generated variable long lists made by a reading script (hand editing is blocked)
F fixed defines the form; changing it means "another template" (edit needs a reason)
`fixed_fields` are paths inside a non-F bundle that behave like F (e.g. coping.form); "*" matches every child of an object.
`source` lists where the values normally come from: 표 / 표 밖 치수 / 판독 스크립트 / 사람 입력 / 템플릿 고정.
The result is a draft until the template's owner confirms it (variability.json of each protected template).
"""
import json
SOURCES = ["표", "표 밖 치수", "판독 스크립트", "사람 입력", "템플릿 고정"]
GRADES = {"V1": "값 변수", "V2": "목록 변수", "V3": "생성 변수", "F": "고정"}
V3_MIN_BARS = 100 # a rebar bundle with at least this many explicit bars is a generated list
# key -> (grade, [sources], [fixed_fields], note); the same table serves params and expected (file-specific rows below)
PARAMS = {
"pier_id": ("F", ["템플릿 고정"], [], "교각 식별자"),
"scope": ("F", ["템플릿 고정"], [], "모델링 게이트 범위(실행 설정)"),
"builders": ("F", ["템플릿 고정"], [], "게이트별 생성기(실행 설정)"),
"display": ("F", ["템플릿 고정"], [], "표시·색 설정"),
"lean_concrete": ("V1", ["표 밖 치수"], [], "버림콘크리트 치수·EL"),
"footing": ("V1", ["표 밖 치수"], [], "기초 치수·EL (rot_deg = 기초 회전 각도 값)"),
"column": ("V1", ["표 밖 치수"], ["shape"], "기둥 지름·높이 (단면 형식 shape는 고정)"),
"materials": ("V1", ["표 밖 치수"], [], "재료 강도 주기"),
"piles": ("V1", ["표 밖 치수"], ["kind"], "말뚝 개수·간격·길이 (말뚝 유무는 템플릿 형식)"),
"pile": ("V1", ["표 밖 치수"], ["kind"], "단일 말뚝 구간"),
"coping": ("V2", ["표 밖 치수"], ["form"], "코핑 외곽·치수 (형식 form은 고정)"),
"bearing_seats": ("V1", ["표", "표 밖 치수"], ["pedestal", "pedestals"], "받침 표 값 + 평면 위치 (대좌 구조는 고정)"),
"sole_plates": ("V1", ["표"], ["bearing_size"], "솔플레이트 표 값 (받침 크기 규칙은 고정)"),
"rebar_marks": ("V1", ["표"], ["*.shape"], "재료표 직경·형상 (마크 체계의 형상은 고정)"),
"column_rebar": ("V2", ["표 밖 치수"], ["form"], "기둥 철근 사슬·위치"),
"footing_rebar": ("V2", ["표 밖 치수"], ["form"], "기초 철근 위치 사슬"),
"shaft_rebar": ("V2", ["표 밖 치수"], ["form"], "말뚝 철근"),
"coping_rebar": ("V2", ["표 밖 치수"], ["form", "host", "group"], "코핑 철근"),
}
EXPECTED = {
"pier_id": ("F", ["템플릿 고정"], [], "교각 식별자"),
"tol_mm": ("V1", ["사람 입력"], [], "허용 오차"),
"checkers": ("F", ["템플릿 고정"], [], "게이트별 검사기(실행 설정)"),
"negative": ("F", ["템플릿 고정"], [], "음성 시험 대상(실행 설정)"),
"rebar_palette": ("V1", ["사람 입력"], [], "철근 직경별 색 (사람 결정 기록)"),
"planned_fail": ("V1", ["사람 입력"], [], "계획된 FAIL (사람 결정 기록)"),
**{g: ("V2", ["표", "표 밖 치수"], [], "독립 기대값 (params와 따로 도면에서 옮긴 값)") for g in ("G1", "G2", "G3", "G4", "G5")},
}
def is_info(key):
return key.startswith("_") or key.endswith("_src") or key.startswith("hitl_decisions")
def _bars(value):
"""Number of explicit per-bar entries of a rebar bundle (list `bars`), 0 when the bundle is rule based."""
return len(value.get("bars", [])) if isinstance(value, dict) and isinstance(value.get("bars"), list) else 0
def classify(which, key, value):
"""-> {grade, source, fixed_fields, note, info}."""
if is_info(key):
return dict(grade="F", source=["템플릿 고정"], fixed_fields=[], note="참고 정보(게이트가 쓰지 않음)", info=True)
grade, source, fixed, note = (PARAMS if which == "params" else EXPECTED).get(key, ("V1", ["사람 입력"], [], "미분류 — 담당자 확인"))
if which == "params" and key in ("coping_rebar", "footing_rebar") and _bars(value) >= V3_MIN_BARS:
grade, source, note = "V3", ["판독 스크립트"], note + " — 철근별 중심선 목록(판독 스크립트로 생성)"
return dict(grade=grade, source=list(source), fixed_fields=list(fixed), note=note, info=False)
def build(params, expected):
"""variability.json content for a params / expected pair (draft)."""
sections = {}
for which, d in (("params", params), ("expected", expected)):
for k, v in d.items():
sections[f"{which}:{k}"] = classify(which, k, v)
return {"_schema": "variability/1", "status": "초안 — 템플릿 담당자 확정 전", "grades": GRADES, "sections": sections}
def _walk(value, parts):
"""Values at a path with "*" wildcards, as a list of (path, value); a missing path yields (path, None)."""
if not parts:
return [("", value)]
head, rest = parts[0], parts[1:]
if head == "*":
if not isinstance(value, dict):
return []
out = []
for k, v in value.items():
out += [(f"{k}.{p}".rstrip("."), x) for p, x in _walk(v, rest)]
return out
if not isinstance(value, dict) or head not in value:
return [(head, None)]
return [(f"{head}.{p}".rstrip("."), x) for p, x in _walk(value[head], rest)]
def fixed_view(value, fixed_fields):
"""{path: json text} of the fixed fields of a bundle value (what must not change silently)."""
out = {}
for f in fixed_fields:
for p, x in _walk(value, f.split(".")):
out[p] = json.dumps(x, sort_keys=True, ensure_ascii=False)
return out
def changed_fixed(old, new, fixed_fields):
"""Paths of fixed fields whose value differs between two bundle values."""
a, b = fixed_view(old, fixed_fields), fixed_view(new, fixed_fields)
return sorted(p for p in set(a) | set(b) if a.get(p) != b.get(p))
+416
View File
@@ -0,0 +1,416 @@
"""App check ("앱 확인") of the input bundles — stage B of the 입력 묶음 확인·확정 개편 (2026-10-16).
The app compares every bundle of params.json / expected.json with what it can read independently and reports, per bundle,
one of: g 일치 / w 주의 / b 불일치 / n 근거 없음 / x 해당 없음. The app never confirms anything: a person confirms (D1), the app only
supplies the evidence. "일치" means "the independent source and the value agree", not "the drawing is right" (기획서 4.3).
Checks (C1~C8, 기획서 4.3):
C1 출처 있음 the bundle (or its `<key>_src` sibling) records where its values come from
C2 도면 재대조 table candidates (ELEVATION / 솔플레이트 / 철근재료표) read from the drawing texts vs the current values [strong]
C2b 표 밖 치수 재대조 anchors followed into the drawing (engine/anchors.py): A candidates equal / differ, B only warn; own-drawing anchors are no evidence [strong]
C3 템플릿 원본 잔존 the bundle still equals the protected template's original and nothing independent supports it
C4 내부 정합 bar counts of the generated lists vs the schedule counts in expected [counts only]
C5 params ↔ expected 독립 the expected source notes are not copies of the params notes
C6 범위·단위 sizes > 0, EL / z in a plausible range, no NaN
C7 변수 등급 위반 F bundles / fixed fields unchanged against the template original (or an edit reason is logged)
C8 재생성 일치 V3 bundles: regenerated list equals the current one — no regeneration script is connected yet (해당 없음, 독립성 낮음)
C9 params↔expected 일관성 values that describe the same quantity in both files agree (engine/consistency.py; 2026-10-20) — finds stale values before a gate is run;
not independent evidence (b when they differ, g otherwise, never a strength)
C3b 필드 단위 원본 잔존 fields of a CHANGED bundle that still equal the pier's creation snapshot while elevation-like siblings changed (origin_values.json; 2026-10-20)
Status per bundle: any failed check -> b; template original without independent support -> n; strong support (C2) -> g (w when a check
warns); counts only -> w ("수량만"); F bundle unchanged -> g; nothing -> n; reference-only / human decision records -> x.
Everything is derived from the data files and the drawing text index; no gate is run.
"""
import json
import math
import re
from engine import consistency, reader, variability
VERSION = "B3"
ST_NAME = {"g": "일치", "w": "주의", "b": "불일치", "n": "근거 없음", "x": "해당 없음"}
DECISION_KEYS = {("expected", "planned_fail"), ("expected", "rebar_palette"), ("expected", "tol_mm")}
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
Z_KEYS = re.compile(r"^(z|z_.*|el_x|z0|seat_z)$")
Z_RANGE = (-20000.0, 400000.0) # mm, EL -20 m .. 400 m
def chk(cid, name, st, text, pos="", strength=""):
return {"id": cid, "name": name, "st": st, "text": text, "pos": pos, "strength": strength}
def _walk(v, path=()):
"""(path, leaf) of every scalar in a nested value."""
if isinstance(v, dict):
for k, x in v.items():
yield from _walk(x, path + (str(k),))
elif isinstance(v, list):
for i, x in enumerate(v):
yield from _walk(x, path + (str(i),))
else:
yield path, v
def _src_texts(v):
"""All source notes (`src`, `*_src`, `sources`) inside a bundle value, flattened to strings."""
out = []
def rec(x, key=""):
if isinstance(x, dict):
for k, y in x.items():
rec(y, k)
elif isinstance(x, list):
for y in x:
rec(y, key)
elif isinstance(x, str) and (key == "src" or key.endswith("_src") or key == "sources") and x.strip():
out.append(x.strip())
rec(v)
return out
def _count_bars(value, mark):
return sum(1 for b in value.get("bars", []) if b.get("mark") == mark)
def _planned(expected, gate, mark, what="bar count vs schedule"):
for pf in expected.get("planned_fail", []):
if pf.get("gate") == gate and pf.get("item", "").startswith(f"{mark} {what}"):
return pf
return None
def count_pairs(params, expected):
"""Counts derived from the generated rebar lists vs the schedule counts in expected: {gate: [(label, mark, got, want)]}."""
out = {"G2": [], "G3": [], "G4": []}
cr = params.get("column_rebar") or {}
sn = (expected.get("G2") or {}).get("schedule_n")
if cr.get("form") == "ring" and isinstance(sn, dict):
try:
out["G2"].append(("주철근", cr["main"]["mark"], cr["main"]["count"], sn.get(cr["main"]["mark"])))
for z in cr["hoops"]["zones"]:
out["G2"].append(("띠철근", z["mark"], len(z["levels"]), sn.get(z["mark"])))
t = cr["ties"]
out["G2"].append(("크로스타이", t["mark"], len(t["levels"]) * len(t["angles_deg"]), sn.get(t["mark"])))
except (KeyError, TypeError):
out["G2"] = []
for gate, key in (("G3", "footing_rebar"), ("G4", "coping_rebar")):
fr = params.get(key) or {}
sch = (expected.get(gate) or {}).get("schedule")
if not isinstance(sch, dict) or not fr:
continue
if fr.get("form") == "polylines":
for m, v in sch.items():
if isinstance(v, list) and len(v) == 2 and m in {b.get("mark") for b in fr.get("bars", [])} | set(sch):
out[gate].append(("철근 수", m, _count_bars(fr, m), v[1]))
elif fr.get("form") == "mats" and gate == "G3":
for g in fr.get("groups", []):
out[gate].append(("위치 목록", g["mark"], len(g["pos"]), (sch.get(g["mark"]) or [None, None])[1]))
h, v = fr.get("hoops"), fr.get("verticals")
if h:
out[gate].append(("띠철근 단 수", h["mark"], len(h["levels"]), (sch.get(h["mark"]) or [None, None])[1]))
if v:
n = len(v["points"]) if "points" in v else sum(1 for i, _ in enumerate(v["grid_x"]) for j, _ in enumerate(v["grid_y"]) if (i + j) % 2 == v["parity"])
out[gate].append(("수직근", v["mark"], n, (sch.get(v["mark"]) or [None, None])[1]))
return {g: p for g, p in out.items() if p}
def bundles_of_gate(gate):
return {"G2": [("params", "column_rebar"), ("expected", "G2")], "G3": [("params", "footing_rebar"), ("expected", "G3")],
"G4": [("params", "coping_rebar"), ("expected", "G4")]}[gate]
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
HEIGHT_TOKENS = {"z", "z0", "z_bottom", "z_top", "z_flat", "el_x", "datum_el", "step_top", "tier_z", "loop_z", "levels", "hook_leg_z", "straight_from",
"c10_bottom", "c15_top", "c16_top", "top_right", "top_left", "tiers"}
def height_like(path):
"""True for a leaf that is an elevation (EL in mm): z-like keys, level lists, the z of a point [x, z], the z of an outline point."""
last = path[-1]
if last in HEIGHT_TOKENS or last.startswith("z_") or last.endswith(("_z", "_top", "_bottom")):
return True
if any(t in ("step_top", "tier_z", "loop_z", "levels") for t in path):
return True
if last == "1" and any(t in ("points", "hooks_up", "coping_outline", "top_right", "top_left") for t in path):
return True
if last == "0" and "c14_levels" in path:
return True
return False
EL_MIN, EL_MAX = 10000.0, 60000.0 # mm: elevation-like numbers (EL 10 m .. 60 m)
STALE_MIN_CHANGED = 3 # at least this many elevation-like siblings changed against the snapshot
def stale_fields_check(which, key, value, origin_values, m):
"""C3b: in a bundle that was changed against the pier's creation snapshot, elevation-like numbers that still equal the snapshot value are
suspicious (the drawing moved the other heights but this one was left behind, e.g. a P2 coordinate in a P3 point list). Warns (w), never fails."""
if not origin_values or which not in origin_values or key not in origin_values[which]:
return chk("C3b", "필드 단위 원본 잔존", "x", "교각 생성 시점의 값 기록(origin_values.json)이 없어 필드 단위로 비교하지 못했다")
old = dict(_walk(origin_values[which][key]))
new = dict(_walk(value))
el = [p for p, v in new.items() if isinstance(v, (int, float)) and not isinstance(v, bool) and EL_MIN <= abs(v) <= EL_MAX and p in old and height_like(p)]
if not el or old == new:
return chk("C3b", "필드 단위 원본 잔존", "x" if old == new else "g", "묶음 전체가 생성 시점 그대로라 C3에서 본다" if old == new else "높이로 보이는 값이 없다")
changed = [p for p in el if old[p] != new[p]]
kept = [p for p in el if old[p] == new[p]]
if len(changed) < STALE_MIN_CHANGED:
return chk("C3b", "필드 단위 원본 잔존", "g", f"높이로 보이는 값 {len(el)}개 중 바뀐 것이 {len(changed)}개뿐이라 비교하지 않는다")
if kept:
names = ", ".join(".".join(p) + f" = {new[p]:g}" for p in kept[:4])
return chk("C3b", "필드 단위 원본 잔존", "w", f"바뀐 묶음인데 높이로 보이는 값 {len(el)}개 중 {len(kept)}개가 생성 시점 값 그대로다 — 도면과 대조: {names}")
return chk("C3b", "필드 단위 원본 잔존", "g", f"높이로 보이는 값 {len(el)}개가 모두 생성 시점과 다르다")
def verify(params, expected, texts, meta, origin_fixed=None, edit_log=None, current_fixed=None, template_values=None, anchor_rows=None, origin_values=None):
"""-> {"params:key": {"st", "checks", "summary"}} for every bundle.
meta[bundle] = variability.classify result + "origin" (True / False / None); origin_fixed[bundle] = fixed_view at copy time."""
origin_fixed = origin_fixed or {}
edit_log = edit_log or []
template_values = template_values or {}
arows, amiss = (anchor_rows or ([], []))[:2]
docs = {"params": params, "expected": expected}
# ---- C2: table candidates of the drawing texts vs the current values
c2 = {}
tables_ok = False
if texts:
tabs = reader.table_candidates(texts)
cands = reader.field_candidates(tabs)
tables_ok = bool(cands)
for r in reader.proposals(cands, params, expected):
c2.setdefault(f"{r['file']}:{r['path'][0]}", []).append(r)
# ---- C9: params <-> expected consistency (rules of engine/consistency.py), per bundle of either file
cons = consistency.check(params, expected)
cons_by = {}
for r in cons:
cons_by.setdefault("params:" + consistency.bundles_of(r["p"]), []).append(r)
cons_by.setdefault("expected:" + consistency.bundles_of(r["e"]), []).append(r)
# ---- C4: counts per gate pair
pairs = count_pairs(params, expected)
out = {}
for which in ("params", "expected"):
for key, value in docs[which].items():
b = f"{which}:{key}"
m = meta.get(b) or variability.classify(which, key, value)
checks = []
if m.get("info"):
out[b] = {"st": "x", "checks": [chk("C1", "참고 정보", "x", "게이트가 쓰지 않는 참고 정보라 확인하지 않는다")], "summary": "참고 정보"}
continue
# C1
srcs = _src_texts(value) + (_src_texts(params.get(key + "_src")) if which == "params" and key + "_src" in params else [])
if key + "_src" in docs[which] and isinstance(docs[which][key + "_src"], str):
srcs.append(docs[which][key + "_src"])
if m["grade"] == "F":
checks.append(chk("C1", "출처 있음", "x", "고정(F) 항목: 값은 템플릿이 정한다"))
elif srcs:
checks.append(chk("C1", "출처 있음", "g", f"값의 출처 기록이 {len(srcs)}건 있다", pos=srcs[0][:80]))
else:
checks.append(chk("C1", "출처 있음", "n", "값의 출처 기록이 없다 (일반 출처: " + " · ".join(m["source"]) + ")"))
# C2
rows = c2.get(b)
if rows:
bad = [r for r in rows if not r["same"]]
src0 = rows[0]["src"]
if bad:
d = "; ".join(f"{'.'.join(str(x) for x in r['path'])}: 현재 {r['current']} ≠ 표 {r['candidate']}" for r in bad[:3])
checks.append(chk("C2", "도면 재대조", "b", f"표 후보 {len(rows)}건 중 {len(bad)}건이 다르다 — {d}", pos=bad[0]["src"], strength="S"))
else:
checks.append(chk("C2", "도면 재대조", "g", f"표 후보 {len(rows)}건이 현재 값과 모두 같다", pos=src0, strength="S"))
elif not texts and "표" in m["source"]:
checks.append(chk("C2", "도면 재대조", "n", "도면 문자 색인이 없어 표와 대조하지 못했다"))
elif texts and "표" in m["source"] and not tables_ok:
checks.append(chk("C2", "도면 재대조", "n", "도면에서 표를 인식하지 못했다"))
else:
checks.append(chk("C2", "도면 재대조", "x", "표 후보가 없는 묶음(표 밖 치수는 C단계 몫)"))
# C2b
ac = anchor_check(arows, amiss, f"{which}:{key}.") if arows else None
if ac:
checks.append(ac)
# C4
gate = next((g for g, bl in ((g, bundles_of_gate(g)) for g in ("G2", "G3", "G4")) if (which, key) in bl), None)
if gate and gate in pairs:
bad, soft, ok_n = [], [], 0
for label, mark, got, want in pairs[gate]:
if want is None:
continue
if got == want:
ok_n += 1
continue
pf = _planned(expected, gate, mark)
(soft if pf else bad).append(f"{mark} {label} {got} ≠ 재료표 {want}" + (f" (계획 FAIL: {pf.get('approved', '')})" if pf else ""))
if bad:
checks.append(chk("C4", "내부 정합", "b", "수량이 재료표와 다르다 — " + "; ".join(bad[:3]), strength="C"))
elif soft:
checks.append(chk("C4", "내부 정합", "w", "수량이 재료표와 다르지만 계획된 예외다 — " + "; ".join(soft[:3]), strength="C"))
else:
checks.append(chk("C4", "내부 정합", "g", f"생성 목록 수량 {ok_n}건이 재료표 수량과 같다 (위치·좌표는 확인하지 않음)", strength="C"))
else:
checks.append(chk("C4", "내부 정합", "x", "수량 대조 대상이 아니다"))
# C5
if which == "expected" and key in ("G1", "G2", "G3", "G4", "G5"):
es = _src_texts(value)
ps = {t for k in params for t in _src_texts(params[k])}
if not es:
checks.append(chk("C5", "params와 독립", "n", "expected에 출처 기록이 없어 독립 입력인지 알 수 없다"))
elif any(len(t) > 12 and t in ps for t in es):
checks.append(chk("C5", "params와 독립", "w", "expected의 출처 문구가 params와 같다 — 독립 입력인지 확인"))
else:
checks.append(chk("C5", "params와 독립", "g", "expected의 출처 문구가 params와 다르다"))
else:
checks.append(chk("C5", "params와 독립", "x", "해당 없음"))
# C6
bad6, warn6 = [], []
for path, v in _walk(value):
name = path[-1] if path else ""
if isinstance(v, float) and (math.isnan(v) or math.isinf(v)):
bad6.append(f"{'.'.join(path)} = {v}")
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:
bad6.append(f"{'.'.join(path)} = {v} (0 이하)")
elif Z_KEYS.match(name) and not (Z_RANGE[0] <= v <= Z_RANGE[1]):
warn6.append(f"{'.'.join(path)} = {v} (EL 범위 밖)")
if bad6:
checks.append(chk("C6", "범위·단위", "b", "; ".join(bad6[:3])))
elif warn6:
checks.append(chk("C6", "범위·단위", "w", "; ".join(warn6[:3])))
else:
checks.append(chk("C6", "범위·단위", "g", "크기·EL이 상식 범위 안에 있다"))
# C7
ofix = origin_fixed.get(b)
logged = [e for e in edit_log if e.get("section") == b]
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", "템플릿 원본 기록이 없어 고정 항목을 비교하지 못했다"))
elif m["origin"]:
checks.append(chk("C7", "변수 등급 위반", "g", "고정(F) 항목이 템플릿 원본과 같다", strength="F"))
elif logged:
checks.append(chk("C7", "변수 등급 위반", "w", f"고정(F) 항목이 원본과 다르다 — 사유 기록: {logged[-1]['reason']}"))
else:
checks.append(chk("C7", "변수 등급 위반", "b", "고정(F) 항목이 원본과 다르고 사유 기록이 없다"))
elif m["fixed_fields"] and ofix is not None and current_fixed is not None:
diff = [p for p in set(ofix) | set(current_fixed.get(b, {})) if ofix.get(p) != current_fixed.get(b, {}).get(p)]
if not diff:
checks.append(chk("C7", "변수 등급 위반", "g", "고정 필드가 템플릿 원본과 같다"))
elif logged:
checks.append(chk("C7", "변수 등급 위반", "w", f"고정 필드({', '.join(sorted(diff))})가 원본과 다르다 — 사유: {logged[-1]['reason']}"))
else:
checks.append(chk("C7", "변수 등급 위반", "b", f"고정 필드({', '.join(sorted(diff))})가 원본과 다르고 사유 기록이 없다"))
else:
checks.append(chk("C7", "변수 등급 위반", "x", "고정 필드 비교 대상이 없다"))
# C8
checks.append(chk("C8", "재생성 일치", "x", "생성 스크립트가 앱에 연결되어 있지 않다 (재생성 일치는 독립성이 낮아 근거로 쓰지 않는다)" if m["grade"] == "V3"
else "V3 묶음이 아니다"))
# C9
rel = cons_by.get(b, [])
badc = [r for r in rel if not r["ok"]]
if badc:
d = "; ".join(f"{r['text']}: params {r['p']} = {r['pv']:g} ≠ expected {r['e']} = {r['ev']:g}" if isinstance(r["pv"], float) and isinstance(r["ev"], float)
else f"{r['text']}: {r['pv']} / {r['ev']}" for r in badc[:3])
checks.append(chk("C9", "params↔expected 일관성", "b", f"서로 같아야 할 값 {len(rel)}건 중 {len(badc)}건이 다르다 — {d}"))
elif rel:
checks.append(chk("C9", "params↔expected 일관성", "g", f"서로 같아야 할 값 {len(rel)}건이 params와 expected에서 같다 (같은 도면 읽기에서 나왔다면 독립 근거는 아님)"))
else:
checks.append(chk("C9", "params↔expected 일관성", "x", "연동 값 규칙이 없는 묶음"))
# C3b: fields of a changed bundle that still equal the creation snapshot
checks.append(stale_fields_check(which, key, value, origin_values, m))
# C3 (needs C2 / C4 results)
strong = any(c["id"] in ("C2", "C2b") and c["st"] == "g" for c in checks)
if m["grade"] == "F":
checks.append(chk("C3", "템플릿 원본 잔존", "x", "고정(F) 항목은 C7에서 본다"))
elif m.get("origin") is None:
checks.append(chk("C3", "템플릿 원본 잔존", "x", "템플릿 복사 기록이 없어 원본과 비교하지 못했다"))
elif m["origin"]:
checks.append(chk("C3", "템플릿 원본 잔존", "g" if strong else "n",
"원본과 같지만 도면(표·표 밖 치수)과 일치한다" if strong else "현재 값이 템플릿 원본 그대로이고 새 도면 근거가 없다"))
else:
checks.append(chk("C3", "템플릿 원본 잔존", "g", "원본과 다른 값이 들어 있다"))
order = ("C1", "C2", "C2b", "C3", "C3b", "C4", "C5", "C6", "C7", "C8", "C9")
checks.sort(key=lambda c: order.index(c["id"]))
st = combine(checks, m, (which, key) in DECISION_KEYS)
out[b] = {"st": st, "checks": checks, "summary": summarize(st, checks)}
return out
def combine(checks, m, decision):
by = {c["id"]: c for c in checks}
if decision:
return "x"
if any(c["st"] == "b" for c in checks):
return "b"
if by["C3"]["st"] == "n":
return "n"
warn = any(c["st"] == "w" for c in checks)
if m["grade"] == "F":
c7 = by["C7"]
return "w" if warn else ("g" if c7["st"] == "g" else "n")
if any(c["strength"] == "S" and c["st"] == "g" for c in checks):
return "w" if warn else "g"
if any(c["strength"] == "C" and c["st"] in ("g", "w") for c in checks):
return "w"
return "n"
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 "앱이 독립적으로 대조할 근거가 없다 — 도면과 직접 대조한 뒤 사유와 함께 확정"
def counts(results, required):
"""Per-status counts of the required bundles."""
c = {"g": 0, "w": 0, "b": 0, "n": 0, "x": 0}
for b in required:
r = results.get(b)
if r:
c[r["st"]] += 1
return c