* 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>
709 lines
22 KiB
Plaintext
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()
|