테스트 dxf 파일 : AC1021 - 2007/2008/2009, AC1009 - 11/12(LT R1/R2)
import ezdxf
import numpy as np
import math
import matplotlib.pyplot as plt
from typing import List, Tuple, Sequence
# ---------- 타입 정의 ----------
Point = Sequence[float] # (x, y) or (x, y, z)
Shape = List[Point] # [(x,y), (x,y), ...]
Shapes = List[Shape] # [shape1, shape2, ...]
# ---------- 보조 함수 ----------
def _xy(p: Point) -> Tuple[float, float]:
"""3D 포인트가 와도 x,y만 사용"""
return float(p[0]), float(p[1])
def _dist2(a: Point, b: Point) -> float:
"""유클리드 거리 제곱(속도 위해 제곱만 비교)"""
ax, ay = _xy(a); bx, by = _xy(b)
dx, dy = ax - bx, ay - by
return dx*dx + dy*dy
# ---------- 센터, 스케일, 회전 이용한 좌표 변환 ----------
def transform_points(pts: Shape, insert, scale=(1, 1), rotation=0.0) -> Shape:
"""scale + rotation + translation 적용"""
angle = math.radians(rotation)
cos_a, sin_a = math.cos(angle), math.sin(angle)
sx, sy = scale
tx, ty, *_ = insert
out = []
for x, y in pts:
xs, ys = x * sx, y * sy
xr = xs * cos_a - ys * sin_a
yr = xs * sin_a + ys * cos_a
out.append((xr + tx, yr + ty))
return out
from ezdxf import path as ezpath
Point = Tuple[float, float]
# -------------------- 보조: 폴리라인을 목표 세그 길이에 맞춰 재샘플 --------------------
def _resample_by_seglen(pts: List[Point], seg_len: float, closed: bool = False) -> List[Point]:
if not pts or seg_len <= 0:
return pts
# 닫힌 곡선이면 마지막 중복점 제거 후 길이계산
work = pts[:]
if closed and len(work) > 1 and work[0] == work[-1]:
work = work[:-1]
# 누적 길이 계산
dists = [0.0]
for i in range(1, len(work)):
dx = work[i][0] - work[i-1][0]
dy = work[i][1] - work[i-1][1]
dists.append(dists[-1] + math.hypot(dx, dy))
total = dists[-1]
if total == 0:
# 모든 점이 동일
out = [work[0]]
if closed:
out.append(out[0])
return out
# 목표 샘플 위치들 (0, seg_len, 2*seg_len, ..., total)
nseg = max(1, int(math.ceil(total / seg_len)))
targets = [i * (total / nseg) for i in range(nseg + 1)] # 끝점 포함
# 이진탐색/선형보간으로 점 생성
out: List[Point] = []
j = 0
for t in targets:
while j+1 < len(dists) and dists[j+1] < t:
j += 1
if j+1 >= len(dists):
out.append(work[-1])
else:
# [j, j+1] 구간에서 보간
t0, t1 = dists[j], dists[j+1]
if t1 == t0:
out.append(work[j])
else:
s = (t - t0) / (t1 - t0)
x = work[j][0] + s * (work[j+1][0] - work[j][0])
y = work[j][1] + s * (work[j+1][1] - work[j][1])
out.append((x, y))
if closed:
if out[0] != out[-1]:
out.append(out[0])
return out
# 벌지에서 arc 만들기
def _arc_points_from_bulge(p0: Point, p1: Point, bulge: float, seg_len: float) -> List[Point]:
"""
bulge(=tan(θ/4))로 정의된 p0→p1 호를 seg_len에 맞게 선분으로 샘플.
bulge>0: CCW, bulge<0: CW
"""
x0, y0 = p0; x1, y1 = p1
dx, dy = x1 - x0, y1 - y0
L = math.hypot(dx, dy)
if L == 0 or abs(bulge) < 1e-12:
return [p0, p1]
theta = 4.0 * math.atan(bulge) # 중앙각(부호 포함)
R = (L / 2.0) / math.sin(abs(theta) / 2.0) # 반지름(양수)
# chord 단위 벡터 & 좌측 법선
ux, uy = dx / L, dy / L
nx, ny = -uy, ux
sgn = 1.0 if bulge > 0 else -1.0
# chord 중점 → 중심
mx, my = (x0 + x1) * 0.5, (y0 + y1) * 0.5
d = R * math.cos(theta / 2.0) # 중심까지 거리(부호 포함)
cx = mx + sgn * d * nx
cy = my + sgn * d * ny
# 시작/끝 각도
a0 = math.atan2(y0 - cy, x0 - cx)
a1 = math.atan2(y1 - cy, x1 - cx)
# 진행방향 보정
if theta > 0:
while a1 <= a0: a1 += 2 * math.pi # CCW
else:
while a1 >= a0: a1 -= 2 * math.pi # CW
sweep = a1 - a0 # 부호 포함
arc_len = abs(R * sweep)
nseg = max(1, int(math.ceil(arc_len / max(seg_len, 1e-9))))
angs = np.linspace(a0, a1, nseg)
pts = [(cx + R * math.cos(t), cy + R * math.sin(t)) for t in angs]
pts.append((x1, y1)) # 끝점 포함
return pts
# bulge가 있는지 검색하여 arc로 만드는 것과 선으로 나누는 것을 분류
def _poly_vertices_to_xy(points_bulge: List[Tuple[float, float, float]], closed: bool, seg_len: float) -> List[Point]:
"""
(x,y,bulge) 시퀀스를 직선/호로 풀어 선분 점열로 반환.
bulge=0 → 직선, ≠0 → _arc_points_from_bulge
"""
out: List[Point] = []
n = len(points_bulge)
if n == 0:
return out
def add_pts(seg_pts: List[Point]):
nonlocal out
if not seg_pts:
return
if not out:
out.extend(seg_pts)
else:
# 중복 첫점 제거
if out[-1] == seg_pts[0]:
out.extend(seg_pts[1:])
else:
out.extend(seg_pts)
last = n if closed else n - 1
for i in range(last):
x0, y0, b0 = points_bulge[i]
j = (i + 1) % n
x1, y1, _ = points_bulge[j]
if abs(b0) < 1e-12:
add_pts([(x0, y0), (x1, y1)])
else:
add_pts(_arc_points_from_bulge((x0, y0), (x1, y1), b0, seg_len))
return out
# 타원을 선분으로 만들기
def _ellipse_points(center: Point,
major_axis: Tuple[float, float],
ratio: float,
start_param: float,
end_param: float,
ccw: bool,
seg_len: float) -> List[Point]:
"""
타원 파라메트릭:
P(t) = C + R(φ) @ [ a*cos t, b*sin t ]
- major_axis = (ax, ay) (길이 = a)
- ratio = b/a (0<ratio<=1)
- t 범위: start_param → end_param (ccw 여부 고려)
세그 길이 기반으로 분할 개수 결정.
"""
cx, cy = center
ax, ay = major_axis
a = math.hypot(ax, ay)
b = a * float(ratio)
if a <= 0 or b <= 0:
return [(cx, cy)]
# 타원 회전각
phi = math.atan2(ay, ax)
cosp, sinp = math.cos(phi), math.sin(phi)
# t 진행 방향 정리
t0 = float(start_param)
t1 = float(end_param)
if ccw:
while t1 <= t0:
t1 += 2*math.pi
else:
while t1 >= t0:
t1 -= 2*math.pi
sweep = t1 - t0 # 부호 포함
# 타원 둘레 근사(라마누잔)로 전체 길이 추정 → 분할 개수
# 부분호 길이는 sweep 비율로 단순 근사
perim = math.pi * (3*(a+b) - math.sqrt((3*a+b)*(a+3*b)))
arc_len_est = abs(sweep) / (2*math.pi) * perim
nseg = max(3, int(math.ceil(arc_len_est / max(seg_len, 1e-9))))
ts = np.linspace(t0, t1, nseg + 1)
pts = []
for t in ts:
x = cx + (a*math.cos(t))*cosp - (b*math.sin(t))*sinp
y = cy + (a*math.cos(t))*sinp + (b*math.sin(t))*cosp
pts.append((x, y))
return pts
# -------------------- 엔티티 → 점 리스트 (seg_len 기준) --------------------
# 곡선의 경우 정해진 길이에 맞춰 분할
# 폴리라인에서 벌지가 있을 경우, arc, circle, 타원은 선분으로 구성
# 해치라인의 외곽 인식
def entity_to_xy(e, seg_len: float = 0.2) -> List[Tuple[float, float]]:
"""
DXF 엔티티를 (x,y) 점 리스트로 변환.
- ARC/CIRCLE/SPLINE 은 'seg_len'에 가까운 선 길이로 분해
- LWPOLYLINE/POLYLINE 은 기존 로직 유지(필요시 여기에도 재샘플 호출 가능)
"""
t = e.dxftype()
pts: List[Point] = []
# LINE (그대로)
if t == "LINE":
return [(e.dxf.start.x, e.dxf.start.y), (e.dxf.end.x, e.dxf.end.y)]
# LWPOLYLINE (rounded rectangle 포함)
elif t == "LWPOLYLINE":
ptsb: List[Tuple[float, float, float]] = []
for x, y, b in e.get_points("xyb"):
ptsb.append((float(x), float(y), float(b or 0.0)))
closed = bool(e.closed)
pts = _poly_vertices_to_xy(ptsb, closed, seg_len=seg_len)
if closed and pts and pts[0] != pts[-1]:
pts.append(pts[0])
return pts
# POLYLINE + VERTEX (구식 폴리라인의 bulge)
elif t == "POLYLINE":
# ezdxf 버전에 따라 vertices가 메서드/속성 모두 고려
verts_attr = getattr(e, "vertices", None)
verts_iter = verts_attr() if callable(verts_attr) else (verts_attr or [])
ptsb: List[Tuple[float, float, float]] = []
for v in verts_iter:
if hasattr(v.dxf, "location"):
x = float(v.dxf.location.x); y = float(v.dxf.location.y)
else:
x = float(getattr(v.dxf, "x", 0.0)); y = float(getattr(v.dxf, "y", 0.0))
b = float(getattr(v.dxf, "bulge", 0.0) or 0.0)
ptsb.append((x, y, b))
closed = bool(getattr(e, "is_closed", getattr(e, "closed", False)))
pts = _poly_vertices_to_xy(ptsb, closed, seg_len=seg_len)
if closed and pts and pts[0] != pts[-1]:
pts.append(pts[0])
return pts
# ARC → 길이 기반 분해
elif t == "ARC":
c = e.dxf.center
r = float(e.dxf.radius)
a0 = math.radians(float(e.dxf.start_angle))
a1 = math.radians(float(e.dxf.end_angle))
if a1 < a0:
a1 += 2 * math.pi
sweep = a1 - a0
arc_len = abs(sweep) * r
nseg = max(1, int(math.ceil(arc_len / max(seg_len, 1e-9))))
ang = np.linspace(a0, a1, nseg + 1)
pts = [(c.x + r * math.cos(t), c.y + r * math.sin(t)) for t in ang]
return pts
# CIRCLE → 길이 기반 분해(닫힘)
elif t == "CIRCLE":
c = e.dxf.center
r = float(e.dxf.radius)
circ = 2 * math.pi * r
nseg = max(3, int(math.ceil(circ / max(seg_len, 1e-9)))) # 최소 삼각형
ang = np.linspace(0, 2 * math.pi, nseg + 1)
pts = [(c.x + r * math.cos(t), c.y + r * math.sin(t)) for t in ang]
if pts[0] != pts[-1]:
pts.append(pts[0])
return pts
elif t == "ELLIPSE":
c = e.dxf.center
maj = e.dxf.major_axis # Vec3
ratio = float(e.dxf.ratio) # b/a
sp = float(getattr(e.dxf, "start_param", 0.0))
ep = float(getattr(e.dxf, "end_param", 2*math.pi))
is_ccw = bool(getattr(e.dxf, "ccw", True)) # ezdxf는 보통 ccw 기준
return _ellipse_points(
(float(c.x), float(c.y)),
(float(maj.x), float(maj.y)),
ratio, sp, ep, is_ccw, seg_len
)
# SPLINE → 먼저 촘촘 평탄화 → 균등 길이 재샘플
elif t == "SPLINE":
dense: List[Point] = []
# 1) path 기반 평탄화(가장 견고)
try:
path = ezpath.make_path(e)
for sub in path.flattening(distance=seg_len/4):
for p in sub:
dense.append((p.x, p.y) if hasattr(p, "x") else (p[0], p[1]))
except Exception:
dense = []
# 2) 엔티티 직접 평탄화
if not dense:
try:
for p in e.flattening(distance=seg_len/4):
dense.append((p.x, p.y) if hasattr(p, "x") else (p[0], p[1]))
except Exception:
dense = []
# 3) approximate(n) 폴백
if not dense:
try:
n = max(64, int(math.ceil(4.0 / max(seg_len, 1e-6)))) # 대충 충분히 촘촘하게
for p in e.approximate(n):
dense.append((p.x, p.y) if hasattr(p, "x") else (p[0], p[1]))
except Exception:
dense = []
# 4) 그래도 없으면 fit_points 폴백
if not dense:
try:
fp = list(getattr(e, "fit_points", []) or [])
dense = [(p.x, p.y) if hasattr(p, "x") else (p[0], p[1]) for p in fp]
except Exception:
dense = []
if not dense:
return []
closed = bool(getattr(e, "closed", False))
if closed and dense[0] != dense[-1]:
dense.append(dense[0])
# 목표 세그 길이로 재샘플
return _resample_by_seglen(dense, seg_len, closed=closed)
# 기타 엔티티는 스킵
return pts
# HATCH 경계의 edge(Line/Arc/Ellipse/Spline)를 점열로 변환
def _hatch_path_edge_points(edge, seg_len: float) -> List[Point]:
etype = getattr(edge, "type", None) # 또는 isinstance 체크 (버전별 다름)
# LineEdge: .start, .end (Vec2/3)
if etype == "LineEdge" or hasattr(edge, "start") and hasattr(edge, "end"):
s = edge.start; e = edge.end
return [ (float(s.x), float(s.y)), (float(e.x), float(e.y)) ]
# ArcEdge: .center, .radius, .start_angle, .end_angle, .ccw
if etype == "ArcEdge" or hasattr(edge, "radius"):
c = edge.center
r = float(edge.radius)
a0 = math.radians(float(edge.start_angle))
a1 = math.radians(float(edge.end_angle))
ccw = bool(getattr(edge, "ccw", True))
# 진행방향 보정
if ccw:
while a1 <= a0: a1 += 2*math.pi
else:
while a1 >= a0: a1 -= 2*math.pi
sweep = a1 - a0
arc_len = abs(r * sweep)
nseg = max(1, int(math.ceil(arc_len / max(seg_len, 1e-9))))
ang = np.linspace(a0, a1, nseg + 1)
return [ (float(c.x) + r*math.cos(t), float(c.y) + r*math.sin(t)) for t in ang ]
# EllipseEdge: .center, .major_axis, .ratio, .start_angle, .end_angle, .ccw
if etype == "EllipseEdge" or hasattr(edge, "ratio"):
c = edge.center
maj = edge.major_axis
ratio = float(edge.ratio)
a0 = math.radians(float(edge.start_angle))
a1 = math.radians(float(edge.end_angle))
ccw = bool(getattr(edge, "ccw", True))
return _ellipse_points(
(float(c.x), float(c.y)),
(float(maj.x), float(maj.y)),
ratio, a0, a1, ccw, seg_len
)
# SplineEdge: .control_points / .fit_points 등 (버전별 차이)
if etype == "SplineEdge" or hasattr(edge, "control_points") or hasattr(edge, "fit_points"):
dense: List[Point] = []
# 1) fit_points 우선
fp = list(getattr(edge, "fit_points", []) or [])
if fp:
dense = [(float(p.x), float(p.y)) for p in fp]
else:
# 2) control_points 기반 근사
cps = list(getattr(edge, "control_points", []) or [])
if cps:
try:
from ezdxf.math import BSpline, Vec3
deg = int(getattr(edge, "degree", 3))
bs = BSpline([Vec3(p) for p in cps], order=deg+1)
n = max(64, int(math.ceil(4.0 / max(seg_len, 1e-6))))
approx = bs.approximate(n)
dense = [(float(p.x), float(p.y)) for p in approx]
except Exception:
pass
if not dense:
return []
# 균등 길이 재샘플(선택) — 간단 버전
# 길이 누적
lens = [0.0]
for i in range(1, len(dense)):
dx = dense[i][0]-dense[i-1][0]; dy = dense[i][1]-dense[i-1][1]
lens.append(lens[-1] + math.hypot(dx, dy))
total = lens[-1]
if total <= 0:
return [dense[0]]
nseg = max(1, int(math.ceil(total / max(seg_len, 1e-9))))
targets = [i*(total/nseg) for i in range(nseg+1)]
out: List[Point] = []
j = 0
for tlen in targets:
while j+1 < len(lens) and lens[j+1] < tlen:
j += 1
if j+1 >= len(lens):
out.append(dense[-1]); break
t0, t1 = lens[j], lens[j+1]
if t1 == t0:
out.append(dense[j])
else:
s = (tlen - t0)/(t1 - t0)
x = dense[j][0] + s*(dense[j+1][0]-dense[j][0])
y = dense[j][1] + s*(dense[j+1][1]-dense[j][1])
out.append((x, y))
return out
# 알 수 없는 에지는 스킵
return []
def hatch_paths_to_shapes(hatch, seg_len: float = 0.2) -> List[List[Point]]:
# HATCH 엔티티의 모든 경계 경로를 선분화하여 shape 리스트로 반환.
# 각 BoundaryPath → 하나의 shape.
shapes: List[List[Point]] = []
for path in hatch.paths: # BoundaryPath
path_pts: List[Point] = []
# 1) Edge 모드 (선/호/타원/스플라인) 경계
if getattr(path, "has_edge_data", False):
for edge in path.edges:
seg = _hatch_path_edge_points(edge, seg_len)
if not seg:
continue
if not path_pts:
path_pts.extend(seg)
else:
# 중복 첫점 제거
if path_pts[-1] == seg[0]:
path_pts.extend(seg[1:])
else:
path_pts.extend(seg)
# 2) Polyline 경계 (bulge 포함 가능)
elif getattr(path, "has_polyline_data", False):
verts = getattr(path, "polyline_path", None)
if verts:
# verts = list of (x, y, bulge)
from math import isclose
def add_pts(a, b, bulge):
if abs(bulge) < 1e-12:
return [a, b]
return _arc_points_from_bulge(a, b, bulge, seg_len)
temp: List[Point] = []
n = len(verts)
for i in range(n - (0 if path.is_closed else 1)):
x0, y0, b0 = verts[i]
x1, y1, _ = verts[(i+1) % n]
seg = add_pts((x0, y0), (x1, y1), b0 or 0.0)
if not temp:
temp.extend(seg)
else:
if temp[-1] == seg[0]:
temp.extend(seg[1:])
else:
temp.extend(seg)
path_pts = temp
if path_pts:
# 닫힘 표시
if path.is_closed and path_pts[0] != path_pts[-1]:
path_pts.append(path_pts[0])
shapes.append(path_pts)
return shapes
Point = Tuple[float, float]
Shape = List[Point]
Shapes = List[Shape]
def plot_dxf(doc, seg_len: float = 0.2) -> Tuple[Shapes, Shapes]:
"""
모델공간에서:
- INSERT(블록 참조) 안의 엔티티 → block_shapes
- INSERT가 아닌 직접 엔티티 → shapes
둘 다 plt.plot으로 그려주고 (block 먼저/후는 자유),
(block_shapes, shapes) 를 반환한다.
"""
msp = doc.modelspace()
inserts = list(msp.query("INSERT"))
# 수집 컨테이너
block_shapes: Shapes = []
shapes: Shapes = []
fig, ax = plt.subplots(figsize=(8, 8))
ax.set_aspect("equal", adjustable="box")
ax.grid(True, linestyle=":", linewidth=0.5)
# 1) 모델공간의 '직접' 엔티티 먼저 수집 (INSERT 제외)
for e in msp:
dxftype = e.dxftype()
if dxftype == "HATCH":
shapes.extend(hatch_paths_to_shapes(e, seg_len))
elif dxftype in ("LINE","LWPOLYLINE","POLYLINE","ARC","CIRCLE","SPLINE","ELLIPSE"):
pts = entity_to_xy(e, seg_len)
if len(pts) >= 2:
shapes.append(pts)
# 2) INSERT(블록 참조) 처리 → block_shapes 에 수집
if inserts:
print(f"Found {len(inserts)} INSERT entities.")
for insert in inserts:
name = insert.dxf.name
ins_pt = insert.dxf.insert
xscale = getattr(insert.dxf, "xscale", 1.0)
yscale = getattr(insert.dxf, "yscale", 1.0)
rotation = getattr(insert.dxf, "rotation", 0.0)
if name not in doc.blocks:
print(f"[skip] block '{name}' not found")
continue
block = doc.blocks[name]
for e in block:
local_pts = entity_to_xy(e, seg_len)
if len(local_pts) >= 2:
world_pts = transform_points(local_pts, ins_pt, (xscale, yscale), rotation)
block_shapes.append(world_pts)
# 블록 라벨(옵션)
ax.text(ins_pt[0], ins_pt[1], name, fontsize=8)
# 3) 플롯: 원하면 레이어나 출처에 따라 스타일 다르게 줄 수 있음
# - 직접 엔티티
for pts in shapes:
xs, ys = zip(*pts)
ax.plot(xs, ys, linewidth=1.0)
# - 블록 엔티티
for pts in block_shapes:
xs, ys = zip(*pts)
ax.plot(xs, ys, linewidth=1.0)
plt.show()
# 💡요청대로: 블록에서 온 것(block_shapes), 직접 엔티티(shapes) 둘 다 반환
return block_shapes, shapes
cad
dxf_path = r"C:\py\cad\1.dxf"
doc = ezdxf.readfile(dxf_path)
block_shapes, shapes=plot_dxf(doc, 20) # 곡선을 선으로 만들 때는 20mm 최소 단위로 곡선을 직선화
Found 2 INSERT entities.
(x,y) tuple로 모아 놓으면 곡선들이 선으로 분리되면서 순서가 바뀌어 형상이 깨짐
fig, ax = plt.subplots(figsize=(8, 8))
ax.set_aspect("equal", adjustable="box")
mpt=[(x,y) for s in shapes for (x, y) in s ]
xs, ys = zip(*mpt)
ax.plot(xs, ys, linewidth=1.0)
mpt=[(x,y) for s in block_shapes for (x, y) in s ]
xs, ys = zip(*mpt)
ax.plot(xs, ys, linewidth=1.0)
plt.show()
붙어 있는 객체와 떨어진 객체를 분리해 group으로 나누고 각 group은 선의 시작과 끝이 만나도록 정렬
from typing import List, Sequence, Tuple, Optional
Point = Sequence[float] # (x,y) 또는 (x,y,z)
Shape = List[Tuple[float, float]] # [(x,y), ...]
Shapes = List[Shape]
def _xy(p: Point) -> Tuple[float, float]:
return float(p[0]), float(p[1])
def _dist2(a: Point, b: Point) -> float:
ax, ay = _xy(a); bx, by = _xy(b)
dx, dy = ax - bx, ay - by
return dx*dx + dy*dy
def _is_closed(s: Shape) -> bool:
return len(s) >= 2 and s[0] == s[-1]
def _rotate_closed_to_nearest(s: Shape, ref: Tuple[float, float]) -> Shape:
"""폐곡선 s에서 ref에 가장 가까운 꼭짓점을 시작점으로 회전."""
if not _is_closed(s) or len(s) <= 2:
return s
# 마지막 중복점 제거 후 가장 가까운 꼭짓점 찾기
core = s[:-1]
best_i, best_d2 = 0, float("inf")
for i, p in enumerate(core):
d2 = _dist2(p, ref)
if d2 < best_d2:
best_d2, best_i = d2, i
rotated = core[best_i:] + core[:best_i]
rotated.append(rotated[0]) # 다시 닫기
return rotated
def _snap_point(p: Tuple[float, float], snap: float) -> Tuple[float, float]:
x, y = p
return (round(x / snap) * snap, round(y / snap) * snap)
def reorder_shapes_grouped(
shapes: Shapes,
tol: float,
start_index: int = 0,
snap: Optional[float] = None,
rotate_closed: bool = True,
) -> List[Shapes]:
"""
- 첫 그룹: start_index 형상으로 시작해서, 현재 끝점과 tol 이내로 이어붙일 수 있는
가장 가까운 shape를 탐욕적으로 선택(+ 필요시 뒤집기/회전) → 그룹 완성.
- tol 이내 후보가 더 이상 없으면, 남은 shape들 중 '어떤 그룹의 끝점'과 가장 가까운 것을 골라
새 그룹을 시작. (모든 shape가 소진될 때까지 반복)
- 반환: 그룹들의 리스트 [ [shape1, shape2, ...], [shapeA, ...], ... ]
(각 shape는 (x,y) 튜플의 리스트)
"""
# 1) 유효/정규화 + 스냅
pool: Shapes = []
for s in shapes:
if not s:
continue
clean = [tuple(_xy(p)) for p in s]
if snap:
clean = [_snap_point(p, snap) for p in clean]
pool.append(clean)
if not pool:
return []
# 2) 시작 shape 선택
start_index = max(0, min(start_index, len(pool) - 1))
groups: List[Shapes] = []
# 아직 배치 안 된 shape 집합
unplaced = pool[:]
# 각 그룹의 '현재 끝점'들을 추적
# 그룹 단위로 진행하되, 내부에서는 탐욕적으로 tol 이내 연결
while unplaced:
# --- 새 그룹 시작 후보 선정: 기존 모든 그룹 끝점들과의 최단거리 비교로 가장 가까운 shape 고르기
if not groups:
# 첫 그룹은 사용자가 지정한 start_index로
current = unplaced.pop(start_index)
else:
# 기존 그룹들 끝점들 수집
frontier_pts: List[Tuple[float, float]] = []
for g in groups:
last_shape = g[-1]
frontier_pts.append(last_shape[-1]) # 그룹의 현재 끝점
# 남은 shape 중 any frontier에 가장 가까운 shape 선택
best_i, best_ref_idx, best_d2 = None, None, float("inf")
best_reverse = False
for i, s in enumerate(unplaced):
# 후보 s의 양 끝 비교
for fi, ref in enumerate(frontier_pts):
d2_start = _dist2(ref, s[0])
d2_end = _dist2(ref, s[-1])
if d2_start < best_d2:
best_i, best_ref_idx, best_d2, best_reverse = i, fi, d2_start, False
if d2_end < best_d2:
best_i, best_ref_idx, best_d2, best_reverse = i, fi, d2_end, True
current = unplaced.pop(best_i)
# 가까운 그룹 뒤에 이어붙일 것이므로 그 그룹을 이어갈지, 새로 시작할지 결정
# 요구사항: '연결되지 않으면 별도로 분리'이므로 tol 밖이면 새 그룹 시작
# (가까운 ref가 tol 이내면 그 그룹 뒤에 바로 붙이고, 밖이면 새 그룹으로 시작)
# 일단 current를 새 그룹 seed로 만들고, 아래에서 연결을 시도
# 새 그룹 초기화
group: Shapes = []
# 그룹 내부 탐욕 연결 루프
# 그룹의 '현재 끝점' 정의
# 폐곡선이면 필요 시 회전
if rotate_closed and _is_closed(current):
# 그룹 시작점 기준으로 회전할 ref는 "현재 그룹 끝점" 개념이 없으니 그냥 current[0] 유지
pass
group.append(current)
cur_end = current[-1]
# 그룹 내부: tol 이내로만 이어붙임
while unplaced:
# 가장 가까운 후보 찾기
best_i, best_d2, best_reverse = None, float("inf"), False
for i, s in enumerate(unplaced):
# 폐곡선이라도 시작/끝 두 점 비교 (닫힘이면 거의 동일)
d2_start = _dist2(cur_end, s[0])
d2_end = _dist2(cur_end, s[-1])
if d2_start < best_d2:
best_i, best_d2, best_reverse = i, d2_start, False
if d2_end < best_d2:
best_i, best_d2, best_reverse = i, d2_end, True
# tol 이내 없으면 그룹 종료
if best_i is None or best_d2 > tol*tol:
break
# 이어붙일 shape 꺼내기
nxt = unplaced.pop(best_i)
# 폐곡선이면 ref에 가깝도록 회전
if rotate_closed and _is_closed(nxt):
nxt = _rotate_closed_to_nearest(nxt, cur_end)
# 필요시 뒤집기
if best_reverse:
nxt.reverse()
group.append(nxt)
cur_end = nxt[-1]
# 그룹 완성
groups.append(group)
return groups
groups = reorder_shapes_grouped(shapes, tol=0.05, snap=1e-3, rotate_closed=True)
fig, ax = plt.subplots()
ax.set_aspect('equal', adjustable='box')
ax.grid(True, linestyle=':', linewidth=0.5)
for gi, group in enumerate(groups, 1):
for pts in group:
xs, ys = zip(*pts)
ax.plot(xs, ys, linewidth=1.2)
# 그룹 라벨 (첫 shape의 첫 점)
ax.text(group[0][0][0], group[0][0][1], f"Group {gi}", fontsize=8)
plt.show()
block_groups = reorder_shapes_grouped(block_shapes, tol=0.05, snap=1e-3, rotate_closed=True)
fig, ax = plt.subplots()
ax.set_aspect('equal', adjustable='box')
ax.grid(True, linestyle=':', linewidth=0.5)
for gi, group in enumerate(groups, 1):
g_pts=[(x,y) for s in group for (x, y) in s ]
xs, ys = zip(*g_pts)
ax.plot(xs, ys, linewidth=1.2)
# 그룹 라벨 (첫 shape의 첫 점)
ax.text(group[0][0][0], group[0][0][1], f"Group {gi}", fontsize=8)
for bgi, block_group in enumerate(block_groups, gi+1):
g_pts=[(x,y) for s in block_group for (x, y) in s ]
xs, ys = zip(*g_pts)
ax.plot(xs, ys, linewidth=1.2)
# 그룹 라벨 (첫 shape의 첫 점)
ax.text(block_group[0][0][0], block_group[0][0][1], f"Group {bgi}", fontsize=8)
plt.show()
댓글 영역