"""여수로 수문(Gate) 3D 파라메트릭 빌더 — Blender bpy 포트.

원본: gate_3d_builder.py (PyVista + VTK 기반)
이 파일: 동일 로직을 Blender 4.x bpy로 1:1 포팅.

좌표계 (원본과 동일):
- X: dam axis (span, 수문이 나란히 배치되는 방향)
- Y: 유출방향 (upstream → downstream)
- Z: 표고 (해발 m)

구성요소 (원본과 동일):
1. 여수로 본체 (ogee 프로파일 → span 방향 prism extrude)
2. 교각 (pier) n+1개 — Phase B' 폴리곤 우선, parametric 폴백
3. 래디얼 게이트 (Tainter gate) n개 — 원호 곡면 스킨 + 양측 암
4. 공도교 (service bridge) — 데크 + 양측 난간
5. 여수로 개폐장치 (gate hoist) — 각 pier 상면에 박스 + 지붕
6. 상류 수면 (N.H.W.L 평판)
7. 하류 에이프런

----------------------------------------------------------------------
변경 이력 (이 파일)
----------------------------------------------------------------------
v3 (지형 합성 워크플로 지원):
  - [feat] setup_lighting_and_camera()에 transparent_bg 옵션 추가.
           True면 Sky 텍스처를 비우고 World 알파 0 → 합성 입력으로 적합.
  - [feat] render_to_png()에 transparent_bg 옵션 추가.
           film.transparent=True + RGBA 출력. PNG 알파 채널 보존.
  - [feat] CLI에 --transparent 토글 추가.
  주의: 두 함수 모두 기본값은 False (기존 동작 유지). 기존 호출자 영향 없음.

v2 (검증 후 수정):
  - [bug-fix] _make_yz_extrude_x_mesh / _make_xy_polygon_extrude_mesh 끝면
              fan winding 정정 + enclosed mesh에 recalc_face_normals 안전망

----------------------------------------------------------------------
사용법
----------------------------------------------------------------------

[A] 헤드리스 (S-CANVAS 파이프라인 통합):
  1) S-CANVAS 환경에서:
       from gate_parser import parse_gate_dxf
       from params_to_json import dump_dataclass_to_json
       p = parse_gate_dxf(plan_dxf, section_dxf)
       dump_dataclass_to_json(p, "gate_params.json")
  2) Blender 헤드리스 실행:
       blender --background --python gate_3d_builder_bpy.py -- ^
           --params gate_params.json ^
           --render gate.png --transparent      ← 합성용 투명 PNG

[B] Blender GUI 안에서 직접:
    import sys; sys.path.append(r"D:\\2026\\PROGRAM\\1_S-CANVAS")
    from gate_3d_builder_bpy import GateBuilderBpy, params_from_dict
    import json
    p = params_from_dict(json.load(open("gate_params.json", encoding="utf-8")))
    GateBuilderBpy(p).build_all()
"""

from __future__ import annotations

import json
import math
import sys
from dataclasses import asdict, dataclass, field, fields
from pathlib import Path
from typing import Any, ClassVar

import bpy
import bmesh
from mathutils import Matrix, Vector, Quaternion
import contextlib


# ===========================================================================
# 색상 팔레트 (원본과 동일)
# ===========================================================================

COLORS = {
    "concrete":        "#B8B5A8",  # 콘크리트
    "pier":            "#A8A59B",  # 교각 (약간 밝게)
    "gate_panel":      "#3D4A5C",  # 수문 강재 (암회색)
    "gate_frame":      "#5A4A3A",  # 수문 프레임
    "bridge_deck":     "#8B8B8B",  # 공도교 상판
    "bridge_rail":     "#4A4A4A",  # 난간
    "gate_hoist":      "#D4A373",  # 여수로 개폐장치 본체
    "gate_hoist_roof": "#3A3A3A",  # 개폐장치 지붕
    "water":           "#3A7AA8",  # 수면
    "apron":           "#9A968C",  # 여수로 에이프런
}

# 컬러별 PBR 프리셋. PyVista 평면색 → 사실적 재질로 업그레이드.
_PBR_BY_COLOR = {
    "#B8B5A8": dict(metallic=0.00, roughness=0.85),                       # concrete
    "#A8A59B": dict(metallic=0.00, roughness=0.80),                       # pier
    "#3D4A5C": dict(metallic=1.00, roughness=0.40),                       # gate steel skin
    "#5A4A3A": dict(metallic=0.50, roughness=0.55),                       # gate frame
    "#8B8B8B": dict(metallic=0.10, roughness=0.70),                       # bridge deck
    "#4A4A4A": dict(metallic=1.00, roughness=0.45),                       # bridge rail
    "#D4A373": dict(metallic=0.20, roughness=0.65),                       # hoist housing
    "#3A3A3A": dict(metallic=0.30, roughness=0.55),                       # hoist roof
    "#3A7AA8": dict(metallic=0.00, roughness=0.05, transmission=0.85),    # water
    "#9A968C": dict(metallic=0.00, roughness=0.85),                       # apron
}


# ===========================================================================
# 데이터 클래스 (gate_parser.GateParams 와 동일 shape)
# ===========================================================================

