Zero-shot search over a collection of printable-miniature STLs. No labels, no training: each mesh is rendered headless, its up-axis resolved, the views embedded with SigLIP, and then any text query — or any other model — is scored against those embeddings.
Ask it for "wizard with a staff" and it finds one, in a library where the
filenames are things like 32_Unsupported_Renard_BodyNoMask.stl.
The product is the caches, not the CSV. A classify run writes, per model:
- a resolved pose (which way is up, and which view is the front),
- per-view embeddings as
.npy, - optionally the renders themselves.
Everything else reads those. results.csv is a thin top-3 dump; the real
querying happens in the REPL or over the HTTP API, both of which are
milliseconds per query once the caches exist because no rendering happens
again.
- Python 3.12, a
.venvmanaged with uv (uv pip install --python .venv/bin/python <pkg>). - A GPU for SigLIP (CUDA if present, CPU otherwise) and a working headless Open3D/Filament for rendering. On the development machine those are two different devices — rendering on an AMD iGPU, SigLIP on an RTX 4060 — which is a measured win, not a requirement.
- Somewhere with STL files. The library is not in the repo.
# 1. build the caches (the slow part: renders, poses, embeddings)
.venv/bin/python classify_stls.py /path/to/stls --cache-dir embed-cache
# 2. query interactively
.venv/bin/python test_categories.py --cache-dir embed-cache
# 3. or serve the same queries over HTTP
.venv/bin/python serve_api.py --cache-dir embed-cache --port 8077After the first run, the cache-identity flags (--views, --elevations,
--model, …) default to what that run recorded, so later commands usually
need only --cache-dir.
<enter> reloads categories.txt and classifies everything; any other text is
a one-off query; :find, :pool, :min and :raw adjust the search. Results
are clickable links to the model's render.
Start it before anything that consumes it — nothing launches it for you,
and a consumer polling http://127.0.0.1:8077/status sees a connection
refusal until you do:
.venv/bin/python serve_api.py --cache-dir embed-cache --port 8077It answers /status immediately with ready: false and serves queries once
SigLIP is resident (16.0 s measured against embed-cache2, 2801 models,
SigLIP on the 4060 — the model load is nearly all of it; LEARNINGS,
2026-08-19); /query, /similar, /poses and /under return 503 in between,
so a consumer can tell warming from not-running.
Six routes over the same code the REPL uses — GET /status, POST /query,
POST /similar, POST /poses, POST /under, POST /reload. It is
loopback-only and unauthenticated by design: the intended caller is another
local service, not a browser.
/poses is the one that carries no search: paths in, the same pose block a
hit carries out (null for anything this index has not walked), so a consumer
listing a directory can orient models it never searched for. It is a lookup in
the pose cache — no GPU, no lock.
/under is the step before that: a directory in, every model this index holds
beneath it with its pose out, sorted by root-relative path and cut at a
required limit. It answers a folder's contact sheet from the store, so a
consumer whose own walk cannot find the posed kits does not have to walk at
all — the same no-GPU, no-lock, no-per-model-I/O budget /poses keeps.
/query takes a path to search within, and reports what it could not cover
— a directory that exists but has never been classified answers 200 with
status: "unindexed" rather than looking like "nothing matched". Full spec in
docs/api/surface.md; it generates its own OpenAPI
document at /openapi.json.
cluster_models.py |
k-means over the cached embeddings — discover groupings without categories |
migrate_cache_keys.py |
re-key a cache written under an older scheme, instead of re-rendering it |
unpack_models.py |
some sets ship per-model zips inside the download, so the walk sees no STLs and the whole set is silently absent; this extracts them |
eval/ |
measurement harnesses; every number in LEARNINGS.md came from one |
The docs are load-bearing here — most of the non-obvious decisions were measured rather than chosen, and the measurement is written down.
CLAUDE.md— the orientation document, and the constraints that cost real time to discover.LEARNINGS.md— index of dated write-ups; every measured number resolves through it.OPEN_QUESTIONS.md— open work, amended in place with answers rather than deleted.docs/actor-refactor/interfaces.md— the live spec of the pipeline: what each module may import, and why.docs/api/— the query API's spec and its phased plan.docs/cache-rebuild.md— debt that only a full cache rebuild can pay off. Read before rebuilding.
.venv/bin/python -m pytest tests/ -qThe renderer is faked at the op-log level and the API is exercised against a
stub embedder, so the bulk needs no GPU and no model download. The few tests
that do need something — CUDA, a local ollama, a 7z binary — skip when it is
absent rather than failing.