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>
This commit is contained in:
352
intake_tower_parser.py
Normal file
352
intake_tower_parser.py
Normal file
@@ -0,0 +1,352 @@
|
||||
"""취수탑 (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")
|
||||
Reference in New Issue
Block a user