Watertight, on the bed, and the wrong shape
I generate STL files from Python. No CAD — polygons in code, extruded into meshes, checked and sliced. A plate came off the printer with 4.00 mm tongues of material reaching into openings that were meant to carry a clean 1.40 mm border. Every printability check I had was green, and had been green the whole time.
They were all correct. None of them was broken, and none of them could have caught this, because none of them is asking the question I needed answered.
What those checks actually assert
The gate I run before anything reaches the printer is four questions, and they are good questions:
- Watertight — is the mesh a closed surface, with no boundary edges?
- On the bed — does it sit inside the build volume, at z ≥ 0?
- Islands and cantilevers — is any material floating, or reaching out over nothing?
- A headless slice — does the real slicer accept it, with supports off, at 0.00% overhang?
ELI5: what does "watertight" mean?
A 3D model is a shell of triangles. Watertight means the shell is completely sealed — every edge is shared by exactly two triangles, so there are no holes and the software can tell inside from outside. It is the minimum a slicer needs in order to know where to put plastic.
Every one answers a version of is this a closed solid a printer can make? None answers is this the solid I meant? You don't usually need the second question, because you drew the thing and can see it. Generate it in code and nobody ever looks.
The offset
The border came from insetting each opening's outline by 1.40 mm. My inset did the obvious thing: walk the outline, and move each vertex inward along the bisector of its two edges.
ELI5: what is offsetting a polygon?
Growing or shrinking a shape by a fixed distance, so the new outline is everywhere that far from the old one — like drawing a second line 2 mm inside a traced shape. It sounds like it should be easy, and for a rectangle it is. It stops being easy the moment the shape has sharp corners or tight curves.
A vertex on the bisector has to move further than the offset distance, or
the two new edges won't meet. It moves d / cos(θ/2), where θ is
the angle between the edge normals. Fine at 90°. As the corner sharpens the
cosine goes to zero and the vertex shoots off into space. This is the miter
blow-up, and it is old news — most offsetting libraries expose a miter
limit capping how far the vertex may travel, precisely so this can't
happen.
Mine had one. It was set to allow five times the offset distance — which is also Shapely's default. Clipper, which most slicers use, caps it at twice. There is no single convention, which is part of the problem: the number is a knob most people never turn.
# the vertex travels d/cos along the bisector; the floor caps how far.
# 0.2 permits 5x the offset, matching Shapely's default. Clipper uses 2x (0.5).
cosh = max(miter_floor, dot(edge_normal, bisector))
out.append((p[0] + bx * d / cosh, p[1] + by * d / cosh))
At five times, two 21° valleys in one outline became 7.00 mm tongues poking into an opening where the inset is 1.40. Nineteen of the twenty-two outlines in the part grew at least one. They had been there for months and nobody had seen them, because the border is black and the plate is black. They became obvious the first time the first layer drew that border in a contrasting colour.
Capping the miter did not fix it
Tightening the limit to Clipper's 2× took the worst excursion from 7.00 mm to 4.00 mm. It did not take it to 1.40 mm, and 4.00 mm is what printed.
There is a second failure, and no miter limit reaches it. Where the outline curves more tightly than the offset distance, the inset inverts. An arc of radius 0.39 mm, inset by 1.40 mm, turns inside out: the new points cross over each other and the ring folds back through itself. That is not a corner-join problem, so capping the join does nothing for it.
I went looking for these with an angle test — flag any vertex that turns more sharply than a threshold. It found nothing useful, and the reason is worth knowing. On a densely resampled curve each step turns by a few degrees, so the sharpness is spread across dozens of points: the tightest place on the outline measured 119.7° at its worst single vertex, which is barely a corner. Curvature is the accumulation, not any one turn.
Why nothing downstream noticed
Both failures produce the same thing: a closed ring that crosses itself. My extruder turns a ring into a solid by scanline — for each row, find where the ring crosses it, sort the crossings, fill between them in pairs. That is an even-odd fill, and it is why a fold is invisible.
ELI5: what's an even-odd fill?
A rule for deciding which parts of a shape are "inside". Draw a line across the shape and count the edges you cross: after the first you're inside, after the second outside, and so on. It's simple and fast, and it never asks whether the outline made sense — it just alternates.
A self-crossing loop still closes, and the scanline still produces matched pairs of crossings. So the mesh is watertight, sits on the bed, has no islands and no cantilevers, and slices at 0.00% overhang with supports off. It is a perfectly good solid. It just isn't the one I drew — where the ring folds, the fold cancels itself into stray slivers, and where it spikes you get a tongue.
When I finally measured it, the vertex-bisector inset was producing 197 self-intersections across those 22 outlines — every single outline, not a handful of bad ones.
Two more of the same shape
The same function had two other silent failures. For a long time it ignored the sign of the offset and always shrank; every caller happened to want an inset, so nothing noticed until one asked for a grow and got a 1.9 mm shrink. Right shape, wrong size, in the direction that makes parts not fit. And offsetting a notched outline outward folds every concave valley through itself, leaving a loop that points back inside the original — measured here, a 1.9 mm outward offset reached 1.60 mm inward. Check that one by area and the shape has grown, so the obvious sanity test passes too.
What fixed it
Erosion, rather than moving vertices. The inset of a shape by d
is the set of centres of discs of radius d that fit entirely
inside it — a definition in terms of what fits, not where a vertex goes. It
has neither failure by construction: a disc cannot fit in a spike, and it
cannot fit in a curve tighter than itself, so those regions simply have no
inset and the result says so.
In practice: rasterise the polygon, run a Euclidean distance transform,
subtract d, take the zero contour with marching squares.
Against a circle, where the exact answer is known, r=40 eroded by 5 comes
back between 35.003 and 35.025 against a true 35.000. Against the real
outlines, worst excursion 4.00 mm → 1.40 mm, exactly nominal, and 197
self-intersections → 0.
There is a trap inside the fix, and I fell into it first. Marching squares gives you a bag of unordered segments to chain into a ring, and I tried two reasonable heuristics — order by the nearest point on the original outline's arc length, and a greedy nearest-unvisited walk. Both left three or four jumps of 35 to 57 mm in each ring. My fix for self-crossing rings produced self-crossing rings, and every check passed on those too.
The fix for the fix is to stop matching by proximity. Each segment ends on a
specific grid edge, so key the endpoints by edge identity —
('h', row, col) or ('v', row, col) — and two cells
sharing an edge share the crossing exactly. No tolerance, nothing to tune.
The check I should have had
None of this needed a clever check. It needed one that asks whether a ring crosses itself, which is about twenty lines:
def self_intersections(poly):
"""Count crossing pairs of non-adjacent edges in a closed ring."""
n = len(poly)
if n < 4:
return 0
def turns(p, q, r):
return (q[1]-p[1]) * (r[0]-q[0]) - (q[0]-p[0]) * (r[1]-q[1])
hits = 0
for i in range(n):
a, b = poly[i], poly[(i + 1) % n]
for j in range(i + 2, n):
if i == 0 and j == n - 1:
continue # they share a vertex
c, d = poly[j], poly[(j + 1) % n]
d1, d2 = turns(a, b, c), turns(a, b, d)
d3, d4 = turns(c, d, a), turns(c, d, b)
if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
hits += 1
return hits
Two honest limits. It is O(n²) — fine on a resampled outline of a few
hundred points, not fine on tens of thousands. And the strict
> 0 comparisons detect proper crossings only; segments that
merely touch at a point, or overlap while collinear, need the degenerate
cases handled. A fold produces a proper crossing, which is what I was after.
I verified it the way I now verify any check: put the defect back. Build the part with the old vertex-bisector inset and 44 of the 69 rings it checks go red — several per opening, since each one contributes the outline as drawn plus every ring derived from it. A check that has only ever been seen green is not evidence that it works.
The check I nearly shipped instead
My first attempt measured the distance between consecutive points and flagged outliers, on the reasoning that a scrambled ring has a big jump in it. It does, and that test finds one. It also fires on correct rings: the largest step as a fraction of the shape's own diameter ran 0.56 to 0.89 across these outlines while they were perfectly fine, because a rectangle's longest straight edge is naturally about that fraction of its diagonal. A ring made of a few long edges looks exactly like a ring with a jump in it. That reading nearly convinced me the erosion fix was broken.
Self-intersection separates the two cases and step size does not. If a new check disagrees with a result you have independently validated, the check is a suspect too.
What I'd tell you to check first
If you generate geometry in code and your validity checks are green while the part is wrong, the gap is probably this one: mesh validity is a claim about the mesh, not about the shape. An even-odd fill closes a folded loop as happily as a good one.
So: if you offset polygons by moving vertices, test the resulting rings for self-intersection before extruding. If your shapes have sharp corners, set a miter limit and know what multiple of the offset yours permits. And if they have curves tighter than the offset distance, no miter limit will save you — reach for erosion, or a straight skeleton, or a library that has already solved this.
Then break the code on purpose and watch your new check go red. Two of the checks in this story were written to catch defects they could not actually see, and I only found that out by trying.
The offsetting and erosion code, the checks, and the commit messages with all the measurements are in printing-toolkit, which is MIT licensed. There is more about what I use it for on the 3D printing page.