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:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

730
gate_3d_builder.py Normal file
View 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 여수로 수문 설치도(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}")