← back to Quadrille Showroom

public/proto/v17-tatami.html

724 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>TATAMI · China Seas</title>
<style>
  :root { --sumi:#2a2620; --washi:#efe7d6; --tea:#8a7a5c; }
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; overflow:hidden; background:#e9e2d2; color:var(--sumi);
    font-family:"Hiragino Mincho ProN","Yu Mincho",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:20; padding:46px 34px 60px;
    pointer-events:none; text-align:center;
    background:linear-gradient(180deg, rgba(233,226,210,.78) 0%, rgba(233,226,210,0) 100%); }
  .masthead .kicker { font-size:11px; letter-spacing:.5em; text-transform:uppercase;
    color:#9a8a68; font-weight:600; }
  .masthead h1 { font-size:clamp(26px,4.4vw,46px); font-weight:400; letter-spacing:.34em;
    line-height:1; margin-top:11px; color:#33302a; padding-left:.34em; }
  .masthead h1 b { font-weight:600; color:#6f5f43; }
  .masthead .sub { margin-top:12px; font-size:12.5px; letter-spacing:.05em; color:#7a715e;
    max-width:560px; margin-left:auto; margin-right:auto; font-style:italic; }

  /* ---- focused board caption ---- */
  #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
    z-index:22; text-align:center; opacity:0; transition:opacity .55s, transform .55s cubic-bezier(.2,.8,.2,1);
    pointer-events:none; }
  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
  #caption .cn { font-size:23px; letter-spacing:.04em; color:#34302a;
    text-shadow:0 1px 10px rgba(255,250,238,.7); }
  #caption .cc { margin-top:6px; font-size:10.5px; letter-spacing:.38em; text-transform:uppercase; color:#8a7a5c; }
  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.05em; color:#766c59; }
  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:15px; padding:10px 28px;
    border:1px solid rgba(122,108,82,.5); border-radius:2px; color:#5d5038; font-size:10.5px;
    letter-spacing:.26em; text-transform:uppercase; text-decoration:none; font-weight:600;
    background:rgba(255,250,238,.4); transition:background .15s, transform .15s; }
  #caption a.cta:hover { background:rgba(255,250,238,.85); 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(80,70,52,.5);
    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:#7a6c4e; background:rgba(247,242,232,.66); border:1px solid rgba(122,108,82,.28); border-radius:6px;
    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
  #fps.off { opacity:.34; }

  /* ---- loading ---- */
  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
    align-items:center; justify-content:center; background:#e9e2d2; transition:opacity .9s; }
  #boot .ring { width:44px; height:44px; border-radius:50%;
    border:2px solid rgba(122,108,82,.22); border-top-color:#8a7a5c; animation:spin 1.1s linear infinite; }
  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.4em; text-transform:uppercase; color:#8a7a5c;
    font-style:italic; }
  @keyframes spin { to { transform:rotate(360deg); } }

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

<div class="masthead">
  <div class="kicker">Designer Wallcoverings · 茶室 The Tea House</div>
  <h1>TATAMI<b>畳</b></h1>
  <div class="sub">China Seas, presented in the stillness of a shoji-lit tea room. Soft north light
    glows through the paper screens; one board steps forward at a time — full pattern, edge to edge.
    Drag to slide the bank; tap a board to bring it to center.</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">Quieting the room…</div></div>

