Name it Gyre - Sand Art Table

README, build guide, software README, web page header and title, jog page,
desktop alerts and the start-up message now say Gyre. The repo, the Python
package and its commands keep their names (sandTable, python3 -m sandtable),
so links and setups keep working. New web page screenshot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ckochandClaude Opus 5.5 committed 2026-11-01 15:09:05 -05:00
1 parent e7a4f67a96
commit 97ebae93b2
9 files changed
+22 -22

No files matched your search

+2 -2
View File
@@ -1,6 +1,6 @@
# Build guide
# Gyre - build guide
This guide takes you from a pile of parts to a table drawing in sand. It follows the order that works: case first, then the electronics, the arm on the bench, the arm into the table, a test run without sand, and finally the floor, sand and glass.
This guide takes you from a pile of parts to Gyre, a sand art table, drawing in sand. It follows the order that works: case first, then the electronics, the arm on the bench, the arm into the table, a test run without sand, and finally the floor, sand and glass.
![the arm, bolted to its hub, with the counterweight](docs/arm-bolted-counterweight.png)
+2 -2
View File
@@ -1,6 +1,6 @@
# sandTable
# Gyre - Sand Art Table
A 12" kinetic sand table that draws patterns in sand. A steel ball rolls under glass, pulled by a magnet on a polar arm underneath. The case is about 12.5" across and 5" tall. The parts are 3D-printed for a Bambu P1S and designed in SolidCraft.
Gyre is a 12" kinetic sand table that draws patterns in sand. A steel ball rolls under glass, pulled by a magnet on a polar arm underneath. The case is about 12.5" across and 5" tall. The parts are 3D-printed for a Bambu P1S and designed in SolidCraft.
**Building one? Start with [BUILD.md](BUILD.md):** parts to buy, what to print, assembly in order, wiring, the first run and troubleshooting.
+1 -1
View File
@@ -1,4 +1,4 @@
# Sand table software
# Gyre 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

After

Width:  |  Height:  |  Size: 306 KiB

+1 -1
View File
@@ -1 +1 @@
"""Sand table pattern player: .thr files and built-in patterns -> GRBL / FluidNC over USB."""
"""Gyre sand art table: pattern player for .thr files and built-in patterns -> GRBL / FluidNC over USB."""
+4 -4
View File
@@ -52,7 +52,7 @@ def alert(title, msg):
return
try:
import subprocess
subprocess.run(['notify-send', '-u', 'critical', '-a', 'Sand table', title, msg],
subprocess.run(['notify-send', '-u', 'critical', '-a', 'Gyre', title, msg],
timeout=5, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception:
pass
@@ -71,15 +71,15 @@ def guarded(fn):
try:
return fn()
except SwitchTripped as e:
alert('Sand table stopped: home switch pressed',
alert('Gyre 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))
alert('Gyre 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',
alert('Gyre: 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)
+2 -2
View File
@@ -13,7 +13,7 @@ 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>
<title>Gyre - 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}}
@@ -36,7 +36,7 @@ input{font:inherit;width:90px;padding:8px;border-radius:6px;border:1px solid var
.err{color:var(--stop)}
small{color:var(--mute)}
</style></head><body><main>
<h1>Sand table: jog &amp; calibrate</h1>
<h1>Gyre: jog &amp; calibrate</h1>
<div class="card"><div class="stat">
<div><span>State</span><b id="state">-</b></div>
<div><span>Rho (mm)</span><b id="rho">-</b></div>
+9 -9
View File
@@ -251,17 +251,17 @@ class Player:
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)
self.alert('Gyre 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)
self.alert('Gyre: 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)
self.alert('Gyre stopped', self.message)
except Exception as e: # a bug: stop safely and say so
import traceback
self.p.homed = False
@@ -271,7 +271,7 @@ class Player:
c.stop()
except Exception:
pass
self.alert('Sand table stopped', self.message)
self.alert('Gyre stopped', self.message)
finally:
c.guard = None
c.abort = False
@@ -324,7 +324,7 @@ class Player:
# ---------------------------------------------------------------- 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>
<title>Gyre - Sand Art 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}}
@@ -369,7 +369,7 @@ 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>
<h1>Gyre <span style="font-weight:400;color:var(--mute);font-size:15px">sand art table</span></h1>
<span class="pill" id="mach">connecting…</span>
<button id="homeBtn" onclick="post('home')">Home</button>
<button class="stop" onclick="post('stop')">STOP</button>
@@ -511,8 +511,8 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
lib = library or Library(cfg)
player = Player(panel, cfg, lib, alert=alert)
threading.Thread(target=lib.warm, daemon=True).start()
jog_page = JOG_PAGE.replace('<h1>Sand table: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Sand table: jog &amp; calibrate</h1>')
jog_page = JOG_PAGE.replace('<h1>Gyre: jog &amp; calibrate</h1>',
'<p><a href="/">&larr; Library</a></p><h1>Gyre: jog &amp; calibrate</h1>')
def busy():
if not panel.connected:
@@ -614,7 +614,7 @@ def serve(ctrl, cfg, host='127.0.0.1', port=8765, open_browser=True, open_path='
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)
print(f'Gyre 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:
+1 -1
View File
@@ -249,7 +249,7 @@ class TestHttp(unittest.TestCase):
return json.loads(r.read())
def test_pages_and_play(self):
self.assertIn(b'Sand Table', self.get('/'))
self.assertIn(b'Gyre', self.get('/'))
self.assertIn(b'jog &amp; calibrate', self.get('/jog'))
lib = json.loads(self.get('/api/library'))
self.assertEqual(len(lib), 2)