← back to Quadrille Showroom
public/proto/v16-artdeco-brass.html
590 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no" />
<title>ART-DECO BRASS & MARBLE — China Seas</title>
<style>
:root { --brass:#c9a24b; --ink:#120e09; --paper:#f3ead4; }
* { box-sizing:border-box; margin:0; padding:0; }
html,body { height:100%; overflow:hidden; background:#0b0906; color:var(--paper);
font-family:"Hoefler Text",Georgia,"Times New Roman",serif; -webkit-font-smoothing:antialiased; }
#app { position:fixed; inset:0; }
canvas { display:block; }
/* ---- masthead ---- */
.masthead { position:fixed; top:0; left:0; right:0; z-index:12; padding:20px 30px 46px;
pointer-events:none; text-align:center;
background:linear-gradient(180deg, rgba(11,9,6,.82) 0%, rgba(11,9,6,0) 100%); }
.masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
color:#d8be7e; font-weight:600; }
.masthead h1 { font-size:clamp(24px,4.4vw,46px); font-weight:400; letter-spacing:.14em;
line-height:1; margin-top:8px; color:#f3ead4;
text-shadow:0 2px 18px rgba(0,0,0,.6); }
.masthead h1 b { font-weight:700; color:#e7c878; }
.masthead .sub { margin-top:9px; font-size:12px; letter-spacing:.16em; text-transform:uppercase;
color:#b09a6a; }
/* ---- now-viewing plate (bottom centre) ---- */
.plate { position:fixed; left:50%; bottom:30px; transform:translateX(-50%); z-index:12;
text-align:center; pointer-events:none; max-width:80vw;
text-shadow:0 2px 14px rgba(0,0,0,.7); transition:opacity .4s; }
.plate .pn { font-size:20px; letter-spacing:.04em; color:#f3ead4; }
.plate .meta { margin-top:5px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:#c9a24b; }
.hint { position:fixed; left:50%; bottom:84px; transform:translateX(-50%); z-index:11;
font-size:10.5px; letter-spacing:.30em; text-transform:uppercase; color:rgba(243,234,212,.5);
pointer-events:none; transition:opacity .8s; }
/* ---- fps readout ---- */
#fps { position:fixed; left:14px; bottom:12px; z-index:14;
font-family:"SF Mono",Menlo,monospace; font-size:11px; letter-spacing:.08em;
color:#d8be7e; background:rgba(11,9,6,.55); border:1px solid rgba(201,162,75,.4);
padding:4px 9px; border-radius:6px; cursor:pointer; pointer-events:auto;
-webkit-user-select:none; user-select:none; }
#fps.hidden { opacity:0; pointer-events:none; }
/* ---- detail spec card ---- */
#detail { position:fixed; z-index:13; right:32px; top:50%; transform:translateY(-50%) translateX(40px);
width:min(322px,40vw); background:rgba(18,14,9,.78); backdrop-filter:blur(16px);
-webkit-backdrop-filter:blur(16px); border:1px solid rgba(201,162,75,.42);
border-radius:6px; padding:24px; opacity:0; pointer-events:none;
box-shadow:0 24px 60px -16px rgba(0,0,0,.8);
transition:opacity .4s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
#detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
#detail .pname { font-size:21px; line-height:1.14; color:#f3ead4; padding-right:18px; }
#detail .pcoll { margin-top:5px; font-size:10.5px; letter-spacing:.26em; text-transform:uppercase; color:#c9a24b; }
#detail .rule { height:1px; margin:16px 0 4px; background:linear-gradient(90deg,transparent,rgba(201,162,75,.6),transparent); }
#detail .specrow { display:flex; justify-content:space-between; padding:8px 0;
border-bottom:1px solid rgba(201,162,75,.14); font-size:12.5px; }
#detail .specrow .k { color:#9c8a63; letter-spacing:.03em; }
#detail .specrow .v { color:#efe6d0; text-align:right; }
#detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
background:linear-gradient(180deg,#e3cb95,#c19a44); color:#1a1206; font-size:11px;
letter-spacing:.22em; text-transform:uppercase; font-weight:700; text-decoration:none;
border-radius:3px; transition:transform .15s; font-family:Georgia,serif; }
#detail a.cta:hover { transform:translateY(-1px); }
#detail .closex { position:absolute; top:12px; right:14px; font-size:20px; color:#c9a24b;
cursor:pointer; line-height:1; }
#detail .closex:hover { color:#f3ead4; }
/* ---- boot ---- */
#boot { position:fixed; inset:0; z-index:40; display:flex; flex-direction:column;
align-items:center; justify-content:center; background:#0b0906; transition:opacity .8s; }
#boot .ring { width:42px; height:42px; border-radius:50%;
border:2px solid rgba(201,162,75,.2); border-top-color:#e7c878; animation:spin 1s linear infinite; }
#boot .lbl { margin-top:18px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#9c8a63; }
@keyframes spin { to { transform:rotate(360deg); } }
@media (max-width:760px){
#detail { right:12px; left:12px; width:auto; top:auto; bottom:96px; transform:translateY(20px); }
#detail.show { transform:translateY(0); }
.masthead .sub { display:none; }
}
</style>
</head>
<body>
<div id="app"></div>
<div class="masthead">
<div class="kicker">Designer Wallcoverings</div>
<h1>ART <b>DECO</b> SALON</h1>
<div class="sub">Brass & Marble · China Seas</div>
</div>
<div class="plate" id="plate"><div class="pn" id="p_name">—</div><div class="meta" id="p_meta"></div></div>
<div class="hint" id="hint">Click a board to bring it to centre</div>
<div id="fps">FPS —</div>
<div id="detail">
<div class="closex" id="d_close">×</div>
<div class="pname" id="d_name">—</div>
<div class="pcoll" id="d_coll">China Seas</div>
<div class="rule"></div>
<div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
<div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
<div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
<div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
<div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
<a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
</div>
<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Polishing the brass…</div></div>
<script src="./three.min.js"></script>
<script>
"use strict";
(function () {
const API = "/api/showroom/products?limit=50";
const bootlbl = document.getElementById("bootlbl");
// ----------------------------------------------------------------------------
// RENDERER — full high-end baseline
// ----------------------------------------------------------------------------
const app = document.getElementById("app");
const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.06;
if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
app.appendChild(renderer.domElement);
const MAXANISO = renderer.capabilities.getMaxAnisotropy();
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x14100a);
scene.fog = new THREE.Fog(0x14100a, 9, 26);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 120);
// sit close & at chest height so the centred 6' board fills the frame, with the
// slivers raking off to either side and the marble floor sweeping to the camera.
camera.position.set(-0.55, 1.0, -0.15);
camera.lookAt(-0.4, 1.0, -3.4);
// ----------------------------------------------------------------------------
// PMREM ENVIRONMENT — procedural Art-Deco "salon" equirect so brass reflects.
// A warm gradient with several bright soft sources + a crisp horizon band, so a
// metalness~1 surface shows specular highlights (reads as polished metal, not
// flat yellow) and the marble clearcoat catches reflections.
// ----------------------------------------------------------------------------
function buildEnvTexture(){
const c = document.createElement("canvas");
c.width = 1024; c.height = 512;
const g = c.getContext("2d");
// vertical gradient: warm amber ceiling glow → deep bronze floor
const grd = g.createLinearGradient(0, 0, 0, 512);
grd.addColorStop(0.00, "#3a2c14");
grd.addColorStop(0.34, "#6e5526");
grd.addColorStop(0.50, "#caa763"); // bright horizon (key wash)
grd.addColorStop(0.62, "#5b4521");
grd.addColorStop(1.00, "#1c150c");
g.fillStyle = grd; g.fillRect(0, 0, 1024, 512);
// bright soft "light fixtures" — these become the brass highlights
function lamp(x, y, r, col){
const lg = g.createRadialGradient(x, y, 0, x, y, r);
lg.addColorStop(0, col); lg.addColorStop(1, "rgba(0,0,0,0)");
g.globalCompositeOperation = "lighter";
g.fillStyle = lg; g.beginPath(); g.arc(x, y, r, 0, Math.PI*2); g.fill();
g.globalCompositeOperation = "source-over";
}
lamp(180, 150, 150, "rgba(255,244,214,0.95)"); // warm key
lamp(840, 170, 150, "rgba(255,236,196,0.85)"); // warm fill
lamp(512, 90, 120, "rgba(255,250,235,0.9)"); // overhead
lamp(512, 300, 220, "rgba(255,221,160,0.55)"); // horizon glow
lamp(40, 300, 90, "rgba(214,230,255,0.45)"); // cool rim (left)
lamp(990, 300, 90, "rgba(214,230,255,0.4)"); // cool rim (right)
// a crisp bright horizon line gives metals a sharp reflected edge
g.fillStyle = "rgba(255,243,214,0.5)";
g.fillRect(0, 252, 1024, 6);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping;
if ("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
else if ("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
return tex;
}
const pmrem = new THREE.PMREMGenerator(renderer);
pmrem.compileEquirectangularShader();
const envTex = buildEnvTexture();
const envMap = pmrem.fromEquirectangular(envTex).texture;
scene.environment = envMap;
envTex.dispose();
// ----------------------------------------------------------------------------
// LIGHTING — key + fill + rim, warm, with a soft key shadow
// ----------------------------------------------------------------------------
scene.add(new THREE.HemisphereLight(0xfff0d4, 0x2a2114, 0.55));
const key = new THREE.DirectionalLight(0xfff1d6, 2.4);
key.position.set(-5.5, 8.5, 6.5);
key.castShadow = true;
key.shadow.mapSize.set(2048, 2048);
key.shadow.camera.near = 1; key.shadow.camera.far = 30;
key.shadow.camera.left = -8; key.shadow.camera.right = 8;
key.shadow.camera.top = 8; key.shadow.camera.bottom = -4;
key.shadow.bias = -0.0004;
scene.add(key);
const fill = new THREE.DirectionalLight(0xffe2b4, 0.55);
fill.position.set(6, 4, 7); scene.add(fill);
const rim = new THREE.DirectionalLight(0xcfe0ff, 0.8);
rim.position.set(0, 5, -8); scene.add(rim);
// warm spot pooling onto the centre (focal) board
const spot = new THREE.SpotLight(0xfff0d0, 2.2, 22, Math.PI/6.5, 0.5, 1.2);
spot.position.set(0, 7.5, 6.5); spot.target.position.set(0, 1.7, 0);
scene.add(spot); scene.add(spot.target);
// ----------------------------------------------------------------------------
// MARBLE FLOOR — physical clearcoat, env-mapped reflection
// ----------------------------------------------------------------------------
function marbleTexture(){
const c = document.createElement("canvas"); c.width = c.height = 1024;
const g = c.getContext("2d");
g.fillStyle = "#ece3d2"; g.fillRect(0,0,1024,1024);
// soft warm clouding
for(let i=0;i<60;i++){
const x=Math.random()*1024, y=Math.random()*1024, r=80+Math.random()*240;
const lg=g.createRadialGradient(x,y,0,x,y,r);
const a=0.05+Math.random()*0.08;
lg.addColorStop(0,`rgba(196,178,150,${a})`); lg.addColorStop(1,"rgba(196,178,150,0)");
g.fillStyle=lg; g.beginPath(); g.arc(x,y,r,0,Math.PI*2); g.fill();
}
// grey/bronze veining
g.lineCap="round";
for(let i=0;i<26;i++){
g.strokeStyle = Math.random()<0.5 ? "rgba(120,108,86,0.30)" : "rgba(80,70,52,0.18)";
g.lineWidth = 0.6+Math.random()*2.2;
let x=Math.random()*1024, y=Math.random()*1024;
g.beginPath(); g.moveTo(x,y);
const steps=8+Math.floor(Math.random()*10), ang=Math.random()*Math.PI;
for(let s=0;s<steps;s++){ x+=Math.cos(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70;
y+=Math.sin(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70; g.lineTo(x,y); }
g.stroke();
}
const t=new THREE.CanvasTexture(c);
t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(3,3);
t.anisotropy=MAXANISO;
if("colorSpace" in t) t.colorSpace=THREE.SRGBColorSpace; else if("encoding" in t) t.encoding=THREE.sRGBEncoding;
return t;
}
const floorMat = new THREE.MeshPhysicalMaterial({
map: marbleTexture(), color:0xf2ebda, roughness:0.12, metalness:0.0,
clearcoat:1.0, clearcoatRoughness:0.06, envMapIntensity:1.1, reflectivity:0.6
});
const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), floorMat);
floor.rotation.x = -Math.PI/2; floor.position.y = 0; floor.receiveShadow = true;
scene.add(floor);
// ----------------------------------------------------------------------------
// BACK WALL + Art-Deco fluted brass pilasters (symmetry)
// ----------------------------------------------------------------------------
const wallMat = new THREE.MeshStandardMaterial({ color:0x241b10, roughness:0.85, metalness:0.05 });
const backWall = new THREE.Mesh(new THREE.PlaneGeometry(60, 18), wallMat);
backWall.position.set(0, 9, -4.2); backWall.receiveShadow = true; scene.add(backWall);
// shared polished-brass material (metalness 1, low roughness → reflects env)
const brassMat = new THREE.MeshPhysicalMaterial({
color:0xe7c067, metalness:1.0, roughness:0.14, envMapIntensity:2.2,
clearcoat:0.5, clearcoatRoughness:0.14
});
const brassDark = new THREE.MeshPhysicalMaterial({
color:0xb08a3a, metalness:1.0, roughness:0.30, envMapIntensity:1.3
});
// symmetrical fluted pilasters flanking the bank
(function pilasters(){
const g = new THREE.CylinderGeometry(0.16, 0.16, 7.6, 20, 1);
[-5.6, 5.6].forEach(x=>{
const p = new THREE.Mesh(g, brassMat); p.position.set(x, 3.8, -3.9);
p.castShadow = true; scene.add(p);
const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.30,0.22,0.34,20), brassMat);
cap.position.set(x, 7.7, -3.9); scene.add(cap);
const base = new THREE.Mesh(new THREE.CylinderGeometry(0.26,0.32,0.30,20), brassMat);
base.position.set(x, 0.15, -3.9); scene.add(base);
});
// deco sunburst lintel above
const lintel = new THREE.Mesh(new THREE.BoxGeometry(11.6, 0.32, 0.3), brassMat);
lintel.position.set(0, 7.9, -3.85); lintel.castShadow = true; scene.add(lintel);
const rayMat = brassDark;
for(let i=0;i<13;i++){
const t=i/12, x=-5.2 + t*10.4;
const h=0.5 + Math.abs(Math.sin(t*Math.PI))*0.9;
const r=new THREE.Mesh(new THREE.BoxGeometry(0.05, h, 0.06), rayMat);
r.position.set(x, 8.3+h/2, -3.8); scene.add(r);
}
})();
// soft contact shadow under the bank
(function contactShadow(){
const cc=document.createElement("canvas"); cc.width=cc.height=256; const cg=cc.getContext("2d");
const grd=cg.createRadialGradient(128,128,10,128,128,128);
grd.addColorStop(0,"rgba(0,0,0,0.5)"); grd.addColorStop(0.6,"rgba(0,0,0,0.2)"); grd.addColorStop(1,"rgba(0,0,0,0)");
cg.fillStyle=grd; cg.fillRect(0,0,256,256);
const t=new THREE.CanvasTexture(cc);
const m=new THREE.Mesh(new THREE.PlaneGeometry(14,4),
new THREE.MeshBasicMaterial({ map:t, transparent:true, opacity:0.7, depthWrite:false,
blending:THREE.MultiplyBlending }));
m.rotation.x=-Math.PI/2; m.position.set(0,0.01,-1.6); scene.add(m);
})();
// ----------------------------------------------------------------------------
// THE WING BANK — brass-framed sample boards ~2" apart on a side swivel.
// Frames are an INSTANCED brass border (FPS budget); each board face is its own
// textured plane (China Seas). Click a board → carousel-shift to dead centre →
// swivel open to a CLEAN full-pattern face, no occluders.
// ----------------------------------------------------------------------------
const IN = 0.0254; // 1 inch in metres
const BOARD_W = 30 * IN; // 30" wide
const BOARD_H = 72 * IN; // 6' tall
const FRAME_T = 1.2 * IN; // thin brass frame
const GAP = 2 * IN; // 2" apart
const PITCH = BOARD_W + FRAME_T*2 + GAP;
const BOARD_Y = 0.06 + BOARD_H/2; // feet near floor
const BANK_Z = -3.4;
const NBOARDS_TARGET = 50;
const loader = new THREE.TextureLoader();
loader.crossOrigin = "anonymous";
// one shared geometry for board faces
const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
// shared neutral face material while textures stream in
const placeholderMat = new THREE.MeshStandardMaterial({ color:0x2b2218, roughness:0.9 });
const boards = []; // { group, mesh, mat, data, slot, swivel(0..1) }
const bankGroup = new THREE.Group(); scene.add(bankGroup);
const pickables = [];
// Build the four-sided brass frame for a board as a small group of boxes.
function makeFrame(){
const grp = new THREE.Group();
const ow = BOARD_W + FRAME_T*2, oh = BOARD_H + FRAME_T*2, d = 0.9*IN;
const matsTB = brassMat, matsLR = brassMat;
const top = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); top.position.y = oh/2 - FRAME_T/2;
const bot = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); bot.position.y = -oh/2 + FRAME_T/2;
const lft = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); lft.position.x = -ow/2 + FRAME_T/2;
const rgt = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); rgt.position.x = ow/2 - FRAME_T/2;
[top,bot,lft,rgt].forEach(b=>{ b.castShadow = true; grp.add(b); });
return grp;
}
let focusIdx = -1; // currently centred/open board (slot index)
let centerSlot = 0; // which slot is at dead-centre
function slotX(slot){ return (slot - centerSlot) * PITCH; }
function buildBoard(d, slot){
const group = new THREE.Group();
// hinge on the LEFT edge so the board swivels open like the reference
const hingeX = -(BOARD_W/2 + FRAME_T);
group.position.set(0, BOARD_Y, BANK_Z);
const swivel = new THREE.Group(); // rotates about the hinge
swivel.position.x = hingeX;
group.add(swivel);
const inner = new THREE.Group(); // offset so geometry centres correctly
inner.position.x = -hingeX;
swivel.add(inner);
const frame = makeFrame(); inner.add(frame);
const face = new THREE.Mesh(faceGeo, placeholderMat.clone());
face.position.z = 0.5*IN; face.castShadow = false; face.receiveShadow = true;
inner.add(face);
pickables.push(face);
bankGroup.add(group);
const rec = { group, swivel, inner, face, data:d, slot, swivelT:0, mat:face.material };
face.userData.rec = rec;
boards.push(rec);
// stream the China Seas texture in
const url = d.tile || d.image;
if (url){
loader.load(url, (tex)=>{
tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
tex.anisotropy = MAXANISO;
if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
else if("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
const pm = new THREE.MeshStandardMaterial({
map:tex, roughness:0.82, metalness:0.0, envMapIntensity:0.45,
color:0xffffff, side:THREE.FrontSide
});
face.material.dispose && face.material.dispose();
face.material = pm; rec.mat = pm;
}, undefined, ()=>{ if(url!==d.image && d.image){ loader.load(d.image,(t2)=>{
t2.anisotropy=MAXANISO; if("colorSpace" in t2)t2.colorSpace=THREE.SRGBColorSpace;
face.material = new THREE.MeshStandardMaterial({map:t2,roughness:0.82,metalness:0,envMapIntensity:0.45});
rec.mat=face.material; }); } });
}
return rec;
}
// ----------------------------------------------------------------------------
// INTERACTION — pick a board → centre it → swivel open
// ----------------------------------------------------------------------------
const ray = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let dragging=false, downX=0, downY=0, moved=0;
renderer.domElement.addEventListener("pointerdown",(e)=>{
dragging=true; moved=0; downX=e.clientX; downY=e.clientY;
});
renderer.domElement.addEventListener("pointermove",(e)=>{
if(!dragging) return; moved += Math.abs(e.clientX-downX)+Math.abs(e.clientY-downY);
});
renderer.domElement.addEventListener("pointerup",(e)=>{
dragging=false;
if(moved<6) tryPick(e);
});
function tryPick(e){
const r = renderer.domElement.getBoundingClientRect();
ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
ray.setFromCamera(ndc, camera);
const hits = ray.intersectObjects(pickables, false);
if(hits.length){
const rec = hits[0].object.userData.rec;
if(rec) centreOn(rec);
}
}
function centreOn(rec){
centerSlot = rec.slot;
focusIdx = rec.slot;
showDetail(rec);
updatePlate(rec);
}
// keyboard left/right to walk the bank (senior-friendly)
window.addEventListener("keydown",(e)=>{
if(e.key==="ArrowRight"){ const n=Math.min(boards.length-1, centerSlot+1); centreOn(boards[n]); }
else if(e.key==="ArrowLeft"){ const n=Math.max(0, centerSlot-1); centreOn(boards[n]); }
});
// ----------------------------------------------------------------------------
// DETAIL CARD + PLATE
// ----------------------------------------------------------------------------
const detail = document.getElementById("detail");
document.getElementById("d_close").addEventListener("click", ()=> detail.classList.remove("show"));
function showDetail(rec){
const d = rec.data;
document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
document.getElementById("d_coll").textContent = d.collection || d.vendor || "China Seas";
const w = d.width || 27;
document.getElementById("d_dim").textContent = `${w}" × ${w}"`;
document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
document.getElementById("d_match").textContent = d.match_type || "—";
document.getElementById("d_finish").textContent = d.finish || "—";
document.getElementById("d_mat").textContent = d.material || "—";
const cta = document.getElementById("d_cta");
if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; } else cta.style.display="none";
detail.classList.add("show");
}
function updatePlate(rec){
const d = rec.data;
document.getElementById("p_name").textContent = d.pattern_name || d.title || "—";
document.getElementById("p_meta").textContent =
(d.collection || "China Seas") + (d.repeat ? " · " + d.repeat + '" repeat' : "");
}
// ----------------------------------------------------------------------------
// DATA LOAD
// ----------------------------------------------------------------------------
async function start(){
let products = [];
try {
const res = await fetch(API);
const json = await res.json();
products = (json.products || json || []).filter(p=> p.tile || p.image);
} catch(e){
bootlbl.textContent = "Could not reach the catalog.";
console.error("fetch failed", e); return;
}
products = products.slice(0, NBOARDS_TARGET);
if(!products.length){ bootlbl.textContent = "No designs."; return; }
products.forEach((d,i)=> buildBoard(d, i));
// open the bank at its middle so the first frame is full + symmetrical
centerSlot = Math.floor(products.length/2);
centreOn(boards[centerSlot]);
window.__ARTDECO_DONE = true;
finishBoot();
}
function finishBoot(){
const boot = document.getElementById("boot");
boot.style.opacity = "0";
setTimeout(()=> boot.remove(), 850);
const hint = document.getElementById("hint");
setTimeout(()=> hint.style.opacity = "0", 4500);
}
// ----------------------------------------------------------------------------
// RENDER LOOP — FPS readout, swivel/centre easing, pause on hidden
// ----------------------------------------------------------------------------
const fpsEl = document.getElementById("fps");
fpsEl.addEventListener("click", ()=> fpsEl.classList.toggle("hidden"));
let frames=0, fpsT0=performance.now(), fps=0;
let running=true, t0=performance.now();
function tick(now){
if(!running) return;
requestAnimationFrame(tick);
const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
// ease the whole bank so the centred slot sits at x=0 (carousel conveyor)
const targetX = -centerSlot * PITCH;
bankGroup.position.x += (targetX - bankGroup.position.x) * Math.min(1, dt*5.5);
// each board: slide to its slot X, and swivel — the centred board opens FLAT
// to face the camera (full clean face); the rest rake to thin slivers.
for(const b of boards){
const isFocus = (b.slot === focusIdx);
// raked angle for non-focus boards (thin sliver), 0 for the open face
const targetSwivel = isFocus ? 0 : 1;
b.swivelT += (targetSwivel - b.swivelT) * Math.min(1, dt*6);
// open face = 0 rad (parallel to wall, facing camera); raked = ~58°
b.swivel.rotation.y = b.swivelT * (58 * Math.PI/180);
// gently pull the focused board a touch forward off the wall so nothing
// crosses its face
const fwd = isFocus ? 0.16 : 0.0;
b.group.position.z += ((BANK_Z + fwd) - b.group.position.z) * Math.min(1, dt*6);
b.group.position.x = b.slot * PITCH; // local slot, bankGroup pans the whole row
// dim non-focused faces slightly for focus
if(b.mat){
const wantR = isFocus ? 0.82 : 0.9;
if(b.mat.color){ /* keep colour */ }
}
}
// FPS
frames++;
if(now - fpsT0 >= 500){
fps = Math.round(frames * 1000 / (now - fpsT0));
fpsEl.textContent = "FPS " + fps;
frames = 0; fpsT0 = now;
window.__ARTDECO_FPS = fps;
}
renderer.render(scene, camera);
}
requestAnimationFrame(tick);
document.addEventListener("visibilitychange", ()=>{
if(document.hidden){ running=false; }
else if(!running){ running=true; t0=performance.now(); fpsT0=performance.now(); frames=0; requestAnimationFrame(tick); }
});
window.addEventListener("resize", ()=>{
camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// headless probe
window.__ARTDECO__ = {
ready: ()=> boards.length,
done: ()=> !!window.__ARTDECO_DONE,
fps: ()=> window.__ARTDECO_FPS || 0,
textured: ()=> boards.filter(b=> b.mat && b.mat.map).length,
focus: ()=> focusIdx
};
start();
})();
</script>
<script>window.PROTO_META={"key":"V16","label":"V16 — Art-Deco Brass & Marble","elements":[
{"n":1,"label":"Brass-framed sample boards on a side swivel, ~2\" apart"},
{"n":2,"label":"Click a board → it shifts to centre & swivels to a clean full face"},
{"n":3,"label":"Polished marble floor with clearcoat reflections"},
{"n":4,"label":"Env-mapped reflective brass (pilasters, sunburst lintel, frames)"},
{"n":5,"label":"Warm key + spot lighting, symmetrical 1920s salon composition"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>