@dataclass
class GateParams:
    """gate_parser.GateParams와 동일한 필드 구조 (JSON 라운드트립용 미러)."""
    # 수문 본체
    n_gates: int = 3
    gate_width: float = 15.0
    gate_height: float = 7.0

    # 교각
    pier_count: int = 4
    pier_width: float = 3.0
    pier_length: float = 25.0

    # 주요 표고
    el_gate_sill: float = 46.700
    el_stoplog_sill: float = 46.000
    el_weir_crest: float = 47.000
    el_gate_top: float = 53.700
    el_trunnion_pin: float = 50.200
    el_mwl: float = 53.830
    el_nhwl: float = 52.500
    el_lwl: float = 45.000
    el_downstream: float = 44.000
    el_upstream_bed: float = 41.500
    el_bridge_top: float = 56.000

    # 구조 전체 (평면)
    total_span: float = 75.0
    total_length: float = 25.0
    dam_axis_y: float = 0.0

    # 단면 / 평면 폴리라인
    ogee_profile: list = field(default_factory=list)
    plan_outline_upstream: list = field(default_factory=list)
    plan_outline_downstream: list = field(default_factory=list)
    gate_centers_x: list = field(default_factory=list)
    plan_frame_angle_deg: float = 0.0

    # 부속 구조물 플래그
    has_service_bridge: bool = False
    has_hoist_housings: bool = True
    has_downstream_apron: bool = True
    has_water_surface: bool = True

    # Phase B' 도면 기하
    plan_outline_polygon: list = field(default_factory=list)
    pier_plan_polygons: list = field(default_factory=list)
    bridge_plan_bbox: tuple | None = None
    bridge_deck_thickness_m: float = 1.2

    # 사용자 override
    bridge_x_start: float | None = None
    bridge_x_end: float | None = None
    bridge_y_start: float | None = None
    bridge_y_end: float | None = None

    # 메타
    flow_direction_2d: tuple | None = None
    source_files: list = field(default_factory=list)
    raw_text_annotations: list = field(default_factory=list)


def params_from_dict(d: dict) -> GateParams:
    """JSON dict → GateParams. 알 수 없는 필드는 무시."""
    known = {f.name for f in fields(GateParams)}
    payload = {k: v for k, v in d.items() if k in known}

    # tuple로 저장된 필드 복원 (JSON에선 list)
    if "bridge_plan_bbox" in payload and payload["bridge_plan_bbox"] is not None:
        v = payload["bridge_plan_bbox"]
        payload["bridge_plan_bbox"] = tuple(v) if isinstance(v, list) else v
    if "flow_direction_2d" in payload and payload["flow_direction_2d"] is not None:
        v = payload["flow_direction_2d"]
        payload["flow_direction_2d"] = tuple(v) if isinstance(v, list) else v

    def _normalize_xy_list(seq):
        if not seq:
            return []
        return [tuple(p) if isinstance(p, list) else p for p in seq]

    for key in ("ogee_profile", "gate_centers_x", "plan_outline_polygon",
                "plan_outline_upstream", "plan_outline_downstream"):
        if key in payload:
            payload[key] = _normalize_xy_list(payload[key])

    if "pier_plan_polygons" in payload:
        payload["pier_plan_polygons"] = [
            _normalize_xy_list(poly) for poly in payload["pier_plan_polygons"]
        ]

    return GateParams(**payload)


def dump_params_to_json(params, path: str) -> None:
    """GateParams → JSON 파일."""
    def _to_serializable(obj):
        if hasattr(obj, "__dataclass_fields__"):
            return {k: _to_serializable(v) for k, v in asdict(obj).items()}
        if isinstance(obj, (list, tuple)):
            return [_to_serializable(x) for x in obj]
        if isinstance(obj, dict):
            return {k: _to_serializable(v) for k, v in obj.items()}
        if isinstance(obj, (str, int, float, bool)) or obj is None:
            return obj
        try:
            return float(obj)
        except (TypeError, ValueError):
            return str(obj)

    payload = _to_serializable(params)
    Path(path).write_text(json.dumps(payload, ensure_ascii=False, indent=2),
                          encoding="utf-8")


# ===========================================================================
# 헬퍼
# ===========================================================================

def _hex_to_rgb(hex_color: str) -> tuple[float, float, float]:
    h = hex_color.lstrip("#")
    return (int(h[0:2], 16) / 255.0,
            int(h[2:4], 16) / 255.0,
            int(h[4:6], 16) / 255.0)


def _set_principled_input(bsdf, name: str, value: Any) -> bool:
    candidates = {
        "Transmission": ["Transmission Weight", "Transmission"],
        "Specular":     ["Specular IOR Level", "Specular"],
        "Emission":     ["Emission Color", "Emission"],
    }.get(name, [name])
    for key in candidates:
        if key in bsdf.inputs:
            bsdf.inputs[key].default_value = value
            return True
    return False


def _recalc_outward_normals(bm: bmesh.types.BMesh) -> None:
    """enclosed manifold mesh에 대해 노멀이 모두 outward가 되도록 자동 재계산.

    winding 오류·orientation 불일치를 일괄 정리. open mesh(곡면 strip 등)에는
    부르지 말 것 — 결과가 비결정적일 수 있음.
    """
    # bm.faces 가 비어있거나 manifold 가 아닐 때 폴백 — 그냥 normal_update만
    with contextlib.suppress(Exception):
        bmesh.ops.recalc_face_normals(bm, faces=bm.faces[:])


# ===========================================================================
# 빌더
# ===========================================================================

