Converting an STL back to a STEP file means guessing the original CAD faces. This paper proposes a normal binary STL that also records them, so slicers still open it and converters no longer have to guess. It is also a public, dated description of the format, and a list of the ideas that didn’t work.
Matthew Armstrong · GitHub The-Dorkknight · United
Kingdom
First published 15 October 2026 · Version 1.0
Text and figures: CC BY-SA 4.0.
Code that comes with it: PolyForm
Noncommercial 1.0.0.
Status: nothing in this paper has been run in real FreeCAD, in Fusion, or in a slicer. The format and its reference code were tested on Linux, using test parts built with OpenCASCADE and the file-reading code that slicers use (not the slicers themselves). The FreeCAD and Fusion exporters were only tested against stand-ins for those programs. The conversion results come from one converter of mine. If you open the example files in your slicer before I do, please tell me what happened.
Not legal advice. I am not a lawyer. Where this paper talks about prior art, it says what I found, where I found it, and when. A patent attorney is the person to ask about what it means.
| Plain STL: 1,103 faces | Hardpoint STL: 8 faces |
|---|---|
The same knob, made from the same triangles, converted to STEP. Left: from a plain STL, which came out with 1,103 faces. Right: from the hardpoint version of the file, which came out with 8 faces. The original CAD model has 4.
An STL file is just a pile of triangles. It has lost everything the CAD program knew: which triangles made up one flat face, which made up a round hole, and where the corners were. Turning an STL back into a real CAD solid means guessing all of that again, and the guesses go wrong most often on coarse meshes and curved edges.
This paper describes a way to keep that information in the file without breaking it for anyone else:
.stl extension.Section 8 describes the format and its variations in enough detail to build them. It is written as prior art, so the method stays free for everyone to use.
How it started:
So I built the hardpoint STL, plus an exporter for FreeCAD and one for Fusion. First I researched how slicers read STL files. Then I tried to find every way a file like this could break them.
Why publish all of this, including the failures? Four reasons:
How this was made. The ideas, the questions and the rules are mine. The code, the tests, the measurements and the first draft of this write-up were done with an AI assistant (Claude, by Anthropic), working to my design. All sources are linked so you can check them, and the code is included so you can re-run every number that can be re-run (section 12).
A CAD solid is made of faces: planes, cylinders, spheres, cones, tori and freeform surfaces, joined along edges and corners. Exporting it as STL cuts every face into triangles and writes only the triangles. A flat face and a rounded edge of the same bracket both end up as nothing but triangles.
A mesh-to-CAD converter has to work out again which triangles form one flat face (and how flat is flat enough?) and which form a curve. When the mesh is coarse, or a curve meets a flat face very gently, it can’t always tell.
The design had to meet six requirements, in this order:
.stl and the file loads in any slicer without a
plug-in.A binary STL has an 80-byte header, a 4-byte triangle count, then 50 bytes per triangle: 12 for the normal, 36 for three corners, and 2 for an “attribute byte count” that slicers ignore. So there are exactly two places that carry no meaning to a slicer. Anything after the last triangle is a different matter.
| Reader | Used by | Binary or text test | Triangle count | Extra bytes at the end | How this is known |
|---|---|---|---|---|---|
admesh stlinit.cpp |
OrcaSlicer; PrusaSlicer-family slicers share this code | Binary if any of the 128 bytes after offset 84 is above 127 | Taken from the file size; the header count only logs a warning | Rejected unless a multiple of 50, then read as phantom triangles | Read OrcaSlicer’s source (PrusaSlicer’s and Bambu Studio’s copies not read) |
| numpy-stl | Cura, through Uranium’s STLReader | Automatic mode | From the header | Load fails with an error | Read Uranium’s source; ran the library |
| trimesh | many Python tools | Binary only if the size is exactly 84 + 50 × count, otherwise tried as text | From the header | Not binary, then fails as text (0 faces) | Read trimesh’s source; ran the library |
Other findings that shaped the format:
solid.
Some software then treats the file as text (Wikipedia).COLOR= string in the header, bits 0–4 red, 5–9 green, 10–14
blue, bit 15 = 0 means the triangle has its own colour (both as
described on Wikipedia).process=True it survives vertex merging, but removing
sliver triangles rebuilds the mesh. On one test part 5,572 triangles
became 5,564 and the field was gone.Using the attribute bytes of an STL for something other than “nothing” is old: the two colour conventions above are the best-known uses. What I looked for was a file that keeps CAD face identity, surface types and CAD vertices inside an ordinary STL.
Whether any of this affects anyone’s rights is a question for lawyers, not me.
I can’t build exact CAD parts by hand, so I had a CAD kernel make them:
What this gets wrong (please read before quoting a number):
Seven ideas were built or tested and then dropped. Each row says what the evidence was, so nobody repeats the experiment blind.
| Idea | What happened | Evidence |
|---|---|---|
| Store extra data after the last triangle | Loaders fail or read garbage | numpy-stl raised an error, trimesh loaded 0 faces, admesh’s logic reads phantom triangles |
| A global face number in 9 bits | Replaced by colours | Capped at 511 faces; colours need 2 to 5 values for any count |
| Hide the STEP file itself in the STL | It doesn’t fit as text, and only sometimes when compressed | Section 4.3 |
| Remember how a freeform face was cut into patches (a second, 3-bit “patch colour”) | Faster but worse, so removed | Organic part: 83 s down to 19 s, but 20 areas left as facets instead of 2 and maximum deviation 0.16 mm instead of 0.12 mm |
| Let the converter’s normal detection handle cone and torus faces | It chopped them into cylinder and sphere pieces | Knob: 131 STEP faces and 3 failed faces, against 8 faces when treated as freeform |
| Label a whole-body mesh by nearest triangle centre | Mislabelled triangles beside face borders | About 1 % wrong on the knob, which turned into 11 false flats and 315 STEP faces. Point-to-triangle distance gave 100 % |
| Carry the attribute field through trimesh’s clean-up | Lost whenever slivers are removed | 5,572 triangles became 5,564 with no attribute data. Matching by position replaced it |
A likely reason saved patch layouts gave a worse result (not proven): the fit of a patch depends on its border curves, and the converter smooths those borders step by step over several passes. Starting from the final layout skips the steps that made it fit.
These are in the order they happened. Most were the AI assistant’s, and they were caught by tests I had asked for.
1. A missing triangle. The knob’s sphere has a pole where one triangle collapses to a line once nearby points are welded. The writer rightly dropped it (1 of 2,959). The tests then compared labels with the wrong triangles, because the output order no longer matched the input. The encoder now reports which input triangle sits at each output position.
2. About 1 % of triangles mislabelled. Fusion’s fallback labels a whole-body mesh from per-face meshes. First version: nearest triangle centre. It looked fine in a picture and mislabelled about 1 % of triangles beside face borders, which a converter read as 11 flat faces and 315 STEP faces. Point-to-triangle distance fixed it.
3. A flat disc shown as rough facets. The converter’s preview showed the top of the bracket’s boss as dark left-as-facets. The rule that called a planar face a facet was “three edges or fewer”; a flat disc bounded by one circle qualifies. It now also requires every edge to be straight.
4. A renamed OpenCASCADE method. The test code
called TopoDS.Edge_s, which doesn’t exist in the OCP 8
build installed. A small compatibility helper hides the difference.
5. A changelog that claimed too much. I compared my changelog with the public repository of the converter on the day I packaged it. Five bullets under the published version had been added after it was published. They moved to the new version’s section.
6. Screenshots taken at the wrong moment. Two documentation screenshots showed a job queue card and a sandbox folder name. They were retaken in a clean state.
7. A plug-in never tried in its real program. I
could not install FreeCAD where this was built: its release downloads,
conda-forge, the Ubuntu package source, the PPA and Flathub were all
blocked, and I did not route around the block. So the workbench ran only
against stand-ins. In its place, tests/freecad_selfcheck.py
runs inside the real program in a few seconds. It has not been run
either. I keep repeating that because it is the most useful thing a
reader could check.
Partway through I asked: would it be easier to hide a STEP file inside the STL as plain text? It was worth checking, because it would give the exact CAD model back with no fitting at all.
It doesn’t fit. The room in an STL grows with the triangle count (2 bytes each), while the size of a STEP file grows with the number of faces:
| Part | STEP file | STEP compressed | Attribute room (2 bytes × triangles) |
|---|---|---|---|
| Bracket, 1,180 triangles at 0.05 mm | 48.1 KB | 6.6 KB | 2.3 KB |
| Knob, 2,958 triangles | 9.3 KB | 2.1 KB | 5.8 KB |
Simple parts with big flat faces fit worst. A text (ASCII) STL has no comment syntax; from reading OrcaSlicer’s reader, extra text lines inflate its triangle count and the load fails (read, not run). A compressed STEP would also need every attribute bit, including bit 15, and would compete with the labels for the same bytes. It could be an optional extra: store it when it fits, fall back to labels when it doesn’t. That is a variation in section 8.6, not a result.
Drawn from the specification by
tools/make_illustrations.py.
Header (80 bytes of ASCII, padded with spaces):
HARDPOINT-STL 1 u=mm tol=0.05 ang=15 crc=e67609a0 by=cad-example
| Field | Meaning |
|---|---|
HARDPOINT-STL 1 |
Marker and format version |
u |
Units: mm, in, cm or
m |
tol |
How far triangles may be from the true surface, in those units; 0 = unknown |
ang |
Meshing or sharp-edge angle in degrees; 0 = unknown |
crc |
CRC-32 of the 48 geometry bytes (normal and corners) of every triangle, in file order |
by |
What wrote the file |
Attribute field (16 bits per triangle, little-endian):
| Bits | Meaning |
|---|---|
| 0–4 | Face colour, 0 to 31. Faces that touch along an edge never share a colour |
| 5–6 | Reserved, 0 |
| 7 | 1 = this triangle is labelled |
| 8–10 | Surface type: 0 unknown, 1 plane, 2 cylinder, 3 sphere, 4 cone, 5 torus, 6 freeform, 7 keep as facets |
| 11–13 | Hardpoint flags for corner 1, 2 and 3 of the triangle |
| 14 | Reserved, 0 |
| 15 | Always 0 (some viewers read bit 15 = 1 as “this field is a colour”) |
The bracket example
(examples/bracket_hardpoint.stl): 1,180 triangles, 16 faces
labelled with 3 colours, 25 hardpoints. Drawn from the file
itself.
Why these choices (every one follows from section 1.3 or from a measurement):
| Question | Chosen | Rejected | Why |
|---|---|---|---|
| Where the labels live | Header and attribute fields | Data after the last triangle | A trailer fails in numpy-stl and trimesh and becomes phantom triangles in admesh |
| How a face is identified | A 5-bit colour; a face is the edge-connected triangles sharing it | A global face number | Colours label any number of faces. Test parts needed 2 to 5 colours |
| Surface parameters (plane, axis, radius) | Not stored; the reader fits them | A parameter table | No room without a trailer. Fitting is cheap once the faces are known, and a reader must verify anyway |
| CAD vertices | 3 bits per triangle, one per corner | A separate vertex list | No room. Needed where only two faces meet but a line runs into an arc |
| Detecting edits | CRC-32 of the triangle data in the header | No check | Stale labels on an edited mesh would mislead the converter |
| “Labelled” flag | Bit 7, and a labelled triangle is written first | A separate guard | Bit 7 of the first attribute byte also defeats the admesh text misread |
| Bit 15 | Always 0 | Free use | VisCAM-style viewers read bit 15 = 1 as a colour |
| Trust | Every label is checked against tolerance | Believe the file | A wrong or stale label must not produce a wrong solid |
Labels helped most on a coarse mesh and on a part with a sphere and a rounded edge. On a clean, fine mesh of flats and cylinders they changed little.
Volume error is the STEP solid against the original CAD solid (bracket 24,490.97 mm³, knob 12,347.01 mm³). Converter: STL2STEP 0.2.0-wip, Classic engine, default settings.
| Part | Triangles | Plain: STEP faces | Plain: volume error | Plain: max deviation | Hardpoint: STEP faces | Hardpoint: volume error | Hardpoint: max deviation |
|---|---|---|---|---|---|---|---|
| Bracket, 0.05 mm mesh | 1,180 | 21 | −0.013 % | 0.050 mm | 17 | +0.001 % | 0.049 mm |
| Same bracket, coarse 0.3 mm mesh | 578 | 90 | −0.075 % | 0.109 mm | 29 | +0.002 % | 0.086 mm |
| Knob, 0.05 mm mesh | 2,958 | 1,103 | −0.18 % | 0.129 mm | 8 | +0.007 % | 0.060 mm |
The knob in the converter’s own 3D preview, from the plain STL and from its hardpoint twin (same triangles; blue = flat, green = cylinder or sphere, grey = freeform surface, dark = left as facets):
| Plain STL: 1,103 faces | Hardpoint STL: 8 faces |
|---|---|
Other measurements:
| Tested here | Simulation or stand-in only | Not tested / unknown | |
|---|---|---|---|
| Format | Write, read, tamper check, verify, strip;
examples pass |
||
| Loaders | numpy-stl, trimesh; admesh text test (logic copied from the source) | The slicer programs themselves | |
| FreeCAD export | Logic against a stand-in filled with OpenCASCADE geometry | Real FreeCAD (0.20 to current); whether older versions accept the meshing call used | |
| Fusion export | Per-face and whole-body routes against a stand-in | Real Fusion; whether per-face meshes close; winding on reversed faces | |
| Converter | Three parts, two engines | Real downloaded models; large meshes (labels tested up to 5,120 triangles) | |
| Systems | Linux | Windows, macOS | |
| Colour-aware viewers | Whether a viewer using the Magics convention tints faces without
COLOR= in the header |
Known limits:
This section is written to be prior art. It describes the method and its variations in enough detail for a person skilled in CAD programming and mesh file formats to build them. I dedicate the methods described in this paper to the public, and I will not seek patent protection for them. Where a variation is untested, it says so; untested variations are disclosed as possibilities, not as results.
A method of storing computer-aided-design face information in a triangle-mesh file in the binary STL format, such that the file remains a valid binary STL, comprising:
key=value fields: units, mesh tolerance,
mesh angle, a checksum of the geometry bytes, and the writer’s name. The
header does not begin with the word solid. Readers ignore
keys they do not know.The reference writer is hstl.encode, about 100 lines of
Python (standard library only), so it can run inside FreeCAD’s and
Fusion’s own interpreters.
This is the behaviour of the converter I measured with, written as a recipe:
| Label | Check | If it passes | If it fails |
|---|---|---|---|
| Plane | Least-squares plane; every corner within 1.5 × tolerance | A true plane | Unaided detection inside that face |
| Cylinder, sphere | The usual fit; if its sanity rules reject it, a least-squares fit of the labelled kind with every corner within tolerance | An analytic cylinder or sphere | Unaided detection inside that face |
| Cone, torus, freeform | None up front | Freeform patch fitting, without a search for primitives | The existing behaviour |
| Keep as facets | None | Kept as exact triangles | |
| Unknown or unlabelled | None | Unaided detection inside that area |
The second cylinder check exists because the usual fit rejects shallow rounds, which it cannot tell from flat strips. A CAD label can.
| FreeCAD | Fusion | |
|---|---|---|
| Mesh | One mesh of the whole shape with one segment per face:
MeshPart.meshFromShape(Shape, LinearDeflection, AngularDeflection, Relative=False, Segments=True) |
Each face meshed on its own through
face.meshManager.createMeshCalculator(), with a surface
tolerance (cm) and a maximum normal deviation (radians) |
| Triangle to face | Segment i is face i in explorer order (checked in FreeCAD’s source) | Known by construction |
| Surface type | Class name of face.Surface (Plane, Cylinder, Sphere,
Cone, Toroid; anything else freeform) |
Last part of face.geometry.objectType (Plane, Cylinder,
Sphere, Cone, Torus; anything else freeform) |
| Hardpoints | shape.Vertexes |
body.vertices, each .geometry |
| Coordinates | mm; the object’s global placement is applied | cm × 10; the body’s own component coordinates |
| If something is off | Segment count differs from the face count: faces are still labelled, types are written as unknown | Per-face meshes don’t close: mesh the whole body and give each triangle the face of the per-face triangle its middle point lies on |
The whole-body fallback uses true point-to-triangle distance (nearest centre mislabelled about 1 %, section 4.2). It searches a uniform grid of cells, in growing shells, until the nearest triangle found is closer than the edge of the searched block.
FreeCAD’s source shows that meshFromShape runs one
incremental mesh over the whole shape, reads the triangulation back face
by face in explorer order, flips reversed faces, and with
Segments=True adds one segment per face. For Fusion,
whether per-face meshes share identical points along a common edge is
not stated in Autodesk’s pages; the writer reports open and wrongly
oriented edges and the add-in switches to the whole-body route when
there are any.
Where the bits go
Carrying more than flags
Header
key=value fields: part name, source file name,
body count, a flag that hardpoints include two-face vertices, a creation
date. Readers ignore keys they don’t know.Writers
Readers
hstl.py strip writes a
plain STL with identical triangles).Other carriers (untested)
hstl.py (about 600 lines, standard
library only): pack_word, unpack_word,
make_header, parse_header,
geometry_crc, read, decode,
colour, encode, write,
transfer_faces, describe, verify,
strip. Command line:
python hstl.py info|verify part.stl,
python hstl.py strip part.stl plain.stl.HardpointSTL):
hardpoint_fc.py (export logic, no GUI code) and
hardpoint_stl_fc.py (the command and dialog).HardpointSTL):
hardpoint_fusion.py (no adsk imports) and
HardpointSTL.py (the command).Appendix A shows a real header and records.
bracket_hardpoint.stl
and bracket_plain.stl into Cura, PrusaSlicer, OrcaSlicer
and Bambu Studio and compare the sliced G-code. If they differ, the
format has failed requirement 2 and I want to know.tests/freecad_selfcheck.py in FreeCAD (0.21, 1.0, 1.1) and
send the output. Then export a part you actually modelled.Nothing here came from one person working alone:
Collaboration in the open does three things a patent never will:
An open invitation:
hstl.encode takes triangles, a face number per triangle, a
type per face and a list of CAD vertices.Everyone who contributes gets credited in the next version.
My part:
The AI assistant’s part (working to that design):
Every measurement in this paper was made by running the code in this repository, or the converter named in section 6, on a Linux cloud machine between 4 and 10 October 2026. None was made on real FreeCAD, Fusion or slicer software.
Two repositories:
a-point-of-contention.
Its front page is this paper, generated from
paper/paper.md. It also holds the reference reader and
writer (code/hstl.py, Python 3, standard library only), the
four example files (data/: two parts, each as a hardpoint
STL and as a plain STL with the same triangles), the full research notes
(notes/DESIGN_NOTES.md) and the specification
(notes/HARDPOINT_STL.md).hardpoint-stl.
The FreeCAD workbench and Fusion add-in (plugins/), the
installers, the tests (test_hstl.py and
test_install.py use the standard library only;
test_hardpoint_cad.py needs cadquery-ocp,
numpy, trimesh, and optionally
numpy-stl; freecad_selfcheck.py runs inside
real FreeCAD) and the scripts that regenerate the example files and
drawings (tools/make_examples.py,
tools/make_illustrations.py).Armstrong, M. (2026). A POINT of contention: STL to STEP conversion shouldn’t be that HARD. A proposed hybrid STL format that is CAD-friendly and slicer-compatible. A hobbyist’s field notes on the hardpoint STL, including a defensive publication of the format. Version 1.0, 15 October 2026. https://github.com/The-Dorkknight/a-point-of-contention
deps_src/admesh/stlinit.cpp (binary or text
test, count from file size).
https://raw.githubusercontent.com/OrcaSlicer/OrcaSlicer/main/deps_src/admesh/stlinit.cppSTLReader.py (Cura loads STL through
numpy-stl).
https://raw.githubusercontent.com/Ultimaker/Uranium/main/plugins/FileHandlers/STLReader/STLReader.pyexchange/stl.py (exact-length check, attribute
field kept).
https://raw.githubusercontent.com/mikedh/trimesh/main/trimesh/exchange/stl.pysolid
rule, the two colour conventions.
https://en.wikipedia.org/wiki/STL_(file_format)src/Mod/MeshPart/App/Mesher.cpp,
AppMeshPartPy.cpp;
src/Mod/Part/App/TopoShape.cpp, BRepMesh.cpp,
Tools.cpp; src/Mod/Mesh/App/Mesh.pyi.
https://github.com/FreeCAD/FreeCADPages and source files opened between 3 and 10 October 2026.
From examples/bracket_hardpoint.stl (59,084 bytes = 84 +
50 × 1,180).
The header, as written (80 bytes, padded with spaces):
HARDPOINT-STL 1 u=mm tol=0.05 ang=15 crc=e67609a0 by=cad-example
The first triangle’s 50-byte record, in hexadecimal:
00 00 80 bf 00 00 00 00 00 00 00 00 normal (-1, 0, 0)
00 00 f0 c1 00 00 80 c1 00 00 20 41 corner 1 (-30, -16, 10)
00 00 f0 c1 00 00 80 41 00 00 00 00 corner 2 (-30, 16, 0)
00 00 f0 c1 00 00 80 c1 00 00 00 00 corner 3 (-30, -16, 0)
80 39 attribute word 0x3980
Reading the attribute word 0x3980 =
0011 1001 1000 0000:
| Bits | Value | Meaning |
|---|---|---|
| 0–4 | 0 | face colour 0 |
| 7 | 1 | labelled |
| 8–10 | 1 | surface type 1: plane |
| 11–13 | 7 (all three set) | all three corners are CAD vertices |
| 15 | 0 | as required |
python src/hstl.py info examples/bracket_hardpoint.stl
prints:
hardpoint STL version 1, 1180 triangles, units mm, written by cad-example
16 faces (1 cone, 8 cylinder, 7 plane), 25 hardpoints
meshed to 0.05 mm, 15 degrees
and
python src/hstl.py verify examples/bracket_hardpoint.stl
ends with RESULT: PASS.