If you've ever tried to 3D print a logo, a QR code, or a flat illustration, you know the usual pain: open Fusion 360 or Blender, trace the outlines, extrude, export, pray the mesh is manifold. It's a 30-minute detour for what should be a 30-second task.
Over the last few months, I've been building a set of browser-based tools that collapse this entire workflow into three clicks: upload image, pick thickness, download STL. No installs. No CAD knowledge. No server-side Python pipeline.
This post walks through the engineering behind that pipeline — the algorithms, the edge cases, and the browser-specific constraints that shaped every decision. If you're building geometry tools for the web, or you're just curious how raster-to-mesh conversion actually works, this one's for you.
Why Browser-Based Matters (And Why It's Hard)
Most 3D conversion tools are either desktop apps (Blender, Meshmixer) or server-side services where you upload a file, wait for a worker to process it, then download the result. Both have problems:
- Desktop apps require installs, have steep learning curves, and don't fit into quick workflows.
- Server-side services process your files on someone else's infrastructure. For designers handling client work, that's a privacy non-starter.
Browser-based processing solves both — but it comes with constraints that shape every architectural decision:
-
No filesystem access. You work with
Fileobjects,Blobs, andArrayBuffers. - Memory ceilings. Mobile Safari will kill your tab around 300–400 MB. Even desktop Chrome gets unhappy past 1.5 GB.
- Single-threaded by default. Any heavy computation blocks the UI unless you push it to a Web Worker.
- No native mesh libraries. You're either writing your own geometry code or wrapping WebAssembly builds of C++ libraries.
With those constraints in mind, let's look at the three conversion problems I solved and how each one informed the pipeline.
Problem 1: Flat Image → 3D Extrusion
The simplest case. You have a PNG with transparency (say, a logo), and you want it as a printable relief. The algorithm is straightforward in theory:
- Decode the image into a pixel array.
- Threshold the alpha channel to get a binary mask.
- Trace the boundary of the mask using marching squares.
- Triangulate the polygon.
- Extrude the 2D triangulation by a chosen thickness.
- Cap the top and bottom with matching triangles.
- Serialize as STL.
Here's the skeleton of the boundary extraction:
function extractBoundary(imageData, alphaThreshold = 128) {
const { width, height, data } = imageData;
const mask = new Uint8Array(width * height);
for (let i = 0; i < width * height; i++) {
mask[i] = data[i * 4 + 3] >= alphaThreshold ? 1 : 0;
}
const contours = [];
const visited = new Uint8Array(width * height);
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width - 1; x++) {
if (visited[y * width + x]) continue;
const code = marchingSquaresCase(mask, x, y, width);
if (code === 0 || code === 15) continue;
const contour = traceContour(mask, x, y, width, height, visited);
if (contour.length > 3) contours.push(contour);
}
}
return contours;
}
Marching squares gives you closed polygons. To mesh them into triangles, I use earcut — a fast ear-clipping triangulator from Mapbox that handles holes (for example, the inside of a letter "O" or "A"):
import earcut from 'earcut';
function triangulatePolygon(outerRing, holes = []) {
const vertices = [...outerRing.flat()];
const holeIndices = [];
holes.forEach(hole => {
holeIndices.push(vertices.length / 2);
vertices.push(...hole.flat());
});
return earcut(vertices, holeIndices, 2);
}
Once you have a 2D triangulation, extrusion is almost trivial. For each 2D triangle, you create two copies at z = 0 and z = thickness. For each edge on the boundary polygon, you create two side-wall triangles. The result is a watertight mesh.
The ugly surprise: winding order. If your top and bottom faces have the same winding, your normals point inward on one side and your slicer will reject the mesh. Flip the bottom face's winding before writing.
I packaged this whole pipeline as a free tool if you want to see the output for your own images: PNG/SVG to STL converter. It handles alpha-masked PNGs and SVG paths in the same pipeline.
Problem 2: QR Codes → Printable 3D Models
QR codes look like a subset of the PNG case, but they're not. A 33×33 QR code has somewhere around 500+ "on" modules, and naive marching squares gives you 500+ disconnected polygons with horrible triangulation.
The right abstraction is to treat modules as unit squares and emit geometry directly:
function qrToMesh(matrix, moduleSize, baseThickness, codeHeight) {
const vertices = [];
const triangles = [];
const size = matrix.length * moduleSize;
emitBox(vertices, triangles, 0, 0, 0, size, size, baseThickness);
for (let row = 0; row < matrix.length; row++) {
for (let col = 0; col < matrix[row].length; col++) {
if (!matrix[row][col]) continue;
const x = col * moduleSize;
const y = (matrix.length - row - 1) * moduleSize;
emitBox(
vertices, triangles,
x, y, baseThickness,
moduleSize, moduleSize, codeHeight
);
}
}
return { vertices, triangles };
}
This is dramatically faster than marching squares and produces a cleaner mesh — and critically, it makes the QR code actually readable after printing, because adjacent "on" modules merge into contiguous raised regions.
One subtle detail: QR scanners need contrast, not just geometry. If you print the whole thing in one color, your phone won't read it. The two real approaches are multi-material (dual-extruder) or printing the raised modules in one color and pausing at the base layer height to swap filament. The STL should reflect that workflow — the base and the raised modules are logically separate even if they ship as one file.
If you want to skip all this and just generate printable QR codes, the tool is here: QR Code to STL generator. It outputs both STL and 3MF so you can use the 3MF version for pause-at-height color changes in PrusaSlicer or Bambu Studio.
Problem 3: Shrinking a 55 MB STL to 4 MB
This is the problem I spent the most time on. An STL from a high-resolution input can easily hit 50+ MB, which is:
- Painful to upload to a slicer over a slow connection
- Impossible to share over Discord/Slack (8 MB limit)
- Wasteful, because 90% of the triangles describe flat regions that could be a single quad
The algorithm here is mesh decimation — specifically, quadric error metrics (QEM) decimation, introduced by Garland and Heckbert in 1997 and still the standard.
The intuition:
- For each vertex, compute a 4×4 "error quadric" matrix that encodes how far the vertex can move before distorting the surrounding planes.
- For each edge, compute the cost of collapsing it (merging its two endpoints into one vertex).
- Repeatedly collapse the lowest-cost edge, updating quadrics, until you hit your target triangle count.
The implementation is heavy enough that I won't drop the whole thing here, but the core data structure is a priority queue of edges keyed by collapse cost:
class DecimationQueue {
constructor(mesh) {
this.heap = new MinHeap();
this.quadrics = new Float64Array(mesh.vertexCount * 16);
for (let f = 0; f < mesh.faceCount; f++) {
const q = planeQuadric(mesh.face(f));
addToVertex(this.quadrics, mesh.face(f).v0, q);
addToVertex(this.quadrics, mesh.face(f).v1, q);
addToVertex(this.quadrics, mesh.face(f).v2, q);
}
for (const edge of mesh.edges()) {
const cost = computeCollapseCost(edge, this.quadrics);
this.heap.push({ edge, cost });
}
}
collapseNext() {
const { edge } = this.heap.pop();
const newVertex = optimalPosition(edge, this.quadrics);
// Merge edge endpoints, update neighbors, recompute costs
}
}
Two pragmatic lessons I learned the hard way:
Lesson 1: Run decimation in a Web Worker, always. A 50 MB STL has around 1M triangles. Reducing to 100K triangles takes about 4–8 seconds on a modern laptop. Doing that on the main thread will freeze your UI and trigger the "page unresponsive" banner.
Lesson 2: Preserve boundary edges. If you naively decimate, the outer silhouette of your model will wobble as edges collapse. For 3D printing this is fatal — dimensional accuracy matters. Tag all vertices that sit on a boundary (any edge shared by only one face) and assign them infinite collapse cost.
The production tool that wraps all of this is here: STL Size Reducer. You can feed it a 50 MB file, tell it "give me 4 MB", and it'll decimate to hit that target. Internally it does binary search on the triangle count until the output file size lands within ~5% of your target.
The Full Pipeline: Stitching It Together
For a typical logo keychain workflow — upload a company logo, extrude to 3mm, add a keyring hole — the whole pipeline runs in under a second in the browser, including the STL write. The decimation pass only kicks in if you fed it a high-resolution source.
The flow is:
- Decode input (PNG, SVG, or QR matrix)
- Vectorize into polygons (marching squares or SVG parse)
- Triangulate with earcut
- Extrude to 3D
- Write binary STL
- Optionally decimate if result is over 10 MB
If you want to inspect the resulting mesh before slicing, I also built an in-browser STL/OBJ/3MF viewer and a 3D model converter that handles STL, OBJ, GLB, 3MF, and PLY. Between those, the pipeline above, and the decimator, you can go from "I have a PNG" to "I have a sliced G-code" without installing a single desktop app.
Binary STL Writing: The One Thing Everyone Gets Wrong
A quick note because I've seen several browser STL exporters do this incorrectly: always write binary STL, not ASCII.
ASCII STL looks like this:
facet normal 0.0 0.0 1.0
outer loop
vertex 0.0 0.0 0.0
vertex 1.0 0.0 0.0
vertex 1.0 1.0 0.0
endloop
endfacet
It's human-readable but around 5–7x larger than the binary equivalent. A 1M-triangle mesh that's 50 MB in binary becomes 280+ MB in ASCII. Your users will run out of browser memory before they can even download.
Binary STL is 84 bytes of header plus 50 bytes per triangle:
function writeBinarySTL(vertices, triangles) {
const triCount = triangles.length / 3;
const buffer = new ArrayBuffer(84 + triCount * 50);
const view = new DataView(buffer);
view.setUint32(80, triCount, true);
let offset = 84;
for (let i = 0; i < triCount; i++) {
const [a, b, c] = [
triangles[i * 3],
triangles[i * 3 + 1],
triangles[i * 3 + 2],
];
const n = computeNormal(vertices, a, b, c);
view.setFloat32(offset, n.x, true); offset += 4;
view.setFloat32(offset, n.y, true); offset += 4;
view.setFloat32(offset, n.z, true); offset += 4;
for (const idx of [a, b, c]) {
view.setFloat32(offset, vertices[idx * 3], true); offset += 4;
view.setFloat32(offset, vertices[idx * 3 + 1], true); offset += 4;
view.setFloat32(offset, vertices[idx * 3 + 2], true); offset += 4;
}
view.setUint16(offset, 0, true); offset += 2;
}
return new Blob([buffer], { type: 'model/stl' });
}
Little-endian everywhere (the true argument to DataView methods). Every major slicer reads binary STL fine. There's no reason to ship ASCII in 2026.
What I'd Do Differently
Three things I'd change if I were rebuilding this:
1. Use WebAssembly for decimation from day one. I wrote the QEM decimator in pure JS first, and it worked, but a WASM port of MeshOptimizer would be roughly 3x faster with half the memory footprint. If your mesh library has a C++ reference implementation, wrap it, don't rewrite it.
2. Offload to WebGPU where available. For rasterizing SVG paths into a mask, and for computing vertex adjacency during decimation, WebGPU compute shaders would destroy my CPU implementation. The fallback path still needs to exist for Safari and older browsers, but on Chrome/Edge it's a free 10x speedup.
3. Design the STL writer to stream. Right now I build the whole mesh in memory, then serialize. For very large meshes (5M+ triangles, which you'd never 3D-print but some users try), I should stream triangle batches directly into the output Blob.
Try It, Break It, Tell Me What Went Wrong
If you've got a weird image, a stubborn SVG, or a QR code with a logo overlay — throw it at these tools and see what happens:
Everything runs client-side on Omnvert — no uploads, no accounts, no rate limits. If you find a mesh it chokes on, reply here or drop a comment and I'll dig into it.
Happy printing.
Written by the Omnvert team. We build privacy-first browser tools for images, PDFs, audio, 3D models, and network diagnostics.
Top comments (0)