class GateBuilderBpy:
    """여수로 수문 파라메트릭 3D 빌더 (Blender bpy)."""

    _COLLECTION_OF: ClassVar[dict[str, str]] = {
        "concrete":        "01_SpillwayBody",
        "pier":            "02_Piers",
        "gate_panel":      "03_Gates",
        "gate_frame":      "03_Gates",
        "bridge_deck":     "04_ServiceBridge",
        "bridge_rail":     "04_ServiceBridge",
        "gate_hoist":      "05_Hoists",
        "gate_hoist_roof": "05_Hoists",
        "water":           "06_Site",
        "apron":           "06_Site",
    }
    _HEX_TO_KEY: dict | None = None

    def __init__(self, params: GateParams, *,
                 clear_scene: bool = True,
                 root_collection_name: str = "Gate"):
        self.params = params
        self._materials: dict = {}
        self._collections: dict = {}
        self._object_count = 0
        self._root_name = root_collection_name

        if GateBuilderBpy._HEX_TO_KEY is None:
            GateBuilderBpy._HEX_TO_KEY = {
                COLORS[k]: v for k, v in self._COLLECTION_OF.items() if k in COLORS
            }

        if clear_scene:
            self._clear_scene()
        self._init_collections()

    # ---- 진입점 (원본 build_all과 동일 순서) ----------------------------

    def build_all(self) -> bpy.types.Collection:
        self._build_spillway_body()
        self._build_piers()
        self._build_radial_gates()
        if getattr(self.params, "has_service_bridge", False):
            self._build_service_bridge()
        if getattr(self.params, "has_hoist_housings", True):
            self._build_gate_hoists()
        if getattr(self.params, "has_water_surface", True):
            self._build_water_surface()
        if getattr(self.params, "has_downstream_apron", True):
            self._build_downstream_apron()
        return self._collections[self._root_name]

    # ---- 씬/컬렉션 -----------------------------------------------------

    def _clear_scene(self):
        for obj in list(bpy.data.objects):
            bpy.data.objects.remove(obj, do_unlink=True)
        for coll in list(bpy.data.collections):
            bpy.data.collections.remove(coll)
        for mesh in list(bpy.data.meshes):
            if mesh.users == 0:
                bpy.data.meshes.remove(mesh)

    def _init_collections(self):
        scene = bpy.context.scene
        root = bpy.data.collections.new(self._root_name)
        scene.collection.children.link(root)
        self._collections[self._root_name] = root

        for sub in ["01_SpillwayBody", "02_Piers", "03_Gates",
                    "04_ServiceBridge", "05_Hoists", "06_Site"]:
            c = bpy.data.collections.new(sub)
            root.children.link(c)
            self._collections[sub] = c

    def _collection_for(self, color_hex: str) -> bpy.types.Collection:
        key = self._HEX_TO_KEY.get(color_hex, "01_SpillwayBody")
        return self._collections.get(key, self._collections[self._root_name])

    # ---- 재질 ---------------------------------------------------------

    def _get_material(self, color_hex: str, alpha: float = 1.0) -> bpy.types.Material:
        key = (color_hex, round(alpha, 3))
        if key in self._materials:
            return self._materials[key]

        suffix = "" if alpha >= 0.999 else f"_a{alpha:.2f}"
        mat = bpy.data.materials.new(name=f"Mat_{color_hex.lstrip('#')}{suffix}")
        mat.use_nodes = True
        bsdf = mat.node_tree.nodes.get("Principled BSDF")
        if bsdf is None:
            mat.node_tree.nodes.clear()
            bsdf = mat.node_tree.nodes.new("ShaderNodeBsdfPrincipled")
            output = mat.node_tree.nodes.new("ShaderNodeOutputMaterial")
            mat.node_tree.links.new(bsdf.outputs[0], output.inputs[0])

        r, g, b = _hex_to_rgb(color_hex)
        bsdf.inputs["Base Color"].default_value = (r, g, b, 1.0)

        pbr = _PBR_BY_COLOR.get(color_hex, dict(metallic=0.0, roughness=0.7))
        bsdf.inputs["Metallic"].default_value = pbr["metallic"]
        bsdf.inputs["Roughness"].default_value = pbr["roughness"]
        if "transmission" in pbr:
            _set_principled_input(bsdf, "Transmission", pbr["transmission"])

        if alpha < 0.999:
            bsdf.inputs["Alpha"].default_value = alpha
            mat.blend_method = "BLEND"
            with contextlib.suppress(AttributeError):
                mat.shadow_method = "HASHED"

        self._materials[key] = mat
        return mat

    # ---- 메시 프리미티브 -----------------------------------------------

    def _make_box_mesh(self, name: str,
                       x0: float, x1: float, y0: float, y1: float,
                       z0: float, z1: float) -> bpy.types.Mesh:
        """축정렬 박스 (8 vertex / 6 quad). enclosed manifold."""
        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        v = [
            bm.verts.new((x0, y0, z0)),
            bm.verts.new((x1, y0, z0)),
            bm.verts.new((x1, y1, z0)),
            bm.verts.new((x0, y1, z0)),
            bm.verts.new((x0, y0, z1)),
            bm.verts.new((x1, y0, z1)),
            bm.verts.new((x1, y1, z1)),
            bm.verts.new((x0, y1, z1)),
        ]
        face_indices = [
            (0, 3, 2, 1),  # bottom
            (4, 5, 6, 7),  # top
            (0, 1, 5, 4),  # -Y
            (2, 3, 7, 6),  # +Y
            (1, 2, 6, 5),  # +X
            (0, 4, 7, 3),  # -X
        ]
        for fi in face_indices:
            with contextlib.suppress(ValueError):
                bm.faces.new([v[i] for i in fi])
        _recalc_outward_normals(bm)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_flat_rect_mesh(self, name: str,
                             x0: float, x1: float, y0: float, y1: float,
                             z: float) -> bpy.types.Mesh:
        """수평 사각형 평면 (open — recalc 호출하지 않음)."""
        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        verts = [
            bm.verts.new((x0, y0, z)),
            bm.verts.new((x1, y0, z)),
            bm.verts.new((x1, y1, z)),
            bm.verts.new((x0, y1, z)),
        ]
        with contextlib.suppress(ValueError):
            bm.faces.new(verts)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_yz_extrude_x_mesh(self, name: str,
                                profile_yz: list,
                                x_span: float) -> bpy.types.Mesh:
        """(y, z) 단면 폴리곤을 X 방향으로 extrude한 prism. enclosed manifold."""
        n = len(profile_yz)
        if n < 3:
            return None  # type: ignore

        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        front = [bm.verts.new((0.0, y, z)) for (y, z) in profile_yz]
        back = [bm.verts.new((x_span, y, z)) for (y, z) in profile_yz]

        for i in range(n):
            j = (i + 1) % n
            try:
                bm.faces.new([front[i], front[j], back[j], back[i]])
            except ValueError:
                try:
                    bm.faces.new([front[i], front[j], back[j]])
                    bm.faces.new([front[i], back[j], back[i]])
                except ValueError:
                    pass

        for i in range(1, n - 1):
            with contextlib.suppress(ValueError):
                bm.faces.new([front[0], front[i], front[i + 1]])
            with contextlib.suppress(ValueError):
                bm.faces.new([back[0], back[i + 1], back[i]])

        _recalc_outward_normals(bm)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_xy_polygon_extrude_mesh(self, name: str,
                                      poly_xy: list,
                                      z_bot: float, z_top: float) -> bpy.types.Mesh:
        """(x, y) 폴리곤을 Z 방향으로 extrude한 prism (pier 폴리곤용)."""
        if len(poly_xy) < 3:
            return None  # type: ignore

        pts = list(poly_xy)
        if (abs(pts[0][0] - pts[-1][0]) < 1e-6 and
                abs(pts[0][1] - pts[-1][1]) < 1e-6):
            pts = pts[:-1]
        n = len(pts)
        if n < 3:
            return None  # type: ignore

        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        bot = [bm.verts.new((x, y, z_bot)) for (x, y) in pts]
        top = [bm.verts.new((x, y, z_top)) for (x, y) in pts]

        for i in range(n):
            j = (i + 1) % n
            try:
                bm.faces.new([bot[i], bot[j], top[j], top[i]])
            except ValueError:
                try:
                    bm.faces.new([bot[i], bot[j], top[j]])
                    bm.faces.new([bot[i], top[j], top[i]])
                except ValueError:
                    pass

        for i in range(1, n - 1):
            with contextlib.suppress(ValueError):
                bm.faces.new([bot[0], bot[i], bot[i + 1]])
            with contextlib.suppress(ValueError):
                bm.faces.new([top[0], top[i + 1], top[i]])

        _recalc_outward_normals(bm)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_pier_nose_mesh(self, name: str,
                             cx: float, width: float,
                             y_front: float,
                             z_bot: float, z_top: float) -> bpy.types.Mesh:
        """교각 상류측 삼각형 물가르기 노즈. enclosed manifold."""
        half_w = width / 2
        nose_len = width * 1.2
        y_tip = y_front - nose_len

        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        v = [
            bm.verts.new((cx - half_w, y_front, z_bot)),  # 0
            bm.verts.new((cx + half_w, y_front, z_bot)),  # 1
            bm.verts.new((cx,           y_tip,   z_bot)), # 2
            bm.verts.new((cx - half_w, y_front, z_top)),  # 3
            bm.verts.new((cx + half_w, y_front, z_top)),  # 4
            bm.verts.new((cx,           y_tip,   z_top)), # 5
        ]
        for fi in [(0, 1, 2),
                   (5, 4, 3),
                   (0, 2, 5), (0, 5, 3),
                   (1, 4, 5), (1, 5, 2),
                   (0, 3, 4, 1)]:
            with contextlib.suppress(ValueError):
                bm.faces.new([v[i] for i in fi])

        _recalc_outward_normals(bm)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_radial_skin_mesh(self, name: str,
                               cx: float, width: float,
                               sill_el: float, top_el: float,
                               trunnion_y: float, trunnion_el: float,
                               radius: float) -> bpy.types.Mesh:
        """래디얼 게이트 스킨 (원호 곡면 strip — open mesh)."""
        dz_sill = sill_el - trunnion_el
        dz_top = top_el - trunnion_el
        dx_sill = math.sqrt(max(0.01, radius * radius - dz_sill * dz_sill))
        dx_top = math.sqrt(max(0.01, radius * radius - dz_top * dz_top))
        ang_sill = math.atan2(dz_sill, dx_sill)
        ang_top = math.atan2(dz_top, dx_top)

        if ang_top - ang_sill > math.pi:
            ang_top -= 2 * math.pi
        elif ang_top - ang_sill < -math.pi:
            ang_top += 2 * math.pi

        n_circ = 16
        half_w = width / 2 - 0.1

        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        verts = []
        for i in range(n_circ + 1):
            t = i / n_circ
            ang = ang_sill * (1 - t) + ang_top * t
            dy = math.cos(ang) * radius
            dz = math.sin(ang) * radius
            y = trunnion_y + dy
            z = trunnion_el + dz
            verts.append(bm.verts.new((cx - half_w, y, z)))
            verts.append(bm.verts.new((cx + half_w, y, z)))

        for i in range(n_circ):
            i0 = 2 * i
            i1 = 2 * i + 1
            i2 = 2 * (i + 1)
            i3 = 2 * (i + 1) + 1
            with contextlib.suppress(ValueError):
                bm.faces.new([verts[i0], verts[i3], verts[i1]])
            with contextlib.suppress(ValueError):
                bm.faces.new([verts[i0], verts[i2], verts[i3]])

        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    def _make_tube_between(self, name: str,
                           p1: tuple, p2: tuple,
                           radius: float, n_sides: int = 8) -> bpy.types.Mesh:
        """두 점을 잇는 원통 튜브 (PyVista line.tube 대체). enclosed manifold."""
        a = Vector(p1)
        b = Vector(p2)
        direction = b - a
        length = direction.length
        if length < 0.05:
            return None  # type: ignore
        center = (a + b) * 0.5

        z_axis = Vector((0.0, 0.0, 1.0))
        d_norm = direction.normalized()
        dot = z_axis.dot(d_norm)
        if dot > 0.9999:
            rot_quat = Quaternion()
        elif dot < -0.9999:
            rot_quat = Quaternion((1.0, 0.0, 0.0), math.pi)
        else:
            axis = z_axis.cross(d_norm).normalized()
            angle = math.acos(max(-1.0, min(1.0, dot)))
            rot_quat = Quaternion(axis, angle)

        mat = Matrix.Translation(center) @ rot_quat.to_matrix().to_4x4()

        mesh = bpy.data.meshes.new(name)
        bm = bmesh.new()
        bmesh.ops.create_cone(
            bm,
            cap_ends=True,
            cap_tris=False,
            segments=n_sides,
            radius1=radius,
            radius2=radius,
            depth=length,
            matrix=mat,
            calc_uvs=False,
        )
        _recalc_outward_normals(bm)
        bm.normal_update()
        bm.to_mesh(mesh)
        bm.free()
        return mesh

    # ---- 객체 생성 -----------------------------------------------------

    def _add_object(self, name: str, mesh: bpy.types.Mesh,
                    color_hex: str, alpha: float = 1.0) -> bpy.types.Object | None:
        if mesh is None:
            return None
        obj = bpy.data.objects.new(name, mesh)
        obj.data.materials.append(self._get_material(color_hex, alpha))
        self._collection_for(color_hex).objects.link(obj)
        self._object_count += 1
        return obj

    # ===================================================================
    # 컴포넌트 빌더 (원본과 1:1 대응)
    # ===================================================================

    def _build_spillway_body(self):
        p = self.params
        profile = p.ogee_profile
        if len(profile) < 3:
            return

        xs = [pt[0] for pt in profile]
        zs = [pt[1] for pt in profile]
        x_max = max(xs)
        z_min = min(zs) - 1.0

        closed_yz = [(xs[0], z_min), *list(zip(xs, zs, strict=False)), (x_max, z_min)]

        mesh = self._make_yz_extrude_x_mesh(
            "SpillwayBody", closed_yz, x_span=p.total_span,
        )
        self._add_object("SpillwayBody", mesh, COLORS["concrete"])

    def _compute_pier_x_centers(self) -> list:
        p = self.params
        pier_polys = getattr(p, "pier_plan_polygons", None) or []
        expected_n_piers = p.n_gates + 1
        if pier_polys and len(pier_polys) == expected_n_piers \
                and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
            centers = []
            for poly in pier_polys:
                xs = [pt[0] for pt in poly]
                centers.append((min(xs) + max(xs)) / 2.0)
            centers.sort()
            return centers

        pier_w = p.pier_width
        gate_xs = p.gate_centers_x
        if not gate_xs:
            return []
        centers = [gate_xs[0] - p.gate_width / 2 - pier_w / 2]
        for i in range(len(gate_xs) - 1):
            centers.append((gate_xs[i] + gate_xs[i + 1]) / 2)
        centers.append(gate_xs[-1] + p.gate_width / 2 + pier_w / 2)
        return centers

    @staticmethod
    def _validate_pier_polys(pier_polys: list, pier_width: float, pier_length: float,
                             tol_ratio: float = 0.5) -> bool:
        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
        for poly in pier_polys:
            xs = [p[0] for p in poly]
            ys = [p[1] for p in poly]
            if len(xs) < 3:
                return False
            w = max(xs) - min(xs)
            l = max(ys) - min(ys)
            if not (w_lo <= w <= w_hi):
                return False
            if not (l_lo <= l <= l_hi):
                return False
        return True

    @staticmethod
    def _validate_bridge_bbox(bbox: tuple, total_span: float, pier_length: float) -> bool:
        if bbox is None or len(bbox) != 4:
            return False
        x0, y0, x1, y1 = bbox
        w = x1 - x0
        h = y1 - y0
        if w < 1.0 or h < 0.5:
            return False
        if not (total_span * 0.2 <= w <= total_span * 1.5):
            return False
        return pier_length * 0.05 <= h <= pier_length * 1.2

    def _build_piers(self):
        p = self.params
        pier_top_el = p.el_bridge_top
        pier_bot_el = p.el_gate_sill - 2.0

        pier_polys = getattr(p, "pier_plan_polygons", None) or []
        expected_n_piers = p.n_gates + 1

        if pier_polys and len(pier_polys) == expected_n_piers \
                and self._validate_pier_polys(pier_polys, p.pier_width, p.pier_length):
            for i, poly in enumerate(pier_polys):
                mesh = self._make_xy_polygon_extrude_mesh(
                    f"Pier_{i:02d}", poly, pier_bot_el, pier_top_el
                )
                self._add_object(f"Pier_{i:02d}", mesh, COLORS["pier"])
            return

        pier_w = p.pier_width
        pier_l = p.pier_length
        pier_x_centers = self._compute_pier_x_centers()
        if not pier_x_centers:
            return

        nose_len = pier_w * 1.2
        body_y0 = nose_len
        body_y1 = pier_l

        for i, cx in enumerate(pier_x_centers):
            body_mesh = self._make_box_mesh(
                f"Pier_{i:02d}_body",
                cx - pier_w / 2, cx + pier_w / 2,
                body_y0, body_y1,
                pier_bot_el, pier_top_el,
            )
            self._add_object(f"Pier_{i:02d}_body", body_mesh, COLORS["pier"])

            nose_mesh = self._make_pier_nose_mesh(
                f"Pier_{i:02d}_nose",
                cx, pier_w, body_y0, pier_bot_el, pier_top_el,
            )
            self._add_object(f"Pier_{i:02d}_nose", nose_mesh, COLORS["pier"])

    def _compute_gate_geometry(self) -> dict:
        p = self.params
        sill_el = p.el_gate_sill
        top_el = p.el_gate_top
        gate_h = top_el - sill_el

        radius = gate_h * 1.25

        mid_el = (sill_el + top_el) / 2
        trunnion_el_user = p.el_trunnion_pin
        trunnion_el = mid_el if abs(trunnion_el_user - mid_el) > 0.5 else trunnion_el_user

        dz_half = abs(trunnion_el - sill_el)
        horizontal = math.sqrt(max(0.01, radius ** 2 - dz_half ** 2))

        body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
        crest_y_candidate: float | None = None
        if p.ogee_profile:
            best_diff = float("inf")
            for (x, z) in p.ogee_profile:
                if x <= 0.1:
                    continue
                diff = abs(z - p.el_weir_crest)
                if diff < best_diff:
                    best_diff = diff
                    crest_y_candidate = float(x)
        if crest_y_candidate is None or crest_y_candidate < 0.5:
            crest_y_candidate = body_len * 0.45

        hoist_half = 2.0
        gate_y_min = horizontal + hoist_half
        gate_y_max = max(gate_y_min + 0.1, body_len - 0.5)
        gate_y = max(gate_y_min, min(crest_y_candidate, gate_y_max))
        trunnion_y = gate_y - horizontal

        return {
            "gate_y": gate_y,
            "trunnion_y": trunnion_y,
            "trunnion_el": trunnion_el,
            "radius": radius,
        }

    def _build_radial_gates(self):
        p = self.params
        gate_w = p.gate_width
        sill_el = p.el_gate_sill
        top_el = p.el_gate_top

        geom = self._compute_gate_geometry()
        trunnion_y = geom["trunnion_y"]
        trunnion_el = geom["trunnion_el"]
        radius = geom["radius"]

        for gi, gx in enumerate(p.gate_centers_x):
            skin = self._make_radial_skin_mesh(
                f"GateSkin_{gi:02d}",
                cx=gx, width=gate_w,
                sill_el=sill_el, top_el=top_el,
                trunnion_y=trunnion_y, trunnion_el=trunnion_el,
                radius=radius,
            )
            self._add_object(f"GateSkin_{gi:02d}", skin, COLORS["gate_panel"])

            half_w = gate_w / 2 - 0.15
            arm_thick = 0.3
            mid_el = (sill_el + top_el) / 2
            for side_idx, side_offset in enumerate([-half_w, half_w]):
                t_pt = (gx + side_offset, trunnion_y, trunnion_el)
                s_pt = (gx + side_offset, trunnion_y + radius, mid_el)
                arm = self._make_tube_between(
                    f"GateArm_{gi:02d}_{side_idx}",
                    t_pt, s_pt,
                    radius=arm_thick, n_sides=8,
                )
                self._add_object(
                    f"GateArm_{gi:02d}_{side_idx}", arm, COLORS["gate_frame"]
                )

    def _build_service_bridge(self):
        p = self.params
        deck_thickness = getattr(p, "bridge_deck_thickness_m", 1.2)
        deck_top = p.el_bridge_top + deck_thickness
        deck_bot = p.el_bridge_top

        ux0 = getattr(p, "bridge_x_start", None)
        ux1 = getattr(p, "bridge_x_end", None)
        uy0 = getattr(p, "bridge_y_start", None)
        uy1 = getattr(p, "bridge_y_end", None)
        user_bbox = None
        if (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):
            cand = (float(ux0), float(uy0), float(ux1), float(uy1))
            if self._validate_bridge_bbox(cand, p.total_span, p.pier_length):
                user_bbox = cand

        if user_bbox is not None:
            x0, y0, x1, y1 = user_bbox
        else:
            bbox = getattr(p, "bridge_plan_bbox", None)
            if bbox is not None and self._validate_bridge_bbox(
                    bbox, p.total_span, p.pier_length):
                x0, y0, x1, y1 = bbox
            else:
                x0 = -p.pier_width * 0.5
                x1 = p.total_span + p.pier_width * 0.5
                y0 = p.pier_length * 0.3
                y1 = p.pier_length * 0.55

        deck_mesh = self._make_box_mesh(
            "BridgeDeck", x0, x1, y0, y1, deck_bot, deck_top
        )
        self._add_object("BridgeDeck", deck_mesh, COLORS["bridge_deck"])

        rail_height = 1.1
        rail_thick = 0.2
        for ri, y_rail in enumerate([y0, y1 - rail_thick]):
            rail_mesh = self._make_box_mesh(
                f"BridgeRail_{ri}",
                x0, x1, y_rail, y_rail + rail_thick,
                deck_top, deck_top + rail_height,
            )
            self._add_object(f"BridgeRail_{ri}", rail_mesh, COLORS["bridge_rail"])

    def _build_gate_hoists(self):
        p = self.params
        pier_x_centers = self._compute_pier_x_centers()
        if not pier_x_centers:
            return

        house_w = max(p.pier_width, 2.5)
        house_l = 2.2
        house_h = 2.1
        embed_depth = 0.1
        roof_overhang = 0.2
        roof_thick = 0.2

        pier_top_el = p.el_bridge_top
        base_z = pier_top_el - embed_depth
        top_z = base_z + house_h

        pier_w = p.pier_width
        nose_len = pier_w * 1.2
        body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
        y_center_target = nose_len + 0.5
        margin = house_l / 2 + roof_overhang + 0.1
        y_center = min(max(y_center_target, margin), body_len - margin)
        y0 = y_center - house_l / 2
        y1 = y_center + house_l / 2

        for i, cx in enumerate(pier_x_centers):
            box = self._make_box_mesh(
                f"Hoist_{i:02d}_body",
                cx - house_w / 2, cx + house_w / 2,
                y0, y1, base_z, top_z,
            )
            self._add_object(f"Hoist_{i:02d}_body", box, COLORS["gate_hoist"])
            roof = self._make_box_mesh(
                f"Hoist_{i:02d}_roof",
                cx - house_w / 2 - roof_overhang, cx + house_w / 2 + roof_overhang,
                y0 - roof_overhang, y1 + roof_overhang,
                top_z, top_z + roof_thick,
            )
            self._add_object(f"Hoist_{i:02d}_roof", roof, COLORS["gate_hoist_roof"])

    def _build_water_surface(self):
        p = self.params
        water_el = p.el_nhwl
        x0 = -10
        x1 = p.total_span + 10
        y0 = -40
        y1 = 0.5

        water = self._make_flat_rect_mesh("WaterSurface", x0, x1, y0, y1, water_el)
        self._add_object("WaterSurface", water, COLORS["water"], alpha=0.85)

    def _build_downstream_apron(self):
        p = self.params
        apron_el = p.el_downstream
        x0 = -5
        x1 = p.total_span + 5
        y0 = p.pier_length
        y1 = y0 + 30

        apron = self._make_flat_rect_mesh("Apron", x0, x1, y0, y1, apron_el)
        self._add_object("Apron", apron, COLORS["apron"])


