Files
ckochandClaude Opus 5.5 95ad4d25de Software: pattern player, web page with a design library, GRBL tools
- python3 -m sandtable web: local web page to play designs from a library of
  27 built-in patterns, upload .thr or .svg, pause/skip/stop, jog and calibrate.
  Reconnects by itself when the USB cable comes back.
- Plays .thr files and built-in generators with an erase spiral between them,
  constant ball speed, host-side homing on stock GRBL 1.1 (Uno + CNC shield),
  FluidNC for the ESP32 board.
- Home switch guard: patterns never press the switch, so a press means lost
  steps and stops the machine at once with an alert.
- lineart.py turns SVG line drawings into one continuous path.
- Tests against a GRBL simulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:40:23 -05:00

231 lines
9.1 KiB
Python

"""Built-in pattern generators. Each returns a theta-rho path (theta in radians, continuous;
rho 0..1) that starts and ends at the centre or the edge, like a .thr file."""
import math
TAU = 2 * math.pi
# ---------------------------------------------------------------- helpers
def densify_xy(xy, step):
"""Split straight Cartesian segments so points are at most `step` apart (unit-disc units).
Needed for shapes made of straight lines: the table moves in theta and rho, so a long
move between two far-apart points would come out as a curve."""
out = [xy[0]]
for (x1, y1), (x2, y2) in zip(xy, xy[1:]):
n = max(1, int(math.ceil(math.hypot(x2 - x1, y2 - y1) / step)))
for i in range(1, n + 1):
t = i / n
out.append((x1 + (x2 - x1) * t, y1 + (y2 - y1) * t))
return out
def _through_centre(xy, near=0.03):
"""Where a segment passes close to the centre, add the exact centre point, so the arm can
swing round there (with the ball at rho 0, drawing nothing) instead of a few mm out."""
out = [xy[0]]
for (x1, y1), (x2, y2) in zip(xy, xy[1:]):
dx, dy = x2 - x1, y2 - y1
L2 = dx * dx + dy * dy
if L2 > 0:
t = -(x1 * dx + y1 * dy) / L2
if 0 < t < 1 and math.hypot(x1 + t * dx, y1 + t * dy) < near:
out.append((0.0, 0.0))
out.append((x2, y2))
return out
def xy_to_polar(xy):
"""Cartesian (unit disc) -> continuous theta-rho. At the centre the arm turns in place to the
next direction before the ball moves out again."""
pts = []
prev = None
at_centre = False
for x, y in _through_centre(xy):
r = min(1.0, math.hypot(x, y))
if r < 1e-6:
th = prev if prev is not None else 0.0
at_centre = True
else:
a = math.atan2(y, x)
th = a if prev is None else prev + ((a - prev + math.pi) % TAU - math.pi)
if at_centre:
pts.append((th, 0.0)) # turn in place at the centre
at_centre = False
pts.append((th, r))
prev = th
return pts
def _normalise(xy):
m = max(math.hypot(x, y) for x, y in xy) or 1.0
return [(x / m, y / m) for x, y in xy]
# ---------------------------------------------------------------- generators
def spiral(loops=20, direction='out'):
"""Archimedean spiral between the centre and the edge."""
n = int(loops * 180)
pts = [(TAU * loops * i / n, i / n) for i in range(n + 1)]
if direction == 'in':
pts = [(th, 1 - r) for th, r in pts]
return pts
def rose(petals=5, denominator=1, copies=1, twist_deg=0.0):
"""Rose curve r = cos(k*theta) with k = petals/denominator, repeated `copies` times,
each copy turned by `twist_deg`. Starts and ends at the edge."""
k = petals / denominator
span = math.pi * denominator if (petals * denominator) % 2 else TAU * denominator
xy = []
for c in range(int(copies)):
rot = math.radians(twist_deg) * c
n = int(span / math.radians(0.5))
for i in range(n + 1):
t = span * i / n
r = math.cos(k * t)
xy.append((r * math.cos(t + rot), r * math.sin(t + rot)))
return xy_to_polar(xy)
def spirograph(big=96, small=35, pen=0.85):
"""Hypotrochoid: a pen `pen` (fraction of the small radius) on a wheel of radius `small`
rolling inside a ring of radius `big`. Starts and ends at the edge."""
R, r, d = float(big), float(small), float(pen) * float(small)
g = math.gcd(int(big), int(small)) or 1
span = TAU * int(small) / g
n = int(span / math.radians(0.4))
xy = []
for i in range(n + 1):
t = span * i / n
xy.append(((R - r) * math.cos(t) + d * math.cos((R - r) / r * t),
(R - r) * math.sin(t) - d * math.sin((R - r) / r * t)))
return xy_to_polar(_normalise(xy))
def polygons(sides=6, layers=40, twist_deg=3.0):
"""Nested regular polygons from the edge to the centre, each turned a little: a twisted star."""
xy = []
for L in range(int(layers) + 1):
rad = 1 - L / layers
rot = math.radians(twist_deg) * L
for v in range(int(sides) + 1):
a = rot + TAU * v / sides
xy.append((rad * math.cos(a), rad * math.sin(a)))
xy.append((0.0, 0.0))
return xy_to_polar(densify_xy(xy, 0.004))
def ripples(waves=9, amplitude=0.035, loops=17):
"""A spiral from the edge to the centre with a wave along it: rings of ripples."""
n = int(loops * 360)
pts = [(0.0, 1.0)] # start at the edge, where the erase spiral ends
for i in range(n + 1):
th = TAU * loops * i / n
base = (1 - amplitude) * (1 - i / n)
fade = min(1.0, base / (4 * amplitude)) if amplitude else 0.0 # flatten near the centre
r = base + amplitude * fade * math.sin(waves * th)
pts.append((th, max(0.0, min(1.0, r))))
pts.append((pts[-1][0], 0.0))
return pts
def _polar_path(pts, step=0.02):
"""Join theta-rho corner points with short steps (at most `step` across the sand, unit disc),
so radial lines stay straight and moves along the rim stay on the rim."""
out = [pts[0]]
for (t0, r0), (t1, r1) in zip(pts, pts[1:]):
n = max(1, int(math.ceil(max(abs(r1 - r0), max(r0, r1) * abs(t1 - t0)) / step)))
out += [(t0 + (t1 - t0) * i / n, r0 + (r1 - r0) * i / n) for i in range(1, n + 1)]
return out
def _start_at_edge(xy):
"""A closed loop (first point == last) restarted at its outermost point."""
i0 = max(range(len(xy)), key=lambda i: math.hypot(*xy[i]))
return xy[i0:] + xy[1:i0 + 1]
def string_art(points=101, step=40):
"""Straight chords between `points` pegs round the rim, each chord `step` pegs on, like
string art. The chords outline a circle in the middle."""
n, k = int(points), int(step)
m = n // math.gcd(n, k)
xy = [(math.cos(TAU * (j * k % n) / n), math.sin(TAU * (j * k % n) / n)) for j in range(m + 1)]
return xy_to_polar(densify_xy(xy, 0.01))
def lissajous(a=3, b=2, phase_deg=90.0, copies=1, twist_deg=0.0):
"""Lissajous figure x = sin(a t + phase), y = sin(b t), stretched to fill the disc. With
copies, it is drawn again `copies` times, each turned `twist_deg` further, joined along the edge."""
a, b, ph = int(a), int(b), math.radians(float(phase_deg))
n = 3000 * max(a, b)
xy = [(math.sin(a * TAU * i / n + ph), math.sin(b * TAU * i / n)) for i in range(n + 1)]
base = _start_at_edge(_normalise(xy))
out, tw = [], math.radians(float(twist_deg))
for c in range(int(copies)):
ca, sa = math.cos(tw * c), math.sin(tw * c)
pol = xy_to_polar([(x * ca - y * sa, x * sa + y * ca) for x, y in base])
if out:
k = round((out[-1][0] + tw - pol[0][0]) / TAU) # carry on from where the last copy ended
pol = [(t + TAU * k, r) for t, r in pol]
out += _polar_path([out[-1], pol[0]])[1:]
out += pol if not out else pol[1:]
return out
def sunburst(rays=24):
"""`rays` straight lines through the centre, evenly spaced, joined by short arcs along the
edge: a starburst."""
n = int(rays)
d = math.pi / n
pts, t = [], 0.0
for _ in range(n):
pts += [(t, 1.0), (t, 0.0), (t + math.pi, 0.0), (t + math.pi, 1.0), (t + math.pi + d, 1.0)]
t += math.pi + d
return _polar_path(pts)
def spiral_arms(arms=4, turns=1.0):
"""A galaxy: `arms` spiral arms, each `turns` turns long, drawn out along one arm and back
in along the next."""
n, T = int(arms) // 2 * 2 or 2, float(turns)
pts = [(0.0, 0.0)]
for k in range(0, n, 2):
a, b = TAU * k / n, TAU * (k + 1) / n
pts += [(a, 0.0), (a + TAU * T, 1.0), (b + TAU * T, 1.0), (b, 0.0)]
pts.append((TAU, 0.0))
return _polar_path(pts)
def test_f():
"""The letter F, to check that patterns aren't mirrored. Reads normally when the table is right."""
xy = [(0, 0), (-0.2, 0.1), (-0.2, 0.8), (0.3, 0.8), (-0.2, 0.8), (-0.2, 0.45), (0.15, 0.45),
(-0.2, 0.45), (-0.2, 0.1), (0, 0)]
return xy_to_polar(densify_xy(xy, 0.004))
GENERATORS = {
'spiral': (spiral, {'loops': 20, 'direction': 'out'}),
'rose': (rose, {'petals': 5, 'denominator': 1, 'copies': 1, 'twist_deg': 0.0}),
'spirograph': (spirograph, {'big': 96, 'small': 35, 'pen': 0.85}),
'polygons': (polygons, {'sides': 6, 'layers': 40, 'twist_deg': 3.0}),
'ripples': (ripples, {'waves': 9, 'amplitude': 0.035, 'loops': 17}),
'string_art': (string_art, {'points': 101, 'step': 40}),
'lissajous': (lissajous, {'a': 3, 'b': 2, 'phase_deg': 90.0, 'copies': 1, 'twist_deg': 0.0}),
'sunburst': (sunburst, {'rays': 24}),
'spiral_arms': (spiral_arms, {'arms': 4, 'turns': 1.0}),
'test_f': (test_f, {}),
}
def generate(name, **params):
if name not in GENERATORS:
raise KeyError(f'unknown pattern {name!r}; choose from {", ".join(GENERATORS)}')
fn, defaults = GENERATORS[name]
args = dict(defaults)
for k, v in params.items():
if k not in defaults:
raise KeyError(f'{name} has no setting {k!r}; settings: {", ".join(defaults) or "none"}')
args[k] = type(defaults[k])(v) if not isinstance(defaults[k], str) else str(v)
return fn(**args)