<!-- r128 core (local) + r128 example modules (CDN, attach to global THREE) -->
<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.08;          // calm, slightly lifted for the bright tea room
  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.setClearColor(0xe9e2d2, 1);
  app.appendChild(renderer.domElement);
  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();

  const scene = new THREE.Scene();
  scene.background = new THREE.Color(0xe7e0d0);
  // very soft warm haze for depth — the air of a quiet room, not murk
  scene.fog = new THREE.FogExp2(0xe5ddcb, 0.014);

  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) — soft IBL so wood/paper read with
  // gentle reflected light rather than flat ambient.
  // -------------------------------------------------------------------------
  (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 — serene diffuse NORTH light. A broad cool-warm key glows through
  // the shoji from the left; gentle warm fill; the shoji panels themselves are
  // softly emissive so the paper appears lit from behind.
  // -------------------------------------------------------------------------
  scene.add(new THREE.HemisphereLight(0xf6efe0, 0x9c8e72, 0.55));   // sky/ground bounce
  scene.add(new THREE.AmbientLight(0xfff6e6, 0.20));

  // soft diffuse north key — comes through the left screen wall, low intensity,
  // big soft shadow (the calm raking light of an overcast north window)
  const key = new THREE.DirectionalLight(0xfff4e2, 0.62);
  key.position.set(-6, 8, 7);
  key.castShadow = true;
  key.shadow.mapSize.set(2048, 2048);
  key.shadow.camera.near = 1; key.shadow.camera.far = 44;
  key.shadow.camera.left = -11; key.shadow.camera.right = 11;
  key.shadow.camera.top = 12; key.shadow.camera.bottom = -4;
  key.shadow.bias = -0.0004; key.shadow.radius = 6;       // very soft penumbra
  scene.add(key);

  // warm fill from the right so the far side of each board isn't dead
  const fill = new THREE.DirectionalLight(0xffe9c8, 0.20);
  fill.position.set(7, 4, 5); scene.add(fill);

  // a low warm bounce from the tatami floor
  const floorBounce = new THREE.DirectionalLight(0xf2e2bf, 0.10);
  floorBounce.position.set(0, -3, 6); scene.add(floorBounce);

  // -------------------------------------------------------------------------
  // PROCEDURAL TEXTURES (canvas → texture, $0 local)
  // -------------------------------------------------------------------------
  // tatami weave: tight horizontal/vertical rush-grass weave, soft green-gold
  function makeTatamiTexture(){
    const c = document.createElement("canvas"); c.width = c.height = 256;
    const x = c.getContext("2d");
    x.fillStyle = "#cdbf92"; x.fillRect(0,0,256,256);
    // fine vertical rush lines
    for(let i=0;i<256;i+=2){
      const v = 150 + ((Math.random()*40)|0);
      x.strokeStyle = `rgba(${v-10},${v},${v-40},0.45)`;
      x.beginPath(); x.moveTo(i+0.5,0); x.lineTo(i+0.5,256); x.stroke();
    }
    // subtle horizontal weave banding
    for(let j=0;j<256;j+=1){
      x.fillStyle = `rgba(120,110,70,${(Math.sin(j*0.7)*0.04+0.04).toFixed(3)})`;
      x.fillRect(0,j,256,1);
    }
    const t = new THREE.CanvasTexture(c);
    t.wrapS = t.wrapT = THREE.RepeatWrapping; t.anisotropy = MAX_ANISO;
    return t;
  }
  // tatami roughness — slightly varied so it isn't a flat mat
  function makeTatamiRough(){
    const c = document.createElement("canvas"); c.width = c.height = 128;
    const x = c.getContext("2d"); x.fillStyle="#b0b0b0"; x.fillRect(0,0,128,128);
    const img = x.getImageData(0,0,128,128); const d=img.data;
    for(let i=0;i<d.length;i+=4){ const n=(Math.random()*40)|0; d[i]=d[i+1]=d[i+2]=160+n; }
    x.putImageData(img,0,0);
    const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
  }
  // shoji paper (washi) — soft mottled fibrous off-white, warm, faint grain
  function makeWashiTexture(){
    const c = document.createElement("canvas"); c.width = c.height = 256;
    const x = c.getContext("2d");
    x.fillStyle = "#f3ecdc"; x.fillRect(0,0,256,256);
    // fibrous flecks
    for(let i=0;i<2200;i++){
      const px=(Math.random()*256)|0, py=(Math.random()*256)|0;
      const a=(Math.random()*0.05).toFixed(3);
      x.fillStyle = Math.random()<0.5 ? `rgba(210,196,168,${a})` : `rgba(255,252,244,${a})`;
      x.fillRect(px,py,1+(Math.random()*2|0),1);
    }
    const t = new THREE.CanvasTexture(c);
    t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
  }
  // light wood grain (hinoki / cedar post-and-beam) — warm, straight grain
  function makeWoodTexture(){
    const c = document.createElement("canvas"); c.width=64; c.height=256;
    const x = c.getContext("2d");
    const g = x.createLinearGradient(0,0,64,0);
    g.addColorStop(0,"#caa978"); g.addColorStop(0.5,"#d8bd92"); g.addColorStop(1,"#bd9b6c");
    x.fillStyle=g; x.fillRect(0,0,64,256);
    for(let i=0;i<26;i++){
      const xx = Math.random()*64;
      x.strokeStyle = `rgba(120,92,56,${(Math.random()*0.18+0.05).toFixed(3)})`;
      x.lineWidth = Math.random()*1.4+0.3;
      x.beginPath(); x.moveTo(xx, 0);
      for(let yy=0; yy<256; yy+=12){ x.lineTo(xx + Math.sin(yy*0.05)*2, yy); }
      x.stroke();
    }
    const t = new THREE.CanvasTexture(c);
    t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
  }

  const TATAMI_MAP = makeTatamiTexture();
  const TATAMI_ROUGH = makeTatamiRough();
  const WASHI_MAP = makeWashiTexture();
  const WOOD_MAP = makeWoodTexture();

  // -------------------------------------------------------------------------
  // ROOM — tatami floor, shoji screen walls (softly emissive paper held in a
  // wood lattice), warm post-and-beam frame, low ceiling. Serene & minimal.
  // -------------------------------------------------------------------------
  const FLOOR_Y = 0;
  const ROOM_HALF = 11;        // ~20-22' room half-width
  const WALL_Z = -7.6;
  const CEIL_Y = 7.2;

  function woodMat(rep){
    const m = WOOD_MAP.clone(); m.needsUpdate = true; m.repeat.set(rep[0], rep[1]);
    return new THREE.MeshPhysicalMaterial({ map:m, color:0xffffff, roughness:0.62, metalness:0.0,
      clearcoat:0.06, clearcoatRoughness:0.7, envMapIntensity:0.5 });
  }

  (function buildRoom(){
    // TATAMI FLOOR — large mats, woven rush, soft green-gold, low spec
    const tMap = TATAMI_MAP.clone(); tMap.needsUpdate = true; tMap.repeat.set(14, 14);
    const tRough = TATAMI_ROUGH.clone(); tRough.needsUpdate = true; tRough.repeat.set(14,14);
    const floorMat = new THREE.MeshPhysicalMaterial({
      map:tMap, roughnessMap:tRough, roughness:0.92, metalness:0.0, envMapIntensity:0.35
    });
    const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 34), floorMat);
    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
    scene.add(floor);

    // black tatami-mat border seams (a few thin dark strips) for the woven-mat read
    const seamMat = new THREE.MeshStandardMaterial({ color:0x2c281f, roughness:0.85, metalness:0.0 });
    for(let i=-2;i<=2;i++){
      const seam = new THREE.Mesh(new THREE.PlaneGeometry(0.06, 34), seamMat);
      seam.rotation.x = -Math.PI/2; seam.position.set(i*4.0, 0.002, 0); seam.receiveShadow = true;
      scene.add(seam);
    }
    const seamH = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 0.06), seamMat);
    seamH.rotation.x = -Math.PI/2; seamH.position.set(0, 0.002, -2.0); scene.add(seamH);

    // SHOJI SCREEN WALL builder — a softly back-lit washi panel inside a wood
    // lattice grid. The paper is emissive (low) so it reads as glowing with the
    // diffuse north light behind it. Transmission/translucency handled via a
    // gentle emissive + high roughness physical material (cheap & stable on r128).
    function buildShojiWall(width, height, cx, cy, cz, ry){
      const wall = new THREE.Group();
      // the glowing paper panel
      const paperMap = WASHI_MAP.clone(); paperMap.needsUpdate = true;
      paperMap.repeat.set(Math.max(2, width/2), Math.max(2, height/2));
      const paperMat = new THREE.MeshPhysicalMaterial({
        map:paperMap, color:0xf6efe0,
        roughness:0.95, metalness:0.0,
        transmission:0.35, thickness:0.4, ior:1.2,
        emissive:0xfff3df, emissiveIntensity:0.42,    // soft glow = north light through paper
        envMapIntensity:0.4, transparent:true, opacity:1.0
      });
      const paper = new THREE.Mesh(new THREE.PlaneGeometry(width, height), paperMat);
      paper.position.set(0,0,0); paper.receiveShadow = false;
      wall.add(paper);

      // a faint light-card behind the paper to fake the glowing window beyond
      const glow = new THREE.Mesh(new THREE.PlaneGeometry(width*0.98, height*0.98),
        new THREE.MeshBasicMaterial({ color:0xfff8ec, transparent:true, opacity:0.5 }));
      glow.position.z = -0.18; wall.add(glow);

      // wood lattice (kumiko) — thin instanced bars over the paper. Vertical +
      // horizontal mullions create the classic shoji grid.
      const barMat = woodMat([1,4]);
      const VCOLS = Math.max(3, Math.round(width/0.95));
      const HROWS = Math.max(2, Math.round(height/0.95));
      const vGeo = new THREE.BoxGeometry(0.035, height, 0.05);
      const hGeo = new THREE.BoxGeometry(width, 0.035, 0.05);
      const vMesh = new THREE.InstancedMesh(vGeo, barMat, VCOLS+1);
      const hMesh = new THREE.InstancedMesh(hGeo, barMat, HROWS+1);
      const dum = new THREE.Object3D();
      for(let i=0;i<=VCOLS;i++){
        dum.position.set(-width/2 + (i/VCOLS)*width, 0, 0.04); dum.rotation.set(0,0,0);
        dum.updateMatrix(); vMesh.setMatrixAt(i, dum.matrix);
      }
      for(let j=0;j<=HROWS;j++){
        dum.position.set(0, -height/2 + (j/HROWS)*height, 0.04);
        dum.updateMatrix(); hMesh.setMatrixAt(j, dum.matrix);
      }
      vMesh.castShadow = false; hMesh.castShadow = false;
      wall.add(vMesh, hMesh);

      // heavy outer wood frame (post-and-beam stiles)
      const frame = woodMat([1,2]);
      const stileL = new THREE.Mesh(new THREE.BoxGeometry(0.14, height+0.14, 0.16), frame);
      stileL.position.set(-width/2-0.07, 0, 0.03); stileL.castShadow=true;
      const stileR = stileL.clone(); stileR.position.x = width/2+0.07;
      const railT = new THREE.Mesh(new THREE.BoxGeometry(width+0.28, 0.14, 0.16), frame);
      railT.position.set(0, height/2+0.07, 0.03); railT.castShadow=true;
      const railB = railT.clone(); railB.position.y = -height/2-0.07;
      wall.add(stileL, stileR, railT, railB);

      wall.position.set(cx, cy, cz); wall.rotation.y = ry;
      scene.add(wall);
      return wall;
    }

    // BACK shoji wall (the glowing north light directly behind the bank)
    buildShojiWall(ROOM_HALF*2+2, CEIL_Y-0.4, 0, (CEIL_Y-0.4)/2, WALL_Z, 0);
    // LEFT shoji wall — strongest light source side
    buildShojiWall(28, CEIL_Y-0.4, -ROOM_HALF-0.2, (CEIL_Y-0.4)/2, 2, Math.PI/2);
    // RIGHT shoji wall (dimmer side, no extra glow card emphasis but same build)
    buildShojiWall(28, CEIL_Y-0.4, ROOM_HALF+0.2, (CEIL_Y-0.4)/2, 2, -Math.PI/2);

    // CEILING — warm wood plank ceiling with exposed beams
    const ceilMap = WOOD_MAP.clone(); ceilMap.needsUpdate = true; ceilMap.repeat.set(10, 4);
    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 34),
      new THREE.MeshStandardMaterial({ map:ceilMap, color:0xbfa97f, roughness:0.8, metalness:0.0 }));
    ceil.position.set(0, CEIL_Y, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);

    // exposed dark beams across the ceiling
    const beamMat = new THREE.MeshStandardMaterial({ map:WOOD_MAP.clone(), color:0x8f7551, roughness:0.7, metalness:0.0 });
    beamMat.map.repeat.set(1,6); beamMat.map.needsUpdate = true;
    const beamGeo = new THREE.BoxGeometry(ROOM_HALF*2+4, 0.22, 0.34);
    for(let i=-3;i<=3;i++){
      const beam = new THREE.Mesh(beamGeo, beamMat);
      beam.position.set(0, CEIL_Y-0.12, i*3.4); beam.castShadow = true;
      scene.add(beam);
    }

    // CORNER POSTS — warm post-and-beam columns at the room corners
    const postGeo = new THREE.BoxGeometry(0.28, CEIL_Y, 0.28);
    const postMat = woodMat([1,5]);
    [[-ROOM_HALF+0.2, WALL_Z+0.3],[ROOM_HALF-0.2, WALL_Z+0.3],
     [-ROOM_HALF+0.2, 6.5],[ROOM_HALF-0.2, 6.5]].forEach(([px,pz])=>{
      const post = new THREE.Mesh(postGeo, postMat);
      post.position.set(px, CEIL_Y/2, pz); post.castShadow = true; post.receiveShadow = true;
      scene.add(post);
    });

    // a low wooden floor-runner / step beneath the bank to ground it
    const runner = new THREE.Mesh(new THREE.BoxGeometry(ROOM_HALF*2, 0.10, 1.4),
      new THREE.MeshStandardMaterial({ map:WOOD_MAP.clone(), color:0xb89a6e, roughness:0.62, metalness:0.0 }));
    runner.material.map.repeat.set(8,1); runner.material.map.needsUpdate = true;
    runner.position.set(0, 0.05, WALL_Z+1.5); runner.castShadow = true; runner.receiveShadow = true;
    scene.add(runner);
  })();

  // -------------------------------------------------------------------------
  // THE WING BANK — thin light-wood-framed boards, ~2" apart, side swivel.
  // (mechanic mirrors the established v3 reference; reskinned for the tea room)
  // -------------------------------------------------------------------------
  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.0;
  const N_TARGET = 50;

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

  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
  // light hinoki-wood frame, warm, low metalness (tea-house, not bronze)
  const frameMap = WOOD_MAP.clone(); frameMap.repeat.set(1,4); frameMap.needsUpdate = true;
  const frameMat = new THREE.MeshPhysicalMaterial({
    map:frameMap, color:0xd9c197, roughness:0.55, metalness:0.0,
    clearcoat:0.08, clearcoatRoughness:0.6, envMapIntensity:0.5
  });

  function placeholderFace(){
    return new THREE.MeshPhysicalMaterial({ color:0xe8e0cf, roughness:0.7, metalness:0.0, envMapIntensity:0.4 });
  }

  function buildBoard(idx){
    const g = new THREE.Group();
    // thin backing board (pale wood)
    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:0xe7ddc8, roughness:0.8, metalness:0.0 })
    );
    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;
    face.userData.idx = idx;
    g.add(face);

    // four thin light-wood frame bars (per-board so they swivel with the hinge)
    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 — a soft dimmed mirror of the featured face on the tatami.
  // Tatami is matte, so this is very subtle (a faint settled glow).
  // -------------------------------------------------------------------------
  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 featured 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);     // one clean full motif 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.62, metalness:0.0, clearcoat:0.06, clearcoatRoughness:0.6, envMapIntensity:0.3
      });
      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
  // -------------------------------------------------------------------------
  function arcZ(x){ return BANK_Z - Math.abs(x)*0.016; }    // gentle concavity

  // -------------------------------------------------------------------------
  // 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.45;

      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;

      // gentle calm tonal recede — neighbours sit a touch softer than the
      // featured board, but never plunge into darkness (this is a bright room)
      const litTarget = 0.74 + 0.26*Math.pow(openAmt, 1.2);
      if(b._lit===undefined) b._lit = litTarget;
      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }

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

    // soft tatami reflection tracks the featured board
    const front = boards[focusIdx];
    if(front){
      const fx = front.group.position.x + BOARD_W/2;
      if(front.faceMat && front.faceMat.map){
        reflMat.map = front.faceMat.map;
        reflMat.opacity += (0.07 - reflMat.opacity)*Math.min(1,dt*5);   // faint on matte mat
        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
      } 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.__TT_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 = `Setting ${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.__TT__ = {
      boards: ()=>boards.length,
      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
      focus: ()=>focusIdx,
      fps: ()=>window.__TT_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":"V17","label":"V17 — Japanese Tatami","elements":[
  {"n":1,"label":"Tea-house room — woven tatami floor, warm post-and-beam wood"},
  {"n":2,"label":"Shoji screen walls glowing with soft diffuse north light"},
  {"n":3,"label":"Thin light-wood framed boards, ~2\" apart on a side swivel"},
  {"n":4,"label":"Calm minimal neutral palette, subtle paper translucency"},
  {"n":5,"label":"Click a board → carousel-shift to center → full pattern face"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>