A few days ago I wrote about mesh2cad, a Python tool that turns an STL back into editable CAD by fitting planes, cylinders, spheres and cones to the triangles. Two things were missing from that post: fillets, and a way to edit the result without a CAD program. Both landed this week (0.5.0 and 0.6.0), so this is the follow-up.
pip install mesh2cad
mesh2cad convert plate.stl --openscad plate.scad -o plate.step --tol 0.05
Everything below comes from runs on my machine with the released 0.6.0 and OpenSCAD 2021.01. The test parts are the synthetic ones from the repo: ground-truth CadQuery models, tessellated, subdivided to edges of at most 2 mm, then hit with Gaussian vertex noise. Because the true shape is known, I can measure the error against it, not just against the noisy mesh.
Why fillets are harder than holes
A through-hole in a mesh is easy to spot. It's a closed band of triangles whose normals all point at one axis, so a cylinder fit locks on fast.
A fillet is a thin strip. A 5 mm corner round on a 6 mm plate is a quarter cylinder, and with 0.05 mm of noise its normals are not much cleaner than those of the flat walls beside it. A free cylinder fit on that strip is badly conditioned: the arc covers only 90°, so radius and axis position trade off against each other. You get a plausible cylinder whose radius and axis are both slightly off, and it no longer meets the walls tangentially. Then the B-rep won't sew.
mesh2cad doesn't trust the free fit. It treats it as a hint and rebuilds the cylinder from the two planes it sits between:
- Find the two neighbouring planes whose distance to the fitted axis is close to the fitted radius, and whose normals are not parallel. If a third plane is just as tangent, the strip isn't one edge fillet, so give up on it.
- The axis direction is the cross product of the two plane normals. It's no longer fitted, it's implied by the walls.
- For any radius R there is exactly one axis line at distance R from both planes, on the correct side. So the only unknown left is R, and it gets picked as the value whose tangent cylinder best passes through the arc's points. Wall triangles that ended up in the patch don't drag it around.
- Build an exact partial cylinder face, trimmed by the two end caps, tangent to both walls, and sew.
Generators of the cylinder now lie exactly in the wall planes, which is what lets OpenCascade sew the solid.
How well it works
Here's the benchmark, worst of 3 noise seeds per row (python benchmarks/run.py). "Radius err" is the worst |fitted − true| fillet radius among seeds that came back exact. The plate is 40×30×6 with r=5 corners, the box 30×24×16 with r=2, and the L-bracket has all six vertical edges at r=3, including the inner corner.
| part | noise σ | exact | radius err | Hausdorff | vol err |
|---|---|---|---|---|---|
| filleted plate (r=5) | 0 | 3/3 | 0.009 mm | 0.017 mm | 0.00 % |
| filleted plate (r=5) | 0.05 mm | 3/3 | 0.100 mm | 0.038 mm | 0.07 % |
| filleted plate (r=5) | 0.2 mm | 2/3 | 0.447 mm | 0.857 mm | 0.33 % |
| filleted box (r=2) | 0.05 mm | 3/3 | 0.041 mm | 0.028 mm | 0.03 % |
| filleted box (r=2) | 0.2 mm | 3/3 | 0.206 mm | 0.845 mm | 0.18 % |
| L-bracket (r=3) | 0.05 mm | 3/3 | 0.131 mm | 0.041 mm | 0.02 % |
| L-bracket (r=3) | 0.2 mm | 0/3 | — | 0.958 mm | 0.50 % |
On a single run of the bracket at 0.05 mm noise (seed 0), the six recovered radii were 2.892, 2.936, 2.998, 3.015, 3.044 and 3.045 mm against a true 3 mm, and the STEP came back as 8 planes + 6 cylinders, Hausdorff 0.037 mm to the true part.
At 0.2 mm the numbers tell the honest story: that's where it starts to break. More on that below.
Writing OpenSCAD instead of B-rep
STEP is the right output if you have a CAD program. A lot of people who download printable parts don't. They have OpenSCAD, or the Customizer on Thingiverse and Printables, which is OpenSCAD with sliders. So 0.6.0 adds --openscad.
For the 40×30×6 plate with a Ø10 hole at 0.05 mm noise, this is the file it wrote (header trimmed):
// --- parameters ---
/* [Base] */
// base length (X)
base_x = 40.0;
// base width (Y)
base_y = 30.0;
// base thickness (Z)
base_t = 6.0;
base_cx = 0.0;
base_cy = 0.0;
// bottom face height
base_z0 = -3.0;
/* [Holes] */
// diameter
hole_1_d = 10.0; // [0.2:0.1:30]
hole_1_x = 0.0;
hole_1_y = 0.0;
/* [Hidden] */
$fn = 96; // circle segments
// --- model ---
module mesh2cad_part() {
difference() {
translate([base_cx, base_cy, base_z0 + base_t / 2])
cube([base_x, base_y, base_t], center=true);
translate([hole_1_x, hole_1_y, base_z0 - 1])
cylinder(h=base_t + 2, d=hole_1_d);
}
}
mesh2cad_part();
The fitted hole radius on that mesh was 4.993 mm. The script says hole_1_d = 10.0 because values are rounded to clean numbers only when the rounded part still matches the mesh. The /* [Holes] */ group and the // [0.2:0.1:30] comment are Customizer syntax: the hole gets a slider capped at the plate's short side. Positions stay plain numbers, since there's no sensible bound to give them.
The plate with r=5 corners comes out as a 2D offset, so the corner radius is one variable too:
// vertical corner radius
fillet_r = 5.0; // [0:0.1:14.9]
...
module mesh2cad_part() {
translate([base_cx, base_cy, base_z0])
linear_extrude(base_t)
offset(r=fillet_r) // four vertical corners, one radius
square([base_x - 2 * fillet_r, base_y - 2 * fillet_r], center=true);
}
Its four corners were fitted at 4.959, 4.969, 5.001 and 5.007 mm. The emitter only writes fillet_r when all four share one radius, and here they did within tolerance.
"Make the hole 2 mm bigger"
This is the whole point, so I checked it with OpenSCAD instead of trusting the diff. I rendered the original script, changed one line, and rendered again:
| edit | volume before | volume after | change | expected |
|---|---|---|---|---|
hole_1_d 10 → 12 |
6729.10 mm³ | 6521.90 mm³ | −207.20 mm³ | −207.35 mm³ |
fillet_r 5 → 8 |
7070.90 mm³ | 6869.51 mm³ | −201.39 mm³ | −200.87 mm³ |
"Expected" is plain geometry: π/4·(12² − 10²)·6 for the hole, 4·(1 − π/4)·(8² − 5²)·6 for the corners. The small gap is $fn = 96: OpenSCAD circles are polygons, not true arcs. The bounding box stayed 40×30×6 in all four renders.
Before the .scad file is written, mesh2cad renders it with the openscad CLI and compares that STL with the input mesh (sampled Hausdorff and volume). On these two parts the reported max deviation was 0.130 mm and 0.124 mm. That's measured against the noisy input, so it includes the 0.05 mm of noise itself; against the true part, the matching STEP files were at 0.028 and 0.038 mm. If openscad isn't installed, the file is still written and the command says it wasn't verified. OpenSCAD isn't a Python dependency.
What doesn't work (yet)
This is the part I'd want to read before trying a tool, so here are the failures from the same session:
-
The L-bracket can't be written as OpenSCAD. STEP is exact at 0.05 mm, but the script emitter only knows one fillet case, the four vertical corners of a rectangular base. Output:
cannot write OpenSCAD script: fillet is not the four vertical corners of a rectangular base, exit code 2, no file written. -
0.2 mm noise breaks fillets. The bracket falls back to a faceted solid on all 3 seeds. On the plate's failing seed, the result was a faceted-hybrid solid (5796 faces, i.e. one per triangle), and
--openscadrefused withsphere faces are not supported yet, because a noisy corner patch got fitted as a sphere. On another plate seed the solid is exact but missing one of the four corners. The fallbacks are still valid solids, and the report says which method was used. - Fillets that meet other curves aren't rebuilt. A box with every edge filleted has sphere-octant corners. A round at the base of a boss or the rim of a hole is a torus. Neither is supported. Free-form surfaces aren't either.
- The OpenSCAD vocabulary is small on purpose. One Z-up box or plate, rectangular pads on top, vertical holes (through, blind, countersunk) and vertical bosses (straight, drafted, chamfered). Anything else gets STEP only. I'd rather refuse than write a script that looks editable and is quietly wrong.
From an AI assistant
The MCP server has a to_openscad tool next to convert, analyze, report and to_cadquery. It returns the parameters and the measured deviation, or verified: false when OpenSCAD is missing. Install with pip install "mesh2cad[mcp]":
{ "mcpServers": { "mesh2cad": { "command": "mesh2cad", "args": ["mcp"] } } }
Next
Holes and bosses that aren't vertical are the next gap I want to close, and a FreeCAD macro is still on the list. The code is MIT-licensed on GitHub and on PyPI. If you have a simple mechanical STL with rounded corners that it gets wrong, please open an issue with the file attached. Real parts beat my synthetic ones every time.
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)