← back to Quadrille Showroom

public/proto/v9-walkin.html

491 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>WALK-IN ROOM — China Seas</title>
<style>
  :root { --ink:#2b2622; --paper:#f4efe7; }
  * { box-sizing:border-box; }
  html,body { margin:0; height:100%; overflow:hidden; background:#1a1714;
    font-family:"Hoefler Text", Georgia, "Times New Roman", serif; color:var(--paper); }
  #app { position:fixed; inset:0; }
  canvas { display:block; }

  /* top brand bar */
  .topbar { position:fixed; top:0; left:0; right:0; z-index:10;
    display:flex; align-items:center; justify-content:space-between;
    padding:14px 22px; pointer-events:none;
    background:linear-gradient(to bottom, rgba(20,17,14,.55), rgba(20,17,14,0)); }
  .brand { letter-spacing:.32em; font-size:12px; text-transform:uppercase; opacity:.9; }
  .now { text-align:right; font-size:11px; letter-spacing:.06em; line-height:1.5; opacity:.85; max-width:46%; }
  .now b { display:block; letter-spacing:.04em; font-size:13px; }

  /* bottom design switcher strip */
  .strip { position:fixed; left:0; right:0; bottom:0; z-index:10;
    padding:14px 16px 18px; display:flex; gap:12px; align-items:flex-end;
    overflow-x:auto; overflow-y:hidden;
    background:linear-gradient(to top, rgba(20,17,14,.82), rgba(20,17,14,0)); }
  .strip::-webkit-scrollbar { height:6px; }
  .strip::-webkit-scrollbar-thumb { background:rgba(244,239,231,.25); border-radius:3px; }
  .swatch { flex:0 0 auto; width:78px; cursor:pointer; text-align:center;
    opacity:.72; transition:opacity .2s, transform .2s; }
  .swatch:hover { opacity:1; transform:translateY(-3px); }
  .swatch.active { opacity:1; }
  .swatch img { width:78px; height:78px; object-fit:cover; border-radius:6px;
    border:2px solid transparent; background:#3a342d; display:block; }
  .swatch.active img { border-color:var(--paper); box-shadow:0 6px 18px rgba(0,0,0,.5); }
  .swatch span { display:block; margin-top:5px; font-size:9.5px; line-height:1.25;
    letter-spacing:.02em; opacity:.85; max-height:24px; overflow:hidden;
    text-transform:uppercase; }

  .hint { position:fixed; left:50%; top:54%; transform:transl(-50%,-50%);
    transform:translateX(-50%); z-index:9; pointer-events:none;
    font-size:11px; letter-spacing:.18em; text-transform:uppercase;
    opacity:.0; transition:opacity 1s; text-shadow:0 1px 6px rgba(0,0,0,.6); }
  .hint.show { opacity:.55; }

  .loading { position:fixed; inset:0; z-index:20; display:flex;
    align-items:center; justify-content:center; flex-direction:column; gap:14px;
    background:#1a1714; transition:opacity .6s; }
  .loading.gone { opacity:0; pointer-events:none; }
  .ring { width:34px; height:34px; border:2px solid rgba(244,239,231,.2);
    border-top-color:var(--paper); border-radius:50%; animation:spin 1s linear infinite; }
  @keyframes spin { to { transform:rotate(360deg); } }
  .loading p { letter-spacing:.28em; text-transform:uppercase; font-size:11px; opacity:.7; }
</style>
</head>
<body>
  <div id="app"></div>

  <div class="topbar">
    <div class="brand">China&nbsp;Seas &nbsp;·&nbsp; Walk-In Room</div>
    <div class="now" id="now"><b>—</b><span id="meta"></span></div>
  </div>

  <div class="hint" id="hint">Drag to look around the room</div>

  <div class="strip" id="strip"></div>

  <div class="loading" id="loading"><div class="ring"></div><p>Papering the room…</p></div>

  <script src="./three.min.js"></script>
  <script src="./OrbitControls.js"></script>
  <script>
  (function(){
    "use strict";
    const FT = 0.3048;            // 1 ft in metres
    const REPEAT_IN = 27;          // architectural wallpaper repeat (inches)
    const REPEAT_M = REPEAT_IN * 0.0254;  // ~0.6858 m

    // Room dimensions (a real-feeling living room): 16ft W x 13ft D x 9ft H
    const W = 16 * FT, D = 13 * FT, H = 9 * FT;
    const EYE = 1.62;              // standing eye height

    const app = document.getElementById('app');

    // ---- renderer ----
    const renderer = new THREE.WebGLRenderer({ antialias:true });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
    renderer.setSize(window.innerWidth, window.innerHeight);
    renderer.shadowMap.enabled = true;
    renderer.shadowMap.type = THREE.PCFSoftShadowMap;
    if ('outputColorSpace' in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
    else if ('outputEncoding' in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
    renderer.toneMapping = THREE.ACESFilmicToneMapping;
    renderer.toneMappingExposure = 1.05;
    app.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    scene.background = new THREE.Color(0x2a2520);

    // ---- camera INSIDE the room, near the back wall, looking across ----
    const camera = new THREE.PerspectiveCamera(72, window.innerWidth/window.innerHeight, 0.05, 100);
    camera.position.set(0, EYE, D*0.30);   // standing a bit back from centre

    // OrbitControls used as a "look around from inside" rig: target sits just
    // in front of the eye, so dragging rotates the view, not an exterior orbit.
    const controls = new THREE.OrbitControls(camera, renderer.domElement);
    controls.target.set(-W*0.32, EYE, -D*0.5);   // look toward far wall + window corner
    controls.enablePan = false;
    controls.enableZoom = false;           // it's a room, not a model — no zoom
    controls.rotateSpeed = -0.32;          // negative = natural "look" direction
    controls.minPolarAngle = THREE.MathUtils.degToRad(58);
    controls.maxPolarAngle = THREE.MathUtils.degToRad(118);
    controls.enableDamping = true;
    controls.dampingFactor = 0.08;
    // keep the pivot pinned to the eye every frame (first-person look)
    function lockPivot(){
      const dir = new THREE.Vector3();
      camera.getWorldDirection(dir);
      controls.target.copy(camera.position).add(dir.multiplyScalar(0.6));
    }

    // ================= LIGHTING (warm, soft) =================
    const hemi = new THREE.HemisphereLight(0xfff3e0, 0x3a2f26, 0.42);
    scene.add(hemi);
    const ambient = new THREE.AmbientLight(0xffe8cf, 0.26);
    scene.add(ambient);

    // Daylight pouring through the window (key light), warm + directional so the
    // walls carry a light->shadow gradient (depth cue: you're in a real lit room)
    const sun = new THREE.DirectionalLight(0xfff1da, 1.55);
    sun.position.set(-W*0.55, H*0.9, D*0.55);
    sun.castShadow = true;
    sun.shadow.mapSize.set(1024,1024);
    sun.shadow.camera.near = 0.5; sun.shadow.camera.far = 30;
    sun.shadow.camera.left = -W; sun.shadow.camera.right = W;
    sun.shadow.camera.top = H; sun.shadow.camera.bottom = -H;
    sun.shadow.bias = -0.0004;
    scene.add(sun);

    // soft warm fill bounce from the room interior
    const fill = new THREE.PointLight(0xffd9a8, 0.45, 18, 2);
    fill.position.set(W*0.25, H*0.7, D*0.1);
    scene.add(fill);
    // gentle lamp glow near the sofa
    const lamp = new THREE.PointLight(0xffcaa0, 0.55, 9, 2);
    lamp.position.set(W*0.32, 1.4, -D*0.30);
    scene.add(lamp);
    // bright daylight spill right at the window so that wall glows (falloff = depth)
    const dayspill = new THREE.PointLight(0xfff4e0, 0.85, 7, 2);
    dayspill.position.set(-W*0.5 + 0.4, 1.4, -D*0.10);
    scene.add(dayspill);

    // ================= ROOM SHELL =================
    const tex = { loader: new THREE.TextureLoader() };
    tex.loader.crossOrigin = 'anonymous';

    // CONTRARIAN FIX — a procedural PAPER bump so the wall has surface tooth: the
    // warm window key grazes it and catches micro-relief, instead of reading as a
    // flat decal pasted on cardboard. China Seas is hand screen-printed on paper.
    function paperBumpTex(){
      const c=document.createElement('canvas'); c.width=c.height=256;
      const g=c.getContext('2d');
      g.fillStyle='#808080'; g.fillRect(0,0,256,256);
      const img=g.getImageData(0,0,256,256); const d=img.data;
      for(let i=0;i<d.length;i+=4){ const n=128+(Math.random()-0.5)*46;  // fine fibre grain
        d[i]=d[i+1]=d[i+2]=n; }
      g.putImageData(img,0,0);
      // a few faint long fibres for a laid-paper feel
      g.globalAlpha=0.05; g.strokeStyle='#fff';
      for(let k=0;k<40;k++){ g.beginPath(); const y=Math.random()*256;
        g.moveTo(0,y); g.lineTo(256,y+(Math.random()-0.5)*6); g.stroke(); }
      const t=new THREE.CanvasTexture(c);
      t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(8,8);
      return t;
    }
    const PAPER_BUMP=paperBumpTex();

    // wall material starts neutral; texture swapped in on load.
    // vertexColors:true lets the baked corner-AO (dark toward edges) multiply the
    // wallpaper map in-shader → soft corner occlusion with zero floating planes.
    const wallMat = new THREE.MeshStandardMaterial({ color:0xece4d6, roughness:0.94, metalness:0.0,
      side:THREE.FrontSide, vertexColors:true, bumpMap:PAPER_BUMP, bumpScale:0.012 });

    // CONTRARIAN FIX — bake ambient occlusion into a wall's vertices: darken toward
    // the two vertical corner seams and the floor/ceiling so the room reads as lit,
    // not a flat-bright plastic box. Subdivided so the falloff is smooth. The AO is
    // MODULATED by window proximity (windowFn returns 0..1 brightness boost) so the
    // shade AGREES with the warm key light from the left-wall window instead of
    // fighting it — corners near the window stay light, far corners go deeper.
    function addCornerAO(geo, w, h, windowFn){
      const pos=geo.attributes.position; const cols=[]; const col=new THREE.Color();
      for(let i=0;i<pos.count;i++){
        const x=pos.getX(i), y=pos.getY(i);
        const fx=(x+w/2)/w, fy=(y+h/2)/h;            // 0..1 across / up the wall
        const edgeX=Math.min(fx,1-fx)*2;             // 0 at vertical corner → 1 mid
        const edgeY=Math.min(fy,1-fy)*2;             // 0 at floor/ceil → 1 mid
        const aoX=Math.max(0, 1-edgeX/0.34);
        const aoY=Math.max(0, 1-edgeY/0.20);
        let ao=Math.min(0.55, aoX*0.42 + aoY*0.30 + aoX*aoY*0.25);
        if(windowFn){ ao *= windowFn(fx); }          // lift AO near the window
        const v=1-ao; col.setRGB(v,v,v); cols.push(col.r,col.g,col.b);
      }
      geo.setAttribute('color', new THREE.Float32BufferAttribute(cols,3));
    }

    // returns 0..1 AO strength across a wall given which local-x end faces the
    // window (left wall). nearEnd in {0,1}: which fx is closest to the window.
    function windowAO(nearEnd){
      return (fx)=>{
        const distToWindow = nearEnd===0 ? fx : (1-fx);   // 0 at window end
        return THREE.MathUtils.clamp(0.35 + distToWindow*0.95, 0.35, 1.0);
      };
    }

    function wall(w, h, windowFn){
      const g = new THREE.PlaneGeometry(w, h, 24, 24);
      addCornerAO(g, w, h, windowFn);
      const m = wallMat.clone();
      m.userData.wallW = w;   // physical width of this wall (m)
      return new THREE.Mesh(g, m);
    }

    const walls = [];
    // back wall (-Z): local +x → world +x, so fx=0 (world -W/2) is the window side
    let m = wall(W, H, windowAO(0)); m.position.set(0, H/2, -D/2); walls.push(m);
    // front wall (+Z, rotated π): local +x → world -x, so fx=1 is the window side
    m = wall(W, H, windowAO(1)); m.position.set(0, H/2, D/2); m.rotation.y = Math.PI; walls.push(m);
    // left wall (-X) = THE WINDOW WALL: keep it bright (window light washes it)
    m = wall(D, H, ()=>0.30); m.position.set(-W/2, H/2, 0); m.rotation.y = Math.PI/2; walls.push(m);
    // right wall (+X) = farthest from the window: full AO
    m = wall(D, H, ()=>1.0); m.position.set(W/2, H/2, 0); m.rotation.y = -Math.PI/2; walls.push(m);
    walls.forEach(w => { w.receiveShadow = true; scene.add(w); });

    // ceiling — soft off-white
    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(W, D),
      new THREE.MeshStandardMaterial({ color:0xf3ece0, roughness:1 }));
    ceil.rotation.x = Math.PI/2; ceil.position.y = H; ceil.receiveShadow = true; scene.add(ceil);

    // floor — warm wood tone
    const floor = new THREE.Mesh(new THREE.PlaneGeometry(W, D),
      new THREE.MeshStandardMaterial({ color:0x6b4a31, roughness:0.78 }));
    floor.rotation.x = -Math.PI/2; floor.position.y = 0; floor.receiveShadow = true; scene.add(floor);

    // baseboard + crown trim (white) to frame the paper like a real install
    const trimMat = new THREE.MeshStandardMaterial({ color:0xf6f1e8, roughness:0.6 });
    function trimRing(y, h){
      const grp = new THREE.Group();
      const t = 0.02;
      const spec = [
        [W, 0, -D/2, 0], [W, 0, D/2, 0],
        [D, -W/2, 0, Math.PI/2], [D, W/2, 0, Math.PI/2],
      ];
      spec.forEach(([len,x,z,ry])=>{
        const b = new THREE.Mesh(new THREE.BoxGeometry(len, h, t+0.01), trimMat);
        b.position.set(x, y, z); b.rotation.y = ry; b.castShadow=false; grp.add(b);
      });
      return grp;
    }
    scene.add(trimRing(0.05, 0.10));      // baseboard
    scene.add(trimRing(H-0.06, 0.12));    // crown

    // ================= WINDOW (light source, on left wall) =================
    (function(){
      const ww = 1.6, wh = 1.5, wy = 1.25, wx = -W/2 + 0.012;
      // bright daylight panel (emissive so it reads as a window)
      const glass = new THREE.Mesh(new THREE.PlaneGeometry(ww, wh),
        new THREE.MeshStandardMaterial({ color:0xfdf6e8, emissive:0xfff2d6, emissiveIntensity:1.2, roughness:1 }));
      glass.position.set(wx, wy, -D*0.10); glass.rotation.y = Math.PI/2; scene.add(glass);
      // white frame
      const fr = new THREE.Mesh(new THREE.PlaneGeometry(ww+0.16, wh+0.16),
        new THREE.MeshStandardMaterial({ color:0xf6f1e8, roughness:0.5 }));
      fr.position.set(wx+0.002, wy, -D*0.10); fr.rotation.y = Math.PI/2; scene.add(fr);
      // mullions
      const mull = new THREE.MeshStandardMaterial({ color:0xeae3d6 });
      const v = new THREE.Mesh(new THREE.BoxGeometry(0.03, wh, 0.03), mull);
      v.position.set(wx-0.01, wy, -D*0.10); scene.add(v);
      const hbar = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, ww), mull);
      hbar.position.set(wx-0.01, wy, -D*0.10); scene.add(hbar);
    })();

    // ================= CONTACT SHADOWS + CORNER AO (CONTRARIAN FIX) =========
    // Real photos read as real because objects are anchored to the floor by a soft
    // contact shadow, and room corners fall into ambient-occlusion darkness. The
    // hardware shadow map alone leaves a "plastic box" look (hard-edged, no corner
    // falloff), so we bake soft radial/edge gradients and lay them over the floor.
    function radialShadowTex(soft){
      const c=document.createElement('canvas'); c.width=c.height=256;
      const g=c.getContext('2d');
      const grd=g.createRadialGradient(128,128,8, 128,128,128);
      grd.addColorStop(0,`rgba(0,0,0,${soft?0.42:0.6})`);
      grd.addColorStop(0.55,`rgba(0,0,0,${soft?0.18:0.28})`);
      grd.addColorStop(1,'rgba(0,0,0,0)');
      g.fillStyle=grd; g.fillRect(0,0,256,256);
      const t=new THREE.CanvasTexture(c);
      if('colorSpace' in t) t.colorSpace=THREE.SRGBColorSpace;
      return t;
    }
    const SHADOW_TEX = radialShadowTex(false);
    function contactShadow(x,z,w,d,opacity){
      const m=new THREE.Mesh(new THREE.PlaneGeometry(w,d),
        new THREE.MeshBasicMaterial({ map:SHADOW_TEX, transparent:true,
          opacity:opacity==null?0.85:opacity, depthWrite:false,
          blending:THREE.MultiplyBlending, polygonOffset:true, polygonOffsetFactor:-2 }));
      m.rotation.x=-Math.PI/2; m.position.set(x,0.006,z); m.renderOrder=2; scene.add(m);
      return m;
    }

    // Floor perimeter AO — a soft floor overlay darkening toward the floor/wall
    // seams. Kept GENTLE (low opacity, short reach) so it does NOT stack with the
    // furniture contact shadows into a crushed-black wedge — the combined floor
    // darkness must read as soft shade, never a void.
    (function cornerAO(){
      const c=document.createElement('canvas'); c.width=c.height=512;
      const g=c.getContext('2d');
      g.fillStyle='rgba(0,0,0,0)'; g.fillRect(0,0,512,512);
      // four edge gradients darkening inward from each wall (softer + shorter reach)
      const edge=(x0,y0,x1,y1)=>{ const gr=g.createLinearGradient(x0,y0,x1,y1);
        gr.addColorStop(0,'rgba(0,0,0,0.26)'); gr.addColorStop(0.30,'rgba(0,0,0,0.07)');
        gr.addColorStop(1,'rgba(0,0,0,0)'); g.fillStyle=gr; g.fillRect(0,0,512,512); };
      edge(0,0,150,0); edge(512,0,362,0); edge(0,0,0,150); edge(0,512,0,362);
      const t=new THREE.CanvasTexture(c);
      if('colorSpace' in t) t.colorSpace=THREE.SRGBColorSpace;
      const ao=new THREE.Mesh(new THREE.PlaneGeometry(W,D),
        new THREE.MeshBasicMaterial({ map:t, transparent:true, opacity:0.7,
          depthWrite:false, blending:THREE.MultiplyBlending }));
      ao.rotation.x=-Math.PI/2; ao.position.set(0,0.003,0); ao.renderOrder=1; scene.add(ao);
    })();

    // Wall-corner AO is baked directly into the wall geometry as VERTEX COLORS
    // (see addCornerAO below, applied when each wall is built) — darkening toward
    // the vertical corner seams + the floor/ceiling edges. This multiplies with the
    // wallpaper texture in-shader, so there are NO floating planes / black-bar
    // artifacts; the corners simply fall into soft shade like a real lit room.

    // ================= FURNITURE silhouette =================
    (function sofa(){
      const grp = new THREE.Group();
      const mat = new THREE.MeshStandardMaterial({ color:0xcfc4b0, roughness:0.95 });
      const base = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.45, 0.95), mat);
      base.position.y = 0.32; base.castShadow = true; base.receiveShadow=true; grp.add(base);
      const back = new THREE.Mesh(new THREE.BoxGeometry(2.2, 0.7, 0.22), mat);
      back.position.set(0, 0.62, -0.36); back.castShadow=true; grp.add(back);
      const seat = new THREE.Mesh(new THREE.BoxGeometry(2.05, 0.18, 0.78), new THREE.MeshStandardMaterial({color:0xe7ddc8, roughness:1}));
      seat.position.set(0, 0.58, 0.04); seat.castShadow=true; grp.add(seat);
      [-1.0,1.0].forEach(x=>{ const a=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.6,0.95),mat); a.position.set(x,0.45,0); a.castShadow=true; grp.add(a); });
      const sofaZ = -D/2 + 0.78;
      grp.position.set(0, 0, sofaZ);  // against the back wall
      grp.rotation.y = 0;
      scene.add(grp);
      // CONTRARIAN FIX — soft contact shadow grounding the sofa to the floor so it
      // doesn't float like a plastic prop. Kept soft (0.6) so it doesn't stack with
      // the floor perimeter AO into a crushed-black wedge; offset forward of the
      // back wall so the two shadows sit side-by-side, not on top of each other.
      contactShadow(0, sofaZ+0.30, 3.0, 1.5, 0.6);
      // round rug
      const rug = new THREE.Mesh(new THREE.CircleGeometry(1.7, 48),
        new THREE.MeshStandardMaterial({ color:0xb9a88c, roughness:1 }));
      rug.rotation.x = -Math.PI/2; rug.position.set(0, 0.012, -D/2 + 1.7); rug.receiveShadow=true; scene.add(rug);
      // soft shadow seating the rug onto the floor
      contactShadow(0, -D/2+1.9, 3.9, 3.9, 0.4);
      // a floor lamp by the sofa (gives the lamp glow a body)
      const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.02,0.02,1.5,12), new THREE.MeshStandardMaterial({color:0x3a3026}));
      pole.position.set(W*0.32, 0.75, -D*0.30); scene.add(pole);
      const shade = new THREE.Mesh(new THREE.CylinderGeometry(0.18,0.24,0.28,20),
        new THREE.MeshStandardMaterial({color:0xfff1d8, emissive:0xffe6c0, emissiveIntensity:0.6, roughness:1}));
      shade.position.set(W*0.32, 1.5, -D*0.30); scene.add(shade);
      // contact shadow pooling under the floor lamp base
      contactShadow(W*0.32, -D*0.30, 0.7, 0.7, 0.8);
    })();

    // ================= WALLPAPER TEXTURE =================
    let designs = [];
    let active = -1;

    function applyTexture(t){
      t.wrapS = t.wrapT = THREE.RepeatWrapping;
      if ('colorSpace' in t) t.colorSpace = THREE.SRGBColorSpace;
      else if ('encoding' in t) t.encoding = THREE.sRGBEncoding;
      t.anisotropy = renderer.capabilities.getMaxAnisotropy();
      // The tile image is one horizontal repeat (27") wide; its physical HEIGHT
      // is 27" * (imgH/imgW). Respect that aspect so the pattern isn't squashed.
      const iw = (t.image && t.image.width)  || 2000;
      const ih = (t.image && t.image.height) || 2500;
      const tileW = REPEAT_M;                 // 27" wide
      const tileH = REPEAT_M * (ih / iw);     // proportional height
      walls.forEach(w=>{
        const tt = t.clone(); tt.needsUpdate = true;
        tt.wrapS = tt.wrapT = THREE.RepeatWrapping;
        if ('colorSpace' in tt) tt.colorSpace = THREE.SRGBColorSpace;
        tt.anisotropy = t.anisotropy;
        // repeats = wall_dimension / physical_tile_dimension
        tt.repeat.set(w.material.userData.wallW / tileW, H / tileH);
        w.material.map = tt;
        w.material.color.set(0xffffff);
        w.material.needsUpdate = true;
      });
    }

    function pickDesign(i){
      const d = designs[i]; if(!d) return;
      active = i;
      document.querySelectorAll('.swatch').forEach((el,k)=> el.classList.toggle('active', k===i));
      document.getElementById('now').querySelector('b').textContent = d.pattern_name || d.sku;
      document.getElementById('meta').textContent =
        (d.collection || 'China Seas') + (d.repeat ? ' · ' + d.repeat + '" repeat' : '');
      const url = d.tile || d.image;
      tex.loader.load(url, applyTexture, undefined, (e)=>{
        // fallback to the flat product image if tile fails
        if (url !== d.image && d.image) tex.loader.load(d.image, applyTexture);
        else console.warn('texture load failed', url, e);
      });
    }

    function buildStrip(){
      const strip = document.getElementById('strip');
      strip.innerHTML = '';
      designs.forEach((d,i)=>{
        const el = document.createElement('div');
        el.className = 'swatch';
        el.innerHTML = '<img alt="" src="'+(d.tile||d.image)+'"><span>'+
          ((d.pattern_name||d.sku).split(/[-–]/)[0].trim())+'</span>';
        el.addEventListener('click', ()=> pickDesign(i));
        strip.appendChild(el);
      });
    }

    // ---- boot ----
    fetch('/api/showroom/products?limit=24')
      .then(r=>r.json())
      .then(j=>{
        const list = (j.products||j||[]).filter(p=> p.tile || p.image);
        designs = list.slice(0, 18);
        if (!designs.length) throw new Error('no designs');
        buildStrip();
        // preload first then reveal
        const first = designs[0];
        tex.loader.load(first.tile || first.image, (t)=>{
          applyTexture(t);
          active = 0;
          pickDesign(0);
          reveal();
        }, undefined, ()=>{ pickDesign(0); reveal(); });
      })
      .catch(err=>{
        console.error('boot error', err);
        // still show the room with the neutral wall so it's not a black screen
        reveal();
      });

    function reveal(){
      const ld = document.getElementById('loading');
      ld.classList.add('gone');
      const hint = document.getElementById('hint');
      hint.classList.add('show');
      setTimeout(()=> hint.classList.remove('show'), 4200);
      setTimeout(()=> ld.remove(), 700);
    }

    // ---- resize ----
    window.addEventListener('resize', ()=>{
      camera.aspect = window.innerWidth/window.innerHeight;
      camera.updateProjectionMatrix();
      renderer.setSize(window.innerWidth, window.innerHeight);
    });

    // ---- render loop ----
    let t0 = performance.now();
    function tick(){
      requestAnimationFrame(tick);
      controls.update();
      lockPivot();          // keep first-person pivot at the eye
      renderer.render(scene, camera);
    }
    tick();

    // expose for verification
    window.__walkin = {
      ready: ()=> active >= 0 && walls[0].material.map != null,
      designs: ()=> designs.length,
      active: ()=> active,
    };
  })();
  </script>

<script>window.PROTO_META={"key":"V9","label":"V9 — Walk-In Room","elements":[{"n":1,"label":"Stand INSIDE a 1:1 room fully papered in the design"},{"n":2,"label":"Look around (drag) from the centre of the room"},{"n":3,"label":"Switch the wallpaper to any design"},{"n":4,"label":"Now-viewing pattern + colorway read-out"},{"n":5,"label":"Real room scale (window, floor, ceiling)"}]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>