# ===========================================================================
# 옵션: 카메라 + 조명 + 렌더 (헤드리스 워크플로용)
# ===========================================================================

def setup_lighting_and_camera(params: GateParams,
                              time_preset: str = "daytime",
                              *,
                              transparent_bg: bool = False) -> bpy.types.Object:
    """여수로 전체를 oblique aerial 앵글로 잡는 카메라 + Sun + Sky.

    Args:
        params: GateParams
        time_preset: 'daytime' | 'sunset' | 'overcast' (Sun 위치/세기 + Sky)
        transparent_bg: True면 World 배경을 투명한 검정으로 (Sky 텍스처 미연결).
            Sun 광원은 그대로 두므로 구조물 라이팅은 정상 유지. 합성 입력에 적합.
    """
    p = params

    world = bpy.context.scene.world or bpy.data.worlds.new("World")
    bpy.context.scene.world = world
    world.use_nodes = True
    nt = world.node_tree
    nt.nodes.clear()
    bg = nt.nodes.new("ShaderNodeBackground")
    out = nt.nodes.new("ShaderNodeOutputWorld")

    presets = {
        "daytime":  {"sun_elev": math.radians(60), "intensity": 1.0, "sun_strength": 4.0},
        "sunset":   {"sun_elev": math.radians(8),  "intensity": 0.8, "sun_strength": 6.0},
        "overcast": {"sun_elev": math.radians(45), "intensity": 0.5, "sun_strength": 1.5},
    }
    pre = presets.get(time_preset, presets["daytime"])

    if transparent_bg:
        # 투명 배경 모드 — Sky 텍스처를 만들지 않고 검정 배경 + 약한 Strength
        # (구조물 표면이 바라본 방향에 'sky'가 안 보이도록).
        # 실제 투명 픽셀은 render.film_transparent + RGBA로 만들어짐.
        bg.inputs["Color"].default_value = (0.0, 0.0, 0.0, 1.0)
        bg.inputs["Strength"].default_value = 0.0
    else:
        sky = nt.nodes.new("ShaderNodeTexSky")
        with contextlib.suppress(TypeError, AttributeError):
            sky.sky_type = "NISHITA"
        try:
            sky.sun_elevation = pre["sun_elev"]
            sky.sun_rotation = math.radians(225)
        except AttributeError:
            pass
        nt.links.new(sky.outputs[0], bg.inputs["Color"])
        bg.inputs["Strength"].default_value = pre["intensity"]

    nt.links.new(bg.outputs[0], out.inputs["Surface"])

    # Sun light는 두 모드 모두 추가 (구조물 라이팅 일관성)
    sun_data = bpy.data.lights.new("Sun", "SUN")
    sun_data.energy = pre["sun_strength"]
    sun_obj = bpy.data.objects.new("Sun", sun_data)
    bpy.context.scene.collection.objects.link(sun_obj)
    sun_obj.rotation_euler = (math.radians(50), math.radians(15), math.radians(225))

    cam_data = bpy.data.cameras.new("Cam")
    cam = bpy.data.objects.new("Cam", cam_data)
    bpy.context.scene.collection.objects.link(cam)

    target_x = p.total_span / 2
    target_y = p.pier_length / 2
    target_z = (p.el_gate_sill + p.el_bridge_top) / 2
    target = Vector((target_x, target_y, target_z))

    span = max(p.total_span, p.pier_length, p.el_bridge_top - p.el_upstream_bed)
    distance = span * 1.6

    cam_pos = Vector((
        target_x + distance * 0.4,
        target_y - distance * 0.9,
        target_z + span * 0.5,
    ))
    cam.location = cam_pos

    direction = (target - cam_pos).normalized()
    rot_quat = direction.to_track_quat("-Z", "Y")
    cam.rotation_euler = rot_quat.to_euler()
    cam_data.lens = 50

    bpy.context.scene.camera = cam
    return cam


