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

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

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

No files matched your search

+103
View File
@@ -0,0 +1,103 @@
"""Line drawings (SVG and the fan-art designs) joined into one sand-table path.
Run from the software folder: python3 -m unittest discover -s tests -v"""
import math
import os
import sys
import tempfile
import unittest
os.environ['SANDTABLE_QUIET'] = '1'
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
from sandtable import config, thr
from sandtable.lineart import drawing_to_polar, join_strokes, path_polylines, svg_strokes
SVG = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<defs><circle cx="0" cy="0" r="99"/></defs>
<rect x="10" y="10" width="20" height="20"/>
<g transform="translate(50 50) scale(2)"><circle cx="0" cy="0" r="10"/></g>
<path d="M10 80 C20 70 30 90 40 80 Q50 70 60 80 A10 10 0 0 1 80 80 L90 90 h-5 v5 z"/>
<text x="0" y="0">ignored</text>
</svg>"""
def unit_xy(pts):
return [(r * math.cos(t), r * math.sin(t)) for t, r in pts]
class TestSvg(unittest.TestCase):
def test_shapes_and_transforms(self):
s = svg_strokes(SVG)
self.assertEqual(len(s), 3) # defs and text are skipped
circle = s[1]
self.assertAlmostEqual(max(p[0] for p in circle), 70, delta=0.1) # r 10 scaled 2x, moved to 50
self.assertAlmostEqual(min(p[0] for p in circle), 30, delta=0.1)
path = s[2]
self.assertAlmostEqual(math.dist(path[0], path[-1]), 0, places=6) # z closes it
self.assertAlmostEqual(min(p[1] for p in path), 70, delta=0.6) # the arc's top (centre 70,80, r 10)
def test_arc_and_relative(self):
(semi,) = path_polylines('M0 0 a10 10 0 0 1 20 0', tol=0.1)
self.assertAlmostEqual(min(p[1] for p in semi), -10, delta=0.05) # sweep 1 bulges up the page (y down)
lines = path_polylines('m0 0 l10 0 10 0 M0 5 H20 V9')
self.assertEqual(lines[0][-1], (20.0, 0.0)) # implicit repeated l
self.assertEqual(lines[1][-1], (20.0, 9.0))
class TestJoin(unittest.TestCase):
def check_path(self, xy, strokes):
for a, b in zip(xy, xy[1:]):
self.assertLess(math.dist(a, b), 0.008) # one continuous line
for end in (xy[0], xy[-1]):
r = math.hypot(*end)
self.assertTrue(r < 1e-9 or abs(r - 1) < 1e-9, end) # starts and ends at the centre or the edge
for st in strokes: # every stroke is drawn
for p in st[::7]:
self.assertLess(min(math.dist(p, q) for q in xy), 0.01)
def test_triangle_from_three_lines_needs_no_jumps(self):
a, b, c = (0.0, 0.85), (-0.74, -0.42), (0.74, -0.42)
strokes = [[a, b], [c, b], [c, a]]
stats = {}
xy = join_strokes(strokes, stats=stats)
self.check_path(xy, strokes)
self.assertLess(stats['jump'], 0.35) # only the short lines in from and out to the edge
def test_separate_shapes(self):
sq = lambda x, y, d: [(x, y), (x + d, y), (x + d, y + d), (x, y + d), (x, y)]
strokes = [sq(-0.6, -0.6, 0.3), sq(0.3, 0.3, 0.3), sq(0.3, -0.6, 0.3), [(-0.5, 0.5), (-0.2, 0.2)]]
xy = join_strokes(strokes)
self.check_path(xy, strokes)
def test_fan_art_designs(self):
try:
import make_fan_art # not in the public repository (trademarks)
except ImportError:
self.skipTest('tools/make_fan_art.py is not here')
for name, (fn, opts) in make_fan_art.DESIGNS.items():
pts = drawing_to_polar(fn(), **opts)
self.assertGreater(len(pts), 100, name)
for end in (pts[0][1], pts[-1][1]):
self.assertTrue(end < 0.02 or end > 0.98, f'{name} ends at rho {end}')
xy = unit_xy(pts)
for a, b in zip(xy, xy[1:]):
self.assertLess(math.dist(a, b), 0.06, name)
class TestUpload(unittest.TestCase):
def test_svg_upload_becomes_a_design(self):
from sandtable.web import Library
with tempfile.TemporaryDirectory() as d:
lib = Library(config.load('/nonexistent.toml'), root=os.path.join(d, 'p'), cache=os.path.join(d, 'c'),
removed=os.path.join(d, 'r'))
ident = lib.add('My Logo.svg', SVG)
self.assertEqual(ident, 'uploads/My-Logo.thr')
pts = thr.load(lib.path(ident))
self.assertGreater(len(pts), 100)
with self.assertRaises(ValueError):
lib.add('empty.svg', '<svg xmlns="http://www.w3.org/2000/svg"><text>hi</text></svg>')
if __name__ == '__main__':
unittest.main()