mesh2cad is a Python tool that turns an STL back into editable CAD. It fits planes, cylinders, spheres, cones and fillets to the triangles, sews them into a B-rep, and can write a STEP file, a CadQuery script or a parametric OpenSCAD file. I've written about the fitting and about fillets and OpenSCAD before.
Its benchmark looked good: 14 synthetic parts, three noise levels, three seeds each, almost everything came back exact. This week I finally ran it on STLs I didn't make myself. It did badly, and the reason is worth writing down.
The test
I took six printed parts from the Original Prusa i3 MK3S repository (Prusa Research, GPL-2.0, commit 529e5ad). They're real mechanical parts exported from CAD: a plug aligner, an endstop block, a belt idler, a belt holder, a motor holder and a rod holder. From 48 to 5608 triangles.
On 0.7.0:
| part | triangles | result |
|---|---|---|
| plug-aligner | 48 | faceted-hybrid, nothing fitted |
| endstop-block | 308 | faceted-hybrid, 75 planar faces |
| y-belt-idler | 2464 | faceted, 915 faces, ~15 s |
| y-belt-holder | 2472 | didn't finish in 300 s |
| y-motor-holder | 2802 | didn't finish in 300 s |
| y-rod-holder | 5608 | didn't finish in 300 s |
The plug aligner is a 14-plane prism with 48 triangles. It's about the simplest solid you can hand the tool, and it couldn't find a single plane.
Why synthetic meshes lied
My benchmark builds each part in CadQuery, tessellates it, then subdivides every edge to 2 mm or less, and optionally adds Gaussian noise. That gives lots of small, similar triangles, which is roughly what a 3D scan looks like.
A CAD export is the opposite. A flat face is often two long sliver triangles. Triangle sizes vary by orders of magnitude. And there's no noise at all: every normal is exact.
Three things in the pipeline were built for the scan-like case and broke on that:
- Smoothing. Normals were taken from a Taubin-smoothed copy of the mesh, which helps a lot with noise. On a mesh with sliver triangles it blends the normals of neighbouring faces across sharp edges, so coplanar triangles stopped agreeing and the planes never grouped.
- Small-region cleanup. A planar region of one or two triangles was treated as noise and dropped. On a CAD export, that's a normal face.
- Spheres from flat walls. The side walls of a rectangular step all lie on one circumsphere, so with few enough normal directions a sphere fit could "win" over four planes.
And the 300-second hangs weren't the fitting at all. Segmentation finished in a few seconds. Once the fit gave up, the faceted fallback called OpenCascade's UnifySameDomain on a solid with thousands of faces, and that's where the time went.
What 0.8.0 changes
- If a mesh's dihedral angles pile up on a few exact values (a clean CAD tessellation, including exports where faces are single quads), it's segmented on the raw normals, with no smoothing.
- A planar face of one or two triangles is kept.
- Coplanar patches only merge if they share an edge, so two separate faces on the same plane stay separate.
- A sphere needs at least eight distinct normal directions in its patch, and on a clean mesh it has to beat the cylinder fit by a real margin.
I also added benchmarks/real_parts.py, which downloads those six STLs into a gitignored cache (they aren't vendored, they're Prusa's) and prints a fidelity table, plus tests/test_coarse.py, which runs unsubdivided CadQuery tessellations in CI.
Results on 0.8.0 (same on 0.9.0)
| part | method | output faces | Hausdorff mm | time s |
|---|---|---|---|---|
| plug-aligner | exact | 14 plane | 0.000 | 0.6 |
| endstop-block | exact | 15 plane, 2 cylinder | 0.017 | 2.0 |
| y-belt-idler | faceted-hybrid | 2464 | 0.280 | 12.3 |
| y-belt-holder | faceted | 890 | 0.000 | 21.0 |
| y-motor-holder | faceted-hybrid | 2802 | 0.274 | 13.4 |
| y-rod-holder | faceted | 3043 | 0.000 | 38.6 |
Two parts come back as real CAD now. The endstop block is 15 planes and two holes with radii 1.65 mm and 3.10 mm. The 0.017 mm Hausdorff distance is the chordal error of the holes: a true cylinder sits slightly off the flat facets that approximated it in the STL.
The other four don't. They're valid solids and they finish in under a minute instead of hanging, but they're not exact. Each has patches that aren't any shape mesh2cad knows: small edge rounds that aren't simple plane-to-plane fillets, and belt profiles. That's 0.5% of the area on the idler and 6.6% on the rod holder. Exact sewing needs every region fitted, so one unknown patch sends the whole part to the fallback. Cylinders that a groove cuts into bands also come back as several separate faces, sometimes with slightly different radii.
On 0.8.0, --openscad and --cadquery still refused all six. The parametric writers only knew one shape: a rectangular box with pads, holes and bosses. The plug aligner came back as 14 exact planes, but it isn't a box, so the box script missed it by 2.6 mm and the tool refused to write it.
0.9.0: straight extrusions
The plug aligner is really one outline pushed straight along Z. So 0.9.0 adds a second shape to the parametric writers: an outline (an L, a notch, a slanted edge) extruded along X, Y or Z, with polygonal openings through it and round through-holes. This is what it writes for the real Prusa STL (header comments trimmed):
$ mesh2cad convert plug-aligner.stl -o plug.step --openscad plug.scad
wrote plug.scad params=2 max_dev=0.0000
/* [Outline] */
// length along Z
extrude_len = 5.5;
// start along Z
extrude_0 = 0.0;
// outline in the XY plane, (x, y)
outline = [
[0.0, 0.0],
[6.1, 0.0],
[6.1, 6.95],
[7.3, 6.95],
[7.3, 10.75],
[6.1, 10.75],
[6.1, 17.7],
[0.0, 17.7],
];
// through opening in the outline
cutout_1 = [
[0.45, 1.85],
[5.65, 1.85],
[5.65, 15.85],
[0.45, 15.85],
];
module mesh2cad_part() {
translate([0, 0, extrude_0])
linear_extrude(extrude_len)
difference() {
polygon(outline);
polygon(cutout_1);
}
}
max_dev is the self-check: the script's geometry is compared back to the input mesh, and the tool refuses to write a script that doesn't match. Setting extrude_len = 8 and rendering in OpenSCAD gives a 7.3 × 17.7 × 8 mm watertight part, which is the whole point: you change a number instead of sculpting triangles.
The endstop block is still refused, and the tool says why instead of writing something wrong:
cannot write OpenSCAD script: not a straight extrusion along X, Y, or Z.
along X, 4 faces are tilted (largest 65 mm²); along Y, 4 faces are tilted
(largest 73 mm²); along Z, 3 faces are tilted (largest 73 mm²).
The cross-section changes along the part
It's exact as a STEP file (15 planes, two cylinders), it just isn't a box or a single extrusion. So the parametric score on real parts is one of six. That's low, and it's the honest number.
What I took from it
The synthetic benchmark was measuring something real, just not the thing people would actually feed the tool. If you want to turn a Thingiverse download back into CAD, the input looks like these Prusa parts, not like my noisy subdivided boxes. So the real-part table is in the README now, next to the synthetic one, including the four that fail.
Next on the list:
- a hybrid output that keeps the fitted faces exact and only the unknown patches faceted, instead of falling back for the whole part
- merging cylinder bands of the same axis and radius across a groove
- parametric export for parts made of more than one extrusion, like the endstop block
pip install mesh2cad
mesh2cad convert part.stl part.step
It's MIT-licensed on GitHub and PyPI, and it runs as an MCP server too (mesh2cad mcp). If you have a simple mechanical STL that it gets wrong, an issue with the file attached is the most useful thing you can send me.
This article was written with AI assistance. The code, benchmarks and numbers come from the repo and were checked by running the tool.
Top comments (0)