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

574
dxf_geometry.py Normal file
View File

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