Software: pattern player, web page with a design library, GRBL tools

- python3 -m sandtable web: local web page to play designs from a library of
  27 built-in patterns, upload .thr or .svg, pause/skip/stop, jog and calibrate.
  Reconnects by itself when the USB cable comes back.
- Plays .thr files and built-in generators with an erase spiral between them,
  constant ball speed, host-side homing on stock GRBL 1.1 (Uno + CNC shield),
  FluidNC for the ESP32 board.
- Home switch guard: patterns never press the switch, so a press means lost
  steps and stops the machine at once with an alert.
- lineart.py turns SVG line drawings into one continuous path.
- Tests against a GRBL simulator.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 13:40:23 -05:00
1 parent 1400cc8afd
commit 95ad4d25de
52 files changed
+388117

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
previews/*.gcode
__pycache__/
*.pyc
.cache/
removed-patterns/
# fan art: trademarked logos and characters, for my own table only (not published)
patterns/fan-art/
art/fan-art/
tools/make_fan_art.py
previews/fan-art.png
+180
View File
@@ -0,0 +1,180 @@
# 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://<this computer's address>: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`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+738
View File
@@ -0,0 +1,738 @@
# sandtable generator rose petals=4
0.00000 1.00000
0.00873 0.99939
0.01745 0.99756
0.02618 0.99452
0.03491 0.99027
0.04363 0.98481
0.05236 0.97815
0.06109 0.97030
0.06981 0.96126
0.07854 0.95106
0.08727 0.93969
0.09599 0.92718
0.10472 0.91355
0.11345 0.89879
0.12217 0.88295
0.13090 0.86603
0.13963 0.84805
0.14835 0.82904
0.15708 0.80902
0.16581 0.78801
0.17453 0.76604
0.18326 0.74314
0.19199 0.71934
0.20071 0.69466
0.20944 0.66913
0.21817 0.64279
0.22689 0.61566
0.23562 0.58779
0.24435 0.55919
0.25307 0.52992
0.26180 0.50000
0.27053 0.46947
0.27925 0.43837
0.28798 0.40674
0.29671 0.37461
0.30543 0.34202
0.31416 0.30902
0.32289 0.27564
0.33161 0.24192
0.34034 0.20791
0.34907 0.17365
0.35779 0.13917
0.36652 0.10453
0.37525 0.06976
0.38397 0.03490
0.38397 0.00000
0.38397 0.00000
-2.74017 0.00000
-2.74017 0.03490
-2.73144 0.06976
-2.72271 0.10453
-2.71399 0.13917
-2.70526 0.17365
-2.69653 0.20791
-2.68781 0.24192
-2.67908 0.27564
-2.67035 0.30902
-2.66163 0.34202
-2.65290 0.37461
-2.64417 0.40674
-2.63545 0.43837
-2.62672 0.46947
-2.61799 0.50000
-2.60927 0.52992
-2.60054 0.55919
-2.59181 0.58779
-2.58309 0.61566
-2.57436 0.64279
-2.56563 0.66913
-2.55691 0.69466
-2.54818 0.71934
-2.53945 0.74314
-2.53073 0.76604
-2.52200 0.78801
-2.51327 0.80902
-2.50455 0.82904
-2.49582 0.84805
-2.48709 0.86603
-2.47837 0.88295
-2.46964 0.89879
-2.46091 0.91355
-2.45219 0.92718
-2.44346 0.93969
-2.43473 0.95106
-2.42601 0.96126
-2.41728 0.97030
-2.40855 0.97815
-2.39983 0.98481
-2.39110 0.99027
-2.38237 0.99452
-2.37365 0.99756
-2.36492 0.99939
-2.35619 1.00000
-2.34747 0.99939
-2.33874 0.99756
-2.33001 0.99452
-2.32129 0.99027
-2.31256 0.98481
-2.30383 0.97815
-2.29511 0.97030
-2.28638 0.96126
-2.27765 0.95106
-2.26893 0.93969
-2.26020 0.92718
-2.25147 0.91355
-2.24275 0.89879
-2.23402 0.88295
-2.22529 0.86603
-2.21657 0.84805
-2.20784 0.82904
-2.19911 0.80902
-2.19039 0.78801
-2.18166 0.76604
-2.17293 0.74314
-2.16421 0.71934
-2.15548 0.69466
-2.14675 0.66913
-2.13803 0.64279
-2.12930 0.61566
-2.12058 0.58779
-2.11185 0.55919
-2.10312 0.52992
-2.09440 0.50000
-2.08567 0.46947
-2.07694 0.43837
-2.06822 0.40674
-2.05949 0.37461
-2.05076 0.34202
-2.04204 0.30902
-2.03331 0.27564
-2.02458 0.24192
-2.01586 0.20791
-2.00713 0.17365
-1.99840 0.13917
-1.98968 0.10453
-1.98095 0.06976
-1.97222 0.03490
-1.97222 0.00000
-1.97222 0.00000
-5.09636 0.00000
-5.09636 0.03490
-5.08763 0.06976
-5.07891 0.10453
-5.07018 0.13917
-5.06145 0.17365
-5.05273 0.20791
-5.04400 0.24192
-5.03527 0.27564
-5.02655 0.30902
-5.01782 0.34202
-5.00909 0.37461
-5.00037 0.40674
-4.99164 0.43837
-4.98292 0.46947
-4.97419 0.50000
-4.96546 0.52992
-4.95674 0.55919
-4.94801 0.58779
-4.93928 0.61566
-4.93056 0.64279
-4.92183 0.66913
-4.91310 0.69466
-4.90438 0.71934
-4.89565 0.74314
-4.88692 0.76604
-4.87820 0.78801
-4.86947 0.80902
-4.86074 0.82904
-4.85202 0.84805
-4.84329 0.86603
-4.83456 0.88295
-4.82584 0.89879
-4.81711 0.91355
-4.80838 0.92718
-4.79966 0.93969
-4.79093 0.95106
-4.78220 0.96126
-4.77348 0.97030
-4.76475 0.97815
-4.75602 0.98481
-4.74730 0.99027
-4.73857 0.99452
-4.72984 0.99756
-4.72112 0.99939
-4.71239 1.00000
-4.70366 0.99939
-4.69494 0.99756
-4.68621 0.99452
-4.67748 0.99027
-4.66876 0.98481
-4.66003 0.97815
-4.65130 0.97030
-4.64258 0.96126
-4.63385 0.95106
-4.62512 0.93969
-4.61640 0.92718
-4.60767 0.91355
-4.59894 0.89879
-4.59022 0.88295
-4.58149 0.86603
-4.57276 0.84805
-4.56404 0.82904
-4.55531 0.80902
-4.54658 0.78801
-4.53786 0.76604
-4.52913 0.74314
-4.52040 0.71934
-4.51168 0.69466
-4.50295 0.66913
-4.49422 0.64279
-4.48550 0.61566
-4.47677 0.58779
-4.46804 0.55919
-4.45932 0.52992
-4.45059 0.50000
-4.44186 0.46947
-4.43314 0.43837
-4.42441 0.40674
-4.41568 0.37461
-4.40696 0.34202
-4.39823 0.30902
-4.38950 0.27564
-4.38078 0.24192
-4.37205 0.20791
-4.36332 0.17365
-4.35460 0.13917
-4.34587 0.10453
-4.33714 0.06976
-4.32842 0.03490
-4.32842 0.00000
-4.32842 0.00000
-7.45256 0.00000
-7.45256 0.03490
-7.44383 0.06976
-7.43510 0.10453
-7.42638 0.13917
-7.41765 0.17365
-7.40892 0.20791
-7.40020 0.24192
-7.39147 0.27564
-7.38274 0.30902
-7.37402 0.34202
-7.36529 0.37461
-7.35656 0.40674
-7.34784 0.43837
-7.33911 0.46947
-7.33038 0.50000
-7.32166 0.52992
-7.31293 0.55919
-7.30420 0.58779
-7.29548 0.61566
-7.28675 0.64279
-7.27802 0.66913
-7.26930 0.69466
-7.26057 0.71934
-7.25184 0.74314
-7.24312 0.76604
-7.23439 0.78801
-7.22566 0.80902
-7.21694 0.82904
-7.20821 0.84805
-7.19948 0.86603
-7.19076 0.88295
-7.18203 0.89879
-7.17330 0.91355
-7.16458 0.92718
-7.15585 0.93969
-7.14712 0.95106
-7.13840 0.96126
-7.12967 0.97030
-7.12094 0.97815
-7.11222 0.98481
-7.10349 0.99027
-7.09476 0.99452
-7.08604 0.99756
-7.07731 0.99939
-7.06858 1.00000
-7.05986 0.99939
-7.05113 0.99756
-7.04240 0.99452
-7.03368 0.99027
-7.02495 0.98481
-7.01622 0.97815
-7.00750 0.97030
-6.99877 0.96126
-6.99004 0.95106
-6.98132 0.93969
-6.97259 0.92718
-6.96386 0.91355
-6.95514 0.89879
-6.94641 0.88295
-6.93768 0.86603
-6.92896 0.84805
-6.92023 0.82904
-6.91150 0.80902
-6.90278 0.78801
-6.89405 0.76604
-6.88532 0.74314
-6.87660 0.71934
-6.86787 0.69466
-6.85914 0.66913
-6.85042 0.64279
-6.84169 0.61566
-6.83296 0.58779
-6.82424 0.55919
-6.81551 0.52992
-6.80678 0.50000
-6.79806 0.46947
-6.78933 0.43837
-6.78060 0.40674
-6.77188 0.37461
-6.76315 0.34202
-6.75442 0.30902
-6.74570 0.27564
-6.73697 0.24192
-6.72824 0.20791
-6.71952 0.17365
-6.71079 0.13917
-6.70206 0.10453
-6.69334 0.06976
-6.68461 0.03490
-6.68461 0.00000
-6.68461 0.00000
-9.80875 0.00000
-9.80875 0.03490
-9.80002 0.06976
-9.79130 0.10453
-9.78257 0.13917
-9.77384 0.17365
-9.76512 0.20791
-9.75639 0.24192
-9.74766 0.27564
-9.73894 0.30902
-9.73021 0.34202
-9.72148 0.37461
-9.71276 0.40674
-9.70403 0.43837
-9.69530 0.46947
-9.68658 0.50000
-9.67785 0.52992
-9.66912 0.55919
-9.66040 0.58779
-9.65167 0.61566
-9.64294 0.64279
-9.63422 0.66913
-9.62549 0.69466
-9.61676 0.71934
-9.60804 0.74314
-9.59931 0.76604
-9.59058 0.78801
-9.58186 0.80902
-9.57313 0.82904
-9.56440 0.84805
-9.55568 0.86603
-9.54695 0.88295
-9.53822 0.89879
-9.52950 0.91355
-9.52077 0.92718
-9.51204 0.93969
-9.50332 0.95106
-9.49459 0.96126
-9.48586 0.97030
-9.47714 0.97815
-9.46841 0.98481
-9.45968 0.99027
-9.45096 0.99452
-9.44223 0.99756
-9.43350 0.99939
-9.42478 1.00000
-9.41605 0.99939
-9.40732 0.99756
-9.39860 0.99452
-9.38987 0.99027
-9.38114 0.98481
-9.37242 0.97815
-9.36369 0.97030
-9.35496 0.96126
-9.34624 0.95106
-9.33751 0.93969
-9.32878 0.92718
-9.32006 0.91355
-9.31133 0.89879
-9.30260 0.88295
-9.29388 0.86603
-9.28515 0.84805
-9.27642 0.82904
-9.26770 0.80902
-9.25897 0.78801
-9.25025 0.76604
-9.24152 0.74314
-9.23279 0.71934
-9.22407 0.69466
-9.21534 0.66913
-9.20661 0.64279
-9.19789 0.61566
-9.18916 0.58779
-9.18043 0.55919
-9.17171 0.52992
-9.16298 0.50000
-9.15425 0.46947
-9.14553 0.43837
-9.13680 0.40674
-9.12807 0.37461
-9.11935 0.34202
-9.11062 0.30902
-9.10189 0.27564
-9.09317 0.24192
-9.08444 0.20791
-9.07571 0.17365
-9.06699 0.13917
-9.05826 0.10453
-9.04953 0.06976
-9.04081 0.03490
-9.04081 0.00000
-9.04081 0.00000
-12.16494 0.00000
-12.16494 0.03490
-12.15622 0.06976
-12.14749 0.10453
-12.13876 0.13917
-12.13004 0.17365
-12.12131 0.20791
-12.11259 0.24192
-12.10386 0.27564
-12.09513 0.30902
-12.08641 0.34202
-12.07768 0.37461
-12.06895 0.40674
-12.06023 0.43837
-12.05150 0.46947
-12.04277 0.50000
-12.03405 0.52992
-12.02532 0.55919
-12.01659 0.58779
-12.00787 0.61566
-11.99914 0.64279
-11.99041 0.66913
-11.98169 0.69466
-11.97296 0.71934
-11.96423 0.74314
-11.95551 0.76604
-11.94678 0.78801
-11.93805 0.80902
-11.92933 0.82904
-11.92060 0.84805
-11.91187 0.86603
-11.90315 0.88295
-11.89442 0.89879
-11.88569 0.91355
-11.87697 0.92718
-11.86824 0.93969
-11.85951 0.95106
-11.85079 0.96126
-11.84206 0.97030
-11.83333 0.97815
-11.82461 0.98481
-11.81588 0.99027
-11.80715 0.99452
-11.79843 0.99756
-11.78970 0.99939
-11.78097 1.00000
-11.77225 0.99939
-11.76352 0.99756
-11.75479 0.99452
-11.74607 0.99027
-11.73734 0.98481
-11.72861 0.97815
-11.71989 0.97030
-11.71116 0.96126
-11.70243 0.95106
-11.69371 0.93969
-11.68498 0.92718
-11.67625 0.91355
-11.66753 0.89879
-11.65880 0.88295
-11.65007 0.86603
-11.64135 0.84805
-11.63262 0.82904
-11.62389 0.80902
-11.61517 0.78801
-11.60644 0.76604
-11.59771 0.74314
-11.58899 0.71934
-11.58026 0.69466
-11.57153 0.66913
-11.56281 0.64279
-11.55408 0.61566
-11.54535 0.58779
-11.53663 0.55919
-11.52790 0.52992
-11.51917 0.50000
-11.51045 0.46947
-11.50172 0.43837
-11.49299 0.40674
-11.48427 0.37461
-11.47554 0.34202
-11.46681 0.30902
-11.45809 0.27564
-11.44936 0.24192
-11.44063 0.20791
-11.43191 0.17365
-11.42318 0.13917
-11.41445 0.10453
-11.40573 0.06976
-11.39700 0.03490
-11.39700 0.00000
-11.39700 0.00000
-14.52114 0.00000
-14.52114 0.03490
-14.51241 0.06976
-14.50369 0.10453
-14.49496 0.13917
-14.48623 0.17365
-14.47751 0.20791
-14.46878 0.24192
-14.46005 0.27564
-14.45133 0.30902
-14.44260 0.34202
-14.43387 0.37461
-14.42515 0.40674
-14.41642 0.43837
-14.40769 0.46947
-14.39897 0.50000
-14.39024 0.52992
-14.38151 0.55919
-14.37279 0.58779
-14.36406 0.61566
-14.35533 0.64279
-14.34661 0.66913
-14.33788 0.69466
-14.32915 0.71934
-14.32043 0.74314
-14.31170 0.76604
-14.30297 0.78801
-14.29425 0.80902
-14.28552 0.82904
-14.27679 0.84805
-14.26807 0.86603
-14.25934 0.88295
-14.25061 0.89879
-14.24189 0.91355
-14.23316 0.92718
-14.22443 0.93969
-14.21571 0.95106
-14.20698 0.96126
-14.19825 0.97030
-14.18953 0.97815
-14.18080 0.98481
-14.17207 0.99027
-14.16335 0.99452
-14.15462 0.99756
-14.14589 0.99939
-14.13717 1.00000
-14.12844 0.99939
-14.11971 0.99756
-14.11099 0.99452
-14.10226 0.99027
-14.09353 0.98481
-14.08481 0.97815
-14.07608 0.97030
-14.06735 0.96126
-14.05863 0.95106
-14.04990 0.93969
-14.04117 0.92718
-14.03245 0.91355
-14.02372 0.89879
-14.01499 0.88295
-14.00627 0.86603
-13.99754 0.84805
-13.98881 0.82904
-13.98009 0.80902
-13.97136 0.78801
-13.96263 0.76604
-13.95391 0.74314
-13.94518 0.71934
-13.93645 0.69466
-13.92773 0.66913
-13.91900 0.64279
-13.91027 0.61566
-13.90155 0.58779
-13.89282 0.55919
-13.88409 0.52992
-13.87537 0.50000
-13.86664 0.46947
-13.85791 0.43837
-13.84919 0.40674
-13.84046 0.37461
-13.83173 0.34202
-13.82301 0.30902
-13.81428 0.27564
-13.80555 0.24192
-13.79683 0.20791
-13.78810 0.17365
-13.77937 0.13917
-13.77065 0.10453
-13.76192 0.06976
-13.75319 0.03490
-13.75319 0.00000
-13.75319 0.00000
-16.87733 0.00000
-16.87733 0.03490
-16.86861 0.06976
-16.85988 0.10453
-16.85115 0.13917
-16.84243 0.17365
-16.83370 0.20791
-16.82497 0.24192
-16.81625 0.27564
-16.80752 0.30902
-16.79879 0.34202
-16.79007 0.37461
-16.78134 0.40674
-16.77261 0.43837
-16.76389 0.46947
-16.75516 0.50000
-16.74643 0.52992
-16.73771 0.55919
-16.72898 0.58779
-16.72025 0.61566
-16.71153 0.64279
-16.70280 0.66913
-16.69407 0.69466
-16.68535 0.71934
-16.67662 0.74314
-16.66789 0.76604
-16.65917 0.78801
-16.65044 0.80902
-16.64171 0.82904
-16.63299 0.84805
-16.62426 0.86603
-16.61553 0.88295
-16.60681 0.89879
-16.59808 0.91355
-16.58935 0.92718
-16.58063 0.93969
-16.57190 0.95106
-16.56317 0.96126
-16.55445 0.97030
-16.54572 0.97815
-16.53699 0.98481
-16.52827 0.99027
-16.51954 0.99452
-16.51081 0.99756
-16.50209 0.99939
-16.49336 1.00000
-16.48463 0.99939
-16.47591 0.99756
-16.46718 0.99452
-16.45845 0.99027
-16.44973 0.98481
-16.44100 0.97815
-16.43227 0.97030
-16.42355 0.96126
-16.41482 0.95106
-16.40609 0.93969
-16.39737 0.92718
-16.38864 0.91355
-16.37992 0.89879
-16.37119 0.88295
-16.36246 0.86603
-16.35374 0.84805
-16.34501 0.82904
-16.33628 0.80902
-16.32756 0.78801
-16.31883 0.76604
-16.31010 0.74314
-16.30138 0.71934
-16.29265 0.69466
-16.28392 0.66913
-16.27520 0.64279
-16.26647 0.61566
-16.25774 0.58779
-16.24902 0.55919
-16.24029 0.52992
-16.23156 0.50000
-16.22284 0.46947
-16.21411 0.43837
-16.20538 0.40674
-16.19666 0.37461
-16.18793 0.34202
-16.17920 0.30902
-16.17048 0.27564
-16.16175 0.24192
-16.15302 0.20791
-16.14430 0.17365
-16.13557 0.13917
-16.12684 0.10453
-16.11812 0.06976
-16.10939 0.03490
-16.10939 0.00000
-16.10939 0.00000
-19.23353 0.00000
-19.23353 0.03490
-19.22480 0.06976
-19.21608 0.10453
-19.20735 0.13917
-19.19862 0.17365
-19.18990 0.20791
-19.18117 0.24192
-19.17244 0.27564
-19.16372 0.30902
-19.15499 0.34202
-19.14626 0.37461
-19.13754 0.40674
-19.12881 0.43837
-19.12008 0.46947
-19.11136 0.50000
-19.10263 0.52992
-19.09390 0.55919
-19.08518 0.58779
-19.07645 0.61566
-19.06772 0.64279
-19.05900 0.66913
-19.05027 0.69466
-19.04154 0.71934
-19.03282 0.74314
-19.02409 0.76604
-19.01536 0.78801
-19.00664 0.80902
-18.99791 0.82904
-18.98918 0.84805
-18.98046 0.86603
-18.97173 0.88295
-18.96300 0.89879
-18.95428 0.91355
-18.94555 0.92718
-18.93682 0.93969
-18.92810 0.95106
-18.91937 0.96126
-18.91064 0.97030
-18.90192 0.97815
-18.89319 0.98481
-18.88446 0.99027
-18.87574 0.99452
-18.86701 0.99756
-18.85828 0.99939
-18.84956 1.00000
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+892
View File
@@ -0,0 +1,892 @@
# sandtable generator test_f
0.00000 0.00000
2.67795 0.00000
2.67795 0.00399
2.67795 0.00799
2.67795 0.01198
2.67795 0.01597
2.67795 0.01996
2.67795 0.02396
2.67795 0.02795
2.67795 0.03194
2.67795 0.03594
2.67795 0.03993
2.67795 0.04392
2.67795 0.04792
2.67795 0.05191
2.67795 0.05590
2.67795 0.05989
2.67795 0.06389
2.67795 0.06788
2.67795 0.07187
2.67795 0.07587
2.67795 0.07986
2.67795 0.08385
2.67795 0.08785
2.67795 0.09184
2.67795 0.09583
2.67795 0.09982
2.67795 0.10382
2.67795 0.10781
2.67795 0.11180
2.67795 0.11580
2.67795 0.11979
2.67795 0.12378
2.67795 0.12778
2.67795 0.13177
2.67795 0.13576
2.67795 0.13975
2.67795 0.14375
2.67795 0.14774
2.67795 0.15173
2.67795 0.15573
2.67795 0.15972
2.67795 0.16371
2.67795 0.16771
2.67795 0.17170
2.67795 0.17569
2.67795 0.17968
2.67795 0.18368
2.67795 0.18767
2.67795 0.19166
2.67795 0.19566
2.67795 0.19965
2.67795 0.20364
2.67795 0.20763
2.67795 0.21163
2.67795 0.21562
2.67795 0.21961
2.67795 0.22361
2.66207 0.22542
2.64646 0.22730
2.63110 0.22922
2.61601 0.23121
2.60117 0.23324
2.58660 0.23532
2.57228 0.23745
2.55822 0.23963
2.54442 0.24186
2.53087 0.24413
2.51757 0.24645
2.50452 0.24881
2.49172 0.25121
2.47917 0.25365
2.46685 0.25612
2.45478 0.25864
2.44293 0.26120
2.43132 0.26379
2.41994 0.26641
2.40878 0.26907
2.39784 0.27176
2.38711 0.27449
2.37660 0.27724
2.36630 0.28003
2.35619 0.28284
2.34629 0.28569
2.33659 0.28856
2.32708 0.29145
2.31775 0.29437
2.30861 0.29732
2.29965 0.30029
2.29087 0.30329
2.28226 0.30631
2.27381 0.30935
2.26553 0.31241
2.25742 0.31549
2.24946 0.31860
2.24165 0.32172
2.23400 0.32486
2.22649 0.32802
2.21913 0.33120
2.21191 0.33440
2.20482 0.33762
2.19787 0.34085
2.19105 0.34409
2.18435 0.34736
2.17778 0.35063
2.17134 0.35393
2.16501 0.35723
2.15880 0.36056
2.15270 0.36389
2.14671 0.36724
2.14084 0.37060
2.13506 0.37397
2.12940 0.37736
2.12383 0.38076
2.11836 0.38417
2.11299 0.38759
2.10771 0.39102
2.10252 0.39446
2.09742 0.39791
2.09241 0.40138
2.08749 0.40485
2.08265 0.40833
2.07789 0.41183
2.07322 0.41533
2.06862 0.41884
2.06410 0.42236
2.05965 0.42588
2.05527 0.42942
2.05097 0.43296
2.04674 0.43651
2.04257 0.44007
2.03848 0.44364
2.03444 0.44721
2.03048 0.45079
2.02657 0.45438
2.02273 0.45798
2.01894 0.46158
2.01522 0.46519
2.01155 0.46880
2.00793 0.47242
2.00438 0.47605
2.00087 0.47968
1.99742 0.48332
1.99402 0.48697
1.99068 0.49062
1.98738 0.49427
1.98413 0.49793
1.98092 0.50160
1.97777 0.50527
1.97466 0.50894
1.97159 0.51262
1.96857 0.51631
1.96559 0.52000
1.96265 0.52369
1.95975 0.52739
1.95690 0.53110
1.95408 0.53480
1.95130 0.53852
1.94856 0.54223
1.94586 0.54595
1.94319 0.54968
1.94056 0.55340
1.93797 0.55714
1.93541 0.56087
1.93288 0.56461
1.93039 0.56835
1.92793 0.57210
1.92550 0.57585
1.92311 0.57960
1.92074 0.58336
1.91840 0.58711
1.91610 0.59088
1.91382 0.59464
1.91157 0.59841
1.90935 0.60218
1.90716 0.60596
1.90499 0.60973
1.90286 0.61351
1.90074 0.61730
1.89866 0.62108
1.89660 0.62487
1.89456 0.62866
1.89255 0.63246
1.89056 0.63625
1.88859 0.64005
1.88665 0.64385
1.88473 0.64765
1.88284 0.65146
1.88096 0.65527
1.87911 0.65908
1.87728 0.66289
1.87547 0.66671
1.87368 0.67052
1.87191 0.67434
1.87016 0.67816
1.86843 0.68199
1.86672 0.68581
1.86503 0.68964
1.86336 0.69347
1.86170 0.69730
1.86007 0.70113
1.85845 0.70497
1.85685 0.70880
1.85526 0.71264
1.85370 0.71648
1.85215 0.72032
1.85061 0.72417
1.84910 0.72801
1.84759 0.73186
1.84611 0.73571
1.84464 0.73956
1.84318 0.74341
1.84174 0.74726
1.84032 0.75112
1.83891 0.75497
1.83751 0.75883
1.83613 0.76269
1.83476 0.76655
1.83341 0.77041
1.83206 0.77428
1.83074 0.77814
1.82942 0.78201
1.82812 0.78588
1.82683 0.78974
1.82555 0.79361
1.82429 0.79749
1.82304 0.80136
1.82180 0.80523
1.82057 0.80911
1.81935 0.81298
1.81815 0.81686
1.81696 0.82074
1.81577 0.82462
1.81106 0.82366
1.80634 0.82272
1.80161 0.82179
1.79686 0.82089
1.79211 0.82000
1.78735 0.81913
1.78257 0.81828
1.77779 0.81745
1.77299 0.81664
1.76819 0.81584
1.76338 0.81507
1.75856 0.81431
1.75373 0.81357
1.74889 0.81286
1.74404 0.81216
1.73919 0.81148
1.73432 0.81082
1.72945 0.81018
1.72457 0.80955
1.71969 0.80895
1.71479 0.80837
1.70989 0.80780
1.70499 0.80726
1.70007 0.80673
1.69515 0.80623
1.69023 0.80574
1.68529 0.80527
1.68036 0.80483
1.67541 0.80440
1.67046 0.80399
1.66551 0.80360
1.66055 0.80323
1.65559 0.80288
1.65063 0.80256
1.64566 0.80225
1.64068 0.80196
1.63571 0.80169
1.63072 0.80144
1.62574 0.80121
1.62075 0.80100
1.61577 0.80081
1.61078 0.80064
1.60578 0.80049
1.60079 0.80036
1.59579 0.80025
1.59079 0.80016
1.58580 0.80009
1.58080 0.80004
1.57580 0.80001
1.57080 0.80000
1.56580 0.80001
1.56080 0.80004
1.55580 0.80009
1.55080 0.80016
1.54580 0.80025
1.54081 0.80036
1.53581 0.80049
1.53082 0.80064
1.52583 0.80081
1.52084 0.80100
1.51585 0.80121
1.51087 0.80144
1.50589 0.80169
1.50091 0.80196
1.49594 0.80225
1.49097 0.80256
1.48600 0.80288
1.48104 0.80323
1.47608 0.80360
1.47113 0.80399
1.46618 0.80440
1.46124 0.80483
1.45630 0.80527
1.45137 0.80574
1.44644 0.80623
1.44152 0.80673
1.43661 0.80726
1.43170 0.80780
1.42680 0.80837
1.42191 0.80895
1.41702 0.80955
1.41214 0.81018
1.40727 0.81082
1.40241 0.81148
1.39755 0.81216
1.39270 0.81286
1.38786 0.81357
1.38303 0.81431
1.37821 0.81507
1.37340 0.81584
1.36860 0.81664
1.36380 0.81745
1.35902 0.81828
1.35425 0.81913
1.34948 0.82000
1.34473 0.82089
1.33998 0.82179
1.33525 0.82272
1.33053 0.82366
1.32582 0.82462
1.32112 0.82560
1.31643 0.82660
1.31175 0.82761
1.30708 0.82865
1.30243 0.82970
1.29779 0.83077
1.29316 0.83186
1.28854 0.83296
1.28393 0.83408
1.27934 0.83522
1.27476 0.83638
1.27019 0.83756
1.26564 0.83875
1.26109 0.83996
1.25656 0.84119
1.25205 0.84243
1.24755 0.84370
1.24306 0.84498
1.23858 0.84627
1.23412 0.84758
1.22967 0.84891
1.22524 0.85026
1.22082 0.85162
1.21642 0.85300
1.21203 0.85440
1.21642 0.85300
1.22082 0.85162
1.22524 0.85026
1.22967 0.84891
1.23412 0.84758
1.23858 0.84627
1.24306 0.84498
1.24755 0.84370
1.25205 0.84243
1.25656 0.84119
1.26109 0.83996
1.26564 0.83875
1.27019 0.83756
1.27476 0.83638
1.27934 0.83522
1.28393 0.83408
1.28854 0.83296
1.29316 0.83186
1.29779 0.83077
1.30243 0.82970
1.30708 0.82865
1.31175 0.82761
1.31643 0.82660
1.32112 0.82560
1.32582 0.82462
1.33053 0.82366
1.33525 0.82272
1.33998 0.82179
1.34473 0.82089
1.34948 0.82000
1.35425 0.81913
1.35902 0.81828
1.36380 0.81745
1.36860 0.81664
1.37340 0.81584
1.37821 0.81507
1.38303 0.81431
1.38786 0.81357
1.39270 0.81286
1.39755 0.81216
1.40241 0.81148
1.40727 0.81082
1.41214 0.81018
1.41702 0.80955
1.42191 0.80895
1.42680 0.80837
1.43170 0.80780
1.43661 0.80726
1.44152 0.80673
1.44644 0.80623
1.45137 0.80574
1.45630 0.80527
1.46124 0.80483
1.46618 0.80440
1.47113 0.80399
1.47608 0.80360
1.48104 0.80323
1.48600 0.80288
1.49097 0.80256
1.49594 0.80225
1.50091 0.80196
1.50589 0.80169
1.51087 0.80144
1.51585 0.80121
1.52084 0.80100
1.52583 0.80081
1.53082 0.80064
1.53581 0.80049
1.54081 0.80036
1.54580 0.80025
1.55080 0.80016
1.55580 0.80009
1.56080 0.80004
1.56580 0.80001
1.57080 0.80000
1.57580 0.80001
1.58080 0.80004
1.58580 0.80009
1.59079 0.80016
1.59579 0.80025
1.60079 0.80036
1.60578 0.80049
1.61078 0.80064
1.61577 0.80081
1.62075 0.80100
1.62574 0.80121
1.63072 0.80144
1.63571 0.80169
1.64068 0.80196
1.64566 0.80225
1.65063 0.80256
1.65559 0.80288
1.66055 0.80323
1.66551 0.80360
1.67046 0.80399
1.67541 0.80440
1.68036 0.80483
1.68529 0.80527
1.69023 0.80574
1.69515 0.80623
1.70007 0.80673
1.70499 0.80726
1.70989 0.80780
1.71479 0.80837
1.71969 0.80895
1.72457 0.80955
1.72945 0.81018
1.73432 0.81082
1.73919 0.81148
1.74404 0.81216
1.74889 0.81286
1.75373 0.81357
1.75856 0.81431
1.76338 0.81507
1.76819 0.81584
1.77299 0.81664
1.77779 0.81745
1.78257 0.81828
1.78735 0.81913
1.79211 0.82000
1.79686 0.82089
1.80161 0.82179
1.80634 0.82272
1.81106 0.82366
1.81577 0.82462
1.81695 0.82076
1.81814 0.81691
1.81933 0.81305
1.82054 0.80920
1.82176 0.80534
1.82300 0.80149
1.82424 0.79764
1.82550 0.79379
1.82677 0.78994
1.82805 0.78610
1.82934 0.78225
1.83065 0.77840
1.83197 0.77456
1.83330 0.77072
1.83464 0.76688
1.83600 0.76304
1.83738 0.75920
1.83876 0.75537
1.84016 0.75153
1.84158 0.74770
1.84301 0.74387
1.84446 0.74004
1.84592 0.73621
1.84739 0.73238
1.84888 0.72856
1.85039 0.72473
1.85191 0.72091
1.85345 0.71709
1.85500 0.71327
1.85658 0.70946
1.85817 0.70564
1.85977 0.70183
1.86140 0.69802
1.86304 0.69421
1.86470 0.69040
1.86637 0.68659
1.86807 0.68279
1.86979 0.67899
1.87152 0.67519
1.87328 0.67139
1.87505 0.66759
1.87685 0.66380
1.87866 0.66001
1.88050 0.65622
1.88236 0.65243
1.88424 0.64865
1.88614 0.64487
1.88806 0.64109
1.89001 0.63731
1.89198 0.63353
1.89397 0.62976
1.89599 0.62599
1.89803 0.62222
1.90010 0.61846
1.90219 0.61470
1.90431 0.61094
1.90646 0.60718
1.90863 0.60343
1.91082 0.59968
1.91305 0.59593
1.91530 0.59218
1.91759 0.58844
1.91990 0.58470
1.92224 0.58097
1.92461 0.57723
1.92702 0.57350
1.92945 0.56978
1.93192 0.56606
1.93442 0.56234
1.93695 0.55862
1.93951 0.55491
1.94211 0.55120
1.94475 0.54750
1.94742 0.54380
1.95013 0.54010
1.95288 0.53641
1.95566 0.53272
1.95848 0.52903
1.96134 0.52535
1.96425 0.52168
1.96719 0.51801
1.97018 0.51434
1.97320 0.51068
1.97628 0.50702
1.97939 0.50337
1.98256 0.49972
1.98576 0.49608
1.98902 0.49244
1.98162 0.49084
1.97416 0.48927
1.96666 0.48772
1.95912 0.48620
1.95152 0.48471
1.94388 0.48324
1.93619 0.48181
1.92846 0.48040
1.92068 0.47902
1.91286 0.47767
1.90499 0.47635
1.89709 0.47507
1.88913 0.47381
1.88114 0.47258
1.87311 0.47138
1.86503 0.47021
1.85692 0.46907
1.84876 0.46796
1.84057 0.46689
1.83234 0.46584
1.82408 0.46483
1.81577 0.46385
1.80744 0.46290
1.79907 0.46198
1.79067 0.46110
1.78223 0.46025
1.77377 0.45943
1.76528 0.45865
1.75675 0.45789
1.74820 0.45718
1.73963 0.45649
1.73103 0.45584
1.72240 0.45522
1.71375 0.45464
1.70508 0.45409
1.69639 0.45357
1.68769 0.45309
1.67896 0.45265
1.67021 0.45223
1.66146 0.45186
1.65268 0.45151
1.64390 0.45121
1.63510 0.45093
1.62629 0.45069
1.61748 0.45049
1.60866 0.45032
1.59983 0.45019
1.59100 0.45009
1.58216 0.45003
1.57332 0.45000
1.56448 0.45001
1.55565 0.45005
1.54681 0.45013
1.53798 0.45024
1.52915 0.45039
1.52033 0.45057
1.51152 0.45079
1.50272 0.45104
1.49393 0.45133
1.48515 0.45166
1.47638 0.45201
1.46763 0.45241
1.45889 0.45283
1.45017 0.45329
1.44147 0.45379
1.43279 0.45432
1.42413 0.45488
1.41549 0.45548
1.40688 0.45611
1.39829 0.45678
1.38972 0.45748
1.38118 0.45821
1.37267 0.45898
1.36419 0.45978
1.35574 0.46061
1.34732 0.46148
1.33893 0.46237
1.33058 0.46330
1.32226 0.46427
1.31397 0.46526
1.30572 0.46629
1.29751 0.46734
1.28933 0.46843
1.28119 0.46955
1.27310 0.47070
1.26504 0.47189
1.25702 0.47310
1.24905 0.47434
1.25702 0.47310
1.26504 0.47189
1.27310 0.47070
1.28119 0.46955
1.28933 0.46843
1.29751 0.46734
1.30572 0.46629
1.31397 0.46526
1.32226 0.46427
1.33058 0.46330
1.33893 0.46237
1.34732 0.46148
1.35574 0.46061
1.36419 0.45978
1.37267 0.45898
1.38118 0.45821
1.38972 0.45748
1.39829 0.45678
1.40688 0.45611
1.41549 0.45548
1.42413 0.45488
1.43279 0.45432
1.44147 0.45379
1.45017 0.45329
1.45889 0.45283
1.46763 0.45241
1.47638 0.45201
1.48515 0.45166
1.49393 0.45133
1.50272 0.45104
1.51152 0.45079
1.52033 0.45057
1.52915 0.45039
1.53798 0.45024
1.54681 0.45013
1.55565 0.45005
1.56448 0.45001
1.57332 0.45000
1.58216 0.45003
1.59100 0.45009
1.59983 0.45019
1.60866 0.45032
1.61748 0.45049
1.62629 0.45069
1.63510 0.45093
1.64390 0.45121
1.65268 0.45151
1.66146 0.45186
1.67021 0.45223
1.67896 0.45265
1.68769 0.45309
1.69639 0.45357
1.70508 0.45409
1.71375 0.45464
1.72240 0.45522
1.73103 0.45584
1.73963 0.45649
1.74820 0.45718
1.75675 0.45789
1.76528 0.45865
1.77377 0.45943
1.78223 0.46025
1.79067 0.46110
1.79907 0.46198
1.80744 0.46290
1.81577 0.46385
1.82408 0.46483
1.83234 0.46584
1.84057 0.46689
1.84876 0.46796
1.85692 0.46907
1.86503 0.47021
1.87311 0.47138
1.88114 0.47258
1.88913 0.47381
1.89709 0.47507
1.90499 0.47635
1.91286 0.47767
1.92068 0.47902
1.92846 0.48040
1.93619 0.48181
1.94388 0.48324
1.95152 0.48471
1.95912 0.48620
1.96666 0.48772
1.97416 0.48927
1.98162 0.49084
1.98902 0.49244
1.99233 0.48881
1.99568 0.48518
1.99908 0.48156
2.00254 0.47795
2.00605 0.47434
2.00961 0.47074
2.01323 0.46714
2.01690 0.46355
2.02063 0.45996
2.02442 0.45638
2.02827 0.45281
2.03218 0.44925
2.03615 0.44569
2.04019 0.44214
2.04429 0.43860
2.04846 0.43506
2.05270 0.43153
2.05701 0.42801
2.06138 0.42450
2.06583 0.42099
2.07036 0.41750
2.07496 0.41401
2.07964 0.41053
2.08440 0.40707
2.08924 0.40361
2.09417 0.40016
2.09918 0.39672
2.10428 0.39329
2.10947 0.38987
2.11475 0.38646
2.12012 0.38306
2.12559 0.37967
2.13116 0.37630
2.13682 0.37294
2.14260 0.36959
2.14847 0.36625
2.15446 0.36292
2.16055 0.35961
2.16676 0.35631
2.17308 0.35303
2.17953 0.34976
2.18609 0.34650
2.19278 0.34326
2.19959 0.34004
2.20654 0.33683
2.21362 0.33364
2.22083 0.33046
2.22819 0.32730
2.23568 0.32417
2.24333 0.32104
2.25112 0.31794
2.25907 0.31486
2.26717 0.31180
2.27543 0.30876
2.28386 0.30574
2.29245 0.30274
2.30122 0.29977
2.31016 0.29682
2.31928 0.29389
2.32858 0.29099
2.33807 0.28811
2.34774 0.28526
2.35762 0.28244
2.36769 0.27965
2.37796 0.27688
2.38844 0.27415
2.39913 0.27144
2.41003 0.26877
2.42116 0.26613
2.43250 0.26352
2.44407 0.26095
2.45586 0.25841
2.46789 0.25591
2.48016 0.25345
2.49266 0.25103
2.50540 0.24864
2.51839 0.24630
2.53163 0.24400
2.54512 0.24174
2.55885 0.23953
2.57284 0.23737
2.58709 0.23525
2.60159 0.23318
2.61635 0.23116
2.63136 0.22919
2.64664 0.22728
2.66216 0.22541
2.67795 0.22361
2.67795 0.21961
2.67795 0.21562
2.67795 0.21163
2.67795 0.20763
2.67795 0.20364
2.67795 0.19965
2.67795 0.19566
2.67795 0.19166
2.67795 0.18767
2.67795 0.18368
2.67795 0.17968
2.67795 0.17569
2.67795 0.17170
2.67795 0.16771
2.67795 0.16371
2.67795 0.15972
2.67795 0.15573
2.67795 0.15173
2.67795 0.14774
2.67795 0.14375
2.67795 0.13975
2.67795 0.13576
2.67795 0.13177
2.67795 0.12778
2.67795 0.12378
2.67795 0.11979
2.67795 0.11580
2.67795 0.11180
2.67795 0.10781
2.67795 0.10382
2.67795 0.09982
2.67795 0.09583
2.67795 0.09184
2.67795 0.08785
2.67795 0.08385
2.67795 0.07986
2.67795 0.07587
2.67795 0.07187
2.67795 0.06788
2.67795 0.06389
2.67795 0.05989
2.67795 0.05590
2.67795 0.05191
2.67795 0.04792
2.67795 0.04392
2.67795 0.03993
2.67795 0.03594
2.67795 0.03194
2.67795 0.02795
2.67795 0.02396
2.67795 0.01996
2.67795 0.01597
2.67795 0.01198
2.67795 0.00799
2.67795 0.00399
2.67795 0.00000
Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

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