"""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 ") sys.exit(2) json_path = sys.argv[1] sys.exit(validate_file(json_path)) if __name__ == "__main__": main()