Spirographe V2 Python

Le Spirographe est un jeu de création de motifs géométriques à l’aide de cercles crantés. Il fonctionne en plaçant un point traceur dans un petit cercle qui roule à l’intérieur d’un cercle fixe. Ce mouvement génère des motifs appelés hypocycloïdes.

En utilisant des équations d’hypocycloïde et quelques paramètres (rayon du cercle fixe, rayon du cercle roulant et distance du point traceur), il est possible de générer automatiquement des tracés similaires à ceux obtenus avec le jeu Spirographe. La variation de ces paramètres permet de produire des motifs différents, allant de figures simples et régulières à des formes plus complexes.

Mouvement Equations
Une hypocycloïde peut être définie en Python par l’équation paramétrique suivante :

x = (R - r) * cos(t) + d * cos((R - r) / r * t)

y = (R - r) * sin(t) - d * sin((R - r) / r * t)


R : rayon du cercle fixe
r : rayon du cercle roulant
d : distance du point traceur au centre du cercle roulant
t : angle de rotation du cercle roulant

Principes

Le script est relativement simple et le principe repose sur un grand cercle fixe, sur un petit cercle qui tourne à l’intérieur, et un point précis sur ce dernier (là où le stylo se place) :

Grand cercle (R)
  └── Petit cercle (r) qui roule dedans (ou selon les valeurs à l’extérieur)
       └── Stylo à une distance (d) du centre

Note 1 : Le rayon du cercle intérieur (“petit cercle”) peut être plus grand que celui du cercle fixe. Le tracé fonctionnera toujours.
Note 2 : Le crayon peut se trouver à l’extérieur du cercle intérieur (“petit cercle”). Le tracé fonctionnera toujours.

Interface du Spirographe

Cette version possède une interface graphique pour controller les paramètres. L’animation permet de comprendre le déplacement du stylo et de comprendre la courbe finale.

Cercle central (blanc) plus petit que le cercle fixe (orange) :

Cercle central (blanc) plus petit que le cercle fixe (orange) avec un crayon à l’extérieur :

Cercle central (blanc) plus grand que le cercle fixe (orange) :

Exemples

Pour des motifs hypotrochoïdes ( formes étoile ou fleur), le secret réside dans le rapport R / r et la distance d.

Pour une figure en étoile/fleur avec beaucoup de pétales :

  • R et r premiers entre eux (gcd(R,r) = 1) : le motif fait R tours avant de se refermer
  • d < r : boucles serrées → pétales fins
  • d ≈ r : pétales qui touchent le centre → forme étoile plus marquée
  • d > r : motifs plus ouverts et boucles plus grosses

Exemples de variations en fonction des Valeurs R, r et d :

  Valeurs   Formes   Valeurs   Formes   Valeurs   Formes
R = 8
r = 3
d = 4
T = 3
R = 13
r = 3.33
d = 33
T = 5
R = 15
r = 9
d = 10
T = 3
R = 26
r = 10
d = 5
T = 5
R = 8
r = 3
d = 2
d = 3
R = 20
r = 9
d = 10
T = 9
R = 14
r = 3
d = 7.5
T = 7
R = 25
r = 4
d = 25
d = 4
R = 17
r = 9.5
d = 10
T = 19
R = 8.5
r = 3.39
d = 4
T = 40
R = 9.56
r = 12
d = 19
T = 59
R = 52
r = 7.5
d = 40
T = 11
R = 52
r = 7.5
d = 40
T = 5
R = 14
r = 3
d = 2
T = 3
R = 12.1
r = 13.1
d = 5.3
T = 70

Spirographe Python V2

Modules requis

Les classes numpy, matplotlib et tkinter seront nécéssaires :

python -m pip install numpy matplotlib tkinter

Script

Le script est sur le même principe que la version http://n0tes.fr/2026/01/06/Python-Spirographe/, la construction du GUI a juste générée beaucoup de code en plus :

# ==================================================================== #
# #
# SPIROGRAPHE #
# #
# ==================================================================== #
# 2026 - V1.0 #
# ==================================================================== #

# ------- Librairies standard -----------------------------
import tkinter as tk
import tkinter.font as tkfont
from tkinter import colorchooser, filedialog
# ------- Librairies scientifiques ------------------------
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.animation import FuncAnimation

