"""취수탑 (Intake Tower) 전용 DXF 파서. 취수탑 구조의 특성: - L자 또는 직사각 콘크리트 본체 (여러 층 구조) - 다수의 취수수문 (각각 다른 EL에 배치) - 수문마다 개폐장치 (원통형) - 상부 호이스트 크레인 + 레일 - 점검구, 계단, 사다리 - 여러 바닥 slab (각 EL별) 핵심 파싱 로직: 1. 뷰 검출: 평면도 / 정면도 / 측면도 2. 수문 위치: 정면도 내 반복되는 원(개폐장치 상징) → 개수 + 위치 + EL 3. 본체 외곽: 정면도 or 평면도의 최대 closed polygon 4. 주요 EL: 텍스트 "EL.XXX.XXX" 패턴 5. 호이스트 레일: 상단 긴 수평 LINE 6. 지붕 / 바닥 slabs: 여러 EL 별 수평선 사용법: parser = IntakeTowerParser() params = parser.parse([plan_section_dxf_path]) """ from __future__ import annotations import re from dataclasses import dataclass, field import ezdxf from view_detector import detect_view_regions, ViewRegion from dxf_geometry import extract_structural_geometry # --------------------------------------------------------------------------- # 데이터 클래스 # --------------------------------------------------------------------------- @dataclass class GatePosition: """개별 취수수문 정보.""" index: int # 0부터 center_x: float # 본체 로컬 X (m) elevation: float # EL (m, 해발) actuator_radius: float = 0.6 # 개폐장치 원통 반경 gate_width: float = 2.0 # 수문 폭 (로컬 X방향) gate_height: float = 2.0 # 수문 높이 label: str = "" @dataclass class IntakeTowerParams: """취수탑 파라미터 (단위: m).""" # 본체 외곽 body_width: float = 11.2 # 가로 body_depth: float = 6.4 # 세로 (평면도에서) body_bottom_el: float = 39.0 # 바닥 EL body_top_el: float = 57.2 # 상단 EL # L자 여부 (접근수로 옹벽 포함) has_l_extension: bool = True # 한쪽으로 연장된 부분 extension_length: float = 14.5 # 연장 길이 extension_width: float = 6.4 # 연장 폭 extension_bottom_el: float = 41.0 # 수문 배치 (정면도 기준) gates: list = field(default_factory=list) # 호이스트 has_hoist: bool = True hoist_rail_el: float = 56.0 # 호이스트 레일 EL hoist_rail_length: float = 10.0 # 지붕 roof_type: str = "flat" # flat | gabled roof_thickness: float = 0.5 # 내부 바닥 slabs (각 EL) floor_elevations: list = field(default_factory=list) # [43.0, 46.0, 48.5, ...] # 외부 출입 has_entry_stairs: bool = True stairs_width: float = 1.5 stairs_side: str = "left" # left | right | front | back # 점검구 has_inspection_cover: bool = True inspection_cover_x: float = 2.0 # 본체 로컬 X inspection_cover_y: float = 3.0 inspection_cover_size: float = 2.5 # 난간 has_parapet: bool = True parapet_height: float = 1.1 # 소스 파일 source_files: list = field(default_factory=list) raw_annotations: list = field(default_factory=list) def summary(self) -> str: return ( f"Intake Tower: {self.body_width:.1f} × {self.body_depth:.1f}m, " f"EL.{self.body_bottom_el:.1f}~{self.body_top_el:.1f} " f"(H={self.body_top_el - self.body_bottom_el:.1f}m)\n" f" 수문 {len(self.gates)}개, 바닥 {len(self.floor_elevations)}개 EL, " f"호이스트 {'O' if self.has_hoist else 'X'}" ) # --------------------------------------------------------------------------- # 파서 # --------------------------------------------------------------------------- EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE) class IntakeTowerParser: """취수탑 DXF 파서.""" def parse(self, dxf_paths: list[str]) -> IntakeTowerParams: """여러 DXF 파일에서 파라미터 추출.""" params = IntakeTowerParams() params.source_files = list(dxf_paths) # 모든 DXF를 순회하며 정보 수집 for path in dxf_paths: try: self._parse_single(path, params) except Exception as e: print(f" 파싱 오류 ({path}): {e}") # 정리 및 정규화 self._finalize_params(params) return params def _parse_single(self, path: str, params: IntakeTowerParams): """단일 DXF에서 정보 추출 → params에 누적.""" doc = ezdxf.readfile(path) msp = doc.modelspace() geom = extract_structural_geometry(path) scale = geom.unit_scale views = detect_view_regions(path) # 1) 표고(EL) 텍스트 수집 el_texts = self._collect_el_texts(msp, scale) params.raw_annotations.extend( [(f"EL.{v:.2f}", x, y) for (x, y, v) in el_texts] ) if el_texts: els = [v for (_, _, v) in el_texts] params.body_top_el = max(params.body_top_el, *els) params.body_bottom_el = min(params.body_bottom_el, *els) # 2) 수문 개폐장치 원 검출 (정면도 내) front_view = self._find_view(views, "front") if front_view: gates = self._detect_gates_in_front_view(msp, front_view, el_texts, scale) if gates: params.gates = gates # 3) 평면도 영역에서 본체 크기 추정 plan_view = self._find_view(views, "plan") if plan_view: # 평면도 bbox를 본체 크기로 (근사) params.body_width = max(params.body_width, plan_view.width) params.body_depth = max(params.body_depth, plan_view.height) # 4) 호이스트 레일 검출 (상단 긴 수평선) hoist = self._detect_hoist_rail(msp, scale, params.body_top_el) if hoist: params.hoist_rail_el = hoist["el"] params.hoist_rail_length = hoist["length"] params.has_hoist = True # 5) 바닥 EL 목록 (표고 텍스트 + 수문 EL) floor_els = set() for (_, _, v) in el_texts: if v > params.body_bottom_el + 0.5 and v < params.body_top_el - 0.5: floor_els.add(round(v, 1)) for g in params.gates: floor_els.add(round(g.elevation, 1)) params.floor_elevations = sorted(floor_els) def _collect_el_texts(self, msp, scale: float) -> list[tuple]: """모든 EL. 텍스트 수집 → [(x, y, value), ...].""" results = [] 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 results.append((pos.x * scale, pos.y * scale, float(m.group(1)))) except Exception: continue return results def _find_view(self, views: list[ViewRegion], view_type: str) -> ViewRegion | None: for v in views: if v.view_type == view_type: return v return None def _detect_gates_in_front_view(self, msp, front_view: ViewRegion, el_texts: list, scale: float) -> list[GatePosition]: """정면도 내 반복되는 원(개폐장치) → 수문 배치. 반복 조건: 같은 반경의 원이 3개 이상, 같은 X 또는 Y선상 정렬. """ # 정면도 bbox (월드 좌표, m) fx0, fy0, fx1, fy1 = front_view.bounds # margin 확대 (원이 bbox 경계 걸쳐 있을 수 있음) margin = 1.0 fx0 -= margin; fy0 -= margin; fx1 += margin; fy1 += margin # 정면도 영역 안의 원들 수집 circles_in_view = [] for e in msp.query("CIRCLE"): try: cx = e.dxf.center.x * scale cy = e.dxf.center.y * scale r = e.dxf.radius * scale if fx0 <= cx <= fx1 and fy0 <= cy <= fy1: # 너무 작은 원(볼트/리벳)은 제외 if r < 0.05: continue circles_in_view.append((cx, cy, r, e.dxf.layer)) except Exception: continue if len(circles_in_view) < 2: return [] # 반경별 그룹화 (0.1m 허용오차) from collections import defaultdict groups = defaultdict(list) for cx, cy, r, layer in circles_in_view: key = round(r, 1) groups[key].append((cx, cy, r, layer)) # 3개 이상의 그룹 우선 (수문은 보통 3문), 2개도 허용 candidate_groups = [g for g in groups.values() if len(g) >= 2] if not candidate_groups: return [] # 가장 큰 반경의 그룹 선택 (수문 개폐장치는 보통 큼) candidate_groups.sort(key=lambda g: (-g[0][2], -len(g))) main_group = candidate_groups[0] # 수문 위치 확정: X 또는 Y 정렬 여부 확인 (현재는 정면도 가정 — 좌우/상하 배치 분기는 미구현) # X 변화가 크면 → 수문이 좌우 배치 (평면도), Y 변화가 크면 → 상하 배치 (정면도, EL별) gates = [] # 로컬 X 좌표 계산 (정면도 내에서 중심 기준) front_cx = (front_view.bounds[0] + front_view.bounds[2]) / 2 for i, (cx, cy, r, layer) in enumerate(sorted(main_group, key=lambda c: c[1])): # EL 추정: cy 좌표 근처의 EL 텍스트 찾기 best_el = 43.0 + i * 2.5 # 기본값 best_dist = 5.0 for (ex, ey, ev) in el_texts: if abs(ey - cy) < best_dist: best_dist = abs(ey - cy) best_el = ev local_x = cx - front_cx gates.append(GatePosition( index=i, center_x=local_x, elevation=best_el, actuator_radius=r, gate_width=max(r * 3, 1.5), gate_height=max(r * 3, 1.5), label=f"수문{i+1} EL.{best_el:.2f}", )) return gates def _detect_hoist_rail(self, msp, scale: float, top_el: float) -> dict | None: """상단 긴 수평선 검출 → 호이스트 레일.""" best = None for e in msp.query("LINE"): try: s = e.dxf.start en = e.dxf.end dx = abs(en.x - s.x) * scale dy = abs(en.y - s.y) * scale # 수평선 + 길이 5m 이상 if dy < 0.3 and dx > 5.0: y_el = s.y * scale # 상단 1/3 영역만 (top_el 부근) # y값의 절대 위치는 EL과 꼭 맞진 않음 → 도면 좌표계 기준으로 위쪽 1/3 if best is None or dx > best["length"]: best = {"el": top_el - 1.5, "length": dx, "y_raw": y_el} except Exception: continue return best def _finalize_params(self, params: IntakeTowerParams): """파라미터 정리 및 기본값 보완.""" # 바닥 EL은 수문 최저 EL 아래로 조정 if params.gates: min_gate_el = min(g.elevation for g in params.gates) if params.body_bottom_el > min_gate_el - 1: params.body_bottom_el = min_gate_el - 4.0 # 수문이 하나도 없으면 기본 3문 가정 if not params.gates: for i in range(3): params.gates.append(GatePosition( index=i, center_x=(i - 1) * 3.0, elevation=params.body_bottom_el + 4 + i * 2.5, actuator_radius=0.6, gate_width=2.0, gate_height=2.0, )) # 호이스트 레일 EL이 상단과 불일치하면 상단 - 2m로 조정 if (params.has_hoist and (params.hoist_rail_el > params.body_top_el or params.hoist_rail_el < params.body_bottom_el)): params.hoist_rail_el = params.body_top_el - 2.0 # 편의 함수 def parse_intake_tower(dxf_paths: list[str]) -> IntakeTowerParams: return IntakeTowerParser().parse(dxf_paths) if __name__ == "__main__": import sys paths = sys.argv[1:] if len(sys.argv) > 1 else [ "SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(1/2).dxf", "SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(2/2).dxf", ] params = parse_intake_tower(paths) print(params.summary()) print() print("상세 수문 정보:") for g in params.gates: print(f" {g.label} @ X={g.center_x:+.1f}m, R={g.actuator_radius:.2f}m") print(f"\n바닥 EL 목록: {params.floor_elevations}") if params.has_hoist: print(f"호이스트 레일: EL.{params.hoist_rail_el:.1f}, 길이 {params.hoist_rail_length:.1f}m")