// tag3d.jsx — interactive 3D preview of the keychain tag (three.js). // Shows the plate colour + QR relief on BOTH faces (recto-verso), so the // client sees it is double-sided. Auto-rotates, drag to spin, resumes auto // after idle. TagStage wraps it with a 3D / 2D toggle and falls back to the // flat PhotoTagPreview when WebGL/three is unavailable. // Exports to window: Tag3DPreview, TagStage (function () { const THREE = window.THREE; // ---- base STL (the real printed tag geometry) ---- // plate+ring span x∈[0,46], y∈[140,177], z∈[0,4] (mm); ring on the +x side. let _stlPromise = null; function loadBaseGeometry() { if (!_stlPromise) { _stlPromise = fetch("assets/base-tag.stl") .then((r) => { if (!r.ok) throw new Error("base STL not found"); return r.arrayBuffer(); }) .then((buf) => { const geo = new THREE.STLLoader().parse(buf); // planar UVs so the print-texture bump map has coordinates to live on const pos = geo.attributes.position; const uv = new Float32Array(pos.count * 2); for (let i = 0; i < pos.count; i++) { uv[i * 2] = pos.getX(i) * 0.045; uv[i * 2 + 1] = pos.getY(i) * 0.045; } geo.setAttribute("uv", new THREE.BufferAttribute(uv, 2)); return geo; }); } return _stlPromise; } // ---- geometry helpers ------------------------------------------------- function qrMatrix(text) { try { const qr = window.qrcode(0, "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; } catch (e) { return null; } } // subtle diagonal cross-hatch print texture (like the real product photos) let _hatchTex = null; function hatchTexture() { if (_hatchTex) return _hatchTex; const c = document.createElement("canvas"); c.width = 512; c.height = 512; const x = c.getContext("2d"); x.fillStyle = "#808080"; x.fillRect(0, 0, 512, 512); x.strokeStyle = "#6e6e6e"; x.lineWidth = 2; for (let i = -512; i < 1024; i += 7) { x.beginPath(); x.moveTo(i, 0); x.lineTo(i + 512, 512); x.stroke(); x.beginPath(); x.moveTo(i + 512, 0); x.lineTo(i, 512); x.stroke(); } x.strokeStyle = "#8f8f8f"; x.lineWidth = 1; for (let i = -512; i < 1024; i += 7) { x.beginPath(); x.moveTo(i + 3, 0); x.lineTo(i + 515, 512); x.stroke(); } const t = new THREE.CanvasTexture(c); t.wrapS = t.wrapT = THREE.RepeatWrapping; _hatchTex = t; return t; } // plate colour → physical material tuning function plateMaterial(colorId, hex) { const col = new THREE.Color(hex); const p = { color: col, roughness: 0.62, metalness: 0.04, clearcoat: 0.25, clearcoatRoughness: 0.6, bumpMap: hatchTexture(), bumpScale: 0.12 }; if (colorId === "gold") { p.metalness = 0.9; p.roughness = 0.4; p.clearcoat = 0.4; p.bumpScale = 0.16; } else if (colorId === "clear") { p.transmission = 0.72; p.roughness = 0.22; p.metalness = 0; p.transparent = true; p.opacity = 0.85; p.thickness = 3; p.ior = 1.45; p.clearcoat = 0.5; } else if (colorId === "white") { p.roughness = 0.7; } else if (colorId === "black") { p.roughness = 0.5; } return new THREE.MeshPhysicalMaterial(p); } // build the whole tag as a THREE.Group centred at origin, using the REAL // printed geometry (assets/base-tag.stl) + QR relief placed exactly like // the print generator (stl.jsx): recto on top, verso mirrored about the // plate centre. const STL = { qrX0: 3.0, qrY0: 143.0, qrSize: 31.0, zTop: 4.0, zBottom: 0.0, relief: 0.8, mirrorX: 37.0, cx: 23.0, cy: 158.5, cz: 2.0 }; function buildTag(opts, baseGeo) { const { text, plateId, plateHex, codeHex } = opts; const plateMat = plateMaterial(plateId, plateHex); const codeMat = new THREE.MeshPhysicalMaterial({ color: new THREE.Color(codeHex), roughness: 0.55, metalness: 0.04, clearcoat: 0.2 }); const inner = new THREE.Group(); inner.add(new THREE.Mesh(baseGeo, plateMat)); const m = qrMatrix(text); if (m) { const n = m.length; const ms = STL.qrSize / n; const boxGeo = new THREE.BoxGeometry(ms, ms, STL.relief); let dark = 0; for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (m[r][c]) dark++; const front = new THREE.InstancedMesh(boxGeo, codeMat, dark); const back = new THREE.InstancedMesh(boxGeo, codeMat, dark); const dummy = new THREE.Object3D(); let i = 0; for (let r = 0; r < n; r++) { for (let c = 0; c < n; c++) { if (!m[r][c]) continue; // laid out so the QR reads upright when the ring points up on screen const x = STL.qrX0 + STL.qrSize - (r + 0.5) * ms; const y = STL.qrY0 + STL.qrSize - (c + 0.5) * ms; dummy.position.set(x, y, STL.zTop + STL.relief / 2 - 0.02); dummy.updateMatrix(); front.setMatrixAt(i, dummy.matrix); // verso: mirrored about the plate centre, like the printed model dummy.position.set(STL.mirrorX - x, y, STL.zBottom - STL.relief / 2 + 0.02); dummy.updateMatrix(); back.setMatrixAt(i, dummy.matrix); i++; } } front.instanceMatrix.needsUpdate = true; back.instanceMatrix.needsUpdate = true; inner.add(front); inner.add(back); } inner.position.set(-STL.cx, -STL.cy, -STL.cz); const group = new THREE.Group(); group.add(inner); group.rotation.z = Math.PI / 2; // ring (+x in the STL) points up group.userData.dispose = function () { plateMat.dispose(); codeMat.dispose(); group.traverse(function (o) { if (o.geometry && o.geometry !== baseGeo) o.geometry.dispose(); }); }; return group; } // ---- React component -------------------------------------------------- function Tag3DPreview({ text, plateId, plateHex, codeHex, size = 260, brightness = 1, contrast = 1, saturation = 1, relief = 1, lightAngle = -27 }) { const mountRef = React.useRef(null); const stateRef = React.useRef(null); // one-time scene setup React.useEffect(function () { const mount = mountRef.current; if (!mount || !THREE) return; const W = size, H = Math.round(size * 1.12); const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(32, W / H, 0.1, 500); camera.position.set(0, 2, 118); let renderer; try { renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, preserveDrawingBuffer: true }); } catch (e) { return; } renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); renderer.setSize(W, H); renderer.outputEncoding = THREE.sRGBEncoding; mount.appendChild(renderer.domElement); renderer.domElement.style.cursor = "grab"; renderer.domElement.style.touchAction = "pan-y"; // lights (base intensities × brightness tweak) const hemi = new THREE.HemisphereLight(0xffffff, 0x9099a8, 0.85); scene.add(hemi); const key = new THREE.DirectionalLight(0xffffff, 1.15); key.position.set(-40, 60, 80); scene.add(key); const fill = new THREE.DirectionalLight(0xffffff, 0.5); fill.position.set(50, 10, 40); scene.add(fill); const rim = new THREE.DirectionalLight(0xffffff, 0.6); rim.position.set(0, -20, -80); scene.add(rim); const lights = [[hemi, 0.85], [key, 1.15], [fill, 0.5], [rim, 0.6]]; const named = { hemi, key, fill, rim }; const pivot = new THREE.Group(); scene.add(pivot); const st = { renderer, scene, camera, pivot, tag: null, lights, named, auto: true, idle: 0, velY: 0.006, dragging: false, lastX: 0, lastY: 0, rotX: -0.12, raf: 0, W, H, }; stateRef.current = st; // interaction const el = renderer.domElement; function down(e) { st.dragging = true; st.auto = false; st.idle = 0; el.style.cursor = "grabbing"; const p = e.touches ? e.touches[0] : e; st.lastX = p.clientX; st.lastY = p.clientY; } function move(e) { if (!st.dragging) return; const p = e.touches ? e.touches[0] : e; const dx = p.clientX - st.lastX, dy = p.clientY - st.lastY; st.lastX = p.clientX; st.lastY = p.clientY; pivot.rotation.y += dx * 0.01; st.rotX = Math.max(-0.9, Math.min(0.9, st.rotX + dy * 0.008)); st.velY = dx * 0.01; if (e.cancelable) e.preventDefault(); } function up() { st.dragging = false; st.idle = 0; el.style.cursor = "grab"; } el.addEventListener("mousedown", down); window.addEventListener("mousemove", move); window.addEventListener("mouseup", up); el.addEventListener("touchstart", down, { passive: true }); el.addEventListener("touchmove", move, { passive: false }); window.addEventListener("touchend", up); function loop() { st.raf = requestAnimationFrame(loop); if (st.auto) { pivot.rotation.y += st.velY; } else { st.idle += 1; if (!st.dragging && st.idle > 150) { st.auto = true; st.velY = 0.006; } // ease residual momentum if (!st.dragging) { pivot.rotation.y += st.velY; st.velY *= 0.94; } } pivot.rotation.x += (st.rotX - pivot.rotation.x) * 0.1; renderer.render(scene, camera); } loop(); st._cleanup = function () { cancelAnimationFrame(st.raf); el.removeEventListener("mousedown", down); window.removeEventListener("mousemove", move); window.removeEventListener("mouseup", up); el.removeEventListener("touchstart", down); el.removeEventListener("touchmove", move); window.removeEventListener("touchend", up); if (st.tag && st.tag.userData.dispose) st.tag.userData.dispose(); renderer.dispose(); if (el.parentNode) el.parentNode.removeChild(el); }; return function () { st._cleanup && st._cleanup(); stateRef.current = null; }; }, [size]); // rebuild tag when content/colours change (base geometry loads once, then is reused) React.useEffect(function () { let stale = false; loadBaseGeometry().then(function (geo) { const st = stateRef.current; if (stale || !st) return; if (st.tag) { st.pivot.remove(st.tag); if (st.tag.userData.dispose) st.tag.userData.dispose(); } const tag = buildTag({ text, plateId, plateHex, codeHex }, geo); st.pivot.add(tag); st.tag = tag; }).catch(function () {}); return function () { stale = true; }; }, [text, plateId, plateHex, codeHex]); // brightness + relief tweaks → light intensities. // relief > 1: stronger key/rim, softer ambient → marked shadows, shape // reads better. relief < 1: flatter, softer lighting. React.useEffect(function () { const st = stateRef.current; if (!st || !st.named) return; const b = Math.max(0.1, brightness || 1); const r = Math.max(0, Math.min(2, relief == null ? 1 : relief)); st.named.hemi.intensity = 0.85 * b * Math.max(0.3, 1.3 - 0.3 * r); st.named.key.intensity = 1.15 * b * r; st.named.fill.intensity = 0.5 * b * Math.max(0.3, 1.4 - 0.4 * r); st.named.rim.intensity = 0.6 * b * r; }, [brightness, relief]); // light angle tweak → orbit the key light around the tag React.useEffect(function () { const st = stateRef.current; if (!st || !st.named) return; const rad = ((lightAngle == null ? -27 : lightAngle) * Math.PI) / 180; st.named.key.position.set(Math.sin(rad) * 89, 60, Math.cos(rad) * 89); }, [lightAngle]); // contrast + saturation tweaks → CSS filter on the canvas React.useEffect(function () { const st = stateRef.current; if (!st) return; const c = Math.max(0.1, contrast || 1); const s = Math.max(0, saturation == null ? 1 : saturation); const parts = []; if (c !== 1) parts.push("contrast(" + c + ")"); if (s !== 1) parts.push("saturate(" + s + ")"); st.renderer.domElement.style.filter = parts.join(" "); }, [contrast, saturation]); return React.createElement("div", { ref: mountRef, style: { width: size, height: Math.round(size * 1.12), display: "grid", placeItems: "center" } }); } // ---- toggle wrapper --------------------------------------------------- const canWebGL = (function () { try { const c = document.createElement("canvas"); return !!(window.THREE && (c.getContext("webgl") || c.getContext("experimental-webgl"))); } catch (e) { return false; } })(); function TagStage({ text, plateId, plateHex, codeHex, size = 230, lang, brightness, contrast, saturation, relief, lightAngle }) { const [mode, setMode] = React.useState(canWebGL ? "3d" : "2d"); const is3d = mode === "3d" && canWebGL; const L = (fr, en) => (lang === "en" ? en : fr); return React.createElement("div", { className: "tagstage" }, is3d ? React.createElement(React.Fragment, null, React.createElement(window.Tag3DPreview, { text, plateId, plateHex, codeHex, size: size + 40, brightness, contrast, saturation, relief, lightAngle }), React.createElement("div", { className: "tagstage__hint" }, React.createElement("span", { className: "tagstage__rv" }, L("Recto-verso", "Double-sided")), React.createElement("span", null, L("Glissez pour tourner", "Drag to rotate"))), ) : React.createElement(window.PhotoTagPreview, { text, colorId: plateId, code: codeHex, size }), ); } Object.assign(window, { Tag3DPreview, TagStage }); })();