VisualLearningIdea / vli /papercraft.py
kwabs22
Claude Opus 5.5
Return papercraft pages as PNG pictures as well as SVG files
2fe7d5d
Raw History Blame Contribute Delete
13.7 kB
"""Papercraft from the rings rung: every band unrolls into one strip, plus two caps.
A band is the ring of quads between two neighbouring rings. Its triangles share
edges in sequence, so laying them flat one after another is exact (no stretching).
Each joint gets glue tabs on exactly one side, labelled with the piece it meets:
band i top edge -> band i+1 (the last band -> top cap)
band i seam end -> its own seam start (or the next part, when a band is split)
bottom cap rim -> band 1 bottom edge
A band that would curl past ~120 degrees on paper is split into parts (2a, 2b, ...).
Output is A4 pages of SVG in millimetres, cut lines solid, fold lines dashed. Each piece
is turned to its narrowest orientation; fit_factor gives the scale at which the largest
piece fills a page.
"""
import numpy as np
TAB_MM = 6.0
GAP_MM = 8.0
PAGE_MM = (190.0, 277.0) # A4 inside 10 mm margins
MARGIN_MM = 10.0
HEADER_MM = 8.0
STRIP_MAX_MM = PAGE_MM[1] - HEADER_MM - 2 * TAB_MM # longer strips are split
MIN_CAP_MM = 4.0
LEGEND = "cut solid, fold dashed, glue tabs to the named piece"
MIN_TAB_MM = 2.0
LABEL_TAB_MM = 8.0
def _ring_points(p, dirs):
"""3D points of one ring from its profile entry (centre, radii) and sample directions."""
cx, cz = p["center"]
r = np.asarray(p["radii"])[:, None]
return np.c_[cx + r[:, 0] * dirs[:, 0], np.full(len(dirs), p["y"]), cz + r[:, 0] * dirs[:, 1]]
def _rings(profile, samples):
ang = np.linspace(0, 2 * np.pi, samples, endpoint=False)
dirs = np.c_[np.cos(ang), np.sin(ang)]
return [_ring_points(p, dirs) for p in profile]
def _place(a2, c2, da, dc, away2):
"""The 2D point at distance da from a2 and dc from c2, on the side away from away2."""
d = np.linalg.norm(c2 - a2)
if d < 1e-9:
return a2.copy()
x = (da * da - dc * dc + d * d) / (2 * d)
h = np.sqrt(max(da * da - x * x, 0.0))
u = (c2 - a2) / d
n = np.array([-u[1], u[0]])
base = a2 + x * u
p1, p2 = base + h * n, base - h * n
return p1 if np.linalg.norm(p1 - away2) >= np.linalg.norm(p2 - away2) else p2
def _unroll_band(bot, top):
"""Lay a band flat. bot/top: (S, 3) ring points. Returns 2D (S+1, 2) bottom and top rows
(the seam vertex appears at both ends) and the fold edges between them."""
S = len(bot)
B, T = [np.array([0.0, 0.0])], [np.array([0.0, np.linalg.norm(top[0] - bot[0])])]
prev_b = np.array([-1.0, 0.0]) # strip grows towards +x
for j in range(S):
k = (j + 1) % S
t_next = _place(B[j], T[j], np.linalg.norm(top[k] - bot[j]), np.linalg.norm(top[k] - top[j]), prev_b)
b_next = _place(B[j], t_next, np.linalg.norm(bot[k] - bot[j]), np.linalg.norm(bot[k] - top[k]), T[j])
prev_b = B[j]
T.append(t_next)
B.append(b_next)
B, T = np.array(B), np.array(T)
folds = [(B[j], T[j]) for j in range(1, S)] + [(B[j], T[j + 1]) for j in range(S)]
return B, T, folds
def _tab(p, q, inside, label):
"""A trapezoid glue tab on edge p-q, on the side away from `inside`.
Edges under MIN_TAB_MM get no tab (too small to glue), and only edges of
LABEL_TAB_MM or more print the label, so converging edges don't pile up text.
"""
e = q - p
L = np.linalg.norm(e)
if L < MIN_TAB_MM:
return None
n = np.array([-e[1], e[0]]) / L
if np.dot(n, inside - p) > 0:
n = -n
w, inset = min(TAB_MM, 0.4 * L), min(TAB_MM, 0.25 * L)
u = e / L
return {"poly": [p, q, q + n * w - u * inset, p + n * w + u * inset], "label": label,
"show": bool(L >= LABEL_TAB_MM), "at": (p + q) / 2 + n * w / 2}
def _chunks(B, max_turn_deg=120, max_len=np.inf):
"""Split a strip where its bottom edge has turned too far (a strip that curls
past a half circle overlaps itself on paper: bands near a pole) or has grown
longer than `max_len` mm (so a long band still fits a page)."""
S = len(B) - 1
edges = np.diff(B, axis=0)
heading = np.unwrap(np.arctan2(edges[:, 1], edges[:, 0]))
length = np.r_[0, np.cumsum(np.linalg.norm(edges, axis=1))]
cuts, start = [0], 0
for j in range(1, S):
if abs(heading[j] - heading[start]) > np.radians(max_turn_deg) or length[j + 1] - length[start] > max_len:
cuts.append(j)
start = j
return list(zip(cuts, cuts[1:] + [S]))
def net(profile, samples, height_cm=None):
"""Pieces for the rings rung, scaled to millimetres. Returns (pieces, scale).
Caps under MIN_CAP_MM across are left out: the band there closes to a point.
"""
real_h = profile[-1]["y"] - profile[0]["y"]
scale = 1000.0 if not height_cm else 10.0 * float(height_cm) / max(real_h, 1e-6)
rings = [r * scale for r in _rings(profile, samples)]
caps = {k: 2 * max(profile[i]["radii"]) * scale >= MIN_CAP_MM for k, i in (("bottom", 0), ("top", -1))}
n = len(rings) - 1
pieces = []
for i in range(n):
B, T, _ = _unroll_band(rings[i], rings[i + 1])
above = f"band {i + 2}" if i + 1 < n else ("top cap" if caps["top"] else None)
parts = _chunks(B, max_len=STRIP_MAX_MM)
names = [f"band {i + 1}" + (chr(ord("a") + k) if len(parts) > 1 else "") for k in range(len(parts))]
for k, (j0, j1) in enumerate(parts):
mid = (B[j0:j1 + 1].mean(axis=0) + T[j0:j1 + 1].mean(axis=0)) / 2
tabs = [_tab(T[j], T[j + 1], mid, above) for j in range(j0, j1)] if above else []
tabs.append(_tab(B[j1], T[j1], B[j1 - 1], names[(k + 1) % len(parts)] + (" start" if k + 1 == len(parts) else "")))
folds = [(B[j], T[j]) for j in range(j0 + 1, j1)] + [(B[j], T[j + 1]) for j in range(j0, j1)]
pieces.append({"name": names[k], "outline": list(B[j0:j1 + 1]) + list(T[j0:j1 + 1][::-1]),
"folds": folds, "tabs": [t for t in tabs if t]})
for name, ring, key in (("bottom cap", rings[0], "bottom"), ("top cap", rings[-1], "top")):
if not caps[key]:
continue
c = ring.mean(axis=0)
poly = [np.array([x - c[0], z - c[2]]) for x, _, z in ring]
if key == "bottom":
poly = [np.array([x, -y]) for x, y in poly] # seen from below
tabs = [_tab(poly[j], poly[(j + 1) % samples], np.zeros(2), "band 1") for j in range(samples)] \
if key == "bottom" else []
pieces.append({"name": name, "outline": poly, "folds": [], "tabs": [t for t in tabs if t]})
return pieces, scale
def _points(piece):
return np.array(list(piece["outline"]) + [p for t in piece["tabs"] for p in t["poly"]])
def _upright(piece):
"""Rotate a piece to its narrowest orientation, long side vertical (fits a portrait page)."""
pts = _points(piece)
best = None
for deg in range(0, 180, 3):
a = np.radians(deg)
rot = np.array([[np.cos(a), -np.sin(a)], [np.sin(a), np.cos(a)]])
w, h = np.ptp(pts @ rot.T, axis=0)
if w > h:
continue
if best is None or w < best[0]:
best = (w, rot)
rot = best[1] if best else np.eye(2)
def r(p):
return rot @ p
return {"name": piece["name"], "outline": [r(p) for p in piece["outline"]],
"folds": [(r(p), r(q)) for p, q in piece["folds"]],
"tabs": [{**t, "poly": [r(p) for p in t["poly"]], "at": r(t["at"])} for t in piece["tabs"]]}
def fit_factor(pieces):
"""How much the pieces can grow (or must shrink) so the largest fits an A4 page.
Tabs keep their size when the net is rescaled, so they are left out of the fit."""
f = np.inf
for pc in pieces:
w, h = np.ptp(np.array(_upright({**pc, "tabs": []})["outline"]), axis=0)
f = min(f, (PAGE_MM[0] - 2 * TAB_MM) / max(w, 1e-6), (PAGE_MM[1] - 2 * TAB_MM - HEADER_MM) / max(h, 1e-6))
return float(f)
def a4_height_cm(profile, samples):
"""The tallest model whose every piece fits an A4 page. Long strips are split, so
what limits it is how wide a band is and how big the caps are."""
real_cm = (profile[-1]["y"] - profile[0]["y"]) * 100
rings = _rings(profile, samples)
widest = max([np.linalg.norm(a - b, axis=1).max() for a, b in zip(rings, rings[1:])]
+ [2 * max(profile[i]["radii"]) for i in (0, -1)])
h = real_cm * min(PAGE_MM) * 0.9 / max(widest * 1000, 1e-6)
for _ in range(6): # curved strips can still be wider than their band: shrink until all fit
f = fit_factor(net(profile, samples, h)[0])
if f >= 1:
break
h *= 0.98 * f
return h
def pages(pieces):
"""Shelf-pack upright pieces onto A4 pages. A piece too big for a page gets a page of its own size."""
out, cur, x, y, row_h = [], [], 0.0, HEADER_MM, 0.0
for pc in map(_upright, pieces):
pts = _points(pc)
lo = pts.min(axis=0)
w, h = np.ptp(pts, axis=0)
if x > 0 and x + w > PAGE_MM[0]:
x, y, row_h = 0.0, y + row_h + GAP_MM, 0.0
if cur and y + h > PAGE_MM[1]:
out.append(cur)
cur, x, y, row_h = [], 0.0, HEADER_MM, 0.0
cur.append((pc, np.array([x, y]) - lo))
x, row_h = x + w + GAP_MM, max(row_h, h)
if cur:
out.append(cur)
return out
def svg(placed, title):
"""One A4 page (millimetres, 10 mm margins) of placed pieces."""
width, height = _page_size(placed)
def pts(poly, off):
return " ".join(f"{p[0] + off[0] + MARGIN_MM:.1f},{p[1] + off[1] + MARGIN_MM:.1f}" for p in poly)
def xy(p, off):
return p[0] + off[0] + MARGIN_MM, p[1] + off[1] + MARGIN_MM
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{width:.0f}mm" height="{height:.0f}mm" '
f'viewBox="0 0 {width:.1f} {height:.1f}" font-family="sans-serif">',
'<rect width="100%" height="100%" fill="#fff"/>',
f'<text x="{MARGIN_MM}" y="{MARGIN_MM + 4}" font-size="4">{_esc(title)}: {LEGEND}</text>']
for pc, off in placed:
for t in pc["tabs"]:
out.append(f'<polygon points="{pts(t["poly"], off)}" fill="#eee" stroke="#000" stroke-width="0.2"/>')
if not t["show"]:
continue
a = xy(t["at"], off)
out.append(f'<text x="{a[0]:.1f}" y="{a[1]:.1f}" font-size="2" text-anchor="middle" '
f'dominant-baseline="middle">{_esc(t["label"])}</text>')
out.append(f'<polygon points="{pts(pc["outline"], off)}" fill="#fff" stroke="#000" stroke-width="0.3"/>')
for p, q in pc["folds"]:
(x1, y1), (x2, y2) = xy(p, off), xy(q, off)
out.append(f'<line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
f'stroke="#888" stroke-width="0.2" stroke-dasharray="1.5,1"/>')
c = xy(np.mean(pc["outline"], axis=0), off)
out.append(f'<text x="{c[0]:.1f}" y="{c[1]:.1f}" font-size="4" text-anchor="middle" '
f'dominant-baseline="middle">{_esc(pc["name"])}</text>')
out.append("</svg>")
return "\n".join(out), (round(width), round(height))
def _page_size(placed):
pts_all = np.concatenate([_points(pc) + off for pc, off in placed])
return (max(PAGE_MM[0], float(pts_all[:, 0].max())) + 2 * MARGIN_MM,
max(PAGE_MM[1], float(pts_all[:, 1].max())) + 2 * MARGIN_MM)
def png(placed, title, path, dpi=150):
"""The same page as svg(), rasterised: chats, <img> tags and fetchers show PNGs, not SVG files."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from matplotlib.patches import Polygon
width, height = _page_size(placed)
fig = plt.figure(figsize=(width / 25.4, height / 25.4), dpi=dpi)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_xlim(0, width)
ax.set_ylim(height, 0)
ax.axis("off")
pt = 72 / 25.4 # points per mm
m = np.array([MARGIN_MM, MARGIN_MM])
ax.text(MARGIN_MM, MARGIN_MM + 4, f"{title}: {LEGEND}", fontsize=4 * pt, va="baseline")
for pc, off in placed:
o = off + m
for t in pc["tabs"]:
ax.add_patch(Polygon(np.array(t["poly"]) + o, facecolor="#eee", edgecolor="k", linewidth=0.2 * pt))
if t["show"]:
ax.text(*(t["at"] + o), t["label"], fontsize=2 * pt, ha="center", va="center")
ax.add_patch(Polygon(np.array(pc["outline"]) + o, facecolor="#fff", edgecolor="k", linewidth=0.3 * pt))
for p, q in pc["folds"]:
ax.plot([p[0] + o[0], q[0] + o[0]], [p[1] + o[1], q[1] + o[1]], color="#888",
linewidth=0.2 * pt, linestyle=(0, (1.5 / 0.2, 1 / 0.2)))
ax.text(*(np.mean(pc["outline"], axis=0) + o), pc["name"], fontsize=4 * pt, ha="center", va="center")
fig.savefig(path, facecolor="white")
plt.close(fig)
return path
def joins(pieces):
"""Both sides of every joint. Tabs sit on one side only (the last band carries the
tabs for the top cap), so a piece lists the tabs it carries and the pieces whose
tabs land on it. A label names a piece, a split band ("band 2" -> 2a, 2b) or a
seam start ("band 1a start")."""
names = [pc["name"] for pc in pieces]
def targets(label):
base = label[:-len(" start")] if label.endswith(" start") else label
return [n for n in names if n == base or (n != base and n.rstrip("abcdefghijklmnopqrstuvwxyz") == base)]
out = {n: {"glues_to": set(), "receives_from": set()} for n in names}
for pc in pieces:
for t in pc["tabs"]:
for n in targets(t["label"]):
out[pc["name"]]["glues_to"].add(n)
out[n]["receives_from"].add(pc["name"])
return {n: {k: sorted(v) for k, v in j.items()} for n, j in out.items()}
def _esc(s):
return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")