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

47 lines
2.1 KiB
Python

"""Draw what the ball will trace, as a PNG (top view, looking down through the glass)."""
import math
from PIL import Image, ImageDraw
COLORS = {'pattern': (60, 45, 30), 'erase': (205, 190, 165), 'travel': (200, 60, 60)}
SAND = (236, 222, 196)
def render(segments, rho_max_mm, path, size=900, invert_theta=False, transparent=False):
"""segments: [(kind, [(theta_deg, rho_mm), ...])] in machine units. transparent: no square
background behind the sand disc (for the web page)."""
im = Image.new('RGBA', (size, size), (0, 0, 0, 0)) if transparent else Image.new('RGB', (size, size), (250, 250, 248))
d = ImageDraw.Draw(im)
c = size / 2
s = (size / 2 - 12) / rho_max_mm
d.ellipse([c - rho_max_mm * s - 6, c - rho_max_mm * s - 6, c + rho_max_mm * s + 6, c + rho_max_mm * s + 6],
fill=SAND, outline=(150, 135, 110), width=3)
sign = -1 if invert_theta else 1 # show the pattern as it should look, not as the motor turns
for kind, pts in segments:
xy = [(c + r * s * math.cos(math.radians(sign * t)), c - r * s * math.sin(math.radians(sign * t)))
for t, r in pts]
if len(xy) > 1:
d.line(xy, fill=COLORS.get(kind, (0, 0, 0)), width=max(1, size // 400) if kind == 'pattern' else 1)
im.save(path)
return path
def sheet(jobs, rho_max_mm, path, tile=420, invert_theta=False, cols=3):
"""One tile per pattern (each drawn after its erase), with the name under it."""
import os
import tempfile
n = len(jobs)
cols = min(cols, n)
rows = (n + cols - 1) // cols
out = Image.new('RGB', (cols * tile, rows * (tile + 28)), (250, 250, 248))
d = ImageDraw.Draw(out)
with tempfile.TemporaryDirectory() as tmp:
for i, (name, segs) in enumerate(jobs):
f = os.path.join(tmp, f'{i}.png')
render([s for s in segs if s[0] != 'erase'], rho_max_mm, f, size=tile, invert_theta=invert_theta)
x, y = (i % cols) * tile, (i // cols) * (tile + 28)
out.paste(Image.open(f), (x, y))
d.text((x + 12, y + tile + 6), name, fill=(40, 40, 40))
out.save(path)
return path