STL to STEP Conversion Script for FreeCAD

This is what happens when I get pissed off and bored at the same time. :face_savoring_food:

As many threads have noted here, converting STL to STEP for CAD work involves plenty of roadblocks. The best online tool I’ve found so fare is Convert STL to STEP Online - Free & Fast | AnyConv But even that one often pukes on STLs larger than 10,000 vertices. FreeCAD 1.0 was released in 2024 and after 24 years in development, it finally got out of BETA. Better late than never!!! Before this release it was truly a science project, it still is but the STL to STEP conversion is pretty robust if not complicated though.

So I had AI generate a Python script to my specs and I am using it as a FreeCAD macro. Over the last three years, I have learned that if you can clearly articulate a technical problem, using the AI is like having a smart college intern working for me. My coding skills are rusty, but my computer science fundamentals are intact, so this is a pretty effective arrangement. That said, you still need to understand the AI intern’s limitations and as the normal disclaimer goes: Use at your own risk!!!

Problem #1:

  • STL to STEP conversion. A lot of online converters choke on high-poly meshes and/or messy geometry (non-manifold edges, self-intersections, etc.).

Solution:

  • FreeCAD 1.0+ for Windows has a very robust mesh-to-shape / mesh-to-solid workflow. For complex meshes it can take a long time (minutes to hours). It may look frozen. I have let it run overnight and ended up with a valid solid STEP. As always, your computer and mileage may vary.

Problem #2:

  • Even when conversion succeeds, importing into OnShape (my preferred CAD program) can put the part a mile away from the origin - because STL is just triangles in whatever coordinate space the author used. Sure, I can transform it manually, but I would rather make the computer do the boring part.

Solution:

  • A FreeCAD macro that:

    1. normalizes the mesh placement,
    2. converts mesh → shape → solid, and
    3. exports a STEP that is centered at the origin.
  • For a FreeCAD installation tutorial, consult YouTube. Here’s a short one that walks you through it. I’ve forwarded the video to 0:56 to speed things up: https://youtu.be/9TrUSYzOHUM?t=56

  • For one example tutorial on how to create a Macro: https://www.youtube.com/watch?v=RRbukysf8fc

What this Macro does

  • Prompts you to select an STL file using a standard Windows file dialog.
  • Imports the STL into FreeCAD as a mesh.
  • Translates the mesh so its minimum bounding box corner is positioned at the global origin (0,0,0) as a normalization step.
  • Converts the mesh to a Part shape using a tolerance of 0.10 with sewing enabled.
  • Converts the resulting shape/shell into a solid body (when possible).
  • Recenters the final solid so the bounding box center is located at the global origin.
  • Assigns the final solid an object name based on the STL filename, truncated to 24 characters if required.
  • Exports the centered solid as a STEP file in the same directory as the source STL.
  • Appends _Origin_Placement.STEP to the original STL filename for the exported file.
  • Reports key actions and file paths to the FreeCAD Report view for verification.

Support policy and Non-Warranty Policy(read this twice):

  1. This is provided as-is.
  2. If you find a bug and fix it, please post your fix so others benefit.
  3. If you want customizations, feature requests, or personal tutoring, refer to bullet #1. :wink:
  4. I am not making a dime on this. The refund policy is exactly what you paid. :rofl:

Click Here to reveal code for Copy and Paste in the FreeCAD Macro Editor
# FreeCAD 1.0+ macro
# STL -> mesh-to-shape (sew ON, tol=0.10) -> solid -> STEP
# Prompts for STL
# - Final object name = STL base name, truncated to 24 chars
# - Output STEP name = <base>_Origin_Placement.STEP (same folder as STL)

import FreeCAD as App
import Mesh
import Part
import Import
import os
from PySide import QtWidgets

TOLERANCE = 0.10
SEW_SHAPE = True
MAX_OBJ_NAME_LEN = 24


def prompt_for_stl():
    dlg = QtWidgets.QFileDialog()
    dlg.setWindowTitle("Select STL file")
    dlg.setNameFilter("STL files (*.stl *.STL)")
    dlg.setFileMode(QtWidgets.QFileDialog.ExistingFile)
    if dlg.exec_():
        files = dlg.selectedFiles()
        if files:
            return files[0]
    return None


def sanitize_object_name(name, max_len):
    # FreeCAD internal object names: avoid spaces/special chars; avoid leading digit
    clean = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
    if clean and clean[0].isdigit():
        clean = "_" + clean
    return clean[:max_len] if clean else "STL_Object"


