STL to DXF for laser cutting: cut a cross-section, don't flatten the mesh
Every few weeks someone asks how to turn an STL into something a laser cutter can use. The answer that sounds obvious — project the mesh onto a plane and export the outline — is also the one that quietly ruins parts. I
Every few weeks someone asks how to turn an STL into something a laser cutter can use. The answer that sounds obvious — project the mesh onto a plane and export the outline — is also the one that quietly ruins parts.
I build a browser-based 3D converter (Flip3D), and STL → DXF is one of the tools. This post is about the two honest conversions, the geometry that makes the section one work, and the places where a naive implementation breaks.
A mesh has no 2D equivalent
STL is a triangle soup. DXF is a 2D drawing format. There is no conversion that keeps everything, so the tool offers the two outputs that are actually useful:
- 2D section — cut the model with a plane at a height you choose and export the outline of that cut as closed polylines. This is what laser cutting, waterjet, plasma and CNC profiling want.
-
3D faces — write every triangle as a DXF
3DFACEentity. The shape is unchanged, but it is still a mesh, and the file is large. Useful for a CAD package that imports faceted geometry, useless for a cutter.
The section is the interesting one. A projection answers a different question ("what does the shadow look like?"), and for a part with tapered or curved walls the shadow is not the profile you want to cut. A section is a genuine slice at one height, so you pick the height that matters.
That distinction is also why the same STL can give you several different outlines: a cone sliced near the tip is a small circle, sliced at the base it is a big one.
Cutting triangles with a plane
The core routine intersects every triangle with the horizontal plane z = z0. For one triangle, compare the signed distance of its three vertices:
const da = az - z0, db = bz - z0, dc = cz - z0;
if (allSameSign(da, db, dc)) return null; // triangle entirely above or below
If the triangle straddles the plane, exactly two of its edges cross it. Interpolate along those edges and you have a segment:
const t = d1 / (d1 - d2);
hits.push([x1 + (x2 - x1) * t, y1 + (y2 - y1) * t]);
Two details matter more than they look:
-
A vertex exactly on the plane. Its distance is zero, and
above(d)is false while its neighbours may also be false, so the triangle can look like it is entirely on one side and produce no segment — or produce a zero-length one. I nudge any distance within1e-7of the plane to+1e-7. That removes the degenerate cases without moving geometry anyone can see. - The segment direction is not consistent. Which edge is hit first depends on which two edges the plane happened to cross, so the resulting segments do not circulate around the outline. This is the bug that makes a naive implementation produce a chain of disconnected pieces. The joining step below has to be direction-agnostic.
Joining segments into loops
Each triangle contributes a segment. Neighbouring triangles share an edge, so their segments share an endpoint — up to floating-point noise. I weld endpoints on a grid whose tolerance is scaled to the model: 1e-5 of the largest bounding-box dimension. That is far below any real feature and far above the error of the interpolation.
The adjacency index stores both endpoints of every segment:
for each segment i:
index(keyOf(seg.a), i)
index(keyOf(seg.b), i)
Then walk: from the current tail, look up unused segments at that point, append whichever end is not the join point, and stop when you return to the start. Closed loops come out with their closing point duplicated; pop it.
Segments that never close are reported, not silently dropped. They come from holes or non-manifold edges in the source mesh, and pretending they are closed is how you get a laser path that cuts across the part. The UI shows them in amber and counts them separately.
Winding: outer loops CCW, holes CW
CAM software reads winding as material vs hole. After the loops are built, each loop's signed area (shoelace) tells you whether it is clockwise, and a point-in-polygon test against the other loops gives its nesting depth:
const depth = loops.reduce((d, other) =>
other !== pts && pointInPolygon(pts[0], other) ? d + 1 : d, 0);
const wantCCW = depth % 2 === 0;
Even depth → outer boundary → counter-clockwise. Odd depth → hole → clockwise. A square tube comes out as two loops, with the inner one reversed.
This is also why chaining has to tolerate inconsistent segment direction: the direction is fixed afterwards from the geometry, not trusted from the intersection.
Writing DXF that laser software accepts
The output is AutoCAD R12 (AC1009) ASCII, using POLYLINE / VERTEX / SEQEND. Not LWPOLYLINE: that is R14+, and older laser and CAM packages reject the file. R12 is the flavour LightBurn, Inkscape, Illustrator and Fusion 360 all read.
Two more small things:
-
$INSUNITS = 4(millimetres) in the header. R12-era readers ignore it, modern CAM software respects it. - Numbers are written with six fixed decimals and never in exponent notation.
1e-7is valid in some parsers and a hard error in others.
The same section also downloads as SVG. SVG's Y axis points down, so the path coordinates are flipped on export — otherwise the part comes out mirrored. The SVG is a hairline stroke outline sized in millimetres, not a filled illustration.
What I tested
The DXF module is pure data — no DOM, no WebGL — so it runs in Node and has 118 assertions in the test suite. The one that matters most is the round trip:
cube 30 × 18 × 4
→ section at one Z
→ write DXF (R12 polylines)
→ parse the DXF back
→ extrude to a solid
✓ outline survived: 30 × 18 × 4
✓ round-tripped solid is watertight
✓ volume 2.16 cm³
Other checks that caught real bugs:
- A square tube keeps its hole: outer area
+400(CCW), hole−64(CW), both closed. - A section above or below the model returns zero loops instead of an empty or broken file.
- An open chain from a non-manifold mesh stays open — it is never quietly welded shut.
- SVG export: Y is flipped, closed paths end with
Z, open paths do not.
What this is not
- It is not a projection. A section is the outline at one height. If your part is a curved shell and you wanted its unfolded footprint, this is the wrong tool.
- It is not a redrawn CAD sketch. The output is polylines through the mesh's actual intersection — no curve fitting, no smoothing. A faceted mesh gives faceted outlines.
- No kerf offset. The outline is the geometry, not the tool path. Add kerf compensation in LightBurn or your CAM software.
-
STEP → DXF holes are polygons, not
CIRCLEentities. STEP is triangulated first, so a hole becomes a many-segment polyline. It cuts correctly; it just is not a parametric circle. - Open contours are surfaced, not repaired. If the source mesh has holes, you see the open chains and can decide what to do.
Try it
flip3d.app/stl-to-dxf — drop an STL, pick the cut axis (Z top / Y front / X side), drag the height slider until the preview matches what you need, then download DXF or SVG. Everything runs in the browser; the file is never uploaded. There is also a dedicated STL to SVG page and the reverse direction, DXF to STL.
The code is in the repo: lib/dxf/section.ts (intersection, welding, winding), lib/dxf/write.ts (R12 writer) and lib/dxf/parse.ts (the reader used by the reverse direction).
If you have an STL where the section comes out wrong, send it in the comments — real broken meshes are how the edge cases above got found.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.