Compare commits

...

4 commits

Author SHA1 Message Date
dlawler489
d6ad49d9cb Merge colour-accurate filament matching and the Bambu filament picker
All checks were successful
build-and-push / docker (push) Successful in 16s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:06:40 +10:00
dlawler489
2635a1e9f0 Pick spools from the maker's filament list, and name slot colours
Adding a spool no longer means typing a hex code: choose "PLA Basic — Jade
White (10100)" and the material, colour name, swatch and filament code fill
themselves. Slot colours on the model page are named where they match, so a
slot reads "#586579 · Basic Blue Gray" instead of bare hex.

The filament code is stored per spool. It isn't decoration — the AMS reports
filament IDs, so this is what a later read-only printer connection will match a
loaded tray against.

db/bambu-filaments.json holds Bambu Lab's 325 filaments (material, type, colour
name, code, colours), included by kind permission of 3dfilamentprofiles.com,
whose terms otherwise reserve the data. The permission and source are recorded
in the file itself as well as the README, so nobody reuses it by accident.

Naming uses a much tighter threshold than matching (12 against 50): identifying
a colour and substituting for one are different questions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 13:01:09 +10:00
dlawler489
7b018aefdb Match slots by colour, not just material
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>
2026-09-17 12:47:18 +10:00
dlawler489
8814e79669 Count slots, not materials: four colours need four spools
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>
2026-09-17 12:41:40 +10:00
9 changed files with 630 additions and 61 deletions

View file

