"""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()