def translate_mesh_to_min_corner_at_origin(mesh):
    bb = mesh.BoundBox
    dx = -bb.XMin
    dy = -bb.YMin
    dz = -bb.ZMin
    mesh.translate(dx, dy, dz)  # Mesh.Mesh.translate requires 3 scalars
    return App.Vector(dx, dy, dz)


def make_shape_from_mesh(mesh, tol=0.10, sew=True):
    shape = Part.Shape()
    shape.makeShapeFromMesh(mesh.Topology, tol)

    if sew:
        try:
            sewn = shape.sewShape(tol)
            if sewn:
                shape = sewn
        except Exception:
            pass

    return shape


def make_solid_from_shape(shape):
    if shape.ShapeType == "Solid":
        return shape
    if shape.ShapeType == "Shell":
        return Part.makeSolid(shape)
    if hasattr(shape, "Shells") and shape.Shells:
        return Part.makeSolid(shape.Shells[0])
    raise RuntimeError(f"Cannot create solid from ShapeType={shape.ShapeType}")


def recenter_to_origin_by_bbox_center(shape):
    bb = shape.BoundBox
    center = App.Vector(
        (bb.XMin + bb.XMax) / 2.0,
        (bb.YMin + bb.YMax) / 2.0,
        (bb.ZMin + bb.ZMax) / 2.0
    )
    moved = shape.copy()
    moved.translate(-center)
    return moved, center


def main():
    stl_path = prompt_for_stl()
    if not stl_path:
        App.Console.PrintMessage("No STL selected. Aborted.\n")
        return

    folder = os.path.dirname(stl_path)
    base_name = os.path.splitext(os.path.basename(stl_path))[0]

    obj_name = sanitize_object_name(base_name, MAX_OBJ_NAME_LEN)

    # append '_Origin_Placement.STEP'
    step_filename = f"{base_name}_Origin_Placement.STEP"
    step_path = os.path.join(folder, step_filename)

    doc = App.ActiveDocument or App.newDocument("STL_to_STEP")

    # 1) Load mesh and move so MIN corner is at (0,0,0)
    mesh = Mesh.Mesh(stl_path)
    v_min_to_origin = translate_mesh_to_min_corner_at_origin(mesh)

    mesh_obj = doc.addObject("Mesh::Feature", f"{obj_name}_Mesh")
    mesh_obj.Mesh = mesh

    # 2) Mesh -> Shape (sew ON, tol=0.10)
    shape = make_shape_from_mesh(mesh, TOLERANCE, SEW_SHAPE)
    shape_obj = doc.addObject("Part::Feature", f"{obj_name}_Shape")
    shape_obj.Shape = shape

    # 3) Shape -> Solid
    solid = make_solid_from_shape(shape)
    solid_obj = doc.addObject("Part::Feature", f"{obj_name}_Solid")
    solid_obj.Shape = solid

    doc.recompute()

    # 4) Center solid at origin (bbox center) and export STEP
    centered, old_center = recenter_to_origin_by_bbox_center(solid_obj.Shape)

    centered_obj = doc.addObject("Part::Feature", obj_name)
    centered_obj.Shape = centered

    doc.recompute()

    Import.export([centered_obj], step_path)

    App.Console.PrintMessage("Done.\n")
    App.Console.PrintMessage(f"STL:  {stl_path}\n")
    App.Console.PrintMessage(f"Object name: {obj_name}\n")
    App.Console.PrintMessage(f"Move (min corner -> origin): {v_min_to_origin}\n")
    App.Console.PrintMessage(f"Recentered by old bbox center: {old_center}\n")
    App.Console.PrintMessage(f"STEP exported: {step_path}\n")


main()

_______________________________________________________

What follows is an AI Prompt to use if you want to try it out in your favorite AI Chat tool.

Note: This prompt was generated by CPT itself, so it is only as accurate as CPT is feeling it that day. :rofl:

Click here and Copy and paste into your favorite AI Chat

You are an expert FreeCAD (v1.0+) macro author.

Generate a complete, ready-to-run FreeCAD macro (Python) that performs the following end-to-end workflow with no manual edits required:

  • Prompt the user with a file dialog to select an STL file located anywhere on disk.
  • Import the STL as a mesh into the active FreeCAD document (create a document if none exists).
  • Translate the mesh so the minimum bounding box corner is positioned at the global origin (0,0,0).
  • Convert the mesh to a Part Shape using:
    • Sew mesh enabled
    • Tolerance set to 0.10
  • Convert the resulting shape or shell into a solid.
  • Recenter the final solid so the bounding box center is located at the global origin.
  • Name the final Part object using the STL filename (without extension), sanitized for FreeCAD rules and truncated to 24 characters if necessary.
  • Export the final centered solid as a STEP file in the same directory as the STL.
  • The STEP filename must be the original STL base name with _Origin_Placement.STEP appended.
  • Print clear status messages to the FreeCAD Report view indicating:
    • Source STL path
    • Translation vector applied
    • Recenter operation
    • Final STEP export path

