← back to Quadrille Showroom

public/proto/v15-brutalist-concrete.html

686 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>BRUTALIST CONCRETE · China Seas</title>
<style>
  :root { --conc:#9a958c; --paper:#e9e6df; }
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; overflow:hidden; background:#16161a; color:var(--paper);
    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
  #app { position:fixed; inset:0; }
  canvas { display:block; }

  /* ---- masthead — hard architectural type ---- */
  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:78px 30px 46px;
    pointer-events:none; text-align:center;
    background:linear-gradient(180deg, rgba(14,14,16,.78) 0%, rgba(14,14,16,0) 100%); }
  .masthead .kicker { font-size:11px; letter-spacing:.52em; text-transform:uppercase;
    color:#7e7a72; font-weight:700; }
  .masthead h1 { font-family:"Helvetica Neue",Arial,sans-serif; font-size:clamp(28px,5vw,54px);
    font-weight:200; letter-spacing:.22em; line-height:1; margin-top:12px; color:#eceae3;
    text-transform:uppercase; }
  .masthead h1 b { font-weight:800; color:#fbfaf6; }
  .masthead .sub { margin-top:12px; font-size:12px; letter-spacing:.06em; color:#827e76;
    max-width:560px; margin-left:auto; margin-right:auto; }

  /* ---- focused board caption — concrete slab card ---- */
  #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
    z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
    pointer-events:none; }
  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
  #caption .cn { font-family:"Helvetica Neue",Arial,sans-serif; font-weight:300; font-size:23px;
    letter-spacing:.04em; color:#f2efe8; text-shadow:0 2px 22px rgba(0,0,0,.85); }
  #caption .cc { margin-top:6px; font-size:10.5px; letter-spacing:.40em; text-transform:uppercase; color:#a59f93; }
  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.05em; color:#928d82; }
  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:15px; padding:11px 28px;
    border:1px solid rgba(210,206,196,.45); border-radius:2px; color:#eceae3; font-size:10.5px;
    letter-spacing:.26em; text-transform:uppercase; text-decoration:none; font-weight:700;
    background:rgba(210,206,196,.05); transition:background .15s, transform .15s, border-color .15s; }
  #caption a.cta:hover { background:rgba(210,206,196,.14); border-color:rgba(210,206,196,.7); transform:translateY(-1px); }

  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
    font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:rgba(220,216,206,.4);
    pointer-events:none; transition:opacity .6s; }

  /* ---- FPS readout ---- */
  #fps { position:fixed; bottom:14px; right:16px; z-index:25; font:600 11px/1.3 ui-monospace,Menlo,monospace;
    color:#8c887f; background:rgba(16,16,18,.62); border:1px solid rgba(180,176,166,.22); border-radius:4px;
    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
  #fps.off { opacity:.32; }

  /* ---- loading ---- */
  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
    align-items:center; justify-content:center; background:#16161a; transition:opacity .9s; }
  #boot .ring { width:44px; height:44px; border-radius:50%;
    border:2px solid rgba(180,176,166,.16); border-top-color:#cfcabf; animation:spin 1s linear infinite; }
  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.38em; text-transform:uppercase; color:#736f67; }
  @keyframes spin { to { transform:rotate(360deg); } }

  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:72px;} }
</style>
</head>
<body>
<div id="app"></div>

<div class="masthead">
  <div class="kicker">Designer Wallcoverings · The Concrete Hall</div>
  <h1>BRUTALIST <b>CONCRETE</b></h1>
  <div class="sub">China Seas hung in a board-formed concrete hall. One hard shaft of light, raw grey
    surfaces, and the pattern as the only colour in the room. Drag to swing the bank; tap a board to bring it forward.</div>
</div>

<div id="caption">
  <div class="cn" id="cap_name">—</div>
  <div class="cc" id="cap_coll">China Seas</div>
  <div class="cspec" id="cap_spec">—</div>
  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
</div>

<div class="hint" id="hint">drag · click a board · scroll</div>
<div id="fps" title="Toggle FPS">— fps</div>

<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Pouring the concrete…</div></div>

