"""여수로 수문 구조물 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 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 # 바닥 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)) 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}")