def render_to_png(filepath: str,
                  resolution=(1920, 1080),
                  samples: int = 256,
                  engine: str = "CYCLES",
                  *,
                  transparent_bg: bool = False) -> None:
    """현재 씬을 PNG로 렌더.

    Args:
        filepath: 출력 PNG 경로
        resolution: (width, height) 픽셀
        samples: Cycles 샘플 수 (CYCLES 외 엔진은 무시)
        engine: 'CYCLES' | 'BLENDER_EEVEE' | 'BLENDER_EEVEE_NEXT'
        transparent_bg: True면 film 알파 채널 활성화 → RGBA PNG.
            지형 합성 워크플로의 입력으로 사용.
    """
    scene = bpy.context.scene
    scene.render.engine = engine
    if engine == "CYCLES":
        scene.cycles.samples = samples
        try:
            prefs = bpy.context.preferences.addons["cycles"].preferences
            prefs.compute_device_type = "CUDA"
            prefs.get_devices()
            scene.cycles.device = "GPU"
        except (KeyError, AttributeError, RuntimeError):
            scene.cycles.device = "CPU"
    scene.render.resolution_x = resolution[0]
    scene.render.resolution_y = resolution[1]
    scene.render.resolution_percentage = 100

    # 투명 배경 처리 — 두 엔진 모두 지원
    scene.render.film_transparent = bool(transparent_bg)

    scene.render.image_settings.file_format = "PNG"
    if transparent_bg:
        scene.render.image_settings.color_mode = "RGBA"
    else:
        scene.render.image_settings.color_mode = "RGB"

    scene.render.filepath = str(Path(filepath).resolve())

    bpy.ops.render.render(write_still=True)


