상세 컨텐츠

본문 제목

[파이썬] DXF CAD 형상 깨지지 않게 matplotlib 에서 plot하기

Data Science/Python

by FDG 2025. 11. 8. 23:50

본문

dxf_to_axplot.ipynb
0.41MB
1.dxf
0.03MB
cad
plot

ezdxf에서 entity 불러와 matplotlib plot 가능하게 선으로 변환

테스트 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

image.png

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.

png

(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()

png

붙어 있는 객체와 떨어진 객체를 분리해 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()

png

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()

png

관련글 더보기

댓글 영역