← 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 &amp; 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&nbsp;<b>DECO</b>&nbsp;SALON</h1>
  <div class="sub">Brass &amp; 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">&times;</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>