Sand table software
A small Python program that drives the table from your computer over USB. It has a web page with a library of designs, plays .thr pattern files (yours or the built-in ones), and erases the sand with a spiral between patterns.
- Now: the Arduino Uno + CNC shield in the table, running stock GRBL 1.1.
- Later: the custom ESP32 board running FluidNC (one setting changes; see the end).
Status: running on the table since 2026-11-01. On a new build, do the first-run steps below in order.
Web page (everyday use)
python3 -m sandtable web
This opens http://127.0.0.1:8765 in your browser. The table stays on the USB cable and the page is the remote control, so leave the program running in its terminal (Ctrl-C there quits).
- Library: every design in
patterns/, grouped by folder, each with a picture and its drawing time. Click one to draw it; it erases the sand first. - Play all: plays the designs in the list you're looking at (one category, or all), shuffled and repeating if those are ticked. The
toolsfolder is left out. - Now playing:
- shows progress and the time left, with Pause, Skip and Stop;
- STOP at the top stops everything at once;
- the position is kept after a stop, so the next design starts without homing again.
- Speed and "erase first": these apply from the next design on, and reset when the program restarts. Lasting defaults go in
sandtable.toml. - Motors off when done: when the designs finish, or you press Stop, the motors switch off, so the table is quiet and they don't sit warm. The status then reads "Motors off".
- The arm can be turned by hand then, so the next design homes first.
- To keep them powered, set
motors_off_when_done = falseunder[motion]insandtable.toml.
- Upload .thr / .svg: adds your own designs. They go in
patterns/uploads/..thr: for example from Sandify..svg: a line drawing. Every line in it is joined into one path for the ball (see "Line drawings" below).
- ✕ on a design: moves it to
removed-patterns/. Move it back to restore it. - Jog & calibrate: the link at the bottom opens the jog panel. While a design plays, its buttons are refused.
- Problems: if the home switch guard trips or the USB link drops, the page shows what happened in red, and a desktop notification pops up.
- From a phone: run
python3 -m sandtable web --host 0.0.0.0, then openhttp://<this computer's address>:8765. There is no password, so only do this on your home network. - Without the table:
python3 tests/demo_web.pyruns the same page against the simulator at http://127.0.0.1:8766.
One-time setup
- Python packages:
pip install -r requirements.txt(pyserial and Pillow; both are probably installed already). - USB permission (Linux):
sudo usermod -aG dialout $USER, then log out and back in. - Flashing tool:
arduino-cli(installed in~/.local/binon this PC, with the Arduino AVR core, which includes avrdude). On another computer: download it from the arduino-cli releases, then runarduino-cli core install arduino:avr. - From this
softwarefolder, with the Uno on USB:
| Step | Command | What it does |
|---|---|---|
| Find the port | python3 -m sandtable ports |
Lists serial ports. auto finds the Uno if it's the only one. |
| Flash GRBL | python3 -m sandtable flash |
Puts GRBL 1.1h on the Uno (replacing whatever program is there) and clears its stored settings. Only USB is needed; leave the 12 V supply off. Skip this if info already shows Grbl 1.1. |
| Power | Now plug in the table's 12 V supply. | |
| Check | python3 -m sandtable info |
Shows the GRBL version and which settings differ from this table's. |
| Settings | python3 -m sandtable setup |
Writes this table's GRBL settings: steps per degree / mm, speeds, accelerations, motors always on, no limits. Asks before writing. |
| Directions | python3 -m sandtable test |
Turns the arm 90° and moves the magnet 15 mm, asks if each went the right way, and fixes GRBL's direction setting if not. Slide the carriage to mid-arm first, as it says. |
| Home | python3 -m sandtable home |
Finds the home switch and sets rho. |
| Mirror check | python3 -m sandtable play patterns/tools/test_f.thr |
Draws a letter F. If it comes out mirrored, set invert_theta = true in sandtable.toml. |
| Calibrate | python3 -m sandtable ui |
The web page, opened at the jog panel (see below). Jog a known amount, measure, and let it correct the steps. |
This table (2026-10-31): the drivers are BIGTREETECH DRV8825 V2.0 modules. With only the M2 jumper fitted they ran at 1/16 microstepping, and both axes moved twice as far as the defaults assume (measured: 35.556 steps/deg, 80 steps/mm). The M0 and M1 jumpers were then added, so they now run at 1/32, the defaults apply again (71.111 / 160), and sandtable.toml says so. Theta also needed its direction flipped ($3=1, set by test). With these, the carriage reaches about 144 mm, so the 133 mm limit leaves about 11 mm to spare.
Jog panel
The web page's second page, http://127.0.0.1:8765/jog (python3 -m sandtable ui opens it directly). It shows the state, rho, theta and the home switch live, and has:
- STOP, Home rho and Unlock;
- rho jogs of ±10 and ±0.5 mm, and theta jogs of ±90, ±10 and ±0.5°;
- a Calibrate box: enter the commanded and the measured distance, and it rescales the steps on the Uno.
The web program keeps the USB port open, so the Uno doesn't restart between moves. Close it (Ctrl-C) before running play from the command line.
- Inward jogs stop by themselves when they press the home switch, and are refused while it's pressed (only + works then).
- Outward jogs have no switch at the far end, so watch the carriage there.
Playing patterns
python3 -m sandtable play patterns/flowers/rose-5.thr
python3 -m sandtable play patterns/flowers patterns/spirals --shuffle --loop # every .thr in those folders, forever
python3 -m sandtable play mine/*.thr --speed 20 --pause 60 # slower, 1 minute between patterns
python3 -m sandtable play "gen:rose:petals=7,copies=3,twist_deg=10"
python3 -m sandtable erase # spiral-erase, ball ends at the centre
- Homing: each
playhomes rho first. The Uno restarts whenever the port is opened, so it doesn't remember where the carriage was. - Erasing: before each pattern the ball spirals across the whole tray (7 mm apart) and finishes exactly where the pattern starts. Turn it off with
--no-erase. - Pause and stop: press Ctrl-C to pause (feed hold), then choose resume or stop.
- Motors off: when
playfinishes or is stopped, it switches the motors off ($SLP), unlessmotors_off_when_done = false. - Home switch guard: see the next section.
- Preview first: add
--dry-run --preview out.pngto see the pattern and its running time without moving anything;--gcode out.gcodealso writes the G-code. - Other options:
--rotate DEGand--mirrorchange how a pattern is placed.
Home switch guard
Patterns never press the home switch: the ball stops 3 mm from the centre (rho_min_mm), 1 mm past where the switch trips. Under a 9.5 mm ball that doesn't show in the sand.
So if the switch is pressed while a pattern runs, the carriage has lost its place: missed steps, a slipping belt, or something in the way. Left running, it would grind into the end stop, and later patterns could run it off the other end of the arm. Instead play (and move):
- resets the controller at once, which stops both motors immediately;
- beeps, prints what happened and where, and pops up a desktop notification;
- quits, even with
--loop.
Check the arm, belt and carriage, then run play again; it homes first.
Details:
- The switch state comes from the controller's status reports, asked for about 12 times a second. A press must show in two reports in a row, so one electrical blip on the long switch wires doesn't stop a pattern.
- Right after homing the switch is still pressed; that's expected until the carriage has moved off it. If it stays pressed more than 5 mm out, the switch is stuck or its wiring is shorted, and that stops the machine too.
- On the ESP32 board, FluidNC's own hard limit for rho is on as well (
../pcb/fluidnc/config.yaml), so the firmware stops by itself even without the computer.
Patterns
.thrfiles: each line istheta rho, with theta in radians (it keeps counting past 2π) and rho from 0 at the centre to 1 at the edge. That's the format used by:- Sandify: design a pattern, then export it as Theta Rho (
.thr); - the Sisyphus and Dune Weaver pattern libraries. Patterns should start and end at the centre or the edge; most do.
- Sandify: design a pattern, then export it as Theta Rho (
- The library (
patterns/): 27 designs in foldersflowers,geometric,spirals,spirographandtools(the mirror-check F), plusuploadsfor your own.- Running times range from 2 to 17 minutes, about 3 hours in all, plus about 4 minutes of erasing before each.
python3 tools/make_library.pywrites the built-in ones again; add to its list to change them. It never touchesuploads/.
- Built-in generators:
python3 -m sandtable gen listshows them with their settings:- spiral, rose, spirograph, polygons, ripples, string_art, lissajous, sunburst, spiral_arms (galaxy), and test_f.
- Make one with
gen NAME key=value .... Add--preview p.pngto look at it,--save file.thrto keep it, or--playto draw it now.
Line drawings and fan art
The ball can't lift, so a drawing's separate lines have to become one line. sandtable/lineart.py does that:
- Joining: to get from one stroke to the next, the ball runs back along lines it has already drawn, which leaves no new mark. It only crosses fresh sand where it has to, along the shortest line it can.
- Start and end: each drawing starts and ends at the edge or the centre, whichever is nearer, so the erase spiral can hand over to it.
- What it reads from SVG files: paths (lines, curves, arcs), lines, polylines, polygons, rectangles, circles and ellipses, including transforms.
- Every shape is drawn as its outline; fills are ignored.
- Text and images are skipped; convert text to paths first.
- How to draw: use center lines for thin parts and outlines for solid ones, and keep it simple. A busy picture means more connecting lines.
Fan art: on my own table I drew logos and characters from anime and games the same way: a short Python script with a small drawing kit (ellipses, curves, pixel-art outlines, and clipping, so lines behind a shape are hidden, such as crossbones behind a skull). Those designs aren't in this repository, because the logos belong to their owners. Draw your own in any vector program and upload the SVG.
Settings (sandtable.toml)
| Setting | Default | Meaning |
|---|---|---|
serial.port |
auto |
Or a port such as /dev/ttyACM0 |
controller.type |
grbl |
fluidnc for the ESP32 board |
machine.rho_max_mm |
133 | How far out the ball goes |
machine.rho_min_mm |
3 | How close to the centre the ball goes. Keep it at least 1 mm more than homing.switch_rho_mm (2), or patterns press the switch |
machine.invert_theta |
false | Set to true if patterns come out mirrored |
machine.theta_offset_deg |
0 | Turns every pattern |
motion.ball_speed_mm_s |
25 | Drawing speed. Slower is crisper and quieter |
motion.max_theta_deg_s |
60 | Arm spin limit, which also limits speed near the centre |
motion.motors_off_when_done |
true | Switch the motors off when the designs finish or are stopped; the next design homes first |
erase.pitch_mm, erase.speed_mm_s |
7, 35 | Erase spiral spacing and speed |
All other defaults (steps, accelerations, homing speeds) are in sandtable/config.py.
How it works
- Axes: X is theta in degrees (unlimited), Y is rho in mm.
- The controller moves X and Y together in a straight line, which on the sand is a short piece of spiral. That is exactly how
.thrpoints are meant to be joined. - Long moves are split into pieces of at most 1.5 mm or 3°.
- Each piece gets its own feed rate so the ball crosses the sand at a steady speed. Without that it would crawl near the centre and race at the rim.
- The controller moves X and Y together in a straight line, which on the sand is a short piece of spiral. That is exactly how
- Streaming: uses GRBL's character-counting method, so the Uno's 128-byte input buffer stays full without overflowing. The tests check this with a simulator.
- Homing from the computer: GRBL's built-in homing expects a switch on every axis it homes (X and Y by default), and this table only has a rho switch. So the program jogs the carriage toward the centre while watching the switch state in GRBL's status reports, backs off, comes in slowly, and sets rho there. Stock GRBL needs no changes.
- Unwinding the arm: the arm's angle keeps adding up from pattern to pattern. Each erase spiral turns the way that brings the total back toward zero, which keeps the controller's numbers small and precise.
- Code layout:
| File | What |
|---|---|
thr.py |
.thr files |
patterns.py |
Generators |
motion.py |
Erase, splitting and feed rates |
grbl.py |
Serial link and homing |
preview.py |
Pictures |
web.py |
Web page: library, player, upload |
lineart.py |
SVG and line drawings → one path |
ui.py |
Jog panel |
cli.py |
Commands |
Firmware
firmware/grbl_v1.1h.20190825.hex is the official GRBL 1.1h build from gnea/grbl, unchanged. It's under the GPL v3 (firmware/COPYING-GRBL); the rest of this repository is CC BY-NC-SA 4.0. The file is:
- SHA-256
1198b332…673165d; - 29,920 bytes, which fits an Uno with its bootloader;
- includes the switch-state field (
Pn:) that the homing uses.
flash uploads it with arduino-cli:
- First try: as an Uno.
- Then: as an old-bootloader Nano (some clones).
- If both fail: try another USB cable; many are charge-only.
Tests
python3 -m unittest discover -s tests -v runs:
- the pattern and motion maths;
- streaming, homing and stopping against a GRBL simulator (
tests/fakegrbl.py), which errors if the Uno's receive buffer would overflow; - the web page's library, player (play, pause, skip, stop, switch trip, USB unplugged and back) and HTTP API, against the same simulator;
- SVG reading and joining line drawings.
The tests never pop up desktop notifications (they set SANDTABLE_QUIET).
Moving to the ESP32 board (FluidNC)
- Flash FluidNC and upload
../pcb/fluidnc/config.yaml(see../pcb/README.md). - Set
type = "fluidnc"under[controller]insandtable.toml. - Skip
setup: FluidNC takes its settings fromconfig.yaml. Homing uses FluidNC's own$H. The board runs the DRV8825s at 1/32 (M0–M2 tied high), soconfig.yamlkeeps 71.111 steps/deg and 160 steps/mm, double the CNC-shield values above. - If
testsays a direction is wrong, add:lowto that axis'sdirection_pininconfig.yaml.

