← back to Quadrille Showroom
auto-save: 2026-06-29T06:19:10 (4 files) — public/proto/v13-atrium-daylight.html public/proto/v14-velvet-lounge.html public/proto/v15-brutalist-concrete.html public/proto/v17-tatami.html
607b67f994bceaec317340d14ef972ba7ae1e992 · 2026-06-29 06:19:13 -0700 · Steve Abrams
Files touched
A public/proto/v13-atrium-daylight.htmlA public/proto/v14-velvet-lounge.htmlA public/proto/v15-brutalist-concrete.htmlA public/proto/v17-tatami.html
Diff
commit 607b67f994bceaec317340d14ef972ba7ae1e992
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Mon Jun 29 06:19:13 2026 -0700
auto-save: 2026-06-29T06:19:10 (4 files) — public/proto/v13-atrium-daylight.html public/proto/v14-velvet-lounge.html public/proto/v15-brutalist-concrete.html public/proto/v17-tatami.html
---
public/proto/v13-atrium-daylight.html | 646 +++++++++++++++++++++++++++
public/proto/v14-velvet-lounge.html | 679 +++++++++++++++++++++++++++++
public/proto/v15-brutalist-concrete.html | 685 +++++++++++++++++++++++++++++
public/proto/v17-tatami.html | 723 +++++++++++++++++++++++++++++++
4 files changed, 2733 insertions(+)
diff --git a/public/proto/v13-atrium-daylight.html b/public/proto/v13-atrium-daylight.html
new file mode 100644
index 0000000..3d2cfd1
--- /dev/null
+++ b/public/proto/v13-atrium-daylight.html
@@ -0,0 +1,646 @@
+<!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>ATRIUM DAYLIGHT · China Seas</title>
+<style>
+ :root { --ink:#2b2b28; --paper:#fbf9f3; --accent:#8a7a55; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#eaf1f6; color:var(--ink);
+ 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:34px 30px 56px;
+ pointer-events:none; text-align:center;
+ background:linear-gradient(180deg, rgba(251,249,243,.9) 0%, rgba(251,249,243,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+ color:#9a8c66; font-weight:600; }
+ .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+ font-weight:400; letter-spacing:.1em; line-height:1; margin-top:9px; color:#34322c; }
+ .masthead h1 b { font-weight:700; color:#8a7a55; }
+ .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.04em; color:#6f6a5e;
+ max-width:560px; margin-left:auto; margin-right:auto; }
+
+ /* ---- focused board caption ---- */
+ #caption { position:fixed; left:50%; bottom:84px; 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:.01em; color:#2f2d27;
+ text-shadow:0 1px 14px rgba(255,255,255,.8); }
+ #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#9a8c66; }
+ #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#6a6458; }
+ #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+ border:1px solid rgba(138,122,85,.55); border-radius:28px; color:#6e5f3d; font-size:10.5px;
+ letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+ background:rgba(138,122,85,.08); transition:background .15s, transform .15s; }
+ #caption a.cta:hover { background:rgba(138,122,85,.2); transform:translateY(-1px); }
+
+ .hint { position:fixed; bottom:28px; left:50%; transform:translateX(-50%); z-index:20;
+ font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(70,66,56,.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:#6e5f3d; background:rgba(255,255,255,.6); border:1px solid rgba(138,122,85,.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:linear-gradient(180deg,#dcebf4 0%, #f3f6f1 58%, #efe9da 100%); transition:opacity .9s; }
+ #boot .ring { width:44px; height:44px; border-radius:50%;
+ border:2px solid rgba(138,122,85,.22); border-top-color:#8a7a55; animation:spin 1s linear infinite; }
+ #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#857a60; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+
+ @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:28px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+ <div class="kicker">Designer Wallcoverings · The Daylight Atrium</div>
+ <h1>ATRIUM <b>DAYLIGHT</b></h1>
+ <div class="sub">China Seas in a glass atrium — sun pouring across pale stone, soft shafts and long
+ shadows. One board steps forward at a time, full pattern edge to edge. 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 atrium…</div></div>
+
+<!-- r128 core (local). NOTE: r128 three.min.js has no UnrealBloomPass; this look
+ relies on the bright PMREM environment for highlights, not postprocessing. -->
+<script src="./three.min.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.05; // bright but held back from blow-out
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.setClearColor(0xdfeaf2, 1);
+ app.appendChild(renderer.domElement);
+ const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+ const scene = new THREE.Scene();
+ // airy haze for atmospheric depth — light, not murky
+ scene.fog = new THREE.Fog(0xeaf1f6, 22, 58);
+
+ const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 220);
+ camera.position.set(0, 1.62, 9.4);
+ camera.lookAt(0, 1.6, 0);
+
+ // -------------------------------------------------------------------------
+ // SKY-GRADIENT ENVIRONMENT — a procedural luminous sky dome baked through
+ // PMREM so every gloss surface picks up soft daylight (IBL). Doubles as the
+ // visible backdrop sky so the atrium reads as glass-open to a bright sky.
+ // -------------------------------------------------------------------------
+ function makeSkyTexture(){
+ const c = document.createElement("canvas"); c.width = 16; c.height = 256;
+ const x = c.getContext("2d");
+ const g = x.createLinearGradient(0,0,0,256);
+ g.addColorStop(0.00, "#bcd9ee"); // high sky — soft cyan-blue
+ g.addColorStop(0.42, "#dfeaf2"); // pale horizon haze
+ g.addColorStop(0.62, "#f4f1e8"); // warm light band where sun pools
+ g.addColorStop(1.00, "#efe7d6"); // warm ground glow
+ x.fillStyle = g; x.fillRect(0,0,16,256);
+ const t = new THREE.CanvasTexture(c);
+ if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
+ t.needsUpdate = true;
+ return t;
+ }
+ // big inverted sky sphere as the visible backdrop
+ (function buildSkyDome(){
+ const tex = makeSkyTexture();
+ const sky = new THREE.Mesh(
+ new THREE.SphereGeometry(120, 32, 24),
+ new THREE.MeshBasicMaterial({ map:tex, side:THREE.BackSide, fog:false, toneMapped:true })
+ );
+ scene.add(sky);
+ })();
+ // PMREM IBL from a bright gradient room scene → light wraps onto everything
+ (function buildEnv(){
+ try {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ // a simple bright box whose walls glow the sky palette = a daylight studio env
+ const envScene = new THREE.Scene();
+ const tex = makeSkyTexture();
+ const dome = new THREE.Mesh(
+ new THREE.SphereGeometry(40, 24, 16),
+ new THREE.MeshBasicMaterial({ map:tex, side:THREE.BackSide })
+ );
+ envScene.add(dome);
+ // a bright soft area light proxy (emissive panel) gives a hot key reflection
+ const sunPanel = new THREE.Mesh(
+ new THREE.PlaneGeometry(26, 26),
+ new THREE.MeshBasicMaterial({ color:0xffffff })
+ );
+ sunPanel.position.set(10, 16, 8); sunPanel.lookAt(0,0,0);
+ envScene.add(sunPanel);
+ const envMap = pmrem.fromScene(envScene, 0.02).texture;
+ scene.environment = envMap;
+ pmrem.dispose();
+ } catch(e){ /* env optional */ }
+ })();
+
+ // -------------------------------------------------------------------------
+ // LIGHTING — bright, airy daylight. Strong soft sky ambient + hemisphere
+ // (sky/ground bounce) + a warm low-angle SUN that throws long soft shadows
+ // across the pale stone. Cool fill keeps shadows from going muddy.
+ // -------------------------------------------------------------------------
+ // a touch less flat ambient so the warm sun can carve real (soft) shadow depth
+ scene.add(new THREE.AmbientLight(0xeaf2fb, 0.4));
+ const hemi = new THREE.HemisphereLight(0xcfe4f5, 0xe9e0cd, 0.78); // sky cyan over warm-stone bounce
+ hemi.position.set(0, 20, 0);
+ scene.add(hemi);
+
+ // the SUN — warm, low and raking from upper-left so shadows stretch long & soft
+ const sun = new THREE.DirectionalLight(0xfff4dc, 2.35);
+ sun.position.set(-11, 8.5, 7);
+ sun.castShadow = true;
+ sun.shadow.mapSize.set(2048, 2048);
+ sun.shadow.camera.near = 1; sun.shadow.camera.far = 50;
+ sun.shadow.camera.left = -16; sun.shadow.camera.right = 16;
+ sun.shadow.camera.top = 14; sun.shadow.camera.bottom = -6;
+ sun.shadow.bias = -0.0004; sun.shadow.radius = 7; // soft penumbra
+ scene.add(sun);
+ const sunTarget = new THREE.Object3D(); sunTarget.position.set(2, 1, -3); scene.add(sunTarget);
+ sun.target = sunTarget;
+
+ // gentle cool fill from camera-right so the shadow side still reads
+ const fill = new THREE.DirectionalLight(0xbcd3ea, 0.35);
+ fill.position.set(8, 5, 6); scene.add(fill);
+
+ // -------------------------------------------------------------------------
+ // ROOM — glass atrium: pale stone floor (clearcoat sheen), bright walls, a
+ // mullioned glass ceiling/back that lets the sky read through.
+ // -------------------------------------------------------------------------
+ const FLOOR_Y = 0;
+
+ // procedural pale-stone roughness/tint (canvas → texture, $0 local)
+ function makeStoneRoughness(){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const x = c.getContext("2d");
+ x.fillStyle = "#b8b8b8"; x.fillRect(0,0,256,256);
+ const img = x.getImageData(0,0,256,256); const d = img.data;
+ for(let i=0;i<d.length;i+=4){ const n=(Math.random()*46)|0; const v=150+n; d[i]=d[i+1]=d[i+2]=v; }
+ x.putImageData(img,0,0);
+ // faint slab seams (limestone tiles) — slightly rougher grout lines
+ x.strokeStyle = "rgba(90,90,90,0.5)"; x.lineWidth = 2;
+ for(let i=0;i<=256;i+=64){ x.beginPath(); x.moveTo(i,0); x.lineTo(i,256); x.stroke();
+ x.beginPath(); x.moveTo(0,i); x.lineTo(256,i); x.stroke(); }
+ const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+ }
+ function makeStoneColor(){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const x = c.getContext("2d");
+ x.fillStyle = "#ece7da"; x.fillRect(0,0,256,256); // warm pale limestone
+ const img = x.getImageData(0,0,256,256); const d = img.data;
+ for(let i=0;i<d.length;i+=4){ const n=(Math.random()*16-8)|0;
+ d[i]=clamp(236+n,210,250); d[i+1]=clamp(231+n,205,248); d[i+2]=clamp(218+n,195,238); }
+ x.putImageData(img,0,0);
+ x.strokeStyle = "rgba(150,142,124,0.45)"; x.lineWidth = 1.5;
+ for(let i=0;i<=256;i+=64){ x.beginPath(); x.moveTo(i,0); x.lineTo(i,256); x.stroke();
+ x.beginPath(); x.moveTo(0,i); x.lineTo(256,i); x.stroke(); }
+ const t = new THREE.CanvasTexture(c);
+ if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
+ t.anisotropy = MAX_ANISO; return t;
+ }
+
+ (function buildRoom(){
+ // PALE STONE FLOOR — clearcoat physical gives a soft daylight sheen + long
+ // shadows land legibly on it. Tiled limestone color + roughness.
+ const stoneCol = makeStoneColor();
+ const stoneRgh = makeStoneRoughness();
+ stoneCol.wrapS = stoneCol.wrapT = THREE.RepeatWrapping; stoneCol.repeat.set(10,10);
+ stoneRgh.wrapS = stoneRgh.wrapT = THREE.RepeatWrapping; stoneRgh.repeat.set(10,10);
+ const floorMat = new THREE.MeshPhysicalMaterial({
+ map:stoneCol, roughnessMap:stoneRgh, roughness:0.62, metalness:0.0,
+ clearcoat:0.35, clearcoatRoughness:0.5, envMapIntensity:0.85
+ });
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 60), floorMat);
+ floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+ scene.add(floor);
+
+ // bright back wall — warm off-white plaster, receives the wing-bank shadow
+ const wallMat = new THREE.MeshStandardMaterial({ color:0xf3efe6, roughness:0.92, metalness:0.0, envMapIntensity:0.6 });
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(70, 26), wallMat);
+ back.position.set(0, 7, -8.2); back.receiveShadow = true; scene.add(back);
+
+ // side walls — pale, recede toward the light
+ const sideMat = new THREE.MeshStandardMaterial({ color:0xf6f3ec, roughness:0.94, metalness:0.0, envMapIntensity:0.5 });
+ const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 26), sideMat);
+ left.position.set(-16, 7, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+ const right = left.clone(); right.position.x = 16; right.rotation.y = -Math.PI/2; scene.add(right);
+
+ // GLASS CEILING with mullions — thin frame grid against the bright sky so the
+ // atrium reads as a daylit glass roof. Mullions cast faint shadow bars too.
+ const mullMat = new THREE.MeshStandardMaterial({ color:0xd8d2c4, roughness:0.5, metalness:0.3, envMapIntensity:0.8 });
+ const ceilGroup = new THREE.Group();
+ const span = 36, depth = 30, y = 13;
+ for(let i=-span/2; i<=span/2; i+=4){
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, depth), mullMat);
+ bar.position.set(i, y, -2); ceilGroup.add(bar);
+ }
+ for(let j=-depth/2; j<=depth/2; j+=5){
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(span, 0.12, 0.12), mullMat);
+ bar.position.set(0, y, j-2); ceilGroup.add(bar);
+ }
+ scene.add(ceilGroup);
+
+ // a few faint floating dust motes catching the light (tiny additive points)
+ const moteGeo = new THREE.BufferGeometry();
+ const MOTES = 120; const pos = new Float32Array(MOTES*3);
+ for(let i=0;i<MOTES;i++){
+ pos[i*3] = (Math.random()-0.5)*24;
+ pos[i*3+1] = Math.random()*9 + 1;
+ pos[i*3+2] = (Math.random()-0.5)*12 - 2;
+ }
+ moteGeo.setAttribute("position", new THREE.BufferAttribute(pos,3));
+ const moteMat = new THREE.PointsMaterial({ color:0xfff6e0, size:0.045, transparent:true,
+ opacity:0.5, depthWrite:false, blending:THREE.AdditiveBlending, sizeAttenuation:true });
+ const motes = new THREE.Points(moteGeo, moteMat);
+ scene.add(motes);
+ window.__motes = motes;
+ })();
+
+ // -------------------------------------------------------------------------
+ // THE WING BANK — thin pale-framed boards, ~2" apart, side swivel.
+ // Same proven mechanic as the gallery build; reskinned for daylight.
+ // -------------------------------------------------------------------------
+ const BOARD_W = 1.46; // ~30" wide board face
+ const BOARD_H = 3.55; // ~6' tall
+ const FRAME_T = 0.045; // thin frame border
+ const FRAME_D = 0.05; // frame depth
+ 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);
+ // thin light-bronze / champagne frame (matches the bright-gallery white frames)
+ const frameMat = new THREE.MeshStandardMaterial({ color:0xe8e2d3, roughness:0.42, metalness:0.55, envMapIntensity:1.0 });
+ function placeholderFace(){
+ return new THREE.MeshPhysicalMaterial({ color:0xf2efe7, roughness:0.6, metalness:0.0, envMapIntensity:0.5 });
+ }
+
+ 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:0xeae6db, roughness:0.8, metalness:0.05 })
+ );
+ 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);
+
+ 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, _lit:1 };
+ }
+
+ // -------------------------------------------------------------------------
+ // FLOOR REFLECTION — soft mirrored clone of the featured board (subtle on
+ // stone, just a hint of the pattern picked up by the polished slab).
+ // -------------------------------------------------------------------------
+ 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: featured board + a window of neighbours only.
+ // -------------------------------------------------------------------------
+ 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 full 27" repeat across the board face
+ tex.needsUpdate = true;
+ texCache.set(url, tex);
+ resolve(tex);
+ };
+ img.onerror = ()=>resolve(null);
+ img.src = url;
+ });
+ }
+ function ensureBoardTexture(b){
+ if(b.texLoaded || !b.data) return;
+ const url = imgURL(b.data.image || b.data.tile);
+ if(!url){ b.texLoaded = true; return; }
+ b.texLoaded = true;
+ loadTexture(url).then(tex=>{
+ if(!tex) return;
+ const m = new THREE.MeshPhysicalMaterial({
+ map:tex, roughness:0.55, metalness:0.0, clearcoat:0.06, clearcoatRoughness:0.6, envMapIntensity:0.55
+ });
+ 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++){
+ if(Math.abs(i-focusIdx) <= 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;
+
+ 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;
+
+ // DAYLIGHT — everything stays bright (no chiaroscuro fade). The featured
+ // board is at full daylight; neighbours are only very gently cooled so the
+ // eye settles on center without losing the airy, well-lit feel.
+ const litTarget = 0.82 + 0.18*Math.pow(openAmt, 1.2);
+ 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;
+ }
+
+ // sun + reflection track the featured board (sun follows so its long shadow
+ // sweeps with whatever's open)
+ const front = boards[focusIdx];
+ if(front){
+ const fx = front.group.position.x + BOARD_W/2;
+ sunTarget.position.set(fx*0.6, 1.2, front.group.position.z - 1);
+ reflMesh.visible = true;
+ if(front.faceMat && front.faceMat.map){
+ reflMat.map = front.faceMat.map;
+ reflMat.opacity += (0.11 - 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);
+ }
+ }
+
+ // drifting motes
+ if(window.__motes){
+ window.__motes.rotation.y = now*0.00004;
+ window.__motes.material.opacity = 0.4 + Math.sin(now*0.0008)*0.12;
+ }
+
+ 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.__AD_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.__AD__ = {
+ boards: ()=>boards.length,
+ textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+ focus: ()=>focusIdx,
+ fps: ()=>window.__AD_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":"V13","label":"V13 — Atrium Daylight","elements":[
+ {"n":1,"label":"Glass atrium — luminous sky-gradient environment, soft global illumination"},
+ {"n":2,"label":"Thin champagne-framed boards, ~2\" apart on a side swivel"},
+ {"n":3,"label":"Warm low sun throws long soft shadows across pale limestone"},
+ {"n":4,"label":"Mullioned glass ceiling + drifting dust motes catch the light"},
+ {"n":5,"label":"Click a board → carousel-shift to center → full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v14-velvet-lounge.html b/public/proto/v14-velvet-lounge.html
new file mode 100644
index 0000000..335318c
--- /dev/null
+++ b/public/proto/v14-velvet-lounge.html
@@ -0,0 +1,679 @@
+<!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>VELVET LOUNGE · China Seas</title>
+<style>
+ :root { --brass:#d8b779; --paper:#f1e8d6; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#0a0607; 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:80px 30px 46px;
+ pointer-events:none; text-align:center;
+ background:linear-gradient(180deg, rgba(10,6,7,.78) 0%, rgba(10,6,7,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+ color:#b89a66; font-weight:600; }
+ .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+ font-weight:400; letter-spacing:.14em; line-height:1; margin-top:9px; color:#f3ead8; }
+ .masthead h1 b { font-weight:700; color:#e9c98f; }
+ .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#9a8d76;
+ max-width:540px; 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:#f4ecdb;
+ text-shadow:0 2px 22px rgba(0,0,0,.92); }
+ #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#c4a36a; }
+ #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#a59a85; }
+ #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+ border:1px solid rgba(216,183,121,.55); border-radius:28px; color:#e9c98f; font-size:10.5px;
+ letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+ background:rgba(216,183,121,.07); transition:background .15s, transform .15s; }
+ #caption a.cta:hover { background:rgba(216,183,121,.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(228,214,188,.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:#b89a66; background:rgba(14,9,10,.6); border:1px solid rgba(216,183,121,.25); 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:#0a0607; transition:opacity .9s; }
+ #boot .ring { width:44px; height:44px; border-radius:50%;
+ border:2px solid rgba(216,183,121,.18); border-top-color:#d8b779; animation:spin 1s linear infinite; }
+ #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#8a7d62; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+
+ @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:74px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+ <div class="kicker">Designer Wallcoverings · The Velvet Lounge</div>
+ <h1>VELVET <b>LOUNGE</b></h1>
+ <div class="sub">China Seas, set in a jewel-box salon — deep velvet walls, brass sconces, low warm light.
+ One board glides into the glow at a time, full pattern edge to edge. Drag to swing the bank.</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">Lighting the sconces…</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.12; // a touch hot so the jewel tones glow, not muddy
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+ renderer.setClearColor(0x0a0607, 1);
+ app.appendChild(renderer.domElement);
+ const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+ var _glowTex = null; // shared additive-glow texture (hoisted; built lazily)
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x0c0709);
+ // slight warm haze for plush depth (the "slight haze" the brief asks for)
+ scene.fog = new THREE.FogExp2(0x140a10, 0.026);
+
+ const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 200);
+ camera.position.set(0, 1.9, 9.0); // a touch higher & closer so velvet walls
+ camera.lookAt(0, 2.05, 0); // fill the frame, less muddy floor in view
+
+ // -------------------------------------------------------------------------
+ // PMREM ENVIRONMENT (RoomEnvironment) — so brass & gloss reflect richly
+ // -------------------------------------------------------------------------
+ (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 */ }
+ })();
+
+ // r128 sheen API: MeshPhysicalMaterial.sheen is a COLOR (or null), NOT the
+ // scalar sheen/sheenColor/sheenRoughness split that arrived in r132+. So we
+ // detect by whether assigning a Color sticks, and drive velvet plushness that way.
+ const SHEEN_OK = (function(){
+ try {
+ const m = new THREE.MeshPhysicalMaterial();
+ return ("sheen" in m) && !("sheenColor" in m); // r128-style sheen=Color
+ } catch(e){ return false; }
+ })();
+
+ // -------------------------------------------------------------------------
+ // LIGHTING — intimate, low-key, warm. A warm amber key + cool sapphire fill +
+ // warm rim, PLUS two brass-sconce point lights flanking the bank and a hero
+ // spotlight that follows the featured board.
+ // -------------------------------------------------------------------------
+ scene.add(new THREE.AmbientLight(0x241019, 0.5)); // warm plum base, low
+
+ const fill = new THREE.DirectionalLight(0x5a6fa0, 0.26); // cool sapphire fill from left
+ fill.position.set(-7, 6, 6); scene.add(fill);
+
+ const rim = new THREE.DirectionalLight(0xffcaa0, 0.34); // warm rim from behind-right
+ rim.position.set(6, 4, -8); scene.add(rim);
+
+ // low warm wash from floor level so the emerald velvet glows from below the
+ // sconces rather than dying to black — keeps the lounge plush, not muddy-dark.
+ const uplight = new THREE.DirectionalLight(0xffb888, 0.16);
+ uplight.position.set(0, -2, 4); scene.add(uplight);
+
+ const key = new THREE.DirectionalLight(0xffe7c2, 0.34); // soft warm key
+ key.position.set(2, 9, 8);
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 1; key.shadow.camera.far = 40;
+ key.shadow.camera.left = -10; key.shadow.camera.right = 10;
+ key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
+ key.shadow.bias = -0.0004; key.shadow.radius = 4;
+ scene.add(key);
+
+ // hero spotlight — warm, tight, casts the gallery shadow on the featured board
+ const spot = new THREE.SpotLight(0xffe2b4, 0, 18, Math.PI/7.0, 0.5, 1.3);
+ spot.position.set(0, 6.2, 5.4);
+ spot.castShadow = true;
+ spot.shadow.mapSize.set(2048, 2048);
+ spot.shadow.camera.near = 1; spot.shadow.camera.far = 24;
+ spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+ scene.add(spot);
+ const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+ spot.target = spotTarget;
+
+ // two brass wall-sconce point lights flanking the bank — warm pools of light
+ const sconceL = new THREE.PointLight(0xffb774, 6.5, 14, 2.0); sconceL.position.set(-6.4, 4.4, -2.0); scene.add(sconceL);
+ const sconceR = new THREE.PointLight(0xffb774, 6.5, 14, 2.0); sconceR.position.set( 6.4, 4.4, -2.0); scene.add(sconceR);
+
+ // -------------------------------------------------------------------------
+ // ROOM — deep jewel-tone VELVET walls (emerald / sapphire / oxblood), a warm
+ // dark herringbone-ish floor, brass sconce fixtures with emissive "glow".
+ // Velvet = high roughness + sheen (when supported) so the nap catches the rim.
+ // -------------------------------------------------------------------------
+ const FLOOR_Y = 0;
+
+ function makeVelvetMat(color, sheenColor){
+ const m = new THREE.MeshPhysicalMaterial({
+ color: color, roughness: 0.96, metalness: 0.0, envMapIntensity: 0.35
+ });
+ if (SHEEN_OK){
+ // r128: sheen is a Color — set it to the jewel highlight so the velvet nap
+ // catches the warm rim/sconce light at grazing angles (plush, not flat).
+ m.sheen = new THREE.Color(sheenColor);
+ }
+ return m;
+ }
+
+ // a soft vertical "drape" gradient + faint nap noise so velvet walls aren't flat
+ function makeVelvetRoughness(){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const x = c.getContext("2d");
+ x.fillStyle = "#f0f0f0"; x.fillRect(0,0,256,256);
+ const img = x.getImageData(0,0,256,256); 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]=210+n; }
+ x.putImageData(img,0,0);
+ const t = new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(4,4);
+ t.anisotropy = MAX_ANISO; return t;
+ }
+ const velvetRough = makeVelvetRoughness();
+
+ (function buildRoom(){
+ // BACK WALL — deep emerald velvet (the jewel anchor behind the bank).
+ // Slightly richer envMap so the nap glints under the sconces, not flat.
+ const backMat = makeVelvetMat(0x103a2c, 0x2a8062);
+ backMat.envMapIntensity = 0.5;
+ backMat.roughnessMap = velvetRough;
+ const back = new THREE.Mesh(new THREE.PlaneGeometry(60, 24), backMat);
+ back.position.set(0, 6, -7.5); back.receiveShadow = true; scene.add(back);
+
+ // SIDE WALLS — sapphire (left) + oxblood (right) for jewel-tone contrast
+ const leftMat = makeVelvetMat(0x122347, 0x2a4a8f);
+ leftMat.roughnessMap = velvetRough;
+ const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), leftMat);
+ left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+
+ const rightMat = makeVelvetMat(0x3d1218, 0x7a2a36); // oxblood
+ rightMat.roughnessMap = velvetRough;
+ const right = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), rightMat);
+ right.position.set(14, 6, 2); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
+
+ // CEILING — deep warm plum, near-matte, no bounce
+ const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+ new THREE.MeshStandardMaterial({ color:0x14090f, roughness:1.0 }));
+ ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+ // FLOOR — warm dark wood, satin clearcoat so the brass + boards reflect softly
+ const floorMat = new THREE.MeshPhysicalMaterial({
+ color:0x18100b, roughness:0.42, metalness:0.0,
+ clearcoat:0.7, clearcoatRoughness:0.5, envMapIntensity:0.45
+ });
+ floorMat.roughnessMap = makeFloorRoughness();
+ floorMat.roughnessMap.wrapS = floorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+ floorMat.roughnessMap.repeat.set(10, 10);
+ const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), floorMat);
+ floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+ scene.add(floor);
+
+ // a faint warm GLOW pool on the back wall behind each sconce (radial sprite-ish
+ // additive plane) — cheap fake-bloom so the sconces read as light sources.
+ addGlowPanel(-6.4, 4.4, -7.35, 0xffb262, 3.2);
+ addGlowPanel( 6.4, 4.4, -7.35, 0xffb262, 3.2);
+
+ // BRASS SCONCES — small emissive brass fixtures on the back wall
+ buildSconce(-6.4, 4.4);
+ buildSconce( 6.4, 4.4);
+ })();
+
+ // warm wood roughness streak
+ function makeFloorRoughness(){
+ const c = document.createElement("canvas"); c.width = c.height = 256;
+ const x = c.getContext("2d");
+ x.fillStyle = "#8a8a8a"; x.fillRect(0,0,256,256);
+ const img = x.getImageData(0,0,256,256); const d = img.data;
+ for(let i=0;i<d.length;i+=4){ const n=(Math.random()*60)|0; d[i]=d[i+1]=d[i+2]=120+n; }
+ x.putImageData(img,0,0);
+ const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+ }
+
+ // additive radial glow texture (shared) for fake-bloom glow pools.
+ // var (hoisted, TDZ-safe) so the buildRoom IIFE above can call glowTexture().
+ function glowTexture(){
+ if(_glowTex) return _glowTex;
+ const c = document.createElement("canvas"); c.width=c.height=128;
+ const g = c.getContext("2d");
+ const grd = g.createRadialGradient(64,64,2, 64,64,64);
+ grd.addColorStop(0, "rgba(255,255,255,1)");
+ grd.addColorStop(0.35, "rgba(255,255,255,0.45)");
+ grd.addColorStop(1, "rgba(255,255,255,0)");
+ g.fillStyle = grd; g.fillRect(0,0,128,128);
+ _glowTex = new THREE.CanvasTexture(c); return _glowTex;
+ }
+ function addGlowPanel(x,y,z,color,size){
+ const m = new THREE.MeshBasicMaterial({
+ map: glowTexture(), color: color, transparent:true, opacity:0.55,
+ blending: THREE.AdditiveBlending, depthWrite:false, toneMapped:false
+ });
+ const mesh = new THREE.Mesh(new THREE.PlaneGeometry(size, size), m);
+ mesh.position.set(x,y,z); mesh.renderOrder = 2; scene.add(mesh);
+ }
+
+ // a brass wall sconce: a small emissive brass cup + a tiny additive flame glow
+ function buildSconce(x,y){
+ const brass = new THREE.MeshPhysicalMaterial({
+ color:0x8a6a2e, roughness:0.28, metalness:1.0,
+ clearcoat:0.6, clearcoatRoughness:0.3, envMapIntensity:1.2,
+ emissive:0x4a2f0c, emissiveIntensity:0.6
+ });
+ const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,0.5,12), brass);
+ arm.position.set(x, y, -7.05); arm.rotation.x = Math.PI/2; scene.add(arm);
+ const cup = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.3, 14, 1, true), brass);
+ cup.position.set(x, y+0.05, -6.78); scene.add(cup);
+ // emissive "flame" core
+ const flameMat = new THREE.MeshBasicMaterial({ color:0xffd089, toneMapped:false });
+ const flame = new THREE.Mesh(new THREE.SphereGeometry(0.07, 10, 10), flameMat);
+ flame.position.set(x, y+0.16, -6.78); scene.add(flame);
+ // small additive halo right at the flame
+ const halo = new THREE.Mesh(new THREE.PlaneGeometry(1.0,1.0), new THREE.MeshBasicMaterial({
+ map: glowTexture(), color:0xffc070, transparent:true, opacity:0.7,
+ blending:THREE.AdditiveBlending, depthWrite:false, toneMapped:false
+ }));
+ halo.position.set(x, y+0.16, -6.7); halo.renderOrder = 3; scene.add(halo);
+ }
+
+ // -------------------------------------------------------------------------
+ // THE WING BANK — thin brass-framed boards, ~2" apart on an arc, side swivel.
+ // -------------------------------------------------------------------------
+ const BOARD_W = 1.46; // ~30" wide board face (world units; 1u ≈ 20.5")
+ const BOARD_H = 3.55; // ~6' tall
+ const FRAME_T = 0.045; // thin frame border thickness
+ const FRAME_D = 0.05; // frame depth
+ const GAP = 0.10; // ~2" between closed boards
+ const PITCH = BOARD_W + GAP;
+ const BANK_Z = -4.4; // closed boards rest here
+ const N_TARGET = 50;
+
+ const boards = []; // { group, faceMesh, data, idx, base x }
+ let products = [];
+ let focusIdx = 0;
+
+ // shared geometries / materials
+ const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+ // polished BRASS frame material — metalness + clearcoat per the brief
+ const frameMat = new THREE.MeshPhysicalMaterial({
+ color:0x9c7836, roughness:0.26, metalness:1.0,
+ clearcoat:0.55, clearcoatRoughness:0.28, envMapIntensity:1.25,
+ emissive:0x2a1c08, emissiveIntensity:0.35
+ });
+ function placeholderFace(){
+ return new THREE.MeshPhysicalMaterial({ color:0x1a1512, roughness:0.62, metalness:0.0, envMapIntensity:0.35 });
+ }
+
+ // board pivots on its LEFT edge (side swivel). Group origin = left hinge.
+ 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:0x140f0c, roughness:0.85, metalness:0.05 })
+ );
+ 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);
+
+ 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 — mirrored, dimmed clone of the featured board's face.
+ // -------------------------------------------------------------------------
+ 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: only load the featured board + a window of neighbours
+ // -------------------------------------------------------------------------
+ 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);
+ 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.10, clearcoatRoughness:0.6, envMapIntensity:0.35
+ });
+ 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;
+
+ pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+ const eased = pan;
+
+ // gentle candle-flicker on the sconce lights so the lounge feels alive
+ const fl = 1 + Math.sin(now*0.013)*0.05 + Math.sin(now*0.041)*0.03;
+ sconceL.intensity = 6.5*fl;
+ sconceR.intensity = 6.5*(2 - fl);
+
+ 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;
+
+ // low-key chiaroscuro — only the featured board sits fully in the warm pool
+ const litTarget = 0.22 + 0.78*Math.pow(openAmt, 1.35);
+ 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) > 7;
+ b.group.visible = !farOff;
+ }
+
+ 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, 6.4, front.group.position.z + 5.2);
+ spot.intensity += ((HAS_PRODUCTS?8.5:0) - spot.intensity)*Math.min(1,dt*5);
+
+ if(front.faceMat && front.faceMat.map){
+ reflMat.map = front.faceMat.map;
+ reflMat.opacity += (0.14 - 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.__VL_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-0)*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.__VL__ = {
+ boards: ()=>boards.length,
+ textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+ focus: ()=>focusIdx,
+ fps: ()=>window.__VL_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":"V14","label":"V14 — Velvet Lounge","elements":[
+ {"n":1,"label":"Jewel-box salon — emerald / sapphire / oxblood velvet walls"},
+ {"n":2,"label":"Brass wall sconces with warm flame-glow pools"},
+ {"n":3,"label":"Intimate low-key warm key + sapphire fill + flicker"},
+ {"n":4,"label":"Thin brass-framed boards, ~2\" apart on a side swivel"},
+ {"n":5,"label":"Click a board → glides to center → full pattern face + soft floor reflection"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v15-brutalist-concrete.html b/public/proto/v15-brutalist-concrete.html
new file mode 100644
index 0000000..cc662ce
--- /dev/null
+++ b/public/proto/v15-brutalist-concrete.html
@@ -0,0 +1,685 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>BRUTALIST CONCRETE · China Seas</title>
+<style>
+ :root { --conc:#9a958c; --paper:#e9e6df; }
+ * { box-sizing:border-box; margin:0; padding:0; }
+ html,body { height:100%; overflow:hidden; background:#16161a; color:var(--paper);
+ font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+ #app { position:fixed; inset:0; }
+ canvas { display:block; }
+
+ /* ---- masthead — hard architectural type ---- */
+ .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:78px 30px 46px;
+ pointer-events:none; text-align:center;
+ background:linear-gradient(180deg, rgba(14,14,16,.78) 0%, rgba(14,14,16,0) 100%); }
+ .masthead .kicker { font-size:11px; letter-spacing:.52em; text-transform:uppercase;
+ color:#7e7a72; font-weight:700; }
+ .masthead h1 { font-family:"Helvetica Neue",Arial,sans-serif; font-size:clamp(28px,5vw,54px);
+ font-weight:200; letter-spacing:.22em; line-height:1; margin-top:12px; color:#eceae3;
+ text-transform:uppercase; }
+ .masthead h1 b { font-weight:800; color:#fbfaf6; }
+ .masthead .sub { margin-top:12px; font-size:12px; letter-spacing:.06em; color:#827e76;
+ max-width:560px; margin-left:auto; margin-right:auto; }
+
+ /* ---- focused board caption — concrete slab card ---- */
+ #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
+ z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
+ pointer-events:none; }
+ #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+ #caption .cn { font-family:"Helvetica Neue",Arial,sans-serif; font-weight:300; font-size:23px;
+ letter-spacing:.04em; color:#f2efe8; text-shadow:0 2px 22px rgba(0,0,0,.85); }
+ #caption .cc { margin-top:6px; font-size:10.5px; letter-spacing:.40em; text-transform:uppercase; color:#a59f93; }
+ #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.05em; color:#928d82; }
+ #caption a.cta { pointer-events:auto; display:inline-block; margin-top:15px; padding:11px 28px;
+ border:1px solid rgba(210,206,196,.45); border-radius:2px; color:#eceae3; font-size:10.5px;
+ letter-spacing:.26em; text-transform:uppercase; text-decoration:none; font-weight:700;
+ background:rgba(210,206,196,.05); transition:background .15s, transform .15s, border-color .15s; }
+ #caption a.cta:hover { background:rgba(210,206,196,.14); border-color:rgba(210,206,196,.7); transform:translateY(-1px); }
+
+ .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+ font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:rgba(220,216,206,.4);
+ pointer-events:none; transition:opacity .6s; }
+
+ /* ---- FPS readout ---- */
+ #fps { position:fixed; bottom:14px; right:16px; z-index:25; font:600 11px/1.3 ui-monospace,Menlo,monospace;
+ color:#8c887f; background:rgba(16,16,18,.62); border:1px solid rgba(180,176,166,.22); border-radius:4px;
+ padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+ #fps.off { opacity:.32; }
+
+ /* ---- loading ---- */
+ #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+ align-items:center; justify-content:center; background:#16161a; transition:opacity .9s; }
+ #boot .ring { width:44px; height:44px; border-radius:50%;
+ border:2px solid rgba(180,176,166,.16); border-top-color:#cfcabf; animation:spin 1s linear infinite; }
+ #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.38em; text-transform:uppercase; color:#736f67; }
+ @keyframes spin { to { transform:rotate(360deg); } }
+
+ @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:72px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+ <div class="kicker">Designer Wallcoverings · The Concrete Hall</div>
+ <h1>BRUTALIST <b>CONCRETE</b></h1>
+ <div class="sub">China Seas hung in a board-formed concrete hall. One hard shaft of light, raw grey
+ surfaces, and the pattern as the only colour in the room. Drag to swing the bank; tap a board to bring it forward.</div>
+</div>
+
+<div id="caption">
+ <div class="cn" id="cap_name">—</div>
+ <div class="cc" id="cap_coll">China Seas</div>
+ <div class="cspec" id="cap_spec">—</div>
+ <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Pouring the concrete…</div></div>
+
+<!-- r128 core (local) + r128 RoomEnvironment for PMREM (CDN, attaches to global THREE). No bloom dep. -->
+<script src="./three.min.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
+<script>
+"use strict";
+(function () {
+ const API = "/api/showroom/products?limit=50";
+ const app = document.getElementById("app");
+ const bootlbl = document.getElementById("bootlbl");
+ const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+ const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+ const lerp = (a,b,t)=>a+(b-a)*t;
+
+ // -------------------------------------------------------------------------
+ // RENDERER (high-end baseline)
+ // -------------------------------------------------------------------------
+ const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+ renderer.setSize(W(), H());
+ if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+ else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+ renderer.toneMapping = THREE.ACESFilmicToneMapping;
+ renderer.toneMappingExposure = 1.02;
+ if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+ renderer.shadowMap.enabled = true;
+ renderer.shadowMap.type = THREE.PCFSoftShadowMap; // soft sampling, but a HARD key (small radius) keeps shadows crisp
+ renderer.setClearColor(0x16161a, 1);
+ app.appendChild(renderer.domElement);
+ const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+ const scene = new THREE.Scene();
+ scene.background = new THREE.Color(0x17171b);
+ scene.fog = new THREE.FogExp2(0x141417, 0.018); // gentle haze for depth, not a blackout
+
+ const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 200);
+ camera.position.set(0, 1.55, 9.4);
+ camera.lookAt(0, 1.55, 0);
+
+ // -------------------------------------------------------------------------
+ // PMREM ENVIRONMENT (RoomEnvironment) — neutral IBL so the concrete reads as a
+ // real lit material and the steel frames pick up soft grey reflections. The
+ // environment is intentionally LOW intensity per material so the one hard key
+ // remains the dominant light (brutalist = single dramatic source).
+ // -------------------------------------------------------------------------
+ (function buildEnv(){
+ try {
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ const env = new THREE.RoomEnvironment();
+ const envMap = pmrem.fromScene(env, 0.04).texture;
+ scene.environment = envMap;
+ pmrem.dispose();
+ } catch(e){ /* env optional */ }
+ })();
+
+ // -------------------------------------------------------------------------
+ // LIGHTING — ONE hard directional KEY (crisp shadows) + a low cool ambient and
+ // a soft fill. No spotlight follow, no rim warmth: the brutalist read is a
+ // single raking shaft of daylight across raw grey.
+ // -------------------------------------------------------------------------
+ scene.add(new THREE.AmbientLight(0x2c2e34, 0.24)); // low cool ambient — recesses stay dark (AO read)
+
+ const fill = new THREE.HemisphereLight(0x4c4f58, 0x0c0c0f, 0.22); // restrained soft fill so the key dominates
+ scene.add(fill);
+
+ // THE hard key — raking, near-white daylight, full strength, TIGHT crisp shadow.
+ const key = new THREE.DirectionalLight(0xfff4e2, 3.0);
+ key.position.set(-7.5, 11.5, 7.5); // rakes from upper-left → long crisp shadows to the right
+ key.castShadow = true;
+ key.shadow.mapSize.set(2048, 2048);
+ key.shadow.camera.near = 1; key.shadow.camera.far = 48;
+ key.shadow.camera.left = -13; key.shadow.camera.right = 13;
+ key.shadow.camera.top = 13; key.shadow.camera.bottom = -13;
+ key.shadow.bias = -0.00035;
+ key.shadow.radius = 1.6; // SMALL radius → hard-edged, crisp gallery shadows
+ scene.add(key);
+ const keyTarget = new THREE.Object3D(); keyTarget.position.set(0, 1.6, -3); scene.add(keyTarget);
+ key.target = keyTarget;
+
+ // -------------------------------------------------------------------------
+ // PROCEDURAL CONCRETE MAPS — board-formed concrete: horizontal plank seams +
+ // vertical form-tie marks + wood grain inside each board + mottled aggregate.
+ // We bake a colour map, a roughness map, a normal map and a baked-AO map on a
+ // single canvas set, $0 local. Shared across all concrete surfaces.
+ // -------------------------------------------------------------------------
+ function makeConcreteMaps(opts){
+ opts = opts || {};
+ const S = 512;
+ const planks = opts.planks || 7; // board-form planks down the height
+ const base = opts.base || 152; // mid grey
+ // --- colour canvas ---
+ const cc = document.createElement("canvas"); cc.width = cc.height = S;
+ const cx = cc.getContext("2d");
+ cx.fillStyle = `rgb(${base},${base},${base+2})`; cx.fillRect(0,0,S,S);
+ // mottled aggregate + pour staining
+ const cimg = cx.getImageData(0,0,S,S); const cd = cimg.data;
+ for(let y=0;y<S;y++){
+ for(let x=0;x<S;x++){
+ const i=(y*S+x)*4;
+ // low-freq stain (vertical pour streaks)
+ const stain = Math.sin(x*0.013 + Math.sin(y*0.006)*2.0)*10
+ + Math.sin(x*0.05)*4;
+ // fine aggregate speckle
+ const spk = (Math.random()*26 - 13);
+ const v = clamp(base + stain + spk, 96, 196);
+ cd[i]=v; cd[i+1]=v; cd[i+2]=v+2; cd[i+3]=255;
+ }
+ }
+ cx.putImageData(cimg,0,0);
+
+ // --- roughness canvas (mostly matte; seams slightly darker = rougher) ---
+ const rc = document.createElement("canvas"); rc.width = rc.height = S;
+ const rx = rc.getContext("2d");
+ rx.fillStyle = "#c8c8c8"; rx.fillRect(0,0,S,S); // ~0.78 rough base
+ const rimg = rx.getImageData(0,0,S,S); const rd = rimg.data;
+ for(let i=0;i<rd.length;i+=4){ const n=(Math.random()*30-15); const v=clamp(200+n,150,235); rd[i]=rd[i+1]=rd[i+2]=v; rd[i+3]=255; }
+ rx.putImageData(rimg,0,0);
+
+ // --- normal canvas — start flat (128,128,255) ---
+ const nc = document.createElement("canvas"); nc.width = nc.height = S;
+ const nx = nc.getContext("2d");
+ nx.fillStyle = "rgb(128,128,255)"; nx.fillRect(0,0,S,S);
+
+ // --- AO canvas — white (no occlusion) by default; we darken grooves ---
+ const ac = document.createElement("canvas"); ac.width = ac.height = S;
+ const ax = ac.getContext("2d");
+ ax.fillStyle = "#fff"; ax.fillRect(0,0,S,S);
+
+ // helper to draw a horizontal groove (plank seam) into all maps
+ const plankH = S/planks;
+ function drawSeam(cy){
+ // colour: dark line + a faint highlight lip above it (lit from top-left)
+ cx.strokeStyle = "rgba(40,40,44,0.55)"; cx.lineWidth = 3;
+ cx.beginPath(); cx.moveTo(0,cy); cx.lineTo(S,cy); cx.stroke();
+ cx.strokeStyle = "rgba(220,220,224,0.28)"; cx.lineWidth = 1.5;
+ cx.beginPath(); cx.moveTo(0,cy-2.5); cx.lineTo(S,cy-2.5); cx.stroke();
+ // roughness: seam is rougher (brighter in roughness map)
+ rx.strokeStyle = "rgba(255,255,255,0.6)"; rx.lineWidth = 4;
+ rx.beginPath(); rx.moveTo(0,cy); rx.lineTo(S,cy); rx.stroke();
+ // AO: groove is occluded (darker)
+ const g = ax.createLinearGradient(0,cy-5,0,cy+5);
+ g.addColorStop(0,"rgba(0,0,0,0)"); g.addColorStop(0.5,"rgba(0,0,0,0.45)"); g.addColorStop(1,"rgba(0,0,0,0)");
+ ax.fillStyle = g; ax.fillRect(0,cy-5,S,10);
+ // normal: a small ridge → bright(up)/dark(down) green-channel banding
+ nx.strokeStyle = "rgba(128,210,255,0.9)"; nx.lineWidth = 2; // surface tilts up just above seam
+ nx.beginPath(); nx.moveTo(0,cy-2); nx.lineTo(S,cy-2); nx.stroke();
+ nx.strokeStyle = "rgba(128,46,255,0.9)"; nx.lineWidth = 2; // tilts down just below
+ nx.beginPath(); nx.moveTo(0,cy+2); nx.lineTo(S,cy+2); nx.stroke();
+ }
+ for(let p=1;p<planks;p++) drawSeam(Math.round(p*plankH));
+
+ // wood-grain form lines INSIDE each plank — subtle horizontal striations that
+ // give board-formed concrete its signature timber-imprint texture.
+ cx.save();
+ for(let p=0;p<planks;p++){
+ const y0 = p*plankH, y1 = (p+1)*plankH;
+ const lines = 9 + ((Math.random()*5)|0);
+ for(let l=0;l<lines;l++){
+ const yy = y0 + (l+0.5)/lines*(y1-y0) + (Math.random()*3-1.5);
+ const a = 0.05 + Math.random()*0.06;
+ cx.strokeStyle = `rgba(70,70,74,${a})`;
+ cx.lineWidth = 0.8 + Math.random()*0.7;
+ cx.beginPath();
+ // gentle wave so grain isn't ruler-straight
+ cx.moveTo(0, yy);
+ for(let xx=0; xx<=S; xx+=32){ cx.lineTo(xx, yy + Math.sin(xx*0.02 + p)*0.8); }
+ cx.stroke();
+ // matching faint normal striation
+ nx.strokeStyle = `rgba(128,${138+((Math.random()*16)|0)},255,0.20)`;
+ nx.lineWidth = 0.8; nx.beginPath(); nx.moveTo(0,yy); nx.lineTo(S,yy); nx.stroke();
+ }
+ }
+ cx.restore();
+
+ // form-tie cone holes — the little recessed circles brutalist concrete is known
+ // for, on a regular grid. Dark dot + AO well + normal divot.
+ const cols = 4, rows = planks;
+ for(let r=0;r<rows;r++){
+ for(let c=0;c<cols;c++){
+ const tx = (c+0.5)/cols*S, ty = (r+0.5)/rows*S;
+ // colour: dark recessed cone
+ const rg = cx.createRadialGradient(tx,ty,0, tx,ty,7);
+ rg.addColorStop(0,"rgba(30,30,34,0.85)"); rg.addColorStop(0.6,"rgba(60,60,64,0.5)"); rg.addColorStop(1,"rgba(0,0,0,0)");
+ cx.fillStyle = rg; cx.beginPath(); cx.arc(tx,ty,7,0,Math.PI*2); cx.fill();
+ // AO well
+ const ag = ax.createRadialGradient(tx,ty,0, tx,ty,9);
+ ag.addColorStop(0,"rgba(0,0,0,0.55)"); ag.addColorStop(1,"rgba(0,0,0,0)");
+ ax.fillStyle = ag; ax.beginPath(); ax.arc(tx,ty,9,0,Math.PI*2); ax.fill();
+ // normal divot (left bright, right dark for a round dent)
+ const ng = nx.createRadialGradient(tx-2,ty-2,0, tx,ty,7);
+ ng.addColorStop(0,"rgba(150,150,255,0.7)"); ng.addColorStop(1,"rgba(128,128,255,0)");
+ nx.fillStyle = ng; nx.beginPath(); nx.arc(tx,ty,7,0,Math.PI*2); nx.fill();
+ }
+ }
+
+ const mk = (canvas, srgb)=>{
+ const t = new THREE.CanvasTexture(canvas);
+ t.wrapS = t.wrapT = THREE.RepeatWrapping;
+ t.anisotropy = MAX_ANISO;
+ if(srgb){ if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding; }
+ return t;
+ };
+ return { map:mk(cc,true), roughnessMap:mk(rc,false), normalMap:mk(nc,false), aoMap:mk(ac,false) };
+ }
+
+ // build the shared concrete map set once
+ const CMAP = makeConcreteMaps({ planks:7, base:150 });
+
+ function concreteMaterial(o){
+ o = o || {};
+ const m = new THREE.MeshStandardMaterial({
+ color: o.color!==undefined ? o.color : 0xbdbab2,
+ map: CMAP.map,
+ roughnessMap: CMAP.roughnessMap,
+ normalMap: CMAP.normalMap,
+ aoMap: CMAP.aoMap,
+ roughness: 0.94,
+ metalness: 0.0,
+ envMapIntensity: o.env!==undefined ? o.env : 0.22
+ });
+ m.normalScale = new THREE.Vector2(o.nrm!==undefined?o.nrm:0.85, o.nrm!==undefined?o.nrm:0.85);
+ m.aoMapIntensity = o.ao!==undefined ? o.ao : 1.15; // strong AO in the recesses
+ return m;
+ }
+
+ // -------------------------------------------------------------------------
+ // ROOM — a board-formed concrete hall: back wall, two side walls, a heavy
+ // ceiling soffit, and a raw concrete floor with a raised plinth under the bank.
+ // PlaneGeometry needs a 2nd UV set (uv2) for aoMap → we copy uv→uv2.
+ // -------------------------------------------------------------------------
+ function withUV2(geo, repeatU, repeatV){
+ geo.setAttribute("uv2", new THREE.BufferAttribute(geo.attributes.uv.array, 2));
+ return geo;
+ }
+ const FLOOR_Y = 0;
+ (function buildRoom(){
+ // BACK WALL — the big board-formed slab behind the bank (the hero concrete face)
+ const backMat = concreteMaterial({ color:0xc4c1b9, env:0.20, ao:1.25, nrm:1.0 });
+ backMat.map = CMAP.map.clone(); backMat.map.needsUpdate = true;
+ backMat.map.repeat.set(3.2, 1.6); backMat.map.wrapS = backMat.map.wrapT = THREE.RepeatWrapping;
+ backMat.roughnessMap = CMAP.roughnessMap; backMat.normalMap = CMAP.normalMap; backMat.aoMap = CMAP.aoMap;
+ backMat.roughnessMap.repeat = backMat.normalMap.repeat = backMat.aoMap.repeat = backMat.map.repeat;
+ const back = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 24)), backMat);
+ back.position.set(0, 6, -7.6); back.receiveShadow = true; scene.add(back);
+
+ // SIDE WALLS — darker grey, recede
+ const sideMat = concreteMaterial({ color:0x9d9a92, env:0.16, ao:1.2, nrm:0.8 });
+ const left = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat);
+ left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+ const right = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat.clone());
+ right.position.set(14, 6, 2); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
+
+ // CEILING SOFFIT — heavy dark slab so the key reads as a shaft from a gap
+ const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+ new THREE.MeshStandardMaterial({ color:0x6f6d68, roughness:1.0, metalness:0.0 }));
+ ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; ceil.receiveShadow = true; scene.add(ceil);
+
+ // FLOOR — raw matte concrete (no clearcoat: brutalist floor is NOT polished)
+ const floorMat = concreteMaterial({ color:0xa7a49c, env:0.14, ao:1.1, nrm:0.6 });
+ floorMat.map = CMAP.map.clone(); floorMat.map.repeat.set(8, 6);
+ floorMat.map.wrapS = floorMat.map.wrapT = THREE.RepeatWrapping; floorMat.map.needsUpdate = true;
+ floorMat.roughness = 0.97;
+ const floor = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 40)), floorMat);
+ floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true; scene.add(floor);
+
+ // RAISED PLINTH — a low concrete dais the wing bank sits on (architectural base)
+ const plinthMat = concreteMaterial({ color:0xb4b1a9, env:0.18, ao:1.2, nrm:0.7 });
+ const plinth = new THREE.Mesh(new THREE.BoxGeometry(20, 0.32, 4.4), plinthMat);
+ // box geo already has uv2-able uvs per face; ensure uv2 exists
+ plinth.geometry.setAttribute("uv2", new THREE.BufferAttribute(plinth.geometry.attributes.uv.array, 2));
+ plinth.position.set(0, 0.16, -4.2); plinth.castShadow = true; plinth.receiveShadow = true; scene.add(plinth);
+
+ // two heavy concrete pilasters framing the bank (symmetry / depth)
+ const pilMat = concreteMaterial({ color:0x96938b, env:0.16, ao:1.25, nrm:0.9 });
+ [-8.6, 8.6].forEach(px=>{
+ const pil = new THREE.Mesh(new THREE.BoxGeometry(1.5, 22, 1.5), pilMat.clone());
+ pil.geometry.setAttribute("uv2", new THREE.BufferAttribute(pil.geometry.attributes.uv.array, 2));
+ pil.position.set(px, 11, -6.6); pil.castShadow = true; pil.receiveShadow = true; scene.add(pil);
+ });
+ })();
+
+ // -------------------------------------------------------------------------
+ // THE WING BANK — thin-framed boards (~2" apart) on a side swivel; the focused
+ // board swings face-on showing a CLEAN full pattern face. Frames are raw steel
+ // (charcoal, low-gloss) to read against the concrete — still monochrome.
+ // -------------------------------------------------------------------------
+ const BOARD_W = 1.46; // ~30" wide board face (1u ≈ 20.5")
+ const BOARD_H = 3.55; // ~6' tall
+ const FRAME_T = 0.04; // thin frame border
+ const FRAME_D = 0.05;
+ const GAP = 0.10; // ~2" between closed boards
+ const PITCH = BOARD_W + GAP;
+ const BANK_Z = -4.4;
+ const PLINTH_TOP = 0.32; // boards stand on the plinth
+ const N_TARGET = 50;
+
+ const boards = [];
+ let products = [];
+ let focusIdx = 0;
+
+ const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+ // raw blackened-steel frame — charcoal, slightly metallic, picks up the env
+ const frameMat = new THREE.MeshStandardMaterial({ color:0x2b2b2e, roughness:0.42, metalness:0.78, envMapIntensity:0.6 });
+ function placeholderFace(){
+ return new THREE.MeshStandardMaterial({ color:0x6a6862, roughness:0.7, metalness:0.0, envMapIntensity:0.18 });
+ }
+
+ function buildBoard(idx){
+ const g = new THREE.Group();
+ // backing slab (thin) — concrete-toned so closed slivers still read as material
+ const backing = new THREE.Mesh(
+ new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.035),
+ new THREE.MeshStandardMaterial({ color:0x4f4d49, roughness:0.9, metalness:0.04 })
+ );
+ backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.025);
+ backing.castShadow = true; backing.receiveShadow = true;
+ g.add(backing);
+
+ const faceMat = placeholderFace();
+ const face = new THREE.Mesh(faceGeo, faceMat);
+ face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+ face.castShadow = false; face.receiveShadow = true;
+ face.userData.idx = idx;
+ g.add(face);
+
+ const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+ const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+ const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+ const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+ const lf = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+ const rt = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+ g.add(top, bot, lf, rt);
+
+ scene.add(g);
+ return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+ }
+
+ // -------------------------------------------------------------------------
+ // TEXTURE LOADER — lazy: only featured board + a small neighbour window.
+ // -------------------------------------------------------------------------
+ const texCache = new Map();
+ function imgURL(u){
+ if(!u) return null;
+ if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+ return "/api/proxy/image?url=" + encodeURIComponent(u);
+ }
+ function loadTexture(url){
+ if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+ return new Promise((resolve)=>{
+ const img = new Image(); img.crossOrigin = "anonymous";
+ img.onload = ()=>{
+ const tex = new THREE.Texture(img);
+ if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+ else tex.encoding = THREE.sRGBEncoding;
+ tex.anisotropy = MAX_ANISO;
+ tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+ tex.repeat.set(1, 1.0); // one clean full motif edge-to-edge on the open face
+ tex.needsUpdate = true;
+ texCache.set(url, tex);
+ resolve(tex);
+ };
+ img.onerror = ()=>resolve(null);
+ img.src = url;
+ });
+ }
+
+ function ensureBoardTexture(b){
+ if(b.texLoaded || !b.data) return;
+ const url = imgURL(b.data.image || b.data.tile);
+ if(!url){ b.texLoaded = true; return; }
+ b.texLoaded = true;
+ loadTexture(url).then(tex=>{
+ if(!tex) return;
+ // The pattern face is a flat, low-spec surface so the COLOUR pops cleanly
+ // against the monochrome room (no clearcoat sheen washing it out).
+ const m = new THREE.MeshStandardMaterial({
+ map:tex, roughness:0.62, metalness:0.0, envMapIntensity:0.18
+ });
+ b.face.material.dispose && b.face.material.dispose();
+ b.face.material = m; b.faceMat = m;
+ });
+ }
+
+ function refreshTextureWindow(){
+ const WIN = 4;
+ for(let i=0;i<boards.length;i++){
+ const d = Math.abs(i-focusIdx);
+ if(d <= WIN) ensureBoardTexture(boards[i]);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // LAYOUT helpers
+ // -------------------------------------------------------------------------
+ function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+ // -------------------------------------------------------------------------
+ // INTERACTION
+ // -------------------------------------------------------------------------
+ let pan = 0, targetFocus = 0;
+ const ray = new THREE.Raycaster();
+ const ndc = new THREE.Vector2();
+
+ function setFocus(i){
+ targetFocus = clamp(i, 0, boards.length-1);
+ focusIdx = Math.round(targetFocus);
+ refreshTextureWindow();
+ showCaption(boards[focusIdx]);
+ }
+
+ let dragging=false, dragX=0, focusStart=0, moved=0;
+ renderer.domElement.addEventListener("pointerdown",(e)=>{
+ dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+ renderer.domElement.setPointerCapture(e.pointerId);
+ });
+ renderer.domElement.addEventListener("pointermove",(e)=>{
+ if(!dragging) return;
+ const dx = e.clientX - dragX; moved += Math.abs(dx);
+ targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+ focusIdx = Math.round(targetFocus);
+ });
+ renderer.domElement.addEventListener("pointerup",(e)=>{
+ dragging=false;
+ if(moved < 6){ tryPick(e); }
+ else { setFocus(Math.round(targetFocus)); }
+ });
+ window.addEventListener("wheel",(e)=>{
+ e.preventDefault();
+ const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+ setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+ }, { passive:false });
+
+ function tryPick(e){
+ const r = renderer.domElement.getBoundingClientRect();
+ ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+ ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+ ray.setFromCamera(ndc, camera);
+ const meshes = boards.map(b=>b.face);
+ const hits = ray.intersectObjects(meshes, false);
+ if(hits.length){
+ const idx = hits[0].object.userData.idx;
+ if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+ else setFocus(idx);
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // CAPTION
+ // -------------------------------------------------------------------------
+ const capEl = document.getElementById("caption");
+ function showCaption(b){
+ if(!b || !b.data){ capEl.classList.remove("show"); return; }
+ const d = b.data;
+ document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+ document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+ const bits = [];
+ if(d.width) bits.push(`${d.width}" wide`);
+ if(d.repeat) bits.push(`${d.repeat}" repeat`);
+ if(d.match_type) bits.push(d.match_type);
+ document.getElementById("cap_spec").textContent = bits.join(" · ") || "China Seas";
+ const cta = document.getElementById("cap_cta");
+ if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+ else cta.style.display="none";
+ capEl.classList.add("show");
+ }
+
+ // -------------------------------------------------------------------------
+ // RESIZE
+ // -------------------------------------------------------------------------
+ window.addEventListener("resize", ()=>{
+ camera.aspect = W()/H(); camera.updateProjectionMatrix();
+ renderer.setSize(W(), H());
+ });
+
+ // -------------------------------------------------------------------------
+ // FPS readout + pause on hidden
+ // -------------------------------------------------------------------------
+ const fpsEl = document.getElementById("fps");
+ let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+ fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+ let running = true;
+ document.addEventListener("visibilitychange", ()=>{
+ running = !document.hidden;
+ if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+ });
+
+ // -------------------------------------------------------------------------
+ // RENDER LOOP
+ // -------------------------------------------------------------------------
+ let t0 = performance.now();
+ function tick(now){
+ if(!running) return;
+ const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+ pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+ const eased = pan;
+
+ for(let i=0;i<boards.length;i++){
+ const b = boards[i];
+ const rel = i - eased;
+ const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+ const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+ b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+ b.group.rotation.y = b.swivel;
+
+ const slotX = rel * PITCH;
+ const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+ const targetZ = arcZ(slotX) + openAmt*1.55;
+
+ b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+ b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+ b.group.position.y = PLINTH_TOP; // standing on the plinth
+
+ // Brutalist read = light driven by the room (hard key + AO), NOT a per-board
+ // brightness fade. We keep neighbours fully material; only a mild recede via
+ // fog + the raking shadow. Featured board glides forward into the shaft.
+ const farOff = Math.abs(rel) > 8;
+ b.group.visible = !farOff;
+ }
+
+ renderer.render(scene, camera);
+
+ // fps
+ frames++;
+ if(now - fpsT0 >= 500){
+ fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+ if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+ window.__BC_FPS = fpsVal;
+ }
+ requestAnimationFrame(tick);
+ }
+
+ // -------------------------------------------------------------------------
+ // BOOT
+ // -------------------------------------------------------------------------
+ let HAS_PRODUCTS = false;
+ async function start(){
+ try {
+ const res = await fetch(API);
+ const json = await res.json();
+ products = (json.products||[]).slice(0, N_TARGET);
+ } catch(e){
+ bootlbl.textContent = "Could not reach the catalog.";
+ console.error("fetch failed", e);
+ return;
+ }
+ if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+ HAS_PRODUCTS = true;
+
+ for(let i=0;i<products.length;i++){
+ const b = buildBoard(i);
+ b.data = products[i];
+ b.group.position.set(i*PITCH, PLINTH_TOP, BANK_Z);
+ boards.push(b);
+ }
+ bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+ focusIdx = Math.min(2, boards.length-1);
+ pan = targetFocus = focusIdx;
+ refreshTextureWindow();
+
+ const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+ await loadTexture(featUrl).catch(()=>null);
+ ensureBoardTexture(boards[focusIdx]);
+ showCaption(boards[focusIdx]);
+
+ window.__BC__ = {
+ boards: ()=>boards.length,
+ textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+ focus: ()=>focusIdx,
+ fps: ()=>window.__BC_FPS||0,
+ ready: true
+ };
+
+ finishBoot();
+ requestAnimationFrame(tick);
+ }
+
+ function finishBoot(){
+ const boot = document.getElementById("boot");
+ boot.style.opacity = "0";
+ setTimeout(()=>boot.remove(), 950);
+ const hint = document.getElementById("hint");
+ setTimeout(()=>{ hint.style.opacity="0"; }, 6000);
+ }
+
+ start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V15","label":"V15 — Brutalist Concrete","elements":[
+ {"n":1,"label":"Board-formed concrete hall — procedural normal/roughness, wood-grain form lines"},
+ {"n":2,"label":"One hard directional key light, crisp raking shadows"},
+ {"n":3,"label":"Strong baked ambient occlusion in the recesses + form-tie holes"},
+ {"n":4,"label":"Monochrome grey/charcoal restraint — the pattern is the only colour"},
+ {"n":5,"label":"Thin steel-framed boards swivel open to a clean full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v17-tatami.html b/public/proto/v17-tatami.html
new file mode 100644
index 0000000..c361f5d
--- /dev/null
+++ b/public/proto/v17-tatami.html
@@ -0,0 +1,723 @@
+<!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>
← fad82ea Register V12 Gallery Noir + V16 Art-Deco Salon + V21 Garcia
·
back to Quadrille Showroom
·
High-end versions batch 2: V13 Atrium Daylight + V14 Velvet c4480af →