맥 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 정도로 시험 중인데, 그때그때 맞춰 조정하면 될 듯.


댓글 남기기