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>
227 lines
7.5 KiB
Python
227 lines
7.5 KiB
Python
"""옹벽 (Retaining Wall) 전용 DXF 파서.
|
||
|
||
구조 특성:
|
||
- 선형 옹벽 경로 (긴 길이방향)
|
||
- 구간별로 다른 높이 (지형에 따라 변화)
|
||
- 배면(뒤쪽) 앵커바 격자 배치
|
||
- 상단 안전난간/파라펫
|
||
- 기초부 (base slab, 넓음)
|
||
- 수축이음 (균등 간격)
|
||
- 배수공 (weep hole)
|
||
|
||
사용법:
|
||
p = parse_retaining_wall(dxf_paths)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import math
|
||
from dataclasses import dataclass, field
|
||
|
||
import ezdxf
|
||
|
||
from view_detector import detect_view_regions
|
||
from dxf_geometry import extract_structural_geometry
|
||
from view_reconstructor import compute_oriented_bbox
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 데이터 클래스
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class WallSection:
|
||
"""옹벽 구간 하나."""
|
||
start_station: float = 0.0 # 시점 측점 (m)
|
||
end_station: float = 0.0 # 종점 측점
|
||
top_el: float = 0.0 # 상단 EL
|
||
bottom_el: float = 0.0 # 바닥 EL
|
||
# 단면 치수
|
||
top_width: float = 0.5
|
||
bottom_width: float = 2.5
|
||
front_batter: float = 0.02 # 전면 경사 (1:N 비율 중 1 부분)
|
||
|
||
|
||
@dataclass
|
||
class RetainingWallParams:
|
||
"""옹벽 파라미터."""
|
||
# 전체 선형
|
||
total_length: float = 100.0 # 총 연장 (m)
|
||
path_direction: str = "X" # "X" 또는 "Y" (주 방향)
|
||
|
||
# 높이 범위
|
||
top_el: float = 60.0
|
||
bottom_el: float = 41.5 # 기초 바닥
|
||
|
||
# 단면 (평균)
|
||
avg_top_width: float = 0.6 # 상단 폭
|
||
avg_bottom_width: float = 3.0 # 하단 폭 (중간)
|
||
base_slab_width: float = 5.0 # 기초 slab 폭
|
||
base_slab_thickness: float = 1.0 # 기초 두께
|
||
front_batter_ratio: float = 0.05 # 전면 경사 (1:20 수준)
|
||
|
||
# 구간별 (상세 있을 경우)
|
||
sections: list = field(default_factory=list)
|
||
|
||
# 앵커바
|
||
has_anchors: bool = True
|
||
anchor_count: int = 78
|
||
anchor_spacing_h: float = 3.0 # 수평 간격
|
||
anchor_spacing_v: float = 3.0 # 수직 간격
|
||
anchor_diameter: float = 0.05 # 앵커바 직경 (50mm)
|
||
anchor_length: float = 12.0 # 앵커 매입 길이
|
||
anchor_angle_deg: float = 15 # 하향 각도
|
||
|
||
# 상단 안전난간
|
||
has_parapet: bool = True
|
||
parapet_height: float = 1.1
|
||
parapet_thickness: float = 0.15
|
||
|
||
# 수축이음
|
||
has_contraction_joints: bool = True
|
||
joint_spacing: float = 10.0 # 간격
|
||
|
||
# 배수공
|
||
has_weep_holes: bool = True
|
||
weep_hole_spacing: float = 3.0
|
||
weep_hole_diameter: float = 0.1
|
||
|
||
# 기타
|
||
ground_level: float = 41.5 # 전면 지반 EL
|
||
|
||
source_files: list = field(default_factory=list)
|
||
raw_annotations: list = field(default_factory=list)
|
||
|
||
def total_height(self) -> float:
|
||
return self.top_el - self.bottom_el
|
||
|
||
def summary(self) -> str:
|
||
return (
|
||
f"Retaining Wall: L={self.total_length:.1f}m, "
|
||
f"EL.{self.bottom_el:.1f}~{self.top_el:.1f} (H={self.total_height():.1f}m)\n"
|
||
f" 단면 상단={self.avg_top_width:.2f}m, 하단={self.avg_bottom_width:.2f}m, "
|
||
f"기초 slab {self.base_slab_width:.1f}×{self.base_slab_thickness:.1f}m\n"
|
||
f" 앵커 {self.anchor_count}개 ({self.anchor_spacing_h:.1f}×{self.anchor_spacing_v:.1f}m), "
|
||
f"난간 {'O' if self.has_parapet else 'X'}"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 파서
|
||
# ---------------------------------------------------------------------------
|
||
|
||
EL_PATTERN = re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE)
|
||
|
||
|
||
class RetainingWallParser:
|
||
|
||
def parse(self, dxf_paths: list[str]) -> RetainingWallParams:
|
||
params = RetainingWallParams()
|
||
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: RetainingWallParams):
|
||
doc = ezdxf.readfile(path)
|
||
msp = doc.modelspace()
|
||
geom = extract_structural_geometry(path)
|
||
scale = geom.unit_scale
|
||
|
||
views = detect_view_regions(path)
|
||
|
||
# EL 수집
|
||
els = []
|
||
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
|
||
els.append((pos.x * scale, pos.y * scale, float(m.group(1))))
|
||
except Exception:
|
||
pass
|
||
|
||
if els:
|
||
ev = [v for _, _, v in els]
|
||
params.top_el = max(params.top_el, *ev)
|
||
params.bottom_el = min(params.bottom_el, *ev)
|
||
params.raw_annotations.extend(
|
||
[(f"EL.{v:.2f}", x, y) for x, y, v in els]
|
||
)
|
||
|
||
# 평면도에서 총 연장 추정
|
||
plan_view = None
|
||
for v in views:
|
||
if v.view_type == "plan":
|
||
plan_view = v
|
||
break
|
||
|
||
if plan_view:
|
||
# 평면도 안의 shapes 점들 수집 → PCA로 길이 추정
|
||
local_shapes = plan_view.get_local_shapes()
|
||
all_pts = []
|
||
for s in local_shapes:
|
||
all_pts.extend(s.points)
|
||
|
||
if len(all_pts) >= 5:
|
||
obb = compute_oriented_bbox(all_pts)
|
||
if obb and obb["aspect_ratio"] >= 1.5:
|
||
params.total_length = max(params.total_length, obb["length"])
|
||
params.path_direction = "X" if abs(obb["axis_long"][0]) > abs(obb["axis_long"][1]) else "Y"
|
||
|
||
# 앵커바 개수 추정 (B_Dot 블록 사용)
|
||
anchor_block_count = 0
|
||
for e in msp.query("INSERT"):
|
||
name = getattr(e.dxf, "name", "")
|
||
if "B_Dot" in name or "anchor" in name.lower():
|
||
anchor_block_count += 1
|
||
if anchor_block_count > 10:
|
||
params.anchor_count = anchor_block_count
|
||
|
||
# 정면도 영역에서 앵커 격자 확인
|
||
front_view = None
|
||
for v in views:
|
||
if v.view_type == "front":
|
||
front_view = v
|
||
break
|
||
if front_view:
|
||
# 정면도 면적 / 앵커개수로 간격 추정
|
||
area = front_view.width * front_view.height
|
||
if params.anchor_count > 0:
|
||
spacing_approx = math.sqrt(area / params.anchor_count)
|
||
if 1.0 <= spacing_approx <= 6.0:
|
||
params.anchor_spacing_h = spacing_approx
|
||
params.anchor_spacing_v = spacing_approx
|
||
|
||
def _finalize(self, p: RetainingWallParams):
|
||
# 기초 slab 폭: 본체 하단 폭의 1.5배 정도
|
||
p.base_slab_width = max(p.avg_bottom_width * 1.5, 4.0)
|
||
# 높이가 너무 크면 하단 폭 증가
|
||
H = p.total_height()
|
||
if H > 10:
|
||
p.avg_bottom_width = max(p.avg_bottom_width, H * 0.15)
|
||
p.base_slab_width = max(p.base_slab_width, p.avg_bottom_width * 1.4)
|
||
|
||
|
||
def parse_retaining_wall(paths: list[str]) -> RetainingWallParams:
|
||
return RetainingWallParser().parse(paths)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
paths = sys.argv[1:] if len(sys.argv) > 1 else [
|
||
"SAMPLE_CAD/1. 좌안옹벽 일반도 작성(2026.0109).dxf",
|
||
]
|
||
p = parse_retaining_wall(paths)
|
||
print(p.summary())
|