Constraints:

  • Regenerate the full macro code in one block.
  • Do not provide line-edit instructions or partial snippets.
  • Do not hardcode file paths.
  • Use only APIs compatible with FreeCAD 1.0 or newer.
  • Account for FreeCAD API quirks (e.g., Mesh.translate requires three scalar arguments).
  • Prefer robustness over brevity.

Output only the final macro code.

5 Likes

but how clean are the results? is freecad able to make planes, extrusions and such? because stls are quite destructive.

Ive tried the “feature recognition” plugin in Inventor, but it is horrible, slow and laggy.

Fair question. No, this is not like what Fusion360 advertises in their paid version with Prismatic conversion. I say “advertises” because I am unwilling to pay for the license just to experiment with that feature. Based on the demo videos I’ve seen, they imply it can convert planar surfaces and other geometry back into the original solid body. I’m skeptical, but I would welcome someone else trying it and reporting back.

With the FreeCAD conversion, a planar face is often still divided into 2 triangles, and you still have to go in and rebuild some features. While in my example below that did not happen, I recognize that when it does it can be time-consuming, but as anyone who has tried operating directly on an imported STL mesh can tell you, it is still far easier than working on the raw mesh itself.

Here’s a visual example of an STL and its STEP file import after it was converted.
FreeCAD converted and imported into OnShape

STEP file import


Side by Side

But as stated previously, the frustrations I was trying to overcome were the following import issues.

  • Non-Solid body - Making it very difficult to operate on.
  • Placing the model at the origin. I find It makes it easier to constrain the geometry when one corner of the model is at the origin rather than having to transform it manually.
  • Loss of Parametric Planar information.
  • Other minor “creature comforts” and “quality of life” issues and conveniences in addition import at origin, naming the object consistently upon import.

Note: For those unfamiliar with the distinction between when an item is a mesh or solid, it illustrated with the difference in icons upon import to OnShape or FreeCAD. Other CAD programs have similar restrictions and ICON notations.

OnShape:

FreeCAD

As I stated in my opening post, a lot of this is personal nitpicking but since I so often find the need to modify an STL I find on some of the repositories, it becomes a real time saver to import correct, the first time out.

As we all know, ultimately when it comes to importing into the slicer, everything is converted to a mesh anyway but it’s those operations outside the slicer that matter.

yeah I see, thanks for sharing. It is an interesting topic due the noise of STL, I think it would be (poorly) solved by some AI because discrete mathematical stuff will always fail, like you could see some objects being 3.999999999998 where you as a human rebuilder will turn to 4, or that sphere for example, but you will also notice if is wrong to turn to sphere based on other conditions and such…

Quicksurface is an interesting tool to explore too, at least they have a fully functional trial. I often end using the STL for references only, and redrawing it.

I’m always looking for a better STL conversion tool. I just checked their website and, honestly, the experience was off-putting. An obfuscated site that took seven clicks and about 20 pages of scrolling just to find a price of €480 is not a great first impression. Not listing pricing in USD also suggests they’re not particularly interested in my business. Did you end up paying for it, and if so, what was the actual price?

By comparison, Fusion 360’s ~$680/year price is already about $630 beyond what I’m willing to pay for a single feature that may or may not work as advertised (prismatic conversion).

For what it’s worth, until I rediscovered FreeCAD after 1.0 release, over the past year I’ve leaned more toward Meshmixer as a conversion aid. That said, it’s no longer supported by Autodesk and could disappear at any time. It also requires far too many manual steps, which means babysitting the process for hours on complex models. If Meshmixer had a true macro or batch mode where I could set it up, walk away, and come back to a finished result, it would be much more compelling.

Yeah, that’s my approach as well in similar cases (well, 96% of cases… 4% are dropped), though I do confess that, like Olias, I’d love having a tool that would correctly import a STL to work on it.

1 Like

Time ago I had a 3d scan and explored some tools (meshmixer, meshlab, etc) and others which normally are insanely expensive for this task. Quicksurface trial was very helpful to extract something useful because it is so interactive: you do few clicks and tell “this is a plane” and “this is a sphere” and it fits however it can, even stuff you wont imagine like extrusions and such, so if I had to do this for anything commercial I would probably use it.

