#!/usr/bin/env python
# -*- coding: utf-8 -*-
# ===========================================================================
# File name : 3D_Printer_3mf_workflow.FCMacro
# ===========================================================================
# FreeCAD macro exporting smooth 3MF files and preserving all slicer print settings,
# with automatic workflow to your preferred slicer.
#
# This macro automates and enhances the 3D printing workflow from FreeCAD to your slicer by:
# • Generating smooth 3D prints without visible facets
# • Preserving print settings (temperature, supports, speed…) directly within the FreeCAD project
# • Launching external programs to streamline the full pipeline:
# Use https://github.com/2cv001/3D_printer_3mf_workflow/blob/main/3D_Printer_3mf_Workflow_ConfigIni.FCMacro
# to add external programs
#tests effectués:
# lancement 1° 2° sans sauver la première, et fois suivantes en sauvant ok
# mémorisation déplacement de l'objet ok
# mémorisation paramètre impression ok
# copier l'objet pour en avoir 2 sur le plateau les retire sauf 1 ok
# superslicer (famille qidislicer) : ok
# orcaslicer (famille qidistudio) : ok
# qidistudio (famille qidistudio) : ok
# qidislicer (famille qidislicer) : ok
# cura (famille qidislicer) : ok
# anycubic(famille qidistudio) : ok
# Creality (famille qidistudio) : ok
__Name__ = "3D_Printer_3mf_workflow"
__Comment__ = "FreeCAD macro exporting smooth 3MF files and preserving all slicer print settings, with automatic workflow to your preferred slicer."
__Author__ = "2cv001"
__Title__ = "Macro 3D Printer 3mf workflow"
__Date__ = "2026/08/11 08:47" # YYYY/MM/DD
__Version__ = __Date__
__Icon__ = "https://wiki.freecad.org/images/3/32/Macro_3D_Printer_3mf_Workflow.png"
__Wiki__ = "https://wiki.freecad.org/Macro_3D_Printer_3mf_Workflow"
__Github__ = "https://github.com/2cv001/3D_printer_3mf_workflow"
__License__ = "Apache-2.0"
import math, os, zipfile, shutil, subprocess, re, fnmatch, xml.etree.ElementTree as ET
import configparser, ast, platform, time, shlex
import FreeCAD, FreeCADGui, Mesh, MeshPart
from PySide import QtGui, QtCore
from typing import Dict, Optional
import gc
import tempfile
import urllib.request
import shutil
# -------------------------
# Constantes et configuration
# -------------------------
INI_FILE = os.path.join(os.path.dirname(__file__), "3D_Printer_3mf_Workflow.ini")
PLATEAU_X, PLATEAU_Y, PLATEAU_Z = 240, 240, 0
global_origin_x = 0.0
global_origin_y = 0.0
global_origin_z = 0.0
MAX_OLD_BACKUPS = 2 # nb de sauvegarde
DEBUG = False
DEBUGPIPE = False
def is_running_in_flatpak():
return platform.system() == "Linux" and (
bool(os.environ.get("FLATPAK_ID"))
or os.environ.get("container") == "flatpak"
or os.path.exists("/.flatpak-info")
)
def can_use_flatpak_spawn():
return is_running_in_flatpak() and shutil.which("flatpak-spawn") is not None
def is_command_like_slicer_path(slicer_path):
return bool(slicer_path) and slicer_path.strip().startswith(("flatpak run", "snap run"))
def split_command_line(command):
try:
return shlex.split(command.strip())
except Exception:
return None
def build_slicer_launch_command(slicer_path, fc_3mf):
if is_command_like_slicer_path(slicer_path):
cmd = split_command_line(slicer_path)
if not cmd:
raise ValueError(f"Invalid slicer command: {slicer_path}")
if slicer_path.strip().startswith("flatpak run"):
if "--file-forwarding" not in cmd:
cmd = cmd[:2] + ["--file-forwarding"] + cmd[2:]
cmd.extend(["@@", fc_3mf, "@@"])
else:
cmd.append(fc_3mf)
else:
cmd = [slicer_path, fc_3mf]
if can_use_flatpak_spawn():
cmd = ["flatpak-spawn", "--host"] + cmd
return cmd
def host_path_exists(path):
if not path or not can_use_flatpak_spawn():
return False
try:
result = subprocess.run(
["flatpak-spawn", "--host", "test", "-e", path],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
return result.returncode == 0
except Exception:
return False
def is_valid_slicer_entry(slicer_path):
if not slicer_path:
return False
if is_command_like_slicer_path(slicer_path):
return split_command_line(slicer_path) is not None
if can_use_flatpak_spawn():
return host_path_exists(slicer_path) or os.path.exists(slicer_path)
return os.path.exists(slicer_path)
if platform.system() == "Windows":
DEFAULT_SLICER_EXE = r"C:\Program Files\QIDIStudio\qidi-studio.exe"
elif platform.system() == "Linux":
DEFAULT_SLICER_EXE = "/usr/bin/qidi-studio"
else:
DEFAULT_SLICER_EXE = ""
DEFAULT_LINEAR_DEFLECTION = 0.05
DEFAULT_ANGULAR_DEGREES = 3
DEFAULT_ANGULAR_DEFLECTION = DEFAULT_ANGULAR_DEGREES * math.pi / 180.0
EXTRA_COMMANDS = [
# (["calc.exe"], "Ouvrir la calculatrice Windows ?", 0, False),
# (["curl", "-u", "admin:motDePass", "--insecure",
# "http://IpDeLaPriseShelly/rpc/Switch.Set?id=0&on=true"],
# "Allumer la prise Shelly Gen2 ?", 0, False),
]
class Transforms3MF:
def __init__(self):
self.components = []
self.items = []
self.assemble_items = []
class Transform3MF:
def __init__(self, s: str):
vals = list(map(float, s.split()))
# Matrice de rotation 3×3
self.R = [
[vals[0], vals[3], vals[6]],
[vals[1], vals[4], vals[7]],
[vals[2], vals[5], vals[8]],
]
# Translation
self.x = vals[9]
self.y = vals[10]
self.z = vals[11]
# Chaîne brute (utile pour réécriture)
self.raw = s
def __repr__(self):
return f"Transform3MF(x={self.x}, y={self.y}, z={self.z})"
# -------------------------
# Traductions multilingues
# -------------------------
TRANSLATIONS = {
"fr": {
# Titres
"error_title": "Erreur",
"warn_title": "Avertissement",
"info_title": "Info",
# Buttons
# "btn_keep": "Conserver",
"btn_delete": "Ok",
"btn_cancel": "Annuler",
# Options UI
"options_title": "Options d'export vers Slicer",
"keep_positions": "Conserver les positions sur le plateau",
"generate_stl": "En plus du .3mf, générer également un STL",
"launch_slicer": "Lancer le slicer",
"browse": "Parcourir…",
"linear_label": "Précision (LinearDeflection). Entre 0.01 et 10. Haute qualité : 0.01",
"angular_label": "Tolérance angulaire (AngularDeflection) (ex: 3)",
"external_actions": "Actions externes à exécuter avant fermeture :",
"ignore_transform": "Ne pas récupérer la position/rotation du slicer",
"base3mf_label": "Fichier 3MF pour paramètres d'impression :",
"base3mf_dialog_title": "Choisir un fichier 3MF pour les paramètres d'impression",
"base3mf_filter": "Fichiers 3MF (*.3mf)",
"select_3mf_placeholder": "Sélectionner un fichier .3mf",
"hint_base3mf": "Facultatif : permet d’utiliser les paramètres d’imprimante d’un autre fichier 3MF."
"\nPar défaut, le fichier 3MF du projet est utilisé.",
# Messages d'erreur / avertissement
"error_no_doc": "❌ Aucun document ouvert dans FreeCAD.",
"error_no_save": "❌ Le projet n'a pas encore été sauvegardé.",
"warn_no_selection": "⚠️ Aucun objet sélectionné.",
"warn_multi_selection": "⚠️ Sélection multiple non supportée.",
"error_write_ini": "❌ Écriture impossible sur le fichier ini :",
"error_export_3mf": "❌ Erreur lors de l'export 3MF :",
"error_generate_stl": "❌ Erreur lors de la génération du STL :",
"error_create_3mf": "❌ Erreur lors de la création du 3MF final :",
"error_replace_3mf": "❌ Impossible de remplacer le 3MF :",
"error_launch_slicer": "❌ Erreur lors du lancement du slicer :",
"error_external_cmd": "❌ Impossible d'exécuter la commande externe :",
"error_no_geom": "❌ Aucun fichier de géométrie détecté dans le 3MF exporté.",
"error_invalid_slicer": "❌ Le chemin du slicer est invalide :",
"info_launch_command": "Commande de lancement du slicer :\n\n{command}",
"warn_no_geom_vertices": "L'objet sélectionné n'a pas de géométrie 3D utilisable (par ex. un Sketch).",
"warn_incompatible_slicer_dialog":
"Le fichier 3MF d’origine provient de : {source}\n"
"Le slicer cible sélectionné est : {target}\n\n"
"Ces slicers ne sont pas identiques.\n"
"Continuer en effaçant le fichier 3mf, sans récupération des paramètres d'impression ?",
"warn_empty_plate":
"⚠️ Le fichier 3MF du slicer ne contient aucun objet et ne peut servir de base.\n"
"Cela arrive souvent après une suppression dans le slicer.\n\n"
"Que souhaitez-vous faire ?\n\n"
"• Restaurer l'ancien fichier 3MF du slicer\n"
"• Supprimer le 3MF exporté par FreeCAD et repartir comme un nouveau projet\n"
"• Annuler",
"warn_empty_plate_title": "Plateau vide",
"btn_restore_old_3mf": "Restaurer l'ancien 3MF",
"btn_reset_new_project": "Repartir à zéro",
# Infos
"info_done": "✅ Export terminé. Le slicer n'a pas été lancé (option décochée).",
"info_export_fc": "✅ Fichier généré uniquement par FreeCAD :",
"info_export_slicer": "✅ Export avec récupération des paramètres du slicer :",
"reset_all": "Repartir de zéro (ne rien conserver)",
"hint_reset_all": "Supprime le fichier 3MF existant et relance la macro pour repartir d’un fichier vierge.",
"reset_title": "Réinitialisation",
"reset_message": "Le fichier 3MF existant a été supprimé.\nLa macro va redémarrer pour repartir d’un fichier vierge.",
# Aides / tooltips
"hint_linear": "Valeur plus petite = maillage plus fin.\nExemple : 0.01 = haute qualité, mais fichier lourd.\n"
"Exemple : 0.1 = adapté à une imprimante FDM classique.",
"hint_angular": "Tolérance angulaire en degrés.\nPetite valeur = plus de détails, mais fichier plus lourd.",
"hint_positions": "Conserve la position XY sur le plateau.\n⚠️ Le Z est toujours recalculé pour éviter que l'objet soit sous le plateau.",
"no_external_actions": "Pas encore de commandes utilisateur ajoutées.",
"hint_external_actions": "Vous pouvez ajouter ou modifier vos actions personnalisées en cliquant sur le bouton. ⚙️",
"hint_generate_stl": "Génère aussi un fichier STL en plus du 3MF.",
"hint_launch_slicer": (
"Lance automatiquement le slicer après export si le chemin ou la commande est valide. "
"Supporte les commandes comme 'flatpak run org.orcaslicer.OrcaSlicer' ou les chemins directs. "
"Avec FreeCAD en Flatpak sous Linux, le lancement utilise flatpak-spawn vers l'hôte et active "
"le file-forwarding pour qu'une instance déjà ouverte puisse récupérer le 3MF."
),
"ini_file_label": "Fichier ini :",
"hint_ignore_transform": "Si coché, la position XY et la rotation du slicer ne seront pas récupérées.\n"
"L'objet sera replacé droit et recentré sur le plateau.",
"dependency_missing_title": "Macro manquante",
"dependency_missing_msg": "La macro requise {filename} est manquante.",
"dependency_download": "Téléchargez-la ici :",
"dependency_copy_to": "Copiez-la ensuite dans ce dossier :",
"dependency_auto_download": "Télécharger automatiquement",
"dependency_ok": "OK",
"dependency_download_success": "La macro a été téléchargée dans :",
"dependency_download_error": "Impossible de télécharger la macro :",
},
"en": {
# Titles
"error_title": "Error",
"warn_title": "Warning",
"info_title": "Info",
# Buttons
# "btn_keep": "Keep",
"btn_delete": "Ok",
"btn_cancel": "Cancel",
# UI options
"options_title": "Export options for Slicer",
"generate_stl": "Also generate an STL along with the 3MF",
"launch_slicer": "Launch slicer",
"browse": "Browse…",
"linear_label": "Precision (LinearDeflection). Between 0.01 and 10. High quality: 0.01",
"angular_label": "Angular tolerance (AngularDeflection) (e.g. 3)",
"external_actions": "External actions to execute before closing:",
"ignore_transform": "Do not recover slicer position/rotation",
"base3mf_label": "3MF file for print parameters:",
"base3mf_dialog_title": "Select a 3MF file for print parameters",
"base3mf_filter": "3MF files (*.3mf)",
"select_3mf_placeholder": "Select a .3mf file",
"hint_base3mf": "Optional: allows using printer parameters from another 3MF file."
"\nBy default, the project’s 3MF file is used.",
# Error / warning messages
"error_no_doc": "❌ No document open in FreeCAD.",
"error_no_save": "❌ The project has not been saved yet.",
"warn_no_selection": "⚠️ No object selected.",
"warn_multi_selection": "⚠️ Multiple selection not supported.",
"error_write_ini": "❌ Unable to write to the ini file:",
"error_export_3mf": "❌ Error during 3MF export:",
"error_generate_stl": "❌ Error during STL generation:",
"error_create_3mf": "❌ Error while creating the final 3MF:",
"error_replace_3mf": "❌ Unable to replace the 3MF:",
"error_launch_slicer": "❌ Error while launching slicer:",
"error_external_cmd": "❌ Unable to execute external command:",
"error_no_geom": "❌ No geometry file detected in exported 3MF.",
"error_invalid_slicer": "❌ Invalid slicer path:",
"info_launch_command": "Slicer launch command:\n\n{command}",
"warn_no_geom_vertices": "Selected object does not contain usable 3D geometry (e.g. a Sketch).",
"warn_incompatible_slicer_dialog":
"The original 3MF file was created by: {source}\n"
"The selected target slicer is: {target}\n\n"
"These slicers are not identical.\n"
"Continue by deleting the 3MF file, without recovering the print settings?\n\n",
"warn_empty_plate":
"⚠️ The slicer's 3MF file contains no objects and cannot be used as a base.\n"
"This often happens after deleting objects inside the slicer.\n\n"
"What would you like to do?\n\n"
"• Restore the previous 3MF file from the slicer\n"
"• Delete the 3MF exported by FreeCAD and start a new project\n"
"• Cancel",
"warn_empty_plate_title": "Empty build plate",
"btn_restore_old_3mf": "Restore previous 3MF",
"btn_reset_new_project": "Start a new project",
# Infos
"info_done": "✅ Export finished. Slicer was not launched (option unchecked).",
"info_export_fc": "✅ File generated only by FreeCAD:",
"info_export_slicer": "✅ Export with slicer settings recovered:",
"reset_all": "Start from scratch (discard everything)",
"hint_reset_all": "Deletes the existing 3MF file and restarts the macro with a fresh empty project.",
"reset_title": "Reset",
"reset_message": "The existing 3MF file has been deleted.\nThe macro will restart with a fresh empty project.",
# Tooltips
"hint_linear": "Smaller value = finer mesh.\nExample: 0.01 = high quality but heavy file.\n"
"Example: 0.1 = suitable for standard FDM printer.",
"hint_angular": "Angular tolerance in degrees.\nSmaller value = more detail, but heavier file.",
"hint_positions": "Keeps XY position on the build plate.\n⚠️ Z is always recalculated to prevent"
"the object from going below the build plate.",
"no_external_actions": "No user commands added yet.",
"hint_external_actions": "You can add or edit your custom actions by clicking the ⚙️ button.",
"hint_generate_stl": "Also generates an STL file along with the 3MF.",
"hint_launch_slicer": (
"Automatically launches the slicer after export if the path or command is valid. "
"Supports commands like 'flatpak run org.orcaslicer.OrcaSlicer' or direct paths. "
"With FreeCAD running as a Linux Flatpak, launch uses flatpak-spawn on the host and "
"enables file-forwarding so an already running instance can receive the 3MF."
),
"ini_file_label": "INI file:",
"hint_ignore_transform": "If checked, the slicer's XY position and rotation will not be recovered.\n"
"The object will be placed upright and centered on the build plate.",
"dependency_missing_title": "Missing macro",
"dependency_missing_msg": "The required macro {filename} is missing.",
"dependency_download": "Download it here:",
"dependency_copy_to": "Then copy it into this folder:",
"dependency_auto_download": "Download automatically",
"dependency_ok": "OK",
"dependency_download_success": "The macro has been downloaded to:",
"dependency_download_error": "Unable to download the macro:",
},
"de": {
# Titles
"error_title": "Fehler",
"warn_title": "Warnung",
"info_title": "Info",
# Buttons
"btn_delete": "Ok",
"btn_cancel": "Abbrechen",
# UI options
"options_title": "Export Optionen für QidiStudio",
"keep_positions": "Positionen auf dem Druckbett beibehalten",
"generate_stl": "Generiere eine STL zusätzlich zu der 3MF",
"launch_slicer": "Startbefehl Slicer",
"browse": "Durchsuchen…",
"linear_label": "Genauigkeit (LinearDeflection). Zwischen 0.01 und 10. Höchste Qualität: 0.01",
"angular_label": "Winkeltoleranz (AngularDeflection) (z.B. 3)",
"external_actions": "Weitere Aktionen vor dem Schließen ausführen:",
"ignore_transform": "Position/Rotation des Slicers nicht wiederherstellen",
"base3mf_label": "3MF Datei für Druckparameter:",
"base3mf_dialog_title": "Wähle eine 3MF Datei für Druckparameter",
"base3mf_filter": "3MF Dateien (*.3mf)",
"select_3mf_placeholder": "Wähle eine .3mf Datei",
"hint_base3mf": "Optional: Ermöglicht die Nutzung der Druckerparameter einer anderen 3MF Datei."
"\nStandardmäßig wird die projekteigene 3MF Datei genutzt.",
# Error / warning messages
"error_no_doc": "❌ Es ist kein Dokument in FreeCAD geöffnet.",
"error_no_save": "❌ Das Projekt wurde noch nicht gespeichert.",
"warn_no_selection": "⚠️ Kein Objekt ist ausgewählt.",
"warn_multi_selection": "⚠️ Die Auswahl von mehreren Objekten wird nicht unterstützt.",
"error_write_ini": "❌ Die ini Datei konnte nicht beschrieben werden:",
"error_export_3mf": "❌ Während dem 3MF Export ist ein Fehler aufgetreten:",
"error_generate_stl": "❌ Während der STL Generierung ist ein Fehler aufgetreten:",
"error_create_3mf": "❌ Während der finalen 3MF Erstellung ist ein Fehler aufgetreten:",
"error_replace_3mf": "❌ Die 3MF Datei konnte nicht ersetzt werden:",
"error_launch_slicer": "❌ Der Slicer konnte nicht gestartet werden:",
"error_external_cmd": "❌ Ein externer Befehl konnte nicht ausgeführt werden:",
"error_no_geom": "❌ Es wurde keine Geometrie-Datei in der exportierten 3MF Datei gefunden.",
"error_invalid_slicer": "❌ Ungültiger Slicer Pfad:",
"warn_no_geom_vertices": "Das ausgewählte Objekt enthält keine verwendbare 3D-Geometrie (z. B. eine Skizze).",
"warn_incompatible_slicer_dialog":
"Die ursprüngliche 3MF Datei wurde erstellt von: {source}\n"
"Der ausgewählte Ziel-Slicer ist: {target}\n\n"
"Diese Slicer sind nicht identisch.\n"
"Fortfahren und die 3MF Datei löschen, ohne die Druckeinstellungen zu übernehmen?\n\n",
"warn_empty_plate":
"⚠️ Die 3MF Datei des Slicers enthält keine Objekte und kann nicht als Basis verwendet werden.\n"
"Dies passiert häufig nach dem Löschen von Objekten im Slicer.\n\n"
"Was möchten Sie tun?\n\n"
"• Die vorherige 3MF Datei des Slicers wiederherstellen\n"
"• Die von FreeCAD exportierte 3MF Datei löschen und ein neues Projekt starten\n"
"• Abbrechen",
"warn_empty_plate_title": "Leeres Druckbett",
"btn_restore_old_3mf": "Vorherige 3MF wiederherstellen",
"btn_reset_new_project": "Neues Projekt starten",
# Infos
"info_done": "✅ Export abgeschlossen. Der Slicer wurde nicht gestartet (Option nicht aktiviert).",
"info_export_fc": "✅ Datei wurde ausschließlich von FreeCAD erzeugt:",
"info_export_slicer": "✅ Export mit übernommenen Slicer-Einstellungen:",
"reset_all": "Von vorne beginnen (alles verwerfen)",
"hint_reset_all": "Löscht die vorhandene 3MF Datei und startet das Makro mit einem neuen, leeren Projekt neu.",
"reset_title": "Zurücksetzen",
"reset_message": "Die vorhandene 3MF Datei wurde gelöscht.\nDas Makro wird neu gestartet, um mit einem leeren Projekt zu beginnen.",
# Tooltips
"hint_linear": "Kleinerer Wert = feineres Netz.\nBeispiel: 0.01 = hohe Qualität, aber große Datei.\n"
"Beispiel: 0.1 = geeignet für einen gewöhnlichen FDM-Drucker.",
"hint_angular": "Winkeltoleranz in Grad.\nKleinerer Wert = mehr Details, aber größere Datei.",
"hint_positions": "Behält die XY-Position auf dem Druckbett bei.\n⚠️ Die Z-Position wird immer neu berechnet, "
"damit das Objekt nicht unter das Druckbett fällt.",
"no_external_actions": "Es wurden noch keine benutzerdefinierten Befehle hinzugefügt.",
"hint_external_actions": "Sie können Ihre eigenen Aktionen über die Schaltfläche ⚙️ hinzufügen oder bearbeiten.",
"hint_generate_stl": "Erzeugt zusätzlich zur 3MF auch eine STL Datei.",
"hint_launch_slicer": "Startet den Slicer automatisch nach dem Export, sofern der Pfad gültig ist.",
"ini_file_label": "INI Datei:",
"hint_ignore_transform": "Wenn aktiviert, werden die XY-Position und Rotation des Slicers nicht übernommen.\n"
"Das Objekt wird aufrecht und zentriert auf dem Druckbett platziert.",
"dependency_missing_title": "Fehlendes Makro",
"dependency_missing_msg": "Das benötigte Makro {filename} fehlt.",
"dependency_download": "Hier herunterladen:",
"dependency_copy_to": "Anschließend in diesen Ordner kopieren:",
"dependency_auto_download": "Automatisch herunterladen",
"dependency_ok": "OK",
"dependency_download_success": "Das Makro wurde heruntergeladen nach:",
"dependency_download_error": "Das Makro konnte nicht heruntergeladen werden:",
}
}
# Détermine la langue de l’utilisateur à partir de FreeCADGui
def get_user_language():
try:
locale = FreeCADGui.getLocale().lower()
# Normalisation
if locale.startswith("fr") or "french" in locale:
return "fr"
if locale.startswith("en") or "english" in locale:
return "en"
if locale.startswith("de") or "german" in locale:
return "de"
'''
if locale.startswith("es") or "spanish" in locale:
return "es"
if locale.startswith("it") or "italian" in locale:
return "it"
'''
# Fallback
return "en"
except Exception:
return "en"
# Traduit une clé donnée selon la langue utilisateur
def tr(key):
lang = get_user_language()
return TRANSLATIONS.get(lang, TRANSLATIONS["en"]).get(key, key)
##############################################################
# Fonctions utilitaires
##############################################################
# Affiche une boîte de message d’information dans FreeCAD
def show_message(title, message):
QtGui.QMessageBox.information(None, title, message)
# -------------------------
# Rotation des sauvegardes
# -------------------------
def rotate_old_backups(base_name, max_backups):
"""
Sauvegarde le fichier base_name.3mf sous la forme :
base_name.old.YYYY-MM-DD-HH-MM-SS.3mf
Et conserve uniquement les max_backups fichiers les plus récents.
"""
fc_3mf = base_name + ".3mf"
if not os.path.exists(fc_3mf):
return
# 1) Créer un backup daté
timestamp = time.strftime("%Y-%m-%d-%H-%M-%S")
backup_path = f"{base_name}.old.{timestamp}.3mf"
shutil.copy(fc_3mf, backup_path)
# 2) Lister tous les backups datés
dir_name = os.path.dirname(base_name) or "."
prefix = os.path.basename(base_name) + ".old."
suffix = ".3mf"
backups = []
for fname in os.listdir(dir_name):
if fname.startswith(prefix) and fname.endswith(suffix):
full = os.path.join(dir_name, fname)
backups.append(full)
# 3) Trier par date de modification (du plus récent au plus ancien)
backups.sort(key=lambda p: os.path.getmtime(p), reverse=True)
# 4) Supprimer les plus anciens au-delà de max_backups
for old in backups[max_backups:]:
if DEBUG:
print("Suppression ancien backup :", os.path.basename(old))
os.remove(old)
##############################################################
# Fonctions options utilisateurs
##############################################################
# Charge les commandes externes définies dans le fichier ini
def load_extra_commands_from_ini(path=INI_FILE):
cfg = configparser.ConfigParser()
try:
cfg.read(path, encoding="utf-8")
if "extra_commands" in cfg and "EXTRA_COMMANDS" in cfg["extra_commands"]:
return ast.literal_eval(cfg["extra_commands"]["EXTRA_COMMANDS"])
except Exception as e:
print("⚠️ Read error EXTRA_COMMANDS ini:", e)
return []
# Fusion au démarrage : commandes ini avant celles codées en dur
EXTRA_COMMANDS = load_extra_commands_from_ini(INI_FILE) + EXTRA_COMMANDS
def run_macro_if_exists(filename, download_url, raw_url):
"""
Utilise le dictionnaire global `tr` pour les libellés.
"""
current_dir = os.path.dirname(__file__)
macro_path = os.path.join(current_dir, filename)
# --- 1) Fonction interne pour exécuter la macro ---
def execute_macro(path):
with open(path, "r", encoding="utf-8") as f:
code = f.read()
exec(code, globals())
# --- 2) Si la macro existe → exécution immédiate ---
if os.path.isfile(macro_path):
execute_macro(macro_path)
return True
# --- 3) Sinon → fenêtre Qt personnalisée ---
dialog = QtGui.QDialog()
dialog.setWindowTitle(tr("dependency_missing_title"))
dialog.setMinimumWidth(520)
layout = QtGui.QVBoxLayout(dialog)
folder_url = "file:///" + current_dir.replace("\\", "/")
html = f"""
"""
label = QtGui.QLabel()
label.setTextFormat(QtCore.Qt.RichText)
label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction)
label.setOpenExternalLinks(False)
label.setText(html)
layout.addWidget(label)
# --- Bouton Télécharger automatiquement ---
btn_download = QtGui.QPushButton(tr("dependency_auto_download"))
layout.addWidget(btn_download)
# --- Bouton OK ---
btn_ok = QtGui.QPushButton(tr("dependency_ok"))
btn_ok.clicked.connect(dialog.accept)
layout.addWidget(btn_ok)
# --- 4) Gestion des clics sur les liens ---
def on_link_clicked(url):
if hasattr(url, "toString"):
url = url.toString()
else:
url = str(url)
if url.startswith("http"):
QtGui.QDesktopServices.openUrl(QtCore.QUrl(url))
return
if url.startswith("file:///"):
path = url.replace("file:///", "")
if os.path.isdir(path):
if os.name == "nt":
os.startfile(path)
elif sys.platform == "darwin":
subprocess.Popen(["open", path])
else:
subprocess.Popen(["xdg-open", path])
label.linkActivated.connect(on_link_clicked)
# --- 5) Téléchargement automatique + exécution ---
def auto_download():
dest = os.path.join(current_dir, filename)
try:
urllib.request.urlretrieve(raw_url, dest)
QtGui.QMessageBox.information(
dialog,
tr("info_title"),
f"{tr('dependency_download_success')}\n{dest}\n\n→ {tr('dependency_auto_download')}"
)
dialog.accept()
execute_macro(dest)
except Exception as e:
QtGui.QMessageBox.warning(
dialog,
tr("error_title"),
f"{tr('dependency_download_error')}\n{e}"
)
btn_download.clicked.connect(auto_download)
dialog.exec()
return False
# Charge la configuration (slicer, déflections) depuis le fichier ini
def load_config():
cfg = {
"slicer_exe": DEFAULT_SLICER_EXE,
"linear_deflection": DEFAULT_LINEAR_DEFLECTION,
"angular_deflection": DEFAULT_ANGULAR_DEFLECTION,
"ignore_transform": True, # valeur par défaut
}
config = configparser.ConfigParser()
if os.path.exists(INI_FILE):
try:
config.read(INI_FILE)
if "Settings" in config:
s = config["Settings"]
if "slicer_exe" in s:
cfg["slicer_exe"] = s.get("slicer_exe", cfg["slicer_exe"])
if "linear_deflection" in s:
cfg["linear_deflection"] = float(s.get("linear_deflection", cfg["linear_deflection"]))
if "angular_deflection_deg" in s:
deg = float(s.get("angular_deflection_deg", DEFAULT_ANGULAR_DEGREES))
cfg["angular_deflection"] = deg * math.pi / 180.0
if "ignore_transform" in s:
cfg["ignore_transform"] = s.getboolean("ignore_transform", True)
except Exception as e:
print("⚠️ Erreur lecture ini:", e)
return cfg
# Sauvegarde les paramètres (déflections, chemin slicer) dans le fichier ini
def save_settings(linear, angular_rad, slicer_path, ignore_transform):
angular_deg = None
if angular_rad is not None:
angular_deg = angular_rad * 180.0 / math.pi
# Création initiale si le fichier n'existe pas
if not os.path.exists(INI_FILE):
with open(INI_FILE, "w", encoding="utf-8") as f:
f.write(
"[Settings]\n"
f"linear_deflection = {linear}\n"
f"angular_deflection_deg = {angular_deg}\n"
f"ignore_transform = {str(ignore_transform).lower()}\n"
f"slicer_exe = {slicer_path}\n\n"
"[extra_commands]\n"
"EXTRA_COMMANDS = [\n"
" ]\n"
)
return
# Mise à jour ciblée si le fichier existe
with open(INI_FILE, "r", encoding="utf-8") as f:
lines = f.readlines()
new_lines = []
in_settings = False
# Flags pour savoir si les clés existaient
found_linear = False
found_angular = False
found_slicer = False
found_ignore = False
for line in lines:
stripped = line.strip()
if stripped.startswith("[Settings]"):
in_settings = True
new_lines.append(line)
continue
if in_settings and stripped.startswith("[") and not stripped.startswith("[Settings]"):
in_settings = False
if in_settings:
if stripped.startswith("linear_deflection"):
new_lines.append(f"linear_deflection = {linear}\n")
found_linear = True
continue
if stripped.startswith("angular_deflection_deg"):
new_lines.append(f"angular_deflection_deg = {angular_deg}\n")
found_angular = True
continue
if stripped.startswith("slicer_exe"):
new_lines.append(f"slicer_exe = {slicer_path}\n")
found_slicer = True
continue
if stripped.startswith("ignore_transform"):
new_lines.append(f"ignore_transform = {str(ignore_transform).lower()}\n")
found_ignore = True
continue
new_lines.append(line)
# Ajout des clés manquantes juste après [Settings]
for i, line in enumerate(new_lines):
if line.strip().startswith("[Settings]"):
insert_index = i + 1
break
if not found_linear:
new_lines.insert(insert_index, f"linear_deflection = {linear}\n")
insert_index += 1
if not found_angular:
new_lines.insert(insert_index, f"angular_deflection_deg = {angular_deg}\n")
insert_index += 1
if not found_ignore:
new_lines.insert(insert_index, f"ignore_transform = {str(ignore_transform).lower()}\n")
insert_index += 1
if not found_slicer:
new_lines.insert(insert_index, f"slicer_exe = {slicer_path}\n")
insert_index += 1
with open(INI_FILE, "w", encoding="utf-8") as f:
f.writelines(new_lines)
# Affiche une boîte de dialogue pour demander les options d’export à l’utilisateur
def ask_user_options(current_slicer, current_linear, current_angular, ignore_transform_pref):
"""Affiche boîte de dialogue pour options export (positions, params, STL, slicer, deflection)."""
dialog = QtGui.QDialog()
dialog.setWindowTitle(tr("options_title"))
dialog.setMinimumWidth(720)
layout = QtGui.QVBoxLayout()
# Cases principales
chk_stl = QtGui.QCheckBox(tr("generate_stl"))
chk_stl.setToolTip(tr("hint_generate_stl"))
chk_stl.setChecked(False)
# Nouvelle case : repartir de zéro
chk_reset = QtGui.QCheckBox(tr("reset_all"))
chk_reset.setToolTip(tr("hint_reset_all"))
chk_reset.setChecked(False)
layout.addWidget(chk_reset)
layout.addWidget(chk_stl)
# Nouvelle case : ignorer la position/rotation du slicer
chk_ignore_transform = QtGui.QCheckBox(tr("ignore_transform"))
chk_ignore_transform.setToolTip(tr("hint_ignore_transform"))
chk_ignore_transform.setChecked(ignore_transform_pref)
layout.addWidget(chk_ignore_transform)
# Ligne combinée : case "Lancer le slicer" + champ chemin
h_slicer = QtGui.QHBoxLayout()
chk_launch = QtGui.QCheckBox(tr("launch_slicer"))
chk_launch.setFixedWidth(250) # largeur colonne gauche
chk_launch.setToolTip(tr("hint_launch_slicer"))
chk_launch.setChecked(True)
h_slicer.addWidget(chk_launch)
edit_slicer = QtGui.QLineEdit(current_slicer or "")
edit_slicer.setMinimumWidth(420)
h_slicer.addWidget(edit_slicer, stretch=1)
btn_browse = QtGui.QPushButton(tr("browse"))
def browse():
path, _ = QtGui.QFileDialog.getOpenFileName(dialog, tr("browse"))
if path:
edit_slicer.setText(path)
btn_browse.clicked.connect(browse)
h_slicer.addWidget(btn_browse)
layout.addLayout(h_slicer)
# ------------------------------------------------------------
# Calcul du fichier 3MF par défaut
# ------------------------------------------------------------
doc = FreeCAD.ActiveDocument
if doc and doc.FileName:
fcstd_path = doc.FileName
default_fc_3mf = os.path.splitext(fcstd_path)[0] + ".3mf"
else:
default_fc_3mf = "select file .3mf"
# ------------------------------------------------------------
# Ligne "Fichier 3MF de base" alignée EXACTEMENT comme la ligne EXE
# ------------------------------------------------------------
h_base3mf = QtGui.QHBoxLayout()
lbl_base3mf = QtGui.QLabel(tr("base3mf_label"))
lbl_base3mf.setFixedWidth(chk_launch.width())
lbl_base3mf.setToolTip(tr("hint_base3mf"))
h_base3mf.addWidget(lbl_base3mf)
edit_base3mf = QtGui.QLineEdit(default_fc_3mf)
edit_base3mf.setToolTip(tr("hint_base3mf"))
edit_base3mf.setMinimumWidth(420)
h_base3mf.addWidget(edit_base3mf, stretch=1)
btn_base3mf = QtGui.QPushButton(tr("browse"))
def browse_base3mf():
fcstd_dir = os.path.dirname(FreeCAD.ActiveDocument.FileName) \
if FreeCAD.ActiveDocument and FreeCAD.ActiveDocument.FileName else ""
path, _ = QtGui.QFileDialog.getOpenFileName(
dialog,
tr("base3mf_dialog_title"),
fcstd_dir,
tr("base3mf_filter")
)
if path:
edit_base3mf.setText(path)
btn_base3mf.clicked.connect(browse_base3mf)
h_base3mf.addWidget(btn_base3mf)
layout.addLayout(h_base3mf)
# Précision linéaire
h_lin = QtGui.QHBoxLayout()
lbl_lin = QtGui.QLabel(tr("linear_label"))
lbl_lin.setMinimumWidth(420)
h_lin.addWidget(lbl_lin)
spin_lin = QtGui.QDoubleSpinBox()
spin_lin.setToolTip(tr("hint_linear"))
spin_lin.setRange(0.01, 10.0)
spin_lin.setSingleStep(0.01)
spin_lin.setDecimals(2)
spin_lin.setMaximumWidth(80)
try:
spin_lin.setValue(round(float(current_linear), 2))
except Exception:
spin_lin.setValue(DEFAULT_LINEAR_DEFLECTION)
h_lin.addWidget(spin_lin)
lbl_lin_unit = QtGui.QLabel("mm")
h_lin.addWidget(lbl_lin_unit)
h_lin.addStretch()
layout.addLayout(h_lin)
# Tolérance angulaire
h_ang = QtGui.QHBoxLayout()
lbl_ang = QtGui.QLabel(tr("angular_label"))
lbl_ang.setMinimumWidth(420)
h_ang.addWidget(lbl_ang)
spin_ang = QtGui.QDoubleSpinBox()
spin_ang.setToolTip(tr("hint_angular"))
spin_ang.setRange(0.01, 90.0)
spin_ang.setSingleStep(0.01)
spin_ang.setDecimals(2)
spin_ang.setMaximumWidth(80)
try:
deg = float(current_angular) * 180.0 / math.pi
spin_ang.setValue(deg)
except Exception:
spin_ang.setValue(DEFAULT_ANGULAR_DEGREES)
h_ang.addWidget(spin_ang)
lbl_ang_unit = QtGui.QLabel("°")
h_ang.addWidget(lbl_ang_unit)
h_ang.addStretch()
layout.addLayout(h_ang)
# Cases pour les commandes externes
extra_checks = []
if EXTRA_COMMANDS:
lbl_ext = QtGui.QLabel(tr("external_actions"))
lbl_ext.setToolTip(tr("hint_external_actions") )
layout.addWidget(lbl_ext)
for cmd, label, delay, default in EXTRA_COMMANDS:
chk = QtGui.QCheckBox(label)
chk.setChecked(default)
layout.addWidget(chk)
extra_checks.append((chk, cmd, delay))
else:
# 👉 Afficher un libellé même si aucune commande n'est définie
lbl_ext = QtGui.QLabel(tr("no_external_actions"))
lbl_ext.setToolTip(tr("hint_external_actions") )
layout.addWidget(lbl_ext)
# --- Bouton "+" pour installer la macro ConfigIni ---
btn_add_macro = QtGui.QPushButton("⚙️")
btn_add_macro.setFixedWidth(30)
btn_add_macro.setToolTip(tr("hint_external_actions"))
def install_config_macro():
run_macro_if_exists(
"3D_Printer_3mf_Workflow_ConfigIni.FCMacro",
"https://github.com/2cv001/3D_printer_3mf_workflow/blob/main/3D_Printer_3mf_Workflow_ConfigIni.FCMacro",
"https://raw.githubusercontent.com/2cv001/3D_printer_3mf_workflow/main/3D_Printer_3mf_Workflow_ConfigIni.FCMacro"
)
btn_add_macro.clicked.connect(install_config_macro)
layout.addWidget(btn_add_macro)
# Boutons OK/Annuler
btns = QtGui.QDialogButtonBox(QtGui.QDialogButtonBox.Ok | QtGui.QDialogButtonBox.Cancel)
layout.addWidget(btns)
dialog.setLayout(layout)
btns.accepted.connect(dialog.accept)
btns.rejected.connect(dialog.reject)
if dialog.exec() == QtGui.QDialog.Accepted:
linear_val = float(spin_lin.value())
angular_deg = float(spin_ang.value())
angular_rad = angular_deg * math.pi / 180.0
return (
chk_stl.isChecked(),
edit_slicer.text(),
edit_base3mf.text(),
chk_launch.isChecked(),
linear_val,
angular_rad,
chk_reset.isChecked(),
chk_ignore_transform.isChecked(),
[(chk.isChecked(), cmd, delay) for chk, cmd, delay in extra_checks],
)
else:
return (None, None, None, None, None, None, None, None, [])
def get_user_options(cfg):
"""
Affiche la boîte de dialogue utilisateur :
- paramètres FreeCAD
- positions
- STL
- slicer
- commandes externes
Gère :
- annulation
- validation du chemin du slicer
- sauvegarde des préférences
"""
# Préférences actuelles
slicer_path_pref = cfg.get("slicer_exe", DEFAULT_SLICER_EXE)
linear_pref = cfg.get("linear_deflection", DEFAULT_LINEAR_DEFLECTION)
angular_pref = cfg.get("angular_deflection", DEFAULT_ANGULAR_DEFLECTION)
ignore_transform_pref = cfg.get("ignore_transform", True)
while True:
# Appel de la boîte de dialogue utilisateur
(generate_stl,
slicer_path, base3mf_path, launch_slicer,
linear_value, angular_value, reset_all,
ignore_transform, extra_cmds) = ask_user_options(
slicer_path_pref, linear_pref, angular_pref, ignore_transform_pref
)
# Annulation
if slicer_path is None and launch_slicer is None:
# show_message(tr("info_title"), tr("info_cancel"))
return None
# Validation du chemin du slicer
if launch_slicer and slicer_path:
if not is_valid_slicer_entry(slicer_path):
show_message(tr("error_title"), f"{tr('error_invalid_slicer')}\n{slicer_path}")
continue
# Options valides → on sort de la boucle
break
# Sauvegarde des préférences
save_settings(
linear=linear_value,
angular_rad=angular_value,
slicer_path=slicer_path,
ignore_transform = ignore_transform
)
return (
generate_stl,
slicer_path,
base3mf_path,
launch_slicer,
linear_value,
angular_value,
reset_all,
ignore_transform,
extra_cmds
)
##############################################################
# Fonctions analyse extraction 3MF
##############################################################
# détecte le slicer qui a créé le 3MF
def detect_slicer_target_from_path(path):
if not path:
return None
p = path.lower()
# QidiSlicer (mono-objet)
if "qidislicer" in p:
return "qidislicer"
# QidiStudio family (multi-objets Qidi-compatible)
if any(x in p for x in (
"qidistudio",
"orca",
"bambu",
"creality",
"anycubic"
)):
return "qidistudio"
if any(x in p for x in (
"prusa",
)):
return "qidislicer"
if any(x in p for x in (
#"prusa",
"super",
"superslicer",
"flashprint",# non compatible :e ne génère pas de 3mf avaec paramètres imp
"ideamaker", # non compatible :e ne génère pas de 3mf avaec paramètres imp
"raise3d", # non compatible :e ne génère pas de 3mf avaec paramètres imp
#"orca",
)):
return "qidislicer"
# Cura
if "cura" in p:
#return "cura"
return "qidislicer"
# Par défaut
return "qidislicer"
def build_model_xml(vertices, triangles):
"""
Construit un XML 3MF minimal pour 3D/3dmodel.model
sans aucun namespace (version texte brut).
"""
if DEBUGPIPE: print('début build_model_xml---------------------------------')
# --- Construction du bloc ---
verts_xml = []
for (x, y, z) in vertices:
verts_xml.append(f'')
verts_block = "\n".join(verts_xml)
# --- Construction du bloc ---
tris_xml = []
for (v1, v2, v3) in triangles:
tris_xml.append(f'')
tris_block = "\n".join(tris_xml)
# --- Construction complète du fichier ---
xml = f'''
'''
return xml.encode("utf-8")
def extract_transforms_from_3mf(path_3mf: str) -> Transforms3MF:
tr = Transforms3MF()
with zipfile.ZipFile(path_3mf, 'r') as z:
# 1) Lecture du fichier principal 3D/3dmodel.model
with z.open('3D/3dmodel.model') as f:
tree = ET.parse(f)
root = tree.getroot()
#
for comp in root.findall('.//{*}component'):
t = comp.get("transform")
if t is None or t.strip() == "":
tr.components.append(None)
else:
tr.components.append(Transform3MF(t))
#
for item in root.findall('.//{*}item'):
t = item.get("transform")
if t is None or t.strip() == "":
tr.items.append(None)
else:
tr.items.append(Transform3MF(t))
# 2) Lecture de Metadata/model_settings.config (pas de namespace)
if 'Metadata/model_settings.config' in z.namelist():
with z.open('Metadata/model_settings.config') as f:
tree2 = ET.parse(f)
root2 = tree2.getroot()
for a in root2.findall('.//assemble_item'):
t = a.get('transform')
if t is None or t.strip() == "":
tr.assemble_items.append(None)
else:
tr.assemble_items.append(Transform3MF(t))
return tr
def extract_vertices_from_3mf(path_3mf: str):
"""
Extrait tous les vertices du mesh dans 3D/3dmodel.model.
Retourne une liste de tuples (x, y, z).
"""
try:
with zipfile.ZipFile(path_3mf, 'r') as z:
xml = z.read("3D/3dmodel.model").decode("utf-8")
except Exception:
return []
# Cherche les vertices dans
pattern = r']*x="([^"]+)"[^>]*y="([^"]+)"[^>]*z="([^"]+)"'
matches = re.findall(pattern, xml)
vertices = []
for x, y, z in matches:
try:
vertices.append((float(x), float(y), float(z)))
except Exception:
pass
return vertices
def compute_Zmin_global(path_3mf: str, tr: Transforms3MF) -> float:
vertices = extract_vertices_from_3mf(path_3mf)
if not vertices:
return 0.0
Zs = []
for t in tr.items:
vals = list(map(float, t.raw.split()))
R = [
[vals[0], vals[3], vals[6]],
[vals[1], vals[4], vals[7]],
[vals[2], vals[5], vals[8]],
]
Tz = vals[11]
for (x, y, z) in vertices:
zr = R[2][0]*x + R[2][1]*y + R[2][2]*z + Tz
Zs.append(zr)
return min(Zs)
def copy_all_transforms(xml: str, tr: Transforms3MF) -> str:
#
def repl_component(match):
repl_component.i += 1
t = tr.components[repl_component.i - 1]
t_raw = t.raw if t is not None else ""
return f'{match.group(1)}{t_raw}{match.group(3)}'
repl_component.i = 0
xml = re.sub(r'(]*transform=")([^"]+)(")', repl_component, xml)
#
def repl_item(match):
repl_item.i += 1
t = tr.items[repl_item.i - 1]
t_raw = t.raw if t is not None else ""
return f'{match.group(1)}{t_raw}{match.group(3)}'
repl_item.i = 0
xml = re.sub(r'(]*transform=")([^"]+)(")', repl_item, xml)
#
def repl_assemble(match):
repl_assemble.i += 1
t = tr.assemble_items[repl_assemble.i - 1]
t_raw = t.raw if t is not None else ""
return f'{match.group(1)}{t_raw}{match.group(3)}'
repl_assemble.i = 0
xml = re.sub(r'(]*transform=")([^"]+)(")', repl_assemble, xml)
return xml
def apply_saved_transforms_to_3mf(
path_3mf: str,
tr: Transforms3MF,
mesh_vertices_from_freecad,
center=False,
cancel_rotation=False,
x0=None,
y0=None,
):
Zmin_global = compute_Zmin_global(path_3mf, tr)
def modify_item_transforms(xml: str, center: bool, cancel_rotation: bool,
x0, y0, Zmin_global, tr: Transforms3MF,
path_3mf: str,
mesh_vertices_from_freecad
) -> str:
if DEBUGPIPE: print('Début modify_item_transforms')
if DEBUGPIPE:
print("modify_item_transforms reçoit x0 =", x0, "y0 =", y0)
pattern = r'(]*transform=")([^"]+)(")'
matches = list(re.finditer(pattern, xml))
new_xml = xml
for m in matches:
vals = m.group(2).split()
# rotation identité
if cancel_rotation:
vals[0:9] = ["1", "0", "0", "0", "1", "0", "0", "0", "1"]
# centrage XY
if center:
vals[9] = str(x0)
vals[10] = str(y0)
new_t = " ".join(vals)
start, end = m.span()
new_xml = new_xml[:start] + f'{m.group(1)}{new_t}{m.group(3)}' + new_xml[end:]
return new_xml
# ZIP temporaire
tmp_zip = tempfile.NamedTemporaryFile(delete=False)
tmp_zip.close()
with zipfile.ZipFile(path_3mf, 'r') as zin, \
zipfile.ZipFile(tmp_zip.name, 'w', zipfile.ZIP_DEFLATED) as zout:
for info in zin.infolist():
data = zin.read(info.filename)
if info.filename == "3D/3dmodel.model":
xml = data.decode("utf-8")
# 1) Recopie brute
xml = copy_all_transforms(xml, tr)
# 2) Modifications sur
xml = modify_item_transforms(
xml,
center,
cancel_rotation,
x0,
y0,
Zmin_global,
tr,
path_3mf,
mesh_vertices_from_freecad
)
zout.writestr(info.filename, xml.encode("utf-8"))
else:
zout.writestr(info.filename, data)
shutil.move(tmp_zip.name, path_3mf)
def extract_vertices_and_triangles_from_model_xml(xml_bytes):
"""
Extraction TEXTE BRUT des vertices et triangles depuis un .model.
Compatible avec tous les slicers, sans namespace, sans ElementTree.
"""
if DEBUG: print('début extract_vertices_and_triangles_from_model_xml')
text = xml_bytes.decode("utf-8", errors="replace")
# --- Extraction des vertices ---
verts = re.findall(
r']*x="([^"]+)"[^>]*y="([^"]+)"[^>]*z="([^"]+)"',
text
)
vertices = [(float(x), float(y), float(z)) for x, y, z in verts]
# --- Extraction des triangles ---
tris = re.findall(
r']*v1="([^"]+)"[^>]*v2="([^"]+)"[^>]*v3="([^"]+)"',
text
)
triangles = [(int(v1), int(v2), int(v3)) for v1, v2, v3 in tris]
return vertices, triangles
def extract_vertices_from_model_xml(xml_bytes):
"""
Extrait la liste des vertices [(x,y,z), ...] depuis un XML 3MF (3D/3dmodel.model).
"""
root = ET.fromstring(xml_bytes)
# Gestion du namespace éventuel
if "}" in root.tag:
ns_uri = root.tag.split("}")[0].strip("{")
ns = {"m": ns_uri}
vert_elems = root.findall(".//m:vertex", ns)
else:
vert_elems = root.findall(".//vertex")
vertices = []
for v in vert_elems:
x = float(v.get("x"))
y = float(v.get("y"))
z = float(v.get("z"))
vertices.append((x, y, z))
return vertices
def compute_mesh_center(vertices):
xs = [v[0] for v in vertices]
ys = [v[1] for v in vertices]
zs = [v[2] for v in vertices]
return (
(min(xs) + max(xs)) / 2.0,
(min(ys) + max(ys)) / 2.0,
(min(zs) + max(zs)) / 2.0,
)
def compute_translation_to_match_centers(freecad_vertices, vertices_3mf):
if DEBUGPIPE: print ('Début compute_translation_to_match_centers')
global global_origin_x, global_origin_y, global_origin_z
print('global_origin_x', global_origin_x)
center_fc = compute_mesh_center(freecad_vertices)
center_qidi = compute_mesh_center(vertices_3mf)
tx = center_qidi[0] - center_fc[0]
ty = center_qidi[1] - center_fc[1]
tz = center_qidi[2] - center_fc[2]
return (tx, ty, tz)
def apply_translation(vertices, translation):
tx, ty, tz = translation
return [(x + tx, y + ty, z + tz) for (x, y, z) in vertices]
# ------------------------------------------------------------
# Analyse du fichier 3MF (nb objets et slicer)
# ------------------------------------------------------------
def analyse_fichier_3mf(file_path: str) -> Dict[str, Optional[str]]:
"""
Analyse un fichier 3MF et retourne :
- le nombre d'objets 3D (en filtrant les objets vides)
- le logiciel d'export (FreeCAD, PrusaSlicer, QidiSlicer, Cura, etc.)
Orcaslicer est détecté comme bambu car c'est un fork direct
"""
result = {
'nombre_objets': None,
'logiciel': None
}
if not os.path.exists(file_path):
result = {
'nombre_objets': 'nofile',
'logiciel': 'nofile'
}
if DEBUG: print('Dans analyse_fichier_3mf : pas de fichier', os.path.basename(file_path))
return result
# ------------------------------------------------------------
# Normalisation du nom du logiciel
# ------------------------------------------------------------
def normaliser_logiciel(nom: str) -> str:
if not nom:
return "Inconnu"
txt = nom.strip()
lower = txt.lower()
# Table de correspondance
MAPPINGS = {
"qidistudio": "qidistudio",
"bambu": "qidistudio", # Orca détecté comme Bambu
"orca": "qidistudio", # même pipeline que QidiStudio
"creality": "qidistudio", # Creality Print
"qidislicer": "qidislicer",
"cura": "qidislicer",
"superslicer": "qidislicer",
"prusaslicer": "qidislicer",
"prusa-slicer": "qidislicer",
"anycubic": "qidistudio",
"flashprint": "qidislicer", # FlashPrint 5 ne génère pas de 3mf avec paramètres imprimante
"ideamaker": "qidislicer", # IdeaMaker ne génère pas de 3mf avec paramètres imprimante
"raise3d": "qidislicer", # IdeaMaker ne génère pas de 3mf avec paramètres imprimante
# FreeCAD
"freecad": "freecad",
}
# Recherche automatique
for key, value in MAPPINGS.items():
if key in lower:
return value
# Sinon : par défaut : qidislicer
return "qidislicer"
with zipfile.ZipFile(file_path, 'r') as z:
# ------------------------------------------------------------
# 1. Compter les objets 3D (en filtrant les objets vides)
# ------------------------------------------------------------
with z.open('3D/3dmodel.model') as f:
model = ET.parse(f)
ns = {'m': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'}
objects = model.findall('.//m:object', ns)
objets_valides = []
for obj in objects:
has_mesh = obj.find('.//m:mesh', ns) is not None
has_components = obj.find('.//m:components', ns) is not None
if has_mesh or has_components:
objets_valides.append(obj)
# Si aucun objet valide → fallback FreeCAD (mesh unique)
if objets_valides:
result['nombre_objets'] = len(objets_valides)
else:
meshes = model.findall('.//m:mesh', ns)
result['nombre_objets'] = len(meshes) if meshes else 1
# ------------------------------------------------------------
# 2. Détecter le logiciel qui a fourni la géométrie.
# ------------------------------------------------------------
logiciel_trouve = False
metadata_file = '3D/3dmodel.model'
with z.open(metadata_file) as f:
metadata = ET.parse(f)
root = metadata.getroot()
ns = {'m': 'http://schemas.microsoft.com/3dmanufacturing/core/2015/02'}
# --------------------------------------------------------
# 2.bis Détection QIDIStudio via métadonnées "QIDIStudio:*"
# --------------------------------------------------------
if not logiciel_trouve:
metas = root.findall('.//metadata') + root.findall('.//m:metadata', ns)
for meta in metas:
name = meta.get('name', '').lower()
if name.startswith("qidistudio:"):
result['logiciel'] = "qidistudio"
logiciel_trouve = True
break
# --------------------------------------------------------
# 2.b Détection Cura via namespace
# --------------------------------------------------------
if not logiciel_trouve:
for k, v in root.attrib.items():
if "ultimaker.com/xml/cura" in v.lower():
result['logiciel'] = "Ultimaker Cura"
logiciel_trouve = True
break
# --------------------------------------------------------
# 2.c Détection Cura via métadonnées "cura:*"
# --------------------------------------------------------
if not logiciel_trouve:
# 1) métadonnées AVEC namespace
metas = root.findall('.//m:metadata', ns)
# 2) métadonnées SANS namespace
metas += root.findall('.//metadata')
for meta in metas:
name = meta.get('name', '').lower()
# Ignorer le metadata ajouté par la macro
if name == "cura:drop_to_buildplate":
continue
if name.startswith("cura:"):
result['logiciel'] = normaliser_logiciel("cura")
logiciel_trouve = True
break
# --------------------------------------------------------
# 2.d Détection via
# --------------------------------------------------------
if not logiciel_trouve:
creator = metadata.find('.//Creator') or metadata.find('.//Application')
if creator is None:
for meta in root.findall('.//m:metadata', ns):
if meta.get('name', '').lower() == 'application':
creator = meta
break
if creator is None:
for meta in root.findall('.//metadata'):
if meta.get('name', '').lower() == 'application':
creator = meta
break
if creator is not None and creator.text:
result['logiciel'] = normaliser_logiciel(creator.text)
logiciel_trouve = True
# ------------------------------------------------------------
# 4. Détection FreeCAD via texte brut
# ------------------------------------------------------------
if not logiciel_trouve:
with z.open('3D/3dmodel.model') as f:
content = f.read().decode('utf-8', errors='ignore')
if "freecad" in content.lower():
result['logiciel'] = "freecad"
else:
result['logiciel'] = "unknown"
return result
def nameSlicer(File3mf: str):
result = analyse_fichier_3mf(File3mf)
return result['logiciel']
def zip_contains_real_mesh(zip_path):
"""
Retourne True si le 3MF contient au moins un .
Fonction fiable pour Cura, Qidi, Prusa, Orca, etc.
"""
try:
with zipfile.ZipFile(zip_path, "r") as zin:
for name in zin.namelist():
if name.endswith(".model"):
data = zin.read(name)
if b" 0.1 mm
"""
mesh = MeshPart.meshFromShape(
Shape=obj.Shape,
LinearDeflection=float(linear_deflection),
AngularDeflection=float(angular_deflection),
Relative=False
)
mobj = doc.addObject("Mesh::Feature", f"TempMeshSTL_{obj.Name}")
mobj.Mesh = mesh
temp_objs.append(mobj)
doc.recompute()
Mesh.export(temp_objs, stl_path)
finally:
for t in temp_objs:
try:
doc.removeObject(t.Name)
except Exception:
pass
doc.recompute()
# Exporte une sélection en 3MF avec paramètres de tessellation
def export_selection_with_deflection(selection, out_3mf_path,
linear_deflection=DEFAULT_LINEAR_DEFLECTION,
angular_deflection=DEFAULT_ANGULAR_DEFLECTION):
print('Exportation du fichier 3mf par Freecad')
doc = FreeCAD.ActiveDocument
temp_objs = []
try:
for obj in selection:
mesh = MeshPart.meshFromShape(
Shape=obj.Shape,
LinearDeflection=float(linear_deflection),
AngularDeflection=float(angular_deflection),
Relative=False
)
mobj = doc.addObject("Mesh::Feature", f"TempMesh_{obj.Name}")
mobj.Mesh = mesh
temp_objs.append(mobj)
doc.recompute()
Mesh.export(temp_objs, out_3mf_path)
finally:
for t in temp_objs:
try:
doc.removeObject(t.Name)
except Exception:
pass
doc.recompute()
print("Fin d'exportation")
def force_zmax_in_transform(model_data, decalz):
"""
Remplace uniquement la composante Z (12e valeur) du transform des dans 3dmodel.model.
- Conserve X/Y et la matrice de rotation/échelle.
- decalz (hauteur max des vertex) calculé par recenter_vertices.
Si plusieurs existent, on les met tous à jour.
"""
text = model_data.decode("utf-8")
def repl(m):
nums = m.group(1).split()
# transform = 12 nombres : 9 pour la matrice, 3 pour la translation (X, Y, Z)
if len(nums) == 12:
nums[-1] = f"{decalz}"
return 'transform="' + " ".join(nums) + '"'
# Si format inattendu, on ne modifie pas
return m.group(0)
# Met à jour tous les transform="..."; count=0 = sans limite
text = re.sub(r'transform="([^"]+)"', repl, text, count=0)
return text.encode("utf-8")
def correct_item_transform_z(model_data_centered, transform_str):
# Calcule un transform corrigé (tz ajusté) en lisant les vertices du mesh recentré.
if DEBUGPIPE: print('Début correct_item_transform_z')
text = model_data_centered.decode("utf-8", errors="replace")
# --- 1) Extraction des vertices par regex ---
import re
vertex_pattern = r']*x="([^"]+)"[^>]*y="([^"]+)"[^>]*z="([^"]+)"'
verts = re.findall(vertex_pattern, text)
if not verts:
return transform_str # rien à corriger
vertices = [(float(x), float(y), float(z)) for (x, y, z) in verts]
# --- 2) Lire la matrice transform ---
values = [float(x) for x in transform_str.split()]
if DEBUG: print('values', values)
if len(values) != 12:
return transform_str
a, b, c, d, e, f, g, h, i, tx, ty, tz = values
# --- 3) Appliquer la rotation complète ---
z_rotated = []
for (x, y, z) in vertices:
zr = g * x + h * y + i * z
z_rotated.append(zr)
Zmin_rot = min(z_rotated)
Zmin_effectif = Zmin_rot + tz
tz_corrected = tz - Zmin_effectif
if DEBUG:
print(f"[DEBUG ROT] Zmin_rot={Zmin_rot:.3f}, tz={tz:.3f} → tz_corrected={tz_corrected:.3f}")
# --- 4) Retourner le transform corrigé ---
new_values = [a, b, c, d, e, f, g, h, i, tx, ty, tz_corrected]
return " ".join(f"{v:.6f}" for v in new_values)
# ------------------------------------------------------------
# Converti un fichier 3mf type qidistudio multi Objet en
# fichier 3mf mono objet
# ------------------------------------------------------------
def convert_qidistudio_to_singleobject(input_path, output_path=None):
print("DEBUG: Nettoyage QIDI 3MF (mono-objet, texte brut)")
if output_path is None:
output_path = input_path
tmp_output = output_path + ".tmp"
# ------------------------------------------------------------
# Fonction de nettoyage du fichier Metadata/model_settings.config
# ------------------------------------------------------------
def clean_model_settings(settings_xml, correct_wrapper_id):
# 1. Nettoyage des