Files
s-canvas/dxf_geometry.py
HYUNJUNGLEE b9342f6726 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>
2026-05-08 10:29:08 +09:00

575 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 여수로 수문 설치도(12).dxf"),
str(base / "12995740-M40-002 여수로 수문 설치도(22).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)")