"""여수로 수문 구조물 3D 파라메트릭 빌더.

GateParams 객체를 받아 PyVista 메쉬들을 생성한다:
- 여수로 본체 (ogee 프로파일을 span 방향으로 extrude)
- 교각 (pier) n+1개
- 래디얼 게이트 (Tainter gate) n개
- 공도교 (service bridge)
- 여수로 개폐장치 (gate hoist) — pier 상면에 embedded

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

from __future__ import annotations

import math

import numpy as np
import pyvista as pv

from gate_parser import GateParams
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",         # 여수로 에이프런
}


# ---------------------------------------------------------------------------
# 빌더
# ---------------------------------------------------------------------------

class GateBuilder:
    """파라미터 기반 여수로 3D 모델 빌더."""

    def __init__(self, params: GateParams):
        self.params = params
        self.meshes: list[tuple[pv.PolyData, str, float]] = []  # (mesh, color, opacity)

    def build_all(self) -> list[tuple[pv.PolyData, str, float]]:
        """모든 구성요소를 빌드하여 리스트 반환.

        부속 구조물은 GateParams의 has_* 플래그가 True일 때만 빌드 (Phase A).
        주 구조물(본체/교각)은 도면 기하(Phase B')를 우선, 없으면 parametric 폴백.
        """
        self.meshes = []
        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.meshes

    # --- 여수로 본체 ---

    def _build_spillway_body(self):
        """Ogee 프로파일을 span 방향으로 extrude하여 본체 생성."""
        p = self.params
        # 매끄러운 곡면을 위해 ogee 프로파일을 CubicSpline으로 4× densify
        # (사용자 피드백 #2: 곡선 직선 분절 → spline 보간으로 매끄럽게)
        profile = self._densify_profile(p.ogee_profile, n_factor=4)

        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  # 바닥 1m 확장

        # 닫힌 단면 (상류시작 바닥 → 프로파일 → 하류끝 바닥 → 돌아옴)
        closed_pts_2d = [(xs[0], z_min), *list(zip(xs, zs, strict=False)), (x_max, z_min)]

        # Y 방향(span)으로 extrude
        span = p.total_span
        span_pts = self._extrude_2d_profile(closed_pts_2d, span)
        mesh = self._triangulate_prism(span_pts, len(closed_pts_2d))

        if mesh is not None:
            self.meshes.append((mesh, COLORS["concrete"], 1.0))

    @staticmethod
    def _densify_profile(profile_2d, n_factor: int = 4, n_min: int = 4):
        """(x, z) 프로파일 점을 arc-length parametric CubicSpline로 보간.

        Ogee 단면은 단조 함수가 아닐 수 있어(상류 옹벽 수직 구간에서 동일 x 다중 z)
        parametric (s, x), (s, z) 곡선이 안전하다. 호 길이 누적을 매개변수로 사용해
        균등하게 재샘플링한다.

        Args:
            profile_2d: [(x, z), ...] 원본 단면 점.
            n_factor: 출력 점 개수 = max(n_factor * len(profile), n_min).
            n_min: 출력 최소 점 개수.

        Returns:
            [(x, z), ...] 보간된 점 리스트. scipy 미설치 등 실패 시 원본 반환.
        """
        if profile_2d is None:
            return []
        if len(profile_2d) < 4:
            # 점이 너무 적으면 spline 의미 없음 — 원본 그대로
            return list(profile_2d)
        try:
            from scipy.interpolate import CubicSpline
        except Exception:
            return list(profile_2d)

        pts = np.asarray(profile_2d, dtype=float)
        diffs = np.diff(pts, axis=0)
        seg_len = np.sqrt((diffs ** 2).sum(axis=1))
        s = np.concatenate([[0.0], np.cumsum(seg_len)])
        if s[-1] <= 1e-9:
            return list(profile_2d)
        s_norm = s / s[-1]

        try:
            cs_x = CubicSpline(s_norm, pts[:, 0], bc_type="natural")
            cs_z = CubicSpline(s_norm, pts[:, 1], bc_type="natural")
        except Exception:
            return list(profile_2d)

        n_out = max(n_factor * len(profile_2d), n_min)
        s_new = np.linspace(0.0, 1.0, n_out)
        return list(zip(cs_x(s_new).tolist(), cs_z(s_new).tolist(), strict=False))

    def _extrude_2d_profile(self, profile_2d: list, span: float) -> np.ndarray:
        """(y, z) 프로파일 점들을 X 방향으로 2개 평면(start/end) 생성.

        Args:
            profile_2d: [(y, z), ...] 단면 프로파일
            span: X 방향 길이

        Returns:
            np.ndarray shape (2*n, 3): X=0면 n점 + X=span면 n점
        """
        n = len(profile_2d)
        pts = np.zeros((2 * n, 3))
        for i, (y, z) in enumerate(profile_2d):
            pts[i] = [0.0, y, z]
            pts[i + n] = [span, y, z]
        return pts

    def _triangulate_prism(self, pts: np.ndarray, n: int) -> pv.PolyData | None:
        """프리즘 메쉬 생성 (두 개의 n-gon 끝면 + 측면 스트립)."""
        if len(pts) != 2 * n:
            return None

        faces = []

        # 측면 (n개의 사각형 → 삼각형 2개씩)
        for i in range(n):
            i_next = (i + 1) % n
            # 앞면 i, 앞면 i_next, 뒷면 i_next
            faces.append([3, i, i_next, i_next + n])
            # 앞면 i, 뒷면 i_next, 뒷면 i
            faces.append([3, i, i_next + n, i + n])

        # 양끝면 (fan triangulation)
        # 앞면 (X=0)
        faces.extend([3, 0, i, i + 1] for i in range(1, n - 1))
        # 뒷면 (X=span)
        faces.extend([3, n, n + i + 1, n + i] for i in range(1, n - 1))

        faces_flat = np.concatenate(faces)
        return pv.PolyData(pts, faces_flat)

    # --- 교각 ---

    def _compute_pier_x_centers(self) -> list:
        """Parametric pier X centers (n_gates + 1 개).

        외곽 wing pier는 gate 양쪽 반폭 + pier 반폭 바깥, 내부 pier는
        인접 gate 중심의 중점. Phase B' 폴리곤이 사용되는 경우엔 pier 폴리곤
        자체의 bbox 중심을 쓰도록 별도 계산한다.
        """
        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

    def _build_piers(self):
        """교각 빌드. Phase B' 폴리곤이 **완전 추출 + sanity check 통과** 시만
        실제 기하 사용. 하나라도 비정상 폭/길이면 parametric 폴백 전체 사용."""
        p = self.params
        pier_top_el = p.el_bridge_top
        pier_bot_el = p.el_gate_sill - 2.0

        # --- Phase B': sanity check 후 폴리곤 경로 ---
        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 poly in pier_polys:
                try:
                    mesh = self._extrude_polygon_xy(
                        poly, pier_bot_el, pier_top_el
                    )
                    if mesh is not None:
                        self.meshes.append((mesh, COLORS["pier"], 1.0))
                except Exception:
                    continue
            return

        # --- Parametric 폴백 ---
        pier_w = p.pier_width
        pier_l = p.pier_length
        pier_x_centers = self._compute_pier_x_centers()
        if not pier_x_centers:
            return

        # Pier body는 nose_len만큼 안쪽에서 시작해 slab Y 범위(0 ~ pier_length)
        # 바깥으로 돌출되지 않게 한다. nose는 [0, nose_len] 구간에 위치.
        nose_len = pier_w * 1.2
        body_y0 = nose_len
        body_y1 = pier_l
        for cx in pier_x_centers:
            mesh = self._make_box(
                x0=cx - pier_w / 2, x1=cx + pier_w / 2,
                y0=body_y0, y1=body_y1,
                z0=pier_bot_el, z1=pier_top_el,
            )
            nose = self._make_pier_nose(cx, pier_w, body_y0, pier_bot_el, pier_top_el)
            if nose is not None:
                with contextlib.suppress(Exception):
                    mesh = mesh.merge(nose)

            self.meshes.append((mesh, COLORS["pier"], 1.0))

    def _extrude_polygon_xy(self, poly_xy: list, z_bot: float, z_top: float) -> pv.PolyData | None:
        """임의 XY 폴리곤을 Z 방향으로 extrude해 3D 프리즘 메시 생성.

        poly_xy: [(x, y), ...] chamber-local 좌표, 폐합 가정.
        시계방향/반시계방향 둘 다 허용 (면 normal은 무관).
        """
        if len(poly_xy) < 3:
            return None
        # 중복된 마지막 점(=첫점) 제거
        pts2d = list(poly_xy)
        if (abs(pts2d[0][0] - pts2d[-1][0]) < 1e-6
                and abs(pts2d[0][1] - pts2d[-1][1]) < 1e-6):
            pts2d = pts2d[:-1]
        n = len(pts2d)
        if n < 3:
            return None

        # 상·하 고리 정점
        pts = np.zeros((2 * n, 3))
        for i, (x, y) in enumerate(pts2d):
            pts[i] = [x, y, z_bot]
            pts[i + n] = [x, y, z_top]

        faces: list[int] = []
        # 측면 사각형 (n개) → 각각 2 삼각형
        for i in range(n):
            j = (i + 1) % n
            # (i, j, j+n)
            faces.extend([3, i, j, j + n])
            # (i, j+n, i+n)
            faces.extend([3, i, j + n, i + n])
        # 위/아래 fan triangulation (간단; 오목 폴리곤이면 결과가 다소 비정상이나 pier는 보통 거의 볼록)
        for i in range(1, n - 1):
            faces.extend([3, 0, i, i + 1])           # 바닥 (0..n-1)
            faces.extend([3, n, n + i + 1, n + i])   # 상부 (n..2n-1)

        return pv.PolyData(pts, np.array(faces))

    # ----- Phase B' sanity check -----

    @staticmethod
    def _validate_pier_polys(pier_polys: list, pier_width: float, pier_length: float,
                               tol_ratio: float = 0.5) -> bool:
        """Phase B' pier 폴리곤이 합리적 크기 범위 안인지 검증.

        조건:
          - 각 pier의 X-폭이 pier_width × (1 ± tol_ratio) 범위
          - 각 pier의 Y-길이가 pier_length × (0.4 ~ 1.5) 범위
        하나라도 실패하면 False 반환 → 빌더가 parametric으로 폴백.
        tol_ratio=0.5이면 pier_width의 50%~150% 허용 (기본).
        """
        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:
        """Bridge bbox가 합리적 범위인지 검증. (x0, y0, x1, y1) local m."""
        if bbox is None or len(bbox) != 4:
            return False
        x0, y0, x1, y1 = bbox
        w = x1 - x0
        h = y1 - y0
        # 최소 1m, x-폭은 total_span의 30%~150%, y-길이는 pier_length의 10%~100%
        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 _make_pier_nose(self, cx: float, width: float,
                        y_front: float, z_bot: float, z_top: float) -> pv.PolyData | None:
        """교각 상류측 삼각형 물가르기 노즈."""
        half_w = width / 2
        nose_len = width * 1.2  # 노즈 돌출 길이
        y_tip = y_front - nose_len

        # 8개 점: 바닥 3(좌,우,앞) + 상부 3
        pts = np.array([
            [cx - half_w, y_front, z_bot],    # 0: 좌측 뒤 바닥
            [cx + half_w, y_front, z_bot],    # 1: 우측 뒤 바닥
            [cx, y_tip, z_bot],                # 2: 앞 끝 바닥
            [cx - half_w, y_front, z_top],    # 3: 좌측 뒤 상부
            [cx + half_w, y_front, z_top],    # 4: 우측 뒤 상부
            [cx, y_tip, z_top],                # 5: 앞 끝 상부
        ])
        faces = np.array([
            3, 0, 1, 2,               # 바닥 (inward)
            3, 5, 4, 3,               # 상부 (outward)
            3, 0, 2, 5, 3, 0, 5, 3,   # 좌 측면 (2 tri)
            3, 1, 4, 5, 3, 1, 5, 2,   # 우 측면 (2 tri)
            4, 0, 3, 4, 1,            # 뒷면 사각형 (pier body와 맞닿음; quad)
        ])
        return pv.PolyData(pts, faces)

    # --- 래디얼 게이트 ---

    def _compute_gate_geometry(self):
        """수문(Tainter) 기하를 기하학적 일관성 있게 계산하는 헬퍼.

        좌표계: 빌더 +Y = downstream, body Y 범위는 [0, pier_length].
        게이트 skin은 crest 근처에 배치되고, trunnion은 skin의 상류 쪽
        (`trunnion_y = gate_y - horizontal`)으로 뻗는다. 이전 이 빌더에서
        `gate_y = 1.0` 하드코딩이 쓰이면서 gate_height 7m · radius 8.75m 조합에서
        `trunnion_y ≈ -7.75m`가 되어 개폐장치·암이 여수로 본체(Y=0~25m) 밖
        -9m까지 튀어나오는 현상이 발생했다.

        수정 로직:
          1) ogee_profile에서 `el_weir_crest`와 가장 가까운 점의 x(=유출방향
             거리)를 후보 `crest_y`로 추출. 상류 끝점(x=0)은 제외.
          2) 실패 시 `pier_length * 0.45`를 폴백.
          3) `gate_y`를 [horizontal + hoist_half, pier_length - margin] 범위로
             clamp하여 trunnion과 개폐장치가 body 안으로 들어오게 함.
        """
        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 (설계 관행)
        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

        # 개폐장치 절반 길이(=1.10) + 지붕 margin(0.2) 이상 여유 확보
        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):
        """각 수문 위치에 래디얼(Tainter) 게이트 생성."""
        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()
        gate_y = geom["gate_y"]
        trunnion_y = geom["trunnion_y"]
        trunnion_el = geom["trunnion_el"]
        radius = geom["radius"]

        for gx in p.gate_centers_x:
            # 게이트 스킨플레이트 (곡면)
            skin = self._make_radial_skin(
                cx=gx, width=gate_w,
                sill_el=sill_el, top_el=top_el,
                gate_y=gate_y, trunnion_y=trunnion_y, trunnion_el=trunnion_el,
                radius=radius,
            )
            if skin is not None:
                self.meshes.append((skin, COLORS["gate_panel"], 1.0))

            # 게이트 암 (trunnion → skin 연결부)
            arms = self._make_gate_arms(
                cx=gx, width=gate_w,
                sill_el=sill_el, top_el=top_el,
                gate_y=gate_y, trunnion_y=trunnion_y, trunnion_el=trunnion_el,
                radius=radius,  # 수정: 암이 곡면에 정확히 닿도록 radius 파라미터 전달
            )
            if arms is not None:
                self.meshes.append((arms, COLORS["gate_frame"], 1.0))

    def _make_radial_skin(self, cx: float, width: float,
                          sill_el: float, top_el: float,
                          gate_y: float, trunnion_y: float, trunnion_el: float,
                          radius: float) -> pv.PolyData | None:
        """래디얼 게이트의 스킨플레이트 (원통면 일부).

        sill·top 각도는 각 점의 dz만 정확히 알고 있으므로
        dx = sqrt(radius² - dz²)로 원호 상 위치를 되찾는다.
        (단순히 `gate_y - trunnion_y`를 쓰면 trunnion_el가 mid_el에서
        벗어날 때 sill/top이 비대칭이 되어 스킨이 원호를 벗어남.)
        """
        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

        pts = []
        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
            pts.extend([cx + s, y, z] for s in (-half_w, half_w))

        pts = np.array(pts)

        # 삼각형 메쉬 (좌우 쌍으로 strip, Normal 방향 렌더링 오류 수정)
        faces = []
        for i in range(n_circ):
            i0 = 2 * i
            i1 = 2 * i + 1
            i2 = 2 * (i + 1)
            i3 = 2 * (i + 1) + 1
            # 수정: Winding Order를 역순으로 바꿔 면의 바깥쪽(볼록한 면) 노멀이 정상적으로 향하도록 조치
            faces.append([3, i0, i3, i1])
            faces.append([3, i0, i2, i3])

        faces_flat = np.concatenate(faces)
        return pv.PolyData(pts, faces_flat)

    def _make_gate_arms(self, cx: float, width: float,
                        sill_el: float, top_el: float,
                        gate_y: float, trunnion_y: float, trunnion_el: float,
                        radius: float) -> pv.PolyData | None:
        """게이트 암: trunnion → skin 양 끝으로 뻗는 빔."""
        half_w = width / 2 - 0.15
        arm_thick = 0.3
        mid_el = (sill_el + top_el) / 2

        parts = []
        for side_cx in [cx - half_w, cx + half_w]:
            # Trunnion 위치
            t_pt = np.array([side_cx, trunnion_y, trunnion_el])

            # 수정: 암이 수문의 깊은 곡면(볼록한 중앙)까지 완전히 닿도록 Y좌표 연장
            s_pt_y = trunnion_y + radius
            s_pt = np.array([side_cx, s_pt_y, mid_el])

            dir_v = s_pt - t_pt
            length = np.linalg.norm(dir_v)
            if length < 0.1:
                continue

            line = pv.Line(t_pt.tolist(), s_pt.tolist())
            try:
                tube = line.tube(radius=arm_thick, n_sides=8)
                parts.append(tube)
            except Exception:
                pass

        if not parts:
            return None
        merged = parts[0]
        for m in parts[1:]:
            try:
                merged = merged.merge(m)
            except Exception:
                continue
        return merged

    # --- 공도교 ---

    def _build_service_bridge(self):
        """수문 상부 공도교 (service bridge).

        우선순위:
          1) 사용자가 UI에서 bridge_x_start/end/y_start/end를 **명시 입력**했으면 그 값
          2) Phase B' 파서가 추출한 bridge_plan_bbox (sanity 통과 시)
          3) parametric default
        """
        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

        # 1) 사용자 명시 값 (UI param, 0이 아닌 편집값)
        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_override = (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)

        # 사용자 override 후보에도 sanity 적용 (파서가 자동 채운 이상값이 여기로 흘러올 수 있음)
        user_bbox = None
        if user_override:
            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
            source = "user"
        else:
            # 2) Phase B' bbox (sanity 재확인)
            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
                source = "extracted"
            else:
                # 3) parametric 폴백
                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
                source = "parametric"

        # 건설 기록을 raw_text_annotations에 남김
        with contextlib.suppress(Exception):
            p.raw_text_annotations.append((
                f"[builder] bridge source={source} bbox=({x0:.2f},{y0:.2f},{x1:.2f},{y1:.2f})",
                0.0, 0.0
            ))

        deck = self._make_box(x0, x1, y0, y1, deck_bot, deck_top)
        self.meshes.append((deck, COLORS["bridge_deck"], 1.0))

        # 양쪽 난간
        rail_height = 1.1
        rail_thick = 0.2
        for y_rail in [y0, y1 - rail_thick]:
            rail = self._make_box(
                x0, x1, y_rail, y_rail + rail_thick,
                deck_top, deck_top + rail_height,
            )
            self.meshes.append((rail, COLORS["bridge_rail"], 1.0))

    # --- 여수로 개폐장치 (gate hoist) ---

    def _build_gate_hoists(self):
        """각 pier 상면에 올라앉는 여수로 개폐장치(gate hoist).

        **수문_1.dxf 평면도 실측**:
          - pier 상면의 CS-CONC-Spillway closed 4각형(X폭 ≈ 4481mm, Y길이 ≈ 2181mm)
            이 pier X 중심과 정확히 일치 → 개폐장치 기초 footprint.
          - 평면 Y 범위는 pier body 상류 끝(Y=49783~51964mm, body Y=49125 기준
            local 658~2839mm ≈ 1.5m 중심).
        **수문_2.dxf 측면도 실측**:
          - MZ-BASE Y=24938~27015mm (표고 offset +30.962 → EL.55.9~57.977)
          - 높이 ≈ 2.1m, 바닥 EL.55.9 = pier 상면(el_bridge_top=56.0) − 0.1m

        → X 중심은 **pier X 중심들**(`_compute_pier_x_centers`), gate 중심이 아님.
        → Z는 pier 상면에 일부 embed(0.1m)되어 허공 부양 없음.
        """
        p = self.params
        pier_x_centers = self._compute_pier_x_centers()
        if not pier_x_centers:
            return

        # 평면도 실측 기반 치수 (m). 폭은 pier 폭과 동일(실측 도면: pier 상면 4각형과
        # 기초 footprint가 정확히 일치) — 좁은 pier에도 양옆으로 튀어나오지 않게.
        house_w = max(p.pier_width, 2.5)
        house_l = 2.2              # Y 길이
        house_h = 2.1              # 높이 (측면도 MZ-BASE 관찰)
        embed_depth = 0.1          # pier 상면 안으로 파고드는 깊이
        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

        # Y 중심: pier body 상류 끝(nose_len 직후) 근방 — 평면도에서 local Y≈1.5m
        pier_w = p.pier_width
        nose_len = pier_w * 1.2    # pier body와 동일 계산식
        body_len = p.pier_length if p.pier_length and p.pier_length > 0 else 25.0
        y_center_target = nose_len + 0.5  # pier body 시작선 0.5m 하류
        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 cx in pier_x_centers:
            # 기초 + 본체 (pier 상면 안쪽으로 살짝 embedded)
            box = self._make_box(
                cx - house_w / 2, cx + house_w / 2,
                y0, y1, base_z, top_z,
            )
            self.meshes.append((box, COLORS["gate_hoist"], 1.0))
            # 지붕 (평지붕 + 약간 돌출)
            roof = self._make_box(
                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.meshes.append((roof, COLORS["gate_hoist_roof"], 1.0))

    # --- 수면 ---

    def _build_water_surface(self):
        """상류 수면 (N.H.W.L 기준 평판)."""
        p = self.params
        water_el = p.el_nhwl

        # 상류측 수면 (상류쪽으로 40m)
        x0 = -10
        x1 = p.total_span + 10
        y0 = -40  # 상류 40m
        y1 = 0.5  # 여수로 앞

        water = self._make_flat_rect(x0, x1, y0, y1, water_el)
        self.meshes.append((water, COLORS["water"], 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  # 하류 30m

        apron = self._make_flat_rect(x0, x1, y0, y1, apron_el)
        self.meshes.append((apron, COLORS["apron"], 1.0))

    # --- 유틸리티: 기본 형상 ---

    def _make_box(self, x0, x1, y0, y1, z0, z1) -> pv.PolyData:
        """축정렬 박스 메쉬 생성."""
        pts = np.array([
            [x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0],  # bottom
            [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1],  # top
        ])
        faces = np.hstack([
            [4, 0, 3, 2, 1],  # bottom (inward normal)
            [4, 4, 5, 6, 7],  # top
            [4, 0, 1, 5, 4],  # front
            [4, 2, 3, 7, 6],  # back
            [4, 1, 2, 6, 5],  # right
            [4, 0, 4, 7, 3],  # left
        ])
        return pv.PolyData(pts, faces)

    def _make_flat_rect(self, x0, x1, y0, y1, z) -> pv.PolyData:
        """수평 사각형 평면."""
        pts = np.array([
            [x0, y0, z], [x1, y0, z], [x1, y1, z], [x0, y1, z],
        ])
        faces = np.array([4, 0, 1, 2, 3])
        return pv.PolyData(pts, faces)


# ---------------------------------------------------------------------------
# 편의 함수
# ---------------------------------------------------------------------------

def build_gate_meshes(params: GateParams) -> list[tuple[pv.PolyData, str, float]]:
    """편의 함수: 파라미터 → 메쉬 리스트."""
    return GateBuilder(params).build_all()


if __name__ == "__main__":
    from gate_parser import parse_gate_dxf
    from pathlib import Path

    base = Path("Gate_Sample")
    f1 = base / "12995740-M40-001 여수로 수문 설치도(1／2).dxf"
    f2 = base / "12995740-M40-002 여수로 수문 설치도(2／2).dxf"

    params = parse_gate_dxf(str(f1), str(f2))
    print(params.summary())

    builder = GateBuilder(params)
    meshes = builder.build_all()
    print(f"\nBuilt {len(meshes)} mesh components")
    for i, (m, c, o) in enumerate(meshes):
        print(f"  [{i}] {m.n_points} pts, {m.n_cells} cells, color={c}, opacity={o}")
