"""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