← back to Quadrille Showroom

public/proto/v19-conservatory.html

774 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>CONSERVATORY · China Seas</title>
<style>
  :root { --leaf:#5d7a52; --brass:#b9a06a; --paper:#eef1e6; }
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; overflow:hidden; background:#0d1410; color:var(--paper);
    font-family:"Helvetica Neue",Helvetica,Arial,sans-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:20; padding:72px 30px 46px;
    pointer-events:none; text-align:center;
    background:linear-gradient(180deg, rgba(18,30,22,.74) 0%, rgba(18,30,22,0) 100%); }
  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
    color:#9bb18a; font-weight:600; }
  .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
    font-weight:400; letter-spacing:.12em; line-height:1; margin-top:9px; color:#f1f5ea;
    text-shadow:0 2px 24px rgba(0,0,0,.5); }
  .masthead h1 b { font-weight:700; color:#cdb784; }
  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#a7b59a;
    max-width:560px; margin-left:auto; margin-right:auto; }

  /* ---- focused board caption ---- */
  #caption { position:fixed; left:50%; bottom:96px; 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:Georgia,serif; font-size:22px; letter-spacing:.02em; color:#f3f6ec;
    text-shadow:0 2px 18px rgba(0,0,0,.85); }
  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#bcc9a6; }
  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#9fad8f; }
  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
    border:1px solid rgba(185,160,106,.6); border-radius:28px; color:#d8c590; font-size:10.5px;
    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
    background:rgba(185,160,106,.08); transition:background .15s, transform .15s; }
  #caption a.cta:hover { background:rgba(185,160,106,.2); transform:translateY(-1px); }

  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(225,235,210,.42);
    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:#9bb18a; background:rgba(14,22,16,.6); border:1px solid rgba(155,177,138,.3); border-radius:7px;
    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:#0d1410; transition:opacity .9s; }
  #boot .ring { width:44px; height:44px; border-radius:50%;
    border:2px solid rgba(155,177,138,.2); border-top-color:#9bb18a; animation:spin 1s linear infinite; }
  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#7a8a6c; }
  @keyframes spin { to { transform:rotate(360deg); } }

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

<div class="masthead">
  <div class="kicker">Designer Wallcoverings · The Palm House</div>
  <h1>CONSERVATORY <b>GARDEN</b></h1>
  <div class="sub">China Seas, hung in a Victorian glasshouse — wrought-iron and glass, lush palms,
    dappled leaf-light falling across the boards. Drag to swing the bank; tap a board to bring it into the light.</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">Opening the glasshouse…</div></div>

