Add source code, design assets, and CAD samples

This commit is contained in:
2026-05-07 20:30:34 +09:00
parent 720858c7ae
commit 184185c635
49 changed files with 3407636 additions and 0 deletions

361
intake_tower_parser.py Normal file
View File

@@ -0,0 +1,361 @@
"""취수탑 (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
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
# ---------------------------------------------------------------------------
# 데이터 클래스
# ---------------------------------------------------------------------------
@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, max(els))
params.body_bottom_el = min(params.body_bottom_el, min(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) -> Optional[ViewRegion]:
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 정렬 여부 확인
xs = [c[0] for c in main_group]
ys = [c[1] for c in main_group]
x_var = max(xs) - min(xs)
y_var = max(ys) - min(ys)
# 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) -> Optional[dict]:
"""상단 긴 수평선 검출 → 호이스트 레일."""
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:
if 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
from pathlib import Path
paths = sys.argv[1:] if len(sys.argv) > 1 else [
"SAMPLE_CAD/12996710-M40-001 신설 취수탑 설비 설치도(12).dxf",
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(22).dxf",
]
params = parse_intake_tower(paths)
print(params.summary())
print()
print(f"상세 수문 정보:")
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")