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>
575 lines
20 KiB
Python
575 lines
20 KiB
Python
"""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)")
|