def export_glb(filepath: str) -> None:
    bpy.ops.export_scene.gltf(
        filepath=str(Path(filepath).resolve()),
        export_format="GLB",
        export_apply=True,
        export_yup=True,
    )


def save_blend(filepath: str) -> None:
    bpy.ops.wm.save_as_mainfile(filepath=str(Path(filepath).resolve()))


# ===========================================================================
# CLI 진입점 (Blender 헤드리스)
# ===========================================================================

def _parse_argv():
    import argparse
    argv = sys.argv
    argv = argv[argv.index("--") + 1:] if "--" in argv else []

    ap = argparse.ArgumentParser(description="Gate (spillway) → Blender scene")
    ap.add_argument("--params", required=True, help="JSON 경로 (GateParams)")
    ap.add_argument("--blend",  default=None)
    ap.add_argument("--glb",    default=None)
    ap.add_argument("--render", default=None)
    ap.add_argument("--time", default="daytime",
                    choices=["daytime", "sunset", "overcast"])
    ap.add_argument("--samples", type=int, default=256)
    ap.add_argument("--width",   type=int, default=1920)
    ap.add_argument("--height",  type=int, default=1080)
    ap.add_argument("--engine", default="CYCLES",
                    choices=["CYCLES", "BLENDER_EEVEE", "BLENDER_EEVEE_NEXT"])
    ap.add_argument("--transparent", action="store_true",
                    help="투명 배경 RGBA PNG (지형 합성 입력용)")
    return ap.parse_args(argv)


