#!/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
__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/02/14 16:27" # 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
import FreeCAD, FreeCADGui, Mesh, MeshPart
from PySide import QtGui, QtCore
from typing import Dict, Optional
import gc
import tempfile
import urllib.request
# -------------------------
# 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 = 3
DEBUG = False
DEBUGPIPE = False
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 QidiStudio",
"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 impr. :",
"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 :",
"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 utilisateurs 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 est valide.",
"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 QidiStudio",
"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:",
"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 is valid.",
"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:",
}
}
# 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 and not os.path.exists(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
##############################################################
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")):
return "qidistudio"
# # Prusa / SuperSlicer (multi-objets standard Slic3r)
if any(x in p for x in (
"prusa",
"super",
"superslicer",
"anycubic",
"flashprint",
"ideamaker",
"raise3d",
)):
#return "slic3r"
return "qidislicer"
# Cura
if "cura" in p:
#return "cura"
return "qidislicer"
# return "slic3r"
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",
# Prusa-like (Slic3r family)
"prusaslicer": "slic3r",
#"superslicer": "slic3r",
"anycubic": "slic3r", # Anycubic Slicer = fork Prusa
"flashprint": "slic3r", # FlashPrint 5 = structure Prusa-like
"ideamaker": "slic3r", # IdeaMaker = XML proche Prusa
"raise3d": "slic3r", # Raise3D Slicer = IdeaMaker rebadgé
# FreeCAD
"freecad": "freecad",
}
# Recherche automatique
for key, value in MAPPINGS.items():
if key in lower:
return value
# Sinon : par défaut : idem prusa
return "slic3r"
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)
def clean_multi_object_style(src_3mf, dst_3mf):
"""
Nettoyage CHIRURGICAL d'un 3MF QidiStudio.
Version corrigée + PATCH chemins :
- Normalisation des chemins (strip + replace)
- Détection correcte du parent (object id="X")
- Suppression des objets excédentaires
- Suppression des items excédentaires
- Suppression des relations excédentaires
- Nettoyage complet de Metadata/model_settings.config
"""
if DEBUG: print("\n=== clean_multi_object_style ===")
if not os.path.exists(src_3mf):
if DEBUG: print(' dans clean_multi_object_style fichier src_3mf inexistant', src_3mf)
rels_path = "3D/_rels/3dmodel.model.rels"
config_path = "Metadata/model_settings.config"
model_path = "3D/3dmodel.model"
with zipfile.ZipFile(src_3mf, "r") as zin:
# --- 1) Détection des fichiers objets ---
object_models = [
n.strip().replace("\\", "/")
for n in zin.namelist()
if n.startswith("3D/Objects/") and n.endswith(".model")
]
if not object_models:
# Cas Cura / Prusa / QidiSlicer / FreeCAD : mono-objet standard
if DEBUG: print("ℹ️ Aucun fichier 3D/Objects/*.model → mono-objet standard")
shutil.copy(src_3mf, dst_3mf)
return None, "3D/3dmodel.model"
keep_model = object_models[0]
to_delete = object_models[1:]
# --- 2) Nettoyage CHIRURGICAL du 3dmodel.model ---
model_txt = zin.read(model_path).decode("utf-8")
parent_pattern = (
rf'