commit a57721216bc4b210c909e933b514170ae7c36f2e Author: Augustin Date: Fri Feb 20 14:12:11 2026 +0100 Initial commit: IMP3D Corrector - XYZ correction for 3D printers Features: - G-code parser for movement commands - XYZ corrector with skew, scale, offset, and rotation - Simple calibration (1 square) and advanced calibration (5 squares) - Multi-point measurement analysis - Profile management (create, edit, delete, list) - CLI interface with interactive calibration Generated with Crush Assisted-by: GLM-5 via Crush diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b44fe04 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Hidden folders +.crush/ +.git/ +.idea/ +.vscode/ +*.egg-info/ + +# Python +__pycache__/ +*.pyc +*.pyo +.env +.venv +venv/ +env/ +dist/ +build/ +*.egg + +# Generated files +*.gcode +!example/*.gcode + +# OS +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..960fca9 --- /dev/null +++ b/README.md @@ -0,0 +1,125 @@ +# IMP3D Corrector + +Correction de plan XYZ pour imprimantes 3D avec problèmes de calibration. + +## Problèmes résolus + +- **Skew X-Y**: Axes X et Y non perpendiculaires +- **Erreurs d'échelle**: Dimensions incorrectes sur les bords +- **Décalage d'origine**: Position zéro mal alignée +- **Rotation du plateau**: Léger angle du plateau +- **Asymétries**: Différences de dimensions selon la position + +## Installation + +```bash +python -m venv .venv +.venv/bin/pip install -e . +``` + +## Utilisation + +### Calibration simple (1 carré) + +Pour une calibration rapide: + +```bash +# Générer le G-code de calibration +imp3d-corrector calibrate -w 220 -d 220 -o calibration.gcode + +# Imprimer, mesurer, puis créer le profil +imp3d-corrector create ma_printer +``` + +### Calibration avancée (5 carrés) - RECOMMANDÉE + +Pour une calibration précise avec détection des problèmes: + +```bash +# 1. Générer le G-code avec 5 carrés (coins + centre) +imp3d-corrector calibrate-advanced -w 220 -d 220 -o calibration_advanced.gcode + +# 2. Imprimer le fichier + +# 3. Mesurer chaque carré et créer le profil +imp3d-corrector create-advanced ma_printer +``` + +Le système analysera: +- **Échelle moyenne** (compensation globale) +- **Skew** (angle entre X et Y) +- **Asymétrie** (différence gauche/droite, haut/bas) +- **Variance** (uniformité des dimensions sur le plateau) + +### Analyser sans créer de profil + +```bash +imp3d-corrector analyze +``` + +### Appliquer les corrections + +```bash +imp3d-corrector correct mon_fichier.gcode -p ma_printer -o fichier_corrigé.gcode +``` + +### Gestion des profils + +```bash +# Lister les profils +imp3d-corrector list + +# Afficher un profil +imp3d-corrector show ma_printer + +# Modifier un profil +imp3d-corrector edit ma_printer + +# Supprimer un profil +imp3d-corrector delete ma_printer +``` + +## Structure du projet + +``` +imp3d_corrector/ +├── cli.py # Interface en ligne de commande +├── config/ +│ └── profile_manager.py # Gestion des profils +└── core/ + ├── corrector.py # Logique de correction XYZ + ├── gcode_parser.py # Parser G-code + └── advanced_calibration.py # Calibration multi-points +``` + +## Workflow recommandé + +1. **Première calibration**: Utilisez `calibrate-advanced` pour avoir une vue complète +2. **Analysez** les résultats - si des problèmes d'asymétrie sont détectés, vérifiez votre matériel +3. **Créez le profil** avec les corrections calculées +4. **Testez** avec un carré simple pour valider + +## Exemple de configuration YAML + +Les profils sont stockés dans `~/.config/imp3d_corrector/profiles/`: + +```yaml +name: ma_printer +skew_xy: 0.5 +offset_x: 0.0 +offset_y: 0.0 +scale_x: 1.002 +scale_y: 0.998 +rotation: 0.0 +bed_width: 220.0 +bed_depth: 220.0 +``` + +## Interprétation des résultats + +| Problème détecté | Cause probable | Solution | +|-----------------|----------------|----------| +| Skew élevé | Axes non perpendiculaires | Ajustement mécanique ou compensation logicielle | +| Asymétrie X | Courroie X détendue d'un côté | Vérifier la tension de courroie | +| Asymétrie Y | Courroie Y détendue d'un côté | Vérifier la tension de courroie | +| Variance élevée | Problème de linéarité | Vérifier les rails/roulements | diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..2528c20 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,10 @@ +name: example_printer +skew_xy: 0.5 +offset_x: 0.0 +offset_y: 0.0 +scale_x: 1.002 +scale_y: 0.998 +rotation: 0.0 +bed_width: 220.0 +bed_depth: 220.0 +calibration_points: [] diff --git a/imp3d_corrector/__init__.py b/imp3d_corrector/__init__.py new file mode 100644 index 0000000..4a904bd --- /dev/null +++ b/imp3d_corrector/__init__.py @@ -0,0 +1,3 @@ +"""IMP3D Corrector - Correction de plan XYZ pour imprimantes 3D.""" + +__version__ = "0.1.0" diff --git a/imp3d_corrector/cli.py b/imp3d_corrector/cli.py new file mode 100644 index 0000000..ba77542 --- /dev/null +++ b/imp3d_corrector/cli.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""CLI pour IMP3D Corrector - Correction de plan XYZ pour imprimantes 3D.""" + +import argparse +import sys +from pathlib import Path + +from imp3d_corrector.config.profile_manager import ProfileManager +from imp3d_corrector.core.corrector import BedCalibration, XYZCorrector +from imp3d_corrector.core.advanced_calibration import ( + AdvancedCalibrationGCode, MultiPointCalibration, + SquareMeasurement, CalibrationPosition +) + + +def cmd_create_profile(args): + """Crée un nouveau profil de calibration.""" + manager = ProfileManager() + + if manager.profile_exists(args.name): + overwrite = input(f"Le profil '{args.name}' existe déjà. Écraser? (o/N): ") + if not overwrite.lower().startswith('o'): + print("Annulé.") + return 1 + + calibration = manager.create_interactive(args.name) + path = manager.save_profile(calibration) + print(f"\n✓ Profil sauvegardé: {path}") + return 0 + + +def cmd_edit_profile(args): + """Modifie un profil existant.""" + manager = ProfileManager() + + if not manager.profile_exists(args.name): + print(f"Erreur: Le profil '{args.name}' n'existe pas.") + return 1 + + calibration = manager.edit_profile(args.name) + if calibration: + path = manager.save_profile(calibration) + print(f"\n✓ Profil mis à jour: {path}") + return 0 + + +def cmd_list_profiles(args): + """Liste tous les profils disponibles.""" + manager = ProfileManager() + profiles = manager.list_profiles() + + if not profiles: + print("Aucun profil trouvé.") + print("Créez-en un avec: imp3d-corrector create ") + return 0 + + print("Profils disponibles:") + for name in profiles: + cal = manager.load_profile(name) + print(f" • {name}") + print(f" Plateau: {cal.bed_width}x{cal.bed_depth}mm") + print(f" Skew: {cal.skew_xy:.2f}°, Scale: {cal.scale_x:.4f}/{cal.scale_y:.4f}") + return 0 + + +def cmd_delete_profile(args): + """Supprime un profil.""" + manager = ProfileManager() + + if not manager.profile_exists(args.name): + print(f"Erreur: Le profil '{args.name}' n'existe pas.") + return 1 + + confirm = input(f"Supprimer le profil '{args.name}'? (o/N): ") + if confirm.lower().startswith('o'): + manager.delete_profile(args.name) + print(f"✓ Profil '{args.name}' supprimé.") + else: + print("Annulé.") + return 0 + + +def cmd_show_profile(args): + """Affiche les détails d'un profil.""" + manager = ProfileManager() + + if not manager.profile_exists(args.name): + print(f"Erreur: Le profil '{args.name}' n'existe pas.") + return 1 + + cal = manager.load_profile(args.name) + print(f"\n=== Profil: {cal.name} ===\n") + print(f"Dimensions du plateau: {cal.bed_width} x {cal.bed_depth} mm") + print(f"\nCorrections:") + print(f" Offset X: {cal.offset_x:.3f} mm") + print(f" Offset Y: {cal.offset_y:.3f} mm") + print(f" Scale X: {cal.scale_x:.6f}") + print(f" Scale Y: {cal.scale_y:.6f}") + print(f" Skew: {cal.skew_xy:.3f}°") + print(f" Rotation: {cal.rotation:.3f}°") + return 0 + + +def cmd_correct(args): + """Applique la correction à un fichier G-code.""" + manager = ProfileManager() + + if not manager.profile_exists(args.profile): + print(f"Erreur: Le profil '{args.profile}' n'existe pas.") + return 1 + + input_path = Path(args.input) + if not input_path.exists(): + print(f"Erreur: Le fichier '{args.input}' n'existe pas.") + return 1 + + # Déterminer le fichier de sortie + if args.output: + output_path = Path(args.output) + else: + output_path = input_path.with_name(f'{input_path.stem}_corrected{input_path.suffix}') + + calibration = manager.load_profile(args.profile) + corrector = XYZCorrector(calibration) + + print(f"Application de la correction avec le profil '{args.profile}'...") + print(f" Entrée: {input_path}") + print(f" Sortie: {output_path}") + + corrector.correct_gcode_file(str(input_path), str(output_path)) + + print(f"\n✓ Correction appliquée: {output_path}") + return 0 + + +def cmd_generate_calibration(args): + """Génère un G-code de calibration.""" + manager = ProfileManager() + + if args.profile and manager.profile_exists(args.profile): + calibration = manager.load_profile(args.profile) + else: + calibration = BedCalibration( + name="calibration", + bed_width=args.width or 220, + bed_depth=args.depth or 220 + ) + + corrector = XYZCorrector(calibration) + gcode = corrector.generate_correction_gcode() + + if args.output: + output_path = Path(args.output) + else: + output_path = Path('calibration_square.gcode') + + with open(output_path, 'w') as f: + f.write(gcode) + + print(f"✓ G-code de calibration généré: {output_path}") + print(" 1. Imprimez ce fichier") + print(" 2. Mesurez le carré (côtés X, Y et diagonale)") + print(" 3. Créez un profil avec ces mesures: imp3d-corrector create ") + return 0 + + +def cmd_generate_advanced_calibration(args): + """Génère un G-code de calibration avancé (5 carrés).""" + generator = AdvancedCalibrationGCode( + bed_width=args.width or 220, + bed_depth=args.depth or 220, + square_size=args.size or 30, + margin=args.margin or 15 + ) + + gcode = generator.generate() + + if args.output: + output_path = Path(args.output) + else: + output_path = Path('calibration_advanced.gcode') + + with open(output_path, 'w') as f: + f.write(gcode) + + print(f"✓ G-code de calibration avancé généré: {output_path}") + print(f" 5 carrés de {args.size or 30}mm seront imprimés") + print(" Positions: 4 coins + centre") + print("\n 1. Imprimez ce fichier") + print(" 2. Mesurez CHAQUE carré (X, Y, diagonale)") + print(" 3. Créez un profil: imp3d-corrector create-advanced ") + return 0 + + +def cmd_create_advanced_profile(args): + """Crée un profil à partir de mesures multi-points.""" + manager = ProfileManager() + + if manager.profile_exists(args.name): + overwrite = input(f"Le profil '{args.name}' existe déjà. Écraser? (o/N): ") + if not overwrite.lower().startswith('o'): + print("Annulé.") + return 1 + + print(f"\n{'='*50}") + print(f"CRÉATION DE PROFIL AVANCÉ: {args.name}") + print(f"{'='*50}\n") + + # Paramètres du plateau + print("Dimensions du plateau:") + bed_width = float(input(" Largeur X (mm) [220]: ") or "220") + bed_depth = float(input(" Profondeur Y (mm) [220]: ") or "220") + square_size = float(input(" Taille des carrés de test (mm) [30]: ") or "30") + + calibration = MultiPointCalibration( + bed_width=bed_width, + bed_depth=bed_depth, + square_size=square_size + ) + + print(f"\n{'='*50}") + print("MESURES DES CARRÉS") + print(f"{'='*50}") + print(f"\nPour chaque carré, entrez les mesures.") + print(f"Taille attendue: {square_size} mm") + print(f"Diagonale attendue: {square_size * 1.414:.2f} mm\n") + + positions_order = [ + (CalibrationPosition.BOTTOM_LEFT, "Bas-Gauche (BL)"), + (CalibrationPosition.BOTTOM_RIGHT, "Bas-Droite (BR)"), + (CalibrationPosition.CENTER, "Centre (C)"), + (CalibrationPosition.TOP_LEFT, "Haut-Gauche (TL)"), + (CalibrationPosition.TOP_RIGHT, "Haut-Droite (TR)"), + ] + + for pos, label in positions_order: + print(f"\n--- {label} ---") + measured_x = float(input(f" Côté X (mm): ")) + measured_y = float(input(f" Côté Y (mm): ")) + measured_diag = float(input(f" Diagonale (mm): ")) + + measurement = SquareMeasurement( + position=pos, + expected_size=square_size, + measured_x=measured_x, + measured_y=measured_y, + measured_diagonal=measured_diag + ) + calibration.add_measurement(measurement) + + # Afficher le rapport + print(calibration.generate_report()) + + # Obtenir les corrections + corrections = calibration.get_corrections() + + if corrections['issues']: + print("\n⚠ Des problèmes ont été détectés.") + print(" Les corrections globales peuvent ne pas être optimales.") + print(" Envisagez des corrections manuelles si nécessaire.\n") + + # Créer le profil + bed_cal = BedCalibration( + name=args.name, + skew_xy=corrections['skew_xy'], + scale_x=corrections['scale_x'], + scale_y=corrections['scale_y'], + bed_width=bed_width, + bed_depth=bed_depth + ) + + # Option pour ajustements manuels + manual = input("\nAjustements manuels supplémentaires? (o/N): ").lower().startswith('o') + + if manual: + bed_cal.offset_x = float(input(f" Offset X (mm) [{bed_cal.offset_x}]: ") or bed_cal.offset_x) + bed_cal.offset_y = float(input(f" Offset Y (mm) [{bed_cal.offset_y}]: ") or bed_cal.offset_y) + bed_cal.rotation = float(input(f" Rotation (°) [{bed_cal.rotation}]: ") or bed_cal.rotation) + + path = manager.save_profile(bed_cal) + print(f"\n✓ Profil sauvegardé: {path}") + return 0 + + +def cmd_analyze_calibration(args): + """Analyse les mesures de calibration sans créer de profil.""" + print(f"\n{'='*50}") + print("ANALYSE DE CALIBRATION") + print(f"{'='*50}\n") + + bed_width = float(input("Largeur plateau X (mm) [220]: ") or "220") + bed_depth = float(input("Profondeur plateau Y (mm) [220]: ") or "220") + square_size = float(input("Taille des carrés (mm) [30]: ") or "30") + + calibration = MultiPointCalibration( + bed_width=bed_width, + bed_depth=bed_depth, + square_size=square_size + ) + + print(f"\nEntrez les mesures (ou 'skip' pour ignorer une position):\n") + + positions_order = [ + (CalibrationPosition.BOTTOM_LEFT, "Bas-Gauche (BL)"), + (CalibrationPosition.BOTTOM_RIGHT, "Bas-Droite (BR)"), + (CalibrationPosition.CENTER, "Centre (C)"), + (CalibrationPosition.TOP_LEFT, "Haut-Gauche (TL)"), + (CalibrationPosition.TOP_RIGHT, "Haut-Droite (TR)"), + ] + + for pos, label in positions_order: + print(f"\n--- {label} ---") + x_input = input(f" Côté X (mm) [skip]: ") + if x_input.lower() == 'skip': + continue + y_input = input(f" Côté Y (mm): ") + diag_input = input(f" Diagonale (mm): ") + + try: + measurement = SquareMeasurement( + position=pos, + expected_size=square_size, + measured_x=float(x_input), + measured_y=float(y_input), + measured_diagonal=float(diag_input) + ) + calibration.add_measurement(measurement) + except ValueError: + print(" → Ignoré (valeurs invalides)") + + print(calibration.generate_report()) + return 0 + + +def main(): + parser = argparse.ArgumentParser( + prog='imp3d-corrector', + description='Correction de plan XYZ pour imprimantes 3D' + ) + subparsers = parser.add_subparsers(dest='command', help='Commandes disponibles') + + # Commande: create + create_parser = subparsers.add_parser('create', help='Créer un nouveau profil') + create_parser.add_argument('name', help='Nom du profil') + create_parser.set_defaults(func=cmd_create_profile) + + # Commande: edit + edit_parser = subparsers.add_parser('edit', help='Modifier un profil existant') + edit_parser.add_argument('name', help='Nom du profil') + edit_parser.set_defaults(func=cmd_edit_profile) + + # Commande: list + list_parser = subparsers.add_parser('list', help='Lister les profils') + list_parser.set_defaults(func=cmd_list_profiles) + + # Commande: delete + delete_parser = subparsers.add_parser('delete', help='Supprimer un profil') + delete_parser.add_argument('name', help='Nom du profil') + delete_parser.set_defaults(func=cmd_delete_profile) + + # Commande: show + show_parser = subparsers.add_parser('show', help='Afficher un profil') + show_parser.add_argument('name', help='Nom du profil') + show_parser.set_defaults(func=cmd_show_profile) + + # Commande: correct + correct_parser = subparsers.add_parser('correct', help='Appliquer la correction à un G-code') + correct_parser.add_argument('input', help='Fichier G-code à corriger') + correct_parser.add_argument('-p', '--profile', required=True, help='Profil à utiliser') + correct_parser.add_argument('-o', '--output', help='Fichier de sortie (défaut: *_corrected.gcode)') + correct_parser.set_defaults(func=cmd_correct) + + # Commande: calibrate + calibrate_parser = subparsers.add_parser('calibrate', help='Générer un G-code de calibration simple (1 carré)') + calibrate_parser.add_argument('-p', '--profile', help='Profil existant à utiliser') + calibrate_parser.add_argument('-w', '--width', type=float, help='Largeur du plateau (mm)') + calibrate_parser.add_argument('-d', '--depth', type=float, help='Profondeur du plateau (mm)') + calibrate_parser.add_argument('-o', '--output', help='Fichier de sortie') + calibrate_parser.set_defaults(func=cmd_generate_calibration) + + # Commande: calibrate-advanced + cal_adv_parser = subparsers.add_parser('calibrate-advanced', + help='Générer un G-code de calibration avancé (5 carrés)') + cal_adv_parser.add_argument('-w', '--width', type=float, help='Largeur du plateau (mm)') + cal_adv_parser.add_argument('-d', '--depth', type=float, help='Profondeur du plateau (mm)') + cal_adv_parser.add_argument('-s', '--size', type=float, help='Taille des carrés (mm) [30]') + cal_adv_parser.add_argument('-m', '--margin', type=float, help='Marge depuis les bords (mm) [15]') + cal_adv_parser.add_argument('-o', '--output', help='Fichier de sortie') + cal_adv_parser.set_defaults(func=cmd_generate_advanced_calibration) + + # Commande: create-advanced + create_adv_parser = subparsers.add_parser('create-advanced', + help='Créer un profil depuis mesures multi-points') + create_adv_parser.add_argument('name', help='Nom du profil') + create_adv_parser.set_defaults(func=cmd_create_advanced_profile) + + # Commande: analyze + analyze_parser = subparsers.add_parser('analyze', + help='Analyser des mesures de calibration (sans créer de profil)') + analyze_parser.set_defaults(func=cmd_analyze_calibration) + + args = parser.parse_args() + + if args.command is None: + parser.print_help() + return 0 + + return args.func(args) + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/imp3d_corrector/config/__init__.py b/imp3d_corrector/config/__init__.py new file mode 100644 index 0000000..55294dd --- /dev/null +++ b/imp3d_corrector/config/__init__.py @@ -0,0 +1 @@ +"""Configuration module for bed profiles.""" diff --git a/imp3d_corrector/config/profile_manager.py b/imp3d_corrector/config/profile_manager.py new file mode 100644 index 0000000..106ba45 --- /dev/null +++ b/imp3d_corrector/config/profile_manager.py @@ -0,0 +1,127 @@ +"""Gestion des profils de plateau pour imprimantes 3D.""" + +import os +from pathlib import Path +from typing import Dict, List, Optional +import yaml + +from ..core.corrector import BedCalibration + + +class ProfileManager: + """Gère les profils de calibration des plateaux.""" + + def __init__(self, config_dir: Optional[str] = None): + """Initialise le gestionnaire de profils. + + Args: + config_dir: Répertoire de stockage des profils. + Par défaut: ~/.config/imp3d_corrector/profiles/ + """ + if config_dir: + self.config_dir = Path(config_dir) + else: + self.config_dir = Path.home() / '.config' / 'imp3d_corrector' / 'profiles' + + self.config_dir.mkdir(parents=True, exist_ok=True) + + def list_profiles(self) -> List[str]: + """Retourne la liste des profils disponibles.""" + profiles = [] + for f in self.config_dir.glob('*.yaml'): + profiles.append(f.stem) + return sorted(profiles) + + def load_profile(self, name: str) -> BedCalibration: + """Charge un profil par son nom.""" + filepath = self.config_dir / f'{name}.yaml' + if not filepath.exists(): + raise FileNotFoundError(f"Profil '{name}' non trouvé") + return BedCalibration.from_yaml(str(filepath)) + + def save_profile(self, calibration: BedCalibration) -> str: + """Sauvegarde un profil.""" + filepath = self.config_dir / f'{calibration.name}.yaml' + calibration.to_yaml(str(filepath)) + return str(filepath) + + def delete_profile(self, name: str) -> bool: + """Supprime un profil.""" + filepath = self.config_dir / f'{name}.yaml' + if filepath.exists(): + filepath.unlink() + return True + return False + + def profile_exists(self, name: str) -> bool: + """Vérifie si un profil existe.""" + return (self.config_dir / f'{name}.yaml').exists() + + def get_profile_path(self, name: str) -> Path: + """Retourne le chemin du fichier de profil.""" + return self.config_dir / f'{name}.yaml' + + def create_interactive(self, name: str) -> BedCalibration: + """Crée un profil de manière interactive (pour utilisation CLI).""" + print(f"\n=== Création du profil: {name} ===\n") + + print("Dimensions du plateau:") + bed_width = float(input(" Largeur X (mm) [220]: ") or "220") + bed_depth = float(input(" Profondeur Y (mm) [220]: ") or "220") + + print("\n--- Mesures de calibration ---") + print("Imprimez d'abord un carré de test et mesurez-le.") + print("(Laissez vide pour utiliser les valeurs par défaut)") + + expected = float(input(" Taille attendue du carré (mm) [50]: ") or "50") + measured_x = float(input(" Taille mesurée sur X (mm) [50]: ") or "50") + measured_y = float(input(" Taille mesurée sur Y (mm) [50]: ") or "50") + measured_diag = float(input(" Diagonale mesurée (mm) [70.71]: ") or "70.71") + + calibration = BedCalibration.from_measurements( + name=name, + expected_square_size=expected, + measured_x=measured_x, + measured_y=measured_y, + measured_diagonal=measured_diag, + bed_width=bed_width, + bed_depth=bed_depth + ) + + print("\n--- Corrections calculées ---") + print(f" Scale X: {calibration.scale_x:.4f}") + print(f" Scale Y: {calibration.scale_y:.4f}") + print(f" Skew X-Y: {calibration.skew_xy:.2f}°") + + # Option pour ajustements manuels + manual = input("\nAjustements manuels? (o/N): ").lower().startswith('o') + + if manual: + calibration.offset_x = float(input(f" Offset X (mm) [{calibration.offset_x}]: ") or calibration.offset_x) + calibration.offset_y = float(input(f" Offset Y (mm) [{calibration.offset_y}]: ") or calibration.offset_y) + calibration.rotation = float(input(f" Rotation (°) [{calibration.rotation}]: ") or calibration.rotation) + calibration.skew_xy = float(input(f" Skew X-Y (°) [{calibration.skew_xy}]: ") or calibration.skew_xy) + calibration._skew_rad = __import__('math').radians(calibration.skew_xy) + + return calibration + + def edit_profile(self, name: str) -> Optional[BedCalibration]: + """Modifie un profil existant.""" + calibration = self.load_profile(name) + + print(f"\n=== Modification du profil: {name} ===\n") + print("Laissez vide pour garder la valeur actuelle\n") + + calibration.bed_width = float(input(f"Largeur X (mm) [{calibration.bed_width}]: ") or calibration.bed_width) + calibration.bed_depth = float(input(f"Profondeur Y (mm) [{calibration.bed_depth}]: ") or calibration.bed_depth) + calibration.offset_x = float(input(f"Offset X (mm) [{calibration.offset_x}]: ") or calibration.offset_x) + calibration.offset_y = float(input(f"Offset Y (mm) [{calibration.offset_y}]: ") or calibration.offset_y) + calibration.scale_x = float(input(f"Scale X [{calibration.scale_x}]: ") or calibration.scale_x) + calibration.scale_y = float(input(f"Scale Y [{calibration.scale_y}]: ") or calibration.scale_y) + calibration.skew_xy = float(input(f"Skew X-Y (°) [{calibration.skew_xy}]: ") or calibration.skew_xy) + calibration.rotation = float(input(f"Rotation (°) [{calibration.rotation}]: ") or calibration.rotation) + + calibration._skew_rad = __import__('math').radians(calibration.skew_xy) + calibration._rotation_rad = __import__('math').radians(calibration.rotation) + + return calibration diff --git a/imp3d_corrector/core/__init__.py b/imp3d_corrector/core/__init__.py new file mode 100644 index 0000000..e39a375 --- /dev/null +++ b/imp3d_corrector/core/__init__.py @@ -0,0 +1 @@ +"""Core module for XYZ correction.""" diff --git a/imp3d_corrector/core/advanced_calibration.py b/imp3d_corrector/core/advanced_calibration.py new file mode 100644 index 0000000..3f4d285 --- /dev/null +++ b/imp3d_corrector/core/advanced_calibration.py @@ -0,0 +1,355 @@ +"""Calibration avancée avec multi-points pour imprimantes 3D.""" + +import math +from dataclasses import dataclass, field +from typing import List, Dict, Tuple, Optional +from enum import Enum + + +class CalibrationPosition(Enum): + """Positions des carrés de calibration sur le plateau.""" + CENTER = "center" + TOP_LEFT = "top_left" + TOP_RIGHT = "top_right" + BOTTOM_LEFT = "bottom_left" + BOTTOM_RIGHT = "bottom_right" + + +@dataclass +class SquareMeasurement: + """Mesures d'un carré de calibration.""" + position: CalibrationPosition + expected_size: float # mm + measured_x: float # mm + measured_y: float # mm + measured_diagonal: float # mm + + @property + def scale_x(self) -> float: + """Facteur d'échelle X calculé.""" + return self.expected_size / self.measured_x if self.measured_x > 0 else 1.0 + + @property + def scale_y(self) -> float: + """Facteur d'échelle Y calculé.""" + return self.expected_size / self.measured_y if self.measured_y > 0 else 1.0 + + @property + def skew_angle(self) -> float: + """Angle de skew calculé en degrés.""" + avg_side = (self.measured_x + self.measured_y) / 2 + if avg_side > 0 and self.measured_diagonal > 0: + try: + return math.degrees(math.acos(self.measured_diagonal / (2 * avg_side))) - 45 + except ValueError: + return 0.0 + return 0.0 + + @property + def diagonal_error(self) -> float: + """Erreur de diagonale en mm.""" + expected_diag = self.expected_size * math.sqrt(2) + return self.measured_diagonal - expected_diag + + +@dataclass +class MultiPointCalibration: + """Calibration multi-points complète.""" + bed_width: float = 220.0 + bed_depth: float = 220.0 + square_size: float = 30.0 + measurements: Dict[CalibrationPosition, SquareMeasurement] = field(default_factory=dict) + + def add_measurement(self, measurement: SquareMeasurement): + """Ajoute une mesure à la calibration.""" + self.measurements[measurement.position] = measurement + + @property + def has_center(self) -> bool: + return CalibrationPosition.CENTER in self.measurements + + @property + def has_corners(self) -> bool: + corners = {CalibrationPosition.TOP_LEFT, CalibrationPosition.TOP_RIGHT, + CalibrationPosition.BOTTOM_LEFT, CalibrationPosition.BOTTOM_RIGHT} + return corners.issubset(self.measurements.keys()) + + @property + def is_complete(self) -> bool: + """Vérifie si toutes les mesures sont présentes.""" + return len(self.measurements) == 5 + + def get_global_scale(self) -> Tuple[float, float]: + """Calcule l'échelle globale moyenne.""" + if not self.measurements: + return 1.0, 1.0 + + avg_scale_x = sum(m.scale_x for m in self.measurements.values()) / len(self.measurements) + avg_scale_y = sum(m.scale_y for m in self.measurements.values()) / len(self.measurements) + return avg_scale_x, avg_scale_y + + def get_global_skew(self) -> float: + """Calcule le skew global moyen.""" + if not self.measurements: + return 0.0 + + return sum(m.skew_angle for m in self.measurements.values()) / len(self.measurements) + + def get_scale_variation(self) -> Dict[str, Tuple[float, float]]: + """Analyse les variations d'échelle selon la position. + + Retourne les écarts d'échelle par zone. + """ + if not self.has_corners: + return {} + + variations = {} + center = self.measurements.get(CalibrationPosition.CENTER) + + for pos in [CalibrationPosition.TOP_LEFT, CalibrationPosition.TOP_RIGHT, + CalibrationPosition.BOTTOM_LEFT, CalibrationPosition.BOTTOM_RIGHT]: + corner = self.measurements[pos] + if center: + diff_x = corner.scale_x - center.scale_x + diff_y = corner.scale_y - center.scale_y + variations[pos.value] = (diff_x, diff_y) + + return variations + + def detect_non_linearity(self) -> Dict[str, float]: + """Détecte les problèmes de linéarité sur les axes. + + Compare les échelles gauche/droite et haut/bas. + """ + result = { + 'x_asymmetry': 0.0, # Différence gauche/droite + 'y_asymmetry': 0.0, # Différence haut/bas + 'x_variance': 0.0, # Variance sur X + 'y_variance': 0.0, # Variance sur Y + } + + if not self.has_corners: + return result + + tl = self.measurements[CalibrationPosition.TOP_LEFT] + tr = self.measurements[CalibrationPosition.TOP_RIGHT] + bl = self.measurements[CalibrationPosition.BOTTOM_LEFT] + br = self.measurements[CalibrationPosition.BOTTOM_RIGHT] + + # Asymétrie X: différence entre côté gauche et droit + left_avg_x = (tl.scale_x + bl.scale_x) / 2 + right_avg_x = (tr.scale_x + br.scale_x) / 2 + result['x_asymmetry'] = right_avg_x - left_avg_x + + # Asymétrie Y: différence entre haut et bas + top_avg_y = (tl.scale_y + tr.scale_y) / 2 + bottom_avg_y = (bl.scale_y + br.scale_y) / 2 + result['y_asymmetry'] = bottom_avg_y - top_avg_y + + # Variance sur X + all_x = [tl.scale_x, tr.scale_x, bl.scale_x, br.scale_x] + mean_x = sum(all_x) / len(all_x) + result['x_variance'] = sum((x - mean_x) ** 2 for x in all_x) / len(all_x) + + # Variance sur Y + all_y = [tl.scale_y, tr.scale_y, bl.scale_y, br.scale_y] + mean_y = sum(all_y) / len(all_y) + result['y_variance'] = sum((y - mean_y) ** 2 for y in all_y) / len(all_y) + + return result + + def get_corrections(self) -> Dict: + """Génère les corrections recommandées.""" + scale_x, scale_y = self.get_global_scale() + skew = self.get_global_skew() + nonlin = self.detect_non_linearity() + + corrections = { + 'scale_x': scale_x, + 'scale_y': scale_y, + 'skew_xy': skew, + 'offset_x': 0.0, + 'offset_y': 0.0, + 'rotation': 0.0, + 'issues': [], + } + + # Détecter les problèmes + if abs(nonlin['x_asymmetry']) > 0.005: + side = "droit plus long" if nonlin['x_asymmetry'] > 0 else "gauche plus long" + corrections['issues'].append( + f"Asymétrie X détectée ({side}): {abs(nonlin['x_asymmetry']*100):.2f}%" + ) + + if abs(nonlin['y_asymmetry']) > 0.005: + side = "bas plus long" if nonlin['y_asymmetry'] > 0 else "haut plus long" + corrections['issues'].append( + f"Asymétrie Y détectée ({side}): {abs(nonlin['y_asymmetry']*100):.2f}%" + ) + + if nonlin['x_variance'] > 0.0001: + corrections['issues'].append( + f"Variance X élevée: mesures non uniformes sur le plateau" + ) + + if nonlin['y_variance'] > 0.0001: + corrections['issues'].append( + f"Variance Y élevée: mesures non uniformes sur le plateau" + ) + + if abs(skew) > 0.3: + corrections['issues'].append( + f"Skew important: {skew:.2f}° (axes non perpendiculaires)" + ) + + return corrections + + def generate_report(self) -> str: + """Génère un rapport de calibration lisible.""" + lines = ["=" * 50] + lines.append("RAPPORT DE CALIBRATION") + lines.append("=" * 50) + lines.append(f"\nPlateau: {self.bed_width} x {self.bed_depth} mm") + lines.append(f"Taille des carrés: {self.square_size} mm") + lines.append(f"Points mesurés: {len(self.measurements)}/5") + + if self.measurements: + lines.append("\n--- Mesures par position ---") + for pos, m in self.measurements.items(): + lines.append(f"\n{pos.value.upper().replace('_', ' ')}:") + lines.append(f" X: {m.measured_x:.2f} mm (scale: {m.scale_x:.4f})") + lines.append(f" Y: {m.measured_y:.2f} mm (scale: {m.scale_y:.4f})") + lines.append(f" Diag: {m.measured_diagonal:.2f} mm (skew: {m.skew_angle:.2f}°)") + + corrections = self.get_corrections() + lines.append("\n--- Corrections calculées ---") + lines.append(f" Scale X: {corrections['scale_x']:.6f}") + lines.append(f" Scale Y: {corrections['scale_y']:.6f}") + lines.append(f" Skew: {corrections['skew_xy']:.3f}°") + + if corrections['issues']: + lines.append("\n--- Problèmes détectés ---") + for issue in corrections['issues']: + lines.append(f" ⚠ {issue}") + else: + lines.append("\n✓ Aucun problème majeur détecté") + + lines.append("\n" + "=" * 50) + return "\n".join(lines) + + +class AdvancedCalibrationGCode: + """Générateur de G-code de calibration avancé.""" + + def __init__(self, bed_width: float = 220.0, bed_depth: float = 220.0, + square_size: float = 30.0, margin: float = 15.0): + self.bed_width = bed_width + self.bed_depth = bed_depth + self.square_size = square_size + self.margin = margin + + def get_positions(self) -> Dict[CalibrationPosition, Tuple[float, float]]: + """Retourne les positions centrales de chaque carré.""" + half = self.square_size / 2 + + positions = { + CalibrationPosition.CENTER: (self.bed_width / 2, self.bed_depth / 2), + } + + # Coins avec marge + positions[CalibrationPosition.BOTTOM_LEFT] = (self.margin + half, self.margin + half) + positions[CalibrationPosition.BOTTOM_RIGHT] = (self.bed_width - self.margin - half, self.margin + half) + positions[CalibrationPosition.TOP_LEFT] = (self.margin + half, self.bed_depth - self.margin - half) + positions[CalibrationPosition.TOP_RIGHT] = (self.bed_width - self.margin - half, self.bed_depth - self.margin - half) + + return positions + + def generate(self, include_labels: bool = True) -> str: + """Génère le G-code de calibration complet.""" + positions = self.get_positions() + + lines = [ + "; " + "=" * 50, + "; G-CODE DE CALIBRATION AVANCÉ", + "; " + "=" * 50, + f"; Plateau: {self.bed_width} x {self.bed_depth} mm", + f"; Carrés de test: {self.square_size} x {self.square_size} mm", + ";", + "; Instructions:", + "; 1. Imprimez ce fichier", + "; 2. Mesurez CHAQUE carré (X, Y, diagonale)", + "; 3. Notez les valeurs pour chaque position", + "; 4. Utilisez 'imp3d-corrector calibrate-advanced' pour entrer les mesures", + ";", + "; Positions:", + "; TL = Top Left (haut gauche)", + "; TR = Top Right (haut droit)", + "; BL = Bottom Left (bas gauche)", + "; BR = Bottom Right (bas droit)", + "; C = Center (centre)", + "; " + "=" * 50, + "", + "G21 ; Unités en millimètres", + "G90 ; Positionnement absolu", + "M82 ; Extrusion absolue", + "G28 ; Home", + "G1 Z10 F3000 ; Monter la buse", + "", + ] + + extrusion = 0 + order = [ + CalibrationPosition.BOTTOM_LEFT, + CalibrationPosition.BOTTOM_RIGHT, + CalibrationPosition.CENTER, + CalibrationPosition.TOP_LEFT, + CalibrationPosition.TOP_RIGHT, + ] + + for i, pos in enumerate(order): + cx, cy = positions[pos] + half = self.square_size / 2 + + label = { + CalibrationPosition.BOTTOM_LEFT: "BL", + CalibrationPosition.BOTTOM_RIGHT: "BR", + CalibrationPosition.CENTER: "C", + CalibrationPosition.TOP_LEFT: "TL", + CalibrationPosition.TOP_RIGHT: "TR", + }[pos] + + lines.append(f"; --- Carré {label} ({pos.value}) ---") + lines.append(f"; Position centre: X={cx:.1f} Y={cy:.1f}") + + # Aller au point de départ + lines.append(f"G1 X{cx - half:.3f} Y{cy - half:.3f} F6000") + lines.append("G1 Z0.3 F3000") + + # Dessiner le carré + extrusion += 5 + lines.append(f"G1 X{cx + half:.3f} Y{cy - half:.3f} E{extrusion:.1f} F1500") + extrusion += 5 + lines.append(f"G1 X{cx + half:.3f} Y{cy + half:.3f} E{extrusion:.1f} F1500") + extrusion += 5 + lines.append(f"G1 X{cx - half:.3f} Y{cy + half:.3f} E{extrusion:.1f} F1500") + extrusion += 5 + lines.append(f"G1 X{cx - half:.3f} Y{cy - half:.3f} E{extrusion:.1f} F1500") + + lines.append("G1 Z2 F3000") + lines.append("") + + # Terminer + lines.append("; --- Fin ---") + lines.append("G1 Z10 F3000") + lines.append(f"G1 X0 Y{self.bed_depth} F6000") + lines.append("M84 ; Désactiver les moteurs") + lines.append("") + lines.append("; " + "=" * 50) + lines.append("; MESURES À PRENDRE:") + lines.append("; Pour chaque carré, mesurez:") + lines.append(f"; - Côté X (attendu: {self.square_size} mm)") + lines.append(f"; - Côté Y (attendu: {self.square_size} mm)") + lines.append(f"; - Diagonale (attendu: {self.square_size * 1.414:.2f} mm)") + lines.append("; " + "=" * 50) + + return "\n".join(lines) diff --git a/imp3d_corrector/core/corrector.py b/imp3d_corrector/core/corrector.py new file mode 100644 index 0000000..8183298 --- /dev/null +++ b/imp3d_corrector/core/corrector.py @@ -0,0 +1,273 @@ +"""Correcteur de plan XYZ pour imprimantes 3D. + +Ce module applique les corrections de: +- Décalage des axes X/Y (skew) +- Erreurs de longueur sur les bords +- Rotation du plateau +- Mise à l'échelle +""" + +import math +from dataclasses import dataclass, field +from typing import Optional, Tuple +import yaml + + +@dataclass +class BedCalibration: + """Configuration de calibration du plateau. + + Mesures pour détecter les défauts: + - skew_xy: Angle de désalignement X-Y en degrés (positif = Y tourne vers X) + - offset_x: Décalage de l'origine sur X (mm) + - offset_y: Décalage de l'origine sur Y (mm) + - scale_x: Facteur d'échelle X (1.0 = pas de changement) + - scale_y: Facteur d'échelle Y (1.0 = pas de changement) + - rotation: Rotation du plateau en degrés + """ + name: str = "default" + skew_xy: float = 0.0 # degrés + offset_x: float = 0.0 # mm + offset_y: float = 0.0 # mm + scale_x: float = 1.0 + scale_y: float = 1.0 + rotation: float = 0.0 # degrés + + # Dimensions du plateau + bed_width: float = 220.0 # mm (X) + bed_depth: float = 220.0 # mm (Y) + + # Points de calibration (optionnel, pour calibration avancée) + calibration_points: list = field(default_factory=list) + + def __post_init__(self): + """Convertit les angles en radians pour les calculs.""" + self._skew_rad = math.radians(self.skew_xy) + self._rotation_rad = math.radians(self.rotation) + + @classmethod + def from_measurements( + cls, + name: str, + expected_square_size: float, + measured_x: float, + measured_y: float, + measured_diagonal: float, + bed_width: float = 220.0, + bed_depth: float = 220.0 + ) -> 'BedCalibration': + """Crée une calibration à partir de mesures d'un carré de test. + + Args: + name: Nom du profil + expected_square_size: Taille attendue du carré (mm) + measured_x: Taille mesurée sur X (mm) + measured_y: Taille mesurée sur Y (mm) + measured_diagonal: Diagonale mesurée (mm) + bed_width: Largeur du plateau (mm) + bed_depth: Profondeur du plateau (mm) + """ + # Calcul du facteur d'échelle + scale_x = expected_square_size / measured_x if measured_x > 0 else 1.0 + scale_y = expected_square_size / measured_y if measured_y > 0 else 1.0 + + # Calcul du skew à partir de la diagonale + # Pour un carré parfait: diag = sqrt(2) * côté + expected_diagonal = expected_square_size * math.sqrt(2) + + # Si la diagonale ne correspond pas, il y a du skew + # Formule simplifiée: skew ≈ acos(diag_mesurée / (2 * côté_moyen)) - 45° + avg_side = (measured_x + measured_y) / 2 + if avg_side > 0 and measured_diagonal > 0: + try: + skew_angle = math.degrees(math.acos(measured_diagonal / (2 * avg_side))) - 45 + except ValueError: + skew_angle = 0.0 + else: + skew_angle = 0.0 + + return cls( + name=name, + skew_xy=skew_angle, + scale_x=scale_x, + scale_y=scale_y, + bed_width=bed_width, + bed_depth=bed_depth + ) + + @classmethod + def from_yaml(cls, filepath: str) -> 'BedCalibration': + """Charge une calibration depuis un fichier YAML.""" + with open(filepath, 'r') as f: + data = yaml.safe_load(f) + return cls(**data) + + def to_yaml(self, filepath: str): + """Sauvegarde la calibration dans un fichier YAML.""" + data = { + 'name': self.name, + 'skew_xy': self.skew_xy, + 'offset_x': self.offset_x, + 'offset_y': self.offset_y, + 'scale_x': self.scale_x, + 'scale_y': self.scale_y, + 'rotation': self.rotation, + 'bed_width': self.bed_width, + 'bed_depth': self.bed_depth, + 'calibration_points': self.calibration_points + } + with open(filepath, 'w') as f: + yaml.dump(data, f, default_flow_style=False) + + +class XYZCorrector: + """Applique les corrections XYZ à un fichier G-code.""" + + def __init__(self, calibration: BedCalibration): + self.calibration = calibration + self._current_pos = {'X': 0.0, 'Y': 0.0, 'Z': 0.0} + + def correct_point(self, x: float, y: float, z: float) -> Tuple[float, float, float]: + """Applique les corrections à un point 3D. + + Ordre des transformations: + 1. Appliquer le décalage d'origine + 2. Appliquer la mise à l'échelle + 3. Appliquer le skew + 4. Appliquer la rotation + """ + # 1. Décalage d'origine + x -= self.calibration.offset_x + y -= self.calibration.offset_y + + # 2. Mise à l'échelle + x *= self.calibration.scale_x + y *= self.calibration.scale_y + + # 3. Correction du skew (cisaillement X-Y) + # Le skew fait que l'axe Y n'est pas perpendiculaire à X + skew = self.calibration._skew_rad + x_new = x - y * math.tan(skew) + y_new = y + + # 4. Rotation + rot = self.calibration._rotation_rad + cos_r = math.cos(rot) + sin_r = math.sin(rot) + x_final = x_new * cos_r - y_new * sin_r + y_final = x_new * sin_r + y_new * cos_r + + return x_final, y_final, z + + def correct_gcode_line(self, line: str) -> str: + """Corrige une ligne G-code si elle contient des coordonnées.""" + import re + + stripped = line.strip() + + # Préserver les commentaires et lignes vides + if not stripped or stripped.startswith(';'): + return line + + # Commandes de mouvement + if not any(stripped.startswith(cmd) for cmd in ['G0', 'G1', 'G2', 'G3']): + return line + + # Extraire les coordonnées + def replace_coord(match): + axis = match.group(1) + value = float(match.group(2)) + + # Stocker la valeur actuelle + if axis in 'XYZ': + self._current_pos[axis] = value + + return match.group(0) # Temporaire, on va recalculer + + # Calculer la position corrigée + x_match = re.search(r'X([-+]?[0-9]*\.?[0-9]+)', stripped, re.IGNORECASE) + y_match = re.search(r'Y([-+]?[0-9]*\.?[0-9]+)', stripped, re.IGNORECASE) + z_match = re.search(r'Z([-+]?[0-9]*\.?[0-9]+)', stripped, re.IGNORECASE) + + # Mettre à jour la position courante + if x_match: + self._current_pos['X'] = float(x_match.group(1)) + if y_match: + self._current_pos['Y'] = float(y_match.group(1)) + if z_match: + self._current_pos['Z'] = float(z_match.group(1)) + + # Appliquer la correction + new_x, new_y, new_z = self.correct_point( + self._current_pos['X'], + self._current_pos['Y'], + self._current_pos['Z'] + ) + + # Reconstruire la ligne + result = stripped + + if x_match: + result = re.sub(r'X[-+]?[0-9]*\.?[0-9]+', f'X{new_x:.3f}', result, flags=re.IGNORECASE) + if y_match: + result = re.sub(r'Y[-+]?[0-9]*\.?[0-9]+', f'Y{new_y:.3f}', result, flags=re.IGNORECASE) + if z_match: + result = re.sub(r'Z[-+]?[0-9]*\.?[0-9]+', f'Z{new_z:.3f}', result, flags=re.IGNORECASE) + + # Préserver le formatage original (indentation, etc.) + if line.startswith(' ') or line.startswith('\t'): + return line[:len(line) - len(line.lstrip())] + result + + return result + + def correct_gcode_file(self, input_path: str, output_path: str): + """Corrige un fichier G-code complet.""" + with open(input_path, 'r') as infile, open(output_path, 'w') as outfile: + for line in infile: + corrected = self.correct_gcode_line(line) + outfile.write(corrected if corrected.endswith('\n') else corrected + '\n') + + def generate_correction_gcode(self) -> str: + """Génère un G-code de calibration pour vérifier les corrections. + + Ce G-code dessine un carré de test aux dimensions spécifiées. + """ + bed_w = self.calibration.bed_width + bed_d = self.calibration.bed_depth + size = min(bed_w, bed_d) * 0.5 # Carré à 50% du plateau + + # Centrer le carré + cx, cy = bed_w / 2, bed_d / 2 + half = size / 2 + + gcode = f"""; G-code de calibration généré pour: {self.calibration.name} +; Dimensions du plateau: {bed_w} x {bed_d} mm +; Carré de test: {size} x {size} mm + +G21 ; Unités en millimètres +G90 ; Positionnement absolu +M82 ; Extrusion absolue +G28 ; Home + +; Monter la buse +G1 Z10 F3000 + +; Aller au point de départ +G1 X{cx - half:.3f} Y{cy - half:.3f} F6000 + +; Dessiner le carré +G1 Z0.3 F3000 +G1 X{cx + half:.3f} Y{cy - half:.3f} E10 F1500 +G1 X{cx + half:.3f} Y{cy + half:.3f} E20 F1500 +G1 X{cx - half:.3f} Y{cy + half:.3f} E30 F1500 +G1 X{cx - half:.3f} Y{cy - half:.3f} E40 F1500 + +; Terminer +G1 Z10 F3000 +G1 X0 Y{bed_d} F6000 +M84 ; Désactiver les moteurs + +; Mesurez les côtés et la diagonale de ce carré +; Entrez les valeurs dans la configuration +""" + return gcode diff --git a/imp3d_corrector/core/gcode_parser.py b/imp3d_corrector/core/gcode_parser.py new file mode 100644 index 0000000..d79d06a --- /dev/null +++ b/imp3d_corrector/core/gcode_parser.py @@ -0,0 +1,101 @@ +"""Parser G-code pour extraire les commandes de mouvement.""" + +import re +from dataclasses import dataclass +from typing import Optional, List, Generator + + +@dataclass +class GCodeMove: + """Représente une commande de mouvement G-code.""" + command: str + x: Optional[float] = None + y: Optional[float] = None + z: Optional[float] = None + e: Optional[float] = None + f: Optional[float] = None + original_line: str = "" + line_number: int = 0 + is_relative: bool = False + + +class GCodeParser: + """Parse les fichiers G-code et extrait les commandes de mouvement.""" + + MOVE_COMMANDS = {'G0', 'G1', 'G2', 'G3'} + + def __init__(self): + self.absolute_mode = True # G90 (absolu) vs G91 (relatif) + self.absolute_extrusion = True # M82 vs M83 + self.current_pos = {'X': 0.0, 'Y': 0.0, 'Z': 0.0, 'E': 0.0} + + def parse_line(self, line: str, line_number: int = 0) -> Optional[GCodeMove]: + """Parse une ligne G-code et retourne un GCodeMove si c'est un mouvement.""" + original = line + line = line.strip() + + # Ignorer les commentaires et lignes vides + if not line or line.startswith(';'): + return None + + # Retirer les commentaires en fin de ligne + if ';' in line: + line = line.split(';')[0].strip() + + # Détecter le mode de positionnement + if line == 'G90': + self.absolute_mode = True + return None + elif line == 'G91': + self.absolute_mode = False + return None + elif line == 'M82': + self.absolute_extrusion = True + return None + elif line == 'M83': + self.absolute_extrusion = False + return None + + # Extraire la commande + parts = line.split() + if not parts: + return None + + cmd = parts[0].upper() + + # Vérifier si c'est une commande de mouvement + if cmd not in self.MOVE_COMMANDS: + return None + + # Parser les paramètres + move = GCodeMove( + command=cmd, + original_line=original, + line_number=line_number, + is_relative=not self.absolute_mode + ) + + param_pattern = re.compile(r'([XYZEF])([-+]?[0-9]*\.?[0-9]+)') + + for part in parts[1:]: + match = param_pattern.match(part.upper()) + if match: + param, value = match.groups() + setattr(move, param.lower(), float(value)) + + return move + + def parse_file(self, filepath: str) -> Generator[GCodeMove, None, None]: + """Parse un fichier G-code et génère les mouvements.""" + with open(filepath, 'r') as f: + for line_num, line in enumerate(f, 1): + move = self.parse_line(line, line_num) + if move: + yield move + + def parse_lines(self, lines: List[str]) -> Generator[GCodeMove, None, None]: + """Parse une liste de lignes G-code.""" + for line_num, line in enumerate(lines, 1): + move = self.parse_line(line, line_num) + if move: + yield move diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..8723fdb --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,42 @@ +[build-system] +requires = ["setuptools>=61.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "imp3d-corrector" +version = "0.1.0" +description = "Correction de plan XYZ pour imprimantes 3D" +readme = "README.md" +requires-python = ">=3.8" +license = {text = "MIT"} +authors = [ + {name = "muyue"} +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +dependencies = [ + "PyYAML>=6.0", +] + +[project.scripts] +imp3d-corrector = "imp3d_corrector.cli:main" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "black>=23.0", + "flake8>=6.0", +] + +[tool.setuptools.packages.find] +where = ["."] +include = ["imp3d_corrector*"]