Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진. ~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수). 이 커밋은 7-iter cleanup이 적용된 상태로 import: - F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError (Py3.13에서 reproduce 확인된 진짜 버그) - RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화 - F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization 신규 파일: - ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화 - requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel) - .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외 검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
730
gate_3d_builder.py
Normal file
730
gate_3d_builder.py
Normal file
@@ -0,0 +1,730 @@
|
||||
"""여수로 수문 구조물 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}")
|
||||
Reference in New Issue
Block a user