I have no idea they had a complicated website, I remember clicking get trial and that was most of it, maybe annoying signup? I dont remember.

1 Like

So, I do use free cad and have done a couple file type conversions using it but never used a macro. My question is to use the example of a macro you have here, how do i implement it on an existing file, as in how do i run it on my existing stl just to see it in action.

This is a nice macro.

I have done something similar.

I use a command ‘RefineShape’ as the last step.

In your macro it can be added with this code change

# 4) Center solid at origin (bbox center) and export STEP
centered, old_center = recenter_to_origin_by_bbox_center(solid_obj.Shape)

centered_obj = doc.addObject("Part::Feature", obj_name)
centered_obj.Shape = centered
solid_obj.Visibility = False

doc.recompute()

### Begin command Part_RefineShape
export_obj = doc.addObject('Part::Refine',obj_name)
doc.ActiveObject.Source = FreeCAD.getDocument('STL_to_STEP').getObject(obj_name)
doc.ActiveObject.Label = FreeCAD.getDocument('STL_to_STEP').getObject(obj_name).Label
centered_obj.Visibility = False

doc.recompute()

Import.export([export_obj], step_path)

Your code without ‘RefineShape’:

With ‘RefineShape’

Use this code at your own risk :slightly_smiling_face:

Good Question.

There are two ways. The hotkey is the easiest method. Once you define a macro, it is automatically assigned a hotkey. That’s the quickest way you invoke the script. It is designed to prompt you for a file name and will output to the same folder. You can find out what is assigned through the recent macros menu.


If for some reason a hotkey is not assigned, the second method is via the macro menu then once the macro execute macro dialogue box opens, select the macro and click the “execute” button.

Thank you.

Yes, I came across that after I made this original post and added it to my version but was too lazy to post it here. Although it’s a useful parameter, so far in my limited testing, I’ve only come across one shape where I could see a difference in the face count. But I can definitely see the value which is why I added it later to my private version, again, not trying to hold back just too lazy to post. :face_savoring_food:

At any rate, for the sake of completeness even though you already posted the edit, here is the complete updated macro with “refine shape” option included:

Click here for a copy and paste window to open
# FreeCAD 1.0+ macro
# STL -> mesh-to-shape (sew ON, tol=0.10) -> solid -> center at origin -> REFINE -> STEP export
# Prompts for STL
# - Final object name = STL base name, truncated to 24 chars
# - Output STEP name = <base>_Origin_Placement.STEP (same folder as STL)

import FreeCAD as App
import Mesh
import Part
import Import
import os
from PySide import QtWidgets

TOLERANCE = 0.10
SEW_SHAPE = True
MAX_OBJ_NAME_LEN = 24


def prompt_for_stl():
    dlg = QtWidgets.QFileDialog()
    dlg.setWindowTitle("Select STL file")
    dlg.setNameFilter("STL files (*.stl *.STL)")
    dlg.setFileMode(QtWidgets.QFileDialog.ExistingFile)
    if dlg.exec_():
        files = dlg.selectedFiles()
        if files:
            return files[0]
    return None


def sanitize_object_name(name, max_len):
    clean = "".join(c if c.isalnum() or c == "_" else "_" for c in name)
    if clean and clean[0].isdigit():
        clean = "_" + clean
    return clean[:max_len] if clean else "STL_Object"


def translate_mesh_to_min_corner_at_origin(mesh):
    bb = mesh.BoundBox
    dx = -bb.XMin
    dy = -bb.YMin
    dz = -bb.ZMin
    mesh.translate(dx, dy, dz)  # Mesh.Mesh.translate requires 3 scalars
    return App.Vector(dx, dy, dz)


def make_shape_from_mesh(mesh, tol=0.10, sew=True):
    shape = Part.Shape()
    shape.makeShapeFromMesh(mesh.Topology, tol)

    if sew:
        try:
            sewn = shape.sewShape(tol)
            if sewn:
                shape = sewn
        except Exception:
            pass

    return shape


def make_solid_from_shape(shape):
    if shape.ShapeType == "Solid":
        return shape
    if shape.ShapeType == "Shell":
        return Part.makeSolid(shape)
    if hasattr(shape, "Shells") and shape.Shells:
        return Part.makeSolid(shape.Shells[0])
    raise RuntimeError(f"Cannot create solid from ShapeType={shape.ShapeType}")


