POUR LE BINÔME ADULTE
Lignes - : retirées. Lignes + : ajoutées. Ce fichier ne se lance pas.

--- rubiks_etape_08.py
+++ rubiks_etape_09.py
@@ -1,10 +1,12 @@
-"""Rubik's Cube — étape 08.
-Lancement : py rubiks_etape_08.py
-Chaque fichier fonctionne seul. Lis aussi le carnet Word.
+"""Rubik's Cube — étape 09 : la 3D avec Pygame seulement.
+Lancement : py rubiks_etape_09.py
+Fichier autonome. Les étapes 01 à 08 restent inchangées.
 """
 
+import math
+import random
+from collections import deque
 import pygame
-import random
 
 # Les lettres sont celles de la notation internationale du cube.
 # U haut, D bas, F devant, B derrière, L gauche, R droite.
@@ -77,133 +79,223 @@
                for ligne in lignes for case in ligne)
 
 
-# Réglages à personnaliser.
-TAILLE_CASE = 44
-ECART = 4
-FOND = (30, 35, 50)
-# Positions dans le patron : colonne, ligne (pas des pixels).
-POSITIONS = {"U": (1, 0), "L": (0, 1), "F": (1, 1),
-             "R": (2, 1), "B": (3, 1), "D": (1, 2)}
-
-
-def origine(face):
-    colonne, ligne = POSITIONS[face]
-    pas = 3 * TAILLE_CASE + 22
-    return 24 + colonne * pas, 85 + ligne * pas
-
-
-# À TOI : invente un titre et choisis ton mélange.
-TITRE = "Mon laboratoire de cubes"
+# --- NOTRE PETIT ATELIER 3D ---
+# X va vers la droite, Y vers le haut, Z vers le devant du cube.
+# Un vecteur est ici un groupe de trois nombres (x, y, z).
+NORMALES = {"U": (0, 1, 0), "D": (0, -1, 0),
+            "F": (0, 0, 1), "B": (0, 0, -1),
+            "R": (1, 0, 0), "L": (-1, 0, 0)}
+DROITES = {"U": (1, 0, 0), "D": (1, 0, 0),
+           "F": (1, 0, 0), "B": (-1, 0, 0),
+           "R": (0, 0, -1), "L": (0, 0, 1)}
+BAS = {"U": (0, 0, 1), "D": (0, 0, -1),
+       "F": (0, -1, 0), "B": (0, -1, 0),
+       "R": (0, -1, 0), "L": (0, -1, 0)}
+
+# À TOI : modifie un seul réglage à la fois.
+TITRE = "Mon Rubik's Cube en 3D"
+FOND = (22, 27, 40)
+DUREE_TOUR = 0.45          # Secondes : plus grand = plus lent.
 LONGUEUR_MELANGE = 20
-AFFICHER_LETTRES = True
+TAILLE_AUTOCOLLANT = 0.82 # Reste entre 0.5 et 0.9.
+
+
+def produit_scalaire(a, b):
+    return sum(x * y for x, y in zip(a, b))
+
+
+def produit_vectoriel(a, b):
+    return (a[1]*b[2] - a[2]*b[1], a[2]*b[0] - a[0]*b[2],
+            a[0]*b[1] - a[1]*b[0])
+
+
+def rotation(v, axe, angle):
+    # Formule de Rodrigues : tourner autour d'un axe de longueur 1.
+    # On peut l'utiliser comme un outil et l'étudier plus tard.
+    cosinus, sinus = math.cos(angle), math.sin(angle)
+    croix = produit_vectoriel(axe, v)
+    projection = produit_scalaire(axe, v)
+    return tuple(v[i]*cosinus + croix[i]*sinus
+                 + axe[i]*projection*(1 - cosinus) for i in range(3))
+
+
+def position_case(face, ligne, colonne):
+    return tuple(NORMALES[face][i] + (colonne - 1)*DROITES[face][i]
+                 + (ligne - 1)*BAS[face][i] for i in range(3))
+
+
+def autocollants(cube):
+    # On associe chaque carré de couleur à un petit cube et à sa normale.
+    resultat = {}
+    for face in cube:
+        for ligne in range(3):
+            for colonne in range(3):
+                cle = (position_case(face, ligne, colonne), NORMALES[face])
+                resultat[cle] = cube[face][ligne][colonne]
+    return resultat
+
+
+class Camera:
+    def __init__(self):
+        self.recentrer()
+
+    def recentrer(self):
+        self.lacet = -0.60
+        self.inclinaison = 0.48
+        self.zoom = 92
+
+    def orienter(self, vecteur):
+        v = rotation(vecteur, (0, 1, 0), self.lacet)
+        return rotation(v, (1, 0, 0), self.inclinaison)
+
+    def projeter(self, point):
+        x, y, z = self.orienter(point)
+        # Les objets proches paraissent plus grands : c'est la perspective.
+        facteur = self.zoom * 9 / (9 - z)
+        return (340 + x * facteur, 340 - y * facteur)
+
+    def glisser(self, dx, dy):
+        self.lacet += dx * 0.008
+        self.inclinaison += dy * 0.008
+
+    def agrandir(self, crans):
+        self.zoom = max(55, min(125, self.zoom + crans * 6))
+
+
+def transformation(point, position, animation):
+    # Seule la tranche concernée bouge ; le reste du cube reste immobile.
+    if animation and produit_scalaire(position, NORMALES[animation["face"]]) > 0.5:
+        fraction = min(1.0, animation["temps"] / animation["duree"])
+        souple = fraction * fraction * (3 - 2 * fraction)
+        signe = 1 if animation["inverse"] else -1
+        return rotation(point, NORMALES[animation["face"]], signe * math.pi/2 * souple)
+    return point
+
+
+def surfaces(cube, animation=None):
+    # Les 27 petits cubes ont un corps sombre et des autocollants extérieurs.
+    # On dessine aussi leurs faces internes : elles apparaissent pendant un tour.
+    couleurs = autocollants(cube)
+    resultat = []
+    for x in (-1, 0, 1):
+        for y in (-1, 0, 1):
+            for z in (-1, 0, 1):
+                position = (x, y, z)
+                for face, normale in NORMALES.items():
+                    lettre = couleurs.get((position, normale))
+                    tailles = [(0.94, 0.47, None)]
+                    if lettre:
+                        tailles.append((TAILLE_AUTOCOLLANT, 0.476, lettre))
+                    for taille, profondeur, etiquette in tailles:
+                        centre = tuple(position[i] + normale[i]*profondeur for i in range(3))
+                        coins = []
+                        for a, b in [(-1, -1), (1, -1), (1, 1), (-1, 1)]:
+                            point = tuple(centre[i] + taille/2 * (a*DROITES[face][i]
+                                          + b*BAS[face][i]) for i in range(3))
+                            coins.append(transformation(point, position, animation))
+                        n = transformation(normale, position, animation)
+                        centre = transformation(centre, position, animation)
+                        resultat.append((coins, n, centre, etiquette))
+    return resultat
+
+
+def dessiner_cube(fenetre, cube, camera, police, lettres=False, animation=None):
+    ancien_cadrage = fenetre.get_clip()
+    fenetre.set_clip(pygame.Rect(15, 85, 675, 515))
+    polygones = []
+    for coins, normale, centre, etiquette in surfaces(cube, animation):
+        normal_vue = camera.orienter(normale)
+        centre_vue = camera.orienter(centre)
+        vers_camera = (-centre_vue[0], -centre_vue[1], 9 - centre_vue[2])
+        if produit_scalaire(normal_vue, vers_camera) <= 0:
+            continue  # Une surface qui nous tourne le dos est cachée.
+        profondeur = sum(camera.orienter(p)[2] for p in coins) / 4
+        points = [camera.projeter(p) for p in coins]
+        if etiquette:
+            lumiere = 0.72 + 0.28 * max(0, produit_scalaire(normal_vue, (-0.3, 0.5, 0.81)))
+            couleur = tuple(min(255, int(c*lumiere)) for c in COULEURS[etiquette])
+        else:
+            couleur = (12, 15, 22)
+        polygones.append((profondeur, points, couleur, etiquette, camera.projeter(centre)))
+    # Peintre : les surfaces éloignées sont dessinées avant les surfaces proches.
+    for profondeur, points, couleur, etiquette, centre in sorted(polygones, key=lambda p: p[0]):
+        pygame.draw.polygon(fenetre, couleur, points)
+        if etiquette and lettres:
+            texte = police.render(etiquette, True, (12, 15, 20))
+            fenetre.blit(texte, texte.get_rect(center=(int(centre[0]), int(centre[1]))))
+    fenetre.set_clip(ancien_cadrage)
 
 
 def main():
     pygame.init()
-    fenetre = pygame.display.set_mode((1000, 700))
-    pygame.display.set_caption("Mon Rubik's Cube - Etape 8")
+    fenetre = pygame.display.set_mode((1040, 740))
+    pygame.display.set_caption("Mon Rubik's Cube 3D - Etape 09")
     horloge = pygame.time.Clock()
-    police = pygame.font.Font(None, 25)
-    petite = pygame.font.Font(None, 21)
+    police = pygame.font.Font(None, 26)
+    petite = pygame.font.Font(None, 22)
+    titre = pygame.font.Font(None, 36)
+    camera = Camera()
+    glisser = False
+    lettres = False
     cube = creer_cube()
-    compteur = 0
-    message = "Prêt à jouer !"
-    historique = []
-    # Un bouton contient un rectangle, un texte et une action.
     boutons = []
-    for indice, face in enumerate("UDFBLR"):
-        boutons.append((pygame.Rect(685, 110 + indice * 47, 110, 37),
-                        face, (face, False)))
-        boutons.append((pygame.Rect(810, 110 + indice * 47, 110, 37),
-                        face + "'", (face, True)))
-    boutons.append((pygame.Rect(685, 420, 235, 38), "Mélanger", "melanger"))
-    boutons.append((pygame.Rect(685, 467, 235, 38), "Remettre à zéro", "reset"))
-    boutons.append((pygame.Rect(685, 514, 235, 38), "Annuler un coup", "annuler"))
+    boutons.append((pygame.Rect(725, 553, 245, 36), "Recentrer la vue", "camera"))
+    boutons.append((pygame.Rect(725, 599, 245, 36), "Afficher les lettres", "lettres"))
     en_marche = True
     while en_marche:
-        action = None
+        secondes = min(horloge.tick(60) / 1000, 0.1)
         for evenement in pygame.event.get():
+            action = None
             if evenement.type == pygame.QUIT:
                 en_marche = False
-            action = None
-            if evenement.type == pygame.MOUSEBUTTONDOWN and evenement.button == 1:
-                for rectangle, texte, commande in boutons:
-                    if rectangle.collidepoint(evenement.pos):
-                        action = commande
-            if evenement.type == pygame.KEYDOWN:
-                lettre = pygame.key.name(evenement.key).upper()
-                if lettre in COULEURS:
-                    inverse = bool(evenement.mod & pygame.KMOD_SHIFT)
-                    action = (lettre, inverse)
-                if evenement.key == pygame.K_SPACE:
-                    action = "melanger"
-                if evenement.key == pygame.K_DELETE:
-                    action = "reset"
-                if evenement.key == pygame.K_BACKSPACE:
-                    action = "annuler"
-            if isinstance(action, tuple):
-                face, inverse = action
-                tourner(cube, face, inverse)
-                compteur += 1
-                message = face + ("'" if inverse else "")
-                historique.append(action)
-            elif action == "reset":
-                cube = creer_cube()
-                compteur = 0
-                message = "Cube remis à zéro."
-                historique.clear()
-            elif action == "melanger":
-                # On part du cube résolu et on lui applique de vrais mouvements.
-                cube = creer_cube()
-                precedente = None
-                for coup in range(LONGUEUR_MELANGE):
-                    choix = [face for face in COULEURS if face != precedente]
-                    face = random.choice(choix)
-                    tourner(cube, face, random.choice([False, True]))
-                    precedente = face
-                compteur = 0
-                message = "Mélange prêt. À toi de jouer !"
-                historique.clear()
-            elif action == "annuler":
-                if historique:
-                    face, inverse = historique.pop()
-                    tourner(cube, face, not inverse)
-                    compteur -= 1
-                    message = "Dernier coup annulé."
+            elif evenement.type == pygame.MOUSEBUTTONDOWN and evenement.button == 1:
+                if evenement.pos[0] < 690:
+                    glisser = True
                 else:
-                    message = "Aucun coup à annuler."
-
+                    for rectangle, texte, commande in boutons:
+                        if rectangle.collidepoint(evenement.pos):
+                            action = commande
+            elif evenement.type == pygame.MOUSEBUTTONUP and evenement.button == 1:
+                glisser = False
+            elif evenement.type == pygame.MOUSEMOTION:
+                if glisser and evenement.buttons[0]:
+                    camera.glisser(*evenement.rel)
+                elif not evenement.buttons[0]:
+                    glisser = False
+            elif evenement.type == pygame.WINDOWFOCUSLOST:
+                glisser = False
+            elif evenement.type == pygame.MOUSEWHEEL:
+                camera.agrandir(evenement.y)
+            elif evenement.type == pygame.KEYDOWN:
+                if evenement.key == pygame.K_HOME:
+                    action = "camera"
+                elif evenement.key == pygame.K_TAB:
+                    action = "lettres"
+            if action == "camera":
+                camera.recentrer()
+            elif action == "lettres":
+                lettres = not lettres
         fenetre.fill(FOND)
-        fenetre.blit(police.render(TITRE, True, (245, 245, 245)), (24, 20))
-        for face in POSITIONS:
-            x, y = origine(face)
-            fenetre.blit(petite.render(face, True, (240, 240, 240)), (x, y - 20))
-            for ligne in range(3):
-                for colonne in range(3):
-                    couleur = COULEURS[cube[face][ligne][colonne]]
-                    rectangle = pygame.Rect(x + colonne * TAILLE_CASE,
-                                            y + ligne * TAILLE_CASE,
-                                            TAILLE_CASE - ECART, TAILLE_CASE - ECART)
-                    pygame.draw.rect(fenetre, couleur, rectangle)
-                    if AFFICHER_LETTRES:
-                        etiquette = petite.render(cube[face][ligne][colonne], True, (15, 20, 30))
-                        fenetre.blit(etiquette, etiquette.get_rect(center=rectangle.center))
-        legende = "U haut   D bas   F devant   B derrière   L gauche   R droite"
-        fenetre.blit(petite.render(legende, True, (230, 230, 235)), (24, 590))
+        fenetre.blit(titre.render(TITRE, True, (245, 245, 250)), (30, 25))
+        pygame.draw.ellipse(fenetre, (15, 19, 29), (155, 555, 370, 42))
+        dessiner_cube(fenetre, cube, camera, petite, lettres)
+        pygame.draw.rect(fenetre, (31, 38, 55), (700, 85, 300, 620), border_radius=16)
+        fenetre.blit(police.render("Les commandes", True, (245, 245, 250)), (725, 95))
         for rectangle, texte, commande in boutons:
-            pygame.draw.rect(fenetre, (65, 75, 100), rectangle, border_radius=6)
-            etiquette = police.render(texte, True, (255, 255, 255))
+            actif = rectangle.collidepoint(pygame.mouse.get_pos())
+            couleur = (76, 96, 140) if actif else (54, 68, 100)
+            pygame.draw.rect(fenetre, couleur, rectangle, border_radius=7)
+            etiquette = police.render(texte, True, (250, 250, 255))
             fenetre.blit(etiquette, etiquette.get_rect(center=rectangle.center))
-        fenetre.blit(petite.render("Une lettre = un tour horaire", True, (235, 235, 240)), (685, 60))
-        fenetre.blit(petite.render("Apostrophe = tour inverse", True, (235, 235, 240)), (685, 82))
-        aide = "Clavier : U D F B L R. Maintiens Maj pour le sens inverse."
-        fenetre.blit(petite.render(aide, True, (235, 235, 240)), (24, 625))
-        etat = "Cube résolu !" if est_resolu(cube) else "Cube en cours"
-        fenetre.blit(police.render(etat, True, (255, 220, 90)), (685, 580))
-        fenetre.blit(petite.render(f"Tes coups : {compteur}", True, (245,245,245)), (685, 610))
-        fenetre.blit(petite.render(message, True, (240,240,240)), (24, 660))
+        aides = ["Souris maintenue : tourner la vue", "Molette : zoomer",
+                 "Début : recentrer | Tab : lettres",
+                 "U haut | D bas | F devant",
+                 "B derrière | L gauche | R droite"]
+        for indice, texte in enumerate(aides):
+            colonne, ligne = divmod(indice, 5)
+            fenetre.blit(petite.render(texte, True, (210, 219, 238)),
+                         (30 + colonne*345, 610 + ligne*22))
         pygame.display.flip()
-        horloge.tick(60)
     pygame.quit()
 
 
