{"id":2645,"date":"2026-03-05T07:00:41","date_gmt":"2026-03-05T13:00:41","guid":{"rendered":"https:\/\/wollen.org\/blog\/?p=2645"},"modified":"2026-03-05T07:14:16","modified_gmt":"2026-03-05T13:14:16","slug":"a-spirograph-is-worth-a-thousand-words","status":"publish","type":"post","link":"https:\/\/wollen.org\/blog\/2026\/03\/a-spirograph-is-worth-a-thousand-words\/","title":{"rendered":"A Spirograph is worth a thousand words"},"content":{"rendered":"<p>I used to love playing with these things as a kid. If you aren&#8217;t familiar, Spirograph is basically an art kit that lets you draw cool spiral shapes. Below is a demonstration from one of their commercials.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/spirograph-commercial.gif\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2649\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/spirograph-commercial.gif\" alt=\"\" width=\"350\" height=\"197\" \/><\/a><\/p>\n<p>I had the idea to recreate these designs with Python and Matplotlib. There&#8217;s a bit of math involved but it&#8217;s not as complicated as you might think. We&#8217;ll skip the odd shapes like ovals and triangles and stick with basic circles for now.<\/p>\n<hr \/>\n<h4>1. Math.<\/h4>\n<p>To understand what&#8217;s happening, it helps to start with <strong>cycloid<\/strong> curves. Forget spirographs for a moment and imagine a <span style=\"color: #dd0000;\">point<\/span> on the edge of a rolling wheel. The path traced by that point is called a cycloid.<\/p>\n<p>We can predict the point&#8217;s position (x, y) as a function of angular rotation (t) using a pair of parametric equations.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-2679 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1.png\" alt=\"\" width=\"1200\" height=\"250\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1.png 1200w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1-300x63.png 300w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1-1024x213.png 1024w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/cycloid_diagram-1-768x160.png 768w\" sizes=\"auto, (max-width: 1200px) 100vw, 1200px\" \/><\/a><\/p>\n<div style=\"margin-left: 15%; margin-right: 15%;\">\n<hr \/>\n<p><strong>Note:<\/strong> Naming the independent variable <em>t<\/em> is a convention in parametric equations. It doesn&#8217;t generally represent time. But in this case, if you assume the speed of angular rotation is constant, you can think of position as a function of time.<\/p>\n<hr \/>\n<\/div>\n<p>Imagine dragging a pen around that point on the edge of the circle. You could create fun designs! That&#8217;s what we&#8217;re attempting to do here.<\/p>\n<p>But first we have to move from a flat surface to the inside a large circle. The path traced in that case is called a <strong>hypocycloid<\/strong>.<\/p>\n<p>Its equations are a little more complicated but still not outrageous.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid_diagram-1.png\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter wp-image-2670 size-full\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid_diagram-1.png\" alt=\"\" width=\"800\" height=\"840\" srcset=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid_diagram-1.png 800w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid_diagram-1-286x300.png 286w, https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid_diagram-1-768x806.png 768w\" sizes=\"auto, (max-width: 800px) 100vw, 800px\" \/><\/a><\/p>\n<p>Where&#8230;<\/p>\n<ul>\n<li><strong>R<\/strong> is the radius of the large circle.<\/li>\n<li><strong>r<\/strong> is the radius of the small circle.<\/li>\n<li><strong>L<\/strong> is the length from the small circle&#8217;s center to the pen.<\/li>\n<li><strong>\u03c1<\/strong> (&#8220;rho&#8221;) is the ratio of <strong>L<\/strong> to <strong>r<\/strong>.<\/li>\n<\/ul>\n<p>Technically the traced path is called a <a href=\"https:\/\/en.wikipedia.org\/wiki\/Hypotrochoid\" target=\"_blank\" rel=\"noopener\">hypotrochoid<\/a>. A hypocycloid is a special case where the pen (the red dot) is on the edge of the small circle and <strong>R<\/strong> is a multiple of <strong>r<\/strong>. I don&#8217;t expect anyone outside university math departments to care, but Spirographs make more sense if we start by considering the special case\u2014the hypocycloid.<\/p>\n<hr \/>\n<h4>2. Code.<\/h4>\n<p>We&#8217;ll create an animated GIF that shows a pen tracing a hypocycloid curve. The script will start at <code>t=0.0<\/code> and a large <code>for<\/code> loop will increment <code>t<\/code> while saving images.<\/p>\n<p>First define constants. Let&#8217;s follow the diagram above and make <code>R<\/code> four times the size of <code>r<\/code>. So if we wanted to, we could line up four small circles within the large one.<\/p>\n<p><code>rho<\/code> defines how far from the small circle&#8217;s center the pen is held. Setting it to 1.0 means the pen is on the outer edge, which is what we want.<\/p>\n<p>The GIF will animate one full 360\u00b0 trip around the large circle. When the script finishes running, <code>t<\/code> will equal 2\u03c0. <code>t_steps<\/code> is the number of frames generated throughout that motion.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">R = 4\r\nr = 1\r\nrho = 1.0\r\nt_steps = 300<\/pre>\n<p>Begin by setting <code>t=0.0<\/code>. In our equations, the small circle starts at zero degrees\u2014to the east of the origin.<\/p>\n<p>Since we&#8217;re drawing the pen&#8217;s path as it&#8217;s created, we need to record a <em>history<\/em> of every position it&#8217;s been up to the current moment. Then we&#8217;ll be able to call <code>plot()<\/code> and pass the two <code>movement_history<\/code> lists.<\/p>\n<p>Remember that <code>t_steps<\/code> defines how many frames are generated, i.e. the number of passes through the <code>for<\/code> loop. Name the loop variable <code>frame_counter<\/code> and we can use it when saving images.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">import matplotlib.pyplot as plt\r\n\r\nt = 0.0\r\n\r\nmovement_history_x = []\r\nmovement_history_y = []\r\n\r\nplt.style.use(\"wollen_spirograph.mplstyle\")\r\n\r\nfor frame_counter in range(t_steps):\r\n    print(f\"{frame_counter + 1}\/{t_steps}\")\r\n\r\n    fig, ax = plt.subplots()\r\n\r\n    [...]<\/pre>\n<p>Let&#8217;s draw a &#8220;crosshair&#8221; through axes lines. That should help the viewer&#8217;s eyes to judge the small circle&#8217;s location.<\/p>\n<p>Draw circles using Matplotlib <a href=\"https:\/\/matplotlib.org\/stable\/api\/_as_gen\/matplotlib.patches.Circle.html\" target=\"_blank\" rel=\"noopener\">patches<\/a>. You could obviously generate points manually but it&#8217;s easier to define a circle&#8217;s center point and radius and let Matplotlib take care of it.<\/p>\n<p>No matter where the small circle is located, its center point will always be a fixed distance from the large circle&#8217;s center (R &#8211; r).<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from math import sin, cos, pi\r\nfrom matplotlib.patches import Circle\r\n\r\nfor frame_counter in range(t_steps):\r\n\r\n    [...]\r\n\r\n    # Axis Lines:\r\n    ax.plot([-R * 10, R * 10], [0, 0], c=\"#888\", lw=1.0, zorder=1)\r\n    ax.plot([0, 0], [-R * 10, R * 10], c=\"#888\", lw=1.0, zorder=1)\r\n\r\n    # Big Circle:\r\n    ax.add_patch(Circle((0, 0), R, fc=\"None\", ec=\"#000\", lw=1.5, zorder=3))\r\n\r\n    # Small Circle:\r\n    center_point_x = (R - r) * cos(t)\r\n    center_point_y = (R - r) * sin(t)\r\n    ax.scatter([center_point_x], [center_point_y], c=\"#000\", s=20, zorder=3)\r\n    ax.add_patch(Circle((center_point_x, center_point_y), r, fc=\"None\", ec=\"#000\", lw=1.5, zorder=3))\r\n\r\n    [...]<\/pre>\n<p>Now we can draw the little red dot that represents the pen. Its (x, y) position is given by the equations above. Implement the equations using <code>sin<\/code> and <code>cos<\/code> from the math library.<\/p>\n<p>Plot the pen tip using <code>scatter<\/code> and remember to append its (x, y) coordinates to their respective movement history lists.<\/p>\n<p>Then call <code>plot<\/code> to draw a line tracing all those points up to the current moment. Let&#8217;s do a red dashed line to ensure it stands out.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(t_steps):\r\n\r\n    [...]\r\n\r\n    # Ball Point Pen:\r\n    pen_point_x = (R - r) * cos(t) + rho * r * cos((R - r) \/ r * t)\r\n    pen_point_y = (R - r) * sin(t) - rho * r * sin((R - r) \/ r * t)\r\n    ax.scatter([pen_point_x], [pen_point_y], c=\"#F00\", s=40, zorder=4)\r\n    movement_history_x.append(pen_point_x)\r\n    movement_history_y.append(pen_point_y)\r\n    ax.plot(movement_history_x, movement_history_y, c=\"#F00\", ls=\"--\", lw=1.0, zorder=2)\r\n\r\n    [...]<\/pre>\n<p>It&#8217;s a good idea to define ticks and window limits as a function of <code>R<\/code>, the big circle&#8217;s radius. That will make it easy to adjust constants at the top of the page.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(t_steps):\r\n\r\n    [...]\r\n\r\n    ticks = range(-R, R + 1)\r\n    ax.set(xticks=ticks,\r\n           yticks=ticks,\r\n           xlim=(-R * 1.05, R * 1.05),\r\n           ylim=(-R * 1.05, R * 1.05))\r\n\r\n    [...]<\/pre>\n<p>Make a note of those constants in the top-right corner with <code>text<\/code>. It will make it clear exactly what parameters were used in generating the animation.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">for frame_counter in range(t_steps):\r\n\r\n    [...]\r\n\r\n    ax.text(x=R * 0.99,\r\n            y=R * 0.98,\r\n            s=f\"R = {R:.1f}\\nr = {r:.1f}\\n\u03c1 = {rho:.1f}\",\r\n            ha=\"right\",\r\n            va=\"top\")\r\n\r\n    [...]<\/pre>\n<p>Finally, save and <code>close<\/code> the figure, which will prevent Matplotlib from holding every frame in memory.<\/p>\n<p>Remember to increment <code>t<\/code>. We know <code>t<\/code> counts up to 2\u03c0 and we know it takes <code>t_steps<\/code> frames to get there.<\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"generic\">for frame_counter in range(t_steps):\r\n\r\n    [...]\r\n\r\n    plt.savefig(f\"frames\/{frame_counter}.png\")\r\n\r\n    plt.close()\r\n\r\n    t += 2 * pi \/ t_steps\r\n<\/pre>\n<p>After running the script, you&#8217;ll have 300 images in a <em>frames<\/em> folder. There are many options to convert PNG files into an animated GIF. The image editor <a href=\"https:\/\/www.gimp.org\/downloads\/\" target=\"_blank\" rel=\"noopener\">GIMP<\/a> is one of the easiest. I&#8217;ve gotten used to <a href=\"https:\/\/ffmpeg.org\/\" target=\"_blank\" rel=\"noopener\">FFMPEG<\/a>, a command-line tool.<\/p>\n<hr \/>\n<h4>3. The output.<\/h4>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid.gif\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2660\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/hypocycloid.gif\" alt=\"\" width=\"800\" height=\"800\" \/><\/a><\/p>\n<p>Since the small circle&#8217;s diameter is 1\/4 of the large circle, it does exactly four rotations during its trip. The resulting diamond shape is called an <a href=\"https:\/\/en.wikipedia.org\/wiki\/Astroid\" target=\"_blank\" rel=\"noopener\">astroid<\/a>. You can change the ratio and create all sorts of hypocycloid curves.<\/p>\n<p>But it gets more interesting when the <strong>R\/r<\/strong> ratio isn&#8217;t a nice even number. What if the small radius is 1.63? Then the path won&#8217;t close neatly after a single 360\u00b0 trip. It will be shifted slightly over and repeat itself, which is how those cool Spirograph designs emerge.<\/p>\n<p>We also don&#8217;t have to keep the pen at the outer edge of the small circle. Change <code>rho<\/code> to 0.47 and it will be about halfway between the small circle&#8217;s center point and its edge.<\/p>\n<p>I plotted four Spirograph designs that I thought were interesting. I won&#8217;t post the code but it&#8217;s essentially the same as above, except with tweaks to <code>R<\/code>, <code>r<\/code>, and <code>rho<\/code> as described. <code>t<\/code> is scaled to allow for three full trips around the large circle.<\/p>\n<p><a href=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/spirograph_gif_2x2_grid.gif\"><img loading=\"lazy\" decoding=\"async\" class=\"aligncenter size-full wp-image-2661\" src=\"https:\/\/wollen.org\/blog\/wp-content\/uploads\/2026\/02\/spirograph_gif_2x2_grid.gif\" alt=\"\" width=\"900\" height=\"900\" \/><\/a><\/p>\n<p>This GIF is hypnotizing to me. Small adjustments to the parameters lead to wildly different designs. You could change color and line width for even more customization, like swapping out your pen in real life.<!-- HFCM by 99 Robots - Snippet # 15: endmark-python -->\n<span class=\"endmark-python\"><\/span>\n<!-- \/end HFCM by 99 Robots -->\n<\/p>\n<hr \/>\n<p><a href=\"https:\/\/wollen.org\/misc\/spirograph_2026.zip\"><strong>Download the Matplotlib style.<\/strong><\/a><\/p>\n<p><strong>Full code:<\/strong><\/p>\n<pre class=\"EnlighterJSRAW\" data-enlighter-language=\"python\">from math import sin, cos, pi\r\nimport matplotlib.pyplot as plt\r\nfrom matplotlib.patches import Circle\r\n\r\n\r\nR = 4\r\nr = 1\r\nrho = 1.0\r\nt_steps = 300\r\n\r\nt = 0.0\r\n\r\nmovement_history_x = []\r\nmovement_history_y = []\r\n\r\nplt.style.use(\"wollen_spirograph.mplstyle\")\r\n\r\nfor frame_counter in range(t_steps):\r\n    print(f\"{frame_counter + 1}\/{t_steps}\")\r\n\r\n    fig, ax = plt.subplots()\r\n\r\n    # Axis Lines:\r\n    ax.plot([-R * 10, R * 10], [0, 0], c=\"#888\", lw=1.0, zorder=1)\r\n    ax.plot([0, 0], [-R * 10, R * 10], c=\"#888\", lw=1.0, zorder=1)\r\n\r\n    # Big Circle:\r\n    ax.add_patch(Circle((0, 0), R, fc=\"None\", ec=\"#000\", lw=1.5, zorder=3))\r\n\r\n    # Small Circle:\r\n    center_point_x = (R - r) * cos(t)\r\n    center_point_y = (R - r) * sin(t)\r\n    ax.scatter([center_point_x], [center_point_y], c=\"#000\", s=20, zorder=3)\r\n    ax.add_patch(Circle((center_point_x, center_point_y), r, fc=\"None\", ec=\"#000\", lw=1.5, zorder=3))\r\n\r\n    # Ball Point Pen:\r\n    pen_point_x = (R - r) * cos(t) + rho * r * cos((R - r) \/ r * t)\r\n    pen_point_y = (R - r) * sin(t) - rho * r * sin((R - r) \/ r * t)\r\n    ax.scatter([pen_point_x], [pen_point_y], c=\"#F00\", s=40, zorder=4)\r\n    movement_history_x.append(pen_point_x)\r\n    movement_history_y.append(pen_point_y)\r\n    ax.plot(movement_history_x, movement_history_y, c=\"#F00\", ls=\"--\", lw=1.0, zorder=2)\r\n\r\n    ticks = range(-R, R + 1)\r\n    ax.set(xticks=ticks,\r\n           yticks=ticks,\r\n           xlim=(-R * 1.05, R * 1.05),\r\n           ylim=(-R * 1.05, R * 1.05))\r\n\r\n    ax.text(x=R * 0.99,\r\n            y=R * 0.98,\r\n            s=f\"R = {R:.1f}\\nr = {r:.1f}\\n\u03c1 = {rho:.1f}\",\r\n            ha=\"right\",\r\n            va=\"top\")\r\n\r\n    plt.savefig(f\"frames\/{frame_counter}.png\")\r\n\r\n    plt.close()\r\n\r\n    t += 2 * pi \/ t_steps<\/pre>\n<p>&nbsp;<\/p>\n","protected":false},"excerpt":{"rendered":"<p>I used to love playing with these things as a kid. If you aren&#8217;t familiar, Spirograph is basically an art kit that lets you draw cool spiral shapes. Below is a demonstration from one of their commercials. I had the<\/p>\n","protected":false},"author":1,"featured_media":2677,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[40,561],"tags":[435,387,437,557,39,554,22,556,86,438,551,552,559,24,126,558,25,555,550,553,560],"class_list":["post-2645","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-math","category-animated","tag-angle","tag-animated","tag-animation","tag-circle","tag-code","tag-cycloid","tag-data","tag-diameter","tag-geometry","tag-gif","tag-hypocycloid","tag-hypotrochoid","tag-marker","tag-matplotlib","tag-mplstyle","tag-pen","tag-python","tag-radius","tag-spirograph","tag-stencil","tag-trace"],"_links":{"self":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2645","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/comments?post=2645"}],"version-history":[{"count":25,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2645\/revisions"}],"predecessor-version":[{"id":3151,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/posts\/2645\/revisions\/3151"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media\/2677"}],"wp:attachment":[{"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/media?parent=2645"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/categories?post=2645"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/wollen.org\/blog\/wp-json\/wp\/v2\/tags?post=2645"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}