"""제수변실 (Valve Chamber) + 도수관로 DXF 파서. 구조 특성: - 콘크리트 실(chamber) 본체 - 내부 밸브 다수 (게이트/버터플라이/체크 등) - 도수관 (intake main pipe, 주 입수관) - 송수관 (transmission pipes, 여러 계통) - 상단 슬라이드 뚜껑/맨홀 - 외부 관로 연장 사용법: parser = ValveChamberParser() params = parser.parse(["valve_chamber.dxf"]) """ from __future__ import annotations import re import math from dataclasses import dataclass, field from pathlib import Path from typing import Optional import ezdxf import numpy as np from view_detector import detect_view_regions, ViewRegion from dxf_geometry import extract_structural_geometry # --------------------------------------------------------------------------- # 데이터 클래스 # --------------------------------------------------------------------------- VALVE_TYPES = { "BUTTERFLY": "버터플라이", "GATE": "게이트", "CHECK": "체크", "BALL": "볼", "UNKNOWN": "일반", } @dataclass class Valve: """개별 밸브.""" index: int = 0 name: str = "" # "M-302" 등 valve_type: str = "GATE" # BUTTERFLY / GATE / CHECK center_x: float = 0.0 # 실 로컬 X (m) center_y: float = 0.0 # 실 로컬 Y (m) elevation: float = 0.0 # EL (m) diameter: float = 0.4 # 관경 (m) label: str = "" # "M-302 주밸브" @dataclass class Pipe: """개별 관로 (도수관/송수관).""" name: str = "" # "M-301 도수관" diameter: float = 0.8 # 관경 (m) start: tuple = (0.0, 0.0, 0.0) # 월드 좌표 end: tuple = (0.0, 0.0, 0.0) elevation: float = 0.0 @dataclass class ValveChamberParams: """제수변실 파라미터.""" # 실 본체 chamber_width: float = 27.0 # X 방향 가로 (실 폭) chamber_depth: float = 9.0 # Y 방향 세로 (실 깊이) chamber_wall_thickness: float = 0.6 bottom_el: float = 21.0 top_el: float = 28.5 # 상판 EL # 내부 바닥 여러 EL (단형 바닥) floor_elevations: list = field(default_factory=list) # 밸브 valves: list = field(default_factory=list) # 관로 (도수/송수) pipes: list = field(default_factory=list) # 주 도수관 (chamber 관통) main_conduit_diameter: float = 1.0 main_conduit_direction: str = "X" # "X" (가로 관통) | "Y" main_conduit_el: float = 22.0 # 상단 슬라이드 뚜껑 / 맨홀 has_hatch: bool = True hatch_count: int = 1 hatch_size: float = 1.0 # 외부 관로 연장 (방향별 분리 — 도면 실측에 맞춤) external_pipe_length: float = 5.0 # legacy 단일값 (fallback·하위호환) upstream_pipe_length: float = 3.0 # 좌측 도수관 상류(분기점 왼쪽) 길이 downstream_pipe_length: float = 4.0 # 우측 송수관 하류 길이 (실측 ≈ 3.6m) # 상류 도수관 분기 (Y-branch): 도수관 1개 → 상단·하단 2개로 분기 후 chamber 진입 has_inlet_branch: bool = True # 분기 구조 여부 (UI 토글) branch_spread_m: float = 4.4 # 상단·하단 관 중심 Y 간격 (실측) branch_angle_deg: float = 35.0 # 분기 합류각 (deg, 단관→분기) branch_trunk_length: float = 3.0 # 분기점 이전의 단일 도수관 길이 # 출입 계단 has_entry_stairs: bool = True # 지반 / 환경 ground_level: Optional[float] = None # 실 바닥보다 높은 지반 # 소스 source_files: list = field(default_factory=list) raw_annotations: list = field(default_factory=list) def summary(self) -> str: return ( f"Valve Chamber: {self.chamber_width:.1f} × {self.chamber_depth:.1f}m, " f"EL.{self.bottom_el:.1f}~{self.top_el:.1f} (H={self.top_el - self.bottom_el:.1f}m)\n" f" 밸브 {len(self.valves)}개, 관로 {len(self.pipes)}개, " f"뚜껑 {'O' if self.has_hatch else 'X'}" ) # --------------------------------------------------------------------------- # 파서 # --------------------------------------------------------------------------- EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE) VALVE_TEXT_PATTERN = re.compile( r"(M-\d{3})[\s.]*(?:(.*?밸브)|(.*?관))", re.IGNORECASE, ) # 밸브 타입 키워드 VALVE_TYPE_KEYWORDS = { "BUTTERFLY": ["버터플라이", "butterfly"], "GATE": ["게이트", "gate"], "CHECK": ["체크", "check"], "BALL": ["볼", "ball"], } def _clean_mtext(raw: str) -> str: """MTEXT 포맷 코드(\\C4;, \\f...;, \\H...;)를 제거하고 \\P를 줄바꿈으로 변환.""" if not raw: return "" text = raw # 색상 \\C#;, 폰트 \\f...;, 높이 \\H...;, 너비 \\W...; 등 일반 포맷 코드 제거 text = re.sub(r"\\C\d+;?", "", text) text = re.sub(r"\\f[^;]*;", "", text) text = re.sub(r"\\H[\d.]+x?;?", "", text) text = re.sub(r"\\W[\d.]+;?", "", text) text = re.sub(r"\\Q[\d.\-]+;?", "", text) text = re.sub(r"\\T[\d.]+;?", "", text) text = re.sub(r"\\A\d+;?", "", text) # 줄바꿈 text = text.replace("\\P", "\n") # 그룹 괄호 정리 text = text.strip() if text.startswith("{"): text = text[1:] if text.endswith("}"): text = text[:-1] return text.strip() def _mleader_endpoint(ml) -> Optional[tuple[float, float]]: """MLEADER에서 첫 leader line의 끝점(화살표 위치, DXF raw 좌표) 반환.""" try: ctx = ml.context for leader in (getattr(ctx, "leaders", None) or []): for line in (getattr(leader, "lines", None) or []): vs = getattr(line, "vertices", None) if vs: return (vs[0].x, vs[0].y) except Exception: return None return None def _mleader_text(ml) -> str: try: ctx = ml.context if ctx and getattr(ctx, "mtext", None): return _clean_mtext(ctx.mtext.default_content or "") except Exception: return "" return "" class ValveChamberParser: """제수변실 파서.""" def parse(self, dxf_paths: list[str]) -> ValveChamberParams: params = ValveChamberParams() params.source_files = list(dxf_paths) for path in dxf_paths: try: self._parse_single(path, params) except Exception as e: print(f" 파싱 오류: {e}") self._finalize(params) return params def _parse_single(self, path: str, params: ValveChamberParams): doc = ezdxf.readfile(path) msp = doc.modelspace() geom = extract_structural_geometry(path) scale = geom.unit_scale views = detect_view_regions(path) # EL 텍스트 수집 el_texts = self._collect_el(msp, scale) if el_texts: els = [v for _, _, v in el_texts] params.top_el = max(params.top_el, max(els)) params.bottom_el = min(params.bottom_el, min(els)) # 중간 EL들 추출 (바닥 단) mid_els = sorted(set( round(v, 1) for _, _, v in el_texts if params.bottom_el < v < params.top_el )) params.floor_elevations = mid_els[:5] # 상위 5개 params.raw_annotations.extend( [(f"EL.{v:.2f}", x, y) for x, y, v in el_texts] ) # 실 본체 크기: 평면도 영역 plan_view = None for v in views: if v.view_type == "plan": plan_view = v break # 1순위: 평면도 영역 그대로 사용 (기본값 27×9는 폴백, 실제 도면 우선) if plan_view: params.chamber_width = plan_view.width params.chamber_depth = plan_view.height else: # 2순위: CS-CONC-밸브실 레이어 bbox chamber_pts = [] for e in msp: if e.dxf.layer != "CS-CONC-밸브실": continue try: if e.dxftype() == "LWPOLYLINE": for p in e.get_points(): chamber_pts.append((p[0] * scale, p[1] * scale)) elif e.dxftype() == "LINE": chamber_pts.append((e.dxf.start.x * scale, e.dxf.start.y * scale)) chamber_pts.append((e.dxf.end.x * scale, e.dxf.end.y * scale)) except Exception: continue if chamber_pts: arr = np.array(chamber_pts) params.chamber_width = arr[:, 0].max() - arr[:, 0].min() params.chamber_depth = arr[:, 1].max() - arr[:, 1].min() # 밸브/관로 추출: # 1) MLEADER (안내선) 끝점에서 정확한 chamber-local 좌표 확보 # 2) MLEADER에 없는 항목은 TEXT 기반 폴백 (M-301 도수관 등) ml_valves, ml_pipes = self._extract_from_mleaders(msp, plan_view, scale, params) txt_valves, txt_pipes = self._extract_valves_and_pipes(msp, scale) # MLEADER 결과가 우선. 같은 M-NNN이 양쪽에 있으면 MLEADER 사용. ml_names = {v.name for v in ml_valves} | {p.name for p in ml_pipes} merged_valves = list(ml_valves) for v in txt_valves: if v.name and v.name not in ml_names: merged_valves.append(v) merged_pipes = list(ml_pipes) for p in txt_pipes: if p.name and p.name not in ml_names: merged_pipes.append(p) # dedupe by name — 같은 M-NNN이 여러 엔티티(label column TEXT + in-drawing MTEXT) # 에서 잡혀 중복 생성되는 케이스 방지. 직경이 명시된 항목 우선. def _dedupe(items, default_dia: float): best: dict[str, object] = {} for it in items: key = it.name if not key: continue if key not in best: best[key] = it else: cur = best[key] cur_default = abs(getattr(cur, "diameter", 0) - default_dia) < 1e-6 new_default = abs(getattr(it, "diameter", 0) - default_dia) < 1e-6 if cur_default and not new_default: best[key] = it return list(best.values()) merged_valves = _dedupe(merged_valves, default_dia=0.4) merged_pipes = _dedupe(merged_pipes, default_dia=0.8) if merged_valves: params.valves = merged_valves if merged_pipes: params.pipes = merged_pipes for p in merged_pipes: if "M-301" in p.name or "도수관" in p.name: params.main_conduit_diameter = max(p.diameter, params.main_conduit_diameter) params.main_conduit_el = p.elevation or params.main_conduit_el # 슬라이드 뚜껑 INSERT 확인 for e in msp.query("INSERT"): if "슬라이드" in getattr(e.dxf, "name", ""): params.has_hatch = True params.hatch_count = max(params.hatch_count, 1) def _collect_el(self, msp, scale: float) -> list: out = [] for e in msp: if e.dxftype() not in ("TEXT", "MTEXT"): continue try: txt = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "") m = EL_PATTERN.search(txt) if m: pos = e.dxf.insert out.append((pos.x * scale, pos.y * scale, float(m.group(1)))) except Exception: continue return out def _extract_from_mleaders(self, msp, plan_view, scale: float, params: ValveChamberParams ) -> tuple[list[Valve], list[Pipe]]: """MLEADER(안내선)로 밸브/관로의 정확한 chamber-local 위치 추출. - leader 끝점이 plan_view bounds 안이면 chamber-local 좌표로 변환 - 텍스트에서 M-NNN, "밸브"/"도수관"/"송수관"/"D=숫자" 추출 - destination-only pipe (예: "{천상정수장 D1,200}")도 출력 관로로 인식 """ valves: list[Valve] = [] pipes: list[Pipe] = [] if plan_view: cx = (plan_view.bounds[0] + plan_view.bounds[2]) / 2.0 cy = (plan_view.bounds[1] + plan_view.bounds[3]) / 2.0 x0, y0, x1, y1 = plan_view.bounds else: cx = cy = 0.0 x0 = y0 = -1e18; x1 = y1 = 1e18 for ml in msp.query("MULTILEADER MLEADER"): txt = _mleader_text(ml) end = _mleader_endpoint(ml) if not txt or end is None: continue ex_m = end[0] * scale ey_m = end[1] * scale # plan view 밖이면 (예: side view, M-306 등) 건너뜀 — chamber 평면 배치엔 안 씀 if not (x0 <= ex_m <= x1 and y0 <= ey_m <= y1): continue local_x = ex_m - cx local_y = ey_m - cy # 텍스트 분류 m_match = re.search(r"(M-\d{3})", txt) name = m_match.group(1) if m_match else "" txt_clean = txt.replace("\n", " ").strip() is_valve = "밸브" in txt_clean is_pipe_kw = any(kw in txt_clean for kw in ("도수관", "송수관", "강재도관")) # destination + D=숫자 → 출력 송수관 is_dest_pipe = ( ("정수장" in txt_clean or "계통" in txt_clean or "유지용수" in txt_clean) and re.search(r"D[\s,.]*\d", txt_clean) is not None and not is_valve ) # 직경 파싱 (D1,200 / D80 / Φ800 / D=800 / %%c800) # D 다음에 optional 공백·=·콜론, 그 뒤 숫자(쉼표/마침표 천 단위 구분자 허용) dia_m = re.search(r"(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)", txt_clean) diameter = None if dia_m: raw = dia_m.group(1).replace(",", "").rstrip(".") try: val = float(raw) # mm 단위 가정 (val>=10), 그 이하는 m로 가정 diameter = val / 1000.0 if val >= 10 else val except ValueError: pass if is_valve: # 타입 추정: 부밸브/주밸브에서 직접 못 가르므로 GATE 기본 vtype = "GATE" for vt, kws in VALVE_TYPE_KEYWORDS.items(): if any(kw in txt_clean for kw in kws): vtype = vt break v = Valve( index=len(valves), name=name or f"V?{len(valves)+1}", valve_type=vtype, center_x=local_x, center_y=local_y, elevation=params.bottom_el + 1.5, diameter=diameter or 0.5, label=txt_clean[:60], ) valves.append(v) elif is_pipe_kw or is_dest_pipe: # 방향: 끝점이 chamber 중심 기준 어느 쪽에 가까운지로 판단 # 좌측 가까우면 -X 외부, 우측이면 +X 외부, 위/아래는 ±Y half_w = params.chamber_width / 2.0 half_d = params.chamber_depth / 2.0 rx = abs(local_x) / max(half_w, 1e-6) ry = abs(local_y) / max(half_d, 1e-6) z = params.bottom_el + 1.5 if rx >= ry: # X 방향: 좌측=상류 도수관(has_inlet_branch 시 _finalize가 분기 생성), # 우측=하류 송수관 → 실측 길이 분리 sx = 1.0 if local_x >= 0 else -1.0 ext = (params.downstream_pipe_length if sx > 0 else params.upstream_pipe_length) start = (local_x, local_y, z) end_pt = (sx * (half_w + ext), local_y, z) else: sy = 1.0 if local_y >= 0 else -1.0 ext = max(3.0, params.external_pipe_length) start = (local_x, local_y, z) end_pt = (local_x, sy * (half_d + ext), z) p = Pipe( name=name or txt_clean[:30], diameter=diameter or 0.8, start=start, end=end_pt, elevation=z, ) pipes.append(p) return valves, pipes def _extract_valves_and_pipes(self, msp, scale: float, proximity_radius_m: float = 5.0 ) -> tuple[list[Valve], list[Pipe]]: """TEXT/MTEXT에서 M-번호 밸브/관로 추출 (proximity grouping). 도면에 따라 라벨이 분리될 수 있는 4가지 케이스 모두 처리: 1) 단일 TEXT: "M-302 송수관 주밸브(1)" — 한 엔티티에 모든 정보 2) MTEXT 멀티라인: "{도수관\\PM-301}" — 같은 엔티티 두 줄 (\\P 분리) 3) 인접 TEXT 분리: "M-301" + "도수관"이 인접 좌표에 별개 엔티티 4) 라벨 + 외부 직경 텍스트: "M-302 주밸브" + "Φ800"이 근처 알고리즘: - 모든 TEXT/MTEXT 정리(MTEXT 포맷 코드 strip, \\P→\\n) - 각 엔티티의 TEXT를 (clean_txt, x, y)로 수집 - M-NNN을 포함하는 엔티티마다 proximity_radius_m 내 다른 엔티티 텍스트를 병합해 full_label 구성 (단, 다른 M-NNN 엔티티는 제외) - full_label에서 분류·직경 추출 """ valves: list[Valve] = [] pipes: list[Pipe] = [] # 1) 모든 텍스트 엔티티 수집 (정리된 텍스트 + 위치) items: list[tuple[str, float, float]] = [] for e in msp: if e.dxftype() not in ("TEXT", "MTEXT"): continue try: raw = e.dxf.text if e.dxftype() == "TEXT" else (e.text or "") if e.dxftype() == "MTEXT": txt = _clean_mtext(raw) else: txt = raw.strip() if not txt: continue pos = e.dxf.insert items.append((txt, pos.x * scale, pos.y * scale)) except Exception: continue # 2) M-NNN을 가진 인덱스 찾기 (re.search로 any-position 매칭, 케이스 1+2 커버) m_indices: list[tuple[int, str, float, float, str]] = [] # (idx, name, x, y, txt) for i, (txt, x, y) in enumerate(items): m_match = re.search(r"\b(M-\d{3})\b", txt) if m_match: m_indices.append((i, m_match.group(1), x, y, txt)) # 3) 각 M-NNN을 분류. 우선 own_txt만으로 시도 → 모호하면 proximity로 보강. m_idx_set = {idx for idx, *_ in m_indices} def _classify(text: str) -> tuple[bool, bool, bool]: is_p = any(kw in text for kw in ("도수관", "송수관", "강재도관", "pipe", "Pipe")) is_v = any(kw in text for kw in ("밸브", "valve", "Valve")) is_dest = ( ("정수장" in text or "계통" in text or "유지용수" in text) and re.search(r"D[\s,.=]*\d", text) is not None and not is_v ) return is_p, is_v, is_dest def _parse_dia(text: str) -> Optional[float]: m = re.search(r"(?:Φ|%%[cC]|\bD[\s=:]*)(\d[\d,.]*)", text) if not m: return None raw_d = m.group(1).replace(",", "").rstrip(".") try: val = float(raw_d) return val / 1000.0 if val >= 10 else val except ValueError: return None for j, (idx, name, lx, ly, own_txt) in enumerate(m_indices): # 1차: own_txt만으로 분류 is_pipe_kw, is_valve, is_dest_pipe = _classify(own_txt) diameter = _parse_dia(own_txt) full_txt = own_txt # 2차: own에 분류·직경 정보가 부족할 때만 proximity로 보강 need_class = not (is_pipe_kw or is_valve or is_dest_pipe) need_dia = diameter is None if need_class or need_dia: near_parts: list[str] = [] r2 = proximity_radius_m * proximity_radius_m for k, (txt2, x2, y2) in enumerate(items): if k == idx or k in m_idx_set: continue d2 = (x2 - lx) ** 2 + (y2 - ly) ** 2 if d2 <= r2: near_parts.append(txt2) if near_parts: near_txt = " ".join(near_parts).replace("\n", " ").strip() if need_class: np_pipe, np_valve, np_dest = _classify(near_txt) is_pipe_kw = is_pipe_kw or np_pipe is_valve = is_valve or np_valve is_dest_pipe = is_dest_pipe or np_dest if need_dia: diameter = _parse_dia(near_txt) full_txt = (own_txt + " | " + near_txt).strip() if is_valve: vtype = "GATE" for vt, kws in VALVE_TYPE_KEYWORDS.items(): if any(kw in full_txt for kw in kws): vtype = vt break valves.append(Valve( index=j, name=name, valve_type=vtype, center_x=0, center_y=0, elevation=0, diameter=diameter or (0.5 if vtype == "BUTTERFLY" else 0.4), label=full_txt[:60], )) elif is_pipe_kw or is_dest_pipe: pipe = Pipe(name=name) pipe.diameter = diameter if diameter is not None else 0.8 pipes.append(pipe) # 밸브도 파이프도 아니면 스킵 (펌프/사다리/덮개 등 비처리 항목) return valves, pipes def _finalize(self, params: ValveChamberParams): """파라미터 정리. MLEADER에서 정확한 위치를 못 얻은 항목만 폴백 배치. 상류 도수관(M-301)은 `has_inlet_branch=True`일 때 **분기 구조**로 교체: 단일 trunk → Y-branch(경사 2개) → 상단·하단 평행 관(chamber 진입). 도면(신설 제수변실.dxf) 실측: branch_spread ≈ 4.4m, 우측 하류 관로 ≈ 3.6m. """ # 위치(center_x,y) 또는 (start,end)가 (0,0,0)인 것만 자동 배치 if params.valves: unset = [v for v in params.valves if v.center_x == 0 and v.center_y == 0] n = len(unset) for i, v in enumerate(unset): t = (i + 0.5) / max(n, 1) - 0.5 v.center_x = t * params.chamber_width * 0.7 v.center_y = 0.0 v.elevation = params.bottom_el + 2.0 # --- 상류 도수관 분기 생성 / 단일 폴백 --- if params.pipes: main_idx = [i for i, p in enumerate(params.pipes) if "도수관" in p.name or p.name == "M-301"] main_pipes = [params.pipes[i] for i in main_idx] orig_main = main_pipes[0] if main_pipes else None # 기존 도수관 항목 제거 (분기든 단일이든 새로 생성) if main_idx: params.pipes = [p for j, p in enumerate(params.pipes) if j not in main_idx] dia = orig_main.diameter if orig_main else params.main_conduit_diameter z = (orig_main.elevation if orig_main and orig_main.elevation else params.main_conduit_el or (params.bottom_el + 1.0)) half_w = params.chamber_width / 2.0 if params.has_inlet_branch and orig_main is not None: import math as _m spread_half = params.branch_spread_m / 2.0 angle = _m.radians(max(10.0, min(70.0, params.branch_angle_deg))) # 분기 경사 길이: spread_half = L_branch * sin(angle) → L_branch = spread_half/sin # X 진행: L_branch * cos(angle) L_branch = spread_half / max(_m.sin(angle), 0.1) dx_branch = L_branch * _m.cos(angle) # chamber 좌측벽 X = -half_w # 상단·하단 평행 관 길이 = upstream_pipe_length (분기점 이후 → chamber 벽) x_branch_out = -half_w - 0.0 # 상단·하단이 chamber 벽에 도달하는 X x_branch_in = x_branch_out - params.upstream_pipe_length # 분기가 합쳐지는 지점(X 방향 안쪽 끝) x_merge = x_branch_in - dx_branch # Y축 0으로 모이는 분기점(상류측) # 1) 상단 평행관 (chamber 좌측벽 → 분기 상단 끝) params.pipes.append(Pipe( name="M-301 상단", diameter=dia, start=(x_branch_in, spread_half, z), end=(x_branch_out, spread_half, z), elevation=z, )) # 2) 하단 평행관 params.pipes.append(Pipe( name="M-301 하단", diameter=dia, start=(x_branch_in, -spread_half, z), end=(x_branch_out, -spread_half, z), elevation=z, )) # 3) 상단 경사 (분기점 → 상단 끝) params.pipes.append(Pipe( name="M-301 분기상경사", diameter=dia, start=(x_merge, 0.0, z), end=(x_branch_in, spread_half, z), elevation=z, )) # 4) 하단 경사 params.pipes.append(Pipe( name="M-301 분기하경사", diameter=dia, start=(x_merge, 0.0, z), end=(x_branch_in, -spread_half, z), elevation=z, )) # 5) 단일 trunk (분기점 → 상류로 trunk_length) params.pipes.append(Pipe( name="M-301 도수관", diameter=dia, start=(x_merge - max(0.5, params.branch_trunk_length), 0.0, z), end=(x_merge, 0.0, z), elevation=z, )) elif orig_main is not None and orig_main.start != (0.0, 0.0, 0.0): # 분기 비활성 + 원본 pipe가 명시적 경로를 가졌을 때만 유지. # (start/end가 origin인 seed는 폐기 — 도면에 없는 관로가 만들어지지 않게) params.pipes.append(orig_main) # 나머지 origin-only pipe는 방향별 폴백 for p in params.pipes: if not (p.start == (0.0, 0.0, 0.0) and p.end == (0.0, 0.0, 0.0)): continue half_d = params.chamber_depth / 2 + params.external_pipe_length z2 = params.bottom_el + 1.5 p.start = (0, -half_d, z2) p.end = (0, half_d, z2) p.elevation = z2 def parse_valve_chamber(paths: list[str]) -> ValveChamberParams: return ValveChamberParser().parse(paths) if __name__ == "__main__": import sys paths = sys.argv[1:] if len(sys.argv) > 1 else [ "SAMPLE_CAD/12996710-M43-002 신설 제수변실 설비 배치도.dxf", ] p = parse_valve_chamber(paths) print(p.summary()) print("\n밸브:") for v in p.valves: print(f" {v.name} [{VALVE_TYPES.get(v.valve_type, '?')}] {v.label}") print("\n관로:") for pipe in p.pipes: print(f" {pipe.name} Φ{pipe.diameter*1000:.0f}mm") print(f"\n바닥 EL: {p.floor_elevations}")