Files
drawing3d-web/check.sh
T
오문성andClaude Haiku 5.5 9068b6c7f2 공개용 정리본: 1차 제출본(도면 좌표·과업명 제거)으로 새 이력 시작 (SPEC 34장)
- 정리는 코치 세션이 저장소 밖 도구로 수행, 하이쿠 세션이 커밋.
- 옛 81커밋은 로컬에만 보존. 기록은 evidence/gitlog_v1.txt.
- F13 기준 변경: .gitignore 에 private/*.dwg·private/full.dxf 제외 (SPEC 34장 끝 문장).
- check.sh 결과: 결과: PASS 151 / FAIL 0 / SKIP 3 (검사 범위 F01~F46)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-27 14:27:50 +09:00

1088 lines
75 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# check.sh — drawing3d-web 합격 판정 (설계 단계에서 작성, 하이쿠는 수정 금지)
# 사용: bash check.sh → 전체 기능 검사
# bash check.sh F06 → F01~F06 만 검사, 뒤 기능은 SKIP
# 판정: FAIL 이 0 이면 종료 코드 0
cd "$(dirname "$0")" || exit 2
python3 - "$@" <<'PY'
import json, math, os, re, sys
ORDER = ["F01", "F02", "F03", "F04", "F05", "F06", "F07", "F08", "F09", "F10", "F11", "F12", "F13", "F14", "F15", "F16", "F17", "F18", "F19", "F20", "F21", "F22", "F23", "F24", "F25", "F26", "F27", "F28", "F29", "F30", "F31", "F32", "F33", "F34", "F35", "F36", "F37", "F38", "F39", "F40", "F41", "F42", "F43", "F44", "F45", "F46"]
upto = sys.argv[1].upper() if len(sys.argv) > 1 else ORDER[-1]
if upto not in ORDER:
print("사용법: bash check.sh [F01~F46]")
sys.exit(2)
LIMIT = ORDER.index(upto)
TOL = 1.0
count = {"PASS": 0, "FAIL": 0, "SKIP": 0}
def report(fid, ok, msg):
if ORDER.index(fid) > LIMIT:
tag = "SKIP"
else:
tag = "PASS" if ok else "FAIL"
count[tag] += 1
print(f"[{tag}] {fid} {msg}")
def skip(fid, msg):
count["SKIP"] += 1
print(f"[SKIP] {fid} {msg}")
def read(p):
try:
with open(p, encoding="utf-8") as f:
return f.read()
except OSError:
return None
def near(a, b):
try:
return abs(float(a) - float(b)) <= TOL
except (TypeError, ValueError):
return False
# ---- F01: 파일, 스크립트 순서, 라이브러리 0 ----
html, viewer, model_txt = read("index.html"), read("viewer.js"), read("model.js")
report("F01", html is not None and viewer is not None and model_txt is not None,
"index.html, viewer.js, model.js 가 있다")
order_ok = False
if html:
srcs = re.findall(r'<script[^>]*src\s*=\s*["\']([^"\']+)["\']', html)
order_ok = "model_extracted.js" in srcs and "viewer.js" in srcs and srcs.index("model_extracted.js") < srcs.index("viewer.js") and "model.js" not in srcs
report("F01", order_ok, "index.html 이 model_extracted.js(도면에서 만든 모델) 다음에 viewer.js 를 읽는다 (30장)")
bad = []
for name, txt in (("index.html", html), ("viewer.js", viewer), ("model.js", model_txt)):
for pat in ("http://", "https://", "import ", "require("):
if txt and pat in txt.replace("http://www.w3.org/", ""):
bad.append(f"{name}:{pat}")
report("F01", not bad, "외부 라이브러리·URL 흔적 없음" + (f" ({', '.join(bad)})" if bad else ""))
# ---- 함수 이름 (SPEC 7장) ----
def has_func(name):
return bool(viewer) and re.search(r"\bfunction\s+" + name + r"\b|\b" + name + r"\s*=", viewer) is not None
FUNCS = {
"F03": ["facesBox", "rotatePoint", "project", "shadeColor", "sortFaces", "draw"],
"F04": ["onMouseDown", "onMouseMove", "onMouseUp"],
"F05": ["onWheel"],
"F07": ["facesDisc", "buildFaces"],
"F08": ["facesRing"],
"F09": ["facesPipe"],
"F10": ["facesStair"],
"F11": ["facesFill"],
"F13": ["onSplitDown", "onSplitMove", "onSplitUp"],
"F14": ["fitDrawing", "onDrawWheel", "onDrawDown", "onDrawMove", "onDrawUp"],
"F15": ["sectionPoly", "planPoly", "buildHotspots", "selectPart"],
"F19": ["applyDrawView"],
"F20": ["viewHotspots"],
"F21": ["facesDiscHoles"],
"F22": ["panView", "zoomAt", "resetView"],
"F23": ["buildWalkPath", "walkerAt", "facesPerson", "animate"],
"F24": ["facesPrism", "facesZone", "facesLadder"],
"F25": ["bottomHotspots"],
"F26": ["holePolygon"],
"F27": ["facesObox"],
"F28": ["facesRailing"],
"F29": ["upperHotspots"],
"F30": ["teamAt"],
"F31": ["stairProfile"],
}
report("F02", True, "삼각형 화면은 수석 육안 확인 (기계 검사 없음)")
for fid, names in FUNCS.items():
missing = [n for n in names if not has_func(n)]
report(fid, not missing, "viewer.js 함수: " + ", ".join(names) + (f" — 없음: {', '.join(missing)}" if missing else ""))
# ---- model.js 읽기 ----
MODEL, parse_err = None, ""
if model_txt:
m = re.match(r"\s*const\s+MODEL\s*=\s*(.*?);?\s*$", model_txt, re.S)
if not m:
parse_err = "첫머리가 const MODEL = 가 아님"
else:
try:
MODEL = json.loads(m.group(1))
except json.JSONDecodeError as e:
parse_err = f"순수 JSON 아님: {e}"
report("F06", MODEL is not None, "model.js 가 const MODEL = 순수 JSON ; 꼴" + (f" ({parse_err})" if parse_err else ""))
parts = MODEL.get("parts", []) if isinstance(MODEL, dict) else []
parts = [p for p in parts if isinstance(p, dict)]
def kind(k):
return [p for p in parts if p.get("kind") == k]
report("F06", isinstance(MODEL, dict) and MODEL.get("units") == "mm", "units 가 mm")
ids = [p.get("id") for p in parts]
report("F06", len(ids) == len(set(ids)) and None not in ids, "부재 id 가 모두 있고 겹치지 않음")
# ---- F06: 벽·슬래브·패드·하부·바닥콘크리트 (SPEC 4.1~4.3) ----
def check_part(fid, k, typ, fields, label):
ps = kind(k)
ok = len(ps) == 1 and ps[0].get("type") == typ and all(near(ps[0].get(f), v) for f, v in fields.items())
got = {f: ps[0].get(f) for f in fields} if len(ps) == 1 else f"{len(ps)}개"
report(fid, ok, f"{label}: {typ} {fields}" + ("" if ok else f" — 실제 {got}"))
check_part("F06", "wall", "ring", {"cx": 0, "cy": 0, "z0": -70200, "z1": -2000, "r_out": 5500, "r_in": 4850}, "벽체 (윗면 -2000, SPEC 17.1 정정)")
walls = kind("wall")
report("F06", len(walls) == 1 and near(walls[0].get("alpha"), 0.25), "벽체 alpha 0.25")
check_part("F06", "pad", "disc", {"cx": 0, "cy": 0, "z0": -66200, "z1": -66100, "r": 4750}, "19B 패드")
check_part("F06", "bottom", "disc", {"cx": 0, "cy": 0, "z0": -70200, "z1": -69000, "r": 4850}, "하부 슬래브")
check_part("F06", "lean", "disc", {"cx": 0, "cy": 0, "z0": -70300, "z1": -70200, "r": 5500}, "바닥콘크리트")
TOPS = [-7400 - 3300 * i for i in range(18)] + [-66200] # 1B~18B, 19B
slabs = sorted(kind("slab"), key=lambda p: -float(p.get("z1", 0) or 0))
report("F06", len(slabs) == 19, f"중간 슬래브 19장 (실제 {len(slabs)})")
bad = []
for i, (p, top) in enumerate(zip(slabs, TOPS)):
if not (p.get("type") == "disc" and near(p.get("z1"), top) and near(p.get("z0"), top - 300)
and near(p.get("r"), 4850) and near(p.get("cx"), 0) and near(p.get("cy"), 0)):
bad.append(f"{i + 1}B")
report("F06", len(slabs) == 19 and not bad, "슬래브 상면 z·두께 300·r 4850 이 SPEC 4.2 표와 같다" + (f" — 틀림: {', '.join(bad)}" if bad else ""))
# ---- F09: TBM 터널 (SPEC 4.5) ----
tun = kind("tunnel")
t = tun[0] if len(tun) == 1 else {}
start_r = math.hypot(float(t.get("x", 0) or 0), float(t.get("y", 0) or 0)) if t else 0
start_a = math.degrees(math.atan2(float(t.get("y", 0) or 0), float(t.get("x", 0) or 0))) % 360 if t else 0
ok = (len(tun) == 1 and t.get("type") == "pipe" and near(t.get("z"), -65050) and near(t.get("angle_deg"), 0)
and near(t.get("length"), 20650) and near(t.get("r_out"), 1500) and near(t.get("r_in"), 1300))
report("F09", ok, "터널 pipe: z -65050, 0도(SPEC 22장), 길이 20650, r_out 1500, r_in 1300")
report("F09", len(tun) == 1 and near(start_r, 4850) and min(start_a, 360 - start_a) <= 0.1,
f"터널 시작점이 벽 안쪽면(r 4850) 0도 위 (실제 r {start_r:.0f}, {start_a:.1f}도)")
report("F09", len(tun) == 1 and near(float(t.get("z", 0) or 0) - float(t.get("r_out", 0) or 0), -66550),
"세그먼트 바닥 외면 = 19B 슬래브 하면 - 50 = -66550")
# ---- F10: 계단 18런 (SPEC 4.4) ----
stairs = sorted(kind("stair"), key=lambda p: -float(p.get("z_high", 0) or 0))
report("F10", len(stairs) == 18, f"계단 18런 (실제 {len(stairs)})")
bad = []
for i, p in enumerate(stairs[:18]):
n = p.get("risers")
need_n = 17 if i < 17 else 14
try:
rise = float(p.get("first_riser") or 0) + float(p.get("riser") or 0) * (int(n or 1) - 1)
except (TypeError, ValueError):
bad.append(f"런{i + 1}?")
continue
ok = (p.get("type") == "stair" and n == need_n and near(p.get("first_riser"), 100) and near(p.get("riser"), 200)
and near(p.get("tread"), 300) and near(p.get("z_high"), TOPS[i]) and near(p.get("z_low"), TOPS[i + 1])
and near(rise, TOPS[i] - TOPS[i + 1]) and near(abs(float(p.get("y1", 0) or 0) - float(p.get("y0", 0) or 0)), 1000))
if not ok:
bad.append(f"런{i + 1}")
report("F10", len(stairs) == 18 and not bad, "런마다 단 수 17(마지막 14)·첫 단 100·단 200·디딤판 300·폭 1000·단 높이 합 = 층고" + (f" — 틀림: {', '.join(bad)}" if bad else ""))
dirs = [p.get("dir") for p in stairs]
report("F10", len(dirs) == 18 and all(d in (1, -1) for d in dirs) and all(dirs[i] == -dirs[i + 1] for i in range(17)),
"런 방향 dir 이 +1/-1 로 번갈아 바뀐다")
out = []
for i, p in enumerate(stairs[:18]):
try:
xs = [float(p["x_top"]), float(p["x_top"]) + p["dir"] * (p["risers"] - 1) * float(p["tread"])]
ys = [float(p["y0"]), float(p["y1"])]
if any(math.hypot(x, y) > 4850 + TOL for x in xs for y in ys):
out.append(f"런{i + 1}")
except (KeyError, TypeError, ValueError):
out.append(f"런{i + 1}?")
report("F10", len(stairs) == 18 and not out, "계단 네 모서리가 벽 안쪽(r 4850) 안에 있다" + (f" — 벗어남: {', '.join(out)}" if out else ""))
# ---- F11: 터널 채움 (SPEC 4.5 표) ----
fills = kind("fill")
want = [(-1300, -1175, 0), (-1175, -1100, 0), (-1100, -1050, 400)]
got = sorted((float(p.get("v0", 0) or 0), float(p.get("v1", 0) or 0), float(p.get("half_width", 0) or 0)) for p in fills)
report("F11", len(fills) == 3 and all(near(a, c) and near(b, d) and near(w, e) for (a, b, w), (c, d, e) in zip(got, want)),
f"채움 3개 v 범위·폭: 잡석, 패드 2단, 패드 1단(폭 800) (실제 {got})")
same_axis = len(tun) == 1 and len(fills) == 3 and all(
p.get("type") == "fill" and near(p.get("r"), 1300) and all(near(p.get(k), t.get(k)) for k in ("x", "y", "z", "angle_deg", "length"))
for p in fills)
report("F11", same_axis, "채움 3개가 터널과 같은 축·길이, r 1300")
top = max((float(p.get("z", 0) or 0) + float(p.get("v1", 0) or 0) for p in fills), default=None)
report("F11", top is not None and near(top, -66100), f"터널 패드 상면 = 19B 패드 상면 -66100 (실제 {top})")
# ---- F12: README ----
readme = read("README.md")
report("F12", bool(readme) and "index.html" in readme and "🔒" in readme, "README.md 에 index.html 구동 설명과 🔒 표기")
# ---- F13~F15: 도면 분할·클릭 연결 (SPEC 10장) ----
import subprocess
def has_id(i):
return bool(html) and re.search(r'id\s*=\s*["\']' + i + r'["\']', html) is not None
report("F13", all(has_id(i) for i in ("drawPane", "splitter", "viewPane")), "index.html 에 #drawPane, #splitter, #viewPane")
gi = read(".gitignore") or ""
report("F13", "private/*.dwg" in gi.split() and "private/full.dxf" in gi.split(), ".gitignore 에 원본 DWG·full.dxf 제외 (SPEC 34장 — private/ 의 정리본 도면은 커밋)")
try:
tracked = subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.split("\n")
except OSError:
tracked = ["?"]
leak = [t for t in tracked if t.startswith("private/") or t.lower().endswith((".svg", ".pdf", ".dwg"))]
report("F13", tracked != ["?"] and not leak, "도면 파일이 커밋되지 않음 (private/, .svg, .pdf, .dwg)" + (f" — 있음: {leak}" if leak else ""))
report("F14", all(has_id(i) for i in ("drawStage", "drawing", "hotspots")) and "private/drawing.svg" in ((html or "") + (viewer or "") + (model_txt or "")),
"#drawStage, img#drawing, svg#hotspots, private/drawing.svg 참조")
d = MODEL.get("drawing", {}) if isinstance(MODEL, dict) else {}
sec, pln = d.get("section", {}) or {}, d.get("plan", {}) or {}
ok = (d.get("src") == "private/drawing.svg" and d.get("viewBox") == [0, 0, 443, 443] and d.get("fit") == [118, 160, 245, 250]
and near(sec.get("x0"), 139.0) and near(sec.get("y0"), 174.08) and near(sec.get("mm_per_unit"), 1000)
and near(pln.get("cx"), 227.51) and near(pln.get("cy"), 177.1) and near(pln.get("mm_per_unit"), 1000))
report("F15", ok, "MODEL.drawing 값이 SPEC 10.3 과 같다")
# ---- F16~F17: 제출 문서 (SPEC 11장) ----
import glob
GUIDE_PAGES = ["purpose", "plan", "scope", "run", "control", "method", "limits", "history"]
ghtml = {p: (read(f"guide/{p}.html") or "") for p in ["index"] + GUIDE_PAGES}
guide = (read("구동설명서.md") or "") + "\n".join(ghtml.values())
heads = [l for l in guide.splitlines() if l.startswith("## ")] + re.findall(r"<h[12][^>]*>(.*?)</h[12]>", guide, re.S)
want = ["목적", "구동", "조작"]
miss = [w for w in want if not any(w in h for h in heads)]
report("F16", bool(guide) and not miss, "구동설명서에 제목 목적·구동·조작 (SPEC 18.3)" + (f" — 없음: {', '.join(miss)}" if miss else ""))
rd = readme or ""
rheads = [l for l in rd.splitlines() if l.startswith("## ")]
rwant = ["파일 구조", "실행", "데이터", "코드", "그리기", "도면 연결", "검사"]
rmiss = [w for w in rwant if not any(w in h for h in rheads)]
report("F17", "🔒" in rd and not rmiss, "README.md 에 🔒 와 ## 제목 7개(파일 구조~검사)" + (f" — 없음: {', '.join(rmiss)}" if rmiss else ""))
allf = sorted({n for fid2, names in FUNCS.items() if ORDER.index(fid2) <= LIMIT for n in names} | {"facesBox"})
fmiss = [n for n in allf if n not in rd]
report("F17", not fmiss, f"README.md 에 함수 이름 {len(allf)}개 전부" + (f" — 없음: {', '.join(fmiss)}" if fmiss else ""))
# ---- F18: 구동설명서 HTML 위키 (SPEC 12장) ----
KEEP = ["index", "purpose", "run", "control"]
gmiss = [p for p in KEEP if not ghtml.get(p)]
report("F18", not gmiss and bool(read("guide/guide.css")), "guide/ 목차 + 목적·구동·조작 + guide.css" + (f" — 없음: {', '.join(gmiss)}" if gmiss else ""))
bad = []
for p in KEEP:
t = ghtml.get(p, "")
links = set(re.findall(r'href\s*=\s*["\']([^"\'#]+)', t))
others = {l for l in links if l.replace(".html", "") in GUIDE_PAGES and l.replace(".html", "") != p}
if not (t and "../index.html" in links and "돌아가기" in t and re.search(r"<h1", t) and (p == "index" or len(others) >= 2)):
bad.append(p)
report("F18", not gmiss and not bad, "남는 쪽: h1, 「돌아가기」(../index.html), 다른 쪽 링크 2개 이상" + (f" — 틀림: {', '.join(bad)}" if bad else ""))
toc = ghtml.get("index", "")
report("F18", all(f"{p}.html" in toc for p in KEEP[1:]), "목차가 목적·구동·조작으로 이어진다")
report("F18", read("구동설명서.md") is None, "구동설명서.md 는 지워졌다(내용은 guide/ 로 옮김)")
report("F18", bool(html) and re.search(r'id\s*=\s*["\']guideLink["\']', html) is not None and "guide/index.html" in html, "index.html 에 a#guideLink → guide/index.html")
# ---- F19~F20: 선명한 도면, 모든 그림 연결 (SPEC 13장) ----
prep = read("tools/prep_drawing.py") or ""
report("F19", "non-scaling-stroke" in prep and "import " in prep and "requests" not in prep, "tools/prep_drawing.py 가 있고 non-scaling-stroke 를 건다")
pd = read("private/drawing.svg")
report("F19", pd is None or "non-scaling-stroke" in pd, "private/drawing.svg 가 있으면 1px 선 처리본이다")
report("F19", bool(viewer) and "scale(" not in re.sub(r"//.*", "", viewer), "viewer.js 에 CSS scale( 없음 (실제 크기로 확대)")
WANT_VIEWS = [{"id": "secA", "type": "section", "label": "단면 A-A", "x0": 139.0, "y0": 174.08}, {"id": "secB", "type": "section", "label": "단면 B-B", "x0": 179.01, "y0": 174.08}, {"id": "secA2", "type": "section_break", "label": "단면 A-A (4B~15B 생략)", "x0": 263.41, "y0_upper": 173.04, "y0_lower": 140.04, "z_upper_min": -17600, "z_lower_max": -53600}, {"id": "secB2", "type": "section_break", "label": "단면 B-B (4B~15B 생략)", "x0": 284.23, "y0_upper": 173.04, "y0_lower": 140.04, "z_upper_min": -17600, "z_lower_max": -53600}, {"id": "plan1", "type": "plan", "label": "평면도", "cx": 227.51, "cy": 177.1, "tunnel": True, "rot_deg": -30.3}, {"id": "plan2", "type": "plan", "label": "평면도 2", "cx": 227.51, "cy": 197.1, "tunnel": False, "rot_deg": -30.3}, {"id": "slabOdd", "type": "slabplan", "label": "중간슬래브 (홀수층)", "cx": 227.51, "cy": 226.31, "kind": "slab", "z1": [-7400, -14000, -20600, -27200, -33800, -40400, -47000, -53600, -60200], "tunnel": False}, {"id": "slabEven", "type": "slabplan", "label": "중간슬래브 (짝수층)", "cx": 252.27, "cy": 226.31, "kind": "slab", "z1": [-10700, -17300, -23900, -30500, -37100, -43700, -50300, -56900], "tunnel": False}, {"id": "slab18", "type": "slabplan", "label": "중간슬래브 (18B)", "cx": 277.02, "cy": 226.31, "kind": "slab", "z1": [-63500], "tunnel": False}, {"id": "slab19", "type": "slabplan", "label": "중간슬래브 (19B)", "cx": 227.51, "cy": 247.32, "kind": "slab", "z1": [-66200], "tunnel": True}, {"id": "slab20", "type": "slabplan", "label": "하부슬래브 (20B)", "cx": 252.27, "cy": 247.32, "kind": "bottom", "z1": [-69000], "tunnel": False}]
def same(a, b):
if isinstance(b, bool) or isinstance(b, str):
return a == b
if isinstance(b, list):
return isinstance(a, list) and len(a) == len(b) and all(same(x, y) for x, y in zip(a, b))
return near(a, b)
got_views = {v.get("id"): v for v in (d.get("views") or []) if isinstance(v, dict)}
vbad = [w["id"] for w in WANT_VIEWS if not (w["id"] in got_views and all(same(got_views[w["id"]].get(k2), v2) for k2, v2 in w.items() if k2 != "label"))]
report("F20", len(got_views) == len(WANT_VIEWS) and not vbad, f"MODEL.drawing.views {len(WANT_VIEWS)}개가 SPEC 13.2 표와 같다" + (f" — 틀림: {', '.join(vbad)}" if vbad else ""))
# ---- F21~F23: 슬래브 구멍, 계단 방향, 카메라, 사람 (SPEC 15장) ----
WANT_HOLES = [['slab', -7400, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -14000, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -20600, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -27200, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -33800, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -40400, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -47000, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -53600, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -60200, [[-2100, 2100, 0, 2200, 8], [-1250, 1250, -2200, -1000, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -10700, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -17300, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -23900, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -30500, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -37100, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -43700, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -50300, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -56900, [[-2100, 2100, -2200, 0, 8], [-1250, 1250, 1000, 2200, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -63500, [[-2100, 2100, -1000, 0, 4], [-750, 750, 3650, 4790, 4], [2079, 3072, 3528, 4304, 4]]], ['slab', -66200, [[-750, 750, 3650, 4790, 4]]], ['pad', -66100, [[-750, 750, 3650, 4750, 4]]], ['bottom', -69000, []]]
def hole_bboxes(p):
out = []
for poly in (p.get("holes") or []):
try:
xs = [float(q[0]) for q in poly]; ys = [float(q[1]) for q in poly]
out.append([min(xs), max(xs), min(ys), max(ys), len(poly)])
except (TypeError, ValueError, IndexError):
out.append(None)
return sorted(out, key=lambda b: b or [0])
hbad = []
for kd, z1, wb in WANT_HOLES:
ps = [p for p in parts if p.get("kind") == kd and near(p.get("z1"), z1)]
gb = hole_bboxes(ps[0]) if len(ps) == 1 else None
if gb is None or not all(any(g and all(near(a, b) for a, b in zip(g, w)) for g in gb) for w in wb) or (not wb and gb):
hbad.append(f"{kd}{z1}")
report("F21", not hbad, "슬래브·패드 holes 에 SPEC 15.1 표의 구멍이 모두 있다 (구멍별 범위·꼭짓점 수)" + (f" — 틀림: {', '.join(hbad[:6])}" if hbad else ""))
sbad = []
for i, p in enumerate(stairs[:18]):
odd = i % 2 == 0
if not (near(p.get("x_top"), 2100 if odd else -2100) and p.get("dir") == (-1 if odd else 1)
and near(p.get("y0"), 0 if odd else -1000) and near(p.get("y1"), 1000 if odd else 0)):
sbad.append(f"런{i + 1}")
report("F21", len(stairs) == 18 and not sbad, "계단 방향: 홀수 런 x_top 2100·dir -1·Y 0~1000, 짝수 런 x_top -2100·dir +1·Y -1000~0" + (f" — 틀림: {', '.join(sbad[:6])}" if sbad else ""))
report("F22", bool(html) and re.search(r'id\s*=\s*["\']resetView["\']', html) is not None, "index.html 에 button#resetView")
report("F22", bool(viewer) and re.search(r"ZOOM_MAX\s*=\s*40\b", viewer) is not None and "contextmenu" in viewer, "3D 배율 상한 40, 오른쪽 클릭 메뉴 막기")
report("F23", bool(html) and re.search(r'id\s*=\s*["\']walkToggle["\']', html) is not None, "index.html 에 button#walkToggle")
report("F23", bool(viewer) and "requestAnimationFrame" in viewer and "ff8c1a" in viewer.lower(), "requestAnimationFrame 과 사람 색 #ff8c1a")
# ---- F24~F25: 바닥부 (SPEC 16장) ----
def bb_poly(poly):
try:
xs = [float(q[0]) for q in poly]; ys = [float(q[1]) for q in poly]
return [min(xs), max(xs), min(ys), max(ys)]
except (TypeError, ValueError, IndexError):
return None
weirs = sorted([[*(bb_poly(p.get("polygon")) or [0, 0, 0, 0]), float(p.get("z0", 0) or 0), float(p.get("z1", 0) or 0)] for p in kind("weir") if p.get("type") == "prism"])
want_w = sorted([[1400, 1600, -4580, -500, -69000, -67000], [1400, 1600, 500, 4580, -69000, -67000], [1400, 1600, -500, 500, -69000, -67500]])
report("F24", len(weirs) == 3 and all(all(near(a, b) for a, b in zip(g, w)) for g, w in zip(weirs, want_w)), f"월류벽 prism 3개가 SPEC 16.1 과 같다 (실제 {len(weirs)}개)")
pits = sorted([[float(p.get(f, 0) or 0) for f in ("x_min", "x_max", "z0", "z1", "r")] for p in kind("pit") if p.get("type") == "zone"])
want_p = sorted([[-4850, 1400, -69000, -67000, 4850], [1600, 4850, -69000, -67000, 4850]])
report("F24", len(pits) == 2 and all(all(near(a, b) for a, b in zip(g, w)) for g, w in zip(pits, want_p)), "집수조·침사조 zone 2개가 SPEC 16.1 과 같다")
lad = kind("ladder")
report("F24", len(lad) == 1 and lad[0].get("type") == "ladder" and all(near(lad[0].get(f), v) for f, v in
{"x0": 4668, "x1": 4708, "yc": 0, "width": 400, "z0": -68700, "z1": -66200, "rung": 300}.items()), "사다리 ladder 1개가 SPEC 16.1 과 같다")
def has_hole(kd, z1, w):
ps = [p for p in parts if p.get("kind") == kd and near(p.get("z1"), z1)]
return len(ps) == 1 and any((bb_poly(h) or [None]) and all(near(a, b) for a, b in zip(bb_poly(h) or [], w)) for h in (ps[0].get("holes") or []))
report("F24", has_hole("slab", -66200, [3850, 4820, -500, 500]) and has_hole("pad", -66100, [3850, 4720, -500, 500]), "19B 슬래브·패드에 침사구 구멍")
# ---- F26~F29: 상부 구조·환기구·난간·배관 (SPEC 17장) ----
ts = kind("topslab")
circ = sorted([tuple(h["circle"]) for h in (ts[0].get("holes") or []) if isinstance(h, dict) and "circle" in h]) if len(ts) == 1 else []
report("F26", len(ts) == 1 and ts[0].get("type") == "disc" and all(near(ts[0].get(f), v) for f, v in {"r": 5500, "z0": -2000, "z1": -1500, "cx": 0, "cy": 0}.items())
and len(circ) == 2 and all(all(near(a, b) for a, b in zip(c, w)) for c, w in zip(circ, [(0, 0, 450), (0, 4405, 450)])), "상부 슬래브 r 5500, z -2000~-1500, 원 구멍 2개")
necks = sorted([bb_poly(p.get("polygon")) or [0, 0, 0, 0] for p in kind("neck") if p.get("type") == "prism" and near(p.get("z0"), -1500) and near(p.get("z1"), 0)])
report("F26", len(necks) == 2 and all(all(near(a, b) if abs(b) > 0 else near(a, b) for a, b in zip(g, w)) for g, w in zip(necks, [[-650, 650, -650, 650], [-650, 650, 3755, 5055]])), "맨홀 목 prism 2개 (축에 맞춘 1300 정사각형, SPEC 22장, z -1500~0)")
ob = lambda kd: sorted([[float(p.get(f, 0) or 0) for f in ("angle_deg", "s0", "s1", "t0", "t1", "z0", "z1")] for p in kind(kd) if p.get("type") == "obox"])
WANT_BRANCH = [[197.3, 4850, 8350, -1450, -1100, -4100, -2000], [197.3, 4850, 8350, -1450, 1450, -4550, -4100], [197.3, 4850, 8350, -1450, 1450, -2000, -1650], [197.3, 4850, 8350, 1100, 1450, -4100, -2000], [197.3, 8000, 8350, -1100, 1100, -4100, -2000]]
WANT_VENT = [[30.3, -1850, -1500, -10500, -4610, -4100, -2000], [30.3, -1850, 1850, -10500, -4610, -4600, -4100], [30.3, -1850, 1850, -10500, -4610, -2000, -1650], [30.3, -1850, 7504, -10500, -10150, -4100, -2000], [30.3, 1500, 1850, -6800, -4610, -4100, -2000], [30.3, 1500, 7504, -7150, -6800, -4100, -2000], [30.3, 1850, 7504, -10500, -6800, -4600, -4100], [30.3, 1850, 7504, -10500, -6800, -2000, -1650], [30.3, 7504, 7854, -10149, -7149, -2000, 0], [30.3, 7504, 11205, -10500, -10149, -4100, 0], [30.3, 7504, 11205, -10500, -6799, -4600, -4100], [30.3, 7504, 11205, -7149, -6799, -4100, 0], [30.3, 10855, 11205, -10500, -6799, -4100, 0]]
gb, gv = ob("branch"), ob("vent")
report("F27", len(gb) == len(WANT_BRANCH) and all(all(near(a, b) for a, b in zip(g, w)) for g, w in zip(gb, WANT_BRANCH)), f"분기구 obox {len(WANT_BRANCH)}개가 SPEC 17.2 와 같다 (실제 {len(gb)})")
report("F27", len(gv) == len(WANT_VENT) and all(all(near(a, b) for a, b in zip(g, w)) for g, w in zip(gv, WANT_VENT)), f"환기구 obox {len(WANT_VENT)}개가 SPEC 17.2 와 같다 (실제 {len(gv)})")
rails = kind("railing")
rz = sorted(float(p.get("z", 0) or 0) for p in rails)
want_rz = sorted([-7400, -14000, -20600, -27200, -33800, -40400, -47000, -53600, -60200, -10700, -17300, -23900, -30500, -37100, -43700, -50300, -56900, -66100])
report("F28", len(rails) == 18 and all(near(a, b) for a, b in zip(rz, want_rz)) and all(near(p.get("h"), 1100) and len(p.get("paths") or []) in (1, 4) for p in rails), "방호 난간 18장 (홀수 9·짝수 8·19B), h 1100")
pp = kind("pipe")
report("F28", len(pp) == 1 and pp[0].get("type") == "ring" and all(near(pp[0].get(f), v) for f, v in {"cx": 0, "cy": 4220, "r_out": 57, "r_in": 50, "z0": -68800, "z1": -2000}.items()), "펌프 배출관 ring")
lu = sorted([[float(p.get(f, 0) or 0) for f in ("x0", "x1", "yc", "width", "z0", "z1", "rung")] for p in kind("ladder_up") if p.get("type") == "ladder"])
report("F28", len(lu) == 2 and all(all(near(a, b) for a, b in zip(g, w)) for g, w in zip(lu, sorted([[-4622, -4582, -1260, 400, -7400, -4100, 300], [-20, 20, 0, 360, -7400, 0, 300]]))), "위쪽 사다리 2개")
# ---- F32: 구동설명서 3쪽 (SPEC 18.3) ----
gone = [p for p in ("plan", "scope", "limits", "method", "history") if read(f"guide/{p}.html") is not None]
report("F32", not gone, "지울 쪽(plan·scope·limits·method·history)이 없다" + (f" — 남음: {', '.join(gone)}" if gone else ""))
kept = "\n".join(ghtml.get(p, "") for p in KEEP)
dead = sorted({l for l in re.findall(r'href\s*=\s*["\']([^"\'#]+)', kept) if l.replace(".html", "") in ("plan", "scope", "limits", "method", "history")})
report("F32", "🔒" not in kept and not dead, "남는 쪽에 🔒 줄과 지운 쪽 링크가 없다" + (f" — 링크: {', '.join(dead)}" if dead else ""))
pp_ = ghtml.get("purpose", "")
report("F32", "두 명" in pp_ and "2명 이상" in pp_ and "유지관리 동선" in pp_ and "도면을 찾는 방향" not in kept, "목적 쪽 사람 두 명 문장, 「도면을 찾는 방향」 문장 없음")
# ---- F33~F34: 도면 추출 도구(DXF)와 대조 (SPEC 20장) ----
import tempfile
def run_extract(src_path, out_path):
try:
r = subprocess.run(["python3", "tools/extract_from_dxf.py", src_path, out_path], capture_output=True, text=True, timeout=180)
txt = read(out_path)
m = re.match(r"\s*const\s+MODEL\s*=\s*(.*?);?\s*$", txt or "", re.S)
return (json.loads(m.group(1)) if m else None), (r.stderr or "")[-300:]
except Exception as e:
return None, str(e)[:300]
tool = read("tools/extract_from_dxf.py") or ""
DXF = "private/drawing.dxf"
have_dxf = os.path.exists(DXF)
bad_nums = [n for n in ("11334", "11333", "11338", "8140", "8133", "11664", "11688", "11713", "8134", "8113", "7400", "3300") if n in tool]
bad_imp = [m for m in re.findall(r"^\s*(?:import|from)\s+([A-Za-z_][\w.]*)", tool, re.M) if m.split(".")[0] not in
("sys", "re", "math", "json", "os", "argparse", "collections", "itertools", "functools", "pathlib")]
report("F33", bool(tool) and not bad_nums and not bad_imp, "tools/extract_from_dxf.py 있음, 도면 좌표·정답 숫자·외부 라이브러리 없음"
+ (f" — 숫자: {bad_nums}" if bad_nums else "") + (f" — import: {bad_imp}" if bad_imp else ""))
TMP = tempfile.gettempdir()
EX, err = (run_extract(DXF, os.path.join(TMP, "d3w_ex.js")) if have_dxf else (None, "private/drawing.dxf 없음"))
ex_parts = EX.get("parts", []) if isinstance(EX, dict) else []
ek = lambda k_: [p for p in ex_parts if isinstance(p, dict) and p.get("kind") == k_]
report("F33", EX is not None and len(ek("wall")) == 1 and len(ek("slab")) == 19 and len(ek("railing")) >= 17,
f"도구 실행 → 벽 1·슬래브 19·난간 17 이상 (실제 {len(ek('wall'))}/{len(ek('slab'))}/{len(ek('railing'))})" + (f" — {err}" if EX is None else ""))
committed = read("model_extracted.js")
report("F33", bool(committed) and committed.strip().startswith("const MODEL"), "model_extracted.js 가 있다(커밋 대상)")
w = ek("wall")
report("F34", len(w) == 1 and abs(float(w[0].get("r_out", 0) or 0) - 5500) <= 5 and abs(float(w[0].get("r_in", 0) or 0) - 4850) <= 5, "추출 벽 반지름 = model.js (±5)")
mt = sorted([float(p.get("z1", 0) or 0) for p in kind("slab")], reverse=True)
et = sorted([float(p.get("z1", 0) or 0) for p in ek("slab")], reverse=True)
report("F34", len(et) == 19 and all(abs(a - b) <= 5 for a, b in zip(et, mt)), f"추출 슬래브 윗면 19개 = model.js (±5) (최대 차 {max([abs(a - b) for a, b in zip(et, mt)] or [0]):.0f})")
def bbox2(poly):
try:
xs = [float(q[0]) for q in poly]; ys = [float(q[1]) for q in poly]
return min(xs), max(xs), min(ys), max(ys)
except Exception:
return None
hbad, hcount = [], 0
for ep in ek("slab"):
mp = [p for p in kind("slab") if abs(float(p.get("z1", 0) or 0) - float(ep.get("z1", 0) or 0)) <= 5]
mboxes = [bbox2(h if isinstance(h, list) else []) for h in ((mp[0].get("holes") or []) if mp else [])]
for h in (ep.get("holes") or []):
hcount += 1
b = bbox2(h)
if not b or not any(m and b[0] >= m[0] - 10 and b[1] <= m[1] + 10 and b[2] >= m[2] - 10 and b[3] <= m[3] + 10 for m in mboxes):
hbad.append(ep.get("name", "?"))
report("F34", hcount >= 60 and not hbad, f"추출 구멍 {hcount}개가 모두 model.js 구멍 범위 안 (±10)" + (f" — 벗어남: {sorted(set(hbad))[:5]}" if hbad else ""))
st_n = sum(1 for p in ek("slab") for h in (p.get("holes") or []) if bbox2(h) and bbox2(h)[1] - bbox2(h)[0] > 4000)
report("F34", st_n == 18, f"계단 구멍 18개 (홀수 9·짝수 8·18B 1, 실제 {st_n})")
rbad = []
for er in ek("railing"):
mr = [p for p in kind("railing") if abs(float(p.get("z", 0) or 0) - float(er.get("z", 0) or 0)) <= 130]
ep_, mp_ = er.get("paths") or [], (mr[0].get("paths") or []) if mr else []
def pset(path):
q = [(float(x[0]), float(x[1])) for x in path]
return q[:-1] if len(q) > 1 and abs(q[0][0] - q[-1][0]) < 1 and abs(q[0][1] - q[-1][1]) < 1 else q
def same_path(a, b):
A, B = pset(a), pset(b)
return len(A) == len(B) and all(any(abs(x - u) <= 10 and abs(y - v) <= 10 for u, v in B) for x, y in A)
ok = len(ep_) == len(mp_) and all(any(same_path(a, b) for b in mp_) for a in ep_)
if not ok:
rbad.append(er.get("name", "?"))
report("F34", len(ek("railing")) >= 17 and not rbad, "추출 난간 꺾은선 = model.js (꼭짓점 ±10)" + (f" — 틀림: {rbad[:5]}" if rbad else ""))
# 변조 사본 (채점기만 아는 기준점으로 만든다)
def dxf_lines():
return (open(DXF, encoding="utf-8", errors="replace").read().replace("\r", "")).split("\n")
def ent_range(L_):
s_ = next(i for i in range(len(L_) - 3) if L_[i].strip() == "0" and L_[i + 1].strip() == "SECTION" and L_[i + 3].strip() == "ENTITIES")
e_ = next(i for i in range(s_, len(L_) - 1) if L_[i].strip() == "0" and L_[i + 1].strip() == "ENDSEC")
return s_, e_
def write_tmp(name, L_):
p_ = os.path.join(TMP, name); open(p_, "w", encoding="utf-8").write("\n".join(L_)); return p_
def sig(M):
ps = (M or {}).get("parts", [])
return sorted(round(float(p.get("z1", p.get("z", 0)) or 0)) for p in ps if isinstance(p, dict)) + sorted(round(float(p.get("r_out", 0) or 0)) for p in ps if isinstance(p, dict))
inv_ok = m2_ok = m3_ok = False
if EX is not None:
L0 = dxf_lines(); s0, e0 = ent_range(L0)
# M1: 좌표 전체 이동
L1 = list(L0)
for i in range(s0, e0, 2):
c_ = L1[i].strip()
if c_.isdigit() and 10 <= int(c_) <= 18 or c_.isdigit() and 20 <= int(c_) <= 28:
try:
L1[i + 1] = repr(float(L1[i + 1]) + (7.0 if int(c_) < 20 else 5.0))
except ValueError:
pass
E1, _ = run_extract(write_tmp("d3w_m1.dxf", L1), os.path.join(TMP, "d3w_m1.js"))
s1, s2 = sig(EX), sig(E1)
inv_ok = E1 is not None and len(s1) == len(s2) and all(abs(a - b) <= 2 for a, b in zip(s1, s2))
# M2: 1B 윗면 선(왼쪽 안쪽 벽~오른쪽 안쪽 벽, Y 8133.472)만 0.5 m 내림
L2 = list(L0); lastx = None; changed = 0
for i in range(s0, e0, 2):
c_ = L2[i].strip()
try:
if c_ == "10": lastx = float(L2[i + 1])
elif c_ == "20" and lastx is not None and min(abs(float(L2[i + 1]) - 8133.472), abs(float(L2[i + 1]) - 8133.172)) < 0.0005 and 11334.483 < lastx < 11344.283:
L2[i + 1] = repr(float(L2[i + 1]) - 0.5); changed += 1
except ValueError:
pass
if changed:
E2, _ = run_extract(write_tmp("d3w_m2.dxf", L2), os.path.join(TMP, "d3w_m2.js"))
a_ = sorted([float(p.get("z1", 0) or 0) for p in (E2 or {}).get("parts", []) if p.get("kind") == "slab"], reverse=True)
m2_ok = len(a_) == 19 and abs((et[0] - a_[0]) - 500) <= 5 and all(abs(x - y) <= 2 for x, y in zip(a_[1:], et[1:]))
# M3: 홀수층 아래 slot(구조물 계단, 그 사각형 안 선분 전부) 지움
BOX = (11663.333, 8132.066, 11665.853, 8133.296)
out_ = L0[:s0]; i = s0; ent = []
def flush(ent_):
if not ent_: return
lay_ = next((ent_[k + 1].strip() for k in range(0, len(ent_) - 1, 2) if ent_[k].strip() == "8"), "")
xs_ = [float(ent_[k + 1]) for k in range(0, len(ent_) - 1, 2) if ent_[k].strip() in ("10", "11")]
ys_ = [float(ent_[k + 1]) for k in range(0, len(ent_) - 1, 2) if ent_[k].strip() in ("20", "21")]
if lay_ == "구조물 계단" and xs_ and BOX[0] <= min(xs_) and max(xs_) <= BOX[2] and BOX[1] <= min(ys_) and max(ys_) <= BOX[3]:
return
out_.extend(ent_)
while i < e0:
if L0[i].strip() == "0" and ent:
flush(ent); ent = []
ent.extend(L0[i:i + 2]); i += 2
flush(ent); out_.extend(L0[e0:])
E3, _ = run_extract(write_tmp("d3w_m3.dxf", out_), os.path.join(TMP, "d3w_m3.js"))
cnt = lambda M: sum(len(p.get("holes") or []) for p in (M or {}).get("parts", []) if p.get("kind") == "slab")
m3_ok = E3 is not None and cnt(EX) - cnt(E3) == 9
report("F34", inv_ok, "좌표를 (+7, +5) m 옮긴 사본에서도 같은 결과 (기준점을 도면에서 찾는다)")
report("F34", m2_ok, "1B 슬래브(윗면·아랫면 선)만 0.5 m 내린 사본 → 1B 만 500 낮아짐 (도면에서 읽는다)")
report("F34", m3_ok, "홀수층 아래 slot 을 지운 사본 → 구멍 9개 감소 (도면에서 읽는다)")
ch = read("compare.html") or ""
srcs2 = re.findall(r'<script[^>]*src\s*=\s*["\']([^"\']+)["\']', ch)
report("F34", "model_extracted.js" in srcs2 and "viewer.js" in srcs2 and srcs2.index("model_extracted.js") < srcs2.index("viewer.js") and "model.js" not in srcs2,
"compare.html 이 model_extracted.js 다음 viewer.js 를 읽는다")
# ---- F35: 계단 추출 (SPEC 21장) ----
ms = sorted(kind("stair"), key=lambda p: -float(p.get("z_high", 0) or 0))
es = sorted(ek("stair"), key=lambda p: -float(p.get("z_high", 0) or 0))
sbad = []
for i, (m_, e_) in enumerate(zip(ms, es)):
ok = all(abs(float(e_.get(k2, 0) or 0) - float(m_.get(k2, 0) or 0)) <= tol for k2, tol in
(("z_low", 5), ("z_high", 5), ("risers", 0), ("first_riser", 1), ("riser", 1), ("tread", 1), ("x_top", 10), ("y0", 10), ("y1", 10))) and e_.get("dir") == m_.get("dir")
if not ok: sbad.append(f"런{i + 1}")
report("F35", len(es) == 18 and not sbad, f"추출 계단 18런 = model.js (높이·단 수·단 높이·디딤판·x_top·방향·Y 띠) (실제 {len(es)})" + (f" — 틀림: {sbad[:6]}" if sbad else ""))
m4_ok = False
if EX is not None:
L0 = dxf_lines(); s0, e0 = ent_range(L0)
L4 = list(L0); i = s0; hit = 0
BOX4 = (11664.592 - 2.2, 8134.280 - 0.01, 11664.592 + 2.2, 8134.280 + 1.01) # 홀수층 평면 계단 구멍
while i < e0:
if L4[i].strip() == "0" and L4[i + 1].strip() in ("LINE", "LWPOLYLINE"):
j = i + 2
while j < e0 and L4[j].strip() != "0": j += 2
blk_ = L4[i:j]
lay_ = next((blk_[k + 1].strip() for k in range(0, len(blk_) - 1, 2) if blk_[k].strip() == "8"), "")
xs_ = [float(blk_[k + 1]) for k in range(0, len(blk_) - 1, 2) if blk_[k].strip() in ("10", "11")]
ys_ = [float(blk_[k + 1]) for k in range(0, len(blk_) - 1, 2) if blk_[k].strip() in ("20", "21")]
if lay_ == "오프닝" and xs_ and BOX4[0] <= min(xs_) and max(xs_) <= BOX4[2] and BOX4[1] <= min(ys_) and max(ys_) <= BOX4[3]:
for k in range(i, j, 2):
if L4[k].strip() in ("10", "11"): L4[k + 1] = repr(float(L4[k + 1]) + 0.3)
hit += 1
i = j
else:
i += 2
if hit:
E4, _ = run_extract(write_tmp("d3w_m4.dxf", L4), os.path.join(TMP, "d3w_m4.js"))
s4 = sorted([p for p in (E4 or {}).get("parts", []) if p.get("kind") == "stair"], key=lambda p: -float(p.get("z_high", 0) or 0))
m4_ok = len(s4) == 18 and all(abs(float(a_.get("x_top", 0)) - float(b_.get("x_top", 0)) - (300 if k_ % 2 == 0 and k_ < 17 else 0)) <= 5 for k_, (a_, b_) in enumerate(zip(s4, es)))
report("F35", m4_ok, "홀수층 평면의 계단 구멍만 X 로 0.3 m 옮긴 사본 → 홀수 런 9개 x_top 만 300 바뀜 (도면에서 읽는다)")
# ---- F36: 정답지 좌표틀 정정 (SPEC 22장) ----
def fn_text(src, head):
i = (src or "").find(head)
if i < 0:
return ""
m = re.search(r"\n(?=\S)", src[i + 1:])
return src[i:i + 1 + m.start()] if m else src[i:]
report("F36", "rot_deg" in fn_text(viewer, "const planXY") or "rot_deg" in fn_text(viewer, "function planXY"), "planXY 가 그림의 rot_deg 로 돌린다")
report("F36", "rot_deg" in fn_text(viewer, "function planPoly") or "planXY" in fn_text(viewer, "function planPoly"), "planPoly(터널 사각형)도 rot_deg 로 돌린다")
pv = [v for v in (d.get("views") or []) if isinstance(v, dict) and v.get("type") == "plan"]
report("F36", len(pv) == 2 and all(near(v.get("rot_deg"), -30.3) for v in pv), "평면도 1·2 rot_deg -30.3")
# ---- F37: 터널 추출 (SPEC 23장) ----
tool37 = read("tools/extract_from_dxf.py") or ""
bad37 = [n for n in ("65050", "63550", "66550", "66100", "4.828", "5.292", "11664", "8113") if n in tool37]
report("F37", not bad37, "추출 도구에 터널 좌표·정답 숫자 없음" + (f" — 숫자: {bad37}" if bad37 else ""))
def tun_of(M):
ps = [p for p in (M or {}).get("parts", []) if isinstance(p, dict)]
t_ = [p for p in ps if p.get("kind") == "tunnel"]
return (t_[0] if len(t_) == 1 else None), sorted([p for p in ps if p.get("kind") == "fill"], key=lambda p: float(p.get("v0", 0) or 0))
et_, ef_ = tun_of(EX)
mt_, mf_ = tun_of(MODEL)
def ang_d(a, b):
x_ = (float(a or 0) - float(b or 0)) % 360
return min(x_, 360 - x_)
def same_tun(a, b, tol=10):
return bool(a and b) and all(abs(float(a.get(k, 0) or 0) - float(b.get(k, 0) or 0)) <= tol for k in ("x", "y", "z", "r_out", "r_in", "length")) and ang_d(a.get("angle_deg"), b.get("angle_deg")) <= 0.5
report("F37", same_tun(et_, mt_), "추출 터널 = model.js (x·y·z·r_out·r_in·길이 ±10, 방향 ±0.5°)" + ("" if et_ else " — 추출 터널 없음"))
fill_ok = len(ef_) == 3 and len(mf_) == 3 and all(
all(abs(float(a.get(k, 0) or 0) - float(b.get(k, 0) or 0)) <= 5 for k in ("v0", "v1", "half_width", "r")) and same_tun({**a, "r_out": 0, "r_in": 0}, {**b, "r_out": 0, "r_in": 0})
for a, b in zip(ef_, mf_))
report("F37", fill_ok, f"추출 채움 3개 = model.js (v0·v1·폭·r ±5, 터널과 같은 축) (실제 {len(ef_)}개)")
def map_ents(L_, fn):
s_, e_ = ent_range(L_)
out_, ent, i = L_[:s_], [], s_
def flush(ent_):
if ent_:
r_ = fn(ent_)
if r_ is not None:
out_.extend(r_)
while i < e_:
if L_[i].strip() == "0" and ent:
flush(ent); ent = []
ent.extend(L_[i:i + 2]); i += 2
flush(ent); out_.extend(L_[e_:])
return out_
def ent_info(ent_):
pairs = [(ent_[k].strip(), ent_[k + 1].strip()) for k in range(0, len(ent_) - 1, 2)]
lay_ = next((v for c, v in pairs if c == "8"), "")
xs_ = [float(v) for c, v in pairs if c in ("10", "11")]
ys_ = [float(v) for c, v in pairs if c in ("20", "21")]
return pairs[0][1] if pairs else "", lay_, xs_, ys_
m5_ok = m6_ok = False
m5_n = m6_n = 0
if EX is not None and et_:
L0 = dxf_lines()
# M5: 단면 A-A 오른쪽 바깥 벽에서 나가는 터널 선 다섯 줄만 0.5 m 내림
def f5(ent_):
global m5_n
t_, lay_, xs_, ys_ = ent_info(ent_)
if t_ == "LINE" and lay_ == "구조물" and xs_ and abs(min(xs_) - 11344.881) < 0.003 and abs(max(xs_) - 11567.483) > 1 and max(xs_) < 11347.483 and 8074.096 < min(ys_) and max(ys_) < 8078.096:
m5_n += 1
return [ent_[k] if k % 2 == 0 or ent_[k - 1].strip() not in ("20", "21") else repr(float(ent_[k]) - 0.5) for k in range(len(ent_))]
return ent_
L5 = map_ents(L0, f5)
if m5_n == 5:
E5, _ = run_extract(write_tmp("d3w_m5.dxf", L5), os.path.join(TMP, "d3w_m5.js"))
t5, f5_ = tun_of(E5)
m5_ok = bool(t5) and abs(float(t5.get("z", 0)) - (float(et_.get("z", 0)) - 500)) <= 5 and same_tun({**t5, "z": 0}, {**et_, "z": 0}) and len(f5_) == 3 \
and all(abs(float(a.get("v1", 0)) - float(b.get("v1", 0))) <= 5 for a, b in zip(f5_, ef_))
# M6: 19B 평면의 터널 선(-평면계획)을 중심 기준 좌우로 뒤집음
CX6, CY6 = 11664.592, 8113.280
def f6(ent_):
global m6_n
t_, lay_, xs_, ys_ = ent_info(ent_)
if lay_ == "-평면계획" and xs_ and all(abs(x_ - CX6) < 8.5 for x_ in xs_) and all(abs(y_ - CY6) < 8.5 for y_ in ys_):
m6_n += 1
return [ent_[k] if k % 2 == 0 or ent_[k - 1].strip() not in ("10", "11") else repr(2 * CX6 - float(ent_[k])) for k in range(len(ent_))]
return ent_
L6 = map_ents(L0, f6)
if m6_n >= 4:
E6, _ = run_extract(write_tmp("d3w_m6.dxf", L6), os.path.join(TMP, "d3w_m6.js"))
t6, _f6 = tun_of(E6)
m6_ok = bool(t6) and ang_d(t6.get("angle_deg"), 180) <= 0.5 and abs(float(t6.get("x", 0)) + float(et_.get("x", 0))) <= 10 and abs(float(t6.get("z", 0)) - float(et_.get("z", 0))) <= 5
report("F37", m5_ok, f"단면 A-A 터널 선만 0.5 m 내린 사본 → 터널·채움 축이 500 낮아짐 (도면에서 읽는다, 바꾼 선 {m5_n})")
report("F37", m6_ok, f"19B 평면 터널 선을 좌우로 뒤집은 사본 → 터널 방향 180° (도면에서 읽는다, 바꾼 객체 {m6_n})")
# ---- F38: 분기구·환기구 추출 (SPEC 24장) ----
tool38 = read("tools/extract_from_dxf.py") or ""
bad38 = [n for n in ("197.", "30.3", "1650", "4550", "4600", "10500", "7504", "11205", "8350", "11540", "8136", "11378") if n in tool38]
report("F38", not bad38, "추출 도구에 분기구·환기구 좌표·정답 숫자 없음" + (f" — 숫자: {bad38}" if bad38 else ""))
def solid_in(p, x, y, z, e=0.0):
try:
if not (float(p["z0"]) < z < float(p["z1"])):
return False
if p.get("type") == "obox":
a = math.radians(float(p["angle_deg"])); dx, dy = x - float(p.get("cx", 0)), y - float(p.get("cy", 0))
s = dx * math.cos(a) + dy * math.sin(a); t = -dx * math.sin(a) + dy * math.cos(a)
return float(p["s0"]) + e < s < float(p["s1"]) - e and float(p["t0"]) + e < t < float(p["t1"]) - e
if p.get("type") == "prism":
def pin(poly):
c = False
for (x1, y1), (x2, y2) in zip(poly, poly[1:] + poly[:1]):
if (y1 > y) != (y2 > y) and x < x1 + (y - y1) * (x2 - x1) / (y2 - y1):
c = not c
return c
return pin(p["polygon"]) and not any(pin(h) for h in (p.get("holes") or []) if isinstance(h, list))
except (KeyError, TypeError, ValueError, ZeroDivisionError):
return False
return False
ZS38 = (-4575, -4300, -3000, -1800, -1000)
def kparts(M, k_):
return [p for p in (M or {}).get("parts", []) if isinstance(p, dict) and p.get("kind") == k_]
def obox_xy(p):
a = math.radians(float(p["angle_deg"])); out = []
for s in (float(p["s0"]), float(p["s1"])):
for t in (float(p["t0"]), float(p["t1"])):
out.append((float(p.get("cx", 0)) + s * math.cos(a) - t * math.sin(a), float(p.get("cy", 0)) + s * math.sin(a) + t * math.cos(a)))
return out
def grid38(parts, step=100):
pts = [q for p in parts if p.get("type") == "obox" for q in obox_xy(p)]
if not pts:
return []
x0, x1 = min(q[0] for q in pts) - 400, max(q[0] for q in pts) + 400
y0, y1 = min(q[1] for q in pts) - 400, max(q[1] for q in pts) + 400
xs = [x0 + 50 + step * i for i in range(int((x1 - x0) / step))]
ys = [y0 + 50 + step * j for j in range(int((y1 - y0) / step))]
return [(x, y) for x in xs for y in ys if math.hypot(x, y) > 5520]
def same_solid(ext, mod):
G_ = grid38(mod); bad = 0; tot = 0
for z in ZS38:
for x, y in G_:
e_in = any(solid_in(p, x, y, z) for p in ext)
m_in = any(solid_in(p, x, y, z, 20) for p in mod)
m_out = not any(solid_in(p, x, y, z, -20) for p in mod)
tot += m_in
if (m_in and not e_in) or (e_in and m_out):
bad += 1
return bad, tot
res38 = {}
for k_ in ("branch", "vent"):
ex_k, mo_k = kparts(EX, k_), kparts(MODEL, k_)
bad, tot = same_solid(ex_k, mo_k) if ex_k and mo_k else (-1, 0)
res38[k_] = bad
report("F38", ex_k and 0 <= bad <= max(5, tot // 100), f"추출 {'분기구' if k_ == 'branch' else '환기구'} 입체 = model.js (높이 5곳 × 100 mm 격자, 경계 ±20, 어긋난 점 {bad} / 안쪽 점 {tot})")
def map_groups(L_, fn):
s_, e_ = ent_range(L_)
chunks, cur, i = [], [], s_
while i < e_:
if L_[i].strip() == "0" and cur:
chunks.append(cur); cur = []
cur.extend(L_[i:i + 2]); i += 2
if cur:
chunks.append(cur)
groups, j = [], 0
while j < len(chunks):
if chunks[j][1].strip() == "POLYLINE":
k2 = j + 1
while k2 < len(chunks) and chunks[k2][1].strip() != "SEQEND":
k2 += 1
groups.append(chunks[j:k2 + 1]); j = k2 + 1
else:
groups.append([chunks[j]]); j += 1
out_ = L_[:s_]
for g in groups:
out_.extend(x for c in fn(g) for x in c)
out_.extend(L_[e_:])
return out_
def g_info(g):
pairs = [(c[k].strip(), c[k + 1].strip()) for c in g for k in range(0, len(c) - 1, 2)]
lay_ = next((v for c, v in pairs if c == "8"), "")
pts = []
for c in g:
cp = [(c[k].strip(), c[k + 1].strip()) for k in range(0, len(c) - 1, 2)]
if g[0][1].strip() == "POLYLINE" and c is g[0]:
continue
xs_ = [float(v) for cc, v in cp if cc in ("10", "11")]; ys_ = [float(v) for cc, v in cp if cc in ("20", "21")]
pts += list(zip(xs_, ys_))
return g[0][1].strip(), lay_, pts
def shift_code(c, codes, f):
return [c[k] if k % 2 == 0 or c[k - 1].strip() not in codes else repr(f(float(c[k]))) for k in range(len(c))]
m7_ok = m8_ok = False
m7_n = m8_n = 0
if EX is not None and kparts(EX, "vent") and kparts(EX, "branch"):
L0 = dxf_lines()
PCX, PCY = 11541.134, 8136.814
def f7(g):
global m7_n
t_, lay_, pts = g_info(g)
if not pts or lay_ not in ("구조물", "환기구") or not all(math.hypot(x - PCX, y - PCY) < 16 and y - PCY > -12 for x, y in pts):
return g
is_c = lay_ == "환기구" and math.hypot(pts[0][0] - PCX, pts[0][1] - PCY) < 0.01
is_v = lay_ == "구조물" and all(x - PCX > -2.0 and y - PCY < -4.0 for x, y in pts)
if not (is_c or is_v):
return g
m7_n += 1
return [shift_code(c, ("10", "11"), lambda v: 2 * PCX - v) for c in g]
L7 = map_groups(L0, f7)
E7, _ = run_extract(write_tmp("d3w_m7.dxf", L7), os.path.join(TMP, "d3w_m7.js"))
v7, v0 = kparts(E7, "vent"), kparts(EX, "vent")
if v7:
ph = math.radians(30.3); bad7 = tot7 = 0
def T7(x, y):
u = x * math.cos(-ph) - y * math.sin(-ph); w_ = x * math.sin(-ph) + y * math.cos(-ph)
u = -u
return u * math.cos(ph) - w_ * math.sin(ph), u * math.sin(ph) + w_ * math.cos(ph)
for z in (-3000, -1800):
for x in range(-15950, 16000, 200):
for y in range(-15950, 16000, 200):
if math.hypot(x, y) <= 5520:
continue
a_ = any(solid_in(p, x, y, z) for p in v7)
b_ = any(solid_in(p, *T7(x, y), z) for p in v0)
tot7 += b_
bad7 += a_ != b_
m7_ok = tot7 > 100 and bad7 <= max(5, tot7 // 50)
def f8(g):
global m8_n
t_, lay_, pts = g_info(g)
if lay_ == "구조물" and len(pts) == 2 and any(abs(x - 11333.881) < 0.002 and abs(y - 8139.3717) < 0.002 for x, y in pts) and any(abs(x - 11333.731) < 0.002 and abs(y - 8139.2217) < 0.002 for x, y in pts):
m8_n += 1
return [shift_code(c, ("20", "21"), lambda v: v - 0.2 if abs(v - 8139.2217) < 0.002 else v) for c in g]
return g
L8 = map_groups(L0, f8)
if m8_n == 1:
E8, _ = run_extract(write_tmp("d3w_m8.dxf", L8), os.path.join(TMP, "d3w_m8.js"))
top0 = max([float(p.get("z1", 0)) for p in kparts(EX, "branch")] or [0])
top8 = max([float(p.get("z1", 0)) for p in kparts(E8, "branch")] or [0])
vt0 = sorted(float(p.get("z1", 0)) for p in kparts(EX, "vent")); vt8 = sorted(float(p.get("z1", 0)) for p in kparts(E8, "vent"))
m8_ok = bool(kparts(E8, "branch")) and abs((top0 - top8) - 200) <= 5 and vt0 == vt8
report("F38", m7_ok, f"평면도의 환기구(덕트·탑)를 좌우로 뒤집은 사본 → 추출 환기구도 뒤집힘 (도면에서 읽는다, 바꾼 객체 {m7_n})")
report("F38", m8_ok, f"단면 A-A 분기구 지붕 헌치를 0.2 m 내린 사본 → 분기구 지붕 윗면만 200 낮아짐 (도면에서 읽는다, 바꾼 객체 {m8_n})")
# ---- F39: 나머지 부재 추출 (SPEC 25장) ----
tool39 = read("tools/extract_from_dxf.py") or ""
bad39 = [n for n in ("4405", "4750", "66100", "69000", "70200", "70300", "67500", "1400", "1600", "11688", "-1500") if n in tool39]
report("F39", not bad39, "추출 도구에 나머지 부재 좌표·정답 숫자 없음" + (f" — 숫자: {bad39}" if bad39 else ""))
def bb39(poly):
try:
xs = [float(q[0]) for q in poly]; ys = [float(q[1]) for q in poly]
return [min(xs), max(xs), min(ys), max(ys)]
except Exception:
return None
def circ39(p):
return sorted([[float(v) for v in h["circle"]] for h in (p.get("holes") or []) if isinstance(h, dict) and "circle" in h])
def close(a, b, tol):
return a is not None and b is not None and len(a) == len(b) and all(abs(float(x) - float(y)) <= tol for x, y in zip(a, b))
def one(M, k_):
ps = kparts(M, k_)
return ps[0] if len(ps) == 1 else None
def cmp39(E_):
out = {}
t_e, t_m = one(E_, "topslab"), one(MODEL, "topslab")
out["topslab"] = bool(t_e) and close([t_e.get(k) for k in ("r", "z0", "z1")], [t_m.get(k) for k in ("r", "z0", "z1")], 5) and len(circ39(t_e)) == len(circ39(t_m)) and all(close(a, b, 10) for a, b in zip(circ39(t_e), circ39(t_m)))
ne, nm = sorted(kparts(E_, "neck"), key=lambda p: (bb39(p.get("polygon")) or [0])[2:]), sorted(kparts(MODEL, "neck"), key=lambda p: (bb39(p.get("polygon")) or [0])[2:])
out["neck"] = len(ne) == len(nm) == 2 and all(close(bb39(a.get("polygon")), bb39(b.get("polygon")), 10) and close([a.get("z0"), a.get("z1")], [b.get("z0"), b.get("z1")], 5) and close((circ39(a) or [None])[0], (circ39(b) or [None])[0], 10) for a, b in zip(ne, nm))
p_e, p_m = one(E_, "pad"), one(MODEL, "pad")
hb = lambda p: sorted(bb39(h) for h in (p.get("holes") or []) if isinstance(h, list))
out["pad"] = bool(p_e) and close([p_e.get(k) for k in ("r", "z0", "z1")], [p_m.get(k) for k in ("r", "z0", "z1")], 5) and len(hb(p_e)) == len(hb(p_m)) and all(close(a, b, 40) for a, b in zip(hb(p_e), hb(p_m)))
for k_ in ("bottom", "lean"):
a, b = one(E_, k_), one(MODEL, k_)
out[k_] = bool(a) and close([a.get(k) for k in ("r", "z0", "z1")], [b.get(k) for k in ("r", "z0", "z1")], 5)
we = sorted([bb39(p.get("polygon")) + [float(p.get("z0", 0)), float(p.get("z1", 0))] for p in kparts(E_, "weir")])
wm = sorted([bb39(p.get("polygon")) + [float(p.get("z0", 0)), float(p.get("z1", 0))] for p in kparts(MODEL, "weir")])
out["weir"] = len(we) == len(wm) == 3 and all(close(a, b, 10) for a, b in zip(we, wm))
pe = sorted([[float(p.get(k, 0)) for k in ("x_min", "x_max", "z0", "z1")] for p in kparts(E_, "pit")])
pm = sorted([[float(p.get(k, 0)) for k in ("x_min", "x_max", "z0", "z1")] for p in kparts(MODEL, "pit")])
pid = sorted([(p.get("id"), round(float(p.get("x_min", 0)))) for p in kparts(E_, "pit")], key=lambda q: q[1])
out["pit"] = len(pe) == len(pm) == 2 and all(close(a, b, 10) for a, b in zip(pe, pm)) and [q[0] for q in pid] == [q.get("id") for q in sorted(kparts(MODEL, "pit"), key=lambda p: float(p.get("x_min", 0)))]
s19 = [p for p in kparts(E_, "slab") if abs(float(p.get("z1", 0)) - (-66200)) <= 5]
out["slab19"] = bool(s19) and any(b_ and b_[0] > 3000 and b_[1] - b_[0] < 1200 for b_ in (bb39(h) for h in (s19[0].get("holes") or []) if isinstance(h, list)))
return out
c39 = cmp39(EX) if EX is not None else {}
NAMES39 = {"topslab": "상부 슬래브(r·z ±5, 원 구멍 ±10)", "neck": "맨홀 목 2개(윤곽 ±10)", "pad": "19B 패드(r·z ±5, 구멍 ±40)", "bottom": "하부 슬래브", "lean": "바닥 콘크리트",
"weir": "월류벽 3개(윤곽·높이 ±10)", "pit": "집수조·침사조(범위 ±10, id 쪽 맞음)", "slab19": "19B 슬래브에 침사구 구멍(X 표시)"}
for k_, nm in NAMES39.items():
report("F39", bool(c39.get(k_)), f"추출 {nm} = model.js")
m9_ok = m10_ok = False
m9_n = m10_n = 0
if EX is not None and kparts(EX, "weir") and kparts(EX, "neck"):
L0 = dxf_lines()
WX, WY = 11689.342, 8113.280
def f9(g):
global m9_n
t_, lay_, pts = g_info(g)
if t_ == "LINE" and lay_ == "구조물" and pts and all(1.3 < x - WX < 1.7 and abs(y - WY) < 4.9 for x, y in pts):
m9_n += 1
return [shift_code(c, ("10", "11"), lambda v: v - 0.3) for c in g]
return g
L9 = map_groups(L0, f9)
E9, _ = run_extract(write_tmp("d3w_m9.dxf", L9), os.path.join(TMP, "d3w_m9.js"))
w9 = sorted([bb39(p.get("polygon")) for p in kparts(E9, "weir")]); w0 = sorted([bb39(p.get("polygon")) for p in kparts(EX, "weir")])
p9 = sorted([[float(p.get("x_min", 0)), float(p.get("x_max", 0))] for p in kparts(E9, "pit")]); p0 = sorted([[float(p.get("x_min", 0)), float(p.get("x_max", 0))] for p in kparts(EX, "pit")])
m9_ok = m9_n >= 6 and len(w9) == 3 and all(abs(a[0] - b[0] + 300) <= 5 and abs(a[1] - b[1] + 300) <= 5 for a, b in zip(w9, w0)) and len(p9) == 2 and abs(p9[0][1] - p0[0][1] + 300) <= 5 and abs(p9[1][0] - p0[1][0] + 300) <= 5
PX, PY = 11541.134, 8136.814
def f10(g):
global m10_n
t_, lay_, pts = g_info(g)
if lay_ == "구조물" and pts and all(1.0 < x - PX < 3.5 and 2.5 < y - PY < 5.0 for x, y in pts):
m10_n += 1
return [shift_code(c, ("10", "11"), lambda v: v + 0.5) for c in g]
return g
L10 = map_groups(L0, f10)
E10, _ = run_extract(write_tmp("d3w_m10.dxf", L10), os.path.join(TMP, "d3w_m10.js"))
ph = math.radians(30.3); want = (500 * math.cos(ph), 500 * math.sin(ph))
ts0, ts1 = one(EX, "topslab"), one(E10, "topslab")
c0, c1 = (circ39(ts0) if ts0 else []), (circ39(ts1) if ts1 else [])
moved = [(b[0] - a[0], b[1] - a[1]) for a, b in zip(sorted(c0, key=lambda c: c[1]), sorted(c1, key=lambda c: c[1]))]
n1 = sorted(kparts(E10, "neck"), key=lambda p: (bb39(p.get("polygon")) or [0, 0, 0])[2]); n0 = sorted(kparts(EX, "neck"), key=lambda p: (bb39(p.get("polygon")) or [0, 0, 0])[2])
m10_ok = m10_n >= 2 and len(moved) == 2 and abs(moved[0][0]) <= 5 and abs(moved[0][1]) <= 5 and abs(moved[1][0] - want[0]) <= 10 and abs(moved[1][1] - want[1]) <= 10 \
and len(n1) == 2 and abs(bb39(n1[1]["polygon"])[0] - bb39(n0[1]["polygon"])[0] - want[0]) <= 10
report("F39", m9_ok, f"20B 평면 월류벽 선을 중심 쪽으로 0.3 m 옮긴 사본 → 월류벽·조 경계가 300 옮겨짐 (도면에서 읽는다, 바꾼 선 {m9_n})")
report("F39", m10_ok, f"평면도 맨홀 2(원·목)를 0.5 m 옮긴 사본 → 상부 슬래브 구멍 2·목 2 만 φ 돌린 만큼 옮겨짐 (바꾼 객체 {m10_n})")
# ---- F40: 정답지 보강 — 환기구 사다리 2개 (SPEC 26장) ----
WANT40 = {"ladder_vent_wall": [300.3, 4582, 4622, 0, 400, -7400, -4100, 300], "ladder_vent_tower": [30.3, 10100, 10140, -8650, 400, -4100, 0, 300]}
got40 = {p.get("id"): [p.get(f) for f in ("angle_deg", "x0", "x1", "yc", "width", "z0", "z1", "rung")] for p in kind("ladder_vent") if p.get("type") == "ladder"}
report("F40", set(got40) == set(WANT40) and all(all(near(a, b) for a, b in zip(got40[i], WANT40[i])) for i in WANT40), f"환기구 사다리 2개가 SPEC 26장 표와 같다 (실제 {sorted(got40)})")
fl40 = re.search(r"function facesLadder[^{]*\{(.*?)\n\}", viewer or "", re.S)
report("F40", bool(fl40) and "angle_deg" in fl40.group(1), "facesLadder 가 angle_deg 로 돌린다")
# ---- F41: 설계 규칙 파일, 추출 모델 = 정답지 전체 (SPEC 27장) ----
DR_TXT = read("tools/design_rules.json") or ""
try:
DR = json.loads(DR_TXT)
except ValueError:
DR = None
RULE_IDS = ["ladder_19B", "ladder_up_W", "ladder_up_C", "ladder_vent_wall", "ladder_vent_tower", "pump_pipe"]
MID = {p.get("id"): p for p in MODEL.get("parts", [])} if isinstance(MODEL, dict) else {}
def same_part(a, b):
if not isinstance(a, dict) or not isinstance(b, dict):
return False
for k_, v in b.items():
if k_ in ("name",):
continue
if isinstance(v, (int, float)) and not isinstance(v, bool):
if not near(a.get(k_), v):
return False
elif a.get(k_) != v:
return False
return True
drp = {p.get("id"): p for p in ((DR or {}).get("parts") or []) if isinstance(p, dict)}
report("F41", isinstance(DR, dict) and set(drp) == set(RULE_IDS) and all(same_part(drp[i], MID.get(i)) for i in RULE_IDS),
f"tools/design_rules.json 의 🔶 부재 6개 = model.js (실제 {sorted(drp)})")
bad41 = [n for n in ("4405", "65050", "66100", "69000", "70200", "70300", "197.", "4578", "1401", "4850", "5500") if n in DR_TXT]
report("F41", bool(DR_TXT) and not bad41, "설계 규칙 파일에 도면에서 읽는 값 없음" + (f" — 숫자: {bad41}" if bad41 else ""))
xp = [p for p in (EX or {}).get("parts", []) if isinstance(p, dict)]
xid = {p.get("id"): p for p in xp}
mk, xk = {p.get("kind") for p in MID.values()}, {p.get("kind") for p in xp}
need = {i for i, p in MID.items() if p.get("kind") not in ("branch", "vent")}
miss = sorted(need - set(xid))
report("F41", mk == xk and not miss, "추출 모델의 kind = model.js, 분기구·환기구 말고 id 도 모두 있음" + (f" — 없음: {miss[:6]}" if miss else "") + (f" — kind 차이: {sorted(mk ^ xk)}" if mk != xk else ""))
cbad = [i for i in need & set(xid) if xid[i].get("color") != MID[i].get("color") or (MID[i].get("alpha") is not None and not near(xid[i].get("alpha"), MID[i].get("alpha")))]
report("F41", bool(xp) and not cbad, "같은 id 의 color·alpha = model.js" + (f" — 다름: {sorted(cbad)[:6]}" if cbad else ""))
nbad = [p.get("id") for p in xp if not p.get("name") or not p.get("color")]
report("F41", bool(xp) and not nbad, "추출 모델의 모든 부재에 name·color" + (f" — 없음: {nbad[:6]}" if nbad else ""))
rp = [i for i in RULE_IDS if not same_part(xid.get(i), MID.get(i))]
report("F41", bool(xp) and not rp, "추출 모델의 🔶 부재 6개 = model.js" + (f" — 다름: {rp}" if rp else ""))
# ---- F42: 변환 패키지 DWG → DXF → 도면 범위 (SPEC 28장) ----
import shutil
PK = {p_: read(p_) for p_ in ("tools/dwg_to_dxf.sh", "tools/select_views.py", "tools/make_model.sh", "tools/views.json")}
report("F42", all(PK.values()), "tools/dwg_to_dxf.sh, select_views.py, make_model.sh, views.json 있음" + (f" — 없음: {[p_ for p_, v in PK.items() if not v]}" if not all(PK.values()) else ""))
bad42 = [n for n in ("11307", "11527", "11570", "11653", "8061", "8099", "8103", "8151", "도곽", "T-타이틀") if n in (PK["tools/select_views.py"] or "") + (PK["tools/make_model.sh"] or "")]
report("F42", bool(PK["tools/select_views.py"]) and not bad42, "select_views.py·make_model.sh 에 상자 좌표·레이어 이름 없음(views.json 에서 읽는다)" + (f" — {bad42}" if bad42 else ""))
rd = readme or ""
report("F42", "LibreDWG" in rd and "make_model.sh" in rd and "dwg2dxf" in rd, "README 에 변환 패키지 설치·실행")
def dxf_pairs(p_):
try:
L_ = open(p_, encoding="utf-8", errors="replace").read().replace("\r", "").split("\n")
except OSError:
return None
P_ = [(L_[i].strip(), L_[i + 1].strip()) for i in range(0, len(L_) - 1, 2)]
try:
s_ = next(i for i in range(len(P_) - 1) if P_[i] == ("0", "SECTION") and P_[i + 1] == ("2", "ENTITIES"))
e_ = next(i for i in range(s_, len(P_)) if P_[i] == ("0", "ENDSEC"))
except StopIteration:
return None
return P_[s_:e_]
def same_pairs(a, b):
if a is None or b is None or len(a) != len(b):
return False
for (c1, v1), (c2, v2) in zip(a, b):
if c1 != c2:
return False
if v1 != v2:
try:
if abs(float(v1) - float(v2)) > 1e-6:
return False
except ValueError:
return False
return True
D2D = shutil.which("dwg2dxf") or (os.path.expanduser("~/.local/bin/dwg2dxf") if os.path.exists(os.path.expanduser("~/.local/bin/dwg2dxf")) else None)
ok42a = ok42b = ok42c = False
why42 = ""
if not (D2D and os.path.exists("private/drawing.dwg")):
why42 = " — dwg2dxf 또는 private/drawing.dwg 없음"
elif all(PK.values()):
OUT42 = tempfile.mkdtemp(prefix="d3w_pkg_")
try:
r42 = subprocess.run(["bash", "tools/make_model.sh", "private/drawing.dwg", OUT42], capture_output=True, text=True, timeout=900)
why42 = (" — " + (r42.stderr or r42.stdout)[-200:]) if r42.returncode else ""
except Exception as e42:
why42 = " — " + str(e42)[:200]
ok42a = same_pairs(dxf_pairs(os.path.join(OUT42, "drawing.dxf")), dxf_pairs("private/drawing.dxf"))
m42 = read(os.path.join(OUT42, "model_extracted.js")) or ""
mm42 = re.match(r"\s*const\s+MODEL\s*=\s*(.*?);?\s*$", m42, re.S)
ok42b = bool(mm42) and isinstance(EX, dict) and json.loads(mm42.group(1)).get("parts") == EX.get("parts")
one_box = json.loads(PK["tools/views.json"]); one_box["boxes"] = one_box["boxes"][:1]
vj = os.path.join(OUT42, "one.json"); open(vj, "w", encoding="utf-8").write(json.dumps(one_box, ensure_ascii=False))
try:
subprocess.run(["python3", "tools/select_views.py", os.path.join(OUT42, "full.dxf"), vj, os.path.join(OUT42, "one.dxf")], capture_output=True, timeout=600)
P1 = dxf_pairs(os.path.join(OUT42, "one.dxf")) or []
P0 = dxf_pairs("private/drawing.dxf") or []
bx = one_box["boxes"][0]["range"]
xs1 = [float(v) for c, v in P1 if c in ("10", "11") and float(v) != 0.0]
ok42c = 0 < len(P1) < len(P0) and all(bx[0] - 6 <= x <= bx[2] + 6 for x in xs1)
except Exception:
ok42c = False
MSG42 = ["make_model.sh: 원본 DWG → drawing.dxf 객체가 private/drawing.dxf 와 같다 (숫자 1e-6)",
"make_model.sh: 나온 model_extracted.js 부재 = 리포의 model_extracted.js",
"상자 하나만 남긴 views.json → 그 상자 안 객체만 남는다 (select_views 가 views.json 을 읽는다)"]
if not (D2D and os.path.exists("private/drawing.dwg")):
for m42s in MSG42:
skip("F42", m42s + " — 이 환경에 dwg2dxf(GNU LibreDWG) 또는 private/drawing.dwg 가 없어 건너뜀 (SPEC 32장)")
else:
report("F42", ok42a, MSG42[0] + why42)
report("F42", ok42b, MSG42[1])
report("F42", ok42c, MSG42[2])
# ---- F43: DXF → SVG, 도면 연결값을 도면에서 (SPEC 29장) ----
import xml.etree.ElementTree as ET
SVGT = read("tools/dxf_to_svg.py") or ""
VJ43 = json.loads(read("tools/views.json") or "{}")
imp43 = [m for m in re.findall(r"^\s*(?:import|from)\s+([A-Za-z_][\w.]*)", SVGT, re.M) if m.split(".")[0] not in
("sys", "re", "math", "json", "os", "colorsys", "argparse", "collections", "itertools", "functools", "pathlib", "html", "xml")]
bad43 = [n for n in ("11199", "11313", "11322", "11436", "8314", "8313", "8360", "11307", "11653") if n in SVGT]
report("F43", bool(SVGT) and not imp43 and not bad43, "tools/dxf_to_svg.py 있음, 표준 라이브러리만, 상자·배치 좌표 없음" + (f" — {imp43 + bad43}" if imp43 or bad43 else ""))
def svg_cells(txt, boxes, step=0.5):
segs_ = []
for m in re.finditer(r'<(polyline|polygon)\b[^>]*\bpoints="([^"]+)"', txt):
nums = [float(v) for v in re.findall(r"-?\d+(?:\.\d+)?(?:[eE]-?\d+)?", m.group(2))]
P_ = list(zip(nums[0::2], nums[1::2])); segs_ += list(zip(P_, P_[1:]))
for m in re.finditer(r'<line\b[^>]*>', txt):
g_ = {k_: float(v) for k_, v in re.findall(r'\b(x1|y1|x2|y2)="(-?[\d.eE-]+)"', m.group(0))}
if len(g_) == 4:
segs_.append(((g_["x1"], g_["y1"]), (g_["x2"], g_["y2"])))
for m in re.finditer(r'<path\b[^>]*\bd="([^"]+)"', txt):
d_ = m.group(1); cur = None
if d_.startswith("M0,443 443,443"):
continue
for cmd, args in re.findall(r"([MLAZmlaz])([^MLAZmlaz]*)", d_):
nums = [float(v) for v in re.findall(r"-?\d+(?:\.\d+)?(?:[eE]-?\d+)?", args)]
if cmd in "ML" and len(nums) >= 2:
P_ = ([cur] if cmd == "L" and cur else []) + list(zip(nums[0::2], nums[1::2])); segs_ += list(zip(P_, P_[1:])); cur = P_[-1]
elif cmd == "A" and len(nums) >= 7:
cur = (nums[5], nums[6])
C_ = set()
for (x0, y0), (x1, y1) in segs_:
n_ = max(1, int(math.hypot(x1 - x0, y1 - y0) / (step / 2)))
for i in range(n_ + 1):
t_ = i / n_; u_, v_ = x0 + (x1 - x0) * t_, y0 + (y1 - y0) * t_
if any(b[0] + 1 <= u_ <= b[2] - 1 and b[1] + 1 <= v_ <= b[3] - 1 for b in boxes):
C_.add((int(u_ / step), int(v_ / step)))
return C_
ok43a = ok43b = False
info43 = ""
GEN43 = os.path.join(TMP, "d3w_gen.svg")
if SVGT and VJ43.get("boxes") and all("place" in b for b in VJ43["boxes"]):
try:
subprocess.run(["python3", "tools/dxf_to_svg.py", DXF, "tools/views.json", GEN43], capture_output=True, timeout=600)
gtxt = open(GEN43, encoding="utf-8").read()
root = ET.fromstring(gtxt.encode("utf-8"))
n_el = sum(1 for _ in root.iter()); n_tx = len(re.findall(r"<text\b", gtxt))
ok43a = root.get("viewBox", "").split() == ["0", "0", "443", "443"] and n_el >= 7000 and n_tx >= 400
info43 = f" (요소 {n_el}, 글자 {n_tx})"
BX = [(b["range"][0] - b["place"][0], b["place"][1] - b["range"][3], b["range"][2] - b["place"][0], b["place"][1] - b["range"][1]) for b in VJ43["boxes"]]
o_ = svg_cells(read("private/drawing.svg") or "", BX); g_ = svg_cells(gtxt, BX)
rec, pre = (len(o_ & g_) / len(o_) if o_ else 0), (len(o_ & g_) / len(g_) if g_ else 0)
ok43b = rec >= 0.85 and pre >= 0.85
info43b = f" (재현율 {rec:.3f}, 정밀도 {pre:.3f})"
except Exception as e43:
info43 = " — " + str(e43)[:200]; info43b = ""
else:
info43b = ""
report("F43", ok43a, "dxf_to_svg.py 로 만든 SVG: XML, viewBox 0 0 443 443, 요소 7000·글자 400 이상" + info43)
report("F43", ok43b, "생성 SVG 와 원본 SVG 의 선 칸 비교(상자 안) 재현율·정밀도 0.85 이상" + info43b)
MS = read("tools/make_model.sh") or ""
report("F43", "dxf_to_svg.py" in MS and "drawing_gen.svg" in MS, "make_model.sh 가 drawing_gen.svg 도 만든다")
XD = (EX or {}).get("drawing") if isinstance(EX, dict) else None
MD = MODEL.get("drawing", {}) if isinstance(MODEL, dict) else {}
def same_view(a, b):
if not isinstance(a, dict) or a.get("type") != b.get("type"):
return False
for k_, v in b.items():
if k_ == "label":
continue
if isinstance(v, bool) or isinstance(v, str):
if a.get(k_) != v:
return False
elif isinstance(v, list):
av = a.get(k_)
if not isinstance(av, list) or len(av) != len(v) or any(abs(float(x) - float(y)) > 5 for x, y in zip(av, v)):
return False
else:
tol = 0.1 if k_ == "rot_deg" else (5 if k_.startswith("z_") else 0.05)
try:
if abs(float(a.get(k_)) - float(v)) > tol:
return False
except (TypeError, ValueError):
return False
return True
xv = {v.get("id"): v for v in ((XD or {}).get("views") or []) if isinstance(v, dict)}
mv = {v.get("id"): v for v in (MD.get("views") or [])}
vb43 = [i for i in mv if not same_view(xv.get(i), mv[i])]
ok43c = isinstance(XD, dict) and XD.get("src") == "private/drawing_gen.svg" and XD.get("viewBox") == MD.get("viewBox") and XD.get("fit") == MD.get("fit") and not vb43 \
and all(abs(float((XD.get("section") or {}).get(k_, 1e9)) - float(MD["section"][k_])) <= 0.05 for k_ in ("x0", "y0")) \
and all(abs(float((XD.get("plan") or {}).get(k_, 1e9)) - float(MD["plan"][k_])) <= 0.05 for k_ in ("cx", "cy"))
report("F43", ok43c, "추출 모델 drawing = model.js drawing (views 11개 ±0.05, z1 ±5, 플래그, rot_deg ±0.1, viewBox·fit, src drawing_gen.svg)" + (f" — 다름: {vb43}" if vb43 else ""))
ok43d = False
if EX is not None and VJ43.get("boxes"):
vj2 = json.loads(json.dumps(VJ43))
for b in vj2["boxes"]:
r_ = b["range"]
if r_[0] - 1 <= 11541.134 <= r_[2] + 1 and r_[1] - 1 <= 8136.814 <= r_[3] + 1:
b["place"][0] -= 10
pv2 = os.path.join(TMP, "d3w_views2.json"); open(pv2, "w", encoding="utf-8").write(json.dumps(vj2, ensure_ascii=False))
try:
subprocess.run(["python3", "tools/extract_from_dxf.py", DXF, os.path.join(TMP, "d3w_v2.js"), pv2], capture_output=True, timeout=300)
mm = re.match(r"\s*const\s+MODEL\s*=\s*(.*?);?\s*$", read(os.path.join(TMP, "d3w_v2.js")) or "", re.S)
V2 = {v.get("id"): v for v in json.loads(mm.group(1)).get("drawing", {}).get("views", [])} if mm else {}
ok43d = bool(V2) and all(abs(float(V2[i]["cx"]) - float(xv[i]["cx"]) - 10) <= 0.02 for i in ("plan1", "plan2")) \
and all(abs(float(V2[i].get("x0", V2[i].get("cx", 0))) - float(xv[i].get("x0", xv[i].get("cx", 0)))) <= 0.02 for i in xv if i not in ("plan1", "plan2"))
except Exception:
ok43d = False
report("F43", ok43d, "views.json 평면도 상자 place 를 10 m 바꾼 사본 → plan1·plan2 cx 만 10 바뀜 (배치를 views.json 에서 읽는다)")
# ---- F44: 화면 전환 (SPEC 30장) ----
ans = read("answer.html") or ""
srcs44 = re.findall(r'<script[^>]*src\s*=\s*["\']([^"\']+)["\']', ans)
report("F44", "model.js" in srcs44 and "viewer.js" in srcs44 and srcs44.index("model.js") < srcs44.index("viewer.js") and "model_extracted.js" not in srcs44 and "정답지" in ans,
"answer.html(정답지 화면)이 model.js 다음 viewer.js 를 읽는다")
report("F44", all(re.search(r'id\s*=\s*["\']' + i + '["\']', ans) for i in ("drawPane", "splitter", "viewPane", "resetView", "walkToggle", "drawing", "hotspots")), "answer.html 에 index.html 과 같은 칸·버튼")
report("F44", bool(viewer) and "drawing.src" in viewer, "viewer.js 가 MODEL.drawing.src 로 도면 파일을 읽는다")
report("F44", os.path.exists("private/drawing_gen.svg"), "private/drawing_gen.svg 가 있다 (make_model.sh)")
report("F44", bool(readme) and "answer.html" in readme and "make_model.sh" in readme and "model_extracted.js" in readme, "README 에 첫 화면·정답지·다시 만들기")
g44 = read("guide/run.html") or ""
report("F44", "answer.html" in g44 and "make_model.sh" in g44, "구동 설명서 구동 쪽에 정답지 화면·다시 만들기")
# ---- F45: README 사용 도구 (SPEC 31장) ----
rd45 = re.sub(r"(?<=\S) (?=\S)", "", readme or "")
report("F45", "사용 도구" in (readme or "") and re.search(r"Ubuntu\s+\S+\s+2\b", (readme or "") + "\n" + rd45) is not None and "Haiku" in (readme or "") and "LibreDWG" in (readme or "") and "check.sh" in (readme or ""),
"README 「사용 도구」: wsl -l -v 출력(Ubuntu, VERSION 2), Haiku, LibreDWG, check.sh")
# ---- F46: WSL2 설치 증빙 (SPEC 33장) ----
ev = read("evidence/wsl2.txt") or ""
report("F46", re.search(r"Ubuntu\s+\S+\s+2\b", ev) is not None and "설치일" in ev and "wsl --version" in ev, "evidence/wsl2.txt Windows 쪽: wsl -l -v(Ubuntu, VERSION 2)·wsl --version·설치일")
report("F46", "microsoft-standard-WSL2" in ev and "PRETTY_NAME" in ev and re.search(r"\$ claude --version\s*\n\s*\S", ev) is not None, "evidence/wsl2.txt WSL 안쪽: microsoft-standard-WSL2 커널·Ubuntu 버전·claude 버전")
report("F46", "evidence/wsl2.txt" in (readme or ""), "README 사용 도구가 evidence/wsl2.txt 를 가리킨다")
print(f"결과: PASS {count['PASS']} / FAIL {count['FAIL']} / SKIP {count['SKIP']} (검사 범위 F01~{upto})")
sys.exit(0 if count["FAIL"] == 0 else 1)
PY