Counting slots fixed one half of the problem; this fixes the other. A slot is
now filled by a spool of the same material whose colour is close to what the
slot asks for, one spool per slot, best matches assigned first. So four PLA
slots in black, blue-grey, grey and white are not satisfied by four spools of
white — only the white slot is.
Closeness is redmean distance with a tolerance of 50, which treats genuinely
near shades as the same colour while keeping #000000 and #161616 (about 66
apart) distinct. Slot colours arrive as #RRGGBB and #RRGGBBAA — the alpha
channel is parsed off rather than making the colour unreadable.
The model page now lists every slot: its colour, which spool fills it, and the
nearest thing on the shelf when nothing does.
Two things this fixes in code I wrote an hour ago: renderDetailPrintable still
called a function the rewrite removed, so opening any model would have thrown;
and the README claimed colours weren't matched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from the library: a four-colour AMS bookmark showed as "printable now"
against a single spool of Jade White. The matching collapsed the project's four
slots into the set of materials it used — {PLA} — and then asked only whether a
PLA spool existed. A sixteen-colour model would have passed the same way.
Ignoring colour is right: printing a bookmark in red instead of white still
prints. Ignoring slot count is not: four slots need four spools loaded. Matching
now counts slots per material and reports the shortfall — "needs 3 more PLA",
and on the model page "Needs 4 × PLA. You have 1 of 4 PLA."
The tests carried the same mistake, asserting that one spool satisfied a
multi-slot project, so they are rewritten around the reported case.
Also documents the feature in the README, which had no entry for it at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Bambu Studio or OrcaSlicer 3MF names the filaments its plates use, but
reading that means shelling out to Python, so it can't be done per page load.
A background pass — in the same loop as thumbnails and fingerprints — reads it
once per model into models.filaments, which turns "what can I print now" into
a comparison of two lists.
Adds a Filament page for the spools on hand: material, colour, brand and grams
left, edited in place. Matching is on material only; a project's exact colours
would make almost nothing printable, and a different colour still prints.
Measured on the dev library: 10 models read with no errors, the two Bambu
projects resolving to four PLA colours each, and no plate images written
because the reader skips --images and queues behind anything interactive.
Not done yet: nothing in the library view uses this — no "printable now" badge
or filter — and the Filament page itself has not been seen rendered, because
the local preview browser has no session.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Checks have imported metadata only since MakerWorld refused bulk downloads.
Today's measurement changed that picture: it refuses bursts a few seconds
apart, but 67 models downloaded in a row about 20 seconds apart without one
refusal. So downloading during a check is viable — at the right pace.
Per watch, off by default: "Files: on/off" on the row, a checkbox when adding
one. With it on, a check spaces downloads 20 seconds apart instead of 3, and
stops after two refusals in a row, coming back shortly rather than collecting
refusals for the whole batch. A model whose files were refused still lands as
'partial', so "Get files" can finish it later.
Auto-imports now get the same AI treatment a manual run does — tags, a clearer
name where the site's is boilerplate, and collections to review — rather than
tags alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Neither the FlareSolverr call nor the CDN fetch set a timeout, so a stalled
connection hung the request indefinitely: the button sat on "Downloading…"
with nothing to reset it and no error to show. FlareSolverr calls now give up
15s after the deadline FlareSolverr itself was given (and a timeout counts as
retryable, like other transport failures), and a CDN download gives up after
three minutes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tag paths are sorted now, but two writers of model_projects still iterated
in caller order: findOrCreateProject over the models it attaches, and
setProjects over a model's collections. Two of either running at once could
take the same rows in opposite orders — the same shape as the tag deadlock, one
table across.
Preventative: unlike the tag case, no failure from these was ever observed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two transactions tagging overlapping models could each hold a tag row the other
wanted. tagId used ON CONFLICT DO UPDATE, which rewrites the row and therefore
write-locks it even though the name never changes, and each caller iterated in
whatever order it happened to have. That is what killed "Mushroom Clicker"
during AI run 21, while the collection watcher was importing and rule-tagging
the same tags.
tagId no longer rewrites unchanged rows, and applyAiResult, replaceRuleTags and
setTags all tag in sorted order, so locks are taken in the same sequence every
time. db.transaction retries a deadlock victim up to three times as well, which
covers the paths this doesn't — the victim rolled back and changed nothing, so
running it again is safe.
Measured: the old pattern deadlocks on demand (40P01), and the same collision
run 48 times through the new code fails zero times.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run's detail only carried the last dozen items, so a model that failed early
scrolled out of reach: run 21 reported "1 failed" with no way to learn which
model or why. The detail now carries every failed item, listed in full.
"Retry failed" existed but could never appear: it needs a run that is finished
with failures, and such a run is 'done', which neither canResume nor resume()
accepted. Both now do; resume() already no-ops when nothing is pending, so a
clean run is unaffected.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things the live run showed:
- The page only polled while a check was already running when it rendered, so
a page opened between checks showed stale numbers for ever — it missed a
check started by the schedule or another tab. It now refreshes either way.
- Only the five most recent items were listed, so most models still without
files had no "Get files" button anywhere. They're now in a collapsible list.
- Adding a watch read the whole collection twice: once to create it, once in
the first check. Measured on a 535-model collection: the add still takes 52s,
but the first import now lands 6s later instead of another read.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MakerWorld allows an account only a couple of downloads in a row, so a check
that tried to fetch files left most models empty. A check now imports each
model's page — name, description, picture, licence — and marks it 'partial';
"Get files" pulls the files for the ones you actually want to print.
Verified against a public collection: 11 models imported with descriptions and
thumbnails, no files fetched, no MakerWorld token needed for the check.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against the live site: FlareSolverr reports no challenge on that
endpoint and an unauthenticated call gets a normal reply in 3s, so this is
MakerWorld limiting an account to a couple of downloads in a row, not a
Cloudflare page. Two models retried three times each still came back empty —
the retry only made every failure a minute slower.
Keeps the classification, so a refusal is named for what it is and the model
is flagged as still without files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pause only prevented the next check: the batch already running kept importing
its remaining models, which is the opposite of what the button suggests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Models imported before 'partial' existed are stored as 'imported' with an
error, so they'd never show the button. Mark them on startup, and key the
button on the stored error rather than the status.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run of downloads gets challenged by Cloudflare partway through: the answer
comes back as an ordinary reply, so nothing retried it and the model landed
with only its metadata — looking, on the watch page, like a success.
resolveDownload now recognises a challenge and gives FlareSolverr's browser a
couple more goes; what still fails is recorded as 'partial' and shown with its
reason and a "Get files" button, which clears the flag once the files arrive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- A collection page lists only its first ~20 models. Checks now page through the
same load-more call the page uses (offset + a shared seed, 64 at a time, up to
~640 models), so a watch sees the whole collection.
- A private collection read without a valid token comes back as a placeholder
(id -1, no models), which was being treated as an empty collection. It now
reports the token problem instead.
- Adding a watch starts a check, and "Check now" could start a second one
alongside it, importing models twice. One check per collection at a time now,
and a check interrupted by a restart no longer looks like it's still running.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A watch reads the collection page through FlareSolverr carrying your token, so
private collections work, and imports the models it hasn't seen into a Printhive
collection of the same name (optionally AI-tagging them). Checks run on your
chosen interval, a few models at a time with pauses between, and each design is
remembered so nothing is imported twice. A refused token stops the check and the
Watched page offers to update it; models removed on MakerWorld stay here.
The import endpoint's work moved into importFromUrl() so watches reuse it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Red panda kept failing with "The AI reply wasn't the JSON asked for", and
nothing showed what came back. Now:
- the prompt includes an example object with just the requested fields
(DeepSeek's JSON mode can otherwise send back an empty reply)
- JSON is found inside code fences, notes, or braces after it
- a bad reply is asked again up to twice: with more room when it was cut off,
and the last time without JSON mode; max_tokens starts at 1500
- the error says whether the reply was empty or cut off and quotes its start
- tokens spent on failed attempts count towards the run's cost
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Descriptions sent to the AI are cut to 800 characters. When the cut landed
inside an emoji (stored as two UTF-16 halves), the request carried half of it,
which DeepSeek rejects: "unexpected end of hex escape". Text is now cut before
the emoji, anything already broken in the library is replaced before sending,
and the tags, names, collection ideas and descriptions read back are cut the
same safe way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
When a download fails because MakerWorld refused (or is missing) the auth
token, the error carries reason makerworld_token. The import and "Download
files from source" then open an "Update your MakerWorld token" prompt with
steps to copy the token cookie; saving it retries the download straight away
and says if MakerWorld still refuses it. A missing FlareSolverr URL is reported
separately, since a token alone won't help.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MakerWorld answers an expired or missing auth token with {"error": "Please log
in to download models."} and no download link, which was skipped without a
word, so imports and "Download files from source" just ended with no files.
Now the reason comes back: missing settings, a refused token (probably expired),
no printable files on the page, or a failed download. The import screen shows
it, and the Settings hint explains where the token comes from.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Runs can now do any mix of: tags & descriptions, clearer names, and
collection suggestions. A run without tags doesn't mark models as tagged.
- Collection ideas reuse your collections and earlier ideas where they fit and
wait on the AI page, grouped by name, until you create/add or dismiss them.
Dismissed ideas aren't suggested for that model again; spelling variants
group together under the capitalised spelling.
- A reply that isn't the JSON asked for is asked again once; both requests
count towards the run's tokens and cost.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A run option asks the AI for a clearer name only when the current one is messy
(file-name leftovers, typos, site boilerplate). The previous name is kept, the
run feed shows what was renamed, and the model page offers Undo. Giving a model
a name yourself clears the undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model page's tag list wasn't a wrapping row, so the last tag could break in
half, and four action buttons crushed onto two lines each. AI runs also never
woke the background worker, so new AI tags and descriptions weren't searchable
until something else changed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- AI tagging through any OpenAI-compatible API: DeepSeek by default, or
OpenRouter, OpenAI, or a local server (Ollama, LM Studio). Settings → AI
with presets, prices and "Save & test connection"; the key never leaves
the server except to the provider.
- Runs over untagged models, all models, or one model from its page: live
progress, tokens and cost, stop/resume (retrying failures), an optional
spending limit, and paused rather than resumed after a restart. Adapts to
models without JSON mode or image input; stops on a rejected key or no
credit. Tags are only added; descriptions filled only where empty unless
asked.
- Search by meaning: local bge-small-en-v1.5 embeddings (transformers.js),
indexed in the background whenever a model's text changes; Words/Meaning
switch on the search box. Stored as REAL[] in Postgres, no pgvector needed.
- Automatic tags: strict whole-word rules (part types, mechanisms, materials,
techniques) over names, file names and archive contents, plus your own
word -> tag rules; on/off (optionally removing its tags) and re-apply.
- Tags remember where they came from (user / rule / ai); edits keep that,
rule and AI passes never touch your own tags, and the model page marks them.
- Docker: ships the embedding model and only Linux onnxruntime binaries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Settings → Slicer: pick Bambu Studio, OrcaSlicer, PrusaSlicer, Cura, a
custom open-link template, or plain download (remembered per browser)
- Bambu Studio / OrcaSlicer launch via their URL schemes with a 10-minute
signed download link (/dl/<token>/<name>) that needs no session cookie
- PrusaSlicer and Cura fall back to a download (they can't open our links)
- 3MF projects report which app saved them; opening one in a different
slicer asks first
- "slicer" action on every file row and archive entry
- Drag model cards (file, or zip of all files) and file/entry rows out to
the desktop via Chromium's DownloadURL
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- The background worker now also gives models without a thumbnail one:
an embedded 3MF/plate preview, else a picture uploaded with the model,
else a 3MF preview or picture inside one of its archives, else a render
of an STL/OBJ/3MF (loose or inside an archive).
- tools/render_thumbnail.py renders without a GPU: meshes are simplified
per distinct part (the 8M-face lion project renders in ~8 s / ~200 MB),
3MF projects show their first plate in filament colours, and only
camera-facing faces are painted (inside-out meshes and mirrored parts are
re-oriented first).
- models.thumbnail_status stops retries when there's nothing to use; adding
files clears it. The browser now only renders FBX/STEP/3DM thumbnails.
- The library checks back while fingerprints or thumbnails are pending and
refreshes when they land.
- Python sidecar needs pillow and fast-simplification; compose keeps the
archive extraction cache in a volume. README covers the phase 2 features.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- threemf_stream reads Bambu Studio / OrcaSlicer project metadata: plates
(name, objects, preview images), filaments, and each part's type and
extruder. Placements now record which build item and part they belong to.
- convert_3mf.py leaves out negative/modifier/support parts, colours each
part by its filament (a part's extruder 0 falls back to its object's), and
takes --plate N. Geometry is still stored once per (part, colour).
- New threemf_info.py lists plates and filaments without loading geometry
and extracts plate preview images; served via /api/{files,entries}/:id/project.
- /glb?plate=N builds and caches a per-plate preview. Cached GLB names carry
a converter version, so previews made before colours are rebuilt.
- Model detail: plate chips (with the slicer's plate images) under the
viewer — multi-plate projects open on plate 1 — plus "All plates", and a
Filaments section with colour swatches.
- Fingerprints leave out negative/modifier parts, so a project matches the
model it prints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Archives
- ZIP, 7Z, RAR and TAR/.tar.gz uploads are indexed in the background into
archive_entries (7-Zip compiled to WASM in a worker thread; node-tar for
tar). macOS/Windows clutter and paths that could escape the extraction
folder are skipped.
- Models inside archives are extracted once per archive to hash and
geometry-fingerprint them, so duplicates are found inside packs too.
Pictures, PDFs and readmes are listed but never matched.
- New routes list an archive's entries and serve/convert a single entry,
extracted on first use and cached under extracted/.
Viewer
- Model detail lists what's inside each archive with view/download links;
opening an entry adds a viewer tab.
- New 3D formats: FBX (with sibling textures), OBJ with its MTL and
textures, STEP/STP via occt-import-js, and Rhino 3DM via rhino3dm — the
WASM readers are served from node_modules, loaded on first use.
- Pictures, PDFs and text files (jpg/png/gif/webp/bmp/svg, pdf, txt/md)
can be uploaded and preview in the viewer; a model without an embedded
3MF preview uses its first picture as the card thumbnail.
- Inline file responses send nosniff, and SVGs are sandboxed.
- GLB conversions write to a temp file and rename, so a half-written
preview is never served; a later tab can't be overdrawn by a slow load.
- Better 3MF card image: the project thumbnail or plate 1 render, not a
slicer pick map.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
trimesh's 3MF loader re-reads a whole production-extension part file for
every component that points into it. A Bambu Studio project with 884
components across four ~50 MB part files grew past 3 GB within seconds
and failed both the preview conversion and the geometry fingerprint.
- Add tools/threemf_stream.py: parses each part once with lxml iterparse,
resolves each component to the single object it names, and returns each
mesh once plus its placements. The same project now converts in ~8 s at
~380 MB peak (fingerprinting ~190 MB).
- convert_3mf.py and fingerprint.py use it; the GLB stores each object once
and instances repeats. Stats are computed per placement (mirrored
placements keep a positive volume), without concatenating meshes.
- Run sidecar scripts through one queue (lib/python.js) so a preview and
background fingerprinting never load big meshes at the same time;
previews go first, and 3MFs are fingerprinted one per run.
- Surface the converter's JSON error / timeout instead of "Command failed".
- Tests with a Bambu-style component fixture (shared part, mirrored
placement, decoy object) and STL/3MF fingerprint parity.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Fingerprint every file in the background: SHA-256 for all formats plus a
trimesh geometry fingerprint for STL/OBJ/3MF, so renamed, re-exported or
converted copies are still matched. Backfills existing files on startup.
- Duplicates view groups models that share files (exact copies vs same
shape), recommends which to keep, merges tags/collections/notes into the
kept model and trashes the rest; groups can be dismissed.
- Deleting now moves models to a restorable trash; files are only removed
from disk on permanent delete or when the trash is emptied.
- Per-model notes with autosave.
- Search across names, notes, tags, files and designers; filter by format,
source and license; include/exclude tags with any/all matching; sort and
group by source or format. Preferences persist per browser.
- Add node:test unit tests for duplicate grouping and fingerprints.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Changing a license policy applies to all models with that license, and
opening Settings reconciles old imports to current rules (store.reconcileModelsToRules)
- Editing a model's license re-resolves its sellability from the rule
- Removed the per-model commercial-use override (set by license now)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- license_rules table (name -> commercial); licenses auto-register on
import with a best-effort seed, overridable in Settings (applies the
choice to every model with that license)
- resolveCommercial() replaces ad-hoc inferCommercial across import/refresh
- GET/PUT /api/licenses; "License types" section in Settings
- Fix CC short codes: BY / BY-SA / BY-ND now classed sellable (MakerWorld
stores e.g. "BY-SA"); NC variants stay non-commercial
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- users + password_resets tables; bcrypt hashing; JWT session in an
httpOnly cookie (auth_secret auto-generated in settings)
- /api gated behind auth (except /api/config and /api/auth/*)
- First-run creates the account (no open registration after that);
login, logout, forgot/reset-password flows
- SMTP settings (host/port/secure/user/pass/from) in Settings menu;
nodemailer sends reset links
- Auth screen (sign in / create account / forgot / reset) gates the SPA;
Sign out button; COOKIE_SECURE=true in compose for HTTPS
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Dockerfile: node:22-bookworm-slim + Python venv with trimesh for the
3MF->GLB sidecar (PYTHON_BIN env points the converter at it)
- docker-compose.yml: app + postgres 16 with healthcheck/depends_on,
named volumes for pgdata/uploads/thumbnails/glb, token env passthrough
- .dockerignore, tools/requirements.txt
- converter.js honours PYTHON_BIN (defaults to local venv)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>