"""제수변실 + 도수관로 3D 파라메트릭 빌더. 구성요소: 1. 실 본체 (콘크리트 박스) 2. 벽체 절개 뷰 (내부가 보이도록 상부 일부 제거) 3. 도수관 (chamber 관통 파이프) 4. 송수관 (외부 연장) 5. 밸브 × N (원통 + 핸들) 6. 상단 슬라이드 뚜껑 / 맨홀 7. 내부 바닥 slabs (각 EL) 8. 외부 출입 계단 9. 지반 """ from __future__ import annotations import math from typing import Optional import numpy as np import pyvista as pv from valve_chamber_parser import ValveChamberParams, Valve, Pipe COLORS = { "chamber": "#A8A59B", "slab": "#9A968C", "pipe_steel": "#4E6E8E", # 강재도관 (강청색) "pipe_cast": "#6B7B8C", # 주철관 "valve_body": "#27AE60", # 밸브 바디 (녹색 - 산업 표준) "valve_handle":"#E74C3C", # 밸브 핸들 (빨강) "valve_motor":"#F39C12", # 전동 모터 (주황) "hatch": "#BDC3C7", # 뚜껑 "stairs": "#8B7D6B", "parapet": "#A8A59B", "ground": "#7F6F5F", "pipe_conn": "#4A4A4A", # 플랜지 } class ValveChamberBuilder: def __init__(self, params: ValveChamberParams): self.p = params self.meshes: list[tuple[pv.PolyData, str, float]] = [] def build_all(self): self.meshes = [] self._build_chamber_body() self._build_floor_slabs() self._build_main_conduit() self._build_transmission_pipes() self._build_valves() self._build_hatch() self._build_entry_stairs() self._build_ground() return self.meshes # --- 실 본체 --- def _build_chamber_body(self): p = self.p hw = p.chamber_width / 2 hd = p.chamber_depth / 2 wt = p.chamber_wall_thickness z0 = p.bottom_el z_top = p.top_el # 4개 벽 (상단 일부는 뚜껑을 위해 빼지 않음) # 앞 (Y = -hd) self._add_box(-hw, hw, -hd, -hd + wt, z0, z_top, COLORS["chamber"]) # 뒤 self._add_box(-hw, hw, hd - wt, hd, z0, z_top, COLORS["chamber"]) # 좌 self._add_box(-hw, -hw + wt, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"]) # 우 self._add_box(hw - wt, hw, -hd + wt, hd - wt, z0, z_top, COLORS["chamber"]) # 바닥 self._add_box(-hw, hw, -hd, hd, z0, z0 + 0.4, COLORS["chamber"]) # 상판 (지붕) - 뚜껑 공간 제외 slab_t = 0.4 if p.has_hatch and p.hatch_count > 0: # 뚜껑 위치는 상단 중앙 hatch_s = p.hatch_size / 2 n = p.hatch_count # 뚜껑 여러 개면 X방향 분산 for i in range(n): if n == 1: hx = 0 else: hx = -hw * 0.5 + (i / (n - 1)) * hw # 상판은 뚜껑 기준 좌우로 분할 # 좌측 if i == 0: self._add_box(-hw, hx - hatch_s, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"]) # 우측 (마지막이면 우측 끝까지) if i == n - 1: self._add_box(hx + hatch_s, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"]) # 뚜껑 주변 Y 방향 채움 self._add_box(hx - hatch_s, hx + hatch_s, -hd, -hatch_s, z_top, z_top + slab_t, COLORS["chamber"]) self._add_box(hx - hatch_s, hx + hatch_s, hatch_s, hd, z_top, z_top + slab_t, COLORS["chamber"]) else: # 풀 상판 self._add_box(-hw, hw, -hd, hd, z_top, z_top + slab_t, COLORS["chamber"]) # --- 내부 바닥 slab (각 EL) --- def _build_floor_slabs(self): p = self.p hw = p.chamber_width / 2 - p.chamber_wall_thickness - 0.1 hd = p.chamber_depth / 2 - p.chamber_wall_thickness - 0.1 for el in p.floor_elevations: if el <= p.bottom_el + 0.4: continue if el >= p.top_el - 0.4: continue self._add_box(-hw, hw, -hd, hd, el - 0.1, el + 0.1, COLORS["slab"]) # --- 도수관 (chamber 관통) --- def _build_main_conduit(self): """파서가 M-301/도수관 pipe를 추출했으면 그쪽에 위임 (no-op). 분기(has_inlet_branch)·단일 모두 파서 `_finalize`가 pipe 객체로 생성하므로 빌더는 `_build_transmission_pipes` 한 경로만 돌면 된다. 파서에서 도수관이 전혀 없을 때만(비정상 상태) 경고 출력 후 종료. """ p = self.p has_main = any( ("M-301" in pp.name) or ("도수관" in pp.name) for pp in (p.pipes or []) ) if has_main: return # 여기에 도달한다면 파서가 도수관을 생성 실패한 것 — 조용히 skip하여 # "혼자 떠 있는 고아 파이프"가 생기지 않도록 한다. return # --- 송수관 및 외부 연장 --- def _build_transmission_pipes(self): """parser가 추출한 모든 pipe를 명시적 start/end/diameter로 빌드.""" p = self.p if not p.pipes: return for pipe in p.pipes: start = pipe.start end = pipe.end # 유효성 체크: zero-vector 이면 폴백 _finalize에서 처리됨 if start == (0.0, 0.0, 0.0) and end == (0.0, 0.0, 0.0): continue is_main = ("M-301" in pipe.name) or ("도수관" in pipe.name) color = COLORS["pipe_steel"] if is_main else COLORS["pipe_cast"] self._add_pipe(start, end, pipe.diameter, color) # 양 끝 플랜지 (도수관/대구경만) if pipe.diameter >= 0.3: self._add_flange(start, pipe.diameter, COLORS["pipe_conn"]) self._add_flange(end, pipe.diameter, COLORS["pipe_conn"]) # --- 밸브 --- def _build_valves(self): p = self.p for v in p.valves: self._build_single_valve(v) def _build_single_valve(self, v: Valve): """밸브 하나: 바디 + 핸들/모터.""" # 위치 검증 z = v.elevation x = v.center_x y = v.center_y # 밸브 바디 (축정렬 원통 또는 박스) body_r = v.diameter / 2 body_h = v.diameter * 1.5 # 밸브가 놓인 파이프 방향 추정 (X 또는 Y) # 가장 가까운 pipe의 (end-start) 방향을 사용; 없으면 X축 기본 flow_dir = (1.0, 0.0, 0.0) try: best_d = None for pp in (self.p.pipes or []): if pp.start == (0,0,0) and pp.end == (0,0,0): continue # pipe 중간점과 valve 거리 mx = (pp.start[0] + pp.end[0]) / 2 my = (pp.start[1] + pp.end[1]) / 2 d = ((mx - x)**2 + (my - y)**2) ** 0.5 if best_d is None or d < best_d: dx = pp.end[0] - pp.start[0] dy = pp.end[1] - pp.start[1] L = (dx*dx + dy*dy) ** 0.5 if L > 1e-6: flow_dir = (dx/L, dy/L, 0.0) best_d = d except Exception: pass if v.valve_type == "BUTTERFLY": # 버터플라이: 납작한 원통 (관로 방향에 직교 평면) body = pv.Cylinder( center=(x, y, z), direction=flow_dir, radius=body_r * 1.1, height=body_h * 0.6, resolution=20, ).extract_surface() self.meshes.append((body, COLORS["valve_body"], 1.0)) # 상부 모터 박스 motor_size = body_r * 1.2 motor_base = z + body_r * 1.1 motor_top = motor_base + motor_size * 2 self._add_box( x - motor_size / 2, x + motor_size / 2, y - motor_size / 2, y + motor_size / 2, motor_base, motor_top, COLORS["valve_motor"], ) # 모터 축 stem = pv.Cylinder( center=(x, y, (z + body_r + motor_base) / 2), direction=(0, 0, 1), radius=0.05, height=(motor_base - z - body_r), resolution=8, ).extract_surface() self.meshes.append((stem, COLORS["pipe_conn"], 1.0)) elif v.valve_type == "GATE": # 게이트: 박스형 바디. 관로 방향이 X면 X축으로 길게, Y면 Y축으로 길게 if abs(flow_dir[0]) >= abs(flow_dir[1]): # X 방향 흐름 → 박스도 X 길이↑ self._add_box( x - body_r * 1.4, x + body_r * 1.4, y - body_r, y + body_r, z - body_r, z + body_r, COLORS["valve_body"], ) else: # Y 방향 흐름 self._add_box( x - body_r, x + body_r, y - body_r * 1.4, y + body_r * 1.4, z - body_r, z + body_r, COLORS["valve_body"], ) # 상부 stem (게이트 밸브의 특징) stem_h = body_r * 4 stem = pv.Cylinder( center=(x, y, z + body_r + stem_h / 2), direction=(0, 0, 1), radius=0.04, height=stem_h, resolution=8, ).extract_surface() self.meshes.append((stem, COLORS["pipe_conn"], 1.0)) # 상부 핸들 or 모터 handle_r = body_r * 1.0 handle = pv.Cylinder( center=(x, y, z + body_r + stem_h), direction=(0, 0, 1), radius=handle_r, height=0.1, resolution=16, ).extract_surface() self.meshes.append((handle, COLORS["valve_handle"], 1.0)) else: # 일반: 단순 박스 self._add_box( x - body_r, x + body_r, y - body_r, y + body_r, z - body_r, z + body_r, COLORS["valve_body"], ) # --- 상단 슬라이드 뚜껑 --- def _build_hatch(self): p = self.p if not p.has_hatch: return hw = p.chamber_width / 2 hs = p.hatch_size / 2 z = p.top_el + 0.5 # 상판 위 for i in range(p.hatch_count): if p.hatch_count == 1: hx = 0 else: hx = -hw * 0.5 + (i / (p.hatch_count - 1)) * hw # 뚜껑 (약간 돌출) self._add_box( hx - hs, hx + hs, -hs, hs, z, z + 0.15, COLORS["hatch"], ) # --- 외부 출입 계단 --- def _build_entry_stairs(self): p = self.p if not p.has_entry_stairs: return hw = p.chamber_width / 2 total_rise = p.top_el - p.bottom_el n_steps = max(int(total_rise / 0.17), 8) step_h = total_rise / n_steps step_d = 0.28 stair_w = 1.2 z0 = p.bottom_el # 좌측 외부 계단 x_start = -hw - 0.5 for i in range(n_steps): sx0 = x_start - (i + 1) * step_d sx1 = x_start - i * step_d z_top = z0 + (i + 1) * step_h self._add_box(sx0, sx1, -stair_w / 2, stair_w / 2, z0, z_top, COLORS["stairs"]) # --- 지반 --- def _build_ground(self): p = self.p margin = max(p.chamber_width, p.chamber_depth) * 0.8 hw = p.chamber_width / 2 + margin hd = p.chamber_depth / 2 + margin ground_el = p.bottom_el - 0.5 pts = np.array([ [-hw, -hd, ground_el], [hw, -hd, ground_el], [hw, hd, ground_el], [-hw, hd, ground_el], ]) self.meshes.append((pv.PolyData(pts, np.array([4, 0, 1, 2, 3])), COLORS["ground"], 1.0)) # --- 저수준 헬퍼 --- def _add_box(self, x0, x1, y0, y1, z0, z1, color): if x1 <= x0 or y1 <= y0 or z1 <= z0: return 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], ]) self.meshes.append((pv.PolyData(pts, faces), color, 1.0)) def _add_pipe(self, start, end, diameter, color): try: s = np.array(start) e = np.array(end) length = float(np.linalg.norm(e - s)) if length < 0.1: return direction = (e - s) / length center = (s + e) / 2 pipe = pv.Cylinder( center=tuple(center), direction=tuple(direction), radius=diameter / 2, height=length, resolution=20, ).extract_surface() self.meshes.append((pipe, color, 1.0)) except Exception: pass def _add_flange(self, pos, diameter, color): try: flange = pv.Cylinder( center=pos, direction=(1, 0, 0), # X방향 간단 배치 radius=diameter / 2 * 1.3, height=0.15, resolution=16, ).extract_surface() self.meshes.append((flange, color, 1.0)) except Exception: pass def build_valve_chamber_meshes(params: ValveChamberParams): return ValveChamberBuilder(params).build_all() if __name__ == "__main__": from valve_chamber_parser import parse_valve_chamber paths = ["SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf"] p = parse_valve_chamber(paths) print(p.summary()) meshes = ValveChamberBuilder(p).build_all() print(f"\n{len(meshes)}개 구성요소 생성")