Spaces:
Running on Zero
Running on Zero
Download vli/papercraft.py from KwabsHug/VisualLearningIdea: direct link, hf CLI and curl.
- Browser
- Download file 13.7 kB
-
https://huggingface.co/spaces/KwabsHug/VisualLearningIdea/resolve/main/vli/papercraft.py
- Command line
-
hf download hf://spaces/KwabsHug/VisualLearningIdea/vli/papercraft.py
-
curl -L -o papercraft.py https://huggingface.co/spaces/KwabsHug/VisualLearningIdea/resolve/main/vli/papercraft.py
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("&", "&").replace("<", "<").replace(">", ">") | |