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:
2026-05-08 10:29:08 +09:00
parent 53d8b53c2f
commit b9342f6726
92 changed files with 3413501 additions and 0 deletions

551
detail_parser.py Normal file
View File

@@ -0,0 +1,551 @@
"""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