1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-07 23:35:16 +00:00
terraform-playground/snub-dodecahedron/generate.py
Cursor Agent 6e0225b175
Add parametric magnet pockets (default 1/8" dia x 1/16" deep) on each exterior face
Co-authored-by: Jason Hall <imjasonh@users.noreply.github.com>
2026-06-15 01:36:09 +00:00

139 lines
5.7 KiB
Python

#!/usr/bin/env python3
# /// script
# requires-python = ">=3.9"
# dependencies = ["numpy>=1.24", "scipy>=1.10"]
# ///
"""
Generate a foldable, beveled snub-dodecahedron net as OpenSCAD (+ optional STL).
Example:
uv run generate.py --edge 30 --depth 3 --web 0.6 --slack 6 --stl
The output is a flat plate you print, then fold along the grooved hinges and
glue the seams to obtain a snub dodecahedron. See README.md for details.
"""
from __future__ import annotations
import argparse
import os
import shutil
import subprocess
import sys
from preview import verify_no_overlap, write_svg
from scadgen import FoldParams, generate_scad
from snubgeom import SnubDodecahedron
from unfold import Unfolder
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--edge", type=float, default=30.0,
help="edge length of every polygon, mm (default 30)")
ap.add_argument("--depth", "--thickness", dest="depth", type=float,
default=12.0, help="plate depth / thickness, mm (default 12)")
ap.add_argument("--web", type=float, default=0.4,
help="living-hinge web thickness at the top, mm (default 0.4)")
ap.add_argument("--slack", type=float, default=6.0,
help="extra fold opening added to every groove, deg (default 6)")
ap.add_argument("--over", type=float, default=0.4,
help="run grooves this far past each vertex, mm (default 0.4)")
ap.add_argument("--magnet-diameter", type=float, default=25.4 / 8.0,
help="magnet pocket diameter, mm (default 3.175 = 1/8 in)")
ap.add_argument("--magnet-depth", type=float, default=25.4 / 16.0,
help="magnet pocket depth, mm (default 1.5875 = 1/16 in)")
ap.add_argument("--no-magnets", action="store_true",
help="do not pocket magnet holes in the faces")
ap.add_argument("--max-faces-per-piece", type=int, default=None,
help="split the net into smaller pieces of at most N faces")
ap.add_argument("--no-boundary-bevel", action="store_true",
help="leave glue-seam edges square instead of chamfered")
ap.add_argument("--out", default="out/snub_net",
help="output path prefix (default out/snub_net)")
ap.add_argument("--stl", action="store_true",
help="also render an STL with the openscad CLI")
ap.add_argument("--solid", action="store_true",
help="also export the target snub dodecahedron solid (reference)")
args = ap.parse_args(argv)
out_dir = os.path.dirname(args.out) or "."
os.makedirs(out_dir, exist_ok=True)
print("building snub dodecahedron geometry ...")
solid = SnubDodecahedron()
info = solid.verify()
print(f" verified: {info['faces']} faces "
f"({info['pentagons']} pentagons + {info['triangles']} triangles), "
f"dihedrals 3-3={info['dihedral_3_3']:.3f} 3-5={info['dihedral_3_5']:.3f}")
print("unfolding into a flat net ...")
pieces = Unfolder(solid).unfold(max_faces_per_piece=args.max_faces_per_piece)
bad = verify_no_overlap(pieces)
n_hinge = sum(len(p.hinges) for p in pieces)
n_seam = sum(len(p.boundary) for p in pieces) // 2
print(f" {len(pieces)} piece(s), {sum(len(p.faces) for p in pieces)} faces, "
f"{n_hinge} fold hinges, {n_seam} glue seams, "
f"{bad} overlapping face pairs")
if bad:
print(" WARNING: overlaps detected; try --max-faces-per-piece to split",
file=sys.stderr)
if not args.no_magnets:
print(f" magnet pockets: {args.magnet_diameter:.3f} mm dia x "
f"{args.magnet_depth:.3f} mm deep, one per face (exterior)")
svg_path = args.out + "_preview.svg"
magnet_r = (args.magnet_diameter / 2.0 / args.edge) if not args.no_magnets else 0.0
write_svg(pieces, svg_path, magnet_radius=magnet_r)
print(f" wrote {svg_path}")
params = FoldParams(
edge_mm=args.edge,
thickness_mm=args.depth,
web_mm=args.web,
slack_deg=args.slack,
over_mm=args.over,
bevel_boundary=not args.no_boundary_bevel,
magnets=not args.no_magnets,
magnet_diameter_mm=args.magnet_diameter,
magnet_depth_mm=args.magnet_depth,
)
scad = generate_scad(pieces, params)
scad_path = args.out + ".scad"
with open(scad_path, "w") as f:
f.write(scad)
print(f" wrote {scad_path}")
if args.stl:
openscad = shutil.which("openscad")
if not openscad:
print(" openscad not found; skipping STL", file=sys.stderr)
else:
stl_path = args.out + ".stl"
print(f" rendering {stl_path} (this can take a minute) ...")
r = subprocess.run([openscad, "-o", stl_path, scad_path],
capture_output=True, text=True)
if r.returncode != 0:
print(r.stderr[-2000:], file=sys.stderr)
print(" STL render failed", file=sys.stderr)
return 1
print(f" wrote {stl_path}")
if args.solid:
V = solid.vertices * args.edge
pts = ", ".join(f"[{v[0]:.4f},{v[1]:.4f},{v[2]:.4f}]" for v in V)
faces = ", ".join("[" + ",".join(str(i) for i in f) + "]"
for f in solid.faces)
solid_scad = args.out + "_solid.scad"
with open(solid_scad, "w") as f:
f.write(f"// target snub dodecahedron (edge = {args.edge} mm)\n"
f"polyhedron(points=[{pts}], faces=[{faces}], convexity=10);\n")
print(f" wrote {solid_scad}")
print("done.")
return 0
if __name__ == "__main__":
raise SystemExit(main())