// stl.jsx — per-order STL generation: base blank tag + QR in relief, recto/verso.
// The base tag (assets/base-tag.stl): plate 46×37mm at x∈[0,46], y∈[140,177],
// z∈[0,4] with rounded edges; ring + hole on the right (x>36) — QR area avoids it.
// Exports: downloadQRStl, buildQRStlBuffer (for testing)
const STL_GEOM = {
qrX0: 3.0, // QR area left (mm)
qrY0: 143.0, // QR area bottom (mm)
qrSize: 31.0, // QR area side (mm)
zTop: 4.0, // top face of the plate (thickened model)
zBottom: 0.0, // bottom face of the plate
relief: 0.8, // QR relief height (mm)
mirrorX: 37.0, // verso QR mirror axis = 2×(qrX0 + qrSize/2) — keeps the QR in the same spot on both faces
};
let _baseStlPromise = null;
function loadBaseStl() {
if (!_baseStlPromise) {
_baseStlPromise = fetch("assets/base-tag.stl").then((r) => {
if (!r.ok) throw new Error("base STL not found");
return r.arrayBuffer();
});
}
return _baseStlPromise;
}
// Full QR matrix (finders included — raw standard matrix, best for printing).
function fullQrMatrix(text, ecc) {
const qr = window.qrcode(0, ecc || "M");
qr.addData(text && text.length ? text : " ");
qr.make();
const n = qr.getModuleCount();
const m = [];
for (let r = 0; r < n; r++) {
const row = [];
for (let c = 0; c < n; c++) row.push(qr.isDark(r, c));
m.push(row);
}
return m;
}
// Merge consecutive dark modules in a row into [colStart, colEnd] runs.
function rowRuns(row) {
const runs = [];
let s = -1;
for (let c = 0; c <= row.length; c++) {
const dark = c < row.length && row[c];
if (dark && s < 0) s = c;
if (!dark && s >= 0) { runs.push([s, c - 1]); s = -1; }
}
return runs;
}
// Binary STL writer helpers: each box = 12 triangles.
function writeTri(dv, off, n, a, b, c) {
dv.setFloat32(off, n[0], true); dv.setFloat32(off + 4, n[1], true); dv.setFloat32(off + 8, n[2], true);
const pts = [a, b, c];
for (let i = 0; i < 3; i++) {
dv.setFloat32(off + 12 + i * 12, pts[i][0], true);
dv.setFloat32(off + 16 + i * 12, pts[i][1], true);
dv.setFloat32(off + 20 + i * 12, pts[i][2], true);
}
dv.setUint16(off + 48, 0, true);
return off + 50;
}
function writeBox(dv, off, x0, y0, z0, x1, y1, z1) {
const A = [x0, y0, z0], B = [x1, y0, z0], C = [x1, y1, z0], D = [x0, y1, z0];
const E = [x0, y0, z1], F = [x1, y0, z1], G = [x1, y1, z1], H = [x0, y1, z1];
// bottom (z0, normal -z), top (z1, +z), 4 sides
off = writeTri(dv, off, [0, 0, -1], A, C, B); off = writeTri(dv, off, [0, 0, -1], A, D, C);
off = writeTri(dv, off, [0, 0, 1], E, F, G); off = writeTri(dv, off, [0, 0, 1], E, G, H);
off = writeTri(dv, off, [0, -1, 0], A, B, F); off = writeTri(dv, off, [0, -1, 0], A, F, E);
off = writeTri(dv, off, [1, 0, 0], B, C, G); off = writeTri(dv, off, [1, 0, 0], B, G, F);
off = writeTri(dv, off, [0, 1, 0], C, D, H); off = writeTri(dv, off, [0, 1, 0], C, H, G);
off = writeTri(dv, off, [-1, 0, 0], D, A, E); off = writeTri(dv, off, [-1, 0, 0], D, E, H);
return off;
}
// Build the final STL ArrayBuffer.
// mode: "top" = upper half (z 1→2 + QR recto), laid flat (cut face on the bed)
// "bottom" = lower half (z 0→1 + QR verso), rotated 180° so it also lies flat
// "full" = whole tag with both reliefs
async function buildQRStlBuffer(text, opts) {
const o = opts || {};
const mode = o.mode || "full";
const g = { ...STL_GEOM, ...(o.geom || {}) };
const base = await loadBaseStl();
const baseDv = new DataView(base);
const baseCount = baseDv.getUint32(80, true);
const zMid = (g.zTop + g.zBottom) / 2; // 1.0 — cut plane
const matrix = fullQrMatrix(text, o.ecc);
const n = matrix.length;
const m = g.qrSize / n; // module pitch in mm
const eps = 0.01; // sink slightly into the plate to guarantee fusion
// collect QR boxes for the side(s) we keep
const boxes = [];
for (let r = 0; r < n; r++) {
const y0 = g.qrY0 + (n - 1 - r) * m;
const y1 = y0 + m;
for (const [c0, c1] of rowRuns(matrix[r])) {
if (mode !== "bottom")
boxes.push([g.qrX0 + c0 * m, y0, g.zTop - eps, g.qrX0 + (c1 + 1) * m, y1, g.zTop + g.relief]);
if (mode !== "top") {
const mx0 = g.mirrorX - (g.qrX0 + (c1 + 1) * m);
const mx1 = g.mirrorX - (g.qrX0 + c0 * m);
boxes.push([mx0, y0, g.zBottom - g.relief, mx1, y1, g.zBottom + eps]);
}
}
}
// gather all triangles as {n:[3], v:[3][3]} in ORIGINAL coordinates
const tris = [];
for (let i = 0; i < baseCount; i++) {
const off = 84 + i * 50;
const nor = [baseDv.getFloat32(off, true), baseDv.getFloat32(off + 4, true), baseDv.getFloat32(off + 8, true)];
const v = [];
for (let k = 0; k < 3; k++) v.push([baseDv.getFloat32(off + 12 + k * 12, true), baseDv.getFloat32(off + 16 + k * 12, true), baseDv.getFloat32(off + 20 + k * 12, true)]);
// halve the base plate: clamp the far face's vertices to the cut plane
if (mode === "top") for (const p of v) { if (p[2] < zMid) p[2] = zMid; }
if (mode === "bottom") for (const p of v) { if (p[2] > zMid) p[2] = zMid; }
// drop triangles fully collapsed onto the cut plane that came from the
// removed face (zero area after clamping) — keep cut-face geometry from
// the opposite face instead (it keeps the silhouette, normals stay valid)
const flat = v.every((p) => Math.abs(p[2] - zMid) < 1e-6);
const wasFar = mode === "top" ? nor[2] < -0.5 : mode === "bottom" ? nor[2] > 0.5 : false;
if (flat && !wasFar && mode !== "full") continue; // collapsed wall slivers
tris.push({ n: nor, v });
}
// QR boxes → triangles
const boxBuf = new ArrayBuffer(50 * 12);
for (const b of boxes) {
const tmp = new DataView(new ArrayBuffer(12 * 50));
writeBox(tmp, 0, b[0], b[1], b[2], b[3], b[4], b[5]);
for (let i = 0; i < 12; i++) {
const off = i * 50;
const nor = [tmp.getFloat32(off, true), tmp.getFloat32(off + 4, true), tmp.getFloat32(off + 8, true)];
const v = [];
for (let k = 0; k < 3; k++) v.push([tmp.getFloat32(off + 12 + k * 12, true), tmp.getFloat32(off + 16 + k * 12, true), tmp.getFloat32(off + 20 + k * 12, true)]);
tris.push({ n: nor, v });
}
}
// transform so each half lies flat on the bed (cut face at z=0, QR up)
function xform(p) {
if (mode === "top") return [p[0], p[1], p[2] - zMid]; // translate down
if (mode === "bottom") return [46 - p[0], p[1], zMid - p[2]]; // 180° about Y axis
return p;
}
function xformN(nr) {
if (mode === "bottom") return [-nr[0], nr[1], -nr[2]];
return nr;
}
const out = new ArrayBuffer(84 + tris.length * 50);
const dv = new DataView(out);
const header = "QR Bag Tag - " + mode + " (QR relief, print flat side down)";
for (let i = 0; i < 80; i++) dv.setUint8(i, i < header.length ? header.charCodeAt(i) : 32);
dv.setUint32(80, tris.length, true);
let off = 84;
for (const t of tris) {
off = writeTri(dv, off, xformN(t.n), xform(t.v[0]), xform(t.v[1]), xform(t.v[2]));
}
return out;
}
async function downloadQRStl(text, opts) {
const o = opts || {};
const buf = await buildQRStlBuffer(text, o);
const blob = new Blob([buf], { type: "model/stl" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = o.filename || "qrbagtag.stl";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
// Two flat-printable halves (glue together after printing).
async function downloadQRStlSplit(text, opts) {
const o = opts || {};
const base = (o.filename || "qrbagtag.stl").replace(/\.stl$/i, "");
await downloadQRStl(text, { ...o, mode: "top", filename: base + "-recto.stl" });
setTimeout(() => downloadQRStl(text, { ...o, mode: "bottom", filename: base + "-verso.stl" }), 450);
}
// ---------- FLAT (no relief) version, for AMS / multi-colour printers ----------
// The QR is a thin inlay whose top face is FLUSH with the plate top (zTop),
// so the scanning surface is perfectly smooth. Contrast comes from a second
// filament (AMS), never from relief shadows → scans at any angle, laid flat.
// Output = 2 bodies: the blank plate (your plate colour) + the QR inlay
// (your code colour). In Bambu Studio: import both, select → Merge into one
// object (they become parts), then assign each part a filament.
function buildFlatQrStl(text, opts) {
const o = opts || {};
const g = { ...STL_GEOM, ...(o.geom || {}) };
const inlay = o.inlay != null ? o.inlay : 0.6; // top-layer thickness (mm), ~3 layers
const matrix = fullQrMatrix(text, o.ecc);
const n = matrix.length;
const m = g.qrSize / n;
const boxes = [];
for (let r = 0; r < n; r++) {
const y0 = g.qrY0 + (n - 1 - r) * m;
const y1 = y0 + m;
for (const [c0, c1] of rowRuns(matrix[r])) {
// flush inlay: bottom sinks `inlay` into the plate, top sits exactly at zTop
boxes.push([g.qrX0 + c0 * m, y0, g.zTop - inlay, g.qrX0 + (c1 + 1) * m, y1, g.zTop]);
}
}
const out = new ArrayBuffer(84 + boxes.length * 12 * 50);
const dv = new DataView(out);
const header = "QR Bag Tag - flat QR inlay (AMS multicolor)";
for (let i = 0; i < 80; i++) dv.setUint8(i, i < header.length ? header.charCodeAt(i) : 32);
dv.setUint32(80, boxes.length * 12, true);
let off = 84;
for (const b of boxes) off = writeBox(dv, off, b[0], b[1], b[2], b[3], b[4], b[5]);
return out;
}
function triggerDownload(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 2000);
}
async function downloadFlatTag(text, opts) {
const o = opts || {};
const base = (o.filename || "qrbagtag.stl").replace(/\.stl$/i, "");
// 1) blank plate (+ ring) — assign your PLATE colour in the slicer
const plate = await loadBaseStl();
triggerDownload(new Blob([plate], { type: "model/stl" }), base + "-plaque.stl");
// 2) flush QR inlay — assign your CODE colour
setTimeout(() => {
const qr = buildFlatQrStl(text, o);
triggerDownload(new Blob([qr], { type: "model/stl" }), base + "-qr.stl");
}, 450);
}
// ---------- 3MF export: ONE file, plate + QR as two distinct meshes ----------
// Imports in Bambu Studio / PrusaSlicer as a single object with TWO selectable
// parts ("Plaque" + "QR code") — assign one AMS filament to each, then print.
// The QR is a flush inlay in the top 0.6 mm (no relief, smooth surface).
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xEDB88320 ^ (c >>> 1) : c >>> 1;
t[i] = c >>> 0;
}
return t;
})();
function crc32(u8) {
let c = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) c = CRC_TABLE[(c ^ u8[i]) & 0xFF] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
// Minimal ZIP writer (entries stored, no compression) — enough for 3MF.
function makeZip(files) {
const enc = new TextEncoder();
const parts = [], central = [];
let offset = 0;
for (const f of files) {
const name = enc.encode(f.name);
const data = typeof f.data === "string" ? enc.encode(f.data) : f.data;
const crc = crc32(data);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0, 0x04034b50, true);
lh.setUint16(4, 20, true);
lh.setUint32(14, crc, true);
lh.setUint32(18, data.length, true);
lh.setUint32(22, data.length, true);
lh.setUint16(26, name.length, true);
parts.push(new Uint8Array(lh.buffer), name, data);
const ch = new DataView(new ArrayBuffer(46));
ch.setUint32(0, 0x02014b50, true);
ch.setUint16(4, 20, true); ch.setUint16(6, 20, true);
ch.setUint32(16, crc, true);
ch.setUint32(20, data.length, true);
ch.setUint32(24, data.length, true);
ch.setUint16(28, name.length, true);
ch.setUint32(42, offset, true);
central.push(new Uint8Array(ch.buffer), name);
offset += 30 + name.length + data.length;
}
let cdSize = 0;
for (const c of central) cdSize += c.length;
const end = new DataView(new ArrayBuffer(22));
end.setUint32(0, 0x06054b50, true);
end.setUint16(8, files.length, true);
end.setUint16(10, files.length, true);
end.setUint32(12, cdSize, true);
end.setUint32(16, offset, true);
const all = [...parts, ...central, new Uint8Array(end.buffer)];
let total = 0; for (const a of all) total += a.length;
const out = new Uint8Array(total);
let p = 0; for (const a of all) { out.set(a, p); p += a.length; }
return out;
}
// Indexed mesh with vertex dedup (3MF wants vertices + triangle indices).
function newMesh() { return { verts: [], tris: [], map: new Map() }; }
function meshVert(mesh, x, y, z) {
const k = x.toFixed(4) + "," + y.toFixed(4) + "," + z.toFixed(4);
let i = mesh.map.get(k);
if (i === undefined) { i = mesh.verts.length; mesh.verts.push([x, y, z]); mesh.map.set(k, i); }
return i;
}
function meshBox(mesh, x0, y0, z0, x1, y1, z1) {
const A = meshVert(mesh, x0, y0, z0), B = meshVert(mesh, x1, y0, z0), C = meshVert(mesh, x1, y1, z0), D = meshVert(mesh, x0, y1, z0);
const E = meshVert(mesh, x0, y0, z1), F = meshVert(mesh, x1, y0, z1), G = meshVert(mesh, x1, y1, z1), H = meshVert(mesh, x0, y1, z1);
const T = mesh.tris;
T.push([A, C, B], [A, D, C]); // bottom (outward -z)
T.push([E, F, G], [E, G, H]); // top (+z)
T.push([A, B, F], [A, F, E]);
T.push([B, C, G], [B, G, F]);
T.push([C, D, H], [C, H, G]);
T.push([D, A, E], [D, E, H]);
}
function meshXml(mesh) {
let v = "", t = "";
for (const p of mesh.verts) v += ``;
for (const tr of mesh.tris) t += ``;
return `${v}${t}`;
}
// Convert the binary-STL base plate into an indexed mesh.
function stlToMesh(buf) {
const dv = new DataView(buf);
const count = dv.getUint32(80, true);
const mesh = newMesh();
for (let i = 0; i < count; i++) {
const off = 84 + i * 50;
const idx = [];
for (let k = 0; k < 3; k++) {
idx.push(meshVert(
mesh,
dv.getFloat32(off + 12 + k * 12, true),
dv.getFloat32(off + 16 + k * 12, true),
dv.getFloat32(off + 20 + k * 12, true)
));
}
if (idx[0] !== idx[1] && idx[1] !== idx[2] && idx[0] !== idx[2]) mesh.tris.push(idx);
}
return mesh;
}
// QR modules as a "logo" mesh: anchored 0.4 mm INTO the plate for fusion,
// proud by 0.2 mm (one layer) ABOVE it — the Bambu-standard way to do
// multicolour logos. Avoids coplanar faces (no z-fighting, unambiguous slicing);
// 0.2 mm is visually flat and casts no usable shadow, so scanning is unaffected.
function flatQrMesh(text, opts) {
const o = opts || {};
const g = { ...STL_GEOM, ...(o.geom || {}) };
const anchor = o.anchor != null ? o.anchor : 0.4; // depth sunk into the plate
const proud = o.proud != null ? o.proud : 0.2; // height above the plate (1 layer)
const matrix = fullQrMatrix(text, o.ecc);
const n = matrix.length;
const m = g.qrSize / n;
const mesh = newMesh();
for (let r = 0; r < n; r++) {
const y0 = g.qrY0 + (n - 1 - r) * m, y1 = y0 + m;
for (const [c0, c1] of rowRuns(matrix[r])) {
meshBox(mesh, g.qrX0 + c0 * m, y0, g.zTop - anchor, g.qrX0 + (c1 + 1) * m, y1, g.zTop + proud);
}
}
return mesh;
}
async function build3mfTag(text, opts) {
const base = await loadBaseStl();
const plate = stlToMesh(base);
const qr = flatQrMesh(text, opts);
const model =
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
``;
return makeZip([
{
name: "[Content_Types].xml",
data: ``,
},
{
name: "_rels/.rels",
data: ``,
},
{ name: "3D/3dmodel.model", data: model },
]);
}
async function download3mfTag(text, opts) {
const o = opts || {};
const zip = await build3mfTag(text, o);
const name = (o.filename || "qrbagtag").replace(/\.(stl|3mf)$/i, "") + ".3mf";
triggerDownload(new Blob([zip], { type: "model/3mf" }), name);
}
// ---------- DOUBLE-SIDED: two glue-together halves (QR on both faces) ----------
// assets/half-tag.stl is one watertight 3 mm half: rounded outer face at z=0,
// flat glue face at z=3, keyring hole preserved. We flip it so the glue face is
// on the bed (z=0) and the rounded face + QR are on top (z=3). The profile is
// symmetric and the hole sits on the plate's centre axis, so BOTH halves are
// identical: print the two, flip one about its long axis, glue glue-face to
// glue-face → a 6 mm tag with a readable QR on each side.
const HALF_TOTAL = 3.0;
let _halfStlPromise = null;
function loadHalfStl() {
if (!_halfStlPromise) {
_halfStlPromise = fetch("assets/half-tag.stl").then((r) => {
if (!r.ok) throw new Error("half STL not found");
return r.arrayBuffer();
});
}
return _halfStlPromise;
}
// Convert the half STL to an indexed mesh, flipped in Z (z -> zt - z) so the
// glue face lands on the bed; winding reversed to keep normals outward.
function stlToMeshFlipped(buf, zt) {
const dv = new DataView(buf);
const count = dv.getUint32(80, true);
const mesh = newMesh();
for (let i = 0; i < count; i++) {
const off = 84 + i * 50;
const idx = [];
for (let k = 0; k < 3; k++) {
idx.push(meshVert(
mesh,
dv.getFloat32(off + 12 + k * 12, true),
dv.getFloat32(off + 16 + k * 12, true),
zt - dv.getFloat32(off + 20 + k * 12, true)
));
}
if (idx[0] !== idx[1] && idx[1] !== idx[2] && idx[0] !== idx[2]) mesh.tris.push([idx[0], idx[2], idx[1]]);
}
return mesh;
}
// QR logo on the TOP face (z = zFace) of a flipped half.
function flatQrMeshTop(text, opts) {
const o = opts || {};
const g = { ...STL_GEOM, ...(o.geom || {}) };
const zFace = o.zFace != null ? o.zFace : HALF_TOTAL;
const anchor = o.anchor != null ? o.anchor : 0.4;
const proud = o.proud != null ? o.proud : 0.2;
const matrix = fullQrMatrix(text, o.ecc);
const n = matrix.length;
const m = g.qrSize / n;
const mesh = newMesh();
for (let r = 0; r < n; r++) {
const y0 = g.qrY0 + (n - 1 - r) * m, y1 = y0 + m;
for (const [c0, c1] of rowRuns(matrix[r])) {
meshBox(mesh, g.qrX0 + c0 * m, y0, zFace - anchor, g.qrX0 + (c1 + 1) * m, y1, zFace + proud);
}
}
return mesh;
}
async function build3mfDouble(text, opts) {
const o = opts || {};
const half = await loadHalfStl();
const plate = stlToMeshFlipped(half, HALF_TOTAL);
const qr = flatQrMeshTop(text, { ...o, zFace: HALF_TOTAL });
const GAP = 56; // place the 2nd half beside the 1st (plate is 46 mm wide)
const model =
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
`` +
``;
return makeZip([
{
name: "[Content_Types].xml",
data: ``,
},
{
name: "_rels/.rels",
data: ``,
},
{ name: "3D/3dmodel.model", data: model },
]);
}
async function download3mfDouble(text, opts) {
const o = opts || {};
const zip = await build3mfDouble(text, o);
const name = (o.filename || "qrbagtag").replace(/\.(stl|3mf)$/i, "") + "-recto-verso.3mf";
triggerDownload(new Blob([zip], { type: "model/3mf" }), name);
}
Object.assign(window, { downloadQRStl, downloadQRStlSplit, buildQRStlBuffer, buildFlatQrStl, downloadFlatTag, build3mfTag, download3mfTag, build3mfDouble, download3mfDouble });