"""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)