Perler Bead Blueprint Maker:可將作品匯出為單一 HTML 檔案的熔珠圖案生成器
I built a Perler bead pattern generator that exports your work as a single HTML file
作者製作 Perler Bead Blueprint Maker,讓用戶把生成器連同當前圖案及進度匯出為單一 HTML 檔案。工具在瀏覽器本機運行,支援多種熔珠色盤,並以固定隨機種子的 Lab 空間 k-means 進行顏色量化。匯出時以 DOM 操作注入資料,避免正則表達式誤改應用程式程式碼;匯出檔案可離線使用。
If you've never heard of Perler beads (fuse beads): you place tiny plastic beads on a pegged board, iron them, and they melt into pixel art you can keep. It's physical pixel art — and people make genuinely impressive stuff with it.
Planning a big project requires a pattern: a grid telling you which bead color goes in which cell. Existing online generators do the quantization for you, but I wanted one with a specific superpower: the whole tool, plus your current work, exportable as ONE self-contained HTML file you can keep, email, or open years later.
So I built Perler Bead Blueprint Maker — 100% client-side, no account, no backend, images never leave your browser. Source is on GitHub (MIT).
This post is about how it works and the one bug that taught me to never regex-replace HTML again.
TL;DR — what it does
- Upload an image → quantized to real bead palettes (Perler, MARD, ARTKAL, YANT — including their actual color codes and names)
- Preset sizes (29/38/49/69/104…) or any custom N×N
- Color budget: k-means clustering in Lab space to hit "≤ 25 colors" without visual mush
- Interactive 2D grid (bead codes printed in cells, highlight tracking, "done" marks that persist)
-
3D board view with real perler beads (Three.js
InstancedMesh) and a depth-layering mode that stacks the board into relief layers - Editing tools: brush / fill / eraser / eyedropper with undo history
- Export: poster PNG/PDF, multi-size PDF collection + CSV shopping list, and — the reason this project exists — a single HTML file that IS the app WITH your work inside
The quantization: Lab space + seeded k-means
Naive RGB distance quantizes badly (blues and purples especially). Everything runs in CIELAB and uses ΔE76 for matching:
// rgb → Lab, then nearest palette color by ΔE76
function nearest(hex: string, palette: Color[]): Color {
const [L, a, b] = rgb2lab(hex);
let best = palette[0], bestD = Infinity;
for (const c of palette) {
const d = deltaE76(L, a, b, c.lab);
if (d < bestD) { bestD = d; best = c; }
}
return best;
}
For the color budget, a palette of 221 colors reduced to "25 colors max" is a clustering problem. k-means++ in Lab space works well, with one addition: a fixed seed, so the same image always quantizes to the same pattern. Sounds obvious, but most generators re-roll the dice on every page load — you refresh and your carefully-tuned 20-color selection becomes 23 different colors.
The 3D board
Beads are InstancedMesh cylinders — one draw call for thousands of beads — with a canvas-generated texture that fakes the hole. The pegboard underneath has bumps. Nothing fancy, but it sells the illusion:
const geo = new THREE.CylinderGeometry(0.42, 0.42, 0.32, 24);
const mesh = new THREE.InstancedMesh(geo, mat, count);
// one setMatrixAt per bead, y offset = board layer
The depth-layering mode is my favorite: a heuristic (brightness + center weighting + smoothing, then equal-frequency bucketing into 7 layers) turns a flat pattern into stacked relief layers — for making actual 3D bead sculptures, shakelight-style.
The single-file export (and the bug that broke it)
The exported file is the entire app bundle + your current state (blueprint, progress, source image as a compressed data URL) embedded in a <script type="application/json"> tag. On open, the app checks that tag, restores state into localStorage, and you continue where you left off. No server, no database — the file is the database.
My first implementation did the injection with a regex:
// ❌ DO NOT DO THIS
html = html.replace(
/<script id="perlerSaved"[^>]*>[\s\S]*?<\/script>/,
`<script id="perlerSaved">${json}</script>`
);
It worked… until it didn't. The check I skipped: the marker string perlerSaved also exists inside the bundled JS — in the very restore code that reads the tag, and inside minified variable-containing chunks. The regex matched there first, and replaced ~84KB of application code with a JSON blob. The exported file opened to a white screen.
Two lessons, now burned into the project's docs:
// ✅ DOM-based injection: the parser finds THE element, not any text occurrence
const tpl = document.createElement('div');
tpl.innerHTML = html;
const tag = tpl.querySelector('#perlerSaved');
tag.textContent = json; // textContent never executes, never mis-matches
html = '<!DOCTYPE html>' + tpl.innerHTML;
- Never regex-replace structured markup. Parse, query, mutate, serialize. The regex version produced a file that worked exactly once — when I tested it before the bundle contained the string, and never again.
- Test the export against the real production bundle, not a dev build. Dev bundles don't contain minified code that collides with your markers.
Bonus property of this design: the exported file strips external <script src> tags, so your exported copy has no analytics, no external requests — it works offline forever.
Small things that mattered more than expected
- Printing bead codes inside grid cells came from a single user comment ("no way to know which color goes where without cross-checking the list"). Cells auto-hide codes below ~13px, and text color flips black/white by cell luminance with a contrast outline.
-
Language toggle (中文/EN) — first-use detection from
navigator.language, persisted choice. A pixel-art tool is visual, but "MARD 221" labels and toasts still need to be readable. - Pointer Events everywhere — one code path for mouse and touch, pinch-zoom included. Writing mouse + touch handlers separately in 2026 is self-harm.
-
Privacy-friendly analytics: GoatCounter, no cookies, event names like
e/upload/104/mard48/zh/m(event/size/palette/language/device). One gotcha I hit the hard way: GoatCounter silently classifies traffic from cloud-provider IP ranges as bots — if you test your own site through a VPN/proxy, your dashboard stays at zero and nothing tells you why.
Try it
Everything runs locally; upload an image, hit the 3D view, and export a single file as a souvenir.
If you build something with a pattern from it (or find a quantization case where it picks a clearly wrong color), I'd genuinely like to hear about it in the comments.
來源:DEV Community · dev.to