
Print Flagship portrait
Workshop · the court · the three.js build · the shop
The shop on the live site is a WebGL room. Every mug, tee, tote and frame in it is plain three.js geometry in the colour of the real blank, with the artwork laid on top from the file that goes to the press. Hover shows a price, a click opens the piece, the bag lives in the browser, and checkout never leaves the domain.
Behind that, a 130-line Printify module and one Stripe webhook turn a paid intent into a print order, and the bug that shipped every t-shirt as a Small is the reason the catalog now refuses to guess.
Two things are for sale on helenthecatlive.com: a $2 treat, which is its own page, and the Royal Collection, which is this one. The collection is twenty-two print-on-demand pieces in six zones (back wall, centre table, rack, bench, cabinet and the Court Annex console) fulfilled by Printify and sold from a three.js room that shares its renderer, quality loop and dither with the court. The room does not load the generated cat; the Tripo-to-GLB path is on the 3D generation page, and everything in the shop is procedural geometry with a texture. What follows is read from the source: src/shopGoods.ts, src/shopRoom.ts, src/shopCourt.ts, src/site/catalog.ts, server/api.mjs, server/printify.mjs and scripts/sync-printify-variants.mjs, plus the live catalog.json and decal manifest the site serves.
Layer 1 of the workshop layout: a static zone map. Each zone links to the section below. The live room uses one annex fly-to waypoint for console goods so six small targets do not fight for camera space.

Print Flagship portrait

Tee Portrait on black cotton

Mug Wrap around ceramic

Case Print on the back

Frame Helen, Softly

Mug Crescent moon

Tee Gold crest

Tee The decree

Tee Crowned H

Tote Natural canvas

Mug Everyday crest

Stickers Four-piece set

Print Rescued Queen

Hoodie Soft black

Pillow Two-sided

Journal Ruled pages
Printify studio mockups of the original sixteen pieces. The 3D room no longer wears these photographs. It wears the print files below. Six annex SKUs are visible in the room as coming soon until Printify products are bound.
Three flagship prints stay centred; the left decorative photo became the Royal Decree poster so the wall sells the joke instead of hanging a non-stock portrait beside it.
Four hanging tees and one hoodie. The Winter Park tee took the leftmost rod slot so the rescue story is wearable, not only on the wall.
Mugs face the camera with atan2 so the printed band reads from the doorway. Stickers and the journal share the tabletop without stacking sellables on top of each other.
The phone case sits on a lit shelf; two totes lean on the floor as a deliberate pair. Shelf props (book stacks, trinket trays) are set dressing only.
A new lacquered console against the right wall holds small-format goods. Clicking any annex sellable flies the camera to one shared waypoint; a sticker sample and a folded tee on the lower shelf are non-clickable scale references.
Folded tee stacks repeat rack designs for visual weight; only the top fold in each stack is sellable. The portrait pillow props against the arm.
The first version of the room textured each object with its Printify studio mockup, a photograph of the finished product on a white sweep. A cylinder wore a photo of a mug, a plane on the rack wore a photo of a shirt with its white background, and a gilt frame contained a photo of a framed print. The fix was to stop using pictures of products and use the artwork itself: the print files Printify sends to the press are flat, unlit, 3600×3600 and mostly carry alpha already. They were pulled down once, resized, and written to public/merch/decal/ with a manifest beside them.
The files that go to the press. Checkerboard is alpha. The mug band is wide because it only covers the printable arc, not the handle.
{
"royal-portrait-mug": {
"file": "/merch/decal/royal-portrait-mug.png",
"w": 768,
"h": 319,
"aspect": 2.4075,
"kind": "mug"
},
"royal-crest-hoodie": {
"file": "/merch/decal/royal-crest-tee.png",
"w": 512,
"h": 512,
"aspect": 1,
"kind": "hoodie"
}
}
Sixteen entries, fourteen PNG files, 444 kB on disk. Each builder in shopGoods.ts makes the object out of the material it is printed on (0xeeece7 ceramic, 0x17130f black cotton, 0xe9e3d7 natural canvas, 0x14100e case shell) and then adds one extra mesh for the art, sized from the decal’s own aspect so nothing stretches. The mug band is a partial cylinder rather than a full wrap, because the print file covers the printable area and leaves a gap behind the handle; wrapping it 360° distorted the art nearly two to one. The tee’s chest plane, the tote’s front panel, the pillow face and the phone-case back all read w and h from the manifest the same way.
function decalMaterial(opts: { roughness?: number; metalness?: number } = {}) {
return new THREE.MeshPhysicalMaterial({
color: 0xffffff,
transparent: true,
// Starts invisible. A white lit material at full alpha with no map is an
// opaque white card, so every product wore a blank rectangle for the whole
// texture load - and permanently if the manifest fetch failed.
opacity: 0,
roughness: opts.roughness ?? 0.62,
metalness: opts.metalness ?? 0.02,
envMapIntensity: 0.18,
// Decals sit a fraction of a millimetre off their surface. Without this the
// two coplanar faces fight and the art flickers as the camera moves.
polygonOffset: true,
polygonOffsetFactor: -2,
polygonOffsetUnits: -2,
depthWrite: false,
});
}
Two details in that function are the whole trick. The decal starts at opacity: 0 and fades up when its texture lands, because a white lit material with no map is an opaque white card, and every product wore a blank rectangle for as long as the texture took to load, or forever if the manifest fetch failed. And the decal sits a fraction of a millimetre off its surface with polygonOffset and depthWrite: false, otherwise the two coplanar faces fight and the art flickers as the camera moves. Only meshes tagged with userData.decalKey ever receive a texture; an earlier version sniffed for placeholder colours and stamped product art onto anything that happened to share a hex.
| Object | Before | After |
|---|---|---|
| Mug | cylinder, map = photo of a mug | white ceramic body, gold rim and handle, printed band wrapped around the printable arc |
| Tee | rectangle, map = photo of a shirt | t-shirt silhouette in black cotton with sheen, chest print sized from the decal’s aspect |
| Tote | box, map = photo of a tote | natural canvas body, black webbing straps, crest on the front panel |
| Phone case | map = photo of a case | black shell with clearcoat, print on the back |
| Framed print | frame containing a photo of a framed print | frame containing the poster artwork |
| Textures per visit | 23 files, 2,675 kB, 1.55 MB of it never drawn | the decals plus three small portraits already cached from the home page: 571 kB |
The room is checked without a browser. npm run check-shop bundles buildShopRoom() under Node with no GPU and fails if a catalog product is missing from the scene, if an object is tagged with a slug that is not in the catalog, if a decal key has no artwork, or if any of the framed photographs that are set dressing becomes clickable. That last one was a real bug: for months two wall frames showed photographs of Helen but were tagged as prints that looked nothing like them, so clicking the photo opened a different product.