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 <crush@charm.land>
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -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
|
||||
125
README.md
Normal file
125
README.md
Normal file
@@ -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 |
|
||||
10
config.example.yaml
Normal file
10
config.example.yaml
Normal file
@@ -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: []
|
||||
3
imp3d_corrector/__init__.py
Normal file
3
imp3d_corrector/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""IMP3D Corrector - Correction de plan XYZ pour imprimantes 3D."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
412
imp3d_corrector/cli.py
Normal file
412
imp3d_corrector/cli.py
Normal file
@@ -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 <nom>")
|
||||
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 <nom>")
|
||||
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 <nom>")
|
||||
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())
|
||||
1
imp3d_corrector/config/__init__.py
Normal file
1
imp3d_corrector/config/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Configuration module for bed profiles."""
|
||||
127
imp3d_corrector/config/profile_manager.py
Normal file
127
imp3d_corrector/config/profile_manager.py
Normal file
@@ -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
|
||||
1
imp3d_corrector/core/__init__.py
Normal file
1
imp3d_corrector/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Core module for XYZ correction."""
|
||||
355
imp3d_corrector/core/advanced_calibration.py
Normal file
355
imp3d_corrector/core/advanced_calibration.py
Normal file
@@ -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)
|
||||
273
imp3d_corrector/core/corrector.py
Normal file
273
imp3d_corrector/core/corrector.py
Normal file
@@ -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
|
||||
101
imp3d_corrector/core/gcode_parser.py
Normal file
101
imp3d_corrector/core/gcode_parser.py
Normal file
@@ -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
|
||||
42
pyproject.toml
Normal file
42
pyproject.toml
Normal file
@@ -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*"]
|
||||
Reference in New Issue
Block a user