// 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 = `` + `` + `` + `${meshXml(plate)}` + `${meshXml(qr)}` + `` + `` + `` + ``; 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 = `` + `` + `` + `${meshXml(plate)}` + `${meshXml(qr)}` + `` + `` + `` + `` + `` + `` + `` + ``; 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 });