def main():
    args = _parse_argv()

    params_dict = json.loads(Path(args.params).read_text(encoding="utf-8"))
    params = params_from_dict(params_dict)

    print(f"[bpy-gate] Loaded params from {args.params}")
    print(f"  Gate: {params.n_gates} gates x W{params.gate_width:.1f}m x H{params.gate_height:.1f}m")
    print(f"  Sill EL.{params.el_gate_sill:.2f} / Top EL.{params.el_gate_top:.2f} / "
          f"Trunnion EL.{params.el_trunnion_pin:.2f}")
    print(f"  Total: {params.total_span:.1f} x {params.total_length:.1f} m")
    print(f"  ogee_profile pts: {len(params.ogee_profile)}, "
          f"piers: {len(params.pier_plan_polygons)}, "
          f"bridge: {params.has_service_bridge}, hoist: {params.has_hoist_housings}")
    if args.transparent:
        print("  [mode] transparent background (RGBA)")

    builder = GateBuilderBpy(params, clear_scene=True)
    builder.build_all()
    print(f"[bpy-gate] Created {builder._object_count} objects "
          f"in {len(builder._collections) - 1} sub-collections")

    if args.render or args.blend or args.glb:
        setup_lighting_and_camera(params, time_preset=args.time,
                                  transparent_bg=args.transparent)

    if args.blend:
        save_blend(args.blend)
        print(f"[bpy-gate] Saved .blend -> {args.blend}")
    if args.glb:
        export_glb(args.glb)
        print(f"[bpy-gate] Exported glTF -> {args.glb}")
    if args.render:
        render_to_png(args.render,
                      resolution=(args.width, args.height),
                      samples=args.samples,
                      engine=args.engine,
                      transparent_bg=args.transparent)
        print(f"[bpy-gate] Rendered -> {args.render}")


if __name__ == "__main__" and "--params" in sys.argv:
    main()
