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

|
||||||
|
|
||||||
|
**Status:** running on the table since 2026-11-01. On a new build, do the first-run steps below in order.
|
||||||
|
|
||||||
|
## Web page (everyday use)
|
||||||
|
```
|
||||||
|
python3 -m sandtable web
|
||||||
|
```
|
||||||
|
This opens http://127.0.0.1:8765 in your browser. The table stays on the USB cable and the page is the remote control, so leave the program running in its terminal (Ctrl-C there quits).
|
||||||
|
|
||||||
|

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