The Royal Collection is sixteen print-on-demand pieces, and on a desktop it is sold from a room: a salon with a marble floor, a velvet rug, a long table, a clothes rack, a cabinet and a bench, lit by a chandelier, with the three art prints on the back wall and framed photographs of the cat that are not for sale on the side walls. Point at anything and its name and price appear above it; click and the camera flies to it and a panel opens with the sizes. Underneath the room, on the same page, is a plain list of the same sixteen pieces, and on a phone the list is all there is. The shop page covers what the objects are made of, the catalog, the price check on the server and the print order; what follows is the floor plan, the camera, and the pick path, read from src/shopRoom.ts, src/shopCourt.ts and src/shopMain.ts.
The floor plan
Units are metres and the origin is the middle of the floor. The camera's home position is at z = 4.18, looking toward negative z, so the back wall at z = -3.08 is the far wall and the side walls at x = ±3.48 are left and right of the visitor.
| Piece | Position, and what stands on it |
|---|---|
| Floor, rug | 7.8 × 8.2 m marble slab; a 1.28 m velvet circle with a gold ring at (0, 0.35). |
| Back wall | z = -3.08. Three prints for sale at x = -1.24, 0, 1.24, hung at y = 2.16 (the centre one, the Royal Portrait, larger and at 2.22 with a crest). Two house photographs at x = ±2.48, not for sale. |
| Side walls | x = ±3.48. Two framed photographs each at z = -1.35 and 1.45, turned to face the room. Decor only; a tee in a gilt frame was one of the things the first version got wrong. |
| Table | Centre, z = 0.62, top at y = 0.824. Three mugs in a row at x = -0.42, -0.06, 0.3, each turned to face the camera; the sticker tray behind them; the journal at x = 0.5, turned 0.28 rad. |
| Clothes rack | Left, x = -2.22, rod at y = 1.62. Four tees hung at x = -2.86, -2.6, -2.34, -2.08, each 4.4 cm further forward than the last so they read as a row; the hoodie at the near end, x = -1.79, hung from its own 0.46 m hanger offset rather than the tee's 0.42. |
| Bench | Left front, x = -2.22, z = 1.42, top at y = 0.343. A three-high folded stack (crest, monogram, crest) and the pillow propped against the arm at x = -2.72, tilted back 0.16 rad. |
| Cabinet | Right, x = 2.22, four shelves at y = 0.08, 0.48, 0.9, 1.28. The phone case on the third shelf. Books, a trinket tray and a small frame on the others as set dressing. |
| Floor by the cabinet | Two totes at (1.62, 0.86) and (1.98, 0.62), the deliberate pair. |
// The table's gold trim is a full slab spanning to y 0.824, so 0.81 buried
// everything standing on it by 14mm.
const TABLE_TOP = 0.824;
const FACE_CAM_X = 0;
const FACE_CAM_Z = 4.18;
const faceCamera = (x: number, z: number) => Math.atan2(FACE_CAM_X - x, FACE_CAM_Z - z);
const mugs: Array<[string, number, number]> = [
["royal-portrait-mug", -0.42, 0.44],
["hrh-helen-mug", -0.06, 0.5],
["softly-moon-mug", 0.3, 0.44],
];
for (const [slug, x, z] of mugs) {
sell(buildMug(slug, goldSoft, decals), slug, "mug", slug, (o) => {
o.position.set(x, TABLE_TOP, z);
o.rotation.y = faceCamera(x, z);
});
}
// Same again on the bench: its trim tops out at 0.343.
const BENCH_TOP = 0.343;
const sell = (obj, slug, kind, texKey, place) => {
place(obj);
tagGoods(obj, { slug, kind, texKey }); // group.userData.slug = slug
scene.add(obj);
pickables.push(obj);
return obj;
};
const dress = (texKey, w, h, x, y, z, yaw = 0) => {
const frame = makeFrame(texKey, w, h);
frame.position.set(x, y, z);
frame.rotation.y = yaw;
frame.userData.decor = true; // never in pickables
scene.add(frame);
};
sell() and dress() are the whole distinction between stock and scenery. Anything placed with sell() gets a product slug in its userData and goes into the array the raycaster tests; anything placed with dress() or the shelf-prop helper gets decor: true and is never tested. The room today has twenty sellable objects for sixteen products (three repeats: two folded crest tees and the pair of totes) and six decorative frames. The version before this one had thirty-five clickable objects for thirteen products, with the Royal Portrait print appearing five times, and two of the wall frames showed photographs of the cat while being tagged as prints that looked nothing like them.
faceCamera() turns an object on the table toward the home camera position, so a mug's printed band faces the visitor at rest. The two _TOP constants are there because both pieces of furniture have a gold trim that is modelled as a slab on top of the wood, and the first placement used the wood's height: every mug, the tray and the journal sat 14 mm inside the table.
The camera has manners
const HOME_LOOK = new THREE.Vector3(0, 1.22, -0.15);
const HOME_POS = new THREE.Vector3(0, 1.48, 4.18);
const HOME_DIST = HOME_POS.distanceTo(HOME_LOOK); // 4.34 m
const HOME_POLAR = Math.acos((HOME_POS.y - HOME_LOOK.y) / HOME_DIST);
const HOME_AZ = Math.atan2(HOME_POS.x - HOME_LOOK.x, HOME_POS.z - HOME_LOOK.z);
const ROOM = { x: 3.4, yMin: 0.52, yMax: 3.12, zMin: -3.1, zMax: 4.3 };
const camera = new THREE.PerspectiveCamera(44, innerWidth / innerHeight, 0.1, 40);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.rotateSpeed = 0.52;
controls.enablePan = false;
controls.enableZoom = false;
function applyHomeRig() {
camera.fov = 44;
camera.updateProjectionMatrix();
controls.minDistance = HOME_DIST;
controls.maxDistance = HOME_DIST;
controls.minPolarAngle = HOME_POLAR;
controls.maxPolarAngle = HOME_POLAR;
controls.minAzimuthAngle = HOME_AZ - 0.72;
controls.maxAzimuthAngle = HOME_AZ + 0.72;
}
At rest the visitor stands 4.34 m from a point just above the table, at eye height 1.48 m, with a 44° lens, and the only thing a drag can do is turn the head 0.72 radians (about 41°) either way. No zoom, no pan, no tilt: distance and polar angle are clamped to their home values. That is the sixth line of the repository's quality bar ("Camera has manners. Slow dolly in. Tight orbit.") as code, and it is why the room never ends up showing its own ceiling or a wall seen from behind. Every frame the camera position is additionally clamped to the ROOM box, which matters during the flight below.
Hover: the tag is HTML
const ray = new THREE.Raycaster();
const pointer = new THREE.Vector2();
function findGoods(hit: THREE.Intersection): THREE.Object3D | null {
let obj: THREE.Object3D | null = hit.object;
while (obj) {
if (obj.userData.slug) return obj; // walk up from the mesh to the tagged group
obj = obj.parent;
}
return null;
}
function goodsUnderPointer(): THREE.Object3D | null {
ray.setFromCamera(pointer, camera);
const hits = ray.intersectObjects(pickables, true);
if (!hits.length) return null;
return findGoods(hits[0]);
}
canvas.addEventListener("pointermove", (ev) => {
pointer.x = (ev.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(ev.clientY / window.innerHeight) * 2 + 1;
hovered = goodsUnderPointer();
canvas.style.cursor = hovered ? "pointer" : "";
publishHover();
});
const tagBox = new THREE.Box3();
const tagPoint = new THREE.Vector3();
function publishHover(force = false) {
const slug = hovered?.userData.slug ? String(hovered.userData.slug) : null;
if (!slug) { /* dispatch { slug: null } once */ return; }
tagBox.setFromObject(hovered as THREE.Object3D);
tagPoint.set(
(tagBox.min.x + tagBox.max.x) / 2,
tagBox.max.y + 0.045, // 4.5 cm above the object's top
(tagBox.min.z + tagBox.max.z) / 2,
);
tagPoint.project(camera);
const behind = tagPoint.z > 1;
window.dispatchEvent(new CustomEvent("helen-shop-hover", {
detail: {
slug: behind ? null : slug,
x: (tagPoint.x * 0.5 + 0.5) * window.innerWidth,
y: (-tagPoint.y * 0.5 + 0.5) * window.innerHeight,
},
}));
}
// in tick(), every frame:
for (const goods of pickables) {
const want = !reduced && goods === hovered ? 1 : 0;
goods.userData.hover = THREE.MathUtils.damp(Number(goods.userData.hover) || 0, want, 8, dt);
const base = goods.userData.baseScale as THREE.Vector3;
const s = 1 + Number(goods.userData.hover) * 0.03;
goods.scale.set(base.x * s, base.y * s, base.z * s);
}
if (hovered) publishHover(); // the camera drifts, so re-project
The raycast only tests the twenty sellable groups, so the chandelier, the books and the photographs cost nothing on pointer move and never light up. A hovered object grows three percent with a damped ease and shrinks back the same way, which is the only motion the object itself makes. The label is not in the scene at all: the scene works out a point 4.5 cm above the object's bounding box, projects it to screen coordinates, and hands the page an x, y and a slug. The page (shopMain.ts) keeps one div.shop-tag in the overlay, looks the slug up in the catalog it has already fetched, and moves the tag with a transform. Text drawn that way is sharp at any pixel ratio, costs no texture memory and can be styled with CSS. The first version of the room had no tags; it named no prices at all, and a visitor had to click to learn what anything was.
Pick: a click that was not a drag, then a flight
canvas.addEventListener("pointerdown", (ev) => { downX = ev.clientX; downY = ev.clientY; });
canvas.addEventListener("pointerup", (ev) => {
if (Math.hypot(ev.clientX - downX, ev.clientY - downY) > 8) return; // that was a drag
pointer.x = (ev.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(ev.clientY / window.innerHeight) * 2 + 1;
const goods = goodsUnderPointer();
if (!goods?.userData.slug) return;
inspectGoods(goods);
window.dispatchEvent(new CustomEvent("helen-shop-pick", { detail: { slug: String(goods.userData.slug) } }));
});
function inspectGoods(goods: THREE.Object3D) {
inspecting = true;
const look = new THREE.Vector3();
goods.getWorldPosition(look);
const size = new THREE.Vector3();
new THREE.Box3().setFromObject(goods).getSize(size);
look.y += Math.min(0.16, size.y * 0.22);
const away = camera.position.clone().sub(look);
if (away.lengthSq() < 0.0004) away.set(0, 0.18, 1);
away.normalize();
const dist = THREE.MathUtils.clamp(Math.max(size.length() * 1.05, 0.62), 0.55, 1.55);
const pos = look.clone().add(away.multiplyScalar(dist));
pos.x = THREE.MathUtils.clamp(pos.x, -ROOM.x, ROOM.x);
pos.y = THREE.MathUtils.clamp(pos.y, ROOM.yMin, ROOM.yMax);
pos.z = THREE.MathUtils.clamp(pos.z, ROOM.zMin, ROOM.zMax);
tweenTo(pos, look, 1.05);
}
function tweenTo(pos: THREE.Vector3, look: THREE.Vector3, duration: number) {
if (reduced) { camera.position.copy(pos); controls.target.copy(look); return; }
const from = camera.position.clone();
const mid = from.clone().lerp(pos, 0.55);
mid.y = THREE.MathUtils.clamp(mid.y, ROOM.yMin + 0.1, ROOM.yMax - 0.1);
tween = { from, to: pos, mid, lookFrom: activeLook.clone(), lookTo: look, t: 0, duration };
controls.enabled = false;
}
// in tick(): quintic smoothstep along a quadratic Bezier through mid
const e = t * t * t * (t * (t * 6 - 15) + 10);
Eight pixels of movement between down and up is the line between a click and a drag; below it the pointer is re-cast at the release point and the first sellable object under it is picked. The flight aims at a point slightly above the object's centre (up to 16 cm, scaled to its height), backs off along the line from the current camera position by a distance derived from the object's size and clamped to between 0.55 and 1.55 m, clamps the destination inside the room, and gets there in 1.05 seconds along a curve that bows through a midpoint 55 % of the way, with a quintic ease so it neither snaps nor floats. The controls are disabled for the flight and re-enabled at the end; if the panel is then closed, home() flies back in 1.15 seconds and applyHomeRig() restores the clamps. With prefers-reduced-motion both moves are instantaneous.
What the visitor sees during that second is the page's doing. shopMain.ts hears helen-shop-pick, finds the product in the catalog, fills a dialog (role="dialog", aria-modal, focus moved to the add button, Escape closes it) with the image, the badge, the summary, a <select> of variants with prices, and "Add to bag". Adding writes to localStorage and opens the bag drawer; closing the dialog calls api.home(). Nothing about the sale happens in the scene.
The plain list, and the phone
Under the canvas is <section id="ledger">, painted from the same catalog with the same card markup the phone shell and the fallback grid use, and the hint under the room's title says so: "Hover any piece for its price. Click to buy. The full list is below." On a phone the script never imports the room, the hint reads "Scroll. The whole collection is listed below. Tap one to buy it", and the splash is dismissed at once. If the room throws or has not resolved in 25 seconds, the overlay gets is-fallback and a second copy of the list appears in place of the canvas. Three ways of reaching the same sixteen products, one source for all of them.
Why a room rather than a grid, and what it cost to make usable
The grid exists; it is the ledger. The room is on top of it because the show is a room, the court on the home page is a room, and a visitor who has just been turning in one should not be dropped into a spreadsheet to buy a mug. The bar the repository sets for it is that the objects look like what they are (a mug with a design printed on it, not a cylinder with a photo of a mug on it) and that clicking teaches you something true about what is for sale. Meeting that bar took, in order: replacing product photographs with the actual print files as decals; cutting thirty-five clickable objects to twenty and making the photographs unclickable; adding the hover tag so prices were visible without a click; rewriting the title copy to say how many pieces there are and that hovering shows the price; the placement corrections above; and writing a check that runs without a browser and fails the build when the room and the catalog disagree. That check is on the quality-checks page.
Where the assistants were, and what a review found
If you lay out your own
Tag stock and scenery at placement time with two different helpers, and only ever raycast the stock. Keep every word of text on the page side and hand the scene nothing but slugs and screen points. Lock the camera to one distance and one tilt with a narrow yaw, and treat a click as a flight to a computed standoff clamped inside the room, not a teleport. Measure the tops of your furniture from the geometry, not from the number you remember typing. Then write the headless check, because the two frames that sold the wrong product were wrong for months and looked fine in every screenshot.
What the objects are made of and what happens after "Add to bag" is the shop page. The check that keeps the room and the catalog agreeing is on the quality-checks page. Which of the pictures on the print files a model drew is the site-imagery page. Plain version: the 3D website.