Import S-CANVAS source + iter=1~7 lint cleanup

S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

701
validate_gate_params.py Normal file
View File

@@ -0,0 +1,701 @@
"""validate_gate_params.py — Gate(여수로 수문) Params JSON 정적 검증기.
Blender 헤드리스 실행 *전*에 JSON 파일 하나만 가지고:
- 필수 필드 / 타입 / 물리적 일관성 검사
- 빌더(gate_3d_builder_bpy.py)가 어떤 분기로 갈지 예측
(pier: Phase B' polygon vs parametric / bridge: user vs extracted vs parametric)
- 예상 객체 수 계산
- 잠재 이슈 경고
bpy를 import하지 않으므로 S-CANVAS env / 일반 Python / 어디서든 실행 가능.
검증 로직은 빌더의 `_validate_pier_polys` / `_validate_bridge_bbox` 과 정확히 동일.
즉 본 검증이 통과하면 빌더에서도 같은 분기 선택 보장.
----------------------------------------------------------------------
사용법
----------------------------------------------------------------------
python validate_gate_params.py gate_params.json
Exit code:
0 PASS — 빌드 진행 가능, 모든 분기가 의도대로 작동
1 WARN — 빌드는 되나 일부 폴백 적용 (parametric 경로 등)
2 FAIL — 빌드 시 빈 결과/예외 가능성 높음 (필수 필드 누락 등)
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
from typing import Any
# ===========================================================================
# 검증 로직 — 빌더와 정확히 동일한 sanity check
# ===========================================================================
def validate_pier_polys(pier_polys: list,
pier_width: float,
pier_length: float,
tol_ratio: float = 0.5) -> tuple[bool, list[str]]:
"""gate_3d_builder_bpy.GateBuilderBpy._validate_pier_polys 와 동일.
Returns: (passed, [개별 사유 메시지])
"""
reasons = []
w_lo = pier_width * (1 - tol_ratio)
w_hi = pier_width * (1 + tol_ratio)
l_lo = pier_length * 0.4
l_hi = pier_length * 1.5
all_ok = True
for i, poly in enumerate(pier_polys):
xs = [p[0] for p in poly]
ys = [p[1] for p in poly]
if len(xs) < 3:
reasons.append(f"pier#{i}: vertex < 3 ({len(xs)})")
all_ok = False
continue
w = max(xs) - min(xs)
l = max(ys) - min(ys)
if not (w_lo <= w <= w_hi):
reasons.append(f"pier#{i}: width {w:.2f}m 범위 [{w_lo:.2f},{w_hi:.2f}] 벗어남")
all_ok = False
if not (l_lo <= l <= l_hi):
reasons.append(f"pier#{i}: length {l:.2f}m 범위 [{l_lo:.2f},{l_hi:.2f}] 벗어남")
all_ok = False
return all_ok, reasons
def validate_bridge_bbox(bbox: tuple | None,
total_span: float,
pier_length: float) -> tuple[bool, str]:
"""gate_3d_builder_bpy.GateBuilderBpy._validate_bridge_bbox 와 동일.
Returns: (passed, 사유 메시지)
"""
if bbox is None:
return False, "bbox None"
if not isinstance(bbox, (list, tuple)) or len(bbox) != 4:
return False, f"bbox 형식 오류 (len={len(bbox) if hasattr(bbox, '__len__') else '?'})"
x0, y0, x1, y1 = bbox
w = x1 - x0
h = y1 - y0
if w < 1.0 or h < 0.5:
return False, f"너무 작음 (W={w:.2f}m, H={h:.2f}m)"
if not (total_span * 0.2 <= w <= total_span * 1.5):
return False, (
f"width {w:.2f}m 가 total_span 범위 "
f"[{total_span * 0.2:.2f}, {total_span * 1.5:.2f}] 벗어남"
)
if not (pier_length * 0.05 <= h <= pier_length * 1.2):
return False, (
f"depth {h:.2f}m 가 pier_length 범위 "
f"[{pier_length * 0.05:.2f}, {pier_length * 1.2:.2f}] 벗어남"
)
return True, "OK"
# ===========================================================================
# 색상 출력 (Windows cmd / PowerShell / Linux 모두 지원)
# ===========================================================================
class C:
"""ANSI 컬러 — Windows 10+ cmd는 자동 활성화. 미지원 환경은 빈 문자열."""
RESET = "\033[0m"
BOLD = "\033[1m"
GRAY = "\033[90m"
RED = "\033[91m"
GREEN = "\033[92m"
YELLOW = "\033[93m"
BLUE = "\033[94m"
CYAN = "\033[96m"
def _supports_color() -> bool:
if sys.platform == "win32":
# Windows 10 1607+ 에서 ANSI 지원. 환경변수로 비활성화 가능.
if "NO_COLOR" in __import__("os").environ:
return False
try:
import ctypes
kernel32 = ctypes.windll.kernel32
kernel32.SetConsoleMode(kernel32.GetStdHandle(-11), 7)
return True
except Exception:
return False
return sys.stdout.isatty()
if not _supports_color():
for _attr in ("RESET", "BOLD", "GRAY", "RED", "GREEN", "YELLOW", "BLUE", "CYAN"):
setattr(C, _attr, "")
def _ok(msg: str) -> str: return f"{C.GREEN}{C.RESET} {msg}"
def _warn(msg: str) -> str: return f"{C.YELLOW}{C.RESET} {msg}"
def _fail(msg: str) -> str: return f"{C.RED}{C.RESET} {msg}"
def _info(msg: str) -> str: return f"{C.CYAN}·{C.RESET} {msg}"
def _h(title: str) -> str:
return f"\n{C.BOLD}{C.BLUE}── {title} {'' * max(2, 60 - len(title))}{C.RESET}"
# ===========================================================================
# 검증 컨텍스트
# ===========================================================================
class ReportLevel:
PASS = 0
WARN = 1
FAIL = 2
class Report:
def __init__(self):
self.level = ReportLevel.PASS
self.lines: list[str] = []
self.fail_count = 0
self.warn_count = 0
def ok(self, msg: str):
self.lines.append(_ok(msg))
def info(self, msg: str):
self.lines.append(_info(msg))
def warn(self, msg: str):
self.lines.append(_warn(msg))
self.warn_count += 1
self.level = max(self.level, ReportLevel.WARN)
def fail(self, msg: str):
self.lines.append(_fail(msg))
self.fail_count += 1
self.level = ReportLevel.FAIL
def header(self, title: str):
self.lines.append(_h(title))
def blank(self):
self.lines.append("")
def raw(self, text: str):
self.lines.append(text)
def emit(self):
for line in self.lines:
print(line)
# ===========================================================================
# 검증 단계들
# ===========================================================================
def _is_number(x: Any) -> bool:
return isinstance(x, (int, float)) and not isinstance(x, bool)
def check_required_fields(d: dict, R: Report) -> bool:
"""필수 필드 존재 + 타입 검증. False 반환 시 후속 검사 skip."""
R.header("1. 필수 필드 / 타입")
required_numeric = [
"n_gates", "gate_width", "gate_height",
"pier_width", "pier_length",
"el_gate_sill", "el_weir_crest", "el_gate_top",
"el_trunnion_pin", "el_mwl", "el_nhwl", "el_lwl",
"el_downstream", "el_upstream_bed", "el_bridge_top",
"total_span", "total_length",
]
required_lists = ["ogee_profile", "gate_centers_x"]
fatal = False
for key in required_numeric:
if key not in d:
R.fail(f"필드 누락: {key}")
fatal = True
elif not _is_number(d[key]):
R.fail(f"필드 {key}={d[key]!r} (number 아님)")
fatal = True
for key in required_lists:
if key not in d:
R.fail(f"필드 누락: {key}")
fatal = True
elif not isinstance(d[key], list):
R.fail(f"필드 {key}={d[key]!r} (list 아님)")
fatal = True
if not fatal:
R.ok(f"필수 numeric 필드 {len(required_numeric)}개 존재")
R.ok(f"필수 list 필드 {len(required_lists)}개 존재")
return not fatal
def check_physical_consistency(d: dict, R: Report) -> None:
"""표고 순서 / 치수 양수 / 게이트 개수 일관성."""
R.header("2. 물리적 일관성")
# 표고 순서
bed = d["el_upstream_bed"]
sill = d["el_gate_sill"]
crest = d["el_weir_crest"]
top = d["el_gate_top"]
bridge = d["el_bridge_top"]
el_chain = [
("el_upstream_bed", bed),
("el_gate_sill", sill),
("el_weir_crest", crest),
("el_gate_top", top),
("el_bridge_top", bridge),
]
from itertools import pairwise
chain_ok = True
for (n_a, v_a), (n_b, v_b) in pairwise(el_chain):
if v_a > v_b + 1e-6:
R.fail(f"표고 역전: {n_a}={v_a:.3f} > {n_b}={v_b:.3f}")
chain_ok = False
if chain_ok:
R.ok(f"표고 단조 증가: {bed:.2f}{sill:.2f}{crest:.2f}{top:.2f}{bridge:.2f}")
# 수위 표고
lwl = d["el_lwl"]; nhwl = d["el_nhwl"]; mwl = d["el_mwl"]
wl_ok = True
if not (lwl <= nhwl + 1e-6):
R.fail(f"수위 역전: LWL={lwl:.2f} > NHWL={nhwl:.2f}")
wl_ok = False
if not (nhwl <= mwl + 1e-6):
R.fail(f"수위 역전: NHWL={nhwl:.2f} > MWL={mwl:.2f}")
wl_ok = False
if wl_ok:
R.ok(f"수위 단조: LWL {lwl:.2f} ≤ NHWL {nhwl:.2f} ≤ MWL {mwl:.2f}")
# NHWL이 게이트 sill 위쪽인지 (수면이 수문 아래면 본체가 물에 잠기지 않는 경우)
if nhwl <= sill:
R.warn(f"NHWL={nhwl:.2f}이 sill={sill:.2f}보다 낮음 — 상류 수면이 수문 아래로 그려짐")
# 양의 치수
n_gates = d["n_gates"]
gate_w = d["gate_width"]
gate_h_param = d["gate_height"]
pier_w = d["pier_width"]
pier_l = d["pier_length"]
total_span = d["total_span"]
if n_gates < 1:
R.fail(f"n_gates={n_gates} (1 이상이어야 함)")
elif n_gates > 20:
R.warn(f"n_gates={n_gates} (>20, 비정상적으로 많음)")
else:
R.ok(f"n_gates={n_gates}")
for name, val in [("gate_width", gate_w), ("gate_height", gate_h_param),
("pier_width", pier_w), ("pier_length", pier_l),
("total_span", total_span)]:
if val <= 0:
R.fail(f"{name}={val} ≤ 0")
# gate_height 와 sill→top 일관성
gate_h_calc = top - sill
if abs(gate_h_param - gate_h_calc) > 0.5:
R.warn(
f"gate_height={gate_h_param:.2f} 가 (top - sill)={gate_h_calc:.2f} 와 0.5m 이상 차이"
)
else:
R.ok(f"gate_height={gate_h_param:.2f}m ≈ (top - sill)={gate_h_calc:.2f}m")
# gate_centers_x 와 n_gates 일관성
gate_centers = d["gate_centers_x"]
if len(gate_centers) != n_gates:
R.warn(
f"len(gate_centers_x)={len(gate_centers)} ≠ n_gates={n_gates} "
f"— builder 내부에서 보정될 수 있음"
)
else:
R.ok(f"gate_centers_x: {n_gates}개 일치")
# gate_centers_x 단조 증가 (sorted)
if len(gate_centers) >= 2:
if not all(gate_centers[i] <= gate_centers[i + 1] + 1e-6
for i in range(len(gate_centers) - 1)):
R.warn("gate_centers_x 가 정렬되지 않음 — pier_x_centers 계산이 비정상이 될 수 있음")
else:
spacings = [gate_centers[i+1] - gate_centers[i]
for i in range(len(gate_centers) - 1)]
avg = sum(spacings) / len(spacings)
min_s = min(spacings); max_s = max(spacings)
R.ok(
f"gate_centers_x 정렬 OK — 평균 간격 {avg:.2f}m "
f"(범위 {min_s:.2f}~{max_s:.2f})"
)
# pier_count
pier_count = d.get("pier_count", n_gates + 1)
if pier_count != n_gates + 1:
R.warn(f"pier_count={pier_count} ≠ n_gates+1={n_gates + 1}")
else:
R.ok(f"pier_count = n_gates+1 = {pier_count}")
def check_ogee_profile(d: dict, R: Report) -> bool:
"""ogee_profile 점 개수 / 단조 증가 / 표고 범위.
Returns: True if 본체 빌드가 가능 (점 ≥ 3)
"""
R.header("3. Ogee 프로파일 (여수로 본체)")
profile = d.get("ogee_profile", [])
n = len(profile)
if n < 3:
R.fail(f"ogee_profile 점 {n}개 (< 3) — 빌더가 본체를 그리지 못함")
return False
# 각 점이 (x, z) 쌍인지
bad = 0
for i, pt in enumerate(profile):
if not (isinstance(pt, (list, tuple)) and len(pt) == 2
and _is_number(pt[0]) and _is_number(pt[1])):
bad += 1
if bad:
R.fail(f"ogee_profile 에서 {bad}개 점이 (x, z) 형식 아님")
return False
R.ok(f"ogee_profile: {n}개 점, 모두 (x, z) 형식 OK")
xs = [pt[0] for pt in profile]
zs = [pt[1] for pt in profile]
# x 단조 증가 (엄격하지 않음 — ogee의 상류 수직 부분은 x 동일점 허용)
decreasing = sum(1 for i in range(n - 1) if xs[i + 1] < xs[i] - 1e-6)
if decreasing > 0:
R.warn(
f"ogee_profile.x 가 {decreasing}회 감소 — "
f"prism 단면이 자기교차할 수 있음"
)
else:
R.ok(f"ogee_profile.x 단조 증가 (range {min(xs):.2f}~{max(xs):.2f}m)")
# z 범위 표고와 일치
z_min = min(zs); z_max = max(zs)
sill = d["el_gate_sill"]; crest = d["el_weir_crest"]
bed = d["el_upstream_bed"]
if z_min > sill - 0.1:
R.warn(
f"ogee z_min={z_min:.2f} 가 sill={sill:.2f}보다 높음 — "
f"본체 바닥이 게이트 sill 위로 올라감"
)
if z_max < crest - 0.5:
R.warn(
f"ogee z_max={z_max:.2f} 가 crest={crest:.2f}보다 낮음 — "
f"본체가 weir crest에 미치지 못함"
)
if z_min < bed - 5.0:
R.warn(
f"ogee z_min={z_min:.2f}가 upstream_bed={bed:.2f}보다 5m 이상 깊음"
)
R.info(f"ogee z-range: {z_min:.2f} ~ {z_max:.2f}m (span {z_max - z_min:.2f}m)")
return True
def predict_pier_branch(d: dict, R: Report) -> tuple[str, int]:
"""빌더의 pier 빌드 분기 예측.
Returns: (branch_name, expected_pier_object_count)
branch_name ∈ {"phase_b_polygon", "parametric"}
"""
R.header("4. Pier 빌드 분기 예측")
pier_polys = d.get("pier_plan_polygons", [])
n_gates = d["n_gates"]
expected_n_piers = n_gates + 1
pier_w = d["pier_width"]
pier_l = d["pier_length"]
R.info(f"기대 pier 개수: n_gates+1 = {expected_n_piers}")
R.info(f"sanity 기준: pier_width={pier_w:.2f}m × {0.5}~{1.5}, "
f"pier_length={pier_l:.2f}m × {0.4}~{1.5}")
# Phase B' 경로 후보
if not pier_polys:
R.warn("pier_plan_polygons 비어있음 → parametric 폴백")
# parametric: body + nose × n_piers
return "parametric", expected_n_piers * 2
if len(pier_polys) != expected_n_piers:
R.warn(
f"pier_plan_polygons 개수={len(pier_polys)}{expected_n_piers} "
f"→ parametric 폴백"
)
return "parametric", expected_n_piers * 2
# 각 폴리곤 sanity
passed, reasons = validate_pier_polys(pier_polys, pier_w, pier_l)
if not passed:
R.warn("pier 폴리곤 sanity 실패 → parametric 폴백:")
for r in reasons:
R.raw(f" - {r}")
return "parametric", expected_n_piers * 2
R.ok(f"Phase B' 경로 통과 — 폴리곤 {len(pier_polys)}개 sanity OK")
return "phase_b_polygon", expected_n_piers
def predict_bridge_branch(d: dict, R: Report) -> tuple[str, int]:
"""빌더의 bridge 빌드 분기 예측.
Returns: (branch_name, expected_object_count)
branch_name ∈ {"none", "user", "extracted", "parametric"}
(deck 1 + rail 2 = 3 또는 0)
"""
R.header("5. Bridge 빌드 분기 예측")
if not d.get("has_service_bridge", False):
R.info("has_service_bridge=False → 공도교 빌드 안 함")
return "none", 0
total_span = d["total_span"]
pier_l = d["pier_length"]
# 1순위: 사용자 명시
ux0 = d.get("bridge_x_start")
ux1 = d.get("bridge_x_end")
uy0 = d.get("bridge_y_start")
uy1 = d.get("bridge_y_end")
user_complete = (
ux0 is not None and ux1 is not None and ux1 > ux0
and uy0 is not None and uy1 is not None and uy1 > uy0
)
if user_complete:
cand = (float(ux0), float(uy0), float(ux1), float(uy1))
ok, reason = validate_bridge_bbox(cand, total_span, pier_l)
if ok:
R.ok(
f"User override 통과: bbox=({cand[0]:.2f},{cand[1]:.2f}) "
f"→ ({cand[2]:.2f},{cand[3]:.2f})"
)
return "user", 3
else:
R.warn(f"User override 실패 ({reason}) → 다음 단계 검사")
# 2순위: 파서 추출
bbox = d.get("bridge_plan_bbox")
if bbox is not None:
if isinstance(bbox, list):
bbox = tuple(bbox)
ok, reason = validate_bridge_bbox(bbox, total_span, pier_l)
if ok:
R.ok(
f"파서 추출 bbox 통과: ({bbox[0]:.2f},{bbox[1]:.2f}) "
f"→ ({bbox[2]:.2f},{bbox[3]:.2f})"
)
return "extracted", 3
else:
R.warn(f"파서 추출 bbox 실패 ({reason}) → parametric 폴백")
else:
R.info("bridge_plan_bbox=None → parametric 폴백")
# 3순위: parametric
pier_w = d["pier_width"]
px0 = -pier_w * 0.5
px1 = total_span + pier_w * 0.5
py0 = pier_l * 0.3
py1 = pier_l * 0.55
R.info(
f"Parametric bbox 사용: ({px0:.2f},{py0:.2f}) → ({px1:.2f},{py1:.2f}) "
f"= W{px1-px0:.1f}m × H{py1-py0:.1f}m"
)
return "parametric", 3
def predict_object_count(d: dict, R: Report,
pier_branch: str, pier_objs: int,
bridge_branch: str, bridge_objs: int) -> int:
"""전체 예상 객체 수 합산."""
R.header("6. 예상 객체 수")
n_gates = d["n_gates"]
n_piers = n_gates + 1
has_hoist = d.get("has_hoist_housings", True)
has_water = d.get("has_water_surface", True)
has_apron = d.get("has_downstream_apron", True)
# ogee_profile이 있어야 본체 빌드됨
ogee_ok = len(d.get("ogee_profile", [])) >= 3
body = 1 if ogee_ok else 0
# gates: skin + arms × 2 = 3 per gate
gates = n_gates * 3 if d.get("gate_centers_x") else 0
# hoists: body + roof = 2 per pier
hoists = n_piers * 2 if has_hoist else 0
water = 1 if has_water else 0
apron = 1 if has_apron else 0
rows = [
("SpillwayBody", body, "(ogee_profile ≥ 3pts)" if ogee_ok else "(ogee 부족)"),
("Piers", pier_objs, f"({pier_branch})"),
("Gates (skin+arms)", gates, f"({n_gates} × 3)"),
("ServiceBridge", bridge_objs, f"({bridge_branch})"),
("Hoists", hoists, f"({n_piers} × 2)" if has_hoist else "(disabled)"),
("Water", water, "" if has_water else "(disabled)"),
("Apron", apron, "" if has_apron else "(disabled)"),
]
total = sum(n for _, n, _ in rows)
R.raw("")
R.raw(f" {C.BOLD}{'Component':<22}{'Count':>6} Notes{C.RESET}")
R.raw(f" {'' * 22}{'' * 6} {'' * 30}")
for name, count, note in rows:
color = C.GRAY if (count == 0 and "disabled" not in note and "부족" not in note) or count == 0 else C.RESET
R.raw(f" {color}{name:<22}{count:>6} {note}{C.RESET}")
R.raw(f" {'' * 22}{'' * 6}")
R.raw(f" {C.BOLD}{'Total':<22}{total:>6}{C.RESET}")
R.raw("")
R.raw(f" {C.CYAN}→ Blender 콘솔에서 '[bpy-gate] Created {total} objects' "
f"가 보여야 정상{C.RESET}")
return total
def check_secondary(d: dict, R: Report) -> None:
"""추가 sanity / 정보성 검사."""
R.header("7. 추가 검사 (정보·경고)")
# flow_direction_2d 단위벡터 확인
fd = d.get("flow_direction_2d")
if fd is not None:
if isinstance(fd, list):
fd = tuple(fd)
if len(fd) == 2 and _is_number(fd[0]) and _is_number(fd[1]):
mag = math.sqrt(fd[0] ** 2 + fd[1] ** 2)
if abs(mag - 1.0) > 0.05:
R.warn(f"flow_direction_2d magnitude={mag:.4f} (1.0과 0.05 이상 차이)")
else:
R.ok(f"flow_direction_2d=({fd[0]:+.3f},{fd[1]:+.3f}), |v|={mag:.4f}")
else:
R.warn(f"flow_direction_2d 형식 비정상: {fd!r}")
# plan_outline_polygon
outline = d.get("plan_outline_polygon", [])
if outline:
n = len(outline)
if n < 4:
R.warn(f"plan_outline_polygon 점 {n}개 (< 4) — 평면 외곽이 너무 단순")
else:
R.ok(f"plan_outline_polygon: {n}개 점")
# Trunnion이 mid_el과 너무 다르면 빌더가 mid로 강제
sill = d["el_gate_sill"]; top = d["el_gate_top"]
mid_el = (sill + top) / 2
trun = d["el_trunnion_pin"]
if abs(trun - mid_el) > 0.5:
R.info(
f"trunnion EL.{trun:.2f} ↔ mid EL.{mid_el:.2f} (차이 {abs(trun-mid_el):.2f}m) "
f"→ 빌더가 trunnion_el = mid_el로 강제 사용"
)
else:
R.ok(f"trunnion EL.{trun:.2f} ≈ mid EL.{mid_el:.2f} (그대로 사용)")
# source_files 정보
sf = d.get("source_files", [])
if sf:
R.info(f"source_files: {len(sf)}")
for f in sf:
R.raw(f" - {Path(f).name if isinstance(f, str) else f}")
# raw_text_annotations 양 (참고용)
rta = d.get("raw_text_annotations", [])
R.info(f"raw_text_annotations: {len(rta)}개 (디버그 정보)")
# ===========================================================================
# 진입점
# ===========================================================================
def validate_file(json_path: str) -> int:
R = Report()
R.raw("")
R.raw(f"{C.BOLD}Gate Params 검증{C.RESET}{C.GRAY}{json_path}{C.RESET}")
p = Path(json_path)
if not p.exists():
R.fail(f"파일 없음: {json_path}")
R.emit()
return ReportLevel.FAIL
try:
text = p.read_text(encoding="utf-8")
except UnicodeDecodeError:
text = p.read_text(encoding="utf-8-sig")
try:
d = json.loads(text)
except json.JSONDecodeError as e:
R.fail(f"JSON 파싱 실패: {e}")
R.emit()
return ReportLevel.FAIL
if not isinstance(d, dict):
R.fail(f"최상위가 dict 아님: {type(d).__name__}")
R.emit()
return ReportLevel.FAIL
R.info(f"필드 수: {len(d)}, 파일 크기: {p.stat().st_size:,} bytes")
# 1. 필수 필드
if not check_required_fields(d, R):
R.header("결론")
R.fail("필수 필드 누락 — 빌더 실행 불가")
R.emit()
return ReportLevel.FAIL
# 2. 물리적 일관성
check_physical_consistency(d, R)
# 3. ogee_profile
check_ogee_profile(d, R)
# 4. pier 분기 예측
pier_branch, pier_objs = predict_pier_branch(d, R)
# 5. bridge 분기 예측
bridge_branch, bridge_objs = predict_bridge_branch(d, R)
# 6. 예상 객체 수
total = predict_object_count(d, R, pier_branch, pier_objs, bridge_branch, bridge_objs)
# 7. 추가 검사
check_secondary(d, R)
# 결론
R.header("결론")
if R.level == ReportLevel.PASS:
R.raw(f"{C.GREEN}{C.BOLD}✓ PASS{C.RESET} — 빌드 진행 가능. "
f"예상 객체 {total}")
elif R.level == ReportLevel.WARN:
R.raw(f"{C.YELLOW}{C.BOLD}⚠ WARN{C.RESET}"
f"빌드는 가능하나 폴백/주의 사항 {R.warn_count}건. "
f"예상 객체 {total}")
else:
R.raw(f"{C.RED}{C.BOLD}✗ FAIL{C.RESET}"
f"FAIL {R.fail_count}건 / WARN {R.warn_count}건. 빌드 전 수정 권장")
R.raw("")
R.emit()
return R.level
def main():
if len(sys.argv) < 2:
print("Usage: python validate_gate_params.py <gate_params.json>")
sys.exit(2)
json_path = sys.argv[1]
sys.exit(validate_file(json_path))
if __name__ == "__main__":
main()