리소그래프·디자인 스튜디오

SVG 벡터 도면을 STL 인쇄판으로 만들기

맥 OS 터미널 환경에서 아래 코드를 실행하면 되는데,

from build123d import *
from svgpathtools import svg2paths2
from shapely.geometry import Polygon, MultiPolygon, GeometryCollection
from shapely.ops import unary_union
from shapely import affinity
import math
import os

# -------------------------------------------------
# [1. 설정]
# -------------------------------------------------
svg_file = "test-svg-2.svg"
render_mode = "male"   # "male", "female", "both"

# -------------------------------------------------
# [2. 규격 - mm 단위]
# -------------------------------------------------
stamp_width_mm  = 110.0
stamp_height_mm = 110.0

size_mode = 1
content_width_mm = 86.8
margin_mm = 5.0

base_thickness = 2.0
press_height = 1.0
paper_gap = 0.3

# -------------------------------------------------
# [3. taper / top 유지 설정]
# -------------------------------------------------
seed_h = 0.01
male_taper_mm = 0.6
top_offset_mm = 0.05   # 최상단에서도 남겨둘 최소 오프셋
step_count = 4

# -------------------------------------------------
# [4. SVG -> Polygon 보정 설정]
# -------------------------------------------------
sample_density = 3.0
min_samples = 12
simplify_eps_mm = 0.02
buffer_resolution = 8
work_dir = "_stamp_tmp_svg"

# -------------------------------------------------
# [5. 출력 파일명]
# -------------------------------------------------
out_name = "stamp_build123d"

# -------------------------------------------------
# 계산
# -------------------------------------------------
final_w = content_width_mm if size_mode == 1 else (stamp_width_mm - margin_mm * 2)
cone_step_h = press_height / step_count
top_shape_h = seed_h + press_height


def dist(a, b):
    return math.hypot(a[0] - b[0], a[1] - b[1])


def explode_polygons(geom):
    if geom.is_empty:
        return []
    if isinstance(geom, Polygon):
        return [geom]
    if isinstance(geom, MultiPolygon):
        return [g for g in geom.geoms if not g.is_empty]
    if isinstance(geom, GeometryCollection):
        out = []
        for g in geom.geoms:
            out.extend(explode_polygons(g))
        return out
    return []


def sample_subpath(subpath):
    pts = []
    for seg in subpath:
        try:
            seg_len = float(seg.length(error=1e-4))
        except Exception:
            seg_len = 1.0
        n = max(min_samples, int(max(seg_len, 0.1) * sample_density))
        for i in range(n):
            z = seg.point(i / n)
            p = (float(z.real), float(z.imag))
            if not pts or dist(pts[-1], p) > 1e-9:
                pts.append(p)

    if len(subpath) > 0:
        z = subpath[-1].point(1.0)
        p = (float(z.real), float(z.imag))
        if not pts or dist(pts[-1], p) > 1e-9:
            pts.append(p)

    if len(pts) >= 3 and dist(pts[0], pts[-1]) > 1e-6:
        pts.append(pts[0])

    return pts


def svg_to_repaired_parts(svg_path):
    print("[1/8] SVG 파싱 시작")
    paths, attributes, svg_attributes = svg2paths2(svg_path)
    print(f"[1/8] path count: {len(paths)}")

    parts = []

    for path in paths:
        subs = path.continuous_subpaths()
        if not subs:
            subs = [path]

        local_polys = []
        for sub in subs:
            coords = sample_subpath(sub)
            if len(coords) < 4:
                continue
            try:
                poly = Polygon(coords)
                if not poly.is_valid:
                    poly = poly.buffer(0)
                for p in explode_polygons(poly):
                    if p.area > 1e-9:
                        local_polys.append(p)
            except Exception:
                pass

        if not local_polys:
            continue

        local_polys = [p for p in local_polys if p.area > 1e-8]
        local_polys.sort(key=lambda g: g.area, reverse=True)

        geom = None
        for i, p in enumerate(local_polys):
            rp = p.representative_point()
            depth = 0
            for j, q in enumerate(local_polys):
                if i == j:
                    continue
                if q.area > p.area and q.contains(rp):
                    depth += 1

            if geom is None:
                geom = p if depth % 2 == 0 else GeometryCollection()
                if depth % 2 == 1:
                    geom = geom.difference(p)
            else:
                geom = geom.union(p) if depth % 2 == 0 else geom.difference(p)

        if geom is not None and not geom.is_empty:
            geom = geom.buffer(0)
            for p in explode_polygons(geom):
                if p.area > 1e-8:
                    parts.append(p)

    if not parts:
        raise ValueError("SVG에서 유효한 polygon을 만들지 못했습니다.")

    print(f"[2/8] repaired parts: {len(parts)}")
    return parts


