-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathspirograph_rail.py
More file actions
252 lines (196 loc) · 9.16 KB
/
spirograph_rail.py
File metadata and controls
252 lines (196 loc) · 9.16 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
#!/usr/bin/env python3
"""
Spirograph Rail Module
======================
Simulates a spirograph gear rolling along a linear rack (the horizontal
"rail" base that comes with some spirograph sets).
The gear rolls along the rack while a pen traces from a hole in the gear.
This produces a trochoid curve translated along the rail direction.
Parametric equations:
x = rail_position(t) + d·cos(gear_angle(t))
y = r + d·sin(gear_angle(t))
Where:
r = radius of the gear
d = distance from gear center to pen hole
rail_position = how far along the rail the gear has traveled
gear_angle = rotation angle of the gear (depends on distance traveled)
"""
import numpy as np
from fractions import Fraction
from math import pi
from main import TransformModule
class SpirographRailModule(TransformModule):
"""
Linear rail spirograph: gear rolling along a straight rack.
This is a GENERATOR module - it ignores input z and produces coordinates
purely from the time parameter t.
The gear rolls without slipping, so the rotation angle is determined by
the distance traveled along the rail.
Configuration:
rail_length: Total length of the rail
gear_teeth: Number of teeth on the gear
tooth_pitch: Distance per tooth (determines gear circumference)
hole_position: Position of pen hole (0=center, 1=edge)
passes: Number of complete passes along the rail
scale: Output scale factor
rail_angle: Orientation of rail in degrees (0=horizontal)
"""
def _load_config(self):
"""Load rail configuration."""
self.rail_length = self._getfloat('rail_length', 200.0)
self.gear_teeth = self._getint('gear_teeth', 40)
self.tooth_pitch = self._getfloat('tooth_pitch', 1.0)
self.hole_position = self._getfloat('hole_position', 0.6)
self.end_hole_position = self._getfloat('end_hole_position', self.hole_position)
self.passes = self._getint('passes', 2)
self.cycles = self._getfloat('cycles', 1.0) # How many times to draw the pattern
self.scale = self._getfloat('scale', 1.0)
self.rail_angle = self._getfloat('rail_angle', 0.0)
self._drifts = (self.end_hole_position != self.hole_position)
# Compute derived values
# Gear circumference = teeth * pitch
self.gear_circumference = self.gear_teeth * self.tooth_pitch
self.gear_radius = self.gear_circumference / (2 * pi)
self.pen_distance = self.hole_position * self.gear_radius
# Convert rail angle to radians
self.rail_angle_rad = self.rail_angle * pi / 180
# Unit vector along the rail
self.rail_direction = np.exp(1j * self.rail_angle_rad)
# Perpendicular direction (gear center is offset from rail)
self.perp_direction = np.exp(1j * (self.rail_angle_rad + pi/2))
def transform(self, z: complex, t: float) -> complex:
"""
Generate rail spirograph point at time t and add to input.
The motion is back-and-forth: each pass alternates direction.
With cycles > 1, the pattern repeats for moiré effects.
"""
# Normalize t to [0, 1]
period = float(self._pipeline_period)
t_norm = t / period if period > 0 else t
# Convert to position within cycles
t_in_cycles = t_norm * self.cycles
# Position within current cycle [0, 1)
t_frac = t_in_cycles % 1.0
# Total distance to travel over all passes within this cycle
total_distance = self.rail_length * self.passes
# Distance traveled at time t
raw_distance = t_frac * total_distance
# Compute position along rail (handles back-and-forth motion)
# Each pass is one rail_length
pass_number = int(raw_distance / self.rail_length)
within_pass = raw_distance - pass_number * self.rail_length
# Odd passes go backward
if pass_number % 2 == 1:
rail_position = self.rail_length - within_pass
direction_sign = -1
else:
rail_position = within_pass
direction_sign = 1
# Center the rail around the origin
centered_position = rail_position - self.rail_length / 2
# Gear rotation angle (based on distance traveled, accounting for direction)
# The gear rotates as it rolls without slipping
# For every circumference traveled, the gear rotates 2π
cumulative_distance = raw_distance # Total distance, not position
gear_angle = (cumulative_distance / self.gear_radius)
# Position of gear center (on the rail, offset by gear radius)
gear_center = (centered_position * self.rail_direction +
self.gear_radius * self.perp_direction)
# Position of pen relative to gear center
if self._drifts:
hole = self._interpolate(self.hole_position, self.end_hole_position, t_norm, 'hole_position')
pd = hole * self.gear_radius
else:
pd = self.pen_distance
pen_offset = pd * np.exp(1j * gear_angle)
# Rotate pen offset to align with rail orientation
pen_offset = pen_offset * self.rail_direction
# Total position - add to input
result = gear_center + pen_offset
return z + result * self.scale
@property
def natural_period(self) -> Fraction:
"""Period based on cycles."""
return Fraction(self.cycles).limit_denominator(1000)
def __repr__(self):
return (f"SpirographRailModule(rail={self.rail_length}, "
f"gear_teeth={self.gear_teeth}, passes={self.passes}, cycles={self.cycles})")
class SpirographRailTransformModule(TransformModule):
"""
Linear rail as a TRANSFORMER - adds rail motion to existing coordinates.
This version takes input coordinates and translates them along the rail
path, useful for chaining after another spirograph module.
Configuration is the same as SpirographRailModule, but hole_position
is ignored (the "pen" is the input coordinate).
"""
def _load_config(self):
"""Load rail configuration."""
self.rail_length = self._getfloat('rail_length', 200.0)
self.gear_teeth = self._getint('gear_teeth', 40)
self.tooth_pitch = self._getfloat('tooth_pitch', 1.0)
self.passes = self._getint('passes', 2)
self.cycles = self._getfloat('cycles', 1.0)
self.scale = self._getfloat('scale', 1.0)
self.rail_angle = self._getfloat('rail_angle', 0.0)
# Compute derived values
self.gear_circumference = self.gear_teeth * self.tooth_pitch
self.gear_radius = self.gear_circumference / (2 * pi)
self.rail_angle_rad = self.rail_angle * pi / 180
self.rail_direction = np.exp(1j * self.rail_angle_rad)
def transform(self, z: complex, t: float) -> complex:
"""
Transform input coordinates by rail motion.
With cycles > 1, the transform repeats for moiré effects.
"""
# Normalize t to [0, 1]
period = float(self._pipeline_period)
t_norm = t / period if period > 0 else t
# Convert to position within cycles
t_in_cycles = t_norm * self.cycles
t_frac = t_in_cycles % 1.0
# Total distance over all passes
total_distance = self.rail_length * self.passes
raw_distance = t_frac * total_distance
# Position along rail
pass_number = int(raw_distance / self.rail_length)
within_pass = raw_distance - pass_number * self.rail_length
if pass_number % 2 == 1:
rail_position = self.rail_length - within_pass
else:
rail_position = within_pass
centered_position = rail_position - self.rail_length / 2
# Translation vector
translation = centered_position * self.rail_direction * self.scale
return z + translation
@property
def natural_period(self) -> Fraction:
"""Period based on cycles."""
return Fraction(self.cycles).limit_denominator(1000)
def __repr__(self):
return f"SpirographRailTransformModule(rail={self.rail_length}, passes={self.passes}, cycles={self.cycles})"
# Convenience function for standalone testing
def _test():
"""Quick visual test of the module."""
import configparser
config = configparser.ConfigParser()
config.read_string("""
[spirograph_rail]
rail_length = 200.0
gear_teeth = 40
tooth_pitch = 1.0
hole_position = 0.6
passes = 2
scale = 1.0
rail_angle = 0.0
""")
module = SpirographRailModule(config, 'spirograph_rail')
print(module)
print(f"Gear radius: {module.gear_radius:.3f}")
print(f"Pen distance: {module.pen_distance:.3f}")
print(f"Natural period: {module.natural_period}")
# Generate some test points
for t in [0.0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1.0]:
z = module.transform(0j, t)
print(f" t={t:.3f}: ({z.real:8.3f}, {z.imag:8.3f})")
if __name__ == "__main__":
_test()