Skip to content

About

Turn an image or heightmap into a watertight, millimetre-scaled STL you can slice without repair. Zero dependencies, runs in the browser.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

image-to-stl

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 image-to-stl browser converter: a photo has been turned into a lithophane previewed in backlit mode, next to a printability report listing watertight, manifold, oriented normals and a 500 x 375 sample grid

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.


The three things that go wrong

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.


Install

npm install image-to-stl

Or copy src/ into your project. It is plain ES modules with no build step and no dependencies.

Use in the browser

<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.

Use in Node

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 80
import { 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.


API

High level

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.

Presets

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 }).

Stages

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 })

What analyze() actually checks

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.


Choosing numbers that print

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.


When a height field is not enough

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.


How it compares

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.


Develop

npm test         # 17 tests: topology, scale, resampling, STL round-trip
npm run demo     # http://localhost:4173

The 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.

License

MIT. See LICENSE.

About

Turn an image or heightmap into a watertight, millimetre-scaled STL you can slice without repair. Zero dependencies, runs in the browser.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages