← back to CelebritySignatures
wear: real rotatable 3D viewer for the classic tee (Three.js) — TK-10286
bda7296b7187d9807086187b8302bb9b3697d3f2 · 2026-09-08 15:46:14 -0700 · Steve Abrams
Steve: "show each shirt with blender and three.js". Blender isn't installed
and there's no from-scratch 3D garment model — building one with correct
cloth draping + UV-mapped print area is genuine 3D-artist work, not a script.
Used the practical path instead: a free, MIT-licensed, pre-made crew-neck
tee model (Starklord17/threejs-t-shirt on GitHub — itself the well-known
"JS Mastery 3D T-shirt customizer" tutorial asset by Anderson Mancini + Paul
Henschel), loaded in Three.js with real rotation, live garment-color swap,
and the signature applied as a DecalGeometry projected onto the mesh at the
model's own standard chest-decal placement.
Scoped to classic-tee ONLY — it's a generic crew-neck shape, wrong for the
polo (no collar) and pocket-tee (no pocket), and no matching free model was
found for those. Showing the wrong silhouette would be worse than no 3D view,
so the "View in 3D" toggle only appears for classic-tee; switching to another
garment while in 3D mode falls back to the real photo automatically.
Hit and fixed 2 real integration bugs during verification:
1. The addon modules (GLTFLoader/OrbitControls/DecalGeometry) do bare
`import ... from 'three'` internally, which a plain dynamic import() of a
CDN URL can't resolve — added an <script type="importmap"> mapping
"three" and "three/addons/" to the CDN, verified via Playwright that this
is what actually made the modules load (the error before the fix was
caught, not silent).
2. Drawing the (cross-origin, Wikimedia-hosted) signature image onto the
decal's offscreen canvas tainted it, throwing a WebGL SecurityError on
texSubImage2D — verified Wikimedia serves permissive CORS
(access-control-allow-origin: *) and set sigImg.crossOrigin='anonymous'
to fix it. Only affects the new 3D path; the existing 2D canvas never
reads pixels back so it needed no CORS handling.
Verified end-to-end via Playwright: 3D toggle appears for classic-tee, loads
and renders the real 3D model with the signature decal visible on the chest,
drag-to-rotate works, switching colors while in 3D mode updates the garment
color live, and switching to polo correctly hides the toggle and falls back
to the 2D photo — 0 console errors throughout (after both fixes). Checkout
re-verified still fully gated off (comingSoon:true) afterward.
The shirt_baked.glb model itself (1.0MB, MIT-licensed) was already captured
by this repo's auto-data-snapshot automation before this commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
Files touched
Diff
commit bda7296b7187d9807086187b8302bb9b3697d3f2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 8 15:46:14 2026 -0700
wear: real rotatable 3D viewer for the classic tee (Three.js) — TK-10286
Steve: "show each shirt with blender and three.js". Blender isn't installed
and there's no from-scratch 3D garment model — building one with correct
cloth draping + UV-mapped print area is genuine 3D-artist work, not a script.
Used the practical path instead: a free, MIT-licensed, pre-made crew-neck
tee model (Starklord17/threejs-t-shirt on GitHub — itself the well-known
"JS Mastery 3D T-shirt customizer" tutorial asset by Anderson Mancini + Paul
Henschel), loaded in Three.js with real rotation, live garment-color swap,
and the signature applied as a DecalGeometry projected onto the mesh at the
model's own standard chest-decal placement.
Scoped to classic-tee ONLY — it's a generic crew-neck shape, wrong for the
polo (no collar) and pocket-tee (no pocket), and no matching free model was
found for those. Showing the wrong silhouette would be worse than no 3D view,
so the "View in 3D" toggle only appears for classic-tee; switching to another
garment while in 3D mode falls back to the real photo automatically.
Hit and fixed 2 real integration bugs during verification:
1. The addon modules (GLTFLoader/OrbitControls/DecalGeometry) do bare
`import ... from 'three'` internally, which a plain dynamic import() of a
CDN URL can't resolve — added an <script type="importmap"> mapping
"three" and "three/addons/" to the CDN, verified via Playwright that this
is what actually made the modules load (the error before the fix was
caught, not silent).
2. Drawing the (cross-origin, Wikimedia-hosted) signature image onto the
decal's offscreen canvas tainted it, throwing a WebGL SecurityError on
texSubImage2D — verified Wikimedia serves permissive CORS
(access-control-allow-origin: *) and set sigImg.crossOrigin='anonymous'
to fix it. Only affects the new 3D path; the existing 2D canvas never
reads pixels back so it needed no CORS handling.
Verified end-to-end via Playwright: 3D toggle appears for classic-tee, loads
and renders the real 3D model with the signature decal visible on the chest,
drag-to-rotate works, switching colors while in 3D mode updates the garment
color live, and switching to polo correctly hides the toggle and falls back
to the 2D photo — 0 console errors throughout (after both fixes). Checkout
re-verified still fully gated off (comingSoon:true) afterward.
The shirt_baked.glb model itself (1.0MB, MIT-licensed) was already captured
by this repo's auto-data-snapshot automation before this commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AmxcNKtFg77wP47uZq5Zsm
---
public/wear.html | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 115 insertions(+), 2 deletions(-)
diff --git a/public/wear.html b/public/wear.html
index 5ff92fd..12e87c7 100644
--- a/public/wear.html
+++ b/public/wear.html
@@ -6,6 +6,12 @@
<title>Wear a Signature — Celebrity Signatures</title>
<link rel="icon" type="image/png" href="/assets/favicon.png">
<link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,600;1,400&display=swap" rel="stylesheet">
+<script type="importmap">
+{ "imports": {
+ "three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
+ "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
+} }
+</script>
<style>
:root { --cols: 5; --ink:#1a1a1a; --green:#1d7a36; --line:#e6e3dc; --paper:#f7f5f0; }
* { box-sizing: border-box; }
@@ -52,7 +58,12 @@
.scrim.on { display:flex; }
.panel { background:#fff; border-radius:16px; max-width:860px; width:100%; max-height:92vh; overflow:auto; display:grid; grid-template-columns:1fr 1fr; }
@media (max-width:720px){ .panel{ grid-template-columns:1fr; } .grid{ } }
- .preview { background:#efece5; display:flex; align-items:center; justify-content:center; padding:22px; border-radius:16px 0 0 16px; }
+ .preview { background:#efece5; display:flex; align-items:center; justify-content:center; padding:22px; border-radius:16px 0 0 16px; position:relative; }
+ #stage3d { width:100%; max-width:340px; aspect-ratio:1/1; cursor:grab; }
+ #stage3d:active { cursor:grabbing; }
+ #stage3d canvas { width:100%!important; height:100%!important; display:block; border-radius:8px; }
+ .view3d-btn { position:absolute; bottom:14px; right:14px; font:inherit; font-size:12px; background:#fff; border:1px solid var(--line); border-radius:999px; padding:6px 12px; cursor:pointer; }
+ .view3d-btn:hover { border-color:var(--ink); }
@media (max-width:720px){ .preview{ border-radius:16px 16px 0 0; } }
canvas#stage { width:100%; max-width:340px; height:auto; }
.buy { padding:24px; }
@@ -109,7 +120,11 @@
<div class="scrim" id="scrim">
<span class="close" id="close">×</span>
<div class="panel" style="position:relative">
- <div class="preview"><canvas id="stage" width="400" height="480"></canvas></div>
+ <div class="preview">
+ <canvas id="stage" width="400" height="480"></canvas>
+ <div id="stage3d" style="display:none"></div>
+ <button class="view3d-btn" id="view3dToggle" hidden>View in 3D</button>
+ </div>
<div class="buy">
<h2 id="pName">—</h2>
<div class="who" id="pCat"></div>
@@ -279,6 +294,11 @@ function buildOptions(){
let sigImg = null;
function loadSigThenDraw(){
sigImg = new Image();
+ // Wikimedia serves permissive CORS (access-control-allow-origin: *) — this
+ // is required so the 3D decal canvas (which draws sigImg then gets read
+ // back as a WebGL texture) isn't "tainted" by a cross-origin image. Has no
+ // effect on the 2D <canvas> path, which never reads pixels back.
+ sigImg.crossOrigin = 'anonymous';
sigImg.onload = draw; sigImg.onerror = ()=>{ sigImg=null; draw(); };
sigImg.src = state.sig.signature_image_url;
}
@@ -303,6 +323,91 @@ function recolorToLight(img){
return off;
}
+// ---- 3D viewer (Three.js) — classic-tee only ----------------------------
+// Free MIT-licensed crew-neck tee model (Starklord17/threejs-t-shirt, itself
+// the well-known JS Mastery "3D T-shirt customizer" tutorial asset by
+// Anderson Mancini + Paul Henschel). It's a generic crew-neck shape, so it's
+// only offered for classic-tee — showing it for the polo or pocket-tee would
+// render the wrong garment silhouette, which is worse than no 3D view at all.
+const THREE3D = { loaded:false, THREE:null, scene:null, camera:null, renderer:null, controls:null, mesh:null, decalMesh:null, DecalGeometryCtor:null };
+
+async function ensureThree3D(){
+ if (THREE3D.loaded) return;
+ const [THREE, { GLTFLoader }, { OrbitControls }, { DecalGeometry }] = await Promise.all([
+ import('three'),
+ import('three/addons/loaders/GLTFLoader.js'),
+ import('three/addons/controls/OrbitControls.js'),
+ import('three/addons/geometries/DecalGeometry.js'),
+ ]);
+ const el = document.getElementById('stage3d');
+ const size = 340;
+ const renderer = new THREE.WebGLRenderer({ antialias:true, alpha:true });
+ renderer.setSize(size, size); renderer.setPixelRatio(Math.min(devicePixelRatio||1, 2));
+ el.appendChild(renderer.domElement);
+ const scene = new THREE.Scene();
+ const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100);
+ camera.position.set(0, 0, 2.4);
+ scene.add(new THREE.AmbientLight(0xffffff, 1.1));
+ const key = new THREE.DirectionalLight(0xffffff, 0.9); key.position.set(1, 1, 2); scene.add(key);
+ const fill = new THREE.DirectionalLight(0xffffff, 0.4); fill.position.set(-1, 0.5, -1); scene.add(fill);
+ const controls = new OrbitControls(camera, renderer.domElement);
+ controls.enablePan = false; controls.minDistance = 1.6; controls.maxDistance = 3.2;
+ controls.enableDamping = true;
+ const gltf = await new GLTFLoader().loadAsync('/assets/models/shirt_baked.glb');
+ const mesh = gltf.scene.getObjectByProperty('type', 'SkinnedMesh') || gltf.scene.getObjectByProperty('type', 'Mesh');
+ mesh.material = mesh.material.clone();
+ scene.add(gltf.scene);
+ (function frameLoop(){ requestAnimationFrame(frameLoop); controls.update(); renderer.render(scene, camera); })();
+ Object.assign(THREE3D, { loaded:true, THREE, scene, camera, renderer, controls, mesh, DecalGeometryCtor: DecalGeometry });
+}
+
+// Rebuilds the color + signature decal on the 3D shirt to match current state.
+function update3DGarment(){
+ if (!THREE3D.loaded) return;
+ const { THREE, scene, mesh, DecalGeometryCtor } = THREE3D;
+ mesh.material.color.set(state.color.hex);
+ if (THREE3D.decalMesh) { scene.remove(THREE3D.decalMesh); THREE3D.decalMesh.geometry.dispose(); THREE3D.decalMesh.material.dispose(); THREE3D.decalMesh = null; }
+ if (!(sigImg && sigImg.width)) return;
+ // Draw the signature onto a canvas (same multiply-vs-recolor logic as the
+ // 2D photo view) so the decal texture already carries the right ink color
+ // and has a transparent ground for DecalGeometry's alpha-tested material.
+ const cnv = document.createElement('canvas'); cnv.width = 512; cnv.height = 512;
+ const cctx = cnv.getContext('2d');
+ const ar = sigImg.width/sigImg.height; let w = 420, h = w/ar; if (h > 200) { h = 200; w = h*ar; }
+ const drawSrc = luminance(state.color.hex) < 0.4 ? recolorToLight(sigImg) : sigImg;
+ cctx.drawImage(drawSrc, (512-w)/2, (512-h)/2, w, h);
+ const decalTexture = new THREE.CanvasTexture(cnv);
+ const decalMaterial = new THREE.MeshStandardMaterial({
+ map: decalTexture, transparent: true, depthTest: true, depthWrite: false, polygonOffset: true, polygonOffsetFactor: -4,
+ });
+ // Standard chest placement for this exact model (position/rotation/scale
+ // are this tutorial asset's well-established defaults for a front decal).
+ const position = new THREE.Vector3(0, 0.04, 0.15);
+ const rotation = new THREE.Euler(0, 0, 0);
+ const size = new THREE.Vector3(0.15, 0.15, 0.15);
+ const decalGeom = new DecalGeometryCtor(mesh, position, rotation, size);
+ const decalMesh = new THREE.Mesh(decalGeom, decalMaterial);
+ scene.add(decalMesh);
+ THREE3D.decalMesh = decalMesh;
+}
+
+let in3DMode = false;
+document.getElementById('view3dToggle').onclick = async ()=>{
+ const btn = document.getElementById('view3dToggle');
+ if (!in3DMode){
+ btn.textContent = 'Loading 3D…'; btn.disabled = true;
+ try { await ensureThree3D(); update3DGarment(); }
+ catch(e){ console.error('3D view failed', e); btn.textContent = 'View in 3D'; btn.disabled = false; return; }
+ document.getElementById('stage').style.display = 'none';
+ document.getElementById('stage3d').style.display = 'block';
+ btn.textContent = 'View photo'; btn.disabled = false; in3DMode = true;
+ } else {
+ document.getElementById('stage3d').style.display = 'none';
+ document.getElementById('stage').style.display = '';
+ btn.textContent = 'View in 3D'; in3DMode = false;
+ }
+};
+
// Real garment photos are loaded once per URL and cached — switching sizes
// (which doesn't change the photo) never re-fetches.
const photoCache = {};
@@ -317,6 +422,14 @@ function loadPhoto(src, cb){
function draw(){
const cv = document.getElementById('stage'), ctx = cv.getContext('2d');
const g = state.garment, col = state.color;
+ // The free 3D model is a generic crew-neck shape — only correct for
+ // classic-tee. Hide the toggle (and fall back to the photo) for garments
+ // it doesn't actually match, rather than show the wrong silhouette.
+ const has3D = g.id === 'classic-tee';
+ const toggleBtn = document.getElementById('view3dToggle');
+ toggleBtn.hidden = !has3D;
+ if (!has3D && in3DMode){ toggleBtn.onclick(); } // forces back to the photo view
+ if (has3D && in3DMode) update3DGarment();
cv.width = g.photoWidth; cv.height = g.photoHeight;
loadPhoto(col.photo, photoImg => {
ctx.clearRect(0, 0, cv.width, cv.height);
← 8b345a4 auto-data-snapshot: 2026-09-08T15:35:04 (1 data files) — pub
·
back to CelebritySignatures
·
wear: fix the black-garment print contrast bug for real (TK- 9f1ba52 →