<!-- r128 core (local) + r128 RoomEnvironment for PMREM (CDN, attaches to global THREE). No bloom dep. -->
<script src="./three.min.js"></script>
<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
<script>
"use strict";
(function () {
  const API = "/api/showroom/products?limit=50";
  const app = document.getElementById("app");
  const bootlbl = document.getElementById("bootlbl");
  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
  const lerp = (a,b,t)=>a+(b-a)*t;

  // -------------------------------------------------------------------------
  // RENDERER (high-end baseline)
  // -------------------------------------------------------------------------
  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
  renderer.setSize(W(), H());
  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.02;
  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;     // soft sampling, but a HARD key (small radius) keeps shadows crisp
  renderer.setClearColor(0x16161a, 1);
  app.appendChild(renderer.domElement);
  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x17171b);
  scene.fog = new THREE.FogExp2(0x141417, 0.018);       // gentle haze for depth, not a blackout

  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 200);
  camera.position.set(0, 1.55, 9.4);
  camera.lookAt(0, 1.55, 0);

  // -------------------------------------------------------------------------
  // PMREM ENVIRONMENT (RoomEnvironment) — neutral IBL so the concrete reads as a
  // real lit material and the steel frames pick up soft grey reflections. The
  // environment is intentionally LOW intensity per material so the one hard key
  // remains the dominant light (brutalist = single dramatic source).
  // -------------------------------------------------------------------------
  (function buildEnv(){
    try {
      const pmrem = new THREE.PMREMGenerator(renderer);
      const env = new THREE.RoomEnvironment();
      const envMap = pmrem.fromScene(env, 0.04).texture;
      scene.environment = envMap;
      pmrem.dispose();
    } catch(e){ /* env optional */ }
  })();

  // -------------------------------------------------------------------------
  // LIGHTING — ONE hard directional KEY (crisp shadows) + a low cool ambient and
  // a soft fill. No spotlight follow, no rim warmth: the brutalist read is a
  // single raking shaft of daylight across raw grey.
  // -------------------------------------------------------------------------
  scene.add(new THREE.AmbientLight(0x2c2e34, 0.24));               // low cool ambient — recesses stay dark (AO read)

  const fill = new THREE.HemisphereLight(0x4c4f58, 0x0c0c0f, 0.22); // restrained soft fill so the key dominates
  scene.add(fill);

  // THE hard key — raking, near-white daylight, full strength, TIGHT crisp shadow.
  const key = new THREE.DirectionalLight(0xfff4e2, 3.0);
  key.position.set(-7.5, 11.5, 7.5);                  // rakes from upper-left → long crisp shadows to the right
  key.castShadow = true;
  key.shadow.mapSize.set(2048, 2048);
  key.shadow.camera.near = 1; key.shadow.camera.far = 48;
  key.shadow.camera.left = -13; key.shadow.camera.right = 13;
  key.shadow.camera.top = 13;  key.shadow.camera.bottom = -13;
  key.shadow.bias = -0.00035;
  key.shadow.radius = 1.6;                            // SMALL radius → hard-edged, crisp gallery shadows
  scene.add(key);
  const keyTarget = new THREE.Object3D(); keyTarget.position.set(0, 1.6, -3); scene.add(keyTarget);
  key.target = keyTarget;

  // -------------------------------------------------------------------------
  // PROCEDURAL CONCRETE MAPS — board-formed concrete: horizontal plank seams +
  // vertical form-tie marks + wood grain inside each board + mottled aggregate.
  // We bake a colour map, a roughness map, a normal map and a baked-AO map on a
  // single canvas set, $0 local. Shared across all concrete surfaces.
  // -------------------------------------------------------------------------
  function makeConcreteMaps(opts){
    opts = opts || {};
    const S = 512;
    const planks = opts.planks || 7;            // board-form planks down the height
    const base = opts.base || 152;              // mid grey
    // --- colour canvas ---
    const cc = document.createElement("canvas"); cc.width = cc.height = S;
    const cx = cc.getContext("2d");
    cx.fillStyle = `rgb(${base},${base},${base+2})`; cx.fillRect(0,0,S,S);
    // mottled aggregate + pour staining
    const cimg = cx.getImageData(0,0,S,S); const cd = cimg.data;
    for(let y=0;y<S;y++){
      for(let x=0;x<S;x++){
        const i=(y*S+x)*4;
        // low-freq stain (vertical pour streaks)
        const stain = Math.sin(x*0.013 + Math.sin(y*0.006)*2.0)*10
                    + Math.sin(x*0.05)*4;
        // fine aggregate speckle
        const spk = (Math.random()*26 - 13);
        const v = clamp(base + stain + spk, 96, 196);
        cd[i]=v; cd[i+1]=v; cd[i+2]=v+2; cd[i+3]=255;
      }
    }
    cx.putImageData(cimg,0,0);

    // --- roughness canvas (mostly matte; seams slightly darker = rougher) ---
    const rc = document.createElement("canvas"); rc.width = rc.height = S;
    const rx = rc.getContext("2d");
    rx.fillStyle = "#c8c8c8"; rx.fillRect(0,0,S,S);            // ~0.78 rough base
    const rimg = rx.getImageData(0,0,S,S); const rd = rimg.data;
    for(let i=0;i<rd.length;i+=4){ const n=(Math.random()*30-15); const v=clamp(200+n,150,235); rd[i]=rd[i+1]=rd[i+2]=v; rd[i+3]=255; }
    rx.putImageData(rimg,0,0);

    // --- normal canvas — start flat (128,128,255) ---
    const nc = document.createElement("canvas"); nc.width = nc.height = S;
    const nx = nc.getContext("2d");
    nx.fillStyle = "rgb(128,128,255)"; nx.fillRect(0,0,S,S);

    // --- AO canvas — white (no occlusion) by default; we darken grooves ---
    const ac = document.createElement("canvas"); ac.width = ac.height = S;
    const ax = ac.getContext("2d");
    ax.fillStyle = "#fff"; ax.fillRect(0,0,S,S);

    // helper to draw a horizontal groove (plank seam) into all maps
    const plankH = S/planks;
    function drawSeam(cy){
      // colour: dark line + a faint highlight lip above it (lit from top-left)
      cx.strokeStyle = "rgba(40,40,44,0.55)"; cx.lineWidth = 3;
      cx.beginPath(); cx.moveTo(0,cy); cx.lineTo(S,cy); cx.stroke();
      cx.strokeStyle = "rgba(220,220,224,0.28)"; cx.lineWidth = 1.5;
      cx.beginPath(); cx.moveTo(0,cy-2.5); cx.lineTo(S,cy-2.5); cx.stroke();
      // roughness: seam is rougher (brighter in roughness map)
      rx.strokeStyle = "rgba(255,255,255,0.6)"; rx.lineWidth = 4;
      rx.beginPath(); rx.moveTo(0,cy); rx.lineTo(S,cy); rx.stroke();
      // AO: groove is occluded (darker)
      const g = ax.createLinearGradient(0,cy-5,0,cy+5);
      g.addColorStop(0,"rgba(0,0,0,0)"); g.addColorStop(0.5,"rgba(0,0,0,0.45)"); g.addColorStop(1,"rgba(0,0,0,0)");
      ax.fillStyle = g; ax.fillRect(0,cy-5,S,10);
      // normal: a small ridge → bright(up)/dark(down) green-channel banding
      nx.strokeStyle = "rgba(128,210,255,0.9)"; nx.lineWidth = 2;      // surface tilts up just above seam
      nx.beginPath(); nx.moveTo(0,cy-2); nx.lineTo(S,cy-2); nx.stroke();
      nx.strokeStyle = "rgba(128,46,255,0.9)"; nx.lineWidth = 2;       // tilts down just below
      nx.beginPath(); nx.moveTo(0,cy+2); nx.lineTo(S,cy+2); nx.stroke();
    }
    for(let p=1;p<planks;p++) drawSeam(Math.round(p*plankH));

    // wood-grain form lines INSIDE each plank — subtle horizontal striations that
    // give board-formed concrete its signature timber-imprint texture.
    cx.save();
    for(let p=0;p<planks;p++){
      const y0 = p*plankH, y1 = (p+1)*plankH;
      const lines = 9 + ((Math.random()*5)|0);
      for(let l=0;l<lines;l++){
        const yy = y0 + (l+0.5)/lines*(y1-y0) + (Math.random()*3-1.5);
        const a = 0.05 + Math.random()*0.06;
        cx.strokeStyle = `rgba(70,70,74,${a})`;
        cx.lineWidth = 0.8 + Math.random()*0.7;
        cx.beginPath();
        // gentle wave so grain isn't ruler-straight
        cx.moveTo(0, yy);
        for(let xx=0; xx<=S; xx+=32){ cx.lineTo(xx, yy + Math.sin(xx*0.02 + p)*0.8); }
        cx.stroke();
        // matching faint normal striation
        nx.strokeStyle = `rgba(128,${138+((Math.random()*16)|0)},255,0.20)`;
        nx.lineWidth = 0.8; nx.beginPath(); nx.moveTo(0,yy); nx.lineTo(S,yy); nx.stroke();
      }
    }
    cx.restore();

    // form-tie cone holes — the little recessed circles brutalist concrete is known
    // for, on a regular grid. Dark dot + AO well + normal divot.
    const cols = 4, rows = planks;
    for(let r=0;r<rows;r++){
      for(let c=0;c<cols;c++){
        const tx = (c+0.5)/cols*S, ty = (r+0.5)/rows*S;
        // colour: dark recessed cone
        const rg = cx.createRadialGradient(tx,ty,0, tx,ty,7);
        rg.addColorStop(0,"rgba(30,30,34,0.85)"); rg.addColorStop(0.6,"rgba(60,60,64,0.5)"); rg.addColorStop(1,"rgba(0,0,0,0)");
        cx.fillStyle = rg; cx.beginPath(); cx.arc(tx,ty,7,0,Math.PI*2); cx.fill();
        // AO well
        const ag = ax.createRadialGradient(tx,ty,0, tx,ty,9);
        ag.addColorStop(0,"rgba(0,0,0,0.55)"); ag.addColorStop(1,"rgba(0,0,0,0)");
        ax.fillStyle = ag; ax.beginPath(); ax.arc(tx,ty,9,0,Math.PI*2); ax.fill();
        // normal divot (left bright, right dark for a round dent)
        const ng = nx.createRadialGradient(tx-2,ty-2,0, tx,ty,7);
        ng.addColorStop(0,"rgba(150,150,255,0.7)"); ng.addColorStop(1,"rgba(128,128,255,0)");
        nx.fillStyle = ng; nx.beginPath(); nx.arc(tx,ty,7,0,Math.PI*2); nx.fill();
      }
    }

    const mk = (canvas, srgb)=>{
      const t = new THREE.CanvasTexture(canvas);
      t.wrapS = t.wrapT = THREE.RepeatWrapping;
      t.anisotropy = MAX_ANISO;
      if(srgb){ if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding; }
      return t;
    };
    return { map:mk(cc,true), roughnessMap:mk(rc,false), normalMap:mk(nc,false), aoMap:mk(ac,false) };
  }

  // build the shared concrete map set once
  const CMAP = makeConcreteMaps({ planks:7, base:150 });

  function concreteMaterial(o){
    o = o || {};
    const m = new THREE.MeshStandardMaterial({
      color: o.color!==undefined ? o.color : 0xbdbab2,
      map: CMAP.map,
      roughnessMap: CMAP.roughnessMap,
      normalMap: CMAP.normalMap,
      aoMap: CMAP.aoMap,
      roughness: 0.94,
      metalness: 0.0,
      envMapIntensity: o.env!==undefined ? o.env : 0.22
    });
    m.normalScale = new THREE.Vector2(o.nrm!==undefined?o.nrm:0.85, o.nrm!==undefined?o.nrm:0.85);
    m.aoMapIntensity = o.ao!==undefined ? o.ao : 1.15;     // strong AO in the recesses
    return m;
  }

  // -------------------------------------------------------------------------
  // ROOM — a board-formed concrete hall: back wall, two side walls, a heavy
  // ceiling soffit, and a raw concrete floor with a raised plinth under the bank.
  // PlaneGeometry needs a 2nd UV set (uv2) for aoMap → we copy uv→uv2.
  // -------------------------------------------------------------------------
  function withUV2(geo, repeatU, repeatV){
    geo.setAttribute("uv2", new THREE.BufferAttribute(geo.attributes.uv.array, 2));
    return geo;
  }
  const FLOOR_Y = 0;
  (function buildRoom(){
    // BACK WALL — the big board-formed slab behind the bank (the hero concrete face)
    const backMat = concreteMaterial({ color:0xc4c1b9, env:0.20, ao:1.25, nrm:1.0 });
    backMat.map = CMAP.map.clone(); backMat.map.needsUpdate = true;
    backMat.map.repeat.set(3.2, 1.6); backMat.map.wrapS = backMat.map.wrapT = THREE.RepeatWrapping;
    backMat.roughnessMap = CMAP.roughnessMap; backMat.normalMap = CMAP.normalMap; backMat.aoMap = CMAP.aoMap;
    backMat.roughnessMap.repeat = backMat.normalMap.repeat = backMat.aoMap.repeat = backMat.map.repeat;
    const back = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 24)), backMat);
    back.position.set(0, 6, -7.6); back.receiveShadow = true; scene.add(back);

    // SIDE WALLS — darker grey, recede
    const sideMat = concreteMaterial({ color:0x9d9a92, env:0.16, ao:1.2, nrm:0.8 });
    const left = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat);
    left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
    const right = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat.clone());
    right.position.set(14, 6, 2); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);

    // CEILING SOFFIT — heavy dark slab so the key reads as a shaft from a gap
    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
      new THREE.MeshStandardMaterial({ color:0x6f6d68, roughness:1.0, metalness:0.0 }));
    ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; ceil.receiveShadow = true; scene.add(ceil);

    // FLOOR — raw matte concrete (no clearcoat: brutalist floor is NOT polished)
    const floorMat = concreteMaterial({ color:0xa7a49c, env:0.14, ao:1.1, nrm:0.6 });
    floorMat.map = CMAP.map.clone(); floorMat.map.repeat.set(8, 6);
    floorMat.map.wrapS = floorMat.map.wrapT = THREE.RepeatWrapping; floorMat.map.needsUpdate = true;
    floorMat.roughness = 0.97;
    const floor = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 40)), floorMat);
    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true; scene.add(floor);

    // RAISED PLINTH — a low concrete dais the wing bank sits on (architectural base)
    const plinthMat = concreteMaterial({ color:0xb4b1a9, env:0.18, ao:1.2, nrm:0.7 });
    const plinth = new THREE.Mesh(new THREE.BoxGeometry(20, 0.32, 4.4), plinthMat);
    // box geo already has uv2-able uvs per face; ensure uv2 exists
    plinth.geometry.setAttribute("uv2", new THREE.BufferAttribute(plinth.geometry.attributes.uv.array, 2));
    plinth.position.set(0, 0.16, -4.2); plinth.castShadow = true; plinth.receiveShadow = true; scene.add(plinth);

    // two heavy concrete pilasters framing the bank (symmetry / depth)
    const pilMat = concreteMaterial({ color:0x96938b, env:0.16, ao:1.25, nrm:0.9 });
    [-8.6, 8.6].forEach(px=>{
      const pil = new THREE.Mesh(new THREE.BoxGeometry(1.5, 22, 1.5), pilMat.clone());
      pil.geometry.setAttribute("uv2", new THREE.BufferAttribute(pil.geometry.attributes.uv.array, 2));
      pil.position.set(px, 11, -6.6); pil.castShadow = true; pil.receiveShadow = true; scene.add(pil);
    });
  })();

  // -------------------------------------------------------------------------
  // THE WING BANK — thin-framed boards (~2" apart) on a side swivel; the focused
  // board swings face-on showing a CLEAN full pattern face. Frames are raw steel
  // (charcoal, low-gloss) to read against the concrete — still monochrome.
  // -------------------------------------------------------------------------
  const BOARD_W = 1.46;       // ~30" wide board face (1u ≈ 20.5")
  const BOARD_H = 3.55;       // ~6' tall
  const FRAME_T = 0.04;       // thin frame border
  const FRAME_D = 0.05;
  const GAP = 0.10;           // ~2" between closed boards
  const PITCH = BOARD_W + GAP;
  const BANK_Z = -4.4;
  const PLINTH_TOP = 0.32;    // boards stand on the plinth
  const N_TARGET = 50;

  const boards = [];
  let products = [];
  let focusIdx = 0;

  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
  // raw blackened-steel frame — charcoal, slightly metallic, picks up the env
  const frameMat = new THREE.MeshStandardMaterial({ color:0x2b2b2e, roughness:0.42, metalness:0.78, envMapIntensity:0.6 });
  function placeholderFace(){
    return new THREE.MeshStandardMaterial({ color:0x6a6862, roughness:0.7, metalness:0.0, envMapIntensity:0.18 });
  }

  function buildBoard(idx){
    const g = new THREE.Group();
    // backing slab (thin) — concrete-toned so closed slivers still read as material
    const backing = new THREE.Mesh(
      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.035),
      new THREE.MeshStandardMaterial({ color:0x4f4d49, roughness:0.9, metalness:0.04 })
    );
    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.025);
    backing.castShadow = true; backing.receiveShadow = true;
    g.add(backing);

    const faceMat = placeholderFace();
    const face = new THREE.Mesh(faceGeo, faceMat);
    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
    face.castShadow = false; face.receiveShadow = true;
    face.userData.idx = idx;
    g.add(face);

    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
    g.add(top, bot, lf, rt);

    scene.add(g);
    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
  }

  // -------------------------------------------------------------------------
  // TEXTURE LOADER — lazy: only featured board + a small neighbour window.
  // -------------------------------------------------------------------------
  const texCache = new Map();
  function imgURL(u){
    if(!u) return null;
    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
    return "/api/proxy/image?url=" + encodeURIComponent(u);
  }
  function loadTexture(url){
    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
    return new Promise((resolve)=>{
      const img = new Image(); img.crossOrigin = "anonymous";
      img.onload = ()=>{
        const tex = new THREE.Texture(img);
        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
        else tex.encoding = THREE.sRGBEncoding;
        tex.anisotropy = MAX_ANISO;
        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
        tex.repeat.set(1, 1.0);          // one clean full motif edge-to-edge on the open face
        tex.needsUpdate = true;
        texCache.set(url, tex);
        resolve(tex);
      };
      img.onerror = ()=>resolve(null);
      img.src = url;
    });
  }

  function ensureBoardTexture(b){
    if(b.texLoaded || !b.data) return;
    const url = imgURL(b.data.image || b.data.tile);
    if(!url){ b.texLoaded = true; return; }
    b.texLoaded = true;
    loadTexture(url).then(tex=>{
      if(!tex) return;
      // The pattern face is a flat, low-spec surface so the COLOUR pops cleanly
      // against the monochrome room (no clearcoat sheen washing it out).
      const m = new THREE.MeshStandardMaterial({
        map:tex, roughness:0.62, metalness:0.0, envMapIntensity:0.18
      });
      b.face.material.dispose && b.face.material.dispose();
      b.face.material = m; b.faceMat = m;
    });
  }

  function refreshTextureWindow(){
    const WIN = 4;
    for(let i=0;i<boards.length;i++){
      const d = Math.abs(i-focusIdx);
      if(d <= WIN) ensureBoardTexture(boards[i]);
    }
  }

  // -------------------------------------------------------------------------
  // LAYOUT helpers
  // -------------------------------------------------------------------------
  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }

  // -------------------------------------------------------------------------
  // INTERACTION
  // -------------------------------------------------------------------------
  let pan = 0, targetFocus = 0;
  const ray = new THREE.Raycaster();
  const ndc = new THREE.Vector2();

  function setFocus(i){
    targetFocus = clamp(i, 0, boards.length-1);
    focusIdx = Math.round(targetFocus);
    refreshTextureWindow();
    showCaption(boards[focusIdx]);
  }

  let dragging=false, dragX=0, focusStart=0, moved=0;
  renderer.domElement.addEventListener("pointerdown",(e)=>{
    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
    renderer.domElement.setPointerCapture(e.pointerId);
  });
  renderer.domElement.addEventListener("pointermove",(e)=>{
    if(!dragging) return;
    const dx = e.clientX - dragX; moved += Math.abs(dx);
    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
    focusIdx = Math.round(targetFocus);
  });
  renderer.domElement.addEventListener("pointerup",(e)=>{
    dragging=false;
    if(moved < 6){ tryPick(e); }
    else { setFocus(Math.round(targetFocus)); }
  });
  window.addEventListener("wheel",(e)=>{
    e.preventDefault();
    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
  }, { passive:false });

  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 meshes = boards.map(b=>b.face);
    const hits = ray.intersectObjects(meshes, false);
    if(hits.length){
      const idx = hits[0].object.userData.idx;
      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
      else setFocus(idx);
    }
  }

  // -------------------------------------------------------------------------
  // CAPTION
  // -------------------------------------------------------------------------
  const capEl = document.getElementById("caption");
  function showCaption(b){
    if(!b || !b.data){ capEl.classList.remove("show"); return; }
    const d = b.data;
    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
    const bits = [];
    if(d.width) bits.push(`${d.width}" wide`);
    if(d.repeat) bits.push(`${d.repeat}" repeat`);
    if(d.match_type) bits.push(d.match_type);
    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
    const cta = document.getElementById("cap_cta");
    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
    else cta.style.display="none";
    capEl.classList.add("show");
  }

  // -------------------------------------------------------------------------
  // RESIZE
  // -------------------------------------------------------------------------
  window.addEventListener("resize", ()=>{
    camera.aspect = W()/H(); camera.updateProjectionMatrix();
    renderer.setSize(W(), H());
  });

  // -------------------------------------------------------------------------
  // FPS readout + pause on hidden
  // -------------------------------------------------------------------------
  const fpsEl = document.getElementById("fps");
  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
  let running = true;
  document.addEventListener("visibilitychange", ()=>{
    running = !document.hidden;
    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
  });

  // -------------------------------------------------------------------------
  // RENDER LOOP
  // -------------------------------------------------------------------------
  let t0 = performance.now();
  function tick(now){
    if(!running) return;
    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;

    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
    const eased = pan;

    for(let i=0;i<boards.length;i++){
      const b = boards[i];
      const rel = i - eased;
      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);

      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
      b.group.rotation.y = b.swivel;

      const slotX = rel * PITCH;
      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
      const targetZ = arcZ(slotX) + openAmt*1.55;

      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
      b.group.position.y = PLINTH_TOP;          // standing on the plinth

      // Brutalist read = light driven by the room (hard key + AO), NOT a per-board
      // brightness fade. We keep neighbours fully material; only a mild recede via
      // fog + the raking shadow. Featured board glides forward into the shaft.
      const farOff = Math.abs(rel) > 8;
      b.group.visible = !farOff;
    }

    renderer.render(scene, camera);

    // fps
    frames++;
    if(now - fpsT0 >= 500){
      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
      window.__BC_FPS = fpsVal;
    }
    requestAnimationFrame(tick);
  }

  // -------------------------------------------------------------------------
  // BOOT
  // -------------------------------------------------------------------------
  let HAS_PRODUCTS = false;
  async function start(){
    try {
      const res = await fetch(API);
      const json = await res.json();
      products = (json.products||[]).slice(0, N_TARGET);
    } catch(e){
      bootlbl.textContent = "Could not reach the catalog.";
      console.error("fetch failed", e);
      return;
    }
    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
    HAS_PRODUCTS = true;

    for(let i=0;i<products.length;i++){
      const b = buildBoard(i);
      b.data = products[i];
      b.group.position.set(i*PITCH, PLINTH_TOP, BANK_Z);
      boards.push(b);
    }
    bootlbl.textContent = `Hanging ${boards.length} boards…`;

    focusIdx = Math.min(2, boards.length-1);
    pan = targetFocus = focusIdx;
    refreshTextureWindow();

    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
    await loadTexture(featUrl).catch(()=>null);
    ensureBoardTexture(boards[focusIdx]);
    showCaption(boards[focusIdx]);

    window.__BC__ = {
      boards: ()=>boards.length,
      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
      focus: ()=>focusIdx,
      fps: ()=>window.__BC_FPS||0,
      ready: true
    };

    finishBoot();
    requestAnimationFrame(tick);
  }

  function finishBoot(){
    const boot = document.getElementById("boot");
    boot.style.opacity = "0";
    setTimeout(()=>boot.remove(), 950);
    const hint = document.getElementById("hint");
    setTimeout(()=>{ hint.style.opacity="0"; }, 6000);
  }

  start();
})();
</script>

<script>window.PROTO_META={"key":"V15","label":"V15 — Brutalist Concrete","elements":[
  {"n":1,"label":"Board-formed concrete hall — procedural normal/roughness, wood-grain form lines"},
  {"n":2,"label":"One hard directional key light, crisp raking shadows"},
  {"n":3,"label":"Strong baked ambient occlusion in the recesses + form-tie holes"},
  {"n":4,"label":"Monochrome grey/charcoal restraint — the pattern is the only colour"},
  {"n":5,"label":"Thin steel-framed boards swivel open to a clean full pattern face"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>