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/scadgen.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

208 lines
8.1 KiB
Python

"""
Turn an unfolded net into a 3D, foldable, beveled solid (OpenSCAD + STL).
Printing / folding model
------------------------
The net is printed as a flat plate of thickness ``T`` lying in the XY plane:
z = T -> the OUTER surface of the finished polyhedron (stays continuous
across every fold, so the outside looks clean)
z = 0 -> the INNER surface (this is where the V-grooves open)
To fold two adjacent panels from flat (180 deg) to the solid's interior
dihedral angle ``theta`` we remove a V-groove on the inner side along the
shared edge. When the groove closes, the panels sit at ``theta``.
fold angle = 180 - theta (how far we rotate, total)
each wall is cut at half-angle gamma = (fold + slack) / 2 from vertical
groove half-width at the base w = (T - web) * tan(gamma)
a thin "web" of thickness ``web`` is left at the top as a living hinge.
``slack`` makes the groove a little wider than strictly necessary so the
panels can always reach ``theta`` (and a touch beyond); any resulting gap is
filled with glue. This follows the "leave more room than necessary" bias.
Glue seams (edges where two far-apart parts of the net meet when folded) are
chamfered on each mating face by ``gamma`` as well, so the two faces are
coplanar and glue flush, forming the correct dihedral.
Recommended print orientation: inner (grooved) side DOWN on the bed. Every
bevel wall is within ~gamma of vertical (<~20 deg), so the grooves are
self-supporting and need no support material.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import numpy as np
@dataclass
class FoldParams:
edge_mm: float = 30.0 # length of every polygon edge
thickness_mm: float = 12.0 # plate depth (T): thick, rigid panels
web_mm: float = 0.4 # living-hinge thickness left at the top (~2 layers)
slack_deg: float = 6.0 # extra opening added to every fold (more room)
over_mm: float = 0.4 # run grooves slightly past each vertex
eps_mm: float = 0.05 # cut a hair below the bed for clean booleans
bevel_boundary: bool = True # chamfer glue-seam edges too
piece_gap_mm: float = 8.0 # spacing between pieces in the layout
magnets: bool = True # pocket a magnet hole in each exterior face
magnet_diameter_mm: float = 25.4 / 8.0 # 1/8 inch
magnet_depth_mm: float = 25.4 / 16.0 # 1/16 inch
magnet_fn: int = 32 # facets for the round magnet pocket
def _unit(v):
v = np.asarray(v, float)
n = np.linalg.norm(v)
return v / n if n else v
def _wedge_multmatrix(start, x_axis, z_axis):
"""4x4 (row-major lists) mapping local (x=u, y=z_world, z=length) -> world.
local x -> x_axis (in-plane direction the groove widens)
local y -> world +Z (plate thickness direction)
local z -> z_axis (along the edge / extrusion direction)
origin -> start
"""
xa = _unit(x_axis)
za = _unit(z_axis)
return [
[xa[0], 0.0, za[0], start[0]],
[xa[1], 0.0, za[1], start[1]],
[0.0, 1.0, 0.0, start[2]],
[0.0, 0.0, 0.0, 1.0],
]
def _fmt_mat(m):
rows = ", ".join("[" + ", ".join(f"{v:.6f}" for v in row) + "]" for row in m)
return "[" + rows + "]"
def _gamma(dihedral, slack_deg):
"""Half wall-angle from vertical for a fold to `dihedral` (radians)."""
fold = 180.0 - dihedral
return math.radians((fold + slack_deg) / 2.0)
def _scad_for_piece(piece, params: FoldParams, offset, name):
e = params.edge_mm
T = params.thickness_mm
web = params.web_mm
over = params.over_mm
eps = params.eps_mm
ox, oy = offset
def S(pt): # scale a unit-net 2D point to mm + apply layout offset
return (pt[0] * e + ox, pt[1] * e + oy)
lines = [f"module {name}() {{", " difference() {"]
# --- the flat plate: 2D union of all face polygons, extruded ---
lines.append(" linear_extrude(height = T)")
lines.append(" union() {")
for _, poly in piece.faces:
pts = ", ".join(f"[{x:.4f}, {y:.4f}]" for x, y in (S(p) for p in poly))
lines.append(f" polygon([{pts}]);")
lines.append(" }")
# --- subtract every groove / chamfer ---
apex_z = T - web
if apex_z <= 0:
raise ValueError("web_mm must be smaller than thickness_mm")
# internal fold hinges: symmetric V-groove
for h in piece.hinges:
A = np.array([*S(h.p), 0.0])
B = np.array([*S(h.q), 0.0])
d = _unit(B - A)
nperp = np.array([-d[1], d[0], 0.0])
g = _gamma(h.dihedral, params.slack_deg)
w = apex_z * math.tan(g)
wb = w * (apex_z + eps) / apex_z # widen base so walls keep angle g
L = float(np.linalg.norm(B - A)) + 2 * over
start = A - d * over
m = _wedge_multmatrix(start, nperp, d)
tri = f"[[{-wb:.4f}, {-eps:.4f}], [{wb:.4f}, {-eps:.4f}], [0, {apex_z:.4f}]]"
lines.append(f" multmatrix({_fmt_mat(m)})")
lines.append(f" linear_extrude(height = {L:.4f}) polygon({tri});")
# glue-seam (boundary) edges: one-sided chamfer, feathered to the top
if params.bevel_boundary:
for b in piece.boundary:
A = np.array([*S(b.p), 0.0])
B = np.array([*S(b.q), 0.0])
d = _unit(B - A)
nperp = np.array([-d[1], d[0], 0.0])
inward = np.array([b.inward[0], b.inward[1], 0.0])
if nperp @ inward < 0:
nperp = -nperp
g = _gamma(b.dihedral, params.slack_deg)
# chamfer up to the same apex as the hinge grooves, leaving a thin
# land at the top: this keeps the mesh a clean 2-manifold (no
# knife edge) while still giving a flush glue face.
w = apex_z * math.tan(g)
wb = w * (apex_z + eps) / apex_z
L = float(np.linalg.norm(B - A)) + 2 * over
start = A - d * over
m = _wedge_multmatrix(start, nperp, d)
tri = f"[[0, {-eps:.4f}], [{wb:.4f}, {-eps:.4f}], [0, {apex_z:.4f}]]"
lines.append(f" multmatrix({_fmt_mat(m)})")
lines.append(f" linear_extrude(height = {L:.4f}) polygon({tri});")
# magnet pockets: one per face, centered on the exterior (top) surface
if params.magnets and params.magnet_diameter_mm > 0 and params.magnet_depth_mm > 0:
if params.magnet_depth_mm >= T:
raise ValueError("magnet_depth_mm must be smaller than thickness_mm")
d = params.magnet_diameter_mm
depth = params.magnet_depth_mm
z0 = T - depth
for _, poly in piece.faces:
cx = sum(S(p)[0] for p in poly) / len(poly)
cy = sum(S(p)[1] for p in poly) / len(poly)
lines.append(
f" translate([{cx:.4f}, {cy:.4f}, {z0:.4f}]) "
f"cylinder(h = {depth + eps:.4f}, d = {d:.4f}, $fn = {params.magnet_fn});"
)
lines.append(" }")
lines.append("}")
return "\n".join(lines)
def generate_scad(pieces, params: FoldParams) -> str:
# lay pieces out left-to-right
offsets = []
cursor = 0.0
for p in pieces:
x0, y0, x1, y1 = p.bbox()
offsets.append((cursor - x0 * params.edge_mm, -y0 * params.edge_mm))
cursor += (x1 - x0) * params.edge_mm + params.piece_gap_mm
magnet_note = (
f"// magnet pockets = {params.magnet_diameter_mm:.3f} mm dia x "
f"{params.magnet_depth_mm:.3f} mm deep, exterior face centers"
if params.magnets else "// magnet pockets: disabled"
)
header = [
"// Snub dodecahedron foldable net - generated by scadgen.py",
f"// edge = {params.edge_mm} mm, thickness(depth) = {params.thickness_mm} mm,",
f"// hinge web = {params.web_mm} mm, fold slack = {params.slack_deg} deg",
magnet_note,
"// Print inner (grooved) side DOWN; no supports needed.",
f"T = {params.thickness_mm};",
"$fn = 8;",
"",
]
body = []
calls = []
for i, (p, off) in enumerate(zip(pieces, offsets)):
name = f"piece{i}"
body.append(_scad_for_piece(p, params, off, name))
calls.append(f"{name}();")
return "\n".join(header) + "\n".join(body) + "\n\n" + "\n".join(calls) + "\n"