Turn an image or a heightmap into an STL your slicer accepts — watertight, scaled in millimetres, with detail clamped to what the nozzle can actually print.
Open the converter → — it runs entirely in the browser tab. No upload, no account, no build step, no network request after the page loads.
The demo's backlit view: what the panel looks like with a light behind it, computed from the actual thickness of the mesh you are about to download.
import { imageToSTL, lithophane } from 'image-to-stl';
const { stl, report } = imageToSTL(imageData, lithophane({ widthMm: 100 }));
console.log(report.watertight, report.bounds.widthMm, report.triangleCount);Zero dependencies. Runs in a browser and in Node. MIT.
Almost every "image to STL" tool, including the popular ones, breaks in the same three places. They are worth naming, because each has a specific fix and a specific cost.
1. It emits a surface, not a solid.
A heightmap is naturally a sheet of triangles. Export that sheet and the slicer sees a part with no back, no walls, and zero thickness. It either refuses the file or prints it as a wafer with holes. The fix is to close the mesh — top, bottom, four walls, shared vertices — which is what heightfieldToSolid() does. analyze() then counts the edges and reports openEdges: 0 rather than assuming it.
2. The scale is a guess.
STL has no units. A mesh built in normalised coordinates slices at two millimetres tall, and then the wall thickness is wrong too, because it was scaled by the same factor. Here the physical size is an input, not an afterthought: you pass widthMm: 100, and the sample spacing, the thickness range and the exported coordinates are all derived from it. The report's bounding box is the check.
3. The detail is finer than the printer.
A 2000 px photo across a 100 mm print is 0.05 mm per pixel. A 0.4 mm nozzle cannot place a feature thinner than about 0.4 mm, so roughly seven out of eight pixels are asking for something the machine cannot do. The slicer averages them away, or turns them into strings and blobs, and you paid for four million triangles to get there. planResolution() samples at two points per nozzle diameter instead — 500 samples across, 375k triangles, same visible result.
That third fix is the one people push back on, so it is worth being blunt: the image is averaged down on purpose, and the discarded pixel count is reported back to you. If you want the fine detail anyway, raise samplesPerNozzle; you will get a bigger file, a slower slice, and a print that looks the same.
npm install image-to-stlOr copy src/ into your project. It is plain ES modules with no build step and no dependencies.
<input type="file" id="file" accept="image/*">
<script type="module">
import { imageToSTL, lithophane } from './node_modules/image-to-stl/src/index.js';
document.getElementById('file').addEventListener('change', async (e) => {
const bitmap = await createImageBitmap(e.target.files[0]);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const ctx = canvas.getContext('2d');
ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
const { stl, report } = imageToSTL(imageData, lithophane({ widthMm: 100 }));
if (!report.ok) console.warn(report.warnings);
const url = URL.createObjectURL(new Blob([stl], { type: 'model/stl' }));
// ... hand `url` to an <a download>
});
</script>The library never touches the DOM or the filesystem. It takes pixels and returns geometry, which is the whole reason the same file works in both environments.
There is no image decoder in here, and that is deliberate: a decoder is the one part that would force a dependency. Feed it values instead.
node examples/heightmap-to-stl.mjs examples/sample.pgm out.stl --width 80import { fieldToSTL } from 'image-to-stl';
const { stl, report } = fieldToSTL({
field, // Float32Array of 0..1, row-major
cols: 400,
rows: 250,
widthMm: 80,
zMinMm: 0.8,
zMaxMm: 3,
});examples/heightmap-to-stl.mjs reads a PGM (a two-line header and raw bytes) so the example stays dependency-free. If you already have sharp, jimp, or a canvas, decode to a Uint8ClampedArray and call imageToSTL() instead.
| Call | Returns |
|---|---|
imageToSTL(imageData, preset, opts?) |
{ stl, mesh, report, meta } — the whole pipeline |
imageToMesh(imageData, preset) |
{ mesh, meta } — same, without serialising |
fieldToSTL({ field, cols, rows, widthMm, … }) |
{ stl, mesh, report } — skip the image stage |
imageData is anything with { data, width, height } — a DOM ImageData works unchanged.
| Preset | Defaults | Use for |
|---|---|---|
lithophane() |
100 mm wide, 0.8–3.0 mm, inverted | Backlit panel: dark prints thick, photo appears when lit |
relief() |
100 mm wide, 1.6 mm base + 3 mm relief, 3 mm border | Raised panel, read under oblique light |
heightmap() |
100 mm wide, 0.4–5.0 mm, no normalisation | The image already is a heightmap (DEM, DCC export) |
All three accept overrides: lithophane({ widthMm: 60, nozzleMm: 0.6 }).
rgbaToLuma(data, { channels, background }) // RGBA bytes -> 0..1 luminance
lumaToHeight(luma, { invert, gamma }) // luminance -> 0..1 height
normalizeHeights(heights, { low, high }) // clip + stretch, reports contrast
planResolution({ pixelWidth, pixelHeight, widthMm, nozzleMm, samplesPerNozzle })
areaResample({ data, width, height, cols, rows }) // area-average, not point sample
applyBorder(field, cols, rows, borderMm, pixelSizeMm, edgeValue)
heightfieldToSolid({ field, cols, rows, widthMm, heightMm, zMin, zMax })
computeNormals(positions, indices)
toBinarySTL(positions, indices, { name })
toAsciiSTL(positions, indices, { name, precision })
analyze(mesh, { nozzleMm, layerHeightMm, bedMm })Three separate counts, because they fail separately:
openEdges— edges used by exactly one triangle. Non-zero means not watertight.nonManifoldEdges— edges used by more than two. Non-zero means a slicer will guess.misorientedEdges— directed edges with no opposite twin. Non-zero means part of the mesh is inside-out.
Plus signed volume (> 0 means normals face outward), surface area, bounding box, and a material estimate in metres of filament and grams of PLA at 100% infill.
Every warning carries a number, not an adjective: "Thinnest section is 0.60 mm — under 1.5× the 0.4 mm nozzle." A report you cannot argue with is a report you can act on.
The defaults come from what survives an FDM printer, not from round figures.
| Nozzle | Minimum wall worth printing | Why |
|---|---|---|
| 0.25 mm | 0.50 mm | 2 perimeters at 0.25 |
| 0.4 mm | 0.80 mm | 2 perimeters at 0.4 — the default |
| 0.6 mm | 1.20 mm | 2 perimeters at 0.6 |
| 0.8 mm | 1.60 mm | 2 perimeters at 0.8 |
Thickness range for a lithophane. 0.8 mm to 3.0 mm is the range that reads well in white PLA at 1.5–2 mm layers of light behind it. Go thinner at the bottom and the panel is fragile and translucent everywhere; go thicker at the top and the dark end crushes to black with no detail left in the shadows.
Sample spacing. samplesPerNozzle defaults to 2, which puts two samples in every nozzle-width. Below 1.5 the slope becomes visibly faceted; above 3 you are paying for triangles the extruder smooths away.
Transmission is not linear. Thickness eats light exponentially (Beer–Lambert), which is why a linear brightness-to-thickness map comes out muddy in the midtones. Compensate with gamma: 0.8 in lithophane() if the midtones look flat — or use the demo's Backlit preview, which applies exp(-1.6·t) so what you see is roughly what the lamp will show.
Orientation. Row 0 of the image is the top of the model (max Y), matching image coordinates. The mesh is closed, so it imports the right way up in Cura, PrusaSlicer, Bambu Studio and Orca without a rotate step.
This library maps brightness to thickness. That is a real constraint, and it decides the category of work it fits.
A photo is 2.5D. It records how much light hit a surface, not how far away the surface was. Nothing here can invent the back of a head, wrap a texture around a curved body, or recover the dent a shadow is hiding. If the geometry is a relief — a lithophane, a logo, a map, a terrain tile — this is the right tool and the output is exact, because the geometry is the height field.
If you need an actual 3D reconstruction from a single photo — a bust, a figure with volume, something with a back — that is a different problem with different failure modes, and it is what PhotoSTL does: one photo in, a printable draft out, with print recipes and a printability check built around the slicer rather than around the mesh. Same destination, different starting assumption. Use this one when the geometry is already flat; use that one when it is not.
The honest summary: deterministic and free here, generative and paid there. Neither replaces the other, and mixing them up is how people end up disappointed by both.
| Tool | Environment | Output | Notes |
|---|---|---|---|
| image-to-stl | Browser + Node, zero deps | Closed solid, mm-scaled | This library. Library-first: the demo is a client of the same API. |
lithomaker, stl-ready-image, hmstl |
Python desktop | Varies; several emit an open surface | Solid tools, but they want a Python environment and a GUI. None is importable from a JS app. |
| Slicer import of a heightmap | Cura / PrusaSlicer plugins | Varies | Convenient, but you cannot put it in a product, a pipeline, or a test. |
| AI image-to-3D services | Cloud | Generated mesh, arbitrary scale | Different job. Needs print prep afterwards — see above. |
What is genuinely missing from the JS ecosystem is not another lithophane GUI; it is a small, testable, dependency-free module that turns a brightness field into a closed mesh with correct units. That is the gap this fills.
npm test # 17 tests: topology, scale, resampling, STL round-trip
npm run demo # http://localhost:4173The test that matters most: build a mesh, serialise it to binary STL, read it back as a vertex soup, rebuild topology from coordinates alone — and assert that a slicer's view of the file is still closed. If that passes, the file is genuinely watertight rather than merely intended to be.
MIT. See LICENSE.
