Engraving Spirograph / Guilloché patterns on scrap CDs and hard drive platters now works better than ever:

After, that is, I realized:

- Any Rotor will work, as long as it’s smaller than the Stator
- You must pick pen offset L so the pattern never crosses the stator center point
- L ≥ 1 is perfectly fine
- You must scale the resulting pattern to fit the actual space on the disk

One of my final doodles showing how the variables relate to each other, although the Wikipedia article may be useful for the underlying math and other posts have more pix on various machines:

Cheat sheet:

- Stator has tooth count (∝ radius) R
- Rotor has tooth count (∝ radius) r
- K = r/R, so if you normalize R=1, K=r
- Pen offset L puts it at radius rL in the rotor

Picking a suitable rotor requires iterating with random choices until one fits:

```
RotorTeeth = Stators[-1];
n = 0;
while (RotorTeeth >= floor(0.95 * StatorTeeth) || RotorTeeth < 5) {
RotorTeeth = (XORshift() & 0x007f); // this is why Stator can't have more than 127 teeth
n++;
}
comment("Rotor: ",RotorTeeth," in ",n," iterations");
```

The 5% buffer on the high end ensures there will be an L keeping a hole in the middle of the pattern. Requiring at least five teeth on the low end just seems like a Good Idea.

Given the stator & rotor tooth counts, iterate on random L values until one works:

```
n = 0;
do {
L = (to_float((XORshift() & 0x1f) + 1) / 32.0) * (1.0/K - 1.0); // allow L > 1.0
n++;
} while (L >= (1.0/K - 1.0) || L < 0.01);
}
comment("Offset L: ", L," in ",n," iterations");
```

With L chosen to leave a hole in the middle of the pattern, then the pattern traced by the pen in the rotor is centered at 1.0 – K (the normalized Stator radius minus the normalized Rotor radius) and varies by ±LK (the offset times the normalized Rotor radius) on either side:

```
RotorMin = 1.0 - 2*K;
comment("Rotor Min: ",RotorMin);
BandCtr = 1.0 - K; // band center radius
BandMin = BandCtr - L*K; // ... min radius
BandMax = BandCtr + L*K; // ... max radius
BandAmpl = BandMax - BandCtr;
comment("Band Min: ",BandMin," Ctr: ",BandCtr," Max: ",BandMax);
```

Knowing that, rescaling the pattern to fit the disk limits goes like this:

```
FillPath = {};
foreach (Path; pt) {
a = atan_xy(pt); // recover angle to point
r = length(pt); // ... radius to point
br = (r - BandCtr) / BandAmpl; // remove center bias, rescale to 1.0 amplitude
dr = br * (OuterRad - MidRad); // rescale to fill disk
pr = dr + MidRad; // set at disk centerline
x = pr * cos(a); // find new XY coords
y = pr * sin(a);
FillPath += {[x,y]};
}
comment("Path has ",count(FillPath)," points");
```

The final step prunes coordinates so close together as to produce no useful motion, which I define to be 0.2 mm:

```
PointList = {FillPath[0]}; // must include first point
lp = FillPath[0];
n = 0;
foreach (FillPath; pt) {
if (length(pt - lp) <= Snuggly) { // discard too-snuggly point
n++;
}
else {
PointList += {pt}; // otherwise, add it to output
lp = pt;
}
}
PointList += {FillPath[-1]}; // ensure closure at last point
comment("Pruned ",n," points, ",count(PointList)," remaining");
```

The top of the resulting G-Code file contains all the various settings for debugging:

```
(Disk type: CD)
(Outer Diameter: 117.000mm)
( Radius: 58.500mm)
(Inner Diameter: 38.000mm)
( Radius: 19.000mm)
(Mid Diameter: 77.500mm)
( Radius: 38.750mm)
(Legend Diameter: 30.000mm)
( Radius: 15.000mm)
(PRNG seed: 674203941)
(Stator 8: 71)
(Rotor: 12 in 1 iterations)
(Dia ratio K: 0.169 1/K: 5.917)
(GCD: 1)
(Lobes: 71)
(Turns: 12)
(Offset L: 3.227 in 1 iterations)
(Rotor Min: 0.662)
(Band Min: 0.286 Ctr: 0.831 Max: 1.376)
(Path has 43201 points)
(Pruned 14235 points, 28968 remaining)
```

The GCMC source code as a GitHub Gist: