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,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
|
||||
Reference in new issue
Block a user