STL Browser: one searchable home for every 3D print file

If you 3D print for long enough, your files end up everywhere. Mine are spread across a NAS share, my laptop and OneDrive. The same model exists as Seed_Plug_Matrix_V4.stl, Seed_Plug_Matrix_V5.stl, Seed_Plug_Matrix_V5 (1).stl and a _LowTop variant. Some STLFlix 3MF files hold 20 million triangles. Finding the one I want meant clicking through Explorer and opening each file in a slicer.

So I built STL Browser, a small web app that indexes every STL and 3MF I own and shows them as a searchable grid of thumbnails, with a 3D viewer for each one. I built it with Claude Code as a pair programmer. I set the requirements and tested each step against my real library, and Claude wrote the code, the tests and the documentation. This post covers how it works and the parts I found interesting. The full source is at the end.

What it does

  • One grid for everything. It scans any number of folders (NAS shares, local drives, OneDrive) into one catalog, with a folder tree, search and STL/3MF filters.
  • Versions collapse into one card. _V5, (1) copies and named variants are grouped into a single model, newest version first.
  • Kits show as one card. A chess set with 25 parts, or a drawer system with boxes, roofs and baseplates, shows up as one "Kit · 25 parts" card instead of 25 cards.
  • Thumbnails and a 3D viewer. It uses the slicer's own preview image when a 3MF has one, and renders one when it doesn't. You can spin any model around in the browser before downloading it.
  • Favorites, "printed" and tags, a duplicate finder (mine found 158 MB of identical files), open in slicer buttons, a settings page and automatic rescans.
  • Runs locally or in Docker, with the library mounted read-only. The app never writes to your model folders.

How it's built

It's deliberately boring tech:

  • Backend: Python with FastAPI, trimesh to read meshes, and SQLite for the catalog.
  • Frontend: React and TypeScript built with Vite, with three.js for the 3D viewer.
  • One process. The backend serves the built frontend, so the app is one command on a PC and one container in Docker.

Scanning runs in two passes. The first pass only lists files, which is fast even over the network, so models show up in the grid within seconds. The second pass works through each file in the background, reading its size and triangle count and making a thumbnail. The grid fills in while you browse.

Grouping versions by name

The core trick is splitting each file name into a base name, a version, a variant and a copy marker:

Seed_Plug_Matrix_V5_LowTop (1).stl
└── base ──────┘ └v┘ └var─┘ └copy┘

Files in the same folder with the same base name become one model. Two regular expressions do most of the work:

# " (1)", "(2)", " - Copy", "_copy", "copy 3"
_COPY_RE = re.compile(r"(?:\s*\(\d+\)|[\s_-]*-?\s*copy(?:\s*\d+)?)$", re.IGNORECASE)

# A version token on its own: V5, v1.2, ver3, rev2, version_4, r12
_VERSION_RE = re.compile(
    r"(?:^|[\s_.-])(?P<tok>(?:v|ver|version|rev|r)[\s_.-]?(?P<num>\d+(?:\.\d+)*))(?=$|[\s_.-])",
    re.IGNORECASE,
)

The edge cases are where it gets fun. A version at the very start of a name is part of the name, so V8 engine stays V8 engine. When a name has two version tags, like bracket_v2_v3, the last one wins. And anything after the version tag is the variant, so _V5_LowTop is version 5, variant "LowTop".

Don't read the whole file over the network

To find duplicates and to cache thumbnails, every file needs a fingerprint. Hashing whole files over SMB was far too slow: a 1.7 GB library at 6 MB/s takes five minutes, every scan. Instead, the fingerprint hashes the size plus the first and last megabyte:

def fingerprint(path: Path, size: int) -> str:
    """SHA-1 of the size plus the first and last megabyte."""
    h = hashlib.sha1(str(size).encode())
    with path.open("rb") as f:
        h.update(f.read(FINGERPRINT_CHUNK))
        if size > 2 * FINGERPRINT_CHUNK:
            f.seek(-FINGERPRINT_CHUNK, os.SEEK_END)
            h.update(f.read())
    return h.hexdigest()