# =========================================================
# FONCTIONS DE BASES
# =========================================================

# ---------------------------------------------------------
# ------- Calcul de l'hypocycloïde ------------------------
# ---------------------------------------------------------
def compute_spiro(R, r, d, T, points=3000):
t = np.linspace(0, 2 * np.pi * T, points)
x = (R - r) * np.cos(t) + d * np.cos((R - r) / r * t)
y = (R - r) * np.sin(t) - d * np.sin((R - r) / r * t)
return x, y

# ---------------------------------------------------------
# ------ Calculer les limites pour ne pas sortir du cadre -
# ---------------------------------------------------------
def set_axes_limits(ax, R, r, d, T):
# calculer le spirographe complet pour avoir toutes les positions possibles
x, y = compute_spiro(R, r, d, T, points=5000) # beaucoup de points pour attraper les extrêmes

# trouver les limites exactes
x_min, x_max = min(x), max(x)
y_min, y_max = min(y), max(y)

# ajouter une marge de sécurité (2%)
margin = 0.02
x_range = (x_max - x_min) * margin
y_range = (y_max - y_min) * margin

ax.set_xlim(x_min - x_range, x_max + x_range)
ax.set_ylim(y_min - y_range, y_max + y_range)
ax.set_aspect("equal")


# =========================================================
# FONCTIONS DE CONTRÔLES
# =========================================================

# ---------------------------------------------------------
# ------ Choix de la couleur du tracé (hexadécimal) -------
# ---------------------------------------------------------
def choose_color():
color = colorchooser.askcolor()[1]
if color:
color_var.set(color)

# ---------------------------------------------------------
# ------ Prévisualisation des paramètres avant animation --
# ---------------------------------------------------------
def preview():
ax.clear()
ax.set_facecolor("black")
ax.axis("off")

# récupérer les paramètres
R, r, d, T = R_var.get(), r_var.get(), d_var.get(), T_var.get()

# fixer les limites pour que rien ne sorte du cadre
set_axes_limits(ax, R, r, d, T)

# tracer le cercle fixe pour repère
theta_c = np.linspace(0, 2*np.pi, 300)
ax.plot(R * np.cos(theta_c), R * np.sin(theta_c), color="orange", lw=1)

# centre initial du petit cercle
cx = R - r
cy = 0

# cercle roulant initial
theta_r = np.linspace(0, 2*np.pi, 300)
ax.plot(cx + r * np.cos(theta_r), cy + r * np.sin(theta_r), color="white", lw=1)

# position du stylo
px, py = cx + d, cy
ax.scatter([px], [py], color=color_var.get(), s=20, zorder=3)

# segment reliant centre du cercle au stylo
ax.plot([cx, px], [cy, py], color="white", lw=1)

# croix au centre du petit cercle
cross_size = r * 0.1 # 10% du rayon du petit cercle
# horizontal
ax.plot([cx - cross_size, cx + cross_size], [cy, cy], color="white", lw=1)
# vertical
ax.plot([cx, cx], [cy - cross_size, cy + cross_size], color="white", lw=1)

canvas.draw()

# ---------------------------------------------------------
# ------ Lancer l'animation du tracé ----------------------
# ---------------------------------------------------------
def animate():
global animation

ax.clear()
R, r, d, T = R_var.get(), r_var.get(), d_var.get(), T_var.get()

# calculer le tracé complet
x, y = compute_spiro(R, r, d, T, points=3000)

# fixer les limites pour que rien ne sorte du cadre
set_axes_limits(ax, R, r, d, T)

ax.axis("off")
ax.set_facecolor("black")

# cercle fixe
theta_c = np.linspace(0, 2*np.pi, 300)
ax.plot(R * np.cos(theta_c), R * np.sin(theta_c), color="orange", lw=1)

# éléments animés
line, = ax.plot([], [], color=color_var.get(), lw=1)
rolling_circle, = ax.plot([], [], color="white", lw=1)
rotation_marker = ax.scatter([], [], s=20, color="red", zorder=3)
link_line, = ax.plot([], [], color="white", lw=1, zorder=2)

# croix au centre du petit cercle (2 segments)
cross_h, = ax.plot([], [], color="white", lw=1, zorder=4) # horizontal
cross_v, = ax.plot([], [], color="white", lw=1, zorder=4) # vertical

