"""DXF 지오메트리 추출 공통 유틸리티. 모든 구조물 템플릿이 공유하는 DXF 처리 로직: 1. 단위 자동 감지 (mm vs m) 2. 주석/치수/해치 레이어 자동 필터링 3. LINE/LWPOLYLINE/POLYLINE/ARC/SPLINE/CIRCLE 통합 추출 4. 뷰 영역 자동 분할 (평면/정면/측면) 사용법: from dxf_geometry import extract_structural_geometry result = extract_structural_geometry(dxf_path) for layer_name, shapes in result.by_layer.items(): for shape in shapes: # shape.points: [(x, y), ...] (단위 정규화 완료) # shape.closed: bool # shape.kind: "polyline" | "line" | "arc" | ... ... """ from __future__ import annotations import math import re from dataclasses import dataclass, field from pathlib import Path import ezdxf import numpy as np # --------------------------------------------------------------------------- # 데이터 구조 # --------------------------------------------------------------------------- @dataclass class Shape: """추출된 단일 지오메트리 요소 (단위: m).""" kind: str # "polyline" | "line" | "arc" | "circle" layer: str points: list # [(x, y), ...] — m 단위 closed: bool = False extra: dict = field(default_factory=dict) # 부가정보 (center, radius, angles 등) @property def bbox(self) -> tuple[float, float, float, float]: """(xmin, ymin, xmax, ymax)""" if not self.points: return (0, 0, 0, 0) arr = np.array(self.points) return (float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 0].max()), float(arr[:, 1].max())) @property def centroid(self) -> tuple[float, float]: if not self.points: return (0, 0) arr = np.array(self.points) return (float(arr[:, 0].mean()), float(arr[:, 1].mean())) @property def area(self) -> float: """closed polygon 면적 (shoelace). open이면 0.""" if not self.closed or len(self.points) < 3: return 0.0 n = len(self.points) s = 0.0 for i in range(n): x1, y1 = self.points[i] x2, y2 = self.points[(i + 1) % n] s += x1 * y2 - x2 * y1 return abs(s) / 2 @property def length(self) -> float: """폴리라인 누적 길이.""" if len(self.points) < 2: return 0.0 arr = np.array(self.points) diffs = np.diff(arr, axis=0) return float(np.sum(np.linalg.norm(diffs, axis=1))) @dataclass class GeometryResult: """DXF에서 추출된 전체 지오메트리.""" dxf_path: str unit_scale: float = 1.0 # 원본 → m 변환 계수 (mm이면 0.001) detected_unit: str = "m" # "mm" | "m" shapes: list[Shape] = field(default_factory=list) # 분류별 접근 편의 속성 by_layer: dict[str, list[Shape]] = field(default_factory=dict) closed_shapes: list[Shape] = field(default_factory=list) open_shapes: list[Shape] = field(default_factory=list) # 메타 total_bounds: tuple[float, float, float, float] = (0, 0, 0, 0) raw_text_count: int = 0 dimension_count: int = 0 excluded_layers: list[str] = field(default_factory=list) def largest_closed(self) -> Shape | None: """면적이 가장 큰 closed shape 반환.""" if not self.closed_shapes: return None return max(self.closed_shapes, key=lambda s: s.area) def longest_polyline(self) -> Shape | None: """가장 긴 폴리라인 반환.""" polys = [s for s in self.shapes if s.kind == "polyline" and len(s.points) > 2] if not polys: return None return max(polys, key=lambda s: s.length) # --------------------------------------------------------------------------- # 레이어 필터링 # --------------------------------------------------------------------------- # 구조물 지오메트리로 간주하지 않는 레이어 이름 패턴 # (대소문자 무시, 부분 매칭) # 주의: MZ-HIDL(숨김선)은 도면에 따라 주 구조물이 있을 수 있어 제외 대상에서 뺌 _EXCLUDE_LAYER_PATTERNS = [ # 치수선 r"^DIM$", r"-DIM$", r"^CS-DIM", r"^MZ-DIM", r"치수선", # 해치/패턴 r"^HATCH$", r"-HATCH$", r"^CS-PATT", r"^CZ-PATT", r"^MZ-HATCH", r"^CS-HATCH", r"^PATT-", r"해치", # 텍스트 (레이어명이 명확히 TEXT 전용인 경우만) r"^CS-TEXT$", r"^CZ-TEXT$", r"^CX-BORD", r"^TEXT$", r"^문자$", r"^CZ-TEX[0-9]", r"^텍스트$", # 지시선/리더 r"^CS-LEAT$", r"^CS-LEAL$", r"^CS-LEA$", r"^지시선$", r"^LEADER$", # 표/프레임 r"^CS-TABL", # 중심선 (완전히 중심선 전용 레이어만) r"^중심선\(", r"중심선$", # 기타 주석 r"^Defpoints$", r"^심볼$", r"^SYMB$", r"^AA-", ] def is_excluded_layer(layer_name: str) -> bool: """레이어명이 주석/치수/해치 등 비구조 레이어인지 확인.""" if not layer_name: return False return any(re.search(pat, layer_name, re.IGNORECASE) for pat in _EXCLUDE_LAYER_PATTERNS) # --------------------------------------------------------------------------- # 단위 자동 감지 # --------------------------------------------------------------------------- def detect_unit_scale(doc) -> tuple[float, str]: """DXF에서 단위를 자동 감지하여 m로 변환할 scale 반환. 한국 토목도면은 $INSUNITS가 잘못 설정되는 경우가 많으므로 DIMENSION 값을 최우선 판단 기준으로 사용. 판단 순서: 1. DIMENSION 값 분포 (최우선) — 치수 값이 설계 의도를 반영 2. $INSUNITS header variable (보조 확인) 3. 전체 bbox 크기 (폴백) Returns: (scale, unit_name): 원본 × scale = m. unit_name은 "mm"/"m"/"cm" """ msp = doc.modelspace() # 1) DIMENSION 값 분포 분석 (가장 신뢰할 만한 신호) dim_values = [] for e in msp: if e.dxftype() == "DIMENSION": try: m = e.dxf.get("actual_measurement", None) if m is not None and m > 0: dim_values.append(float(m)) except Exception: pass if len(dim_values) >= 3: median_val = float(np.median(dim_values)) # 토목 구조물 치수 관례: # - m 단위: 중앙값이 보통 0.5~100 범위 # - mm 단위: 중앙값이 보통 100~100,000 범위 # - cm 단위: 중앙값이 50~10,000 범위 (드뭄) if median_val >= 100: return 0.001, "mm" elif median_val >= 1 and median_val < 100: return 1.0, "m" # 2) $INSUNITS header (DIMENSION이 없거나 모호할 때) # 0=unitless, 1=inch, 2=feet, 4=mm, 5=cm, 6=m # inch/feet는 한국 토목도면에서 거의 없음 → 무시하고 bbox로 판단 _INSUNITS_TO_SCALE = {4: (0.001, "mm"), 5: (0.01, "cm"), 6: (1.0, "m")} try: insunits = int(doc.header.get("$INSUNITS", 0)) if insunits in _INSUNITS_TO_SCALE: return _INSUNITS_TO_SCALE[insunits] except Exception: pass # 3) Bbox 크기로 추정 try: # 엔티티 bbox 직접 계산 (header extents는 부정확할 수 있음) all_x = [] all_y = [] for e in msp: if e.dxftype() in ("LWPOLYLINE", "LINE", "CIRCLE", "ARC"): try: if e.dxftype() == "LWPOLYLINE": for p in e.get_points(): all_x.append(p[0]) all_y.append(p[1]) elif e.dxftype() == "LINE": all_x.extend([e.dxf.start.x, e.dxf.end.x]) all_y.extend([e.dxf.start.y, e.dxf.end.y]) elif e.dxftype() in ("CIRCLE", "ARC"): all_x.append(e.dxf.center.x) all_y.append(e.dxf.center.y) except Exception: pass # 성능: 처음 1000개만 샘플 if len(all_x) > 1000: break if all_x and all_y: diag = math.sqrt((max(all_x) - min(all_x)) ** 2 + (max(all_y) - min(all_y)) ** 2) # 토목구조물 전체 크기: 수 m ~ 수백 m # mm이면 대각선 수천 ~ 수십만 if diag > 2000: return 0.001, "mm" elif diag < 500: return 1.0, "m" except Exception: pass # 기본값: 토목도면은 대부분 mm return 0.001, "mm" # --------------------------------------------------------------------------- # 메인 추출 함수 # --------------------------------------------------------------------------- def extract_structural_geometry( dxf_path: str | Path, exclude_layers: bool = True, include_open: bool = True, min_points: int = 2, unit_override: str | None = None, explode_blocks: bool = False, max_block_depth: int = 4, ) -> GeometryResult: """DXF에서 구조물 지오메트리를 추출. Args: dxf_path: DXF 파일 경로 exclude_layers: True면 주석/치수/해치 레이어 제외 include_open: False면 closed 지오메트리만 min_points: 최소 점 개수 unit_override: "mm" | "m" 중 하나면 자동 감지 무시 explode_blocks: True면 INSERT(블록 참조)를 virtual_entities로 재귀 확장. 평면도에 구조물이 블록으로 배치된 경우 필수. max_block_depth: 중첩 블록 재귀 최대 깊이 Returns: GeometryResult """ doc = ezdxf.readfile(str(dxf_path)) msp = doc.modelspace() # 단위 감지 if unit_override == "mm": unit_scale, unit_name = 0.001, "mm" elif unit_override == "m": unit_scale, unit_name = 1.0, "m" else: unit_scale, unit_name = detect_unit_scale(doc) result = GeometryResult( dxf_path=str(dxf_path), unit_scale=unit_scale, detected_unit=unit_name, ) excluded_set = set() def _process_entity(entity, inherited_layer: str | None = None, depth: int = 0): """단일 엔티티 처리. INSERT면 explode_blocks에 따라 재귀 확장.""" etype = entity.dxftype() # 블록 내부 엔티티의 layer가 "0"이면 INSERT의 레이어를 상속 raw_layer = getattr(entity.dxf, "layer", "") layer = inherited_layer if inherited_layer and raw_layer in ("", "0") else raw_layer # 레이어 필터 if exclude_layers and is_excluded_layer(layer): excluded_set.add(layer) return # INSERT 재귀 확장 if etype == "INSERT": if not explode_blocks or depth >= max_block_depth: return try: for sub in entity.virtual_entities(): _process_entity(sub, inherited_layer=layer, depth=depth + 1) except Exception: pass return # 엔티티별 추출 try: shape = _extract_entity(entity, etype, unit_scale) except Exception: return if shape is None: return if len(shape.points) < min_points: return if not include_open and not shape.closed: return shape.layer = layer result.shapes.append(shape) # 레이어별 집계 result.by_layer.setdefault(layer, []).append(shape) if shape.closed: result.closed_shapes.append(shape) else: result.open_shapes.append(shape) for entity in msp: _process_entity(entity) # 메타 카운트 for e in msp: et = e.dxftype() if et in ("TEXT", "MTEXT"): result.raw_text_count += 1 elif et == "DIMENSION": result.dimension_count += 1 result.excluded_layers = sorted(excluded_set) # 전체 bbox if result.shapes: all_pts = [] for s in result.shapes: all_pts.extend(s.points) arr = np.array(all_pts) result.total_bounds = ( float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 0].max()), float(arr[:, 1].max()), ) return result def _extract_entity(entity, etype: str, scale: float) -> Shape | None: """개별 DXF 엔티티 → Shape 변환.""" if etype == "LWPOLYLINE": pts = [(p[0] * scale, p[1] * scale) for p in entity.get_points()] if len(pts) < 2: return None return Shape(kind="polyline", layer="", points=pts, closed=entity.closed) elif etype == "POLYLINE": pts = [(v.dxf.location.x * scale, v.dxf.location.y * scale) for v in entity.vertices] if len(pts) < 2: return None return Shape(kind="polyline", layer="", points=pts, closed=entity.is_closed) elif etype == "LINE": s = entity.dxf.start e = entity.dxf.end return Shape( kind="line", layer="", points=[(s.x * scale, s.y * scale), (e.x * scale, e.y * scale)], closed=False, ) elif etype == "ARC": c = entity.dxf.center r = entity.dxf.radius * scale sa = math.radians(entity.dxf.start_angle) ea = math.radians(entity.dxf.end_angle) if ea < sa: ea += 2 * math.pi n = max(8, int((ea - sa) / math.radians(5))) pts = [] for i in range(n + 1): t = sa + (ea - sa) * i / n pts.append((c.x * scale + r * math.cos(t), c.y * scale + r * math.sin(t))) return Shape( kind="arc", layer="", points=pts, closed=False, extra={"center": (c.x * scale, c.y * scale), "radius": r, "start_angle": sa, "end_angle": ea}, ) elif etype == "CIRCLE": c = entity.dxf.center r = entity.dxf.radius * scale n = 32 pts = [] for i in range(n + 1): t = 2 * math.pi * i / n pts.append((c.x * scale + r * math.cos(t), c.y * scale + r * math.sin(t))) return Shape( kind="circle", layer="", points=pts, closed=True, extra={"center": (c.x * scale, c.y * scale), "radius": r}, ) elif etype == "SPLINE": try: # 제어점으로 근사 pts = [(pt[0] * scale, pt[1] * scale) for pt in entity.control_points] if len(pts) < 2: return None return Shape( kind="polyline", layer="", points=pts, closed=entity.closed, ) except Exception: return None elif etype == "ELLIPSE": # 타원 → 폴리라인 샘플링 try: c = entity.dxf.center # flattening으로 점 목록 얻기 pts = [] for pt in entity.flattening(distance=0.1): pts.append((pt.x * scale, pt.y * scale)) if len(pts) < 2: return None return Shape(kind="polyline", layer="", points=pts, closed=True) except Exception: return None return None # --------------------------------------------------------------------------- # 뷰 영역 분할 (평면/정면/측면) # --------------------------------------------------------------------------- def split_views_by_y(result: GeometryResult, n_views: int = 2) -> list[GeometryResult]: """Y 좌표 분포로 도면을 n개 뷰로 분할. 토목도면은 보통 평면(위)/정면(아래) 또는 측면/단면을 수직 배치. """ if not result.shapes or n_views < 2: return [result] # Y 좌표 집계 y_vals = [] for s in result.shapes: arr = np.array(s.points) y_vals.append(arr[:, 1].mean()) y_vals = np.array(y_vals) # 분위수로 분할 thresholds = np.quantile(y_vals, [i / n_views for i in range(1, n_views)]) views = [[] for _ in range(n_views)] for i, s in enumerate(result.shapes): bucket = n_views - 1 for ti, t in enumerate(thresholds): if y_vals[i] <= t: bucket = ti break views[bucket].append(s) # 각 뷰를 GeometryResult로 래핑 out = [] for shapes in views: v = GeometryResult( dxf_path=result.dxf_path, unit_scale=result.unit_scale, detected_unit=result.detected_unit, ) v.shapes = shapes for s in shapes: v.by_layer.setdefault(s.layer, []).append(s) if s.closed: v.closed_shapes.append(s) else: v.open_shapes.append(s) if shapes: all_pts = [] for s in shapes: all_pts.extend(s.points) arr = np.array(all_pts) v.total_bounds = ( float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 0].max()), float(arr[:, 1].max()), ) out.append(v) return out # --------------------------------------------------------------------------- # 편의 함수 # --------------------------------------------------------------------------- def extract_all(dxf_paths: list[str], **kwargs) -> GeometryResult: """여러 DXF 파일을 모두 파싱하여 단일 GeometryResult로 반환.""" combined = GeometryResult(dxf_path=";".join(dxf_paths)) first_scale = None for p in dxf_paths: try: r = extract_structural_geometry(p, **kwargs) if first_scale is None: first_scale = r.unit_scale combined.unit_scale = r.unit_scale combined.detected_unit = r.detected_unit combined.shapes.extend(r.shapes) for layer, shapes in r.by_layer.items(): combined.by_layer.setdefault(layer, []).extend(shapes) combined.closed_shapes.extend(r.closed_shapes) combined.open_shapes.extend(r.open_shapes) combined.raw_text_count += r.raw_text_count combined.dimension_count += r.dimension_count combined.excluded_layers.extend(r.excluded_layers) except Exception as e: print(f" 추출 실패 ({p}): {e}") if combined.shapes: all_pts = [] for s in combined.shapes: all_pts.extend(s.points) arr = np.array(all_pts) combined.total_bounds = ( float(arr[:, 0].min()), float(arr[:, 1].min()), float(arr[:, 0].max()), float(arr[:, 1].max()), ) return combined if __name__ == "__main__": # 샘플 테스트 import sys from pathlib import Path paths = sys.argv[1:] if not paths: base = Path("Gate_Sample") paths = [ str(base / "12995740-M40-001 여수로 수문 설치도(1/2).dxf"), str(base / "12995740-M40-002 여수로 수문 설치도(2/2).dxf"), ] for p in paths: print(f"\n=== {Path(p).name} ===") r = extract_structural_geometry(p) print(f" 단위: {r.detected_unit} (scale={r.unit_scale})") print(f" 총 지오메트리: {len(r.shapes)}개") print(f" closed: {len(r.closed_shapes)}, open: {len(r.open_shapes)}") print(" 레이어별:") for layer, shapes in sorted(r.by_layer.items(), key=lambda x: -len(x[1]))[:10]: print(f" {layer}: {len(shapes)}개") b = r.total_bounds print(f" bbox: ({b[0]:.2f}, {b[1]:.2f}) ~ ({b[2]:.2f}, {b[3]:.2f}) m") print(f" 제외된 레이어: {r.excluded_layers[:5]}") if r.largest_closed(): lc = r.largest_closed() print(f" 최대 closed: {lc.layer} ({lc.area:.2f} m², {len(lc.points)}pts)")