@ -27,6 +27,7 @@ A self-hosted web app for organizing your 3D-printing files (STL, 3MF, OBJ, FBX,
- **AI tags & descriptions** — bring your own key for DeepSeek (the default), OpenRouter, OpenAI or any OpenAI-compatible server (Ollama, LM Studio). Tag the whole library, only models not yet tagged, or a single model from its page. Each model's name, file names and (optionally) thumbnail are sent; tags are only ever added and descriptions only filled where empty unless you ask. Optionally it also gives models with unclear names (file-name leftovers, typos, site boilerplate) a clearer one; the old name is kept and can be restored with Undo on the model page. It can also suggest collections (reusing yours where they fit): suggestions wait on the AI page, grouped by name, until you create/add or dismiss them. Each run can do any mix of tags & descriptions, names and collections, and a reply that isn't the JSON asked for is retried once. Runs show live progress, tokens and cost, can be stopped and resumed, stop at an optional spending limit, and pause (rather than keep spending) if the server restarts. "Save & test connection" in Settings checks the key, model and whether it accepts images.
- **Search by meaning** — switch the search box to *Meaning* to find models by what they are ("wall hook" finds a "coat hanger bracket"). Uses a small embedding model (bge-small-en-v1.5, ~34 MB) that runs on the server, so it's free and private; the index updates in the background as models change.
- **Automatic tags** — strict whole-word rules over names, file names and archive contents (part types, mechanisms, materials, techniques) plus your own "word → tag" rules. Switch it off (optionally removing its tags) or re-apply it any time. Tags you add yourself are never changed; AI and rule tags are marked on the model page.
- **Filament & what's printable** — keep the spools you own (material, colour, brand, grams left) on the **Filament** page. Bambu Studio / OrcaSlicer projects name the filament in each slot, which Printhive reads once per model in the background, so cards and the model page say whether you can print it now and what you're short of, and the toolbar can show only what's printable. Each slot is matched to a spool by material *and* colour — a four-colour print needs those four colours, and one spool can't fill two slots. Near shades count as the same colour; the model page lists every slot, which spool fills it, and the nearest thing you own when none does. Models that aren't slicer projects don't name their filaments, and say so rather than being called unprintable. Adding a spool can start from Bambu Lab's own filament list — pick "PLA Basic — Jade White (10100)" and the material, colour and code fill themselves — and slot colours are named where they match it. That list is included by kind permission of [3D Filament Profiles](https://3dfilamentprofiles.com), whose terms otherwise reserve it; ask them before reusing `db/bambu-filaments.json` elsewhere.
- **Trash** — deleting moves a model to the trash; restore it, delete it forever, or empty the trash (only then are files removed from disk).
- **Settings** — API tokens / FlareSolverr URL stored in the database (with `.env` fallback), secrets masked.

334
db/bambu-filaments.json Normal file
View file

@ -0,0 +1,334 @@
{
"source": "https://3dfilamentprofiles.com/filaments/bambu-lab",
"permission": "Copied with the site owner's permission, granted on request 2026-09-17. Their terms otherwise prohibit copying, scraping and redistribution — ask them before reusing this file anywhere else.",
"collected": "2026-09-17",
"count": 325,
"note": "Bambu Lab filaments: material, product type, colour name, Bambu filament code (what the AMS reports), and the colour(s) shown. Multi-colour filaments list each colour; a few entries have no published code.",
"filaments": [
{"material":"ABS","type":"Basic","name":"Azure","code":"40601","colours":["#489FDF"]},
{"material":"ABS","type":"Basic","name":"Bambu Green","code":"40500","colours":["#00AE42"]},
{"material":"ABS","type":"Basic","name":"Beige","code":"40401","colours":["#DFD1A7"]},
{"material":"ABS","type":"Basic","name":"Black","code":"40101","colours":["#000000"]},
{"material":"ABS","type":"Basic","name":"Blue","code":"40600","colours":["#0A2CA5"]},
{"material":"ABS","type":"Basic","name":"Grey","code":"10103","colours":["#8E9089"]},
{"material":"ABS","type":"Basic","name":"Lavender","code":"40701","colours":["#7248BD"]},
{"material":"ABS","type":"Basic","name":"Mint","code":"40501","colours":["#ADF4DC"]},
{"material":"ABS","type":"Basic","name":"Navy Blue","code":"40602","colours":["#0C2340"]},
{"material":"ABS","type":"Basic","name":"Olive","code":"40502","colours":["#789D4A"]},
{"material":"ABS","type":"Basic","name":"Orange","code":"40300","colours":["#FF6A13"]},
{"material":"ABS","type":"Basic","name":"Purple","code":"40700","colours":["#AF1685"]},
{"material":"ABS","type":"Basic","name":"Red","code":"40200","colours":["#D32941"]},
{"material":"ABS","type":"Basic","name":"Silver","code":"40102","colours":["#87909A"]},
{"material":"ABS","type":"Basic","name":"Tangerine Yellow","code":"40402","colours":["#FFC72C"]},
{"material":"ABS","type":"Basic","name":"White","code":"40100","colours":["#FFFFFF"]},
{"material":"ABS","type":"Basic","name":"Yellow","code":"40400","colours":["#FFFF20"]},
{"material":"ABS","type":"GF","name":"Black","code":"41101","colours":["#000000"]},
{"material":"ABS","type":"GF","name":"Blue","code":"41600","colours":["#0C3B95"]},
{"material":"ABS","type":"GF","name":"Gray","code":"41102","colours":["#C6C6C6"]},
{"material":"ABS","type":"GF","name":"Green","code":"41500","colours":["#61BF36"]},
{"material":"ABS","type":"GF","name":"Orange","code":"41300","colours":["#F48438"]},
{"material":"ABS","type":"GF","name":"Red","code":"41200","colours":["#E83100"]},
{"material":"ABS","type":"GF","name":"White","code":"41100","colours":["#FFFFFF"]},
{"material":"ABS","type":"GF","name":"Yellow","code":"41400","colours":["#FFE133"]},
{"material":"ASA","type":"Aero","name":"White","code":"46100","colours":["#EFF2DE"]},
{"material":"ASA","type":"Basic","name":"Black","code":"45101","colours":["#000000"]},
{"material":"ASA","type":"Basic","name":"Blue","code":"45600","colours":["#2140B4"]},
{"material":"ASA","type":"Basic","name":"Gray","code":"45102","colours":["#8A949E"]},
{"material":"ASA","type":"Basic","name":"Green","code":"45500","colours":["#00A6A0"]},
{"material":"ASA","type":"Basic","name":"Red","code":"45200","colours":["#E02928"]},
{"material":"ASA","type":"Basic","name":"White","code":"45100","colours":["#FFFAF2"]},
{"material":"ASA","type":"CF","name":"Black","code":"46101","colours":["#000000"]},
{"material":"HIPS","type":"Support","name":"Support for ABS White","code":"66100","colours":["#FFFFFF"]},
{"material":"PA6","type":"CF","name":"Black","code":"72100","colours":["#000000"]},
{"material":"PA6","type":"GF","name":"Black","code":"72104","colours":["#000000"]},
{"material":"PA6","type":"GF","name":"Blue","code":"72600","colours":["#75AED8"]},
{"material":"PA6","type":"GF","name":"Brown","code":"72800","colours":["#5B492F"]},
{"material":"PA6","type":"GF","name":"Gray","code":"72103","colours":["#353533"]},
{"material":"PA6","type":"GF","name":"Lime","code":"72500","colours":["#C5ED48"]},
{"material":"PA6","type":"GF","name":"Orange","code":"72200","colours":["#FF4800"]},
{"material":"PA6","type":"GF","name":"White","code":"72102","colours":["#EAEAE4"]},
{"material":"PA6","type":"GF","name":"Yellow","code":"72400","colours":["#FFCE00"]},
{"material":"PAHT","type":"CF","name":"Black","code":"70100","colours":["#000000"]},
{"material":"PAHT","type":"Support","name":"Support for PA/PET","code":"65500","colours":["#C0DF16"]},
{"material":"PC","type":"Basic","name":"Black","code":"60101","colours":["#000000"]},
{"material":"PC","type":"Basic","name":"Clear Black","code":"60102","colours":["#5A5161"]},
{"material":"PC","type":"Basic","name":"Transparent","code":"60103","colours":["#BFC3C7"]},
{"material":"PC","type":"Basic","name":"White","code":"60100","colours":["#FFFFFF"]},
{"material":"PC","type":"FR","name":"Black","code":"63100","colours":["#000000"]},
{"material":"PC","type":"FR","name":"Gray","code":"63102","colours":["#A8A8AA"]},
{"material":"PC","type":"FR","name":"White","code":"63101","colours":["#FFFFFF"]},
{"material":"PET","type":"CF","name":"Black","code":"71100","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Black","code":"30101","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Black","code":"30105","colours":["#000000"]},
{"material":"PETG","type":"Basic","name":"Blue","code":"30600","colours":["#001489"]},
{"material":"PETG","type":"Basic","name":"Blue Gray","code":"30601","colours":["#2E4F66"]},
{"material":"PETG","type":"Basic","name":"Dark Beige","code":"30403","colours":["#DBC8B6"]},
{"material":"PETG","type":"Basic","name":"Dark Brown","code":"30800","colours":["#4F2C1D"]},
{"material":"PETG","type":"Basic","name":"Gold","code":"30401","colours":["#F6D86A"]},
{"material":"PETG","type":"Basic","name":"Gray","code":"30102","colours":["#ADB1B2"]},
{"material":"PETG","type":"Basic","name":"Gray","code":"30107","colours":["#7F7E83"]},
{"material":"PETG","type":"Basic","name":"Green","code":"30500","colours":["#009639"]},
{"material":"PETG","type":"Basic","name":"Green","code":"30502","colours":["#009639"]},
{"material":"PETG","type":"Basic","name":"Lake Blue","code":"30602","colours":["#0069B1"]},
{"material":"PETG","type":"Basic","name":"Lime Green","code":"30501","colours":["#7CD82B"]},
{"material":"PETG","type":"Basic","name":"Misty Blue","code":"30108","colours":["#688197"]},
{"material":"PETG","type":"Basic","name":"Nature","code":"30104","colours":["#D7D7D7"]},
{"material":"PETG","type":"Basic","name":"Navy Blue","code":"30604","colours":["#0086D6"]},
{"material":"PETG","type":"Basic","name":"Orange","code":"30300","colours":["#FF671F"]},
{"material":"PETG","type":"Basic","name":"Orange","code":"30301","colours":["#FF671F"]},
{"material":"PETG","type":"Basic","name":"Pine Green","code":"30503","colours":["#034638"]},
{"material":"PETG","type":"Basic","name":"Purple","code":"30700","colours":["#9E007E"]},
{"material":"PETG","type":"Basic","name":"Red","code":"30200","colours":["#D6001C"]},
{"material":"PETG","type":"Basic","name":"Red","code":"30201","colours":["#D6001C"]},
{"material":"PETG","type":"Basic","name":"Reflex Blue","code":"30603","colours":["#001489"]},
{"material":"PETG","type":"Basic","name":"Translucent","code":"30103","colours":["#FFFFFF"]},
{"material":"PETG","type":"Basic","name":"White","code":"30100","colours":["#EDF0F2"]},
{"material":"PETG","type":"Basic","name":"White","code":"30106","colours":["#FFFFFF"]},
{"material":"PETG","type":"Basic","name":"Yellow","code":"30400","colours":["#FCE300"]},
{"material":"PETG","type":"Basic","name":"Yellow","code":"30402","colours":["#FCE300"]},
{"material":"PETG","type":"CF","name":"Black","code":"31100","colours":["#000000"]},
{"material":"PETG","type":"CF","name":"Brick Red","code":"31200","colours":["#9F332A"]},
{"material":"PETG","type":"CF","name":"Indigo Blue","code":"31600","colours":["#324585"]},
{"material":"PETG","type":"CF","name":"Malachite Green","code":"31500","colours":["#16B08E"]},
{"material":"PETG","type":"CF","name":"Titan Gray","code":"31101","colours":["#565656"]},
{"material":"PETG","type":"CF","name":"Violet Purple","code":"31700","colours":["#583061"]},
{"material":"PETG","type":"HF","name":"Black","code":"33102","colours":["#000000"]},
{"material":"PETG","type":"HF","name":"Blue","code":"33600","colours":["#002E96"]},
{"material":"PETG","type":"HF","name":"Cream","code":"33401","colours":["#F9DFB9"]},
{"material":"PETG","type":"HF","name":"Dark Gray","code":"33103","colours":["#515151"]},
{"material":"PETG","type":"HF","name":"Forest Green","code":"33502","colours":["#39541A"]},
{"material":"PETG","type":"HF","name":"Gray","code":"33101","colours":["#ADB1B2"]},
{"material":"PETG","type":"HF","name":"Green","code":"33500","colours":["#00AE42"]},
{"material":"PETG","type":"HF","name":"Lake Blue","code":"33601","colours":["#1F79E5"]},
{"material":"PETG","type":"HF","name":"Lime Green","code":"33501","colours":["#6EE53C"]},
{"material":"PETG","type":"HF","name":"Orange","code":"33300","colours":["#F75403"]},
{"material":"PETG","type":"HF","name":"Peanut Brown","code":"33801","colours":["#875718"]},
{"material":"PETG","type":"HF","name":"Red","code":"33200","colours":["#EB3A3A"]},
{"material":"PETG","type":"HF","name":"White","code":"33100","colours":["#FFFFFF"]},
{"material":"PETG","type":"HF","name":"Yellow","code":"33400","colours":["#FFD00B"]},
{"material":"PETG","type":"Translucent","name":"Brown","code":"32800","colours":["#C9A381"]},
{"material":"PETG","type":"Translucent","name":"Clear","code":"32101","colours":["#EBEBEB"]},
{"material":"PETG","type":"Translucent","name":"Gray","code":"32100","colours":["#8E8E8E"]},
{"material":"PETG","type":"Translucent","name":"Light Blue","code":"32600","colours":["#61B0FF"]},
{"material":"PETG","type":"Translucent","name":"Olive","code":"32500","colours":["#748C45"]},
{"material":"PETG","type":"Translucent","name":"Orange","code":"32300","colours":["#FF911A"]},
{"material":"PETG","type":"Translucent","name":"Pink","code":"32200","colours":["#F9C1BD"]},
{"material":"PETG","type":"Translucent","name":"Purple","code":"32700","colours":["#D6ABFF"]},
{"material":"PETG","type":"Translucent","name":"Teal","code":"32501","colours":["#77EDD7"]},
{"material":"PLA","type":"Aero","name":"Black","code":"14103","colours":["#000000"]},
{"material":"PLA","type":"Aero","name":"Gray","code":"14104","colours":["#CDCECA"]},
{"material":"PLA","type":"Aero","name":"White","code":"14102","colours":["#FFFFFF"]},
{"material":"PLA","type":"Basic","name":"Bambu Green","code":"10501","colours":["#00AE42"]},
{"material":"PLA","type":"Basic","name":"Beige","code":"10201","colours":["#F7E6DE"]},
{"material":"PLA","type":"Basic","name":"Black","code":"10101","colours":["#000000"]},
{"material":"PLA","type":"Basic","name":"Blue","code":"10600","colours":["#0A2989"]},
{"material":"PLA","type":"Basic","name":"Blue","code":"10601","colours":["#0A2989"]},
{"material":"PLA","type":"Basic","name":"Blue Gray","code":"10602","colours":["#5B6579"]},
{"material":"PLA","type":"Basic","name":"Bright Green","code":"10503","colours":["#BECF00"]},
{"material":"PLA","type":"Basic","name":"Bronze","code":"10801","colours":["#847D48"]},
{"material":"PLA","type":"Basic","name":"Brown","code":"10800","colours":["#9D432C"]},
{"material":"PLA","type":"Basic","name":"Cobalt Blue","code":"10604","colours":["#0056B8"]},
{"material":"PLA","type":"Basic","name":"Cocoa Brown","code":"10802","colours":["#6F5034"]},
{"material":"PLA","type":"Basic","name":"Cyan","code":"10603","colours":["#0086D6"]},
{"material":"PLA","type":"Basic","name":"Dark Gray","code":"10105","colours":["#545454"]},
{"material":"PLA","type":"Basic","name":"Gold","code":"10401","colours":["#E4BD68"]},
{"material":"PLA","type":"Basic","name":"Gray","code":"10103","colours":["#8E9089"]},
{"material":"PLA","type":"Basic","name":"Green","code":"10500","colours":["#002914"]},
{"material":"PLA","type":"Basic","name":"Hot Pink","code":"10204","colours":["#F5547C"]},
{"material":"PLA","type":"Basic","name":"Indigo Purple","code":"10701","colours":["#482960"]},
{"material":"PLA","type":"Basic","name":"Jade White","code":"10100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Basic","name":"Light Gray","code":"10104","colours":["#D1D3D5"]},
{"material":"PLA","type":"Basic","name":"Magenta","code":"10202","colours":["#EC008C"]},
{"material":"PLA","type":"Basic","name":"Maroon Red","code":"10205","colours":["#9D2235"]},
{"material":"PLA","type":"Basic","name":"Mistletoe Green","code":"10502","colours":["#3F8E43"]},
{"material":"PLA","type":"Basic","name":"Orange","code":"10300","colours":["#FF6A13"]},
{"material":"PLA","type":"Basic","name":"Pink","code":"10203","colours":["#F55A74"]},
{"material":"PLA","type":"Basic","name":"Pumpkin Orange","code":"10301","colours":["#FF9016"]},
{"material":"PLA","type":"Basic","name":"Purple","code":"10700","colours":["#5E43B7"]},
{"material":"PLA","type":"Basic","name":"Red","code":"10200","colours":["#C12E1F"]},
{"material":"PLA","type":"Basic","name":"Silver","code":"10102","colours":["#A6A9AA"]},
{"material":"PLA","type":"Basic","name":"Sunflower Yellow","code":"10402","colours":["#FEC600"]},
{"material":"PLA","type":"Basic","name":"Turquoise","code":"10605","colours":["#00B1B7"]},
{"material":"PLA","type":"Basic","name":"Yellow","code":"10400","colours":["#F4EE2A"]},
{"material":"PLA","type":"CF","name":"Black","code":"14100","colours":["#000000"]},
{"material":"PLA","type":"CF","name":"Burgundy Red","code":"14200","colours":["#951E23"]},
{"material":"PLA","type":"CF","name":"Iris Purple","code":"14700","colours":["#69398E"]},
{"material":"PLA","type":"CF","name":"Jeans Blue","code":"14600","colours":["#6E88BC"]},
{"material":"PLA","type":"CF","name":"Lava Gray","code":"14101","colours":["#4D5054"]},
{"material":"PLA","type":"CF","name":"Matcha Green","code":"14500","colours":["#5C9748"]},
{"material":"PLA","type":"CF","name":"Royal Blue","code":"14601","colours":["#2842AD"]},
{"material":"PLA","type":"CMYK","name":"Lithophane Bundle","code":"","colours":["#00FFFF","#FFFF00","#FF00FF","#FFFFFF"]},
{"material":"PLA","type":"Galaxy","name":"Brown","code":"13203","colours":["#684A43"]},
{"material":"PLA","type":"Galaxy","name":"Green","code":"13503","colours":["#3B665E"]},
{"material":"PLA","type":"Galaxy","name":"Nebula","code":"13504","colours":["#424379"]},
{"material":"PLA","type":"Galaxy","name":"Purple","code":"13602","colours":["#594177"]},
{"material":"PLA","type":"Glow","name":"Glow Blue","code":"15600","colours":["#7AC0E9"]},
{"material":"PLA","type":"Glow","name":"Glow Green","code":"15500","colours":["#A1FFAC"]},
{"material":"PLA","type":"Glow","name":"Glow Orange","code":"15300","colours":["#FF9D5B"]},
{"material":"PLA","type":"Glow","name":"Glow Pink","code":"15200","colours":["#F17B8F"]},
{"material":"PLA","type":"Glow","name":"Glow Yellow","code":"15400","colours":["#F8FF80"]},
{"material":"PLA","type":"Gradient","name":"Arctic Whisper","code":"10900","colours":["#9CDBD9","#FFFFFF"]},
{"material":"PLA","type":"Gradient","name":"Blueberry Bubblegum","code":"10905","colours":["#6FCAEF","#8573DD"]},
{"material":"PLA","type":"Gradient","name":"Cotton Candy Cloud","code":"10907","colours":["#E7C1D5","#8EC9E9"]},
{"material":"PLA","type":"Gradient","name":"Dusk Glare","code":"10906","colours":["#ED6022","#FFCBA6"]},
{"material":"PLA","type":"Gradient","name":"Mint Lime","code":"10904","colours":["#4EC83A","#D7FE9E"]},
{"material":"PLA","type":"Gradient","name":"Ocean to Meadow","code":"10902","colours":["#307FE2","#54FF9B"]},
{"material":"PLA","type":"Gradient","name":"Pink Citrus","code":"10903","colours":["#E4555E","#FCDAD2"]},
{"material":"PLA","type":"Gradient","name":"Solar Breeze","code":"10901","colours":["#FC776A","#FFF7F5"]},
{"material":"PLA","type":"Lite","name":"Beige","code":"16700","colours":["#F7E6DE"]},
{"material":"PLA","type":"Lite","name":"Black","code":"16100","colours":["#000000"]},
{"material":"PLA","type":"Lite","name":"Blue","code":"16601","colours":["#004EA8"]},
{"material":"PLA","type":"Lite","name":"Cocoa Brown","code":"","colours":["#8E3C06"]},
{"material":"PLA","type":"Lite","name":"Cyan","code":"16600","colours":["#00FFFF"]},
{"material":"PLA","type":"Lite","name":"Dark Gray","code":"16102","colours":["#6F6E6D"]},
{"material":"PLA","type":"Lite","name":"Gray","code":"16101","colours":["#9FA19F"]},
{"material":"PLA","type":"Lite","name":"Green","code":"16501","colours":["#00BB31"]},
{"material":"PLA","type":"Lite","name":"Orange","code":"16301","colours":["#FF671F"]},
{"material":"PLA","type":"Lite","name":"Red","code":"16200","colours":["#FF0000"]},
{"material":"PLA","type":"Lite","name":"Sunflower Yellow","code":"16401","colours":["#FFB549"]},
{"material":"PLA","type":"Lite","name":"White","code":"16103","colours":["#FFFEF7"]},
{"material":"PLA","type":"Lite","name":"Yellow","code":"16400","colours":["#FFD834"]},
{"material":"PLA","type":"Marble","name":"Red Granite","code":"13201","colours":["#AD4E38"]},
{"material":"PLA","type":"Marble","name":"White Marble","code":"13103","colours":["#F7F3F0"]},
{"material":"PLA","type":"Matte","name":"Apple Green","code":"11502","colours":["#C2E189"]},
{"material":"PLA","type":"Matte","name":"Ash Gray","code":"11102","colours":["#9B9EA0"]},
{"material":"PLA","type":"Matte","name":"Bone White","code":"11103","colours":["#CBC6B8"]},
{"material":"PLA","type":"Matte","name":"Caramel","code":"11803","colours":["#AE835B"]},
{"material":"PLA","type":"Matte","name":"Charcoal","code":"11101","colours":["#000000"]},
{"material":"PLA","type":"Matte","name":"Dark Blue","code":"11602","colours":["#042F56"]},
{"material":"PLA","type":"Matte","name":"Dark Brown","code":"11801","colours":["#7D6556"]},
{"material":"PLA","type":"Matte","name":"Dark Chocolate","code":"11802","colours":["#4D3324"]},
{"material":"PLA","type":"Matte","name":"Dark Green","code":"11501","colours":["#68724D"]},
{"material":"PLA","type":"Matte","name":"Dark Red","code":"11202","colours":["#BB3D43"]},
{"material":"PLA","type":"Matte","name":"Desert Tan","code":"11401","colours":["#E8DBB7"]},
{"material":"PLA","type":"Matte","name":"Grass Green","code":"11500","colours":["#61C680"]},
{"material":"PLA","type":"Matte","name":"Ice Blue","code":"11601","colours":["#A3D8E1"]},
{"material":"PLA","type":"Matte","name":"Ivory White","code":"11100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Matte","name":"Latte Brown","code":"11800","colours":["#D3B7A7"]},
{"material":"PLA","type":"Matte","name":"Lemon Yellow","code":"11400","colours":["#F7D959"]},
{"material":"PLA","type":"Matte","name":"Lilac Purple","code":"11700","colours":["#AE96D4"]},
{"material":"PLA","type":"Matte","name":"Mandarin Orange","code":"11300","colours":["#F99963"]},
{"material":"PLA","type":"Matte","name":"Marine Blue","code":"11600","colours":["#0078BF"]},
{"material":"PLA","type":"Matte","name":"Nardo Gray","code":"11104","colours":["#757575"]},
{"material":"PLA","type":"Matte","name":"Plum","code":"11204","colours":["#950051"]},
{"material":"PLA","type":"Matte","name":"Sakura Pink","code":"11201","colours":["#E8AFCF"]},
{"material":"PLA","type":"Matte","name":"Scarlet Red","code":"11200","colours":["#DE4343"]},
{"material":"PLA","type":"Matte","name":"Sky Blue","code":"11603","colours":["#56B7E6"]},
{"material":"PLA","type":"Matte","name":"Terracotta","code":"11203","colours":["#B15533"]},
{"material":"PLA","type":"Metal","name":"Cobalt Blue Metallic","code":"13600","colours":["#39699E"]},
{"material":"PLA","type":"Metal","name":"Copper Brown Metallic","code":"13800","colours":["#AA6443"]},
{"material":"PLA","type":"Metal","name":"Iridium Gold Metallic","code":"13400","colours":["#B39B84"]},
{"material":"PLA","type":"Metal","name":"Iron Gray Metallic","code":"13100","colours":["#43403D"]},
{"material":"PLA","type":"Metal","name":"Oxide Green Metallic","code":"13500","colours":["#1D7C6A"]},
{"material":"PLA","type":"Pure","name":"Absolute Black","code":"17101","colours":["#000000"]},
{"material":"PLA","type":"Pure","name":"Apricot","code":"17300","colours":["#FFB673"]},
{"material":"PLA","type":"Pure","name":"Baby Blue","code":"17600","colours":["#A4DBE8"]},
{"material":"PLA","type":"Pure","name":"Milky Pink","code":"17200","colours":["#F7CED7"]},
{"material":"PLA","type":"Pure","name":"Pure White","code":"17100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Silk","name":"Blue","code":"13601","colours":["#147BD1"]},
{"material":"PLA","type":"Silk","name":"Copper","code":"13300","colours":["#5E4B3C"]},
{"material":"PLA","type":"Silk","name":"Gold","code":"13401","colours":["#E5B03D"]},
{"material":"PLA","type":"Silk","name":"Green","code":"13502","colours":["#4CE4A0"]},
{"material":"PLA","type":"Silk","name":"Pink","code":"13202","colours":["#EEB1C1"]},
{"material":"PLA","type":"Silk","name":"Purple","code":"13701","colours":["#580490"]},
{"material":"PLA","type":"Silk","name":"Silver","code":"13104","colours":["#ACB4B5"]},
{"material":"PLA","type":"Silk","name":"White","code":"13105","colours":["#FFFFFF"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Aurora Purple","code":"13909","colours":["#7F3696","#006EC9"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Blue Hawaii (Blue-Green)","code":"13904","colours":["#60A4E8","#4CE4A0"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Dawn Radiance","code":"13912","colours":["#EC984C","#6CD4BC","#A66EB9","#D87694"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Gilded Rose (Pink-Gold)","code":"13901","colours":["#FF9425","#FCA2BF"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Midnight Blaze (Blue-Red)","code":"13902","colours":["#0047BB","#7D1B49"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Mystic Magenta (Purple+Green)","code":"13913","colours":["#720062","#3A913F"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Neon City (Blue-Magenta)","code":"13903","colours":["#0047BB","#BB22A3"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Phantom Blue (Blue-Black)","code":"13916","colours":["#000000","#00629B"]},
{"material":"PLA","type":"Silk Multi-Color","name":"South Beach","code":"13906","colours":["#F772A4","#00918B"]},
{"material":"PLA","type":"Silk Multi-Color","name":"Velvet Eclipse (Black-Red)","code":"13905","colours":["#000000","#C80000"]},
{"material":"PLA","type":"Silk+","name":"Baby Blue","code":"13603","colours":["#A8C6EE"]},
{"material":"PLA","type":"Silk+","name":"Blue","code":"13604","colours":["#008BDA"]},
{"material":"PLA","type":"Silk+","name":"Candy Green","code":"13506","colours":["#018814"]},
{"material":"PLA","type":"Silk+","name":"Candy Red","code":"13205","colours":["#D02727"]},
{"material":"PLA","type":"Silk+","name":"Champagne","code":"13404","colours":["#F3CFB2"]},
{"material":"PLA","type":"Silk+","name":"Gold","code":"13405","colours":["#F4A925"]},
{"material":"PLA","type":"Silk+","name":"Mint","code":"13507","colours":["#96DCB9"]},
{"material":"PLA","type":"Silk+","name":"Pink","code":"13207","colours":["#F7ADA6"]},
{"material":"PLA","type":"Silk+","name":"Purple","code":"13702","colours":["#8671CB"]},
{"material":"PLA","type":"Silk+","name":"Rose Gold","code":"13206","colours":["#BA9594"]},
{"material":"PLA","type":"Silk+","name":"Silver","code":"13109","colours":["#C8C8C8"]},
{"material":"PLA","type":"Silk+","name":"Titan Gray","code":"13108","colours":["#5F6367"]},
{"material":"PLA","type":"Silk+","name":"White","code":"13110","colours":["#FFFFFF"]},
{"material":"PLA","type":"Sparkle","name":"Alpine Green Sparkle","code":"13501","colours":["#3F5443"]},
{"material":"PLA","type":"Sparkle","name":"Classic Gold Sparkle","code":"13402","colours":["#CEA629"]},
{"material":"PLA","type":"Sparkle","name":"Crimson Red Sparkle","code":"13200","colours":["#792B36"]},
{"material":"PLA","type":"Sparkle","name":"Onyx Black Sparkle","code":"13101","colours":["#2D2B28"]},
{"material":"PLA","type":"Sparkle","name":"Royal Purple Sparkle","code":"13700","colours":["#483D8B"]},
{"material":"PLA","type":"Sparkle","name":"Slate Gray Sparkle","code":"13102","colours":["#8E9089"]},
{"material":"PLA","type":"Support","name":"Support for PLA Black","code":"65101","colours":["#000000"]},
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Support","name":"Support for PLA White","code":"65104","colours":["#FFFFFF"]},
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Black","code":"65103","colours":["#000000"]},
{"material":"PLA","type":"Support","name":"Support for PLA/PETG Nature","code":"65102","colours":["#FBF5E4"]},
{"material":"PLA","type":"Tough","name":"Black","code":"","colours":["#000000"]},
{"material":"PLA","type":"Tough","name":"Cream White","code":"","colours":["#FAFAFA"]},
{"material":"PLA","type":"Tough","name":"Gray","code":"12102","colours":["#515A6C"]},
{"material":"PLA","type":"Tough","name":"Lavender-Blue","code":"12700","colours":["#6667AB"]},
{"material":"PLA","type":"Tough","name":"Light-Blue","code":"12600","colours":["#0085AD"]},
{"material":"PLA","type":"Tough","name":"Orange","code":"12300","colours":["#FF7F41"]},
{"material":"PLA","type":"Tough","name":"Pine Green","code":"12500","colours":["#00482B"]},
{"material":"PLA","type":"Tough","name":"Silver","code":"12103","colours":["#9D9D9D"]},
{"material":"PLA","type":"Tough","name":"Vermilion-Red","code":"12200","colours":["#DD3C22"]},
{"material":"PLA","type":"Tough","name":"White","code":"12100","colours":["#FFFFFF"]},
{"material":"PLA","type":"Tough","name":"Yellow","code":"12400","colours":["#FEDB00"]},
{"material":"PLA","type":"Tough+","name":"Black","code":"12104","colours":["#000000"]},
{"material":"PLA","type":"Tough+","name":"Cyan","code":"12601","colours":["#009BD9"]},
{"material":"PLA","type":"Tough+","name":"Gray","code":"12105","colours":["#AFB1AE"]},
{"material":"PLA","type":"Tough+","name":"Orange","code":"12301","colours":["#DC3A27"]},
{"material":"PLA","type":"Tough+","name":"Silver","code":"12106","colours":["#959698"]},
{"material":"PLA","type":"Tough+","name":"White","code":"12107","colours":["#FFFFFF"]},
{"material":"PLA","type":"Tough+","name":"Yellow","code":"12401","colours":["#F4D53F"]},
{"material":"PLA","type":"Translucent","name":"Blue","code":"13611","colours":["#0047BB"]},
{"material":"PLA","type":"Translucent","name":"Cherry Pink","code":"13211","colours":["#F5B6CD"]},
{"material":"PLA","type":"Translucent","name":"Ice Blue","code":"13610","colours":["#B8CDE9"]},
{"material":"PLA","type":"Translucent","name":"Lavender","code":"13711","colours":["#B8ACD6"]},
{"material":"PLA","type":"Translucent","name":"Light Jade","code":"13510","colours":["#96D8AF"]},
{"material":"PLA","type":"Translucent","name":"Mellow Yellow","code":"13410","colours":["#F5DBAB"]},
{"material":"PLA","type":"Translucent","name":"Orange","code":"13301","colours":["#F74E02"]},
{"material":"PLA","type":"Translucent","name":"Purple","code":"13710","colours":["#8344B0"]},
{"material":"PLA","type":"Translucent","name":"Red","code":"13210","colours":["#B50011"]},
{"material":"PLA","type":"Translucent","name":"Teal","code":"13612","colours":["#009FA1"]},
{"material":"PLA","type":"Wood","name":"Black Walnut","code":"13107","colours":["#4F3F24"]},
{"material":"PLA","type":"Wood","name":"Classic Birch","code":"13505","colours":["#918669"]},
{"material":"PLA","type":"Wood","name":"Clay Brown","code":"13801","colours":["#995F11"]},
{"material":"PLA","type":"Wood","name":"Ochre Yellow","code":"13403","colours":["#C98935"]},
{"material":"PLA","type":"Wood","name":"Rosewood","code":"13204","colours":["#4C241C"]},
{"material":"PLA","type":"Wood","name":"White Oak","code":"13106","colours":["#D6CCA3"]},
{"material":"PPA","type":"CF","name":"Black","code":"73100","colours":["#000000"]},
{"material":"PPS","type":"CF","name":"Black","code":"76100","colours":["#000000"]},
{"material":"PVA","type":"Support","name":"Clear","code":"66400","colours":["#F0F1A8"]},
{"material":"TPU","type":"68D (For AMS)","name":"Black","code":"53101","colours":["#000000"]},
{"material":"TPU","type":"68D (For AMS)","name":"Blue","code":"53600","colours":["#5898DD"]},
{"material":"TPU","type":"68D (For AMS)","name":"Gray","code":"53102","colours":["#939393"]},
{"material":"TPU","type":"68D (For AMS)","name":"Neon Green","code":"53500","colours":["#90FF1A"]},
{"material":"TPU","type":"68D (For AMS)","name":"Red","code":"53200","colours":["#ED0000"]},
{"material":"TPU","type":"68D (For AMS)","name":"White","code":"53100","colours":["#FFFFFF"]},
{"material":"TPU","type":"68D (For AMS)","name":"Yellow","code":"53400","colours":["#F9EF41"]},
{"material":"TPU","type":"85A","name":"Black","code":"51107","colours":["#000000"]},
{"material":"TPU","type":"85A","name":"Flesh","code":"51201","colours":["#FFF2D5"]},
{"material":"TPU","type":"85A","name":"Light Cyan","code":"51500","colours":["#C3E2D6"]},
{"material":"TPU","type":"85A","name":"Lime Green","code":"51501","colours":["#EAF28F"]},
{"material":"TPU","type":"85A","name":"Neon Orange","code":"51305","colours":["#F68B1B"]},
{"material":"TPU","type":"90A","name":"Black","code":"51103","colours":["#000000"]},
{"material":"TPU","type":"90A","name":"Blaze","code":"51901","colours":["#F1AAA8","#D21B3C"]},
{"material":"TPU","type":"90A","name":"Cocoa Brown","code":"51800","colours":["#5C4738"]},
{"material":"TPU","type":"90A","name":"Crystal Blue","code":"51601","colours":["#7EB4E1"]},
{"material":"TPU","type":"90A","name":"Frozen","code":"51900","colours":["#FFFFFF","#40B6E4"]},
{"material":"TPU","type":"90A","name":"Grape Jelly","code":"51700","colours":["#D6ABFF"]},
{"material":"TPU","type":"90A","name":"Quicksilver","code":"51106","colours":["#9EA2A2"]},
{"material":"TPU","type":"90A","name":"White","code":"51105","colours":["#FFFFFF"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Black","code":"51100","colours":["#000000"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Blue","code":"51600","colours":["#0072CE"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Gray","code":"51101","colours":["#898D8D"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Red","code":"51200","colours":["#C8102E"]},
{"material":"TPU","type":"95A HF/High Speed","name":"White","code":"51102","colours":["#FFFFFF"]},
{"material":"TPU","type":"95A HF/High Speed","name":"Yellow","code":"51400","colours":["#F3E600"]}
]
}

View file

@ -277,6 +277,7 @@ CREATE TABLE IF NOT EXISTS filament_spools (
total_g INTEGER, -- spool weight when new
remaining_g INTEGER, -- what's left; null = unknown
ams_slot TEXT, -- "<printer>/<unit>/<slot>" once AMS sync lands
code TEXT, -- maker's filament code (Bambu 10101 etc.), what the AMS reports
notes TEXT NOT NULL DEFAULT '',
date_added TIMESTAMPTZ NOT NULL DEFAULT now(),
date_modified TIMESTAMPTZ
@ -287,3 +288,4 @@ CREATE TABLE IF NOT EXISTS filament_spools (
-- thumbnails and fingerprints are done, because the reader shells out to Python.
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments JSONB;
ALTER TABLE models ADD COLUMN IF NOT EXISTS filaments_status TEXT; -- pending | done | none | error
ALTER TABLE filament_spools ADD COLUMN IF NOT EXISTS code TEXT;

View file

@ -25,22 +25,89 @@ const PENDING = `
// "PLA Basic" and "pla basic" are the same filament; "PLA-CF" is not.
const normalise = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
// The materials a model needs that nothing on hand provides. Colour is ignored
// on purpose: matching a project's exact colours would make almost nothing
// printable, and a different colour still prints.
function missingMaterials(filaments, spools) {
const onHand = new Set(spools
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0)
.map((s) => normalise(s.material)));
const needed = [...new Set((filaments || []).map((f) => normalise(f.type)).filter(Boolean))];
return needed.filter((type) => !onHand.has(type));
// Bambu writes #RRGGBB and #RRGGBBAA; the alpha channel is not a colour.
function rgb(hex) {
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
// True when every filament the model names is on a spool you still have.
// A model we know nothing about is not claimed to be printable.
// Redmean distance: closer to how an eye judges two colours than plain RGB.
// 0 is identical, black to white is about 765. Null when either is unknown.
function colourDistance(a, b) {
const x = rgb(a);
const y = rgb(b);
if (!x || !y) return null;
const rm = (x.r + y.r) / 2;
const dr = x.r - y.r;
const dg = x.g - y.g;
const db = x.b - y.b;
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
}
// How close counts as the same colour. 50 keeps #000000 and #161616 apart (~66)
// while allowing genuinely near shades to stand in for each other.
const COLOUR_TOLERANCE = 50;
// Which spool fills which slot. One spool cannot fill two slots, so the best
// colour matches are assigned first. A slot whose project gives no colour is
// satisfied by any spool of the right material.
function assignSpools(filaments, spools) {
const slots = (filaments || []).map((f, i) => ({
index: i, material: normalise(f.type), colour: f.colour || null,
}));
const shelf = (spools || [])
.filter((s) => s.remaining_g === null || s.remaining_g === undefined || Number(s.remaining_g) > 0)
.map((s, i) => ({
key: s.id == null ? `i${i}` : `s${s.id}`, material: normalise(s.material),
colour: s.colour_hex || null, name: s.colour || s.material,
}));
const pairs = [];
for (const slot of slots) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); continue; }
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
}
}
pairs.sort((a, b) => a.d - b.d);
const filledBy = new Map();
const usedSpools = new Set();
for (const p of pairs) {
if (filledBy.has(p.slot) || usedSpools.has(p.spool)) continue;
filledBy.set(p.slot, p.spool);
usedSpools.add(p.spool);
}
return slots.map((slot) => {
const key = filledBy.get(slot.index);
const by = key ? shelf.find((s) => s.key === key) : null;
let nearest = null;
if (!by && slot.colour) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
}
}
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
});
}
// The slots you can't fill from the shelf.
function shortfall(filaments, spools) {
return assignSpools(filaments, spools).filter((s) => !s.filled);
}
// True when every slot has a spool. A model we know nothing about is not
// claimed to be printable.
function isPrintable(filaments, spools) {
if (!filaments || !filaments.length) return false;
return missingMaterials(filaments, spools).length === 0;
return shortfall(filaments, spools).length === 0;
}
// One pass. Returns whether it did any work, so it fits the background worker
@ -82,4 +149,7 @@ async function reread() {
await db.query('UPDATE models SET filaments_status = NULL');
}
module.exports = { run, pendingCount, reread, missingMaterials, isPrintable, normalise };
module.exports = {
run, pendingCount, reread,
assignSpools, shortfall, isPrintable, normalise, colourDistance, rgb, COLOUR_TOLERANCE,
};

View file

@ -665,10 +665,10 @@ async function listSpools() {
async function createSpool(data) {
const { rows } = await db.query(
`INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes)
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
`INSERT INTO filament_spools (material, colour, colour_hex, brand, total_g, remaining_g, notes, code)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING *`,
[String(data.material || '').trim(), data.colour || '', data.colour_hex || null, data.brand || '',
data.total_g ?? null, data.remaining_g ?? null, data.notes || '']);
data.total_g ?? null, data.remaining_g ?? null, data.notes || '', data.code || null]);
return rows[0];
}
@ -683,10 +683,11 @@ async function updateSpool(id, data) {
total_g = COALESCE($6, total_g),
remaining_g = COALESCE($7, remaining_g),
notes = COALESCE($8, notes),
code = COALESCE($9, code),
date_modified = now()
WHERE id = $1 RETURNING *`,
[id, data.material ?? null, data.colour ?? null, data.colour_hex ?? null, data.brand ?? null,
data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null]);
data.total_g ?? null, data.remaining_g ?? null, data.notes ?? null, data.code ?? null]);
return rows[0] || null;
}

View file

@ -6,6 +6,7 @@ let config = { importers: {} };
let stats = { trash: 0, duplicateGroups: 0, fingerprintsPending: 0 };
let selectedScope = 'all'; // 'all' | 'duplicates' | 'trash' | a collection id
let spools = []; // filament on hand, so cards can say what's printable
let palette = []; // the maker's filament list: names, codes and colours
let tagFilters = {}; // tag -> 'include' | 'exclude'
let duplicateData = null; // last /api/duplicates response
let duplicatePoll = null;
@ -159,6 +160,11 @@ async function loadData() {
config = await configRes.json();
stats = statsRes.ok ? await statsRes.json() : stats;
spools = filamentRes.ok ? (await filamentRes.json()).spools : [];
// Static and ~40KB, so fetched once rather than on every refresh.
if (!palette.length) {
palette = await fetch('/api/filament/palette')
.then((r) => (r.ok ? r.json() : { filaments: [] })).then((p) => p.filaments || []).catch(() => []);
}
if (selectedScope === 'trash') await loadTrash();
if (!SPECIAL_SCOPES.includes(selectedScope) && !projects.some((p) => String(p.id) === String(selectedScope))) {
selectedScope = 'all'; // the open collection was deleted
@ -265,22 +271,96 @@ const licenseKey = (m) => (m.commercial_use === true ? 'sellable' : m.commercial
// rather than "no" — only slicer projects name their filaments.
const filamentType = (s) => String(s || '').trim().toUpperCase().replace(/\s+/g, ' ');
// Mirrors lib/filaments.js. Bambu writes #RRGGBB and #RRGGBBAA; alpha isn't colour.
function rgbOf(hex) {
const m = /^#?([0-9a-f]{6})(?:[0-9a-f]{2})?$/i.exec(String(hex || '').trim());
if (!m) return null;
const n = parseInt(m[1], 16);
return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 };
}
// Redmean distance: nearer to how an eye judges colours than plain RGB.
function colourDistance(a, b) {
const x = rgbOf(a);
const y = rgbOf(b);
if (!x || !y) return null;
const rm = (x.r + y.r) / 2;
const dr = x.r - y.r;
const dg = x.g - y.g;
const db = x.b - y.b;
return Math.sqrt((2 + rm / 256) * dr * dr + 4 * dg * dg + (2 + (255 - rm) / 256) * db * db);
}
const COLOUR_TOLERANCE = 50; // keeps #000000 and #161616 (~66 apart) distinct
// The maker's name for a colour, when the palette has one that close. The
// threshold is deliberately tight: this is identification, not substitution.
function paletteName(hex, material) {
let best = null;
for (const f of palette) {
if (material && filamentType(f.material) !== filamentType(material)) continue;
for (const c of f.colours || []) {
const d = colourDistance(hex, c);
if (d !== null && d <= 12 && (!best || d < best.d)) best = { d, name: f.name, type: f.type };
}
}
return best ? `${best.type} ${best.name}` : null;
}
// Which spool fills which slot — best colour matches first, one spool per slot.
function printability(m) {
const needed = [...new Set(((m && m.filaments) || []).map((f) => filamentType(f.type)).filter(Boolean))];
if (!needed.length) return { known: false, needed: [], missing: [], ok: false };
const onHand = new Set(spools
const slots = ((m && m.filaments) || []).map((f, i) => ({
index: i, material: filamentType(f.type), colour: f.colour || null,
}));
if (!slots.length) return { known: false, slots: [], unfilled: [], ok: false };
const shelf = spools
.filter((s) => s.remaining_g == null || Number(s.remaining_g) > 0)
.map((s) => filamentType(s.material)));
const missing = needed.filter((t) => !onHand.has(t));
return { known: true, needed, missing, ok: missing.length === 0 };
.map((s, i) => ({ key: s.id == null ? `i${i}` : `s${s.id}`, material: filamentType(s.material),
colour: s.colour_hex || null, name: s.colour || s.material }));
const pairs = [];
for (const slot of slots) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
if (slot.colour === null) { pairs.push({ slot: slot.index, spool: spool.key, d: 0 }); continue; }
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && d <= COLOUR_TOLERANCE) pairs.push({ slot: slot.index, spool: spool.key, d });
}
}
pairs.sort((a, b) => a.d - b.d);
const filledBy = new Map();
const used = new Set();
for (const p of pairs) {
if (filledBy.has(p.slot) || used.has(p.spool)) continue;
filledBy.set(p.slot, p.spool);
used.add(p.spool);
}
const resolved = slots.map((slot) => {
const key = filledBy.get(slot.index);
const by = key ? shelf.find((s) => s.key === key) : null;
let nearest = null;
if (!by && slot.colour) {
for (const spool of shelf) {
if (spool.material !== slot.material) continue;
const d = colourDistance(slot.colour, spool.colour);
if (d !== null && (!nearest || d < nearest.distance)) nearest = { name: spool.name, distance: Math.round(d) };
}
}
return { ...slot, filled: Boolean(by), by: by ? by.name : null, nearest };
});
const unfilled = resolved.filter((s) => !s.filled);
return { known: true, slots: resolved, unfilled, ok: unfilled.length === 0 };
}
function printBadgeHtml(m) {
const p = printability(m);
if (!p.known) return '';
return p.ok
? '<span class="print-badge ok" title="Every filament it needs is on a spool">✔ printable</span>'
: `<span class="print-badge no" title="Not on the shelf: ${escapeHtml(p.missing.join(', '))}">needs ${escapeHtml(p.missing.join(', '))}</span>`;
if (p.ok) return `<span class="print-badge ok" title="A spool for every slot">✔ printable</span>`;
const n = p.unfilled.length;
return `<span class="print-badge no" title="${n} of ${p.slots.length} slots have no matching spool">needs ${n} colour${n === 1 ? '' : 's'}</span>`;
}
const totalSize = (m) => (m.files || []).reduce((s, f) => s + Number(f.size || 0), 0);
const fileCountLabel = (m) => `${m.files.length} file${m.files.length === 1 ? '' : 's'}`;
@ -2061,12 +2141,24 @@ function renderDetailPrintable(model) {
el.innerHTML = '<div class="lic-note">This model doesn’t name its filaments — only Bambu Studio and OrcaSlicer projects do.</div>';
return;
}
el.innerHTML = p.ok
? `<div><span class="print-badge ok">✔ printable now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))} — all on the shelf.</div>`
: `<div><span class="print-badge no">not right now</span></div>
<div class="lic-note">Needs ${escapeHtml(p.needed.join(', '))}; you have no ${escapeHtml(p.missing.join(', '))}.
Colour isn’t matched — a different colour still prints.</div>`;
const rows = p.slots.map((s, i) => `
<div class="slot-row">
<span class="spool-dot" style="background:${escapeHtml(s.colour || '#888888')}"></span>
<span>Slot ${i + 1} · ${escapeHtml(s.material)}</span>
<span class="form-hint">${escapeHtml(s.colour || 'any colour')}${(() => {
const named = s.colour ? paletteName(s.colour, s.material) : null;
return named ? ` · ${escapeHtml(named)}` : '';
})()}</span>
${s.filled
? `<span class="print-badge ok">${escapeHtml(s.by)}</span>`
: `<span class="print-badge no">none</span>${s.nearest
? `<span class="form-hint">nearest: ${escapeHtml(s.nearest.name)}</span>` : ''}`}
</div>`).join('');
el.innerHTML = `<div>${p.ok
? '<span class="print-badge ok">✔ printable now</span>'
: `<span class="print-badge no">needs ${p.unfilled.length} colour${p.unfilled.length === 1 ? '' : 's'}</span>`}</div>
<div class="slot-list">${rows}</div>
<div class="lic-note">A spool fills one slot, and its colour has to be close to what the slot asks for.</div>`;
}
function renderDetailLicense(model) {
@ -2247,9 +2339,12 @@ function filamentPanelHtml() {
know which filaments they ask for, so Printhive can tell you what's printable without a trip to the shelf.
Colour isn't matched — a different colour still prints.</p>
<form id="spoolForm" class="watch-form">
<input type="text" id="spoolPick" list="spoolPickList" placeholder="Pick a Bambu filament…"
aria-label="Pick a filament from the maker's list" autocomplete="off">
<datalist id="spoolPickList"></datalist>
<input type="text" id="spoolMaterial" placeholder="Material (PLA, PETG…)" aria-label="Material" required>
<input type="text" id="spoolColour" placeholder="Colour name" aria-label="Colour">
<input type="color" id="spoolHex" value="#22c55e" aria-label="Colour swatch" title="Colour">
<input type="color" id="spoolHex" value="#ffffff" aria-label="Colour swatch" title="The spool's colour — matched against what each slot asks for">
<input type="text" id="spoolBrand" placeholder="Brand" aria-label="Brand">
<input type="number" id="spoolRemaining" placeholder="Grams left" aria-label="Grams left" min="0" step="10">
<button type="submit" class="btn btn-primary" id="spoolSubmit">Add spool</button>
@ -2286,6 +2381,20 @@ async function renderFilamentPanel() {
if (!document.getElementById('spoolForm')) {
fileGrid.innerHTML = filamentPanelHtml();
document.getElementById('spoolForm').addEventListener('submit', handleAddSpool);
// Picking a known filament fills in the rest, so nobody types hex by hand.
const pick = document.getElementById('spoolPick');
document.getElementById('spoolPickList').innerHTML = palette
.map((f) => `<option value="${escapeHtml(`${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}`)}"></option>`)
.join('');
pick.addEventListener('input', () => {
const chosen = palette.find((f) => `${f.material} ${f.type} — ${f.name}${f.code ? ` (${f.code})` : ''}` === pick.value);
if (!chosen) return;
document.getElementById('spoolMaterial').value = chosen.material;
document.getElementById('spoolColour').value = chosen.name;
document.getElementById('spoolHex').value = (chosen.colours[0] || '#ffffff').toLowerCase();
pick.dataset.code = chosen.code || '';
});
}
await refreshSpools();
}
@ -2328,12 +2437,14 @@ async function handleAddSpool(e) {
status.textContent = '';
try {
const remaining = document.getElementById('spoolRemaining').value;
const pick = document.getElementById('spoolPick');
await postJson('/api/filament', {
material,
colour: document.getElementById('spoolColour').value.trim(),
colour_hex: document.getElementById('spoolHex').value,
brand: document.getElementById('spoolBrand').value.trim(),
remaining_g: remaining === '' ? null : Number(remaining),
code: (pick && pick.dataset.code) || null, // what the AMS reports, when picked from the list
});
document.getElementById('spoolForm').reset();
document.getElementById('spoolMaterial').focus();

View file

@ -464,6 +464,8 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.watch-head { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; font-size: 14px; }
.watch-actions { margin-left: auto; display: flex; flex-wrap: wrap; gap: 8px; }
.watch-actions .btn-sm { align-self: auto; }
.slot-list { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
.slot-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
.print-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; white-space: nowrap; }
.print-badge.ok { background: color-mix(in srgb, #22c55e 18%, transparent); color: #4ade80; }
.print-badge.no { background: color-mix(in srgb, #f59e0b 16%, transparent); color: #fbbf24; }

View file

@ -29,6 +29,7 @@ const aiRuns = require('./lib/aiRuns');
const collectionWatch = require('./lib/collectionWatch');
const autotag = require('./lib/autotag');
const filaments = require('./lib/filaments');
const filamentPalette = require('./db/bambu-filaments.json');
const embeddings = require('./lib/embeddings');
const app = express();
@ -323,6 +324,13 @@ app.put('/api/settings', async (req, res, next) => {
// ----------------------------------------------------------------- filament
// The spools you have. What a model needs is read from its 3MF in the
// background and kept on the model itself, so matching is a plain comparison.
// The maker's filament list, for picking a spool without typing hex codes.
// Static, so it's cached hard rather than re-read.
app.get('/api/filament/palette', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
res.json(filamentPalette);
});
app.get('/api/filament', async (req, res, next) => {
try {
res.json({ spools: await store.listSpools(), pending: await filaments.pendingCount() });

View file

@ -2,45 +2,85 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const filaments = require('../lib/filaments');
const spools = (...materials) => materials.map((m, i) => ({ id: i + 1, material: m, remaining_g: 500 }));
const spool = (material, hex, name) => ({ id: hex, material, colour_hex: hex, colour: name || hex, remaining_g: 500 });
test('a model is printable when every filament it names is on a spool', () => {
const project = [{ type: 'PLA', colour: '#00AE42' }, { type: 'PETG', colour: '#000000' }];
assert.equal(filaments.isPrintable(project, spools('PLA', 'PETG', 'ABS')), true);
assert.deepEqual(filaments.missingMaterials(project, spools('PLA', 'PETG')), []);
// The model that started this: four PLA slots, one spool of Jade White.
const pooh = [
{ type: 'PLA', colour: '#000000' },
{ type: 'PLA', colour: '#586579' },
{ type: 'PLA', colour: '#8E9089' },
{ type: 'PLA', colour: '#FFFFFF' },
];
test('a four-colour print needs those colours, not four spools of anything', () => {
const shelf = [spool('PLA', '#ffffff', 'Jade White')];
const slots = filaments.assignSpools(pooh, shelf);
assert.deepEqual(slots.map((s) => s.filled), [false, false, false, true]);
assert.equal(slots[3].by, 'Jade White');
assert.equal(filaments.isPrintable(pooh, shelf), false);
assert.equal(filaments.shortfall(pooh, shelf).length, 3);
// Four white spools still can't print it: the other three colours are missing.
const fourWhite = ['a', 'b', 'c', 'd'].map((k) => ({ ...spool('PLA', '#ffffff', 'Jade White'), id: k }));
assert.equal(filaments.isPrintable(pooh, fourWhite), false);
});
test('what is missing is named, not just refused', () => {
const project = [{ type: 'PLA' }, { type: 'PETG' }, { type: 'TPU' }];
assert.deepEqual(filaments.missingMaterials(project, spools('PLA')), ['PETG', 'TPU']);
assert.equal(filaments.isPrintable(project, spools('PLA')), false);
test('with every colour on the shelf it prints', () => {
const shelf = [spool('PLA', '#000000', 'Black'), spool('PLA', '#586579', 'Blue Grey'),
spool('PLA', '#8E9089', 'Grey'), spool('PLA', '#ffffff', 'Jade White')];
assert.equal(filaments.isPrintable(pooh, shelf), true);
assert.deepEqual(filaments.assignSpools(pooh, shelf).map((s) => s.by),
['Black', 'Blue Grey', 'Grey', 'Jade White']);
});
test('colour is ignored — a different colour still prints', () => {
const project = [{ type: 'PLA', colour: '#FF0000' }];
assert.equal(filaments.isPrintable(project, [{ material: 'PLA', colour_hex: '#0000FF', remaining_g: 200 }]), true);
test('one spool cannot fill two slots of the same colour', () => {
const twoBlack = [{ type: 'PLA', colour: '#000000' }, { type: 'PLA', colour: '#000000' }];
assert.equal(filaments.isPrintable(twoBlack, [spool('PLA', '#000000', 'Black')]), false);
assert.equal(filaments.isPrintable(twoBlack,
[spool('PLA', '#000000', 'Black'), { ...spool('PLA', '#000000', 'Black 2'), id: 'b2' }]), true);
});
test('case and spacing don’t matter, but a different material does', () => {
assert.equal(filaments.normalise(' pla basic '), 'PLA BASIC');
// PLA-CF is carbon filled: a plain PLA spool is not a substitute.
assert.deepEqual(filaments.missingMaterials([{ type: 'PLA-CF' }], spools('PLA')), ['PLA-CF']);
assert.deepEqual(filaments.missingMaterials([{ type: 'pla' }], spools('PLA')), []);
test('8-digit hex with alpha parses as its colour', () => {
assert.deepEqual(filaments.rgb('#FFFFFFFF'), { r: 255, g: 255, b: 255 });
assert.deepEqual(filaments.rgb('#586579'), { r: 88, g: 101, b: 121 });
assert.equal(filaments.rgb('not a colour'), null);
// A slot written with alpha still matches a plain spool of that colour.
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#FFFFFFFF' }],
[spool('PLA', '#ffffff', 'Jade White')]), true);
});
test('near shades count, but near-black is not black', () => {
const nearWhite = [{ type: 'PLA', colour: '#FDFDFD' }];
assert.equal(filaments.isPrintable(nearWhite, [spool('PLA', '#ffffff', 'Jade White')]), true);
// #161616 sits about 66 away from black, beyond the tolerance of 50.
assert.ok(filaments.colourDistance('#000000', '#161616') > filaments.COLOUR_TOLERANCE);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: '#161616' }],
[spool('PLA', '#000000', 'Black')]), false);
});
test('the nearest spool is named when a slot can’t be filled', () => {
const [slot] = filaments.assignSpools([{ type: 'PLA', colour: '#000000' }],
[spool('PLA', '#ffffff', 'Jade White')]);
assert.equal(slot.filled, false);
assert.equal(slot.nearest.name, 'Jade White');
assert.ok(slot.nearest.distance > 0);
});
test('material still matters, and a slot with no colour takes any spool of it', () => {
assert.equal(filaments.isPrintable([{ type: 'PETG', colour: '#ffffff' }],
[spool('PLA', '#ffffff', 'Jade White')]), false);
assert.equal(filaments.isPrintable([{ type: 'PLA', colour: null }],
[spool('PLA', '#123456', 'Anything')]), true);
});
test('an empty spool doesn’t count, but an unweighed one does', () => {
const project = [{ type: 'PLA' }];
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: 0 }]), ['PLA']);
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA', remaining_g: null }]), []);
assert.deepEqual(filaments.missingMaterials(project, [{ material: 'PLA' }]), []);
const one = [{ type: 'PLA', colour: '#ffffff' }];
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: 0 }]), false);
assert.equal(filaments.isPrintable(one, [{ ...spool('PLA', '#ffffff', 'Jade White'), remaining_g: null }]), true);
});
test('a model we know nothing about is not claimed to be printable', () => {
assert.equal(filaments.isPrintable(null, spools('PLA')), false);
assert.equal(filaments.isPrintable([], spools('PLA')), false);
});
test('the same material twice is asked for once', () => {
const project = [{ type: 'PLA', colour: '#FFF' }, { type: 'PLA', colour: '#000' }];
assert.deepEqual(filaments.missingMaterials(project, []), ['PLA']);
assert.equal(filaments.isPrintable(null, [spool('PLA', '#ffffff')]), false);
assert.equal(filaments.isPrintable([], [spool('PLA', '#ffffff')]), false);
assert.deepEqual(filaments.assignSpools(null, []), []);
});