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:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent 1400cc8afd
commit 95ad4d25de
52 files changed
+388117

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""Sand table pattern player: .thr files and built-in patterns -> GRBL / FluidNC over USB."""
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
main()
+475
View File
@@ -0,0 +1,475 @@
"""sandtable: drive the sand table from this computer over USB.
Run `python3 -m sandtable --help` from the software folder.
"""
import argparse
import glob
import os
import random
import sys
import time
from . import config, patterns, thr
from .motion import HEADER, Planner
USB_IDS = {0x2341: 'Arduino', 0x2A03: 'Arduino', 0x1A86: 'CH340', 0x0403: 'FTDI', 0x10C4: 'CP210x', 0x303A: 'ESP32'}
# ---------------------------------------------------------------- helpers
def find_port(cfg, explicit=None):
want = explicit or cfg['serial']['port']
if want and want != 'auto':
return want
from serial.tools import list_ports
cands = [p for p in list_ports.comports() if p.vid in USB_IDS]
if len(cands) == 1:
return cands[0].device
if not cands:
sys.exit('No Arduino found on USB. Plug it in, or give --port (see `ports`).')
sys.exit('Several possible ports: ' + ', '.join(p.device for p in cands) + '. Pick one with --port.')
def connect(cfg, args):
from .grbl import Controller
dev = find_port(cfg, args.port)
print(f'connecting to {dev} ...', flush=True)
try:
c = Controller.open(dev, cfg['serial']['baud'], cfg['controller']['type'])
except Exception as e: # pyserial raises SerialException with errno 13, not PermissionError
if getattr(e, 'errno', None) != 13 and 'Permission denied' not in str(e):
raise
sys.exit(f'No permission for {dev}.\n'
f' Quick fix (until it is unplugged): sudo chmod a+rw {dev}\n'
f' Lasting fix: sudo usermod -aG dialout $USER (then log out and back in)')
print(f" {c.banner or 'controller'} state: {c.status.state}")
return c
def alert(title, msg):
"""Make sure a person notices: a beep and a big message here, plus a desktop notification."""
print(f'\a\n*** {title} ***\n{msg}\n', flush=True)
if os.environ.get('SANDTABLE_QUIET'): # set by the tests: no desktop pop-ups
return
try:
import subprocess
subprocess.run(['notify-send', '-u', 'critical', '-a', 'Sand table', title, msg],
timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
def check_floor(cfg):
m, h = cfg['machine'], cfg['homing']
if m['rho_min_mm'] < h['switch_rho_mm'] + 0.5:
sys.exit(f"machine.rho_min_mm ({m['rho_min_mm']}) must be at least 0.5 mm more than "
f"homing.switch_rho_mm ({h['switch_rho_mm']}), or patterns press the home switch")
def guarded(fn):
"""Run fn(); if the switch guard or the controller stops the machine, tell the user loudly."""
from .grbl import GrblError, SwitchTripped
try:
return fn()
except SwitchTripped as e:
alert('Sand table stopped: home switch pressed',
f'{e}.\nThe motors were stopped at once. Check the arm, belts and carriage, then run '
f'`play` again; it homes first.')
sys.exit(1)
except GrblError as e:
alert('Sand table stopped', str(e))
sys.exit(1)
except OSError as e: # pyserial's SerialException is an OSError: the USB link dropped
alert('Sand table: USB connection lost',
f'{e}\nThe Uno restarts when it is plugged back in, so the table stops where it is. '
f'Run `play` again; it homes first.')
sys.exit(1)
def ask(prompt, choices):
while True:
a = input(f'{prompt} [{"/".join(choices)}] ').strip().lower()
if a and a[0] in choices:
return a[0]
def fmt_time(s):
s = int(s)
return f'{s // 3600}h{s % 3600 // 60:02d}m' if s >= 3600 else f'{s // 60}m{s % 60:02d}s'
def expand(items):
files = []
for it in items:
if os.path.isdir(it):
files += sorted(glob.glob(os.path.join(it, '**', '*.thr'), recursive=True))
else:
files += sorted(glob.glob(it)) or [it]
return files
def load_any(item):
"""A .thr file, or a generator written as gen:NAME[:key=value,key=value]."""
if item.startswith('erase:'): # erase only: end at the centre or the edge
r = 0.0 if item.endswith('centre') else 1.0
return 'erase', [(0.0, r), (0.0, r)]
if item.startswith('gen:'):
_, name, *rest = item.split(':', 2)
params = dict(kv.split('=', 1) for kv in rest[0].split(',')) if rest and rest[0] else {}
return name, patterns.generate(name, **params)
return os.path.basename(item), thr.load(item)
def run_job(c, lines, label, est):
"""Stream with progress; Ctrl-C pauses and asks what to do."""
t0 = time.monotonic()
def progress(i, n, st):
el = time.monotonic() - t0
left = max(0.0, est * (1 - i / n)) if n else 0
sys.stdout.write(f'\r {label}: {100 * i / n:5.1f}% {fmt_time(el)} elapsed, ~{fmt_time(left)} left ')
sys.stdout.flush()
from .grbl import RT_STATUS
i, n_err, last = 0, len(c.errors), 0.0
while i < len(lines):
try:
for j in range(i, len(lines)):
c.send(lines[j])
i = j + 1
if len(c.errors) > n_err:
c.hold()
raise SystemExit(f'\nthe controller rejected a line ({c.errors[-1]}): {lines[j]!r}; paused')
now = time.monotonic()
if now - last > 0.25:
c.realtime(RT_STATUS)
last = now
progress(i, len(lines), c.status)
c.wait_idle(on_status=lambda st: progress(len(lines), len(lines), st))
except KeyboardInterrupt:
c.hold()
print('\n paused (feed hold).')
if ask(' resume or stop?', 'rs') == 'r':
c.resume()
continue
print(' stopping ...')
c.stop()
raise SystemExit('stopped. The position is kept; run `play` again to continue with another pattern.')
print()
# ---------------------------------------------------------------- commands
def cmd_ports(cfg, args):
from serial.tools import list_ports
usb = [p for p in list_ports.comports() if p.vid is not None] # skip the built-in /dev/ttyS* ports
if not usb:
print('No USB serial ports. Is the Arduino plugged in?')
for p in usb:
tag = USB_IDS.get(p.vid, '')
print(f'{p.device:16s} {tag:8s} {p.description}')
FIRMWARE = os.path.join(os.path.dirname(config.HERE), 'firmware', 'grbl_v1.1h.20190825.hex')
def cmd_flash(cfg, args):
"""Put GRBL 1.1h on the Arduino Uno (replaces whatever program is on it)."""
import shutil
import subprocess
acli = shutil.which('arduino-cli') or os.path.expanduser('~/.local/bin/arduino-cli')
if not os.path.exists(acli):
sys.exit('arduino-cli not found. See "Flashing GRBL" in software/README.md.')
if cfg['controller']['type'] != 'grbl':
sys.exit('flash is for the Arduino Uno; the ESP32 board gets FluidNC instead (see pcb/README.md).')
dev = find_port(cfg, args.port)
print(f'This replaces the program on the Arduino at {dev} with GRBL 1.1h ({os.path.basename(FIRMWARE)}).')
print('Turn the 12 V supply off first; only the USB cable is needed.')
if not args.yes and ask('Flash it?', 'yn') != 'y':
return
for board in ('arduino:avr:uno', 'arduino:avr:nano:cpu=atmega328old'): # Uno, then old-bootloader clones
print(f' uploading as {board} ...', flush=True)
r = subprocess.run([acli, 'upload', '-b', board, '-p', dev, '--input-file', FIRMWARE],
capture_output=True, text=True)
if r.returncode == 0:
break
msg = (r.stderr or r.stdout).strip().splitlines()
print(' failed: ' + (msg[-1] if msg else 'unknown error'))
if 'ermission' in r.stderr:
sys.exit(f'No permission for {dev}. Run: sudo usermod -aG dialout $USER (then log out and back in)')
else:
sys.exit('Upload failed both ways. Check the USB cable (some are charge-only) and the port.')
c = None
for attempt in range(4): # the board restarts after the upload; give it a moment
time.sleep(1.5)
try:
c = connect(cfg, args)
break
except Exception as e:
print(f' not answering yet ({e}); retrying ...')
if c is None or 'grbl' not in c.banner.lower():
sys.exit('Uploaded, but GRBL did not answer. Try unplugging and replugging the USB cable, then `info`.')
c.command('$RST=*') # clear anything an older program left in the EEPROM
print(' GRBL is on the Arduino, with default settings. Next: `setup`, then `test`.')
c.close()
def cmd_web(cfg, args):
from .grbl import Controller
from .web import serve
check_floor(cfg)
if args.port:
cfg['serial']['port'] = args.port
try:
c, ok = connect(cfg, args), True
except SystemExit as e: # no Uno yet: start anyway, the page connects when it appears
print(f' {e}\n Starting the page anyway; it connects when the Uno is plugged in.')
c, ok = Controller(None, cfg['controller']['type']), False
serve(c, cfg, host=args.host, port=args.http_port, open_browser=not args.no_browser,
open_path='/jog' if args.cmd == 'ui' else '/', alert=alert, connected=ok)
def cmd_info(cfg, args):
c = connect(cfg, args)
st = c.query()
print(f' position: theta {st.wpos[0]:.2f} deg, rho {st.wpos[1]:.2f} mm (not homed yet after connecting)')
if cfg['controller']['type'] == 'grbl':
have = c.settings()
want = config.grbl_settings(cfg)
diff = {k: (have.get(k), v) for k, v in want.items() if _num(have.get(k)) != _num(v)}
print(f" GRBL settings: {len(have)} read, {len(diff)} differ from this machine's values")
for k, (h, w) in diff.items():
print(f' {k}: {h} -> {w}')
if diff:
print(' Run `setup` to write them.')
c.close()
def _num(v):
try:
return round(float(v), 3)
except (TypeError, ValueError):
return v
def cmd_setup(cfg, args):
if cfg['controller']['type'] != 'grbl':
sys.exit('setup writes GRBL settings; FluidNC is set up with pcb/fluidnc/config.yaml instead.')
c = connect(cfg, args)
have = c.settings()
want = config.grbl_settings(cfg)
diff = {k: v for k, v in want.items() if _num(have.get(k)) != _num(v)}
if not diff:
print(' GRBL settings already match.')
return
for k, v in diff.items():
print(f' {k}: {have.get(k)} -> {v}')
if not args.yes and ask(' write these to the Uno?', 'yn') != 'y':
return
for k, v in diff.items():
c.command(f'{k}={v}')
print(' written.')
c.close()
def cmd_test(cfg, args):
"""Check motor directions: +theta should turn the arm counter-clockwise seen from above,
+rho should move the magnet outward."""
c = connect(cfg, args)
c.unlock()
print('\nThe motors are free until the first move. Slide the magnet carriage to roughly the')
print('middle of the arm by hand, so a 15 mm move either way is safe.')
input('Press Enter when ready ... ')
c.command('G21')
c.command('G91')
c.command('G1 X90 F1200')
c.wait_idle()
ccw = ask('Did the arm turn a quarter turn COUNTER-clockwise (looking down through the glass)?', 'yn')
c.command('G1 X-90 F1200')
c.command('G1 Y15 F600')
c.wait_idle()
out = ask('Did the magnet move OUTWARD, away from the centre?', 'yn')
c.command('G1 Y-15 F600')
c.command('G90')
c.wait_idle()
flip = (1 if ccw == 'n' else 0) | (2 if out == 'n' else 0)
if not flip:
print('Both directions are right.')
elif cfg['controller']['type'] == 'grbl':
mask = int(float(c.settings().get('$3', '0'))) ^ flip
c.command(f'$3={mask}')
print(f'Flipped {"theta " if flip & 1 else ""}{"rho" if flip & 2 else ""} in GRBL ($3={mask}). '
'Run `test` again to confirm.')
else:
print('Add :low to the direction_pin of ' + ' and '.join(
a for a, b in (('x (theta)', 1), ('y (rho)', 2)) if flip & b) + ' in config.yaml on the board.')
c.close()
def cmd_home(cfg, args):
c = connect(cfg, args)
st = c.home_rho(cfg)
print(f' homed: rho {st.wpos[1]:.2f} mm')
c.close()
def plan_items(cfg, args, items):
pl = Planner(cfg, 0.0, cfg['homing']['switch_rho_mm'])
jobs = []
for item in items:
name, pts = load_any(item)
lines, segs, est = pl.program(pts, erase=not args.no_erase, speed=args.speed,
rotate_deg=args.rotate, mirror=args.mirror)
jobs.append((name, lines, segs, est))
return jobs
def cmd_play(cfg, args):
items = expand(args.items)
if not items:
sys.exit('nothing to play')
if args.dry_run or args.preview or args.gcode:
jobs = plan_items(cfg, args, items)
total = sum(j[3] for j in jobs)
for name, lines, segs, est in jobs:
print(f' {name}: {len(lines)} lines, about {fmt_time(est)}')
print(f' total about {fmt_time(total)}')
if args.preview:
from .preview import render, sheet
inv, R = cfg['machine']['invert_theta'], cfg['machine']['rho_max_mm']
if len(jobs) == 1:
render(jobs[0][2], R, args.preview, invert_theta=inv)
else:
sheet([(j[0], j[2]) for j in jobs], R, args.preview, invert_theta=inv)
print(f' preview: {args.preview}')
if args.gcode:
with open(args.gcode, 'w') as f:
f.write('\n'.join(HEADER + [l for j in jobs for l in j[1]]) + '\n')
print(f' G-code: {args.gcode}')
if args.dry_run:
return
check_floor(cfg)
c = connect(cfg, args)
guarded(lambda: _play(c, cfg, args, items))
c.close()
def _play(c, cfg, args, items):
if not args.no_home:
print(' homing rho ...', flush=True)
c.home_rho(cfg)
st = c.query()
pl = Planner(cfg, st.wpos[0], st.wpos[1])
for h in HEADER:
c.command(h)
c.arm_guard(cfg) # from here on, a press of the home switch stops everything
rnd = random.Random()
while True:
order = list(items)
if args.shuffle:
rnd.shuffle(order)
for n, item in enumerate(order, 1):
name, pts = load_any(item)
lines, segs, est = pl.program(pts, erase=not args.no_erase, speed=args.speed,
rotate_deg=args.rotate, mirror=args.mirror)
print(f'[{n}/{len(order)}] {name} (~{fmt_time(est)})')
run_job(c, lines, name, est)
if args.pause:
time.sleep(args.pause)
if not args.loop:
break
def cmd_gen(cfg, args):
if args.name == 'list' or not args.name:
for name, (fn, defaults) in patterns.GENERATORS.items():
print(f' {name:11s} {", ".join(f"{k}={v}" for k, v in defaults.items()) or "(no settings)"}')
print(f' {fn.__doc__.strip().splitlines()[0]}')
return
params = dict(kv.split('=', 1) for kv in args.params)
pts = patterns.generate(args.name, **params)
print(f' {args.name}: {len(pts)} points')
if args.save:
thr.save(args.save, pts, header=f'sandtable generator {args.name} {" ".join(args.params)}')
print(f' saved {args.save}')
item = 'gen:' + args.name + (':' + ','.join(args.params) if args.params else '')
if args.play or args.preview or args.dry_run or args.gcode:
args.items = [item]
cmd_play(cfg, args)
def cmd_erase(cfg, args):
args.items = ['erase:' + args.to]
args.no_erase = False
cmd_play(cfg, args)
def cmd_move(cfg, args):
check_floor(cfg)
c = connect(cfg, args)
def go():
if not args.no_home:
c.home_rho(cfg)
rho = min(max(args.rho, cfg['machine']['rho_min_mm']), cfg['machine']['rho_max_mm'])
c.command('G90')
c.arm_guard(cfg)
c.command(f'G1 X{args.theta:.3f} Y{rho:.3f} F600')
c.wait_idle()
guarded(go)
c.close()
# ---------------------------------------------------------------- main
def play_options(p):
p.add_argument('--no-erase', action='store_true', help='skip the erase spiral before each pattern')
p.add_argument('--speed', type=float, help='ball speed in mm/s (default from sandtable.toml)')
p.add_argument('--rotate', type=float, default=0.0, help='turn patterns by this many degrees')
p.add_argument('--mirror', action='store_true', help='mirror patterns')
p.add_argument('--loop', action='store_true', help='repeat the list forever')
p.add_argument('--shuffle', action='store_true', help='random order')
p.add_argument('--pause', type=float, default=0, help='seconds to wait between patterns')
p.add_argument('--no-home', action='store_true', help="don't home rho first (only if nothing was reset)")
p.add_argument('--dry-run', action='store_true', help="don't connect; just plan (with --preview / --gcode)")
p.add_argument('--preview', metavar='PNG', help='draw what the ball will trace')
p.add_argument('--gcode', metavar='FILE', help='also write the G-code to a file')
def main(argv=None):
ap = argparse.ArgumentParser(prog='sandtable', description='Drive the sand table over USB.')
ap.add_argument('--config', help='settings file (default: software/sandtable.toml)')
ap.add_argument('--port', help='serial port, e.g. /dev/ttyACM0 (default: auto)')
sub = ap.add_subparsers(dest='cmd', required=True)
sub.add_parser('ports', help='list serial ports')
sub.add_parser('info', help='connect and show the controller and its settings')
for name, text in (('web', 'the web page: a library of designs to play, plus the jog panel'),
('ui', 'the same web page, opened at the jog panel: home, jog, calibrate')):
u = sub.add_parser(name, help=text)
u.add_argument('--http-port', type=int, default=8765)
u.add_argument('--host', default='127.0.0.1',
help='127.0.0.1 = this computer only (default); 0.0.0.0 = also phones on your network (no password)')
u.add_argument('--no-browser', action='store_true')
fl = sub.add_parser('flash', help='put GRBL 1.1h on the Arduino Uno (one time)')
fl.add_argument('--yes', action='store_true')
s = sub.add_parser('setup', help="write this machine's GRBL settings to the Uno")
s.add_argument('--yes', action='store_true')
sub.add_parser('test', help='check the motor directions (moves the arm a little)')
sub.add_parser('home', help='home rho against the switch')
p = sub.add_parser('play', help='play .thr files, folders of them, or gen:NAME[:k=v,...] patterns')
p.add_argument('items', nargs='+')
play_options(p)
g = sub.add_parser('gen', help='make a pattern with a built-in generator (`gen list` shows them)')
g.add_argument('name', nargs='?', default='list')
g.add_argument('params', nargs='*', help='settings as key=value')
g.add_argument('--save', metavar='THR', help='save it as a .thr file')
g.add_argument('--play', action='store_true', help='draw it now')
play_options(g)
e = sub.add_parser('erase', help='spiral-erase the whole tray')
e.add_argument('--to', choices=('centre', 'edge'), default='centre', help='where the ball ends up')
play_options(e)
m = sub.add_parser('move', help='move the ball to theta (deg) and rho (mm)')
m.add_argument('theta', type=float)
m.add_argument('rho', type=float)
m.add_argument('--no-home', action='store_true')
args = ap.parse_args(argv)
cfg = config.load(args.config)
{'ports': cmd_ports, 'info': cmd_info, 'flash': cmd_flash, 'ui': cmd_web, 'web': cmd_web, 'setup': cmd_setup, 'test': cmd_test, 'home': cmd_home,
'play': cmd_play, 'gen': cmd_gen, 'erase': cmd_erase, 'move': cmd_move}[args.cmd](cfg, args)
+116
View File
@@ -0,0 +1,116 @@
"""Machine settings. Defaults below; sandtable.toml (next to the software folder's README) overrides them."""
import copy
import os
import tomllib
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PATH = os.path.join(os.path.dirname(HERE), 'sandtable.toml')
DEFAULTS = {
'serial': {
'port': 'auto', # 'auto' picks the only Arduino / USB-serial port, or e.g. '/dev/ttyACM0'
'baud': 115200,
},
'controller': {
'type': 'grbl', # 'grbl' (Arduino Uno + CNC shield) or 'fluidnc' (the custom ESP32 board)
},
'machine': {
'rho_max_mm': 133.0, # ball reach from the centre (design: rho 0-133 mm)
'rho_min_mm': 3.0, # patterns stay this far out: 1 mm past where the home switch trips
# (homing.switch_rho_mm), so they never press it. A press while a
# pattern runs then means the carriage lost its place: it stops at once.
'theta_steps_per_deg': 71.111, # 200 steps x 32 microsteps x 80T/20T / 360
'rho_steps_per_mm': 160.0, # 200 x 32 / (20T GT2 = 40 mm)
'invert_theta': False, # flip if patterns come out mirrored (set by `test`)
'theta_offset_deg': 0.0, # rotate every pattern by this much
},
'motion': {
'ball_speed_mm_s': 25.0, # drawing speed of the ball across the sand
'max_theta_deg_s': 60.0, # arm spin limit (10 rpm)
'max_rho_mm_s': 25.0, # carriage limit
'theta_accel_deg_s2': 120.0,
'rho_accel_mm_s2': 100.0,
'max_segment_mm': 1.5, # long moves are split so the speed stays even
'max_segment_deg': 3.0,
'min_segment_mm': 0.3, # closer points are merged (very dense .thr files)
},
'homing': {
'seek_mm_s': 6.0,
'feed_mm_s': 1.0,
'pulloff_mm': 2.0,
'switch_rho_mm': 2.0, # rho where the switch trips; Y0 is the centre
'max_travel_mm': 145.0,
},
'erase': {
'enabled': True,
'pitch_mm': 7.0, # spiral spacing; a bit less than the 9.5 mm ball
'speed_mm_s': 35.0,
},
}
# GRBL settings `setup` writes to the Uno (X = theta in degrees, Y = rho in mm)
def grbl_settings(cfg):
m, mo = cfg['machine'], cfg['motion']
return {
'$0': '10', # step pulse, us (DRV8825 needs 1.9)
'$1': '255', # keep the motors powered between moves, so the arm can't drift
'$2': '0',
'$4': '0',
'$5': '0', # limit pin: switch closes to GND, internal pull-up
'$10': '1', # status reports show machine position
'$11': '0.010',
'$13': '0',
'$20': '0', # no soft limits (theta is unlimited)
'$21': '0', # no GRBL hard limits: homing presses the switch on purpose. This program
# watches the switch while patterns run and stops the machine instead.
'$22': '0', # GRBL's own homing off; this program homes rho itself
'$100': f"{m['theta_steps_per_deg']:.3f}",
'$101': f"{m['rho_steps_per_mm']:.3f}",
'$110': f"{mo['max_theta_deg_s'] * 60:.0f}",
'$111': f"{mo['max_rho_mm_s'] * 60:.0f}",
'$120': f"{mo['theta_accel_deg_s2']:.0f}",
'$121': f"{mo['rho_accel_mm_s2']:.0f}",
}
def _merge(base, over):
for k, v in over.items():
if isinstance(v, dict) and isinstance(base.get(k), dict):
_merge(base[k], v)
else:
base[k] = v
return base
def load(path=None):
cfg = copy.deepcopy(DEFAULTS)
path = path or DEFAULT_PATH
if os.path.exists(path):
with open(path, 'rb') as f:
_merge(cfg, tomllib.load(f))
cfg['_path'] = path
return cfg
def set_value(path, section, key, value):
"""Set one value in the TOML file, keeping the rest of the file as written."""
lines = open(path).read().splitlines() if os.path.exists(path) else []
val = ('true' if value else 'false') if isinstance(value, bool) else (
f'"{value}"' if isinstance(value, str) else repr(value))
out, in_sec, done = [], False, False
for ln in lines:
s = ln.strip()
if s.startswith('['):
if in_sec and not done:
out.append(f'{key} = {val}')
done = True
in_sec = s == f'[{section}]'
elif in_sec and s.split('=')[0].strip() == key:
ln, done = f'{key} = {val}', True
out.append(ln)
if not done:
if not in_sec:
out += ['', f'[{section}]']
out.append(f'{key} = {val}')
with open(path, 'w') as f:
f.write('\n'.join(out) + '\n')
+404
View File
@@ -0,0 +1,404 @@
"""Talk to GRBL 1.1 (Arduino Uno) or FluidNC over USB serial.
Streaming uses GRBL's character-counting method: keep sending lines while the bytes not yet
acknowledged fit in the controller's 128-byte receive buffer. That keeps the planner full, so
the ball doesn't stutter on short moves.
"""
import re
import time
RX_BUFFER = 127
STATUS_RE = re.compile(r'<([^|>]+)(.*)>')
# real-time commands (single bytes, acted on at once, never queued)
RT_STATUS = b'?'
RT_HOLD = b'!'
RT_RESUME = b'~'
RT_RESET = b'\x18'
RT_JOG_CANCEL = b'\x85'
class GrblError(RuntimeError):
pass
class Aborted(GrblError):
"""A wait was cut short because `abort` was set (a STOP from the web page or the jog panel)."""
class SwitchTripped(GrblError):
"""The home switch was pressed while a pattern was running: the carriage has lost its place."""
class Status:
def __init__(self):
self.state = 'Unknown' # Idle, Run, Hold, Jog, Alarm, ...
self.sub = '' # e.g. Hold:0 = stopped, Hold:1 = still slowing down
self.mpos = (0.0, 0.0, 0.0)
self.wco = (0.0, 0.0, 0.0)
self.pins = ''
self.when = 0.0
@property
def wpos(self):
return tuple(m - w for m, w in zip(self.mpos, self.wco))
def parse(self, line):
m = STATUS_RE.match(line)
if not m:
return False
state = m.group(1)
self.state, _, self.sub = state.partition(':')
self.pins = ''
wpos = None
for field in m.group(2).split('|'):
k, _, v = field.partition(':')
if k in ('MPos', 'WPos', 'WCO'):
vals = (tuple(float(x) for x in v.split(',')) + (0.0, 0.0, 0.0))[:3]
if k == 'MPos':
self.mpos = vals
elif k == 'WCO':
self.wco = vals
else:
wpos = vals
elif k == 'Pn':
self.pins = v
if wpos is not None:
self.mpos = tuple(a + b for a, b in zip(wpos, self.wco))
self.when = time.monotonic()
return True
class Controller:
"""One open connection. `port` is a pyserial-like object (the tests pass a simulator)."""
def __init__(self, port, kind='grbl', log=print):
self.port = port
self.kind = kind
self.log = log
self.status = Status()
self.pending = [] # byte lengths of lines sent but not yet acknowledged
self.errors = []
self.banner = ''
self._buf = b''
self.abort = False # set from another thread (the jog panel's STOP) to end a wait
self.guard = None # None, or 'release' / 'armed' while patterns run (see arm_guard)
self.guard_stuck_mm = 0.0
self.status_every = 0.08 # while guarded, ask for a status report this often (s)
self._presses = 0
self._last_q = 0.0
# ------------------------------------------------------------ open / close
@classmethod
def open(cls, device, baud=115200, kind='grbl', log=print):
import serial
p = serial.Serial(device, baud, timeout=0.02, write_timeout=2)
c = cls(p, kind, log)
c.wake()
return c
def reopen(self, port):
"""Carry on with a new port (after the USB link dropped and came back): fresh state, then
wait for the Uno's start-up banner. The position is lost, so home again before drawing."""
try:
if self.port:
self.port.close()
except Exception:
pass
self.port = port
self._buf, self.banner = b'', ''
self.pending.clear()
self.guard, self.abort = None, False
self.status = Status()
self.wake()
def wake(self, wait=3.0):
"""Opening the port resets an Uno (about 2 s); wait for its start-up banner.
(No blank lines are sent to wake it: their late 'ok's would upset the byte counting.)"""
t_end = time.monotonic() + wait
seen = None
while time.monotonic() < t_end:
for line in self._lines():
if 'grbl' in line.lower() or 'fluidnc' in line.lower():
self.banner, seen = line, time.monotonic()
if seen and time.monotonic() - seen > 0.3:
break
self.port.reset_input_buffer()
self._buf = b''
self.pending.clear()
self.query()
def close(self):
self.port.close()
# ------------------------------------------------------------ low level
def _lines(self):
data = self.port.read(self.port.in_waiting or 1)
if data:
self._buf += data
out = []
while b'\n' in self._buf:
raw, self._buf = self._buf.split(b'\n', 1)
line = raw.decode('ascii', 'replace').strip()
if line:
out.append(line)
return out
def poll(self):
"""Read whatever has arrived and act on it."""
if self.guard and time.monotonic() - self._last_q > self.status_every:
self.realtime(RT_STATUS)
for line in self._lines():
if line == 'ok':
if self.pending:
self.pending.pop(0)
elif line.startswith('error:'):
if self.pending:
self.pending.pop(0)
self.errors.append(line)
elif line.startswith('<'):
self.status.parse(line)
if self.guard:
self._check_guard()
elif line.startswith('ALARM'):
self.status.state = 'Alarm'
raise GrblError(f'{line}: the controller stopped. Check the table, then run `home` again.')
elif line.startswith('[MSG:'):
self.log(f'controller says {line}')
elif 'grbl' in line.lower():
self.banner = line
def realtime(self, b):
if b == RT_STATUS:
self._last_q = time.monotonic()
self.port.write(b)
# ------------------------------------------------------------ switch guard
def arm_guard(self, cfg):
"""Stop at once if the home switch is pressed while a pattern runs.
Patterns stay at least `rho_min_mm` out, 1 mm past where the switch trips, so they never
press it. A press can then only mean the carriage isn't where the controller thinks:
missed steps, a slipping belt, or something in the way. Left alone, it would grind into
the end stop, and later patterns would run off the other end of the arm.
Right after homing the switch may still be pressed; that's fine until it has been seen
released once (or the carriage is clearly past it, which means the switch is stuck)."""
self.guard = 'release'
self.guard_stuck_mm = cfg['homing']['switch_rho_mm'] + 5.0
self._presses = 0
def disarm_guard(self):
self.guard = None
def _check_guard(self):
st = self.status
if st.state in ('Alarm', 'Home'):
return
pressed = 'Y' in st.pins
if self.guard == 'release':
if not pressed:
self.guard = 'armed'
elif st.wpos[1] > self.guard_stuck_mm:
self._trip(f'the home switch is still pressed at rho {st.wpos[1]:.1f} mm, so it is stuck '
f'or its wiring is shorted')
return
# two reports in a row (about 0.1 s apart), so one electrical blip doesn't stop a pattern
self._presses = self._presses + 1 if pressed else 0
if self._presses >= 2:
self._trip(f'the home switch was pressed at rho {st.wpos[1]:.1f} mm, where the carriage should never '
f'reach it. The carriage has lost its place (missed steps, a slipping belt, or '
f'something blocking it)')
def _trip(self, why):
self.realtime(RT_RESET) # stops the motors at once; the position is lost anyway
self.guard = None
self.status.state = 'Alarm'
self.pending.clear()
raise SwitchTripped(why)
def query(self, timeout=1.0):
"""Ask for a status report and wait for it."""
before = self.status.when
self.realtime(RT_STATUS)
t_end = time.monotonic() + timeout
while time.monotonic() < t_end:
self.poll()
if self.status.when != before:
return self.status
raise GrblError('no status report from the controller (wrong port, or not GRBL?)')
def send(self, line):
"""Queue one line, waiting while the controller's receive buffer is full."""
data = (line.strip() + '\n').encode('ascii')
while sum(self.pending) + len(data) > RX_BUFFER:
if self.abort:
raise Aborted('stopped')
self.poll()
self.port.write(data)
self.pending.append(len(data))
def command(self, line, timeout=10.0):
"""Send one line and wait for its ok (raises on an error)."""
n_err = len(self.errors)
self.send(line)
t_end = time.monotonic() + timeout
while self.pending:
if self.abort:
raise Aborted('stopped')
self.poll()
if time.monotonic() > t_end:
raise GrblError(f'no answer to {line!r}')
if len(self.errors) > n_err:
raise GrblError(f'{line!r} -> {self.errors[-1]}')
def text(self, line, timeout=3.0):
"""Send a $ query (only while idle) and collect the lines before its ok."""
self.port.write((line + '\n').encode())
out, t_end = [], time.monotonic() + timeout
while time.monotonic() < t_end:
for ln in self._lines():
if ln == 'ok':
return out
if ln.startswith('error'):
raise GrblError(f'{line} -> {ln}')
if not ln.startswith('<'):
out.append(ln)
raise GrblError(f'no answer to {line!r}')
def settings(self):
"""$$ -> {'$100': '71.111', ...}"""
out = {}
for ln in self.text('$$'):
k, _, v = ln.partition('=')
if k.startswith('$'):
out[k] = v.split()[0] if v else ''
return out
def wait_idle(self, every=0.15, on_status=None):
"""Wait until everything sent is acknowledged and the machine has stopped."""
while True:
if self.abort:
raise Aborted('stopped')
st = self.query()
if on_status:
on_status(st)
if not self.pending and st.state == 'Idle':
return st
self.pause(every)
def pause(self, secs):
"""Wait, but keep reading the controller (and, while guarded, keep checking the switch)."""
t_end = time.monotonic() + secs
while time.monotonic() < t_end:
self.poll()
time.sleep(0.005)
# ------------------------------------------------------------ jobs
def stream(self, lines, on_progress=None, status_every=0.25):
"""Send G-code lines. on_progress(sent, total, status) is called a few times a second."""
total, last, n_err = len(lines), 0.0, len(self.errors)
for i, line in enumerate(lines, 1):
self.send(line)
if len(self.errors) > n_err:
self.hold()
raise GrblError(f'the controller rejected a line ({self.errors[-1]}); stopped')
now = time.monotonic()
if now - last > status_every:
self.realtime(RT_STATUS)
last = now
if on_progress:
on_progress(i, total, self.status)
self.wait_idle(on_status=(lambda st: on_progress(total, total, st)) if on_progress else None)
def hold(self):
self.realtime(RT_HOLD)
def resume(self):
self.realtime(RT_RESUME)
def stop(self):
"""Stop cleanly: feed hold, wait until the motion has ended (Hold:0), then reset to throw
away the moves still queued. Resetting while stopped keeps the machine position, but GRBL
clears the G92 offset that homing set, so it is put back: no re-homing needed."""
self.abort = False
guard, self.guard = self.guard, None
self.realtime(RT_HOLD)
t_end = time.monotonic() + 15
while time.monotonic() < t_end:
st = self.query()
if st.state == 'Idle' or (st.state == 'Hold' and st.sub == '0'):
break
time.sleep(0.05)
if st.state == 'Idle' and not self.pending:
return st # nothing moving and nothing queued: no reset needed
x, y = st.wpos[0], st.wpos[1]
self.realtime(RT_RESET)
time.sleep(0.5)
t_end = time.monotonic() + 3
while time.monotonic() < t_end:
self._lines()
time.sleep(0.05)
self.pending.clear()
if self.query().state != 'Alarm':
self.command(f'G92 X{x:.3f} Y{y:.3f}')
if guard:
self.guard = 'release'
self._presses = 0
return self.query()
def unlock(self):
if self.query().state == 'Alarm':
self.command('$X')
# ------------------------------------------------------------ homing (rho only)
def home_rho(self, cfg, log=print):
"""Home the carriage against the switch at the centre end of the arm, then set
theta = 0 and rho = the switch position.
GRBL on the Uno: done from here with jog moves and the switch state that GRBL puts in
every status report ('Pn:Y'), so the stock firmware needs no changes.
FluidNC: its own $H (configured in its config.yaml)."""
h = cfg['homing']
self.disarm_guard()
if self.kind == 'fluidnc':
self.command('$H', timeout=90)
self.command('G92 X0')
return self.wait_idle()
self.unlock()
self.command('G21')
self.command('G90')
def pressed():
return 'Y' in self.query().pins
if pressed():
log('the switch is already pressed; moving off it first')
self.command(f"$J=G91 G21 Y{h['pulloff_mm'] + 3:.2f} F{h['seek_mm_s'] * 60:.0f}")
self.wait_idle()
if pressed():
raise GrblError('the home switch stays pressed: check its wiring (COM and NO to the Y- pins)')
for speed, final in ((h['seek_mm_s'], False), (h['feed_mm_s'], True)):
self.command(f"$J=G91 G21 Y-{h['max_travel_mm']:.1f} F{speed * 60:.0f}")
t_end = time.monotonic() + h['max_travel_mm'] / speed + 5
found = False
while time.monotonic() < t_end:
if self.abort:
raise Aborted('stopped')
if pressed():
self.realtime(RT_JOG_CANCEL)
found = True
break
time.sleep(0.005)
if not found:
self.realtime(RT_JOG_CANCEL)
self.wait_idle()
raise GrblError('home switch not found: is the 12 V supply on? Without it the motors stay still. '
'Otherwise check the switch wiring, and that +Y moves the magnet outward (run `test`)')
self.wait_idle()
if not final:
self.command(f"$J=G91 G21 Y{h['pulloff_mm']:.2f} F{h['seek_mm_s'] * 60:.0f}")
self.wait_idle()
# G92 sets the position without writing the Uno's EEPROM (G10 would, on every homing)
self.command(f"G92 X0 Y{h['switch_rho_mm']:.3f}")
self.command('G90')
return self.wait_idle()
+456
View File
@@ -0,0 +1,456 @@
"""Line drawings -> one continuous theta-rho path, for logos and pictures.
The ball can't lift, so every stroke of a drawing has to be joined into one line. To get from
the end of one stroke to the start of the next, the path runs back along lines it has already
drawn (that leaves no new mark) and only crosses fresh sand where it must, as short as possible.
Input is an SVG file (paths, lines, polylines, polygons, rectangles, circles and ellipses; fills
are ignored, every shape is drawn as its outline) or a list of strokes. Draw the picture as you
want it in the sand: centre lines for thin parts, outlines for solid ones.
"""
import heapq
import math
import re
import xml.etree.ElementTree as ET
from .patterns import densify_xy, xy_to_polar
# ---------------------------------------------------------------- SVG reading
NUM = r'[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?'
TOKEN = re.compile(r'[MmLlHhVvCcSsQqTtAaZz]|' + NUM)
SKIP = {'defs', 'clipPath', 'mask', 'symbol', 'marker', 'pattern', 'title', 'desc', 'metadata', 'style', 'text'}
def _mul(m, n):
a, b, c, d, e, f = m
a2, b2, c2, d2, e2, f2 = n
return (a * a2 + c * b2, b * a2 + d * b2, a * c2 + c * d2, b * c2 + d * d2, a * e2 + c * f2 + e, b * e2 + d * f2 + f)
def _apply(m, p):
a, b, c, d, e, f = m
return (a * p[0] + c * p[1] + e, b * p[0] + d * p[1] + f)
def parse_transform(text):
m = (1, 0, 0, 1, 0, 0)
for name, args in re.findall(r'(\w+)\s*\(([^)]*)\)', text or ''):
v = [float(x) for x in re.findall(NUM, args)]
if name == 'translate':
t = (1, 0, 0, 1, v[0], v[1] if len(v) > 1 else 0)
elif name == 'scale':
t = (v[0], 0, 0, v[1] if len(v) > 1 else v[0], 0, 0)
elif name == 'rotate':
r = math.radians(v[0])
t = (math.cos(r), math.sin(r), -math.sin(r), math.cos(r), 0, 0)
if len(v) == 3:
t = _mul(_mul((1, 0, 0, 1, v[1], v[2]), t), (1, 0, 0, 1, -v[1], -v[2]))
elif name == 'matrix' and len(v) == 6:
t = tuple(v)
elif name == 'skewX':
t = (1, 0, math.tan(math.radians(v[0])), 1, 0, 0)
elif name == 'skewY':
t = (1, math.tan(math.radians(v[0])), 0, 1, 0, 0)
else:
continue
m = _mul(m, t)
return m
def _arc(p0, rx, ry, phi, large, sweep, p1, tol):
"""SVG elliptical arc as points (endpoint to centre form, SVG spec F.6)."""
if rx == 0 or ry == 0 or p0 == p1:
return [p1]
rx, ry = abs(rx), abs(ry)
cp, sp = math.cos(phi), math.sin(phi)
dx, dy = (p0[0] - p1[0]) / 2, (p0[1] - p1[1]) / 2
x1, y1 = cp * dx + sp * dy, -sp * dx + cp * dy
lam = x1 * x1 / (rx * rx) + y1 * y1 / (ry * ry)
if lam > 1:
rx, ry = rx * math.sqrt(lam), ry * math.sqrt(lam)
num = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
den = rx * rx * y1 * y1 + ry * ry * x1 * x1
k = math.sqrt(max(0.0, num / den)) if den else 0.0
if large == sweep:
k = -k
cx1, cy1 = k * rx * y1 / ry, -k * ry * x1 / rx
cx, cy = cp * cx1 - sp * cy1 + (p0[0] + p1[0]) / 2, sp * cx1 + cp * cy1 + (p0[1] + p1[1]) / 2
def ang(ux, uy, vx, vy):
return math.atan2(ux * vy - uy * vx, ux * vx + uy * vy)
t1 = ang(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)
dt = ang((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)
if not sweep and dt > 0:
dt -= 2 * math.pi
elif sweep and dt < 0:
dt += 2 * math.pi
n = max(4, int(math.ceil(abs(dt) * max(rx, ry) / tol)))
out = []
for i in range(1, n + 1):
t = t1 + dt * i / n
ex, ey = rx * math.cos(t), ry * math.sin(t)
out.append((cp * ex - sp * ey + cx, sp * ex + cp * ey + cy))
out[-1] = p1
return out
def _bezier(pts, tol):
L = sum(math.dist(a, b) for a, b in zip(pts, pts[1:]))
n = max(2, min(400, int(math.ceil(L / tol))))
out = []
for i in range(1, n + 1):
t = i / n
q = list(pts)
while len(q) > 1:
q = [((1 - t) * a[0] + t * b[0], (1 - t) * a[1] + t * b[1]) for a, b in zip(q, q[1:])]
out.append(q[0])
return out
def path_polylines(d, tol=0.5):
"""An SVG path's d attribute -> list of polylines (one per subpath)."""
toks = TOKEN.findall(d)
i, cmd = 0, None
cur = start = (0.0, 0.0)
last_ctrl = None
lines, poly = [], []
def num():
nonlocal i
v = float(toks[i])
i += 1
return v
def flag():
# arc flags may be written packed together ("a5 5 0 105 5")
nonlocal i
t = toks[i]
if t in ('0', '1'):
i += 1
return int(t)
toks[i] = t[1:]
return int(t[0])
while i < len(toks):
t = toks[i]
if re.match(r'[A-Za-z]', t):
cmd = t
i += 1
if cmd in 'Zz':
if poly:
poly.append(start)
lines.append(poly)
poly, cur, last_ctrl = [], start, None
continue
elif cmd is None:
i += 1
continue
rel = cmd.islower()
C = cmd.upper()
ox, oy = cur if rel else (0.0, 0.0)
if C == 'M':
if len(poly) > 1:
lines.append(poly)
cur = (ox + num(), oy + num())
start, poly, last_ctrl = cur, [cur], None
cmd = 'l' if rel else 'L' # further pairs are line-tos
continue
if not poly:
poly = [cur]
if C == 'L':
cur = (ox + num(), oy + num())
poly.append(cur)
last_ctrl = None
elif C == 'H':
cur = ((cur[0] if rel else 0.0) + num(), cur[1])
poly.append(cur)
last_ctrl = None
elif C == 'V':
cur = (cur[0], (cur[1] if rel else 0.0) + num())
poly.append(cur)
last_ctrl = None
elif C == 'C':
c1 = (ox + num(), oy + num())
c2 = (ox + num(), oy + num())
p = (ox + num(), oy + num())
poly += _bezier([cur, c1, c2, p], tol)
cur, last_ctrl = p, ('C', c2)
elif C == 'S':
c1 = (2 * cur[0] - last_ctrl[1][0], 2 * cur[1] - last_ctrl[1][1]) if last_ctrl and last_ctrl[0] == 'C' else cur
c2 = (ox + num(), oy + num())
p = (ox + num(), oy + num())
poly += _bezier([cur, c1, c2, p], tol)
cur, last_ctrl = p, ('C', c2)
elif C == 'Q':
c1 = (ox + num(), oy + num())
p = (ox + num(), oy + num())
poly += _bezier([cur, c1, p], tol)
cur, last_ctrl = p, ('Q', c1)
elif C == 'T':
c1 = (2 * cur[0] - last_ctrl[1][0], 2 * cur[1] - last_ctrl[1][1]) if last_ctrl and last_ctrl[0] == 'Q' else cur
p = (ox + num(), oy + num())
poly += _bezier([cur, c1, p], tol)
cur, last_ctrl = p, ('Q', c1)
elif C == 'A':
rx, ry, rot = num(), num(), num()
large, sweep = flag(), flag()
p = (ox + num(), oy + num())
poly += _arc(cur, rx, ry, math.radians(rot), large, sweep, p, tol)
cur, last_ctrl = p, None
else:
i += 1
if len(poly) > 1:
lines.append(poly)
return lines
def _ellipse(cx, cy, rx, ry, tol):
n = max(12, int(math.ceil(2 * math.pi * max(rx, ry) / tol)))
return [(cx + rx * math.cos(2 * math.pi * i / n), cy + ry * math.sin(2 * math.pi * i / n)) for i in range(n + 1)]
def svg_strokes(text):
"""Every shape in an SVG document as polylines, in the document's coordinates (y down)."""
root = ET.fromstring(text)
vb = [float(v) for v in re.findall(NUM, root.get('viewBox', ''))]
size = max(vb[2], vb[3]) if len(vb) == 4 else max(
float((re.findall(NUM, root.get('width', '')) or ['200'])[0]),
float((re.findall(NUM, root.get('height', '')) or ['200'])[0]))
tol = size / 500.0
out = []
def f(el, k, default=0.0):
v = re.findall(NUM, el.get(k, ''))
return float(v[0]) if v else default
def walk(el, m):
tag = el.tag.split('}')[-1]
if tag in SKIP:
return
m = _mul(m, parse_transform(el.get('transform')))
polys = []
if tag == 'path':
polys = path_polylines(el.get('d', ''), tol)
elif tag == 'line':
polys = [[(f(el, 'x1'), f(el, 'y1')), (f(el, 'x2'), f(el, 'y2'))]]
elif tag in ('polyline', 'polygon'):
v = [float(x) for x in re.findall(NUM, el.get('points', ''))]
p = list(zip(v[0::2], v[1::2]))
if tag == 'polygon' and p:
p.append(p[0])
polys = [p]
elif tag == 'rect':
x, y, w, h = f(el, 'x'), f(el, 'y'), f(el, 'width'), f(el, 'height')
polys = [[(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]]
elif tag == 'circle':
r = f(el, 'r')
polys = [_ellipse(f(el, 'cx'), f(el, 'cy'), r, r, tol)]
elif tag == 'ellipse':
polys = [_ellipse(f(el, 'cx'), f(el, 'cy'), f(el, 'rx'), f(el, 'ry'), tol)]
for p in polys:
if len(p) > 1:
out.append([_apply(m, q) for q in p])
for ch in el:
walk(ch, m)
walk(root, (1, 0, 0, 1, 0, 0))
return out
# ---------------------------------------------------------------- joining strokes into one line
def normalise(strokes, fit=0.9, flip_y=True):
"""Centre the drawing and scale it to fit inside a circle of radius `fit` (the table is 1)."""
pts = [p for s in strokes for p in s]
x0, x1 = min(p[0] for p in pts), max(p[0] for p in pts)
y0, y1 = min(p[1] for p in pts), max(p[1] for p in pts)
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
R = max(math.hypot(p[0] - cx, p[1] - cy) for p in pts) or 1.0
k = fit / R
sy = -k if flip_y else k
return [[((x - cx) * k, (y - cy) * sy) for x, y in s] for s in strokes]
class _Graph:
"""The lines drawn so far: points about `snap` apart, joined where the drawing joins or crosses."""
def __init__(self, snap):
self.snap = snap
self.pos, self.adj, self.cells = [], [], {}
def node(self, p):
cx, cy = int(math.floor(p[0] / self.snap)), int(math.floor(p[1] / self.snap))
near = []
for gx in (cx - 1, cx, cx + 1):
for gy in (cy - 1, cy, cy + 1):
for j in self.cells.get((gx, gy), ()):
dd = math.dist(self.pos[j], p)
if dd < self.snap * 0.5:
return j # the same point
if dd < self.snap * 1.5:
near.append((j, dd))
i = len(self.pos)
self.pos.append(p)
self.adj.append([])
self.cells.setdefault((cx, cy), []).append(i)
for j, dd in near: # lines that meet or cross here
self.adj[i].append((j, dd))
self.adj[j].append((i, dd))
return i
def add_line(self, pts):
prev = None
for p in pts:
i = self.node(p)
if prev is not None and prev != i:
w = math.dist(self.pos[prev], self.pos[i])
self.adj[prev].append((i, w))
self.adj[i].append((prev, w))
prev = i
return prev
def dijkstra(self, src):
dist, prev = {src: 0.0}, {}
heap = [(0.0, src)]
while heap:
d, u = heapq.heappop(heap)
if d > dist[u]:
continue
for v, w in self.adj[u]:
nd = d + w
if nd < dist.get(v, 1e18):
dist[v], prev[v] = nd, u
heapq.heappush(heap, (nd, v))
return dist, prev
def join_strokes(strokes, jump_cost=4.0, step=0.005, snap=0.006, stats=None):
"""Order the strokes and join them into one line (unit-disc coordinates). It starts and ends
at the centre or the edge, whichever is closer to the drawing (a pattern must, so the erase
spiral can hand over to it). Going back over drawn lines costs its length; crossing fresh
sand costs `jump_cost` times its length."""
strokes = [densify_xy(s, step) for s in strokes if len(s) > 1 and sum(math.dist(a, b) for a, b in zip(s, s[1:])) > step]
g = _Graph(snap)
cur_node = None
drawn = retrace = jump = 0.0
todo = list(range(len(strokes)))
def entries(k):
s = strokes[k]
if math.dist(s[0], s[-1]) < 2 * snap: # closed: start anywhere on it
every = max(1, int(0.03 / step))
return [(k, j, 'loop') for j in range(0, len(s) - 1, every)]
return [(k, 0, 'fwd'), (k, len(s) - 1, 'rev')]
# start: the centre, or the edge next to the stroke end that is nearest to it
ends = [strokes[k][j] for k in todo for (_, j, _) in entries(k)]
p_in = min(ends, key=lambda p: math.hypot(*p))
p_out = max(ends, key=lambda p: math.hypot(*p))
if 1 - math.hypot(*p_out) < 2 * math.hypot(*p_in): # a short line at the edge shows less than one in the middle
r = math.hypot(*p_out)
start = (p_out[0] / r, p_out[1] / r)
else:
start = (0.0, 0.0)
path = [start]
def best_route(targets):
"""Cheapest way from where the ball is to one of the target points."""
if cur_node is None:
dist, prevs = {}, {}
else:
dist, prevs = g.dijkstra(cur_node)
here = path[-1]
cell = 0.05
grid = {}
for v in dist:
p = g.pos[v]
grid.setdefault((int(math.floor(p[0] / cell)), int(math.floor(p[1] / cell))), []).append(v)
best = None
for t, p in targets:
c0 = jump_cost * math.dist(here, p)
cand = (c0, None, t, p)
r = c0 / jump_cost
if dist:
gx0, gx1 = int(math.floor((p[0] - r) / cell)), int(math.floor((p[0] + r) / cell))
gy0, gy1 = int(math.floor((p[1] - r) / cell)), int(math.floor((p[1] + r) / cell))
if (gx1 - gx0 + 1) * (gy1 - gy0 + 1) > len(grid):
cells = list(grid.values())
else:
cells = [grid[(gx, gy)] for gx in range(gx0, gx1 + 1) for gy in range(gy0, gy1 + 1) if (gx, gy) in grid]
for vs in cells:
for v in vs:
c = dist[v] + jump_cost * math.dist(g.pos[v], p)
if c < cand[0]:
cand = (c, v, t, p)
if best is None or cand[0] < best[0]:
best = cand
_, v, t, p = best
back = []
if v is not None:
while v != cur_node:
back.append(g.pos[v])
v = prevs[v]
back.reverse()
return t, back, p
while todo:
targets = []
for k in todo:
for e in entries(k):
targets.append((e, strokes[e[0]][e[1]]))
(k, j, how), back, p = best_route(targets)
if back:
retrace += sum(math.dist(a, b) for a, b in zip([path[-1]] + back, back))
path += back
jump += math.dist(path[-1], p)
path += densify_xy([path[-1], p], step)[1:]
s = strokes[k]
if how == 'loop':
seq = s[j:] + s[1:j + 1]
elif how == 'rev':
seq = s[::-1]
else:
seq = s
drawn += sum(math.dist(a, b) for a, b in zip(seq, seq[1:]))
path += seq[1:]
cur_node = g.add_line(seq)
todo.remove(k)
# finish at the centre or the edge, whichever is cheaper from somewhere already drawn
dist, prevs = g.dijkstra(cur_node)
best = (jump_cost * min(2 * math.hypot(*path[-1]), 1 - math.hypot(*path[-1])), cur_node)
for v, d in dist.items():
c = d + jump_cost * min(2 * math.hypot(*g.pos[v]), 1 - math.hypot(*g.pos[v]))
if c < best[0]:
best = (c, v)
v = best[1]
back = []
while v != cur_node:
back.append(g.pos[v])
v = prevs[v]
back.reverse()
q = back[-1] if back else path[-1]
rq = math.hypot(*q)
end = (0.0, 0.0) if 2 * rq < 1 - rq else (q[0] / rq, q[1] / rq)
if back:
retrace += sum(math.dist(a, b) for a, b in zip([path[-1]] + back, back))
path += back
jump += math.dist(path[-1], end)
path += densify_xy([path[-1], end], step)[1:]
if stats is not None:
stats.update(start=start, end=end)
stats.update(drawn=drawn, retrace=retrace, jump=jump)
return path
def drawing_to_polar(strokes, fit=0.9, border=False, stats=None, **kw):
"""Strokes (any units, y down as in SVG) -> a theta-rho pattern that starts and ends at the centre or edge.
border: also draw a circle round the picture, just inside the edge."""
s = normalise(strokes, fit)
if border:
n = 720
s.append([(0.97 * math.cos(2 * math.pi * i / n), 0.97 * math.sin(2 * math.pi * i / n)) for i in range(n + 1)])
xy = join_strokes(s, stats=stats, **kw)
return xy_to_polar(densify_xy(xy, 0.01))
def svg_to_polar(text, **kw):
return drawing_to_polar(svg_strokes(text), **kw)
+128
View File
@@ -0,0 +1,128 @@
"""Turn theta-rho patterns into G-code for this table.
Machine axes: X = theta in degrees (unlimited, keeps counting), Y = rho in mm (0 = centre).
Patterns never go inside rho_min_mm (3 mm), so they don't press the home switch.
The controller moves X and Y together in a straight line in (theta, rho) space, which on the
sand is an Archimedean spiral piece: exactly how .thr files are meant to be read. Long moves
are still split into short ones so each piece can get its own feed rate: the feed is set so
the *ball* moves at a steady speed, whatever the radius.
"""
import math
DEG = 180.0 / math.pi
def phys_len(t1, r1, t2, r2):
"""Length on the sand (mm) of a theta-rho move, (deg, mm) -> mm. Exact enough for short moves."""
dt = (t2 - t1) / DEG
rm = (r1 + r2) / 2
return math.hypot(rm * dt, r2 - r1)
class Planner:
"""Keeps track of where the ball is and turns patterns into G-code lines."""
def __init__(self, cfg, theta_deg=0.0, rho_mm=0.0):
self.cfg = cfg
self.theta = theta_deg # current machine X (deg)
self.rho = rho_mm # current machine Y (mm)
# ------------------------------------------------------------ geometry
def to_machine(self, pts, rotate_deg=0.0, mirror=False):
"""Pattern points (rad, 0..1) -> machine points (deg, mm), not yet shifted by whole turns."""
m = self.cfg['machine']
sign = -1.0 if bool(m['invert_theta']) != bool(mirror) else 1.0
off = m['theta_offset_deg'] + rotate_deg
R, rmin = m['rho_max_mm'], m['rho_min_mm']
# rho 0 (the centre) becomes rho_min: under a 9.5 mm ball the 3 mm difference doesn't show
return [(sign * th * DEG + off, max(rho * R, rmin)) for th, rho in pts]
def densify(self, mpts):
mo = self.cfg['motion']
smax, dmax, smin = mo['max_segment_mm'], mo['max_segment_deg'], mo['min_segment_mm']
# merge points closer than smin (keeps the first and last point)
kept = [mpts[0]]
for p in mpts[1:-1]:
if phys_len(*kept[-1], *p) >= smin or abs(p[0] - kept[-1][0]) >= dmax:
kept.append(p)
if len(mpts) > 1:
kept.append(mpts[-1])
out = [kept[0]]
for (t1, r1), (t2, r2) in zip(kept, kept[1:]):
# equal steps in (theta, rho) are longest at the outer end, so size them there
L_outer = math.hypot(max(r1, r2) * (t2 - t1) / DEG, r2 - r1)
n = max(1, math.ceil(L_outer / smax), math.ceil(abs(t2 - t1) / dmax))
for i in range(1, n + 1):
f = i / n
out.append((t1 + (t2 - t1) * f, r1 + (r2 - r1) * f))
return out
def feed(self, t1, r1, t2, r2, speed):
"""G-code F (machine units/min) that gives `speed` mm/s on the sand for this move."""
D = math.hypot(t2 - t1, r2 - r1) # length in machine units (deg, mm mixed)
L = max(phys_len(t1, r1, t2, r2), 0.02) # length on the sand
return min(60.0 * speed * D / L, 200000.0)
def seconds(self, mpts, speed):
mo = self.cfg['motion']
s = 0.0
for (t1, r1), (t2, r2) in zip(mpts, mpts[1:]):
s += max(phys_len(t1, r1, t2, r2) / speed, abs(t2 - t1) / mo['max_theta_deg_s'],
abs(r2 - r1) / mo['max_rho_mm_s'])
return s
# ------------------------------------------------------------ erase
def erase_path(self, target_theta_start, target_rho, pitch_mm):
"""Spiral from the current ball position to (pattern start), sweeping the whole tray.
`target_theta_start` may be shifted by any number of whole turns; the shifted value used
is returned with the path. The spiral turns whichever way unwinds the arm's total angle."""
R, rmin = self.cfg['machine']['rho_max_mm'], self.cfg['machine']['rho_min_mm']
t0, r0 = self.theta, self.rho
far = R if r0 < R / 2 else rmin
legs = [far] if abs(far - target_rho) > 0.01 * R else []
legs.append(target_rho)
if abs(legs[0] - r0) < 0.01 * R and len(legs) > 1: # already at the far end
legs = legs[1:]
direction = -1.0 if t0 > 0 else 1.0
path = [(t0, r0)]
t, r = t0, r0
for i, rr in enumerate(legs):
turns = abs(rr - r) / pitch_mm
ideal = t + direction * turns * 360.0
if i == len(legs) - 1:
k = round((ideal - target_theta_start) / 360.0)
tt = target_theta_start + 360.0 * k
else:
tt = ideal
path.append((tt, rr))
t, r = tt, rr
return path, path[-1][0] - target_theta_start
# ------------------------------------------------------------ programs
def program(self, pts, erase=True, speed=None, rotate_deg=0.0, mirror=False):
"""G-code for one pattern (with the erase spiral before it). Returns
(lines, segments) where segments = [('erase'|'travel'|'pattern', machine points)]."""
mo, er = self.cfg['motion'], self.cfg['erase']
speed = speed or mo['ball_speed_mm_s']
mp = self.to_machine(pts, rotate_deg, mirror)
segs = []
if erase and er['enabled']:
epath, shift = self.erase_path(mp[0][0], mp[0][1], er['pitch_mm'])
segs.append(('erase', self.densify(epath), er['speed_mm_s']))
else:
shift = 360.0 * round((self.theta - mp[0][0]) / 360.0)
if phys_len(self.theta, self.rho, mp[0][0] + shift, mp[0][1]) > 0.5:
segs.append(('travel', self.densify([(self.theta, self.rho), (mp[0][0] + shift, mp[0][1])]), speed))
mp = [(t + shift, r) for t, r in mp]
segs.append(('pattern', self.densify(mp), speed))
lines = []
for kind, path, v in segs:
lines.append(f'(--- {kind} ---)')
for (t1, r1), (t2, r2) in zip(path, path[1:]):
f = self.feed(t1, r1, t2, r2, v)
lines.append(f'G1X{t2:.3f}Y{r2:.3f}F{f:.0f}')
self.theta, self.rho = segs[-1][1][-1]
return lines, [(k, p) for k, p, _ in segs], sum(self.seconds(p, v) for _, p, v in segs)
HEADER = ['G21', 'G90', 'G94'] # mm, absolute, feed per minute
+230
View File
@@ -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)
+46
View File
@@ -0,0 +1,46 @@
"""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
+50
View File
@@ -0,0 +1,50 @@
"""Read and write .thr (theta-rho) pattern files.
Each line holds "<theta> <rho>":
theta: angle in radians, continuous (it keeps counting past 2*pi; it is not wrapped)
rho: distance from the centre, 0 (centre) to 1 (edge)
Lines starting with '#' or '//' are comments. This is the format used by Sandify,
Sisyphus and Dune Weaver.
"""
import math
Pattern = list # list of (theta_rad, rho) tuples
def loads(text):
pts = []
for raw in text.splitlines():
line = raw.strip()
if not line or line.startswith('#') or line.startswith('//'):
continue
parts = line.replace(',', ' ').split()
if len(parts) < 2:
continue
try:
th, rho = float(parts[0]), float(parts[1])
except ValueError:
continue
if math.isfinite(th) and math.isfinite(rho):
pts.append((th, min(1.0, max(0.0, rho))))
return pts
def load(path):
with open(path, encoding='utf-8', errors='replace') as f:
pts = loads(f.read())
if len(pts) < 2:
raise ValueError(f'{path}: no theta-rho points found')
return pts
def dumps(pts, header=None):
out = []
if header:
out += [f'# {h}' for h in header.splitlines()]
out += [f'{th:.5f} {rho:.5f}' for th, rho in pts]
return '\n'.join(out) + '\n'
def save(path, pts, header=None):
with open(path, 'w') as f:
f.write(dumps(pts, header))
+341
View File
@@ -0,0 +1,341 @@
"""Jog panel: a small web page (http://127.0.0.1:8765) to home, jog and calibrate the table.
Keeps the USB connection open the whole time, so the Uno doesn't restart between moves.
Start it with `python3 -m sandtable ui`.
"""
import json
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from .grbl import RT_HOLD, RT_JOG_CANCEL, GrblError
PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sand Table Jog</title>
<style>
:root{--bg:#f6f4ef;--card:#fff;--ink:#222;--mute:#666;--line:#ddd;--acc:#2f6f5e;--stop:#c0392b}
@media (prefers-color-scheme:dark){:root{--bg:#1b1c1e;--card:#26282b;--ink:#eee;--mute:#aaa;--line:#3a3d41;--acc:#5fb49c}}
body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.4 system-ui,sans-serif}
main{max-width:720px;margin:0 auto;padding:16px}
h1{font-size:20px;margin:4px 0 12px}
.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px;margin-bottom:12px}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.stat{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
.stat div{background:var(--bg);border-radius:8px;padding:8px}
.stat b{display:block;font-size:22px;font-variant-numeric:tabular-nums}
.stat span{font-size:12px;color:var(--mute)}
button{font:inherit;padding:10px 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer;min-width:64px}
button:hover{border-color:var(--acc)}
button.main{background:var(--acc);color:#fff;border-color:var(--acc)}
button.stop{background:var(--stop);color:#fff;border-color:var(--stop);font-weight:700;width:100%;padding:16px;font-size:18px}
.lbl{width:90px;color:var(--mute)}
input{font:inherit;width:90px;padding:8px;border-radius:6px;border:1px solid var(--line);background:var(--bg);color:var(--ink)}
.msg{min-height:1.4em;color:var(--mute);font-size:14px}
.err{color:var(--stop)}
small{color:var(--mute)}
</style></head><body><main>
<h1>Sand table: jog &amp; calibrate</h1>
<div class="card"><div class="stat">
<div><span>State</span><b id="state">-</b></div>
<div><span>Rho (mm)</span><b id="rho">-</b></div>
<div><span>Theta (deg)</span><b id="theta">-</b></div>
<div><span>Home switch</span><b id="sw">-</b></div>
</div><p class="msg" id="msg"></p></div>
<div class="card"><button class="stop" onclick="post('stop')">STOP</button></div>
<div class="card">
<div class="row"><button class="main" onclick="post('home')">Home rho</button>
<button onclick="post('unlock')">Unlock</button>
<small id="homed">not homed since connecting</small></div>
</div>
<div class="card">
<div class="row"><span class="lbl">Rho</span>
<button onclick="jog('Y',-10)">&minus;10 mm</button><button onclick="jog('Y',-0.5)">&minus;0.5</button>
<button onclick="jog('Y',0.5)">+0.5</button><button onclick="jog('Y',10)">+10 mm</button></div>
<p><small>+ moves the magnet outward. An inward jog stops by itself when it presses the home switch; nothing stops it at the outer end, so watch the carriage there.</small></p>
<div class="row"><span class="lbl">Theta</span>
<button onclick="jog('X',-90)">&minus;90&deg;</button><button onclick="jog('X',-10)">&minus;10&deg;</button>
<button onclick="jog('X',-0.5)">&minus;0.5&deg;</button><button onclick="jog('X',0.5)">+0.5&deg;</button>
<button onclick="jog('X',10)">+10&deg;</button><button onclick="jog('X',90)">+90&deg;</button></div>
<p><small>+ turns the arm counter-clockwise, seen from above.</small></p>
</div>
<div class="card">
<b>Calibrate</b>
<p><small>Move a known amount with the buttons above, measure what really happened, and enter both. The new steps value is written to the Uno.</small></p>
<div class="row"><span class="lbl">Rho</span>
commanded <input id="rc" value="20"> mm, measured <input id="rm" placeholder="mm">
<button onclick="cal('$101','rc','rm')">Apply</button></div>
<div class="row" style="margin-top:8px"><span class="lbl">Theta</span>
commanded <input id="tc" value="360"> deg, measured <input id="tm" placeholder="deg">
<button onclick="cal('$100','tc','tm')">Apply</button></div>
<p class="msg">Now: rho <b id="s101">-</b> steps/mm, theta <b id="s100">-</b> steps/deg</p>
</div>
</main>
<script>
async function post(path, body){
const r = await fetch('/api/'+path, {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body||{})});
const j = await r.json(); show(j.msg, !j.ok); return j;
}
function jog(axis, d){ post('jog', {axis, d}); }
function cal(key, c, m){
const cmd = parseFloat(document.getElementById(c).value), meas = parseFloat(document.getElementById(m).value);
if(!(cmd>0) || !(meas>0)){ show('enter both numbers', true); return; }
post('calibrate', {key, commanded: cmd, measured: meas});
}
function show(t, bad){ const e = document.getElementById('msg'); e.textContent = t||''; e.className = 'msg' + (bad?' err':''); }
async function tick(){
try{
const s = await (await fetch('/api/status')).json();
document.getElementById('state').textContent = s.state;
document.getElementById('rho').textContent = s.rho.toFixed(2);
document.getElementById('theta').textContent = s.theta.toFixed(1);
const other = (s.pins||'').replace('Y','');
document.getElementById('sw').textContent = (s.switch ? 'pressed' : 'open') + (other ? ' (other inputs on: ' + other + ')' : '');
document.getElementById('homed').textContent = s.homed ? 'homed: rho is measured from the centre' : 'not homed since connecting';
document.getElementById('s101').textContent = s.steps['$101']||'-';
document.getElementById('s100').textContent = s.steps['$100']||'-';
if(s.error) show(s.error, true);
}catch(e){ document.getElementById('state').textContent = 'offline'; }
setTimeout(tick, 300);
}
tick();
</script></body></html>
"""
def _log(msg):
try:
from .web import log
log(msg)
except Exception:
print(msg, flush=True)
def open_usb(cfg):
"""Find the Uno and open it, for reconnecting. Raises OSError with a plain message."""
import serial
from serial.tools import list_ports
from .cli import USB_IDS
dev = cfg['serial']['port']
if not dev or dev == 'auto':
cands = [p.device for p in list_ports.comports() if p.vid in USB_IDS]
if not cands:
raise OSError('the Uno is not connected: plug in its USB cable (it reconnects by itself)')
dev = cands[0]
try:
return serial.Serial(dev, cfg['serial']['baud'], timeout=0.02, write_timeout=2)
except Exception as e:
if getattr(e, 'errno', None) == 13 or 'Permission denied' in str(e):
raise OSError(f'no permission for {dev}: run sudo chmod a+rw {dev} (it reconnects by itself)')
raise OSError(f'cannot open {dev}: {e}')
class Panel:
def __init__(self, ctrl, cfg, connected=True, opener=open_usb):
self.c = ctrl
self.cfg = cfg
self.lock = threading.Lock() # one serial conversation at a time
self.homed = False
self.error = ''
self.steps = {}
self.busy = ''
self.jog_in = False # the last jog moved rho inward, toward the switch
self.connected = connected
self.opener = opener
self._next_try = 0.0
if connected:
with self.lock:
self._read_steps()
else:
self.error = 'the Uno is not connected: plug in its USB cable (it reconnects by itself)'
threading.Thread(target=self._poll, daemon=True).start()
def lost(self, e):
"""The USB link dropped: forget the position and start trying to reconnect."""
_log(f'USB connection lost: {e!r}')
self.connected = False
self.homed = False
self.error = f'USB connection lost ({e}). Plug the Uno back in; the page reconnects by itself.'
try:
self.c.port.close()
except Exception:
pass
def _reconnect(self):
if time.monotonic() < self._next_try:
return
self._next_try = time.monotonic() + 2.0
try:
self.c.reopen(self.opener(self.cfg))
self.c.query()
self._read_steps()
except Exception as e:
self.error = str(e)
return
self.connected = True
self.error = ''
_log('USB connection back')
def _read_steps(self):
s = self.c.settings()
self.steps = {k: s.get(k, '') for k in ('$100', '$101')}
def _poll(self):
while True:
fast = False
if self.lock.acquire(timeout=0.05):
try:
if not self.connected:
self._reconnect()
else:
st = self.c.query()
fast = st.state == 'Jog'
if fast and self.jog_in and 'Y' in st.pins:
# inward jog reached the home switch: stop before the carriage hits the end stop
self.c.realtime(RT_JOG_CANCEL)
self.jog_in = False
self.error = 'inward jog stopped: the home switch is pressed'
except OSError as e: # pyserial's errors are OSErrors: the cable came out
self.lost(e)
except Exception as e: # keep the page alive; show the problem
if str(e) != self.error:
_log(f'panel: {e}')
self.error = str(e)
finally:
self.lock.release()
time.sleep(0.05 if fast else 0.25)
def status(self):
st = self.c.status
return {'state': 'Disconnected' if not self.connected else self.busy or (st.state + (':' + st.sub if st.sub else '')),
'connected': self.connected,
'theta': st.wpos[0], 'rho': st.wpos[1], 'switch': 'Y' in st.pins, 'pins': st.pins,
'homed': self.homed, 'steps': self.steps, 'error': self.error}
# ---------------------------------------------------------- actions
def jog(self, axis, d):
axis = 'X' if axis == 'X' else 'Y'
d = max(-360.0, min(360.0, float(d)))
feed = 1200 if axis == 'X' else 600 # 20 deg/s, 10 mm/s
with self.lock:
st = self.c.query()
if st.state == 'Alarm':
return False, 'alarm: press Unlock first'
inward = axis == 'Y' and d < 0
if inward and 'Y' in st.pins:
return False, 'the home switch is pressed: rho can only move outward (+) from here'
self.error = ''
self.jog_in = inward
self.c.command(f'$J=G91 G21 {axis}{d:.3f} F{feed}')
return True, f'jog {"theta" if axis == "X" else "rho"} {d:+g}'
def home(self):
if not self.lock.acquire(timeout=1):
return False, 'busy'
try:
self.busy = 'Homing'
self.jog_in = False
self.c.abort = False
self.c.home_rho(self.cfg, log=lambda m: None)
self.homed = True
self.error = ''
return True, 'homed'
except GrblError as e:
self.homed = False
return False, str(e)
finally:
self.busy = ''
self.lock.release()
def stop(self):
# real-time bytes go straight out, even while another request holds the lock
self.c.abort = True
self.c.realtime(RT_JOG_CANCEL)
self.c.realtime(RT_HOLD)
with self.lock:
self.c.stop() # waits for the stop, clears the queue, keeps the position
return True, 'stopped'
def unlock(self):
with self.lock:
self.c.command('$X')
return True, 'unlocked'
def calibrate(self, key, commanded, measured):
if key not in ('$100', '$101'):
return False, 'bad setting'
with self.lock:
old = float(self.c.settings().get(key, '0'))
new = old * float(commanded) / float(measured)
if not 0.2 * old < new < 5 * old:
return False, f'that would change {key} from {old:g} to {new:.3f}; check the numbers'
self.c.command(f'{key}={new:.3f}')
self._read_steps()
what = 'rho steps/mm' if key == '$101' else 'theta steps/deg'
return True, (f'{what}: {old:g} -> {new:.3f}. Saved on the Uno. Home again, then copy it into '
f'sandtable.toml so `setup` keeps it.')
def serve(ctrl, cfg, port=8765, open_browser=True):
panel = Panel(ctrl, cfg)
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype='application/json'):
data = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
if self.path in ('/', '/index.html'):
self._send(200, PAGE, 'text/html; charset=utf-8')
elif self.path == '/api/status':
self._send(200, json.dumps(panel.status()))
else:
self._send(404, '{}')
def do_POST(self):
n = int(self.headers.get('Content-Length') or 0)
body = json.loads(self.rfile.read(n) or b'{}')
act = self.path.rsplit('/', 1)[-1]
try:
if act == 'jog':
ok, msg = panel.jog(body.get('axis'), body.get('d', 0))
elif act == 'home':
ok, msg = panel.home()
elif act == 'stop':
ok, msg = panel.stop()
elif act == 'unlock':
ok, msg = panel.unlock()
elif act == 'calibrate':
ok, msg = panel.calibrate(body.get('key'), body.get('commanded'), body.get('measured'))
else:
ok, msg = False, 'unknown action'
except Exception as e:
ok, msg = False, str(e)
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
srv = ThreadingHTTPServer(('127.0.0.1', port), H)
url = f'http://127.0.0.1:{port}'
print(f'Jog panel at {url} (Ctrl-C here to quit)', flush=True)
if open_browser:
try:
webbrowser.open(url)
except Exception:
pass
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
srv.server_close()
+624
View File
@@ -0,0 +1,624 @@
"""Web app: a library of designs to play, plus the jog panel, served from this computer.
The table stays on the USB cable; the page is just the remote control.
Start it with `python3 -m sandtable web` and open http://127.0.0.1:8765.
"""
import json
import os
import random
import re
import shutil
import threading
import time
import webbrowser
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
from . import thr
from .grbl import RT_JOG_CANCEL, Aborted, GrblError, SwitchTripped
from .motion import HEADER, Planner
from .ui import PAGE as JOG_PAGE, Panel
SOFTWARE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
LIBRARY_DIR = os.path.join(SOFTWARE, 'patterns')
CACHE_DIR = os.path.join(SOFTWARE, '.cache', 'thumbs')
REMOVED_DIR = os.path.join(SOFTWARE, 'removed-patterns')
UPLOADS = 'uploads'
LOG_PATH = os.path.join(SOFTWARE, '.cache', 'web.log')
def log(msg):
"""What the page did and every problem it hit: printed here and kept in .cache/web.log."""
line = time.strftime('%Y-%m-%d %H:%M:%S ') + msg
print(line, flush=True)
if os.environ.get('SANDTABLE_QUIET'): # the tests: keep the real log clean
return
try:
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
with open(LOG_PATH, 'a') as f:
f.write(line + '\n')
except OSError:
pass
NOT_IN_PLAY_ALL = {'tools'}
# ---------------------------------------------------------------- library
class Library:
"""The .thr files under patterns/, one folder per category. Thumbnails and running times
are worked out once per file and cached in .cache/thumbs/."""
def __init__(self, cfg, root=LIBRARY_DIR, cache=CACHE_DIR, removed=REMOVED_DIR):
self.cfg, self.root, self.cache, self.removed = cfg, root, cache, removed
os.makedirs(cache, exist_ok=True)
self.lock = threading.Lock()
self.index_path = os.path.join(cache, 'index.json')
try:
with open(self.index_path) as f:
self.index = json.load(f)
except (OSError, ValueError):
self.index = {}
def items(self):
out = []
for dirpath, _, files in os.walk(self.root):
for fn in files:
if not fn.lower().endswith('.thr'):
continue
rel = os.path.relpath(os.path.join(dirpath, fn), self.root).replace(os.sep, '/')
cat = rel.split('/')[0] if '/' in rel else 'other'
info = self.index.get(rel, {})
out.append({'id': rel, 'name': fn[:-4], 'category': cat, 'est': info.get('est')})
out.sort(key=lambda d: (d['category'] == 'tools', d['category'], d['name']))
return out
def path(self, ident):
"""The file for a library id, refusing anything outside the library folder."""
p = os.path.realpath(os.path.join(self.root, ident))
if not p.startswith(os.path.realpath(self.root) + os.sep) or not p.endswith('.thr') or not os.path.isfile(p):
raise KeyError(f'no design {ident!r}')
return p
def load(self, ident):
return os.path.basename(ident)[:-4], thr.load(self.path(ident))
def _key(self, ident):
st = os.stat(self.path(ident))
return f'{st.st_mtime_ns}-{st.st_size}'
def thumb(self, ident, size=360):
"""PNG path for a design's thumbnail, made on first use."""
from .preview import render
key = self._key(ident)
with self.lock:
info = self.index.get(ident)
png = os.path.join(self.cache, re.sub(r'[^A-Za-z0-9_.-]', '_', ident) + f'-{key}.png')
if info and info.get('key') == key and os.path.exists(png):
return png
_, pts = self.load(ident)
m = self.cfg['machine']
pl = Planner(self.cfg, 0.0, m['rho_min_mm'])
_, segs, est = pl.program(pts, erase=False)
render([s for s in segs if s[0] == 'pattern'], m['rho_max_mm'], png, size=size,
invert_theta=m['invert_theta'], transparent=True)
with self.lock:
self.index[ident] = {'key': key, 'est': round(est), 'points': len(pts)}
tmp = self.index_path + '.tmp'
with open(tmp, 'w') as f:
json.dump(self.index, f)
os.replace(tmp, self.index_path)
return png
def warm(self):
"""Make every missing thumbnail (in the background at start-up)."""
for it in self.items():
try:
self.thumb(it['id'])
except Exception:
pass
def add(self, filename, text):
"""Save an uploaded design into patterns/uploads/ and return its id. A .thr file is kept as
it is; an .svg line drawing is turned into a pattern first (see lineart.py)."""
if filename.lower().endswith('.svg') or text.lstrip().startswith(('<svg', '<?xml')):
from .lineart import svg_strokes, drawing_to_polar
strokes = svg_strokes(text)
if not strokes:
raise ValueError('no lines found in that SVG (text and images are skipped; convert them to paths)')
pts = drawing_to_polar(strokes)
text = f'# from {os.path.basename(filename)}: {len(strokes)} strokes\n' + thr.dumps(pts)
pts = thr.loads(text)
if len(pts) < 2:
raise ValueError('that file has no theta-rho points (a .thr file has lines like "0.000 1.000")')
base = re.sub(r'[^A-Za-z0-9_-]+', '-', os.path.splitext(os.path.basename(filename))[0]).strip('-') or 'design'
folder = os.path.join(self.root, UPLOADS)
os.makedirs(folder, exist_ok=True)
name, n = base, 2
while os.path.exists(os.path.join(folder, name + '.thr')):
name, n = f'{base}-{n}', n + 1
with open(os.path.join(folder, name + '.thr'), 'w') as f:
f.write(text if text.endswith('\n') else text + '\n')
return f'{UPLOADS}/{name}.thr'
def remove(self, ident):
"""Move a design out of the library (into removed-patterns/, so it can be put back)."""
src = self.path(ident)
dst = os.path.join(self.removed, ident)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.move(src, dst)
# ---------------------------------------------------------------- player
class Player:
"""Plays a list of designs in a background thread: home if needed, then for each design the
erase spiral and the design itself. Holds the panel's lock while it runs, so the jog panel's
buttons wait (the page greys them out)."""
def __init__(self, panel, cfg, library, alert=None):
self.p, self.c, self.cfg, self.lib = panel, panel.c, cfg, library
self.alert = alert or (lambda title, msg: None)
self.thread = None
self.state = 'idle' # idle | playing | paused | stopping
self.now = None # what is playing: name, phase, progress, times
self.message = '' # the last problem, shown in red on the page
self.speed = cfg['motion']['ball_speed_mm_s']
self.erase = bool(cfg['erase']['enabled'])
self.pause_s = 0.0
self.order, self.loop = [], False
self._stop = self._skip = False
def running(self):
return bool(self.thread and self.thread.is_alive())
def status(self):
now = dict(self.now) if self.now else None
if now:
el = time.monotonic() - now.pop('t0')
now['elapsed'] = round(el)
now['left'] = round(max(0.0, now['est'] * (1 - now['frac'])))
return {'state': self.state, 'now': now, 'message': self.message, 'loop': self.loop,
'queue': len(self.order), 'settings': {'speed': self.speed, 'erase': self.erase, 'pause': self.pause_s}}
def play(self, ids, loop=False, shuffle=False):
for i in ids:
if not i.startswith('erase:'):
self.lib.path(i) # raises for an unknown design
if self.running():
self.stop()
order = list(ids)
if shuffle:
random.shuffle(order)
self.order, self.loop, self.shuffle = order, loop, shuffle
log(f'play {order}' + (' (repeat)' if loop else '') + (' (shuffled)' if shuffle else ''))
self._stop = self._skip = False
self.message = ''
self.state = 'playing'
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def pause(self):
if self.state == 'playing':
self.c.hold()
self.state = 'paused'
def resume(self):
if self.state == 'paused':
self.c.resume()
self.state = 'playing'
def skip(self):
if self.running():
self._skip = True
self.c.abort = True
def stop(self, wait=30):
if self.running():
self._stop = True
self.state = 'stopping'
self.c.abort = True
self.thread.join(wait)
# ------------------------------------------------------------ the worker thread
def _run(self):
c, cfg = self.c, self.cfg
with self.p.lock:
try:
c.abort = False
if not self.p.homed or c.query().state == 'Alarm':
self.now = {'name': 'Homing', 'id': None, 'phase': 'homing', 'frac': 0.0, 'est': 20, 't0': time.monotonic()}
c.home_rho(cfg, log=lambda m: None)
self.p.homed = True
for h in HEADER:
c.command(h)
c.arm_guard(cfg)
while not self._stop:
for ident in list(self.order):
if self._stop:
break
self._play_one(ident)
t_end = time.monotonic() + self.pause_s
while time.monotonic() < t_end and not (self._stop or self._skip):
c.pause(0.2)
self._skip = False
if not self.loop:
break
if self.shuffle:
random.shuffle(self.order)
except Aborted: # a STOP (during homing, or passed on by _play_one)
log('stopped')
c.realtime(RT_JOG_CANCEL)
c.stop() # nothing to do if _play_one already stopped it
except SwitchTripped as e:
self.p.homed = False
self.message = f'Stopped: {e}. Check the arm, belt and carriage, then play again (it homes first).'
log('ERROR ' + self.message + f' | status {c.status.state} pins={c.status.pins!r} wpos={c.status.wpos}')
self.alert('Sand table stopped: home switch pressed', self.message)
except OSError as e: # the USB cable came out: the panel reconnects
self.p.lost(e)
self.message = 'Stopped: the USB connection to the Uno was lost. Plug it back in; the page reconnects by itself.'
log(f'ERROR USB lost: {e!r}')
self.alert('Sand table: USB connection lost', self.message)
except GrblError as e:
self.p.homed = False
self.message = f'Stopped: {e}'
log('ERROR ' + self.message + f' | status {c.status.state} wpos={c.status.wpos} errors={c.errors[-3:]}')
self.alert('Sand table stopped', self.message)
except Exception as e: # a bug: stop safely and say so
import traceback
self.p.homed = False
self.message = f'Stopped by an unexpected error: {e!r}'
log('ERROR ' + self.message + '\n' + traceback.format_exc())
try:
c.stop()
except Exception:
pass
self.alert('Sand table stopped', self.message)
finally:
c.guard = None
c.abort = False
self.now = None
self.state = 'idle'
def _play_one(self, ident):
c = self.c
if ident.startswith('erase:'):
name, pts = 'Erase', [(0.0, 0.0 if ident.endswith('centre') else 1.0)] * 2
else:
name, pts = self.lib.load(ident)
st = c.query()
pl = Planner(self.cfg, st.wpos[0], st.wpos[1])
lines, _, est = pl.program(pts, erase=self.erase or ident.startswith('erase:'), speed=self.speed)
n = sum(1 for l in lines if not l.startswith('('))
self.now = {'name': name, 'id': ident if not ident.startswith('erase:') else None, 'phase': 'drawing',
'frac': 0.0, 'est': round(est), 't0': time.monotonic()}
log(f'start {name}: {n} moves, about {round(est)} s, from theta {st.wpos[0]:.1f} rho {st.wpos[1]:.1f}')
try:
done = 0
for line in lines:
if line.startswith('(---'):
self.now['phase'] = 'erasing' if 'erase' in line else 'drawing'
continue
n_err = len(c.errors)
c.send(line)
if len(c.errors) > n_err:
raise GrblError(f'the controller rejected a line ({c.errors[-1]}): {line}')
done += 1
self.now['frac'] = done / n
c.wait_idle()
log(f'done {name}')
except Aborted:
log(f'{"skip" if self._skip and not self._stop else "stop"} {name} at {100 * self.now["frac"]:.0f}%')
self.state = 'stopping'
c.stop() # stops, clears the queue and keeps the position
if self._stop:
raise
self.state = 'playing' # a skip: carry on with the next design
# ---------------------------------------------------------------- page
PAGE = r"""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sand Table</title>
<style>
:root{--bg:#f4f1ea;--card:#fffdf8;--ink:#2a2622;--mute:#6f675d;--line:#e2dbcf;--acc:#2f6f5e;--acc-ink:#fff;--stop:#c0392b;--bar:#e9e2d4}
@media (prefers-color-scheme:dark){:root{--bg:#18191b;--card:#232528;--ink:#ece8e1;--mute:#a59e94;--line:#36393d;--acc:#5fb49c;--acc-ink:#0d1f1a;--bar:#2e3135}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.45 system-ui,-apple-system,"Segoe UI",sans-serif}
main{max-width:1100px;margin:0 auto;padding:16px}
header{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-bottom:14px}
h1{font-size:20px;margin:0 auto 0 0;letter-spacing:.2px}
.pill{font-size:13px;padding:4px 10px;border-radius:99px;background:var(--card);border:1px solid var(--line);color:var(--mute);font-variant-numeric:tabular-nums}
button{font:inherit;padding:8px 14px;border-radius:8px;border:1px solid var(--line);background:var(--card);color:var(--ink);cursor:pointer}
button:hover:not(:disabled){border-color:var(--acc)}
button:disabled{opacity:.45;cursor:default}
button.main{background:var(--acc);color:var(--acc-ink);border-color:var(--acc);font-weight:600}
button.stop{background:var(--stop);color:#fff;border-color:var(--stop);font-weight:700}
.card{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:14px}
#now{display:none;gap:16px;align-items:center;flex-wrap:wrap}
#now img{width:110px;height:110px;flex:none}
#now .info{flex:1 1 200px;min-width:0}
#now .title{font-size:18px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar{height:8px;background:var(--bar);border-radius:99px;overflow:hidden;margin:8px 0 6px}
.bar div{height:100%;background:var(--acc);width:0;transition:width .5s}
.row{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.mute{color:var(--mute);font-size:13px}
.err{display:none;background:#fdecea;color:#8a1f14;border-color:#f1b8b0}
@media (prefers-color-scheme:dark){.err{background:#3a1f1c;color:#ffb4a8;border-color:#6b2b24}}
.controls label{display:flex;align-items:center;gap:6px;font-size:14px;color:var(--mute)}
.controls input[type=range]{width:110px}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin:4px 0 12px}
.chips button{padding:5px 12px;border-radius:99px;font-size:14px}
.chips button.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:12px}
.tile{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:10px;display:flex;flex-direction:column;gap:6px;position:relative}
.tile.playing{border-color:var(--acc);box-shadow:0 0 0 2px var(--acc) inset}
.tile img{width:100%;aspect-ratio:1;display:block;cursor:pointer}
.tile .name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tile .meta{display:flex;justify-content:space-between;align-items:center}
.tile .del{position:absolute;top:6px;right:6px;padding:2px 8px;font-size:13px;opacity:0;border-radius:6px}
.tile:hover .del{opacity:.8}
@media (hover:none){.tile .del{opacity:.6}}
footer{margin:20px 0 8px;color:var(--mute);font-size:13px}
a{color:var(--acc)}
@media (max-width:560px){#now img{width:72px;height:72px} #now .info{flex-basis:150px} #now .row{width:100%} #now .row button{flex:1} h1{font-size:18px}}
</style></head><body><main>
<header>
<h1>Sand Table</h1>
<span class="pill" id="mach">connecting…</span>
<button id="homeBtn" onclick="post('home')">Home</button>
<button class="stop" onclick="post('stop')">STOP</button>
</header>
<div class="card err" id="err"></div>
<div class="card" id="now">
<img id="nowImg" alt="">
<div class="info">
<div class="mute" id="nowPhase"></div>
<div class="title" id="nowName"></div>
<div class="bar"><div id="nowBar"></div></div>
<div class="mute" id="nowTime"></div>
</div>
<div class="row">
<button id="pauseBtn" onclick="pauseResume()">Pause</button>
<button onclick="post('skip')" id="skipBtn">Skip</button>
<button onclick="post('stop')">Stop</button>
</div>
</div>
<div class="card controls">
<div class="row">
<button class="main" onclick="playAll()">Play all</button>
<label><input type="checkbox" id="shuffle" checked> shuffle</label>
<label><input type="checkbox" id="loop" checked> repeat</label>
<button onclick="post('play',{ids:['erase:edge']})">Erase now</button>
<span style="flex:1"></span>
<label>Speed <input type="range" id="speed" min="10" max="40" step="1" onchange="saveSettings()"> <span id="speedVal"></span></label>
<label><input type="checkbox" id="erase" onchange="saveSettings()"> erase first</label>
<button onclick="document.getElementById('file').click()">Upload .thr / .svg</button>
<input type="file" id="file" accept=".thr,.txt,.svg" multiple hidden onchange="upload(this.files)">
</div>
</div>
<div class="chips" id="chips"></div>
<div class="grid" id="grid"></div>
<footer>Click a design to draw it. Each one starts with an erase spiral (turn off "erase first" to draw over the sand as it is).
Make your own at <a href="https://sandify.org" target="_blank" rel="noopener">sandify.org</a> (export as Theta Rho .thr), or upload a line drawing as .svg: every line in it is joined into one path for the ball.
· <a href="/jog">Jog &amp; calibrate</a></footer>
</main>
<script>
let LIB=[], CAT='all', ST=null;
const $=id=>document.getElementById(id);
const fmt=s=>s==null?'':(s>=3600?Math.floor(s/3600)+' h '+Math.round(s%3600/60)+' min':s>=60?Math.round(s/60)+' min':s+' s');
const thumb=id=>'/thumb?id='+encodeURIComponent(id);
async function post(path, body){
const r=await fetch('/api/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
const j=await r.json(); if(!j.ok) showErr(j.msg); else if(path!=='settings') showErr(''); tick(); return j;
}
function showErr(m){const e=$('err'); e.textContent=m||''; e.style.display=m?'block':'none';}
function visible(){return LIB.filter(d=>CAT==='all'?true:d.category===CAT);}
function playAll(){
const ids=visible().filter(d=>d.category!=='tools').map(d=>d.id);
if(!ids.length){showErr('nothing to play in this list');return;}
post('play',{ids,loop:$('loop').checked,shuffle:$('shuffle').checked});
}
function playOne(d){
if(ST && ST.player.state!=='idle' && !confirm('Stop what is playing and draw "'+d.name+'" instead?')) return;
post('play',{ids:[d.id]});
}
async function del(d,ev){
ev.stopPropagation();
if(!confirm('Remove "'+d.name+'" from the library? (It moves to the removed-patterns folder.)')) return;
await post('delete',{id:d.id}); loadLib();
}
function pauseResume(){post(ST&&ST.player.state==='paused'?'resume':'pause');}
function saveSettings(){$('speedVal').textContent=$('speed').value+' mm/s';
post('settings',{speed:+$('speed').value,erase:$('erase').checked});}
async function upload(files){
let last='';
for(const f of files){
const r=await fetch('/api/upload?name='+encodeURIComponent(f.name),{method:'POST',body:await f.text()});
const j=await r.json(); if(!j.ok){showErr(f.name+': '+j.msg);return;} last=j.msg;
}
$('file').value=''; CAT='uploads'; await loadLib(); showErr('');
}
function renderChips(){
const cats=['all',...new Set(LIB.map(d=>d.category))];
$('chips').innerHTML='';
for(const c of cats){const b=document.createElement('button'); b.textContent=c; if(c===CAT)b.className='on';
b.onclick=()=>{CAT=c;renderChips();renderGrid();}; $('chips').appendChild(b);}
}
function renderGrid(){
const g=$('grid'); g.innerHTML='';
for(const d of visible()){
const t=document.createElement('div'); t.className='tile'; t.dataset.id=d.id;
t.innerHTML='<img loading="lazy" alt=""><div class="name"></div><div class="meta"><span class="mute"></span><button class="main">Play</button></div><button class="del" title="Remove">✕</button>';
t.querySelector('img').src=thumb(d.id); t.querySelector('.name').textContent=d.name; t.querySelector('.name').title=d.id;
t.querySelector('.mute').textContent=d.est!=null?'~'+fmt(d.est):'';
t.querySelector('img').onclick=()=>playOne(d); t.querySelector('.main').onclick=()=>playOne(d);
t.querySelector('.del').onclick=ev=>del(d,ev);
g.appendChild(t);
}
markPlaying();
}
function markPlaying(){
const id=ST&&ST.player.now?ST.player.now.id:null;
document.querySelectorAll('.tile').forEach(t=>t.classList.toggle('playing',t.dataset.id===id));
}
async function loadLib(){LIB=await (await fetch('/api/library')).json(); if(CAT!=='all'&&!LIB.some(d=>d.category===CAT))CAT='all'; renderChips(); renderGrid();
if(LIB.some(d=>d.est==null)) setTimeout(loadLib,4000);} // running times arrive as the thumbnails are made
let settingsLoaded=false, lastConnErr='';
async function tick(){
try{
ST=await (await fetch('/api/status')).json();
const m=ST.machine, p=ST.player;
$('mach').textContent=m.connected?m.state+' · rho '+m.rho.toFixed(1)+' mm · '+(m.homed?'homed':'not homed'):'USB disconnected';
$('mach').style.color=m.connected?'':'var(--stop)';
$('homeBtn').disabled=p.state!=='idle'||!m.connected;
if(!m.connected && !p.message) showErr(m.error);
else if(m.connected && !p.message && $('err').textContent===lastConnErr) showErr('');
lastConnErr=m.connected?'':m.error;
if(!settingsLoaded){$('speed').value=p.settings.speed;$('speedVal').textContent=p.settings.speed+' mm/s';$('erase').checked=p.settings.erase;settingsLoaded=true;}
if(p.message) showErr(p.message);
const now=p.now;
$('now').style.display=now?'flex':'none';
if(now){
const src=now.id?thumb(now.id):''; if($('nowImg').dataset.src!==src){$('nowImg').src=src;$('nowImg').dataset.src=src;$('nowImg').style.visibility=src?'visible':'hidden';}
$('nowName').textContent=now.name;
$('nowPhase').textContent=(p.state==='paused'?'Paused':p.state==='stopping'?'Stopping…':{homing:'Homing',erasing:'Erasing first',drawing:'Drawing'}[now.phase]||'')+(p.queue>1?' · '+p.queue+' in the list'+(p.loop?', repeating':''):'');
$('nowBar').style.width=(100*now.frac).toFixed(1)+'%';
$('nowTime').textContent=Math.round(100*now.frac)+'% · '+fmt(now.elapsed)+' gone · about '+fmt(now.left)+' left';
$('pauseBtn').textContent=p.state==='paused'?'Resume':'Pause';
}
markPlaying();
}catch(e){$('mach').textContent='offline: is `python3 -m sandtable web` still running?';}
}
loadLib(); tick(); setInterval(tick,1000);
</script></body></html>
"""
def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='/', alert=None, library=None,
connected=True):
panel = Panel(ctrl, cfg, connected=connected)
lib = library or Library(cfg)
player = Player(panel, cfg, lib, alert=alert)
threading.Thread(target=lib.warm, daemon=True).start()
jog_page = JOG_PAGE.replace('<h1>Sand table: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Sand table: jog &amp; calibrate</h1>')
def busy():
if not panel.connected:
return False, panel.error or 'the Uno is not connected'
return (False, 'a design is playing: stop it first') if player.running() else None
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def _send(self, code, body, ctype='application/json'):
data = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header('Content-Type', ctype)
self.send_header('Content-Length', str(len(data)))
self.send_header('Cache-Control', 'no-store' if ctype != 'image/png' else 'max-age=3600')
self.end_headers()
self.wfile.write(data)
def do_GET(self):
u = urlparse(self.path)
q = parse_qs(u.query)
if u.path in ('/', '/index.html'):
self._send(200, PAGE, 'text/html; charset=utf-8')
elif u.path == '/jog':
self._send(200, jog_page, 'text/html; charset=utf-8')
elif u.path == '/api/status':
st = panel.status()
st['homed'] = panel.homed
self._send(200, json.dumps({'machine': st, 'player': player.status(),
'state': st['state'], 'theta': st['theta'], 'rho': st['rho'],
'switch': st['switch'], 'pins': st['pins'], 'homed': st['homed'], 'steps': st['steps'],
'error': st['error']}))
elif u.path == '/api/library':
self._send(200, json.dumps(lib.items()))
elif u.path == '/thumb':
try:
with open(lib.thumb(q.get('id', [''])[0]), 'rb') as f:
self._send(200, f.read(), 'image/png')
except Exception:
self._send(404, '{}')
else:
self._send(404, '{}')
def do_POST(self):
u = urlparse(self.path)
n = int(self.headers.get('Content-Length') or 0)
raw = self.rfile.read(n) if n else b''
act = u.path.rsplit('/', 1)[-1]
try:
if act == 'upload':
name = parse_qs(u.query).get('name', ['design.thr'])[0]
ok, msg = True, lib.add(name, raw.decode('utf-8', 'replace'))
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
return
body = json.loads(raw or b'{}')
if act == 'play' and not panel.connected:
ok, msg = False, panel.error or 'the Uno is not connected'
elif act == 'play':
player.play(body.get('ids') or [], loop=bool(body.get('loop')), shuffle=bool(body.get('shuffle')))
ok, msg = True, 'playing'
elif act == 'pause':
player.pause(); ok, msg = True, 'paused'
elif act == 'resume':
player.resume(); ok, msg = True, 'playing'
elif act == 'skip':
player.skip(); ok, msg = True, 'skipping'
elif act == 'stop':
if player.running():
player.stop(); ok, msg = True, 'stopped'
else:
ok, msg = panel.stop()
elif act == 'settings':
if 'speed' in body:
player.speed = max(5.0, min(60.0, float(body['speed'])))
if 'erase' in body:
player.erase = bool(body['erase'])
if 'pause' in body:
player.pause_s = max(0.0, float(body['pause']))
ok, msg = True, 'saved'
elif act == 'delete':
lib.remove(body.get('id', '')); ok, msg = True, 'removed'
elif act in ('jog', 'home', 'unlock', 'calibrate'):
ok, msg = busy() or {
'jog': lambda: panel.jog(body.get('axis'), body.get('d', 0)),
'home': panel.home,
'unlock': panel.unlock,
'calibrate': lambda: panel.calibrate(body.get('key'), body.get('commanded'), body.get('measured')),
}[act]()
else:
ok, msg = False, 'unknown action'
except Exception as e:
ok, msg = False, str(e)
if not ok:
log(f'page: {act} {raw[:200]!r} -> {msg}')
self._send(200, json.dumps({'ok': ok, 'msg': msg}))
log(f'page started, {"connected to " + str(getattr(ctrl.port, "port", "the Uno")) if connected else "waiting for the Uno"}')
srv = ThreadingHTTPServer((host, port), H)
srv.daemon_threads = True
url = f'http://{"127.0.0.1" if host in ("0.0.0.0", "") else host}:{port}'
print(f'Sand table page at {url}{open_path} (Ctrl-C here to quit)', flush=True)
if host in ('0.0.0.0', ''):
print(' Also open to other devices on your network, with no password: anyone on it can drive the table.')
if open_browser:
try:
webbrowser.open(url + open_path)
except Exception:
pass
try:
srv.serve_forever()
except KeyboardInterrupt:
pass
finally:
player.stop()
srv.server_close()