For mesh files this is effectively unique, and rescans skip any file whose size and modified time haven't changed, so they finish in seconds.

Keeping huge 3MFs from taking the server down

A 3MF is a zip of XML, and parsing it takes roughly 20 times its uncompressed size in RAM. One of my STLFlix files would have needed several gigabytes, and in the first version a single file like that could take the whole server down with it.

The fix came in two parts. First, size limits: files over a configurable size aren't opened as meshes, though they still get the slicer's embedded thumbnail and can be downloaded. Second, all mesh work runs in a separate worker process that's recycled every 25 files. If a file still runs out of memory, only that worker dies, the file is marked as failed, and the server carries on.

def run(self, fn, *args):
    executor = self._get()
    try:
        return executor.submit(fn, *args).result()
    except BrokenProcessPool as e:
        # The worker died, almost always from running out of memory.
        ...
        raise WorkerCrashed("the file used more memory than was available") from e

There are two pools, so a 3D view you just clicked on never waits behind the background pass over the whole library.

A bug worth sharing: one SQLite connection, many threads

After the first real-library test, thumbnails failed at random with sqlite3.InterfaceError: bad parameter or other API misuse. FastAPI runs plain endpoints on a thread pool, and the app shared one SQLite connection across all of them. With a grid loading 50 thumbnails at once, two threads would sometimes use the connection at the same moment. The fix is one connection per thread:

_local = threading.local()

def _db():
    if not hasattr(_local, "conn"):
        _local.conn = db.connect(settings.db_path)
    return _local.conn

WAL mode lets all those connections read while the background scan writes. A test that fires over 100 requests from 16 threads at once makes sure it stays fixed.

Spotting kits automatically

Kits were the most recent feature. Folders full of parts for one project, like a chess set, were swamping the grid. Most of my kits come from STLFlix or Thingiverse, and their downloads have recognizable shapes, so the app guesses. A folder is a kit if:

  • it has subfolders named like Monocolor, Multicolor or Multiparts,
  • its name ends in an STLFlix product id, like Castle_Tower_Chess_d437bb2ca5, or
  • it has a files subfolder (the Thingiverse layout).

Anything else, like the drawer families in a modular storage system, you mark yourself with "Show as one kit" or "Make each subfolder a kit". Your choices are stored separately from the catalog, so they survive rescans and rebuilds. Kits are worked out when the grid is listed, not when files are scanned, so marking one takes effect immediately. A search for "pawn" finds the chess kit through its parts.

Packaging it in Docker

The Docker image builds the frontend with Node in one stage and copies it into a slim Python image. Docker Compose mounts the NAS share straight into the container over SMB, read-only, with the login kept in a .env file that git ignores:

volumes:
  nas-3dprints:
    driver: local
    driver_opts:
      type: cifs
      device: "//${NAS_HOST}/${NAS_SHARE}"
      o: "username=${NAS_USER},password=${NAS_PASSWORD},vers=3.0,uid=1000,gid=1000,ro"

The app runs as an unprivileged user, not root. A small entrypoint script fixes ownership of the data folder and then drops privileges with setpriv. One thing doesn't make the move: the "open in slicer" buttons. A container can't start programs on your desktop, so the API only allows that when the request comes from the same computer, and the buttons hide themselves otherwise.

Working with an AI pair programmer

A few things made this go well:

  • Small steps, tested on real data. We went scanner, then thumbnails, then UI, then extras, then Docker. Each step was tested against my actual NAS before moving on. The memory problem and the threading bug only showed up with the real library.
  • Documentation as you go. I asked for docs with every step, so the repo has a user guide, configuration and API references, architecture notes and a changelog that all match the code.
  • Tests for every bug. Each fix came with a test (31 so far), which made the later features much less nerve-racking.

Get the code

I'll add a link to the code later. I have some additional edits that I am making before publishing. If you want to send me an email before I get this updated send on to [email protected].