def normalize_parts(parts):
    print("[3/8] normalize 시작")
    unioned = unary_union(parts).buffer(0)
    minx, miny, maxx, maxy = unioned.bounds
    w = maxx - minx
    if w <= 0:
        raise ValueError("SVG width is zero or invalid.")

    scale_ratio = final_w / w
    cx = (minx + maxx) / 2.0
    cy = (miny + maxy) / 2.0

    out = []
    for p in parts:
        g = affinity.translate(p, xoff=-cx, yoff=-cy)
        g = affinity.scale(g, xfact=-scale_ratio, yfact=scale_ratio, origin=(0, 0))
        g = affinity.translate(g, xoff=stamp_width_mm / 2.0, yoff=stamp_height_mm / 2.0)
        g = g.buffer(0)
        if not g.is_empty:
            out.extend(explode_polygons(g))

    print("[3/8] normalize 완료")
    return out


def clean_part(g):
    g = g.buffer(0)
    g = g.simplify(simplify_eps_mm, preserve_topology=True)
    g = g.buffer(0)
    return g


def offset_parts(parts, delta):
    buffered_parts = []

    for p in parts:
        g = clean_part(p)

        if abs(delta) < 1e-9:
            b = g
        else:
            b = g.buffer(delta, resolution=buffer_resolution, join_style=2)
            b = b.buffer(0)

        if not b.is_empty:
            buffered_parts.extend(explode_polygons(b))

    if not buffered_parts:
        raise ValueError(f"offset 결과가 비었습니다. delta={delta}")

    merged = unary_union(buffered_parts).buffer(0)
    return merged


def ring_to_path(coords):
    pts = list(coords)
    if len(pts) < 4:
        return ""
    parts = [f"M {pts[0][0]:.6f} {pts[0][1]:.6f}"]
    for x, y in pts[1:]:
        parts.append(f"L {x:.6f} {y:.6f}")
    parts.append("Z")
    return " ".join(parts)


def geom_to_svg_file(geom, file_path):
    polys = explode_polygons(geom)
    if not polys:
        raise ValueError("SVG로 쓸 polygon이 없습니다.")

    path_elements = []
    for poly in polys:
        d_parts = []
        d_parts.append(ring_to_path(poly.exterior.coords))
        for hole in poly.interiors:
            d_parts.append(ring_to_path(hole.coords))
        d = " ".join([p for p in d_parts if p])
        path_elements.append(f'<path d="{d}" fill="black" fill-rule="evenodd"/>')

    svg = f'''<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg"
     version="1.1"
     viewBox="0 0 {stamp_width_mm:.6f} {stamp_height_mm:.6f}"
     width="{stamp_width_mm:.6f}mm"
     height="{stamp_height_mm:.6f}mm">
  {"".join(path_elements)}
</svg>
'''
    with open(file_path, "w", encoding="utf-8") as f:
        f.write(svg)


def geom_to_sketch(geom, name):
    os.makedirs(work_dir, exist_ok=True)
    svg_path = os.path.join(work_dir, f"{name}.svg")
    geom_to_svg_file(geom, svg_path)

    imported = import_svg(svg_path, flip_y=False)
    with BuildSketch() as sk:
        add(imported)

    return sk.sketch


def safe_union(parts):
    result = None
    for i, p in enumerate(parts, start=1):
        print(f"[union] {i}/{len(parts)}")
        result = p if result is None else (result + p)
    return result


def make_layer_from_parts(base_parts, delta, z0, h, tag):
    print(f"[layer] {tag}: delta={delta:.4f}, z0={z0:.4f}, h={h:.4f}")
    layer_geom = offset_parts(base_parts, delta)
    layer_sketch = geom_to_sketch(layer_geom, tag)
    solid = extrude(layer_sketch, amount=h)
    return solid.move(Location((0, 0, z0)))


def make_male_press(base_parts):
    print("[4/8] male press 생성 시작")
    parts = []

    parts.append(
        make_layer_from_parts(
            base_parts,
            male_taper_mm + top_offset_mm,
            0.0,
            seed_h,
            "male_cap"
        )
    )

    for i in range(step_count):
        delta_now = top_offset_mm + male_taper_mm * (1 - (i + 1) / step_count)
        z0 = seed_h + i * cone_step_h
        parts.append(
            make_layer_from_parts(
                base_parts,
                delta_now,
                z0,
                cone_step_h,
                f"male_step_{i+1}_{step_count}"
            )
        )

    result = safe_union(parts)
    print("[4/8] male press 생성 완료")
    return result


def make_female_press_cutout(base_parts):
    print("[4/8] female cutout 생성 시작")
    parts = []

    parts.append(
        make_layer_from_parts(
            base_parts,
            male_taper_mm + paper_gap + top_offset_mm,
            0.0,
            seed_h,
            "female_cap"
        )
    )

    for i in range(step_count):
        delta_now = top_offset_mm + paper_gap + male_taper_mm * (1 - (i + 1) / step_count)
        z0 = seed_h + i * cone_step_h
        parts.append(
            make_layer_from_parts(
                base_parts,
                delta_now,
                z0,
                cone_step_h,
                f"female_step_{i+1}_{step_count}"
            )
        )

    result = safe_union(parts)
    print("[4/8] female cutout 생성 완료")
    return result


def make_base_plate(height):
    return Box(
        stamp_width_mm,
        stamp_height_mm,
        height,
        align=(Align.MIN, Align.MIN, Align.MIN),
    )


def make_male_plate(base_parts):
    print("[5/8] male plate 생성")
    plate = make_base_plate(base_thickness)
    press = make_male_press(base_parts).move(Location((0, 0, base_thickness)))
    return plate + press


def make_female_plate(base_parts):
    print("[5/8] female plate 생성")
    plate = make_base_plate(base_thickness + top_shape_h)
    cutout = make_female_press_cutout(base_parts).move(Location((0, 0, base_thickness)))
    return plate - cutout


def build_model():
    repaired_parts = svg_to_repaired_parts(svg_file)
    base_parts = normalize_parts(repaired_parts)

    if render_mode == "male":
        return make_male_plate(base_parts)
    elif render_mode == "female":
        return make_female_plate(base_parts)
    elif render_mode == "both":
        male = make_male_plate(base_parts)
        female = make_female_plate(base_parts).move(Location((stamp_width_mm + 10, 0, 0)))
        return male + female
    else:
        raise ValueError("render_mode must be 'male', 'female', or 'both'")


if __name__ == "__main__":
    print("[0/8] build 시작")
    model = build_model()

    print("[6/8] STEP 저장")
    export_step(model, f"{out_name}.step")

    print("[7/8] STL 저장")
    export_stl(model, f"{out_name}.stl")

    print("[8/8] 완료")
    print(f"Exported: {out_name}.step")
    print(f"Exported: {out_name}.stl")

만들어진 파이썬 파일을 아래와 같이 실행하면 된다.

cd ~/stamp-test
source .venv/bin/activate
python3 stamp_build123d.py

top_offset_mm 값은 0.06 정도로 시험 중인데, 그때그때 맞춰 조정하면 될 듯.