Files
s-canvas/gate_3d_builder.py

744 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""여수로 수문 구조물 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
from dataclasses import dataclass
from typing import Optional
import numpy as np
import pyvista as pv
from gate_parser import GateParams
# ---------------------------------------------------------------------------
# 재질 색상
# ---------------------------------------------------------------------------
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)) + [(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) -> Optional[pv.PolyData]:
"""프리즘 메쉬 생성 (두 개의 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)
for i in range(1, n - 1):
faces.append([3, 0, i, i + 1])
# 뒷면 (X=span)
for i in range(1, n - 1):
faces.append([3, n, n + i + 1, n + i])
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:
try:
mesh = mesh.merge(nose)
except Exception:
pass
self.meshes.append((mesh, COLORS["pier"], 1.0))
def _extrude_polygon_xy(self, poly_xy: list, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
"""임의 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
if not (pier_length * 0.05 <= h <= pier_length * 1.2):
return False
return True
def _make_pier_nose(self, cx: float, width: float,
y_front: float, z_bot: float, z_top: float) -> Optional[pv.PolyData]:
"""교각 상류측 삼각형 물가르기 노즈."""
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
if abs(trunnion_el_user - mid_el) > 0.5:
trunnion_el = mid_el
else:
trunnion_el = 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: Optional[float] = 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) -> Optional[pv.PolyData]:
"""래디얼 게이트의 스킨플레이트 (원통면 일부).
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
for s in [-half_w, half_w]:
pts.append([cx + s, y, z])
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) -> Optional[pv.PolyData]:
"""게이트 암: 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에 남김
try:
p.raw_text_annotations.append((
f"[builder] bridge source={source} bbox=({x0:.2f},{y0:.2f},{x1:.2f},{y1:.2f})",
0.0, 0.0
))
except Exception:
pass
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 여수로 수문 설치도(12).dxf"
f2 = base / "12995740-M40-002 여수로 수문 설치도(22).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}")