Software: pattern player, web page with a design library, GRBL tools
- python3 -m sandtable web: local web page to play designs from a library of 27 built-in patterns, upload .thr or .svg, pause/skip/stop, jog and calibrate. Reconnects by itself when the USB cable comes back. - Plays .thr files and built-in generators with an erase spiral between them, constant ball speed, host-side homing on stock GRBL 1.1 (Uno + CNC shield), FluidNC for the ESP32 board. - Home switch guard: patterns never press the switch, so a press means lost steps and stops the machine at once with an alert. - lineart.py turns SVG line drawings into one continuous path. - Tests against a GRBL simulator. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1400cc8afd
commit
95ad4d25de
52 files changed
+388117
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Sand table pattern player: .thr files and built-in patterns -> GRBL / FluidNC over USB."""
|
||||
@@ -0,0 +1,3 @@
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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 & 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)">−10 mm</button><button onclick="jog('Y',-0.5)">−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)">−90°</button><button onclick="jog('X',-10)">−10°</button>
|
||||
<button onclick="jog('X',-0.5)">−0.5°</button><button onclick="jog('X',0.5)">+0.5°</button>
|
||||
<button onclick="jog('X',10)">+10°</button><button onclick="jog('X',90)">+90°</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()
|
||||
@@ -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 & 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 & calibrate</h1>',
|
||||
'<p><a href="/">← Library</a></p><h1>Sand table: jog & 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()
|
||||
Reference in new issue
Block a user