코치가 저장소 밖 사본 두 개에서 check.sh 를 동시에 돌려 둘 다 FAIL 0·임시 폴더 남지 않음을 확인. 결과: PASS 153 / FAIL 0 / SKIP 3 (검사 범위 F01~F47) Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
1121 lines
77 KiB
Bash
1121 lines
77 KiB
Bash
#!/usr/bin/env bash
|
||
# check.sh — drawing3d-web 합격 판정 (설계 단계에서 작성, 하이쿠는 수정 금지)
|
||
# 사용: bash check.sh → 전체 기능 검사
|
||
# bash check.sh F06 → F01~F06 만 검사, 뒤 기능·이슈 검사는 SKIP
|
||
# 이슈 검사: checks/i<번호>_<이름>.py 를 번호 순으로 모아 돌린다 (F01~F47 은 1차 검사)
|
||
# 판정: 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", "F47"]
|
||
upto = sys.argv[1].upper() if len(sys.argv) > 1 else ORDER[-1]
|
||
if upto not in ORDER:
|
||
print("사용법: bash check.sh [F01~F47]")
|
||
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 = ["?"]
|
||
PUB13 = {"private/drawing.dxf", "private/drawing.svg", "private/drawing_gen.svg"}
|
||
leak = [t for t in tracked if t and t not in PUB13 and (t.startswith("private/") or t.lower().endswith((".svg", ".pdf", ".dwg")))]
|
||
report("F13", tracked != ["?"] and not leak, "커밋된 도면은 공개용 정리본 3개뿐 (SPEC 34장: private/drawing.dxf·drawing.svg·drawing_gen.svg, DWG·그 밖의 private 파일 없음)" + (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 는 짧은 안내서: 🔒 와 ## 제목(실행·파일 구조·검사·사용 도구) (이슈 #1)" + (f" — 없음: {', '.join(rmiss)}" if rmiss 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.mkdtemp(prefix="d3w_") # 실행마다 따로 — 여러 폴더에서 동시에 검사해도 임시 파일이 섞이지 않게
|
||
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 를 가리킨다")
|
||
|
||
# ---- F47: md 가이드 최소화 (이슈 #1) ----
|
||
try:
|
||
trk47 = [t for t in subprocess.run(["git", "ls-files"], capture_output=True, text=True).stdout.split("\n") if t]
|
||
except OSError:
|
||
trk47 = []
|
||
md47 = [t for t in trk47 if t.lower().endswith(".md")]
|
||
sz47 = sum(os.path.getsize(t) for t in md47 if os.path.exists(t))
|
||
report("F47", bool(trk47) and len(md47) <= 3 and sz47 <= 8192, f"저장소 md 3개 이하·합계 8KB 이하 (실제 {len(md47)}개 {sz47} B: {', '.join(md47)})")
|
||
gone47 = [t for t in ("FEATURES.json", "PROGRESS.md") if t in trk47 or os.path.exists(t)]
|
||
report("F47", bool(trk47) and not gone47, "FEATURES.json·PROGRESS.md 없음 — 할 일은 이슈, 경과는 커밋" + (f" — 남음: {gone47}" if gone47 else ""))
|
||
cl47 = read("CLAUDE.md") or ""
|
||
report("F47", "Gitea 이슈" in cl47 and "그 이슈만" in cl47 and "(#이슈번호)" in cl47, "CLAUDE.md 에 이슈 규칙(작업 목록은 이슈·그 이슈만 읽기·커밋에 #번호)")
|
||
|
||
# ---- 이슈별 검사: checks/i<번호>_<이름>.py (이슈 #N 의 완료 기준) ----
|
||
# 이슈마다 파일 하나라 여러 이슈를 동시에 해도 check.sh 는 바뀌지 않는다.
|
||
# 검사 파일은 위의 값(MODEL·html·viewer·read·near 등)을 쓰고 check(ok, 메시지) 로 판정한다.
|
||
def issue_no(p):
|
||
m = re.match(r"i(\d+)_", os.path.basename(p))
|
||
return int(m.group(1)) if m else 10 ** 6
|
||
ichecks = sorted(glob.glob("checks/i*.py"), key=lambda p: (issue_no(p), p))
|
||
for cp in ichecks:
|
||
def check(ok, msg, cid=(f"#{issue_no(cp)}" if issue_no(cp) < 10 ** 6 else os.path.basename(cp))):
|
||
tag = "SKIP" if LIMIT < len(ORDER) - 1 else ("PASS" if ok else "FAIL")
|
||
count[tag] += 1
|
||
print(f"[{tag}] {cid} {msg}")
|
||
g = dict(globals())
|
||
g["check"] = check
|
||
try:
|
||
exec(compile(read(cp) or "", cp, "exec"), g)
|
||
except (Exception, SystemExit) as e:
|
||
check(False, f"{cp} 실행 오류: {type(e).__name__}: {e}")
|
||
print(f"이슈 검사 파일 {len(ichecks)}개" + (f": {', '.join(os.path.basename(p) for p in ichecks)}" if ichecks else ""))
|
||
|
||
shutil.rmtree(TMP, ignore_errors=True)
|
||
print(f"결과: PASS {count['PASS']} / FAIL {count['FAIL']} / SKIP {count['SKIP']} (검사 범위 F01~{upto})")
|
||
sys.exit(0 if count["FAIL"] == 0 else 1)
|
||
PY
|