def recenter_to_origin_by_bbox_center(shape):
    bb = shape.BoundBox
    center = App.Vector(
        (bb.XMin + bb.XMax) / 2.0,
        (bb.YMin + bb.YMax) / 2.0,
        (bb.ZMin + bb.ZMax) / 2.0
    )
    moved = shape.copy()
    moved.translate(-center)
    return moved, center


def refine_shape(shape):
    # Equivalent intent to Part -> Create a copy -> Refine shape
    try:
        return shape.removeSplitter()
    except Exception:
        # Fallback: return original shape if refine is unavailable for this shape/build
        return shape


def main():
    stl_path = prompt_for_stl()
    if not stl_path:
        App.Console.PrintMessage("No STL selected. Aborted.\n")
        return

    folder = os.path.dirname(stl_path)
    base_name = os.path.splitext(os.path.basename(stl_path))[0]

    obj_name = sanitize_object_name(base_name, MAX_OBJ_NAME_LEN)
    step_filename = f"{base_name}_Origin_Placement.STEP"
    step_path = os.path.join(folder, step_filename)

    doc = App.ActiveDocument or App.newDocument("STL_to_STEP")

    # 1) Load mesh and move so MIN corner is at (0,0,0)
    mesh = Mesh.Mesh(stl_path)
    v_min_to_origin = translate_mesh_to_min_corner_at_origin(mesh)

    mesh_obj = doc.addObject("Mesh::Feature", f"{obj_name}_Mesh")
    mesh_obj.Mesh = mesh

    # 2) Mesh -> Shape (sew ON, tol=0.10)
    shape = make_shape_from_mesh(mesh, TOLERANCE, SEW_SHAPE)
    shape_obj = doc.addObject("Part::Feature", f"{obj_name}_Shape")
    shape_obj.Shape = shape

    # 3) Shape -> Solid
    solid = make_solid_from_shape(shape)
    solid_obj = doc.addObject("Part::Feature", f"{obj_name}_Solid")
    solid_obj.Shape = solid

    doc.recompute()

    # 4) Center solid at origin (bbox center)
    centered, old_center = recenter_to_origin_by_bbox_center(solid_obj.Shape)

    # 5) Refine shape (remove splitter edges), then write final object with desired name
    refined = refine_shape(centered)

    final_obj = doc.addObject("Part::Feature", obj_name)
    final_obj.Shape = refined

    doc.recompute()

    # Export refined, centered solid
    Import.export([final_obj], step_path)

    App.Console.PrintMessage("Done.\n")
    App.Console.PrintMessage(f"STL:  {stl_path}\n")
    App.Console.PrintMessage(f"Object name: {obj_name}\n")
    App.Console.PrintMessage(f"Move (min corner -> origin): {v_min_to_origin}\n")
    App.Console.PrintMessage(f"Recentered by old bbox center: {old_center}\n")
    App.Console.PrintMessage("Refine shape: applied (removeSplitter)\n")
    App.Console.PrintMessage(f"STEP exported: {step_path}\n")


main()

1 Like

thank you, i appreciate the information and soem more insight. Its odd i keep getting a double “drawing” when i use the macro, i did try the updated version as well

That’s not odd and that’s not you. That’s an “undocumented feature” – yeah… that’s it, undocumented :wink:-- that I decided to deliberately leave in. I’ll explain.

So what you’re seeing is the the model in all of the stages that it progress through. I chose not to have the macro delete the prior stage just in case I wanted to go back and try tweaking the parameters manual.

  • Stage 1 - STL Mesh - The imported model
  • Stage 2 - Conversion from a mesh to shape
  • Stage 3 - Conversion from shape to a solid shape - this is very important if you miss this step, you will get a hollow body and if you cut that body in the slicer, you will get an “open manifold” error.
  • Stage 4 - Final Solid model with “refined” shape and the STEP file moved to the 0,0,0 origin which was the second goal of this macro. This was the step I left out in v1.0 which you noted above and I included in v1.1.

For those not familiar with FreeCad, you can toggle the visibility of objects by clicking the eyeball icon in the tree menu.
Freecad visibility

So what your seeing is actually four versions of the model. As stated, they were left there deliberately so that I could go back a step and manipulate further. Here’s an example of what I mean. Here I used the default “Sew shape” which is what stitched the mesh into a solid. However, with objects with fewer curves, it produces a STEP file output with fewer faces - which is highly desirable - if you increase the value to let’s say five. But this is at the sacrifice of curves which in turn come out very “polygonish” in appearance with fewer and courser facets.

Ok i see that thank you i appreciate getting more wisdom here about the freecad platform