"""취수탑 3D 파라메트릭 빌더. IntakeTowerParams → PyVista 메쉬 리스트 (구성요소별) 구성요소: 1. 주 본체 (concrete shell, 직사각 or L자) 2. 연장부 (접근수로 방향, L자의 경우) 3. 수문 개구부 × N (사각 홀) 4. 수문 개폐장치 × N (원통 + 모터박스) 5. 상부 호이스트 크레인 (beam + trolley) 6. 호이스트 레일 (I-beam) 7. 점검구 커버 (계단식) 8. 각 EL 바닥 slab 9. 출입 계단 (외부) 10. 지붕 11. 난간 / 파라펫 """ from __future__ import annotations import math from typing import Optional import numpy as np import pyvista as pv from intake_tower_parser import IntakeTowerParams, GatePosition # 색상 팔레트 COLORS = { "body": "#B8B5A8", # 콘크리트 "body_light": "#C5C2B5", # 상부/내부 "gate_steel": "#3D4A5C", # 수문 강재 "actuator": "#5A4A3A", # 개폐장치 모터박스 "actuator_cyl":"#8D9BA6", # 원통 (잭) "hoist_rail": "#4A4A4A", # H빔 "hoist_crane": "#D97706", # 호이스트 크레인 (주황) "stairs": "#8B7D6B", # 계단 "parapet": "#A8A59B", # 난간 "floor": "#9A968C", # 바닥 "inspect": "#D4A373", # 점검구 커버 "roof": "#7A6A5A", # 지붕 "water": "#3A7AA8", # 수면 "ground": "#7F6F5F", # 지반 } class IntakeTowerBuilder: """취수탑 파라메트릭 3D 빌더.""" def __init__(self, params: IntakeTowerParams): self.p = params self.meshes: list[tuple[pv.PolyData, str, float]] = [] def build_all(self) -> list[tuple[pv.PolyData, str, float]]: self.meshes = [] self._build_main_body() self._build_extension() # L자 연장부 self._build_gate_openings() # 수문 개구부 (구멍) self._build_gate_actuators() # 개폐장치 × N self._build_floor_slabs() # 각 EL 바닥 self._build_roof() self._build_hoist_rail() self._build_hoist_crane() self._build_inspection_cover() self._build_entry_stairs() self._build_parapet() self._build_ground_and_water() return self.meshes # --- 주 본체 (L자 또는 직사각) --- def _build_main_body(self): """주 본체: 벽체 4면 + 바닥. 좌표계: - 원점 = 본체 평면 중심 + 바닥 EL - X: 가로 (body_width) - Y: 세로 (body_depth) - Z: 표고 (body_bottom_el ~ body_top_el) """ p = self.p hw = p.body_width / 2 hd = p.body_depth / 2 z0 = p.body_bottom_el z1 = p.body_top_el # 벽 두께 wall_t = 0.5 # 4개 벽: 외곽에서 안쪽으로 wall_t 두께 # 전면 벽 (Y = -hd) self._add_wall(-hw, hw, -hd, -hd + wall_t, z0, z1, COLORS["body"]) # 후면 벽 self._add_wall(-hw, hw, hd - wall_t, hd, z0, z1, COLORS["body"]) # 좌측 벽 self._add_wall(-hw, -hw + wall_t, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"]) # 우측 벽 self._add_wall(hw - wall_t, hw, -hd + wall_t, hd - wall_t, z0, z1, COLORS["body"]) # 바닥 slab self._add_wall(-hw, hw, -hd, hd, z0, z0 + 0.5, COLORS["body"]) def _add_wall(self, x0, x1, y0, y1, z0, z1, color): """박스 형태의 벽 추가.""" mesh = self._make_box(x0, x1, y0, y1, z0, z1) self.meshes.append((mesh, color, 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], [x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1], ]) faces = np.hstack([ [4, 0, 3, 2, 1], [4, 4, 5, 6, 7], [4, 0, 1, 5, 4], [4, 2, 3, 7, 6], [4, 1, 2, 6, 5], [4, 0, 4, 7, 3], ]) return pv.PolyData(pts, faces) # --- 연장부 (L자의 경우 수로 방향) --- def _build_extension(self): """L자 연장부 — 본체 하류 방향으로 뻗은 접근수로 옹벽.""" p = self.p if not p.has_l_extension or p.extension_length <= 0: return hw = p.extension_width / 2 L = p.extension_length z0 = p.extension_bottom_el z1 = p.body_top_el - 5.0 # 연장부는 본체보다 낮음 # 연장부: Y+ 방향 (본체 후면에서 계속) body_hd = p.body_depth / 2 # 양쪽 옹벽 wall_t = 0.5 # 좌측 옹벽 self._add_wall(-hw, -hw + wall_t, body_hd, body_hd + L, z0, z1, COLORS["body"]) # 우측 옹벽 self._add_wall(hw - wall_t, hw, body_hd, body_hd + L, z0, z1, COLORS["body"]) # 바닥 slab self._add_wall(-hw, hw, body_hd, body_hd + L, z0, z0 + 0.5, COLORS["body"]) # --- 수문 개구부 (벽에 구멍) --- def _build_gate_openings(self): """수문 개구부: 본체 전면 벽에 사각 홀로 표현. 주의: 진짜 boolean cut 대신 어두운 사각형을 벽 앞면에 배치하여 시각적 표현. """ p = self.p hw = p.body_width / 2 hd = p.body_depth / 2 for g in p.gates: gx = g.center_x gw = g.gate_width gh = g.gate_height z_center = g.elevation z0 = z_center - gh / 2 z1 = z_center + gh / 2 # 전면 벽 앞쪽에 약간 돌출시킨 검은 사각형 (어두운 개구부 느낌) # 벽 앞면은 Y=-hd이므로 그 앞에 얇은 판 배치 opening = self._make_box( gx - gw / 2, gx + gw / 2, -hd - 0.05, -hd + 0.01, # 벽 전면 바로 앞 (Y-방향으로 밖) z0, z1, ) self.meshes.append((opening, "#1A1A1A", 1.0)) # 어두운 홀 # --- 수문 개폐장치 (원통 + 모터박스) --- def _build_gate_actuators(self): """각 수문 위에 수직 잭(원통) + 상부 모터박스.""" p = self.p hd = p.body_depth / 2 for g in p.gates: gx = g.center_x # 개폐장치는 본체 내부 (벽에서 1m 안쪽) ay = -hd + 1.0 # 잭 원통: 수문 EL부터 상단까지 jack_bottom = g.elevation + g.gate_height / 2 jack_top = p.body_top_el - 1.5 if jack_top <= jack_bottom: continue try: cyl = pv.Cylinder( center=(gx, ay, (jack_bottom + jack_top) / 2), direction=(0, 0, 1), radius=g.actuator_radius * 0.3, # 잭 지름은 장치 크기의 30% height=(jack_top - jack_bottom), resolution=16, ).extract_surface() self.meshes.append((cyl, COLORS["actuator_cyl"], 1.0)) except Exception: pass # 상부 모터박스 (권양기) motor_w = g.actuator_radius * 1.4 motor_h = 1.0 motor_z0 = jack_top - motor_h / 2 motor_z1 = motor_z0 + motor_h motor = self._make_box( gx - motor_w, gx + motor_w, ay - motor_w * 0.7, ay + motor_w * 0.7, motor_z0, motor_z1, ) self.meshes.append((motor, COLORS["actuator"], 1.0)) # --- 바닥 slabs (각 EL) --- def _build_floor_slabs(self): """각 중간 EL에 얇은 바닥 slab.""" p = self.p hw = p.body_width / 2 hd = p.body_depth / 2 slab_t = 0.3 for el in p.floor_elevations: if el <= p.body_bottom_el + 0.5: continue if el >= p.body_top_el - 0.5: continue slab = self._make_box( -hw + 0.5, hw - 0.5, -hd + 0.5, hd - 0.5, el - slab_t / 2, el + slab_t / 2, ) self.meshes.append((slab, COLORS["floor"], 1.0)) # --- 지붕 --- def _build_roof(self): p = self.p hw = p.body_width / 2 + 0.3 # 처마 hd = p.body_depth / 2 + 0.3 z0 = p.body_top_el z1 = z0 + p.roof_thickness roof = self._make_box(-hw, hw, -hd, hd, z0, z1) self.meshes.append((roof, COLORS["roof"], 1.0)) # --- 호이스트 레일 --- def _build_hoist_rail(self): p = self.p if not p.has_hoist: return # 레일은 본체 상단 천장 부근에 Y축 방향으로 길게 # 본체 폭 전체에 걸치거나 body_width의 80% 수준 hw = p.body_width / 2 rail_z = p.hoist_rail_el rail_w = 0.3 # H빔 폭 rail_h = 0.4 # H빔 높이 # 레일 2개 (앞쪽, 뒤쪽) hd = p.body_depth / 2 for y_rail in [-hd + 1.5, hd - 1.5]: rail = self._make_box( -hw + 0.5, hw - 0.5, y_rail - rail_w / 2, y_rail + rail_w / 2, rail_z, rail_z + rail_h, ) self.meshes.append((rail, COLORS["hoist_rail"], 1.0)) # --- 호이스트 크레인 (trolley) --- def _build_hoist_crane(self): p = self.p if not p.has_hoist: return # 크레인 주황색 박스 (레일 중간에 매달린 형태) hw = p.body_width / 2 crane_x = 0.0 # 중앙 crane_w = 1.5 crane_d = 2.5 # Y 방향 crane_h = 1.0 crane_z = p.hoist_rail_el + 0.4 # 레일 위 crane = self._make_box( crane_x - crane_w / 2, crane_x + crane_w / 2, -crane_d / 2, crane_d / 2, crane_z, crane_z + crane_h, ) self.meshes.append((crane, COLORS["hoist_crane"], 1.0)) # --- 점검구 커버 --- def _build_inspection_cover(self): p = self.p if not p.has_inspection_cover: return hw = p.body_width / 2 hd = p.body_depth / 2 # 점검구는 지붕 위의 작은 네모 (계단식 2단) cx = p.inspection_cover_x cy = p.inspection_cover_y size = p.inspection_cover_size # 본체 내부로 위치 맞춤 cx = max(-hw + size, min(hw - size, cx)) cy = max(-hd + size, min(hd - size, cy)) z_roof = p.body_top_el # 2단 계단 step1 = self._make_box( cx - size / 2, cx + size / 2, cy - size / 2, cy + size / 2, z_roof + p.roof_thickness, z_roof + p.roof_thickness + 0.3, ) self.meshes.append((step1, COLORS["inspect"], 1.0)) step2 = self._make_box( cx - size / 3, cx + size / 3, cy - size / 3, cy + size / 3, z_roof + p.roof_thickness + 0.3, z_roof + p.roof_thickness + 0.6, ) self.meshes.append((step2, COLORS["inspect"], 1.0)) # --- 외부 출입 계단 --- def _build_entry_stairs(self): p = self.p if not p.has_entry_stairs: return hw = p.body_width / 2 hd = p.body_depth / 2 # 지상(body_bottom_el 주변)에서 body_top_el까지 계단 # 좌측에서 진입 기본 stair_w = p.stairs_width z0 = p.body_bottom_el z1 = p.body_top_el total_rise = z1 - z0 # 계단 개수 (step height 0.17m 표준) n_steps = max(int(total_rise / 0.17), 10) step_h = total_rise / n_steps step_d = 0.28 # 디딤판 깊이 total_run = n_steps * step_d # 계단 위치: 본체 좌측 외부 x_start = -hw - 1.0 x_end = x_start - total_run # 서쪽 방향 if p.stairs_side == "right": x_start = hw + 1.0 x_end = x_start + total_run # 계단을 한 덩어리 경사판으로 표현 (간략화) # 또는 각 단을 박스로 for i in range(n_steps): z_step_bottom = z0 z_step_top = z0 + (i + 1) * step_h if p.stairs_side == "left": sx0 = x_start - (i + 1) * step_d sx1 = x_start - i * step_d else: sx0 = x_start + i * step_d sx1 = x_start + (i + 1) * step_d step = self._make_box( sx0, sx1, -stair_w / 2, stair_w / 2, z_step_bottom, z_step_top, ) self.meshes.append((step, COLORS["stairs"], 1.0)) # --- 난간 / 파라펫 --- def _build_parapet(self): p = self.p if not p.has_parapet: return hw = p.body_width / 2 hd = p.body_depth / 2 z_base = p.body_top_el + p.roof_thickness z_top = z_base + p.parapet_height thick = 0.15 # 4면 파라펫 # 전 self._add_wall(-hw, hw, -hd, -hd + thick, z_base, z_top, COLORS["parapet"]) # 후 self._add_wall(-hw, hw, hd - thick, hd, z_base, z_top, COLORS["parapet"]) # 좌 self._add_wall(-hw, -hw + thick, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"]) # 우 self._add_wall(hw - thick, hw, -hd + thick, hd - thick, z_base, z_top, COLORS["parapet"]) # --- 지반 + 수면 --- def _build_ground_and_water(self): p = self.p # 지반 ground_margin = max(p.body_width, p.body_depth) * 1.5 hw = p.body_width / 2 + ground_margin hd = p.body_depth / 2 + ground_margin # 연장부 고려 if p.has_l_extension: hd += p.extension_length / 2 ground_pts = np.array([ [-hw, -hd, p.body_bottom_el - 0.5], [hw, -hd, p.body_bottom_el - 0.5], [hw, hd, p.body_bottom_el - 0.5], [-hw, hd, p.body_bottom_el - 0.5], ]) ground = pv.PolyData(ground_pts, np.array([4, 0, 1, 2, 3])) self.meshes.append((ground, COLORS["ground"], 1.0)) # 상류측 수면 (전방 Y=-hd 방향으로 평판) if p.gates: # 수면 EL = 가장 높은 수문 EL + 1m (상시만수위 근사) max_gate_el = max(g.elevation for g in p.gates) water_el = max_gate_el + 2.0 # 약간 여유 water_hw = p.body_width / 2 + 20 front_edge = -p.body_depth / 2 water_pts = np.array([ [-water_hw, front_edge - 30, water_el], [water_hw, front_edge - 30, water_el], [water_hw, front_edge + 0.3, water_el], [-water_hw, front_edge + 0.3, water_el], ]) water = pv.PolyData(water_pts, np.array([4, 0, 1, 2, 3])) self.meshes.append((water, COLORS["water"], 0.8)) # 편의 함수 def build_intake_tower_meshes(params: IntakeTowerParams): return IntakeTowerBuilder(params).build_all() if __name__ == "__main__": from intake_tower_parser import parse_intake_tower from pathlib import Path paths = [ "SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf", "SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf", ] params = parse_intake_tower(paths) print(params.summary()) builder = IntakeTowerBuilder(params) meshes = builder.build_all() print(f"\n{len(meshes)}개 구성요소 생성:") for i, (m, c, o) in enumerate(meshes): print(f" [{i:2}] {m.n_points:5}pts {m.n_cells:5}cells color={c}")