cross_size = r * 0.1 # taille de la croix (10% du rayon du petit cercle)

def update(i):
line.set_data(x[:i], y[:i])

t = i * (2*np.pi*T) / len(x)
cx = (R - r) * np.cos(t)
cy = (R - r) * np.sin(t)

rolling_circle.set_data(cx + r * np.cos(theta), cy + r * np.sin(theta))

px, py = x[i], y[i]
rotation_marker.set_offsets([[px, py]])

# segment du centre du petit cercle vers le stylo
link_line.set_data([cx, px], [cy, py])

# croix au centre du petit cercle
cross_h.set_data([cx - cross_size, cx + cross_size], [cy, cy])
cross_v.set_data([cx, cx], [cy - cross_size, cy + cross_size])

if i >= len(x) - 1:
animation.event_source.stop()

return line, rolling_circle, rotation_marker, link_line, cross_h, cross_v

animation = FuncAnimation(fig, update, frames=len(x), interval=1, blit=True)
canvas.draw()

# ---------------------------------------------------------
# ------ Enregistrer l'image ------------------------------
# ---------------------------------------------------------
def save_image():
file = filedialog.asksaveasfilename(
defaultextension=".png",
filetypes=[("PNG", "*.png")]
)
if file:
# récupérer les paramètres
R, r, d, T = R_var.get(), r_var.get(), d_var.get(), T_var.get()
x, y = compute_spiro(R, r, d, T)

# créer une figure temporaire pour l'enregistrement
fig_save, ax_save = plt.subplots(facecolor="black")
ax_save.set_facecolor("black")
ax_save.axis("equal")
ax_save.axis("off")

# tracer seulement le spirographe
ax_save.plot(x, y, color=color_var.get(), lw=1)

# sauvegarder
fig_save.savefig(file, facecolor="black")
plt.close(fig_save) # fermer la figure temporaire


# ---------------------------------------------------------
# ------ ZOOM + et ZOOM - ---------------------------------
# ---------------------------------------------------------
# facteur de zoom pour chaque clic
ZOOM_STEP = 1.2

def zoom_in():
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
cx, cy = (x0 + x1)/2, (y0 + y1)/2 # centre actuel
w, h = (x1 - x0)/ZOOM_STEP, (y1 - y0)/ZOOM_STEP
ax.set_xlim(cx - w/2, cx + w/2)
ax.set_ylim(cy - h/2, cy + h/2)
canvas.draw()

def zoom_out():
x0, x1 = ax.get_xlim()
y0, y1 = ax.get_ylim()
cx, cy = (x0 + x1)/2, (y0 + y1)/2 # centre actuel
w, h = (x1 - x0)*ZOOM_STEP, (y1 - y0)*ZOOM_STEP
ax.set_xlim(cx - w/2, cx + w/2)
ax.set_ylim(cy - h/2, cy + h/2)
canvas.draw()

# ---------------------------------------------------------
# ------ Quitter proprement l'application -----------------
# ---------------------------------------------------------
def on_close():
global animation
if animation is not None:
animation.event_source.stop()
animation = None
plt.close(fig)
root.destroy()


# =========================================================
# INTERFACE SPIROGRAPHE
# =========================================================

# ---------------------------------------------------------
# ------ Création de la fenêtre ---------------------------
# ---------------------------------------------------------
root = tk.Tk()
root.title("Spirographe interactif")

# ---------------------------------------------------------
# ------ Frame principale pour les contrôles --------------
# ---------------------------------------------------------
ui_frame = tk.Frame(root)
ui_frame.grid(row=0, column=0, sticky="nw", padx=8, pady=8)

# ---------------------------------------------------------
# ------ Police globale -----------------------------------
# ---------------------------------------------------------
default_font = tkfont.nametofont("TkDefaultFont")
default_font.configure(family="Consolas", size=10)
root.option_add("*Font", default_font)

# ---------------------------------------------------------
# ------ Variables ----------------------------------------
# ---------------------------------------------------------
R_var = tk.DoubleVar(value=8)
r_var = tk.DoubleVar(value=3)
d_var = tk.DoubleVar(value=2)
T_var = tk.IntVar(value=3)
color_var = tk.StringVar(value="#00FFFF")

