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>
552 lines
19 KiB
Python
552 lines
19 KiB
Python
"""S-CANVAS 구조물 상세도면 DXF 치수 파서.
|
||
|
||
상세도면(단면도/상세도)에서 TEXT, MTEXT, DIMENSION, ATTRIB 엔티티를 분석하여
|
||
구조물의 높이, 폭, 두께, 계획고, 관경, 사면경사 등 설계 치수를 자동 추출한다.
|
||
|
||
사용법:
|
||
parser = DetailParser()
|
||
result = parser.parse(dxf_path)
|
||
# result.dimensions → [ParsedDimension(...), ...]
|
||
# result.summary() → {"height": 5.0, "width": 3.0, ...}
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from dataclasses import dataclass, field
|
||
from pathlib import Path
|
||
|
||
import ezdxf
|
||
import numpy as np
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 데이터 클래스
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@dataclass
|
||
class ParsedDimension:
|
||
"""파싱된 개별 치수 항목."""
|
||
param: str # height, width, thickness, elevation, diameter, slope, length, ...
|
||
value: float # 수치 값 (미터 단위로 정규화)
|
||
raw_text: str # 원본 텍스트
|
||
source: str # "text" | "mtext" | "dimension" | "attrib"
|
||
layer: str # DXF 레이어명
|
||
position: tuple # (x, y) 좌표
|
||
confidence: float # 0.0 ~ 1.0 신뢰도
|
||
unit: str = "m" # 단위 (m, mm)
|
||
secondary: float | None = None # 보조값 (slope의 경우 비율 등)
|
||
|
||
|
||
@dataclass
|
||
class ParseResult:
|
||
"""전체 파싱 결과."""
|
||
dxf_path: str
|
||
dimensions: list[ParsedDimension] = field(default_factory=list)
|
||
layer_names: list[str] = field(default_factory=list)
|
||
entity_summary: dict = field(default_factory=dict) # {entity_type: count}
|
||
|
||
def summary(self) -> dict:
|
||
"""파라미터별 최고 신뢰도 값을 딕셔너리로 반환.
|
||
|
||
Returns:
|
||
{"height": 5.0, "width": 3.0, "elevation": 85.0, ...}
|
||
"""
|
||
best: dict[str, ParsedDimension] = {}
|
||
for d in self.dimensions:
|
||
if d.param not in best or d.confidence > best[d.param].confidence:
|
||
best[d.param] = d
|
||
return {k: v.value for k, v in best.items()}
|
||
|
||
def by_param(self, param: str) -> list[ParsedDimension]:
|
||
"""특정 파라미터의 모든 파싱 결과를 신뢰도 내림차순으로 반환."""
|
||
return sorted(
|
||
[d for d in self.dimensions if d.param == param],
|
||
key=lambda d: -d.confidence,
|
||
)
|
||
|
||
def all_params(self) -> list[str]:
|
||
"""발견된 모든 파라미터 종류."""
|
||
return sorted(set(d.param for d in self.dimensions))
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 패턴 정의
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# AutoCAD 특수문자: %%C = Φ (파이), %%D = ° (도), %%P = ± (플마)
|
||
_AUTOCAD_PHI = r"%%[cC]"
|
||
|
||
# 토목 도면 치수 패턴 (한글/영문 혼용)
|
||
_PATTERNS: list[tuple[str, str, re.Pattern, float]] = [
|
||
# (param, description, compiled_pattern, base_confidence)
|
||
|
||
# --- 계획고 (elevation) ---
|
||
("elevation", "EL.xxx.xx",
|
||
re.compile(r"EL\.?\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||
|
||
("elevation", "표고 xxx.x",
|
||
re.compile(r"(?:표고|계획고|설계고)\s*[=:]?\s*(\d+\.?\d*)", re.IGNORECASE), 0.90),
|
||
|
||
# --- 높이 (height) ---
|
||
("height", "H=xxx",
|
||
re.compile(r"[Hh]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
("height", "높이=xxx",
|
||
re.compile(r"(?:높이|전고|벽고|壁高)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
("height", "h xxx.x m",
|
||
re.compile(r"(?:^|\s)[Hh]\s+(\d+\.?\d*)\s*[mM](?:\s|$)"), 0.75),
|
||
|
||
# --- 폭 (width) ---
|
||
("width", "W=xxx 또는 B=xxx",
|
||
re.compile(r"[WwBb]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
("width", "폭=xxx",
|
||
re.compile(r"(?:폭|너비|幅|底幅|천단폭)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
# --- 두께 (thickness) ---
|
||
("thickness", "T=xxx 또는 t=xxx",
|
||
re.compile(r"[Tt]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
|
||
|
||
("thickness", "두께=xxx",
|
||
re.compile(r"(?:두께|벽두께|슬래브두께)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
# --- 길이 (length) ---
|
||
("length", "L=xxx",
|
||
re.compile(r"[Ll]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.85),
|
||
|
||
("length", "길이=xxx 또는 연장=xxx",
|
||
re.compile(r"(?:길이|연장|延長|총연장)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
|
||
# --- 관경/직경 (diameter) ---
|
||
("diameter", "%%Cxxx (AutoCAD Φ기호)",
|
||
re.compile(r"%%[cC]\s*(\d+\.?\d*)"), 0.95),
|
||
|
||
("diameter", "D=xxx 또는 Φxxx",
|
||
re.compile(r"(?:[Dd]|[ΦφΦ])\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM)?"), 0.85),
|
||
|
||
("diameter", "관경=xxx",
|
||
re.compile(r"(?:관경|내경|외경|직경)\s*[=:]?\s*(\d+\.?\d*)\s*(?:mm|MM|[mM])?"), 0.90),
|
||
|
||
# --- 사면 경사 (slope) ---
|
||
("slope", "1:N.N",
|
||
re.compile(r"(\d+)\s*:\s*(\d+\.?\d*)"), 0.90),
|
||
|
||
# --- 박스/U형 단면 (composite: width × height) ---
|
||
("_box", "BOX W×H",
|
||
re.compile(r"BOX\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||
|
||
("_uchannel", "U W×H",
|
||
re.compile(r"U\s*(\d+\.?\d*)\s*[xX×]\s*(\d+\.?\d*)", re.IGNORECASE), 0.95),
|
||
|
||
# --- 반경 (radius) ---
|
||
("radius", "R=xxx",
|
||
re.compile(r"[Rr]\s*[=:]\s*(\d+\.?\d*)\s*[mM]?"), 0.80),
|
||
|
||
# --- 간격 (spacing) ---
|
||
("spacing", "@xxx",
|
||
re.compile(r"@\s*(\d+\.?\d*)"), 0.80),
|
||
|
||
# --- 근입깊이 (embedment depth) ---
|
||
("embedment", "근입=xxx",
|
||
re.compile(r"(?:근입|근입깊이|매입깊이)\s*[=:]?\s*(\d+\.?\d*)\s*[mM]?"), 0.90),
|
||
]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 메인 파서
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DetailParser:
|
||
"""구조물 상세도면 DXF에서 설계 치수를 추출하는 파서."""
|
||
|
||
def __init__(self, unit_threshold_mm: float = 50.0):
|
||
"""
|
||
Args:
|
||
unit_threshold_mm: 이 값보다 큰 수치는 mm 단위로 간주하여 m로 변환.
|
||
관경(%%C300 등)은 별도 처리.
|
||
"""
|
||
self.unit_threshold = unit_threshold_mm
|
||
|
||
def parse(self, dxf_path: str | Path) -> ParseResult:
|
||
"""DXF 파일에서 치수를 파싱.
|
||
|
||
Args:
|
||
dxf_path: DXF 파일 경로
|
||
|
||
Returns:
|
||
ParseResult 객체
|
||
"""
|
||
dxf_path = Path(dxf_path)
|
||
doc = ezdxf.readfile(str(dxf_path))
|
||
msp = doc.modelspace()
|
||
|
||
result = ParseResult(dxf_path=str(dxf_path))
|
||
result.layer_names = [layer.dxf.name for layer in doc.layers]
|
||
|
||
# 엔티티 요약 수집
|
||
for entity in msp:
|
||
et = entity.dxftype()
|
||
result.entity_summary[et] = result.entity_summary.get(et, 0) + 1
|
||
|
||
# 1) TEXT / MTEXT 파싱
|
||
self._parse_text_entities(msp, result)
|
||
|
||
# 2) DIMENSION 엔티티 파싱
|
||
self._parse_dimension_entities(msp, result)
|
||
|
||
# 3) INSERT 블록 내 ATTRIB 파싱
|
||
self._parse_attrib_entities(msp, result)
|
||
|
||
return result
|
||
|
||
# ----- TEXT / MTEXT -----
|
||
|
||
def _parse_text_entities(self, msp, result: ParseResult):
|
||
"""TEXT, MTEXT 엔티티에서 패턴 매칭으로 치수 추출."""
|
||
for entity in msp:
|
||
etype = entity.dxftype()
|
||
if etype not in ("TEXT", "MTEXT"):
|
||
continue
|
||
|
||
try:
|
||
txt = entity.dxf.text.strip() if etype == "TEXT" else (entity.text or "").strip()
|
||
except Exception:
|
||
continue
|
||
|
||
if not txt:
|
||
continue
|
||
|
||
layer = entity.dxf.layer
|
||
try:
|
||
pos = entity.dxf.insert
|
||
position = (pos.x, pos.y)
|
||
except Exception:
|
||
position = (0.0, 0.0)
|
||
|
||
source = etype.lower()
|
||
self._match_patterns(txt, source, layer, position, result)
|
||
|
||
def _match_patterns(self, txt: str, source: str, layer: str,
|
||
position: tuple, result: ParseResult):
|
||
"""텍스트에 대해 모든 패턴을 매칭하고 결과를 추가."""
|
||
for param, _desc, pattern, base_conf in _PATTERNS:
|
||
m = pattern.search(txt)
|
||
if not m:
|
||
continue
|
||
|
||
groups = m.groups()
|
||
|
||
# --- 복합 치수 (BOX, U형) → width + height 분리 ---
|
||
if param == "_box":
|
||
w, h = float(groups[0]), float(groups[1])
|
||
w, w_unit = self._normalize_unit(w, param="width")
|
||
h, h_unit = self._normalize_unit(h, param="height")
|
||
result.dimensions.append(ParsedDimension(
|
||
param="width", value=w, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit=w_unit,
|
||
))
|
||
result.dimensions.append(ParsedDimension(
|
||
param="height", value=h, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit=h_unit,
|
||
))
|
||
return # 복합 패턴 매칭 시 개별 패턴 중복 방지
|
||
|
||
if param == "_uchannel":
|
||
w, h = float(groups[0]), float(groups[1])
|
||
w, w_unit = self._normalize_unit(w, param="width")
|
||
h, h_unit = self._normalize_unit(h, param="height")
|
||
result.dimensions.append(ParsedDimension(
|
||
param="width", value=w, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit=w_unit,
|
||
))
|
||
result.dimensions.append(ParsedDimension(
|
||
param="height", value=h, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit=h_unit,
|
||
))
|
||
return
|
||
|
||
# --- slope (1:N) → 특수 처리 ---
|
||
if param == "slope":
|
||
v1, v2 = float(groups[0]), float(groups[1])
|
||
# 사면 경사비가 아닌 좌표 구분자(218,200)와 혼동 방지
|
||
# 일반적인 경사비: 1:0.3 ~ 1:5.0
|
||
if v1 <= 2 and 0.1 <= v2 <= 10.0:
|
||
result.dimensions.append(ParsedDimension(
|
||
param="slope", value=v2, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit="ratio", secondary=v1,
|
||
))
|
||
return # slope는 항상 여기서 종료 (다른 패턴과 중복 방지)
|
||
|
||
# --- 일반 단일값 ---
|
||
value = float(groups[0])
|
||
|
||
# 관경은 항상 mm 단위
|
||
if param == "diameter":
|
||
if value > self.unit_threshold:
|
||
value /= 1000.0
|
||
unit = "mm→m"
|
||
else:
|
||
unit = "m"
|
||
else:
|
||
value, unit = self._normalize_unit(value, param)
|
||
|
||
result.dimensions.append(ParsedDimension(
|
||
param=param, value=value, raw_text=txt, source=source,
|
||
layer=layer, position=position, confidence=base_conf,
|
||
unit=unit,
|
||
))
|
||
|
||
# ----- DIMENSION 엔티티 -----
|
||
|
||
def _parse_dimension_entities(self, msp, result: ParseResult):
|
||
"""DIMENSION 엔티티에서 치수 추출.
|
||
|
||
DIMENSION 엔티티는 defpoint 좌표와 actual_measurement를 직접 제공하므로
|
||
가장 신뢰도가 높다.
|
||
"""
|
||
for entity in msp:
|
||
if entity.dxftype() != "DIMENSION":
|
||
continue
|
||
|
||
layer = entity.dxf.layer
|
||
|
||
try:
|
||
# 실제 측정값 (ezdxf가 계산)
|
||
measurement = entity.dxf.get("actual_measurement", None)
|
||
if measurement is None:
|
||
continue
|
||
|
||
measurement = float(measurement)
|
||
if measurement <= 0:
|
||
continue
|
||
|
||
# 치수 방향 판별 (수직 → 높이, 수평 → 폭)
|
||
param = self._classify_dimension_direction(entity)
|
||
|
||
# 텍스트 오버라이드 확인
|
||
text_override = entity.dxf.get("text", "")
|
||
raw_text = text_override if text_override else f"[DIM]{measurement:.3f}"
|
||
|
||
# 위치: defpoint 중간점
|
||
try:
|
||
dp1 = entity.dxf.defpoint
|
||
dp2 = entity.dxf.defpoint2 if entity.dxf.hasattr("defpoint2") else dp1
|
||
position = ((dp1.x + dp2.x) / 2, (dp1.y + dp2.y) / 2)
|
||
except Exception:
|
||
position = (0.0, 0.0)
|
||
|
||
value, unit = self._normalize_unit(measurement, param)
|
||
|
||
result.dimensions.append(ParsedDimension(
|
||
param=param, value=value, raw_text=raw_text,
|
||
source="dimension", layer=layer, position=position,
|
||
confidence=0.95, unit=unit,
|
||
))
|
||
|
||
except Exception:
|
||
continue
|
||
|
||
def _classify_dimension_direction(self, dim_entity) -> str:
|
||
"""DIMENSION 엔티티의 방향을 분석하여 파라미터 종류를 추론.
|
||
|
||
수직(Y 차이 > X 차이) → height
|
||
수평(X 차이 > Y 차이) → width
|
||
"""
|
||
try:
|
||
dp1 = dim_entity.dxf.defpoint
|
||
dp2 = dim_entity.dxf.defpoint2 if dim_entity.dxf.hasattr("defpoint2") else dp1
|
||
|
||
dx = abs(dp2.x - dp1.x)
|
||
dy = abs(dp2.y - dp1.y)
|
||
|
||
if dy > dx * 1.5:
|
||
return "height"
|
||
elif dx > dy * 1.5:
|
||
return "width"
|
||
else:
|
||
return "length" # 대각선이면 길이로 분류
|
||
except Exception:
|
||
return "length"
|
||
|
||
# ----- ATTRIB (블록 속성) -----
|
||
|
||
def _parse_attrib_entities(self, msp, result: ParseResult):
|
||
"""INSERT 블록의 ATTRIB에서 치수 추출."""
|
||
# 속성 태그명 → 파라미터 매핑
|
||
tag_map = {
|
||
"HEIGHT": "height", "H": "height", "높이": "height",
|
||
"WIDTH": "width", "W": "width", "폭": "width", "B": "width",
|
||
"THICKNESS": "thickness", "T": "thickness", "두께": "thickness",
|
||
"ELEVATION": "elevation", "EL": "elevation", "표고": "elevation",
|
||
"DIAMETER": "diameter", "DIA": "diameter", "D": "diameter", "관경": "diameter",
|
||
"LENGTH": "length", "L": "length", "길이": "length", "연장": "length",
|
||
}
|
||
|
||
for entity in msp:
|
||
if entity.dxftype() != "INSERT":
|
||
continue
|
||
|
||
try:
|
||
attribs = list(entity.attribs) if hasattr(entity, "attribs") else []
|
||
except Exception:
|
||
continue
|
||
|
||
layer = entity.dxf.layer
|
||
try:
|
||
pos = entity.dxf.insert
|
||
position = (pos.x, pos.y)
|
||
except Exception:
|
||
position = (0.0, 0.0)
|
||
|
||
for attrib in attribs:
|
||
try:
|
||
tag = attrib.dxf.tag.strip().upper()
|
||
text = attrib.dxf.text.strip()
|
||
|
||
param = tag_map.get(tag)
|
||
if not param:
|
||
continue
|
||
|
||
# 숫자 추출
|
||
m = re.search(r"(\d+\.?\d*)", text)
|
||
if not m:
|
||
continue
|
||
|
||
value = float(m.group(1))
|
||
value, unit = self._normalize_unit(value, param)
|
||
|
||
result.dimensions.append(ParsedDimension(
|
||
param=param, value=value, raw_text=f"{tag}={text}",
|
||
source="attrib", layer=layer, position=position,
|
||
confidence=0.85, unit=unit,
|
||
))
|
||
except Exception:
|
||
continue
|
||
|
||
# ----- 유틸리티 -----
|
||
|
||
def _normalize_unit(self, value: float, param: str) -> tuple[float, str]:
|
||
"""값의 단위를 판단하여 미터로 정규화.
|
||
|
||
토목 도면 관례:
|
||
- 관경: 보통 mm (300, 600, 1000 등)
|
||
- 높이/폭/두께: 보통 m (0.5 ~ 30 정도)
|
||
- 계획고(EL): 항상 m (해발 기준)
|
||
"""
|
||
if param == "elevation":
|
||
# 계획고는 항상 m 단위 (음수 가능, 큰 값 가능)
|
||
return value, "m"
|
||
|
||
if param == "diameter":
|
||
if value > self.unit_threshold:
|
||
return value / 1000.0, "mm→m"
|
||
return value, "m"
|
||
|
||
# spacing(@간격)은 보통 mm
|
||
if param == "spacing":
|
||
if value > self.unit_threshold:
|
||
return value / 1000.0, "mm→m"
|
||
return value, "m"
|
||
|
||
# 일반 치수: 50 이상이면 mm로 간주
|
||
if value > self.unit_threshold:
|
||
return value / 1000.0, "mm→m"
|
||
|
||
return value, "m"
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 공간 연결: 텍스트 치수를 인근 지오메트리에 매칭
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def associate_dimensions_to_structures(
|
||
parse_result: ParseResult,
|
||
structure_positions: dict[str, tuple[float, float]],
|
||
max_distance: float = 50.0,
|
||
) -> dict[str, list[ParsedDimension]]:
|
||
"""파싱된 치수를 인근 구조물에 공간적으로 연결.
|
||
|
||
Args:
|
||
parse_result: DetailParser.parse() 결과
|
||
structure_positions: {"구조물명": (x, y), ...} 구조물 중심 좌표
|
||
max_distance: 최대 연결 거리 (m)
|
||
|
||
Returns:
|
||
{"구조물명": [ParsedDimension, ...], ...}
|
||
"""
|
||
if not parse_result.dimensions or not structure_positions:
|
||
return {}
|
||
|
||
# 구조물 좌표 배열
|
||
names = list(structure_positions.keys())
|
||
positions = np.array([structure_positions[n] for n in names])
|
||
|
||
associated: dict[str, list[ParsedDimension]] = {n: [] for n in names}
|
||
|
||
for dim in parse_result.dimensions:
|
||
dim_pos = np.array(dim.position)
|
||
dists = np.linalg.norm(positions - dim_pos, axis=1)
|
||
min_idx = int(np.argmin(dists))
|
||
|
||
if dists[min_idx] <= max_distance:
|
||
associated[names[min_idx]].append(dim)
|
||
|
||
return associated
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 치수 → structure params 매핑
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def dimensions_to_structure_params(
|
||
dimensions: list[ParsedDimension],
|
||
) -> dict:
|
||
"""파싱된 치수 목록을 structure_v1.yaml 호환 파라미터 딕셔너리로 변환.
|
||
|
||
Returns:
|
||
{"height": 5.0, "width": 3.0, "z_offset": -2.0, ...}
|
||
"""
|
||
# 파라미터별 최고 신뢰도 값 선택
|
||
best: dict[str, ParsedDimension] = {}
|
||
for d in dimensions:
|
||
if d.param not in best or d.confidence > best[d.param].confidence:
|
||
best[d.param] = d
|
||
|
||
params: dict = {}
|
||
|
||
if "height" in best:
|
||
params["height"] = best["height"].value
|
||
|
||
if "width" in best:
|
||
params["width"] = best["width"].value
|
||
|
||
if "thickness" in best:
|
||
params["thickness"] = best["thickness"].value
|
||
|
||
if "diameter" in best:
|
||
params["diameter"] = best["diameter"].value
|
||
|
||
if "length" in best:
|
||
params["length"] = best["length"].value
|
||
|
||
if "elevation" in best:
|
||
params["elevation"] = best["elevation"].value
|
||
|
||
if "slope" in best:
|
||
params["slope_ratio"] = best["slope"].value
|
||
|
||
if "embedment" in best:
|
||
params["embedment"] = best["embedment"].value
|
||
|
||
if "radius" in best:
|
||
params["radius"] = best["radius"].value
|
||
|
||
return params
|