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>
This commit is contained in:
1 parent
1400cc8afd
commit
95ad4d25de
52 files changed
+388117
No files matched your search
@@ -0,0 +1,230 @@
|
||||
"""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)
|
||||
Reference in new issue
Block a user