controls = [
("R (cercle fixe - orange)", R_var),
("r (cercle roulant - blanc)", r_var),
("d (distance stylo)", d_var),
("T (nombre de tours)", T_var),
]

# ---------------------------------------------------------
# ------ Figure -------------------------------------------
# ---------------------------------------------------------
fig, ax = plt.subplots(facecolor="black")
ax.set_facecolor("black")
ax.axis("equal")
ax.axis("off")

canvas = FigureCanvasTkAgg(fig, master=root)
canvas.get_tk_widget().grid(row=0, column=2, rowspan=20) # assez grand pour la figure

theta = np.linspace(0, 2*np.pi, 200)
animation = None

# ---------------------------------------------------------
# ------ UI: Row tracker ----------------------------------
# ---------------------------------------------------------
row = 0 # Initialiser le row

# ---------------------------------------------------------
# ------ Titre --------------------------------------------
# ---------------------------------------------------------
tk.Label(
ui_frame,
text="Spirographe",
font=("Consolas", 12, "bold")
).grid(row=row, column=0, columnspan=2, pady=(0, 6))
row += 1

# ---------------------------------------------------------
# ------ Description --------------------------------------
# ---------------------------------------------------------
tk.Label(
ui_frame,
text=(
"Une hypocycloïde peut être définie par :\n"
"x = (R - r) * cos(t) + d * cos((R - r) / r * t)\n"
"y = (R - r) * sin(t) - d * sin((R - r) / r * t)"
),
justify="left"
).grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 10))
row += 1

# ---------------------------------------------------------
# ------ Titre pour les variables -------------------------
# ---------------------------------------------------------
tk.Label(
ui_frame,
text="Modifier les valeurs :",
font=("Consolas", 10, "bold"),
justify="left"
).grid(row=row, column=0, columnspan=2, sticky="w", pady=(5, 5))
row += 1

# ---------------------------------------------------------
# ------ Variables modifiables ----------------------------
# ---------------------------------------------------------
for label, var in controls:
tk.Label(ui_frame, text=label).grid(row=row, column=0, sticky="w")
tk.Entry(ui_frame, textvariable=var, width=10).grid(row=row, column=1, sticky="e")
row += 1

# ---------------------------------------------------------
# ------ Section CONTROLES --------------------------------
# ---------------------------------------------------------
tk.Label(
ui_frame,
text="CONTROLES",
font=("Consolas", 11, "bold"),
justify="center"
).grid(row=row, column=0, columnspan=2, sticky="we", pady=(10, 6))
row += 1

BTN_PADY = 2

tk.Button(ui_frame, text="Couleur tracé", command=choose_color).grid(row=row, column=0, columnspan=2, sticky="we", pady=BTN_PADY)
row += 1

tk.Button(ui_frame, text="Prévisualiser", command=preview).grid(row=row, column=0, sticky="we", pady=BTN_PADY)
tk.Button(ui_frame, text="Lancer animation", command=animate).grid(row=row, column=1, sticky="we", pady=BTN_PADY)
row += 1

tk.Button(ui_frame, text="Enregistrer image", command=save_image).grid(row=row, column=0, columnspan=2, sticky="we", pady=BTN_PADY)
row += 1

tk.Button(ui_frame, text="ZOOM +", command=zoom_in).grid(row=row, column=0, sticky="we", pady=BTN_PADY)
tk.Button(ui_frame, text="ZOOM -", command=zoom_out).grid(row=row, column=1, sticky="we", pady=BTN_PADY)
row += 1

tk.Button(ui_frame, text="Quitter", command=on_close).grid(row=row, column=0, columnspan=2, sticky="we", pady=(4, 0))

# ---------------------------------------------------------
# ------ Lancer la prévisualisation initiale --------------
# ---------------------------------------------------------
preview()

# ---------------------------------------------------------
# ------ Fermer proprement --------------------------------
# ---------------------------------------------------------
root.protocol("WM_DELETE_WINDOW", on_close)
root.mainloop()

Documentation

https://fr.wikipedia.org/wiki/Hypocyclo%C3%AFde
https://fr.wikipedia.org/wiki/Repr%C3%A9sentation_param%C3%A9trique
https://fr.wikipedia.org/wiki/Courbe_cyclo%C3%AFdale

🡅 Partager