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,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')
|
||||
Reference in new issue
Block a user