Files
freecad-robust-mcp-fc111/macros/Multi_Export/MultiExport.FCMacro
T
cd77560297 ci: Add CI and Release workflows and other test fixes (#7)
* ci: add caching and fix workflow failures

- Add UV package caching to all workflows for faster dependency installation
- Add pre-commit hook caching with OS-specific cache keys
- Skip no-commit-to-branch hook in CI (fails on main branch)
- Remove broken apt cache action (doesn't work with PPAs)
- Simplify FreeCAD command detection (PPA installs to standard PATH)
- Add FreeCAD Python version logging for debugging

* ci: Add code Rabbit configuration file

* ci: fix issues in CI workflows

* ci: add uv.lock

* ci: tweaks

* ci: Fix errors and add UUID generation and checking

* ci: Use the GitHub FreeCAD release latest stables

* ci: skip macro test for now, due to headless mode

* feat: Add a multi export macro

* ci: Fix tests

* ci: fix docker build workflow

* ci: skip trufflehog in GitHub Actions due to wasm panic bug

TruffleHog has a known wasm/go-re2 panic bug that causes failures
in GitHub Actions environment. The hook still runs locally during
development for secrets detection.

- Add trufflehog to SKIP env var in pre-commit.yaml
- Update trufflehog to v3.88.7 (latest)
- Add reference to upstream issue #3321

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: use boolean holes instead of PartDesign::Hole in CI

PartDesign::Hole has a CADKernelError bug in FreeCAD AppImage headless
mode on Linux (used in GitHub Actions) where it fails with "Cannot make
face from profile". The SmartCutter class already supports boolean holes
as an alternative.

Changes:
- Modify SmartCutter.execute() to use boolean holes by default
- Update all test assertions for Part::Feature output type
- Update test docstrings and class descriptions
- Remove PartDesign-specific checks (Group, Sketcher::SketchObject)
- Update workflow comment explaining the CI limitation

Boolean holes work reliably in both GUI and headless mode across all
FreeCAD configurations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* ci: clean up GitHub Actions workflows

* ci: Dependabot improvements

* ci: add CodeQL scanning workflow

* ci: AI review suggested improvements

* ci: add coderabbit updates for intentional decisions

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:29:25 -08:00

709 lines
22 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.
Version: 1.0.0
Requirements:
- FreeCAD 0.19 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"
"""
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()