- 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>
104 lines
4.6 KiB
Python
104 lines
4.6 KiB
Python
"""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()
|