# 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). ![built-in patterns](previews/all-patterns.png) **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). ![web page](previews/web-page.png) - **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 `tools` folder 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`. - **Upload .thr / .svg:** adds your own designs. They go in `patterns/uploads/`. - `.thr`: for example from [Sandify](https://sandify.org). - `.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 open `http://:8765`. There is no password, so only do this on your home network. - **Without the table:** `python3 tests/demo_web.py` runs the same page against the simulator at http://127.0.0.1:8766. ## One-time setup 1. **Python packages:** `pip install -r requirements.txt` (pyserial and Pillow; both are probably installed already). 2. **USB permission (Linux):** `sudo usermod -aG dialout $USER`, then log out and back in. 3. **Flashing tool:** `arduino-cli` (installed in `~/.local/bin` on this PC, with the Arduino AVR core, which includes avrdude). On another computer: download it from [the arduino-cli releases](https://github.com/arduino/arduino-cli/releases), then run `arduino-cli core install arduino:avr`. 4. From this `software` folder, 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 `play` homes 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. Stopping keeps the position. - **Home switch guard:** see the next section. - **Preview first:** add `--dry-run --preview out.png` to see the pattern and its running time without moving anything; `--gcode out.gcode` also writes the G-code. - **Other options:** `--rotate DEG` and `--mirror` change 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`): 1. resets the controller at once, which stops both motors immediately; 2. beeps, prints what happened and where, and pops up a desktop notification; 3. 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 - **`.thr` files:** each line is `theta 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](https://sandify.org): 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. - **The library (`patterns/`):** 27 designs in folders `flowers`, `geometric`, `spirals`, `spirograph` and `tools` (the mirror-check F), plus `uploads` for 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.py` writes the built-in ones again; add to its list to change them. It never touches `uploads/`. - **Built-in generators:** `python3 -m sandtable gen list` shows 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.png` to look at it, `--save file.thr` to keep it, or `--play` to 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 | | `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 `.thr` points 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. - **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](https://github.com/gnea/grbl/releases/tag/v1.1h.20190825) (GPLv3), unchanged: - 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) 1. Flash FluidNC and upload `../pcb/fluidnc/config.yaml` (see `../pcb/README.md`). 2. Set `type = "fluidnc"` under `[controller]` in `sandtable.toml`. 3. Skip `setup`: FluidNC takes its settings from `config.yaml`. Homing uses FluidNC's own `$H`. The board runs the DRV8825s at 1/32 (M0–M2 tied high), so `config.yaml` keeps 71.111 steps/deg and 160 steps/mm, double the CNC-shield values above. 4. If `test` says a direction is wrong, add `:low` to that axis's `direction_pin` in `config.yaml`.