Files
s-canvas/intake_tower_parser.py
HYUNJUNGLEE b9342f6726 Import S-CANVAS source + iter=1~7 lint cleanup
S-CANVAS (Saman Corp.) — DXF + DEM + AI 기반 3D 조감도 생성 엔진.
~24k LOC Python (scanvas_maker.py 7072 LOC GUI + 구조물 파서/빌더 다수).

이 커밋은 7-iter cleanup이 적용된 상태로 import:
- F821 8 + B023 6: 비동기 lambda + except/loop 변수 캡처 NameError
  (Py3.13에서 reproduce 확인된 진짜 버그)
- RUF012 4 + RUF013 1: ClassVar / implicit Optional 명시화
- F811/B905/B904/F401/F841/W293/F541/UP/SIM/RUF/PLR 700+ cleanup/modernization

신규 파일:
- ruff.toml: target=py313, Korean unicode/저자 스타일/도메인 복잡도 무력화
- requirements-py313.txt: pyproj>=3.7, scipy>=1.14, numpy>=2.0.2 (Py3.13 wheel)
- .gitignore: gcp-key.json, 캐시, 백업, 생성 이미지 제외

검증: ruff 0 errors, py_compile 0 errors, import 33/33 OK on Py3.13.13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 10:29:08 +09:00

353 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""취수탑 (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 신설 취수탑 설비 설치도(12).dxf",
"SAMPLE_CAD/12996710-M40-002 신설 취수탑 설비 설치도(22).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")