diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a9d1259 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,70 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + lint: + name: Format check (black) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install black + run: pip install black + + - name: Check formatting + run: black --check cameras/ engine/ download_models.py + + syntax: + name: Syntax check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check syntax (py_compile) + run: | + python -m py_compile download_models.py + python -m py_compile engine/config.py + python -m py_compile engine/mathlib.py + python -m py_compile engine/dot_obj_parser.py + python -m py_compile engine/opengl_3d_object.py + python -m py_compile cameras/parameters.py + python -m py_compile cameras/fonctions_images.py || true + python -m py_compile cameras/write_read_csv.py + + test: + name: Tests (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: "pip" + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y --no-upgrade libgl1 libglib2.0-0 + + - name: Install dependencies + run: | + pip install -r requirements.txt + pip install -r requirements-dev.txt + + - name: Run tests + run: pytest -v diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..aec8ecb --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,18 @@ +name: Release Please + +on: + push: + branches: [master] + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + release-type: python diff --git a/.gitignore b/.gitignore index 6425674..d348acc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ -/engine/models/ /.venv /.idea test.* __pycache__/ -/program.prof \ No newline at end of file +/program.prof +tmpclaude* + +# Modèles 3D (téléchargés via download_models.py) +/engine/models/*.obj +/engine/models/*.mtl \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..466df71 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/FEEDBACK.md b/FEEDBACK.md new file mode 100644 index 0000000..2e90bc7 --- /dev/null +++ b/FEEDBACK.md @@ -0,0 +1,65 @@ +# Feedback — Krafton + +## Vue d'ensemble + +C'est un **moteur 3D avec système de positionnement par vision**, combinant : +- Un moteur de rendu OpenGL (pygame + PyOpenGL) +- Un pipeline de vision par ordinateur (OpenCV, blob detection LED) +- Des fichiers FreeCAD pour un contrôleur physique ("manette") + +Le projet est clairement un **prototype académique** (les commits mentionnent "l'oral"). + +--- + +## Points positifs + +- **Quaternions** pour les rotations — approche mathématiquement solide, évite le gimbal lock +- **Bonne séparation** graphics vs vision (modules distincts) +- **Type hints** récemment ajoutés — bonne direction +- Hiérarchie de classes propre pour les objets 3D (`OBJECT_BASE`, `FACES`, `AXES`, etc.) +- Orthonormalisation de Gram-Schmidt pour maintenir la base caméra + +--- + +## Problèmes critiques + +| Problème | Fichier | Impact | +|----------|---------|--------| +| `engine/models/` ignoré par `.gitignore` mais requis au démarrage | `main.py:14` | Crash au lancement | +| Vision et moteur 3D complètement découplés — aucune intégration | Tout le projet | Fonctionnalité incomplète | +| Chemin relatif hardcodé `'engine/models/Theiere.obj'` | `main.py:14` | Fragile | + +--- + +## Problèmes de code + +- **16 méthodes de déplacement quasi-identiques** (`forward3D`, `backward3D`, etc.) — `opengl_3d_object.py:169-293`. Une seule fonction paramétrée suffirait. +- **Cache désactivé** avec `force_parse=True` en dur — `dot_obj_parser.py:15` +- **Couleurs aléatoires** à chaque compilation — `opengl_3d_object.py:78,92` +- `produit_scalaire()` dans `fonctions_images.py:37-48` réimplémente ce que NumPy fait nativement +- Aucune gestion d'erreurs (I/O, caméra, OpenGL) +- `if __name__ == "__main__":` absent dans `main.py` + +--- + +## OpenGL Legacy + +Le projet utilise des **display lists** (OpenGL 1.x). Pour de meilleures performances, les VBOs (Vertex Buffer Objects) seraient la prochaine étape — mais pour un prototype, c'est acceptable. + +--- + +## Documentation & Qualité + +- README d'une seule ligne +- Aucun test (et `test.*` explicitement ignoré dans `.gitignore`) +- Messages de commits peu descriptifs (`"a"`, `"Drop the mic..."`) +- Configuration éparpillée entre `parameters.py` et `main.py` + +--- + +## Priorités si tu veux aller plus loin + +1. **Intégrer vision + moteur** — c'est le cœur du projet +2. **Factoriser les méthodes de déplacement** — gain immédiat de lisibilité +3. **Ajouter les modèles au repo** (ou un script de téléchargement) +4. **Documenter le setup** dans le README diff --git a/README.md b/README.md index ec78634..651e880 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,100 @@ # Krafton -Moteur 3d et positionnement par vision + +Moteur 3D avec système de positionnement par vision. Le projet combine un moteur de rendu OpenGL (rotations par quaternions), un pipeline de vision par ordinateur pour la détection de marqueurs LED, et des modèles FreeCAD pour un contrôleur physique. + +## Prérequis + +- Python 3.10+ +- Fichiers de modèles `.obj` à placer dans `engine/models/` + +## Installation + +Créer et activer un environnement virtuel : + +```bash +python -m venv .venv +``` + +Windows : +```bash +.venv\Scripts\activate +``` + +Linux / macOS : +```bash +source .venv/bin/activate +``` + +Installer les dépendances : + +```bash +pip install -r requirements.txt +``` + +## Modèles 3D + +Les modèles `.obj` ne sont pas inclus dans le dépôt. Pour les télécharger : + +```bash +python download_models.py +``` + +## Lancement + +```bash +python -m engine.main +``` + +## Développement + +Le code est formaté avec [black](https://black.readthedocs.io/). Lancer le formateur avant chaque commit : + +```bash +black cameras/ engine/ download_models.py +``` + +## CI/CD + +### Pipelines GitHub Actions + +| Workflow | Déclencheur | Rôle | +|----------|-------------|------| +| `ci.yml` | push / PR → `master` | Formatage black, syntaxe Python, tests unitaires (pytest) | +| `release-please.yml` | push → `master` | Crée automatiquement les PRs de release, bumpe la version dans `pyproject.toml` et génère le changelog | + +Les tests couvrent la logique pure (math, parsing OBJ, caméra). Les méthodes OpenGL (`compile`, `draw`) et la boucle pygame nécessitent un contexte graphique et ne sont pas testées. + +### Release Please + +[Release Please](https://github.com/googleapis/release-please) détecte les commits sur `master` et crée une PR de release qui : +- incrémente la version sémantique dans `pyproject.toml` +- génère / met à jour `CHANGELOG.md` +- crée le tag Git et la GitHub Release au merge + +La version courante est définie dans `pyproject.toml` (`version = "..."`) et suivie dans `.release-please-manifest.json`. + +### Conventional Commits + +Release Please s'appuie sur la convention [Conventional Commits](https://www.conventionalcommits.org/) pour déterminer le type de bump et le contenu du changelog. + +| Préfixe | Effet sur la version | Exemple | +|---------|----------------------|---------| +| `fix:` | patch (`0.1.0` → `0.1.1`) | `fix: corriger le calcul de l'homographie` | +| `feat:` | mineur (`0.1.0` → `0.2.0`) | `feat: ajouter le support des fichiers .gltf` | +| `feat!:` ou `BREAKING CHANGE:` | majeur (`0.1.0` → `1.0.0`) | `feat!: refactoriser l'API caméra` | +| `chore:`, `docs:`, `refactor:`… | aucun bump | `chore: mettre à jour les dépendances` | + +Les commits sans préfixe (comme les commits actuels) sont ignorés par Release Please et n'apparaissent pas dans le changelog. + +## Contrôles + +| Touche | Action | +|--------|--------| +| `↑ ↓ ← →` | Avancer / reculer / strafe | +| `Espace / C` | Monter / descendre | +| `Z S Q D` | Pitch / Yaw | +| `A E` | Roll | +| `Entrée` | Réinitialiser la position | +| `1` | Sélectionner la caméra | +| `2` | Sélectionner l'objet 1 | +| `Échap` | Quitter | diff --git a/SESSION.md b/SESSION.md new file mode 100644 index 0000000..1a7bf75 --- /dev/null +++ b/SESSION.md @@ -0,0 +1,33 @@ +# Messages de la session + +1. Donne moi un feedback sur ce repository ? +2. Ecrit moi l'ensemble de ce feedback dans un fichier a la racine FEEDBACK.md +3. engine/models/ ignoré par .gitignore mais requis au démarrage main.py:14 Crash au lancement occupe toi ca, ajoute les fichiers tmpclaude au gitignore +4. peux tu m'ajouter un debut de doc dans le README; juste une courte description du projet, comment installer avec pip et lancer +5. peux tu ajouter dans la doc la facon de creer un env virtuel python +6. est ce que les versions disponibles dans le requirements peuvent etre upgrade facilement en restant sur des versions stables +7. oui, est ce que c'est la meilleur facon de gerer les dependances en python en 2026 ? +8. (Erreur terminal) FileNotFoundError: engine/models/Theiere.obj introuvable au lancement +9. peux tu rendre ce point meilleur : Chemin relatif hardcodé 'engine/models/Theiere.obj' main.py:14 Fragile +10. Peux tu refactorer ces trois points : 16 méthodes de déplacement quasi-identiques / produit_scalaire() réimplémente NumPy / if __name__ == "__main__": absent dans main.py +11. que peux t on faire sur le point : Configuration éparpillée entre parameters.py et main.py ? +12. je veux l'option A oui merci +13. j'aimerai ajouter un script de telechargement de model +14. ajoute l'instruction au readme +15. je n'y connais rien, ou trouver generalement des models libre de droit pour fonctionner avec ce projet, ajoute des exemples au script download +16. (Erreur terminal) HTTP Error 406: Not Acceptable lors du téléchargement de teapot.obj +17. peux tu rendres le parser plus tolérant (gérer les faces sans slashes) +18. ajoute les models comme teapot au gitignore +19. peux tu reformater tout le code du projet ? +20. peux tu ajouter la methodologie au readme, usage de black avant de commit push +21. Peux tu me lister les commentaires et todo ? +22. peux tu resumer cela dans un fichier a la racine TODOs.md +23. Peux tu ajouter une doc dans chaque fichier python consise et chaque method/fonction importante +24. ajoute un niveau de log info et debug dans les parties principales afin de suivre le travail de la solution +25. peux tu m'ajouter les pipelines standards github, release please, ci classic +26. peux tu ajouter tout cela dans une section du readme et la Note importante sur les conventional commits +27. Peux tu m'ajouter des tests unitaires +28. est il possible de tester le main ou des parties du main non couverte par les autres tests ? +29. j'ai un pb sur le pipeline de test sur github (erreur apt-get 404 libglib2.0-0t64) +30. Peux tu me lister dans un fichier a la racine, notre echange dans cette session avec seulement mes messages +31. Peux tu remonter plus loin dans nos echanges, jusqu'au debut de notre chat et l'ajouter au fichier diff --git a/TODOs.md b/TODOs.md new file mode 100644 index 0000000..d7882db --- /dev/null +++ b/TODOs.md @@ -0,0 +1,30 @@ +# TODOs + +## Tâches ouvertes + +- `engine/main.py:70` — Migrer le système de fenêtre de pygame vers GLUT +- `cameras/images.py:17` — Écrire la fonction de traitement d'image pour les captures 1 et 2 + +## Code commenté à décider (garder ou supprimer) + +### Éclairage OpenGL (`engine/main.py` + `engine/opengl_3d_object.py`) +Tout le système d'éclairage fixe (`GL_LIGHT0`, `glMaterialfv`, `glNormal3fv`) est commenté. +Lignes concernées : `main.py:79-86`, `main.py:99-100`, `opengl_3d_object.py:7-10`, `:101-105`, `:116`, `:121-125`, `:136` + +### Face culling (`engine/main.py:93-95`) +`glFrontFace`, `glCullFace`, `glEnable(GL_CULL_FACE)` désactivés. + +### Prints de debug (`engine/main.py:136-138`) +Logs des vecteurs front/up/right et du FPS. + +### Cache OBJ désactivé (`engine/dot_obj_parser.py:102`) +`self.writeToCache()` commenté — le système de cache n'est pas encore plus rapide que le parsing direct. + +### Classe `VEC3` abandonnée (`engine/mathlib.py:77-88`) +Remplacée par les quaternions. Peut être supprimée. + +### Méthode `__invert__` (`engine/mathlib.py:57-58`) +Prototype commenté sur `QUATERNION`. + +### Conversion BGR→GRAY (`cameras/configuration_camera.py:14`) +`cv2.cvtColor` commenté dans la boucle de calibration. diff --git a/cameras/configuration_camera.py b/cameras/configuration_camera.py index f601aff..83a9bae 100644 --- a/cameras/configuration_camera.py +++ b/cameras/configuration_camera.py @@ -1,3 +1,10 @@ +"""Calibration intrinsèque d'une caméra par détection de chessboard. + +Capture des photos avec la touche configurée dans parameters.py, +détecte les coins du damier et calcule la matrice K via compute_intrinsics(). +Le résultat est sauvegardé avec write(). +""" + import cv2 import numpy as np from parameters import * @@ -11,17 +18,21 @@ while capture.isOpened(): ret, frame = capture.read() - #frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) + # frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) frame = cv2.flip(frame, 1) if cv2.waitKey(1) == ord(take_photo) and nb_photo != nb_photo_max: nb_photo += 1 - ret, corners = cv2.findChessboardCorners(frame, checkboard_info[0], - cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_FAST_CHECK + cv2.CALIB_CB_NORMALIZE_IMAGE) + ret, corners = cv2.findChessboardCorners( + frame, + checkboard_info[0], + cv2.CALIB_CB_ADAPTIVE_THRESH + + cv2.CALIB_CB_FAST_CHECK + + cv2.CALIB_CB_NORMALIZE_IMAGE, + ) lst_points.append(corners) for i in corners: cv2.circle(frame, (int(i[0][0]), int(i[0][1])), 5, (0, 255, 0), 2) - cv2.imshow('a', frame) - + cv2.imshow("a", frame) if cv2.waitKey(1) == ord(quitter): break @@ -32,4 +43,4 @@ K = compute_intrinsics(object_points_list, lst_points) -write(K) \ No newline at end of file +write(K) diff --git a/cameras/fonctions_images.py b/cameras/fonctions_images.py index 30e8e10..ed8bd97 100644 --- a/cameras/fonctions_images.py +++ b/cameras/fonctions_images.py @@ -1,8 +1,14 @@ +"""Fonctions de vision par ordinateur : clustering LED, homographies, calibration caméra.""" + from parameters import * import cv2 -def groupe_leds(point_list:list): - point_visite = [1 for i in range(len(point_list))] # 1 pour point non visite, 0 sinon + +def groupe_leds(point_list: list): + """Regroupe les points détectés en clusters de LEDs proches (distance < distance_max).""" + point_visite = [ + 1 for i in range(len(point_list)) + ] # 1 pour point non visite, 0 sinon liste_groupe = [] for i, point in enumerate(point_list): if point_visite[i]: @@ -13,16 +19,27 @@ def groupe_leds(point_list:list): return liste_groupe -def cluster_recur(index_point_depart:int, point_depart:tuple, cluster:list, point_visite:list, point_list:list): +def cluster_recur( + index_point_depart: int, + point_depart: tuple, + cluster: list, + point_visite: list, + point_list: list, +): + """Approfondit récursivement un cluster en ajoutant les voisins non visités.""" point_visite[index_point_depart] = 0 cluster.append(point_depart) for i, point in enumerate(point_list): if point_visite[i]: - distance_carre = (point_depart[0]-point[0])**2 + (point_depart[1]-point[1])**2 + distance_carre = (point_depart[0] - point[0]) ** 2 + ( + point_depart[1] - point[1] + ) ** 2 if distance_carre < distance_max_carre: cluster_recur(i, point, cluster, point_visite, point_list) + def blob_detection_params(): + """Crée et retourne un détecteur de blobs OpenCV configuré pour les LEDs blanches.""" params = cv2.SimpleBlobDetector_Params() params.minThreshold = minThreshold params.maxThreshold = maxThreshold @@ -34,51 +51,36 @@ def blob_detection_params(): detector = cv2.SimpleBlobDetector_create(params) return detector -def produit_scalaire(mat_1: np.array, mat_2: np.array) -> np.array: - if mat_1.shape[1] != mat_2.shape[0]: - raise ValueError("Dimensions incompatibles pour le produit matriciel") - - n, p = mat_1.shape - n2, p2 = mat_2.shape - - mat = np.zeros((n, p2)) - - for i in range(n): - for j in range(p2): - mat[i, j] = sum(mat_1[i, k] * mat_2[k, j] for k in range(p)) - - return mat def triangulate_parallel(p1, p2, K, B): """ triangulation dans le cas ou les caméras sont parallèles """ - + u1, v1 = p1 u2, v2 = p2 - + f, cx, cy = K - + disparity = u1 - u2 - + if disparity == 0: return None # profondeur infinie - + Z = f * B / disparity - + X = Z * (u1 - cx) / f Y = Z * (v1 - cy) / f - + return (X, Y, Z) + def rotation_matrix_x(angle): + """Retourne la matrice de rotation 3×3 autour de l'axe X pour un angle en radians.""" c = np.cos(angle) s = np.sin(angle) - return np.array([ - [1, 0, 0], - [0, c, -s], - [0, s, c] - ]) + return np.array([[1, 0, 0], [0, c, -s], [0, s, c]]) + def rectify_cameras(K1, R1, T1, K2, R2, T2): """ @@ -86,14 +88,14 @@ def rectify_cameras(K1, R1, T1, K2, R2, T2): permettant de rectifier les deux images. """ - C1 = produit_scalaire(-R1.T, T1) - C2 = produit_scalaire(-R2.T, T2) + C1 = np.dot(-R1.T, T1) + C2 = np.dot(-R2.T, T2) baseline = C2 - C1 x_new = baseline / np.linalg.norm(baseline) - z1 = produit_scalaire(R1.T, np.array([0, 0, 1])) - z2 = produit_scalaire(R2.T, np.array([0, 0, 1])) + z1 = np.dot(R1.T, np.array([0, 0, 1])) + z2 = np.dot(R2.T, np.array([0, 0, 1])) z_new = (z1 + z2) / 2 z_new = z_new / np.linalg.norm(z_new) @@ -104,12 +106,14 @@ def rectify_cameras(K1, R1, T1, K2, R2, T2): R_rect = np.vstack((x_new, y_new, z_new)).T - H1 = produit_scalaire(produit_scalaire(produit_scalaire(K1, R_rect), R1.T), np.linalg.inv(K1)) - H2 = produit_scalaire(produit_scalaire(produit_scalaire(K2, R_rect), R2.T), np.linalg.inv(K2)) + H1 = np.dot(np.dot(np.dot(K1, R_rect), R1.T), np.linalg.inv(K1)) + H2 = np.dot(np.dot(np.dot(K2, R_rect), R2.T), np.linalg.inv(K2)) return H1, H2 + def compute_homography(obj_pts, img_pts): + """Calcule l'homographie 3×3 entre points objet 2D et points image 2D par DLT + SVD.""" N = obj_pts.shape[0] A = [] @@ -117,8 +121,8 @@ def compute_homography(obj_pts, img_pts): X, Y = obj_pts[i] u, v = img_pts[i] - A.append([-X, -Y, -1, 0, 0, 0, u*X, u*Y, u]) - A.append([0, 0, 0, -X, -Y, -1, v*X, v*Y, v]) + A.append([-X, -Y, -1, 0, 0, 0, u * X, u * Y, u]) + A.append([0, 0, 0, -X, -Y, -1, v * X, v * Y, v]) A = np.array(A) @@ -126,18 +130,23 @@ def compute_homography(obj_pts, img_pts): U, S, Vt = np.linalg.svd(A) h = Vt[-1] - H = h.reshape(3,3) - return H / H[2,2] + H = h.reshape(3, 3) + return H / H[2, 2] + def build_v_ij(H, i, j): - return np.array([ - H[0,i]*H[0,j], - H[0,i]*H[1,j] + H[1,i]*H[0,j], - H[1,i]*H[1,j], - H[2,i]*H[0,j] + H[0,i]*H[2,j], - H[2,i]*H[1,j] + H[1,i]*H[2,j], - H[2,i]*H[2,j] - ]) + """Construit le vecteur v_ij de la méthode de Zhang pour la calibration (équation de contrainte).""" + return np.array( + [ + H[0, i] * H[0, j], + H[0, i] * H[1, j] + H[1, i] * H[0, j], + H[1, i] * H[1, j], + H[2, i] * H[0, j] + H[0, i] * H[2, j], + H[2, i] * H[1, j] + H[1, i] * H[2, j], + H[2, i] * H[2, j], + ] + ) + def compute_intrinsics(object_points_list, image_points_list): """ @@ -173,19 +182,15 @@ def compute_intrinsics(object_points_list, image_points_list): B11, B12, B22, B13, B23, B33 = b # 5. Extraction paramètres - v0 = (B12*B13 - B11*B23) / (B11*B22 - B12**2) + v0 = (B12 * B13 - B11 * B23) / (B11 * B22 - B12**2) - lambda_ = B33 - (B13**2 + v0*(B12*B13 - B11*B23)) / B11 + lambda_ = B33 - (B13**2 + v0 * (B12 * B13 - B11 * B23)) / B11 fx = np.sqrt(lambda_ / B11) - fy = np.sqrt(lambda_ * B11 / (B11*B22 - B12**2)) + fy = np.sqrt(lambda_ * B11 / (B11 * B22 - B12**2)) cx = -B13 / B11 cy = v0 - K = np.array([ - [fx, 0, cx], - [0, fy, cy], - [0, 0, 1] - ]) + K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]]) - return K \ No newline at end of file + return K diff --git a/cameras/images.py b/cameras/images.py index fc4f16a..8d9af61 100644 --- a/cameras/images.py +++ b/cameras/images.py @@ -1,10 +1,14 @@ +"""Capture vidéo en temps réel : détection de marqueurs LED par blob detection.""" + import cv2 import numpy as np import time from parameters import * from fonctions_images import * + def image_transform(image): + """Prépare une frame pour la détection : niveaux de gris → ouverture morphologique → seuillage → miroir.""" image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) image = cv2.morphologyEx(image, cv2.MORPH_OPEN, kernel) _, image = cv2.threshold(image, 250, 255, cv2.THRESH_BINARY) @@ -15,18 +19,18 @@ def image_transform(image): # Faire fonction traitement image pour capture 1 et 2. capture = cv2.VideoCapture(0) -'''capture_1 = cv2.VideoCapture(1)''' +"""capture_1 = cv2.VideoCapture(1)""" -x=0 +x = 0 timer = 0 detector = blob_detection_params() list_point = [] while capture.isOpened(): start_time = time.perf_counter() - x+=1 + x += 1 ret, frame = capture.read() - + frame = image_transform(frame) # Detection led @@ -34,19 +38,24 @@ def image_transform(image): if keypoints != (): list_point.append((keypoints[0].pt[0], keypoints[0].pt[1])) end_time = time.perf_counter() - timer += 1/(end_time-start_time) - print((timer)/x) - output = cv2.drawKeypoints(frame, keypoints, np.array([]), (0, 0, 255),cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS) + timer += 1 / (end_time - start_time) + print((timer) / x) + output = cv2.drawKeypoints( + frame, + keypoints, + np.array([]), + (0, 0, 255), + cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS, + ) if ret: # Affiche l'image cv2.imshow("test", output) - # Quitter la video if cv2.waitKey(1) == ord(quitter): break - -capture.release() \ No newline at end of file + +capture.release() diff --git a/cameras/parameters.py b/cameras/parameters.py index 27df8b0..40c3c23 100644 --- a/cameras/parameters.py +++ b/cameras/parameters.py @@ -1,24 +1,24 @@ -import numpy as np - +"""Paramètres partagés entre les scripts de la caméra (images.py, configuration_camera.py).""" +import numpy as np -''' +""" pour utilisateur -''' +""" -''' +""" fin pour utilisateur -''' +""" # Configuration touche clavier -quitter = 'q' +quitter = "q" # Mode configuration -take_photo = 'p' +take_photo = "p" -''' +""" images.py -''' +""" # Blob detection params minThreshold = 10 @@ -33,23 +33,24 @@ distance_max_carre = distance_max**2 # Analyse image -kernel = np.ones((3,3), np.uint8) +kernel = np.ones((3, 3), np.uint8) -''' +""" fin images.py -''' - +""" -''' +""" configuration_camera.py -''' +""" # dimension chessboard (en carres), dimension carre (en mm) checkboard_info = [(6, 8), 22] nb_photo_max = 15 -object_points_list = [[i, j] for i in range(checkboard_info[0][0]) for j in range(checkboard_info[0][1])] +object_points_list = [ + [i, j] for i in range(checkboard_info[0][0]) for j in range(checkboard_info[0][1]) +] -''' +""" fin configuration_camera.py -''' \ No newline at end of file +""" diff --git a/cameras/write_read_csv.py b/cameras/write_read_csv.py index 955a5b5..3dd17f1 100644 --- a/cameras/write_read_csv.py +++ b/cameras/write_read_csv.py @@ -1,18 +1,25 @@ +"""Persistance de matrices NumPy dans un fichier CSV (sauvegarde_matrice.csv).""" + import numpy as np import ast K = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + def write(matrice): - with open('cameras/sauvegarde_matrice.csv', 'w') as f: + """Sauvegarde une matrice NumPy sous forme de liste Python dans le CSV.""" + with open("cameras/sauvegarde_matrice.csv", "w") as f: f.write(str(matrice.tolist())) + def read(line_number): - with open('cameras/sauvegarde_matrice.csv', 'r') as f: + """Lit et retourne la matrice à la ligne donnée du CSV, ou None si absente.""" + with open("cameras/sauvegarde_matrice.csv", "r") as f: for i, line in enumerate(f): if i == line_number: return np.array(ast.literal_eval(line.strip())) return None # si la ligne n'existe pas + write(K) -print(read(0)) \ No newline at end of file +print(read(0)) diff --git a/download_models.py b/download_models.py new file mode 100644 index 0000000..81b6036 --- /dev/null +++ b/download_models.py @@ -0,0 +1,74 @@ +""" +Télécharge les modèles 3D dans engine/models/. +Usage : python download_models.py + +--- +Où trouver des modèles gratuits compatibles (.obj) : + + - https://sketchfab.com (filtrer "downloadable", exporter en OBJ) + - https://free3d.com (format OBJ directement disponible) + - https://www.turbosquid.com (filtrer "free", télécharger en OBJ) + - https://polyhaven.com/models (modèles CC0, haute qualité) + - https://clara.io (export OBJ disponible) + +Compatibilité avec le parser de ce projet : + ✓ f v/vt/vn (vertex / texture / normale) → format Blender par défaut + ✓ f v//vn (vertex / normale, sans UV) + ✗ f v1 v2 v3 (faces simples sans slashes) → crash au chargement + + Si un modèle n'est pas compatible, l'ouvrir dans Blender et + l'exporter via File > Export > Wavefront (.obj) en cochant + "Include Normals" et "Include UVs". +--- +""" + +import logging +import urllib.request +from pathlib import Path + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) + +MODELS_DIR = Path(__file__).parent / "engine" / "models" + +# Modèles à télécharger : { 'nom_local.obj': 'https://url-directe.obj' } +MODELS = { + # Utah Teapot — modèle de test classique en informatique graphique + # Source : alecjacobson/common-3d-test-models (MIT) + "teapot.obj": "https://raw.githubusercontent.com/alecjacobson/common-3d-test-models/master/data/teapot.obj", + # Ajoute tes propres modèles ici : + # 'monModele.obj': 'https://example.com/monModele.obj', +} + +_HEADERS = {"User-Agent": "Mozilla/5.0"} + + +def download(filename: str, url: str) -> None: + """Télécharge filename depuis url dans MODELS_DIR. Sans effet si le fichier existe déjà.""" + dest = MODELS_DIR / filename + if dest.exists(): + logger.info("%s déjà présent, ignoré.", filename) + return + + logger.info("Téléchargement de %s…", filename) + try: + req = urllib.request.Request(url, headers=_HEADERS) + with urllib.request.urlopen(req) as response: + dest.write_bytes(response.read()) + logger.info("%s téléchargé.", filename) + except Exception as e: + logger.error("Erreur pour %s : %s", filename, e) + + +if __name__ == "__main__": + MODELS_DIR.mkdir(parents=True, exist_ok=True) + + if not MODELS: + logger.warning("Aucun modèle défini dans MODELS.") + else: + for filename, url in MODELS.items(): + download(filename, url) + logger.info("Terminé.") diff --git a/engine/config.py b/engine/config.py new file mode 100644 index 0000000..d79391b --- /dev/null +++ b/engine/config.py @@ -0,0 +1,22 @@ +"""Configuration centralisée du moteur : fenêtre, scène, projection, caméra et objets.""" + +# Fenêtre +DISPLAY = (960, 540) +FPS_TARGET = 144 + +# Scène +MODEL_FILE = "bisous.obj" +DEBUG_AXES = True +BACKGROUND = (0, 100 / 255, 0, 1) + +# Projection +FOV = 45 +CLIP_NEAR = 1 +CLIP_FAR = 500 + +# Caméra +CAMERA_SPEED = 0.05 + +# Déplacement objets +OBJECT_MOVE_STEP = 0.05 +OBJECT_ROTATE_STEP = 1 diff --git a/engine/dot_obj_parser.py b/engine/dot_obj_parser.py index 1e5c1f5..bc70e36 100644 --- a/engine/dot_obj_parser.py +++ b/engine/dot_obj_parser.py @@ -1,9 +1,22 @@ +"""Parser de fichiers Wavefront OBJ avec système de cache optionnel.""" + +import logging + +logger = logging.getLogger(__name__) + + class OBJ_FILE: + """Charge et parse un fichier .obj en listes de sommets, normales, textures et faces. + + Les faces sont séparées en triangles (3 sommets) et quads (4 sommets). + Chaque liste de faces est de la forme [vertices, textures, normales]. + """ + def __init__(self, file_path: str) -> None: - self.cache= {} + self.cache = {} self.filePath = file_path self.file = None - self.fileName = self.filePath.split('/')[-1].rstrip('.obj') + self.fileName = self.filePath.split("/")[-1].rstrip(".obj") self.cacheFile = None self.vertices = [] self.normals = [] @@ -15,81 +28,125 @@ def __init__(self, file_path: str) -> None: self.trianglesNormals = [] self.trianglesTextures = [] self.trianglesVertices = [] - self.triangles = [self.trianglesVertices,self.trianglesTextures,self.trianglesNormals] + self.triangles = [ + self.trianglesVertices, + self.trianglesTextures, + self.trianglesNormals, + ] def parse(self, force_parse: bool = False) -> None: + """Parse le fichier OBJ. Utilise le cache si disponible, sauf si force_parse=True.""" if force_parse: + logger.info("Parsing forcé de '%s'.", self.fileName) self.parseFile() else: from engine.models.models_cache import cache - self.cache=cache - if not (f'{self.fileName}_Vertices' in self.cache.keys()): + + self.cache = cache + if not (f"{self.fileName}_Vertices" in self.cache.keys()): + logger.info( + "Cache absent pour '%s', lecture du fichier.", self.fileName + ) self.parseFile() else: + logger.info( + "Cache trouvé pour '%s', chargement depuis le cache.", self.fileName + ) self.parseCache() def parseCache(self) -> None: - self.vertices = self.cache[f'{self.fileName}_Vertices'] - self.normals = self.cache[f'{self.fileName}_Normals'] - self.textures = self.cache[f'{self.fileName}_Textures'] - self.triangles = self.cache[f'{self.fileName}_Triangles'] - self.quads = self.cache[f'{self.fileName}_Quads'] + """Charge les données depuis le cache en mémoire (models_cache.py).""" + self.vertices = self.cache[f"{self.fileName}_Vertices"] + self.normals = self.cache[f"{self.fileName}_Normals"] + self.textures = self.cache[f"{self.fileName}_Textures"] + self.triangles = self.cache[f"{self.fileName}_Triangles"] + self.quads = self.cache[f"{self.fileName}_Quads"] + logger.debug( + "Cache chargé : %d sommets, %d triangles, %d quads.", + len(self.vertices), + len(self.triangles[0]), + len(self.quads[0]), + ) + + def _parse_face_token(self, token: str) -> tuple: + """ + Parse un token de face OBJ. Formats supportés : + v → (v, None, None) + v/vt → (v, vt, None) + v//vn → (v, None, vn) + v/vt/vn → (v, vt, vn) + """ + parts = token.split("/") + v = int(parts[0]) if len(parts) > 0 and parts[0] != "" else None + vt = int(parts[1]) if len(parts) > 1 and parts[1] != "" else None + vn = int(parts[2]) if len(parts) > 2 and parts[2] != "" else None + return v, vt, vn def parseFile(self) -> None: + """Lit et parse le fichier .obj ligne par ligne (v, vn, vt, f).""" + logger.debug("Ouverture du fichier : %s", self.filePath) self.file = open(self.filePath, "r") for line in self.file.readlines(): line = line.split() if not line == []: match line[0]: - case 'v': - vertexCords=[] + case "v": + vertexCords = [] for word in line[1:]: vertexCords.append(float(word)) self.vertices.append(vertexCords) - case 'vn': + case "vn": normalCords = [] for word in line[1:]: normalCords.append(float(word)) self.normals.append(normalCords) - case 'vt': + case "vt": textureCords = [] for word in line[1:]: textureCords.append(float(word)) self.textures.append(textureCords) - case 'f': + case "f": faceVertices = [] faceTextures = [] faceNormals = [] match len(line[1:]): case 3: for word in line[1:]: - infos = word.split('/') - faceVertices.append(int(infos[0])) if not infos[0] == '' else faceVertices.append(None) - faceTextures.append(int(infos[1])) if not infos[1] == '' else faceTextures.append(None) - faceNormals.append(int(infos[2])) if not infos[2] == '' else faceNormals.append(None) + v, vt, vn = self._parse_face_token(word) + faceVertices.append(v) + faceTextures.append(vt) + faceNormals.append(vn) self.trianglesVertices.append(faceVertices) self.trianglesTextures.append(faceTextures) self.trianglesNormals.append(faceNormals) case 4: for word in line[1:]: - infos = word.split('/') - faceVertices.append(int(infos[0])) if not infos[0]=='' else faceVertices.append(None) - faceTextures.append(int(infos[1])) if not infos[1]=='' else faceTextures.append(None) - faceNormals.append(int(infos[2])) if not infos[2]=='' else faceNormals.append(None) + v, vt, vn = self._parse_face_token(word) + faceVertices.append(v) + faceTextures.append(vt) + faceNormals.append(vn) self.quadsVertices.append(faceVertices) self.quadsTextures.append(faceTextures) self.quadsNormals.append(faceNormals) self.file.close() - #self.writeToCache() + logger.info( + "Fichier parsé : %d sommets, %d normales, %d triangles, %d quads.", + len(self.vertices), + len(self.normals), + len(self.trianglesVertices), + len(self.quadsVertices), + ) + # self.writeToCache() def writeToCache(self) -> None: - print('WriteToCache') - self.cache[f'{self.fileName}_Vertices'] = self.vertices - self.cache[f'{self.fileName}_Normals'] = self.normals - self.cache[f'{self.fileName}_Textures'] = self.textures - self.cache[f'{self.fileName}_Triangles'] = self.triangles - self.cache[f'{self.fileName}_Quads'] = self.quads + """Sauvegarde les données parsées dans models_cache.py (actuellement désactivé).""" + logger.debug("Écriture du cache pour '%s'.", self.fileName) + self.cache[f"{self.fileName}_Vertices"] = self.vertices + self.cache[f"{self.fileName}_Normals"] = self.normals + self.cache[f"{self.fileName}_Textures"] = self.textures + self.cache[f"{self.fileName}_Triangles"] = self.triangles + self.cache[f"{self.fileName}_Quads"] = self.quads - self.cacheFile = open('engine/models/models_cache.py', 'w') - self.cacheFile.write(f'cache = {self.cache}') + self.cacheFile = open("engine/models/models_cache.py", "w") + self.cacheFile.write(f"cache = {self.cache}") self.cacheFile.close() diff --git a/engine/main.py b/engine/main.py index 4e95a2b..6d08c8d 100644 --- a/engine/main.py +++ b/engine/main.py @@ -1,36 +1,84 @@ +"""Point d'entrée du moteur 3D Krafton : initialisation OpenGL/pygame et boucle principale.""" + +import logging import pygame +from pathlib import Path from OpenGL.GLU import * from engine.opengl_3d_object import * from engine.mathlib import * from engine.dot_obj_parser import * +from engine import config + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", +) +logger = logging.getLogger(__name__) +MODELS_DIR = Path(__file__).parent / "models" def main(): + """Initialise la scène, la fenêtre OpenGL et lance la boucle d'événements.""" - all_objects=[] - camera=CAMERA() + all_objects = [] + camera = CAMERA(speed=config.CAMERA_SPEED) + logger.info("Caméra initialisée (speed=%.3f)", config.CAMERA_SPEED) - my_object_file = OBJ_FILE('engine/models/caca.obj') + model_path = MODELS_DIR / config.MODEL_FILE + logger.info("Chargement du modèle : %s", model_path) + my_object_file = OBJ_FILE(str(model_path)) try: - my_object_file.parse(force_parse=True)# Cache system not faster yet + my_object_file.parse(force_parse=True) # Cache system not faster yet except FileNotFoundError: - pass + logger.warning("Modèle introuvable : %s", model_path) else: - my_object=FACES(to_be_drew=True,vertices=my_object_file.vertices,quads=my_object_file.quads,triangles=my_object_file.triangles,normals=my_object_file.normals,coordinates=[0,0,0]) + logger.info( + "Modèle chargé — %d sommets, %d triangles, %d quads", + len(my_object_file.vertices), + len(my_object_file.triangles[0]), + len(my_object_file.quads[0]), + ) + my_object = FACES( + to_be_drew=True, + vertices=my_object_file.vertices, + quads=my_object_file.quads, + triangles=my_object_file.triangles, + normals=my_object_file.normals, + coordinates=[0, 0, 0], + ) all_objects.append(my_object) finally: - del my_object_file # Release some memory + del my_object_file # Release some memory - debug_axes=True + debug_axes = config.DEBUG_AXES if debug_axes: - axe_x=AXES(to_be_drew=True,vertices=[[0, 0, 0], [1, 0, 0]], color=[1, 0, 0]) - axe_y=AXES(to_be_drew=True,vertices=[[0, 0, 0], [0, 1, 0]], color=[0, 1, 0]) - axe_z=AXES(to_be_drew=True,vertices=[[0, 0, 0], [0, 0, 1]], color=[0, 0, 1]) + logger.debug("Axes de debug activés") + axe_x = AXES(to_be_drew=True, vertices=[[0, 0, 0], [1, 0, 0]], color=[1, 0, 0]) + axe_y = AXES(to_be_drew=True, vertices=[[0, 0, 0], [0, 1, 0]], color=[0, 1, 0]) + axe_z = AXES(to_be_drew=True, vertices=[[0, 0, 0], [0, 0, 1]], color=[0, 0, 1]) - rotation_axe_x=ROTATION_AXES(to_be_drew=True,vertices=[(0.0, cos(radians(i)), sin(radians(i))) for i in range(0, 360, 1)], color=[1, 0, 0]) - rotation_axe_y=ROTATION_AXES(to_be_drew=True,vertices=[(cos(radians(i)), 0.0, sin(radians(i))) for i in range(0, 360, 1)], color=[0, 1, 0]) - rotation_axe_z=ROTATION_AXES(to_be_drew=True,vertices=[(cos(radians(i)), sin(radians(i)), 0.0) for i in range(0, 360, 1)], color=[0, 0, 1]) + rotation_axe_x = ROTATION_AXES( + to_be_drew=True, + vertices=[ + (0.0, cos(radians(i)), sin(radians(i))) for i in range(0, 360, 1) + ], + color=[1, 0, 0], + ) + rotation_axe_y = ROTATION_AXES( + to_be_drew=True, + vertices=[ + (cos(radians(i)), 0.0, sin(radians(i))) for i in range(0, 360, 1) + ], + color=[0, 1, 0], + ) + rotation_axe_z = ROTATION_AXES( + to_be_drew=True, + vertices=[ + (cos(radians(i)), sin(radians(i)), 0.0) for i in range(0, 360, 1) + ], + color=[0, 0, 1], + ) all_objects.append(rotation_axe_x) all_objects.append(rotation_axe_y) all_objects.append(rotation_axe_z) @@ -38,88 +86,99 @@ def main(): all_objects.append(axe_y) all_objects.append(axe_z) - pygame.init() # todo: changer le système de fenêtre par celui de opengl GLUT - display = [1920//2,1080//2] - #display = [1920,1080] - pygame.display.set_mode(display, pygame.DOUBLEBUF|pygame.OPENGL) - glViewport(0,0,display[0],display[1]) + display = config.DISPLAY + logger.info("Fenêtre pygame créée (%dx%d)", display[0], display[1]) + pygame.display.set_mode(display, pygame.DOUBLEBUF | pygame.OPENGL) + glViewport(0, 0, display[0], display[1]) glMatrixMode(GL_PROJECTION) - gluPerspective(45,display[0]/display[1], 1, 500) - + gluPerspective( + config.FOV, display[0] / display[1], config.CLIP_NEAR, config.CLIP_FAR + ) - #light_ambient = [1.0, 1.0, 1.0, 1.0] - #light_diffuse = [1.0, 1.0, 1.0, 1.0] - #light_specular = [1.0, 1.0, 1.0, 1.0] - #light_position = [100.0, 2.0, 1.0, 1.0] - #glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient) - #glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse) - #glLightfv(GL_LIGHT0, GL_SPECULAR, light_specular) - #glLightfv(GL_LIGHT0, GL_POSITION, light_position) + # light_ambient = [1.0, 1.0, 1.0, 1.0] + # light_diffuse = [1.0, 1.0, 1.0, 1.0] + # light_specular = [1.0, 1.0, 1.0, 1.0] + # light_position = [100.0, 2.0, 1.0, 1.0] + # glLightfv(GL_LIGHT0, GL_AMBIENT, light_ambient) + # glLightfv(GL_LIGHT0, GL_DIFFUSE, light_diffuse) + # glLightfv(GL_LIGHT0, GL_SPECULAR, light_specular) + # glLightfv(GL_LIGHT0, GL_POSITION, light_position) - - #______________Objects to be compiled_______ + # ______________Objects to be compiled_______ + logger.info("Compilation de %d objet(s) en display list…", len(all_objects)) for object_to_be_compiled in all_objects: object_to_be_compiled.compile() - #___________________________________________ - + logger.info("Compilation terminée.") + # ___________________________________________ - #glFrontFace(GL_CW) - #glCullFace(GL_BACK) - #glEnable(GL_CULL_FACE) + # glFrontFace(GL_CW) + # glCullFace(GL_BACK) + # glEnable(GL_CULL_FACE) glEnable(GL_DEPTH_TEST) - glClearColor(0,100/255,0,1) + glClearColor(*config.BACKGROUND) - #glEnable(GL_LIGHTING) - #glEnable(GL_LIGHT0) + # glEnable(GL_LIGHTING) + # glEnable(GL_LIGHT0) - clock=pygame.time.Clock() + clock = pygame.time.Clock() lastFps = 0 selected = camera run = True something_changed = True + logger.info("Boucle principale démarrée (FPS cible : %d)", config.FPS_TARGET) -#_______________________________________________________Main Loop_______________________________________________________ + # _______________________________________________________Main Loop_______________________________________________________ while run: - clock.tick(144) + clock.tick(config.FPS_TARGET) if something_changed: glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT) glMatrixMode(GL_MODELVIEW) glLoadIdentity() gluLookAt( - camera.coordinates[0], camera.coordinates[1], camera.coordinates[2], - camera.coordinates[0]+camera.front.x, camera.coordinates[1]+camera.front.y, camera.coordinates[2]+camera.front.z, - camera.up.x, camera.up.y, camera.up.z + camera.coordinates[0], + camera.coordinates[1], + camera.coordinates[2], + camera.coordinates[0] + camera.front.x, + camera.coordinates[1] + camera.front.y, + camera.coordinates[2] + camera.front.z, + camera.up.x, + camera.up.y, + camera.up.z, ) - - #______________Objects to be drew___________ + # ______________Objects to be drew___________ for object_to_be_drew in all_objects: if object_to_be_drew.to_be_drew: object_to_be_drew.draw() - #____________________________________________ + # ____________________________________________ pygame.display.flip() - something_changed=False + something_changed = False - #print(camera.front.list, camera.up.list, camera.right.list) - #print(camera.front.getLength(), camera.right.getLength(), camera.up.getLength()) - #print(clock.get_fps()) + # print(camera.front.list, camera.up.list, camera.right.list) + # print(camera.front.getLength(), camera.right.getLength(), camera.up.getLength()) + # print(clock.get_fps()) for event in pygame.event.get(): if event.type == pygame.KEYDOWN: if event.key == pygame.K_1: selected = camera + logger.debug("Sélection : caméra") if event.key == pygame.K_2: selected = all_objects[0] + logger.debug("Sélection : objet 0") if event.key == pygame.K_3: selected = all_objects[1] + logger.debug("Sélection : objet 1") if event.type == pygame.QUIT: + logger.info("Événement QUIT reçu, arrêt.") run = False pygame.quit() quit() if pygame.key.get_pressed()[pygame.K_ESCAPE]: + logger.info("ESCAPE pressé, arrêt.") run = False pygame.quit() quit() @@ -127,83 +186,84 @@ def main(): if isinstance(selected, CAMERA): selected.forward3D() else: - selected.addCoordinates([0, 0, -0.05]) + selected.addCoordinates([0, 0, -config.OBJECT_MOVE_STEP]) something_changed = True if pygame.key.get_pressed()[pygame.K_DOWN]: if isinstance(selected, CAMERA): selected.backward3D() else: - selected.addCoordinates([0, 0, 0.05]) + selected.addCoordinates([0, 0, config.OBJECT_MOVE_STEP]) something_changed = True if pygame.key.get_pressed()[pygame.K_LEFT]: if isinstance(selected, CAMERA): selected.left3D() else: - selected.addCoordinates([-0.05, 0, 0]) + selected.addCoordinates([-config.OBJECT_MOVE_STEP, 0, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_RIGHT]: if isinstance(selected, CAMERA): selected.right3D() else: - selected.addCoordinates([0.05, 0, 0]) + selected.addCoordinates([config.OBJECT_MOVE_STEP, 0, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_c]: if isinstance(selected, CAMERA): selected.down3D() else: - selected.addCoordinates([0, -0.05, 0]) + selected.addCoordinates([0, -config.OBJECT_MOVE_STEP, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_SPACE]: if isinstance(selected, CAMERA): selected.up3D() else: - selected.addCoordinates([0, 0.05, 0]) + selected.addCoordinates([0, config.OBJECT_MOVE_STEP, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_z]: if isinstance(selected, CAMERA): - selected.addPitch(1) + selected.addPitch(config.OBJECT_ROTATE_STEP) else: - selected.addRotation([-1, 0, 0]) + selected.addRotation([-config.OBJECT_ROTATE_STEP, 0, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_s]: if isinstance(selected, CAMERA): - selected.addPitch(-1) + selected.addPitch(-config.OBJECT_ROTATE_STEP) else: - selected.addRotation([1, 0, 0]) + selected.addRotation([config.OBJECT_ROTATE_STEP, 0, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_q]: if isinstance(selected, CAMERA): - selected.addYaw(1) + selected.addYaw(config.OBJECT_ROTATE_STEP) else: - selected.addRotation([0, -1, 0]) + selected.addRotation([0, -config.OBJECT_ROTATE_STEP, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_d]: if isinstance(selected, CAMERA): - selected.addYaw(-1) + selected.addYaw(-config.OBJECT_ROTATE_STEP) else: - selected.addRotation([0, 1, 0]) + selected.addRotation([0, config.OBJECT_ROTATE_STEP, 0]) something_changed = True if pygame.key.get_pressed()[pygame.K_a]: if isinstance(selected, CAMERA): - selected.addRoll(-1) + selected.addRoll(-config.OBJECT_ROTATE_STEP) else: - selected.addRotation([0, 0, 1]) + selected.addRotation([0, 0, config.OBJECT_ROTATE_STEP]) something_changed = True if pygame.key.get_pressed()[pygame.K_e]: if isinstance(selected, CAMERA): - selected.addRoll(1) + selected.addRoll(config.OBJECT_ROTATE_STEP) else: - selected.addRotation([0, 0, -1]) + selected.addRotation([0, 0, -config.OBJECT_ROTATE_STEP]) something_changed = True if pygame.key.get_pressed()[pygame.K_RETURN]: if isinstance(selected, CAMERA): selected.reset() else: - selected.rotation=[0,0,0] - selected.coordinates=[0,0,0] + selected.rotation = [0, 0, 0] + selected.coordinates = [0, 0, 0] something_changed = True -#_______________________________________________________________________________________________________________________ -main() +# _______________________________________________________________________________________________________________________ +if __name__ == "__main__": + main() diff --git a/engine/mathlib.py b/engine/mathlib.py index b4d7ed2..e680453 100644 --- a/engine/mathlib.py +++ b/engine/mathlib.py @@ -1,35 +1,78 @@ +"""Bibliothèque mathématique : quaternions et opérations vectorielles 3D.""" + from math import pi, sqrt + class QUATERNION: - def __init__(self, w: float = 0.0, x: float = 0.0, y: float = 0.0, z: float = 0.0) -> None: - self.w = w # partie réel égale cos(angle/2)+sin(angle/2) pour les rotation et 0 pour un vecteur 3d - self.x = x # co du vecteur 3d autour duquel je tourne - self.y = y # co du vecteur 3d autour duquel je tourne - self.z = z # co du vecteur 3d autour duquel je tourne - self.list=[w,x,y,z] - - def __mul__(self, other): # jsp ce que je vais en faire,.... mtn je sais + """Quaternion utilisé comme vecteur 3D (w=0) ou comme rotation (w=cos(θ/2)). + + Attributs: + w: partie scalaire (réelle) + x, y, z: partie vectorielle + list: [w, x, y, z] pour accès par index + """ + + def __init__( + self, w: float = 0.0, x: float = 0.0, y: float = 0.0, z: float = 0.0 + ) -> None: + self.w = w # partie réel égale cos(angle/2)+sin(angle/2) pour les rotation et 0 pour un vecteur 3d + self.x = x # co du vecteur 3d autour duquel je tourne + self.y = y # co du vecteur 3d autour duquel je tourne + self.z = z # co du vecteur 3d autour duquel je tourne + self.list = [w, x, y, z] + + def __mul__(self, other): # jsp ce que je vais en faire,.... mtn je sais + """Multiplication de Hamilton : q1 * q2. Utilisée pour composer des rotations.""" # il y a des méthodes qui permettent de faire supporter des operand a un object en l'occurance le * # python essaye d'abord la méthode de l'object de gauche spuis ensuite la méthode de droite # object1 * object2 --> object1.__mul__(object2) y'a aussi __rmul__ mais j'ai pas trop compris encore assert isinstance(other, QUATERNION) return QUATERNION( - w=(self.w*other.w - self.x*other.x - self.y*other.y - self.z*other.z), - x=(self.w*other.x + self.x*other.w + self.y*other.z - self.z*other.y), - y=(self.w*other.y + self.y*other.w + self.z*other.x - self.x*other.z), - z=(self.w*other.z + self.z*other.w + self.x*other.y - self.y*other.x) + w=( + self.w * other.w + - self.x * other.x + - self.y * other.y + - self.z * other.z + ), + x=( + self.w * other.x + + self.x * other.w + + self.y * other.z + - self.z * other.y + ), + y=( + self.w * other.y + + self.y * other.w + + self.z * other.x + - self.x * other.z + ), + z=( + self.w * other.z + + self.z * other.w + + self.x * other.y + - self.y * other.x + ), ) - def __getitem__(self, index: int) -> float: #pour les object[i] + + def __getitem__(self, index: int) -> float: # pour les object[i] + """Accès par index : quaternion[0]=w, [1]=x, [2]=y, [3]=z.""" return self.list[index] def inverse(self): - return QUATERNION(w=self.w / self.getLengthNoSqrt(), x=-self.x / self.getLengthNoSqrt(), y=-self.y / self.getLengthNoSqrt(), z=-self.z / self.getLengthNoSqrt()) + """Retourne le quaternion conjugué normalisé (inverse pour une rotation unitaire).""" + return QUATERNION( + w=self.w / self.getLengthNoSqrt(), + x=-self.x / self.getLengthNoSqrt(), + y=-self.y / self.getLengthNoSqrt(), + z=-self.z / self.getLengthNoSqrt(), + ) - #def __invert__(self): # hihihi je m'amuse ducoup + # def __invert__(self): # hihihi je m'amuse ducoup # return QUATERNION(w=self.w, x= -self.x, y= -self.y, z= -self.z) def getLengthNoSqrt(self) -> float: - return self.w ** 2 + self.x ** 2 + self.y ** 2 + self.z ** 2 + """Retourne la norme au carré (w²+x²+y²+z²) sans racine carrée.""" + return self.w**2 + self.x**2 + self.y**2 + self.z**2 # Yvan Monka like ptn j'adore ce type c'est un dieu : @@ -38,50 +81,70 @@ def getLengthNoSqrt(self) -> float: def radians(degrees: float) -> float: - return degrees*pi/180 + """Convertit des degrés en radians.""" + return degrees * pi / 180 + def degrees(radians: float) -> float: - return radians*180/pi + """Convertit des radians en degrés.""" + return radians * 180 / pi -#class VEC3: # ça dégage enft on va juste utiliser des lists + +# class VEC3: # ça dégage enft on va juste utiliser des lists # def __init__(self, x=0.0, y=0.0, z=0.0): # self.x = x # self.y = y # self.z = z # self.list=[x,y,z] - #def multiply(self, other): - # assert type(other) == VEC3 - # return VEC3(x=) - # Enft Hamilton à pas trouver donc c pas possible on doit forcement passer aux quaternions - # Mais je viens de voire enft les vecteur 3d c'est juste des quaternions avec un parti real nul pour le w ou la partie scalaire donc ..... +# def multiply(self, other): +# assert type(other) == VEC3 +# return VEC3(x=) +# Enft Hamilton à pas trouver donc c pas possible on doit forcement passer aux quaternions +# Mais je viens de voire enft les vecteur 3d c'est juste des quaternions avec un parti real nul pour le w ou la partie scalaire donc ..... + def dotProduct(a: list, b: list) -> float: + """Produit scalaire de deux vecteurs représentés par des listes.""" assert len(a) == len(b) dot_product = 0.0 for i in range(len(a)): dot_product += a[i] * b[i] return dot_product -def crossProduct(a: QUATERNION, b: QUATERNION) -> QUATERNION: # Le cross product de deux vecteur donne un vecteur perpendiculaire aux deux autres, c'est l'équivalent d'une multiplication - assert isinstance(a, QUATERNION) # faut faire comme ça selon les conventions PEP et pas type(a)==object + +def crossProduct( + a: QUATERNION, b: QUATERNION +) -> ( + QUATERNION +): # Le cross product de deux vecteur donne un vecteur perpendiculaire aux deux autres, c'est l'équivalent d'une multiplication + """Produit vectoriel a × b. Retourne un quaternion perpendiculaire aux deux vecteurs (w=0).""" + assert isinstance( + a, QUATERNION + ) # faut faire comme ça selon les conventions PEP et pas type(a)==object assert isinstance(b, QUATERNION) - return QUATERNION(w=0, - x = (a.y*b.z - a.z*b.y), - y = (a.z*b.x - a.x*b.z), - z = (a.x*b.y - a.y*b.x) + return QUATERNION( + w=0, + x=(a.y * b.z - a.z * b.y), + y=(a.z * b.x - a.x * b.z), + z=(a.x * b.y - a.y * b.x), ) + def normalize(vector: QUATERNION) -> QUATERNION: + """Normalise un vecteur quaternion (w=0) pour qu'il soit unitaire.""" assert isinstance(vector, QUATERNION) length = sqrt(vector.getLengthNoSqrt()) - return QUATERNION(w=0, - x = vector.x / length, - y = vector.y / length, - z = vector.z / length + return QUATERNION( + w=0, x=vector.x / length, y=vector.y / length, z=vector.z / length ) -def crossProductNormalized(a: QUATERNION, b: QUATERNION) -> QUATERNION: # https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process#/media/File:Gram-Schmidt_orthonormalization_process.gif + +def crossProductNormalized( + a: QUATERNION, b: QUATERNION +) -> ( + QUATERNION +): # https://en.wikipedia.org/wiki/Gram%E2%80%93Schmidt_process#/media/File:Gram-Schmidt_orthonormalization_process.gif + """Produit vectoriel normalisé (processus de Gram-Schmidt). Utilisé pour maintenir l'orthonormalité des axes de la caméra.""" assert isinstance(a, QUATERNION) assert isinstance(b, QUATERNION) return normalize(crossProduct(a, b)) - diff --git a/engine/models/.gitkeep b/engine/models/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/engine/opengl_3d_object.py b/engine/opengl_3d_object.py index 8bb5453..d46c2ed 100644 --- a/engine/opengl_3d_object.py +++ b/engine/opengl_3d_object.py @@ -1,56 +1,105 @@ +"""Objets OpenGL 3D : géométrie compilée en display lists et caméra à quaternions.""" + +import logging from OpenGL.GL import * from random import randint +logger = logging.getLogger(__name__) + from engine.mathlib import crossProductNormalized, QUATERNION, radians, normalize from math import cos, sin -#no_mat = [0.0, 0.0, 0.0, 1.0] -#mat_ambient = [0.0, 0.0, 0.3, 1.0] -#mat_diffuse = [1.0, 0.0, 0.0, 1.0] -#no_shininess = [0.0] - +# no_mat = [0.0, 0.0, 0.0, 1.0] +# mat_ambient = [0.0, 0.0, 0.3, 1.0] +# mat_diffuse = [1.0, 0.0, 0.0, 1.0] +# no_shininess = [0.0] class OBJECT_BASE: - def __init__(self,vertices: list = None,normals: list = None,triangles: list = None,quads: list = None,coordinates: list = None,rotation: list = None,color: list = None,to_be_drew: bool = False) -> None: - self.vertices=vertices if vertices is not None else [] - self.normals=normals if normals is not None else [] - self.triangles=triangles if triangles is not None else [] - self.quads=quads if quads is not None else [] - self.gl_list_id=None - self.coordinates=coordinates if coordinates is not None else [0,0,0] - self.rotation=rotation if rotation is not None else [0,0,0] - self.color=color if color is not None else [1,1,1] - self.to_be_drew=to_be_drew - - def draw(self,coordinates: list = None,rotation: list = None) -> None: + """Classe de base pour tous les objets 3D renderables. + + Gère la position, la rotation, la couleur et l'identifiant de display list OpenGL. + Les sous-classes implémentent `compile()` pour construire la display list. + """ + + def __init__( + self, + vertices: list = None, + normals: list = None, + triangles: list = None, + quads: list = None, + coordinates: list = None, + rotation: list = None, + color: list = None, + to_be_drew: bool = False, + ) -> None: + self.vertices = vertices if vertices is not None else [] + self.normals = normals if normals is not None else [] + self.triangles = triangles if triangles is not None else [] + self.quads = quads if quads is not None else [] + self.gl_list_id = None + self.coordinates = coordinates if coordinates is not None else [0, 0, 0] + self.rotation = rotation if rotation is not None else [0, 0, 0] + self.color = color if color is not None else [1, 1, 1] + self.to_be_drew = to_be_drew + + def draw(self, coordinates: list = None, rotation: list = None) -> None: + """Appelle la display list OpenGL avec translation et rotation. + + Si coordinates/rotation sont None, utilise les valeurs de l'objet. + """ if self.gl_list_id is not None: glMatrixMode(GL_MODELVIEW) glPushMatrix() - glTranslatef(self.coordinates[0],self.coordinates[1],self.coordinates[2]) if coordinates is None else glTranslatef(coordinates[0],coordinates[1],coordinates[2]) - glRotatef(self.rotation[0],1,0,0) if rotation is None else glRotatef(rotation[0],1,0,0) - glRotatef(self.rotation[1],0,1,0) if rotation is None else glRotatef(rotation[1],0,1,0) - glRotatef(self.rotation[2],0,0,1) if rotation is None else glRotatef(rotation[2],0,0,1) + ( + glTranslatef( + self.coordinates[0], self.coordinates[1], self.coordinates[2] + ) + if coordinates is None + else glTranslatef(coordinates[0], coordinates[1], coordinates[2]) + ) + ( + glRotatef(self.rotation[0], 1, 0, 0) + if rotation is None + else glRotatef(rotation[0], 1, 0, 0) + ) + ( + glRotatef(self.rotation[1], 0, 1, 0) + if rotation is None + else glRotatef(rotation[1], 0, 1, 0) + ) + ( + glRotatef(self.rotation[2], 0, 0, 1) + if rotation is None + else glRotatef(rotation[2], 0, 0, 1) + ) glCallList(self.gl_list_id) glPopMatrix() else: - print('Not compiled') + logger.warning( + "draw() appelé sur un objet non compilé (%s).", self.__class__.__name__ + ) - def addCoordinates(self,coordinates: list = None) -> None: + def addCoordinates(self, coordinates: list = None) -> None: + """Déplace l'objet en ajoutant [dx, dy, dz] à sa position courante.""" if coordinates is not None: self.coordinates[0] += coordinates[0] self.coordinates[1] += coordinates[1] self.coordinates[2] += coordinates[2] - def addRotation(self,rotation: list = None) -> None: + def addRotation(self, rotation: list = None) -> None: + """Applique une rotation incrémentale [rx, ry, rz] en degrés sur les axes X, Y, Z.""" if rotation is not None: self.rotation[0] += rotation[0] self.rotation[1] += rotation[1] self.rotation[2] += rotation[2] + class VERTICES(OBJECT_BASE): def compile(self) -> None: + """Compile les sommets en une display list GL_POINTS.""" if glIsList(self.gl_list_id) == GL_FALSE: + logger.debug("Compilation VERTICES (%d points).", len(self.vertices)) self.gl_list_id = glGenLists(1, GL_COMPILE) glNewList(self.gl_list_id, GL_COMPILE) glBegin(GL_POINTS) @@ -58,49 +107,73 @@ def compile(self) -> None: glVertex3fv(vertex) glEnd() glEndList() + logger.debug("VERTICES compilé (list id=%d).", self.gl_list_id) else: - print('Already compiled') + logger.warning("VERTICES déjà compilé, ignoré.") + class FACES(OBJECT_BASE): def compile(self) -> None: + """Compile quads et triangles en une display list avec couleur aléatoire par face.""" if self.gl_list_id is None: + logger.debug( + "Compilation FACES (%d quads, %d triangles).", + len(self.quads[0]), + len(self.triangles[0]), + ) self.gl_list_id = glGenLists(1) glNewList(self.gl_list_id, GL_COMPILE) glBegin(GL_QUADS) for i in range(len(self.quads[0])): - #glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, mat_ambient) - #glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, mat_diffuse) - #glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, no_mat) - #glMaterialfv(GL_FRONT_AND_BACK, GL_SHININESS, no_shininess) - #glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, no_mat) - - glColor3fv((randint(0, 255) / 255, randint(0, 255) / 255, randint(0, 255) / 255)) + # glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, mat_ambient) + # glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, mat_diffuse) + # glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, no_mat) + # glMaterialfv(GL_FRONT_AND_BACK, GL_SHININESS, no_shininess) + # glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, no_mat) + + glColor3fv( + ( + randint(0, 255) / 255, + randint(0, 255) / 255, + randint(0, 255) / 255, + ) + ) for j in range(len(self.quads[0][i])): glVertex3fv(self.vertices[self.quads[0][i][j] - 1]) - #glNormal3fv(self.normals[self.quads[2][i][j]-1]) + # glNormal3fv(self.normals[self.quads[2][i][j]-1]) glEnd() glBegin(GL_TRIANGLES) for i in range(len(self.triangles[0])): - #glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, mat_ambient) - #glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, mat_diffuse) - #glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, no_mat) - #glMaterialfv(GL_FRONT_AND_BACK, GL_SHININESS, no_shininess) - #glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, no_mat) - - glColor3fv((randint(0, 255) / 255, randint(0, 255) / 255, randint(0, 255) / 255)) + # glMaterialfv(GL_FRONT_AND_BACK, GL_AMBIENT, mat_ambient) + # glMaterialfv(GL_FRONT_AND_BACK, GL_DIFFUSE, mat_diffuse) + # glMaterialfv(GL_FRONT_AND_BACK, GL_SPECULAR, no_mat) + # glMaterialfv(GL_FRONT_AND_BACK, GL_SHININESS, no_shininess) + # glMaterialfv(GL_FRONT_AND_BACK, GL_EMISSION, no_mat) + + glColor3fv( + ( + randint(0, 255) / 255, + randint(0, 255) / 255, + randint(0, 255) / 255, + ) + ) for j in range(len(self.triangles[0][i])): glVertex3fv(self.vertices[self.triangles[0][i][j] - 1]) - #glNormal3fv(self.normals[self.triangles[2][i][j]-1]) + # glNormal3fv(self.normals[self.triangles[2][i][j]-1]) glEnd() glEndList() + logger.debug("FACES compilé (list id=%d).", self.gl_list_id) else: - print('Already compiled') + logger.warning("FACES déjà compilé, ignoré.") + class LINES_LOOP(OBJECT_BASE): def compile(self) -> None: + """Compile les sommets en une display list GL_LINE_LOOP (contour fermé).""" if self.gl_list_id is None: + logger.debug("Compilation LINES_LOOP (%d sommets).", len(self.vertices)) self.gl_list_id = glGenLists(1) glNewList(self.gl_list_id, GL_COMPILE) glBegin(GL_LINE_LOOP) @@ -109,12 +182,16 @@ def compile(self) -> None: glVertex3fv(vertex) glEnd() glEndList() + logger.debug("LINES_LOOP compilé (list id=%d).", self.gl_list_id) else: - print('Already compiled') + logger.warning("LINES_LOOP déjà compilé, ignoré.") + class LINES(OBJECT_BASE): def compile(self) -> None: + """Compile les sommets en une display list GL_LINES (segments).""" if self.gl_list_id is None: + logger.debug("Compilation LINES (%d sommets).", len(self.vertices)) self.gl_list_id = glGenLists(1) glNewList(self.gl_list_id, GL_COMPILE) glBegin(GL_LINES) @@ -123,191 +200,160 @@ def compile(self) -> None: glVertex3fv(vertex) glEnd() glEndList() + logger.debug("LINES compilé (list id=%d).", self.gl_list_id) else: - print('Already compiled') + logger.warning("LINES déjà compilé, ignoré.") + class CAMERA: + """Caméra libre 6 degrés de liberté avec orientation par quaternions. + + Les trois axes (front, up, right) sont maintenus orthonormaux via Gram-Schmidt. + """ + def __init__(self, coordinates: list = None, speed: float = None) -> None: - self.coordinates = coordinates if coordinates is not None else [0,0,0] + self.coordinates = coordinates if coordinates is not None else [0, 0, 0] self.speed = speed if speed is not None else 0.05 self.front = QUATERNION(w=0, x=0, y=0, z=-1) self.up = QUATERNION(w=0, x=0, y=1, z=0) self.right = QUATERNION(w=0, x=1, y=0, z=0) - self.yaw = 0.0 # Pas indicatife d'une quelconque rotation - self.pitch = 0.0 # Pas indicatife d'une quelconque rotation - self.roll = 0.0 # Pas indicatife d'une quelconque rotation - - def updateFront(self) -> None: # On utilise le process de Gramm-Schimdt. Pour les autres aussi + self.yaw = 0.0 # Pas indicatife d'une quelconque rotation + self.pitch = 0.0 # Pas indicatife d'une quelconque rotation + self.roll = 0.0 # Pas indicatife d'une quelconque rotation + + def updateFront( + self, + ) -> None: # On utilise le process de Gramm-Schimdt. Pour les autres aussi + """Recalcule le vecteur front comme cross product normalisé de up × right.""" self.front = crossProductNormalized(self.up, self.right) def updateRight(self) -> None: + """Recalcule le vecteur right comme cross product normalisé de front × up.""" self.right = crossProductNormalized(self.front, self.up) def updateUp(self) -> None: + """Recalcule le vecteur up comme cross product normalisé de right × front.""" self.up = crossProductNormalized(self.right, self.front) def addYaw(self, angle: float) -> None: + """Tourne autour de l'axe up (rotation gauche/droite), angle en degrés.""" self.yaw += angle - #invertedUp = not self.up - halfAngle=radians(angle/2) - quaternionForRotation = QUATERNION(w = cos(halfAngle), x = sin(halfAngle)*self.up.x, y = sin(halfAngle)*self.up.y, z = sin(halfAngle)*self.up.z) + # invertedUp = not self.up + halfAngle = radians(angle / 2) + quaternionForRotation = QUATERNION( + w=cos(halfAngle), + x=sin(halfAngle) * self.up.x, + y=sin(halfAngle) * self.up.y, + z=sin(halfAngle) * self.up.z, + ) invertedQuaternionForRotation = quaternionForRotation.inverse() - self.front = normalize(quaternionForRotation * self.front * invertedQuaternionForRotation) - self.updateRight() # Mise à jour du dernier vecteur + self.front = normalize( + quaternionForRotation * self.front * invertedQuaternionForRotation + ) + self.updateRight() # Mise à jour du dernier vecteur def addPitch(self, angle: float) -> None: + """Tourne autour de l'axe right (rotation haut/bas), angle en degrés.""" self.pitch += angle - halfAngle=radians(angle/2) - quaternionForRotation = QUATERNION(w = cos(halfAngle), x = sin(halfAngle)*self.right.x, y = sin(halfAngle)*self.right.y, z = sin(halfAngle)*self.right.z) + halfAngle = radians(angle / 2) + quaternionForRotation = QUATERNION( + w=cos(halfAngle), + x=sin(halfAngle) * self.right.x, + y=sin(halfAngle) * self.right.y, + z=sin(halfAngle) * self.right.z, + ) invertedQuaternionForRotation = quaternionForRotation.inverse() - self.up = normalize(quaternionForRotation * self.up * invertedQuaternionForRotation) - self.updateFront() # Mise à jour du dernier vecteur + self.up = normalize( + quaternionForRotation * self.up * invertedQuaternionForRotation + ) + self.updateFront() # Mise à jour du dernier vecteur def addRoll(self, angle: float) -> None: + """Tourne autour de l'axe front (inclinaison latérale), angle en degrés.""" self.roll += angle - halfAngle=radians(angle/2) - quaternionForRotation = QUATERNION(w = cos(halfAngle), x = sin(halfAngle)*self.front.x, y = sin(halfAngle)*self.front.y, z = sin(halfAngle)*self.front.z) + halfAngle = radians(angle / 2) + quaternionForRotation = QUATERNION( + w=cos(halfAngle), + x=sin(halfAngle) * self.front.x, + y=sin(halfAngle) * self.front.y, + z=sin(halfAngle) * self.front.z, + ) invertedQuaternionForRotation = quaternionForRotation.inverse() - self.right = normalize(quaternionForRotation * self.right * invertedQuaternionForRotation) - self.updateUp() # Mise à jour du dernier vecteur + self.right = normalize( + quaternionForRotation * self.right * invertedQuaternionForRotation + ) + self.updateUp() # Mise à jour du dernier vecteur - def addCoordinates(self,coordinates: list = None) -> None: + def addCoordinates(self, coordinates: list = None) -> None: + """Déplace la caméra en ajoutant [dx, dy, dz] à sa position courante.""" if coordinates is not None: self.coordinates[0] += coordinates[0] self.coordinates[1] += coordinates[1] self.coordinates[2] += coordinates[2] - def forward3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] += self.speed*self.front.x - self.coordinates[1] += self.speed*self.front.y - self.coordinates[2] += self.speed*self.front.z - else: - self.coordinates[0] += speed*self.front.x - self.coordinates[1] += speed*self.front.y - self.coordinates[2] += speed*self.front.z + def _move( + self, + direction: QUATERNION, + sign: float, + include_y: bool = True, + speed: float = None, + ) -> None: + """Déplace la caméra le long d'un axe donné. + + Args: + direction: vecteur de direction (front, up ou right) + sign: +1.0 = avant/haut/droite, -1.0 = arrière/bas/gauche + include_y: si False, ignore la composante verticale (déplacement 2D) + speed: vitesse override ; utilise self.speed si None + """ + s = speed if speed is not None else self.speed + self.coordinates[0] += sign * s * direction.x + if include_y: + self.coordinates[1] += sign * s * direction.y + self.coordinates[2] += sign * s * direction.z - def forward2D(self, speed: float = None) -> None: - # Il faudrait pas qu'en regardant en haut on se mette à moins avancer tout droit si on se déplace que sur le plan. - # Il suffit pas de juste ne pas faire de déplacement en Y. Je sais pas encore comment faire ah si ah non - if speed is None: - #front_x_normalized = normalize(front) - self.coordinates[0] += self.speed*self.front.x - #self.coordinates[1] += self.speed*self.front.y - self.coordinates[2] += self.speed*self.front.z - else: - self.coordinates[0] += speed*self.front.x - #self.coordinates[1] += speed*self.front.y - self.coordinates[2] += speed*self.front.z + def forward3D(self, speed: float = None) -> None: + self._move(self.front, 1.0, True, speed) def backward3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] -= self.speed*self.front.x - self.coordinates[1] -= self.speed*self.front.y - self.coordinates[2] -= self.speed*self.front.z - else: - self.coordinates[0] -= speed*self.front.x - self.coordinates[1] -= speed*self.front.y - self.coordinates[2] -= speed*self.front.z + self._move(self.front, -1.0, True, speed) + + def forward2D(self, speed: float = None) -> None: + self._move(self.front, 1.0, False, speed) def backward2D(self, speed: float = None) -> None: - # Il faudrait pas qu'en regardant en haut on se mette à moins avancer tout droit si on se déplace que sur le plan. - # Il suffit pas de juste ne pas faire de déplacement en Y. Je sais pas encore comment faire ah si ah non - if speed is None: - #front_x_normalized = normalize(front) - self.coordinates[0] -= self.speed*self.front.x - #self.coordinates[1] -= self.speed*self.front.y - self.coordinates[2] -= self.speed*self.front.z - else: - self.coordinates[0] -= speed*self.front.x - #self.coordinates[1] -= speed*self.front.y - self.coordinates[2] -= speed*self.front.z + self._move(self.front, -1.0, False, speed) def up3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] += self.speed*self.up.x - self.coordinates[1] += self.speed*self.up.y - self.coordinates[2] += self.speed*self.up.z - else: - self.coordinates[0] += speed*self.up.x - self.coordinates[1] += speed*self.up.y - self.coordinates[2] += speed*self.up.z - - def up2D(self, speed: float = None) -> None: # OUUUUIIIIII je sais ça sert logiquement à rien, chut.... - if speed is None: - self.coordinates[0] += self.speed*self.up.x - #self.coordinates[1] += self.speed*self.up.y - self.coordinates[2] += self.speed*self.up.z - else: - self.coordinates[0] += speed*self.up.x - #self.coordinates[1] += speed*self.up.y - self.coordinates[2] += speed*self.up.z + self._move(self.up, 1.0, True, speed) def down3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] -= self.speed*self.up.x - self.coordinates[1] -= self.speed*self.up.y - self.coordinates[2] -= self.speed*self.up.z - else: - self.coordinates[0] -= speed*self.up.x - self.coordinates[1] -= speed*self.up.y - self.coordinates[2] -= speed*self.up.z - - def down2D(self, speed: float = None) -> None: # OUUUUIIIIII je sais ça sert logiquement à rien, chut.... - if speed is None: - self.coordinates[0] -= self.speed*self.up.x - #self.coordinates[1] -= self.speed*self.up.y - self.coordinates[2] -= self.speed*self.up.z - else: - self.coordinates[0] -= speed*self.up.x - #self.coordinates[1] -= speed*self.up.y - self.coordinates[2] -= speed*self.up.z + self._move(self.up, -1.0, True, speed) - def right3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] += self.speed*self.right.x - self.coordinates[1] += self.speed*self.right.y - self.coordinates[2] += self.speed*self.right.z - else: - self.coordinates[0] += speed*self.right.x - self.coordinates[1] += speed*self.right.y - self.coordinates[2] += speed*self.right.z + def up2D(self, speed: float = None) -> None: + self._move(self.up, 1.0, False, speed) - def right2D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] += self.speed*self.right.x - #self.coordinates[1] += self.speed*self.right.y - self.coordinates[2] += self.speed*self.right.z - else: - self.coordinates[0] += speed*self.right.x - #self.coordinates[1] += speed*self.right.y - self.coordinates[2] += speed*self.right.z + def down2D(self, speed: float = None) -> None: + self._move(self.up, -1.0, False, speed) + + def right3D(self, speed: float = None) -> None: + self._move(self.right, 1.0, True, speed) def left3D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] -= self.speed*self.right.x - self.coordinates[1] -= self.speed*self.right.y - self.coordinates[2] -= self.speed*self.right.z - else: - self.coordinates[0] -= speed*self.right.x - self.coordinates[1] -= speed*self.right.y - self.coordinates[2] -= speed*self.right.z + self._move(self.right, -1.0, True, speed) + + def right2D(self, speed: float = None) -> None: + self._move(self.right, 1.0, False, speed) def left2D(self, speed: float = None) -> None: - if speed is None: - self.coordinates[0] -= self.speed*self.right.x - #self.coordinates[1] -= self.speed*self.right.y - self.coordinates[2] -= self.speed*self.right.z - else: - self.coordinates[0] -= speed*self.right.x - #self.coordinates[1] -= speed*self.right.y - self.coordinates[2] -= speed*self.right.z + self._move(self.right, -1.0, False, speed) def reset(self) -> None: - self.coordinates = [0,0,0] + """Réinitialise la position et l'orientation aux valeurs par défaut (origine, face vers -Z).""" + self.coordinates = [0, 0, 0] self.front = QUATERNION(w=0, x=0, y=0, z=-1) self.up = QUATERNION(w=0, x=0, y=1, z=0) self.right = QUATERNION(w=0, x=1, y=0, z=0) @@ -316,5 +362,6 @@ def reset(self) -> None: class AXES(LINES): pass + class ROTATION_AXES(LINES_LOOP): - pass \ No newline at end of file + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a54b1aa --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "krafton" +version = "0.1.0" +description = "Moteur 3D OpenGL avec détection de marqueurs LED par vision par ordinateur" +requires-python = ">=3.11" +dependencies = [ + "joblib", + "numpy", + "opencv-python", + "pygame-ce", + "PyOpenGL", + "scikit-learn", + "scipy", +] + +[tool.black] +line-length = 88 +target-version = ["py311"] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1fd2f54 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "release-type": "python", + "packages": { + ".": {} + } +} diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..039d26e --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest>=8.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..493be9e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +"""Configuration pytest : ajout de cameras/ au chemin d'import.""" + +import sys +import os + +# Les scripts caméra utilisent des imports directs (from parameters import *). +# On ajoute cameras/ au sys.path pour que pytest puisse les résoudre. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "cameras")) diff --git a/tests/test_cameras.py b/tests/test_cameras.py new file mode 100644 index 0000000..69d1f6b --- /dev/null +++ b/tests/test_cameras.py @@ -0,0 +1,217 @@ +"""Tests unitaires pour cameras/fonctions_images.py. + +Le répertoire cameras/ est ajouté au sys.path dans conftest.py, +ce qui permet les imports directs utilisés par ces scripts. +""" + +import math + +import numpy as np +import pytest + +from fonctions_images import ( + build_v_ij, + compute_homography, + groupe_leds, + rotation_matrix_x, + triangulate_parallel, +) + + +# --------------------------------------------------------------------------- +# rotation_matrix_x +# --------------------------------------------------------------------------- + + +class TestRotationMatrixX: + def test_angle_zero_is_identity(self): + R = rotation_matrix_x(0.0) + assert R == pytest.approx(np.eye(3)) + + def test_angle_pi_half(self): + R = rotation_matrix_x(math.pi / 2) + expected = np.array([[1, 0, 0], [0, 0, -1], [0, 1, 0]], dtype=float) + assert R == pytest.approx(expected, abs=1e-10) + + def test_angle_pi(self): + R = rotation_matrix_x(math.pi) + expected = np.array([[1, 0, 0], [0, -1, 0], [0, 0, -1]], dtype=float) + assert R == pytest.approx(expected, abs=1e-10) + + def test_is_orthogonal(self): + R = rotation_matrix_x(0.7) + assert R.T @ R == pytest.approx(np.eye(3), abs=1e-10) + + def test_determinant_is_one(self): + R = rotation_matrix_x(1.2) + assert np.linalg.det(R) == pytest.approx(1.0) + + +# --------------------------------------------------------------------------- +# triangulate_parallel +# --------------------------------------------------------------------------- + + +class TestTriangulateParallel: + # K = (f, cx, cy), B = baseline + K = (100.0, 320.0, 240.0) + B = 0.1 + + def test_zero_disparity_returns_none(self): + result = triangulate_parallel((300, 240), (300, 240), self.K, self.B) + assert result is None + + def test_known_depth(self): + # disparity = 10 → Z = 100 * 0.1 / 10 = 1.0 + # X = 1.0 * (330 - 320) / 100 = 0.1 + # Y = 1.0 * (240 - 240) / 100 = 0.0 + result = triangulate_parallel((330, 240), (320, 240), self.K, self.B) + assert result is not None + X, Y, Z = result + assert Z == pytest.approx(1.0) + assert X == pytest.approx(0.1) + assert Y == pytest.approx(0.0) + + def test_depth_inversely_proportional_to_disparity(self): + # Double la disparité → moitié de la profondeur + r1 = triangulate_parallel((330, 240), (320, 240), self.K, self.B) + r2 = triangulate_parallel((340, 240), (320, 240), self.K, self.B) + assert r1[2] == pytest.approx(r2[2] * 2, rel=1e-6) + + def test_negative_disparity(self): + # Disparité négative → profondeur négative (hors plan) + result = triangulate_parallel((310, 240), (320, 240), self.K, self.B) + assert result is not None + assert result[2] < 0 + + +# --------------------------------------------------------------------------- +# groupe_leds (distance_max = 50 px défini dans parameters.py) +# --------------------------------------------------------------------------- + + +class TestGroupeLeds: + def test_empty_list(self): + assert groupe_leds([]) == [] + + def test_single_point(self): + result = groupe_leds([(0.0, 0.0)]) + assert result == [[(0.0, 0.0)]] + + def test_two_close_points_same_cluster(self): + # distance = 10 < 50 → un seul cluster + result = groupe_leds([(0.0, 0.0), (10.0, 0.0)]) + assert len(result) == 1 + assert len(result[0]) == 2 + + def test_two_far_points_different_clusters(self): + # distance = 100 > 50 → deux clusters + result = groupe_leds([(0.0, 0.0), (100.0, 0.0)]) + assert len(result) == 2 + + def test_all_points_in_cluster_are_present(self): + pts = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] + result = groupe_leds(pts) + assert len(result) == 1 + assert len(result[0]) == 3 + + def test_two_separated_clusters(self): + # Deux groupes séparés de 200 px + close = [(0.0, 0.0), (10.0, 0.0), (20.0, 0.0)] + far = [(200.0, 0.0), (210.0, 0.0)] + result = groupe_leds(close + far) + assert len(result) == 2 + sizes = sorted(len(g) for g in result) + assert sizes == [2, 3] + + def test_boundary_distance_excluded(self): + # distance exactement = distance_max (50) → hors cluster (< strict) + result = groupe_leds([(0.0, 0.0), (50.0, 0.0)]) + assert len(result) == 2 + + def test_boundary_distance_included(self): + # distance légèrement < 50 → même cluster + result = groupe_leds([(0.0, 0.0), (49.9, 0.0)]) + assert len(result) == 1 + + +# --------------------------------------------------------------------------- +# build_v_ij +# --------------------------------------------------------------------------- + + +class TestBuildVij: + def test_shape(self): + H = np.eye(3) + v = build_v_ij(H, 0, 1) + assert v.shape == (6,) + + def test_identity_v01(self): + # H = I, i=0, j=1 → [0, 1, 0, 0, 0, 0] + H = np.eye(3) + v = build_v_ij(H, 0, 1) + expected = np.array([0.0, 1.0, 0.0, 0.0, 0.0, 0.0]) + assert v == pytest.approx(expected) + + def test_identity_v00(self): + # H = I, i=0, j=0 → [1, 0, 0, 0, 0, 0] + H = np.eye(3) + v = build_v_ij(H, 0, 0) + expected = np.array([1.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + assert v == pytest.approx(expected) + + def test_identity_v11(self): + # H = I, i=1, j=1 → [0, 0, 1, 0, 0, 0] + H = np.eye(3) + v = build_v_ij(H, 1, 1) + expected = np.array([0.0, 0.0, 1.0, 0.0, 0.0, 0.0]) + assert v == pytest.approx(expected) + + def test_symmetry(self): + # v_ij = v_ji + H = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0], [7.0, 8.0, 9.0]]) + assert build_v_ij(H, 0, 1) == pytest.approx(build_v_ij(H, 1, 0)) + + +# --------------------------------------------------------------------------- +# compute_homography +# --------------------------------------------------------------------------- + + +class TestComputeHomography: + def test_identity_mapping(self): + # Points vers eux-mêmes → H ≈ identité (normalisée) + pts = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float) + H = compute_homography(pts, pts) + for pt in pts: + p = np.array([pt[0], pt[1], 1.0]) + mapped = H @ p + mapped /= mapped[2] + assert mapped[0] == pytest.approx(pt[0], abs=1e-6) + assert mapped[1] == pytest.approx(pt[1], abs=1e-6) + + def test_translation(self): + obj_pts = np.array( + [[0, 0], [1, 0], [0, 1], [1, 1], [2, 1]], dtype=float + ) + tx, ty = 5.0, 3.0 + img_pts = obj_pts + np.array([tx, ty]) + H = compute_homography(obj_pts, img_pts) + for obj_pt, img_pt in zip(obj_pts, img_pts): + p = np.array([obj_pt[0], obj_pt[1], 1.0]) + mapped = H @ p + mapped /= mapped[2] + assert mapped[0] == pytest.approx(img_pt[0], abs=1e-5) + assert mapped[1] == pytest.approx(img_pt[1], abs=1e-5) + + def test_output_shape(self): + pts = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float) + H = compute_homography(pts, pts) + assert H.shape == (3, 3) + + def test_normalized_h22(self): + # H[2,2] doit être 1 (normalisé) + pts = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=float) + img_pts = pts * 2.0 + H = compute_homography(pts, img_pts) + assert H[2, 2] == pytest.approx(1.0) diff --git a/tests/test_dot_obj_parser.py b/tests/test_dot_obj_parser.py new file mode 100644 index 0000000..5683b50 --- /dev/null +++ b/tests/test_dot_obj_parser.py @@ -0,0 +1,208 @@ +"""Tests unitaires pour engine/dot_obj_parser.py.""" + +import os +import tempfile + +import pytest + +from engine.dot_obj_parser import OBJ_FILE + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def write_tmp_obj(content: str) -> str: + """Écrit content dans un fichier .obj temporaire et retourne son chemin.""" + with tempfile.NamedTemporaryFile( + mode="w", suffix=".obj", delete=False, encoding="utf-8" + ) as f: + f.write(content) + return f.name + + +# --------------------------------------------------------------------------- +# _parse_face_token +# --------------------------------------------------------------------------- + + +class TestParseFaceToken: + def setup_method(self): + # On instancie avec un chemin fictif — __init__ n'ouvre pas le fichier + self.parser = OBJ_FILE("dummy.obj") + + def test_vertex_only(self): + v, vt, vn = self.parser._parse_face_token("5") + assert v == 5 + assert vt is None + assert vn is None + + def test_vertex_texture(self): + v, vt, vn = self.parser._parse_face_token("1/2") + assert v == 1 + assert vt == 2 + assert vn is None + + def test_vertex_normal_no_texture(self): + v, vt, vn = self.parser._parse_face_token("1//3") + assert v == 1 + assert vt is None + assert vn == 3 + + def test_vertex_texture_normal(self): + v, vt, vn = self.parser._parse_face_token("1/2/3") + assert v == 1 + assert vt == 2 + assert vn == 3 + + def test_large_indices(self): + v, vt, vn = self.parser._parse_face_token("100/200/300") + assert v == 100 + assert vt == 200 + assert vn == 300 + + +# --------------------------------------------------------------------------- +# parseFile +# --------------------------------------------------------------------------- + +TRIANGLE_OBJ = """\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +vn 0.0 0.0 1.0 +f 1//1 2//1 3//1 +""" + +QUAD_OBJ = """\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 1.0 1.0 0.0 +v 0.0 1.0 0.0 +vn 0.0 0.0 1.0 +f 1//1 2//1 3//1 4//1 +""" + +MIXED_OBJ = """\ +v 0.0 0.0 0.0 +v 1.0 0.0 0.0 +v 0.0 1.0 0.0 +v 1.0 1.0 0.0 +vn 0.0 0.0 1.0 +vt 0.0 0.0 +f 1/1/1 2/1/1 3/1/1 +f 1/1/1 2/1/1 3/1/1 4/1/1 +""" + + +class TestParseFile: + def test_vertices_loaded(self): + path = write_tmp_obj(TRIANGLE_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.vertices) == 3 + assert p.vertices[0] == [0.0, 0.0, 0.0] + assert p.vertices[1] == [1.0, 0.0, 0.0] + finally: + os.unlink(path) + + def test_normals_loaded(self): + path = write_tmp_obj(TRIANGLE_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.normals) == 1 + assert p.normals[0] == [0.0, 0.0, 1.0] + finally: + os.unlink(path) + + def test_triangle_face(self): + path = write_tmp_obj(TRIANGLE_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.trianglesVertices) == 1 + assert len(p.quadsVertices) == 0 + assert p.trianglesVertices[0] == [1, 2, 3] + finally: + os.unlink(path) + + def test_quad_face(self): + path = write_tmp_obj(QUAD_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.quadsVertices) == 1 + assert len(p.trianglesVertices) == 0 + assert p.quadsVertices[0] == [1, 2, 3, 4] + finally: + os.unlink(path) + + def test_mixed_faces(self): + path = write_tmp_obj(MIXED_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.trianglesVertices) == 1 + assert len(p.quadsVertices) == 1 + finally: + os.unlink(path) + + def test_texture_coords_loaded(self): + path = write_tmp_obj(MIXED_OBJ) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.textures) == 1 + finally: + os.unlink(path) + + def test_file_not_found(self): + p = OBJ_FILE("/nonexistent/path/missing.obj") + with pytest.raises(FileNotFoundError): + p.parseFile() + + def test_empty_file(self): + path = write_tmp_obj("") + try: + p = OBJ_FILE(path) + p.parseFile() + assert p.vertices == [] + assert p.normals == [] + assert p.trianglesVertices == [] + assert p.quadsVertices == [] + finally: + os.unlink(path) + + def test_comments_ignored(self): + content = "# commentaire\nv 1.0 2.0 3.0\n# autre commentaire\n" + path = write_tmp_obj(content) + try: + p = OBJ_FILE(path) + p.parseFile() + assert len(p.vertices) == 1 + finally: + os.unlink(path) + + +# --------------------------------------------------------------------------- +# parse (force_parse) +# --------------------------------------------------------------------------- + + +class TestParse: + def test_force_parse_reads_file(self): + path = write_tmp_obj(TRIANGLE_OBJ) + try: + p = OBJ_FILE(path) + p.parse(force_parse=True) + assert len(p.vertices) == 3 + finally: + os.unlink(path) + + def test_force_parse_missing_file(self): + p = OBJ_FILE("/nonexistent/missing.obj") + with pytest.raises(FileNotFoundError): + p.parse(force_parse=True) diff --git a/tests/test_mathlib.py b/tests/test_mathlib.py new file mode 100644 index 0000000..c6dbfab --- /dev/null +++ b/tests/test_mathlib.py @@ -0,0 +1,304 @@ +"""Tests unitaires pour engine/mathlib.py.""" + +import math +import pytest + +from engine.mathlib import ( + QUATERNION, + crossProduct, + crossProductNormalized, + degrees, + dotProduct, + normalize, + radians, +) + + +# --------------------------------------------------------------------------- +# radians / degrees +# --------------------------------------------------------------------------- + + +class TestConversions: + def test_radians_zero(self): + assert radians(0) == 0.0 + + def test_radians_180(self): + assert radians(180) == pytest.approx(math.pi) + + def test_radians_360(self): + assert radians(360) == pytest.approx(2 * math.pi) + + def test_degrees_pi(self): + assert degrees(math.pi) == pytest.approx(180.0) + + def test_roundtrip(self): + assert degrees(radians(45)) == pytest.approx(45.0) + + def test_roundtrip_negative(self): + assert degrees(radians(-90)) == pytest.approx(-90.0) + + +# --------------------------------------------------------------------------- +# QUATERNION +# --------------------------------------------------------------------------- + + +class TestQuaternionInit: + def test_default_values(self): + q = QUATERNION() + assert q.w == 0.0 + assert q.x == 0.0 + assert q.y == 0.0 + assert q.z == 0.0 + + def test_explicit_values(self): + q = QUATERNION(1, 2, 3, 4) + assert q.w == 1 + assert q.x == 2 + assert q.y == 3 + assert q.z == 4 + + def test_list_attribute(self): + q = QUATERNION(1, 2, 3, 4) + assert q.list == [1, 2, 3, 4] + + def test_getitem(self): + q = QUATERNION(1, 2, 3, 4) + assert q[0] == 1 + assert q[1] == 2 + assert q[2] == 3 + assert q[3] == 4 + + +class TestGetLengthNoSqrt: + def test_unit_vector(self): + q = QUATERNION(0, 1, 0, 0) + assert q.getLengthNoSqrt() == pytest.approx(1.0) + + def test_known_value(self): + # 3² + 4² = 25 + q = QUATERNION(0, 3, 4, 0) + assert q.getLengthNoSqrt() == pytest.approx(25.0) + + def test_all_components(self): + q = QUATERNION(1, 1, 1, 1) + assert q.getLengthNoSqrt() == pytest.approx(4.0) + + def test_zero_quaternion(self): + q = QUATERNION(0, 0, 0, 0) + assert q.getLengthNoSqrt() == pytest.approx(0.0) + + +class TestQuaternionMultiply: + def test_type_error(self): + q = QUATERNION(1, 0, 0, 0) + with pytest.raises(AssertionError): + _ = q * 5 + + def test_identity_right(self): + q = QUATERNION(1, 2, 3, 4) + identity = QUATERNION(1, 0, 0, 0) + result = q * identity + assert result.w == pytest.approx(q.w) + assert result.x == pytest.approx(q.x) + assert result.y == pytest.approx(q.y) + assert result.z == pytest.approx(q.z) + + def test_identity_left(self): + q = QUATERNION(1, 2, 3, 4) + identity = QUATERNION(1, 0, 0, 0) + result = identity * q + assert result.w == pytest.approx(q.w) + assert result.x == pytest.approx(q.x) + assert result.y == pytest.approx(q.y) + assert result.z == pytest.approx(q.z) + + def test_i_times_j_equals_k(self): + # i * j = k en quaternions purs + i = QUATERNION(0, 1, 0, 0) + j = QUATERNION(0, 0, 1, 0) + result = i * j + assert result.w == pytest.approx(0.0) + assert result.x == pytest.approx(0.0) + assert result.y == pytest.approx(0.0) + assert result.z == pytest.approx(1.0) + + def test_j_times_i_equals_minus_k(self): + # j * i = -k + i = QUATERNION(0, 1, 0, 0) + j = QUATERNION(0, 0, 1, 0) + result = j * i + assert result.w == pytest.approx(0.0) + assert result.x == pytest.approx(0.0) + assert result.y == pytest.approx(0.0) + assert result.z == pytest.approx(-1.0) + + def test_not_commutative(self): + a = QUATERNION(1, 2, 3, 4) + b = QUATERNION(5, 6, 7, 8) + ab = a * b + ba = b * a + # Au moins un composant doit différer + assert not ( + ab.w == pytest.approx(ba.w) + and ab.x == pytest.approx(ba.x) + and ab.y == pytest.approx(ba.y) + and ab.z == pytest.approx(ba.z) + ) + + +class TestQuaternionInverse: + def test_unit_quaternion_inverse(self): + # Pour un quaternion unitaire q, q * q⁻¹ ≈ identité + angle = math.pi / 3 + q = QUATERNION( + math.cos(angle / 2), math.sin(angle / 2), 0, 0 + ) + inv = q.inverse() + result = q * inv + assert result.w == pytest.approx(1.0, abs=1e-9) + assert result.x == pytest.approx(0.0, abs=1e-9) + assert result.y == pytest.approx(0.0, abs=1e-9) + assert result.z == pytest.approx(0.0, abs=1e-9) + + def test_negates_vector_part(self): + # Pour w=1, x=0.5 : l'inverse doit avoir x négatif + q = QUATERNION(w=1, x=0.5, y=0, z=0) + inv = q.inverse() + assert inv.x < 0 + + +# --------------------------------------------------------------------------- +# dotProduct +# --------------------------------------------------------------------------- + + +class TestDotProduct: + def test_orthogonal_vectors(self): + assert dotProduct([1, 0, 0], [0, 1, 0]) == pytest.approx(0.0) + + def test_parallel_unit_vectors(self): + assert dotProduct([1, 0, 0], [1, 0, 0]) == pytest.approx(1.0) + + def test_known_value(self): + # [1,2,3]·[4,5,6] = 4+10+18 = 32 + assert dotProduct([1, 2, 3], [4, 5, 6]) == pytest.approx(32.0) + + def test_negative_values(self): + assert dotProduct([1, 0, 0], [-1, 0, 0]) == pytest.approx(-1.0) + + def test_length_mismatch(self): + with pytest.raises(AssertionError): + dotProduct([1, 2], [1, 2, 3]) + + +# --------------------------------------------------------------------------- +# crossProduct +# --------------------------------------------------------------------------- + + +class TestCrossProduct: + def test_i_cross_j_equals_k(self): + i = QUATERNION(0, 1, 0, 0) + j = QUATERNION(0, 0, 1, 0) + k = crossProduct(i, j) + assert k.w == pytest.approx(0.0) + assert k.x == pytest.approx(0.0) + assert k.y == pytest.approx(0.0) + assert k.z == pytest.approx(1.0) + + def test_j_cross_k_equals_i(self): + j = QUATERNION(0, 0, 1, 0) + k = QUATERNION(0, 0, 0, 1) + result = crossProduct(j, k) + assert result.x == pytest.approx(1.0) + assert result.y == pytest.approx(0.0) + assert result.z == pytest.approx(0.0) + + def test_anticommutative(self): + a = QUATERNION(0, 1, 2, 3) + b = QUATERNION(0, 4, 5, 6) + ab = crossProduct(a, b) + ba = crossProduct(b, a) + assert ab.x == pytest.approx(-ba.x) + assert ab.y == pytest.approx(-ba.y) + assert ab.z == pytest.approx(-ba.z) + + def test_self_cross_is_zero(self): + a = QUATERNION(0, 1, 2, 3) + result = crossProduct(a, a) + assert result.x == pytest.approx(0.0, abs=1e-10) + assert result.y == pytest.approx(0.0, abs=1e-10) + assert result.z == pytest.approx(0.0, abs=1e-10) + + def test_type_error(self): + with pytest.raises(AssertionError): + crossProduct([1, 0, 0], QUATERNION(0, 1, 0, 0)) + + def test_w_is_always_zero(self): + a = QUATERNION(0, 1, 2, 3) + b = QUATERNION(0, 4, 5, 6) + result = crossProduct(a, b) + assert result.w == 0 + + +# --------------------------------------------------------------------------- +# normalize +# --------------------------------------------------------------------------- + + +class TestNormalize: + def test_unit_vector_unchanged(self): + v = QUATERNION(0, 1, 0, 0) + result = normalize(v) + assert result.x == pytest.approx(1.0) + assert result.y == pytest.approx(0.0) + assert result.z == pytest.approx(0.0) + + def test_result_has_unit_length(self): + v = QUATERNION(0, 3, 4, 0) + result = normalize(v) + length = math.sqrt(result.x**2 + result.y**2 + result.z**2) + assert length == pytest.approx(1.0) + + def test_direction_preserved(self): + v = QUATERNION(0, 0, 5, 0) + result = normalize(v) + assert result.x == pytest.approx(0.0) + assert result.y == pytest.approx(1.0) + assert result.z == pytest.approx(0.0) + + def test_type_error(self): + with pytest.raises(AssertionError): + normalize([1, 0, 0]) + + +# --------------------------------------------------------------------------- +# crossProductNormalized +# --------------------------------------------------------------------------- + + +class TestCrossProductNormalized: + def test_result_has_unit_length(self): + a = QUATERNION(0, 1, 0, 0) + b = QUATERNION(0, 0, 1, 0) + result = crossProductNormalized(a, b) + length = math.sqrt(result.x**2 + result.y**2 + result.z**2) + assert length == pytest.approx(1.0) + + def test_result_perpendicular_to_inputs(self): + a = QUATERNION(0, 1, 0, 0) + b = QUATERNION(0, 0, 1, 0) + result = crossProductNormalized(a, b) + dot_a = dotProduct([result.x, result.y, result.z], [a.x, a.y, a.z]) + dot_b = dotProduct([result.x, result.y, result.z], [b.x, b.y, b.z]) + assert dot_a == pytest.approx(0.0, abs=1e-10) + assert dot_b == pytest.approx(0.0, abs=1e-10) + + def test_known_axes(self): + # Gram-Schmidt : up × right = front (0,0,-1) dans la config caméra par défaut + up = QUATERNION(0, 0, 1, 0) + right = QUATERNION(0, 1, 0, 0) + front = crossProductNormalized(up, right) + assert front.z == pytest.approx(-1.0, abs=1e-10) diff --git a/tests/test_opengl_3d_object.py b/tests/test_opengl_3d_object.py new file mode 100644 index 0000000..8dc62c0 --- /dev/null +++ b/tests/test_opengl_3d_object.py @@ -0,0 +1,334 @@ +"""Tests unitaires pour engine/opengl_3d_object.py. + +On teste uniquement les méthodes qui n'appellent pas l'API OpenGL : + - OBJECT_BASE : addCoordinates, addRotation + - CAMERA : init, déplacements (forward/backward/…), rotations (yaw/pitch/roll), reset + +Les méthodes compile() et draw() nécessitent un contexte OpenGL actif +et ne sont pas testées ici. +""" + +import math +import pytest + +from engine.opengl_3d_object import CAMERA, OBJECT_BASE + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def axes_are_orthonormal(cam: CAMERA, tol: float = 1e-9) -> bool: + """Vérifie que front, up et right sont unitaires et mutuellement orthogonaux.""" + front = cam.front + up = cam.up + right = cam.right + + def length(q): + return math.sqrt(q.x**2 + q.y**2 + q.z**2) + + def dot(a, b): + return a.x * b.x + a.y * b.y + a.z * b.z + + return ( + abs(length(front) - 1.0) < tol + and abs(length(up) - 1.0) < tol + and abs(length(right) - 1.0) < tol + and abs(dot(front, up)) < tol + and abs(dot(front, right)) < tol + and abs(dot(up, right)) < tol + ) + + +# --------------------------------------------------------------------------- +# OBJECT_BASE +# --------------------------------------------------------------------------- + + +class TestObjectBase: + def make(self, coords=None, rotation=None): + return OBJECT_BASE( + coordinates=coords if coords is not None else [0, 0, 0], + rotation=rotation if rotation is not None else [0, 0, 0], + ) + + # --- addCoordinates --- + + def test_add_coordinates_updates_position(self): + obj = self.make() + obj.addCoordinates([1, 2, 3]) + assert obj.coordinates == [1, 2, 3] + + def test_add_coordinates_accumulates(self): + obj = self.make() + obj.addCoordinates([1, 0, 0]) + obj.addCoordinates([1, 0, 0]) + assert obj.coordinates[0] == pytest.approx(2.0) + + def test_add_coordinates_none_is_noop(self): + obj = self.make() + obj.addCoordinates(None) + assert obj.coordinates == [0, 0, 0] + + def test_add_coordinates_negative(self): + obj = self.make(coords=[5, 5, 5]) + obj.addCoordinates([-5, -5, -5]) + assert obj.coordinates == [0, 0, 0] + + # --- addRotation --- + + def test_add_rotation_updates_angles(self): + obj = self.make() + obj.addRotation([30, 0, 0]) + assert obj.rotation == [30, 0, 0] + + def test_add_rotation_accumulates(self): + obj = self.make() + obj.addRotation([10, 20, 30]) + obj.addRotation([10, 20, 30]) + assert obj.rotation == [20, 40, 60] + + def test_add_rotation_none_is_noop(self): + obj = self.make() + obj.addRotation(None) + assert obj.rotation == [0, 0, 0] + + +# --------------------------------------------------------------------------- +# CAMERA — état initial +# --------------------------------------------------------------------------- + + +class TestCameraInit: + def test_default_coordinates(self): + cam = CAMERA() + assert cam.coordinates == [0, 0, 0] + + def test_default_speed(self): + cam = CAMERA() + assert cam.speed == pytest.approx(0.05) + + def test_custom_speed(self): + cam = CAMERA(speed=0.2) + assert cam.speed == pytest.approx(0.2) + + def test_default_front(self): + cam = CAMERA() + assert cam.front.x == pytest.approx(0.0) + assert cam.front.y == pytest.approx(0.0) + assert cam.front.z == pytest.approx(-1.0) + + def test_default_up(self): + cam = CAMERA() + assert cam.up.x == pytest.approx(0.0) + assert cam.up.y == pytest.approx(1.0) + assert cam.up.z == pytest.approx(0.0) + + def test_default_right(self): + cam = CAMERA() + assert cam.right.x == pytest.approx(1.0) + assert cam.right.y == pytest.approx(0.0) + assert cam.right.z == pytest.approx(0.0) + + def test_initial_axes_are_orthonormal(self): + assert axes_are_orthonormal(CAMERA()) + + +# --------------------------------------------------------------------------- +# CAMERA — déplacement +# --------------------------------------------------------------------------- + + +class TestCameraMovement: + SPEED = 0.1 # vitesse explicite pour des assertions nettes + + def make(self): + return CAMERA(speed=self.SPEED) + + # forward / backward : front = (0,0,-1) + + def test_forward_decreases_z(self): + cam = self.make() + cam.forward3D() + assert cam.coordinates[2] == pytest.approx(-self.SPEED) + assert cam.coordinates[0] == pytest.approx(0.0) + assert cam.coordinates[1] == pytest.approx(0.0) + + def test_backward_increases_z(self): + cam = self.make() + cam.backward3D() + assert cam.coordinates[2] == pytest.approx(self.SPEED) + + def test_forward_backward_cancel(self): + cam = self.make() + cam.forward3D() + cam.backward3D() + assert cam.coordinates == pytest.approx([0, 0, 0]) + + # right / left : right = (1,0,0) + + def test_right_increases_x(self): + cam = self.make() + cam.right3D() + assert cam.coordinates[0] == pytest.approx(self.SPEED) + + def test_left_decreases_x(self): + cam = self.make() + cam.left3D() + assert cam.coordinates[0] == pytest.approx(-self.SPEED) + + # up / down : up = (0,1,0) + + def test_up_increases_y(self): + cam = self.make() + cam.up3D() + assert cam.coordinates[1] == pytest.approx(self.SPEED) + + def test_down_decreases_y(self): + cam = self.make() + cam.down3D() + assert cam.coordinates[1] == pytest.approx(-self.SPEED) + + # 2D variants (ignore Y) + + def test_forward2d_does_not_change_y(self): + cam = self.make() + cam.forward2D() + assert cam.coordinates[1] == pytest.approx(0.0) + + def test_backward2d_does_not_change_y(self): + cam = self.make() + cam.backward2D() + assert cam.coordinates[1] == pytest.approx(0.0) + + # speed override + + def test_speed_override(self): + cam = self.make() + cam.forward3D(speed=1.0) + assert cam.coordinates[2] == pytest.approx(-1.0) + + # addCoordinates + + def test_add_coordinates(self): + cam = self.make() + cam.addCoordinates([3, 4, 5]) + assert cam.coordinates == [3, 4, 5] + + def test_add_coordinates_none_is_noop(self): + cam = self.make() + cam.addCoordinates(None) + assert cam.coordinates == [0, 0, 0] + + +# --------------------------------------------------------------------------- +# CAMERA — rotations (orthonormalité) +# --------------------------------------------------------------------------- + + +class TestCameraRotations: + """ + Propriété invariante : après toute séquence de rotations, + les axes front / up / right doivent rester orthonormaux. + """ + + def test_yaw_preserves_orthonormality(self): + cam = CAMERA() + cam.addYaw(45) + assert axes_are_orthonormal(cam) + + def test_yaw_90_preserves_orthonormality(self): + cam = CAMERA() + cam.addYaw(90) + assert axes_are_orthonormal(cam) + + def test_pitch_preserves_orthonormality(self): + cam = CAMERA() + cam.addPitch(45) + assert axes_are_orthonormal(cam) + + def test_roll_preserves_orthonormality(self): + cam = CAMERA() + cam.addRoll(45) + assert axes_are_orthonormal(cam) + + def test_combined_rotations_preserve_orthonormality(self): + cam = CAMERA() + for _ in range(10): + cam.addYaw(13) + cam.addPitch(7) + cam.addRoll(5) + assert axes_are_orthonormal(cam) + + def test_full_yaw_360_returns_to_initial(self): + cam = CAMERA() + initial_front = (cam.front.x, cam.front.y, cam.front.z) + for _ in range(4): + cam.addYaw(90) + assert cam.front.x == pytest.approx(initial_front[0], abs=1e-9) + assert cam.front.y == pytest.approx(initial_front[1], abs=1e-9) + assert cam.front.z == pytest.approx(initial_front[2], abs=1e-9) + + def test_full_pitch_360_returns_to_initial(self): + cam = CAMERA() + initial_up = (cam.up.x, cam.up.y, cam.up.z) + for _ in range(4): + cam.addPitch(90) + assert cam.up.x == pytest.approx(initial_up[0], abs=1e-9) + assert cam.up.y == pytest.approx(initial_up[1], abs=1e-9) + assert cam.up.z == pytest.approx(initial_up[2], abs=1e-9) + + def test_yaw_changes_front_not_up(self): + cam = CAMERA() + up_before = (cam.up.x, cam.up.y, cam.up.z) + cam.addYaw(30) + assert cam.up.x == pytest.approx(up_before[0], abs=1e-9) + assert cam.up.y == pytest.approx(up_before[1], abs=1e-9) + assert cam.up.z == pytest.approx(up_before[2], abs=1e-9) + + def test_pitch_changes_up_not_right(self): + cam = CAMERA() + right_before = (cam.right.x, cam.right.y, cam.right.z) + cam.addPitch(30) + assert cam.right.x == pytest.approx(right_before[0], abs=1e-9) + assert cam.right.y == pytest.approx(right_before[1], abs=1e-9) + assert cam.right.z == pytest.approx(right_before[2], abs=1e-9) + + def test_roll_changes_right_not_front(self): + cam = CAMERA() + front_before = (cam.front.x, cam.front.y, cam.front.z) + cam.addRoll(30) + assert cam.front.x == pytest.approx(front_before[0], abs=1e-9) + assert cam.front.y == pytest.approx(front_before[1], abs=1e-9) + assert cam.front.z == pytest.approx(front_before[2], abs=1e-9) + + +# --------------------------------------------------------------------------- +# CAMERA — reset +# --------------------------------------------------------------------------- + + +class TestCameraReset: + def test_reset_restores_default_axes(self): + cam = CAMERA() + cam.addYaw(45) + cam.addPitch(30) + cam.addRoll(20) + cam.reset() + assert cam.front.z == pytest.approx(-1.0) + assert cam.up.y == pytest.approx(1.0) + assert cam.right.x == pytest.approx(1.0) + + def test_reset_restores_coordinates(self): + cam = CAMERA() + cam.addCoordinates([10, 20, 30]) + cam.reset() + assert cam.coordinates == [0, 0, 0] + + def test_reset_orthonormality(self): + cam = CAMERA() + cam.addYaw(123) + cam.addPitch(456) + cam.reset() + assert axes_are_orthonormal(cam)