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>
702 lines
24 KiB
Python
702 lines
24 KiB
Python
"""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()
|