<!-- r128 core (local) + r128 RoomEnvironment (CDN, attaches to global THREE). No UnrealBloomPass 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.12;
  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.setClearColor(0x18241b, 1);
  app.appendChild(renderer.domElement);
  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0x1a2a1e);
  // light volumetric haze — warm-green, gives the glasshouse depth
  scene.fog = new THREE.FogExp2(0x223024, 0.022);

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

  // -------------------------------------------------------------------------
  // PMREM ENVIRONMENT (RoomEnvironment) — so glass, brass & leaves catch light
  // -------------------------------------------------------------------------
  (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 */ }
  })();

  // -------------------------------------------------------------------------
  // DAPPLED LEAF GOBO — a procedural canvas texture (organic leaf-shaped light
  // gaps) projected through the hero SpotLight via spot.map, AND reused as a
  // moving overlay on ambient brightness so leaf-shadow plays across the floor.
  // $0 local, one canvas.
  // -------------------------------------------------------------------------
  function makeLeafGobo(){
    const S = 512;
    const c = document.createElement("canvas"); c.width = c.height = S;
    const x = c.getContext("2d");
    // base = deep canopy shadow; bright warm pools = gaps where sun pours through.
    // Higher contrast so the dapple reads boldly on the floor + boards.
    x.fillStyle = "#0e150a"; x.fillRect(0,0,S,S);
    // scatter warm dappled sun-pools, additive so overlaps brighten like real light
    x.globalCompositeOperation = "lighter";
    for(let i=0;i<70;i++){
      const cx = Math.random()*S, cy = Math.random()*S;
      const r = 26 + Math.random()*90;
      const g = x.createRadialGradient(cx,cy,0,cx,cy,r);
      const warm = Math.random()<0.6;
      g.addColorStop(0, warm ? "rgba(255,240,196,1.0)" : "rgba(214,238,176,0.95)");
      g.addColorStop(0.5, warm ? "rgba(196,206,128,0.45)" : "rgba(150,196,118,0.4)");
      g.addColorStop(1, "rgba(0,0,0,0)");
      x.fillStyle = g; x.beginPath(); x.arc(cx,cy,r,0,Math.PI*2); x.fill();
    }
    // cut crisp leaf-silhouette shadows back through the pools (multiply darkens)
    x.globalCompositeOperation = "multiply";
    x.fillStyle = "rgba(8,14,6,0.96)";
    for(let i=0;i<110;i++){
      const cx = Math.random()*S, cy = Math.random()*S;
      const len = 34 + Math.random()*110, wdt = 9 + Math.random()*26;
      const ang = Math.random()*Math.PI*2;
      x.save(); x.translate(cx,cy); x.rotate(ang);
      x.beginPath();
      x.moveTo(0,-len/2);
      x.quadraticCurveTo(wdt, -len*0.1, 0, len/2);
      x.quadraticCurveTo(-wdt, len*0.1, 0, -len/2);
      x.fill();
      x.restore();
    }
    x.globalCompositeOperation = "source-over";
    const t = new THREE.CanvasTexture(c);
    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
    t.wrapS = t.wrapT = THREE.RepeatWrapping;
    t.anisotropy = MAX_ANISO;
    return t;
  }
  const leafGobo = makeLeafGobo();

  // -------------------------------------------------------------------------
  // LIGHTING — bright botanical daylight: green-tinted ambient/hemisphere, a
  // warm sun key casting leaf-dappled shadows, cool sky fill, warm rim, plus a
  // gobo-masked SpotLight that pours dappled light onto the featured board.
  // -------------------------------------------------------------------------
  scene.add(new THREE.HemisphereLight(0xdfeecb, 0x33402b, 0.55)); // sky/ground
  scene.add(new THREE.AmbientLight(0x46553a, 0.35));

  const fill = new THREE.DirectionalLight(0xaecbe6, 0.35);   // cool sky fill from left
  fill.position.set(-8, 7, 6); scene.add(fill);

  const rim = new THREE.DirectionalLight(0xffe2ab, 0.42);    // warm rim from behind-right
  rim.position.set(7, 5, -8); scene.add(rim);

  // SUN key — warm, high, casts the dappled leaf shadow across boards + floor
  const sun = new THREE.DirectionalLight(0xfff2d2, 0.95);
  sun.position.set(-5, 13, 9);
  sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  sun.shadow.camera.near = 1; sun.shadow.camera.far = 46;
  sun.shadow.camera.left = -12; sun.shadow.camera.right = 12;
  sun.shadow.camera.top = 12; sun.shadow.camera.bottom = -12;
  sun.shadow.bias = -0.0004; sun.shadow.radius = 5;
  scene.add(sun);

  // HERO gobo spotlight — pours warm dappled light onto the featured board face
  const spot = new THREE.SpotLight(0xfff0cf, 0, 20, Math.PI/6.2, 0.5, 1.2);
  spot.position.set(0, 7.2, 5.6);
  if("map" in spot) spot.map = leafGobo;     // r128 SpotLight supports a projected map
  spot.castShadow = true;
  spot.shadow.mapSize.set(2048, 2048);
  spot.shadow.camera.near = 1; spot.shadow.camera.far = 26;
  spot.shadow.bias = -0.0005; spot.shadow.radius = 6;
  scene.add(spot);
  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
  spot.target = spotTarget;

  // -------------------------------------------------------------------------
  // ROOM — wrought-iron & glass conservatory. Glass back/side walls (physical,
  // transmissive-ish), a planted stone floor with a moving dappled-leaf overlay,
  // and an iron-mullion grid arching overhead.
  // -------------------------------------------------------------------------
  const FLOOR_Y = 0;
  let floorDapple = null;     // material whose emissive map we scroll for moving dapple

  (function buildRoom(){
    // --- glasshouse glazing: cool sky-toned glass panes behind/beside the bank ---
    const glassMat = new THREE.MeshPhysicalMaterial({
      color:0xbfd8d0, roughness:0.08, metalness:0.0,
      transmission: ("transmission" in THREE.MeshPhysicalMaterial.prototype ? 0.55 : 0.0),
      transparent:true, opacity:0.5, thickness:0.4,
      clearcoat:0.6, clearcoatRoughness:0.18, envMapIntensity:1.1, side:THREE.DoubleSide
    });
    const back = new THREE.Mesh(new THREE.PlaneGeometry(64, 26), glassMat);
    back.position.set(0, 7, -8.0); scene.add(back);
    const left = new THREE.Mesh(new THREE.PlaneGeometry(44, 26), glassMat);
    left.position.set(-15, 7, 2); left.rotation.y = Math.PI/2; scene.add(left);
    const right = left.clone(); right.position.x = 15; right.rotation.y = -Math.PI/2; scene.add(right);

    // daylight sky + a distant JUNGLE silhouette layer behind the glass, so the
    // glasshouse reads as looking out onto lush gardens — not a flat grey wall.
    const skyC = document.createElement("canvas"); skyC.width = 1024; skyC.height = 512;
    const sx = skyC.getContext("2d");
    // warm-green daylight gradient
    const sg = sx.createLinearGradient(0,0,0,512);
    sg.addColorStop(0, "#eef3da"); sg.addColorStop(0.45, "#dcebcb"); sg.addColorStop(1, "#a9c6a0");
    sx.fillStyle = sg; sx.fillRect(0,0,1024,512);
    // far hazy treeline (soft, low contrast — atmospheric distance)
    sx.fillStyle = "rgba(120,158,108,0.55)";
    for(let i=0;i<60;i++){
      const cx = Math.random()*1024, r = 40+Math.random()*130;
      sx.beginPath(); sx.arc(cx, 360+Math.random()*60, r, 0, Math.PI*2); sx.fill();
    }
    // nearer foliage clumps along the bottom (denser, greener)
    sx.fillStyle = "rgba(74,112,62,0.8)";
    for(let i=0;i<80;i++){
      const cx = Math.random()*1024, cy = 430+Math.random()*80, r = 30+Math.random()*90;
      sx.beginPath(); sx.arc(cx, cy, r, 0, Math.PI*2); sx.fill();
    }
    // a few tall palm silhouettes breaking the treeline
    sx.strokeStyle = "rgba(58,92,50,0.85)"; sx.lineWidth = 6;
    for(let i=0;i<7;i++){
      const px = 90 + Math.random()*860, py = 470, ph = 150+Math.random()*120;
      sx.beginPath(); sx.moveTo(px, py); sx.lineTo(px-10, py-ph); sx.stroke();
      sx.fillStyle = "rgba(58,92,50,0.8)";
      for(let f=0;f<7;f++){
        const a = -Math.PI/2 + (f-3)*0.4;
        sx.save(); sx.translate(px-10, py-ph); sx.rotate(a);
        sx.beginPath(); sx.ellipse(40,0,46,11,0,0,Math.PI*2); sx.fill(); sx.restore();
      }
    }
    const skyTex = new THREE.CanvasTexture(skyC);
    if("colorSpace" in skyTex) skyTex.colorSpace = THREE.SRGBColorSpace;
    skyTex.anisotropy = MAX_ANISO;
    const sky = new THREE.Mesh(new THREE.PlaneGeometry(84, 40),
      new THREE.MeshBasicMaterial({ map:skyTex, fog:false }));
    sky.position.set(0, 9, -10.8); scene.add(sky);

    // --- wrought-iron mullions: a grid of thin dark bars over the glazing ---
    const ironMat = new THREE.MeshStandardMaterial({ color:0x1c241c, roughness:0.45, metalness:0.85, envMapIntensity:0.9 });
    const vBar = new THREE.BoxGeometry(0.07, 26, 0.07);
    const hBar = new THREE.BoxGeometry(64, 0.07, 0.07);
    const mullions = new THREE.Group();
    for(let i=-5;i<=5;i++){ const m = new THREE.Mesh(vBar, ironMat); m.position.set(i*5.6, 7, -7.9); mullions.add(m); }
    for(let j=0;j<5;j++){ const m = new THREE.Mesh(hBar, ironMat); m.position.set(0, 1.5+j*3.0, -7.9); mullions.add(m); }
    scene.add(mullions);

    // --- arching iron ribs overhead (the palm-house roof) ---
    const ribMat = ironMat;
    const ribGroup = new THREE.Group();
    for(let r=0;r<6;r++){
      const z = 2 - r*2.4;
      const pts = [];
      for(let a=0;a<=20;a++){
        const t = a/20, ang = Math.PI*t;
        pts.push(new THREE.Vector3(Math.cos(ang)*14, 12.5 + Math.sin(ang)*3.4, z));
      }
      const curve = new THREE.CatmullRomCurve3(pts);
      const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, 24, 0.08, 6, false), ribMat);
      ribGroup.add(tube);
    }
    scene.add(ribGroup);

    // --- planted stone floor with a MOVING dappled-leaf light overlay ---
    // base stone roughness
    const stoneRough = (function(){
      const c = document.createElement("canvas"); c.width=c.height=256; const g=c.getContext("2d");
      g.fillStyle="#888"; g.fillRect(0,0,256,256);
      const im=g.getImageData(0,0,256,256), d=im.data;
      for(let i=0;i<d.length;i+=4){ const n=(Math.random()*60)|0; d[i]=d[i+1]=d[i+2]=150+n; }
      g.putImageData(im,0,0);
      const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(10,10); t.anisotropy=MAX_ANISO; return t;
    })();
    floorDapple = new THREE.MeshStandardMaterial({
      color:0x575243, roughness:0.82, metalness:0.0,
      roughnessMap: stoneRough,
      emissive:0xfff0c8, emissiveIntensity:0.9, emissiveMap: leafGobo, envMapIntensity:0.45
    });
    floorDapple.emissiveMap.repeat.set(2.0, 2.0);
    const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), floorDapple);
    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
    scene.add(floor);
  })();

  // -------------------------------------------------------------------------
  // GREENERY — cheap potted palms/ferns flanking the bank. Built from a few
  // SHARED low-poly cone/plane fronds + alpha leaf billboards. Instanced trunks,
  // shared frond geometry. NOT heavy meshes.
  // -------------------------------------------------------------------------
  // one alpha leaf texture (frond silhouette) reused everywhere
  function makeFrondTex(){
    const S=256, c=document.createElement("canvas"); c.width=c.height=S; const x=c.getContext("2d");
    x.clearRect(0,0,S,S);
    // a lush feathered palm frond drawn as individual leaflets off a central rib
    // (reads naturalistic, not a flat lozenge). Transparent ground for alpha cut.
    const midX = S*0.5;
    // central rib
    x.strokeStyle = "#3a5a30"; x.lineWidth = 4;
    x.beginPath(); x.moveTo(midX, S*0.06); x.lineTo(midX, S*0.97); x.stroke();
    // paired leaflets, getting shorter toward the tip, with a light/dark gradient
    for(let i=0.08;i<0.94;i+=0.045){
      const t = i;
      const taper = Math.sin(t*Math.PI);            // long in the middle, short at ends
      const ll = S*0.40*taper;
      const droop = 0.18 + t*0.10;                  // leaflets angle down toward the tip
      const shade = 0.55 + 0.4*(1-t);               // brighter near base
      const top = `rgba(${Math.round(70*shade+30)},${Math.round(120*shade+40)},${Math.round(58*shade+30)},1)`;
      x.strokeStyle = top; x.lineWidth = 3.0;
      // right leaflet
      x.beginPath(); x.moveTo(midX, S*t);
      x.quadraticCurveTo(midX+ll*0.6, S*(t+droop*0.4), midX+ll, S*(t+droop)); x.stroke();
      // left leaflet
      x.beginPath(); x.moveTo(midX, S*t);
      x.quadraticCurveTo(midX-ll*0.6, S*(t+droop*0.4), midX-ll, S*(t+droop)); x.stroke();
    }
    const t = new THREE.CanvasTexture(c);
    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace;
    t.anisotropy = MAX_ANISO;
    return t;
  }
  const frondTex = makeFrondTex();
  const frondGeo = new THREE.PlaneGeometry(1.0, 2.0);
  const frondMat = new THREE.MeshStandardMaterial({
    map:frondTex, alphaTest:0.25, transparent:true, side:THREE.DoubleSide,
    roughness:0.66, metalness:0.0, envMapIntensity:0.55
  });
  const potMat = new THREE.MeshStandardMaterial({ color:0x6b4a32, roughness:0.78, metalness:0.05, envMapIntensity:0.4 });
  const trunkMat = new THREE.MeshStandardMaterial({ color:0x4e5d39, roughness:0.85, metalness:0.0 });
  const potGeo = new THREE.CylinderGeometry(0.55, 0.42, 0.8, 14);
  const trunkGeo = new THREE.CylinderGeometry(0.08, 0.12, 1.6, 8);

  function buildPalm(x, z, scale){
    const g = new THREE.Group();
    const pot = new THREE.Mesh(potGeo, potMat); pot.position.y = 0.4; pot.castShadow=true; pot.receiveShadow=true; g.add(pot);
    const trunk = new THREE.Mesh(trunkGeo, trunkMat); trunk.position.y = 1.6; trunk.castShadow=true; g.add(trunk);
    // crown of ~9 fronds radiating from the top — shared geo/material
    const NF = 9, crownY = 2.5;
    for(let i=0;i<NF;i++){
      const f = new THREE.Mesh(frondGeo, frondMat);
      const a = (i/NF)*Math.PI*2;
      const tilt = 0.35 + Math.random()*0.4;
      f.position.set(Math.cos(a)*0.5, crownY + 0.7, Math.sin(a)*0.5);
      f.rotation.order = "YXZ";
      f.rotation.y = -a + Math.PI/2;
      f.rotation.x = tilt;
      f.scale.set(0.8+Math.random()*0.4, 1.0+Math.random()*0.3, 1);
      f.castShadow = true;       // fronds throw their own leaf-shadow
      g.add(f);
    }
    g.position.set(x, 0, z);
    g.scale.setScalar(scale);
    g.userData.fronds = g.children.slice(2); // for breeze sway
    scene.add(g);
    return g;
  }
  const palms = [
    buildPalm(-7.5, -1.2, 1.25),
    buildPalm( 7.5, -1.2, 1.25),
    buildPalm(-9.8,  1.8, 1.55),
    buildPalm( 9.8,  1.8, 1.55),
    buildPalm(-6.2,  3.4, 1.0),
    buildPalm( 6.2,  3.4, 1.0)
  ];

  // -------------------------------------------------------------------------
  // THE WING BANK — thin-framed boards, ~2" apart on an arc, side swivel.
  // -------------------------------------------------------------------------
  const BOARD_W = 1.46;       // ~30" wide board face
  const BOARD_H = 3.55;       // ~6' tall
  const FRAME_T = 0.045;
  const FRAME_D = 0.05;
  const GAP = 0.10;           // ~2" between closed boards
  const PITCH = BOARD_W + GAP;
  const BANK_Z = -4.4;
  const N_TARGET = 50;

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

  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
  // light wrought-iron / aged-brass frame (matches the glasshouse, not bronze noir)
  const frameMat = new THREE.MeshStandardMaterial({ color:0x2b3326, roughness:0.42, metalness:0.85, envMapIntensity:1.0 });
  function placeholderFace(){
    return new THREE.MeshPhysicalMaterial({ color:0xd8dccb, roughness:0.6, metalness:0.0, envMapIntensity:0.4 });
  }

  function buildBoard(idx){
    const g = new THREE.Group();
    const backing = new THREE.Mesh(
      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
      new THREE.MeshStandardMaterial({ color:0x232a1f, roughness:0.82, metalness:0.08 })
    );
    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
    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;   // receives the leaf gobo shadow
    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 };
  }

  // floor reflection of the featured board (cheap soft contact glow on stone)
  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
  reflMesh.scale.y = -1; reflMesh.renderOrder = 1;
  scene.add(reflMesh);

  // -------------------------------------------------------------------------
  // TEXTURE LOADER — lazy window around the focused board
  // -------------------------------------------------------------------------
  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);   // clean full pattern face, edge to edge
        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;
      const m = new THREE.MeshPhysicalMaterial({
        map:tex, roughness:0.56, metalness:0.0, clearcoat:0.08, clearcoatRoughness:0.6, envMapIntensity:0.4
      });
      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.min(Math.abs(i-focusIdx), boards.length-Math.abs(i-focusIdx));
      if(d <= WIN) ensureBoardTexture(boards[i]);
    }
  }

  // -------------------------------------------------------------------------
  // LAYOUT
  // -------------------------------------------------------------------------
  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;
    const tt = now*0.001;

    // moving dappled-leaf light: slowly scroll the floor emissive map + gobo
    if(floorDapple && floorDapple.emissiveMap){
      floorDapple.emissiveMap.offset.x = Math.sin(tt*0.08)*0.06 + tt*0.01;
      floorDapple.emissiveMap.offset.y = Math.cos(tt*0.06)*0.05;
      floorDapple.emissiveIntensity = 0.5 + Math.sin(tt*0.5)*0.12;
    }
    if(spot.map){ spot.map.offset.x = Math.sin(tt*0.1)*0.04; spot.map.offset.y = Math.cos(tt*0.08)*0.04; }

    // gentle breeze sway on the palm fronds (cheap rotation jiggle)
    for(const p of palms){
      const fr = p.userData.fronds;
      for(let k=0;k<fr.length;k++){
        const f = fr[k];
        if(f.geometry === frondGeo){
          f.rotation.z = Math.sin(tt*0.9 + k + p.position.x)*0.06;
        }
      }
    }

    // smooth carousel travel
    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 = 0;

      // soft brightness falloff with distance (gallery focus) — botanical version
      // keeps neighbours readable in the bright glasshouse (not deep noir).
      const litTarget = 0.62 + 0.38*Math.pow(openAmt, 1.3);
      if(b._lit===undefined) b._lit = litTarget;
      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
      if(b.faceMat && !b.faceMat.map){ b.faceMat.color.setScalar(b._lit); }
      else if(b.faceMat && b.faceMat.map){ b.faceMat.color.setScalar(0.62 + 0.38*Math.pow(openAmt,1.3)); }

      const farOff = Math.abs(rel) > 7;
      b.group.visible = !farOff;
    }

    // spotlight + reflection track the featured board
    const front = boards[focusIdx];
    if(front){
      const fx = front.group.position.x + BOARD_W/2;
      spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
      spot.position.set(fx, 7.2, front.group.position.z + 5.4);
      spot.intensity += ((HAS_PRODUCTS?9.0:0) - spot.intensity)*Math.min(1,dt*5);

      if(front.faceMat && front.faceMat.map){
        reflMat.map = front.faceMat.map;
        reflMat.opacity += (0.12 - reflMat.opacity)*Math.min(1,dt*5);
        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
        reflMesh.needsUpdate = true;
      } else {
        reflMat.opacity += (0 - reflMat.opacity)*Math.min(1,dt*5);
      }
    }

    renderer.render(scene, camera);

    frames++;
    if(now - fpsT0 >= 500){
      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
      window.__CV_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, 0, 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.__CV__ = {
      boards: ()=>boards.length,
      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
      palms: ()=>palms.length,
      focus: ()=>focusIdx,
      fps: ()=>window.__CV_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":"V19","label":"V19 — Conservatory Garden","elements":[
  {"n":1,"label":"Victorian glasshouse — wrought-iron mullions, arched ribs, transmissive glass walls"},
  {"n":2,"label":"Lush potted palms flanking the bank with breeze-sway fronds"},
  {"n":3,"label":"Dappled leaf-shadow gobo light across the boards + moving leaf-light on the stone floor"},
  {"n":4,"label":"Thin iron-framed China Seas boards, ~2\" apart on a side swivel"},
  {"n":5,"label":"Click a board → carousel-shift to center → clean full pattern face"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>