Files
freecad-robust-mcp-fc111/macros/Multi_Export/MultiExport.FCMacro
T
Sean P. KaneandGitHub f14ab8177d Fix: rename workbench and prepare for release (#27)
* chore: rename workbench to FreecadRobustMCPBridge

* fix: stdio cleanup and bug fix for JSON RPC

* fix: update FreeCAD MCP bridge and tests

* test: Fix GUI Integration tests and other issues

* fix: unit tests in CI and a few other things

* docs: generate GitHub pages site and link to it.

* fix: General code improvements and DRY refactoring

* chore: General cleanup

* chore: small fixes

* docs: cleanup

* docs: fixes

* docs: small corrections

* docs: fix

* test: release check

* bump: workbench v0.6.0

* chore: bump macros to 0.6.0

* docs: Release improvements

* refactor: improve DRYness of code
2026-01-12 09:30:05 -08:00

723 lines
23 KiB
Plaintext

"""FreeCAD Macro: Multi-Format Export.
SPDX-License-Identifier: MIT
Copyright (c) 2025 Sean P. Kane (GitHub: spkane)
Export selected bodies to multiple file formats simultaneously with a
user-friendly dialog for format selection and output configuration.
Requirements:
- FreeCAD 0.21 or later
- One or more objects selected in the 3D view
Usage:
1. Select the object(s) to export
2. Run the macro
3. Select desired export formats (STL, STEP, 3MF selected by default)
4. Choose output directory and base filename
5. Click "Export"
"""
# FreeCAD Addon Manager metadata
__Name__ = "Multi Export"
__Comment__ = "Export selected bodies to multiple file formats (STL, STEP, 3MF, OBJ, IGES, BREP, PLY, AMF) simultaneously"
__Author__ = "Sean P. Kane"
__Version__ = "0.6.0"
__Date__ = "2026-01-12"
__License__ = "MIT"
__Web__ = "https://github.com/spkane/freecad-robust-mcp-and-more"
__Wiki__ = "https://github.com/spkane/freecad-robust-mcp-and-more#readme"
__Icon__ = ""
__Help__ = "Select one or more objects, run the macro, choose export formats and output location, then click Export."
__Status__ = "Beta"
__Requires__ = "FreeCAD 0.21+"
__Communication__ = "https://github.com/spkane/freecad-robust-mcp-and-more/issues"
__Files__ = ""
import os
import FreeCAD as App
import FreeCADGui as Gui
import Mesh
import Part
from PySide import QtGui
class ExportFormat:
"""Represents an export format with its properties."""
def __init__(
self,
name: str,
extension: str,
description: str,
default_enabled: bool = False,
):
"""Initialize an export format.
Args:
name: Display name for the format
extension: File extension (without dot)
description: Brief description of the format
default_enabled: Whether this format is enabled by default
"""
self.name = name
self.extension = extension
self.description = description
self.default_enabled = default_enabled
# Define available export formats
EXPORT_FORMATS = [
ExportFormat(
"STL",
"stl",
"Stereolithography - common for 3D printing",
default_enabled=True,
),
ExportFormat(
"STEP",
"step",
"Standard for Exchange of Product Data - CAD interchange",
default_enabled=True,
),
ExportFormat(
"3MF",
"3mf",
"3D Manufacturing Format - modern 3D printing format",
default_enabled=True,
),
ExportFormat(
"OBJ",
"obj",
"Wavefront OBJ - 3D graphics and game engines",
default_enabled=False,
),
ExportFormat(
"IGES",
"iges",
"Initial Graphics Exchange Specification - legacy CAD format",
default_enabled=False,
),
ExportFormat(
"BREP",
"brep",
"OpenCASCADE native format - preserves exact geometry",
default_enabled=False,
),
ExportFormat(
"PLY",
"ply",
"Polygon File Format - 3D scanning and printing",
default_enabled=False,
),
ExportFormat(
"AMF",
"amf",
"Additive Manufacturing Format - XML-based 3D printing",
default_enabled=False,
),
]
class MultiExportDialog(QtGui.QDialog):
"""Dialog for configuring multi-format export options."""
def __init__(self, selected_objects: list, parent=None):
"""Initialize the export dialog.
Args:
selected_objects: List of FreeCAD objects to export
parent: Parent widget
"""
super(MultiExportDialog, self).__init__(parent)
self.selected_objects = selected_objects
self.format_checkboxes = {}
self.setWindowTitle("Multi-Format Export")
self.setModal(True)
self.setMinimumWidth(500)
self.setup_ui()
self.populate_defaults()
def setup_ui(self):
"""Initialize the user interface."""
layout = QtGui.QVBoxLayout()
# Objects to export section
objects_group = QtGui.QGroupBox("Objects to Export")
objects_layout = QtGui.QVBoxLayout()
self.objects_list = QtGui.QListWidget()
self.objects_list.setMaximumHeight(100)
self.objects_list.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
for obj in self.selected_objects:
item = QtGui.QListWidgetItem(f"{obj.Label} ({_get_object_type(obj)})")
self.objects_list.addItem(item)
objects_layout.addWidget(self.objects_list)
objects_group.setLayout(objects_layout)
layout.addWidget(objects_group)
# Export formats section
formats_group = QtGui.QGroupBox("Export Formats")
formats_layout = QtGui.QGridLayout()
for i, fmt in enumerate(EXPORT_FORMATS):
checkbox = QtGui.QCheckBox(f"{fmt.name} (.{fmt.extension})")
checkbox.setChecked(fmt.default_enabled)
checkbox.setToolTip(fmt.description)
self.format_checkboxes[fmt.extension] = checkbox
row = i // 2
col = i % 2
formats_layout.addWidget(checkbox, row, col)
# Quick selection buttons
button_row = (len(EXPORT_FORMATS) + 1) // 2
select_all_btn = QtGui.QPushButton("Select All")
select_all_btn.clicked.connect(self._select_all_formats)
select_none_btn = QtGui.QPushButton("Select None")
select_none_btn.clicked.connect(self._select_no_formats)
select_defaults_btn = QtGui.QPushButton("Reset Defaults")
select_defaults_btn.clicked.connect(self._reset_default_formats)
btn_layout = QtGui.QHBoxLayout()
btn_layout.addWidget(select_all_btn)
btn_layout.addWidget(select_none_btn)
btn_layout.addWidget(select_defaults_btn)
btn_layout.addStretch()
formats_layout.addLayout(btn_layout, button_row, 0, 1, 2)
formats_group.setLayout(formats_layout)
layout.addWidget(formats_group)
# Output configuration section
output_group = QtGui.QGroupBox("Output Configuration")
output_layout = QtGui.QFormLayout()
# Directory selection
dir_layout = QtGui.QHBoxLayout()
self.directory_edit = QtGui.QLineEdit()
self.directory_edit.setReadOnly(True)
browse_btn = QtGui.QPushButton("Browse...")
browse_btn.clicked.connect(self._browse_directory)
dir_layout.addWidget(self.directory_edit)
dir_layout.addWidget(browse_btn)
output_layout.addRow("Directory:", dir_layout)
# Base filename
self.filename_edit = QtGui.QLineEdit()
self.filename_edit.setToolTip(
"Base filename without extension. Each format will be appended."
)
output_layout.addRow("Base Filename:", self.filename_edit)
# Preview of output files
self.preview_label = QtGui.QLabel("")
self.preview_label.setWordWrap(True)
self.preview_label.setStyleSheet("color: gray; font-size: 11px;")
output_layout.addRow("Will create:", self.preview_label)
# Connect signals for preview updates
self.filename_edit.textChanged.connect(self._update_preview)
self.directory_edit.textChanged.connect(self._update_preview)
for checkbox in self.format_checkboxes.values():
checkbox.stateChanged.connect(self._update_preview)
output_group.setLayout(output_layout)
layout.addWidget(output_group)
# Mesh options section (for STL, OBJ, PLY, 3MF, AMF)
mesh_group = QtGui.QGroupBox("Mesh Options (STL, OBJ, PLY, 3MF, AMF)")
mesh_layout = QtGui.QFormLayout()
self.tolerance_spin = QtGui.QDoubleSpinBox()
self.tolerance_spin.setRange(0.001, 10.0)
self.tolerance_spin.setValue(0.1)
self.tolerance_spin.setDecimals(3)
self.tolerance_spin.setSuffix(" mm")
self.tolerance_spin.setToolTip(
"Lower values create finer meshes but larger files"
)
mesh_layout.addRow("Surface Tolerance:", self.tolerance_spin)
self.deflection_spin = QtGui.QDoubleSpinBox()
self.deflection_spin.setRange(0.001, 10.0)
self.deflection_spin.setValue(0.1)
self.deflection_spin.setDecimals(3)
self.deflection_spin.setSuffix(" mm")
self.deflection_spin.setToolTip("Angular deflection for curved surfaces")
mesh_layout.addRow("Angular Deflection:", self.deflection_spin)
mesh_group.setLayout(mesh_layout)
layout.addWidget(mesh_group)
# Status label
self.status_label = QtGui.QLabel("")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# Progress bar (hidden initially)
self.progress_bar = QtGui.QProgressBar()
self.progress_bar.setVisible(False)
layout.addWidget(self.progress_bar)
# Buttons
button_box = QtGui.QDialogButtonBox()
self.export_btn = button_box.addButton(
"Export", QtGui.QDialogButtonBox.AcceptRole
)
cancel_btn = button_box.addButton(QtGui.QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setLayout(layout)
def populate_defaults(self):
"""Populate default values based on the current document."""
doc = App.ActiveDocument
# Default directory: same as the FreeCAD document
if doc and doc.FileName:
default_dir = os.path.dirname(doc.FileName)
else:
default_dir = os.path.expanduser("~")
self.directory_edit.setText(default_dir)
# Default filename: based on selected objects or document name
if len(self.selected_objects) == 1:
# Single object: use its label
default_name = _sanitize_filename(self.selected_objects[0].Label)
elif doc and doc.FileName:
# Multiple objects: use document name
doc_name = os.path.splitext(os.path.basename(doc.FileName))[0]
default_name = _sanitize_filename(doc_name)
elif doc:
default_name = _sanitize_filename(doc.Label)
else:
default_name = "export"
self.filename_edit.setText(default_name)
self._update_preview()
def _select_all_formats(self):
"""Select all export formats."""
for checkbox in self.format_checkboxes.values():
checkbox.setChecked(True)
def _select_no_formats(self):
"""Deselect all export formats."""
for checkbox in self.format_checkboxes.values():
checkbox.setChecked(False)
def _reset_default_formats(self):
"""Reset to default format selection."""
for fmt in EXPORT_FORMATS:
self.format_checkboxes[fmt.extension].setChecked(fmt.default_enabled)
def _browse_directory(self):
"""Open directory browser dialog."""
current_dir = self.directory_edit.text() or os.path.expanduser("~")
directory = QtGui.QFileDialog.getExistingDirectory(
self,
"Select Export Directory",
current_dir,
QtGui.QFileDialog.ShowDirsOnly,
)
if directory:
self.directory_edit.setText(directory)
def _update_preview(self):
"""Update the preview of files to be created."""
selected_formats = self.get_selected_formats()
base_name = self.filename_edit.text().strip()
if not selected_formats:
self.preview_label.setText("No formats selected")
return
if not base_name:
self.preview_label.setText("Enter a filename")
return
files = [f"{base_name}.{ext}" for ext in selected_formats]
if len(files) > 4:
preview_text = ", ".join(files[:4]) + f", ... (+{len(files) - 4} more)"
else:
preview_text = ", ".join(files)
self.preview_label.setText(preview_text)
def get_selected_formats(self) -> list[str]:
"""Get list of selected format extensions."""
return [
ext
for ext, checkbox in self.format_checkboxes.items()
if checkbox.isChecked()
]
def get_export_parameters(self) -> dict:
"""Get all export parameters from the dialog."""
return {
"directory": self.directory_edit.text(),
"base_filename": self.filename_edit.text().strip(),
"formats": self.get_selected_formats(),
"mesh_tolerance": self.tolerance_spin.value(),
"mesh_deflection": self.deflection_spin.value(),
}
def set_status(self, message: str, is_error: bool = False):
"""Update status message."""
if is_error:
self.status_label.setStyleSheet("color: red;")
else:
self.status_label.setStyleSheet("color: green;")
self.status_label.setText(message)
QtGui.QApplication.processEvents()
def set_progress(self, value: int, maximum: int = 100):
"""Update progress bar."""
if not self.progress_bar.isVisible():
self.progress_bar.setVisible(True)
self.progress_bar.setMaximum(maximum)
self.progress_bar.setValue(value)
QtGui.QApplication.processEvents()
class MultiExporter:
"""Handles exporting objects to multiple formats."""
def __init__(self, objects: list, params: dict):
"""Initialize the exporter.
Args:
objects: List of FreeCAD objects to export
params: Export parameters from the dialog
"""
self.objects = objects
self.params = params
self.exported_files = []
self.errors = []
def export_all(self, progress_callback=None) -> tuple[list[str], list[str]]:
"""Export objects to all selected formats.
Args:
progress_callback: Optional callback for progress updates
Returns:
Tuple of (list of exported files, list of errors)
"""
formats = self.params["formats"]
total_exports = len(formats)
if total_exports == 0:
return [], ["No formats selected"]
for i, fmt in enumerate(formats):
if progress_callback:
progress = int((i / total_exports) * 100)
progress_callback(progress, f"Exporting {fmt.upper()}...")
try:
filepath = self._export_format(fmt)
self.exported_files.append(filepath)
App.Console.PrintMessage(f"Exported: {filepath}\n")
except Exception as e:
error_msg = f"Failed to export {fmt.upper()}: {e!s}"
self.errors.append(error_msg)
App.Console.PrintError(f"{error_msg}\n")
if progress_callback:
progress_callback(100, "Export complete!")
return self.exported_files, self.errors
def _export_format(self, extension: str) -> str:
"""Export to a specific format.
Args:
extension: File extension (format identifier)
Returns:
Path to the exported file
"""
directory = self.params["directory"]
base_name = self.params["base_filename"]
filepath = os.path.join(directory, f"{base_name}.{extension}")
# Get the shape(s) to export
shapes = []
for obj in self.objects:
if hasattr(obj, "Shape"):
shapes.append(obj.Shape)
elif hasattr(obj, "Mesh"):
shapes.append(obj.Mesh)
if not shapes:
raise ValueError("No exportable shapes found")
# Combine shapes if multiple
if len(shapes) == 1:
combined_shape = shapes[0]
else:
# For Part shapes, make a compound
combined_shape = Part.makeCompound(shapes)
# Export based on format
if extension == "stl":
self._export_mesh(combined_shape, filepath, "stl")
elif extension == "step":
self._export_step(combined_shape, filepath)
elif extension == "3mf":
self._export_mesh(combined_shape, filepath, "3mf")
elif extension == "obj":
self._export_mesh(combined_shape, filepath, "obj")
elif extension == "iges":
self._export_iges(combined_shape, filepath)
elif extension == "brep":
self._export_brep(combined_shape, filepath)
elif extension == "ply":
self._export_mesh(combined_shape, filepath, "ply")
elif extension == "amf":
self._export_mesh(combined_shape, filepath, "amf")
else:
raise ValueError(f"Unsupported format: {extension}")
return filepath
def _export_mesh(self, shape, filepath: str, format_type: str):
"""Export shape as a mesh format (STL, OBJ, PLY, 3MF, AMF).
Args:
shape: Part.Shape to export
filepath: Output file path
format_type: Mesh format type
"""
tolerance = self.params["mesh_tolerance"]
deflection = self.params["mesh_deflection"]
# Create mesh from shape
mesh = Mesh.Mesh()
if hasattr(shape, "tessellate"):
# Part.Shape - tessellate it
vertices, facets = shape.tessellate(tolerance)
mesh_data = []
for facet in facets:
triangle = [
vertices[facet[0]],
vertices[facet[1]],
vertices[facet[2]],
]
mesh_data.append(triangle)
mesh.addFacets(mesh_data)
elif hasattr(shape, "Facets"):
# Already a Mesh
mesh = shape
else:
raise ValueError("Cannot create mesh from object")
# Export the mesh
mesh.write(filepath)
def _export_step(self, shape, filepath: str):
"""Export shape as STEP format.
Args:
shape: Part.Shape to export
filepath: Output file path
"""
shape.exportStep(filepath)
def _export_iges(self, shape, filepath: str):
"""Export shape as IGES format.
Args:
shape: Part.Shape to export
filepath: Output file path
"""
shape.exportIges(filepath)
def _export_brep(self, shape, filepath: str):
"""Export shape as BREP format.
Args:
shape: Part.Shape to export
filepath: Output file path
"""
shape.exportBrep(filepath)
def _get_object_type(obj) -> str:
"""Get a human-readable type description for an object."""
if hasattr(obj, "TypeId"):
type_id = obj.TypeId
if "Part::" in type_id:
return type_id.replace("Part::", "")
if "PartDesign::" in type_id:
return type_id.replace("PartDesign::", "")
if "Mesh::" in type_id:
return "Mesh"
return type_id
if hasattr(obj, "Shape"):
return "Shape"
return ""
def _sanitize_filename(name: str) -> str:
"""Sanitize a string for use as a filename.
Args:
name: The original name
Returns:
Sanitized filename (without extension)
"""
# Replace problematic characters
invalid_chars = '<>:"/\\|?*'
result = name
for char in invalid_chars:
result = result.replace(char, "_")
# Remove leading/trailing whitespace and dots
result = result.strip(" .")
# Ensure non-empty
return result if result else "export"
def _get_exportable_objects(selection: list) -> list:
"""Filter selection to only include exportable objects.
Args:
selection: List of selected FreeCAD objects
Returns:
List of objects that can be exported
"""
exportable = []
for obj in selection:
# Check for Part shapes
if hasattr(obj, "Shape") and hasattr(obj.Shape, "Volume"):
if obj.Shape.Volume > 0.001:
exportable.append(obj)
# Check for Mesh objects
elif hasattr(obj, "Mesh"):
exportable.append(obj)
return exportable
def main():
"""Main macro entry point."""
# Check for active document
if not App.ActiveDocument:
QtGui.QMessageBox.warning(
None, "No Document", "Please open or create a document first."
)
return
# Get current selection
selection = Gui.Selection.getSelection()
if not selection:
QtGui.QMessageBox.warning(
None,
"No Selection",
"Please select one or more objects to export.\n\n"
"You can select multiple objects by holding Ctrl/Cmd while clicking.",
)
return
# Filter to exportable objects
exportable = _get_exportable_objects(selection)
if not exportable:
QtGui.QMessageBox.warning(
None,
"No Exportable Objects",
"None of the selected objects can be exported.\n\n"
"Please select objects with solid shapes (Part or PartDesign bodies).",
)
return
# Show dialog
dialog = MultiExportDialog(exportable)
if dialog.exec_() != QtGui.QDialog.Accepted:
return
params = dialog.get_export_parameters()
# Validate parameters
if not params["formats"]:
QtGui.QMessageBox.warning(
None,
"No Formats Selected",
"Please select at least one export format.",
)
return
if not params["base_filename"]:
QtGui.QMessageBox.warning(
None,
"No Filename",
"Please enter a base filename for the exports.",
)
return
if not os.path.isdir(params["directory"]):
QtGui.QMessageBox.warning(
None,
"Invalid Directory",
f"The directory does not exist:\n{params['directory']}",
)
return
# Perform export
try:
exporter = MultiExporter(exportable, params)
def progress_update(value, message=""):
dialog.set_status(message)
dialog.set_progress(value)
exported_files, errors = exporter.export_all(progress_update)
# Show results
if exported_files:
success_msg = f"Successfully exported {len(exported_files)} file(s):\n"
for f in exported_files[:5]:
success_msg += f"\n • {os.path.basename(f)}"
if len(exported_files) > 5:
success_msg += f"\n ... and {len(exported_files) - 5} more"
if errors:
success_msg += f"\n\nWarnings ({len(errors)}):\n"
for e in errors[:3]:
success_msg += f"\n • {e}"
dialog.set_status(success_msg)
App.Console.PrintMessage(
f"Multi-export complete: {len(exported_files)} files\n"
)
else:
error_msg = "Export failed:\n" + "\n".join(errors)
dialog.set_status(error_msg, is_error=True)
App.Console.PrintError(f"Multi-export failed: {errors}\n")
except Exception as e:
dialog.set_status(f"Export error: {e!s}", is_error=True)
App.Console.PrintError(f"Multi-export error: {e!s}\n")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()