Last active
June 22, 2026 04:58
-
-
Save haliphax/87f7828a7842c56c551b1ed77bce4af9 to your computer and use it in GitHub Desktop.
FreeCAD Macro - Export objects as STL/STEP
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """ | |
| BodyExport.FCMacro | |
| Export each selected solid as both STEP and STL into the project file's directory. | |
| Files are named: <project name> - <body name>.<extension> | |
| If no solid objects are selected, top-level results are exported: | |
| - PartDesign::Body objects (unless they are operands of a standalone | |
| boolean result) | |
| - Standalone boolean results (Part::Cut, Part::Fuse, Part::Common) | |
| Usage: | |
| 1. Select one or more solid objects in the tree or 3D view | |
| (PartDesign::Body, Part::Cut, Part::Fuse, Part::Common, etc.) | |
| 2. Run this macro. | |
| __Name__ = 'BodyExport' | |
| __Comment__ = 'Export selected solids as STEP and STL' | |
| __Author__ = 'haliphax' | |
| __Version__ = '1.8' | |
| __License__ = 'MIT' | |
| __Status__ = 'production' | |
| """ | |
| import FreeCAD | |
| import Part | |
| import os | |
| # --------------------------------------------------------------------------- | |
| # STL export defaults matching the FreeCAD GUI (File -> Export) | |
| # --------------------------------------------------------------------------- | |
| STL_LINEAR_DEFLECTION = 0.5 | |
| STL_ANGULAR_DEFLECTION = 0.5235987755982988 # 30 degrees in radians | |
| # TypeIds for boolean operation results | |
| BOOLEAN_TYPE_IDS = frozenset(["Part::Cut", "Part::Fuse", "Part::Common"]) | |
| # --------------------------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------------------------- | |
| def _project_name_and_dir(): | |
| doc = FreeCAD.activeDocument() | |
| if doc is None or not doc.FileName: | |
| raise RuntimeError( | |
| "The document has not been saved yet. Save it before running this macro." | |
| ) | |
| doc_dir = os.path.dirname(doc.FileName) | |
| doc_name = os.path.splitext(os.path.basename(doc.FileName))[0] | |
| return doc_name, doc_dir | |
| def _is_body(obj): | |
| return hasattr(obj, "TypeId") and obj.TypeId == "PartDesign::Body" | |
| def _is_boolean_result(obj): | |
| return hasattr(obj, "TypeId") and obj.TypeId in BOOLEAN_TYPE_IDS | |
| def _has_solid_shape(obj): | |
| shape = getattr(obj, "Shape", None) | |
| if shape is None: | |
| return False | |
| try: | |
| return len(shape.Solids) > 0 | |
| except Exception: | |
| return False | |
| def _owning_body(obj): | |
| parent = getattr(obj, 'getParentGroup', lambda: None)() | |
| while parent is not None: | |
| if _is_body(parent): | |
| return parent | |
| parent = getattr(parent, 'getParentGroup', lambda: None)() | |
| return None | |
| def _is_in_body(obj): | |
| return _owning_body(obj) is not None | |
| def _is_operand_of(obj, possible_parents): | |
| """Return True if *obj* is referenced in the InList of any object | |
| in *possible_parents* (i.e. it is a direct dependency of one of them).""" | |
| parent_ids = {id(p) for p in possible_parents} | |
| for caller in getattr(obj, 'InList', []): | |
| if id(caller) in parent_ids: | |
| return True | |
| return False | |
| def _collect_bodies(selection): | |
| """ | |
| Return the list of unique objects to export. | |
| Explicit selection: export exactly what was asked for, no filtering. | |
| Fallback (nothing selected): export top-level results. | |
| """ | |
| raw = [] | |
| seen = set() | |
| # --- Explicit selection ------------------------------------------------ | |
| if selection: | |
| for obj in selection: | |
| if _is_body(obj): | |
| if id(obj) not in seen: | |
| raw.append(obj) | |
| seen.add(id(obj)) | |
| continue | |
| parent_body = _owning_body(obj) | |
| if parent_body is not None: | |
| if id(parent_body) not in seen: | |
| raw.append(parent_body) | |
| seen.add(id(parent_body)) | |
| continue | |
| if _has_solid_shape(obj) and id(obj) not in seen: | |
| raw.append(obj) | |
| seen.add(id(obj)) | |
| return raw | |
| # --- Fallback ---------------------------------------------------------- | |
| doc = FreeCAD.activeDocument() | |
| # 1) Collect all Bodies | |
| all_bodies = [] | |
| for o in doc.Objects: | |
| if _is_body(o) and id(o) not in seen: | |
| all_bodies.append(o) | |
| seen.add(id(o)) | |
| # 2) Collect all standalone boolean results (not inside a Body) | |
| booleans = [] | |
| for o in doc.Objects: | |
| if (_is_boolean_result(o) and _has_solid_shape(o) | |
| and not _is_in_body(o) and id(o) not in seen): | |
| booleans.append(o) | |
| seen.add(id(o)) | |
| # 3) Bodies that are operands of a standalone boolean are NOT top-level. | |
| # Use InList: if a Body's InList contains a boolean result, that | |
| # boolean uses this Body as an operand, so the Body is not top-level. | |
| result = [] | |
| for body in all_bodies: | |
| if not _is_operand_of(body, booleans): | |
| result.append(body) | |
| # 4) Add the standalone booleans | |
| result.extend(booleans) | |
| return result | |
| def _slugify(name): | |
| safe = [] | |
| for ch in name: | |
| if ch in ('/', '\\', ':', '*', '?', '"', '<', '>', '|'): | |
| safe.append('_') | |
| else: | |
| safe.append(ch) | |
| return ''.join(safe) | |
| def _export_stl(obj, stl_path): | |
| import MeshPart | |
| mesh = MeshPart.meshFromShape( | |
| Shape=obj.Shape, | |
| LinearDeflection=STL_LINEAR_DEFLECTION, | |
| AngularDeflection=STL_ANGULAR_DEFLECTION, | |
| Relative=False, | |
| ) | |
| mesh.write(stl_path) | |
| # --------------------------------------------------------------------------- | |
| # Main | |
| # --------------------------------------------------------------------------- | |
| def main(): | |
| doc = FreeCAD.activeDocument() | |
| if doc is None: | |
| raise RuntimeError("No active document. Open or create one first.") | |
| proj_name, proj_dir = _project_name_and_dir() | |
| sel = FreeCADGui.Selection.getSelection() | |
| targets = _collect_bodies(sel) | |
| if not targets: | |
| FreeCAD.Console.PrintMessage("No solid objects found. Nothing to export.\n") | |
| return | |
| exported = [] | |
| errors = [] | |
| for obj in targets: | |
| slug = _slugify(obj.Label) | |
| base_name = proj_name + " - " + slug | |
| step_path = os.path.join(proj_dir, base_name + ".step") | |
| stl_path = os.path.join(proj_dir, base_name + ".stl") | |
| try: | |
| Part.export([obj], step_path) | |
| exported.append(step_path) | |
| except Exception as exc: | |
| errors.append("STEP export failed for '" + obj.Label + "': " + str(exc)) | |
| try: | |
| _export_stl(obj, stl_path) | |
| exported.append(stl_path) | |
| except Exception as exc: | |
| errors.append("STL export failed for '" + obj.Label + "': " + str(exc)) | |
| if exported: | |
| FreeCAD.Console.PrintMessage("Exported " + str(len(exported)) + " file(s):\n") | |
| for f in exported: | |
| FreeCAD.Console.PrintMessage(" " + f + "\n") | |
| if errors: | |
| FreeCAD.Console.PrintWarning(str(len(errors)) + " error(s) occurred:\n") | |
| for e in errors: | |
| FreeCAD.Console.PrintWarning(" " + e + "\n") | |
| try: | |
| import FreeCADGui | |
| except ImportError: | |
| raise RuntimeError( | |
| "This macro must be run inside the FreeCAD GUI (not headless console mode)." | |
| ) | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment